From e1119023cba2dac9de90ea2db06d4efb5142a919 Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Wed, 28 Feb 2024 17:47:28 +0100 Subject: [PATCH 01/17] refactor: moved some functions --- inline_snapshot/_change.py | 36 ++++++ inline_snapshot/_inline_snapshot.py | 168 ++++++---------------------- inline_snapshot/_location.py | 30 +++++ inline_snapshot/_sentinels.py | 6 + inline_snapshot/_utils.py | 122 ++++++++++++++++++++ 5 files changed, 230 insertions(+), 132 deletions(-) create mode 100644 inline_snapshot/_change.py create mode 100644 inline_snapshot/_location.py create mode 100644 inline_snapshot/_sentinels.py create mode 100644 inline_snapshot/_utils.py diff --git a/inline_snapshot/_change.py b/inline_snapshot/_change.py new file mode 100644 index 00000000..fcfa995e --- /dev/null +++ b/inline_snapshot/_change.py @@ -0,0 +1,36 @@ +import ast +from dataclasses import dataclass +from typing import Any +from typing import Optional + +from ._location import Location + + +@dataclass() +class Change: + flag: str + + +@dataclass() +class Delete(Change): + location: Location + old_value: Any + + +@dataclass() +class Insert(Change): + location: Location + key_expr: Optional[ast.Expr] + value_expr: ast.Expr + + old_value: Any + new_value: Any + + +@dataclass() +class Replace(Change): + location: Location + value_expr: ast.Expr + + old_value: Any + new_value: Any diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index a1f09fdd..f9c00117 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -1,11 +1,8 @@ import ast import copy import inspect -import io import sys -import token import tokenize -from collections import namedtuple from pathlib import Path from typing import Any from typing import Dict # noqa @@ -19,15 +16,13 @@ from ._rewrite_code import ChangeRecorder from ._rewrite_code import end_of from ._rewrite_code import start_of +from ._sentinels import undefined +from ._utils import ignore_tokens +from ._utils import normalize_strings +from ._utils import simple_token +from ._utils import value_to_token -# sentinels -class Undefined: - pass - - -undefined = Undefined() - snapshots = {} # type: Dict[Tuple[int, int], Snapshot] _active = False @@ -70,6 +65,7 @@ class GenericValue: _new_value: Any _old_value: Any _current_op = "undefined" + _ast_node: ast.Expr def _needs_trim(self): return False @@ -97,6 +93,9 @@ def _visible_value(self): def get_result(self, flags): return self._old_value + def _get_changes(self): + raise NotImplementedError + def __repr__(self): return repr(self._visible_value()) @@ -128,9 +127,10 @@ def __getitem__(self, _item): class UndecidedValue(GenericValue): - def __init__(self, _old_value): + def __init__(self, _old_value, _ast_node): self._old_value = _old_value self._new_value = undefined + self._ast_node = _ast_node def _change(self, cls): self.__class__ = cls @@ -172,6 +172,23 @@ def __eq__(self, other): return self._visible_value() == other + def _get_changes(self): + if isinstance(self._new_value, (list, dict)): + raise NotImplementedError + + new_code = new_repr(self._new_value) + + if self._old_value is undefined: + flag = "create" + elif not self._old_value == self._new_value: + flag = "fix" + elif old_repr(self._ast_node) != new_code: + flag = "update" + else: + return + + yield Change(self._ast_node, new_code, flag) + def _needs_fix(self): return self._old_value is not undefined and self._old_value != self._new_value @@ -321,7 +338,9 @@ def __getitem__(self, index): old_value = {} if index not in self._new_value: - self._new_value[index] = UndecidedValue(old_value.get(index, undefined)) + self._new_value[index] = UndecidedValue( + old_value.get(index, undefined), None + ) return self._new_value[index] @@ -410,7 +429,7 @@ def snapshot(obj=undefined): `snapshot(value)` has the semantic of an noop which returns `value`. """ if not _active: - if isinstance(obj, Undefined): + if obj is undefined: raise AssertionError( "your snapshot is missing a value run pytest with --inline-snapshot=create" ) @@ -440,64 +459,6 @@ def snapshot(obj=undefined): return snapshots[key]._value -ignore_tokens = (token.NEWLINE, token.ENDMARKER, token.NL) - - -# based on ast.unparse -def _str_literal_helper(string, *, quote_types): - """Helper for writing string literals, minimizing escapes. - - Returns the tuple (string literal to write, possible quote types). - """ - - def escape_char(c): - # \n and \t are non-printable, but we only escape them if - # escape_special_whitespace is True - if c in "\n\t": - return c - # Always escape backslashes and other non-printable characters - if c == "\\" or not c.isprintable(): - return c.encode("unicode_escape").decode("ascii") - if c == extra: - return "\\" + c - return c - - extra = "" - if "'''" in string and '"""' in string: - extra = '"' if string.count("'") >= string.count('"') else "'" - - escaped_string = "".join(map(escape_char, string)) - - possible_quotes = [q for q in quote_types if q not in escaped_string] - - if escaped_string: - # Sort so that we prefer '''"''' over """\"""" - possible_quotes.sort(key=lambda q: q[0] == escaped_string[-1]) - # If we're using triple quotes and we'd need to escape a final - # quote, escape it - if possible_quotes[0][0] == escaped_string[-1]: - assert len(possible_quotes[0]) == 3 - escaped_string = escaped_string[:-1] + "\\" + escaped_string[-1] - return escaped_string, possible_quotes - - -def triple_quote(string): - """Write string literal value with a best effort attempt to avoid - backslashes.""" - string, quote_types = _str_literal_helper(string, quote_types=['"""', "'''"]) - quote_type = quote_types[0] - - string = "\\\n" + string - - if not string.endswith("\n"): - string = string + "\\\n" - - return f"{quote_type}{string}{quote_type}" - - -simple_token = namedtuple("simple_token", "type,string") - - def used_externals(tree): if sys.version_info < (3, 8): return [ @@ -524,7 +485,7 @@ def used_externals(tree): class Snapshot: def __init__(self, value, expr): self._expr = expr - self._value = UndecidedValue(value) + self._value = UndecidedValue(value, expr) self._uses_externals = [] @property @@ -534,32 +495,6 @@ def _filename(self): def _format(self, text): return format_code(text, Path(self._filename)) - def _value_to_token(self, value): - if value is undefined: - return [] - input = io.StringIO(self._format(repr(value))) - - def map_string(tok): - """Convert strings with newlines in triple quoted strings.""" - if tok.type == token.STRING: - s = ast.literal_eval(tok.string) - if isinstance(s, str) and "\n" in s: - # unparse creates a triple quoted string here, - # because it thinks that the string should be a docstring - tripple_quoted_string = triple_quote(s) - - assert ast.literal_eval(tripple_quoted_string) == s - - return simple_token(tok.type, tripple_quoted_string) - - return simple_token(tok.type, tok.string) - - return [ - map_string(t) - for t in tokenize.generate_tokens(input.readline) - if t.type not in ignore_tokens - ] - def _change(self): assert self._expr is not None @@ -589,9 +524,7 @@ def _change(self): ): new_value = self._value.get_result(_update_flags) - text = self._format( - tokenize.untokenize(self._value_to_token(new_value)) - ).strip() + text = self._format(tokenize.untokenize(value_to_token(new_value))).strip() try: tree = ast.parse(text) @@ -616,39 +549,10 @@ def _current_tokens(self): if t.type not in ignore_tokens ] - def _normalize_strings(self, token_sequence): - """Normalize string concattenanion. - - "a" "b" -> "ab" - """ - - current_string = None - for t in token_sequence: - if ( - t.type == token.STRING - and not t.string.startswith(("'''", '"""', "b'''", 'b"""')) - and t.string.startswith(("'", '"', "b'", 'b"')) - ): - if current_string is None: - current_string = ast.literal_eval(t.string) - else: - current_string += ast.literal_eval(t.string) - - continue - - if current_string is not None: - yield (token.STRING, repr(current_string)) - current_string = None - - yield t - - if current_string is not None: - yield (token.STRING, repr(current_string)) - def _needs_update(self): return self._expr is not None and [] != list( - self._normalize_strings(self._current_tokens()) - ) != list(self._normalize_strings(self._value_to_token(self._value._old_value))) + normalize_strings(self._current_tokens()) + ) != list(normalize_strings(value_to_token(self._value._old_value))) @property def _flags(self): diff --git a/inline_snapshot/_location.py b/inline_snapshot/_location.py new file mode 100644 index 00000000..a8e61676 --- /dev/null +++ b/inline_snapshot/_location.py @@ -0,0 +1,30 @@ +import ast +import inspect +from dataclasses import dataclass + + +class Location: + pass + + +@dataclass +class PositionalArgument(Location): + position: int + node: ast.FunctionDef + signature: inspect.Signature + + +class KeywordArgument(Location): + keyword: str + node: ast.FunctionDef + signature: inspect.Signature + + +class ListIndex(Location): + node: ast.List + index: int + + +class DictEntry(Location): + node: ast.Dict + key_pos: int diff --git a/inline_snapshot/_sentinels.py b/inline_snapshot/_sentinels.py new file mode 100644 index 00000000..8f34f7fa --- /dev/null +++ b/inline_snapshot/_sentinels.py @@ -0,0 +1,6 @@ +# sentinels +class Undefined: + pass + + +undefined = Undefined() diff --git a/inline_snapshot/_utils.py b/inline_snapshot/_utils.py new file mode 100644 index 00000000..6c43e392 --- /dev/null +++ b/inline_snapshot/_utils.py @@ -0,0 +1,122 @@ +import ast +import io +import token +import tokenize +from collections import namedtuple + +from ._sentinels import undefined + + +def normalize_strings(token_sequence): + """Normalize string concattenanion. + + "a" "b" -> "ab" + """ + + current_string = None + for t in token_sequence: + if ( + t.type == token.STRING + and not t.string.startswith(("'''", '"""', "b'''", 'b"""')) + and t.string.startswith(("'", '"', "b'", 'b"')) + ): + if current_string is None: + current_string = ast.literal_eval(t.string) + else: + current_string += ast.literal_eval(t.string) + + continue + + if current_string is not None: + yield (token.STRING, repr(current_string)) + current_string = None + + yield t + + if current_string is not None: + yield (token.STRING, repr(current_string)) + + +ignore_tokens = (token.NEWLINE, token.ENDMARKER, token.NL) + + +# based on ast.unparse +def _str_literal_helper(string, *, quote_types): + """Helper for writing string literals, minimizing escapes. + + Returns the tuple (string literal to write, possible quote types). + """ + + def escape_char(c): + # \n and \t are non-printable, but we only escape them if + # escape_special_whitespace is True + if c in "\n\t": + return c + # Always escape backslashes and other non-printable characters + if c == "\\" or not c.isprintable(): + return c.encode("unicode_escape").decode("ascii") + if c == extra: + return "\\" + c + return c + + extra = "" + if "'''" in string and '"""' in string: + extra = '"' if string.count("'") >= string.count('"') else "'" + + escaped_string = "".join(map(escape_char, string)) + + possible_quotes = [q for q in quote_types if q not in escaped_string] + + if escaped_string: + # Sort so that we prefer '''"''' over """\"""" + possible_quotes.sort(key=lambda q: q[0] == escaped_string[-1]) + # If we're using triple quotes and we'd need to escape a final + # quote, escape it + if possible_quotes[0][0] == escaped_string[-1]: + assert len(possible_quotes[0]) == 3 + escaped_string = escaped_string[:-1] + "\\" + escaped_string[-1] + return escaped_string, possible_quotes + + +def triple_quote(string): + """Write string literal value with a best effort attempt to avoid + backslashes.""" + string, quote_types = _str_literal_helper(string, quote_types=['"""', "'''"]) + quote_type = quote_types[0] + + string = "\\\n" + string + + if not string.endswith("\n"): + string = string + "\\\n" + + return f"{quote_type}{string}{quote_type}" + + +simple_token = namedtuple("simple_token", "type,string") + + +def value_to_token(value): + if value is undefined: + return [] + input = io.StringIO(repr(value)) + + def map_string(tok): + """Convert strings with newlines in triple quoted strings.""" + if tok.type == token.STRING: + s = ast.literal_eval(tok.string) + if isinstance(s, str) and "\n" in s: + # unparse creates a triple quoted string here, + # because it thinks that the string should be a docstring + tripple_quoted_string = triple_quote(s) + + assert ast.literal_eval(tripple_quoted_string) == s + + return simple_token(tok.type, tripple_quoted_string) + + return simple_token(tok.type, tok.string) + + return [ + map_string(t) + for t in tokenize.generate_tokens(input.readline) + if t.type not in ignore_tokens + ] From 6dfe07e8359ac50e16faea07e532f40d6fab8d5d Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Fri, 8 Mar 2024 18:21:17 +0100 Subject: [PATCH 02/17] refactor: use _get_changes --- inline_snapshot/_change.py | 54 +++++++++++--- inline_snapshot/_inline_snapshot.py | 106 +++++++++++++++++++++++----- inline_snapshot/_rewrite_code.py | 9 ++- inline_snapshot/pytest_plugin.py | 19 +++-- tests/conftest.py | 1 - tests/test_docs.py | 5 ++ tests/test_inline_snapshot.py | 2 +- 7 files changed, 160 insertions(+), 36 deletions(-) diff --git a/inline_snapshot/_change.py b/inline_snapshot/_change.py index fcfa995e..8bf6af42 100644 --- a/inline_snapshot/_change.py +++ b/inline_snapshot/_change.py @@ -1,36 +1,72 @@ import ast from dataclasses import dataclass from typing import Any +from typing import List from typing import Optional +from typing import Tuple -from ._location import Location +from executing import Source + +from ._rewrite_code import ChangeRecorder @dataclass() class Change: flag: str + source: Source + + @property + def filename(self): + return self.source.filename + + def apply(self): + pass @dataclass() class Delete(Change): - location: Location + node: ast.AST old_value: Any @dataclass() -class Insert(Change): - location: Location - key_expr: Optional[ast.Expr] - value_expr: ast.Expr +class AddArgument(Change): + node: ast.Call - old_value: Any + position: Optional[int] + name: Optional[str] + + new_code: str new_value: Any +@dataclass() +class ListInsert(Change): + node: ast.List + position: int + + new_code: List[str] + new_values: List[Any] + + +@dataclass() +class DictInsert(Change): + node: ast.Dict + position: int + + new_code: List[Tuple[str, str]] + new_values: List[Tuple[Any, Any]] + + @dataclass() class Replace(Change): - location: Location - value_expr: ast.Expr + node: ast.AST + new_code: str old_value: Any new_value: Any + + def apply(self): + change = ChangeRecorder.current.new_change() + range = self.source.asttokens().get_text_positions(self.node, False) + change.replace(range, self.new_code, filename=self.filename) diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index f9c00117..486d3cf2 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -6,12 +6,16 @@ from pathlib import Path from typing import Any from typing import Dict # noqa +from typing import Iterator +from typing import List from typing import overload from typing import Tuple # noqa from typing import TypeVar from executing import Source +from ._change import Change +from ._change import Replace from ._format import format_code from ._rewrite_code import ChangeRecorder from ._rewrite_code import end_of @@ -23,6 +27,10 @@ from ._utils import value_to_token +class NotImplementedYet(Exception): + pass + + snapshots = {} # type: Dict[Tuple[int, int], Snapshot] _active = False @@ -66,6 +74,7 @@ class GenericValue: _old_value: Any _current_op = "undefined" _ast_node: ast.Expr + _source: Source def _needs_trim(self): return False @@ -93,8 +102,11 @@ def _visible_value(self): def get_result(self, flags): return self._old_value - def _get_changes(self): - raise NotImplementedError + def _get_changes(self) -> List[Change]: + raise NotImplementedYet() + + def _new_code(self): + raise NotImplementedYet() def __repr__(self): return repr(self._visible_value()) @@ -127,10 +139,11 @@ def __getitem__(self, _item): class UndecidedValue(GenericValue): - def __init__(self, _old_value, _ast_node): - self._old_value = _old_value + def __init__(self, old_value, ast_node, source): + self._old_value = old_value self._new_value = undefined - self._ast_node = _ast_node + self._ast_node = ast_node + self._source = source def _change(self, cls): self.__class__ = cls @@ -172,22 +185,52 @@ def __eq__(self, other): return self._visible_value() == other - def _get_changes(self): + def _current_tokens(self): + + return list( + normalize_strings( + [ + simple_token(t.type, t.string) + for t in self._source.asttokens().get_tokens(self._ast_node) + if t.type not in ignore_tokens + ] + ) + ) + + def _format(self, text): + return format_code(text, Path(self._source.filename)) + + def _token_to_code(self, tokens): + return self._format(tokenize.untokenize(tokens)).strip() + + def _new_code(self): + return self._token_to_code(value_to_token(self._new_value)) + + def _get_changes(self) -> Iterator[Change]: if isinstance(self._new_value, (list, dict)): - raise NotImplementedError + raise NotImplementedYet() - new_code = new_repr(self._new_value) + assert self._old_value is not undefined - if self._old_value is undefined: - flag = "create" - elif not self._old_value == self._new_value: + new_token = value_to_token(self._new_value) + + if not self._old_value == self._new_value: flag = "fix" - elif old_repr(self._ast_node) != new_code: + elif self._current_tokens() != new_token: flag = "update" else: return - yield Change(self._ast_node, new_code, flag) + new_code = self._token_to_code(new_token) + + yield Replace( + node=self._ast_node, + source=self._source, + new_code=new_code, + flag=flag, + old_value=self._old_value, + new_value=self._new_value, + ) def _needs_fix(self): return self._old_value is not undefined and self._old_value != self._new_value @@ -339,7 +382,7 @@ def __getitem__(self, index): if index not in self._new_value: self._new_value[index] = UndecidedValue( - old_value.get(index, undefined), None + old_value.get(index, undefined), None, self._source ) return self._new_value[index] @@ -485,7 +528,9 @@ def used_externals(tree): class Snapshot: def __init__(self, value, expr): self._expr = expr - self._value = UndecidedValue(value, expr) + node = expr.node.args[0] if expr is not None and expr.node.args else None + source = expr.source if expr is not None else None + self._value = UndecidedValue(value, node, source) self._uses_externals = [] @property @@ -498,13 +543,40 @@ def _format(self, text): def _change(self): assert self._expr is not None - change = ChangeRecorder.current.new_change() - tokens = list(self._expr.source.asttokens().get_tokens(self._expr.node)) assert tokens[0].string == "snapshot" assert tokens[1].string == "(" assert tokens[-1].string == ")" + try: + if self._value._old_value is undefined: + if _update_flags.create: + new_code = self._value._new_code() + try: + ast.parse(new_code) + except: + new_code = "" + else: + new_code = "" + + change = ChangeRecorder.current.new_change() + change.set_tags("inline_snapshot") + change.replace( + (end_of(tokens[1]), start_of(tokens[-1])), + new_code, + filename=self._filename, + ) + return + + changes = self._value._get_changes() + for change in changes: + if change.flag in _update_flags.to_set(): + change.apply() + return + except NotImplementedYet: + pass + + change = ChangeRecorder.current.new_change() change.set_tags("inline_snapshot") needs_fix = self._value._needs_fix() diff --git a/inline_snapshot/_rewrite_code.py b/inline_snapshot/_rewrite_code.py index 0ebae3bd..9ada9742 100644 --- a/inline_snapshot/_rewrite_code.py +++ b/inline_snapshot/_rewrite_code.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ast import contextlib import logging import pathlib @@ -62,6 +63,9 @@ def start_of(obj) -> SourcePosition: if isinstance(obj, tuple) and len(obj) == 2: return SourcePosition(lineno=obj[0], col_offset=obj[1]) + if isinstance(obj, ast.AST): + return SourcePosition(lineno=obj.lineno, col_offset=obj.col_offset) + assert False @@ -72,6 +76,9 @@ def end_of(obj) -> SourcePosition: if isinstance(obj, SourceRange): return obj.end + if isinstance(obj, ast.AST): + return SourcePosition(lineno=obj.end_lineno, col_offset=obj.end_col_offset) + return start_of(obj) @@ -189,7 +196,7 @@ def activate(self): yield self ChangeRecorder.current = old_recorder - def get_source(self, filename): + def get_source(self, filename) -> SourceFile: filename = pathlib.Path(filename) if filename not in self._source_files: self._source_files[filename] = SourceFile(filename) diff --git a/inline_snapshot/pytest_plugin.py b/inline_snapshot/pytest_plugin.py index af8b5f21..0a61f43f 100644 --- a/inline_snapshot/pytest_plugin.py +++ b/inline_snapshot/pytest_plugin.py @@ -1,3 +1,4 @@ +import ast from pathlib import Path import pytest @@ -8,6 +9,7 @@ from . import _inline_snapshot from ._find_external import ensure_import from ._inline_snapshot import undefined +from ._inline_snapshot import used_externals from ._rewrite_code import ChangeRecorder @@ -162,16 +164,19 @@ def report(flag, message): count[flag] += 1 snapshot._change() - ensure_external = set() + test_files = set() for snapshot in _inline_snapshot.snapshots.values(): - if snapshot._uses_externals: - ensure_external.add(snapshot._filename) + test_files.add(Path(snapshot._filename)) - for external_name in snapshot._uses_externals: - _external.storage.persist(external_name) + for test_file in test_files: + tree = ast.parse(recorder.get_source(test_file).new_code()) + used = used_externals(tree) + + if used: + ensure_import(test_file, {"inline_snapshot": ["external"]}) - for filename in ensure_external: - ensure_import(filename, {"inline_snapshot": ["external"]}) + for external_name in used: + _external.storage.persist(external_name) recorder.fix_all() diff --git a/tests/conftest.py b/tests/conftest.py index 999c7eeb..c39a2a31 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,7 +36,6 @@ def w(source_code, *, flags="", reported_flags=None, number=1): assert s.flags == reported_flags assert s.number_snapshots == number - assert s.number_changes == number assert s.error == ("fix" in s.flags) s2 = s.run(*flags) diff --git a/tests/test_docs.py b/tests/test_docs.py index e033a774..b665777e 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -82,6 +82,11 @@ def test_docs(project, file, subtests): new_code = new_code.split("# Error:")[0] new_code += "# Error:" + textwrap.indent(result.errorLines(), "# ") + print("new code:") + print(new_code) + print("expected code:") + print(code) + if ( inline_snapshot._inline_snapshot._update_flags.fix ): # pragma: no cover diff --git a/tests/test_inline_snapshot.py b/tests/test_inline_snapshot.py index 28e2d3c4..483d3ad8 100644 --- a/tests/test_inline_snapshot.py +++ b/tests/test_inline_snapshot.py @@ -11,7 +11,7 @@ from inline_snapshot import _inline_snapshot from inline_snapshot import snapshot from inline_snapshot._inline_snapshot import Flags -from inline_snapshot._inline_snapshot import triple_quote +from inline_snapshot._utils import triple_quote def test_snapshot_eq(): From 110594d111ae1ff041f2f1ad2bf3b044103db310 Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Sat, 9 Mar 2024 09:41:53 +0100 Subject: [PATCH 03/17] feat: preserve not changed dict-values and list-elements --- inline_snapshot/_inline_snapshot.py | 83 ++++++++++++++++++++++------- tests/conftest.py | 8 ++- tests/test_preserve_values.py | 15 ++++++ 3 files changed, 84 insertions(+), 22 deletions(-) create mode 100644 tests/test_preserve_values.py diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index 486d3cf2..3abfbceb 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -185,13 +185,13 @@ def __eq__(self, other): return self._visible_value() == other - def _current_tokens(self): + def token_of_node(self, node): return list( normalize_strings( [ simple_token(t.type, t.string) - for t in self._source.asttokens().get_tokens(self._ast_node) + for t in self._source.asttokens().get_tokens(node) if t.type not in ignore_tokens ] ) @@ -207,30 +207,73 @@ def _new_code(self): return self._token_to_code(value_to_token(self._new_value)) def _get_changes(self) -> Iterator[Change]: - if isinstance(self._new_value, (list, dict)): - raise NotImplementedYet() assert self._old_value is not undefined - new_token = value_to_token(self._new_value) + def check(old_value, old_node, new_value): + + if ( + isinstance(old_node, ast.List) + and isinstance(new_value, list) + and isinstance(old_value, list) + ): + if len(old_value) == len(new_value) == len(old_node.elts): + for old_value_element, old_node_element, new_value_element in zip( + old_value, old_node.elts, new_value + ): + yield from check( + old_value_element, old_node_element, new_value_element + ) + return + + elif ( + isinstance(old_node, ast.Dict) + and isinstance(new_value, dict) + and isinstance(old_value, dict) + ): + if len(old_value) == len(old_node.keys): + for value, node in zip(old_value.keys(), old_node.keys): + assert node is not None + + try: + # this is just a sanity check, dicts should be ordered + node_value = ast.literal_eval(node) + except: + continue + assert node_value == value + + same_keys = old_value.keys() & new_value.keys() + new_keys = new_value.keys() - old_value.keys() + removed_keys = old_value.keys() - new_value.keys() + + for key, node in zip(old_value.keys(), old_node.values): + if key in new_value: + yield from check(old_value[key], node, new_value[key]) + + return + + # generic fallback + new_token = value_to_token(new_value) + + if not old_value == new_value: + flag = "fix" + elif self.token_of_node(old_node) != new_token: + flag = "update" + else: + return - if not self._old_value == self._new_value: - flag = "fix" - elif self._current_tokens() != new_token: - flag = "update" - else: - return + new_code = self._token_to_code(new_token) - new_code = self._token_to_code(new_token) + yield Replace( + node=old_node, + source=self._source, + new_code=new_code, + flag=flag, + old_value=old_value, + new_value=new_value, + ) - yield Replace( - node=self._ast_node, - source=self._source, - new_code=new_code, - flag=flag, - old_value=self._old_value, - new_value=self._new_value, - ) + yield from check(self._old_value, self._ast_node, self._new_value) def _needs_fix(self): return self._old_value is not undefined and self._old_value != self._new_value diff --git a/tests/conftest.py b/tests/conftest.py index c39a2a31..70e71407 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -78,7 +78,6 @@ def run(self, *flags): filename.write_text(source, "utf-8") print() - print(f'run: inline-snapshot={",".join(flags.to_set())}') print("input:") print(textwrap.indent(source, " |", lambda line: True).rstrip()) @@ -112,9 +111,14 @@ def run(self, *flags): recorder.fix_all(tags=["inline_snapshot"]) source = filename.read_text("utf-8")[len(prefix) :] + print("reported_flags:", snapshot_flags) + print( + f"run: pytest" + f' --inline-snapshot={",".join(flags.to_set())}' + if flags + else "" + ) print("output:") print(textwrap.indent(source, " |", lambda line: True).rstrip()) - print("reported_flags:", snapshot_flags) return Source( source=source, diff --git a/tests/test_preserve_values.py b/tests/test_preserve_values.py new file mode 100644 index 00000000..c01d3fb6 --- /dev/null +++ b/tests/test_preserve_values.py @@ -0,0 +1,15 @@ +from inline_snapshot import snapshot + + +def test_fix_list(check_update): + assert check_update( + """assert [1,2]==snapshot([0+1,3])""", reported_flags="update,fix", flags="fix" + ) == snapshot("""assert [1,2]==snapshot([0+1,2])""") + + +def test_fix_dict_change(check_update): + assert check_update( + """assert {1:1, 2:2}==snapshot({1:0+1, 2:3})""", + reported_flags="update,fix", + flags="fix", + ) == snapshot("""assert {1:1, 2:2}==snapshot({1:0+1, 2:2})""") From b1a3af0f55d63c59c0c8974dd34607877ca30223 Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Sat, 9 Mar 2024 13:41:57 +0100 Subject: [PATCH 04/17] feat: delete dict items --- inline_snapshot/_change.py | 31 +++++++++++++++++++++++++ inline_snapshot/_inline_snapshot.py | 35 ++++++++++++++++------------- tests/test_preserve_values.py | 14 ++++++++++++ 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/inline_snapshot/_change.py b/inline_snapshot/_change.py index 8bf6af42..63e733e2 100644 --- a/inline_snapshot/_change.py +++ b/inline_snapshot/_change.py @@ -5,6 +5,8 @@ from typing import Optional from typing import Tuple +from asttokens import ASTTokens +from asttokens.util import Token from executing import Source from ._rewrite_code import ChangeRecorder @@ -23,11 +25,40 @@ def apply(self): pass +def extend_comma(atok: ASTTokens, start: Token, end: Token) -> Tuple[Token, Token]: + prev = atok.prev_token(start) + if prev.string == ",": + return prev, end + + next = atok.next_token(end) + if next.string == ",": + return start, next + + return start, end + + @dataclass() class Delete(Change): node: ast.AST old_value: Any + def apply(self): + change = ChangeRecorder.current.new_change() + parent = self.node.parent + atok = self.source.asttokens() + if isinstance(parent, ast.Dict): + index = parent.values.index(self.node) + key = parent.keys[index] + + start, *_ = atok.get_tokens(key) + *_, end = atok.get_tokens(self.node) + + start, end = extend_comma(atok, start, end) + + change.replace((start, end), "", filename=self.filename) + else: + assert False + @dataclass() class AddArgument(Change): diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index 3abfbceb..039a284e 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -15,6 +15,7 @@ from executing import Source from ._change import Change +from ._change import Delete from ._change import Replace from ._format import format_code from ._rewrite_code import ChangeRecorder @@ -230,27 +231,29 @@ def check(old_value, old_node, new_value): isinstance(old_node, ast.Dict) and isinstance(new_value, dict) and isinstance(old_value, dict) + and len(old_value) == len(old_node.keys) ): - if len(old_value) == len(old_node.keys): - for value, node in zip(old_value.keys(), old_node.keys): - assert node is not None + for value, node in zip(old_value.keys(), old_node.keys): + assert node is not None - try: - # this is just a sanity check, dicts should be ordered - node_value = ast.literal_eval(node) - except: - continue - assert node_value == value + try: + # this is just a sanity check, dicts should be ordered + node_value = ast.literal_eval(node) + except: + continue + assert node_value == value - same_keys = old_value.keys() & new_value.keys() - new_keys = new_value.keys() - old_value.keys() - removed_keys = old_value.keys() - new_value.keys() + same_keys = old_value.keys() & new_value.keys() + new_keys = new_value.keys() - old_value.keys() + removed_keys = old_value.keys() - new_value.keys() - for key, node in zip(old_value.keys(), old_node.values): - if key in new_value: - yield from check(old_value[key], node, new_value[key]) + for key, node in zip(old_value.keys(), old_node.values): + if key in new_value: + yield from check(old_value[key], node, new_value[key]) + else: + yield Delete("fix", self._source, node, old_value[key]) - return + return # generic fallback new_token = value_to_token(new_value) diff --git a/tests/test_preserve_values.py b/tests/test_preserve_values.py index c01d3fb6..b21b63a9 100644 --- a/tests/test_preserve_values.py +++ b/tests/test_preserve_values.py @@ -13,3 +13,17 @@ def test_fix_dict_change(check_update): reported_flags="update,fix", flags="fix", ) == snapshot("""assert {1:1, 2:2}==snapshot({1:0+1, 2:2})""") + + +def test_fix_dict_remove(check_update): + assert check_update( + """assert {1:1}==snapshot({0:0, 1:0+1, 2:2})""", + reported_flags="update,fix", + flags="fix", + ) == snapshot("assert {1:1}==snapshot({ 1:0+1})") + + assert check_update( + """assert {}==snapshot({0:0})""", + reported_flags="fix", + flags="fix", + ) == snapshot("assert {}==snapshot({})") From 53b3f46a3668e8b48ff354a02e0b598d4073424a Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Sat, 9 Mar 2024 15:08:48 +0100 Subject: [PATCH 05/17] feat: insert dict items --- inline_snapshot/_change.py | 15 +++++++++++ inline_snapshot/_inline_snapshot.py | 42 ++++++++++++++++++++++++++++- tests/test_preserve_values.py | 10 +++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/inline_snapshot/_change.py b/inline_snapshot/_change.py index 63e733e2..4fdb171a 100644 --- a/inline_snapshot/_change.py +++ b/inline_snapshot/_change.py @@ -88,6 +88,21 @@ class DictInsert(Change): new_code: List[Tuple[str, str]] new_values: List[Tuple[Any, Any]] + def apply(self): + change = ChangeRecorder.current.new_change() + atok = self.source.asttokens() + code = ",".join(f"{k}:{v}" for k, v in self.new_code) + + if self.position == len(self.node.keys): + *_, token = atok.get_tokens(self.node.values[-1]) + token = atok.next_token(token) + code = ", " + code + else: + token, *_ = atok.get_tokens(self.node.keys[self.position]) + code = code + ", " + + change.insert(token, code, filename=self.filename) + @dataclass() class Replace(Change): diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index 039a284e..cc44ffae 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -16,6 +16,7 @@ from ._change import Change from ._change import Delete +from ._change import DictInsert from ._change import Replace from ._format import format_code from ._rewrite_code import ChangeRecorder @@ -204,8 +205,11 @@ def _format(self, text): def _token_to_code(self, tokens): return self._format(tokenize.untokenize(tokens)).strip() + def _value_to_code(self, value): + return self._token_to_code(value_to_token(value)) + def _new_code(self): - return self._token_to_code(value_to_token(self._new_value)) + return self._value_to_code(self._new_value) def _get_changes(self) -> Iterator[Change]: @@ -253,6 +257,42 @@ def check(old_value, old_node, new_value): else: yield Delete("fix", self._source, node, old_value[key]) + to_insert = [] + insert_pos = 0 + for key, new_value_element in new_value.items(): + if key not in old_value: + to_insert.append((key, new_value_element)) + else: + if to_insert: + new_code = [ + (self._value_to_code(k), self._value_to_code(v)) + for k, v in to_insert + ] + yield DictInsert( + "fix", + self._source, + node.parent, + insert_pos, + new_code, + to_insert, + ) + to_insert = [] + insert_pos += 1 + + if to_insert: + new_code = [ + (self._value_to_code(k), self._value_to_code(v)) + for k, v in to_insert + ] + yield DictInsert( + "fix", + self._source, + node.parent, + insert_pos, + new_code, + to_insert, + ) + return # generic fallback diff --git a/tests/test_preserve_values.py b/tests/test_preserve_values.py index b21b63a9..8abc87bf 100644 --- a/tests/test_preserve_values.py +++ b/tests/test_preserve_values.py @@ -27,3 +27,13 @@ def test_fix_dict_remove(check_update): reported_flags="fix", flags="fix", ) == snapshot("assert {}==snapshot({})") + + +def test_fix_dict_insert(check_update): + assert check_update( + """assert {0:"before",1:1,2:"after"}==snapshot({1:0+1})""", + reported_flags="update,fix", + flags="fix", + ) == snapshot( + """assert {0:"before",1:1,2:"after"}==snapshot({0:"before", 1:0+1, 2:"after"})""" + ) From 6476154b1d9e7bb9d35ded39ca62f4ef4917fbc5 Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Sat, 9 Mar 2024 17:16:02 +0100 Subject: [PATCH 06/17] fix: fixed typing and coverage --- inline_snapshot/_change.py | 2 +- inline_snapshot/_inline_snapshot.py | 5 ++--- inline_snapshot/_rewrite_code.py | 7 ------- tests/test_preserve_values.py | 8 ++++++++ 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/inline_snapshot/_change.py b/inline_snapshot/_change.py index 4fdb171a..485fa076 100644 --- a/inline_snapshot/_change.py +++ b/inline_snapshot/_change.py @@ -22,7 +22,7 @@ def filename(self): return self.source.filename def apply(self): - pass + raise NotImplementedError() def extend_comma(atok: ASTTokens, start: Token, end: Token) -> Tuple[Token, Token]: diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index cc44ffae..1ad53dd2 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -7,7 +7,6 @@ from typing import Any from typing import Dict # noqa from typing import Iterator -from typing import List from typing import overload from typing import Tuple # noqa from typing import TypeVar @@ -104,7 +103,7 @@ def _visible_value(self): def get_result(self, flags): return self._old_value - def _get_changes(self) -> List[Change]: + def _get_changes(self) -> Iterator[Change]: raise NotImplementedYet() def _new_code(self): @@ -686,7 +685,7 @@ def _change(self): try: tree = ast.parse(text) - except: + except: # pragma: no cover return self._uses_externals = used_externals(tree) diff --git a/inline_snapshot/_rewrite_code.py b/inline_snapshot/_rewrite_code.py index 9ada9742..a569f0e2 100644 --- a/inline_snapshot/_rewrite_code.py +++ b/inline_snapshot/_rewrite_code.py @@ -1,6 +1,5 @@ from __future__ import annotations -import ast import contextlib import logging import pathlib @@ -63,9 +62,6 @@ def start_of(obj) -> SourcePosition: if isinstance(obj, tuple) and len(obj) == 2: return SourcePosition(lineno=obj[0], col_offset=obj[1]) - if isinstance(obj, ast.AST): - return SourcePosition(lineno=obj.lineno, col_offset=obj.col_offset) - assert False @@ -76,9 +72,6 @@ def end_of(obj) -> SourcePosition: if isinstance(obj, SourceRange): return obj.end - if isinstance(obj, ast.AST): - return SourcePosition(lineno=obj.end_lineno, col_offset=obj.end_col_offset) - return start_of(obj) diff --git a/tests/test_preserve_values.py b/tests/test_preserve_values.py index 8abc87bf..79b27268 100644 --- a/tests/test_preserve_values.py +++ b/tests/test_preserve_values.py @@ -37,3 +37,11 @@ def test_fix_dict_insert(check_update): ) == snapshot( """assert {0:"before",1:1,2:"after"}==snapshot({0:"before", 1:0+1, 2:"after"})""" ) + + +def test_fix_dict_with_non_literal_keys(check_update): + assert check_update( + """assert {1+2:"3"}==snapshot({1+2:"5"})""", + reported_flags="update,fix", + flags="fix", + ) == snapshot('assert {1+2:"3"}==snapshot({1+2:"3"})') From ef1fef2b2cf392dc6a87bbf081a31783f3bc5c3f Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Mon, 11 Mar 2024 21:42:08 +0100 Subject: [PATCH 07/17] feat: fix lists by calculating the alignment of the changed values --- inline_snapshot/_align.py | 64 +++++++++++++++++++++++++++++ inline_snapshot/_change.py | 32 +++++++++++++-- inline_snapshot/_inline_snapshot.py | 41 +++++++++++++++--- inline_snapshot/_rewrite_code.py | 20 ++++++--- tests/test_align.py | 10 +++++ tests/test_preserve_values.py | 20 ++++++++- 6 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 inline_snapshot/_align.py create mode 100644 tests/test_align.py diff --git a/inline_snapshot/_align.py b/inline_snapshot/_align.py new file mode 100644 index 00000000..ef708888 --- /dev/null +++ b/inline_snapshot/_align.py @@ -0,0 +1,64 @@ +from itertools import groupby + + +def align(seq_a, seq_b) -> str: + + matrix: list = [[(0, "e")] + [(0, "i")] * len(seq_b)] + + for a in seq_a: + last = matrix[-1] + + new_line = [(0, "d")] + for bi, b in enumerate(seq_b, 1): + la, lc, lb = new_line[-1], last[bi - 1], last[bi] + values = [(la[0], "i"), (lb[0], "d")] + if a == b: + values.append((lc[0] + 1, "m")) + + new_line.append(max(values)) + matrix.append(new_line) + + # backtrack + + ai = len(seq_a) + bi = len(seq_b) + d = "" + track = "" + + while d != "e": + _, d = matrix[ai][bi] + if d == "m": + ai -= 1 + bi -= 1 + elif d == "i": + bi -= 1 + elif d == "d": + ai -= 1 + if d != "e": + track += d + + return track[::-1] + + +def add_x(track): + """Replaces an `id` with the same number of insertions and deletions with + x.""" + groups = [(c, len(list(v))) for c, v in groupby(track)] + i = 0 + result = "" + while i < len(groups): + g = groups[i] + if i == len(groups) - 1: + result += g[0] * g[1] + break + + ng = groups[i + 1] + if g[0] == "d" and ng[0] == "i" and g[1] == ng[1]: + result += "x" * g[1] + i += 1 + else: + result += g[0] * g[1] + + i += 1 + + return result diff --git a/inline_snapshot/_change.py b/inline_snapshot/_change.py index 485fa076..d511b7f1 100644 --- a/inline_snapshot/_change.py +++ b/inline_snapshot/_change.py @@ -26,9 +26,9 @@ def apply(self): def extend_comma(atok: ASTTokens, start: Token, end: Token) -> Tuple[Token, Token]: - prev = atok.prev_token(start) - if prev.string == ",": - return prev, end + # prev = atok.prev_token(start) + # if prev.string == ",": + # return prev, end next = atok.next_token(end) if next.string == ",": @@ -55,6 +55,13 @@ def apply(self): start, end = extend_comma(atok, start, end) + change.replace((start, end), "", filename=self.filename) + elif isinstance(parent, ast.List): + tokens = list(atok.get_tokens(self.node)) + start, end = tokens[0], tokens[-1] + + start, end = extend_comma(atok, start, end) + change.replace((start, end), "", filename=self.filename) else: assert False @@ -79,6 +86,25 @@ class ListInsert(Change): new_code: List[str] new_values: List[Any] + def apply(self): + change = ChangeRecorder.current.new_change() + atok = self.source.asttokens() + + code = ", ".join(self.new_code) + + assert self.position <= len(self.node.elts) + + if self.position == len(self.node.elts): + *_, token = atok.get_tokens(self.node) + assert token.string == "]" + if self.position != 0: + code = ", " + code + else: + token, *_ = atok.get_tokens(self.node.elts[self.position]) + code = code + ", " + + change.insert(token, code, filename=self.filename) + @dataclass() class DictInsert(Change): diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index 1ad53dd2..7e71590b 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -3,6 +3,7 @@ import inspect import sys import tokenize +from collections import defaultdict from pathlib import Path from typing import Any from typing import Dict # noqa @@ -13,9 +14,12 @@ from executing import Source +from ._align import add_x +from ._align import align from ._change import Change from ._change import Delete from ._change import DictInsert +from ._change import ListInsert from ._change import Replace from ._format import format_code from ._rewrite_code import ChangeRecorder @@ -220,15 +224,42 @@ def check(old_value, old_node, new_value): isinstance(old_node, ast.List) and isinstance(new_value, list) and isinstance(old_value, list) + or isinstance(old_node, ast.Tuple) + and isinstance(new_value, tuple) + and isinstance(old_value, tuple) ): - if len(old_value) == len(new_value) == len(old_node.elts): - for old_value_element, old_node_element, new_value_element in zip( - old_value, old_node.elts, new_value - ): + diff = add_x(align(old_value, new_value)) + old = zip(old_value, old_node.elts) + new = iter(new_value) + old_position = 0 + to_insert = defaultdict(list) + for c in diff: + if c in "mx": + old_value_element, old_node_element = next(old) + new_value_element = next(new) yield from check( old_value_element, old_node_element, new_value_element ) - return + old_position += 1 + elif c == "i": + new_value_element = next(new) + new_code = self._value_to_code(new_value_element) + to_insert[old_position].append((new_code, new_value_element)) + elif c == "d": + old_value_element, old_node_element = next(old) + yield Delete( + "fix", self._source, old_node_element, old_value_element + ) + old_position += 1 + else: + assert False + + for position, code_values in to_insert.items(): + yield ListInsert( + "fix", self._source, old_node, position, *zip(*code_values) + ) + + return elif ( isinstance(old_node, ast.Dict) diff --git a/inline_snapshot/_rewrite_code.py b/inline_snapshot/_rewrite_code.py index a569f0e2..aa58e8e2 100644 --- a/inline_snapshot/_rewrite_code.py +++ b/inline_snapshot/_rewrite_code.py @@ -117,9 +117,11 @@ def insert(self, node, new_content, *, filename): self.replace(start_of(node), new_content, filename=filename) def _replace(self, filename, range, new_contend): - self.change_recorder.get_source(filename).replacements.append( + source = self.change_recorder.get_source(filename) + source.replacements.append( Replacement(range=range, text=new_contend, change_id=self.change_id) ) + source._check() class SourceFile: @@ -133,17 +135,23 @@ def rewrite(self): with open(self.filename, "bw") as code: code.write(new_code.encode()) - def new_code(self): - """Returns the new file contend or None if there are no replacepents to - apply.""" + def _check(self): replacements = list(self.replacements) replacements.sort() for r in replacements: - assert r.range.start <= r.range.end + assert r.range.start <= r.range.end, r for lhs, rhs in pairwise(replacements): - assert lhs.range.end <= rhs.range.start + assert lhs.range.end <= rhs.range.start, (lhs, rhs) + + def new_code(self): + """Returns the new file contend or None if there are no replacepents to + apply.""" + replacements = list(self.replacements) + replacements.sort() + + self._check() code = self.filename.read_text("utf-8") diff --git a/tests/test_align.py b/tests/test_align.py new file mode 100644 index 00000000..02c1a86d --- /dev/null +++ b/tests/test_align.py @@ -0,0 +1,10 @@ +from inline_snapshot import snapshot +from inline_snapshot._align import add_x +from inline_snapshot._align import align + + +def test_align(): + assert align("iabc", "abcd") == snapshot("dmmmi") + + assert align("abbc", "axyc") == snapshot("mddiim") + assert add_x(align("abbc", "axyc")) == snapshot("mxxm") diff --git a/tests/test_preserve_values.py b/tests/test_preserve_values.py index 79b27268..0b207ac9 100644 --- a/tests/test_preserve_values.py +++ b/tests/test_preserve_values.py @@ -1,12 +1,28 @@ from inline_snapshot import snapshot -def test_fix_list(check_update): +def test_fix_list_fix(check_update): assert check_update( """assert [1,2]==snapshot([0+1,3])""", reported_flags="update,fix", flags="fix" ) == snapshot("""assert [1,2]==snapshot([0+1,2])""") +def test_fix_list_insert(check_update): + assert check_update( + """assert [1,2,3,4,5,6]==snapshot([0+1,3])""", + reported_flags="update,fix", + flags="fix", + ) == snapshot("assert [1,2,3,4,5,6]==snapshot([0+1,2, 3, 4, 5, 6])") + + +def test_fix_list_delete(check_update): + assert check_update( + """assert [1,5]==snapshot([0+1,2,3,4,5])""", + reported_flags="update,fix", + flags="fix", + ) == snapshot("assert [1,5]==snapshot([0+1,5])") + + def test_fix_dict_change(check_update): assert check_update( """assert {1:1, 2:2}==snapshot({1:0+1, 2:3})""", @@ -20,7 +36,7 @@ def test_fix_dict_remove(check_update): """assert {1:1}==snapshot({0:0, 1:0+1, 2:2})""", reported_flags="update,fix", flags="fix", - ) == snapshot("assert {1:1}==snapshot({ 1:0+1})") + ) == snapshot("assert {1:1}==snapshot({ 1:0+1, })") assert check_update( """assert {}==snapshot({0:0})""", From de7377ddfefc3e32a9f0c578b3b03240e8900b77 Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Tue, 19 Mar 2024 19:29:47 +0100 Subject: [PATCH 08/17] fix: handle lists with mulitple insertions and deletions --- inline_snapshot/_change.py | 133 ++++++++++++++++++----- inline_snapshot/_inline_snapshot.py | 7 +- tests/test_preserve_values.py | 160 +++++++++++++++++++++++++++- 3 files changed, 268 insertions(+), 32 deletions(-) diff --git a/inline_snapshot/_change.py b/inline_snapshot/_change.py index d511b7f1..5dc5b957 100644 --- a/inline_snapshot/_change.py +++ b/inline_snapshot/_change.py @@ -1,15 +1,22 @@ import ast +from collections import defaultdict from dataclasses import dataclass from typing import Any +from typing import cast +from typing import Dict from typing import List from typing import Optional from typing import Tuple +from typing import Union from asttokens import ASTTokens from asttokens.util import Token -from executing import Source +from executing.executing import EnhancedAST +from executing.executing import Source from ._rewrite_code import ChangeRecorder +from ._rewrite_code import end_of +from ._rewrite_code import start_of @dataclass() @@ -55,13 +62,6 @@ def apply(self): start, end = extend_comma(atok, start, end) - change.replace((start, end), "", filename=self.filename) - elif isinstance(parent, ast.List): - tokens = list(atok.get_tokens(self.node)) - start, end = tokens[0], tokens[-1] - - start, end = extend_comma(atok, start, end) - change.replace((start, end), "", filename=self.filename) else: assert False @@ -86,25 +86,6 @@ class ListInsert(Change): new_code: List[str] new_values: List[Any] - def apply(self): - change = ChangeRecorder.current.new_change() - atok = self.source.asttokens() - - code = ", ".join(self.new_code) - - assert self.position <= len(self.node.elts) - - if self.position == len(self.node.elts): - *_, token = atok.get_tokens(self.node) - assert token.string == "]" - if self.position != 0: - code = ", " + code - else: - token, *_ = atok.get_tokens(self.node.elts[self.position]) - code = code + ", " - - change.insert(token, code, filename=self.filename) - @dataclass() class DictInsert(Change): @@ -142,3 +123,101 @@ def apply(self): change = ChangeRecorder.current.new_change() range = self.source.asttokens().get_text_positions(self.node, False) change.replace(range, self.new_code, filename=self.filename) + + +def apply_all(all_changes: List[Change]): + by_parent: Dict[EnhancedAST, List[Union[Delete, DictInsert, ListInsert]]] = ( + defaultdict(list) + ) + sources = {} + + for change in all_changes: + if isinstance(change, Delete): + node = cast(EnhancedAST, change.node).parent + by_parent[node].append(change) + sources[node] = change.source + + elif isinstance(change, (DictInsert, ListInsert)): + node = cast(EnhancedAST, change.node) + by_parent[node].append(change) + sources[node] = change.source + else: + change.apply() + + for parent, changes in by_parent.items(): + source = sources[parent] + print(parent, changes) + + rec = ChangeRecorder.current.new_change() + if isinstance(parent, (ast.List, ast.Tuple)): + to_delete = { + change.node for change in changes if isinstance(change, Delete) + } + to_insert = { + change.position: change + for change in changes + if isinstance(change, ListInsert) + } + + new_code = [] + deleted = False + last_token, *_, end_token = source.asttokens().get_tokens(parent) + is_start = True + elements = 0 + + for index, entry in enumerate(parent.elts): + if index in to_insert: + new_code += to_insert[index].new_code + print("insert", entry, new_code) + if entry in to_delete: + deleted = True + print("delete1", entry) + else: + entry_tokens = list(source.asttokens().get_tokens(entry)) + first_token = entry_tokens[0] + new_last_token = entry_tokens[-1] + elements += len(new_code) + 1 + + if deleted or new_code: + print("change", deleted, new_code) + + code = "" + if new_code: + code = ", ".join(new_code) + ", " + if not is_start: + code = ", " + code + print("code", code) + + rec.replace( + (end_of(last_token), start_of(first_token)), + code, + filename=source.filename, + ) + print("keep", entry) + new_code = [] + deleted = False + last_token = new_last_token + is_start = False + + if len(parent.elts) in to_insert: + new_code += to_insert[len(parent.elts)].new_code + elements += len(new_code) + + if new_code or deleted or elements == 1 or len(parent.elts) <= 1: + code = ", ".join(new_code) + if not is_start and code: + code = ", " + code + + if elements == 1 and isinstance(parent, ast.Tuple): + # trailing comma for tuples (1,)i + code += "," + + rec.replace( + (end_of(last_token), start_of(end_token)), + code, + filename=source.filename, + ) + + else: + for change in changes: + change.apply() diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index 7e71590b..93d1a537 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -16,6 +16,7 @@ from ._align import add_x from ._align import align +from ._change import apply_all from ._change import Change from ._change import Delete from ._change import DictInsert @@ -685,9 +686,9 @@ def _change(self): return changes = self._value._get_changes() - for change in changes: - if change.flag in _update_flags.to_set(): - change.apply() + apply_all( + [change for change in changes if change.flag in _update_flags.to_set()] + ) return except NotImplementedYet: pass diff --git a/tests/test_preserve_values.py b/tests/test_preserve_values.py index 0b207ac9..c96fa13f 100644 --- a/tests/test_preserve_values.py +++ b/tests/test_preserve_values.py @@ -1,3 +1,5 @@ +import itertools + from inline_snapshot import snapshot @@ -12,7 +14,7 @@ def test_fix_list_insert(check_update): """assert [1,2,3,4,5,6]==snapshot([0+1,3])""", reported_flags="update,fix", flags="fix", - ) == snapshot("assert [1,2,3,4,5,6]==snapshot([0+1,2, 3, 4, 5, 6])") + ) == snapshot("assert [1,2,3,4,5,6]==snapshot([0+1, 2, 3, 4, 5, 6])") def test_fix_list_delete(check_update): @@ -20,7 +22,15 @@ def test_fix_list_delete(check_update): """assert [1,5]==snapshot([0+1,2,3,4,5])""", reported_flags="update,fix", flags="fix", - ) == snapshot("assert [1,5]==snapshot([0+1,5])") + ) == snapshot("assert [1,5]==snapshot([0+1, 5])") + + +def test_fix_tuple_delete(check_update): + assert check_update( + """assert (1,5)==snapshot((0+1,2,3,4,5))""", + reported_flags="update,fix", + flags="fix", + ) == snapshot("assert (1,5)==snapshot((0+1, 5))") def test_fix_dict_change(check_update): @@ -61,3 +71,149 @@ def test_fix_dict_with_non_literal_keys(check_update): reported_flags="update,fix", flags="fix", ) == snapshot('assert {1+2:"3"}==snapshot({1+2:"3"})') + + +# @pytest.mark.skipif(not hasattr(ast, "unparse"), reason="ast.unparse not available") +def test_preserve_case_from_original_mr(check_update): + assert ( + check_update( + """\ +left = { + "a": 1, + "b": { + "c": 2, + "d": [ + 3, + 4, + 5, + ], + }, + "e": ( + { + "f": 6, + "g": 7, + }, + ), +} +assert left == snapshot( + { + "a": 10, + "b": { + "c": 2 * 1 + 0, + "d": [ + int(3), + 40, + 5, + ], + "h": 8, + }, + "e": ( + { + "f": 3 + 3, + }, + 9, + ), + } +) +""", + reported_flags="update,fix", + flags="fix", + ) + == snapshot( + """\ +left = { + "a": 1, + "b": { + "c": 2, + "d": [ + 3, + 4, + 5, + ], + }, + "e": ( + { + "f": 6, + "g": 7, + }, + ), +} +assert left == snapshot( + { + "a": 1, + "b": { + "c": 2 * 1 + 0, + "d": [ + int(3), + 4, + 5, + ], + }, + "e": ({"f": 6, "g": 7},), + } +) +""" + ) + ) + + +stuff = [ + (["5"], [], "delete", {"fix"}), + ([], ["8"], "insert", {"fix"}), + (["2+2"], ["4"], "update", {"update"}), + (["3"], ["3"], "no_change", set()), +] + + +def test_generic(source, subtests): + codes = [] + + for braces in ("[]", "()"): + for s in itertools.product(stuff, repeat=3): + flags = set().union(*[e[3] for e in s]) + name = ",".join(e[2] for e in s) + print(flags) + all_flags = { + frozenset(x) - {""} + for x in itertools.combinations_with_replacement( + flags | {""}, len(flags) + ) + } + print(all_flags) + + def build(l): + values = [x for e in l for x in e] + + code = ", ".join(values) + + comma = "" + if len(values) == 1 and braces == "()": + comma = "," + + return f"{braces[0]}{code}{comma}{braces[1]}" + + c1 = build(e[0] for e in s) + c2 = build(e[1] for e in s) + code = f"assert {c2}==snapshot({c1})" + + named_flags = ", ".join(flags) + with subtests.test(f"{c1} -> {c2} <{named_flags}>"): + + s1 = source(code) + print("source:", code) + + assert set(s1.flags) == flags + + assert ("fix" in flags) == s1.error + + for f in all_flags: + c3 = build([(e[1] if e[3] & f else e[0]) for e in s]) + new_code = f"assert {c2}==snapshot({c3})" + + print(f"{set(f)}:") + print(" ", code) + print(" ", new_code) + + s2 = s1.run(*f) + assert s2.source == new_code + # assert s2.flags== flags-f From 454d3731d6f8dd64c61f8bb2991b1e9656c46eda Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Fri, 22 Mar 2024 12:44:06 +0100 Subject: [PATCH 09/17] fix: handle dicts with mulitple insertions and deletions --- inline_snapshot/_change.py | 206 ++++++++++++++-------------- inline_snapshot/_inline_snapshot.py | 9 +- tests/test_preserve_values.py | 38 ++--- 3 files changed, 128 insertions(+), 125 deletions(-) diff --git a/inline_snapshot/_change.py b/inline_snapshot/_change.py index 5dc5b957..cab50067 100644 --- a/inline_snapshot/_change.py +++ b/inline_snapshot/_change.py @@ -9,7 +9,6 @@ from typing import Tuple from typing import Union -from asttokens import ASTTokens from asttokens.util import Token from executing.executing import EnhancedAST from executing.executing import Source @@ -32,40 +31,11 @@ def apply(self): raise NotImplementedError() -def extend_comma(atok: ASTTokens, start: Token, end: Token) -> Tuple[Token, Token]: - # prev = atok.prev_token(start) - # if prev.string == ",": - # return prev, end - - next = atok.next_token(end) - if next.string == ",": - return start, next - - return start, end - - @dataclass() class Delete(Change): node: ast.AST old_value: Any - def apply(self): - change = ChangeRecorder.current.new_change() - parent = self.node.parent - atok = self.source.asttokens() - if isinstance(parent, ast.Dict): - index = parent.values.index(self.node) - key = parent.keys[index] - - start, *_ = atok.get_tokens(key) - *_, end = atok.get_tokens(self.node) - - start, end = extend_comma(atok, start, end) - - change.replace((start, end), "", filename=self.filename) - else: - assert False - @dataclass() class AddArgument(Change): @@ -95,21 +65,6 @@ class DictInsert(Change): new_code: List[Tuple[str, str]] new_values: List[Tuple[Any, Any]] - def apply(self): - change = ChangeRecorder.current.new_change() - atok = self.source.asttokens() - code = ",".join(f"{k}:{v}" for k, v in self.new_code) - - if self.position == len(self.node.keys): - *_, token = atok.get_tokens(self.node.values[-1]) - token = atok.next_token(token) - code = ", " + code - else: - token, *_ = atok.get_tokens(self.node.keys[self.position]) - code = code + ", " - - change.insert(token, code, filename=self.filename) - @dataclass() class Replace(Change): @@ -125,11 +80,75 @@ def apply(self): change.replace(range, self.new_code, filename=self.filename) +TokenRange = Tuple[Token, Token] + + +def generic_sequence_update( + source: Source, + parent: Union[ast.List, ast.Tuple, ast.Dict], + parent_elements: List[Union[TokenRange, None]], + to_insert: Dict[int, List[str]], +): + rec = ChangeRecorder.current.new_change() + + new_code = [] + deleted = False + last_token, *_, end_token = source.asttokens().get_tokens(parent) + is_start = True + elements = 0 + + for index, entry in enumerate(parent_elements): + if index in to_insert: + new_code += to_insert[index] + if entry is None: + deleted = True + else: + first_token, new_last_token = entry + elements += len(new_code) + 1 + + if deleted or new_code: + + code = "" + if new_code: + code = ", ".join(new_code) + ", " + if not is_start: + code = ", " + code + + rec.replace( + (end_of(last_token), start_of(first_token)), + code, + filename=source.filename, + ) + new_code = [] + deleted = False + last_token = new_last_token + is_start = False + + if len(parent_elements) in to_insert: + new_code += to_insert[len(parent_elements)] + elements += len(new_code) + + if new_code or deleted or elements == 1 or len(parent_elements) <= 1: + code = ", ".join(new_code) + if not is_start and code: + code = ", " + code + + if elements == 1 and isinstance(parent, ast.Tuple): + # trailing comma for tuples (1,)i + code += "," + + rec.replace( + (end_of(last_token), start_of(end_token)), + code, + filename=source.filename, + ) + + def apply_all(all_changes: List[Change]): by_parent: Dict[EnhancedAST, List[Union[Delete, DictInsert, ListInsert]]] = ( defaultdict(list) ) - sources = {} + sources: Dict[EnhancedAST, Source] = {} for change in all_changes: if isinstance(change, Delete): @@ -146,78 +165,53 @@ def apply_all(all_changes: List[Change]): for parent, changes in by_parent.items(): source = sources[parent] - print(parent, changes) - rec = ChangeRecorder.current.new_change() if isinstance(parent, (ast.List, ast.Tuple)): to_delete = { change.node for change in changes if isinstance(change, Delete) } to_insert = { - change.position: change + change.position: change.new_code for change in changes if isinstance(change, ListInsert) } - new_code = [] - deleted = False - last_token, *_, end_token = source.asttokens().get_tokens(parent) - is_start = True - elements = 0 - - for index, entry in enumerate(parent.elts): - if index in to_insert: - new_code += to_insert[index].new_code - print("insert", entry, new_code) - if entry in to_delete: - deleted = True - print("delete1", entry) - else: - entry_tokens = list(source.asttokens().get_tokens(entry)) - first_token = entry_tokens[0] - new_last_token = entry_tokens[-1] - elements += len(new_code) + 1 - - if deleted or new_code: - print("change", deleted, new_code) - - code = "" - if new_code: - code = ", ".join(new_code) + ", " - if not is_start: - code = ", " + code - print("code", code) - - rec.replace( - (end_of(last_token), start_of(first_token)), - code, - filename=source.filename, - ) - print("keep", entry) - new_code = [] - deleted = False - last_token = new_last_token - is_start = False - - if len(parent.elts) in to_insert: - new_code += to_insert[len(parent.elts)].new_code - elements += len(new_code) - - if new_code or deleted or elements == 1 or len(parent.elts) <= 1: - code = ", ".join(new_code) - if not is_start and code: - code = ", " + code + def list_token_range(entry): + r = list(source.asttokens().get_tokens(entry)) + return r[0], r[-1] - if elements == 1 and isinstance(parent, ast.Tuple): - # trailing comma for tuples (1,)i - code += "," + generic_sequence_update( + source, + parent, + [None if e in to_delete else list_token_range(e) for e in parent.elts], + to_insert, + ) - rec.replace( - (end_of(last_token), start_of(end_token)), - code, - filename=source.filename, + elif isinstance(parent, (ast.Dict)): + to_delete = { + change.node for change in changes if isinstance(change, Delete) + } + to_insert = { + change.position: [f"{key}: {value}" for key, value in change.new_code] + for change in changes + if isinstance(change, DictInsert) + } + + def dict_token_range(key, value): + return ( + list(source.asttokens().get_tokens(key))[0], + list(source.asttokens().get_tokens(value))[-1], ) + generic_sequence_update( + source, + parent, + [ + None if value in to_delete else dict_token_range(key, value) + for key, value in zip(parent.keys, parent.values) + ], + to_insert, + ) + else: - for change in changes: - change.apply() + assert False diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index 93d1a537..50086634 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -284,14 +284,17 @@ def check(old_value, old_node, new_value): for key, node in zip(old_value.keys(), old_node.values): if key in new_value: + # check values with same keys yield from check(old_value[key], node, new_value[key]) else: + # delete entries yield Delete("fix", self._source, node, old_value[key]) to_insert = [] insert_pos = 0 for key, new_value_element in new_value.items(): if key not in old_value: + # add new values to_insert.append((key, new_value_element)) else: if to_insert: @@ -302,7 +305,7 @@ def check(old_value, old_node, new_value): yield DictInsert( "fix", self._source, - node.parent, + old_node, insert_pos, new_code, to_insert, @@ -318,8 +321,8 @@ def check(old_value, old_node, new_value): yield DictInsert( "fix", self._source, - node.parent, - insert_pos, + old_node, + len(old_node.values), new_code, to_insert, ) diff --git a/tests/test_preserve_values.py b/tests/test_preserve_values.py index c96fa13f..151a4eb9 100644 --- a/tests/test_preserve_values.py +++ b/tests/test_preserve_values.py @@ -46,7 +46,7 @@ def test_fix_dict_remove(check_update): """assert {1:1}==snapshot({0:0, 1:0+1, 2:2})""", reported_flags="update,fix", flags="fix", - ) == snapshot("assert {1:1}==snapshot({ 1:0+1, })") + ) == snapshot("assert {1:1}==snapshot({1:0+1})") assert check_update( """assert {}==snapshot({0:0})""", @@ -61,7 +61,7 @@ def test_fix_dict_insert(check_update): reported_flags="update,fix", flags="fix", ) == snapshot( - """assert {0:"before",1:1,2:"after"}==snapshot({0:"before", 1:0+1, 2:"after"})""" + 'assert {0:"before",1:1,2:"after"}==snapshot({0: "before", 1:0+1, 2: "after"})' ) @@ -166,23 +166,27 @@ def test_preserve_case_from_original_mr(check_update): def test_generic(source, subtests): - codes = [] - - for braces in ("[]", "()"): - for s in itertools.product(stuff, repeat=3): - flags = set().union(*[e[3] for e in s]) - name = ",".join(e[2] for e in s) - print(flags) + for braces in ("[]", "()", "{}"): + for value_specs in itertools.product(stuff, repeat=3): + flags = set().union(*[e[3] for e in value_specs]) all_flags = { frozenset(x) - {""} for x in itertools.combinations_with_replacement( flags | {""}, len(flags) ) } - print(all_flags) - def build(l): - values = [x for e in l for x in e] + def build(value_lists): + value_lists = list(value_lists) + + if braces == "{}": + values = [ + f"{i}: {value_list[0]}" + for i, value_list in enumerate(value_lists) + if value_list + ] + else: + values = [x for value_list in value_lists for x in value_list] code = ", ".join(values) @@ -192,13 +196,13 @@ def build(l): return f"{braces[0]}{code}{comma}{braces[1]}" - c1 = build(e[0] for e in s) - c2 = build(e[1] for e in s) + c1 = build(spec[0] for spec in value_specs) + c2 = build(spec[1] for spec in value_specs) code = f"assert {c2}==snapshot({c1})" named_flags = ", ".join(flags) - with subtests.test(f"{c1} -> {c2} <{named_flags}>"): + with subtests.test(f"{c1} -> {c2} <{named_flags}>"): s1 = source(code) print("source:", code) @@ -207,7 +211,9 @@ def build(l): assert ("fix" in flags) == s1.error for f in all_flags: - c3 = build([(e[1] if e[3] & f else e[0]) for e in s]) + c3 = build( + [(spec[1] if spec[3] & f else spec[0]) for spec in value_specs] + ) new_code = f"assert {c2}==snapshot({c3})" print(f"{set(f)}:") From 070e64f1fb477010faa931e4c25b66e02fb6db73 Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Mon, 25 Mar 2024 12:52:03 +0100 Subject: [PATCH 10/17] refactor: use _get_changes api for MinMaxValue --- inline_snapshot/_inline_snapshot.py | 66 +++++++++++++++++++---------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index 50086634..c7090c23 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -82,6 +82,27 @@ class GenericValue: _ast_node: ast.Expr _source: Source + def _token_of_node(self, node): + + return list( + normalize_strings( + [ + simple_token(t.type, t.string) + for t in self._source.asttokens().get_tokens(node) + if t.type not in ignore_tokens + ] + ) + ) + + def _format(self, text): + return format_code(text, Path(self._source.filename)) + + def _token_to_code(self, tokens): + return self._format(tokenize.untokenize(tokens)).strip() + + def _value_to_code(self, value): + return self._token_to_code(value_to_token(value)) + def _needs_trim(self): return False @@ -191,27 +212,6 @@ def __eq__(self, other): return self._visible_value() == other - def token_of_node(self, node): - - return list( - normalize_strings( - [ - simple_token(t.type, t.string) - for t in self._source.asttokens().get_tokens(node) - if t.type not in ignore_tokens - ] - ) - ) - - def _format(self, text): - return format_code(text, Path(self._source.filename)) - - def _token_to_code(self, tokens): - return self._format(tokenize.untokenize(tokens)).strip() - - def _value_to_code(self, value): - return self._token_to_code(value_to_token(value)) - def _new_code(self): return self._value_to_code(self._new_value) @@ -334,7 +334,7 @@ def check(old_value, old_node, new_value): if not old_value == new_value: flag = "fix" - elif self.token_of_node(old_node) != new_token: + elif self._token_of_node(old_node) != new_token: flag = "update" else: return @@ -403,6 +403,28 @@ def get_result(self, flags): return self._old_value + def _get_changes(self) -> Iterator[Change]: + new_token = value_to_token(self._new_value) + if not self.cmp(self._old_value, self._new_value): + flag = "fix" + elif not self.cmp(self._new_value, self._old_value): + flag = "trim" + elif self._token_of_node(self._ast_node) != new_token: + flag = "update" + else: + return + + new_code = self._token_to_code(new_token) + + yield Replace( + node=self._ast_node, + source=self._source, + new_code=new_code, + flag=flag, + old_value=self._old_value, + new_value=self._new_value, + ) + class MinValue(MinMaxValue): """ From 38340964efd9ac4969995a3a8970795365b44239 Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Mon, 25 Mar 2024 20:13:31 +0100 Subject: [PATCH 11/17] refactor: use _get_changes api for CollectionValue --- inline_snapshot/_inline_snapshot.py | 37 +++++++++++++++++++++++++++++ tests/test_inline_snapshot.py | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index c7090c23..58be23fb 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -510,6 +510,43 @@ def get_result(self, flags): return self._old_value + def _get_changes(self) -> Iterator[Change]: + + assert isinstance(self._ast_node, ast.List) + + for old_value, old_node in zip(self._old_value, self._ast_node.elts): + if old_value not in self._new_value: + yield Delete( + flag="trim", source=self._source, node=old_node, old_value=old_value + ) + continue + + # check for update + new_token = value_to_token(old_value) + + if self._token_of_node(old_node) != new_token: + new_code = self._token_to_code(new_token) + + yield Replace( + node=old_node, + source=self._source, + new_code=new_code, + flag="update", + old_value=old_value, + new_value=old_value, + ) + + new_values = [v for v in self._new_value if v not in self._old_value] + if new_values: + yield ListInsert( + flag="fix", + source=self._source, + node=self._ast_node, + position=len(self._ast_node.elts), + new_code=[self._value_to_code(v) for v in new_values], + new_values=new_values, + ) + class DictValue(GenericValue): _current_op = "snapshot[key]" diff --git a/tests/test_inline_snapshot.py b/tests/test_inline_snapshot.py index 483d3ad8..24324e72 100644 --- a/tests/test_inline_snapshot.py +++ b/tests/test_inline_snapshot.py @@ -438,7 +438,7 @@ def test_contains(check_update): assert check_update( "for i in range(5): assert i in snapshot([0,1,2,3,4,5,6])", flags="trim" - ) == snapshot("for i in range(5): assert i in snapshot([0, 1, 2, 3, 4])") + ) == snapshot("for i in range(5): assert i in snapshot([0,1,2,3,4])") assert ( check_update( From e23228f7a030f1815e9045f2966542742c980ebc Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Tue, 26 Mar 2024 19:06:40 +0100 Subject: [PATCH 12/17] refactor: use _get_changes api for DictValue --- inline_snapshot/_inline_snapshot.py | 67 ++++++++++++++++++++++++++--- tests/test_inline_snapshot.py | 44 +++++++++++-------- 2 files changed, 87 insertions(+), 24 deletions(-) diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index 58be23fb..95cce9cd 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -178,6 +178,9 @@ def _change(self, cls): def _needs_fix(self): return False + def _new_code(self): + return "" + # functions which determine the type def __eq__(self, other): @@ -278,10 +281,6 @@ def check(old_value, old_node, new_value): continue assert node_value == value - same_keys = old_value.keys() & new_value.keys() - new_keys = new_value.keys() - old_value.keys() - removed_keys = old_value.keys() - new_value.keys() - for key, node in zip(old_value.keys(), old_node.values): if key in new_value: # check values with same keys @@ -403,6 +402,9 @@ def get_result(self, flags): return self._old_value + def _new_code(self): + return self._value_to_code(self._new_value) + def _get_changes(self) -> Iterator[Change]: new_token = value_to_token(self._new_value) if not self.cmp(self._old_value, self._new_value): @@ -510,6 +512,9 @@ def get_result(self, flags): return self._old_value + def _new_code(self): + return self._value_to_code(self._new_value) + def _get_changes(self) -> Iterator[Change]: assert isinstance(self._ast_node, ast.List) @@ -559,9 +564,16 @@ def __getitem__(self, index): if old_value is undefined: old_value = {} + child_node = None + if self._ast_node is not None: + assert isinstance(self._ast_node, ast.Dict) + if index in old_value: + pos = list(old_value.keys()).index(index) + child_node = self._ast_node.values[pos] + if index not in self._new_value: self._new_value[index] = UndecidedValue( - old_value.get(index, undefined), None, self._source + old_value.get(index, undefined), child_node, self._source ) return self._new_value[index] @@ -599,6 +611,51 @@ def get_result(self, flags): return result + def _new_code(self): + + return ( + "{" + + ", ".join( + [ + f"{self._value_to_code(k)}: {v._new_code()}" + for k, v in self._new_value.items() + ] + ) + + "}" + ) + + def _get_changes(self) -> Iterator[Change]: + + assert self._old_value is not undefined + + assert isinstance(self._ast_node, ast.Dict) + + for key, node in zip(self._old_value.keys(), self._ast_node.values): + if key in self._new_value: + # check values with same keys + yield from self._new_value[key]._get_changes() + else: + # delete entries + yield Delete("trim", self._source, node, self._old_value[key]) + + to_insert = [] + for key, new_value_element in self._new_value.items(): + print(key, new_value_element) + if key not in self._old_value: + # add new values + to_insert.append((key, new_value_element._new_code())) + + if to_insert: + new_code = [(self._value_to_code(k), v) for k, v in to_insert] + yield DictInsert( + "create", + self._source, + self._ast_node, + len(self._ast_node.values), + new_code, + to_insert, + ) + T = TypeVar("T") diff --git a/tests/test_inline_snapshot.py b/tests/test_inline_snapshot.py index 24324e72..0469381d 100644 --- a/tests/test_inline_snapshot.py +++ b/tests/test_inline_snapshot.py @@ -104,25 +104,32 @@ def test_generic(source, subtests): ], ) def test_generic_multi(source, subtests, ops): - def gen_code(ops, fixed): - args = ", ".join( - f'"k_{k}": {value}' - for k, value in [ - (k, (op.fvalue if op.flag in fixed else op.svalue)) - for k, op in enumerate(ops) - ] - if value - ) + def gen_code(ops, fixed, old_keys): + keys = old_keys + [k for k in range(len(ops)) if k not in old_keys] + new_keys = [] + + args = [] + print(keys) + for k in keys: + op = ops[k] + value = op.fvalue if op.flag in fixed else op.svalue + if value: + args.append(f'"k_{k}": {value}') + new_keys.append(k) + args = ", ".join(args) + code = "s = snapshot({" + args + "})\n" for k, op in enumerate(ops): code += f'print({op.value} {op.op} s["k_{k}"]) # {op.flag} {op.svalue or ""} -> {op.fvalue}\n' - return code + return code, new_keys all_flags = {op.flag for op in ops} - s = source(gen_code(ops, {})) + keys = [] + code, keys = gen_code(ops, {}, keys) + s = source(code) assert s.flags == all_flags @@ -131,12 +138,11 @@ def gen_code(ops, fixed): s2 = s fixed_flags = set() for flag in flags: - if flag in {"create", "fix", "trim"}: - fixed_flags.add("update") s2 = s2.run(flag) fixed_flags.add(flag) - assert s2.source == gen_code(ops, fixed_flags) + code, keys = gen_code(ops, fixed_flags, keys) + assert s2.source == code s2 = s2.run() assert s2.flags == all_flags - fixed_flags @@ -497,7 +503,7 @@ def test_getitem(check_update): flags="fix", reported_flags="fix,trim", ) == snapshot( - 'for i in range(3): assert i in snapshot({"0": [0], "1": [1, 2], "2": [4, 2]})[str(i)]' + 'for i in range(3): assert i in snapshot({"0": [0], "1": [1,2], "2": [4, 2]})[str(i)]' ) assert check_update( @@ -509,19 +515,19 @@ def test_getitem(check_update): assert check_update( "assert 4 in snapshot({2:[4],3:[]})[2]", flags="trim" - ) == snapshot("assert 4 in snapshot({2: [4]})[2]") + ) == snapshot("assert 4 in snapshot({2:[4]})[2]") assert check_update( "assert 5 in snapshot({2:[4],3:[]})[2]", flags="fix", reported_flags="fix,trim" - ) == snapshot("assert 5 in snapshot({2: [4, 5], 3: []})[2]") + ) == snapshot("assert 5 in snapshot({2:[4, 5],3:[]})[2]") assert check_update( "assert 5 in snapshot({2:[4],3:[]})[2]", flags="fix,trim" - ) == snapshot("assert 5 in snapshot({2: [5]})[2]") + ) == snapshot("assert 5 in snapshot({2:[5]})[2]") assert check_update( "assert 5 in snapshot({3:[1]})[2]", flags="create", reported_flags="create,trim" - ) == snapshot("assert 5 in snapshot({2: [5], 3: [1]})[2]") + ) == snapshot("assert 5 in snapshot({3:[1], 2: [5]})[2]") assert ( check_update( From 8df914530f9f18c3e681e110b45ea12c7727130e Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Tue, 26 Mar 2024 22:15:09 +0100 Subject: [PATCH 13/17] refactor: removed get_result --- inline_snapshot/_inline_snapshot.py | 141 ++++++++-------------------- 1 file changed, 37 insertions(+), 104 deletions(-) diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index 95cce9cd..c0630d56 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -126,9 +126,6 @@ def _visible_value(self): else: return self._old_value - def get_result(self, flags): - return self._old_value - def _get_changes(self) -> Iterator[Change]: raise NotImplementedYet() @@ -181,6 +178,26 @@ def _needs_fix(self): def _new_code(self): return "" + def _get_changes(self) -> Iterator[Change]: + # generic fallback + new_token = value_to_token(self._old_value) + + if self._token_of_node(self._ast_node) != new_token: + flag = "update" + else: + return + + new_code = self._token_to_code(new_token) + + yield Replace( + node=self._ast_node, + source=self._source, + new_code=new_code, + flag=flag, + old_value=self._old_value, + new_value=self._old_value, + ) + # functions which determine the type def __eq__(self, other): @@ -354,11 +371,6 @@ def check(old_value, old_node, new_value): def _needs_fix(self): return self._old_value is not undefined and self._old_value != self._new_value - def get_result(self, flags): - if flags.fix and self._needs_fix() or flags.create and self._needs_create(): - return self._new_value - return self._old_value - class MinMaxValue(GenericValue): """Generic implementation for <=, >=""" @@ -390,18 +402,6 @@ def _needs_fix(self): return False return not self.cmp(self._old_value, self._new_value) - def get_result(self, flags): - if flags.create and self._needs_create(): - return self._new_value - - if flags.fix and self._needs_fix(): - return self._new_value - - if flags.trim and self._needs_trim(): - return self._new_value - - return self._old_value - def _new_code(self): return self._value_to_code(self._new_value) @@ -497,21 +497,6 @@ def _needs_fix(self): return False return any(item not in self._old_value for item in self._new_value) - def get_result(self, flags): - if (flags.fix and flags.trim) or (flags.create and self._needs_create()): - return self._new_value - - if self._old_value is not undefined: - if flags.fix: - return self._old_value + [ - v for v in self._new_value if v not in self._old_value - ] - - if flags.trim: - return [v for v in self._old_value if v in self._new_value] - - return self._old_value - def _new_code(self): return self._value_to_code(self._new_value) @@ -599,20 +584,7 @@ def _needs_create(self): return any(item not in self._old_value for item in self._new_value) - def get_result(self, flags): - result = {k: v.get_result(flags) for k, v in self._new_value.items()} - - result = {k: v for k, v in result.items() if v is not undefined} - - if not flags.trim and self._old_value is not undefined: - for k, v in self._old_value.items(): - if k not in result: - result[k] = v - - return result - def _new_code(self): - return ( "{" + ", ".join( @@ -784,68 +756,29 @@ def _change(self): assert tokens[1].string == "(" assert tokens[-1].string == ")" - try: - if self._value._old_value is undefined: - if _update_flags.create: - new_code = self._value._new_code() - try: - ast.parse(new_code) - except: - new_code = "" - else: + if self._value._old_value is undefined: + if _update_flags.create: + new_code = self._value._new_code() + try: + ast.parse(new_code) + except: new_code = "" + else: + new_code = "" - change = ChangeRecorder.current.new_change() - change.set_tags("inline_snapshot") - change.replace( - (end_of(tokens[1]), start_of(tokens[-1])), - new_code, - filename=self._filename, - ) - return - - changes = self._value._get_changes() - apply_all( - [change for change in changes if change.flag in _update_flags.to_set()] - ) - return - except NotImplementedYet: - pass - - change = ChangeRecorder.current.new_change() - change.set_tags("inline_snapshot") - - needs_fix = self._value._needs_fix() - needs_create = self._value._needs_create() - needs_trim = self._value._needs_trim() - needs_update = self._needs_update() - - if ( - _update_flags.update - and needs_update - or _update_flags.fix - and needs_fix - or _update_flags.create - and needs_create - or _update_flags.trim - and needs_trim - ): - new_value = self._value.get_result(_update_flags) - - text = self._format(tokenize.untokenize(value_to_token(new_value))).strip() - - try: - tree = ast.parse(text) - except: # pragma: no cover - return - - self._uses_externals = used_externals(tree) - + change = ChangeRecorder.current.new_change() + change.set_tags("inline_snapshot") change.replace( (end_of(tokens[1]), start_of(tokens[-1])), - text, + new_code, filename=self._filename, ) + return + + changes = self._value._get_changes() + apply_all( + [change for change in changes if change.flag in _update_flags.to_set()] + ) def _current_tokens(self): if not self._expr.node.args: From 5de2e7e1969da051c57c014ace550f23874ad7ec Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Wed, 27 Mar 2024 00:12:30 +0100 Subject: [PATCH 14/17] refactor: removed old _needs_* logic --- inline_snapshot/_change.py | 2 +- inline_snapshot/_inline_snapshot.py | 146 ++++++++-------------------- inline_snapshot/_utils.py | 4 - tests/conftest.py | 16 +++ tests/test_inline_snapshot.py | 38 +++++--- tests/test_preserve_values.py | 3 +- 6 files changed, 82 insertions(+), 127 deletions(-) diff --git a/inline_snapshot/_change.py b/inline_snapshot/_change.py index cab50067..6b05bb2a 100644 --- a/inline_snapshot/_change.py +++ b/inline_snapshot/_change.py @@ -214,4 +214,4 @@ def dict_token_range(key, value): ) else: - assert False + assert False, parent diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index c0630d56..fba59600 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -95,7 +95,10 @@ def _token_of_node(self, node): ) def _format(self, text): - return format_code(text, Path(self._source.filename)) + if self._source is None: + return text + else: + return format_code(text, Path(self._source.filename)) def _token_to_code(self, tokens): return self._format(tokenize.untokenize(tokens)).strip() @@ -103,15 +106,6 @@ def _token_to_code(self, tokens): def _value_to_code(self, value): return self._token_to_code(value_to_token(value)) - def _needs_trim(self): - return False - - def _needs_create(self): - return self._old_value == undefined - - def _needs_fix(self): - raise NotImplemented - def _ignore_old(self): return ( _update_flags.fix @@ -172,9 +166,6 @@ def __init__(self, old_value, ast_node, source): def _change(self, cls): self.__class__ = cls - def _needs_fix(self): - return False - def _new_code(self): return "" @@ -350,7 +341,9 @@ def check(old_value, old_node, new_value): if not old_value == new_value: flag = "fix" - elif self._token_of_node(old_node) != new_token: + elif ( + self._source is not None and self._token_of_node(old_node) != new_token + ): flag = "update" else: return @@ -368,9 +361,6 @@ def check(old_value, old_node, new_value): yield from check(self._old_value, self._ast_node, self._new_value) - def _needs_fix(self): - return self._old_value is not undefined and self._old_value != self._new_value - class MinMaxValue(GenericValue): """Generic implementation for <=, >=""" @@ -391,17 +381,6 @@ def _generic_cmp(self, other): return self.cmp(self._visible_value(), other) - def _needs_trim(self): - if self._old_value is undefined: - return False - - return not self.cmp(self._new_value, self._old_value) - - def _needs_fix(self): - if self._old_value is undefined: - return False - return not self.cmp(self._old_value, self._new_value) - def _new_code(self): return self._value_to_code(self._new_value) @@ -411,7 +390,10 @@ def _get_changes(self) -> Iterator[Change]: flag = "fix" elif not self.cmp(self._new_value, self._old_value): flag = "trim" - elif self._token_of_node(self._ast_node) != new_token: + elif ( + self._ast_node is not None + and self._token_of_node(self._ast_node) != new_token + ): flag = "update" else: return @@ -487,24 +469,18 @@ def __contains__(self, item): else: return item in self._old_value - def _needs_trim(self): - if self._old_value is undefined: - return False - return any(item not in self._new_value for item in self._old_value) - - def _needs_fix(self): - if self._old_value is undefined: - return False - return any(item not in self._old_value for item in self._new_value) - def _new_code(self): return self._value_to_code(self._new_value) def _get_changes(self) -> Iterator[Change]: - assert isinstance(self._ast_node, ast.List) + if self._ast_node is None: + elements = [None] * len(self._old_value) + else: + assert isinstance(self._ast_node, ast.List) + elements = self._ast_node.elts - for old_value, old_node in zip(self._old_value, self._ast_node.elts): + for old_value, old_node in zip(self._old_value, elements): if old_value not in self._new_value: yield Delete( flag="trim", source=self._source, node=old_node, old_value=old_value @@ -514,7 +490,7 @@ def _get_changes(self) -> Iterator[Change]: # check for update new_token = value_to_token(old_value) - if self._token_of_node(old_node) != new_token: + if old_node is not None and self._token_of_node(old_node) != new_token: new_code = self._token_to_code(new_token) yield Replace( @@ -532,7 +508,7 @@ def _get_changes(self) -> Iterator[Change]: flag="fix", source=self._source, node=self._ast_node, - position=len(self._ast_node.elts), + position=len(self._old_value), new_code=[self._value_to_code(v) for v in new_values], new_values=new_values, ) @@ -563,27 +539,6 @@ def __getitem__(self, index): return self._new_value[index] - def _needs_fix(self): - if self._old_value is not undefined and self._new_value is not undefined: - if any(v._needs_fix() for v in self._new_value.values()): - return True - - return False - - def _needs_trim(self): - if self._old_value is not undefined and self._new_value is not undefined: - if any(v._needs_trim() for v in self._new_value.values()): - return True - - return any(item not in self._new_value for item in self._old_value) - return False - - def _needs_create(self): - if super()._needs_create(): - return True - - return any(item not in self._old_value for item in self._new_value) - def _new_code(self): return ( "{" @@ -600,9 +555,13 @@ def _get_changes(self) -> Iterator[Change]: assert self._old_value is not undefined - assert isinstance(self._ast_node, ast.Dict) + if self._ast_node is None: + values = [None] * len(self._old_value) + else: + assert isinstance(self._ast_node, ast.Dict) + values = self._ast_node.values - for key, node in zip(self._old_value.keys(), self._ast_node.values): + for key, node in zip(self._old_value.keys(), values): if key in self._new_value: # check values with same keys yield from self._new_value[key]._get_changes() @@ -612,7 +571,6 @@ def _get_changes(self) -> Iterator[Change]: to_insert = [] for key, new_value_element in self._new_value.items(): - print(key, new_value_element) if key not in self._old_value: # add new values to_insert.append((key, new_value_element._new_code())) @@ -623,7 +581,7 @@ def _get_changes(self) -> Iterator[Change]: "create", self._source, self._ast_node, - len(self._ast_node.values), + len(self._old_value), new_code, to_insert, ) @@ -745,16 +703,7 @@ def __init__(self, value, expr): def _filename(self): return self._expr.source.filename - def _format(self, text): - return format_code(text, Path(self._filename)) - def _change(self): - assert self._expr is not None - - tokens = list(self._expr.source.asttokens().get_tokens(self._expr.node)) - assert tokens[0].string == "snapshot" - assert tokens[1].string == "(" - assert tokens[-1].string == ")" if self._value._old_value is undefined: if _update_flags.create: @@ -762,9 +711,16 @@ def _change(self): try: ast.parse(new_code) except: - new_code = "" + return else: - new_code = "" + return + + assert self._expr is not None + + tokens = list(self._expr.source.asttokens().get_tokens(self._expr.node)) + assert tokens[0].string == "snapshot" + assert tokens[1].string == "(" + assert tokens[-1].string == ")" change = ChangeRecorder.current.new_change() change.set_tags("inline_snapshot") @@ -780,31 +736,11 @@ def _change(self): [change for change in changes if change.flag in _update_flags.to_set()] ) - def _current_tokens(self): - if not self._expr.node.args: - return [] - - return [ - simple_token(t.type, t.string) - for t in self._expr.source.asttokens().get_tokens(self._expr.node.args[0]) - if t.type not in ignore_tokens - ] - - def _needs_update(self): - return self._expr is not None and [] != list( - normalize_strings(self._current_tokens()) - ) != list(normalize_strings(value_to_token(self._value._old_value))) - @property def _flags(self): - s = set() - if self._value._needs_fix(): - s.add("fix") - if self._value._needs_trim(): - s.add("trim") - if self._value._needs_create(): - s.add("create") - if self._value._old_value is not undefined and self._needs_update(): - s.add("update") - - return s + + if self._value._old_value is undefined: + return {"create"} + + changes = self._value._get_changes() + return {change.flag for change in changes} diff --git a/inline_snapshot/_utils.py b/inline_snapshot/_utils.py index 6c43e392..4a462525 100644 --- a/inline_snapshot/_utils.py +++ b/inline_snapshot/_utils.py @@ -4,8 +4,6 @@ import tokenize from collections import namedtuple -from ._sentinels import undefined - def normalize_strings(token_sequence): """Normalize string concattenanion. @@ -96,8 +94,6 @@ def triple_quote(string): def value_to_token(value): - if value is undefined: - return [] input = io.StringIO(repr(value)) def map_string(tok): diff --git a/tests/conftest.py b/tests/conftest.py index 70e71407..345b330d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,9 +5,11 @@ from dataclasses import dataclass from dataclasses import field from pathlib import Path +from types import SimpleNamespace from typing import Set import black +import executing import pytest import inline_snapshot._external @@ -246,3 +248,17 @@ def run(self, *args): return RunResult(result) return Project() + + +@pytest.fixture(params=[True, False], ids=["executing", "without-executing"]) +def executing_used(request, monkeypatch): + used = request.param + if used: + yield used + else: + + def fake_executing(frame): + return SimpleNamespace(node=None) + + monkeypatch.setattr(executing.Source, "executing", fake_executing) + yield used diff --git a/tests/test_inline_snapshot.py b/tests/test_inline_snapshot.py index 0469381d..c93c90b9 100644 --- a/tests/test_inline_snapshot.py +++ b/tests/test_inline_snapshot.py @@ -55,7 +55,7 @@ def test_disabled(): ] -def test_generic(source, subtests): +def test_generic(source, subtests, executing_used): codes = [] for op in operations: @@ -77,7 +77,10 @@ def test_generic(source, subtests): s = source(code) print("source:", code) - assert list(s.flags) == [reported_flag] + if not executing_used and reported_flag == "update": + assert not s.flags + else: + assert list(s.flags) == [reported_flag] assert (reported_flag == "fix") == s.error @@ -88,6 +91,9 @@ def test_generic(source, subtests): s2 = s.run(flag) assert s2.source == s.source + if not executing_used: + continue + s2 = s.run(reported_flag) assert s2.flags == {reported_flag} @@ -103,7 +109,8 @@ def test_generic(source, subtests): for ops in itertools.combinations(operations, 2) ], ) -def test_generic_multi(source, subtests, ops): +def test_generic_multi(source, subtests, ops, executing_used): + def gen_code(ops, fixed, old_keys): keys = old_keys + [k for k in range(len(ops)) if k not in old_keys] new_keys = [] @@ -131,21 +138,22 @@ def gen_code(ops, fixed, old_keys): code, keys = gen_code(ops, {}, keys) s = source(code) - assert s.flags == all_flags + assert s.flags == all_flags - ({"update"} if not executing_used else set()) - for flags in itertools.permutations(all_flags): - with subtests.test(" ".join(flags)): - s2 = s - fixed_flags = set() - for flag in flags: + if executing_used: + for flags in itertools.permutations(all_flags): + with subtests.test(" ".join(flags)): + s2 = s + fixed_flags = set() + for flag in flags: - s2 = s2.run(flag) - fixed_flags.add(flag) - code, keys = gen_code(ops, fixed_flags, keys) - assert s2.source == code + s2 = s2.run(flag) + fixed_flags.add(flag) + code, keys = gen_code(ops, fixed_flags, keys) + assert s2.source == code - s2 = s2.run() - assert s2.flags == all_flags - fixed_flags + s2 = s2.run() + assert s2.flags == all_flags - fixed_flags for flag in {"update", "fix", "trim", "create"} - all_flags: with subtests.test(f"ignore {flag}"): diff --git a/tests/test_preserve_values.py b/tests/test_preserve_values.py index 151a4eb9..d73327c1 100644 --- a/tests/test_preserve_values.py +++ b/tests/test_preserve_values.py @@ -68,7 +68,7 @@ def test_fix_dict_insert(check_update): def test_fix_dict_with_non_literal_keys(check_update): assert check_update( """assert {1+2:"3"}==snapshot({1+2:"5"})""", - reported_flags="update,fix", + reported_flags="fix", flags="fix", ) == snapshot('assert {1+2:"3"}==snapshot({1+2:"3"})') @@ -219,7 +219,6 @@ def build(value_lists): print(f"{set(f)}:") print(" ", code) print(" ", new_code) - s2 = s1.run(*f) assert s2.source == new_code # assert s2.flags== flags-f From 240b062d90bd27fd9d9cc2b91e8ddb09210b7992 Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Wed, 27 Mar 2024 08:21:21 +0100 Subject: [PATCH 15/17] fix: update with UndecidedValue --- inline_snapshot/_inline_snapshot.py | 8 ++++++-- tests/test_inline_snapshot.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index fba59600..1df347f0 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -173,7 +173,10 @@ def _get_changes(self) -> Iterator[Change]: # generic fallback new_token = value_to_token(self._old_value) - if self._token_of_node(self._ast_node) != new_token: + if ( + self._ast_node is not None + and self._token_of_node(self._ast_node) != new_token + ): flag = "update" else: return @@ -342,7 +345,8 @@ def check(old_value, old_node, new_value): if not old_value == new_value: flag = "fix" elif ( - self._source is not None and self._token_of_node(old_node) != new_token + self._ast_node is not None + and self._token_of_node(old_node) != new_token ): flag = "update" else: diff --git a/tests/test_inline_snapshot.py b/tests/test_inline_snapshot.py index c93c90b9..ca7ecf63 100644 --- a/tests/test_inline_snapshot.py +++ b/tests/test_inline_snapshot.py @@ -560,7 +560,7 @@ def test_assert(check_update): assert check_update("assert 2 == snapshot(5)", reported_flags="fix") -def test_plain(check_update): +def test_plain(check_update, executing_used): assert check_update("s = snapshot(5)", flags="") == snapshot("s = snapshot(5)") assert check_update( From e4aa7222a85f17d43026f5b1d0fa104c17bb85bc Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Tue, 2 Apr 2024 21:56:53 +0200 Subject: [PATCH 16/17] feat: prevent dirty-equal values from triggering of updates --- inline_snapshot/_inline_snapshot.py | 14 ++++++++++++++ tests/test_preserve_values.py | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/inline_snapshot/_inline_snapshot.py b/inline_snapshot/_inline_snapshot.py index 1df347f0..45b50aa5 100644 --- a/inline_snapshot/_inline_snapshot.py +++ b/inline_snapshot/_inline_snapshot.py @@ -215,6 +215,19 @@ def __getitem__(self, item): return self[item] +try: + import dirty_equals # type: ignore +except: + + def update_allowed(value): + return True + +else: + + def update_allowed(value): + return not isinstance(value, dirty_equals.DirtyEquals) + + class EqValue(GenericValue): _current_op = "x == snapshot" @@ -347,6 +360,7 @@ def check(old_value, old_node, new_value): elif ( self._ast_node is not None and self._token_of_node(old_node) != new_token + and update_allowed(old_value) ): flag = "update" else: diff --git a/tests/test_preserve_values.py b/tests/test_preserve_values.py index d73327c1..3a4ee91e 100644 --- a/tests/test_preserve_values.py +++ b/tests/test_preserve_values.py @@ -1,4 +1,7 @@ import itertools +import sys + +import pytest from inline_snapshot import snapshot @@ -73,6 +76,28 @@ def test_fix_dict_with_non_literal_keys(check_update): ) == snapshot('assert {1+2:"3"}==snapshot({1+2:"3"})') +@pytest.mark.skipif( + sys.version_info < (3, 8), reason="dirty equals has dropped the 3.7 support" +) +def test_no_update_for_dirty_equals(check_update): + assert ( + check_update( + """\ +from dirty_equals import IsInt +assert {5:5,2:2}==snapshot({5:IsInt(),2:1+1}) +""", + reported_flags="update", + flags="update", + ) + == snapshot( + """\ +from dirty_equals import IsInt +assert {5:5,2:2}==snapshot({5:IsInt(),2:2}) +""" + ) + ) + + # @pytest.mark.skipif(not hasattr(ast, "unparse"), reason="ast.unparse not available") def test_preserve_case_from_original_mr(check_update): assert ( From 0f7802b4e4e309c57b0efa8cb2713fdebb597aeb Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Thu, 28 Mar 2024 13:05:51 +0100 Subject: [PATCH 17/17] docs: documented the preserving of snapshot parts --- docs/eq_snapshot.md | 155 +++++++++++++++++++++- mkdocs.yml | 1 + noxfile.py | 2 + poetry.lock | 310 +++++++++++++++++++++++++++----------------- pyproject.toml | 2 + tests/conftest.py | 12 ++ tests/test_docs.py | 4 + 7 files changed, 365 insertions(+), 121 deletions(-) diff --git a/docs/eq_snapshot.md b/docs/eq_snapshot.md index d5dd6472..103dce6e 100644 --- a/docs/eq_snapshot.md +++ b/docs/eq_snapshot.md @@ -1,7 +1,8 @@ ## General -A snapshot can be compared against any value with `==`. -The value gets recorded if the snapshot is undefined (`snapshot()`) +A snapshot can be compared with any value using `==`. +The value can be recorded with `--inline-snapshot=create` if the snapshot is empty. +The value can later be changed with `--inline-snapshot=fix` if the value the snapshot is compared with has changed. Example: @@ -9,19 +10,163 @@ Example: ```python def test_something(): - assert 2 + 2 == snapshot() + assert 2 + 4 == snapshot() ``` === "--inline-snapshot=create" ```python def test_something(): - assert 2 + 2 == snapshot(4) + assert 2 + 4 == snapshot(6) ``` +=== "value changed" + + ```python + def test_something(): + assert 2 + 40 == snapshot(4) + ``` + +=== "--inline-snapshot=fix" + + ```python + def test_something(): + assert 2 + 40 == snapshot(42) + ``` + + +## 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. + +Example: + +=== "original code" + + ```python + from inline_snapshot import snapshot + import datetime + + + def get_data(): + return { + "date": datetime.datetime.utcnow(), + "payload": "some data", + } + + + def test_function(): + assert get_data() == snapshot() + ``` + +=== "--inline-snapshot=create" + + ```python + from inline_snapshot import snapshot + import datetime + + + def get_data(): + return { + "date": datetime.datetime.utcnow(), + "payload": "some data", + } + + + def test_function(): + assert get_data() == snapshot( + {"date": datetime.datetime(2024, 3, 14, 0, 0), "payload": "some data"} + ) + ``` + +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`. + +Example: + +=== "using IsDatetime()" + + ```python + from inline_snapshot import snapshot + from dirty_equals import IsDatetime + import datetime + + + def get_data(): + return { + "date": datetime.datetime.utcnow(), + "payload": "some data", + } + + + def test_function(): + assert get_data() == snapshot( + { + "date": IsDatetime(), + "payload": "some data", + } + ) + ``` + +=== "changed payload" + + ```python + from inline_snapshot import snapshot + from dirty_equals import IsDatetime + import datetime + + + def get_data(): + return { + "date": datetime.datetime.utcnow(), + "payload": "data changed for some good reason", + } + + + def test_function(): + assert get_data() == snapshot( + { + "date": IsDatetime(), + "payload": "some data", + } + ) + ``` + + +=== "--inline-snapshot=fix" + + ```python + from inline_snapshot import snapshot + from dirty_equals import IsDatetime + import datetime + + + def get_data(): + return { + "date": datetime.datetime.utcnow(), + "payload": "data changed for some good reason", + } + + + def test_function(): + assert get_data() == snapshot( + { + "date": IsDatetime(), + "payload": "data changed for some good reason", + } + ) + ``` + +!!! note + The current implementation looks only into lists, dictionaries and tuples and not into the representation of other data structures. + ## pytest options It interacts with the following `--inline-snapshot` flags: - `create` create a new value if the snapshot value is undefined. -- `fix` record the new value and store it in the source code if it is different from the current one. +- `fix` record the value parts and store them in the source code if it is different from the current one. +- `update` update parts of the value if their representation has changed. + Parts which are replaced with dirty-equals expressions are not updated. diff --git a/mkdocs.yml b/mkdocs.yml index 7d87970f..cd3a45c6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,6 +8,7 @@ theme: custom_dir: docs/theme features: - toc.follow + - content.code.annotate palette: - media: (prefers-color-scheme) diff --git a/noxfile.py b/noxfile.py index 59ebcb6a..af51e96e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -38,6 +38,8 @@ def test(session): "coverage", "pytest-xdist", "coverage-enable-subprocess", + "dirty-equals", + "time-machine", ) session.env["COVERAGE_PROCESS_START"] = str( Path(__file__).parent / "pyproject.toml" diff --git a/poetry.lock b/poetry.lock index cccba7ef..84de0580 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "asttokens" @@ -35,13 +35,13 @@ wheel = ">=0.23.0,<1.0" [[package]] name = "attrs" -version = "23.1.0" +version = "23.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.7" files = [ - {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, - {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, ] [package.dependencies] @@ -49,25 +49,25 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [package.extras] cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] -dev = ["attrs[docs,tests]", "pre-commit"] +dev = ["attrs[tests]", "pre-commit"] docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] tests = ["attrs[tests-no-zope]", "zope-interface"] -tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] [[package]] name = "babel" -version = "2.13.1" +version = "2.14.0" description = "Internationalization utilities" optional = false python-versions = ">=3.7" files = [ - {file = "Babel-2.13.1-py3-none-any.whl", hash = "sha256:7077a4984b02b6727ac10f1f7294484f737443d7e2e66c5e4380e41a3ae0b4ed"}, - {file = "Babel-2.13.1.tar.gz", hash = "sha256:33e0952d7dd6374af8dbf6768cc4ddf3ccfefc244f9986d4074704f2fbd18900"}, + {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, + {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, ] [package.dependencies] pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} -setuptools = {version = "*", markers = "python_version >= \"3.12\""} [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] @@ -135,13 +135,13 @@ files = [ [[package]] name = "certifi" -version = "2023.11.17" +version = "2024.2.2" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, - {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, ] [[package]] @@ -355,6 +355,23 @@ files = [ [package.dependencies] coverage = "*" +[[package]] +name = "dirty-equals" +version = "0.7.1.post0" +description = "Doing dirty (but extremely useful) things with equals." +optional = false +python-versions = ">=3.7" +files = [ + {file = "dirty_equals-0.7.1.post0-py3-none-any.whl", hash = "sha256:7fb9217ea7cd04c0e95ace3bc717e2ee5532b8990518533483e53b5a43903c88"}, + {file = "dirty_equals-0.7.1.post0.tar.gz", hash = "sha256:78ff80578a46163831ecb3255cf30d03d1dc2fbca8e67f820105691a1bc556dc"}, +] + +[package.dependencies] +pytz = ">=2021.3" + +[package.extras] +pydantic = ["pydantic (>=2.4.2)"] + [[package]] name = "exceptiongroup" version = "1.2.0" @@ -490,13 +507,13 @@ files = [ [[package]] name = "jinja2" -version = "3.1.2" +version = "3.1.3" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" files = [ - {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, - {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, + {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, + {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, ] [package.dependencies] @@ -525,61 +542,71 @@ testing = ["coverage", "pyyaml"] [[package]] name = "markupsafe" -version = "2.1.3" +version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" files = [ - {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, - {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] [[package]] @@ -777,13 +804,13 @@ files = [ [[package]] name = "packaging" -version = "23.2" +version = "24.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] [[package]] @@ -845,27 +872,27 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "psutil" -version = "5.9.6" +version = "5.9.8" description = "Cross-platform lib for process and system monitoring in Python." optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ - {file = "psutil-5.9.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:fb8a697f11b0f5994550555fcfe3e69799e5b060c8ecf9e2f75c69302cc35c0d"}, - {file = "psutil-5.9.6-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:91ecd2d9c00db9817a4b4192107cf6954addb5d9d67a969a4f436dbc9200f88c"}, - {file = "psutil-5.9.6-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:10e8c17b4f898d64b121149afb136c53ea8b68c7531155147867b7b1ac9e7e28"}, - {file = "psutil-5.9.6-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:18cd22c5db486f33998f37e2bb054cc62fd06646995285e02a51b1e08da97017"}, - {file = "psutil-5.9.6-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:ca2780f5e038379e520281e4c032dddd086906ddff9ef0d1b9dcf00710e5071c"}, - {file = "psutil-5.9.6-cp27-none-win32.whl", hash = "sha256:70cb3beb98bc3fd5ac9ac617a327af7e7f826373ee64c80efd4eb2856e5051e9"}, - {file = "psutil-5.9.6-cp27-none-win_amd64.whl", hash = "sha256:51dc3d54607c73148f63732c727856f5febec1c7c336f8f41fcbd6315cce76ac"}, - {file = "psutil-5.9.6-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c69596f9fc2f8acd574a12d5f8b7b1ba3765a641ea5d60fb4736bf3c08a8214a"}, - {file = "psutil-5.9.6-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92e0cc43c524834af53e9d3369245e6cc3b130e78e26100d1f63cdb0abeb3d3c"}, - {file = "psutil-5.9.6-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:748c9dd2583ed86347ed65d0035f45fa8c851e8d90354c122ab72319b5f366f4"}, - {file = "psutil-5.9.6-cp36-cp36m-win32.whl", hash = "sha256:3ebf2158c16cc69db777e3c7decb3c0f43a7af94a60d72e87b2823aebac3d602"}, - {file = "psutil-5.9.6-cp36-cp36m-win_amd64.whl", hash = "sha256:ff18b8d1a784b810df0b0fff3bcb50ab941c3b8e2c8de5726f9c71c601c611aa"}, - {file = "psutil-5.9.6-cp37-abi3-win32.whl", hash = "sha256:a6f01f03bf1843280f4ad16f4bde26b817847b4c1a0db59bf6419807bc5ce05c"}, - {file = "psutil-5.9.6-cp37-abi3-win_amd64.whl", hash = "sha256:6e5fb8dc711a514da83098bc5234264e551ad980cec5f85dabf4d38ed6f15e9a"}, - {file = "psutil-5.9.6-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:daecbcbd29b289aac14ece28eca6a3e60aa361754cf6da3dfb20d4d32b6c7f57"}, - {file = "psutil-5.9.6.tar.gz", hash = "sha256:e4b92ddcd7dd4cdd3f900180ea1e104932c7bce234fb88976e2a3b296441225a"}, + {file = "psutil-5.9.8-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:26bd09967ae00920df88e0352a91cff1a78f8d69b3ecabbfe733610c0af486c8"}, + {file = "psutil-5.9.8-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:05806de88103b25903dff19bb6692bd2e714ccf9e668d050d144012055cbca73"}, + {file = "psutil-5.9.8-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:611052c4bc70432ec770d5d54f64206aa7203a101ec273a0cd82418c86503bb7"}, + {file = "psutil-5.9.8-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:50187900d73c1381ba1454cf40308c2bf6f34268518b3f36a9b663ca87e65e36"}, + {file = "psutil-5.9.8-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:02615ed8c5ea222323408ceba16c60e99c3f91639b07da6373fb7e6539abc56d"}, + {file = "psutil-5.9.8-cp27-none-win32.whl", hash = "sha256:36f435891adb138ed3c9e58c6af3e2e6ca9ac2f365efe1f9cfef2794e6c93b4e"}, + {file = "psutil-5.9.8-cp27-none-win_amd64.whl", hash = "sha256:bd1184ceb3f87651a67b2708d4c3338e9b10c5df903f2e3776b62303b26cb631"}, + {file = "psutil-5.9.8-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aee678c8720623dc456fa20659af736241f575d79429a0e5e9cf88ae0605cc81"}, + {file = "psutil-5.9.8-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cb6403ce6d8e047495a701dc7c5bd788add903f8986d523e3e20b98b733e421"}, + {file = "psutil-5.9.8-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d06016f7f8625a1825ba3732081d77c94589dca78b7a3fc072194851e88461a4"}, + {file = "psutil-5.9.8-cp36-cp36m-win32.whl", hash = "sha256:7d79560ad97af658a0f6adfef8b834b53f64746d45b403f225b85c5c2c140eee"}, + {file = "psutil-5.9.8-cp36-cp36m-win_amd64.whl", hash = "sha256:27cc40c3493bb10de1be4b3f07cae4c010ce715290a5be22b98493509c6299e2"}, + {file = "psutil-5.9.8-cp37-abi3-win32.whl", hash = "sha256:bc56c2a1b0d15aa3eaa5a60c9f3f8e3e565303b465dbf57a1b730e7a2b9844e0"}, + {file = "psutil-5.9.8-cp37-abi3-win_amd64.whl", hash = "sha256:8db4c1b57507eef143a15a6884ca10f7c73876cdf5d51e713151c1236a0e68cf"}, + {file = "psutil-5.9.8-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:d16bbddf0693323b8c6123dd804100241da461e41d6e332fb0ba6058f630f8c8"}, + {file = "psutil-5.9.8.tar.gz", hash = "sha256:6be126e3225486dff286a8fb9a06246a5253f4c7c53b475ea5f5ac934e64194c"}, ] [package.extras] @@ -906,13 +933,13 @@ extra = ["pygments (>=2.12)"] [[package]] name = "pytest" -version = "7.4.3" +version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, - {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, ] [package.dependencies] @@ -929,18 +956,19 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no [[package]] name = "pytest-subtests" -version = "0.11.0" +version = "0.12.1" description = "unittest subTest() support and subtests fixture" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-subtests-0.11.0.tar.gz", hash = "sha256:51865c88457545f51fb72011942f0a3c6901ee9e24cbfb6d1b9dc1348bafbe37"}, - {file = "pytest_subtests-0.11.0-py3-none-any.whl", hash = "sha256:453389984952eec85ab0ce0c4f026337153df79587048271c7fd0f49119c07e4"}, + {file = "pytest-subtests-0.12.1.tar.gz", hash = "sha256:d6605dcb88647e0b7c1889d027f8ef1c17d7a2c60927ebfdc09c7b0d8120476d"}, + {file = "pytest_subtests-0.12.1-py3-none-any.whl", hash = "sha256:100d9f7eb966fc98efba7026c802812ae327e8b5b37181fb260a2ea93226495c"}, ] [package.dependencies] attrs = ">=19.2.0" pytest = ">=7.0" +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "pytest-xdist" @@ -965,13 +993,13 @@ testing = ["filelock"] [[package]] name = "python-dateutil" -version = "2.8.2" +version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, ] [package.dependencies] @@ -998,13 +1026,13 @@ numpy-style = ["docstring_parser (>=0.7)"] [[package]] name = "pytz" -version = "2023.3.post1" +version = "2024.1" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" files = [ - {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, - {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, ] [[package]] @@ -1188,22 +1216,6 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] -[[package]] -name = "setuptools" -version = "69.0.2" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "setuptools-69.0.2-py3-none-any.whl", hash = "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2"}, - {file = "setuptools-69.0.2.tar.gz", hash = "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - [[package]] name = "six" version = "1.16.0" @@ -1226,6 +1238,72 @@ files = [ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, ] +[[package]] +name = "time-machine" +version = "2.10.0" +description = "Travel through time in your tests." +optional = false +python-versions = ">=3.7" +files = [ + {file = "time_machine-2.10.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2d5e93c14b935d802a310c1d4694a9fe894b48a733ebd641c9a570d6f9e1f667"}, + {file = "time_machine-2.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4c0dda6b132c0180941944ede357109016d161d840384c2fb1096a3a2ef619f4"}, + {file = "time_machine-2.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:900517e4a4121bf88527343d6aea2b5c99df134815bb8271ef589ec792502a71"}, + {file = "time_machine-2.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:860279c7f9413bc763b3d1aee622937c4538472e2e58ad668546b49a797cb9fb"}, + {file = "time_machine-2.10.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f451be286d50ec9b685198c7f76cea46538b8c57ec816f60edf5eb68d71c4f4"}, + {file = "time_machine-2.10.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b1b07f5da833b2d8ea170cdf15a322c6fa2c6f7e9097a1bea435adc597cdcb5d"}, + {file = "time_machine-2.10.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6b3a529ecc819488783e371df5ad315e790b9558c6945a236b13d7cb9ab73b9a"}, + {file = "time_machine-2.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:51e36491bd4a43f8a937ca7c0d1a2287b8998f41306f47ebed250a02f93d2fe4"}, + {file = "time_machine-2.10.0-cp310-cp310-win32.whl", hash = "sha256:1e9973091ad3272c719dafae35a5bb08fa5433c2902224d0f745657f9e3ac327"}, + {file = "time_machine-2.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab82ea5a59faa1faa7397465f2edd94789a13f543daa02d16244906339100080"}, + {file = "time_machine-2.10.0-cp310-cp310-win_arm64.whl", hash = "sha256:55bc6d666966fa2e6283d7433ebe875be37684a847eaa802075433c1ab3a377a"}, + {file = "time_machine-2.10.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:99fc366cb4fa26d81f12fa36a929db0da89d99909e28231c045e0f1277e0db84"}, + {file = "time_machine-2.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5969f325c20bdcb7f8917a6ac2ef328ec41cc2e256320a99dfe38b4080eeae71"}, + {file = "time_machine-2.10.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:a1a5e283ab47b28205f33fa3c5a2df3fd9f07f09add63dbe76637c3633893a23"}, + {file = "time_machine-2.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4083ec185ab9ece3e5a7ca7a7589114a555f04bcff31b29d4eb47a37e87d97fe"}, + {file = "time_machine-2.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cbe45f88399b8af299136435a2363764d5fa6d16a936e4505081b6ea32ff3e18"}, + {file = "time_machine-2.10.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d149a3fae8a06a3593361496ec036a27906fed478ade23ffc01dd402acd0b37"}, + {file = "time_machine-2.10.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2e05306f63df3c7760170af6e77e1b37405b7c7c4a97cc9fdf0105f1094b1b1c"}, + {file = "time_machine-2.10.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3d6d7b7680e34dbe60da34d75d6d5f31b6206c7149c0de8a7b0f0311d0ef7e3a"}, + {file = "time_machine-2.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:91b8b06e09e1dfd53dafe272d41b60690d6f8806d7194c62982b003a088dc423"}, + {file = "time_machine-2.10.0-cp311-cp311-win32.whl", hash = "sha256:6241a1742657622ebdcd66cf6045c92e0ec6ca6365c55434cc7fea945008192c"}, + {file = "time_machine-2.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:48cce6dcb7118ba4a58537c6de4d1dd6e7ad6ea15d0257d6e0003b45c4a839c2"}, + {file = "time_machine-2.10.0-cp311-cp311-win_arm64.whl", hash = "sha256:8cb6285095efa0833fd0301e159748a06e950c7744dc3d38e92e7607e2232d5a"}, + {file = "time_machine-2.10.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8829ca7ed939419c2a23c360101edc51e3b57f40708d304b6aed16214d8b2a1f"}, + {file = "time_machine-2.10.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b5b60bc00ad2efa5fefee117e5611a28b26f563f1a64df118d1d2f2590a679a"}, + {file = "time_machine-2.10.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1491fb647568134d38b06e844783d3069f5811405e9a3906eff88d55403e327"}, + {file = "time_machine-2.10.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e78f2759a63fcc7660d283e22054c7cfa7468fad1ad86d0846819b6ea958d63f"}, + {file = "time_machine-2.10.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:30881f263332245a665a49d0e30fda135597c4e18f2efa9c6759c224419c36a5"}, + {file = "time_machine-2.10.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:e93750309093275340e0e95bb270801ec9cbf2ee8702d71031f4ccd8cc91dd7f"}, + {file = "time_machine-2.10.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a906bb338a6be978b83f09f09d8b24737239330f280c890ecbf1c13828e1838c"}, + {file = "time_machine-2.10.0-cp37-cp37m-win32.whl", hash = "sha256:10c8b170920d3f83dad2268ae8d5e1d8bb431a85198e32d778e6f3a1f93b172d"}, + {file = "time_machine-2.10.0-cp37-cp37m-win_amd64.whl", hash = "sha256:5efc4cc914d93138944c488fdebd6e4290273e3ac795d5c7a744af29eb04ce0f"}, + {file = "time_machine-2.10.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:1787887168e36f57d5ca1abf1b9d065a55eb67067df2fa23aaa4382da36f7098"}, + {file = "time_machine-2.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:26a8cc1f8e9f4f69ea3f50b9b9e3a699e80e44ac9359a867208be6adac30fc60"}, + {file = "time_machine-2.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07e2c6c299c5509c72cc221a19f4bf680c87c793727a3127a29e18ddad3db13"}, + {file = "time_machine-2.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f3e5f263a623148a448756a332aad45e65a59876fcb2511f7f61213e6d3ec3e"}, + {file = "time_machine-2.10.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b3abcb48d7ca7ed95e5d99220317b7ce31378636bb020cabfa62f9099e7dad"}, + {file = "time_machine-2.10.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:545a813b7407c33dee388aa380449e79f57f02613ea149c6e907fc9ca3d53e64"}, + {file = "time_machine-2.10.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:458b52673ec83d10da279d989d7a6ad1e60c93e4ba986210d72e6c78e17102f4"}, + {file = "time_machine-2.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:acb2ca50d779d39eab1d0fab48697359e4ffc1aedfa58b79cd3a86ee13253834"}, + {file = "time_machine-2.10.0-cp38-cp38-win32.whl", hash = "sha256:648fec54917a7e67acca38ed8e736b206e8a9688730e13e1cf7a74bcce89dec7"}, + {file = "time_machine-2.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:3ed92d2a6e2c2b7a0c8161ecca5d012041b7ba147cbdfb2b7f62f45c02615111"}, + {file = "time_machine-2.10.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6d2588581d3071d556f96954d084b7b99701e54120bb29dfadaab04791ef6ae4"}, + {file = "time_machine-2.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:185f7a4228e993ddae610e24fb3c7e7891130ebb6a40f42d58ea3be0bfafe1b1"}, + {file = "time_machine-2.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8225eb813ea9488de99e61569fc1b2d148d236473a84c6758cc436ffef4c043"}, + {file = "time_machine-2.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f03ac22440b00abd1027bfb7dd793dfeffb72dda26f336f4d561835e0ce6117"}, + {file = "time_machine-2.10.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4252f4daef831556e6685853d7a61b02910d0465528c549f179ea4e36aaeb14c"}, + {file = "time_machine-2.10.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:58c65bf4775fca62e1678cb234f1ca90254e811d978971c819d2cd24e1b7f136"}, + {file = "time_machine-2.10.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8527ac8fca7b92556c3c4c0f08e0bea995202db4be5b7d95b9b2ccbcb63649f2"}, + {file = "time_machine-2.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4684308d749fdb0c22af173b081206d2a5a85d2154a683a7f4a60c4b667f7a65"}, + {file = "time_machine-2.10.0-cp39-cp39-win32.whl", hash = "sha256:2adc24cf25b7e8d08aea2b109cc42c5db76817b07ee709fae5c66afa4ec7bc6e"}, + {file = "time_machine-2.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:36f5be6f3042734fca043bedafbfbb6ad4809352e40b3283cb46b151a823674c"}, + {file = "time_machine-2.10.0-cp39-cp39-win_arm64.whl", hash = "sha256:c1775a949dd830579d1af5a271ec53d920dc01657035ad305f55c5a1ac9b9f1e"}, + {file = "time_machine-2.10.0.tar.gz", hash = "sha256:64fd89678cf589fc5554c311417128b2782222dd65f703bf248ef41541761da0"}, +] + +[package.dependencies] +python-dateutil = "*" + [[package]] name = "toml" version = "0.10.2" @@ -1408,4 +1486,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7" -content-hash = "98da28b74e19453a498eafec51ab3cc9b9b64a30e73e92761f4cead4a1af23ec" +content-hash = "c31ae0e9eb60ab4f47bbfed4fa61fabc6324e27b6579d412fd9fd071831dc5c5" diff --git a/pyproject.toml b/pyproject.toml index 4b989ca7..39c597be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,11 +63,13 @@ types-toml = ">=0.10.8.7" [tool.poetry.group.dev.dependencies] coverage = ">=7.2.3" coverage-enable-subprocess = ">=1.0" +dirty-equals = ">=0.7.0" hypothesis = ">=6.75.5" mypy = ">=1.2.0" pytest = ">=7.1" pytest-subtests = ">=0.11.0" pytest-xdist = {extras = ["psutil"], version = ">=3.2.1"} +time-machine = ">=2.10.0" [tool.poetry.group.doc.dependencies] mkdocs = ">=1.4.2" diff --git a/tests/conftest.py b/tests/conftest.py index 345b330d..3e0f1249 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -202,6 +202,18 @@ def setup(self, source: str): print(source) self._filename.write_text(source, "utf-8") + (pytester.path / "conftest.py").write_text( + """ +import datetime +import pytest + +@pytest.fixture(autouse=True) +def set_time(time_machine): + time_machine.move_to(datetime.datetime(2024, 3, 14, 0, 0, 0, 0),tick=False) + yield +""" + ) + @property def _filename(self): return pytester.path / "test_file.py" diff --git a/tests/test_docs.py b/tests/test_docs.py index b665777e..07e5c3fb 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,4 +1,5 @@ import re +import sys import textwrap from pathlib import Path @@ -25,6 +26,9 @@ def test_docs(project, file, subtests): * `this` to specify that the input source code should be the current block and not the last * `outcome-passed=2` to check for the pytest test outcome """ + + if sys.version_info < (3, 8) and file.stem == "eq_snapshot": + pytest.skip() block_start = re.compile("( *)``` *python") block_end = re.compile("```.*")