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

inline-snapshot

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

pydantic logfire

## Issues If you encounter any problems, please [report an issue](https://github.com/15r10nk/inline-snapshot/issues) along with a detailed description. ## License Distributed under the terms of the [MIT](http://opensource.org/licenses/MIT) license, "inline-snapshot" is free and open source software. python-inline-snapshot-0.23.2/changelog.d/000077500000000000000000000000001501556550300204065ustar00rootroot00000000000000python-inline-snapshot-0.23.2/changelog.d/.gitkeep000066400000000000000000000000001501556550300220250ustar00rootroot00000000000000python-inline-snapshot-0.23.2/conftest.py000066400000000000000000000006531501556550300204400ustar00rootroot00000000000000import pytest from inline_snapshot._external import DiscStorage from tests.utils import snapshot_env from tests.utils import useStorage @pytest.fixture(autouse=True) def snapshot_env_for_doctest(request, tmp_path): if hasattr(request.node, "dtest"): with snapshot_env(): storage = DiscStorage(tmp_path / ".storage") with useStorage(storage): yield else: yield python-inline-snapshot-0.23.2/docs/000077500000000000000000000000001501556550300171655ustar00rootroot00000000000000python-inline-snapshot-0.23.2/docs/alternatives.md000066400000000000000000000015751501556550300222200ustar00rootroot00000000000000inline-snapshot is not the only snapshot library for python. There are several others to: * [syrupy](https://github.com/syrupy-project/syrupy) * [snapshottest](https://github.com/syrusakbary/snapshottest) * [pytest-snapshot](https://github.com/joseph-roitman/pytest-snapshot) * [pytest-insta](https://github.com/vberlier/pytest-insta) 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. If you miss a feature that is available in other libraries, please let me know. python-inline-snapshot-0.23.2/docs/assets/000077500000000000000000000000001501556550300204675ustar00rootroot00000000000000python-inline-snapshot-0.23.2/docs/assets/favicon.svg000066400000000000000000000101231501556550300226320ustar00rootroot00000000000000 image/svg+xml python-inline-snapshot-0.23.2/docs/assets/logo.svg000066400000000000000000001217341501556550300221600ustar00rootroot00000000000000 image/svg+xml python-inline-snapshot-0.23.2/docs/assets/logo_orig.svg000066400000000000000000000172321501556550300231750ustar00rootroot00000000000000 image/svg+xml ( inline-snapshot create and update inline snapshots in your python code. python-inline-snapshot-0.23.2/docs/assets/star-history.png000066400000000000000000005256761501556550300236720ustar00rootroot00000000000000PNG  IHDR(qsRGB IDATx^ UNsQi%$5Jd2c )\S$2D\C I4:^{g}NUzY)(---5@@@@@ȡ@ js*@@@@@p$(x@@@@@@ $(rN @@@@@@     \E9!     @@@@@@ 99'D@@@@@<      s9'      @g@@@@@r.@"@@@@@HP      @HP䜜"     @@@@@ȹ sB@@@@@ A3     9 AsrN     $(x@@@@@@ $(rN @@@@@@     \E9!     @@@@@@ 99'D@@@@@<      s9'      @g@@@@@r.@"@@@@@HP      @HP䜜"     @@@@@ȹ sB@@@@@ A3     9 AsrN     $(x@@@@@@ $(rN @@@@@@     \E9!     @@@@@@ 99'D@@@@@<      s9'      @g@@@@@r.@"@@@@@HP      @HP䜜"     @@@@@ȹ sB@@@@@ A3     9 AsrN     $(x@@@@@@ $(rN @@@@@@     \E9!     @@ؠ^1b˶n;>|UVm2P.{i@@ wq# /h-Y-Zd5iv}w0`լY3%UVY֭Ic=6q p1B@@HP  9slȑ6eʔc_]wu)%nf;3Îy=qw (3uf~  @.HPBs  $%څ^h~$W]uURq裏m3ue˖B-Zf͚%˗۬Y ;Z ׹|  .@"u3@@( Z޽m)=$Cvo ;|`:uJenB=zq/ϷkmӴiSS): :nA@@ P  @ {mqZcQO>$?l2jŋEm_Yݺu.]ImU^ 2ƍ7뼹 @@$(x@@@]Cz*8:wl}qd4n~׸O<MMjsm z깦W_=z}뮻oʕ+GSctT׮]F97̉9  Y AF  ӭW^o6l0XhӦ^z-{'5kָ 袋 .ѣGmӳgO6mZC͘1sϨXtmF7qD;ꨣ>k߾K)tt>èH;L uYrl@@'@"{ @@ Mvk;2: ڵku޼y;ڵk݄yAAAܳ+K/ms9u]ƨ+.J駟Lɡ3f1"__؛oi{W(qn<`(S}F-:,jd{!  P$(ן# lf@$+b5jȊϏ?h$?Rh~Js1Y9o*Qۻ￷*U{a w=a"&ss=v 'D/X&MomV[ٜ9s\s5̎JVMy.]_~nI]*#  PHPT[  W஻N:)2B= 2 Mk[jJ<vG'NilMZ4Q}5טz;$vɮʘ%ZEJ4l0pr+ޭx8o; RCt/.\_xᅸu>m^>6{tۨQZ_Rd=X(1낵Z/v2%g@@* zg.@@(9$?w_{C?z]AMUzHo/_y၉ 75)N{v^oM8{ѧOS $%+O ,oh_й՗#Є&#C$(6=ַoguq:VV~^h5Vx+].?EFYuֹ-shzj;cn2b#GtR =7|[C   PHPTƻ5! D`РACVi0w\5k܎*9tUWRH^dTZVLDH+4*{QBcN;Z0aWV)j&ci"?o=\)h[$Ÿq\9&%2T( Cɬqũ>8R^+!%Elz7\&гy  @y (;@@ X[PXX$ir8ЊߠAn[lv:^U6nᆸm_zdѪU^hro*-qQj֬i+V9.dP#(i7kQbG|d&BeN=ԤYZjez=јvuWC%> V % _R (A^n *?LdI/u}Ujժ&ƍG sϵ믿>!;%)nVJ믿v۩a\%ʌlk%$2G,CP%%k/tQl]3$Z3bĈgK&3<slߡęא^=..tA@@ k$(FɁ@@RO1ǎkvZh'9!M~'l5AA%4Y:W2IܛnY&ׯLkb<,Pk>&#xE`Ѿ*Q^k|*C:ĉ s(7LMKZIZQZk.ҥKT_ %[BK,ɬf^-5z׊ȸ ]s(!,JFD:u>UfK4/LiXd^hϟ¶  d]EI9   @*AAk2^o MFNk>Є}d̎&1yd;CBd 2.R7#hL^}G1+ *ʉx>'pϡ?iyTHߙYgVJ%t=^? ;MU$ɌGo'^/FsKE&>~kFAת]J~kΝW*tg~ԼysbҩJyS.A@@ /  @/65YÎs5R/}_~wίt!*9yJi~GKQD刴rn WJIw^vwXo: (wd gؤIR=Uh;^9Ӫ>kEVQ(*%BzJT}odVOdP%(JgC+@@SEysn@@'n:דA} 4Zj|]NBˠFʑ9oW^=juۨM2zBJ*h\ZI߿=G.:JP.#q3&tro6mڄvS߈?>hLItwLo>jL 'W_3*qWE]u{;qa%bW9(JBxp@@$HP$ f  'zCC4alQW`|HZyIdM7_=0ȑ#]P9Uǁ" sqvI'èSΝoPRꠃ4ܱ^z6h כ!ygzN;Æk6RRf͚Qתd/ЊK.%2 %? \?WL6-촱/@@HVERl  P&TVSw9pb;IwqG?>lכ MoEd$Pc=6lwMJ׮'?T_"C+CsA1bĈr@GT+ VQ>j/3J6)49ߣGm ԹV7lЕ*;rB^'`^\Gcذa~A+ɣJ'j7PLCߗYfZ%?  HG}@@"7d5"NV ȷB᪫ bѢEָqC.Yv~8B噼m$YI=zt`d;&UJ*W{wOB+9t_"KlE<pX\9!&52PWg/R`^dzwe׏> FQ(ӪZy=fm\ /<n֮7?#   As  Pn*UN8O>Ķ~TvI Xfg-t%kGuTTkA%~|AW(WCϟl?*@ oՄ߁?4ibjlSLGy9c?h5j%?)^dz;wn9TB,(ajQߍ;\r!6yU)!/p@@ A! d,iFJ i":hr׿J]Qc[:Zi5VD}-[&?͛&Uop 6|8$S6>[O:TеyirJ>uɃc[*qJ udS=z]o' MZGNnU*kCDkĐ!Ck]?\EDPPǍ7ٳMͤ3g %+._%Z!:?WAJ,ił?ҽ׺y3Vzk>#M7"1߇5HHGP9&:O<1J*)D   P^$(K" 8% ( z饗lmL|ɨ-cFO;3ݻwwMMy*:^5\xG\_6(V RO\EU J_WE?A ZF-[ \=7Ռ6%TI9JA>H^VDF=gui5Id(Yꫡ޺heLc=0%ʼҥa=og31?   HU@@*_>_~eqbԨQ6l07yI_U7_ͶDVdDz?vxjaL XoWlM&keכ):%D׫Wϖ,YG60fڵs+ ҪTqƦWc^nFdh_ϊf =*/ճgϨdhzn2.袰(Q{x.wPT37#   A  *ot嗧5UTVJL4)p v]wٛoo"Dl蟠W#eOСCO)C!Qɟ'-RY*EбsOS"jTޣG[bE]x8vyMO=T,G6m7ju)]5F%Tj \R@@@HPl oVxD)6Q}9fz>,tИAeto1kdcTcرvi=ldsfG|{Fl֫WMt#GX8   HU@@r"n:7*-|rkժ&y=R`0a+wO Wc[néƵ`7j۶mTɞMju \|6sLWRHo5}II/l"BcPDcVR߾}+05~H^GOZ\C8p`ĄB;ﴆ ;8SYlaJ.iVH(Q{JE6!@@(/%y@@W+%u .AMDii[{Uɲx96   L@@ȡ_oKPh$j /8C@@ AQ@@HR@eK.xRMM:5,q''yf6C@@lHP+GE@@.馛DF]nQO5^xN:6g@@@<HP>F@@R8C'HaMB @@[EyΏ  $)駟I'doV{oֹsgVZ   @@HA.R=z/E;c[ou  e*@Ly98   P6ͳ?l_}Uܓ%4v۲ GE@@ i    Oۦ?F[˧2@@ @@@@@ȹ sB@@@@@ A3     9 AsrN     $(x@@@@@@ $(rN @@@@@@     \E9!     @@@@@@ 99'D@@@@@<      s9'      @g@@@@@r.@"@@@@@HP      @HP䜜"     @@@@@ȹ sB@@@@@ A3     9 AsrN     $(x@@@@@@ $(rN @@@@@@     \E9!     @@@@@@ 99'D@@@@@<      s9'      @g@@@@@r.@"@@@@@HP      @HP䜜"     @@@@@ȹ sB@@@@@ A3     9 AsrN     $(x@@@@@@ $(rN @@@@@@     \E9!     @@@@@@ 99'D@@@@@<      s9'      @g@@@@@r.@"@@@@@HP      @HP䜜"     @@@@@ȹ sB@@@@@ Aʕ+g}֍γ={&=iӦ]weO>M:g0J{     PiHPTBSNo64޽{ /Ԉ>;¶=c뮳'u ol+1     @ A>ݻ_6>حHϷΝ;oimԨQ~fmPc%<     l0$(V/.`92doڶmpĽzӧVjEEErQ(,,'x”s[|=SW_4i/o5jf&괕<$(/[̶rKS'V;hCdkڴYNVG\p6zhifcǎ ;M6 T@@@@Z`lu#ijvj_C"K$(a,P@QF WE)vvG}4hXTV-~u_KRh-    _A=OQ/ H-{iΝ{G#<9 d=oСQb-yMwޡͳyT.$(AAT@@@@!_9tf׏fT1HP3go>4v۷o#j/ticD%\b^zihl+ !A"    @Y]!1쨓ȎcFG0aB(4Sk׮u|ֺu+/vۨWzV(yT`("    @Y )ʏ <|Qr:u\yp?x{HCHyTuװaC[hQc<snv0PdX ۀE*ZlIB-ָ٦"]3cE@@@ KN(픽;K"{iSN6{l2N4i$0`hw3g&N~nF… ݟym@" klMf8>W@.y(Kh>G"sov4F@@$bqL(W:uJ3iV3x|rAfS 뮻줓Nr^K/%ӫj=zӁc%oho l#M"o8R9nALX<ƳySLv|W%-@@<k]`3g2Nd>ȾiJGo]v}.]j%%%vwMI6b-gϞvonÇǞ}لxK.VZeիWl+ |TE#xmJJDxcIDw?#X͇g?l|N?CYݫd?wy?Dȷg-9g/ѵ&s#Iem(dƑ  ^N5'A[勉͚5˕dbРAYʕ+㎬}믇VT {キKn$s+1$A6h +J"IIřm(IFU:gй2=NI>kbO^/,(>/W='ݻb@@ȝVNd-nD̓{"EMS:oi[}veZ}G^wC %/>쳄xGln;jo,J8LPheʼyܯt"&EE'h"7ձb7 7MVzRd&:#su2TǐʱuT۹shݺuR9l`eȗ@2|%'2"Q" T*D+'(T6Eٸ&}wyǔ %Kc;UI'N&7ozNf͚.af?䠃)S?gXICA1c 4ƍg(J7)+X&l&;vo61ǛMe̙!r0љM~Xs6 Dz+}'ofkHyL'ߞDfx];^sMto   G QrIKk9!qŚs3R|/۴iEϷw1pdְaC?ZqGزeˬqƦ>>:ut^PY'5\c#F_c[V |KL$8$S*6VBwXcͷl_wE9^Iyݣ\'#2WL2grd-sxz){6dN'5~DE^(gs#  ! Ki{HPmRGV^z.)P~WWcPsϹ?vi6vؘ2d=Ͽ۰#IБI6[Mlow<ʐHpC^ tО@@ JPP֩, 7Nx}ǦMS  r%K*PBbs\bBQTTdou5wwq5AWPk"HwT+Nfk3<~:WTjRN'3d*6*O@@ C QyӾ0 *IPcƌ WR߈5/ܼ5j԰ Zݺuݦ_ 5nѢ=֡CVIh_w|%2vuװeXg3AʉT%YGlM2"#* ^s  C` lq/NeIPq3,XGt^xkԨQhRN8p`Tvi܊ٳgGEb@"dēVM-u~c=b&+  /[@@OD+'(F"7 ϢL*E&MLuVTZjG[ov+W.>K*/[Z7EgkEPi'YM @( <*D@@ ˦?evʾ{IP9Y${w/"ꫯ . pۥKGaSNy,}zm6{l+хe#A1o<,oH19  d@@Sٓ/OvQG%lǃNx5y㏇UaÆ&ZldXΙEP +${@2LPץ^!@@,SQr,`E>zq?]͚5m}u+'dVOh]vQ*#ׄ y$@"nCA@@ @ Q b!A{X\\lk]zuԩ2G6(?{!@G@*@P ] M+e  I`n=}IP&AQ~ TdWe  T:D<;]wE Nm㌷B߽{ xJH  P$(C"  @Nޡ[NaEM6L욉 L7:Ɍ3HPdU! @6HPdSc!  @N8G A E3._֬^eիoU?{" @Z$(bc'@@D` l1]ԤЙNeAIPe(@"y܏ƌ;"@HPdϒ#!  @N:}'R$(rg͙ Af9o\Ⱥvhd۴~Ei=֏ˬxd%.ymI-]]j5i]a*h`_y啼JRv׺}׮[q 7GuM0!CdQE19  )$JNS S8"RE.9WB{YVU& '([=ߏ޿M:Ԫ}x\[*XXjǧ%'l@Ejzꅆzy٘1cf첋;VfM믿0k ,ʖ-[?v!o ! ؐ>׎  PNh\v*ϻ$(E%(4dPż;Y[:Xnf.x¸;Py'%(;-\Z6cEU f /)_A-/.Gښm"r[ᄈ֭[3P&Jɓ'O;֦L↩: W~gw硇/wy͛7}v1)⮩ĉ'yBI/f͚VqvqM>`:ujضguRP|gۺN8뮻'hvi'Ud8}'?a믷UYi x'%otgv#mܸ 0=\lx 2 wk@@HvHw+XIPTXi"VB8cƌXX[ЭiqT/? %+limo0׭f<}peJu*iuݺuno.+"&"-v%T:Nj-ҽ9ۑGi׏:&@4h ?&֭O?d~[1zUWȑ#rn#FCAM2V(9?m4[rLJh_o'균e9&UIWDdIN2&uzx:Mߞzꩨϕ/e˖{1w] .4%"Cet<@B#{\KK/u>ݣXז N9:t)j&{ѧOl\*@ P  Snl$(J% j^9J׬NSˉoZQRIaw^v='V1me1~הm`ͭnKmK?$֖ӓbR{nIzi&#C~idm;w{O>{M7f9 <W_}u %/|XqW\Fz{oӄhЄmV. ~%lMX5j&J/rd{f._޽{7xz\D*NWVZIzMXM:>>}\2+ ~%9 X2Co?a+9$[P>;\Ry恉' %J %Z\^(AvۅIW^c2Bj^=g%KrL~mwm7=w3TR18W^y[vi\UTII}GU-z$(~o'N7AINkYݪ?^ujVXak׶˗[͍6U˖x#[OQ˭t]Y}r{mXSRjWD&^뮻.f?h>+ձcG ^za+ɠ8^9.V6ĐBek"ś US^en<@SNz\o{%|[Gqkk׮Ϛ *O4ĄJh]IgMp{.pܿ*sč?.d~ ~ p'%b-\RBm"P7^ MhE"yI~M)kx5U&kԨQn_ #1XbuŭV#he& y7o[MFk{=OgJϐޜ2J~ 80^,oL};3\O. WT,J)9gI+OR]ŠDVjh_V(x+R{Շbܸq[yϳw\h{,>)AU.^_W_}5juKY~'R B@3N pHPdCdO`COP^vbcUŬZMۮ=fav˾er߾fVekɶL&6k?kfKZ}^,uf%7m1$囨4?O傴';(iT5'4QԈY ݮ^CjNī&cLפG># O?JhՅBebO7AI6mڸ ]wx㍰ͼ&UB*(Q@oΫ?K/,R}'_HɜX+44>M{X>7A=?vJXy?LZPj:P߂Aɔj=o{/PZ?B+${ ҒgljYI㬷l?zC=lџŮf59SOo="8O܎D&PW2CT&'5ٵOj7iĝ{F2έRBO>{[^eP !M+aD^MJC}ĶM*&]wD*#&=PW(V(୎Њ UodYDGϺ|X+E4^+ZjVD~oM(T^>JJ$W IjD5\Jz.sgF.($^$U[u]JF)S$ޘס*ەJhJ.i]%' J 93\7tl;_)T˻AJd(J϶VT%V~s  +0Rౢnσѹ+AGEŹWH%(IN'(nb1FU !AGY̪խfXv/,}/\e?/[c̬ 6O[*&5%41r=ׄ= 45ꂠPG77 4mO8Sb&jڃW o^Ǻ^/4A2AFhW+^( M9J)J/*wRѽ4gwBA]"rT&d%U~s  ++AQԤJ xR8 1* octh%ĬuZv) {_7k԰jw<1Ӗ)5%fU ~~UV-c'ɛdWMƻ?sGH4"h?5V3i&Ay'X"[6ު[wBIob9yCM;0{G*'^$J0h^IL%>=5//8䓓.kU:j2HuJV+͕^%'c婧 RQr-u\y啡eAZ٥>Ey';>$(*}b  O2bhm dģ"xPEGϱ}R!˗ÎݾeU),*f=gH* m5ѭ28iXzk[o'*J͛7wǑ&G0h&ZN vݯUVG Xp6hŅ~ɕJP95""n䑮kF&ȵJC]vu-*ϻ?'7e  UW]e#GL9Ui'mۭ[7?)GWdg'57RBtP(7*\V!VNQakusyQQZ(S?X?-HLy` A" T<NT{IP*e*yh~&~<լ^d<58# ~/xĖڭЊ/ev1zg42N/M^+Z WWvj5Uo.*/~4~ēQiB7( %Y4MzCVr?C"CMϚ5}akwIMk"dC.8`ȲA7pկ_?jK5V$V{5x"+V[ٜ9sv쵡VjD瑩<]tq."+"sq%ռ7S9W,:?QqkK5p3J=a{cV裸P*cпA=Ufm\dOy'Sy6HPad  W Q߉ӂ_|W፜ņw!ADU6n`֪$Ѧ1?_l\=; x+(OO>?ˮX[=ѤYs{a1D'WFh"To{*㣕J,x7weŋ&J߀V,iӦV&ĉC}*+Ot+%HT+)o&k^Ͳ6&5ƍ *%JuYWO%*}Xɤ/ ZE^  PvjvxwE5;t`Wp#HPL@y'(V4|uZ.wmrVXs}SLB( loPa=K͞xsV~8}#ď<` ǃ&p{eÆ 3%8 剮:;sRSx;zIk5Vi'J)50GFy'[>$(*}b  C ^nσѹWaņy;A!寿hՊbEFZvFM  IDATy֧VVRZb%R]OT(1O]X_4鳿U{F3hMkB}ajMj{os[4 ^hZ+1ؽrN8d&nj/Bcq޿5V{&HPeDҤCtתQcZk& e%+R؀A+]*rZJ(~=oݣF{ͨSW߾}c&6\Л`szPxiŃG{V9?WBk\zկ%2"XScpE^)Aסr!n伎+*wRXQY6]V;rk^w} N&|] HԸ="  P~;wKYEYr̴!A[l͊הkV}dbOLDwBɈ+?߫׬e}j֪= /p%j}:&5XF Jx **MB.X% DnӺ5֤ޘZѠ LAo# JI%T+_ׯ~^Qie34SOC7^"%yՏA2SyTCyB~ 'ã'{|oX?TI+HsRRX!+RjQj Hzf}Q߇;%(C*ՠAwE:5)ęe(a{_}}'EݻwNf4hv+* yuk   @HTIA$I"I(6ˍ@$(rsɟ]ҒPR­!>z[IĄR&>U:QvK%4ɬƾ"?&|HP\q.ARVDq{vf4*w4uTwLMfgJ/mvV(y&횼hH^MH _"\s9))2Oee,1s Ƚ9gD@<k]`{A=A߉su%$(*WC"-kٟ6iϭ_6Q}.Tuw?l{ T)gDZ0dȐR8@ͅ5ᨉ|jٳ&5 J|_+#` ׯ_)lz+'$QYT}EN5% ZԩSH;Ҡ8HPap  y.N]޽uw S?ͧ={`[Zi/+-bC 38! ly&]Ac3 C0 C^UOd%;2#(EO)FP$'-A('zHsϬ&to"Gbl/@K-]Tkx7=CPYAр95F"sʄXjP od3E%(e}~)!`!`!HN5R{l#`E?^\j_^~eyi#bi4tb#믿w1ch0ٳg{RNocƌvOIB)bŊ޸!Xqc='HYcgcq ,L>>Ⱦ5!`!`d7oϑv&뗊A !jN.li!/x'䠃I\9g}9@ssbad?As!`!`!@ u93T&M mُ>:mڴ3j0aB:`zNɉؠ<Z4uT7oހ<FPd~ l!`!`@4cɳdl #`Eٍ@ Y½ti!`0!`!`!;M)vsJ#(r}!(8Nb@~9l:!`@#`E/ 0 C0 C k$Tt}mY3VHz0"Zq"`E冀!`!5A5Ka1 C0 CbI;YD/^fE@&Gı; C0 C AYwC0 C0 G 9aٿFK #(n6 C0 "`E C0 C0rHLsbS:H#(R 5,FP$o!`BL!o!`!`|'I;*~FPSk1 H<0 C02:7 C0 CbL)'C3" `F !`!A+g6 C0 CH7V=ns}#(rwȍj2 C0FP eI!`!`ā@Gri~{Ea2c8퇗A5dEl.꥾QZMB!")-.2.r il=U!`!/\xnk C0 C0 ~@ &kj㞌qCf7#("[T/u dSS򥱹EJKE>]JeQRRP(êːjR9H ӹtֶ!`!0  C0 C0  6,&TNT4Svn `S7"xU:nR`Ry#$OD:dņu2z0)--=q{9seHujQ=DWeۘ C0 ~bm!`!`)B 9-mFN\n\^~8pE( bҤI .v[(˖./Ty}hN IcK7Kq~3\:;C隵2~p ILe9ʠ ),("4LFT9 Cemß wŊRYY)UUOԶȪU6B!`E- 0 C0 C ~ NjZ?ThmiK2սKE:C2ZZCyBB+)C*]+E7_+'|(G !#I3W"=;|d=mF>Øg_W9S<?墋.N:Inl+#(l[!`!``(;fT #(6/G{{,[գE6{$Dkk .Z%uk7HSK *)q#v)+-oJ9n#%?bV5$^ "6lrᇧi^K.Dve+8GFa>#կ~U.k|0?яg?L2E%GޡvwqܖkgyO[n%c㰎G1; C0 C0'̱Mީ{"2"잴!`fhW-r~/%ŲU 6;R& k5#iSv0zhYfl,*,yE_[\X"Ҳ5Z8)|r?߳%߻oc=6[up}7?~7+^B뮻裏v1yMkHyyyf#FUVzm #Ztttx.CjԨ˵ o+z477{=AGq$6Ș1cz}OEﳁNPx-cz_p x&ƛ6m-~ T -̟y~_o}QGY3H\/rU!'͗B}^K,h1o]oBTUTTx픔m;{/ug}>EEE]bdB%^\-0v!`!`@F N yFP*kOmLCj+7$|?榍ҰaxOdPiJK*J0@JerAeJJ$\6WIG&U3\.?KΗ)w+TS $?_o1_]9휮 !ItƔ)SC'ʷH&,Zq[N.R/I$RUUUi tiISSS1%G bG/' /_w?I~7n$gTk?񏞖>Ip[HM:#_%$@B'轓D eٲeޔ9~-,L XSiѢ aÆyX++jT(' xa4#dѮ*|^[ú:+dv-w'{7o? X'M8OVϤ &}tQv{Lk%>i7z.{SAA6|d= w^r?\ |Ox=FM~̂ݤ-'Ik<#=R8$9Mn`ʋdN8xL۳gϖo~󛂱qe]'?|K_]2@}s7$pOfIb85\# D 6k ;xwy簷p9%ݛ $?v#^|sA|Bud ~AT<裁U6w\>HMA$$o/ws0%B3g$*--.ill̙-C I*|FE'ًٻ662C0 C0 "mvseECzY!!sWuTfLO+pJvFP$ݟR2MPw'= [Oc--Mlه"y!_Adc}TIqA xs|2q//}4^dvӥy#")Ɵ|(8횗T'$A'FArN?_$?ܐ#9&0rz6'92=&9DU̙68x^Rp!BF 0馛zal/ƍ# z>(_׶C"cO<+RѭJ&M$T048u&IP Ć5AB&~>|X$ThJp7n|R\L^Rpꩧzυ{N B =xvsaڴi* +:P$pYI~(^,Yg_ D * *B~i&dӨr1NK@~؟[dx. ѦcI%UB*htcwx~(FPd~0 C0 C wY]+!Yb1coܮ 9 L]#|=L$JP`|A*|(lMҼ|. cer)/.w^vyJʥa&Y[au:BڸI=h/R-RP(e%P(AQ\X$,er@*NGh45ZN[*ڀ8#{t]iATC20'!,`^T+IB=Kʜ'Ox`Od4 Od_G$*Ae Q#S$M('tRIR$G9OwD|g'$/2Oɕr"M2%;"==^dB|O {+{k8` Js// )FoH+~*իW{>/ IDATn@<2"&AkO<1lQ#  MOTB;OYZSļy! xiV}}A}kb#2 C0 CH?oϑwKNvh^e&_$A9Adhly"!9˼ޕH lgFPdZH C0 C0pf,!Gw@Ӟ ES&ӏ~_,=lili%w\-œ'Ȓ9aWI=J/-). mjPGtKC Pov멠()* û{B>}+{.s2H(Vd5d2'GR=C:O&SmV"@JC &) ,H5sdJ4q1EW(md0$b3%1̛t$8MN*p&N>䞊XB_ j =OVcC 裏# %{ m5"' elUsekP5Dʺ4Pd`Ay)CV EjJÑ22=Txh{[A5"tmgO+aX\)(3 %27y,I9io*?;-A5''~L5D{C\ W r8NeR:$  )7HRQDr>$qu1##>A3AU%$ Lo6\ԇ}3kξ6 Nts5 \ujd x_^@r)(TZ F+D*HF&>Igm q;*(d f=tJYu1Hc1'mH*O 10;C[3!`!`@t-% dRi;eFP]#(R"2MPB (h%K>/ȓvi ȶ[)* JVcGNj(Гz uuI~^t46HgG|tzv||t52dP aeHe ,RՐd'$I9mHƒ@Y#NK $\8=NW{UZYϘL= QMR+$!.T"D"zO/^Jl`ĭrOHبwM 6>5s$hpw祧 +3 dK;'*He7T?\H)MdSP {*!zanm$}iVasGCW4 +!4L VE"S' w7]@P77Y* 2䎴w!  r &*M0'Bscnrk•xs={]NWM9%ND2cv{b]'$>NWcen U:T%LH&XhJ^{ z BTEcQ2}tT(1m^.I,? gH+!/Jl=}TvQTyRdaJJJ%bP"ݑ[So/*D&X%Y*x6Dڗ] ֫!`!`@#IډѤz2-ޤ_Y"eICGJA|أLT":zNhcFP ЅigTTQ$ZAAk.kWjK:Z~-6*\JDz_06LF*xTpzc"lZr'1ꗊyoט81#3yhI ^HRvaTP[ع%15kV]c$?pYahxFwqs᎕g?+\'>nrMKz<)sI dݨpgby晽a("Ң"ƳsWPhf6sIUw[PkLA8uCao=!`!`- kj)VLNR2! zi%@ (R"߯FFUl.+I}zOBPH;䣹ȸeoɔǟ~YJKe옑*UR\'[dje^ۋdSN 'DƏ+u7T8٬&$UW(DX@De֭['C "_¿/bD( e$4ǿyzzէ" m8IN^kdK_ Ҁ$VM`WgNP+ C$=x'9{}yC0%)*/*"$S䍨T9 .eΘoɌKBᫀei!$&$4̽T0HNn .އBR |;#ώz0ωKAvgo5H)b\]'1qVTJk!Ĩ bIO*/zlhz@Ge!}sH֗[=@@9{:u[=.U2 RY%#(g-l$!`!`C S*H|2]Y(۔>X0">C O_]+ 姯u'?7LN&yP? 83%]ҴAHnkYl$89O: t 'shדP%9 wI^O`t 9BtD&%p+;nfOj{iwʀ$>(\)ov`TZs=B2R#{キ뮻Q$ B kK$ WǕw|[?W@^'XT*1ӯ[\7\/ 伐" ɲj*oQ@BkFYAE {[A,҉dK^'Ȁr!ޜh~طh3GT$2W}1Q5ط;xV c$~^sgbJ?TH,%= iӦ )RAU?; Cw5n Bx?b>Yd'FPdب C0 C0R@DAHɕR[Qd)mi%@& 69:hS{BS9y`9aA2 ݛV^&yB]y)ݻ)&H~I,\L2f.MݕeR;~ںuƂOe)'ʸ㥤D&Ԍݕ k y'$x< 

