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,1037 @@
1
+ # # # This source code is subject to the license referenced at
2
+ # # # https://github.com/NRLMMD-GEOIPS.
3
+
4
+ """Generates all available plugins from installed packages under a given namespace.
5
+
6
+ After all plugins have been generated, they are written to a registered_plugins.json
7
+ file which contains a dictionary of all the registered plugins across all plugin
8
+ repositories under a given namespace.
9
+
10
+ Run 'pluginify create' to produce registered_plugins.json for
11
+ EVERY currently installed plugin package under a given namespace
12
+ (default='pluginify.plugin_packages'). A separate registered_plugins.json is created at
13
+ the top level package directory for each plugin package under that namespace.
14
+ """
15
+
16
+ import logging
17
+ from importlib import metadata, resources, util, import_module
18
+ from inspect import signature
19
+ import json
20
+ from os import makedirs, remove
21
+ from os.path import (
22
+ basename,
23
+ dirname,
24
+ exists,
25
+ join as os_join,
26
+ relpath as os_relpath,
27
+ split,
28
+ splitext,
29
+ )
30
+ import re
31
+ import warnings
32
+
33
+ import yaml
34
+
35
+ from pluginify.config import get_registry_cache_dir
36
+ from pluginify.errors import PluginRegistryError
37
+
38
+ LOG = logging.getLogger(__name__)
39
+
40
+
41
+ def format_docstring(docstring, use_regex=True):
42
+ """Format the provided docstring placement in the plugin registry.
43
+
44
+ Found when using the CLI and inspecting the registry, some docstrings are formatted
45
+ in a hard to read manner and look pretty bad. This function will format these
46
+ docstrings to be easily readable, whether obtained via the CLI or manually inspected
47
+ in the plugin registry.
48
+
49
+ Parameters
50
+ ----------
51
+ docstring: str
52
+ - The docstring which we are going to format.
53
+ use_regex: bool, optional (default=False)
54
+ - Whether or not we want to apply regex formatting to the docstring. Usually
55
+ recommended as it will replace 'newline' chars but not purposeful
56
+ '.newline' strings.
57
+ """
58
+ if docstring:
59
+ if use_regex:
60
+ # Regex pattern for subbing out "\n" but not ".\n"
61
+ pattern = r"(?<!\.)\n"
62
+ docstring = re.sub(
63
+ pattern,
64
+ " ",
65
+ docstring.strip().replace("\n\n", "\n"),
66
+ )
67
+ else:
68
+ docstring = docstring.strip().replace("\n\n", "\n")
69
+ return docstring
70
+
71
+
72
+ def remove_registries(namespace, plugin_packages):
73
+ """Remove all plugin registries if a PluginRegistryError is raised.
74
+
75
+ Parameters
76
+ ----------
77
+ namespace: str
78
+ Namespace that your plugin packages fall under. The argument parser defaults
79
+ this value to 'pluginify.plugin_packages', but a user can create separate
80
+ namespaces if developing interfaces outside of pluginify.
81
+ plugin_packages: list[EntryPoints]
82
+ A list of EntryPoints pointing to each installed package --> ie.
83
+ [EntryPoint(name='pluginify', value='pluginify',
84
+ group='pluginify.plugin_packages'), ...]
85
+
86
+ Returns
87
+ -------
88
+ None
89
+ """
90
+ # Remove registries is called whenever an improperly formatted plugin or
91
+ # package is encountered. This is not called until all errors have been
92
+ # collected and reported, to facilitate rapidly identifying and resolving
93
+ # errors.
94
+ LOG.info(
95
+ "\n\n\n\nERROR: Removing registries due to improperly formatted plugins.\n"
96
+ "You must fix the error(s) shown below before pluginify can operate correctly."
97
+ "\nOnce fixed, please run 'pluginify create' to set up your plugins "
98
+ "appropriately\n\n\n"
99
+ )
100
+ # Remove registered_plugins.yaml and registered_plugins.json if they exist
101
+ # for each plugin package.
102
+ for pkg in plugin_packages:
103
+ write_dir = get_registry_cache_dir(namespace, pkg.value)
104
+ yaml_plug_path = str(write_dir / "registered_plugins.yaml")
105
+ json_plug_path = str(write_dir / "registered_plugins.json")
106
+ if exists(yaml_plug_path):
107
+ remove(yaml_plug_path)
108
+ if exists(json_plug_path):
109
+ remove(json_plug_path)
110
+
111
+
112
+ def registry_sanity_check(plugin_packages, save_type, namespace):
113
+ """Check that each plugin package registry has no duplicate lowest depth entries.
114
+
115
+ If it does, raise a `PluginRegistryError` for that specific package, then remove all
116
+ plugin registries from each package so the user must fix the error before
117
+ continuing. While this doesn't cause a normal error, duplicate plugins will be
118
+ overwritten by same-named plugin found in the last package-entrypoint.
119
+
120
+ Parameters
121
+ ----------
122
+ plugin_packages: list EntryPoints
123
+ A list of EntryPoints pointing to each installed package --> ie.
124
+ `[EntryPoint(name='pluginify', value='pluginify',
125
+ group='pluginify.plugin_packages')]`
126
+ save_type: str
127
+ The file format to save to `[json, yaml]`
128
+
129
+ Returns
130
+ -------
131
+ No returns
132
+
133
+ Exceptions
134
+ ----------
135
+ PluginRegistryError
136
+ If `error_message` has contents, then raise PluginRegistryError(error_message).
137
+ The `error_message` string will collect and report on all errors within
138
+ this function prior to raising the `PluginRegistryError` to facilitate rapidly
139
+ identifying and resolving errors throughout all plugin packages.
140
+ """
141
+ error_message = ""
142
+ # comp_pkg is the package being compared against. This package is compared
143
+ # against every other available package that is installed.
144
+ for comp_idx, comp_pkg in enumerate(plugin_packages):
145
+ # yaml output is used primarily for testing purposes (since it is more human
146
+ # readable than json), and json output is used for processing. Ensure we
147
+ # can load either option.
148
+ comp_write_dir = get_registry_cache_dir(namespace, comp_pkg.value)
149
+ if save_type == "yaml":
150
+ registry_fname = comp_write_dir / "registered_plugins.yaml"
151
+ with open(registry_fname, "r") as reg_file:
152
+ comp_registry = yaml.safe_load(reg_file)
153
+ else:
154
+ # json.load is much faster than yaml.safe_load
155
+ registry_fname = comp_write_dir / "registered_plugins.json"
156
+ with open(registry_fname, "r") as reg_file:
157
+ comp_registry = json.load(reg_file)
158
+ for pkg_idx, pkg in enumerate(plugin_packages):
159
+ # pkg is the package being compared against comp_pkg. For example, if
160
+ # comp_pkg was 'pluginify', then it would compare against all other packages
161
+ # registered under the pluginify.plugin_packages namespace.
162
+
163
+ # The if statement below checks the index of pkg_idx and comp_idx.
164
+ # If pkg_idk <= comp_idx, that means it's either the same package as
165
+ # comp_pkg, or that the comparison has already been performed.
166
+ if pkg_idx <= comp_idx:
167
+ continue
168
+
169
+ write_dir = get_registry_cache_dir(namespace, pkg.value)
170
+ # Track sets of plugins by plugin type
171
+ # (text, yaml_based, and class_based)
172
+ if save_type == "yaml":
173
+ registry_fname = write_dir / "registered_plugins.yaml"
174
+ with open(registry_fname, "r") as reg_file:
175
+ pkg_registry = yaml.safe_load(reg_file)
176
+ else:
177
+ registry_fname = write_dir / "registered_plugins.json"
178
+ with open(registry_fname, "r") as reg_file:
179
+ pkg_registry = json.load(reg_file)
180
+ for plugin_type in list(pkg_registry.keys()):
181
+ # check the pkg's registry for both yaml-based and class-based plugins
182
+ for interface in comp_registry[plugin_type]:
183
+ # check each interface of comp_pkg
184
+ # for each type of plugin (yaml/class)-based
185
+ if interface in pkg_registry[plugin_type]:
186
+ # if this interface is also in the pkg_registry, then get the
187
+ # dictionary of comp_plugins for that interface
188
+ comp_plugins = comp_registry[plugin_type][interface]
189
+ for plugin in comp_plugins:
190
+ # for each plugin in comp_plugins dict
191
+ if plugin in pkg_registry[plugin_type][interface]:
192
+ # if this plugin is also in the pkg_registry's
193
+ # corresponding interface, then retrieve that plugin
194
+ pkg_plugin = pkg_registry[plugin_type][interface][
195
+ plugin
196
+ ]
197
+ comp_plugin = comp_registry[plugin_type][interface][
198
+ plugin
199
+ ]
200
+ if "relpath" in pkg_plugin:
201
+ # If 'relpath' is found in the plugin, raise a
202
+ # PluginRegistryError, and remove the registries.
203
+ # We do this because 'family' is a top level
204
+ # attribute on all plugins, and means you're at
205
+ # the lowest depth of the Plugin entry, meaning
206
+ # there are two Plugins with Duplicate Names.
207
+ error_message += """Error with packages [{}, {}]:
208
+ You can't have two Plugins of the same
209
+ interface [{}] with the same
210
+ plugin name [{}]
211
+ pkg relpath: {}
212
+ comp relpath: {}""".format(
213
+ comp_pkg.value,
214
+ pkg.value,
215
+ interface,
216
+ plugin,
217
+ pkg_plugin["relpath"],
218
+ comp_plugin["relpath"],
219
+ )
220
+ else:
221
+ # If the statement above is false,
222
+ # that means the plugin
223
+ # we are dealing with is 'Product'-based.
224
+ # This means there are subplugins that we
225
+ # need to check against their defind
226
+ # source names. Grab the comparsion
227
+ # Product Plugin.
228
+ for sub_plg in comp_plugin:
229
+ # Loop through each sub-plugin of the
230
+ # comparison product plugin.
231
+ if sub_plg in pkg_plugin:
232
+ # If this sub-plugin is also in
233
+ # the package Product plugin,
234
+ # raise a PluginRegistryError
235
+ # and remove the registries.
236
+ pkg_relpath = "not defined"
237
+ if "relpath" in pkg_plugin:
238
+ pkg_relpath = pkg_plugin["relpath"]
239
+ subplg_relpath = "not defined"
240
+ if "relpath" in sub_plg:
241
+ subplg_relpath = sub_plg["relpath"]
242
+ error_message += """
243
+ Error with packages:
244
+ [{}, {}]:
245
+ You can't have two products of the same
246
+ interface [{}] with the same
247
+ plugin name [{}] found under
248
+ sub_plg name [{}]
249
+ relpath: {}
250
+ sub_plg relpath: {}\n""".format(
251
+ comp_pkg.value,
252
+ pkg.value,
253
+ interface,
254
+ sub_plg,
255
+ plugin,
256
+ pkg_relpath,
257
+ subplg_relpath,
258
+ )
259
+ if error_message:
260
+ remove_registries(namespace, plugin_packages)
261
+ raise PluginRegistryError(error_message)
262
+
263
+
264
+ def check_plugin_exists(package, plugins, interface_name, plugin_name, relpath):
265
+ """Check if plugin already exists. If it does raise a `PluginRegistryError`.
266
+
267
+ Note this only checks for duplicate plugins within a single plugin package.
268
+ The `registry_sanity_check` function is used after all plugins have been
269
+ loaded to identify duplicate plugins across different plugin packages.
270
+
271
+ Parameters
272
+ ----------
273
+ package: str
274
+ The package being tested against
275
+ plugins: dict
276
+ A dictionary object of all installed plugins in the current plugin package.
277
+ interface_name: str
278
+ A string representing the interface being checked against
279
+ plugin_name: str
280
+ A string representing the name of the plugin within the interface
281
+
282
+ Returns
283
+ -------
284
+ error_message : str
285
+ Empty string if no error, appropriate informative error message if
286
+ duplicate plugin found in current plugin package.
287
+ """
288
+ # Check if the passed in plugin_name is already in the current plugin
289
+ # package dictionary for this interface.
290
+ if plugin_name in plugins[interface_name]:
291
+ return f"""\nError in package [{package}]:
292
+ You can not have two Plugins of the same
293
+ interface [{interface_name}] with the same
294
+ name [{plugin_name}] found at
295
+ relpath [{plugins[interface_name][plugin_name]["relpath"]}] and
296
+ relpath [{relpath}]
297
+ """
298
+ return ""
299
+
300
+
301
+ def write_registered_plugins(pkg_dir, plugins, save_type):
302
+ """Write dictionary of all plugins available from installed packages.
303
+
304
+ Parameters
305
+ ----------
306
+ pkg_dir: str
307
+ Path in which to write registered_plugins
308
+ plugins: dict
309
+ A dictionary object of all installed package plugins
310
+ save_type: str
311
+ The file format to save to [json, yaml]
312
+
313
+ Returns
314
+ -------
315
+ No returns, file written to `pkg_dir`
316
+ """
317
+ if save_type == "yaml":
318
+ reg_plug_abspath = os_join(pkg_dir, "registered_plugins.yaml")
319
+ with open(reg_plug_abspath, "w") as plugin_registry:
320
+ LOG.info("Writing %s", reg_plug_abspath)
321
+ yaml.safe_dump(plugins, plugin_registry, default_flow_style=False)
322
+ else:
323
+ reg_plug_abspath = os_join(pkg_dir, "registered_plugins.json")
324
+ with open(reg_plug_abspath, "w") as plugin_registry:
325
+ LOG.info("Writing %s", reg_plug_abspath)
326
+ json.dump(plugins, plugin_registry, indent=4)
327
+
328
+
329
+ def create_plugin_registries(plugin_packages, save_type, namespace):
330
+ """Generate all plugin paths associated with every installed packages.
331
+
332
+ These paths include text plugins, class_based plugins
333
+ and normal YAML plugins. After these paths are generated, they are sent
334
+ to parse_plugin_paths, which generates and adds the actual plugins to the
335
+ plugins dictionary.
336
+
337
+ Parameters
338
+ ----------
339
+ plugin_packages: list EntryPoints
340
+ A list of EntryPoints pointing to each installed package --> ie.
341
+ [EntryPoint(name='pluginify', value='pluginify',
342
+ group='pluginify.plugin_packages'), ...]
343
+ save_type: str
344
+ The file format to save to [json, yaml]
345
+ namespace: str
346
+ Namespace that your plugin packages fall under. The argument parser defaults
347
+ this value to 'pluginify.plugin_packages', but a user can create separate
348
+ namespaces if developing interfaces outside of pluginify.
349
+ """
350
+ # It appears when there is *.egg-info directory, it picks that package up
351
+ # twice in the list. If the same package path exists twice, only keep one
352
+ # of them. This appears to be a bug with Python 3.9 entry points.
353
+ pkg_dirs = []
354
+ unique_package_entry_points = []
355
+ # Note we only use ep.value (resources.files finds the actual plugins),
356
+ # so we do not need to worry about saving the "wrong" package here.
357
+ # We are actually looping through all the files in each package, so
358
+ # we do not have an entry point for every plugin, just a single entry
359
+ # point for each plugin package.
360
+ for ep in plugin_packages:
361
+ pkg_dir = str(resources.files(ep.value))
362
+ if pkg_dir not in pkg_dirs:
363
+ pkg_dirs += [pkg_dir]
364
+ # Grab the unique package entry points.
365
+ unique_package_entry_points += [ep]
366
+
367
+ error_message = ""
368
+ # Loop through only the unique package entry points to avoid duplicate
369
+ # plugin errors.
370
+ for pkg in unique_package_entry_points:
371
+ # This is passed by reference and populated with each call to parse
372
+ # plugin packages.
373
+ plugins = {
374
+ "text_based": {},
375
+ "yaml_based": {},
376
+ "class_based": {},
377
+ }
378
+ # Track sets of plugins by plugin type
379
+ # (text_based, yaml_based, and class_based)
380
+ package = pkg.value
381
+ LOG.debug("package == " + str(package))
382
+ # We are specifically looping through all files in the ``plugins``
383
+ # directory within the plugin package. Potentially we may want
384
+ # to update this in the future to actually include package_plugins
385
+ # and package_schema entry points, and point the pyproject.toml
386
+ # package_plugins entry point directly to the appropriate directory
387
+ # that holds all plugins, and the package_schema entry point directly
388
+ # to the directory that holds all scheam, so the subdirectories are
389
+ # not hard coded here in the create_plugin_registries code.
390
+ pkg_plugin_path = resources.files(package) / "plugins"
391
+ pkg_dir = str(resources.files(package))
392
+ # Generate a path to the parent directory where we should write the plugin
393
+ # registry file for this package to.
394
+ write_dir = get_registry_cache_dir(namespace, package)
395
+ # Create the directories of 'write_dir' recursively. If they already exist,
396
+ # that's ok.
397
+ if not exists(write_dir):
398
+ makedirs(write_dir, exist_ok=True)
399
+ # Grab all YAML, Python, and txt files within the plugins directory.
400
+ # YAML schema files may be supported in the future.
401
+ yaml_files = pkg_plugin_path.rglob("*.yaml")
402
+ python_files = pkg_plugin_path.rglob("*.py")
403
+ text_files = pkg_plugin_path.rglob("*.txt")
404
+ # plugin_paths dictionary contains lists of files for each plugin
405
+ # type (ie, yaml based, text based, and class based plugins, and
406
+ # in the future potentially schema)
407
+ plugin_paths = {
408
+ "yaml": sorted(yaml_files),
409
+ "text": text_files,
410
+ "py_files": python_files,
411
+ }
412
+ # `plugins` is passed by reference and populated with all YAML, text, and
413
+ # python plugins found within the current plugin package `package`.
414
+ # This dictionary is formatted appropriately to be written out to the
415
+ # plugin registry file as either a json or YAML output.
416
+ # If any errors are found, append the error message string to the current
417
+ # error_message. Do not raise an exception until all plugins have been
418
+ # read in, so we can collect and report on all errors at once.
419
+ error_message += parse_plugin_paths(
420
+ plugin_paths, package, pkg_dir, plugins, namespace
421
+ )
422
+ LOG.debug("Available Plugin Types:\n" + str(plugins.keys()))
423
+ LOG.debug(
424
+ "Available YAML Plugin Interfaces:\n" + str(plugins["yaml_based"].keys())
425
+ )
426
+ LOG.debug(
427
+ "Available Class Plugin Interfaces:\n" + str(plugins["class_based"].keys())
428
+ )
429
+ # Write the current plugin dictionary to the registered plugins file.
430
+ write_registered_plugins(write_dir, plugins, save_type)
431
+ # If error_message is not the empty string, that means we had some errors,
432
+ # so handle appropriately.
433
+ if error_message:
434
+ # Remove all registries to prevent running with an incomplete
435
+ # or corrupt set of plugins. Force user to resolve errors before
436
+ # proceeding.
437
+ remove_registries(namespace, metadata.entry_points(group=namespace))
438
+ # Now raise the error, including the error message with output
439
+ # from every failed plugin/file during the attempted registry process.
440
+ raise PluginRegistryError(error_message)
441
+ # Above error only occurs for duplicates within a single registry.
442
+ # registry_sanity_check will check for duplicates across all
443
+ # registries.
444
+ registry_sanity_check(unique_package_entry_points, save_type, namespace)
445
+
446
+
447
+ def parse_plugin_paths(plugin_paths, package, package_dir, plugins, namespace):
448
+ """Parse the plugin_paths provided from the current installed package.
449
+
450
+ Then, add them to the plugins dictionary based on the path of the plugin.
451
+ The path contains information as to whether the plugin is a class_based, text_based,
452
+ or a yaml_based plugin.
453
+
454
+ Parameters
455
+ ----------
456
+ plugin_paths: dict
457
+ A dictionary of filepaths, with keys referring to the type of plugin
458
+ package: str
459
+ The current package being parsed
460
+ package_dir: str
461
+ The path to the current package (for determining relative paths)
462
+ plugins: dict
463
+ A dictionary object of all installed package plugins
464
+ namespace: str
465
+ Namespace that your plugin packages fall under. The argument parser defaults
466
+ this value to 'pluginify.plugin_packages', but a user can create separate
467
+ namespaces if developing interfaces outside of pluginify.
468
+
469
+ Returns
470
+ -------
471
+ error_message: str
472
+ String containing informative error messages from any plugins that
473
+ were improperly formatted. An exception will be raised at the
474
+ very end if error_message is not the empty string - this allows
475
+ collecting ALL errors throughout the plugin registry process and
476
+ reporting them all at once, to facilitate rapidly identifying and
477
+ resolving errors.
478
+ """
479
+ error_message = ""
480
+ # Loop through each plugin type, ie, text, yaml, class, and later schema.
481
+ for plugin_type in plugin_paths:
482
+ # Loop through each file of the current plugin type.
483
+ for filepath in plugin_paths[plugin_type]:
484
+ filepath = str(filepath)
485
+ # Path relative to the package directory
486
+ relpath = os_relpath(filepath, start=package_dir)
487
+ # plugins is passed by reference, so any new plugins found
488
+ # are added to the plugins dictionary and retained throughout.
489
+ if plugin_type == "yaml": # yaml based plugins
490
+ error_message += add_yaml_plugin(
491
+ filepath, relpath, package, plugins["yaml_based"], namespace
492
+ )
493
+ # Ensure we append any errors to the error_message as we go.
494
+ # Exception will not be raised until the very end, when we
495
+ # have collected ALL errors. This makes it easier to fix
496
+ # all the errors at once.
497
+ elif plugin_type == "text":
498
+ error_message += add_text_plugin(
499
+ package, relpath, plugins["text_based"]
500
+ )
501
+ # Potentially support schema in the future.
502
+ # elif plugin_type == "schemas": # schema based yamls
503
+ # add_schema_plugin(
504
+ # filepath, abspath, relpath, package, plugins["schemas"]
505
+ # )
506
+ else: # Python files; class based plugins
507
+ error_message += add_class_plugin(
508
+ package, relpath, plugins["class_based"]
509
+ )
510
+
511
+ # Ensure we return a string error_message with ALL errors appended.
512
+ # This will be raised at the end if error_message has any contents.
513
+ return error_message
514
+
515
+
516
+ def add_yaml_plugin(filepath, relpath, package, plugins, namespace):
517
+ """Add the yaml plugin associated with the filepaths and package to plugins.
518
+
519
+ Parameters
520
+ ----------
521
+ filepath: str
522
+ The path of the plugin derived from resouces.files(package) / plugin
523
+ relpath: str
524
+ The relative path to the filepath provided
525
+ package: str
526
+ The current package being parsed
527
+ plugins: dict
528
+ A dictionary object of all installed package plugins
529
+ namespace: str
530
+ Namespace that your plugin packages fall under. The argument parser defaults
531
+ this value to 'pluginify.plugin_packages', but a user can create separate
532
+ namespaces if developing interfaces outside of pluginify.
533
+
534
+ Returns
535
+ -------
536
+ error_message: str
537
+ String containing informative error messages from any plugins that
538
+ were improperly formatted. An exception will be raised at the
539
+ very end if error_message is not the empty string - this allows
540
+ collecting ALL errors throughout the plugin registry process and
541
+ reporting them all at once, to facilitate rapidly identifying and
542
+ resolving errors.
543
+ """
544
+ # Since this function always loads all plugins, loading them up front should be
545
+ # fine. It might be good to refactor this at some point. This is a quick fix.
546
+ with open(filepath, "r") as plugin_file:
547
+ new_plugins = list(yaml.safe_load_all(plugin_file))
548
+
549
+ for plugin in new_plugins:
550
+ plugin["relpath"] = relpath
551
+ plugin["package"] = package
552
+
553
+ try:
554
+ interface_name = plugin["interface"]
555
+ except KeyError:
556
+ raise PluginRegistryError(f"""No 'interface' level in '{filepath}'.
557
+ Ensure all required metadata is included.""")
558
+
559
+ if namespace != "geoips.plugin_packages":
560
+ mod = import_module(package)
561
+ interface_module = getattr(mod.interfaces, f"{interface_name}")
562
+ else:
563
+ # This is buried to avoid circular import errors. For some reason, doing
564
+ # this at the top level of the module with 'import_optional_dependencies'
565
+ # does not always work. If this is hit more than once, the cached import
566
+ # will be used. Efficiency is not impacted.
567
+ try:
568
+ import geoips.interfaces
569
+ except ImportError as e:
570
+ raise RuntimeError(
571
+ "The 'geoips' package is required but could not be imported."
572
+ "Please install geoips before running `pluginify create` again."
573
+ ) from e
574
+ interface_module = getattr(geoips.interfaces, f"{interface_name}")
575
+
576
+ if interface_name not in plugins.keys():
577
+ plugins[interface_name] = {}
578
+
579
+ error_message = ""
580
+ # If the current family is "list", make sure we loop through the list,
581
+ # expanding out each individual product found within the list.
582
+ if plugin["family"] == "list":
583
+ # These are not complete plugins at this stage, only the metadata,
584
+ # so do not validate the plugins here, they will be validated on open.
585
+
586
+ # plugin_yaml_to_obj returns the actual plugin object, ie, ProductsPlugin,
587
+ # or SectorsPlugin, etc.
588
+ plg_list = interface_module._plugin_yaml_to_obj(plugin["name"], plugin)
589
+
590
+ # plg_list is an e.g. ProductsPlugin object of family list,
591
+ # so within its e.g. plg_list["spec"]["products"] key, we will
592
+ # find a list of all products contained within this single
593
+ # plugin file.
594
+ for yaml_subplg in plg_list["spec"][interface_module.name]:
595
+ # _create_registered_plugin_names will return a list of names
596
+ # found in this single product specification. Most interfaces
597
+ # this will just return a list of length one, containing
598
+ # [plugin.name], but for products it will return a list of
599
+ # tuples of (source_name, product_name), allowing specifying
600
+ # a list of valid sources within each product spec.
601
+ subplg_names = interface_module._create_registered_plugin_names(
602
+ yaml_subplg
603
+ )
604
+ # Loop through each of the returned registered plugin names.
605
+ # Give each one its own entry in the plugin registry for easy
606
+ # access.
607
+ for subplg_name in subplg_names:
608
+ subplg_source = str(subplg_name[0])
609
+ subplg_product = str(subplg_name[1])
610
+ if subplg_source not in plugins[interface_name]:
611
+ plugins[interface_name][subplg_source] = {}
612
+ # since we are dealing with sub-plugins of a product plugin,
613
+ # include a couple other pieces of information, such as
614
+ # product_defaults and source_names.
615
+ source_names = yaml_subplg["source_names"]
616
+ pd = None
617
+ if "product_defaults" in yaml_subplg:
618
+ pd = yaml_subplg["product_defaults"]
619
+ family = None
620
+ if "family" in yaml_subplg:
621
+ family = yaml_subplg["family"]
622
+ docstring = None
623
+ if "docstring" in yaml_subplg:
624
+ docstring = yaml_subplg["docstring"]
625
+ # If docstring or family are not specified, and a
626
+ # product_defaults isn't specified, raise an error
627
+ # (docstring and family must be defined in a
628
+ # product_defaults if not defined explicitly).
629
+ if (not docstring or not family) and not pd:
630
+ error_message += f"""
631
+ Error with package '{plugin["package"]}':
632
+ docstring or family not defined in product,
633
+ and product_defaults not specified.
634
+ Must specify docstring and family in either product
635
+ or product_defaults.
636
+ interface '{interface_module.name}'
637
+ plugin name '{plugin["name"]}'
638
+ pkg relpath: '{plugin["relpath"]}'\n"""
639
+ continue
640
+
641
+ # I think filling in the nulls should be handled at the CLI
642
+ # and/or interface level, so leave family/docstring as None
643
+ # in the registry.
644
+ # There is no way to guarantee the product_defaults are
645
+ # available in the same plugin package as the current plugin,
646
+ # so pulling the family and docstring from the product defaults
647
+ # when creating the registry will not always work.
648
+ # If we are concerned about efficiency with opening product and
649
+ # product_defaults with every plugin access, we could do a
650
+ # second pass in the plugin registry creation to fill in the
651
+ # nulls (but we will not worry about that yet)
652
+ # if not docstring or not family:
653
+ # # if the yaml_sub_plg doesn't include a docstring, grab its
654
+ # # product_defaults docstring
655
+ # if (
656
+ # "product_defaults" not in plugins
657
+ # or pd not in plugins["product_defaults"]
658
+ # ):
659
+ # LOG.error(
660
+ # f"""Product defaults '{pd}' does not exist.
661
+ # Using 'undefined' docstring.
662
+ # Need to figure out how to pull product defaults
663
+ # from a different plugin package at some point.""" # NOQA
664
+ # )
665
+ # else:
666
+ # if not docstring:
667
+ # docstring = plugins["product_defaults"][pd]["docstring"] # NOQA
668
+ # if not family:
669
+ # family = plugins["product_defaults"][pd]["family"]
670
+ plugins[interface_name][subplg_source][subplg_product] = {
671
+ "docstring": str(format_docstring(docstring)),
672
+ "family": family,
673
+ "interface": interface_module.name,
674
+ "package": plugin["package"],
675
+ "plugin_type": "yaml_based",
676
+ "product_defaults": pd,
677
+ "source_names": source_names,
678
+ "relpath": plugin["relpath"],
679
+ }
680
+ else:
681
+ error_message += check_plugin_exists(
682
+ package, plugins, interface_name, plugin["name"], relpath
683
+ )
684
+
685
+ # If this is not of family list, just set a single entry for
686
+ # current plugin name.
687
+ # Since this is not a product plugin, we can ensure that these top-level
688
+ # attributes should exist. Don't include product_defaults or source_names in
689
+ # this info, because it doesn't apply to this type of plugin.
690
+ if "docstring" not in plugin:
691
+ error_message += f"""
692
+ Error with package '{plugin["package"]}':
693
+ docstring or family not defined in product
694
+ Must specify docstring and family in either product
695
+ or product_defaults.
696
+ interface '{plugin["interface"]}'
697
+ plugin name '{plugin["name"]}'
698
+ pkg relpath: '{relpath}'\n"""
699
+
700
+ else:
701
+ plugins[interface_name][plugin["name"]] = {
702
+ "docstring": format_docstring(plugin["docstring"]),
703
+ "family": plugin["family"],
704
+ "interface": plugin["interface"],
705
+ "package": package,
706
+ "plugin_type": "yaml_based",
707
+ "relpath": relpath,
708
+ }
709
+ return error_message
710
+
711
+
712
+ def add_text_plugin(package, relpath, plugins):
713
+ """Add all text plugins into plugin registries.
714
+
715
+ Parameters
716
+ ----------
717
+ package: str
718
+ The current package being parsed
719
+ relpath: str
720
+ The relpath path to the text plugin
721
+ plugins: dict
722
+ A dictionary object of all installed package plugins
723
+
724
+ Returns
725
+ -------
726
+ error_message: str
727
+ String containing informative error messages from any plugins that
728
+ were improperly formatted. An exception will be raised at the
729
+ very end if error_message is not the empty string - this allows
730
+ collecting ALL errors throughout the plugin registry process and
731
+ reporting them all at once, to facilitate rapidly identifying and
732
+ resolving errors.
733
+ """
734
+ # Eventually, we will add interface, family, and name to the text files
735
+ # themselves, in which case we will pull the appropriate information out
736
+ # of the attributes included in the comments at the beginning of the text
737
+ # file. Note I added these comments to the current ascii_palettes, though
738
+ # they are not yet used.
739
+
740
+ # For now, use the basename of the filename as the "name"
741
+ text_name = splitext(basename(relpath))[0]
742
+
743
+ # For now, use the last directory name as the interface name.
744
+ interface_name = split(dirname(relpath))[-1]
745
+ error_message = ""
746
+ if interface_name not in plugins:
747
+ plugins[interface_name] = {}
748
+ plugins[interface_name][text_name] = {"package": package, "relpath": relpath}
749
+ # For now we have no error messages for text plugins, it will always be
750
+ # an empty string. But return it anyway.
751
+ return error_message
752
+
753
+
754
+ def collect_module_plugin_metadata(
755
+ module, module_name, package, relpath, error_message
756
+ ):
757
+ """Collect metadata for the module-based plugin for the plugin registry.
758
+
759
+ Metadata necessary to collect here includes the interface of the plugin, the
760
+ plugin's name, its docstring, and the family it adheres to. Other metadata such as
761
+ relative path and the package it comes from is collected via 'add_class_plugin'.
762
+
763
+ NOTE: The 'Returns' section of this docstring is XOR. Either 'metadata' will be
764
+ returned, or 'error_message'. Not both or neither.
765
+
766
+ Parameters
767
+ ----------
768
+ module: ModuleType
769
+ - The object of the module which has been loaded in realtime. This should have
770
+ top level attributes such as 'interface', 'name', 'family' and so on.
771
+ module_name: str
772
+ - The name of the .py file.
773
+ package: str
774
+ - The name of the package this plugin comes from
775
+ relpath: str
776
+ - The relative path to the plugin found in 'package'
777
+ error_message: str
778
+ - The current state of errors found during building the registry, stored as a
779
+ string.
780
+
781
+ Returns
782
+ -------
783
+ metadata: dict
784
+ - A dictionary of metadata for the provided plugin class to store in the plugin
785
+ registry.
786
+ error_message: str
787
+ - The current state of errors found during building the registry, stored as a
788
+ string.
789
+ """
790
+ # Try to get "interface" variable from the module. This is required
791
+ # on ALL files within the python module based plugins directory, to
792
+ # ensure create_plugin_registries can explicitly tell whether a file
793
+ # is properly formatted or not. Files that are not full plugins must
794
+ # be specified with "interface = None" (identifying as a python module
795
+ # that should NOT be included in the python registry), and full
796
+ # plugins must include interface, family, and name variables at the top
797
+ # level.
798
+ try:
799
+ interface_name = module.interface
800
+ except AttributeError:
801
+ error_message += f"""\nError,
802
+ 'interface' top level variable missing in
803
+ module '{module_name}' in
804
+ package '{package}'
805
+ at relpath '{relpath}'
806
+
807
+ * must specify 'interface' variable at the
808
+ top level of ALL python modules within the
809
+ plugins subdirectory.
810
+
811
+ * FOR VALID PLUGINS:
812
+ 'interface', 'family', and 'name' must all be specified
813
+ as variables at the top level.
814
+
815
+ * FOR HELPER MODULES WITHIN THE plugins SUBDIRECTORY
816
+ 'interface = None' must be specified at the top level for modules
817
+ within the plugins subdirectory that are not intended to be
818
+ plugins on their own."""
819
+ return error_message
820
+ # If interface is None, then legitimately skip the module.
821
+ # We want to skip this first, before we test anything else.
822
+ # If it is not a plugin, we don't care if there are other
823
+ # errors in it at this stage (ie, avoid unnecessary unrelated
824
+ # catastrophic failures)
825
+ if not interface_name:
826
+ LOG.info(
827
+ f"Skipping module '{module_name}' from '{package}', "
828
+ "interface_name is 'None'"
829
+ )
830
+ return error_message
831
+ # If we get here, it should be a full plugin, so it must include both
832
+ # name and family variables/attributes.
833
+ try:
834
+ name = module.name
835
+ family = module.family
836
+ except AttributeError:
837
+ error_message += f"""\nError, 'family' or 'name' top level variable missing
838
+ in module '{module_name}' in package '{package}'
839
+ at relpath '{relpath}'
840
+ must specify 'interface', 'family', and 'name' variables at the
841
+ top level of ALL module based plugins."""
842
+ return error_message
843
+
844
+ metadata = {
845
+ "docstring": format_docstring(module.__doc__),
846
+ "name": name,
847
+ "family": family,
848
+ "interface": interface_name,
849
+ "package": package,
850
+ "plugin_type": "class_based",
851
+ "signature": str(signature(module.call)),
852
+ "relpath": relpath,
853
+ "is_derived_from_module": True,
854
+ }
855
+
856
+ return metadata
857
+
858
+
859
+ def collect_class_plugin_metadata(plugin_class):
860
+ """Collect metadata linked to the class plugin in 'module' for the plugin registry.
861
+
862
+ Metadata necessary to collect here includes the interface of the plugin, the
863
+ plugin's name, its docstring, and the family it adheres to. Other metadata such as
864
+ relative path and the package it comes from is collected via 'add_module_plugin'.
865
+
866
+ Parameters
867
+ ----------
868
+ plugin_class: Object, default=None
869
+ - The plugin class from 'module' to collect metadata from. If None, this
870
+ function will attempt to locate the correct plugin class from the provided
871
+ module.
872
+
873
+ Returns
874
+ -------
875
+ metadata: dict
876
+ - A dictionary of metadata for the provided plugin class to store in the plugin
877
+ registry.
878
+ """
879
+ # NOTE: Probably should add some attribute checks here as
880
+ # 'collect_module_plugin_metadata' does
881
+ metadata = {
882
+ "docstring": format_docstring(plugin_class.__doc__),
883
+ "name": plugin_class.name,
884
+ "family": plugin_class.family,
885
+ "interface": plugin_class.interface,
886
+ "plugin_type": "class_based",
887
+ "signature": str(signature(plugin_class.call)),
888
+ "is_derived_from_module": False,
889
+ }
890
+
891
+ return metadata
892
+
893
+
894
+ def add_class_plugin(package, relpath, plugins):
895
+ """Add the class-based plugin associated with the filepaths and package to plugins.
896
+
897
+ NOTE: This function will work for 'legacy' module-based plugins that are dynamically
898
+ converted to class-based objects via <interface>.get_plugin and true class-based
899
+ plugins. Stores them all under the 'class_based' portion of the plugin registry and
900
+ denotes whether a plugin is truly class based or not via the metadata variable
901
+ 'is_derived_from_module'.
902
+
903
+ Parameters
904
+ ----------
905
+ package: str
906
+ The current package being parsed
907
+ relpath: str
908
+ The relpath path to the class-based plugin
909
+ plugins: dict
910
+ A dictionary object of all installed package plugins
911
+
912
+ Returns
913
+ -------
914
+ error_message: str
915
+ String containing informative error messages from any plugins that
916
+ were improperly formatted. An exception will be raised at the
917
+ very end if error_message is not the empty string - this allows
918
+ collecting ALL errors throughout the plugin registry process and
919
+ reporting them all at once, to facilitate rapidly identifying and
920
+ resolving errors.
921
+ """
922
+ error_message = ""
923
+
924
+ if "__init__.py" in relpath:
925
+ # Ignore init files. We should probably ignore other dunder files such as
926
+ # __version.py, etc.
927
+ return error_message
928
+
929
+ module_name = splitext(basename(relpath))[0]
930
+ # We need the full path to the module in order
931
+ # for relative imports to work within modules.
932
+ module_path = splitext(relpath.replace("/", "."))[0]
933
+ module_path = f"{package}.{module_path}"
934
+ abspath = resources.files(package) / relpath
935
+
936
+ spec = util.spec_from_file_location(module_path, abspath)
937
+ module = util.module_from_spec(spec)
938
+ # Attempting importing module, catch ImportError
939
+ # We have to fix these to be able to import the module to
940
+ # see if 'interface' is defined, in order to see if it
941
+ # is a properly formatted python module.
942
+ try:
943
+ spec.loader.exec_module(module)
944
+ except ImportError as resp:
945
+ LOG.exception(resp)
946
+ error_message += f"""\nError {str(resp)}:
947
+ Failed importing '{module_name}' in
948
+ package '{package}'
949
+ at relpath '{relpath}'\n"""
950
+ return error_message
951
+
952
+ # We've encountered a truly 'class-based' plugin. I.e. we don't need to generate
953
+ # the plugin object from the module itself. Collect metadata from this plugin
954
+ # in a different fashion to what we've done previously.
955
+
956
+ # Once we've fully implemented class-based plugins and removed support for
957
+ # module-based plugins, we can remove this conditional and only keep the 'if'
958
+ # portion of the if-else statement below. The 'else' part of the conditional handles
959
+ # legacy module based plugins for the time being.
960
+ if "classes" in module_path or hasattr(module, "PLUGIN_CLASS"):
961
+ is_class = True
962
+ try:
963
+ PLUGIN_CLASS = getattr(module, "PLUGIN_CLASS")
964
+ except AttributeError:
965
+ plugin_class_error_message = (
966
+ f"Error: Detected class-based plugin at '{module_path}' but couldn't "
967
+ "locate the associated 'PLUGIN_CLASS' variable defining which class in "
968
+ "that module is the actual plugin class to use. \n Please define that "
969
+ "attribute before continuing. It should be a simple module-level "
970
+ "attribute that references the uninstantiated version of your plugin "
971
+ "class."
972
+ )
973
+ error_message += str(plugin_class_error_message)
974
+ return error_message
975
+
976
+ plugin_metadata = collect_class_plugin_metadata(PLUGIN_CLASS)
977
+ interface_name = plugin_metadata["interface"]
978
+ name = plugin_metadata.pop("name")
979
+
980
+ plugin_metadata["package"] = package
981
+ plugin_metadata["relpath"] = relpath
982
+ else:
983
+ is_class = False
984
+ # Collect top level metadata from the module plugin
985
+ return_type = collect_module_plugin_metadata(
986
+ module, module_name, package, relpath, error_message
987
+ )
988
+ if isinstance(return_type, str):
989
+ # This conditional checks if the returned object is of a string instance. If
990
+ # it is, that means an error message was added and we should just return it
991
+ return return_type
992
+ else:
993
+ # Otherwise, metadata was returned as expected
994
+ plugin_metadata = return_type
995
+ name = plugin_metadata.pop("name")
996
+ interface_name = plugin_metadata["interface"]
997
+
998
+ # If the current interface_name is not in the plugins dictionary yet, add it
999
+ # as an empty dictionary.
1000
+ if interface_name not in plugins.keys():
1001
+ plugins[interface_name] = {}
1002
+ # Check_plugin_exists will return a text error message if there are any errors
1003
+ # rather than raising an exception. This allows collecting all errors as
1004
+ # we go, and reporting once at the end with an error message including ALL
1005
+ # errors found across all plugins in all plugin packages. Append the new
1006
+ # error message to the error messages that have already been collected.
1007
+ error_message += check_plugin_exists(
1008
+ package, plugins, interface_name, name, relpath
1009
+ )
1010
+
1011
+ # Add the generated metadata to the plugin's entry in the registry
1012
+ plugins[interface_name][name] = plugin_metadata
1013
+
1014
+ if interface_name == "readers":
1015
+ if not is_class and hasattr(module, "source_names"):
1016
+ plugins[interface_name][name]["source_names"] = module.source_names
1017
+ elif is_class and hasattr(PLUGIN_CLASS, "source_names"):
1018
+ plugins[interface_name][name]["source_names"] = PLUGIN_CLASS.source_names
1019
+ else:
1020
+ warnings.warn(
1021
+ (
1022
+ f"Plugin package '{package}'s reader"
1023
+ f" plugin '{name}' is using a deprecated source_names "
1024
+ "implementation. Please add a module/class-level 'source_names' "
1025
+ "attribute to this plugin and re-run "
1026
+ "'pluginify create'."
1027
+ ),
1028
+ DeprecationWarning,
1029
+ stacklevel=2,
1030
+ )
1031
+ plugins[interface_name][name]["source_names"] = ["Unspecified"]
1032
+
1033
+ del module
1034
+ # Return the final error message - an exception will be raised at the very
1035
+ # end after collecting and reporting on all errors if there were any errors
1036
+ # during plugin registry creation.
1037
+ return error_message