pax_global_header00006660000000000000000000000064152577364110014525gustar00rootroot0000000000000052 comment=4b273d82f17b84b7479e92794cfb9c9d639e8453 testfixtures-12.4.0/000077500000000000000000000000001525773641100143625ustar00rootroot00000000000000testfixtures-12.4.0/.github/000077500000000000000000000000001525773641100157225ustar00rootroot00000000000000testfixtures-12.4.0/.github/workflows/000077500000000000000000000000001525773641100177575ustar00rootroot00000000000000testfixtures-12.4.0/.github/workflows/ci.yml000066400000000000000000000075361525773641100211100ustar00rootroot00000000000000name: "Continuous Integration" on: push: branches: [main] pull_request: schedule: - cron: "0 1 * * *" jobs: tests: name: "tests / python ${{ matrix.python-version }} / ${{ matrix.uv-resolution }} ${{ matrix.extra}}" runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: - "3.11" - "3.14" - "3.15" uv-resolution: - "lowest-direct" - "highest" extra: - "" - "--all-extras --all-groups" include: # Now test each of the extras individually - python-version: "3.14" uv-resolution: "highest" extra: "--extra django --group django-dev" - python-version: "3.14" uv-resolution: "highest" extra: "--extra mock-backport" - python-version: "3.14" uv-resolution: "highest" extra: "--extra sybil" - python-version: "3.14" uv-resolution: "highest" extra: "--extra loguru" - python-version: "3.14" uv-resolution: "highest" extra: "--extra polars" - python-version: "3.14" uv-resolution: "highest" extra: "--extra pandas --group pandas-dev" - python-version: "3.14" uv-resolution: "highest" extra: "--extra numpy" - python-version: "3.14" uv-resolution: "highest" extra: "--extra structlog" - python-version: "3.14" uv-resolution: "highest" extra: "--extra pydantic" - python-version: "3.14" uv-resolution: "highest" extra: "--extra twisted --group twisted-dev" - python-version: "3.14" uv-resolution: "highest" extra: "--extra yaml" - python-version: "3.14" uv-resolution: "highest" extra: "--extra toml" steps: - uses: cjw296/python-action/run-tests@v3 with: python-version: ${{ matrix.python-version }} uv-params: "--dev ${{ matrix.extra }} --resolution ${{ matrix.uv-resolution }}" coverage: needs: tests runs-on: ubuntu-latest steps: - uses: cjw296/python-action/check-coverage@v3 typing: name: type checking / python ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: matrix: python-version: - "3.11" - "3.14" - "3.15" steps: - uses: cjw296/python-action/check-typing@v3 with: python-version: ${{ matrix.python-version }} uv-params: "--all-groups --all-extras" build-and-inspect-package: needs: - coverage - tests - typing runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: hynek/build-and-inspect-python-package@2abe76da66d0a6a4a227101f9348ee855797cfa5 # v3.0.1 check-package: needs: - build-and-inspect-package runs-on: ubuntu-latest strategy: matrix: extra: - "" - "django" - "loguru" - "polars" - "pandas" - "numpy" - "sybil" - "mock" - "structlog" - "pydantic" - "twisted" steps: - uses: cjw296/python-action/check-distributions@v3 with: package: "testfixtures" python-version: "3.14" extras: ${{ matrix.extra == 'mock' && '[mock-backport]' || (matrix.extra && format('[{0}]', matrix.extra) || '') }} checks: ${{ matrix.extra && format('python -c "import testfixtures.{0}"', matrix.extra) || 'python -c "import testfixtures"' }} all-green: if: always() needs: - check-package runs-on: ubuntu-latest steps: - uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} testfixtures-12.4.0/.github/workflows/release.yml000066400000000000000000000022261525773641100221240ustar00rootroot00000000000000name: "Release" on: workflow_run: workflows: [Continuous Integration] types: [completed] branches: [main] jobs: check: runs-on: ubuntu-latest if: ${{ github.repository == 'simplistix/testfixtures' && github.event.workflow_run.conclusion == 'success' }} outputs: tag: ${{ steps.carthorse.outputs.tag }} steps: - uses: cjw296/python-action/carthorse@v1 id: carthorse with: tag-format: '{version}' check: "true" release: runs-on: ubuntu-latest needs: check if: ${{ needs.check.outputs.tag != '' }} permissions: contents: write id-token: write steps: - name: Show tag run: echo "${{ toJson(needs.check.outputs) }}" shell: bash - name: Download packages from CI run uses: dawidd6/action-download-artifact@v9 with: run_id: ${{ github.event.workflow_run.id }} name: Packages path: dist - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - uses: cjw296/python-action/carthorse@v1 with: tag-format: '{version}' testfixtures-12.4.0/.gitignore000066400000000000000000000003631525773641100163540ustar00rootroot00000000000000/.installed.cfg /bin/ /develop-eggs /dist /docs/_build /eggs /*.egg-info/ /parts/ *.pyc /.coverage /*.xml /.tox /htmlcov /include /lib /local /man /.Python desc.html pip-selfcheck.json .coverage.* .cache .pytest* /build .venv /.claude /*.lock testfixtures-12.4.0/.python-version000066400000000000000000000000051525773641100173620ustar00rootroot000000000000003.14 testfixtures-12.4.0/.readthedocs.yml000066400000000000000000000004201525773641100174440ustar00rootroot00000000000000version: 2 build: os: ubuntu-24.04 commands: - asdf plugin add uv - asdf install uv latest - asdf global uv latest - uv sync --group docs --all-extras --no-extra mock-backport - uv run sphinx-build --fail-on-warning -T -b html docs $READTHEDOCS_OUTPUT/html testfixtures-12.4.0/AGENTS.md000066400000000000000000000063341525773641100156730ustar00rootroot00000000000000# Agent Instructions ## Principles - **Done means green**: a change is only complete when `./happy.sh` exits 0; do not commit until it does. - **No unrelated failures**: if `./happy.sh` fails on something unrelated to your changes, do NOT assume it is a pre-existing problem and proceed anyway. Stop immediately and ask the user how to proceed. - **Docs for everything public**: new functionality or public API changes must have accompanying docs in `docs/*.rst` - **Keep the api.rst comparers list current**: the `Comparing` > `Comparers` list in `docs/api.rst` is hand-curated (not an `automodule`). When you add a `compare_*` comparer to `comparers.py`, add a matching `.. autofunction::` entry there, otherwise it silently goes undocumented. - **No em-dashes or parenthetical asides in prose**: in `docs/*.rst` prose and Python docstrings, never use em-dashes, and never tuck a clause inside parentheses; rephrase with commas or separate sentences. This does not apply to code comments or agent-facing notes such as this file, where both are fine. - **No stacked headings in docs**: a heading in `docs/*.rst` must be followed by prose, never immediately by a sub-heading. Add a short lead-in or merge the levels. - **Type-annotate public APIs**: all public functions and classes need type annotations; mypy is the gate - **Use `compare()` in tests**: never use bare `assert` statements; always use `compare(actual, expected=...)` for assertions - **No `noqa`, ever**: this project has zero linter suppressions; don't add them. Fix the underlying issue instead. - **No `CHANGELOG.rst` edits during development**: that file is updated at release time, not as part of feature work. ## Project Overview Testfixtures is a Python testing utilities library. Features: object comparison with diff output, mocking/replacement, logging capture, temporary directories, datetime mocking, exception/warning testing, Django and Twisted support. ## Environment ```bash uv sync --all-extras --all-groups # setup or after pulling rm -rf .venv && uv sync --all-extras --all-groups # full reset ``` ## Commands ```bash ./happy.sh # all checks: required before commit uv run pytest # all tests + doctests uv run pytest tests/test_comparison.py # single file uv run pytest --cov=testfixtures --cov-report=term-missing # with coverage uv run mypy src/testfixtures # type checking make -C docs html # build docs (use this, not sphinx-build directly) uv build # build sdist + wheel ``` ## Architecture `src/testfixtures/`: all source. Key modules: - `comparison.py`: `compare()`, `diff()`, `Comparison`, `TextComparison`, `RoundComparison`, etc. - `replace.py`: `Replacer`, `replace()` decorators - `logcapture.py`: `LogCapture` - `datetime.py`: `mock_datetime`, `mock_date`, `mock_time` - `tempdirectory.py`: temporary directory management - `shouldraise.py`: `ShouldRaise`, `should_raise` - `shouldwarn.py`: `ShouldWarn`, `ShouldNotWarn` - `django.py`, `twisted.py`: framework-specific support - `__init__.py`: main API exports Config: `pyproject.toml` (optional deps for Django, Sybil, Twisted). testfixtures-12.4.0/CHANGELOG.rst000066400000000000000000001364351525773641100164170ustar00rootroot00000000000000.. py:currentmodule:: testfixtures Changes ======= 12.4.0 (2 Oct 2026) ------------------- - Support Python 3.15 - Speed up the common cases for :meth:`Replacer.on_class`. 12.3.0 (8 Jul 2026) -------------------- - Added :doc:`pydantic ` support: a :func:`comparer ` for :class:`~pydantic.BaseModel` is automatically registered, and the ``testfixtures[pydantic]`` extra installs a compatible version. - Added :class:`ReprComparison` and :func:`repr_like` for asserting an object's type along with its :func:`repr`. - Added :class:`StrComparison` and :func:`str_like` for asserting an object's type along with its :class:`str`. - Added :func:`mapping`, the typed helper for :class:`MappingComparison`. - :func:`like` now accepts a regular expression, as a string or a compiled :class:`re.Pattern`, returning a :class:`TextComparison` typed as a :class:`str`. :class:`TextComparison` can also now be built directly from a compiled :class:`re.Pattern`. - :class:`ShouldRaise` now accepts :func:`repr_like` and :func:`str_like` matchers, so a raised exception's type and rendering can be checked without constructing the exception instance. - Renamed :class:`!StringComparison` to :class:`TextComparison`. :class:`!StringComparison` remains as a deprecated alias. - Fixed a bug where comparing two matcher instances directly, such as two :class:`SequenceComparison` objects, could produce a confusing diff instead of comparing them for equality. 12.2.0 (20 Jun 2026) -------------------- - Added :meth:`LogCapture.check_empty`. - Added :meth:`LogCapture.disabled` context manager. - Added ``level`` and ``predicate`` parameters to :meth:`LogCapture.check`. 12.1.0 (15 Jun 2026) -------------------- - :func:`compare` now supports :doc:`numpy ` arrays. - Added a helper for :doc:`testing command-line scripts `. 12.0.1 (11 Jun 2026) -------------------- - Fix bug where :class:`frozensets ` would not :func:`compare` equal when a library requiring ``ignore_eq`` for its types, such as :doc:`polars `, was present. 12.0.0 (23 May 2026) -------------------- .. warning:: Breaking changes: - Comparers have moved from :mod:`!testfixtures.comparison` to :mod:`testfixtures.comparers`. - :func:`!django_compare` has been removed, :func:`compare` now works with Django models. - Refactoring of :class:`LogCapture` to introduce support for :doc:`loguru `, :doc:`structlog ` and similar support for :doc:`twisted ` by way of the new :class:`~testfixtures.logcapture.CaptureSource` architecture. - :func:`compare` now supports per-type ``ignore_eq``. - :func:`compare` now supports both :doc:`polars ` and :doc:`pandas ` dataframes. - :func:`compare` now provides better feedback when objects being compared raised exceptions in their :any:`str` or :any:`repr`. - Added ``raises`` parameter to :meth:`OutputCapture.compare`, :meth:`LogCapture.check` and :meth:`LogCapture.check_present` to they can be more easily used in compositions. 11.0.0 (9 Mar 2026) ------------------- - Moved to a `uv`__-based, ``pyproject.toml``-driven project layout, with ``main`` replacing ``master`` as the default git branch. __ https://docs.astral.sh/uv/ - Move from Circle CI to Github Actions for continuous integration and releasing. - Lots of documentation has been refreshed. - Python versions between 3.11 and 3.14, inclusive, are now supported. - Python versions 3.10 and earlier are no longer supported. - :class:`TempDir` is now introduced as a :class:`~pathlib.Path`-based alternative to :class:`TempDirectory`. - :class:`TempDir` and :class:`TempDirectory` have grown support for dumping and parsing JSON, YAML and TOML natively by way of the :meth:`~TempDir.dump` and :meth:`TempDir.parse` methods. - :class:`TempDir` and :class:`TempDirectory` now have :meth:`~TempDir.read_text` and :meth:`TempDir.read_bytes` methods that mirror the behaviour of their :class:`~pathlib.Path` equivalents. - :class:`TempDir` and :class:`TempDirectory` now support :class:`~pathlib.Path` objects anywhere they previously supported string and sequences of strings. - :class:`TempDir` and :class:`TempDirectory` now have a :meth:`~TempDir.clone` method for copying existing files or directories into themselves for using during testing. - ``None`` can now be passed to :class:`ShouldRaise` to check that no exception is raised. This is useful for parameterised tests where some cases may expect an exception to be raised while others do not. - :class:`ShouldRaise` now has a ``match`` parameter to bring feature parity with :func:`pytest.raises`. - :class:`Comparison` now shows the wrong type when it is compared against a wrong type. - :class:`Comparison` no longer spuriously reports :class:`~testfixtures.comparers.AlreadySeen` as the wrong type. - Fixed a bug where comparing parameterised generic type objects resulted in an infinite loop. 10.0.0 (29 Oct 2025) -------------------- - Fixed a bug in the warning issued when a :class:`Replacer` is deleted with replacements still in place. - Reworked :func:`compare` options so that typos and the like will now result in an exception being raised. This ended up being a breaking change if you use custom comparers that take options as this now has a :ref:`different API `. - Reworked the :doc:`compare documentation ` to explain why you should be using it as well as updating the examples and explaining more fully how to configure and customise it. 9.2.0 (13 Oct 2025) ------------------- - Implement :func:`like`, :func:`sequence`, :func:`contains`, :func:`unordered` for strictly typed :ref:`comparisons `. - Correctly type :attr:`ShouldRaise.raised`. 9.1.0 (8 Jul 2025) ------------------ - Add support for passing :class:`StringComparison` instances as parameters to :meth:`OutputCapture.compare`. 9.0.1 (4 Jul 2025) ------------------ - :attr:`ShouldRaise.raised` is now always an exception instance. If no exception has been raised, it will be a :class:`~testfixtures.shouldraise.NoException` instance. This prevents typing complaints around using an attribute that might be ``None``. - :func:`mock_time` has been reworked to return an instance of :class:`~testfixtures.datetime.MockTime` rather than the class itself. :class:`~testfixtures.datetime.MockTime` has also been reworked such that it can no longer be instantiated with the same parameters as :class:`~datetime.datetime` - this edge case was never supported, but is highlighted here in case folks were using it. 9.0.0 (3 Jul 2025) ------------------ - Python 3.11 is now the minimum supported version. - A ``show_whitespace`` parameter has been added to :class:`ShouldAssert`. - ``testfixtures`` is now fully typed, checked with `mypy`__ and is distributed with a ``py.typed`` marker file. __ https://mypy.readthedocs.io/en/stable/ 8.3.0 (7 Jun 2024) ------------------ - :class:`ShouldRaise` now supports :class:`ExceptionGroup`. - Fixed bug where :func:`~testfixtures.comparers.compare_generator` did not respect ``strict=True``. - Fixed bug in the type annotations for :class:`ShouldRaise` and :func:`~testfixtures.comparers.compare_exception`. - :class:`LogCapture` will now raise an exception if closed while still installed. This can be a source of particularly confusing bugs. 8.2.0 (1 May 2024) ------------------ - The ``strict`` option is now correctly respected when :doc:`comparing ` nested objects. - When comparing :class:`~datetime.datetime` or :class:`~datetime.time` instances, if the fold is the only thing that's different, it's now shown in the output. - A more detailed exception is now raised when resolving a :doc:`replacement ` doesn't give what's expected. - :doc:`Replacement ` of methods on instances is now prevented when ``strict=True``. - An exception is now raised when :doc:`mocking ` and the original is not in the ``__dict__`` of its containing objects. 8.1.0 (1 Mar 2024) ------------------ - Fix bug where replacement of methods on subclasses failed when using :meth:`Replacer.on_class`. - Implement :ref:`strict comparison ` as an option for :class:`~datetime.datetime` and :class:`~datetime.time`. 8.0.0 (16 Feb 2024) ------------------- - Retire ``zope.component`` helpers. - Support :class:`bytes` in :class:`popen.MockPopen` commands. - Allow :class:`TempDirectory` instances to be traversed as :class:`~pathlib.Path` objects. - Use the system default encoding where possible in :class:`TempDirectory`. - Add :class:`TempDirectory` option to manage current working directory. - Allow string class attributes to be replaced with :class:`Replacer` and friends. - Fix nasty bug meaning some :meth:`~Replacer.on_class` and :meth:`~Replacer.in_module` replacements weren't restored. - Allow an alternative separator to be used for traversal during replacement with :class:`Replacer` and friends. - Officially support Python 3.12. - Drop support Python 3.6. 7.2.2 (20 Oct 2023) ------------------- - Fix bug in support for :class:`os.PathLike` arguments to :class:`popen.MockPopen`. 7.2.1 (20 Oct 2023) ------------------- - Added missing support for :class:`os.PathLike` arguments to :class:`popen.MockPopen`. 7.2.0 (15 Sep 2023) ------------------- - Add ``order_matters`` parameter to :class:`ShouldWarn`. Thanks to Jan Musílek for the implementation! 7.1.0 (8 Feb 2023) ------------------ - Implement new IDE and static-analysis ways of :doc:`mocking ` including additional parameters to :meth:`~Replacer.replace` along with the :any:`replace_on_class`, :any:`replace_in_module` and :any:`replace_in_environ` context managers. 7.0.4 (5 Dec 2022) ------------------ - Remove `py.typed` file: neither `mypy` nor `testfixtures` are ready for this file to be present. 7.0.3 (3 Nov 2022) ------------------ - Further bugfixes around self-referential datastructures and :func:`compare`. 7.0.2 (1 Nov 2022) ------------------ - Reinstate support for self-referential data structures in :func:`compare`. The new implementation provides more clarity about what's going on and also ignores more immutable data types. 7.0.1 (1 Nov 2022) ------------------ - Remove non-functional support for self-referential data structures in :func:`compare`. The functionality didn't work but did cause erroneous reported equality of values in dictionaries that were actually not equal. 7.0.0 (6 Jul 2022) ------------------ - Refresh documentation. - Add type annotations. - Drop support for Python 2. The minimum supported Python version is now 3.6. - Sybil 3 is now the minimum supported version if you use :class:`~.sybil.FileParser`. - Rename and refactor the date and time mocks, they are now :any:`mock_date`, :any:`mock_datetime` and :any:`mock_time`. :any:`test_date`, :any:`test_datetime ` and :any:`test_time ` are still present as aliases but are now deprecated. - Add :meth:`TempDirectory.as_string`, :meth:`TempDirectory.as_path` and ``TempDirectory.as_local``. :meth:`TempDirectory.getpath` is now deprecated. - :class:`TempDirectory` can now be used to wrap existing directories. - Fixed a bug where :any:`OutputCapture.captured` returned bytes instead of a string with ``fd=True``. - The deprecated ``strict`` option to :class:`Comparison` has been removed, use the ``partial`` option instead. - The deprecated ``TempDirectory.check``, ``TempDirectory.check_dir`` and ``TempDirectory.check_all`` methods have been removed. 6.18.5 (1 Mar 2022) ------------------- - Fix bug in detection of Mock backport. 6.18.4 (25 Feb 2022) -------------------- - Ensure compatibility with Sybil 2 and Sybil 3 along with pytest 6 and pytest 7. 6.18.3 (29 Sep 2021) -------------------- - Fix bug when using :func:`compare` on two regular expressions that have very long patterns. Thanks to Christoph Ludwig for the report. 6.18.2 (21 Sep 2021) -------------------- - Fix bug that meant :class:`LogCapture` didn't preserve or provide a clean testing environment for filters. Thanks to Jesse Rittner for the fix. 6.18.1 (20 Aug 2021) -------------------- - Fix bug when showing differences between mappings found by :func:`compare` when mismatching values contained the same number more than once. 6.18.0 (15 Jul 2021) -------------------- - Add support for lazy resolution of ``prefix`` and ``suffix`` when using :func:`compare`. 6.17.1 (14 Jan 2020) -------------------- - Fix bug where bug where duplicated entries in an ordered but partial :class:`SequenceComparison` could result in a failed match. 6.17.0 (16 Dec 2020) -------------------- - Add simpler flag support to :class:`StringComparison`. - Fix deprecation warning about invalid escape sequence. Thanks to Wim Glenn for the deprecation warning fix. 6.16.0 (9 Dec 2020) ------------------- - Simplify and clarify the documentation of timezones when using :any:`test_datetime ` . - :doc:`api` has been re-arranged to make it easier to browse. - The ``strict`` parameter to :class:`Comparison` has been deprecated in favour of ``partial``. - Add :class:`SequenceComparison`, :class:`Subset` and :class:`Permutation` objects. - Add :class:`MappingComparison` objects. - Officially support Python 3.9. 6.15.0 (9 Oct 2020) ------------------- - Add support to :class:`LogCapture` for making sure log entries above a specified level have been checked. Thanks to Zoltan Farkas for the implementation. 6.14.2 (4 Sep 2020) ------------------- - Fix bug where ``ignore_eq`` had no effect on nested objects when using :func:`compare`. Thanks to Grégoire Payen de La Garanderie for the fix. 6.14.1 (20 Apr 2020) -------------------- - Fix bugs in comparison of :func:`~unittest.mock.call` objects where the :func:`repr` of the :func:`~unittest.mock.call` arguments were the same even when their attributes were not. 6.14.0 (24 Feb 2020) -------------------- - Add support for non-deterministic logging order when using :meth:`twisted.LogCapture`. 6.13.1 (20 Feb 2020) -------------------- - Fix for using :func:`compare` to compare two-element :func:`~unittest.mock.call` objects. Thanks to Daniel Fortunov for the fix. 6.13.0 (18 Feb 2020) -------------------- - Allow any attributes that need to be ignored to be specified directly when calling :func:`~testfixtures.comparers.compare_object`. This is handy when writing comparers for :func:`compare`. 6.12.1 (16 Feb 2020) -------------------- - Fix a bug that occured when using :func:`compare` to compare a string with a slotted object that had the same :func:`repr` as the string. 6.12.0 (6 Feb 2020) ------------------- - Add support for ``universal_newlines``, ``text``, ``encoding`` and ``errors`` to :class:`popen.MockPopen`, but only for Python 3. 6.11.0 (29 Jan 2020) -------------------- - :class:`decimal.Decimal` now has better representation when :func:`compare` displays a failed comparison, particularly on Python 2. - Add support to :func:`compare` for explicitly naming objects to be compared as ``x`` and ``y``. This allows symmetry with the ``x_label`` and ``y_label`` parameters that are now documented. - Restore ability for :class:`Comparison` to compare properties and methods, although these uses are not recommended. Thanks to Daniel Fortunov for all of the above. 6.10.3 (22 Nov 2019) -------------------- - Fix bug where new-style classes had their attributes checked with :func:`compare` even when they were of different types. 6.10.2 (15 Nov 2019) -------------------- - Fix bugs in :func:`compare` when comparing objects which have both ``__slots__`` and a ``__dict__``. 6.10.1 (1 Nov 2019) ------------------- - Fix edge case where string interning made dictionary comparison output much less useful. 6.10.0 (19 Jun 2019) -------------------- - Better feedback where objects do not :func:`compare` equal but do have the same representation. 6.9.0 (10 Jun 2019) ------------------- - Fix deprecation warning relating to ``getargspec``. - Improve :doc:`mocking ` docs. - Added ``strip_whitespace`` option to :class:`OutputCapture`. - When ``separate`` is used with :class:`OutputCapture`, differences in ``stdout`` and ``stderr`` are now given in the same :class:`AssertionError`. - :class:`ShouldRaise` no longer catches exceptions that are not of the required type. - Fixed a problem that resulted in unhelpful :func:`compare` failures when :func:`~unittest.mock.call` was involved and Python 3.6.7 was used. Thanks to Łukasz Rogalski for the deprecation warning fix. Thanks to Wim Glenn for the :class:`ShouldRaise` idea. 6.8.2 (4 May 2019) ------------------ - Fix handling of the latest releases of the ``mock`` backport. 6.8.1 (2 May 2019) ------------------ - Fix bogus import in :class:`OutputCapture`. 6.8.0 (2 May 2019) ------------------ - Allow :class:`OutputCapture` to capture the underlying file descriptors for :any:`sys.stdout` and :any:`sys.stderr`. 6.7.1 (29 Apr 2019) ------------------- - Silence :class:`DeprecationWarning` relating to ``collections.abc`` on Python 3.7. Thanks to Tom Hendrikx for the fix. 6.7.0 (11 Apr 2019) ------------------- - Add :meth:`twisted.LogCapture.raise_logged_failure` debugging helper. 6.6.2 (22 Mar 2019) ------------------- - :meth:`popen.MockPopen.set_command` is now symmetrical with :class:`popen.MockPopen` process instantiation in that both can be called with either lists or strings, in the same way as :class:`subprocess.Popen`. 6.6.1 (13 Mar 2019) ------------------- - Fixed bugs where using :attr:`not_there` to ensure a key or attribute was not there but would be set by a test would result in the test attribute or key being left behind. - Add support for comparing :func:`~functools.partial` instances and fix comparison of functions and other objects where ``vars()`` returns an empty :class:`dict`. 6.6.0 (22 Feb 2019) ------------------- - Add the ability to ignore attributes of particular object types when using :func:`compare`. 6.5.2 (18 Feb 2019) ------------------- - Fix bug when :func:`compare` was used with objects that had ``__slots__`` inherited from a base class but where their ``__slots__`` was an empty sequence. 6.5.1 (18 Feb 2019) ------------------- - Fix bug when :func:`compare` was used with objects that had ``__slots__`` inherited from a base class. 6.5.0 (28 Jan 2019) ------------------- - Experimental support for making assertions about events logged with Twisted's logging framework. 6.4.3 (10 Jan 2019) ------------------- - Fix problems on Python 2 when the rolling backport of `mock`__ was not installed. __ https://mock.readthedocs.io 6.4.2 (9 Jan 2019) ------------------ - Fixed typo in the ``executable`` parameter name for :class:`~testfixtures.popen.MockPopen`. - Fixed :func:`~unittest.mock.call` patching to only patch when needed. - Fixed :func:`compare` with :func:`~unittest.mock.call` objects for the latest Python releases. 6.4.1 (24 Dec 2018) ------------------- - Fix bug when using :func:`unittest.mock.patch` and any of the testfixtures decorators at the same time and where the object being patched in was not hashable. 6.4.0 (19 Dec 2018) ------------------- - Add official support for Python 3.7. - Drop official support for Python 3.5. - Introduce a facade for :mod:`unittest.mock` at :mod:`testfixtures.mock`, including an important bug fix for :func:`~unittest.mock.call` objects. - Better feedback when :func:`~unittest.mock.call` comparisons fail when using :func:`compare`. - A re-working of :class:`~testfixtures.popen.MockPopen` to enable it to handle multiple processes being active at the same time. - Fixes to :doc:`datetime` documentation. Thanks to Augusto Wagner Andreoli for his work on the :doc:`datetime` documentation. 6.3.0 (4 Sep 2018) ------------------ - Allow the behaviour specified with :meth:`~testfixtures.popen.MockPopen.set_command` to be a callable meaning that mock behaviour can now be dynamic based on the command executed and whatever was sent to ``stdin``. - Make :class:`~testfixtures.popen.MockPopen` more accurately reflect :class:`subprocess.Popen` on Python 3 by adding ``timeout`` parameters to :meth:`~testfixtures.popen.MockPopenInstance.wait` and :meth:`~testfixtures.popen.MockPopenInstance.communicate` along with some other smaller changes. Thanks to Tim Davies for his work on :class:`~testfixtures.popen.MockPopen`. 6.2.0 (14 Jun 2018) ------------------- - Better rendering of differences between :class:`bytes` when using :func:`compare` on Python 3. 6.1.0 (6 Jun 2018) ------------------ - Support filtering for specific warnings with :class:`ShouldWarn`. 6.0.2 (2 May 2018) ------------------ - Fix nasty bug where objects that had neither ``__dict__`` nor ``__slots__`` would always be considered equal by :func:`compare`. 6.0.1 (17 April 2018) --------------------- - Fix a bug when comparing equal :class:`set` instances using :func:`compare` when ``strict==True``. 6.0.0 (27 March 2018) --------------------- - :func:`compare` will now handle objects that do not natively support equality or inequality and will treat these objects as equal if they are of the same type and have the same attributes as found using :func:`vars` or ``__slots__``. This is a change in behaviour which, while it could conceivably cause tests that are currently failing to pass, should not cause any currently passing tests to start failing. - Add support for writing to the ``stdin`` of :class:`~testfixtures.popen.MockPopen` instances. - The default behaviour of :class:`~testfixtures.popen.MockPopen` can now be controlled by providing a callable. - :meth:`LogCapture.actual` is now part of the documented public interface. - Add :meth:`LogCapture.check_present` to help with assertions about a sub-set of messages logged along with those that are logged in a non-deterministic order. - :class:`Comparison` now supports objects with ``__slots__``. - Added :class:`ShouldAssert` as a simpler tool for testing test helpers. - Changed the internals of the various decorators testfixtures provides such that they can be used in conjunction with :func:`unittest.mock.patch` on the same test method or function. - Changed the internals of :class:`ShouldRaise` and :class:`Comparison` to make use of :func:`compare` and so provide nested comparisons with better feedback. This finally allows :class:`ShouldRaise` to deal with Django's :class:`~django.core.exceptions.ValidationError`. - Added handling of self-referential structures to :func:`compare` by treating all but the first occurence as equal. Another change needed to support Django's insane :class:`~django.core.exceptions.ValidationError`. Thanks to Hamish Downer and Tim Davies for their work on :class:`~testfixtures.popen.MockPopen`. Thanks to Wim Glenn and Daniel Fortunov for their help reviewing some of the more major changes. 5.4.0 (25 January 2018) ----------------------- - Add explicit support for :class:`~unittest.mock.Mock` to :func:`compare`. 5.3.1 (21 November 2017) ------------------------ - Fix missing support for the `start_new_session` parameter to :class:`~testfixtures.popen.MockPopen`. 5.3.0 (28 October 2017) ----------------------- - Add pytest traceback hiding for :meth:`TempDirectory.compare`. - Add warnings that :func:`log_capture`, :func:`tempdir` and :func:`replace` are not currently compatible with pytest's fixtures mechanism. - Better support for ``stdout`` or ``stderr`` *not* being set to ``PIPE`` when using :class:`~testfixtures.popen.MockPopen`. - Add support to :class:`~testfixtures.popen.MockPopen` for using :class:`subprocess.Popen` as a context manager in Python 3. - Add support to :class:`~testfixtures.popen.MockPopen` for ``stderr=STDOUT``. Thanks to Tim Davies for his work on :class:`~testfixtures.popen.MockPopen`. 5.2.0 (3 September 2017) ------------------------ - :any:`test_datetime ` and :class:`test_time ` now accept a :class:`~datetime.datetime` instance during instantiation to set the initial value. - :class:`test_date ` now accepts a :class:`~datetime.date` instance during instantiation to set the initial value. - Relax the restriction on adding, setting or instantiating :any:`test_datetime ` with `tzinfo` such that if the `tzinfo` matches the one configured, then it's okay to add. This means that you can now instantiate a :any:`test_datetime ` with an existing :class:`~datetime.datetime` instance that has `tzinfo` set. - :func:`testfixtures.django.compare_model` now ignores :class:`many to many ` fields rather than blowing up on them. - Drop official support for Python 3.4, although things should continue to work. 5.1.1 (8 June 2017) ------------------- - Fix support for Django 1.9 in :func:`testfixtures.django.compare_model`. 5.1.0 (8 June 2017) ------------------- - Added support for including non-edit able fields to the :func:`comparer ` used by :func:`compare` when comparing :doc:`django ` :class:`~django.db.models.Model` instances. 5.0.0 (5 June 2017) ------------------- - Move from `nose`__ to `pytest`__ for running tests. __ http://nose.readthedocs.io/en/latest/ __ https://docs.pytest.org/en/latest/ - Switch from `manuel`__ to `sybil`__ for checking examples in documentation. This introduces a backwards incompatible change in that :class:`~testfixtures.sybil.FileParser` replaces the Manuel plugin that is no longer included. __ http://packages.python.org/manuel/ __ http://sybil.readthedocs.io/en/latest/ - Add a 'tick' method to :meth:`test_datetime `, :meth:`test_date ` and :meth:`test_time `, to advance the returned point in time, which is particularly helpful when ``delta`` is set to zero. 4.14.3 (15 May 2017) -------------------- - Fix build environment bug in ``.travis.yml`` that caused bad tarballs. 4.14.2 (15 May 2017) -------------------- - New release as it looks like Travis mis-built the 4.14.1 tarball. 4.14.1 (15 May 2017) -------------------- - Fix mis-merge. 4.14.0 (15 May 2017) -------------------- - Added helpers for testing with :doc:`django ` :class:`~django.db.models.Model` instances. 4.13.5 (1 March 2017) ------------------------- - :func:`compare` now correctly compares nested empty dictionaries when using ``ignore_eq=True``. 4.13.4 (6 February 2017) ------------------------ - Keep the `Reproducible Builds`__ guys happy. __ https://reproducible-builds.org/ 4.13.3 (13 December 2016) ------------------------- - :func:`compare` now better handles equality comparison with ``ignore_eq=True`` when either of the objects being compared cannot be hashed. 4.13.2 (16 November 2016) ------------------------- - Fixed a bug where a :class:`LogCapture` wouldn't be cleared when used via :func:`log_capture` on a base class and sub class execute the same test. Thanks to "mlabonte" for the bug report. 4.13.1 (2 November 2016) ------------------------ - When ``ignore_eq`` is used with :func:`compare`, fall back to comparing by hash if not type-specific comparer can be found. 4.13.0 (2 November 2016) ------------------------ - Add support to :func:`compare` for ignoring broken ``__eq__`` implementations. 4.12.0 (18 October 2016) ------------------------ - Add support for specifying a callable to extract rows from log records when using :class:`LogCapture`. - Add support for recursive comparison of log messages with :class:`LogCapture`. 4.11.0 (12 October 2016) ------------------------ - Allow the attributes returned in :meth:`LogCapture.actual` rows to be specified. - Allow a default to be specified for encoding in :meth:`TempDirectory.read` and :meth:`TempDirectory.write`. 4.10.1 (5 September 2016) ------------------------- - Better docs for :meth:`TempDirectory.compare`. - Remove the need for expected paths supplied to :meth:`TempDirectory.compare` to be in sorted order. - Document a good way of restoring ``stdout`` when in a debugger. - Fix handling of trailing slashes in :meth:`TempDirectory.compare`. Thanks to Maximilian Albert for the :meth:`TempDirectory.compare` docs. 4.10.0 (17 May 2016) -------------------- - Fixed examples in documentation broken in 4.5.1. - Add :class:`RangeComparison` for comparing against values that fall in a range. - Add :meth:`~popen.MockPopen.set_default` to :class:`~popen.MockPopen`. Thanks to Asaf Peleg for the :class:`RangeComparison` implementation. 4.9.1 (19 February 2016) ------------------------ - Fix for use with PyPy, broken since 4.8.0. Thanks to Nicola Iarocci for the pull request to fix. 4.9.0 (18 February 2016) ------------------------ - Added the `suffix` parameter to :func:`compare` to allow failure messages to include some additional context. - Update package metadata to indicate Python 3.5 compatibility. Thanks for Felix Yan for the metadata patch. Thanks to Wim Glenn for the suffix patch. 4.8.0 (2 February 2016) ----------------------- - Introduce a new :class:`Replace` context manager and make :class:`Replacer` callable. This gives more succinct and easy to read mocking code. - Add :class:`ShouldWarn` and :class:`ShouldNotWarn` context managers. 4.7.0 (10 December 2015) ------------------------ - Add the ability to pass ``raises=False`` to :func:`compare` to just get the resulting message back rather than having an exception raised. 4.6.0 (3 December 2015) ------------------------ - Fix a bug that mean symlinked directories would never show up when using :meth:`TempDirectory.compare` and friends. - Add the ``followlinks`` parameter to :meth:`TempDirectory.compare` to indicate that symlinked or hard linked directories should be recursed into when using ``recursive=True``. 4.5.1 (23 November 2015) ------------------------ - Switch from ``cStringIO`` to ``StringIO`` in :class:`OutputCapture` to better handle unicode being written to `stdout` or `stderr`. Thanks to "tell-k" for the patch. 4.5.0 (13 November 2015) ------------------------ - :class:`LogCapture`, :class:`OutputCapture` and :class:`TempDirectory` now explicitly show what is expected versus actual when reporting differences. Thanks to Daniel Fortunov for the pull request. 4.4.0 (1 November 2015) ----------------------- - Add support for labelling the arguments passed to :func:`compare`. - Allow ``expected`` and ``actual`` keyword parameters to be passed to :func:`compare`. - Fix ``TypeError: unorderable types`` when :func:`compare` found multiple differences in sets and dictionaries on Python 3. - Add official support for Python 3.5. - Drop official support for Python 2.6. Thanks to Daniel Fortunov for the initial ideas for explicit ``expected`` and ``actual`` support in :func:`compare`. 4.3.3 (15 September 2015) ------------------------- - Add wheel distribution to release. - Attempt to fix up various niggles from the move to Travis CI for doing releases. 4.3.2 (15 September 2015) ------------------------- - Fix broken 4.3.1 tag. 4.3.1 (15 September 2015) ------------------------- - Fix build problems introduced by moving the build process to Travis CI. 4.3.0 (15 September 2015) ------------------------- - Add :meth:`TempDirectory.compare` with a cleaner, more explicit API that allows comparison of only the files in a temporary directory. - Deprecate ``TempDirectory.check``, ``TempDirectory.check_dir`` and ``TempDirectory.check_all`` - Relax absolute-path rules so that if it's inside the :class:`TempDirectory`, it's allowed. - Allow :class:`OutputCapture` to separately check output to ``stdout`` and ``stderr``. 4.2.0 (11 August 2015) ---------------------- - Add :class:`~testfixtures.popen.MockPopen`, a mock helpful when testing code that uses :class:`subprocess.Popen`. - :class:`ShouldRaise` now subclasses :class:`object`, so that subclasses of it may use ``super()``. - Drop official support for Python 3.2. Thanks to BATS Global Markets for donating the code for :class:`~testfixtures.popen.MockPopen`. 4.1.2 (30 January 2015) ----------------------- - Clarify documentation for ``name`` parameter to :class:`LogCapture`. - :class:`ShouldRaise` now shows different output when two exceptions have the same representation but still differ. - Fix bug that could result in a :class:`dict` comparing equal to a :class:`list`. Thanks to Daniel Fortunov for the documentation clarification. 4.1.1 (30 October 2014) ----------------------- - Fix bug that prevented logger propagation to be controlled by the :class:`log_capture` decorator. Thanks to John Kristensen for the fix. 4.1.0 (14 October 2014) ----------------------- - Fix :func:`compare` bug when :class:`dict` instances with :class:`tuple` keys were not equal. - Allow logger propagation to be controlled by :class:`LogCapture`. - Enabled disabled loggers if a :class:`LogCapture` is attached to them. Thanks to Daniel Fortunov for the :func:`compare` fix. 4.0.2 (10 September 2014) ------------------------- - Fix "maximum recursion depth exceeded" when comparing a string with bytes that did not contain the same character. 4.0.1 (4 August 2014) --------------------- - Fix bugs when string compared equal and options to :func:`compare` were used. - Fix bug when strictly comparing two nested structures containing identical objects. 4.0.0 (22 July 2014) -------------------- - Moved from buildout to virtualenv for development. - The ``identity`` singleton is no longer needed and has been removed. - :func:`compare` will now work recursively on data structures for which it has registered comparers, giving more detailed feedback on nested data structures. Strict comparison will also be applied recursively. - Re-work the interfaces for using custom comparers with :func:`compare`. - Better feedback when comparing :func:`collections.namedtuple` instances. - Official support for Python 3.4. Thanks to Yevgen Kovalienia for the typo fix in :doc:`datetime`. 3.1.0 (25 May 2014) ------------------- - Added :class:`RoundComparison` helper for comparing numerics to a specific precision. - Added ``unless`` parameter to :class:`ShouldRaise` to cover some very specific edge cases. - Fix missing imports that showed up :class:`TempDirectory` had to do the "convoluted folder delete" dance on Windows. Thanks to Jon Thompson for the :class:`RoundComparison` implementation. Thanks to Matthias Lehmann for the import error reports. 3.0.2 (7 April 2014) -------------------- - Document :attr:`ShouldRaise.raised` and make it part of the official API. - Fix rare failures when cleaning up :class:`TempDirectory` instances on Windows. 3.0.1 (10 June 2013) -------------------- - Some documentation tweaks and clarifications. - Fixed a bug which masked exceptions when using :func:`compare` with a broken generator. - Fixed a bug when comparing a generator with a non-generator. - Ensure :class:`LogCapture` cleans up global state it may effect. - Fixed replacement of static methods using a :class:`Replacer`. 3.0.0 (5 March 2013) -------------------- - Added compatibility with Python 3.2 and 3.3. - Dropped compatibility with Python 2.5. - Removed support for the following obscure uses of :class:`should_raise`: .. invisible-code-block: python from testfixtures.mock import MagicMock should_raise = x = MagicMock() .. code-block:: python should_raise(x, IndexError)[1] should_raise(x, KeyError)['x'] - Dropped the `mode` parameter to :meth:`TempDirectory.read`. - :meth:`TempDirectory.makedir` and :meth:`TempDirectory.write` no longer accept a `path` parameter. - :meth:`TempDirectory.read` and :meth:`TempDirectory.write` now accept an `encoding` parameter to control how non-byte data is decoded and encoded respectively. - Added the `prefix` parameter to :func:`compare` to allow failure messages to be made more informative. - Fixed a problem when using sub-second deltas with :any:`test_time `. 2.3.5 (13 August 2012) ---------------------- - Fixed a bug in :func:`~testfixtures.comparers.compare_dict` that mean the list of keys that were the same was returned in an unsorted order. 2.3.4 (31 January 2012) ----------------------- - Fixed compatibility with Python 2.5 - Fixed compatibility with Python 2.7 - Development model moved to continuous integration using Jenkins. - Introduced `Tox`__ based testing to ensure packaging and dependencies are as expected. __ http://tox.testrun.org/latest/ - 100% line and branch coverage with tests. - Mark :any:`test_datetime ` , :class:`test_date ` and :class:`test_time ` such that nose doesn't mistake them as tests. 2.3.3 (12 December 2011) ------------------------- - Fixed a bug where when a target was replaced more than once using a single :class:`Replacer`, :meth:`~Replacer.restore` would not correctly restore the original. 2.3.2 (10 November 2011) ------------------------- - Fixed a bug where attributes and keys could not be removed by a :class:`Replacer` as described in :ref:`removing_attr_and_item` if the attribute or key might not be there, such as where a test wants to ensure an ``os.environ`` variable is not set. 2.3.1 (8 November 2011) ------------------------- - Move to use `nose `__ for running the testfixtures unit tests. - Fixed a bug where :meth:`~testfixtures.datetime.MockDateTime.now` returned an instance of the wrong type when `tzinfo` was passed in :ref:`strict mode `. 2.3.0 (11 October 2011) ------------------------- - :class:`Replacer`, :class:`TempDirectory`, :class:`LogCapture` and ``TestComponents`` instances will now warn if the process they are created in exits without them being cleaned up. Instances of these classes should be cleaned up at the end of each test and these warnings serve to point to a cause for possible mysterious failures elsewhere. 2.2.0 (4 October 2011) ------------------------- - Add a :ref:`strict mode ` to :any:`test_datetime ` and :class:`test_date `. When used, instances returned from the mocks are instances of those mocks. The default behaviour is now to return instances of the real :class:`~datetime.datetime` and :class:`~datetime.date` classes instead, which is usually much more useful. 2.1.0 (29 September 2011) ------------------------- - Add a :ref:`strict mode ` to :func:`compare`. When used, it ensures that the values compared are not only equal but also of the same type. This mode is not used by default, and the default mode restores the more commonly useful functionality where values of similar types but that aren't equal give useful feedback about differences. 2.0.1 (23 September 2011) ------------------------- - add back functionality to allow comparison of generators with non-generators. 2.0.0 (23 September 2011) ------------------------- - :func:`compare` now uses a registry of comparers that can be modified either by passing a `registry` option to :func:`compare` or, globally, using the :func:`~comparison.register` function. - added a comparer for :class:`set` instances to :func:`compare`. - added a new `show_whitespace` parameter to :func:`~comparers.compare_text`, the comparer used when comparing strings and unicodes with :func:`compare`. - The internal queue for :any:`test_datetime ` is now considered to be in local time. This has implication on the values returned from both :meth:`~testfixtures.datetime.MockDateTime.now` and :meth:`~testfixtures.datetime.MockDateTime.utcnow` when `tzinfo` is passed to the :any:`test_datetime ` constructor. - :meth:`~testfixtures.datetime.MockDate.set` and :meth:`~testfixtures.datetime.MockDate.add` on :class:`test_date `, :any:`test_datetime ` and :class:`test_time ` now accept instances of the appropriate type as an alternative to just passing in the parameters to create the instance. - Refactored the monolithic ``__init__.py`` into modules for each type of functionality. 1.12.0 (16 August 2011) ----------------------- - Add a :attr:`~OutputCapture.captured` property to :class:`OutputCapture` so that more complex assertion can be made about the output that has been captured. - :class:`OutputCapture` context managers can now be temporarily disabled using their :meth:`~OutputCapture.disable` method. - Logging can now be captured only when it exceeds a specified logging level. - The handling of timezones has been reworked in both :any:`test_datetime ` and :any:`test_time `. This is not backwards compatible but is much more useful and correct. 1.11.3 (3 August 2011) ---------------------- - Fix bugs where various :class:`test_date `, :class:`test_datetime ` and :class:`test_time ` methods didn't accept keyword parameters. 1.11.2 (28 July 2011) --------------------- - Fix for 1.10 and 1.11 releases that didn't include non-.py files as a result of the move from subversion to git. 1.11.1 (28 July 2011) --------------------- - Fix bug where :meth:`testfixtures.datetime.MockDateTime.now` didn't accept the `tz` parameter that :meth:`datetime.datetime.now` did. 1.11.0 (27 July 2011) --------------------- - Give more useful output when comparing dicts and their subclasses. - Turn :class:`should_raise` into a decorator form of :class:`ShouldRaise` rather than the rather out-moded wrapper function that it was. 1.10.0 (19 July 2011) --------------------- - Remove dependency on ``zope.dottedname``. - Implement the ability to mock out :class:`dict` and :class:`list` items using :class:`~testfixtures.Replacer` and :func:`~testfixtures.replace`. - Implement the ability to remove attributes and :class:`dict` items using :class:`~testfixtures.Replacer` and :func:`~testfixtures.replace`. 1.9.2 (20 April 2011) --------------------- - Fix for issue #328: :meth:`~testfixtures.datetime.MockDateTime.utcnow` of :any:`test_datetime ` now returns items from the internal queue in the same way as :meth:`~testfixtures.datetime.MockDateTime.now`. 1.9.1 (11 March 2011) ------------------------ - Fix bug when :class:`ShouldRaise` context managers incorrectly reported what exception was incorrectly raised when the incorrectly raised exception was a :class:`KeyError`. 1.9.0 (11 February 2011) ------------------------ - Added ``TestComponents`` for getting a sterile registry when testing code that uses ``zope.component``. 1.8.0 (14 January 2011) ----------------------- - Added full Sphinx-based documentation. - added a `Manuel `__ plugin for reading and writing files into a :class:`TempDirectory`. - any existing log handlers present when a :class:`LogCapture` is installed for a particular logger are now removed. - fix the semantics of :class:`should_raise`, which should always expect an exception to be raised! - added the :class:`ShouldRaise` context manager. - added recursive support to :meth:`TempDirectory.listdir` and added the new ``TempDirectory.check_all`` method. - added support for forward-slash separated paths to all relevant :class:`TempDirectory` methods. - added :meth:`TempDirectory.getpath` method. - allow files and directories to be ignored by a regular expression specification when using :class:`TempDirectory`. - made :class:`Comparison` objects work when the attributes expected might be class attributes. - re-implement :any:`test_time ` so that it uses the correct way to get timezone-less time. - added :meth:`~testfixtures.datetime.MockDateTime.set` along with `delta` and `delta_type` parameters to :any:`test_date `, :any:`test_datetime ` and :any:`test_time `. - allow the date class returned by the :meth:`~testfixtures.datetime.MockDate.today` method to be configured. - added the :class:`OutputCapture` context manager. - added the :class:`StringComparison` class. - added options to ignore trailing whitespace and blank lines when comparing multi-line strings with :func:`compare`. - fixed bugs in the handling of some exception types when using :class:`Comparison`, :class:`ShouldRaise` or :class:`should_raise`. - changed :func:`wrap` to correctly set __name__, along with some other attributes, which should help when using the decorators with certain testing frameworks. 1.7.0 (20 January 2010) ----------------------- - fixed a bug where the @replace decorator passed a classmethod rather than the replacment to the decorated callable when replacing a classmethod - added set method to test_date, test_datetime and test_time to allow setting the parameters for the next instance to be returned. - added delta and delta_type parameters to test_date,test_datetime and test_time to control the intervals between returned instances. 1.6.2 (23 September 2009) ------------------------- - changed Comparison to use __eq__ and __ne__ instead of the deprecated __cmp__ - documented that order matters when using Comparisons with objects that implement __eq__ themselves, such as instances of Django models. 1.6.1 (06 September 2009) ------------------------- - @replace and Replacer.replace can now replace attributes that may not be present, provided the `strict` parameter is passed as False. - should_raise now catches BaseException rather than Exception so raising of SystemExit and KeyboardInterrupt can be tested. 1.6.0 (09 May 2009) ------------------- - added support for using TempDirectory, Replacer and LogCapture as context managers. - fixed test failure in Python 2.6. 1.5.4 (11 Feb 2009) ------------------- - fix bug where should_raise didn't complain when no exception was raised but one was expected. - clarified that the return of a should_raise call will be None in the event that an exception is raised but no expected exception is specified. 1.5.3 (17 Dec 2008) ------------------- - should_raise now supports methods other than __call__ 1.5.2 (14 Dec 2008) ------------------- - added `makedir` and `check_dir` methods to TempDirectory and added support for sub directories to `read` and `write` 1.5.1 (12 Dec 2008) ------------------- - added `path` parameter to `write` method of TempDirectory so that the full path of the file written can be easilly obtained 1.5.0 (12 Dec 2008) ------------------- - added handy `read` and `write` methods to TempDirectory for creating and reading files in the temporary directory - added support for rich comparison of objects that don't support vars() 1.4.0 (12 Dec 2008) ------------------- - improved representation of failed Comparison - improved representation of failed compare with sequences 1.3.1 (10 Dec 2008) ------------------- - fixed bug that occurs when directory was deleted by a test that use tempdir or TempDirectory 1.3.0 (9 Dec 2008) ------------------ - added TempDirectory helper - added tempdir decorator 1.2.0 (3 Dec 2008) ------------------ - LogCaptures now auto-install on creation unless configured otherwise - LogCaptures now have a clear method - LogCaptures now have a class method uninstall_all that uninstalls all instances of LogCapture. Handy for a tearDown method in doctests. 1.1.0 (3 Dec 2008) ------------------ - add support to Comparisons for only comparing some attributes - move to use zope.dottedname 1.0.0 (26 Nov 2008) ------------------- - Initial Release testfixtures-12.4.0/CLAUDE.md000066400000000000000000000000421525773641100156350ustar00rootroot00000000000000Read @AGENTS.md for instructions. testfixtures-12.4.0/LICENSE.txt000066400000000000000000000021361525773641100162070ustar00rootroot00000000000000Copyright (c) 2008-2015 Simplistix Ltd Copyright (c) 2015 onwards Chris Withers 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. testfixtures-12.4.0/README.rst000066400000000000000000000070431525773641100160550ustar00rootroot00000000000000Testfixtures ============ |Docs|_ |PyPI|_ |Git|_ .. |Docs| image:: https://readthedocs.org/projects/testfixtures/badge/?version=latest .. _Docs: http://testfixtures.readthedocs.org/en/latest/ .. |PyPI| image:: https://badge.fury.io/py/testfixtures.svg .. _PyPI: https://pypi.org/project/testfixtures/ .. |Git| image:: https://github.com/simplistix/testfixtures/actions/workflows/ci.yml/badge.svg .. _Git: https://github.com/simplistix/testfixtures Testfixtures is a collection of helpers and mock objects that are useful when writing automated tests in Python. The areas of testing this package can help with are listed below: **Comparing, asserting equality and explaining differences** The ``compare`` function gives readable feedback when results aren't as expected, with clear diffs for deeply nested data structures and for objects that don't normally support comparison. This includes first-class support for pandas and polars dataframes, numpy arrays and pydantic models. Flexible placeholder objects and matchers such as ``like``, ``sequence``, ``generator``, ``Comparison``, ``RoundComparison``, ``RangeComparison``, ``TextComparison`` and ``SequenceComparison`` let you assert that only part of a value matters, that a number is within a range or rounded to a precision, or that a string matches a pattern. **Mocking out objects and methods** ``Replacer`` makes it easy to swap out objects, classes or individual methods for the duration of a test, restoring the originals automatically when it ends. You point them at the thing being replaced directly rather than at a dotted string, so your type checker verifies the target and the replacement survives refactoring. ``replace_on_class``, ``replace_in_module`` and ``replace_in_environ`` cover common cases such as methods, module globals and environment variables. **Mocking dates and times** ``mock_datetime``, ``mock_date`` and ``mock_time`` are drop-in replacements for the ``datetime`` and ``time`` APIs, for testing code that depends on the current date or time. They cover the thorny issues of timezones, non-uniform sequences of mock time increments, explicit ticking and freezing a point in time so you don't have to invent them from scratch. **Testing subprocesses** ``MockPopen`` lets you test code that uses ``subprocess.Popen`` without running real processes. **Testing command-line scripts** ``Command`` drives entry-point scripts end to end and checks their output, logging, mock calls and return code. **Testing logging** ``LogCapture`` captures log messages so you can check what was logged is what you expected, including from the loguru and structlog libraries. **Testing stream output** ``OutputCapture`` captures stream output, such as that from print calls or even writes made directly to file descriptors, and makes it easy to assert about. **Testing with files and directories** ``TempDirectory`` gives you a sandboxed directory to work in. It makes it easy to read and write files, serialise and deserialise structured data in formats such as JSON, YAML and TOML, create directory trees, and assert on the contents of both individual files and whole directories. **Testing exceptions and warnings** ``ShouldRaise``, ``ShouldAssert``, ``ShouldWarn`` and ``ShouldNotWarn`` check that a particular exception is raised, that an assertion fails with a particular message, or that a warning is issued, down to the parameters involved. **Testing when using Django** Helpers for comparing instances of Django models. **Testing when using Twisted** Helpers for making assertions about logging when using Twisted. testfixtures-12.4.0/conftest.py000066400000000000000000000021471525773641100165650ustar00rootroot00000000000000from doctest import REPORT_NDIFF, ELLIPSIS from typing import Any try: from sybil import Sybil except ImportError: pass else: from sybil.parsers.doctest import DocTestParser from sybil.parsers.codeblock import PythonCodeBlockParser from sybil.parsers.capture import parse_captures from sybil.parsers.skip import skip from testfixtures import TempDirectory from testfixtures.sybil import FileParser def sybil_setup(namespace: dict[str, Any]) -> None: # _tempdir is in case it's overwritten by a test. namespace['tempdir'] = namespace['_tempdir'] = TempDirectory() def sybil_teardown(namespace: dict[str, Any]) -> None: namespace['_tempdir'].cleanup() pytest_collect_file = Sybil( parsers=[ DocTestParser(optionflags=REPORT_NDIFF|ELLIPSIS), PythonCodeBlockParser(), parse_captures, FileParser('tempdir'), skip, ], patterns=['*.rst', '*.py'], setup=sybil_setup, teardown=sybil_teardown, fixtures=['tmp_path'], exclude='tests/*.py' ).pytest() testfixtures-12.4.0/context7.json000066400000000000000000000001501525773641100170240ustar00rootroot00000000000000{ "url": "https://context7.com/simplistix/testfixtures", "public_key": "pk_t4Wchggqi7DXETwMtHTMF" } testfixtures-12.4.0/docs/000077500000000000000000000000001525773641100153125ustar00rootroot00000000000000testfixtures-12.4.0/docs/Makefile000066400000000000000000000012011525773641100167440ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= uv run sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) testfixtures-12.4.0/docs/api.rst000066400000000000000000000133401525773641100166160ustar00rootroot00000000000000API Reference ============= .. currentmodule:: testfixtures .. Register module targets that docstrings and the changelog cross-reference, without rendering their (empty) module docstrings. .. module:: testfixtures.comparison .. module:: testfixtures.comparers .. currentmodule:: testfixtures Comparing --------- .. autofunction:: compare .. autofunction:: testfixtures.comparison.register .. autoclass:: testfixtures.comparison.CompareContext :members: Comparers ~~~~~~~~~ .. autofunction:: testfixtures.comparers.compare_simple .. autofunction:: testfixtures.comparers.compare_object .. autofunction:: testfixtures.comparers.merge_ignored_attributes .. autofunction:: testfixtures.comparers.compare_exception .. autofunction:: testfixtures.comparers.compare_exception_group .. autofunction:: testfixtures.comparers.compare_with_type .. autofunction:: testfixtures.comparers.compare_sequence .. autofunction:: testfixtures.comparers.compare_generator .. autofunction:: testfixtures.comparers.compare_tuple .. autofunction:: testfixtures.comparers.compare_dict .. autofunction:: testfixtures.comparers.compare_set .. autofunction:: testfixtures.comparers.compare_text .. autofunction:: testfixtures.comparers.compare_bytes .. autofunction:: testfixtures.comparers.compare_call .. autofunction:: testfixtures.comparers.compare_partial .. autofunction:: testfixtures.comparers.compare_path .. autofunction:: testfixtures.comparers.compare_with_fold .. autofunction:: testfixtures.comparers.safe_repr .. autofunction:: testfixtures.comparers.safe_pformat .. autoclass:: testfixtures.comparers.AlreadySeen .. currentmodule:: testfixtures Matchers -------- .. autofunction:: testfixtures.like .. autofunction:: testfixtures.repr_like .. autofunction:: testfixtures.str_like .. autofunction:: testfixtures.sequence .. autofunction:: testfixtures.contains .. autofunction:: testfixtures.unordered .. autofunction:: testfixtures.mapping Comparison objects ------------------ .. autoclass:: Comparison .. autoclass:: ReprComparison .. autoclass:: StrComparison .. autoclass:: TextComparison :members: .. autoclass:: SequenceComparison :members: .. autoclass:: Subset :members: .. autoclass:: Permutation :members: .. autoclass:: MappingComparison :members: .. autoclass:: RangeComparison :members: .. autoclass:: RoundComparison :members: .. py:class:: StringComparison Deprecated alias for :class:`TextComparison`. Note this is *not* the same as :class:`StrComparison`. Capturing --------- .. autoclass:: LogCapture :members: .. autoclass:: LoggingSource :members: .. autofunction:: log_capture .. autoclass:: testfixtures.logcapture.CaptureSource :members: .. autoclass:: testfixtures.logcapture.Entry :members: .. autoclass:: OutputCapture :members: Mocking ------- .. autoclass:: Replace :members: .. autofunction:: replace_in_environ .. autofunction:: replace_on_class .. autofunction:: replace_in_module .. autoclass:: Replacer :members: :special-members: __call__ .. autofunction:: replace .. autofunction:: mock_date .. autoclass:: testfixtures.datetime.MockDate :members: .. autofunction:: mock_datetime .. autoclass:: testfixtures.datetime.MockDateTime :members: .. autofunction:: mock_time .. autoclass:: testfixtures.datetime.MockTime :members: :special-members: __new__ testfixtures.mock ~~~~~~~~~~~~~~~~~ .. automodule:: testfixtures.mock testfixtures.popen ~~~~~~~~~~~~~~~~~~ .. automodule:: testfixtures.popen :members: .. currentmodule:: testfixtures Assertions ---------- .. autoclass:: testfixtures.shouldraise.NoException .. autoclass:: ShouldRaise :members: .. autoclass:: should_raise .. autofunction:: ShouldAssert .. autoclass:: ShouldWarn :members: .. autoclass:: ShouldNotWarn :members: Commands -------- .. automodule:: testfixtures.command :members: :special-members: __call__ .. currentmodule:: testfixtures Resources --------- .. autoclass:: TempDir :members: :inherited-members: .. autoclass:: TempDirectory :members: :inherited-members: .. autofunction:: tempdir Serialization Formats ~~~~~~~~~~~~~~~~~~~~~ .. automodule:: testfixtures.formats :members: Helpers and Constants --------------------- .. currentmodule:: testfixtures .. autofunction:: diff .. autofunction:: generator .. autofunction:: wrap .. autoclass:: singleton :members: .. data:: not_there A singleton used to represent the absence of a particular attribute or parameter. Framework Helpers ----------------- Framework-specific helpers provided by testfixtures. testfixtures.django ~~~~~~~~~~~~~~~~~~~ .. automodule:: testfixtures.django :members: testfixtures.sybil ~~~~~~~~~~~~~~~~~~ .. autoclass:: testfixtures.sybil.FileParser :member-order: bysource :members: testfixtures.loguru ~~~~~~~~~~~~~~~~~~~ .. automodule:: testfixtures.loguru :members: testfixtures.numpy ~~~~~~~~~~~~~~~~~~ .. automodule:: testfixtures.numpy :members: testfixtures.pandas ~~~~~~~~~~~~~~~~~~~ .. automodule:: testfixtures.pandas :members: testfixtures.polars ~~~~~~~~~~~~~~~~~~~ .. automodule:: testfixtures.polars :members: testfixtures.pydantic ~~~~~~~~~~~~~~~~~~~~~ .. automodule:: testfixtures.pydantic :members: testfixtures.structlog ~~~~~~~~~~~~~~~~~~~~~~ .. automodule:: testfixtures.structlog :members: testfixtures.twisted ~~~~~~~~~~~~~~~~~~~~ .. automodule:: testfixtures.twisted .. autoclass:: testfixtures.twisted.TwistedSource :members: .. autoclass:: testfixtures.twisted.LogCapture :members: .. autodata:: testfixtures.twisted.DEBUG .. autodata:: testfixtures.twisted.INFO .. autodata:: testfixtures.twisted.WARN .. autodata:: testfixtures.twisted.ERROR .. autodata:: testfixtures.twisted.CRITICAL testfixtures-12.4.0/docs/changes.rst000066400000000000000000000001001525773641100174430ustar00rootroot00000000000000 .. currentmodule:: testfixtures .. include:: ../CHANGELOG.rst testfixtures-12.4.0/docs/command.rst000066400000000000000000000264741525773641100174770ustar00rootroot00000000000000Testing command-line scripts ============================ .. invisible-code-block: python try: import loguru except ImportError: loguru = None try: import click except ImportError: click = None .. currentmodule:: testfixtures.command Command line scripts always parse :data:`sys.argv` and exit with an integer return code. Along the way, they often configurate loggers and other services, log their progress or send it to a stream such :data:`~sys.stderr` or :data:`~sys.stdout`. This often involves testing boilerplate which needs to vary slightly across projects, but which has some intricacies that are easy to miss. :class:`Command` aims to make this testing easy and flexible. Take a simple :class:`~argparse.ArgumentParser` script: .. code-block:: python from argparse import ArgumentParser def main() -> None: parser = ArgumentParser() parser.add_argument('message') args = parser.parse_args() print(f"Your message: {args.message}") This can be tested as follows: >>> from testfixtures import Command >>> Command(main).run('my message').check(output='Your message: my message') We can also test what happens if the required argument is not provided. The program name :mod:`argparse` puts in its messages varies with how the tests are run, so we use a :class:`~testfixtures.TextComparison` to match it: >>> from testfixtures import TextComparison as S >>> Command(main).run().check( ... output=S( ... r'usage: .+ \[-h\] message\n' ... r'.+: error: the following arguments are required: message' ... ), ... return_code=2, ... ) .. skip: start if(click is None, reason="No click installed") If we now implement our entrypoint with Click instead: .. code-block:: python import click @click.command() @click.argument('message') def main(message: str) -> None: click.echo(f"Your message: {message}") Our test doesn't change: >>> Command(main).run('my message').check(output='Your message: my message') .. skip: end If logging is used instead of :func:`print`: .. code-block:: python from argparse import ArgumentParser import logging def main() -> None: parser = ArgumentParser() parser.add_argument('message') args = parser.parse_args() logging.info(f"Your message: %s", args.message) Testing is just as simple: >>> Command(main).run('my message').check(logging=[('INFO', 'Your message: my message')]) Customising setup ----------------- Most scripts that log instead of print will configure their logging framework and often offer a way of specifying the log level on the command line: .. code-block:: python from argparse import ArgumentParser import logging def main() -> None: parser = ArgumentParser() parser.add_argument('message') parser.add_argument('-l', '--log-level', default='INFO') args = parser.parse_args() logging.basicConfig(level=getattr(logging, args.log_level.upper())) logging.info(f"Your message: %s", args.message) To test this, we can override :meth:`~AbstractRun.setup_mocks` to intercept and record the configuration call: .. code-block:: python from testfixtures import Run as DefaultRun, Replacer from testfixtures.mock import Mock, call class Run(DefaultRun): @classmethod def setup_mocks(cls, replace: Replacer) -> Mock: mocks = Mock() replace.in_module(logging.basicConfig, mocks.basicConfig) return mocks Now we can check that logging is set up correctly, as well as preventing the configuration call from getting in the way of the logging capture: >>> Command(main, Run).run('my message').check( ... logging=[('INFO', 'Your message: my message')], ... mock_calls=[call.basicConfig(level=20)], ... ) We can also check that the log level option works as intended: >>> Command(main, Run).run('-l', 'warning', 'would not be logged').check( ... logging=[('INFO', 'Your message: would not be logged')], ... mock_calls=[call.basicConfig(level=30)], ... ) .. skip: start if(loguru is None, reason="No loguru installed") If we decided to switch to :doc:`loguru ` for our logging: .. code-block:: python from argparse import ArgumentParser from loguru import logger import sys def main() -> None: parser = ArgumentParser() parser.add_argument('message') parser.add_argument('-l', '--log-level', default='INFO') args = parser.parse_args() logger.add(sys.stderr, format="{time} {level} {message}", level=args.log_level) logger.info(f"Your message: {args.message}") We can additionally override :meth:`~AbstractRun.setup_logging`: .. code-block:: python from testfixtures import Run as DefaultRun, LogCapture, Replacer, like from testfixtures.mock import Mock, call from testfixtures.loguru import LoguruSource from io import StringIO from loguru._logger import Logger class Run(DefaultRun): @classmethod def setup_logging(cls) -> LogCapture: return LogCapture(LoguruSource()) @classmethod def setup_mocks(cls, replace: Replacer) -> Mock: mocks = Mock() replace.on_class(Logger.add, mocks.logger.add) return mocks The checking of logging doesn't change, but the mock calls do: >>> Command(main, Run).run('my message').check( ... logging=[('INFO', 'Your message: my message')], ... mock_calls=[ ... call.logger.add(like(StringIO), format='{time} {level} {message}', level='INFO') ... ], ... ) .. note:: The ``like(StringIO)`` is necessary because by the time the script's :meth:`!logger.add` call is reached, :class:`Command` has already mocked :data:`sys.stderr` with an :class:`~testfixtures.OutputCapture`. .. skip: end Customising checks ------------------ If we have a script that always logs a debug message on startup: .. code-block:: python import logging, sys def main() -> None: logging.debug('starting') logging.info(f'action: {sys.argv[1]}') Our testing can be made more succinct by overriding :meth:`~AbstractRun.check_logging`: .. code-block:: python from collections.abc import Sequence from testfixtures import Run as DefaultRun, LogCapture, like from testfixtures.command import CheckResult, check_logging class Run(DefaultRun): @staticmethod def check_logging( expected: Sequence[tuple[str, ...] | str], logging: LogCapture ) -> CheckResult: return check_logging((('DEBUG', 'starting'),) + tuple(expected), logging) Now we only need to specify the action logging in each test: >>> Command(main, Run).run('wave').check(logging=[('INFO', 'action: wave')]) Similarly, if we have a script that should only ever write to :data:`sys.stdout`: .. code-block:: python import logging, sys def main() -> None: action = sys.argv[1] print(f'action: {action}', file=sys.stderr if action == 'error' else sys.stdout) We can override both :meth:`~AbstractRun.setup_output` and :meth:`~AbstractRun.check_output`: .. code-block:: python from testfixtures import Run as DefaultRun, OutputCapture, like from testfixtures.command import CheckResult, check_logging class Run(DefaultRun): @classmethod def setup_output(cls) -> OutputCapture: return OutputCapture(separate=True) @staticmethod def check_output(expected: str, output: OutputCapture) -> CheckResult: return CheckResult( 'output', output.compare(stdout=expected, stderr='', raises=False) ) Now we can check our script as follows: >>> Command(main, Run).run('wave').check(output='action: wave') But if there is an error, we will be able to see the output in the failure: >>> Command(main, Run).run('error').check(output='action: ?') Traceback (most recent call last): ... AssertionError: output: dict not as expected: values differ: 'stderr': '' (expected) != 'action: error' (actual) 'stdout': 'action: ?' (expected) != '' (actual) While comparing ['stderr']: '' (expected) != 'action: error' (actual) While comparing ['stdout']: 'action: ?' (expected) != '' (actual) Customising everything ---------------------- A script or set of scripts may have a well defined approach that doesn't match the default :meth:`Run.check` signature, such as always allowing the log level to be set, always logging and never writing to :data:`~sys.stderr` or :data:`~sys.stdout`: .. skip: start if(click is None or loguru is None, reason="No click or loguru installed") .. code-block:: python import click, sys from loguru import logger @click.group() @click.option( "--log-level", default="INFO", show_default=True, type=click.Choice(["DEBUG", "INFO", "ERROR"], case_sensitive=False), ) def cli(log_level): logger.remove() logger.add(sys.stderr, level=log_level.upper()) @cli.command() def build(): logger.info("building") @cli.command() def deploy(): logger.info("deploying") For ergonomic testing of this family of commands, in addition to overriding :meth:`~AbstractRun.setup_logging` and :meth:`~AbstractRun.setup_mocks`, we can also implement our own :meth:`~Run.check` method: .. code-block:: python from collections.abc import Sequence from io import StringIO from testfixtures import like, LogCapture, Replacer from testfixtures.command import AbstractRun from testfixtures.mock import Mock, call from testfixtures.loguru import LoguruSource from loguru import logger from loguru._logger import Logger class Run(AbstractRun): @classmethod def setup_logging(cls) -> LogCapture: return LogCapture(LoguruSource()) @classmethod def setup_mocks(cls, replace: Replacer) -> Mock: original_remove = Logger.remove mocks = Mock() def remove(self, handler_id: int = None) -> None: # Allow setup_logging to uninstall its handler: if handler_id: original_remove(self, handler_id) else: mocks.logger.remove() replace.on_class(Logger.remove, remove) replace.on_class(Logger.add, mocks.logger.add) return mocks def check( self, logging: Sequence[tuple[str, str]] = (), log_level: str = 'INFO', return_code: int = 0, ) -> None: self.check_results( self.check_output(output=self.output, expected=''), self.check_return_code(return_code, self.return_code), self.check_logging(logging, self.logging), self.check_mock_calls( [call.logger.remove(), call.logger.add(like(StringIO), level=log_level)], self.mocks ), ) command = Command(cli, runner=Run) Now out individual tests are succinct and easy to read while still testing their full implementation on each call: >>> command.run('build').check(logging=[('INFO', 'building')]) >>> result = command.run('--log-level', 'debug', 'deploy') >>> result.check(logging=[('INFO', 'deploying')], log_level='DEBUG') .. skip: end testfixtures-12.4.0/docs/comparing.rst000066400000000000000000001023431525773641100200260ustar00rootroot00000000000000Finding and explaining differences ================================== .. currentmodule:: testfixtures .. invisible-code-block: python from collections import namedtuple try: import polars except ImportError: polars = None The :func:`compare` function checks that two values are equal and, when they are not, explains *how* they differ. Reach for it instead of :meth:`~unittest.TestCase.assertEqual` or a plain ``assert``: it offers much more :ref:`flexible ` and :ref:`configurable ` comparison, and far clearer feedback when a check fails, particularly for deeply nested data structures, objects that don't :ref:`natively support equality checking `, and objects that do :ref:`silly things ` when compared. By default a failed comparison is raised as an :class:`AssertionError`, so it reads as an assertion in your tests. If you would rather examine the difference yourself, pass ``raises=False`` and :func:`compare` returns the explanation as text instead of raising it. If, instead of checking for *exact* equality, you want to assert that a value merely *matches* a specification, such as a partial object, a number within a range, a string matching a pattern, or a sequence in any order, use the flexible :ref:`comparison objects and matchers `. They slot into the expected side of :func:`compare`, and into plain ``assert`` statements. Comparing expected and actual ----------------------------- In its simplest form, :func:`compare` takes two values and raises an :class:`AssertionError` if they are not equal: >>> from testfixtures import compare >>> compare(1, 2) Traceback (most recent call last): ... AssertionError: 1 != 2 The expected and actual value can also be explicitly supplied, making it clearer as to what has gone wrong: >>> compare(expected=1, actual=2) Traceback (most recent call last): ... AssertionError: 1 (expected) != 2 (actual) Only one of these needs to be specified, the other will then be inferred: >>> actual = '123' + '456' >>> compare(actual, expected='123457') Traceback (most recent call last): ... AssertionError: '123457' (expected) != '123456' (actual) However, if there are more specific labels that would be more useful, they can be supplied: >>> compare(1001, 1002, x_label='realised', y_label='unrealised') Traceback (most recent call last): ... AssertionError: 1001 (realised) != 1002 (unrealised) A prefix can also be specified for the message to be used in the event of failure: >>> compare(expected=1, actual=2, prefix='wrong number of orders') Traceback (most recent call last): ... AssertionError: wrong number of orders: 1 (expected) != 2 (actual) You can also optionally specify a suffix, which will be appended to the message on a new line: >>> compare(expected=1, actual=2, suffix='(Except for very large values of 1)') Traceback (most recent call last): ... AssertionError: 1 (expected) != 2 (actual) (Except for very large values of 1) Sometimes the feedback you wish to provide can be expensive to compute, and so you will only want to do this in the event the comparison fails. This can be done by providing a callable to either ``prefix`` or ``suffix``: >>> compare(expected=1, actual=2, suffix=lambda: 'This is very expensive to compute...') Traceback (most recent call last): ... AssertionError: 1 (expected) != 2 (actual) This is very expensive to compute... The real strengths of :func:`compare` show when comparing more complex and nested data: for many built-in types it pinpoints *where* the values differ rather than simply reporting that they are unequal. See :ref:`compare-types` for the full catalog of how each type is handled. .. _flexible: Controlling the comparison -------------------------- Several keyword arguments change how :func:`compare` decides whether two values are equal. .. _strict-comparison: Strict comparison ~~~~~~~~~~~~~~~~~ By default, :func:`compare` is lenient: it accepts any reasonable notion of equality. It treats values of different but compatible types as equal, and lets some comparers apply their own tolerances. That is usually what you want when checking a result. Strict mode removes that leniency at every level. It asks whether the two values are *identical*: of exactly the same type, with their contents inspected directly and no tolerance applied anywhere. Reach for it when a difference the default would forgive actually matters. The most obvious leniency is over type. By default a :class:`list` matches a :class:`tuple` with the same items, but strict mode rejects it: >>> compare([1, 2], (1, 2)) >>> compare([1, 2], (1, 2), strict=True) Traceback (most recent call last): ... AssertionError: [1, 2] () != (1, 2) () This catches differences between types that are otherwise treated as interchangeable. For example, two different :func:`~collections.namedtuple` classes compare equal by value but not under strict mode: >>> TypeA = namedtuple('A', 'x') >>> TypeB = namedtuple('B', 'x') >>> compare(TypeA(1), TypeB(1)) >>> compare(TypeA(1), TypeB(1), strict=True) Traceback (most recent call last): ... AssertionError: A(x=1) () != B(x=1) () This is also how you assert that a function returns a :ref:`generator ` rather than some other iterable, since by default :func:`compare` unwinds a generator and compares its contents like any other sequence. The same principle reaches into individual comparers: where one would normally allow some leeway, strict mode asks for an exact match instead. For example, :func:`compare` permits the small floating point differences that :doc:`pandas ` and :doc:`polars ` tolerate between dataframes, while strict mode requires them to be exactly equal. .. _ignore-attributes: Ignoring attributes ~~~~~~~~~~~~~~~~~~~ When comparing objects, there may be attributes that you don't care about or cannot easily control, such as timestamps or auto-generated IDs. For example, consider this class: .. code-block:: python from datetime import datetime class MyObject: def __init__(self, name): self.timestamp = datetime.now() self.name = name You can use the ``ignore_attributes`` parameter as follows: >>> obj1 = MyObject('foo') >>> obj2 = MyObject('foo') >>> compare(expected=obj1, actual=obj2, ignore_attributes=['timestamp']) You can also specify which attributes to ignore on a per-type basis by passing a dictionary mapping types to sets of attribute names: >>> class OtherObject: ... def __init__(self, id, value): ... self.id = id ... self.value = value >>> compare( ... expected=[MyObject('x'), OtherObject(1, 'y')], ... actual=[MyObject('x'), OtherObject(2, 'y')], ... ignore_attributes={MyObject: {'timestamp'}, OtherObject: {'id'}} ... ) .. _ignore-eq: Ignoring ``__eq__`` ~~~~~~~~~~~~~~~~~~~ .. warning:: If you find yourself in a situation where objects incorrectly express equality, be very careful to ensure that you see any tests you implement failing due to inequality before you assume that anything described in this section is working as you expect. Equality checking is complex, and there are gotchas lurking with container types and objects on either side of an equality check implementing ``__eq__``. Some objects, such as :doc:`pandas ` and :doc:`polars ` dataframes and :doc:`Django ORM objects`, make unfortunate choices in their implementations of ``__eq__`` when it comes to checking that objects have identical attributes. Since :func:`compare` normally relies on this, it can result in objects appearing to be equal when they are not. Take this class, for example: .. code-block:: python class OrmObj: def __init__(self, a): self.a = a def __eq__(self, other): return True def __repr__(self): return 'OrmObj: '+str(self.a) If we compare normally, we erroneously understand the objects to be equal: >>> compare(actual=OrmObj(1), expected=OrmObj(2)) In order to get a correct comparison, we need to use the ``ignore_eq`` parameter: >>> compare(actual=OrmObj(1), expected=OrmObj(2), ignore_eq=OrmObj) Traceback (most recent call last): ... AssertionError: OrmObj not as expected: attributes differ: 'a': 2 (expected) != 1 (actual) ``ignore_eq`` accepts a single type, an iterable of types, or ``True`` to skip ``__eq__`` for *every* object during the comparison. If a particular type and all of its subclasses are always problematic, you can register it once globally so callers don't need to remember the ``ignore_eq=`` argument: .. invisible-code-block: python from testfixtures.comparing import Registry registry = Registry.initial().install() .. code-block:: python from testfixtures import register register(OrmObj, ignore_eq=True) .. invisible-code-block: python registry.uninstall() Custom containers and ``ignore_eq`` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When you pass a type to ``ignore_eq``, :func:`compare` also has to ignore the ``__eq__`` of any containers that might contain the type passed to ``ignore_eq``. This is handled for standard container types and their subclasses, specifically :class:`list`, :class:`tuple`, :class:`dict`, :class:`set`, and :class:`frozenset`. For custom container or wrapper types that implement ``__eq__`` and don't subclass one of these standard container types, *and* which can contain instances of a type for which you'd like to ``ignore_eq``, you will find that ``ignore_eq`` for the inner type alone is not sufficient. For example, consider a class that groups together the two dataframes used for an evaluation, with an ``__eq__`` that looks reasonable enough: .. skip: start if(polars is None, reason="No polars installed") .. code-block:: python import polars as pl class Eval: def __init__(self, test, train): self.test = test self.train = train def __eq__(self, other): return self.test == other.test and self.train == other.train e1 = Eval(pl.DataFrame({'x': [1, 2]}), pl.DataFrame({'y': [3, 4]})) e2 = Eval(pl.DataFrame({'x': [1, 3]}), pl.DataFrame({'y': [3, 4]})) :class:`~polars.DataFrame` implements ``__eq__`` in a way that returns another :class:`~polars.DataFrame` of element-wise results rather than a single :class:`bool`, so ``Eval.__eq__`` raises :exc:`TypeError` as soon as it tries to use that result in the ``and``: >>> compare(e1, expected=e2) Traceback (most recent call last): ... TypeError: the truth value of a DataFrame is ambiguous Hint: to check if a DataFrame contains any values, use `is_empty()`. If we only pass :class:`~polars.DataFrame` to ``ignore_eq``, ``Eval.__eq__`` still fires first and the comparison still raises: >>> compare(e1, expected=e2, ignore_eq=pl.DataFrame) Traceback (most recent call last): ... TypeError: the truth value of a DataFrame is ambiguous Hint: to check if a DataFrame contains any values, use `is_empty()`. We need to pass both the inner type and :class:`!Eval` to ``ignore_eq``: >>> compare(e1, expected=e2, ignore_eq=[pl.DataFrame, Eval]) Traceback (most recent call last): ... AssertionError: Eval not as expected: attributes same: ['train'] attributes differ: 'test': shape: (2, 1) ┌─────┐ │ x │ │ --- │ │ i64 │ ╞═════╡ │ 1 │ │ 3 │ └─────┘ (expected) != shape: (2, 1) ┌─────┐ │ x │ │ --- │ │ i64 │ ╞═════╡ │ 1 │ │ 2 │ └─────┘ (actual) While comparing .test: DataFrames are different (value mismatch for column "x") [left]: shape: (2,) Series: 'x' [i64] [ 1 3 ] [right]: shape: (2,) Series: 'x' [i64] [ 1 2 ] :class:`~polars.DataFrame` is already registered with ``ignore_eq=True`` if Polars is installed, so if :class:`!Eval` is used a lot, the only registration you need to add yourself is for :class:`!Eval` itself: .. invisible-code-block: python from testfixtures.comparing import Registry from testfixtures.polars import compare_dataframe registry = Registry.initial().install() register(pl.DataFrame, compare_dataframe, ignore_eq=True) .. code-block:: python from testfixtures import register register(Eval, ignore_eq=True) >>> compare(e1, expected=e2) Traceback (most recent call last): ... AssertionError: Eval not as expected: attributes same: ['train'] attributes differ: 'test': shape: (2, 1) ┌─────┐ │ x │ │ --- │ │ i64 │ ╞═════╡ │ 1 │ │ 3 │ └─────┘ (expected) != shape: (2, 1) ┌─────┐ │ x │ │ --- │ │ i64 │ ╞═════╡ │ 1 │ │ 2 │ └─────┘ (actual) While comparing .test: DataFrames are different (value mismatch for column "x") [left]: shape: (2,) Series: 'x' [i64] [ 1 3 ] [right]: shape: (2,) Series: 'x' [i64] [ 1 2 ] .. invisible-code-block: python registry.uninstall() .. skip: end .. _recursion: Nested and recursive comparison ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Where :func:`compare` is able to provide a descriptive comparison for a particular type, it will then recurse to do the same for the elements contained within objects of that type. For example, when comparing a list of dictionaries, the description will not only tell you where in the list the difference occurred, but also what the differences were within the dictionaries that weren't equal: >>> compare([{'one': 1}, {'two': 2, 'text':'foo\nbar\nbaz'}], ... [{'one': 1}, {'two': 2, 'text':'foo\nbob\nbaz'}]) Traceback (most recent call last): ... AssertionError: sequence not as expected: same: [{'one': 1}] first: [{'text': 'foo\nbar\nbaz', 'two': 2}] second: [{'text': 'foo\nbob\nbaz', 'two': 2}] While comparing [1]: dict not as expected: same: ['two'] values differ: 'text': 'foo\nbar\nbaz' != 'foo\nbob\nbaz' While comparing [1]['text']: --- first +++ second @@ -1,3 +1,3 @@ foo -bar +bob baz This also applies to any comparer you provide, as shown under :ref:`comparer-register`. Preventing infinite recursion ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ If an object refers back to itself, directly or via something it contains, the recursive comparison used by :func:`compare` would loop forever. To avoid this, if an object is seen more than once during a comparison, it is wrapped with an :class:`~testfixtures.comparers.AlreadySeen` marker rather than being compared again. When that happens *and* a difference is being reported anyway, the marker becomes visible in the output: >>> ouroboros1 = {} >>> ouroboros1['ouroboros'] = ouroboros1 >>> ouroboros2 = {} >>> ouroboros2['ouroboros'] = ouroboros2 >>> compare({1: ouroboros1, 2: 'foo'}, {1: ouroboros2, 2: ouroboros2}) Traceback (most recent call last): ... AssertionError: dict not as expected: same: [1] values differ: 2: 'foo' != {'ouroboros': } While comparing [2]: not equal: 'foo' The ``at [1]`` part of the marker is the path where that object was first encountered, so you can trace the cycle back to its origin. If the same object appears at the same position in both sides of a comparison :func:`compare` treats it as equal by identity without calling its ``__eq__``. No marker is visible in that case, and an :ref:`unhelpful ` ``__eq__`` cannot cause a spurious difference. .. _compare-types: How each type is compared ------------------------- Where :func:`compare` recognises the type of the values it is given, it produces a description tailored to that type, pinpointing exactly what differs, and :ref:`recurses ` into the elements those values contain. The following sections show the feedback given for each supported type. sets ~~~~ Comparing sets that aren't the same will attempt to highlight where the differences lie: >>> compare(expected={1, 2}, actual={2, 3}) Traceback (most recent call last): ... AssertionError: set not as expected: in expected but not actual: [1] in actual but not expected: [3] See :ref:`SequenceComparison ` to assert only that certain items are present, regardless of order. dicts ~~~~~ Comparing dictionaries that aren't the same will attempt to highlight where the differences lie: >>> compare(expected=dict(x=1, y=2, a=4), actual=dict(x=1, z=3, a=5)) Traceback (most recent call last): ... AssertionError: dict not as expected: same: ['x'] in expected but not actual: 'y': 2 in actual but not expected: 'z': 3 values differ: 'a': 4 (expected) != 5 (actual) See :ref:`MappingComparison ` to assert only that certain keys are present, or to check the order of keys. lists and tuples ~~~~~~~~~~~~~~~~ Comparing lists or tuples that aren't the same will attempt to highlight where the differences lie: >>> compare(expected=[1, 2, 3], actual=[1, 2, 4]) Traceback (most recent call last): ... AssertionError: sequence not as expected: same: [1, 2] expected: [3] actual: [4] See :ref:`SequenceComparison ` to compare without regard to order, or to assert only that certain items are present. namedtuples ~~~~~~~~~~~ When two :func:`~collections.namedtuple` instances are compared, if they are of the same type, the description given will highlight which elements were the same and which were different: >>> from collections import namedtuple >>> TestTuple = namedtuple('TestTuple', 'x y z') >>> compare(expected=TestTuple(1, 2, 3), actual=TestTuple(1, 4, 3)) Traceback (most recent call last): ... AssertionError: TestTuple not as expected: same: ['x', 'z'] values differ: 'y': 2 (expected) != 4 (actual) .. _compare-generators: generators ~~~~~~~~~~ When two generators are compared, they are both first unwound into tuples and those tuples are then compared. The :ref:`generator ` helper is useful for creating a generator to represent the expected results: >>> from testfixtures import generator >>> def my_gen(t): ... i = 0 ... while i>> compare(expected=generator(1, 2, 3), actual=my_gen(2)) Traceback (most recent call last): ... AssertionError: sequence not as expected: same: (1, 2) expected: (3,) actual: () See :ref:`SequenceComparison ` to compare the unwound results without regard to order, or to assert only that certain items are present. .. warning:: If you wish to assert that a function returns a generator, say, for performance reasons, then you should use :ref:`strict comparison `. .. _compare-strings: strings ~~~~~~~ Comparison of strings can be tricky, particularly when those strings contain multiple lines; spotting the differences between the expected and actual values can be hard. To help with this, long strings give a more helpful representation when comparison fails: >>> compare(expected="1234567891011", actual="1234567789") Traceback (most recent call last): ... AssertionError: '1234567891011' (expected) != '1234567789' (actual) Likewise, multi-line strings give unified diffs when their comparison fails: >>> compare( ... expected=""" ... This is line 1 ... This is line 2 ... This is line 3 ... """, ... actual=""" ... This is line 1 ... This is another line ... This is line 3 ... """ ... ) Traceback (most recent call last): ... AssertionError: --- expected +++ actual @@ -1,5 +1,5 @@ This is line 1 - This is line 2 + This is another line This is line 3 Such comparisons can still be confusing as white space is taken into account. If you need to care about whitespace characters, you can make spotting the differences easier as follows: >>> compare("\tline 1\r\nline 2"," line1 \nline 2", show_whitespace=True) Traceback (most recent call last): ... AssertionError: --- first +++ second @@ -1,2 +1,2 @@ -'\tline 1\r\n' +' line1 \n' 'line 2' However, you may not care about some of the whitespace involved. To help with this, :func:`compare` has two options that can be set to ignore certain types of whitespace. If you wish to compare two strings that contain blank lines or lines containing only whitespace characters, but where you only care about the content, you can use the following: .. code-block:: python compare( expected='line1\nline2', actual='line1\n \nline2\n\n', blanklines=False ) If you wish to compare two strings made up of lines that may have trailing whitespace that you don't care about, you can do so with the following: .. code-block:: python compare( expected='line1\nline2', actual='line1 \t\nline2 \n', trailing_whitespace=False ) See :ref:`TextComparison ` to assert that a string matches a regular expression instead of comparing it exactly. .. _compare-datetime: datetimes and times ~~~~~~~~~~~~~~~~~~~ Given the following two :class:`~datetime.datetime` objects: >>> from datetime import datetime >>> from zoneinfo import ZoneInfo >>> t1 = datetime(2024, 10, 27, 1, fold=0, tzinfo=ZoneInfo('Europe/London')) >>> str(t1) '2024-10-27 01:00:00+01:00' >>> t2 = datetime(2024, 10, 27, 1, fold=1, tzinfo=ZoneInfo('Europe/London')) >>> str(t2) '2024-10-27 01:00:00+00:00' It may well be surprising to find out that Python considers them equivalent: >>> t1 == t2 True Unfortunately, that also means that :func:`compare` will also consider them equal: >>> compare(t1, t2) If it is important for you to be able to check you have the correct point in time, then you can use :ref:`strict comparison `, which will highlight the difference: >>> compare(t1, t2, strict=True) Traceback (most recent call last): ... AssertionError: datetime.datetime(2024, 10, 27, 1, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')) != datetime.datetime(2024, 10, 27, 1, 0, fold=1, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')) This problem can also be seen with :class:`~datetime.time` objects, where given the following two times: >>> from datetime import time >>> t1 = time(1, 30, fold=0) >>> str(t1) '01:30:00' >>> t2 = time(1, 30, fold=1) >>> str(t2) '01:30:00' The times will be considered equal: >>> t1 == t2 True >>> compare(t1, t2) However, once again, strict comparison will highlight the difference: >>> compare(t1, t2, strict=True) Traceback (most recent call last): ... AssertionError: datetime.time(1, 30) != datetime.time(1, 30, fold=1) .. _comparer-objects: objects ~~~~~~~ Even if your objects do not natively support comparison, when they are compared they will be considered identical if they are of the same type and have identical attributes. Take instances of this class as an example: .. code-block:: python class MyObject: def __init__(self, name): self.name = name def __repr__(self): return '' If the attributes and type of instances are the same, they will be considered equal: >>> compare(MyObject('foo'), expected=MyObject('foo')) However, if their attributes differ, you will get an informative error: >>> compare(MyObject('foo'), expected=MyObject('bar')) Traceback (most recent call last): ... AssertionError: MyObject not as expected: attributes differ: 'name': 'bar' (expected) != 'foo' (actual) While comparing .name: 'bar' (expected) != 'foo' (actual) This type of comparison is also used on objects that make use of ``__slots__``. To compare only some of an object's attributes, see :ref:`ignore-attributes`, or use the partial :func:`like` matcher described in :ref:`comparison-objects`. .. _comparer-register: Providing your own comparers ---------------------------- The sections above cover how :func:`compare` performs out of the box. When you need richer feedback for your own types, you can teach :func:`compare` how to compare them. .. note:: If you are reading this section as a result of needing to test objects that don't natively support comparison, or as a result of needing to infrequently compare your own subclasses of python basic types, take a look at :ref:`comparison-objects` as this may well be an easier solution. .. invisible-code-block: python from testfixtures.comparing import Registry from testfixtures.comparers import compare_sequence registry = Registry.initial({list: compare_sequence}).install() As an example, suppose you have a class whose instances have a timestamp and a name as attributes, but you'd like to ignore the timestamp when comparing: .. code-block:: python from datetime import datetime class MyObject: def __init__(self, name): self.timestamp = datetime.now() self.name = name To compare lots of these, you would first write a comparer: .. code-block:: python def compare_my_object(x, y, context): if x.name == y.name: return return 'MyObject named %s != MyObject named %s' % ( context.label('x', repr(x.name)), context.label('y', repr(y.name)), ) Then you'd register that comparer for your type: .. code-block:: python from testfixtures import register register(MyObject, compare_my_object) Now, it'll get used when comparing objects of that type, even if they're contained within other objects: >>> compare(expected=[1, MyObject('foo')], actual=[1, MyObject('bar')]) Traceback (most recent call last): ... AssertionError: sequence not as expected: same: [1] expected: [] actual: [] While comparing [1]: MyObject named 'foo' (expected) != MyObject named 'bar' (actual) From this example, you can also see that a comparer can indicate that two objects are equal, for :func:`compare`'s purposes, by returning ``None``: >>> MyObject('foo') == MyObject('foo') False >>> compare(MyObject('foo'), MyObject('foo')) You can also see that you can, and should, use the context object passed in to add labels to the representations of the objects being compared if the comparison fails: >>> compare(MyObject('foo'), MyObject('bar'), x_label='stored', y_label='supplied') Traceback (most recent call last): ... AssertionError: MyObject named 'foo' (stored) != MyObject named 'bar' (supplied) It may be that you only want to use a comparer or set of comparers for a particular test. If that's the case, you can pass :func:`compare` a ``comparers`` parameter consisting of a dictionary that maps types to comparers: >>> compare(MyObject('foo'), MyObject('bar'), ... comparers={MyObject: compare_my_object}) Traceback (most recent call last): ... AssertionError: MyObject named 'foo' != MyObject named 'bar' .. invisible-code-block: python registry.uninstall() A full list of the available comparers included can be found below the API documentation for :func:`compare`. These make good candidates for registering for your own classes, if they provide the necessary behaviour, and their source is also good to read when wondering how to implement your own comparers. .. note:: A comparer should always return some text when it considers the two objects it is comparing to be different. .. _custom-comparer-different: Handing off comparison ~~~~~~~~~~~~~~~~~~~~~~ You may be wondering what the ``context`` object passed to the comparer is for: it allows you to hand off comparison of parts of the two objects currently being compared back to the :func:`compare` machinery. For example, you may have an object that has a couple of dictionaries as attributes: .. code-block:: python class Request: def __init__(self, uri, headers, body): self.uri = uri self.headers = headers self.body = body When your tests encounter instances of these that are not as expected, you want feedback about which bits of the request or response weren't as expected. This can be achieved by implementing a comparer as follows: .. code-block:: python def compare_request(x, y, context): uri_different = x.uri != y.uri headers_different = context.different(x.headers, y.headers, '.headers') body_different = context.different(x.body, y.body, '.body') if uri_different or headers_different or body_different: return f'Request for {x.uri!r} != Request for {y.uri!r}' Here's this custom request comparer in action: >>> compare(Request('/foo', {'method': 'POST'}, {'my_field': 'value_1'}), ... Request('/foo', {'method': 'GET'}, {'my_field': 'value_2'}), ... comparers={Request: compare_request}) Traceback (most recent call last): ... AssertionError: Request for '/foo' != Request for '/foo' While comparing .headers: dict not as expected: values differ: 'method': 'POST' != 'GET' While comparing .headers['method']: 'POST' != 'GET' While comparing .body: dict not as expected: values differ: 'my_field': 'value_1' != 'value_2' While comparing .body['my_field']: 'value_1' != 'value_2' .. note:: A comparer should always return some text when it considers the two objects it is comparing to be different. .. _custom-comparer-options: Options for custom comparers ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ As an example of passing options through to a comparer, suppose you wanted to compare all decimals in a nested data structure by rounding them to a number of decimal places that varies from test to test. The comparer could be implemented and registered as follows: .. invisible-code-block: python registry = Registry.initial().install() .. code-block:: python from decimal import Decimal from testfixtures import register def compare_decimal(x, y, context, precision: int = 2): if round(x, precision) != round(y, precision): return f'{x!r} != {y!r} when rounded to {precision} places' register(Decimal, compare_decimal) Now, this comparer will be used for comparing all decimals and the precision used will be that passed to :func:`compare`: >>> expected_order = {'price': Decimal('1.234'), 'quantity': 5} >>> actual_order = {'price': Decimal('1.236'), 'quantity': 5} >>> compare(expected_order, actual_order, precision=1) >>> compare(expected_order, actual_order, precision=3) Traceback (most recent call last): ... AssertionError: dict not as expected: same: ['quantity'] values differ: 'price': Decimal('1.234') != Decimal('1.236') While comparing ['price']: Decimal('1.234') != Decimal('1.236') when rounded to 3 places If no precision is passed, the default of ``2`` will be used: >>> compare(Decimal('2.006'), Decimal('2.009')) >>> compare(Decimal('2.001'), Decimal('2.009')) Traceback (most recent call last): ... AssertionError: Decimal('2.001') != Decimal('2.009') when rounded to 2 places If you only need to compare numbers approximately or within a range, the :ref:`RoundComparison ` and :ref:`RangeComparison ` objects may be simpler than a custom comparer. Ignoring attributes in custom comparers ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When writing custom comparers that delegate to :func:`~testfixtures.comparers.compare_object`, you may want to always ignore certain attributes while still allowing users to pass additional attributes to ignore via the ``ignore_attributes`` parameter to :func:`compare`. The :func:`~testfixtures.comparers.merge_ignored_attributes` function makes this easy by combining multiple ignore specifications: .. code-block:: python from testfixtures.comparers import compare_object, merge_ignored_attributes class Thing: def __init__(self, **kw): for k, v in kw.items(): setattr(self, k, v) def compare_thing(x, y, context): # Always ignore 'y' attribute, plus any user-specified ignores context_ignored = context.options.get('ignore_attributes') ignored = merge_ignored_attributes('y', context_ignored) return compare_object(x, y, context, ignore_attributes=ignored) Now the ``y`` attribute will always be ignored, but users can still specify additional attributes to ignore: >>> compare(Thing(x=1, y=2), Thing(x=1, y=3), ... comparers={Thing: compare_thing}) >>> compare(Thing(x=1, y=2, z=3), Thing(x=1, y=4, z=5), ... comparers={Thing: compare_thing}, ... ignore_attributes=['z']) .. invisible-code-block: python registry.uninstall() Rendering objects safely in custom comparers ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When a custom comparer builds a message it usually needs to render the objects it is comparing. If one of those objects raises an exception as part of that process, the caller sees an unrelated traceback instead of any useful comparison message. :func:`~testfixtures.comparers.safe_repr` and :func:`~testfixtures.comparers.safe_pformat` are drop-in replacements for :func:`repr` and :func:`pprint.pformat` that catch exceptions, but not :class:`BaseException` instances such as :exc:`KeyboardInterrupt` or :exc:`SystemExit`, and substitute any failures with a marker. For example, an object whose ``__repr__`` raises yields a marker instead of propagating the exception: >>> from testfixtures import safe_repr >>> class Broken: ... def __repr__(self): ... raise ValueError('boom') >>> print(safe_repr(Broken())) testfixtures-12.4.0/docs/comparison.rst000066400000000000000000000531241525773641100202230ustar00rootroot00000000000000.. _comparison-objects: Comparison objects and matchers =============================== .. currentmodule:: testfixtures .. invisible-code-block: python from testfixtures import compare from testfixtures.compat import PY_312_PLUS Often you want to assert that a value *matches* a specification rather than being exactly equal to something: that it is an instance of a particular type, that only some of its attributes matter, that a number falls within a range, or that a string matches a pattern. Functions and objects are provided that express these expectations and slot into the expected side of :func:`compare`, or into a plain ``assert``. The typed matchers :func:`like`, :func:`repr_like`, :func:`str_like`, :func:`sequence`, :func:`contains`, :func:`unordered` and :func:`mapping` are the place to start. They are typed to match the values you compare against, so they keep type checkers such as `mypy`__ happy. :func:`like` also accepts a string or compiled regular expression, for matching a string value against a pattern. Under the hood they build the comparison objects described below, which you can also construct directly when you need one that has no matcher. __ https://mypy-lang.org/ Some expectations have no matcher and are used as objects directly: :ref:`RangeComparison ` and :ref:`RoundComparison ` for numbers. The examples below use these dataclasses: .. code-block:: python from dataclasses import dataclass @dataclass class SampleClass: x: int y: str @dataclass class Container: items: list[SampleClass] @dataclass class TupleContainer: items: tuple[SampleClass, ...] Instance matchers ----------------- The :func:`~testfixtures.like`, :func:`~testfixtures.repr_like` and :func:`~testfixtures.str_like` functions check an object's type along with its attributes, :func:`repr` or :class:`str`, and are typed to match the value being compared so they slot into strictly typed code. Partial comparisons with ``like()`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The :func:`~testfixtures.like` function creates partial object comparisons that are typed to match the class being compared: >>> from testfixtures import compare, like >>> expected: list[SampleClass] = [like(SampleClass, x=1)] >>> compare(expected, actual=[SampleClass(1, '2')]) You can use :func:`~testfixtures.like` anywhere you need a partial comparison, including in assertions: >>> expected: SampleClass = like(SampleClass) >>> assert expected == SampleClass(1, '2') >>> assert expected == SampleClass(3, '4') When passed a type and, optionally, attributes, ``like()`` builds a partial :ref:`Comparison `. Reach for a :ref:`Comparison ` directly when you need to match by type alone, by dotted import path, against an existing instance, or matching exactly rather than partially. When passed a regular expression pattern, either as a string or an already compiled :class:`re.Pattern`, :func:`~testfixtures.like` returns a :class:`TextComparison` typed as a :class:`str`: >>> expected_str: str = like(r'Starting thread \d+') >>> compare(expected_str, actual='Starting thread 132356') Matching ``repr()`` or ``str()`` with ``repr_like()`` and ``str_like()`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The :func:`~testfixtures.repr_like` and :func:`~testfixtures.str_like` functions do the same for :class:`ReprComparison` and :class:`StrComparison`, returning a value typed as the compared type while checking its :func:`repr` or :class:`str`: >>> from testfixtures import repr_like, str_like >>> by_repr: KeyError = repr_like(KeyError, "KeyError('foo')") >>> compare(by_repr, actual=KeyError('foo')) >>> by_str: KeyError = str_like(KeyError, "'foo'") >>> compare(by_str, actual=KeyError('foo')) Sequence matchers ----------------- :func:`sequence`, :func:`contains` and :func:`unordered` compare sequences flexibly and all return :ref:`SequenceComparison ` objects. :func:`contains` and :func:`unordered` are the typed equivalents of the :class:`Subset` and :class:`Permutation` shortcuts. Configurable sequence comparisons with ``sequence()`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The :func:`~testfixtures.sequence` function builds a flexible sequence matcher with control over ordering, partial matching and recursion. By default the items must match exactly and in order. Pass ``partial=True`` to ignore any additional items: >>> from testfixtures import sequence >>> compare(expected=sequence(partial=True)([1, 2]), actual=[1, 2, 3]) If you only care that certain items are present, :func:`contains` says this more concisely. Pass ``ordered=False`` to ignore the order of the items: >>> compare(expected=sequence(ordered=False)([2, 1]), actual=[1, 2]) For a full match in any order, :func:`unordered` says this more concisely. Pass ``recursive=True`` to explain the first item that differs rather than just listing the items that were not matched: >>> compare(expected=sequence(recursive=True)([{'k': 1}]), actual=[{'k': 2}]) Traceback (most recent call last): ... AssertionError: not equal: same: [] expected: [{'k': 1}] actual: [{'k': 2}] While comparing [0]: dict not as expected: values differ: 'k': 1 (expected) != 2 (actual) (expected) [{'k': 2}] (actual) Type checkers will complain unless :class:`!TupleContainer` is instantiated with a :class:`tuple` of :class:`!SampleClass` instances, so pass the ``returns`` parameter for the result to be typed correctly: >>> actual = TupleContainer((SampleClass(1, 'x'), SampleClass(2, 'x'))) >>> compare( ... actual, ... expected=TupleContainer( ... sequence(returns=tuple[SampleClass, ...])([ ... SampleClass(1, 'x'), ... SampleClass(2, 'x'), ... ]) ... ), ... ) To assert that a value is specifically a generator rather than just something that yields the right items, build the expected value with :func:`generator` and use :ref:`strict comparison `. Checking for item presence with ``contains()`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The :func:`~testfixtures.contains` function checks that specified items are present, regardless of order or additional items: >>> from testfixtures import contains >>> actual = Container([SampleClass(1, '2'), SampleClass(3, '4'), SampleClass(5, '6')]) >>> compare( ... actual, ... expected=Container(contains([SampleClass(1, '2'), SampleClass(3, '4')])) ... ) Use the ``returns`` parameter when needed for type compatibility: >>> actual = TupleContainer((SampleClass(1, '2'), SampleClass(3, '4'), SampleClass(5, '6'))) >>> compare( ... actual, ... expected=TupleContainer( ... contains([SampleClass(1, '2')], returns=tuple[SampleClass, ...]) ... ), ... ) Order-independent full matches with ``unordered()`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The :func:`~testfixtures.unordered` function checks that sequences contain exactly the same items, but in any order: >>> from testfixtures import unordered >>> actual = Container([SampleClass(2, 'x'), SampleClass(1, 'x')]) >>> compare( ... actual, ... expected=Container( ... unordered([SampleClass(1, 'x'), SampleClass(2, 'x')]) ... ), ... ) Use the ``returns`` parameter for type compatibility: >>> actual = TupleContainer((SampleClass(2, 'x'), SampleClass(1, 'x'))) >>> compare( ... actual, ... expected=TupleContainer( ... unordered([SampleClass(1, 'x'), SampleClass(2, 'x')], returns=tuple[SampleClass, ...]) ... ), ... ) Mapping matchers ---------------- The :func:`~testfixtures.mapping` function is the mapping equivalent of :func:`sequence`. It builds a typed :ref:`MappingComparison `, so it slots into a dictionary-typed value while keeping type checkers happy. By default the keys and values must match exactly. Pass ``partial=True`` to ignore any additional keys: >>> from testfixtures import mapping >>> expected: dict[str, int] = mapping(partial=True)({'a': 1}) >>> compare(expected, actual={'a': 1, 'b': 2}) Pass ``ordered=True`` to also require the keys to appear in the given order: >>> compare( ... expected=mapping(ordered=True)({'a': 1, 'b': 2}), ... actual={'b': 2, 'a': 1}, ... ) Traceback (most recent call last): ... AssertionError: not equal: wrong key order: same: [] expected: ['a', 'b'] actual: ['b', 'a'] While comparing [0]: 'a' (expected) != 'b' (actual) (expected) {'b': 2, 'a': 1} (actual) As with :func:`sequence`, use the ``returns`` parameter when the mapping is of a different type. See :ref:`MappingComparison ` for the full behaviour. Comparison objects ------------------ The matchers above build these objects, and you can construct them directly. A :ref:`Comparison `, returned by :func:`like`, a :ref:`SequenceComparison `, returned by :func:`sequence`, :func:`contains` and :func:`unordered`, and a :ref:`MappingComparison `, returned by :func:`mapping`, are usually an implementation detail. The rest have no matcher and are meant to be used directly. .. _comparison: ``Comparison`` ~~~~~~~~~~~~~~ A :class:`~testfixtures.Comparison` is what :func:`like` returns. It compares equal to any object of the same type whose attributes match those you specify, even when that type does not support equality itself. For example, take this class: .. code-block:: python class SomeClass: def __init__(self, x, y): self.x, self.y = x, y When a comparison fails, the :class:`~testfixtures.Comparison` will not equal the object it was compared with and its representation changes to give information about what was different: >>> from testfixtures import Comparison >>> c = Comparison(SomeClass, x=2) >>> print(repr(c)) x: 2 >>> c == SomeClass(1, 2) False >>> print(repr(c)) attributes in actual but not Comparison: 'y': 2 attributes differ: 'x': 2 (Comparison) != 1 (actual) .. note:: Some test frameworks and helpers, including :meth:`~unittest.TestCase.assertEqual`, truncate the text shown in assertions. Use :func:`compare` instead, which will give you other desirable behaviour as well as showing you the full output of failed comparisons. There are several ways a comparison can be set up depending on what you want to check. If you only care about the type of an object, you can set up the comparison with only the class: >>> Comparison(SomeClass) == SomeClass(1, 2) True This can also be achieved by specifying the type of the object as a dotted name: >>> import sys >>> Comparison('types.ModuleType') == sys True Alternatively, if you happen to have an object already around, comparison can be done with it: >>> Comparison(SomeClass(1, 2)) == SomeClass(1, 2) True If you only care about certain attributes, this can also easily be achieved by doing a partial comparison: >>> Comparison(SomeClass, x=1, partial=True) == SomeClass(1, 2) True The above can be problematic if you want to compare an object with attributes that share names with parameters to the :class:`~testfixtures.Comparison` constructor. For this reason, you can pass the attributes in a dictionary: >>> compare( ... Comparison(SomeClass, {'partial': 3}, partial=True), ... SomeClass(1, 2), ... ) Traceback (most recent call last): ... AssertionError: not equal: attributes in Comparison but not actual: 'partial': 3 <...SomeClass...> Gotchas ^^^^^^^ - If the object being compared has an ``__eq__`` method, such as Django model instances, then the :class:`~testfixtures.Comparison` must be the first object in the equality check. The following class is an example of this: .. code-block:: python class SomeModel: def __eq__(self,other): if isinstance(other, SomeModel): return True return False It will not work correctly if used as the second object in the expression: >>> SomeModel() == Comparison(SomeModel) False However, if the comparison is correctly placed first, then everything will behave as expected: >>> Comparison(SomeModel)==SomeModel() True - It probably goes without saying, but comparisons should not be used on both sides of an equality check: >>> Comparison(SomeClass) == Comparison(SomeClass) False .. _sequencecomparison: ``SequenceComparison`` ~~~~~~~~~~~~~~~~~~~~~~ When comparing sequences, you may not care about the order of items in the sequence. While this type of comparison can often be achieved by pouring the sequence into a :class:`set`, this may not be possible if the items in the sequence are unhashable, or part of a nested data structure. :class:`SequenceComparison` objects can be used in this case: >>> from testfixtures import compare, SequenceComparison >>> compare( ... expected={'k': SequenceComparison({1}, {2}, ordered=False)}, ... actual={'k': [{2}, {1}]}, ... ) You may also only care about certain items being present in a sequence, but where it is important that those items are in the order you expected. This can also be achieved with :class:`SequenceComparison` objects: >>> compare( ... expected=SequenceComparison(1, 3, 5, partial=True), ... actual=[1, 2, 3, 4, 6], ... ) Traceback (most recent call last): ... AssertionError: not equal: ignored: [2, 4, 6] same: [1, 3] expected: [5] actual: [] (expected) [1, 2, 3, 4, 6] (actual) Where there are differences, they may be hard to spot. In this case, you can ask for a more detailed explanation of what wasn't as expected: >>> compare( ... expected=SequenceComparison({1: 'a'}, {2: 'c'}, recursive=True), ... actual=[{1: 'a'}, {2: 'd'}], ... ) Traceback (most recent call last): ... AssertionError: not equal: same: [{1: 'a'}] expected: [{2: 'c'}] actual: [{2: 'd'}] While comparing [1]: dict not as expected: values differ: 2: 'c' (expected) != 'd' (actual) While comparing [1][2]: 'c' (expected) != 'd' (actual) (expected) [{1: 'a'}, {2: 'd'}] (actual) There are also the :class:`Subset` and :class:`Permutation` shortcuts: >>> from testfixtures import Subset, Permutation >>> assert Subset({1}, {2}) == [{1}, {2}, {3}] >>> assert Permutation({1}, {2}) == [{2}, {1}] .. _mappingcomparison: ``MappingComparison`` ~~~~~~~~~~~~~~~~~~~~~ When comparing mappings such as :class:`dict` and :class:`~collections.OrderedDict`, you may need to check the order of the keys is as you expect. :class:`MappingComparison` objects can be used for this: .. skip: start if(not PY_312_PLUS, reason="Python 3.12 has nicer reprs") >>> from collections import OrderedDict >>> from testfixtures import compare, MappingComparison >>> compare( ... expected=MappingComparison((('a', 1), ('c', 3), ('d', 2)), ordered=True), ... actual=OrderedDict((('a', 1), ('d', 2), ('c', 3))), ... ) Traceback (most recent call last): ... AssertionError: not equal: wrong key order: same: ['a'] expected: ['c', 'd'] actual: ['d', 'c'] (expected) OrderedDict({'a': 1, 'd': 2, 'c': 3}) (actual) You may also only care about certain keys being present in a mapping. This can also be achieved with :class:`MappingComparison` objects: >>> compare( ... expected=MappingComparison(a=1, d=2, partial=True), ... actual={'a': 1, 'c': 3}, ... ) Traceback (most recent call last): ... AssertionError: not equal: ignored: ['c'] same: ['a'] in expected but not actual: 'd': 2 (expected) {'a': 1, 'c': 3} (actual) Where there are differences, they may be hard to spot. In this case, you can ask for a more detailed explanation of what wasn't as expected: >>> compare( ... expected=MappingComparison( ... (('a', [1, 2]), ('d', [1, 3])), ordered=True, recursive=True ... ), ... actual=OrderedDict((('a', [1, 2]), ('d', [1, 4]))), ... ) Traceback (most recent call last): ... AssertionError: not equal: same: ['a'] values differ: 'd': [1, 3] (expected) != [1, 4] (actual) While comparing ['d']: sequence not as expected: same: [1] expected: [3] actual: [4] (expected) OrderedDict({'a': [1, 2], 'd': [1, 4]}) (actual) .. skip: end .. _roundcomparison: ``RoundComparison`` ~~~~~~~~~~~~~~~~~~~ When comparing numerics you often want to be able to compare to a given precision to allow for rounding issues which make precise equality impossible. For these situations, you can use :class:`RoundComparison` objects wherever you would use floats or Decimals, and they will compare equal to any float or Decimal that matches when both sides are rounded to the specified precision. Here's an example: .. code-block:: python from testfixtures import compare, RoundComparison compare( expected=RoundComparison(1234.5678, precision=2), actual=1234.5681, ) .. note:: You should always pass the same type of object to the :class:`RoundComparison` object as you intend to compare it with. If the type of the rounded expected value is not the same as the type of the rounded value it is being compared to, a :class:`TypeError` will be raised. .. _rangecomparison: ``RangeComparison`` ~~~~~~~~~~~~~~~~~~~ When comparing numbers, dates, times and any other type that can be ordered, you may only want to assert what range a value will fall into. :class:`RangeComparison` objects let you confirm a value is within a certain tolerance or range. Here's an example with numbers: .. code-block:: python from decimal import Decimal from testfixtures import compare, RangeComparison compare( expected=RangeComparison(123.456, 789), actual=Decimal(555.01), ) Here's an example with dates: .. code-block:: python from datetime import date from testfixtures import compare, RangeComparison compare( expected=RangeComparison(date(1978, 6, 13), date(1978, 10, 31)), actual=date(1978, 7, 1), ) .. note:: :class:`RangeComparison` is inclusive of both the lower and upper bound. .. _reprcomparison: ``ReprComparison`` ~~~~~~~~~~~~~~~~~~ Sometimes the easiest way to assert that an object is correct is to check that it is of the expected type and has the expected :func:`repr`, particularly when the object's type doesn't support meaningful equality. For these situations, you can use :class:`ReprComparison` objects, which compare equal to any object that is exactly of the supplied type and whose :func:`repr` matches the supplied string: .. code-block:: python from testfixtures import compare, ReprComparison compare(expected=ReprComparison(KeyError, "KeyError('foo')"), actual=KeyError('foo')) .. _strcomparison: ``StrComparison`` ~~~~~~~~~~~~~~~~~ :class:`StrComparison` works just like :class:`ReprComparison`, but checks the object's :class:`str` instead of its :func:`repr`: .. code-block:: python from testfixtures import compare, StrComparison compare(expected=StrComparison(KeyError, "'foo'"), actual=KeyError('foo')) Both :class:`ReprComparison` and :class:`StrComparison` can also match against a regular expression instead of an exact string by passing ``match``: .. code-block:: python from testfixtures import compare, StrComparison compare(expected=StrComparison(KeyError, match=r"'\w+'"), actual=KeyError('foo')) .. _textcomparison: ``TextComparison`` ~~~~~~~~~~~~~~~~~~ When comparing sequences of strings, particularly those coming from things like the python logging package, you often end up wanting to express a requirement that one string should be almost like another, or maybe fit a particular pattern expressed as a regular expression. For these situations, you can use :class:`TextComparison` objects wherever you would use normal strings, and they will compare equal to any string that matches the regular expression they are created with. Here's an example: .. code-block:: python from testfixtures import compare, TextComparison compare( expected=TextComparison(r'Starting thread \d+'), actual='Starting thread 132356', ) If you need to specify flags, this can be done in one of three ways: - As parameters: .. code-block:: python compare( expected=TextComparison(".*BaR", dotall=True, ignorecase=True), actual="foo\nbar", ) - As you would to :func:`re.compile`: .. code-block:: python import re compare( expected=TextComparison(".*BaR", re.DOTALL|re.IGNORECASE), actual="foo\nbar", ) - Inline: .. code-block:: python compare( expected=TextComparison("(?s:.*bar)"), actual="foo\nbar", ) You can also construct a :class:`TextComparison` from an already compiled :class:`re.Pattern`: .. code-block:: python import re compare( expected=TextComparison(re.compile(".*BaR", re.DOTALL|re.IGNORECASE)), actual="foo\nbar", ) testfixtures-12.4.0/docs/conf.py000066400000000000000000000073271525773641100166220ustar00rootroot00000000000000from importlib import metadata import datetime import os import time extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx' ] intersphinx_mapping = { 'python': ('https://docs.python.org/3/', None), 'django': ('https://django.readthedocs.io/en/latest/', None), 'pytest': ('https://docs.pytest.org/en/latest/', None), 'loguru': ('https://loguru.readthedocs.io/en/stable/', None), 'structlog': ('https://www.structlog.org/en/stable/', None), 'sybil': ('https://sybil.readthedocs.io/en/latest/', None), 'numpy': ('https://numpy.org/doc/stable/', None), 'pandas': ('https://pandas.pydata.org/docs/', None), 'polars': ('https://docs.pola.rs/api/python/stable/', None), 'pydantic': ('https://docs.pydantic.dev/latest/', None), } # General source_suffix = {'.rst': 'restructuredtext'} master_doc = 'index' project = 'testfixtures' copyright = '2008-2015 Simplistix Ltd, 2016 onwards Chris Withers' release = metadata.version(project) exclude_trees = ['_build'] pygments_style = 'sphinx' # Type hints autodoc_typehints = 'both' autodoc_typehints_description_target = "documented" # Options for HTML output html_theme = 'furo' htmlhelp_basename = project+'doc' exclude_patterns = ['**/furo.js.LICENSE.txt'] autodoc_member_order = 'bysource' nitpicky = True nitpick_ignore = [ ('py:class', 'P'), # param spec ('py:class', '~P'), # param spec ('py:class', 'constantly._constants.NamedConstant'), # twisted logging constants ('py:class', 'django.db.models.base.Model'), # not documented upstream ('py:class', 'functools.partial'), # rendered from a partial_type annotation, no class target ('py:class', 'polars.DataFrame'), # polars inventory has no top-level class entry ('py:class', 'polars.dataframe.frame.DataFrame'), # same ('py:class', 'module'), # ModuleType not documented. ('py:class', 'tempfile.TemporaryFile'), # not documented as a class so type annotation broken ('py:class', 'testfixtures.comparison.S'), # type var ('py:class', 'testfixtures.comparison.S_'), # type var ('py:class', 'testfixtures.comparison.M'), # type var ('py:class', 'testfixtures.comparison.M_'), # type var ('py:class', 'testfixtures.comparison.T'), # type var ('py:class', 'testfixtures.datetime.MockedCurrent'), # internal class that shouldn't be doc'ed ('py:class', 'testfixtures.command.RunT'), # type var ('py:class', 'testfixtures.replace.R'), # type var ('py:class', 'testfixtures.shouldraise.E'), # type var ('py:class', 'testfixtures.tempdirectory.P'), # type var ('py:class', 'testfixtures.utils.T'), # type var ('py:class', 'testfixtures.utils.U'), # type var ('py:class', 'twisted.logger.Logger'), # twisted doesn't use sphinx ('py:class', 'twisted.logger.LogLevel'), # twisted doesn't use sphinx ('py:class', 'twisted.logger.FileLogObserver'), # twisted doesn't use sphinx ('py:class', 'twisted.python.failure.Failure'), # twisted doesn't use sphinx ('py:func', 'twisted.logger.formatEvent'), # twisted doesn't use sphinx ('py:class', 'twisted.trial._asynctest.TestCase'), # twisted doesn't use sphinx ('py:class', 'twisted.trial.unittest.TestCase'), # twisted doesn't use sphinx ('py:class', 'unittest.case.TestCase'), # no docs, apparently ('py:class', 'unittest.mock._Call'), # No docstring. ] try: import mock except ImportError: pass else: # If the mock backport is installed when docs are built, don't warn. # This is common for development: if you see broken refs to Mock or _Call, # then `uv sync --all-groups --all-extras --no-extra mock-backport`. nitpick_ignore.extend([ ('py:class', 'mock.mock._Call'), ('py:class', 'mock.mock.Mock'), ]) testfixtures-12.4.0/docs/datetime.rst000066400000000000000000000341531525773641100176460ustar00rootroot00000000000000Mocking dates and times ======================= .. currentmodule:: testfixtures Testing code that involves dates and times or which has behaviour dependent on the date or time it is executed at has historically been tricky. Mocking lets you test this type of code and testfixtures provides three specialised mock objects to help with this. Dates ~~~~~ The testfixtures package provides the :func:`~testfixtures.mock_date` function that returns a subclass of :class:`datetime.date` with a :meth:`~datetime.date.today` method that will return a consistent sequence of dates each time it is called. This enables you to write tests for code such as the following, from the ``tests.sample1`` package: .. literalinclude:: ../tests/sample1.py :lines: 8-9,21-22 :class:`~testfixtures.Replace` can be used to apply the mock as shown in the following example: >>> from testfixtures import Replace, mock_date >>> from tests.sample1 import str_today_1 >>> with Replace('tests.sample1.date', mock_date()): ... str_today_1() ... str_today_1() '2001-01-01' '2001-01-02' If you need a specific date to be returned, you can specify it: >>> with Replace('tests.sample1.date', mock_date(1978, 6, 13)): ... str_today_1() '1978-06-13' If you need to test with a whole sequence of specific dates, this can be done as follows: >>> with Replace('tests.sample1.date', mock_date(None)) as d: ... d.add(1978, 6, 13) ... d.add(2009, 11, 12) ... str_today_1() ... str_today_1() '1978-06-13' '2009-11-12' Another way to test with a specific sequence of dates is to use the ``delta_type`` and ``delta`` parameters to :func:`~testfixtures.mock_date`. These parameters control the type and size, respectively, of the difference between each date returned. For example, where 2 days elapse between each returned value: >>> with Replace('tests.sample1.date', ... mock_date(1978, 6, 13, delta=2, delta_type='days')) as d: ... str_today_1() ... str_today_1() ... str_today_1() '1978-06-13' '1978-06-15' '1978-06-17' The ``delta_type`` can be any keyword parameter accepted by the :class:`~datetime.timedelta` constructor. Specifying a ``delta`` of zero can be an effective way of ensuring that all calls to the :meth:`~testfixtures.datetime.MockDate.today` method return the same value: >>> with Replace('tests.sample1.date', ... mock_date(1978, 6, 13, delta=0)) as d: ... str_today_1() ... str_today_1() ... str_today_1() '1978-06-13' '1978-06-13' '1978-06-13' When using :func:`~testfixtures.mock_date`, you can, at any time, set the next date to be returned using the :meth:`~testfixtures.datetime.MockDate.set` method. The date returned after this will be the set date plus the ``delta`` in effect: >>> with Replace('tests.sample1.date', mock_date(delta=2)) as d: ... str_today_1() ... d.set(1978,8,1) ... str_today_1() ... str_today_1() '2001-01-01' '1978-08-01' '1978-08-03' Datetimes ~~~~~~~~~ The testfixtures package provides the :func:`~testfixtures.mock_datetime` function that returns a subclass of :class:`datetime.datetime` with a :meth:`~datetime.datetime.now` method that will return a consistent sequence of :obj:`~datetime.datetime` objects each time it is called. This enables you to write tests for code such as the following, from the ``tests.sample1`` package: .. literalinclude:: ../tests/sample1.py :lines: 8-10,11-12 :class:`~testfixtures.Replace` can be used to apply the mock as shown in the following example: >>> from testfixtures import Replace, mock_datetime >>> from tests.sample1 import str_now_1 >>> with Replace('tests.sample1.datetime', mock_datetime()): ... str_now_1() ... str_now_1() '2001-01-01 00:00:00' '2001-01-01 00:00:10' If you need a specific datetime to be returned, you can specify it: >>> with Replace('tests.sample1.datetime', ... mock_datetime(1978, 6, 13, 1, 2, 3)): ... str_now_1() '1978-06-13 01:02:03' If you need to test with a whole sequence of specific datetimes, this can be done as follows: >>> with Replace('tests.sample1.datetime', ... mock_datetime(None)) as d: ... d.add(1978, 6, 13, 16, 0, 1) ... d.add(2009, 11, 12, 11, 41, 20) ... str_now_1() ... str_now_1() '1978-06-13 16:00:01' '2009-11-12 11:41:20' Another way to test with a specific sequence of datetimes is to use the ``delta_type`` and ``delta`` parameters to :func:`~testfixtures.mock_datetime`. These parameters control the type and size, respectively, of the difference between each datetime returned. For example, where 2 hours elapse between each returned value: >>> with Replace( ... 'tests.sample1.datetime', ... mock_datetime(1978, 6, 13, 16, 0, 1, delta=2, delta_type='hours') ... ) as d: ... str_now_1() ... str_now_1() ... str_now_1() '1978-06-13 16:00:01' '1978-06-13 18:00:01' '1978-06-13 20:00:01' The ``delta_type`` can be any keyword parameter accepted by the :class:`~datetime.timedelta` constructor. Specifying a ``delta`` of zero can be an effective way of ensuring that all calls to the :meth:`~testfixtures.datetime.MockDateTime.now` method return the same value: >>> with Replace('tests.sample1.datetime', ... mock_datetime(1978, 6, 13, 16, 0, 1, delta=0)) as d: ... str_now_1() ... str_now_1() ... str_now_1() '1978-06-13 16:00:01' '1978-06-13 16:00:01' '1978-06-13 16:00:01' When using :func:`~testfixtures.mock_datetime`, you can, at any time, set the next datetime to be returned using the :meth:`~testfixtures.datetime.MockDateTime.set` method. The value returned after this will be the set value plus the ``delta`` in effect: >>> with Replace('tests.sample1.datetime', ... mock_datetime(delta=2)) as d: ... str_now_1() ... d.set(1978, 8, 1) ... str_now_1() ... str_now_1() '2001-01-01 00:00:00' '1978-08-01 00:00:00' '1978-08-01 00:00:02' .. _timezones: Timezones --------- For the examples in this section, we need to have a timezone to work with: .. code-block:: python from datetime import tzinfo, timedelta class ATZInfo(tzinfo): def tzname(self, dt): return 'A TimeZone' def utcoffset(self, dt): # In general, this timezone is 5 hours behind UTC offset = timedelta(hours=-5) return offset+self.dst(dt) def dst(self, dt): # However, between March and September, it is only # 4 hours behind UTC if 3 < dt.month < 9: return timedelta(hours=1) return timedelta() By default, the internal queue of datetimes in a :func:`~testfixtures.mock_datetime` simulates local time in the UTC timezone: >>> datetime = mock_datetime(delta=0) This means we get the following when the simulated date is 1st Jan 2001: >>> datetime.set(2001, 1, 1, 10, 0) >>> datetime.now() datetime.datetime(2001, 1, 1, 10, 0) >>> datetime.utcnow() datetime.datetime(2001, 1, 1, 10, 0) >>> datetime.now(ATZInfo()) datetime.datetime(2001, 1, 1, 5, 0, tzinfo=) We get the following when the simulated date is 1st Apr 2001: >>> datetime.set(2001, 4, 1, 10, 0) >>> datetime.now() datetime.datetime(2001, 4, 1, 10, 0) >>> datetime.utcnow() datetime.datetime(2001, 4, 1, 10, 0) >>> datetime.now(ATZInfo()) datetime.datetime(2001, 4, 1, 6, 0, tzinfo=) If you wish to simulate a different local time, you should pass its :class:`datetime.tzinfo` to the :func:`~testfixtures.mock_datetime` constructor: >>> datetime = mock_datetime(delta=0, tzinfo=ATZInfo()) This means we get the following when the simulated date is 1st Jan 2001: >>> datetime.set(2001, 1, 1, 10, 0) >>> datetime.now() datetime.datetime(2001, 1, 1, 10, 0) >>> datetime.utcnow() datetime.datetime(2001, 1, 1, 15, 0) >>> datetime.now(ATZInfo()) datetime.datetime(2001, 1, 1, 10, 0, tzinfo=) We get the following when the simulated date is 1st Apr 2001: >>> datetime.set(2001, 4, 1, 10, 0) >>> datetime.now() datetime.datetime(2001, 4, 1, 10, 0) >>> datetime.utcnow() datetime.datetime(2001, 4, 1, 14, 0) >>> datetime.now(ATZInfo()) datetime.datetime(2001, 4, 1, 10, 0, tzinfo=) .. warning:: For your own sanity, you should avoid using the ``tzinfo`` parameter or passing :class:`~datetime.datetime` instances with non-``None`` :attr:`~datetime.datetime.tzinfo` attributes when calling :meth:`~testfixtures.datetime.MockDateTime.add` or :meth:`~testfixtures.datetime.MockDateTime.set`. Times ~~~~~ The testfixtures package provides the :func:`~testfixtures.mock_time` function that, when called, returns a replacement for the :func:`time.time` function. This enables you to write tests for code such as the following, from the ``tests.sample1`` package: .. literalinclude:: ../tests/sample1.py :lines: 30-34 :class:`~testfixtures.Replace` can be used to apply the mock as shown in the following example: >>> from testfixtures import Replace, mock_time >>> from tests.sample1 import str_time >>> with Replace('tests.sample1.time', mock_time()): ... str_time() ... str_time() '978307200.0' '978307201.0' If you need an integer representing a specific time to be returned, you can specify it: >>> with Replace('tests.sample1.time', ... mock_time(1978, 6, 13, 1, 2, 3)): ... str_time() '266547723.0' If you need to test with a whole sequence of specific timestamps, this can be done as follows: >>> with Replace('tests.sample1.time', mock_time(None)) as t: ... t.add(1978, 6, 13, 16, 0, 1) ... t.add(2009, 11, 12, 11, 41, 20) ... str_time() ... str_time() '266601601.0' '1258026080.0' Another way to test with a specific sequence of timestamps is to use the ``delta_type`` and ``delta`` parameters to :func:`~testfixtures.mock_time`. These parameters control the type and size, respectively, of the difference between each timestamp returned. For example, where 2 hours elapse between each returned value: >>> with Replace( ... 'tests.sample1.time', ... mock_time(1978, 6, 13, 16, 0, 1, delta=2, delta_type='hours') ... ) as d: ... str_time() ... str_time() ... str_time() '266601601.0' '266608801.0' '266616001.0' The ``delta_type`` can be any keyword parameter accepted by the :class:`~datetime.timedelta` constructor. Specifying a ``delta`` of zero can be an effective way of ensuring that all calls to the :func:`~time.time` function return the same value: >>> with Replace('tests.sample1.time', ... mock_time(1978, 6, 13, 16, 0, 1, delta=0)) as d: ... str_time() ... str_time() ... str_time() '266601601.0' '266601601.0' '266601601.0' When using :func:`~testfixtures.mock_time`, you can, at any time, set the next timestamp to be returned using the :meth:`~testfixtures.datetime.MockTime.set` method. The value returned after this will be the set value plus the ``delta`` in effect: >>> with Replace('tests.sample1.time', mock_time(delta=2)) as d: ... str_time() ... d.set(1978, 8, 1) ... str_time() ... str_time() '978307200.0' '270777600.0' '270777602.0' Gotchas with dates and times ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Using these specialised mock objects can have some intricacies as described below: Local references to functions ----------------------------- There are situations where people may have obtained a local reference to the :meth:`~datetime.date.today` or :meth:`~datetime.datetime.now` methods, such as the following code from the ``tests.sample1`` package: .. literalinclude:: ../tests/sample1.py :lines: 8-10,14-18,24-28 In these cases, you need to be careful with the replacement: >>> from testfixtures import Replacer, mock_datetime >>> from tests.sample1 import str_now_2, str_today_2 >>> with Replacer() as replace: ... today = replace('tests.sample1.today', mock_date().today) ... now = replace('tests.sample1.now', mock_datetime().now) ... str_today_2() ... str_now_2() '2001-01-01' '2001-01-01 00:00:00' .. _strict-dates-and-times: Use with code that checks class types ------------------------------------- When using the above specialist mocks, you may find code that checks the type of parameters passed may get confused. This is because, by default, :class:`mock_datetime` and :class:`mock_date` return instances of the real :class:`~datetime.datetime` and :class:`~datetime.date` classes: >>> from testfixtures import mock_datetime >>> from datetime import datetime >>> datetime_ = mock_datetime() >>> issubclass(datetime_, datetime) True >>> type(datetime_.now()) <...'datetime.datetime'> The above behaviour, however, is generally what you want as other code in your application and, more importantly, in other code such as database adapters, may handle instances of the real :class:`~datetime.datetime` and :class:`~datetime.date` classes, but not instances of the :class:`mock_datetime` and :class:`mock_date` mocks. That said, this behaviour can cause problems if you check the type of an instance against one of the mock classes. Most people might expect the following to return ``True``: >>> isinstance(datetime_(2011, 1, 1), datetime_) False >>> isinstance(datetime_.now(), datetime_) False If this causes a problem for you, then both :class:`~datetime.datetime` and :class:`~datetime.date` take a `strict` keyword parameter that can be used as follows: >>> datetime_ = mock_datetime(strict=True) >>> type(datetime_.now()) >>> isinstance(datetime_.now(), datetime_) True You will need to take care that you have replaced occurrences of the class where type checking is done with the correct :class:`mock_datetime` or :class:`mock_date`. Also, be aware that the :meth:`~testfixtures.datetime.MockDateTime.date` method of :class:`mock_datetime` instances will still return a normal :class:`~datetime.date` instance. If type checking related to this is causing problems, the type the :meth:`~testfixtures.datetime.MockDateTime.date` method returns can be controlled as shown in the following example: .. code-block:: python from testfixtures import mock_date, mock_datetime date_type = mock_date(strict=True) datetime_type = mock_datetime(strict=True, date_type=date_type) With things set up like this, the :meth:`~testfixtures.datetime.MockDateTime.date` method will return an instance of the :class:`~testfixtures.datetime.MockDate` mock: >>> somewhen = datetime_type.now() >>> somewhen.date() MockDate(2001, 1, 1) >>> type(_) is date_type True testfixtures-12.4.0/docs/development.rst000066400000000000000000000031441525773641100203700ustar00rootroot00000000000000Development =========== If you wish to contribute to this project, then you should fork the repository found here: https://github.com/simplistix/testfixtures/ Once that has been done and you have a checkout, you can follow the instructions below to perform various development tasks. For detailed development guidelines, code style requirements, and additional commands, see ``AGENTS.md`` in the repository root. Setting up a development environment ------------------------------------ The recommended way to set up a development environment is to use `uv`__ to install all groups and extras: __ https://docs.astral.sh/uv/ .. code-block:: bash uv sync --all-groups --all-extras Running the tests ----------------- Once you've set up the environment, the tests can be run from the root of a source checkout as follows: .. code-block:: bash uv run pytest Building the documentation -------------------------- The Sphinx documentation is built by doing the following from the repository root: .. code-block:: bash cd docs make html To check that the description that will be used on PyPI renders properly, do the following: .. code-block:: bash uv run python -c "from docutils.core import publish_file; publish_file(source_path='README.rst', destination_path='desc.html', writer_name='html')" The resulting ``desc.html`` should be checked by opening in a browser. Making a release ---------------- To make a release, just update the version in ``pyproject.toml``, update the change log in ``CHANGELOG.rst`` and push to https://github.com/simplistix/testfixtures. Carthorse should take care of the rest. testfixtures-12.4.0/docs/django.rst000077500000000000000000000044011525773641100173100ustar00rootroot00000000000000Testing with Django =================== .. note:: To ensure you are using compatible versions, install with the ``testfixtures[django]`` extra. .. invisible-code-block: python try: import django except ImportError: django = None else: from tests.test_django.models import SampleModel .. skip: start if(django is None, reason="No django installed") Django's ORM has an unfortunate implementation choice of considering :class:`~django.db.models.Model` instances to be identical as long as their primary keys are the same: .. literalinclude:: ../tests/test_django/models.py :pyobject: SampleModel >>> SampleModel(id=1, value=1) == SampleModel(id=1, value=2) True To work around this, when Django is installed, a :func:`comparer ` for the :class:`~django.db.models.Model` class is automatically :ref:`registered ` with ``ignore_eq=True``, so :func:`~testfixtures.compare` does the right thing: >>> from testfixtures import compare >>> compare(SampleModel(id=1, value=1), SampleModel(id=1, value=2)) Traceback (most recent call last): ... AssertionError: SampleModel not as expected: same: ['id'] values differ: 'value': 1 != 2 Ignoring fields --------------- It may also be that you want to ignore fields over which you have no control and cannot easily mock, such as created or modified times. For this, you can use the ``ignore_fields`` option: >>> compare(SampleModel(id=1, value=1), SampleModel(id=1, value=2), ... ignore_fields=['value']) Comparing non-editable fields ----------------------------- By default, non-editable fields are ignored: >>> compare(SampleModel(not_editable=1), SampleModel(not_editable=2)) If you wish to include these fields in the comparison, pass the ``non_editable_fields`` option: >>> compare(SampleModel(not_editable=1), SampleModel(not_editable=2), ... non_editable_fields=True) Traceback (most recent call last): ... AssertionError: SampleModel not as expected: same: ['created', 'id', 'value'] values differ: 'not_editable': 1 != 2 .. note:: The registered comparer currently ignores :class:`many to many ` fields. Patches to fix this deficiency are welcome! testfixtures-12.4.0/docs/exceptions.rst000066400000000000000000000224371525773641100202350ustar00rootroot00000000000000Testing exceptions ================== .. currentmodule:: testfixtures .. invisible-code-block: python try: import pydantic except ImportError: pydantic = None Testfixtures has tools to help when making assertions about exceptions that should be raised by a piece of code. The :class:`ShouldRaise` context manager ---------------------------------------- The :class:`ShouldRaise` context manager is the recommended way to make assertions about a piece of code that should raise exceptions. Suppose we wanted to test the following function to make sure that the right exception was raised: .. code-block:: python def the_thrower(throw=True): if throw: raise ValueError('Not good!') The following example shows how to test that the correct exception is raised: >>> from testfixtures import ShouldRaise >>> with ShouldRaise(ValueError('Not good!')): ... the_thrower() If the exception raised doesn't match the one expected, :class:`ShouldRaise` will raise an :class:`AssertionError` causing the tests in which it occurs to fail: >>> with ShouldRaise(ValueError('Is good!')): ... the_thrower() Traceback (most recent call last): ... AssertionError: not equal: ValueError('Is good!') (expected) ValueError('Not good!') (raised) If you're not concerned about anything more than the type of the exception that's raised, you can check as follows: >>> from testfixtures import ShouldRaise >>> with ShouldRaise(ValueError): ... the_thrower() If you want to match :func:`pytest.raises` behaviour and just check that ``str(exception)`` matches a pattern you supply, see :ref:`shouldraise-match` below. If you're feeling slack and just want to check that an exception is raised, but don't care about the type of that exception, the following will suffice: >>> from testfixtures import ShouldRaise >>> with ShouldRaise(): ... the_thrower() If no exception is raised by the code under test, :class:`ShouldRaise` will raise an :class:`AssertionError` to indicate this: >>> from testfixtures import ShouldRaise >>> with ShouldRaise(): ... the_thrower(throw=False) Traceback (most recent call last): ... testfixtures.shouldraise.NoException: No exception raised! :class:`ShouldRaise` has been implemented such that it can be used to test code that raises all exceptions, including :class:`SystemExit` and :class:`KeyboardInterrupt` exceptions. To help with :class:`SystemExit` and other exceptions that are tricky to construct yourself, :class:`ShouldRaise` instances have a :attr:`~ShouldRaise.raised` attribute. This will contain the actual exception raised and can be used to inspect parts of it: >>> import sys >>> from testfixtures import ShouldRaise >>> with ShouldRaise() as s: ... sys.exit(42) >>> s.raised.code 42 The :func:`should_raise` decorator ----------------------------------------- If you are working in a traditional :mod:`unittest` environment and want to check that a particular test function raises an exception, you may find the decorator suits your needs better: .. code-block:: python from testfixtures import should_raise @should_raise(ValueError('Not good!')) def test_function(): the_thrower() This decorator behaves exactly as the :class:`ShouldRaise` context manager described in the documentation above. .. note:: It is slightly recommended that you use the context manager rather than the decorator in most cases. With the decorator, all exceptions raised within the decorated function will be checked, which can hinder test development. With the context manager, you can make assertions about only the exact lines of code that you expect to raise the exception. Parameterised testing with optional exceptions ---------------------------------------------- When writing parameterised tests that may or may not expect an exception, you can pass ``None`` to :class:`ShouldRaise` to indicate that no exception should be raised. This avoids the need for conditional context manager selection: .. code-block:: python import pytest from testfixtures import ShouldRaise @pytest.mark.parametrize('value,exception', [ (1, None), (-1, ValueError('negative value')), ]) def test_something(value, exception): with ShouldRaise(exception): if value < 0: raise ValueError('negative value') .. _shouldraise-match: Matching exception messages --------------------------- If you want just match a pattern in the ``str(exception)`` of a raised exception you can do this as follows: >>> with ShouldRaise(ValueError, match='Not good'): ... the_thrower() The ``match`` parameter can also be a :class:`re.Pattern`: >>> import re >>> with ShouldRaise(ValueError, match=re.compile('not good', re.IGNORECASE)): ... the_thrower() If the pattern doesn't match, an :class:`AssertionError` is raised: >>> with ShouldRaise(ValueError, match='^All good$'): ... the_thrower() Traceback (most recent call last): ... AssertionError: Pattern did not match: '^All good$' (expected) 'Not good!' (actual) ``match`` can also be used without specifying an exception type, when you only care that something was raised and its message matches: >>> with ShouldRaise(match='Not good'): ... the_thrower() ``match`` is not permitted when passing an exception instance or ``None``, indicating exception is expected; both will raise a :class:`TypeError` at construction time. Matching the type and ``repr()`` or ``str()`` --------------------------------------------- Sometimes you want to assert both the type of the exception and its :func:`repr` or :class:`str` without constructing the exception instance yourself. The :func:`repr_like` and :func:`str_like` matchers do this and are typed to stand in for the exception. This is useful for exceptions that carry structured parameters rather than a plain message, where it isn't obvious what ends up in the rendered text: .. code-block:: python class InvalidRecord(Exception): def __init__(self, id, reason): self.id = id self.reason = reason super().__init__(f'record {id} invalid: {reason}') class SpecialInvalidRecord(InvalidRecord): pass >>> from testfixtures import repr_like >>> with ShouldRaise( ... repr_like(InvalidRecord, "InvalidRecord('record 42 invalid: missing name')") ... ): ... raise InvalidRecord(42, 'missing name') If a subclass is raised instead, :class:`ShouldRaise` lets it propagate, since the type must match exactly: >>> with ShouldRaise( ... repr_like(InvalidRecord, "InvalidRecord('record 42 invalid: missing name')") ... ): ... raise SpecialInvalidRecord(42, 'missing name') Traceback (most recent call last): ... SpecialInvalidRecord: record 42 invalid: missing name If the type matches but the rendering does not, an :class:`AssertionError` explains the difference: >>> with ShouldRaise( ... repr_like(InvalidRecord, "InvalidRecord('record 42 invalid: missing surname')") ... ): ... raise InvalidRecord(42, 'missing name') Traceback (most recent call last): ... AssertionError: not equal: (expected) InvalidRecord('record 42 invalid: missing name') (raised) :func:`str_like` works the same way but checks :class:`str` instead of :func:`repr`. This is particularly useful for exceptions such as :class:`pydantic.ValidationError `, which has no public constructor that accepts a plain message: .. skip: start if(pydantic is None, reason="No pydantic installed") .. code-block:: python from pydantic import BaseModel, ValidationError class Point(BaseModel): x: int y: int ``match`` is a regular expression, so literal square brackets in pydantic's rendering need escaping: >>> from testfixtures import str_like >>> with ShouldRaise( ... str_like( ... ValidationError, ... match=( ... "Input should be a valid integer, " ... "unable to parse string as an integer " ... r"\[type=int_parsing, input_value='not-an-int', input_type=str\]" ... ) ... ) ... ): ... Point(x='not-an-int', y=2) .. skip: end The type must match exactly, so a subclass of the expected exception will not match. See :ref:`comparison-objects` for more about :func:`repr_like` and :func:`str_like`. Exceptions that are conditionally raised ---------------------------------------- Some exceptions are only raised in certain versions of Python. For example, in Python 2, ``bytes()`` will turn both bytes and strings into bytes, while in Python 3, it will raise an exception when presented with a string. If you wish to make assertions that this behaviour is expected, you can use the ``unless`` option to :class:`ShouldRaise` as follows: .. code-block:: python import sys from testfixtures import ShouldRaise PY2 = sys.version_info[:2] < (3, 0) with ShouldRaise(TypeError, unless=PY2): bytes('something') .. note:: Do **not** abuse this functionality to make sloppy assertions. It is always better have two different tests that cover a case when an exception should be raised and a case where an exception should not be raised rather than using it above functionality. It is *only* provided to help in cases where something in the environment that cannot be mocked out or controlled influences whether or not an exception is raised. testfixtures-12.4.0/docs/files.rst000066400000000000000000000602561525773641100171570ustar00rootroot00000000000000Testing with files and directories ================================== .. currentmodule:: testfixtures Working with files and directories in tests can often require excessive amounts of boilerplate code to make sure that the tests happen in their own sandbox, files and directories contain what they should or code processes test files correctly, and the sandbox is cleared up at the end of the tests. To help with this, testfixtures provides the :class:`TempDir` class that hides most of the boilerplate code you would need to write. .. note:: :class:`TempDirectory` is still available for backward compatibility but is deprecated as of version 11. New code should use :class:`TempDir`, which returns :class:`~pathlib.Path` objects instead of strings. Methods of use -------------- Suppose you wanted to test the following function: .. code-block:: python from pathlib import Path def foo2bar(dirpath: Path, filename: str) -> None: path = dirpath / filename data = path.read_bytes() data = data.replace(b'foo', b'bar') path.write_bytes(data) There are two ways to use a :class:`TempDir`: The context manager ~~~~~~~~~~~~~~~~~~~ A :class:`TempDir` can be used as a context manager: >>> from testfixtures import TempDir >>> with TempDir() as d: ... test_txt = d.write('Downloads/test.txt', b'some foo thing') ... foo2bar(d / 'Downloads', 'test.txt') ... test_txt.read_bytes() b'some bar thing' Manual usage ~~~~~~~~~~~~ If you want to work with files or directories for the duration of a doctest or in every test in a :class:`~unittest.TestCase`, then you can use the :class:`TempDir` manually. The instantiation is done in the set-up step of the :class:`~unittest.TestCase` or equivalent: >>> from testfixtures import TempDir >>> d = TempDir() You can then use the temporary directory for your testing: >>> d.write('test.txt', 'some foo thing') PosixPath('...') >>> foo2bar(d.path, 'test.txt') >>> d.read('test.txt') == b'some bar thing' True Then, in the tear-down step of the :class:`~unittest.TestCase` or equivalent, you should make sure the temporary directory is cleaned up: >>> d.cleanup() The :meth:`~testfixtures.TempDir.cleanup` method can also be added as an :meth:`~unittest.TestCase.addCleanup` if that is easier or more compact in your test suite. If you have multiple :class:`TempDir` objects in use, you can easily clean them all up: >>> TempDir.cleanup_all() .. invisible-code-block: python # Need to put re-create the docs-wide fixture now: sample_path = _tempdir.create() Working with other interfaces ----------------------------- If you're using a testing framework that already provides a temporary directory, such as pytest's :ref:`tmp_path ` or :ref:`tmpdir `, but wish to make use of the :class:`TempDir` API for creating content or making assertions, then you can wrap the existing object as follows: >>> with TempDir(tmp_path) as d: ... d.write('some/path.txt', 'some text') ... d.compare(expected=('some/', 'some/path.txt')) PosixPath('...') When doing this, :class:`TempDir` will not remove the directory it is wrapping: >>> tmp_path.exists() True Features of a temporary directory --------------------------------- No matter which usage pattern you pick, you will always end up with a :class:`TempDir` object. These have an array of methods that let you perform common file and directory related tasks without all the manual boiler plate. The following sections show you how to perform the various tasks you're likely to bump into in the course of testing. .. create a tempdir for the examples: >>> tempdir = TempDir() Computing paths ~~~~~~~~~~~~~~~ If you need to know the real path of the temporary directory, the :class:`TempDir` object has a :attr:`~TempDir.path` attribute: >>> tempdir.path PosixPath('...tmp...') If you want to compute a path within the directory, you can traverse directly from it as if it were a :class:`~pathlib.Path`: >>> tempdir / 'some' / 'where' PosixPath('.../some/where') You can also explicitly ask for either a :class:`~pathlib.Path` or a string path: >>> tempdir.as_path('foo') PosixPath('.../foo') >>> tempdir.as_string('bar') '.../bar' If you want to compute a deeper path, you can pass either a tuple or a forward slash-separated path to any of the methods: >>> import os >>> tempdir.as_string(('foo', 'baz')).rsplit(os.sep, 2)[-2:] ['foo', 'baz'] >>> tempdir.as_string('foo/baz').rsplit(os.sep, 2)[-2:] ['foo', 'baz'] .. note:: If passing a string containing path separators, a forward slash should be used as the separator regardless of the underlying platform separator. Writing files ~~~~~~~~~~~~~ To write to a file in the root of the temporary directory, you pass the name of the file and the content you want to write: >>> tempdir.write('myfile.txt', 'some text') PosixPath(...) >>> print((tempdir / 'myfile.txt').read_text()) some text The absolute path of the newly written file is returned: >>> path = tempdir.write('anotherfile.txt', 'some more text') >>> print(path.read_text()) some more text You can also write files into a sub-directory of the temporary directory as follows; if any intermediate directories do not exist, they will be created: >>> path = tempdir.write(('some', 'folder', 'afile.txt'), 'the text') >>> print(path.read_text()) the text You can also specify the path to write to as a forward-slash separated string: >>> path = tempdir.write('some/folder/bfile.txt', 'the text') >>> print(path.read_text()) the text .. note:: Forward slashes should be used regardless of the file system or operating system in use. For structured data like JSON, YAML, or TOML, the :meth:`~TempDir.dump` method provides automatic serialization based on file extension: >>> tempdir.dump('config.json', {'host': 'localhost', 'port': 8080}) PosixPath('...') >>> print(tempdir.read_text('config.json')) {"host": "localhost", "port": 8080} .. invisible-code-block: python try: import yaml import tomlkit except ImportError: missing_format_deps = True else: missing_format_deps = False .. skip: start if(missing_format_deps, reason="yaml or tomlkit not installed") >>> tempdir.dump('settings.yaml', {'debug': True, 'level': 'info'}) PosixPath('...') >>> print(tempdir.read_text('settings.yaml')) debug: true level: info .. skip: end You can also clone existing static files and directories using :meth:`~TempDir.clone`: .. invisible-code-block: python sample_path = _tempdir.write("sample.txt", "Sample Content") >>> sample_path '.../sample.txt' >>> tempdir.clone(sample_path) PosixPath('...') >>> (tempdir / 'sample.txt').read_text() 'Sample Content' Creating directories ~~~~~~~~~~~~~~~~~~~~ If you just want to create a sub-directory in the temporary directory you can do so as follows: .. new tempdir: >>> tempdir = TempDir() >>> tempdir.makedir('output') PosixPath('...') >>> (tempdir / 'output').is_dir() True As with file creation, the absolute path of the sub-directory that has just been created is returned: >>> path = tempdir.makedir('more_output') >>> Path(path).is_dir() True Finally, you can create a nested sub-directory even if the intervening parent directories do not exist: >>> (tempdir / 'some').exists() False >>> path = tempdir.makedir(('some', 'sub', 'dir')) >>> Path(path).exists() True You can also specify the path of the directories to create as a forward-slash separated string: >>> (tempdir / 'another').exists() False >>> path = tempdir.makedir('another/sub/dir') >>> Path(path).exists() True .. note:: Forward slashes should be used regardless of the file system or operating system in use. Checking the contents of files ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Once a file has been written into the temporary directory, you will often want to check its contents. This is done with the :meth:`TempDir.read` method. Suppose the code you are testing creates some files: .. new tempdir: >>> tempdir = TempDir() .. code-block:: python def spew(root): (root / 'root.txt').write_bytes(b'root output') (root / 'subdir').mkdir() (root / 'subdir' / 'file.txt').write_bytes(b'subdir output') (root / 'subdir' / 'logs').mkdir() We can test this function by passing it the temporary directory's path and then using the :meth:`TempDir.read` method to check the files were created with the correct content: >>> spew(tempdir.as_path()) >>> tempdir.read_text('root.txt') 'root output' >>> tempdir.read_text('subdir/file.txt') 'subdir output' The second part of the above test shows how to use the :meth:`TempDir.read` method to check the contents of files that are in sub-directories of the temporary directory. This can also be done by specifying the path relative to the root of the temporary directory as a forward-slash separated string: >>> tempdir.read_text('subdir/file.txt') 'subdir output' .. note:: Forward slashes should be used regardless of the file system or operating system in use. Checking the contents of directories ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ It's good practice to test that your code is only writing files you expect it to and to check they are being written to the path you expect. :meth:`TempDir.compare` is the method to use to do this. As an example, we could check that the ``spew()`` function above created no extraneous files as follows: >>> tempdir.compare([ ... 'root.txt', ... 'subdir/', ... 'subdir/file.txt', ... 'subdir/logs/', ... ]) If we only wanted to check the sub-directory, we would specify the path to start from, relative to the root of the temporary directory: >>> tempdir.compare([ ... 'file.txt', ... 'logs/', ... ], path='subdir') If, like git, we only cared about files, we could do the comparison as follows: >>> tempdir.compare([ ... 'root.txt', ... 'subdir/file.txt', ... ], files_only=True) And finally, if we only cared about files at a particular level, we could turn off the recursive comparison as follows: >>> tempdir.compare([ ... 'root.txt', ... 'subdir', ... ], recursive=False) The :meth:`~testfixtures.TempDir.compare` method can also be used to check whether a directory contains nothing, for example: >>> tempdir.compare(path=('subdir', 'logs'), expected=()) The above can also be done by specifying the sub-directory to be checked as a forward-slash separated path: >>> tempdir.compare(path='subdir/logs', expected=()) If the actual directory contents do not match the expected contents passed in, an :class:`AssertionError` is raised, which will show up as a unit test failure: >>> tempdir.compare(['subdir'], recursive=False) Traceback (most recent call last): ... AssertionError: sequence not as expected: same: () expected: ('subdir',) actual: ('root.txt', 'subdir') In some circumstances, you may want to ignore certain files or sub-directories when checking contents. To make this easy, the :class:`~testfixtures.TempDir` constructor takes an optional `ignore` parameter which, if provided, should contain a sequence of regular expressions. If any of the regular expressions return a match when used to search through the results of any of the methods covered in this section, that result will be ignored. For example, suppose we are testing some revision control code, but don't really care about the revision control system's metadata directories, which may or may not be present: .. code-block:: python from random import choice def git_ish(dirpath, filename): root = Path(dirpath) if choice((True, False)): (root / '.git').mkdir() (root / filename).write_bytes(b'something') To test this, we can use any of the previously described methods. When used manually or as a context manager, this would be as follows: >>> with TempDir(ignore=['.git']) as d: ... git_ish(d.path, 'test.txt') ... d.compare(['test.txt']) .. set things up again: >>> tempdir = TempDir() >>> spew(tempdir.as_path()) If you are working with doctests, the :meth:`~testfixtures.TempDir.listdir` method can be used instead: >>> tempdir.listdir() root.txt subdir >>> tempdir.listdir('subdir') file.txt logs >>> tempdir.listdir(('subdir', 'logs')) No files or directories found. The above example also shows how to check the contents of sub-directories of the temporary directory and also shows what is printed when a directory contains nothing. The :meth:`~testfixtures.TempDir.listdir` method can also take a path separated by forward slashes, which can make doctests a little more readable. The above test could be written as follows: >>> tempdir.listdir('subdir/logs') No files or directories found. However, if you have a nested folder structure, such as that created by our ``spew()`` function, it can be easier to just inspect the whole tree of files and folders created. You can do this by using the `recursive` parameter to :meth:`~testfixtures.TempDir.listdir`: >>> tempdir.listdir(recursive=True) root.txt subdir/ subdir/file.txt subdir/logs/ Bytes versus Strings ~~~~~~~~~~~~~~~~~~~~ .. new tempdir: >>> tempdir = TempDir() You'll notice that all of the examples so far have only used bytes. To work with strings, :class:`TempDir` provides explicit parameters for providing the character set to use for decoding and encoding. Using these as example, which all contain the British Pound symbol: .. code-block:: python some_bytes = '\xa3'.encode('utf-8') some_text = '\xa3' When writing, you can either write bytes directly, as we have been in the examples so far: >>> path = tempdir.write('currencies.txt', some_bytes) >>> Path(path).read_bytes() b'\xc2\xa3' Or, you can write text, in which case your system default encoding, usually ``utf-8``, will be used when writing the data to the file: >>> path = tempdir.write('currencies.txt', some_text) >>> Path(path).read_bytes() b'\xc2\xa3' Alternatively, you can specify an explicit encoding to use when writing the data to the file: >>> latin_path = tempdir.write('latin-currencies.txt', some_text, encoding='latin1') >>> Path(latin_path).read_bytes() b'\xa3' The same is true when reading files. You can either read bytes: >>> tempdir.read('currencies.txt') == some_bytes True Or, you can read text, but must specify an encoding that will be used to decode the data in the file: >>> tempdir.read('currencies.txt', encoding='utf-8') == some_text True If you're always using a common character encoding, you can instead specify it to the constructor: >>> tempdir = TempDir(encoding='utf-8') >>> tempdir.write('more-currencies.txt', some_text) PosixPath('...') >>> Path(path).read_bytes().decode('utf-8') == some_text True >>> tempdir.read('more-currencies.txt') == some_text True Serialization Formats ~~~~~~~~~~~~~~~~~~~~~ .. skip: start if(missing_format_deps, reason="yaml or tomlkit not installed") .. new tempdir: >>> tempdir = TempDir() :class:`TempDir` also supports working with common serialization formats like JSON, YAML, and TOML through the :meth:`~TempDir.parse` and :meth:`~TempDir.dump` methods, which auto-detect the format from the file extension: >>> config = {'database': {'host': 'localhost', 'port': 5432}, 'debug': True} >>> tempdir.dump('config.json', config) PosixPath('...') >>> print(_.read_text()) {"database": {"host": "localhost", "port": 5432}, "debug": true} >>> tempdir.parse('config.json') {'database': {'host': 'localhost', 'port': 5432}, 'debug': True} If you need to test a situation where the format cannot be inferred from the filename, then it can be explicitly specified: >>> from testfixtures.formats import YAML >>> tempdir.dump('config', config, format=YAML) PosixPath('...') >>> print(_.read_text()) database: host: localhost port: 5432 debug: true >>> tempdir.parse('config', format=YAML) {'database': {'host': 'localhost', 'port': 5432}, 'debug': True} A format can also be specified to :meth:`~TempDir.write` and :meth:`~TempDir.read`. When both ``encoding`` and ``format`` are provided, the ``format`` parameter handles the serialization between objects and strings while the ``encoding`` parameter controls how the serialized strings are converted to and from bytes. When writing, the format first renders the object to a string, then that string is encoded with the specified encoding: >>> from testfixtures.formats import TOML >>> data = {'gbp': '£'} >>> path = tempdir.write('data.toml', data, format=TOML, encoding='latin-1') >>> path.read_bytes() b'gbp = "\xa3"\n' When reading, bytes are first decoded with the specified encoding, then parsed by the format: >>> loaded = tempdir.read('data.toml', format=TOML, encoding='latin-1') >>> loaded {'gbp': '£'} Built-in formats ^^^^^^^^^^^^^^^^ Three built-in format handlers are provided: **JSON** - Always available, uses the standard library :mod:`json` module: >>> tempdir.dump('data.json', [1, 2, {'key': 'value'}]) PosixPath('...') >>> print(_.read_text()) [1, 2, {"key": "value"}] >>> tempdir.parse('data.json') [1, 2, {'key': 'value'}] **YAML** - Available when PyYAML is importable. .. note:: To use the YAML format, install with the ``testfixtures[yaml]`` extra. >>> tempdir.dump('config.yaml', {'app': {'name': 'test'}}) PosixPath('...') >>> print(_.read_text()) app: name: test >>> tempdir.parse('config.yaml') {'app': {'name': 'test'}} **TOML** - Reads uses the standard library :mod:`tomllib` module (Python 3.11+) or ``tomlkit`` if available. Writing requires ``tomlkit``. .. note:: To use TOML format for writing, install with the ``testfixtures[toml]`` extra. >>> tempdir.dump('config.toml', {'tool': {'name': 'example'}}) PosixPath('...') >>> print(_.read_text()) [tool] name = "example" >>> tempdir.parse('config.toml') {'tool': {'name': 'example'}} .. skip: end Implementing custom formats ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ You can add support for additional formats by implementing the :class:`~testfixtures.formats.Format` protocol. The protocol requires two methods: - ``parse(data: str) -> Any`` - Deserialize a string into a Python object - ``render(obj: Any) -> str`` - Serialize a Python object into a string For example, here's a simple CSV format handler: .. code-block:: python import csv from io import StringIO from testfixtures.formats import Format class CSVFormat: def parse(self, data: str) -> list[list[str]]: reader = csv.reader(StringIO(data)) return list(reader) def render(self, obj: list[list[str]]) -> str: output = StringIO() writer = csv.writer(output) writer.writerows(obj) return output.getvalue() Here's how it could be used >>> with TempDir() as d: ... path = d.dump('data.csv', [['name', 'age'], ['Alice', '30']], format=CSVFormat()) ... print(path.read_text()) ... rows = d.parse('data.csv', format=CSVFormat()) name,age Alice,30 >>> rows [['name', 'age'], ['Alice', '30']] Working with an existing sandbox -------------------------------- Some testing infrastructure already provides a sandbox temporary directory, however that infrastructure might not provide the same level of functionality that :class:`~testfixtures.TempDir` provides. For this reason, it is possible to wrap an existing directory such as the following with a :class:`~testfixtures.TempDir`: >>> from tempfile import mkdtemp >>> thedir = mkdtemp() When working with the context manager, this is done as follows: >>> with TempDir(path=thedir) as d: ... d.write('file', b'data') ... d.makedir('directory') ... sorted(os.listdir(thedir)) PosixPath('...') PosixPath('...') ['directory', 'file'] .. check thedir still exists and reset >>> from shutil import rmtree >>> os.path.exists(thedir) True >>> rmtree(thedir) >>> thedir = mkdtemp() It is important to note that if an existing directory is used, it will not be deleted by the context manager. You will need to make sure that the directory is cleaned up as required. .. check the above statement is true: >>> os.path.exists(thedir) True .. better clean it up: >>> rmtree(thedir) Changing the current working directory -------------------------------------- While it's generally not a good idea to have software that relies on the current working directory, there's still plenty of occasions where it ends up mattering during testing. If you'd like the current working directory to be set to the temporary directory for the duration of a managed context, you can do it like this: >>> import os >>> with TempDir(cwd=True) as d: ... os.getcwd() == str(d.as_path().resolve()) True It's better practice to only change the current working directory for the smallest context possible, and in this case it's better to use the :func:`~contextlib.chdir` context manager from the standard library, available on Python 3.11 or newer. .. _sybil: Using with Sybil ----------------- `Sybil`__ is a tool for testing the examples found in documentation. It works by applying a set of specialised parsers to the documentation and testing or otherwise using the examples returned by those parsers. __ https://sybil.readthedocs.io The key differences between testing with Sybil and traditional doctests are that it is possible to plug in different types of parser, not just the "python console session" one, and so it is possible to test different types of examples. Testfixtures provides one these parsers to aid working with :class:`~testfixtures.TempDir` objects. This parser makes use of `topic`__ directives with specific classes set to perform different actions. __ https://docutils.sourceforge.io/docs/ref/rst/directives.html#topic The following sections describe how to use this parser to help with writing temporary files and checking their contents. .. note:: To ensure you are using a compatible version of Sybil, install with the ``testfixtures[sybil]`` extra. Setting up ~~~~~~~~~~ To use the Sybil parser, you need to make sure a :class:`TempDir` instance is available under a particular name in the sybil test namespace. This name is then passed to the parser's constructor and the parser is passed to the :class:`~sybil.Sybil` constructor. The following example shows how to use Sybil's `pytest`__ integration to execute all of the examples below. These require not only the Testfixtures parser but also the Sybil parsers that give more traditional doctest behaviour, invisible code blocks that are useful for setting things up and checking examples without breaking up the flow of the documentation, and capturing of examples from the documentation to use for use in other forms of testing: __ https://docs.pytest.org/en/latest/ .. literalinclude:: ../conftest.py Writing files ~~~~~~~~~~~~~ To write a file, a `topic`__ with a class of ``write-file`` is included in the documentation. The following example is a complete reStructuredText file that shows how to write a file that is then used by a later example: __ https://docutils.sourceforge.io/docs/ref/rst/directives.html#topic .. literalinclude:: ../tests/configparser-read.txt :language: rest Checking the contents of files ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To check the contents of a file, a `topic`__ with a class of ``read-file`` containing the expected content is included in the documentation. The following example is a complete reStructuredText file that shows how to check the values written by the code being documented while also using this check as part of the documentation: __ https://docutils.sourceforge.io/docs/ref/rst/directives.html#topic .. literalinclude:: ../tests/configparser-write.txt :language: rest Checking the contents of directories ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ While :class:`~testfixtures.sybil.FileParser` itself does not offer any facility for checking the contents of directories, Sybil's :class:`~sybil.parsers.rest.CaptureParser` can be used in conjunction with the existing features of a :class:`TempDir` to illustrate the contents expected in a directory seamlessly within the documentation. Here's a complete reStructuredText document that illustrates this technique: .. literalinclude:: ../tests/directory-contents.txt :language: rest A note on line endings ~~~~~~~~~~~~~~~~~~~~~~ As currently implemented, the parser provided by testfixtures always writes content with ``'\n'`` line separators and, when read, will always have its line endings normalised to ``'\n'``. If you hit any limitations caused by this, please raise an issue in the tracker on GitHub. .. clean up: >>> tempdir.cleanup_all() >>> _tempdir.create() `. - ``[structlog]``: Structlog logging framework support. See :doc:`structlog`. - ``[pydantic]``: Pydantic model helpers. See :doc:`pydantic`. - ``[yaml]``: YAML format support for :class:`~testfixtures.TempDir`. See :ref:`files`. - ``[toml]``: TOML format writing support for :class:`~testfixtures.TempDir`. See :ref:`files`. Multiple extras can be installed together, for example: ``testfixtures[django,twisted]``. testfixtures-12.4.0/docs/license.rst000066400000000000000000000000741525773641100174670ustar00rootroot00000000000000======= License ======= .. literalinclude:: ../LICENSE.txt testfixtures-12.4.0/docs/logging.rst000066400000000000000000000324621525773641100175010ustar00rootroot00000000000000Testing logging =============== .. currentmodule:: testfixtures Logging is important and testing that your logging is correct should be as easy as possible. :class:`LogCapture` allows this for standard library :mod:`logging`, :doc:`loguru ` and :doc:`twisted ` logging. Support for other frameworks is easy to implement by way of the :class:`~testfixtures.logcapture.CaptureSource` protocol. If you want to test that your logging has been correctly configured, see :ref:`check-log-config`. As a simple example, you can capture logging with a `pytest`__ fixture such as this: __ https://docs.pytest.org/ .. code-block:: python import pytest from typing import Iterator from testfixtures import LogCapture, LoggingSource @pytest.fixture() def logs() -> Iterator[LogCapture]: with LogCapture(LoggingSource()) as logs_: yield logs_ You can check that the code you're testing logs correctly like this: .. code-block:: python import logging def test_logging(logs: LogCapture) -> None: # code under test logging.info('%i is fine', 42) logging.error('%s is not', 13) logs.check( ('INFO', '42 is fine'), ('ERROR', '13 is not'), ) .. invisible-code-block: python from sybil.testing import run_pytest run_pytest(test_logging, fixtures=[logs]) Checking captured log messages ------------------------------ There are three ways of checking that the messages captured were as expected. The following example is used to show these: .. code-block:: python from testfixtures import LogCapture, LoggingSource from logging import getLogger logger = getLogger() with LogCapture(LoggingSource()) as log: logger.info('start of block number %i', 1) try: logger.debug('inside try block') raise RuntimeError('No code to run!') except: logger.error('error occurred', exc_info=True) The check methods ~~~~~~~~~~~~~~~~~ :obj:`~testfixtures.LogCapture` instances have :meth:`~testfixtures.LogCapture.check` and :meth:`~testfixtures.LogCapture.check_present` methods to make assertions about entries that have been logged. :meth:`~testfixtures.LogCapture.check` will compare the captured logging with what you expect: >>> log.check( ... ('INFO', 'start of block number 1'), ... ('DEBUG', 'inside try block'), ... ('ERROR', 'error occurred'), ... ) If the actual entries logged were different, you'll get an :class:`AssertionError`: >>> log.check(('INFO', 'start of block number 1')) Traceback (most recent call last): ... AssertionError: sequence not as expected: same: (('INFO', 'start of block number 1'),) expected: () actual: (('DEBUG', 'inside try block'), ('ERROR', 'error occurred')) In contrast, :meth:`~testfixtures.LogCapture.check_present` will only check that the entries you specify are present, and that their order is as specified. Other entries will be ignored: >>> log.check_present( ... ('INFO', 'start of block number 1'), ... ('ERROR', 'error occurred'), ... ) If the order of entries is non-deterministic, then you can be explicit that the order doesn't matter: >>> log.check_present( ... ('ERROR', 'error occurred'), ... ('INFO', 'start of block number 1'), ... order_matters=False ... ) Similarly, if the order of entries is non-deterministic, but you want to ensure there is no unexpected logging, :meth:`~testfixtures.LogCapture.check` also support ``order_matters``: >>> log.check( ... ('DEBUG', 'inside try block'), ... ('INFO', 'start of block number 1'), ... ('ERROR', 'error occurred'), ... order_matters=False ... ) Pass ``level`` to exclude entries below it, such as debugging output: >>> import logging >>> log.check( ... ('INFO', 'start of block number 1'), ... ('ERROR', 'error occurred'), ... level=logging.INFO, ... ) Pass ``predicate`` to filter on each captured :class:`~testfixtures.logcapture.Entry`, including its ``raw`` framework object; only those for which it returns true are compared: >>> log.check( ... ('ERROR', 'error occurred'), ... predicate=lambda entry: entry.exception is not None, ... ) Both can be combined; entries excluded by either are left un-checked, so :meth:`~testfixtures.LogCapture.ensure_checked` still reports them. To assert that nothing was logged, :meth:`~testfixtures.LogCapture.check_empty` is clearer than :meth:`~testfixtures.LogCapture.check` with no expected entries. It takes the same ``level`` and ``predicate``, so you can assert that nothing was logged above a level: .. code-block:: python with LogCapture() as quiet: getLogger().debug('just debugging') quiet.check_empty(level=logging.INFO) Inspecting ~~~~~~~~~~ :obj:`~testfixtures.LogCapture` instances also keep a list of the :class:`entries ` they capture. This is useful when you want to check specifics of the captured logging that aren't available from either the string representation or the :meth:`~testfixtures.LogCapture.check` method. A common case of this is where you want to check that exception information was logged for certain messages: .. code-block:: python from testfixtures import compare compare(log.entries[-1].exception, expected=RuntimeError('No code to run!')) If you need access to the raw object captured from the logging framework: >>> log.entries[0].raw If you want to access the items considered by the check methods, then use :meth:`~testfixtures.LogCapture.actual`: >>> from pprint import pprint >>> pprint(log.actual()) [('INFO', 'start of block number 1'), ('DEBUG', 'inside try block'), ('ERROR', 'error occurred')] Printing ~~~~~~~~ :obj:`~testfixtures.LogCapture` instances have a string representation that shows what entries it has captured. This can be useful in doc tests: >>> print(log) INFO start of block number 1 DEBUG inside try block ERROR error occurred This representation can also be used to check that no logging has occurred: >>> empty = LogCapture() >>> print(empty) No logging captured Only capturing specific logging ------------------------------- You can capture only logging above a certain level like this: .. code-block:: python with LogCapture(LoggingSource(level=logging.WARNING)) as logs: logger = getLogger() logger.debug('junk') logger.info('something we care about') logger.error('an error') logs.check( ('ERROR', 'an error'), ) To only capture a specific logger: .. code-block:: python with LogCapture(LoggingSource(names=['specific'])) as logs: getLogger('something').info('junk') getLogger('specific').info('what we care about') getLogger().info('more junk') logs.check( ('INFO', 'what we care about'), ) To capture multiple loggers: .. code-block:: python with LogCapture(LoggingSource(names=('one', 'two'))) as logs: getLogger('three').info('3') getLogger('two').info('2') getLogger('one').info('1') logs.check( ('INFO', '2'), ('INFO', '1'), ) Capturing can also be disabled and enabled during a test by only having the :class:`~testfixtures.LogCapture` installed when necessary: >>> logger = logging.getLogger() >>> logs = LogCapture(LoggingSource(), install=False) >>> logger.info('junk') >>> logs.install() >>> logger.info('something we care about') >>> logs.uninstall() >>> logger.info('more junk') >>> logs.check(('INFO', 'something we care about')) To ignore noisy setup, use the :meth:`~testfixtures.LogCapture.disabled` context manager, which uninstalls the capture for the duration of the ``with`` block: .. code-block:: python with LogCapture(LoggingSource()) as logs: with logs.disabled(): logger.warning('noisy setup output') logger.info('what we care about') logs.check(('INFO', 'what we care about')) You can also capture different attributes by specifying their names; if the attribute is callable, as with ``getMessage`` here, it will be called: .. code-block:: python logger = getLogger() with LogCapture(LoggingSource(attributes=('name', 'levelname', 'getMessage'))) as logs: logger.debug('a debug message') logger.info('something %s', 'info') logger.error('an error') logs.check( ('root', 'DEBUG', 'a debug message'), ('root', 'INFO', 'something info'), ('root', 'ERROR', 'an error'), ) If you need even more control, you can pass a callable to extract the required information: .. code-block:: python def extract(record): return {'level': record.levelname, 'message': record.getMessage()} with LogCapture(attributes=extract) as log: logger = getLogger() logger.debug('a debug message') logger.error('an error') log.check( {'level': 'DEBUG', 'message': 'a debug message'}, {'level': 'ERROR', 'message': 'an error'}, ) Methods of capture ------------------ There are three different ways of having a :class:`LogCapture` installed while your code under test is running: The context manager ~~~~~~~~~~~~~~~~~~~ The context manager can be used as follows: .. code-block:: python import logging from testfixtures import LogCapture, LoggingSource logger = logging.getLogger() with LogCapture(LoggingSource()) as logs: logger.info('a message') logger.error('an error') For the duration of the ``with`` block, logging is captured. If it doesn't match expectations then an :class:`AssertionError` will be raised: >>> logs.check( ... ('INFO', 'a message'), ... ('ERROR', 'another error'), ... ) Traceback (most recent call last): ... AssertionError: sequence not as expected: same: (('INFO', 'a message'),) expected: (('ERROR', 'another error'),) actual: (('ERROR', 'an error'),) The decorator ~~~~~~~~~~~~~ To capture logging for a particular test function: .. code-block:: python from testfixtures import log_capture @log_capture() def test_function(capture): logger = logging.getLogger() logger.info('a message') logger.error('an error') capture.check( ('root', 'INFO', 'a message'), ('root', 'ERROR', 'an error'), ) .. check the above raises no assertion error: >>> test_function() .. note:: This method is not compatible with pytest's fixture discovery stuff. Manual usage ~~~~~~~~~~~~ You can also manually instantiate, install and uninstall a :class:`~testfixtures.LogCapture`: >>> from testfixtures import LogCapture, LoggingSource >>> logs = LogCapture(LoggingSource(), install=False) When you want to start capturing, :meth:`~LogCapture.install` the capture: >>> logs.install() You can then execute code that will log the events you want to test: >>> from logging import getLogger >>> getLogger().info('a message') At any point, you can check what has been logged using the check method: >>> logs.check(('INFO', 'a message')) Alternatively, you can use the string representation of the :class:`~testfixtures.LogCapture`: >>> print(logs) INFO a message When you're done capturing: >>> logs.uninstall() The :meth:`~testfixtures.LogCapture.uninstall` method can also be added as an :meth:`~unittest.TestCase.addCleanup` if that is easier or more compact in your test suite. If you have multiple :class:`~testfixtures.LogCapture` objects in use, you can easily uninstall them all: >>> LogCapture.uninstall_all() .. _check-log-config: Testing logging configuration ----------------------------- :class:`LogCapture` is good for checking that your code is logging the correct messages; just as important is checking that your application has correctly configured log handlers. If you have a ``setup_logging`` function such as this: .. code-block:: python def setup_logging(level: int = logging.INFO) -> None: # Our logging configuration code, in this case just a # call to basicConfig: logging.basicConfig( format='%(levelname)s %(message)s', level=level, force=True, ) This can be tested with a unit test such as the following: .. code-block:: python from testfixtures import Replacer, compare, like import logging import sys logger = logging.getLogger() def test_setup_logging() -> None: with Replacer() as replace: # We mock out the handlers list for the logger we're # configuring in such a way that we have no handlers # configured at the start of the test and the handlers our # configuration installs are removed at the end of the test. replace(logger.handlers, [], container=logger, name='handlers') replace(logger.level, 0, container=logger, name='level') setup_logging(level=logging.WARNING) compare(logger.level, expected=logging.WARNING) compare( logger.handlers, expected=[ like( logging.StreamHandler, stream = sys.stderr, formatter = like( logging.Formatter, _fmt = '%(levelname)s %(message)s' ), level=logging.NOTSET ) ] ) .. check it: >>> test_setup_logging() testfixtures-12.4.0/docs/loguru.rst000066400000000000000000000176041525773641100173710ustar00rootroot00000000000000Testing with Loguru =================== .. note:: To ensure you are using compatible versions, install with the ``testfixtures[loguru]`` extra. .. invisible-code-block: python try: import loguru except ImportError: loguru = None .. skip: start if(loguru is None, reason="No loguru installed") `Loguru `_ provides a streamlined logging API through a single global :class:`~loguru._logger.Logger` object. :class:`~testfixtures.loguru.LoguruSource` is provided to capture and test using :class:`~testfixtures.LogCapture`. As a simple example, you can capture loguru output with a `pytest`__ fixture such as this: __ https://docs.pytest.org/ .. code-block:: python import pytest from testfixtures import LogCapture from testfixtures.loguru import LoguruSource @pytest.fixture() def logs(): with LogCapture(LoguruSource()) as logs_: yield logs_ You can then check logging in your tests like this: .. code-block:: python from loguru import logger def test_logging(logs: LogCapture) -> None: logger.info('42 is fine') logger.error('13 is not') logs.check( ('INFO', '42 is fine'), ('ERROR', '13 is not'), ) .. invisible-code-block: python from sybil.testing import run_pytest run_pytest(test_logging, fixtures=[logs]) See :doc:`logging` for the full :class:`~testfixtures.LogCapture` API, including :meth:`~testfixtures.LogCapture.check`, :meth:`~testfixtures.LogCapture.check_present`, :meth:`~testfixtures.LogCapture.actual`, and the :attr:`~testfixtures.LogCapture.entries` attribute. Inspecting raw records ---------------------- Each :class:`~testfixtures.logcapture.Entry` in :attr:`~testfixtures.LogCapture.entries` exposes the underlying loguru :ref:`record dict ` via its ``raw`` attribute: .. code-block:: python from testfixtures import LogCapture from testfixtures.loguru import LoguruSource with LogCapture(LoguruSource()) as log: logger.info('hello world') >>> log.entries[0].raw {..., 'level': (name='INFO', ..., 'message': 'hello world', ...} The loguru :ref:`record dict ` contains rich contextual information. Checking logging context ------------------------ Loguru supports structured context via :meth:`~loguru._logger.Logger.bind`, which creates a logger with fixed extra fields, and :meth:`~loguru._logger.Logger.contextualize`, which temporarily adds context for all logging within a block. Both are exposed through the ``extra`` key in the record dict. To capture ``extra`` alongside the default level and message, include it in the ``attributes``: .. code-block:: python from testfixtures.loguru import LoguruSource, level_name request_log = logger.bind(request_id='abc123') with LogCapture(LoguruSource((level_name, 'message', 'extra'))) as log: request_log.info('handling request') request_log.info('request complete') log.check( ('INFO', 'handling request', {'request_id': 'abc123'}), ('INFO', 'request complete', {'request_id': 'abc123'}), ) :meth:`~loguru._logger.Logger.contextualize` works the same way but scopes the context to a ``with`` block, affecting all loggers: .. code-block:: python with LogCapture(LoguruSource((level_name, 'message', 'extra'))) as log: logger.info('before task') with logger.contextualize(task_id=1234): logger.info('processing task') logger.info('after task') log.check( ('INFO', 'before task', {}), ('INFO', 'processing task', {'task_id': 1234}), ('INFO', 'after task', {}) ) Capturing specific fields ------------------------- You can control which fields form the ``actual`` value by passing sequence of ``attributes`` to :class:`~testfixtures.loguru.LoguruSource`. Elements may be string keys into the :ref:`record dict ` or callables that receive the :ref:`record dict `. To capture only the message: .. code-block:: python with LogCapture(LoguruSource('message')) as log: logger.info('just the message') log.check('just the message') You can also mix string keys and callables: .. code-block:: python with LogCapture(LoguruSource((lambda r: r['level'].name, 'message'))) as log: logger.debug('a debug message') logger.info('something info') log.check( ('DEBUG', 'a debug message'), ('INFO', 'something info'), ) For full control, pass a single callable: .. code-block:: python def extract(record): return {'level': record['level'].name, 'message': record['message']} with LogCapture(LoguruSource(extract)) as log: logger.debug('a debug message') logger.error('an error') log.check( {'level': 'DEBUG', 'message': 'a debug message'}, {'level': 'ERROR', 'message': 'an error'}, ) Filtering by level ------------------ To capture only messages at or above a minimum level: .. code-block:: python with LogCapture(LoguruSource(level='WARNING')) as log: logger.info('ignored') logger.warning('captured') log.check(('WARNING', 'captured')) Combining with standard library logging --------------------------------------- If your application uses both loguru and the standard library's :mod:`logging`, pass both a :class:`~testfixtures.LoggingSource` and a :class:`~testfixtures.loguru.LoguruSource` to one :class:`~testfixtures.LogCapture` to capture and check both: .. code-block:: python import logging from testfixtures import LogCapture, LoggingSource from testfixtures.loguru import LoguruSource with LogCapture(LoggingSource(), LoguruSource()) as log: logging.warning('from standard library logging') logger.warning('from loguru') log.check( ('WARNING', 'from standard library logging'), ('WARNING', 'from loguru'), ) Exceptions ---------- When code logs an exception, the underlying exception object is stored in :attr:`~testfixtures.logcapture.Entry`'s ``exception`` attribute: .. code-block:: python from testfixtures import LogCapture, compare from testfixtures.loguru import LoguruSource with LogCapture(LoguruSource()) as logs: try: raise ValueError('boom') except ValueError: logger.exception('oh no') compare(logs.entries[-1].exception, expected=ValueError('boom')) Checking the configuration of your log sinks --------------------------------------------- :class:`~testfixtures.loguru.LoguruSource` is good for checking that your code is logging the correct messages; just as important is checking that your application has correctly configured loguru :ref:`sinks `. If you have a ``setup_logging`` function such as this: .. code-block:: python import sys from loguru import logger def setup_logging(level: str = 'INFO') -> None: logger.remove() logger.add(sys.stderr, level=level, format='{level} {message}') This can be tested with a unit test such as the following: .. code-block:: python import logging import sys from loguru._handler import Handler as LoguruHandler from loguru._simple_sinks import StreamSink from testfixtures import Replacer, compare, like def test_setup_logging() -> None: with Replacer() as replace: replace(logger._core.handlers, {}, container=logger._core, name='handlers') setup_logging(level='WARNING') handlers = list(logger._core.handlers.values()) compare( handlers, expected=[ like( LoguruHandler, levelno=logging.WARNING, _sink=like(StreamSink, _stream=sys.stderr), ) ], ) compare(handlers[0]._formatter.strip(), expected='{level} {message}\n{exception}') .. check it: >>> test_setup_logging() testfixtures-12.4.0/docs/make.bat000066400000000000000000000047621525773641100167300ustar00rootroot00000000000000@ECHO OFF REM Command file for Sphinx documentation set SPHINXBUILD=..\bin\sphinx-build set ALLSPHINXOPTS=-d _build/doctrees %SPHINXOPTS% . if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. changes to make an overview over all changed/added/deprecated items echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (_build\*) do rmdir /q /s %%i del /q /s _build\* goto end ) if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% _build/html echo. echo.Build finished. The HTML pages are in _build/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% _build/dirhtml echo. echo.Build finished. The HTML pages are in _build/dirhtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% _build/pickle echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% _build/json echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% _build/htmlhelp echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in _build/htmlhelp. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% _build/latex echo. echo.Build finished; the LaTeX files are in _build/latex. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% _build/changes echo. echo.The overview file is in _build/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% _build/linkcheck echo. echo.Link check complete; look for any errors in the above output ^ or in _build/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% _build/doctest echo. echo.Testing of doctests in the sources finished, look at the ^ results in _build/doctest/output.txt. goto end ) :end testfixtures-12.4.0/docs/mocking.rst000066400000000000000000000465501525773641100175050ustar00rootroot00000000000000Mocking out objects and methods =============================== .. py:currentmodule:: testfixtures Mocking is the process of replacing objects used in your code with ones that make testing easier, but only while the tests are running. This may mean replacing resources or dependencies, such as database connections or file paths, with ones that are isolated for testing. It may also mean replacing chunks of complex functionality that aren't the subject of the test with mock objects that allow you to check that the mocked out functionality is being used as expected. .. _what-mock: What to mock with ----------------- Python has a standard mock implementation in the form of :mod:`unittest.mock` which is also available as a `rolling backport`__ so that the latest features and bugfixes can be used in any version of Python. __ https://mock.readthedocs.io For convenience, testfixtures provides a facade over both of these in the form of :mod:`testfixtures.mock`. The contents are identical and preference is given to the rolling backport if it is installed. The facade also contains any bugfixes that are critical to the operation of functionality provided by testfixtures. .. note:: If using :mod:`testfixtures.mock`, you can install with the ``testfixtures[mock-backport]`` extra to ensure a compatible version of the mock backport is used. Testfixtures also provides specialised mocks for dealing with :doc:`dates and times ` and :doc:`subprocesses `. How to mock ----------- Testfixtures provides :class:`Replace`, :class:`Replacer` and the :func:`replace` decorator to mock out objects. These work in a similar way to :func:`unittest.mock.patch`, and have been around longer. They still provide a little more flexibility than :func:`~unittest.mock.patch`, so use whichever feels best in your codebase. Methods of replacement ---------------------- When using the tools provided by Testfixtures, there are three different methods of mocking out functionality that can be used to replace functions, classes or even individual methods on a class. Consider the following module: .. topic:: tests.sample1 :class: module .. literalinclude:: ../tests/sample1.py :pyobject: X .. do the import quietly >>> from tests.sample1 import X We want to mock out the ``y`` method of the ``X`` class, with, for example, the following function: .. code-block:: python def mock_y(self): return 'mock y' The context managers ~~~~~~~~~~~~~~~~~~~~ The most flexible tool for replacement of a single thing, is the :class:`~testfixtures.Replace` context manager: .. code-block:: python from testfixtures import Replace from tests.sample1 import X def test_function(): with Replace(X.y, mock_y, container=X): print(X().y()) For the duration of the ``with`` block, the replacement is used: >>> test_function() mock y For multiple replacements, the :class:`~testfixtures.Replacer` context manager is the best tool: .. code-block:: python from testfixtures import Replacer from tests.sample1 import X from tests import sample1, sample3 import os def mock_y(self) -> str: sample_env = os.environ.get('SAMPLE_ENV') sample_constant = sample3.SOME_CONSTANT sample_z = sample1.z() return f'mock y, {sample_env=}, {sample_constant=}, {sample_z=}' def test_function(): with Replacer() as replace: replace.on_class(X.y, mock_y) replace.in_module(sample1.z, lambda: 'mock z') replace.in_environ('SAMPLE_ENV', 'test') replace(sample3.SOME_CONSTANT, 24, name='SOME_CONSTANT', container=sample3) print(X().y()) For the duration of the ``with`` block, the replacement is used: >>> test_function() mock y, sample_env='test', sample_constant=24, sample_z='mock z' Three other convenience context managers are provided for common replacement patterns: - To replace or remove environment variables, use :func:`replace_in_environ`: .. code-block:: python import os from testfixtures import replace_in_environ def test_function(): with replace_in_environ('SOME_ENV_VAR', 1234): print(repr(os.environ['SOME_ENV_VAR'])) For the duration of the ``with`` block, the replacement is used: >>> test_function() '1234' For more details, see :ref:`replacing-in-environ`. - To replace methods on classes, including normal methods, class methods and static methods, use :func:`replace_on_class`: .. code-block:: python from testfixtures import replace_on_class from tests.sample1 import X def test_function(): instance = X() with replace_on_class(X.y, lambda self: 'mock y'): print(instance.y()) For the duration of the ``with`` block, the replacement is used: >>> test_function() mock y For more details, see :ref:`replacing-on-classes`. - To replace functions in modules use :func:`replace_in_module`: .. code-block:: python from testfixtures import replace_in_module from tests.sample1 import z as original_z def test_function(): with replace_in_module(original_z, lambda: 'replacement z'): from tests.sample1 import z print(z()) For the duration of the ``with`` block, the replacement is used: >>> test_function() replacement z For more details, see :ref:`replacing-in-modules`. The decorator ~~~~~~~~~~~~~ If you want to replace different things in different test functions, you may find the decorator suits your needs better: .. code-block:: python from testfixtures import replace def mock_y(self): return 'mock y' @replace(X.y, mock_y, container=X) def test_function(): print(X().y()) When using the decorator, the replacement is used for the duration of the decorated callable's execution: >>> test_function() mock y If you need to manipulate or inspect the object that's used as a replacement, define the mock before decorating: .. code-block:: python from testfixtures.mock import Mock, call from testfixtures import compare, replace @replace(X.y, Mock(), container=X) def test_function(mocked_y: Mock) -> None: mocked_y.return_value = 'mock y' print(X().y()) compare(mocked_y.mock_calls, expected=[call()]) The above still results in the same output: >>> test_function() mock y .. note:: This method is not compatible with pytest's fixture discovery stuff. Instead, put a fixture such as the following in your ``conftest.py``: .. code-block:: python from testfixtures import replace_on_class from testfixtures.mock import Mock import pytest @pytest.fixture() def mocked_y(): m = Mock() with replace(X.y, Mock(), container=X): yield m Manual usage ~~~~~~~~~~~~ If you want to replace something for the duration of a doctest or you want to replace something for every test in a :class:`~unittest.TestCase`, then you can use the :class:`~testfixtures.Replacer` manually. The instantiation and replacement are done in the set-up step of the :class:`~unittest.TestCase` or equivalent: >>> from testfixtures import Replacer >>> replacer = Replacer() >>> replacer.on_class(X.y, mock_y) The replacement then stays in place until removed: >>> X().y() 'mock y' Then, in the tear-down step of the :class:`~unittest.TestCase` or equivalent, the replacement is removed: >>> replacer.restore() >>> X().y() 'original y' The :meth:`~testfixtures.Replacer.restore` method can also be added as an :meth:`~unittest.TestCase.addCleanup` if that is easier or more compact in your test suite. Replacing more than one thing ----------------------------- Both the :class:`~testfixtures.Replacer` and the :func:`~testfixtures.replace` decorator can be used to replace more than one thing at a time. For the former, this is fairly obvious: .. code-block:: python from tests.sample1 import X def test_function(): with Replacer() as replace: replace.on_class(X.y, lambda self: 'mock y') replace.on_class(X.aMethod, lambda cls: 'mock method') x = X() print(x.y(), x.aMethod()) .. the result: >>> test_function() mock y mock method For the decorator, it's less obvious but still pretty easy: .. code-block:: python from testfixtures import replace_on_class from testfixtures.mock import Mock @replace(X.y, Mock(name='y', return_value='mock y'), container=X) @replace(X.aMethod, Mock(name='a', return_value='mock method'), container=X, strict=False) def test_function(aMethod, y): print(aMethod, y) x = X() print(x.y(), x.aMethod()) You'll notice that you can still get access to the replacements, even though there are several of them: >>> test_function() mock y mock method Replacing things that may not be there -------------------------------------- The following code shows a situation where ``hpy`` may or may not be present depending on whether the ``guppy`` package is installed or not. .. topic:: tests.sample2 :class: module .. literalinclude:: ../tests/sample2.py :lines: 10-19 To test the behaviour of the code that uses ``hpy`` in both of these cases, regardless of whether or not the ``guppy`` package is actually installed, we need to be able to mock out both ``hpy`` and the ``guppy`` global. This is done by doing non-strict replacement, as shown in the following :class:`~unittest.TestCase`: .. imports >>> import unittest,sys >>> from pprint import pprint .. code-block:: python from tests import sample2 as sample2_module from tests.sample2 import dump, guppy from testfixtures import replace from testfixtures.mock import Mock, call class Tests(unittest.TestCase): @replace(container=sample2_module, target=guppy, name='guppy', replacement=True) @replace(container=sample2_module, target='.hpy', replacement=Mock(), strict=False) def test_method(self, hpy): dump('somepath') compare(hpy.mock_calls, expected=[ call(), call().heap(), call().heap().stat.dump('somepath') ]) @replace(container=sample2_module, target=guppy, name='guppy', replacement=False) @replace(container=sample2_module, target='.hpy', replacement=Mock(), strict=False) def test_method_no_heapy(self, hpy): dump('somepath') compare(hpy.mock_calls, expected=[]) .. the result: >>> from io import StringIO >>> suite = unittest.TestLoader().loadTestsFromTestCase(Tests) >>> unittest.TextTestRunner(verbosity=0,stream=StringIO()).run(suite) Non-strict replacement using the ``strict`` keyword parameter is supported when calling a :class:`Replacer` or any of its methods. Replacing items in dictionaries and lists ----------------------------------------- :class:`~testfixtures.Replace`, :class:`~testfixtures.Replacer` and the :func:`~testfixtures.replace` decorator can be used to replace items in dictionaries and lists. If the dictionary is :any:`os.environ`, then see :ref:`replacing-in-environ`. .. imports >>> from testfixtures import Replace For a lists such as this: >>> sample_list = [1, 2, 3] An element can be placed as follows: >>> with Replace(container=sample_list, target='.1', replacement=42): ... print(sample_list) [1, 42, 3] For dictionaries such as this: >>> sample_dict = {1: 'a', 'z': 'b'} String keys can be replaced as follows: >>> with Replace(container=sample_dict, target='.z', replacement='c'): ... print(sample_dict) {1: 'a', 'z': 'c'} For non-string keys, it takes a bit more work: >>> from operator import getitem >>> with Replace( ... container=sample_dict, accessor=getitem, name=1, target='a', replacement='c' ... ): ... print(sample_dict) {1: 'c', 'z': 'b'} For nested data structures such as this: >>> nested = {'key': [1, 2, 3]} Nested traversal can be used: >>> with Replace(container=nested, target='.key.2', replacement=42): ... print(nested) {'key': [1, 2, 42]} If your dictionary or other item-based traversal key contains periods, a different separator can be used: >>> sample_dict = {'.foo': 'bar'} >>> with Replace(container=sample_dict, target=':.foo', sep=':', replacement='baz'): ... print(sample_dict) {'.foo': 'baz'} .. _removing_attr_and_item: Removing attributes and dictionary items ---------------------------------------- :class:`~testfixtures.Replace`, :class:`~testfixtures.Replacer` and the :func:`~testfixtures.replace` decorator can be used to remove attributes from objects and remove items from dictionaries. For example, suppose you have a data structure like the following: .. topic:: tests.sample1 :class: module .. literalinclude:: ../tests/sample1.py :lines: 67-70 If you want to remove the ``key`` for the duration of a test, you can do so as follows: .. code-block:: python from testfixtures import Replace, not_there from tests.sample1 import some_dict def test_function(): with Replace(container=some_dict, target='.key', replacement=not_there): pprint(some_dict) While the replacement is in effect, ``key`` is gone: >>> test_function() {'complex_key': [1, 2, 3]} When it is no longer in effect, ``key`` is returned: >>> pprint(some_dict) {'complex_key': [1, 2, 3], 'key': 'value'} If you want the whole ``some_dict`` dictionary to be removed for the duration of a test, you would do so as follows: .. code-block:: python from testfixtures import Replace, not_there from tests import sample1 as sample1_module from tests.sample1 import some_dict def test_function(): with Replace( container=sample1, target=some_dict, name='some_dict', replacement=not_there ): print(hasattr(sample1, 'some_dict')) While the replacement is in effect, ``some_dict`` is gone: >>> test_function() False When it is no longer in effect, ``some_dict`` is returned: >>> pprint(sample1.some_dict) {'complex_key': [1, 2, 3], 'key': 'value'} .. _replacing-in-environ: Replacing environment variables ------------------------------- To ensure an environment variable is present and set to a particular value, use :meth:`~Replacer.in_environ`: >>> import os >>> replace = Replacer() >>> replace.in_environ('SOME_ENV_VAR', 1234) >>> print(repr(os.environ['SOME_ENV_VAR'])) '1234' If you want to make sure an environment variable is unset and not present, use :any:`not_there`: >>> replace.in_environ('SOME_ENV_VAR', not_there) >>> 'SOME_ENV_VAR' in os.environ False .. invisible-code-block: python replace.restore() .. _replacing-on-classes: Replacing methods on classes ---------------------------- To replace methods on classes, including normal methods, class methods and static methods, in a way that is friendly to static analysis, use :meth:`~Replacer.on_class`: .. code-block:: python class MyClass: def normal_method(self, value: str) -> str: return 'original' + value @classmethod def class_method(cls, value: str) -> str: return 'original' + value @staticmethod def static_method(value: str) -> str: return 'original' + value For normal methods, the replacement will be called with the correct ``self``: >>> instance = MyClass() >>> replace = Replacer() >>> replace.on_class(MyClass.normal_method, lambda self, value: type(self).__name__+value) >>> print(instance.normal_method(':it')) MyClass:it For class methods, the replacement you provide will be wrapped in a :any:`classmethod` if you have not already done so: >>> replace.on_class(MyClass.class_method, lambda cls, value: cls.__name__+value) >>> print(instance.class_method(':it')) MyClass:it Likewise, for static methods, the replacement you provide will be wrapped in a :any:`staticmethod` if you have not already done so: >>> replace.on_class(MyClass.static_method, lambda value: 'mocked'+value) >>> print(instance.static_method(':it')) mocked:it .. invisible-code-block: python replace.restore() If you need to replace a class attribute such as ``FOO`` in this example: .. code-block:: python class MyClass: FOO = 1 It can be done like this: >>> instance = MyClass() >>> replace = Replacer() >>> replace(MyClass.FOO, 42, container=MyClass, name='FOO') 42 >>> print(instance.FOO) 42 .. invisible-code-block: python replace.restore() If you encounter methods that have an incorrect ``__name__``, such as those returned by poorly implemented decorators: .. code-block:: python def bad(f): def inner(self, x): return f(self, x) return inner class SampleClass: @bad def method(self, x): return x*2 They can be replaced by specifying the correct name: >>> instance = SampleClass() >>> replace = Replacer() >>> replace.on_class(SampleClass.method, lambda self, value: value*3, name='method') >>> print(instance.method(2)) 6 .. invisible-code-block: python replace.restore() .. _replacing-in-modules: Replacing items in modules -------------------------- To replace functions in modules use :meth:`~Replacer.in_module`: >>> from tests.sample1 import z as original_z >>> replace = Replacer() >>> replace.in_module(original_z, lambda: 'replacement z') >>> from tests.sample1 import z >>> z() 'replacement z' .. invisible-code-block: python replace.restore() If you need to replace usage in a module other than the one where the function is defined, it can be done as follows >>> from tests.sample1 import z >>> from tests import sample3 >>> replace = Replacer() >>> replace.in_module(z, lambda: 'replacement z', module=sample3) >>> sample3.z() 'replacement z' .. invisible-code-block: python replace.restore() If you need to replace a module global, then you can use :class:`Replace` as follows: >>> from tests import sample3 >>> replacer = Replacer() >>> replacer.replace( ... sample3.SOME_CONSTANT, 43, container=sample3, name='SOME_CONSTANT' ... ) >>> from tests.sample3 import SOME_CONSTANT >>> SOME_CONSTANT 43 .. invisible-code-block: python replacer.restore() Gotchas ------- - Make sure you replace the object where it's used and not where it's defined. For example, with the following code from the ``tests.sample1`` package: .. literalinclude:: ../tests/sample1.py :lines: 30-34 .. invisible-code-block: python import time You might be tempted to mock things as follows: >>> replace = Replacer() >>> replace.in_module(time.time, Mock()) But this won't work: >>> from tests.sample1 import str_time >>> type(float(str_time())) <... 'float'> .. cleanup >>> replace.restore() You need to replace :func:`~time.time` where it's used, not where it's defined: >>> from tests import sample1 as sample1_module >>> replace.in_module(time.time, Mock(), module=sample1_module) >>> str_time() "<...Mock...>" .. cleanup >>> replace.restore() A corollary of this is that you need to replace *all* occurrences of an original to safely be able to test. This can be tricky when an original is imported into many modules that may be used by a particular test. - You can't replace whole top level modules, and nor should you want to! The reason being that everything up to the last dot in the replacement target specifies where the replacement will take place, and the part after the last dot is used as the name of the thing to be replaced: >>> Replacer().replace('sys', Mock()) Traceback (most recent call last): ... ValueError: target must contain at least one dot! testfixtures-12.4.0/docs/numpy.rst000066400000000000000000000070501525773641100172160ustar00rootroot00000000000000Testing with NumPy ================== .. note:: To ensure you are using compatible versions, install with the ``testfixtures[numpy]`` extra. .. invisible-code-block: python try: import numpy except ImportError: numpy = None .. skip: start if(numpy is None, reason="No numpy installed") When numpy is installed, :func:`comparers ` for :class:`numpy.ndarray` and :class:`numpy.ma.MaskedArray` are automatically :ref:`registered ` with ``ignore_eq=True``. They hand off to :func:`numpy.testing.assert_allclose` for float and complex arrays and :func:`numpy.testing.assert_array_equal` for arrays of any other :class:`dtype `, so the diff output is that of numpy's own test helpers. Shape and dtype must always match: there is no broadcasting and an ``int32`` array is never equal to an ``int64`` one. NaNs in matching positions compare equal. numpy scalars need none of this: their ``==`` returns a real :class:`bool`, so they already work with :func:`~testfixtures.compare`. Equal arrays compare equal: >>> import numpy as np >>> from testfixtures import compare >>> compare(np.array([1, 2, 3]), expected=np.array([1, 2, 3])) When arrays differ, numpy's excellent summaries are used: >>> compare(np.array([1., 2., 3.]), expected=np.array([1., 2.5, 3.])) Traceback (most recent call last): ... AssertionError: Not equal to tolerance rtol=1e-05, atol=1e-08 Mismatched elements: 1 / 3 (33.3%)... Max absolute difference among violations: 0.5 Max relative difference among violations: 0.2 ACTUAL: array([1., 2., 3.]) DESIRED: array([1. , 2.5, 3. ]) When arrays differ inside a larger structure, :func:`~testfixtures.compare`'s breadcrumbs still point at the location of the difference: >>> compare({'foo': np.array([1])}, expected={'foo': np.array([2])}) Traceback (most recent call last): ... AssertionError: dict not as expected: values differ: 'foo': array([2]) (expected) != array([1]) (actual) While comparing ['foo']: Arrays are not equal Mismatched elements: 1 / 1 (100%)... Max absolute difference among violations: 1 Max relative difference among violations: 0.5 ACTUAL: array([1]) DESIRED: array([2]) Float and complex arrays compare equal within ``rtol=1e-5`` and ``atol=1e-8``, matching the pandas and polars comparers rather than the tighter defaults of :func:`numpy.testing.assert_allclose`: >>> fa = np.array([1.0 + 1e-9, 2.0]) >>> fb = np.array([1.0, 2.0]) >>> compare(fa, expected=fb) Pass ``strict=True`` to require bitwise equality instead: >>> compare(fa, expected=fb, strict=True) Traceback (most recent call last): ... AssertionError: Arrays are not equal Mismatched elements: 1 / 2 (50%)... Max absolute difference among violations: 1.00000008e-09 Max relative difference among violations: 1.00000008e-09 ACTUAL: array([1., 2.]) DESIRED: array([1., 2.]) Masked arrays ------------- For :class:`numpy.ma.MaskedArray`, the mask is part of the data, much as nulls are in a dataframe: masks must match exactly regardless of other options, while data under matching masked positions is ignored: >>> m1 = np.ma.MaskedArray([1, 2, 3], mask=[False, True, False]) >>> m2 = np.ma.MaskedArray([1, 9, 3], mask=[False, True, False]) >>> compare(m1, expected=m2) >>> compare(m1, expected=np.ma.MaskedArray([1, 2, 3])) Traceback (most recent call last): ... AssertionError: masks differ: Arrays are not equal Mismatched elements: 1 / 3 (33.3%)... ACTUAL: array([False, True, False]) DESIRED: array([False, False, False]) testfixtures-12.4.0/docs/pandas.rst000066400000000000000000000041341525773641100173140ustar00rootroot00000000000000Testing with Pandas =================== .. note:: To ensure you are using compatible versions, install with the ``testfixtures[pandas]`` extra. .. invisible-code-block: python try: import pandas except ImportError: pandas = None .. skip: start if(pandas is None, reason="No pandas installed") When pandas is installed, a :func:`comparer ` for :class:`pandas.DataFrame` is automatically :ref:`registered ` with ``ignore_eq=True``. It hands off to :func:`pandas.testing.assert_frame_equal`, so the diff output and tolerance semantics are exactly those of pandas' own test helper. Passing ``strict=True`` to :func:`~testfixtures.compare` switches the underlying call to ``check_exact=True``. Equal frames compare equal: >>> import pandas as pd >>> from testfixtures import compare >>> df1 = pd.DataFrame({'a': [1]}) >>> df2 = pd.DataFrame({'a': [1]}) >>> compare(df1, expected=df2) When frames differ inside a larger structure, :func:`~testfixtures.compare`'s breadcrumbs still point at the location of the difference: >>> compare({'foo': df1}, expected={'foo': pd.DataFrame({'a': [2]})}) Traceback (most recent call last): ... AssertionError: dict not as expected: values differ: 'foo': a 0 2 (expected) != a 0 1 (actual) While comparing ['foo']: DataFrame.iloc[:, 0] (column name="a") are different DataFrame.iloc[:, 0] (column name="a") values are different (100.0 %) [index]: [0] [left]: [2] [right]: [1] At positional index 0, first diff: 2 != 1 Float comparisons use pandas' default ``rtol`` and ``atol``, so small differences compare equal: >>> fa = pd.DataFrame({'x': [1.0]}) >>> fb = pd.DataFrame({'x': [1.0 + 1e-9]}) >>> compare(fa, expected=fb) Pass ``strict=True`` to require bitwise equality instead: >>> compare(fa, expected=fb, strict=True) Traceback (most recent call last): ... AssertionError: DataFrame.iloc[:, 0] (column name="x") are different DataFrame.iloc[:, 0] (column name="x") values are different (100.0 %) [index]: [0] [left]: [1.000000001] [right]: [1.0] testfixtures-12.4.0/docs/polars.rst000066400000000000000000000037221525773641100173500ustar00rootroot00000000000000Testing with Polars =================== .. note:: To ensure you are using compatible versions, install with the ``testfixtures[polars]`` extra. .. invisible-code-block: python try: import polars except ImportError: polars = None .. skip: start if(polars is None, reason="No polars installed") When polars is installed, a :func:`comparer ` for ``polars.DataFrame`` is automatically :ref:`registered ` with ``ignore_eq=True``. It hands off to :func:`polars.testing.assert_frame_equal`, so the diff output and tolerance semantics are exactly those of polars' own test helper. Passing ``strict=True`` to :func:`~testfixtures.compare` switches the underlying call to ``check_exact=True``. Equal frames compare equal: >>> import polars as pl >>> from testfixtures import compare >>> df1 = pl.DataFrame({'a': [1]}) >>> df2 = pl.DataFrame({'a': [1]}) >>> compare(df1, expected=df2) When frames differ inside a larger structure, :func:`~testfixtures.compare`'s breadcrumbs still point at the location of the difference: >>> compare({'foo': df1}, expected={'foo': pl.DataFrame({'a': [2]})}) Traceback (most recent call last): ... AssertionError: dict not as expected: values differ: 'foo': shape: (1, 1) ... While comparing ['foo']: DataFrames are different (value mismatch for column "a") [left]: shape: (1,) Series: 'a' [i64] [ 2 ] [right]: shape: (1,) Series: 'a' [i64] [ 1 ] Float comparisons use polars' default ``rel_tol`` and ``abs_tol``, so small differences compare equal: >>> fa = pl.DataFrame({'x': [1.0]}) >>> fb = pl.DataFrame({'x': [1.0 + 1e-9]}) >>> compare(fa, expected=fb) Pass ``strict=True`` to require bitwise equality instead: >>> compare(fa, expected=fb, strict=True) Traceback (most recent call last): ... AssertionError: DataFrames are different (value mismatch for column "x") [left]: shape: (1,) Series: 'x' [f64] [ 1.0 ] [right]: shape: (1,) Series: 'x' [f64] [ 1.0 ] testfixtures-12.4.0/docs/popen.rst000066400000000000000000000162631525773641100171750ustar00rootroot00000000000000 .. currentmodule:: testfixtures.popen Testing subprocesses ==================== When using the :mod:`subprocess` package there are two approaches to testing: * Have your tests exercise the real processes being instantiated and used. * Mock out use of the :mod:`subprocess` package and provide expected output while recording interactions with the package to make sure they are as expected. While the first of these should be preferred, it means that you need to have all the external software available everywhere you wish to run tests. Your tests will also need to make sure any dependencies of that software on an external environment are met. If that external software takes a long time to run, your tests will also take a long time to run. These challenges can often make the second approach more practical and can be the more pragmatic approach when coupled with a mock that accurately simulates the behaviour of a subprocess. :class:`~testfixtures.popen.MockPopen` is an attempt to provide just such a mock. .. warning:: Previous versions of this mock made use of :attr:`~unittest.mock.Mock.mock_calls`. These are deceptively incapable of recording some information important in the use of this mock, so please switch to making assertions about :attr:`~MockPopen.all_calls` and :attr:`~MockPopenInstance.calls` instead. Example usage ------------- As an example, suppose you have code such as the following that you need to test: .. literalinclude:: ../tests/test_popen_docs.py :lines: 4-12 Tests that exercise this code using :class:`~testfixtures.popen.MockPopen` could be written as follows: .. literalinclude:: ../tests/test_popen_docs.py :lines: 16-52 Passing input to processes -------------------------- If your testing requires passing input to the subprocess, you can do so by checking for the input passed to :meth:`~subprocess.Popen.communicate` method when you check the calls on the mock as shown in this example: .. literalinclude:: ../tests/test_popen_docs.py :pyobject: TestMyFunc.test_communicate_with_input :dedent: 4 .. note:: Accessing ``.stdin`` isn't current supported by this mock. Reading from ``stdout`` and ``stderr`` -------------------------------------- The :attr:`~MockPopenInstance.stdout` and :attr:`~MockPopenInstance.stderr` attributes of the mock returned by :class:`~testfixtures.popen.MockPopen` will be file-like objects as with the real :class:`~subprocess.Popen` and can be read as shown in this example: .. literalinclude:: ../tests/test_popen_docs.py :pyobject: TestMyFunc.test_read_from_stdout_and_stderr :dedent: 4 .. warning:: While these streams behave a lot like the streams of a real :class:`~subprocess.Popen` object, they do not exhibit the deadlocking behaviour that can occur when the two streams are read as in the example above. Be very careful when reading :attr:`~MockPopenInstance.stdout` and :attr:`~MockPopenInstance.stderr` and consider using :meth:`~subprocess.Popen.communicate` instead. Writing to ``stdin`` -------------------- If you set ``stdin=PIPE`` in your call to :class:`~subprocess.Popen` then the :attr:`~MockPopenInstance.stdin` attribute of the mock returned by :class:`~testfixtures.popen.MockPopen` will be a mock and you can then examine the write calls to it as shown in this example: .. literalinclude:: ../tests/test_popen_docs.py :pyobject: TestMyFunc.test_write_to_stdin :dedent: 4 Specifying the return code -------------------------- Often code will need to behave differently depending on the return code of the launched process. Specifying a simulated response code, along with testing for the correct usage of :meth:`~subprocess.Popen.wait`, can be seen in the following example: .. literalinclude:: ../tests/test_popen_docs.py :pyobject: TestMyFunc.test_wait_and_return_code :dedent: 4 Checking for signal sending --------------------------- Calls to :meth:`~MockPopenInstance.send_signal`, :meth:`~MockPopenInstance.terminate` and :meth:`~MockPopenInstance.kill` are all recorded by the mock returned by :class:`~testfixtures.popen.MockPopen`. However, other than being recorded, these calls do nothing. The following example doesn't make sense for a real test of sub-process usage but does show how the mock behaves: .. literalinclude:: ../tests/test_popen_docs.py :pyobject: TestMyFunc.test_send_signal :dedent: 4 Polling a process ----------------- The :meth:`~subprocess.Popen.poll` method is often used as part of a loop in order to do other work while waiting for a sub-process to complete. The mock returned by :class:`~testfixtures.popen.MockPopen` supports this by allowing the :meth:`~MockPopenInstance.poll` method to be called a number of times before the :attr:`~MockPopenInstance.returncode` is set using the ``poll_count`` parameter as shown in the following example: .. literalinclude:: ../tests/test_popen_docs.py :pyobject: TestMyFunc.test_poll_until_result :dedent: 4 Different behaviour on sequential processes ------------------------------------------- If your code needs to call the same command but have different behaviour on each call, then you can pass a callable behaviour like this: .. literalinclude:: ../tests/test_popen_docs.py :pyobject: TestMyFunc.test_multiple_responses :dedent: 4 If you need to keep state across calls, such as accumulating :attr:`~MockPopenInstance.stdin` or failing for a configurable number of calls, then wrap that behaviour up into a class: .. literalinclude:: ../tests/test_popen_docs.py :pyobject: CustomBehaviour This can then be used like this: .. literalinclude:: ../tests/test_popen_docs.py :pyobject: TestMyFunc.test_count_down :dedent: 4 Using default behaviour ----------------------- If you're testing something that needs to make many calls to many different commands that all behave the same, it can be tedious to specify the behaviour of each with :class:`~MockPopen.set_command`. For this case, :class:`~MockPopen` has the :class:`~MockPopen.set_default` method which can be used to set the behaviour of any command that has not been specified with :class:`~MockPopen.set_command` as shown in the following example: .. literalinclude:: ../tests/test_popen_docs.py :pyobject: TestMyFunc.test_default_behaviour :dedent: 4 Tracking multiple simultaneous processes ---------------------------------------- Conversely, if you're testing something that spins up multiple subprocesses and manages their simultaneous execution, you will want to explicitly define the behaviour of each process using :class:`~MockPopen.set_command` and then make assertions about each process using :attr:`~MockPopen.all_calls`. For example, suppose we wanted to test this function: .. literalinclude:: ../tests/test_popen_docs.py :pyobject: process_in_batches Then you could test it as follows: .. literalinclude:: ../tests/test_popen_docs.py :pyobject: TestMyFunc.test_multiple_processes :dedent: 4 Note that the order of all calls is explicitly recorded. If the order of these calls is non-deterministic due to your method of process management, you may wish to use a :class:`~testfixtures.SequenceComparison`: .. literalinclude:: ../tests/test_popen_docs.py :pyobject: TestMyFunc.test_multiple_processes_unordered :dedent: 4 testfixtures-12.4.0/docs/pydantic.rst000066400000000000000000000146351525773641100176700ustar00rootroot00000000000000Testing with Pydantic ===================== .. note:: To ensure you are using compatible versions, install with the ``testfixtures[pydantic]`` extra. .. invisible-code-block: python try: import pydantic except ImportError: pydantic = None .. skip: start if(pydantic is None, reason="No pydantic installed") When pydantic is installed, a :func:`comparer ` for :class:`~pydantic.BaseModel` is automatically :ref:`registered ` with ``ignore_eq=True``. It compares models field-by-field using their declared attributes, so differences are shown clearly: >>> from pydantic import BaseModel >>> from testfixtures import compare >>> class Point(BaseModel): ... x: int ... y: int >>> compare(Point(x=1, y=2), expected=Point(x=1, y=3)) Traceback (most recent call last): ... AssertionError: Point not as expected: attributes same: ['x'] attributes differ: 'y': 3 (expected) != 2 (actual) The ``ignore_eq=True`` registration is also needed whenever a model contains attributes whose type has a custom ``__eq__`` that :func:`~testfixtures.compare` has a registered comparer for, such as :doc:`Polars ` or :doc:`Pandas ` DataFrames. Without it, pydantic's ``__eq__`` calls ``==`` on each attribute value before testfixtures can intercept it, which for many such types raises an error or gives a misleading result. For example, consider a model with a :class:`~polars.DataFrame` attribute: .. code-block:: python import polars as pl from pydantic import BaseModel, ConfigDict class Report(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) name: str data: pl.DataFrame r1 = Report(name='sales', data=pl.DataFrame({'x': [1, 2], 'y': [3, 4]})) r2 = Report(name='sales', data=pl.DataFrame({'x': [1, 2], 'y': [3, 5]})) Without the ``BaseModel`` registration, pydantic's ``__eq__`` fires first and raises a :exc:`TypeError`: .. invisible-code-block: python from testfixtures.comparing import Registry registry = Registry.initial().install() >>> compare(r1, expected=r2) Traceback (most recent call last): ... TypeError: the truth value of a DataFrame is ambiguous Hint: to check if a DataFrame contains any values, use `is_empty()`. .. invisible-code-block: python registry.uninstall() With the registration in place, :func:`~testfixtures.compare` hands off to the :doc:`Polars comparer ` and produces a clear diff: >>> compare(r1, expected=r2) Traceback (most recent call last): ... AssertionError: Report not as expected: attributes same: ['name'] attributes differ: 'data': shape: (2, 2) ┌─────┬─────┐ │ x ┆ y │ │ --- ┆ --- │ │ i64 ┆ i64 │ ╞═════╪═════╡ │ 1 ┆ 3 │ │ 2 ┆ 5 │ └─────┴─────┘ (expected) != shape: (2, 2) ┌─────┬─────┐ │ x ┆ y │ │ --- ┆ --- │ │ i64 ┆ i64 │ ╞═════╪═════╡ │ 1 ┆ 3 │ │ 2 ┆ 4 │ └─────┴─────┘ (actual) While comparing .data: DataFrames are different (value mismatch for column "y") [left]: shape: (2,) Series: 'y' [i64] [ 3 5 ] [right]: shape: (2,) Series: 'y' [i64] [ 3 4 ] Testing validation errors -------------------------- When a :class:`~pydantic.BaseModel` is given invalid data, pydantic raises :class:`pydantic.ValidationError `. This type has no public constructor that takes a plain message, so building an instance to hand to :class:`~testfixtures.ShouldRaise` for a full comparison is impractical. Even setting aside how hard it is to construct one, comparing full instances would not catch anything useful anyway. Pydantic stores the details of what went wrong outside the ``args`` and ``__dict__`` that :func:`~testfixtures.compare` inspects for exceptions, so any two naturally raised :class:`pydantic.ValidationError ` instances compare equal regardless of what actually failed: .. code-block:: python from pydantic import ValidationError try: Point(x='not-an-int', y=2) except ValidationError as e: exception_1 = e try: Point(x=2, y='also-not-an-int') except ValidationError as e: exception_2 = e >>> compare(exception_1, exception_2) The way to check a raised :class:`pydantic.ValidationError ` is therefore to check its :class:`str` rendering with :func:`~testfixtures.str_like`. ``match`` is a regular expression, so literal square brackets in pydantic's rendering need escaping: >>> from testfixtures import ShouldRaise, str_like >>> with ShouldRaise( ... str_like( ... ValidationError, ... match=( ... "Input should be a valid integer, " ... "unable to parse string as an integer " ... r"\[type=int_parsing, input_value='not-an-int', input_type=str\]" ... ) ... ) ... ): ... Point(x='not-an-int', y=2) A short ``match`` pattern is easy to write but easy to get wrong too: it only has to be found somewhere in the rendering, so it can still pass even when a part of the message you care about isn't what you expect. Where that matters, compare the whole rendering instead: .. the block below must stay ``python3`` rather than ``python``: Sybil then renders it without executing it directly, the capture that follows grabs its source and the invisible code block rewrites the version-pinned URL for the installed pydantic before executing it for real. .. code-block:: python3 with ShouldRaise( str_like( ValidationError, "1 validation error for Point\n" "x\n" " Input should be a valid integer, unable to parse string as an integer " "[type=int_parsing, input_value='not-an-int', input_type=str]\n" " For further information visit https://errors.pydantic.dev/2.13/v/int_parsing" ) ): Point(x='not-an-int', y=2) .. -> source .. invisible-code-block: python import re from pydantic.version import version_short exec(re.sub(r'errors\.pydantic\.dev/\d+\.\d+/', f'errors.pydantic.dev/{version_short()}/', source)) The URL on the last line of the rendering is specific to the installed version of pydantic, so expect to update assertions like this when upgrading. testfixtures-12.4.0/docs/streams.rst000066400000000000000000000052771525773641100175350ustar00rootroot00000000000000Testing output to streams ========================= .. currentmodule:: testfixtures In many situations, it's perfectly legitimate for output to be printed to one of the standard streams. To aid with testing this kind of output, testfixtures provides the :class:`OutputCapture` helper. This helper is a context manager that captures output sent to ``sys.stdout`` and ``sys.stderr`` and provides a :meth:`~OutputCapture.compare` method to check that the output was as expected. Here's a simple example: .. code-block:: python from testfixtures import OutputCapture import sys with OutputCapture() as output: # code under test print("Hello!") print("Something bad happened!", file=sys.stderr) output.compare('\n'.join([ "Hello!", "Something bad happened!", ])) To make life easier, both the actual and expected output are stripped of leading and trailing whitespace before the comparison is done: >>> with OutputCapture() as o: ... print(' Bar! ') ... o.compare(' Foo! ') Traceback (most recent call last): ... AssertionError: 'Foo!' (expected) != 'Bar!' (actual) However, if you need to make very explicit assertions about what has been written to the stream then you can do so using the `captured` property of the :class:`OutputCapture`: >>> with OutputCapture() as o: ... print(' Bar! ') >>> print(repr(o.captured)) ' Bar! \n' If you need to explicitly check whether output went to ``stdout`` or ``stderr``, `separate` mode can be used: .. code-block:: python from testfixtures import OutputCapture import sys with OutputCapture(separate=True) as output: print("Hello!") print("Something bad happened!", file=sys.stderr) output.compare( stdout="Hello!", stderr="Something bad happened!", ) Finally, you may sometimes want to disable an :class:`OutputCapture` without removing it from your code. This often happens when you want to insert a :any:`breakpoint` call while an :class:`OutputCapture` is active; if it remains enabled, all debugger output will be captured making the debugger very difficult to use! To deal with this problem, the :class:`OutputCapture` may be disabled and then re-enabled as follows: >>> with OutputCapture() as o: ... print('Foo') ... o.disable() ... print('Bar') ... o.enable() ... print('Baz') Bar >>> print(o.captured) Foo Baz .. note:: Some debuggers, notably :mod:`pdb`, do interesting things with streams such that calling :meth:`~OutputCapture.disable` from within the debugger will have no effect. A good fallback is to type the following, which will almost always restore output to where you want it: .. code-block:: python import sys; sys.stdout=sys.__stdout__ testfixtures-12.4.0/docs/structlog.rst000066400000000000000000000275631525773641100201070ustar00rootroot00000000000000Testing with Structlog ====================== .. currentmodule:: testfixtures.structlog .. note:: To ensure you are using compatible versions, install with the ``testfixtures[structlog]`` extra. .. invisible-code-block: python try: import structlog except ImportError: structlog = None .. skip: start if(structlog is None, reason="No structlog installed") .. invisible-code-block: python structlog.reset_defaults() structlog.contextvars.clear_contextvars() `structlog `_ builds log entries as event dictionaries that flow through a configurable chain of :doc:`processors `. :class:`StructlogSource` is provided to capture and test these event dictionaries using :class:`~testfixtures.LogCapture`. If you have ``cache_logger_on_first_use`` enabled and acquire loggers before :class:`~testfixtures.LogCapture` is installed, those cached loggers will keep their old processor chain and bypass capture; acquire loggers inside the capture block, or disable ``cache_logger_on_first_use`` during tests. .. note:: Structlog also has its own :doc:`testing tools ` including the :func:`~structlog.testing.capture_logs` context manager and :class:`~structlog.testing.LogCapture` processor, which should not be confused with :class:`testfixtures.LogCapture`! As a simple example, you can capture structlog output with a `pytest`__ fixture such as this: __ https://docs.pytest.org/ .. code-block:: python import pytest from testfixtures import LogCapture from testfixtures.structlog import StructlogSource @pytest.fixture() def logs(): with LogCapture(StructlogSource()) as logs_: yield logs_ You can then check logging in your tests like this: .. code-block:: python import structlog logger = structlog.get_logger() def test_logging(logs: LogCapture) -> None: logger.info('42 is fine') logger.error('13 is not') logs.check( ('INFO', '42 is fine'), ('ERROR', '13 is not'), ) .. invisible-code-block: python from sybil.testing import run_pytest run_pytest(test_logging, fixtures=[logs]) See :doc:`logging` for the full :class:`~testfixtures.LogCapture` API, including :meth:`~testfixtures.LogCapture.check`, :meth:`~testfixtures.LogCapture.check_present`, :meth:`~testfixtures.LogCapture.actual`, and the :attr:`~testfixtures.LogCapture.entries` attribute. Inspecting raw event dicts -------------------------- Each :class:`~testfixtures.logcapture.Entry` in :attr:`~testfixtures.LogCapture.entries` exposes the underlying structlog event dict via its ``raw`` attribute: .. code-block:: python from testfixtures import LogCapture from testfixtures.structlog import StructlogSource with LogCapture(StructlogSource()) as log: structlog.get_logger().info('hello world') >>> log.entries[0].raw {'event': 'hello world', 'level': 'info'} Checking logging context ------------------------ Structlog supports two ways of carrying contextual data: per-logger :meth:`~structlog.BoundLoggerBase.bind`, which returns a new logger with extra context bound int, and the contextvars-based :func:`~structlog.contextvars.bind_contextvars` or :func:`~structlog.contextvars.bound_contextvars`, which carry context across function calls and async tasks. Both end up as keys at the top level of the event dict. To full capture context, use the :func:`raw` helper: .. code-block:: python from testfixtures import LogCapture from testfixtures.structlog import StructlogSource, raw request_log = structlog.get_logger().bind(request_id='abc123') with LogCapture(StructlogSource(raw)) as log: request_log.info('handling request') request_log.info('request complete') log.check( {'event': 'handling request', 'level': 'info', 'request_id': 'abc123'}, {'event': 'request complete', 'level': 'info', 'request_id': 'abc123'}, ) The contextvars-based API works the same way; it relies on the :func:`~structlog.contextvars.merge_contextvars` processor, which is included in :class:`~testfixtures.structlog.StructlogSource`'s default ``processors`` so that bound context shows up in tests with no extra wiring: .. code-block:: python from structlog.contextvars import bound_contextvars with LogCapture(StructlogSource(raw)) as log: structlog.get_logger().info('before task') with bound_contextvars(task_id=1234): structlog.get_logger().info('processing task') structlog.get_logger().info('after task') log.check( {'event': 'before task', 'level': 'info'}, {'event': 'processing task', 'level': 'info', 'task_id': 1234}, {'event': 'after task', 'level': 'info'} ) Capturing specific fields ------------------------- You can control which fields form the ``actual`` value by passing a sequence of ``attributes`` to :class:`~testfixtures.structlog.StructlogSource`. Elements may be string keys into the event dict or callables that receive the event dict. To capture only the event message: .. code-block:: python with LogCapture(StructlogSource('event')) as log: structlog.get_logger().info('just the event') log.check('just the event') You can also mix string keys and callables: .. code-block:: python from testfixtures import LogCapture from testfixtures.structlog import StructlogSource, level_name with LogCapture(StructlogSource((level_name, 'event'))) as log: structlog.get_logger().debug('a debug message') structlog.get_logger().info('something info') log.check( ('DEBUG', 'a debug message'), ('INFO', 'something info'), ) For full control, pass a single callable: .. code-block:: python def extract(event_dict): return {'level': event_dict['level'].upper(), 'event': event_dict['event']} with LogCapture(StructlogSource(extract)) as log: structlog.get_logger().debug('a debug message') structlog.get_logger().error('an error') log.check( {'level': 'DEBUG', 'event': 'a debug message'}, {'level': 'ERROR', 'event': 'an error'}, ) Filtering by level ------------------ To capture only messages at or above a minimum level: .. code-block:: python with LogCapture(StructlogSource(level='WARNING')) as log: structlog.get_logger().info('ignored') structlog.get_logger().warning('captured') log.check(('WARNING', 'captured')) Combining with standard library logging --------------------------------------- If your application uses both structlog and the standard library's :mod:`logging`, pass both a :class:`~testfixtures.logcapture.LoggingSource` and a :class:`~testfixtures.structlog.StructlogSource` to one :class:`~testfixtures.LogCapture`: .. code-block:: python import logging from testfixtures import LogCapture from testfixtures.logcapture import LoggingSource from testfixtures.structlog import StructlogSource with LogCapture(StructlogSource(), LoggingSource()) as log: logging.info('from standard library logging') structlog.get_logger().info('from structlog') log.check( ('INFO', 'from standard library logging'), ('INFO', 'from structlog'), ) Note that if your structlog is configured with :class:`structlog.stdlib.LoggerFactory` so that structlog calls flow *through* stdlib logging, then a single :class:`~testfixtures.logcapture.LoggingSource` is enough and adding :class:`~testfixtures.structlog.StructlogSource` would double-capture each event. Exceptions ---------- When code logs an exception, the underlying exception object is stored in :attr:`~testfixtures.logcapture.Entry`'s ``exception`` attribute: .. code-block:: python from testfixtures import LogCapture, compare from testfixtures.structlog import StructlogSource with LogCapture(StructlogSource()) as logs: try: raise ValueError('boom') except ValueError: structlog.get_logger().exception('oh no') compare(logs.entries[-1].exception, expected=ValueError('boom')) Running selected processors during capture ------------------------------------------ By default :class:`~testfixtures.structlog.StructlogSource` bypasses your configured processor chain and only :func:`structlog.stdlib.add_log_level` and :func:`~structlog.contextvars.merge_contextvars` run before capture. This mirrors :func:`structlog.testing.capture_logs` and keeps assertions clean of per-run noise like timestamps and call sites. If you need different processors during capture you can pass them via ``processors``: .. code-block:: python from structlog.contextvars import merge_contextvars from structlog.processors import KeyValueRenderer with LogCapture( StructlogSource( attributes=raw, processors=[merge_contextvars, KeyValueRenderer(sort_keys=True)], ) ) as log: structlog.get_logger().bind(user='alice').info('hi') log.check("event='hi' user='alice'") The same pattern works for :class:`~structlog.processors.JSONRenderer`: .. code-block:: python from structlog.processors import JSONRenderer with LogCapture( StructlogSource( attributes=raw, processors=[merge_contextvars, JSONRenderer(sort_keys=True)], ) ) as log: structlog.get_logger().bind(user='alice').info('hi') log.check('{"event": "hi", "user": "alice"}') When a renderer is the last processor, the captured value is a string, so ``attributes`` should be set to :func:`raw`. See structlog's :doc:`processors guide ` and :func:`~structlog.testing.capture_logs` for the underlying mechanics. Avoid putting :func:`structlog.processors.format_exc_info` in ``processors`` as it replaces ``exc_info`` with a formatted string and defeats the :attr:`~testfixtures.logcapture.Entry.exception` extraction shown above. Checking the configuration of your logging ------------------------------------------ :class:`~testfixtures.structlog.StructlogSource` is good for checking that your code is logging the correct messages; just as important is checking that your application has correctly configured structlog. If you have a ``setup_logging`` function such as this: .. code-block:: python import structlog def setup_logging() -> None: structlog.configure( processors=[ structlog.contextvars.merge_contextvars, structlog.stdlib.add_log_level, structlog.processors.TimeStamper(fmt='iso'), structlog.processors.JSONRenderer(), ], ) This can be tested with the following unit test: .. code-block:: python from testfixtures import compare, Replacer from testfixtures.mock import Mock, call def test_setup_logging() -> None: mocks = Mock() with Replacer() as replace: # While used from structlog, configure is defined in structlog._config: replace.in_module(structlog.configure, mocks.configure, module=structlog) # Instances of these classes have internal state that does not compare equal, # so just mock them and ensure we've called them correctly: replace.in_module(structlog.processors.TimeStamper, mocks.TimeStamper) replace.in_module(structlog.processors.JSONRenderer, mocks.JSONRenderer) setup_logging() compare( mocks.mock_calls, expected=[ call.TimeStamper(fmt='iso'), call.JSONRenderer(), call.configure( processors=[ structlog.contextvars.merge_contextvars, structlog.stdlib.add_log_level, mocks.TimeStamper.return_value, mocks.JSONRenderer.return_value, ], ), ], ) .. check it: >>> test_setup_logging() >>> structlog.reset_defaults() testfixtures-12.4.0/docs/twisted.rst000066400000000000000000000164111525773641100175320ustar00rootroot00000000000000Testing with Twisted ==================== .. note:: To ensure you are using compatible versions, install with the ``testfixtures[twisted]`` extra. .. invisible-code-block: python try: import twisted except ImportError: twisted = None .. skip: start if(twisted is None, reason="No twisted installed") Due to its longevity, Twisted has its own structured logging framework built around :class:`~twisted.logger.Logger` objects and log event dicts. :class:`~testfixtures.twisted.TwistedSource` is provided to work with this. Testing logging --------------- Pass a :class:`~testfixtures.twisted.TwistedSource` instance when constructing :class:`~testfixtures.LogCapture`. It replaces all existing Twisted log observers for the duration of the context manager and restores them on exit: .. code-block:: python from twisted.logger import Logger from testfixtures import LogCapture from testfixtures.twisted import TwistedSource log = Logger() with LogCapture(TwistedSource()) as logs: log.info('hello {name}', name='world') logs.check(('INFO', 'hello world')) See :doc:`logging` for the full :class:`~testfixtures.LogCapture` API, including :meth:`~testfixtures.LogCapture.check`, :meth:`~testfixtures.LogCapture.check_present`, :meth:`~testfixtures.LogCapture.actual`, and the :attr:`~testfixtures.LogCapture.entries` attribute. Inspecting raw events ~~~~~~~~~~~~~~~~~~~~~ Each :class:`~testfixtures.logcapture.Entry` in :attr:`~testfixtures.LogCapture.entries` exposes the underlying Twisted log event dict via its ``raw`` attribute: .. code-block:: python with LogCapture(TwistedSource()) as logs: log.info('hello {greeting}', greeting='world') >>> logs.entries[0].raw {'greeting': 'world',..., 'log_level': , 'log_namespace': '', ...} The event dict contains all the structured fields Twisted attaches to each log call, including ``log_namespace``, ``log_source``, ``log_level``, ``log_format`` along with any keyword arguments passed to the log call. ``log_failure`` is present when logging a :class:`~twisted.python.failure.Failure`. Capturing specific fields ~~~~~~~~~~~~~~~~~~~~~~~~~ You can control which fields form the ``actual`` value by passing an ``attributes`` sequence to :class:`~testfixtures.twisted.TwistedSource`. Elements may be string keys into the event dict or callables that receive the event dict. For example, to capture only the formatted message, pass :func:`~twisted.logger.formatEvent` as the sole element: .. code-block:: python from twisted.logger import formatEvent with LogCapture(TwistedSource(attributes=formatEvent)) as logs: log.info('hello {greeting}', greeting='world') logs.check('hello world') You can also mix string keys and callables: .. code-block:: python with LogCapture(TwistedSource(attributes=('log_namespace', formatEvent))) as logs: Logger(namespace='myapp').info('started') logs.check(('myapp', 'started')) For full control, pass a single callable as ``attributes``: .. code-block:: python from testfixtures.twisted import INFO, WARN logger = Logger(namespace='myapp') def extract(event): return {'level': event['log_level'], 'message': formatEvent(event)} with LogCapture(TwistedSource(attributes=extract)) as logs: logger.info('started') logger.warn('low memory') logs.check( {'level': INFO, 'message': 'started'}, {'level': WARN, 'message': 'low memory'}, ) In the above example, the Twisted log level objects are returned. For convenience in making assertions about these, the following constants are exported from :mod:`testfixtures.twisted` for use in assertions: :data:`~testfixtures.twisted.DEBUG`, :data:`~testfixtures.twisted.INFO`, :data:`~testfixtures.twisted.WARN`, :data:`~testfixtures.twisted.ERROR` and :data:`~testfixtures.twisted.CRITICAL`. Filtering by level ~~~~~~~~~~~~~~~~~~ To capture only events at or above a minimum level: .. code-block:: python with LogCapture(TwistedSource(level='warn')) as capture: log.info('ignored') log.warn('captured') capture.check(('WARN', 'captured')) Combining with standard library logging ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If your application uses both Twisted and the standard library's :mod:`logging`, pass both a :class:`~testfixtures.LoggingSource` and a :class:`~testfixtures.twisted.TwistedSource` to one :class:`~testfixtures.LogCapture` to capture and check both: .. code-block:: python import logging from testfixtures import LogCapture, LoggingSource from testfixtures.twisted import TwistedSource with LogCapture(LoggingSource(), TwistedSource()) as logs: logging.warning('from stdlib') log.warn('from twisted') logs.check( ('WARNING', 'from stdlib'), ('WARN', 'from twisted'), ) .. note:: Twisted's ``LogLevel.warn`` surfaces as ``'WARN'`` while standard library logging uses ``'WARNING'``. Exceptions and failures ~~~~~~~~~~~~~~~~~~~~~~~ When code logs a :class:`~twisted.python.failure.Failure`, the underlying exception object is stored in the :attr:`~testfixtures.logcapture.Entry`'s ``exception`` attribute: .. code-block:: python from testfixtures import LogCapture, compare from testfixtures.twisted import TwistedSource with LogCapture(TwistedSource()) as logs: try: raise ValueError('boom') except Exception: log.failure('something went wrong') logs.check( ('CRITICAL', 'something went wrong') ) compare(logs.entries[-1].exception, expected=ValueError('boom')) Testing logging configuration ----------------------------- :class:`~testfixtures.twisted.TwistedSource` is good for checking that your code is logging the correct messages; just as important is checking that your application has correctly configured its Twisted log observers. If you have a ``setup_logging`` function such as this: .. code-block:: python import sys from twisted.logger import globalLogPublisher, textFileLogObserver def setup_logging(stream=None) -> None: globalLogPublisher.addObserver(textFileLogObserver(stream or sys.stderr)) This can be tested with a unit test such as the following: .. code-block:: python from twisted.logger import FileLogObserver from testfixtures import Replacer, compare, like def test_setup_logging() -> None: with Replacer() as replace: replace( globalLogPublisher._observers, [], container=globalLogPublisher, name='_observers', ) setup_logging() compare( globalLogPublisher._observers, expected=[like(FileLogObserver)], ) .. check it: >>> test_setup_logging() Trial test case support ----------------------- Since Twisted's ``trial`` test runner has its own ``TestCase`` class on which you may well need to call ``flushLoggedErrors()`` in the event that you're testing exception logging, a dedicated :class:`testfixtures.twisted.LogCapture` is also provided, including a :meth:`~testfixtures.twisted.LogCapture.make` class method for creating, installing and adding a cleanup for the capture. This sample test suite shows it all in action: .. literalinclude:: ../tests/test_twisted.py :pyobject: TestLogCapture testfixtures-12.4.0/docs/utilities.rst000066400000000000000000000063751525773641100200720ustar00rootroot00000000000000Utilities ========= .. currentmodule:: testfixtures This section describes a few handy functions that didn't fit nicely in any other section. .. _generator: The ``generator`` helper ------------------------ It can be handy when testing to be able to turn a simple sequence into a generator. This can be necessary when you want to check that your code will behave correctly when processing a generator instead of a simple sequence, or when you're looking to make assertions about the expected return value of a callable that returns a generator. If you need to turn a simple sequence into a generator, the :func:`generator` function is the way to do it: >>> from testfixtures import generator >>> generator(1,2,3) Iterating over this generator will return the arguments passed to the :func:`generator` function: >>> for i in _: ... print(i, end=' ') 1 2 3 The ``wrap`` helper ------------------- The :func:`wrap` helper is a decorator function that allows you to wrap the call to the decorated callable with calls to other callables. This can be useful when you want to perform setup and teardown actions either side of a test function. For example, take the following functions: .. code-block:: python def before(): print("before") def after(): print("after") The :func:`wrap` helper can be used to wrap a function with these: .. code-block:: python from testfixtures import wrap @wrap(before, after) def a_function(): print("a_function") When the wrapped function is executed, the output is as follows: >>> a_function() before a_function after The section argument to :func:`wrap` is optional: .. code-block:: python from testfixtures import wrap @wrap(before) def a_function(): print("a_function") Now, the wrapped function gives the following output when executed: >>> a_function() before a_function Multiple wrapping functions can be provided by stacking :func:`wrap` decorations: .. code-block:: python def before1(): print("before 1") def after1(): print("after 1") def before2(): print("before 2") def after2(): print("after 2") @wrap(before2, after2) @wrap(before1, after1) def a_function(): print("a_function") The order of execution is illustrated below: >>> a_function() before 1 before 2 a_function after 2 after 1 The results of calling the wrapping functions executed before the wrapped function can be made available to the wrapped function provided it accepts positional arguments for these results: .. code-block:: python def before1(): return "return 1" def before2(): return "return 2" @wrap(before2) @wrap(before1) def a_function(r1, r2): print(r1) print(r2) Calling the wrapped function illustrates the behaviour: >>> a_function() return 1 return 2 Finally, the return value of the wrapped function will always be that of the original function: .. code-block:: python def before1(): return 1 def after1(): return 2 def before2(): return 3 def after2(): return 4 @wrap(before2, after2) @wrap(before1, after2) def a_function(): return 'original' When the above wrapped function is executed, the original return value is still returned: >>> a_function() 'original' testfixtures-12.4.0/docs/warnings.rst000066400000000000000000000063131525773641100176770ustar00rootroot00000000000000Testing warnings ================ .. currentmodule:: testfixtures Testfixtures has tools that make it easy to make assertions about code that may emit warnings. The :class:`ShouldWarn` context manager --------------------------------------- This context manager allows you to assert that particular warnings are emitted in a block of code, for example: >>> from warnings import warn >>> from testfixtures import ShouldWarn >>> with ShouldWarn(UserWarning('you should fix that')): ... warn('you should fix that') If a warning issued doesn't match the one expected, :class:`ShouldWarn` will raise an :class:`AssertionError` causing the test in which it occurs to fail: >>> from warnings import warn >>> from testfixtures import ShouldWarn >>> with ShouldWarn(UserWarning('you should fix that')): ... warn("sorry dave, I can't let you do that") Traceback (most recent call last): ... AssertionError: not equal: same: [] expected: [ attributes differ: 'args': ('you should fix that',) (Comparison) != ("sorry dave, I can't let you do that",) (actual) ] actual: [UserWarning("sorry dave, I can't let you do that")] (expected) [UserWarning("sorry dave, I can't let you do that")] (actual) You can check multiple warnings in a particular piece of code: >>> from warnings import warn >>> from testfixtures import ShouldWarn >>> with ShouldWarn(UserWarning('you should fix that'), ... UserWarning('and that too')): ... warn('you should fix that') ... warn('and that too') If you don't care about the order of issued warnings, you can use ``order_matters=False``: >>> from warnings import warn >>> from testfixtures import ShouldWarn >>> with ShouldWarn(UserWarning('you should fix that'), ... UserWarning('and that too'), ... order_matters=False): ... warn('and that too') ... warn('you should fix that') If you want to inspect more details of the warnings issued, you can capture them into a list as follows: >>> from warnings import warn_explicit >>> from testfixtures import ShouldWarn >>> with ShouldWarn() as captured: ... warn_explicit(message='foo', category=DeprecationWarning, ... filename='bar.py', lineno=42) >>> len(captured) 1 >>> captured[0].message DeprecationWarning('foo'...) >>> captured[0].lineno 42 The :class:`ShouldNotWarn` context manager ------------------------------------------ If you do not expect any warnings to be logged in a piece of code, you can use the :class:`ShouldNotWarn` context manager. If any warnings are issued in the context it manages, it will raise an :class:`AssertionError` to indicate this: >>> from warnings import warn >>> from testfixtures import ShouldNotWarn >>> with ShouldNotWarn(): ... warn("woah dude") Traceback (most recent call last): ... AssertionError: not equal: same: [] expected: [] actual: [UserWarning('woah dude')] (expected) [UserWarning('woah dude')] (actual) testfixtures-12.4.0/happy.sh000077500000000000000000000005261525773641100160450ustar00rootroot00000000000000#!/bin/bash set -ex echo "=== Syncing dependencies ===" uv sync --all-extras --all-groups echo "=== Tests + Coverage ===" uv run pytest --cov --cov-report=term-missing:skip-covered echo "=== Type Checking ===" uv run mypy . echo "=== Docs Build ===" make -C docs clean html SPHINXOPTS=--fail-on-warning echo "=== All checks passed! ===" testfixtures-12.4.0/pyproject.toml000066400000000000000000000071351525773641100173040ustar00rootroot00000000000000[project] name = "testfixtures" version = "12.4.0" description = "A collection of helpers and mock objects for unit tests and doc tests." readme = "README.rst" authors = [ { name = "Chris Withers", email = "chris@simplistix.co.uk" } ] license = { text = "MIT" } requires-python = ">=3.11" dependencies = [ "typing_extensions>=4.12; python_version < '3.13'", ] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", ] [project.urls] Homepage = "https://github.com/Simplistix/testfixtures" Documentation = "http://testfixtures.readthedocs.org/en/latest/" Repository = "https://github.com/Simplistix/testfixtures" Changelog = "https://testfixtures.readthedocs.io/en/latest/changes.html" [project.optional-dependencies] django = [ "django>=4; python_version < '3.13'", "django>=5.1.3; python_version >= '3.13'", ] sybil = ["sybil>=10.1.0"] mock-backport = ["mock>=4.0.3"] loguru = ["loguru>=0.7.3"] polars = ["polars>=1.32"] pandas = ["pandas>=2.3.3"] numpy = ["numpy>=2.3.2"] structlog = ["structlog>=24.3.0"] pydantic = [ "pydantic>=2.12.0; python_version < '3.15'", "pydantic>=2.13.0; python_version >= '3.15'", ] twisted = ["twisted>=22.1"] yaml = ["pyyaml>=6.0.3"] toml = ["tomlkit>=0.10.0"] [dependency-groups] dev = [ "mypy>=1.19", "pytest>=8.0", "pytest-cov>=5", "types-mock", ] django-dev = [ "django-stubs", "pytest-django>=4", ] pandas-dev = [ "pandas-stubs>=2.3.0", ] twisted-dev = [ "mypy-zope", ] click-dev = [ "click>=8", ] docs = [ "sphinx", "furo", ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.pytest.ini_options] pythonpath = ["."] addopts = "-p no:doctest" norecursedirs = "_build" testpaths = ["tests", "docs"] DJANGO_SETTINGS_MODULE = "tests.test_django.settings" filterwarnings = [ "ignore::DeprecationWarning", "ignore::PendingDeprecationWarning", "error::SyntaxWarning", "error::UserWarning", "ignore:Unknown config option.+DJANGO_SETTINGS_MODULE:pytest.PytestConfigWarning", ] [tool.mypy] disallow_untyped_defs = true disallow_incomplete_defs = true no_implicit_reexport = true warn_unused_ignores = true plugins = [ "mypy_django_plugin.main", "mypy_zope:plugin", ] # "nice to have" stuff to fix: [[tool.mypy.overrides]] module = "tests.*" disable_error_code = ["no-untyped-call", "no-untyped-def"] # Be more picky with some test files: [[tool.mypy.overrides]] module = [ "tests.test_commands", "tests.test_date", "tests.test_datetime", "tests.test_formats", "tests.test_outputcapture", "tests.test_should_raise", "tests.test_time", ] enable_error_code = ["no-untyped-call", "no-untyped-def"] # Be very picky with these test files: [[tool.mypy.overrides]] module = [ "tests.test_commands", "tests.test_comparison_typed", "tests.test_formats", "tests.test_tempdirectory", ] check_untyped_defs = true warn_return_any = true no_implicit_optional = true [[tool.mypy.overrides]] module = [ # permanent exclusions and workaround: "constantly.*", # guppy isn't actually ever installed: "guppy" ] ignore_missing_imports = true [tool.django-stubs] django_settings_module = "tests.test_django.settings" [tool.ruff] line-length = 100 [tool.ruff.format] quote-style = "preserve" [tool.coverage.run] source = ["testfixtures", "tests"] [tool.coverage.report] exclude_lines = [ "pragma: no cover", "'No exception raised!'", "self\\.fail\\('Expected", "guppy =", "if TYPE_CHECKING:", "\\.\\.\\.", ] testfixtures-12.4.0/src/000077500000000000000000000000001525773641100151515ustar00rootroot00000000000000testfixtures-12.4.0/src/testfixtures/000077500000000000000000000000001525773641100177225ustar00rootroot00000000000000testfixtures-12.4.0/src/testfixtures/__init__.py000066400000000000000000000051001525773641100220270ustar00rootroot00000000000000class singleton: def __init__(self, name: str) -> None: self.name = name def __repr__(self) -> str: return '<%s>' % self.name __str__ = __repr__ not_there: singleton = singleton('not_there') from testfixtures.comparers import diff, safe_pformat, safe_repr from testfixtures.comparing import compare, register from testfixtures.comparison import ( Comparison, TextComparison, StringComparison, RoundComparison, RangeComparison, ReprComparison, StrComparison, SequenceComparison, Subset, Permutation, MappingComparison, like, repr_like, str_like, sequence, contains, unordered, mapping ) from testfixtures.command import Command, Run from testfixtures.datetime import mock_datetime, mock_date, mock_time from testfixtures.logcapture import LogCapture, log_capture, LoggingSource from testfixtures.outputcapture import OutputCapture from testfixtures.resolve import resolve from testfixtures.replace import ( Replacer, Replace, replace, replace_in_environ, replace_on_class, replace_in_module, ) from testfixtures.shouldraise import ShouldRaise, should_raise, ShouldAssert from testfixtures.shouldwarn import ShouldWarn, ShouldNotWarn from testfixtures.tempdirectory import TempDir, TempDirectory, tempdir from testfixtures.utils import wrap, generator # backwards compatibility for the old names test_datetime = mock_datetime test_datetime.__test__ = False # type: ignore[attr-defined] test_date = mock_date test_date.__test__ = False # type: ignore[attr-defined] test_time = mock_time test_time.__test__ = False # type: ignore[attr-defined] __all__ = [ 'Command', 'Comparison', 'LogCapture', 'LoggingSource', 'MappingComparison', 'OutputCapture', 'Permutation', 'RangeComparison', 'ReprComparison', 'Replace', 'Replacer', 'Run', 'RoundComparison', 'SequenceComparison', 'ShouldAssert', 'ShouldRaise', 'ShouldNotWarn', 'ShouldWarn', 'Subset', 'StrComparison', 'StringComparison', 'TextComparison', 'TempDirectory', 'TempDir', 'compare', 'contains', 'diff', 'generator', 'like', 'log_capture', 'mapping', 'mock_date', 'mock_datetime', 'mock_time', 'not_there', 'register', 'replace', 'replace_in_environ', 'replace_on_class', 'replace_in_module', 'repr_like', 'resolve', 'safe_pformat', 'safe_repr', 'sequence', 'should_raise', 'singleton', 'str_like', 'tempdir', 'test_date', 'test_datetime', 'test_time', 'unordered', 'wrap', ] testfixtures-12.4.0/src/testfixtures/command.py000066400000000000000000000215031525773641100217130ustar00rootroot00000000000000""" Tools for testing entry-point scripts. """ import sys from dataclasses import dataclass from typing import Callable, Generic, Sequence, Self from testfixtures.compat import TypeVar from testfixtures.comparing import compare from testfixtures.logcapture import LogCapture, LoggingSource from testfixtures.mock import Mock, _Call as Call from testfixtures.outputcapture import OutputCapture from testfixtures.replace import Replacer @dataclass class CheckResult: """ The result of a single check for one of the :class:`AbstractRun` attributes. Failure is indicated by providing a :attr:`message`. """ #: The name of this check name: str #: The message describing why this check failed, or ``None`` if it passed. message: str | None def check_output(expected: str, output: OutputCapture) -> CheckResult: """ Check if the expected output matches the combined output written to :data:`~sys.stdout` and :data:`~sys.stderr` during a :meth:`~Command.run`. Used in the default :meth:`Run.check`. """ return CheckResult('output', output.compare(expected, raises=False)) def check_logging(expected: Sequence[tuple[str, ...] | str], logging: LogCapture) -> CheckResult: """ Check if the expected logging matches that :attr:`captured ` during a :meth:`~Command.run`. Used in the default :meth:`Run.check`. """ return CheckResult('logging', logging.check(*expected, raises=False)) def check_mock_calls(expected: Sequence[Call], mocks: Mock) -> CheckResult: """ Check if the expected mock calls match those :attr:`captured ` during a :meth:`~Command.run`. Used in the default :meth:`Run.check`. """ return CheckResult('mock calls', compare(expected, actual=mocks.mock_calls, raises=False)) def check_return_code(expected: int, actual: int) -> CheckResult: """ Check if the expected return code matches the actual one from a :meth:`~Command.run`. Used in the default :meth:`Run.check`. """ return CheckResult('return_code', compare(expected=expected, actual=actual, raises=False)) @dataclass class AbstractRun: """ A base class used for implementing a :meth:`Command.run` and its result. """ #: The :class:`~testfixtures.OutputCapture` that was active during the run. output: OutputCapture #: The return code derived from any :class:`SystemExit` raised during the run, #: or ``0`` if none was raised. return_code: int #: The :class:`~testfixtures.LogCapture` that was active during the run. logging: LogCapture #: The mocks used during the run. mocks: Mock @classmethod def setup_output(cls) -> OutputCapture: """ Instantiate the :class:`~testfixtures.OutputCapture` used for each run. Can be overriden to make different capturing choices, such as not capturing at all or capturing :data:`~sys.stdout` or :data:`~sys.stderr` separately. """ return OutputCapture() @classmethod def setup_logging(cls) -> LogCapture: """ Instantiate the :class:`~testfixtures.LogCapture` used for each run. Can be overriden to only capture logging at or above a certain level or if a different logging framework is in use, such as :mod:`loguru` or :mod:`structlog`. """ return LogCapture(LoggingSource()) @classmethod def setup_mocks(cls, replace: Replacer) -> Mock: """ Use the provided :class:`~testfixtures.Replacer` to mock any required callables or objects, either where they would interfere with testing of the callable, or where you want to check that things are called with appropriate options given the command line specified in :data:`sys.argv`. Any mocks installed should be attributes of a root :class:`unittest.mock.Mock` instance, with that root mock being returned for later :meth:`checking `. """ return Mock() @staticmethod def check_output(expected: str, output: OutputCapture) -> CheckResult: """ Override to customise the checking of :attr:`output`. """ return check_output(expected, output) @staticmethod def check_return_code(expected: int, actual: int) -> CheckResult: """ Override to customise the checking of :attr:`return_code`. """ return check_return_code(expected, actual) @staticmethod def check_logging( expected: Sequence[tuple[str, ...] | str], logging: LogCapture ) -> CheckResult: """ Override to customise the checking of :attr:`logging`. """ return check_logging(expected, logging) @staticmethod def check_mock_calls(expected: Sequence[Call], mocks: Mock) -> CheckResult: """ Override to customise the checking of calls on :attr:`mocks`. """ return check_mock_calls(expected, mocks) @classmethod def produce(cls, main: Callable[[], None], argv: list[str]) -> Self: """ Produce a :class:`run ` by setting up all required mocking and capturing and then calling the supplied ``main`` with :data:`sys.argv` set to the supplied ``argv``. """ with ( Replacer() as replace, cls.setup_output() as output, cls.setup_logging() as logging, ): mocks = cls.setup_mocks(replace) replace( sys.argv, container=sys, name='argv', replacement=[getattr(main, '__name__', 'main')] + argv, ) return_code = 0 try: main() except SystemExit as e: match e.code: case str(): print(e.code, file=sys.stderr) return_code = 1 case int(): return_code = e.code case None: return_code = 1 return cls(output, return_code, logging, mocks) def check_results(self, *checks: CheckResult) -> None: """ Accumulate output from the supplied :class:`CheckResult` instances and raise an :class:`AssertionError` if any of them failed. """ __tracebackhide__ = True problems = [f"{check.name}: {check.message}" for check in checks if check.message] if problems: raise AssertionError('\n\n'.join(problems)) class Run(AbstractRun): """ The result of a single :meth:`Command.run`. """ def check( self, output: str = '', return_code: int = 0, logging: Sequence[tuple[str, str]] = (), mock_calls: Sequence[Call] = (), ) -> None: """ Check the result of a :meth:`Command.run`. :param output: Expected output written to either :data:`~sys.stdout` or :data:`~sys.stderr`. :param return_code: Expected return code. :param logging: Expected log entries to be :meth:`checked `. :param mock_calls: Expected :attr:`~unittest.mock.Mock.mock_calls`. """ __tracebackhide__ = True self.check_results( self.check_output(output, self.output), self.check_return_code(return_code, self.return_code), self.check_logging(logging, self.logging), self.check_mock_calls(mock_calls, self.mocks), ) RunT = TypeVar('RunT', bound=AbstractRun, default=Run) @dataclass class Command(Generic[RunT]): """ A test harness for running an entry-point or script callable with various mocks in place. These mocks provide a synthetic :data:`sys.argv`, capture output written to :data:`~sys.stdout` or :data:`~sys.stderr`, capture logging, and capture any setup calls that have been mocked along with any :class:`SystemExit` raised. """ #: The entry-point or script callable to run. main: Callable[[], None] #: The :class:`runner ` to use. runner: type[RunT] = Run # type: ignore[assignment] def run(self, *argv: str) -> RunT: """ Run :attr:`main` with :data:`sys.argv` set to ``[main.__name__, *argv]`` with :meth:`~AbstractRun.setup_output` and :meth:`~AbstractRun.setup_logging` captured and :meth:`~AbstractRun.setup_mocks` in place. Any :class:`SystemExit` raised by ``main`` is translated to :attr:`AbstractRun.return_code` as follows: - :class:`int` codes are passed through. - :class:`str` codes are printed to stderr and reported as ``1`` - A bare :class:`SystemExit` is reported as ``1``. """ return self.runner.produce(self.main, list(argv)) def __call__(self, *argv: str) -> RunT: """ Alternative way of calling :meth:`run`. """ return self.run(*argv) testfixtures-12.4.0/src/testfixtures/comparers.py000066400000000000000000000457471525773641100223100ustar00rootroot00000000000000import re from collections import defaultdict from datetime import datetime from difflib import unified_diff from functools import partial as partial_type from pathlib import Path from pprint import pformat from typing import ( Any, Iterable, List, Mapping, Pattern, Sequence, TypeVar, TYPE_CHECKING, ) from testfixtures import not_there from testfixtures.mock import parent_name, _Call from testfixtures.resolve import type_name if TYPE_CHECKING: from .comparing import CompareContext __all__ = [ 'AlreadySeen', 'compare_bytes', 'compare_call', 'compare_dict', 'compare_exception', 'compare_exception_group', 'compare_generator', 'compare_object', 'compare_partial', 'compare_path', 'compare_sequence', 'compare_set', 'compare_simple', 'compare_text', 'compare_tuple', 'compare_with_fold', 'compare_with_type', 'diff', 'merge_ignored_attributes', 'safe_pformat', 'safe_repr', 'sorted_by_repr', 'split_repr', 'strip_blank_lines', 'trailing_whitespace_re', ] def safe_repr(obj: Any) -> str: """ A fault-tolerant version of :func:`repr`. :exc:`KeyboardInterrupt` and :exc:`SystemExit` are not caught. """ try: return repr(obj) except Exception as e: type_ = type(obj) cls_name = type_.__name__ match obj: case list(): return '[' + ', '.join(safe_repr(e) for e in obj) + ']' case tuple(): body = ', '.join(safe_repr(e) for e in obj) suffix = ',)' if len(obj) == 1 else ')' return '(' + body + suffix case dict(): body = ', '.join(f'{safe_repr(k)}: {safe_repr(v)}' for k, v in obj.items()) return '{' + body + '}' case frozenset(): if not obj: return cls_name return cls_name + '({' + ', '.join(safe_repr(e) for e in obj) + '})' case set(): if not obj: return cls_name + '()' return '{' + ', '.join(safe_repr(e) for e in obj) + '}' case _: try: detail = f'{type(e).__name__}: {e}' except: detail = type(e).__name__ return f'' def safe_pformat(obj: Any) -> str: """ A fault-tolerant version of :func:`pprint.pformat` but tolerant. Falls back to :func:`safe_repr` when :func:`~pprint.pformat` fails. """ try: return pformat(obj) except: return safe_repr(obj) class AlreadySeen: """ A marker for an object that has already been seen during a :func:`~testfixtures.compare` call. These are used to prevent infinite recursion during comparison. """ def __init__(self, id_: int, obj: Any, breadcrumb: str) -> None: self.id = id_ self.obj = obj self.breadcrumb = breadcrumb def __repr__(self) -> str: return f'' def __eq__(self, other: Any)-> bool: if isinstance(other, AlreadySeen): return self.breadcrumb == other.breadcrumb else: return self.obj == other def diff(x: str, y: str, x_label: str | None = '', y_label: str | None = '') -> str: """ A shorthand function that uses :mod:`difflib` to return a string representing the differences between the two string arguments. Most useful when comparing multi-line strings. """ return '\n'.join( unified_diff( x.split('\n'), y.split('\n'), x_label or 'first', y_label or 'second', lineterm='') ) def compare_simple( x: Any, y: Any, context: 'CompareContext', newline_threshold: int = 15 ) -> str | None: """ Returns a very simple textual difference between the two supplied objects. """ if x != y: repr_x = safe_repr(x) repr_y = safe_repr(y) if repr_x == repr_y: if type(x) is not type(y): return compare_with_type(x, y, context) x_attrs = _extract_attrs(x) y_attrs = _extract_attrs(y) diff_ = None if not (x_attrs is None or y_attrs is None): diff_ = _compare_mapping(x_attrs, y_attrs, context, x, 'attributes ', '.%s') if diff_: return diff_ x_label = context.x_label or 'x' y_label = context.y_label or 'y' return f'Both {x_label} and {y_label} appear as {repr_x!r}, but are not equal!' labeled_x = context.label('x', repr_x) labeled_y = context.label('y', repr_y) if len(repr_x) > newline_threshold or len(repr_y) > newline_threshold: return f'not equal:\n{labeled_x}\n{labeled_y}' else: return f'{labeled_x} != {labeled_y}' return None def _extract_attrs(obj: Any, ignore: Iterable[str] | None = None) -> dict[str, Any] | None: try: attrs = vars(obj).copy() except TypeError: attrs = None else: if isinstance(obj, BaseException): attrs['args'] = obj.args has_slots = getattr(obj, '__slots__', not_there) is not not_there if has_slots: slots = set[str]() for cls in type(obj).__mro__: slots.update(getattr(cls, '__slots__', ())) if slots and attrs is None: attrs = {} for n in slots: value = getattr(obj, n, not_there) if value is not not_there: attrs[n] = value if attrs is None: return None if ignore is not None: for attr in ignore: attrs.pop(attr, None) return attrs def merge_ignored_attributes( *ignored: Iterable[str] | Mapping[type, Iterable[str]] | str | None ) -> Mapping[type, set[str]]: """ Merge multiple specifications of attributes to ignore into a single mapping. This is particularly useful when implementing custom comparers that need to combine their own attribute ignores with those passed via the context. Each argument can be: - ``None``: ignored - A :class:`~typing.Mapping` of type to iterable of attribute names: attributes for specific types - An iterable of attribute names: applies to all types - A single attribute name string: applies to all types Returns a mapping of types to sets of attribute names to ignore, where :data:`~typing.Any` is used as the key for attributes that apply to all types. """ final = defaultdict[type, set[str]](set) for i in ignored: if i is None: pass elif isinstance(i, Mapping): for type_, values in i.items(): final[type_].update(values) else: final[Any].update(i) return final def _attrs_to_ignore( ignore_attributes: Iterable[str] | Mapping[type, Iterable[str]], obj: Any ) -> set[str]: ignored = set() if isinstance(ignore_attributes, dict): ignored |= set(ignore_attributes.get(type(obj), ())) ignored |= set(ignore_attributes.get(Any, ())) else: ignored.update(ignore_attributes) return ignored def compare_object( x: object, y: object, context: 'CompareContext', ignore_attributes: Iterable[str] | Mapping[type, Iterable[str]] = () ) -> str | None: """ Compare the two supplied objects based on their type and attributes. :param ignore_attributes: Either a sequence of strings containing attribute names to be ignored when comparing, or a :class:`~typing.Mapping` of type to sequence of strings containing attribute names to be ignored when comparing that type. When specified as a mapping, you can use :data:`~typing.Any` as a key to specify attributes that should be ignored for all types. """ if type(x) is not type(y) or isinstance(x, type): return compare_simple(x, y, context) x_attrs = _extract_attrs(x, _attrs_to_ignore(ignore_attributes, x)) y_attrs = _extract_attrs(y, _attrs_to_ignore(ignore_attributes, y)) if x_attrs is None or y_attrs is None or not (x_attrs and y_attrs): return compare_simple(x, y, context) if not context.qualified_equals(x_attrs, y_attrs): return _compare_mapping(x_attrs, y_attrs, context, x, 'attributes ', '.%s') return None def compare_exception( x: BaseException, y: BaseException, context: 'CompareContext' ) -> str | None: """ Compare the two supplied exceptions based on their message, type and attributes. """ if x.args != y.args: return compare_simple(x, y, context) return context.call(compare_object, x, y) def compare_with_type(x: Any, y: Any, context: 'CompareContext') -> str: """ Return a textual description of the difference between two objects including information about their types. """ if type(x) is AlreadySeen and type(x.obj) is type(y) and x.obj == y: return '' source = locals() to_render = {} for name in 'x', 'y': obj = source[name] to_render[name] = context.label( name, '{0} ({1!r})'.format(_short_repr(obj), type(obj)) ) return '{x} != {y}'.format(**to_render) def compare_sequence( x: Sequence, y: Sequence, context: 'CompareContext', prefix: bool = True ) -> str | None: """ Returns a textual description of the differences between the two supplied sequences. """ l_x = len(x) l_y = len(y) i = 0 while i < l_x and i < l_y: if context.different(x[i], y[i], '[%i]' % i): break i += 1 if l_x == l_y and i == l_x: return None header = 'sequence not as expected:\n\n' if prefix else '' same = safe_pformat(x[:i]) x_label = context.x_label or 'first' y_label = context.y_label or 'second' return ( f'{header}same:\n{same}\n\n' f'{x_label}:\n{safe_pformat(x[i:])}\n\n' f'{y_label}:\n{safe_pformat(y[i:])}' ) def compare_generator(x: Iterable, y: Iterable, context: 'CompareContext') -> str | None: """ Returns a textual description of the differences between the two supplied generators. This is done by first unwinding each of the generators supplied into tuples and then passing those tuples to :func:`compare_sequence`. """ x = tuple(x) y = tuple(y) if context.qualified_equals(x, y): return None return compare_sequence(x, y, context) def compare_tuple(x: tuple, y: tuple, context: 'CompareContext') -> str | None: """ Returns a textual difference between two tuples or :func:`collections.namedtuple` instances. The presence of a ``_fields`` attribute on a tuple is used to decide whether or not it is a :func:`~collections.namedtuple`. """ x_fields = getattr(x, '_fields', None) y_fields = getattr(y, '_fields', None) if x_fields and y_fields: if x_fields == y_fields: return _compare_mapping(dict(zip(x_fields, x)), dict(zip(y_fields, y)), context, x) else: return compare_with_type(x, y, context) return compare_sequence(x, y, context) def compare_dict(x: dict, y: dict, context: 'CompareContext') -> str | None: """ Returns a textual description of the differences between the two supplied dictionaries. """ return _compare_mapping(x, y, context, x) Item = TypeVar('Item') def sorted_by_repr(sequence: Iterable[Item]) -> List[Item]: return sorted(sequence, key=safe_repr) def _compare_mapping( x: Mapping, y: Mapping, context: 'CompareContext', obj_for_class: Any, prefix: str = '', breadcrumb: str = '[%r]', check_y_not_x: bool = True ) -> str | None: x_keys = set(x.keys()) y_keys = set(y.keys()) x_not_y = x_keys - y_keys y_not_x = y_keys - x_keys same = [] diffs = [] for key in sorted_by_repr(x_keys.intersection(y_keys)): if context.different(x[key], y[key], breadcrumb % (key, )): labelled_x = context.label('x', safe_pformat(x[key])) labelled_y = context.label('y', safe_pformat(y[key])) diffs.append(f'{safe_repr(key)}: {labelled_x} != {labelled_y}') else: same.append(key) if not (x_not_y or (check_y_not_x and y_not_x) or diffs): return None if obj_for_class is not_there: lines = [] else: lines = [f'{obj_for_class.__class__.__name__} not as expected:'] if same: try: same = sorted(same) except TypeError: pass lines.extend(('', f'{prefix}same:', safe_repr(same))) x_label = context.x_label or 'first' y_label = context.y_label or 'second' if x_not_y: lines.extend(('', f'{prefix}in {x_label} but not {y_label}:')) for key in sorted_by_repr(x_not_y): lines.append(f'{safe_repr(key)}: {safe_pformat(x[key])}') if y_not_x: lines.extend(('', f'{prefix}in {y_label} but not {x_label}:')) for key in sorted_by_repr(y_not_x): lines.append(f'{safe_repr(key)}: {safe_pformat(y[key])}') if diffs: lines.extend(('', f"{prefix or 'values '}differ:")) lines.extend(diffs) return '\n'.join(lines) def compare_set(x: set, y: set, context: 'CompareContext') -> str | None: """ Returns a textual description of the differences between the two supplied sets. """ x_not_y = x - y y_not_x = y - x if not (y_not_x or x_not_y): return None lines = [f'{x.__class__.__name__} not as expected:', ''] x_label = context.x_label or 'first' y_label = context.y_label or 'second' if x_not_y: lines.extend(( f'in {x_label} but not {y_label}:', safe_pformat(sorted_by_repr(x_not_y)), '', )) if y_not_x: lines.extend(( f'in {y_label} but not {x_label}:', safe_pformat(sorted_by_repr(y_not_x)), '', )) return '\n'.join(lines)+'\n' trailing_whitespace_re: Pattern = re.compile(r'\s+$', re.MULTILINE) def strip_blank_lines(text: str) -> str: result = [] for line in text.split('\n'): if line and not line.isspace(): result.append(line) return '\n'.join(result) def split_repr(text: str) -> str: parts = text.split('\n') for i, part in enumerate(parts[:-1]): parts[i] = repr(part + '\n') parts[-1] = repr(parts[-1]) return '\n'.join(parts) def compare_text( x: str, y: str, context: 'CompareContext', blanklines: bool = True, trailing_whitespace: bool = True, show_whitespace: bool = False, ) -> str | None: """ Returns an informative string describing the differences between the two supplied strings. The way in which this comparison is performed can be controlled using the following parameters: :param blanklines: If `False`, then when comparing multi-line strings, any blank lines in either argument will be ignored. :param trailing_whitespace: If `False`, then when comparing multi-line strings, trailing whilespace on lines will be ignored. :param show_whitespace: If `True`, then whitespace characters in multi-line strings will be replaced with their representations. """ if not trailing_whitespace: x = trailing_whitespace_re.sub('', x) y = trailing_whitespace_re.sub('', y) if not blanklines: x = strip_blank_lines(x) y = strip_blank_lines(y) if x == y: return None labelled_x = context.label('x', repr(x)) labelled_y = context.label('y', repr(y)) if len(x) > 10 or len(y) > 10: if '\n' in x or '\n' in y: if show_whitespace: x = split_repr(x) y = split_repr(y) message = '\n' + diff(x, y, context.x_label, context.y_label) else: message = '\n%s\n!=\n%s' % (labelled_x, labelled_y) else: message = labelled_x+' != '+labelled_y return message def compare_bytes(x: bytes, y: bytes, context: 'CompareContext') -> str | None: if x == y: return None labelled_x = context.label('x', repr(x)) labelled_y = context.label('y', repr(y)) return '\n%s\n!=\n%s' % (labelled_x, labelled_y) def compare_call(x: _Call, y: _Call, context: 'CompareContext') -> str | None: if x == y: return None def extract(call: _Call) -> tuple[str, tuple[Any], dict[str, Any]]: try: name, args, kwargs = call except ValueError: name = None args, kwargs = call return name, args, kwargs x_name, x_args, x_kw = extract(x) y_name, y_args, y_kw = extract(y) if x_name == y_name and x_args == y_args and x_kw == y_kw: return compare_call(getattr(x, parent_name), getattr(y, parent_name), context) if repr(x) != repr(y): return compare_text(repr(x), repr(y), context) different = ( context.different(x_name, y_name, ' function name') or context.different(x_args, y_args, ' args') or context.different(x_kw, y_kw, ' kw') ) if not different: return None return 'mock.call not as expected:' def compare_partial(x: partial_type, y: partial_type, context: 'CompareContext') -> str | None: x_attrs = dict(func=x.func, args=x.args, keywords=x.keywords) y_attrs = dict(func=y.func, args=y.args, keywords=y.keywords) if x_attrs != y_attrs: return _compare_mapping(x_attrs, y_attrs, context, x, 'attributes ', '.%s') return None def compare_path(x: Path, y: Path, context: 'CompareContext') -> str | None: return compare_text(str(x), str(y), context) def compare_with_fold(x: datetime, y: datetime, context: 'CompareContext') -> str | None: if not (x == y and x.fold == y.fold): repr_x = repr(x) repr_y = repr(y) if repr_x == repr_y: repr_x += f' (fold={x.fold})' repr_y += f' (fold={y.fold})' return context.label('x', repr_x)+' != '+context.label('y', repr_y) return None def _short_repr(obj: Any) -> str: repr_ = safe_repr(obj) if len(repr_) > 30: repr_ = repr_[:30] + '...' return repr_ def compare_exception_group( x: BaseExceptionGroup, y: BaseExceptionGroup, context: 'CompareContext' ) -> str | None: """ Compare the two supplied exception groups """ x_msg, x_excs = x.args y_msg, y_excs = y.args msg_different = context.different(x_msg, y_msg, 'msg') excs_different = context.different(x_excs, y_excs, 'excs') if msg_different or excs_different: return 'exception group not as expected:' return None testfixtures-12.4.0/src/testfixtures/comparing.py000066400000000000000000000424661525773641100222670ustar00rootroot00000000000000from collections.abc import Iterable from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime, time from decimal import Decimal from functools import partial as partial_type from inspect import signature from pathlib import Path from types import GeneratorType from typing import ( Any, List, Callable, overload, Self, TypeAlias, Literal, Iterator, ) from typing import _GenericAlias as GenericAlias # type: ignore[attr-defined] from unittest.mock import call as unittest_mock_call from testfixtures import not_there, singleton from testfixtures.mock import mock_call from .comparers import * # Some common types that are immutable, for optimisation purposes within CompareContext IMMUTABLE_TYPEs = str, bytes, int, float, tuple, type(None) # Container types whose `__eq__` delegates to their elements. When per-type # ignore_eq is in play, we can't trust `x == y` on these because nested # instances of an ignored type would silently leak through. CONTAINER_TYPES = list, tuple, dict, set, frozenset # Things that are iterable, but should not be treated as such. UNSAFE_ITERABLES = str, bytes, dict, GenericAlias Comparer = Callable[[Any, Any, 'CompareContext'], str | None] Comparers: TypeAlias = dict[type, Comparer] DEFAULT_COMPARERS: Comparers = { dict: compare_dict, set: compare_set, frozenset: compare_set, list: compare_sequence, tuple: compare_tuple, str: compare_text, bytes: compare_bytes, int: compare_simple, float: compare_simple, Decimal: compare_simple, GeneratorType: compare_generator, mock_call.__class__: compare_call, unittest_mock_call.__class__: compare_call, BaseException: compare_exception, BaseExceptionGroup: compare_exception_group, partial_type: compare_partial, Path: compare_path, datetime: compare_with_fold, time: compare_with_fold, } @dataclass class Registry: comparers: dict[type, Comparer] all_option_names: set[str] options_for: dict[Comparer, set[str]] ignore_eq_types: set[type] original: "Registry | None" = None @staticmethod def _shared_mro(x: Any, y: Any) -> Iterable[type]: y_mro = set(type(y).__mro__) for class_ in type(x).__mro__: if class_ in y_mro: yield class_ def lookup(self, x: Any, y: Any, strict: bool) -> Comparer: if strict and type(x) is not type(y): return compare_with_type for class_ in self._shared_mro(x, y): comparer = self.comparers.get(class_) if comparer: return comparer # fallback for iterables if ((isinstance(x, Iterable) and isinstance(y, Iterable)) and not (isinstance(x, UNSAFE_ITERABLES) or isinstance(y, UNSAFE_ITERABLES))): return compare_generator return compare_object def __setitem__(self, key: type, value: Comparer) -> None: options = set(tuple(signature(value).parameters)[3:]) self.options_for[value] = options self.all_option_names |= options self.comparers[key] = value @classmethod def initial(cls, comparers: Comparers | None = None) -> Self: registry = cls( comparers={}, all_option_names = {'ignore_attributes'}, options_for = {compare_object: {'ignore_attributes'}}, ignore_eq_types = set(), ) for name, value in (DEFAULT_COMPARERS if comparers is None else comparers).items(): registry[name] = value return registry def copy(self) -> Self: return type(self)( comparers=self.comparers.copy(), all_option_names = self.all_option_names.copy(), options_for = self.options_for.copy(), ignore_eq_types = self.ignore_eq_types.copy(), ) def overlay_with(self, comparers: Comparers) -> Self: registry = self.copy() for name, value in comparers.items(): registry[name] = value return registry def install(self) -> Self: global _registry self.original = _registry _registry = self return self def uninstall(self) -> None: global _registry assert self.original is not None, "uninstall() called without install()" _registry = self.original del self.original _registry = Registry.initial() @contextmanager def registry(comparers: Comparers | None = None) -> Iterator[Registry]: _registry = Registry.initial(comparers) _registry.install() try: yield _registry finally: _registry.uninstall() @overload def register(type_: type, comparer: Comparer) -> None: ... @overload def register(type_: type, comparer: Comparer, *, ignore_eq: bool) -> None: ... @overload def register(type_: type, *, ignore_eq: Literal[True]) -> None: ... def register( type_: type, comparer: Comparer | None = None, *, ignore_eq: bool = False, ) -> None: """ Register the supplied comparer for the specified type, and/or mark the type as one whose ``__eq__`` should be ignored during comparison. At least one of ``comparer`` or ``ignore_eq=True`` must be supplied. This registration is global and will be in effect from the point this function is called until the end of the current process. """ if comparer is None and not ignore_eq: raise TypeError( "register() requires either a comparer or ignore_eq=True" ) if comparer is not None: _registry[type_] = comparer if ignore_eq: _registry.ignore_eq_types.add(type_) class CompareContext: """ Stores the context of the current comparison in process during a call to :func:`testfixtures.compare`. """ def __init__( self, x_label: str | None, y_label: str | None, recursive: bool = True, strict: bool = False, ignore_eq: bool | type | Iterable[type] = False, comparers: Comparers | None = None, options: dict[str, Any] | None = None, ): self._registry = _registry.overlay_with(comparers) if comparers else _registry if options: invalid = set(options) - self._registry.all_option_names if invalid: raise TypeError( 'The following options are not valid: ' + ', '.join(invalid) ) self.x_label = x_label self.y_label = y_label self.recursive: bool = recursive self.strict: bool = strict self.ignore_eq_all: bool = False self.ignore_eq_types: set[type] = set(self._registry.ignore_eq_types) if ignore_eq is True: self.ignore_eq_all = True elif ignore_eq is False: pass elif isinstance(ignore_eq, type): self.ignore_eq_types.add(ignore_eq) else: self.ignore_eq_types.update(ignore_eq) self.options: dict[str, Any] = options or {} self.message: str = '' self.breadcrumbs: List[str] = [] self._seen: dict[int, str] = {} def extract_args(self, args: tuple, x: Any, y: Any, expected: Any, actual: Any) -> List: possible = list[Any]() def append_if_specified(source: Any) -> None: if source is not unspecified: possible.append(source) append_if_specified(expected) possible.extend(args) append_if_specified(actual) append_if_specified(x) append_if_specified(y) if len(possible) != 2: message = 'Exactly two objects needed, you supplied:' if possible: message += ' {}'.format(possible) raise TypeError(message) return possible def label(self, side: Literal["x", "y"], value: Any) -> str: """ Generate a labelled representation of the value for one side of a comparison. """ r = str(value) label = getattr(self, side+'_label') if label: r += ' ('+label+')' return r def _separator(self) -> str: return '\n\nWhile comparing %s: ' % ''.join(self.breadcrumbs[1:]) def _break_loops(self, obj: Any, breadcrumb: str) -> Any: # Don't bother with this process for simple, immutable types: if isinstance(obj, IMMUTABLE_TYPEs): return obj id_ = id(obj) breadcrumb_ = self._seen.get(id_) if breadcrumb_ is not None: return AlreadySeen(id_, obj, breadcrumb_) else: self._seen[id_] = breadcrumb return obj def qualified_equals(self, x: Any, y: Any) -> bool: """ Determines if two objects are equal, taking ``strict``, ``ignore_eq`` and instances of :class:`~testfixtures.comparers.AlreadySeen` into account. """ pair = x, y # When either side is an AlreadySeen wrapper for the other (same # underlying id), it's the same object we already handled — # equal by identity. Skipping __eq__ here keeps that guarantee # when __eq__ is broken, strict about unknown operands, or being # bypassed via ignore_eq. if any(type(o) is AlreadySeen for o in pair): a, b = (o.id if type(o) is AlreadySeen else id(o) for o in pair) if a == b: return True # AlreadySeen.__eq__ delegates to the wrapped object, so let # normal equality run when the wrapper is on the right. if type(y) is AlreadySeen: return x == y if self.strict or self.ignore_eq_all: return False # Containers delegate __eq__ to their elements, so when any # ignored type is in play we must block container == as well. types = self.ignore_eq_types if types and any( isinstance(o, CONTAINER_TYPES) or not types.isdisjoint(type(o).__mro__) for o in pair ): return False return x == y def call(self, comparer: Comparer, x: Any, y: Any) -> str | None: kw = {} option_names = self._registry.options_for.get(comparer) if option_names: for name in option_names: value = self.options.get(name, not_there) if value is not not_there: kw[name] = value return comparer(x, y, self, **kw) def different(self, x: Any, y: Any, breadcrumb: str) -> bool | str | None: """ Comparers can call this method to :ref:`hand off ` comparison of elements within the objects they are currently comparing. """ x = self._break_loops(x, breadcrumb) y = self._break_loops(y, breadcrumb) recursed = bool(self.breadcrumbs) self.breadcrumbs.append(breadcrumb) existing_message = self.message self.message = '' current_message = '' try: try: if self.qualified_equals(x, y): return False except RecursionError: pass comparer: Comparer = self._registry.lookup(x, y, self.strict) result = self.call(comparer, x, y) specific_comparer = comparer is not compare_simple if result: if specific_comparer and recursed: current_message = self._separator() if specific_comparer or not recursed: current_message += result if self.recursive: current_message += self.message return result finally: self.message = existing_message + current_message self.breadcrumbs.pop() def _resolve_lazy(source: Any) -> str: value = source() if callable(source) else source try: return str(value) except Exception: return safe_repr(value) unspecified = singleton('unspecified') def compare( *args: Any, x: Any = unspecified, y: Any = unspecified, expected: Any = unspecified, actual: Any = unspecified, prefix: str | Callable[[], str] | None = None, suffix: str | Callable[[], str] | None = None, x_label: str | None = None, y_label: str | None = None, raises: bool = True, recursive: bool = True, strict: bool = False, ignore_eq: bool | type | Iterable[type] = False, comparers: Comparers | None = None, **options: Any ) -> str | None: """ Compare two objects, raising an :class:`AssertionError` if they are not the same. The :class:`AssertionError` raised will attempt to provide descriptions of the differences found. The two objects to compare can be passed either positionally or using explicit keyword arguments named ``x`` and ``y``, or ``expected`` and ``actual``, or a mixture of these. :param prefix: If provided, in the event of an :class:`AssertionError` being raised, the prefix supplied will be prepended to the message in the :class:`AssertionError`. This may be a callable, in which case it will only be resolved if needed. :param suffix: If provided, in the event of an :class:`AssertionError` being raised, the suffix supplied will be appended to the message in the :class:`AssertionError`. This may be a callable, in which case it will only be resolved if needed. :param x_label: If provided, in the event of an :class:`AssertionError` being raised, the object passed as the first positional argument, or ``x`` keyword argument, will be labelled with this string in the message in the :class:`AssertionError`. :param y_label: If provided, in the event of an :class:`AssertionError` being raised, the object passed as the second positional argument, or ``y`` keyword argument, will be labelled with this string in the message in the :class:`AssertionError`. :param raises: If ``False``, the message that would be raised in the :class:`AssertionError` will be returned instead of the exception being raised. :param recursive: If ``True``, when a difference is found in a nested data structure, attempt to highlight the location of the difference. :param strict: If ``True``, objects will only compare equal if they are of the same type as well as being equal. :param ignore_eq: Controls when ``__eq__`` is skipped in favour of a registered comparer (or the hash-equality fallback): * ``True`` — skip ``__eq__`` for *every* object. * ``False`` (default) — honour ``__eq__`` except for types registered with ``ignore_eq=True``. * a type — also skip ``__eq__`` whenever an instance of that type is on either side of a comparison. * an iterable of types — as above, for each type. :param comparers: If supplied, should be a dictionary mapping types to comparer functions for those types. These will be added to the comparer registry for the duration of this call. Any other keyword parameters supplied will be passed to the functions that end up doing the comparison. See the :mod:`API documentation below ` for details of these. """ __tracebackhide__ = True if not (expected is unspecified and actual is unspecified): x_label = x_label or 'expected' y_label = y_label or 'actual' context = CompareContext(x_label, y_label, recursive, strict, ignore_eq, comparers, options) x, y = context.extract_args(args, x, y, expected, actual) if not context.different(x, y, ''): return None message = context.message if prefix: message = _resolve_lazy(prefix) + ': ' + message if suffix: message += '\n' + _resolve_lazy(suffix) if raises: raise AssertionError(message) return message try: from django.db.models import Model from .django import compare_model except ImportError: pass else: register(Model, compare_model, ignore_eq=True) try: from pandas import DataFrame as PandasDataFrame from .pandas import compare_dataframe as compare_pandas_dataframe except ImportError: pass else: register(PandasDataFrame, compare_pandas_dataframe, ignore_eq=True) try: from polars import DataFrame as PolarsDataFrame from .polars import compare_dataframe as compare_polars_dataframe except ImportError: pass else: register(PolarsDataFrame, compare_polars_dataframe, ignore_eq=True) try: from numpy import ndarray from numpy.ma import MaskedArray from .numpy import compare_masked_array, compare_ndarray except ImportError: pass else: register(ndarray, compare_ndarray, ignore_eq=True) register(MaskedArray, compare_masked_array, ignore_eq=True) try: from pydantic import BaseModel as PydanticBaseModel from .pydantic import compare_basemodel except ImportError: pass else: register(PydanticBaseModel, compare_basemodel, ignore_eq=True) testfixtures-12.4.0/src/testfixtures/comparison.py000066400000000000000000000674521525773641100224640ustar00rootroot00000000000000import re from abc import ABC, abstractmethod from collections import OrderedDict from functools import reduce from operator import __or__ from pprint import pformat from types import NotImplementedType from typing import ( Any, Sequence, TypeVar, Mapping, Callable, cast, overload, ) from typing import _GenericAlias as GenericAlias # type: ignore[attr-defined] from testfixtures import not_there, safe_pformat from testfixtures.comparers import ( _extract_attrs, AlreadySeen, _compare_mapping, safe_repr, compare_simple ) from testfixtures.comparing import CompareContext, compare, register from testfixtures.resolve import resolve, type_name from testfixtures.utils import indent class StatefulComparison: """ A base class for stateful comparison objects. """ failed: str | None = '' expected: Any = None name_attrs: Sequence[str] = () def __eq__(self, other: Any) -> bool: return not(self != other) def name(self) -> str: name = type(self).__name__ if self.name_attrs: name += '(%s)' % ', '.join('%s=%r' % (n, getattr(self, n)) for n in self.name_attrs) return name def body(self) -> str: return safe_pformat(self.expected)[1:-1] def __repr__(self) -> str: name = self.name() body = self.failed or self.body() prefix = '<%s%s>' % (name, self.failed and '(failed)' or '') if '\n' in body: return '\n'+prefix+'\n'+body.strip('\n')+'\n'+'' % name elif body: return prefix + body + '' return prefix class Comparison(StatefulComparison): """ These are used when you need to compare an object's type, a subset of its attributes or make equality checks with objects that do not natively support comparison. :param object_or_type: The object or class from which to create the :class:`Comparison`. :param attribute_dict: An optional dictionary containing attributes to place on the :class:`Comparison`. :param partial: If true, only the specified attributes will be checked and any extra attributes of the object being compared with will be ignored. :param attributes: Any other keyword parameters passed will placed as attributes on the :class:`Comparison`. """ def __init__(self, object_or_type: Any, attribute_dict: dict[str, Any] | None = None, partial: bool = False, **attributes: Any): self.partial = partial if attributes: if attribute_dict is None: attribute_dict = attributes else: attribute_dict.update(attributes) if isinstance(object_or_type, str): c = resolve(object_or_type).found if c is not_there: raise AttributeError( '%r could not be resolved' % object_or_type ) elif isinstance(object_or_type, type): c = object_or_type else: c = object_or_type.__class__ if attribute_dict is None: attribute_dict = _extract_attrs(object_or_type) self.expected_type = c self.expected_attributes = attribute_dict def __ne__(self, other: Any) -> bool: if isinstance(other, AlreadySeen): other = other.obj other_type = type(other) if self.expected_type is not other_type: self.failed = 'wrong type: ' + type_name(other_type) return True if self.expected_attributes is None: return False attribute_names = set(self.expected_attributes.keys()) actual_attributes: dict[str, Any] if self.partial: actual_attributes = {} else: actual_attributes = cast(dict[str, Any], _extract_attrs(other)) attribute_names -= set(actual_attributes) for name in attribute_names: try: actual_attributes[name] = getattr(other, name) except AttributeError: pass context = CompareContext(x_label='Comparison', y_label='actual') self.failed = _compare_mapping(self.expected_attributes, actual_attributes, context, obj_for_class=not_there, prefix='attributes ', breadcrumb='.%s', check_y_not_x=not self.partial) return bool(self.failed) def name(self) -> str: return 'C:' + type_name(self.expected_type) def body(self) -> str: if self.expected_attributes: # if we're not failed, show what we will expect: lines = [] for k, v in sorted(self.expected_attributes.items()): rv = safe_repr(v) if '\n' in rv: rv = indent(rv) lines.append(f'{k}: {rv}') return '\n'.join(lines) return '' class SequenceComparison(StatefulComparison): """ An object that can be used in comparisons of expected and actual sequences. :param expected: The items expected to be in the sequence. :param ordered: If ``True``, then the items are expected to be in the order specified. If ``False``, they may be in any order. Defaults to ``True``. :param partial: If ``True``, then any keys not expected will be ignored. Defaults to ``False``. :param recursive: If a difference is found, recursively compare the item where the difference was found to highlight exactly what was different. Defaults to ``False``. """ name_attrs: Sequence[str] = ('ordered', 'partial') def __init__( self, *expected: Any, ordered: bool = True, partial: bool = False, recursive: bool = False, ): self.expected = expected self.ordered = ordered self.partial = partial self.recursive = recursive self.checked_indices = set[int]() def __ne__(self, other: Any) -> bool: actual: list[Any] try: actual = original_actual = list(other) except TypeError: self.failed = 'bad type' return True expected = list(self.expected) actual = list(actual) matched = [] matched_expected_indices = [] matched_actual_indices = [] missing_from_expected = actual missing_from_expected_indices = actual_indices = list(range(len(actual))) missing_from_actual = [] missing_from_actual_indices = [] start = 0 for e_i, e in enumerate(expected): try: i = actual.index(e, start) a_i = actual_indices.pop(i) except ValueError: missing_from_actual.append(e) missing_from_actual_indices.append(e_i) else: matched.append(missing_from_expected.pop(i)) matched_expected_indices.append(e_i) matched_actual_indices.append(a_i) self.checked_indices.add(a_i) if self.ordered: start = i matches_in_order = matched_actual_indices == sorted(matched_actual_indices) all_matched = not (missing_from_actual or missing_from_expected) partial_match = self.partial and not missing_from_actual if (matches_in_order or not self.ordered) and (all_matched or partial_match): return False expected_indices = matched_expected_indices+missing_from_actual_indices actual_indices = matched_actual_indices if self.partial: # try to give a clue as to what didn't match: if self.recursive and self.ordered and missing_from_expected: actual_indices.append(missing_from_expected_indices.pop(0)) missing_from_expected.pop(0) ignored = missing_from_expected missing_from_expected = [] else: actual_indices += missing_from_expected_indices ignored = None message = [] def add_section(name: str, content: Any) -> None: if content: message.append(name+':\n'+pformat(content)) add_section('ignored', ignored) if self.ordered: message.append(cast(str, compare( expected=[self.expected[i] for i in sorted(expected_indices)], actual=[original_actual[i] for i in sorted(actual_indices)], recursive=self.recursive, raises=False )).split('\n\n', 1)[1]) else: add_section('same', matched) add_section('in expected but not actual', missing_from_actual) add_section('in actual but not expected', missing_from_expected) self.failed = '\n\n'.join(message) return True class Subset(SequenceComparison): """ A shortcut for :class:`SequenceComparison` that checks if the specified items are present in the sequence. """ name_attrs = () def __init__(self, *expected: Any) -> None: super(Subset, self).__init__(*expected, ordered=False, partial=True) class Permutation(SequenceComparison): """ A shortcut for :class:`SequenceComparison` that checks if the set of items in the sequence is as expected, but without checking ordering. """ def __init__(self, *expected: Any) -> None: super(Permutation, self).__init__(*expected, ordered=False, partial=False) class MappingComparison(StatefulComparison): """ An object that can be used in comparisons of expected and actual mappings. :param expected_mapping: The mapping that should be matched expressed as either a sequence of ``(key, value)`` tuples or a mapping. :param expected_items: The items that should be matched. :param ordered: If ``True``, then the keys in the mapping are expected to be in the order specified. Defaults to ``False``. :param partial: If ``True``, then any keys not expected will be ignored. Defaults to ``False``. :param recursive: If a difference is found, recursively compare the value where the difference was found to highlight exactly what was different. Defaults to ``False``. """ name_attrs = ('ordered', 'partial') def __init__( self, *expected_mapping: tuple[Any, Any] | Mapping[Any, Any], ordered: bool = False, partial: bool = False, recursive: bool = False, **expected_items: Any, ): self.ordered = ordered self.partial = partial self.recursive = recursive if len(expected_mapping) == 1: expected = OrderedDict(*expected_mapping) else: expected = OrderedDict(expected_mapping) # type: ignore[arg-type] expected.update(expected_items) self.expected = expected def body(self) -> str: parts = [] text_length = 0 for key, value in self.expected.items(): part = f'{safe_repr(key)}: {safe_pformat(value)}' text_length += len(part) parts.append(part) if text_length > 60: sep = ',\n' else: sep = ', ' return sep.join(parts) def __ne__(self, other: Any) -> bool: try: actual_keys = other.keys() actual_mapping = dict(other.items()) except AttributeError: self.failed = 'bad type' return True expected_keys = self.expected.keys() expected_mapping = self.expected if self.partial: ignored_keys = set(actual_keys) - set(expected_keys) for key in ignored_keys: del actual_mapping[key] # preserve the order: actual_keys = [k for k in actual_keys if k not in ignored_keys] else: ignored_keys = None mapping_differences = compare( expected=expected_mapping, actual=actual_mapping, recursive=self.recursive, raises=False ) if self.ordered: key_differences = compare( expected=list(expected_keys), actual=list(actual_keys), recursive=self.recursive, raises=False ) else: key_differences = None if key_differences or mapping_differences: message = [] if ignored_keys: message.append('ignored:\n'+pformat(sorted(ignored_keys))) if mapping_differences: message.append(mapping_differences.split('\n\n', 1)[1]) if key_differences: message.append('wrong key order:\n\n'+key_differences.split('\n\n', 1)[1]) self.failed = '\n\n'.join(message) return True return False class TextComparison: """ An object that can be used in comparisons of expected and actual strings where the string expected matches a pattern rather than a specific concrete string. :param regex_source: A string containing the source for a regular expression that will be used whenever this :class:`TextComparison` is compared with any :class:`str` instance, or an already compiled :class:`re.Pattern`. :param flags: Flags passed to :func:`re.compile`. :param flag_names: See the :ref:`examples `. """ @overload def __init__(self, regex_source: re.Pattern[str]): ... @overload def __init__(self, regex_source: str, flags: int | None = None, **flag_names: bool): ... def __init__( self, regex_source: str | re.Pattern[str], flags: int | None = None, **flag_names: bool, ): if isinstance(regex_source, re.Pattern): self.re = regex_source return args: list[Any] = [regex_source] flags_ = [] if flags: flags_.append(flags) flags_.extend(getattr(re, f.upper()) for f in flag_names) if flags_: args.append(reduce(__or__, flags_)) self.re = re.compile(*args) def __eq__(self, other: Any) -> bool | NotImplementedType: if not isinstance(other, str): return NotImplemented return self.re.match(other) is not None def __repr__(self) -> str: return '' % self.re.pattern def __lt__(self, other: Any) -> bool: return self.re.pattern < other def __gt__(self, other: Any) -> bool: return self.re.pattern > other # Deprecated alias for TextComparison. Note this is NOT the same as # StrComparison, which checks an object's type and its str(). StringComparison = TextComparison class RoundComparison: """ An object that can be used in comparisons of expected and actual numerics to a specified precision. :param value: numeric to be compared. :param precision: Number of decimal places to round to in order to perform the comparison. """ def __init__(self, value: float, precision: int): self.rounded = round(value, precision) self.precision = precision def __eq__(self, other: Any) -> bool: other_rounded = round(other, self.precision) if type(self.rounded) is not type(other_rounded): raise TypeError('Cannot compare %r with %r' % (self, type(other))) return self.rounded == other_rounded def __ne__(self, other: Any) -> bool: return not self == other def __repr__(self) -> str: return '' % (self.rounded, self.precision) class RangeComparison: """ An object that can be used in comparisons of orderable types to check that a value specified within the given range. :param lower_bound: the inclusive lower bound for the acceptable range. :param upper_bound: the inclusive upper bound for the acceptable range. """ def __init__(self, lower_bound: Any, upper_bound: Any) -> None: self.lower_bound = lower_bound self.upper_bound = upper_bound def __eq__(self, other: Any) -> bool: return self.lower_bound <= other <= self.upper_bound def __ne__(self, other: Any) -> bool: return not self == other def __repr__(self) -> str: return '' % (self.lower_bound, self.upper_bound) class RenderingComparison(ABC): """ A base for comparisons that check an object's type and a string rendering of it produced by :attr:`render`. """ @staticmethod @abstractmethod def render(other: object, /) -> str: """The callable used to render the compared object as a string.""" @overload def __init__(self, type_: type, expected: str): ... @overload def __init__(self, type_: type, *, match: str | re.Pattern[str]): ... def __init__( self, type_: type, expected: str | None = None, *, match: str | re.Pattern[str] | None = None, ): if (expected is None) == (match is None): raise TypeError('provide either expected or match') self.type = type_ self.expected = expected self.match = match def __eq__(self, other: Any) -> bool: if type(other) is not self.type: return False rendered = self.render(other) if self.match is not None: return re.search(self.match, rendered) is not None return rendered == self.expected def __ne__(self, other: Any) -> bool: return not self == other def __repr__(self) -> str: detail = self.expected if self.expected is not None else f'match={self.match!r}' return f'<{type(self).__name__}: {type_name(self.type)}: {detail}>' class ReprComparison(RenderingComparison): """ An object that can be used in comparisons to check that an object is both of an expected type and has an expected :func:`repr`. :param type_: the type the compared object must be exactly; subclasses do not match. :param expected: the :func:`repr` the compared object must have exactly. :param match: a regular expression, as either a :class:`str` or a compiled :class:`re.Pattern`, that the compared object's :func:`repr` must match. Mutually exclusive with ``expected``. """ render = staticmethod(repr) class StrComparison(RenderingComparison): """ An object that can be used in comparisons to check that an object is both of an expected type and has an expected :class:`str`. :param type_: the type the compared object must be exactly; subclasses do not match. :param expected: the :class:`str` the compared object must have exactly. :param match: a regular expression, as either a :class:`str` or a compiled :class:`re.Pattern`, that the compared object's :class:`str` must match. Mutually exclusive with ``expected``. """ render = staticmethod(str) T = TypeVar('T') @overload def like(t: str | re.Pattern[str]) -> str: ... @overload def like(t: type[T], **attributes: Any) -> T: ... def like(t: type[T] | str | re.Pattern[str], **attributes: Any) -> T | str: """ Create a type-safe partial comparison for use in strictly typed code. This is a convenience function that creates a :class:`Comparison` with ``partial=True`` but is typed to return the type being compared, making it compatible with strict type checkers like mypy. If passed a :class:`str` pattern or a compiled :class:`re.Pattern`, a :class:`TextComparison` typed as a :class:`str` is returned instead. :param t: The type to compare against, or a regular expression pattern. :param attributes: Keyword arguments specifying the attributes to check. :return: A :class:`Comparison` typed as the input type, or a :class:`TextComparison` typed as a :class:`str`. """ if isinstance(t, (str, re.Pattern)): return cast(str, TextComparison(t)) return Comparison(t, attribute_dict=attributes, partial=True) # type: ignore[return-value] @overload def repr_like(type_: type[T], expected: str) -> T: ... @overload def repr_like(type_: type[T], *, match: str | re.Pattern[str]) -> T: ... def repr_like( type_: type[T], expected: str | None = None, *, match: str | re.Pattern[str] | None = None, ) -> T: """ Create a type-safe :class:`ReprComparison` for use in strictly typed code. This is a convenience function that creates a :class:`ReprComparison` but is typed to return the type being compared, making it compatible with strict type checkers like mypy. :param type_: the type the compared object must be exactly; subclasses do not match. :param expected: the :func:`repr` the compared object must have exactly. :param match: a regular expression, as either a :class:`str` or a compiled :class:`re.Pattern`, that the compared object's :func:`repr` must match. Mutually exclusive with ``expected``. :return: A :class:`ReprComparison` typed as the input type. """ return cast(T, ReprComparison(type_, expected, match=match)) # type: ignore[call-overload] @overload def str_like(type_: type[T], expected: str) -> T: ... @overload def str_like(type_: type[T], *, match: str | re.Pattern[str]) -> T: ... def str_like( type_: type[T], expected: str | None = None, *, match: str | re.Pattern[str] | None = None, ) -> T: """ Create a type-safe :class:`StrComparison` for use in strictly typed code. This is a convenience function that creates a :class:`StrComparison` but is typed to return the type being compared, making it compatible with strict type checkers like mypy. :param type_: the type the compared object must be exactly; subclasses do not match. :param expected: the :class:`str` the compared object must have exactly. :param match: a regular expression, as either a :class:`str` or a compiled :class:`re.Pattern`, that the compared object's :class:`str` must match. Mutually exclusive with ``expected``. :return: A :class:`StrComparison` typed as the input type. """ return cast(T, StrComparison(type_, expected, match=match)) # type: ignore[call-overload] S = TypeVar("S", bound=Sequence[Any]) S_ = TypeVar("S_", bound=Sequence[Any]) @overload def sequence( partial: bool = False, ordered: bool = True, recursive: bool = True, ) -> Callable[[S], S]: ... @overload def sequence( partial: bool = False, ordered: bool = True, recursive: bool = True, *, returns: type[S_], ) -> Callable[[S], S_]: ... def sequence( partial: bool = False, ordered: bool = True, recursive: bool = True, *, returns: type[S_] | None = None, ) -> Callable[[S], S | S_]: """ Create a type-safe sequence comparison with configurable partial matching and ordering requirements. This function returns a callable that wraps a sequence in a comparison object, making it compatible with strict type checkers. :param partial: If ``True``, only items in the expected sequence need to be present in the actual sequence. Defaults to ``False``. :param ordered: If ``True``, items must appear in the same order. Defaults to ``True``. :param recursive: If ``True``, provide detailed recursive comparison when differences are found. Defaults to ``True``. :param returns: Optional type hint for the return type, used to satisfy type checkers when the comparison needs to appear as a different sequence type. :return: A callable that takes a sequence and returns a comparison object typed as a sequence. """ def maker(items: S) -> S | S_: return SequenceComparison( # type: ignore[return-value] *items, partial=partial, ordered=ordered, recursive=recursive ) return maker @overload def contains( items: S, ) -> S: ... @overload def contains( items: S, *, returns: type[S_], ) -> S_: ... def contains( items: S, *, returns: type[S_] | None = None, ) -> S | S_: """ Create a type-safe partial sequence comparison that ignores order. Checks that the specified items are present in the actual sequence, regardless of their order or what other items are present. This is useful when you only care that certain elements exist in a collection. :param items: The sequence of items that must be present. :param returns: Optional type hint for the return type, used to satisfy type checkers when the comparison needs to appear as a different sequence type. :return: A comparison object typed as a sequence. """ return SequenceComparison( # type: ignore[return-value] *items, ordered=False, partial=True, recursive=True ) @overload def unordered( items: S, ) -> S: ... @overload def unordered( items: S, *, returns: type[S_], ) -> S_: ... def unordered( items: S, *, returns: type[S_] | None = None, ) -> S | S_: """ Create a type-safe sequence comparison that ignores order but requires all items to match. Checks that the actual sequence contains exactly the same items as specified, but in any order. This is useful when order doesn't matter but you want to ensure no extra or missing items. :param items: The sequence of items that must match exactly. :param returns: Optional type hint for the return type, used to satisfy type checkers when the comparison needs to appear as a different sequence type. :return: A comparison object typed as a sequence. """ return SequenceComparison( # type: ignore[return-value] *items, ordered=False, partial=False, recursive=True ) M = TypeVar("M", bound=Mapping[Any, Any]) M_ = TypeVar("M_", bound=Mapping[Any, Any]) @overload def mapping( partial: bool = False, ordered: bool = False, recursive: bool = True, ) -> Callable[[M], M]: ... @overload def mapping( partial: bool = False, ordered: bool = False, recursive: bool = True, *, returns: type[M_], ) -> Callable[[M], M_]: ... def mapping( partial: bool = False, ordered: bool = False, recursive: bool = True, *, returns: type[M_] | None = None, ) -> Callable[[M], M | M_]: """ Create a type-safe mapping comparison with configurable key ordering and partial matching. This is the mapping equivalent of :func:`sequence`. It returns a callable that wraps a mapping in a :class:`MappingComparison`, typed to match the mapping being compared so that strict type checkers stay happy. :param partial: If ``True``, any keys not expected are ignored. Defaults to ``False``. :param ordered: If ``True``, the keys must appear in the order given. Defaults to ``False``. :param recursive: If ``True``, provide detailed recursive comparison when differences are found. Defaults to ``True``. :param returns: Optional type hint for the return type, used to satisfy type checkers when the comparison needs to appear as a different mapping type. :return: A callable that takes a mapping and returns a comparison object typed as a mapping. """ def maker(items: M) -> M | M_: return MappingComparison( # type: ignore[return-value] items, ordered=ordered, partial=partial, recursive=recursive ) return maker register(StatefulComparison, compare_simple) testfixtures-12.4.0/src/testfixtures/compat.py000066400000000000000000000006551525773641100215650ustar00rootroot00000000000000# compatibility module for different python versions import sys from typing import Tuple PY_VERSION: Tuple[int, int] = sys.version_info[:2] PY_312_PLUS: bool = PY_VERSION >= (3, 12) PY_313_PLUS: bool = PY_VERSION >= (3, 13) PY_315_PLUS: bool = PY_VERSION >= (3, 15) if PY_313_PLUS: from typing import TypeVar as TypeVar else: from typing_extensions import TypeVar as TypeVar # type: ignore[assignment, unused-ignore] testfixtures-12.4.0/src/testfixtures/datetime.py000066400000000000000000000471501525773641100220770ustar00rootroot00000000000000from calendar import timegm from datetime import datetime, timedelta, date, tzinfo as TZInfo from typing import Callable, Self, Tuple, TypeVar, Generic, Mapping import datetime as datetime_module T = TypeVar('T', bound=datetime | date) class Queue(list[T]): delta: float delta_delta: float delta_type: str def __init__(self, delta: float | None, delta_delta: float, delta_type: str) -> None: super().__init__() if delta is None: self.delta = 0 self.delta_delta = delta_delta else: self.delta = delta self.delta_delta = 0 self.delta_type = delta_type def advance_next(self, delta: timedelta) -> None: self[-1] += delta # type: ignore[assignment] def next(self) -> T: instance = self.pop(0) if not self: self.delta += self.delta_delta n = instance + timedelta(**{self.delta_type: self.delta}) self.append(n) # type: ignore[arg-type] return instance class MockedCurrent(Generic[T]): _mock_queue: Queue[T] _mock_base_class: type _mock_class: type _mock_tzinfo: TZInfo | None _mock_date_type: type[date] _correct_mock_type: Callable[[T], T] | None = None def __init_subclass__( cls, concrete: bool = False, queue: Queue[T] | None = None, strict: bool | None = None, tzinfo: TZInfo | None = None, date_type: type[date] = date, ) -> None: if concrete: assert not queue is None, 'queue must be passed if concrete=True' cls._mock_queue = queue cls._mock_base_class = cls.__bases__[0].__bases__[1] cls._mock_class = cls if strict else cls._mock_base_class cls._mock_tzinfo = tzinfo cls._mock_date_type = date_type @classmethod def add(cls, *args: int | T, **kw: int | TZInfo | None) -> None: if 'tzinfo' in kw or len(args) > 7: raise TypeError('Cannot add using tzinfo on %s' % cls.__name__) if args and isinstance(args[0], cls._mock_base_class): instance = args[0] instance_tzinfo = getattr(instance, 'tzinfo', None) if instance_tzinfo: if instance_tzinfo != cls._mock_tzinfo: raise ValueError( 'Cannot add %s with tzinfo of %s as configured to use %s' % ( instance.__class__.__name__, instance_tzinfo, cls._mock_tzinfo )) instance = instance.replace(tzinfo=None) # type: ignore[attr-defined,union-attr,call-arg,assignment,unused-ignore] if cls._correct_mock_type: instance = cls._correct_mock_type(instance) # type: ignore[arg-type,unused-ignore] else: instance = cls(*args, **kw) # type: ignore[arg-type,assignment,unused-ignore] cls._mock_queue.append(instance) # type: ignore[arg-type,unused-ignore] @classmethod def set(cls, *args: int | T, **kw: int | TZInfo | None) -> None: cls._mock_queue.clear() cls.add(*args, **kw) @classmethod def tick(cls, *args: timedelta, **kw: float) -> None: if kw: delta = timedelta(**kw) else: delta, = args cls._mock_queue.advance_next(delta) def __add__(self, other: timedelta) -> Self: instance = super().__add__(other) # type: ignore[misc] if self._correct_mock_type: instance = self._correct_mock_type(instance) return instance def __new__(cls, *args: int, **kw: int | TZInfo | None) -> Self: if cls is cls._mock_class: return super().__new__(cls, *args, **kw) else: return cls._mock_class(*args, **kw) def mock_factory( type_name: str, mock_class: type[MockedCurrent[T]], default: Tuple[int, ...], args: tuple[int | T | None | TZInfo, ...], kw: Mapping[str, int | TZInfo | None], delta: float | None, delta_type: str, delta_delta: float = 1, date_type: type[date] | None = None, tzinfo: TZInfo | None = None, strict: bool = False ) -> type[MockedCurrent[T]]: cls = type( type_name, (mock_class,), {}, concrete=True, queue=Queue(delta, delta_delta, delta_type), strict=strict, tzinfo=tzinfo, date_type=date_type, ) if args != (None,): if not (args or kw): args = default cls.add(*args, **kw) # type: ignore[attr-defined] return cls class MockDateTime(MockedCurrent[datetime], datetime): @classmethod def add(cls, *args: int | datetime, **kw: int | TZInfo | None) -> None: """ This will add the :class:`datetime.datetime` created from the supplied parameters to the queue of datetimes to be returned by :meth:`~MockDateTime.now` or :meth:`~MockDateTime.utcnow`. An instance of :class:`~datetime.datetime` may also be passed as a single positional argument. """ return super().add(*args, **kw) @classmethod def set(cls, *args: int | datetime, **kw: int | TZInfo | None) -> None: """ This will set the :class:`datetime.datetime` created from the supplied parameters as the next datetime to be returned by :meth:`~MockDateTime.now` or :meth:`~MockDateTime.utcnow`, clearing out any datetimes in the queue. An instance of :class:`~datetime.datetime` may also be passed as a single positional argument. """ return super().set(*args, **kw) @classmethod def tick(cls, *args: timedelta, **kw: float) -> None: """ This method should be called either with a :class:`~datetime.timedelta` as a positional argument, or with keyword parameters that will be used to construct a :class:`~datetime.timedelta`. The :class:`~datetime.timedelta` will be used to advance the next datetime to be returned by :meth:`~MockDateTime.now` or :meth:`~MockDateTime.utcnow`. """ return super().tick(*args, **kw) @classmethod def _correct_mock_type(cls, instance: datetime) -> Self: return cls._mock_class( instance.year, instance.month, instance.day, instance.hour, instance.minute, instance.second, instance.microsecond, instance.tzinfo, ) @classmethod def _adjust_instance_using_tzinfo(cls, instance: datetime) -> Self: if cls._mock_tzinfo: offset = cls._mock_tzinfo.utcoffset(instance) if offset is None: raise TypeError('tzinfo with .utcoffset() returning None is not supported') instance = instance - offset return instance # type: ignore[return-value] @classmethod def now(cls, tz: TZInfo | None = None) -> Self: """ :param tz: An optional timezone to apply to the returned time. If supplied, it must be an instance of a :class:`~datetime.tzinfo` subclass. This will return the next supplied or calculated datetime from the internal queue, rather than the actual current datetime. If `tz` is supplied, see :ref:`timezones`. """ instance = cls._mock_queue.next() if tz is not None: instance = tz.fromutc(cls._adjust_instance_using_tzinfo(instance).replace(tzinfo=tz)) return cls._correct_mock_type(instance) @classmethod def utcnow(cls) -> Self: """ This will return the next supplied or calculated datetime from the internal queue, rather than the actual current UTC datetime. If you care about timezones, see :ref:`timezones`. """ instance = cls._mock_queue.next() return cls._adjust_instance_using_tzinfo(instance) def date(self) -> datetime_module.date: """ This will return the date component of the current mock instance, but using the date type supplied when the mock class was created. """ return self._mock_date_type( self.year, self.month, self.day ) def mock_datetime( *args: int | datetime | None | TZInfo, tzinfo: TZInfo | None = None, delta: float | None = None, delta_type: str = 'seconds', date_type: type[date] = date, strict: bool = False, **kw: int | TZInfo | None, ) -> type[MockDateTime]: """ .. currentmodule:: testfixtures.datetime A function that returns a mock object that can be used in place of the :class:`datetime.datetime` class but where the return value of :meth:`~MockDateTime.now` can be controlled. If a single positional argument of ``None`` is passed, then the queue of datetimes to be returned will be empty and you will need to call :meth:`~MockDateTime.set` or :meth:`~MockDateTime.add` before calling :meth:`~MockDateTime.now` or :meth:`~MockDateTime.utcnow`. If an instance of :class:`~datetime.datetime` is passed as a single positional argument, that will be used as the first date returned by :meth:`~MockDateTime.now` :param year: An optional year used to create the first datetime returned by :meth:`~MockDateTime.now`. :param month: An optional month used to create the first datetime returned by :meth:`~MockDateTime.now`. :param day: An optional day used to create the first datetime returned by :meth:`~MockDateTime.now`. :param hour: An optional hour used to create the first datetime returned by :meth:`~MockDateTime.now`. :param minute: An optional minute used to create the first datetime returned by :meth:`~MockDateTime.now`. :param second: An optional second used to create the first datetime returned by :meth:`~MockDateTime.now`. :param microsecond: An optional microsecond used to create the first datetime returned by :meth:`~MockDateTime.now`. :param tzinfo: An optional :class:`datetime.tzinfo`, see :ref:`timezones`. :param delta: The size of the delta to use between values returned from mocked class methods. If not specified, it will increase by 1 with each call to :meth:`~MockDateTime.now`. :param delta_type: The type of the delta to use between values returned from mocked class methods. This can be any keyword parameter accepted by the :class:`~datetime.timedelta` constructor. :param date_type: The type to use for the return value of the mocked class methods. This can help with gotchas that occur when type checking is performed on values returned by the :meth:`~testfixtures.datetime.MockDateTime.date` method. :param strict: If ``True``, calling the mock class and any of its methods will result in an instance of the mock being returned. If ``False``, the default, an instance of :class:`~datetime.datetime` will be returned instead. The mock returned will behave exactly as the :class:`datetime.datetime` class as well as being a subclass of :class:`~testfixtures.datetime.MockDateTime`. """ if len(args) > 7: tzinfo = args[7] # type: ignore[assignment] args = args[:7] else: tzinfo = tzinfo or (getattr(args[0], 'tzinfo', None) if args else None) return mock_factory( 'MockDateTime', MockDateTime, (2001, 1, 1, 0, 0, 0), args, kw, tzinfo=tzinfo, delta=delta, delta_delta=10, delta_type=delta_type, date_type=date_type, strict=strict, ) # type: ignore[return-value] class MockDate(MockedCurrent[date], date): @classmethod def _correct_mock_type(cls, instance: date) -> Self: return cls._mock_class( instance.year, instance.month, instance.day, ) @classmethod def add(cls, *args: int | date, **kw: int | TZInfo | None) -> None: """ This will add the :class:`datetime.date` created from the supplied parameters to the queue of dates to be returned by :meth:`~MockDate.today`. An instance of :class:`~datetime.date` may also be passed as a single positional argument. """ return super().add(*args, **kw) @classmethod def set(cls, *args: int | date, **kw: int | TZInfo | None) -> None: """ This will set the :class:`datetime.date` created from the supplied parameters as the next date to be returned by :meth:`~MockDate.today`, regardless of any dates in the queue. An instance of :class:`~datetime.date` may also be passed as a single positional argument. """ return super().set(*args, **kw) @classmethod def tick(cls, *args: timedelta, **kw: float) -> None: """ This method should be called either with a :class:`~datetime.timedelta` as a positional argument, or with keyword parameters that will be used to construct a :class:`~datetime.timedelta`. The :class:`~datetime.timedelta` will be used to advance the next date to be returned by :meth:`~MockDate.today`. """ return super().tick(*args, **kw) @classmethod def today(cls) -> Self: """ This will return the next supplied or calculated date from the internal queue, rather than the actual current date. """ return cls._mock_queue.next() # type: ignore[return-value] def mock_date( *args: int | date | None, delta: float | None = None, delta_type: str = 'days', strict: bool = False, **kw: int ) -> type[MockDate]: """ .. currentmodule:: testfixtures.datetime A function that returns a mock object that can be used in place of the :class:`datetime.date` class but where the return value of :meth:`~datetime.date.today` can be controlled. If a single positional argument of ``None`` is passed, then the queue of dates to be returned will be empty and you will need to call :meth:`~MockDate.set` or :meth:`~MockDate.add` before calling :meth:`~MockDate.today`. If an instance of :class:`~datetime.date` is passed as a single positional argument, that will be used as the first date returned by :meth:`~datetime.date.today` :param year: An optional year used to create the first date returned by :meth:`~datetime.date.today`. :param month: An optional month used to create the first date returned by :meth:`~datetime.date.today`. :param day: An optional day used to create the first date returned by :meth:`~datetime.date.today`. :param delta: The size of the delta to use between values returned from :meth:`~datetime.date.today`. If not specified, it will increase by 1 with each call to :meth:`~datetime.date.today`. :param delta_type: The type of the delta to use between values returned from :meth:`~datetime.date.today`. This can be any keyword parameter accepted by the :class:`~datetime.timedelta` constructor. :param strict: If ``True``, calling the mock class and any of its methods will result in an instance of the mock being returned. If ``False``, the default, an instance of :class:`~datetime.date` will be returned instead. The mock returned will behave exactly as the :class:`datetime.date` class as well as being a subclass of :class:`~testfixtures.datetime.MockDate`. """ return mock_factory( 'MockDate', MockDate, (2001, 1, 1), args, kw, delta=delta, delta_type=delta_type, strict=strict, ) # type: ignore[return-value] ms = 10**6 class MockTimeFactory(MockedCurrent[datetime], datetime): pass class MockTime: def __init__(self, factory: type[MockedCurrent[datetime]]): self.factory = factory def add(self, *args: int | datetime, **kw: int | TZInfo | None) -> None: """ This will add the time specified by the supplied parameters to the queue of times to be returned by calls to the mock. The parameters are the same as the :class:`datetime.datetime` constructor. An instance of :class:`~datetime.datetime` may also be passed as a single positional argument. """ self.factory.add(*args, **kw) def set(self, *args: int | datetime, **kw: int | TZInfo | None) -> None: """ This will set the time specified by the supplied parameters as the next time to be returned by a call to the mock, regardless of any times in the queue. The parameters are the same as the :class:`datetime.datetime` constructor. An instance of :class:`~datetime.datetime` may also be passed as a single positional argument. """ self.factory.set(*args, **kw) def tick(self, *args: timedelta, **kw: float) -> None: """ This method should be called either with a :class:`~datetime.timedelta` as a positional argument, or with keyword parameters that will be used to construct a :class:`~datetime.timedelta`. The :class:`~datetime.timedelta` will be used to advance the next time to be returned by a call to the mock. """ self.factory.tick(*args, **kw) def __call__(self) -> float: instance = self.factory._mock_queue.next() time: float = timegm(instance.utctimetuple()) time += (float(instance.microsecond) / ms) return time def mock_time( *args: int | datetime | None, delta: float | None = None, delta_type: str = 'seconds', **kw: int, ) -> MockTime: """ .. currentmodule:: testfixtures.datetime A function that returns a :class:`mock object ` that can be used in place of the :func:`time.time` function but where the return value can be controlled. If a single positional argument of ``None`` is passed, then the queue of times to be returned will be empty and you will need to call :meth:`~MockTime.set` or :meth:`~MockTime.add` before calling the mock. If an instance of :class:`~datetime.datetime` is passed as a single positional argument, that will be used to create the first time returned. :param year: An optional year used to create the first time returned. :param month: An optional month used to create the first time. :param day: An optional day used to create the first time. :param hour: An optional hour used to create the first time. :param minute: An optional minute used to create the first time. :param second: An optional second used to create the first time. :param microsecond: An optional microsecond used to create the first time. :param delta: The size of the delta to use between values returned. If not specified, it will increase by 1 with each call to the mock. :param delta_type: The type of the delta to use between values returned. This can be any keyword parameter accepted by the :class:`~datetime.timedelta` constructor. The :meth:`~testfixtures.datetime.MockTime.add`, :meth:`~testfixtures.datetime.MockTime.set` and :meth:`~testfixtures.datetime.MockTime.tick` methods on the mock can be used to control the return values. """ if 'tzinfo' in kw or len(args) > 7 or (args and getattr(args[0], 'tzinfo', None)): raise TypeError("You don't want to use tzinfo with test_time") factory = mock_factory( 'MockTime', MockTimeFactory, (2001, 1, 1, 0, 0, 0), args, kw, delta=delta, delta_type=delta_type, ) return MockTime(factory) testfixtures-12.4.0/src/testfixtures/django.py000066400000000000000000000035071525773641100215430ustar00rootroot00000000000000from typing import TYPE_CHECKING, Any, Iterable, Sequence from django.db.models import Model, Field from .comparers import _compare_mapping if TYPE_CHECKING: from .comparing import CompareContext def instance_fields(instance: Model) -> Iterable[Field]: opts = instance._meta for name in ( 'concrete_fields', 'virtual_fields', 'private_fields', ): fields = getattr(opts, name, None) if fields: for field in fields: yield field def model_to_dict( instance: Model, exclude: Sequence[str], include_not_editable: bool, ) -> dict[str, Any]: data = {} for f in instance_fields(instance): if f.name in exclude: continue if not getattr(f, 'editable', False) and not include_not_editable: continue data[f.name] = f.value_from_object(instance) return data def compare_model( x: Model, y: Model, context: 'CompareContext', ignore_fields: Sequence[str] = (), non_editable_fields: bool = False, ) -> str | None: """ Returns an informative string describing the differences between the two supplied Django model instances. The way in which this comparison is performed can be controlled using the following parameters: :param ignore_fields: A sequence of fields to ignore during comparison, most commonly set to ``['id']``. By default, no fields are ignored. :param non_editable_fields: If `True`, then fields with ``editable=False`` will be included in the comparison. By default, these fields are ignored. """ args: Any = [] for obj in x, y: args.append(model_to_dict(obj, ignore_fields, non_editable_fields)) args.append(context) args.append(x) return _compare_mapping(*args) testfixtures-12.4.0/src/testfixtures/formats.py000066400000000000000000000070511525773641100217520ustar00rootroot00000000000000""" Format handlers for serialization and deserialization of common data formats. This module provides a consistent interface for working with JSON, YAML, and TOML formats. Each format handler implements the Format protocol with parse() and render() methods. """ import json import tomllib from types import ModuleType from typing import Protocol, Any, Sequence tomlkit: ModuleType | None try: import tomlkit except ImportError: tomlkit = None yaml: ModuleType | None try: import yaml except ImportError: yaml = None class Format(Protocol): """ Protocol for format handlers that can parse and render data. This protocol defines the interface for serialization formats used with :class:`~testfixtures.TempDirectory`. Implement this protocol to add support for additional formats. """ #: File extensions supported by this format (with leading dot, e.g. ('.json',)) suffixes: Sequence[str] def parse(self, data: str) -> Any: """ Parse a string into a Python object. :param data: The string to parse. :returns: The deserialized Python object. """ ... def render(self, obj: Any) -> str: """ Render a Python object into a string. :param obj: The Python object to serialize. :returns: The serialized string. """ ... class JSONFormat: """JSON format handler using the standard library json module.""" suffixes: Sequence[str] = ('.json',) def parse(self, data: str) -> Any: """Parse JSON string into a Python object.""" return json.loads(data) def render(self, obj: Any) -> str: """Render a Python object into a JSON string.""" return json.dumps(obj) class YAMLFormat: """YAML format handler. Requires pyyaml to be installed.""" suffixes: Sequence[str] = ('.yaml', '.yml') def parse(self, data: str) -> Any: """Parse YAML string into a Python object.""" if yaml is None: raise ImportError("YAML support requires pyyaml to be installed") return yaml.safe_load(data) def render(self, obj: Any) -> str: """Render a Python object into a YAML string.""" if yaml is None: raise ImportError("YAML support requires pyyaml to be installed") return yaml.safe_dump(obj) class TOMLFormat: """TOML format handler. Reading uses the standard library tomllib (3.11+) or tomlkit if available. Writing requires tomlkit.""" suffixes: Sequence[str] = ('.toml',) def parse(self, data: str) -> Any: """Parse TOML string into a Python dict.""" if tomlkit is not None: return tomlkit.loads(data) return tomllib.loads(data) def render(self, obj: Any) -> str: """Render a Python dict into a TOML string.""" if tomlkit is None: raise ImportError("TOML writing requires tomlkit to be installed") return tomlkit.dumps(obj) #: JSON format handler using the standard library. Always available. JSON: Format = JSONFormat() #: YAML format handler. Requires the ``pyyaml`` package to be installed. #: #: To install the required package, use your package manager to install ``pyyaml``. YAML: Format = YAMLFormat() #: TOML format handler. Reading uses the standard library ``tomllib`` module #: (Python 3.11+) or ``tomlkit`` if available. Writing requires the ``tomlkit`` #: package. #: #: To install the package for writing support, use your package manager to install #: ``tomlkit``, or install testfixtures with the ``toml`` extra. TOML: Format = TOMLFormat() testfixtures-12.4.0/src/testfixtures/logcapture.py000066400000000000000000000536531525773641100224550ustar00rootroot00000000000000import atexit import logging import warnings from collections import defaultdict from contextlib import contextmanager from dataclasses import dataclass from logging import LogRecord from pprint import pformat from types import TracebackType from typing import ( Any, Callable, Iterator, Sequence, TypeAlias, TypeVar, List, Tuple, Self, Protocol, overload ) from warnings import warn from .comparing import compare from .comparison import SequenceComparison from .utils import wrap class CaptureSource(Protocol): """ Protocol that :class:`LogCapture` sources must implement. """ def install(self, collector: Callable[['Entry'], None]) -> None: """ Start capturing. :param collector: The destination for captured :attr:`~testfixtures.LogCapture.entries`. """ ... def uninstall(self) -> None: """Stop capturing and restore any previous state.""" ... T = TypeVar('T') AttributeSpec: TypeAlias = Sequence[str | Callable[[T], Any]] | Callable[[T], Any] def build_actual_extractor( attributes: AttributeSpec[T], extract_field: Callable[[T, str], Any], ) -> Callable[[T], Any]: # Compile the attributes spec into a callable that extracts an entry's # ``actual`` value from a raw record. A bare string is treated as a single # attribute name (``str`` is itself a ``Sequence[str]``). if callable(attributes): return attributes if isinstance(attributes, str): attributes = (attributes,) attrs = tuple(attributes) if len(attrs) == 1: only = attrs[0] if callable(only): return only return lambda raw: extract_field(raw, only) return lambda raw: tuple( a(raw) if callable(a) else extract_field(raw, a) for a in attrs ) @dataclass class Entry: """ A captured log entry captured by a :class:`~testfixtures.logcapture.CaptureSource`. """ #: The raw object delivered by the logging framework. This is a :class:`~logging.LogRecord` #: for standard library logging. raw: Any #: The extracted value used by :meth:`~testfixtures.LogCapture.check` and related methods. #: Its structure is determined by the ``attributes`` parameter of the source. actual: Any #: Numeric log level used by :meth:`~testfixtures.LogCapture.ensure_checked`, or ``None`` #: if the source does not provide a comparable numeric level. level: int | None #: The exception associated with this entry if one was captured, otherwise ``None``. exception: BaseException | None = None #: Whether this entry has been marked as checked by a :meth:`~testfixtures.LogCapture.check` #: call. checked: bool = False LogRecordAttributes: TypeAlias = AttributeSpec[LogRecord] class LogCapture: """ Captures log entries from one or more sources and provides methods to check what was logged. :param sources: One or more :class:`capture sources ` :param install: If ``True`` (the default), capturing starts immediately on construction. If ``False``, installation is deferred until :meth:`install` is called. :param recursive_check: If ``True``, entries are compared recursively by :meth:`check`. :param ensure_checks_above: The log level above which entries must have been checked. See :meth:`ensure_checked`. For compatibility with earlier versions, capturing only standard library :mod:`logging` is supported by instantiating using these parameters: :param names: A string (or tuple of strings) containing the dotted name(s) of loggers to capture. By default, the root logger is captured. :param level: The minimum log level to capture. Defaults to ``1``, capturing everything. :param propagate: If specified, any captured loggers will have their propagate attribute set to the supplied value. This can be used to prevent propagation from a child logger to a parent logger that has configured handlers. :param attributes: The sequence of attributes to return for each record or a callable that extracts :attr:`~testfixtures.logcapture.Entry.actual` from a record. If a sequence of attribute names is passed, for each item, an attribute of that name will be obtained from the :class:`logging.LogRecord`. If the attribute is callable, the result of calling it will be used as the attribute value. If an attribute is missing, ``None`` will be used in its place. If a callable is passed, it will be called with the :class:`~logging.LogRecord` and the value returned will be used as :attr:`~testfixtures.logcapture.Entry.actual`. """ #: The list of :class:`entries ` captured so far. entries: list[Entry] @property def records(self) -> List[LogRecord]: """ The records captured by this :class:`LogCapture`. .. deprecated:: 12 Use the :attr:`entries` attribute instead. """ return [e.raw for e in self.entries if isinstance(e.raw, LogRecord)] instances: set['LogCapture'] = set() atexit_setup = False installed = False ensure_checks_above: int | None #: Class-level default for :meth:`ensure_checked`. Set this to apply a level #: threshold to all :class:`LogCapture` instances that do not override it explicitly. default_ensure_checks_above: int | None = None @overload def __init__( self, *sources: CaptureSource, install: bool = True, recursive_check: bool = False, ensure_checks_above: int | None = None, ) -> None: ... @overload def __init__( self, names: str | Tuple[str | None, ...] | None = None, *, install: bool = True, level: int = 1, propagate: bool | None = None, attributes: LogRecordAttributes = ..., recursive_check: bool = False, ensure_checks_above: int | None = None, ) -> None: ... def __init__( # type: ignore[misc] self, *args: Any, install: bool = True, level: int = 1, propagate: bool | None = None, attributes: LogRecordAttributes = ( 'name', 'levelname', 'getMessage', ), recursive_check: bool = False, ensure_checks_above: int | None = None, ) -> None: if args and hasattr(args[0], 'install'): self._sources = list(args) else: names: str | Tuple[str | None, ...] | None = args[0] if args else None if not isinstance(names, tuple): names = (names,) self._sources = [LoggingSource(attributes, level, names=names, propagate=propagate)] self.recursive_check = recursive_check self._disabled = False if ensure_checks_above is None: self.ensure_checks_above = self.default_ensure_checks_above else: self.ensure_checks_above = ensure_checks_above self.clear() if install: self.install() @classmethod def atexit(cls) -> None: if cls.instances: warnings.warn( 'LogCapture instances not uninstalled by shutdown, ' 'sources captured:\n' '%s' % ('\n'.join((', '.join(repr(s) for s in i._sources) for i in cls.instances))) ) def __len__(self) -> int: return len(self.entries) def __getitem__(self, index: int) -> Any: return self.entries[index].actual def __contains__(self, what: Any) -> bool: for i, entry in enumerate(self.entries): if what == entry.actual: self.entries[i].checked = True return True return False def clear(self) -> None: """Clear any entries that have been captured.""" self.entries: list[Entry] = [] def mark_all_checked(self) -> None: """ Mark all captured events as checked. This should be called if you have made assertions about logging other than through :class:`LogCapture` methods. """ for entry in self.entries: entry.checked = True def ensure_checked(self, level: int | None = None) -> None: """ Ensure every entry logged above the specified `level` has been checked. Raises an :class:`AssertionError` if this is not the case. :param level: This defaults to the level passed during :class:`LogCapture` instantiation. """ threshold: int | None = level if level is not None else self.ensure_checks_above if threshold is None: return un_checked = [] for entry in self.entries: if entry.level is not None and entry.level >= threshold and not entry.checked: un_checked.append(entry.actual) if un_checked: raise AssertionError(('Not asserted ERROR log(s): %s') % (pformat(un_checked))) def _collect_entry(self, entry: Entry) -> None: self.entries.append(entry) @contextmanager def disabled(self) -> Iterator[None]: """ A context manager that stops capturing for the duration of the ``with`` block by uninstalling the capture and reinstalling it on exit. Anything logged while it is active is handled as if the capture were not present, which is useful for ignoring noisy setup in a test. """ self.uninstall() try: yield finally: self.install() def install(self) -> Self | None: """ Install this :class:`LogCapture`, enabling all configured sources to begin capturing. """ for source in self._sources: source.install(self._collect_entry) self.instances.add(self) if not self.__class__.atexit_setup: atexit.register(self.atexit) self.__class__.atexit_setup = True return None def uninstall(self) -> None: """ Uninstall this :class:`LogCapture`, restoring all sources to their previous state. """ if self in self.instances: for source in self._sources: source.uninstall() self.instances.remove(self) @classmethod def uninstall_all(cls) -> None: "This will uninstall all existing :class:`LogCapture` objects." for i in tuple(cls.instances): i.uninstall() def actual(self) -> list[Any]: """ The sequence of :attr:`~testfixtures.logcapture.Entry.actual` items captured. This can be useful for making more complex assertions about captured logging. The full :class:`~testfixtures.logcapture.Entry` objects captured for each logging call can be accessed through :attr:`entries`. """ return [e.actual for e in self.entries] def __str__(self) -> str: if not self.entries: return 'No logging captured' tuples = (r if isinstance(r, tuple) else (r,) for r in self.actual()) return '\n'.join(' '.join(str(e) for e in t) for t in tuples) def check( self, *expected: Any, order_matters: bool = True, raises: bool = True, level: int | None = None, predicate: Callable[[Entry], bool] | None = None, ) -> str | None: """ This will compare the captured entries with the expected entries provided and raise an :class:`AssertionError` if they do not match. :param expected: A sequence of expected :attr:`~testfixtures.logcapture.Entry.actual` items. :param order_matters: A keyword-only parameter that controls whether the order of the captured entries is required to match those of the expected entries. Defaults to ``True``. :param raises: If ``False``, the message that would be raised in the :class:`AssertionError` will be returned instead of the exception being raised. :param level: A keyword-only parameter that, if provided, excludes any captured entry with a numeric :attr:`~testfixtures.logcapture.Entry.level` below it from the comparison. Entries with a ``level`` of ``None`` are always included. :param predicate: A keyword-only parameter that, if provided, is called with each captured :class:`~testfixtures.logcapture.Entry`; only those for which it returns a true value are included in the comparison. """ __tracebackhide__ = True actual = [] for entry in self.entries: if level is not None and entry.level is not None and entry.level < level: continue if predicate is not None and not predicate(entry): continue entry.checked = True actual.append(entry.actual) result = None if order_matters: result = compare( expected, actual=actual, recursive=self.recursive_check, raises=False ) else: expected_ = SequenceComparison( *expected, ordered=False, partial=False, recursive=self.recursive_check ) if expected_ != actual: result = expected_.failed if result and raises: raise AssertionError(result) return result def check_empty( self, level: int | None = None, predicate: Callable[[Entry], bool] | None = None, ) -> None: """ Assert that nothing was logged, raising an :class:`AssertionError` if it was. This is a clearer way of writing :meth:`check` with no expected entries. :param level: If provided, only entries with a numeric :attr:`~testfixtures.logcapture.Entry.level` at or above it are considered. Entries with a ``level`` of ``None`` are always considered. :param predicate: If provided, it is called with each captured :class:`~testfixtures.logcapture.Entry`; only those for which it returns a true value are considered. """ __tracebackhide__ = True self.check(level=level, predicate=predicate) def raise_first_exception(self, start_index: int = 0) -> None: """ Raise the first captured exception from ``start_index`` onwards. :param start_index: The index into :attr:`entries` from where to start looking. """ for entry in self.entries[start_index:]: if entry.exception is not None: raise entry.exception def check_exception_str(self, expected: str, index: int = -1) -> None: """ Check the string representation of a captured exception. :param expected: The expected string. :param index: The index into :attr:`entries` where the exception should have been captured. """ compare(expected, actual=str(self.entries[index].exception)) def check_present( self, *expected: Any, order_matters: bool = True, raises: bool = True ) -> str | None: """ This will check if the captured attr:`entries` contain all of the expected entries provided and raise an :class:`AssertionError` if not. This will ignore entries that have been captured but that do not match those in ``expected``. :param expected: A sequence of expected :attr:`~testfixtures.logcapture.Entry.actual` items. :param order_matters: A keyword-only parameter that controls whether the order of the captured entries is required to match those of the expected entries. Defaults to ``True``. :param raises: If ``False``, the message that would be raised in the :class:`AssertionError` will be returned instead of the exception being raised. """ __tracebackhide__ = True actual = self.actual() expected_ = SequenceComparison( *expected, ordered=order_matters, partial=True, recursive=self.recursive_check ) if expected_ != actual: if raises: raise AssertionError(expected_.failed) return expected_.failed for index in expected_.checked_indices: self.entries[index].checked = True return None def __enter__(self) -> Self: return self def __exit__( self, exc_type: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None, ) -> None: self.uninstall() self.ensure_checked() class LogCaptureForDecorator(LogCapture): def install(self) -> Self: LogCapture.install(self) self.clear() return self def log_capture(*names: str, **kw: Any) -> Callable: """ A decorator for making a :class:`LogCapture` installed and available for the duration of a test function. :param names: An optional sequence of names specifying the loggers to be captured. If not specified, the root logger will be captured. Keyword parameters other than ``install`` may also be supplied and will be passed on to the :class:`LogCapture` constructor. """ l = LogCaptureForDecorator(names or None, install=False, **kw) return wrap(l.install, l.uninstall) class LoggingHandler(logging.Handler): """Minimal handler that forwards records to a :class:`LoggingSource`.""" def __init__(self, source: 'LoggingSource') -> None: super().__init__() self._source = source def __bool__(self) -> bool: # Some logging internals check boolean rather than identity for handlers :-( return True def emit(self, record: LogRecord) -> None: self._source._handle_record(record) def close(self) -> None: super().close() self._source._handler_closed() class LoggingSource: """ A :class:`~testfixtures.logcapture.CaptureSource` for the standard-library :mod:`logging` framework, for use with :class:`~testfixtures.LogCapture`. :param attributes: The sequence of attributes to return for each record or a callable that extracts :attr:`~testfixtures.logcapture.Entry.actual` from a record. If a sequence of attribute names is passed, for each item, an attribute of that name will be obtained from the :class:`logging.LogRecord`. If the attribute is callable, the result of calling it will be used as the attribute value. If an attribute is missing, ``None`` will be used in its place. If a callable is passed, it will be called with the :class:`~logging.LogRecord` and the value returned will be used as :attr:`~testfixtures.logcapture.Entry.actual`. :param level: The minimum log level to capture. Defaults to ``1``, capturing everything. :param names: A string (or tuple of strings) containing the dotted name(s) of loggers to capture. By default, the root logger is captured. :param propagate: If specified, any captured loggers will have their propagate attribute set to the supplied value. This can be used to prevent propagation from a child logger to a parent logger that has configured handlers. """ def __init__( self, attributes: LogRecordAttributes = ('levelname', 'getMessage'), level: int = 1, *, names: Tuple[str | None, ...] = (None,), propagate: bool | None = None, ) -> None: self.attributes = attributes self.level = level self.names = names self.propagate = propagate self.old: dict[str, dict[str | None, Any]] = defaultdict(dict) self._collector: Callable[[Entry], None] | None = None self._handler: LoggingHandler | None = None self._compute_actual = build_actual_extractor(attributes, self.extract_field) def extract_field(self, raw: LogRecord, attribute: str) -> Any: value = getattr(raw, attribute, None) if callable(value): value = value() return value def __repr__(self) -> str: return f'LoggingSource({self.names!r})' def _handler_closed(self) -> None: if self._collector is not None: warn( f'LoggingSource closed while still capturing loggers: {self.names!r}\n' 'Call uninstall() or use LogCapture as a context manager.' ) def _handle_record(self, record: LogRecord) -> None: if self._collector is None: return exception = record.exc_info[1] if record.exc_info else None self._collector(Entry( raw=record, actual=self._compute_actual(record), level=record.levelno, exception=exception, )) def install(self, collector: Callable[[Entry], None]) -> None: self._collector = collector self._handler = LoggingHandler(self) for name in self.names: logger = logging.getLogger(name) self.old['levels'][name] = logger.level self.old['filters'][name] = logger.filters self.old['handlers'][name] = logger.handlers self.old['disabled'][name] = logger.disabled self.old['propagate'][name] = logger.propagate logger.setLevel(self.level) logger.filters = [] logger.handlers = [self._handler] logger.disabled = False if self.propagate is not None: logger.propagate = self.propagate def uninstall(self) -> None: for name in self.names: logger = logging.getLogger(name) logger.setLevel(self.old['levels'][name]) logger.filters = self.old['filters'][name] logger.handlers = self.old['handlers'][name] logger.disabled = self.old['disabled'][name] logger.propagate = self.old['propagate'][name] self._handler = None self._collector = None testfixtures-12.4.0/src/testfixtures/loguru.py000066400000000000000000000101661525773641100216150ustar00rootroot00000000000000""" Tools for helping to test applications that use Loguru. """ from typing import TYPE_CHECKING, Callable, Any, TypeAlias from warnings import warn from loguru import logger from .logcapture import Entry, build_actual_extractor, AttributeSpec if TYPE_CHECKING: from loguru import Record, Message else: Record = dict RecordAttributes: TypeAlias = AttributeSpec[Record] def level_name(record: Record) -> str: """ Extract level name from the logged record :param record: A loguru :ref:`record dict `. """ return record['level'].name class LoguruSource: """ A :class:`~testfixtures.logcapture.CaptureSource` for `loguru `_ logging, for use with :class:`~testfixtures.LogCapture`. On :meth:`~testfixtures.logcapture.CaptureSource.install` all existing loguru handlers are removed and replaced with a single capture handler. On :meth:`~testfixtures.logcapture.CaptureSource.uninstall`, the original handlers are restored. :param attributes: The sequence of attributes to return for each :ref:`record ` or a callable that extracts :attr:`~testfixtures.logcapture.Entry.actual` from a record. If a sequence of attribute names is passed, for each item, a key of that name will be obtained from the :ref:`record dict `. If the key is missing, ``None`` will be used in its place. If a callable is passed, it will be called with the :ref:`record ` and the value returned will be used as :attr:`~testfixtures.logcapture.Entry.actual`. :param level: Minimum log level to capture. Defaults to ``0``, capture everything. Additional keyword arguments are forwarded to :meth:`logger.add `. """ def __init__( self, attributes: RecordAttributes = (level_name, 'message'), level: int | str = 0, **kw: Any, ) -> None: self.attributes = attributes self._kw: dict[str, Any] = {'level': level, 'colorize': False, 'catch': False, **kw} self._collector: Callable[[Entry], None] | None = None self._id: int | None = None self._original_handlers: dict | None = None self._original_min_level: int | None = None self._compute_actual = build_actual_extractor(attributes, self.extract_field) def extract_field(self, raw: Record, attribute: str) -> Any: return raw.get(attribute) def write(self, message: 'Message') -> None: if self._collector is not None: record = message.record exc_info = record['exception'] entry = Entry( raw=record, actual=self._compute_actual(record), level=record['level'].no, exception=exc_info.value if exc_info is not None else None, ) self._collector(entry) def stop(self) -> None: if self._collector is not None: warn( 'LoguruSource left installed at shutdown.\n' 'Call uninstall() or use LogCapture as a context manager.' ) def __repr__(self) -> str: return 'LoguruSource()' def install(self, collector: Callable[[Entry], None]) -> None: core = logger._core # type: ignore[attr-defined] self._collector = collector self._original_handlers = dict(core.handlers) self._original_min_level = core.min_level core.handlers = {} # hide existing sinks, don't stop them self._id = logger.add(self, format="{message}", **self._kw) def uninstall(self) -> None: if self._original_handlers is not None: self._collector = None # suppress the shutdown warning from stop() core = logger._core # type: ignore[attr-defined] if self._id in core.handlers: logger.remove(self._id) # stop only the capture handler core.min_level = self._original_min_level core.handlers = self._original_handlers # restore live sinks self._original_handlers = None self._id = None testfixtures-12.4.0/src/testfixtures/mock.py000066400000000000000000000032721525773641100212310ustar00rootroot00000000000000""" A facade for either :mod:`unittest.mock` or its `rolling backport`__, if it is installed, with a preference for the latter as it may well have newer functionality and bugfixes. The facade also contains any bugfixes that are critical to the operation of functionality provided by testfixtures. __ https://mock.readthedocs.io """ import sys backport_version: tuple[int, int, int] | None try: from mock import * from mock.mock import _Call, _Sentinel from mock.mock import call as mock_call from mock import version_info as backport_version except ImportError: backport_version = None class MockCall: pass mock_call = MockCall() # type: ignore[assignment] from unittest.mock import * # type: ignore[assignment] from unittest.mock import _Call, _Sentinel # type: ignore[assignment] has_backport = backport_version is not None # Explicit re-export list for mypy's no_implicit_reexport __all__ = [ 'Mock', 'MagicMock', 'patch', 'sentinel', 'DEFAULT', 'ANY', 'call', 'create_autospec', 'AsyncMock', 'ThreadingMock', 'FILTER_DIR', 'NonCallableMock', 'NonCallableMagicMock', 'mock_open', 'PropertyMock', 'seal', '_Call', '_Sentinel', 'mock_call', 'backport_version', 'has_backport', 'parent_name', ] if not ( (has_backport and backport_version[:3] > (2, 0, 0)) or # type: ignore[index] (sys.version_info < (3, 0, 0) and not has_backport) or (3, 6, 7) < sys.version_info[:3] < (3, 7, 0) or sys.version_info[:3] > (3, 7, 1) ): # pragma: no cover raise ImportError('Please upgrade Python (you have {}) or Mock Backport (You have {})'.format( sys.version_info, backport_version )) parent_name = '_mock_parent' testfixtures-12.4.0/src/testfixtures/numpy.py000066400000000000000000000043171525773641100214510ustar00rootroot00000000000000""" Tools for helping to test applications that use NumPy. """ from typing import TYPE_CHECKING import numpy as numpy from numpy import ndarray from numpy.ma import MaskedArray, getmaskarray from numpy.testing import assert_allclose, assert_array_equal if TYPE_CHECKING: from .comparing import CompareContext INEXACT_KINDS = 'fc' # The tolerances used by the pandas and polars frame comparers, rather than # assert_allclose's tighter defaults, so float semantics agree across all three: RTOL = 1e-5 ATOL = 1e-8 def compare_ndarray(x: ndarray, y: ndarray, context: 'CompareContext') -> str | None: """ Returns a textual description of the differences between two :class:`numpy.ndarray` instances, as reported by :func:`numpy.testing.assert_allclose` for float and complex arrays or :func:`numpy.testing.assert_array_equal` for arrays of any other :class:`dtype `. Shape and dtype must always match: there is no broadcasting and an ``int32`` array is never equal to an ``int64`` one. NaNs in matching positions compare equal. When ``strict=True`` is passed to :func:`~testfixtures.compare`, float and complex values must be exactly equal; otherwise ``rtol=1e-5`` and ``atol=1e-8`` apply, matching the pandas and polars comparers. """ try: if x.dtype.kind in INEXACT_KINDS and not context.strict: assert_allclose(y, x, rtol=RTOL, atol=ATOL, strict=True) else: assert_array_equal(y, x, strict=True) except AssertionError as e: return str(e) return None def compare_masked_array(x: MaskedArray, y: MaskedArray, context: 'CompareContext') -> str | None: """ Returns a textual description of the differences between two :class:`numpy.ma.MaskedArray` instances. Masks are part of the data, so they must match exactly regardless of other options; data under masked positions is ignored. The unmasked data is then compared as described for :func:`~testfixtures.numpy.compare_ndarray`. """ masks = compare_ndarray(getmaskarray(x), getmaskarray(y), context) if masks is not None: return 'masks differ: ' + masks return compare_ndarray(x.compressed(), y.compressed(), context) testfixtures-12.4.0/src/testfixtures/outputcapture.py000066400000000000000000000127741525773641100232330ustar00rootroot00000000000000import os import sys from io import StringIO from tempfile import TemporaryFile from typing import Self, Any, IO from .comparing import compare from .comparison import TextComparison class OutputCapture: """ A context manager for capturing output to the :any:`sys.stdout` and :any:`sys.stderr` streams. :param separate: If ``True``, ``stdout`` and ``stderr`` will be captured separately and their expected values must be passed to :meth:`~OutputCapture.compare`. :param fd: If ``True``, the underlying file descriptors will be captured, rather than just the attributes on :mod:`sys`. This allows you to capture things like subprocesses that write directly to the file descriptors, but is more invasive, so only use it when you need it. :param strip_whitespace: When ``True``, which is the default, leading and training whitespace is trimmed from both the expected and actual values when comparing. .. note:: If ``separate`` is passed as ``True``, :attr:`OutputCapture.captured` will be an empty string. """ output: IO stdout: IO stderr: IO original_stdout: IO[str] | int | None = None original_stderr: IO[str] | int | None = None def __init__(self, separate: bool = False, fd: bool = False, strip_whitespace: bool = True): self.separate = separate self.fd = fd self.strip_whitespace = strip_whitespace def __enter__(self) -> Self: if self.fd: self.output = TemporaryFile() self.stdout = TemporaryFile() self.stderr = TemporaryFile() else: self.output = StringIO() self.stdout = StringIO() self.stderr = StringIO() self.enable() return self def __exit__(self, *args: Any) -> None: self.disable() def disable(self) -> None: "Disable the output capture if it is enabled." if self.fd: for original, current in ( (self.original_stdout, sys.stdout), (self.original_stderr, sys.stderr), ): os.dup2(original, current.fileno()) # type: ignore[arg-type] os.close(original) # type: ignore[arg-type] else: sys.stdout = self.original_stdout sys.stderr = self.original_stderr def enable(self) -> None: "Enable the output capture if it is disabled." if self.original_stdout is None: if self.fd: self.original_stdout = os.dup(sys.stdout.fileno()) self.original_stderr = os.dup(sys.stderr.fileno()) else: self.original_stdout = sys.stdout self.original_stderr = sys.stderr if self.separate: if self.fd: os.dup2(self.stdout.fileno(), sys.stdout.fileno()) os.dup2(self.stderr.fileno(), sys.stderr.fileno()) else: sys.stdout = self.stdout sys.stderr = self.stderr else: if self.fd: os.dup2(self.output.fileno(), sys.stdout.fileno()) os.dup2(self.output.fileno(), sys.stderr.fileno()) else: sys.stdout = sys.stderr = self.output def _read(self, stream: IO | StringIO) -> str: if self.fd: stream.seek(0) return stream.read().decode() # type: ignore[union-attr] else: return stream.getvalue() # type: ignore[union-attr] @property def captured(self) -> str: "A property containing any output that has been captured so far." return self._read(self.output) def compare( self, expected: str | TextComparison = '', stdout: str | TextComparison = '', stderr: str | TextComparison = '', raises: bool = True, ) -> str | None: """ Compare the captured output to that expected. If the output is not the same, an :class:`AssertionError` will be raised. :param expected: A string containing the expected combined output of ``stdout`` and ``stderr``. :param stdout: A string containing the expected output to ``stdout``. :param stderr: A string containing the expected output to ``stderr``. :param raises: If ``False``, the message that would be raised in the :class:`AssertionError` will be returned instead of the exception being raised. """ __tracebackhide__ = True expected_mapping = {} actual_mapping = {} for prefix, _expected, captured in ( ('captured', expected, self.captured), ('stdout', stdout, self._read(self.stdout)), ('stderr', stderr, self._read(self.stderr)), ): if self.strip_whitespace: if isinstance(_expected, str): _expected = _expected.strip() captured = captured.strip() if _expected != captured: expected_mapping[prefix] = _expected actual_mapping[prefix] = captured if len(expected_mapping) == 1: return compare(expected=tuple(expected_mapping.values())[0], actual=tuple(actual_mapping.values())[0], raises=raises) return compare(expected=expected_mapping, actual=actual_mapping, raises=raises) testfixtures-12.4.0/src/testfixtures/pandas.py000066400000000000000000000015021525773641100215400ustar00rootroot00000000000000""" Tools for helping to test applications that use Pandas. """ from typing import TYPE_CHECKING import pandas as pandas from pandas import DataFrame from pandas.testing import assert_frame_equal if TYPE_CHECKING: from .comparing import CompareContext def compare_dataframe( x: DataFrame, y: DataFrame, context: 'CompareContext' ) -> str | None: """ Returns a textual description of the differences between two :class:`pandas.DataFrame` instances, as reported by :func:`pandas.testing.assert_frame_equal`. When ``strict=True`` is passed to :func:`~testfixtures.compare`, ``check_exact=True`` is used; otherwise pandas' default tolerances apply. """ try: assert_frame_equal(x, y, check_exact=context.strict) except AssertionError as e: return str(e) return None testfixtures-12.4.0/src/testfixtures/polars.py000066400000000000000000000015021525773641100215720ustar00rootroot00000000000000""" Tools for helping to test applications that use Polars. """ from typing import TYPE_CHECKING import polars as polars from polars import DataFrame from polars.testing import assert_frame_equal if TYPE_CHECKING: from .comparing import CompareContext def compare_dataframe( x: DataFrame, y: DataFrame, context: 'CompareContext' ) -> str | None: """ Returns a textual description of the differences between two :class:`polars.DataFrame` instances, as reported by :func:`polars.testing.assert_frame_equal`. When ``strict=True`` is passed to :func:`~testfixtures.compare`, ``check_exact=True`` is used; otherwise polars' default tolerances apply. """ try: assert_frame_equal(x, y, check_exact=context.strict) except AssertionError as e: return str(e) return None testfixtures-12.4.0/src/testfixtures/popen.py000066400000000000000000000304361525773641100214230ustar00rootroot00000000000000import shlex from functools import wraps, partial, reduce from io import TextIOWrapper from itertools import chain, zip_longest from os import PathLike from subprocess import STDOUT, PIPE from tempfile import TemporaryFile from types import TracebackType from typing import ( Callable, List, Sequence, Tuple, Iterable, TextIO, TypeAlias, ParamSpec, TypeVar, Concatenate, IO, Any, Mapping, Collection, Literal, Self, Protocol ) from .mock import Mock, call, _Call as Call from .utils import extend_docstring StrOrBytesPath: TypeAlias = str | bytes | PathLike Command: TypeAlias = StrOrBytesPath | Sequence[StrOrBytesPath] File : TypeAlias = None | int | IO[Any] def shell_join(command: Command) -> str: if isinstance(command, str): return command elif isinstance(command, bytes): return command.decode() elif isinstance(command, PathLike): return str(command) elif isinstance(command, Iterable): quoted_parts = [] for part in command: if isinstance(part, str): pass elif isinstance(part, bytes): part = part.decode() elif isinstance(part, PathLike): part = str(part) elif not isinstance(part, (str, bytes)): raise TypeError(f'{part!r} in {command!r} was {type(part)}, must be str') quoted_parts.append(shlex.quote(part)) return " ".join(quoted_parts) else: raise TypeError(f'{command!r} was {type(command)}, must be str') class PopenBehaviour: """ An object representing the behaviour of a :class:`MockPopen` when simulating a particular command. """ def __init__( self, stdout: bytes = b'', stderr: bytes = b'', returncode: int = 0, pid: int = 1234, poll_count: int = 3 ): self.stdout = stdout self.stderr = stderr self.returncode = returncode self.pid = pid self.poll_count = poll_count class CallableBehaviour(Protocol): def __call__(self, command: str, stdin: File) -> PopenBehaviour: ... P = ParamSpec('P') R = TypeVar('R') def record( func: Callable[Concatenate['MockPopenInstance', P], R] ) -> Callable[Concatenate['MockPopenInstance', P], R]: @wraps(func) def recorder(self: 'MockPopenInstance', *args: P.args, **kw: P.kwargs) -> R: self._record((func.__name__,), *args, **kw) return func(self, *args, **kw) return recorder class MockPopenInstance: """ A mock process as returned by :class:`MockPopen`. """ #: A :class:`~unittest.mock.Mock` representing the pipe into this process. #: This is only set if ``stdin=PIPE`` is passed the constructor. #: The mock records writes and closes in :attr:`MockPopen.all_calls`. stdin: Mock | None = None #: A file representing standard output from this process. stdout: TextIO | None = None #: A file representing error output from this process. stderr: TextIO | None = None # These are not types as instantiation of this class is an internal implementation detail. def __init__( self, mock_class: 'MockPopen', root_call: Call, args: Command, bufsize: int = 0, executable: StrOrBytesPath | None = None, stdin: File = None, stdout: File = None, stderr: File = None, preexec_fn: Callable[[], Any] | None = None, close_fds: bool = False, shell: bool = False, cwd: StrOrBytesPath | None = None, env: Mapping[str, str] | None = None, universal_newlines: bool = False, startupinfo: Any = None, creationflags: int = 0, restore_signals: bool = True, start_new_session: bool = False, pass_fds: Collection[int] = (), *, encoding: str | None = None, errors: str | None = None, text: bool | None = None, ) -> None: self.mock: Mock = Mock() self.class_instance_mock: Mock = mock_class.mock.Popen_instance #: A :func:`unittest.mock.call` representing the call made to instantiate #: this mock process. self.root_call: Call = root_call #: The calls made on this mock process, represented using #: :func:`~unittest.mock.call` instances. self.calls: List[Call] = [] self.all_calls: List[Call] = mock_class.all_calls cmd = shell_join(args) behaviour: Any behaviour = mock_class.commands.get(cmd, mock_class.default_behaviour) if behaviour is None: raise KeyError('Nothing specified for command %r' % cmd) if callable(behaviour): behaviour = behaviour(command=cmd, stdin=stdin) self.behaviour: PopenBehaviour = behaviour stdout_value = behaviour.stdout stderr_value = behaviour.stderr if stderr == STDOUT: line_iterator = chain.from_iterable(zip_longest( stdout_value.splitlines(True), stderr_value.splitlines(True) )) stdout_value = b''.join(l for l in line_iterator if l) stderr_value = None self.poll_count: int = behaviour.poll_count for name, option, mock_value in ( ('stdout', stdout, stdout_value), ('stderr', stderr, stderr_value) ): value: Any = None if option is PIPE: value = TemporaryFile() value.write(mock_value) value.flush() value.seek(0) if universal_newlines or text or encoding: value = TextIOWrapper(value, encoding=encoding, errors=errors) setattr(self, name, value) if stdin == PIPE: self.stdin = Mock() for method in 'write', 'close': record_writes = partial(self._record, ('stdin', method)) getattr(self.stdin, method).side_effect = record_writes self.pid: int = behaviour.pid #: The return code of this mock process. self.returncode: int | None = None self.args: Command = args def _record(self, names: Sequence[str], *args: Any, **kw: Any) -> None: for mock in self.class_instance_mock, self.mock: reduce(getattr, names, mock)(*args, **kw) for base_call, store in ( (call, self.calls), (self.root_call, self.all_calls) ): store.append(reduce(getattr, names, base_call)(*args, **kw)) def __enter__(self) -> Self: return self def __exit__( self, exc_type: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None, ) -> None: self.wait() for stream in self.stdout, self.stderr: if stream: stream.close() @record def wait(self, timeout: float | None = None) -> int: "Simulate calls to :meth:`subprocess.Popen.wait`" self.returncode = self.behaviour.returncode return self.returncode @record def communicate( self, input: str | bytes | None = None, timeout: float | None = None ) -> Tuple[str | bytes | None, str | bytes | None]: "Simulate calls to :meth:`subprocess.Popen.communicate`" self.returncode = self.behaviour.returncode stdout = None if self.stdout is None else self.stdout.read() stderr = None if self.stderr is None else self.stderr.read() return stdout, stderr @record def poll(self) -> int | None: "Simulate calls to :meth:`subprocess.Popen.poll`" while self.poll_count and self.returncode is None: self.poll_count -= 1 return None # This call to wait() is NOT how poll() behaves in reality. # poll() NEVER sets the returncode. # The returncode is *only* ever set by process completion. # The following is an artifact of the fixture's implementation. self.returncode = self.behaviour.returncode return self.returncode @record def send_signal(self, signal: int) -> None: "Simulate calls to :meth:`subprocess.Popen.send_signal`" pass @record def terminate(self) -> None: "Simulate calls to :meth:`subprocess.Popen.terminate`" pass @record def kill(self) -> None: "Simulate calls to :meth:`subprocess.Popen.kill`" pass class MockPopen: """ A specialised mock for testing use of :class:`subprocess.Popen`. An instance of this class can be used in place of the :class:`subprocess.Popen` and is often inserted where it's needed using :func:`unittest.mock.patch` or a :class:`~testfixtures.Replacer`. """ default_behaviour: PopenBehaviour | CallableBehaviour | None = None def __init__(self) -> None: self.commands: dict[str, PopenBehaviour | CallableBehaviour] = {} self.mock: Mock = Mock() #: All calls made using this mock and the objects it returns, represented using #: :func:`~unittest.mock.call` instances. self.all_calls: List[Call] = [] def _resolve_behaviour( self, stdout: bytes, stderr: bytes, returncode: int, pid: int, poll_count: int, behaviour: PopenBehaviour | CallableBehaviour | None ) -> PopenBehaviour | CallableBehaviour: if behaviour is None: return PopenBehaviour( stdout, stderr, returncode, pid, poll_count ) else: return behaviour def set_command( self, command: str, stdout: bytes = b'', stderr: bytes = b'', returncode: int = 0, pid: int = 1234, poll_count: int = 3, behaviour: PopenBehaviour | CallableBehaviour | None = None ) -> None: """ Set the behaviour of this mock when it is used to simulate the specified command. :param command: A :class:`str` representing the command to be simulated. """ self.commands[shell_join(command)] = self._resolve_behaviour( stdout, stderr, returncode, pid, poll_count, behaviour ) def set_default( self, stdout: bytes = b'', stderr: bytes = b'', returncode: int =0, pid: int = 1234, poll_count: int = 3, behaviour: PopenBehaviour | CallableBehaviour | None = None ) -> None: """ Set the behaviour of this mock when it is used to simulate commands that have no explicit behavior specified using :meth:`~MockPopen.set_command`. """ self.default_behaviour = self._resolve_behaviour( stdout, stderr, returncode, pid, poll_count, behaviour ) def __call__(self, *args: Any, **kw: Any) -> MockPopenInstance: self.mock.Popen(*args, **kw) root_call = call.Popen(*args, **kw) self.all_calls.append(root_call) return MockPopenInstance(self, root_call, *args, **kw) set_command_params = """ :param stdout: :class:`bytes` representing the simulated content written by the process to the stdout pipe. :param stderr: :class:`bytes` representing the simulated content written by the process to the stderr pipe. :param returncode: An integer representing the return code of the simulated process. :param pid: An integer representing the process identifier of the simulated process. This is useful if you have code the prints out the pids of running processes. :param poll_count: Specifies the number of times :meth:`~MockPopenInstance.poll` can be called before :attr:`~MockPopenInstance.returncode` is set and returned by :meth:`~MockPopenInstance.poll`. If supplied, ``behaviour`` must be either a :class:`PopenBehaviour` instance or a callable that takes the ``command`` string representing the command to be simulated and the ``stdin`` supplied when instantiating the :class:`subprocess.Popen` with that command and should return a :class:`PopenBehaviour` instance. """ # add the param docs, so we only have one copy of them! extend_docstring(set_command_params, [MockPopen.set_command, MockPopen.set_default]) testfixtures-12.4.0/src/testfixtures/py.typed000066400000000000000000000000001525773641100214070ustar00rootroot00000000000000testfixtures-12.4.0/src/testfixtures/pydantic.py000066400000000000000000000015641525773641100221150ustar00rootroot00000000000000""" Tools for helping to test applications that use Pydantic. """ from typing import TYPE_CHECKING import pydantic as pydantic from pydantic import BaseModel from .comparers import _compare_mapping, compare_simple if TYPE_CHECKING: from .comparing import CompareContext def compare_basemodel( x: BaseModel, y: BaseModel, context: 'CompareContext', ) -> str | None: """ Returns an informative string describing the differences between the two supplied Pydantic :class:`~pydantic.BaseModel` instances, based on their declared fields. """ if type(x) is not type(y): return compare_simple(x, y, context) x_attrs = x.__dict__.copy() y_attrs = y.__dict__.copy() if not context.qualified_equals(x_attrs, y_attrs): return _compare_mapping(x_attrs, y_attrs, context, x, 'attributes ', '.%s') return None testfixtures-12.4.0/src/testfixtures/replace.py000066400000000000000000000351611525773641100217150ustar00rootroot00000000000000import os import warnings from contextlib import contextmanager from functools import partial from gc import get_referrers, get_referents from operator import setitem, getitem from types import ModuleType, MethodType, TracebackType from typing import Any, TypeVar, Callable, Tuple, Generic, Self, Iterator from testfixtures import not_there from testfixtures.utils import wrap, extend_docstring from .resolve import resolve, Resolved, classmethod_type, class_type, Key Accessor = Callable[[Any, str], Any] def not_same_descriptor(x: Any, y: Any, descriptor: type[classmethod] | type[staticmethod]) -> bool: return isinstance(x, descriptor) and not isinstance(y, descriptor) R = TypeVar('R') class Replacer: """ These are used to manage the mocking out of objects so that units of code can be tested without having to rely on their normal dependencies. """ def __init__(self) -> None: self.originals: dict[Key, Tuple[Any, Resolved]] = {} def _replace(self, resolved: Resolved, value: Any) -> None: if value is not_there: if resolved.setter is setattr: try: delattr(resolved.container, resolved.name) except AttributeError: pass if resolved.setter is setitem: try: del resolved.container[resolved.name] except KeyError: pass elif resolved.setter is not None: resolved.setter(resolved.container, resolved.name, value) def __call__( self, target: Any, replacement: R, strict: bool = True, container: Any | None = None, accessor: Accessor | None = None, name: str | None = None, sep: str = '.', ) -> R: """ Replace the specified target with the supplied replacement. """ if name is None and accessor is not None: raise TypeError('accessor is not used unless name is specified') if isinstance(target, str) and not name: resolved = resolve(target, container, sep) else: if container is None: container = target name = name or getattr(target, '__name__', None) if name is None: raise TypeError('name must be specified when target is not a string') else: if accessor is None: try: accessor = getitem found = accessor(container, name) except KeyError: found = not_there except TypeError: accessor = getattr found = accessor(container, name, not_there) else: try: found = accessor(container, name) except (KeyError, AttributeError): found = not_there if strict and not (found is not_there or target is container): if found is not target: if isinstance(found, MethodType): raise TypeError( 'Cannot replace methods on instances with strict=True, ' 'replace on class or use strict=False' ) raise AssertionError( f'{accessor} of {name!r} from {container!r} gave {found!r}, ' f'expected {target}' ) resolved = Resolved( container, setitem if accessor is getitem else setattr, name, found ) if resolved.setter is None: raise ValueError('target must contain at least one dot!') if resolved.found is not_there and strict: raise AttributeError('Original %r not found' % resolved.name) if ( hasattr(resolved.container, '__dict__') and resolved.setter is setattr and resolved.name not in resolved.container.__dict__ ): if strict: raise AttributeError( f'{resolved.container!r} has __dict__ but {resolved.name!r} is not in it' ) else: resolved.found = not_there replacement_to_use: Any = replacement if isinstance(resolved.container, type): # if we have a descriptor, don't accidentally use the result of its __get__ method: if resolved.name in resolved.container.__dict__: resolved.found = resolved.container.__dict__[resolved.name] if not_same_descriptor(resolved.found, replacement, classmethod): replacement_to_use = classmethod(replacement) # type: ignore[arg-type] elif not_same_descriptor(resolved.found, replacement, staticmethod): replacement_to_use = staticmethod(replacement) # type: ignore[arg-type] self._replace(resolved, replacement_to_use) key = resolved.key() if key not in self.originals: self.originals[key] = target, resolved return replacement def replace(self, target: Any, replacement: Any, strict: bool = True, container: Any | None = None, accessor: Accessor | None = None, name: str | None = None) -> None: """ Replace the specified target with the supplied replacement. """ self(target, replacement, strict, container, accessor, name) def in_environ(self, name: str, replacement: Any) -> None: """ This method provides a convenient way of ensuring an environment variable in :any:`os.environ` is set to a particular value. If you wish to ensure that an environment variable is *not* present, then use :any:`not_there` as the ``replacement``. """ self(os.environ, name=name, accessor=getitem, strict=False, replacement=not_there if replacement is not_there else str(replacement)) def _find_container( self, attribute: Callable, name: str | None, break_on_static: bool ) -> tuple[Any, Any]: for referrer in get_referrers(attribute): if break_on_static and isinstance(referrer, staticmethod): return None, referrer elif isinstance(referrer, dict): if referrer.get(name) is attribute: for container in get_referrers(referrer): if isinstance(container, type): return container, None return None, None def on_class(self, attribute: Callable, replacement: Any, name: str | None = None) -> None: """ This method provides a convenient way to replace methods, static methods and class methods on their classes. If the attribute being replaced has a ``__name__`` that differs from the attribute name on the class, such as that returned by poorly implemented decorators, then ``name`` must be used to provide the correct name. """ name = name or getattr(attribute, '__name__', None) if not callable(attribute): name_text = f' named {name!r} ' if name else ' ' raise TypeError(f'attribute{name_text}must be a method') container = None qualname = getattr(attribute, '__qualname__', None) if qualname and '<' not in qualname: module_name = getattr(attribute, '__module__', None) if module_name: resolved = resolve(f"{module_name}.{qualname}") if not resolved.found is not_there: container = resolved.container if resolved.found is not attribute: container = None if container is not None: pass elif isinstance(attribute, classmethod_type): for referred in get_referents(attribute): if isinstance(referred, class_type): container = referred else: container, staticmethod_ = self._find_container(attribute, name, break_on_static=True) if staticmethod_ is not None: container, _ = self._find_container(staticmethod_, name, break_on_static=False) if container is None: raise AttributeError(f'could not find container of {attribute!r} using name {name!r}') self(container, name=name, accessor=getattr, replacement=replacement) def in_module(self, target: Any, replacement: Any, module: ModuleType | None = None) -> None: """ This method provides a convenient way to replace targets that are module globals, particularly functions or other objects with a ``__name__`` attribute. If an object has been imported into a module other than the one where it has been defined, then ``module`` should be used to specify the module where you would like the replacement to occur. """ container = module or resolve(target.__module__).found name = target.__name__ self(container, name=name, accessor=getattr, replacement=replacement) def restore(self) -> None: """ Restore all the original objects that have been replaced by calls to the :meth:`replace` method of this :class:`Replacer`. """ for id_, (target, resolved) in tuple(self.originals.items()): self._replace(resolved, resolved.found) del self.originals[id_] def __enter__(self) -> Self: return self def __exit__( self, exc_type: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None, ) -> None: self.restore() def __del__(self) -> None: if self.originals: parts = [] for target, resolved in self.originals.values(): if isinstance(target, str): prefix = repr(target) else: prefix = f"{type(target)} ({resolved.name!r})" parts.append(f'{prefix}: original = {resolved.found!r}') originals_text = ', '.join(parts) # no idea why coverage misses the following statement # it's covered by test_replace.TestReplace.test_replacer_del warnings.warn( # pragma: no cover 'Replacer deleted without being restored, originals not restored: ' + originals_text ) def replace( target: Any, replacement: Any, strict: bool = True, container: Any | None = None, accessor: Accessor | None = None, name: str | None = None, sep: str ='.' ) -> Callable[[Callable], Callable]: """ A decorator to replace a target object for the duration of a test function. """ r = Replacer() return wrap( partial(r.__call__, target, replacement, strict, container, accessor, name, sep), r.restore ) @contextmanager def replace_in_environ(name: str, replacement: Any) -> Iterator[None]: """ This context manager provides a quick way to use :meth:`Replacer.in_environ`. """ with Replacer() as r: r.in_environ(name, replacement) yield @contextmanager def replace_on_class( attribute: Callable, replacement: Any, name: str | None = None ) -> Iterator[None]: """ This context manager provides a quick way to use :meth:`Replacer.on_class`. """ with Replacer() as r: r.on_class(attribute, replacement, name) yield @contextmanager def replace_in_module( target: Any, replacement: Any, module: ModuleType | None = None ) -> Iterator[None]: """ This context manager provides a quick way to use :meth:`Replacer.in_module`. """ with Replacer() as r: r.in_module(target, replacement, module) yield class Replace(Generic[R]): """ A context manager that uses a :class:`Replacer` to replace a single target. """ def __init__( self, target: Any, replacement: R, strict: bool = True, container: Any | None = None, accessor: Accessor | None = None, name: str | None = None, sep: str ='.' ): self.target = target self.replacement = replacement self.strict = strict self.container: Any = container self.accessor = accessor self.name = name self.sep: str = sep self._replacer = Replacer() def __enter__(self) -> R: return self._replacer( self.target, self.replacement, self.strict, self.container, self.accessor, self.name, self.sep, ) def __exit__( self, exc_type: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None, ) -> None: self._replacer.restore() replace_params_doc = """ :param target: This must be one of the following: - A string containing the dotted-path to the object to be replaced, in which case it will be resolved the the object to be replaced. This path may specify a module in a package, an attribute of a module, or any attribute of something contained within a module. - The container of the object to be replaced, in which case ``name`` must be specified. - The object to be replaced, in which case ``container`` must be specified. ``name`` must also be specified if it cannot be obtained from the ``__name__`` attribute of the object to be replaced. :param replacement: The object to use as a replacement. :param strict: When `True`, an exception will be raised if an attempt is made to replace an object that does not exist or if the object that is obtained using the ``accessor`` to access the ``name`` from the ``container`` is not identical to the ``target``. :param container: The container of the object from which ``target`` can be accessed using either :func:`getattr` or :func:`~operator.getitem`. :param accessor: Either :func:`getattr` or :func:`~operator.getitem`. If not supplied, this will be inferred. :param name: The name used to access the ``target`` from the ``container`` using the ``accessor``. If required but not specified, the ``__name__`` attribute of the ``target`` will be used. :param sep: When ``target`` is a string, this is the separator between traversal segments. """ # add the param docs, so we only have one copy of them! extend_docstring(replace_params_doc, [Replacer.__call__, Replacer.replace, replace, Replace]) testfixtures-12.4.0/src/testfixtures/resolve.py000066400000000000000000000047601525773641100217620ustar00rootroot00000000000000from operator import setitem from typing import Any, Callable, Tuple, TypeAlias, Literal from testfixtures import not_there Setter: TypeAlias = Callable[[object, str, Any], None] | Callable[[Any, int, Any], None] | None Key: TypeAlias = Tuple[int, Setter, str | int | None] class Resolved: def __init__(self, container: Any, setter: Setter, name: Any, found: Any): self.container: Any = container self.setter = setter self.name = name self.found: Any = found def key(self) -> Key: return id(self.container), self.setter, self.name def resolve(dotted_name: str, container: Any | None = None, sep: str = '.') -> Resolved: names = dotted_name.split(sep) used = names.pop(0) found: Any if container is None: found = __import__(used) container = found else: assert not used, 'Absolute traversal not allowed when container supplied' used = '' found = container setter: Setter = None name: Any = None for name in names: container = found used += '.' + name try: found = getattr(found, name) setter = setattr except AttributeError: try: if sep != '.': raise ImportError __import__(used) except ImportError: setter = setitem try: found = found[name] except KeyError: found = not_there except TypeError: try: name = int(name) except ValueError: setter = setattr found = not_there else: found = found[name] else: found = getattr(found, name) setter = getattr if found is not_there: break return Resolved(container, setter, name, found) def type_name(type_: Any) -> str: module = getattr(type_, '__module__', None) prefix = module + '.' if module else '' name = getattr(type_, '__qualname__', None) or getattr(type_, '__name__', None) return prefix + (name or repr(type_)) class _Reference: @classmethod def classmethod(cls) -> None: # pragma: no cover pass @staticmethod def staticmethod() -> None: # pragma: no cover pass class_type = type(_Reference) classmethod_type = type(_Reference.classmethod) testfixtures-12.4.0/src/testfixtures/rmtree.py000066400000000000000000000051051525773641100215730ustar00rootroot00000000000000# lamosity needed to make things reliable on Windows :-( # (borrowed from Python's test_support.py) import errno import os import shutil import sys import time import warnings from pathlib import Path if sys.platform.startswith("win"): # pragma: no cover def _waitfor(func, pathname, waitall=False): # Perform the operation func(pathname) # Now setup the wait loop if waitall: dirname = pathname else: dirname, name = os.path.split(pathname) dirname = dirname or '.' # Check for `pathname` to be removed from the filesystem. # The exponential backoff of the timeout amounts to a total # of ~1 second after which the deletion is probably an error # anyway. # Testing on a i7@4.3GHz shows that usually only 1 iteration is # required when contention occurs. timeout = 0.001 while timeout < 1.0: # pragma: no branch # Note we are only testing for the existence of the file(s) in # the contents of the directory regardless of any security or # access rights. If we have made it this far, we have sufficient # permissions to do that much using Python's equivalent of the # Windows API FindFirstFile. # Other Windows APIs can fail or give incorrect results when # dealing with files that are pending deletion. L = os.listdir(dirname) if not (L if waitall else name in L): # pragma: no branch return # Increase the timeout and try again time.sleep(timeout) # pragma: no cover timeout *= 2 # pragma: no cover warnings.warn('tests may fail, delete still pending for ' + pathname, # pragma: no cover RuntimeWarning, stacklevel=4) def _rmtree(path): def _rmtree_inner(path): for name in os.listdir(path): fullname = os.path.join(path, name) if os.path.isdir(fullname): _waitfor(_rmtree_inner, fullname, waitall=True) os.rmdir(fullname) else: os.unlink(fullname) _waitfor(_rmtree_inner, path, waitall=True) _waitfor(os.rmdir, path) else: _rmtree = shutil.rmtree def rmtree(path: str | Path) -> None: try: _rmtree(path) except OSError as e: # pragma: no cover # Unix returns ENOENT, Windows returns ESRCH. if e.errno not in (errno.ENOENT, errno.ESRCH): # pragma: no branch raise testfixtures-12.4.0/src/testfixtures/shouldraise.py000066400000000000000000000173131525773641100226230ustar00rootroot00000000000000import re from contextlib import contextmanager from functools import wraps from types import TracebackType from typing import Callable, TypeAlias, Iterator, Self, ParamSpec, TypeVar, Generic, cast, overload from testfixtures import diff, compare, not_there, singleton from .comparers import split_repr from .comparison import RenderingComparison param_docs = """ :param exception: This can be one of the following: * Not passed, indicating that an exception must be raised, but the type is unimportant. * ``None``, indicating that no exception should be raised. This is useful for parameterised tests where the parameter may be either an exception or ``None``. * An exception class, indicating that the type of the exception is important but not the parameters it is created with. * An exception instance, indicating that an exception exactly matching the one supplied should be raised. * A :class:`ReprComparison` or :class:`StrComparison`, such as those returned by :func:`repr_like` and :func:`str_like`, indicating that a raised exception must be of the expected type and have the expected :func:`repr` or :class:`str`. :param unless: Can be passed a boolean that, when ``True`` indicates that no exception is expected. This is useful when checking that exceptions are only raised on certain versions of Python. """ class NoException(AssertionError): """ A marker class indicating no exception has been raised. .. currentmodule:: testfixtures :attr:`ShouldRaise.raised` is set to an instance of this class unless an exception has otherwise been seen. """ def __init__(self) -> None: super().__init__('No exception raised!') E = TypeVar("E", bound=BaseException) class ShouldRaise(Generic[E]): __doc__ = """ This context manager is used to assert that an exception is raised within the context it is managing. """ + param_docs #: The exception captured by the context manager. #: Can be used to inspect specific attributes of the exception. raised: E = NoException() # type: ignore[assignment] exception: E | type[E] | None expected: bool match: str | re.Pattern[str] | None @overload def __init__(self, exception: type[E], *, match: str | re.Pattern[str] | None = None, unless: bool | None = False) -> None: ... @overload def __init__(self, exception: singleton = not_there, *, match: str | re.Pattern[str] | None = None, unless: bool | None = False) -> None: ... @overload def __init__(self, exception: E | None, *, match: None = None, unless: bool | None = False) -> None: ... def __init__( self, exception: E | type[E] | None | singleton = not_there, *, match: str | re.Pattern[str] | None = None, unless: bool | None = False, ) -> None: if match is not None and (exception is None or (exception is not not_there and not isinstance(exception, type))): raise TypeError('match can only be used when an exception type is provided') self.exception = None if exception in (None, not_there) else cast('E | type[E]', exception) self.expected = False if exception is None else not unless self.match = match def __enter__(self) -> Self: return self def _check_match(self, actual: BaseException) -> None: __tracebackhide__ = True if self.match is not None and not re.search(self.match, str(actual)): raise AssertionError( f'Pattern did not match:\n{self.match!r} (expected)\n{str(actual)!r} (actual)' ) def __exit__( self, exc_type: type[BaseException] | None, actual: BaseException | None, traceback: TracebackType | None, ) -> bool: __tracebackhide__ = True self.raised = actual or NoException() # type: ignore[assignment] if self.expected: if self.exception: actual_: type[BaseException] | BaseException | None = actual if actual is not None: if isinstance(self.exception, type): actual_ = type(actual) if self.exception is not actual_: return False self._check_match(actual) elif isinstance(self.exception, RenderingComparison): if self.exception.type is not type(actual): return False else: if type(self.exception) is not type(actual): return False compare(self.exception, actual_, x_label='expected', y_label='raised') elif not actual: raise NoException() else: self._check_match(actual) elif actual: return False return True P = ParamSpec("P") T = TypeVar("T") class should_raise(Generic[E]): __doc__ = """ A decorator to assert that the decorated function will raised an exception. An exception class or exception instance may be passed to check more specifically exactly what exception will be raised. """ + param_docs @overload def __init__(self, exception: type[E], *, match: str | re.Pattern[str] | None = None, unless: bool | None = None) -> None: ... @overload def __init__(self, exception: singleton = not_there, *, match: str | re.Pattern[str] | None = None, unless: bool | None = None) -> None: ... @overload def __init__(self, exception: E | None, *, match: None = None, unless: bool | None = None) -> None: ... def __init__( self, exception: E | type[E] | None | singleton = not_there, *, match: str | re.Pattern[str] | None = None, unless: bool | None = None, ) -> None: self.exception: E | type[E] | None | singleton = exception self.match = match self.unless = unless def __call__(self, target: Callable[P, T]) -> Callable[P, None]: @wraps(target) def _should_raise_wrapper(*args: P.args, **kw: P.kwargs) -> None: with ShouldRaise(self.exception, match=self.match, unless=self.unless): # type: ignore[arg-type] target(*args, **kw) return _should_raise_wrapper @contextmanager def ShouldAssert(expected_text: str, show_whitespace: bool = False) -> Iterator[None]: """ A context manager to check that an :class:`AssertionError` is raised and its text is as expected. :param show_whitespace: If `True`, then whitespace characters in multi-line strings will be replaced with their representations. """ __tracebackhide__ = True try: yield except AssertionError as e: actual_text = str(e) if expected_text != actual_text: if show_whitespace: expected_text = split_repr(expected_text) actual_text = split_repr(actual_text) raise AssertionError(diff(expected_text, actual_text, x_label='expected', y_label='actual')) else: raise AssertionError('Expected AssertionError(%r), None raised!' % expected_text) testfixtures-12.4.0/src/testfixtures/shouldwarn.py000066400000000000000000000060241525773641100224640ustar00rootroot00000000000000import warnings from types import TracebackType from typing import TypeAlias, Any from testfixtures import Comparison, SequenceComparison, compare WarningOrType: TypeAlias = Warning | type[Warning] class ShouldWarn(warnings.catch_warnings): """ This context manager is used to assert that warnings are issued within the context it is managing. :param expected: This should be a sequence made up of one or more elements, each of one of the following types: * A warning class, indicating that the type of the warnings is important but not the parameters it is created with. * A warning instance, indicating that a warning exactly matching the one supplied should have been issued. If no expected warnings are passed, you will need to inspect the contents of the list returned by the context manager. :param order_matters: A keyword-only parameter that controls whether the order of the captured entries is required to match those of the expected entries. Defaults to ``True``. :param filters: If passed, these are used to create a filter such that only warnings you are interested in will be considered by this :class:`ShouldWarn` instance. The names and meanings are the same as the parameters for :func:`warnings.filterwarnings`. """ _empty_okay = False recorded: list[warnings.WarningMessage] def __init__( self, *expected: WarningOrType, order_matters: bool = True, **filters: Any ) -> None: super(ShouldWarn, self).__init__(record=True) self.order_matters = order_matters self.expected = [Comparison(e) for e in expected] self.filters = filters def __enter__(self) -> list[warnings.WarningMessage]: # We pass `record=True` above, so the following will *always* return a list: self.recorded = super(ShouldWarn, self).__enter__() # type: ignore[assignment] warnings.filterwarnings("always", **self.filters) return self.recorded def __exit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: __tracebackhide__ = True super(ShouldWarn, self).__exit__(exc_type, exc_val, exc_tb) if not self.recorded and self._empty_okay: return if not self.expected and self.recorded and not self._empty_okay: return compare( expected=SequenceComparison(*self.expected, ordered=self.order_matters), actual=[wm.message for wm in self.recorded] ) class ShouldNotWarn(ShouldWarn): """ This context manager is used to assert that no warnings are issued within the context it is managing. """ _empty_okay = True def __init__(self) -> None: super(ShouldNotWarn, self).__init__() testfixtures-12.4.0/src/testfixtures/structlog.py000066400000000000000000000133051525773641100223240ustar00rootroot00000000000000""" Tools for helping to test applications that use structlog. """ import logging import sys from typing import Any, Callable, Sequence, TypeAlias from warnings import warn import structlog from structlog._log_levels import NAME_TO_LEVEL, map_method_name from structlog.exceptions import DropEvent from structlog.types import EventDict, Processor from .logcapture import Entry, build_actual_extractor, AttributeSpec EventDictAttributes: TypeAlias = AttributeSpec[EventDict] def level_name(event_dict: EventDict) -> str: """ Extract the upper-case level name from a structlog event dict as set by :func:`structlog.stdlib.add_log_level`. """ return str(event_dict.get('level', '')).upper() def raw(event_dict: EventDict) -> EventDict: """ Return the raw structlog event dict for use as the :attr:`~testfixtures.logcapture.Entry.actual` value. """ return event_dict class StructlogSource: """ A :class:`~testfixtures.logcapture.CaptureSource` for `structlog `_, for use with :class:`~testfixtures.LogCapture`. On :meth:`~testfixtures.logcapture.CaptureSource.install`, the current structlog configuration is saved replaced with a chain that captures each event. On :meth:`~testfixtures.logcapture.CaptureSource.uninstall`, the saved configuration is restored. :param attributes: The sequence of attributes to return for each event dict, or a callable that extracts :attr:`~testfixtures.logcapture.Entry.actual` from the event dict. If a sequence is passed, each item may be a string key into the event dict or a callable that receives the event dict. Missing string keys yield ``None``. :param level: Minimum level to capture, as a numeric level or a structlog/stdlib level name (case-insensitive). Defaults to ``0`` capturing everything. :param processors: :doc:`Processors ` to run before the capture processor. The defaults ensure that the ``level`` key is present and values bound via :func:`structlog.contextvars.bind_contextvars` or :func:`structlog.contextvars.bound_contextvars` are captured. """ collector: Callable[[Entry], None] | None = None live_processors: list[Processor] | None = None saved_processors: list[Processor] | None = None def __init__( self, attributes: EventDictAttributes = (level_name, 'event'), level: int | str = 0, processors: Sequence[Processor] = ( structlog.stdlib.add_log_level, structlog.contextvars.merge_contextvars, ), ) -> None: self.attributes = attributes self.processors: tuple[Processor, ...] = tuple(processors) self.min_level = self.resolve_level(level) self.compute_actual = build_actual_extractor(attributes, self.extract_field) @staticmethod def resolve_level(level: int | str) -> int: match level: case int(): resolved = level case str(): resolved_ = NAME_TO_LEVEL.get(map_method_name(level.lower())) if isinstance(resolved_, int): resolved = resolved_ else: raise ValueError(f'Unknown structlog level name: {level!r}') return resolved def extract_field(self, raw: EventDict, attribute: str) -> Any: return raw.get(attribute) def capture( self, logger: Any, method_name: str, event_dict: Any, ) -> EventDict: if self.collector is None: raise DropEvent level = self.resolve_level(method_name) if level < self.min_level: raise DropEvent exception: BaseException | None = None if isinstance(event_dict, dict): exc_info = event_dict.get('exc_info') if exc_info is True: exc_info = sys.exc_info() if isinstance(exc_info, tuple) and len(exc_info) == 3: exception = exc_info[1] elif isinstance(exc_info, BaseException): exception = exc_info actual = self.compute_actual(event_dict) self.collector(Entry(raw=event_dict, actual=actual, level=level, exception=exception)) raise DropEvent def install(self, collector: Callable[[Entry], None]) -> None: self.collector = collector # Mutate the configured processors list in place rather than swapping # in a new one — loggers bound (or cached) before install() hold a # reference to this list, and replacing it would leave them on the # old chain. structlog.testing.capture_logs uses the same trick. self.live_processors = structlog.get_config()['processors'] self.saved_processors = list(self.live_processors) self.live_processors[:] = [*self.processors, self.capture] structlog.configure(processors=self.live_processors) def uninstall(self) -> None: if self.live_processors is None or self.saved_processors is None: return live = self.live_processors saved = self.saved_processors del self.live_processors del self.saved_processors del self.collector current = structlog.get_config()['processors'] if current is not live or self.capture not in current: warn( 'StructlogSource was displaced from structlog configuration before uninstall.\n' 'Avoid calling structlog.configure() or structlog.reset_defaults() ' 'inside a LogCapture block.' ) current = live current[:] = saved structlog.configure(processors=current) testfixtures-12.4.0/src/testfixtures/sybil.py000066400000000000000000000045661525773641100214310ustar00rootroot00000000000000import os import textwrap from dataclasses import dataclass from typing import Iterable from sybil import Document, Region, Example from sybil.parsers.rest.lexers import DirectiveLexer from testfixtures import diff @dataclass class FileBlock: path: str content: str action: str class FileParser: """ A `Sybil `__ parser that parses certain ReST sections to read and write files in the configured :class:`~testfixtures.TempDirectory`. :param name: This is the name of the :class:`~testfixtures.TempDirectory` to use in the Sybil test namespace. """ def __init__(self, name: str): self.name = name self.lexer = DirectiveLexer('topic', arguments='.+') def __call__(self, document: Document) -> Iterable[Region]: for region in self.lexer(document): options = region.lexemes.get('options') if options is not None: class_ = options.get('class') if class_ in ('read-file', 'write-file'): lines = region.lexemes['source'].splitlines(keepends=True) index = 0 if lines[index].strip() == '::': index += 1 source = textwrap.dedent(''.join(lines[index:])).lstrip() if source[-1] != '\n': source += '\n' region.parsed = FileBlock( path=region.lexemes['arguments'], content=source, action=class_.split('-')[0] ) region.evaluator = self.evaluate yield region def evaluate(self, example: Example) -> str | None: block: FileBlock = example.parsed temp_directory = example.namespace[self.name] if block.action == 'read': actual = temp_directory.as_path(block.path).read_text().replace(os.linesep, '\n') if actual != block.content: return diff( block.content, actual, 'File %r, line %i:' % (example.path, example.line), 'Reading from "%s":' % temp_directory.as_string(block.path) ) if block.action == 'write': temp_directory.write(block.path, block.content) return None testfixtures-12.4.0/src/testfixtures/tempdirectory.py000066400000000000000000000602121525773641100231670ustar00rootroot00000000000000import atexit import os import shutil import warnings from abc import ABC, abstractmethod, abstractproperty from pathlib import Path from re import compile from tempfile import mkdtemp from types import TracebackType from typing import ( Sequence, Callable, TypeAlias, Self, TypeVar, Generic, cast, ClassVar, Any, overload ) from testfixtures.comparing import compare from testfixtures.formats import Format, JSON, YAML, TOML from testfixtures.utils import wrap from .rmtree import rmtree PathStrings: TypeAlias = str | Sequence[str] | Path P = TypeVar('P', bound=str | Path) # When Python 3.13+ becomes the minimum version, we can use PEP 696 (type parameter defaults) # to collapse TempDirectory and TempDir into a single class with backward-compatible # aliases. Use PEP 695 syntax: class TempDir[P: str | Path = str]: ... class _TempDir(ABC, Generic[P]): """ A class representing a temporary directory on disk. :param ignore: A sequence of strings containing regular expression patterns that match filenames that should be ignored by the :class:`TempDirectory` listing and checking methods. :param create: If `True`, the temporary directory will be created as part of class instantiation. :param path: If passed, this should be a string containing an absolute path to use as the temporary directory. When passed, :class:`TempDirectory` will not create a new directory to use. :param encoding: A default encoding to use for :meth:`read` and :meth:`write` operations when the ``encoding`` parameter is not passed to those methods. :param cwd: If ``True``, set the current working directory to be that of the temporary directory when used as a decorator or context manager. """ instances: ClassVar[set['_TempDir']] = set() atexit_setup = False _path: str | None = None @property @abstractmethod def path(self) -> P: """ The absolute path where data will be stored """ def __init__( self, path: str | Path | None = None, *, ignore: Sequence[str] = (), create: bool | None = None, encoding: str | None = None, cwd: bool = False, formats: Sequence[Format] = (JSON, YAML, TOML), ): self.ignore = [] for regex in ignore: self.ignore.append(compile(regex)) self._path = str(path) if path else None self.encoding = encoding self.cwd = cwd self.original_cwd: str | None = None self.dont_remove = bool(path) # Build extension-to-format lookup self.formats: dict[str, Format] = {} for fmt in formats: for suffix in fmt.suffixes: if suffix in self.formats: raise ValueError( f"Multiple formats registered for extension '{suffix}'" ) self.formats[suffix.lower()] = fmt if create or (path is None and create is None): self.create() @classmethod def atexit(cls) -> None: if cls.instances: warnings.warn( 'TempDirectory instances not cleaned up by shutdown:\n' '%s' % ('\n'.join(i._path for i in cls.instances if i._path)) ) def create(self) -> Self: """ Create a temporary directory for this instance to use if one has not already been created. """ if self._path: return self self._path = mkdtemp() self.instances.add(self) if not self.__class__.atexit_setup: atexit.register(self.atexit) self.__class__.atexit_setup = True if self.cwd: self.original_cwd = os.getcwd() os.chdir(self._path) return self def cleanup(self) -> None: """ Delete the temporary directory and anything in it. This :class:`TempDirectory` cannot be used again unless :meth:`create` is called. """ if self.cwd and self.original_cwd: os.chdir(self.original_cwd) self.original_cwd = None if self._path and os.path.exists(self._path) and not self.dont_remove: rmtree(self._path) del self._path if self in self.instances: self.instances.remove(self) @classmethod def cleanup_all(cls) -> None: """ Delete all temporary directories associated with all :class:`TempDirectory` objects. """ for i in tuple(cls.instances): i.cleanup() def actual( self, path: PathStrings | None = None, recursive: bool = False, files_only: bool = False, followlinks: bool = False, ) -> list[str]: path = self._join(path) result: list[str] = [] if recursive: for dirpath, dirnames, filenames in os.walk(path, followlinks=followlinks): dirpath = '/'.join(dirpath[len(path)+1:].split(os.sep)) if dirpath: dirpath += '/' for dirname in dirnames: if not files_only: result.append(dirpath+dirname+'/') for name in sorted(filenames): result.append(dirpath+name) else: for n in os.listdir(path): result.append(n) filtered = [] for result_path in sorted(result): ignore = False for regex in self.ignore: if regex.search(result_path): ignore = True break if ignore: continue filtered.append(result_path) return filtered def listdir(self, path: PathStrings | None = None, recursive: bool = False) -> None: """ Print the contents of the specified directory. :param path: The path to list, which can be: * `None`, indicating the root of the temporary directory should be listed. * A tuple of strings, indicating that the elements of the tuple should be used as directory names to traverse from the root of the temporary directory to find the directory to be listed. * A forward-slash separated string, indicating the directory or subdirectory that should be traversed to from the temporary directory and listed. :param recursive: If `True`, the directory specified will have its subdirectories recursively listed too. """ actual = self.actual(path, recursive) if not actual: print('No files or directories found.') for n in actual: print(n) def compare( self, expected: Sequence[str], path: PathStrings | None = None, files_only: bool = False, recursive: bool = True, followlinks: bool = False, ) -> None: """ Compare the expected contents with the actual contents of the temporary directory. An :class:`AssertionError` will be raised if they are not the same. :param expected: A sequence of strings containing the paths expected in the directory. These paths should be forward-slash separated and relative to the root of the temporary directory. :param path: The path to use as the root for the comparison, relative to the root of the temporary directory. This can either be: * A tuple of strings, making up the relative path. * A forward-slash separated string. If it is not provided, the root of the temporary directory will be used. :param files_only: If specified, directories will be excluded from the list of actual paths used in the comparison. :param recursive: If ``False``, only the direct contents of the directory specified by ``path`` will be included in the actual contents used for comparison. :param followlinks: If ``True``, symlinks and hard links will be followed when recursively building up the actual list of directory contents. """ __tracebackhide__ = True compare(expected=sorted(expected), actual=tuple(self.actual( path, recursive, files_only, followlinks )), recursive=False) def _join(self, parts: PathStrings | None) -> str: if self._path is None: raise RuntimeError('Instantiated with create=False and .create() not called') match parts: case None: return self._path case str(): parts = parts.split('/') case Path(): parts = parts.relative_to(self._path).parts relative = os.sep.join(parts).rstrip(os.sep) if relative.startswith(os.sep): if relative.startswith(self._path): return relative raise ValueError( 'Attempt to read or write outside the temporary Directory' ) return os.path.join(self._path, relative) def _makedir(self, dirpath: PathStrings) -> str: thepath = self._join(dirpath) os.makedirs(thepath) return thepath @abstractmethod def makedir(self, dirpath: PathStrings) -> P: """ Make an empty directory at the specified path within the temporary directory. Any intermediate subdirectories that do not exist will also be created. :param dirpath: The directory to create, which can be: * A tuple of strings. * A forward-slash separated string. :returns: The absolute path of the created directory. """ def _write( self, filepath: PathStrings, data: str | bytes | Any, encoding: str | None = None, format: Format | None = None ) -> str: path = Path(self._join(filepath)) path.parent.mkdir(parents=True, exist_ok=True) if format is not None: data = format.render(data) encoding = encoding or self.encoding if encoding is not None: if isinstance(data, bytes): raise TypeError('Cannot specify encoding when data is bytes') data = data.encode(encoding) elif isinstance(data, str): data = data.encode() path.write_bytes(data) return str(path) @overload def write(self, filepath: PathStrings, data: bytes) -> P: ... @overload def write(self, filepath: PathStrings, data: str, encoding: str | None = None) -> P: ... @overload def write(self, filepath: PathStrings, data: Any, encoding: str, format: Format) -> P: ... @overload def write(self, filepath: PathStrings, data: Any, * , format: Format) -> P: ... @abstractmethod def write( self, filepath: PathStrings, data: str | bytes | Any, encoding: str | None = None, format: Format | None = None ) -> P: """ Write the supplied data to a file at the specified path within the temporary directory. Any subdirectories specified that do not exist will also be created. The file will always be written in binary mode. The data supplied must either be bytes or an encoding must be supplied to convert the string into bytes, or a format must be supplied to serialize the data. :param filepath: The path to the file to create, which can be: * A tuple of strings. * A forward-slash separated string. :param data: :class:`bytes` containing the data to be written, a :class:`str` if ``encoding`` has been supplied, or any Python object if ``format`` has been supplied. :param encoding: The encoding to be used for converting strings to bytes. Should not be passed if data is already bytes. When used with ``format``, this controls the encoding of the formatted string before writing to disk. :param format: A :class:`~testfixtures.formats.Format` instance to use for serializing the data. When used with ``encoding``, the format first renders the data to a string, then that string is encoded with the specified encoding. :returns: The absolute path of the file written. """ def _find_format(self, filepath: PathStrings) -> Format: match filepath: case str(): filename = filepath case Path(): filename = filepath.name case _: filename = filepath[-1] suffix = os.path.splitext(filename)[-1].lower() if suffix not in self.formats: raise ValueError( f"No format registered for extension '{suffix}'. " f"Supported extensions: {sorted(self.formats.keys())}" ) return self.formats[suffix] @abstractmethod def dump(self, filepath: PathStrings, data: Any, format: Format | None = None) -> P: """ Serialize and write data to a file, auto-detecting format from file extension. :param filepath: The path to write, relative to the temporary directory. :param data: The Python object to serialize. :param format: Optional format handler. If provided, uses this format instead of auto-detecting from file extension. :returns: The full path of the file that was written. """ def _clone( self, source: str | Path, filepath: PathStrings | None = None, ) -> str: source_path = Path(source) into_dir = ( (isinstance(filepath, str) and filepath.endswith('/')) or (isinstance(filepath, Sequence) and not isinstance(filepath, str) and filepath[-1] == '') ) if into_dir: # Clone INTO directory dest_path = self._join(filepath) if source_path.is_dir(): shutil.copytree(source_path, dest_path, dirs_exist_ok=True) return dest_path os.makedirs(dest_path, exist_ok=True) shutil.copy2(source_path, dest_path) return os.path.join(dest_path, source_path.name) # Clone AS path dest_path = self._join(source_path.name if filepath is None else filepath) if source_path.is_dir(): shutil.copytree(source_path, dest_path) return dest_path if parent := os.path.dirname(dest_path): os.makedirs(parent, exist_ok=True) shutil.copy2(source_path, dest_path) return dest_path @abstractmethod def clone(self, source: str | Path, filepath: PathStrings | None = None) -> P: """ Clone a file or directory from outside the TempDir into it. :param source: Path to the source file or directory. :param filepath: Destination within TempDir. If None, uses source's name at root. If ends with '/' or is a sequence ending with '', clones into that directory rather than as that path. :returns: Absolute path of the cloned content. """ def as_string(self, path: str | Sequence[str] | None = None) -> str: """ Return the full path on disk that corresponds to the path relative to the temporary directory that is passed in. :param path: The path to the file to create, which can be: * A tuple of strings. * A forward-slash separated string. :returns: A string containing the absolute path. """ return self._join(path) #: .. deprecated:: 7 #: #: Use :meth:`as_string` instead. getpath = as_string def as_path(self, path: PathStrings | None = None) -> Path: """ Return the :class:`~pathlib.Path` that corresponds to the path relative to the temporary directory that is passed in. :param path: The path to the file to create, which can be: * A tuple of strings. * A forward-slash separated string. """ return Path(self._join(path)) def __truediv__(self, other: str) -> Path: return self.as_path() / other @overload def read(self, filepath: PathStrings) -> str | bytes: ... @overload def read(self, filepath: PathStrings, encoding: str | None) -> str | bytes: ... @overload def read(self, filepath: PathStrings, *, format: Format) -> Any: ... @overload def read(self, filepath: PathStrings, encoding: str | None, format: Format) -> Any: ... def read( self, filepath: PathStrings, encoding: str | None = None, format: Format | None = None ) -> str | bytes | Any: """ Reads the file at the specified path within the temporary directory. The file is always read in binary mode. Bytes will be returned unless an encoding is supplied, in which case a unicode string of the decoded data will be returned, or a format is supplied, in which case the deserialized Python object will be returned. :param filepath: The path to the file to read, which can be: * A tuple of strings. * A forward-slash separated string. :param encoding: The encoding used to decode bytes from the file into a string. When used with ``format``, this controls the decoding of bytes from the file before parsing. :param format: A :class:`~testfixtures.formats.Format` instance to use for deserializing the data. When used with ``encoding``, the file bytes are first decoded with the specified encoding, then parsed by the format. :returns: The contents of the file as a :class:`str`, :class:`bytes`, or deserialized Python object depending on the parameters provided. """ with open(self._join(filepath), 'rb') as f: data = f.read() encoding = encoding or self.encoding if encoding is not None: text = data.decode(encoding) if format is None: return text else: return format.parse(text) if format is not None: return format.parse(data.decode('utf-8')) return data def read_text( self, filepath: PathStrings, encoding: str | None = None, errors: str | None = None ) -> str: """ Read file as text, mirroring pathlib.Path.read_text() behavior. :param filepath: The path to the file to read. :param encoding: Text encoding. Uses instance default encoding if None, then falls back to platform default (locale encoding). :param errors: Error handling strategy (e.g., 'strict', 'ignore', 'replace'). :returns: The file contents as a string. """ return Path(self._join(filepath)).read_text(encoding or self.encoding, errors) def read_bytes(self, filepath: PathStrings) -> bytes: """ Read file as bytes, mirroring pathlib.Path.read_bytes() behavior. :param filepath: The path to the file to read. :returns: The file contents as bytes. """ return Path(self._join(filepath)).read_bytes() def parse(self, filepath: PathStrings, format: Format | None = None) -> Any: """ Read and deserialize a file, auto-detecting format from file extension. :param filepath: The path to read, relative to the temporary directory. :param format: Optional format handler. If provided, uses this format instead of auto-detecting from file extension. :returns: The deserialized Python object. """ return self.read(filepath, format=format or self._find_format(filepath)) def __enter__(self) -> Self: return self def __exit__( self, exc_type: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None, ) -> None: self.cleanup() class TempDirectory(_TempDir[str]): @property def path(self) -> str: return self._path # type: ignore[return-value] def makedir(self, dirpath: PathStrings) -> str: return self._makedir(dirpath) @overload def write(self, filepath: PathStrings, data: bytes) -> str: ... @overload def write(self, filepath: PathStrings, data: str, encoding: str | None = None) -> str: ... @overload def write(self, filepath: PathStrings, data: Any, encoding: str, format: Format) -> str: ... @overload def write(self, filepath: PathStrings, data: Any, * , format: Format) -> str: ... def write( self, filepath: PathStrings, data: str | bytes | Any, encoding: str | None = None, format: Format | None = None ) -> str: return self._write(filepath, data, encoding, format) def dump(self, filepath: PathStrings, data: Any, format: Format | None = None) -> str: if format is None: format = self._find_format(filepath) return self._write(filepath, data, format=format) def clone(self, source: str | Path, filepath: PathStrings | None = None) -> str: return self._clone(source, filepath) TempDirectory.__doc__ = ( " .. deprecated:: 11\n Use :class:`TempDir` instead.\n\n" + (_TempDir.__doc__ or "") ) class TempDir(_TempDir[Path]): @property def path(self) -> Path: return Path(self._path) # type: ignore[arg-type] def makedir(self, dirpath: PathStrings) -> Path: return Path(self._makedir(dirpath)) @overload def write(self, filepath: PathStrings, data: bytes) -> Path: ... @overload def write(self, filepath: PathStrings, data: str, encoding: str | None = None) -> Path: ... @overload def write(self, filepath: PathStrings, data: Any, encoding: str, format: Format) -> Path: ... @overload def write(self, filepath: PathStrings, data: Any, * , format: Format) -> Path: ... def write( self, filepath: PathStrings, data: str | bytes | Any, encoding: str | None = None, format: Format | None = None ) -> Path: return Path(self._write(filepath, data, encoding, format)) def dump(self, filepath: PathStrings, data: Any, format: Format | None = None) -> Path: return Path(self._write(filepath, data, format=format or self._find_format(filepath))) def clone(self, source: str | Path, filepath: PathStrings | None = None) -> Path: return Path(self._clone(source, filepath)) def tempdir( path: str | Path | None = None, *, ignore: Sequence[str] = (), encoding: str | None = None, cwd: bool = False, ) -> Callable[[Callable], Callable]: """ .. deprecated:: 11 Use :class:`TempDir` as a context manager, or create a `pytest fixture `_. A decorator for making a :class:`TempDirectory` available for the duration of a test function. All arguments and parameters are passed through to the :class:`TempDirectory` constructor. """ l = TempDirectory(path, ignore=ignore, encoding=encoding, cwd=cwd, create=False) return wrap(l.create, l.cleanup) testfixtures-12.4.0/src/testfixtures/twisted.py000066400000000000000000000160121525773641100217570ustar00rootroot00000000000000""" Tools for helping to test Twisted applications. """ from typing import Callable, Any, Self, TypeAlias import zope.interface from constantly import NamedConstant from twisted.logger import globalLogPublisher, formatEvent, LogLevel, ILogObserver, LogEvent from twisted.trial.unittest import TestCase from . import compare from .logcapture import Entry, LogCapture as _LogCapture, build_actual_extractor, AttributeSpec LEVEL_MAP: dict[NamedConstant, int] = { LogLevel.debug: 10, LogLevel.info: 20, LogLevel.warn: 30, LogLevel.error: 40, LogLevel.critical: 50, } LEVEL_NAME_MAP: dict[str, int] = {level.name: numeric for level, numeric in LEVEL_MAP.items()} def level_name(event: LogEvent) -> str: return event['log_level'].name.upper() LogEventAttributes: TypeAlias = AttributeSpec[LogEvent] @zope.interface.implementer(ILogObserver) class TwistedSource: """ A :class:`~testfixtures.logcapture.CaptureSource` for Twisted log events, for use with :class:`~testfixtures.LogCapture`. On :meth:`~testfixtures.logcapture.CaptureSource.install` all existing observers on ``twisted.logger.globalLogPublisher`` are replaced with this source; on :meth:`~testfixtures.logcapture.CaptureSource.uninstall` the original observers are restored. :param attributes: The sequence of attributes to return for each :class:`!LogEvent` or a callable that extracts :attr:`~testfixtures.logcapture.Entry.actual` from a record. If a sequence of attribute names is passed, for each item, a key of that name will be obtained from the :class:`!LogEvent`. If the key is missing, ``None`` will be used in its place. If a callable is passed, it will be called with the :class:`!LogEvent` and the value returned will be used as :attr:`~testfixtures.logcapture.Entry.actual`. :param level: The minimum log level to capture. Accepts an :class:`int`, a Twisted :class:`!LogLevel` constant, or a level name string such as ``'warn'``. The module-level constants :data:`DEBUG`, :data:`INFO`, :data:`WARN`, :data:`ERROR`, :data:`CRITICAL` can also be used. Defaults to ``0`` (capture everything). """ def __init__( self, attributes: LogEventAttributes = (level_name, formatEvent), level: int | str | NamedConstant = 0, ) -> None: self.attributes = attributes if isinstance(level, str): self.level: int = LEVEL_NAME_MAP[level.lower()] elif isinstance(level, int): self.level = level else: self.level = LEVEL_MAP[level] self._collector: Callable[[Entry], None] | None = None self._original_observers: list | None = None self._compute_actual = build_actual_extractor(attributes, self.extract_field) def extract_field(self, raw: LogEvent, attribute: str) -> Any: return raw.get(attribute) def __call__(self, event: LogEvent) -> None: if self._collector is not None: if self.level: event_level = LEVEL_MAP.get(event.get('log_level')) if event_level is None or event_level < self.level: return failure = event.get('log_failure') entry = Entry( raw=event, actual=self._compute_actual(event), level=LEVEL_MAP.get(event.get('log_level')), exception=failure.value if failure is not None else None, ) self._collector(entry) def install(self, collector: Callable[[Entry], None]) -> None: self._collector = collector self._original_observers = globalLogPublisher._observers globalLogPublisher._observers = [self] def uninstall(self) -> None: if self._original_observers is not None: globalLogPublisher._observers = self._original_observers self._original_observers = None DEFAULT_ATTRIBUTES = ('log_level', formatEvent) class LogCapture(_LogCapture): """ A helper for capturing stuff logged using Twisted's loggers. :param fields: The sequence of fields to return for each :class:`!LogEvent` or a callable that extracts :attr:`~testfixtures.logcapture.Entry.actual` from a record. If a sequence of attribute names is passed, for each item, a key of that name will be obtained from the :class:`!LogEvent`. If the key is missing, ``None`` will be used in its place. If a callable is passed, it will be called with the :class:`!LogEvent` and the value returned will be used as :attr:`~testfixtures.logcapture.Entry.actual`. """ def __init__( self, fields: LogEventAttributes = DEFAULT_ATTRIBUTES, install: bool = False ) -> None: super().__init__(TwistedSource(fields), install=install) @property def events(self) -> list[LogEvent]: """The list of raw Twisted log events captured.""" return [e.raw for e in self.entries] def check_failure_text(self, expected: str, index: int = -1, attribute: str = 'value') -> None: """ Check the string representation of an attribute of a logged ``Failure`` is as expected. :param expected: The expected string representation. :param index: The index into :attr:`events` where the failure should have been logged. :param attribute: The attribute of the failure of which to find the string representation. """ compare(expected, actual=str(getattr(self.events[index]['log_failure'], attribute))) def raise_logged_failure(self, start_index: int = 0) -> None: """ A debugging tool that raises the first failure encountered in captured logging. :param start_index: The index into :attr:`events` from where to start looking for failures. """ for event in self.events[start_index:]: failure = event.get('log_failure') if failure: raise failure @classmethod def make( cls, testcase: TestCase, fields: LogEventAttributes = DEFAULT_ATTRIBUTES ) -> Self: """ Instantiate, install and add a cleanup for a :class:`LogCapture`. :param testcase: This must be an instance of :class:`twisted.trial.unittest.TestCase`. Any other parameters are passed directly to the :class:`LogCapture` constructor. :return: The :class:`LogCapture` instantiated by this method. """ capture = cls(fields) capture.install() testcase.addCleanup(capture.uninstall) return capture #: Short reference to Twisted's ``LogLevel.debug`` DEBUG: NamedConstant = LogLevel.debug #: Short reference to Twisted's ``LogLevel.info`` INFO: NamedConstant = LogLevel.info #: Short reference to Twisted's ``LogLevel.warn`` WARN: NamedConstant = LogLevel.warn #: Short reference to Twisted's ``LogLevel.error`` ERROR: NamedConstant = LogLevel.error #: Short reference to Twisted's ``LogLevel.critical`` CRITICAL: NamedConstant = LogLevel.critical testfixtures-12.4.0/src/testfixtures/utils.py000066400000000000000000000063251525773641100214420ustar00rootroot00000000000000import sys from functools import wraps from textwrap import dedent from inspect import getfullargspec from types import TracebackType from typing import Callable, Sequence, Any, Generator, TypeVar, Generic, ParamSpec, TypeAlias from .mock import DEFAULT, _Sentinel def generator(*args: Any) -> Generator[Any, None, None]: """ A utility function for creating a generator that will yield the supplied arguments. """ for i in args: yield i T = TypeVar("T") class Wrapping(Generic[T]): attribute_name = None new = DEFAULT def __init__(self, before: Callable[[], T], after: Callable[[], None] | None): self.before, self.after = before, after def __enter__(self) -> T: return self.before() def __exit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: if self.after is not None: self.after() ExcInfo: TypeAlias = tuple[type[BaseException] | None, BaseException | None, TracebackType | None] P = ParamSpec("P") U = TypeVar("U") def wrap( before: Callable[[], T], after: Callable[[], None] | None = None ) -> Callable[[Callable[P, U]], Callable[P, U]]: """ A decorator that causes the supplied callables to be called before or after the wrapped callable, as appropriate. """ wrapping = Wrapping(before, after) def wrapper(func: Callable[P, U]) -> Callable[P, U]: if hasattr(func, 'patchings'): func.patchings.append(wrapping) return func @wraps(func) def patched(*args: P.args, **keywargs: P.kwargs) -> U: extra_args = [] entered_patchers = [] to_add = len(getfullargspec(func).args[len(args):]) added = 0 exc_info: ExcInfo = (None, None, None) try: for patching in patched.patchings: # type: ignore[attr-defined] arg = patching.__enter__() entered_patchers.append(patching) if patching.attribute_name is not None: keywargs.update(arg) elif patching.new is DEFAULT and added < to_add: extra_args.append(arg) added += 1 args += tuple(extra_args) # type: ignore[assignment] return func(*args, **keywargs) except: # Pass the exception to __exit__ exc_info = sys.exc_info() # re-raise the exception raise finally: for patching in reversed(entered_patchers): patching.__exit__(*exc_info) patched.patchings = [wrapping] # type: ignore[attr-defined] return patched return wrapper def extend_docstring(docstring: str, objs: Sequence) -> None: for obj in objs: obj.__doc__ = dedent(obj.__doc__) + docstring def indent(text: str, indent_size: int = 2) -> str: indented = [] for do_indent, line in enumerate(text.splitlines(True)): if do_indent: line = ' '*indent_size + line indented.append(line) return ''.join(indented) testfixtures-12.4.0/tests/000077500000000000000000000000001525773641100155245ustar00rootroot00000000000000testfixtures-12.4.0/tests/__init__.py000066400000000000000000000001001525773641100176240ustar00rootroot00000000000000import warnings warnings.simplefilter('default', ImportWarning) testfixtures-12.4.0/tests/configparser-read.txt000066400000000000000000000015001525773641100216540ustar00rootroot00000000000000Here's an example configuration file: .. topic:: example.cfg :class: write-file :: [A Section] dir=frob long: this value continues on the next line .. invisible-code-block: python # change to the temp directory import os original_dir = os.getcwd() os.chdir(tempdir.path) To parse this file using the :mod:`ConfigParser` module, you would do the following: .. code-block:: python from configparser import ConfigParser config = ConfigParser() config.read('example.cfg') The items in the section are now available as follows: >>> for name, value in sorted(config.items('A Section')): ... print('{0!r}:{1!r}'.format(name, value)) 'dir':'frob' 'long':'this value continues\non the next line' .. invisible-code-block: python # change out again import os os.chdir(original_dir) testfixtures-12.4.0/tests/configparser-write.txt000066400000000000000000000014071525773641100221010ustar00rootroot00000000000000.. invisible-code-block: python # change to the temp directory import os original_dir = os.getcwd() os.chdir(tempdir.path) To construct a configuration file using the :mod:`ConfigParser` module, you would do the following: .. code-block:: python from configparser import ConfigParser config = ConfigParser() config.add_section('A Section') config.set('A Section', 'dir', 'frob') f = open('example.cfg','w') config.write(f) f.close() The generated configuration file will be as follows: .. topic:: example.cfg :class: read-file :: [A Section] dir = frob .. config parser writes whitespace at the end, be careful when testing! .. invisible-code-block: python # change out again import os os.chdir(original_dir) testfixtures-12.4.0/tests/directory-contents.txt000066400000000000000000000013431525773641100221250ustar00rootroot00000000000000Here's an example piece of code that creates some files and directories: .. code-block:: python import os def spew(path): with open(os.path.join(path, 'root.txt'), 'wb') as f: f.write(b'root output') os.mkdir(os.path.join(path, 'subdir')) with open(os.path.join(path, 'subdir', 'file.txt'), 'wb') as f: f.write(b'subdir output') os.mkdir(os.path.join(path, 'subdir', 'logs')) This function is used as follows: >>> spew(tempdir.path) This will create the following files and directories:: root.txt subdir/ subdir/file.txt subdir/logs/ .. -> expected_listing .. invisible-code-block: python # check the listing was as expected tempdir.compare(expected_listing.strip().split('\n')) testfixtures-12.4.0/tests/sample1.py000066400000000000000000000035661525773641100174520ustar00rootroot00000000000000# NB: This file is used in the documentation, if you make changes, ensure # you update the line numbers in mocking.rst! """ A sample module containing the kind of code that testfixtures helps with testing """ from datetime import datetime, date from functools import wraps from typing import Any def str_now_1(): return str(datetime.now()) now = datetime.now def str_now_2(): return str(now()) def str_today_1(): return str(date.today()) today = date.today def str_today_2(): return str(today()) from time import time def str_time(): return str(time()) class X: def y(self): return "original y" @classmethod def aMethod(cls): return cls @staticmethod def bMethod(): return 2 def z(): return "original z" class SampleClassA: def __init__(self, *args): self.args = args class SampleClassB(SampleClassA): pass def a_function(): return (SampleClassA(1), SampleClassB(2), SampleClassA(3)) some_dict = dict( key='value', complex_key=[1, 2, 3], ) class Slotted: __slots__ = ['x', 'y'] def __init__(self, x, y): self.x = x self.y = y def wrap(fn: Any) -> Any: @wraps(fn) def inner(self: Any) -> Any: return fn(self) return inner class ClassWithWrappedMethod: @wrap def method(self) -> str: return 'original wrapped' class Outer: class Inner: def method(self) -> str: return 'original inner' @staticmethod def static() -> str: return 'original static' @classmethod def class_(cls) -> str: return 'original class' def _instantiate(cls: type) -> Any: return cls() # Module-level name Singleton bound to the instance: @_instantiate class Singleton: __slots__ = () def method(self) -> str: return 'original singleton' testfixtures-12.4.0/tests/sample2.py000066400000000000000000000006211525773641100174400ustar00rootroot00000000000000# NB: This file is used in the documentation, if you make changes, ensure # you update the line numbers in mocking.rst! """ A sample module containing the kind of code that testfixtures helps with testing """ from tests.sample1 import X, z try: from guppy import hpy guppy = True except ImportError: guppy = False def dump(path): if guppy: hpy().heap().stat.dump(path) testfixtures-12.4.0/tests/sample3.py000066400000000000000000000000531525773641100174400ustar00rootroot00000000000000from .sample1 import z SOME_CONSTANT = 42 testfixtures-12.4.0/tests/temporary_module.py000066400000000000000000000011131525773641100214610ustar00rootroot00000000000000import importlib import sys from contextlib import contextmanager from types import ModuleType from typing import Iterator from testfixtures import TempDir, Replace @contextmanager def temporary_module(name: str, content: str) -> Iterator[ModuleType]: with TempDir() as d: d.write(f'{name}.py', content) with Replace(target=sys.path, container=sys, name='path', replacement=[d.as_string()]): try: yield importlib.import_module(name) finally: if name in sys.modules: del sys.modules[name] testfixtures-12.4.0/tests/test_command.py000066400000000000000000000272231525773641100205610ustar00rootroot00000000000000import logging import sys from argparse import ArgumentParser from dataclasses import dataclass from functools import partial from typing import assert_type, Sequence from testfixtures.mock import Mock, call, _Call as Call import pytest from testfixtures import ( LogCapture, LoggingSource, OutputCapture, Replacer, ShouldAssert, compare, ) from testfixtures.command import ( AbstractRun, CheckResult, Command, Run as DefaultRun, check_logging, check_mock_calls, check_return_code, ) def sample_main_minimal() -> None: pass def sample_main_streams() -> None: return_code = int(sys.argv[1]) print('stdout 1', file=sys.stdout) print('stderr 1', file=sys.stderr) print('stdout 2', file=sys.stdout) print('stderr 2', file=sys.stderr) sys.exit(return_code) def setup_logging(level: int) -> None: ... def sample_main_logging() -> None: parser = ArgumentParser() parser.add_argument('-l', '--log-level', type=int) args, _ = parser.parse_known_args() setup_logging(args.log_level) logging.info(f'argv: {sys.argv!r}') def sample_main_loguru() -> None: from loguru import logger as loguru_logger loguru_logger.info('hello loguru') def sample_argparse_main() -> None: parser = ArgumentParser(prog='sample_argparse_main') parser.add_argument('--name', default='world') args = parser.parse_args() print(f'hello {args.name}') def check_argv_first() -> None: compare(sys.argv[0], expected='main') def test_minimal_success() -> None: Command(sample_main_minimal)().check() def test_result_typing() -> None: result = Command(sample_main_minimal)() # check __call__ type assert_type(result, DefaultRun) assert isinstance(result, DefaultRun) # check run type result = Command(sample_main_minimal).run() assert_type(result, DefaultRun) assert isinstance(result, DefaultRun) def test_output_matches() -> None: def main() -> None: print("hello") Command(main)().check("hello") def test_output_fails() -> None: def main() -> None: print("hello") with ShouldAssert("output: 'goodbye' (expected) != 'hello' (actual)"): Command(main)().check("goodbye") def test_text_systemexit() -> None: class SplitOutput(DefaultRun): @classmethod def setup_output(cls) -> OutputCapture: return OutputCapture(separate=True) def main() -> None: raise SystemExit("foo!") result = Command(main, runner=SplitOutput)() result.output.compare(stdout="", stderr="foo!") compare(result.return_code, expected=1) def test_multi_line_output_matches() -> None: def main() -> None: print("line 1") print("line 2") print("line 3") Command(main)().check(output="line 1\nline 2\nline 3") def test_multi_line_output_fails() -> None: def main() -> None: print("line 1") print("line 2") print("line 3") with ShouldAssert( 'output: \n--- expected\n+++ actual\n@@ -1,3 +1,3 @@\n line 1\n line 2\n-line X\n+line 3' ): Command(main)().check(output="line 1\nline 2\nline X") class KeepWhitespaceRun(DefaultRun): @classmethod def setup_output(cls) -> OutputCapture: return OutputCapture(strip_whitespace=False) def test_multi_line_output_strict_matches() -> None: def main() -> None: print("line 1") print("line 2") Command(main, runner=KeepWhitespaceRun)().check(output="line 1\nline 2\n") def test_multi_line_output_strict_fails() -> None: def main() -> None: print("line 1") print("line 2") with ShouldAssert('output: \n--- expected\n+++ actual\n@@ -1,2 +1,3 @@\n line 1\n line 2\n+'): Command(main, runner=KeepWhitespaceRun)().check(output="line 1\nline 2") def test_return_code_failure() -> None: runner = Command(sample_main_streams) with ShouldAssert( 'output: \n' '--- expected\n' '+++ actual\n' '@@ -1 +1,4 @@\n' '-\n' '+stdout 1\n' '+stderr 1\n' '+stdout 2\n' '+stderr 2\n\n' 'return_code: 0 (expected) != 1 (actual)' ): runner.run('1').check() def test_everything_failure() -> None: runner = Command(sample_main_streams) with ShouldAssert( 'output: \n' '--- expected\n' '+++ actual\n' '@@ -1 +1,4 @@\n' '-nope\n' '+stdout 1\n' '+stderr 1\n' '+stdout 2\n' '+stderr 2\n\n' 'return_code: 2 (expected) != 1 (actual)' ): runner.run('1').check(return_code=2, output='nope') def test_non_zero_return_code_expected() -> None: runner = Command(sample_main_streams) with ShouldAssert( 'output: \n' '--- expected\n' '+++ actual\n' '@@ -1 +1,4 @@\n' '-\n' '+stdout 1\n' '+stderr 1\n' '+stdout 2\n' '+stderr 2' ): runner.run('1').check(return_code=1) def test_stdlib_logging() -> None: Command(sample_main_logging).run('-l', '0').check( logging=[ ('INFO', "argv: ['sample_main_logging', '-l', '0']"), ] ) def test_capture_setup_logging_call() -> None: class MockingResult(DefaultRun): @classmethod def setup_mocks(cls, replace: Replacer) -> Mock: mock = Mock() replace.in_module(setup_logging, mock.setup_logging) return mock Command(sample_main_logging, runner=MockingResult).run('-l', '10').check( logging=[ ('INFO', "argv: ['sample_main_logging', '-l', '10']"), ], mock_calls=[call.setup_logging(10)], ) def test_systemexit_none() -> None: def main() -> None: raise SystemExit() result = Command(main)() compare(result.return_code, expected=1) def test_callable_without_name() -> None: Command(partial(check_argv_first))() def test_loguru_logging() -> None: pytest.importorskip("loguru") from testfixtures.loguru import LoguruSource class LoguruRun(DefaultRun): @classmethod def setup_logging(cls) -> LogCapture: return LogCapture(LoguruSource()) Command(sample_main_loguru, runner=LoguruRun)().check( logging=[('INFO', 'hello loguru')], ) def test_click() -> None: click = pytest.importorskip("click") @click.command() @click.option('--name', default='world') def sample_click(name: str) -> None: click.echo(f'hello {name}') Command(sample_click).run('--name', 'chris').check(output='hello chris') def test_argparse() -> None: Command(sample_argparse_main).run('--name', 'chris').check(output='hello chris') def test_customise_by_overriding_setup_and_check() -> None: @dataclass class Run(AbstractRun): @classmethod def setup_output(cls) -> OutputCapture: return OutputCapture(separate=True) @classmethod def setup_logging(cls) -> LogCapture: return LogCapture(LoggingSource('getMessage', level=logging.WARNING)) @classmethod def setup_mocks(cls, replace: Replacer) -> Mock: mocks = Mock() replace.in_module(setup_logging, mocks.setup_logging) return mocks def check( self, stdout: str = '', stderr: str = '', return_code: int = 0, ) -> None: output = self.output.compare(stdout=stdout, stderr=stderr, raises=False) self.check_results( CheckResult('output', output), check_return_code(return_code, self.return_code), check_logging(['warning message'], self.logging), check_mock_calls([call.setup_logging(logging.WARNING)], self.mocks), ) def sample_main() -> None: setup_logging(int(sys.argv[2])) print('stdout', file=sys.stdout) print('stderr', file=sys.stderr) logging.info('info message') logging.warning('warning message') sys.exit(int(sys.argv[1])) command = Command(sample_main, runner=Run) # happy path: result = command('0', str(logging.WARNING)) result.check(stdout='stdout', stderr='stderr') # check typing for call: assert_type(result, Run) assert isinstance(result, Run) # sad path: result = command.run('1', str(logging.INFO)) with ShouldAssert( 'output: dict not as expected:\n\n' 'values differ:\n' "'stderr': '' (expected) != 'stderr' (actual)\n" "'stdout': '' (expected) != 'stdout' (actual)\n\n" "While comparing ['stderr']: '' (expected) != 'stderr' (actual)\n\n" "While comparing ['stdout']: '' (expected) != 'stdout' (actual)\n\n" 'return_code: 0 (expected) != 1 (actual)\n\n' 'mock calls: sequence not as expected:\n\n' 'same:\n' '[]\n\n' 'expected:\n' '[call.setup_logging(30)]\n\n' 'actual:\n' '[call.setup_logging(20)]\n\n' 'While comparing [0]: \n' "'call.setup_logging(30)' (expected)\n" '!=\n' "'call.setup_logging(20)' (actual)" ): result.check() # check typing for run: assert_type(result, Run) assert isinstance(result, Run) def test_customise_checking_by_changing_check_defaults() -> None: @dataclass class Run(DefaultRun): @classmethod def setup_mocks(cls, replace: Replacer) -> Mock: mocks = Mock() replace.in_module(setup_logging, mocks.setup_logging) return mocks def check( self, output: str = 'default output', return_code: int = 1, logging: Sequence[tuple[str, str]] = (('INFO', 'default logging'),), mock_calls: Sequence[Call] = (call.setup_logging(logging.WARNING),), ) -> None: return super().check(output, return_code, logging, mock_calls) def sample_main() -> None: setup_logging(int(sys.argv[2])) print('default', file=sys.stdout, end=' ') print('output', file=sys.stderr) logging.info('default logging') sys.exit(int(sys.argv[1])) Command(sample_main, runner=Run).run('1', str(logging.WARNING)).check() def test_customise_by_overriding_check_methods() -> None: @dataclass class Run(DefaultRun): @classmethod def setup_mocks(cls, replace: Replacer) -> Mock: mocks = Mock() replace.in_module(setup_logging, mocks.setup_logging) return mocks @classmethod def setup_output(cls) -> OutputCapture: return OutputCapture(separate=True) @staticmethod def check_output(expected: str, output: OutputCapture) -> CheckResult: return CheckResult('output', output.compare(stdout=expected, stderr='', raises=False)) @staticmethod def check_return_code(expected: int, return_code: int) -> CheckResult: return check_return_code(expected, actual=return_code + 10) @staticmethod def check_logging( expected: Sequence[tuple[str, ...] | str], logging: LogCapture ) -> CheckResult: return check_logging((('INFO', 'command starting'),) + tuple(expected), logging) @staticmethod def check_mock_calls(expected: Sequence[Call], mocks: Mock) -> CheckResult: return check_mock_calls([call.setup_logging(logging.INFO), *expected], mocks) def sample_main() -> None: setup_logging(logging.INFO) logging.info('command starting') print('some output') sys.exit(int(sys.argv[1])) command = Command(sample_main, runner=Run) result = command('42', str(logging.WARNING)) result.check(output='some output', return_code=52) testfixtures-12.4.0/tests/test_compare.py000066400000000000000000002607361525773641100206010ustar00rootroot00000000000000import json import re import uuid from abc import ABC from collections import namedtuple from dataclasses import dataclass from datetime import date, datetime, time from decimal import Decimal from enum import StrEnum, auto from functools import partial from re import compile from typing import TypeVar, Generic, Any from unittest import TestCase from uuid import uuid4 from testfixtures import ( Comparison as C, MappingComparison, Replace, SequenceComparison, ShouldRaise, compare, generator, register, safe_pformat, safe_repr, singleton, ) from testfixtures.comparers import ( compare_sequence, compare_object, compare_text, merge_ignored_attributes, ) from testfixtures.comparing import registry from testfixtures.comparison import like from testfixtures.compat import PY_312_PLUS from testfixtures.mock import Mock, call from testfixtures.shouldraise import ShouldAssert from tests.sample1 import Slotted hexaddr = compile('0x[0-9A-Fa-f]+') def hexsub(raw): return hexaddr.sub('...', raw) call_list_repr = repr(Mock().mock_calls.__class__) marker = object() _compare = compare class Lazy: def __init__(self, message): self.message = message def __str__(self): return self.message class Strict: def __init__(self, value): self.value = value def __eq__(self, other): return isinstance(other, Strict) and self.value == other.value def __hash__(self): return hash(self.value) @dataclass class Item: value: int def __repr__(self): return f"" @dataclass class Sample: id: Item part: Item x: str class Broken: # An object whose __repr__ raises on demand. marker = '' def __init__(self, label='broken', exc=None): self.label = label self._exc = ValueError('boom!') if exc is None else exc def __repr__(self): raise self._exc class HashableBroken(Broken): # Hashable + comparable so it can be used as a dict key or set member. marker = '' def __init__(self, label='broken', exc=None): super().__init__(label, exc) def __hash__(self): return hash(self.label) def __eq__(self, other): return isinstance(other, HashableBroken) and self.label == other.label class TestSampleClasses: # Test coverage for the helper objects above, to make sure the things test below rely on # behave as expected: def test_strict_eq(self) -> None: @dataclass class Other: value: Any assert not Strict('foo') == Other('foo') assert not Strict('foo') == Strict('bar') assert hash(Strict('foo')) == hash('foo') def test_hashable_broken_eq(self) -> None: @dataclass class Other: label: Any assert not HashableBroken('foo') == Other('foo') assert not HashableBroken('foo') == Strict('bar') def check_raises(x_=marker, y_=marker, message=None, regex=None, compare=compare, **kw): args = [] for value in x_, y_: if value is not marker: args.append(value) try: compare(*args, **kw) except Exception as e: if not isinstance(e, AssertionError): # pragma: no cover raise actual = hexsub(e.args[0]) if message is not None: # handy for debugging, but can't be relied on for tests! _compare(actual, expected=message, show_whitespace=True) assert actual == message else: if not regex.match(actual): # pragma: no cover raise AssertionError( '%r did not match %r' % (actual, regex.pattern) ) else: raise AssertionError('No exception raised!') class CompareHelper: def check_raises(self, *args, **kw): check_raises(*args, **kw) class TestCompare(CompareHelper, TestCase): def test_object_same(self): o = object() compare(o, o) def test_object_diff(self): self.check_raises( object(), object(), 'not equal:\n\n' ) def test_different_types(self): self.check_raises('x', 1, "'x' != 1") def test_number_same(self): compare(1, 1) def test_number_different(self): self.check_raises(1, 2, '1 != 2') def test_decimal_different(self): self.check_raises(Decimal(1), Decimal(2), "Decimal('1') != Decimal('2')") def test_different_with_labels(self): self.check_raises(1, 2, '1 (expected) != 2 (actual)', x_label='expected', y_label='actual') def test_string_same(self): compare('x', 'x') def test_unicode_string_different(self): self.check_raises('a', b'b', "'a' != b'b'") def test_bytes_different(self): expected = ( "\n" "b'12345678901'\n" '!=\n' "b'12345678902'" ) self.check_raises( b'12345678901', b'12345678902', expected ) def test_bytes_same_strict(self): compare(actual=b'', expected=b'', strict=True) def test_moar_bytes_different(self): self.check_raises( actual=b'{"byte_pound":"b\'\\\\xa3\'"}', expected=b'{"byte_pound":"b\\\'\\xa3\'"}', message = ( "\n" "b'{\"byte_pound\":\"b\\\\\\'\\\\xa3\\\'\"}' (expected)\n" '!=\n' "b'{\"byte_pound\":\"b\\\'\\\\\\\\xa3\\\'\"}' (actual)" ) ) def test_string_diff_short(self): self.check_raises( '\n'+('x'*9), '\n'+('y'*9), "'\\nxxxxxxxxx' != '\\nyyyyyyyyy'" ) def test_string_diff_long(self): self.check_raises( 'x'*11, 'y'*11, "\n'xxxxxxxxxxx'\n!=\n'yyyyyyyyyyy'" ) def test_string_diff_long_newlines(self): self.check_raises( 'x'*5+'\n'+'y'*5, 'x'*5+'\n'+'z'*5, "\n--- first\n+++ second\n@@ -1,2 +1,2 @@\n xxxxx\n-yyyyy\n+zzzzz" ) def test_string_diff_short_labels(self): self.check_raises( '\n'+('x'*9), '\n'+('y'*9), "'\\nxxxxxxxxx' (expected) != '\\nyyyyyyyyy' (actual)", x_label='expected', y_label='actual' ) def test_string_diff_long_labels(self): self.check_raises( 'x'*11, 'y'*11, "\n'xxxxxxxxxxx' (expected)\n!=\n'yyyyyyyyyyy' (actual)", x_label='expected', y_label='actual' ) def test_string_diff_long_newlines_labels(self): self.check_raises( 'x'*5+'\n'+'y'*5, 'x'*5+'\n'+'z'*5, "\n--- expected\n+++ actual\n" "@@ -1,2 +1,2 @@\n xxxxx\n-yyyyy\n+zzzzz", x_label='expected', y_label='actual' ) def test_exception_same_object(self): e = ValueError('some message') compare(e, e) def test_exception_same_c_wrapper(self): e1 = ValueError('some message') e2 = ValueError('some message') compare(C(e1), e2) def test_exception_different_object(self): e1 = ValueError('some message') e2 = ValueError('some message') compare(e1, e2) def test_exception_different_object_c_wrapper(self): e1 = ValueError('some message') e2 = ValueError('some message') compare(C(e1), e2) def test_exception_diff(self): e1 = ValueError('some message') e2 = ValueError('some other message') self.check_raises( e1, e2, "not equal:\nValueError('some message')\nValueError('some other message')" ) def test_exception_diff_c_wrapper(self): e1 = ValueError('some message') e2 = ValueError('some other message') self.check_raises( C(e1), e2, ("not equal:\n" "\n" "\n" "attributes differ:\n" "'args': ('some message',) (Comparison) " "!= ('some other message',) (actual)\n" "\n" "ValueError('some other message')" ) ) def test_two_c_wrappers(self): self.check_raises( C(ValueError, args=(1,)), C(TypeError, args=(2,)), ("not equal:\n" "" "wrong type: testfixtures.comparison.Comparison\n" "args: (2,)" ) ) def test_two_sequence_comparisons(self): self.check_raises( SequenceComparison(1, 2), SequenceComparison(1, 3), ("not equal:\n" "" "bad type\n" "1, 3" ) ) def test_sequence_long(self): self.check_raises( ['quite a long string 1', 'quite a long string 2', 'quite a long string 3', 'quite a long string 4', 'quite a long string 5', 'quite a long string 6', 'quite a long string 7', 'quite a long string 8'], ['quite a long string 1', 'quite a long string 2', 'quite a long string 3', 'quite a long string 4', 'quite a long string 9', 'quite a long string 10', 'quite a long string 11', 'quite a long string 12'], "sequence not as expected:\n\n" "same:\n" "['quite a long string 1',\n" " 'quite a long string 2',\n" " 'quite a long string 3',\n" " 'quite a long string 4']\n\n" "first:\n" "['quite a long string 5',\n" " 'quite a long string 6',\n" " 'quite a long string 7',\n" " 'quite a long string 8']\n\n" "second:\n" "['quite a long string 9',\n" " 'quite a long string 10',\n" " 'quite a long string 11',\n" " 'quite a long string 12']\n" "\n" "While comparing [4]: \n" "'quite a long string 5'\n" "!=\n" "'quite a long string 9'" ) def test_sequence_different_labels_supplied(self): self.check_raises( [1, 2, 3], [1, 2, 4], "sequence not as expected:\n\n" "same:\n" "[1, 2]\n\n" "expected:\n" "[3]\n\n" "actual:\n" "[4]", x_label='expected', y_label='actual', ) def test_list_same(self): compare([1, 2, 3], [1, 2, 3]) def test_list_different(self): self.check_raises( [1, 2, 3], [1, 2, 4], "sequence not as expected:\n\n" "same:\n" "[1, 2]\n\n" "first:\n" "[3]\n\n" "second:\n" "[4]" ) def test_list_different_float(self): self.check_raises( [1, 2, 3.0], [1, 2, 4.0], "sequence not as expected:\n\n" "same:\n" "[1, 2]\n\n" "first:\n" "[3.0]\n\n" "second:\n" "[4.0]" ) def test_list_different_decimal(self): self.check_raises( [1, 2, Decimal(3)], [1, 2, Decimal(4)], "sequence not as expected:\n\n" "same:\n" "[1, 2]\n\n" "first:\n" "[Decimal('3')]\n\n" "second:\n" "[Decimal('4')]" ) def test_list_totally_different(self): self.check_raises( [1], [2], "sequence not as expected:\n\n" "same:\n" "[]\n\n" "first:\n" "[1]\n\n" "second:\n" "[2]" ) def test_list_first_shorter(self): self.check_raises( [1, 2], [1, 2, 3], "sequence not as expected:\n\n" "same:\n[1, 2]\n\n" "first:\n[]\n\n" "second:\n[3]" ) def test_list_second_shorter(self): self.check_raises( [1, 2, 3], [1, 2], "sequence not as expected:\n\n" "same:\n[1, 2]\n\n" "first:\n[3]\n\n" "second:\n[]" ) def test_dict_same(self): compare(dict(x=1), dict(x=1)) def test_dict_first_missing_keys(self): self.check_raises( dict(), dict(z=3), "dict not as expected:\n" "\n" "in second but not first:\n" "'z': 3" ) def test_dict_second_missing_keys(self): self.check_raises( dict(z=3), dict(), "dict not as expected:\n" "\n" "in first but not second:\n" "'z': 3" ) def test_dict_values_different(self): self.check_raises( dict(x=1), dict(x=2), "dict not as expected:\n" "\n" "values differ:\n" "'x': 1 != 2" ) def test_dict_identical_non_matching_ints(self): self.check_raises( dict(x=1, y=1), dict(x=2, y=2), "dict not as expected:\n" "\n" "values differ:\n" "'x': 1 != 2\n" "'y': 1 != 2" ) def test_dict_identical_non_matching_floats(self): self.check_raises( dict(x=1.0, y=1.0), dict(x=2.0, y=2.0), "dict not as expected:\n" "\n" "values differ:\n" "'x': 1.0 != 2.0\n" "'y': 1.0 != 2.0" ) def test_dict_identical_none_matching_nones_and_ones(self): self.check_raises( { 'foo': None, 'baz': None, }, { 'foo': 1, 'baz': 1, }, "dict not as expected:\n" "\n" 'values differ:\n' "'baz': None != 1\n" "'foo': None != 1\n" '\n' "While comparing ['baz']: None != 1\n" "\n" "While comparing ['foo']: None != 1" ) def test_dict_labels_specified(self): self.check_raises( dict(x=1, y=2), dict(x=2, z=3), "dict not as expected:\n" "\n" "in expected but not actual:\n" "'y': 2\n" "\n" "in actual but not expected:\n" "'z': 3\n" "\n" "values differ:\n" "'x': 1 (expected) != 2 (actual)", x_label='expected', y_label='actual' ) def test_dict_tuple_keys_same_value(self): compare({(1, 2): None}, {(1, 2): None}) def test_dict_tuple_keys_different_value(self): self.check_raises( {(1, 2): 3}, {(1, 2): 42}, "dict not as expected:\n" "\n" "values differ:\n" "(1, 2): 3 != 42" ) def test_dict_full_diff(self): self.check_raises( dict(x=1, y=2, a=4), dict(x=1, z=3, a=5), "dict not as expected:\n" "\n" 'same:\n' "['x']\n" "\n" "in first but not second:\n" "'y': 2\n" '\n' "in second but not first:\n" "'z': 3\n" '\n' "values differ:\n" "'a': 4 != 5" ) def test_dict_consistent_ordering(self): self.check_raises( dict(xa=1, xb=2, ya=1, yb=2, aa=3, ab=4), dict(xa=1, xb=2, za=3, zb=4, aa=5, ab=5), "dict not as expected:\n" "\n" 'same:\n' "['xa', 'xb']\n" "\n" "in first but not second:\n" "'ya': 1\n" "'yb': 2\n" '\n' "in second but not first:\n" "'za': 3\n" "'zb': 4\n" '\n' "values differ:\n" "'aa': 3 != 5\n" "'ab': 4 != 5" ) def test_dict_consistent_ordering_types_same(self): self.check_raises( {None: 1, 6: 2, 1: 3}, {None: 1, 6: 2, 1: 4}, "dict not as expected:\n" "\n"+ 'same:\n'+ "[6, None]\n"+ "\n" "values differ:\n" "1: 3 != 4" ) def test_dict_consistent_ordering_types_x_not_y(self): self.check_raises( {None: 1, 3: 2}, {}, "dict not as expected:\n" "\n" "in first but not second:\n" "3: 2\n" "None: 1" ) def test_dict_consistent_ordering_types_y_not_x(self): self.check_raises( {}, {None: 1, 3: 2}, "dict not as expected:\n" "\n" "in second but not first:\n" "3: 2\n" "None: 1" ) def test_dict_consistent_ordering_types_value(self): self.check_raises( {None: 1, 6: 2}, {None: 3, 6: 4}, "dict not as expected:\n" "\n" "values differ:\n" "6: 2 != 4\n" "None: 1 != 3" ) def test_set_same(self): compare(set([1]), set([1])) def test_set_first_missing_keys(self): self.check_raises( set(), set([3]), "set not as expected:\n" "\n" "in second but not first:\n" "[3]\n" '\n' ) def test_set_second_missing_keys(self): self.check_raises( set([3]), set(), "set not as expected:\n" "\n" "in first but not second:\n" "[3]\n" '\n' ) def test_set_full_diff(self): self.check_raises( set([1, 2, 4]), set([1, 3, 5]), "set not as expected:\n" "\n" "in first but not second:\n" "[2, 4]\n" '\n' "in second but not first:\n" "[3, 5]\n" '\n' ) def test_set_type_ordering(self): self.check_raises( {None, 1}, {'', 2}, "set not as expected:\n" "\n" "in first but not second:\n" "[1, None]\n" '\n' "in second but not first:\n" "['', 2]\n" '\n' ) def test_set_labels(self): self.check_raises( set([1, 2, 4]), set([1, 3, 5]), "set not as expected:\n" "\n" "in expected but not actual:\n" "[2, 4]\n" '\n' "in actual but not expected:\n" "[3, 5]\n" '\n', x_label='expected', y_label='actual', ) def test_frozen_set_different(self): class S(StrEnum): a = auto() b = auto() c = auto() self.check_raises( frozenset([S.a, S.b]), frozenset([S.b, S.c]), 'frozenset not as expected:\n\n' 'in first but not second:\n' "[]\n\n" 'in second but not first:\n' "[]\n\n" ) def test_tuple_same(self): compare((1, 2, 3), (1, 2, 3)) def test_tuple_different(self): self.check_raises( (1, 2, 3), (1, 2, 4), "sequence not as expected:\n\n" "same:\n(1, 2)\n\n" "first:\n(3,)\n\n" "second:\n(4,)" ) def test_tuple_totally_different(self): self.check_raises( (1, ), (2, ), "sequence not as expected:\n\n" "same:\n()\n\n" "first:\n(1,)\n\n" "second:\n(2,)" ) def test_tuple_first_shorter(self): self.check_raises( (1, 2), (1, 2, 3), "sequence not as expected:\n\n" "same:\n(1, 2)\n\n" "first:\n()\n\n" "second:\n(3,)" ) def test_tuple_second_shorter(self): self.check_raises( (1, 2, 3), (1, 2), "sequence not as expected:\n\n" "same:\n(1, 2)\n\n" "first:\n(3,)\n\n" "second:\n()" ) def test_generator_same(self): compare(generator(1, 2, 3), generator(1, 2, 3)) def test_generator_different(self): self.check_raises( generator(1, 2, 3), generator(1, 2, 4), "sequence not as expected:\n\n" "same:\n(1, 2)\n\n" "first:\n(3,)\n\n" "second:\n(4,)" ) def test_generator_totally_different(self): self.check_raises( generator(1, ), generator(2, ), "sequence not as expected:\n\n" "same:\n()\n\n" "first:\n(1,)\n\n" "second:\n(2,)" ) def test_generator_first_shorter(self): self.check_raises( generator(1, 2), generator(1, 2, 3), "sequence not as expected:\n\n" "same:\n(1, 2)\n\n" "first:\n()\n\n" "second:\n(3,)" ) def test_generator_second_shorted(self): self.check_raises( generator(1, 2, 3), generator(1, 2), "sequence not as expected:\n\n" "same:\n(1, 2)\n\n" "first:\n(3,)\n\n" "second:\n()" ) def test_nested_generator_different(self): self.check_raises( generator(1, 2, generator(3), 4), generator(1, 2, generator(3), 5), "sequence not as expected:\n" "\n" "same:\n" "(1, 2, )\n" "\n" "first:\n" "(4,)\n" "\n" "second:\n" "(5,)" ) def test_nested_generator_tuple_left(self): compare( generator(1, 2, (3, ), 4), generator(1, 2, generator(3), 4), ) def test_nested_generator_tuple_right(self): compare( generator(1, 2, generator(3), 4), generator(1, 2, (3, ), 4), ) def test_sequence_and_generator(self): compare((1, 2, 3), generator(1, 2, 3)) def test_sequence_and_generator_strict(self): expected = compile( r"\(1, 2, 3\) \(<(class|type) 'tuple'>\) \(expected\) != " r"\) \(actual\)" ) self.check_raises( (1, 2, 3), generator(1, 2, 3), regex=expected, strict=True, x_label='expected', y_label='actual', ) def test_generator_and_sequence(self): compare(generator(1, 2, 3), (1, 2, 3)) def test_iterable_with_iterable_same(self): compare(range(1, 4), range(1, 4)) def test_iterable_with_iterable_different(self): self.check_raises( range(1, 4), range(1, 3), "sequence not as expected:\n" "\n" "same:\n" "(1, 2)\n" "\n" "first:\n" "(3,)\n" "\n" "second:\n" "()" ) def test_iterable_and_generator(self): compare(range(1, 4), generator(1, 2, 3)) def test_iterable_and_generator_strict(self): expected = compile( r"range\(1, 4\) \(<(class|type) 'x?range'>\) != " r"\)" ) self.check_raises( range(1, 4), generator(1, 2, 3), regex=expected, strict=True, ) def test_generator_and_iterable(self): compare(generator(1, 2, 3), range(1, 4)) def test_tuple_and_list(self): compare((1, 2, 3), [1, 2, 3]) def test_tuple_and_list_strict(self): expected = ("(1, 2, 3) () != " "[1, 2, 3] ()") self.check_raises( (1, 2, 3), [1, 2, 3], expected, strict=True ) def test_float_subclass_strict(self): class TestFloat(float): pass compare(TestFloat(0.75), TestFloat(0.75), strict=True) def test_float_int_strict(self): self.check_raises( float(7.0), int(7), "7.0 () != 7 ()", strict=True, ) def test_bug(self): self.check_raises( generator(1.0), generator(1), 'sequence not as expected:\n' '\n' 'same:\n' '()\n' '\n' 'first:\n' '(1.0,)\n' '\n' 'second:\n' '(1,)\n' '\n' "While comparing [0]: 1.0 () != 1 ()", strict=True, ) def test_old_style_classes_same(self): class X: pass compare(X, X) def test_default_style_classes_different(self): expected = ( "not equal:\n" ".X'>\n" ".Y'>" ) class X: pass class Y: pass self.check_raises(X, Y, expected) def test_new_style_classes_same(self): class X: pass compare(X, X) def test_new_style_classes_different(self): expected = ( "not equal:\n" ".X'>\n" ".Y'>" ) class X: pass class Y: pass self.check_raises(X, Y, expected) def test_show_whitespace(self): # does nothing! ;-) self.check_raises( ' x \n\r', ' x \n \t', "' x \\n\\r' != ' x \\n \\t'", show_whitespace=True ) def test_show_whitespace_long(self): self.check_raises( "\t \n '", '\r \n ', '\n--- first\n' '+++ second\n' '@@ -1,2 +1,2 @@\n' '-\'\\t \\n\'\n' '-" \'"\n' '+\'\\r \\n\'\n' '+\' \'', show_whitespace=True ) def test_show_whitespace_equal(self): compare('x', 'x', show_whitespace=True) def test_show_whitespace_not_used_because_of_other_difference(self): self.check_raises( (1, 'a'), (2, 'b'), "sequence not as expected:\n" "\n" "same:\n" "()\n" "\n" "first:\n" "(1, 'a')\n" "\n" "second:\n" "(2, 'b')", show_whitespace=False ) def test_include_trailing_whitespace(self): self.check_raises( ' x \n', ' x \n', "' x \\n' != ' x \\n'" ) def test_ignore_trailing_whitespace(self): compare(' x \t\n', ' x\t \n', trailing_whitespace=False) def test_ignore_trailing_whitespace_non_string(self): self.check_raises( 1, '', "1 != ''", trailing_whitespace=False ) def test_ignore_trailing_whitespace_but_respect_leading_whitespace(self): # NB: careful: this strips off the last newline too # DON'T use if you care about that! self.check_raises( 'a\n b\n c\n', 'a\nb\nc\n', "'a\\n b\\n c' != 'a\\nb\\nc'", trailing_whitespace=False ) def test_include_blank_lines(self): self.check_raises( '\n \n', '\n ', "'\\n \\n' != '\\n '" ) def test_ignore_blank_lines(self): compare(""" a \t b """, ' a\nb', blanklines=False) def test_ignore_blank_lines_non_string(self): self.check_raises( 1, '', "1 != ''", blanklines=False ) def test_supply_comparer(self): def compare_dict(x, y, context, foo='bar'): self.assertEqual(x, {1: 1}) self.assertEqual(y, {2: 2}) self.assertEqual(foo, 'bar') return 'not equal' with ShouldAssert('not equal'): compare({1: 1}, {2: 2}, foo='bar', comparers={dict: compare_dict}) def test_register_more_specific(self): class_ = namedtuple('Test', 'x') with ShouldAssert('compare class_'): compare(class_(1), class_(2), comparers={ tuple: Mock(return_value='compare tuple'), class_: Mock(return_value='compare class_') }) def test_extra_comparers_leave_existing(self): class MyObject: def __init__(self, name): self.name = name def __repr__(self): return 'MyObject instance' def compare_my_object(x, y, context): return '%s != %s' % (x.name, y.name) with registry({list: compare_sequence, str: compare_text}): self.check_raises( [1, MyObject('foo')], [1, MyObject('bar')], "sequence not as expected:\n" "\n" "same:\n" "[1]\n" "\n" "first:\n" "[MyObject instance]\n" "\n" "second:\n" "[MyObject instance]\n" "\n" "While comparing [1]: foo != bar", comparers={MyObject: compare_my_object} ) # check the compare call did not mutate the global registry: self.check_raises( [MyObject('foo')], [MyObject('bar')], "sequence not as expected:\n" "\n" "same:\n[]\n" "\n" "first:\n" "[MyObject instance]\n" "\n" "second:\n" "[MyObject instance]\n" "\n" "While comparing [0]: MyObject not as expected:\n" "\n" "attributes differ:\n" "'name': 'foo' != 'bar'\n" "\n" "While comparing [0].name: 'foo' != 'bar'", ) def test_list_subclass(self): class MyList(list): pass a_list = MyList([1]) b_list = MyList([2]) self.check_raises( a_list, b_list, "sequence not as expected:\n\n" "same:\n[]\n\n" "first:\n[1]\n\n" "second:\n[2]" ) def test_same_object_strict_okay(self): m = object() compare(m, m, strict=True) def test_strict_comparer_supplied(self): compare_obj = Mock() compare_obj.return_value = 'not equal' self.check_raises( object(), object(), "not equal", strict=True, comparers={object: compare_obj}, ) def test_strict_default_comparer(self): class MyList(list): pass # default comparer used! self.check_raises( MyList((1, 2, 3)), MyList((1, 2, 4)), "sequence not as expected:\n" "\n" "same:\n" "[1, 2]\n" "\n" "first:\n" "[3]\n" "\n" "second:\n" "[4]", strict=True, ) def test_list_subclass_strict(self): m = Mock() m.aCall() self.check_raises( [call.aCall()], m.method_calls, ("[call.aCall()] () != [call.aCall()] " f"({call_list_repr})"), strict=True, ) def test_list_subclass_long_strict(self): m = Mock() m.call('X'*20) self.check_raises( [call.call('Y'*20)], m.method_calls, ("[call.call('YYYYYYYYYYYYYYYYYY... " "() != " "[call.call('XXXXXXXXXXXXXXXXXX... " f"({call_list_repr})"), strict=True, ) def test_prefix(self): self.check_raises(1, 2, 'wrong number of orders: 1 != 2', prefix='wrong number of orders') def test_prefix_multiline(self): self.check_raises( 'x'*5+'\n'+'y'*5, 'x'*5+'\n'+'z'*5, "file content: \n--- first\n+++ second\n" "@@ -1,2 +1,2 @@\n xxxxx\n-yyyyy\n+zzzzz", prefix='file content' ) def test_prefix_callable(self): with ShouldAssert('foo: 1 != 2'): compare(1, 2, prefix=lambda: 'foo') def test_prefix_stringable(self): with ShouldAssert('foo: 1 != 2'): compare(1, 2, prefix=Lazy('foo')) def test_prefix_lazy(self): compare(2, 2, prefix=Mock(side_effect=Exception('boom!'))) def test_suffix(self): self.check_raises( 1, 2, '1 != 2\n' 'additional context', suffix='additional context', ) def test_suffix_callable(self): with ShouldAssert('1 != 2\n3'): compare(1, 2, suffix=lambda: 3) def test_suffix_stringable(self): with ShouldAssert('1 != 2\nfoo'): compare(1, 2, suffix=Lazy('foo')) def test_suffix_lazy(self): compare(2, 2, suffix=Mock(side_effect=Exception('boom!'))) def test_labels_multiline(self): self.check_raises( 'x'*5+'\n'+'y'*5, 'x'*5+'\n'+'z'*5, "\n--- expected\n+++ actual\n" "@@ -1,2 +1,2 @@\n xxxxx\n-yyyyy\n+zzzzz", x_label='expected', y_label='actual' ) def test_generator_with_non_generator(self): self.check_raises( generator(1, 2, 3), None, 'not equal:\n\nNone', ) def test_generator_with_buggy_generator(self): def bad_gen(): yield 1 # raising a TypeError here is important :-/ raise TypeError('foo') with ShouldRaise(TypeError('foo')): compare(generator(1, 2, 3), bad_gen()) def test_nested_dict_tuple_values_different(self): self.check_raises( dict(x=(1, 2, 3)), dict(x=(1, 2, 4)), "dict not as expected:\n" "\n" "values differ:\n" "'x': (1, 2, 3) != (1, 2, 4)\n" '\n' "While comparing ['x']: sequence not as expected:\n" "\n" "same:\n" "(1, 2)\n" "\n" "first:\n" "(3,)\n" "\n" "second:\n" "(4,)" ) def test_nested_dict_different(self): self.check_raises( dict(x=dict(y=1)), dict(x=dict(y=2)), "dict not as expected:\n" "\n" "values differ:\n" "'x': {'y': 1} != {'y': 2}\n" '\n' "While comparing ['x']: dict not as expected:\n" "\n" "values differ:\n" "'y': 1 != 2" ) def test_nested_dict_empty_but_same(self): compare(dict(x=dict()), dict(x=dict()), ignore_eq=True) def test_nested_dict_empty_with_keys(self): compare(dict(x=dict(x=1)), dict(x=dict(x=1)), ignore_eq=True) def test_tuple_list_different(self): self.check_raises( (1, [2, 3, 5]), (1, [2, 4, 5]), "sequence not as expected:\n" "\n" "same:\n" "(1,)\n" "\n" "first:\n" "([2, 3, 5],)\n" "\n" "second:\n" "([2, 4, 5],)\n" "\n" "While comparing [1]: sequence not as expected:\n" "\n" "same:\n" "[2]\n" "\n" "first:\n" "[3, 5]\n" "\n" "second:\n" "[4, 5]" ) def test_tuple_long_strings_different(self): self.check_raises( (1, 2, "foo\nbar\nbaz\n", 4), (1, 2, "foo\nbob\nbaz\n", 4), "sequence not as expected:\n" "\n" "same:\n" "(1, 2)\n" "\n" "first:\n" "('foo\\nbar\\nbaz\\n', 4)\n" "\n" "second:\n" "('foo\\nbob\\nbaz\\n', 4)\n" "\n" "While comparing [2]: \n" "--- first\n" "+++ second\n" "@@ -1,4 +1,4 @@\n" # check that show_whitespace bubbles down " 'foo\\n'\n" "-'bar\\n'\n" "+'bob\\n'\n" " 'baz\\n'\n" " ''", show_whitespace=True ) def test_dict_multiple_differences(self): self.check_raises( dict(x=(1, 2, 3), y=(4, 5, 6, )), dict(x=(1, 2, 4), y=(4, 5, 7, )), "dict not as expected:\n" "\n" "values differ:\n" "'x': (1, 2, 3) != (1, 2, 4)\n" "'y': (4, 5, 6) != (4, 5, 7)\n" "\n" "While comparing ['x']: sequence not as expected:\n" "\n" "same:\n" "(1, 2)\n" "\n" "first:\n" "(3,)\n" "\n" "second:\n" "(4,)\n" "\n" "While comparing ['y']: sequence not as expected:\n" "\n" "same:\n" "(4, 5)\n" "\n" "first:\n" "(6,)\n" "\n" "second:\n" "(7,)" ) def test_deep_breadcrumbs(self): obj1 = singleton('obj1') obj2 = singleton('obj2') gen1 = generator(obj1, obj2) gen2 = generator(obj1, ) # dict -> list -> tuple -> generator self.check_raises( dict(x=[1, ('a', 'b', gen1), 3], y=[3, 4]), dict(x=[1, ('a', 'b', gen2), 3], y=[3, 4]), ( "dict not as expected:\n" "\n" "same:\n" "['y']\n" "\n" "values differ:\n" "'x': [1, ('a', 'b', {gen1}), 3] != [1, ('a', 'b', {gen2}), 3]" "\n\n" "While comparing ['x']: sequence not as expected:\n" "\n" "same:\n" "[1]\n" "\n" "first:\n" "[('a', 'b', {gen1}), 3]\n" "\n" "second:\n" "[('a', 'b', {gen2}), 3]\n" "\n" "While comparing ['x'][1]: sequence not as expected:\n" "\n" "same:\n" "('a', 'b')\n" "\n" "first:\n" "({gen1},)\n" "\n" "second:\n" "({gen2},)\n" "\n" "While comparing ['x'][1][2]: sequence not as expected:\n" "\n" "same:\n" "(,)\n" "\n" "first:\n" "(,)\n" "\n" "second:\n" "()" ).format(gen1=hexsub(repr(gen1)), gen2=hexsub(repr(gen2))) ) def test_nested_labels(self): obj1 = singleton('obj1') obj2 = singleton('obj2') gen1 = generator(obj1, obj2) gen2 = generator(obj1, ) # dict -> list -> tuple -> generator self.check_raises( dict(x=[1, ('a', 'b', gen1), 3], y=[3, 4]), dict(x=[1, ('a', 'b', gen2), 3], y=[3, 4]), ( "dict not as expected:\n" "\n" "same:\n" "['y']\n" "\n" "values differ:\n" "'x': [1, ('a', 'b', {gen1}), 3] (expected) != " "[1, ('a', 'b', {gen2}), 3] (actual)\n" "\n" "While comparing ['x']: sequence not as expected:\n" "\n" "same:\n" "[1]\n" "\n" "expected:\n" "[('a', 'b', {gen1}), 3]\n" "\n" "actual:\n" "[('a', 'b', {gen2}), 3]\n" "\n" "While comparing ['x'][1]: sequence not as expected:\n" "\n" "same:\n" "('a', 'b')\n" "\n" "expected:\n" "({gen1},)\n" "\n" "actual:\n" "({gen2},)\n" "\n" "While comparing ['x'][1][2]: sequence not as expected:\n" "\n" "same:\n" "(,)\n" "\n" "expected:\n" "(,)\n" "\n" "actual:\n" "()" ).format(gen1=hexsub(repr(gen1)), gen2=hexsub(repr(gen2))), x_label='expected', y_label='actual', ) def test_nested_strict_only_type_difference(self): MyTuple = namedtuple('MyTuple', 'x y z') type_repr = repr(MyTuple) tuple_repr = repr(tuple) self.check_raises( [MyTuple(1, 2, 3)], [(1, 2, 3)], ("sequence not as expected:\n" "\n" "same:\n" "[]\n" "\n" "first:\n" "[MyTuple(x=1, y=2, z=3)]\n" "\n" "second:\n" "[(1, 2, 3)]\n" "\n" "While comparing [0]: MyTuple(x=1, y=2, z=3) " "(%s) " "!= (1, 2, 3) " "(%s)") % (type_repr, tuple_repr), strict=True ) def test_strict_nested_different(self): expected = "[1, 2] () != (1, 3) ()" self.check_raises( (1, 2, [1, 2]), (1, 2, (1, 3)), "sequence not as expected:\n" "\n" "same:\n" "(1, 2)\n" "\n" "first:\n" "([1, 2],)\n" "\n" "second:\n" "((1, 3),)" "\n\n" "While comparing [2]: " + expected, strict=True, ) def test_namedtuple_equal(self): class_ = namedtuple('Foo', 'x') compare(class_(1), class_(1)) def test_namedtuple_same_type(self): class_ = namedtuple('Foo', 'x y') self.check_raises( class_(1, 2), class_(1, 3), "Foo not as expected:\n\n" "same:\n" "['x']\n\n" "values differ:\n" "'y': 2 != 3" ) def test_namedtuple_different_type(self): class_a = namedtuple('Foo', 'x y') class_b = namedtuple('Bar', 'x y z') self.check_raises( class_a(1, 2), class_b(1, 2, 3), "Foo(x=1, y=2) () != " "Bar(x=1, y=2, z=3) " "()" ) def test_dict_with_list(self): self.check_raises( {1: 'one', 2: 'two'}, [1, 2], "not equal:\n{1: 'one', 2: 'two'}\n[1, 2]" ) def test_explicit_expected(self): self.check_raises('x', expected='y', message="'y' (expected) != 'x' (actual)") def test_explicit_actual(self): self.check_raises('x', actual='y', message="'x' (expected) != 'y' (actual)") def test_explicit_both(self): self.check_raises(expected='x', actual='y', message="'x' (expected) != 'y' (actual)") def test_implicit_and_labels(self): self.check_raises('x', 'y', x_label='x_label', y_label='y_label', message="'x' (x_label) != 'y' (y_label)") def test_explicit_and_labels(self): self.check_raises(x='x', y='y', x_label='x_label', y_label='y_label', message="'x' (x_label) != 'y' (y_label)") def test_invalid_two_args_expected(self): with ShouldRaise(TypeError( "Exactly two objects needed, you supplied: ['z', 'x', 'y']" )): compare('x', 'y', expected='z') def test_invalid_two_args_actual(self): with ShouldRaise(TypeError( "Exactly two objects needed, you supplied: ['x', 'y', 'z']" )): compare('x', 'y', actual='z') def test_invalid_zero_args(self): with ShouldRaise(TypeError( 'Exactly two objects needed, you supplied:' )): compare() def test_invalid_one_args(self): with ShouldRaise(TypeError( "Exactly two objects needed, you supplied: ['x']" )): compare('x') def test_invalid_three_args(self): with ShouldRaise(TypeError( "Exactly two objects needed, you supplied: ['x', 'y', 'z']" )): compare('x', 'y', 'z') def test_invalid_because_of_typo(self): with ShouldRaise(TypeError("The following options are not valid: expceted")): compare('x', expceted='z') def test_dont_raise(self): self.assertEqual(compare('x', 'y', raises=False), "'x' != 'y'") class OrmObj: def __init__(self, a): self.a = a def __eq__(self, other): return True def __repr__(self): return 'OrmObj: '+str(self.a) def test_django_orm_is_horrible(self): self.assertTrue(self.OrmObj(1) == self.OrmObj(2)) def query_set(): yield self.OrmObj(1) yield self.OrmObj(2) self.check_raises( message=( "sequence not as expected:\n" "\n" "same:\n" "(OrmObj: 1,)\n" "\n" "expected:\n" "(OrmObj: 3,)\n" "\n" "actual:\n" "(OrmObj: 2,)\n" '\n' 'While comparing [1]: OrmObj not as expected:\n' '\n' 'attributes differ:\n' "'a': 3 (expected) != 2 (actual)" ), expected=[self.OrmObj(1), self.OrmObj(3)], actual=query_set(), ignore_eq=True ) def test_django_orm_is_horrible_type(self): assert self.OrmObj(1) == self.OrmObj(2) def query_set(): yield self.OrmObj(1) yield self.OrmObj(2) self.check_raises( message=( "sequence not as expected:\n" "\n" "same:\n" "(OrmObj: 1,)\n" "\n" "expected:\n" "(OrmObj: 3,)\n" "\n" "actual:\n" "(OrmObj: 2,)\n" '\n' 'While comparing [1]: OrmObj not as expected:\n' '\n' 'attributes differ:\n' "'a': 3 (expected) != 2 (actual)" ), expected=[self.OrmObj(1), self.OrmObj(3)], actual=query_set(), ignore_eq=self.OrmObj ) def test_django_orm_is_horrible_iterable(self): assert self.OrmObj(1) == self.OrmObj(2) def query_set(): yield self.OrmObj(1) yield self.OrmObj(2) self.check_raises( message=( "sequence not as expected:\n" "\n" "same:\n" "(OrmObj: 1,)\n" "\n" "expected:\n" "(OrmObj: 3,)\n" "\n" "actual:\n" "(OrmObj: 2,)\n" '\n' 'While comparing [1]: OrmObj not as expected:\n' '\n' 'attributes differ:\n' "'a': 3 (expected) != 2 (actual)" ), expected=[self.OrmObj(1), self.OrmObj(3)], actual=query_set(), ignore_eq=[self.OrmObj] ) def test_ignore_eq_default_respects_broken_eq(self): compare(self.OrmObj(1), self.OrmObj(2)) def test_ignore_eq_registered_by_type(self): with registry(): register(self.OrmObj, ignore_eq=True) self.check_raises( message=( 'OrmObj not as expected:\n' '\n' 'attributes differ:\n' "'a': 1 (expected) != 2 (actual)" ), expected=self.OrmObj(1), actual=self.OrmObj(2), ) def test_ignore_eq_mro_matching(self): class SubOrm(self.OrmObj): pass self.check_raises( message=( 'SubOrm not as expected:\n' '\n' 'attributes differ:\n' "'a': 1 (expected) != 2 (actual)" ), expected=SubOrm(1), actual=SubOrm(2), ignore_eq=self.OrmObj, ) def test_ignore_eq_blocks_shortcut_for_nested_ignored_types(self): # When any per-type ignore is in play, the `x == y` shortcut is # disabled even at the container level — otherwise a list of # OrmObj would have list.__eq__ silently delegate to OrmObj's # broken __eq__ and report equal. self.check_raises( message=( "sequence not as expected:\n" "\n" "same:\n" "[OrmObj: 1]\n" "\n" "expected:\n" "[OrmObj: 3]\n" "\n" "actual:\n" "[OrmObj: 2]\n" '\n' 'While comparing [1]: OrmObj not as expected:\n' '\n' 'attributes differ:\n' "'a': 3 (expected) != 2 (actual)" ), expected=[self.OrmObj(1), self.OrmObj(3)], actual=[self.OrmObj(1), self.OrmObj(2)], ignore_eq=self.OrmObj, ) @dataclass(kw_only=True) class Attrs: x: int y: int def __repr__(self): return f'{type(self).__name__}(x={self.x}, y={self.y})' class OnlyXEq(Attrs): def __eq__(self, other): return self.x == other.x class OnlyYEq(Attrs): def __eq__(self, other): return self.y == other.y def test_ignore_eq_does_not_disable_other_types_eq(self): # The OnlyYEq does not have its __eq__ ignored, so even though the instances have # differing x attributes, no AssertionError is raised: compare( expected=self.OnlyYEq(x=1, y=3), actual=self.OnlyYEq(x=2, y=3), ignore_eq=self.OnlyXEq, ) # The OnlyXEq instances are identical, so the ignore_eq doesn't uncover any attribute # differences that would have been ignored: compare( expected=self.OnlyXEq(x=3, y=1), actual=self.OnlyXEq(x=3, y=1), ignore_eq=self.OnlyXEq, ) # Now, we ignore the __eq__ of OnlyXEq, so an AssertionError is raised as the differences # in the y attributes are no longer ignoreed: self.check_raises( message=( "OnlyXEq not as expected:\n\n" "attributes same:\n" "['x']\n\n" "attributes differ:\n" "'y': 1 (expected) != 2 (actual)" ), expected=self.OnlyXEq(x=1, y=1), actual=self.OnlyXEq(x=1, y=2), ignore_eq=self.OnlyXEq, ) def test_ignore_eq_does_not_disable_other_types_eq_in_sequence(self): self.check_raises( message=( 'sequence not as expected:\n\n' 'same:\n' '[OnlyXEq(x=1, y=1)]\n\n' 'expected:\n' '[OnlyYEq(x=1, y=3)]\n\n' 'actual:\n' '[OnlyYEq(x=2, y=3)]\n\n' 'While comparing [1]: OnlyYEq not as expected:\n\n' 'attributes same:\n' "['y']\n\n" 'attributes differ:\n' "'x': 1 (expected) != 2 (actual)" ), # Here, we don't ignore_eq for OnlyXEq, so the two instances compare equal # even though their y attributes differ. We ignore_eq for OnlyYEq, so even though the # two OnlyYEq instances have identical y attributes, we ignore the __eq__ and so we # get an AssertionError for the different in the x attributes. expected=[self.OnlyXEq(x=1, y=1), self.OnlyYEq(x=1, y=3)], actual=[self.OnlyXEq(x=1, y=2), self.OnlyYEq(x=2, y=3)], ignore_eq=self.OnlyYEq, ) def test_ignore_eq_registry_and_parameter_combine(self): class OtherBroken: def __init__(self, a): self.a = a # def __eq__(self, other): # return True # def __repr__(self): # return 'OtherBroken: '+str(self.a) with registry(): register(self.OrmObj, ignore_eq=True) # use empty registry primitive self.check_raises( message=( 'OtherBroken not as expected:\n' '\n' 'attributes differ:\n' "'a': 1 (expected) != 2 (actual)" ), expected=OtherBroken(1), actual=OtherBroken(2), ignore_eq=OtherBroken, ) self.check_raises( message=( 'OrmObj not as expected:\n' '\n' 'attributes differ:\n' "'a': 1 (expected) != 2 (actual)" ), expected=self.OrmObj(1), actual=self.OrmObj(2), ignore_eq=OtherBroken, ) def test_register_requires_comparer_or_ignore_eq(self): class SomeType: pass with ShouldRaise(TypeError( "register() requires either a comparer or ignore_eq=True" )): register(SomeType) def test_ignore_eq_per_type_with_shared_instance(self): # When the same object appears at the same position in expected and # actual, AlreadySeen kicks in on the second sighting. Equality must # fall back to identity rather than the type's __eq__ — which is # precisely the operator ignore_eq is asking us to distrust, and # which may not cooperate with unknown operands like AlreadySeen. s = Strict(1) compare(expected=[s], actual=[s], ignore_eq=Strict) def test_ignore_eq_registered_with_shared_instance(self): # Same as above but via the registry — guards against a globally # registered ignore_eq type leaking into unrelated comparisons that # happen to reuse instances within a container. with registry(): register(Strict, ignore_eq=True) s = Strict(1) compare(expected=[s], actual=[s]) def test_django_orm_is_horrible_part_2(self): t_compare = partial(compare, ignore_eq=True) t_compare(self.OrmObj(1), self.OrmObj(1)) t_compare(self.OrmObj('some longish string'), self.OrmObj('some longish string')) t_compare(self.OrmObj(date(2016, 1, 1)), self.OrmObj(date(2016, 1, 1))) def test_django_orm_is_horrible_part_3(self): compare( expected=self.OrmObj(1), actual=self.OrmObj(1), ignore_eq=True ) def test_django_orm_is_horrible_part_4(self): self.check_raises( message='[1] (expected) != 2 (actual)', expected=[1], actual=2, ignore_eq=True ) def test_nested_django_orm_in_object(self): class MyObject: def __init__(self, orm): self.orm = orm self.check_raises( message="MyObject not as expected:\n" "\n" "attributes differ:\n" "'orm': OrmObj: 1 (expected) != OrmObj: 2 (actual)\n" "\n" "While comparing .orm: OrmObj not as expected:\n" "\n" "attributes differ:\n" "'a': 1 (expected) != 2 (actual)", expected=MyObject(self.OrmObj(1)), actual=MyObject(self.OrmObj(2)), ignore_eq=True) def test_mock_call_same(self): m = Mock() m.foo(1, 2, x=3) compare(m.mock_calls, m.mock_calls) def test_mock_call_same_strict(self): m = Mock() m.foo(1, 2, x=3) compare(m.mock_calls, m.mock_calls, strict=True) def test_mock_call_equal(self): m1 = Mock() m1.foo(1, 2, x=3) m2 = Mock() m2.foo(1, 2, x=3) compare(m1.mock_calls, m2.mock_calls) def test_mock_call_equal_strict(self): m1 = Mock() m1.foo(1, 2, x=3) m2 = Mock() m2.foo(1, 2, x=3) compare(m1.mock_calls, m2.mock_calls, strict=True) def test_calls_different(self): m1 = Mock() m2 = Mock() m1.foo(1, 2, x=3, y=4) m2.bar(1, 3, x=7, y=4) self.check_raises( m1.mock_calls, m2.mock_calls, "sequence not as expected:\n" "\n" "same:\n" "[]\n" "\n" "first:\n" "[call.foo(1, 2, x=3, y=4)]\n" "\n" "second:\n" "[call.bar(1, 3, x=7, y=4)]" "\n\n" 'While comparing [0]: \n' "'call.foo(1, 2, x=3, y=4)'\n" '!=\n' "'call.bar(1, 3, x=7, y=4)'" ) def test_call_args_different(self): m = Mock() m.foo(1) self.check_raises( m.foo.call_args, call(2), "'call(1)' != 'call(2)'" ) def test_calls_args_different_but_same_repr(self): class Annoying: def __init__(self, x): self.x = x def __repr__(self): return '' m1 = Mock() m2 = Mock() m1.foo(Annoying(1)) m2.foo(Annoying(3)) self.check_raises( m1.mock_calls, m2.mock_calls, 'sequence not as expected:\n' '\n' 'same:\n' '[]\n' '\n' 'first:\n' '[call.foo()]\n' '\n' 'second:\n' '[call.foo()]\n' '\n' 'While comparing [0]: mock.call not as expected:\n' '\n' 'While comparing [0] args: sequence not as expected:\n' '\n' 'same:\n' '()\n' '\n' 'first:\n' '(,)\n' '\n' 'second:\n' '(,)\n' '\n' 'While comparing [0] args[0]: Annoying not as expected:\n' '\n' 'attributes differ:\n' "'x': 1 != 3" ) def test_calls_nested_equal_sub_attributes(self): class Annoying: def __init__(self, x): self.x = x def __repr__(self): return '' m1 = Mock() m2 = Mock() m1.foo(x=[Annoying(1)]) m2.foo(x=[Annoying(1)]) compare(m1.mock_calls, m2.mock_calls) def test_compare_arbitrary_nested_diff(self): class OurClass: def __init__(self, *args): self.args = args def __repr__(self): return '' self.check_raises( OurClass(OurClass(1)), OurClass(OurClass(2)), "OurClass not as expected:\n" "\n" 'attributes differ:\n' "'args': (,) != (,)\n" '\n' 'While comparing .args: sequence not as expected:\n' '\n' 'same:\n' '()\n' '\n' 'first:\n' '(,)\n' '\n' 'second:\n' '(,)\n' '\n' 'While comparing .args[0]: OurClass not as expected:\n' '\n' 'attributes differ:\n' "'args': (1,) != (2,)\n" '\n' 'While comparing .args[0].args: sequence not as expected:\n' '\n' 'same:\n' '()\n' '\n' 'first:\n' '(1,)\n' '\n' 'second:\n' '(2,)' ) def test_compare_slotted_same(self): compare(Slotted(1, 2), Slotted(1, 2)) def test_compare_slotted_diff(self): self.check_raises( Slotted(1, 2), Slotted(1, 3), "Slotted not as expected:\n" "\n" "attributes same:\n" "['x']\n" "\n" 'attributes differ:\n' "'y': 2 != 3" ) def test_empty_sets(self): compare(set(), set()) def test_empty_sets_strict(self): compare(set(), set(), strict=True) def test_datetime_not_equal(self): self.check_raises( datetime(2001, 1, 1), datetime(2001, 1, 2), "datetime.datetime(2001, 1, 1, 0, 0) != " "datetime.datetime(2001, 1, 2, 0, 0)" ) def test_inherited_slots(self): class Parent: __slots__ = ('a',) class Child(Parent): __slots__ = ('b',) def __init__(self, a, b): self.a, self.b = a, b self.check_raises( Child(1, 'x'), Child(2, 'x'), 'Child not as expected:\n' '\n' 'attributes same:\n' "['b']\n" '\n' 'attributes differ:\n' "'a': 1 != 2" ) def test_empty_child_slots(self): class Parent: __slots__ = ('a',) def __init__(self, a): self.a = a class Child(Parent): __slots__ = () compare(Child(1), Child(1)) def test_slots_and_attrs(self): class Parent: __slots__ = ('a',) class Child(Parent): def __init__(self, a, b): self.a = a self.b = b self.check_raises(Child(1, 2), Child(1, 3), message=( 'Child not as expected:\n' '\n' 'attributes same:\n' "['a']\n" '\n' 'attributes differ:\n' "'b': 2 != 3" )) def test_partial_callable_different(self): def foo(x): pass def bar(y): pass self.check_raises( partial(foo), partial(bar), ( 'partial not as expected:\n' '\n' 'attributes same:\n' "['args', 'keywords']\n" '\n' 'attributes differ:\n' "'func': {foo} != {bar}\n" '\n' 'While comparing .func: not equal:\n{foo}\n{bar}' ).format(foo=hexsub(repr(foo)), bar=hexsub(repr(bar)))) def test_partial_args_different(self): def foo(x): pass self.check_raises( partial(foo, 1), partial(foo, 2), 'partial not as expected:\n' '\n' 'attributes same:\n' "['func', 'keywords']\n" '\n' 'attributes differ:\n' "'args': (1,) != (2,)\n" '\n' 'While comparing .args: sequence not as expected:\n' '\n' 'same:\n' '()\n' '\n' 'first:\n' '(1,)\n' '\n' 'second:\n' '(2,)' ) def test_partial_kw_different(self): def foo(x): pass self.check_raises( partial(foo, x=1, y=3), partial(foo, x=2, z=4), 'partial not as expected:\n' '\n' 'attributes same:\n' "['args', 'func']\n" '\n' 'attributes differ:\n' "'keywords': {'x': 1, 'y': 3} != {'x': 2, 'z': 4}\n" '\n' 'While comparing .keywords: dict not as expected:\n' '\n' 'in first but not second:\n' "'y': 3\n" '\n' 'in second but not first:\n' "'z': 4\n" '\n' 'values differ:\n' "'x': 1 != 2" ) def test_partial_equal(self): def foo(x): pass compare(partial(foo, 1, x=2), partial(foo, 1, x=2)) def test_repr_and_attributes_equal(self): class Wut: def __repr__(self): return 'Wut' def __eq__(self, other): return False self.check_raises( Wut(), Wut(), "Both x and y appear as 'Wut', but are not equal!" ) self.check_raises( expected=Wut(), actual=Wut(), message="Both expected and actual appear as 'Wut', but are not equal!" ) def test_string_with_slotted(self): class Slotted: __slots__ = ['foo'] def __init__(self, foo): self.foo = foo def __repr__(self): return repr(self.foo) self.check_raises( 'foo', Slotted('foo'), "'foo' (%s) != 'foo' (%s)" % (repr(str), repr(Slotted)) ) def test_not_recursive(self): self.check_raises( {1: 'foo', 2: 'foo'}, {1: 'bar', 2: 'bar'}, "dict not as expected:\n" "\n" "values differ:\n" "1: 'foo' != 'bar'\n" "2: 'foo' != 'bar'\n" "\n" "While comparing [1]: 'foo' != 'bar'" "\n\n" "While comparing [2]: 'foo' != 'bar'" ) def test_regex(self): shared_prefix = "a" * 199 self.check_raises( re.compile(shared_prefix + "x"), re.compile(shared_prefix + "y"), 'Both x and y appear as "re.compile(\''+'a'*199+')", but are not equal!' ) def test_self_referential_same(self): expected = {1: 'foo'} expected[2] = expected actual = {1: 'foo'} actual[2] = actual compare(expected, actual) def test_self_referential_different(self): expected = {1: 'foo'} expected[2] = expected actual = {1: 'bar'} actual[2] = actual self.check_raises( expected, actual, 'dict not as expected:\n' '\n' 'same:\n' '[2]\n' '\n' 'values differ:\n' "1: 'foo' != 'bar'\n" '\n' "While comparing [1]: 'foo' != 'bar'" ) def test_self_referential_different_but_shows_already_seen(self): ouroboros1 = {} ouroboros1['ouroboros'] = ouroboros1 ouroboros2 = {} ouroboros2['ouroboros'] = ouroboros2 id2 = str(id(ouroboros2)) self.check_raises( {1: ouroboros1, 2: 'foo'}, {1: ouroboros2, 2: ouroboros2}, 'dict not as expected:\n' '\n' 'same:\n' '[1]\n' '\n' 'values differ:\n' "2: 'foo' != {'ouroboros': }\n" '\n' "While comparing [2]: not equal:\n" "'foo'\n" "" ) def test_self_referential_comparison_object_and_failed(self) -> None: i = Item(1) self.check_raises( expected=Sample(like(Item), like(Item), x='foo'), actual=Sample(i, i, x='bar'), message=( 'Sample not as expected:\n' '\n' 'attributes same:\n' "['id', 'part']\n" '\n' 'attributes differ:\n' "'x': 'foo' (expected) != 'bar' (actual)\n" '\n' "While comparing .x: 'foo' (expected) != 'bar' (actual)" ) ) def test_self_referential_comparison_object_and_succeeded(self) -> None: i = Item(1) compare( # There's short-circuit checking for the AlreadySeen that kicks in here, so we don't # see any problems: expected=Sample(like(Item), like(Item), x='foo'), actual=Sample(i, i, x='foo'), ) def test_self_referential_comparison_object_and_same_but_strict(self) -> None: i = Item(1) self.check_raises( expected=Sample(like(Item), like(Item), x='foo'), actual=Sample(i, i, x='bar'), strict=True, message=( 'Sample not as expected:\n' '\n' 'attributes same:\n' "['part']\n" '\n' 'attributes differ:\n' "'id': (expected) != (actual)\n" "'x': 'foo' (expected) != 'bar' (actual)\n" '\n' "While comparing .id: " "() (expected) != " " ()" " (actual)\n" '\n' "While comparing .x: 'foo' (expected) != 'bar' (actual)" ) ) def test_self_referential_comparison_object_and_same_but_ignore_eq(self) -> None: i = Item(1) self.check_raises( expected=Sample(like(Item), like(Item), x='foo'), actual=Sample(i, i, x='bar'), ignore_eq=True, message=( 'Sample not as expected:\n' '\n' 'attributes same:\n' "['id', 'part']\n" '\n' 'attributes differ:\n' "'x': 'foo' (expected) != 'bar' (actual)\n" '\n' "While comparing .x: 'foo' (expected) != 'bar' (actual)" ) ) def test_self_referential_object_tree(self): class Node: def __init__(self): self.parent = None self.children = [] def add(self, child: 'Node'): self.children.append(child) child.parent = self def __repr__(self): return f'' expected = Node() expected.add(Node()) expected.add(Node()) actual = Node() actual.add(Node()) self.check_raises( expected, actual, 'Node not as expected:\n' '\n' 'attributes same:\n' "['parent']\n" '\n' 'attributes differ:\n' "'children': [, ] != []\n" '\n' 'While comparing .children: sequence not as expected:\n' '\n' 'same:\n' '[]\n' '\n' 'first:\n' '[]\n' '\n' 'second:\n' '[]' ) def test_repeated_object_on_the_left_side_ignore_eq(self): item = [1, 2, 3] compare(expected=[item, item], actual=[[1, 2, 3], [1, 2, 3]], ignore_eq=True) def test_repeated_object_on_both_sides_ignore_eq(self): item = [1, 2, 3] compare(expected=[item, item], actual=[item, [1, 2, 3]], ignore_eq=True) def test_repeated_object_on_both_sides_left_at_compare_strict_type_same(self): item = [1, 2, 3] compare(expected=[item, item], actual=[item, [1, 2, 3]], strict=True) def test_repeated_object_on_both_sides_right_at_compare_strict_type_same(self): item = [1, 2, 3] compare(expected=[item, [1, 2, 3]], actual=[item, item], strict=True) def test_repeated_object_on_both_sides_strict_type_different(self): item = [1, 2, 3] class MyList(list): def __repr__(self): return f'<{type(self).__name__}:{super().__repr__()}>' type_repr = repr(MyList) self.check_raises( [item, item], [item, MyList((1, 2, 3))], strict=True, message = ( 'sequence not as expected:\n' '\n' 'same:\n' '[[1, 2, 3]]\n' '\n' 'first:\n' '[[1, 2, 3]]\n' '\n' 'second:\n' '[]\n' '\n' f"While comparing [1]: ) != " f" ({type_repr})" ) ) def test_uuid(self): uuid = uuid4() compare(uuid, uuid) class TestIgnore(CompareHelper): class Parent: def __init__(self, id, other): self.id = id self.other = other def __repr__(self): return '<{}:{}>'.format(type(self).__name__, self.id) class Child(Parent): pass def test_ignore_attributes(self): compare(self.Parent(1, 3), self.Parent(2, 3), ignore_attributes={'id'}) def test_ignore_attributes_different_types(self): self.check_raises( self.Parent(1, 3), self.Child(2, 3), ' != ', ignore_attributes={'id'} ) def test_ignore_attributes_per_type(self): ignore = {self.Parent: {'id'}} compare(self.Parent(1, 3), self.Parent(2, 3), ignore_attributes=ignore) self.check_raises( self.Child(1, 3), self.Child(2, 3), 'Child not as expected:\n' '\n' 'attributes same:\n' "['other']\n" '\n' 'attributes differ:\n' "'id': 1 != 2", ignore_attributes=ignore ) class TestCompareObject: class Thing: def __init__(self, **kw): for k, v in kw.items(): setattr(self, k, v) @staticmethod def compare_thing(x, y, context): context_ignored = context.options.get('ignore_attributes') ignored = merge_ignored_attributes('y', context_ignored) return compare_object(x, y, context, ignore_attributes=ignored) def test_ignore(self): compare(self.Thing(x=1, y=2), self.Thing(x=1, y=3), comparers={self.Thing: self.compare_thing}) def test_ignore_dict_context_param(self): compare(self.Thing(x=1, y=2, z=3), self.Thing(x=1, y=4, z=5), comparers={self.Thing: self.compare_thing}, ignore_attributes={self.Thing: ['z']}) def test_ignore_list_context_param(self): compare(self.Thing(x=1, y=2, z=3), self.Thing(x=1, y=4, z=5), comparers={self.Thing: self.compare_thing}, ignore_attributes=['z']) def test_strict_respected_when_nested(self): with ShouldAssert( "Thing not as expected:\n\n" "attributes differ:\n" "'x': datetime.time(0, 0) != datetime.time(0, 0, fold=1)\n\n" "While comparing .x: datetime.time(0, 0) != datetime.time(0, 0, fold=1)", ): compare(self.Thing(x=time()), self.Thing(x=time(fold=1)), strict=True) class BaseClass(ABC): pass class MyDerivedClass(BaseClass): def __init__(self, thing): self.thing = thing class ConcreteBaseClass: pass class ConcreteDerivedClass(ConcreteBaseClass): def __init__(self, thing): self.thing = thing class TestBaseClasses(CompareHelper): def test_abc_equal(self): thing1 = MyDerivedClass(1) thing2 = MyDerivedClass(1) compare(thing1, thing2) def test_abc_unequal(self): thing1 = MyDerivedClass(1) thing2 = MyDerivedClass(2) self.check_raises(thing1, thing2, message=( "MyDerivedClass not as expected:\n\n" "attributes differ:\n" "'thing': 1 != 2" )) def test_concrete_equal(self): thing1 = ConcreteDerivedClass(1) thing2 = ConcreteDerivedClass(1) compare(thing1, thing2) def test_concrete_unequal(self): thing1 = ConcreteDerivedClass(1) thing2 = ConcreteDerivedClass(2) self.check_raises(thing1, thing2, message=( "ConcreteDerivedClass not as expected:\n\n" "attributes differ:\n" "'thing': 1 != 2" )) class TestDateAndTime: def test_datetime_with_same_fold(self): compare(datetime(2000, 1, 1, fold=0), datetime(2000, 1, 1, fold=0)) def test_time_with_same_fold(self): compare(time(fold=0), time(fold=0)) def test_datetime_with_different_fold(self): # because Python is insane... compare(datetime(2000, 1, 1, fold=1), datetime(2000, 1, 1, fold=0)) def test_time_with_different_fold(self): # because Python is insane... compare(time(fold=1), time(fold=0)) def test_datetime_with_same_fold_strict(self): compare(datetime(2000, 1, 1, fold=0), datetime(2000, 1, 1, fold=0), strict=True) def test_time_with_same_fold_strict(self): compare(time(fold=0), time(fold=0), strict=True) def test_datetime_with_different_fold_strict(self): with ShouldAssert( 'datetime.datetime(2000, 1, 1, 0, 0, fold=1) != datetime.datetime(2000, 1, 1, 0, 0)' ): compare(datetime(2000, 1, 1, fold=1), datetime(2000, 1, 1, fold=0), strict=True) def test_time_with_different_fold_strict(self): with ShouldAssert( 'datetime.time(0, 0, fold=1) != datetime.time(0, 0)' ): compare(time(fold=1), time(fold=0), strict=True) def test_datetime_with_different_fold_strict_but_identical_repr(self): class PandasDatetime(datetime): def __repr__(self): return f'' with ShouldAssert( ' (fold=1) != (fold=0)' ): compare( PandasDatetime(2000, 1, 1, fold=1), PandasDatetime(2000, 1, 1, fold=0), strict=True ) T = TypeVar("T") class SampleGeneric(Generic[T]): pass class TestGenericTypes: def test_equal(self): compare(SampleGeneric[float], SampleGeneric[float]) def test_not_equal(self): if PY_312_PLUS: attributes = ("['__module__', '__origin__', '__parameters__', '__slots__', " "'__weakref__', '_inst', '_name']" ) else: attributes = ( "['__module__', '__origin__', '__parameters__', '__slots__', '__weakref__', " "'_inst', '_name', '_paramspec_tvars']" ) check_raises(SampleGeneric[int], SampleGeneric[float], message=( '_GenericAlias not as expected:\n' '\n' 'attributes same:\n' f"{attributes}\n" '\n' 'attributes differ:\n' "'__args__': (,) != (,)\n" '\n' 'While comparing .__args__: sequence not as expected:\n' '\n' 'same:\n' '()\n' '\n' 'first:\n' "(,)\n" '\n' 'second:\n' "(,)\n" '\n' "While comparing .__args__[0]: != " )) class TestSafeHelpers: def test_safe_repr_normal_object(self): compare(safe_repr(42), expected='42') compare(safe_repr('hi'), expected="'hi'") compare(safe_repr([1, 2]), expected='[1, 2]') def test_safe_repr_broken_object(self): compare(safe_repr(Broken()), expected=Broken.marker) def test_safe_repr_other_exception_class(self): compare( safe_repr(Broken(exc=TypeError('nope'))), expected='', ) def test_safe_repr_list_with_broken_element(self): compare( safe_repr([1, Broken(), 3]), expected=f'[1, {Broken.marker}, 3]', ) def test_safe_repr_tuple_with_broken_element(self): compare( safe_repr((1, Broken())), expected=f'(1, {Broken.marker})', ) def test_safe_repr_single_element_tuple_with_broken(self): compare( safe_repr((Broken(),)), expected=f'({Broken.marker},)', ) def test_safe_repr_dict_with_broken_value(self): compare( safe_repr({'k': Broken()}), expected=f"{{'k': {Broken.marker}}}", ) def test_safe_repr_dict_with_broken_key(self): compare( safe_repr({HashableBroken('k1'): 'v'}), expected=f"{{{HashableBroken.marker}: 'v'}}", ) def test_safe_repr_set_with_broken_element(self): compare( safe_repr({HashableBroken('k1')}), expected='{' + HashableBroken.marker + '}', ) def test_safe_repr_frozenset_with_broken_element(self): compare( safe_repr(frozenset({HashableBroken('k1')})), expected='frozenset({' + HashableBroken.marker + '})', ) def test_safe_repr_empty_frozenset_subclass_with_broken_repr(self): # repr() of an empty frozenset never fails naturally, so reach the # empty-frozenset fallback via a subclass whose __repr__ raises. class BrokenFrozenSet(frozenset): def __repr__(self): raise ValueError('boom!') compare(safe_repr(BrokenFrozenSet()), expected='BrokenFrozenSet') def test_safe_repr_empty_set_subclass_with_broken_repr(self): class BrokenSet(set): def __repr__(self): raise ValueError('boom!') compare(safe_repr(BrokenSet()), expected='BrokenSet()') def test_safe_repr_propagates_keyboard_interrupt(self): class K(KeyboardInterrupt): pass with ShouldRaise(K): safe_repr(Broken(exc=K())) def test_safe_repr_handles_exception_with_broken_str(self): class BrokenExc(Exception): def __str__(self): raise RuntimeError('inner-fail') # When the exception's __str__ raises, the marker still falls back # to just the exception class name without the message. result = safe_repr(Broken(exc=BrokenExc())) compare( result, expected='', ) def test_safe_pformat_normal_object(self): compare(safe_pformat([1, 2, 3]), expected='[1, 2, 3]') def test_safe_pformat_falls_back_to_safe_repr(self): compare( safe_pformat([1, Broken(), 3]), expected=f'[1, {Broken.marker}, 3]', ) def test_safe_pformat_propagates_keyboard_interrupt(self): class K(KeyboardInterrupt): pass with ShouldRaise(K): safe_pformat(Broken(exc=K())) class TestSafeRenderingInComparers: def test_compare_simple_broken_x(self): check_raises( Broken(), 42, message=( 'not equal:\n' f'{Broken.marker}\n' '42' ), ) def test_compare_simple_broken_y(self): check_raises( 42, Broken(), message=( 'not equal:\n' '42\n' f'{Broken.marker}' ), ) def test_compare_sequence_broken_element(self): check_raises( [1, Broken(), 3], [1, Broken(), 3, 4], message=( 'sequence not as expected:\n' '\n' 'same:\n' f'[1, {Broken.marker}, 3]\n' '\n' 'first:\n' '[]\n' '\n' 'second:\n' '[4]' ), ) def test_compare_dict_broken_value_difference(self): check_raises( {'k': Broken()}, {'k': 99}, message=( 'dict not as expected:\n' '\n' 'values differ:\n' f"'k': {Broken.marker} != 99\n" '\n' "While comparing ['k']: not equal:\n" f'{Broken.marker}\n' '99' ), ) def test_compare_dict_key_only_on_one_side(self): check_raises( {HashableBroken('only-x'): 1}, {HashableBroken('only-y'): 2}, message=( 'dict not as expected:\n' '\n' 'in first but not second:\n' f'{HashableBroken.marker}: 1\n' '\n' 'in second but not first:\n' f'{HashableBroken.marker}: 2' ), ) def test_compare_set_broken_element(self): check_raises( {HashableBroken('a'), 1}, {1}, message=( 'set not as expected:\n' '\n' 'in first but not second:\n' f'[{HashableBroken.marker}]\n' '\n' ), ) def test_compare_with_type_short_repr_broken(self): check_raises( Broken(), 'hello', strict=True, message=( ") != " "'hello' ()" ), ) def test_already_seen_broken_repr(self): broken = Broken() check_raises( [broken, [1, 2, 3]], [broken, broken], strict=True, message=( 'sequence not as expected:\n' '\n' 'same:\n' f'[{Broken.marker}]\n' '\n' 'first:\n' '[[1, 2, 3]]\n' '\n' 'second:\n' f'[{Broken.marker}]\n' '\n' "While comparing [1]: [1, 2, 3] () != " ")" ), ) class BrokenStr(Broken): # __str__ AND __repr__ raise on demand def __str__(self): raise self._exc class TestSafeRenderingOfLazyPrefixSuffix: def test_resolve_lazy_prefix_str_raises(self): # prefix is a callable whose return value's __str__ raises; # _resolve_lazy must not crash compare's diff rendering. check_raises( 1, 2, prefix=lambda: BrokenStr(), message=( ': 1 != 2' ), ) def test_resolve_lazy_suffix_str_raises(self): check_raises( 1, 2, suffix=lambda: BrokenStr(), message=( '1 != 2\n' '' ), ) @dataclass(repr=False) class JsonReprThing: name: str def __repr__(self) -> str: # blow up if we get a Comparison for one of our attributes: return json.dumps(vars(self)) class TestSafeRendering: def test_json_repr(self): check_raises( expected=[JsonReprThing(name=like(str))], actual=[JsonReprThing(name='hello'), JsonReprThing(name='extra')], message=( 'sequence not as expected:\n' '\n' 'same:\n' '[]\n' '\n' 'expected:\n' '[]\n' '\n' 'actual:\n' '[{"name": "extra"}]' ), ) testfixtures-12.4.0/tests/test_comparison.py000066400000000000000000000565431525773641100213240ustar00rootroot00000000000000import sys from unittest import TestCase from uuid import UUID, uuid4 from testfixtures import ( Comparison as C, TempDirectory, diff, Comparison, MappingComparison, SequenceComparison, compare, like, ) from tests.sample1 import SampleClassA, a_function class AClass: def __init__(self, x, y=None): self.x = x if y: self.y = y def __repr__(self): return '<'+self.__class__.__name__+'>' class BClass(AClass): pass class WeirdException(Exception): def __init__(self, x, y): self.x = x self.y = y class X: __slots__ = ['x'] class FussyDefineComparison: def __init__(self, attr): self.attr = attr def __eq__(self, other): if not isinstance(other, self.__class__): # pragma: no cover raise TypeError() return False # pragma: no cover def __ne__(self, other): return not self == other # pragma: no cover class Broken: # An object whose __repr__ raises on demand. marker = '' def __init__(self, label='broken', exc=None): self.label = label self._exc = ValueError('boom!') if exc is None else exc def __repr__(self): raise self._exc def compare_repr(obj, expected): actual = diff(expected, repr(obj)) if actual: # pragma: no cover raise AssertionError(actual) class TestC(TestCase): def test_example(self): # In this pattern, we want to check a sequence is # of the correct type and order. r = a_function() self.assertEqual(r, ( C('tests.sample1.SampleClassA'), C('tests.sample1.SampleClassB'), C('tests.sample1.SampleClassA'), )) # We also want to check specific parts of some # of the returned objects' attributes self.assertEqual(r[0].args[0], 1) self.assertEqual(r[1].args[0], 2) self.assertEqual(r[2].args[0], 3) def test_example_with_object(self): # Here we see compare an object with a Comparison # based on an object of the same type and with the # same attributes: self.assertEqual( C(AClass(1, 2)), AClass(1, 2), ) # ...even though the original class doesn't support # meaningful comparison: self.assertNotEqual( AClass(1, 2), AClass(1, 2), ) def test_example_with_vars(self): # Here we use a Comparison to make sure both the # type and attributes of an object are correct. self.assertEqual( C('tests.test_comparison.AClass', x=1, y=2), AClass(1, 2), ) def test_example_with_odd_vars(self): # If the variable names class with parameters to the # Comparison constructor, they can be specified in a # dict: self.assertEqual( C('tests.test_comparison.AClass', {'x': 1, 'y': 2}), AClass(1, 2), ) def test_example_partial(self): self.assertEqual( C('tests.test_comparison.AClass', x=1, partial=True), AClass(1, 2), ) def test_example_dont_use_c_wrappers_on_both_sides(self): # NB: don't use C wrappers on both sides! e = ValueError('some message') x, y = C(e), C(e) assert x != y compare_repr( x, "wrong type: testfixtures.comparison.Comparison" ) compare_repr( y, "args: ('some message',)" ) def test_repr_module(self): compare_repr(C('datetime'), '') def test_repr_class(self): compare_repr(C('tests.sample1.SampleClassA'), '') def test_repr_function(self): compare_repr(C('tests.sample1.z'), '') def test_repr_instance(self): compare_repr(C(SampleClassA('something')), "" "args: ('something',)" "" ) def test_repr_exception(self): compare_repr(C(ValueError('something')), "args: ('something',)") def test_repr_exception_not_args(self): compare_repr( C(WeirdException(1, 2)), "\n\n" "args: (1, 2)\n" "x: 1\n" "y: 2\n" "" ) def test_repr_class_and_vars(self): compare_repr( C(SampleClassA, {'args': (1,)}), "args: (1,)" ) def test_repr_nested(self): compare_repr( C(SampleClassA, y=C(AClass), z=C(BClass(1, 2))), "\n" "\n" "y: \n" "z: \n" " \n" " x: 1\n" " y: 2\n" " \n" "" ) def test_repr_failed_wrong_class(self): c = C('tests.test_comparison.AClass', x=1, y=2) assert c != BClass(1, 2) compare_repr( c, "" "wrong type: tests.test_comparison.BClass" "" ) def test_repr_failed_all_reasons_in_one(self): c = C('tests.test_comparison.AClass', y=5, z='missing') assert c != AClass(1, 2) compare_repr(c, "\n" "\n" "attributes in Comparison but not actual:\n" "'z': 'missing'\n\n" "attributes in actual but not Comparison:\n" "'x': 1\n\n" "attributes differ:\n" "'y': 5 (Comparison) != 2 (actual)\n" "", ) def test_repr_failed_not_in_other(self): c = C('tests.test_comparison.AClass', x=1, y=2, z=(3, )) assert c != AClass(1, 2) compare_repr(c , "\n" "\n" "attributes same:\n" "['x', 'y']\n\n" "attributes in Comparison but not actual:\n" "'z': (3,)\n" "", ) def test_repr_failed_not_in_self(self): c = C('tests.test_comparison.AClass', y=2) assert c != AClass(x=(1, ), y=2) compare_repr(c, "\n" "\n" "attributes same:\n" "['y']\n\n" "attributes in actual but not Comparison:\n" "'x': (1,)\n" "", ) def test_repr_failed_not_in_self_partial(self): c = C('tests.test_comparison.AClass', x=1, y=2, z=(3, ), partial=True) assert c != AClass(x=1, y=2) compare_repr(c, "\n" "\n" "attributes same:\n" "['x', 'y']\n\n" "attributes in Comparison but not actual:\n" "'z': (3,)\n" "", ) def test_repr_failed_one_attribute_not_equal(self): c = C('tests.test_comparison.AClass', x=1, y=(2, )) assert c != AClass(1, (3, )) compare_repr(c, "\n" "\n" "attributes same:\n" "['x']\n\n" "attributes differ:\n" "'y': (2,) (Comparison) != (3,) (actual)\n" "", ) def test_repr_failed_nested(self): left_side = [C(AClass, x=1, y=2), C(BClass, x=C(AClass, x=1, y=2), y=C(AClass))] right_side = [AClass(1, 3), AClass(1, 3)] # do the comparison left_side == right_side compare_repr( left_side, "[\n" "\n" "attributes same:\n" "['x']\n\n" "attributes differ:\n" "'y': 2 (Comparison) != 3 (actual)\n" ", \n" "\n" "x: \n" " \n" " x: 1\n" " y: 2\n" " \n" "y: \n" "]" ) compare_repr(right_side, "[, ]") def test_repr_failed_nested_failed(self): left_side = [C(AClass, x=1, y=2), C(BClass, x=C(AClass, x=1, partial=True), y=C(AClass, z=2))] right_side = [AClass(1, 2), BClass(AClass(1, 2), AClass(1, 2))] # do the comparison left_side == right_side compare_repr( left_side, "[\n" "\n" "x: 1\n" "y: 2\n" ", \n" "\n" "attributes same:\n" "['x']\n\n" "attributes differ:\n" "'y': \n" "\n" "attributes in Comparison but not actual:\n" "'z': 2\n\n" "attributes in actual but not Comparison:\n" "'x': 1\n" "'y': 2\n" " (Comparison) != (actual)\n" "]", ) compare_repr(right_side, '[, ]') def test_repr_failed_passed_failed(self): c = C('tests.test_comparison.AClass', x=1, y=2) assert c != AClass(1, 3) compare_repr(c, "\n" "\n" "attributes same:\n" "['x']\n\n" "attributes differ:\n" "'y': 2 (Comparison) != 3 (actual)\n" "", ) assert c == AClass(1, 2) assert c != AClass(3, 2) compare_repr(c, "\n" "\n" "attributes same:\n" "['y']\n\n" "attributes differ:\n" "'x': 1 (Comparison) != 3 (actual)\n" "", ) def test_first(self): self.assertEqual( C('tests.sample1.SampleClassA'), SampleClassA() ) def test_second(self): self.assertEqual( SampleClassA(), C('tests.sample1.SampleClassA'), ) def test_not_same_first(self): self.assertNotEqual( C('datetime'), SampleClassA() ) def test_not_same_second(self): self.assertNotEqual( SampleClassA(), C('datetime') ) def test_object_supplied(self): self.assertEqual( SampleClassA(1), C(SampleClassA(1)) ) def test_class_and_vars(self): self.assertEqual( SampleClassA(1), C(SampleClassA, {'args': (1,)}) ) def test_class_and_kw(self): self.assertEqual( SampleClassA(1), C(SampleClassA, args=(1,)) ) def test_class_and_vars_and_kw(self): self.assertEqual( AClass(1, 2), C(AClass, {'x': 1}, y=2) ) def test_object_and_vars(self): # vars passed are used instead of the object's self.assertEqual( SampleClassA(1), C(SampleClassA(), {'args': (1,)}) ) def test_object_and_kw(self): # kws passed are used instead of the object's self.assertEqual( SampleClassA(1), C(SampleClassA(), args=(1,)) ) def test_object_partial(self): # only attributes on comparison object # are used self.assertEqual( C(AClass(1), partial=True), AClass(1, 2), ) def run_property_equal_test(self, partial): class SomeClass: @property def prop(self): return 1 self.assertEqual( C(SomeClass, prop=1, partial=partial), SomeClass() ) def test_property_equal(self): self.run_property_equal_test(partial=False) def test_property_equal_partial(self): self.run_property_equal_test(partial=True) def run_property_not_equal_test(self, partial): class SomeClass: @property def prop(self): return 1 c = C(SomeClass, prop=2, partial=partial) self.assertNotEqual(c, SomeClass()) compare_repr( c, "\n" ".SomeClass(failed)>\n" "attributes differ:\n" "'prop': 2 (Comparison) != 1 (actual)\n" ".SomeClass>") def test_property_not_equal(self): self.run_property_not_equal_test(partial=False) def test_property_not_equal_partial(self): self.run_property_not_equal_test(partial=True) def run_method_equal_test(self, partial): class SomeClass: def method(self): pass # pragma: no cover instance = SomeClass() self.assertEqual( C(SomeClass, method=instance.method, partial=partial), instance ) def test_method_equal(self): self.run_method_equal_test(partial=False) def test_method_equal_partial(self): self.run_method_equal_test(partial=True) def run_method_not_equal_test(self, partial): class SomeClass: pass instance = SomeClass() instance.method = min c = C(SomeClass, method=max, partial=partial) self.assertNotEqual(c, instance) compare_repr( c, "\n" ".SomeClass(failed)>\n" "attributes differ:\n" "'method': (Comparison)" " != (actual)\n" ".SomeClass>" ) def test_method_not_equal(self): self.run_method_not_equal_test(partial=False) def test_method_not_equal_partial(self): self.run_method_not_equal_test(partial=True) def test_exception(self): self.assertEqual( ValueError('foo'), C(ValueError('foo')) ) def test_exception_class_and_args(self): self.assertEqual( ValueError('foo'), C(ValueError, args=('foo', )) ) def test_exception_instance_and_args(self): self.assertEqual( ValueError('foo'), C(ValueError('bar'), args=('foo', )) ) def test_exception_not_same(self): self.assertNotEqual( ValueError('foo'), C(ValueError('bar')) ) def test_exception_no_args_different(self): self.assertNotEqual( WeirdException(1, 2), C(WeirdException(1, 3)) ) def test_exception_no_args_same(self): self.assertEqual( C(WeirdException(1, 2)), WeirdException(1, 2) ) def test_repr_file_different(self): with TempDirectory() as d: path = d.write('file', b'stuff') f = open(path) f.close() c = C('io.TextIOWrapper', name=path, mode='r', closed=False, partial=True) assert f != c compare_repr(c, "\n" "\n" "attributes same:\n" "['mode', 'name']\n\n" "attributes differ:\n" "'closed': False (Comparison) != True (actual)\n" "", ) def test_file_same(self): with TempDirectory() as d: path = d.write('file', b'stuff') f = open(path) f.close() self.assertEqual( f, C('io.TextIOWrapper', name=path, mode='r', closed=True, partial=True) ) def test_no___dict___strict(self): c = C(X, x=1) assert c != X() compare_repr(c, "\n" "\n" "attributes in Comparison but not actual:\n" "'x': 1\n" "") def test_no___dict___partial_same(self): x = X() x.x = 1 self.assertEqual(C(X, x=1, partial=True), x) def test_no___dict___partial_missing_attr(self): c = C(X, x=1, partial=True) assert c != X() compare_repr(c, "\n" "\n" "attributes in Comparison but not actual:\n" "'x': 1\n" "", ) def test_no___dict___partial_different(self): x = X() x.x = 2 c = C(X, x=1, y=2, partial=True) assert c != x compare_repr(c, "\n" "\n" "attributes in Comparison but not actual:\n" "'y': 2\n\n" "attributes differ:\n" "'x': 1 (Comparison) != 2 (actual)\n" "", ) def test_compared_object_defines_eq(self): # If an object defines eq, such as Django instances, # things become tricky class Annoying: def __init__(self): self.eq_called = 0 def __eq__(self, other): self.eq_called += 1 if isinstance(other, Annoying): return True return False self.assertEqual(Annoying(), Annoying()) # Suddenly, order matters. # This order is wrong, as it uses the class's __eq__: self.assertFalse(Annoying() == C(Annoying)) # but on __eq__ is used as a fallback: self.assertTrue(Annoying() != C(Annoying)) # This is the right ordering: self.assertTrue(C(Annoying) == Annoying()) self.assertFalse(C(Annoying) != Annoying()) # When the ordering is right, you still get the useful # comparison representation afterwards c = C(Annoying, eq_called=1) c == Annoying() compare_repr( c, '\n.Annoying(failed)>\n' 'attributes differ:\n' "'eq_called': 1 (Comparison) != 0 (actual)\n" '.Annoying>' ) def test_importerror(self): assert C(ImportError('x')) == ImportError('x') def test_class_defines_comparison_strictly(self): self.assertEqual( C('tests.test_comparison.FussyDefineComparison', attr=1), FussyDefineComparison(1) ) def test_cant_resolve(self): try: C('testfixtures.bonkers') except Exception as e: self.assertTrue(isinstance(e, AttributeError)) self.assertEqual( e.args, ("'testfixtures.bonkers' could not be resolved", ) ) else: self.fail('No exception raised!') def test_no_name(self): class NoName: pass NoName.__name__ = '' NoName.__qualname__ = '' NoName.__module__ = '' c = C(NoName) self.assertEqual(repr(c), ">") def test_missing_expected_attribute_not_partial(self): class MyClass: def __init__(self, **attrs): self.__dict__.update(attrs) c = Comparison(MyClass, b=2, c=3, partial=False) assert c != MyClass(a=1, b=2) def test_missing_expected_attribute_partial(self): class MyClass: def __init__(self, **attrs): self.__dict__.update(attrs) c = Comparison(MyClass, b=2, c=3, partial=True) assert c != MyClass(a=1, b=2) def test_extra_expected_attribute_not_partial(self): class MyClass: def __init__(self, **attrs): self.__dict__.update(attrs) c = Comparison(MyClass, a=1, partial=False) assert c != MyClass(a=1, b=2) def test_extra_expected_attribute_partial(self): class MyClass: def __init__(self, **attrs): self.__dict__.update(attrs) c = Comparison(MyClass, a=1, partial=True) assert c == MyClass(a=1, b=2) def test_already_seen(self): c = Comparison(UUID) uuid = uuid4() compare(expected={'1': c, '2': c}, actual={'1': uuid, '2': uuid}) class TestSafeRendering: def test_comparison_body_broken_attribute_value(self): # Comparison.body is hit when repr() is called on a fresh Comparison # whose comparison hasn't run yet (self.failed empty). class Holder: pass compare( repr(C(Holder, attr=Broken())), expected=( '.Holder>' f'attr: {Broken.marker}' ), ) def test_mapping_comparison_body_broken_value(self): compare( repr(MappingComparison(k=Broken())), expected=( '' f"'k': {Broken.marker}" ), ) def test_sequence_comparison_body_broken_value(self): compare( repr(SequenceComparison(Broken())), expected=( '' f'{Broken.marker},' ), ) testfixtures-12.4.0/tests/test_comparison_typed.py000066400000000000000000000135341525773641100225220ustar00rootroot00000000000000# Tests that ensure compare and Comparison work correctly in strictly type checked environments import re from collections import OrderedDict from dataclasses import dataclass from uuid import UUID, uuid4 from testfixtures import compare, Comparison from testfixtures.comparison import ( like, repr_like, str_like, sequence, contains, unordered, mapping ) @dataclass class SampleClass: x: int y: str class OtherClass: pass @dataclass class ListCollection: items: list[SampleClass] @dataclass class TupleCollection: items: tuple[SampleClass, ...] @dataclass class DictCollection: items: dict[str, SampleClass] @dataclass class OrderedDictCollection: items: "OrderedDict[str, SampleClass]" def test_simple_compare() -> None: compare(SampleClass(1, '2'), expected=SampleClass(1, '2')) def test_comparison_bad_typing_in_list() -> None: expected: list[SampleClass] = [ Comparison(SampleClass, x=1, partial=True) # type: ignore[list-item] ] compare(expected, actual=[SampleClass(1, '2')]) def test_comparison_via_like_in_list() -> None: expected: list[SampleClass] = [like(SampleClass, x=1)] compare(expected, actual=[SampleClass(1, '2')]) def test_comparison_via_like_in_assert() -> None: expected: SampleClass = like(SampleClass) assert expected == SampleClass(1, '2') assert expected == SampleClass(3, '4') assert expected != SampleClass assert expected != OtherClass() def test_uuid_already_seen_like(): comparison = Comparison(UUID) uuid = uuid4() compare(uuid, expected=like(UUID)) def test_like_str_pattern() -> None: expected: str = like(r'on \d+') compare(expected, actual='on 40220') def test_like_compiled_pattern() -> None: expected: str = like(re.compile(r'on \d+')) compare(expected, actual='on 40220') def test_repr_like() -> None: expected: SampleClass = repr_like(SampleClass, "SampleClass(x=1, y='2')") compare(expected, actual=SampleClass(1, '2')) def test_repr_like_match() -> None: expected: SampleClass = repr_like(SampleClass, match=re.compile(r'SampleClass\(')) compare(expected, actual=SampleClass(1, '2')) def test_str_like() -> None: expected: SampleClass = str_like(SampleClass, "SampleClass(x=1, y='2')") compare(expected, actual=SampleClass(1, '2')) def test_str_like_match() -> None: expected: SampleClass = str_like(SampleClass, match=r'SampleClass\(') compare(expected, actual=SampleClass(1, '2')) class TestSequence: def test_minimal(self) -> None: actual = ListCollection([SampleClass(1, '2')]) compare(actual, expected=ListCollection(sequence()([SampleClass(1, '2')]))) def test_maximal(self) -> None: actual = ListCollection( [SampleClass(1, 'x'), SampleClass(2, 'x'), SampleClass(3, 'x')] ) compare( actual, expected=ListCollection( sequence(partial=True, ordered=False, recursive=False)( [ SampleClass(3, 'x'), SampleClass(2, 'x'), ], ) ), ) def test_minimal_type_override(self) -> None: actual = TupleCollection((SampleClass(1, '2'),)) compare( actual, expected=TupleCollection( sequence(returns=tuple[SampleClass, ...])([SampleClass(1, '2')]) ), ) def test_maximal_type_override(self) -> None: actual = TupleCollection( ( SampleClass(1, 'x'), SampleClass(2, 'x'), SampleClass(3, 'x'), ) ) compare( actual, expected=TupleCollection( sequence( partial=True, ordered=False, recursive=False, returns=tuple[SampleClass, ...], )( [ SampleClass(3, 'x'), SampleClass(2, 'x'), ] ) ), ) class TestContains: def test_it(self) -> None: actual = ListCollection([SampleClass(1, "2"), SampleClass(3, "4")]) compare(actual, expected=ListCollection(contains([SampleClass(1, "2")]))) def test_type_override(self) -> None: actual = TupleCollection((SampleClass(1, "2"), SampleClass(3, "4"))) compare( actual, expected=TupleCollection( contains([SampleClass(1, "2")], returns=tuple[SampleClass, ...]) ), ) class TestUnordered: def test_it(self) -> None: actual = ListCollection([SampleClass(2, "x"), SampleClass(1, "x")]) compare( actual, expected=ListCollection( unordered([SampleClass(1, "x"), SampleClass(2, "x")]) ), ) def test_type_override(self) -> None: actual = TupleCollection((SampleClass(2, "x"), SampleClass(1, "x"))) compare( actual, expected=TupleCollection( unordered( [SampleClass(1, "x"), SampleClass(2, "x")], returns=tuple[SampleClass, ...], ) ), ) class TestMapping: def test_it(self) -> None: actual = DictCollection({"a": SampleClass(1, "2"), "b": SampleClass(3, "4")}) compare( actual, expected=DictCollection(mapping(partial=True)({"a": SampleClass(1, "2")})), ) def test_type_override(self) -> None: actual = OrderedDictCollection(OrderedDict(a=SampleClass(1, "2"))) compare( actual, expected=OrderedDictCollection( mapping(returns=OrderedDict[str, SampleClass])( {"a": SampleClass(1, "2")} ) ), ) testfixtures-12.4.0/tests/test_date.py000066400000000000000000000266061525773641100200640ustar00rootroot00000000000000from datetime import date as d, timedelta, date from time import strptime from typing import cast from testfixtures import ShouldRaise, mock_date, replace, compare from testfixtures.datetime import MockDate from tests import sample1, sample2 from unittest import TestCase class TestDate(TestCase): # NB: Only the today method is currently stubbed out, # if you need other methods, tests and patches # greatfully received! @replace('datetime.date', mock_date()) def test_today(self) -> None: from datetime import date compare(date.today(), d(2001, 1, 1)) compare(date.today(), d(2001, 1, 2)) compare(date.today(), d(2001, 1, 4)) @replace('datetime.date', mock_date(2001, 2, 3)) def test_today_supplied(self) -> None: from datetime import date compare(date.today(), d(2001, 2, 3)) @replace('datetime.date', mock_date(year=2001, month=2, day=3)) def test_today_all_kw(self) -> None: from datetime import date compare(date.today(), d(2001, 2, 3)) @replace('datetime.date', mock_date(None)) def test_today_sequence(self, t: type[MockDate]) -> None: t.add(2002, 1, 1) t.add(2002, 1, 2) t.add(2002, 1, 3) from datetime import date compare(date.today(), d(2002, 1, 1)) compare(date.today(), d(2002, 1, 2)) compare(date.today(), d(2002, 1, 3)) @replace('datetime.date', mock_date(None)) def test_today_requested_longer_than_supplied(self, t: type[MockDate]) -> None: t.add(2002, 1, 1) t.add(2002, 1, 2) from datetime import date compare(date.today(), d(2002, 1, 1)) compare(date.today(), d(2002, 1, 2)) compare(date.today(), d(2002, 1, 3)) compare(date.today(), d(2002, 1, 5)) @replace('datetime.date', mock_date(None)) def test_add_date_supplied(self) -> None: from datetime import date date_mock = cast(type[MockDate], date) date_mock.add(d(2001, 1, 2)) date_mock.add(date(2001, 1, 3)) compare(date.today(), d(2001, 1, 2)) compare(date.today(), d(2001, 1, 3)) def test_instantiate_with_date(self) -> None: from datetime import date t = mock_date(date(2002, 1, 1)) compare(t.today(), d(2002, 1, 1)) @replace('datetime.date', mock_date(strict=True)) def test_call(self, t: type[MockDate]) -> None: compare(t(2002, 1, 2), d(2002, 1, 2)) from datetime import date dt = date(2003, 2, 1) self.assertFalse(dt.__class__ is d) compare(dt, d(2003, 2, 1)) def test_gotcha_import(self) -> None: # standard `replace` caveat, make sure you # patch all relevant places where date # has been imported: @replace('datetime.date', mock_date()) def test_something() -> None: from datetime import date compare(date.today(), d(2001, 1, 1)) compare(sample1.str_today_1(), '2001-01-02') with ShouldRaise(AssertionError) as s: test_something() # This convoluted check is because we can't stub # out the date, since we're testing stubbing out # the date ;-) assert s.raised is not None j, dt1, j, dt2, j = s.raised.args[0].split("'") # check we can parse the date strptime(dt1, '%Y-%m-%d') # check the dt2 bit was as it should be compare(dt2, '2001-01-02') # What you need to do is replace the imported type: @replace('tests.sample1.date', mock_date()) def test_something_fixed() -> None: compare(sample1.str_today_1(), '2001-01-01') test_something_fixed() def test_gotcha_import_and_obtain(self) -> None: # Another gotcha is where people have locally obtained # a class attributes, where the normal patching doesn't # work: @replace('tests.sample1.date', mock_date()) def test_something() -> None: compare(sample1.str_today_2(), '2001-01-01') with ShouldRaise(AssertionError) as s: test_something() # This convoluted check is because we can't stub # out the date, since we're testing stubbing out # the date ;-) assert s.raised is not None j, dt1, j, dt2, j = s.raised.args[0].split("'") # check we can parse the date dt1 = strptime(dt1, '%Y-%m-%d') # check the dt2 bit was as it should be compare(dt2, '2001-01-01') # What you need to do is replace the imported name: @replace('tests.sample1.today', mock_date().today) def test_something_fixed() -> None: compare(sample1.str_today_2(), '2001-01-01') test_something_fixed() # if you have an embedded `today` as above, *and* you need to supply # a list of required dates, then it's often simplest just to # do a manual try-finally with a replacer: def test_import_and_obtain_with_lists(self) -> None: t = mock_date(None) t.add(2002, 1, 1) t.add(2002, 1, 2) from testfixtures import Replacer r = Replacer() r.replace('tests.sample1.today', t.today) try: compare(sample1.str_today_2(), '2002-01-01') compare(sample1.str_today_2(), '2002-01-02') finally: r.restore() @replace('datetime.date', mock_date()) def test_repr(self) -> None: from datetime import date compare(repr(date), "") @replace('datetime.date', mock_date(delta=2)) def test_delta(self) -> None: from datetime import date compare(date.today(), d(2001, 1, 1)) compare(date.today(), d(2001, 1, 3)) compare(date.today(), d(2001, 1, 5)) @replace('datetime.date', mock_date(delta_type='weeks')) def test_delta_type(self) -> None: from datetime import date compare(date.today(), d(2001, 1, 1)) compare(date.today(), d(2001, 1, 8)) compare(date.today(), d(2001, 1, 22)) @replace('datetime.date', mock_date(None)) def test_set(self) -> None: from datetime import date date_mock = cast(type[MockDate], date) date_mock.set(2001, 1, 2) compare(date.today(), d(2001, 1, 2)) date_mock.set(2002, 1, 1) compare(date.today(), d(2002, 1, 1)) compare(date.today(), d(2002, 1, 3)) @replace('datetime.date', mock_date(None)) def test_set_date_supplied(self) -> None: from datetime import date date_mock = cast(type[MockDate], date) date_mock.set(d(2001, 1, 2)) compare(date.today(), d(2001, 1, 2)) date_mock.set(date(2001, 1, 3)) compare(date.today(), d(2001, 1, 3)) @replace('datetime.date', mock_date(None)) def test_set_kw(self) -> None: from datetime import date date_mock = cast(type[MockDate], date) date_mock.set(year=2001, month=1, day=2) compare(date.today(), d(2001, 1, 2)) @replace('datetime.date', mock_date(None)) def test_add_kw(self, t: type[MockDate]) -> None: t.add(year=2002, month=1, day=1) from datetime import date compare(date.today(), d(2002, 1, 1)) @replace('datetime.date', mock_date(strict=True)) def test_isinstance_strict_true(self) -> None: from datetime import date date_mock = cast(type[MockDate], date) to_check = [] to_check.append(date_mock(1999, 1, 1)) to_check.append(date_mock.today()) date_mock.set(2001, 1, 2) to_check.append(date_mock.today()) date_mock.add(2001, 1, 3) to_check.append(date_mock.today()) to_check.append(date_mock.today()) date_mock.set(date_mock(2001, 1, 4)) to_check.append(date_mock.today()) date_mock.add(date_mock(2001, 1, 5)) to_check.append(date_mock.today()) to_check.append(date_mock.today()) date_mock.set(d(2001, 1, 4)) to_check.append(date_mock.today()) date_mock.add(d(2001, 1, 5)) to_check.append(date_mock.today()) to_check.append(date_mock.today()) for inst in to_check: self.assertTrue(isinstance(inst, date_mock), inst) self.assertTrue(inst.__class__ is date_mock, inst) self.assertTrue(isinstance(inst, d), inst) self.assertFalse(inst.__class__ is d, inst) def test_strict_addition(self) -> None: mock_d = mock_date(strict=True) dt = mock_d(2001, 1, 1) + timedelta(days=1) assert type(dt) is mock_d def test_non_strict_addition(self) -> None: from datetime import date mock_d = mock_date(strict=False) dt = mock_d(2001, 1, 1) + timedelta(days=1) assert type(dt) is date def test_strict_add(self) -> None: mock_d = mock_date(None, strict=True) mock_d.add(2001, 1, 1) assert type(mock_d.today()) is mock_d def test_non_strict_add(self) -> None: from datetime import date mock_d = mock_date(None, strict=False) mock_d.add(2001, 1, 1) assert type(mock_d.today()) is date @replace('datetime.date', mock_date()) def test_isinstance_default(self) -> None: from datetime import date date_mock = cast(type[MockDate], date) to_check = [] to_check.append(date_mock(1999, 1, 1)) to_check.append(date_mock.today()) date_mock.set(2001, 1, 2) to_check.append(date_mock.today()) date_mock.add(2001, 1, 3) to_check.append(date_mock.today()) to_check.append(date_mock.today()) date_mock.set(date_mock(2001, 1, 4)) to_check.append(date_mock.today()) date_mock.add(date_mock(2001, 1, 5)) to_check.append(date_mock.today()) to_check.append(date_mock.today()) date_mock.set(d(2001, 1, 4)) to_check.append(date_mock.today()) date_mock.add(d(2001, 1, 5)) to_check.append(date_mock.today()) to_check.append(date_mock.today()) for inst in to_check: self.assertFalse(isinstance(inst, date_mock), inst) self.assertFalse(inst.__class__ is date_mock, inst) self.assertTrue(isinstance(inst, d), inst) self.assertTrue(inst.__class__ is d, inst) def test_tick_when_static(self) -> None: date = mock_date(delta=0) compare(date.today(), expected=d(2001, 1, 1)) date.tick(days=1) compare(date.today(), expected=d(2001, 1, 2)) def test_tick_when_dynamic(self) -> None: # hopefully not that common? date = mock_date() compare(date.today(), expected=d(2001, 1, 1)) date.tick(days=1) compare(date.today(), expected=d(2001, 1, 3)) def test_tick_with_timedelta_instance(self) -> None: date = mock_date(delta=0) compare(date.today(), expected=d(2001, 1, 1)) date.tick(timedelta(days=1)) compare(date.today(), expected=d(2001, 1, 2)) def test_old_import(self) -> None: from testfixtures import test_date assert test_date is mock_date def test_add_timedelta_not_strict(self) -> None: mock_class = mock_date() value = mock_class.today() + timedelta(days=1) assert isinstance(value, date) assert type(value) is date def test_add_timedelta_strict(self) -> None: mock_class = mock_date(strict=True) value = mock_class.today() + timedelta(days=1) assert isinstance(value, date) assert type(value) is mock_class testfixtures-12.4.0/tests/test_datetime.py000066400000000000000000000444671525773641100207500ustar00rootroot00000000000000from datetime import date, datetime from datetime import datetime as d from datetime import timedelta from datetime import tzinfo from typing import cast from testfixtures import ( Replacer, ShouldRaise, compare, mock_datetime, mock_date, replace, ) from testfixtures.datetime import MockDateTime from tests import sample1 from unittest import TestCase class SampleTZInfo(tzinfo): def tzname(self, dt: datetime | None) -> str: return "SAMPLE" def utcoffset(self, dt: datetime | None) -> timedelta | None: return timedelta(minutes=3) + self.dst(dt) def dst(self, dt: datetime | None) -> timedelta: return timedelta(minutes=1) class SampleTZInfo2(tzinfo): def tzname(self, dt: datetime | None) -> str: return "SAMPLE2" def utcoffset(self, dt: datetime | None) -> timedelta | None: return timedelta(minutes=5) def dst(self, dt: datetime | None) -> timedelta: return timedelta(minutes=0) class WeirdTZInfo(tzinfo): def tzname(self, dt: datetime | None) -> str: return "WEIRD" def utcoffset(self, dt: datetime | None) -> timedelta | None: return None def dst(self, dt: datetime | None) -> timedelta | None: return None def test_sample_tzinfos() -> None: compare(SampleTZInfo().tzname(None), expected='SAMPLE') compare(SampleTZInfo2().tzname(None), expected='SAMPLE2') compare(WeirdTZInfo().tzname(None), expected='WEIRD') compare(WeirdTZInfo().utcoffset(datetime(1, 2, 3)), expected=None) compare(WeirdTZInfo().dst(datetime(1, 2, 3)), expected=None) class TestDateTime(TestCase): @replace('datetime.datetime', mock_datetime()) def test_now(self) -> None: from datetime import datetime compare(datetime.now(), d(2001, 1, 1, 0, 0, 0)) compare(datetime.now(), d(2001, 1, 1, 0, 0, 10)) compare(datetime.now(), d(2001, 1, 1, 0, 0, 30)) @replace('datetime.datetime', mock_datetime()) def test_now_with_tz_supplied(self) -> None: from datetime import datetime info = SampleTZInfo() compare(datetime.now(info), d(2001, 1, 1, 0, 4, tzinfo=SampleTZInfo())) @replace('datetime.datetime', mock_datetime(tzinfo=SampleTZInfo())) def test_now_with_tz_setup(self) -> None: from datetime import datetime compare(datetime.now(), d(2001, 1, 1)) @replace('datetime.datetime', mock_datetime(tzinfo=WeirdTZInfo())) def test_now_with_werid_tz_setup(self) -> None: from datetime import datetime with ShouldRaise(TypeError('tzinfo with .utcoffset() returning None is not supported')): datetime.now(tz=SampleTZInfo()) @replace('datetime.datetime', mock_datetime(tzinfo=SampleTZInfo())) def test_now_with_tz_setup_and_supplied(self) -> None: from datetime import datetime info = SampleTZInfo2() compare(datetime.now(info), d(2001, 1, 1, 0, 1, tzinfo=info)) @replace('datetime.datetime', mock_datetime(tzinfo=SampleTZInfo())) def test_now_with_tz_setup_and_same_supplied(self) -> None: from datetime import datetime info = SampleTZInfo() compare(datetime.now(info), d(2001, 1, 1, tzinfo=info)) def test_now_with_tz_instance(self) -> None: dt = mock_datetime(d(2001, 1, 1, tzinfo=SampleTZInfo())) compare(dt.now(), d(2001, 1, 1)) def test_now_with_tz_instance_and_supplied(self) -> None: dt = mock_datetime(d(2001, 1, 1, tzinfo=SampleTZInfo())) info = SampleTZInfo2() compare(dt.now(info), d(2001, 1, 1, 0, 1, tzinfo=info)) def test_now_with_tz_instance_and_same_supplied(self) -> None: dt = mock_datetime(d(2001, 1, 1, tzinfo=SampleTZInfo())) info = SampleTZInfo() compare(dt.now(info), d(2001, 1, 1, tzinfo=info)) @replace('datetime.datetime', mock_datetime(2002, 1, 1, 1, 2, 3)) def test_now_supplied(self) -> None: from datetime import datetime compare(datetime.now(), d(2002, 1, 1, 1, 2, 3)) @replace('datetime.datetime', mock_datetime(None)) def test_now_sequence(self, t: type[MockDateTime]) -> None: t.add(2002, 1, 1, 1, 0, 0) t.add(2002, 1, 1, 2, 0, 0) t.add(2002, 1, 1, 3, 0, 0) from datetime import datetime compare(datetime.now(), d(2002, 1, 1, 1, 0, 0)) compare(datetime.now(), d(2002, 1, 1, 2, 0, 0)) compare(datetime.now(), d(2002, 1, 1, 3, 0, 0)) @replace('datetime.datetime', mock_datetime()) def test_add_and_set(self, t: type[MockDateTime]) -> None: t.add(2002, 1, 1, 1, 0, 0) t.add(2002, 1, 1, 2, 0, 0) t.set(2002, 1, 1, 3, 0, 0) from datetime import datetime compare(datetime.now(), d(2002, 1, 1, 3, 0, 0)) compare(datetime.now(), d(2002, 1, 1, 3, 0, 10)) compare(datetime.now(), d(2002, 1, 1, 3, 0, 30)) @replace('datetime.datetime', mock_datetime(None)) def test_add_datetime_supplied(self, t: type[MockDateTime]) -> None: from datetime import datetime t.add(d(2002, 1, 1, 1)) t.add(datetime(2002, 1, 1, 2)) compare(datetime.now(), d(2002, 1, 1, 1, 0, 0)) compare(datetime.now(), d(2002, 1, 1, 2, 0, 0)) tzinfo = SampleTZInfo() tzrepr = repr(tzinfo) with ShouldRaise(ValueError( 'Cannot add datetime with tzinfo of %s as configured to use None' %( tzrepr ))): t.add(d(2001, 1, 1, tzinfo=tzinfo)) def test_instantiate_with_datetime(self) -> None: from datetime import datetime t = mock_datetime(datetime(2002, 1, 1, 1)) compare(t.now(), d(2002, 1, 1, 1, 0, 0)) @replace('datetime.datetime', mock_datetime(None)) def test_now_requested_longer_than_supplied(self, t: type[MockDateTime]) -> None: t.add(2002, 1, 1, 1, 0, 0) t.add(2002, 1, 1, 2, 0, 0) from datetime import datetime compare(datetime.now(), d(2002, 1, 1, 1, 0, 0)) compare(datetime.now(), d(2002, 1, 1, 2, 0, 0)) compare(datetime.now(), d(2002, 1, 1, 2, 0, 10)) compare(datetime.now(), d(2002, 1, 1, 2, 0, 30)) @replace('datetime.datetime', mock_datetime(strict=True)) def test_call(self, t: type[MockDateTime]) -> None: compare(t(2002, 1, 2, 3, 4, 5), d(2002, 1, 2, 3, 4, 5)) from datetime import datetime dt = datetime(2001, 1, 1, 1, 0, 0) self.assertFalse(dt.__class__ is d) compare(dt, d(2001, 1, 1, 1, 0, 0)) def test_date_return_type(self) -> None: with Replacer() as r: r.replace('datetime.datetime', mock_datetime()) from datetime import datetime dt = datetime(2001, 1, 1, 1, 0, 0) d = dt.date() compare(d, date(2001, 1, 1)) self.assertTrue(d.__class__ is date) def test_date_return_type_picky(self) -> None: # type checking is a bitch :-/ date_type = mock_date(strict=True) with Replacer() as r: r.replace('datetime.datetime', mock_datetime(date_type=date_type, strict=True, )) from datetime import datetime dt = datetime(2010, 8, 26, 14, 33, 13) d = dt.date() compare(d, date_type(2010, 8, 26)) self.assertTrue(d.__class__ is date_type) # if you have an embedded `now` as above, *and* you need to supply # a list of required datetimes, then it's often simplest just to # do a manual try-finally with a replacer: def test_import_and_obtain_with_lists(self) -> None: t = mock_datetime(None) t.add(2002, 1, 1, 1, 0, 0) t.add(2002, 1, 1, 2, 0, 0) from testfixtures import Replacer r = Replacer() r.replace('tests.sample1.now', t.now) try: compare(sample1.str_now_2(), '2002-01-01 01:00:00') compare(sample1.str_now_2(), '2002-01-01 02:00:00') finally: r.restore() @replace('datetime.datetime', mock_datetime()) def test_repr(self) -> None: from datetime import datetime compare(repr(datetime), "") @replace('datetime.datetime', mock_datetime(delta=1)) def test_delta(self) -> None: from datetime import datetime compare(datetime.now(), d(2001, 1, 1, 0, 0, 0)) compare(datetime.now(), d(2001, 1, 1, 0, 0, 1)) compare(datetime.now(), d(2001, 1, 1, 0, 0, 2)) @replace('datetime.datetime', mock_datetime(delta_type='minutes')) def test_delta_type(self) -> None: from datetime import datetime compare(datetime.now(), d(2001, 1, 1, 0, 0, 0)) compare(datetime.now(), d(2001, 1, 1, 0, 10, 0)) compare(datetime.now(), d(2001, 1, 1, 0, 30, 0)) @replace('datetime.datetime', mock_datetime(None)) def test_set(self) -> None: from datetime import datetime dt_mock = cast(type[MockDateTime], datetime) dt_mock.set(2001, 1, 1, 1, 0, 1) compare(datetime.now(), d(2001, 1, 1, 1, 0, 1)) dt_mock.set(2002, 1, 1, 1, 0, 0) compare(datetime.now(), d(2002, 1, 1, 1, 0, 0)) compare(datetime.now(), d(2002, 1, 1, 1, 0, 20)) @replace('datetime.datetime', mock_datetime(None)) def test_set_datetime_supplied(self, t: type[MockDateTime]) -> None: from datetime import datetime t.set(d(2002, 1, 1, 1)) compare(datetime.now(), d(2002, 1, 1, 1, 0, 0)) t.set(datetime(2002, 1, 1, 2)) compare(datetime.now(), d(2002, 1, 1, 2, 0, 0)) tzinfo = SampleTZInfo() tzrepr = repr(tzinfo) with ShouldRaise(ValueError( 'Cannot add datetime with tzinfo of %s as configured to use None' %( tzrepr ))): t.set(d(2001, 1, 1, tzinfo=tzinfo)) @replace('datetime.datetime', mock_datetime(None, tzinfo=SampleTZInfo())) def test_set_tz_setup(self) -> None: from datetime import datetime dt_mock = cast(type[MockDateTime], datetime) dt_mock.set(year=2002, month=1, day=1) compare(datetime.now(), d(2002, 1, 1)) @replace('datetime.datetime', mock_datetime(None)) def test_set_kw(self) -> None: from datetime import datetime dt_mock = cast(type[MockDateTime], datetime) dt_mock.set(year=2002, month=1, day=1) compare(datetime.now(), d(2002, 1, 1)) @replace('datetime.datetime', mock_datetime(None)) def test_set_tzinfo_kw(self) -> None: from datetime import datetime dt_mock = cast(type[MockDateTime], datetime) with ShouldRaise(TypeError('Cannot add using tzinfo on MockDateTime')): dt_mock.set(year=2002, month=1, day=1, tzinfo=SampleTZInfo()) @replace('datetime.datetime', mock_datetime(None)) def test_set_tzinfo_args(self) -> None: from datetime import datetime dt_mock = cast(type[MockDateTime], datetime) with ShouldRaise(TypeError('Cannot add using tzinfo on MockDateTime')): dt_mock.set(2002, 1, 2, 3, 4, 5, 6, SampleTZInfo()) # type: ignore[arg-type] @replace('datetime.datetime', mock_datetime(None)) def test_add_kw(self, t: type[MockDateTime]) -> None: from datetime import datetime t.add(year=2002, day=1, month=1) compare(datetime.now(), d(2002, 1, 1)) @replace('datetime.datetime', mock_datetime(None)) def test_add_tzinfo_kw(self, t: type[MockDateTime]) -> None: with ShouldRaise(TypeError('Cannot add using tzinfo on MockDateTime')): t.add(year=2002, month=1, day=1, tzinfo=SampleTZInfo()) @replace('datetime.datetime', mock_datetime(None)) def test_add_tzinfo_args(self, t: type[MockDateTime]) -> None: with ShouldRaise(TypeError('Cannot add using tzinfo on MockDateTime')): t.add(2002, 1, 2, 3, 4, 5, 6, SampleTZInfo()) # type: ignore[arg-type] @replace('datetime.datetime', mock_datetime(2001, 1, 2, 3, 4, 5, 6, SampleTZInfo())) def test_max_number_args(self) -> None: from datetime import datetime compare(datetime.now(), d(2001, 1, 2, 3, 4, 5, 6)) @replace('datetime.datetime', mock_datetime(2001, 1, 2)) def test_min_number_args(self) -> None: from datetime import datetime compare(datetime.now(), d(2001, 1, 2)) @replace('datetime.datetime', mock_datetime( year=2001, month=1, day=2, hour=3, minute=4, second=5, microsecond=6, tzinfo=SampleTZInfo() )) def test_all_kw(self) -> None: from datetime import datetime compare(datetime.now(), d(2001, 1, 2, 3, 4, 5, 6)) @replace('datetime.datetime', mock_datetime(2001, 1, 2)) def test_utc_now(self) -> None: from datetime import datetime compare(datetime.utcnow(), d(2001, 1, 2)) @replace('datetime.datetime', mock_datetime(2001, 1, 2, tzinfo=SampleTZInfo())) def test_utc_now_with_tz(self) -> None: from datetime import datetime compare(datetime.utcnow(), d(2001, 1, 1, 23, 56)) @replace('datetime.datetime', mock_datetime(strict=True)) def test_isinstance_strict(self) -> None: from datetime import datetime dt_mock = cast(type[MockDateTime], datetime) to_check = [] to_check.append(dt_mock(1999, 1, 1)) to_check.append(dt_mock.now()) to_check.append(dt_mock.now(SampleTZInfo())) to_check.append(dt_mock.utcnow()) dt_mock.set(2001, 1, 1, 20) to_check.append(dt_mock.now()) dt_mock.add(2001, 1, 1, 21) to_check.append(dt_mock.now()) to_check.append(dt_mock.now()) dt_mock.set(dt_mock(2001, 1, 1, 22)) to_check.append(dt_mock.now()) to_check.append(dt_mock.now(SampleTZInfo())) dt_mock.add(dt_mock(2001, 1, 1, 23)) to_check.append(dt_mock.now()) to_check.append(dt_mock.now()) to_check.append(dt_mock.now(SampleTZInfo())) dt_mock.set(d(2001, 1, 1, 22)) to_check.append(dt_mock.now()) dt_mock.add(d(2001, 1, 1, 23)) to_check.append(dt_mock.now()) to_check.append(dt_mock.now()) to_check.append(dt_mock.now(SampleTZInfo())) for inst in to_check: self.assertTrue(isinstance(inst, dt_mock), inst) self.assertTrue(inst.__class__ is dt_mock, inst) self.assertTrue(isinstance(inst, d), inst) self.assertFalse(inst.__class__ is d, inst) def test_strict_addition(self) -> None: mock_dt = mock_datetime(strict=True) dt = mock_dt(2001, 1, 1) + timedelta(days=1) assert type(dt) is mock_dt def test_non_strict_addition(self) -> None: from datetime import datetime mock_dt = mock_datetime(strict=False) dt = mock_dt(2001, 1, 1) + timedelta(days=1) assert type(dt) is datetime def test_strict_add(self) -> None: mock_dt = mock_datetime(None, strict=True) mock_dt.add(2001, 1, 1) assert type(mock_dt.now()) is mock_dt def test_non_strict_add(self) -> None: from datetime import datetime mock_dt = mock_datetime(None, strict=False) mock_dt.add(2001, 1, 1) assert type(mock_dt.now()) is datetime @replace('datetime.datetime', mock_datetime()) def test_isinstance_default(self) -> None: from datetime import datetime dt_mock = cast(type[MockDateTime], datetime) to_check = [] to_check.append(dt_mock(1999, 1, 1)) to_check.append(dt_mock.now()) to_check.append(dt_mock.now(SampleTZInfo())) to_check.append(dt_mock.utcnow()) dt_mock.set(2001, 1, 1, 20) to_check.append(dt_mock.now()) dt_mock.add(2001, 1, 1, 21) to_check.append(dt_mock.now()) to_check.append(dt_mock.now(SampleTZInfo())) dt_mock.set(dt_mock(2001, 1, 1, 22)) to_check.append(dt_mock.now()) dt_mock.add(dt_mock(2001, 1, 1, 23)) to_check.append(dt_mock.now()) to_check.append(dt_mock.now()) to_check.append(dt_mock.now(SampleTZInfo())) dt_mock.set(d(2001, 1, 1, 22)) to_check.append(dt_mock.now()) dt_mock.add(d(2001, 1, 1, 23)) to_check.append(dt_mock.now()) to_check.append(dt_mock.now()) to_check.append(dt_mock.now(SampleTZInfo())) for inst in to_check: self.assertFalse(isinstance(inst, dt_mock), inst) self.assertFalse(inst.__class__ is dt_mock, inst) self.assertTrue(isinstance(inst, d), inst) self.assertTrue(inst.__class__ is d, inst) def test_subsecond_deltas(self) -> None: mock_dt = mock_datetime(delta=0.5) compare(mock_dt.now(), d(2001, 1, 1, 0, 0, 0, 0)) compare(mock_dt.now(), d(2001, 1, 1, 0, 0, 0, 500000)) compare(mock_dt.now(), d(2001, 1, 1, 0, 0, 1, 0)) def test_ms_delta(self) -> None: mock_dt = mock_datetime(delta=100, delta_type='microseconds') compare(mock_dt.now(), d(2001, 1, 1, 0, 0, 0, 0)) compare(mock_dt.now(), d(2001, 1, 1, 0, 0, 0, 100)) compare(mock_dt.now(), d(2001, 1, 1, 0, 0, 0, 200)) def test_tick_when_static(self) -> None: mock_dt = mock_datetime(delta=0) compare(mock_dt.now(), expected=d(2001, 1, 1)) mock_dt.tick(hours=1) compare(mock_dt.now(), expected=d(2001, 1, 1, 1)) def test_tick_when_dynamic(self) -> None: # hopefully not that common? mock_dt = mock_datetime() compare(mock_dt.now(), expected=d(2001, 1, 1)) mock_dt.tick(hours=1) compare(mock_dt.now(), expected=d(2001, 1, 1, 1, 0, 10)) def test_tick_with_timedelta_instance(self) -> None: mock_dt = mock_datetime(delta=0) compare(mock_dt.now(), expected=d(2001, 1, 1)) mock_dt.tick(timedelta(hours=1)) compare(mock_dt.now(), expected=d(2001, 1, 1, 1)) def test_old_import(self) -> None: from testfixtures import test_datetime assert test_datetime is mock_datetime def test_add_timedelta_not_strict(self) -> None: mock_class = mock_datetime() value = mock_class.now() + timedelta(seconds=10) assert isinstance(value, datetime) assert type(value) is datetime def test_add_timedelta_strict(self) -> None: mock_class = mock_datetime(strict=True) value = mock_class.now() + timedelta(seconds=10) assert isinstance(value, datetime) assert type(value) is mock_class testfixtures-12.4.0/tests/test_diff.py000066400000000000000000000015741525773641100200540ustar00rootroot00000000000000from unittest import TestCase from testfixtures import diff class TestDiff(TestCase): def test_example(self): actual = diff(''' line1 line2 line3 ''', ''' line1 line changed line3 ''') expected = '''\ --- first +++ second @@ -1,5 +1,5 @@ line1 - line2 + line changed line3 ''' self.assertEqual( [line.strip() for line in expected.split("\n")], [line.strip() for line in actual.split("\n")], '\n%r\n!=\n%r' % (expected, actual) ) def test_no_newlines(self): actual = diff('x', 'y') expected = '--- first\n+++ second\n@@ -1 +1 @@\n-x\n+y' self.assertEqual( expected, actual, '\n%r\n!=\n%r' % (expected, actual) ) testfixtures-12.4.0/tests/test_django/000077500000000000000000000000001525773641100200255ustar00rootroot00000000000000testfixtures-12.4.0/tests/test_django/__init__.py000066400000000000000000000000541525773641100221350ustar00rootroot00000000000000import pytest pytest.importorskip("django") testfixtures-12.4.0/tests/test_django/manage.py000066400000000000000000000004041525773641100216250ustar00rootroot00000000000000import os def main(): os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.test_django.settings") from django.core.management import execute_from_command_line execute_from_command_line() if __name__ == "__main__": # pragma: no cover main() testfixtures-12.4.0/tests/test_django/models.py000066400000000000000000000003721525773641100216640ustar00rootroot00000000000000from django.db import models class OtherModel(models.Model): pass class SampleModel(models.Model): value = models.IntegerField() not_editable = models.IntegerField(editable=False) created = models.DateTimeField(auto_now_add=True) testfixtures-12.4.0/tests/test_django/settings.py000066400000000000000000000003701525773641100222370ustar00rootroot00000000000000SECRET_KEY = 'fake-key' INSTALLED_APPS = [ 'django.contrib.auth', 'django.contrib.contenttypes', "tests.test_django", ] DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3'}} DEFAULT_AUTO_FIELD='django.db.models.AutoField' testfixtures-12.4.0/tests/test_django/test_compare.py000066400000000000000000000053621525773641100230720ustar00rootroot00000000000000from unittest import TestCase import pytest from django.contrib.auth.models import User from testfixtures import OutputCapture, Replacer from testfixtures.comparing import registry from .models import SampleModel from tests.test_django.manage import main from tests.test_compare import CompareHelper from testfixtures import compare class CompareTests(CompareHelper, TestCase): def test_simple_same(self): compare(SampleModel(id=1), SampleModel(id=1)) def test_simple_diff(self): self.check_raises( SampleModel(id=1), SampleModel(id=2), message=( 'SampleModel not as expected:\n' '\n' 'same:\n' "['value']\n" '\n' 'values differ:\n' "'id': 1 != 2" ) ) def test_simple_ignore_fields(self): compare(SampleModel(id=1), SampleModel(id=1), ignore_fields=['id']) def test_ignored_because_speshul(self): compare(SampleModel(not_editable=1), SampleModel(not_editable=2)) def test_ignored_because_no_longer_speshul(self): self.check_raises( SampleModel(not_editable=1), SampleModel(not_editable=2), message=( 'SampleModel not as expected:\n' '\n' 'same:\n' "['created', 'id', 'value']\n" '\n' 'values differ:\n' "'not_editable': 1 != 2" ), non_editable_fields=True ) def test_normal_compare_id_same(self): # without the comparer registered, the models compare equal: with registry(): compare(SampleModel(id=1, value=1), SampleModel(id=1, value=2)) def test_normal_compare_id_diff(self): self.check_raises( SampleModel(id=3, value=1), SampleModel(id=4, value=2), message=( 'SampleModel not as expected:\n' '\n' 'values differ:\n' "'id': 3 != 4\n" "'value': 1 != 2" ) ) def test_manage(self): with OutputCapture() as output: with Replacer() as r: r.replace('os.environ.DJANGO_SETTINGS_MODULE', '', strict=False) r.replace('sys.argv', ['x', 'check']) main() output.compare('System check identified no issues (0 silenced).') @pytest.mark.django_db def test_many_to_many_same(self): user = User.objects.create(username='foo') compare(user, expected=User( username='foo', first_name='', last_name='', is_superuser=False ), ignore_fields=['id', 'date_joined']) testfixtures-12.4.0/tests/test_django/test_shouldraise.py000066400000000000000000000012051525773641100237560ustar00rootroot00000000000000from django.core.exceptions import ValidationError from testfixtures import ShouldRaise from testfixtures.shouldraise import ShouldAssert class TestShouldRaiseWithValidatorErrors: def test_as_expected(self): with ShouldRaise(ValidationError("d'oh")): raise ValidationError("d'oh") def test_not_as_expected(self): message = ( 'not equal:\n' 'ValidationError(["d\'oh"]) (expected)\n' 'ValidationError([\'nuts\']) (raised)' ) with ShouldAssert(message): with ShouldRaise(ValidationError("d'oh")): raise ValidationError("nuts") testfixtures-12.4.0/tests/test_formats.py000066400000000000000000000111241525773641100206070ustar00rootroot00000000000000from typing import Any import pytest from testfixtures import compare, ShouldRaise, Replacer from testfixtures import formats from testfixtures.formats import JSON, YAML, TOML class TestJSON: def test_parse_dict(self) -> None: result = JSON.parse('{"key": "value", "num": 42}') compare(result, expected={"key": "value", "num": 42}) def test_parse_list(self) -> None: result = JSON.parse('[1, 2, 3]') compare(result, expected=[1, 2, 3]) def test_parse_nested(self) -> None: result = JSON.parse('{"outer": {"inner": "value"}}') compare(result, expected={"outer": {"inner": "value"}}) def test_render_dict(self) -> None: result = JSON.render({"key": "value", "num": 42}) compare(result, expected='{"key": "value", "num": 42}') def test_render_list(self) -> None: result = JSON.render([1, 2, 3]) compare(result, expected='[1, 2, 3]') def test_roundtrip(self) -> None: original: dict[str, Any] = {"a": 1, "b": [2, 3], "c": {"d": "e"}} serialized = JSON.render(original) deserialized = JSON.parse(serialized) compare(deserialized, expected=original) def test_typed_annotation(self) -> None: data: dict[str, str] = {"key": "value"} serialized: str = JSON.render(data) deserialized: dict[str, str] = JSON.parse(serialized) compare(deserialized, expected=data) class TestYAML: def test_parse_dict(self) -> None: pytest.importorskip("yaml") result = YAML.parse('key: value\nnum: 42\n') compare(result, expected={"key": "value", "num": 42}) def test_parse_list(self) -> None: pytest.importorskip("yaml") result = YAML.parse('- 1\n- 2\n- 3\n') compare(result, expected=[1, 2, 3]) def test_render_dict(self) -> None: pytest.importorskip("yaml") result = YAML.render({"key": "value", "num": 42}) compare(result, expected='key: value\nnum: 42\n') def test_roundtrip(self) -> None: pytest.importorskip("yaml") original: dict[str, Any] = {"a": 1, "b": [2, 3], "c": {"d": "e"}} serialized = YAML.render(original) deserialized = YAML.parse(serialized) compare(deserialized, expected=original) def test_typed_annotation(self) -> None: pytest.importorskip("yaml") data: list[str] = ["foo", "bar", "baz"] serialized: str = YAML.render(data) deserialized: list[str] = YAML.parse(serialized) compare(deserialized, expected=data) def test_missing_pyyaml_parse(self) -> None: with ( Replacer() as r, ShouldRaise(ImportError("YAML support requires pyyaml to be installed")) ): r.replace('.yaml', None, container=formats) YAML.parse('key: value\n') def test_missing_pyyaml_render(self) -> None: with ( Replacer() as r, ShouldRaise(ImportError("YAML support requires pyyaml to be installed")) ): r.replace('.yaml', None, container=formats) YAML.render({"key": "value"}) class TestTOML: def test_parse_dict(self) -> None: result = TOML.parse('key = "value"\nnum = 42\n') compare(result, expected={"key": "value", "num": 42}) def test_parse_nested(self) -> None: result = TOML.parse('[section]\nkey = "value"\n') compare(result, expected={"section": {"key": "value"}}) def test_parse_array(self) -> None: result = TOML.parse('items = [1, 2, 3]\n') compare(result, expected={"items": [1, 2, 3]}) def test_render_dict(self) -> None: pytest.importorskip("tomlkit") result = TOML.render({"key": "value", "num": 42}) compare(result, expected='key = "value"\nnum = 42\n') def test_roundtrip(self) -> None: pytest.importorskip("tomlkit") original: dict[str, Any] = {"a": 1, "b": [2, 3], "c": {"d": "e"}} serialized = TOML.render(original) deserialized = TOML.parse(serialized) compare(deserialized, expected=original) def test_typed_annotation(self) -> None: pytest.importorskip("tomlkit") data: dict[str, int] = {"num": 42} serialized: str = TOML.render(data) deserialized: dict[str, int] = TOML.parse(serialized) compare(deserialized, expected=data) def test_missing_tomlkit_render(self) -> None: with ( Replacer() as r, ShouldRaise(ImportError('TOML writing requires tomlkit to be installed')) ): r.replace('.tomlkit', None, container=formats) TOML.render({"key": "value"}) testfixtures-12.4.0/tests/test_generator.py000066400000000000000000000007231525773641100211250ustar00rootroot00000000000000from unittest import TestCase from types import GeneratorType from testfixtures import generator class TestG(TestCase): def test_example(self): g = generator(1, 2, 3) self.assertTrue(isinstance(g, GeneratorType)) self.assertEqual(tuple(g), (1, 2, 3)) def test_from_sequence(self): s = (1, 2, 3) g = generator(*s) self.assertTrue(isinstance(g, GeneratorType)) self.assertEqual(tuple(g), (1, 2, 3)) testfixtures-12.4.0/tests/test_log_capture.py000066400000000000000000000162711525773641100214500ustar00rootroot00000000000000from logging import getLogger, ERROR from unittest import TestCase from testfixtures.shouldraise import ShouldAssert from testfixtures.mock import patch from testfixtures import ( log_capture, compare, Comparison as C, LogCapture ) root = getLogger() one = getLogger('one') two = getLogger('two') child = getLogger('one.child') class TestLog_Capture(TestCase): @log_capture('two', 'one.child') @log_capture('one') @log_capture() def test_logging(self, l1, l2, l3): # we can now log as normal root.info('1') one.info('2') two.info('3') child.info('4') # and later check what was logged l1.check( ('root', 'INFO', '1'), ('one', 'INFO', '2'), ('two', 'INFO', '3'), ('one.child', 'INFO', '4'), ) l2.check( ('one', 'INFO', '2'), ('one.child', 'INFO', '4') ) l3.check( ('two', 'INFO', '3'), ('one.child', 'INFO', '4') ) # each logger also exposes the real # log records should anything else be neeeded compare(l3.records, [ C('logging.LogRecord'), C('logging.LogRecord'), ]) @log_capture(ensure_checks_above=ERROR) def test_simple_strict(self, l): root.error('during') l.check(("root", "ERROR", "during")) def test_uninstall_properly(self): root = getLogger() child = getLogger('child') before_root = root.handlers[:] before_child = child.handlers[:] try: old_root_level = root.level root.setLevel(49) old_child_level = child.level child.setLevel(69) @log_capture('child') @log_capture() def test_method(l1, l2): root = getLogger() root.info('1') self.assertEqual(root.level, 1) child = getLogger('child') self.assertEqual(child.level, 1) child.info('2') l1.check( ('root', 'INFO', '1'), ('child', 'INFO', '2'), ) l2.check( ('child', 'INFO', '2'), ) test_method() self.assertEqual(root.level, 49) self.assertEqual(child.level, 69) self.assertEqual(root.handlers, before_root) self.assertEqual(child.handlers, before_child) finally: root.setLevel(old_root_level) child.setLevel(old_child_level) @log_capture() def test_decorator_returns_logcapture(self, l): # check for what we get, so we only have to write # tests in test_logcapture.py self.assertTrue(isinstance(l, LogCapture)) def test_remove_existing_handlers(self): logger = getLogger() # get original handlers original = logger.handlers try: # put in a stub which will blow up if used logger.handlers = start = [object()] @log_capture() def test_method(l): logger.info('during') l.check(('root', 'INFO', 'during')) test_method() compare(logger.handlers, start) finally: logger.handlers = original def test_clear_global_state(self): from logging import _handlers, _handlerList capture = LogCapture() capture.uninstall() self.assertFalse(capture in _handlers) self.assertFalse(capture in _handlerList) def test_no_propogate(self): logger = getLogger('child') # paranoid check compare(logger.propagate, True) @log_capture('child', propagate=False) def test_method(l): logger.info('a log message') l.check(('child', 'INFO', 'a log message')) with LogCapture() as global_log: test_method() global_log.check() compare(logger.propagate, True) def test_different_attributes(self): with LogCapture(attributes=('funcName', 'processName')) as log: getLogger().info('oh hai') log.check( ('test_different_attributes', 'MainProcess') ) def test_missing_attribute(self): with LogCapture(attributes=('msg', 'lolwut')) as log: getLogger().info('oh %s', 'hai') log.check( ('oh %s', None) ) def test_single_attribute(self): # one which isn't a string, to boot! with LogCapture(attributes=['msg']) as log: getLogger().info(dict(foo='bar', baz='bob')) log.check( dict(foo='bar', baz='bob'), ) def test_callable_instead_of_attribute(self): def extract_msg(record): return {k: v for (k, v) in record.msg.items() if k != 'baz'} with LogCapture(attributes=extract_msg) as log: getLogger().info(dict(foo='bar', baz='bob')) log.check( dict(foo='bar'), ) def test_msg_is_none(self): with LogCapture(attributes=('msg', 'foo')) as log: getLogger().info(None, extra=dict(foo='bar')) log.check( (None, 'bar') ) def test_normal_check(self): with LogCapture() as log: getLogger().info('oh hai') with ShouldAssert( "sequence not as expected:\n\n" "same:\n" "()\n\n" "expected:\n" "(('root', 'INFO', 'oh noez'),)\n\n" "actual:\n" "(('root', 'INFO', 'oh hai'),)" ): log.check(('root', 'INFO', 'oh noez')) def test_recursive_check(self): with LogCapture(recursive_check=True) as log: getLogger().info('oh hai') with ShouldAssert( "sequence not as expected:\n\n" "same:\n()\n\n" "expected:\n(('root', 'INFO', 'oh noez'),)\n\n" "actual:\n(('root', 'INFO', 'oh hai'),)\n\n" "While comparing [0]: sequence not as expected:\n\n" "same:\n('root', 'INFO')\n\n" "expected:\n" "('oh noez',)\n\n" "actual:\n" "('oh hai',)\n\n" "While comparing [0][2]: 'oh noez' (expected) != 'oh hai' (actual)" ): log.check(('root', 'INFO', 'oh noez')) @log_capture() @patch('tests.sample1.SampleClassA') def test_patch_then_log(self, a1, a2): actual = [type(c).__name__ for c in (a1, a2)] compare(actual, expected=['MagicMock', 'LogCaptureForDecorator']) @patch('tests.sample1.SampleClassA') @log_capture() def test_log_then_patch(self, a1, a2): actual = [type(c).__name__ for c in (a1, a2)] compare(actual, expected=['LogCaptureForDecorator', 'MagicMock']) class BaseCaptureTest(TestCase): a = 33 @log_capture() def test_logs_if_a_smaller_than_44(self, logs): logger = getLogger() if self.a < 44: logger.info('{} is smaller than 44'.format(self.a)) logs.check( ('root', 'INFO', '{} is smaller than 44'.format(self.a)), ) class SubclassCaptureTest(BaseCaptureTest): a = 2 testfixtures-12.4.0/tests/test_logcapture.py000066400000000000000000000660551525773641100213160ustar00rootroot00000000000000import atexit import logging from logging import getLogger, INFO, WARNING, ERROR, Filter, shutdown from textwrap import dedent from unittest import TestCase from testfixtures import Replacer, LogCapture, compare, Replace, ShouldRaise, ShouldWarn from testfixtures.logcapture import Entry, LoggingSource from testfixtures.mock import Mock, call from testfixtures.shouldraise import ShouldAssert root = getLogger() one = getLogger('one') two = getLogger('two') child = getLogger('one.child') class DummyFilter(Filter): def filter(self, _): return True class LevellessSource: # A minimal CaptureSource for a framework that has no comparable numeric # level, so its entries are captured with level=None. def install(self, collector): self.collector = collector def uninstall(self): self.collector = None def log(self, *actual): self.collector(Entry(raw=actual, actual=actual, level=None)) class TestLogCapture(TestCase): def test_simple(self): root.info('before') l = LogCapture() root.info('during') l.uninstall() root.info('after') assert str(l) == "root INFO during" def test_simple_strict(self): log_capture = LogCapture(ensure_checks_above=ERROR) root.error('during') log_capture.uninstall() with ShouldAssert("Not asserted ERROR log(s): [('root', 'ERROR', 'during')]"): log_capture.ensure_checked() def test_simple_strict_re_defaulted(self): with Replace('testfixtures.LogCapture.default_ensure_checks_above', ERROR): LogCapture.default_ensure_checks_above = ERROR log_capture = LogCapture() root.error('during') log_capture.uninstall() with ShouldAssert("Not asserted ERROR log(s): [('root', 'ERROR', 'during')]"): log_capture.ensure_checked() def test_simple_strict_asserted_by_check(self): log_capture = LogCapture(ensure_checks_above=ERROR) root.error('during') log_capture.uninstall() log_capture.check(("root", "ERROR", "during")) log_capture.ensure_checked() def test_simple_strict_asserted_by_check_present_ordered(self): log_capture = LogCapture(ensure_checks_above=ERROR) root.error('during') log_capture.uninstall() log_capture.check_present(("root", "ERROR", "during")) log_capture.ensure_checked() def test_simple_strict_asserted_by_check_present_unordered(self): log_capture = LogCapture(ensure_checks_above=ERROR) root.error('during') log_capture.uninstall() log_capture.check_present(("root", "ERROR", "during"), order_matters=False) log_capture.ensure_checked() def test_simple_strict_not_asserted_by_check_present(self): log_capture = LogCapture(ensure_checks_above=ERROR) root.error('before') root.error('during') log_capture.uninstall() log_capture.check_present(("root", "ERROR", "during")) with ShouldAssert("Not asserted ERROR log(s): [('root', 'ERROR', 'before')]"): log_capture.ensure_checked() def test_simple_strict_asserted_by_containment(self): log_capture = LogCapture(ensure_checks_above=ERROR) root.error('during') log_capture.uninstall() assert ("root", "ERROR", "during") in log_capture assert ("root", "INFO", "during") not in log_capture log_capture.ensure_checked() def test_simple_strict_asserted_by_mark_all_checked(self): log_capture = LogCapture(ensure_checks_above=ERROR) root.error('during') log_capture.uninstall() log_capture.mark_all_checked() log_capture.ensure_checked() def test_simple_strict_ctx(self): with ShouldAssert("Not asserted ERROR log(s): [('root', 'ERROR', 'during')]"): with LogCapture(ensure_checks_above=ERROR): root.error('during') def test_simple_strict_asserted_ctx(self): with LogCapture(ensure_checks_above=ERROR) as log_capture: root.error('during') log_capture.check(("root", "ERROR", "during")) def test_specific_logger(self): l = LogCapture('one') root.info('1') one.info('2') two.info('3') child.info('4') l.uninstall() assert str(l) == ( "one INFO 2\n" "one.child INFO 4" ) def test_minimum_level(self): l = LogCapture(level=logging.WARNING) root.info('at info') root.warning('at warning') root.critical('at critical') assert str(l) == ( "root WARNING at warning\n" "root CRITICAL at critical" ) def test_minimum_level_logging_source(self): l = LogCapture(LoggingSource(level=logging.WARNING)) root.info('at info') root.warning('at warning') root.critical('at critical') assert str(l) == ( "WARNING at warning\n" "CRITICAL at critical" ) def test_multiple_loggers(self): l = LogCapture(('one.child','two')) root.info('1') one.info('2') two.info('3') child.info('4') l.uninstall() assert str(l) == ( "two INFO 3\n" "one.child INFO 4" ) def test_simple_manual_install(self): l = LogCapture(install=False) root.info('before') l.install() root.info('during') l.uninstall() root.info('after') assert str(l) == "root INFO during" def test_uninstall(self): # Lets start off with a couple of loggers: root = getLogger() child = getLogger('child') # Add a dummy filter so we can verify it is swapped out # during the capture, and swapped back in after `uninstall`. root.addFilter(DummyFilter()) # Lets also record the handlers for these loggers before # we start the test: before_root = root.handlers[:] before_child = child.handlers[:] # Lets also record the levels for the loggers: old_root_level=root.level old_child_level=child.level # Also record the filters: old_root_filters = root.filters[:] old_child_filters = child.filters[:] # Now the test: try: root.setLevel(49) child.setLevel(69) l1 = LogCapture() l2 = LogCapture('child') root = getLogger() root.info('1') child = getLogger('child') assert root.level == 1 assert child.level == 1 assert root.filters == [] assert child.filters == [] child.info('2') assert str(l1) == ( "root INFO 1\n" "child INFO 2" ) assert str(l2) == ( "child INFO 2" ) # Add a dummy filter to the child, # which should be removed by `uninstall`. child.addFilter(DummyFilter()) l2.uninstall() l1.uninstall() assert root.level == 49 assert child.level == 69 finally: root.setLevel(old_root_level) child.setLevel(old_child_level) # Now we check the handlers are as they were before # the test: assert root.handlers == before_root assert child.handlers == before_child # Also check the filters were restored: assert root.filters == old_root_filters assert child.filters == old_child_filters def test_uninstall_all(self): before_handlers_root = root.handlers[:] before_handlers_child = child.handlers[:] l1 = LogCapture() l2 = LogCapture('one.child') # We can see that the LogCaptures have changed the # handlers, removing existing ones and installing # their own: assert len(root.handlers) == 1 assert root.handlers != before_handlers_root assert len(child.handlers) == 1 assert child.handlers != before_handlers_child # Now we show the function in action: LogCapture.uninstall_all() # ...and we can see the handlers are back as # they were beefore: assert before_handlers_root == root.handlers assert before_handlers_child == child.handlers def test_two_logcaptures_on_same_logger(self): # If you create more than one LogCapture on a single # logger, the 2nd one installed will stop the first # one working! l1 = LogCapture() root.info('1st message') assert str(l1) == "root INFO 1st message" l2 = LogCapture() root.info('2nd message') # So, l1 missed this message: assert str(l1) == "root INFO 1st message" # ...because l2 kicked it out and recorded the message: assert str(l2) == "root INFO 2nd message" LogCapture.uninstall_all() def test_uninstall_more_than_once(self): # There's no problem with uninstalling a LogCapture # more than once: old_level = root.level try: root.setLevel(49) l = LogCapture() assert root.level == 1 l.uninstall() assert root.level == 49 root.setLevel(69) l.uninstall() assert root.level == 69 finally: root.setLevel(old_level) # And even when loggers have been uninstalled, there's # no problem having uninstall_all as a backstop: l.uninstall_all() def test_with_statement(self): root.info('before') with LogCapture() as l: root.info('during') root.info('after') assert str(l) == "root INFO during" def test_str_logging_source_default(self): with LogCapture(LoggingSource()) as l: root.info('during') assert str(l) == "INFO during" def test_str_single_attribute(self): with LogCapture(attributes=('getMessage',)) as l: root.info('during') assert str(l) == "during" class LogCaptureTests(TestCase): def test_remove_existing_handlers(self): logger = getLogger() # get original handlers original_handlers = logger.handlers # put in a stub which will blow up if used try: logger.handlers = start = [object()] with LogCapture() as l: logger.info('during') l.check(('root', 'INFO', 'during')) compare(logger.handlers, start) finally: # only executed if the test fails logger.handlers = original_handlers def test_atexit(self): m = Mock() with Replacer() as replace: # make sure the marker is false, other tests will # probably have set it replace( LogCapture.atexit_setup, replacement=False, name='atexit_setup', container=LogCapture, ) replace.in_module(atexit.register, m.register) l = LogCapture() compare(m.mock_calls, expected = [call.register(l.atexit)]) with ShouldWarn( UserWarning( "LogCapture instances not uninstalled by shutdown, " "sources captured:\n" "LoggingSource((None,))" ) ): l.atexit() l.uninstall() compare(LogCapture.instances, expected = set()) # check re-running has no ill effects l.atexit() def test_numeric_log_level(self): with LogCapture() as log: getLogger().log(42, 'running in the family') log.check(('root', 'Level 42', 'running in the family')) def test_enable_disabled_logger(self): logger = getLogger('disabled') logger.disabled = True with LogCapture('disabled') as log: logger.info('a log message') log.check(('disabled', 'INFO', 'a log message')) compare(logger.disabled, True) def test_no_propogate(self): logger = getLogger('child') # paranoid check compare(logger.propagate, True) with LogCapture() as global_log: with LogCapture('child', propagate=False) as child_log: logger.info('a log message') child_log.check(('child', 'INFO', 'a log message')) global_log.check() compare(logger.propagate, True) def test_len_and_getitem(self): with LogCapture() as log: compare(len(log), expected=0) getLogger('foo').info('a log message') compare(len(log), expected=1) compare(log[0], expected=('foo', 'INFO', 'a log message')) def test_shutdown_while_installed(self): with LogCapture(): with ShouldWarn( UserWarning( "LoggingSource closed while still capturing loggers: (None,)\n" "Call uninstall() or use LogCapture as a context manager." ) ): shutdown() # second shutdown should be fine: shutdown() class TestCheck: def test_raises_false_no_difference(self): with LogCapture() as log: root.info('hello') compare(log.check(('root', 'INFO', 'hello'), raises=False), expected=None) def test_raises_false_with_difference(self): with LogCapture() as log: root.info('hello') compare(log.check(('root', 'INFO', 'world'), raises=False), expected=( "sequence not as expected:\n\n" "same:\n()\n\n" "expected:\n(('root', 'INFO', 'world'),)\n\n" "actual:\n(('root', 'INFO', 'hello'),)" )) def test_level(self): with LogCapture() as log: root.debug('noisy') root.info('one') root.error('two') log.check( ('root', 'INFO', 'one'), ('root', 'ERROR', 'two'), level=INFO, ) def test_level_order_doesnt_matter(self): with LogCapture() as log: root.debug('noisy') root.error('two') root.info('one') log.check( ('root', 'INFO', 'one'), ('root', 'ERROR', 'two'), level=INFO, order_matters=False, ) def test_level_keeps_none_level(self): source = LevellessSource() with LogCapture(source) as log: source.log('custom', 'a message') log.check(('custom', 'a message'), level=ERROR) def test_predicate(self): with LogCapture() as log: root.info('one') root.error('two') log.check( ('root', 'ERROR', 'two'), predicate=lambda entry: entry.level is not None and entry.level >= ERROR, ) def test_level_and_predicate_compose(self): with LogCapture() as log: root.debug('skip: too low') root.info('keep') root.warning('skip: predicate') log.check( ('root', 'INFO', 'keep'), level=INFO, predicate=lambda entry: 'skip' not in entry.actual[2], ) def test_excluded_entries_not_marked_checked(self): log_capture = LogCapture(ensure_checks_above=ERROR) root.info('info') root.error('boom') log_capture.uninstall() log_capture.check( ('root', 'INFO', 'info'), predicate=lambda entry: entry.level is not None and entry.level < ERROR, ) with ShouldAssert("Not asserted ERROR log(s): [('root', 'ERROR', 'boom')]"): log_capture.ensure_checked() class TestCheckEmpty: def test_empty(self): with LogCapture() as log: pass log.check_empty() def test_not_empty(self): with LogCapture() as log: root.info('boom') with ShouldAssert( "sequence not as expected:\n\n" "same:\n()\n\n" "expected:\n()\n\n" "actual:\n(('root', 'INFO', 'boom'),)" ): log.check_empty() def test_level_excludes_lower(self): with LogCapture() as log: root.debug('noisy') root.info('chatter') log.check_empty(level=WARNING) def test_level_not_empty(self): with LogCapture() as log: root.debug('noisy') root.error('boom') with ShouldAssert( "sequence not as expected:\n\n" "same:\n()\n\n" "expected:\n()\n\n" "actual:\n(('root', 'ERROR', 'boom'),)" ): log.check_empty(level=WARNING) def test_predicate_excludes_all(self): with LogCapture() as log: root.info('chatter') root.warning('still fine') log.check_empty(predicate=lambda entry: entry.level is not None and entry.level >= ERROR) def test_level_and_predicate_compose(self): with LogCapture() as log: root.debug('debug noise') root.error('expected error') log.check_empty( level=WARNING, predicate=lambda entry: 'expected' not in entry.actual[2], ) class TestCheckPresent: def test_order_matters_ok(self): with LogCapture() as log: root.info('one') root.error('junk') root.warning('two') root.error('junk') root.error('three') log.check_present( ('root', 'INFO', 'one'), ('root', 'WARNING', 'two'), ('root', 'ERROR', 'three'), ) def test_order_matters_not_okay(self): with LogCapture() as log: root.error('junk') with ShouldAssert(dedent("""\ ignored: [('root', 'ERROR', 'junk')] same: [] expected: [('root', 'INFO', 'one')] actual: []""")): log.check_present( ('root', 'INFO', 'one'), ) def test_order_matters_not_okay_recursive(self): with LogCapture(recursive_check=True) as log: root.error('junk') with ShouldAssert(dedent("""\ same: [] expected: [('root', 'INFO', 'one')] actual: [('root', 'ERROR', 'junk')] While comparing [0]: sequence not as expected: same: ('root',) expected: ('INFO', 'one') actual: ('ERROR', 'junk') While comparing [0][1]: 'INFO' (expected) != 'ERROR' (actual)""")): log.check_present( ('root', 'INFO', 'one'), ) def test_order_matters_but_wrong(self): with LogCapture() as log: root.info('one') root.error('j1') root.error('three') root.warning('two') root.error('j2') with ShouldAssert(dedent("""\ ignored: [('root', 'ERROR', 'j1'), ('root', 'ERROR', 'three'), ('root', 'ERROR', 'j2')] same: [('root', 'INFO', 'one'), ('root', 'WARNING', 'two')] expected: [('root', 'ERROR', 'three')] actual: []""")): log.check_present( ('root', 'INFO', 'one'), ('root', 'WARNING', 'two'), ('root', 'ERROR', 'three'), ) def test_order_doesnt_matter_ok(self): with LogCapture() as log: root.info('one') root.error('junk') root.warning('two') root.error('junk') root.error('three') log.check_present( ('root', 'ERROR', 'three'), ('root', 'INFO', 'one'), ('root', 'WARNING', 'two'), order_matters=False ) def test_order_doesnt_matter_not_okay(self): with LogCapture() as log: root.error('junk') with ShouldAssert(dedent("""\ ignored: [('root', 'ERROR', 'junk')] in expected but not actual: [('root', 'INFO', 'one')]""")): log.check_present( ('root', 'INFO', 'one'), order_matters=False ) def test_single_item_ok(self): with LogCapture() as log: root.info('one') root.error('junk') root.warning('two') root.error('junk') root.error('three') log.check_present( ('root', 'WARNING', 'two'), ) def test_single_item_not_ok(self): with LogCapture(attributes=['getMessage']) as log: root.info('one') root.error('junk') root.error('three') with ShouldAssert(dedent("""\ ignored: ['one', 'junk', 'three'] same: [] expected: ['two'] actual: []""")): log.check_present('two') def test_multiple_identical_expected_order_matters(self): with LogCapture() as log: root.info('one') root.info('one') root.error('junk') root.warning('two') root.error('junk') root.warning('two') log.check_present( ('root', 'INFO', 'one'), ('root', 'INFO', 'one'), ('root', 'WARNING', 'two'), ('root', 'WARNING', 'two'), ) def test_multiple_identical_expected_order_doesnt_matter_ok(self): with LogCapture() as log: root.info('one') root.warning('two') root.error('junk') root.warning('two') root.error('junk') root.info('one') log.check_present( ('root', 'INFO', 'one'), ('root', 'INFO', 'one'), ('root', 'WARNING', 'two'), ('root', 'WARNING', 'two'), order_matters=False ) def test_multiple_identical_expected_order_doesnt_matter_not_ok(self): with LogCapture() as log: root.error('junk') root.info('one') root.warning('two') root.error('junk') root.info('one') with ShouldAssert(dedent("""\ ignored: [('root', 'ERROR', 'junk'), ('root', 'ERROR', 'junk')] same: [('root', 'INFO', 'one'), ('root', 'INFO', 'one'), ('root', 'WARNING', 'two')] in expected but not actual: [('root', 'WARNING', 'two')]""")): log.check_present( ('root', 'INFO', 'one'), ('root', 'INFO', 'one'), ('root', 'WARNING', 'two'), ('root', 'WARNING', 'two'), order_matters=False ) def test_attributes_callable_in_sequence(self): with LogCapture(LoggingSource((lambda r: r.levelname.lower(), 'getMessage'))) as log: root.info('hello') root.warning('world') log.check( ('info', 'hello'), ('warning', 'world'), ) def test_attributes_bare_string(self): with LogCapture(LoggingSource('getMessage')) as log: root.info('hello') log.check('hello') def test_attributes_bare_string_via_logcapture_kwarg(self): with LogCapture(attributes='getMessage') as log: root.info('hello') log.check('hello') def test_entries_are_dictionaries(self): def extract(record): return {'level': record.levelname, 'message': record.getMessage()} with LogCapture(attributes=extract) as log: root.info('one') root.error('junk') root.warning('two') root.error('junk') root.info('one') log.check_present( {'level': 'INFO', 'message': 'one'}, {'level': 'INFO', 'message': 'one'}, {'level': 'WARNING', 'message': 'two'}, order_matters=False ) def test_almost_same_order_matters(self): with LogCapture() as log: root.info('one') root.error('junk') root.warning('two') root.error('junk') with ShouldAssert(dedent("""\ ignored: [('root', 'ERROR', 'junk'), ('root', 'ERROR', 'junk')] same: [('root', 'INFO', 'one'), ('root', 'WARNING', 'two')] expected: [('root', 'ERROR', 'three')] actual: []""")): log.check_present( ('root', 'INFO', 'one'), ('root', 'WARNING', 'two'), ('root', 'ERROR', 'three'), ) def test_almost_same_order_doesnt_matter(self): with LogCapture() as log: root.info('one') root.error('junk') root.error('three') root.error('junk') with ShouldAssert(dedent("""\ ignored: [('root', 'ERROR', 'junk'), ('root', 'ERROR', 'junk')] same: [('root', 'ERROR', 'three'), ('root', 'INFO', 'one')] in expected but not actual: [('root', 'WARNING', 'two')]""")): log.check_present( ('root', 'ERROR', 'three'), ('root', 'INFO', 'one'), ('root', 'WARNING', 'two'), order_matters=False ) def test_check_present_raises_false_no_difference(self): with LogCapture() as log: root.info('hello') compare(log.check_present(('root', 'INFO', 'hello'), raises=False), expected=None) def test_check_present_raises_false_with_difference(self): with LogCapture() as log: root.info('hello') compare(log.check_present(('root', 'INFO', 'world'), raises=False), expected=( "ignored:\n[('root', 'INFO', 'hello')]\n\n" "same:\n[]\n\n" "expected:\n[('root', 'INFO', 'world')]\n\n" "actual:\n[]" )) class TestDisabled: def test_disabled(self): with LogCapture() as log: root.info('before') with log.disabled(): root.info('noise') root.info('after') log.check( ('root', 'INFO', 'before'), ('root', 'INFO', 'after'), ) def test_disabled_restores_on_exception(self): with LogCapture() as log: with ShouldRaise(ValueError('boom')): with log.disabled(): raise ValueError('boom') root.info('after') log.check(('root', 'INFO', 'after')) testfixtures-12.4.0/tests/test_loguru.py000066400000000000000000000176111525773641100204600ustar00rootroot00000000000000import logging from pathlib import Path import pytest from testfixtures.logcapture import LoggingSource from testfixtures.mock import Mock, call, _Call as Call from testfixtures import ( LogCapture, ShouldNotWarn, ShouldRaise, ShouldWarn, TextComparison, compare, ) pytest.importorskip("loguru") from loguru import logger from testfixtures.loguru import LoguruSource, level_name @pytest.fixture(autouse=True) def clean_loguru(): logger.remove() yield logger.remove() class TestLogCapture: def test_simple(self): with LogCapture(LoguruSource()) as log: logger.info('hello world') log.check(('INFO', 'hello world')) def test_fields_single(self): with LogCapture(LoguruSource(('message', ))) as log: logger.info('just the message') log.check('just the message') def test_attributes_bare_string(self): with LogCapture(LoguruSource('message')) as log: logger.info('just the message') log.check('just the message') def test_fields_callable(self): with LogCapture(LoguruSource((lambda r: r['level'].name, 'message'))) as log: logger.debug('msg1') logger.info('msg2') log.check( ('DEBUG', 'msg1'), ('INFO', 'msg2'), ) def test_attributes_single_callable(self): def extract(record): return {'level': record['level'].name, 'message': record['message']} with LogCapture(LoguruSource(extract)) as log: logger.info('hello') logger.error('boom') log.check( {'level': 'INFO', 'message': 'hello'}, {'level': 'ERROR', 'message': 'boom'}, ) def test_exception(self): with LogCapture(LoguruSource()) as log: try: raise ValueError('boom') except ValueError: logger.exception('oh no') log.check(('ERROR', 'oh no')) log.check_exception_str('boom') def test_raise_first_exception(self): with LogCapture(LoguruSource()) as log: try: raise ValueError('boom') except ValueError: logger.exception('oh no') with ShouldRaise(ValueError('boom')): log.raise_first_exception() def test_order_doesnt_matter(self): with LogCapture(LoguruSource()) as log: logger.info('first') logger.info('second') log.check( ('INFO', 'second'), ('INFO', 'first'), order_matters=False, ) def test_level_filtering(self): with LogCapture(LoguruSource(level='WARNING')) as log: logger.info('ignored') logger.warning('captured') log.check(('WARNING', 'captured')) def test_check_level(self): with LogCapture(LoguruSource()) as log: logger.debug('noisy') logger.info('useful') logger.error('boom') log.check( ('INFO', 'useful'), ('ERROR', 'boom'), level=logging.INFO, ) def test_check_predicate_on_raw(self): with LogCapture(LoguruSource()) as log: logger.bind(user='admin').warning('deleted a record') logger.warning('cache expired') log.check( ('WARNING', 'deleted a record'), predicate=lambda entry: 'user' in entry.raw['extra'], ) def test_str(self): with LogCapture(LoguruSource()) as log: logger.info('hello world') assert str(log) == "INFO hello world" def test_broken_attribute_method(self) -> None: def raw(e): raise ValueError("boom") with LogCapture(LoguruSource((raw,))) as log: with ShouldRaise(ValueError("boom")): logger.info("task logging") log.check() def test_contextualize(self) -> None: with LogCapture(LoguruSource((level_name, 'message', 'extra'))) as log: with logger.contextualize(task=1234): logger.info("task logging") log.check(('INFO', 'task logging', {'task': 1234})) def test_bind(self) -> None: with LogCapture(LoguruSource((level_name, 'message', 'extra'))) as log: context_logger = logger.bind(ip="192.168.0.1", user="someone") context_logger.info("Contextualize your logger easily") context_logger.bind(other="foo").info("Inline binding of extra attribute") context_logger.info("Use kwargs to add context during formatting: {user}", user="me") log.check( ('INFO', 'Contextualize your logger easily', {'ip': '192.168.0.1', 'user': 'someone'}), ('INFO', 'Inline binding of extra attribute', {'ip': '192.168.0.1', 'user': 'someone', 'other': 'foo'}), ('INFO', 'Use kwargs to add context during formatting: me', {'ip': '192.168.0.1', 'user': 'me'}) ) def test_serialize(self): sink = Mock() logger.add(sink, serialize=True) logger.info('before capture') with LogCapture(LoguruSource()) as log: logger.info('during capture') logger.info('after capture') log.check(('INFO', 'during capture')) def write_call(message: str) -> Call: return call.write( TextComparison( ( r'{"text": ".+ \| INFO +\| tests.test_loguru:test_serialize:\d+' r' - MESSAGE\\n", "record": {"elapsed": {"repr": "0.+", "seconds": .+}, ' r'"exception": null, "extra": {}, ' r'"file": {"name": "test_loguru.py", "path": ".+"}, ' '"function": "test_serialize", ' r'"level": {"icon": "ℹ️", "name": "INFO", "no": 20}, "line": \d+, ' '"message": "MESSAGE", ' '"module": "test_loguru", "name": "tests.test_loguru", ' '"process": {.+}, ' '"thread": {.+}, ' '"time": {"repr": ".+", "timestamp": .+}}}\n' ).replace('MESSAGE', message) )) compare(sink.mock_calls, expected=[ write_call('before capture'), call.flush(), write_call('after capture'), call.flush(), ]) def test_format(self, tmp_path: Path): log_file= tmp_path / "file.log" logger.add(log_file, format="{level} {extra[ip]} {extra[user]} {message}") with logger.contextualize(ip="192.168.0.1", user="someone"): logger.info('before capture') with LogCapture(LoguruSource()) as log: logger.info('during capture') logger.info('after capture') compare( log_file.read_text(), expected=( "INFO 192.168.0.1 someone before capture\n" "INFO 192.168.0.1 someone after capture\n" ) ) def test_combined_logging(self): with LogCapture(LoguruSource(), LoggingSource()) as log: for level in 'info', 'warning': getattr(logging, level)('from stdlib') getattr(logger, level)('from loguru') log.check( ('INFO', 'from stdlib'), ('INFO', 'from loguru'), ('WARNING', 'from stdlib'), ('WARNING', 'from loguru'), ) def test_shutdown_while_installed(self): with LogCapture(LoguruSource()): with ShouldWarn(UserWarning( 'LoguruSource left installed at shutdown.\n' 'Call uninstall() or use LogCapture as a context manager.' )): logger.remove() def test_uninstall_does_not_warn(self): with ShouldNotWarn(): with LogCapture(LoguruSource()): logger.info('hi') testfixtures-12.4.0/tests/test_mappingcomparison.py000066400000000000000000000226531525773641100226730ustar00rootroot00000000000000from collections import OrderedDict from textwrap import dedent from testfixtures import MappingComparison, ShouldRaise, compare def check_repr(obj, expected): compare(repr(obj), expected=dedent(expected).rstrip('\n')) class TestMappingComparison: def test_repr(self): m = MappingComparison({'a': 1}, b=2) check_repr(m, "'a': 1, 'b': 2") def test_repr_ordered(self): m = MappingComparison((('b', 3), ('a', 1)), ordered=True) check_repr(m, "'b': 3, 'a': 1") def test_repr_long(self): m = MappingComparison({1: 'a', 2: 'b'*60}) compare(repr(m)[:65], expected="\n\n1: 'a',\n2: 'bb") def test_repr_after_equal(self): m = MappingComparison({'a': 1}) assert m == {'a': 1} check_repr(m, "'a': 1") def test_equal_mapping(self): m = MappingComparison({'a': 1}) assert m == {'a': 1} def test_equal_sequence(self): m = MappingComparison(('a', 1), ('b', 2)) assert m == {'a': 1, 'b': 2} def test_equal_items(self): m = MappingComparison(a=1) assert m == {'a': 1} def test_equal_both(self): m = MappingComparison({'a': 1, 'b': 2}, b=3) assert m == {'a': 1, 'b': 3} def test_equal_items_ordered(self): m = MappingComparison(b=3, a=1, ordered=True) assert m == {'b': 3, 'a': 1} def test_equal_ordered_and_dict_supplied(self): m = MappingComparison({'b': 3, 'a': 1}, ordered=True) assert m == {'b': 3, 'a': 1} def test_equal_ordered_dict_sequence_expected(self): m = MappingComparison((('a', 1), ('b', 3)), ordered=True) assert m == OrderedDict((('a', 1), ('b', 3))) def test_equal_ordered_dict_ordered_dict_expected(self): m = MappingComparison(OrderedDict((('a', 1), ('b', 3))), ordered=True) assert m == OrderedDict((('a', 1), ('b', 3))) def test_equal_partial(self): m = MappingComparison({'a': 1}, partial=True) assert m == {'a': 1, 'b': 2} def test_equal_partial_ordered(self): m = MappingComparison((('a', 1), ('b', 3)), ordered=True, partial=True) assert m == OrderedDict((('a', 1), ('c', 2), ('b', 3))) def test_unequal_wrong_type(self): m = MappingComparison({'a': 1}) assert m != [] compare(repr(m), expected="bad type") def test_unequal_not_partial(self): m = MappingComparison({'a': 1, 'b': 2}) assert m != {'a': 1, 'b': 2, 'c': 3} check_repr(m, expected=''' same: ['a', 'b'] in actual but not expected: 'c': 3 ''') def test_unequal_keys_and_values(self): m = MappingComparison({'a': 1, 'b': 2, 'c': 3}) assert m != {'a': 1, 'c': 4, 'd': 5} check_repr(m, expected=''' same: ['a'] in expected but not actual: 'b': 2 in actual but not expected: 'd': 5 values differ: 'c': 3 (expected) != 4 (actual) ''') def test_unequal_order(self): m = MappingComparison((('b', 3), ('a', 1)), ordered=True) assert m != OrderedDict((('a', 1), ('b', 3))) check_repr(m, expected=''' wrong key order: same: [] expected: ['b', 'a'] actual: ['a', 'b'] ''') def test_unequal_order_recursive(self): m = MappingComparison(((('b', 'x'), 3), (('b', 'y'), 1)), ordered=True, recursive=True) assert m != OrderedDict(((('b', 'y'), 1), (('b', 'x'), 3))) check_repr(m, expected=''' wrong key order: same: [] expected: [('b', 'x'), ('b', 'y')] actual: [('b', 'y'), ('b', 'x')] While comparing [0]: sequence not as expected: same: ('b',) expected: ('x',) actual: ('y',) While comparing [0][1]: 'x' (expected) != 'y' (actual) ''') def test_unequal_order_wrong(self): m = MappingComparison(b=3, a=1, ordered=True) assert m != {'a': 1, 'b': 3} check_repr(m, expected=''' wrong key order: same: [] expected: ['b', 'a'] actual: ['a', 'b'] ''') def test_unequal_partial_keys_missing(self): m = MappingComparison({'a': 1, 'b': 2}, partial=True) assert m != {'a': 1} check_repr(m, expected=''' same: ['a'] in expected but not actual: 'b': 2 ''') def test_unequal_partial_values_wrong(self): m = MappingComparison({'a': 1, 'b': 2}, partial=True) assert m != {'a': 1, 'b': 3} check_repr(m, expected=''' same: ['a'] values differ: 'b': 2 (expected) != 3 (actual) ''') def test_unequal_partial_ordered(self): m = MappingComparison((('b', 3), ('a', 1)), partial=True, ordered=True) assert m != OrderedDict((('a', 1), ('b', 3))) check_repr(m, expected=''' wrong key order: same: [] expected: ['b', 'a'] actual: ['a', 'b'] ''') def test_unequal_partial_ordered_some_ignored(self): m = MappingComparison((('b', 3), ('c', 1), ('a', 1)), partial=True, ordered=True) assert m != OrderedDict((('b', 3), ('d', 4), ('a', 1), ('c', 1), )) check_repr(m, expected=''' ignored: ['d'] wrong key order: same: ['b'] expected: ['c', 'a'] actual: ['a', 'c'] ''') def test_unequal_recursive(self): m = MappingComparison({'a': 1, 'b': {'c': 2}}, recursive=True) assert m != {'a': 1, 'b': {'c': 3}} check_repr(m, expected=''' same: ['a'] values differ: 'b': {'c': 2} (expected) != {'c': 3} (actual) While comparing ['b']: dict not as expected: values differ: 'c': 2 (expected) != 3 (actual) ''') def test_everything_wrong(self): m = MappingComparison((('a', 1), ('b', 2), ('c', 3)), ordered=True, partial=True, recursive=True) assert m != OrderedDict((('b', 2), ('a', 1), ('d', 4))) check_repr(m, expected=''' ignored: ['d'] same: ['a', 'b'] in expected but not actual: 'c': 3 wrong key order: same: [] expected: ['a', 'b', 'c'] actual: ['b', 'a'] While comparing [0]: 'a' (expected) != 'b' (actual) ''') def test_partial_nothing_specified(self): m = MappingComparison(partial=True) assert m == {} def test_partial_nothing_specified_wrong_type(self): m = MappingComparison(partial=True) assert m != [] check_repr(m, 'bad type') def test_boolean_return(self): m = MappingComparison({'k': 'v'}) result = m != {'k': 'v'} assert isinstance(result, bool) testfixtures-12.4.0/tests/test_mock.py000066400000000000000000000043051525773641100200700ustar00rootroot00000000000000from testfixtures.mock import Mock, call, ANY from .test_compare import CompareHelper class TestCall(CompareHelper): def test_non_root_call_not_equal(self): self.check_raises( call.foo().bar(), call.baz().bar(), '\n' "'call.foo().bar()'\n" '!=\n' "'call.baz().bar()'" ) def test_non_root_attr_not_equal(self): self.check_raises( call.foo.bar(), call.baz.bar(), '\n' "'call.foo.bar()'\n" '!=\n' "'call.baz.bar()'" ) def test_non_root_params_not_equal(self): self.check_raises( call.foo(x=1).bar(), call.foo(x=2).bar(), '\n' "'call.foo(x=1)'\n" '!=\n' "'call.foo(x=2)'" ) def test_any(self): assert call == ANY def test_no_len(self): assert not call == object() def test_two_elements(self): m = Mock() m(x=1) assert m.call_args == ((), {'x': 1}) def test_other_empty(self): assert call == () def test_other_single(self): assert call == ((),) assert call == ({},) assert call == ('',) def test_other_double(self): assert call == ('', (),) assert call == ('', {},) def test_other_quad(self): assert not call == (1, 2, 3, 4) class TestMock(CompareHelper): def test_non_root_call_not_equal(self): m = Mock() m.foo().bar() self.check_raises( m.mock_calls[-1], call.baz().bar(), '\n' "'call.foo().bar()'\n" '!=\n' "'call.baz().bar()'" ) def test_non_root_attr_not_equal(self): m = Mock() m.foo.bar() self.check_raises( m.mock_calls[-1], call.baz.bar(), '\n' "'call.foo.bar()'\n" '!=\n' "'call.baz.bar()'" ) def test_non_root_params_not_equal(self): m = Mock() m.foo(x=1).bar() # surprising and annoying (and practically unsolvable :-/): assert m.mock_calls[-1] == call.foo(y=2).bar() testfixtures-12.4.0/tests/test_numpy.py000066400000000000000000000156401525773641100203130ustar00rootroot00000000000000import pytest pytest.importorskip("numpy") import numpy as np import testfixtures.numpy from testfixtures import ShouldAssert, compare NUMPY_2_4 = np.lib.NumpyVersion(np.__version__) >= "2.4.0" def mismatch_at_index(detail: str) -> str: if NUMPY_2_4: return "Mismatch at index:\n " + detail + "\n" return "" def test_importable(): compare(testfixtures.numpy.numpy.__name__, expected="numpy") def test_equal_arrays(): a1 = np.array([1, 2, 3]) a2 = np.array([1, 2, 3]) compare(a1, expected=a2) def test_unequal_arrays(): a1 = np.array([1, 2, 3]) a2 = np.array([1, 2, 4]) with ShouldAssert( "\n" "Arrays are not equal\n" "\n" "Mismatched elements: 1 / 3 (33.3%)\n" + mismatch_at_index("[2]: 3 (ACTUAL), 4 (DESIRED)") + "Max absolute difference among violations: 1\n" "Max relative difference among violations: 0.25\n" " ACTUAL: array([1, 2, 3])\n" " DESIRED: array([1, 2, 4])" ): compare(a1, expected=a2) def test_unequal_arrays_in_dict_breadcrumb(): a1 = np.array([1]) a2 = np.array([2]) with ShouldAssert( "dict not as expected:\n" "\n" "values differ:\n" "'foo': array([2]) (expected) != array([1]) (actual)\n" "\n" "While comparing ['foo']: \n" "Arrays are not equal\n" "\n" "Mismatched elements: 1 / 1 (100%)\n" + mismatch_at_index("[0]: 1 (ACTUAL), 2 (DESIRED)") + "Max absolute difference among violations: 1\n" "Max relative difference among violations: 0.5\n" " ACTUAL: array([1])\n" " DESIRED: array([2])" ): compare({"foo": a1}, expected={"foo": a2}) def test_floats_within_tolerance_compare_equal(): a1 = np.array([1.0 + 1e-9, 2.0]) a2 = np.array([1.0, 2.0]) compare(a1, expected=a2) def test_floats_outside_tolerance(): a1 = np.array([1.0, 2.0, 3.0]) a2 = np.array([1.0, 2.5, 3.0]) with ShouldAssert( "\n" "Not equal to tolerance rtol=1e-05, atol=1e-08\n" "\n" "Mismatched elements: 1 / 3 (33.3%)\n" + mismatch_at_index("[1]: 2.0 (ACTUAL), 2.5 (DESIRED)") + "Max absolute difference among violations: 0.5\n" "Max relative difference among violations: 0.2\n" " ACTUAL: array([1., 2., 3.])\n" " DESIRED: array([1. , 2.5, 3. ])" ): compare(a1, expected=a2) def test_strict_requires_exact_floats(): a1 = np.array([1.0 + 1e-9, 2.0]) a2 = np.array([1.0, 2.0]) with ShouldAssert( "\n" "Arrays are not equal\n" "\n" "Mismatched elements: 1 / 2 (50%)\n" + mismatch_at_index("[0]: 1.000000001 (ACTUAL), 1.0 (DESIRED)") + "Max absolute difference among violations: 1.00000008e-09\n" "Max relative difference among violations: 1.00000008e-09\n" " ACTUAL: array([1., 2.])\n" " DESIRED: array([1., 2.])" ): compare(a1, expected=a2, strict=True) def test_strict_exact_floats_compare_equal(): a1 = np.array([1.0, 2.0]) a2 = np.array([1.0, 2.0]) compare(a1, expected=a2, strict=True) def test_dtype_checked_even_when_not_strict(): a1 = np.array([1, 2], dtype=np.int32) a2 = np.array([1, 2], dtype=np.int64) with ShouldAssert( "\n" "Arrays are not equal\n" "\n" "(dtypes int32, int64 mismatch)\n" " ACTUAL: array([1, 2], dtype=int32)\n" " DESIRED: array([1, 2])" ): compare(a1, expected=a2) def test_shape_checked_no_broadcasting(): a1 = np.array([3.0, 3.0, 3.0]) a2 = np.array(3.0) with ShouldAssert( "\n" "Not equal to tolerance rtol=1e-05, atol=1e-08\n" "\n" "(shapes (3,), () mismatch)\n" " ACTUAL: array([3., 3., 3.])\n" " DESIRED: array(3.)" ): compare(a1, expected=a2) def test_nan_equals_nan(): a1 = np.array([1.0, np.nan]) a2 = np.array([1.0, np.nan]) compare(a1, expected=a2) def test_nan_equals_nan_strict(): a1 = np.array([1.0, np.nan]) a2 = np.array([1.0, np.nan]) compare(a1, expected=a2, strict=True) def test_zero_d_arrays_equal(): compare(np.array(1.0), expected=np.array(1.0)) def test_zero_d_arrays_unequal(): with ShouldAssert( "\n" "Not equal to tolerance rtol=1e-05, atol=1e-08\n" "\n" "Mismatched elements: 1 / 1 (100%)\n" "Max absolute difference among violations: 1.\n" "Max relative difference among violations: 1.\n" " ACTUAL: array(2.)\n" " DESIRED: array(1.)" ): compare(np.array(2.0), expected=np.array(1.0)) def test_equal_masked_arrays(): m1 = np.ma.MaskedArray([1, 2, 3], mask=[False, True, False]) m2 = np.ma.MaskedArray([1, 2, 3], mask=[False, True, False]) compare(m1, expected=m2) def test_masked_positions_ignore_underlying_data(): m1 = np.ma.MaskedArray([1, 2, 3], mask=[False, True, False]) m2 = np.ma.MaskedArray([1, 9, 3], mask=[False, True, False]) compare(m1, expected=m2) def test_mask_mismatch_fails_even_when_not_strict(): m1 = np.ma.MaskedArray([1, 2, 3], mask=[False, True, False]) m2 = np.ma.MaskedArray([1, 2, 3], mask=[False, False, False]) with ShouldAssert( "masks differ: \n" "Arrays are not equal\n" "\n" "Mismatched elements: 1 / 3 (33.3%)\n" + mismatch_at_index("[1]: True (ACTUAL), False (DESIRED)") + " ACTUAL: array([False, True, False])\n" " DESIRED: array([False, False, False])" ): compare(m1, expected=m2) def test_unequal_masked_data(): m1 = np.ma.MaskedArray([1, 2, 3], mask=[False, False, False]) m2 = np.ma.MaskedArray([1, 4, 3], mask=[False, False, False]) with ShouldAssert( "\n" "Arrays are not equal\n" "\n" "Mismatched elements: 1 / 3 (33.3%)\n" + mismatch_at_index("[1]: 2 (ACTUAL), 4 (DESIRED)") + "Max absolute difference among violations: 2\n" "Max relative difference among violations: 0.5\n" " ACTUAL: array([1, 2, 3])\n" " DESIRED: array([1, 4, 3])" ): compare(m1, expected=m2) def test_masked_floats_within_tolerance_compare_equal(): m1 = np.ma.MaskedArray([1.0 + 1e-9, 2.0], mask=[False, True]) m2 = np.ma.MaskedArray([1.0, 5.0], mask=[False, True]) compare(m1, expected=m2) def test_masked_strict_requires_exact_floats(): m1 = np.ma.MaskedArray([1.0 + 1e-9, 2.0], mask=[False, True]) m2 = np.ma.MaskedArray([1.0, 5.0], mask=[False, True]) with ShouldAssert( "\n" "Arrays are not equal\n" "\n" "Mismatched elements: 1 / 1 (100%)\n" + mismatch_at_index("[0]: 1.000000001 (ACTUAL), 1.0 (DESIRED)") + "Max absolute difference among violations: 1.00000008e-09\n" "Max relative difference among violations: 1.00000008e-09\n" " ACTUAL: array([1.])\n" " DESIRED: array([1.])" ): compare(m1, expected=m2, strict=True) testfixtures-12.4.0/tests/test_outputcapture.py000066400000000000000000000132521525773641100220640ustar00rootroot00000000000000import sys from subprocess import call from unittest import TestCase from _pytest.capture import CaptureFixture from testfixtures import OutputCapture, compare, TextComparison from .test_compare import CompareHelper class TestOutputCapture(CompareHelper, TestCase): def test_compare_strips(self) -> None: with OutputCapture() as o: print(' Bar! ') o.compare('Bar!') def test_compare_doesnt_strip(self) -> None: with OutputCapture(strip_whitespace=False) as o: print(' Bar! ') self.check_raises( '\tBar!', compare=o.compare, message="'\\tBar!' (expected) != ' Bar! \\n' (actual)", ) def test_stdout_and_stderr(self) -> None: with OutputCapture() as o: print('hello', file=sys.stdout) print('out', file=sys.stderr) print('there', file=sys.stdout) print('now', file=sys.stderr) o.compare("hello\nout\nthere\nnow\n") def test_unicode(self) -> None: with OutputCapture() as o: print(u'\u65e5', file=sys.stdout) o.compare(u'\u65e5\n') def test_separate_capture(self) -> None: with OutputCapture(separate=True) as o: print('hello', file=sys.stdout) print('out', file=sys.stderr) print('there', file=sys.stdout) print('now', file=sys.stderr) o.compare(stdout="hello\nthere\n", stderr="out\nnow\n") def test_compare_both_at_once(self) -> None: with OutputCapture(separate=True) as o: print('hello', file=sys.stdout) print('out', file=sys.stderr) self.check_raises( stdout="out\n", stderr="hello\n", compare=o.compare, message=( 'dict not as expected:\n' '\n' 'values differ:\n' "'stderr': 'hello' (expected) != 'out' (actual)\n" "'stdout': 'out' (expected) != 'hello' (actual)\n" '\n' "While comparing ['stderr']: 'hello' (expected) != 'out' (actual)\n" '\n' "While comparing ['stdout']: 'out' (expected) != 'hello' (actual)" ), ) def test_original_restore(self) -> None: o_out, o_err = sys.stdout, sys.stderr with OutputCapture() as o: self.assertFalse(sys.stdout is o_out) self.assertFalse(sys.stderr is o_err) self.assertTrue(sys.stdout is o_out) self.assertTrue(sys.stderr is o_err) def test_double_disable(self) -> None: o_out, o_err = sys.stdout, sys.stderr with OutputCapture() as o: self.assertFalse(sys.stdout is o_out) self.assertFalse(sys.stderr is o_err) o.disable() self.assertTrue(sys.stdout is o_out) self.assertTrue(sys.stderr is o_err) o.disable() self.assertTrue(sys.stdout is o_out) self.assertTrue(sys.stderr is o_err) self.assertTrue(sys.stdout is o_out) self.assertTrue(sys.stderr is o_err) def test_double_enable(self) -> None: o_out, o_err = sys.stdout, sys.stderr with OutputCapture() as o: o.disable() self.assertTrue(sys.stdout is o_out) self.assertTrue(sys.stderr is o_err) o.enable() self.assertFalse(sys.stdout is o_out) self.assertFalse(sys.stderr is o_err) o.enable() self.assertFalse(sys.stdout is o_out) self.assertFalse(sys.stderr is o_err) self.assertTrue(sys.stdout is o_out) self.assertTrue(sys.stderr is o_err) def test_compare_expected_is_textcomparison(self) -> None: with OutputCapture() as output: print('foo') output.compare(TextComparison(r'^foo\Z')) def test_compare_stdout_and_stdderr_are_textcomparisons(self) -> None: with OutputCapture(separate=True) as output: print('hello', file=sys.stdout) print('world', file=sys.stderr) output.compare(stdout=TextComparison(r'^hello\Z'), stderr=TextComparison(r'^world\Z')) def test_compare_raises_false_no_difference(self) -> None: with OutputCapture() as output: print('foo') compare(output.compare('foo', raises=False), expected=None) def test_compare_raises_false_with_difference(self) -> None: with OutputCapture() as output: print('foo') compare(output.compare('bar', raises=False), expected="'bar' (expected) != 'foo' (actual)") def test_compare_raises_false_separate_with_difference(self) -> None: with OutputCapture(separate=True) as output: print('hello', file=sys.stdout) print('world', file=sys.stderr) compare(output.compare(stdout='hello', stderr='bad', raises=False), expected="'bad' (expected) != 'world' (actual)") class TestOutputCaptureWithDescriptors: def test_fd(self, capfd: CaptureFixture) -> None: with capfd.disabled(), OutputCapture(fd=True) as o: call([sys.executable, '-c', "import sys; sys.stdout.write('out')"]) call([sys.executable, '-c', "import sys; sys.stderr.write('err')"]) compare(o.captured, expected='outerr') o.compare(expected='outerr') def test_fd_separate(self, capfd: CaptureFixture) -> None: with capfd.disabled(), OutputCapture(fd=True, separate=True) as o: call([sys.executable, '-c', "import sys; sys.stdout.write('out')"]) call([sys.executable, '-c', "import sys; sys.stderr.write('err')"]) compare(o.captured, expected='') o.compare(stdout='out', stderr='err') testfixtures-12.4.0/tests/test_pandas.py000066400000000000000000000045441525773641100204120ustar00rootroot00000000000000import pytest pytest.importorskip("pandas") import pandas as pd import testfixtures.pandas from testfixtures import ShouldAssert, compare def test_importable(): compare(testfixtures.pandas.pandas.__name__, expected="pandas") def test_equal_dataframes(): df1 = pd.DataFrame({"a": [1, 2], "b": [3, 4]}) df2 = pd.DataFrame({"a": [1, 2], "b": [3, 4]}) compare(df1, expected=df2) def test_unequal_dataframes(): df1 = pd.DataFrame({"a": [1, 2], "b": [3, 4]}) df2 = pd.DataFrame({"a": [1, 2], "b": [3, 5]}) with ShouldAssert( 'DataFrame.iloc[:, 1] (column name="b") are different\n' "\n" 'DataFrame.iloc[:, 1] (column name="b") values are different (50.0 %)\n' "[index]: [0, 1]\n" "[left]: [3, 5]\n" "[right]: [3, 4]\n" "At positional index 1, first diff: 5 != 4" ): compare(df1, expected=df2) def test_unequal_dataframes_in_dict_breadcrumb(): df1 = pd.DataFrame({"a": [1]}) df2 = pd.DataFrame({"a": [2]}) with ShouldAssert( "dict not as expected:\n" "\n" "values differ:\n" "'foo': a\n" "0 2 (expected) != a\n" "0 1 (actual)\n" "\n" 'While comparing [\'foo\']: DataFrame.iloc[:, 0] (column name="a") are different\n' "\n" 'DataFrame.iloc[:, 0] (column name="a") values are different (100.0 %)\n' "[index]: [0]\n" "[left]: [2]\n" "[right]: [1]\n" "At positional index 0, first diff: 2 != 1" ): compare({"foo": df1}, expected={"foo": df2}) def test_floats_within_tolerance_compare_equal(): df1 = pd.DataFrame({"x": [1.0, 2.0]}) df2 = pd.DataFrame({"x": [1.0 + 1e-9, 2.0]}) compare(df1, expected=df2) def test_strict_requires_exact_floats(): df1 = pd.DataFrame({"x": [1.0, 2.0]}) df2 = pd.DataFrame({"x": [1.0 + 1e-9, 2.0]}) with ShouldAssert( 'DataFrame.iloc[:, 0] (column name="x") are different\n' "\n" 'DataFrame.iloc[:, 0] (column name="x") values are different (50.0 %)\n' "[index]: [0, 1]\n" "[left]: [1.000000001, 2.0]\n" "[right]: [1.0, 2.0]" ): compare(df1, expected=df2, strict=True) def test_strict_exact_floats_compare_equal(): df1 = pd.DataFrame({"x": [1.0, 2.0]}) df2 = pd.DataFrame({"x": [1.0, 2.0]}) compare(df1, expected=df2, strict=True) testfixtures-12.4.0/tests/test_polars.py000066400000000000000000000054621525773641100204440ustar00rootroot00000000000000import pytest pytest.importorskip("polars") import polars as pl import testfixtures.polars from testfixtures import ShouldAssert, compare def test_importable(): compare(testfixtures.polars.polars.__name__, expected="polars") def test_equal_dataframes(): df1 = pl.DataFrame({"a": [1, 2], "b": [3, 4]}) df2 = pl.DataFrame({"a": [1, 2], "b": [3, 4]}) compare(df1, expected=df2) def test_unequal_dataframes(): df1 = pl.DataFrame({"a": [1, 2], "b": [3, 4]}) df2 = pl.DataFrame({"a": [1, 2], "b": [3, 5]}) with ShouldAssert( 'DataFrames are different (value mismatch for column "b")\n' "[left]: shape: (2,)\n" "Series: 'b' [i64]\n" "[\n" "\t3\n" "\t5\n" "]\n" "[right]: shape: (2,)\n" "Series: 'b' [i64]\n" "[\n" "\t3\n" "\t4\n" "]" ): compare(df1, expected=df2) def test_unequal_dataframes_in_dict_breadcrumb(): df1 = pl.DataFrame({"a": [1]}) df2 = pl.DataFrame({"a": [2]}) with ShouldAssert( "dict not as expected:\n" "\n" "values differ:\n" "'foo': shape: (1, 1)\n" "┌─────┐\n" "│ a │\n" "│ --- │\n" "│ i64 │\n" "╞═════╡\n" "│ 2 │\n" "└─────┘ (expected) != shape: (1, 1)\n" "┌─────┐\n" "│ a │\n" "│ --- │\n" "│ i64 │\n" "╞═════╡\n" "│ 1 │\n" "└─────┘ (actual)\n" "\n" "While comparing ['foo']: DataFrames are different" ' (value mismatch for column "a")\n' "[left]: shape: (1,)\n" "Series: 'a' [i64]\n" "[\n" "\t2\n" "]\n" "[right]: shape: (1,)\n" "Series: 'a' [i64]\n" "[\n" "\t1\n" "]" ): compare({"foo": df1}, expected={"foo": df2}) def test_floats_within_tolerance_compare_equal(): df1 = pl.DataFrame({"x": [1.0, 2.0]}) df2 = pl.DataFrame({"x": [1.0 + 1e-9, 2.0]}) compare(df1, expected=df2) def test_strict_requires_exact_floats(): df1 = pl.DataFrame({"x": [1.0, 2.0]}) df2 = pl.DataFrame({"x": [1.0 + 1e-9, 2.0]}) with ShouldAssert( 'DataFrames are different (value mismatch for column "x")\n' "[left]: shape: (2,)\n" "Series: 'x' [f64]\n" "[\n" "\t1.0\n" "\t2.0\n" "]\n" "[right]: shape: (2,)\n" "Series: 'x' [f64]\n" "[\n" "\t1.0\n" "\t2.0\n" "]" ): compare(df1, expected=df2, strict=True) def test_strict_exact_floats_compare_equal(): df1 = pl.DataFrame({"x": [1.0, 2.0]}) df2 = pl.DataFrame({"x": [1.0, 2.0]}) compare(df1, expected=df2, strict=True) testfixtures-12.4.0/tests/test_popen.py000066400000000000000000000602131525773641100202600ustar00rootroot00000000000000import subprocess from pathlib import Path from subprocess import PIPE, STDOUT from unittest import TestCase from testfixtures.mock import call from testfixtures import ShouldRaise, compare, Replacer from testfixtures.popen import MockPopen, PopenBehaviour import signal class Tests(TestCase): def test_command_min_args(self): # setup Popen = MockPopen() Popen.set_command('a command') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE) # process started, no return code compare(process.pid, 1234) compare(None, process.returncode) out, err = process.communicate() # test the rest compare(out, b'') compare(err, b'') compare(process.returncode, 0) # test call list compare([ call.Popen('a command', stderr=-1, stdout=-1), call.Popen_instance.communicate(), ], Popen.mock.method_calls) def test_command_max_args(self): Popen = MockPopen() Popen.set_command('a command', b'out', b'err', 1, 345) process = Popen('a command', stdout=PIPE, stderr=PIPE) compare(process.pid, 345) compare(None, process.returncode) out, err = process.communicate() # test the rest compare(out, b'out') compare(err, b'err') compare(process.returncode, 1) # test call list compare([ call.Popen('a command', stderr=-1, stdout=-1), call.Popen_instance.communicate(), ], Popen.mock.method_calls) def test_callable_default_behaviour(self): def some_callable(command, stdin): return PopenBehaviour(bytes(command, 'ascii'), bytes(stdin, 'ascii'), 1, 345, 0) Popen = MockPopen() Popen.set_default(behaviour=some_callable) process = Popen('a command', stdin='some stdin', stdout=PIPE, stderr=PIPE) compare(process.pid, 345) out, err = process.communicate() compare(out, b'a command') compare(err, b'some stdin') compare(process.returncode, 1) def test_command_is_sequence(self): Popen = MockPopen() Popen.set_command('a command') process = Popen(['a', 'command'], stdout=PIPE, stderr=PIPE) compare(process.wait(), 0) compare([ call.Popen(['a', 'command'], stderr=-1, stdout=-1), call.Popen_instance.wait(), ], Popen.mock.method_calls) def test_command_is_pathlike(self): Popen = MockPopen() Popen.set_command('a command') process = Popen(Path('a command')) compare(process.wait(), 0) compare([ call.Popen(Path('a command')), call.Popen_instance.wait(), ], Popen.mock.method_calls) def test_command_is_bytes(self): Popen = MockPopen() Popen.set_command('a command') process = Popen(b'a command') compare(process.wait(), 0) compare([ call.Popen(b'a command'), call.Popen_instance.wait(), ], Popen.mock.method_calls) def test_command_is_incorrect_type(self): Popen = MockPopen() Popen.set_command('a command') with ShouldRaise(TypeError("42 was , must be str")): Popen(42) def test_command_is_sequence_of_pathlike(self): Popen = MockPopen() Popen.set_command('a command') process = Popen(['a', Path('command')]) compare(process.wait(), 0) compare([ call.Popen(['a', Path('command')]), call.Popen_instance.wait(), ], Popen.mock.method_calls) def test_command_is_sequence_of_bytes(self): Popen = MockPopen() Popen.set_command('a command') process = Popen(['a', b'command']) compare(process.wait(), 0) compare([ call.Popen(['a', b'command']), call.Popen_instance.wait(), ], Popen.mock.method_calls) def test_command_is_sequence_of_incorrect_type(self): Popen = MockPopen() Popen.set_command('a command') with ShouldRaise(TypeError("42 in ['x', 42] was , must be str")): Popen(['x', 42]) def test_communicate_with_input(self): # setup Popen = MockPopen() Popen.set_command('a command') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) out, err = process.communicate('foo') # test call list compare([ call.Popen('a command', shell=True, stderr=-1, stdout=-1), call.Popen_instance.communicate('foo'), ], Popen.mock.method_calls) def test_communicate_with_timeout(self): Popen = MockPopen() Popen.set_command('a command', returncode=3) process = Popen('a command') process.communicate(timeout=1) process.communicate('foo', 1) compare([ call.Popen('a command'), call.Popen_instance.communicate(timeout=1), call.Popen_instance.communicate('foo', 1), ], expected=Popen.mock.method_calls) def test_read_from_stdout(self): # setup Popen = MockPopen() Popen.set_command('a command', stdout=b'foo') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) self.assertTrue(isinstance(process.stdout.fileno(), int)) compare(process.stdout.read(), b'foo') # test call list compare([ call.Popen('a command', shell=True, stderr=-1, stdout=-1), ], Popen.mock.method_calls) def test_read_from_stderr(self): # setup Popen = MockPopen() Popen.set_command('a command', stderr=b'foo') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) self.assertTrue(isinstance(process.stdout.fileno(), int)) compare(process.stderr.read(), b'foo') # test call list compare([ call.Popen('a command', shell=True, stderr=-1, stdout=-1), ], Popen.mock.method_calls) def test_read_from_stdout_with_stderr_redirected_check_stdout_contents(self): # setup Popen = MockPopen() Popen.set_command('a command', stdout=b'foo', stderr=b'bar') # usage process = Popen('a command', stdout=PIPE, stderr=STDOUT, shell=True) # test stdout contents compare(b'foobar', process.stdout.read()) compare(process.stderr, None) def test_read_from_stdout_with_stderr_redirected_check_stdout_stderr_interleaved(self): # setup Popen = MockPopen() Popen.set_command('a command', stdout=b'o1\no2\no3\no4\n', stderr=b'e1\ne2\n') # usage process = Popen('a command', stdout=PIPE, stderr=STDOUT, shell=True) self.assertTrue(isinstance(process.stdout.fileno(), int)) # test stdout contents compare(b'o1\ne1\no2\ne2\no3\no4\n', process.stdout.read()) def test_communicate_with_stderr_redirected_check_stderr_is_none(self): # setup Popen = MockPopen() Popen.set_command('a command', stdout=b'foo', stderr=b'bar') # usage process = Popen('a command', stdout=PIPE, stderr=STDOUT, shell=True) out, err = process.communicate() # test stderr is None compare(out, b'foobar') compare(err, None) def test_read_from_stdout_and_stderr(self): # setup Popen = MockPopen() Popen.set_command('a command', stdout=b'foo', stderr=b'bar') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) compare(process.stdout.read(), b'foo') compare(process.stderr.read(), b'bar') # test call list compare([ call.Popen('a command', shell=True, stderr=PIPE, stdout=PIPE), ], Popen.mock.method_calls) def test_communicate_text_mode(self): Popen = MockPopen() Popen.set_command('a command', stdout=b'foo', stderr=b'bar') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, text=True) actual = process.communicate() # check compare(actual, expected=(u'foo', u'bar')) def test_communicate_universal_newlines(self): Popen = MockPopen() Popen.set_command('a command', stdout=b'foo', stderr=b'bar') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, universal_newlines=True) actual = process.communicate() # check compare(actual, expected=(u'foo', u'bar')) def test_communicate_encoding(self): Popen = MockPopen() Popen.set_command('a command', stdout=b'foo', stderr=b'bar') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, encoding='ascii') actual = process.communicate() # check compare(actual, expected=(u'foo', u'bar')) def test_communicate_encoding_with_errors(self): Popen = MockPopen() Popen.set_command('a command', stdout=b'\xa3', stderr=b'\xa3') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, encoding='ascii', errors='ignore') actual = process.communicate() # check compare(actual, expected=(u'', u'')) def test_read_from_stdout_and_stderr_text_mode(self): Popen = MockPopen() Popen.set_command('a command', stdout=b'foo', stderr=b'bar') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, text=True) actual = process.stdout.read(), process.stderr.read() # check compare(actual, expected=(u'foo', u'bar')) def test_write_to_stdin(self): # setup Popen = MockPopen() Popen.set_command('a command') # usage process = Popen('a command', stdin=PIPE, shell=True) process.stdin.write('some text') # test call list compare(Popen.mock.method_calls, expected=[ call.Popen('a command', shell=True, stdin=PIPE), call.Popen_instance.stdin.write('some text'), ]) compare(Popen.all_calls, expected=[ call.Popen('a command', shell=True, stdin=PIPE), call.Popen('a command', shell=True, stdin=PIPE).stdin.write('some text'), ]) compare(process.mock.method_calls, expected=[ call.stdin.write('some text'), ]) compare(process.calls, expected=[ call.stdin.write('some text'), ]) repr(call.stdin.write('some text')) def test_wait_and_return_code(self): # setup Popen = MockPopen() Popen.set_command('a command', returncode=3) # usage process = Popen('a command') compare(process.returncode, None) # result checking compare(process.wait(), 3) compare(process.returncode, 3) # test call list compare([ call.Popen('a command'), call.Popen_instance.wait(), ], Popen.mock.method_calls) def test_wait_timeout(self): Popen = MockPopen() Popen.set_command('a command', returncode=3) process = Popen('a command') process.wait(timeout=1) process.wait(1) compare([ call.Popen('a command'), call.Popen_instance.wait(timeout=1), call.Popen_instance.wait(1) ], expected=Popen.mock.method_calls) def test_multiple_uses(self): Popen = MockPopen() Popen.set_command('a command', b'a') Popen.set_command('b command', b'b') process = Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) out, err = process.communicate('foo') compare(out, b'a') process = Popen(['b', 'command'], stdout=PIPE, stderr=PIPE, shell=True) out, err = process.communicate('foo') compare(out, b'b') compare([ call.Popen('a command', shell=True, stderr=-1, stdout=-1), call.Popen_instance.communicate('foo'), call.Popen(['b', 'command'], shell=True, stderr=-1, stdout=-1), call.Popen_instance.communicate('foo'), ], Popen.mock.method_calls) def test_send_signal(self): # setup Popen = MockPopen() Popen.set_command('a command') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) process.send_signal(0) # result checking compare([ call.Popen('a command', shell=True, stderr=-1, stdout=-1), call.Popen_instance.send_signal(0), ], Popen.mock.method_calls) def test_terminate(self): # setup Popen = MockPopen() Popen.set_command('a command') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) process.terminate() # result checking compare([ call.Popen('a command', shell=True, stderr=-1, stdout=-1), call.Popen_instance.terminate(), ], Popen.mock.method_calls) def test_kill(self): # setup Popen = MockPopen() Popen.set_command('a command') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) process.kill() # result checking compare([ call.Popen('a command', shell=True, stderr=-1, stdout=-1), call.Popen_instance.kill(), ], Popen.mock.method_calls) def test_all_signals(self): # setup Popen = MockPopen() Popen.set_command('a command') # usage process = Popen('a command') process.send_signal(signal.SIGINT) process.terminate() process.kill() # test call list compare([ call.Popen('a command'), call.Popen_instance.send_signal(signal.SIGINT), call.Popen_instance.terminate(), call.Popen_instance.kill(), ], Popen.mock.method_calls) def test_poll_no_setup(self): # setup Popen = MockPopen() Popen.set_command('a command') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) compare(process.poll(), None) compare(process.poll(), None) compare(process.wait(), 0) compare(process.poll(), 0) # result checking compare([ call.Popen('a command', shell=True, stderr=-1, stdout=-1), call.Popen_instance.poll(), call.Popen_instance.poll(), call.Popen_instance.wait(), call.Popen_instance.poll(), ], Popen.mock.method_calls) def test_poll_setup(self): # setup Popen = MockPopen() Popen.set_command('a command', poll_count=1) # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) compare(process.poll(), None) compare(process.poll(), 0) compare(process.wait(), 0) compare(process.poll(), 0) # result checking compare([ call.Popen('a command', shell=True, stderr=-1, stdout=-1), call.Popen_instance.poll(), call.Popen_instance.poll(), call.Popen_instance.wait(), call.Popen_instance.poll(), ], Popen.mock.method_calls) def test_poll_until_result(self): # setup Popen = MockPopen() Popen.set_command('a command', returncode=3, poll_count=2) # example usage process = Popen('a command') while process.poll() is None: # you'd probably have a sleep here, or go off and # do some other work. pass # result checking compare(process.returncode, 3) compare([ call.Popen('a command'), call.Popen_instance.poll(), call.Popen_instance.poll(), call.Popen_instance.poll(), ], Popen.mock.method_calls) def test_command_not_specified(self): Popen = MockPopen() with ShouldRaise(KeyError( "Nothing specified for command 'a command'" )): Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) def test_default_command_min_args(self): # setup Popen = MockPopen() Popen.set_default() # usage process = Popen('a command', stdout=PIPE, stderr=PIPE) # process started, no return code compare(process.pid, 1234) compare(None, process.returncode) out, err = process.communicate() # test the rest compare(out, b'') compare(err, b'') compare(process.returncode, 0) # test call list compare([ call.Popen('a command', stderr=-1, stdout=-1), call.Popen_instance.communicate(), ], Popen.mock.method_calls) def test_default_command_max_args(self): Popen = MockPopen() Popen.set_default(b'out', b'err', 1, 345) process = Popen('a command', stdout=PIPE, stderr=PIPE) compare(process.pid, 345) compare(None, process.returncode) out, err = process.communicate() # test the rest compare(out, b'out') compare(err, b'err') compare(process.returncode, 1) # test call list compare([ call.Popen('a command', stderr=-1, stdout=-1), call.Popen_instance.communicate(), ], Popen.mock.method_calls) def test_invalid_parameters(self): message = "MockPopenInstance.__init__() got an unexpected keyword argument 'foo'" Popen = MockPopen() with ShouldRaise(TypeError(message)): Popen(foo='bar') def test_invalid_method_or_attr(self): Popen = MockPopen() Popen.set_command('command') process = Popen('command') with ShouldRaise(AttributeError): process.foo() def test_invalid_attribute(self): Popen = MockPopen() Popen.set_command('command') process = Popen('command') with ShouldRaise(AttributeError): process.foo def test_invalid_communicate_call(self): message = "MockPopenInstance.communicate() got an unexpected keyword argument 'foo'" Popen = MockPopen() Popen.set_command('bar') process = Popen('bar') with ShouldRaise(TypeError(message)): process.communicate(foo='bar') def test_invalid_wait_call(self): message = "MockPopenInstance.wait() got an unexpected keyword argument 'foo'" Popen = MockPopen() Popen.set_command('bar') process = Popen('bar') with ShouldRaise(TypeError(message)): process.wait(foo='bar') def test_invalid_send_signal(self): message = "MockPopenInstance.send_signal() got an unexpected keyword argument 'foo'" Popen = MockPopen() Popen.set_command('bar') process = Popen('bar') with ShouldRaise(TypeError(message)): process.send_signal(foo='bar') def test_invalid_terminate(self): message = "MockPopenInstance.terminate() got an unexpected keyword argument 'foo'" Popen = MockPopen() Popen.set_command('bar') process = Popen('bar') with ShouldRaise(TypeError(message)): process.terminate(foo='bar') def test_invalid_kill(self): Popen = MockPopen() Popen.set_command('bar') process = Popen('bar') text = 'MockPopenInstance.kill() takes 1 positional argument but 2 were given' with ShouldRaise(TypeError(text)): process.kill('moo') def test_invalid_poll(self): Popen = MockPopen() Popen.set_command('bar') process = Popen('bar') text = 'MockPopenInstance.poll() takes 1 positional argument but 2 were given' with ShouldRaise(TypeError(text)): process.poll('moo') def test_non_pipe(self): # setup Popen = MockPopen() Popen.set_command('a command') # usage process = Popen('a command') # checks compare(process.stdout, expected=None) compare(process.stderr, expected=None) out, err = process.communicate() # test the rest compare(out, expected=None) compare(err, expected=None) # test call list compare([ call.Popen('a command'), call.Popen_instance.communicate(), ], Popen.mock.method_calls) def test_use_as_context_manager(self): # setup Popen = MockPopen() Popen.set_command('a command') # usage with Popen('a command', stdout=PIPE, stderr=PIPE) as process: # process started, no return code compare(process.pid, 1234) compare(None, process.returncode) out, err = process.communicate() # test the rest compare(out, b'') compare(err, b'') compare(process.returncode, 0) compare(process.stdout.closed, expected=True) compare(process.stderr.closed, expected=True) # test call list compare([ call.Popen('a command', stderr=-1, stdout=-1), call.Popen_instance.communicate(), call.Popen_instance.wait(), ], Popen.mock.method_calls) def test_start_new_session(self): # setup Popen = MockPopen() Popen.set_command('a command') # usage Popen('a command', start_new_session=True) # test call list compare([ call.Popen('a command', start_new_session=True), ], Popen.mock.method_calls) def test_simultaneous_processes(self): Popen = MockPopen() Popen.set_command('a command', b'a', returncode=1) Popen.set_command('b command', b'b', returncode=2) process_a = Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) process_b = Popen(['b', 'command'], stdout=PIPE, stderr=PIPE, shell=True) compare(process_a.wait(), expected=1) compare(process_b.wait(), expected=2) a_call = call.Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) b_call = call.Popen(['b', 'command'], stdout=PIPE, stderr=PIPE, shell=True) compare(Popen.all_calls, expected=[ a_call, b_call, a_call.wait(), b_call.wait(), ]) compare(process_a.mock.method_calls, expected=[ call.wait() ]) compare(process_b.mock.method_calls, expected=[ call.wait() ]) def test_pass_executable(self): Popen = MockPopen() Popen.set_command('a command', b'a', returncode=1) Popen('a command', executable='/foo/bar') compare(Popen.all_calls, expected=[ call.Popen('a command', executable='/foo/bar') ]) def test_set_command_with_list(self): Popen = MockPopen() Popen.set_command(['a', 'command']) Popen(['a', 'command'], stdout=PIPE, stderr=PIPE) compare([call.Popen(['a', 'command'], stderr=-1, stdout=-1)], actual=Popen.all_calls) class IntegrationTests(TestCase): def setUp(self): self.popen = MockPopen() replacer = Replacer() replacer.replace('tests.test_popen.subprocess.Popen', self.popen) self.addCleanup(replacer.restore) def test_command_called_with_check_call_check_returncode(self): self.popen.set_command('ls') compare(0, subprocess.check_call(['ls'])) def test_command_called_with_check_output_check_stdout_returned(self): self.popen.set_command('ls', stdout=b'abc') compare(b'abc', subprocess.check_output(['ls'])) def test_command_called_with_check_output_stderr_to_stdout_check_returned(self): self.popen.set_command('ls', stderr=b'xyz') compare(b'xyz', subprocess.check_output(['ls'], stderr=STDOUT)) def test_command_called_with_check_call_failing_command_check_exception(self): self.popen.set_command('ls', returncode=1) with self.assertRaises(subprocess.CalledProcessError): subprocess.check_output(['ls']) testfixtures-12.4.0/tests/test_popen_docs.py000066400000000000000000000167571525773641100213060ustar00rootroot00000000000000# NB: This file is used in the documentation, if you make changes, ensure # you update the line numbers in popen.rst! from subprocess import Popen, PIPE def my_func(): process = Popen(['svn', 'ls', '-R', 'foo'], stdout=PIPE, stderr=PIPE) out, err = process.communicate() if process.returncode: raise RuntimeError('something bad happened') return out dotted_path = 'tests.test_popen_docs.Popen' from unittest import TestCase from testfixtures.mock import call from testfixtures import Replacer, ShouldRaise, compare, SequenceComparison from testfixtures.popen import MockPopen, PopenBehaviour class TestMyFunc(TestCase): def setUp(self): self.Popen = MockPopen() self.r = Replacer() self.r.replace(dotted_path, self.Popen) self.addCleanup(self.r.restore) def test_example(self): # set up self.Popen.set_command('svn ls -R foo', stdout=b'o', stderr=b'e') # testing of results compare(my_func(), b'o') # testing calls were in the right order and with the correct parameters: process = call.Popen(['svn', 'ls', '-R', 'foo'], stderr=PIPE, stdout=PIPE) compare(Popen.all_calls, expected=[ process, process.communicate() ]) def test_example_bad_returncode(self): # set up Popen.set_command('svn ls -R foo', stdout=b'o', stderr=b'e', returncode=1) # testing of error with ShouldRaise(RuntimeError('something bad happened')): my_func() def test_communicate_with_input(self): # setup Popen = MockPopen() Popen.set_command('a command') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) out, err = process.communicate('foo') # test call list compare(Popen.all_calls, expected=[ process.root_call, process.root_call.communicate('foo'), ]) def test_read_from_stdout_and_stderr(self): # setup Popen = MockPopen() Popen.set_command('a command', stdout=b'foo', stderr=b'bar') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) compare(process.stdout.read(), expected=b'foo') compare(process.stderr.read(), expected=b'bar') def test_write_to_stdin(self): # setup Popen = MockPopen() Popen.set_command('a command') # usage process = Popen('a command', stdin=PIPE, shell=True) process.stdin.write('some text') process.stdin.close() # test call list compare(Popen.all_calls, expected=[ process.root_call, process.root_call.stdin.write('some text'), process.root_call.stdin.close(), ]) def test_wait_and_return_code(self): # setup Popen = MockPopen() Popen.set_command('a command', returncode=3) # usage process = Popen('a command') compare(process.returncode, expected=None) # result checking compare(process.wait(), expected=3) compare(process.returncode, expected=3) # test call list compare(Popen.all_calls, expected=[ call.Popen('a command'), call.Popen('a command').wait(), ]) def test_send_signal(self): # setup Popen = MockPopen() Popen.set_command('a command') # usage process = Popen('a command', stdout=PIPE, stderr=PIPE, shell=True) process.send_signal(0) # result checking compare(Popen.all_calls, expected=[ process.root_call, process.root_call.send_signal(0), ]) def test_poll_until_result(self): # setup Popen = MockPopen() Popen.set_command('a command', returncode=3, poll_count=2) # example usage process = Popen('a command') while process.poll() is None: # you'd probably have a sleep here, or go off and # do some other work. pass # result checking compare(process.returncode, expected=3) compare(Popen.all_calls, expected=[ process.root_call, process.root_call.poll(), process.root_call.poll(), process.root_call.poll(), ]) def test_default_behaviour(self): # set up self.Popen.set_default(stdout=b'o', stderr=b'e') # testing of results compare(my_func(), b'o') # testing calls were in the right order and with the correct parameters: root_call = call.Popen(['svn', 'ls', '-R', 'foo'], stderr=PIPE, stdout=PIPE) compare(Popen.all_calls, expected=[ root_call, root_call.communicate() ]) def test_multiple_responses(self): # set up behaviours = [ PopenBehaviour(stderr=b'e', returncode=1), PopenBehaviour(stdout=b'o'), ] def behaviour(command, stdin): return behaviours.pop(0) self.Popen.set_command('svn ls -R foo', behaviour=behaviour) # testing of error: with ShouldRaise(RuntimeError('something bad happened')): my_func() # testing of second call: compare(my_func(), b'o') def test_count_down(self): # set up self.Popen.set_command('svn ls -R foo', behaviour=CustomBehaviour()) # testing of error: with ShouldRaise(RuntimeError('something bad happened')): my_func() # testing of second call: compare(my_func(), b'o') def test_multiple_processes(self): # set up self.Popen.set_command('process --batch=0', stdout=b'42') self.Popen.set_command('process --batch=1', stdout=b'13') # testing of results compare(process_in_batches(2), expected=55) # testing of process management: p1 = call.Popen('process --batch=0', shell=True, stderr=PIPE, stdout=PIPE) p2 = call.Popen('process --batch=1', shell=True, stderr=PIPE, stdout=PIPE) compare(Popen.all_calls, expected=[ p1, p2, p1.communicate(), p2.communicate(), ]) def test_multiple_processes_unordered(self): # set up self.Popen.set_command('process --batch=0', stdout=b'42') self.Popen.set_command('process --batch=1', stdout=b'13') # testing of results compare(process_in_batches(2), expected=55) # testing of process management: p1 = call.Popen('process --batch=0', shell=True, stderr=PIPE, stdout=PIPE) p2 = call.Popen('process --batch=1', shell=True, stderr=PIPE, stdout=PIPE) compare(Popen.all_calls, expected=SequenceComparison( p2, p2.communicate(), p1, p1.communicate(), ordered=False )) class CustomBehaviour: def __init__(self, fail_count=1): self.fail_count = fail_count def __call__(self, command, stdin): while self.fail_count > 0: self.fail_count -= 1 return PopenBehaviour(stderr=b'e', returncode=1) return PopenBehaviour(stdout=b'o') def process_in_batches(n): processes = [] for i in range(n): processes.append(Popen('process --batch='+str(i), stdout=PIPE, stderr=PIPE, shell=True)) total = 0 for process in processes: out, err = process.communicate() total += int(out) return total testfixtures-12.4.0/tests/test_pydantic.py000066400000000000000000000054211525773641100207520ustar00rootroot00000000000000import pytest pytest.importorskip("pydantic") from pydantic import BaseModel, PrivateAttr import testfixtures.pydantic from testfixtures import ShouldAssert, compare from testfixtures.comparing import registry from testfixtures.pydantic import compare_basemodel class Point(BaseModel): x: int y: int class Session(BaseModel): user: str _cache: dict = PrivateAttr(default_factory=dict) class NamedPoint(BaseModel): name: str x: int y: int class Segment(BaseModel): start: Point end: Point def test_importable(): compare(testfixtures.pydantic.pydantic.__name__, expected="pydantic") def test_equal_models(): compare(Point(x=1, y=2), expected=Point(x=1, y=2)) def test_private_attributes_ignored(): s1 = Session(user='chris') s2 = Session(user='chris') s1._cache['token'] = 'abc' compare(s1, expected=s2) def test_comparer_in_limited_registry(): s1 = Session(user='chris') s2 = Session(user='chris') s1._cache['token'] = 'abc' with registry({Session: compare_basemodel}): compare(s1, expected=s2) def test_unequal_models(): with ShouldAssert( "Point not as expected:\n" "\n" "attributes same:\n" "['x']\n" "\n" "attributes differ:\n" "'y': 3 (expected) != 2 (actual)" ): compare(Point(x=1, y=2), expected=Point(x=1, y=3)) def test_models_of_different_types(): with ShouldAssert( "not equal:\n" "NamedPoint(name='p', x=1, y=2) (expected)\n" "Point(x=1, y=2) (actual)" ): compare(Point(x=1, y=2), expected=NamedPoint(name="p", x=1, y=2)) def test_unequal_models_in_dict_breadcrumb(): with ShouldAssert( "dict not as expected:\n" "\n" "values differ:\n" "'foo': Point(x=1, y=3) (expected) != Point(x=1, y=2) (actual)\n" "\n" "While comparing ['foo']: Point not as expected:\n" "\n" "attributes same:\n" "['x']\n" "\n" "attributes differ:\n" "'y': 3 (expected) != 2 (actual)" ): compare({"foo": Point(x=1, y=2)}, expected={"foo": Point(x=1, y=3)}) def test_nested_models(): with ShouldAssert( "Segment not as expected:\n" "\n" "attributes same:\n" "['start']\n" "\n" "attributes differ:\n" "'end': Point(x=3, y=5) (expected) != Point(x=3, y=4) (actual)\n" "\n" "While comparing .end: Point not as expected:\n" "\n" "attributes same:\n" "['x']\n" "\n" "attributes differ:\n" "'y': 5 (expected) != 4 (actual)" ): compare( Segment(start=Point(x=1, y=2), end=Point(x=3, y=4)), expected=Segment(start=Point(x=1, y=2), end=Point(x=3, y=5)), ) testfixtures-12.4.0/tests/test_rangecomparison.py000066400000000000000000000123221525773641100223240ustar00rootroot00000000000000from decimal import Decimal from testfixtures import RangeComparison as R, ShouldRaise, compare from unittest import TestCase class Tests(TestCase): def test_equal_yes_rhs(self): self.assertTrue(5 == R(2, 5)) def test_equal_yes_lhs(self): self.assertTrue(R(2, 5) == 2) def test_equal_no_rhs(self): self.assertFalse(5 == R(2, 4)) def test_equal_no_lhs(self): self.assertFalse(R(2, 3) == 5) def test_not_equal_yes_rhs(self): self.assertTrue(5 != R(2, 2)) def test_not_equal_yes_lhs(self): self.assertTrue(R(2, 4) != 1) def test_not_equal_no_rhs(self): self.assertFalse(5 != R(-10, 10)) def test_not_equal_no_lhs(self): self.assertFalse(R(2, 5) != 2) def test_equal_in_sequence_rhs(self): self.assertEqual((1, 2, 5), (1, 2, R(2, 5))) def test_equal_in_sequence_lhs(self): self.assertEqual((1, 2, R(2, 5)), (1, 2, 5)) def test_not_equal_in_sequence_rhs(self): self.assertNotEqual((1, 2, 5), (1, 2, R(2, 4))) def test_not_equal_in_sequence_lhs(self): self.assertNotEqual((1, 2, R(2, 4)), (1, 2, 5)) def test_not_numeric_rhs(self): with ShouldRaise(TypeError): 'abc' == R(2, 5) with ShouldRaise(TypeError): {} == R(2, 5) with ShouldRaise(TypeError): [] == R(2, 5) def test_not_numeric_lhs(self): with ShouldRaise(TypeError): R(2, 5) == 'abc' with ShouldRaise(TypeError): R(2, 5) == {} with ShouldRaise(TypeError): R(2, 5) == [] def test_repr(self): compare('', repr(R(2, 5))) def test_str(self): compare('', str(R(2, 5))) def test_str_negative(self): compare('', repr(R(2, 5))) def test_equal_yes_decimal_lhs(self): self.assertTrue(R(2, 5) == Decimal(3)) def test_equal_yes_decimal_rhs(self): self.assertTrue(Decimal(3) == R(2, 5)) def test_equal_no_decimal_lhs(self): self.assertFalse(R(2, 5) == Decimal(1.0)) def test_equal_no_decimal_rhs(self): self.assertFalse(Decimal(1.0) == R(2, 5)) def test_equal_yes_float_lhs(self): self.assertTrue(R(2, 5) == 3.0) def test_equal_yes_float_rhs(self): self.assertTrue(3.0 == R(2, 5)) def test_equal_no_float_lhs(self): self.assertFalse(R(2, 5) == 1.0) def test_equal_no_float_rhs(self): self.assertFalse(1.0 == R(2, 5)) def test_equal_yes_decimal_in_range_lhs(self): self.assertTrue(R(Decimal(1), 5) == 3) self.assertTrue(R(1, Decimal(5)) == 3) self.assertTrue(R(Decimal(1), Decimal(5)) == 3) def test_equal_yes_decimal_in_range_rhs(self): self.assertTrue(3 == R(Decimal(1), 5)) self.assertTrue(3 == R(1, Decimal(5))) self.assertTrue(3 == R(Decimal(1), Decimal(5))) def test_equal_no_decimal_in_range_lhs(self): self.assertFalse(R(Decimal(1), 5) == 6) self.assertFalse(R(1, Decimal(5)) == 6) self.assertFalse(R(Decimal(1), Decimal(5)) == 6) def test_equal_no_decimal_in_range_rhs(self): self.assertFalse(6 == R(Decimal(1), 5)) self.assertFalse(6 == R(1, Decimal(5))) self.assertFalse(6 == R(Decimal(1), Decimal(5))) def test_equal_yes_float_in_range_lhs(self): self.assertTrue(R(1.0, 5) == 3) self.assertTrue(R(1, 5.0) == 3) self.assertTrue(R(1.0, 5.0) == 3) def test_equal_yes_float_in_range_rhs(self): self.assertTrue(3 == R(1.0, 5)) self.assertTrue(3 == R(1, 5.0)) self.assertTrue(3 == R(1.0, 5.0)) def test_equal_no_float_in_range_lhs(self): self.assertFalse(R(1.0, 5) == 6) self.assertFalse(R(1, 5.0) == 6) self.assertFalse(R(1.0, 5.0) == 6) def test_equal_no_float_in_range_rhs(self): self.assertFalse(6 == R(1.0, 5)) self.assertFalse(6 == R(1, 5.0)) self.assertFalse(6 == R(1.0, 5.0)) def test_equal_yes_negative_lhs(self): self.assertTrue(R(-5, 5) == -3) self.assertTrue(R(-10, -5) == -7) def test_equal_yes_negative_rhs(self): self.assertTrue(-2 == R(-5, 5)) self.assertTrue(-7 == R(-10, -5)) def test_equal_no_negative_lhs(self): self.assertFalse(R(-5, 5) == -10) self.assertFalse(R(-10, -5) == -3) def test_equal_no_negative_rhs(self): self.assertFalse(-10 == R(-5, 5)) self.assertFalse(-30 == R(-10, -5)) def test_equal_yes_no_range_lhs(self): self.assertTrue(R(0, 0) == 0) self.assertTrue(R(2, 2) == 2) self.assertTrue(R(-1, -1) == -1) def test_equal_yes_no_range_rhs(self): self.assertTrue(0 == R(0, 0)) self.assertTrue(2 == R(2, 2)) self.assertTrue(-1 == R(-1, -1)) def test_equal_no_no_range_lhs(self): self.assertFalse(R(0, 0) == 1) self.assertFalse(R(2, 2) == 1) self.assertFalse(R(-1, -1) == 11) def test_equal_no_no_range_rhs(self): self.assertFalse(1 == R(0, 0)) self.assertFalse(1 == R(2, 2)) self.assertFalse(1 == R(-1, -1)) testfixtures-12.4.0/tests/test_replace.py000066400000000000000000001401041525773641100205500ustar00rootroot00000000000000import importlib import inspect import os import sys from contextlib import contextmanager from gc import get_referrers from operator import getitem from typing import Any, Callable, Iterator from unittest import TestCase from testfixtures import ( Replacer, Replace, ShouldRaise, TempDirectory, replace, compare, not_there, replace_in_environ, replace_on_class, replace_in_module, ShouldWarn, ) from testfixtures.compat import PY_313_PLUS from testfixtures.mock import Mock from tests import sample1, sample3 from tests import sample2 from .sample1 import z, X, Outer, ClassWithWrappedMethod, Singleton from .sample3 import SOME_CONSTANT from .temporary_module import temporary_module # can't import the replace module simply as replace-the-function is imported into the main # testfixtures package :-( replace_module = importlib.import_module('testfixtures.replace') class TestReplace(TestCase): def test_function(self): def test_z(): return 'replacement z' compare(sample1.z(), 'original z') @replace('tests.sample1.z', test_z) def test_something(): compare(sample1.z(), 'replacement z') compare(sample1.z(), 'original z') test_something() compare(sample1.z(), 'original z') def test_class(self): OriginalX = sample1.X class ReplacementX(sample1.X): pass self.assertFalse(OriginalX is ReplacementX) self.assertTrue(isinstance(sample1.X(), OriginalX)) @replace('tests.sample1.X', ReplacementX) def test_something(): self.assertFalse(OriginalX is ReplacementX) self.assertTrue(isinstance(sample1.X(), ReplacementX)) self.assertFalse(OriginalX is ReplacementX) self.assertTrue(isinstance(sample1.X(), OriginalX)) test_something() self.assertFalse(OriginalX is ReplacementX) self.assertTrue(isinstance(sample1.X(), OriginalX)) def test_method(self): def test_y(self): return self compare(sample1.X().y(), 'original y') @replace('tests.sample1.X.y', test_y) def test_something(): self.assertTrue(isinstance(sample1.X().y(), sample1.X)) compare(sample1.X().y(), 'original y') test_something() compare(sample1.X().y(), 'original y') def test_class_method(self): def rMethod(cls): return (cls, 1) compare(sample1.X().aMethod(), sample1.X) @replace('tests.sample1.X.aMethod', rMethod) def test_something(r): compare(r, rMethod) compare(sample1.X().aMethod(), (sample1.X, 1)) compare(sample1.X().aMethod(), sample1.X) test_something() compare(sample1.X().aMethod(), sample1.X) def test_multiple_replace(self): def test_y(self): return 'test y' def test_z(): return 'test z' compare(sample1.z(), 'original z') compare(sample1.X().y(), 'original y') @replace('tests.sample1.z', test_z) @replace('tests.sample1.X.y', test_y) def test_something(passed_test_y, passed_test_z): compare(test_z, passed_test_z) compare(test_y, passed_test_y) compare(sample1.z(), 'test z') compare(sample1.X().y(), 'test y') compare(sample1.z(), 'original z') compare(sample1.X().y(), 'original y') test_something() compare(sample1.z(), 'original z') compare(sample1.X().y(), 'original y') def test_gotcha(self): # Just because you replace an object in one context, # doesn't meant that it's replaced in all contexts! def test_z(): return 'test z' compare(sample1.z(), 'original z') compare(sample2.z(), 'original z') @replace('tests.sample1.z', test_z) def test_something(): compare(sample1.z(), 'test z') compare(sample2.z(), 'original z') compare(sample1.z(), 'original z') compare(sample2.z(), 'original z') test_something() compare(sample1.z(), 'original z') compare(sample2.z(), 'original z') def test_raises(self): def test_z(): return 'replacement z' compare(sample1.z(), 'original z') @replace('tests.sample1.z', test_z) def test_something(): compare(sample1.z(), 'replacement z') raise Exception() compare(sample1.z(), 'original z') with ShouldRaise(): test_something() compare(sample1.z(), 'original z') def test_want_replacement(self): o = object() @replace('tests.sample1.z', o) def test_something(r): self.assertTrue(r is o) self.assertTrue(sample1.z is o) test_something() def test_not_there(self): o = object() @replace('tests.sample1.bad', o) def test_something(r): pass # pragma: no cover with ShouldRaise(AttributeError("Original 'bad' not found")): test_something() def test_not_there_ok(self): o = object() @replace('tests.sample1.bad', o, strict=False) def test_something(r): self.assertTrue(r is o) self.assertTrue(sample1.bad is o) test_something() def test_replace_dict(self): from tests.sample1 import some_dict original = some_dict['key'] replacement = object() @replace('tests.sample1.some_dict.key', replacement) def test_something(obj): self.assertTrue(obj is replacement) self.assertTrue(some_dict['key'] is replacement) test_something() self.assertTrue(some_dict['key'] is original) def test_replace_delattr(self): from tests import sample1 @replace('tests.sample1.some_dict', not_there) def test_something(obj): self.assertFalse(hasattr(sample1, 'some_dict')) test_something() self.assertEqual(sample1.some_dict, {'complex_key': [1, 2, 3], 'key': 'value'}) def test_replace_delattr_not_there(self): @replace('tests.sample1.foo', not_there) def test_something(obj): pass # pragma: no cover with ShouldRaise(AttributeError("Original 'foo' not found")): test_something() def test_replace_delattr_not_there_not_strict(self): from tests import sample1 @replace('tests.sample1.foo', not_there, strict=False) def test_something(obj): self.assertFalse(hasattr(sample1, 'foo')) test_something() def test_replace_delattr_not_there_restored(self): from tests import sample1 @replace('tests.sample1.foo', not_there, strict=False) def test_something(obj): sample1.foo = 'bar' test_something() self.assertFalse(hasattr(sample1, 'foo')) def test_replace_delattr_cant_remove(self): message = " has __dict__ but 'today' is not in it" with Replacer() as r: with ShouldRaise(AttributeError(message)): r.replace('datetime.datetime.today', not_there) def test_replace_delattr_cant_remove_not_strict(self): message = "cannot set 'today' attribute of immutable type 'datetime.datetime'" with Replacer() as r: with ShouldRaise(TypeError(message)): r.replace('datetime.datetime.today', not_there, strict=False) def test_replace_dict_remove_key(self): from tests.sample1 import some_dict @replace('tests.sample1.some_dict.key', not_there) def test_something(obj): self.assertFalse('key' in some_dict) test_something() self.assertEqual(sorted(some_dict.keys()), ['complex_key', 'key']) def test_replace_dict_remove_key_not_there(self): from tests.sample1 import some_dict @replace('tests.sample1.some_dict.badkey', not_there) def test_something(obj): self.assertFalse('badkey' in some_dict) # pragma: no cover with ShouldRaise(AttributeError("Original 'badkey' not found")): test_something() self.assertEqual(sorted(some_dict.keys()), ['complex_key', 'key']) def test_replace_dict_remove_key_not_there_not_strict(self): from tests.sample1 import some_dict @replace('tests.sample1.some_dict.badkey', not_there, strict=False) def test_something(obj): self.assertFalse('badkey' in some_dict) test_something() self.assertEqual(sorted(some_dict.keys()), ['complex_key', 'key']) def test_replace_dict_ensure_key_not_there_restored(self): from tests.sample1 import some_dict @replace('tests.sample1.some_dict.badkey', not_there, strict=False) def test_something(obj): some_dict['badkey'] = 'some test value' test_something() self.assertEqual(sorted(some_dict.keys()), ['complex_key', 'key']) def test_replace_dict_not_there(self): from tests.sample1 import some_dict replacement = object() @replace('tests.sample1.some_dict.key2', replacement, strict=False) def test_something(obj): self.assertTrue(obj is replacement) self.assertTrue(some_dict['key2'] is replacement) test_something() self.assertEqual(sorted(some_dict.keys()), ['complex_key', 'key']) def test_replace_dict_not_there_empty_string(self): from tests.sample1 import some_dict @replace('tests.sample1.some_dict.key2', '', strict=False) def test_something(): self.assertEqual(some_dict['key2'], '') test_something() self.assertEqual(sorted(some_dict.keys()), ['complex_key', 'key']) def test_replace_complex(self): from tests.sample1 import some_dict original = some_dict['complex_key'][1] replacement = object() @replace('tests.sample1.some_dict.complex_key.1', replacement) def test_something(obj): self.assertTrue(obj is replacement) self.assertEqual(some_dict['complex_key'], [1, obj, 3]) test_something() self.assertEqual(some_dict['complex_key'], [1, 2, 3]) self.assertTrue(original is some_dict['complex_key'][1]) def test_replacer_del(self): r = Replacer() r.replace('tests.sample1.left_behind', object(), strict=False) with ShouldWarn( UserWarning( "Replacer deleted without being restored, originals not restored: " "'tests.sample1.left_behind': original = " ) ): del r def test_replacer_del_env_replaced(self): with replace_in_environ('TEST_ENV_VAR', not_there): r = Replacer() r.in_environ('TEST_ENV_VAR', 'test_value') with ShouldWarn( UserWarning( "Replacer deleted without being restored, originals not restored: " " ('TEST_ENV_VAR'): original = " ) ): del r def test_multiple_replaces(self): orig = os.path.sep with Replacer() as r: r.replace('os.path.sep', '$') compare(os.path.sep, '$') r.replace('os.path.sep', '=') compare(os.path.sep, '=') compare(orig, os.path.sep) def test_sub_module_import(self): with TempDirectory() as dir: dir.write('module/__init__.py', b'') dir.write('module/submodule.py', b'def foo(): return "foo"') with Replacer() as r: r.replace('sys.path', [dir.path]) def bar(): return "bar" # now test r.replace('module.submodule.foo', bar) from module.submodule import foo # type: ignore[import-not-found] compare(foo(), "bar") def test_staticmethod(self): compare(sample1.X.bMethod(), 2) with Replacer() as r: r.replace('tests.sample1.X.bMethod', lambda: 1) compare(sample1.X.bMethod(), 1) compare(sample1.X.bMethod(), 2) def test_use_as_cleanup(self): def test_z(): return 'replacement z' compare(sample1.z(), 'original z') replace = Replacer() compare(sample1.z(), 'original z') replace('tests.sample1.z', test_z) cleanup = replace.restore try: compare(sample1.z(), 'replacement z') finally: cleanup() compare(sample1.z(), 'original z') def test_replace_context_manager(self): def test_z(): return 'replacement z' compare(sample1.z(), 'original z') with Replace('tests.sample1.z', test_z) as z: compare(z(), 'replacement z') compare(sample1.z(), 'replacement z') compare(sample1.z(), 'original z') def test_multiple_context_managers(self): def test_y(self): return 'test y' def test_z(): return 'test z' compare(sample1.z(), 'original z') compare(sample1.X().y(), 'original y') with Replacer() as replace: z = replace('tests.sample1.z', test_z) y = replace('tests.sample1.X.y', test_y) compare(z(), 'test z') compare(y, sample1.X.y) compare(sample1.X().y(), 'test y') compare(sample1.z(), 'test z') compare(sample1.X().y(), 'test y') compare(sample1.z(), 'original z') compare(sample1.X().y(), 'original y') def test_context_manager_not_strict(self): def test_z(): return 'replacement z' with Replace('tests.sample1.foo', test_z, strict=False): compare(sample1.foo(), 'replacement z') def test_context_manager_full_spec(self): my_dict = {} with Replace(my_dict, name='foo', accessor=getitem, replacement=42, strict=False): compare(my_dict, expected={'foo': 42}) compare(my_dict, expected={}) def test_decorator_full_spec(self): my_dict = {} @replace(my_dict, name='foo', accessor=getitem, replacement=42, strict=False) def test_something(): compare(my_dict, expected={'foo': 42}) test_something() compare(my_dict, expected={}) def test_replace_method_full_spec(self): my_dict = {} with Replacer() as r: r.replace(my_dict, name='foo', accessor=getitem, replacement=42, strict=False) compare(my_dict, expected={'foo': 42}) compare(my_dict, expected={}) def test_context_manager_specified_method(self): class SampleClass: def method(self, x): return x*2 sample_obj = SampleClass() with Replace(SampleClass.method, lambda self, x: x*3, container=SampleClass, name='method', accessor=getattr): compare(sample_obj.method(1), expected=3) compare(sample_obj.method(1), expected=2) def test_decorator_specified_method(self): class SampleClass: def method(self, x): return x*2 sample_obj = SampleClass() @replace(SampleClass.method, lambda self, x: x*3, container=SampleClass, name='method', accessor=getattr) def test_something(): compare(sample_obj.method(1), expected=3) test_something() compare(sample_obj.method(1), expected=2) def test_fully_specified_method(self): class SampleClass: def method(self, x): return x*2 sample_obj = SampleClass() with Replacer() as r: r.replace(SampleClass.method, lambda self, x: x*3, container=SampleClass, name='method', accessor=getattr) compare(sample_obj.method(1), expected=3) compare(sample_obj.method(1), expected=2) def test_fully_specified_method_incorrect_name(self): class SampleClass: def a(self): return 1 def b(self): return 2 sample_obj = SampleClass() s_repr = repr(SampleClass) a_repr = repr(SampleClass.a) b_repr = repr(SampleClass.b) replacer = Replacer() with ShouldRaise(AssertionError( f" of 'b' from {s_repr} gave {b_repr}, expected {a_repr}" )): replacer(SampleClass.a, lambda self: 3, container=SampleClass, name='b', accessor=getattr) compare(sample_obj.a(), expected=1) compare(sample_obj.b(), expected=2) def test_traverse_from_container(self): x = sample1.X() with Replace('.X.aMethod', lambda cls: 'FOO', container=sample1): compare(x.aMethod(), expected='FOO') assert x.aMethod() is sample1.X def test_only_relative_traverse_from_container(self): x = sample1.X() replacer = Replacer() with ShouldRaise(AssertionError('Absolute traversal not allowed when container supplied')): replacer('foo', object(), container=sample1) def test_no_name_and_target_string(self): x = sample1.X() replacer = Replacer() with ShouldRaise(AttributeError("Original 'aMethod' not found")): replacer('.X', lambda cls: 'FOO', name='aMethod', container=sample1) assert x.aMethod() is sample1.X def test_no_accessor_allowed_when_name_not_specified(self): my_dict = {} replacer = Replacer() with ShouldRaise(TypeError('accessor is not used unless name is specified')): replacer(container=my_dict, target='.my.key', accessor=getitem, replacement='bar') compare(my_dict, expected={}) def test_dict_and_name(self): my_dict = {} with Replace(my_dict, name='foo', replacement=42, strict=False): compare(my_dict, expected={'foo': 42}) compare(my_dict, expected={}) def test_name_and_dict(self): environ = {} with Replace('.MY_ENV_VAR', 'True', container=environ, strict=False): compare(environ, expected={'MY_ENV_VAR': 'True'}) compare(environ, expected={}) def test_non_string_target_and_no_name(self): my_dict = {} replacer = Replacer() with ShouldRaise(TypeError('name must be specified when target is not a string')): replacer(my_dict, replacement=42, strict=False) compare(my_dict, expected={}) def test_unclear_target(self): replacer = Replacer() with ShouldRaise(ValueError('target must contain at least one dot!')): replacer('sys', replacement=None) def test_method_on_instance(self): class SampleClass: def method(self, x): return x*2 sample = SampleClass() with Replacer() as replace: replace(sample.method, lambda x: x * 3, container=sample, strict=False) compare(sample.method(1), expected=3) compare(sample.method(1), expected=2) def test_class_attribute(self): mock = Mock() mock.FOO = set() class SampleClass: FOO = mock.FOO sample = SampleClass() with Replace(SampleClass.FOO, {'X'}, container=SampleClass, name='FOO'): compare(sample.FOO, expected={'X'}) compare(mock.FOO, expected=set()) compare(sample.FOO, expected=set()) def test_function_and_module(self): with Replace(z, lambda: 'all new z', container=sample1): from .sample1 import z as sample1_z from .sample3 import z as sample3_z compare(sample1_z(), expected='all new z') compare(sample3_z(), expected='original z') def test_constant(self): with Replace(SOME_CONSTANT, 43, container=sample3, name='SOME_CONSTANT'): from .sample3 import SOME_CONSTANT as sample3_some_constant compare(sample3_some_constant, expected=43) def test_relative_nested_but_not_present(self): nested = {'b': [1, 2, 3]} with Replacer() as r: with ShouldRaise(AttributeError("Original 'a' not found")): r(container=nested, target='.a.1', replacement=42) def test_alternative_separator(self): nested = {'.b': 1, 'c.d': 2, 'e': {'f': 42}} with Replacer() as r: r(container=nested, target=':.b', replacement=3, sep=':') r(container=nested, target=':c.d', replacement=4, sep=':') compare(nested, expected={'.b': 3, 'c.d': 4, 'e': {'f': 42}}) with Replace(container=nested, target=':e:f', replacement=43, sep=':'): compare(nested, expected={'.b': 1, 'c.d': 2, 'e': {'f': 43}}) @replace(container=nested, target=':e:f', replacement=43, sep=':') def function(): compare(nested, expected={'.b': 1, 'c.d': 2, 'e': {'f': 43}}) function() def test_alternative_separator_traversal_not_valid(self): nested = {'.b': 1} with Replacer() as r: with ShouldRaise(AttributeError("Original '.a.1' not found")): r(container=nested, target=':.a.1', replacement=3, sep=':') class TestEnviron: def test_key_present(self): os.environ['TESTFIXTURES_SAMPLE_KEY_PRESENT'] = 'ORIGINAL' with Replacer() as replace: replace.in_environ('TESTFIXTURES_SAMPLE_KEY_PRESENT', 'NEW') compare(os.environ['TESTFIXTURES_SAMPLE_KEY_PRESENT'], expected='NEW') compare(os.environ['TESTFIXTURES_SAMPLE_KEY_PRESENT'], expected='ORIGINAL') def test_key_not_present(self): assert 'TESTFIXTURES_SAMPLE_KEY_MISSING' not in os.environ with Replacer() as replace: replace.in_environ('TESTFIXTURES_SAMPLE_KEY_MISSING', 'NEW') compare(os.environ['TESTFIXTURES_SAMPLE_KEY_MISSING'], expected='NEW') assert 'TESTFIXTURES_SAMPLE_KEY_MISSING' not in os.environ def test_non_string_replacement(self): with Replacer() as replace: replace.in_environ('PORT', 1) compare(os.environ['PORT'], expected='1') def test_ensure_not_present(self): os.environ['TESTFIXTURES_SAMPLE_KEY_PRESENT'] = 'ORIGINAL' with Replacer() as replace: replace.in_environ('TESTFIXTURES_SAMPLE_KEY_PRESENT', not_there) assert 'TESTFIXTURES_SAMPLE_KEY_PRESENT' not in os.environ class TestOnClass: def test_method_on_class(self): class SampleClass: def method(self, x): return x*2 original = SampleClass.__dict__['method'] sample = SampleClass() with Replacer() as replace: replace.on_class(SampleClass.method, lambda self, x: x*3) compare(sample.method(1), expected=3) compare(sample.method(1), expected=2) assert SampleClass.__dict__['method'] is original def test_method_on_subclass(self): class SampleClass: def method_a(self, x): return x*2 class SampleSubClass(SampleClass): """ Some doc! """ def method_b(self, x): return x*3 original_a = SampleClass.__dict__['method_a'] original_b = SampleSubClass.__dict__['method_b'] sample = SampleSubClass() with Replacer() as replace: replace.on_class(SampleSubClass.method_a, lambda self, x: x*4) replace.on_class(SampleSubClass.method_b, lambda self, x: x*5) compare(sample.method_a(1), expected=4) compare(sample.method_b(1), expected=5) compare(sample.method_a(1), expected=2) compare(sample.method_b(1), expected=3) assert SampleClass.__dict__['method_a'] is original_a assert SampleSubClass.__dict__['method_b'] is original_b assert 'method_a' not in SampleSubClass.__dict__ def test_attributes_on_class(self): class SampleClass: x = 1 y = 'a' sample = SampleClass() with Replacer() as replace: # without names, we get a useful errors: with ShouldRaise(TypeError("attribute named 'x' must be a method")): replace.on_class(SampleClass.x, 2, name='x') with ShouldRaise(TypeError("attribute must be a method")): replace.on_class(SampleClass.y, 'b') # okay, so we'll use the full form: replace(SampleClass.x, 2, container=SampleClass, name='x') replace(SampleClass.y, 'b', container=SampleClass, name='y') compare(sample.x, expected=2) compare(sample.y, expected='b') compare(sample.x, expected=1) compare(sample.y, expected='a') def test_method_on_instance(self): class SampleClass: def method(self, x): return x*2 original = SampleClass.__dict__['method'] sample = SampleClass() with Replacer() as replace: with ShouldRaise(AttributeError): replace.on_class(sample.method, lambda self, x: x*3) # ...so use explicit and non-strict: replace(sample.method, lambda x: x * 3, container=sample, strict=False) compare(sample.method(1), expected=3) compare(sample.method(1), expected=2) assert SampleClass.__dict__['method'] is original def test_badly_decorated_method(self): def bad(f): def inner(self, x): return f(self, x) return inner class SampleClass: @bad def method(self, x): return x*2 original = SampleClass.__dict__['method'] sample = SampleClass() with Replacer() as replace: # without the name, we get a useful error: with ShouldRaise(AttributeError( f"could not find container of {SampleClass.method} using name 'inner'" )): replace.on_class(SampleClass.method, lambda self_, x: x*3) assert SampleClass.__dict__['method'] is original replace.on_class(SampleClass.method, lambda self_, x: x*3, name='method') compare(sample.method(1), expected=3) compare(sample.method(1), expected=2) assert SampleClass.__dict__['method'] is original def test_classmethod(self): class SampleClass: @classmethod def method(cls, x): return x*2 original = SampleClass.__dict__['method'] with Replacer() as replace: replace.on_class(SampleClass.method, classmethod(lambda cls, x: x*3)) compare(SampleClass.method(1), expected=3) compare(SampleClass.method(1), expected=2) assert SampleClass.__dict__['method'] is original def test_staticmethod(self): class SampleClass: @staticmethod def method(x): return x*2 original = SampleClass.__dict__['method'] with Replacer() as replace: replace.on_class(SampleClass.method, lambda x: x*3) compare(SampleClass.method(1), expected=3) compare(SampleClass.method(1), expected=2) assert SampleClass.__dict__['method'] is original def test_method_on_class_in_module(self): sample = X() original = X.__dict__['y'] with Replacer() as replace: replace.on_class(X.y, lambda self_: 'replacement y') compare(sample.y(), expected='replacement y') compare(sample.y(), expected='original y') assert X.__dict__['y'] is original def test_method_on_instance_in_module(self): sample = X() original = X.__dict__['y'] with Replacer() as replace: replace(sample.y, lambda: 'replacement y', container=sample, strict=False) compare(sample.y(), expected='replacement y') compare(sample.y(), expected='original y') assert X.__dict__['y'] is original def test_classmethod_on_class_in_module(self): original = X.__dict__['aMethod'] with Replacer() as replace: replace.on_class(X.aMethod, classmethod(lambda cls: (cls, cls))) compare(X.aMethod(), expected=(X, X)) compare(X.aMethod(), expected=X) assert X.__dict__['aMethod'] is original def test_classmethod_on_instance_in_module(self): sample = X() original = X.__dict__['aMethod'] with Replacer() as replace: replace.on_class(sample.aMethod, classmethod(lambda cls: (cls, cls))) compare(sample.aMethod(), expected=(X, X)) compare(sample.aMethod(), expected=X) assert X.__dict__['aMethod'] is original def test_staticmethod_on_class_in_module(self): original = X.__dict__['bMethod'] with Replacer() as replace: replace.on_class(X.bMethod, lambda: 3) compare(X.bMethod(), expected=3) compare(X.bMethod(), expected=2) assert X.__dict__['bMethod'] is original def test_staticmethod_on_instance_in_module(self): original = X.__dict__['bMethod'] sample = X() with Replacer() as replace: replace(sample.bMethod, lambda: 3, container=sample, strict=False) compare(sample.bMethod(), expected=3) compare(X.bMethod(), expected=2) assert X.__dict__['bMethod'] is original def test_multiple_methods_on_class(self): original_y = X.y original_a_result = X.aMethod() with Replacer() as replace: # y = replace('tests.sample1.X.y', Mock()) # y.return_value = 'mock y' # aMethod = replace('tests.sample1.X.aMethod', Mock()) # aMethod.return_value = 'mock method' replace.on_class(X.y, lambda self: 'mock y') replace.on_class(X.aMethod, lambda cls: 'mock method') x = X() compare(x.y(), expected='mock y') compare(x.aMethod(), expected='mock method') assert X.y is original_y assert X.aMethod() is original_a_result class TestInModule: def test_function_guess_module(self): with Replacer() as replace: replace.in_module(z, lambda: 'all new z') from .sample1 import z as sample1_z from .sample3 import z as sample3_z compare(sample1_z(), expected='all new z') compare(sample3_z(), expected='original z') from .sample1 import z as sample1_z from .sample3 import z as sample3_z compare(sample1_z(), expected='original z') compare(sample3_z(), expected='original z') def test_function_explict_module(self): with Replacer() as replace: replace.in_module(z, lambda: 'all new z', module=sample3) from .sample1 import z as sample1_z from .sample3 import z as sample3_z compare(sample1_z(), expected='original z') compare(sample3_z(), expected='all new z') from .sample1 import z as sample1_z from .sample3 import z as sample3_z compare(sample1_z(), expected='original z') compare(sample3_z(), expected='original z') def test_constant(self): replace = Replacer() with ShouldRaise(AttributeError("'int' object has no attribute '__module__'")): replace.in_module(SOME_CONSTANT, 43) from .sample3 import SOME_CONSTANT as sample3_some_constant compare(sample3_some_constant, expected=42) class TestConvenience: def test_environ(self): os.environ['TESTFIXTURES_SAMPLE_KEY_PRESENT'] = 'ORIGINAL' with replace_in_environ('TESTFIXTURES_SAMPLE_KEY_PRESENT', 'NEW'): compare(os.environ['TESTFIXTURES_SAMPLE_KEY_PRESENT'], expected='NEW') compare(os.environ['TESTFIXTURES_SAMPLE_KEY_PRESENT'], expected='ORIGINAL') def test_on_class(self): class SampleClass: def method(self, x): return x*2 sample = SampleClass() with replace_on_class(SampleClass.method, lambda self, x: x*3, name='method'): compare(sample.method(1), expected=3) compare(sample.method(1), expected=2) def test_in_module(self): with replace_in_module(z, lambda: 'all new z', module=sample3): from .sample1 import z as sample1_z from .sample3 import z as sample3_z compare(sample1_z(), expected='original z') compare(sample3_z(), expected='all new z') from .sample1 import z as sample1_z from .sample3 import z as sample3_z compare(sample1_z(), expected='original z') compare(sample3_z(), expected='original z') class OriginA: bar = 13 def __init__(self): self.foo = 43 def method(self, x): return x * 2 original = method class UseB(OriginA): pass class UseC(OriginA): pass class OriginD: def __init__(self, attrs): self.attrs = attrs def __getattr__(self, item): return self.attrs[item] class OriginE: __slots__ = 'attr', def method(self, x): return x * 2 original = method class UseF(OriginE): __slots__ = 'attr', class UseG(OriginE): __slots__ = 'attr', def check_originals_not_modified(): assert OriginA.__dict__['method'] is OriginA.original assert OriginA.__dict__['bar'] == 13 assert 'method' not in UseB.__dict__ assert 'method' not in UseC.__dict__ assert OriginE.method is OriginE.original def check_behaviour_is_unchanged(*callables): for c in callables: compare(c(1), expected=2, prefix=repr(c)) class TestReplaceWithInterestingOriginsStrict: strict = True def test_class_attribute_on_class(self): sample_a = OriginA() sample_b = UseB() sample_c = UseC() with Replace(OriginA.bar, name='bar', replacement=31, strict=self.strict, container=OriginA): compare(sample_a.bar, expected=31) compare(sample_b.bar, expected=31) compare(sample_c.bar, expected=31) compare(sample_a.bar, expected=13) compare(sample_b.bar, expected=13) compare(sample_c.bar, expected=13) check_originals_not_modified() def test_class_attribute_on_subclass(self): sample_a = OriginA() sample_b = UseB() sample_c = UseC() replace_ = Replacer() with ShouldRaise(AttributeError(f"{UseB!r} has __dict__ but 'bar' is not in it")): replace_(UseB.bar, name='bar', replacement=31, strict=self.strict, container=UseB) compare(sample_a.bar, expected=13) compare(sample_b.bar, expected=13) compare(sample_c.bar, expected=13) check_originals_not_modified() def test_method_on_subclass(self): sample_a = OriginA() sample_b = UseB() sample_c = UseC() replace_ = Replacer() with ShouldRaise(AttributeError(f"{UseB!r} has __dict__ but 'method' is not in it")): replace_( UseB.method, lambda self_, x: x*4, name='method', container=UseB, strict=self.strict ) check_behaviour_is_unchanged(sample_a.method, sample_b.method, sample_c.method) check_originals_not_modified() def test_class_attribute_on_instance_of_class(self): obj = OriginA() bar = obj.bar replace_ = Replacer() with ShouldRaise(AttributeError(f"{obj!r} has __dict__ but 'bar' is not in it")): replace_(obj, name='bar', replacement=31, strict=self.strict) compare(obj.bar, expected=13) assert obj.bar is bar def test_instance_attribute_on_instance_of_class(self): obj = OriginA() foo = obj.foo with Replace(obj, name='foo', replacement=42, strict=self.strict): compare(obj.foo, expected=42) assert obj.foo is foo def test_method_on_instance_of_class(self): sample_a = OriginA() sample_b = UseB() sample_c = UseC() replace = Replacer() with ShouldRaise(TypeError( "Cannot replace methods on instances with strict=True, " "replace on class or use strict=False") ): replace( sample_a.method, lambda self_, x: x*4, name='method', container=sample_a, strict=self.strict ) check_behaviour_is_unchanged(sample_a.method, sample_b.method, sample_c.method) check_originals_not_modified() def test_method_on_instance_of_subclass(self): obj = UseB() replace = Replacer() with ShouldRaise(TypeError( "Cannot replace methods on instances with strict=True, " "replace on class or use strict=False") ): replace( obj.method, lambda self_, x: x*4, name='method', container=obj, strict=self.strict ) check_behaviour_is_unchanged(obj.method) check_originals_not_modified() def test_valid_attribute_on_instance_of_slotted_class(self): obj = OriginE() assert not hasattr(obj, '__dict__') obj.attr = 41 with Replace(obj, name='attr', replacement=42, strict=self.strict): compare(obj.attr, expected=42) assert obj.attr == 41 def test_invalid_attribute_on_instance_of_slotted_class(self): obj = OriginE() assert not hasattr(obj, '__dict__') replace_ = Replacer() with ShouldRaise(AttributeError("Original 'bad' not found")): replace_(obj, name='bad', replacement=42, strict=self.strict) def test_method_on_instance_of_slotted_subclass(self): sample_e = OriginE() sample_f = UseF() sample_g = UseG() obj = UseF() assert not hasattr(obj, '__dict__') replace_ = Replacer() with ShouldRaise(TypeError( "Cannot replace methods on instances with strict=True, " "replace on class or use strict=False" )): replace_( obj.method, lambda self_, x: x*4, name='method', container=obj, strict=self.strict ) check_behaviour_is_unchanged(sample_e.method, sample_f.method, sample_g.method) check_behaviour_is_unchanged() def test_interesting_container(self): replace_ = Replacer() sample = OriginD({'foo': 'bar'}) with ShouldRaise(AttributeError(f"{sample!r} has __dict__ but 'foo' is not in it")): replace_(sample.foo, 'baz', name='foo', container=sample, strict=self.strict) compare(sample.foo, expected='bar') assert 'foo' not in sample.__dict__ def test_mock_and_name(self): my_obj = Mock() foo = my_obj.foo # Mock instances are tricky in that they have a __dict__ but # their attributes are not in it: replace_ = Replacer() with ShouldRaise(AttributeError(f"{my_obj!r} has __dict__ but 'foo' is not in it")): replace_(my_obj, name='foo', replacement=42, strict=self.strict) assert my_obj.foo is foo class TestReplaceWithInterestingOriginsNotStrict(TestReplaceWithInterestingOriginsStrict): # This subclasses TestReplaceWithInterestingOriginsStrict to ensure we check all the same cases strict = False def test_class_attribute_on_subclass(self): sample_a = OriginA() sample_b = UseB() sample_c = UseC() with Replace(UseB.bar, name='bar', replacement=31, strict=self.strict, container=UseB): compare(sample_a.bar, expected=13) compare(sample_b.bar, expected=31) compare(sample_c.bar, expected=13) compare(sample_a.bar, expected=13) compare(sample_b.bar, expected=13) compare(sample_c.bar, expected=13) check_originals_not_modified() def test_method_on_subclass(self): sample_a = OriginA() sample_b = UseB() sample_c = UseC() with Replace( UseB.method, lambda self_, x: x*4, name='method', container=UseB, strict=self.strict ): compare(sample_b.method(1), expected=4) check_behaviour_is_unchanged(sample_a.method, sample_c.method) check_behaviour_is_unchanged(sample_a.method, sample_b.method, sample_c.method) check_originals_not_modified() def test_class_attribute_on_instance_of_class(self): obj = OriginA() bar = obj.bar with Replace(obj, name='bar', replacement=31, strict=self.strict): compare(obj.bar, expected=31) assert obj.bar is bar def test_instance_attribute_on_instance_of_class(self): obj = OriginA() foo = obj.foo with Replace(obj, name='foo', replacement=42, strict=self.strict): compare(obj.foo, expected=42) assert obj.foo is foo def test_method_on_instance_of_class(self): sample_a = OriginA() sample_b = UseB() sample_c = UseC() with Replace( sample_a.method, lambda x: x*4, name='method', container=sample_a, strict=self.strict ): compare(sample_a.method(1), expected=4) check_behaviour_is_unchanged(sample_b.method, sample_c.method) check_behaviour_is_unchanged(sample_a.method, sample_b.method, sample_c.method) check_originals_not_modified() def test_method_on_instance_of_subclass(self): sample_a = OriginA() sample_b = UseB() sample_c = UseC() with Replace( sample_b.method, lambda x: x*4, name='method', container=sample_b, strict=self.strict ): compare(sample_b.method(1), expected=4) check_behaviour_is_unchanged(sample_a.method, sample_c.method) check_behaviour_is_unchanged(sample_a.method, sample_b.method, sample_c.method) check_originals_not_modified() def test_invalid_attribute_on_instance_of_slotted_class(self): obj = OriginE() assert not hasattr(obj, '__dict__') replace_ = Replacer() message = "'OriginE' object has no attribute 'bad'" if PY_313_PLUS: message += " and no __dict__ for setting new attributes" with ShouldRaise(AttributeError(message)): replace_(obj, name='bad', replacement=42, strict=self.strict) def test_method_on_instance_of_slotted_subclass(self): sample_e = OriginE() sample_f = UseF() sample_g = UseG() obj = UseF() assert not hasattr(obj, '__dict__') replace_ = Replacer() with ShouldRaise(AttributeError("'UseF' object attribute 'method' is read-only")): replace_( obj.method, lambda self_, x: x*4, name='method', container=obj, strict=self.strict ) check_behaviour_is_unchanged(sample_e.method, sample_f.method, sample_g.method) check_behaviour_is_unchanged() def test_interesting_container(self): sample = OriginD({'foo': 'bar'}) with Replace(sample.foo, 'baz', name='foo', container=sample, strict=self.strict): compare(sample.foo, expected='baz') compare(sample.foo, expected='bar') assert 'foo' not in sample.__dict__ def test_mock_and_name(self): my_obj = Mock() foo = my_obj.foo assert hasattr(my_obj, 'foo') assert getattr(my_obj, 'foo', None) with Replace(my_obj, name='foo', replacement=42, strict=self.strict): compare(my_obj.foo, expected=42) with ShouldRaise(AttributeError('foo')): # Mock instances are tricky in that they have a __dict__ but # their attributes are not in it, confusing Replace, which deletes # the attribute on restore as a result: assert my_obj.foo is foo class TestReplaceOnClassUsesQualname: @staticmethod @contextmanager def check( expression: Callable[[], Any], expected_get_referrers_calls: int = 0 ) -> Iterator[Replacer]: mock_get_referrers = Mock(side_effect=get_referrers) with replace_in_module(get_referrers, mock_get_referrers, replace_module): before = expression() with Replacer() as replace: yield replace after = expression() compare(expected=before, actual=after) compare(mock_get_referrers.call_count, expected=expected_get_referrers_calls) def test_simple_method(self) -> None: expression = lambda: X().y() with self.check(expression) as replace_: replace_.on_class(X.y, lambda self_: 'mock y') compare(expression(), expected='mock y') def test_class_method(self) -> None: expression = lambda: X().aMethod() with self.check(expression) as replace_: replace_.on_class(X.aMethod, lambda cls: 'mock aMethod') compare(expression(), expected='mock aMethod') def test_static_method(self) -> None: expression = lambda: X().bMethod() with self.check(expression) as replace_: replace_.on_class(X.bMethod, lambda: 3) compare(expression(), expected=3) def test_inner_simple_method(self) -> None: expression = lambda: Outer.Inner().method() with self.check(expression) as replace_: replace_.on_class(Outer.Inner.method, lambda self_: 'mock inner simple') compare(expression(), expected='mock inner simple') def test_inner_static_method(self) -> None: expression = lambda: Outer.Inner().static() with self.check(expression) as replace_: replace_.on_class(Outer.Inner.static, lambda: 'mock inner static') compare(expression(), expected='mock inner static') def test_inner_class_method(self) -> None: expression = lambda: Outer.Inner().class_() with self.check(expression) as replace_: replace_.on_class(Outer.Inner.class_, lambda cls: 'mock inner class') compare(expression(), expected='mock inner class') def test_wrapped(self) -> None: expression = lambda: ClassWithWrappedMethod().method() with self.check(expression) as replace_: replace_.on_class(ClassWithWrappedMethod.method, lambda self: 'wrapped') compare(expression(), expected='wrapped') def test_wrapped_manually_unwrapped(self) -> None: expression = lambda: ClassWithWrappedMethod().method() with self.check(expression, expected_get_referrers_calls=1) as replace_: t = ClassWithWrappedMethod.method.__wrapped__ with ShouldRaise( AttributeError(f"could not find container of {repr(t)} using name 'method'") ): replace_.on_class(t, lambda self: 'wrapped') compare(expression(), expected='original wrapped') def test_method_on_class_from_module_no_longer_in_sys_modules(self): with temporary_module('unloaded', inspect.getsource(X)) as module: expression = lambda: UnloadedX().y() UnloadedX = module.X del sys.modules['unloaded'] with self.check(expression, expected_get_referrers_calls=2) as replace_: replace_.on_class(UnloadedX.y, lambda self: 'replaced') compare(expression(), expected='replaced') assert 'unloaded' in sys.modules, 'module was not reloaded' def test_method_on_class_whose_name_is_bound_to_an_instance(self): expression = lambda: Singleton.method() with self.check(expression, expected_get_referrers_calls=2) as replace_: replace_.on_class(type(Singleton).method, lambda self: 'replaced') compare(expression(), expected='replaced') compare(expression(), expected='original singleton') def test_method_on_class_from_before_module_reload(self): with temporary_module('reloaded', inspect.getsource(X)) as module: expression = lambda: OldX().y() OldX = module.X importlib.reload(module) with self.check(expression, expected_get_referrers_calls=2) as replace_: replace_.on_class(OldX.y, lambda self: 'replaced') compare(expression(), expected='replaced') compare(module.X().y(), expected='original y') compare(module.X().y(), expected='original y') testfixtures-12.4.0/tests/test_replacer.py000066400000000000000000000071131525773641100207340ustar00rootroot00000000000000from unittest import TestCase from testfixtures import Replacer, ShouldRaise class TestReplacer(TestCase): def test_function(self): from tests import sample1 assert sample1.z() == 'original z' def test_z(): return 'replacement z' r = Replacer() r.replace('tests.sample1.z',test_z) assert sample1.z() == 'replacement z' r.restore() assert sample1.z() == 'original z' def test_class(self): from tests import sample1 x = sample1.X() assert x.__class__.__name__ == 'X' class XReplacement(sample1.X): pass r = Replacer() r.replace('tests.sample1.X', XReplacement) x = sample1.X() assert x.__class__.__name__ == 'XReplacement' assert sample1.X().y() == 'original y' r.restore() x = sample1.X() assert x.__class__.__name__ == 'X' def test_method(self): from tests import sample1 assert sample1.X().y() == 'original y' def test_y(self): return 'replacement y' r = Replacer() r.replace('tests.sample1.X.y',test_y) assert sample1.X().y()[:38] == 'replacement y' r.restore() assert sample1.X().y() == 'original y' def test_class_method(self): from tests import sample1 c = sample1.X assert sample1.X.aMethod() is c def rMethod(cls): return cls, 1 r = Replacer() r.replace('tests.sample1.X.aMethod',rMethod) sample1.X.aMethod() assert sample1.X.aMethod() == (c, 1) r.restore() sample1.X.aMethod() assert sample1.X.aMethod() is c def test_multiple_replace(self): from tests import sample1 assert sample1.z() == 'original z' assert sample1.X().y() == 'original y' def test_y(self): return self.__class__.__name__ def test_z(): return 'replacement z' r = Replacer() r.replace('tests.sample1.z',test_z) r.replace('tests.sample1.X.y',test_y) assert sample1.z() == 'replacement z' assert sample1.X().y() == 'X' r.restore() assert sample1.z() == 'original z' assert sample1.X().y() == 'original y' def test_gotcha(self): # Just because you replace an object in one context: from tests import sample1 from tests import sample2 assert sample1.z() == 'original z' def test_z(): return 'replacement z' r = Replacer() r.replace('tests.sample1.z',test_z) assert sample1.z() == 'replacement z' # Doesn't meant that it's replaced in all contexts: assert sample2.z() == 'original z' r.restore() def test_remove_called_twice(self): from tests import sample1 def test_z(): pass r = Replacer() r.replace('tests.sample1.z',test_z) r.restore() assert sample1.z() == 'original z' r.restore() assert sample1.z() == 'original z' def test_with_statement(self): from tests import sample1 assert sample1.z() == 'original z' def test_z(): return 'replacement z' with Replacer() as r: r.replace('tests.sample1.z',test_z) assert sample1.z() == 'replacement z' assert sample1.z() == 'original z' def test_not_there(self): def test_bad(): pass with Replacer() as r: with ShouldRaise(AttributeError("Original 'bad' not found")): r.replace('tests.sample1.bad', test_bad) testfixtures-12.4.0/tests/test_reprcomparison.py000066400000000000000000000052411525773641100222020ustar00rootroot00000000000000import re from testfixtures import ReprComparison, ShouldRaise, compare class AClass: def __repr__(self): return '' class BClass: def __repr__(self): return '' def test_equal_yes_rhs(): assert AClass() == ReprComparison(AClass, '') def test_equal_yes_lhs(): assert ReprComparison(AClass, '') == AClass() def test_equal_no_repr_rhs(): assert not (AClass() == ReprComparison(AClass, '')) def test_equal_no_repr_lhs(): assert not (ReprComparison(AClass, '') == AClass()) def test_equal_no_type_rhs(): # BClass deliberately has the same repr as AClass, so only the type differs. assert repr(BClass()) == '' assert not (BClass() == ReprComparison(AClass, '')) def test_equal_no_type_lhs(): assert not (ReprComparison(AClass, '') == BClass()) def test_not_equal_yes(): assert BClass() != ReprComparison(AClass, '') def test_not_equal_no(): assert not (AClass() != ReprComparison(AClass, '')) def test_subclass_not_equal(): # The type must match exactly; a subclass is not enough. class SubClass(AClass): pass assert not (SubClass() == ReprComparison(AClass, '')) def test_in_sequence(): compare((1, 2, AClass()), expected=(1, 2, ReprComparison(AClass, ''))) def test_compare(): compare(AClass(), expected=ReprComparison(AClass, '')) def test_repr(): compare('>', actual=repr(ReprComparison(AClass, ''))) def test_str(): compare('>', actual=str(ReprComparison(AClass, ''))) def test_match_str(): compare(AClass(), expected=ReprComparison(AClass, match=r'')) def test_match_str_no_match(): assert AClass() != ReprComparison(AClass, match=r'') def test_match_str_wrong_type(): assert BClass() != ReprComparison(AClass, match=r'') def test_match_compiled_pattern(): compare(AClass(), expected=ReprComparison(AClass, match=re.compile(r''))) def test_match_repr(): compare("", actual=repr(ReprComparison(AClass, match=r''))) def test_neither_repr_nor_match() -> None: with ShouldRaise(TypeError('provide either expected or match')): ReprComparison(AClass) # type: ignore[call-overload] def test_both_repr_and_match() -> None: with ShouldRaise(TypeError('provide either expected or match')): ReprComparison(AClass, '', match=r'') # type: ignore[call-overload] testfixtures-12.4.0/tests/test_roundcomparison.py000066400000000000000000000113341525773641100223610ustar00rootroot00000000000000from decimal import Decimal from testfixtures import RoundComparison as R, compare, ShouldRaise from unittest import TestCase class Tests(TestCase): def test_equal_yes_rhs(self): self.assertTrue(0.123457 == R(0.123456, 5)) def test_equal_yes_lhs(self): self.assertTrue(R(0.123456, 5) == 0.123457) def test_equal_no_rhs(self): self.assertFalse(0.123453 == R(0.123456, 5)) def test_equal_no_lhs(self): self.assertFalse(R(0.123456, 5) == 0.123453) def test_not_equal_yes_rhs(self): self.assertFalse(0.123457 != R(0.123456, 5)) def test_not_equal_yes_lhs(self): self.assertFalse(R(0.123456, 5) != 0.123457) def test_not_equal_no_rhs(self): self.assertTrue(0.123453 != R(0.123456, 5)) def test_not_equal_no_lhs(self): self.assertTrue(R(0.123456, 5) != 0.123453) def test_equal_in_sequence_rhs(self): self.assertEqual((1, 2, 0.123457), (1, 2, R(0.123456, 5))) def test_equal_in_sequence_lhs(self): self.assertEqual((1, 2, R(0.123456, 5)), (1, 2, 0.123457)) def test_not_equal_in_sequence_rhs(self): self.assertNotEqual((1, 2, 0.1236), (1, 2, R(0.123456, 5))) def test_not_equal_in_sequence_lhs(self): self.assertNotEqual((1, 2, R(0.123456, 5)), (1, 2, 0.1236)) def test_not_numeric_rhs(self): with ShouldRaise(TypeError): 'abc' == R(0.123456, 5) def test_not_numeric_lhs(self): with ShouldRaise(TypeError): R(0.123456, 5) == 'abc' def test_repr(self): compare('', repr(R(0.123456, 5))) def test_str(self): compare('', repr(R(0.123456, 5))) def test_str_negative(self): compare('', repr(R(123456, -2))) TYPE_ERROR_DECIMAL = TypeError( "Cannot compare with " ) def test_equal_yes_decimal_to_float_rhs(self): with ShouldRaise(self.TYPE_ERROR_DECIMAL): self.assertTrue(Decimal("0.123457") == R(0.123456, 5)) def test_equal_yes_decimal_to_float_lhs(self): with ShouldRaise(self.TYPE_ERROR_DECIMAL): self.assertTrue(R(0.123456, 5) == Decimal("0.123457")) def test_equal_no_decimal_to_float_rhs(self): with ShouldRaise(self.TYPE_ERROR_DECIMAL): self.assertFalse(Decimal("0.123453") == R(0.123456, 5)) def test_equal_no_decimal_to_float_lhs(self): with ShouldRaise(self.TYPE_ERROR_DECIMAL): self.assertFalse(R(0.123456, 5) == Decimal("0.123453")) TYPE_ERROR_FLOAT = TypeError( "Cannot compare with " ) def test_equal_yes_float_to_decimal_rhs(self): with ShouldRaise(self.TYPE_ERROR_FLOAT): self.assertTrue(0.123457 == R(Decimal("0.123456"), 5)) def test_equal_yes_float_to_decimal_lhs(self): with ShouldRaise(self.TYPE_ERROR_FLOAT): self.assertTrue(R(Decimal("0.123456"), 5) == 0.123457) def test_equal_no_float_to_decimal_rhs(self): with ShouldRaise(self.TYPE_ERROR_FLOAT): self.assertFalse(0.123453 == R(Decimal("0.123456"), 5)) def test_equal_no_float_to_decimal_lhs(self): with ShouldRaise(self.TYPE_ERROR_FLOAT): self.assertFalse(R(Decimal("0.123456"), 5) == 0.123453) def test_integer_float(self): with ShouldRaise(TypeError): 1 == R(1.000001, 5) def test_float_integer(self): with ShouldRaise(TypeError): R(1.000001, 5) == 1 def test_equal_yes_integer_other_rhs(self): self.assertTrue(10 == R(11, -1)) def test_equal_yes_integer_lhs(self): self.assertTrue(R(11, -1) == 10) def test_equal_no_integer_rhs(self): self.assertFalse(10 == R(16, -1)) def test_equal_no_integer_lhs(self): self.assertFalse(R(16, -1) == 10) def test_equal_integer_zero_precision(self): self.assertTrue(1 == R(1, 0)) def test_equal_yes_negative_precision(self): self.assertTrue(149.123 == R(101.123, -2)) def test_equal_no_negative_precision(self): self.assertFalse(149.123 == R(150.001, -2)) def test_decimal_yes_rhs(self): self.assertTrue(Decimal('0.123457') == R(Decimal('0.123456'), 5)) def test_decimal_yes_lhs(self): self.assertTrue(R(Decimal('0.123456'), 5) == Decimal('0.123457')) def test_decimal_no_rhs(self): self.assertFalse(Decimal('0.123453') == R(Decimal('0.123456'), 5)) def test_decimal_no_lhs(self): self.assertFalse(R(Decimal('0.123456'), 5) == Decimal('0.123453')) testfixtures-12.4.0/tests/test_sequencecomparison.py000066400000000000000000000300701525773641100230400ustar00rootroot00000000000000from testfixtures import SequenceComparison, generator, compare, Subset, Permutation class TestSequenceComparison: def test_repr(self): compare(repr(SequenceComparison(1, 2, 3)), expected='1, 2, 3') def test_repr_long(self): actual = repr(SequenceComparison('a', 'b', 'c'*1000))[:60] compare(actual, expected='\n' "\n'a',\n 'b'") def test_repr_after_equal(self): s = SequenceComparison(1, 2, 3) assert s == (1, 2, 3) compare(repr(s), expected='1, 2, 3') def test_equal_list(self): s = SequenceComparison(1, 2, 3) assert s == [1, 2, 3] def test_equal_tuple(self): s = SequenceComparison(1, 2, 3) assert s == (1, 2, 3) def test_equal_nested_unhashable_unordered(self): s = SequenceComparison({1}, {2}, {2}, ordered=False) assert s == ({2}, {1}, {2}) def test_equal_nested_unhashable_unordered_partial(self): s = SequenceComparison({1}, {2}, {2}, ordered=False, partial=True) assert s == ({2}, {1}, {2}, {3}) def test_equal_generator(self): s = SequenceComparison(1, 2, 3) assert s == generator(1, 2, 3) def test_equal_unordered(self): s = SequenceComparison(1, 2, 3, ordered=False) assert s == (1, 3, 2) def test_equal_partial_unordered(self): s = SequenceComparison(1, 2, ordered=False, partial=True) assert s == (2, 1, 4) def test_equal_partial_ordered(self): s = SequenceComparison(1, 2, 1, ordered=True, partial=True) assert s == (1, 1, 2, 1) def test_equal_ordered_duplicates(self): s = SequenceComparison(1, 2, 2, ordered=True, partial=True) assert s == (1, 2, 2, 3) def test_unequal_bad_type(self): s = SequenceComparison(1, 3) assert s != object() compare(repr(s), expected="bad type") def test_unequal_list(self): s = SequenceComparison(1, 2, 3) assert s != (1, 2, 4) compare(repr(s), expected=( '\n' '\n' 'same:\n' '[1, 2]\n\n' 'expected:\n' '[3]\n\n' 'actual:\n' '[4]\n' '' )) def test_unequal_same_but_all_wrong_order(self): s = SequenceComparison(1, 2, 3) assert s != (3, 1, 2) compare(repr(s), expected=( '\n' '\n' 'same:\n' '[]\n\n' 'expected:\n' '[1, 2, 3]\n\n' 'actual:\n' '[3, 1, 2]\n' '' )) def test_unequal_prefix_match_but_partial_false(self): s = SequenceComparison(1, 2, partial=False) assert s != (1, 2, 4) compare(repr(s), expected=( '\n' '\n' 'same:\n' '[1, 2]\n\n' 'expected:\n' '[]\n\n' 'actual:\n' '[4]\n' '' )) def test_unequal_partial_ordered(self): s = SequenceComparison(1, 3, 5, ordered=True, partial=True, recursive=False) assert s != (1, 2, 3, 4, 0) compare(repr(s), expected=( '\n' '\n' 'ignored:\n' '[2, 4, 0]\n\n' 'same:\n' '[1, 3]\n\n' 'expected:\n' '[5]\n\n' 'actual:\n' '[]\n' '' )) def test_unequal_partial_ordered_recursive(self): s = SequenceComparison(1, 3, 5, ordered=True, partial=True, recursive=True) assert s != (1, 2, 3, 4, 0) compare(repr(s), expected=( '\n' '\n' 'ignored:\n' '[4, 0]\n\n' 'same:\n' '[1]\n\n' 'expected:\n' '[3, 5]\n\n' 'actual:\n' '[2, 3]\n' '' )) def test_unequal_partial_ordered_only_one_ignored_recursive(self): s = SequenceComparison(1, 2, ordered=True, partial=True, recursive=True) assert s != (2, 1, 4) compare(repr(s), expected=( '\n' '\n' 'ignored:\n' '[4]\n\n' 'same:\n' '[]\n\n' 'expected:\n' '[1, 2]\n\n' 'actual:\n' '[2, 1]\n' '' )) def test_unequal_full_ordered(self): s = SequenceComparison(1, 3, 5, ordered=True, partial=False) assert s != (0, 1, 2, 3, 4) compare(repr(s), expected=( '\n' '\n' 'same:\n' '[]\n\n' 'expected:\n' '[1, 3, 5]\n\n' 'actual:\n' '[0, 1, 2, 3, 4]\n' '' )) def test_unequal_partial_ordered_with_prefix(self): s = SequenceComparison('a', 'b', 1, 2, ordered=True, partial=True) assert s != ('a', 'b', 2, 1, 4) compare(repr(s), expected=( '\n' '\n' 'ignored:\n' '[2, 4]\n\n' 'same:\n' "['a', 'b', 1]\n\n" 'expected:\n' '[2]\n\n' 'actual:\n' '[]\n' '' )) def test_unequal_partial_unordered(self): s = SequenceComparison(1, 3, ordered=False, partial=True) assert s != (2, 1, 4) compare(repr(s), expected=( '\n' '\n' 'ignored:\n' '[2, 4]\n\n' 'same:\n' "[1]\n\n" 'in expected but not actual:\n' "[3]\n" '' )) def test_unequal_unordered_duplicates(self): s = SequenceComparison(2, 1, 2, ordered=False, partial=False) assert s != (1, 2) compare(repr(s), expected=( '\n' '\n' 'same:\n' "[2, 1]\n\n" 'in expected but not actual:\n' "[2]\n" '' )) def test_unequal_partial_unordered_duplicates(self): s = SequenceComparison(1, 2, 2, ordered=False, partial=True) assert s != (1, 2) compare(repr(s), expected=( '\n' '\n' 'same:\n' "[1, 2]\n\n" 'in expected but not actual:\n' "[2]\n" '' )) def test_unequal_partial_ordered_duplicates(self): s = SequenceComparison(1, 2, 2, partial=True) assert s != (1, 2) compare(repr(s), expected=( '\n' '\n' 'same:\n' "[1, 2]\n\n" 'expected:\n' '[2]\n\n' 'actual:\n' '[]\n' '' )) def test_unequal_generator(self): s = SequenceComparison(1, 3) assert s != generator(1, 2) compare(repr(s), expected=( '\n' '\n' 'same:\n' "[1]\n\n" 'expected:\n' '[3]\n\n' 'actual:\n' '[2]\n' '' )) def test_unequal_nested(self): s = SequenceComparison({1: 'a', 2: 'b'}, [1, 2], recursive=False) assert s != ({2: 'b', 3: 'c'}, [1, 3]) compare(repr(s), expected=( '\n' '\n' 'same:\n' "[]\n\n" 'expected:\n' "[{1: 'a', 2: 'b'}, [1, 2]]\n\n" 'actual:\n' "[{2: 'b', 3: 'c'}, [1, 3]]\n" '' )) def test_unequal_nested_recursive(self): s = SequenceComparison({1: 'a', 2: 'b'}, [1, 2], recursive=True) assert s != ({2: 'b', 3: 'c'}, [1, 3]) compare(repr(s), expected=( '\n' '\n' 'same:\n' "[]\n\n" 'expected:\n' "[{1: 'a', 2: 'b'}, [1, 2]]\n\n" 'actual:\n' "[{2: 'b', 3: 'c'}, [1, 3]]\n\n" "While comparing [0]: dict not as expected:\n\n" "same:\n" "[2]\n\n" "in expected but not actual:\n" "1: 'a'\n\n" "in actual but not expected:\n" "3: 'c'\n" '' )) def test_unequal_nested_unhashable_unordered(self): s = SequenceComparison({2: True}, {1: True}, {2: True}, {3: True}, ordered=False) assert s != ({1: True}, {2: True}, {4: True}) compare(repr(s), expected=( '\n' '\n' 'same:\n' "[{2: True}, {1: True}]\n\n" 'in expected but not actual:\n' "[{2: True}, {3: True}]\n\n" 'in actual but not expected:\n' "[{4: True}]\n" '' )) def test_unequal_nested_unhashable_unordered_partial(self): s = SequenceComparison({2: True}, {1: True}, {2: True}, {3: True}, ordered=False, partial=True) assert s != ({1: True}, {2: True}, {4: True}) compare(repr(s), expected=( '\n' '\n' 'ignored:\n' "[{4: True}]\n\n" 'same:\n' "[{2: True}, {1: True}]\n\n" 'in expected but not actual:\n' "[{2: True}, {3: True}]\n" '' )) def test_unequal_wrong_order(self): s = SequenceComparison(1, 2, 3) assert s != (1, 3, 2) compare(repr(s), expected=( '\n' '\n' 'same:\n' "[1]\n\n" 'expected:\n' '[2, 3]\n\n' 'actual:\n' '[3, 2]\n' '' )) def test_partial_nothing_specified(self): s = SequenceComparison(partial=True) assert s == {} def test_partial_wrong_type(self): s = SequenceComparison(partial=True) assert s != object() class TestSubset: def test_equal(self): assert Subset({1}, {2}) == [{1}, {2}, {3}] def test_unequal(self): assert Subset({1}, {2}) != [{1}] class TestPermutation: def test_equal(self): assert Permutation({1}, {2}) == [{2}, {1}] def test_unequal(self): assert Permutation({1}) != [{2}, {1}] testfixtures-12.4.0/tests/test_should_raise.py000066400000000000000000000404071525773641100216230ustar00rootroot00000000000000import re from textwrap import dedent from testfixtures import ( Comparison as C, ShouldRaise, should_raise, compare, repr_like, str_like ) from unittest import TestCase from testfixtures.shouldraise import ShouldAssert, NoException class TestShouldAssert: def test_no_exception(self) -> None: try: with ShouldAssert('foo'): pass except AssertionError as e: assert str(e) == "Expected AssertionError('foo'), None raised!" def test_wrong_exception(self) -> None: try: with ShouldAssert('foo'): raise KeyError() except KeyError: pass def test_wrong_text(self) -> None: try: with ShouldAssert('foo'): assert False, 'bar' except AssertionError as e: assert str(e) == dedent("""\ --- expected +++ actual @@ -1 +1,2 @@ -foo +bar +assert False""") def test_show_whitespace(self) -> None: try: with ShouldAssert('foo ', show_whitespace=True): assert False, ' foo' except AssertionError as e: assert str(e) == dedent( """\ --- expected +++ actual @@ -1 +1,2 @@ -'foo ' +' foo\\n' +'assert False'""" ) class TestShouldRaise(TestCase): def test_no_params(self) -> None: def to_test() -> None: raise ValueError('wrong value supplied') should_raise(ValueError('wrong value supplied'))(to_test)() def test_no_exception(self) -> None: def to_test() -> None: pass with ShouldAssert('ValueError() (expected) != None (raised)'): should_raise(ValueError())(to_test)() def test_wrong_exception(self) -> None: def to_test() -> None: raise ValueError('bar') expected = "not equal:\nValueError('foo') (expected)\nValueError('bar') (raised)" with ShouldAssert(expected): should_raise(ValueError('foo'))(to_test)() def test_only_exception_class(self) -> None: def to_test() -> None: raise ValueError('bar') should_raise(ValueError)(to_test)() def test_wrong_exception_class(self) -> None: expected_exception = ValueError('bar') def to_test() -> None: raise expected_exception try: should_raise(KeyError)(to_test)() except ValueError as actual_exception: assert actual_exception is expected_exception else: # pragma: no cover self.fail(('Wrong exception raised')) def test_wrong_exception_type(self) -> None: expected_exception = ValueError('bar') def to_test() -> None: raise expected_exception try: should_raise(KeyError('foo'))(to_test)() except ValueError as actual_exception: assert actual_exception is expected_exception else: # pragma: no cover self.fail(('Wrong exception raised')) def test_no_supplied_or_raised(self) -> None: # effectively we're saying "something should be raised!" # but we want to inspect s.raised rather than making # an up-front assertion def to_test() -> None: pass with ShouldAssert("No exception raised!"): should_raise()(to_test)() def test_args(self) -> None: def to_test(*args: object) -> None: raise ValueError('%s' % repr(args)) should_raise(ValueError('(1,)'))(to_test)(1) def test_kw_to_args(self) -> None: def to_test(x: object) -> None: raise ValueError('%s' % x) should_raise(ValueError('1'))(to_test)(x=1) def test_kw(self) -> None: def to_test(**kw: object) -> None: raise ValueError('%r' % kw) should_raise(ValueError("{'x': 1}"))(to_test)(x=1) def test_both(self) -> None: def to_test(*args: object, **kw: object) -> None: raise ValueError('%r %r' % (args, kw)) should_raise(ValueError("(1,) {'x': 2}"))(to_test)(1, x=2) def test_method_args(self) -> None: class X: def to_test(self, *args: object) -> None: self.args = args raise ValueError() x = X() should_raise(ValueError)(x.to_test)(1, 2, 3) self.assertEqual(x.args, (1, 2, 3)) def test_method_kw(self) -> None: class X: def to_test(self, **kw: object) -> None: self.kw = kw raise ValueError() x = X() should_raise(ValueError)(x.to_test)(x=1, y=2) self.assertEqual(x.kw, {'x': 1, 'y': 2}) def test_method_both(self) -> None: class X: def to_test(self, *args: object, **kw: object) -> None: self.args = args self.kw = kw raise ValueError() x = X() should_raise(ValueError)(x.to_test)(1, y=2) self.assertEqual(x.args, (1, )) self.assertEqual(x.kw, {'y': 2}) def test_class_class(self) -> None: class Test: def __init__(self, x: object) -> None: # The TypeError is raised due to the mis-matched parameters # so the pass never gets executed pass # pragma: no cover should_raise(TypeError)(Test)() # type: ignore[call-arg] def test_raised(self) -> None: # If you're strict on typing, then if you want this pattern, you'll # need to specify the type, so might as well use ShouldRaise(ValueError)! with ShouldRaise[ValueError]() as s: raise ValueError('wrong value supplied') self.assertEqual(s.raised, C(ValueError('wrong value supplied'))) def test_catch_baseexception_1(self) -> None: with ShouldRaise(SystemExit): raise SystemExit() def test_catch_baseexception_2(self) -> None: with ShouldRaise(KeyboardInterrupt): raise KeyboardInterrupt() def test_with_exception_class_supplied(self) -> None: with ShouldRaise(ValueError): raise ValueError('foo bar') def test_with_exception_supplied(self) -> None: with ShouldRaise(ValueError('foo bar')): raise ValueError('foo bar') def test_with_exception_supplied_wrong_args(self) -> None: expected = "not equal:\nValueError('foo') (expected)\nValueError('bar') (raised)" with ShouldAssert(expected): with ShouldRaise(ValueError('foo')): raise ValueError('bar') def test_neither_supplied(self) -> None: with ShouldRaise(): raise ValueError('foo bar') def test_with_no_exception_when_expected(self) -> None: expected = "not equal:\nValueError('foo') (expected)\nNone (raised)" with ShouldAssert(expected): with ShouldRaise(ValueError('foo')): pass def test_with_no_exception_when_expected_by_type(self) -> None: with ShouldAssert("not equal:\n (expected)\nNone (raised)"): with ShouldRaise(ValueError): pass def test_with_no_exception_when_neither_expected(self) -> None: with ShouldAssert("No exception raised!"): with ShouldRaise(): pass def test_with_getting_raised_exception(self) -> None: e = ValueError('foo bar') # If you're not strictly type checking, then you don't need to specify a type. # Using type ignore here just to check this works: with ShouldRaise() as s: # type: ignore[var-annotated] raise e assert e is s.raised def test_import_errors_1(self) -> None: with ShouldRaise(ModuleNotFoundError("No module named 'textfixtures'")): import textfixtures.foo.bar # type: ignore[import-not-found] def test_import_errors_2(self) -> None: with ShouldRaise(ImportError('X')): raise ImportError('X') def test_custom_exception(self) -> None: class FileTypeError(Exception): def __init__(self, value: object) -> None: self.value = value with ShouldRaise(FileTypeError('X')): raise FileTypeError('X') def test_decorator_usage(self) -> None: @should_raise(ValueError('bad')) def to_test() -> None: raise ValueError('bad') to_test() def test_unless_false_okay(self) -> None: with ShouldRaise(unless=False): raise AttributeError() def test_unless_false_bad(self) -> None: with ShouldAssert("No exception raised!"): with ShouldRaise(unless=False): pass def test_unless_true_okay(self) -> None: # I don't know why anyone would do this, but it's what mypy recommends: s: ShouldRaise[NoException] # If you're strict typing, and this is intended rather than a test failre, # then you should probably do `with ShouldRaise[NoException](unless=True):` instead. with ShouldRaise(unless=True) as s: pass # This documents the value of .raised in the rare care where it isn't # the exception raised within the context manager: assert isinstance(s.raised, NoException) def test_unless_true_not_okay(self) -> None: expected_exception = AttributeError('foo') try: with ShouldRaise(unless=True): raise expected_exception except AttributeError as actual_exception: assert actual_exception is expected_exception else: # pragma: no cover self.fail('Wrong exception raised') def test_unless_decorator_usage(self) -> None: @should_raise(unless=True) def to_test() -> None: pass to_test() def test_none_means_no_exception_expected(self) -> None: with ShouldRaise(None): pass def test_none_means_no_exception_expected_but_one_raised(self) -> None: expected_exception = ValueError('oops') try: with ShouldRaise(None): raise expected_exception except ValueError as actual_exception: assert actual_exception is expected_exception else: # pragma: no cover self.fail('Exception should have propagated') def test_identical_reprs(self) -> None: class AnnoyingException(Exception): def __init__(self, **kw: object) -> None: self.other = kw.get('other') with ShouldAssert( "AnnoyingException not as expected:\n\n" 'attributes same:\n' "['args']\n\n" "attributes differ:\n" "'other': 'bar' (expected) != 'baz' (raised)\n\n" "While comparing .other: 'bar' (expected) != 'baz' (raised)" ): with ShouldRaise(AnnoyingException(other='bar')): raise AnnoyingException(other='baz') def test_identical_reprs_but_args_different(self) -> None: class MessageError(Exception): def __init__(self, message: object, type: object = None) -> None: self.message = message self.type = type def __repr__(self) -> str: return 'MessageError({!r}, {!r})'.format(self.message, self.type) with ShouldAssert( "MessageError not as expected:\n\n" 'attributes same:\n' "['message', 'type']\n\n" "attributes differ:\n" "'args': ('foo',) (expected) != ('foo', None) (raised)\n\n" "While comparing .args: sequence not as expected:\n\n" "same:\n" "('foo',)\n\n" "expected:\n" "()\n\n" "raised:\n" "(None,)" ): with ShouldRaise(MessageError('foo')): raise MessageError('foo', None) def test_exception_group_okay(self) -> None: with ShouldRaise(ExceptionGroup('foo', [Exception('bar')])): raise ExceptionGroup('foo', [Exception('bar')]) def test_exception_group_different(self) -> None: with ShouldAssert( "exception group not as expected:\n\n" "While comparing msg: 'fob' (expected) != 'foo' (raised)\n\n" "While comparing excs: sequence not as expected:\n\n" "same:\n" "[]\n\n" "expected:\n" "[Exception('baz')]\n\n" "raised:\n" "[Exception('bar')]\n\n" "While comparing excs[0]: not equal:\n" "Exception('baz') (expected)\n" "Exception('bar') (raised)" ): with ShouldRaise(ExceptionGroup('fob', [Exception('baz')])): raise ExceptionGroup('foo', [Exception('bar')]) def test_check_notes_on_raised_exception(self) -> None: exception = ValueError('foo') exception.add_note('bar') with ShouldRaise(ValueError) as s: raise exception assert s.raised.__notes__ == ['bar'] def test_typing_on_attributes(self) -> None: class MyException(Exception): def __init__(self, x: int) -> None: self.x = x with ShouldRaise(MyException(1)) as r: raise MyException(1) compare(r.raised.x, expected= 1) class TestMatch: def test_type_passes(self) -> None: with ShouldRaise(ValueError, match=r'bad'): raise ValueError('bad value') def test_type_fails(self) -> None: with ShouldAssert("Pattern did not match:\n'good' (expected)\n'bad value' (actual)"): with ShouldRaise(ValueError, match=r'good'): raise ValueError('bad value') def test_no_type_passes(self) -> None: with ShouldRaise(match=r'bad'): raise ValueError('bad value') def test_no_type_fails(self) -> None: with ShouldAssert("Pattern did not match:\n'good' (expected)\n'bad value' (actual)"): with ShouldRaise(match=r'good'): raise ValueError('bad value') def test_instance_raises(self) -> None: with ShouldRaise(TypeError('match can only be used when an exception type is provided')): ShouldRaise(ValueError('x'), match=r'x') # type: ignore[call-overload] def test_none_raises(self) -> None: with ShouldRaise(TypeError('match can only be used when an exception type is provided')): ShouldRaise(None, match=r'x') # type: ignore[call-overload] def test_pattern_object(self) -> None: with ShouldRaise(ValueError, match=re.compile(r'bad')): raise ValueError('bad value') class TestRenderingComparison: def test_repr_like_passes(self) -> None: with ShouldRaise(repr_like(KeyError, "KeyError('foo')")): raise KeyError('foo') def test_str_like_passes(self) -> None: with ShouldRaise(str_like(ValueError, 'boom')): raise ValueError('boom') def test_repr_like_match_regex(self) -> None: with ShouldRaise(repr_like(KeyError, match=r"KeyError\('\w+'\)")): raise KeyError('foo') def test_str_like_match_regex(self) -> None: with ShouldRaise(str_like(ValueError, match=r'^bo')): raise ValueError('boom') def test_repr_like_wrong_value_fails(self) -> None: with ShouldAssert( "not equal:\n" " (expected)\n" "KeyError('foo') (raised)" ): with ShouldRaise(repr_like(KeyError, "KeyError('other')")): raise KeyError('foo') def test_str_like_wrong_value_fails(self) -> None: with ShouldAssert( "not equal:\n" " (expected)\n" "ValueError('boom') (raised)" ): with ShouldRaise(str_like(ValueError, 'nope')): raise ValueError('boom') def test_wrong_type_propagates(self) -> None: with ShouldRaise(ValueError('boom')): with ShouldRaise(repr_like(KeyError, "KeyError('foo')")): raise ValueError('boom') def test_via_decorator(self) -> None: @should_raise(repr_like(KeyError, "KeyError('foo')")) def raiser() -> None: raise KeyError('foo') raiser() testfixtures-12.4.0/tests/test_shouldwarn.py000066400000000000000000000145431525773641100213320ustar00rootroot00000000000000from unittest import TestCase import warnings from testfixtures import ( ShouldWarn, compare, ShouldRaise, ShouldNotWarn, Comparison as C ) from testfixtures.compat import PY_315_PLUS from testfixtures.shouldraise import ShouldAssert class ShouldWarnTests(TestCase): def test_warn_expected(self): with warnings.catch_warnings(record=True) as backstop: with ShouldWarn(UserWarning('foo')): warnings.warn('foo') compare(len(backstop), expected=0) def test_warn_not_expected(self): with ShouldAssert( "not equal:\n" "\n\n" "same:\n[]\n\n" "expected:\n[]\n\n" "actual:\n[UserWarning('foo')]\n" " (expected)\n" "[UserWarning('foo')] (actual)" ): with warnings.catch_warnings(record=True) as backstop: with ShouldNotWarn(): warnings.warn('foo') compare(len(backstop), expected=0) def test_no_warn_expected(self): with ShouldNotWarn(): pass def test_no_warn_not_expected(self): with ShouldAssert( "not equal:\n" "\n\n" "same:\n[]\n\n" "expected:\n[args: ('foo',)]" "\n\nactual:\n[]\n" " (expected)\n" "[] (actual)" ): with ShouldWarn(UserWarning('foo')): pass def test_filters_removed(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") with ShouldWarn(UserWarning("foo")): warnings.warn('foo') def test_multiple_warnings(self): with ShouldRaise(AssertionError) as s: with ShouldWarn(UserWarning('foo')): warnings.warn('foo') warnings.warn('bar') content = str(s.raised) self.assertTrue('foo' in content) self.assertTrue('bar' in content) def test_multiple_warnings_ordered(self): with warnings.catch_warnings(record=True) as backstop: with ShouldWarn(UserWarning('foo'), UserWarning('bar')): warnings.warn('foo') warnings.warn('bar') compare(len(backstop), expected=0) def test_multiple_warnings_wrong_order(self): with ShouldRaise(AssertionError) as s: with ShouldWarn(UserWarning('foo'), UserWarning('bar')): warnings.warn('bar') warnings.warn('foo') content = str(s.raised) self.assertTrue('foo' in content) self.assertTrue('bar' in content) def test_multiple_warnings_ignore_order(self): with warnings.catch_warnings(record=True) as backstop: with ShouldWarn(UserWarning('foo'), UserWarning('bar'), order_matters=False): warnings.warn('bar') warnings.warn('foo') compare(len(backstop), expected=0) def test_minimal_ok(self): with ShouldWarn(UserWarning): warnings.warn('foo') def test_minimal_bad(self): with ShouldAssert( "not equal:\n" "\n\n" "same:\n[]\n\n" "expected:\n" "[wrong type: builtins.UserWarning]\n\n" "actual:\n[UserWarning('foo')]\n" " (expected)\n" "[UserWarning('foo')] (actual)" ): with ShouldWarn(DeprecationWarning): warnings.warn('foo') def test_maximal_ok(self): with ShouldWarn(DeprecationWarning('foo')): warnings.warn_explicit( 'foo', DeprecationWarning, 'bar.py', 42, 'bar_module' ) def test_maximal_bad(self): with ShouldAssert( "not equal:\n" "\n\n" "same:\n[]\n\n" "expected:\n[\n" "\n" "attributes differ:\n" "'args': ('bar',) (Comparison) != ('foo',) (actual)\n" "]\n\n" "actual:\n[DeprecationWarning('foo')]\n" " (expected)\n" "[DeprecationWarning('foo')] (actual)" ): with ShouldWarn(DeprecationWarning('bar')): warnings.warn_explicit( 'foo', DeprecationWarning, 'bar.py', 42, 'bar_module' ) def test_maximal_explore(self): with ShouldWarn() as recorded: warnings.warn_explicit( 'foo', DeprecationWarning, 'bar.py', 42, 'bar_module' ) compare(len(recorded), expected=1) expected_attrs = dict( _category_name='DeprecationWarning', category=DeprecationWarning, file=None, filename='bar.py', line=None, lineno=42, message=C(DeprecationWarning('foo')), source=None ) if PY_315_PLUS: expected_attrs['module'] = 'bar_module' compare(expected=C(warnings.WarningMessage, **expected_attrs), actual=recorded[0]) def test_filter_present(self): with ShouldWarn(DeprecationWarning, message="This function is deprecated."): warnings.warn("This utility is deprecated.", DeprecationWarning) warnings.warn("This function is deprecated.", DeprecationWarning) def test_filter_missing(self): with ShouldAssert( "not equal:\n" "\n\n" "same:\n[]\n\n" "expected:\n[]\n\n" "actual:\n[]\n" " (expected)\n" "[] (actual)" ): with ShouldWarn(DeprecationWarning, message="This function is deprecated."): warnings.warn("This utility is deprecated.", DeprecationWarning) testfixtures-12.4.0/tests/test_strcomparison.py000066400000000000000000000050621525773641100220430ustar00rootroot00000000000000import re from testfixtures import StrComparison, ShouldRaise, compare class AClass: def __str__(self): return 'an A' class BClass: def __str__(self): return 'an A' def test_equal_yes_rhs(): assert AClass() == StrComparison(AClass, 'an A') def test_equal_yes_lhs(): assert StrComparison(AClass, 'an A') == AClass() def test_equal_no_str_rhs(): assert not (AClass() == StrComparison(AClass, 'other')) def test_equal_no_str_lhs(): assert not (StrComparison(AClass, 'other') == AClass()) def test_equal_no_type_rhs(): # BClass deliberately has the same str as AClass, so only the type differs. assert str(BClass()) == 'an A' assert not (BClass() == StrComparison(AClass, 'an A')) def test_equal_no_type_lhs(): assert not (StrComparison(AClass, 'an A') == BClass()) def test_not_equal_yes(): assert BClass() != StrComparison(AClass, 'an A') def test_not_equal_no(): assert not (AClass() != StrComparison(AClass, 'an A')) def test_subclass_not_equal(): # The type must match exactly; a subclass is not enough. class SubClass(AClass): pass assert not (SubClass() == StrComparison(AClass, 'an A')) def test_in_sequence(): compare((1, 2, AClass()), expected=(1, 2, StrComparison(AClass, 'an A'))) def test_compare(): compare(AClass(), expected=StrComparison(AClass, 'an A')) def test_repr(): compare('', actual=repr(StrComparison(AClass, 'an A'))) def test_str(): compare('', actual=str(StrComparison(AClass, 'an A'))) def test_match_str(): compare(AClass(), expected=StrComparison(AClass, match=r'an \w')) def test_match_str_no_match(): assert AClass() != StrComparison(AClass, match=r'a B') def test_match_str_wrong_type(): assert BClass() != StrComparison(AClass, match=r'an \w') def test_match_compiled_pattern(): compare(AClass(), expected=StrComparison(AClass, match=re.compile(r'an \w'))) def test_match_repr(): compare("", actual=repr(StrComparison(AClass, match=r'an .'))) def test_neither_str_nor_match() -> None: with ShouldRaise(TypeError('provide either expected or match')): StrComparison(AClass) # type: ignore[call-overload] def test_both_str_and_match() -> None: with ShouldRaise(TypeError('provide either expected or match')): StrComparison(AClass, 'an A', match=r'an \w') # type: ignore[call-overload] testfixtures-12.4.0/tests/test_stringcomparison.py000066400000000000000000000045621525773641100225450ustar00rootroot00000000000000import re from testfixtures import StringComparison, TextComparison, compare from unittest import TestCase class Tests(TestCase): def test_old_name_is_alias(self): assert StringComparison is TextComparison def test_equal_yes(self): self.assertTrue('on 40220' == StringComparison(r'on \d+')) def test_equal_no(self): self.assertFalse('on xxx' == StringComparison(r'on \d+')) def test_not_equal_yes(self): self.assertFalse('on 40220' != StringComparison(r'on \d+')) def test_not_equal_no(self): self.assertTrue('on xxx' != StringComparison(r'on \d+')) def test_comp_in_sequence(self): self.assertTrue((1, 2, 'on 40220') == (1, 2, StringComparison(r'on \d+'))) def test_not_string(self): self.assertFalse(40220 == StringComparison(r'on \d+')) def test_not_string_returns_not_implemented(self): assert StringComparison(r'x').__eq__(40220) is NotImplemented def test_not_string_reflected_equality(self): class MatchesAnything: def __eq__(self, other): return True def __ne__(self, other): return False other = MatchesAnything() assert StringComparison(r'x') == other assert other == StringComparison(r'x') assert not (StringComparison(r'x') != other) assert not (other != StringComparison(r'x')) def test_repr(self): compare('', repr(StringComparison(r'on \d+'))) def test_str(self): compare('', str(StringComparison(r'on \d+'))) def test_sort(self): a = StringComparison('a') b = StringComparison('b') c = StringComparison('c') compare(sorted(('d', c, 'e', a, 'a1', b)), expected=[a, 'a1', b, c, 'd', 'e']) def test_flags_argument(self): compare(StringComparison(".*bar", re.DOTALL), actual="foo\nbar") def test_flags_parameter(self): compare(StringComparison(".*bar", flags=re.DOTALL), actual="foo\nbar") def test_flags_names(self): compare(StringComparison(".*BaR", dotall=True, ignorecase=True), actual="foo\nbar") def test_compiled_pattern(self): compare(StringComparison(re.compile(r'on \d+')), actual='on 40220') def test_compiled_pattern_with_flags(self): compare(StringComparison(re.compile(".*BaR", re.DOTALL | re.IGNORECASE)), actual="foo\nbar") testfixtures-12.4.0/tests/test_structlog.py000066400000000000000000000236351525773641100211740ustar00rootroot00000000000000import logging import pytest pytest.importorskip("structlog") import structlog from structlog.contextvars import bind_contextvars, bound_contextvars, clear_contextvars from testfixtures import ( LogCapture, ShouldNotWarn, ShouldRaise, ShouldWarn, compare, ) from testfixtures.logcapture import LoggingSource from testfixtures.mock import Mock from testfixtures.structlog import StructlogSource, level_name, raw @pytest.fixture(autouse=True) def clean_structlog(): structlog.reset_defaults() clear_contextvars() yield structlog.reset_defaults() clear_contextvars() class TestLogCapture: def test_simple(self): with LogCapture(StructlogSource()) as log: structlog.get_logger().info('hello world') log.check(('INFO', 'hello world')) def test_fields_single(self): with LogCapture(StructlogSource(('event',))) as log: structlog.get_logger().info('just the event') log.check('just the event') def test_attributes_bare_string(self): with LogCapture(StructlogSource('event')) as log: structlog.get_logger().info('just the event') log.check('just the event') def test_fields_callable(self): logger = structlog.get_logger() with LogCapture(StructlogSource((lambda d: d['level'].upper(), 'event'))) as log: logger.debug('msg1') logger.info('msg2') log.check( ('DEBUG', 'msg1'), ('INFO', 'msg2'), ) def test_attributes_single_callable(self): logger = structlog.get_logger() with LogCapture(StructlogSource(raw)) as log: logger.info('hello') logger.error('boom') log.check( {'level': 'info', 'event': 'hello'}, {'level': 'error', 'event': 'boom'}, ) def test_exception(self): with LogCapture(StructlogSource()) as log: try: raise ValueError('boom') except ValueError: structlog.get_logger().exception('oh no') log.check(('ERROR', 'oh no')) log.check_exception_str('boom') with ShouldRaise(ValueError('boom')): log.raise_first_exception() compare(log.entries[0].exception, expected=ValueError('boom')) def test_level_filtering(self): with LogCapture(StructlogSource(level='WARNING')) as log: structlog.get_logger().info('ignored') structlog.get_logger().warning('captured') log.check(('WARNING', 'captured')) def test_check_level(self): logger = structlog.get_logger() with LogCapture(StructlogSource()) as log: logger.debug('noisy') logger.info('useful') logger.error('boom') log.check( ('INFO', 'useful'), ('ERROR', 'boom'), level=logging.INFO, ) def test_check_predicate_on_raw(self): logger = structlog.get_logger() with LogCapture(StructlogSource()) as log: logger.info('request handled', path='/api/users') logger.info('request handled', path='/health') log.check( ('INFO', 'request handled'), predicate=lambda entry: entry.raw.get('path') != '/health', ) def test_broken_attribute_method(self): def bad(event_dict): raise ValueError('boom') with LogCapture(StructlogSource((bad,))) as log: with ShouldRaise(ValueError('boom')): structlog.get_logger().info('task logging') log.check() def test_bad_level_name(self): with ShouldRaise(ValueError("Unknown structlog level name: 'wut?'")): with LogCapture(StructlogSource(level='wut?')): ... def test_bound_contextvars(self): with LogCapture(StructlogSource(raw)) as log: with bound_contextvars(task=1234): structlog.get_logger().info('task logging') log.check({'event': 'task logging', 'level': 'info', 'task': 1234}) def test_bind_contextvars(self): bind_contextvars(request_id='abc123') logger = structlog.get_logger() with LogCapture(StructlogSource(raw)) as log: logger.info('handling') logger.info('done') log.check( {'event': 'handling', 'level': 'info', 'request_id': 'abc123'}, {'event': 'done', 'level': 'info', 'request_id': 'abc123'}, ) def test_bind(self): logger = structlog.get_logger().bind(ip='192.168.0.1', user='someone') with LogCapture(StructlogSource((level_name, 'event', 'ip', 'user', 'other'))) as log: logger.info('Contextualize your logger easily') logger.bind(other='foo').info('Inline binding of extra attribute') log.check( ('INFO', 'Contextualize your logger easily', '192.168.0.1', 'someone', None), ('INFO', 'Inline binding of extra attribute', '192.168.0.1', 'someone', 'foo'), ) def test_processor_chain_restored_after_capture(self) -> None: rendered: list[str] = [] def render(logger, method_name, event_dict): rendered.append(text := f"{event_dict['level']}: {event_dict['event']}") return text structlog.configure(processors=[structlog.stdlib.add_log_level, render]) logger = structlog.get_logger() logger.info('before capture') with LogCapture(StructlogSource()) as log: logger.info('during capture') logger.info('after capture') log.check(('INFO', 'during capture')) compare(rendered, expected=['info: before capture', 'info: after capture']) def test_renderer_in_processors(self): with LogCapture( StructlogSource( attributes=raw, processors=[ structlog.contextvars.merge_contextvars, structlog.processors.KeyValueRenderer(sort_keys=True), ], ) ) as log: structlog.get_logger().bind(user='alice').info('hi') log.check("event='hi' user='alice'") def test_processors_supplied_excluding_loglevel(self): logger = structlog.get_logger().bind(user='alice') with LogCapture( StructlogSource( attributes=raw, processors=[structlog.processors.KeyValueRenderer(sort_keys=True)], level='WARNING', ), ) as log: logger.info('hi from info') logger.warning('hi from warning') # note that filtering still works: log.check("event='hi from warning' user='alice'") def test_combined_logging(self): with LogCapture(StructlogSource(), LoggingSource()) as log: for level in 'info', 'warning': getattr(logging, level)('from stdlib') getattr(structlog.get_logger(), level)('from structlog') log.check( ('INFO', 'from stdlib'), ('INFO', 'from structlog'), ('WARNING', 'from stdlib'), ('WARNING', 'from structlog'), ) def test_displaced_by_reset_defaults_warns(self): with ShouldWarn( UserWarning( 'StructlogSource was displaced from structlog configuration before uninstall.\n' 'Avoid calling structlog.configure() or structlog.reset_defaults() ' 'inside a LogCapture block.' ) ): with LogCapture(StructlogSource()): structlog.reset_defaults() def test_displaced_by_reconfigure_warns(self): processors_before = structlog.get_config()['processors'] with ShouldWarn( UserWarning( 'StructlogSource was displaced from structlog configuration before uninstall.\n' 'Avoid calling structlog.configure() or structlog.reset_defaults() ' 'inside a LogCapture block.' ) ): with LogCapture(StructlogSource()) as cap: structlog.get_logger().info('captured before displacement') structlog.configure(processors=[structlog.processors.KeyValueRenderer()]) # Anything logged after the user swapped the list reference goes # through their new chain, not our capture — nothing we can do # about that. We still warn and restore the original config on # uninstall. structlog.get_logger().bind(after='displacement').info('lost') cap.check(('INFO', 'captured before displacement')) compare(structlog.get_config()['processors'], expected=processors_before) def test_uninstall_does_not_warn(self): with ShouldNotWarn(): with LogCapture(StructlogSource()): structlog.get_logger().info('hi') def test_capture_dropped_when_chain_outlives_source(self) -> None: # If something holds the live processor chain mid-capture and reuses # it after uninstall — eg. a user snapshotted it, or a bound logger # was passed across the boundary — the leaked capture processor must # drop the event cleanly rather than blow up on a missing collector. source = StructlogSource() leaked_chain: list = [] with LogCapture(source): leaked_chain[:] = structlog.get_config()['processors'] structlog.get_logger().info('during') sentinel = Mock() leaked_chain.append(sentinel) structlog.configure(processors=leaked_chain) structlog.get_logger().info('after uninstall via leaked chain') sentinel.assert_not_called() def test_exception_object_via_exc_info(self): boom = ValueError('boom') with LogCapture(StructlogSource()) as log: structlog.get_logger().error('oh no', exc_info=boom) compare(log.entries[0].exception, expected=boom) def test_uninstall_without_install_is_a_noop(self): StructlogSource().uninstall() testfixtures-12.4.0/tests/test_sybil.py000066400000000000000000000103721525773641100202620ustar00rootroot00000000000000import pytest pytest.importorskip("sybil") from textwrap import dedent from unittest import TestCase from testfixtures.mock import Mock from sybil.document import Document from testfixtures import compare, Comparison as C, TempDirectory from testfixtures.sybil import FileParser, FileBlock class TestFileParser(TestCase): def check_document(self, text, expected): d = Document(dedent(text), path='/dev/null') compare( list(r.parsed for r in FileParser('td')(d)), expected=expected ) def test_multiple_files(self): self.check_document( text=""" .. topic:: file.txt :class: write-file line 1 line 2 line 3 .. topic:: file2.txt :class: read-file line 4 line 5 line 6 """, expected = [ C(FileBlock, path='file.txt', content="line 1\n\nline 2\nline 3\n", action='write'), C(FileBlock, path='file2.txt', content='line 4\n\nline 5\nline 6\n', action='read'), ]) def test_ignore_literal_blocking(self): self.check_document( text=""" .. topic:: file.txt :class: write-file :: line 1 line 2 line 3 """, expected=[ C(FileBlock, path='file.txt', content="line 1\n\nline 2\nline 3\n", action='write'), ]) def test_file_followed_by_text(self): self.check_document( text=""" .. topic:: file.txt :class: write-file print("hello") out = 'there' foo = 'bar' This is just some normal text! """, expected=[ C(FileBlock, path='file.txt', content='print("hello")' '\nout = \'there\'\n\nfoo = \'bar\'\n', action='write'), ]) def test_red_herring(self): self.check_document( text=""" .. topic:: file.txt :class: not-a-file print "hello" out = 'there' """, expected=[] ) def test_no_class(self): self.check_document( text=""" .. topic:: file.txt print "hello" out = 'there' """, expected=[] ) def check_evaluate(self, dir, block, expected): parser = FileParser('td') compare(expected, actual=parser.evaluate(Mock( parsed=block, namespace={'td': dir}, path='/the/file', line=42, ))) def test_evaluate_read_same(self): with TempDirectory() as dir: dir.write('foo', b'content') self.check_evaluate( dir, FileBlock('foo', 'content', 'read'), expected=None ) def test_evaluate_read_difference(self): with TempDirectory() as dir: dir.write('foo', b'actual') self.check_evaluate( dir, FileBlock('foo', 'expected', 'read'), expected=( "--- File '/the/file', line 42:\n" "+++ Reading from \"{}/foo\":\n" "@@ -1 +1 @@\n" "-expected\n" "+actual" ).format(dir.path) ) def test_evaluate_write(self): with TempDirectory() as dir: self.check_evaluate( dir, FileBlock('foo', 'content', 'write'), expected=None ) dir.compare(['foo']) compare(dir.read('foo', 'ascii'), 'content') testfixtures-12.4.0/tests/test_tempdir.py000066400000000000000000000063361525773641100206110ustar00rootroot00000000000000import os from pathlib import Path from testfixtures.shouldraise import ShouldAssert from testfixtures.mock import Mock from tempfile import mkdtemp from testfixtures import Replacer, TempDirectory, compare, tempdir from unittest import TestCase from testfixtures.rmtree import rmtree class TestTempDir(TestCase): @tempdir() def test_simple(self, d): d.write('something', b'stuff') d.write('.svn', b'stuff') d.compare(( '.svn', 'something', )) @tempdir() def test_subdirs(self, d): subdir = ['some', 'thing'] d.write(subdir+['something'], b'stuff') d.write(subdir+['.svn'], b'stuff') d.compare(path=subdir, expected=( '.svn', 'something', )) @tempdir() def test_not_same(self, d): d.write('something', b'stuff') with ShouldAssert( "sequence not as expected:\n" "\n" "same:\n" "()\n" "\n" "expected:\n" "('.svn', 'something')\n" "\n" "actual:\n" "('something',)" ): d.compare(['.svn', 'something']) @tempdir(ignore=('.svn', )) def test_ignore(self, d): d.write('something', b'stuff') d.write('.svn', b'stuff') d.compare(['something']) def test_cleanup_properly(self): r = Replacer() try: m = Mock() d = mkdtemp() m.return_value = d r.replace('testfixtures.tempdirectory.mkdtemp', m) self.assertTrue(os.path.exists(d)) self.assertFalse(m.called) @tempdir() def test_method(d): d.write('something', b'stuff') d.compare(['something']) self.assertFalse(m.called) compare(os.listdir(d), []) test_method() self.assertTrue(m.called) self.assertFalse(os.path.exists(d)) finally: r.restore() if os.path.exists(d): # only runs if the test fails! rmtree(d) # pragma: no cover @tempdir() def test_cleanup_test_okay_with_deleted_dir(self, d): rmtree(d.path) @tempdir() def test_decorator_returns_tempdirectory(self, d): # check for what we get, so we only have to write # tests in test_tempdirectory.py self.assertTrue(isinstance(d, TempDirectory)) def test_dont_create_or_cleanup_with_path(self): with Replacer() as r: m = Mock() r.replace('testfixtures.tempdirectory.mkdtemp', m) r.replace('testfixtures.tempdirectory.rmtree', m) @tempdir(path='foo') def test_method(d): compare(d.path, 'foo') test_method() self.assertFalse(m.called) def test_cwd_directory(self): @tempdir(cwd=True) def test_method(d): compare(Path(os.getcwd()).resolve(), expected=Path(d.path).resolve()) original = os.getcwd() try: test_method() compare(Path(os.getcwd()).resolve(), expected=Path(original).resolve()) finally: os.chdir(original) testfixtures-12.4.0/tests/test_tempdirectory.py000066400000000000000000000747431525773641100220460ustar00rootroot00000000000000import atexit import os from pathlib import Path from tempfile import mkdtemp from typing import Iterator, Sequence, TypeAlias from unittest import TestCase from warnings import catch_warnings import pytest from testfixtures.mock import Mock, call from testfixtures import ( TempDirectory, TempDir, Replacer, ShouldRaise, compare, OutputCapture, ShouldWarn ) from testfixtures.formats import JSON, Format, YAML from testfixtures.rmtree import rmtree from testfixtures.tempdirectory import _TempDir TempDirClasses: TypeAlias = type[TempDir | TempDirectory] some_bytes = '\xa3'.encode('utf-8') some_text = '\xa3' class TestTempDirectory(TestCase): def test_cleanup(self) -> None: d = TempDirectory() p = d.path assert p is not None assert os.path.exists(p) is True p = d.write('something', b'stuff') d.cleanup() assert os.path.exists(p) is False def test_cleanup_all(self) -> None: d1 = TempDirectory() d2 = TempDirectory() assert d1.path is not None assert os.path.exists(d1.path) is True p1 = d1.path assert d2.path is not None assert os.path.exists(d2.path) is True p2 = d2.path TempDirectory.cleanup_all() assert os.path.exists(p1) is False assert os.path.exists(p2) is False def test_with_statement(self) -> None: with TempDirectory() as d: p = d.path assert p is not None assert os.path.exists(p) is True d.write('something', b'stuff') assert os.listdir(p) == ['something'] with open(os.path.join(p, 'something')) as f: assert f.read() == 'stuff' assert os.path.exists(p) is False def test_listdir_sort(self) -> None: # pragma: no branch with TempDirectory() as d: d.write('ga', b'') d.write('foo1', b'') d.write('Foo2', b'') d.write('g.o', b'') with OutputCapture() as output: d.listdir() output.compare('Foo2\nfoo1\ng.o\nga') class TempDirectoryTests(TestCase): def test_write_with_slash_at_start(self) -> None: with TempDirectory() as d: with ShouldRaise(ValueError( 'Attempt to read or write outside the temporary Directory' )): d.write('/some/folder', 'stuff') def test_makedir_with_slash_at_start(self) -> None: with TempDirectory() as d: with ShouldRaise(ValueError( 'Attempt to read or write outside the temporary Directory' )): d.makedir('/some/folder') def test_read_with_slash_at_start(self) -> None: with TempDirectory() as d: with ShouldRaise(ValueError( 'Attempt to read or write outside the temporary Directory' )): d.read('/some/folder') def test_listdir_with_slash_at_start(self) -> None: with TempDirectory() as d: with ShouldRaise(ValueError( 'Attempt to read or write outside the temporary Directory' )): d.listdir('/some/folder') def test_compare_with_slash_at_start(self) -> None: with TempDirectory() as d: with ShouldRaise(ValueError( 'Attempt to read or write outside the temporary Directory' )): d.compare((), path='/some/folder') def test_read_with_slash_at_start_ok(self) -> None: with TempDirectory() as d: path = d.write('foo', b'bar') compare(d.read(path), b'bar') def test_dont_cleanup_with_path(self) -> None: d = mkdtemp() fp = os.path.join(d, 'test') with open(fp, 'w') as f: f.write('foo') try: td = TempDirectory(path=d) self.assertEqual(d, td.path) td.cleanup() # checks self.assertEqual(os.listdir(d), ['test']) with open(fp) as f: self.assertEqual(f.read(), 'foo') finally: rmtree(d) def test_dont_create_with_path(self) -> None: d = mkdtemp() rmtree(d) td = TempDirectory(path=d) self.assertEqual(d, td.path) self.assertFalse(os.path.exists(d)) def test_delay_create(self) -> None: td = TempDirectory(create=False) assert td.path is None with ShouldRaise(RuntimeError('Instantiated with create=False and .create() not called')): td.as_path() td.create() assert td.path is not None td.cleanup() assert td.path is None def test_compare_sort_actual(self) -> None: with TempDirectory() as d: d.write('ga', b'') d.write('foo1', b'') d.write('Foo2', b'') d.write('g.o', b'') d.compare(['Foo2', 'foo1', 'g.o', 'ga']) def test_compare_sort_expected(self) -> None: with TempDirectory() as d: d.write('ga', b'') d.write('foo1', b'') d.write('Foo2', b'') d.write('g.o', b'') d.compare(['Foo2', 'ga', 'foo1', 'g.o']) def test_compare_path_tuple(self) -> None: with TempDirectory() as d: d.write('a/b/c', b'') d.compare(path=('a', 'b'), expected=['c']) def test_recursive_ignore(self) -> None: with TempDirectory(ignore=['.svn']) as d: d.write('.svn/rubbish', b'') d.write('a/.svn/rubbish', b'') d.write('a/b/.svn', b'') d.write('a/b/c', b'') d.write('a/d/.svn/rubbish', b'') d.compare([ 'a/', 'a/b/', 'a/b/c', 'a/d/', ]) def test_files_only(self) -> None: with TempDirectory() as d: d.write('a/b/c', b'') d.compare(['a/b/c'], files_only=True) def test_path(self) -> None: with TempDirectory() as d: expected1 = d.makedir('foo') expected2 = d.write('baz/bob', b'') expected3 = d.as_string('a/b/c') actual1 = d.as_string('foo') actual2 = d.as_string('baz/bob') actual3 = d.as_string(('a', 'b', 'c')) self.assertEqual(expected1, actual1) self.assertEqual(expected2, actual2) self.assertEqual(expected3, actual3) def test_getpath(self) -> None: with TempDirectory() as d: expected1 = d.getpath() expected2 = d.getpath('foo') actual1 = d.as_string() actual2 = d.as_string('foo') compare(expected1, actual=actual1) compare(expected2, actual=actual2) def test_atexit(self) -> None: m = Mock() with Replacer() as replace: # make sure the marker is false, other tests will # probably have set it replace( TempDirectory.atexit_setup, replacement=False, name='atexit_setup', container=TempDirectory, ) replace.in_module(atexit.register, m.register) d = TempDirectory() compare(m.mock_calls, expected = [call.register(d.atexit)]) with ShouldWarn( UserWarning( f"TempDirectory instances not cleaned up by shutdown:\n{d.path}" ) ): d.atexit() assert d.path is not None d.cleanup() compare(TempDirectory.instances, expected=set()) # check re-running has no ill effects d.atexit() def test_read_decode(self) -> None: with TempDirectory() as d: assert d.path is not None with open(os.path.join(d.path, 'test.file'), 'wb') as f: f.write(b'\xc2\xa3') compare(d.read('test.file', 'utf8'), some_text) def test_read_no_decode(self) -> None: with TempDirectory() as d: assert d.path is not None with open(os.path.join(d.path, 'test.file'), 'wb') as f: f.write(b'\xc2\xa3') compare(d.read('test.file'), b'\xc2\xa3') def test_write_bytes(self) -> None: with TempDirectory() as d: d.write('test.file', b'\xc2\xa3') assert d.path is not None with open(os.path.join(d.path, 'test.file'), 'rb') as f: compare(f.read(), b'\xc2\xa3') def test_write_unicode(self) -> None: with TempDirectory() as d: d.write('test.file', some_text, 'utf8') assert d.path is not None with open(os.path.join(d.path, 'test.file'), 'rb') as f: compare(f.read(), b'\xc2\xa3') def test_write_unicode_default_encoding(self) -> None: with TempDirectory() as d: d.write('test.file', u'\xa3') compare((d / 'test.file').read_text(), expected=u'\xa3') def test_just_empty_non_recursive(self) -> None: with TempDirectory() as d: d.makedir('foo/bar') d.makedir('foo/baz') d.compare(path='foo', expected=['bar', 'baz'], recursive=False) def test_just_empty_dirs(self) -> None: with TempDirectory() as d: d.makedir('foo/bar') d.makedir('foo/baz') d.compare(['foo/', 'foo/bar/', 'foo/baz/']) def test_symlink(self) -> None: with TempDirectory() as d: d.write('foo/bar.txt', b'x') os.symlink(d.as_string('foo'), d.as_string('baz')) d.compare(['baz/', 'foo/', 'foo/bar.txt']) def test_follow_symlinks(self) -> None: with TempDirectory() as d: d.write('foo/bar.txt', b'x') os.symlink(d.as_string('foo'), d.as_string('baz')) d.compare(['baz/', 'baz/bar.txt', 'foo/', 'foo/bar.txt'], followlinks=True) def test_trailing_slash(self) -> None: with TempDirectory() as d: d.write('source/foo/bar.txt', b'x') d.compare(path='source/', expected=['foo/', 'foo/bar.txt']) def test_default_encoding(self) -> None: encoded = b'\xc2\xa3' decoded = encoded.decode('utf-8') with TempDirectory(encoding='utf-8') as d: d.write('test.txt', decoded) compare(d.read('test.txt'), expected=decoded) def test_override_default_encoding(self) -> None: encoded = b'\xc2\xa3' decoded = encoded.decode('utf-8') with TempDirectory(encoding='ascii') as d: d.write('test.txt', decoded, encoding='utf-8') compare(d.read('test.txt', encoding='utf-8'), expected=decoded) def test_attempt_to_encode_bytes(self) -> None: with TempDirectory() as d: with ShouldRaise(TypeError("Cannot specify encoding when data is bytes")): d.write('test.txt', b'\xc2\xa3', encoding='utf-8') # type: ignore[call-overload] def test_as_path_minimal(self) -> None: with TempDirectory(encoding='ascii') as d: assert d.path is not None compare(d.as_path(), expected=Path(d.path), strict=True) def test_as_path_relative_string(self) -> None: with TempDirectory(encoding='ascii') as d: assert d.path is not None compare(d.as_path('foo/bar'), expected=Path(d.path) / 'foo' / 'bar', strict=True) def test_as_path_relative_sequence(self) -> None: with TempDirectory(encoding='ascii') as d: assert d.path is not None compare(d.as_path(('foo', 'bar')), expected=Path(d.path) / 'foo' / 'bar', strict=True) def test_traverse(self) -> None: with TempDirectory(encoding='ascii') as d: assert d.path is not None compare((d / 'foo' / 'bar'), expected=Path(d.path) / 'foo' / 'bar', strict=True) def test_cwd_context_manager(self) -> None: original = os.getcwd() try: # need to resolve links thanks to /tmp location on macos! with TempDirectory(cwd=True) as d: assert d.path is not None compare(Path(os.getcwd()).resolve(), expected=Path(d.path).resolve()) compare(Path(os.getcwd()).resolve(), expected=Path(original).resolve()) finally: os.chdir(original) def test_write_read_roundtrip(self): with TempDirectory(cwd=True) as d: path = d.write('file', 'stuff') result = d.read(path) compare(result, expected=b'stuff') def test_write_path(self): with TempDirectory(cwd=True) as d: path = d.write(d / 'file', 'stuff') compare(Path(path).read_text(), expected='stuff') def test_wrap_path(tmp_path: Path) -> None: with TempDirectory(tmp_path) as d: assert d.path == str(tmp_path) assert tmp_path.exists() class TestTempDir: @pytest.fixture() def tempdir(self) -> Iterator[TempDir]: with TempDir() as d: yield d def test_makedir(self, tempdir: TempDir) -> None: path = tempdir.makedir('foo') assert isinstance(path, Path) def test_write(self, tempdir: TempDir) -> None: path = tempdir.write('foo', '') assert isinstance(path, Path) def test_path(self, tempdir: TempDir) -> None: assert isinstance(tempdir.path, Path) assert tempdir.path.exists() def test_attempt_to_encode_bytes(self, tempdir: TempDir) -> None: with ShouldRaise(TypeError("Cannot specify encoding when data is bytes")): tempdir.write('test.txt', b'\xc2\xa3', encoding='utf-8') # type: ignore[call-overload] def test_write_read_roundtrip(self, tempdir: TempDir): path = tempdir.write('file', 'stuff') result = tempdir.read(path) compare(result, expected=b'stuff') def test_write_path(self, tempdir: TempDir): path = tempdir.write(tempdir / 'file', 'stuff') compare(path.read_text(), expected='stuff') @pytest.mark.parametrize('class_', [TempDir, TempDirectory]) class TestFormats: def test_write_format(self, class_: TempDirClasses) -> None: with class_() as d: data = {'key': 'value', 'number': 42} d.write('config.json', data, format=JSON) with open(os.path.join(d.path, 'config.json'), 'rb') as f: compare(f.read(), b'{"key": "value", "number": 42}') def test_read_format(self, class_: TempDirClasses): with class_() as d: with open(os.path.join(d.path, 'config.json'), 'wb') as f: f.write(b'{"key": "value", "number": 42}') result = d.read('config.json', format=JSON) compare(result, expected={'key': 'value', 'number': 42}) def test_roundtrip_format(self, class_: TempDirClasses): with class_() as d: original = {'nested': {'data': [1, 2, 3]}, 'flag': True} d.write('data.json', original, format=JSON) result = d.read('data.json', format=JSON) compare(result, expected=original) def test_write_format_with_encoding(self, class_: TempDirClasses): with class_() as d: # Test that latin-1 encoding is used with ASCII-safe JSON # JSON module escapes non-ASCII as \uXXXX by default data = {'message': 'test'} d.write('config.json', data, encoding='latin-1', format=JSON) with open(os.path.join(d.path, 'config.json'), 'rb') as f: # JSON string encoded as latin-1 (ASCII compatible) content = f.read() compare(content, expected=b'{"message": "test"}') def test_read_format_with_encoding(self, class_: TempDirClasses): with class_() as d: # Write latin-1 encoded JSON with non-ASCII character # £ is 0xa3 in latin-1 with open(os.path.join(d.path, 'config.json'), 'wb') as f: f.write(b'{"message": "\xa3100"}') result = d.read('config.json', encoding='latin-1', format=JSON) compare(result, expected={'message': '£100'}) def test_roundtrip_format_with_encoding(self, class_: TempDirClasses): with class_() as d: # Roundtrip with latin-1 encoding # Use ASCII-safe data since JSON escapes non-ASCII by default original = {'name': 'test', 'value': 123} d.write('data.json', original, encoding='latin-1', format=JSON) result = d.read('data.json', encoding='latin-1', format=JSON) compare(result, expected=original) def test_format_with_default_encoding(self, class_: TempDirClasses): with class_(encoding='utf-8') as d: data = {'test': 'data'} d.write('config.json', data, format=JSON) result = d.read('config.json', format=JSON) compare(result, expected=data) def test_write_no_format_but_data_provided(self, class_: TempDirClasses): with class_() as d: with ShouldRaise(TypeError("memoryview: a bytes-like object is required, not 'dict'")): d.write('config.json', {'test': 'data'}) # type: ignore[call-overload] @pytest.mark.parametrize('class_', [TempDir, TempDirectory]) class TestReadText: def test_basic_text_reading(self, class_: TempDirClasses): with class_() as d: d.write('file.txt', 'hello world', encoding='utf-8') result = d.read_text('file.txt', encoding='utf-8') compare(result, expected='hello world') def test_instance_default_encoding(self, class_: TempDirClasses): with class_(encoding='utf-8') as d: d.write('file.txt', 'test content', encoding='utf-8') result = d.read_text('file.txt') compare(result, expected='test content') def test_explicit_encoding_overrides_instance(self, class_: TempDirClasses): with class_(encoding='latin-1') as d: d.write('file.txt', 'café', encoding='utf-8') result = d.read_text('file.txt', encoding='utf-8') compare(result, expected='café') def test_errors_parameter(self, class_: TempDirClasses): with class_() as d: d.write('file.txt', b'\xff\xfe') result = d.read_text('file.txt', encoding='utf-8', errors='ignore') assert isinstance(result, str) def test_type_annotation(self, class_: TempDirClasses): with class_() as d: d.write('file.txt', 'text', encoding='utf-8') result: str = d.read_text('file.txt') assert isinstance(result, str) @pytest.mark.parametrize('class_', [TempDir, TempDirectory]) class TestReadBytes: def test_basic_bytes_reading(self, class_: TempDirClasses): with class_() as d: d.write('file.bin', b'\x00\x01\x02\x03') result = d.read_bytes('file.bin') compare(result, expected=b'\x00\x01\x02\x03') def test_binary_data(self, class_: TempDirClasses): with class_() as d: binary_data = bytes(range(256)) d.write('file.bin', binary_data) result = d.read_bytes('file.bin') compare(result, expected=binary_data) def test_type_annotation(self, class_: TempDirClasses): with class_() as d: d.write('file.bin', b'data') result: bytes = d.read_bytes('file.bin') assert isinstance(result, bytes) @pytest.mark.parametrize('class_', [TempDir, TempDirectory]) class TestParseDump: def test_dump_return_type(self, class_: TempDirClasses): with class_() as d: data = {'key': 'value'} path = d.dump('config.json', data) if class_ is TempDir: assert isinstance(path, Path) else: assert isinstance(path, str) def test_dump_json(self, class_: TempDirClasses): with class_() as d: data = {'key': 'value', 'number': 42} path = d.dump('config.json', data) with open(path, 'rb') as f: compare(f.read(), b'{"key": "value", "number": 42}') def test_parse_json(self, class_: TempDirClasses): with class_() as d: d.write('config.json', b'{"key": "value", "number": 42}') result = d.parse('config.json') compare(result, expected={'key': 'value', 'number': 42}) def test_roundtrip_json(self, class_: TempDirClasses): with class_() as d: original = {'nested': {'data': [1, 2, 3]}, 'flag': True} d.dump('data.json', original) result = d.parse('data.json') compare(result, expected=original) def test_dump_yaml(self, class_: TempDirClasses): pytest.importorskip('yaml') with class_() as d: data = {'app': {'name': 'test'}} path = d.dump('config.yaml', data) with open(path, 'rb') as f: content = f.read() assert b'app:' in content assert b'name: test' in content def test_parse_yaml(self, class_: TempDirClasses): pytest.importorskip('yaml') with class_() as d: d.write('config.yaml', b'app:\n name: test\n') result = d.parse('config.yaml') compare(result, expected={'app': {'name': 'test'}}) def test_yml_extension(self, class_: TempDirClasses): pytest.importorskip('yaml') with class_() as d: data = {'test': 'data'} path = d.dump('config.yml', data) result = d.parse(path) compare(result, expected=data) def test_dump_toml(self, class_: TempDirClasses): with class_() as d: # TOML reading works with stdlib tomllib, no import needed data = {'key': 'value', 'num': 42} # But writing requires tomlkit pytest.importorskip('tomlkit') path = d.dump('config.toml', data) with open(path, 'rb') as f: content = f.read() assert b'key = "value"' in content assert b'num = 42' in content def test_parse_toml(self, class_: TempDirClasses): with class_() as d: d.write('config.toml', b'key = "value"\nnum = 42\n') result = d.parse('config.toml') compare(result, expected={'key': 'value', 'num': 42}) def test_case_insensitive(self, class_: TempDirClasses): with class_() as d: data = {'test': 'data'} path = d.dump('config.JSON', data) result = d.parse(path) compare(result, expected=data) def test_unknown_extension(self, class_: TempDirClasses): with class_() as d: with ShouldRaise(ValueError( "No format registered for extension '.txt'. " "Supported extensions: ['.json', '.toml', '.yaml', '.yml']" )): d.parse('data.txt') def test_no_extension(self, class_: TempDirClasses): with class_() as d: with ShouldRaise(ValueError( "No format registered for extension ''. " "Supported extensions: ['.json', '.toml', '.yaml', '.yml']" )): d.parse('filename') def test_multiple_dots(self, class_: TempDirClasses): with class_() as d: data = {'backup': 'config'} d.dump('backup.config.json', data) result = d.parse('backup.config.json') compare(result, expected=data) def test_tuple_of_names_infer_format(self, class_: TempDirClasses): with class_() as d: original = {'nested': {'data': [1, 2, 3]}, 'flag': True} d.dump(('folder', 'config.json'), original) result = d.parse(('folder', 'config.json')) compare(result, expected=original) def test_custom_formats(self, class_: TempDirClasses): with class_(formats=[JSON]) as d: data = {'test': 'data'} d.dump('config.json', data) result = d.parse('config.json') compare(result, expected=data) def test_duplicate_suffix(self, class_: TempDirClasses): class CustomFormat: suffixes: Sequence[str] = ('.json',) def parse(self, data: str): ... def render(self, obj): ... custom_fmt = CustomFormat() with ShouldRaise(ValueError("Multiple formats registered for extension '.json'")): class_(formats=[JSON, custom_fmt]) def test_parse_with_instance_encoding(self, class_: TempDirClasses): with class_(encoding='utf-8') as d: data = {'message': 'test'} d.dump('config.json', data) result = d.parse('config.json') compare(result, expected=data) def test_dump_with_instance_encoding(self, class_: TempDirClasses): with class_(encoding='utf-8') as d: data = {'message': 'test'} path = d.dump('config.json', data) with open(path, 'rb') as f: content = f.read() # Verify it's properly encoded as UTF-8 assert b'{"message": "test"}' in content def test_parse_no_extension_with_format(self, class_: TempDirClasses): with class_() as d: d.write('config', b'{"key": "value"}') result = d.parse('config', format=JSON) compare(result, expected={'key': 'value'}) def test_dump_no_extension_with_format(self, class_: TempDirClasses): with class_() as d: data = {'key': 'value'} path = d.dump('config', data, format=JSON) with open(path, 'rb') as f: compare(f.read(), expected=b'{"key": "value"}') def test_roundtrip_no_extension(self, class_: TempDirClasses): with class_() as d: original = {'nested': {'data': [1, 2, 3]}, 'flag': True} d.dump('config', original, format=JSON) result = d.parse('config', format=JSON) compare(result, expected=original) def test_parse_format_overrides_extension(self, class_: TempDirClasses): pytest.importorskip('yaml') with class_() as d: d.write('data.json', b'key: value\n') result = d.parse('data.json', format=YAML) compare(result, expected={'key': 'value'}) def test_dump_format_overrides_extension(self, class_: TempDirClasses): pytest.importorskip('yaml') with class_() as d: data = {'key': 'value'} path = d.dump('data.json', data, format=YAML) with open(path, 'rb') as f: content = f.read() assert b'key: value' in content @pytest.mark.parametrize('class_', [TempDir, TempDirectory]) class TestClone: def test_clone_file_to_root(self, tmp_path: Path, class_: TempDirClasses): source_file = tmp_path / 'sample.txt' source_file.write_text('sample content') with class_() as d: result = d.clone(source_file) compare(Path(result).name, expected='sample.txt') compare(Path(result).read_text(), expected='sample content') d.compare(['sample.txt']) def test_clone_file_to_specific_dest(self, tmp_path: Path, class_: TempDirClasses): source_file = tmp_path / 'original.txt' source_file.write_text('hello') with class_() as d: result = d.clone(source_file, 'renamed.txt') compare(Path(result).name, expected='renamed.txt') compare(Path(result).read_text(), expected='hello') d.compare(['renamed.txt']) def test_clone_file_into_directory(self, tmp_path: Path, class_: TempDirClasses): source_file = tmp_path / 'data.txt' source_file.write_text('file content') with class_() as d: result = d.clone(source_file, 'subdir/') compare(Path(result).name, expected='data.txt') compare('subdir' in str(result), expected=True) compare(Path(result).read_text(), expected='file content') d.compare(['subdir/', 'subdir/data.txt']) def test_clone_file_into_directory_sequence(self, tmp_path: Path, class_: TempDirClasses): source_file = tmp_path / 'data.txt' source_file.write_text('file content') with class_() as d: result = d.clone(source_file, ['subdir', '']) compare(Path(result).name, expected='data.txt') compare('subdir' in str(result), expected=True) compare(Path(result).read_text(), expected='file content') d.compare(['subdir/', 'subdir/data.txt']) def test_clone_directory_as_new_name(self, tmp_path: Path, class_: TempDirClasses): source_dir = tmp_path / 'mydir' source_dir.mkdir() (source_dir / 'file1.txt').write_text('one') (source_dir / 'file2.txt').write_text('two') with class_() as d: result = d.clone(source_dir, 'newname') compare(Path(result).name, expected='newname') compare((Path(result) / 'file1.txt').read_text(), expected='one') compare((Path(result) / 'file2.txt').read_text(), expected='two') d.compare(['newname/', 'newname/file1.txt', 'newname/file2.txt']) def test_clone_directory_contents_into_dest(self, tmp_path: Path, class_: TempDirClasses): source_dir = tmp_path / 'src' source_dir.mkdir() (source_dir / 'a.txt').write_text('a') (source_dir / 'b.txt').write_text('b') with class_() as d: result = d.clone(source_dir, 'dest/') compare(Path(result).name, expected='dest') compare((Path(result) / 'a.txt').read_text(), expected='a') compare((Path(result) / 'b.txt').read_text(), expected='b') d.compare(['dest/', 'dest/a.txt', 'dest/b.txt']) def test_clone_to_nested_destination(self, tmp_path: Path, class_: TempDirClasses): source_file = tmp_path / 'deep.txt' source_file.write_text('deep content') with class_() as d: result = d.clone(source_file, 'a/b/c/deep.txt') compare(Path(result).read_text(), expected='deep content') d.compare(['a/', 'a/b/', 'a/b/c/', 'a/b/c/deep.txt']) def test_clone_nonexistent_source_raises(self, tmp_path: Path, class_: TempDirClasses): nonexistent = tmp_path / 'does_not_exist.txt' with class_() as d: with ShouldRaise(FileNotFoundError(2, 'No such file or directory')): d.clone(nonexistent) def test_clone_returns_correct_type(self, tmp_path: Path, class_: TempDirClasses): source_file = tmp_path / 'type_test.txt' source_file.write_text('test') with class_() as d: result = d.clone(source_file) assert isinstance(result, Path if class_ is TempDir else str) testfixtures-12.4.0/tests/test_time.py000066400000000000000000000202101525773641100200660ustar00rootroot00000000000000from datetime import timedelta from unittest import TestCase from testfixtures import mock_time, replace, compare, ShouldRaise from testfixtures.datetime import MockTime from .test_datetime import SampleTZInfo class TestTime(TestCase): @replace('time.time', mock_time()) def test_time_call(self) -> None: from time import time compare(time(), 978307200.0) compare(time(), 978307201.0) compare(time(), 978307203.0) @replace('time.time', mock_time(2002, 1, 1, 1, 2, 3)) def test_time_supplied(self) -> None: from time import time compare(time(), 1009846923.0) @replace('time.time', mock_time(None)) def test_time_sequence(self, t: MockTime) -> None: t.add(2002, 1, 1, 1, 0, 0) t.add(2002, 1, 1, 2, 0, 0) t.add(2002, 1, 1, 3, 0, 0) from time import time compare(time(), 1009846800.0) compare(time(), 1009850400.0) compare(time(), 1009854000.0) @replace('time.time', mock_time(None)) def test_add_datetime_supplied(self, t: MockTime) -> None: from datetime import datetime from time import time t.add(datetime(2002, 1, 1, 2)) compare(time(), 1009850400.0) tzinfo = SampleTZInfo() tzrepr = repr(tzinfo) with ShouldRaise(ValueError( 'Cannot add datetime with tzinfo of %s as configured to use None' %( tzrepr ))): t.add(datetime(2001, 1, 1, tzinfo=tzinfo)) def test_instantiate_with_datetime(self) -> None: from datetime import datetime t = mock_time(datetime(2002, 1, 1, 2)) compare(t(), 1009850400.0) @replace('time.time', mock_time(None)) def test_now_requested_longer_than_supplied(self, t: MockTime) -> None: t.add(2002, 1, 1, 1, 0, 0) t.add(2002, 1, 1, 2, 0, 0) from time import time compare(time(), 1009846800.0) compare(time(), 1009850400.0) compare(time(), 1009850401.0) compare(time(), 1009850403.0) @replace('time.time', mock_time()) def test_call(self, t: MockTime) -> None: compare(t(), 978307200.0) from time import time compare(time(), 978307201.0) @replace('time.time', mock_time()) def test_repr_time(self) -> None: from time import time assert repr(time).startswith(' None: from time import time compare(time(), 978307200.0) compare(time(), 978307210.0) compare(time(), 978307220.0) @replace('time.time', mock_time(delta_type='minutes')) def test_delta_type(self) -> None: from time import time compare(time(), 978307200.0) compare(time(), 978307260.0) compare(time(), 978307380.0) @replace('time.time', mock_time(None)) def test_set(self, time_mock: MockTime) -> None: from time import time time_mock.set(2001, 1, 1, 1, 0, 1) compare(time(), 978310801.0) time_mock.set(2002, 1, 1, 1, 0, 0) compare(time(), 1009846800.0) compare(time(), 1009846802.0) @replace('time.time', mock_time(None)) def test_set_datetime_supplied(self, t: MockTime) -> None: from datetime import datetime from time import time t.set(datetime(2001, 1, 1, 1, 0, 1)) compare(time(), 978310801.0) tzinfo = SampleTZInfo() tzrepr = repr(tzinfo) with ShouldRaise(ValueError( 'Cannot add datetime with tzinfo of %s as configured to use None' %( tzrepr ))): t.set(datetime(2001, 1, 1, tzinfo=tzinfo)) @replace('time.time', mock_time(None)) def test_set_kw(self, time_mock: MockTime) -> None: from time import time time_mock.set(year=2001, month=1, day=1, hour=1, second=1) compare(time(), 978310801.0) @replace('time.time', mock_time(None)) def test_set_kw_tzinfo(self, time_mock: MockTime) -> None: with ShouldRaise(TypeError('Cannot add using tzinfo on MockTime')): time_mock.set(year=2001, tzinfo=SampleTZInfo()) @replace('time.time', mock_time(None)) def test_set_args_tzinfo(self, time_mock: MockTime) -> None: with ShouldRaise(TypeError('Cannot add using tzinfo on MockTime')): time_mock.set(2002, 1, 2, 3, 4, 5, 6, SampleTZInfo()) # type: ignore[arg-type] @replace('time.time', mock_time(None)) def test_add_kw(self, time_mock: MockTime) -> None: from time import time time_mock.add(year=2001, month=1, day=1, hour=1, second=1) compare(time(), 978310801.0) @replace('time.time', mock_time(None)) def test_add_tzinfo_kw(self, time_mock: MockTime) -> None: with ShouldRaise(TypeError('Cannot add using tzinfo on MockTime')): time_mock.add(year=2001, tzinfo=SampleTZInfo()) @replace('time.time', mock_time(None)) def test_add_tzinfo_args(self, time_mock: MockTime) -> None: with ShouldRaise(TypeError('Cannot add using tzinfo on MockTime')): time_mock.add(2001, 1, 2, 3, 4, 5, 6, SampleTZInfo()) # type: ignore[arg-type] @replace('time.time', mock_time(2001, 1, 2, 3, 4, 5, 600000)) def test_max_number_args(self) -> None: from time import time compare(time(), 978404645.6) def test_max_number_tzinfo(self) -> None: with ShouldRaise(TypeError( "You don't want to use tzinfo with test_time" )): mock_time(2001, 1, 2, 3, 4, 5, 6, SampleTZInfo()) # type: ignore[arg-type] @replace('time.time', mock_time(2001, 1, 2)) def test_min_number_args(self) -> None: from time import time compare(time(), 978393600.0) @replace('time.time', mock_time( year=2001, month=1, day=2, hour=3, minute=4, second=5, microsecond=6, )) def test_all_kw(self) -> None: from time import time compare(time(), 978404645.000006) def test_kw_tzinfo(self) -> None: with ShouldRaise(TypeError( "You don't want to use tzinfo with test_time" )): mock_time(year=2001, tzinfo=SampleTZInfo()) # type: ignore[arg-type] def test_instance_tzinfo(self) -> None: from datetime import datetime with ShouldRaise(TypeError( "You don't want to use tzinfo with test_time" )): mock_time(datetime(2001, 1, 1, tzinfo=SampleTZInfo())) def test_subsecond_deltas(self) -> None: time = mock_time(delta=0.5) compare(time(), 978307200.0) compare(time(), 978307200.5) compare(time(), 978307201.0) def test_ms_deltas(self) -> None: time = mock_time(delta=1000, delta_type='microseconds') compare(time(), 978307200.0) compare(time(), 978307200.001) compare(time(), 978307200.002) def test_tick_when_static(self) -> None: time = mock_time(delta=0) compare(time(), expected=978307200.0) time.tick(seconds=1) compare(time(), expected=978307201.0) def test_tick_when_dynamic(self) -> None: # hopefully not that common? time = mock_time() compare(time(), expected=978307200.0) time.tick(seconds=1) compare(time(), expected=978307202.0) def test_tick_with_timedelta_instance(self) -> None: time = mock_time(delta=0) compare(time(), expected=978307200.0) time.tick(timedelta(seconds=1)) compare(time(), expected=978307201.0) def test_old_import(self) -> None: from testfixtures import test_time assert test_time is mock_time def test_addition_to_no_params_call_indirect(self) -> None: mock = mock_time() # Calling the mock without any parameters definitely gives you a float: value = mock() assert isinstance(value, float) compare(value + 3600, expected=978307200.0 + 3600.0) def test_addition_to_no_params_call_direct(self) -> None: mock = mock_time() # ...but if if you do it in one step, mypy gets confused: compare(mock() + 3600, expected=978307200.0 + 3600.0) testfixtures-12.4.0/tests/test_twisted.py000066400000000000000000000217621525773641100206300ustar00rootroot00000000000000import pytest pytest.importorskip("twisted") from twisted.logger import Logger, LogLevel, formatEvent from twisted.python.failure import Failure from twisted.trial.unittest import TestCase from testfixtures import compare, ShouldRaise, TextComparison as S, ShouldAssert from testfixtures import LogCapture from testfixtures.twisted import LogCapture as TwistedLogCapture, INFO, WARN, TwistedSource log = Logger() class TestLogCapture(TestCase): def test_simple(self): capture = TwistedLogCapture.make(self) log.info('er, {greeting}', greeting='hi') capture.check((INFO, 'er, hi')) def test_captured(self): capture = TwistedLogCapture.make(self) log.info('er, {greeting}', greeting='hi') assert len(capture.events) == 1 compare(capture.events[0]['log_namespace'], expected='tests.test_twisted') def test_fields(self): capture = TwistedLogCapture.make(self, fields=('a', 'b')) log.info('{a}, {b}', a=1, b=2) log.info('{a}, {b}', a=3, b=4) capture.check( [1, 2], [3, 4], ) def test_field(self): capture = TwistedLogCapture.make(self, fields=(formatEvent,)) log.info('er, {greeting}', greeting='hi') capture.check('er, hi') def test_check_failure_test_minimal(self): capture = TwistedLogCapture.make(self) try: raise Exception('all gone wrong') except: log.failure('oh dear') capture.check_failure_text('all gone wrong') self.flushLoggedErrors() def test_check_failure_test_maximal(self): capture = TwistedLogCapture.make(self) try: raise TypeError('all gone wrong') except: log.failure('oh dear') log.info("don't look at me...") capture.check_failure_text(str(TypeError), index=0, attribute='type') self.flushLoggedErrors() self.flushLoggedErrors() def test_raise_logged_failure(self): capture = TwistedLogCapture.make(self) try: raise TypeError('all gone wrong') except: log.failure('oh dear') with ShouldRaise(Failure) as s: capture.raise_logged_failure() compare(s.raised.value, expected=TypeError('all gone wrong')) self.flushLoggedErrors() def test_raise_later_logged_failure(self): capture = TwistedLogCapture.make(self) try: raise ValueError('boom!') except: log.failure('oh dear') try: raise TypeError('all gone wrong') except: log.failure('what now?!') with ShouldRaise(Failure) as s: capture.raise_logged_failure(start_index=1) compare(s.raised.value, expected=TypeError('all gone wrong')) self.flushLoggedErrors() def test_order_doesnt_matter_ok(self): capture = TwistedLogCapture.make(self) log.info('Failed to send BAR') log.info('Sent FOO, length 1234') log.info('Sent 1 Messages') capture.check( (INFO, S(r'Sent FOO, length \d+')), (INFO, 'Failed to send BAR'), (INFO, 'Sent 1 Messages'), order_matters=False ) def test_order_doesnt_matter_failure(self): capture = TwistedLogCapture.make(self) log.info('Failed to send BAR') log.info('Sent FOO, length 1234') log.info('Sent 1 Messages') with ShouldAssert( "same:\n" "[(, 'Failed to send BAR'), (, 'Sent 1 Messages')]\n" "\n" "in expected but not actual:\n" "[(, )]\n" "\n" "in actual but not expected:\n" "[(, 'Sent FOO, length 1234')]" ): capture.check( (INFO, S('Sent FOO, length abc')), (INFO, 'Failed to send BAR'), (INFO, 'Sent 1 Messages'), order_matters=False ) def test_order_doesnt_matter_extra_in_expected(self): capture = TwistedLogCapture.make(self) log.info('Failed to send BAR') log.info('Sent FOO, length 1234') with ShouldAssert( "same:\n" "[(, 'Sent FOO, length 1234'),\n" " (, 'Failed to send BAR')]\n" "\n" "in expected but not actual:\n" "[(, 'Sent 1 Messages')]" ): capture.check( (INFO, S('Sent FOO, length 1234')), (INFO, 'Failed to send BAR'), (INFO, 'Sent 1 Messages'), order_matters=False ) def test_order_doesnt_matter_extra_in_actual(self): capture = TwistedLogCapture.make(self) log.info('Failed to send BAR') log.info('Sent 1 Messages') with ShouldAssert( "same:\n" "[(, 'Failed to send BAR'), (, 'Sent 1 Messages')]\n" "\n" "in expected but not actual:\n" "[(, )]" ): capture.check( (INFO, S('Sent FOO, length abc')), (INFO, 'Failed to send BAR'), (INFO, 'Sent 1 Messages'), order_matters=False ) class TestUnifiedLogCapture(TestCase): def test_basic_capture(self): with LogCapture(TwistedSource()) as capture: log.info('hello {name}', name='world') capture.check(('INFO', 'hello world')) def test_check_order_doesnt_matter_ok(self): with LogCapture(TwistedSource()) as capture: log.info('first') log.info('second') capture.check( ('INFO', 'second'), ('INFO', 'first'), order_matters=False, ) def test_check_order_doesnt_matter_failure(self): with LogCapture(TwistedSource()) as capture: log.info('first') log.info('second') with ShouldAssert( "same:\n" "[('INFO', 'second')]\n\n" "in expected but not actual:\n" "[('INFO', 'nope')]\n\n" "in actual but not expected:\n" "[('INFO', 'first')]" ): capture.check( ('INFO', 'second'), ('INFO', 'nope'), order_matters=False, ) def test_raise_first_exception(self): with LogCapture(TwistedSource()) as capture: try: raise ValueError('boom') except Exception: log.failure('oh no') with ShouldRaise(ValueError('boom')): capture.raise_first_exception() self.flushLoggedErrors() def test_raise_first_exception_with_start_index(self): with LogCapture(TwistedSource()) as capture: try: raise ValueError('first') except Exception: log.failure('first failure') try: raise TypeError('second') except Exception: log.failure('second failure') with ShouldRaise(TypeError('second')): capture.raise_first_exception(start_index=1) self.flushLoggedErrors() def test_check_exception_str(self): with LogCapture(TwistedSource()) as capture: try: raise ValueError('expected message') except Exception: log.failure('logged') capture.check_exception_str('expected message') self.flushLoggedErrors() def test_single_field(self): with LogCapture(TwistedSource((formatEvent,))) as capture: log.info('hello {name}', name='world') capture.check('hello world') def test_attributes_bare_string(self): with LogCapture(TwistedSource('log_format')) as capture: log.info('hi there') capture.check('hi there') def test_attributes_single_callable(self): def extract(event): return {'level': event['log_level'].name, 'message': formatEvent(event)} with LogCapture(TwistedSource(extract)) as capture: log.info('started') log.warn('low memory') capture.check( {'level': 'info', 'message': 'started'}, {'level': 'warn', 'message': 'low memory'}, ) def test_level_filtering_int(self): with LogCapture(TwistedSource(level=30)) as capture: log.info('ignored') log.warn('captured') capture.check(("WARN", 'captured')) def test_level_filtering_string(self): with LogCapture(TwistedSource(level='warn')) as capture: log.info('ignored') log.warn('captured') capture.check(("WARN", 'captured')) def test_level_filtering_twisted_level(self): with LogCapture(TwistedSource(level=LogLevel.warn)) as capture: log.info('ignored') log.warn('captured') capture.check(("WARN", 'captured')) testfixtures-12.4.0/tests/test_wrap.py000066400000000000000000000145741525773641100201210ustar00rootroot00000000000000from unittest import TestCase from testfixtures.mock import Mock, MagicMock, patch, DEFAULT from testfixtures import wrap, compare, log_capture, LogCapture class TestWrap(TestCase): def test_wrapping(self): m = Mock() @wrap(m.before, m.after) def test_function(r): m.test() return 'something' compare(m.method_calls, []) compare(test_function(), 'something') compare(m.method_calls, [ ('before', (), {}), ('test', (), {}), ('after', (), {}) ]) def test_wrapping_only_before(self): before = Mock() @wrap(before) def test_function(): return 'something' self.assertFalse(before.called) compare(test_function(), 'something') compare(before.call_count, 1) def test_wrapping_wants_return(self): m = Mock() m.before.return_value = 'something' @wrap(m.before, m.after) def test_function(r): m.test(r) return 'r:'+r compare(m.method_calls, []) compare(test_function(), 'r:something') compare(m.method_calls, [ ('before', (), {}), ('test', ('something', ), {}), ('after', (), {}) ]) def test_wrapping_wants_arguments(self): # This only works in python 2.5+, for # earlier versions, you'll have to come # up with your own `partial` class... from functools import partial m = Mock() @wrap(partial(m.before, 1, x=2), partial(m.after, 3, y=4)) def test_function(r): m.test() return 'something' compare(m.method_calls, []) compare(test_function(), 'something') compare(m.method_calls, [ ('before', (1, ), {'x': 2}), ('test', (), {}), ('after', (3, ), {'y': 4}) ]) def test_multiple_wrappers(self): m = Mock() @wrap(m.before2, m.after2) @wrap(m.before1, m.after1) def test_function(): m.test_function() return 'something' compare(m.method_calls, []) compare(test_function(), 'something') compare(m.method_calls, [ ('before1', (), {}), ('before2', (), {}), ('test_function', (), {}), ('after2', (), {}), ('after1', (), {}), ]) def test_multiple_wrappers_wants_return(self): m = Mock() m.before1.return_value = 1 m.before2.return_value = 2 @wrap(m.before2, m.after2) @wrap(m.before1, m.after1) def test_function(r1, r2): m.test_function(r1, r2) return 'something' compare(m.method_calls, []) compare(test_function(), 'something') compare(m.method_calls, [ ('before1', (), {}), ('before2', (), {}), ('test_function', (1, 2), {}), ('after2', (), {}), ('after1', (), {}), ]) def test_multiple_wrappers_only_want_first_return(self): m = Mock() m.before1.return_value = 1 @wrap(m.before2, m.after2) @wrap(m.before1, m.after1) def test_function(r1): m.test_function(r1) return 'something' compare(m.method_calls, []) compare(test_function(), 'something') compare(m.method_calls, [ ('before1', (), {}), ('before2', (), {}), ('test_function', (1, ), {}), ('after2', (), {}), ('after1', (), {}), ]) def test_wrap_method(self): m = Mock() class T: @wrap(m.before, m.after) def method(self): m.method() T().method() compare(m.method_calls, [ ('before', (), {}), ('method', (), {}), ('after', (), {}) ]) def test_wrap_method_wants_return(self): m = Mock() m.before.return_value = 'return' class T: @wrap(m.before, m.after) def method(self, r): m.method(r) T().method() compare(m.method_calls, [ ('before', (), {}), ('method', ('return', ), {}), ('after', (), {}) ]) def test_wrapping_different_functions(self): m = Mock() @wrap(m.before1, m.after1) def test_function1(): m.something1() return 'something1' @wrap(m.before2, m.after2) def test_function2(): m.something2() return 'something2' compare(m.method_calls, []) compare(test_function1(), 'something1') compare(m.method_calls, [ ('before1', (), {}), ('something1', (), {}), ('after1', (), {}) ]) compare(test_function2(), 'something2') compare(m.method_calls, [ ('before1', (), {}), ('something1', (), {}), ('after1', (), {}), ('before2', (), {}), ('something2', (), {}), ('after2', (), {}) ]) def test_wrapping_local_vars(self): m = Mock() @wrap(m.before, m.after) def test_function(): something = 1 m.test() return 'something' compare(m.method_calls, []) compare(test_function(), 'something') compare(m.method_calls, [ ('before', (), {}), ('test', (), {}), ('after', (), {}) ]) def test_wrapping__name__(self): m = Mock() @wrap(m.before, m.after) def test_function(): pass # pragma: no cover compare(test_function.__name__, 'test_function') def test_our_wrap_dealing_with_mock_patch(self): @patch.multiple('tests.sample1', X=DEFAULT) @log_capture() def patched(log, X): from tests.sample1 import X as imported_X assert isinstance(log, LogCapture) assert isinstance(X, MagicMock) assert imported_X is X patched() def test_patch_with_dict(self): @patch('tests.sample1.X', {'x': 1}) @log_capture() def patched(log): assert isinstance(log, LogCapture) from tests.sample1 import X assert X == {'x': 1} patched()