Skip to content

Commit 5fd4dc8

Browse files
joeycasey87ladcFelix ErdmannFelixE91
authored
Adapt pysteps to allow for postprocessing plugins (#405)
* Added structure for the creation of postprocessing plugins * Update interface.py * Updated to fit black formatting * Update interface.py * Update diagnostics.py * Added tests to improve code coverage Also reformatted with black. Tests were added for the get_method and diagnostics_info functions in the postprocessing interface. Tests for the discover_diagnostics function will be written once these changes have been merged as then the cookiecutter plugins can then be properly tested. * Remove redundant import and getattr statements * Adjusted so that the interface is more easily interpretable * Fixed error from stashed chages * update test_pysteps Changed the test file to match the test updated pysteps test file * Remove try-import statements for diagnostics.py These were necessary for the IO plugins, but less so for the diagnostics. * Revert back to master and Python 3.10 in check_black.yml * Make sure the postprocessors are actually discovered in the main init * Refactor code for postprocessing plugin detection. Avoid duplicate code, refactor into functions. Also fix a small typo causing a bug: postprocessor_s_ * Update dummy code names for new plugin structure * Change postprocessor interface to use diagnostic_ and ensemblestat_ plugins * Fix the postprocessing interface - should match names as in the plugin cookiecutter * Update postprocessing package to work with plugins - diagnostic plugins created with the cookiecutter are now correctly recognized and implemented * Update both io interface and postprocessign interface - importer and diagnostic plugins correctly recognized in entry points - cleaning: removed unused import modules * Reformatted files with pre-commit * Remove tests for diagnostics plugins interfaces. * Fix io.interface to work with the new cookiecutter * Test if the default cookiecutter plugin can be loaded in the CI tests. * Fix default plugin path in tox.ini. * Fix plugin tests to use new cookiecutter template; try out importer and diagnostic plugin. * Added postprocessing module interface test * Postprocessing interface reformatted with pre-commit * Cleaning as requested in ladc's review * Fix postprocessing interface test * Simplift postprocessing.interface and add more tests for it * Fix postprocessing.interface test to match expected warning * Bug fixes to run tests * more test - codecov * io interface update to also check for plugins of group pysteps.plugins.importers in discover_importers() * Add comment explaining the two entry point lists for importers. * Add comment about pysteps plugin tests: using default name. --------- Co-authored-by: Lesley De Cruz <lesley.decruz@meteo.be> Co-authored-by: Felix Erdmann <ferdman@kili.oma.be> Co-authored-by: FelixE91 <ferdman@meteo.be>
1 parent 9fc914c commit 5fd4dc8

File tree

9 files changed

+389
-37
lines changed

9 files changed

+389
-37
lines changed

ci/test_plugin_support.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,11 @@
1313
from pysteps import io
1414

1515
print("Testing plugin support: ", end="")
16-
assert hasattr(io.importers, "import_abc_xxx")
17-
assert hasattr(io.importers, "import_abc_yyy")
16+
assert hasattr(io.importers, "import_institution_name")
1817

19-
assert "abc_xxx" in io.interface._importer_methods
20-
assert "abc_yyy" in io.interface._importer_methods
18+
assert "institution_name" in io.interface._importer_methods
2119

22-
from pysteps.io.importers import import_abc_xxx, import_abc_yyy
23-
24-
import_abc_xxx("filename")
25-
import_abc_yyy("filename")
20+
from pysteps.io.importers import import_institution_name
2621

22+
import_institution_name("filename")
2723
print("PASSED")

pysteps/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,4 @@ def load_config_file(params_file=None, verbose=False, dryrun=False):
218218

219219
# After the sub-modules are loaded, register the discovered importers plugin.
220220
io.interface.discover_importers()
221+
postprocessing.interface.discover_postprocessors()

pysteps/io/interface.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,8 @@
1414
"""
1515
import importlib
1616

17-
from importlib.metadata import entry_points
18-
19-
from pysteps import io
2017
from pysteps.decorators import postprocess_import
21-
from pysteps.io import importers, exporters
18+
from pysteps.io import importers, exporters, interface
2219
from pprint import pprint
2320

2421
_importer_methods = dict(
@@ -49,7 +46,18 @@ def discover_importers():
4946
The importers found are added to the `pysteps.io.interface_importer_methods`
5047
dictionary containing the available importers.
5148
"""
52-
for entry_point in entry_points(group="pysteps.plugins.importers"):
49+
# The pkg resources needs to be reload to detect new packages installed during
50+
# the execution of the python application. For example, when the plugins are
51+
# installed during the tests
52+
import pkg_resources
53+
54+
importlib.reload(pkg_resources)
55+
# Backward compatibility with previous entry point 'pysteps.plugins.importers' next to 'pysteps.plugins.importer'
56+
for entry_point in list(
57+
pkg_resources.iter_entry_points(group="pysteps.plugins.importer", name=None)
58+
) + list(
59+
pkg_resources.iter_entry_points(group="pysteps.plugins.importers", name=None)
60+
):
5361
_importer = entry_point.load()
5462

5563
importer_function_name = _importer.__name__
@@ -63,14 +71,14 @@ def discover_importers():
6371
RuntimeWarning(
6472
f"The importer identifier '{importer_short_name}' is already available in"
6573
"'pysteps.io.interface._importer_methods'.\n"
66-
f"Skipping {entry_point.module}:{entry_point.attr}"
74+
f"Skipping {entry_point.module_name}:{entry_point.attrs}"
6775
)
6876

6977
if hasattr(importers, importer_function_name):
7078
RuntimeWarning(
7179
f"The importer function '{importer_function_name}' is already an attribute"
7280
"of 'pysteps.io.importers`.\n"
73-
f"Skipping {entry_point.module}:{entry_point.attr}"
81+
f"Skipping {entry_point.module_name}:{entry_point.attrs}"
7482
)
7583
else:
7684
setattr(importers, importer_function_name, _importer)
@@ -81,22 +89,22 @@ def importers_info():
8189

8290
# Importers available in the `io.importers` module
8391
available_importers = [
84-
attr for attr in dir(io.importers) if attr.startswith("import_")
92+
attr for attr in dir(importers) if attr.startswith("import_")
8593
]
8694

8795
print("\nImporters available in the pysteps.io.importers module")
8896
pprint(available_importers)
8997

9098
# Importers declared in the pysteps.io.get_method interface
9199
importers_in_the_interface = [
92-
f.__name__ for f in io.interface._importer_methods.values()
100+
f.__name__ for f in interface._importer_methods.values()
93101
]
94102

95103
print("\nImporters available in the pysteps.io.get_method interface")
96104
pprint(
97105
[
98106
(short_name, f.__name__)
99-
for short_name, f in io.interface._importer_methods.items()
107+
for short_name, f in interface._importer_methods.items()
100108
]
101109
)
102110

@@ -107,7 +115,6 @@ def importers_info():
107115

108116
difference = available_importers ^ importers_in_the_interface
109117
if len(difference) > 0:
110-
print("\nIMPORTANT:")
111118
_diff = available_importers - importers_in_the_interface
112119
if len(_diff) > 0:
113120
print(

pysteps/postprocessing/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,6 @@
22
"""Methods for post-processing of forecasts."""
33

44
from . import ensemblestats
5+
from .diagnostics import *
6+
from .interface import *
7+
from .ensemblestats import *

pysteps/postprocessing/diagnostics.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""
2+
pysteps.postprocessing.diagnostics
3+
====================
4+
5+
Methods for applying diagnostics postprocessing.
6+
7+
The methods in this module implement the following interface::
8+
9+
diagnostic_xxx(optional arguments)
10+
11+
where **xxx** is the name of the diagnostic to be applied.
12+
13+
Available Diagnostics Postprocessors
14+
------------------------
15+
16+
.. autosummary::
17+
:toctree: ../generated/
18+
19+
"""
20+
21+
# Add your diagnostic_ function here AND add this method to the _diagnostics_methods
22+
# dictionary in postprocessing.interface.py

pysteps/postprocessing/interface.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
pysteps.postprocessing.interface
4+
====================
5+
6+
Interface for the postprocessing module.
7+
8+
Support postprocessing types:
9+
- ensmeblestats
10+
- diagnostics
11+
12+
.. currentmodule:: pysteps.postprocessing.interface
13+
14+
.. autosummary::
15+
:toctree: ../generated/
16+
17+
get_method
18+
"""
19+
import importlib
20+
21+
from pysteps.postprocessing import diagnostics, ensemblestats
22+
from pprint import pprint
23+
import warnings
24+
25+
_diagnostics_methods = dict()
26+
27+
_ensemblestats_methods = dict(
28+
mean=ensemblestats.mean,
29+
excprob=ensemblestats.excprob,
30+
banddepth=ensemblestats.banddepth,
31+
)
32+
33+
34+
def add_postprocessor(
35+
postprocessors_function_name, _postprocessors, module, attributes
36+
):
37+
"""
38+
Add the postprocessor to the appropriate _methods dictionary and to the module.
39+
Parameters
40+
----------
41+
42+
postprocessors_function_name: str
43+
for example, e.g. diagnostic_example1
44+
_postprocessors: function
45+
the function to be added
46+
@param module: the module where the function is added, e.g. 'diagnostics'
47+
@param attributes: the existing functions in the selected module
48+
"""
49+
# the dictionary where the function is added
50+
methods_dict = (
51+
_diagnostics_methods if "diagnostic" in module else _ensemblestats_methods
52+
)
53+
54+
# get funtion name without mo
55+
short_name = postprocessors_function_name.replace(f"{module}_", "")
56+
if short_name not in methods_dict:
57+
methods_dict[short_name] = _postprocessors
58+
else:
59+
warnings.warn(
60+
f"The {module} identifier '{short_name}' is already available in "
61+
f"'pysteps.postprocessing.interface_{module}_methods'.\n"
62+
f"Skipping {module}:{'.'.join(attributes)}",
63+
RuntimeWarning,
64+
)
65+
66+
if hasattr(globals()[module], postprocessors_function_name):
67+
warnings.warn(
68+
f"The {module} function '{short_name}' is already an attribute"
69+
f"of 'pysteps.postprocessing.{module}'.\n"
70+
f"Skipping {module}:{'.'.join(attributes)}",
71+
RuntimeWarning,
72+
)
73+
else:
74+
setattr(globals()[module], postprocessors_function_name, _postprocessors)
75+
76+
77+
def discover_postprocessors():
78+
"""
79+
Search for installed postprocessing plugins in the entrypoint 'pysteps.plugins.postprocessors'
80+
81+
The postprocessors found are added to the appropriate `_methods`
82+
dictionary in 'pysteps.postprocessing.interface' containing the available postprocessors.
83+
"""
84+
85+
# The pkg resources needs to be reloaded to detect new packages installed during
86+
# the execution of the python application. For example, when the plugins are
87+
# installed during the tests
88+
import pkg_resources
89+
90+
importlib.reload(pkg_resources)
91+
92+
# Discover the postprocessors available in the plugins
93+
for plugintype in ["diagnostic", "ensemblestat"]:
94+
for entry_point in pkg_resources.iter_entry_points(
95+
group=f"pysteps.plugins.{plugintype}", name=None
96+
):
97+
_postprocessors = entry_point.load()
98+
99+
postprocessors_function_name = _postprocessors.__name__
100+
101+
if plugintype in entry_point.module_name:
102+
add_postprocessor(
103+
postprocessors_function_name,
104+
_postprocessors,
105+
f"{plugintype}s",
106+
entry_point.attrs,
107+
)
108+
109+
110+
def print_postprocessors_info(module_name, interface_methods, module_methods):
111+
"""
112+
Helper function to print the postprocessors available in the module and in the interface.
113+
114+
Parameters
115+
----------
116+
module_name: str
117+
Name of the module, for example 'pysteps.postprocessing.diagnostics'.
118+
interface_methods: dict
119+
Dictionary of the postprocessors declared in the interface, for example _diagnostics_methods.
120+
module_methods: list
121+
List of the postprocessors available in the module, for example 'diagnostic_example1'.
122+
123+
"""
124+
print(f"\npostprocessors available in the {module_name} module")
125+
pprint(module_methods)
126+
127+
print(
128+
"\npostprocessors available in the pysteps.postprocessing.get_method interface"
129+
)
130+
pprint([(short_name, f.__name__) for short_name, f in interface_methods.items()])
131+
132+
module_methods_set = set(module_methods)
133+
interface_methods_set = set(interface_methods.keys())
134+
135+
difference = module_methods_set ^ interface_methods_set
136+
if len(difference) > 0:
137+
# print("\nIMPORTANT:")
138+
_diff = module_methods_set - interface_methods_set
139+
if len(_diff) > 0:
140+
print(
141+
f"\nIMPORTANT:\nThe following postprocessors are available in {module_name} module but not in the pysteps.postprocessing.get_method interface"
142+
)
143+
pprint(_diff)
144+
_diff = interface_methods_set - module_methods_set
145+
if len(_diff) > 0:
146+
print(
147+
"\nWARNING:\n"
148+
f"The following postprocessors are available in the pysteps.postprocessing.get_method interface but not in the {module_name} module"
149+
)
150+
pprint(_diff)
151+
152+
153+
def postprocessors_info():
154+
"""Print all the available postprocessors."""
155+
156+
available_postprocessors = set()
157+
postprocessors_in_the_interface = set()
158+
# List the plugins that have been added to the postprocessing.[plugintype] module
159+
for plugintype in ["diagnostics", "ensemblestats"]:
160+
# in the dictionary and found by get_methods() function
161+
interface_methods = (
162+
_diagnostics_methods
163+
if plugintype == "diagnostics"
164+
else _ensemblestats_methods
165+
)
166+
# in the pysteps.postprocessing module
167+
module_name = f"pysteps.postprocessing.{plugintype}"
168+
available_module_methods = [
169+
attr
170+
for attr in dir(importlib.import_module(module_name))
171+
if attr.startswith(plugintype[:-1])
172+
]
173+
# add the pre-existing ensemblestats functions (see _ensemblestats_methods above)
174+
# that do not follow the convention to start with "ensemblestat_" as the plugins
175+
if "ensemblestats" in plugintype:
176+
available_module_methods += [
177+
em
178+
for em in _ensemblestats_methods.keys()
179+
if not em.startswith("ensemblestat_")
180+
]
181+
print_postprocessors_info(
182+
module_name, interface_methods, available_module_methods
183+
)
184+
available_postprocessors = available_postprocessors.union(
185+
available_module_methods
186+
)
187+
postprocessors_in_the_interface = postprocessors_in_the_interface.union(
188+
interface_methods.keys()
189+
)
190+
191+
return available_postprocessors, postprocessors_in_the_interface
192+
193+
194+
def get_method(name, method_type):
195+
"""
196+
Return a callable function for the method corresponding to the given
197+
name.
198+
199+
Parameters
200+
----------
201+
name: str
202+
Name of the method. The available options are:\n
203+
204+
diagnostics:
205+
[nothing pre-installed]
206+
207+
ensemblestats:
208+
pre-installed: mean, excprob, banddepth
209+
210+
Additional options might exist if plugins are installed.
211+
212+
method_type: {'diagnostics', 'ensemblestats'}
213+
Type of the method (see tables above).
214+
215+
"""
216+
217+
if isinstance(method_type, str):
218+
method_type = method_type.lower()
219+
else:
220+
raise TypeError(
221+
"Only strings supported for for the method_type"
222+
+ " argument\n"
223+
+ "The available types are: 'diagnostics', 'ensemblestats'"
224+
) from None
225+
226+
if isinstance(name, str):
227+
name = name.lower()
228+
else:
229+
raise TypeError(
230+
"Only strings supported for the method's names.\n"
231+
+ "\nAvailable diagnostics names:"
232+
+ str(list(_diagnostics_methods.keys()))
233+
+ "\nAvailable ensemblestats names:"
234+
+ str(list(_ensemblestats_methods.keys()))
235+
) from None
236+
237+
if method_type == "diagnostics":
238+
methods_dict = _diagnostics_methods
239+
elif method_type == "ensemblestats":
240+
methods_dict = _ensemblestats_methods
241+
else:
242+
raise ValueError(
243+
"Unknown method type {}\n".format(method_type)
244+
+ "The available types are: 'diagnostics', 'ensemblestats'"
245+
) from None
246+
247+
try:
248+
return methods_dict[name]
249+
except KeyError:
250+
raise ValueError(
251+
"Unknown {} method {}\n".format(method_type, name)
252+
+ "The available methods are:"
253+
+ str(list(methods_dict.keys()))
254+
) from None

0 commit comments

Comments
 (0)