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

Improve test coverage #310

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 20 additions & 7 deletions nomenclature/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from importlib.metadata import version
from pathlib import Path

import yaml

Expand Down Expand Up @@ -46,30 +47,42 @@ def create_yaml_from_xlsx(source, target, sheet_name, col, attrs=None):
).to_yaml(target)


def parse_model_registration(model_registration_file):
def parse_model_registration(
model_registration_file: str | Path, output_directory: str | Path = Path(".")
) -> None:
"""Parses a model registration file and writes the definitions & mapping yaml files

Parameters
----------
source : str, path, file-like object
model_registration_file : str, path, file-like object
Path to xlsx model registration file.
file_name : str
Model-identifier part of the yaml file names.
output_directory : str, path, file-like object
Directory where the model mapping and region file will be saved;
defaults to current working directory
"""
if not isinstance(output_directory, Path):
output_directory = Path(output_directory)

region_aggregregation_mapping = RegionAggregationMapping.from_file(
model_registration_file
)
file_model_name = "".join(
x if (x.isalnum() or x in "._- ") else "_"
for x in region_aggregregation_mapping.model[0]
)
region_aggregregation_mapping.to_yaml(f"{file_model_name}_mapping.yaml")
region_aggregregation_mapping.to_yaml(
output_directory / f"{file_model_name}_mapping.yaml"
)
if native_regions := [
{
region_aggregregation_mapping.model[
0
]: region_aggregregation_mapping.upload_native_regions
}
]:
with open(f"{file_model_name}_regions.yaml", "w") as f:
yaml.dump(native_regions, f)
with open(
(output_directory / f"{file_model_name}_regions.yaml"),
"w",
encoding="utf-8",
) as file:
yaml.dump(native_regions, file)
14 changes: 2 additions & 12 deletions nomenclature/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import ast
from pathlib import Path
from typing import List, Optional

Expand All @@ -12,16 +11,6 @@
cli = click.Group()


class PythonLiteralOption(click.Option):
def type_cast_value(self, ctx, value):
if value is None:
return None
try:
return ast.literal_eval(value)
except Exception:
raise click.BadParameter(value)


@cli.command("validate-yaml")
@click.argument("path", type=click.Path(exists=True, path_type=Path))
def cli_valid_yaml(path: Path):
Expand Down Expand Up @@ -49,7 +38,8 @@ def cli_valid_yaml(path: Path):
@click.option(
"--dimensions",
help="Optional list of dimensions",
cls=PythonLiteralOption,
type=str,
multiple=True,
default=None,
)
def cli_valid_project(
Expand Down
7 changes: 6 additions & 1 deletion nomenclature/codelist.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def check_stray_tag(cls, v: Dict[str, Code]) -> Dict[str, Code]:
return v

@field_validator("mapping")
@classmethod
def check_end_whitespace(
cls, v: Dict[str, Code], info: ValidationInfo
) -> Dict[str, Code]:
Expand All @@ -66,9 +67,13 @@ def check_end_whitespace(
)
return v

def __setitem__(self, key, value):
def __setitem__(self, key: str, value: Code) -> None:
if key in self.mapping:
raise ValueError(f"Duplicate item in {self.name} codelist: {key}")
if not isinstance(value, Code):
raise TypeError("Codelist can only contain Code items")
if key != value.name:
raise ValueError("Key has to be equal to code name")
self.mapping[key] = value

def __getitem__(self, k):
Expand Down
15 changes: 3 additions & 12 deletions nomenclature/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import yaml
from git import Repo
from pydantic import BaseModel, ValidationInfo, field_validator, model_validator
from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator


class CodeListConfig(BaseModel):
Expand All @@ -27,9 +27,8 @@ class Repository(BaseModel):
url: str
hash: str | None = None
release: str | None = None
local_path: Path | None = (
None # defined via the `repository` name in the configuration
)
local_path: Path | None = Field(default=None, validate_default=True)
# defined via the `repository` name in the configuration

@model_validator(mode="after")
@classmethod
Expand Down Expand Up @@ -110,14 +109,6 @@ def check_definitions_repository(
definitions_repos = v.definitions.repos if v.definitions else {}
mapping_repos = {"mappings": v.mappings.repository} if v.mappings else {}
repos = {**definitions_repos, **mapping_repos}
if repos and not v.repositories:
raise ValueError(
(
"If repositories are used for definitions or mappings, they need "
"to be defined under `repositories`"
)
)

for use, repository in repos.items():
if repository not in v.repositories:
raise ValueError((f"Unknown repository '{repository}' in {use}."))
Expand Down
2 changes: 1 addition & 1 deletion nomenclature/processor/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
class Processor(BaseModel, abc.ABC):
@abc.abstractmethod
def apply(self, df: IamDataFrame) -> IamDataFrame:
return
raise NotImplementedError
10 changes: 3 additions & 7 deletions nomenclature/processor/region.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,6 @@ def target_native_region(self) -> str:
"""
return self.rename if self.rename is not None else self.name

def __eq__(self, other: "NativeRegion") -> bool:
return super().__eq__(other)


class CommonRegion(BaseModel):
"""Common region used for model intercomparison.
Expand Down Expand Up @@ -92,9 +89,6 @@ def rename_dict(self):
"rename_dict is only available for single constituent regions"
)

def __eq__(self, other: "CommonRegion") -> bool:
return super().__eq__(other)


class RegionAggregationMapping(BaseModel):
"""Hold information for region processing on a per-model basis.
Expand Down Expand Up @@ -413,7 +407,9 @@ def __eq__(self, other: "RegionAggregationMapping") -> bool:
return self.model_dump(exclude={"file"}) == other.model_dump(exclude={"file"})

def to_yaml(self, file) -> None:
dict_representation = {"model": self.model}
dict_representation = {
"model": self.model[0] if len(self.model) == 1 else self.model
}
if self.native_regions:
dict_representation["native_regions"] = [
{native_region.name: native_region.rename}
Expand Down
5 changes: 1 addition & 4 deletions nomenclature/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,7 @@ def assert_valid_structure(
f"Definitions directory not found: {path / definitions}"
)

if dimensions == []: # if "dimensions" were specified as "[]"
raise ValueError("No dimensions to validate.")

if dimensions is None: # if "dimensions" were not specified
if dimensions == (): # if "dimensions" were not specified
dimensions = [x.stem for x in (path / definitions).iterdir() if x.is_dir()]
if not dimensions:
raise FileNotFoundError(
Expand Down
Binary file not shown.
12 changes: 12 additions & 0 deletions tests/data/nomenclature_configs/hash_and_release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
repositories:
common-definitions:
url: https://github.com/IAMconsortium/common-definitions.git/
hash: asdf
release: "1.0"
definitions:
region:
repository: common-definitions
country: true
variable:
repository: common-definitions
repository_dimension_path: definitions/variable
3 changes: 3 additions & 0 deletions tests/data/nomenclature_configs/unknown_repo.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
definitions:
region:
repository: common-definitions
79 changes: 60 additions & 19 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import subprocess
import sys

from click.testing import CliRunner
from nomenclature import cli
from nomenclature.testing import assert_valid_yaml, assert_valid_structure
import pytest
import pandas as pd
import pydantic

import pytest
from click.testing import CliRunner
from conftest import TEST_DATA_DIR
from pandas.testing import assert_frame_equal
from pyam import IAMC_IDX, IamDataFrame, assert_iamframe_equal

from nomenclature import cli
from nomenclature.testing import assert_valid_structure, assert_valid_yaml

runner = CliRunner()

Expand Down Expand Up @@ -156,7 +159,11 @@ def test_cli_custom_dimensions_runs():
"validate-project",
str(TEST_DATA_DIR / "non-default_dimensions"),
"--dimensions",
"['variable', 'region', 'scenario']",
"variable",
"--dimensions",
"region",
"--dimensions",
"scenario",
],
)
assert result_valid.exit_code == 0
Expand All @@ -172,7 +179,7 @@ def test_cli_custom_dimensions_fails():
"validate-project",
str(TEST_DATA_DIR / "non-default_dimensions"),
"--dimensions",
"['variable', 'region', 'foo']",
"foo",
],
)
assert result_invalid.exit_code == 1
Expand All @@ -190,7 +197,9 @@ def test_cli_empty_dimensions_run():
"validate-project",
str(TEST_DATA_DIR / "non-default_dimensions_one_empty"),
"--dimensions",
"['variable', 'region']",
"variable",
"--dimensions",
"region",
],
)
assert result_valid.exit_code == 0
Expand Down Expand Up @@ -255,19 +264,51 @@ def test_cli_empty_definitions_dir():
assert "`definitions` directory is empty" in str(cli_result.exception)


def test_cli_empty_dimensions():
"""Assert that an error is raised when an empty list is given as dimensions"""

cli_result = runner.invoke(
def test_check_region_aggregation(tmp_path):
IamDataFrame(
pd.DataFrame(
[
["m_a", "s_a", "region_A", "Primary Energy", "EJ/yr", 1, 2],
["m_a", "s_a", "region_B", "Primary Energy", "EJ/yr", 3, 4],
["m_a", "s_a", "World", "Primary Energy", "EJ/yr", 5, 6],
],
columns=IAMC_IDX + [2005, 2010],
)
).to_excel(tmp_path / "data.xlsx")
runner.invoke(
cli,
[
"validate-project",
str(TEST_DATA_DIR / "non-default_dimensions"),
"--dimensions",
"[]",
"check-region-aggregation",
str(tmp_path / "data.xlsx"),
"--workflow-directory",
str(TEST_DATA_DIR / "region_processing"),
"--definitions",
"dsd",
"--mappings",
"partial_aggregation",
"--processed-data",
str(tmp_path / "results.xlsx"),
"--differences",
str(tmp_path / "differences.xlsx"),
],
)

assert cli_result.exit_code == 1
assert isinstance(cli_result.exception, ValueError)
assert "No dimensions to validate." in str(cli_result.exception)
# Check differences
exp_difference = pd.DataFrame(
[
["m_a", "s_a", "World", "Primary Energy", "EJ/yr", 2005, 5, 4, 20.0],
],
columns=IAMC_IDX + ["year", "original", "aggregated", "difference (%)"],
)
assert_frame_equal(
pd.read_excel(tmp_path / "differences.xlsx"), exp_difference, check_dtype=False
)

# Check aggregation result
exp_result = IamDataFrame(
pd.DataFrame(
[["m_a", "s_a", "World", "Primary Energy", "EJ/yr", 5, 6]],
columns=IAMC_IDX + [2005, 2010],
)
)
assert_iamframe_equal(IamDataFrame(tmp_path / "results.xlsx"), exp_result)
19 changes: 15 additions & 4 deletions tests/test_code.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import pytest
from pytest import raises

from nomenclature.code import Code, VariableCode, RegionCode, MetaCode


def test_variable_without_unit_raises():
with pytest.raises(ValueError, match="unit\n.*required"):
with raises(ValueError, match="unit\n.*required"):
VariableCode(name="No unit")


Expand All @@ -26,7 +27,7 @@ def test_variable_alias_setting():
@pytest.mark.parametrize("illegal_key", ["contains-hyphen", "also not allowed", "True"])
def test_illegal_additional_attribute(illegal_key):
match = f"{illegal_key}.*'code1'.*not allowed"
with pytest.raises(ValueError, match=match):
with raises(ValueError, match=match):
Code(name="code1", extra_attributes={illegal_key: True})


Expand Down Expand Up @@ -161,7 +162,7 @@ def test_RegionCode_iso3_code_list_fail():
"IBL, ITL, LIC, MLA, BEG, FRT, ANB, GDR, LXB, MNO, NTD, NRW, PRE, EPA, " # noqa
"SWD, CEW, GTR, SOR" # noqa
)
with pytest.raises(ValueError, match=error_pattern):
with raises(ValueError, match=error_pattern):
RegionCode(name="Western Europe", hierarchy="R5OECD", iso3_codes=iso3_codes)


Expand All @@ -171,7 +172,7 @@ def test_RegionCode_iso3_code_str_fail():
"iso3_codes\n"
" Value error, Region 'Austria' has invalid ISO3 country code\(s\): AUTT"
)
with pytest.raises(ValueError, match=error_pattern):
with raises(ValueError, match=error_pattern):
RegionCode(name="Austria", hierarchy="country", iso3_codes="AUTT")


Expand All @@ -182,3 +183,13 @@ def test_MetaCode_allowed_values_attribute():
)

assert meta.allowed_values == [True]


def test_code_with_multi_key_dict_raises():
with raises(ValueError, match="Code is not a single name-attributes mapping"):
Code.from_dict({"name": "", "illegal second key": ""})


def test_code_with_definition_and_description_raises():
with raises(ValueError, match="Found both 'definition' and 'description'"):
Code.from_dict({"Code": {"definition": "", "description": ""}})
Loading
Loading