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
25 changes: 12 additions & 13 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,52 +3,51 @@ 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.

## [unreleased]
- 🆕 Add new cli command for easy update of snapshots without the need to rerun tests
## [0.5.0] - 2025-07-30
- 🆕 Add new cli command for easy update of snapshots without the need to rerun tests. Added: `snappylapy update` CLI command that will update the snapshots that have changed or create new snapshots.

## [0.4.0]
## [0.4.0] - 2025-06-08
- 🆕 Add support for pandas DataFrame assertions (pandas is an optional dependency, and snappylapy can be used without it)
- 🆕 Add new simpler and more intuitive Expect API. We can use expect() calls and an overload will select the appropriate Expect class based on the type of the input. This is in contrast with expect.dict(), expect.list(), etc. This is not a breaking change, since the old API is still supported.

## [0.3.2]
## [0.3.2] - 2025-04-26
- 🐞 Bug fix of test_directory fixture, not loading correctly depending fixture

## [0.3.1]
## [0.3.1] - 2025-01-29
- 🐞 Fix load_snapshot fixture not loading correctly
- 🐞 Fix wrong naming for cases where custom name is falsy
- Change the ident property of json to make it more human readable


## [0.3.0]
## [0.3.0] - 2025-01-26
- Add snappylapy parametization functionality to snappylapy and allow it to be loaded in the load_snapshot fixture
- Do reporting showing count of updated, created and deleted snapshots
- **Breaking Changes**
- Change setting of path to only have a single path configurable and update such it is configured through the marker
- Make the folder names of __snapshots__ and __test_results__ enforced to be fixed (needed for cleanup and diffing)
- Rename output file names of snapshots to match [filename][testname][name].extention conversion

## [0.2.1] - 13-01-2025
## [0.2.1] - 2025-01-13
- Added missing dependency for typer to make the CLI work

## [0.2.0] - 13-01-2025
## [0.2.0] - 2025-01-13
- Better error messages by using pytest assertion rewriting
- Allow users to set the snapshot directory when using the load_snapshot fixture
- Add CLI for for init and clear commands
- Added automated generation of documentation using mkdocs

## [0.1.1] - 10-01-2025
## [0.1.1] - 2025-01-10
- Update dependencies with the lower bounds of package compatibility
- Refactor to make code easier for users of package to modify and extend

## [0.1.0] - 08-01-2025
## [0.1.0] - 2025-01-08
- Added fixture for loading snapshots from previous tests (load_snapshot fixture)
- Added the snappylapy marker for tests that depend on previous tests (pytest.mark.snappylapy). This will be used for more advanced features in the future.

## [0.0.2] - 07-01-2025
## [0.0.2] - 2025-01-07
- 🐞 Added fix for python 3.9, by refactoring incompatible type annotation
- Loosened the version requirements for pytest (until the lower bound have been discovered, with automated testing)
- Improved metadata for pypi

## [0.0.1] - 06-01-2025
## [0.0.1] - 2025-01-06
- Initial release of Snappylapy
- Implemented basic snapshot testing functionality for dict, list, bytes and str data types
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ from mypackage import my_function
def test_snapshot_dict(expect: Expect):
"""Test snapshot with dictionary data."""
data: dict = my_function()
expect.dict(data).to_match_snapshot()
# or expect(data).to_match_snapshot()
expect(data).to_match_snapshot()
# or expect.dict(data).to_match_snapshot()
```

In this example, `snappylapy` captures the output of `my_function` and compares it against a stored snapshot. If the output changes unexpectedly, pytest will flag the test, allowing you to review the differences and ensure your code behaves as expected.
Expand All @@ -59,7 +59,7 @@ from snappylapy import Expect, LoadSnapshot

def test_snapshot_dict(expect: Expect):
"""Test snapshot with dictionary data."""
expect.dict({
expect({
"name": "John Doe",
"age": 31
}).to_match_snapshot()
Expand All @@ -68,6 +68,8 @@ def test_snapshot_dict(expect: Expect):
def test_load_snapshot_from_file(load_snapshot: LoadSnapshot):
"""Test loading snapshot data created in test_snapshot_dict from a file using the deserializer."""
data = load_snapshot.dict()
# Normally you would use the data as an input for some other function
# For demonstration, we will just assert the data matches the expected snapshot
assert data == {"name": "John Doe", "age": 31}
```

Expand All @@ -81,12 +83,18 @@ The results is split into two folders, for ease of comparison, and for handling
- __snapshots__: Updated only when --snapshot-update flag is used when running the test suite. Commit this to your version control system.

## Usage
Update snapshots with:
Snapshots can be updated when running pytest:

```bash
pytest --snapshot-update
```

Alternatively, you can use the CLI command to update snapshots:

```bash
snappylapy update
```

## Fixtures and roadmap
Registers fixtures:
- expect ✅
Expand Down
104 changes: 104 additions & 0 deletions devtools/create_new_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Create a new release for the project, by reading the version from the pyproject.toml file, and adding and pushing a new tag to the repository."""
import sys
import toml
import typer
import pathlib
import requests
import subprocess
from packaging.version import Version
from toolit import tool

PYPROJECT_TOML = pathlib.Path(__file__).parent.parent / "pyproject.toml"
CHANGELOG_MD = pathlib.Path(__file__).parent.parent / "CHANGELOG.md"
PYPI_ENDPOINT = "https://pypi.org/pypi/snappylapy/json"

def read_pyproject_version() -> Version:
"""Read the version from the pyproject.toml file."""
with open(PYPROJECT_TOML, "r", encoding="utf-8") as file:
data = toml.load(file)
version_str = data["project"]["version"]
return Version(version_str)

def read_pypi_version() -> Version:
"""Check if the version is newer than the one in pypi."""
response = requests.get(PYPI_ENDPOINT)
version_str = response.json()["info"]["version"]
return Version(version_str)

def check_change_log(version: Version) -> bool:
"""Check if the version has a corresponding entry in the change log. It will be a line starting with ## [0.0.2]"""
with open(CHANGELOG_MD, "r", encoding="utf-8") as file:
lines = file.readlines()
version_str = f"## [{version}]"
return any(line.startswith(version_str) for line in lines)


@tool
def create_new_release() -> None:
"""Create a new release for the project by reading the version from the pyproject.toml file, checking the change log, and pushing a new tag to the repository."""
if not PYPROJECT_TOML.exists():
raise FileNotFoundError(f"{PYPROJECT_TOML} does not exist. Please run this script from the project root directory, and ensure the {PYPROJECT_TOML} file exists.")
if not CHANGELOG_MD.exists():
raise FileNotFoundError(f"{CHANGELOG_MD} does not exist. Please run this script from the project root directory, and ensure the {CHANGELOG_MD} file exists.")
version_pyproject: Version = validate_and_get_version()
validate_change_log_entry(version_pyproject)
response: str = typer.prompt(
f"Do you want to create a new release for version {typer.style(str(version_pyproject), fg=typer.colors.GREEN, bold=True)}? "
"This will create a new tag and push it to the repository. (yes/no)",
default="no",
)
if response.lower() not in ("yes", "y"):
typer.secho("Release creation aborted.", fg=typer.colors.RED, bold=True)

sys.exit(1)

# Create a new tag
res: subprocess.CompletedProcess = subprocess.run(["git", "tag", str(version_pyproject)])
if res.returncode != 0:
raise ValueError(f"Error creating tag {version_pyproject}")

# Push the new tag
res = subprocess.run(["git", "push", "origin", str(version_pyproject)])
if res.returncode != 0:
raise ValueError(f"Error pushing tag {version_pyproject}")

typer.secho("Release created successfully.", fg=typer.colors.GREEN, bold=True)


def validate_and_get_version() -> Version:
"""Validate the version from the pyproject.toml file and check if it is newer than the one in pypi."""
version_pyproject: Version = read_pyproject_version()
typer.echo(f"Creating a new release for version {typer.style(str(version_pyproject), fg=typer.colors.GREEN, bold=True)} (read from {PYPROJECT_TOML}).")
version_pypi: Version = read_pypi_version()
typer.echo(
f"Current version in pypi is {typer.style(str(version_pypi), fg=typer.colors.YELLOW, bold=True)} (read from {PYPI_ENDPOINT})."
)
typer.echo(
f"Validating the "
f"{typer.style(str(version_pypi), fg=typer.colors.YELLOW, bold=True)}"
" -> "
f"{typer.style(str(version_pyproject), fg=typer.colors.GREEN, bold=True)} deployment."
)
if version_pyproject <= version_pypi:
error_message = (
f"Version {version_pyproject} is not newer than the one in pypi. "
"Please update the version in pyproject.toml."
)
raise ValueError(error_message)
return version_pyproject

