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 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.
@@ -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
+
@@ -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)})"