Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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 }}

Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 23 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 22 additions & 7 deletions dirty_equals/_base.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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]'

Expand Down
8 changes: 7 additions & 1 deletion dirty_equals/_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 63 additions & 62 deletions dirty_equals/_other.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]):
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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

Expand Down
7 changes: 1 addition & 6 deletions dirty_equals/_strings.py
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
2 changes: 1 addition & 1 deletion dirty_equals/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = '0.7.0'
VERSION = '0.7.1'
4 changes: 2 additions & 2 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading