joythief 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- joythief-0.1.0/LICENSE +13 -0
- joythief-0.1.0/PKG-INFO +33 -0
- joythief-0.1.0/README.md +15 -0
- joythief-0.1.0/pyproject.toml +74 -0
- joythief-0.1.0/src/joythief/__init__.py +0 -0
- joythief-0.1.0/src/joythief/core.py +28 -0
- joythief-0.1.0/src/joythief/numbers.py +35 -0
- joythief-0.1.0/src/joythief/objects.py +45 -0
- joythief-0.1.0/src/joythief/strings.py +142 -0
joythief-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright (c) 2025-present, Jonathan Sharpe
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
4
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
5
|
+
copyright notice and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
8
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
9
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
10
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
11
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
12
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
13
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
joythief-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: joythief
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Comparison is the thief of joy
|
|
5
|
+
License: ISC
|
|
6
|
+
Author: Jonathan Sharpe
|
|
7
|
+
Author-email: mail@jonrshar.pe
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Classifier: License :: OSI Approved
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# JoyThief
|
|
19
|
+
|
|
20
|
+
Comparison is the thief of joy.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
JoyThief provides a collection of matchers which can be used for testing.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from joythief.numbers import NaN
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_my_func_with_no_arguments_returns_nan():
|
|
31
|
+
assert my_func() == NaN()
|
|
32
|
+
```
|
|
33
|
+
|
joythief-0.1.0/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# JoyThief
|
|
2
|
+
|
|
3
|
+
Comparison is the thief of joy.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
JoyThief provides a collection of matchers which can be used for testing.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from joythief.numbers import NaN
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_my_func_with_no_arguments_returns_nan():
|
|
14
|
+
assert my_func() == NaN()
|
|
15
|
+
```
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "joythief"
|
|
3
|
+
# Version is set on publication
|
|
4
|
+
version = "0.1.0"
|
|
5
|
+
description = "Comparison is the thief of joy"
|
|
6
|
+
authors = [
|
|
7
|
+
{name = "Jonathan Sharpe", email = "mail@jonrshar.pe"}
|
|
8
|
+
]
|
|
9
|
+
license = "ISC"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = [
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
17
|
+
build-backend = "poetry.core.masonry.api"
|
|
18
|
+
|
|
19
|
+
[tool.coverage.run]
|
|
20
|
+
omit = ["tests/**"]
|
|
21
|
+
|
|
22
|
+
[tool.coverage.report]
|
|
23
|
+
exclude_also = ["if tp\\.TYPE_CHECKING:"]
|
|
24
|
+
|
|
25
|
+
[tool.isort]
|
|
26
|
+
profile = "black"
|
|
27
|
+
|
|
28
|
+
[tool.mypy]
|
|
29
|
+
strict = true
|
|
30
|
+
|
|
31
|
+
[[tool.mypy.overrides]]
|
|
32
|
+
module = "tests.*"
|
|
33
|
+
disable_error_code = [
|
|
34
|
+
"no-untyped-def"
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.poetry]
|
|
38
|
+
packages = [{include = "joythief", from = "src"}]
|
|
39
|
+
|
|
40
|
+
[tool.poetry.group.deployment]
|
|
41
|
+
optional = true
|
|
42
|
+
|
|
43
|
+
[tool.poetry.group.deployment.dependencies]
|
|
44
|
+
twine = "^6.1.0"
|
|
45
|
+
|
|
46
|
+
[tool.poetry.group.dev.dependencies]
|
|
47
|
+
black = "^25.1.0"
|
|
48
|
+
coverage = "^7.9.2"
|
|
49
|
+
isort = "^6.0.1"
|
|
50
|
+
mypy = "^1.16.1"
|
|
51
|
+
pytest = "^8.4.1"
|
|
52
|
+
tox = "^4.27.0"
|
|
53
|
+
|
|
54
|
+
[tool.poetry.group.docs]
|
|
55
|
+
optional = true
|
|
56
|
+
|
|
57
|
+
[tool.poetry.group.docs.dependencies]
|
|
58
|
+
myst-parser = {version = "^4.0.1", markers = "python_version>='3.11'"}
|
|
59
|
+
sphinx = {version = "^8.2.3", markers = "python_version>='3.11'"}
|
|
60
|
+
furo = {version = "^2024.8.6", markers = "python_version>='3.11'"}
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
pythonpath = ["src"]
|
|
64
|
+
|
|
65
|
+
[tool.tox]
|
|
66
|
+
legacy_tox_ini = """
|
|
67
|
+
[tox]
|
|
68
|
+
envlist = py3{9,10,11,12,13}
|
|
69
|
+
isolated_build = True
|
|
70
|
+
|
|
71
|
+
[testenv]
|
|
72
|
+
deps = pytest >=8.4.1, <9
|
|
73
|
+
commands = pytest
|
|
74
|
+
"""
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing as tp
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
|
|
6
|
+
if tp.TYPE_CHECKING:
|
|
7
|
+
from typing_extensions import TypeAlias
|
|
8
|
+
|
|
9
|
+
T = tp.TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Matcher(tp.Generic[T], ABC):
|
|
13
|
+
"""Defines the core requirements for any matcher.
|
|
14
|
+
|
|
15
|
+
- Must be comparable for equality with anything.
|
|
16
|
+
- Must have a sensible representation.
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def __eq__(self, other: tp.Any) -> bool: ...
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def __repr__(self) -> str: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
MaybeMatcher: TypeAlias = tp.Union[T, Matcher[T]]
|
|
28
|
+
"""Either ``T`` or a matcher of ``T``."""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Matchers for `numeric types`_ (e.g. :py:class:`float`).
|
|
2
|
+
|
|
3
|
+
.. _numeric types: https://docs.python.org/3/library/stdtypes.html#numeric-types-int-float-complex
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
import typing as tp
|
|
9
|
+
from numbers import Real
|
|
10
|
+
|
|
11
|
+
from joythief.core import Matcher
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NaN(Matcher[Real]):
|
|
15
|
+
"""Matches any :py:class:`float` instance representing NaN.
|
|
16
|
+
|
|
17
|
+
`IEEE 754`_ NaN (*"not a number"*) instances are, by definition, not
|
|
18
|
+
equal to each other. This matcher compares equal to e.g.
|
|
19
|
+
:py:data:`math.nan` or :code:`float("nan")` using
|
|
20
|
+
:py:func:`math.isnan`.
|
|
21
|
+
|
|
22
|
+
Originally formulated for `this answer`_.
|
|
23
|
+
|
|
24
|
+
.. _IEEE 754: https://en.wikipedia.org/wiki/IEEE_754
|
|
25
|
+
.. _this answer: https://stackoverflow.com/a/79699116/3001761
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __eq__(self, other: tp.Any) -> bool:
|
|
30
|
+
if not isinstance(other, Real):
|
|
31
|
+
return NotImplemented
|
|
32
|
+
return math.isnan(other)
|
|
33
|
+
|
|
34
|
+
def __repr__(self) -> str:
|
|
35
|
+
return "nan"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Matchers for general object types."""
|
|
2
|
+
|
|
3
|
+
import typing as tp
|
|
4
|
+
|
|
5
|
+
from joythief.core import Matcher
|
|
6
|
+
|
|
7
|
+
T = tp.TypeVar("T")
|
|
8
|
+
|
|
9
|
+
Type = tp.Union[type[T], tuple[type[T], ...]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InstanceOf(Matcher[T]):
|
|
13
|
+
"""Matches any instance of the specified type(s).
|
|
14
|
+
|
|
15
|
+
This matcher compares the received value using
|
|
16
|
+
:py:func:`isinstance`, so accepts either a single type or a tuple
|
|
17
|
+
of types. With :code:`nullable` set to :py:const:`True`, the
|
|
18
|
+
received value can also be :py:const:`None`.
|
|
19
|
+
|
|
20
|
+
Originally formulated for `this answer`_.
|
|
21
|
+
|
|
22
|
+
.. _this answer: https://stackoverflow.com/a/64973325/3001761
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
_nullable: bool
|
|
27
|
+
_type: Type[T]
|
|
28
|
+
|
|
29
|
+
def __init__(self, type_: Type[T], *, nullable: bool = False):
|
|
30
|
+
self._nullable = nullable
|
|
31
|
+
self._type = type_
|
|
32
|
+
|
|
33
|
+
def __eq__(self, other: tp.Any) -> bool:
|
|
34
|
+
type_: tuple[type[tp.Any], ...] = (
|
|
35
|
+
self._type if isinstance(self._type, tuple) else (self._type,)
|
|
36
|
+
)
|
|
37
|
+
if self._nullable:
|
|
38
|
+
type_ = type_ + (type(None),)
|
|
39
|
+
return isinstance(other, type_)
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
return (
|
|
43
|
+
f"InstanceOf({self._type!r}"
|
|
44
|
+
f"{', nullable=True' if self._nullable else ''})"
|
|
45
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Matchers for the `text sequence type`_ (:py:class:`str`).
|
|
2
|
+
|
|
3
|
+
.. _text sequence type: https://docs.python.org/3/library/stdtypes.html#text-sequence-type-str
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import typing as tp
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from urllib.parse import parse_qs, urlparse
|
|
11
|
+
|
|
12
|
+
from joythief.core import Matcher, MaybeMatcher
|
|
13
|
+
from joythief.objects import InstanceOf
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JsonString(Matcher[str]):
|
|
17
|
+
"""Matches any :py:class:`str` instance representing JSON.
|
|
18
|
+
|
|
19
|
+
:param expected: What the result of parsing the JSON should be.
|
|
20
|
+
If omitted, any valid JSON string is matched.
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
__ANYTHING = object()
|
|
25
|
+
|
|
26
|
+
_expected: tp.Any
|
|
27
|
+
|
|
28
|
+
def __init__(self, expected: tp.Any = __ANYTHING):
|
|
29
|
+
self._expected = InstanceOf(object) if expected == self.__ANYTHING else expected
|
|
30
|
+
|
|
31
|
+
def __eq__(self, other: tp.Any) -> bool:
|
|
32
|
+
if not isinstance(other, str):
|
|
33
|
+
return NotImplemented
|
|
34
|
+
try:
|
|
35
|
+
return tp.cast(bool, json.loads(other) == self._expected)
|
|
36
|
+
except json.decoder.JSONDecodeError:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str:
|
|
40
|
+
return f"JsonString({self._expected!r})"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StringMatching(Matcher[str]):
|
|
44
|
+
"""Matches any :py:class:`str` instance matching a regular expression.
|
|
45
|
+
|
|
46
|
+
:param pattern: Regex pattern to match, as a string or compiled pattern.
|
|
47
|
+
|
|
48
|
+
:param flags: Any `flags`_ to compile a :py:class:`str` pattern with
|
|
49
|
+
|
|
50
|
+
:raises ValueError: if flags are provided with a pre-compiled pattern.
|
|
51
|
+
|
|
52
|
+
.. _flags: https://docs.python.org/3/library/re.html#flags
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
_pattern: re.Pattern[str]
|
|
57
|
+
|
|
58
|
+
@tp.overload
|
|
59
|
+
def __init__(self, pattern: re.Pattern[str]): ...
|
|
60
|
+
|
|
61
|
+
@tp.overload
|
|
62
|
+
def __init__(self, pattern: str, *, flags: int = 0): ...
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
pattern: tp.Union[str, re.Pattern[str]],
|
|
67
|
+
*,
|
|
68
|
+
flags: int = 0,
|
|
69
|
+
):
|
|
70
|
+
self._pattern = re.compile(pattern, flags=flags)
|
|
71
|
+
|
|
72
|
+
def __eq__(self, other: tp.Any) -> bool:
|
|
73
|
+
if not isinstance(other, str):
|
|
74
|
+
return NotImplemented
|
|
75
|
+
return self._pattern.match(other) is not None
|
|
76
|
+
|
|
77
|
+
def __repr__(self) -> str:
|
|
78
|
+
return f"StringMatching({self._pattern!r})"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class UrlString(Matcher[str]):
|
|
82
|
+
"""Matches any :py:class:`str` instance representing a URL.
|
|
83
|
+
|
|
84
|
+
The string is parsed with :py:func:`~urllib.parse.urlparse` and
|
|
85
|
+
compared attribute-by-attribute.
|
|
86
|
+
Any attributes not provided are ignored.
|
|
87
|
+
|
|
88
|
+
:param scheme: the scheme (e.g. ``"https"``)
|
|
89
|
+
:param hostname: the hostname (e.g. ``"example.com"``)
|
|
90
|
+
:param path: the path (e.g. ``"/some/path"``)
|
|
91
|
+
:param query: the result of parsing the query string with
|
|
92
|
+
:py:func:`~urllib.parse.parse_qs`
|
|
93
|
+
|
|
94
|
+
:raises TypeError: if no arguments are provided.
|
|
95
|
+
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
_hostname: tp.Optional[MaybeMatcher[str]]
|
|
99
|
+
_path: tp.Optional[MaybeMatcher[str]]
|
|
100
|
+
_query: tp.Optional[Mapping[str, Sequence[str]]]
|
|
101
|
+
_scheme: tp.Optional[MaybeMatcher[str]]
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
scheme: tp.Optional[MaybeMatcher[str]] = None,
|
|
107
|
+
hostname: tp.Optional[MaybeMatcher[str]] = None,
|
|
108
|
+
path: tp.Optional[MaybeMatcher[str]] = None,
|
|
109
|
+
query: tp.Optional[Mapping[str, Sequence[str]]] = None,
|
|
110
|
+
):
|
|
111
|
+
self._hostname = hostname
|
|
112
|
+
self._path = path
|
|
113
|
+
self._query = query
|
|
114
|
+
self._scheme = scheme
|
|
115
|
+
if all(
|
|
116
|
+
getattr(self, attr) is None
|
|
117
|
+
for attr in ["_hostname", "_path", "_query", "_scheme"]
|
|
118
|
+
):
|
|
119
|
+
raise TypeError("A UrlString with no arguments matches any string")
|
|
120
|
+
|
|
121
|
+
def __eq__(self, other: tp.Any) -> bool:
|
|
122
|
+
if not isinstance(other, str):
|
|
123
|
+
return NotImplemented
|
|
124
|
+
parsed = urlparse(other)
|
|
125
|
+
for attribute in ["hostname", "path", "scheme"]:
|
|
126
|
+
if (expected := getattr(self, f"_{attribute}")) is not None and getattr(
|
|
127
|
+
parsed, attribute
|
|
128
|
+
) != expected:
|
|
129
|
+
return False
|
|
130
|
+
if (expected := self._query) is not None and parse_qs(
|
|
131
|
+
parsed.query, keep_blank_values=True
|
|
132
|
+
) != expected:
|
|
133
|
+
return False
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str:
|
|
137
|
+
parameters = [
|
|
138
|
+
f"{name}={value!r}"
|
|
139
|
+
for name in ["scheme", "hostname", "path", "query"]
|
|
140
|
+
if (value := getattr(self, f"_{name}")) is not None
|
|
141
|
+
]
|
|
142
|
+
return f"UrlString({', '.join(parameters)})"
|