lO$ ߜXWsX GGI fu.GFe<׮dTSNkIM٠ H`͙3Gf̘] 9s뭷+ b7 3p偒3IX׉7#)Is@$4e0w W_}'IK$Ʈ t8 s"INB}@B#T/ޕiTHK| I$I 4W&h Ǩ <kvr*}AUQG#) ug1`TV";0";Fe!`!:‘0y:J!vLmi,vQ0"k`#pH D<(tO/m;oWv뾻WYA edu85opu`g|Aӟ;f\kThyAh7 !`!`!pN.!S*dFo3op^bD AT~(;Nlh7ִ͕%1xYss^BLB.),(AUCizwUHs{,ZR.ƖNyk᧲n0E~։2d ) ucdBxTHꍀt'IG $V9aͩcNkxF‡=i"U AHRinߓD%q /&Kz>Djb4;21$9s&){9:L+ؒD&&t!y '2fAh̙.R5h asނhѠC hW}'ː_&ވgٯl{ 7 TJ?A9b SF'qd^D\cDVi~P8 9!45x@@LA}ۂwpb"HtԸuk=1 ;?V9or "7.A^|uC0 C0 #z•wzzY!ZcF#e 0B"ef!#(2 uG [lZ&ٸiJk[HyYɆޗKGWHZZ Rea`=vHzBO4gqRE/A2\+mb7װW!ځ0 FPy!`!`C 9] [Jª#~4q0 l]KSkTTTz++I~^4._,^}^IG^di$iXR80=b:"S#4N(%J": uL"H!©pAUSMsUWy"&3c\ve=s~Kǹۜ lD qRda!`!`:~y:@VUۣvF%\)1;Ҍ@* SP".X^J$P:0n({R ۤ_l9Z|nR^]@ָ:xp'9묳2=lGsl-dy#ݤ&HbU {rG{I1z![7ls9ٌdg C0 C0EyG\`?yy=Xw_Uj)?O1SӰb !`EBM!`!`YsW'.)- HAa#0"dY,O>72|g²0 !`E氷 C0 C0G`yC̭keICGH:QDmuAuKb2 ,D믗s=WN*ڐ km!`!`seD  $ IDATdJH#(AM FPVk0 C0҈iך6 C0 C%$\ ۓwNlqn00E!`E- 0 C0 `mC0 C0 L"oMjI9!x_%_xLLNgA?L6C0 C``!`Zo!`!`F%$HuƺG6H5=#('JP0g}VH&M fmL!`!0-M0 C0 #σ.O\_ ~O5?X #(ۊ#))!`YY@6#jD&Lc3Hpׯ_/rWDƍNlqÆ 2zh$m&w\-}G^|E9[nIogzTyW*\p\{QOFPz=C0 C0Gb"ڔ3%3pNU|]F\pM2M۽@\A\vq_ `f!'#(/*)z!!|/M!gѽ+kǙɁ?뮻3uGdD~ '7r9dr9sݳ>+n: EC~ꪫK.ڱg?G9ӹӥ C0 CHjzteWADĊ 7|'e88k335{/a-UTTx t/~ -Uy|K_J9{)dҤIpae3hH^M2E>SolvY7='z뭳n: ?䨈?Or'fX .2饗tFPRZg!`!`ČoWVvYЎBF>[bڨR(ʪ |$rb/ok#(b.;unuWX iI^wڤkt{>vY$Yؒ'=a]YP볂O>& gڞ1c&' ;C9昜s_rӦM2l0R@_ڃY >eee*+_\2d8c,ZA ח`E_e!`@7H2zU^!TJ<>mD_Yjߞ#+4iTlċ"fק ryoNdw5ImC"-.͟un "yHu{f"CewZ]Op]?ȉDa^KWkRT p6l# ,̔x KYn9묳l/w:zٲe;ʹyvg_1 Lnl1ݗꫂoIWW;_}sY7l%6w]ΝucttSO=(k֬!CdxԤzĈjժ>r:}֙!`!` `xpaCZ+";9UOAѯ7&-Es[:6=3Kѐ/KaErpA A>nyBCM1I/,+)e3GP,F)q뭷E]O n]w%Ig}؎lYh̟?_HCyQo[{*\HFzrGl1OH2f/} + d9|r{Ze溳g#̞;z`z7ϑa 4(m0S3vmO?9lǸyg>~?!/_.K,+xG"g̘1b O*O?s/|A.5jTLgp5551ݓE+W {ԩS=I,Se"E3%g=[̉$7u\@E+T(X^R,$R~LdN{C<<MV<ǐC<) 0hL0ApG">kCw}Nr饗zU8f*Tm)+R=T= ev|'dĉ[\1FظqTWw(R ND\;iٞ֨!`!` @)>ѠT&<$|%Ct-%nVqTt}ma6E]:l!(;%HOʓ"Nۍb'?;NĺID=vqbڟ&nA466E3gA.s-3 󺺺C&iQI;HIk<$Tqp衇Fk>IТ- XB :Oy&$YII\:n|K.qy_ Z@Sq*%E5FP@ wz av*.vyH&κcprTh{ ?|v=A&*W^y%l;<琖<D">4U"8蠃zIGs I?~;93]TP1rH*$ 2R!fT~@@PB!voHf?j[T{ru+D)Mr!5T"0&>E[n={!m,"W䏾JD}"`q?L}kyCW'1x#3n:/Hq2D:Iy* p0d^VzĜ Gr]viJ?&yo| N.n'aSLg(?GHe[|}q$MDϕg &~k?(#}`*Np' ! rzPU !gF7b,Tp4@Mpr!kI!g*xp&k bI`pș!Fw噂Ӏy{~FN^-!Ml{Hx"ærv!*RXm#*@x_(ɒ\u/ U@Oq7$;D_#(=9!`!` @/#]YCQS7^ &"Ȋ&>ead#(2/(+a#)'H ^=툏I-dHj~Wz?(B0'9=X ,%IѠ>a&?z%>ߧ;_'jZL&8!#1@b$5 I<0Kv$ ICA$ aGpbw5B$g馛 -mP~$ܢ2G _صO,$ \5֎9@*J%u \ (dƨ%*joyƩ "YUe`Srߓ~Զ|0 TL1A~)тg" !"d}wxIw[ G+ VK T"S6B#ދs}KPeR1DJ+q-%P=r"(Oq\dx! ?xAJIGKw)m_}oE"=!`!@@̭k|# "% =5'pN`hW#(ҏFP=AA"DXOW$I_2Dia6V0}q঺!hj$8͎VO%IsD!,[E*&ܩpe*`ƑG%#H~c %2s­* ·zNG wNT*HH NC`.HըHj =e<A $[@Z3 tr=%9VAXOGmO͜gsPse IT=J$epe}ں<}t?ґ@B /k%m䶟._{#Eb;=A ψ+{ }Tw3XjT\]~S+2wʲ:tϔDC,DP{ C0 C P"1fĺPՔfel hNٲR60"pZ! HϩNNw&l+%Jw0 x"yx M.̃I@)i}|Nr<T8"I-=i;4޳'H`sZyPcBo$T5Hbb$=rO#Az@WoרW[HVnF/qx C%P! 8N:*vSUX2.dtuo*L4!,Y#iF2`ճH O*diܠ*o> 'w[Iڤz!\$M(TY _=O3F 7sg?}Erw! \!+{Ͼ"L4"#pml ` ! \p}Vj+TWn@TT/2CM!1yw$xh@iqV~K%:WDu @֍KwH"!f f!`@_#ѠRH}g%ݪWV>p篒M3'W@Hv?Dv2r"YT#`EM=$qOlC)O4@-lMW*ۊgAk6l!(`^A:5ˢSX:O X-ǃ"PAxPHlUvf3(̥F[HcA15CVp0LI7zD48)k9}$!j+;jdi^xpZs$Hڇ AɎ4+1$[5 T=#TnH Y ,6n9(a~_B! 6t&9yz_hT-976g֛;b\Q/-ޕo*-$F1NlԹJS͢ klύ/!iQ.ُڦ\p5 ^Qpcaܱ{Hu 5y^MJ7\5$\yR1wuKd^ѡw|撜T@&;v^gE!`!WD:j\MJe/vꏫڿdE/ =3 IQmԫ?8ȩDzGT/O%AHcH"7n l/E2dU@ P_ r=uItv?YSZYz>˓2x{@C^nH} #'a Md8r _dd7 ;@EeQ{AH<gJ Hi{fڞ& P9T &ci2[C͋{:CU TEH\CV+m"I>*JE?;O?tɺK|"UbsGGmWu‘ YdèpCɢX/}#y^Ds"Ͼ۞z tKC4ړG*N~ϼx68daB;} +T="rw^Huu2seϮ ^>ֶh~G>Cp_?*w2cvӽHem!`@<($^ 4D )H~tH|#AB葸 !枌uʶb*( rkv.A$~SAϠ$DJ,;VzN+I(p:$"I2_LJmtTdNC.\{qͪ]%q@p+!7T~G ^KP WEھ:Lہ*"d^c/va/E5qk?8,m XƱD}]ⳠF B{h;]Bߛn.[s0pLkJ{Jt_Ø`R2H@ +1x0 C0AȈd(y7aQA,X?)Ho^\3fxII7 HN`rrNN kOe[#$7{:nd%%XiM+-=L4ɑ#k$jլVX!F 9$jJrgCUfܹ=I&YFbC2$wU@J$fI7#TI U$y%(MQ[m%(m UYCP1X7p% Q`6[ $[{(xEdsaSuj/H,GpOr%5T'@*E4؟Tᕢ'B jˌ// T*Dd @Ӡ 9P=Ujx׺Ԃ z_ꩧzkdW9'k4U dwAT,'c;m( [r߫p`lW9ϮT?WKP0~Wd0 C0! ܪ>73]*'6y4X0":Fi⦛n8IUZδۖpyT2MPo|BZWϐ6Y6UAQW&J>/k;WɒK{b!JqQqR wHd2jK,'Hx8u jGq=)ȘԢZAC.ƮkKRS.1! d e!A#G l,pOچ+[7OsQ\?D{y oD DB $?#R37YvRb1IPZj}sP'̖;xd,AJIYF$fZ("A~QGIA$RQOȇˁ%cr+ BUP*_o[ s53H p% G[mnECkއ2I%Ja߫I~\5 ՏGt NwH_#(RY[!`E`N]?{@A* #Y*{ԔɘžY*6RZ FPd^r>3"(J94 IM`p$F*ۊl (ZV^'O3md_8,-Y)8t'8Fʊɷ- ^pԩ$ a$D9mr'k!h44zvNqP$I j>5%WF%H*8LBn5tAJ`lߟnIxA{u_N$YWzi3D&pYgS /Mc3J'iC4zP@A\T)ᚊHTFvZqGr" +H&Qq'evDdG` `'k_ $](Ne g>'HUqXC DɻS=VԃkkH&!,}j O h[@#$dV{ X]+Dx@&4 `C`E*"k0 Cpa,ol"n;`hSgf""kFտ(줭rb`\Y$8=KDJb:C¯1ʶb^iqt6%%ɛx"QY۲(!tփ;tm; 2 57R2$T?+tz>\^{y"s/ ?^ͭ&@F } (@R"|܊T scTT$KD%'IbW$KAD5HwOke󀠈'9Ԏ?͞%T9w $8O[Hў_ן 2t0꫽w~܏T0gjD42d4\"ü0B'rG}`=A \ﴢlV >do~j*Hae}` EnּBbH7bI|O5$U0HsAfSj\PD\cJ)LwH:D#%5!`!PWC$T#<ĭmm'ȂU|7H^`xb/ArTZAb{8%JpTM$ dhHll+^x!({5!j}35j[$+~mR2$)|#ue;$!8><'H~TpWb+YەBd=>-#EJ_*i \ [%~'\Q6NC0 C 1ɼZɈ ٦fgwe+&픭+cJ#(E.q53Ʊ$ +yp3I jarY5Me[”(AiE=H:W}H|Mۺ/'DŤvZP_?[ֶl)/ $ߐ5m`Z)cTK?$1i:Z~D:!CA誫 3gIu@ [g8FP$i!` H4AJuhmm*ȋ5ݏ*DNA/EmTzvuv]v#;|Μ92~xW"q'h y8 C0 C}\٦Yߧ:{!2mTVLS"}-w?AA7ٖS?BZ,_lmĂ HU=\$Ocp܏2Dٳg+YPg}"D(J=1'@v|HHBM,TYڦ6OK ~p5%JEl9X%@"BV#FPd̙F>m4:t^dڵgffe f݈`qIcQ_Էß믿>*7᱁[< O=T.3`puYJbO& 3-/SF5-D0+"Xv4waJ@QK+y!Yd@x<?,O>zl ߿Vjj۷O6{Wˌ33̶<5A`Ϟ=һwoAi>nD_}p$= Dmݦ|@?;I$@$@Lƺ~HwH%yKsc\~NSlnh"@Vsٲe_|!^zQ\r%v_ mn3iӦ Ċ+i5-ڵSgyFƌ̶K$@$@$(P*p $@$@D "42)U/bD$m(N4@QK &O;4ѠAFR]t Vh/ LJ}ѲeKUsȒmګ2Pgߌ` b $@$@$@@  @WbҚ"wN%,j\qJFXn\ZU:Yw"?gGVO>{Vdȑ-ZŋU w}W,]~}ٸqp?!83T+W<5z͞=[ek`)PxCϐ    3WIJ"V"'0N^8 |'X)\V "C0~%//嘆NX _3f߅GJI$;996ٖN)Px$|HHH LP0HH"`xl"3KjDĈ)y5>DLDbfdMJ$sc0ZBaڵk%55Us:ShJ     $@$@L !L^'{6=Qrl$t,tl0 PE!SHHH : PuHH :\M ZMDD؃I {@{w"V8@Ϋc@ݢ[VJ#~񒐐  @8艙͖IH0%PʩBgEP`@]}'"a9`@,l'((PXQa҃rPz0.N$!1N${6HeMVyJjI  @p 0{"< 5K4uLiOO@pgM߉1ʺHš#>I7:)=PH!$~ziu l7J۶mUVSYSHH \XdY~[%R+CcZN'clLQ6kN\@V%PDSis*--gEHJxt,QRT)ӟ%KM"^?z*)))a_||ײrJҥKX :t ۶mYfI߾}~6%KH^]vaÆ~f[n-euYμ)PZs$@$j< l%嚜gHTEz~pWމ1-bv(bvs(%ācOJxؔ/?\Ņ|{ A2eL6M,Ç˱?3 ' Ȥ3g\pj[nVZpC:6)^6(P"p$@$ H,].+j,#Y8"@ZNN&(P" Y(lD ,:uo|l sPޓCB? 7ܠ:a?ϚDzܷo:@ߍ5={@2w ۣG^xA]qCdĐ!Cdԩa7\ddff pBIh 5YBF J# Ȉp5?K dJf53yB$;clN(%@"J6REj*Ydբ9Tfa+ r՜$NIߡLa"TSԴxƒqzxQtX+mRi16oPh/riu[ZHJJrpC+˗/ݻ7}{SOC=O?IϞ=CFGyDv!:uRBJ@V/,gW5cEV2|&>>^nf6X\o@kϙ p$(Fl#;߉&" IZhԶ@a-.6_U`t?LPk|\9 l"[$ džr(?"Ppz]?4hs{0|9sU3 Q([Pk:u䤓NR'eرO sqBIfٸqUѣGh}z_~38CSn]СCmڵKyogqx"և~zlҧ/+St\C~k P6+KjxXz*L%gMP  %.%%{3  h3w}l,I aE6 dMAiڳ$wnrWXZ#E!Ұq}IHHh^ꪫԁ6v={\tEk4h 0jӧuYnad4L0A{GF~A^|EegDFF\~Ү];IpW{Ȕ@lٲEZnߖc=V+ PY`z'@mv|w,8))I:޽{{#FwyG=}ɳ>W_}UTi-9srx0??_}Qٴi\>g1_<.7o6)--.D'c`>s`1fHKK}arV`_a/$ٌ3"C~>ू@9iӦtH"XL B/, /dJR\#&& >3G8%#|qd$@ wQ2ʼnY5uv D3 Ѽ8(͕3WL-UC?%r`! RجG&$%O2( PQA;8C~vѭo䛵+{+VP27uǶ+JE dh#Y@>u. 0K!8p@e ٣7o; 2qo}ёR^^ڵG2oQ̘c9ӣ^~y)0d8 xc\wuNC,0Kyo|ʋ9j6,۶Uk֬p\dȜرQt>L5AEMPf$@$:KK ݛȘ%ShV $C9&M$z\ZUY{"kŸ_am  KA]ޒش_m-'奕^ 6)[i@I"u'T3v'PO<2 / f<駟?k8wۚ9s\ve՚*+p .@S?U%;#KK͎tjYYT[*33SPȟ `h&h"lӦM!V LAG AYژUƍ&LEcv d #+ټy[nB} /΃>2]0'd8 d% _W3;8/3>lyᇏj{e50+Ζ,Yʖ{{W^mРʀ$$ܽӢE ??Ѿ#(gQ,,#.1 G qr,$@$@ @@B@Z螙ZMp,l"@bwDd!Gz(BϘ=@Vo~W#-5~VLJW=C}{{%P {t_L{$Ob A}(S=RBƝ@Q7-填 0θq{nYmCn_2e hʕW^6v? !0n#p:qj8>*?n_a;Jd3ƀg% Z`LNwT{(wY$8G(kpB%hhdž68UɤIԘ-}qlǽޫP 3n8 3H8˚裏kU܏@xlʗ!0'Zv G=?]1.6k8 Ko B2?t6ALg xbެi;h(1u5רL7xCe a; p @"Vc  PiŮ|%L1=w ''<$p "P@~{dPIHh/f@]o{@\8!VL] #h!724irԐ(#mg[oi8MtdPٌ_e{ P7㎫Y B!C| >H4^wCLҙhO M3!ed6@Gvu @gT P87^q6@x\p iQ'sz_0mׂއ H믫s(NjƆl dv `@#d7A(c~5!w(Pp @@eʔ׳ !oi9"0LcXF PA3h(0Scr=(aO=ḑmoAm;k@f.38ZӸ|0p~zY~,#e9?Qp ,?^e-`0oכm(3;orpL! ~թSU -CnfYi%sLZClAƈB&\FIf .Hj̟oCFs==uuܣ&_{;vPet,6y;m"_,rrre3rj3#wZ#P@ lH|#A3\i6uΧIH ah3w= (Pp; 6ٷo*W doEt:2`DCiд*i[GJ<{bM$ΑOI깸8YWgHJ$<("p؏0OAha@o6d6;Y%>wfnB3g{[~ŁAwp^SD6cx@&>̟qF(qH6+{w~ K <>>w M Y+B)rF .-Jܖa߸MxQ(]JfMW\q" Isr &{ƍ.k !/ JyÉϐ@($;T& x&My%K_=2S[zlk K {kEq_|/ PՍź@%Á&b:Uy/884"`UzyX _8&g7!1N zII^epС5@yYa[2kgLTرh(Qԛm#`ps:?ޞqlOܑf Ȓ7!0 YQ~}݀RFϘβ7?C}\ӳG @8w O_uUw K~{D| #u5#BP> b 7t7M\,=ˈBFLݻwW?ȊyUtK` p pXH Vtfhr+ 9O JЪKU΢}$ -@@ æ@!C[w{,RRX>IMKa4;"|<_ :5H!+ɩqC+${mʶmT0 nذaA$  T|5ס*ٳgp"ɺu:sÆ O#=(0VZG`p73aj ›诼S!|@`iL hsmg.`%B8+e>(o b{X#CDa>0_T?NPt}sʦiQ&Y@@J9̟>@:q=*fP'# O^V\$ZMMk_Y o /S$5(bm|(DPtq@'/?\tf.QХaP@Νk7q"w d঻m{<A[nl2:q|9J#FwyG8%ph/(cYYlXmj z%87ChdH@p(Ǝk%"%  [bJfU/D`KI<Ȼ:kȜ@ 8 "PżuR|kPcQ#AH 8|ѷ>~%<: (cbĭު{QlyK=#me{PqJ1,sX zmf=y7m)CrxUB1WGKz_>"ȔA8f Cp <&t\p2ϙ |3}йcI-d؟O!ζqfz~va4 Wu`3(dQ.V|r饗=)j/(uȨGb%'`@llH  c}7q$P3(N g(PDFj[8r]__ 7`@/*jVʬWȁ}K<ʼnڇ2%>񰷄(mȴߙT9Ⱥh.IINQ(dž'0GD*3yxÈϐ@(PUI$+)Fhf[3HH <Y5u^I P勾׶@a-._&[ nbVCI4W;Vp\}ċPYqvVf( KۤxQIZDHJs5>%~cYYf)gx 2,`ʌQ!:;g}$p;׸yl(eYqKܗx%;pxr3:̲?0a(;A`l:sYv2j `2m2vnZ +Qz d(bd`-ܢgׁG}dcP3f|L6m$q?D#a"B Jp|ܹ޽i>~n8Ї`ϋrbxBM@ ;O{}YDyC=Z9gwKd|BEP0 "lQGftk"]M|  "I8.jj|"HYC  ܅YwRZŲHWlCkf H$W[ow"4HL;^:A&Y\!"lgDq.Aq`hvp:jԨ8&3gϞ/[_~z7QGQMC@WA@giම>,>5͓I ;+‘)u7O D-׋H%P)WʮD~,@Ks '"P}6ѦqͤGm=00WІ(Edf\Q+TCnPEaadff]_}Ujn.x BHY53\P8HBIYVD03"b6iU/D(m DO0 xEWPM@7wʪgR\|k |v2'jb_kP6[FF@D5_xbAn_KX:.>O$@$@"4 @6/O旅|Xк{a٦f$@~(MmdN XO|-& PeI(L#RTvl,K!o,-՗y|V fCU0-]Tz4qUWI(AĆ Tɧ]v }ϓ @@Q9 @ @X<~3$hlEds$@!!8Y)$h@ Iӣ@y ֔Cb,T$%'JJJICa3]>HSNOT~?P</"==]b^Z^y_{ DÌ;V0HH R?BsDHDIz/( {(C†3b…8] 62(^YY)VUp?`?#0H_YYY}veDrJ > duęA$@HE$L$e  l ^6L$PC'2wШ D ѵ= ||hܸ<{ԓ1 ׋-ٳgUS;vT LuY6HH eAQ xAY3KY^Y |H b\`)(Nx"I= !aC 7a3q”xqA$@$  H Q"3#"a9F @ x+NJ;1H'@v|3%P١KW %+RPnI&9( (a8 w̑_eq*;yZ_m% $ވ&"iE9p'@"W(, RPé @-0 H %D$(Ns޵IEmgP   D}("q9f&CD΄H$@qKP|LE9 P$  =(j={&N  X"@q"Vs 7(mEbx<(bx9u  `("x8t:k=2S[Kϒ DQP@a å@ͫ˹ @@k˙@813D5̅hz9D$MV/{Rj)4m΅ŠZ ^ΞHH"H]9"@1K9Es$@%0)N;[#g(Pp_  a Clq  JLZS$9%HiQ{f*3'"ɗI"ʼnH_A?PPIHH Y(b|3p$V$3,J衬&b$@$(NDrGEiΈE.N$@$@1OEo  XD[S51M A$@Nq%b}pAEmPgN PocJJ|\4$"   P$X$cʃ%ar01gXD9 S'1H | PߵQnZ*e|Y=_V֢T*'MR夦M{cSswd O(f$JȒ@D$42TDĨI @ rK~&3$k@$@~@'8| pBӧ{;N?Zl"nȑy;VEleXQ.b %omZ4i.ڷ ;mZ5 D7G>ѽޜ xCIJeA@{Yi2t6E oVϐ 2K~ =rfDS$1y,cܽ wd/#@ ->R(\cwrcaa*R[*)qIod)?X&)4Ž=$u'i. vI:q ׯۇP8!@"F$(=% >ݚP&TE$eOP-$/6M!_/˗-ΕQF&_< !@R,Εw~^'VܾI|KNiQ_ڳWedH1 ^IH#;dxB;-gdHnnܹS6n(eeer=H:ujlxrg}&O<OT $#35S=M P(^m%dPX%&b=P"yZ,'V/ET*,}fgOUH-A wK1%j+(){=W_gשS'}gԩ2dq3?/XN=ԣC~֭[]ve/K˖-2hdʔ)2m4(Ȥ:thv-?ӤaÆ~rW;ؐ&RSSwQfM7ݤ;ONHHuNGvȑ#墋.R%/=Z5Ld2LӧOk&ݱ!`>.))QbaM޽{Æ ɓ'T' P G%d1LZSl QM8) 0$@q" %̇4aD;F94)P $ @0 &(c<8wTX,J]ʃb=O>4El XP$=s|=;IKuJ$B%H$1IZ*$}Mܺ(j Wdxcǎ\O  3)*֭['|"]v/^X,^MLLT!D *++OM6O0A.55wءċ }݂`2^}UI*..VMNK[ڏK/$FRelmE(AhɴZuC@\3fLR#H[1ɵ%7HH (Nq4k<)Px"(g'CL  VM v!iZm0CV"Y%WRE^0]^yLO* %ePOlq"{wʶ?HvN3{mK[-rI:@URKΖ[~_c7nJ2l o&]tQ޽,]ܔʯν| fٳg De͒RUWD !?4Pnꫯo}@W\̾(#( P SOٍ'ٳP8^&uժUʋfvj>H t`r",l OD֋- 37;:qhs2>KѯE? " Aɦ#NC{%@9[Rꪉ] 6Hn ׵F.iu,ع@wʿ\d/;&ŭʭ*xX*%!^$\qVAd뎭u|[˗/WZ㳺u؆*7n,j.B<?T 0 ,. (~u>9ܶm&(;m;t8R Y.zтnx9߿_ 4͚5s)Π.YntIPǖ-[2ݮ];ښ1Bv?S~0Ԇʕ+kM6뮻NPʬEneϗs=W?g0~썀"^>>F l@ ۤI̪   'P4U):e'j~MµG9tE.(eӾp&R,\Pp{ 9;3ܪ>D: )_oFu'Iq>A۴KT&qQVL543(yP^ݚF$h,͛a|.qfz-[&=zT0wqe1'"fpoWf83 @,q,{>7ǵA&20㏯xQ %v=@Yվm:p=+&Tc@l"y9ԋ`g!/wيVe ptQMf/Sd١1#-Ν;WkƟBo߾J8C["dǀ0c~>Q 7Γsz\9 ӇA @"T& N@gEDY_{C.ޠ04lH&Pz;m;M'|l.6D8W;: ^ ! 3d A$P܅2>E2f@@RaM*8ax\"-~/WP%տJT=zAysxeˎ=bݽSؼV->wT{f8[@8a..y/:F8;! Oͨ_uݻw?raq[8ցlhȀЁ,dDo#{W{9sO=C{dOtBѥr9D KgR#8GY,`K,O())QhuǾB$43ƅLY('MKz{_zؿ !`dra:Y^7|bhr <:sȼ׿eL7_dJMBAE(M@J5 P©{f4O4wS$@$@Jп}%cHz/(%0fC^eNtnYtnxriQuQն@w-Uy?Ū=2E ]ЙۤJ̩rmwT*JK$\nݹgӥs';wH YtdkTY)̔}}?p;1 rAwq@@( !:4cI&ouY@(դhbpQ --Mn0aCu;wu]w`/ J]wێɎ[ܾG|p<<- `k gqlq3"~̚5kÆ D lBi"da;qj hsu~7i 6lX- ϱ0&1c+mb2g7(Jg k9^;]* l~%QƗuzApC `jr7jBEY[B$@_,9._A'Mf(_VUSk|N"4\٪(Х?/}M?Ov͟/R(2(lbxLySW6K!:@vy7 ΗƇrrlZ,2DAea_7WW?{&7eP80GxG|֦M{n: ,5@EzzniC({dfPd[h#_WA)Lj:u:6k#_W`,([1)7n!ۙυJqhf">FX]2 %pk_N:?-+q Y)Ȩ@Xsf{^?XkxO uv*Bʑ%˂a?a߾}yd\s5*W B)2A?#Ⱦp?ty43߹"+Ѩ2_ 17Ă  tǫ (0E 2Kv&@"ח](jF$ LP5g$@$@älRP PYG ,QH@RlW ?,bі\E`g8{ _ IDAT'|I%NDH݆ %L+lbILBr\ҩkvem.2͹vmtVJz)=T/J} __с`}c+s_w}d  3O qh{afO?dm \2R̒5qL2 PU" >fd971n3 @J$Ab< @!72'|ހp2̬=^oo%y28K#ewM/d :ra=-^0fv8!k Mw؋`eYj ȰBkx (@d(~T-ZȞKS~@8Ng B/, '[bkda %M{-  oĉ.=ٳS{]Vitq Z^v_@0 HQŒ|Iis͢1alΖrzEj\,6$$$KxB&w{L]IYJ=';z^-{4PxTZU>6$CV>VUɗ@I{L]ɗ6E 7oJ4חgm+23p0<+#ZA<<$ %}m]rW^Q (Uh\! @J߸  |rbD'+!epL ( =y_? Zȁp/-(/sp]dN0n6 _dI&j3?Xt \i}4JisWګc`}B莾 s@AY h;WdȞ@n@i,w Ls5/7gH_(%H|Sh? "vIH t<Y5ŹЍ-3o 5>+H"|ւ#q(dA>}xddfPH(6'2F@@&Q&bt%$I\ߚ-k֬K&lY=_~~[e*!zX u"gJ*>]-J6Q,0EA/CN}Cemʭoh- Z|pնAWguq`_ ̯a3b=/p}h _hmuwSe|@~Z Пm3 5?`TmX7#a y&x%paH-988@92dau끽=/cx,%Ǵ{ϱƩ*7G0R_3Wx@с] 3K9!c~-ȦC)Zds Pl$)L||%@Wb|Zsw D6OflI#'L`š2wgTWA1 A%mQ'k,EU*qqҼH&uL{DJ+$'wlv֢n)ػW6ʲ>#"&C"*lC_w0ǨD]ǫmaA-mޱcG&u}o x@ `"MzGe_!*Ln~C[8ՙ}bƍqo@@rSprV :^ P(s'+l3BQR y$f?ʃܕWpXqU "PV S63'pz2I\)(' ̪š֦G}H۴ZDPaKq$f73(֭:Hrٷ)[v-K!8ِ/JD6v, bZĊ wYדnJ9R͈@@]]][8T-vm*W-(=x K8pFy_,{/\}M]kZ㙭[JV;vlqog7q +VЃ7ۙA9%=(̃]r[h ]8BDu֩`p[N:  M('Yp_{ BcMss (ʼnZͅs6ҹag]'js\M" %DB_qj(4ᅯ~&)M[Hô $HNP]]ymOl/>Wؒ%!RYHeXm6&ڤ1on&y뭷{SNUn5lofp̙3ƸLQ‚f}O p;:@>ka1sC=8~m-qv П={*T^^nv~! @.}ܐ6w7{G^|E2S޽a3vyxO V;>(7c@`Q c=^xda'ҥ*)}3 A¾63Xqx=㬴DM6/j@^3z\G. \}=bY3e:;u0w^#nAdP؝/qx^9qȼxbիQSۮ];ٴitI^D׮]{':P 0F@Y;k9JMAu, ǀ/L(CkF>c 6cĈ_wU?uy#OeqM4sq\uU|~_7lr`ܨQ#kI!;2B֒%KT/ɏI`=U}뭟04h2@ eԐ6p o d@@Y5[` ?d!Bw k b~+NsK{`(==H 6 &"@l’M 8~ %?Wr}ӂ2($I!v F15pȠv.LmnK.ջ{f/M$)wY:ɉb3~;߀~rpo,;ٲa.,J8)|j*-dVJKtVJ&7Gi<3 mQuI:Lt$m@Rg@f7oD 0"` 0ǮS5B»(íK/DmƁ{ ]M,=[CϛŮD]j\q@0ot?zjCn&;wV]&@D WS<.3ǾG!c3`B 2Kѣzb A&BX#d8 1C3?(} yf2,N`Cgc3dg3?7v<*P>$`@@U^Hfn)%P‰>M$@$ N;L,9N!@0q bwnX,3 &1=p(퓉OenXRR|yo򇌝41QTVXА,(_f}ԫ"V\6BciԠ޺SVl-P%EV_N@*mś'{yȦuu8ltwuз02p!Dtgڏ±m"8dai\]d%8a31C}"2>?<4h`o O=o  Z0?P0nkon@ V/Xc;3< :@f~OAN p_5/XY,`;$@$%܉bԅY;g3Q8*1|y~KnBq"EYT8\J<䂂=2y{ /H\\IĄx8(qq" qS9r\ IJ/Z#%%(s'wF'p߿:`k)1J, #<"O<񄂁RJIH 0(Ƿ#2&ff2MSX'j|H'qN IqdfelYSŋơSpӧ,NH.]&iiRY O ԑ;r$^X)'՛J||طoxMTach;2'dW[aСG~9O 0J}n/ x& o|&V1rNѴ 8 .H]6 qDpGjkIU9q;%Z))P҇)P_|W2zj9TQ!';ԫ&6HRrId@seyK. HBJ;I&>`޲eːI V PՕy#[bҚ"D dK@` 3ES_i3w=ᑀx2"Q(PDE)Px/H-qӦ 22$=93u5ȧH2mM  p&P0{"b7[Dͮ{# H$ dO uU ͘',]Ր@r(|gIH=o>_> @eFߌ =`sc$@$Q< ̜ `\idCOEP% ?|oSeeeBf$@! @"Pd$cB_S/;$ $`ϕw9}"rntpFm,35S=tVjZ P  x饗dԨQҡCYvmfK$@PTȖX\?1Sh|HH  @ȽwXsΞD n 'SFY'.tnx2E Q4 Ѵ D?>ٱc =(#uȔy?ט3%"u9n yUK&Ki(GQk8Qk)P+ =   =1P`)ۦ5 @ wzbfdMH|7 .],L9 0 ѷ=# |<  15ޝWU]_4 J&pD!H G`@:D<2("UHD-EԖɉQ(Q4Cs <>B>{jbЂ׺X^j 2PZY6~@$'ўEřϯ ^@ ;햳c]WV)0Ihm;K遪4NLMIԗI@*ϐ$OΓt\.(9<\̗3ն"Aaۭ#p@\)@•nEkBBhhDCy@ERt ^ z@W;pD¹{k˕4  Aڭq}S"HL|; p@e:{_6 ld^ёQc.@"[@5HP>  HPi-gm|\VMu(|m@ Tҡ8>We +J+wgMONhKk%JQ1MHPD[ A@; tQvQ+sʰa b@ubBCF}p4Mvhy'1@j- 0t$ 0 U5#*p;^g۬m@@@@DKu} `p]㔛h*o  Szz@$'󎐠p^bG իEY:t ۷o,@@`]dYD^TĞUҪi0Y- @T%'⻥KSyQɬ):F㓸ɚHP"-AQXX(ݹshBn㋂tt@@ wW;Hw@8p/ @,ʟΑ|N"&I\RXǜ*%'ܞ/Y,b‰ H2v IPd }` IDAT>y@\) Wn{+4ѩ N61  @3bt'I|Z:W; A;a֟Ѕ   X^[rGy5fl;K遪d&8ғ%/+9qx@ ==ᯑhHk&ݟpޢY_ h _PRK0 ^!lx6P5oZWVi$'h Jl] n,$'b;֜wL4QۚHPDZhhb޼y2|ZsFŰaÂZsu4@;>/~ahFWΌz!(<%B@H *qyIf\ =ѷu'uMWF  A= @CSVۇ-@Io_^k젆"+>Dg$, v5XekAꈅdc4p޵w%׎;钜 giUZ=/a}'L@V:Km5P:)/9Cqz"HPu'AtR‘vBz %IKH&!¹$4%O8ֱ|i9fj{Nh^sw^xùvg1= 5hv=F49-333|Bť 1ѣqƩ  Vtj".)ERX%\Ⰸ@Ye _3k4zrB',Y1E Йҷ@8:s"C X=Ma/[b~%/B./beyCٓHqE#Pt_X9Pi̾K31c^+/Т$&֌AONP\t:1:5ZB\-@AlWo˅m1[Ҡy$(>\ ;wI '*.ZE˖p^+Z^/R 1 OLÛW$}J''bjb+"' qt(׌2i >}[iX5@իW#CAפ5iu5/J{w7ԝQߗG,c5YiB5spXOJk85 yяO X\V dHGx6W9Eߘ@vN'ikm |wnْZ_o%(4#G~y裏ziz_Z+jlޞ &Q|1H~ye6`쉷YS3ck㯿ٸ5g %>B\=j~l3VE-jbLlˆ=%3P{ hbB 4MHPc"9A@ CGKлD N$(l Eq:x_ ^woUz"/YE4kt ~ߡ#Zv!-+;`uތ얨o>,5&Q/ @踼$!2r6< EF"Mr?a -$("@Ef͚z{XK&XtD@@ICHtqX͡ONp"@ :=,k$uܺYP/`ԟG"Aas`NP8%! DA աi  v(:G8") $b'JZ.1)$@JA,B@@J MLf$RJJ, 41 ئ(]_  Aa @@Z_J)=Px@Ed}$',NajtY{EP 5#IE @@ $P _WOu&z<! Ȟg8N'kFIYeY?a=t$("-A   auĄ! `wb}"RHֽ0E]EəП @@H h2BO:h_/~@֕U<5:5AZ5 y D@ $' ΋Wbnْpގ"D%) %33333<  S]ߙ /W\[ڇĄSօS PQ츤I_NV@Ѿ͒aIPIP8nK CV XEuzSxNLXe@Zwth/Pw̮_bi%4aHPVXN5OG\  `MD*Iiʚ+&*@BtqyIh|P-g$s{j?~/_.f͒~[-[&_~yȫ XE !>G@%:U6(1 -#1 X3 NO8v#eIk&ݟL`٧2СtQN*}ٖ-[dɒ%2rHIL^Lf5ۈ#駟VαLLŒ}@@Ą&(Ҩ/=E@ͲKIk%%eei >}Ţ%X |yO.cƌ1~Cɣ>5)SȽk|/&R9oY6m/[ppv AaV~  ֕UD8p(2 ՝(v1_v ǥޭ{[DQa\̙S_߾}yƍ%..N*++kkԨ2plα+A¬@@w hBBG`\Ԫi\8c @,/j'u"@_ =gKR[al0 r>o[Nx@qi=뮻,--M6mTߚ5k$33w}W:$[n?h"/5qpBxu' XȐF  {41BqEu%XG;ZvϻJ@'@r!Z$(%myHPXdN=T:4p5xLЦݻV믿^z-g։#GŋeȐ!rA9Evαa&A}@@ hbB xcS] s 0Sw/hf0^K-yl`؋]vI=l۶(o3Ghkٲ'0?^T}ڢXzTn"Gw}-"zm&&4AAC@ԝp/ro[Pk$Ì AaY|\~F$͛7NFV3AI MfT<6lۓO>Y#M4:'|b֖ _u+Xb@p@jKP@W;ד L-.w1Ev"p l)S{5"0`h_mɒ%rW׭A1tP7>U@:t;w?G~<s`IP+F@@ MLhBB 1a@"'@r"r[`QRVyJ6`| N,8zGzgT tb13ϔz[nkfyGXV  =֕U̢EJsJZ5 eA@ԝpZzqފdKcܻuoKNp A=k3~W_nVFAkIO:W赙 H'i!QeDDm;8 l,=tQZj%JrrrG)g-&#&M2~]3AѧO~EqF:믿&9V~N"X1# 3FB¬@0/@r¼=#')WTo!EnbF m{XA~~qBg-[l ^{Mlk߾ر#c N@@9 3],k$uY%ee:}ֽ-/NECyĈ2g'|R&L8ZlFkNdee7n7n7xFZ/^]$ ` ND@oC+W;DSMG";`HPgHkx0~~iٲh mzeysu۷oO?]Z'mzU^[󅅅G@@FĄ)&:! vjQhfpUmK- L 6Ś'(}QnEu+ۻ[{9ΖW^ymی+Xb_(HP+I@@ 2J¨  `p/|P- +ub!Aa0.f27N&OynƌFbB[\\|^o\-Ŷjt"A@@ h삽&H==AC@ lDvrFG|z.$(xU {1vX:ua<eeeҹsgmʻ+iii?,jUk"K.p,1 ` DGl͉ѩ FbUӸ, X\]* nX"A vz;Q}^|y!{1# Q]xRUU%~iyy #cNh@zԚ^0  \Ļ`E_{mp)$(bNou]g~]RS x;=qSP( mpyylΝ;]v^Wj*y7|5*FmnҤՇs,0%tD@"*@Z\p a$ uS8>7"s2(|Nk&ݟ$(lb{2e^3NR8pN@þkݗJ NleWq!€E/@"x3 24  `RL͉kRUSA4tC@\dm :w:F(KHPXj;@\&R\[.\a .{AX. %@uV^)OBl^$(x?,%@RA0  2b.{!X. r[B@+A ^@$( yTHPD@@kJtD @b(N1gje2|S' @B|UQf2@@@m//WIHP  ;_W;iDͲJ̮Z<]^ DIk>,޿ K v  @ еNJ@ ,@vc\R/|L'dnϗpV6adp"c  fl;e%֞! @|]%OΓ̮U{BQƥޭ{ڇ A؈Q AElB@ +Eߋ=P wuMԍ@@ \a`Yhf0K- ylt -VJD  `C3Ek. 7@)P:4KJGEC鉾= 3y]  @xăkM֙:;ҚsSx@IBb(:=Aq(lC Aᐍt2HP8e'Y XA SFAl ^bD[鉴i 0%@SNG7ÇsΑ-Z0 x A{ . M2{jBzSqOΓ  6sDW;BDhI| AL 0I5vX#Avm//MNn$(\,@BBW4&&@"D8>7*q0 V^)O󉣵' HP`*--:ȯ#_؏|%( 233C@\-P(&E_[n\LKi'g$R;4" %u'4NODi#ƫ&' vJѾ"B V;vI m 4u˖-W^dIKK-[ȭ*6l4m4'Z$(7 @p@vޠ5u]]J@}FLrfp/(MHPx3gIe͚52p@',{&Mxܷo\zFB[ӦMer'Fx7< )+B@ z&$d#  W;%Hˠк띸Ɍ&}j >S}㓅 JVV$%%IUUE5\S۷K׮]͛'ټmA KPpStG@[ ,ڶ_ND&4)AB֯#HhHp`ѥމ8E&hB**\vȑ#eϮJYdI'nj#ӧO7~N-~ 8K gmh"ئ mzrUӸ`? H1gZvAԏƅ IDAT8=auHPxy%dժUҫW/ٶmqF츸8N{'3~v9͛y A$@@QZRz]3MH4N\@@^ cw\^bz'W;mGI6>J "A .?dСFQlm]tԙXnѣ}r)QFgL+9+,,xr> @.ʩ M Wl|^>N_D>x,߽rdnϗ\^ЫdW?IFer7z_VZiHP' 8G`ρ*m 3ĤDq2F@,,jnT'4 h݉g]&W;9-HPx1d>E]IJeˤ豺7xCz(_(ŞƎ+>lwA3pf@0%0cSL/>Z_Ӥ&081H@^뤭sz{(*e~NN讋?P.2O-ݾ+ԩL0A&MTc}NOXT_-l$(wD  IP͖@V:a6(sbs@8\EC?2l0}7QczHq(2sL-8y@-]N)MDޫ%z@>5Q}L\R/ INpS̶q{1JlݺU[i׮quӉ'h<7ҿ֭^h-E@b)W5=\JT ctj5!a27 D@@eȰ _v1ǥޭ{}^t w%WM’BP  Fh~#e%sj"  JA,MHP[B A2" ̢^4S3ѓY|5 T7'5 c"3HO=_ t!@"?/={=A 22 VsvP; DC 鉄%a]9%PVY.9&HYef>QZtA GdڵҶm[پ}'Ed@b)0cSL/ AM`n@, E d  PoL)09T_:!N^49"5|RZZ*m۶ ;cy Ak vBW S # 9-̢ILhRHֽ-?Knٲ.]T~ߺ͈jIP)@@ $삽OMyYɢ'h  .'R$e~@XmMNP ;j[D>HPkeѢEƧz̞=($("   `E  aD)M  `?}; 5:?F>x+ СCuV~ٸqr%H^i$(0  uѩ rWׄ  `m3btA6(M/Fҭ@0ɉ$cmHO: ֙$(|&:t _i&_}7sΑ͛7$(~@)\[nԄ@uNOB@ :&(F2v+iV E~ϟ/rKѤI9|pϻAnq֋ 95ةqzU8/@@ ɓ8=vuHr½{洛2l0ٳgOP~'JvvLH_P3 l4D@%f$  S Pr|/"jɉiH4X ²23gZJV^-UUUS񒑑!5vtIryɅ^({R-@w@b)@ܿ\Ɖ6*I0 F?@*0.,k %ۂ3HD &Q   +*dzqEsj"h2@@pZnQS+Eb" ,$@BPHP ` D_Ԅ^LKi'g$R;4" `'j_C VVY.SIѾSHD |Dzc#0s$Aܽee XQ SZ V\1! @ jl4[kBLrAfڵK:$7,F 9_IB47@@{ -~VMLL@p'ܴ]+HNDw_-$(LznܸQ &[n]JVK/ݻ erc͛޽{39 HP  @$IJTǑUNF@Y<ˮ2NeL#V Aar7xy'M>mڴi2z蠞q{gnX? XmP\!LO TtD@CD\ڰ91}$<HPܐgyF>S^4c WW`.Der@@rVX_V)k  K`"):''xw hbbӾb), ֙:Fv A`s='yyyF֭[i&ׯCy}zʕrea-# m ! `[`OMf$n8 R:sz"avW M%&t}RHֽTbF ?/NJ++xeĈxSO=%˖-Yq)@"H0# W`NM'ˤD `.! iNOkFNLpj$(n޽eժUF=zĉ8rQk5+q';YNy@! g#@@Y7}eW h_ 95aN6 Agڴi#w6zlݺU:,[}ʔ)S>zzDt y5; D@fONpjokF@ <Ed}>Dx8J0Eu}[{RG;5! Ae߿o3z\q;}u|O~?~<[$(+ x֕Uʃk˥@O0  P@ jO4Tعs&&.k}\VHPyϟ/r#<"?ߗgԩ2vXM7$ .e BEXtE@Z\7)Q"ؼ0  PW;wK:;PL*)>I6Bئvꫯ%KxzO&M$ݺuYo7>ӧ8]:HPDI@٫tzr⎴i  @NOpS}NMl`jԚ0D' W6nXw!Fs=>^{neΜ9|m"$ed@ @ܿ\J'3mtj5LW  ~8= @ΆL&1*}(@Įft{ V[Eoŋeܸq}O=cG$(x#@@@v^NMj  @ʟΑ");fc|P-UZ4:F-= }A>|X^{5Y`\R~Z#[jwALj_IB @\$h~yt!켬dNM` DK,k`Ba L-.w!W:Yx -&$(_M6ri5`DU  ¦  DJb ?DjjƵ@z̩ͧ )FGEt%A`@ hזsj{˒@@,*+: ;hxc., K- ČCHPq#[ټy{rI'qdw E]j@)L!쁝h4D@@ "ZwBOxkz^Ds@Ѿ2x&~rs$(|8}2w\o+gy)nA|M9SO>dSѩ @)L!!݉Ī@@ NO/VQɬ# =9Qo:E$ Acoڷo/v풓O>Yd5(vmt;v.ׯ7]j+錩HP8cY u41 3-7#Hdh!l5 @h+Ű_Dd&(nfg-={7xڧvQ6n(\p= EIBA( Ill11)#QZ5  6_W;wKHOJNv1ke!DS Y4iRYE\|Ҷm[Y`Ç{_s-Ik?NHH0 fGs?1 Gl#@@-UE! ޷ߩ9=zܞ/6@fGLP۷O222/0uzʢUViųHqqHUٳ[o55>j @@36U SJ==AC@b%PYNϟXm]B#muԚi-X0zBBLPTo7ߔ>@>m&f xnv"8Q+ @g ;=W;5rħ;yV^)O+Br :AQD4au#?hf͚INNKu %33e",@{ 9P%+itj5^ $Z@p{'~Z'} V>\dŊFe˖ٴi:tH>SѺ{Ҽys+SN%JER@'NR k8؊@ ׼ B%'t,O.V@HP2dҸqc駟"ϰuHPN  `OhkIҪi\t@@!h(g}%gmGIm) Ac~ay$--89A 83  0[o+©X  .NOK:=NgVa>Bpzw䢋.VZYg o0CpJypmz2S3G  ,s|.`V@r"-"qd0 $  aMN 3-/+Yj'  `5NOXmGbOeW b7\[[-@\&`6&\b\@l& mXD$(x5Ŗ-[/۷x2..Nt"^zIFuHPN  `mŰHt  ނNOh AaСC2j(ۻcǎr˸qHTpօEp< @$()J4  'B$'lm"~y+4NCl? 0@VXaz/ .f(iIP+F@'5'VxMQq @@y~Jaaϫ*+`HPXk?@ГkBn| XF NOXf"HZIn'" vW'(4ImwݻW7oy'4y/ֳqOe\uU0MEG@".`&A(;Qw+  6-:^BKjȚfv>QZԮUkpu}{nj3LZlpdذaF:u*hHPs BWDw@@ Ue{th/& - C g13W;QbF8puc_AkVkE||g~\|_wyG䄶F AaΉ^ DR gm,ڶZ[ c@@; ;=q)O IDAT4g ))>I| :AѾ}{ٵkݻw7 `myf/qo֬YZr|GHP&  [9W;j)MD&fG@*}>I"T=N60u#Gٳg{6z֭rYgÇ˼yegg{~駟 .bˍ7(+^p,E8@%'tkRUӸ&)@@ NO$OΓFtz ́@mW'( .G j irBŎ;C?yꩧ<}V\Yk^@$(xC@Ub;^4)#E@@ qI)2Aa!,&˅mϨbF8puBwW^zjφ;V}Y7m$ݺu^{:s /5Yffhf^y+z" IONjS䮮 9  `Iqz’[/;Z/W>QZt \Xjݻ^sIf͌)))g̙3=%:r<#cyk׮]!@"," 3 ܌DةYfc@@ 6NOwKbFM`jtY{)j[Dp}BE&O,Ǐsc n|j^?:t{oܸ7+  @fl~G 9`E@Kpz *e>'9ab }&M$&LZ rwxլQ?lӦL>]p,: A[ @t$'EN@@; lg\d5{K-17@ "$(j~G2qDYhW/\/^,w.ȸIٞ?z,7 J @b-\m3qxF"ɉXo# @|]7(s<X[_!Y{DHPx?بAųCrmI߾}QFزeL:U &_|_/E䍙@zbbRFjn(V #8"|P-ڏ@HPD  A}@N `hQl_M ak   ʢulKv%ee:szœ H 3~P$(3 %'t 9 6wz*rJyxaHP4T 82JHP a@!`&91:5AꚀ  `{Gk?u-\d-6 HѾ"4# 2x$(? זΩS$'x@@Iupn_}[{RG"`a7FΚ@)ɉ{@a^DC@$ogˉ.O9iŇع'JZ.!HPX`$Eaadff… >kM%'HL! S՞Sw|]$s{VHPX| 7:kFh&'ք,If@@e:#ֽe{Y@IkhE6KΆ ^8v<@$HPDB1$@A|< BEK+Ą6 6wzz`@HPΞ}@@M2TgNMb X Pql6 CfU{*Ev" AI]IEHl< "=-8SɉqxF"&\~T@( mOx]>'V_vsVk'WEIUb@^ib"O+[q9  `{ҡYRUVZo%<ۯX{Gp֘Hb­/ F@jOpz]/|zz'w>$(,W|DXb޽{Fu%|g}&?xG~i֭[kv={93#F0JHHj,3IPQ INTvE@w Tҡ.Y@IN\,۶$(Ln/l"eeGUW]%sQw{eʔ)AɇBTUUI^?yƍe2x`Sqk=QiFz!߿n:u&A6JBpN$7#v & Aa!݊m &DIk!+e8G/'|g=UJ{GG 7|S:UNOuk޼|w})o߾RPP`t&e*++k=ҨQ#x|u&AFAp@v^YWV߽ޖ6:5Aad  P-0.9e~%Es/J^Bl(@Ǧ5K O?^{+ '`k.9me۶m8q'IдiSYpW:uA***5 qM77_Sk֬<:vXygFu{wСCuV%-2chą+i  ` d3 =1''h  sԞp1|()$(|~34))IC׮]رCKrOoQ^X+6U?ӦM{YfFSO_.C 1 4I믗zqFF$MgG1uX?]v Z? 0 CHN8d#Y DTW".)ER_'ƥޭ{"`#>6s5jNI?3>|Y? 3JAp ,B@p =AapJg,'-IY "">6gϞ{Ţ5HO;hӓZâ!MO,p !f͚%v!_z%ON;MK=/DS C|ǘ9s;w}tF~B^> x@Ԝp&@h@,*+7W;5ֶlx@yٶVw $m:u2{վ/bֲeK)+_WDm۶y֭QAkaxk$#Gz~kޙ3gʝwi||eʕ+=]HII{=Mq- k}~ayG.^?!  HN| @"NQa$OVHPp ݻ˼y䬳ĪUwv>lٲeKȯ\ ޚ֓۲dŊƏă|֬Y#Fo5~?(m(+'xB|Au]gԬU@,@rλG  -vUbH2AZ "2&(*++e>[-MFџ7t\zr1x>ӂz~䮻*|9=I=ѵkWcl߾HTr9SzZC^͝;WnV׺4MJz*3 XUUw@@jNOt\~&|՟>QZtq+FFMPԬ1~xr%& ބ^qR\\,;weXC$-Zd'COʄ k Z5QZX_;xp .I 59Im(oBZ: Hy@$',A (:G<9O-+DW`jtY"5;Ew P\+E֩سg4k,CرC^~Z4nZQF~c8rH>WMhAoxV6`l^<]tP Px@$({Ď *#CCye y,(0|()]6Ev‚pmB$mX_MɅ?oƍ"בh\y啞56mG}$^xxfٿޓ}ʳ>+ƍ3~6`9 6H=+RSSe֭F_WZe˖FMm 9|_ SۤI$''RuB@p@~Q2@@ KNPAzx %ۂzk"Amf1 Acssssb_kBh퉻[fC0---MYf{n֞8<]>3ڵ?ĀO?Yb2rH8s̑Ç{~Wx.=megg+x۶mұc_x;EA" Vw@@*bsUvqx+$s{d S`iƌr~M6FbB3<#{gJ+ӤIk^nz֦ iӦgu'|׋nlQm$Aa# HNl@b&N1i-$[ #F^v] L{z>ҥzꩵzk?n//SӐ!CW_5kf-h +?syt޿4ck?_xx姟~<7qDkSVV&Z[Ohk۶Q:$~aOv_@$(L:tB@rYmϨ;a %\@V:)"tjtY{EIPvK ܅$(|l^)M_|qZ[Ӛ Ozj6h]+WzD޽=}:9s$&&{~2~xW^-={OkPԭpiɕ:ierכ35ۅ^(ZCyZN$(L p;aM#@W;ńݶlx@ՋRw 0mׯg@kN;p@YxG͛7?FA OoٲH.>䆷/N#iDO[hkoR.":^Io@EbG@ع2:TV_#! wS%ᘂ1$0|(),"dxab=o#Izucn\Gyc}H0aHPꫯj]wI-LϠ'(4Q[o'1􊥚M dk +vHn;F %'F&]]D@\/`?Ձخ=_vUIP `/aܯÇqQa:Cau,jJ8㌐ƱCzm @,ڶ_0FQl&q" @$jҡNNw$($(¸Zci&ͼ VD'wD j;iFo %E'- h>W^>Lu&7p|z}4i"zSӦM>G m@ND c@@7 I]w(,kFEW{ _w|Ƨ͚5ݻ!:v(] EڮjYx D-AQXX(@-@r@0/D|tI~*`tܞ%)?ʗERm;xtA曐6qƲa-8y@ *%`o@89   %H'i.` ,9&{E<@lHPp/--iӦK/$Zlk޼ˀ>B$(x@$@rNE  `@W;.#_ cD /ƍe޼y_ҥKСCzҳgO%1cd&Xv!A!D[lr"=)^򲒣! ޷ϸe 񹖌'@z{BD"@"~A6m$siKMk 2 @?G.$,. $)leD,-b@ _DW\PUVCc)R ?(AR`$BoLzcܹ3f3g^x샶9ss7gNg_B;D8H@v Qsԇ|Hn\'"<(p4tiKe=-@@!"rG2iPQփ!&P+ώsŒ @@N|O*P$NBDv!O=|׿UKwww͛7ng>җd}˝;'F!FyJlj: @@`@ ݧ"%1-m Q PFe+/o>{qy' Pb nwM`B]~w@@ H E}NAꦾ޴~|sՈ̓=2R P|/9{_ s+@@Vq ~ ԯ^Gk‘  BuZەi>-.9~\6Χ2-x-@@Rtdk?iy]0n =h}D @@ i6>&:D 7x\q:H~mw2̭[)!!@8*s" @ΈtrNaxr۲nA$ p>TSS38zӦMr!an((1@I'i'Xj s@@ TND^-.[6Gy^A 0: ~{챃TVVan((1@k7 &E8>! @܄Vn2_SY+02 MEYP|5o.|Gz˻Fc@hږMiT$ yXB@Pv^Xv-|ɈVk m[Ca 7ʯ~+y0/..qɿ˿ȢEϞw+\ .(d&@gξT5wvL$wO8@@ \3'NڭBP Pܹs7MxM&ߣg}㏗8@;<)--zްH@^@ *])} "' HPClB2tw I& G l"r%ŋ= yMβ.@@?Y'y'$*+@@M>[H Ҧ&2  c[B$@@x'|͛G16a e9 6@@ 0NWe[/N/"-dE`ǎ+HwwQ'ۭ˃gCy+g?+O<K P CZ)DqFD~7"  l^iML>dzZ^ F=jرc]dHRw@OᄪN07 YNVn=` 5rC/@@W@ }zA% n1Κ@@O'L@S  P瞓?r'Kee3c*pU"_-MmNB@H-x3t Э#ԃ@6f=tMrYgܹs]ɞr)㏋:cЀo *PEK~3' ".$Ŕ PYE u8:>1c }6kg-s-| ^2 Q]lvnh0Ұ< P<(R vvvsNWjEh4*[lvSp p(@w} jH;O;d [ %~?nE#ǩII8_e\P"+"{Oꆲ2ioo}gNȰ%N@޳r@kNԮꔎud"|  QNw2݁Z EE PyeҤIY't<3ֽ?TUU p 0@GږMo;P*! mii S_."<BP7Nz-:uTVVg?Y;v^*X~x≃gVKvbx5C'JP%>q! F ]@A8F1  HǼjIt2p$x'ʀߗ[ţ:J6n8sɦ#8v|"w|Gqsb=[dPeq @҅̎y(A@pSau_`Y p#ʀ ~{v[|ƫCj5fyG !-@=h}41)-1܏ -n1#0,$ (=߲e<Oȃ>(oƨ_=C~_G=W& [Y/ @S[ԯhw5V򒨷f6@@0mW ÚPyP k׮&K,s{䓟|ߕSO=, ,и@KHU ^@@ 9l _Ӛr`D@ Y+;NMJZH ,@@ac4k,kWE4' ^xA>Cy筃>O |O9  T7wHG_v‰le@"HI=ayNg_0@ ` PؼME'.(7  Pluv\^ݤ"Y0ĥ&@@3r$@@#h6v\|RSS#wm`]Hǭ!`! 0L@vDMY4TW@H'NDJb["?)a6m\ PabR՛  :[Dev,$,@N@;ѽ>|);[O?[fF yi/ [L"ݵa1@@3'Hau_J22 T P 1Nɳ'ƴ  ONvN-q7tE {(tEfΜ)ӧOo]b1> "<@g_BjWuJG_¶x‰@@ |)<[ o= | P ?3O'/o}[z   NVC\  ]1*-N,@@sw (x;p 䨣?,K,w}Wk/9餓d=~ٸqcI]],\0N0 E@H'N`B\  Nv (t!^boa?/OUXꫯFˠ v   vNޘx͇7x?@S!䗿\|Ń?=裭]ns=W~߹ʘ(x%@R 4Jn[XADZfsvo  N vNE U@Q-;NeP@ Kbȫ|r7OϟoyyX~iyW䭷޲~|! ~jʔ)βP\"s3@Lp '9! vODJb[c(|'Lb7.'Y\ ȭ7OCtKHUsG2 't"! |I.PW^I2 ح۶m-[HIII (Btp:wp@ppN82B/Y+0 >Wn0@ 0ZSV( ŁY " HtuJǼ*GsD>3G#`w@6hT Pd`)7nc=VƎ u+@@Vq vONV i'oE }zA%x!@@!"n:6mZJ͛7)"6l dҤIr'E/c]HǭX!..Devp ^O;ސ Я'Th(DdKEE<䓲__uU`|Ygɯ~+)**M/w@@D8w@v!3)@@g_ lBP<#RU5-C=T:::}b1Jk|Gc=&rH>BX`  `=h}9޳2@V ݧ"%1-m̆@(rcȑ@K/T-Zdqu^;n6m1c~3曥ogss(+Bz~u7?G@8wWdT: []wK0R Ň~hPgL롇N;mX~agS$ s=1|{THʾk EPBg z NA@ 8;QX]#W:+ayXi(^9s;V|MD"z/Za'w}Wacv?[,^شw%g!5BKHUsm-udyҢY  (SeگH'@@Y(."ۭlٲUgO =WM&L0b܂ dɒ%֟7nǛh591 *])}ԔJCER   m wCD'c!T_'vP LPӦM 'uvmr;d[Z?[nݰy)--~> [ (P6PeP< @/ ‰o_[v'KIbu@Q\\,oն^xAN'|<^}U'>r/k֬~vw˗/"=@ -k OuN <@L=&u9k!wY(ƌ#kuց٩ 6XġjbDP< f2yU>3= S8+HA h'ꔎyU)뚰rvRPd=-b_Ȍ3V<N;mzKJJJ/7P N(qR^ kD@<vI4 Ф(ʤIWEzF ᄓ?G@;eg] P*C [꬈/}KO0TjW6mV _^ާB+Vȩm?O/ `ie=  @zmiJYN RX]Kԁ'02 Z >PPW\!/κ9슥KٳA%0qyOvboV HtuJǼN-q7@(rͣȑ^{M}ݗ 7~^{} N-@@!ٗ掴!0׬@r/ro/@@P^ P]nc|'|Rȏ~VjjjdĉrqyݗPG@x0D_^-}ݗF8aHY \@}I})Ź9o̡Ey9 p >-v;Er܇! uDpB^Q OO;gTw̄.t:(x@ ,^#NT  i'Dy PE"/<4]@ǥ<@M܄NдzB@Hi)AEP! " sYa>kG O:O;]1)-uyԍ ] ݧ"%1-m{~ 3? Ƚ9Ot A#0l‰J@@@_>oo,wv`򁓥ksW OBO(-3# C '@@a^O" @WwKS[J&ɂ)E!`  |__fMY|sՈA^R-I (k !#&.kUi(B@@<N 2Q* 0,&!t\>]&@`@/!W=zDB@@O;y PRr-.PuqԭdAB#tD "-c`  3'|D$&-~?RB˶PY PdMǍ~Pʜ j[6PO:O;q! x#qdge[0+Bp Pcԋ& Z~vgN6@ȇvʇ: @B"(N(x Ъ!P;&՟V3'Bt@E@PW֔OX2)A (TJ P6h%@@U;(B(+KHG_"'&IiA$J,@G g( Pԙ Pdn @28$ 5: @@>+-.P+lR( j K!0-xm,Yb{ IDATRd5@n|/#  cvL&42(x@:W#d<:kB]Ș@@힘rCqa (g #h"(%+A لJ;'5D@ݹwg.@@w  i]!mZ.o G`~ Ч" KM8+ȏ+o   @O;{BӦQV`(* EQ‘ ȕ4A]ԁWDKk @%ؼ '@@-3#k\<[ ^ ' nR,R4# Y +72@gXlR] EHePKԈ~ՁNM8 s@@@gi'=C ~YI m(i @9n C@HtuJǼwVH Ym 0T ]@ %@@~]-eq `#&haؼA  @@Ξ`D@H D(Wr?護ޒ|P6m&>#f͒Grq9rJ[+V駟x/{EFS8+HR)-t KI֧\4gO]` PR".}v[k)3f /vnM.a?袋d…RT^Ε 0B@JwDyIT(&0,@"`w0vѼ:) , (|'L"gjkk?_z)"K,1CN8yGCN;>˹F@$@ i'.@@`})RҖ`,*E@E p!/viya8q̞=[o?yWeҥ?O~23gJKQ-"{=#555i\NNBLpZ# f   &tv0 5W9/@@῱y9EH^&MrYg ;tM֟{ya?Grϖ/_.[n_~Y}ijj7Z?Ww-g}vAD((1,@8Q;  `/`{Fl< ȣE񓏾;'q˕W^g}V>O }}}{=s{~_QQ!O>䰿߹s477ܹs>} /?r.7nA j@pYXz.J9ñ@1}(4ȷmu>ď~#۪m&{ ===r!G}d\}nwUW]% %\b=r.nA ZǨ@p/ꔎyU)oN@&h1(47,*PuJwL;v찆 =(QկZ~AIgg0c?u䭷6˹PbIp"HݢV@@ s>w 0Z r?P W#K %%%o͛'˖-~=|yGځ|P8^jaAᖉq  ֮~mٔbPC)@H!`P{T8ogey0"/7w) e˖-֎ u}Ѳaj\9s]wekr.p+8]/aXܑT ;I}  @zޖ&^X?bPѼ:)>A@oou[֍pDg ~[~_:BxH X[[L0!㯽Zꫭ1 ufse~L pvUU^Ǯ"Uv@@3wq0{CF"@/e8!T_"=p]o~;w&={kԩS_|8w!^x5u.s91dE&ZE]'ԧ]v@@vvR3Pwd$ Pd*x л?ս ri;c/{m^WbNzqe܉YfY㊋e͞XnRx\*++3 @܄ spy<@@ cϾr\ ̈@>(~;/y晃[{UFy;wZr.KF@@@'tu  AQX]#W6nrF[ ^ м۷oΚ{+g?ٰӟ_n?yY|ʞ~i6mอ[Z;2˱Pd*xAm8+HR)-P65  d)睲6<HP-#s<σ0J"WY>+oqn;bŊ ˭Œ3<9cQs9LpNg&, x $ S4FN.)Lv%@@q?_+)rG:آ\Pd*xȇ5vO8]NB@@O;UPT|-+f=GDl}:O3cgyK[nVɄ \@@c@ P  @(+_$ 4eBB6pB}j)sBd7ŋ[!P#/3ա^](2b,R/!:o{.vN8 s@@ X vOT|  ]xWSO9ʺ\W%'NΟP:T{2ydj5\c}.j襂}s˹\/@D(2b,Ret1h(\Ƴ@@|Hy' #@@A/7l rl޼yߞqu*P!ӟu /0b>_)\nB@Vq KNv;>nR,R8  AG5Gœ^(4xf͚%>`%G}O,ȉ'8yk֬q\C=dsr.bv p+8ȕpBP'@@Hy +7XV@@((D $?Ųz1#?r!Wx:dŊ>ǧ}sYdBݷsN73TM8+ %Q O @C ݧTz*'@@bs(_<@裏dʕR]]^^??WgTYd^\jPqt]/@ ,^#Fj]<@@?DWt̫}wϞpHP{y4 Р\p,]4J 7OS.vPIց@z~u"Il1R+p  Hi'vOj]@:l=gI Ph'|R^u)--;LʆUcy䨣\hFRQQ )82 d/I8R^a܉  S81FN.ײvB *P׺-F," ok4I¤n @kWs'\j)  p:wO;TkP[V.p[Ph" DXξԮꔎ ' V}D#%1-m ((L&k E (4l %!sBp&ɂ)EN9  @Ԯ u4KVSJ Pb4:($J܆5ePQlY   rO:%y'B|(k !EVl_̋v  @:UK#-Naz (Ts餮+ Y9@L 3ʪ%@@~kZ [D!@8aDY  @VtE=N74Jq,@ x!@@"sx&'  YN鼢0lj>dVYfՅC"}*Sxܪ20PS/!:/a[ ᄞ*@@ [u״:NA8H Ъ@VYq_|/YE%vNvWݤ"Y0,@@CT8&PUM$Ϟ0d,(o1 E% uZS8QSV( Z"  'S˭C˄@(3*F`w >睴j 8‰@@jrΚFBVPY PdMǍ~UD ‰XADJ# .@@ 'J9o"fP |Ш¨v.P[zmPDl1RZ{  @&aNd̝&@@[G(278$G\F d*Pݥ‰hTX.  `@&&'?+ E 0Ƭ˜V*@8W~ D]t/Z]SsJ^ Ft 'TM(ZE;awsBQ  Z}DWc9kE F  FBVPHw ZTݤ"Y0(p@@ P_"=4fvZ`<g1f!#NԔJCE?gV@@ t/ޖ&s‘^"-d/6Ц@'2 F@p{ 6DV܄@(2 F`/6Ц@‰XADZf&E@)Pl #@@^Rs(mVF@Q0y_-Mmi0wB@@ ,],=K$R%RR:q7F"0Pl(x9 Ц@ T0tWc8)/b=  0R )7^%kZVH "@(Bpkm (7F P  @nP`B\ >'@@a^O"ȩ@kWs']iKx  ੀ:ke?w5|脕<}>!@p(+*ENwC mZA!h+ٗUїnR,R( @@ @̉8s0V@Ze݈5-#s<ص0L 0_ EH@@P;(R]5ePQ*x  n Μ̍@0vO (CD@ Ph#@@M+(-҅ lY'.@@ 4IϝK$Ցv9S9^ z;(f. ϖ3 H ]8+HR)-H" =sB(N"%, @?57"Vw1?GN ]8@l@@  Ep5$EzΊ 0\E F*P[zmA8+?#  &T L@(>G`PA mZA!h! P]Z"@@2 &nh`E8* 0ǁZ]Hǥ2PkX^p"{;D@th91(2&B! u۲nzP, S:i:( i$@`} jnR,R4'p+  @2 '8s"yKnZEzIPh%@@U;(T7wHG_"k 8/uP@@[<̜71EX;ϺM 0^SB-< ږMO8Š!  jDtjiu]"k*"pPK#VB@aDY `B]vD "-c    C=g=$etUN#fGD ʚ*@@knBAٚ6@`gǤ @@`|)3U0QplN.mI܏! YYF7x#^Ϩ zDGG@ &z\N11Fp;'  0&,„. ,^#@8ސ~ IDAT  ]W6H3N~‰`0(vP `94b%FE JWWwK@ኒA  g^HuE'OHIgu2["gf P"-dPa'Nu &ŧ\q2@W$T!]-QrB C9ȏE~yf t%V5w.nR,Rd6C@4P5š^(28 pB]붬1tnsyNSsHBfPof $wMBi(6!  Ϝ8jI>4jI&@7n@p(ӫTjRq  ED.k &.5! Z k?5gs":ZOAl>Ug)pPϬ2<u`VJ@VQ( v;'D꼉M`@@W=w. )JA gPV"<ͅ $ 0p"Vٱr@@dzք%JGf-kIJ9"Lok5E”N R֛BP;(@@4P]3un ̆ PxTh @@A(ao,^#NRV  +7la [Yw8#`(jׄ=awk@GIz\"P;'8_5@ y @@*SNbt~܍0luPQ,5e:J   1ƴ )83(Q 0,&} ])} o;!2,@O`jM[@@cI5@`w (k !9vP]q2@pHtuJW (bK))u :wL"3/F@"< Y'y'.@@o֋nA BwCb(<PaC]%QQcs! ^ &S倹ߔ?@ (4h% GLw̄@.T0tW "-c(g   dkQ;'@(L&k E O@aS( 75vOp! Ngb],MXX"A Jg g#FX"<Qt%A&."  Ϝ(> `ema [^4%P!}0lu(6  ׵;~.kZN!أsnEpzE8 P8 P䜜"@m&iOy &T@  0:MCGG  5Rp NBP#'x)@@z[%sSN@@O =BUd#@@*@@+/#0jtᄚVHu|^0O.P+|ҥ2}-EH (tU/!UgDX ֍  kwɹ9;Se#wI"LZ (( udPX@@ SL?ĎLa KYg(倭1U@ǥ2`+\HPԔJCE9e%  |)q74JtrǕ0 `}d( (k \.Dev,:,@pZןt*W'E8O@ !n>K7N¸A!+0G Ν0Ϭ@YXz.r5:8ە@ aXI&uӐPHxk{dul;!kd   WK튉A /PKcVB@aL+YHz~u =R: *Aؾ29 ` P" `94f%ƴT_ԧ.‰6@@WLfDŽ`rEC#@@Vg'ٗfOM*S[8+B@HtuJW:F.㜉, H P. `94b% x<.FE @Ck X @ȩ@&sbDN[@p B%@@vX {D 'K>ą  眊IѼ!x(@@!&S!g<7 @ ?‰XADZfSOE@4YXzW6KsCKN-~S5Ye PKVVZbB"P[zmWˡ!yX& _*,3&9'^&@_ }\ PRg9 P81OE{')I?sNAk:"@h(B^f;H@$ (2b>3} ])}km(~^SVh D@p d{RsNzWX-S jE|)}ʹmŭ[O3%jDrDe2"-Fȡ@B=~Ν9G!\sBHwP"PwT  fï9RX@4lsys#8"z2((F  uY< )fD(_PD% =Z 7{]An\!p ŰPƙ'&(/Zbs! &$?V'2=*BEz*j+\9\ssHE@at{8"KeeeD@~u4}R "3J x  @2 C?܃  Yn]g΃Cc1ٝޣoPI@ʶh }{Cez `BN9vPp! AP;%T@'Q@@?$ahu:Z,ضgXr I6uYCD"~H?<|n)ScEfVZb4 мA  dsvsNEqg`"@ {Q-|y$‰UI]ah0HHRC+ A1 t%ñBvN81@4P;% N-,yF_'4m,e! %w?"gB%|`)9aNNBC*XSV  (ќ11t|)PmX@k*ku%z?lV"fRebwDzǓ (PeQ )8${TlS8dHaY *0`")ޕMR8O:,@;>K:m]΁PB8 uQ; wNʡEyT &.k;cp"h@B)0%gLa b^\ì]vMyHiy (x Ю%@nQcbiӨZJA@FLpo N0O560B_ bC۪vFK펰B@;w܉: P jI@*8[nQ   kk5'gKn!C@<7"Bs#ԟp Å]AD:(ӫTJ@V  5E E@ `O͢vNdsͫll0L  Әud觙n|soZQ[nA:$C@Q3(e@7} jHB8[  ϜU17! %Pc>Ŵu3LáZf׮4º"%g[7O #пG"t-gij[6IkWZ#u vpl0@@]s";'kZ9ZFR . *xQK>_jg?p&/.e Prh'@@]K((On L @Q{a`BկΗki~Su\5!Y _vgjW "8|ZnPC1w ߉y@@&.kO[)D@I L P"LO9q! $0kR|ҥ2}t۳ykdu7|.@@;1T"S1ƛ(xm,ZcXADZfL\:kB@ dLpD@M @rPXWv~D=1UF\_(|aePF{MhjݶKQb#@@L&0,ȃ]@p˺"+HtYO千XGJ "KDn EZ+Wʭ*wXBN?t76˹ p& v 'U^5! @91aR@t*PZ-޸}u2xcdq7`sLy5a 0v\|VwE… (U{9PQb} ])}SgM S `"yDu)) 梩@  (l qAoPdVqpu ]B3ϔx<.ӦM~xĈ9C=T~ȬYdr.LJ@@Vq jݵaӖz@@ l4%# (!1fS%B1 veBӗ в-k̙bј1c$Hc={r.ngS8QSV( l4@ dNpD;L vŵ|reGeO24 ||V;seL@cMp 'ggO^ր  ^Ϩ0A B^ZK (?F>.=Ʉ O xXO!ys9rZ'|RΝ;YΝk^~#$+fLtk{dcW^f_b~֯͛'˖-~=|VpG7k̃>(gqx/ʔ"S1M/!UifDкJ !6P-KԌ @7`$Aϝ `&PvIstLR PP*rl۶:: ϶60aB]{rW[cY uy9W6٨qOPj[6IkWm5ePQP'  Y4% nѪ\%ĮT;?.w/_:, #tn,!B"őG)UgkԩS?;;jw!^xu/\ uy9W 5G<Lc<Z NhA@ j5O9B>oPq#m _6_qC.b.)HnE"UvAtwG/;NMJ A|9,7xê}srwWN:$yꩧWΝ5k5X6ol˹mEbS8҂HPC  `L4KVW`ׄ+&!!*|PA!DCgvhکL>)NH5 5O.?#J$M׸ P>'r!ec',z|߰lj*ysN^Xn(2c| u vl1Ah&5" `LܹD]VA0኉A Pnd倮&e{ϭru4E~Zķ>zR2O*_~ge厕<2mڴq[n[˱Pd*xWwKS[o27M^k>@[`BP/W'$w1'v2X6tS\ IDATÔͣOέvD {˴ (cxtG(|￿gkk2 f̘!---<3砒׎;D^Xn(2cȢ=%%Q@m  `LR'US: 0\sBG $;Jd 0Y yvs#tvH u9~H @j#)HBf)e ݵkɓ3')Ѩ2f̘S{?/~Q_{95 C@up '&ɂ)E:NM  0uf)AT0vMdzLd*xȷG;ZUß%Rb}:͋=gsoKPb Fd/,sɧcNJ:GB]=|S]]{{y䑢>뤮뮻N_{9Wx<.Nxr.L'r *غ)+PC6DѼ:)Y#R#X_`h7n-HRAc!R"] >xVzlKI֙2q^ BMd/@@]^Kyy'vmryC+PdXz~C8S@` &z\bȺxL\ @>1W >Z^vF+b=@:F<ӝ;'F!tjWuQUQ%'NΟPa&˗/ΨP%q5 \eNR;!iȊ@c(, (Ȱ0@ *,"3E@Pɨ0Hh!t"CB4^ ?yUu.yt]9o߮{Α}vBƑGgA1Z ĉx; @ `/r]0,QB L$\ ĉzkԇ<~UAJ Lu#%CTV8m@ 1; W j֜Tj#>6+L>Xp [r@(%XmmmrgeȑE~Ogy`z:9* 'Bn&TFQx ; @'tM!LTXUߢFIHmfSܺިcED+4_wwhS вo/֨XpU${РA rb"PvH`huGI f7d, @@/-v]NBB#`It[͙LZC1bcE܅(IȎ+0MdR;8 @@ QS"4"G=Sw@6( W7CATM&E P{%/(D: "i)6Lh  4+J""#撒)hǥx>;'E\ !Pwiq" ^f T+}ӐD8d$ v=_z:b10LC@H/cO}7eʊl}.Q/ O<6@@q=@ V4bbϦel"#j4 nDY2. <&œKt@ PDi6"ޏKj3 Auhbb+ѽwĄ=2b@mDOT7@Q}`"@I)EY6YDO)!@'TB5%t "N (Ͳ͗(A!x:v"P:( "v.KE;J#iH46@*~|!C@"~٧ʙءSo"B"PL42GzYF-o_^ҜOOf7db2v@ "_)D'15RD@"zv[+F<eq%PLh4ȭmaw Y^z풞gko}!GMc#hm7&TFD u~_*JiEM; PeFFEVS7. msITe߳|y(:n( ( "@,OfI'8MMJSXL^[zL@@ x!'\t@* zag*VR;b m@HK(JJ#$"{Z68 v { 9&z-D7>CdNN  @8֍q"+m#}[R@z Z{~(ND5uS]Nx=(b뺪óD ᙣI胫@O|1w`"7u;JZN9s!@> {J &Ph GpL"n=- @$d6(|cgn&UG#g  ċsOuԕpH66:{kg[({WAe@ӪhU jRͨ =T5N$œWx%#P@ώ h֜(Ψֻ ĎFMtq'q >1@.2bZnti v3@+d Ǟ\cbp$5DjZ֌T-@>GrtKrS [7@(@@m^&ٖ=  S~&h@ztnkHT4 Dz;O?4+LXBĨpn0EfqE AHܽzjsCG@ /ů*JhZ' |{jSh*F132?f0@LTZgsf_Hcy@tOSQԜ>sBAl JmcďO5uD%V ǜQY I @/?5Dp>(g&r160d"!jc@@eRO$@~ Dג RY# (e=&2GD3ފ@j*EQFN~~A-< DrڱldWpmq4"b51:B z:ۤs{d^-jV#*E = egEL-3f(\@KI^&t"Mp @"D7.Mdݤ)h]VQqR3Qf!c+L#%+~ڰ7 eL(B PD[hͺ3ex|qV  *Lh!3g~A"RFzױڄ@HclkϪ}9ůu:("Ṕorhuyp *L 0)h@ JhF:)%0a `1@?%JĻ8 n%/os57&j"P/0 @:ڮL-Nd&/DuƪGAtk7jC @zVDkFX"DL 4@ ʵJͨ &5Mc]$@QA,U-Q:3p;= @(&4jkӈ  !BgٽFOUPuh*ߟ@Gg[Z=[ݥVa"sA  bYȉ@1b޽NCy=M-[\GM(q2axm E@@2 &Ɣ;H`W#PtLNƜ!u#!C*NhYߐi[%dMJiY s&<^g$EZ;nmr ;Po.:C"E ۲Bvv ݈֘v\1 "E1Žԑ<Qg+Er΃%DKwOD2E2] P$mBLhRkBvY8sUOu&tgDMǿX @00G94Ӑ&8R{'*DHmCAAI 4"Fc@ PD׋lߙFJN0]bE $APZcNnwBԄ[R D@NLhl+ -vcjC#B[!@KwsqR S|Eaen 2gs1 >&j">R@/ʉPj&)vݽ9_ *F zhD)pS@} PD@)L-VJg(u@Vf+ E9⦵+TAEXd0Dͨ!R;uM~qz&E\ )skֶhuW7DLh k  @&Pn&5wL,"%*;K6SzϦž7j#FEM@(%֘@ TbZSnR3O}L :5[B'u&ܱ @Z&Eu!&D2hM i?5 (&P4d,o=ؚ4C/Y)k7ޖFI j(G EpM-}(H")۸*m4NW1 @0 gPn\lmV7 ɮ}(3DK8@  ĉP&fZAgGHWc';wt<DM$,+@Hr IiP:g]C+0 @^Dv5yNyLu$-ԐƟO@ m_}}5"x3h ah"R"|$nĹ4B Y9Q -B*G (aQr>cGAuvQB"FjPհ5!%@[Rْ- NDXŰtN*L @C@ENMôJˤbFu4$"(*IZZBPjMWGh;Er3H  @ A&LRQQ(a7U z@1K'{x!r 2gΜيA tNna#Np @#ĎeK%j'㴶D۵1A*'OsQ%LtDgĞM7Hf1z PT$Y^v>ψQ}%@[%pxV %4Uq)2axm<7 ȶ]**PmFXw\9gu#£_ (ѥ雲OUm/3ӥ&(20X5;X@%?kS$;|uiYo!-=bެ `D@H[)ψn,_8^Z+} @ |#L%QA'T tgHW\=0 HmZKԌШ! QS7.L3@ PTMd)M5cK"Kq#i ^ E4FIԍ+֞o Q;7=е!"μ@ PTkQ$s5cK"K eݻ76˗JۻO4@CŲպεQSl&kZt30 {}eS7 1Qqf*ER Gҳ5E%#P݃ "NUqmu;UaB#*:@*F@S9uq'aBz#N8bv*E*pJ&%1i$*]ցDْ=-Ȟ  ~IҊIfB@mG"ND?X@(E0DDNpCԒɮ %45 #@K3ӞUE{ʉZ#+Ǟ+*J-&[ PabDz]&nXך~˪IY  ĉ @%t'&"Lp"M@Ύ{~ 5Cb?;? @z*PiD@T免ZQ. IDAT`ѵ2(*ù*N@@1NLAcDZ#}٬FD>A a6&E~O@ PK)@wޔv'ÝU!@&t>LpB`OҽSD\%PETd[5$:֓VDg٪KY˖/l/jFNhݐ @H r_+"A 4>&}SȹN]yJ%/owQ(LtD$2GMMI۔sm!P{!Y̙Qa';d<3>t @<~LP<挮<i?@#%gKV:_htD!11씦  (2G(j2'\ @?.Dk_TE^ <*Z %(ѳmakjIN#u%b"J[B!@ V&-E9ՔNwt9FO Nxg@~kLdfΖΧ 5u"a?F@@4TJ0#TӲAzfCj"HFJ"I]s7'J'8r @ ET /^h7 v<+31  @)Bz-QڭorK~I%@Tx_u!K^D,rV XDN LpxHm䄊ug1G'c3 (7""n Q3j_ P$ E|5+-{ g˂C+g+AR@ ۲B,IV-u&\c LD@ A Q '1i"tJ(l@) M餩h Gk!l3SetIg=ҹQ s}@&?ܳ7@kM-BQ &:BI8A P83G 'tf3gN-IRZ{uE78l;@<~߻224Ʋ"|,=x&4X! &fTq]1Hc)n],DIj@T8weݎ!NV Sg"3s^Pa^ ZnI@zx)R}/& )(G EHd @Q,{V~z:2r 1 &t8E9Fq `%O!La@ )T@Q3*cf"֚ 1"G"q.(ڔ2aXE.`v@H DJFW-1cc: 5TM~.&PI/HP7¯W(cd(BƔ 3f $@eU/«0AnmOҽٲ~ҵYFJ#L`6 2 lJ˛vR7m-e ӷO8N=z\Oc_:@@/{$@NFO~0t @N@-Kn LD h{SFf^H86tMRS7ΩC@Bdt-$(LQkM4i2DBۈ?0Q;@ޝNY3# $@۵Y*6W/퀇ҝMXj鲷-PQ9|}@@o?XqKՠh(W;-VCn B= YAVaB[f%-űi #Dqj]nWfl=|+ a+"­nI@<h7MS϶]GJhdMv>~yL޼ 1MrX X(8"@;FMjDNÜY @ ֘Sv:H5vl!R{:#Lc5Κ "#T|کceਡ-&%/o*NhCP05B @TO?;3q|@'0b²S!gb1ײk] 6o1LMPlm^zļY[ےoFm& ² k>vj |kA"zh# P?wty(vt9NBZ'GDt זF_;aBi T:uT ^ED MB7EPq. #H"[ߩ-?=n!PDćE|| K(Ԙ6 @CDJϿ@&pvx2Ų;=jDF H ! l_v E{TCfr1_:"Ff@ PT 5 !@қ}T0Ű݌$r %@@ a"'=km,٧^TTEDو'`R ً"Mٟ'қ?ó5Qcyt jFe ֑R'*ր@(le@pe\@p "A O:U2MSDA  Q`Y7b.DcND@1 T>hEibu tPjgG,&^07Wк4(I!<2c([d,Z᮳ NFEG@H 2z)97amԗl%Lڦse@mDD G}ĂxXQ!G~uPH9E'ED%@Q]GPaB: TVNm phJ'%Pn} jGC4ʠ͖!AеC0ε)LF.PW>l2~xY]m$x 6@r(*ǚ\@(IkM.PZ]pK~ d&ZBպVUh2A6r2Hđ@ #:Y>'BIv>. hWѨ>{ mਡequMʷ'zn"!tmݲat}9n(lxĚ}ur1|ߚm^?q"D05B$@"\N?;mʼn)k'XM^  $ ٖ% ~FT b"!B۰ \gLRTW u9M/*g B!PA[+ L̴DLIе떜v.h%ӌ`f\ίC6Y1۱!PCg(BZ{vCF:b] Z*"^hDMA1]sGFԍ9(Ob`fd63]f˚@##HT>S73-%=OKYyt7 PEy PYj(t6QmSqBS: @i#tm g85}u##Ww0ap)ÜDhA~ad]WMd3tT`{a֚k[dنP;l93 <H@uz8B>b\|1 tܹX?4NVKxί"Dȹ9Ͳ-pQNhVC f4MO]} Qk$FE^0d~UPjbb%+s^*>oz1;Bk#PfD]23r*@@$C 6YЅ3e  $/xԟ0BE mԗH {Şn\QugE($8L*:X[vE޴4RZo?ڌJ;j  9t 9vV('~\ Ps ;';d7nFLh  &]EԘ Z"M'^UnZQ:숈bU1W6 Lp,!P)@ݳmիg7f-ѡg24_l"ThPQapdvœP;uTGuo 裟u)p'N<^/M:N2䇫Ɍ1o4i~FO">L*Px7&  A̜-O8h}ڣ#%F_`&R"m;(mzI=}tPѡW|"*.*PhD |yfvA.:(G-L]3rgrѽ ^*P|я1)j!C)I(DMh:Wwt &"u,1B"0ASi+-@ط/FX7/Uh,vE[{A$Bf_*4X#~XvO[eEMh! jN"&Px5D,B/ԗ](#N<WwP #E#K!@ROkȶ;PA"zf1Mr3iL@ WT)i(Oba0RY Pq5Emj]i}MD 'OȪ$vD&aS;63_Cx$%=zԄ&na"٢{" <C'-x}_9@a@ Pp."E Ie{H @ s2bu k$.09izń! ޾de?^DOz"LLSuW,y᮲ސiC S$p"@D+J Ŋ֬ԲESZZ 'R@q$U+^ ԖB:}+)Hԍydwu0A7  :\,^Zv %

