diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 503e932..98fec81 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'] # 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 @@ -75,7 +73,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 -r requirements/docs.txt @@ -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..ab1c07e 100644 --- a/Makefile +++ b/Makefile @@ -3,18 +3,34 @@ 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 requirements/tests.in + pip-compile -q -o requirements/docs.txt requirements/docs.in + pip-compile -q -o requirements/pyproject.txt --extra pydantic 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/_other.py b/dirty_equals/_other.py index 7da1904..07c880c 100644 --- a/dirty_equals/_other.py +++ b/dirty_equals/_other.py @@ -4,8 +4,9 @@ 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, TypeVar, Union, overload from uuid import UUID from ._base import DirtyEquals @@ -18,6 +19,10 @@ from typing_extensions import Literal # type: ignore[assignment] +if TYPE_CHECKING: + from pydantic import TypeAdapter + + class IsUUID(DirtyEquals[UUID]): """ A class that checks if a value is a valid UUID, optionally checking UUID version. @@ -155,25 +160,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 +200,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 +228,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 + 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/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 04716bb..5bfee65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ 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', @@ -39,7 +38,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 +54,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 +75,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..29f84b2 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,32 +1,30 @@ # -# 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 # -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 - # via - # black - # mkdocs +click==8.1.7 + # via mkdocs colorama==0.4.6 # via # griffe # 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 +32,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 +56,39 @@ 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 - # via - # black - # mkdocs -pathspec==0.11.1 - # via black -platformdirs==3.5.0 - # via black -pygments==2.15.1 +packaging==23.2 + # via mkdocs +paginate==0.5.6 + # via mkdocs-material +pathspec==0.11.2 + # via mkdocs +platformdirs==4.0.0 + # via mkdocs +pygments==2.16.1 # via mkdocs-material -pymdown-extensions==9.11 +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 +96,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..48db362 100644 --- a/requirements/pyproject.txt +++ b/requirements/pyproject.txt @@ -1,12 +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 --extra=pydantic --output-file=requirements/pyproject.txt pyproject.toml # -pydantic==1.10.7 - # via dirty-equals (pyproject.toml) -pytz==2022.2.1 +annotated-types==0.6.0 + # via pydantic +pydantic==2.4.2 # via dirty-equals (pyproject.toml) -typing-extensions==4.5.0 +pydantic-core==2.10.1 # via pydantic +pytz==2023.3.post1 + # via dirty-equals (pyproject.toml) +typing-extensions==4.8.0 + # via + # pydantic + # pydantic-core diff --git a/requirements/tests.txt b/requirements/tests.txt index ac3bb23..4665a14 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,39 +1,37 @@ # -# 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 # -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 +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 @@ -41,16 +39,11 @@ pytest==7.3.1 # pytest-pretty 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 +ruff==0.1.5 # via pytest-examples -tomli==2.0.1 - # via - # black - # coverage - # pytest 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)