def validate_change_log_entry(version_pyproject: Version):
"""Validate that the version has a corresponding entry in the change log."""
if not check_change_log(version_pyproject):
error_message = (
f"Version {version_pyproject} does not have a corresponding entry in the change log."
)
raise ValueError(error_message)
typer.secho(
f"Version {version_pyproject} has a corresponding entry in the change log.",
fg=typer.colors.GREEN,
bold=True,
)

if __name__ == "__main__":
create_new_release()
3 changes: 3 additions & 0 deletions devtools/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
In this project, toolit is used as a command runner used for easy automation with scripts, such they can be run with the `toolit` command line interface (CLI), MCP server, or as VSCode tasks.

Add the automation scripts in this folder with @tool decorator from `toolit` package.
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ dependencies = [
"typing-extensions ; python_full_version == '3.9.*'",
]
name = "snappylapy"
version = "0.4.0"
version = "0.5.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 @@ -83,4 +83,5 @@ dev = [
"mkdocs-literate-nav<1.0.0,>=0.6.1",
"mkdocs-section-index<1.0.0,>=0.3.9",
"pandas-stubs>=2.2.2.240807",
"toolit>=0.2.0",
]
60 changes: 0 additions & 60 deletions scripts/create_new_release.py

This file was deleted.

28 changes: 20 additions & 8 deletions snappylapy/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from typing import Any, Protocol, overload


class CallableExpectation(Protocol):
class _CallableExpectation(Protocol):
"""Protocol for callable expectations to use internally in this module."""

def __call__(
Expand Down Expand Up @@ -215,6 +215,7 @@ def read_test_results(self) -> bytes:

@overload
def __call__(self, data_to_snapshot: dict, name: str | None = None, filetype: str | None = None) -> DictExpect: ...

@overload
def __call__(
self,
Expand All @@ -236,7 +237,10 @@ def __call__(

@overload
def __call__(
self, data_to_snapshot: DataframeExpect.DataFrame, name: str | None = None, filetype: str | None = None,
self,
data_to_snapshot: DataframeExpect.DataFrame,
name: str | None = None,
filetype: str | None = None,
) -> DataframeExpect: ...

def __call__(
Expand All @@ -252,7 +256,7 @@ def __call__(
if filetype is not None:
kwargs["filetype"] = filetype

type_map: dict[type, CallableExpectation] = {
type_map: dict[type, _CallableExpectation] = {
dict: self.dict,
list: self.list,
str: self.string,
Expand All @@ -261,13 +265,14 @@ def __call__(
}

for typ, func in type_map.items():
if isinstance(data_to_snapshot, typ):
if isinstance(typ, type) and isinstance(data_to_snapshot, typ):
return func(data_to_snapshot, **kwargs)

error_message = f"Unsupported type {type(data_to_snapshot)}. Expected one of: dict, list, str, bytes."
raise TypeError(
error_message,
)
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)


class LoadSnapshot:
Expand Down Expand Up @@ -307,3 +312,10 @@ def bytes(self) -> bytes:
"""Load bytes snapshot."""
self.settings.depending_filename_extension = "bytes.txt"
return BytesSerializer().deserialize(self._read_snapshot())

def dataframe(self) -> DataframeExpect.DataFrame:
"""Load dataframe snapshot."""
self.settings.depending_filename_extension = "dataframe.json"
return DataframeExpect.DataFrame(
JsonPickleSerializer[DataframeExpect.DataFrame]().deserialize(self._read_snapshot()),
)
9 changes: 9 additions & 0 deletions tests/test_snappylappy_new.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ def test_snapshot_string(expect: Expect):
"""Test snapshot with string data."""
expect.string("Hello World").to_match_snapshot()

def test_unsupported_type(expect: Expect):
"""Test snapshot with unsupported type."""
class Unsupported:
"""An unsupported type for snapshot."""
pass

with pytest.raises(TypeError):
expect(Unsupported())

def test_snapshot_bytes(expect: Expect):
"""Test snapshot with bytes data."""
expect.bytes(b"Hello World", name="bytes_snapshot").to_match_snapshot()
Expand Down
Loading