_DK)!`/Bm_^(mUWvxxb~>~x9nRr_CYFZ j[VۇK"XVe8 +%qǍ2e7Ӹ/\M=^#HԎ+j#:^L''FLH 9% CTTL(%.=%Ed#Ҧ gq^l@WtN4r@:%v,[*U+2U#&)Q‚+=5R4Mv#,A"(cJ!:ҳmt-|@#%2GS `<k"rش{3*ܞK @ P1c˖YY Yњ a5 !@ d*Dٖ=ד7܅2ӤaB;H:]m0D݈jJ1.{dft*ڝ/o,2"Ԕ[Y44-FBiDĮHրpɈN<n>1$a *Hh ~ ݵ) 4@@ hQO>.^j *P!J-gw<z8;̊@Q[TowoeDҦi[w[i4=R^0d3bެ>k` tɾ~2u%)4Y:> 8hʏ}P:' @HGc0 $Vnm 4#L @ߢNka"4 'XGJtg*sFwMq!Vꦚ:d#G///YOp2Mߺcn> {SNQT_7cDV-ď݌_K^Tg]lVQjm"\@pN PJ̙ u%0„@@~Aq_Z:M&ffΖ'*gGT5FD &OҰgc "FHxxdkkȌkQ.y==cK"K hss+o]|;^l@d mDz] aNm`V*m"7 \8 IDATvJF.f52Q3jN ÐۅݎֆV)[%2䷖5D8$ܱ@n P%E #PhMZZS[5*h أ!4.HV4SoݞIU*JB 4yp] yqi項U8j(ӫpcfM7?r Pj:3B1M`?E9TB@@~)4o~k-i]IbVOga]m9)0! b#x2v~:oN΃߅FJ~f@aZ`: BB9q}0b d@H?c/';dKeݎ.{Vabz0X@DADT\՚ɧASIiJ1)2VIݔc&2BՈ{zxlJq @<3g"v[Q~„j 7*-JkTH4Fxn垺1gHg7zsYW"ٰQ"3J60ft (4cW]k OR%{:*c G^hj%m[7Rpݯ;ݒ ̜Q{6O I13舆d 4@HcB"Ũ>,3.lg5d9T@q!ϫH1k#EЩ7YFx9(d'yϤ/Y̍{v&ٺޛ[쯿)JJͫe>N`/i)7MF;ca?MRkhck\Li PM=($P| 05bB  jG?Zr[ovŠ!Z7Fufn6-mpaC"NF-m-lPH\-{Iȧ@N͢h0 ;[?3E$AMCdְ|b NA'4Aof䅻 .g/`5m̍*ބ' @HcC#P gBYROYnܒg?~wMbt&o6GH^g_U:x!e[H k&5ottn{RUe`V*b];WV[pgezyI5Dox~UuMm-)KwuZp^Kć(8 @Le^+cD-oBP""*gTvݣE I _8Abo{nm3y<՝g4;QxSlC3p◣FO]{T;= /y{^2$r}U`Pj% *w3rTaO6"kEB5!)}Ux{޲Gkúhfl*$<~OPjs7~AWkO~ b\9<͵$w2AgL"ޏUr]2m\YĬC,a"\X68Q}n7 A鍞oχ0wڻ[mT;1'';u>7F\NJ=YaŘ8VhbO{~̹ҴKΝ봴%w:\p\q<\B_˿'[K1V\L=n嚜;tt풷VimŊLWCl(?nNj?;a.x9~lv3Pzd#xL7KRAP}4ABw T͹/ú5,~:WA3SCWRl Z-IaOʔ voE˿V_~uYinkg\ih8^T}SʾaTyr82p ^=1*7_sшzS+֦5\#ϭu\+l7Q9͘j1K@Tֵou[[啎mi:G$=: ~u3jqc}&^0.c=&5;k,umu2hSߪr9-c J]>o/sQk;<3gNڎEE)CԠpxK.t慮V/g؎85V]OYX_bbVYw#^TB["ź ;e_ݰ[f5 ￿+[_~eW-8J; ׿-CYȃKJ!A;{CXf$B6cJ@M[B"P14V0G$!3oz_< HBQ1u fC1MȬkpMAE^ZmM;egQlyk5&`yO_[5]!(( QF^;߰@%@(,0N7hIuZ3o/㗋%iu7n%E?'K/4<[}91VLo DMÝ2qH|XM~=/:LkƝs_g;z_+{2Ú)7헀߫r.ƹ'i&sn;䀕.'_!*  x"@Sj잶 eua/j| F.n;b Q/"_l5~O$ 6q~A3 ~='q_s蕫'|py(u}@3t-K?Fu(./Jaі;a}l >n_!؞q='p랃|9쀯p^0|@(,Q[۷+0.(~sAq~?{׶qN78t|VzOnr_ُmQ8};W +1.L?a9y]$OˉkXn+yagBc=gnDrY{r8zഏrp;^7vL?I^B>N/Qbq. S9yRy)&Ht./(_{S~Wv0ksTin\K~sc vCt2C̅ͅ$ zǀ2]*uܚ|Y9aܰ)ɍJ),_~V5o..GP<\nmIp{υ[I:?0|)}d׹=NZs ^77+s]u! gʹ:㗫۳S~AQgxmy< 1&-yܘtC>]X}Y@ ~" ݔsgiMMރ#Q4RƷmT@'Hx0l @ @ @ @ @*NY @ @   @ @ @'@Qq,@ @ @g @ @ @8r@o~?Imf?a> W]u3qC?򗿔zHN:$1^:رC~ʷmٽ{5J?| d^/ Gs?UVIggo{r%Hmmm9zzz[oŋyٳGOSygry^>9Nn]zy[*2`qt D ר/}IW^}Uk C O>YoHccӶ׃btRC 5S<6 "@  |7o}krM7L+&N(~E'??qkl*HX#2xa@z=*k* ;nܸ};<;r>\.r+BG^.byꩧ׿uȋ]vY?{NJ/Yo:m!$^O^uGR-qPo~#rJf͚%^zL&Sr4J;tG;htg]EK4.k,@# HtBw-5qGܹs_U,mӉ'(*vhӧ4I6cCMM?N;m>MZο`sƏoٸq~ʚz@k1h y//b렉@Јh4F=zlٲISNTKbz-a9+ҧ5FCܹӺZCT<̡9ibmQǘOi f̘a3y?Z7 A{N Otl3}!7֒xf=5MWzݲ7M)|Szںu]z-(2ytXY}pB+ij~]h61.$CaKKBz#@]T>D;7~_ }OiH^WjN ;$|ޗKZ*=kSS<@ e(Rp R4׮n6bƔӦ}F~_Zd^pu޽tR+ZCS5 :/a~PԦ7T0Um}>@򗿈K1M |@hܗ^zbTd=hd5|6!@ron4OjBQi.sS4US<ӹ&ؿ[k: agB4_f7}5ՈQ憝 CGs7OKSTAB@?Li򫮺*3*kO|__TO~b}EUX(cZB {ӈ5}W'{uI'?l_砃z~MEe~ KB k_o3j<9cu/<8 pC 5զ{T]PÞ C@'@TpDjA%KX)r>MߜӟJ-Fl ӿZ\K#-4GYgwK=Q, $ *jˏNi6kZդh @i4hkFEQqEEmGycc !kGgܺ/yܮG?@ ^@anw>Y?E:2@>  *RhӜ ~IRbg}}K"DsgZ/gNHG ` Pp 4BAInݺ9yp6ݫ5?67 ַ+__ӈh mw'xcΝ;-/)CEf5% @X4uKK̘1Z'sεQ b6M|iHU `MgBo>(tkaìk6Mg/8@'k^zMSjP?-y@ ^ӞJT\~#[S͢Fz01eWL@ W*FhϚ'Mm}65>1_n\bazL .]:ƍҐC T,՚<*j/8} 7… Ù~HsjzMk6'T֬Ycb2B>9~BwErdK1mڴ\N`UPAC ({SO= K 5G%۶m|4C~F7u򝖟!Nec@@͝<7F31I3]z\q~{Q,6mdQDmZB7!Ekkku}0`t ш/ӴΧ?av7Z_r%}q/*Fͭa&Ք$<֏4=^+5iݡ7|tI?OV?tRG (^S%M>SS5OJ{M-mc+Y3t@H5|?!zh.Ň>y'N??s74wZ'y޽֘ŋ[ftI}^4׻.&Nv4\mg͚%Z.Λ7/'jJ)N8ᄒҔMf?yktw6l~ƅajߗem: ~vڳgOlj<^֤/ |q}{1Қ&`@%@[a8*K@E}L&3M O)D amf IDAT2[EiwﶞX峟BEVҗgЈ hѵܴ뮻N.\WiA ??*H>ɯ~+^c~ QFuww[&=iS҂˚O 5O>٪3ao'Oj~r犾I!|1;@0'&iZ+7]#k)#׿zzz7N9܍1˞J'{5qve5\[V'?)@P/%t3.6WQSi䃹=Îc4U=_4NǏ7.PhESĻ": Gw#k^gzk^z!+5<^֤/ lqWM0Aw5+?#Y]?_uUv.@P@4oY*hC~[d$ӧ[)w-ZB>yK/Y7&W\q_27>&\KK}1btttu@% hĄF5h?J}ԧ>%_euZ©]p~s .4Vy{dǎuuuՖxޖKgu5]k>@ DiTossx<3 hIxA0ITAH/ !PQBAd= 7 5}Xe20(+8 .Bdҥ5W>mMg?va)5… -B>a~8'6htNLP'4@ۉ'(oK.)SNW_}d&u^/UgƍEAfMS5_Gn| 'QF=VYWWeWtxN׃ZA )5xh ms)z@ PXP ^BzLiӦ]wV:}cMńwEV=46'ős9G4:CHwc_'֮]k!ZCCC.Kdr@ QkEiāiNZtN m쳏%5J ڒ*";ype  LI{K ڼO_@ (#,@ ]o=˿+[/^l )a6fϞoւXն`?o} j+Foi!.mt>aiguUrJ9r<\:o׿UT+EsҨ {䄮aPJOiV7Mur't gh3g} H'(^TTPQDNg҂˚M 5xLkimTӛ t@H- {oo~Çn|ɎcMV9Cڴo~ijj'uI?W!#̭chNr)|4Sa7PoFp^K."Q4(ksuTM))tG}T45 i^/kh*=j5izaܚ'z>ŧ]z}j[:B$k?+DN(x?X'gS/I~~hu׈eS0xA'@F@VqԃЉ5\_!M !OFi[:l͡_Mi֛2}t[? QF=3rGj+ j $.֝}?v( @H#%k.ћ&Gw˂ JNz+Tj*G4#'>K˗Ao~>z]@D!L o{۬}z/< a Xiy9CnaO9 u@ A(Lr,[*irOjo f֨XpU${РA%aoV>w-R[ik~@OnRyšεji՟[s^?OckqjW}_B 5-o]{\FkY{iDsN3fUWOSaWӍ5$@kK?~|/,!Ez|N!P0ӧޞyϤ=X+ES~ؼ7mdEIL6J-D5kz}ڴ׿u?4Νg(c "k4mĈaѯڂǫKo/뮻J35Ez|N!H`޽֛-@4̲8͝mZL[heR49.H@'m6: \K̚5_ԗFy51X5>1I:{ P2 @ @@hE.;VGg}|mɑ#I @,ec0 @ @@h[/wekPcu5p|vgKdH͐aa<@ @ @q$ץgo d ~Zk_wU.YTOuR']{;NΪ?[r=ix@8#gA@ @ JҽE~*oV>tIPqbsfa',u&!˦MV$ϐ$R>f=@ ~(3, @@lvZ9蠃bc3B@hý?/mlzMF)MCѧ)O1c c厕mήW^_gՙ8bkmd;>Ep @a뮻g?ޑO4eJ__/[1Ys󺌭+6KsܱY}567nXh#kȑ#eРA3 P&22 @ pM7\PrzK /aÆU̽{ʴi_>s!@J@/+mF޽"2@ C1gəcϴ)42.T ek ; ;jn)!Z VinZb* 2ro7sfr`>&g><}unwaƒ.A~yM];u̠(--+jkkm֮];4; 7`-aÆY>}vt2[ǧ@@˗/w%֭[gvYgYnoqsz&N?zʘݻ;fov=>C@ ((?V?n5VcyYyh)L̉9vp>HjY<ƞ^3Ū몭g^O:fDKg9ڞfwuxWgqTocǎQ_nH]w;fϐ ؍7gd4%Mnr-  !׿Վ;8wʎ뮻bF_g}&Olw?.R۶ѣ>jCu;sn@  P\e6i$A숢#][y̢uQkin]sA;mǴ<dY6n8{rf ;^dv:fʹsms*ZAv]˯Yv&q2sτxb.u̘1ֽ{_z%{ݹ9{<9)+@r|@@4 (a{)`hL6ѭ03K.k+aNe=?#뮻κtuv^AP 7@@@ UbP`BC g̘j8h {wbON;-5zC={=yeuG?j  [",/geu=ַe_+-?O7K޲U5+jMrs.jjAl֯e?Z_q]vuaͶg-[fcǎuķ&3e,\ƍ.Ϸ:htQYO &Xaay @X9>  @4}}ٳg{&W}ԩ͟?V5?5ѹsmu8 @ $?%K(]U|] dE\a~|{f3fɛ6?zG˭,bӼMumkkٮ3=k6wP_`&3e,YĮzw*IyG| ^/qSv 1lkkڕW^vi.[ӑ}(v!  ?o?OܟY~ڠҢE\0ïS#F4n>ުU+{b5eW蟩KڬY\HqqgСC*g@\aA`W:òm@{dGuNkL6 HYm_w8Knkt`5?h+km2(vŔ)S^sv 'z\eWF'~_K=裏N鲗/_n~}7m߬Y3S?9<5qD޽{ۨQ̾Nj?mժUI'3bt~=*d<@@-lC q{Q?)Q%nVh~aJKu#^U.SJ6JKKmAYYFn>sS==CphY<&~Ԭnm\q6X)cG]EH3˷<+YE]k&(eZ hݲVW*[ٔ]ޮ(UA/O>~w}w,R]w}Dwm79h4t~7~tqǹM*C>&zY$-Xu/̲. ?rma"E߱=^Kw}[b۾}X;R5]d0r۩UW]&_qoD&BZL_|a7t~hŗ_~i* 2±f]}$9J3?p$Æ sQZxb[bE,5~;rHSpDRgPfz;${H"l((P@}%^xᅍ>{sbsF7p 2]~ꮻr}-ROpSY|ISH8uQ!P2`g??./vh)N/ˤ}AOzk׮_ڴiəF IDATe)`k*X"{Wݾ+>&t  f (c7LTA Sl?yF8"TX%Z DC/h\[[֮guV*hzS]t E RlPV`2@hH@Y}.Y|3+*Oڜhw+a5V]`c_n'>gڔ1dM+fYy*-ebhno+йm?aܸq&EUw_UUUn9Y>hZرc]v 7U`p$~h[L4Q>~\pVR|K=N z64hP ?Z9QG{d+?c)qyl&#PvDC%5nj-Rӿ#}6'*~z2YtQ@SO=='e(xl(p @ʎC̫L}>wX?a醮)ES\  @m]wF׿tW&?4:J??G*yP(//w/yve!,jB515믏5K&Q1~&ϚP+R`#@X[F-eObeeVɊlI+Zfw>6xx5u56uTcfɛVFYfL69ml/+E"17C%@C˵p׿whVk~:tdeY։&5qJGj{ _s̱YfD~g?kQObMXf~CԾ|"!GRo8  iG8 [iMR ()E|}7:>jӧO}D\}셕+W(oQ_LϫzF=ztR2i(S'~=\W V<u(駟_z)=Eq8~h{H橧r@ɼybӔ\|w6wngކ|8w@@]x3ϤpWj-'Wcē2%1UiKL/c V$z/ZiJjL+..SK/4_j@ #׽n.оeFdYL.-E}Q-LSm]U;w9fCɗ :ȵ\Wi}m½m懻*#cKVAZ2K/~b\W=ASZ p_FViHĴ~{sϵ"lEVd^=l0bĈ%t'hrr )VRy#K Mxa0A|QO.m=Eg*y^[m{K*~ e:ZFSPO!ҥKݵ -D9ݶAe>&eyG~R}P.=wj2R*OTFڤOHQ LC9g=|m۶m[m{",{ga}}PꝦL#Fel @7@@L@'Kj>)](Z_>@ ]j3$rL&G~lrlMHUl^$SfoX,Ɇ^&U:JC4}FJl k׻SlufuYVoۜb7wPPcV,ɶKγ]w2[Ypʹs,.NJrW.1Pnt#mMV`~WkZ Z3 M߻eо~j!VUv 44Jd:Kh{G娴O6k[mzv&PQ DC(쑡 V744 K;]r%^zSF< 7%^/c~%X]B*KVjZq~:7韉Pe *P-k_qGZF잞کOM)A}d|1T.VVvewaofY:V.h)<ۿykFښ,WdC VrǗuD_xLD ~(P&!C_~n7aI%< =Sl|^Rɢ=:իW씔ujzMk?={ܤQIk㼼<sShH8t\_C 1ٯɂ6uT2E4cwwM }G|VH|Jㆁ!yᒬ|}R&%Ef e020 hMТ_K7T>GI@@ cT#7n!kz5%4a=BjM($—DMٳg&AҮ̇&PTJC/u}ӾO *&w-&`ԬX$;)/̶gڐCsnzwESkEmPlyr{hCmeJB {AY~V~v*Ir=}EM吔/AálJ_*)&5qGk%CCe^ ʶ -`Wk;VܡO&L&/'_|q׃ުWC#,f749|….ȤQ_je(P+}?aLA}KgaBd S t7@@8^pתd+&afS!ZNhg@iRt/3B$ą/_PybzVj?izC/P9"#n< 7egʺ kNo{]jk<1}Am*+kjVYE]l|=yϳHQY[ra棏>N>veX;W@D # BOǗ=򥖶 3Epռ+|-M7t~as+~\$Q}csjyg~.| Y}3JLY6m4{'i_y:Rm ٢$ehgf ]|UVmT+Q˟п+zm;v(v;# d^RUcX+USن+r}15Č/tFЋE$Zշo_˼2)T PH| ԧد\˸#l3*u٭e"¾_aUG%o۫`/Y-XAv'4;IʂL4&'ts1mRM*T$ Zi2 Uamv񫯾jO?tlD%wOawJg%znQ,e(86T9,9I~aGpÆ >s%4=Me S}Ĺo&ݘ{c HStTJ)s/si"w.?{ݏ9rd,{( )'l]_i-}Nz.nDk6$@n"  YzV%{ΔQQ^^zӤE8?}Xg03ԓB+PH1R}i'KC*3NdR l-5S;˳lVG.л6wcs,ծt mwE"v-Zd ؿJ޵O*EVUl)ʶE]n;/lwXxiU.>פJl5U ȵk׺7֟)R5Pھq{k&2_MÒĸ>3b׏!]#PY?Mg0@f$고Qa ? serG~;;-6Ht9gxN5V_pN RysZ2La  4M3(R ڄ>N\[|Ɨr iFc"YD*++c宴XHA|@@vMkL2ŕg*@%)B:uJxڗjM+_~x6 n4mP^'{{,T} -Q 76W/P0l`Y,0@1|X,]I=àUicܔ%@є&ׂ  J_Ūz1TIP9i̚5ˎ?x[h&ZLDCi~jVR%NUijZu 5J\$ VPaF 6c[W5>QvE8Vb~||ںW5޿p[Rľ*.g\΅6\Eς6;= TgoD'YMZJ_@FmzvH4TFS0RTF}3l5V_&v< zuUG5ŒM2ǏēՠmҔh 2(V+g-zfΜۼL0裞]t~0EY3VJC ISGԜZ#C6o׳dcoJ\CAT+K4ٯ\AS(8蠃\%/Fϭ 1]SDeT<=>(c:dA0,XcgP45  d@X>)م2~{?+Pj2$_˙^ }FƁ~ʐybQy _;[/˴0O/})r74Z1\, @JqKՏم?:gU "6:[Z.y+a_V~MYonv?_2ҖW/55kݟ$*j% VP@>C}G>߇AU~hWЫ-* tz6ysZ9K@JFk(DOTJ(PdhDJvzVR ={%R4o׮PAz^RI(Jy)C&’QHL?SֈLPҽeSh5Ԉ\C e;I*忿a#di*p1NO곦DT}磒'OvH;kjjhjwA@hGC-c&KCϝ}nZP+:j^ З+ Vjf*Cmۦ) d@M]ݿ~yM+'Pia{v5KPDM]UD+\ТvCP,ʳyml}WA/ {j X8Fw&PFM *IdaVgAV'r˺ͧ]Z1Jvc7鿥mq3C|?=?yGaCE+~(0e6>\E6|iΆ>1cƸ,&ʆUIS=* ((뇂5XQFB}Ce69?GƔ' 38.;#ŎxW8'@@RP9sl=Li%ZA*)o\Vi[q˼V"jr"*;NZW &ڷV| $PaiR;{F4vA 5vuV~d8 F![I Et]@Oe/Uիs^=6zC4eM$~*Pķ?Q Wypu\B_&t`YԢتl䢋m]:%W.C[ha~ǫģJ$5iBxƌnѶ&5٭rQ Vh5/EIdF ^hժU*V*D&\o([DY#;vtۃC iTZJMgmh!uCZT34R,|sgٖr8O@@@@D)b-ZD(0kr;!q }Ae?߻po+*뿾ZDKcNGENjK9ܶ6t֖Z]&!Im)l 5@W(lmy>VfH)F5PLY $(/6C 6lifkW&E&u@@@)W̷յ]l?Zd.y]\"~(MZ-y.[6D |IDAT|.,zVK^ں-kzLwU. :( GF)>%p@CMTE\    2q47Vv؀|G6i v@@@@`'1ؽ+hl[V-5(wцwk_2@fΜiwlNp" 5T^058cOlP @4#W    >,F.ioUum+x16;鱆4-;&MYfγe˖֣GD25VlLM+@"s=W    Q(aZ+{dEMţT&w6yfޤT fSLz***^[vvhRwqCqn| @@@@ jj͒7mc탲     ʤVY$+bYI6E @w@@@@@*@EZ98     )@"3;W     @ZP#     (2s     UEZ98     )@"3;W     @ZP#     (2s     UEZ98     )@"3;W     @ZP#     (2s     UEZ98     )@"3;W     @ZP#     (2s     UEZ98     )@"3;W     @ZP#     (2s     UEZ98     )@"3;W     @ZP#     (2s     UEZ98     )@"3;W     @ZP#     (2s     UEZ98     )@"3;W     @ZP#     (2s     UEZ98     )@"3;W     @ZP#     (2s     UEZ98     )@"3;W     @ZP#     (2s     UEZ98     )@"3;W     @ZP#     (2s     UEZ98     )@"3;W     @ZP#     (2s     UEZ98     )@"3;W     @ZP#      tT"IENDB`python-inline-snapshot-0.23.2/docs/categories.md000066400000000000000000000170501501556550300216370ustar00rootroot00000000000000 Each snapshot change is assigned to a different category. This is done because inline-snapshot supports more than just `==` checks. There are changes which: * [create](#create) new snapshot values * [fix](#fix) your tests * [update](#update) only the syntax to a new representation * [trim](#trim) unused pieces from your snapshots *Create* and *fix* are mainly used, but it is good to know what type of change you are approving, because it helps with the decision if this changes should be applied. ## Categories ### Create These changes are made when new snapshots are created. The result of each comparison is `True`, which allows to run the whole test to fill all new snapshots with values. Example:

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