diff --git a/src/pytest_regressions/common.py b/src/pytest_regressions/common.py index de2f23d..123c974 100644 --- a/src/pytest_regressions/common.py +++ b/src/pytest_regressions/common.py @@ -20,6 +20,24 @@ def import_error_message(libname: str) -> str: return f"'{libname}' library is an optional dependency and must be installed explicitly when the fixture 'check' is used" +def sort_dict_by_keys(data: MutableMapping[Any, Any]) -> MutableMapping[Any, Any]: + """Recursively sort a dict by its keys, including nested dicts in sequences. + + :param data: The dict to sort. + :return: The sorted dict. + """ + + def _sort_nested(value: Any) -> Any: + if isinstance(value, MutableMapping): + normalized = {k: _sort_nested(v) for k, v in value.items()} + return dict(sorted(normalized.items())) + if isinstance(value, MutableSequence): + return [_sort_nested(item) for item in value] + return value + + return _sort_nested(data) + + def check_text_files( obtained_fn: "os.PathLike[str]", expected_fn: "os.PathLike[str]", diff --git a/src/pytest_regressions/data_regression.py b/src/pytest_regressions/data_regression.py index 8252cef..0cd44d8 100644 --- a/src/pytest_regressions/data_regression.py +++ b/src/pytest_regressions/data_regression.py @@ -1,3 +1,4 @@ +import json import os from collections.abc import Callable from collections.abc import MutableMapping @@ -13,6 +14,7 @@ from .common import check_text_files from .common import perform_regression_check from .common import round_digits_in_data +from .common import sort_dict_by_keys if TYPE_CHECKING: from pytest_datadir.plugin import LazyDataDir @@ -41,6 +43,9 @@ def check( basename: str | None = None, fullpath: Optional["os.PathLike[str]"] = None, round_digits: int | None = None, + extension: str = ".yml", + *, + indent: int = 2, ) -> None: """ Checks the given dict against a previously recorded version, or generate a new file. @@ -58,6 +63,10 @@ def check( :param round_digits: If given, round all floats in the dict to the given number of digits. + :param extension: Extension of the file. Defaults to ".yml". + If equal to ".json", expects `data_dict` to be JSON serializable + and dumps it using standard `json.dump`. + ``basename`` and ``fullpath`` are exclusive. """ __tracebackhide__ = True @@ -65,19 +74,32 @@ def check( if round_digits is not None: round_digits_in_data(data_dict, round_digits) + data_dict = sort_dict_by_keys(data_dict) + def dump(filename: Path) -> None: """Dump dict contents to the given filename""" - - dumped_str = yaml.dump_all( - [data_dict], - Dumper=RegressionYamlDumper, - default_flow_style=False, - allow_unicode=True, - indent=2, - encoding="utf-8", - ) - with filename.open("wb") as f: - f.write(dumped_str) + if extension.lower() in [".yml", ".yaml"]: + dumped_str = yaml.dump_all( + [data_dict], + Dumper=RegressionYamlDumper, + default_flow_style=False, + allow_unicode=True, + indent=indent, + encoding="utf-8", + ) + with filename.open("wb") as f: + f.write(dumped_str) + elif extension.lower() == ".json": + dumped_str = json.dumps( + data_dict, indent=indent, sort_keys=True, ensure_ascii=False + ) + with filename.open("w", encoding="utf-8") as f: + f.write(dumped_str) + else: + raise NotImplementedError( + f"file extension `{extension}` is not supported by data_regression; " + "supported extensions are '.yml', '.yaml', '.json'" + ) perform_regression_check( datadir=self.datadir, @@ -85,7 +107,7 @@ def dump(filename: Path) -> None: request=self.request, check_fn=partial(check_text_files, encoding="UTF-8"), dump_fn=dump, - extension=".yml", + extension=extension, basename=basename, fullpath=fullpath, force_regen=self.force_regen, diff --git a/tests/test_data_regression.py b/tests/test_data_regression.py index f3c3aa3..beb62f1 100644 --- a/tests/test_data_regression.py +++ b/tests/test_data_regression.py @@ -1,23 +1,45 @@ +import json import sys from textwrap import dedent import pytest import yaml +from pytest_regressions.common import sort_dict_by_keys from pytest_regressions.data_regression import DataRegressionFixture from pytest_regressions.testing import check_regression_fixture_workflow -def test_example(data_regression: DataRegressionFixture) -> None: +def test_sort_dict_by_keys_matches_json_sort_keys() -> None: + contents = { + "z": [{"b": 2, "a": 1}, {"k": 0, "inner": [{"d": 4, "c": 3}]}], + "a": {"y": 2, "x": 1}, + "m": [{"beta": 2, "alpha": 1}, ["keep", {"bb": 2, "aa": 1}]], + } + + sorted_contents = sort_dict_by_keys(contents) + + assert list(sorted_contents) == ["a", "m", "z"] + assert list(sorted_contents["z"][0]) == ["a", "b"] + assert list(sorted_contents["z"][1]["inner"][0]) == ["c", "d"] + assert list(sorted_contents["m"][0]) == ["alpha", "beta"] + assert list(sorted_contents["m"][1][1]) == ["aa", "bb"] + + assert json.dumps(sorted_contents) == json.dumps(contents, sort_keys=True) + + +@pytest.mark.parametrize("extension", [".yml", ".json"]) +def test_example(data_regression: DataRegressionFixture, extension: str) -> None: """Basic example""" contents = {"contents": "Foo", "value": 11} - data_regression.check(contents) + data_regression.check(contents, extension=extension) -def test_basename(data_regression: DataRegressionFixture) -> None: +@pytest.mark.parametrize("extension", [".yml", ".json"]) +def test_basename(data_regression: DataRegressionFixture, extension: str) -> None: """Basic example using basename parameter""" contents = {"contents": "Foo", "value": 11} - data_regression.check(contents, basename="case.normal") + data_regression.check(contents, basename="case.normal", extension=extension) def test_integer_keys(data_regression: DataRegressionFixture) -> None: @@ -51,14 +73,15 @@ def dump_scalar(dumper, scalar): data_regression.check(contents) -def test_round_digits(data_regression: DataRegressionFixture) -> None: +@pytest.mark.parametrize("extension", [".yml", ".json"]) +def test_round_digits(data_regression: DataRegressionFixture, extension: str) -> None: """Example including float numbers and check rounding capabilities.""" contents = { "content": {"value1": "toto", "value": 1.123456789}, "values": [1.12345, 2.34567], "value": 1.23456789, } - data_regression.check(contents, round_digits=2) + data_regression.check(contents, round_digits=2, extension=extension) with pytest.raises(AssertionError): contents = { @@ -66,7 +89,7 @@ def test_round_digits(data_regression: DataRegressionFixture) -> None: "values": [1.13456, 2.45678], "value": 1.23456789, } - data_regression.check(contents, round_digits=2) + data_regression.check(contents, round_digits=2, extension=extension) def test_usage_workflow(pytester, monkeypatch): @@ -101,21 +124,56 @@ def get_yaml_contents(): ) -def test_data_regression_full_path(pytester, tmp_path): +def test_usage_workflow_json(pytester, monkeypatch): + import json + + monkeypatch.setattr( + sys, "testing_get_data", lambda: {"contents": "Foo", "value": 10}, raising=False + ) + source = """ + import sys + def test_1(data_regression) -> None: + contents = sys.testing_get_data() + data_regression.check(contents, extension=".json") + """ + + def get_json_contents(): + json_filename = pytester.path / "test_file" / "test_1.json" + assert json_filename.is_file() + with json_filename.open() as f: + return json.load(f) + + check_regression_fixture_workflow( + pytester, + source=source, + data_getter=get_json_contents, + data_modifier=lambda: monkeypatch.setattr( + sys, + "testing_get_data", + lambda: {"contents": "Bar", "value": 20}, + raising=False, + ), + expected_data_1={"contents": "Foo", "value": 10}, + expected_data_2={"contents": "Bar", "value": 20}, + ) + + +@pytest.mark.parametrize("extension", [".yml", ".json"]) +def test_data_regression_full_path(pytester, tmp_path, extension): """ Test data_regression with ``fullpath`` parameter. """ - fullpath = tmp_path.joinpath("full/path/to/contents.yaml") + fullpath = tmp_path.joinpath(f"full/path/to/contents{extension}") fullpath.parent.mkdir(parents=True) assert not fullpath.is_file() source = """ def test(data_regression) -> None: contents = {'data': [1, 2]} - data_regression.check(contents, fullpath=%s) - """ % (repr(str(fullpath))) + data_regression.check(contents, fullpath=%s, extension=%r) + """ % (repr(str(fullpath)), extension) pytester.makepyfile(test_foo=source) - # First run fails because there's no yml file yet + # First run fails because there's no expected file yet result = pytester.inline_run() result.assertoutcome(failed=1) @@ -210,6 +268,14 @@ def __init__(self, value, unit): assert not yaml_file.is_file() +def test_unsupported_extension(data_regression): + data = {"foo": "bar"} + with pytest.raises( + NotImplementedError, match=r"file extension `\.txt` is not supported" + ): + data_regression.check(data, extension=".txt") + + def test_regen_all(pytester, tmp_path): source = """ def test_1(data_regression) -> None: diff --git a/tests/test_data_regression/case.normal.json b/tests/test_data_regression/case.normal.json new file mode 100644 index 0000000..573d49b --- /dev/null +++ b/tests/test_data_regression/case.normal.json @@ -0,0 +1,4 @@ +{ + "contents": "Foo", + "value": 11 +} diff --git a/tests/test_data_regression/test_example__json_.json b/tests/test_data_regression/test_example__json_.json new file mode 100644 index 0000000..573d49b --- /dev/null +++ b/tests/test_data_regression/test_example__json_.json @@ -0,0 +1,4 @@ +{ + "contents": "Foo", + "value": 11 +} diff --git a/tests/test_data_regression/test_example.yml b/tests/test_data_regression/test_example__yml_.yml similarity index 100% rename from tests/test_data_regression/test_example.yml rename to tests/test_data_regression/test_example__yml_.yml diff --git a/tests/test_data_regression/test_round_digits__json_.json b/tests/test_data_regression/test_round_digits__json_.json new file mode 100644 index 0000000..a7593a6 --- /dev/null +++ b/tests/test_data_regression/test_round_digits__json_.json @@ -0,0 +1,11 @@ +{ + "content": { + "value": 1.12, + "value1": "toto" + }, + "value": 1.23, + "values": [ + 1.12, + 2.35 + ] +} diff --git a/tests/test_data_regression/test_round_digits.yml b/tests/test_data_regression/test_round_digits__yml_.yml similarity index 100% rename from tests/test_data_regression/test_round_digits.yml rename to tests/test_data_regression/test_round_digits__yml_.yml