Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ All notable changes to this project will be documented in this file.

*NOTE:* Version 0.X.X might have breaking changes in bumps of the minor version number. This is because the project is still in early development and the API is not yet stable. It will still be marked clearly in the release notes.

## [0.5.1] - 2025-08-31
## [0.6.0] - 2025-08-23
- 🆕 Added: `snappylapy diff` CLI command for comparing changed test results with the snapshot. Currently it only supports VScode for showing diffs.
- 🆕 Support generic types. Now custom objects can be used. It can serialize and deserialize any that jsonpickle supports.
- 🐞 Fix issue where files in __test_results__ is being deleted when test discovery is ran
- 🐞 Ensure cross-platform consistancy on how snapshot files are serialized and deserialized
- 🐞 Fixed issue where pandas dataframe types did not resolve correctly at runtime to the dataframe expect class in older python versions

## [0.5.1] - 2025-07-31
- 🐞 Fix missing submodule `expectation_classes`, missing in build version of the package.

## [0.5.0] - 2025-07-30
Expand Down
85 changes: 32 additions & 53 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
# Snappylapy

Welcome to **Snappylapy**, a powerful and intuitive snapshot testing tool for Python's pytest framework. Snappylapy simplifies the process of capturing and verifying snapshots of your data, ensuring your code behaves as expected across different runs. With Snappylapy, you can save snapshots in a human-readable format and deserialize them for robust integration testing, providing a clear separation layer to help isolate errors and maintain code integrity.
Welcome to **Snappylapy**, a powerful and intuitive snapshot testing plugin for Python's pytest framework. Snappylapy simplifies the process of capturing and verifying snapshots of your data, ensuring your code behaves as expected across different runs. With Snappylapy, you can save snapshots in a human-readable format and deserialize them for robust integration testing, providing a clear separation layer to help isolate errors and maintain code integrity.

## Installation
To get started with Snappylapy, install the package via pip:
To get started with Snappylapy, install the package via pip, uv or poetry:

```bash
pip install snappylapy
```

## Key Features
Legend:
- ✅ Implemented
- ❌ Not implemented yet
```bash
uv add snappylapy
```

The features are in development:

- **Human-Readable Snapshots**✅: Save snapshots in a format that's easy to read and understand, making it simpler to review changes and debug issues.
- **Serialization and Deserialization**✅: Snapshots can be serialized and deserialized, allowing for flexible and reusable test cases.
- **Integration Testing**✅: Use snapshots for integration testing with a clear separation layer, preventing interdependencies between code components and making it easier to isolate and identify errors.
- **Easy to Use**✅: Seamlessly integrates with pytest, allowing you to start capturing and verifying snapshots with minimal setup. For a good developer experience the package is fully typed, with docstrings to provide good editor code completion.
- **Customizable Output**✅: Store snapshots in a location of your choice, enabling you to organize and manage your test data effectively.
- **Diff Report Generation**❌: Generate a diff report in html format for easy comparison between test results and snapshots.
- **Provides a wide set of assertions**❌: The package provides a wide set of assertions to make it easy to compare different types of data, for do fuzzy matching or ignore certain parts of the data that are variable.
```bash
poetry add snappylapy
```

## Key Features
- **Human-Readable Snapshots**: Save snapshots in a format that's easy to read and understand, making it simpler to review changes and debug issues.
- **Serialization and Deserialization**: Snapshots can be serialized and deserialized, allowing for flexible and reusable test cases.
- **Easy to Use**: Seamlessly integrates with pytest, enabling you to capture and verify snapshots with minimal setup. The fully typed fixtures and rich editor support provide intuitive code completion and guidance, making your workflow faster and more productive.
- **Customizable Output**: Store snapshots in a location (static or dynamic) of your choice, enabling you to organize and manage your test data effectively.
- **Editor Integration**: Can show a diff comparison in VS code when a snapshot test fails, for easy comparison between test results and snapshots.

