Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adapt pysteps to allow for postprocessing plugins #405

Open
wants to merge 36 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
f2c7514
Added structure for the creation of postprocessing plugins
joeycasey87 Jul 19, 2024
11abb0d
Update interface.py
joeycasey87 Jul 25, 2024
36741db
Updated to fit black formatting
joeycasey87 Jul 25, 2024
9e79882
Update interface.py
joeycasey87 Jul 25, 2024
5abe467
Update diagnostics.py
joeycasey87 Jul 25, 2024
092557c
Added tests to improve code coverage
joeycasey87 Aug 13, 2024
15de2a4
Remove redundant import and getattr statements
ladc Aug 20, 2024
c6ca85d
Adjusted so that the interface is more easily interpretable
joeycasey87 Aug 26, 2024
f5558ac
Fixed error from stashed chages
joeycasey87 Aug 26, 2024
e64b70b
update test_pysteps
joeycasey87 Aug 26, 2024
6605643
Remove try-import statements for diagnostics.py
ladc Aug 26, 2024
2ed3411
Revert back to master and Python 3.10 in check_black.yml
ladc Aug 26, 2024
01bf7ba
Make sure the postprocessors are actually discovered in the main init
ladc Aug 26, 2024
3586f56
Refactor code for postprocessing plugin detection.
ladc Aug 27, 2024
f581846
Update dummy code names for new plugin structure
ladc Jan 28, 2025
cb89616
Change postprocessor interface to use diagnostic_ and ensemblestat_ p…
ladc Jan 28, 2025
f9bf9cf
Fix the postprocessing interface
Jan 29, 2025
13922fc
Update postprocessing package to work with plugins
Jan 29, 2025
cb2529c
Update both io interface and postprocessign interface
Jan 30, 2025
c1c4817
Merge branch 'master' into postprocessor_plugin
FelixE91 Feb 25, 2025
198df51
Reformatted files with pre-commit
Feb 25, 2025
8c905f2
Merge branch 'postprocessor_plugin' of github.com:pySTEPS/pysteps int…
Feb 25, 2025
1305e88
Remove tests for diagnostics plugins interfaces.
ladc Feb 25, 2025
296694d
Fix io.interface to work with the new cookiecutter
Feb 26, 2025
b45c9a7
Merge branch 'master' into postprocessor_plugin
ladc Feb 26, 2025
a20ce17
Test if the default cookiecutter plugin can be loaded in the CI tests.
ladc Feb 26, 2025
a4e0b6a
Fix default plugin path in tox.ini.
ladc Feb 26, 2025
f340085
Fix plugin tests to use new cookiecutter template; try out importer a…
ladc Feb 26, 2025
06d181a
Added postprocessing module interface test
Feb 27, 2025
17397e5
Postprocessing interface reformatted with pre-commit
Feb 27, 2025
f5df5a8
Cleaning as requested in ladc's review
Feb 27, 2025
ccbb41f
Fix postprocessing interface test
Feb 27, 2025
db83773
Simplift postprocessing.interface and add more tests for it
Feb 27, 2025
fa97a35
Fix postprocessing.interface test to match expected warning
Feb 27, 2025
444331a
Bug fixes to run tests
Feb 27, 2025
657e56e
more test - codecov
Feb 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions ci/test_plugin_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,11 @@
from pysteps import io

print("Testing plugin support: ", end="")
assert hasattr(io.importers, "import_abc_xxx")
assert hasattr(io.importers, "import_abc_yyy")
assert hasattr(io.importers, "import_institution_name")

assert "abc_xxx" in io.interface._importer_methods
assert "abc_yyy" in io.interface._importer_methods
assert "institution_name" in io.interface._importer_methods

from pysteps.io.importers import import_abc_xxx, import_abc_yyy

import_abc_xxx("filename")
import_abc_yyy("filename")
from pysteps.io.importers import import_institution_name
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should similar lines be added to test the diagnostics plugin? Or not needed here in ci?


import_institution_name("filename")
print("PASSED")
1 change: 1 addition & 0 deletions pysteps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,4 @@ def load_config_file(params_file=None, verbose=False, dryrun=False):

# After the sub-modules are loaded, register the discovered importers plugin.
io.interface.discover_importers()
postprocessing.interface.discover_postprocessors()
28 changes: 17 additions & 11 deletions pysteps/io/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,8 @@
"""
import importlib

from importlib.metadata import entry_points

from pysteps import io
from pysteps.decorators import postprocess_import
from pysteps.io import importers, exporters
from pysteps.io import importers, exporters, interface
from pprint import pprint

_importer_methods = dict(
Expand Down Expand Up @@ -49,7 +46,16 @@ def discover_importers():
The importers found are added to the `pysteps.io.interface_importer_methods`
dictionary containing the available importers.
"""
for entry_point in entry_points(group="pysteps.plugins.importers"):
# The pkg resources needs to be reload to detect new packages installed during
# the execution of the python application. For example, when the plugins are
# installed during the tests
import pkg_resources

importlib.reload(pkg_resources)

for entry_point in pkg_resources.iter_entry_points(
group="pysteps.plugins.importer", name=None
):
_importer = entry_point.load()

importer_function_name = _importer.__name__
Expand All @@ -63,14 +69,14 @@ def discover_importers():
RuntimeWarning(
f"The importer identifier '{importer_short_name}' is already available in"
"'pysteps.io.interface._importer_methods'.\n"
f"Skipping {entry_point.module}:{entry_point.attr}"
f"Skipping {entry_point.module_name}:{entry_point.attrs}"
)

if hasattr(importers, importer_function_name):
RuntimeWarning(
f"The importer function '{importer_function_name}' is already an attribute"
"of 'pysteps.io.importers`.\n"
f"Skipping {entry_point.module}:{entry_point.attr}"
f"Skipping {entry_point.module_name}:{entry_point.attrs}"
)
else:
setattr(importers, importer_function_name, _importer)
Expand All @@ -81,22 +87,22 @@ def importers_info():

# Importers available in the `io.importers` module
available_importers = [
attr for attr in dir(io.importers) if attr.startswith("import_")
attr for attr in dir(importers) if attr.startswith("import_")
]

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

# Importers declared in the pysteps.io.get_method interface
importers_in_the_interface = [
f.__name__ for f in io.interface._importer_methods.values()
f.__name__ for f in interface._importer_methods.values()
]

print("\nImporters available in the pysteps.io.get_method interface")
pprint(
[
(short_name, f.__name__)
for short_name, f in io.interface._importer_methods.items()
for short_name, f in interface._importer_methods.items()
]
)

Expand All @@ -107,7 +113,7 @@ def importers_info():

difference = available_importers ^ importers_in_the_interface
if len(difference) > 0:
print("\nIMPORTANT:")
# print("\nIMPORTANT:")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete?

