From 1cb18a753a6d8338a2b442ef8ff789d274e01eb6 Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Sat, 19 Oct 2024 22:22:48 +0200 Subject: [PATCH] docs: document new unmanaged snapshot values --- docs/customize_repr.md | 4 +- docs/eq_snapshot.md | 171 +++++++++++++++- docs/pytest.md | 4 +- src/inline_snapshot/_code_repr.py | 2 +- tests/conftest.py | 5 +- tests/test_docs.py | 330 ++++++++++++++++++------------ 6 files changed, 380 insertions(+), 136 deletions(-) diff --git a/docs/customize_repr.md b/docs/customize_repr.md index 177548f..4bde16c 100644 --- a/docs/customize_repr.md +++ b/docs/customize_repr.md @@ -42,7 +42,7 @@ def test_enum(): inline-snapshot comes with a special implementation for the following types: -```python exec="1" +``` python exec="1" from inline_snapshot._code_repr import code_repr_dispatch, code_repr for name, obj in sorted( @@ -60,7 +60,7 @@ for name, obj in sorted( Container types like `dict` or `dataclass` need a special implementation because it is necessary that the implementation uses `repr()` for the child elements. -```python exec="1" result="python" +``` python exec="1" result="python" print('--8<-- "src/inline_snapshot/_code_repr.py:list"') ``` diff --git a/docs/eq_snapshot.md b/docs/eq_snapshot.md index 899ba13..de32a4a 100644 --- a/docs/eq_snapshot.md +++ b/docs/eq_snapshot.md @@ -33,9 +33,31 @@ Example: def test_something(): assert 2 + 40 == snapshot(42) ``` +## unmanaged snapshot parts +inline-snapshots manages everything inside `snapshot(...)`, which means that the developer should not change these parts, but there are cases where it is useful to give the developer a bit more control over the snapshot content. -## dirty-equals +Therefor some types will be ignored by inline-snapshot and will **not be updated or fixed**, even if they cause tests to fail. + +These types are: + +* dirty-equals expression +* dynamic code inside `Is(...)` +* and snapshots inside snapshots. + +inline-snapshot is able to handle these types inside the following containers: + +* list +* tuple +* dict +* namedtuple +* dataclass + + +### dirty-equals It might be, that larger snapshots with many lists and dictionaries contain some values which change frequently and are not relevant for the test. They might be part of larger data structures and be difficult to normalize. @@ -82,7 +104,7 @@ Example: inline-snapshot tries to change only the values that it needs to change in order to pass the equality comparison. This allows to replace parts of the snapshot with [dirty-equals](https://dirty-equals.helpmanual.io/latest/) expressions. -This expressions are preserved as long as the `==` comparison with them is `True`. +This expressions are preserved even if the `==` comparison with them is `False`. Example: @@ -159,8 +181,149 @@ Example: ) ``` -!!! note - The current implementation looks only into lists, dictionaries and tuples and not into the representation of other data structures. +### Is(...) + +`Is()` can be used to put runtime values inside snapshots. +It tells inline-snapshot that the developer wants control over some part of the snapshot. + + +``` python +from inline_snapshot import snapshot, Is + +current_version = "1.5" + + +def request(): + return {"data": "page data", "version": current_version} + + +def test_function(): + assert request() == snapshot( + {"data": "page data", "version": Is(current_version)} + ) +``` + +The `current_version` can now be changed without having to correct the snapshot. + +`Is()` can also be used when the snapshot is evaluated multiple times. + +=== "original code" + + ``` python + from inline_snapshot import snapshot, Is + + + def test_function(): + for c in "abc": + assert [c, "correct"] == snapshot([Is(c), "wrong"]) + ``` + +=== "--inline-snapshot=fix" + + ``` python hl_lines="6" + from inline_snapshot import snapshot, Is + + + def test_function(): + for c in "abc": + assert [c, "correct"] == snapshot([Is(c), "correct"]) + ``` + +### inner snapshots + +Snapshots can be used inside other snapshots in different use cases. + +#### conditional snapshots +It is possible to describe version specific parts of snapshots by replacing the specific part with `#!python snapshot() if some_condition else snapshot()`. +The test has to be executed in each specific condition to fill the snapshots. + +The following example shows how this can be used to run a tests with two different library versions: + +=== "my_lib v1" + + + ``` python + version = 1 + + + def get_schema(): + return [{"name": "var_1", "type": "int"}] + ``` + +=== "my_lib v2" + + + ``` python + version = 2 + + + def get_schema(): + return [{"name": "var_1", "type": "string"}] + ``` + + + +``` python +from inline_snapshot import snapshot +from my_lib import version, get_schema + + +def test_function(): + assert get_schema() == snapshot( + [ + { + "name": "var_1", + "type": snapshot("int") if version < 2 else snapshot("string"), + } + ] + ) +``` + +The advantage of this approach is that the test uses always the correct values for each library version. + +#### common snapshot parts + +Another usecase is the extraction of common snapshot parts into an extra snapshot: + + +``` python +from inline_snapshot import snapshot + + +def some_data(name): + return {"header": "really long header\n" * 5, "your name": name} + + +def test_function(): + + header = snapshot( + """\ +really long header +really long header +really long header +really long header +really long header +""" + ) + + assert some_data("Tom") == snapshot( + { + "header": header, + "your name": "Tom", + } + ) + + assert some_data("Bob") == snapshot( + { + "header": header, + "your name": "Bob", + } + ) +``` + +This simplifies test data and allows inline-snapshot to update your values if required. +It makes also sure that the header is the same in both cases. + ## pytest options diff --git a/docs/pytest.md b/docs/pytest.md index 5d82561..a7d34ac 100644 --- a/docs/pytest.md +++ b/docs/pytest.md @@ -11,7 +11,7 @@ inline-snapshot provides one pytest option with different flags (*create*, Snapshot comparisons return always `True` if you use one of the flags *create*, *fix* or *review*. This is necessary because the whole test needs to be run to fix all snapshots like in this case: -```python +``` python from inline_snapshot import snapshot @@ -30,7 +30,7 @@ def test_something(): Approve the changes of the given [category](categories.md). These flags can be combined with *report* and *review*. -```python title="test_something.py" +``` python title="test_something.py" from inline_snapshot import snapshot diff --git a/src/inline_snapshot/_code_repr.py b/src/inline_snapshot/_code_repr.py index 9a5dcd3..c34f5a2 100644 --- a/src/inline_snapshot/_code_repr.py +++ b/src/inline_snapshot/_code_repr.py @@ -62,7 +62,7 @@ def customize_repr(f): """Register a funtion which should be used to get the code representation of a object. - ```python + ``` python @customize_repr def _(obj: MyCustomClass): return f"MyCustomClass(attr={repr(obj.attr)})" diff --git a/tests/conftest.py b/tests/conftest.py index 5ef37fc..f46cea6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -288,7 +288,10 @@ def format(self): ) def pyproject(self, source): - (pytester.path / "pyproject.toml").write_text(source, "utf-8") + self.write_file("pyproject.toml", source) + + def write_file(self, filename, content): + (pytester.path / filename).write_text(content, "utf-8") def storage(self): dir = pytester.path / ".inline-snapshot" / "external" diff --git a/tests/test_docs.py b/tests/test_docs.py index 14a4046..65775f6 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,20 +1,141 @@ +import itertools import platform import re import sys import textwrap +from collections import defaultdict +from dataclasses import dataclass from pathlib import Path +from typing import Optional import inline_snapshot._inline_snapshot import pytest +@dataclass +class Block: + code: str + code_header: Optional[str] + block_options: str + line: int + + +def map_code_blocks(file): + def w(func): + + block_start = re.compile("( *)``` *python(.*)") + block_end = re.compile("```.*") + + header = re.compile("") + + current_code = file.read_text("utf-8") + new_lines = [] + block_lines = [] + options = set() + is_block = False + code = None + indent = "" + block_start_linenum = None + block_options = None + code_header = None + header_line = "" + + for linenumber, line in enumerate(current_code.splitlines(), start=1): + m = block_start.fullmatch(line) + if m and not is_block: + # ``` python + block_start_linenum = linenumber + indent = m[1] + block_options = m[2] + block_lines = [] + is_block = True + continue + + if block_end.fullmatch(line.strip()) and is_block: + # ``` + is_block = False + + code = "\n".join(block_lines) + "\n" + code = textwrap.dedent(code) + if file.suffix == ".py": + code = code.replace("\\\\", "\\") + + try: + new_block = func( + Block( + code=code, + code_header=code_header, + block_options=block_options, + line=block_start_linenum, + ) + ) + except Exception: + print(f"error at block at line {block_start_linenum}") + print(f"{code_header=}") + print(f"{block_options=}") + print(code) + raise + + if new_block.code_header is not None: + new_lines.append( + f"{indent}" + ) + + new_lines.append( + f"{indent}``` {("python "+new_block.block_options.strip()).strip()}" + ) + + new_code = new_block.code.rstrip("\n") + if file.suffix == ".py": + new_code = new_code.replace("\\", "\\\\") + new_code = textwrap.indent(new_code, indent) + + new_lines.append(new_code) + + new_lines.append(f"{indent}```") + + header_line = "" + code_header = None + + continue + + if is_block: + block_lines.append(line) + continue + + m = header.fullmatch(line.strip()) + if m: + # comment + header_line = line + code_header = m[1].strip() + continue + else: + if header_line: + new_lines.append(header_line) + code_header = None + header_line = "" + + if not is_block: + new_lines.append(line) + + new_code = "\n".join(new_lines) + "\n" + + if inline_snapshot._inline_snapshot._update_flags.fix: + file.write_text(new_code) + else: + assert current_code.splitlines() == new_code.splitlines() + assert current_code == new_code + + return w + + @pytest.mark.skipif( platform.system() == "Windows", reason="\\r in stdout can cause problems in snapshot strings", ) @pytest.mark.skipif( sys.version_info[:2] != (3, 12), - reason="\\r in stdout can cause problems in snapshot strings", + reason="there is no reason to test the doc with different python versions", ) @pytest.mark.parametrize( "file", @@ -36,19 +157,7 @@ def test_docs(project, file, subtests): * `outcome-passed=2` to check for the pytest test outcome """ - block_start = re.compile("( *)``` *python.*") - block_end = re.compile("```.*") - - header = re.compile("") - - text = file.read_text("utf-8") - new_lines = [] - block_lines = [] - options = set() - is_block = False - code = None - indent = "" - first_block = True + last_code = None project.pyproject( """ @@ -57,132 +166,101 @@ def test_docs(project, file, subtests): """ ) - for linenumber, line in enumerate(text.splitlines(), start=1): - m = block_start.fullmatch(line) - if m and is_block == True: - block_start_line = line - indent = m[1] - block_lines = [] - continue + extra_files = defaultdict(list) - if block_end.fullmatch(line.strip()) and is_block: - with subtests.test(line=linenumber): - is_block = False + @map_code_blocks(file) + def _(block: Block): + if block.code_header is None: + return block - last_code = code - code = "\n".join(block_lines) + "\n" - code = textwrap.dedent(code) - if file.suffix == ".py": - code = code.replace("\\\\", "\\") + if block.code_header.startswith("inline-snapshot-lib:"): + extra_files[block.code_header.split()[1]].append(block.code) + return block - flags = options & {"fix", "update", "create", "trim"} + if block.code_header.startswith("todo-inline-snapshot:"): + return block + assert False - args = ["--inline-snapshot", ",".join(flags)] if flags else [] + nonlocal last_code + with subtests.test(line=block.line): - if flags and "first_block" not in options: - project.setup(last_code) - else: - project.setup(code) + code = block.code - result = project.run(*args) + options = set(block.code_header.split()) - print("flags:", flags) + flags = options & {"fix", "update", "create", "trim"} - new_code = code - if flags: - new_code = project.source + args = ["--inline-snapshot", ",".join(flags)] if flags else [] - if "show_error" in options: - new_code = new_code.split("# Error:")[0] - new_code += "# Error:\n" + textwrap.indent( - result.errorLines(), "# " - ) + if flags and "first_block" not in options: + project.setup(last_code) + else: + project.setup(code) - print("new code:") - print(new_code) - print("expected code:") - print(code) - - if ( - inline_snapshot._inline_snapshot._update_flags.fix - ): # pragma: no cover - flags_str = " ".join( - sorted(flags) - + sorted(options & {"first_block", "show_error"}) - + [ - f"outcome-{k}={v}" - for k, v in result.parseoutcomes().items() - if k in ("failed", "errors", "passed") - ] - ) - header_line = f"{indent}" - - new_lines.append(header_line) - - from inline_snapshot._align import align - - linenum = 1 - hl_lines = "" - if last_code is not None and "first_block" not in options: - changed_lines = [] - alignment = align(last_code.split("\n"), new_code.split("\n")) - for c in alignment: - if c == "d": - continue - elif c == "m": - linenum += 1 - else: - changed_lines.append(str(linenum)) - linenum += 1 - if changed_lines: - hl_lines = f' hl_lines="{" ".join(changed_lines)}"' - else: - assert False, "no lines changed" + if extra_files: + all_files = [ + [(key, file) for file in files] + for key, files in extra_files.items() + ] + for files in itertools.product(*all_files): + for filename, content in files: + project.write_file(filename, content) + result = project.run(*args) - new_lines.append(f"{indent}``` python{hl_lines}") + else: - if ( - inline_snapshot._inline_snapshot._update_flags.fix - ): # pragma: no cover - new_code = new_code.rstrip("\n") - if file.suffix == ".py": - new_code = new_code.replace("\\", "\\\\") - new_code = textwrap.indent(new_code, indent) + result = project.run(*args) - new_lines.append(new_code) + print("flags:", flags, repr(block.block_options)) + + new_code = code + if flags: + new_code = project.source + + if "show_error" in options: + new_code = new_code.split("# Error:")[0] + new_code += "# Error:\n" + textwrap.indent(result.errorLines(), "# ") + + print("new code:") + print(new_code) + print("expected code:") + print(code) + + block.code_header = "inline-snapshot: " + " ".join( + sorted(flags) + + sorted(options & {"first_block", "show_error"}) + + [ + f"outcome-{k}={v}" + for k, v in result.parseoutcomes().items() + if k in ("failed", "errors", "passed") + ] + ) + + from inline_snapshot._align import align + + linenum = 1 + hl_lines = "" + if last_code is not None and "first_block" not in options: + changed_lines = [] + alignment = align(last_code.split("\n"), new_code.split("\n")) + for c in alignment: + if c == "d": + continue + elif c == "m": + linenum += 1 + else: + changed_lines.append(str(linenum)) + linenum += 1 + if changed_lines: + hl_lines = f'hl_lines="{" ".join(changed_lines)}"' else: - new_lines += block_lines + assert False, "no lines changed" + block.block_options = hl_lines - new_lines.append(line) + block.code = new_code - if not inline_snapshot._inline_snapshot._update_flags.fix: - if flags: - assert result.ret == 0 - else: - assert { - f"outcome-{k}={v}" - for k, v in result.parseoutcomes().items() - if k in ("failed", "errors", "passed") - } == {flag for flag in options if flag.startswith("outcome-")} - assert code == new_code - else: # pragma: no cover - pass - - continue - - m = header.fullmatch(line.strip()) - if m: - options = set(m.group(1).split()) - if first_block: - options.add("first_block") - first_block = False - header_line = line - is_block = True - - if is_block: - block_lines.append(line) - else: - new_lines.append(line) + if flags: + assert result.ret == 0 - if inline_snapshot._inline_snapshot._update_flags.fix: # pragma: no cover - file.write_text("\n".join(new_lines) + "\n", "utf-8") + last_code = code + return block