├── .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 | 20 | 22 | 30 | 35 | 36 | 44 | 49 | 50 | 51 | 74 | 76 | 77 | 79 | image/svg+xml 80 | 82 | 83 | 84 | 85 | 89 | 95 | 99 | 103 | 108 | 109 | 116 | 117 | 118 | 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 | --------------------------------------------------------------------------------