Skip to content
Merged
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
12 changes: 5 additions & 7 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']
# 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 @@ -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
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
24 changes: 20 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
121 changes: 64 additions & 57 deletions dirty_equals/_other.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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

Expand Down
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
18 changes: 2 additions & 16 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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
Expand Down
4 changes: 1 addition & 3 deletions requirements/docs.in
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading