Freva: Plugin Development¶

freva dev

Martin Bergemann, Bijan Fallah, Andrej Fast, Mostafa Hadizadeh, Christopher Kadow, Etor E. Lucio-Eceiza, Felix Örtel, Manuel Reis, and many others…

Deutsches Klimarechenzentrum @ CLINT in Data Analysis Dpt. (+ Data Management Dpt.*)

Overview of a Freva plugin¶

Why should I build a Freva plugin?¶

It allows you to be part of "The Freva Cycle"

parts

Structure of a Plugin¶

There are 3 main building blocks in a plugin:

  • Wrapper: connects the tool to Freva
  • Tool: scientific code
  • Installer: they install the necessary libraries
parts

Structure of a Plugin: The Wrapper¶

The connection to Freva:

  • Written in python 3
  • Needs to follow a series of standards
parts

Structure of a Plugin: The tool¶

The scientific code:

  • Written in any* language (python, R, C, FORTRAN, a mix...)
  • A single script or multiple scripts and/or folders (subroutines, libraries, etc.).
parts
*caveat: we encourage free software

Structure of a Plugin: Installer¶

Installs the plugin environment:

  • Usually: Makefile (instructions) + .yaml file (libraries)
  • It can also be more elaborated: e.g. binary compilation, installation of subroutines...
parts

NOTE: It replaces the dependencies on the HPC (e.g. modules) and makes it more transferable to other e.g. institutions

Git and Gitlab¶

What is git? freva dev

Is a free and open source Distributed Version Control System:

  • Version Control System: a system that tracks and provides control over changes to source code, documentation and configuration files.
  • Distributed: a form of version control in which the complete codebase, including its full history, is mirrored on every developer's computer

