diff --git a/CHANGELOG.md b/CHANGELOG.md index edafb2b82..e952920e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `init` param to reactive.watch +- `CSS_PATH` can now be a list of CSS files https://github.com/Textualize/textual/pull/1079 ## [0.3.0] - 2022-10-31 diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 4ca7785c3..8579d9a15 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -4,7 +4,7 @@ Textual uses CSS to apply style to widgets. If you have any exposure to web deve ## Stylesheets -CSS stands for _Cascading Stylesheets_. A stylesheet is a list of styles and rules about how those styles should be applied to a web page. In the case of Textual, the stylesheet applies [styles](./styles.md) to widgets but otherwise it is the same idea. +CSS stands for _Cascading Stylesheets_. A stylesheet is a list of styles and rules about how those styles should be applied to a web page. In the case of Textual, the stylesheet applies [styles](./styles.md) to widgets, but otherwise it is the same idea. When Textual loads CSS it sets attributes on your widgets' `style` object. The effect is the same as if you had set attributes in Python. @@ -48,7 +48,7 @@ Header { } ``` -The lines inside the curly braces contains CSS _rules_, which consist of a rule name and rule value separated by a colon and ending in a semi-colon. Such rules are typically written one per line, but you could add additional rules as long as they are separated by semi-colons. +The lines inside the curly braces contains CSS _rules_, which consist of a rule name and rule value separated by a colon and ending in a semicolon. Such rules are typically written one per line, but you could add additional rules as long as they are separated by semicolons. The first rule in the above example reads `"dock: top;"`. The rule name is `dock` which tells Textual to place the widget on an edge of the screen. The text after the colon is `top` which tells Textual to dock to the _top_ of the screen. Other valid values for `dock` are "right", "bottom", or "left"; but "top" is most appropriate for a header. @@ -93,7 +93,7 @@ This doesn't look much like a tree yet. Let's add a header and a footer to this ```{.textual path="docs/examples/guide/dom2.py"} ``` -With a header and a footer widget the DOM looks the this: +With a header and a footer widget the DOM looks like this:
--8<-- "docs/images/dom2.excalidraw.svg" @@ -132,7 +132,7 @@ Here's the output from this example: ``` -You may recognize some of the elements in the above screenshot, but it doesn't quite look like a dialog. This is because we haven't added a stylesheet. +You may recognize some elements in the above screenshot, but it doesn't quite look like a dialog. This is because we haven't added a stylesheet. ## CSS files @@ -142,7 +142,8 @@ To add a stylesheet set the `CSS_PATH` classvar to a relative path: --8<-- "docs/examples/guide/dom4.py" ``` -You may have noticed that some of the constructors have additional keyword arguments: `id` and `classes`. These are used by the CSS to identify parts of the DOM. We will cover these in the next section. +You may have noticed that some constructors have additional keyword arguments: `id` and `classes`. +These are used by the CSS to identify parts of the DOM. We will cover these in the next section. Here's the CSS file we are applying: @@ -158,6 +159,10 @@ With the CSS in place, the output looks very different: ``` +### Using multiple CSS files + +You can also set the `CSS_PATH` class variable to a list of paths. Textual will combine the rules from all of the supplied paths. + ### Why CSS? It is reasonable to ask why use CSS at all? Python is a powerful and expressive language. Wouldn't it be easier to set styles in your `.py` files? @@ -178,7 +183,7 @@ Being able to iterate on the design without restarting the application makes it A selector is the text which precedes the curly braces in a set of rules. It tells Textual which widgets it should apply the rules to. -Selectors can target a kind of widget or a very specific widget. For instance you could have a selector that modifies all buttons, or you could target an individual button used in one dialog. This gives you a lot of flexibility in customizing your user interface. +Selectors can target a kind of widget or a very specific widget. For instance, you could have a selector that modifies all buttons, or you could target an individual button used in one dialog. This gives you a lot of flexibility in customizing your user interface. Let's look at the selectors supported by Textual CSS. @@ -201,7 +206,7 @@ Button { } ``` -The type selector will also match a widget's base classes. Consequently a `Static` selector will also style the button because the `Button` Python class extends `Static`. +The type selector will also match a widget's base classes. Consequently, a `Static` selector will also style the button because the `Button` Python class extends `Static`. ```sass Static { diff --git a/src/textual/_import_app.py b/src/textual/_import_app.py index e65e7fc85..fcd39f9a6 100644 --- a/src/textual/_import_app.py +++ b/src/textual/_import_app.py @@ -35,8 +35,13 @@ def import_app(import_name: str) -> App: from textual.app import App, WINDOWS import_name, *argv = shlex.split(import_name, posix=not WINDOWS) + drive, import_name = os.path.splitdrive(import_name) + lib, _colon, name = import_name.partition(":") + if drive: + lib = os.path.join(drive, os.sep, lib) + if lib.endswith(".py"): path = os.path.abspath(lib) sys.path.append(str(Path(path).parent)) @@ -62,7 +67,7 @@ def import_app(import_name: str) -> App: except KeyError: raise AppFail(f"App {name!r} not found in {lib!r}") else: - # Find a App class or instance that is *not* the base class + # Find an App class or instance that is *not* the base class apps = [ value for value in global_vars.values() diff --git a/src/textual/_path.py b/src/textual/_path.py index 28a9ba1bc..860041a50 100644 --- a/src/textual/_path.py +++ b/src/textual/_path.py @@ -1,7 +1,6 @@ from __future__ import annotations import inspect -import sys from pathlib import Path, PurePath diff --git a/src/textual/app.py b/src/textual/app.py index 7ea329ed4..d53495730 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -25,6 +25,7 @@ from typing import ( TypeVar, cast, Union, + List, ) from weakref import WeakSet, WeakValueDictionary @@ -126,6 +127,10 @@ class ScreenStackError(ScreenError): """Raised when attempting to pop the last screen from the stack.""" +class CssPathError(Exception): + """Raised when supplied CSS path(s) are invalid.""" + + ReturnType = TypeVar("ReturnType") @@ -137,23 +142,27 @@ class _NullFile: pass -CSSPathType = Union[str, PurePath, None] +CSSPathType = Union[str, PurePath, List[Union[str, PurePath]], None] @rich.repr.auto class App(Generic[ReturnType], DOMNode): """The base class for Textual Applications. - Args: driver_class (Type[Driver] | None, optional): Driver class or ``None`` to auto-detect. Defaults to None. - css_path (str | PurePath | None, optional): Path to CSS or ``None`` for no CSS file. Defaults to None. + css_path (str | PurePath | list[str | PurePath] | None, optional): Path to CSS or ``None`` for no CSS file. + Defaults to None. To load multiple CSS files, pass a list of strings or paths which will be loaded in order. watch_css (bool, optional): Watch CSS for changes. Defaults to False. + + Raises: + CssPathError: When the supplied CSS path(s) are an unexpected type. """ - # Inline CSS for quick scripts (generally css_path should be preferred.) CSS = "" + """Inline CSS, useful for quick scripts. This is loaded after CSS_PATH, + and therefore takes priority in the event of a specificity clash.""" - # Default (lowest priority) CSS + # Default (the lowest priority) CSS DEFAULT_CSS = """ App { background: $background; @@ -227,15 +236,30 @@ class App(Generic[ReturnType], DOMNode): self.stylesheet = Stylesheet(variables=self.get_css_variables()) self._require_stylesheet_update: set[DOMNode] = set() - # We want the CSS path to be resolved from the location of the App subclass css_path = css_path or self.CSS_PATH if css_path is not None: + # When value(s) are supplied for CSS_PATH, we normalise them to a list of Paths. if isinstance(css_path, str): - css_path = Path(css_path) - css_path = _make_path_object_relative(css_path, self) if css_path else None + css_paths = [Path(css_path)] + elif isinstance(css_path, PurePath): + css_paths = [css_path] + elif isinstance(css_path, list): + css_paths = [] + for path in css_path: + css_paths.append(Path(path) if isinstance(path, str) else path) + else: + raise CssPathError( + "Expected a str, Path or list[str | Path] for the CSS_PATH." + ) - self.css_path = css_path + # We want the CSS path to be resolved from the location of the App subclass + css_paths = [ + _make_path_object_relative(css_path, self) for css_path in css_paths + ] + else: + css_paths = [] + self.css_path = css_paths self._registry: WeakSet[DOMNode] = WeakSet() self._installed_screens: WeakValueDictionary[ @@ -774,15 +798,16 @@ class App(Generic[ReturnType], DOMNode): async def _on_css_change(self) -> None: """Called when the CSS changes (if watch_css is True).""" - if self.css_path is not None: + css_paths = self.css_path + if css_paths: try: time = perf_counter() stylesheet = self.stylesheet.copy() - stylesheet.read(self.css_path) + stylesheet.read_all(css_paths) stylesheet.parse() elapsed = (perf_counter() - time) * 1000 self.log.system( - f" loaded {self.css_path!r} in {elapsed:.0f} ms" + f" loaded {len(css_paths)} CSS files in {elapsed:.0f} ms" ) except Exception as error: # TODO: Catch specific exceptions @@ -1141,8 +1166,8 @@ class App(Generic[ReturnType], DOMNode): self.log.system(features=self.features) try: - if self.css_path is not None: - self.stylesheet.read(self.css_path) + if self.css_path: + self.stylesheet.read_all(self.css_path) for path, css, tie_breaker in self.get_default_css(): self.stylesheet.add_source( css, path=path, is_default_css=True, tie_breaker=tie_breaker diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 9d09835c6..b91273eb4 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -248,11 +248,24 @@ class Stylesheet: with open(filename, "rt") as css_file: css = css_file.read() path = os.path.abspath(filename) - except Exception as error: + except Exception: raise StylesheetError(f"unable to read CSS file {filename!r}") from None self.source[str(path)] = CssSource(css, False, 0) self._require_parse = True + def read_all(self, paths: list[PurePath]) -> None: + """Read multiple CSS files, in order. + + Args: + paths (list[PurePath]): The paths of the CSS files to read, in order. + + Raises: + StylesheetError: If the CSS could not be read. + StylesheetParseError: If the CSS is invalid. + """ + for path in paths: + self.read(path) + def add_source( self, css: str, @@ -268,6 +281,7 @@ class Stylesheet: Defaults to None. is_default_css (bool): True if the CSS is defined in the Widget, False if the CSS is defined in a user stylesheet. + tie_breaker (int): Integer representing the priority of this source. Raises: StylesheetError: If the CSS could not be read. diff --git a/src/textual/demo.py b/src/textual/demo.py index 1bcfe9b53..f3401ba7c 100644 --- a/src/textual/demo.py +++ b/src/textual/demo.py @@ -102,15 +102,15 @@ Here's an example of some CSS used in this app: EXAMPLE_CSS = """\ Screen { layers: base overlay notes; - overflow: hidden; + overflow: hidden; } -Sidebar { +Sidebar { width: 40; - background: $panel; - transition: offset 500ms in_out_cubic; + background: $panel; + transition: offset 500ms in_out_cubic; layer: overlay; - + } Sidebar.-hidden { @@ -142,7 +142,7 @@ Build your own or use the builtin widgets. - **DataTable** A spreadsheet-like widget for navigating data. Cells may contain text or Rich renderables. - **TreeControl** An generic tree with expandable nodes. - **DirectoryTree** A tree of file and folders. -- *... many more planned ...* +- *... many more planned ...* """ @@ -319,7 +319,7 @@ class DemoApp(App): self.query_one(TextLog).write(renderable) def compose(self) -> ComposeResult: - example_css = "\n".join(Path(self.css_path).read_text().splitlines()[:50]) + example_css = "\n".join(Path(self.css_path[0]).read_text().splitlines()[:50]) yield Container( Sidebar(classes="-hidden"), Header(show_clock=True), diff --git a/src/textual/file_monitor.py b/src/textual/file_monitor.py index 562682db8..778884865 100644 --- a/src/textual/file_monitor.py +++ b/src/textual/file_monitor.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os from pathlib import PurePath from typing import Callable @@ -9,20 +11,20 @@ from ._callback import invoke @rich.repr.auto class FileMonitor: - """Monitors a file for changes and invokes a callback when it does.""" + """Monitors files for changes and invokes a callback when it does.""" - def __init__(self, path: PurePath, callback: Callable) -> None: - self.path = path + def __init__(self, paths: list[PurePath], callback: Callable) -> None: + self.paths = paths self.callback = callback - self._modified = self._get_modified() + self._modified = self._get_last_modified_time() - def _get_modified(self) -> float: - """Get the modified time for a file being watched.""" - return os.stat(self.path).st_mtime + def _get_last_modified_time(self) -> float: + """Get the most recent modified time out of all files being watched.""" + return max(os.stat(path).st_mtime for path in self.paths) def check(self) -> bool: - """Check the monitored file. Return True if it was changed.""" - modified = self._get_modified() + """Check the monitored files. Return True if any were changed since the last modification time.""" + modified = self._get_last_modified_time() changed = modified != self._modified self._modified = modified return changed @@ -32,5 +34,5 @@ class FileMonitor: await self.on_change() async def on_change(self) -> None: - """Called when file changes.""" + """Called when any of the monitored files change.""" await invoke(self.callback) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index d9eb39417..51ad5531c 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -344,7 +344,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/align.py] +# name: test_css_property[align.py] ''' @@ -502,7 +502,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/background.py] +# name: test_css_property[background.py] ''' @@ -657,7 +657,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/border.py] +# name: test_css_property[border.py] ''' @@ -815,7 +815,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/box_sizing.py] +# name: test_css_property[box_sizing.py] ''' @@ -971,7 +971,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/color.py] +# name: test_css_property[color.py] ''' @@ -1128,7 +1128,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/content_align.py] +# name: test_css_property[content_align.py] ''' @@ -1285,7 +1285,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/display.py] +# name: test_css_property[display.py] ''' @@ -1441,7 +1441,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/grid.py] +# name: test_css_property[grid.py] ''' @@ -1598,7 +1598,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/height.py] +# name: test_css_property[height.py] ''' @@ -1754,7 +1754,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/layout.py] +# name: test_css_property[layout.py] ''' @@ -1912,7 +1912,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/links.py] +# name: test_css_property[links.py] ''' @@ -2069,7 +2069,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/margin.py] +# name: test_css_property[margin.py] ''' @@ -2226,7 +2226,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/offset.py] +# name: test_css_property[offset.py] ''' @@ -2384,7 +2384,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/opacity.py] +# name: test_css_property[opacity.py] ''' @@ -2547,7 +2547,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/outline.py] +# name: test_css_property[outline.py] ''' @@ -2704,7 +2704,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/overflow.py] +# name: test_css_property[overflow.py] ''' @@ -2863,7 +2863,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/padding.py] +# name: test_css_property[padding.py] ''' @@ -3018,7 +3018,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/scrollbar_gutter.py] +# name: test_css_property[scrollbar_gutter.py] ''' @@ -3174,7 +3174,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/scrollbar_size.py] +# name: test_css_property[scrollbar_size.py] ''' @@ -3330,7 +3330,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/scrollbars.py] +# name: test_css_property[scrollbars.py] ''' @@ -3487,7 +3487,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/text_align.py] +# name: test_css_property[text_align.py] ''' @@ -3649,7 +3649,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/text_opacity.py] +# name: test_css_property[text_opacity.py] ''' @@ -3807,7 +3807,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/text_style.py] +# name: test_css_property[text_style.py] ''' @@ -3965,7 +3965,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/tint.py] +# name: test_css_property[tint.py] ''' @@ -4129,7 +4129,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/visibility.py] +# name: test_css_property[visibility.py] ''' @@ -4285,7 +4285,7 @@ ''' # --- -# name: test_css_property_snapshot[docs/examples/styles/width.py] +# name: test_css_property[width.py] ''' @@ -6165,6 +6165,163 @@ ''' # --- +# name: test_multiple_css + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MultipleCSSApp + + + + + + + + + + #one + #two + + + + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_textlog_max_lines ''' diff --git a/tests/snapshot_tests/conftest.py b/tests/snapshot_tests/conftest.py index 58fb1125f..3fb63c5ef 100644 --- a/tests/snapshot_tests/conftest.py +++ b/tests/snapshot_tests/conftest.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime from operator import attrgetter from os import PathLike -from pathlib import Path +from pathlib import Path, PurePath from typing import Union, List, Optional, Callable, Iterable import pytest @@ -31,7 +31,7 @@ TEXTUAL_APP_KEY = pytest.StashKey[App]() @pytest.fixture def snap_compare( snapshot: SnapshotAssertion, request: FixtureRequest -) -> Callable[[str], bool]: +) -> Callable[[str | PurePath], bool]: """ This fixture returns a function which can be used to compare the output of a Textual app with the output of the same app in the past. This is snapshot testing, and it @@ -39,7 +39,7 @@ def snap_compare( """ def compare( - app_path: str, + app_path: str | PurePath, press: Iterable[str] = ("_",), terminal_size: tuple[int, int] = (80, 24), ) -> bool: @@ -50,7 +50,8 @@ def snap_compare( the snapshot on disk will be updated to match the current screenshot. Args: - app_path (str): The path of the app. + app_path (str): The path of the app. Relative paths are relative to the location of the + test this function is called from. press (Iterable[str]): Key presses to run before taking screenshot. "_" is a short pause. terminal_size (tuple[int, int]): A pair of integers (WIDTH, HEIGHT), representing terminal size. @@ -58,7 +59,17 @@ def snap_compare( bool: True if the screenshot matches the snapshot. """ node = request.node - app = import_app(app_path) + path = Path(app_path) + if path.is_absolute(): + # If the user supplies an absolute path, just use it directly. + app = import_app(str(path.resolve())) + else: + # If a relative path is supplied by the user, it's relative to the location of the pytest node, + # NOT the location that `pytest` was invoked from. + node_path = node.path.parent + resolved = (node_path / app_path).resolve() + app = import_app(str(resolved)) + actual_screenshot = take_svg_screenshot( app=app, press=press, @@ -114,16 +125,19 @@ def pytest_sessionfinish( actual_svg = item.stash.get(TEXTUAL_ACTUAL_SVG_KEY, None) app = item.stash.get(TEXTUAL_APP_KEY, None) - if snapshot_svg and actual_svg and app: + if app: path, line_index, name = item.reportinfo() + similarity = ( + 100 + * difflib.SequenceMatcher( + a=str(snapshot_svg), b=str(actual_svg) + ).ratio() + ) diffs.append( SvgSnapshotDiff( snapshot=str(snapshot_svg), actual=str(actual_svg), - file_similarity=100 - * difflib.SequenceMatcher( - a=str(snapshot_svg), b=str(actual_svg) - ).ratio(), + file_similarity=similarity, test_name=name, path=path, line_number=line_index + 1, diff --git a/tests/snapshot_tests/snapshot_apps/__init__.py b/tests/snapshot_tests/snapshot_apps/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshots/fr_units.py b/tests/snapshot_tests/snapshot_apps/fr_units.py similarity index 100% rename from tests/snapshots/fr_units.py rename to tests/snapshot_tests/snapshot_apps/fr_units.py diff --git a/tests/snapshot_tests/snapshot_apps/multiple_css/first.css b/tests/snapshot_tests/snapshot_apps/multiple_css/first.css new file mode 100644 index 000000000..f09f24039 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/multiple_css/first.css @@ -0,0 +1,8 @@ +#one { + background: green; + color: cyan; +} + +#two { + color: red; +} \ No newline at end of file diff --git a/tests/snapshot_tests/snapshot_apps/multiple_css/multiple_css.py b/tests/snapshot_tests/snapshot_apps/multiple_css/multiple_css.py new file mode 100644 index 000000000..5f97223c1 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/multiple_css/multiple_css.py @@ -0,0 +1,34 @@ +"""Testing multiple CSS files, including app-level CSS + +-- element #one +The `background` rule on #one tests a 3-way specificity clash between +classvar CSS and two separate CSS files. The background ends up red +because classvar CSS wins. +The `color` rule tests a clash between loading two external CSS files. +The color ends up as darkred (from 'second.css'), because that file is loaded +second and wins. + +-- element #two +This element tests that separate rules applied to the same widget are mixed +correctly. The color is set to cadetblue in 'first.css', and the background is +darkolivegreen in 'second.css'. Both of these should apply. +""" +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class MultipleCSSApp(App): + CSS = """ + #one { + background: red; + } + """ + + def compose(self) -> ComposeResult: + yield Static("#one", id="one") + yield Static("#two", id="two") + + +app = MultipleCSSApp(css_path=["first.css", "second.css"]) +if __name__ == '__main__': + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/multiple_css/second.css b/tests/snapshot_tests/snapshot_apps/multiple_css/second.css new file mode 100644 index 000000000..9b15e09a6 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/multiple_css/second.css @@ -0,0 +1,8 @@ +#one { + background: blue; + color: darkred; +} + +#two { + background: darkolivegreen; +} \ No newline at end of file diff --git a/tests/snapshots/textlog_max_lines.py b/tests/snapshot_tests/snapshot_apps/textlog_max_lines.py similarity index 100% rename from tests/snapshots/textlog_max_lines.py rename to tests/snapshot_tests/snapshot_apps/textlog_max_lines.py diff --git a/tests/snapshot_tests/snapshot_report_template.jinja2 b/tests/snapshot_tests/snapshot_report_template.jinja2 index d1f7b2530..1f161bdd5 100644 --- a/tests/snapshot_tests/snapshot_report_template.jinja2 +++ b/tests/snapshot_tests/snapshot_report_template.jinja2 @@ -61,6 +61,7 @@ {{ diff.path }}:{{ diff.line_number }} + {% if diff.snapshot != "" %}
@@ -68,6 +69,7 @@ Show difference
+ {% endif %}
@@ -86,17 +88,38 @@
- {{ diff.snapshot }} + {% if diff.snapshot != "" %} + {{ diff.snapshot }} + {% else %} +
+
+

No history for this test

+

If you're happy with the content on the left, + save it to disk by running pytest with the --snapshot-update flag.

+
Unexpected?
+

+ Snapshots are named after the name of the test you call snap_compare in by default. +
+ If you've renamed a test, the association between the snapshot and the test is lost, + and you'll need to run with --snapshot-update to associate the snapshot + with the new test name. +

+
+
+ {% endif %}
+ {% if diff.snapshot != "" %}
- Historical snapshot + + Historical snapshot +
+ {% endif %} - {# Modal with debug info: #}