diff --git a/doc/api/esmvalcore.api.config.rst b/doc/api/esmvalcore.api.config.rst new file mode 100644 index 0000000000..5c694c8d7f --- /dev/null +++ b/doc/api/esmvalcore.api.config.rst @@ -0,0 +1,141 @@ +.. _api_config: + +Configuration +============= + +This section describes the config submodule of the API. + +Config +****** + +Configuration of ESMValCore/Tool is done via the ``Config`` object. +The global configuration can be imported from the ``esmvalcore.experimental`` module as ``CFG``: + +.. code-block:: python + + >>> from esmvalcore.experimental import CFG + >>> CFG + Config({'auxiliary_data_dir': PosixPath('/home/user/auxiliary_data'), + 'compress_netcdf': False, + 'config_developer_file': None, + 'config_file': PosixPath('/home/user/.esmvaltool/config-user.yml'), + 'drs': {'CMIP5': 'default', 'CMIP6': 'default'}, + 'exit_on_warning': False, + 'log_level': 'info', + 'max_parallel_tasks': None, + 'output_dir': PosixPath('/home/user/esmvaltool_output'), + 'output_file_type': 'png', + 'profile_diagnostic': False, + 'remove_preproc_dir': True, + 'rootpath': {'CMIP5': '~/default_inputpath', + 'CMIP6': '~/default_inputpath', + 'default': '~/default_inputpath'}, + 'save_intermediary_cubes': False, + 'write_netcdf': True, + 'write_plots': True}) + +The parameters for the user configuration file are listed `here `__. + +``CFG`` is essentially a python dictionary with a few extra functions, similar to ``matplotlib.rcParams``. +This means that values can be updated like this: + +.. code-block:: python + + >>> CFG['output_dir'] = '~/esmvaltool_output' + >>> CFG['output_dir'] + PosixPath('/home/user/esmvaltool_output') + +Notice that ``CFG`` automatically converts the path to an instance of ``pathlib.Path`` and expands the home directory. +All values entered into the config are validated to prevent mistakes, for example, it will warn you if you make a typo in the key: + +.. code-block:: python + + >>> CFG['otoptu_dri'] = '~/esmvaltool_output' + InvalidConfigParameter: `otoptu_dri` is not a valid config parameter. + +Or, if the value entered cannot be converted to the expected type: + +.. code-block:: python + + >>> CFG['max_years'] = '🐜' + InvalidConfigParameter: Key `max_years`: Could not convert '🐜' to int + +``Config`` is also flexible, so it tries to correct the type of your input if possible: + +.. code-block:: python + + >>> CFG['max_years'] = '123' # str + >>> type(CFG['max_years']) + int + +By default, the config is loaded from the default location (``/home/user/.esmvaltool/config-user.yml``). +If it does not exist, it falls back to the default values. +to load a different file: + +.. code-block:: python + + >>> CFG.load_from_file('~/my-config.yml') + +Or to reload the current config: + +.. code-block:: python + + >>> CFG.reload() + + +Session +******* + +Recipes and diagnostics will be run in their own directories. +This behaviour can be controlled via the ``Session`` object. +A ``Session`` can be initiated from the global ``Config``. + +.. code-block:: python + + >>> session = CFG.start_session(name='my_session') + +A ``Session`` is very similar to the config. +It is also a dictionary, and copies all the keys from the ``Config``. +At this moment, ``session`` is essentially a copy of ``CFG``: + +.. code-block:: python + + >>> print(session == CFG) + True + >>> session['output_dir'] = '~/my_output_dir' + >>> print(session == CFG) # False + False + +A ``Session`` also knows about the directories where the data will stored. +The session name is used to prefix the directories. + +.. code-block:: python + + >>> session.session_dir + /home/user/my_output_dir/my_session_20201203_155821 + >>> session.run_dir + /home/user/my_output_dir/my_session_20201203_155821/run + >>> session.work_dir + /home/user/my_output_dir/my_session_20201203_155821/work + >>> session.preproc_dir + /home/user/my_output_dir/my_session_20201203_155821/preproc + >>> session.plot_dir + /home/user/my_output_dir/my_session_20201203_155821/plots + +Unlike the global configuration, of which only one can exist, multiple sessions can be initiated from the ``Config``. + + +API reference +************* + +.. autoclass:: esmvalcore.experimental.config.CFG + :no-inherited-members: + :no-show-inheritance: + +.. autoclass:: esmvalcore.experimental.config.Config + :no-inherited-members: + :no-show-inheritance: + +.. autoclass:: esmvalcore.experimental.config.Session + :no-inherited-members: + :no-show-inheritance: diff --git a/doc/api/esmvalcore.api.recipe_info.rst b/doc/api/esmvalcore.api.recipe_info.rst new file mode 100644 index 0000000000..e73ceb25af --- /dev/null +++ b/doc/api/esmvalcore.api.recipe_info.rst @@ -0,0 +1,22 @@ +.. _api_recipe_info: + +Recipe Metadata +=============== + +This section describes the :py:mod:`esmvalcore.experimental.recipe_info` submodule of the API. + +Recipe metadata +*************** + +:py:class:`esmvalcore.experimental.recipe_info.RecipeInfo` info is a class that holds metadata from a recipe. + +.. code-block:: python + + >>> RecipeInfo('path/to/my_recipe.yml') + recipe = RecipeInfo('My recipe') + + +API reference +************* + +.. automodule:: esmvalcore.experimental.recipe_info diff --git a/doc/api/esmvalcore.api.rst b/doc/api/esmvalcore.api.rst index 8870ef67e0..0f23a0ebda 100644 --- a/doc/api/esmvalcore.api.rst +++ b/doc/api/esmvalcore.api.rst @@ -7,137 +7,8 @@ This page describes the new ESMValCore API. The API module is available in the submodule ``esmvalcore.experimental``. The API is under development, so use at your own risk! -Config -****** +.. toctree:: -Configuration of ESMValCore/Tool is done via the ``Config`` object. -The global configuration can be imported from the ``esmvalcore.experimental`` module as ``CFG``: - -.. code-block:: python - - >>> from esmvalcore.experimental import CFG - >>> CFG - Config({'auxiliary_data_dir': PosixPath('/home/user/auxiliary_data'), - 'compress_netcdf': False, - 'config_developer_file': None, - 'config_file': PosixPath('/home/user/.esmvaltool/config-user.yml'), - 'drs': {'CMIP5': 'default', 'CMIP6': 'default'}, - 'exit_on_warning': False, - 'log_level': 'info', - 'max_parallel_tasks': None, - 'output_dir': PosixPath('/home/user/esmvaltool_output'), - 'output_file_type': 'png', - 'profile_diagnostic': False, - 'remove_preproc_dir': True, - 'rootpath': {'CMIP5': '~/default_inputpath', - 'CMIP6': '~/default_inputpath', - 'default': '~/default_inputpath'}, - 'save_intermediary_cubes': False, - 'write_netcdf': True, - 'write_plots': True}) - -The parameters for the user configuration file are listed `here `__. - -``CFG`` is essentially a python dictionary with a few extra functions, similar to ``matplotlib.rcParams``. -This means that values can be updated like this: - -.. code-block:: python - - >>> CFG['output_dir'] = '~/esmvaltool_output' - >>> CFG['output_dir'] - PosixPath('/home/user/esmvaltool_output') - -Notice that ``CFG`` automatically converts the path to an instance of ``pathlib.Path`` and expands the home directory. -All values entered into the config are validated to prevent mistakes, for example, it will warn you if you make a typo in the key: - -.. code-block:: python - - >>> CFG['otoptu_dri'] = '~/esmvaltool_output' - InvalidConfigParameter: `otoptu_dri` is not a valid config parameter. - -Or, if the value entered cannot be converted to the expected type: - -.. code-block:: python - - >>> CFG['max_years'] = '🐜' - InvalidConfigParameter: Key `max_years`: Could not convert '🐜' to int - -``Config`` is also flexible, so it tries to correct the type of your input if possible: - -.. code-block:: python - - >>> CFG['max_years'] = '123' # str - >>> type(CFG['max_years']) - int - -By default, the config is loaded from the default location (``/home/user/.esmvaltool/config-user.yml``). -If it does not exist, it falls back to the default values. -to load a different file: - -.. code-block:: python - - >>> CFG.load_from_file('~/my-config.yml') - -Or to reload the current config: - -.. code-block:: python - - >>> CFG.reload() - - -Session -******* - -Recipes and diagnostics will be run in their own directories. -This behaviour can be controlled via the ``Session`` object. -A ``Session`` can be initiated from the global ``Config``. - -.. code-block:: python - - >>> session = CFG.start_session(name='my_session') - -A ``Session`` is very similar to the config. -It is also a dictionary, and copies all the keys from the ``Config``. -At this moment, ``session`` is essentially a copy of ``CFG``: - -.. code-block:: python - - >>> print(session == CFG) - True - >>> session['output_dir'] = '~/my_output_dir' - >>> print(session == CFG) # False - False - -A ``Session`` also knows about the directories where the data will stored. -The session name is used to prefix the directories. - -.. code-block:: python - - >>> session.session_dir - /home/user/my_output_dir/my_session_20201203_155821 - >>> session.run_dir - /home/user/my_output_dir/my_session_20201203_155821/run - >>> session.work_dir - /home/user/my_output_dir/my_session_20201203_155821/work - >>> session.preproc_dir - /home/user/my_output_dir/my_session_20201203_155821/preproc - >>> session.plot_dir - /home/user/my_output_dir/my_session_20201203_155821/plots - -Unlike the global configuration, of which only one can exist, multiple sessions can be initiated from the ``Config``. - - -API reference -************* - -.. autoclass:: esmvalcore.experimental.config.CFG - :no-inherited-members: - :no-show-inheritance: - -.. autoclass:: esmvalcore.experimental.config.Config - :no-inherited-members: - :no-show-inheritance: - -.. autoclass:: esmvalcore.experimental.config.Session - :no-inherited-members: - :no-show-inheritance: + esmvalcore.api.config + esmvalcore.api.recipe_info + esmvalcore.api.utils diff --git a/doc/api/esmvalcore.api.utils.rst b/doc/api/esmvalcore.api.utils.rst new file mode 100644 index 0000000000..554f141484 --- /dev/null +++ b/doc/api/esmvalcore.api.utils.rst @@ -0,0 +1,63 @@ +.. _api_utils: + + + +Utils +===== + +This section describes the utilities submodule of the API. + +Finding recipes +*************** + +One of the first thing we may want to do, is to simply get one of the recipes available in ``ESMValTool`` + +If you already know which recipe you want to load, call :py:func:`esmvalcore.experimental.utils.get_recipe`. + +.. code-block:: python + + from esmvalcore.experimental import get_recipe + >>> get_recipe('examples/recipe_python') + RecipeInfo('Recipe python') + +Call the :py:func:`esmvalcore.experimental.utils.get_all_recipes` function to get a list of all available recipes. + +.. code-block:: python + + >>> from esmvalcore.experimental import get_all_recipes + >>> recipes = get_all_recipes() + >>> recipes + [RecipeInfo('Recipe perfmetrics cmip5 4cds'), + RecipeInfo('Recipe martin18grl'), + ... + RecipeInfo('Recipe wflow'), + RecipeInfo('Recipe pcrglobwb')] + +To search for a specific recipe, you can use the :py:meth:`esmvalcore.experimental.utils.RecipeList.find` method. This takes a search query that looks through the recipe metadata and returns any matches. The query can be a regex pattern, so you can make it as complex as you like. + +.. code-block:: python + + >>> results = recipes.find('climwip') + [RecipeInfo('Recipe climwip')] + +The recipes are loaded in a :py:class:`esmvalcore.experimental.recipe_info.RecipeInfo` object, which knows about the documentation, authors, project, and related references of the recipe. It resolves all the tags, so that it knows which institute an author belongs to and which references are associated with the recipe. + +This means you can search for something like this: + +.. code-block:: python + + >>> recipes.find('Geophysical Research Letters') + [RecipeInfo('Recipe martin18grl'), + RecipeInfo('Recipe climwip'), + RecipeInfo('Recipe ecs constraints'), + RecipeInfo('Recipe ecs scatter'), + RecipeInfo('Recipe ecs'), + RecipeInfo('Recipe seaice')] + + +API reference +************* + +.. automodule:: esmvalcore.experimental.utils + :no-inherited-members: + :no-show-inheritance: diff --git a/doc/requirements.txt b/doc/requirements.txt index 7d67ccad6e..0094f9fec0 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -6,6 +6,7 @@ numpy pandas pillow prov[dot] +pybtex pyyaml scipy shapely[vectorized] diff --git a/esmvalcore/experimental/__init__.py b/esmvalcore/experimental/__init__.py index 5614ce7770..e7078ae06b 100644 --- a/esmvalcore/experimental/__init__.py +++ b/esmvalcore/experimental/__init__.py @@ -8,7 +8,13 @@ '\n More info: https://github.com/ESMValGroup/ESMValCore/issues/498', ) from .config import CFG # noqa: E402 +from .recipe_info import RecipeInfo # noqa: E402 +from .utils import RecipeList, get_all_recipes, get_recipe # noqa: E402 __all__ = [ 'CFG', + 'get_all_recipes', + 'get_recipe', + 'RecipeInfo', + 'RecipeList', ] diff --git a/esmvalcore/experimental/recipe_info.py b/esmvalcore/experimental/recipe_info.py new file mode 100644 index 0000000000..5fe7ac830f --- /dev/null +++ b/esmvalcore/experimental/recipe_info.py @@ -0,0 +1,275 @@ +"""Recipe metadata.""" + +import textwrap +from pathlib import Path + +import pybtex +import yaml +from pybtex.database.input import bibtex + +from esmvalcore._citation import REFERENCES_PATH +from esmvalcore._config import TAGS + + +class RenderError(BaseException): + """Error during rendering of object.""" + + +class Contributor: + """Contains contributor (author or maintainer) information.""" + + def __init__(self, name: str, institute: str, orcid: str = None): + self.name = name + self.institute = institute + self.orcid = orcid + + def __repr__(self) -> str: + """Return canonical string representation.""" + return (f'{self.__class__.__name__}({self.name!r},' + f' institute={self.institute!r}, orcid={self.orcid!r})') + + def __str__(self) -> str: + """Return string representation.""" + string = f'{self.name} ({self.institute}' + if self.orcid: + string += f'; {self.orcid}' + string += ')' + return string + + def _repr_markdown_(self): + """Represent using markdown renderer in a notebook environment.""" + return str(self) + + @classmethod + def from_tag(cls, tag: str): + """Return an instance of Contributor from a tag (``TAGS``). + + Contributors are defined by author tags in ``config- + references.yml``. + """ + mapping = TAGS['authors'][tag] + + name = ' '.join(reversed(mapping['name'].split(', '))) + institute = mapping.get('institute', 'No affiliation') + orcid = mapping['orcid'] + + return cls(name=name, institute=institute, orcid=orcid) + + +class Project: + """Contains project information.""" + + def __init__(self, project: str): + self.project = project + + def __repr__(self) -> str: + """Return canonical string representation.""" + return f'{self.__class__.__name__}({self.project!r})' + + def __str__(self) -> str: + """Return string representation.""" + string = f'{self.project}' + return string + + @classmethod + def from_tag(cls, tag: str): + """Return an instance of Project from a tag (``TAGS``). + + The project tags are defined in ``config-references.yml``. + """ + project = TAGS['projects'][tag] + return cls(project=project) + + +class Reference: + """Contains reference information.""" + + def __init__(self, filename): + parser = bibtex.Parser(strict=False) + bib_data = parser.parse_file(filename) + + if len(bib_data.entries) > 1: + raise NotImplementedError( + f'{self.__class__.__name__} cannot handle bibtex files ' + 'with more than 1 entry.') + + self._bib_data = bib_data + self._key, self._entry = list(bib_data.entries.items())[0] + self._filename = filename + + @classmethod + def from_tag(cls, tag: str): + """Return an instance of Reference from a bibtex tag. + + The bibtex tags resolved as + ``esmvaltool/references/{tag}.bibtex``. + """ + filename = Path(REFERENCES_PATH, f'{tag}.bibtex') + return cls(filename) + + def __repr__(self): + """Return canonical string representation.""" + return f'{self.__class__.__name__}({self._key!r})' + + def __str__(self): + """Return string representation.""" + return self.render(renderer='plaintext') + + def _repr_markdown_(self): + """Represent using markdown renderer in a notebook environment.""" + return self.render(renderer='markdown') + + def render(self, renderer: str = 'plaintext') -> str: + """Render the reference. + + Parameters + ---------- + renderer : str + Choose the renderer for the string representation. + Must be one of: 'plaintext', 'markdown', 'html', 'latex' + + Returns + ------- + str + Rendered reference + """ + style = 'plain' # alpha, plain, unsrt, unsrtalpha + backend = pybtex.plugin.find_plugin('pybtex.backends', renderer)() + style = pybtex.plugin.find_plugin('pybtex.style.formatting', style)() + + try: + formatter = style.format_entry(self._key, self._entry) + rendered = formatter.text.render(backend) + except Exception as err: + raise RenderError( + f'Could not render {self._key!r}: {err}') from None + + return rendered + + +class RecipeInfo(): + """Contains Recipe metadata. + + Parameters + ---------- + path : pathlike + Path to the recipe. + """ + + def __init__(self, path: str): + self.path = Path(path) + if not self.path.exists(): + raise FileNotFoundError(f'Cannot find recipe: `{path}`.') + + self._data = None + self._authors = None + self._maintainers = None + self._projects = None + self._references = None + self._description = None + + def __repr__(self) -> str: + """Return canonical string representation.""" + return f'{self.__class__.__name__}({self.name!r})' + + def _repr_markdown_(self) -> str: + """Represent using markdown renderer in a notebook environment.""" + return self.render('markdown') + + def __str__(self) -> str: + """Return string representation.""" + return self.render('plaintext') + + def to_markdown(self) -> str: + """Return markdown formatted string.""" + return self.render('markdown') + + def render(self, renderer: str = 'plaintext') -> str: + """Return formatted string. + + Parameters + ---------- + renderer : str + Choose the renderer for the string representation. + Must be one of 'plaintext', 'markdown' + """ + bullet = '\n - ' + string = f'## {self.name}' + + string += '\n\n' + string += f'{self.description}' + + string += '\n\n### Authors' + for author in self.authors: + string += f'{bullet}{author}' + + string += '\n\n### Maintainers' + for maintainer in self.maintainers: + string += f'{bullet}{maintainer}' + + if self.projects: + string += '\n\n### Projects' + for project in self.projects: + string += f'{bullet}{project}' + + if self.references: + string += '\n\n### References' + for reference in self.references: + string += bullet + reference.render(renderer) + + string += '\n' + + return string + + @property + def data(self) -> dict: + """Return dictionary representation of the recipe.""" + if self._data is None: + self._data = yaml.safe_load(open(self.path, 'r')) + return self._data + + @property + def name(self) -> str: + """Name of the recipe.""" + return self.path.stem.replace('_', ' ').capitalize() + + @property + def description(self) -> str: + """Recipe description.""" + if self._description is None: + description = self.data['documentation']['description'] + self._description = '\n'.join(textwrap.wrap(description)) + return self._description + + @property + def authors(self) -> tuple: + """List of recipe authors.""" + if self._authors is None: + tags = self.data['documentation']['authors'] + self._authors = tuple(Contributor.from_tag(tag) for tag in tags) + return self._authors + + @property + def maintainers(self) -> tuple: + """List of recipe maintainers.""" + if self._maintainers is None: + tags = self.data['documentation']['maintainer'] + self._maintainers = tuple( + Contributor.from_tag(tag) for tag in tags) + return self._maintainers + + @property + def projects(self) -> tuple: + """List of recipe projects.""" + if self._projects is None: + tags = self.data['documentation'].get('projects', []) + self._projects = tuple(Project.from_tag(tag) for tag in tags) + return self._projects + + @property + def references(self) -> tuple: + """List of project references.""" + if self._references is None: + tags = self.data['documentation'].get('references', []) + self._references = tuple(Reference.from_tag(tag) for tag in tags) + return self._references diff --git a/esmvalcore/experimental/utils.py b/esmvalcore/experimental/utils.py new file mode 100644 index 0000000000..3e2edb8649 --- /dev/null +++ b/esmvalcore/experimental/utils.py @@ -0,0 +1,85 @@ +"""ESMValCore utilities.""" + +import re +from pathlib import Path + +from esmvalcore._config import DIAGNOSTICS_PATH + +from .recipe_info import RecipeInfo + + +class RecipeList(list): + """Container for recipes.""" + + def find(self, query: str): + """Search for recipes matching the search query or pattern. + + This function will search the recipe description, author list, and + project information. + + Parameters + ---------- + query : str + String to search for, e.g. ``find_recipes('righi')`` will return + all matching that author. Can be a `regex` pattern. + + Returns + ------- + RecipeList + List of recipes matching the search query. + """ + query = re.compile(query, flags=re.IGNORECASE) + + matches = RecipeList() + + for recipe in self: + match = re.search(query, str(recipe)) + if match: + matches.append(recipe) + + return matches + + +def get_all_recipes(subdir: str = None) -> list: + """Return a list of all available recipes. + + Parameters + ---------- + subdir : str + Sub-directory of the ``DIAGNOSTICS_PATH`` to look for + recipes, e.g. ``get_all_recipes(subdir='examples')``. + + Returns + ------- + RecipeList + List of available recipes + """ + if not subdir: + subdir = '**' + rootdir = Path(DIAGNOSTICS_PATH, 'recipes') + files = rootdir.glob(f'{subdir}/*.yml') + return RecipeList(RecipeInfo(file) for file in files) + + +def get_recipe(name: str) -> 'RecipeInfo': + """Get a recipe by its name. + + Parameters + ---------- + name : str + Name of the recipe, i.e. ``examples/recipe_python.yml`` + + Returns + ------- + RecipeInfo + """ + locations = Path(), Path(DIAGNOSTICS_PATH, 'recipes') + filenames = name, name + '.yml' + + for location in locations: + for filename in filenames: + try_path = Path(location, filename) + if try_path.exists(): + return RecipeInfo(try_path) + + raise FileNotFoundError(f'Could not find `{name}') diff --git a/package/meta.yaml b/package/meta.yaml index f5ee7471e7..d69e26856e 100644 --- a/package/meta.yaml +++ b/package/meta.yaml @@ -51,6 +51,7 @@ requirements: - numpy - prov - psutil + - pybtex - pydot - pyyaml - requests @@ -82,7 +83,7 @@ test: - esmvalcore.cmor.check - esmvalcore.cmor.fix - esmvalcore.preprocessor - + - esmvalcore.experimental about: home: https://www.esmvaltool.org diff --git a/setup.py b/setup.py index 3554240a63..03e4482cc7 100755 --- a/setup.py +++ b/setup.py @@ -39,6 +39,7 @@ 'numpy', 'prov[dot]', 'psutil', + 'pybtex', 'pyyaml', 'requests', 'scitools-iris>=2.2', diff --git a/tests/unit/experimental/test_recipe_info.py b/tests/unit/experimental/test_recipe_info.py new file mode 100644 index 0000000000..887b70c5f8 --- /dev/null +++ b/tests/unit/experimental/test_recipe_info.py @@ -0,0 +1,48 @@ +import pytest + +from esmvalcore.experimental import get_recipe +from esmvalcore.experimental.recipe_info import Contributor, Project, Reference + +pytest.importorskip( + 'esmvaltool', + reason='The behaviour of these tests depends on what ``DIAGNOSTICS_PATH``' + 'points to. This is defined by a forward-reference to ESMValTool, which' + 'is not installed in the CI, but likely to be available in a developer' + 'or user installation.') + + +def test_contributor(): + """Coverage test for Contributor.""" + contributor = Contributor.from_tag('righi_mattia') + + assert contributor.name == 'Mattia Righi' + assert contributor.institute == 'DLR, Germany' + assert contributor.orcid.startswith('https://orcid.org/') + assert isinstance(repr(contributor), str) + assert isinstance(str(contributor), str) + + +def test_reference(): + """Coverage test for Reference.""" + reference = Reference.from_tag('acknow_project') + + assert isinstance(repr(reference), str) + assert isinstance(str(reference), str) + assert isinstance(reference.render('markdown'), str) + + +def test_project(): + """Coverage test for Project.""" + project = Project.from_tag('esmval') + + assert isinstance(repr(project), str) + assert isinstance(str(project), str) + + +def test_recipe_info(): + """Coverage test for RecipeInfo.""" + recipe = get_recipe('examples/recipe_python') + + assert isinstance(repr(recipe), str) + assert isinstance(str(recipe), str) + assert isinstance(recipe.to_markdown(), str) diff --git a/tests/unit/experimental/test_utils.py b/tests/unit/experimental/test_utils.py new file mode 100644 index 0000000000..33dcc0d14f --- /dev/null +++ b/tests/unit/experimental/test_utils.py @@ -0,0 +1,40 @@ +import pytest + +from esmvalcore.experimental.recipe_info import RecipeInfo +from esmvalcore.experimental.utils import ( + RecipeList, + get_all_recipes, + get_recipe, +) + +pytest.importorskip( + 'esmvaltool', + reason='The behaviour of these tests depends on what ``DIAGNOSTICS_PATH``' + 'points to. This is defined by a forward-reference to ESMValTool, which' + 'is not installed in the CI, but likely to be available in a developer' + 'or user installation.') + + +def test_get_recipe(): + """Get single recipe.""" + recipe = get_recipe('examples/recipe_python.yml') + assert isinstance(recipe, RecipeInfo) + + +def test_get_all_recipes(): + """Get all recipes.""" + recipes = get_all_recipes() + assert isinstance(recipes, list) + + recipes.find('') + + +def test_recipe_list_find(): + """Get all recipes.""" + recipes = get_all_recipes(subdir='examples') + + assert isinstance(recipes, RecipeList) + + result = recipes.find('python') + + assert isinstance(result, RecipeList)