diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28b8b8e..b9722f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,15 +16,13 @@ jobs: fail-fast: false matrix: os: [ubuntu, macos] - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] # test pypy on ubuntu only to speed up CI, no reason why macos X pypy should fail separately include: - - os: 'ubuntu' - python-version: 'pypy-3.7' - - os: 'ubuntu' - python-version: 'pypy-3.8' - os: 'ubuntu' python-version: 'pypy-3.9' + - os: 'ubuntu' + python-version: 'pypy-3.10' runs-on: ${{ matrix.os }}-latest @@ -59,7 +57,7 @@ jobs: - uses: actions/setup-python@v4 with: - python-version: '3.10' + python-version: '3.11' - run: pip install -r requirements/linting.txt @@ -75,14 +73,14 @@ jobs: - name: set up python uses: actions/setup-python@v4 with: - python-version: '3.10' + python-version: '3.11' - name: install run: pip install -r requirements/docs.txt - name: install mkdocs-material-insiders if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') - run: pip install https://files.scolvin.com/${MKDOCS_TOKEN}/mkdocs-material/mkdocs_material-9.1.5+insiders.4.32.4-py3-none-any.whl + run: pip install https://files.scolvin.com/${MKDOCS_TOKEN}/mkdocs_material-9.4.2+insiders.4.42.0-py3-none-any.whl env: MKDOCS_TOKEN: ${{ secrets.mkdocs_token }} @@ -166,7 +164,7 @@ jobs: - name: set up python uses: actions/setup-python@v4 with: - python-version: '3.10' + python-version: '3.11' - name: install run: pip install -U build diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86a0a4c..44e9438 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,6 +11,12 @@ repos: - repo: local hooks: + - id: format + name: Format + entry: make format + types: [python] + language: system + pass_filenames: false - id: lint name: Lint entry: make lint diff --git a/Makefile b/Makefile index 9691eb8..56e9d08 100644 --- a/Makefile +++ b/Makefile @@ -3,18 +3,37 @@ sources = dirty_equals tests .PHONY: install install: + pip install -U pip pre-commit pip-tools pip install -r requirements/all.txt pre-commit install +.PHONY: refresh-lockfiles +refresh-lockfiles: + @echo "Replacing requirements/*.txt files using pip-compile" + find requirements/ -name '*.txt' ! -name 'all.txt' -type f -delete + make update-lockfiles + +.PHONY: update-lockfiles +update-lockfiles: + @echo "Updating requirements/*.txt files using pip-compile" + pip-compile -q -o requirements/linting.txt requirements/linting.in + pip-compile -q -o requirements/tests.txt -c requirements/linting.txt requirements/tests.in + pip-compile -q -o requirements/docs.txt -c requirements/linting.txt -c requirements/tests.txt requirements/docs.in + pip-compile -q -o requirements/pyproject.txt \ + --extra pydantic \ + -c requirements/linting.txt -c requirements/tests.txt -c requirements/docs.txt \ + pyproject.toml + pip install --dry-run -r requirements/all.txt + .PHONY: format format: - black $(sources) - ruff --fix $(sources) + ruff check --fix-only $(sources) + ruff format $(sources) .PHONY: lint lint: - ruff $(sources) - black $(sources) --check --diff + ruff check $(sources) + ruff format --check $(sources) .PHONY: test test: diff --git a/dirty_equals/_base.py b/dirty_equals/_base.py index 6138cc5..99c32af 100644 --- a/dirty_equals/_base.py +++ b/dirty_equals/_base.py @@ -1,11 +1,6 @@ from abc import ABCMeta -from typing import TYPE_CHECKING, Any, Dict, Generic, Iterable, Optional, Tuple, TypeVar - -try: - from typing import Protocol -except ImportError: - # Python 3.7 doesn't have Protocol - Protocol = object # type: ignore[assignment] +from pprint import PrettyPrinter +from typing import TYPE_CHECKING, Any, Dict, Generic, Iterable, Optional, Protocol, Tuple, TypeVar from ._utils import Omit @@ -137,6 +132,26 @@ def __repr__(self) -> str: # else return something which explains what's going on. return self._repr_ne() + def _pprint_format(self, pprinter: PrettyPrinter, *args: Any, **kwargs: Any) -> str: + # pytest diffs use pprint to format objects, so we patch pprint to call this method + # for DirtyEquals objects. So this method needs to follow the same pattern as __repr__. + # We check that the protected _format method actually exists + # to be safe and to make linters happy. + if self._was_equal and hasattr(pprinter, '_format'): + return pprinter._format(self._other, *args, **kwargs) + else: + return repr(self) # i.e. self._repr_ne() (for now) + + +# Patch pprint to call _pprint_format for DirtyEquals objects +# Check that the protected attribute _dispatch exists to be safe and to make linters happy. +# The reason we modify _dispatch rather than _format +# is that pytest sometimes uses a subclass of PrettyPrinter which overrides _format. +if hasattr(PrettyPrinter, '_dispatch'): + PrettyPrinter._dispatch[DirtyEquals.__repr__] = lambda pprinter, obj, *args, **kwargs: obj._pprint_format( + pprinter, *args, **kwargs + ) + InstanceOrType: 'TypeAlias' = 'Union[DirtyEquals[Any], DirtyEqualsMeta]' diff --git a/dirty_equals/_datetime.py b/dirty_equals/_datetime.py index 6337453..b4d6f8f 100644 --- a/dirty_equals/_datetime.py +++ b/dirty_equals/_datetime.py @@ -184,7 +184,13 @@ def _get_now(self) -> datetime: if self.tz is None: return datetime.now() else: - return datetime.utcnow().replace(tzinfo=timezone.utc).astimezone(self.tz) + try: + from datetime import UTC + + utc_now = datetime.now(UTC).replace(tzinfo=timezone.utc) + except ImportError: + utc_now = datetime.utcnow().replace(tzinfo=timezone.utc) + return utc_now.astimezone(self.tz) def prepare(self, other: Any) -> datetime: # update approx for every comparing, to check if other value is dirty equal diff --git a/dirty_equals/_other.py b/dirty_equals/_other.py index 7da1904..f61c12c 100644 --- a/dirty_equals/_other.py +++ b/dirty_equals/_other.py @@ -4,18 +4,17 @@ import re from dataclasses import asdict, is_dataclass from enum import Enum +from functools import lru_cache from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_network -from typing import Any, Callable, TypeVar, Union, overload +from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar, Union, overload from uuid import UUID from ._base import DirtyEquals from ._dict import IsDict from ._utils import Omit, plain_repr -try: - from typing import Literal -except ImportError: - from typing_extensions import Literal # type: ignore[assignment] +if TYPE_CHECKING: + from pydantic import TypeAdapter class IsUUID(DirtyEquals[UUID]): @@ -155,25 +154,33 @@ def equals(self, other: Any) -> bool: return self.func(other) -class IsUrl(DirtyEquals[str]): +T = TypeVar('T') + + +@lru_cache() +def _build_type_adapter(ta: type[TypeAdapter[T]], schema: T) -> TypeAdapter[T]: + return ta(schema) + + +_allowed_url_attribute_checks: set[str] = { + 'scheme', + 'host', + 'host_type', + 'user', + 'password', + 'port', + 'path', + 'query', + 'fragment', +} + + +class IsUrl(DirtyEquals[Any]): """ A class that checks if a value is a valid URL, optionally checking different URL types and attributes with [Pydantic](https://pydantic-docs.helpmanual.io/usage/types/#urls). """ - allowed_attribute_checks: set[str] = { - 'scheme', - 'host', - 'host_type', - 'user', - 'password', - 'tld', - 'port', - 'path', - 'query', - 'fragment', - } - def __init__( self, any_url: bool = False, @@ -187,19 +194,19 @@ def __init__( ): """ Args: - any_url: any scheme allowed, TLD not required, host required - any_http_url: scheme http or https, TLD not required, host required - http_url: scheme http or https, TLD required, host required, max length 2083 + any_url: any scheme allowed, host required + any_http_url: scheme http or https, host required + http_url: scheme http or https, host required, max length 2083 file_url: scheme file, host not required - postgres_dsn: user info required, TLD not required - ampqp_dsn: schema amqp or amqps, user info not required, TLD not required, host not required - redis_dsn: scheme redis or rediss, user info not required, tld not required, host not required + postgres_dsn: user info required + ampqp_dsn: schema amqp or amqps, user info not required, host not required + redis_dsn: scheme redis or rediss, user info not required, host not required **expected_attributes: Expected values for url attributes ```py title="IsUrl" from dirty_equals import IsUrl assert 'https://example.com' == IsUrl - assert 'https://example.com' == IsUrl(tld='com') + assert 'https://example.com' == IsUrl(host='example.com') assert 'https://example.com' == IsUrl(scheme='https') assert 'https://example.com' != IsUrl(scheme='http') assert 'postgres://user:pass@localhost:5432/app' == IsUrl(postgres_dsn=True) @@ -215,66 +222,60 @@ def __init__( HttpUrl, PostgresDsn, RedisDsn, + TypeAdapter, ValidationError, - parse_obj_as, - version, ) - self.AmqpDsn = AmqpDsn - self.AnyHttpUrl = AnyHttpUrl - self.AnyUrl = AnyUrl - self.FileUrl = FileUrl - self.HttpUrl = HttpUrl - self.PostgresDsn = PostgresDsn - self.RedisDsn = RedisDsn - self.parse_obj_as = parse_obj_as self.ValidationError = ValidationError - self.pydantic_version = tuple(map(int, version.VERSION.split('.'))) - - except ImportError as e: - raise ImportError('pydantic is not installed, run `pip install dirty-equals[pydantic]`') from e + except ImportError as e: # pragma: no cover + raise ImportError('Pydantic V2 is not installed, run `pip install dirty-equals[pydantic]`') from e url_type_mappings = { - self.AnyUrl: any_url, - self.AnyHttpUrl: any_http_url, - self.HttpUrl: http_url, - self.FileUrl: file_url, - self.PostgresDsn: postgres_dsn, - self.AmqpDsn: ampqp_dsn, - self.RedisDsn: redis_dsn, + AnyUrl: any_url, + AnyHttpUrl: any_http_url, + HttpUrl: http_url, + FileUrl: file_url, + PostgresDsn: postgres_dsn, + AmqpDsn: ampqp_dsn, + RedisDsn: redis_dsn, } url_types_sum = sum(url_type_mappings.values()) - if url_types_sum > 1: + if url_types_sum == 0: + url_type: Any = AnyUrl + elif url_types_sum == 1: + url_type = max(url_type_mappings, key=url_type_mappings.get) # type: ignore[arg-type] + else: raise ValueError('You can only check against one Pydantic url type at a time') + + self.type_adapter = _build_type_adapter(TypeAdapter, url_type) + for item in expected_attributes: - if item not in self.allowed_attribute_checks: + if item not in _allowed_url_attribute_checks: raise TypeError( - 'IsURL only checks these attributes: scheme, host, host_type, user, password, tld, ' + 'IsURL only checks these attributes: scheme, host, host_type, user, password, ' 'port, path, query, fragment' ) self.attribute_checks = expected_attributes - if url_types_sum == 0: - url_type = AnyUrl - else: - url_type = max(url_type_mappings, key=url_type_mappings.get) # type: ignore[arg-type] - self.url_type = url_type - super().__init__(url_type) + super().__init__() def equals(self, other: Any) -> bool: try: - parsed = self.parse_obj_as(self.url_type, other) + other_url = self.type_adapter.validate_python(other) except self.ValidationError: raise ValueError('Invalid URL') - if self.pydantic_version[0] == 1: # checking major version - equal = parsed == other - else: - equal = parsed.unicode_string() == other + # we now check that str() of the parsed URL equals its original value + # so that invalid encodings fail + # we remove trailing slashes since they're added by pydantic's URL parsing, but don't mean `other` is invalid + other_url_str = str(other_url) + if not other.endswith('/') and other_url_str.endswith('/'): + other_url_str = other_url_str[:-1] + equal = other_url_str == other if not self.attribute_checks: return equal for attribute, expected in self.attribute_checks.items(): - if getattr(parsed, attribute) != expected: + if getattr(other_url, attribute) != expected: return False return equal diff --git a/dirty_equals/_strings.py b/dirty_equals/_strings.py index 11be92e..ed7d940 100644 --- a/dirty_equals/_strings.py +++ b/dirty_equals/_strings.py @@ -1,14 +1,9 @@ import re -from typing import Any, Optional, Pattern, Tuple, Type, TypeVar, Union +from typing import Any, Literal, Optional, Pattern, Tuple, Type, TypeVar, Union from ._base import DirtyEquals from ._utils import Omit, plain_repr -try: - from typing import Literal -except ImportError: - from typing_extensions import Literal # type: ignore[assignment] - T = TypeVar('T', str, bytes) __all__ = 'IsStr', 'IsBytes', 'IsAnyStr' diff --git a/dirty_equals/version.py b/dirty_equals/version.py index 2810d0a..7c63695 100644 --- a/dirty_equals/version.py +++ b/dirty_equals/version.py @@ -1 +1 @@ -VERSION = '0.7.0' +VERSION = '0.7.1' diff --git a/mkdocs.yml b/mkdocs.yml index 6209823..6da8b25 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,8 +59,8 @@ markdown_extensions: - attr_list - md_in_html - pymdownx.emoji: - emoji_index: !!python/name:materialx.emoji.twemoji - emoji_generator: !!python/name:materialx.emoji.to_svg + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg extra: version: diff --git a/pyproject.toml b/pyproject.toml index 1b8ff7a..d839594 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ path = 'dirty_equals/version.py' name = 'dirty-equals' description = 'Doing dirty (but extremely useful) things with equals.' authors = [{name = 'Samuel Colvin', email = 's@muelcolvin.com'}] -license = {file = 'LICENSE'} +license = 'MIT' readme = 'README.md' classifiers = [ 'Development Status :: 4 - Beta', @@ -25,11 +25,11 @@ classifiers = [ 'Environment :: MacOS X', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Internet', 'Typing :: Typed', @@ -39,7 +39,7 @@ dependencies = [ 'typing-extensions>=4.0.1;python_version<"3.8"', 'pytz>=2021.3', ] -optional-dependencies = {pydantic = ['pydantic>=1.9.1'] } +optional-dependencies = {pydantic = ['pydantic>=2.4.2'] } dynamic = ['version'] [project.urls] @@ -55,6 +55,7 @@ extend-select = ['Q', 'RUF100', 'C90', 'UP', 'I'] flake8-quotes = {inline-quotes = 'single', multiline-quotes = 'double'} mccabe = { max-complexity = 14 } isort = { known-first-party = ['tests'] } +format.quote-style = 'single' target-version = 'py37' [tool.pytest.ini_options] @@ -75,20 +76,6 @@ exclude_lines = [ "@overload", ] -[tool.black] -color = true -line-length = 120 -target-version = ["py39"] -skip-string-normalization = true - -[tool.isort] -line_length = 120 -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -combine_as_imports = true -color_output = true - [tool.mypy] strict = true warn_return_any = false diff --git a/requirements/docs.in b/requirements/docs.in index b5f7e63..1751abe 100644 --- a/requirements/docs.in +++ b/requirements/docs.in @@ -1,6 +1,4 @@ -black -# waiting for https://github.com/jimporter/mike/issues/154 -git+https://github.com/jimporter/mike.git +mike mkdocs mkdocs-material mkdocs-simple-hooks diff --git a/requirements/docs.txt b/requirements/docs.txt index be73f8f..3f7f4f7 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,18 +1,18 @@ # -# This file is autogenerated by pip-compile with python 3.10 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: # -# pip-compile --output-file=requirements/docs.txt requirements/docs.in +# pip-compile --constraint=requirements/linting.txt --constraint=requirements/tests.txt --output-file=requirements/docs.txt requirements/docs.in # -black==23.3.0 - # via -r requirements/docs.in -certifi==2022.12.7 +babel==2.13.1 + # via mkdocs-material +certifi==2023.7.22 # via requests -charset-normalizer==3.1.0 +charset-normalizer==3.3.2 # via requests -click==8.1.3 +click==8.1.7 # via - # black + # -c requirements/tests.txt # mkdocs colorama==0.4.6 # via @@ -20,13 +20,13 @@ colorama==0.4.6 # mkdocs-material ghp-import==2.1.0 # via mkdocs -griffe==0.27.1 +griffe==0.38.0 # via mkdocstrings-python idna==3.4 # via requests -importlib-metadata==6.6.0 +importlib-metadata==6.8.0 # via mike -importlib-resources==5.12.0 +importlib-resources==6.1.1 # via mike jinja2==3.1.2 # via @@ -34,22 +34,23 @@ jinja2==3.1.2 # mkdocs # mkdocs-material # mkdocstrings -markdown==3.3.7 +markdown==3.5.1 # via # mkdocs # mkdocs-autorefs # mkdocs-material # mkdocstrings # pymdown-extensions -markupsafe==2.1.2 +markupsafe==2.1.3 # via # jinja2 + # mkdocs # mkdocstrings mergedeep==1.3.4 # via mkdocs -mike @ git+https://github.com/jimporter/mike.git +mike==2.0.0 # via -r requirements/docs.in -mkdocs==1.4.2 +mkdocs==1.5.3 # via # -r requirements/docs.in # mike @@ -57,39 +58,47 @@ mkdocs==1.4.2 # mkdocs-material # mkdocs-simple-hooks # mkdocstrings -mkdocs-autorefs==0.4.1 +mkdocs-autorefs==0.5.0 # via mkdocstrings -mkdocs-material==9.1.8 +mkdocs-material==9.4.8 # via -r requirements/docs.in -mkdocs-material-extensions==1.1.1 +mkdocs-material-extensions==1.3 # via mkdocs-material mkdocs-simple-hooks==0.1.5 # via -r requirements/docs.in -mkdocstrings[python]==0.21.2 +mkdocstrings[python]==0.23.0 # via # -r requirements/docs.in # mkdocstrings-python -mkdocstrings-python==0.9.0 +mkdocstrings-python==1.7.4 # via mkdocstrings -mypy-extensions==1.0.0 - # via black -packaging==23.1 +packaging==23.2 # via - # black + # -c requirements/tests.txt # mkdocs -pathspec==0.11.1 - # via black -platformdirs==3.5.0 - # via black -pygments==2.15.1 +paginate==0.5.6 # via mkdocs-material -pymdown-extensions==9.11 +pathspec==0.11.2 + # via + # -c requirements/tests.txt + # mkdocs +platformdirs==4.0.0 + # via + # -c requirements/tests.txt + # mkdocs +pygments==2.16.1 + # via + # -c requirements/tests.txt + # mkdocs-material +pymdown-extensions==10.4 # via # mkdocs-material # mkdocstrings +pyparsing==3.1.1 + # via mike python-dateutil==2.8.2 # via ghp-import -pyyaml==6.0 +pyyaml==6.0.1 # via # mike # mkdocs @@ -97,19 +106,17 @@ pyyaml==6.0 # pyyaml-env-tag pyyaml-env-tag==0.1 # via mkdocs -regex==2023.3.23 +regex==2023.10.3 # via mkdocs-material -requests==2.29.0 +requests==2.31.0 # via mkdocs-material six==1.16.0 # via python-dateutil -tomli==2.0.1 - # via black -urllib3==1.26.15 +urllib3==2.1.0 # via requests verspec==0.1.0 # via mike watchdog==3.0.0 # via mkdocs -zipp==3.15.0 +zipp==3.17.0 # via importlib-metadata diff --git a/requirements/linting.in b/requirements/linting.in index f6decdc..c8cf998 100644 --- a/requirements/linting.in +++ b/requirements/linting.in @@ -1,6 +1,4 @@ -black mypy -pre-commit pydantic ruff types-pytz diff --git a/requirements/linting.txt b/requirements/linting.txt index 21938bf..6735ae3 100644 --- a/requirements/linting.txt +++ b/requirements/linting.txt @@ -1,57 +1,25 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # pip-compile --output-file=requirements/linting.txt requirements/linting.in # -black==23.3.0 - # via -r requirements/linting.in -cfgv==3.3.1 - # via pre-commit -click==8.1.3 - # via black -distlib==0.3.6 - # via virtualenv -filelock==3.12.0 - # via virtualenv -identify==2.5.23 - # via pre-commit -mypy==1.2.0 +annotated-types==0.6.0 + # via pydantic +mypy==1.7.0 # via -r requirements/linting.in mypy-extensions==1.0.0 - # via - # black - # mypy -nodeenv==1.7.0 - # via pre-commit -packaging==23.1 - # via black -pathspec==0.11.1 - # via black -platformdirs==3.5.0 - # via - # black - # virtualenv -pre-commit==3.2.2 + # via mypy +pydantic==2.4.2 # via -r requirements/linting.in -pydantic==1.10.7 +pydantic-core==2.10.1 + # via pydantic +ruff==0.1.5 # via -r requirements/linting.in -pyyaml==6.0 - # via pre-commit -ruff==0.0.263 - # via -r requirements/linting.in -tomli==2.0.1 - # via - # black - # mypy -types-pytz==2023.3.0.0 +types-pytz==2023.3.1.1 # via -r requirements/linting.in -typing-extensions==4.5.0 +typing-extensions==4.8.0 # via # mypy # pydantic -virtualenv==20.22.0 - # via pre-commit - -# The following packages are considered to be unsafe in a requirements file: -# setuptools + # pydantic-core diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt index ded8abb..f8be50d 100644 --- a/requirements/pyproject.txt +++ b/requirements/pyproject.txt @@ -1,12 +1,25 @@ # -# This file is autogenerated by pip-compile with python 3.10 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: # -# pip-compile --extra=pydantic --output-file=requirements/pyproject.txt pyproject.toml +# pip-compile --constraint=requirements/docs.txt --constraint=requirements/linting.txt --constraint=requirements/tests.txt --extra=pydantic --output-file=requirements/pyproject.txt pyproject.toml # -pydantic==1.10.7 +annotated-types==0.6.0 + # via + # -c requirements/linting.txt + # pydantic +pydantic==2.4.2 + # via + # -c requirements/linting.txt + # dirty-equals (pyproject.toml) +pydantic-core==2.10.1 + # via + # -c requirements/linting.txt + # pydantic +pytz==2023.3.post1 # via dirty-equals (pyproject.toml) -pytz==2022.2.1 - # via dirty-equals (pyproject.toml) -typing-extensions==4.5.0 - # via pydantic +typing-extensions==4.8.0 + # via + # -c requirements/linting.txt + # pydantic + # pydantic-core diff --git a/requirements/tests.txt b/requirements/tests.txt index 5088750..77154df 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,56 +1,53 @@ # -# This file is autogenerated by pip-compile with python 3.10 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: # -# pip-compile --output-file=requirements/tests.txt requirements/tests.in +# pip-compile --constraint=requirements/linting.txt --output-file=requirements/tests.txt requirements/tests.in # -black==23.3.0 +black==23.11.0 # via pytest-examples -click==8.1.3 +click==8.1.7 # via black -coverage[toml]==7.2.3 +coverage[toml]==7.3.2 # via -r requirements/tests.in -exceptiongroup==1.1.1 - # via pytest iniconfig==2.0.0 # via pytest -markdown-it-py==2.2.0 +markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py mypy-extensions==1.0.0 - # via black -packaging==23.1 + # via + # -c requirements/linting.txt + # black +packaging==23.2 # via # -r requirements/tests.in # black # pytest -pathspec==0.11.1 +pathspec==0.11.2 # via black -platformdirs==3.5.0 +platformdirs==4.0.0 # via black -pluggy==1.0.0 +pluggy==1.3.0 # via pytest -pygments==2.15.1 +pygments==2.16.1 # via rich -pytest==7.3.1 +pytest==7.4.3 # via # -r requirements/tests.in # pytest-examples # pytest-mock # pytest-pretty -pytest-examples==0.0.8 +pytest-examples==0.0.10 # via -r requirements/tests.in -pytest-mock==3.10.0 +pytest-mock==3.12.0 # via -r requirements/tests.in pytest-pretty==1.2.0 # via -r requirements/tests.in -rich==13.3.4 +rich==13.6.0 # via pytest-pretty -ruff==0.0.263 - # via pytest-examples -tomli==2.0.1 +ruff==0.1.5 # via - # black - # coverage - # pytest + # -c requirements/linting.txt + # pytest-examples diff --git a/tests/test_base.py b/tests/test_base.py index 3fc519d..3839291 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,9 +1,10 @@ import platform +import pprint import packaging.version import pytest -from dirty_equals import Contains, IsApprox, IsInt, IsNegative, IsOneOf, IsPositive, IsStr +from dirty_equals import Contains, IsApprox, IsInt, IsList, IsNegative, IsOneOf, IsPositive, IsStr from dirty_equals.version import VERSION @@ -39,8 +40,7 @@ def test_value_eq(): v.value assert 'foo' == v - assert str(v) == "'foo'" - assert repr(v) == "'foo'" + assert repr(v) == str(v) == "'foo'" == pprint.pformat(v) assert v.value == 'foo' @@ -50,8 +50,7 @@ def test_value_ne(): with pytest.raises(AssertionError): assert 1 == v - assert str(v) == 'IsStr()' - assert repr(v) == 'IsStr()' + assert repr(v) == str(v) == 'IsStr()' == pprint.pformat(v) with pytest.raises(AttributeError, match='value is not available until __eq__ has been called'): v.value @@ -110,7 +109,7 @@ def test_repr(): ], ) def test_repr_class(v, v_repr): - assert repr(v) == v_repr + assert repr(v) == str(v) == v_repr == pprint.pformat(v) def test_is_approx_without_init(): @@ -119,11 +118,46 @@ def test_is_approx_without_init(): def test_ne_repr(): v = IsInt - assert repr(v) == 'IsInt' + assert repr(v) == str(v) == 'IsInt' == pprint.pformat(v) assert 'x' != v - assert repr(v) == 'IsInt' + assert repr(v) == str(v) == 'IsInt' == pprint.pformat(v) + + +def test_pprint(): + v = [IsList(length=...), 1, [IsList(length=...), 2], 3, IsInt()] + lorem = ['lorem', 'ipsum', 'dolor', 'sit', 'amet'] * 2 + with pytest.raises(AssertionError): + assert [lorem, 1, [lorem, 2], 3, '4'] == v + + assert repr(v) == (f'[{lorem}, 1, [{lorem}, 2], 3, IsInt()]') + assert pprint.pformat(v) == ( + "[['lorem',\n" + " 'ipsum',\n" + " 'dolor',\n" + " 'sit',\n" + " 'amet',\n" + " 'lorem',\n" + " 'ipsum',\n" + " 'dolor',\n" + " 'sit',\n" + " 'amet'],\n" + ' 1,\n' + " [['lorem',\n" + " 'ipsum',\n" + " 'dolor',\n" + " 'sit',\n" + " 'amet',\n" + " 'lorem',\n" + " 'ipsum',\n" + " 'dolor',\n" + " 'sit',\n" + " 'amet'],\n" + ' 2],\n' + ' 3,\n' + ' IsInt()]' + ) @pytest.mark.parametrize( diff --git a/tests/test_datetime.py b/tests/test_datetime.py index 2d5d524..ad1b21a 100644 --- a/tests/test_datetime.py +++ b/tests/test_datetime.py @@ -99,7 +99,13 @@ def test_repr(): def test_is_now_tz(): - now_ny = datetime.utcnow().replace(tzinfo=timezone.utc).astimezone(pytz.timezone('America/New_York')) + try: + from datetime import UTC + + utc_now = datetime.now(UTC).replace(tzinfo=timezone.utc) + except ImportError: + utc_now = datetime.utcnow().replace(tzinfo=timezone.utc) + now_ny = utc_now.astimezone(pytz.timezone('America/New_York')) assert now_ny == IsNow(tz='America/New_York') # depends on the time of year and DST assert now_ny == IsNow(tz=timezone(timedelta(hours=-5))) | IsNow(tz=timezone(timedelta(hours=-4))) @@ -111,7 +117,6 @@ def test_is_now_tz(): assert now.isoformat() == IsNow(iso_string=True) assert now.isoformat() != IsNow - utc_now = datetime.utcnow().replace(tzinfo=timezone.utc) assert utc_now == IsNow(tz=timezone.utc) diff --git a/tests/test_docs.py b/tests/test_docs.py index 72ea8e6..4351ea6 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,4 +1,5 @@ import platform +import sys from pathlib import Path import pytest @@ -7,12 +8,13 @@ root_dir = Path(__file__).parent.parent examples = find_examples( - str(root_dir / 'dirty_equals'), - str(root_dir / 'docs'), + root_dir / 'dirty_equals', + root_dir / 'docs', ) @pytest.mark.skipif(platform.python_implementation() == 'PyPy', reason='PyPy does not allow metaclass dunder methods') +@pytest.mark.skipif(sys.version_info >= (3, 12), reason="pytest-examples doesn't yet support 3.12") @pytest.mark.parametrize('example', examples, ids=str) def test_docstrings(example: CodeExample, eval_example: EvalExample): prefix_settings = example.prefix_settings() @@ -20,14 +22,14 @@ def test_docstrings(example: CodeExample, eval_example: EvalExample): # I001 refers is a problem with black and ruff disagreeing about blank lines :shrug: eval_example.set_config(ruff_ignore=['E711', 'E712', 'I001']) - if prefix_settings.get('lint') != 'skip': - if eval_example.update_examples: - eval_example.format(example) - else: - eval_example.lint(example) - if prefix_settings.get('test') != 'skip': if eval_example.update_examples: eval_example.run_print_update(example) else: eval_example.run_print_check(example) + + if prefix_settings.get('lint') != 'skip': + if eval_example.update_examples: + eval_example.format(example) + else: + eval_example.lint(example) diff --git a/tests/test_other.py b/tests/test_other.py index 00ede34..204c4fe 100644 --- a/tests/test_other.py +++ b/tests/test_other.py @@ -300,7 +300,7 @@ def test_is_url_false(other, dirty): def test_is_url_invalid_kwargs(): with pytest.raises( TypeError, - match='IsURL only checks these attributes: scheme, host, host_type, user, password, tld, port, path, query, ' + match='IsURL only checks these attributes: scheme, host, host_type, user, password, port, path, query, ' 'fragment', ): IsUrl(https=True)