├── .github
├── FUNDING.yml
├── actions
│ └── setup
│ │ └── action.yml
├── pull_request_template.md
└── workflows
│ ├── ci.yml
│ ├── deploy_development_docs.yml
│ ├── docs.yml
│ └── test_docs.yml
├── .gitignore
├── .pre-commit-config.yaml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── MANIFEST.in
├── README.md
├── changelog.d
└── .gitkeep
├── conftest.py
├── docs
├── alternatives.md
├── assets
│ ├── favicon.svg
│ ├── logo.svg
│ ├── logo_orig.svg
│ └── star-history.png
├── categories.md
├── changelog.md
├── cmp_snapshot.md
├── code_generation.md
├── configuration.md
├── contributing.md
├── customize_repr.md
├── eq_snapshot.md
├── extra.md
├── fix_assert.md
├── getitem_snapshot.md
├── in_snapshot.md
├── index.md
├── insiders.md
├── limitations.md
├── outsource.md
├── plugins
│ ├── pyproject.toml
│ └── replace_url.py
├── pytest.md
├── testing.md
├── theme
│ └── main.html
├── third_party.md
└── types.md
├── mkdocs.yml
├── pyproject.toml
├── scripts
└── replace_words.py
├── src
└── inline_snapshot
│ ├── __init__.py
│ ├── _adapter
│ ├── __init__.py
│ ├── adapter.py
│ ├── dict_adapter.py
│ ├── generic_call_adapter.py
│ ├── sequence_adapter.py
│ └── value_adapter.py
│ ├── _align.py
│ ├── _change.py
│ ├── _code_repr.py
│ ├── _compare_context.py
│ ├── _config.py
│ ├── _exceptions.py
│ ├── _external.py
│ ├── _find_external.py
│ ├── _flags.py
│ ├── _format.py
│ ├── _global_state.py
│ ├── _inline_snapshot.py
│ ├── _is.py
│ ├── _problems.py
│ ├── _rewrite_code.py
│ ├── _sentinels.py
│ ├── _snapshot
│ ├── collection_value.py
│ ├── dict_value.py
│ ├── eq_value.py
│ ├── generic_value.py
│ ├── min_max_value.py
│ └── undecided_value.py
│ ├── _source_file.py
│ ├── _types.py
│ ├── _unmanaged.py
│ ├── _utils.py
│ ├── extra.py
│ ├── fix_pytest_diff.py
│ ├── py.typed
│ ├── pydantic_fix.py
│ ├── pytest_plugin.py
│ ├── syntax_warnings.py
│ └── testing
│ ├── __init__.py
│ └── _example.py
├── testing
└── generate_tests.py
└── tests
├── __init__.py
├── _is_normalized.py
├── adapter
├── test_change_types.py
├── test_dataclass.py
├── test_dict.py
├── test_general.py
└── test_sequence.py
├── conftest.py
├── test_align.py
├── test_change.py
├── test_ci.py
├── test_code_repr.py
├── test_config.py
├── test_dirty_equals.py
├── test_docs.py
├── test_example.py
├── test_external.py
├── test_formatting.py
├── test_fstring.py
├── test_hasrepr.py
├── test_inline_snapshot.py
├── test_is.py
├── test_is_normalized.py
├── test_preserve_values.py
├── test_pydantic.py
├── test_pypy.py
├── test_pytest_diff_fix.py
├── test_pytest_plugin.py
├── test_pytester.py
├── test_raises.py
├── test_rewrite_code.py
├── test_skip_updates.py
├── test_string.py
├── test_typing.py
├── test_update.py
├── test_warns.py
├── test_xdist.py
├── test_xfail.py
└── utils.py
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: 15r10nk
2 |
--------------------------------------------------------------------------------
/.github/actions/setup/action.yml:
--------------------------------------------------------------------------------
1 | name: General Setup
2 | description: checkout & setup python
3 | inputs:
4 | python-version: # id of input
5 | description: the python version to use
6 | required: false
7 | default: '3.12'
8 | runs:
9 | using: composite
10 | steps:
11 | - name: Install uv
12 | uses: astral-sh/setup-uv@v5
13 | with:
14 | python-version: ${{inputs.python-version}}
15 |
16 | - name: Set up Python
17 | uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
18 | with:
19 | python-version: ${{inputs.python-version}}
20 | architecture: x64
21 | allow-prereleases: true
22 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 |
2 | ## Description
3 |
7 |
8 | ## Related Issue(s)
9 |
13 |
14 | ## Checklist
15 | - [ ] I have tested my changes thoroughly (you can download the test coverage with `hatch run cov:github`).
16 | - [ ] I have added/updated relevant documentation.
17 | - [ ] I have added tests for new functionality (if applicable).
18 | - [ ] I have reviewed my own code for errors.
19 | - [ ] I have added a changelog entry with `hatch run changelog:entry`
20 | - [ ] I used semantic commits (`feat:` will cause a major and `fix:` a minor version bump)
21 | - [ ] You can squash you commits, otherwise they will be squashed during merge
22 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches: [main]
7 |
8 | jobs:
9 |
10 | mypy:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
15 | steps:
16 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
17 | - uses: ./.github/actions/setup
18 | with:
19 | python-version: ${{matrix.python-version}}
20 | - run: uvx hatch run +py=${{matrix.python-version}} types:check
21 |
22 | test:
23 | runs-on: ${{matrix.os}}
24 | strategy:
25 | matrix:
26 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', pypy3.9, pypy3.10]
27 | os: [ubuntu-latest, windows-latest, macos-13]
28 | extra_deps: ['"--with=pydantic<2"', '"--with=pydantic>2"']
29 | env:
30 | TOP: ${{github.workspace}}
31 | COVERAGE_PROCESS_START: ${{github.workspace}}/pyproject.toml
32 | steps:
33 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
34 | - uses: ./.github/actions/setup
35 | with:
36 | python-version: ${{matrix.python-version}}
37 |
38 | - run: |
39 | uv run ${{matrix.extra_deps}} --extra black --extra dirty-equals -m ${{ matrix.os == 'ubuntu-latest' && 'coverage run -m' || '' }} pytest -n=auto -vv
40 | - run: |
41 | uv run -m coverage combine
42 | mv .coverage .coverage.${{ matrix.python-version }}-${{matrix.os}}-${{strategy.job-index}}
43 | if: matrix.os == 'ubuntu-latest'
44 |
45 | - name: Upload coverage data
46 | uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
47 | if: matrix.os == 'ubuntu-latest'
48 | with:
49 | name: coverage-data-${{github.run_id}}-${{ matrix.python-version }}-${{matrix.os}}-${{strategy.job-index}}
50 | path: .coverage.*
51 | include-hidden-files: true
52 | if-no-files-found: ignore
53 |
54 | coverage:
55 | name: Combine & check coverage
56 | env:
57 | TOP: ${{github.workspace}}
58 | if: always()
59 | needs: test
60 | runs-on: ubuntu-latest
61 |
62 | steps:
63 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
64 | - uses: ./.github/actions/setup
65 |
66 | - uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
67 | with:
68 | pattern: coverage-data-${{github.run_id}}-*
69 | merge-multiple: true
70 |
71 | - name: Combine coverage & fail if it's <100%
72 | run: |
73 | uv pip install --upgrade coverage[toml]
74 |
75 | coverage combine
76 | coverage html --skip-covered --skip-empty
77 |
78 | # Report and write to summary.
79 | coverage report --format=markdown >> $GITHUB_STEP_SUMMARY
80 |
81 | # Report again and fail if under 100%.
82 | coverage report --fail-under=100
83 |
84 | - name: Upload HTML report if check failed
85 | uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
86 | with:
87 | name: html-report
88 | path: htmlcov
89 | if: ${{ failure() }}
90 |
91 |
92 | publish:
93 | name: Publish new release
94 | runs-on: ubuntu-latest
95 | needs: [test, coverage]
96 | environment: pypi
97 | permissions:
98 | # IMPORTANT: this permission is mandatory for Trusted Publishing
99 | id-token: write
100 | # this permission is mandatory to create github releases
101 | contents: write
102 |
103 | steps:
104 | - name: Checkout main
105 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
106 | with:
107 | fetch-depth: 0
108 | - uses: ./.github/actions/setup
109 |
110 | - name: Check if the commit has a vx.y.z tag
111 | id: check-version
112 | run: |
113 | if git tag --list --points-at ${{ github.sha }} | grep -q -E '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
114 | echo "is new version"
115 | echo "should_continue=true" >> "$GITHUB_OUTPUT"
116 | else
117 | echo "is not a new version"
118 | echo "should_continue=false" >> "$GITHUB_OUTPUT"
119 | fi
120 |
121 | - run: uv pip install hatch scriv
122 |
123 | - name: build package
124 | run: hatch build
125 |
126 | - name: Publish package distributions to PyPI
127 | if: ${{ steps.check-version.outputs.should_continue == 'true' }}
128 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
129 |
130 | - name: create github release
131 | if: ${{ steps.check-version.outputs.should_continue == 'true' }}
132 | env:
133 | GITHUB_TOKEN: ${{ github.token }}
134 | run: scriv github-release
135 |
--------------------------------------------------------------------------------
/.github/workflows/deploy_development_docs.yml:
--------------------------------------------------------------------------------
1 | name: deploy development docs
2 | on:
3 | push:
4 | branches: [main]
5 |
6 | jobs:
7 | build:
8 | name: Deploy development docs
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout main
12 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
13 |
14 | - run: pip install hatch
15 | - name: publish docs
16 | env:
17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
18 | run: |
19 | git config user.name "Frank Hoffmann"
20 | git config user.email "15r10nk@users.noreply.github.com"
21 | git fetch origin gh-pages --depth=1
22 | hatch run docs:mike deploy --push development
23 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: Publish docs via GitHub Pages
2 | on:
3 | push:
4 | tags:
5 | - v[0-9]+.[0-9]+.[0-9]+
6 |
7 | jobs:
8 | build:
9 | name: Deploy docs for new version
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout main
13 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
14 |
15 | - run: pip install hatch
16 | - run: hatch version | sed -rne "s:([0-9]*)\.([0-9]*)\..*:INLINE_SNAPSHOT_VERSION=\1.\2:p" >> ${GITHUB_ENV}
17 | - name: publish docs
18 | env:
19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
20 | run: |
21 | git config user.name "Frank Hoffmann"
22 | git config user.email "15r10nk@users.noreply.github.com"
23 | git fetch origin gh-pages --depth=1
24 | hatch run docs:mike deploy -u --push ${INLINE_SNAPSHOT_VERSION} latest
25 |
--------------------------------------------------------------------------------
/.github/workflows/test_docs.yml:
--------------------------------------------------------------------------------
1 | name: test docs
2 | on:
3 | pull_request:
4 |
5 | jobs:
6 | build:
7 | name: Deploy development docs
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Checkout main
11 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
12 |
13 | - run: pip install hatch
14 | - name: test docs
15 | run: |
16 | hatch run docs:build
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 |
27 | # PyInstaller
28 | # Usually these files are written by a python script from a template
29 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
30 | *.manifest
31 | *.spec
32 |
33 | # Installer logs
34 | pip-log.txt
35 | pip-delete-this-directory.txt
36 |
37 | # Unit test / coverage reports
38 | htmlcov/
39 | .tox/
40 | .coverage
41 | .coverage.*
42 | .cache
43 | nosetests.xml
44 | coverage.xml
45 | *,cover
46 | .hypothesis/
47 | .pytest_cache
48 |
49 | # Translations
50 | *.mo
51 | *.pot
52 |
53 | # Django stuff:
54 | *.log
55 | local_settings.py
56 |
57 | # Flask instance folder
58 | instance/
59 |
60 | # Sphinx documentation
61 | docs/_build/
62 |
63 | # MkDocs documentation
64 | /site/
65 |
66 | # PyBuilder
67 | target/
68 |
69 | # IPython Notebook
70 | .ipynb_checkpoints
71 |
72 | # pyenv
73 | .python-version
74 | .mutmut-cache
75 | html
76 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | ci:
2 | autofix_pr: false
3 | autoupdate_commit_msg: 'style: pre-commit autoupdate'
4 | autofix_commit_msg: 'style: pre-commit fixed styling'
5 | autoupdate_schedule: monthly
6 |
7 | repos:
8 | - hooks:
9 | - id: check-ast
10 | - id: check-merge-conflict
11 | - id: trailing-whitespace
12 | - id: mixed-line-ending
13 | - id: fix-byte-order-marker
14 | - id: check-case-conflict
15 | - id: check-json
16 | - id: end-of-file-fixer
17 | repo: https://github.com/pre-commit/pre-commit-hooks
18 | rev: v5.0.0
19 | - hooks:
20 | - args:
21 | - --in-place
22 | - --expand-star-imports
23 | - --remove-all-unused-imports
24 | - --ignore-init-module-imports
25 | id: autoflake
26 | repo: https://github.com/myint/autoflake
27 | rev: v2.3.1
28 |
29 | - repo: local
30 | hooks:
31 | - id: replace-words
32 | name: Replace Words
33 | entry: python3 scripts/replace_words.py
34 | language: system
35 | files: \.(md|py)$
36 |
37 | - repo: https://github.com/asottile/setup-cfg-fmt
38 | rev: v2.8.0
39 | hooks:
40 | - id: setup-cfg-fmt
41 |
42 | - repo: https://github.com/pycqa/isort
43 | rev: 6.0.1
44 | hooks:
45 | - id: isort
46 | name: isort (python)
47 |
48 | - hooks:
49 | - args:
50 | - --py38-plus
51 | id: pyupgrade
52 | repo: https://github.com/asottile/pyupgrade
53 | rev: v3.20.0
54 | - hooks:
55 | - id: black
56 | repo: https://github.com/psf/black
57 | rev: 25.1.0
58 | - hooks:
59 | - id: blacken-docs
60 | args: [-l80]
61 | repo: https://github.com/adamchainz/blacken-docs
62 | rev: 1.19.1
63 |
64 |
65 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
66 | rev: v2.14.0
67 | hooks:
68 | - id: pretty-format-yaml
69 | args: [--autofix, --indent, '2']
70 |
71 | - hooks:
72 | - id: commitizen
73 | stages:
74 | - commit-msg
75 | repo: https://github.com/commitizen-tools/commitizen
76 | rev: v4.8.2
77 |
78 |
79 | # - repo: https://github.com/PyCQA/docformatter
80 | # rev: v1.7.5
81 | # hooks:
82 | # - id: docformatter
83 |
84 | - repo: https://github.com/abravalheri/validate-pyproject
85 | rev: v0.24.1
86 | hooks:
87 | - id: validate-pyproject
88 | # Optional extra validations from SchemaStore:
89 | additional_dependencies: ['validate-pyproject-schema-store[all]']
90 |
91 | - repo: https://github.com/rhysd/actionlint
92 | rev: v1.7.7
93 | hooks:
94 | - id: actionlint
95 |
96 | - repo: https://github.com/crate-ci/typos
97 | rev: v1
98 | hooks:
99 | - id: typos
100 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 | Contributions are welcome.
3 | Please create an issue before writing a pull request so we can discuss what needs to be changed.
4 |
5 | # Testing
6 | The code can be tested with [hatch](https://hatch.pypa.io/latest/tutorials/testing/overview/)
7 |
8 | * `hatch test` can be used to test all supported python versions and to check for coverage.
9 | * `hatch test -py 3.10 -- --sw` runs pytest for python 3.10 with the `--sw` argument.
10 |
11 | The preferred way to test inline-snapshot is by using [`inline-snapshot.texting.Example`](https://15r10nk.github.io/inline-snapshot/latest/testing/).
12 | 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.
13 |
14 |
15 | # Coverage
16 | This project has a hard coverage requirement of 100% (which is checked in CI).
17 | You can also check the coverage locally with `hatch test -acp`.
18 | The goal here is to find different edge cases which might have bugs.
19 |
20 | However, it is possible to exclude some code from the coverage.
21 |
22 | Code can be marked with `pragma: no cover`, if it can not be tested for some reason.
23 | This makes it easy to spot uncovered code in the source.
24 |
25 | Impossible conditions can be handled with `assert False`.
26 | ``` python
27 | if some_condition:
28 | ...
29 | if some_other_condition:
30 | ...
31 | else:
32 | assert False, "unreachable because ..."
33 | ```
34 | This serves also as an additional check during runtime.
35 |
36 | # Commits
37 | Please use [pre-commit](https://pre-commit.com/) for your commits.
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | The MIT License (MIT)
3 |
4 | Copyright (c) 2022 Frank Hoffmann
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in
14 | all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | THE SOFTWARE.
23 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE
2 | include README.rst
3 |
4 | recursive-exclude * __pycache__
5 | recursive-exclude * *.py[co]
6 |
--------------------------------------------------------------------------------
/changelog.d/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/15r10nk/inline-snapshot/43d7f86e397708695bd9b14fe024d7c8da699798/changelog.d/.gitkeep
--------------------------------------------------------------------------------
/conftest.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from inline_snapshot._external import DiscStorage
4 | from tests.utils import snapshot_env
5 | from tests.utils import useStorage
6 |
7 |
8 | @pytest.fixture(autouse=True)
9 | def snapshot_env_for_doctest(request, tmp_path):
10 | if hasattr(request.node, "dtest"):
11 | with snapshot_env():
12 | storage = DiscStorage(tmp_path / ".storage")
13 | with useStorage(storage):
14 | yield
15 | else:
16 | yield
17 |
--------------------------------------------------------------------------------
/docs/alternatives.md:
--------------------------------------------------------------------------------
1 | inline-snapshot is not the only snapshot library for python.
2 | There are several others to:
3 |
4 | * [syrupy](https://github.com/syrupy-project/syrupy)
5 | * [snapshottest](https://github.com/syrusakbary/snapshottest)
6 | * [pytest-snapshot](https://github.com/joseph-roitman/pytest-snapshot)
7 | * [pytest-insta](https://github.com/vberlier/pytest-insta)
8 |
9 | All of them have things that make them unique. What sets inline-snapshot apart is the ability to store snapshots directly in the source code. This leads to less indirections in the code which improves readability and code-reviews.
10 |
11 | If you miss a feature that is available in other libraries, please let me know.
12 |
13 |
20 |
--------------------------------------------------------------------------------
/docs/assets/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
119 |
--------------------------------------------------------------------------------
/docs/assets/star-history.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/15r10nk/inline-snapshot/43d7f86e397708695bd9b14fe024d7c8da699798/docs/assets/star-history.png
--------------------------------------------------------------------------------
/docs/changelog.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ``` python exec="1"
4 | from pathlib import Path
5 | from subprocess import run
6 | import re
7 |
8 | new_changes = list(Path.cwd().glob("changelog.d/*.md"))
9 | next_version = (
10 | run(["cz", "bump", "--get-next"], capture_output=True)
11 | .stdout.decode()
12 | .strip()
13 | )
14 |
15 | if new_changes:
16 | print(f"## upcoming version ({next_version})")
17 |
18 | for file in new_changes:
19 | print(file.read_text())
20 |
21 | full_changelog = Path("CHANGELOG.md").read_text()
22 |
23 | full_changelog = re.sub("^#", "##", full_changelog, flags=re.M)
24 |
25 | print(full_changelog)
26 | ```
27 |
--------------------------------------------------------------------------------
/docs/cmp_snapshot.md:
--------------------------------------------------------------------------------
1 | ## General
2 |
3 | A snapshot can be compared against any value with `<=` or `>=`.
4 | This can be used to create a upper/lower bound for some result.
5 | The snapshot value can be trimmed to the lowest/largest valid value.
6 |
7 | Example:
8 |
9 | === "original code"
10 |
11 | ``` python
12 | from inline_snapshot import snapshot
13 |
14 |
15 | def gcd(x, y):
16 | iterations = 0
17 | if x > y:
18 | small = y
19 | else:
20 | small = x
21 | for i in range(1, small + 1):
22 | iterations += 1
23 | if (x % i == 0) and (y % i == 0):
24 | gcd = i
25 |
26 | return gcd, iterations
27 |
28 |
29 | def test_gcd():
30 | result, iterations = gcd(12, 18)
31 |
32 | assert result == snapshot()
33 | assert iterations <= snapshot()
34 | ```
35 |
36 | === "--inline-snapshot=create"
37 |
38 | ``` python hl_lines="21 22"
39 | from inline_snapshot import snapshot
40 |
41 |
42 | def gcd(x, y):
43 | iterations = 0
44 | if x > y:
45 | small = y
46 | else:
47 | small = x
48 | for i in range(1, small + 1):
49 | iterations += 1
50 | if (x % i == 0) and (y % i == 0):
51 | gcd = i
52 |
53 | return gcd, iterations
54 |
55 |
56 | def test_gcd():
57 | result, iterations = gcd(12, 18)
58 |
59 | assert result == snapshot(6)
60 | assert iterations <= snapshot(12)
61 | ```
62 |
63 | === "optimized code "
64 |
65 | ``` python hl_lines="5 7 9 10"
66 | from inline_snapshot import snapshot
67 |
68 |
69 | def gcd(x, y):
70 | # use Euclidean Algorithm
71 | iterations = 0
72 | while y:
73 | iterations += 1
74 | x, y = y, x % y
75 | return abs(x), iterations
76 |
77 |
78 | def test_gcd():
79 | result, iterations = gcd(12, 18)
80 |
81 | assert result == snapshot(6)
82 | assert iterations <= snapshot(12)
83 | ```
84 |
85 | === "--inline-snapshot=trim"
86 |
87 | ``` python hl_lines="17"
88 | from inline_snapshot import snapshot
89 |
90 |
91 | def gcd(x, y):
92 | # use Euclidean Algorithm
93 | iterations = 0
94 | while y:
95 | iterations += 1
96 | x, y = y, x % y
97 | return abs(x), iterations
98 |
99 |
100 | def test_gcd():
101 | result, iterations = gcd(12, 18)
102 |
103 | assert result == snapshot(6)
104 | assert iterations <= snapshot(3)
105 | ```
106 |
107 | !!! warning
108 | This should not be used to check for any flaky values like the runtime of some code, because it will randomly break your tests.
109 |
110 | The same snapshot value can also be used in multiple assertions.
111 |
112 | === "original code"
113 |
114 | ``` python
115 | from inline_snapshot import snapshot
116 |
117 |
118 | def test_something():
119 | value = snapshot()
120 |
121 | assert 5 <= value
122 | assert 6 <= value
123 | ```
124 | === "--inline-snapshot=create"
125 |
126 | ``` python hl_lines="5"
127 | from inline_snapshot import snapshot
128 |
129 |
130 | def test_something():
131 | value = snapshot(6)
132 |
133 | assert 5 <= value
134 | assert 6 <= value
135 | ```
136 |
137 | ## pytest options
138 |
139 | It interacts with the following `--inline-snapshot` flags:
140 |
141 | - `create` create a new value if the snapshot value is undefined.
142 | - `fix` record the new value and store it in the source code if it is contradicts the comparison.
143 | - `trim` record the new value and store it in the source code if it is more strict than the old one.
144 |
--------------------------------------------------------------------------------
/docs/code_generation.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | You can use almost any python data type and also complex values like `datetime.date`, because `repr()` is used to convert the values to source code.
4 | The default `__repr__()` behaviour can be [customized](customize_repr.md).
5 | It might be necessary to import the right modules to match the `repr()` output.
6 |
7 | === "original code"
8 |
9 | ``` python
10 | from inline_snapshot import snapshot
11 | import datetime
12 |
13 |
14 | def something():
15 | return {
16 | "name": "hello",
17 | "one number": 5,
18 | "numbers": list(range(10)),
19 | "sets": {1, 2, 15},
20 | "datetime": datetime.date(1, 2, 22),
21 | "complex stuff": 5j + 3,
22 | "bytes": b"byte abc\n\x16",
23 | }
24 |
25 |
26 | def test_something():
27 | assert something() == snapshot()
28 | ```
29 | === "--inline-snapshot=create"
30 |
31 | ``` python hl_lines="18 19 20 21 22 23 24 25 26 27 28"
32 | from inline_snapshot import snapshot
33 | import datetime
34 |
35 |
36 | def something():
37 | return {
38 | "name": "hello",
39 | "one number": 5,
40 | "numbers": list(range(10)),
41 | "sets": {1, 2, 15},
42 | "datetime": datetime.date(1, 2, 22),
43 | "complex stuff": 5j + 3,
44 | "bytes": b"byte abc\n\x16",
45 | }
46 |
47 |
48 | def test_something():
49 | assert something() == snapshot(
50 | {
51 | "name": "hello",
52 | "one number": 5,
53 | "numbers": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
54 | "sets": {1, 2, 15},
55 | "datetime": datetime.date(1, 2, 22),
56 | "complex stuff": (3 + 5j),
57 | "bytes": b"byte abc\n\x16",
58 | }
59 | )
60 | ```
61 |
62 | The code is generated in the following way:
63 |
64 | 1. The value is copied with `value = copy.deepcopy(value)` and it is checked if the copied value is equal to the original value.
65 | 2. The code is generated with:
66 | * `repr(value)` (which can be [customized](customize_repr.md))
67 | * or a special internal implementation for container types to support [unmanaged snapshot values](eq_snapshot.md#unmanaged-snapshot-values).
68 | This can currently not be customized.
69 | 3. Strings which contain newlines are converted to triple quoted strings.
70 |
71 | !!! note
72 | Missing newlines at start or end are escaped (since 0.4.0).
73 |
74 | === "original code"
75 |
76 | ``` python
77 | from inline_snapshot import snapshot
78 |
79 |
80 | def test_something():
81 | assert "first line\nsecond line" == snapshot(
82 | """first line
83 | second line"""
84 | )
85 | ```
86 |
87 | === "--inline-snapshot=update"
88 |
89 | ``` python hl_lines="6 7 8 9"
90 | from inline_snapshot import snapshot
91 |
92 |
93 | def test_something():
94 | assert "first line\nsecond line" == snapshot(
95 | """\
96 | first line
97 | second line\
98 | """
99 | )
100 | ```
101 |
102 |
103 | 4. The new code fragments are formatted with black if it is installed.
104 |
105 | !!! note
106 | Black is an optional dependency since inline-snapshot v0.19.0.
107 | You can install it with:
108 | ``` sh
109 | pip install inline-snapshot[black]
110 | ```
111 |
112 | 5. The whole file is formatted
113 | * with black if it was formatted with black before.
114 |
115 | !!! note
116 | The black formatting of the whole file could not work for the following reasons:
117 |
118 | 1. black is configured with cli arguments and not in a configuration file.
119 | **Solution:** configure black in a [configuration file](https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#configuration-via-a-file)
120 | 2. inline-snapshot uses a different black version.
121 | **Solution:** specify which black version inline-snapshot should use by adding black with a specific version to your dependencies.
122 | 3. black is not installed. Black is an optional dependency since inline-snapshot v0.19.0
123 |
124 | * or with the [format-command][format-command] if you defined one.
125 |
--------------------------------------------------------------------------------
/docs/configuration.md:
--------------------------------------------------------------------------------
1 | Default configuration:
2 |
3 | ``` toml
4 | [tool.inline-snapshot]
5 | hash-length=15
6 | default-flags=["report"]
7 | default-flags-tui=["create", "review"]
8 | format-command=""
9 | show-updates=false
10 |
11 | [tool.inline-snapshot.shortcuts]
12 | review=["review"]
13 | fix=["create","fix"]
14 | ```
15 |
16 | * **hash-length:** specifies the length of the hash used by `external()` in the code representation.
17 | This does not affect the hash length used to store the data.
18 | The hash should be long enough to avoid hash collisions.
19 | * **default-flags:** defines which flags should be used if there are no flags specified with `--inline-snapshot=...`.
20 | You can also use the environment variable `INLINE_SNAPSHOT_DEFAULT_FLAGS=...` to specify the flags and to override those in the configuration file.
21 |
22 | * **default-flags-tui:** defines which flags should be used if you run pytest in an interactive terminal.
23 | inline-snapshot creates all snapshots by default in this case and asks when there are values to change.
24 | This feature requires *cpython>=3.11*
25 |
26 | !!! note
27 | The default flags are different if you use *cpython<3.11* due to some [technical limitations](limitations.md#pytest-assert-rewriting-is-disabled):
28 | ``` toml
29 | [tool.inline-snapshot]
30 | default-flags=["short-report"]
31 | default-flags-tui=["short-report"]
32 | ```
33 |
34 |
35 |
36 | * **shortcuts:** allows you to define custom commands to simplify your workflows.
37 | `--fix` and `--review` are defined by default, but this configuration can be changed to fit your needs.
38 |
39 | * **storage-dir:** allows you to define the directory where inline-snapshot stores data files such as external snapshots.
40 | By default, it will be `/.inline-snapshot`,
41 | where `` is replaced by the directory containing the Pytest configuration file, if any.
42 | External snapshots will be stored in the `external` subfolder of the storage directory.
43 | * **format-command:[](){#format-command}** allows you to specify a custom command which is used to format the python code after code is changed.
44 | ``` toml
45 | [tool.inline-snapshot]
46 | format-command="ruff format --stdin-filename {filename}"
47 | ```
48 | The placeholder `{filename}` can be used to specify the filename if it is needed to find the correct formatting options for this file.
49 |
50 | !!! important
51 | 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*.
52 |
53 | You can also use a `|` if you want to use multiple commands.
54 | ``` toml
55 | [tool.inline-snapshot]
56 | format-command="ruff check --fix-only --stdin-filename {filename} | ruff format --stdin-filename {filename}"
57 | ```
58 |
59 | * **show-updates:**[](){#show-updates} shows updates in reviews and reports.
60 |
--------------------------------------------------------------------------------
/docs/contributing.md:
--------------------------------------------------------------------------------
1 | --8<-- "CONTRIBUTING.md"
2 |
--------------------------------------------------------------------------------
/docs/customize_repr.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | `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.
5 | Here are some examples:
6 |
7 | ```pycon
8 | >>> repr(int)
9 | ""
10 |
11 | >>> from enum import Enum
12 | >>> E = Enum("E", ["a", "b"])
13 | >>> repr(E.a)
14 | ''
15 | ```
16 |
17 | `customize_repr` can be used to overwrite the default `repr()` behaviour.
18 |
19 | The implementation for `Enum` looks like this:
20 |
21 | ``` python exec="1" result="python"
22 | print('--8<-- "src/inline_snapshot/_code_repr.py:Enum"')
23 | ```
24 |
25 | This implementation is then used by inline-snapshot if `repr()` is called during the code generation, but not in normal code.
26 |
27 |
28 | ``` python
29 | from inline_snapshot import snapshot
30 | from enum import Enum
31 |
32 |
33 | def test_enum():
34 | E = Enum("E", ["a", "b"])
35 |
36 | # normal repr
37 | assert repr(E.a) == ""
38 |
39 | # the special implementation to convert the Enum into a code
40 | assert E.a == snapshot(E.a)
41 | ```
42 |
43 | ## built-in data types
44 |
45 | inline-snapshot comes with a special implementation for the following types:
46 |
47 | ``` python exec="1"
48 | from inline_snapshot._code_repr import code_repr_dispatch, code_repr
49 |
50 | for name, obj in sorted(
51 | (
52 | getattr(
53 | obj, "_inline_snapshot_name", f"{obj.__module__}.{obj.__qualname__}"
54 | ),
55 | obj,
56 | )
57 | for obj in code_repr_dispatch.registry.keys()
58 | ):
59 | if obj is not object:
60 | print(f"- `{name}`")
61 | ```
62 |
63 | Please open an [issue](https://github.com/15r10nk/inline-snapshot/issues) if you found a built-in type which is not supported by inline-snapshot.
64 |
65 | !!! note
66 | 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.
67 |
68 |
69 | ## customize recursive repr
70 |
71 | You can also use `repr()` inside `__repr__()`, if you want to make your own type compatible with inline-snapshot.
72 |
73 |
74 | ``` python
75 | from inline_snapshot import snapshot
76 | from enum import Enum
77 |
78 |
79 | class Pair:
80 | def __init__(self, a, b):
81 | self.a = a
82 | self.b = b
83 |
84 | def __repr__(self):
85 | # this would not work
86 | # return f"Pair({self.a!r}, {self.b!r})"
87 |
88 | # you have to use repr()
89 | return f"Pair({repr(self.a)}, {repr(self.b)})"
90 |
91 | def __eq__(self, other):
92 | if not isinstance(other, Pair):
93 | return NotImplemented
94 | return self.a == other.a and self.b == other.b
95 |
96 |
97 | def test_enum():
98 | E = Enum("E", ["a", "b"])
99 |
100 | # the special repr implementation is used recursive here
101 | # to convert every Enum to the correct representation
102 | assert Pair(E.a, [E.b]) == snapshot(Pair(E.a, [E.b]))
103 | ```
104 |
105 | !!! note
106 | 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.
107 |
108 | !!! note
109 | 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)`
110 |
111 |
112 | you can also customize the representation of data types in other libraries:
113 |
114 | ``` python
115 | from inline_snapshot import customize_repr
116 | from other_lib import SomeType
117 |
118 |
119 | @customize_repr
120 | def _(value: SomeType):
121 | return f"SomeType(x={repr(value.x)})"
122 | ```
123 |
--------------------------------------------------------------------------------
/docs/extra.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ::: inline_snapshot.extra
4 | options:
5 | heading_level: 1
6 | show_root_heading: true
7 | show_source: true
8 |
--------------------------------------------------------------------------------
/docs/fix_assert.md:
--------------------------------------------------------------------------------
1 | ## General
2 |
3 |
4 | !!! info
5 |
6 | The following feature is available for [insider](insiders.md) :heart: only
7 | and requires cpython>=3.11.
8 |
9 |
10 | The `snapshot()` function provides a lot of flexibility, but there is a easier way for simple assertion.
11 | You can write a normal assertion and use `...` where inline-snapshot should create the new value, like in the following example.
12 |
13 | === "original code"
14 |
15 | ``` python
16 | def test_assert():
17 | assert 1 + 1 == ...
18 | ```
19 |
20 | === "--inline-snapshot=create"
21 |
22 | ``` python hl_lines="2"
23 | def test_assert():
24 | assert 1 + 1 == 2
25 | ```
26 |
27 | inline-snapshot will detect these failures and will replace `...` with the correct value.
28 |
29 | It is also possible to fix existing values.
30 |
31 | === "original code"
32 |
33 | ``` python
34 | def test_assert():
35 | assert 1 + 1 == 5
36 | ```
37 |
38 | === "--inline-snapshot=fix-assert"
39 |
40 | ``` python hl_lines="2"
41 | def test_assert():
42 | assert 1 + 1 == 2
43 | ```
44 |
45 | This is especially useful to fix values in existing codebases where `snapshot()` is currently not used.
46 |
47 | The logic to create/fix the assertions is the same like for snapshots, but there are rules which specify which side of the `==` should be fixed.
48 | This allows assertions like `#!python assert 5 == 1 + 2` to be fixed and prevents inline-snapshot to try to fix code like `#!python assert f1() == f2()`.
49 |
50 | The rule is that exactly one side of the equation must be a *value expression*, which is defined as follows:
51 |
52 | * a constant
53 | * a list/tuple/dict/set of *value expressions*
54 | * a constructor call such as `T(...arguments)`
55 | * where the arguments are *value expressions*
56 | * and `T` is a type (which excludes function calls)
57 |
58 |
59 |
60 | ## Limitations
61 |
62 | * `cpython>=3.11` is required to create/fix assertions.
63 | * It can only fix the first failing assertion in a test.
64 | You need to run your tests a multiple times to fix the remaining ones.
65 | * It is not possible to fix values where inline-snapshot did not know which side of the equal sign should be fixed.
66 | You can use `snapshot()` in this case to make this clear.
67 |
68 |
69 |
70 | ## pytest options
71 |
72 | It interacts with the following `--inline-snapshot` flags:
73 |
74 | - `create` create a new value where `...` is used.
75 | - `fix-assert` fix the value if the assertion fails.
76 |
77 | !!! note
78 |
79 | fix-assert is used to distinguisch between snapshot fixes and assertion fixes without snapshot().
80 | This should help in deciding whether some fixes should be approved.
81 | Fixing normal assertions is inherently more complicated because these assertions are written by a human without the intention of being automatically fixed.
82 | Separating the two helps in approving the changes.
83 |
--------------------------------------------------------------------------------
/docs/getitem_snapshot.md:
--------------------------------------------------------------------------------
1 | ## General
2 |
3 | It is possible to generate sub-snapshots during runtime.
4 | This sub-snapshots can be used like a normal snapshot.
5 |
6 | Example:
7 |
8 | === "original code"
9 |
10 | ``` python
11 | from inline_snapshot import snapshot
12 |
13 |
14 | def test_something():
15 | s = snapshot()
16 |
17 | assert s["a"] == 4
18 | assert s["b"] == 5
19 | ```
20 |
21 | === "--inline-snapshot=create"
22 |
23 | ``` python hl_lines="5"
24 | from inline_snapshot import snapshot
25 |
26 |
27 | def test_something():
28 | s = snapshot({"a": 4, "b": 5})
29 |
30 | assert s["a"] == 4
31 | assert s["b"] == 5
32 | ```
33 |
34 | `s[key]` can be used with every normal snapshot operation including `s[key1][key2]`.
35 |
36 |
37 | ## pytest options
38 |
39 | It interacts with the following `--inline-snapshot` flags:
40 |
41 | - `create` create a new value if the snapshot value is undefined or create a new sub-snapshot if one is missing.
42 | - `trim` remove sub-snapshots if they are not needed any more.
43 |
44 | The flags `fix` and `update` are applied recursive to all sub-snapshots.
45 |
--------------------------------------------------------------------------------
/docs/in_snapshot.md:
--------------------------------------------------------------------------------
1 | ## General
2 |
3 | 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.
4 |
5 | Example:
6 |
7 | === "original code"
8 |
9 | ``` python
10 | from inline_snapshot import snapshot
11 |
12 |
13 | def test_something():
14 | s = snapshot()
15 |
16 | assert 5 in s
17 | assert 5 in s
18 | assert 8 in s
19 |
20 | for v in ["a", "b"]:
21 | assert v in s
22 | ```
23 |
24 | === "--inline-snapshot=create"
25 |
26 | ``` python hl_lines="5"
27 | from inline_snapshot import snapshot
28 |
29 |
30 | def test_something():
31 | s = snapshot([5, 8, "a", "b"])
32 |
33 | assert 5 in s
34 | assert 5 in s
35 | assert 8 in s
36 |
37 | for v in ["a", "b"]:
38 | assert v in s
39 | ```
40 |
41 | ## pytest options
42 |
43 | It interacts with the following `--inline-snapshot` flags:
44 |
45 | - `create` create a new value if the snapshot value is undefined.
46 | - `fix` adds a value to the list if it is missing.
47 | - `trim` removes a value from the list if it is not necessary.
48 |
--------------------------------------------------------------------------------
/docs/insiders.md:
--------------------------------------------------------------------------------
1 | # Insiders
2 |
3 | Hi, I'm Frank Hoffmann I created and maintain inline-snapshot and several [other tools](https://github.com/15r10nk).
4 | Working on open-source projects is really exciting but requires also a lot of time.
5 | Being able to finance my work allows me to put more time into my projects.
6 |
7 | Many open-source projects follow a *sponsorware* strategy where they build new features for insiders first and release them when they have reached a specific funding goal.
8 | This is not an option for most of the inline-snapshot features, because this would force everyone who wants to run the tests of a project to become a sponsor when the maintainer want to use insider-only features of inline-snapshot which require some new API.
9 |
10 | But there are some features which require no new API and provide you a lot of value.
11 | Fixing raw assertions like the following is one of them:
12 |
13 | === "original code"
14 |
15 | ``` python
16 | def test_assert():
17 | assert 1 + 1 == 5
18 | ```
19 |
20 | === "--inline-snapshot=fix-assert"
21 |
22 | ``` python hl_lines="2"
23 | def test_assert():
24 | assert 1 + 1 == 2
25 | ```
26 |
27 | And this is what I want to offer for my sponsors, the ability to [create and fix normal assertions](fix_assert.md).
28 |
29 | But I also want to make use of funding-goals, where I want to reduce the minimum amount, starting from 10$ a month. The last sponsoring goal will make fixing of raw assertions available for everyone.
30 |
31 | I follow some goals with this plan:
32 |
33 | 1. I would like to convince many people/companies to sponsor open source projects.
34 | 2. Lowering the minimum amount allows you to support other projects as well.
35 | 3. The ultimate goal is to have the time and money to work on my projects without having to offer sponsor-only features. I don't know if that will work out, but I think it's worth a try.
36 |
37 | I don't currently have a detailed plan for when I will reduce the funding amount for the first time or which functions I will make available to everyone, because I don't know how things will develop.
38 | The future will be exciting.
39 |
40 | ## Getting started
41 |
42 | The inline-snapshot insider version is API-compatible with the normal inline-snapshot version, but allows to [fix assertions](fix_assert.md).
43 | Note that in order to access the Insiders repository, you need to become an eligible sponsor of [@15r10nk](https://github.com/sponsors/15r10nk) on GitHub with 10$ per month or more.
44 | You will then be invited to join the insider team and gain access to the repositories that are only accessible to insiders.
45 |
46 |
47 | ### Installation
48 |
49 | You can install the insiders version with pip from the git repository:
50 |
51 | ``` bash
52 | pip install git+ssh://git@github.com/15r10nk-insiders/inline-snapshot.git
53 | ```
54 |
--------------------------------------------------------------------------------
/docs/limitations.md:
--------------------------------------------------------------------------------
1 |
2 | ## pytest assert rewriting is disabled
3 |
4 | inline-snapshot must disable pytest assert-rewriting if you use *report/review/create/fix/trim/update* flags and *cpython<3.11*.
5 |
6 | ## xdist is not supported
7 |
8 | You can not use inline-snapshot in combination with [pytest-xdist](https://pytest-xdist.readthedocs.io/).
9 | xdist being active implies `--inline-snapshot=disable`.
10 |
11 | ## works only with cpython
12 |
13 | inline-snapshot works currently only with cpython. `--inline-snapshot=disable` is enforced for every other implementation.
14 |
--------------------------------------------------------------------------------
/docs/outsource.md:
--------------------------------------------------------------------------------
1 | ## General
2 |
3 | !!! info
4 | This feature is currently under development. See this [issue](https://github.com/15r10nk/inline-snapshot/issues/86) for more information.
5 |
6 | Storing snapshots in the source code is the main feature of inline snapshots.
7 | This has the advantage that you can easily see changes in code reviews. But it also has some problems:
8 |
9 | * It is problematic to snapshot a lot of data, because it takes up a lot of space in your tests.
10 | * Binary data or images are not readable in your tests.
11 |
12 | The `outsource()` function solves this problem and integrates itself nicely with the inline snapshot.
13 | It stores the data in a special `external()` object that can be compared in snapshots.
14 | The object is represented by the hash of the data.
15 | The actual data is stored in a separate file in your project.
16 |
17 | This allows the test to be renamed and moved around in your code without losing the connection to the stored data.
18 |
19 | Example:
20 |
21 | === "original code"
22 |
23 | ``` python
24 | from inline_snapshot import snapshot, outsource
25 |
26 |
27 | def test_something():
28 | assert outsource("long text\n" * 1000) == snapshot()
29 | ```
30 |
31 | === "--inline-snapshot=create"
32 |
33 | ``` python hl_lines="3 4 7 8 9"
34 | from inline_snapshot import snapshot, outsource
35 |
36 | from inline_snapshot import external
37 |
38 |
39 | def test_something():
40 | assert outsource("long text\n" * 1000) == snapshot(
41 | external("f5a956460453*.txt")
42 | )
43 | ```
44 |
45 | The `external` object can be used inside other data structures.
46 |
47 | === "original code"
48 |
49 | ``` python
50 | from inline_snapshot import snapshot, outsource
51 |
52 |
53 | def test_something():
54 | assert [
55 | outsource("long text\n" * times) for times in [50, 100, 1000]
56 | ] == snapshot()
57 | ```
58 |
59 | === "--inline-snapshot=create"
60 |
61 | ``` python hl_lines="3 4 9 10 11 12 13 14 15"
62 | from inline_snapshot import snapshot, outsource
63 |
64 | from inline_snapshot import external
65 |
66 |
67 | def test_something():
68 | assert [
69 | outsource("long text\n" * times) for times in [50, 100, 1000]
70 | ] == snapshot(
71 | [
72 | external("362ad8374ed6*.txt"),
73 | external("5755afea3f8d*.txt"),
74 | external("f5a956460453*.txt"),
75 | ]
76 | )
77 | ```
78 |
79 |
80 | ## API
81 |
82 | ::: inline_snapshot.outsource
83 | options:
84 | show_root_heading: true
85 |
86 | ::: inline_snapshot.external
87 | options:
88 | show_root_heading: true
89 |
90 | ## pytest options
91 |
92 | It interacts with the following `--inline-snapshot` flags:
93 |
94 | - `trim` removes every snapshots form the storage which is not referenced with `external(...)` in the code.
95 |
--------------------------------------------------------------------------------
/docs/plugins/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | build-backend = "hatchling.build"
3 | requires = ["hatchling"]
4 |
5 | [project]
6 | authors = [
7 | {name = "Frank Hoffmann", email = "15r10nk@polarbit.de"}
8 | ]
9 | classifiers = [
10 | "Development Status :: 4 - Beta",
11 | "Programming Language :: Python",
12 | "Programming Language :: Python :: 3.8",
13 | "Programming Language :: Python :: 3.9",
14 | "Programming Language :: Python :: 3.10",
15 | "Programming Language :: Python :: 3.11",
16 | "Programming Language :: Python :: 3.12",
17 | "Programming Language :: Python :: Implementation :: CPython",
18 | "Programming Language :: Python :: Implementation :: PyPy"
19 | ]
20 | dependencies = [
21 | "mkdocs"
22 | ]
23 | license = "MIT"
24 | name = "replace-url"
25 | requires-python = ">=3.8"
26 | version = "0.3.0"
27 |
28 | [project.entry-points."mkdocs.plugins"]
29 | replace-url = "replace_url:ReplaceUrlPlugin"
30 |
--------------------------------------------------------------------------------
/docs/plugins/replace_url.py:
--------------------------------------------------------------------------------
1 | import mkdocs
2 |
3 |
4 | class ReplaceUrlPlugin(mkdocs.plugins.BasePlugin):
5 | def on_page_content(self, html, page, config, files):
6 | return html.replace("docs/assets", "assets")
7 |
--------------------------------------------------------------------------------
/docs/pytest.md:
--------------------------------------------------------------------------------
1 |
2 | inline-snapshot provides one pytest option with different flags (*create*,
3 | *fix*,
4 | *trim*,
5 | *update*,
6 | *short-report*,
7 | *report*,
8 | *disable*).
9 |
10 |
11 | Snapshot comparisons return always `True` if you use one of the flags *create*, *fix* or *review*.
12 | This is necessary because the whole test needs to be run to fix all snapshots like in this case:
13 |
14 | ``` python
15 | from inline_snapshot import snapshot
16 |
17 |
18 | def test_something():
19 | assert 1 == snapshot(5)
20 | assert 2 <= snapshot(5)
21 | ```
22 |
23 | !!! note
24 | Every flag with the exception of *disable* and *short-report* disables the pytest assert-rewriting.
25 |
26 |
27 |
28 | ## --inline-snapshot=create,fix,trim,update
29 |
30 | Approve the changes of the given [category](categories.md).
31 | These flags can be combined with *report* and *review*.
32 |
33 | ``` python title="test_something.py"
34 | from inline_snapshot import snapshot
35 |
36 |
37 | def test_something():
38 | assert 1 == snapshot()
39 | assert 2 <= snapshot(5)
40 | ```
41 |
42 | ```bash exec="1" title="something" result="ansi"
43 | cd $(mktemp -d)
44 | export -n CI
45 | export -n GITHUB_ACTIONS
46 |
47 | export FORCE_COLOR=256
48 | export COLUMNS=80
49 |
50 | function run(){
51 | echo -en "\x1b[1;34m> "
52 | echo $@
53 | echo -en "\x1b[0m"
54 | $@
55 | echo
56 | }
57 |
58 | black -q - > test_something.py << EOF
59 | from inline_snapshot import snapshot
60 |
61 | def test_something():
62 | assert 1 == snapshot()
63 | assert 2 <= snapshot(5)
64 | EOF
65 |
66 | run pytest test_something.py --inline-snapshot=create,report
67 | ```
68 |
69 |
70 | ## --inline-snapshot=short-report
71 |
72 | give a short report over which changes can be made to the snapshots
73 |
74 | ```bash exec="1" title="something" result="ansi"
75 | cd $(mktemp -d)
76 | export -n CI
77 | export -n GITHUB_ACTIONS
78 |
79 | export FORCE_COLOR=256
80 | export COLUMNS=80
81 |
82 | function run(){
83 | echo -en "\x1b[1;34m> "
84 | echo $@
85 | echo -en "\x1b[0m"
86 | python -m $@
87 | echo
88 | }
89 |
90 | black -q - > test_something.py << EOF
91 | from inline_snapshot import snapshot
92 |
93 | def test_something():
94 | assert 1 == snapshot()
95 | assert 2 <= snapshot(5)
96 | EOF
97 |
98 | run pytest test_something.py --inline-snapshot=short-report
99 | ```
100 |
101 | !!! info
102 | short-report exists mainly to show that snapshots have changed with enabled pytest assert-rewriting.
103 | This option will be replaced with *report* when this restriction is lifted.
104 |
105 | ## --inline-snapshot=report
106 |
107 | Shows a diff report over which changes can be made to the snapshots
108 |
109 | ```bash exec="1" title="something" result="ansi"
110 | cd $(mktemp -d)
111 | export -n CI
112 | export -n GITHUB_ACTIONS
113 |
114 | export FORCE_COLOR=256
115 | export COLUMNS=80
116 |
117 | function run(){
118 | echo -en "\x1b[1;34m> "
119 | echo $@
120 | echo -en "\x1b[0m"
121 | $@
122 | echo
123 | }
124 |
125 | black -q - > test_something.py << EOF
126 | from inline_snapshot import snapshot
127 |
128 | def test_something():
129 | assert 1 == snapshot()
130 | assert 2 <= snapshot(5)
131 | EOF
132 |
133 | run pytest test_something.py --inline-snapshot=report
134 | ```
135 |
136 | ## --inline-snapshot=review
137 |
138 | Shows a diff report for each category and ask if you want to apply the changes
139 |
140 | ```bash exec="1" title="something" result="ansi"
141 | cd $(mktemp -d)
142 | export -n CI
143 | export -n GITHUB_ACTIONS
144 |
145 | export FORCE_COLOR=256
146 | export COLUMNS=80
147 |
148 | function run(){
149 | echo -en "\x1b[1;34m> "
150 | echo $@
151 | echo -en "\x1b[0m"
152 | $@
153 | echo
154 | }
155 |
156 | black -q - > test_something.py << EOF
157 | from inline_snapshot import snapshot
158 |
159 | def test_something():
160 | assert 1 == snapshot()
161 | assert 2 <= snapshot(5)
162 | EOF
163 |
164 | yes | run pytest test_something.py --inline-snapshot=review
165 | ```
166 |
167 |
168 |
169 | ## --inline-snapshot=disable
170 |
171 | Disables all the snapshot logic. `snapshot(x)` will just return `x`.
172 | 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.
173 |
174 | !!! info "deprecation"
175 | This option was previously called `--inline-snapshot-disable`
176 |
--------------------------------------------------------------------------------
/docs/testing.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | `inline_snapshot.testing` provides tools which can be used to test inline-snapshot workflows.
4 | This might be useful if you want to build your own libraries based on inline-snapshot.
5 |
6 | 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.
7 |
8 | === "original"
9 |
10 |
11 | ``` python
12 | from inline_snapshot.testing import Example
13 | from inline_snapshot import snapshot
14 |
15 |
16 | def test_something():
17 |
18 | Example(
19 | {
20 | "test_a.py": """\
21 | from inline_snapshot import snapshot
22 | def test_a():
23 | assert 1+1 == snapshot()
24 | """
25 | }
26 | ).run_inline( # run without flags
27 | reported_categories=snapshot(),
28 | ).run_pytest(
29 | ["--inline-snapshot=short-report"], # check the pytest report
30 | changed_files=snapshot(),
31 | report=snapshot(),
32 | returncode=snapshot(),
33 | ).run_pytest( # run with create flag and check the changed files
34 | ["--inline-snapshot=create"],
35 | changed_files=snapshot(),
36 | )
37 | ```
38 |
39 | === "--inline-snapshot=create"
40 |
41 |
42 | ``` python hl_lines="16 19 20 21 22 23 24 25 26"
43 | from inline_snapshot.testing import Example
44 | from inline_snapshot import snapshot
45 |
46 |
47 | def test_something():
48 |
49 | Example(
50 | {
51 | "test_a.py": """\
52 | from inline_snapshot import snapshot
53 | def test_a():
54 | assert 1+1 == snapshot()
55 | """
56 | }
57 | ).run_inline( # run without flags
58 | reported_categories=snapshot(["create"]),
59 | ).run_pytest(
60 | ["--inline-snapshot=short-report"], # check the pytest report
61 | changed_files=snapshot({}),
62 | report=snapshot(
63 | """\
64 | Error: one snapshot is missing a value (--inline-snapshot=create)
65 | You can also use --inline-snapshot=review to approve the changes interactively\
66 | """
67 | ),
68 | returncode=snapshot(1),
69 | ).run_pytest( # run with create flag and check the changed files
70 | ["--inline-snapshot=create"],
71 | changed_files=snapshot(),
72 | )
73 | ```
74 |
75 |
76 | ## API
77 | ::: inline_snapshot.testing.Example
78 | options:
79 | heading_level: 3
80 | show_root_heading: true
81 | show_root_full_path: false
82 | show_source: false
83 | annotations_path: brief
84 |
--------------------------------------------------------------------------------
/docs/theme/main.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block outdated %}
4 | You're not viewing the latest version.
5 |
6 | Click here to go to latest.
7 |
8 | {% endblock %}
9 |
10 | {% block announce %}
11 |
12 | {{ super() }}
13 | Become a
14 |
15 |
16 | {% include ".icons/octicons/heart-fill-16.svg" %}
17 |
18 |
19 | Sponsor
20 |
21 |
22 | or follow @15r10nk on
23 |
24 |
27 | or
28 |
29 |
30 |
31 | {% include ".icons/fontawesome/brands/mastodon.svg" %}
32 |
33 |
34 |
68 | for updates
69 | {% endblock %}
70 |
--------------------------------------------------------------------------------
/docs/third_party.md:
--------------------------------------------------------------------------------
1 |
2 | Third-party extensions can be used to enhance the testing experience with other frameworks.
3 | The goal of inline-snapshot is to provide the core functionality for many different use cases.
4 |
5 | List of current third-party extensions:
6 |
7 |
30 | * [inline-snapshot-pandas](https://pypi.org/project/inline-snapshot-pandas/) pandas integration for inline-snapshot (insider only)
31 |
32 |
33 |
34 | !!! info "How to add your extension to this list?"
35 | Your package name has to start with `inline-snapshot-` and has to be available on [PyPI](https://pypi.org).
36 | The summary of your package will be used as description.
37 |
38 | 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).
39 |
--------------------------------------------------------------------------------
/docs/types.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ::: inline_snapshot
4 | options:
5 | heading_level: 1
6 | members: [Snapshot,Category]
7 | show_root_heading: true
8 | show_bases: false
9 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: inline-snapshot
2 | site_url: https://15r10nk.github.io/inline-snapshot/
3 | repo_url: https://github.com/15r10nk/inline-snapshot/
4 | edit_uri: edit/main/docs
5 |
6 | theme:
7 | name: material
8 | custom_dir: docs/theme
9 | logo: assets/favicon.svg
10 | favicon: assets/favicon.svg
11 | features:
12 | - toc.follow
13 | - content.code.annotate
14 | - navigation.tabs
15 |
16 | palette:
17 | - media: (prefers-color-scheme)
18 |
19 | # Palette toggle for light mode
20 | - scheme: default
21 | media: '(prefers-color-scheme: light)'
22 | primary: teal
23 |
24 | # Palette toggle for dark mode
25 | - scheme: slate
26 | media: '(prefers-color-scheme: dark)'
27 | primary: teal
28 |
29 | validation:
30 | links:
31 | absolute_links: relative_to_docs
32 |
33 |
34 | watch:
35 | - CONTRIBUTING.md
36 | - CHANGELOG.md
37 | - README.md
38 | - src/inline_snapshot
39 |
40 | nav:
41 | - Home:
42 | - Introduction: index.md
43 | - Configuration: configuration.md
44 | - pytest integration: pytest.md
45 | - Categories: categories.md
46 | - Code generation: code_generation.md
47 | - Limitations: limitations.md
48 | - Alternatives: alternatives.md
49 | - Changelog: changelog.md
50 | - Core:
51 | - assert x == ...: fix_assert.md
52 | - x == snapshot(): eq_snapshot.md
53 | - x <= snapshot(): cmp_snapshot.md
54 | - x in snapshot(): in_snapshot.md
55 | - snapshot()[key]: getitem_snapshot.md
56 | - outsource(data): outsource.md
57 | - '@customize_repr': customize_repr.md
58 | - types: types.md
59 | - Extensions:
60 | - first-party (extra): extra.md
61 | - third-party: third_party.md
62 | - Insiders: insiders.md
63 | - Development:
64 | - Testing: testing.md
65 | - Contributing: contributing.md
66 |
67 |
68 |
69 | markdown_extensions:
70 | - toc:
71 | permalink: true
72 | - admonition
73 | - pymdownx.highlight:
74 | anchor_linenums: true
75 | - pymdownx.inlinehilite
76 | - pymdownx.snippets:
77 | check_paths: true
78 | - pymdownx.superfences
79 | - admonition
80 | - pymdownx.details
81 | - pymdownx.superfences
82 | - pymdownx.tabbed:
83 | alternate_style: true
84 | - attr_list
85 | - pymdownx.emoji:
86 | emoji_index: !!python/name:material.extensions.emoji.twemoji
87 | emoji_generator: !!python/name:material.extensions.emoji.to_svg
88 |
89 | plugins:
90 | - mkdocstrings:
91 | handlers:
92 | python:
93 | options:
94 | show_symbol_type_heading: true
95 | show_symbol_type_toc: true
96 | - social
97 | - search
98 | - markdown-exec:
99 | ansi: required
100 | - replace-url
101 | - autorefs
102 |
103 |
104 | extra:
105 | social:
106 | - icon: fontawesome/brands/x-twitter
107 | link: https://x.com/15r10nk
108 | - icon: fontawesome/brands/mastodon
109 | link: https://fosstodon.org/@15r10nk
110 | version:
111 | provider: mike
112 | default:
113 | - latest
114 | - development
115 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | build-backend = "hatchling.build"
3 | requires = ["hatchling"]
4 |
5 | [project]
6 | authors = [
7 | {name = "Frank Hoffmann", email = "15r10nk-git@polarbit.de"}
8 | ]
9 | classifiers = [
10 | "Development Status :: 4 - Beta",
11 | "Framework :: Pytest",
12 | "Intended Audience :: Developers",
13 | "Operating System :: OS Independent",
14 | "Topic :: Software Development :: Testing",
15 | "Programming Language :: Python",
16 | "Programming Language :: Python :: 3.8",
17 | "Programming Language :: Python :: 3.9",
18 | "Programming Language :: Python :: 3.10",
19 | "Programming Language :: Python :: 3.11",
20 | "Programming Language :: Python :: 3.12",
21 | "Programming Language :: Python :: 3.13",
22 | "Programming Language :: Python :: Implementation :: CPython",
23 | "Programming Language :: Python :: Implementation :: PyPy",
24 | "License :: OSI Approved :: MIT License",
25 | ]
26 | dependencies = [
27 | "asttokens>=2.0.5",
28 | "executing>=2.2.0",
29 | "rich>=13.7.1",
30 | "tomli>=2.0.0; python_version < '3.11'",
31 | "pytest>=8.3.4",
32 | ]
33 | description = "golden master/snapshot/approval testing library which puts the values right into your source code"
34 | keywords = []
35 | name = "inline-snapshot"
36 | readme = "README.md"
37 | requires-python = ">=3.8"
38 | version = "0.23.2"
39 |
40 | [project.optional-dependencies]
41 | black = [
42 | "black>=23.3.0",
43 | ]
44 |
45 | dirty-equals =[
46 | "dirty-equals>=0.9.0",
47 | ]
48 |
49 | [dependency-groups]
50 | dev = [
51 | "hypothesis>=6.75.5",
52 | "mypy>=1.2.0",
53 | "pyright>=1.1.359",
54 | "pytest-subtests>=0.11.0",
55 | "pytest-freezer>=0.4.8",
56 | "pytest-mock>=3.14.0",
57 | "pytest-xdist>=3.6.1",
58 | "coverage[toml]>=7.6.1",
59 | "coverage-enable-subprocess>=1.0",
60 | "attrs>=24.3.0",
61 | "pydantic>=1",
62 | ]
63 |
64 | [project.entry-points.pytest11]
65 | inline_snapshot = "inline_snapshot.pytest_plugin"
66 |
67 | [project.urls]
68 | Changelog = "https://15r10nk.github.io/inline-snapshot/latest/changelog/"
69 | Discussions = "https://github.com/15r10nk/inline-snapshots/discussions"
70 | Documentation = "https://15r10nk.github.io/inline-snapshot/latest"
71 | Funding = "https://github.com/sponsors/15r10nk"
72 | Homepage = "https://15r10nk.github.io/inline-snapshot/latest"
73 | Issues = "https://github.com/15r10nk/inline-snapshots/issues"
74 | Repository = "https://github.com/15r10nk/inline-snapshot/"
75 |
76 | [tool.commitizen]
77 | major_version_zero = true
78 | tag_format = "v$major.$minor.$patch$prerelease"
79 | version_files = [
80 | "src/inline_snapshot/__init__.py:__version__"
81 | ]
82 | version_provider = "pep621"
83 |
84 | [tool.coverage.paths]
85 | inline_snapshot = ["src/inline_snapshot", "*/inline_snapshot/src/inline_snapshot"]
86 | tests = ["tests", "*/inline_snapshot/tests"]
87 |
88 | [tool.coverage.report]
89 | exclude_lines = ["assert False", "raise NotImplemented", "# pragma: no cover", "if TYPE_CHECKING:"]
90 |
91 | [tool.coverage.run]
92 | branch = true
93 | data_file = "$TOP/.coverage"
94 | omit = [
95 | "src/inline_snapshot/__about__.py"
96 | ]
97 | parallel = true
98 | source_pkgs = ["inline_snapshot", "tests"]
99 |
100 | [tool.hatch.envs.docs]
101 | dependencies = [
102 | "markdown-exec[ansi]>=1.8.0",
103 | "mkdocs>=1.4.2",
104 | "mkdocs-material[imaging]>=9.5.17",
105 | "mike",
106 | "mkdocstrings[python]>=0.19.0",
107 | "mkdocs-autorefs",
108 | "replace-url @ {root:uri}/docs/plugins",
109 | "black",
110 | "commitizen"
111 | ]
112 | [tool.hatch.envs.default]
113 | installer="uv"
114 |
115 | [tool.hatch.envs.cov.scripts]
116 | github=[
117 | "- rm htmlcov/*",
118 | "gh run download -n html-report -D htmlcov",
119 | "xdg-open htmlcov/index.html",
120 | ]
121 |
122 | [tool.hatch.envs.docs.scripts]
123 | build = "mkdocs build --strict"
124 | deploy = "mkdocs gh-deploy"
125 | export-deps = "pip freeze"
126 | serve = "mkdocs serve"
127 |
128 | [tool.hatch.envs.cog]
129 | dependencies=["cogapp","lxml","requests"]
130 | scripts.update="cog -r docs/**.md"
131 |
132 | [tool.hatch.envs.gen]
133 | dependencies=["pysource-minimize"]
134 | scripts.test=["python testing/generate_tests.py"]
135 |
136 | [[tool.hatch.envs.hatch-test.matrix]]
137 | python = ["3.13", "3.12", "3.11", "3.10", "3.9", "3.8","pypy3.9","pypy3.10"]
138 | extra-deps=["low","hight"]
139 |
140 | [tool.hatch.envs.hatch-test.overrides]
141 | matrix.extra-deps.dependencies = [
142 | { value = "pydantic<2", if = ["low"] },
143 | { value = "pydantic>=2", if = ["hight"] },
144 | ]
145 |
146 | [tool.hatch.envs.hatch-test]
147 | extra-dependencies = [
148 | "inline-snapshot[black,dirty-equals]",
149 | "dirty-equals>=0.9.0",
150 | "hypothesis>=6.75.5",
151 | "mypy>=1.2.0",
152 | "pyright>=1.1.359",
153 | "pytest-subtests>=0.11.0",
154 | "pytest-freezer>=0.4.8",
155 | "pytest-mock>=3.14.0"
156 | ]
157 | env-vars.TOP = "{root}"
158 |
159 | [tool.hatch.envs.hatch-test.scripts]
160 | run = "pytest{env:HATCH_TEST_ARGS:} {args}"
161 | run-cov = "coverage run -m pytest{env:HATCH_TEST_ARGS:} {args}"
162 | cov-combine = "coverage combine"
163 | cov-report=["coverage report","coverage html"]
164 |
165 | [tool.hatch.envs.types]
166 | extra-dependencies = [
167 | "inline-snapshot[black,dirty-equals]",
168 | "mypy>=1.0.0",
169 | "hypothesis>=6.75.5",
170 | "pydantic",
171 | "attrs",
172 | "typing-extensions"
173 | ]
174 |
175 | [[tool.hatch.envs.types.matrix]]
176 | python = ["3.8", "3.9", "3.10", "3.11", "3.12","3.13"]
177 |
178 | [tool.hatch.envs.types.scripts]
179 | check = "mypy --install-types --non-interactive {args:src/inline_snapshot tests}"
180 |
181 | [tool.mypy]
182 | exclude = "tests/.*_samples"
183 |
184 | [tool.pyright]
185 | venv = "test-3-12"
186 | venvPath = ".nox"
187 |
188 |
189 | [tool.hatch.envs.release]
190 | detached=true
191 | dependencies=[
192 | "scriv[toml]",
193 | "commitizen"
194 | ]
195 |
196 | [tool.hatch.envs.release.scripts]
197 | create=[
198 | "scriv collect",
199 | "- pre-commit run -a",
200 | "cz bump",
201 | "git push --force-with-lease origin main $(git describe main --tags)",
202 | ]
203 |
204 | [tool.hatch.envs.changelog]
205 | detached=true
206 | dependencies=[
207 | "scriv[toml]",
208 | ]
209 | scripts.entry="scriv create --add --edit"
210 |
211 | [tool.scriv]
212 | format = "md"
213 | version = "command: cz bump --get-next"
214 |
215 | [tool.pytest.ini_options]
216 | markers=["no_rewriting: marks tests which need no code rewriting and can be used with pypy"]
217 |
218 | [tool.isort]
219 | profile="black"
220 | force_single_line=true
221 |
222 | [tool.inline-snapshot]
223 | show-updates=true
224 |
--------------------------------------------------------------------------------
/scripts/replace_words.py:
--------------------------------------------------------------------------------
1 | import re
2 | import sys
3 |
4 |
5 | def replace_words(file_path, replacements):
6 | with open(file_path) as file:
7 | content = file.read()
8 |
9 | for old_word, new_word in replacements.items():
10 | content = re.sub(rf"\b{re.escape(old_word)}\b", new_word, content)
11 |
12 | with open(file_path, "w") as file:
13 | file.write(content)
14 |
15 |
16 | if __name__ == "__main__":
17 |
18 | replacements = {
19 | "http://localhost:8000/inline-snapshot/": "https://15r10nk.github.io/inline-snapshot/latest/",
20 | }
21 |
22 | for file_path in sys.argv[1:]:
23 | print(file_path)
24 | replace_words(file_path, replacements)
25 |
--------------------------------------------------------------------------------
/src/inline_snapshot/__init__.py:
--------------------------------------------------------------------------------
1 | from ._code_repr import HasRepr
2 | from ._code_repr import customize_repr
3 | from ._exceptions import UsageError
4 | from ._external import external
5 | from ._external import outsource
6 | from ._inline_snapshot import snapshot
7 | from ._is import Is
8 | from ._types import Category
9 | from ._types import Snapshot
10 |
11 | __all__ = [
12 | "snapshot",
13 | "external",
14 | "outsource",
15 | "customize_repr",
16 | "HasRepr",
17 | "Is",
18 | "Category",
19 | "Snapshot",
20 | "UsageError",
21 | ]
22 |
23 | __version__ = "0.23.2"
24 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_adapter/__init__.py:
--------------------------------------------------------------------------------
1 | from .adapter import get_adapter_type
2 |
3 | __all__ = ("get_adapter_type",)
4 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_adapter/adapter.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import ast
4 | import typing
5 | from dataclasses import dataclass
6 |
7 | from inline_snapshot._source_file import SourceFile
8 |
9 |
10 | def get_adapter_type(value):
11 | from inline_snapshot._adapter.generic_call_adapter import get_adapter_for_type
12 |
13 | adapter = get_adapter_for_type(type(value))
14 | if adapter is not None:
15 | return adapter
16 |
17 | if isinstance(value, list):
18 | from .sequence_adapter import ListAdapter
19 |
20 | return ListAdapter
21 |
22 | if type(value) is tuple:
23 | from .sequence_adapter import TupleAdapter
24 |
25 | return TupleAdapter
26 |
27 | if isinstance(value, dict):
28 | from .dict_adapter import DictAdapter
29 |
30 | return DictAdapter
31 |
32 | from .value_adapter import ValueAdapter
33 |
34 | return ValueAdapter
35 |
36 |
37 | class Item(typing.NamedTuple):
38 | value: typing.Any
39 | node: ast.expr
40 |
41 |
42 | @dataclass
43 | class FrameContext:
44 | globals: dict
45 | locals: dict
46 |
47 |
48 | @dataclass
49 | class AdapterContext:
50 | file: SourceFile
51 | frame: FrameContext | None
52 |
53 | def eval(self, node):
54 | assert self.frame is not None
55 |
56 | return eval(
57 | compile(ast.Expression(node), self.file.filename, "eval"),
58 | self.frame.globals,
59 | self.frame.locals,
60 | )
61 |
62 |
63 | class Adapter:
64 | context: AdapterContext
65 |
66 | def __init__(self, context: AdapterContext):
67 | self.context = context
68 |
69 | def get_adapter(self, old_value, new_value) -> Adapter:
70 | if type(old_value) is not type(new_value):
71 | from .value_adapter import ValueAdapter
72 |
73 | return ValueAdapter(self.context)
74 |
75 | adapter_type = get_adapter_type(old_value)
76 | if adapter_type is not None:
77 | return adapter_type(self.context)
78 | assert False
79 |
80 | def assign(self, old_value, old_node, new_value):
81 | raise NotImplementedError(self)
82 |
83 | def value_assign(self, old_value, old_node, new_value):
84 | from .value_adapter import ValueAdapter
85 |
86 | adapter = ValueAdapter(self.context)
87 | result = yield from adapter.assign(old_value, old_node, new_value)
88 | return result
89 |
90 | @classmethod
91 | def map(cls, value, map_function):
92 | raise NotImplementedError(cls)
93 |
94 | @classmethod
95 | def repr(cls, value):
96 | raise NotImplementedError(cls)
97 |
98 |
99 | def adapter_map(value, map_function):
100 | return get_adapter_type(value).map(value, map_function)
101 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_adapter/dict_adapter.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import ast
4 | import warnings
5 |
6 | from .._change import Delete
7 | from .._change import DictInsert
8 | from ..syntax_warnings import InlineSnapshotSyntaxWarning
9 | from .adapter import Adapter
10 | from .adapter import Item
11 | from .adapter import adapter_map
12 |
13 |
14 | class DictAdapter(Adapter):
15 |
16 | @classmethod
17 | def repr(cls, value):
18 | result = (
19 | "{"
20 | + ", ".join(f"{repr(k)}: {repr(value)}" for k, value in value.items())
21 | + "}"
22 | )
23 |
24 | if type(value) is not dict:
25 | result = f"{repr(type(value))}({result})"
26 |
27 | return result
28 |
29 | @classmethod
30 | def map(cls, value, map_function):
31 | return {k: adapter_map(v, map_function) for k, v in value.items()}
32 |
33 | @classmethod
34 | def items(cls, value, node):
35 | if node is None or not isinstance(node, ast.Dict):
36 | return [Item(value=value, node=None) for value in value.values()]
37 |
38 | result = []
39 |
40 | for value_key, node_key, node_value in zip(
41 | value.keys(), node.keys, node.values
42 | ):
43 | try:
44 | # this is just a sanity check, dicts should be ordered
45 | node_key = ast.literal_eval(node_key)
46 | except Exception:
47 | pass
48 | else:
49 | assert node_key == value_key
50 |
51 | result.append(Item(value=value[value_key], node=node_value))
52 |
53 | return result
54 |
55 | def assign(self, old_value, old_node, new_value):
56 | if old_node is not None:
57 | if not (
58 | isinstance(old_node, ast.Dict) and len(old_value) == len(old_node.keys)
59 | ):
60 | result = yield from self.value_assign(old_value, old_node, new_value)
61 | return result
62 |
63 | for key, value in zip(old_node.keys, old_node.values):
64 | if key is None:
65 | warnings.warn_explicit(
66 | "star-expressions are not supported inside snapshots",
67 | filename=self.context.file._source.filename,
68 | lineno=value.lineno,
69 | category=InlineSnapshotSyntaxWarning,
70 | )
71 | return old_value
72 |
73 | for value, node in zip(old_value.keys(), old_node.keys):
74 |
75 | try:
76 | # this is just a sanity check, dicts should be ordered
77 | node_value = ast.literal_eval(node)
78 | except:
79 | continue
80 | assert node_value == value
81 |
82 | result = {}
83 | for key, node in zip(
84 | old_value.keys(),
85 | (old_node.values if old_node is not None else [None] * len(old_value)),
86 | ):
87 | if key not in new_value:
88 | # delete entries
89 | yield Delete("fix", self.context.file._source, node, old_value[key])
90 |
91 | to_insert = []
92 | insert_pos = 0
93 | for key, new_value_element in new_value.items():
94 | if key not in old_value:
95 | # add new values
96 | to_insert.append((key, new_value_element))
97 | result[key] = new_value_element
98 | else:
99 | if isinstance(old_node, ast.Dict):
100 | node = old_node.values[list(old_value.keys()).index(key)]
101 | else:
102 | node = None
103 | # check values with same keys
104 | result[key] = yield from self.get_adapter(
105 | old_value[key], new_value[key]
106 | ).assign(old_value[key], node, new_value[key])
107 |
108 | if to_insert:
109 | new_code = [
110 | (
111 | self.context.file._value_to_code(k),
112 | self.context.file._value_to_code(v),
113 | )
114 | for k, v in to_insert
115 | ]
116 | yield DictInsert(
117 | "fix",
118 | self.context.file._source,
119 | old_node,
120 | insert_pos,
121 | new_code,
122 | to_insert,
123 | )
124 | to_insert = []
125 |
126 | insert_pos += 1
127 |
128 | if to_insert:
129 | new_code = [
130 | (
131 | self.context.file._value_to_code(k),
132 | self.context.file._value_to_code(v),
133 | )
134 | for k, v in to_insert
135 | ]
136 | yield DictInsert(
137 | "fix",
138 | self.context.file._source,
139 | old_node,
140 | len(old_value),
141 | new_code,
142 | to_insert,
143 | )
144 |
145 | return result
146 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_adapter/sequence_adapter.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import ast
4 | import warnings
5 | from collections import defaultdict
6 |
7 | from .._align import add_x
8 | from .._align import align
9 | from .._change import Delete
10 | from .._change import ListInsert
11 | from .._compare_context import compare_context
12 | from ..syntax_warnings import InlineSnapshotSyntaxWarning
13 | from .adapter import Adapter
14 | from .adapter import Item
15 | from .adapter import adapter_map
16 |
17 |
18 | class SequenceAdapter(Adapter):
19 | node_type: type
20 | value_type: type
21 | braces: str
22 | trailing_comma: bool
23 |
24 | @classmethod
25 | def repr(cls, value):
26 | if len(value) == 1 and cls.trailing_comma:
27 | seq = repr(value[0]) + ","
28 | else:
29 | seq = ", ".join(map(repr, value))
30 | return cls.braces[0] + seq + cls.braces[1]
31 |
32 | @classmethod
33 | def map(cls, value, map_function):
34 | result = [adapter_map(v, map_function) for v in value]
35 | return cls.value_type(result)
36 |
37 | @classmethod
38 | def items(cls, value, node):
39 | if node is None or not isinstance(node, cls.node_type):
40 | return [Item(value=v, node=None) for v in value]
41 |
42 | assert len(value) == len(node.elts)
43 |
44 | return [Item(value=v, node=n) for v, n in zip(value, node.elts)]
45 |
46 | def assign(self, old_value, old_node, new_value):
47 | if old_node is not None:
48 | if not isinstance(
49 | old_node, ast.List if isinstance(old_value, list) else ast.Tuple
50 | ):
51 | result = yield from self.value_assign(old_value, old_node, new_value)
52 | return result
53 |
54 | for e in old_node.elts:
55 | if isinstance(e, ast.Starred):
56 | warnings.warn_explicit(
57 | "star-expressions are not supported inside snapshots",
58 | filename=self.context.file.filename,
59 | lineno=e.lineno,
60 | category=InlineSnapshotSyntaxWarning,
61 | )
62 | return old_value
63 |
64 | with compare_context():
65 | diff = add_x(align(old_value, new_value))
66 | old = zip(
67 | old_value,
68 | old_node.elts if old_node is not None else [None] * len(old_value),
69 | )
70 | new = iter(new_value)
71 | old_position = 0
72 | to_insert = defaultdict(list)
73 | result = []
74 | for c in diff:
75 | if c in "mx":
76 | old_value_element, old_node_element = next(old)
77 | new_value_element = next(new)
78 | v = yield from self.get_adapter(
79 | old_value_element, new_value_element
80 | ).assign(old_value_element, old_node_element, new_value_element)
81 | result.append(v)
82 | old_position += 1
83 | elif c == "i":
84 | new_value_element = next(new)
85 | new_code = self.context.file._value_to_code(new_value_element)
86 | result.append(new_value_element)
87 | to_insert[old_position].append((new_code, new_value_element))
88 | elif c == "d":
89 | old_value_element, old_node_element = next(old)
90 | yield Delete(
91 | "fix",
92 | self.context.file._source,
93 | old_node_element,
94 | old_value_element,
95 | )
96 | old_position += 1
97 | else:
98 | assert False
99 |
100 | for position, code_values in to_insert.items():
101 | yield ListInsert(
102 | "fix", self.context.file._source, old_node, position, *zip(*code_values)
103 | )
104 |
105 | return self.value_type(result)
106 |
107 |
108 | class ListAdapter(SequenceAdapter):
109 | node_type = ast.List
110 | value_type = list
111 | braces = "[]"
112 | trailing_comma = False
113 |
114 |
115 | class TupleAdapter(SequenceAdapter):
116 | node_type = ast.Tuple
117 | value_type = tuple
118 | braces = "()"
119 | trailing_comma = True
120 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_adapter/value_adapter.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import ast
4 | import warnings
5 |
6 | from .._change import Replace
7 | from .._code_repr import value_code_repr
8 | from .._sentinels import undefined
9 | from .._unmanaged import Unmanaged
10 | from .._unmanaged import update_allowed
11 | from .._utils import value_to_token
12 | from ..syntax_warnings import InlineSnapshotInfo
13 | from .adapter import Adapter
14 |
15 |
16 | class ValueAdapter(Adapter):
17 |
18 | @classmethod
19 | def repr(cls, value):
20 | return value_code_repr(value)
21 |
22 | @classmethod
23 | def map(cls, value, map_function):
24 | return map_function(value)
25 |
26 | def assign(self, old_value, old_node, new_value):
27 | # generic fallback
28 |
29 | # because IsStr() != IsStr()
30 | if isinstance(old_value, Unmanaged):
31 | return old_value
32 |
33 | if old_node is None:
34 | new_token = []
35 | else:
36 | new_token = value_to_token(new_value)
37 |
38 | if isinstance(old_node, ast.JoinedStr) and isinstance(new_value, str):
39 | if not old_value == new_value:
40 | warnings.warn_explicit(
41 | f"inline-snapshot will be able to fix f-strings in the future.\nThe current string value is:\n {new_value!r}",
42 | filename=self.context.file._source.filename,
43 | lineno=old_node.lineno,
44 | category=InlineSnapshotInfo,
45 | )
46 | return old_value
47 |
48 | if not old_value == new_value:
49 | if old_value is undefined:
50 | flag = "create"
51 | else:
52 | flag = "fix"
53 | elif (
54 | old_node is not None
55 | and update_allowed(old_value)
56 | and self.context.file._token_of_node(old_node) != new_token
57 | ):
58 | flag = "update"
59 | else:
60 | # equal and equal repr
61 | return old_value
62 |
63 | new_code = self.context.file._token_to_code(new_token)
64 |
65 | yield Replace(
66 | node=old_node,
67 | file=self.context.file._source,
68 | new_code=new_code,
69 | flag=flag,
70 | old_value=old_value,
71 | new_value=new_value,
72 | )
73 |
74 | return new_value
75 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_align.py:
--------------------------------------------------------------------------------
1 | from itertools import groupby
2 |
3 |
4 | def align(seq_a, seq_b) -> str:
5 |
6 | start = 0
7 |
8 | for a, b in zip(seq_a, seq_b):
9 | if a == b:
10 | start += 1
11 | else:
12 | break
13 |
14 | if start == len(seq_a) == len(seq_b):
15 | return "m" * start
16 |
17 | end = 0
18 |
19 | for a, b in zip(reversed(seq_a[start:]), reversed(seq_b[start:])):
20 | if a == b:
21 | end += 1
22 | else:
23 | break
24 |
25 | diff = nw_align(seq_a[start : len(seq_a) - end], seq_b[start : len(seq_b) - end])
26 |
27 | return "m" * start + diff + "m" * end
28 |
29 |
30 | def nw_align(seq_a, seq_b) -> str:
31 |
32 | matrix: list = [[(0, "e")] + [(0, "i")] * len(seq_b)]
33 |
34 | for a in seq_a:
35 | last = matrix[-1]
36 |
37 | new_line = [(0, "d")]
38 | for bi, b in enumerate(seq_b, 1):
39 | la, lc, lb = new_line[-1], last[bi - 1], last[bi]
40 | values = [(la[0], "i"), (lb[0], "d")]
41 | if a == b:
42 | values.append((lc[0] + 1, "m"))
43 |
44 | new_line.append(max(values))
45 | matrix.append(new_line)
46 |
47 | # backtrack
48 |
49 | ai = len(seq_a)
50 | bi = len(seq_b)
51 | d = ""
52 | track = ""
53 |
54 | while d != "e":
55 | _, d = matrix[ai][bi]
56 | if d == "m":
57 | ai -= 1
58 | bi -= 1
59 | elif d == "i":
60 | bi -= 1
61 | elif d == "d":
62 | ai -= 1
63 | if d != "e":
64 | track += d
65 |
66 | return track[::-1]
67 |
68 |
69 | def add_x(track):
70 | """Replaces an `id` with the same number of insertions and deletions with
71 | x."""
72 | groups = [(c, len(list(v))) for c, v in groupby(track)]
73 | i = 0
74 | result = ""
75 | while i < len(groups):
76 | g = groups[i]
77 | if i == len(groups) - 1:
78 | result += g[0] * g[1]
79 | break
80 |
81 | ng = groups[i + 1]
82 | if g[0] == "d" and ng[0] == "i" and g[1] == ng[1]:
83 | result += "x" * g[1]
84 | i += 1
85 | else:
86 | result += g[0] * g[1]
87 |
88 | i += 1
89 |
90 | return result
91 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_code_repr.py:
--------------------------------------------------------------------------------
1 | import ast
2 | from enum import Enum
3 | from enum import Flag
4 | from functools import singledispatch
5 | from unittest import mock
6 |
7 | real_repr = repr
8 |
9 |
10 | class HasRepr:
11 | """This class is used for objects where `__repr__()` returns an non-
12 | parsable representation.
13 |
14 | HasRepr uses the type and repr of the value for equal comparison.
15 |
16 | You can change `__repr__()` to return valid python code or use
17 | `@customize_repr` to customize repr which is used by inline-
18 | snapshot.
19 | """
20 |
21 | def __init__(self, type, str_repr: str) -> None:
22 | self._type = type
23 | self._str_repr = str_repr
24 |
25 | def __repr__(self):
26 | return f"HasRepr({self._type.__qualname__}, {self._str_repr!r})"
27 |
28 | def __eq__(self, other):
29 | if isinstance(other, HasRepr):
30 | if other._type is not self._type:
31 | return False
32 | else:
33 | if type(other) is not self._type:
34 | return False
35 |
36 | other_repr = code_repr(other)
37 | return other_repr == self._str_repr or other_repr == repr(self)
38 |
39 |
40 | def used_hasrepr(tree):
41 | return [
42 | n
43 | for n in ast.walk(tree)
44 | if isinstance(n, ast.Call)
45 | and isinstance(n.func, ast.Name)
46 | and n.func.id == "HasRepr"
47 | and len(n.args) == 2
48 | ]
49 |
50 |
51 | @singledispatch
52 | def code_repr_dispatch(value):
53 | return real_repr(value)
54 |
55 |
56 | def customize_repr(f):
57 | """Register a function which should be used to get the code representation
58 | of a object.
59 |
60 | ``` python
61 | @customize_repr
62 | def _(obj: MyCustomClass):
63 | return f"MyCustomClass(attr={repr(obj.attr)})"
64 | ```
65 |
66 | it is important to use `repr()` inside the implementation, because it is mocked to return the code representation
67 |
68 | you dont have to provide a custom implementation if:
69 | * __repr__() of your class returns a valid code representation,
70 | * and __repr__() uses `repr()` to get the representation of the child objects
71 | """
72 | code_repr_dispatch.register(f)
73 |
74 |
75 | def code_repr(obj):
76 |
77 | with mock.patch("builtins.repr", mocked_code_repr):
78 | return mocked_code_repr(obj)
79 |
80 |
81 | def mocked_code_repr(obj):
82 | from inline_snapshot._adapter.adapter import get_adapter_type
83 |
84 | adapter = get_adapter_type(obj)
85 | assert adapter is not None
86 | return adapter.repr(obj)
87 |
88 |
89 | def value_code_repr(obj):
90 | if not type(obj) == type(obj): # pragma: no cover
91 | # this was caused by https://github.com/samuelcolvin/dirty-equals/issues/104
92 | # dispatch will not work in cases like this
93 | return (
94 | f"HasRepr({repr(type(obj))}, '< type(obj) can not be compared with == >')"
95 | )
96 |
97 | result = code_repr_dispatch(obj)
98 |
99 | try:
100 | ast.parse(result)
101 | except SyntaxError:
102 | return real_repr(HasRepr(type(obj), result))
103 |
104 | return result
105 |
106 |
107 | # -8<- [start:Enum]
108 | @customize_repr
109 | def _(value: Enum):
110 | return f"{type(value).__qualname__}.{value.name}"
111 |
112 |
113 | # -8<- [end:Enum]
114 |
115 |
116 | @customize_repr
117 | def _(value: Flag):
118 | name = type(value).__qualname__
119 | return " | ".join(f"{name}.{flag.name}" for flag in type(value) if flag in value)
120 |
121 |
122 | def sort_set_values(set_values):
123 | is_sorted = False
124 | try:
125 | set_values = sorted(set_values)
126 | is_sorted = True
127 | except TypeError:
128 | pass
129 |
130 | set_values = list(map(repr, set_values))
131 | if not is_sorted:
132 | set_values = sorted(set_values)
133 |
134 | return set_values
135 |
136 |
137 | @customize_repr
138 | def _(value: set):
139 | if len(value) == 0:
140 | return "set()"
141 |
142 | return "{" + ", ".join(sort_set_values(value)) + "}"
143 |
144 |
145 | @customize_repr
146 | def _(value: frozenset):
147 | if len(value) == 0:
148 | return "frozenset()"
149 |
150 | return "frozenset({" + ", ".join(sort_set_values(value)) + "})"
151 |
152 |
153 | @customize_repr
154 | def _(value: type):
155 | return value.__qualname__
156 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_compare_context.py:
--------------------------------------------------------------------------------
1 | from contextlib import contextmanager
2 |
3 |
4 | def compare_only():
5 | return _eq_check_only
6 |
7 |
8 | _eq_check_only = False
9 |
10 |
11 | @contextmanager
12 | def compare_context():
13 | global _eq_check_only
14 | old_eq_only = _eq_check_only
15 | _eq_check_only = True
16 | yield
17 | _eq_check_only = old_eq_only
18 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_config.py:
--------------------------------------------------------------------------------
1 | import sys
2 | from dataclasses import dataclass
3 | from dataclasses import field
4 | from pathlib import Path
5 | from typing import Dict
6 | from typing import List
7 | from typing import Optional
8 |
9 | if sys.version_info >= (3, 11):
10 | from tomllib import loads
11 | else:
12 | from tomli import loads
13 |
14 |
15 | @dataclass
16 | class Config:
17 | hash_length: int = 12
18 | default_flags: List[str] = field(default_factory=lambda: ["short-report"])
19 | default_flags_tui: List[str] = field(default_factory=lambda: ["short-report"])
20 | shortcuts: Dict[str, List[str]] = field(default_factory=dict)
21 | format_command: Optional[str] = None
22 | storage_dir: Optional[Path] = None
23 | show_updates: bool = False
24 |
25 |
26 | config = Config()
27 |
28 |
29 | def read_config(path: Path, config=Config()) -> Config:
30 | tool_config = {}
31 |
32 | if path.exists():
33 | data = loads(path.read_text("utf-8"))
34 |
35 | try:
36 | tool_config = data["tool"]["inline-snapshot"]
37 | except KeyError:
38 | pass
39 |
40 | try:
41 | config.hash_length = tool_config["hash-length"]
42 | except KeyError:
43 | pass
44 |
45 | try:
46 | config.default_flags = tool_config["default-flags"]
47 | except KeyError:
48 | pass
49 |
50 | try:
51 | config.default_flags_tui = tool_config["default-flags-tui"]
52 | except KeyError:
53 | pass
54 |
55 | try:
56 | config.show_updates = tool_config["show-updates"]
57 | except KeyError:
58 | pass
59 |
60 | config.shortcuts = tool_config.get(
61 | "shortcuts", {"fix": ["create", "fix"], "review": ["review"]}
62 | )
63 |
64 | if storage_dir := tool_config.get("storage-dir"):
65 | storage_dir = Path(storage_dir)
66 | if not storage_dir.is_absolute():
67 | # Make it relative to pyproject.toml, and absolute.
68 | storage_dir = path.parent.joinpath(storage_dir).absolute()
69 | config.storage_dir = storage_dir
70 |
71 | config.format_command = tool_config.get("format-command", None)
72 |
73 | return config
74 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_exceptions.py:
--------------------------------------------------------------------------------
1 | class UsageError(Exception):
2 | pass
3 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_external.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import pathlib
3 | import re
4 | from typing import Optional
5 | from typing import Set
6 | from typing import Union
7 |
8 | from . import _config
9 | from ._global_state import state
10 |
11 |
12 | class HashError(Exception):
13 | pass
14 |
15 |
16 | class DiscStorage:
17 | def __init__(self, directory):
18 | self.directory = pathlib.Path(directory)
19 |
20 | def _ensure_directory(self):
21 | self.directory.mkdir(exist_ok=True, parents=True)
22 | gitignore = self.directory / ".gitignore"
23 | if not gitignore.exists():
24 | gitignore.write_text(
25 | "# ignore all snapshots which are not referred in the source\n*-new.*\n",
26 | "utf-8",
27 | )
28 |
29 | def save(self, name, data):
30 | assert "*" not in name
31 | self._ensure_directory()
32 | (self.directory / name).write_bytes(data)
33 |
34 | def read(self, name):
35 | return self._lookup_path(name).read_bytes()
36 |
37 | def prune_new_files(self):
38 | for file in self.directory.glob("*-new.*"):
39 | file.unlink()
40 |
41 | def list(self) -> Set[str]:
42 | if self.directory.exists():
43 | return {item.name for item in self.directory.iterdir()} - {".gitignore"}
44 | else:
45 | return set()
46 |
47 | def persist(self, name):
48 | try:
49 | file = self._lookup_path(name)
50 | except HashError:
51 | return
52 | if file.stem.endswith("-new"):
53 | stem = file.stem[:-4]
54 | file.rename(file.with_name(stem + file.suffix))
55 |
56 | def _lookup_path(self, name) -> pathlib.Path:
57 | files = list(self.directory.glob(name))
58 |
59 | if len(files) > 1:
60 | raise HashError(f"hash collision files={sorted(f.name for f in files)}")
61 |
62 | if not files:
63 | raise HashError(f"hash {name!r} is not found in the DiscStorage")
64 |
65 | return files[0]
66 |
67 | def lookup_all(self, name) -> Set[str]:
68 | return {file.name for file in self.directory.glob(name)}
69 |
70 | def remove(self, name):
71 | self._lookup_path(name).unlink()
72 |
73 |
74 | class external:
75 | def __init__(self, name: str):
76 | """External objects are used as a representation for outsourced data.
77 | You should not create them directly.
78 |
79 | The external data is by default stored inside `/.inline-snapshot/external`,
80 | where `` is replaced by the directory containing the Pytest configuration file, if any.
81 | To store data in a different location, set the `storage-dir` option in pyproject.toml.
82 | Data which is outsourced but not referenced in the source code jet has a '-new' suffix in the filename.
83 |
84 | Parameters:
85 | name: the name of the external stored object.
86 | """
87 |
88 | m = re.fullmatch(r"([0-9a-fA-F]*)\*?(\.[a-zA-Z0-9]*)", name)
89 |
90 | if m:
91 | self._hash, self._suffix = m.groups()
92 | else:
93 | raise ValueError(
94 | "path has to be of the form . or *."
95 | )
96 |
97 | @property
98 | def _path(self):
99 | return f"{self._hash}*{self._suffix}"
100 |
101 | def __repr__(self):
102 | """Returns the representation of the external object.
103 |
104 | The length of the hash can be specified in the
105 | [config](configuration.md).
106 | """
107 | hash = self._hash[: _config.config.hash_length]
108 |
109 | if len(hash) == 64:
110 | return f'external("{hash}{self._suffix}")'
111 | else:
112 | return f'external("{hash}*{self._suffix}")'
113 |
114 | def __eq__(self, other):
115 | """Two external objects are equal if they have the same hash and
116 | suffix."""
117 | if not isinstance(other, external):
118 | return NotImplemented
119 |
120 | min_hash_len = min(len(self._hash), len(other._hash))
121 |
122 | if self._hash[:min_hash_len] != other._hash[:min_hash_len]:
123 | return False
124 |
125 | if self._suffix != other._suffix:
126 | return False
127 |
128 | return True
129 |
130 | def _load_value(self):
131 | assert state().storage is not None
132 | return state().storage.read(self._path)
133 |
134 |
135 | def outsource(data: Union[str, bytes], *, suffix: Optional[str] = None) -> external:
136 | """Outsource some data into an external file.
137 |
138 | ``` pycon
139 | >>> png_data = b"some_bytes" # should be the replaced with your actual data
140 | >>> outsource(png_data, suffix=".png")
141 | external("212974ed1835*.png")
142 |
143 | ```
144 |
145 | Parameters:
146 | data: data which should be outsourced. strings are encoded with `"utf-8"`.
147 |
148 | suffix: overwrite file suffix. The default is `".bin"` if data is an instance of `#!python bytes` and `".txt"` for `#!python str`.
149 |
150 | Returns:
151 | The external data.
152 | """
153 | if isinstance(data, str):
154 | data = data.encode("utf-8")
155 | if suffix is None:
156 | suffix = ".txt"
157 |
158 | elif isinstance(data, bytes):
159 | if suffix is None:
160 | suffix = ".bin"
161 | else:
162 | raise TypeError("data has to be of type bytes | str")
163 |
164 | if not suffix or suffix[0] != ".":
165 | raise ValueError("suffix has to start with a '.' like '.png'")
166 |
167 | m = hashlib.sha256()
168 | m.update(data)
169 | hash = m.hexdigest()
170 |
171 | storage = state().storage
172 |
173 | assert storage is not None
174 |
175 | name = hash + suffix
176 |
177 | if not storage.lookup_all(name):
178 | path = hash + "-new" + suffix
179 | storage.save(path, data)
180 |
181 | return external(name)
182 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_find_external.py:
--------------------------------------------------------------------------------
1 | import ast
2 | import pathlib
3 | from typing import Set
4 |
5 | from executing import Source
6 |
7 | from ._global_state import state
8 | from ._rewrite_code import ChangeRecorder
9 | from ._rewrite_code import end_of
10 | from ._rewrite_code import start_of
11 |
12 |
13 | def contains_import(tree, module, name):
14 | for node in tree.body:
15 | if (
16 | isinstance(node, ast.ImportFrom)
17 | and node.module == module
18 | and any(alias.name == name for alias in node.names)
19 | ):
20 | return True
21 | return False
22 |
23 |
24 | def used_externals_in(source) -> Set[str]:
25 | tree = ast.parse(source)
26 |
27 | if not contains_import(tree, "inline_snapshot", "external"):
28 | return set()
29 |
30 | usages = []
31 |
32 | for node in ast.walk(tree):
33 | if (
34 | isinstance(node, ast.Call)
35 | and isinstance(node.func, ast.Name)
36 | and node.func.id == "external"
37 | ):
38 | usages.append(node)
39 |
40 | return {
41 | u.args[0].value
42 | for u in usages
43 | if u.args and isinstance(u.args[0], ast.Constant)
44 | }
45 |
46 |
47 | def used_externals() -> Set[str]:
48 | result = set()
49 | for filename in state().files_with_snapshots:
50 | result |= used_externals_in(pathlib.Path(filename).read_text("utf-8"))
51 |
52 | return result
53 |
54 |
55 | def unused_externals() -> Set[str]:
56 | storage = state().storage
57 | assert storage is not None
58 | unused_externals = storage.list()
59 | for name in used_externals():
60 | unused_externals -= storage.lookup_all(name)
61 |
62 | return unused_externals
63 |
64 |
65 | def ensure_import(filename, imports, recorder: ChangeRecorder):
66 | source = Source.for_filename(filename)
67 |
68 | change = recorder.new_change()
69 |
70 | tree = source.tree
71 | token = source.asttokens()
72 |
73 | to_add = []
74 |
75 | for module, names in imports.items():
76 | for name in names:
77 | if not contains_import(tree, module, name):
78 | to_add.append((module, name))
79 |
80 | assert isinstance(tree, ast.Module)
81 |
82 | last_import = None
83 | for node in tree.body:
84 | if not isinstance(node, (ast.ImportFrom, ast.Import)):
85 | break
86 | last_import = node
87 |
88 | if last_import is None:
89 | position = start_of(tree.body[0].first_token) # type: ignore
90 | else:
91 | last_token = last_import.last_token # type: ignore
92 | while True:
93 | next_token = token.next_token(last_token)
94 | if last_token.end[0] == next_token.end[0]:
95 | last_token = next_token
96 | else:
97 | break
98 | position = end_of(last_token)
99 |
100 | code = ""
101 | for module, name in to_add:
102 | code += f"\nfrom {module} import {name}\n"
103 |
104 | change.insert(position, code, filename=filename)
105 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_flags.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Set
4 | from typing import cast
5 |
6 | from ._types import Category
7 |
8 |
9 | class Flags:
10 | """
11 | fix: the value needs to be changed to pass the tests
12 | update: the value should be updated because the token-stream has changed
13 | create: the snapshot is empty `snapshot()`
14 | trim: the snapshot contains more values than necessary. 1 could be trimmed in `5 in snapshot([1,5])`.
15 | """
16 |
17 | def __init__(self, flags: set[Category] = set()):
18 | self.create = "create" in flags
19 | self.fix = "fix" in flags
20 | self.trim = "trim" in flags
21 | self.update = "update" in flags
22 |
23 | def to_set(self) -> set[Category]:
24 | return cast(Set[Category], {k for k, v in self.__dict__.items() if v})
25 |
26 | def __iter__(self):
27 | return (k for k, v in self.__dict__.items() if v)
28 |
29 | def __repr__(self):
30 | return f"Flags({self.to_set()})"
31 |
32 | @staticmethod
33 | def all() -> Flags:
34 | return Flags({"fix", "create", "update", "trim"})
35 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_format.py:
--------------------------------------------------------------------------------
1 | import subprocess as sp
2 | import warnings
3 |
4 | from rich.markup import escape
5 |
6 | from . import _config
7 | from ._problems import raise_problem
8 |
9 |
10 | def enforce_formatting():
11 | return _config.config.format_command is not None
12 |
13 |
14 | def file_mode_for_path(path):
15 | from black import FileMode
16 | from black import find_pyproject_toml
17 | from black import parse_pyproject_toml
18 |
19 | mode = FileMode()
20 | pyproject_path = find_pyproject_toml((), path)
21 | if pyproject_path is not None:
22 | config = parse_pyproject_toml(pyproject_path)
23 |
24 | if "line_length" in config:
25 | mode.line_length = int(config["line_length"])
26 | if "skip_magic_trailing_comma" in config:
27 | mode.magic_trailing_comma = not config["skip_magic_trailing_comma"]
28 | if "skip_string_normalization" in config:
29 | # The ``black`` command line argument is
30 | # ``--skip-string-normalization``, but the parameter for
31 | # ``black.Mode`` needs to be the opposite boolean of
32 | # ``skip-string-normalization``, hence the inverse boolean
33 | mode.string_normalization = not config["skip_string_normalization"]
34 | if "preview" in config:
35 | mode.preview = config["preview"]
36 |
37 | return mode
38 |
39 |
40 | def format_code(text, filename):
41 | if _config.config.format_command is not None:
42 | format_command = _config.config.format_command.format(filename=filename)
43 | result = sp.run(
44 | format_command, shell=True, input=text.encode("utf-8"), capture_output=True
45 | )
46 | if result.returncode != 0:
47 | raise_problem(
48 | f"""\
49 | [b]The format_command '{escape(format_command)}' caused the following error:[/b]
50 | """
51 | + result.stdout.decode("utf-8")
52 | + result.stderr.decode("utf-8")
53 | )
54 | return text
55 | return result.stdout.decode("utf-8")
56 |
57 | try:
58 | from black import format_str
59 | except ImportError:
60 | raise_problem(
61 | f"""\
62 | [b]inline-snapshot is not able to format your code.[/b]
63 | This issue can be solved by:
64 | * installing {escape('inline-snapshot[black]')} which gives you the same formatting like in older versions
65 | * 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).
66 | """
67 | )
68 | return text
69 |
70 | with warnings.catch_warnings():
71 | warnings.simplefilter("ignore")
72 |
73 | mode = file_mode_for_path(filename)
74 |
75 | try:
76 | return format_str(text, mode=mode)
77 | except:
78 | raise_problem(
79 | """\
80 | [b]black could not format your code, which might be caused by this issue:[/b]
81 | [link=https://github.com/15r10nk/inline-snapshot/issues/138]https://github.com/15r10nk/inline-snapshot/issues/138[/link]\
82 | """
83 | )
84 | return text
85 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_global_state.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import contextlib
4 | from dataclasses import dataclass
5 | from dataclasses import field
6 | from typing import TYPE_CHECKING
7 | from typing import Generator
8 |
9 | from ._flags import Flags
10 |
11 | if TYPE_CHECKING:
12 | from ._external import DiscStorage
13 |
14 |
15 | @dataclass
16 | class State:
17 | # snapshot
18 | missing_values: int = 0
19 | incorrect_values: int = 0
20 |
21 | snapshots: dict = field(default_factory=dict)
22 | update_flags: Flags = field(default_factory=Flags)
23 | active: bool = True
24 | files_with_snapshots: set[str] = field(default_factory=set)
25 |
26 | # external
27 | storage: DiscStorage | None = None
28 |
29 | flags: set[str] = field(default_factory=set)
30 |
31 |
32 | _latest_global_states: list[State] = []
33 |
34 | _current: State = State()
35 | _current.active = False
36 |
37 |
38 | def state() -> State:
39 | global _current
40 | return _current
41 |
42 |
43 | def enter_snapshot_context():
44 | global _current
45 | _latest_global_states.append(_current)
46 | _current = State()
47 |
48 |
49 | def leave_snapshot_context():
50 | global _current
51 | _current = _latest_global_states.pop()
52 |
53 |
54 | @contextlib.contextmanager
55 | def snapshot_env() -> Generator[State]:
56 |
57 | enter_snapshot_context()
58 |
59 | try:
60 | yield _current
61 | finally:
62 | leave_snapshot_context()
63 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_inline_snapshot.py:
--------------------------------------------------------------------------------
1 | import ast
2 | import inspect
3 | from typing import Any
4 | from typing import TypeVar
5 | from typing import cast
6 |
7 | from executing import Source
8 |
9 | from inline_snapshot._source_file import SourceFile
10 |
11 | from ._adapter.adapter import AdapterContext
12 | from ._adapter.adapter import FrameContext
13 | from ._change import CallArg
14 | from ._global_state import state
15 | from ._sentinels import undefined
16 | from ._snapshot.undecided_value import UndecidedValue
17 |
18 |
19 | class ReprWrapper:
20 | def __init__(self, func):
21 | self.func = func
22 |
23 | def __call__(self, *args, **kwargs):
24 | return self.func(*args, **kwargs)
25 |
26 | def __repr__(self):
27 | return self.func.__name__
28 |
29 |
30 | _T = TypeVar("_T")
31 |
32 |
33 | def repr_wrapper(func: _T) -> _T:
34 | return ReprWrapper(func) # type: ignore
35 |
36 |
37 | @repr_wrapper
38 | def snapshot(obj: Any = undefined) -> Any:
39 | """`snapshot()` is a placeholder for some value.
40 |
41 | `pytest --inline-snapshot=create` will create the value which matches your conditions.
42 |
43 | >>> assert 5 == snapshot()
44 | >>> assert 5 <= snapshot()
45 | >>> assert 5 >= snapshot()
46 | >>> assert 5 in snapshot()
47 |
48 | `snapshot()[key]` can be used to create sub-snapshots.
49 |
50 | The generated value will be inserted as argument to `snapshot()`
51 |
52 | >>> assert 5 == snapshot(5)
53 |
54 | `snapshot(value)` has general the semantic of an noop which returns `value`.
55 | """
56 | if not state().active:
57 | if obj is undefined:
58 | raise AssertionError(
59 | "your snapshot is missing a value run pytest with --inline-snapshot=create"
60 | )
61 | else:
62 | return obj
63 |
64 | frame = inspect.currentframe()
65 | assert frame is not None
66 | frame = frame.f_back
67 | assert frame is not None
68 | frame = frame.f_back
69 | assert frame is not None
70 |
71 | expr = Source.executing(frame)
72 |
73 | source = cast(Source, getattr(expr, "source", None) if expr is not None else None)
74 | context = AdapterContext(
75 | file=SourceFile(source),
76 | frame=FrameContext(globals=frame.f_globals, locals=frame.f_locals),
77 | )
78 |
79 | module = inspect.getmodule(frame)
80 | if module is not None and module.__file__ is not None:
81 | state().files_with_snapshots.add(module.__file__)
82 |
83 | key = id(frame.f_code), frame.f_lasti
84 |
85 | if key not in state().snapshots:
86 | node = expr.node
87 | if node is None:
88 | # we can run without knowing of the calling expression but we will not be able to fix code
89 | state().snapshots[key] = SnapshotReference(obj, None, context)
90 | else:
91 | assert isinstance(node, ast.Call)
92 | state().snapshots[key] = SnapshotReference(obj, expr, context)
93 | else:
94 | state().snapshots[key]._re_eval(obj, context)
95 |
96 | return state().snapshots[key]._value
97 |
98 |
99 | def used_externals(tree):
100 | return [
101 | n.args[0].value
102 | for n in ast.walk(tree)
103 | if isinstance(n, ast.Call)
104 | and isinstance(n.func, ast.Name)
105 | and n.func.id == "external"
106 | and n.args
107 | and isinstance(n.args[0], ast.Constant)
108 | ]
109 |
110 |
111 | class SnapshotReference:
112 | def __init__(self, value, expr, context: AdapterContext):
113 | self._expr = expr
114 | node = expr.node.args[0] if expr is not None and expr.node.args else None
115 | self._value = UndecidedValue(value, node, context)
116 |
117 | def _changes(self):
118 |
119 | if (
120 | self._value._old_value is undefined
121 | if self._expr is None
122 | else not self._expr.node.args
123 | ):
124 |
125 | if self._value._new_value is undefined:
126 | return
127 |
128 | new_code = self._value._new_code()
129 |
130 | yield CallArg(
131 | flag="create",
132 | file=self._value._file,
133 | node=self._expr.node if self._expr is not None else None,
134 | arg_pos=0,
135 | arg_name=None,
136 | new_code=new_code,
137 | new_value=self._value._new_value,
138 | )
139 |
140 | else:
141 |
142 | yield from self._value._get_changes()
143 |
144 | def _re_eval(self, obj, context: AdapterContext):
145 | self._value._re_eval(obj, context)
146 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_is.py:
--------------------------------------------------------------------------------
1 | import typing
2 | from typing import TYPE_CHECKING
3 |
4 | from inline_snapshot._unmanaged import declare_unmanaged
5 |
6 | if TYPE_CHECKING:
7 |
8 | T = typing.TypeVar("T")
9 |
10 | def Is(v: T) -> T:
11 | return v
12 |
13 | else:
14 |
15 | @declare_unmanaged
16 | class Is:
17 | def __init__(self, value):
18 | self.value = value
19 |
20 | def __eq__(self, other):
21 | return self.value == other
22 |
23 | def __repr__(self):
24 | return repr(self.value)
25 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_problems.py:
--------------------------------------------------------------------------------
1 | from typing import Callable
2 |
3 | from rich.console import Console
4 |
5 | all_problems = set()
6 |
7 |
8 | def raise_problem(message):
9 | all_problems.add(message)
10 |
11 |
12 | def report_problems(console: Callable[[], Console]):
13 |
14 | global all_problems
15 | if not all_problems:
16 | return
17 | console().rule("[red]Problems")
18 | for problem in all_problems:
19 | console().print(f"{problem}")
20 | console().print()
21 |
22 | all_problems = set()
23 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_rewrite_code.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import logging
4 | import pathlib
5 | import sys
6 | from collections import defaultdict
7 | from collections.abc import Iterable
8 | from dataclasses import dataclass
9 | from difflib import unified_diff
10 | from itertools import islice
11 |
12 | import asttokens.util
13 | from asttokens import LineNumbers
14 |
15 | from ._format import enforce_formatting
16 | from ._format import format_code
17 |
18 | if sys.version_info >= (3, 10):
19 | from itertools import pairwise
20 | else:
21 | from itertools import tee
22 |
23 | def pairwise(iterable): # type: ignore
24 | a, b = tee(iterable)
25 | next(b, None)
26 | return zip(a, b)
27 |
28 |
29 | @dataclass(order=True)
30 | class SourcePosition:
31 | lineno: int
32 | col_offset: int
33 |
34 | def offset(self, line_numbers):
35 | return line_numbers.line_to_offset(self.lineno, self.col_offset)
36 |
37 |
38 | @dataclass(order=True)
39 | class SourceRange:
40 | start: SourcePosition
41 | end: SourcePosition
42 |
43 | def __post_init__(self):
44 | if self.start > self.end:
45 | raise ValueError("range start should be lower then end")
46 |
47 |
48 | @dataclass(order=True)
49 | class Replacement:
50 | range: SourceRange
51 | text: str
52 | change_id: int = 0
53 |
54 |
55 | def start_of(obj) -> SourcePosition:
56 | if isinstance(obj, asttokens.util.Token):
57 | return SourcePosition(lineno=obj.start[0], col_offset=obj.start[1])
58 |
59 | if isinstance(obj, SourcePosition):
60 | return obj
61 |
62 | if isinstance(obj, SourceRange):
63 | return obj.start
64 |
65 | if isinstance(obj, tuple) and len(obj) == 2:
66 | return SourcePosition(lineno=obj[0], col_offset=obj[1])
67 |
68 | assert False
69 |
70 |
71 | def end_of(obj) -> SourcePosition:
72 | if isinstance(obj, asttokens.util.Token):
73 | return SourcePosition(lineno=obj.end[0], col_offset=obj.end[1])
74 |
75 | if isinstance(obj, SourceRange):
76 | return obj.end
77 |
78 | return start_of(obj)
79 |
80 |
81 | def range_of(obj):
82 | if isinstance(obj, tuple) and len(obj) == 2:
83 | return SourceRange(start_of(obj[0]), end_of(obj[1]))
84 |
85 | return SourceRange(start_of(obj), end_of(obj))
86 |
87 |
88 | class UsageError(Exception):
89 | pass
90 |
91 |
92 | class Change: # ChangeSet
93 | _next_change_id = 0
94 |
95 | def __init__(self, change_recorder):
96 | self.change_recorder = change_recorder
97 |
98 | self.change_recorder._changes.append(self)
99 |
100 | self.change_id = self._next_change_id
101 | type(self)._next_change_id += 1
102 |
103 | def replace(self, node, new_contend, *, filename):
104 | assert isinstance(new_contend, str)
105 |
106 | self._replace(
107 | filename,
108 | range_of(node),
109 | new_contend,
110 | )
111 |
112 | def delete(self, node, *, filename):
113 | self.replace(node, "", filename=filename)
114 |
115 | def insert(self, node, new_content, *, filename):
116 | self.replace(start_of(node), new_content, filename=filename)
117 |
118 | def _replace(self, filename, range, new_contend):
119 | source = self.change_recorder.get_source(filename)
120 | source.replacements.append(
121 | Replacement(range=range, text=new_contend, change_id=self.change_id)
122 | )
123 | source._check()
124 |
125 |
126 | class SourceFile:
127 | def __init__(self, filename: pathlib.Path):
128 | self.replacements: list[Replacement] = []
129 | self.filename = filename
130 | self.source = self.filename.read_text("utf-8")
131 |
132 | def rewrite(self):
133 | new_code = self.new_code()
134 |
135 | with open(self.filename, "bw") as code:
136 | code.write(new_code.encode())
137 |
138 | def virtual_write(self):
139 | self.source = self.new_code()
140 |
141 | def _check(self):
142 | replacements = list(self.replacements)
143 | replacements.sort()
144 |
145 | for r in replacements:
146 | assert r.range.start <= r.range.end, r
147 |
148 | for lhs, rhs in pairwise(replacements):
149 | assert lhs.range.end <= rhs.range.start, (lhs, rhs)
150 |
151 | def new_code(self) -> str:
152 | """Returns the new file contend or None if there are no replacepents to
153 | apply."""
154 | replacements = list(self.replacements)
155 | replacements.sort()
156 |
157 | self._check()
158 |
159 | code = self.filename.read_text("utf-8")
160 |
161 | format_whole_file = enforce_formatting() or code == format_code(
162 | code, self.filename
163 | )
164 |
165 | if not format_whole_file:
166 | logging.info(f"file is not formatted with black: {self.filename}")
167 | import black
168 |
169 | logging.info(f"black version: {black.__version__}")
170 |
171 | line_numbers = LineNumbers(code)
172 |
173 | new_code = asttokens.util.replace(
174 | code,
175 | [
176 | (
177 | r.range.start.offset(line_numbers),
178 | r.range.end.offset(line_numbers),
179 | r.text,
180 | )
181 | for r in replacements
182 | ],
183 | )
184 |
185 | if format_whole_file:
186 | new_code = format_code(new_code, self.filename)
187 |
188 | return new_code
189 |
190 | def diff(self):
191 | return "\n".join(
192 | islice(
193 | unified_diff(self.source.splitlines(), self.new_code().splitlines()),
194 | 2,
195 | None,
196 | )
197 | ).strip()
198 |
199 |
200 | class ChangeRecorder:
201 |
202 | def __init__(self):
203 | self._source_files = defaultdict(SourceFile)
204 | self._changes = []
205 |
206 | def get_source(self, filename) -> SourceFile:
207 | filename = pathlib.Path(filename)
208 | if filename not in self._source_files:
209 | self._source_files[filename] = SourceFile(filename)
210 |
211 | return self._source_files[filename]
212 |
213 | def files(self) -> Iterable[SourceFile]:
214 | return self._source_files.values()
215 |
216 | def new_change(self):
217 | return Change(self)
218 |
219 | def num_fixes(self):
220 | changes = set()
221 | for file in self._source_files.values():
222 | changes.update(change.change_id for change in file.replacements)
223 | return len(changes)
224 |
225 | def fix_all(self):
226 | for file in self._source_files.values():
227 | file.rewrite()
228 |
229 | def virtual_write(self):
230 | for file in self._source_files.values():
231 | file.virtual_write()
232 |
233 | def dump(self): # pragma: no cover
234 | for file in self._source_files.values():
235 | print("file:", file.filename)
236 | for change in file.replacements:
237 | print(" change:", change)
238 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_sentinels.py:
--------------------------------------------------------------------------------
1 | undefined = ...
2 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_snapshot/collection_value.py:
--------------------------------------------------------------------------------
1 | import ast
2 | from typing import Iterator
3 |
4 | from .._change import Change
5 | from .._change import Delete
6 | from .._change import ListInsert
7 | from .._change import Replace
8 | from .._global_state import state
9 | from .._sentinels import undefined
10 | from .._utils import value_to_token
11 | from .generic_value import GenericValue
12 | from .generic_value import clone
13 | from .generic_value import ignore_old_value
14 |
15 |
16 | class CollectionValue(GenericValue):
17 | _current_op = "x in snapshot"
18 |
19 | def __contains__(self, item):
20 | if self._old_value is undefined:
21 | state().missing_values += 1
22 |
23 | if self._new_value is undefined:
24 | self._new_value = [clone(item)]
25 | else:
26 | if item not in self._new_value:
27 | self._new_value.append(clone(item))
28 |
29 | if ignore_old_value() or self._old_value is undefined:
30 | return True
31 | else:
32 | return self._return(item in self._old_value)
33 |
34 | def _new_code(self):
35 | return self._file._value_to_code(self._new_value)
36 |
37 | def _get_changes(self) -> Iterator[Change]:
38 |
39 | if self._ast_node is None:
40 | elements = [None] * len(self._old_value)
41 | else:
42 | assert isinstance(self._ast_node, ast.List)
43 | elements = self._ast_node.elts
44 |
45 | for old_value, old_node in zip(self._old_value, elements):
46 | if old_value not in self._new_value:
47 | yield Delete(
48 | flag="trim",
49 | file=self._file,
50 | node=old_node,
51 | old_value=old_value,
52 | )
53 | continue
54 |
55 | # check for update
56 | new_token = value_to_token(old_value)
57 |
58 | if (
59 | old_node is not None
60 | and self._file._token_of_node(old_node) != new_token
61 | ):
62 | new_code = self._file._token_to_code(new_token)
63 |
64 | yield Replace(
65 | node=old_node,
66 | file=self._file,
67 | new_code=new_code,
68 | flag="update",
69 | old_value=old_value,
70 | new_value=old_value,
71 | )
72 |
73 | new_values = [v for v in self._new_value if v not in self._old_value]
74 | if new_values:
75 | yield ListInsert(
76 | flag="fix",
77 | file=self._file,
78 | node=self._ast_node,
79 | position=len(self._old_value),
80 | new_code=[self._file._value_to_code(v) for v in new_values],
81 | new_values=new_values,
82 | )
83 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_snapshot/dict_value.py:
--------------------------------------------------------------------------------
1 | import ast
2 | from typing import Iterator
3 |
4 | from .._adapter.adapter import AdapterContext
5 | from .._change import Change
6 | from .._change import Delete
7 | from .._change import DictInsert
8 | from .._global_state import state
9 | from .._inline_snapshot import UndecidedValue
10 | from .._sentinels import undefined
11 | from .generic_value import GenericValue
12 |
13 |
14 | class DictValue(GenericValue):
15 | _current_op = "snapshot[key]"
16 |
17 | def __getitem__(self, index):
18 |
19 | if self._new_value is undefined:
20 | self._new_value = {}
21 |
22 | if index not in self._new_value:
23 | old_value = self._old_value
24 | if old_value is undefined:
25 | state().missing_values += 1
26 | old_value = {}
27 |
28 | child_node = None
29 | if self._ast_node is not None:
30 | assert isinstance(self._ast_node, ast.Dict)
31 | if index in old_value:
32 | pos = list(old_value.keys()).index(index)
33 | child_node = self._ast_node.values[pos]
34 |
35 | self._new_value[index] = UndecidedValue(
36 | old_value.get(index, undefined), child_node, self._context
37 | )
38 |
39 | return self._new_value[index]
40 |
41 | def _re_eval(self, value, context: AdapterContext):
42 | super()._re_eval(value, context)
43 |
44 | if self._new_value is not undefined and self._old_value is not undefined:
45 | for key, s in self._new_value.items():
46 | if key in self._old_value:
47 | s._re_eval(self._old_value[key], context)
48 |
49 | def _new_code(self):
50 | return (
51 | "{"
52 | + ", ".join(
53 | [
54 | f"{self._file._value_to_code(k)}: {v._new_code()}"
55 | for k, v in self._new_value.items()
56 | if not isinstance(v, UndecidedValue)
57 | ]
58 | )
59 | + "}"
60 | )
61 |
62 | def _get_changes(self) -> Iterator[Change]:
63 |
64 | assert self._old_value is not undefined
65 |
66 | if self._ast_node is None:
67 | values = [None] * len(self._old_value)
68 | else:
69 | assert isinstance(self._ast_node, ast.Dict)
70 | values = self._ast_node.values
71 |
72 | for key, node in zip(self._old_value.keys(), values):
73 | if key in self._new_value:
74 | # check values with same keys
75 | yield from self._new_value[key]._get_changes()
76 | else:
77 | # delete entries
78 | yield Delete("trim", self._file, node, self._old_value[key])
79 |
80 | to_insert = []
81 | for key, new_value_element in self._new_value.items():
82 | if key not in self._old_value and not isinstance(
83 | new_value_element, UndecidedValue
84 | ):
85 | # add new values
86 | to_insert.append((key, new_value_element._new_code()))
87 |
88 | if to_insert:
89 | new_code = [(self._file._value_to_code(k), v) for k, v in to_insert]
90 | yield DictInsert(
91 | "create",
92 | self._file,
93 | self._ast_node,
94 | len(self._old_value),
95 | new_code,
96 | to_insert,
97 | )
98 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_snapshot/eq_value.py:
--------------------------------------------------------------------------------
1 | from typing import Iterator
2 | from typing import List
3 |
4 | from inline_snapshot._adapter.adapter import Adapter
5 |
6 | from .._change import Change
7 | from .._compare_context import compare_only
8 | from .._global_state import state
9 | from .._sentinels import undefined
10 | from .generic_value import GenericValue
11 | from .generic_value import clone
12 |
13 |
14 | class EqValue(GenericValue):
15 | _current_op = "x == snapshot"
16 | _changes: List[Change]
17 |
18 | def __eq__(self, other):
19 | if self._old_value is undefined:
20 | state().missing_values += 1
21 |
22 | if not compare_only() and self._new_value is undefined:
23 | self._changes = []
24 | adapter = Adapter(self._context).get_adapter(self._old_value, other)
25 | it = iter(adapter.assign(self._old_value, self._ast_node, clone(other)))
26 | while True:
27 | try:
28 | self._changes.append(next(it))
29 | except StopIteration as ex:
30 | self._new_value = ex.value
31 | break
32 |
33 | return self._return(self._old_value == other, self._new_value == other)
34 |
35 | def _new_code(self):
36 | return self._file._value_to_code(self._new_value)
37 |
38 | def _get_changes(self) -> Iterator[Change]:
39 | return iter(self._changes)
40 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_snapshot/generic_value.py:
--------------------------------------------------------------------------------
1 | import ast
2 | import copy
3 | from typing import Any
4 | from typing import Iterator
5 |
6 | from .._adapter.adapter import AdapterContext
7 | from .._adapter.adapter import get_adapter_type
8 | from .._change import Change
9 | from .._code_repr import code_repr
10 | from .._exceptions import UsageError
11 | from .._global_state import state
12 | from .._sentinels import undefined
13 | from .._types import SnapshotBase
14 | from .._unmanaged import Unmanaged
15 | from .._unmanaged import declare_unmanaged
16 | from .._unmanaged import update_allowed
17 |
18 |
19 | def clone(obj):
20 | new = copy.deepcopy(obj)
21 | if not obj == new:
22 | raise UsageError(
23 | f"""\
24 | inline-snapshot uses `copy.deepcopy` to copy objects,
25 | but the copied object is not equal to the original one:
26 |
27 | value = {code_repr(obj)}
28 | copied_value = copy.deepcopy(value)
29 | assert value == copied_value
30 |
31 | Please fix the way your object is copied or your __eq__ implementation.
32 | """
33 | )
34 | return new
35 |
36 |
37 | def ignore_old_value():
38 | return state().update_flags.fix or state().update_flags.update
39 |
40 |
41 | @declare_unmanaged
42 | class GenericValue(SnapshotBase):
43 | _new_value: Any
44 | _old_value: Any
45 | _current_op = "undefined"
46 | _ast_node: ast.Expr
47 | _context: AdapterContext
48 |
49 | def _return(self, result, new_result=True):
50 |
51 | if not result:
52 | state().incorrect_values += 1
53 | flags = state().update_flags
54 |
55 | if flags.fix or flags.create or flags.update or self._old_value is undefined:
56 | return new_result
57 | return result
58 |
59 | @property
60 | def _file(self):
61 | return self._context.file
62 |
63 | def get_adapter(self, value):
64 | return get_adapter_type(value)(self._context)
65 |
66 | def _re_eval(self, value, context: AdapterContext):
67 | self._context = context
68 |
69 | def re_eval(old_value, node, value):
70 | if isinstance(old_value, Unmanaged):
71 | old_value.value = value
72 | return
73 |
74 | assert type(old_value) is type(value)
75 |
76 | adapter = self.get_adapter(old_value)
77 | if adapter is not None and hasattr(adapter, "items"):
78 | old_items = adapter.items(old_value, node)
79 | new_items = adapter.items(value, node)
80 | assert len(old_items) == len(new_items)
81 |
82 | for old_item, new_item in zip(old_items, new_items):
83 | re_eval(old_item.value, old_item.node, new_item.value)
84 |
85 | else:
86 | if update_allowed(old_value):
87 | if not old_value == value:
88 | raise UsageError(
89 | "snapshot value should not change. Use Is(...) for dynamic snapshot parts."
90 | )
91 | else:
92 | assert False, "old_value should be converted to Unmanaged"
93 |
94 | re_eval(self._old_value, self._ast_node, value)
95 |
96 | def _ignore_old(self):
97 | return (
98 | state().update_flags.fix
99 | or state().update_flags.update
100 | or state().update_flags.create
101 | or self._old_value is undefined
102 | )
103 |
104 | def _visible_value(self):
105 | if self._ignore_old():
106 | return self._new_value
107 | else:
108 | return self._old_value
109 |
110 | def _get_changes(self) -> Iterator[Change]:
111 | raise NotImplementedError()
112 |
113 | def _new_code(self):
114 | raise NotImplementedError()
115 |
116 | def __repr__(self):
117 | return repr(self._visible_value())
118 |
119 | def _type_error(self, op):
120 | __tracebackhide__ = True
121 | raise TypeError(
122 | f"This snapshot cannot be use with `{op}`, because it was previously used with `{self._current_op}`"
123 | )
124 |
125 | def __eq__(self, _other):
126 | __tracebackhide__ = True
127 | self._type_error("==")
128 |
129 | def __le__(self, _other):
130 | __tracebackhide__ = True
131 | self._type_error("<=")
132 |
133 | def __ge__(self, _other):
134 | __tracebackhide__ = True
135 | self._type_error(">=")
136 |
137 | def __contains__(self, _other):
138 | __tracebackhide__ = True
139 | self._type_error("in")
140 |
141 | def __getitem__(self, _item):
142 | __tracebackhide__ = True
143 | self._type_error("snapshot[key]")
144 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_snapshot/min_max_value.py:
--------------------------------------------------------------------------------
1 | from typing import Iterator
2 |
3 | from .._change import Change
4 | from .._change import Replace
5 | from .._global_state import state
6 | from .._sentinels import undefined
7 | from .._utils import value_to_token
8 | from .generic_value import GenericValue
9 | from .generic_value import clone
10 | from .generic_value import ignore_old_value
11 |
12 |
13 | class MinMaxValue(GenericValue):
14 | """Generic implementation for <=, >="""
15 |
16 | @staticmethod
17 | def cmp(a, b):
18 | raise NotImplementedError
19 |
20 | def _generic_cmp(self, other):
21 | if self._old_value is undefined:
22 | state().missing_values += 1
23 |
24 | if self._new_value is undefined:
25 | self._new_value = clone(other)
26 | if self._old_value is undefined or ignore_old_value():
27 | return True
28 | return self._return(self.cmp(self._old_value, other))
29 | else:
30 | if not self.cmp(self._new_value, other):
31 | self._new_value = clone(other)
32 |
33 | return self._return(self.cmp(self._visible_value(), other))
34 |
35 | def _new_code(self):
36 | return self._file._value_to_code(self._new_value)
37 |
38 | def _get_changes(self) -> Iterator[Change]:
39 | new_token = value_to_token(self._new_value)
40 | if not self.cmp(self._old_value, self._new_value):
41 | flag = "fix"
42 | elif not self.cmp(self._new_value, self._old_value):
43 | flag = "trim"
44 | elif (
45 | self._ast_node is not None
46 | and self._file._token_of_node(self._ast_node) != new_token
47 | ):
48 | flag = "update"
49 | else:
50 | return
51 |
52 | new_code = self._file._token_to_code(new_token)
53 |
54 | yield Replace(
55 | node=self._ast_node,
56 | file=self._file,
57 | new_code=new_code,
58 | flag=flag,
59 | old_value=self._old_value,
60 | new_value=self._new_value,
61 | )
62 |
63 |
64 | class MinValue(MinMaxValue):
65 | """
66 | handles:
67 |
68 | >>> snapshot(5) <= 6
69 | True
70 |
71 | >>> 6 >= snapshot(5)
72 | True
73 |
74 | """
75 |
76 | _current_op = "x >= snapshot"
77 |
78 | @staticmethod
79 | def cmp(a, b):
80 | return a <= b
81 |
82 | __le__ = MinMaxValue._generic_cmp
83 |
84 |
85 | class MaxValue(MinMaxValue):
86 | """
87 | handles:
88 |
89 | >>> snapshot(5) >= 4
90 | True
91 |
92 | >>> 4 <= snapshot(5)
93 | True
94 |
95 | """
96 |
97 | _current_op = "x <= snapshot"
98 |
99 | @staticmethod
100 | def cmp(a, b):
101 | return a >= b
102 |
103 | __ge__ = MinMaxValue._generic_cmp
104 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_snapshot/undecided_value.py:
--------------------------------------------------------------------------------
1 | from typing import Iterator
2 |
3 | from inline_snapshot._adapter.adapter import adapter_map
4 |
5 | from .._adapter.adapter import AdapterContext
6 | from .._adapter.adapter import get_adapter_type
7 | from .._change import Change
8 | from .._change import Replace
9 | from .._sentinels import undefined
10 | from .._unmanaged import Unmanaged
11 | from .._unmanaged import map_unmanaged
12 | from .._utils import value_to_token
13 | from .generic_value import GenericValue
14 |
15 |
16 | class UndecidedValue(GenericValue):
17 | def __init__(self, old_value, ast_node, context: AdapterContext):
18 |
19 | old_value = adapter_map(old_value, map_unmanaged)
20 | self._old_value = old_value
21 | self._new_value = undefined
22 | self._ast_node = ast_node
23 | self._context = context
24 |
25 | def _change(self, cls):
26 | self.__class__ = cls
27 |
28 | def _new_code(self):
29 | assert False
30 |
31 | def _get_changes(self) -> Iterator[Change]:
32 |
33 | def handle(node, obj):
34 |
35 | adapter = get_adapter_type(obj)
36 | if adapter is not None and hasattr(adapter, "items"):
37 | for item in adapter.items(obj, node):
38 | yield from handle(item.node, item.value)
39 | return
40 |
41 | if not isinstance(obj, Unmanaged) and node is not None:
42 | new_token = value_to_token(obj)
43 | if self._file._token_of_node(node) != new_token:
44 | new_code = self._file._token_to_code(new_token)
45 |
46 | yield Replace(
47 | node=self._ast_node,
48 | file=self._file,
49 | new_code=new_code,
50 | flag="update",
51 | old_value=self._old_value,
52 | new_value=self._old_value,
53 | )
54 |
55 | if self._file._source is not None:
56 | yield from handle(self._ast_node, self._old_value)
57 |
58 | # functions which determine the type
59 |
60 | def __eq__(self, other):
61 | from .._snapshot.eq_value import EqValue
62 |
63 | self._change(EqValue)
64 | return self == other
65 |
66 | def __le__(self, other):
67 | from .._snapshot.min_max_value import MinValue
68 |
69 | self._change(MinValue)
70 | return self <= other
71 |
72 | def __ge__(self, other):
73 | from .._snapshot.min_max_value import MaxValue
74 |
75 | self._change(MaxValue)
76 | return self >= other
77 |
78 | def __contains__(self, other):
79 | from .._snapshot.collection_value import CollectionValue
80 |
81 | self._change(CollectionValue)
82 | return other in self
83 |
84 | def __getitem__(self, item):
85 | from .._snapshot.dict_value import DictValue
86 |
87 | self._change(DictValue)
88 | return self[item]
89 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_source_file.py:
--------------------------------------------------------------------------------
1 | import tokenize
2 | from pathlib import Path
3 |
4 | from executing import Source
5 |
6 | from inline_snapshot._format import enforce_formatting
7 | from inline_snapshot._format import format_code
8 | from inline_snapshot._utils import normalize
9 | from inline_snapshot._utils import simple_token
10 | from inline_snapshot._utils import value_to_token
11 |
12 | from ._utils import ignore_tokens
13 |
14 |
15 | class SourceFile:
16 | _source: Source
17 |
18 | def __init__(self, source: Source):
19 | self._source = source
20 |
21 | @property
22 | def filename(self):
23 | return self._source.filename
24 |
25 | def _format(self, text):
26 | if self._source is None or enforce_formatting():
27 | return text
28 | else:
29 | return format_code(text, Path(self._source.filename))
30 |
31 | def asttokens(self):
32 | return self._source.asttokens()
33 |
34 | def _token_to_code(self, tokens):
35 | return self._format(tokenize.untokenize(tokens)).strip()
36 |
37 | def _value_to_code(self, value):
38 | return self._token_to_code(value_to_token(value))
39 |
40 | def _token_of_node(self, node):
41 |
42 | return list(
43 | normalize(
44 | [
45 | simple_token(t.type, t.string)
46 | for t in self._source.asttokens().get_tokens(node)
47 | if t.type not in ignore_tokens
48 | ]
49 | )
50 | )
51 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_types.py:
--------------------------------------------------------------------------------
1 | """The following types are for type checking only."""
2 |
3 | from typing import Literal
4 | from typing import Protocol
5 | from typing import TypeVar
6 |
7 | T = TypeVar("T", covariant=True)
8 |
9 |
10 | class SnapshotBase:
11 | pass
12 |
13 |
14 | class Snapshot(Protocol[T]):
15 | """Can be used to annotate function arguments which accept snapshot
16 | values.
17 |
18 | You can annotate function arguments with `Snapshot[T]` to declare that a snapshot-value can be passed as function argument.
19 | `Snapshot[T]` is a type alias for `T`, which allows you to pass `int` values instead of `int` snapshots.
20 |
21 |
22 | Example:
23 |
24 | ``` python
25 | from typing import Optional
26 | from inline_snapshot import snapshot, Snapshot
27 |
28 | # required snapshots
29 |
30 |
31 | def check_in_bounds(value, lower: Snapshot[int], upper: Snapshot[int]):
32 | assert lower <= value <= upper
33 |
34 |
35 | def test_numbers():
36 | for c in "hello world":
37 | check_in_bounds(ord(c), snapshot(32), snapshot(119))
38 |
39 | # use with normal values
40 | check_in_bounds(5, 0, 10)
41 |
42 |
43 | # optional snapshots
44 |
45 |
46 | def check_container(
47 | value,
48 | *,
49 | value_repr: Optional[Snapshot[str]] = None,
50 | length: Optional[Snapshot[int]] = None
51 | ):
52 | if value_repr is not None:
53 | assert repr(value) == value_repr
54 |
55 | if length is not None:
56 | assert len(value) == length
57 |
58 |
59 | def test_container():
60 | check_container([1, 2], value_repr=snapshot("[1, 2]"), length=snapshot(2))
61 |
62 | check_container({1, 1}, length=snapshot(1))
63 | ```
64 | """
65 |
66 | def __eq__(self, other: object, /) -> bool: ... # pragma: no cover
67 |
68 |
69 | Category = Literal["update", "fix", "create", "trim"]
70 | """See [categories](categories.md)"""
71 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_unmanaged.py:
--------------------------------------------------------------------------------
1 | try:
2 | import dirty_equals # type: ignore
3 | except ImportError: # pragma: no cover
4 |
5 | def is_dirty_equal(value):
6 | return False
7 |
8 | else:
9 |
10 | def is_dirty_equal(value):
11 | return isinstance(value, dirty_equals.DirtyEquals) or (
12 | isinstance(value, type) and issubclass(value, dirty_equals.DirtyEquals)
13 | )
14 |
15 |
16 | def update_allowed(value):
17 | global unmanaged_types
18 | return not (is_dirty_equal(value) or isinstance(value, tuple(unmanaged_types))) # type: ignore
19 |
20 |
21 | unmanaged_types = []
22 |
23 |
24 | def is_unmanaged(value):
25 | return not update_allowed(value)
26 |
27 |
28 | def declare_unmanaged(data_type):
29 | global unmanaged_types
30 | unmanaged_types.append(data_type)
31 | return data_type
32 |
33 |
34 | class Unmanaged:
35 | def __init__(self, value):
36 | self.value = value
37 |
38 | def __eq__(self, other):
39 | assert not isinstance(other, Unmanaged)
40 |
41 | return self.value == other
42 |
43 | def __repr__(self):
44 | return repr(self.value)
45 |
46 |
47 | def map_unmanaged(value):
48 | if is_unmanaged(value):
49 | return Unmanaged(value)
50 | else:
51 | return value
52 |
--------------------------------------------------------------------------------
/src/inline_snapshot/_utils.py:
--------------------------------------------------------------------------------
1 | import ast
2 | import io
3 | import token
4 | import tokenize
5 | from collections import namedtuple
6 |
7 | from ._code_repr import code_repr
8 |
9 |
10 | def normalize_strings(token_sequence):
11 | """Normalize string concattenanion.
12 |
13 | "a" "b" -> "ab"
14 | """
15 |
16 | current_string = None
17 | for t in token_sequence:
18 | if (
19 | t.type == token.STRING
20 | and not t.string.startswith(("'''", '"""', "b'''", 'b"""'))
21 | and t.string.startswith(("'", '"', "b'", 'b"'))
22 | ):
23 | if current_string is None:
24 | current_string = ast.literal_eval(t.string)
25 | else:
26 | current_string += ast.literal_eval(t.string)
27 |
28 | continue
29 |
30 | if current_string is not None:
31 | yield simple_token(token.STRING, repr(current_string))
32 | current_string = None
33 |
34 | yield t
35 |
36 | if current_string is not None:
37 | yield simple_token(token.STRING, repr(current_string))
38 |
39 |
40 | def skip_trailing_comma(token_sequence):
41 | token_sequence = list(token_sequence)
42 |
43 | for index, token in enumerate(token_sequence):
44 | if index + 1 < len(token_sequence):
45 | next_token = token_sequence[index + 1]
46 |
47 | if token.string == "," and next_token.string in ("]", ")", "}"):
48 | continue
49 | yield token
50 |
51 |
52 | def normalize(token_sequence):
53 | return skip_trailing_comma(normalize_strings(token_sequence))
54 |
55 |
56 | ignore_tokens = (token.NEWLINE, token.ENDMARKER, token.NL)
57 |
58 |
59 | # based on ast.unparse
60 | def _str_literal_helper(string, *, quote_types):
61 | """Helper for writing string literals, minimizing escapes.
62 |
63 | Returns the tuple (string literal to write, possible quote types).
64 | """
65 |
66 | def escape_char(c):
67 | # \n and \t are non-printable, but we only escape them if
68 | # escape_special_whitespace is True
69 | if c in "\n\t":
70 | return c
71 | # Always escape backslashes and other non-printable characters
72 | if c == "\\" or not c.isprintable():
73 | return c.encode("unicode_escape").decode("ascii")
74 | if c == extra:
75 | return "\\" + c
76 | return c
77 |
78 | extra = ""
79 | if "'''" in string and '"""' in string:
80 | extra = '"' if string.count("'") >= string.count('"') else "'"
81 |
82 | escaped_string = "".join(map(escape_char, string))
83 |
84 | possible_quotes = [q for q in quote_types if q not in escaped_string]
85 |
86 | if escaped_string:
87 | # Sort so that we prefer '''"''' over """\""""
88 | possible_quotes.sort(key=lambda q: q[0] == escaped_string[-1])
89 | # If we're using triple quotes and we'd need to escape a final
90 | # quote, escape it
91 | if possible_quotes[0][0] == escaped_string[-1]:
92 | assert len(possible_quotes[0]) == 3
93 | escaped_string = escaped_string[:-1] + "\\" + escaped_string[-1]
94 | return escaped_string, possible_quotes
95 |
96 |
97 | def triple_quote(string):
98 | """Write string literal value with a best effort attempt to avoid
99 | backslashes."""
100 | string, quote_types = _str_literal_helper(string, quote_types=['"""', "'''"])
101 | quote_type = quote_types[0]
102 |
103 | string = string.replace(" \n", " \\n\\\n")
104 |
105 | string = "\\\n" + string
106 |
107 | if not string.endswith("\n"):
108 | string = string + "\\\n"
109 |
110 | return f"{quote_type}{string}{quote_type}"
111 |
112 |
113 | class simple_token(namedtuple("simple_token", "type,string")):
114 |
115 | def __eq__(self, other):
116 | if self.type == other.type == 3:
117 | if any(
118 | s.startswith(suffix)
119 | for s in (self.string, other.string)
120 | for suffix in ("f", "rf", "Rf", "F", "rF", "RF")
121 | ):
122 | return False
123 |
124 | return ast.literal_eval(self.string) == ast.literal_eval(
125 | other.string
126 | ) and self.string.replace("'", '"') == other.string.replace("'", '"')
127 | else:
128 | return super().__eq__(other)
129 |
130 |
131 | def value_to_token(value):
132 | input = io.StringIO(code_repr(value))
133 |
134 | def map_string(tok):
135 | """Convert strings with newlines in triple quoted strings."""
136 | if tok.type == token.STRING:
137 | s = ast.literal_eval(tok.string)
138 | if isinstance(s, str) and (
139 | ("\n" in s and s[-1] != "\n") or s.count("\n") > 1
140 | ):
141 | # unparse creates a triple quoted string here,
142 | # because it thinks that the string should be a docstring
143 | triple_quoted_string = triple_quote(s)
144 |
145 | assert ast.literal_eval(triple_quoted_string) == s
146 |
147 | return simple_token(tok.type, triple_quoted_string)
148 |
149 | return simple_token(tok.type, tok.string)
150 |
151 | return [
152 | map_string(t)
153 | for t in tokenize.generate_tokens(input.readline)
154 | if t.type not in ignore_tokens
155 | ]
156 |
--------------------------------------------------------------------------------
/src/inline_snapshot/extra.py:
--------------------------------------------------------------------------------
1 | """The following functions are build on top of inline-snapshot and could also
2 | be implemented in an extra library.
3 |
4 | They are part of inline-snapshot because they are general useful and do
5 | not depend on other libraries.
6 | """
7 |
8 | import contextlib
9 | import io
10 | import warnings
11 | from contextlib import redirect_stderr
12 | from contextlib import redirect_stdout
13 | from typing import List
14 | from typing import Tuple
15 | from typing import Union
16 |
17 | from inline_snapshot._types import Snapshot
18 |
19 |
20 | @contextlib.contextmanager
21 | def raises(exception: Snapshot[str]):
22 | """Check that an exception is raised.
23 |
24 | Parameters:
25 | exception: snapshot which is compared with `#!python f"{type}: {message}"` if an exception occurred or `#!python ""` if no exception was raised.
26 |
27 | === "original"
28 |
29 |
30 | ``` python
31 | from inline_snapshot import snapshot
32 | from inline_snapshot.extra import raises
33 |
34 |
35 | def test_raises():
36 | with raises(snapshot()):
37 | 1 / 0
38 | ```
39 |
40 | === "--inline-snapshot=create"
41 |
42 |
43 | ``` python hl_lines="6"
44 | from inline_snapshot import snapshot
45 | from inline_snapshot.extra import raises
46 |
47 |
48 | def test_raises():
49 | with raises(snapshot("ZeroDivisionError: division by zero")):
50 | 1 / 0
51 | ```
52 | """
53 |
54 | try:
55 | yield
56 | except Exception as ex:
57 | msg = str(ex)
58 | if "\n" in msg:
59 | assert f"{type(ex).__name__}:\n{ex}" == exception
60 | else:
61 | assert f"{type(ex).__name__}: {ex}" == exception
62 | else:
63 | assert "" == exception
64 |
65 |
66 | @contextlib.contextmanager
67 | def prints(*, stdout: Snapshot[str] = "", stderr: Snapshot[str] = ""):
68 | """Uses `contextlib.redirect_stderr/stdout` to capture the output and
69 | compare it with the snapshots. `dirty_equals.IsStr` can be used to ignore
70 | the output if needed.
71 |
72 | Parameters:
73 | stdout: snapshot which is compared to the recorded output
74 | stderr: snapshot which is compared to the recorded error output
75 |
76 | === "original"
77 |
78 |
79 | ``` python
80 | from inline_snapshot import snapshot
81 | from inline_snapshot.extra import prints
82 | import sys
83 |
84 |
85 | def test_prints():
86 | with prints(stdout=snapshot(), stderr=snapshot()):
87 | print("hello world")
88 | print("some error", file=sys.stderr)
89 | ```
90 |
91 | === "--inline-snapshot=create"
92 |
93 |
94 | ``` python hl_lines="7 8 9"
95 | from inline_snapshot import snapshot
96 | from inline_snapshot.extra import prints
97 | import sys
98 |
99 |
100 | def test_prints():
101 | with prints(
102 | stdout=snapshot("hello world\\n"), stderr=snapshot("some error\\n")
103 | ):
104 | print("hello world")
105 | print("some error", file=sys.stderr)
106 | ```
107 |
108 | === "ignore stdout"
109 |
110 |
111 | ``` python hl_lines="3 9 10"
112 | from inline_snapshot import snapshot
113 | from inline_snapshot.extra import prints
114 | from dirty_equals import IsStr
115 | import sys
116 |
117 |
118 | def test_prints():
119 | with prints(
120 | stdout=IsStr(),
121 | stderr=snapshot("some error\\n"),
122 | ):
123 | print("hello world")
124 | print("some error", file=sys.stderr)
125 | ```
126 | """
127 |
128 | with redirect_stdout(io.StringIO()) as stdout_io:
129 | with redirect_stderr(io.StringIO()) as stderr_io:
130 | yield
131 |
132 | assert stderr_io.getvalue() == stderr
133 | assert stdout_io.getvalue() == stdout
134 |
135 |
136 | Warning = Union[str, Tuple[int, str], Tuple[str, str], Tuple[str, int, str]]
137 |
138 |
139 | @contextlib.contextmanager
140 | def warns(
141 | expected_warnings: Snapshot[List[Warning]],
142 | /,
143 | include_line: bool = False,
144 | include_file: bool = False,
145 | ):
146 | """
147 | Captures warnings with `warnings.catch_warnings` and compares them against expected warnings.
148 |
149 | Parameters:
150 | expected_warnings: Snapshot containing a list of expected warnings.
151 | include_line: If `True`, each expected warning is a tuple `(linenumber, message)`.
152 | include_file: If `True`, each expected warning is a tuple `(filename, message)`.
153 |
154 | The format of the expected warning:
155 |
156 | - `(filename, linenumber, message)` if both `include_line` and `include_file` are `True`.
157 | - `(linenumber, message)` if only `include_line` is `True`.
158 | - `(filename, message)` if only `include_file` is `True`.
159 | - A string `message` if both are `False`.
160 |
161 | === "original"
162 |
163 |
164 | ``` python
165 | from inline_snapshot import snapshot
166 | from inline_snapshot.extra import warns
167 | from warnings import warn
168 |
169 |
170 | def test_warns():
171 | with warns(snapshot(), include_line=True):
172 | warn("some problem")
173 | ```
174 |
175 | === "--inline-snapshot=create"
176 |
177 |
178 | ``` python hl_lines="7"
179 | from inline_snapshot import snapshot
180 | from inline_snapshot.extra import warns
181 | from warnings import warn
182 |
183 |
184 | def test_warns():
185 | with warns(snapshot([(8, "UserWarning: some problem")]), include_line=True):
186 | warn("some problem")
187 | ```
188 | """
189 | with warnings.catch_warnings(record=True) as result:
190 | warnings.simplefilter("always")
191 | yield
192 |
193 | def make_warning(w):
194 | message = f"{w.category.__name__}: {w.message}"
195 | if not include_line and not include_file:
196 | return message
197 | message = (message,)
198 |
199 | if include_line:
200 | message = (w.lineno, *message)
201 | if include_file:
202 | message = (w.filename, *message)
203 |
204 | return message
205 |
206 | assert [make_warning(w) for w in result] == expected_warnings
207 |
--------------------------------------------------------------------------------
/src/inline_snapshot/fix_pytest_diff.py:
--------------------------------------------------------------------------------
1 | from typing import IO
2 | from typing import Any
3 | from typing import Set
4 |
5 | from inline_snapshot._is import Is
6 | from inline_snapshot._snapshot.generic_value import GenericValue
7 | from inline_snapshot._unmanaged import Unmanaged
8 |
9 |
10 | def fix_pytest_diff():
11 | from _pytest._io.pprint import PrettyPrinter
12 |
13 | def _pprint_snapshot(
14 | self,
15 | object: Any,
16 | stream: IO[str],
17 | indent: int,
18 | allowance: int,
19 | context: Set[int],
20 | level: int,
21 | ) -> None:
22 | self._format(object._old_value, stream, indent, allowance, context, level)
23 |
24 | PrettyPrinter._dispatch[GenericValue.__repr__] = _pprint_snapshot
25 |
26 | def _pprint_unmanaged(
27 | self,
28 | object: Any,
29 | stream: IO[str],
30 | indent: int,
31 | allowance: int,
32 | context: Set[int],
33 | level: int,
34 | ) -> None:
35 | self._format(object.value, stream, indent, allowance, context, level)
36 |
37 | PrettyPrinter._dispatch[Unmanaged.__repr__] = _pprint_unmanaged
38 |
39 | def _pprint_is(
40 | self,
41 | object: Any,
42 | stream: IO[str],
43 | indent: int,
44 | allowance: int,
45 | context: Set[int],
46 | level: int,
47 | ) -> None:
48 | self._format(object.value, stream, indent, allowance, context, level)
49 |
50 | PrettyPrinter._dispatch[Is.__repr__] = _pprint_is
51 |
--------------------------------------------------------------------------------
/src/inline_snapshot/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/15r10nk/inline-snapshot/43d7f86e397708695bd9b14fe024d7c8da699798/src/inline_snapshot/py.typed
--------------------------------------------------------------------------------
/src/inline_snapshot/pydantic_fix.py:
--------------------------------------------------------------------------------
1 | from ._types import SnapshotBase
2 |
3 | is_fixed = False
4 |
5 |
6 | def pydantic_fix():
7 | global is_fixed
8 | if is_fixed:
9 | return # pragma: no cover
10 | is_fixed = True
11 |
12 | try:
13 | from pydantic import BaseModel
14 | except ImportError: # pragma: no cover
15 | return
16 |
17 | import pydantic
18 |
19 | if not pydantic.version.VERSION.startswith("1."):
20 | return
21 |
22 | origin_eq = BaseModel.__eq__
23 |
24 | def new_eq(self, other):
25 | if isinstance(other, SnapshotBase): # type: ignore
26 | return other == self
27 | else:
28 | return origin_eq(self, other)
29 |
30 | BaseModel.__eq__ = new_eq
31 |
--------------------------------------------------------------------------------
/src/inline_snapshot/syntax_warnings.py:
--------------------------------------------------------------------------------
1 | class InlineSnapshotSyntaxWarning(Warning):
2 | pass
3 |
4 |
5 | class InlineSnapshotInfo(Warning):
6 | pass
7 |
--------------------------------------------------------------------------------
/src/inline_snapshot/testing/__init__.py:
--------------------------------------------------------------------------------
1 | from ._example import Example
2 |
3 | __all__ = ("Example",)
4 |
--------------------------------------------------------------------------------
/testing/generate_tests.py:
--------------------------------------------------------------------------------
1 | import ast
2 | import random
3 | import sys
4 |
5 | from pysource_minimize import minimize
6 |
7 | from inline_snapshot import UsageError
8 | from inline_snapshot.testing import Example
9 |
10 | header = """
11 | from dataclasses import dataclass
12 |
13 | @dataclass
14 | class A:
15 | a:int
16 | b:int=5
17 | c:int=5
18 |
19 | def f(v):
20 | return v
21 |
22 | """
23 |
24 |
25 | def chose(l):
26 | return random.choice(l)
27 |
28 |
29 | def gen_new_value(values):
30 |
31 | kind = chose(["dataclass", "dict", "tuple", "list", "f"])
32 |
33 | if kind == "dataclass":
34 | data_type = chose(["A"])
35 | num_args = chose(range(1, 3))
36 | num_pos_args = chose(range(num_args))
37 | assert num_pos_args <= num_args
38 |
39 | args_names = ["a", "b", "c"]
40 | random.shuffle(args_names)
41 |
42 | args = [
43 | *[chose(values) for _ in range(num_pos_args)],
44 | *[
45 | f"{args_names.pop()}={chose(values)}"
46 | for _ in range(num_args - num_pos_args)
47 | ],
48 | ]
49 |
50 | return f"{data_type}({', '.join(args)})"
51 |
52 | if kind == "tuple":
53 | return "(" + ", ".join(chose(values) for _ in range(3)) + ")"
54 |
55 | if kind == "list":
56 | return "[" + ", ".join(chose(values) for _ in range(3)) + "]"
57 |
58 | if kind == "dict":
59 | return (
60 | "[" + ", ".join(f"{chose(values)}:{chose(values)}" for _ in range(3)) + "]"
61 | )
62 |
63 | if kind == "f":
64 | return f"f({chose(values)})"
65 |
66 |
67 | context = dict()
68 | exec(header, context, context)
69 |
70 |
71 | def is_valid_value(v):
72 | try:
73 | eval(v, context, {})
74 | except:
75 | return False
76 | return True
77 |
78 |
79 | def value_of(v):
80 | return eval(v, context, {})
81 |
82 |
83 | def gen_values():
84 | values = [
85 | "1",
86 | "'abc'",
87 | "True",
88 | "list()",
89 | "dict()",
90 | "set()",
91 | "tuple()",
92 | "[]",
93 | "{}",
94 | "()",
95 | "{*()}",
96 | ]
97 |
98 | while len(values) <= 500:
99 | new_value = gen_new_value([v for v in values if len(v) < 50])
100 |
101 | if is_valid_value(new_value):
102 | values.append(new_value)
103 |
104 | return values
105 |
106 |
107 | def fmt(code):
108 | return ast.unparse(ast.parse(code))
109 |
110 |
111 | class Store:
112 | def __eq__(self, other):
113 | self.value = other
114 | return True
115 |
116 |
117 | def gen_test(values):
118 |
119 | va = chose(values)
120 | vb = chose(values)
121 |
122 | test = f"""
123 | from inline_snapshot import snapshot
124 | {header}
125 |
126 | def test_a():
127 | assert {va} == snapshot({vb})
128 | assert {vb} == snapshot({va})
129 |
130 | def test_b():
131 | for _ in [1,2,3]:
132 | assert {va} == snapshot({vb})
133 | assert {vb} == snapshot({va})
134 |
135 | def test_c():
136 | snapshot({vb})
137 | snapshot({va})
138 | """
139 | test = fmt(test)
140 |
141 | if value_of(va) == value_of(vb):
142 | return
143 |
144 | def contains_bug(code):
145 |
146 | try:
147 | Example({"test.py": code}).run_inline(["--inline-snapshot=fix"])
148 | except UsageError:
149 | return False
150 | except KeyboardInterrupt:
151 | raise
152 | except AssertionError:
153 | return True
154 | except:
155 | return True
156 |
157 | return False
158 |
159 | if not contains_bug(test):
160 | return
161 |
162 | test = minimize(test, checker=contains_bug)
163 | print("minimal code:")
164 | print("=" * 20)
165 | print(test)
166 | sys.exit()
167 |
168 |
169 | if __name__ == "__main__":
170 | values = gen_values()
171 |
172 | for i in range(100000):
173 | gen_test(values)
174 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/15r10nk/inline-snapshot/43d7f86e397708695bd9b14fe024d7c8da699798/tests/__init__.py
--------------------------------------------------------------------------------
/tests/_is_normalized.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot._unmanaged import declare_unmanaged
2 |
3 |
4 | @declare_unmanaged
5 | class IsNormalized:
6 | def __init__(self, func, value) -> None:
7 | self._func = func
8 | self._value = value
9 | self._last_value = None
10 |
11 | def __eq__(self, other) -> bool:
12 | self._last_value = self._func(other)
13 | return self._last_value == self._value
14 |
15 | def __repr__(self):
16 | return f"IsNormalized({self._value}, should_be={self._last_value!r})"
17 |
18 |
19 | def normalization(func):
20 | def f(value):
21 | return IsNormalized(func, value)
22 |
23 | return f
24 |
--------------------------------------------------------------------------------
/tests/adapter/test_change_types.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from inline_snapshot.testing._example import Example
4 |
5 | values = ["1", '"2\'"', "[5]", "{1: 2}", "F(i=5)", "F.make1('2')", "f(7)"]
6 |
7 |
8 | @pytest.mark.parametrize("a", values)
9 | @pytest.mark.parametrize("b", values + ["F.make2(Is(5))"])
10 | def test_change_types(a, b):
11 | context = """\
12 | from inline_snapshot import snapshot, Is
13 | from dataclasses import dataclass
14 |
15 | @dataclass
16 | class F:
17 | i: int
18 |
19 | @staticmethod
20 | def make1(s):
21 | return F(i=int(s))
22 |
23 | @staticmethod
24 | def make2(s):
25 | return F(i=s)
26 |
27 | def f(v):
28 | return v
29 |
30 | """
31 |
32 | def code_repr(v):
33 | g = {}
34 | exec(context + f"r=repr({a})", g)
35 | return g["r"]
36 |
37 | def code(a, b):
38 | return f"""\
39 | {context}
40 |
41 | def test_change():
42 | for _ in [1,2]:
43 | assert {a} == snapshot({b})
44 | """
45 |
46 | print(a, b)
47 | print(code_repr(a), code_repr(b))
48 |
49 | Example(code(a, b)).run_inline(
50 | ["--inline-snapshot=fix,update"],
51 | changed_files=(
52 | {"test_something.py": code(a, code_repr(a))} if code_repr(a) != b else {}
53 | ),
54 | )
55 |
--------------------------------------------------------------------------------
/tests/adapter/test_dict.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 | from inline_snapshot.testing import Example
3 |
4 |
5 | def test_dict_var():
6 |
7 | Example(
8 | """\
9 | from inline_snapshot import snapshot,Is
10 |
11 | def test_list():
12 | l={1:2}
13 | assert l == snapshot(l), "not equal"
14 | """
15 | ).run_inline(
16 | ["--inline-snapshot=update"],
17 | changed_files=snapshot(
18 | {
19 | "test_something.py": """\
20 | from inline_snapshot import snapshot,Is
21 |
22 | def test_list():
23 | l={1:2}
24 | assert l == snapshot({1: 2}), "not equal"
25 | """
26 | }
27 | ),
28 | )
29 |
30 |
31 | def test_dict_constructor():
32 |
33 | Example(
34 | """\
35 | from inline_snapshot import snapshot
36 |
37 | def test_dict():
38 | snapshot(dict())
39 | """
40 | ).run_inline(
41 | ["--inline-snapshot=fix"],
42 | changed_files=snapshot({}),
43 | )
44 |
--------------------------------------------------------------------------------
/tests/adapter/test_general.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 | from inline_snapshot.testing import Example
3 |
4 |
5 | def test_adapter_mismatch():
6 |
7 | Example(
8 | """\
9 | from inline_snapshot import snapshot
10 |
11 |
12 | def test_thing():
13 | assert [1,2] == snapshot({1:2})
14 |
15 | """
16 | ).run_inline(
17 | ["--inline-snapshot=fix"],
18 | changed_files=snapshot(
19 | {
20 | "test_something.py": """\
21 | from inline_snapshot import snapshot
22 |
23 |
24 | def test_thing():
25 | assert [1,2] == snapshot([1, 2])
26 |
27 | \
28 | """
29 | }
30 | ),
31 | )
32 |
33 |
34 | def test_reeval():
35 |
36 | Example(
37 | """\
38 | from inline_snapshot import snapshot,Is
39 |
40 |
41 | def test_thing():
42 | for i in (1,2):
43 | assert {1:i} == snapshot({1:Is(i)})
44 | assert [i] == [Is(i)]
45 | assert (i,) == (Is(i),)
46 | """
47 | ).run_pytest(["--inline-snapshot=short-report"], report=snapshot(""))
48 |
--------------------------------------------------------------------------------
/tests/adapter/test_sequence.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from inline_snapshot._inline_snapshot import snapshot
4 | from inline_snapshot.testing._example import Example
5 |
6 |
7 | def test_list_adapter_create_inner_snapshot():
8 |
9 | Example(
10 | """\
11 | from inline_snapshot import snapshot
12 | from dirty_equals import IsInt
13 |
14 | def test_list():
15 |
16 | assert [1,2,3,4] == snapshot([1,IsInt(),snapshot(),4]),"not equal"
17 | """
18 | ).run_inline(
19 | ["--inline-snapshot=create"],
20 | changed_files=snapshot(
21 | {
22 | "test_something.py": """\
23 | from inline_snapshot import snapshot
24 | from dirty_equals import IsInt
25 |
26 | def test_list():
27 |
28 | assert [1,2,3,4] == snapshot([1,IsInt(),snapshot(3),4]),"not equal"
29 | """
30 | }
31 | ),
32 | raises=snapshot(None),
33 | )
34 |
35 |
36 | def test_list_adapter_fix_inner_snapshot():
37 |
38 | Example(
39 | """\
40 | from inline_snapshot import snapshot
41 | from dirty_equals import IsInt
42 |
43 | def test_list():
44 |
45 | assert [1,2,3,4] == snapshot([1,IsInt(),snapshot(8),4]),"not equal"
46 | """
47 | ).run_inline(
48 | ["--inline-snapshot=fix"],
49 | changed_files=snapshot(
50 | {
51 | "test_something.py": """\
52 | from inline_snapshot import snapshot
53 | from dirty_equals import IsInt
54 |
55 | def test_list():
56 |
57 | assert [1,2,3,4] == snapshot([1,IsInt(),snapshot(3),4]),"not equal"
58 | """
59 | }
60 | ),
61 | raises=snapshot(None),
62 | )
63 |
64 |
65 | @pytest.mark.no_rewriting
66 | def test_list_adapter_reeval(executing_used):
67 |
68 | Example(
69 | """\
70 | from inline_snapshot import snapshot,Is
71 |
72 | def test_list():
73 |
74 | for i in (1,2,3):
75 | assert [1,i] == snapshot([1,Is(i)]),"not equal"
76 | """
77 | ).run_inline(
78 | changed_files=snapshot({}),
79 | raises=snapshot(None),
80 | )
81 |
82 |
83 | def test_list_var():
84 |
85 | Example(
86 | """\
87 | from inline_snapshot import snapshot,Is
88 |
89 | def test_list():
90 | l=[1]
91 | assert l == snapshot(l), "not equal"
92 | """
93 | ).run_inline(
94 | ["--inline-snapshot=update"],
95 | changed_files=snapshot(
96 | {
97 | "test_something.py": """\
98 | from inline_snapshot import snapshot,Is
99 |
100 | def test_list():
101 | l=[1]
102 | assert l == snapshot([1]), "not equal"
103 | """
104 | }
105 | ),
106 | )
107 |
108 |
109 | def test_tuple_constructor():
110 | Example(
111 | """\
112 | from inline_snapshot import snapshot
113 |
114 | def test_tuple():
115 | snapshot(tuple()), "not equal"
116 | """
117 | ).run_inline(
118 | ["--inline-snapshot=fix"],
119 | changed_files=snapshot({}),
120 | )
121 |
--------------------------------------------------------------------------------
/tests/test_align.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 | from inline_snapshot._align import add_x
3 | from inline_snapshot._align import align
4 |
5 |
6 | def test_align():
7 | assert align("iabc", "abcd") == snapshot("dmmmi")
8 |
9 | assert align("abbc", "axyc") == snapshot("mddiim")
10 | assert add_x(align("abbc", "axyc")) == snapshot("mxxm")
11 |
--------------------------------------------------------------------------------
/tests/test_change.py:
--------------------------------------------------------------------------------
1 | import ast
2 |
3 | import pytest
4 | from executing import Source
5 |
6 | from inline_snapshot._change import CallArg
7 | from inline_snapshot._change import Delete
8 | from inline_snapshot._change import Replace
9 | from inline_snapshot._change import apply_all
10 | from inline_snapshot._inline_snapshot import snapshot
11 | from inline_snapshot._rewrite_code import ChangeRecorder
12 | from inline_snapshot._source_file import SourceFile
13 |
14 |
15 | @pytest.fixture
16 | def check_change(tmp_path):
17 | i = 0
18 |
19 | def w(source, changes, new_code):
20 | nonlocal i
21 |
22 | filename = tmp_path / f"test_{i}.py"
23 | i += 1
24 |
25 | filename.write_text(source)
26 | print(f"\ntest: {source}")
27 |
28 | source = Source.for_filename(filename)
29 | module = source.tree
30 | context = SourceFile(source)
31 |
32 | call = module.body[0].value
33 | assert isinstance(call, ast.Call)
34 |
35 | cr = ChangeRecorder()
36 | apply_all(changes(context, call), cr)
37 |
38 | cr.virtual_write()
39 |
40 | cr.dump()
41 |
42 | assert list(cr.files())[0].source == new_code
43 |
44 | return w
45 |
46 |
47 | def test_change_function_args(check_change):
48 |
49 | check_change(
50 | "f(a,b=2)",
51 | lambda source, call: [
52 | Replace(
53 | flag="fix",
54 | file=source,
55 | node=call.args[0],
56 | new_code="22",
57 | old_value=0,
58 | new_value=0,
59 | )
60 | ],
61 | snapshot("f(22,b=2)"),
62 | )
63 |
64 | check_change(
65 | "f(a,b=2)",
66 | lambda source, call: [
67 | Delete(
68 | flag="fix",
69 | file=source,
70 | node=call.args[0],
71 | old_value=0,
72 | )
73 | ],
74 | snapshot("f(b=2)"),
75 | )
76 |
77 | check_change(
78 | "f(a,b=2)",
79 | lambda source, call: [
80 | CallArg(
81 | flag="fix",
82 | file=source,
83 | node=call,
84 | arg_pos=0,
85 | arg_name=None,
86 | new_code="22",
87 | new_value=22,
88 | )
89 | ],
90 | snapshot("f(22, a,b=2)"),
91 | )
92 |
--------------------------------------------------------------------------------
/tests/test_ci.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 | from inline_snapshot.testing._example import Example
3 |
4 |
5 | def test_ci_run():
6 | e = Example(
7 | """
8 | from inline_snapshot import snapshot
9 | def test_something():
10 | assert type(snapshot(5)) is int
11 |
12 | """
13 | )
14 |
15 | e.run_pytest(
16 | env={"CI": "true"},
17 | report=snapshot(
18 | """\
19 | INFO: CI run was detected because environment variable "CI" was defined.
20 | INFO: inline-snapshot runs with --inline-snapshot=disable by default in CI.\
21 | """
22 | ),
23 | )
24 |
25 |
26 | def test_ci_and_fix():
27 | Example(
28 | """
29 | from inline_snapshot import snapshot
30 | def test_something():
31 | assert 5==snapshot(2)
32 |
33 | """
34 | ).run_pytest(
35 | ["--inline-snapshot=fix"],
36 | env={"CI": "true"},
37 | report=snapshot(
38 | """\
39 | -------------------------------- Fix snapshots ---------------------------------
40 | +----------------------------- test_something.py ------------------------------+
41 | | @@ -1,6 +1,6 @@ |
42 | | |
43 | | |
44 | | from inline_snapshot import snapshot |
45 | | def test_something(): |
46 | | - assert 5==snapshot(2) |
47 | | + assert 5==snapshot(5) |
48 | +------------------------------------------------------------------------------+
49 | These changes will be applied, because you used fix\
50 | """
51 | ),
52 | returncode=1,
53 | )
54 |
55 |
56 | def test_no_ci_run():
57 | Example(
58 | """
59 | from inline_snapshot import snapshot
60 | def test_something():
61 | assert not isinstance(snapshot(5),int)
62 |
63 | """
64 | ).run_pytest(
65 | env={"TEAMCITY_VERSION": "true", "PYCHARM_HOSTED": "true"},
66 | )
67 |
--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 | from inline_snapshot.testing import Example
3 |
4 | file_to_trim = {
5 | "test_a.py": """\
6 | from inline_snapshot import snapshot
7 |
8 | def test_a():
9 | assert 1 <= snapshot(5)
10 | assert 1 == snapshot(2)
11 | """,
12 | }
13 |
14 | trimmed_files = snapshot(
15 | {
16 | "test_a.py": """\
17 | from inline_snapshot import snapshot
18 |
19 | def test_a():
20 | assert 1 <= snapshot(1)
21 | assert 1 == snapshot(2)
22 | """
23 | }
24 | )
25 |
26 |
27 | def test_config_pyproject():
28 |
29 | Example(
30 | {
31 | **file_to_trim,
32 | "pyproject.toml": """
33 | [tool.inline-snapshot]
34 | default-flags = ["trim"]
35 | """,
36 | }
37 | ).run_pytest(changed_files=trimmed_files, returncode=snapshot(1))
38 |
39 |
40 | def test_config_env():
41 | e = Example(file_to_trim)
42 |
43 | e.run_pytest(
44 | env={"INLINE_SNAPSHOT_DEFAULT_FLAGS": "trim"},
45 | changed_files=trimmed_files,
46 | returncode=snapshot(1),
47 | )
48 |
49 | e.run_pytest(
50 | stdin=b"\n",
51 | env={"INLINE_SNAPSHOT_DEFAULT_FLAGS": "trim"},
52 | changed_files=trimmed_files,
53 | returncode=snapshot(1),
54 | )
55 |
56 |
57 | def test_shortcuts():
58 |
59 | Example(
60 | {
61 | **file_to_trim,
62 | "pyproject.toml": """
63 | [tool.inline-snapshot.shortcuts]
64 | strim=["trim"]
65 | """,
66 | }
67 | ).run_pytest(["--strim"], changed_files=trimmed_files, returncode=snapshot(1))
68 |
69 |
70 | def test_default_shortcuts():
71 |
72 | Example(
73 | {
74 | **file_to_trim,
75 | "pyproject.toml": """
76 | """,
77 | }
78 | ).run_pytest(
79 | ["--fix"],
80 | changed_files=snapshot(
81 | {
82 | "test_a.py": """\
83 | from inline_snapshot import snapshot
84 |
85 | def test_a():
86 | assert 1 <= snapshot(5)
87 | assert 1 == snapshot(1)
88 | """
89 | }
90 | ),
91 | returncode=1,
92 | )
93 |
--------------------------------------------------------------------------------
/tests/test_dirty_equals.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from inline_snapshot._inline_snapshot import snapshot
4 | from inline_snapshot.testing._example import Example
5 |
6 |
7 | @pytest.mark.xfail
8 | def test_dirty_equals_repr():
9 | Example(
10 | """\
11 | from inline_snapshot import snapshot
12 | from dirty_equals import IsStr
13 |
14 | def test_something():
15 | assert [IsStr()] == snapshot()
16 | """
17 | ).run_inline(
18 | ["--inline-snapshot=create"],
19 | changed_files=snapshot({}),
20 | raises=snapshot(
21 | """\
22 | UsageError:
23 | inline-snapshot uses `copy.deepcopy` to copy objects,
24 | but the copied object is not equal to the original one:
25 |
26 | original: [HasRepr(IsStr, '< type(obj) can not be compared with == >')]
27 | copied: [HasRepr(IsStr, '< type(obj) can not be compared with == >')]
28 |
29 | Please fix the way your object is copied or your __eq__ implementation.
30 | """
31 | ),
32 | )
33 |
34 |
35 | def test_compare_dirty_equals_twice() -> None:
36 |
37 | Example(
38 | """
39 | from dirty_equals import IsStr
40 | from inline_snapshot import snapshot
41 |
42 | def test():
43 | for x in 'ab':
44 | assert x == snapshot(IsStr())
45 | assert [x,5] == snapshot([IsStr(),3])
46 | assert {'a':x,'b':5} == snapshot({'a':IsStr(),'b':3})
47 |
48 | """
49 | ).run_inline(
50 | ["--inline-snapshot=fix"],
51 | changed_files=snapshot(
52 | {
53 | "test_something.py": """\
54 |
55 | from dirty_equals import IsStr
56 | from inline_snapshot import snapshot
57 |
58 | def test():
59 | for x in 'ab':
60 | assert x == snapshot(IsStr())
61 | assert [x,5] == snapshot([IsStr(),5])
62 | assert {'a':x,'b':5} == snapshot({'a':IsStr(),'b':5})
63 |
64 | """
65 | }
66 | ),
67 | )
68 |
69 |
70 | def test_dirty_equals_in_unused_snapshot() -> None:
71 |
72 | Example(
73 | """
74 | from dirty_equals import IsStr
75 | from inline_snapshot import snapshot,Is
76 |
77 | snapshot([IsStr(),3])
78 | snapshot((IsStr(),3))
79 | snapshot({1:IsStr(),2:3})
80 | snapshot({1+1:2})
81 |
82 | t=(1,2)
83 | d={1:2}
84 | l=[1,2]
85 | snapshot([Is(t),Is(d),Is(l)])
86 |
87 | def test():
88 | pass
89 |
90 | """
91 | ).run_inline(
92 | ["--inline-snapshot=fix"],
93 | changed_files=snapshot({}),
94 | )
95 |
96 |
97 | def test_now_like_dirty_equals():
98 | # test for cases like https://github.com/15r10nk/inline-snapshot/issues/116
99 |
100 | Example(
101 | """
102 | from dirty_equals import DirtyEquals
103 | from inline_snapshot import snapshot
104 |
105 |
106 | def test_time():
107 |
108 | now = 5
109 |
110 | class Now(DirtyEquals):
111 | def equals(self, other):
112 | return other == now
113 |
114 | assert 5 == snapshot(Now())
115 |
116 | now = 6
117 |
118 | assert 5 == snapshot(Now()), "different time"
119 | """
120 | ).run_inline(
121 | ["--inline-snapshot=fix"],
122 | changed_files=snapshot({}),
123 | raises=snapshot(
124 | """\
125 | AssertionError:
126 | different time\
127 | """
128 | ),
129 | )
130 |
131 |
132 | def test_dirty_equals_with_changing_args() -> None:
133 |
134 | Example(
135 | """\
136 | from dirty_equals import IsInt
137 | from inline_snapshot import snapshot
138 |
139 | def test_number():
140 |
141 | for i in range(5):
142 | assert ["a",i] == snapshot(["e",IsInt(gt=i-1,lt=i+1)])
143 |
144 | """
145 | ).run_inline(
146 | ["--inline-snapshot=fix"],
147 | changed_files=snapshot(
148 | {
149 | "test_something.py": """\
150 | from dirty_equals import IsInt
151 | from inline_snapshot import snapshot
152 |
153 | def test_number():
154 |
155 | for i in range(5):
156 | assert ["a",i] == snapshot(["a",IsInt(gt=i-1,lt=i+1)])
157 |
158 | """
159 | }
160 | ),
161 | )
162 |
--------------------------------------------------------------------------------
/tests/test_example.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 | from inline_snapshot.extra import raises
3 | from inline_snapshot.testing import Example
4 |
5 |
6 | def test_example():
7 |
8 | e = Example(
9 | {
10 | "test_a.py": """
11 | from inline_snapshot import snapshot
12 |
13 | def test_a():
14 | assert 1==snapshot(2)
15 | """,
16 | "test_b.py": "1+1",
17 | },
18 | )
19 |
20 | e.run_pytest(
21 | ["--inline-snapshot=create,fix"],
22 | returncode=1,
23 | )
24 |
25 | e.run_inline(
26 | ["--inline-snapshot=fix"],
27 | reported_categories=snapshot(["fix"]),
28 | ).run_inline(
29 | ["--inline-snapshot=fix"],
30 | changed_files=snapshot({}),
31 | )
32 |
33 |
34 | def test_no_tests():
35 |
36 | with raises(snapshot("UsageError: no test_*() functions in the example")):
37 | Example("").run_inline()
38 |
39 |
40 | def test_throws_exception():
41 |
42 | with raises(snapshot("Exception: test")):
43 | Example(
44 | """\
45 | def test_a():
46 | raise Exception("test")
47 |
48 | """
49 | ).run_inline()
50 |
--------------------------------------------------------------------------------
/tests/test_fstring.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 | from inline_snapshot.extra import warns
3 | from inline_snapshot.testing import Example
4 |
5 |
6 | def test_fstring():
7 | Example(
8 | """
9 | from inline_snapshot import snapshot
10 |
11 | def test_a():
12 | assert "a 1" == snapshot(f"a {1}")
13 | """
14 | ).run_inline(reported_categories=snapshot([]))
15 |
16 |
17 | def test_fstring_fix():
18 |
19 | with warns(
20 | snapshot(
21 | [
22 | """\
23 | InlineSnapshotInfo: inline-snapshot will be able to fix f-strings in the future.
24 | The current string value is:
25 | 'a 1'\
26 | """
27 | ]
28 | )
29 | ):
30 | Example(
31 | """
32 | from inline_snapshot import snapshot
33 |
34 | def test_a():
35 | assert "a 1" == snapshot(f"b {1}"), "not equal"
36 | """
37 | ).run_inline(
38 | ["--inline-snapshot=fix"],
39 | raises=snapshot(
40 | """\
41 | AssertionError:
42 | not equal\
43 | """
44 | ),
45 | )
46 |
--------------------------------------------------------------------------------
/tests/test_hasrepr.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot._code_repr import HasRepr
2 |
3 |
4 | def test_hasrepr_eq():
5 |
6 | assert float("nan") == HasRepr(float, "nan")
7 |
8 | class Thing:
9 | def __repr__(self):
10 | return ""
11 |
12 | assert Thing() == HasRepr(Thing, "")
13 |
--------------------------------------------------------------------------------
/tests/test_is.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import Is
2 | from inline_snapshot import snapshot
3 | from inline_snapshot.testing import Example
4 |
5 |
6 | def test_missing_is():
7 |
8 | Example(
9 | """\
10 | from inline_snapshot import snapshot
11 |
12 | def test_is():
13 | for i in (1,2):
14 | assert i == snapshot(i)
15 | """
16 | ).run_inline(
17 | raises=snapshot(
18 | """\
19 | UsageError:
20 | snapshot value should not change. Use Is(...) for dynamic snapshot parts.\
21 | """
22 | )
23 | )
24 |
25 |
26 | def test_is_repr():
27 | # repr(Is(x)) == repr(x)
28 | # see #217
29 | assert "5" == repr(Is(5))
30 |
--------------------------------------------------------------------------------
/tests/test_is_normalized.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 | from inline_snapshot.testing._example import Example
3 |
4 |
5 | def test_repr():
6 | Example(
7 | """\
8 | from inline_snapshot import snapshot
9 | from tests._is_normalized import IsNormalized
10 |
11 | def test_a():
12 | n=IsNormalized(sorted,snapshot())
13 | assert [3,5,2] == n
14 | assert repr(n)==snapshot()
15 | """
16 | ).run_inline(
17 | ["--inline-snapshot=create"],
18 | changed_files=snapshot(
19 | {
20 | "test_something.py": """\
21 | from inline_snapshot import snapshot
22 | from tests._is_normalized import IsNormalized
23 |
24 | def test_a():
25 | n=IsNormalized(sorted,snapshot([2, 3, 5]))
26 | assert [3,5,2] == n
27 | assert repr(n)==snapshot("IsNormalized([2, 3, 5], should_be=[2, 3, 5])")
28 | """
29 | }
30 | ),
31 | )
32 |
--------------------------------------------------------------------------------
/tests/test_pydantic.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 | from inline_snapshot.testing import Example
3 |
4 |
5 | def test_pydantic_create_snapshot():
6 |
7 | Example(
8 | """
9 | from pydantic import BaseModel
10 | from inline_snapshot import snapshot
11 |
12 | class M(BaseModel):
13 | size:int
14 | name:str
15 | age:int=4
16 |
17 | def test_pydantic():
18 | m=M(size=5,name="Tom")
19 | assert m==snapshot()
20 | assert m.dict()==snapshot()
21 |
22 | """
23 | ).run_pytest(
24 | ["--inline-snapshot=create"],
25 | changed_files=snapshot(
26 | {
27 | "test_something.py": """\
28 |
29 | from pydantic import BaseModel
30 | from inline_snapshot import snapshot
31 |
32 | class M(BaseModel):
33 | size:int
34 | name:str
35 | age:int=4
36 |
37 | def test_pydantic():
38 | m=M(size=5,name="Tom")
39 | assert m==snapshot(M(size=5, name="Tom"))
40 | assert m.dict()==snapshot({"size": 5, "name": "Tom", "age": 4})
41 |
42 | \
43 | """
44 | }
45 | ),
46 | returncode=1,
47 | ).run_pytest(
48 | ["--inline-snapshot=disable"]
49 | )
50 |
51 |
52 | def test_pydantic_field_repr():
53 |
54 | Example(
55 | """\
56 | from inline_snapshot import snapshot
57 | from pydantic import BaseModel,Field
58 |
59 | class container(BaseModel):
60 | a: int
61 | b: int = Field(default=5,repr=False)
62 |
63 | def test():
64 | assert container(a=1,b=5) == snapshot()
65 | """
66 | ).run_pytest(
67 | ["--inline-snapshot=create"],
68 | changed_files=snapshot(
69 | {
70 | "test_something.py": """\
71 | from inline_snapshot import snapshot
72 | from pydantic import BaseModel,Field
73 |
74 | class container(BaseModel):
75 | a: int
76 | b: int = Field(default=5,repr=False)
77 |
78 | def test():
79 | assert container(a=1,b=5) == snapshot(container(a=1))
80 | """
81 | }
82 | ),
83 | returncode=1,
84 | ).run_pytest(
85 | ["--inline-snapshot=disable"]
86 | )
87 |
88 |
89 | def test_pydantic_default_value():
90 | Example(
91 | """\
92 | from inline_snapshot import snapshot,Is
93 | from dataclasses import dataclass,field
94 | from pydantic import BaseModel,Field
95 |
96 | class A(BaseModel):
97 | a:int
98 | b:int=2
99 | c:list=Field(default_factory=list)
100 |
101 | def test_something():
102 | assert A(a=1) == snapshot(A(a=1,b=2,c=[]))
103 | """
104 | ).run_pytest(
105 | ["--inline-snapshot=update"],
106 | changed_files=snapshot(
107 | {
108 | "test_something.py": """\
109 | from inline_snapshot import snapshot,Is
110 | from dataclasses import dataclass,field
111 | from pydantic import BaseModel,Field
112 |
113 | class A(BaseModel):
114 | a:int
115 | b:int=2
116 | c:list=Field(default_factory=list)
117 |
118 | def test_something():
119 | assert A(a=1) == snapshot(A(a=1))
120 | """
121 | }
122 | ),
123 | )
124 |
125 |
126 | def test_pydantic_evaluate_twice():
127 | Example(
128 | """\
129 | from inline_snapshot import snapshot
130 | from pydantic import BaseModel
131 |
132 | class A(BaseModel):
133 | a:int
134 |
135 | def test_something():
136 | for _ in [1,2]:
137 | assert A(a=1) == snapshot(A(a=1))
138 | """
139 | ).run_pytest(
140 | changed_files=snapshot({}),
141 | )
142 |
143 |
144 | def test_pydantic_factory_method():
145 | Example(
146 | """\
147 | from inline_snapshot import snapshot
148 | from pydantic import BaseModel
149 |
150 | class A(BaseModel):
151 | a:int
152 |
153 | @classmethod
154 | def from_str(cls,s):
155 | return cls(a=int(s))
156 |
157 | def test_something():
158 | for a in [1,2]:
159 | assert A(a=2) == snapshot(A.from_str("1"))
160 | """
161 | ).run_pytest(
162 | ["--inline-snapshot=fix"],
163 | changed_files=snapshot(
164 | {
165 | "test_something.py": """\
166 | from inline_snapshot import snapshot
167 | from pydantic import BaseModel
168 |
169 | class A(BaseModel):
170 | a:int
171 |
172 | @classmethod
173 | def from_str(cls,s):
174 | return cls(a=int(s))
175 |
176 | def test_something():
177 | for a in [1,2]:
178 | assert A(a=2) == snapshot(A(a=2))
179 | """
180 | }
181 | ),
182 | returncode=1,
183 | )
184 |
--------------------------------------------------------------------------------
/tests/test_pypy.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | import pytest
4 |
5 | from inline_snapshot import snapshot
6 | from inline_snapshot.testing import Example
7 |
8 |
9 | @pytest.mark.no_rewriting
10 | def test_pypy():
11 |
12 | no_cpython = sys.implementation.name != "cpython"
13 |
14 | report = (
15 | snapshot("INFO: inline-snapshot was disabled because pypy is not supported")
16 | if sys.implementation.name == "pypy"
17 | else snapshot(
18 | """\
19 | -------------------------------- Fix snapshots ---------------------------------
20 | +----------------------------- test_something.py ------------------------------+
21 | | @@ -1,6 +1,6 @@ |
22 | | |
23 | | from inline_snapshot import snapshot |
24 | | |
25 | | def test_example(): |
26 | | - assert 1+1==snapshot(3) |
27 | | + assert 1+1==snapshot(2) |
28 | +------------------------------------------------------------------------------+
29 | These changes will be applied, because you used fix\
30 | """
31 | )
32 | )
33 |
34 | Example(
35 | """\
36 | from inline_snapshot import snapshot
37 |
38 | def test_example():
39 | assert 1+1==snapshot(3)
40 |
41 | """
42 | ).run_pytest(["--inline-snapshot=fix"], report=report, returncode=1).run_pytest(
43 | ["--inline-snapshot=disable"], report="", returncode=1 if no_cpython else 0
44 | )
45 |
--------------------------------------------------------------------------------
/tests/test_pytest_diff_fix.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from executing import is_pytest_compatible
3 |
4 | from inline_snapshot import snapshot
5 | from inline_snapshot.testing._example import Example
6 |
7 |
8 | @pytest.mark.skipif(
9 | not is_pytest_compatible(),
10 | reason="pytest assert rewriting and report can not be used at the same time",
11 | )
12 | def test_pytest_diff_fix():
13 |
14 | Example(
15 | """\
16 | from inline_snapshot import snapshot,Is
17 |
18 |
19 | def test_dict_report():
20 | usd = snapshot({"name": "US Dollar", "code": "USD", "symbol": "$"})
21 | usd2 = Is([1,2])
22 |
23 | price = {
24 | "amount": 1,
25 | "currency": {
26 | "code": "USD",
27 | "name": "US Dollar",
28 | "symbol": "$",
29 | },
30 | "b":[1,2]
31 | }
32 |
33 | assert price == snapshot({
34 | "amount": 2,
35 | "currency": usd,
36 | "b":usd2
37 | })
38 | """
39 | ).run_pytest(
40 | ["--inline-snapshot=report", "-vv"],
41 | error=snapshot(
42 | """\
43 | > assert price == snapshot({
44 | E AssertionError: assert {'amount': 1, 'currency': {'code': 'USD', 'name': 'US Dollar', 'symbol': '$'}, 'b': [1, 2]} == {'amount': 2, 'currency': {'name': 'US Dollar', 'code': 'USD', 'symbol': '$'}, 'b': [1, 2]}
45 | E \n\
46 | E Common items:
47 | E {'b': [1, 2], 'currency': {'code': 'USD', 'name': 'US Dollar', 'symbol': '$'}}
48 | E Differing items:
49 | E {'amount': 1} != {'amount': 2}
50 | E \n\
51 | E Full diff:
52 | E {
53 | E - 'amount': 2,
54 | E ? ^
55 | E + 'amount': 1,
56 | E ? ^
57 | E 'b': [
58 | E 1,
59 | E 2,
60 | E ],
61 | E 'currency': {
62 | E 'code': 'USD',
63 | E 'name': 'US Dollar',
64 | E 'symbol': '$',
65 | E },
66 | E }
67 | """
68 | ),
69 | returncode=1,
70 | )
71 |
--------------------------------------------------------------------------------
/tests/test_pytester.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 | from inline_snapshot.testing._example import Example
3 |
4 |
5 | def test_run_pytester():
6 | Example(
7 | {
8 | "conftest.py": """\
9 | pytest_plugins = ["pytester"]
10 | """,
11 | "test_things.py": """\
12 | from inline_snapshot import snapshot
13 |
14 | def test_not_pytester():
15 | assert "hey" == snapshot()
16 |
17 | def test_pytester(pytester):
18 | pytester.runpytest()
19 | """,
20 | }
21 | ).run_pytest(
22 | ["--inline-snapshot=create"],
23 | changed_files=snapshot(
24 | {
25 | "test_things.py": """\
26 | from inline_snapshot import snapshot
27 |
28 | def test_not_pytester():
29 | assert "hey" == snapshot("hey")
30 |
31 | def test_pytester(pytester):
32 | pytester.runpytest()
33 | """
34 | }
35 | ),
36 | returncode=1,
37 | ).run_pytest(
38 | ["--inline-snapshot=disable"]
39 | )
40 |
--------------------------------------------------------------------------------
/tests/test_raises.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 | from inline_snapshot.extra import raises
3 |
4 |
5 | def test_raises():
6 | with raises(snapshot("ZeroDivisionError: division by zero")):
7 | 0 / 0
8 |
9 | with raises(snapshot("")):
10 | pass
11 |
12 | with raises(
13 | snapshot(
14 | """\
15 | ValueError:
16 | with
17 | two lines\
18 | """
19 | )
20 | ):
21 | raise ValueError("with\ntwo lines")
22 |
--------------------------------------------------------------------------------
/tests/test_rewrite_code.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from inline_snapshot._rewrite_code import ChangeRecorder
4 | from inline_snapshot._rewrite_code import SourcePosition
5 | from inline_snapshot._rewrite_code import SourceRange
6 | from inline_snapshot._rewrite_code import end_of
7 | from inline_snapshot._rewrite_code import range_of
8 | from inline_snapshot._rewrite_code import start_of
9 |
10 |
11 | def test_range():
12 | a = SourcePosition(1, 2)
13 | b = SourcePosition(2, 5)
14 | assert a < b
15 | r = SourceRange(a, b)
16 | assert start_of(r) == a
17 | assert end_of(r) == b
18 |
19 | assert range_of(r) == r
20 |
21 | with pytest.raises(ValueError):
22 | SourceRange(b, a)
23 |
24 |
25 | def test_rewrite(tmp_path):
26 | file = tmp_path / "file.txt"
27 | file.write_text(
28 | """
29 | 12345
30 | 12345
31 | 12345
32 | """,
33 | "utf-8",
34 | )
35 |
36 | recorder = ChangeRecorder()
37 | s = recorder.new_change()
38 |
39 | s.replace(((2, 2), (2, 3)), "a", filename=file)
40 | s.delete(((3, 2), (3, 3)), filename=file)
41 | s.insert((4, 2), "c", filename=file)
42 |
43 | assert recorder.num_fixes() == 1
44 | recorder.fix_all()
45 |
46 | assert (
47 | file.read_text("utf-8")
48 | == """
49 | 12a45
50 | 1245
51 | 12c345
52 | """
53 | )
54 |
--------------------------------------------------------------------------------
/tests/test_skip_updates.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 | from inline_snapshot.testing._example import Example
3 |
4 |
5 | def test_use_snapshot_updates():
6 |
7 | expected_report = snapshot("")
8 |
9 | Example(
10 | {
11 | "pyproject.toml": f"""\
12 | [tool.inline-snapshot]
13 | """,
14 | "test_a.py": """\
15 | from inline_snapshot import snapshot
16 |
17 | def test_a():
18 | assert 5 == snapshot(2+3)
19 | """,
20 | }
21 | ).run_pytest(
22 | ["--inline-snapshot=review"], changed_files=snapshot({}), report=expected_report
23 | ).run_pytest(
24 | ["--inline-snapshot=report"], changed_files=snapshot({}), report=expected_report
25 | ).run_pytest(
26 | ["--inline-snapshot=update"],
27 | changed_files=snapshot(
28 | {
29 | "test_a.py": """\
30 | from inline_snapshot import snapshot
31 |
32 | def test_a():
33 | assert 5 == snapshot(5)
34 | """
35 | }
36 | ),
37 | report=snapshot(
38 | """\
39 | ------------------------------- Update snapshots -------------------------------
40 | +--------------------------------- test_a.py ----------------------------------+
41 | | @@ -1,4 +1,4 @@ |
42 | | |
43 | | from inline_snapshot import snapshot |
44 | | |
45 | | def test_a(): |
46 | | - assert 5 == snapshot(2+3) |
47 | | + assert 5 == snapshot(5) |
48 | +------------------------------------------------------------------------------+
49 | These changes will be applied, because you used update\
50 | """
51 | ),
52 | )
53 |
--------------------------------------------------------------------------------
/tests/test_string.py:
--------------------------------------------------------------------------------
1 | import ast
2 |
3 | from hypothesis import given
4 | from hypothesis.strategies import text
5 |
6 | from inline_snapshot import snapshot
7 | from inline_snapshot._utils import triple_quote
8 | from inline_snapshot.testing import Example
9 |
10 |
11 | def test_string_update(check_update):
12 | # black --preview wraps strings to keep the line length.
13 | # string concatenation should produce updates.
14 | assert (
15 | check_update(
16 | 'assert "ab" == snapshot("a" "b")', reported_flags="", flags="update"
17 | )
18 | == 'assert "ab" == snapshot("a" "b")'
19 | )
20 |
21 | assert (
22 | check_update(
23 | 'assert "ab" == snapshot("a"\n "b")', reported_flags="", flags="update"
24 | )
25 | == 'assert "ab" == snapshot("a"\n "b")'
26 | )
27 |
28 | assert check_update(
29 | 'assert "ab\\nc" == snapshot("a"\n "b\\nc")', flags="update"
30 | ) == snapshot(
31 | '''\
32 | assert "ab\\nc" == snapshot("""\\
33 | ab
34 | c\\
35 | """)\
36 | '''
37 | )
38 |
39 | assert (
40 | check_update(
41 | 'assert b"ab" == snapshot(b"a"\n b"b")', reported_flags="", flags="update"
42 | )
43 | == 'assert b"ab" == snapshot(b"a"\n b"b")'
44 | )
45 |
46 |
47 | def test_string_newline(check_update):
48 | assert check_update('s = snapshot("a\\nb")', flags="update") == snapshot(
49 | '''\
50 | s = snapshot("""\\
51 | a
52 | b\\
53 | """)\
54 | '''
55 | )
56 |
57 | assert check_update('s = snapshot("a\\"\\"\\"\\nb")', flags="update") == snapshot(
58 | """\
59 | s = snapshot('''\\
60 | a\"\"\"
61 | b\\
62 | ''')\
63 | """
64 | )
65 |
66 | assert check_update(
67 | 's = snapshot("a\\"\\"\\"\\n\\\'\\\'\\\'b")', flags="update"
68 | ) == snapshot(
69 | '''\
70 | s = snapshot("""\\
71 | a\\"\\"\\"
72 | \'\'\'b\\
73 | """)\
74 | '''
75 | )
76 |
77 | assert check_update('s = snapshot(b"a\\nb")') == snapshot('s = snapshot(b"a\\nb")')
78 |
79 | assert check_update('s = snapshot("\\n\\\'")', flags="update") == snapshot(
80 | '''\
81 | s = snapshot("""\\
82 |
83 | '\\
84 | """)\
85 | '''
86 | )
87 |
88 | assert check_update('s = snapshot("\\n\\"")', flags="update") == snapshot(
89 | '''\
90 | s = snapshot("""\\
91 |
92 | "\\
93 | """)\
94 | '''
95 | )
96 |
97 | assert check_update("s = snapshot(\"'''\\n\\\"\")", flags="update") == snapshot(
98 | '''\
99 | s = snapshot("""\\
100 | \'\'\'
101 | \\"\\
102 | """)\
103 | '''
104 | )
105 |
106 | assert check_update('s = snapshot("\\n\b")', flags="update") == snapshot(
107 | '''\
108 | s = snapshot("""\\
109 |
110 | \\x08\\
111 | """)\
112 | '''
113 | )
114 |
115 |
116 | def test_string_quote_choice(check_update):
117 | assert check_update(
118 | "s = snapshot(\" \\'\\'\\' \\'\\'\\' \\\"\\\"\\\"\\nother_line\")",
119 | flags="update",
120 | ) == snapshot(
121 | '''\
122 | s = snapshot("""\\
123 | \'\'\' \'\'\' \\"\\"\\"
124 | other_line\\
125 | """)\
126 | '''
127 | )
128 |
129 | assert check_update(
130 | 's = snapshot(" \\\'\\\'\\\' \\"\\"\\" \\"\\"\\"\\nother_line")', flags="update"
131 | ) == snapshot(
132 | """\
133 | s = snapshot('''\\
134 | \\'\\'\\' \"\"\" \"\"\"
135 | other_line\\
136 | ''')\
137 | """
138 | )
139 |
140 | assert check_update('s = snapshot("\\n\\"")', flags="update") == snapshot(
141 | '''\
142 | s = snapshot("""\\
143 |
144 | "\\
145 | """)\
146 | '''
147 | )
148 |
149 | assert check_update(
150 | "s=snapshot('\\n')", flags="update", reported_flags=""
151 | ) == snapshot("s=snapshot('\\n')")
152 | assert check_update(
153 | "s=snapshot('abc\\n')", flags="update", reported_flags=""
154 | ) == snapshot("s=snapshot('abc\\n')")
155 | assert check_update("s=snapshot('abc\\nabc')", flags="update") == snapshot(
156 | '''\
157 | s=snapshot("""\\
158 | abc
159 | abc\\
160 | """)\
161 | '''
162 | )
163 | assert check_update("s=snapshot('\\nabc')", flags="update") == snapshot(
164 | '''\
165 | s=snapshot("""\\
166 |
167 | abc\\
168 | """)\
169 | '''
170 | )
171 | assert check_update("s=snapshot('a\\na\\n')", flags="update") == snapshot(
172 | '''\
173 | s=snapshot("""\\
174 | a
175 | a
176 | """)\
177 | '''
178 | )
179 |
180 | assert (
181 | check_update(
182 | '''\
183 | s=snapshot("""\\
184 | a
185 | """)\
186 | ''',
187 | flags="update",
188 | )
189 | == snapshot('s=snapshot("a\\n")')
190 | )
191 |
192 |
193 | @given(s=text())
194 | def test_string_convert(s):
195 | print(s)
196 | assert ast.literal_eval(triple_quote(s)) == s
197 |
198 |
199 | def test_newline():
200 | Example(
201 | """\
202 | from inline_snapshot import snapshot
203 |
204 | def test_a():
205 | assert "a\\r\\nb" == snapshot()
206 | """
207 | ).run_inline(
208 | ["--inline-snapshot=create"],
209 | changed_files=snapshot(
210 | {
211 | "test_something.py": '''\
212 | from inline_snapshot import snapshot
213 |
214 | def test_a():
215 | assert "a\\r\\nb" == snapshot("""\\
216 | a\\r
217 | b\\
218 | """)
219 | '''
220 | }
221 | ),
222 | )
223 |
224 |
225 | def test_trailing_whitespaces():
226 | Example(
227 | """\
228 | from inline_snapshot import snapshot
229 |
230 | def test_a():
231 | assert "a \\r\\nb \\nc " == snapshot()
232 | """
233 | ).run_inline(
234 | ["--inline-snapshot=create"],
235 | changed_files=snapshot(
236 | {
237 | "test_something.py": '''\
238 | from inline_snapshot import snapshot
239 |
240 | def test_a():
241 | assert "a \\r\\nb \\nc " == snapshot("""\\
242 | a \\r
243 | b \\n\\
244 | c \\
245 | """)
246 | '''
247 | }
248 | ),
249 | )
250 |
--------------------------------------------------------------------------------
/tests/test_typing.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | import pytest
4 |
5 |
6 | @pytest.fixture(params=["mypy", "pyright"])
7 | def check_typing(pytester, request):
8 | prog = request.param
9 |
10 | def f(code):
11 | pytester.makefile(".py", test_something=code)
12 | pytester.makefile(
13 | ".toml",
14 | pyproject="""
15 |
16 | [tool.pyright]
17 | typeCheckingMode = 'strict'
18 |
19 | [tool.mypy]
20 | strict=true
21 |
22 | """,
23 | )
24 |
25 | result = pytester.run(sys.executable, "-m", prog, "test_something.py")
26 |
27 | print(result.stdout)
28 | assert result.ret == 0
29 |
30 | yield f
31 |
32 |
33 | def test_typing_args(check_typing):
34 | check_typing(
35 | """
36 | from inline_snapshot import snapshot
37 | snapshot([])
38 | snapshot({})
39 | """
40 | )
41 |
42 |
43 | def test_typing_call(check_typing):
44 | check_typing(
45 | """
46 | from inline_snapshot import snapshot,Snapshot
47 |
48 | def f(s:Snapshot[int])->None:
49 | assert s==5
50 |
51 | f(5)
52 | f(snapshot())
53 | f(snapshot(5))
54 |
55 | """
56 | )
57 |
--------------------------------------------------------------------------------
/tests/test_update.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | from inline_snapshot import snapshot
4 | from inline_snapshot.testing._example import Example
5 |
6 | executable = sys.executable.replace("\\", "\\\\")
7 |
8 |
9 | def test_no_reported_updates():
10 |
11 | Example(
12 | {
13 | "fmt_cmd.py": """\
14 | from sys import stdin
15 |
16 | text=stdin.read()
17 | text=text.replace("8","4+4")
18 | print(text,end="")
19 | """,
20 | "pyproject.toml": f"""\
21 | [tool.inline-snapshot]
22 | format-command="{executable} fmt_cmd.py {{filename}}"
23 | show-updates=true
24 | """,
25 | "test_a.py": """\
26 | from inline_snapshot import snapshot
27 |
28 | def test_a():
29 | assert 2**3 == snapshot(4+4)
30 | """,
31 | }
32 | ).run_pytest(
33 | ["--inline-snapshot=report"],
34 | changed_files=snapshot({}),
35 | report=snapshot(""),
36 | )
37 |
--------------------------------------------------------------------------------
/tests/test_warns.py:
--------------------------------------------------------------------------------
1 | import warnings
2 |
3 | from inline_snapshot import snapshot
4 | from inline_snapshot.extra import warns
5 |
6 |
7 | def test_warns():
8 |
9 | def warning():
10 | warnings.warn_explicit(
11 | message="bad things happen",
12 | category=SyntaxWarning,
13 | filename="file.py",
14 | lineno=5,
15 | )
16 |
17 | with warns(
18 | snapshot([("file.py", 5, "SyntaxWarning: bad things happen")]),
19 | include_line=True,
20 | include_file=True,
21 | ):
22 | warning()
23 |
24 | with warns(
25 | snapshot([("file.py", "SyntaxWarning: bad things happen")]),
26 | include_file=True,
27 | ):
28 | warning()
29 |
30 | with warns(
31 | snapshot(["SyntaxWarning: bad things happen"]),
32 | ):
33 | warning()
34 |
--------------------------------------------------------------------------------
/tests/test_xdist.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 |
3 |
4 | def test_xdist(project):
5 |
6 | project.setup(
7 | """\
8 |
9 | def test_a():
10 | assert 1==snapshot()
11 | """
12 | )
13 |
14 | result = project.run("--inline-snapshot=create", "-n=auto")
15 |
16 | assert "\n".join(result.stderr.lines).strip() == snapshot(
17 | "ERROR: --inline-snapshot=create can not be combined with xdist"
18 | )
19 |
20 | assert result.ret == 4
21 |
22 |
23 | def test_xdist_disabled(project):
24 |
25 | project.setup(
26 | """\
27 |
28 | def test_a():
29 | assert 1==snapshot(1)
30 | """
31 | )
32 |
33 | result = project.run("-n=auto")
34 |
35 | assert result.report == snapshot(
36 | "INFO: inline-snapshot was disabled because you used xdist"
37 | )
38 |
39 | assert result.ret == 0
40 |
41 |
42 | def test_xdist_and_disable(project):
43 |
44 | project.setup(
45 | """\
46 |
47 | def test_a():
48 | assert 1==snapshot(2)
49 | """
50 | )
51 |
52 | result = project.run("-n=auto", "--inline-snapshot=disable")
53 |
54 | assert result.report == snapshot("")
55 |
56 | assert result.stderr.lines == snapshot([])
57 |
58 | assert result.ret == 1
59 |
60 | result = project.run("-n=auto", "--inline-snapshot=fix")
61 |
62 | assert result.report == snapshot("")
63 |
64 | assert result.stderr.lines == snapshot(
65 | ["ERROR: --inline-snapshot=fix can not be combined with xdist", ""]
66 | )
67 |
68 | assert result.ret == 4
69 |
70 | project.pyproject(
71 | """\
72 | [tool.inline-snapshot]
73 | default-flags = ["fix"]
74 | """
75 | )
76 |
77 | result = project.run("-n=auto")
78 |
79 | assert result.report == snapshot(
80 | "INFO: inline-snapshot was disabled because you used xdist"
81 | )
82 |
83 | assert result.stderr.lines == snapshot([])
84 |
85 | assert result.ret == 1
86 |
87 |
88 | def test_xdist_zero_processes(project):
89 |
90 | project.setup(
91 | """\
92 |
93 | def test_a():
94 | assert 1==snapshot(2)
95 | """
96 | )
97 |
98 | result = project.run("-n=0", "--inline-snapshot=short-report")
99 |
100 | result.assert_outcomes(failed=1, errors=1)
101 |
102 | assert result.report == snapshot(
103 | """\
104 | Error: one snapshot has incorrect values (--inline-snapshot=fix)
105 | You can also use --inline-snapshot=review to approve the changes interactively
106 | """
107 | )
108 |
109 | assert result.stderr.lines == snapshot([])
110 |
--------------------------------------------------------------------------------
/tests/test_xfail.py:
--------------------------------------------------------------------------------
1 | from inline_snapshot import snapshot
2 | from inline_snapshot.testing import Example
3 |
4 |
5 | def test_xfail_without_condition():
6 |
7 | Example(
8 | """\
9 | import pytest
10 |
11 | @pytest.mark.xfail
12 | def test_a():
13 | assert 1==snapshot(5)
14 | """
15 | ).run_pytest(
16 | ["--inline-snapshot=fix"],
17 | report=snapshot(""),
18 | returncode=snapshot(0),
19 | stderr=snapshot(""),
20 | changed_files=snapshot({}),
21 | )
22 |
23 |
24 | def test_xfail_True():
25 | Example(
26 | """\
27 | import pytest
28 | from inline_snapshot import snapshot
29 |
30 | @pytest.mark.xfail(True,reason="...")
31 | def test_a():
32 | assert 1==snapshot(5)
33 | """
34 | ).run_pytest(
35 | ["--inline-snapshot=fix"],
36 | report=snapshot(""),
37 | returncode=snapshot(0),
38 | stderr=snapshot(""),
39 | changed_files=snapshot({}),
40 | )
41 |
42 |
43 | def test_xfail_False():
44 | Example(
45 | """\
46 | import pytest
47 | from inline_snapshot import snapshot
48 |
49 | @pytest.mark.xfail(False,reason="...")
50 | def test_a():
51 | assert 1==snapshot(5)
52 | """
53 | ).run_pytest(
54 | ["--inline-snapshot=fix"],
55 | report=snapshot(
56 | """\
57 | -------------------------------- Fix snapshots ---------------------------------
58 | +----------------------------- test_something.py ------------------------------+
59 | | @@ -3,4 +3,4 @@ |
60 | | |
61 | | |
62 | | @pytest.mark.xfail(False,reason="...") |
63 | | def test_a(): |
64 | | - assert 1==snapshot(5) |
65 | | + assert 1==snapshot(1) |
66 | +------------------------------------------------------------------------------+
67 | These changes will be applied, because you used fix\
68 | """
69 | ),
70 | returncode=snapshot(1),
71 | stderr=snapshot(""),
72 | changed_files=snapshot(
73 | {
74 | "test_something.py": """\
75 | import pytest
76 | from inline_snapshot import snapshot
77 |
78 | @pytest.mark.xfail(False,reason="...")
79 | def test_a():
80 | assert 1==snapshot(1)
81 | """
82 | }
83 | ),
84 | )
85 |
--------------------------------------------------------------------------------
/tests/utils.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 | from contextlib import contextmanager
3 |
4 | import pytest
5 |
6 | import inline_snapshot._config as _config
7 | import inline_snapshot._external as external
8 | from inline_snapshot._global_state import state
9 | from inline_snapshot._rewrite_code import ChangeRecorder
10 | from inline_snapshot.testing._example import snapshot_env
11 |
12 | __all__ = ("snapshot_env",)
13 |
14 |
15 | @contextlib.contextmanager
16 | def config(**args):
17 | current_config = _config.config
18 | _config.config = _config.Config(**args)
19 | try:
20 | yield
21 | finally:
22 | _config.config = current_config
23 |
24 |
25 | @contextlib.contextmanager
26 | def apply_changes():
27 | recorder = ChangeRecorder()
28 | yield recorder
29 |
30 | recorder.fix_all()
31 |
32 |
33 | @contextmanager
34 | def useStorage(storage):
35 | old_storage = state().storage
36 | state().storage = storage
37 | yield
38 | state().storage = old_storage
39 |
40 |
41 | @pytest.fixture()
42 | def storage(tmp_path):
43 | storage = external.DiscStorage(tmp_path / ".storage")
44 | with useStorage(storage):
45 | yield storage
46 |
--------------------------------------------------------------------------------