What is gitlab? freva dev

  • GitLab is a web-based tool: provides version control, issue tracking, CI/CD etc.
  • DKRZ hosts its own framework at https://gitlab.dkrz.de
  • freva dev

    Gitlab and Freva:¶

    • One central local repository for the plugged plugins
    • Other local repositories for different developers
    • One remote repository for the plugin at gitlab freva dev

    Gitlab and Freva: location of the plugins¶

    • Under https://gitlab.dkrz.de/freva/plugins4freva project: freva dev
    • Other projects have also their own, e.g.:
      • climxtreme: https://gitlab.dkrz.de/bm1159/plugins4freva
      • regiklim-nukleus: https://gitlab.dkrz.de/ch1187/plugins4freva
    • some plugins can be found in different projects (see forks).

    Gitlab and Freva: cloning a repository¶

    To download a repository there are 2 ways: via https and via ssh: freva dev

    • https: straightforward, requires to enter (dkrz) username and password (usually).
    • ssh: convenient, requires to provide a ssh key previously.

    In both cases:

    $ ssh <username>@levante.dkrz.de
    > cd /path/to/my/work/
    > git clone https://gitlab.dkrz.de/freva/plugins4freva/plugin_workshop/example_plugin.git
    

    Gitlab and Freva: setting your SSH key¶

    1. generate a ssh a private and public (.pub) key locally (e.g. laptop or levante):
    $ ssh-keygen -t ed25519
    $ cd ~/.ssh/
    $ ls
    id_ed25519  id_ed25519.pub
    
    1. copy the public key:
    $ cat id_ed25519.pub
    ssh-ed25519 AAAAC3NzaC1lFDI1YTE5AAAAICUqpXx8AyrNH7HQgPSq9LuB7sTEmXP4ODqOnaHVPPQ6 username@levante
    
    1. add the public key in gitlab (user>preferences>ssh keys): freva dev

    Gitlab and Freva: creating a new project¶

    • If we already had an existing Git repository:
    $ cd NEW_PLUGIN # for example
    $ git remote rename origin old-origin
    $ git remote add origin git@gitlab.dkrz.de:freva/plugins4freva/new_plugin.git
    $ git push -u origin --all
    $ git push -u origin --tags
    
    • If we did not already have an existing Git repository:
    $ cd NEW_PLUGIN # for example
    $ git init
    $ git remote add origin git@gitlab.dkrz.de:freva/plugins4freva/new_plugin.git
    # create your new files and folders
    $ git add .
    $ git commit -m "Initial commit of my new project"
    $ git push -u origin master
    

    Now you could (hypothetically) go to https://gitlab.dkrz.de/freva/plugins4freva/new_plugin

    License of Plugins¶

    What kind of License should I follow?¶

    The Freva code follows a BSD3 license, a permissive open-source license with minimal restrictions:

    • It allows for wide usage, including in proprietary software.
    • it imposes very few restrictions on how the software can be redistributed.

    In principle, any derived product from Freva (i.e. plugins), should follow this license. Unless stated otherwise by the developer.

    What kind of License should I follow?¶

    • We encourage open-source software to foster collaboration within and outside your projects.
    • That said, common courtesy should be expected, e.g.:
      • reference to the first manuscript where the developer used the plugin (the plugin documentation is useful for this!).
      • contacting him/her in case there is no such manuscript.

    Connecting your IDE / Code Editor to a remote session using SSH¶

    1. VS-Code freva dev

    Open a new window in VS Code. There are several ways to connect to a remote session, as indicated in the picture below:

    Image depicting multiple ways to start a remote session in VS Code

    freva dev


    Clicking either of the buttons opens a prompt at the top of the window, giving you several options to start a remote session:

    Image depicting the prompt that opens when clicking one of the &quot;Connect to...&quot; buttons in the new VS Code window

    freva dev


    Click "Connect Current Window to Host..." ("Connect to Host..." opens a new window for the connection).

    You can type in the address of the remote host that you want to connect to or, in case the host is specified in your ssh config file (.ssh/config or ssh_config), select them from the list of hosts. To connect to levante, enter [k-number@]levante.dkrz.de :

    Image depicting the prompt to enter a remote SSH host, with k202195@levante.dkrz.de manually entered

    freva dev


    The first time connecting to a host, you will be prompted to specify the OS of the remote host (i.e. Linux / Windows / macOS).

    Depending on the authentication set up for the remote host, you might be prompted to type in a password. Once the connection has been established, your can open a folder on the remote host, like you would do on your local machine (by pressing Ctrl + K, Ctrl + O or by pressing "Open Folder"):

    Image depicting how to open a folder once connected to the remote host, similar to opening a local folder

    freva dev


    You may be prompted again to type in the password at this point. The window should refresh and give you access to the remote folder that you selected in the previous step, similar to what can be seen in the following image:

    freva dev
    freva dev


    From here you can use the editor to work with remote files basically as you would with files on your local machine. Any terminals opened in this window will also connect to the remote host.

    For detailed documentation on remote development using VS Code, including development on dev containers or WSL, click here.

    2. PyCharm freva dev

    😱 Remote Development is currently in beta and only supported for the Professional Version of PyCharm!

    Open your Plugins (Ctrl+Alt+S) and confirm that "Remote Development Gateway" is installed and that it is enabled:

    Image depicting a list of installed PyCharm plugins, with the &quot;Remote Development Gateway&quot; being installed and enabled.

    freva dev


    To open a remote session in PyCharm, go to the welcome screen and click on the "Remote Development" Tab in the left side bar. This should give you a number of options, including the option to connect to a remote session using SSH:

    Image depiciting the welcome screen of PyCharm Professional, with the &quot;Remote Development&quot; Tab highlighted using red arrows.

    freva dev


    To establish a new SSH Connection, click "New Connection" below the SSH Connection option. This should open the following dialogue:

    Image depicting the dialogue that opens when creating a new SSH connection on PyCharm. Includes fields for the username, the host name or address, and the port that should be used connected to.

    ⚠ Note that PyCharm currently only supports connecting to remote hosts running Linux.

    freva dev


    Again, depending on the type of authentication set up by the remote host, you may be prompted to enter a password to establish the remote connection.

    In the next step (pending the successful connection to the remote host), you will be asked to set the IDE version (i.e. the version of the IDE that will be installed on the remote host) and the project directory (i.e. the directory on the remote host that contains the project files that you will be working with):

    Image depicting the PyCharm window that prompts you to set the IDE Version to be installed on the remote host, as well as the project directory that you will be working in

    freva dev


    Click "Download IDE and Connect". The installation of the selected IDE on the remote host might take a while. Once the installation is complete, a window containing the remote IDE should open.

    Image depicting the remote IDE window, after successfully installing it to the remote host

    freva dev


    The remote IDE gives you the ability to work with project files, much like you would with local project files. Any terminal sessions started in this IDE will also be connected to the remote host.

    For detailed documentation on remote development using PyCharm, click here.

    3. R Studio freva dev

    There is currently only one method of using R Studio on a remote host that is officially supported by R, namely by connecting to an instance of R Studio Server running on the remote host. The previously available alternative, by creating a remote connection in R Studio Desktop Pro, has been removed since version 2023.06.2.

    However, R Studio Server is not available on Levante, so creating a remote development environment instance this way is not possible. However, it is possible to remotely develop and execute R-code by using the VS Code R Extension.

    freva dev


    First, connect to Levante as outlined in the VS Code No description has been provided for this image section of this guide. Once the connection is established, you will want to install the R (REditorSupport.r) and R Debugger (RDebugger.r-debugger) Extensions, as shown below: Image depicting R VS Code Plugins

    freva dev


    Make sure that you have installed the extensions to the remote host, and not your local machine, by going to Extensions (Ctrl+Shift+X):

    Image showing that R VS Code Plugins where installed on Levante

    freva dev


    In a terminal that is connected to levante via ssh (e.g. in the VS Code window connected to levante), type in the following command:

    $ module load r
    

    The result should be something along the lines of:

    [k202195@levante3 ~]$ module load r
    Autoloading openjdk/17.0.0_35-gcc-11.2.0
    
    Loading r/4.1.2-gcc-11.2.0
      Loading requirement: openjdk/17.0.0_35-gcc-11.2.0
    [k202195@levante3 ~]$
    
    freva dev


    We'll need to find out, where R is located on the remote host. We do this by using the command:

    $ which R
    

    The output should be something like:

    [k202195@levante3 ~]$ which R
    /sw/spack-levante/r-4.1.2-eprwea/bin/R
    [k202195@levante3 ~]$
    

    Copy this path to your clipboard.

    freva dev


    Install the httpgd package to embed live R graphics into the VS Code environment. You can do this by running the following commands in a terminal on the remote host:

    # Start and R command line session
    $ R
    

    The following command is executed in the R command line session

    install.packages("httpgd")
    
    freva dev


    Open the settings (Ctrl + ,) and type r.rterm.linux in the search bar as shown in the image below. Make sure that you paste the previously copied path into the field, as depicted:

    Image depicting how to set the path for the R Terminal in VS Code

    freva dev


    Once that is done, open any R file on the remote host. Once an R terminal is ready, you could either select the code or put the cursor at the beginning or ending of the code you want to run, press (Ctrl+Enter), and then code will be sent to the active R terminal.

    If you want to run an entire R file, open the file in the editor, and press Ctrl+Shift+S and the file will be sourced in the active R terminal:

    Image depicting some R code being run remotely on Levante and the resulting interactive plots

    freva dev


    For more information on working with R in VS Code, click here.

    What is a Freva Wrapper?¶

    Freva Wrapper Main Classes No description has been provided for this image

    • PluginAbstract Class Functions

    • parameters.py Classes

    • _ConfigWrapper Class


    What is wrapper

    Special Variables:¶

    Variables Description
    USER_BASE_DIR Absolute path to the central directory for this user.
    USER_OUTPUT_DIR Absolute path to where the plugin outputs for this user are stored.
    USER_PLOTS_DIR Absolute path to where the plugin plots for this user are stored.
    USER_CACHE_DIR Absolute path to the cached data (temp data) for this user.
    USER_UID The users’ User Identifier.
    SYSTEM_DATE Current date in the form YYYYMMDD (e.g. 20120130).
    SYSTEM_DATETIME Current date in the form YYYYMMDD_HHmmSS (e.g. 20120130_101123).
    SYSTEM_TIMESTAMP Milliseconds since epoch (i.e. e.g. 1358929581838).
    SYSTEM_RANDOM_UUID A random Universal Unique Identifier (e.g. 912cca21-6364-4f46-9b03-4263410c9899).

    Example:

    output_file='$USER_OUTPUT_DIR/myfile_${SYSTEM_DATETIME}blah.nc'
    

    PluginAbstract Class¶

    Function: class_basedir¶

    class PluginAbstract(abc.ABC):
        """Base class that is used as a template for all Freva plugins.
        Any api wrapper class defining Freva plugins must inherit from this class."""
    
        @deprecated_method("PluginAbstract", "class_basedir")
        def getClassBaseDir(self) -> Optional[str]:
            """Deprecated method for class_basedir."""
    
        def class_basedir(self) -> str:
            """Get absolute path to the module defining the plugin class."""
    

    PluginAbstract Class¶

    Function: call¶

    class PluginAbstract(abc.ABC):
        """Base class that is used as a template for all Freva plugins.
        Any api wrapper class defining Freva plugins must inherit from this class."""
    
        def call(
            self,
            cmd_string: Union[str, list[str]],
            check: bool = True,
            **kwargs,
        ) -> sub.Popen[Any]:
            """Run command with arguments and return a CompletedProcess instance."""
    

    PluginAbstract Class¶

    Function: call and class_basedir - Example¶

    from evaluation_system.api import plugin
    class TemplatePlugin(plugin.PluginAbstract):
        def run_tool(self, config_dict=None)
            self.call(
                f"{self.class_basedir}/main_code.sh {config_dict}"
            )
            # or
            self.call(
                f"{Path(__file__).parent}/main_code.sh {config_dict}"
            )
            # or
            self.call(
                f"{os.getenv(USER_BASE_DIR)}/main_code.sh {config_dict}"
            )
    

    PluginAbstract Class¶

    Function: add_output_to_databrowser¶

    class PluginAbstract(abc.ABC):
        @deprecated_method("PluginAbstract", "add_output_to_databrowser")
        def linkmydata(self, *args, **kwargs):  # pragma: no cover
    
        def add_output_to_databrowser(
            self,
            plugin_output: os.PathLike,
            project: str, # user-<user-id>
            product: str, # project.product
            *,
            model: str = "freva",
            institute: Optional[str] = None,
            ensemble: str = "r1i1p1",
            time_frequency: Optional[str] = None,
            variable: Optional[str] = None,
            experiment: Optional[str] = None,
            index_data: bool = True,
        ) -> Path:
    

    PluginAbstract Class¶

    Function: add_output_to_databrowser - Example¶

    from evaluation_system.api import plugin
    class TemplatePlugin(plugin.PluginAbstract):
        def run_tool(self, config_dict=None)
            self.add_output_to_databrowser(
                plugin_output=config_dict["outputdir"],
                project=config_dict.get("project", "default_project"),
                product=config_dict.get("product", "default_product"),
                model=config_dict.get("model", "default_model"),
                institute=config_dict.get("institute", "default_institute"),
                time_frequency="mon",
            )
    

    How to find it in Freva:¶

    freva databrowser product=project.product project=user-<user-id>
    

    PluginAbstract Class¶

    Function: prepare_output¶

    class PluginAbstract(abc.ABC):
        """Base class that is used as a template for all Freva plugins.
        Any api wrapper class defining Freva plugins must inherit from this class."""
    
        @deprecated_method("PluginAbstract", "prepare_output")
        def prepareOutput(self, *args) -> dict[str, dict[str, str]]:
            """Deprecated method for :class:`prepare_output`. """
    
        def prepare_output(
            self, output_files: Union[str, list[str], dict[str, dict[str, str]]]
        ) -> dict[str, dict[str, str]]:
            """Prepare output for files supposedly created."""
    

    PluginAbstract Class¶

    Function: prepare_output - Example¶

    from evaluation_system.api import plugin
    class TemplatePlugin(plugin.PluginAbstract):
        def run_tool(self, config_dict=None)
            return self.prepare_output(config_dict["outputdir"])
    

    ParameterType Class¶

    class ParameterType(initOrder):
        """Base class for all parameter types.
        All available parameter types inherit from this class. The class creates
        a parameter object holding the following user defined information.
        """
        def __init__(
            self,
            name=None,
            default=None,
            mandatory=False,
            max_items=1,
            item_separator=",",
            regex=None,
            version=1,
            help="No help available.",
            print_format="%s",
            impact=Parameter.Impact.affects_values,
        ):
    

    ParameterDictionary Class¶

    class ParameterDictionary(dict):
        """Directory holding all plugin parameters for a ``Freva`` plugin.
        This class behaves like a built-in ``dict`` with additional features.
        The most prominent feature is that the order of added items is preserved,
        as opposed to a normal build-in ``dict``.
        """
        def __init__(self, *parameters: ParameterType) -> None:
    

    _ConfigWrapper class¶

    class _ConfigWrapper:
        """Convenience class that helps to dynamically set the location of the
        evaluation system config file."""
        _env: str = "EVALUATION_SYSTEM_CONFIG_FILE"
        def __init__(
            self,
            default_file: str,
        ):
            self.default_file = default_file
        def __fspath__(self) -> str:
            return os.environ.get(self._env, self.default_file)
        def __repr__(self) -> str:
            return self.__fspath__()
    

    Freva Wrapper Structure

    • Plugin Details

    • Plugin Parameters

    • Plugin Methods


    wrapper's structure

    Freva Wrapper Structure: 1. Details¶

    from evaluation_system.api import plugin
    class TemplatePlugin(plugin.PluginAbstract):
        __version__ = (0, 0, 0)
        """
        def __version__(self) -> tuple[int, int, int]:
            # 3-value tuple representing the plugin version.
        """
        __short_description__: str = "template plugin"
        __category__ = "something"
        """
        def __category__(self) -> str:
            # Optional category this plugin belongs to.
            return ""
        """
        __tags__ = ["something", "somethingelse"]
        """
        def __tags__(self) -> list[str]:
            # Optional tags, that are the plugin can be described with.
            return [""]
        """
        tool_developer: Optional[str] = "someonename someonefamily"
        __long_description__: Optional[str] = "template plugin long"
    

    Freva Wrapper Structure: 2. Parameters¶

    from evaluation_system.api.parameters import ParameterDictionary, String, Bool
    class TemplatePlugin(plugin.PluginAbstract):
        __parameters__ = ParameterDictionary(
            String(ParameterType),
            """
            Example:
                String(
                    name="string",
                    default="None",
                    help=(
                        "some helps"
                    ),
                )
            """
            Bool(ParameterType),
            """
            Example:
                Bool(
                    name="checkbox",
                    default=True,
                )
            """
        )
    

    Freva Wrapper Structure: 2. Parameters¶

    from evaluation_system.api.parameters import ParameterDictionary, SolrField, InputDirectory, String
    class TemplatePlugin(plugin.PluginAbstract):
        __parameters__ = ParameterDictionary(     
            InputDirectory(String),
            """
            Example:
                InputDirectory(
                    name="inputdirectory",
                ),
            """
            SolrField(String),
            """
            class SolrField(String):
                # A parameter using solr for finding valid values.
                def __init__(
                    self,
                    *args,
                    facet: str,
                    group: int = 1,
                    multiple: bool = False,
                    predefined_facets: Optional[list[str]] = None,
                    editable: bool = True,
                    **kwargs,
                ): 
            """
        )
    

    Freva Wrapper Structure: 2. Parameters¶

    from evaluation_system.api.parameters import ParameterDictionary, String, CacheDirectory, SelectField, Directory
    class TemplatePlugin(plugin.PluginAbstract):
        __parameters__ = ParameterDictionary(
            Directory(String),
            CacheDirectory(Directory),
            SelectField(String),
            """
            class SelectField(String):
                # Select field to select parameter from predefined values.
                def __init__(self, options: dict[str, str], *args, **kwargs):
            Example:
            SelectField(
                options={"first": "1st", "second": "2nd"},
            ),
            """
            Integer(ParameterType),
            """
            Example:
                Integer(
                    name="integer",
                    default=1,
                )
            """
        )
    

    Freva Wrapper Structure: 3. Methods¶

    from evaluation_system.api import plugin
    class TemplatePlugin(plugin.PluginAbstract):
        def run_tool(self, config_dict=None):
            self.call(f"python template_plugin.py arg1 arg2 {config_dict}")
            self.call(f"template_runner.sh {config_dict}")
            self.add_output_to_databrowser(
                output_dir,
                project=config_dict["project"], # user-<user-id>
                product=config_dict["product"], # f"{config_dict["project"]}.{config_dict["project"]}"
                institute=config_dict["institute"],
                model=config_dict["model"],
                experiment=config_dict["experiment"],
                time_frequency=config_dict["time_frequency"],
                variable=config_dict["variable"],
                ensemble=config_dict["ensemble"],
            )
            return self.prepare_output(config_dict["outputdir"])
    

    Recommended: Keep track of errors¶

    from evaluation_system.api import plugin
    import json
    class TemplatePlugin(plugin.PluginAbstract):
        def run_tool(self, config_dict=None):
            json_file = Path(config_dict["cache"]) / "out.json"
            with json_file.open("w") as json_f:
                json.dump(config_dict, json_f, indent=4)
            with errorfile.open("w") as error_f:
                self.call(
                    f"{self.class_basedir}/main_runner.sh {json_file}",
                    stderr=error_f,
                )
    

    Additional: How to deal with JSON files in the main script of plugin¶

    Python:¶

    import json
    import sys
    with open(sys.argv[1]) as f:
        config_dict = json.load(f)
    

    Bash¶

    _jq(){
        jq -r ${1} ${2} | sed "s/null/None/g;s/true/True/g;s/false/False/g"
    
    }
    export CACHE="$(_jq .cache $1)"
    export OUTPUTDIR="$(_jq .outputdir $1)"
    

    Wrapper Illustration¶

    First Wrapper

    Wrapper Illustration¶

    Second Wrapper

    Wrapper Illustration¶

    Third Wrapper

    Wrapper Illustration¶

    Fourth Wrapper

    How To Install All Needed Libraries Of A Plugin¶

    Why the need?¶

    • Formerly, we relied on HPC modules to run plugins' software.
    • Less than ideal when migrating to newer HPC/other institutions!
    • Now, all necessary software installed in a conda/mamba environment at each repo folder.

    Steps to Install Necessary Packages No description has been provided for this image No description has been provided for this image

    • Define an Env YAML file

    • Install env via Conda

    • Activate Environment

    • Alternative: Makefile



    install env

    Define an Env YAML file¶

    e.g. plugin-env.yml:

    channels:
        - conda-forge
    dependencies:
        - r-base
        - pip
    

    Install env via Conda¶

    • The environment must be located at the same level as the wrapper file!
    • The environment must be located named plugin_env/ so Freva recognizes it!
    mamba env create --prefix ./plugin_env -f plugin-env.yml
    

    Activate Environment¶

    conda activate plugin_env/bin/activate
    

    More flexible approach via Makefile¶

    We can define a Makefile as below and then after definition of environment.yml, install all the dependencies listed above via make commands. But first and foremost, we need to ensure, we have make installed on our machine.

    env:
    	@# Installs a conda environment with R and the IDF package
    	@if [ -d "./plugin_env" ]; then \
    		rm -rf ./plugin_env; \
    	fi
    	conda env create --prefix ./plugin_env -f environment.yml --force
    	Rscript -e 'install.packages("IDF", repos="https://CRAN.R-project.org/")'
    cartopy:
    	@# Download various cartopy shape-files
    	@if [ -d "./cartopy" ]; then \
    		rm -rf ./cartopy; \
    	fi
    	./plugin_env/bin/python3 assets/cartopy_download.py physical cultural
    .PHONY: env cartopy
    
    • Using mamba instead of using conda can make the way of installation faster.

    You can take a look at a plugin template here.

    How To Plug a Tool into Freva¶

    Plug a Tool into Freva No description has been provided for this image

    • Plug into Freva via CLI

    • Plug into Freva via Freva-Web



    plug in plugin

    Plug a tool into Freva¶

    plug in plugin
    plug in plugin

    Plug a tool into Freva¶

    In this step we need to clone a plugin in the user directory of freva-dev and replace <username> with your k-number or b-number username:

    ssh <username>@levante.dkrz.de
    #After login to Levante
    cd work/ch1187/clint/freva-dev/share/work/<username>/freva-dev/
    git clone https://gitlab.dkrz.de/freva/plugins4freva/plugin_workshop/example_plugin.git
    

    In this step you need to install the dependencies. Have a look at the previous section (How To Install All Needed Libraries Of A Plugin)

    Plug a tool into Freva¶

    plug in plugin
    plug in plugin

    Plug a tool into Freva¶

    plug in plugin
    plug in plugin
    freva people
    Thanks for your attention!

    Contact: freva@dkrz.de

    Plugin Developer Guide: https://freva-clint.github.io/freva/developers_guide.html

    Workshop GitLab Repository: https://gitlab.dkrz.de/freva/freva_workshop