## Benefits of Snapshot Testing
Snapshot testing is a powerful technique for verifying the output of your code by comparing it to a stored snapshot. This approach offers several benefits, including:
Expand Down Expand Up @@ -96,46 +96,25 @@ snappylapy update
```

## Fixtures and roadmap
Registers fixtures:
- expect
- load_snapshot
Registers pytest fixtures:
- expect
- load_snapshot

Supported data types
- .txt ✅
- .json ✅
- .csv ❌
- .yaml ❌
- .jsonl ❌

Planned data types:

| Python Type | Default Output file type | Implementation Status |
|---------------------|--------------------------|---------------------------|
| bytes | .txt | ✅ |
| pd.DataFrame | .csv | ✅ (missing csv support) |
| pd.Series | .csv | ❌ |
| np.ndarray | .csv | ❌ |
| dict | .json | ✅ |
| list | .json | ✅ |
| tuple | .json | ❌ |
| set | .json | ❌ |
| str | .txt | ✅ |
| int | .txt | ❌ |
| float | .txt | ❌ |
| bool | .txt | ❌ |
| datetime.datetime | .txt | ❌ |
| datetime.date | .txt | ❌ |
| datetime.time | .txt | ❌ |
| pathlib.Path | .txt | ❌ |
| decimal.Decimal | .txt | ❌ |
| uuid.UUID | .txt | ❌ |
| pydantic.BaseModel | .json | ❌ |
| python dataclasses | .json | ❌ |



---

- ✅ .txt - if you provide a string
- ✅ .json - for all other objects
- ✅ custom (decode the data yourself and provide a file extension)

### Supported data types to snapshot test
Snappylapy uses jsonpickle to serialize into json, this means that it can handle almost any Python object out of the box, including:

- Built-in types: str, int, float, bool, None
- Collections: list, tuple, set, dict
- NumPy arrays and pandas DataFrames (with optional dependencies)
- Custom classes (with jsonpickle support)

It is also possible to serialize objects yourself and provide them as a string or bytes data. Then it will be stored and loaded as-is. This means that with snappylapy it is possible to serialize and deserialize any Python object, even those not natively supported.

Snappylapy is your go-to tool for efficient and reliable snapshot testing in Python. By maintaining clear boundaries between different parts of your code, Snappylapy helps you isolate errors, streamline debugging, and ensure your code remains robust and maintainable.

## Contributing
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"py/object": "test_snappylappy_new.test_custom_type.<locals>.CustomObject"
}
16 changes: 8 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
[project]
name = "snappylapy"
authors = [
{name = "Martin Møldrup"},
]
requires-python = "<4.0,>=3.9"
dependencies = [
"pytest>=7.0",
"jsonpickle>=1.0",
"typer",
"typing-extensions ; python_full_version == '3.9.*'",
]
name = "snappylapy"
version = "0.5.1"
version = "0.6.0"
description = "A snapshot library for python optimized for easy of use, human readable snapshots and enabling decoupling of chained integration tests."
readme = "README.md"
classifiers = [
Expand Down Expand Up @@ -39,6 +33,12 @@ keywords = [
"integration-testing",
"unit-testing",
]
dependencies = [
"pytest>=7.0",
"jsonpickle>=1.0",
"typer",
"typing-extensions ; python_full_version == '3.9.*'",
]

[project.urls]
Homepage = "https://martinmoldrup.github.io/snappylapy"
Expand Down
53 changes: 53 additions & 0 deletions snappylapy/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import re
import typer
import pathlib
import subprocess # noqa: S404
from enum import Enum
from snappylapy._utils_directories import DirectoryNamesUtil
from snappylapy.constants import DIRECTORY_NAMES
Expand Down Expand Up @@ -102,6 +103,35 @@ def update() -> None:
typer.echo(f"Updated snapshot: {snapshot_file}")


@app.command()
def diff() -> None:
"""Show the differences between the test results and the snapshots."""
files_test_results = DirectoryNamesUtil().get_all_file_paths_test_results()
file_statuses = check_file_statuses(files_test_results)
files_to_diff = [file for file, status in file_statuses.items() if status == FileStatus.CHANGED]
if not files_to_diff:
status_counts: dict[FileStatus, int] = dict.fromkeys(FileStatus, 0)
for status in file_statuses.values():
status_counts[status] += 1

typer.secho("File status counts:", underline=True, bold=True)
for status, count in status_counts.items():
typer.echo(f"- {status.value}: {count} file(s)")
typer.echo("No files have changed, not opening any diffs.")
return
typer.echo(f"Opening diffs for {len(files_to_diff)} changed files.")
for file in files_to_diff:
snapshot_file = file.parent.parent / DIRECTORY_NAMES.snapshot_dir_name / file.name
success: bool = _try_open_diff(file, snapshot_file)
if not success:
typer.secho(
f"Could not open diff tool. Files to compare:\n"
f" Test result: {file.resolve()}\n"
f" Snapshot: {snapshot_file.resolve()}",
fg=typer.colors.YELLOW,
)


def delete_files(list_of_files_to_delete: list[pathlib.Path]) -> None:
"""Delete files."""
# Delete files
Expand All @@ -116,6 +146,29 @@ def delete_files(list_of_files_to_delete: list[pathlib.Path]) -> None:
root_dir.rmdir()


def _try_open_diff(file1: pathlib.Path, file2: pathlib.Path) -> bool:
"""Try to open diff using available tools, return True if successful."""
diff_commands: list[list[str]] = [
["code", "--diff", str(file1.resolve()), str(file2.resolve())],
["code.cmd", "--diff", str(file1.resolve()), str(file2.resolve())], # Windows alternative
]

for command in diff_commands:
try:
subprocess.run(command, check=True, timeout=10) # noqa: S603 - shell=False and args as list, safe usage
except subprocess.TimeoutExpired: # noqa: PERF203
typer.secho(
f"Diff tool timed out for command: {' '.join(command)}",
fg=typer.colors.RED,
)
continue
except (subprocess.CalledProcessError, FileNotFoundError):
continue
else:
return True
return False


class FileStatus(Enum):
"""Enum to represent the status of a file."""

Expand Down
13 changes: 9 additions & 4 deletions snappylapy/_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,16 @@ def pytest_addoption(parser: pytest.Parser) -> None:


def pytest_sessionstart(session: pytest.Session) -> None:
"""Initialize the snapshot session."""
"""Initialize the snapshot session before running tests."""
# Check if we're in discovery/collection mode
if getattr(session.config.option, "collectonly", False) or getattr(session.config.option, "collect_only", False):
return

session.config.snappylapy_session = SnapshotSession() # type: ignore[attr-defined]
list_of_files_to_delete = DirectoryNamesUtil().get_all_file_paths_test_results()
for file in list_of_files_to_delete:
file.unlink()
directory_util: DirectoryNamesUtil = DirectoryNamesUtil()
files_to_delete: list[pathlib.Path] = directory_util.get_all_file_paths_test_results()
for file_path in files_to_delete:
file_path.unlink()


class ExceptionDuringTestSetupError(Exception):
Expand Down
2 changes: 2 additions & 0 deletions snappylapy/expectation_classes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
from .expect_dataframe import DataframeExpect
from .expect_dict import DictExpect
from .expect_list import ListExpect
from .expect_object import ObjectExpect
from .expect_string import StringExpect

__all__ = [
"BytesExpect",
"DataframeExpect",
"DictExpect",
"ListExpect",
"ObjectExpect",
"StringExpect",
]
4 changes: 3 additions & 1 deletion snappylapy/expectation_classes/base_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def to_match_snapshot(self) -> None:
"""Assert test results match the snapshot."""
if not (self.settings.snapshot_dir / self.settings.filename).exists():
if not self.settings.snapshot_update:
error_msg = f"Snapshot file not found: {self.settings.filename}, run pytest with the --snapshot-update flag to create it." # noqa: E501
error_msg = f"Snapshot file not found: {self.settings.filename}, run 'snappylapy update' command in the terminal, or run pytest with the --snapshot-update flag to create it." # noqa: E501
raise FileNotFoundError(error_msg)
self.snappylapy_session.add_created_snapshot(
self.settings.filename)
Expand All @@ -52,7 +52,9 @@ def to_match_snapshot(self) -> None:
self.settings.filename)
try:
snapshot_data_str = snapshot_data.decode()
snapshot_data_str = snapshot_data_str.replace("\r\n", "\n").replace("\r", "\n")
test_data_str = test_data.decode()
test_data_str = test_data_str.replace("\r\n", "\n").replace("\r", "\n")
assert snapshot_data_str == test_data_str
except AssertionError as error:
if self.settings.snapshot_update:
Expand Down
21 changes: 21 additions & 0 deletions snappylapy/expectation_classes/expect_object.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Snapshot testing and expectations for generic custom objects."""
from __future__ import annotations

from .base_snapshot import BaseSnapshot
from snappylapy.serialization import JsonPickleSerializer


class ObjectExpect(BaseSnapshot[object]):
"""Snapshot testing for generic objects."""

serializer_class = JsonPickleSerializer[object]

def __call__(
self,
data_to_snapshot: object,
name: str | None = None,
filetype: str = "object.json",
) -> ObjectExpect:
"""Prepare an object for snapshot testing."""
self._prepare_test(data_to_snapshot, name, filetype)
return self
54 changes: 47 additions & 7 deletions snappylapy/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
DataframeExpect,
DictExpect,
ListExpect,
ObjectExpect,
StringExpect,
)
from .models import Settings
Expand Down Expand Up @@ -205,6 +206,32 @@ def test_dataframe(expect: Expect) -> None:
```
"""

self.object = ObjectExpect(self.settings, snappylapy_session)
"""ObjectExpect instance for configuring snapshot testing of generic objects.

