From c4631ba11d94f579fc9170710b5fe2b5be8013de Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Mon, 8 Jul 2024 08:28:06 +0200 Subject: [PATCH] feat: added inline_snapshot.testing.Example which can be used to test 3rd-party extensions --- docs/testing.md | 132 +++++++++++++++ inline_snapshot/__init__.py | 2 + inline_snapshot/_inline_snapshot.py | 3 +- inline_snapshot/_types.py | 11 ++ inline_snapshot/testing/__init__.py | 3 + inline_snapshot/testing/_example.py | 250 ++++++++++++++++++++++++++++ mkdocs.yml | 1 + tests/example.py | 181 +------------------- tests/test_code_repr.py | 8 +- tests/test_example.py | 6 +- tests/test_pydantic.py | 2 +- tests/test_pytest_plugin.py | 4 +- tests/utils.py | 33 +--- 13 files changed, 415 insertions(+), 221 deletions(-) create mode 100644 docs/testing.md create mode 100644 inline_snapshot/_types.py create mode 100644 inline_snapshot/testing/__init__.py create mode 100644 inline_snapshot/testing/_example.py diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 00000000..5a1b7376 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,132 @@ + + +`inline_snapshot.testing` provides tools which can be used to test inline-snapshot workflows. +This might be useful if you want to build your own libraries based on inline-snapshot. + +The following example shows how you can use the `Example` class to test what inline-snapshot would do with given the source code. The snapshots in the argument are asserted inside the `run_*` methods, but only when they are provided. + +=== "original" + + + ```python + from inline_snapshot.testing import Example + from inline_snapshot import snapshot + + + def test_something(): + + Example( + { + "test_a.py": """\ + from inline_snapshot import snapshot + def test_a(): + assert 1+1 == snapshot() + """ + } + ).run_inline( + reported_flags=snapshot(), + ).run_pytest( + changed_files=snapshot(), + report=snapshot(), + ).run_pytest( + ["--inline-snapshot=create"], + changed_files=snapshot(), + ) + ``` + +=== "--inline-snapshot=create" + + + ```python + from inline_snapshot.testing import Example + from inline_snapshot import snapshot + + + def test_something(): + + Example( + { + "test_a.py": """\ + from inline_snapshot import snapshot + def test_a(): + assert 1+1 == snapshot() + """ + } + ).run_inline( + reported_flags=snapshot(["create"]), + ).run_pytest( + changed_files=snapshot({}), + report=snapshot( + """\ + Error: one snapshot is missing a value (--inline-snapshot=create) + You can also use --inline-snapshot=review to approve the changes interactiv\ + """ + ), + ).run_pytest( + ["--inline-snapshot=create"], + changed_files=snapshot( + { + "test_a.py": """\ + from inline_snapshot import snapshot + def test_a(): + assert 1+1 == snapshot(2) + """ + } + ), + ) + ``` + + +## API +::: inline_snapshot.testing.Example + options: + separate_signature: true + show_signature_annotations: true + + +## Types + +The following types are for type checking. + +::: inline_snapshot.Category + +see [categories](categories.md) + +::: inline_snapshot.Snapshot + +Can be used to annotate where snapshots can be passed as function arguments. + +??? note "Example" + + ```python + from typing import Optional + from inline_snapshot import snapshot, Snapshot + + + def check_in_bounds(value, lower: Snapshot[int], upper: Snapshot[int]): + assert lower <= value <= upper + + + def test_numbers(): + for c in "hello world": + check_in_bounds(ord(c), snapshot(32), snapshot(119)) + + + def check_container( + value, + *, + value_repr: Optional[Snapshot[str]] = None, + length: Optional[Snapshot[int]] = None + ): + if value_repr is not None: + assert repr(value) == value_repr + + if length is not None: + assert len(value) == length + + + def test_container(): + check_container([1, 2], value_repr=snapshot("[1, 2]"), length=snapshot(2)) + + check_container({1, 1}, length=snapshot(1)) + ``` diff --git a/inline_snapshot/__init__.py b/inline_snapshot/__init__.py index 283b0eba..cc261cdf 100644 --- a/inline_snapshot/__init__.py +++ b/inline_snapshot/__init__.py @@ -3,6 +3,8 @@ from ._external import external from ._external import outsource from ._inline_snapshot import snapshot +from ._types import Category +from ._types import Snapshot __all__ = ["snapshot", "external", "outsource", "customize_repr", "HasRepr"] diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index 84bb2538..07696c8c 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -26,6 +26,7 @@ from ._exceptions import UsageError from ._format import format_code from ._sentinels import undefined +from ._types import Category from ._utils import ignore_tokens from ._utils import normalize from ._utils import simple_token @@ -53,7 +54,7 @@ class Flags: trim: the snapshot contains more values than neccessary. 1 could be trimmed in `5 in snapshot([1,5])`. """ - def __init__(self, flags=set()): + def __init__(self, flags: Set[Category] = set()): self.fix = "fix" in flags self.update = "update" in flags self.create = "create" in flags diff --git a/inline_snapshot/_types.py b/inline_snapshot/_types.py new file mode 100644 index 00000000..a5180257 --- /dev/null +++ b/inline_snapshot/_types.py @@ -0,0 +1,11 @@ +from typing import Literal +from typing import TypeVar + +from typing_extensions import Annotated + +T = TypeVar("T") + +Snapshot = Annotated[T, "just an alias"] + + +Category = Literal["update", "fix", "create", "trim"] diff --git a/inline_snapshot/testing/__init__.py b/inline_snapshot/testing/__init__.py new file mode 100644 index 00000000..ddc07586 --- /dev/null +++ b/inline_snapshot/testing/__init__.py @@ -0,0 +1,3 @@ +from ._example import Example + +__all__ = ("Example",) diff --git a/inline_snapshot/testing/_example.py b/inline_snapshot/testing/_example.py new file mode 100644 index 00000000..70df563a --- /dev/null +++ b/inline_snapshot/testing/_example.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import contextlib +import os +import platform +import re +import subprocess as sp +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any + +import inline_snapshot._external +import inline_snapshot._external as external +from inline_snapshot import _inline_snapshot +from inline_snapshot import Snapshot +from inline_snapshot._inline_snapshot import Flags +from inline_snapshot._rewrite_code import ChangeRecorder +from inline_snapshot._types import Category + + +@contextlib.contextmanager +def snapshot_env(): + import inline_snapshot._inline_snapshot as inline_snapshot + + current = ( + inline_snapshot.snapshots, + inline_snapshot._update_flags, + inline_snapshot._active, + external.storage, + inline_snapshot._files_with_snapshots, + inline_snapshot._missing_values, + ) + + inline_snapshot.snapshots = {} + inline_snapshot._update_flags = inline_snapshot.Flags() + inline_snapshot._active = True + external.storage = None + inline_snapshot._files_with_snapshots = set() + inline_snapshot._missing_values = 0 + + try: + yield + finally: + ( + inline_snapshot.snapshots, + inline_snapshot._update_flags, + inline_snapshot._active, + external.storage, + inline_snapshot._files_with_snapshots, + inline_snapshot._missing_values, + ) = current + + +ansi_escape = re.compile( + r""" + \x1B # ESC + (?: # 7-bit C1 Fe (except CSI) + [@-Z\\-_] + | # or [ for CSI, followed by a control sequence + \[ + [0-?]* # Parameter bytes + [ -/]* # Intermediate bytes + [@-~] # Final byte + ) +""", + re.VERBOSE, +) + + +class Example: + def __init__(self, files: str | dict[str, str]): + """ + Parameters: + files: a collecton of files where inline-snapshot opperates on. + """ + if isinstance(files, str): + files = {"test_something.py": files} + + self.files = files + + def _write_files(self, dir: Path): + for name, content in self.files.items(): + (dir / name).write_text(content) + + def _read_files(self, dir: Path): + return {p.name: p.read_text() for p in dir.iterdir() if p.is_file()} + + def run_inline( + self, + flags: list[Category] = [], + *, + reported_flags: Snapshot[list[Category]] | None = None, + changed_files: Snapshot[dict[str, str]] | None = None, + raises: Snapshot[str] | None = None, + ) -> Example: + """Execute the example files in process and run every `test_*` + function. + + This is useful for fast test execution. + + Parameters: + flags: list of inline-snapshot flags (like "fix" or "create") which should be used. + reported_flags: snapshot of flags which inline-snapshot thinks could be applied. + changed_files: snapshot of files which are changed by this run. + raises: snapshot of the exception which is raised during the test execution. + It is required if your code raises an exception. + + Returns: + A new Example instance which contains the changed files. + """ + + with TemporaryDirectory() as dir: + tmp_path = Path(dir) + + self._write_files(tmp_path) + + with snapshot_env(): + with ChangeRecorder().activate() as recorder: + _inline_snapshot._update_flags = Flags({*flags}) + inline_snapshot._external.storage = ( + inline_snapshot._external.DiscStorage(tmp_path / ".storage") + ) + + try: + for filename in tmp_path.glob("*.py"): + globals: dict[str, Any] = {} + exec( + compile(filename.read_text("utf-8"), filename, "exec"), + globals, + ) + + # run all test_* functions + for k, v in globals.items(): + if k.startswith("test_") and callable(v): + v() + except Exception as e: + assert raises == f"{type(e).__name__}:\n" + str(e) + + finally: + _inline_snapshot._active = False + + snapshot_flags = set() + + for snapshot in _inline_snapshot.snapshots.values(): + snapshot_flags |= snapshot._flags + snapshot._change() + + if reported_flags is not None: + assert sorted(snapshot_flags) == reported_flags + + recorder.fix_all() + + if changed_files is not None: + current_files = {} + + for name, content in sorted(self._read_files(tmp_path).items()): + if name not in self.files or self.files[name] != content: + current_files[name] = content + assert changed_files == current_files + + return Example(self._read_files(tmp_path)) + + def run_pytest( + self, + args: list[str] = [], + *, + env: dict[str, str] = {}, + changed_files: Snapshot[dict[str, str]] | None = None, + report: Snapshot[str] | None = None, + returncode: Snapshot[int] | None = None, + ) -> Example: + """Run pytest with the given args and env variables in an seperate + process. + + It can be used to test the interaction between your code and pytest, but it is a bit slower than `run_inline` + + Parameters: + args: pytest arguments like "--inline-snapshot=fix" + env: dict of environment variables + changed_files: snapshot of files which are changed by this run. + report: snapshot of the report at the end of the pytest run. + returncode: snapshot of the pytest returncode. + + Returns: + A new Example instance which contains the changed files. + """ + + with TemporaryDirectory() as dir: + tmp_path = Path(dir) + self._write_files(tmp_path) + + cmd = ["pytest", *args] + + term_columns = 80 + + command_env = dict(os.environ) + command_env["TERM"] = "unknown" + command_env["COLUMNS"] = str( + term_columns + 1 if platform.system() == "Windows" else term_columns + ) + command_env.pop("CI", None) + + command_env.update(env) + + result = sp.run(cmd, cwd=tmp_path, capture_output=True, env=command_env) + + print("run>", *cmd) + print("stdout:") + print(result.stdout.decode()) + print("stderr:") + print(result.stderr.decode()) + + if returncode is not None: + assert result.returncode == returncode + + if report is not None: + + report_list = [] + record = False + for line in result.stdout.decode().splitlines(): + line = line.strip() + if line.startswith("===="): + record = False + + if record and line: + report_list.append(line) + + if line.startswith("====") and "inline snapshot" in line: + record = True + + report_str = "\n".join(report_list) + + report_str = ansi_escape.sub("", report_str) + + # fix windows problems + report_str = report_str.replace("\u2500", "-") + report_str = report_str.replace("\r", "") + report_str = report_str.replace(" \n", " ⏎\n") + + assert report_str == report + + if changed_files is not None: + current_files = {} + + for name, content in sorted(self._read_files(tmp_path).items()): + if name not in self.files or self.files[name] != content: + current_files[name] = content + assert changed_files == current_files + + return Example(self._read_files(tmp_path)) diff --git a/mkdocs.yml b/mkdocs.yml index 7f04a056..9dd8f910 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -41,6 +41,7 @@ nav: - Categories: categories.md - Configuration: configuration.md - Code generation: code_generation.md +- Testing: testing.md - Contributing: contributing.md - Changelog: changelog.md diff --git a/tests/example.py b/tests/example.py index 50d168e8..e9290ac9 100644 --- a/tests/example.py +++ b/tests/example.py @@ -1,180 +1,3 @@ -from __future__ import annotations +from inline_snapshot.testing import Example -import os -import platform -import re -import subprocess as sp -from pathlib import Path -from tempfile import TemporaryDirectory -from typing import Any - -import inline_snapshot._external -from .utils import snapshot_env -from inline_snapshot import _inline_snapshot -from inline_snapshot._inline_snapshot import Flags -from inline_snapshot._rewrite_code import ChangeRecorder - -pytest_plugins = "pytester" - - -ansi_escape = re.compile( - r""" - \x1B # ESC - (?: # 7-bit C1 Fe (except CSI) - [@-Z\\-_] - | # or [ for CSI, followed by a control sequence - \[ - [0-?]* # Parameter bytes - [ -/]* # Intermediate bytes - [@-~] # Final byte - ) -""", - re.VERBOSE, -) - - -class Example: - def __init__(self, files): - if isinstance(files, str): - files = {"test_something.py": files} - - self.files = files - - def write_files(self, dir: Path): - for name, content in self.files.items(): - (dir / name).write_text(content) - - def read_files(self, dir: Path): - return {p.name: p.read_text() for p in dir.iterdir() if p.is_file()} - - def run_inline( - self, *flags, changes=None, reported_flags=None, changed_files=None, raises=None - ) -> Example: - - with TemporaryDirectory() as dir: - tmp_path = Path(dir) - - self.write_files(tmp_path) - - with snapshot_env(): - with ChangeRecorder().activate() as recorder: - _inline_snapshot._update_flags = Flags({*flags}) - inline_snapshot._external.storage = ( - inline_snapshot._external.DiscStorage(tmp_path / ".storage") - ) - - error = False - - try: - for filename in tmp_path.glob("*.py"): - globals: dict[str, Any] = {} - exec( - compile(filename.read_text("utf-8"), filename, "exec"), - globals, - ) - - # run all test_* functions - for k, v in globals.items(): - if k.startswith("test_") and callable(v): - v() - except Exception as e: - assert raises == f"{type(e).__name__}:\n" + str(e) - - finally: - _inline_snapshot._active = False - - # number_snapshots = len(_inline_snapshot.snapshots) - - snapshot_flags = set() - - all_changes = [] - - for snapshot in _inline_snapshot.snapshots.values(): - snapshot_flags |= snapshot._flags - snapshot._change() - all_changes += snapshot._changes() - - if reported_flags is not None: - assert sorted(snapshot_flags) == reported_flags - - # if changes is not None: - # assert all_changes == changes - - recorder.fix_all() - - if changed_files is not None: - current_files = {} - - for name, content in sorted(self.read_files(tmp_path).items()): - if name not in self.files or self.files[name] != content: - current_files[name] = content - assert changed_files == current_files - - return Example(self.read_files(tmp_path)) - - def run_pytest( - self, *args, changed_files=None, report=None, env={}, returncode=None - ) -> Example: - with TemporaryDirectory() as dir: - tmp_path = Path(dir) - self.write_files(tmp_path) - - cmd = ["pytest", *args] - - term_columns = 80 - - command_env = dict(os.environ) - command_env["TERM"] = "unknown" - command_env["COLUMNS"] = str( - term_columns + 1 if platform.system() == "Windows" else term_columns - ) - command_env.pop("CI", None) - - command_env.update(env) - - result = sp.run(cmd, cwd=tmp_path, capture_output=True, env=command_env) - - print("run>", *cmd) - print("stdout:") - print(result.stdout.decode()) - print("stderr:") - print(result.stderr.decode()) - - if returncode is not None: - assert result.returncode == returncode - - if report is not None: - - report_list = [] - record = False - for line in result.stdout.decode().splitlines(): - line = line.strip() - if line.startswith("===="): - record = False - - if record and line: - report_list.append(line) - - if line.startswith("====") and "inline snapshot" in line: - record = True - - report_str = "\n".join(report_list) - - report_str = ansi_escape.sub("", report_str) - - # fix windows problems - report_str = report_str.replace("\u2500", "-") - report_str = report_str.replace("\r", "") - report_str = report_str.replace(" \n", " ⏎\n") - - assert report_str == report - - if changed_files is not None: - current_files = {} - - for name, content in sorted(self.read_files(tmp_path).items()): - if name not in self.files or self.files[name] != content: - current_files[name] = content - assert changed_files == current_files - - return Example(self.read_files(tmp_path)) +__all__ = ("Example",) diff --git a/tests/test_code_repr.py b/tests/test_code_repr.py index 3a2871ce..6f97815c 100644 --- a/tests/test_code_repr.py +++ b/tests/test_code_repr.py @@ -62,7 +62,7 @@ def test_thing(): """ ).run_pytest( - "--inline-snapshot=create", + ["--inline-snapshot=create"], returncode=snapshot(0), changed_files=snapshot( { @@ -88,7 +88,7 @@ def test_thing(): } ), ).run_pytest( - "--inline-snapshot=disable", returncode=0 + ["--inline-snapshot=disable"], returncode=0 ).run_pytest( returncode=0 ) @@ -160,7 +160,7 @@ class container: assert container(a=1,b=5) == snapshot() """ ).run_inline( - "create", + ["create"], changed_files=snapshot( { "test_something.py": """\ @@ -254,7 +254,7 @@ class Color(Enum): """ ).run_inline( - "create", + ["create"], changed_files=snapshot( { "test_something.py": """\ diff --git a/tests/test_example.py b/tests/test_example.py index dbcd93e7..bb39ed55 100644 --- a/tests/test_example.py +++ b/tests/test_example.py @@ -17,13 +17,13 @@ def test_a(): ) e.run_pytest( - "--inline-snapshot=create,fix", + ["--inline-snapshot=create,fix"], ) e.run_inline( - "fix", + ["fix"], reported_flags=snapshot(["fix"]), ).run_inline( - "fix", + ["fix"], changed_files=snapshot({}), ) diff --git a/tests/test_pydantic.py b/tests/test_pydantic.py index c6c55a7b..0863879e 100644 --- a/tests/test_pydantic.py +++ b/tests/test_pydantic.py @@ -19,7 +19,7 @@ def test_pydantic(): """ ).run_inline( - "create", + ["create"], changed_files=snapshot( { "test_something.py": """\ diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index ee321ac0..ca9ad1f7 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -560,7 +560,7 @@ def test_b(): """, } ).run_pytest( - "--inline-snapshot=create,fix", + ["--inline-snapshot=create,fix"], changed_files=snapshot( { "test_b.py": """\ @@ -626,7 +626,7 @@ def test_a(): """, } ).run_inline( - "--inline-snapshot=create", + ["create"], changed_files=snapshot({}), raises=snapshot( """\ diff --git a/tests/utils.py b/tests/utils.py index 8444a741..8275eb4d 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,39 +5,10 @@ import inline_snapshot._config as _config import inline_snapshot._external as external -import inline_snapshot._inline_snapshot as inline_snapshot from inline_snapshot._rewrite_code import ChangeRecorder +from inline_snapshot.testing._example import snapshot_env - -@contextlib.contextmanager -def snapshot_env(): - current = ( - inline_snapshot.snapshots, - inline_snapshot._update_flags, - inline_snapshot._active, - external.storage, - inline_snapshot._files_with_snapshots, - inline_snapshot._missing_values, - ) - - inline_snapshot.snapshots = {} - inline_snapshot._update_flags = inline_snapshot.Flags() - inline_snapshot._active = True - external.storage = None - inline_snapshot._files_with_snapshots = set() - inline_snapshot._missing_values = 0 - - try: - yield - finally: - ( - inline_snapshot.snapshots, - inline_snapshot._update_flags, - inline_snapshot._active, - external.storage, - inline_snapshot._files_with_snapshots, - inline_snapshot._missing_values, - ) = current +__all__ = ("snapshot_env",) @contextlib.contextmanager