pax_global_header00006660000000000000000000000064147647614030014526gustar00rootroot0000000000000052 comment=7edafe7713c88ae15e12d96d37833af0f9f1f6f3 python-inline-snapshot-0.20.7/000077500000000000000000000000001476476140300162465ustar00rootroot00000000000000python-inline-snapshot-0.20.7/.github/000077500000000000000000000000001476476140300176065ustar00rootroot00000000000000python-inline-snapshot-0.20.7/.github/FUNDING.yml000066400000000000000000000000201476476140300214130ustar00rootroot00000000000000github: 15r10nk python-inline-snapshot-0.20.7/.github/actions/000077500000000000000000000000001476476140300212465ustar00rootroot00000000000000python-inline-snapshot-0.20.7/.github/actions/setup/000077500000000000000000000000001476476140300224065ustar00rootroot00000000000000python-inline-snapshot-0.20.7/.github/actions/setup/action.yml000066400000000000000000000010421476476140300244030ustar00rootroot00000000000000name: General Setup description: checkout & setup python inputs: python-version: # id of input description: the python version to use required: false default: '3.12' runs: using: composite steps: - name: Install uv uses: astral-sh/setup-uv@v5 with: python-version: ${{inputs.python-version}} - name: Set up Python uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: ${{inputs.python-version}} architecture: x64 allow-prereleases: true python-inline-snapshot-0.20.7/.github/workflows/000077500000000000000000000000001476476140300216435ustar00rootroot00000000000000python-inline-snapshot-0.20.7/.github/workflows/ci.yml000066400000000000000000000102651476476140300227650ustar00rootroot00000000000000name: CI on: pull_request: push: branches: [main] jobs: mypy: runs-on: ubuntu-latest strategy: matrix: python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: ./.github/actions/setup with: python-version: ${{matrix.python-version}} - run: uvx hatch run +py=${{matrix.python-version}} types:check test: runs-on: ${{matrix.os}} strategy: matrix: python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', pypy3.9, pypy3.10] os: [ubuntu-latest, windows-latest, macos-13] extra_deps: ['"--with=pytest==8.3.3" "--with=pydantic<2"', '"--with=pytest>=8.3.4" "--with=pydantic>2"'] env: TOP: ${{github.workspace}} COVERAGE_PROCESS_START: ${{github.workspace}}/pyproject.toml steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: ./.github/actions/setup with: python-version: ${{matrix.python-version}} - run: | uv run ${{matrix.extra_deps}} --extra black --extra dirty-equals -m ${{ matrix.os == 'ubuntu-latest' && 'coverage run -m' || '' }} pytest -n=auto -vv - run: | uv run -m coverage combine mv .coverage .coverage.${{ matrix.python-version }}-${{matrix.os}}-${{strategy.job-index}} if: matrix.os == 'ubuntu-latest' - name: Upload coverage data uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 if: matrix.os == 'ubuntu-latest' with: name: coverage-data-${{github.run_id}}-${{ matrix.python-version }}-${{matrix.os}}-${{strategy.job-index}} path: .coverage.* include-hidden-files: true if-no-files-found: ignore coverage: name: Combine & check coverage env: TOP: ${{github.workspace}} if: always() needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: ./.github/actions/setup - uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 with: pattern: coverage-data-${{github.run_id}}-* merge-multiple: true - name: Combine coverage & fail if it's <100% run: | uv pip install --upgrade coverage[toml] coverage combine coverage html --skip-covered --skip-empty # Report and write to summary. coverage report --format=markdown >> $GITHUB_STEP_SUMMARY # Report again and fail if under 100%. coverage report --fail-under=100 - name: Upload HTML report if check failed uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: html-report path: htmlcov if: ${{ failure() }} publish: name: Publish new release runs-on: ubuntu-latest needs: [test, coverage] environment: pypi permissions: # IMPORTANT: this permission is mandatory for Trusted Publishing id-token: write # this permission is mandatory to create github releases contents: write steps: - name: Checkout main uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - uses: ./.github/actions/setup - name: Check if the commit has a vx.y.z tag id: check-version run: | if git tag --list --points-at ${{ github.sha }} | grep -q -E '^v[0-9]+\.[0-9]+\.[0-9]+$'; then echo "is new version" echo "should_continue=true" >> "$GITHUB_OUTPUT" else echo "is not a new version" echo "should_continue=false" >> "$GITHUB_OUTPUT" fi - run: uv pip install hatch scriv - name: build package run: hatch build - name: Publish package distributions to PyPI if: ${{ steps.check-version.outputs.should_continue == 'true' }} uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 - name: create github release if: ${{ steps.check-version.outputs.should_continue == 'true' }} env: GITHUB_TOKEN: ${{ github.token }} run: scriv github-release python-inline-snapshot-0.20.7/.github/workflows/deploy_development_docs.yml000066400000000000000000000011221476476140300272700ustar00rootroot00000000000000name: deploy development docs on: push: branches: [main] jobs: build: name: Deploy development docs runs-on: ubuntu-latest steps: - name: Checkout main uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - run: pip install hatch - name: publish docs env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git config user.name "Frank Hoffmann" git config user.email "15r10nk@users.noreply.github.com" git fetch origin gh-pages --depth=1 hatch run docs:mike deploy --push development python-inline-snapshot-0.20.7/.github/workflows/docs.yml000066400000000000000000000013661476476140300233240ustar00rootroot00000000000000name: Publish docs via GitHub Pages on: push: tags: - v[0-9]+.[0-9]+.[0-9]+ jobs: build: name: Deploy docs for new version runs-on: ubuntu-latest steps: - name: Checkout main uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - run: pip install hatch - run: hatch version | sed -rne "s:([0-9]*)\.([0-9]*)\..*:INLINE_SNAPSHOT_VERSION=\1.\2:p" >> ${GITHUB_ENV} - name: publish docs env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git config user.name "Frank Hoffmann" git config user.email "15r10nk@users.noreply.github.com" git fetch origin gh-pages --depth=1 hatch run docs:mike deploy -u --push ${INLINE_SNAPSHOT_VERSION} latest python-inline-snapshot-0.20.7/.github/workflows/test_docs.yml000066400000000000000000000005031476476140300243530ustar00rootroot00000000000000name: test docs on: pull_request: jobs: build: name: Deploy development docs runs-on: ubuntu-latest steps: - name: Checkout main uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - run: pip install hatch - name: test docs run: | hatch run docs:build python-inline-snapshot-0.20.7/.gitignore000066400000000000000000000016131476476140300202370ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ .pytest_cache # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask instance folder instance/ # Sphinx documentation docs/_build/ # MkDocs documentation /site/ # PyBuilder target/ # IPython Notebook .ipynb_checkpoints # pyenv .python-version .mutmut-cache html python-inline-snapshot-0.20.7/.pre-commit-config.yaml000066400000000000000000000037611476476140300225360ustar00rootroot00000000000000ci: autofix_pr: false autoupdate_commit_msg: 'style: pre-commit autoupdate' autofix_commit_msg: 'style: pre-commit fixed styling' autoupdate_schedule: monthly repos: - hooks: - id: check-ast - id: check-merge-conflict - id: trailing-whitespace - id: mixed-line-ending - id: fix-byte-order-marker - id: check-case-conflict - id: check-json - id: end-of-file-fixer repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 - hooks: - args: - --in-place - --expand-star-imports - --remove-all-unused-imports - --ignore-init-module-imports id: autoflake repo: https://github.com/myint/autoflake rev: v2.3.1 - repo: local hooks: - id: replace-words name: Replace Words entry: python3 scripts/replace_words.py language: system files: \.(md|py)$ - repo: https://github.com/asottile/setup-cfg-fmt rev: v2.5.0 hooks: - id: setup-cfg-fmt - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: - id: isort name: isort (python) - hooks: - args: - --py38-plus id: pyupgrade repo: https://github.com/asottile/pyupgrade rev: v3.17.0 - hooks: - id: black repo: https://github.com/psf/black rev: 24.8.0 - hooks: - id: blacken-docs args: [-l80] repo: https://github.com/adamchainz/blacken-docs rev: 1.18.0 - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks rev: v2.14.0 hooks: - id: pretty-format-yaml args: [--autofix, --indent, '2'] - hooks: - id: commitizen stages: - commit-msg repo: https://github.com/commitizen-tools/commitizen rev: v3.28.0 # - repo: https://github.com/PyCQA/docformatter # rev: v1.7.5 # hooks: # - id: docformatter - repo: https://github.com/abravalheri/validate-pyproject rev: v0.23 hooks: - id: validate-pyproject # Optional extra validations from SchemaStore: additional_dependencies: ['validate-pyproject-schema-store[all]'] - repo: https://github.com/rhysd/actionlint rev: v1.7.4 hooks: - id: actionlint python-inline-snapshot-0.20.7/CHANGELOG.md000066400000000000000000000335261476476140300200700ustar00rootroot00000000000000 # 0.20.7 — 2025-03-14 ## Changed - Tests with failed snapshot comparisons now always result in a pytest `Error`, even if snapshots have been fixed or created. # 0.20.6 — 2025-03-13 ## Fixed - Do not skip snapshots in conditional marked xfail tests. ``` python @pytest.mark.xfail(False, reason="...") def test_a(): assert 5 == snapshot(3) # <- this will be fixed @pytest.mark.xfail(True, reason="...") def test_b(): assert 5 == snapshot(3) # <- this not ``` # 0.20.5 — 2025-03-04 ## Fixed - correct normalization of "python3.9" to "python" in tests # 0.20.4 — 2025-03-03 ## Fixed - Prevent a crash if a value cannot be copied and a UsageError is raised. # 0.20.3 — 2025-02-28 ## Fixed - Use the black API directly to format python code. This solves issues with the upcomming click 8.2.0 (#202) and problems in multithreading (https://github.com/15r10nk/inline-snapshot/pull/193#issuecomment-2660393512). # 0.20.2 — 2025-02-13 ## Fixed - snapshots inside tests which are marked as xfail are now ignored (#184) - Fixed a crash caused by the following code: ``` python snapshot(tuple()) # or snapshot(dict()) ``` # 0.20.1 — 2025-02-04 ## Fixed - Fixed a windows bug with the readline module (#189) # 0.20.0 — 2025-02-01 ## Changed - pytest assert rewriting works now together with inline-snapshot if you use `cpython>=3.11` - `...` is now a special value to *create* snapshot values. The value change in `assert [5,4] == snapshot([5,...])` is now a *create* (previously it was a *fix*) ## Fixed - fixed some issues with dataclass arguments - fixed an issue where --inline-snapshot=review discarded the user input and never formatted the code if you used cpython 3.13. # 0.19.3 — 2025-01-15 ## Fixed - raise no assertion for positional arguments inside constructor methods. # 0.19.2 — 2025-01-15 ## Fixed - fixed a crash when you changed the snapshot to use a custom constructor method for dataclass/pydantic models. example: ``` python from inline_snapshot import snapshot from pydantic import BaseModel class A(BaseModel): a: int @classmethod def from_str(cls, s): return cls(a=int(s)) def test_something(): assert A(a=2) == snapshot(A.from_str("1")) ``` # 0.19.1 — 2025-01-12 ## Added - added the optional `inline-snapshot[dirty-equals]` dependency to depend on the dirty-equals version which works in combination with inline-snapshot. ## Fixed - snapshots with pydantic models can now be compared multiple times ``` python class A(BaseModel): a: int def test_something(): for _ in [1, 2]: assert A(a=1) == snapshot(A(a=1)) ``` # 0.19.0 — 2025-01-10 ## Added - You can now specify which tool you want to use to format your code by setting a `format-command` in your [configuration](https://15r10nk.github.io/inline-snapshot/latest/configuration/#format-command). ## Changed - **BREAKING-CHANGE** you have to install `inline-snapshot[black]` now if you want to format your code like in the previous versions. This option is not required if you use a `format-command`. ## Fixed - Load default config values even if `[tool.inline-snapshot]` is missing. This makes the documented default shortcuts `--review` and `--fix` work. # 0.18.2 — 2025-01-02 ## Changed - added `[dependency-groups]` to *pyproject.toml* and use uv and pytest to run tests in CI. ## Fixed - use '.model_fields' on pydantic model class and not instance. This fixes a deprecation warning in the upcoming pydantic v2.11 (#169) # 0.18.1 — 2024-12-22 ## Fixed - uv is now only used during test time if you run the inline-snapshot tests with `pytest --use-uv` This solves a problem if you want to package inline-snapshot in distributions (#165) # 0.18.0 — 2024-12-21 ## Added - Support for a new `storage-dir` configuration option, to tell inline-snapshot where to store data files such as external snapshots. ## Fixed - pydantic v1 is supported again. pydantic v1 & v2 create now the same snapshots. You can use `.dict()` to get the same snapshots like in inline-snapshot-0.15.0 for pydantic v1. ``` python class M(BaseModel): name: str def test_pydantic(): m = M(name="Tom") assert m == snapshot(M(name="Tom")) assert m.dict() == snapshot({"name": "Tom"}) ``` - Find `pyproject.toml` file in parent directories, not just next to the Pytest configuration file. # 0.17.1 — 2024-12-17 ## Fixed - Code generation for sets is now deterministic. ``` python def test(): assert {1j, 2j, 1, 2, 3} == snapshot({1, 1j, 2, 2j, 3}) ``` # 0.17.0 — 2024-12-14 ## Added - [attrs](https://www.attrs.org/en/stable/index.html) can now contain unmanaged values ``` python import datetime as dt import uuid import attrs from dirty_equals import IsDatetime from inline_snapshot import Is, snapshot @attrs.define class Attrs: ts: dt.datetime id: uuid.UUID def test(): id = uuid.uuid4() assert Attrs(dt.datetime.now(), id) == snapshot( Attrs(ts=IsDatetime(), id=Is(id)) ) ``` # 0.16.0 — 2024-12-12 ## Added - [`inline_snapshot.extra.warns`](https://15r10nk.github.io/inline-snapshot/latest/extra/#inline_snapshot.extra.warns) to captures warnings and compares them against expected warnings. ``` python def test_warns(): with warns(snapshot([(8, "UserWarning: some problem")]), include_line=True): warn("some problem") ``` # 0.15.1 — 2024-12-10 ## Fixed - solved a bug caused by a variable inside a snapshot (#148) # 0.15.0 — 2024-12-10 ## Added - snapshots [inside snapshots](https://15r10nk.github.io/inline-snapshot/latest/eq_snapshot/#inner-snapshots) are now supported. ``` python assert get_schema() == snapshot( [ { "name": "var_1", "type": snapshot("int") if version < 2 else snapshot("string"), } ] ) ``` - [runtime values](https://15r10nk.github.io/inline-snapshot/latest/eq_snapshot/#is) can now be part of snapshots. ``` python from inline_snapshot import snapshot, Is current_version = "1.5" assert request() == snapshot( {"data": "page data", "version": Is(current_version)} ) ``` - [f-strings](https://15r10nk.github.io/inline-snapshot/latest/eq_snapshot/#f-strings) can now also be used within snapshots, but are currently not *fixed* by inline-snapshot. ## Changed - *dirty-equals* expressions are now treated like *runtime values* or *snapshots* within snapshots and are not modified by inline-snapshot. ## Fixed - inline-snapshot checks now if the given command line flags (`--inline-snapshot=...`) are valid - `Example(...).run_pytest(raise=snapshot(...))` uses now the flags from the current run and not the flags from the Example. # 0.14.2 — 2024-12-07 ## Fixed - do not crash when handling raw f-strings (`rf""`,`RF""`,...) (#143) # 0.14.1 — 2024-12-04 ## Fixed - Don't crash for snapshots like `snapshot(f"")` (#139) It first appeared with pytest-8.3.4, but already existed before for cpython-3.11. f-strings in snapshots are currently not official supported, but they should not lead to crashes. - skip formatting if black returns an error (#138) # 0.14.0 — 2024-11-10 ## Removed - removed the `"Programming Language :: Python :: Implementation :: PyPy"` classifier which was incorrect, because inline-snapshot can not fix snapshots on pypy. inline-snapshot now enforces `--inline-snapshot=disable` when used with an implementation other than cpython, which allows it to be used in packages that want to support pypy. ## Added - command line shortcuts can be defined to simplify your workflows. `--review` and `--fix` are defined by default. See the [documentation](https://15r10nk.github.io/inline-snapshot/latest/configuration/) for details. ## Changed - `--inline-snapshot=create/fix/trim/update` will no longer show reports for other categories. You can use `--inline-snapshot=create,report` if you want to use the old behaviour. # 0.13.4 — 2024-11-07 ## Changed - use tomli instead of toml (#130) # 0.13.3 — 2024-09-24 ## Fixed - removed non-optional dirty-equals dependency (#118) # 0.13.2 — 2024-09-24 ## Changed - star-expressions in list or dicts where never valid and cause a warning now. ``` other=[2] assert [5,2]==snapshot([5,*other]) ``` ## Fixed - A snapshot which contains an dirty-equals expression can now be compared multiple times. ``` python def test_something(): greeting = "hello" for name in ["alex", "bob"]: assert (name, greeting) == snapshot((IsString(), "hello")) ``` ## v0.13.1 (2024-09-18) ### Fix - Use tomllib instead of PyPI toml on Python 3.11 and later ## v0.13.0 (2024-09-10) ### Feat - added extra.prints - 3.13 support - strings with one line-break at the end become no multiline strings ## v0.12.1 (2024-08-05) ### Fix - add license to project metadata and some other fixes in pyproject.toml (#104) ## v0.12.0 (2024-07-22) ### Feat - implement extra.raises - added inline_snapshot.testing.Example which can be used to test 3rd-party extensions ## v0.11.0 (2024-07-07) ### Feat - check if the result of copy.deepcopy() is equal to the copied value - support for `enum.Enum`, `enum.Flag`, `type` and omitting of default values (#73) ## v0.10.2 (2024-05-28) ### Fix - changed how --inline-snapshot=disable works in combination with xdist (#90) - fix typo, rename 'theme' with 'them' ## v0.10.1 (2024-05-26) ### Fix - trigger no update for trailing comma changes ## v0.10.0 (2024-05-21) ### BREAKING CHANGE - removed support for python 3.7 - removed `--inline-snapshot-disable` option and replaced it with `--inline-snapshot=disable` ### Feat - new flags: *disable*, *short-report*, *report* and *review* - added config option and environment variable to specify default flags - show diff of changed snapshots in pytest report - interactive *review* mode ## v0.9.0 (2024-05-07) ### Feat - check if inline-snapshot is used in combination with xdist and notify the user that this is not possible ### Fix - change the quoting of strings does not trigger an update ## v0.8.2 (2024-04-24) ### Fix - removed restriction that the snapshot functions has to be called snapshot (#72) - report error in tear down for sub-snapshots with missing values (#70) - element access in sub-snapshots does not create new values ## v0.8.1 (2024-04-22) ### Fix - make typing less strict ## v0.8.0 (2024-04-09) ### Feat - prevent dirty-equal values from triggering of updates - fix lists by calculating the alignment of the changed values - insert dict items - delete dict items - preserve not changed dict-values and list-elements ### Fix - update with UndecidedValue - handle dicts with mulitple insertions and deletions - handle lists with mulitple insertions and deletions - fixed typing and coverage ### Refactor - removed old _needs_* logic - removed get_result - use _get_changes api for DictValue - use _get_changes api for CollectionValue - use _get_changes api for MinMaxValue - use _get_changes - moved some functions ## v0.7.0 (2024-02-27) ### Feat - removed old --update-snapshots option ## v0.6.1 (2024-01-28) ### Fix - use utf-8 encoding to read and write source files ## v0.6.0 (2023-12-10) ### Feat - store snapshot values in external files ## v0.5.2 (2023-11-13) ### Fix - remove upper bound from dependency in pyproject.toml ## v0.5.1 (2023-10-20) ### Fix - show better error messages ## v0.5.0 (2023-10-15) ### Feat - support 3.12 ### Fix - do not change empty snapshot if it is not used ## v0.4.0 (2023-09-29) ### Feat - escaped linebreak at the start/end of multiline strings ### Fix - added py.typed ## v0.3.2 (2023-07-31) ### Fix - handle update flag in sub-snapshots correctly - fixed some edge cases where sub-snapshots had problems with some flags - string literal concatenation should trigger no update ## v0.3.1 (2023-07-14) ### Fix - added `__all__` to inline_snapshot - flags fix/trim/create/update are changing the matching snapshots ## v0.3.0 (2023-07-12) ### BREAKING CHANGE - values have to be copyable with `copy.deepcopy` ### Fix - snapshot the current value of mutable objects ``` python l = [1] assert l == snapshot([1]) # old behaviour: snapshot([1, 2]) l.append(2) assert l == snapshot([1, 2]) ``` ## v0.2.1 (2023-07-09) ### Fix - black configuration files are respected ## v0.2.0 (2023-06-20) ### Feat - `value <= snapshot()` to ensure that something gets smaller/larger over time (number of iterations of an algorithm you want to optimize for example), - `value in snapshot()` to check if your value is in a known set of values, - `snapshot()[key]` to generate new sub-snapshots on demand. - convert strings with newlines to triple quoted strings ``` python assert "a\nb\n" == snapshot( """a b """ ) ``` - preserve black formatting ## v0.1.2 (2022-12-11) ### Fix - updated executing ## v0.1.1 (2022-12-08) ### Fix - fixed typo in pytest plugin name ## v0.1.0 (2022-07-25) ### Feat - first inline-snapshot version python-inline-snapshot-0.20.7/CONTRIBUTING.md000066400000000000000000000027251476476140300205050ustar00rootroot00000000000000# Contributing Contributions are welcome. Please create an issue before writing a pull request so we can discuss what needs to be changed. # Testing The code can be tested with [hatch](https://hatch.pypa.io/latest/tutorials/testing/overview/) * `hatch test` can be used to test all supported python versions and to check for coverage. * `hatch test -py 3.10 -- --sw` runs pytest for python 3.10 with the `--sw` argument. The preferred way to test inline-snapshot is by using [`inline-snapshot.texting.Example`](https://15r10nk.github.io/inline-snapshot/latest/testing/). You will see some other fixtures which are used inside the tests, but these are old ways to write the tests and I try to use the new `Example` class to write new tests. # Coverage This project has a hard coverage requirement of 100% (which is checked in CI). You can also check the coverage locally with `hatch test -acp`. The goal here is to find different edge cases which might have bugs. However, it is possible to exclude some code from the coverage. Code can be marked with `pragma: no cover`, if it can not be tested for some reason. This makes it easy to spot uncovered code in the source. Impossible conditions can be handled with `assert False`. ``` python if some_condition: ... if some_other_codition: ... else: assert False, "unreachable because ..." ``` This serves also as an additional check during runtime. # Commits Please use [pre-commit](https://pre-commit.com/) for your commits. python-inline-snapshot-0.20.7/LICENSE000066400000000000000000000020721476476140300172540ustar00rootroot00000000000000 The MIT License (MIT) Copyright (c) 2022 Frank Hoffmann Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. python-inline-snapshot-0.20.7/MANIFEST.in000066400000000000000000000001411476476140300200000ustar00rootroot00000000000000include LICENSE include README.rst recursive-exclude * __pycache__ recursive-exclude * *.py[co] python-inline-snapshot-0.20.7/README.md000066400000000000000000000153101476476140300175250ustar00rootroot00000000000000