The instance is callable with the following parameters:

Parameters
----------
data_to_snapshot : object
The object data to be snapshotted.
name : str, optional
The name of the snapshot, by default "".
filetype : str, optional
The file type of the snapshot, by default "object.json".

Returns
-------
ObjectExpect
The instance of the ObjectExpect class.

Example
-------
```python
expect.object({"key": "value"}).to_match_snapshot()
```
"""

def read_snapshot(self) -> bytes:
"""Read the snapshot file."""
return (self.settings.snapshot_dir / self.settings.filename).read_bytes()
Expand Down Expand Up @@ -243,13 +270,21 @@ def __call__(
filetype: str | None = None,
) -> DataframeExpect: ...

@overload
def __call__(
self,
data_to_snapshot: Any, # noqa: ANN401
name: str | None = None,
filetype: str | None = None,
) -> ObjectExpect: ...

def __call__(
self,
data_to_snapshot: dict | list[Any] | str | bytes | DataframeExpect.DataFrame,
name: str | None = None,
filetype: str | None = None,
) -> DictExpect | ListExpect | StringExpect | BytesExpect | DataframeExpect:
"""Call the fixture with the given parameters."""
) -> DictExpect | ListExpect | StringExpect | BytesExpect | DataframeExpect | ObjectExpect:
"""Call the fixture with the given parameters. Falls back to object handler for custom objects."""
kwargs: dict[str, str] = {}
if name is not None:
kwargs["name"] = name
Expand All @@ -268,11 +303,16 @@ def __call__(
if isinstance(typ, type) and isinstance(data_to_snapshot, typ):
return func(data_to_snapshot, **kwargs)

supported_types: list[str] = [
getattr(typ, "__name__", str(typ)) if isinstance(typ, type) else typ for typ in type_map.keys() # noqa: SIM118
]
error_message = f"Unsupported type: {type(data_to_snapshot)}. Supported types: {', '.join(supported_types)}."
raise TypeError(error_message)
# Check if the object is a pandas DataFrame without importing pandas directly
if (
type(data_to_snapshot).__module__.startswith("pandas")
and type(data_to_snapshot).__name__ == "DataFrame"
# TODO: Create a protocol class instead that contains all the dependencies we are depending on
):
return self.dataframe(data_to_snapshot, **kwargs) # type: ignore[arg-type]

# Fallback: treat custom objects as dicts for snapshotting
return self.object(data_to_snapshot, **kwargs)


class LoadSnapshot:
Expand Down
Loading