Skip to content
Open
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
18 changes: 18 additions & 0 deletions src/pytest_regressions/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Copy link
Copy Markdown
Member

@nicoddemus nicoddemus Apr 24, 2026

Choose a reason for hiding this comment

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

Why should we always sort the dict keys? Dicts are order preserving, in fact I can think of a few situations where users might want to regress against the original order, without sorting.

Let's remove key sorting altogether from this change, users can sort the dict themselves if they require that, I think.

"""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]",
Expand Down
46 changes: 34 additions & 12 deletions src/pytest_regressions/data_regression.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os
from collections.abc import Callable
from collections.abc import MutableMapping
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -58,34 +63,51 @@ 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`.
Comment on lines +66 to +68
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As I commented in the issue, lets use an enum instead. 👍


``basename`` and ``fullpath`` are exclusive.
"""
__tracebackhide__ = True

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'"
)
Comment on lines +81 to +102
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's use a match against the enum, with an assert_never check for the default case.


perform_regression_check(
datadir=self.datadir,
original_datadir=self.original_datadir,
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,
Expand Down
90 changes: 78 additions & 12 deletions tests/test_data_regression.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -51,22 +73,23 @@ 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 = {
"content": {"value1": "toto", "value": 1.2345678},
"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):
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions tests/test_data_regression/case.normal.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"contents": "Foo",
"value": 11
}
4 changes: 4 additions & 0 deletions tests/test_data_regression/test_example__json_.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"contents": "Foo",
"value": 11
}
11 changes: 11 additions & 0 deletions tests/test_data_regression/test_round_digits__json_.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"content": {
"value": 1.12,
"value1": "toto"
},
"value": 1.23,
"values": [
1.12,
2.35
]
}