inline-snapshot

![ci](https://github.com/15r10nk/inline-snapshot/actions/workflows/ci.yml/badge.svg?branch=main) [![Docs](https://img.shields.io/badge/docs-mkdocs-green)](https://15r10nk.github.io/inline-snapshot/latest/) [![pypi version](https://img.shields.io/pypi/v/inline-snapshot.svg)](https://pypi.org/project/inline-snapshot/) ![Python Versions](https://img.shields.io/pypi/pyversions/inline-snapshot) [![PyPI - Downloads](https://img.shields.io/pypi/dw/inline-snapshot)](https://pypacktrends.com/?packages=inline-snapshot&time_range=2years) [![coverage](https://img.shields.io/badge/coverage-100%25-blue)](https://15r10nk.github.io/inline-snapshot/latest/contributing/#coverage) [![GitHub Sponsors](https://img.shields.io/github/sponsors/15r10nk)](https://github.com/sponsors/15r10nk) ## Installation You can install "inline-snapshot" via [pip](https://pypi.org/project/pip/): ``` bash pip install inline-snapshot ``` ## Key Features - **Intuitive Semantics:** `snapshot(x)` mirrors `x` for easy understanding. - **Versatile Comparison Support:** Equipped with [`x == snapshot(...)`](https://15r10nk.github.io/inline-snapshot/latest/eq_snapshot/), [`x <= snapshot(...)`](https://15r10nk.github.io/inline-snapshot/latest/cmp_snapshot/), [`x in snapshot(...)`](https://15r10nk.github.io/inline-snapshot/latest/in_snapshot/), and [`snapshot(...)[key]`](https://15r10nk.github.io/inline-snapshot/latest/getitem_snapshot/). - **Enhanced Control Flags:** Utilize various [flags](https://15r10nk.github.io/inline-snapshot/latest/pytest/) for precise control of which snapshots you want to change. - **Preserved Black Formatting:** Retains formatting consistency with Black formatting. - **External File Storage:** Store snapshots externally using `outsource(data)`. - **Seamless Pytest Integration:** Integrated seamlessly with pytest for effortless testing. - **Customizable:** code generation can be customized with [@customize_repr](https://15r10nk.github.io/inline-snapshot/latest/customize_repr) - **Nested Snapshot Support:** snapshots can contain [other snapshots](https://15r10nk.github.io/inline-snapshot/latest/eq_snapshot/#inner-snapshots) - **Fuzzy Matching:** Incorporate [dirty-equals](https://15r10nk.github.io/inline-snapshot/latest/eq_snapshot/#dirty-equals) for flexible comparisons within snapshots. - **Dynamic Snapshot Content:** snashots can contain [non-constant values](https://15r10nk.github.io/inline-snapshot/latest/eq_snapshot/#is) - **Comprehensive Documentation:** Access detailed [documentation](https://15r10nk.github.io/inline-snapshot/latest) for complete guidance. ## Usage You can use `snapshot()` instead of the value which you want to compare with. ``` python from inline_snapshot import snapshot def test_something(): assert 1548 * 18489 == snapshot() ``` You can now run the tests and record the correct values. ``` $ pytest --inline-snapshot=review ``` ``` python hl_lines="5" from inline_snapshot import snapshot def test_something(): assert 1548 * 18489 == snapshot(28620972) ``` The following examples show how you can use inline-snapshot in your tests. Take a look at the [documentation](https://15r10nk.github.io/inline-snapshot/latest) if you want to know more. ``` python from inline_snapshot import snapshot, outsource, external def test_something(): for number in range(5): # testing for numeric limits assert number <= snapshot(4) assert number >= snapshot(0) for c in "hello world": # test if something is part of a set assert c in snapshot(["h", "e", "l", "o", " ", "w", "r", "d"]) s = snapshot( { 0: {"square": 0, "pow_of_two": False}, 1: {"square": 1, "pow_of_two": True}, 2: {"square": 4, "pow_of_two": True}, 3: {"square": 9, "pow_of_two": False}, 4: {"square": 16, "pow_of_two": True}, } ) for number in range(5): # create sub-snapshots at runtime assert s[number]["square"] == number**2 assert s[number]["pow_of_two"] == ( (number & (number - 1) == 0) and number != 0 ) assert outsource("large string\n" * 1000) == snapshot( external("8bf10bdf2c30*.txt") ) assert "generates\nmultiline\nstrings" == snapshot( """\ generates multiline strings\ """ ) ``` `snapshot()` can also be used as parameter for functions: ``` python from inline_snapshot import snapshot import subprocess as sp import sys def run_python(cmd, stdout=None, stderr=None): result = sp.run([sys.executable, "-c", cmd], capture_output=True) if stdout is not None: assert result.stdout.decode() == stdout if stderr is not None: assert result.stderr.decode() == stderr def test_cmd(): run_python( "print('hello world')", stdout=snapshot( """\ hello world """ ), stderr=snapshot(""), ) run_python( "1/0", stdout=snapshot(""), stderr=snapshot( """\ Traceback (most recent call last): File "", line 1, in ZeroDivisionError: division by zero """ ), ) ``` ## Feedback inline-snapshot provides some advanced ways to work with snapshots. I would like to know how these features are used to further improve this small library. Let me know if you've found interesting use cases for this library via [twitter](https://twitter.com/15r10nk), [fosstodon](https://fosstodon.org/deck/@15r10nk) or in the github [discussions](https://github.com/15r10nk/inline-snapshot/discussions/new?category=show-and-tell). ## Sponsors I would like to thank my sponsors. Without them, I would not be able to invest so much time in my projects. ### Bronze sponsor 🥉

pydantic logfire

## Issues If you encounter any problems, please [report an issue](https://github.com/15r10nk/inline-snapshot/issues) along with a detailed description. ## License Distributed under the terms of the [MIT](http://opensource.org/licenses/MIT) license, "inline-snapshot" is free and open source software. python-inline-snapshot-0.20.7/changelog.d/000077500000000000000000000000001476476140300204175ustar00rootroot00000000000000python-inline-snapshot-0.20.7/changelog.d/.gitkeep000066400000000000000000000000001476476140300220360ustar00rootroot00000000000000python-inline-snapshot-0.20.7/conftest.py000066400000000000000000000005471476476140300204530ustar00rootroot00000000000000import pytest from tests.utils import snapshot_env from tests.utils import storage # noqa from tests.utils import useStorage @pytest.fixture(autouse=True) def snapshot_env_for_doctest(request, storage): if hasattr(request.node, "dtest"): with snapshot_env(): with useStorage(storage): yield else: yield python-inline-snapshot-0.20.7/docs/000077500000000000000000000000001476476140300171765ustar00rootroot00000000000000python-inline-snapshot-0.20.7/docs/assets/000077500000000000000000000000001476476140300205005ustar00rootroot00000000000000python-inline-snapshot-0.20.7/docs/assets/favicon.svg000066400000000000000000000101231476476140300226430ustar00rootroot00000000000000 image/svg+xml python-inline-snapshot-0.20.7/docs/assets/logo.svg000066400000000000000000001217341476476140300221710ustar00rootroot00000000000000 image/svg+xml python-inline-snapshot-0.20.7/docs/assets/logo_orig.svg000066400000000000000000000172321476476140300232060ustar00rootroot00000000000000 image/svg+xml ( inline-snapshot create and update inline snapshots in your python code. python-inline-snapshot-0.20.7/docs/categories.md000066400000000000000000000135601476476140300216520ustar00rootroot00000000000000 Each snapshot change is assigned to a different category. This is done because inline-snapshot supports more than just `==` checks. There are changes which: * [create](#create) new snapshot values * [fix](#fix) your tests * [update](#update) only the syntax to a new representation * [trim](#trim) unused pieces from your snapshots *Create* and *fix* are mainly used, but it is good to know what type of change you are approving, because it helps with the decision if this changes should be applied. ## Categories ### Create These changes are made when new snapshots are created. The result of each comparison is `True`, which allows to run the whole test to fill all new snapshots with values. Example:
``` python from inline_snapshot import snapshot def test_something(): assert 5 == snapshot() assert 5 <= snapshot() assert 5 in snapshot() s = snapshot() assert 5 == s["key"] ``` ``` python hl_lines="5 7 9 11" from inline_snapshot import snapshot def test_something(): assert 5 == snapshot(5) assert 5 <= snapshot(5) assert 5 in snapshot([5]) s = snapshot({"key": 5}) assert 5 == s["key"] ```
### Fix These changes are made when the snapshots comparison does not return `True` any more (depending on the operation `==`, `<=`, `in`). The result of each comparison is `True` if you change something from this category, which allows to run the whole test and to fix other snapshots.
``` python from inline_snapshot import snapshot def test_something(): assert 8 == snapshot(5) assert 8 <= snapshot(5) assert 8 in snapshot([5]) s = snapshot({"key": 5}) assert 8 == s["key"] ``` ``` python hl_lines="5 7 9 11" from inline_snapshot import snapshot def test_something(): assert 8 == snapshot(8) assert 8 <= snapshot(8) assert 8 in snapshot([5, 8]) s = snapshot({"key": 8}) assert 8 == s["key"] ```
!!! info The main reason for the different categories is to make the number of changes in the **fix** category as small as possible. The changes in the **fix** category are the only changes which change the value of the snapshots and should be reviewed carefully. ### Trim These changes are made when parts of the snapshots are removed which are no longer needed, or if limits can be reduced.
``` python from inline_snapshot import snapshot def test_something(): assert 2 <= snapshot(8) assert 8 in snapshot([5, 8]) s = snapshot({"key1": 1, "key2": 2}) assert 2 == s["key2"] ``` ``` python hl_lines="5 7 9" from inline_snapshot import snapshot def test_something(): assert 2 <= snapshot(2) assert 8 in snapshot([8]) s = snapshot({"key2": 2}) assert 2 == s["key2"] ```
There might be problems in cases where you use the same snapshot in different tests, run only one test and trim the snapshot with `pytest -k test_a --inline-snapshot=trim` in this case:
``` python from inline_snapshot import snapshot s = snapshot(5) def test_a(): assert 2 <= s def test_b(): assert 5 <= s ``` ``` python hl_lines="1" from inline_snapshot import snapshot s = snapshot(2) def test_a(): assert 2 <= s def test_b(): assert 5 <= s ```
The value of the snapshot is reduced to `2`, because `test_a()` was the only test running and inline-snapshot does not know about `5 <= s`. It is recommended to use trim only if you run your complete test suite. ### Update Changes in the update category do not change the value in the code, just the representation. The reason might be that `#!python repr()` of the object has changed or that inline-snapshot provides some new logic which changes the representation. Like with the strings in the following example: === "original" ``` python from inline_snapshot import snapshot class Vector: def __init__(self, x, y): self.x = x self.y = y def __eq__(self, other): if not isinstance(other, Vector): return NotImplemented return self.x == other.x and self.y == other.y def __repr__(self): # return f"Vector(x={self.x}, y={self.y})" return f"Vector({self.x}, {self.y})" def test_something(): assert "a\nb\nc\n" == snapshot("a\nb\nc\n") assert 5 == snapshot(4 + 1) assert Vector(1, 2) == snapshot(Vector(x=1, y=2)) ``` === "--inline-snapshot=update" ``` python hl_lines="20 21 22 23 24 25 26 28 30" from inline_snapshot import snapshot class Vector: def __init__(self, x, y): self.x = x self.y = y def __eq__(self, other): if not isinstance(other, Vector): return NotImplemented return self.x == other.x and self.y == other.y def __repr__(self): # return f"Vector(x={self.x}, y={self.y})" return f"Vector({self.x}, {self.y})" def test_something(): assert "a\nb\nc\n" == snapshot( """\ a b c """ ) assert 5 == snapshot(5) assert Vector(1, 2) == snapshot(Vector(1, 2)) ``` The approval of this type of changes is easier, because inline-snapshot assures that the value has not changed. python-inline-snapshot-0.20.7/docs/changelog.md000066400000000000000000000006361476476140300214540ustar00rootroot00000000000000 ``` python exec="1" from pathlib import Path from subprocess import run new_changes = list(Path.cwd().glob("changelog.d/*.md")) next_version = ( run(["cz", "bump", "--get-next"], capture_output=True) .stdout.decode() .strip() ) if new_changes: print(f"# upcomming version ({next_version})") for file in new_changes: print(file.read_text().replace("###", "##")) ``` --8<-- "CHANGELOG.md" python-inline-snapshot-0.20.7/docs/cmp_snapshot.md000066400000000000000000000066611476476140300222270ustar00rootroot00000000000000## General A snapshot can be compared against any value with `<=` or `>=`. This can be used to create a upper/lower bound for some result. The snapshot value can be trimmed to the lowest/largest valid value. Example: === "original code" ``` python from inline_snapshot import snapshot def gcd(x, y): iterations = 0 if x > y: small = y else: small = x for i in range(1, small + 1): iterations += 1 if (x % i == 0) and (y % i == 0): gcd = i return gcd, iterations def test_gcd(): result, iterations = gcd(12, 18) assert result == snapshot() assert iterations <= snapshot() ``` === "--inline-snapshot=create" ``` python hl_lines="21 22" from inline_snapshot import snapshot def gcd(x, y): iterations = 0 if x > y: small = y else: small = x for i in range(1, small + 1): iterations += 1 if (x % i == 0) and (y % i == 0): gcd = i return gcd, iterations def test_gcd(): result, iterations = gcd(12, 18) assert result == snapshot(6) assert iterations <= snapshot(12) ``` === "optimized code " ``` python hl_lines="5 7 9 10" from inline_snapshot import snapshot def gcd(x, y): # use Euclidean Algorithm iterations = 0 while y: iterations += 1 x, y = y, x % y return abs(x), iterations def test_gcd(): result, iterations = gcd(12, 18) assert result == snapshot(6) assert iterations <= snapshot(12) ``` === "--inline-snapshot=trim" ``` python hl_lines="17" from inline_snapshot import snapshot def gcd(x, y): # use Euclidean Algorithm iterations = 0 while y: iterations += 1 x, y = y, x % y return abs(x), iterations def test_gcd(): result, iterations = gcd(12, 18) assert result == snapshot(6) assert iterations <= snapshot(3) ``` !!! warning This should not be used to check for any flaky values like the runtime of some code, because it will randomly break your tests. The same snapshot value can also be used in multiple assertions. === "original code" ``` python from inline_snapshot import snapshot def test_something(): value = snapshot() assert 5 <= value assert 6 <= value ``` === "--inline-snapshot=create" ``` python hl_lines="5" from inline_snapshot import snapshot def test_something(): value = snapshot(6) assert 5 <= value assert 6 <= value ``` ## 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 contradicts the comparison. - `trim` record the new value and store it in the source code if it is more strict than the old one. python-inline-snapshot-0.20.7/docs/code_generation.md000066400000000000000000000103271476476140300226500ustar00rootroot00000000000000 You can use almost any python datatype and also complex values like `datatime.date`, because `repr()` is used to convert the values to source code. The default `__repr__()` behaviour can be [customized](customize_repr.md). It might be necessary to import the right modules to match the `repr()` output. === "original code" ``` python from inline_snapshot import snapshot import datetime def something(): return { "name": "hello", "one number": 5, "numbers": list(range(10)), "sets": {1, 2, 15}, "datetime": datetime.date(1, 2, 22), "complex stuff": 5j + 3, "bytes": b"byte abc\n\x16", } def test_something(): assert something() == snapshot() ``` === "--inline-snapshot=create" ``` python hl_lines="18 19 20 21 22 23 24 25 26 27 28" from inline_snapshot import snapshot import datetime def something(): return { "name": "hello", "one number": 5, "numbers": list(range(10)), "sets": {1, 2, 15}, "datetime": datetime.date(1, 2, 22), "complex stuff": 5j + 3, "bytes": b"byte abc\n\x16", } def test_something(): assert something() == snapshot( { "name": "hello", "one number": 5, "numbers": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], "sets": {1, 2, 15}, "datetime": datetime.date(1, 2, 22), "complex stuff": (3 + 5j), "bytes": b"byte abc\n\x16", } ) ``` The code is generated in the following way: 1. The value is copied with `value = copy.deepcopy(value)` and it is checked if the copied value is equal to the original value. 2. The code is generated with: * `repr(value)` (which can be [customized](customize_repr.md)) * or a special internal implementation for container types to support [unmanaged snapshot values](eq_snapshot.md#unmanaged-snapshot-values). This can currently not be customized. 3. Strings which contain newlines are converted to triple quoted strings. !!! note Missing newlines at start or end are escaped (since 0.4.0). === "original code" ``` python from inline_snapshot import snapshot def test_something(): assert "first line\nsecond line" == snapshot( """first line second line""" ) ``` === "--inline-snapshot=update" ``` python hl_lines="6 7 8 9" from inline_snapshot import snapshot def test_something(): assert "first line\nsecond line" == snapshot( """\ first line second line\ """ ) ``` 4. The new code fragments are formatted with black if it is installed. !!! note Black is an optional dependency since inline-snapshot v0.19.0. You can install it with: ``` sh pip install inline-snapshot[black] ``` 5. The whole file is formatted * with black if it was formatted with black before. !!! note The black formatting of the whole file could not work for the following reasons: 1. black is configured with cli arguments and not in a configuration file.
**Solution:** configure black in a [configuration file](https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#configuration-via-a-file) 2. inline-snapshot uses a different black version.
**Solution:** specify which black version inline-snapshot should use by adding black with a specific version to your dependencies. 3. black is not installed. Black is an optional dependency since inline-snapshot v0.19.0 * or with the [format-command][format-command] if you defined one. python-inline-snapshot-0.20.7/docs/configuration.md000066400000000000000000000035251476476140300223740ustar00rootroot00000000000000Default configuration: ``` toml [tool.inline-snapshot] hash-length=15 default-flags=["short-report"] format-command="" [tool.inline-snapshot.shortcuts] review=["review"] fix=["create","fix"] ``` * **hash-length:** specifies the length of the hash used by `external()` in the code representation. This does not affect the hash length used to store the data. The hash should be long enough to avoid hash collisions. * **default-flags:** defines which flags should be used if there are no flags specified with `--inline-snapshot=...`. You can also use the environment variable `INLINE_SNAPSHOT_DEFAULT_FLAGS=...` to specify the flags and to override those in the configuration file. * **shortcuts:** allows you to define custom commands to simplify your workflows. `--fix` and `--review` are defined by default, but this configuration can be changed to fit your needs. * **storage-dir:** allows you to define the directory where inline-snapshot stores data files such as external snapshots. By default, it will be `/.inline-snapshot`, where `` is replaced by the directory containing the Pytest configuration file, if any. External snapshots will be stored in the `external` subfolder of the storage directory. * **format-command:[](){#format-command}** allows you to specify a custom command which is used to format the python code after code is changed. ``` toml [tool.inline-snapshot] format-command="ruff format --stdin-filename {filename}" ``` The placeholder `{filename}` can be used to specify the filename if it is needed to find the correct formatting options for this file. !!! important The command should **not** format the file on disk. The current file content (with the new code changes) is passed to *stdin* and the formatted content should be written to *stdout*. python-inline-snapshot-0.20.7/docs/contributing.md000066400000000000000000000000311476476140300222210ustar00rootroot00000000000000--8<-- "CONTRIBUTING.md" python-inline-snapshot-0.20.7/docs/customize_repr.md000066400000000000000000000064651476476140300226050ustar00rootroot00000000000000 `repr()` can be used to convert a python object into a source code representation of the object, but this does not work for every type. Here are some examples: ```pycon >>> repr(int) "" >>> from enum import Enum >>> E = Enum("E", ["a", "b"]) >>> repr(E.a) '' ``` `customize_repr` can be used to overwrite the default `repr()` behaviour. The implementation for `Enum` looks like this: ``` python exec="1" result="python" print('--8<-- "src/inline_snapshot/_code_repr.py:Enum"') ``` This implementation is then used by inline-snapshot if `repr()` is called during the code generation, but not in normal code. ``` python from inline_snapshot import snapshot from enum import Enum def test_enum(): E = Enum("E", ["a", "b"]) # normal repr assert repr(E.a) == "" # the special implementation to convert the Enum into a code assert E.a == snapshot(E.a) ``` ## builtin datatypes inline-snapshot comes with a special implementation for the following types: ``` python exec="1" from inline_snapshot._code_repr import code_repr_dispatch, code_repr for name, obj in sorted( ( getattr( obj, "_inline_snapshot_name", f"{obj.__module__}.{obj.__qualname__}" ), obj, ) for obj in code_repr_dispatch.registry.keys() ): if obj is not object: print(f"- `{name}`") ``` !!! note Container types like `dict`, `list`, `tuple` or `dataclass` are handled in a different way, because inline-snapshot also needs to inspect these types to implement [unmanaged](/eq_snapshot.md#unmanaged-snapshot-values) snapshot values. ## customize recursive repr You can also use `repr()` inside `__repr__()`, if you want to make your own type compatible with inline-snapshot. ``` python from inline_snapshot import snapshot from enum import Enum class Pair: def __init__(self, a, b): self.a = a self.b = b def __repr__(self): # this would not work # return f"Pair({self.a!r}, {self.b!r})" # you have to use repr() return f"Pair({repr(self.a)}, {repr(self.b)})" def __eq__(self, other): if not isinstance(other, Pair): return NotImplemented return self.a == other.a and self.b == other.b def test_enum(): E = Enum("E", ["a", "b"]) # the special repr implementation is used recursive here # to convert every Enum to the correct representation assert Pair(E.a, [E.b]) == snapshot(Pair(E.a, [E.b])) ``` !!! note using `#!python f"{obj!r}"` or `#!c PyObject_Repr()` will not work, because inline-snapshot replaces `#!python builtins.repr` during the code generation. The only way to use the custom repr implementation is to use the `repr()` function. !!! note This implementation allows inline-snapshot to use the custom `repr()` recursively, but it does not allow you to use [unmanaged](/eq_snapshot.md#unmanaged-snapshot-values) snapshot values like `#!python Pair(Is(some_var),5)` you can also customize the representation of datatypes in other libraries: ``` python from inline_snapshot import customize_repr from other_lib import SomeType @customize_repr def _(value: SomeType): return f"SomeType(x={repr(value.x)})" ``` python-inline-snapshot-0.20.7/docs/eq_snapshot.md000066400000000000000000000276621476476140300220610ustar00rootroot00000000000000## General 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: === "original code" ``` python from inline_snapshot import snapshot def test_something(): assert 2 + 4 == snapshot() ``` === "--inline-snapshot=create" ``` python hl_lines="5" from inline_snapshot import snapshot def test_something(): assert 2 + 4 == snapshot(6) ``` === "value changed" ``` python hl_lines="5" from inline_snapshot import snapshot def test_something(): assert 2 + 40 == snapshot(4) ``` === "--inline-snapshot=fix" ``` python hl_lines="5" from inline_snapshot import snapshot def test_something(): assert 2 + 40 == snapshot(42) ``` ## unmanaged snapshot values inline-snapshots manages everything inside `snapshot(...)`, which means that the developer should not change these parts, but there are cases where it is useful to give the developer the control over the snapshot content back. Therefor some types will be ignored by inline-snapshot and will **not be updated or fixed**, even if they cause tests to fail. These types are: * [dirty-equals](#dirty-equals) expressions, * [dynamic code](#is) inside `Is(...)`, * [snapshots](#inner-snapshots) inside snapshots and * [f-strings](#f-strings). inline-snapshot is able to handle these types within the following containers: * list * tuple * dict * [namedtuple](https://docs.python.org/3/library/collections.html#collections.namedtuple) * [dataclass](https://docs.python.org/3/library/dataclasses.html) * [attrs](https://www.attrs.org/en/stable/index.html) Other types are converted with a [customizable](customize_repr.md) `repr()` into code. It is not possible to use unmanaged snapshot values within these objects. ### dirty-equals !!! note Use the optional *dirty-equals* dependency to install the version that works best in combination with inline-snapshot. ``` sh pip install inline-snapshot[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 hl_lines="13 14 15" 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"} ) ``` The date can be replaced with the [dirty-equals](https://dirty-equals.helpmanual.io/latest/) expression `IsDatetime()`. 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 hl_lines="9" 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 hl_lines="17" 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", } ) ``` `snapshot()` can also be used inside dirty-equals expressions. One useful example is `IsJson()`: === "using IsJson()" ``` python from dirty_equals import IsJson from inline_snapshot import snapshot def test_foo(): assert {"json_data": '{"value": 1}'} == snapshot( {"json_data": IsJson(snapshot())} ) ``` === "--inline-snapshot=create" ``` python hl_lines="7" from dirty_equals import IsJson from inline_snapshot import snapshot def test_foo(): assert {"json_data": '{"value": 1}'} == snapshot( {"json_data": IsJson(snapshot({"value": 1}))} ) ``` The general rule is that functions to which you pass a *snapshot* as an argument can only use `==` (or other snapshot operations) on this argument. !!! important You cannot use a *snapshot* for every dirty equals argument, but only for those that also support dirty equals expressions. ### Is(...) `Is()` can be used to put runtime values inside snapshots. It tells inline-snapshot that the developer wants control over some part of the snapshot. ``` python from inline_snapshot import snapshot, Is current_version = "1.5" def request(): return {"data": "page data", "version": current_version} def test_function(): assert request() == snapshot( {"data": "page data", "version": Is(current_version)} ) ``` The snapshot does not need to be fixed when `current_version` changes in the future, but `"page data"` will still be fixed if it changes. `Is()` can also be used when the snapshot is evaluated multiple times, which is useful in loops or parametrized tests. === "original code" ``` python from inline_snapshot import snapshot, Is def test_function(): for c in "abc": assert [c, "correct"] == snapshot([Is(c), "wrong"]) ``` === "--inline-snapshot=fix" ``` python hl_lines="6" from inline_snapshot import snapshot, Is def test_function(): for c in "abc": assert [c, "correct"] == snapshot([Is(c), "correct"]) ``` ### inner snapshots Snapshots can be used inside other snapshots in different use cases. #### conditional snapshots It is also possible to use snapshots inside snapshots. This is useful to describe version specific parts of snapshots by replacing the specific part with `#!python snapshot() if some_condition else snapshot()`. The test has to be executed in each specific condition to fill the snapshots. The following example shows how this can be used to run a tests with two different library versions: === "my_lib.py v1" ``` python version = 1 def get_schema(): return [{"name": "var_1", "type": "int"}] ``` === "my_lib.py v2" ``` python version = 2 def get_schema(): return [{"name": "var_1", "type": "string"}] ``` ``` python from inline_snapshot import snapshot from my_lib import version, get_schema def test_function(): assert get_schema() == snapshot( [ { "name": "var_1", "type": snapshot("int") if version < 2 else snapshot("string"), } ] ) ``` The advantage of this approach is that the test uses always the correct values for each library version. You can also extract the version logic into its own function. ``` python from inline_snapshot import snapshot, Snapshot from my_lib import version, get_schema def version_snapshot(v1: Snapshot, v2: Snapshot): return v1 if version < 2 else v2 def test_function(): assert get_schema() == snapshot( [ { "name": "var_1", "type": version_snapshot( v1=snapshot("int"), v2=snapshot("string") ), } ] ) ``` #### common snapshot parts Another use case is the extraction of common snapshot parts into an extra snapshot: ``` python from inline_snapshot import snapshot def some_data(name): return {"header": "really long header\n" * 5, "your name": name} def test_function(): header = snapshot( """\ really long header really long header really long header really long header really long header """ ) assert some_data("Tom") == snapshot( { "header": header, "your name": "Tom", } ) assert some_data("Bob") == snapshot( { "header": header, "your name": "Bob", } ) ``` This simplifies test data and allows inline-snapshot to update your values if required. It makes also sure that the header is the same in both cases. ### f-strings *f-strings* are not generated by inline-snapshot, but they can be used in snapshots if you want to replace some dynamic part of a string value. ``` python from inline_snapshot import snapshot def get_error(): # example code which generates an error message return __file__ + ": error at line 5" def test_get_error(): assert get_error() == snapshot(f"{__file__}: error at line 5") ``` It is not required to wrap the changed value in `Is(f"...")`, because inline-snapshot knows that *f-strings* are only generated by the developer. !!! Warning "Limitation" inline-snapshot is currently not able to fix the string constants within *f-strings*. `#!python f"...{var}..."` works **currently** like `#!python Is(f"...{var}...")` and issues a warning if the value changes, giving you the opportunity to fix your f-string. `#!python f"...{var}..."` will in the **future** work like `#!python f"...{Is(var)}"`. inline-snapshot will then be able to *fix* the string parts within the f-string. ## pytest options It interacts with the following `--inline-snapshot` flags: - `create` create a new value if the snapshot value is undefined. - `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. python-inline-snapshot-0.20.7/docs/extra.md000066400000000000000000000001661476476140300206460ustar00rootroot00000000000000 ::: inline_snapshot.extra options: heading_level: 1 show_root_heading: true show_source: true python-inline-snapshot-0.20.7/docs/getitem_snapshot.md000066400000000000000000000021401476476140300230720ustar00rootroot00000000000000## General It is possible to generate sub-snapshots during runtime. This sub-snapshots can be used like a normal snapshot. Example: === "original code" ``` python from inline_snapshot import snapshot def test_something(): s = snapshot() assert s["a"] == 4 assert s["b"] == 5 ``` === "--inline-snapshot=create" ``` python hl_lines="5" from inline_snapshot import snapshot def test_something(): s = snapshot({"a": 4, "b": 5}) assert s["a"] == 4 assert s["b"] == 5 ``` `s[key]` can be used with every normal snapshot operation including `s[key1][key2]`. ## pytest options It interacts with the following `--inline-snapshot` flags: - `create` create a new value if the snapshot value is undefined or create a new sub-snapshot if one is missing. - `trim` remove sub-snapshots if they are not needed any more. The flags `fix` and `update` are applied recursive to all sub-snapshots. python-inline-snapshot-0.20.7/docs/in_snapshot.md000066400000000000000000000021441476476140300220460ustar00rootroot00000000000000## General It is possible to check if an value is in a snapshot. The value of the generated snapshot will be a list of all values which are tested. Example: === "original code" ``` python from inline_snapshot import snapshot def test_something(): s = snapshot() assert 5 in s assert 5 in s assert 8 in s for v in ["a", "b"]: assert v in s ``` === "--inline-snapshot=create" ``` python hl_lines="5" from inline_snapshot import snapshot def test_something(): s = snapshot([5, 8, "a", "b"]) assert 5 in s assert 5 in s assert 8 in s for v in ["a", "b"]: assert v in s ``` ## pytest options It interacts with the following `--inline-snapshot` flags: - `create` create a new value if the snapshot value is undefined. - `fix` adds a value to the list if it is missing. - `trim` removes a value from the list if it is not necessary. python-inline-snapshot-0.20.7/docs/index.md000066400000000000000000000134231476476140300206320ustar00rootroot00000000000000 --8<-- "README.md:Header" # Welcome to inline-snapshot inline-snapshot can be used for different things: * golden master/approval/snapshot testing. The idea is that you have a function with a currently unknown result and you want to write a tests, which ensures that the result does not change during refactoring. * Compare things which are complex like lists with lot of numbers or complex data structures. * Things which might change during the development like error messages. `inline-snapshot` automates the process of recording, storing and updating the value you want to compare with. The value is converted with `repr()` and stored in the source file as argument of the `snapshot()` function. ## Usage You can use `snapshot()` instead of the value which you want to compare with and run the tests to record the correct values. === "original code" ``` python from inline_snapshot import snapshot def something(): return 1548 * 18489 def test_something(): assert something() == snapshot() ``` === "--inline-snapshot=create" ``` python hl_lines="9" from inline_snapshot import snapshot def something(): return 1548 * 18489 def test_something(): assert something() == snapshot(28620972) ``` Your tests will break, if you change your code by adding `// 18`. Maybe that is correct and you should fix your code, or your code is correct and you want to update your test results. === "changed code" ``` python hl_lines="5" from inline_snapshot import snapshot def something(): return (1548 * 18489) // 18 def test_something(): assert something() == snapshot(28620972) ``` === "--inline-snapshot=fix" ``` python hl_lines="9" from inline_snapshot import snapshot def something(): return (1548 * 18489) // 18 def test_something(): assert something() == snapshot(1590054) ``` Please verify the new results. `git diff` will give you a good overview over all changed results. Use `pytest -k test_something --inline-snapshot=fix` if you only want to change one test. ## Supported operations You can use `snapshot(x)` like you can use `x` in your assertion with a limited set of operations: - [`value == snapshot()`](eq_snapshot.md) to compare with something, - [`value <= snapshot()`](cmp_snapshot.md) to ensure that something gets smaller/larger over time (number of iterations of an algorithm you want to optimize for example), - [`value in snapshot()`](in_snapshot.md) to check if your value is in a known set of values, - [`snapshot()[key]`](getitem_snapshot.md) to generate new sub-snapshots on demand. !!! warning One snapshot can only be used with one operation. The following code will not work: ``` python from inline_snapshot import snapshot def test_something(): s = snapshot(5) assert 5 <= s assert 5 == s # Error: # > assert 5 == s # E TypeError: This snapshot cannot be use with `==`, because it was previously used with `x <= snapshot` ``` ## Supported usage It is possible to place `snapshot()` anywhere in the tests and reuse it multiple times. === "original code" ``` python from inline_snapshot import snapshot def something(): return 21 * 2 result = snapshot() def test_something(): ... assert something() == result def test_something_again(): ... assert something() == result ``` === "--inline-snapshot=create" ``` python hl_lines="8" from inline_snapshot import snapshot def something(): return 21 * 2 result = snapshot(42) def test_something(): ... assert something() == result def test_something_again(): ... assert something() == result ``` `snapshot()` can also be used in loops: === "original code" ``` python from inline_snapshot import snapshot def test_loop(): for name in ["Mia", "Eva", "Leo"]: assert len(name) == snapshot() ``` === "--inline-snapshot=create" ``` python hl_lines="6" from inline_snapshot import snapshot def test_loop(): for name in ["Mia", "Eva", "Leo"]: assert len(name) == snapshot(3) ``` or passed as an argument to a function: === "original code" ``` python from inline_snapshot import snapshot def check_string_len(string, snapshot_value): assert len(string) == snapshot_value def test_string_len(): check_string_len("abc", snapshot()) check_string_len("1234", snapshot()) check_string_len(".......", snapshot()) ``` === "--inline-snapshot=create" ``` python hl_lines="9 10 11" from inline_snapshot import snapshot def check_string_len(string, snapshot_value): assert len(string) == snapshot_value def test_string_len(): check_string_len("abc", snapshot(3)) check_string_len("1234", snapshot(4)) check_string_len(".......", snapshot(7)) ``` --8<-- "README.md:Feedback" python-inline-snapshot-0.20.7/docs/limitations.md000066400000000000000000000007061476476140300220570ustar00rootroot00000000000000 ## pytest assert rewriting is disabled inline-snapshot must disable pytest assert-rewriting if you use report/review/create/fix/trim/update flags. ## xdist is not supported You can not use inline-snapshot in combination with `pytest-xdist`. The use of `-n=...` implies `--inline-snapshot=disable`. ## works only with cpython inline-snapshot works currently only with cpython. `--inline-snapshot=disable` is enforced for every other implementation. python-inline-snapshot-0.20.7/docs/outsource.md000066400000000000000000000054661476476140300215630ustar00rootroot00000000000000## General !!! info This feature is currently under development. See this [issue](https://github.com/15r10nk/inline-snapshot/issues/86) for more information. Storing snapshots in the source code is the main feature of inline snapshots. This has the advantage that you can easily see changes in code reviews. But it also has some problems: * It is problematic to snapshot a lot of data, because it takes up a lot of space in your tests. * Binary data or images are not readable in your tests. The `outsource()` function solves this problem and integrates itself nicely with the inline snapshot. It stores the data in a special `external()` object that can be compared in snapshots. The object is represented by the hash of the data. The actual data is stored in a separate file in your project. This allows the test to be renamed and moved around in your code without losing the connection to the stored data. Example: === "original code" ``` python from inline_snapshot import snapshot, outsource def test_something(): assert outsource("long text\n" * 1000) == snapshot() ``` === "--inline-snapshot=create" ``` python hl_lines="3 4 7 8 9" from inline_snapshot import snapshot, outsource from inline_snapshot import external def test_something(): assert outsource("long text\n" * 1000) == snapshot( external("f5a956460453*.txt") ) ``` The `external` object can be used inside other data structures. === "original code" ``` python from inline_snapshot import snapshot, outsource def test_something(): assert [ outsource("long text\n" * times) for times in [50, 100, 1000] ] == snapshot() ``` === "--inline-snapshot=create" ``` python hl_lines="3 4 9 10 11 12 13 14 15" from inline_snapshot import snapshot, outsource from inline_snapshot import external def test_something(): assert [ outsource("long text\n" * times) for times in [50, 100, 1000] ] == snapshot( [ external("362ad8374ed6*.txt"), external("5755afea3f8d*.txt"), external("f5a956460453*.txt"), ] ) ``` ## API ::: inline_snapshot.outsource options: show_root_heading: true ::: inline_snapshot.external options: show_root_heading: true ## pytest options It interacts with the following `--inline-snapshot` flags: - `trim` removes every snapshots form the storage which is not referenced with `external(...)` in the code. python-inline-snapshot-0.20.7/docs/plugins/000077500000000000000000000000001476476140300206575ustar00rootroot00000000000000python-inline-snapshot-0.20.7/docs/plugins/pyproject.toml000066400000000000000000000014251476476140300235750ustar00rootroot00000000000000[build-system] build-backend = "hatchling.build" requires = ["hatchling"] [project] authors = [ {name = "Frank Hoffmann", email = "15r10nk@polarbit.de"} ] classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy" ] dependencies = [ "mkdocs" ] license = "MIT" name = "replace-url" requires-python = ">=3.8" version = "0.3.0" [project.entry-points."mkdocs.plugins"] replace-url = "replace_url:ReplaceUrlPlugin" python-inline-snapshot-0.20.7/docs/plugins/replace_url.py000066400000000000000000000002621476476140300235260ustar00rootroot00000000000000import mkdocs class ReplaceUrlPlugin(mkdocs.plugins.BasePlugin): def on_page_content(self, html, page, config, files): return html.replace("docs/assets", "assets") python-inline-snapshot-0.20.7/docs/pytest.md000066400000000000000000000065731476476140300210630ustar00rootroot00000000000000 inline-snapshot provides one pytest option with different flags (*create*, *fix*, *trim*, *update*, *short-report*, *report*, *disable*). Snapshot comparisons return always `True` if you use one of the flags *create*, *fix* or *review*. This is necessary because the whole test needs to be run to fix all snapshots like in this case: ``` python from inline_snapshot import snapshot def test_something(): assert 1 == snapshot(5) assert 2 <= snapshot(5) ``` !!! note Every flag with the exception of *disable* and *short-report* disables the pytest assert-rewriting. ## --inline-snapshot=create,fix,trim,update Approve the changes of the given [category](categories.md). These flags can be combined with *report* and *review*. ``` python title="test_something.py" from inline_snapshot import snapshot def test_something(): assert 1 == snapshot() assert 2 <= snapshot(5) ``` ```bash exec="1" title="something" result="ansi" cd $(mktemp -d) export FORCE_COLOR=256 export COLUMNS=80 function run(){ echo -en "\x1b[1;34m> " echo $@ echo -en "\x1b[0m" $@ echo } black -q - > test_something.py << EOF from inline_snapshot import snapshot def test_something(): assert 1 == snapshot() assert 2 <= snapshot(5) EOF run pytest test_something.py --inline-snapshot=create,report ``` ## --inline-snapshot=short-report give a short report over which changes can be made to the snapshots ```bash exec="1" title="something" result="ansi" cd $(mktemp -d) export FORCE_COLOR=256 export COLUMNS=80 function run(){ echo -en "\x1b[1;34m> " echo $@ echo -en "\x1b[0m" python -m $@ echo } black -q - > test_something.py << EOF from inline_snapshot import snapshot def test_something(): assert 1 == snapshot() assert 2 <= snapshot(5) EOF run pytest test_something.py --inline-snapshot=short-report ``` !!! info short-report exists mainly to show that snapshots have changed with enabled pytest assert-rewriting. This option will be replaced with *report* when this restriction is lifted. ## --inline-snapshot=report Shows a diff report over which changes can be made to the snapshots ```bash exec="1" title="something" result="ansi" cd $(mktemp -d) export FORCE_COLOR=256 export COLUMNS=80 function run(){ echo -en "\x1b[1;34m> " echo $@ echo -en "\x1b[0m" $@ echo } black -q - > test_something.py << EOF from inline_snapshot import snapshot def test_something(): assert 1 == snapshot() assert 2 <= snapshot(5) EOF run pytest test_something.py --inline-snapshot=report ``` ## --inline-snapshot=review Shows a diff report for each category and ask if you want to apply the changes ```bash exec="1" title="something" result="ansi" cd $(mktemp -d) export FORCE_COLOR=256 export COLUMNS=80 function run(){ echo -en "\x1b[1;34m> " echo $@ echo -en "\x1b[0m" $@ echo } black -q - > test_something.py << EOF from inline_snapshot import snapshot def test_something(): assert 1 == snapshot() assert 2 <= snapshot(5) EOF yes | run pytest test_something.py --inline-snapshot=review ``` ## --inline-snapshot=disable Disables all the snapshot logic. `snapshot(x)` will just return `x`. This can be used if you think exclude that snapshot logic causes a problem in your tests, or if you want to speedup your CI. !!! info "deprecation" This option was previously called `--inline-snapshot-disable` python-inline-snapshot-0.20.7/docs/testing.md000066400000000000000000000052061476476140300212000ustar00rootroot00000000000000 `inline_snapshot.testing` provides tools which can be used to test inline-snapshot workflows. This might be useful if you want to build your own libraries based on inline-snapshot. The following example shows how you can use the `Example` class to test what inline-snapshot would do with given the source code. The snapshots in the argument are asserted inside the `run_*` methods, but only when they are provided. === "original" ``` python from inline_snapshot.testing import Example from inline_snapshot import snapshot def test_something(): Example( { "test_a.py": """\ from inline_snapshot import snapshot def test_a(): assert 1+1 == snapshot() """ } ).run_inline( # run without flags reported_categories=snapshot(), ).run_pytest( # run without flags and check the pytest report changed_files=snapshot(), report=snapshot(), ).run_pytest( # run with create flag and check the changed files ["--inline-snapshot=create"], changed_files=snapshot(), ) ``` === "--inline-snapshot=create" ``` python hl_lines="16 18 19 20 21 22 23 24 27 28 29 30 31 32 33 34 35" from inline_snapshot.testing import Example from inline_snapshot import snapshot def test_something(): Example( { "test_a.py": """\ from inline_snapshot import snapshot def test_a(): assert 1+1 == snapshot() """ } ).run_inline( # run without flags reported_categories=snapshot(["create"]), ).run_pytest( # run without flags and check the pytest report changed_files=snapshot({}), report=snapshot( """\ Error: one snapshot is missing a value (--inline-snapshot=create) You can also use --inline-snapshot=review to approve the changes interactively\ """ ), ).run_pytest( # run with create flag and check the changed files ["--inline-snapshot=create"], changed_files=snapshot( { "test_a.py": """\ from inline_snapshot import snapshot def test_a(): assert 1+1 == snapshot(2) """ } ), ) ``` ## API ::: inline_snapshot.testing.Example options: heading_level: 3 show_root_heading: true show_root_full_path: false show_source: false annotations_path: brief python-inline-snapshot-0.20.7/docs/theme/000077500000000000000000000000001476476140300203005ustar00rootroot00000000000000python-inline-snapshot-0.20.7/docs/theme/main.html000066400000000000000000000032001476476140300221050ustar00rootroot00000000000000{% extends "base.html" %} {% block outdated %} You're not viewing the latest version. Click here to go to latest. {% endblock %} {% block announce %} {{ super() }} Become a {% include ".icons/octicons/heart-fill-16.svg" %} Sponsor or follow @15r10nk on or {% include ".icons/fontawesome/brands/mastodon.svg" %} for updates {% endblock %} python-inline-snapshot-0.20.7/docs/third_party.md000066400000000000000000000025441476476140300220560ustar00rootroot00000000000000 Third-party extensions can be used to enhance the testing experience with other frameworks. The goal of inline-snapshot is to provide the core functionality for many different use cases. List of current third-party extensions: * [inline-snapshot-pandas](https://pypi.org/project/inline-snapshot-pandas/) pandas integration for inline-snapshot (insider only) !!! info "How to add your extension to this list?" Your package name has to start with `inline-snapshot-` and has to be available on [PyPI](https://pypi.org). The summary of your package will be used as description. I will update this list from time to time but you can accelerate this process by creating a new [issue](https://github.com/15r10nk/inline-snapshot/issues). python-inline-snapshot-0.20.7/docs/types.md000066400000000000000000000002231476476140300206610ustar00rootroot00000000000000 ::: inline_snapshot options: heading_level: 1 members: [Snapshot,Category] show_root_heading: true show_bases: false python-inline-snapshot-0.20.7/mkdocs.yml000066400000000000000000000044371476476140300202610ustar00rootroot00000000000000site_name: inline-snapshot site_url: https://15r10nk.github.io/inline-snapshot/ repo_url: https://github.com/15r10nk/inline-snapshot/ edit_uri: edit/main/docs theme: name: material custom_dir: docs/theme logo: assets/favicon.svg favicon: assets/favicon.svg features: - toc.follow - content.code.annotate - navigation.tabs palette: - media: (prefers-color-scheme) # Palette toggle for light mode - scheme: default media: '(prefers-color-scheme: light)' primary: teal # Palette toggle for dark mode - scheme: slate media: '(prefers-color-scheme: dark)' primary: teal validation: links: absolute_links: relative_to_docs watch: - CONTRIBUTING.md - CHANGELOG.md - README.md - src/inline_snapshot nav: - Home: - Introduction: index.md - Configuration: configuration.md - pytest integration: pytest.md - Categories: categories.md - Code generation: code_generation.md - Limitations: limitations.md - Changelog: changelog.md - Core: - x == snapshot(): eq_snapshot.md - x <= snapshot(): cmp_snapshot.md - x in snapshot(): in_snapshot.md - snapshot()[key]: getitem_snapshot.md - outsource(data): outsource.md - '@customize_repr': customize_repr.md - types: types.md - Extensions: - first-party (extra): extra.md - third-party: third_party.md - Development: - Testing: testing.md - Contributing: contributing.md markdown_extensions: - toc: permalink: true - admonition - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite - pymdownx.snippets: check_paths: true - pymdownx.superfences - admonition - pymdownx.details - pymdownx.superfences - pymdownx.tabbed: alternate_style: true - attr_list - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg plugins: - mkdocstrings: handlers: python: options: show_symbol_type_heading: true show_symbol_type_toc: true - social - search - markdown-exec: ansi: required - replace-url - autorefs extra: social: - icon: fontawesome/brands/x-twitter link: https://x.com/15r10nk - icon: fontawesome/brands/mastodon link: https://fosstodon.org/@15r10nk version: provider: mike default: - latest - development python-inline-snapshot-0.20.7/pyproject.toml000066400000000000000000000130371476476140300211660ustar00rootroot00000000000000[build-system] build-backend = "hatchling.build" requires = ["hatchling"] [project] authors = [ {name = "Frank Hoffmann", email = "15r10nk-git@polarbit.de"} ] classifiers = [ "Development Status :: 4 - Beta", "Framework :: Pytest", "Intended Audience :: Developers", "Operating System :: OS Independent", "Topic :: Software Development :: Testing", "Programming Language :: Python", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "License :: OSI Approved :: MIT License", ] dependencies = [ "asttokens>=2.0.5", "executing>=2.2.0", "rich>=13.7.1", "tomli>=2.0.0; python_version < '3.11'" ] description = "golden master/snapshot/approval testing library which puts the values right into your source code" keywords = [] name = "inline-snapshot" readme = "README.md" requires-python = ">=3.8" version = "0.20.7" [project.optional-dependencies] black = [ "black>=23.3.0", ] dirty-equals =[ "dirty-equals>=0.9.0", ] [dependency-groups] dev = [ "hypothesis>=6.75.5", "mypy>=1.2.0", "pyright>=1.1.359", "pytest-subtests>=0.11.0", "pytest-freezer>=0.4.8", "pytest-mock>=3.14.0", "pytest-xdist>=3.6.1", "coverage[toml]>=7.6.1", "coverage-enable-subprocess>=1.0", "pytest>=8", "attrs>=24.3.0", "pydantic>=1", ] [project.entry-points.pytest11] inline_snapshot = "inline_snapshot.pytest_plugin" [project.urls] Changelog = "https://15r10nk.github.io/inline-snapshot/latest/changelog/" Discussions = "https://github.com/15r10nk/inline-snapshots/discussions" Documentation = "https://15r10nk.github.io/inline-snapshot/latest" Funding = "https://github.com/sponsors/15r10nk" Homepage = "https://15r10nk.github.io/inline-snapshot/latest" Issues = "https://github.com/15r10nk/inline-snapshots/issues" Repository = "https://github.com/15r10nk/inline-snapshot/" [tool.commitizen] major_version_zero = true tag_format = "v$major.$minor.$patch$prerelease" version_files = [ "src/inline_snapshot/__init__.py:__version__" ] version_provider = "pep621" [tool.coverage.paths] inline_snapshot = ["src/inline_snapshot", "*/inline_snapshot/src/inline_snapshot"] tests = ["tests", "*/inline_snapshot/tests"] [tool.coverage.report] exclude_lines = ["assert False", "raise NotImplemented", "# pragma: no cover", "if TYPE_CHECKING:"] [tool.coverage.run] branch = true data_file = "$TOP/.coverage" omit = [ "src/inline_snapshot/__about__.py" ] parallel = true source_pkgs = ["inline_snapshot", "tests"] [tool.hatch.envs.docs] dependencies = [ "markdown-exec[ansi]>=1.8.0", "mkdocs>=1.4.2", "mkdocs-material[imaging]>=9.5.17", "mike", "mkdocstrings[python]>=0.19.0", "mkdocs-autorefs", "replace-url @ {root:uri}/docs/plugins", "pytest", "black", "commitizen" ] [tool.hatch.envs.default] installer="uv" [tool.hatch.envs.cov.scripts] github=[ "- rm htmlcov/*", "gh run download -n html-report -D htmlcov", "xdg-open htmlcov/index.html", ] [tool.hatch.envs.docs.scripts] build = "mkdocs build --strict" deploy = "mkdocs gh-deploy" export-deps = "pip freeze" serve = "mkdocs serve" [tool.hatch.envs.cog] dependencies=["cogapp","lxml","requests"] scripts.update="cog -r docs/**.md" [tool.hatch.envs.gen] dependencies=["pysource-minimize"] scripts.test=["python testing/generate_tests.py"] [[tool.hatch.envs.hatch-test.matrix]] python = ["3.13", "3.12", "3.11", "3.10", "3.9", "3.8","pypy3.9","pypy3.10"] extra-deps=["low","hight"] [tool.hatch.envs.hatch-test.overrides] matrix.extra-deps.dependencies = [ { value = "pytest==8.3.3", if = ["low"] }, { value = "pytest>=8.3.4", if = ["hight"] }, { value = "pydantic<2", if = ["low"] }, { value = "pydantic>=2", if = ["hight"] }, ] [tool.hatch.envs.hatch-test] extra-dependencies = [ "inline-snapshot[black,dirty-equals]", "dirty-equals>=0.9.0", "hypothesis>=6.75.5", "mypy>=1.2.0", "pyright>=1.1.359", "pytest-subtests>=0.11.0", "pytest-freezer>=0.4.8", "pytest-mock>=3.14.0" ] env-vars.TOP = "{root}" [tool.hatch.envs.hatch-test.scripts] run = "pytest{env:HATCH_TEST_ARGS:} {args}" run-cov = "coverage run -m pytest{env:HATCH_TEST_ARGS:} {args}" cov-combine = "coverage combine" cov-report=["coverage report","coverage html"] [tool.hatch.envs.types] extra-dependencies = [ "inline-snapshot[black,dirty-equals]", "mypy>=1.0.0", "pytest", "hypothesis>=6.75.5", "pydantic", "attrs", "typing-extensions" ] [[tool.hatch.envs.types.matrix]] python = ["3.8", "3.9", "3.10", "3.11", "3.12","3.13"] [tool.hatch.envs.types.scripts] check = "mypy --install-types --non-interactive {args:src/inline_snapshot tests}" [tool.mypy] exclude = "tests/.*_samples" [tool.pyright] venv = "test-3-12" venvPath = ".nox" [tool.hatch.envs.release] detached=true dependencies=[ "scriv", "commitizen" ] [tool.hatch.envs.release.scripts] create=[ "scriv collect", "- pre-commit run -a", "cz bump" ] publish=[ "git push --force-with-lease", "git push --tags", ] # only as an backup now publish-package=[ "- rm dist/*", "hatch build", "hatch publish", "scriv github-release" ] [tool.scriv] format = "md" version = "command: cz bump --get-next" [tool.pytest.ini_options] markers=["no_rewriting: marks tests which need no code rewriting and can be used with pypy"] [tool.isort] profile="black" force_single_line=true python-inline-snapshot-0.20.7/scripts/000077500000000000000000000000001476476140300177355ustar00rootroot00000000000000python-inline-snapshot-0.20.7/scripts/replace_words.py000066400000000000000000000011231476476140300231350ustar00rootroot00000000000000import re import sys def replace_words(file_path, replacements): with open(file_path) as file: content = file.read() for old_word, new_word in replacements.items(): content = re.sub(rf"\b{re.escape(old_word)}\b", new_word, content) with open(file_path, "w") as file: file.write(content) if __name__ == "__main__": replacements = { "http://localhost:8000/inline-snapshot/": "https://15r10nk.github.io/inline-snapshot/latest/", } for file_path in sys.argv[1:]: print(file_path) replace_words(file_path, replacements) python-inline-snapshot-0.20.7/src/000077500000000000000000000000001476476140300170355ustar00rootroot00000000000000python-inline-snapshot-0.20.7/src/inline_snapshot/000077500000000000000000000000001476476140300222325ustar00rootroot00000000000000python-inline-snapshot-0.20.7/src/inline_snapshot/__init__.py000066400000000000000000000007321476476140300243450ustar00rootroot00000000000000from ._code_repr import HasRepr from ._code_repr import customize_repr from ._exceptions import UsageError from ._external import external from ._external import outsource from ._inline_snapshot import snapshot from ._is import Is from ._types import Category from ._types import Snapshot __all__ = [ "snapshot", "external", "outsource", "customize_repr", "HasRepr", "Is", "Category", "Snapshot", "UsageError", ] __version__ = "0.20.7" python-inline-snapshot-0.20.7/src/inline_snapshot/_adapter/000077500000000000000000000000001476476140300240115ustar00rootroot00000000000000python-inline-snapshot-0.20.7/src/inline_snapshot/_adapter/__init__.py000066400000000000000000000001071476476140300261200ustar00rootroot00000000000000from .adapter import get_adapter_type __all__ = ("get_adapter_type",) python-inline-snapshot-0.20.7/src/inline_snapshot/_adapter/adapter.py000066400000000000000000000044431476476140300260100ustar00rootroot00000000000000from __future__ import annotations import ast import typing from dataclasses import dataclass from inline_snapshot._source_file import SourceFile def get_adapter_type(value): from inline_snapshot._adapter.generic_call_adapter import get_adapter_for_type adapter = get_adapter_for_type(type(value)) if adapter is not None: return adapter if isinstance(value, list): from .sequence_adapter import ListAdapter return ListAdapter if type(value) is tuple: from .sequence_adapter import TupleAdapter return TupleAdapter if isinstance(value, dict): from .dict_adapter import DictAdapter return DictAdapter from .value_adapter import ValueAdapter return ValueAdapter class Item(typing.NamedTuple): value: typing.Any node: ast.expr @dataclass class FrameContext: globals: dict locals: dict @dataclass class AdapterContext: file: SourceFile frame: FrameContext | None def eval(self, node): assert self.frame is not None return eval( compile(ast.Expression(node), self.file.filename, "eval"), self.frame.globals, self.frame.locals, ) class Adapter: context: AdapterContext def __init__(self, context: AdapterContext): self.context = context def get_adapter(self, old_value, new_value) -> Adapter: if type(old_value) is not type(new_value): from .value_adapter import ValueAdapter return ValueAdapter(self.context) adapter_type = get_adapter_type(old_value) if adapter_type is not None: return adapter_type(self.context) assert False def assign(self, old_value, old_node, new_value): raise NotImplementedError(self) def value_assign(self, old_value, old_node, new_value): from .value_adapter import ValueAdapter adapter = ValueAdapter(self.context) result = yield from adapter.assign(old_value, old_node, new_value) return result @classmethod def map(cls, value, map_function): raise NotImplementedError(cls) @classmethod def repr(cls, value): raise NotImplementedError(cls) def adapter_map(value, map_function): return get_adapter_type(value).map(value, map_function) python-inline-snapshot-0.20.7/src/inline_snapshot/_adapter/dict_adapter.py000066400000000000000000000111431476476140300270060ustar00rootroot00000000000000from __future__ import annotations import ast import warnings from .._change import Delete from .._change import DictInsert from ..syntax_warnings import InlineSnapshotSyntaxWarning from .adapter import Adapter from .adapter import Item from .adapter import adapter_map class DictAdapter(Adapter): @classmethod def repr(cls, value): result = ( "{" + ", ".join(f"{repr(k)}: {repr(value)}" for k, value in value.items()) + "}" ) if type(value) is not dict: result = f"{repr(type(value))}({result})" return result @classmethod def map(cls, value, map_function): return {k: adapter_map(v, map_function) for k, v in value.items()} @classmethod def items(cls, value, node): if node is None or not isinstance(node, ast.Dict): return [Item(value=value, node=None) for value in value.values()] result = [] for value_key, node_key, node_value in zip( value.keys(), node.keys, node.values ): try: # this is just a sanity check, dicts should be ordered node_key = ast.literal_eval(node_key) except Exception: pass else: assert node_key == value_key result.append(Item(value=value[value_key], node=node_value)) return result def assign(self, old_value, old_node, new_value): if old_node is not None: if not ( isinstance(old_node, ast.Dict) and len(old_value) == len(old_node.keys) ): result = yield from self.value_assign(old_value, old_node, new_value) return result for key, value in zip(old_node.keys, old_node.values): if key is None: warnings.warn_explicit( "star-expressions are not supported inside snapshots", filename=self.context.file._source.filename, lineno=value.lineno, category=InlineSnapshotSyntaxWarning, ) return old_value for value, node in zip(old_value.keys(), old_node.keys): try: # this is just a sanity check, dicts should be ordered node_value = ast.literal_eval(node) except: continue assert node_value == value result = {} for key, node in zip( old_value.keys(), (old_node.values if old_node is not None else [None] * len(old_value)), ): if key not in new_value: # delete entries yield Delete("fix", self.context.file._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)) result[key] = new_value_element else: if isinstance(old_node, ast.Dict): node = old_node.values[list(old_value.keys()).index(key)] else: node = None # check values with same keys result[key] = yield from self.get_adapter( old_value[key], new_value[key] ).assign(old_value[key], node, new_value[key]) if to_insert: new_code = [ ( self.context.file._value_to_code(k), self.context.file._value_to_code(v), ) for k, v in to_insert ] yield DictInsert( "fix", self.context.file._source, old_node, insert_pos, new_code, to_insert, ) to_insert = [] insert_pos += 1 if to_insert: new_code = [ ( self.context.file._value_to_code(k), self.context.file._value_to_code(v), ) for k, v in to_insert ] yield DictInsert( "fix", self.context.file._source, old_node, len(old_value), new_code, to_insert, ) return result python-inline-snapshot-0.20.7/src/inline_snapshot/_adapter/generic_call_adapter.py000066400000000000000000000322661476476140300305030ustar00rootroot00000000000000from __future__ import annotations import ast import warnings from abc import ABC from collections import defaultdict from dataclasses import MISSING from dataclasses import fields from dataclasses import is_dataclass from typing import Any from .._change import CallArg from .._change import Delete from ..syntax_warnings import InlineSnapshotSyntaxWarning from .adapter import Adapter from .adapter import Item from .adapter import adapter_map def get_adapter_for_type(typ): subclasses = GenericCallAdapter.__subclasses__() options = [cls for cls in subclasses if cls.check_type(typ)] if not options: return assert len(options) == 1 return options[0] class Argument: value: Any is_default: bool = False def __init__(self, value, is_default=False): self.value = value self.is_default = is_default class GenericCallAdapter(Adapter): @classmethod def check_type(cls, typ) -> bool: raise NotImplementedError(cls) @classmethod def arguments(cls, value) -> tuple[list[Argument], dict[str, Argument]]: raise NotImplementedError(cls) @classmethod def argument(cls, value, pos_or_name) -> Any: raise NotImplementedError(cls) @classmethod def repr(cls, value): args, kwargs = cls.arguments(value) arguments = [repr(value.value) for value in args] + [ f"{key}={repr(value.value)}" for key, value in kwargs.items() if not value.is_default ] return f"{repr(type(value))}({', '.join(arguments)})" @classmethod def map(cls, value, map_function): new_args, new_kwargs = cls.arguments(value) return type(value)( *[adapter_map(arg.value, map_function) for arg in new_args], **{ k: adapter_map(kwarg.value, map_function) for k, kwarg in new_kwargs.items() }, ) @classmethod def items(cls, value, node): new_args, new_kwargs = cls.arguments(value) if node is not None: assert isinstance(node, ast.Call) assert all(kw.arg for kw in node.keywords) kw_arg_node = {kw.arg: kw.value for kw in node.keywords if kw.arg}.get def pos_arg_node(pos): return node.args[pos] else: def kw_arg_node(_): return None def pos_arg_node(_): return None return [ Item(value=arg.value, node=pos_arg_node(i)) for i, arg in enumerate(new_args) ] + [ Item(value=kw.value, node=kw_arg_node(name)) for name, kw in new_kwargs.items() ] def assign(self, old_value, old_node, new_value): if old_node is None or not isinstance(old_node, ast.Call): result = yield from self.value_assign(old_value, old_node, new_value) return result call_type = self.context.eval(old_node.func) if not (isinstance(call_type, type) and self.check_type(call_type)): result = yield from self.value_assign(old_value, old_node, new_value) return result # positional arguments for pos_arg in old_node.args: if isinstance(pos_arg, ast.Starred): warnings.warn_explicit( "star-expressions are not supported inside snapshots", filename=self.context.file._source.filename, lineno=pos_arg.lineno, category=InlineSnapshotSyntaxWarning, ) return old_value # keyword arguments for kw in old_node.keywords: if kw.arg is None: warnings.warn_explicit( "star-expressions are not supported inside snapshots", filename=self.context.file._source.filename, lineno=kw.value.lineno, category=InlineSnapshotSyntaxWarning, ) return old_value new_args, new_kwargs = self.arguments(new_value) # positional arguments result_args = [] for i, (new_value_element, node) in enumerate(zip(new_args, old_node.args)): old_value_element = self.argument(old_value, i) result = yield from self.get_adapter( old_value_element, new_value_element.value ).assign(old_value_element, node, new_value_element.value) result_args.append(result) if len(old_node.args) > len(new_args): for arg_pos, node in list(enumerate(old_node.args))[len(new_args) :]: yield Delete( "fix", self.context.file._source, node, self.argument(old_value, arg_pos), ) if len(old_node.args) < len(new_args): for insert_pos, value in list(enumerate(new_args))[len(old_node.args) :]: yield CallArg( flag="fix", file=self.context.file._source, node=old_node, arg_pos=insert_pos, arg_name=None, new_code=self.context.file._value_to_code(value.value), new_value=value.value, ) # keyword arguments result_kwargs = {} for kw in old_node.keywords: if (missing := kw.arg not in new_kwargs) or new_kwargs[kw.arg].is_default: # delete entries yield Delete( "fix" if missing else "update", self.context.file._source, kw.value, self.argument(old_value, kw.arg), ) old_node_kwargs = {kw.arg: kw.value for kw in old_node.keywords} to_insert = [] insert_pos = 0 for key, new_value_element in new_kwargs.items(): if new_value_element.is_default: continue if key not in old_node_kwargs: # add new values to_insert.append((key, new_value_element.value)) result_kwargs[key] = new_value_element.value else: node = old_node_kwargs[key] # check values with same keys old_value_element = self.argument(old_value, key) result_kwargs[key] = yield from self.get_adapter( old_value_element, new_value_element.value ).assign(old_value_element, node, new_value_element.value) if to_insert: for key, value in to_insert: yield CallArg( flag="fix", file=self.context.file._source, node=old_node, arg_pos=insert_pos, arg_name=key, new_code=self.context.file._value_to_code(value), new_value=value, ) to_insert = [] insert_pos += 1 if to_insert: for key, value in to_insert: yield CallArg( flag="fix", file=self.context.file._source, node=old_node, arg_pos=insert_pos, arg_name=key, new_code=self.context.file._value_to_code(value), new_value=value, ) return type(old_value)(*result_args, **result_kwargs) class DataclassAdapter(GenericCallAdapter): @classmethod def check_type(cls, value): return is_dataclass(value) @classmethod def arguments(cls, value): kwargs = {} for field in fields(value): # type: ignore if field.repr: field_value = getattr(value, field.name) is_default = False if field.default != MISSING and field.default == field_value: is_default = True if ( field.default_factory != MISSING and field.default_factory() == field_value ): is_default = True kwargs[field.name] = Argument(value=field_value, is_default=is_default) return ([], kwargs) def argument(self, value, pos_or_name): if isinstance(pos_or_name, str): return getattr(value, pos_or_name) else: args = [field for field in fields(value) if field.init] return args[pos_or_name] try: import attrs except ImportError: # pragma: no cover pass else: class AttrAdapter(GenericCallAdapter): @classmethod def check_type(cls, value): return attrs.has(value) @classmethod def arguments(cls, value): kwargs = {} for field in attrs.fields(type(value)): if field.repr: field_value = getattr(value, field.name) is_default = False if field.default is not attrs.NOTHING: default_value = ( field.default if not isinstance(field.default, attrs.Factory) else ( field.default.factory() if not field.default.takes_self else field.default.factory(value) ) ) if default_value == field_value: is_default = True kwargs[field.name] = Argument( value=field_value, is_default=is_default ) return ([], kwargs) def argument(self, value, pos_or_name): assert isinstance(pos_or_name, str) return getattr(value, pos_or_name) try: import pydantic except ImportError: # pragma: no cover pass else: # import pydantic if pydantic.version.VERSION.startswith("1."): # pydantic v1 from pydantic.fields import Undefined as PydanticUndefined # type: ignore[attr-defined,no-redef] def get_fields(value): return value.__fields__ else: # pydantic v2 from pydantic_core import PydanticUndefined def get_fields(value): return type(value).model_fields from pydantic import BaseModel class PydanticContainer(GenericCallAdapter): @classmethod def check_type(cls, value): return issubclass(value, BaseModel) @classmethod def arguments(cls, value): kwargs = {} for name, field in get_fields(value).items(): # type: ignore if getattr(field, "repr", True): field_value = getattr(value, name) is_default = False if ( field.default is not PydanticUndefined and field.default == field_value ): is_default = True if ( field.default_factory is not None and field.default_factory() == field_value ): is_default = True kwargs[name] = Argument(value=field_value, is_default=is_default) return ([], kwargs) @classmethod def argument(cls, value, pos_or_name): assert isinstance(pos_or_name, str) return getattr(value, pos_or_name) class IsNamedTuple(ABC): _inline_snapshot_name = "namedtuple" _fields: tuple _field_defaults: dict @classmethod def __subclasshook__(cls, t): b = t.__bases__ if len(b) != 1 or b[0] != tuple: return False f = getattr(t, "_fields", None) if not isinstance(f, tuple): return False return all(type(n) == str for n in f) class NamedTupleAdapter(GenericCallAdapter): @classmethod def check_type(cls, value): return issubclass(value, IsNamedTuple) @classmethod def arguments(cls, value: IsNamedTuple): return ( [], { field: Argument(value=getattr(value, field)) for field in value._fields if field not in value._field_defaults or getattr(value, field) != value._field_defaults[field] }, ) def argument(self, value, pos_or_name): assert isinstance(pos_or_name, str) return getattr(value, pos_or_name) class DefaultDictAdapter(GenericCallAdapter): @classmethod def check_type(cls, value): return issubclass(value, defaultdict) @classmethod def arguments(cls, value: defaultdict): return ( [Argument(value=value.default_factory), Argument(value=dict(value))], {}, ) def argument(self, value, pos_or_name): assert isinstance(pos_or_name, int) if pos_or_name == 0: return value.default_factory elif pos_or_name == 1: return dict(value) assert False python-inline-snapshot-0.20.7/src/inline_snapshot/_adapter/sequence_adapter.py000066400000000000000000000074371476476140300277060ustar00rootroot00000000000000from __future__ import annotations import ast import warnings from collections import defaultdict from .._align import add_x from .._align import align from .._change import Delete from .._change import ListInsert from .._compare_context import compare_context from ..syntax_warnings import InlineSnapshotSyntaxWarning from .adapter import Adapter from .adapter import Item from .adapter import adapter_map class SequenceAdapter(Adapter): node_type: type value_type: type braces: str trailing_comma: bool @classmethod def repr(cls, value): if len(value) == 1 and cls.trailing_comma: seq = repr(value[0]) + "," else: seq = ", ".join(map(repr, value)) return cls.braces[0] + seq + cls.braces[1] @classmethod def map(cls, value, map_function): result = [adapter_map(v, map_function) for v in value] return cls.value_type(result) @classmethod def items(cls, value, node): if node is None or not isinstance(node, cls.node_type): return [Item(value=v, node=None) for v in value] assert len(value) == len(node.elts) return [Item(value=v, node=n) for v, n in zip(value, node.elts)] def assign(self, old_value, old_node, new_value): if old_node is not None: if not isinstance( old_node, ast.List if isinstance(old_value, list) else ast.Tuple ): result = yield from self.value_assign(old_value, old_node, new_value) return result for e in old_node.elts: if isinstance(e, ast.Starred): warnings.warn_explicit( "star-expressions are not supported inside snapshots", filename=self.context.file.filename, lineno=e.lineno, category=InlineSnapshotSyntaxWarning, ) return old_value with compare_context(): diff = add_x(align(old_value, new_value)) old = zip( old_value, old_node.elts if old_node is not None else [None] * len(old_value), ) new = iter(new_value) old_position = 0 to_insert = defaultdict(list) result = [] for c in diff: if c in "mx": old_value_element, old_node_element = next(old) new_value_element = next(new) v = yield from self.get_adapter( old_value_element, new_value_element ).assign(old_value_element, old_node_element, new_value_element) result.append(v) old_position += 1 elif c == "i": new_value_element = next(new) new_code = self.context.file._value_to_code(new_value_element) result.append(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.context.file._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.context.file._source, old_node, position, *zip(*code_values) ) return self.value_type(result) class ListAdapter(SequenceAdapter): node_type = ast.List value_type = list braces = "[]" trailing_comma = False class TupleAdapter(SequenceAdapter): node_type = ast.Tuple value_type = tuple braces = "()" trailing_comma = True python-inline-snapshot-0.20.7/src/inline_snapshot/_adapter/value_adapter.py000066400000000000000000000041241476476140300272000ustar00rootroot00000000000000from __future__ import annotations import ast import warnings from .._change import Replace from .._code_repr import value_code_repr from .._sentinels import undefined from .._unmanaged import Unmanaged from .._unmanaged import update_allowed from .._utils import value_to_token from ..syntax_warnings import InlineSnapshotInfo from .adapter import Adapter class ValueAdapter(Adapter): @classmethod def repr(cls, value): return value_code_repr(value) @classmethod def map(cls, value, map_function): return map_function(value) def assign(self, old_value, old_node, new_value): # generic fallback # because IsStr() != IsStr() if isinstance(old_value, Unmanaged): return old_value if old_node is None: new_token = [] else: new_token = value_to_token(new_value) if isinstance(old_node, ast.JoinedStr) and isinstance(new_value, str): if not old_value == new_value: warnings.warn_explicit( f"inline-snapshot will be able to fix f-strings in the future.\nThe current string value is:\n {new_value!r}", filename=self.context.file._source.filename, lineno=old_node.lineno, category=InlineSnapshotInfo, ) return old_value if not old_value == new_value: if old_value is undefined: flag = "create" else: flag = "fix" elif ( old_node is not None and update_allowed(old_value) and self.context.file._token_of_node(old_node) != new_token ): flag = "update" else: # equal and equal repr return old_value new_code = self.context.file._token_to_code(new_token) yield Replace( node=old_node, file=self.context.file._source, new_code=new_code, flag=flag, old_value=old_value, new_value=new_value, ) return new_value python-inline-snapshot-0.20.7/src/inline_snapshot/_align.py000066400000000000000000000035771476476140300240510ustar00rootroot00000000000000from itertools import groupby def align(seq_a, seq_b) -> str: start = 0 for a, b in zip(seq_a, seq_b): if a == b: start += 1 else: break if start == len(seq_a) == len(seq_b): return "m" * start end = 0 for a, b in zip(reversed(seq_a[start:]), reversed(seq_b[start:])): if a == b: end += 1 else: break diff = nw_align(seq_a[start : len(seq_a) - end], seq_b[start : len(seq_b) - end]) return "m" * start + diff + "m" * end def nw_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 python-inline-snapshot-0.20.7/src/inline_snapshot/_change.py000066400000000000000000000177731476476140300242070ustar00rootroot00000000000000import ast from collections import defaultdict from dataclasses import dataclass from typing import Any from typing import DefaultDict from typing import Dict from typing import List from typing import Optional from typing import Tuple from typing import Union from typing import cast from asttokens.util import Token from executing.executing import EnhancedAST from inline_snapshot._source_file import SourceFile from ._rewrite_code import ChangeRecorder from ._rewrite_code import end_of from ._rewrite_code import start_of @dataclass() class Change: flag: str file: SourceFile @property def filename(self): return self.file.filename def apply(self, recorder: ChangeRecorder): raise NotImplementedError() @dataclass() class Delete(Change): node: ast.AST old_value: Any @dataclass() class AddArgument(Change): node: ast.Call 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): node: ast.AST new_code: str old_value: Any new_value: Any def apply(self, recorder: ChangeRecorder): change = recorder.new_change() range = self.file.asttokens().get_text_positions(self.node, False) change.replace(range, self.new_code, filename=self.filename) @dataclass() class CallArg(Change): node: Optional[ast.Call] arg_pos: Optional[int] arg_name: Optional[str] new_code: str new_value: Any TokenRange = Tuple[Token, Token] def brace_tokens(source, node) -> TokenRange: first_token, *_, end_token = source.asttokens().get_tokens(node) return first_token, end_token def generic_sequence_update( source: SourceFile, parent: Union[ast.List, ast.Tuple, ast.Dict, ast.Call], brace_tokens: TokenRange, parent_elements: List[Union[TokenRange, None]], to_insert: Dict[int, List[str]], recorder: ChangeRecorder, ): rec = recorder.new_change() new_code = [] deleted = False last_token, end_token = brace_tokens 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,) code += "," rec.replace( (end_of(last_token), start_of(end_token)), code, filename=source.filename, ) def apply_all(all_changes: List[Change], recorder: ChangeRecorder): by_parent: Dict[ EnhancedAST, List[Union[Delete, DictInsert, ListInsert, CallArg]] ] = defaultdict(list) sources: Dict[EnhancedAST, SourceFile] = {} for change in all_changes: if isinstance(change, Delete): node = cast(EnhancedAST, change.node).parent if isinstance(node, ast.keyword): node = node.parent by_parent[node].append(change) sources[node] = change.file elif isinstance(change, (DictInsert, ListInsert, CallArg)): node = cast(EnhancedAST, change.node) by_parent[node].append(change) sources[node] = change.file else: change.apply(recorder) for parent, changes in by_parent.items(): source = sources[parent] if isinstance(parent, (ast.List, ast.Tuple)): to_delete = { change.node for change in changes if isinstance(change, Delete) } to_insert = { change.position: change.new_code for change in changes if isinstance(change, ListInsert) } def list_token_range(entry): r = list(source.asttokens().get_tokens(entry)) return r[0], r[-1] generic_sequence_update( source, parent, brace_tokens(source, parent), [None if e in to_delete else list_token_range(e) for e in parent.elts], to_insert, recorder, ) elif isinstance(parent, ast.Call): to_delete = { change.node for change in changes if isinstance(change, Delete) } atok = source.asttokens() def arg_token_range(node): if isinstance(node.parent, ast.keyword): node = node.parent r = list(atok.get_tokens(node)) return r[0], r[-1] braces_left = atok.next_token(list(atok.get_tokens(parent.func))[-1]) assert braces_left.string == "(" braces_right = list(atok.get_tokens(parent))[-1] assert braces_right.string == ")" to_insert = DefaultDict(list) for change in changes: if isinstance(change, CallArg): if change.arg_name is not None: position = ( change.arg_pos if change.arg_pos is not None else len(parent.args) + len(parent.keywords) ) to_insert[position].append( f"{change.arg_name} = {change.new_code}" ) else: assert change.arg_pos is not None to_insert[change.arg_pos].append(change.new_code) generic_sequence_update( source, parent, (braces_left, braces_right), [ None if e in to_delete else arg_token_range(e) for e in parent.args + [kw.value for kw in parent.keywords] ], to_insert, recorder, ) 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, brace_tokens(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, recorder, ) else: assert False, parent python-inline-snapshot-0.20.7/src/inline_snapshot/_code_repr.py000066400000000000000000000072271476476140300247150ustar00rootroot00000000000000import ast from enum import Enum from enum import Flag from functools import singledispatch from unittest import mock real_repr = repr class HasRepr: """This class is used for objects where `__repr__()` returns an non- parsable representation. HasRepr uses the type and repr of the value for equal comparison. You can change `__repr__()` to return valid python code or use `@customize_repr` to customize repr which is used by inline- snapshot. """ def __init__(self, type, str_repr: str) -> None: self._type = type self._str_repr = str_repr def __repr__(self): return f"HasRepr({self._type.__qualname__}, {self._str_repr!r})" def __eq__(self, other): if isinstance(other, HasRepr): if other._type is not self._type: return False else: if type(other) is not self._type: return False other_repr = code_repr(other) return other_repr == self._str_repr or other_repr == repr(self) def used_hasrepr(tree): return [ n for n in ast.walk(tree) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "HasRepr" and len(n.args) == 2 ] @singledispatch def code_repr_dispatch(value): return real_repr(value) def customize_repr(f): """Register a funtion which should be used to get the code representation of a object. ``` python @customize_repr def _(obj: MyCustomClass): return f"MyCustomClass(attr={repr(obj.attr)})" ``` it is important to use `repr()` inside the implementation, because it is mocked to return the code represenation you dont have to provide a custom implementation if: * __repr__() of your class returns a valid code representation, * and __repr__() uses `repr()` to get the representaion of the child objects """ code_repr_dispatch.register(f) def code_repr(obj): with mock.patch("builtins.repr", mocked_code_repr): return mocked_code_repr(obj) def mocked_code_repr(obj): from inline_snapshot._adapter.adapter import get_adapter_type adapter = get_adapter_type(obj) assert adapter is not None return adapter.repr(obj) def value_code_repr(obj): if not type(obj) == type(obj): # pragma: no cover # this was caused by https://github.com/samuelcolvin/dirty-equals/issues/104 # dispatch will not work in cases like this return ( f"HasRepr({repr(type(obj))}, '< type(obj) can not be compared with == >')" ) result = code_repr_dispatch(obj) try: ast.parse(result) except SyntaxError: return real_repr(HasRepr(type(obj), result)) return result # -8<- [start:Enum] @customize_repr def _(value: Enum): return f"{type(value).__qualname__}.{value.name}" # -8<- [end:Enum] @customize_repr def _(value: Flag): name = type(value).__qualname__ return " | ".join(f"{name}.{flag.name}" for flag in type(value) if flag in value) def sort_set_values(set_values): is_sorted = False try: set_values = sorted(set_values) is_sorted = True except TypeError: pass set_values = list(map(repr, set_values)) if not is_sorted: set_values = sorted(set_values) return set_values @customize_repr def _(value: set): if len(value) == 0: return "set()" return "{" + ", ".join(sort_set_values(value)) + "}" @customize_repr def _(value: frozenset): if len(value) == 0: return "frozenset()" return "frozenset({" + ", ".join(sort_set_values(value)) + "})" @customize_repr def _(value: type): return value.__qualname__ python-inline-snapshot-0.20.7/src/inline_snapshot/_compare_context.py000066400000000000000000000004301476476140300261320ustar00rootroot00000000000000from contextlib import contextmanager def compare_only(): return _eq_check_only _eq_check_only = False @contextmanager def compare_context(): global _eq_check_only old_eq_only = _eq_check_only _eq_check_only = True yield _eq_check_only = old_eq_only python-inline-snapshot-0.20.7/src/inline_snapshot/_config.py000066400000000000000000000032111476476140300242050ustar00rootroot00000000000000import os import sys from dataclasses import dataclass from dataclasses import field from pathlib import Path from typing import Dict from typing import List from typing import Optional if sys.version_info >= (3, 11): from tomllib import loads else: from tomli import loads @dataclass class Config: hash_length: int = 12 default_flags: List[str] = field(default_factory=lambda: ["short-report"]) shortcuts: Dict[str, List[str]] = field(default_factory=dict) format_command: Optional[str] = None storage_dir: Optional[Path] = None config = Config() def read_config(path: Path) -> Config: result = Config() config = {} if path.exists(): data = loads(path.read_text("utf-8")) try: config = data["tool"]["inline-snapshot"] except KeyError: pass try: result.hash_length = config["hash-length"] except KeyError: pass try: result.default_flags = config["default-flags"] except KeyError: pass result.shortcuts = config.get( "shortcuts", {"fix": ["create", "fix"], "review": ["review"]} ) if storage_dir := config.get("storage-dir"): storage_dir = Path(storage_dir) if not storage_dir.is_absolute(): # Make it relative to pyproject.toml, and absolute. storage_dir = path.parent.joinpath(storage_dir).absolute() result.storage_dir = storage_dir result.format_command = config.get("format-command", None) env_var = "INLINE_SNAPSHOT_DEFAULT_FLAGS" if env_var in os.environ: result.default_flags = os.environ[env_var].split(",") return result python-inline-snapshot-0.20.7/src/inline_snapshot/_exceptions.py000066400000000000000000000000461476476140300251240ustar00rootroot00000000000000class UsageError(Exception): pass python-inline-snapshot-0.20.7/src/inline_snapshot/_external.py000066400000000000000000000122471476476140300245730ustar00rootroot00000000000000import hashlib import pathlib import re from typing import Optional from typing import Set from typing import Union from . import _config from ._global_state import state class HashError(Exception): pass class DiscStorage: def __init__(self, directory): self.directory = pathlib.Path(directory) def _ensure_directory(self): self.directory.mkdir(exist_ok=True, parents=True) gitignore = self.directory / ".gitignore" if not gitignore.exists(): gitignore.write_text( "# ignore all snapshots which are not refered in the source\n*-new.*\n", "utf-8", ) def save(self, name, data): assert "*" not in name self._ensure_directory() (self.directory / name).write_bytes(data) def read(self, name): return self._lookup_path(name).read_bytes() def prune_new_files(self): for file in self.directory.glob("*-new.*"): file.unlink() def list(self) -> Set[str]: if self.directory.exists(): return {item.name for item in self.directory.iterdir()} - {".gitignore"} else: return set() def persist(self, name): try: file = self._lookup_path(name) except HashError: return if file.stem.endswith("-new"): stem = file.stem[:-4] file.rename(file.with_name(stem + file.suffix)) def _lookup_path(self, name) -> pathlib.Path: files = list(self.directory.glob(name)) if len(files) > 1: raise HashError(f"hash collision files={sorted(f.name for f in files)}") if not files: raise HashError(f"hash {name!r} is not found in the DiscStorage") return files[0] def lookup_all(self, name) -> Set[str]: return {file.name for file in self.directory.glob(name)} def remove(self, name): self._lookup_path(name).unlink() class external: def __init__(self, name: str): """External objects are used as a representation for outsourced data. You should not create them directly. The external data is by default stored inside `/.inline-snapshot/external`, where `` is replaced by the directory containing the Pytest configuration file, if any. To store data in a different location, set the `storage-dir` option in pyproject.toml. Data which is outsourced but not referenced in the source code jet has a '-new' suffix in the filename. Parameters: name: the name of the external stored object. """ m = re.fullmatch(r"([0-9a-fA-F]*)\*?(\.[a-zA-Z0-9]*)", name) if m: self._hash, self._suffix = m.groups() else: raise ValueError( "path has to be of the form . or *." ) @property def _path(self): return f"{self._hash}*{self._suffix}" def __repr__(self): """Returns the representation of the external object. The length of the hash can be specified in the [config](configuration.md). """ hash = self._hash[: _config.config.hash_length] if len(hash) == 64: return f'external("{hash}{self._suffix}")' else: return f'external("{hash}*{self._suffix}")' def __eq__(self, other): """Two external objects are equal if they have the same hash and suffix.""" if not isinstance(other, external): return NotImplemented min_hash_len = min(len(self._hash), len(other._hash)) if self._hash[:min_hash_len] != other._hash[:min_hash_len]: return False if self._suffix != other._suffix: return False return True def _load_value(self): assert state().storage is not None return state().storage.read(self._path) def outsource(data: Union[str, bytes], *, suffix: Optional[str] = None) -> external: """Outsource some data into an external file. ``` pycon >>> png_data = b"some_bytes" # should be the replaced with your actual data >>> outsource(png_data, suffix=".png") external("212974ed1835*.png") ``` Parameters: data: data which should be outsourced. strings are encoded with `"utf-8"`. suffix: overwrite file suffix. The default is `".bin"` if data is an instance of `#!python bytes` and `".txt"` for `#!python str`. Returns: The external data. """ if isinstance(data, str): data = data.encode("utf-8") if suffix is None: suffix = ".txt" elif isinstance(data, bytes): if suffix is None: suffix = ".bin" else: raise TypeError("data has to be of type bytes | str") if not suffix or suffix[0] != ".": raise ValueError("suffix has to start with a '.' like '.png'") m = hashlib.sha256() m.update(data) hash = m.hexdigest() storage = state().storage assert storage is not None name = hash + suffix if not storage.lookup_all(name): path = hash + "-new" + suffix storage.save(path, data) return external(name) python-inline-snapshot-0.20.7/src/inline_snapshot/_find_external.py000066400000000000000000000051231476476140300255660ustar00rootroot00000000000000import ast import pathlib from typing import Set from executing import Source from ._global_state import state from ._rewrite_code import ChangeRecorder from ._rewrite_code import end_of from ._rewrite_code import start_of def contains_import(tree, module, name): for node in tree.body: if ( isinstance(node, ast.ImportFrom) and node.module == module and any(alias.name == name for alias in node.names) ): return True return False def used_externals_in(source) -> Set[str]: tree = ast.parse(source) if not contains_import(tree, "inline_snapshot", "external"): return set() usages = [] for node in ast.walk(tree): if ( isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "external" ): usages.append(node) return { u.args[0].value for u in usages if u.args and isinstance(u.args[0], ast.Constant) } def used_externals() -> Set[str]: result = set() for filename in state().files_with_snapshots: result |= used_externals_in(pathlib.Path(filename).read_text("utf-8")) return result def unused_externals() -> Set[str]: storage = state().storage assert storage is not None unused_externals = storage.list() for name in used_externals(): unused_externals -= storage.lookup_all(name) return unused_externals def ensure_import(filename, imports, recorder: ChangeRecorder): source = Source.for_filename(filename) change = recorder.new_change() tree = source.tree token = source.asttokens() to_add = [] for module, names in imports.items(): for name in names: if not contains_import(tree, module, name): to_add.append((module, name)) assert isinstance(tree, ast.Module) last_import = None for node in tree.body: if not isinstance(node, (ast.ImportFrom, ast.Import)): break last_import = node if last_import is None: position = start_of(tree.body[0].first_token) # type: ignore else: last_token = last_import.last_token # type: ignore while True: next_token = token.next_token(last_token) if last_token.end[0] == next_token.end[0]: last_token = next_token else: break position = end_of(last_token) code = "" for module, name in to_add: code += f"\nfrom {module} import {name}\n" change.insert(position, code, filename=filename) python-inline-snapshot-0.20.7/src/inline_snapshot/_flags.py000066400000000000000000000017561476476140300240500ustar00rootroot00000000000000from __future__ import annotations from typing import Set from typing import cast from ._types import Category class Flags: """ fix: the value needs to be changed to pass the tests update: the value should be updated because the token-stream has changed create: the snapshot is empty `snapshot()` trim: the snapshot contains more values than neccessary. 1 could be trimmed in `5 in snapshot([1,5])`. """ def __init__(self, flags: set[Category] = set()): self.create = "create" in flags self.fix = "fix" in flags self.trim = "trim" in flags self.update = "update" in flags def to_set(self) -> set[Category]: return cast(Set[Category], {k for k, v in self.__dict__.items() if v}) def __iter__(self): return (k for k, v in self.__dict__.items() if v) def __repr__(self): return f"Flags({self.to_set()})" @staticmethod def all() -> Flags: return Flags({"fix", "create", "update", "trim"}) python-inline-snapshot-0.20.7/src/inline_snapshot/_format.py000066400000000000000000000056071476476140300242430ustar00rootroot00000000000000import subprocess as sp import warnings from rich.markup import escape from . import _config from ._problems import raise_problem def enforce_formatting(): return _config.config.format_command is not None def file_mode_for_path(path): from black import FileMode from black import find_pyproject_toml from black import parse_pyproject_toml mode = FileMode() pyproject_path = find_pyproject_toml((), path) if pyproject_path is not None: config = parse_pyproject_toml(pyproject_path) if "line_length" in config: mode.line_length = int(config["line_length"]) if "skip_magic_trailing_comma" in config: mode.magic_trailing_comma = not config["skip_magic_trailing_comma"] if "skip_string_normalization" in config: # The ``black`` command line argument is # ``--skip-string-normalization``, but the parameter for # ``black.Mode`` needs to be the opposite boolean of # ``skip-string-normalization``, hence the inverse boolean mode.string_normalization = not config["skip_string_normalization"] if "preview" in config: mode.preview = config["preview"] return mode def format_code(text, filename): if _config.config.format_command is not None: format_command = _config.config.format_command.format(filename=filename) result = sp.run( format_command, shell=True, input=text.encode("utf-8"), capture_output=True ) if result.returncode != 0: raise_problem( f"""\ [b]The format_command '{escape(format_command)}' caused the following error:[/b] """ + result.stdout.decode("utf-8") + result.stderr.decode("utf-8") ) return text return result.stdout.decode("utf-8") try: from black import format_str except ImportError: raise_problem( f"""\ [b]inline-snapshot is not able to format your code.[/b] This issue can be solved by: * installing {escape('inline-snapshot[black]')} which gives you the same formatting like in older versions * adding a `format-command` to your pyproject.toml (see [link=https://15r10nk.github.io/inline-snapshot/latest/configuration/#format-command]https://15r10nk.github.io/inline-snapshot/latest/configuration/#format-command[/link] for more information). """ ) return text with warnings.catch_warnings(): warnings.simplefilter("ignore") mode = file_mode_for_path(filename) try: return format_str(text, mode=mode) except: raise_problem( """\ [b]black could not format your code, which might be caused by this issue:[/b] [link=https://github.com/15r10nk/inline-snapshot/issues/138]https://github.com/15r10nk/inline-snapshot/issues/138[/link]\ """ ) return text python-inline-snapshot-0.20.7/src/inline_snapshot/_global_state.py000066400000000000000000000016331476476140300254060ustar00rootroot00000000000000from __future__ import annotations import contextlib from dataclasses import dataclass from dataclasses import field from typing import TYPE_CHECKING from typing import Generator from ._flags import Flags if TYPE_CHECKING: from ._external import DiscStorage @dataclass class State: # snapshot missing_values: int = 0 incorrect_values: int = 0 snapshots: dict = field(default_factory=dict) update_flags: Flags = field(default_factory=Flags) active: bool = True files_with_snapshots: set[str] = field(default_factory=set) # external storage: DiscStorage | None = None _current = State() _current.active = False def state() -> State: global _current return _current @contextlib.contextmanager def snapshot_env() -> Generator[State]: global _current old = _current _current = State() try: yield _current finally: _current = old python-inline-snapshot-0.20.7/src/inline_snapshot/_inline_snapshot.py000066400000000000000000000077041476476140300261500ustar00rootroot00000000000000import ast import inspect from typing import Any from typing import TypeVar from typing import cast from executing import Source from inline_snapshot._source_file import SourceFile from ._adapter.adapter import AdapterContext from ._adapter.adapter import FrameContext from ._change import CallArg from ._global_state import state from ._sentinels import undefined from ._snapshot.undecided_value import UndecidedValue class ReprWrapper: def __init__(self, func): self.func = func def __call__(self, *args, **kwargs): return self.func(*args, **kwargs) def __repr__(self): return self.func.__name__ _T = TypeVar("_T") def repr_wrapper(func: _T) -> _T: return ReprWrapper(func) # type: ignore @repr_wrapper def snapshot(obj: Any = undefined) -> Any: """`snapshot()` is a placeholder for some value. `pytest --inline-snapshot=create` will create the value which matches your conditions. >>> assert 5 == snapshot() >>> assert 5 <= snapshot() >>> assert 5 >= snapshot() >>> assert 5 in snapshot() `snapshot()[key]` can be used to create sub-snapshots. The generated value will be inserted as argument to `snapshot()` >>> assert 5 == snapshot(5) `snapshot(value)` has general the semantic of an noop which returns `value`. """ if not state().active: if obj is undefined: raise AssertionError( "your snapshot is missing a value run pytest with --inline-snapshot=create" ) else: return obj frame = inspect.currentframe() assert frame is not None frame = frame.f_back assert frame is not None frame = frame.f_back assert frame is not None expr = Source.executing(frame) source = cast(Source, getattr(expr, "source", None) if expr is not None else None) context = AdapterContext( file=SourceFile(source), frame=FrameContext(globals=frame.f_globals, locals=frame.f_locals), ) module = inspect.getmodule(frame) if module is not None and module.__file__ is not None: state().files_with_snapshots.add(module.__file__) key = id(frame.f_code), frame.f_lasti if key not in state().snapshots: node = expr.node if node is None: # we can run without knowing of the calling expression but we will not be able to fix code state().snapshots[key] = SnapshotReference(obj, None, context) else: assert isinstance(node, ast.Call) state().snapshots[key] = SnapshotReference(obj, expr, context) else: state().snapshots[key]._re_eval(obj, context) return state().snapshots[key]._value def used_externals(tree): return [ n.args[0].value for n in ast.walk(tree) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "external" and n.args and isinstance(n.args[0], ast.Constant) ] class SnapshotReference: def __init__(self, value, expr, context: AdapterContext): self._expr = expr node = expr.node.args[0] if expr is not None and expr.node.args else None self._value = UndecidedValue(value, node, context) def _changes(self): if ( self._value._old_value is undefined if self._expr is None else not self._expr.node.args ): if self._value._new_value is undefined: return new_code = self._value._new_code() yield CallArg( flag="create", file=self._value._file, node=self._expr.node if self._expr is not None else None, arg_pos=0, arg_name=None, new_code=new_code, new_value=self._value._new_value, ) else: yield from self._value._get_changes() def _re_eval(self, obj, context: AdapterContext): self._value._re_eval(obj, context) python-inline-snapshot-0.20.7/src/inline_snapshot/_is.py000066400000000000000000000004511476476140300233560ustar00rootroot00000000000000import typing from typing import TYPE_CHECKING if TYPE_CHECKING: T = typing.TypeVar("T") def Is(v: T) -> T: return v else: class Is: def __init__(self, value): self.value = value def __eq__(self, other): return self.value == other python-inline-snapshot-0.20.7/src/inline_snapshot/_problems.py000066400000000000000000000005661476476140300245750ustar00rootroot00000000000000from rich.console import Console all_problems = set() def raise_problem(message): all_problems.add(message) def report_problems(console: Console): global all_problems if not all_problems: return console.rule("[red]Problems") for problem in all_problems: console.print(f"{problem}") console.print() all_problems = set() python-inline-snapshot-0.20.7/src/inline_snapshot/_rewrite_code.py000066400000000000000000000140041476476140300254150ustar00rootroot00000000000000from __future__ import annotations import logging import pathlib import sys from collections import defaultdict from collections.abc import Iterable from dataclasses import dataclass from difflib import unified_diff from itertools import islice import asttokens.util from asttokens import LineNumbers from ._format import enforce_formatting from ._format import format_code if sys.version_info >= (3, 10): from itertools import pairwise else: from itertools import tee def pairwise(iterable): # type: ignore a, b = tee(iterable) next(b, None) return zip(a, b) @dataclass(order=True) class SourcePosition: lineno: int col_offset: int def offset(self, line_numbers): return line_numbers.line_to_offset(self.lineno, self.col_offset) @dataclass(order=True) class SourceRange: start: SourcePosition end: SourcePosition def __post_init__(self): if self.start > self.end: raise ValueError("range start should be lower then end") @dataclass(order=True) class Replacement: range: SourceRange text: str change_id: int = 0 def start_of(obj) -> SourcePosition: if isinstance(obj, asttokens.util.Token): return SourcePosition(lineno=obj.start[0], col_offset=obj.start[1]) if isinstance(obj, SourcePosition): return obj if isinstance(obj, SourceRange): return obj.start if isinstance(obj, tuple) and len(obj) == 2: return SourcePosition(lineno=obj[0], col_offset=obj[1]) assert False def end_of(obj) -> SourcePosition: if isinstance(obj, asttokens.util.Token): return SourcePosition(lineno=obj.end[0], col_offset=obj.end[1]) if isinstance(obj, SourceRange): return obj.end return start_of(obj) def range_of(obj): if isinstance(obj, tuple) and len(obj) == 2: return SourceRange(start_of(obj[0]), end_of(obj[1])) return SourceRange(start_of(obj), end_of(obj)) class UsageError(Exception): pass class Change: # ChangeSet _next_change_id = 0 def __init__(self, change_recorder): self.change_recorder = change_recorder self.change_recorder._changes.append(self) self.change_id = self._next_change_id type(self)._next_change_id += 1 def replace(self, node, new_contend, *, filename): assert isinstance(new_contend, str) self._replace( filename, range_of(node), new_contend, ) def delete(self, node, *, filename): self.replace(node, "", filename=filename) def insert(self, node, new_content, *, filename): self.replace(start_of(node), new_content, filename=filename) def _replace(self, filename, range, new_contend): 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: def __init__(self, filename: pathlib.Path): self.replacements: list[Replacement] = [] self.filename = filename self.source = self.filename.read_text("utf-8") def rewrite(self): new_code = self.new_code() with open(self.filename, "bw") as code: code.write(new_code.encode()) def virtual_write(self): self.source = self.new_code() def _check(self): replacements = list(self.replacements) replacements.sort() for r in replacements: assert r.range.start <= r.range.end, r for lhs, rhs in pairwise(replacements): assert lhs.range.end <= rhs.range.start, (lhs, rhs) def new_code(self) -> str: """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") format_whole_file = enforce_formatting() or code == format_code( code, self.filename ) if not format_whole_file: logging.info(f"file is not formatted with black: {self.filename}") import black logging.info(f"black version: {black.__version__}") line_numbers = LineNumbers(code) new_code = asttokens.util.replace( code, [ ( r.range.start.offset(line_numbers), r.range.end.offset(line_numbers), r.text, ) for r in replacements ], ) if format_whole_file: new_code = format_code(new_code, self.filename) return new_code def diff(self): return "\n".join( islice( unified_diff(self.source.splitlines(), self.new_code().splitlines()), 2, None, ) ).strip() class ChangeRecorder: def __init__(self): self._source_files = defaultdict(SourceFile) self._changes = [] def get_source(self, filename) -> SourceFile: filename = pathlib.Path(filename) if filename not in self._source_files: self._source_files[filename] = SourceFile(filename) return self._source_files[filename] def files(self) -> Iterable[SourceFile]: return self._source_files.values() def new_change(self): return Change(self) def num_fixes(self): changes = set() for file in self._source_files.values(): changes.update(change.change_id for change in file.replacements) return len(changes) def fix_all(self): for file in self._source_files.values(): file.rewrite() def virtual_write(self): for file in self._source_files.values(): file.virtual_write() def dump(self): # pragma: no cover for file in self._source_files.values(): print("file:", file.filename) for change in file.replacements: print(" change:", change) python-inline-snapshot-0.20.7/src/inline_snapshot/_sentinels.py000066400000000000000000000000201476476140300247370ustar00rootroot00000000000000undefined = ... python-inline-snapshot-0.20.7/src/inline_snapshot/_snapshot/000077500000000000000000000000001476476140300242305ustar00rootroot00000000000000python-inline-snapshot-0.20.7/src/inline_snapshot/_snapshot/collection_value.py000066400000000000000000000050101476476140300301250ustar00rootroot00000000000000import ast from typing import Iterator from .._change import Change from .._change import Delete from .._change import ListInsert from .._change import Replace from .._global_state import state from .._sentinels import undefined from .._utils import value_to_token from .generic_value import GenericValue from .generic_value import clone from .generic_value import ignore_old_value class CollectionValue(GenericValue): _current_op = "x in snapshot" def __contains__(self, item): if self._old_value is undefined: state().missing_values += 1 if self._new_value is undefined: self._new_value = [clone(item)] else: if item not in self._new_value: self._new_value.append(clone(item)) if ignore_old_value() or self._old_value is undefined: return True else: return self._return(item in self._old_value) def _new_code(self): return self._file._value_to_code(self._new_value) def _get_changes(self) -> Iterator[Change]: 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, elements): if old_value not in self._new_value: yield Delete( flag="trim", file=self._file, node=old_node, old_value=old_value, ) continue # check for update new_token = value_to_token(old_value) if ( old_node is not None and self._file._token_of_node(old_node) != new_token ): new_code = self._file._token_to_code(new_token) yield Replace( node=old_node, file=self._file, 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", file=self._file, node=self._ast_node, position=len(self._old_value), new_code=[self._file._value_to_code(v) for v in new_values], new_values=new_values, ) python-inline-snapshot-0.20.7/src/inline_snapshot/_snapshot/dict_value.py000066400000000000000000000061111476476140300267200ustar00rootroot00000000000000import ast from typing import Iterator from .._adapter.adapter import AdapterContext from .._change import Change from .._change import Delete from .._change import DictInsert from .._global_state import state from .._inline_snapshot import UndecidedValue from .._sentinels import undefined from .generic_value import GenericValue class DictValue(GenericValue): _current_op = "snapshot[key]" def __getitem__(self, index): if self._new_value is undefined: self._new_value = {} if index not in self._new_value: old_value = self._old_value if old_value is undefined: state().missing_values += 1 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] self._new_value[index] = UndecidedValue( old_value.get(index, undefined), child_node, self._context ) return self._new_value[index] def _re_eval(self, value, context: AdapterContext): super()._re_eval(value, context) if self._new_value is not undefined and self._old_value is not undefined: for key, s in self._new_value.items(): if key in self._old_value: s._re_eval(self._old_value[key], context) def _new_code(self): return ( "{" + ", ".join( [ f"{self._file._value_to_code(k)}: {v._new_code()}" for k, v in self._new_value.items() if not isinstance(v, UndecidedValue) ] ) + "}" ) def _get_changes(self) -> Iterator[Change]: assert self._old_value is not undefined 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(), 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._file, node, self._old_value[key]) to_insert = [] for key, new_value_element in self._new_value.items(): if key not in self._old_value and not isinstance( new_value_element, UndecidedValue ): # add new values to_insert.append((key, new_value_element._new_code())) if to_insert: new_code = [(self._file._value_to_code(k), v) for k, v in to_insert] yield DictInsert( "create", self._file, self._ast_node, len(self._old_value), new_code, to_insert, ) python-inline-snapshot-0.20.7/src/inline_snapshot/_snapshot/eq_value.py000066400000000000000000000023151476476140300264040ustar00rootroot00000000000000from typing import Iterator from typing import List from inline_snapshot._adapter.adapter import Adapter from .._change import Change from .._compare_context import compare_only from .._global_state import state from .._sentinels import undefined from .generic_value import GenericValue from .generic_value import clone class EqValue(GenericValue): _current_op = "x == snapshot" _changes: List[Change] def __eq__(self, other): if self._old_value is undefined: state().missing_values += 1 if not compare_only() and self._new_value is undefined: self._changes = [] adapter = Adapter(self._context).get_adapter(self._old_value, other) it = iter(adapter.assign(self._old_value, self._ast_node, clone(other))) while True: try: self._changes.append(next(it)) except StopIteration as ex: self._new_value = ex.value break return self._return(self._visible_value() == other) def _new_code(self): return self._file._value_to_code(self._new_value) def _get_changes(self) -> Iterator[Change]: return iter(self._changes) python-inline-snapshot-0.20.7/src/inline_snapshot/_snapshot/generic_value.py000066400000000000000000000074271476476140300274240ustar00rootroot00000000000000import ast import copy from typing import Any from typing import Iterator from .._adapter.adapter import AdapterContext from .._adapter.adapter import get_adapter_type from .._change import Change from .._code_repr import code_repr from .._exceptions import UsageError from .._global_state import state from .._sentinels import undefined from .._types import Snapshot from .._unmanaged import Unmanaged from .._unmanaged import update_allowed def clone(obj): new = copy.deepcopy(obj) if not obj == new: raise UsageError( f"""\ inline-snapshot uses `copy.deepcopy` to copy objects, but the copied object is not equal to the original one: value = {code_repr(obj)} copied_value = copy.deepcopy(value) assert value == copied_value Please fix the way your object is copied or your __eq__ implementation. """ ) return new def ignore_old_value(): return state().update_flags.fix or state().update_flags.update class GenericValue(Snapshot): _new_value: Any _old_value: Any _current_op = "undefined" _ast_node: ast.Expr _context: AdapterContext @staticmethod def _return(result): if not result: state().incorrect_values += 1 return result @property def _file(self): return self._context.file def get_adapter(self, value): return get_adapter_type(value)(self._context) def _re_eval(self, value, context: AdapterContext): self._context = context def re_eval(old_value, node, value): if isinstance(old_value, Unmanaged): old_value.value = value return assert type(old_value) is type(value) adapter = self.get_adapter(old_value) if adapter is not None and hasattr(adapter, "items"): old_items = adapter.items(old_value, node) new_items = adapter.items(value, node) assert len(old_items) == len(new_items) for old_item, new_item in zip(old_items, new_items): re_eval(old_item.value, old_item.node, new_item.value) else: if update_allowed(old_value): if not old_value == value: raise UsageError( "snapshot value should not change. Use Is(...) for dynamic snapshot parts." ) else: assert False, "old_value should be converted to Unmanaged" re_eval(self._old_value, self._ast_node, value) def _ignore_old(self): return ( state().update_flags.fix or state().update_flags.update or state().update_flags.create or self._old_value is undefined ) def _visible_value(self): if self._ignore_old(): return self._new_value else: return self._old_value def _get_changes(self) -> Iterator[Change]: raise NotImplementedError() def _new_code(self): raise NotImplementedError() def __repr__(self): return repr(self._visible_value()) def _type_error(self, op): __tracebackhide__ = True raise TypeError( f"This snapshot cannot be use with `{op}`, because it was previously used with `{self._current_op}`" ) def __eq__(self, _other): __tracebackhide__ = True self._type_error("==") def __le__(self, _other): __tracebackhide__ = True self._type_error("<=") def __ge__(self, _other): __tracebackhide__ = True self._type_error(">=") def __contains__(self, _other): __tracebackhide__ = True self._type_error("in") def __getitem__(self, _item): __tracebackhide__ = True self._type_error("snapshot[key]") python-inline-snapshot-0.20.7/src/inline_snapshot/_snapshot/min_max_value.py000066400000000000000000000045071476476140300274340ustar00rootroot00000000000000from typing import Iterator from .._change import Change from .._change import Replace from .._global_state import state from .._sentinels import undefined from .._utils import value_to_token from .generic_value import GenericValue from .generic_value import clone from .generic_value import ignore_old_value class MinMaxValue(GenericValue): """Generic implementation for <=, >=""" @staticmethod def cmp(a, b): raise NotImplementedError def _generic_cmp(self, other): if self._old_value is undefined: state().missing_values += 1 if self._new_value is undefined: self._new_value = clone(other) if self._old_value is undefined or ignore_old_value(): return True return self._return(self.cmp(self._old_value, other)) else: if not self.cmp(self._new_value, other): self._new_value = clone(other) return self._return(self.cmp(self._visible_value(), other)) def _new_code(self): return self._file._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): flag = "fix" elif not self.cmp(self._new_value, self._old_value): flag = "trim" elif ( self._ast_node is not None and self._file._token_of_node(self._ast_node) != new_token ): flag = "update" else: return new_code = self._file._token_to_code(new_token) yield Replace( node=self._ast_node, file=self._file, new_code=new_code, flag=flag, old_value=self._old_value, new_value=self._new_value, ) class MinValue(MinMaxValue): """ handles: >>> snapshot(5) <= 6 True >>> 6 >= snapshot(5) True """ _current_op = "x >= snapshot" @staticmethod def cmp(a, b): return a <= b __le__ = MinMaxValue._generic_cmp class MaxValue(MinMaxValue): """ handles: >>> snapshot(5) >= 4 True >>> 4 <= snapshot(5) True """ _current_op = "x <= snapshot" @staticmethod def cmp(a, b): return a >= b __ge__ = MinMaxValue._generic_cmp python-inline-snapshot-0.20.7/src/inline_snapshot/_snapshot/undecided_value.py000066400000000000000000000051161476476140300277250ustar00rootroot00000000000000from typing import Iterator from inline_snapshot._adapter.adapter import adapter_map from .._adapter.adapter import AdapterContext from .._adapter.adapter import get_adapter_type from .._change import Change from .._change import Replace from .._sentinels import undefined from .._unmanaged import Unmanaged from .._unmanaged import map_unmanaged from .._utils import value_to_token from .generic_value import GenericValue class UndecidedValue(GenericValue): def __init__(self, old_value, ast_node, context: AdapterContext): old_value = adapter_map(old_value, map_unmanaged) self._old_value = old_value self._new_value = undefined self._ast_node = ast_node self._context = context def _change(self, cls): self.__class__ = cls def _new_code(self): assert False def _get_changes(self) -> Iterator[Change]: def handle(node, obj): adapter = get_adapter_type(obj) if adapter is not None and hasattr(adapter, "items"): for item in adapter.items(obj, node): yield from handle(item.node, item.value) return if not isinstance(obj, Unmanaged) and node is not None: new_token = value_to_token(obj) if self._file._token_of_node(node) != new_token: new_code = self._file._token_to_code(new_token) yield Replace( node=self._ast_node, file=self._file, new_code=new_code, flag="update", old_value=self._old_value, new_value=self._old_value, ) if self._file._source is not None: yield from handle(self._ast_node, self._old_value) # functions which determine the type def __eq__(self, other): from .._snapshot.eq_value import EqValue self._change(EqValue) return self == other def __le__(self, other): from .._snapshot.min_max_value import MinValue self._change(MinValue) return self <= other def __ge__(self, other): from .._snapshot.min_max_value import MaxValue self._change(MaxValue) return self >= other def __contains__(self, other): from .._snapshot.collection_value import CollectionValue self._change(CollectionValue) return other in self def __getitem__(self, item): from .._snapshot.dict_value import DictValue self._change(DictValue) return self[item] python-inline-snapshot-0.20.7/src/inline_snapshot/_source_file.py000066400000000000000000000024401476476140300252420ustar00rootroot00000000000000import tokenize from pathlib import Path from executing import Source from inline_snapshot._format import enforce_formatting from inline_snapshot._format import format_code from inline_snapshot._utils import normalize from inline_snapshot._utils import simple_token from inline_snapshot._utils import value_to_token from ._utils import ignore_tokens class SourceFile: _source: Source def __init__(self, source: Source): self._source = source @property def filename(self): return self._source.filename def _format(self, text): if self._source is None or enforce_formatting(): return text else: return format_code(text, Path(self._source.filename)) def asttokens(self): return self._source.asttokens() 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 _token_of_node(self, node): return list( normalize( [ simple_token(t.type, t.string) for t in self._source.asttokens().get_tokens(node) if t.type not in ignore_tokens ] ) ) python-inline-snapshot-0.20.7/src/inline_snapshot/_types.py000066400000000000000000000036151476476140300241140ustar00rootroot00000000000000"""The following types are for type checking only.""" from typing import TYPE_CHECKING from typing import Generic from typing import Literal from typing import TypeVar if TYPE_CHECKING: from typing_extensions import TypeAlias T = TypeVar("T") Snapshot: TypeAlias = T else: T = TypeVar("T") class Snapshot(Generic[T]): """Can be used to annotate function arguments which accept snapshot values. You can annotate function arguments with `Snapshot[T]` to declare that a snapshot-value can be passed as function argument. `Snapshot[T]` is a type alias for `T`, which allows you to pass `int` values instead of `int` snapshots. Example: ``` python from typing import Optional from inline_snapshot import snapshot, Snapshot # required snapshots def check_in_bounds(value, lower: Snapshot[int], upper: Snapshot[int]): assert lower <= value <= upper def test_numbers(): for c in "hello world": check_in_bounds(ord(c), snapshot(32), snapshot(119)) # use with normal values check_in_bounds(5, 0, 10) # optional snapshots def check_container( value, *, value_repr: Optional[Snapshot[str]] = None, length: Optional[Snapshot[int]] = None ): if value_repr is not None: assert repr(value) == value_repr if length is not None: assert len(value) == length def test_container(): check_container([1, 2], value_repr=snapshot("[1, 2]"), length=snapshot(2)) check_container({1, 1}, length=snapshot(1)) ``` """ Category = Literal["update", "fix", "create", "trim"] """See [categories](categories.md)""" python-inline-snapshot-0.20.7/src/inline_snapshot/_unmanaged.py000066400000000000000000000021271476476140300247040ustar00rootroot00000000000000from ._is import Is from ._types import Snapshot try: import dirty_equals # type: ignore except ImportError: # pragma: no cover def is_dirty_equal(value): return False else: def is_dirty_equal(value): return isinstance(value, dirty_equals.DirtyEquals) or ( isinstance(value, type) and issubclass(value, dirty_equals.DirtyEquals) ) def update_allowed(value): global unmanaged_types return not (is_dirty_equal(value) or isinstance(value, tuple(unmanaged_types))) # type: ignore unmanaged_types = [Is, Snapshot] def is_unmanaged(value): return not update_allowed(value) def declare_unmanaged(typ): global unmanaged_types unmanaged_types.append(typ) return typ class Unmanaged: def __init__(self, value): self.value = value def __eq__(self, other): assert not isinstance(other, Unmanaged) return self.value == other def __repr__(self): return repr(self.value) def map_unmanaged(value): if is_unmanaged(value): return Unmanaged(value) else: return value python-inline-snapshot-0.20.7/src/inline_snapshot/_utils.py000066400000000000000000000111211476476140300240770ustar00rootroot00000000000000import ast import io import token import tokenize from collections import namedtuple from ._code_repr import code_repr 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 simple_token(token.STRING, repr(current_string)) current_string = None yield t if current_string is not None: yield simple_token(token.STRING, repr(current_string)) def skip_trailing_comma(token_sequence): token_sequence = list(token_sequence) for index, token in enumerate(token_sequence): if index + 1 < len(token_sequence): next_token = token_sequence[index + 1] if token.string == "," and next_token.string in ("]", ")", "}"): continue yield token def normalize(token_sequence): return skip_trailing_comma(normalize_strings(token_sequence)) 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}" class simple_token(namedtuple("simple_token", "type,string")): def __eq__(self, other): if self.type == other.type == 3: if any( s.startswith(suffix) for s in (self.string, other.string) for suffix in ("f", "rf", "Rf", "F", "rF", "RF") ): return False return ast.literal_eval(self.string) == ast.literal_eval( other.string ) and self.string.replace("'", '"') == other.string.replace("'", '"') else: return super().__eq__(other) def value_to_token(value): input = io.StringIO(code_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 and s[-1] != "\n") or s.count("\n") > 1 ): # 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 ] python-inline-snapshot-0.20.7/src/inline_snapshot/extra.py000066400000000000000000000140031476476140300237250ustar00rootroot00000000000000"""The following functions are build on top of inline-snapshot and could also be implemented in an extra library. They are part of inline-snapshot because they are general useful and do not depend on other libraries. """ import contextlib import io import warnings from contextlib import redirect_stderr from contextlib import redirect_stdout from typing import List from typing import Tuple from typing import Union from inline_snapshot._types import Snapshot @contextlib.contextmanager def raises(exception: Snapshot[str]): """Check that an exception is raised. Parameters: exception: snapshot which is compared with `#!python f"{type}: {message}"` if an exception occured or `#!python ""` if no exception was raised. === "original" ``` python from inline_snapshot import snapshot from inline_snapshot.extra import raises def test_raises(): with raises(snapshot()): 1 / 0 ``` === "--inline-snapshot=create" ``` python hl_lines="6" from inline_snapshot import snapshot from inline_snapshot.extra import raises def test_raises(): with raises(snapshot("ZeroDivisionError: division by zero")): 1 / 0 ``` """ try: yield except Exception as ex: msg = str(ex) if "\n" in msg: assert f"{type(ex).__name__}:\n{ex}" == exception else: assert f"{type(ex).__name__}: {ex}" == exception else: assert "" == exception @contextlib.contextmanager def prints(*, stdout: Snapshot[str] = "", stderr: Snapshot[str] = ""): """Uses `contextlib.redirect_stderr/stdout` to capture the output and compare it with the snapshots. `dirty_equals.IsStr` can be used to ignore the output if needed. Parameters: stdout: snapshot which is compared to the recorded output stderr: snapshot which is compared to the recorded error output === "original" ``` python from inline_snapshot import snapshot from inline_snapshot.extra import prints import sys def test_prints(): with prints(stdout=snapshot(), stderr=snapshot()): print("hello world") print("some error", file=sys.stderr) ``` === "--inline-snapshot=create" ``` python hl_lines="7 8 9" from inline_snapshot import snapshot from inline_snapshot.extra import prints import sys def test_prints(): with prints( stdout=snapshot("hello world\\n"), stderr=snapshot("some error\\n") ): print("hello world") print("some error", file=sys.stderr) ``` === "ignore stdout" ``` python hl_lines="3 9 10" from inline_snapshot import snapshot from inline_snapshot.extra import prints from dirty_equals import IsStr import sys def test_prints(): with prints( stdout=IsStr(), stderr=snapshot("some error\\n"), ): print("hello world") print("some error", file=sys.stderr) ``` """ with redirect_stdout(io.StringIO()) as stdout_io: with redirect_stderr(io.StringIO()) as stderr_io: yield assert stderr_io.getvalue() == stderr assert stdout_io.getvalue() == stdout Warning = Union[str, Tuple[int, str], Tuple[str, str], Tuple[str, int, str]] @contextlib.contextmanager def warns( expected_warnings: Snapshot[List[Warning]], /, include_line: bool = False, include_file: bool = False, ): """ Captures warnings with `warnings.catch_warnings` and compares them against expected warnings. Parameters: expected_warnings: Snapshot containing a list of expected warnings. include_line: If `True`, each expected warning is a tuple `(linenumber, message)`. include_file: If `True`, each expected warning is a tuple `(filename, message)`. The format of the expected warning: - `(filename, linenumber, message)` if both `include_line` and `include_file` are `True`. - `(linenumber, message)` if only `include_line` is `True`. - `(filename, message)` if only `include_file` is `True`. - A string `message` if both are `False`. === "original" ``` python from inline_snapshot import snapshot from inline_snapshot.extra import warns from warnings import warn def test_warns(): with warns(snapshot(), include_line=True): warn("some problem") ``` === "--inline-snapshot=create" ``` python hl_lines="7" from inline_snapshot import snapshot from inline_snapshot.extra import warns from warnings import warn def test_warns(): with warns(snapshot([(8, "UserWarning: some problem")]), include_line=True): warn("some problem") ``` """ with warnings.catch_warnings(record=True) as result: warnings.simplefilter("always") yield def make_warning(w): message = f"{w.category.__name__}: {w.message}" if not include_line and not include_file: return message message = (message,) if include_line: message = (w.lineno, *message) if include_file: message = (w.filename, *message) return message assert [make_warning(w) for w in result] == expected_warnings python-inline-snapshot-0.20.7/src/inline_snapshot/py.typed000066400000000000000000000000001476476140300237170ustar00rootroot00000000000000python-inline-snapshot-0.20.7/src/inline_snapshot/pydantic_fix.py000066400000000000000000000011301476476140300252600ustar00rootroot00000000000000from ._types import Snapshot is_fixed = False def pydantic_fix(): global is_fixed if is_fixed: return # pragma: no cover is_fixed = True try: from pydantic import BaseModel except ImportError: # pragma: no cover return import pydantic if not pydantic.version.VERSION.startswith("1."): return origin_eq = BaseModel.__eq__ def new_eq(self, other): if isinstance(other, Snapshot): # type: ignore return other == self else: return origin_eq(self, other) BaseModel.__eq__ = new_eq python-inline-snapshot-0.20.7/src/inline_snapshot/pytest_plugin.py000066400000000000000000000316151476476140300255200ustar00rootroot00000000000000import ast import os import sys from pathlib import Path import pytest from executing import is_pytest_compatible from rich import box from rich.console import Console from rich.panel import Panel from rich.prompt import Confirm from rich.syntax import Syntax from inline_snapshot._problems import report_problems from inline_snapshot.pydantic_fix import pydantic_fix from . import _config from . import _external from . import _find_external from ._change import apply_all from ._code_repr import used_hasrepr from ._find_external import ensure_import from ._flags import Flags from ._global_state import snapshot_env from ._global_state import state from ._inline_snapshot import used_externals from ._rewrite_code import ChangeRecorder from ._snapshot.generic_value import GenericValue pytest.register_assert_rewrite("inline_snapshot.extra") pytest.register_assert_rewrite("inline_snapshot.testing._example") if sys.version_info >= (3, 13): # fixes #186 try: import readline # noqa except ModuleNotFoundError: # pragma: no cover # should fix #189 pass def pytest_addoption(parser, pluginmanager): group = parser.getgroup("inline-snapshot") group.addoption( "--inline-snapshot", metavar="(disable,short-report,report,review,create,update,trim,fix)*", dest="inline_snapshot", help="update specific snapshot values:\n" "disable: disable the snapshot logic\n" "short-report: show a short report\n" "report: show a longer report with a diff report\n" "review: allow to approve the changes interactive\n" "create: creates snapshots which are currently not defined\n" "update: update snapshots even if they are defined\n" "trim: changes the snapshot in a way which will make the snapshot more precise.\n" "fix: change snapshots which currently break your tests\n", ) config_path = Path("pyproject.toml") if config_path.exists(): config = _config.read_config(config_path) for name, value in config.shortcuts.items(): value = ",".join(value) group.addoption( f"--{name}", action="store_const", const=value, dest="inline_snapshot", help=f'shortcut for "--inline-snapshot={value}"', ) categories = Flags.all().to_set() flags = set() def xdist_running(config): return ( hasattr(config.option, "numprocesses") and config.option.numprocesses is not None ) def is_implementation_supported(): return sys.implementation.name == "cpython" def pytest_configure(config): global flags directory = config.rootpath while not (pyproject := directory / "pyproject.toml").exists(): if directory == directory.parent: break directory = directory.parent _config.config = _config.read_config(pyproject) if config.option.inline_snapshot is None: flags = set(_config.config.default_flags) else: flags = config.option.inline_snapshot.split(",") flags = {flag for flag in flags if flag} if xdist_running(config) and flags - {"disable"}: raise pytest.UsageError( f"--inline-snapshot={','.join(flags)} can not be combined with xdist" ) unknown_flags = flags - categories - {"disable", "review", "report", "short-report"} if unknown_flags: raise pytest.UsageError( f"--inline-snapshot={','.join(sorted(unknown_flags))} is a unknown flag" ) if "disable" in flags and flags != {"disable"}: raise pytest.UsageError( f"--inline-snapshot=disable can not be combined with other flags ({', '.join(flags-{'disable'})})" ) if xdist_running(config) or not is_implementation_supported(): state().active = False elif flags & {"review"}: state().active = True state().update_flags = Flags.all() else: state().active = "disable" not in flags state().update_flags = Flags(flags & categories) external_storage = ( _config.config.storage_dir or config.rootpath / ".inline-snapshot" ) / "external" state().storage = _external.DiscStorage(external_storage) if flags - {"short-report", "disable"} and not is_pytest_compatible(): # hack to disable the assertion rewriting # I found no other way because the hook gets installed early sys.meta_path = [ e for e in sys.meta_path if type(e).__name__ != "AssertionRewritingHook" ] pydantic_fix() state().storage.prune_new_files() def is_xfail(request): if not "xfail" in request.keywords: return False xfail = request.keywords["xfail"] if xfail.args and xfail.args[0] == False: return False return True @pytest.fixture(autouse=True) def snapshot_check(request): state().missing_values = 0 state().incorrect_values = 0 if is_xfail(request): with snapshot_env() as local_state: local_state.active = False yield return else: yield missing_values = state().missing_values incorrect_values = state().incorrect_values if missing_values != 0: pytest.fail( ( "your snapshot is missing one value." if missing_values == 1 else f"your snapshot is missing {missing_values} values." ), pytrace=False, ) if incorrect_values != 0: pytest.fail( "some snapshots in this test have incorrect values.", pytrace=False, ) def pytest_assertrepr_compare(config, op, left, right): results = [] if isinstance(left, GenericValue): results = config.hook.pytest_assertrepr_compare( config=config, op=op, left=left._visible_value(), right=right ) if isinstance(right, GenericValue): results = config.hook.pytest_assertrepr_compare( config=config, op=op, left=left, right=right._visible_value() ) external_used = False if isinstance(right, _external.external): external_used = True if right._suffix == ".txt": right = right._load_value().decode() else: right = right._load_value() if isinstance(left, _external.external): external_used = True if left._suffix == ".txt": left = left._load_value().decode() else: left = left._load_value() if external_used: results = config.hook.pytest_assertrepr_compare( config=config, op=op, left=left, right=right ) if results: return results[0] def pytest_terminal_summary(terminalreporter, exitstatus, config): if xdist_running(config): if flags != {"disable"}: terminalreporter.section("inline snapshot") terminalreporter.write( "INFO: inline-snapshot was disabled because you used xdist\n" ) return if not is_implementation_supported(): if flags != {"disable"}: terminalreporter.section("inline snapshot") terminalreporter.write( f"INFO: inline-snapshot was disabled because {sys.implementation.name} is not supported\n" ) return if not state().active: return terminalreporter.section("inline snapshot") capture = config.pluginmanager.getplugin("capturemanager") # --inline-snapshot def apply_changes(flag): if flag in flags: console.print( f"These changes will be applied, because you used [b]--inline-snapshot={flag}[/]", highlight=False, ) console.print() return True if "review" in flags: result = Confirm.ask( f"[bold]do you want to [blue]{flag}[/] these snapshots?[/]", default=False, ) console.print() return result else: console.print("These changes are not applied.") console.print( f"Use [b]--inline-snapshot={flag}[/] to apply them, or use the interactive mode with [b]--inline-snapshot=review[/]", highlight=False, ) console.print() return False # auto mode changes = {f: [] for f in Flags.all()} snapshot_changes = {f: 0 for f in Flags.all()} for snapshot in state().snapshots.values(): all_categories = set() for change in snapshot._changes(): changes[change.flag].append(change) all_categories.add(change.flag) for category in all_categories: snapshot_changes[category] += 1 capture.suspend_global_capture(in_=True) try: console = Console( highlight=False, ) if "short-report" in flags: def report(flag, message, message_n): num = snapshot_changes[flag] if num and not getattr(state().update_flags, flag): console.print( message if num == 1 else message.format(num=num), highlight=False, ) report( "fix", "Error: one snapshot has incorrect values ([b]--inline-snapshot=fix[/])", "Error: {num} snapshots have incorrect values ([b]--inline-snapshot=fix[/])", ) report( "trim", "Info: one snapshot can be trimmed ([b]--inline-snapshot=trim[/])", "Info: {num} snapshots can be trimmed ([b]--inline-snapshot=trim[/])", ) report( "create", "Error: one snapshot is missing a value ([b]--inline-snapshot=create[/])", "Error: {num} snapshots are missing values ([b]--inline-snapshot=create[/])", ) report( "update", "Info: one snapshot changed its representation ([b]--inline-snapshot=update[/])", "Info: {num} snapshots changed their representation ([b]--inline-snapshot=update[/])", ) if sum(snapshot_changes.values()) != 0: console.print( "\nYou can also use [b]--inline-snapshot=review[/] to approve the changes interactively", highlight=False, ) return if not is_pytest_compatible(): assert not any( type(e).__name__ == "AssertionRewritingHook" for e in sys.meta_path ) used_changes = [] for flag in Flags.all(): if not changes[flag]: continue if not {"review", "report", flag} & flags: continue console.rule(f"[yellow bold]{flag.capitalize()} snapshots") cr = ChangeRecorder() apply_all(used_changes, cr) cr.virtual_write() apply_all(changes[flag], cr) for file in cr.files(): diff = file.diff() if diff: name = file.filename.relative_to(Path.cwd()) console.print( Panel( Syntax(diff, "diff", theme="ansi_light"), title=str(name), box=( box.ASCII if os.environ.get("TERM", "") == "unknown" else box.ROUNDED ), ) ) if apply_changes(flag): used_changes += changes[flag] report_problems(console) if used_changes: cr = ChangeRecorder() apply_all(used_changes, cr) for test_file in cr.files(): tree = ast.parse(test_file.new_code()) used = used_externals(tree) required_imports = [] if used: required_imports.append("external") if used_hasrepr(tree): required_imports.append("HasRepr") if required_imports: ensure_import( test_file.filename, {"inline_snapshot": required_imports}, cr, ) for external_name in used: state().storage.persist(external_name) cr.fix_all() unused_externals = _find_external.unused_externals() if unused_externals and state().update_flags.trim: for name in unused_externals: assert state().storage state().storage.remove(name) terminalreporter.write( f"removed {len(unused_externals)} unused externals\n" ) finally: capture.resume_global_capture() return python-inline-snapshot-0.20.7/src/inline_snapshot/syntax_warnings.py000066400000000000000000000001431476476140300260400ustar00rootroot00000000000000class InlineSnapshotSyntaxWarning(Warning): pass class InlineSnapshotInfo(Warning): pass python-inline-snapshot-0.20.7/src/inline_snapshot/testing/000077500000000000000000000000001476476140300237075ustar00rootroot00000000000000python-inline-snapshot-0.20.7/src/inline_snapshot/testing/__init__.py000066400000000000000000000000661476476140300260220ustar00rootroot00000000000000from ._example import Example __all__ = ("Example",) python-inline-snapshot-0.20.7/src/inline_snapshot/testing/_example.py000066400000000000000000000251531476476140300260610ustar00rootroot00000000000000from __future__ import annotations import os import platform import re import subprocess as sp import sys import traceback from argparse import ArgumentParser from io import StringIO from pathlib import Path from pathlib import PurePosixPath from tempfile import TemporaryDirectory from typing import Any from rich.console import Console from inline_snapshot._exceptions import UsageError from inline_snapshot._external import DiscStorage from inline_snapshot._problems import report_problems from .._change import apply_all from .._flags import Flags from .._global_state import snapshot_env from .._rewrite_code import ChangeRecorder from .._types import Category from .._types import Snapshot ansi_escape = re.compile( r""" \x1B # ESC (?: # 7-bit C1 Fe (except CSI) [@-Z\\-_] | # or [ for CSI, followed by a control sequence \[ [0-?]* # Parameter bytes [ -/]* # Intermediate bytes [@-~] # Final byte ) """, re.VERBOSE, ) def normalize(text): text = ansi_escape.sub("", text) # fix windows problems text = text.replace("\u2500", "-") text = text.replace("\r", "") text = text.replace(" \n", " ⏎\n") return text class Example: def __init__(self, files: str | dict[str, str]): """ Parameters: files: a collecton of files where inline-snapshot opperates on, or just a string which will be saved as *test_something.py*. """ if isinstance(files, str): files = {"test_something.py": files} self.files = files self.dump_files() def dump_files(self): for name, content in self.files.items(): print(f"file: {name}") print(content) print() def _write_files(self, dir: Path): for name, content in self.files.items(): filename = dir / name filename.parent.mkdir(exist_ok=True, parents=True) filename.write_text(content) def _read_files(self, dir: Path): return { str(p.relative_to(dir)): p.read_text() for p in [*dir.iterdir(), *dir.rglob("*.py")] if p.is_file() } def run_inline( self, args: list[str] = [], *, reported_categories: Snapshot[list[Category]] | None = None, changed_files: Snapshot[dict[str, str]] | None = None, report: Snapshot[str] | None = None, raises: Snapshot[str] | None = None, ) -> Example: """Execute the example files in process and run every `test_*` function. This is useful for fast test execution. Parameters: args: inline-snapshot arguments (supports only "--inline-snapshot=fix|create|..." ). reported_categories: snapshot of categories which inline-snapshot thinks could be applied. changed_files: snapshot of files which are changed by this run. raises: snapshot of the exception which is raised during the test execution. It is required if your code raises an exception. Returns: A new Example instance which contains the changed files. """ parser = ArgumentParser() parser.add_argument( "--inline-snapshot", metavar="(disable,short-report,report,review,create,update,trim,fix)*", dest="inline_snapshot", help="update specific snapshot values:\n" "disable: disable the snapshot logic\n" "short-report: show a short report\n" "report: show a longer report with a diff report\n" "review: allow to approve the changes interactive\n" "create: creates snapshots which are currently not defined\n" "update: update snapshots even if they are defined\n" "trim: changes the snapshot in a way which will make the snapshot more precise.\n" "fix: change snapshots which currently break your tests\n", ) parsed_args = parser.parse_args(args) flags = (parsed_args.inline_snapshot or "").split(",") with TemporaryDirectory() as dir: tmp_path = Path(dir) self._write_files(tmp_path) raised_exception = [] with snapshot_env() as state: recorder = ChangeRecorder() state.update_flags = Flags({*flags}) state.storage = DiscStorage(tmp_path / ".storage") try: tests_found = False for filename in tmp_path.glob("*.py"): globals: dict[str, Any] = {} print("run> pytest", filename) exec( compile(filename.read_text("utf-8"), filename, "exec"), globals, ) # run all test_* functions tests = [ v for k, v in globals.items() if (k.startswith("test_") or k == "test") and callable(v) ] tests_found |= len(tests) != 0 for v in tests: try: v() except Exception as e: traceback.print_exc() raised_exception.append(e) if not tests_found: raise UsageError("no test_*() functions in the example") finally: state.active = False changes = [] for snapshot in state.snapshots.values(): changes += snapshot._changes() snapshot_flags = {change.flag for change in changes} apply_all( [ change for change in changes if change.flag in state.update_flags.to_set() ], recorder, ) recorder.fix_all() report_output = StringIO() console = Console(file=report_output, width=80) # TODO: add all the report output here report_problems(console) if reported_categories is not None: assert sorted(snapshot_flags) == reported_categories if raised_exception: if raises is None: raise raised_exception[0] assert raises == "\n".join( f"{type(e).__name__}:\n" + str(e) for e in raised_exception ) else: assert raises == None if changed_files is not None: current_files = {} for name, content in sorted(self._read_files(tmp_path).items()): if name not in self.files or self.files[name] != content: current_files[name] = content assert changed_files == current_files if report is not None: assert report == normalize(report_output.getvalue()) return Example(self._read_files(tmp_path)) def run_pytest( self, args: list[str] = [], *, term_columns=80, env: dict[str, str] = {}, changed_files: Snapshot[dict[str, str]] | None = None, report: Snapshot[str] | None = None, stderr: Snapshot[str] | None = None, returncode: Snapshot[int] | None = None, ) -> Example: """Run pytest with the given args and env variables in an seperate process. It can be used to test the interaction between your code and pytest, but it is a bit slower than `run_inline` Parameters: args: pytest arguments like "--inline-snapshot=fix" env: dict of environment variables changed_files: snapshot of files which are changed by this run. report: snapshot of the report at the end of the pytest run. stderr: pytest stderr output returncode: snapshot of the pytest returncode. Returns: A new Example instance which contains the changed files. """ with TemporaryDirectory() as dir: tmp_path = Path(dir) self._write_files(tmp_path) cmd = [sys.executable, "-m", "pytest", *args] command_env = dict(os.environ) command_env["TERM"] = "unknown" command_env["COLUMNS"] = str( term_columns + 1 if platform.system() == "Windows" else term_columns ) command_env.pop("CI", None) command_env.update(env) result = sp.run(cmd, cwd=tmp_path, capture_output=True, env=command_env) print("run>", *cmd) print("stdout:") print(result.stdout.decode()) print("stderr:") print(result.stderr.decode()) if returncode is not None: assert result.returncode == returncode if stderr is not None: original = result.stderr.decode().splitlines() lines = [ line for line in original if not any( s in line for s in [ 'No entry for terminal type "unknown"', "using dumb terminal settings.", ] ) ] assert "\n".join(lines) == stderr if report is not None: report_list = [] record = False for line in result.stdout.decode().splitlines(): line = line.strip() if line.startswith("===="): record = False if record and line: report_list.append(line) if line.startswith("====") and "inline snapshot" in line: record = True report_str = "\n".join(report_list) assert normalize(report_str) == report, repr(report_str) if changed_files is not None: current_files = {} for name, content in sorted(self._read_files(tmp_path).items()): if name not in self.files or self.files[name] != content: current_files[str(PurePosixPath(*Path(name).parts))] = content assert changed_files == current_files return Example(self._read_files(tmp_path)) python-inline-snapshot-0.20.7/testing/000077500000000000000000000000001476476140300177235ustar00rootroot00000000000000python-inline-snapshot-0.20.7/testing/generate_tests.py000066400000000000000000000061761476476140300233230ustar00rootroot00000000000000import ast import random import sys from pysource_minimize import minimize from inline_snapshot import UsageError from inline_snapshot.testing import Example header = """ from dataclasses import dataclass @dataclass class A: a:int b:int=5 c:int=5 def f(v): return v """ def chose(l): return random.choice(l) def gen_new_value(values): kind = chose(["dataclass", "dict", "tuple", "list", "f"]) if kind == "dataclass": typ = chose(["A"]) num_args = chose(range(1, 3)) num_pos_args = chose(range(num_args)) assert num_pos_args <= num_args args_names = ["a", "b", "c"] random.shuffle(args_names) args = [ *[chose(values) for _ in range(num_pos_args)], *[ f"{args_names.pop()}={chose(values)}" for _ in range(num_args - num_pos_args) ], ] return f"{typ}({', '.join(args)})" if kind == "tuple": return "(" + ", ".join(chose(values) for _ in range(3)) + ")" if kind == "list": return "[" + ", ".join(chose(values) for _ in range(3)) + "]" if kind == "dict": return ( "[" + ", ".join(f"{chose(values)}:{chose(values)}" for _ in range(3)) + "]" ) if kind == "f": return f"f({chose(values)})" context = dict() exec(header, context, context) def is_valid_value(v): try: eval(v, context, {}) except: return False return True def value_of(v): return eval(v, context, {}) def gen_values(): values = [ "1", "'abc'", "True", "list()", "dict()", "set()", "tuple()", "[]", "{}", "()", "{*()}", ] while len(values) <= 500: new_value = gen_new_value([v for v in values if len(v) < 50]) if is_valid_value(new_value): values.append(new_value) return values def fmt(code): return ast.unparse(ast.parse(code)) class Store: def __eq__(self, other): self.value = other return True def gen_test(values): va = chose(values) vb = chose(values) test = f""" from inline_snapshot import snapshot {header} def test_a(): assert {va} == snapshot({vb}) assert {vb} == snapshot({va}) def test_b(): for _ in [1,2,3]: assert {va} == snapshot({vb}) assert {vb} == snapshot({va}) def test_c(): snapshot({vb}) snapshot({va}) """ test = fmt(test) if value_of(va) == value_of(vb): return def contains_bug(code): try: Example({"test.py": code}).run_inline(["--inline-snapshot=fix"]) except UsageError: return False except KeyboardInterrupt: raise except AssertionError: return True except: return True return False if not contains_bug(test): return test = minimize(test, checker=contains_bug) print("minimal code:") print("=" * 20) print(test) sys.exit() if __name__ == "__main__": values = gen_values() for i in range(100000): gen_test(values) python-inline-snapshot-0.20.7/tests/000077500000000000000000000000001476476140300174105ustar00rootroot00000000000000python-inline-snapshot-0.20.7/tests/__init__.py000066400000000000000000000000001476476140300215070ustar00rootroot00000000000000python-inline-snapshot-0.20.7/tests/_is_normalized.py000066400000000000000000000010621476476140300227570ustar00rootroot00000000000000from inline_snapshot._unmanaged import declare_unmanaged @declare_unmanaged class IsNormalized: def __init__(self, func, value) -> None: self._func = func self._value = value self._last_value = None def __eq__(self, other) -> bool: self._last_value = self._func(other) return self._last_value == self._value def __repr__(self): return f"IsNormalized({self._value}, should_be={self._last_value!r})" def normalization(func): def f(value): return IsNormalized(func, value) return f python-inline-snapshot-0.20.7/tests/adapter/000077500000000000000000000000001476476140300210305ustar00rootroot00000000000000python-inline-snapshot-0.20.7/tests/adapter/test_change_types.py000066400000000000000000000020251476476140300251110ustar00rootroot00000000000000import pytest from inline_snapshot.testing._example import Example values = ["1", '"2\'"', "[5]", "{1: 2}", "F(i=5)", "F.make1('2')", "f(7)"] @pytest.mark.parametrize("a", values) @pytest.mark.parametrize("b", values + ["F.make2(Is(5))"]) def test_change_types(a, b): context = """\ from inline_snapshot import snapshot, Is from dataclasses import dataclass @dataclass class F: i: int @staticmethod def make1(s): return F(i=int(s)) @staticmethod def make2(s): return F(i=s) def f(v): return v """ def code_repr(v): g = {} exec(context + f"r=repr({a})", g) return g["r"] def code(a, b): return f"""\ {context} def test_change(): for _ in [1,2]: assert {a} == snapshot({b}) """ print(a, b) print(code_repr(a), code_repr(b)) Example(code(a, b)).run_inline( ["--inline-snapshot=fix,update"], changed_files=( {"test_something.py": code(a, code_repr(a))} if code_repr(a) != b else {} ), ) python-inline-snapshot-0.20.7/tests/adapter/test_dataclass.py000066400000000000000000000304211476476140300244000ustar00rootroot00000000000000from inline_snapshot import snapshot from inline_snapshot.extra import warns from inline_snapshot.testing._example import Example def test_unmanaged(): Example( """\ from inline_snapshot import snapshot,Is from dataclasses import dataclass @dataclass class A: a:int b:int def test_something(): assert A(a=2,b=4) == snapshot(A(a=1,b=Is(1))), "not equal" """ ).run_inline( ["--inline-snapshot=fix"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot,Is from dataclasses import dataclass @dataclass class A: a:int b:int def test_something(): assert A(a=2,b=4) == snapshot(A(a=2,b=Is(1))), "not equal" """ } ), raises=snapshot( """\ AssertionError: not equal\ """ ), ) def test_reeval(): Example( """\ from inline_snapshot import snapshot,Is from dataclasses import dataclass @dataclass class A: a:int b:int def test_something(): for c in "ab": assert A(a=1,b=c) == snapshot(A(a=2,b=Is(c))) """ ).run_inline( ["--inline-snapshot=fix"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot,Is from dataclasses import dataclass @dataclass class A: a:int b:int def test_something(): for c in "ab": assert A(a=1,b=c) == snapshot(A(a=1,b=Is(c))) """ } ), ) def test_dataclass_default_value(): Example( """\ from inline_snapshot import snapshot,Is from dataclasses import dataclass,field @dataclass class A: a:int b:int=2 c:list=field(default_factory=list) def test_something(): for _ in [1,2]: assert A(a=1) == snapshot(A(a=1,b=2,c=[])) """ ).run_inline( ["--inline-snapshot=update"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot,Is from dataclasses import dataclass,field @dataclass class A: a:int b:int=2 c:list=field(default_factory=list) def test_something(): for _ in [1,2]: assert A(a=1) == snapshot(A(a=1)) """ } ), ) def test_dataclass_positional_arguments(): Example( """\ from inline_snapshot import snapshot,Is from dataclasses import dataclass,field @dataclass class A: a:int b:int=2 c:list=field(default_factory=list) def test_something(): for _ in [1,2]: assert A(a=1) == snapshot(A(1,2,c=[])) """ ).run_inline( ["--inline-snapshot=update"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot,Is from dataclasses import dataclass,field @dataclass class A: a:int b:int=2 c:list=field(default_factory=list) def test_something(): for _ in [1,2]: assert A(a=1) == snapshot(A(1,2)) """ } ), ) def test_attrs_default_value(): Example( """\ from inline_snapshot import snapshot,Is import attrs @attrs.define class A: a:int b:int=2 c:list=attrs.field(factory=list) d:int=attrs.field(default=attrs.Factory(lambda self:self.a+10,takes_self=True)) def test_something(): assert A(a=1) == snapshot(A(a=1,b=2,c=[],d=11)) assert A(a=2,b=3) == snapshot(A(a=1,b=2,c=[],d=11)) """ ).run_pytest( ["--inline-snapshot=fix"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot,Is import attrs @attrs.define class A: a:int b:int=2 c:list=attrs.field(factory=list) d:int=attrs.field(default=attrs.Factory(lambda self:self.a+10,takes_self=True)) def test_something(): assert A(a=1) == snapshot(A(a=1,b=2,c=[],d=11)) assert A(a=2,b=3) == snapshot(A(a=2,b=3,c=[],d=11)) """ } ), ).run_pytest( ["--inline-snapshot=update"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot,Is import attrs @attrs.define class A: a:int b:int=2 c:list=attrs.field(factory=list) d:int=attrs.field(default=attrs.Factory(lambda self:self.a+10,takes_self=True)) def test_something(): assert A(a=1) == snapshot(A(a=1)) assert A(a=2,b=3) == snapshot(A(a=2,b=3)) """ } ), ) def test_attrs_field_repr(): Example( """\ from inline_snapshot import snapshot import attrs @attrs.define class container: a: int b: int = attrs.field(default=5,repr=False) assert container(a=1,b=5) == snapshot() """ ).run_pytest( ["--inline-snapshot=create"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot import attrs @attrs.define class container: a: int b: int = attrs.field(default=5,repr=False) assert container(a=1,b=5) == snapshot(container(a=1)) """ } ), ).run_pytest() def test_attrs_unmanaged(): Example( """\ import datetime as dt import uuid import attrs from dirty_equals import IsDatetime from inline_snapshot import Is, snapshot @attrs.define class Attrs: ts: dt.datetime id: uuid.UUID def test(): id = uuid.uuid4() assert snapshot(Attrs(ts=IsDatetime(), id=Is(id))) == Attrs( dt.datetime.now(), id ) """ ).run_pytest( ["--inline-snapshot=create,fix"], changed_files=snapshot({}), ).run_pytest() def test_disabled(executing_used): Example( """\ from inline_snapshot import snapshot from dataclasses import dataclass @dataclass class A: a:int def test_something(): assert A(a=3) == snapshot(A(a=5)),"not equal" """ ).run_inline( changed_files=snapshot({}), raises=snapshot( """\ AssertionError: not equal\ """ ), ) def test_starred_warns(): with warns( snapshot( [ ( 10, "InlineSnapshotSyntaxWarning: star-expressions are not supported inside snapshots", ) ] ), include_line=True, ): Example( """ from inline_snapshot import snapshot from dataclasses import dataclass @dataclass class A: a:int def test_something(): assert A(a=3) == snapshot(A(**{"a":5})),"not equal" """ ).run_inline( ["--inline-snapshot=fix"], raises=snapshot( """\ AssertionError: not equal\ """ ), ) def test_add_argument(): Example( """\ from inline_snapshot import snapshot from dataclasses import dataclass @dataclass class A: a:int=0 b:int=0 c:int=0 def test_something(): assert A(a=3,b=3,c=3) == snapshot(A(b=3)),"not equal" """ ).run_inline( ["--inline-snapshot=fix"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot from dataclasses import dataclass @dataclass class A: a:int=0 b:int=0 c:int=0 def test_something(): assert A(a=3,b=3,c=3) == snapshot(A(a = 3, b=3, c = 3)),"not equal" """ } ), raises=snapshot(None), ) def test_positional_star_args(): with warns( snapshot( [ "InlineSnapshotSyntaxWarning: star-expressions are not supported inside snapshots" ] ) ): Example( """\ from inline_snapshot import snapshot from dataclasses import dataclass @dataclass class A: a:int def test_something(): assert A(a=3) == snapshot(A(*[],a=3)),"not equal" """ ).run_inline( ["--inline-snapshot=report"], ) def test_remove_positional_argument(): Example( """\ from inline_snapshot import snapshot from inline_snapshot._adapter.generic_call_adapter import GenericCallAdapter,Argument class L: def __init__(self,*l): self.l=l def __eq__(self,other): if not isinstance(other,L): return NotImplemented return other.l==self.l class LAdapter(GenericCallAdapter): @classmethod def check_type(cls, typ): return issubclass(typ,L) @classmethod def arguments(cls, value): return ([Argument(x) for x in value.l],{}) @classmethod def argument(cls, value, pos_or_name): assert isinstance(pos_or_name,int) return value.l[pos_or_name] def test_L1(): for _ in [1,2]: assert L(1,2) == snapshot(L(1)), "not equal" def test_L2(): for _ in [1,2]: assert L(1,2) == snapshot(L(1, 2, 3)), "not equal" def test_L3(): for _ in [1,2]: assert L(1,2) == snapshot(L(1, 2)), "not equal" """ ).run_pytest().run_pytest( ["--inline-snapshot=fix"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot from inline_snapshot._adapter.generic_call_adapter import GenericCallAdapter,Argument class L: def __init__(self,*l): self.l=l def __eq__(self,other): if not isinstance(other,L): return NotImplemented return other.l==self.l class LAdapter(GenericCallAdapter): @classmethod def check_type(cls, typ): return issubclass(typ,L) @classmethod def arguments(cls, value): return ([Argument(x) for x in value.l],{}) @classmethod def argument(cls, value, pos_or_name): assert isinstance(pos_or_name,int) return value.l[pos_or_name] def test_L1(): for _ in [1,2]: assert L(1,2) == snapshot(L(1, 2)), "not equal" def test_L2(): for _ in [1,2]: assert L(1,2) == snapshot(L(1, 2)), "not equal" def test_L3(): for _ in [1,2]: assert L(1,2) == snapshot(L(1, 2)), "not equal" """ } ), ) def test_namedtuple(): Example( """\ from inline_snapshot import snapshot from collections import namedtuple T=namedtuple("T","a,b") def test_tuple(): assert T(a=1,b=2) == snapshot(T(a=1, b=3)), "not equal" """ ).run_pytest( ["--inline-snapshot=fix"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot from collections import namedtuple T=namedtuple("T","a,b") def test_tuple(): assert T(a=1,b=2) == snapshot(T(a=1, b=2)), "not equal" """ } ), ) def test_defaultdict(): Example( """\ from inline_snapshot import snapshot from collections import defaultdict def test_tuple(): d=defaultdict(list) d[1].append(2) assert d == snapshot(defaultdict(list, {1: [3]})), "not equal" """ ).run_pytest( ["--inline-snapshot=fix"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot from collections import defaultdict def test_tuple(): d=defaultdict(list) d[1].append(2) assert d == snapshot(defaultdict(list, {1: [2]})), "not equal" """ } ), ) def test_dataclass_field_repr(): Example( """\ from inline_snapshot import snapshot from dataclasses import dataclass,field @dataclass class container: a: int b: int = field(default=5,repr=False) def test(): assert container(a=1,b=5) == snapshot() """ ).run_inline( ["--inline-snapshot=create"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot from dataclasses import dataclass,field @dataclass class container: a: int b: int = field(default=5,repr=False) def test(): assert container(a=1,b=5) == snapshot(container(a=1)) """ } ), ).run_inline() def test_dataclass_var(): Example( """\ from inline_snapshot import snapshot,Is from dataclasses import dataclass,field @dataclass class container: a: int def test_list(): l=container(5) assert l == snapshot(l), "not equal" """ ).run_inline( ["--inline-snapshot=update"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot,Is from dataclasses import dataclass,field @dataclass class container: a: int def test_list(): l=container(5) assert l == snapshot(container(a=5)), "not equal" """ } ), ) python-inline-snapshot-0.20.7/tests/adapter/test_dict.py000066400000000000000000000014061476476140300233650ustar00rootroot00000000000000from inline_snapshot import snapshot from inline_snapshot.testing import Example def test_dict_var(): Example( """\ from inline_snapshot import snapshot,Is def test_list(): l={1:2} assert l == snapshot(l), "not equal" """ ).run_inline( ["--inline-snapshot=update"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot,Is def test_list(): l={1:2} assert l == snapshot({1: 2}), "not equal" """ } ), ) def test_dict_constructor(): Example( """\ from inline_snapshot import snapshot def test_dict(): snapshot(dict()) """ ).run_inline( ["--inline-snapshot=fix"], changed_files=snapshot({}), ) python-inline-snapshot-0.20.7/tests/adapter/test_general.py000066400000000000000000000014761476476140300240660ustar00rootroot00000000000000from inline_snapshot import snapshot from inline_snapshot.testing import Example def test_adapter_mismatch(): Example( """\ from inline_snapshot import snapshot def test_thing(): assert [1,2] == snapshot({1:2}) """ ).run_inline( ["--inline-snapshot=fix"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot def test_thing(): assert [1,2] == snapshot([1, 2]) \ """ } ), ) def test_reeval(): Example( """\ from inline_snapshot import snapshot,Is def test_thing(): for i in (1,2): assert {1:i} == snapshot({1:Is(i)}) assert [i] == [Is(i)] assert (i,) == (Is(i),) """ ).run_pytest(["--inline-snapshot=short-report"], report=snapshot("")) python-inline-snapshot-0.20.7/tests/adapter/test_sequence.py000066400000000000000000000044751476476140300242630ustar00rootroot00000000000000import pytest from inline_snapshot._inline_snapshot import snapshot from inline_snapshot.testing._example import Example def test_list_adapter_create_inner_snapshot(): Example( """\ from inline_snapshot import snapshot from dirty_equals import IsInt def test_list(): assert [1,2,3,4] == snapshot([1,IsInt(),snapshot(),4]),"not equal" """ ).run_inline( ["--inline-snapshot=create"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot from dirty_equals import IsInt def test_list(): assert [1,2,3,4] == snapshot([1,IsInt(),snapshot(3),4]),"not equal" """ } ), raises=snapshot(None), ) def test_list_adapter_fix_inner_snapshot(): Example( """\ from inline_snapshot import snapshot from dirty_equals import IsInt def test_list(): assert [1,2,3,4] == snapshot([1,IsInt(),snapshot(8),4]),"not equal" """ ).run_inline( ["--inline-snapshot=fix"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot from dirty_equals import IsInt def test_list(): assert [1,2,3,4] == snapshot([1,IsInt(),snapshot(3),4]),"not equal" """ } ), raises=snapshot(None), ) @pytest.mark.no_rewriting def test_list_adapter_reeval(executing_used): Example( """\ from inline_snapshot import snapshot,Is def test_list(): for i in (1,2,3): assert [1,i] == snapshot([1,Is(i)]),"not equal" """ ).run_inline( changed_files=snapshot({}), raises=snapshot(None), ) def test_list_var(): Example( """\ from inline_snapshot import snapshot,Is def test_list(): l=[1] assert l == snapshot(l), "not equal" """ ).run_inline( ["--inline-snapshot=update"], changed_files=snapshot( { "test_something.py": """\ from inline_snapshot import snapshot,Is def test_list(): l=[1] assert l == snapshot([1]), "not equal" """ } ), ) def test_tuple_constructor(): Example( """\ from inline_snapshot import snapshot def test_tuple(): snapshot(tuple()), "not equal" """ ).run_inline( ["--inline-snapshot=fix"], changed_files=snapshot({}), ) python-inline-snapshot-0.20.7/tests/conftest.py000066400000000000000000000254361476476140300216210ustar00rootroot00000000000000import os import platform import re import shutil import sys import textwrap import traceback from dataclasses import dataclass from dataclasses import field from pathlib import Path from types import SimpleNamespace from typing import Set from unittest import mock import black import executing import pytest import inline_snapshot._external from inline_snapshot._change import apply_all from inline_snapshot._flags import Flags from inline_snapshot._format import format_code from inline_snapshot._rewrite_code import ChangeRecorder from inline_snapshot._types import Category from inline_snapshot.testing._example import snapshot_env pytest_plugins = "pytester" pytest.register_assert_rewrite("tests.example") black.files.find_project_root = black.files.find_project_root.__wrapped__ # type: ignore @pytest.fixture(autouse=True) def check_pypy(request): implementation = sys.implementation.name node = request.node if implementation != "cpython" and node.get_closest_marker("no_rewriting") is None: pytest.skip(f"{implementation} is not supported") yield @pytest.fixture() def check_update(source): def w(source_code, *, flags="", reported_flags=None, number=1): s = source(source_code) flags = {*flags.split(",")} - {""} if reported_flags is None: reported_flags = flags else: reported_flags = {*reported_flags.split(",")} - {""} assert s.flags == reported_flags assert s.number_snapshots == number assert s.error == ("fix" in s.flags) s2 = s.run(*flags) return s2.source return w @pytest.fixture() def source(tmp_path: Path): filecount = 1 @dataclass class Source: source: str flags: Set[str] = field(default_factory=set) error: bool = False number_snapshots: int = 0 number_changes: int = 0 def run(self, *flags_arg: Category): flags = Flags({*flags_arg}) nonlocal filecount filename: Path = tmp_path / f"test_{filecount}.py" filecount += 1 prefix = """\"\"\" PYTEST_DONT_REWRITE \"\"\" # äöß 🐍 from inline_snapshot import snapshot from inline_snapshot import external from inline_snapshot import outsource """ source = prefix + textwrap.dedent(self.source) filename.write_text(source, "utf-8") print() print("input:") print(textwrap.indent(source, " |", lambda line: True).rstrip()) with snapshot_env() as state: recorder = ChangeRecorder() state.update_flags = flags state.storage = inline_snapshot._external.DiscStorage( tmp_path / ".storage" ) error = False try: exec(compile(filename.read_text("utf-8"), filename, "exec"), {}) except AssertionError: traceback.print_exc() error = True finally: state.active = False number_snapshots = len(state.snapshots) changes = [] for snapshot in state.snapshots.values(): changes += snapshot._changes() snapshot_flags = {change.flag for change in changes} apply_all( [ change for change in changes if change.flag in state.update_flags.to_set() ], recorder, ) recorder.fix_all() source = filename.read_text("utf-8")[len(prefix) :] print("reported_flags:", snapshot_flags) print( f'run: pytest --inline-snapshot={",".join(flags.to_set())}' if flags else "" ) print("output:") print(textwrap.indent(source, " |", lambda line: True).rstrip()) return Source( source=source, flags=snapshot_flags, error=error, number_snapshots=number_snapshots, number_changes=len(changes), ) def w(source): return Source(source=source).run() return w ansi_escape = re.compile( r""" \x1B # ESC (?: # 7-bit C1 Fe (except CSI) [@-Z\\-_] | # or [ for CSI, followed by a control sequence \[ [0-?]* # Parameter bytes [ -/]* # Intermediate bytes [@-~] # Final byte ) """, re.VERBOSE, ) class RunResult: def __init__(self, result): self._result = result def __getattr__(self, name): return getattr(self._result, name) @staticmethod def _join_lines(lines): text = "\n".join(lines).rstrip() if "\n" in text: return text + "\n" else: return text @property def report(self): result = [] record = False for line in self._result.stdout.lines: line = line.strip() if line.startswith("===="): record = False if record and line: result.append(line) if line.startswith("====") and "inline snapshot" in line: record = True result = self._join_lines(result) result = ansi_escape.sub("", result) # fix windows problems result = result.replace("\u2500", "-") result = result.replace("\r", "") result = result.replace(" \n", " ⏎\n") return result @property def errors(self): result = [] record = False for line in self._result.stdout.lines: line = line.strip() if line.startswith("====") and "ERRORS" in line: record = True if record and line: result.append(line) result = self._join_lines(result) result = result.replace(" \n", " ⏎\n") result = re.sub(r"\d+\.\d+s", "