pluginify 0.0.0.post1.dev0__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,925 @@
1
+ # # # This source code is subject to the license referenced at
2
+ # # # https://github.com/NRLMMD-GEOIPS.
3
+
4
+ """PluginRegistry class to interface with the JSON plugin registries.
5
+
6
+ The "pluginify create" utility generates a JSON file at the top
7
+ level of every plugin package under a given namespace
8
+ (default='pluginify.plugin_packages') with a complete list of all plugins with the
9
+ associated metadata (everything except the actual contents of the plugin itself).
10
+
11
+ Once all of the registered_plugins.json files have been generated via
12
+ pluginify create, this class uses those registries to quickly
13
+ identify and open plugins as required. Previously the individual
14
+ interface classes would open all plugins every time one was required,
15
+ so moving this process into a single PluginRegistry object allows us to
16
+ more effectively cache plugins across all interfaces, and avoid reading
17
+ in all plugins multiple times.
18
+ """
19
+
20
+ from importlib import import_module, util, metadata, resources
21
+ import json
22
+ import logging
23
+ import os
24
+ from pathlib import Path
25
+ from types import SimpleNamespace
26
+
27
+ from lexeme_type.lexeme import Lexeme
28
+ from pydantic import BaseModel
29
+ import yaml
30
+
31
+ from pluginify.config import NAMESPACE, REBUILD_REGISTRIES, get_registry_cache_dir
32
+ from pluginify.create_plugin_registries import create_plugin_registries
33
+ from pluginify.errors import PluginError, PluginRegistryError
34
+ from pluginify.utils import merge_nested_dicts
35
+
36
+ LOG = logging.getLogger(__name__)
37
+
38
+
39
+ class PluginRegistry:
40
+ """Plugin Registry class definition.
41
+
42
+ Represents all of the plugins found in all of the available plugin packages of a
43
+ given namespace (default='pluginify.plugin_packages').
44
+
45
+ This class will load a plugin when requested, rather than loading all plugins when
46
+ pluginify is instantiated.
47
+ """
48
+
49
+ def __init__(self, namespace, _test_registry_files=[]):
50
+ """Initialize the plugin registry for the namespace provided.
51
+
52
+ Where namespace is the group of plugin packages used to create the plugin
53
+ registry.
54
+ """
55
+ self.namespace = namespace
56
+ # Use this for unit testing
57
+ if _test_registry_files:
58
+ self.registry_files = _test_registry_files
59
+ self._is_test = True
60
+ # Use this for normal operation and collect the registry files
61
+ else:
62
+ self._is_test = False
63
+ self.registry_files = self._find_registry_files(self.namespace)
64
+
65
+ @property
66
+ def registered_plugins(self):
67
+ """Dictionary of every plugin's metadata found within self.namespace.
68
+
69
+ self.namespace usually should correspond to 'pluginify.plugin_packages' unless
70
+ you're creating registries for plugins that exist outside this namespace.
71
+ """
72
+ if not hasattr(self, "_registered_plugins"):
73
+ self._set_class_properties()
74
+ return self._registered_plugins
75
+
76
+ @registered_plugins.setter
77
+ def registered_plugins(self, new_value):
78
+ """Set the registered_plugins class attribute.
79
+
80
+ See the registered_plugins property for more information.
81
+ """
82
+ self._registered_plugins = new_value
83
+
84
+ @property
85
+ def interface_mapping(self):
86
+ """Dictionary of interface types and interfaces of that type.
87
+
88
+ pluginify has three types of interfaces, though only two are commonly used
89
+ (yaml_based, module_based). This dictionary has top level keys of all interface
90
+ types, with their values being the list of unique interfaces that inherit that
91
+ type.
92
+ """
93
+ if not hasattr(self, "_interface_mapping"):
94
+ self._set_class_properties()
95
+ return self._interface_mapping
96
+
97
+ @interface_mapping.setter
98
+ def interface_mapping(self, new_value):
99
+ """Set the interface_mapping class attribute.
100
+
101
+ See the interface_mapping property for more information.
102
+ """
103
+ self._registered_plugins = new_value
104
+
105
+ @property
106
+ def registered_yaml_based_plugins(self):
107
+ """A dictionary of registered YAML-based plugins."""
108
+ if not hasattr(self, "_registered_plugins"):
109
+ self._set_class_properties()
110
+ return self._registered_plugins["yaml_based"]
111
+
112
+ @property
113
+ def registered_class_based_plugins(self):
114
+ """A dictionary of registered class-based plugins."""
115
+ if not hasattr(self, "_registered_plugins"):
116
+ self._set_class_properties()
117
+ return self._registered_plugins["class_based"]
118
+
119
+ def _set_class_properties(
120
+ self,
121
+ force_reset=False,
122
+ rebuild_registries_override=None,
123
+ ):
124
+ """Find all plugins in registered plugin packages.
125
+
126
+ Traverse the ``registered_plugins.json`` of each registered plugin package under
127
+ self.namespace and add their entries to the _registered_plugins dictionary
128
+ attribute.
129
+
130
+ Parameters
131
+ ----------
132
+ force_reset: bool, default=False
133
+ - Whether or not we want to force the plugin registry to recreate its
134
+ 'registered_plugins' attribute. This essentially forces a re-read of all
135
+ of the registered_plugins.json files and recomputes the master dictionary.
136
+ - Useful when we have rebuilt the registry files during runtime.
137
+ rebuild_registries_override: bool, default=None
138
+ - Whether or not we want to override the value of REBUILD_REGISTIRES set in
139
+ the config file for this package to a different boolean value. If None,
140
+ use the default value set in the config file.
141
+ - Used for unit tests.
142
+ """
143
+ # Load the registries here and return them as a dictionary
144
+ if not hasattr(self, "_registered_plugins") or force_reset:
145
+
146
+ # Complete dictionary of all available plugins found in every plugin package
147
+ self._registered_plugins = {}
148
+ # A mapping of interfaces to plugin_types. Ie:
149
+ # {
150
+ # "yaml_based": [products, sectors, ...],
151
+ # "module_based": [algorithms, readers, ...],
152
+ # "text_based": [tpw_cimss, ...]
153
+ # }
154
+ self._interface_mapping = {}
155
+ for reg_path in self.registry_files:
156
+ try:
157
+ registry = self._load_registry(reg_path)
158
+ except FileNotFoundError as e:
159
+ if REBUILD_REGISTRIES and rebuild_registries_override in [
160
+ None,
161
+ True,
162
+ ]:
163
+ # This will be hit if we have this configuration variable set to
164
+ # True
165
+ LOG.warning(
166
+ f"Plugin registry {reg_path} does not exist, "
167
+ "please run 'pluginify create'"
168
+ )
169
+ # We attempt to create plugin registries under self.namespace
170
+ # if one or more plugin packages' registry file is missing and
171
+ # the REBUILD_REGISTRIES configuration variable is set to true.
172
+ # This should not be hit twice.
173
+
174
+ # Create plugin registries
175
+ self.create_registries()
176
+ registry = self._load_registry(reg_path)
177
+ else:
178
+ raise FileNotFoundError(
179
+ f"Plugin registry {reg_path} does not exist and "
180
+ "PLUGINIFY_REBUILD_REGISTRIES isn't set to True. To "
181
+ "manually create these files, run 'pluginify create'."
182
+ ) from e
183
+ return_tuple = self._parse_registry(
184
+ self.interface_mapping, self.registered_plugins, registry
185
+ )
186
+ self.interface_mapping = return_tuple.interface_mapping
187
+ self.registered_plugins = return_tuple.registered_plugins
188
+ # Let's test this separately, not at runtime (see validate_all_registries).
189
+ # Assume it was tested up front, and no longer needs testing at
190
+ # runtime, so we don't fail catastrophically for a single bad
191
+ # plugin that may not even be used.
192
+ # if not self._is_test:
193
+ # self.validate_registry(
194
+ # self._registered_plugins,
195
+ # "all_registered_plugins",
196
+ # )
197
+
198
+ @staticmethod
199
+ def _find_registry_files(namespace):
200
+ """Locate all plugin registry files found under 'namespace'.
201
+
202
+ Parameters
203
+ ----------
204
+ namespace: str
205
+ - The namespace in which plugin packages are registered to. Usually, this
206
+ will be the 'pluginify.plugin_package' namespace, however this can be
207
+ changed by providing a different top-level namespace to the PluginRegistry
208
+ class.
209
+
210
+ Returns
211
+ -------
212
+ registry_files: list[str]
213
+ - A list of filepaths corresponding to expected registered_plugins.json
214
+ files for all plugin packages found under namespace.
215
+
216
+ Raises
217
+ ------
218
+ PluginRegistryError:
219
+ - Occurs if a package is potentially missing an __init__.py file.
220
+ """
221
+ registry_files = [] # Collect the paths to the registry files here
222
+ for pkg in metadata.entry_points(group=namespace):
223
+ write_dir = get_registry_cache_dir(namespace, pkg.value)
224
+ try:
225
+ registry_files.append(str(write_dir / "registered_plugins.json"))
226
+ except TypeError as e:
227
+ raise PluginRegistryError(
228
+ f"resources.files('{pkg.value}') failed\n"
229
+ f"pkg {pkg}\n"
230
+ "Potentially missing __init__.py file? Try:\n"
231
+ f" touch {pkg.value}/__init__.py\n"
232
+ "and try again.\n"
233
+ "Note you will need to add a docstring to "
234
+ f"{pkg.value}/__init__.py in order for all tests to pass"
235
+ ) from e
236
+ return registry_files
237
+
238
+ @staticmethod
239
+ def _load_registry(reg_path):
240
+ """Load the plugin registry found at 'reg_path'.
241
+
242
+ All files provided to this function share the same namespace. Usually, this
243
+ will be the 'pluginify.plugin_package' namespace, however this can be changed by
244
+ providing a different top-level namespace to the PluginRegistry class.
245
+
246
+ Parameters
247
+ ----------
248
+ reg_path: str
249
+ - The absolute path to the plugin registry file for a certain plugin
250
+ package.
251
+
252
+ Returns
253
+ -------
254
+ registry: dict
255
+ - A dictionary representing the contents of a plugin registry file. Can
256
+ include top-level keys such as 'yaml_based' or 'module_based' each of
257
+ which is another dictionary containing interfaces of that type, which
258
+ are also dictionaries containing plugins that correspond to that
259
+ interface.
260
+
261
+ Raises
262
+ ------
263
+ FileNotFoundError:
264
+ - Raised if 'reg_path' doesn't exist.
265
+ """
266
+ # if this is a yaml file, this is used for testing
267
+ if Path(reg_path).suffix == ".yaml":
268
+ with open(reg_path, "r") as fo:
269
+ registry = yaml.safe_load(fo)
270
+ else:
271
+ with open(reg_path, "r") as fo:
272
+ registry = json.load(fo)
273
+
274
+ return registry
275
+
276
+ @staticmethod
277
+ def _parse_registry(interface_mapping, registered_plugins, registry):
278
+ """Parse all plugins found under a package's plugin registry.
279
+
280
+ Parameters
281
+ ----------
282
+ interface_mapping: dict
283
+ - Dictionary of interface types and interfaces of that type.
284
+ pluginify has three types of interfaces, though only two are commonly used
285
+ (yaml_based, class_based). This dictionary has top level keys of all
286
+ interface types, with their values being the list of unique interfaces
287
+ that inherit that type.
288
+ registered_plugins: dict
289
+ - A dictionary of every plugin's metadata found within the plugin_registry's
290
+ namespace.
291
+ registry: dict
292
+ - A dictionary representing the contents of a plugin registry file. Can
293
+ include top-level keys such as 'yaml_based' or 'class_based' each of
294
+ which is another dictionary containing interfaces of that type, which
295
+ are also dictionaries containing plugins that correspond to that
296
+ interface.
297
+
298
+ Returns
299
+ -------
300
+ return_tuple: SimpleNamespace
301
+ - A SimpleNamespace object containing updated values of the variables
302
+ ['interface_mapping', 'registered_plugins'] which are dictionaries. See
303
+ comments about those two variables in the parameters section above.
304
+ """
305
+ for plugin_type in registry:
306
+ if plugin_type not in registered_plugins:
307
+ registered_plugins[plugin_type] = {}
308
+ interface_mapping[plugin_type] = []
309
+
310
+ for interface in registry[plugin_type]:
311
+ interface_dict = registry[plugin_type][interface]
312
+
313
+ if interface not in registered_plugins[plugin_type]:
314
+ registered_plugins[plugin_type][interface] = interface_dict
315
+ interface_mapping[plugin_type].append(interface)
316
+ else:
317
+ merge_nested_dicts(
318
+ registered_plugins[plugin_type][interface],
319
+ interface_dict,
320
+ )
321
+
322
+ return_tuple = SimpleNamespace(
323
+ interface_mapping=interface_mapping, registered_plugins=registered_plugins
324
+ )
325
+ return return_tuple
326
+
327
+ def get_plugin_metadata(self, interface_obj, plugin_name):
328
+ """Retrieve a plugin's metadata.
329
+
330
+ Where the metadata of the plugin matches the plugin's corresponding entry in the
331
+ plugin registry.
332
+
333
+ Parameters
334
+ ----------
335
+ interface_obj: Interface Object
336
+ - The object representing the interface class requesting plugin metadata.
337
+ plugin_name: str or tuple(str)
338
+ - The name of the plugin whose metadata we want.
339
+
340
+ Returns
341
+ -------
342
+ metadata: dict
343
+ - A dictionary of metadata for the requested plugin.
344
+ """
345
+ interface_registry = self.registered_plugins.get(
346
+ interface_obj.interface_type, {}
347
+ ).get(interface_obj.name)
348
+
349
+ if interface_registry is None:
350
+ raise KeyError(
351
+ "Error: There is no interface in the plugin registry of type '"
352
+ f"{interface_obj.interface_type}' called '{interface_obj.name}'."
353
+ )
354
+
355
+ if isinstance(plugin_name, tuple):
356
+ # This occurs for product plugins: i.e. ('abi', 'Infrared')
357
+ metadata = interface_registry.get(plugin_name[0], {}).get(plugin_name[1])
358
+ elif isinstance(plugin_name, str):
359
+ metadata = interface_registry.get(plugin_name)
360
+ else:
361
+ raise TypeError(
362
+ f"Error: cannot search the plugin registry with the provided name = "
363
+ f"{plugin_name}. Please provide either a string or a tuple of strings."
364
+ )
365
+
366
+ if metadata is None:
367
+ raise PluginRegistryError(
368
+ f"Error: There is no associated plugin under interface "
369
+ f"'{interface_obj.name}' called '{plugin_name}'. If you're sure this "
370
+ "plugin exists, please run 'pluginify create'."
371
+ )
372
+
373
+ return metadata
374
+
375
+ def load_plugin(self, data: dict) -> BaseModel:
376
+ """
377
+ Dynamically load and validate pydantic models based on apiVersion and interface.
378
+
379
+ This method parses the `apiVersion` field from the input dictionary to
380
+ determine the package and pydantic model version to use. It then dynamically
381
+ imports the corresponding Pydantic model classes based on the `interface` field
382
+ to validate the input data.
383
+
384
+ Parameters
385
+ ----------
386
+ data : dict
387
+ Dictionary representing a plugin definition. Must include the `interface`
388
+ field. May optionally include `apiVersion`. If not present, "pluginify/v1"
389
+ is assumed.
390
+
391
+ Returns
392
+ -------
393
+ BaseModel
394
+ A validated Pydantic model instance.
395
+
396
+ Raises
397
+ ------
398
+ ValueError
399
+ If `apiVersion` is improperly formatted or if `interface` field is missing.
400
+ ImportError
401
+ If the specified module for the given model version cannot be imported.
402
+ """
403
+ api_version = data.get("apiVersion", "pluginify/v1")
404
+
405
+ # Split "package_name/model_version"
406
+ # Use package_name to select the appropriate package to search for the api.
407
+ try:
408
+ package_name, model_version = api_version.split("/")
409
+ except ValueError as e:
410
+ raise ValueError(
411
+ f"Invalid apiVersion format: {api_version}. "
412
+ f"Expected format: 'package_name/version'"
413
+ ) from e
414
+
415
+ interface = data.get("interface")
416
+ if not interface:
417
+ raise ValueError("Missing 'interface' field for plugin dispatch")
418
+
419
+ # Construct module path and import
420
+ try:
421
+ module = import_module(
422
+ f"{package_name}.pydantic_models.{model_version}.{interface}"
423
+ )
424
+ except ImportError as e:
425
+ raise ImportError(
426
+ f"Could not import models from '{api_version}': {e}"
427
+ ) from e
428
+
429
+ interface_base = str(Lexeme(interface).singular)
430
+ model_name = f"{interface_base.title().replace('_', '')}PluginModel"
431
+
432
+ try:
433
+ model_class = getattr(module, model_name)
434
+ print("model class \t", model_class)
435
+ except AttributeError as e:
436
+ raise ValueError(
437
+ f"Model '{model_name}' not found in '{api_version}'"
438
+ ) from e
439
+
440
+ return model_class.model_validate(data)
441
+
442
+ def get_yaml_plugin(self, interface_obj, name, rebuild_registries=None):
443
+ """Get a YAML plugin by its name.
444
+
445
+ Parameters
446
+ ----------
447
+ interface_obj: Interface Object
448
+ - The object representing the interface class requesting this yaml plugin.
449
+ name: str or tuple(str)
450
+ - The name of the yaml-based plugin. Either a single string or a tuple of
451
+ strings for product plugins.
452
+ rebuild_registries: bool (default=None)
453
+ - Whether or not to rebuild the registries if get_plugin fails. If set to
454
+ None, default to True. If specified, use the input value of
455
+ rebuild_registries, which should be a boolean value. If rebuild registries
456
+ is true and get_plugin fails, rebuild the plugin registry, call then call
457
+ get_plugin once more with rebuild_registries toggled off, so it only gets
458
+ rebuilt once.
459
+ """
460
+ try:
461
+ registered_yaml_plugins = self.registered_plugins["yaml_based"]
462
+ except KeyError:
463
+ # Very likely could occur if registries haven't been built yet
464
+ err_str = (
465
+ "No YAML-based plugins found. There likely have been no plugin "
466
+ "registries built yet. If automatic registry creation has been disabled"
467
+ ", please run 'pluginify create'."
468
+ )
469
+ self.retry_get_plugin(interface_obj, name, rebuild_registries, err_str)
470
+
471
+ if rebuild_registries is None:
472
+ rebuild_registries = interface_obj.rebuild_registries
473
+ elif not isinstance(rebuild_registries, bool):
474
+ raise TypeError(
475
+ "Error: Argument 'rebuild_registries' was specified but isn't a boolean"
476
+ f" value. Encountered this '{rebuild_registries}' instead."
477
+ )
478
+
479
+ interface_entry = registered_yaml_plugins[interface_obj.name]
480
+ # This occurs for product plugins
481
+ if isinstance(name, tuple):
482
+ source_name = name[0]
483
+ plg_name = name[1]
484
+ extra_info = f"under source_name '{source_name}', "
485
+ # This occurs for every other YAML plugin
486
+ else:
487
+ source_name = None
488
+ plg_name = name
489
+ extra_info = ""
490
+
491
+ if plg_name not in interface_entry.get(source_name, interface_entry):
492
+ err_str = (
493
+ f"Plugin '{plg_name}', {extra_info}"
494
+ f"from interface '{interface_obj.name}' "
495
+ f"appears to not exist."
496
+ f"\nCreate plugin, then call pluginify create."
497
+ )
498
+ return self.retry_get_plugin(
499
+ interface_obj, name, rebuild_registries, err_str
500
+ )
501
+
502
+ relpath = interface_entry.get(source_name, interface_entry)[plg_name]["relpath"]
503
+ package = interface_entry.get(source_name, interface_entry)[plg_name]["package"]
504
+ abspath = str(resources.files(package) / relpath)
505
+
506
+ # If abspath doesn't exist the registry is out of date with the actual
507
+ # contents of all, or a certain plugin package.
508
+ if not os.path.exists(abspath):
509
+ err_str = (
510
+ f"Products plugin source: '{source_name}', plugin: '{plg_name} "
511
+ f"exists in the registry but its corresponding file at '{abspath}' "
512
+ "cannot be found. Reinstall your package and re-run "
513
+ "'pluginify create'."
514
+ )
515
+ # This error should never occur, but we're adding error handling here
516
+ # just in case. The reason it will never occur is that, if the path
517
+ # to such plugin does not exist, when pluginify create is
518
+ # re-run that syncs up the path to the associated plugin. It cannot
519
+ # reach this point if the plugin name is invalid, so this point couldn't
520
+ # be hit twice
521
+ return self.retry_get_plugin(
522
+ interface_obj,
523
+ name,
524
+ rebuild_registries,
525
+ err_str,
526
+ PluginRegistryError,
527
+ )
528
+ with open(abspath, "r") as file:
529
+ documents = yaml.safe_load_all(file)
530
+ plugin_found = False
531
+ for plugin in documents:
532
+ # This occurs for product plugins
533
+ if source_name:
534
+ for product in plugin["spec"]["products"]:
535
+ if (
536
+ product["name"] == plg_name
537
+ and source_name in product["source_names"]
538
+ ):
539
+ plugin_found = True
540
+ plugin = product
541
+ break
542
+ # Every other type of YAML plugin
543
+ else:
544
+ if plugin["name"] == plg_name:
545
+ plugin_found = True
546
+
547
+ if plugin_found:
548
+ break
549
+
550
+ if not plugin_found:
551
+ err_str = (
552
+ f"Error: {interface_obj.name} YAML plugin under name '{name}' could"
553
+ " not be found. Please ensure this plugin exists, and if it does, "
554
+ "run 'pluginify create'."
555
+ )
556
+ return self.retry_get_plugin(
557
+ interface_obj, name, rebuild_registries, err_str
558
+ )
559
+ plugin["interface"] = interface_obj.name
560
+ plugin["package"] = package
561
+ plugin["abspath"] = abspath
562
+ plugin["relpath"] = relpath
563
+
564
+ if getattr(interface_obj, "use_pydantic", False):
565
+ return self.load_plugin(plugin).model_dump()
566
+ else:
567
+ validated = interface_obj.validator.validate(plugin)
568
+ return interface_obj._plugin_yaml_to_obj(name, validated)
569
+
570
+ def get_yaml_plugins(self, interface_obj):
571
+ """Retrieve all yaml plugin objects for this interface.
572
+
573
+ Parameters
574
+ ----------
575
+ interface_obj: Interface Object
576
+ - The object representing the interface class requesting all plugins.
577
+ """
578
+ plugins = []
579
+ registered_yaml_plugins = self.registered_yaml_based_plugins
580
+ if interface_obj.name not in registered_yaml_plugins:
581
+ LOG.debug("No plugins found for '%s' interface.", interface_obj.name)
582
+ return plugins
583
+ for name in registered_yaml_plugins[interface_obj.name].keys():
584
+ plugins.append(self.get_yaml_plugin(interface_obj, name))
585
+ return plugins
586
+
587
+ def get_class_plugin(self, interface_obj, name, rebuild_registries=None):
588
+ """Retrieve a class plugin from this interface by name.
589
+
590
+ In pluginify's current state, a class plugin can be a derived plugin object
591
+ (I.e. legacy module-based plugins) or true class-based plugins which are not
592
+ derived.
593
+
594
+ Parameters
595
+ ----------
596
+ interface_obj: Interface Object
597
+ - The object representing the interface class requesting this class plugin.
598
+ name: str
599
+ - The name the desired plugin.
600
+ rebuild_registries: bool (default=None)
601
+ - Whether or not to rebuild the registries if get_plugin fails. If set to
602
+ None, default to True. If specified, use the input value of
603
+ rebuild_registries, which should be a boolean value. If rebuild registries
604
+ is true and get_plugin fails, rebuild the plugin registry, call then call
605
+ get_plugin once more with rebuild_registries toggled off, so it only gets
606
+ rebuilt once.
607
+
608
+ Returns
609
+ -------
610
+ An object of type ``<interface>Plugin`` where ``<interface>`` is the name of
611
+ this interface.
612
+
613
+ Raises
614
+ ------
615
+ PluginError
616
+ If the specified plugin isn't found within the interface.
617
+ """
618
+ try:
619
+ registered_class_plugins = self.registered_plugins["class_based"]
620
+ except KeyError:
621
+ # Very likely could occur if registries haven't been built yet
622
+ err_str = f"No plugins found for '{interface_obj.name}' interface."
623
+ self.retry_get_plugin(interface_obj, name, rebuild_registries, err_str)
624
+
625
+ if rebuild_registries is None:
626
+ rebuild_registries = interface_obj.rebuild_registries
627
+ elif not isinstance(rebuild_registries, bool):
628
+ raise TypeError(
629
+ "Error: Argument 'rebuild_registries' was specified but isn't a boolean"
630
+ f" value. Encountered this '{rebuild_registries}' instead."
631
+ )
632
+
633
+ if name not in registered_class_plugins[interface_obj.name]:
634
+ err_str = (
635
+ f"Plugin '{name}', "
636
+ f"from interface '{interface_obj.name}' "
637
+ f"appears to not exist."
638
+ f"\nCreate plugin, then call pluginify create."
639
+ )
640
+ return self.retry_get_plugin(
641
+ interface_obj, name, rebuild_registries, err_str
642
+ )
643
+
644
+ package = registered_class_plugins[interface_obj.name][name]["package"]
645
+ relpath = registered_class_plugins[interface_obj.name][name]["relpath"]
646
+ module_path = os.path.splitext(relpath.replace("/", "."))[0]
647
+ module_path = f"{package}.{module_path}"
648
+ abspath = resources.files(package) / relpath
649
+ # If abspath doesn't exist the registry is out of date with the actual contents
650
+ # of all, or a certain plugin package.
651
+ if not os.path.exists(abspath):
652
+ err_str = (
653
+ f"Plugin '{name}' exists in the registry but its corresponding file at "
654
+ f"'{abspath}' cannot be found. Reinstall your package and re-run "
655
+ "'pluginify create'."
656
+ )
657
+ # This error should never occur, but we're adding error handling here
658
+ # just in case. The reason it will never occur is that, if the path
659
+ # to such plugin does not exist, when pluginify create is
660
+ # re-run that syncs up the path to the associated plugin. It cannot reach
661
+ # this point if the plugin name is invalid, so this point couldn't be hit
662
+ # twice
663
+ return self.retry_get_plugin(
664
+ interface_obj, name, rebuild_registries, err_str, PluginRegistryError
665
+ )
666
+ spec = util.spec_from_file_location(module_path, abspath)
667
+ module = util.module_from_spec(spec)
668
+ spec.loader.exec_module(module)
669
+
670
+ if registered_class_plugins[interface_obj.name][name]["is_derived_from_module"]:
671
+ # If the requested plugin was derived from a module, use the class factory
672
+ # code.
673
+ plugin = interface_obj._plugin_module_to_obj(name, module)
674
+ else:
675
+ # Otherwise, just grab the associated plugin class and instantiate it.
676
+ PLUGIN_CLASS = getattr(module, "PLUGIN_CLASS")
677
+ plugin = PLUGIN_CLASS(module)
678
+ # Set attributes required to test the interface of a given plugin
679
+ plugin.id = plugin.name
680
+ plugin.docstring = plugin.__doc__
681
+ # This function might raise a PluginError with pertinent information on why
682
+ # the plugin is invalid. Don't catch that, we want the error to be raised.
683
+ interface_obj.plugin_is_valid(plugin)
684
+ return plugin
685
+
686
+ def get_class_plugins(self, interface_obj):
687
+ """Retrieve all class plugins for this interface.
688
+
689
+ In pluginify's current state, this will grab all true class-based plugins and
690
+ those plugins who were derived into a plugin object from a legacy module-based
691
+ plugin.
692
+
693
+ Parameters
694
+ ----------
695
+ interface_obj: Interface Object
696
+ - The object representing the interface class requesting all plugins.
697
+ """
698
+ plugins = []
699
+ # All plugin interfaces are explicitly imported in
700
+ # pluginify/interfaces/__init__.py
701
+ # self.name comes explicitly from one of the interfaces that are
702
+ # found by default on pluginify.interfaces.
703
+ # If there is a defined interface with no plugins available in the current
704
+ # pluginify installation (in any currently installed plugin package),
705
+ # then there will NOT be an entry within registered plugins
706
+ # for that interface, and a KeyError will be raised in the for loop
707
+ # below.
708
+ # Check if the current interface (self.name) is found in the
709
+ # registered_plugins dictionary - if it is not, that means there
710
+ # are no plugins for that interface, so return an empty list.
711
+
712
+ registered_class_plugins = self.registered_plugins["class_based"]
713
+ if interface_obj.name not in registered_class_plugins:
714
+ LOG.debug("No plugins found for '%s' interface.", interface_obj.name)
715
+ return plugins
716
+
717
+ for plugin_name in registered_class_plugins[interface_obj.name]:
718
+ try:
719
+ plugins.append(self.get_class_plugin(interface_obj, plugin_name))
720
+ except AttributeError as resp:
721
+ raise PluginError(
722
+ f"Plugin '{plugin_name}' is missing the 'name' attribute, "
723
+ f"\nfrom package '{plugin_name['package']},' "
724
+ f"'{plugin_name['relpath']}' module,"
725
+ ) from resp
726
+
727
+ return plugins
728
+
729
+ def retry_get_plugin(
730
+ self, interface_obj, name, rebuild_registries, err_str, err_type=PluginError
731
+ ):
732
+ """Rerun self.get_plugin, but call 'pluginify create' beforehand.
733
+
734
+ By running 'pluginify create', we automate the registration of plugins under a
735
+ given namespace (default='pluginify.plugin_packages'). If the plugin
736
+ persists not to be found, then we'll raise an appropriate PluginError as denoted
737
+ by 'err_str'.
738
+
739
+ Parameters
740
+ ----------
741
+ interface_obj: Interface Object
742
+ - The object representing the interface class requesting this plugin.
743
+ name: str or tuple(str)
744
+ - The name of the yaml plugin. Either a single string or a tuple of strings
745
+ for product plugins.
746
+ rebuild_registries: bool
747
+ - Whether or not to rebuild the registries if get_plugin fails. If set to
748
+ true and get_plugin fails, rebuild the plugin registry, call then call
749
+ get_plugin once more with rebuild_registries toggled off, so it only gets
750
+ rebuilt once.
751
+ err_str: string
752
+ - The error to be reported.
753
+ err_type: Exception-based Class
754
+ - The class of exception to be raised.
755
+ """
756
+ if rebuild_registries:
757
+ LOG.info(
758
+ "Running 'pluginify create' due to a missing plugin "
759
+ f"located under interface: '{interface_obj.name}', plugin_name: "
760
+ f"'{name}'."
761
+ )
762
+ self.create_registries()
763
+ # Force a rebuild of the master 'registered_plugins' dictionary.
764
+ self._set_class_properties(force_reset=True)
765
+ # This is done as some interface classes override 'get_plugin' with
766
+ # additional parameters it it's call signature. We only want to call
767
+ # BaseYamlInterface or BaseModuleInterface 'get_plugin', then return such
768
+ # information to the child class which overrode 'get_plugin'. Implementing
769
+ # it this way ensures that will happen.
770
+ base_interface_class = interface_obj.__class__.__base__()
771
+ base_interface_class.name = interface_obj.name
772
+ return base_interface_class.get_plugin(name, rebuild_registries=False)
773
+ else:
774
+ raise err_type(err_str)
775
+
776
+ def create_registries(self, packages=None, save_type="json") -> None:
777
+ """Create one or more plugin registry files.
778
+
779
+ By default, this command will create all plugin registry files for all
780
+ installed plugin packages under a given namespace
781
+ (default=pluginify.plugin_packages). If packages is provided via the argument
782
+ above, create registry files associated with each of those packages.
783
+
784
+ Parameters
785
+ ----------
786
+ packages: list[str], default=None
787
+ - A list of plugin package names corresponding to a namespace whose
788
+ registries we want to create.
789
+ save_type: str, default="json"
790
+ - Format to write registries to. This will also be the file extension. Valid
791
+ options are either 'json' or 'yaml'.
792
+
793
+ Raises
794
+ ------
795
+ ValueError:
796
+ - Raised if 'save_type' is provided but is not one of ['json', 'yaml']
797
+ TypeError:
798
+ - Raised if packages is provided and is not a list of strings.
799
+ PluginRegistryError:
800
+ - Raised if one or more of the packages provided is not a valid
801
+ plugin package under the provided namespace, or if the associated
802
+ namespace provided is not a valid namespace.
803
+ """
804
+ if save_type not in ["json", "yaml"]:
805
+ raise ValueError(
806
+ "Error: 'save_type' kwarg was provided but is not one of "
807
+ "['json', 'yaml']."
808
+ )
809
+ plugin_packages = metadata.entry_points(group=self.namespace)
810
+ if len(plugin_packages) == 0:
811
+ raise PluginRegistryError(
812
+ f"Error: namespace '{self.namespace}' does not exist or has no plugin "
813
+ "packages associated with it. Please resolve this before continuing."
814
+ )
815
+ if packages:
816
+ self._validate_packages_input(packages)
817
+ filtered_packages = []
818
+ for pkg in plugin_packages:
819
+ if pkg.value in packages:
820
+ filtered_packages.append(pkg)
821
+ plugin_packages = filtered_packages
822
+
823
+ LOG.debug(plugin_packages)
824
+ create_plugin_registries(plugin_packages, save_type, self.namespace)
825
+
826
+ def delete_registries(self, packages=None) -> None:
827
+ """Delete one or more plugin registry files.
828
+
829
+ By default, this command will delete all plugin registry files found in all
830
+ installed plugin packages packages (default=pluginify.plugin_packages). If
831
+ packages is provided via the argument above, delete the registry file(s)
832
+ associated with each of those packages.
833
+
834
+ Parameters
835
+ ----------
836
+ packages: list[str], default=None
837
+ - A list of plugin package names corresponding to a namespace whose
838
+ registries we want to delete.
839
+
840
+ Raises
841
+ ------
842
+ TypeError:
843
+ - Raised if packages is provided and is not a list of strings.
844
+ FileNotFoundError:
845
+ - Raised if a registry file could not be found in one or more plugin
846
+ packages under self.namespace.
847
+ PluginRegistryError:
848
+ - Raised if one or more of the packages provided is not a valid
849
+ plugin package under the provided namespace, or if the associated
850
+ namespace provided is not a valid namespace.
851
+ """
852
+ if packages:
853
+ self._validate_packages_input(packages)
854
+ else:
855
+ packages = [ep.value for ep in metadata.entry_points(group=self.namespace)]
856
+ if len(packages) == 0:
857
+ raise PluginRegistryError(
858
+ f"Error: namespace '{self.namespace}' does not exist or has no "
859
+ "plugin packages associated with it. Please resolve this before "
860
+ "continuing."
861
+ )
862
+
863
+ for pkg in packages:
864
+ write_dir = get_registry_cache_dir(self.namespace, pkg)
865
+ # If packages is provided and the current package is in that list, or if
866
+ # we're using the default value for that argument, delete the associated
867
+ # registry
868
+ if (packages and pkg in packages) or packages is None:
869
+ yaml_plug_path = str(write_dir / "registered_plugins.yaml")
870
+ json_plug_path = str(write_dir / "registered_plugins.json")
871
+ for path in [json_plug_path, yaml_plug_path]:
872
+ # Attempt to remove the files, pass silently if they don't exist.
873
+ try:
874
+ os.remove(path)
875
+ print(f"Removed registry file @ {path} from package '{pkg}'.")
876
+ except FileNotFoundError:
877
+ print(
878
+ f"Unable to remove registry file @ {path} from package "
879
+ f"'{pkg}'."
880
+ )
881
+ continue
882
+
883
+ def _validate_packages_input(self, packages):
884
+ """Validate that packages is a list of strings.
885
+
886
+ If not, then raise a TypeError indicating what was formatted incorrectly.
887
+
888
+ Parameters
889
+ ----------
890
+ packages: list[str], default=None
891
+ - A list of plugin package names corresponding to a namespace whose
892
+ registries we want to delete.
893
+
894
+ Raises
895
+ ------
896
+ TypeError:
897
+ - Raised if packages is provided and is not a list of strings.
898
+ PluginRegistryError:
899
+ - Raised if one or more of the packages provided is not a valid
900
+ plugin package under the provided namespace, or if the associated
901
+ namespace provided is not a valid namespace.
902
+ """
903
+ if not isinstance(packages, list):
904
+ raise TypeError(
905
+ "Error: 'packages' kwarg was provided but it is not a list object."
906
+ )
907
+ elif any([not isinstance(pkg, str) for pkg in packages]):
908
+ raise TypeError(
909
+ "Error: 'packages' kwarg was provided but one or more of it's "
910
+ "items were not a string."
911
+ )
912
+ # If any package name in 'packages' could not be associated with an entry point
913
+ # found in pypi's package registry associated with 'self.namespace', raise a
914
+ # plugin registry error.
915
+ found_packages = [
916
+ pkg.value for pkg in metadata.entry_points(group=self.namespace)
917
+ ]
918
+ if any([pkg_name not in found_packages for pkg_name in packages]):
919
+ raise PluginRegistryError(
920
+ "Error: either the namespace provided was invalid or one or more of "
921
+ "the packages whose registry you requested to delete does not exist."
922
+ )
923
+
924
+
925
+ plugin_registry = PluginRegistry(NAMESPACE)