_diff = available_importers - importers_in_the_interface
if len(_diff) > 0:
print(
Expand Down
3 changes: 3 additions & 0 deletions pysteps/postprocessing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
"""Methods for post-processing of forecasts."""

from . import ensemblestats
from .diagnostics import *
from .interface import *
from .ensemblestats import *
22 changes: 22 additions & 0 deletions pysteps/postprocessing/diagnostics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""
pysteps.postprocessing.diagnostics
====================

Methods for applying diagnostics postprocessing.

The methods in this module implement the following interface::

diagnostic_xxx(optional arguments)

where **xxx** is the name of the diagnostic to be applied.

Available Diagnostics Postprocessors
------------------------

.. autosummary::
:toctree: ../generated/

"""

# Add your diagnostic_ function here AND add this method to the _diagnostics_methods
# dictionary in postprocessing.interface.py
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's see how we proceed with this file. I think we must keep it as mention in Originally posted by @ladc in #405 (comment)

254 changes: 254 additions & 0 deletions pysteps/postprocessing/interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
"""
pysteps.postprocessing.interface
====================

Interface for the postprocessing module.

Support postprocessing types:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added that we currently support these two types of postprocessing plugins.
We might need to add some tests for the ensemblestats type later.

Note that we have removed the ensemblestats type from the cookiecutter at the moment.

- ensmeblestats
- diagnostics

.. currentmodule:: pysteps.postprocessing.interface

.. autosummary::
:toctree: ../generated/

get_method
"""
import importlib

from pysteps.postprocessing import diagnostics, ensemblestats
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codacy Static Code Analysis does not like the import of diagnostics as it isn't explicitly called. However, it is needed to add diagnostics plugins as globals()[module] in add_postprocessor() needs the diagnostics module to be imported.

from pprint import pprint
import warnings

_diagnostics_methods = dict()
Copy link
Contributor

@FelixE91 FelixE91 Feb 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is still a discrepancy here, as the dictionary in the io.interface is called _importer_methods, i.e. without “s” on importer. However, I would leave it as it is, as the use of plural is consistent with the module name.


_ensemblestats_methods = dict(
mean=ensemblestats.mean,
excprob=ensemblestats.excprob,
banddepth=ensemblestats.banddepth,
)


def add_postprocessor(
postprocessors_function_name, _postprocessors, module, attributes
):
"""
Add the postprocessor to the appropriate _methods dictionary and to the module.
Parameters
----------

postprocessors_function_name: str
for example, e.g. diagnostic_example1
_postprocessors: function
the function to be added
@param module: the module where the function is added, e.g. 'diagnostics'
@param attributes: the existing functions in the selected module
"""
# the dictionary where the function is added
methods_dict = (
_diagnostics_methods if "diagnostic" in module else _ensemblestats_methods
)

# get funtion name without mo
short_name = postprocessors_function_name.replace(f"{module}_", "")
if short_name not in methods_dict:
methods_dict[short_name] = _postprocessors
else:
warnings.warn(
f"The {module} identifier '{short_name}' is already available in "
f"'pysteps.postprocessing.interface_{module}_methods'.\n"
f"Skipping {module}:{'.'.join(attributes)}",
RuntimeWarning,
)

if hasattr(globals()[module], postprocessors_function_name):
warnings.warn(
f"The {module} function '{short_name}' is already an attribute"
f"of 'pysteps.postprocessing.{module}'.\n"
f"Skipping {module}:{'.'.join(attributes)}",
RuntimeWarning,
)
else:
setattr(globals()[module], postprocessors_function_name, _postprocessors)


def discover_postprocessors():
"""
Search for installed postprocessing plugins in the entrypoint 'pysteps.plugins.postprocessors'

The postprocessors found are added to the appropriate `_methods`
dictionary in 'pysteps.postprocessing.interface' containing the available postprocessors.
"""

# The pkg resources needs to be reloaded to detect new packages installed during
# the execution of the python application. For example, when the plugins are
# installed during the tests
import pkg_resources

importlib.reload(pkg_resources)

# Discover the postprocessors available in the plugins
for plugintype in ["diagnostic", "ensemblestat"]:
for entry_point in pkg_resources.iter_entry_points(
group=f"pysteps.plugins.{plugintype}", name=None
):
_postprocessors = entry_point.load()

postprocessors_function_name = _postprocessors.__name__

if plugintype in entry_point.module_name:
add_postprocessor(
postprocessors_function_name,
_postprocessors,
f"{plugintype}s",
entry_point.attrs,
)


def print_postprocessors_info(module_name, interface_methods, module_methods):
"""
Helper function to print the postprocessors available in the module and in the interface.

Parameters
----------
module_name: str
Name of the module, for example 'pysteps.postprocessing.diagnostics'.
interface_methods: dict
Dictionary of the postprocessors declared in the interface, for example _diagnostics_methods.
module_methods: list
List of the postprocessors available in the module, for example 'diagnostic_example1'.

"""
print(f"\npostprocessors available in the {module_name} module")
pprint(module_methods)

print(
"\npostprocessors available in the pysteps.postprocessing.get_method interface"
)
pprint([(short_name, f.__name__) for short_name, f in interface_methods.items()])

module_methods_set = set(module_methods)
interface_methods_set = set(interface_methods.keys())

difference = module_methods_set ^ interface_methods_set
if len(difference) > 0:
# print("\nIMPORTANT:")
_diff = module_methods_set - interface_methods_set
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we want to avoid differences, it should not be a problem that these lines with if len(difference) > 0 are not considered in the tests - this means that the plugin implementation works well.

if len(_diff) > 0:
print(

Check warning on line 140 in pysteps/postprocessing/interface.py

View check run for this annotation

Codecov / codecov/patch

pysteps/postprocessing/interface.py#L138-L140

Added lines #L138 - L140 were not covered by tests
f"\nIMPORTANT:\nThe following postprocessors are available in {module_name} module but not in the pysteps.postprocessing.get_method interface"
)
pprint(_diff)
_diff = interface_methods_set - module_methods_set
if len(_diff) > 0:
print(

Check warning on line 146 in pysteps/postprocessing/interface.py

View check run for this annotation

Codecov / codecov/patch

pysteps/postprocessing/interface.py#L143-L146

Added lines #L143 - L146 were not covered by tests
"\nWARNING:\n"
f"The following postprocessors are available in the pysteps.postprocessing.get_method interface but not in the {module_name} module"
)
pprint(_diff)

Check warning on line 150 in pysteps/postprocessing/interface.py

View check run for this annotation

Codecov / codecov/patch

pysteps/postprocessing/interface.py#L150

Added line #L150 was not covered by tests


def postprocessors_info():
"""Print all the available postprocessors."""

available_postprocessors = set()
postprocessors_in_the_interface = set()
# List the plugins that have been added to the postprocessing.[plugintype] module
for plugintype in ["diagnostics", "ensemblestats"]:
# in the dictionary and found by get_methods() function
interface_methods = (
_diagnostics_methods
if plugintype == "diagnostics"
else _ensemblestats_methods
)
# in the pysteps.postprocessing module
module_name = f"pysteps.postprocessing.{plugintype}"
available_module_methods = [
attr
for attr in dir(importlib.import_module(module_name))
if attr.startswith(plugintype[:-1])
]
# add the pre-existing ensemblestats functions (see _ensemblestats_methods above)
# that do not follow the convention to start with "ensemblestat_" as the plugins
if "ensemblestats" in plugintype:
available_module_methods += [
em
for em in _ensemblestats_methods.keys()
if not em.startswith("ensemblestat_")
]
print_postprocessors_info(
module_name, interface_methods, available_module_methods
)
available_postprocessors = available_postprocessors.union(
available_module_methods
)
postprocessors_in_the_interface = postprocessors_in_the_interface.union(
interface_methods.keys()
)

return available_postprocessors, postprocessors_in_the_interface


def get_method(name, method_type):
"""
Return a callable function for the method corresponding to the given
name.

Parameters
----------
name: str
Name of the method. The available options are:\n

diagnostics:
[nothing pre-installed]

ensemblestats:
pre-installed: mean, excprob, banddepth

Additional options might exist if plugins are installed.

method_type: {'diagnostics', 'ensemblestats'}
Type of the method (see tables above).

"""

if isinstance(method_type, str):
method_type = method_type.lower()
else:
raise TypeError(
"Only strings supported for for the method_type"
+ " argument\n"
+ "The available types are: 'diagnostics', 'ensemblestats'"
) from None

if isinstance(name, str):
name = name.lower()
else:
raise TypeError(
"Only strings supported for the method's names.\n"
+ "\nAvailable diagnostics names:"
+ str(list(_diagnostics_methods.keys()))
+ "\nAvailable ensemblestats names:"
+ str(list(_ensemblestats_methods.keys()))
) from None

if method_type == "diagnostics":
methods_dict = _diagnostics_methods
elif method_type == "ensemblestats":
methods_dict = _ensemblestats_methods
else:
raise ValueError(
"Unknown method type {}\n".format(method_type)
+ "The available types are: 'diagnostics', 'ensemblestats'"
) from None

try:
return methods_dict[name]
except KeyError:
raise ValueError(
"Unknown {} method {}\n".format(method_type, name)
+ "The available methods are:"
+ str(list(methods_dict.keys()))
) from None
Loading