Metadata-Version: 2.4
Name: varsnap
Version: 1.8.0
Summary: Python client for Varsnap, a snapshot testing service that records and compares function inputs and outputs
Author-email: Varsnap <admin@varsnap.com>
License-Expression: MIT
Project-URL: Homepage, https://www.varsnap.com/
Project-URL: Repository, https://github.com/albertyw/varsnap-python
Project-URL: Documentation, https://github.com/albertyw/varsnap-python/blob/master/README.md
Project-URL: Changelog, https://github.com/albertyw/varsnap-python/blob/master/CHANGELOG.md
Project-URL: Issues, https://github.com/albertyw/varsnap-python/issues
Keywords: testing
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Natural Language :: English
Classifier: Topic :: Software Development
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: requests<3.0,>=2.0
Provides-Extra: test
Requires-Dist: coverage==7.16.1; extra == "test"
Requires-Dist: flask==3.1.3; extra == "test"
Requires-Dist: ruff==0.16.8; extra == "test"
Requires-Dist: mypy==2.3.1; extra == "test"
Requires-Dist: types-requests==2.33.0.20260906; extra == "test"

Varsnap Python
==============

[![Build Status](https://drone.albertyw.com/api/badges/albertyw/varsnap-python/status.svg)](https://drone.albertyw.com/albertyw/varsnap-python)
[![Maintainability](https://qlty.sh/badges/cdd092ce-fea4-49fb-a2a5-bef489c21681/maintainability.svg)](https://qlty.sh/gh/albertyw/projects/varsnap-python)
[![Code Coverage](https://qlty.sh/badges/cdd092ce-fea4-49fb-a2a5-bef489c21681/coverage.svg)](https://qlty.sh/gh/albertyw/projects/varsnap-python)

[Python Varsnap Client](https://www.varsnap.com/)

Installation
------------

Install from PyPI - `pip install varsnap`

Requirements
------------

The client depends on four environment variables to be set:

 - `VARSNAP` - Should be either `true` or `false`.  Varsnap will be disabled if the variable is anything other than `true`.
 - `ENV` - If set to `development`, the client will receive events from production.  If set to `production`, the client will emit events.
 - `VARSNAP_PRODUCER_TOKEN` - Only clients with this token may emit production snapshots.  Copied from https://www.varsnap.com/user/
 - `VARSNAP_CONSUMER_TOKEN` - Only clients with this token may consume production snapshots in development.  Copied from https://www.varsnap.com/user/

Usage
-----

Add the varsnap decorator in front of any function you'd like to make better:

```python
from varsnap import varsnap


@varsnap
def example(args, **kwargs):
    return 'output'
```

Custom serialization
--------------------

Varsnap serializes a function's inputs and outputs as JSON.  Values that JSON
can't represent exactly keep their type if they're one of: bytes, tuples, sets
and frozensets, dicts with non-string keys, `datetime` / `date` / `time` /
`timedelta`, `Decimal`, `UUID`, raised exceptions, or the Flask types below.
Raised exceptions are compared by their type's name, their arguments, and any
attributes they set; the exception's class is never imported or instantiated
from a snapshot.

NaN and infinity, as floats or `Decimal`s, are never snapshotted: they aren't
valid JSON, and a NaN never compares equal to itself, so such a snapshot could
never match.

Other values (such as arbitrary objects) are not snapshotted; varsnap logs a
warning and skips that call.  To snapshot a value of another type, give that
type a pair of `varsnap_serialize` / `varsnap_deserialize` classmethods.
Varsnap reads the function's type annotations and uses these classmethods for
any annotated parameter or return value:

```python
from varsnap import varsnap


class Money:
    def __init__(self, cents):
        self.cents = cents

    @classmethod
    def varsnap_serialize(cls, value):
        return str(value.cents)

    @classmethod
    def varsnap_deserialize(cls, data):
        return cls(int(data))


@varsnap
def add_tax(price: Money) -> Money:
    return Money(round(price.cents * 1.1))
```

If a function isn't annotated (or you want to override its annotations), pass
the types explicitly to the decorator:

```python
@varsnap(types={'price': Money}, returns=Money)
def add_tax(price):
    return Money(round(price.cents * 1.1))
```

An instance method's `self` uses these classmethods from the class the method
is defined on, so methods of a class that provides them are snapshotted too.
Calls on an instance of a subclass are skipped, since the snapshot couldn't
restore the subclass.

The type is always taken from the decorated function, never from the serialized
data, so stored snapshots can't redirect deserialization to a different type.
Values whose type doesn't provide these classmethods use the default
serialization.

### Flask

A Flask `Response` returned from a handler (including inside a
`(response, status)` tuple) is compared by its status code, mimetype, and body.
Headers are ignored, since values like cookies and dates change between runs.
Werkzeug `MultiDict`s such as `request.args` and `request.form`, `Headers`, and
`Markup` keep their types; `request.headers` is restored as `Headers`.  Flask is
not a varsnap dependency.

A streamed response, or one returned by `send_file`, is not snapshotted:
reading its body would consume it before the client received it.

Security of deserialization
---------------------------

Snapshots are fetched from the varsnap server and deserialized on your machine,
so the wire format is treated as untrusted input.  Varsnap serializes values in
one of three formats, all safe to deserialize:

 - `json:` — plain JSON.
 - `builtin:` — JSON with a tag for each value's type, for the types listed
   under "Custom serialization".  Each tag maps to fixed decoding code in
   varsnap, so a payload can't name a class to import or a constructor to call,
   and nesting depth is limited.
 - `type:` — produced by a type's `varsnap_serialize`.  The class is taken from
   the decorated function's annotations, never from the payload.

Pickle is not supported, since unpickling executes arbitrary code embedded in
the payload.  Snapshots recorded by older clients in the `pickle:` format are
skipped.

Testing
-------

With the proper environment variables set, in a test file, add:

```python
import unittest
from varsnap import test

class TestIntegration(unittest.TestCase):
    def test_varsnap(self):
        matches, logs = test()
        if matches is None:
            raise unittest.case.SkipTest('No Snaps found')
        self.assertTrue(matches, logs)
```

If you're testing a Flask application, set up a
[test request context](https://flask.palletsprojects.com/en/stable/api/#flask.Flask.test_request_context) when testing:

```python
# app = Flask()
with app.test_request_context():
    matches, logs = test()
```

Troubleshooting
---------------

**Decorators changing function names**

Using decorators may change the name of functions.  In order to not confuse
varsnap, set the decorated function's `__qualname__` and `__signature__` to
match the original function:

```python
import inspect


def decorator(func):
    def decorated(*args, **kwargs):
        return func(*args, **kwargs)
    decorated.__qualname__ = func.__qualname__
    decorated.__signature__ = inspect.signature(func)
    return decorated
```

Publishing
----------

```bash
pip install build twine
python -m build
twine upload dist/*
```
