assertpy 2.35.0__py3-none-any.whl
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.
- assertpy/__init__.py +87 -0
- assertpy/_clustering.py +511 -0
- assertpy/_dangling.py +505 -0
- assertpy/_engine/__init__.py +1 -0
- assertpy/_engine/_builder_check_typing.py +1365 -0
- assertpy/_engine/_capable_typing.py +772 -0
- assertpy/_engine/_check_typing.py +753 -0
- assertpy/_engine/_compare.py +983 -0
- assertpy/_engine/_compat.py +38 -0
- assertpy/_engine/_contract.py +1926 -0
- assertpy/_engine/_diff.py +1058 -0
- assertpy/_engine/_equality.py +843 -0
- assertpy/_engine/_introspection.py +729 -0
- assertpy/_engine/_membership.py +522 -0
- assertpy/_engine/_mixin_base.py +258 -0
- assertpy/_engine/_negated_typing.py +2176 -0
- assertpy/_engine/_operations.py +75 -0
- assertpy/_engine/_ordering.py +884 -0
- assertpy/_engine/_pairing.py +70 -0
- assertpy/_engine/_path.py +158 -0
- assertpy/_engine/_poll_typing.py +6039 -0
- assertpy/_engine/_require.py +312 -0
- assertpy/_engine/_size.py +31 -0
- assertpy/_engine/_text.py +39 -0
- assertpy/_engine/_typing.py +1226 -0
- assertpy/_hints.py +1213 -0
- assertpy/_inline.py +484 -0
- assertpy/_matcher_impls.py +1876 -0
- assertpy/_passes.py +249 -0
- assertpy/_renamed.py +64 -0
- assertpy/_satisfies.py +835 -0
- assertpy/_snapshot_codec.py +290 -0
- assertpy/assertpy.py +2640 -0
- assertpy/async_assertions.py +864 -0
- assertpy/base.py +862 -0
- assertpy/behave_matchers.py +96 -0
- assertpy/bytes_mixin.py +158 -0
- assertpy/collection.py +528 -0
- assertpy/contains.py +1068 -0
- assertpy/dataframe.py +200 -0
- assertpy/date.py +327 -0
- assertpy/dict.py +249 -0
- assertpy/dynamic.py +222 -0
- assertpy/errors.py +782 -0
- assertpy/exception.py +838 -0
- assertpy/extracting.py +266 -0
- assertpy/file.py +324 -0
- assertpy/helpers.py +747 -0
- assertpy/http_mixin.py +218 -0
- assertpy/json_mixin.py +1265 -0
- assertpy/matchers.py +639 -0
- assertpy/numeric.py +696 -0
- assertpy/outcome.py +163 -0
- assertpy/py.typed +0 -0
- assertpy/pytest_plugin.py +1650 -0
- assertpy/snapshot.py +1031 -0
- assertpy/string.py +803 -0
- assertpy/warning.py +137 -0
- assertpy-2.35.0.dist-info/METADATA +324 -0
- assertpy-2.35.0.dist-info/RECORD +63 -0
- assertpy-2.35.0.dist-info/WHEEL +4 -0
- assertpy-2.35.0.dist-info/entry_points.txt +2 -0
- assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
assertpy/extracting.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import collections.abc
|
|
4
|
+
import difflib
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import TYPE_CHECKING, cast
|
|
7
|
+
|
|
8
|
+
from ._engine._equality import mapping_shaped
|
|
9
|
+
from ._engine._introspection import is_model_dump_object, is_namedtuple
|
|
10
|
+
from ._engine._mixin_base import _MixinBase
|
|
11
|
+
from ._engine._require import argument, refuse, reject_unknown_kwargs, require_type, verdict
|
|
12
|
+
|
|
13
|
+
_EXTRACTING_OPTIONS = frozenset({"filter", "sort"})
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
from ._engine._compat import Self
|
|
19
|
+
from ._engine._introspection import MappingLike
|
|
20
|
+
|
|
21
|
+
__tracebackhide__ = True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_OURS = "_assertpy_extracting"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _extraction_error(message: str) -> ValueError:
|
|
28
|
+
"""A ``ValueError`` tagged as this module's own.
|
|
29
|
+
|
|
30
|
+
The localization wrapper re-raises only tagged errors, so a ``ValueError`` raised by user code (a
|
|
31
|
+
property that fails, say) keeps its own traceback instead of being swallowed. The visible type
|
|
32
|
+
stays ``ValueError``, exactly as before.
|
|
33
|
+
"""
|
|
34
|
+
error = ValueError(message)
|
|
35
|
+
setattr(error, _OURS, True)
|
|
36
|
+
return error
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ExtractingMixin(_MixinBase):
|
|
40
|
+
"""Collection flattening mixin.
|
|
41
|
+
|
|
42
|
+
It is often necessary to test collections of objects. Use the ``extracting()`` helper to
|
|
43
|
+
reduce the collection on a given attribute. Reduce a list of objects:
|
|
44
|
+
|
|
45
|
+
alice = Person('Alice', 'Alpha')
|
|
46
|
+
bob = Person('Bob', 'Bravo')
|
|
47
|
+
people = [alice, bob]
|
|
48
|
+
|
|
49
|
+
assert_that(people).extracting('first_name').is_equal_to(['Alice', 'Bob'])
|
|
50
|
+
assert_that(people).extracting('first_name').contains('Alice', 'Bob')
|
|
51
|
+
assert_that(people).extracting('first_name').does_not_contain('Charlie')
|
|
52
|
+
|
|
53
|
+
Additionally, the ``extracting()`` helper can accept a list of attributes to be extracted, and
|
|
54
|
+
will flatten them into a list of tuples. Reduce a list of objects on multiple attributes:
|
|
55
|
+
|
|
56
|
+
assert_that(people).extracting('first_name', 'last_name').contains(('Alice', 'Alpha'), ('Bob', 'Bravo'))
|
|
57
|
+
|
|
58
|
+
Also, ``extracting()`` works on not just attributes, but also properties, and even
|
|
59
|
+
zero-argument methods. Reduce a list of object on properties and zero-arg methods:
|
|
60
|
+
|
|
61
|
+
assert_that(people).extracting('name').contains('Alice Alpha', 'Bob Bravo')
|
|
62
|
+
assert_that(people).extracting('say_hello').contains('Hello, Alice!', 'Hello, Bob!')
|
|
63
|
+
|
|
64
|
+
And ``extracting()`` even works on *dict-like* objects. Reduce a list of dicts on key:
|
|
65
|
+
|
|
66
|
+
alice = {'first_name': 'Alice', 'last_name': 'Alpha'}
|
|
67
|
+
bob = {'first_name': 'Bob', 'last_name': 'Bravo'}
|
|
68
|
+
people = [alice, bob]
|
|
69
|
+
|
|
70
|
+
assert_that(people).extracting('first_name').contains('Alice', 'Bob')
|
|
71
|
+
|
|
72
|
+
**Filtering**
|
|
73
|
+
|
|
74
|
+
The ``extracting()`` helper can include a *filter* to keep only those items for which the given
|
|
75
|
+
*filter* is truthy. For example:
|
|
76
|
+
|
|
77
|
+
users = [
|
|
78
|
+
{'user': 'Alice', 'age': 36, 'active': True},
|
|
79
|
+
{'user': 'Bob', 'age': 40, 'active': False},
|
|
80
|
+
{'user': 'Charlie', 'age': 13, 'active': True}
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
# filter the active users
|
|
84
|
+
assert_that(users).extracting('user', filter='active').is_equal_to(['Alice', 'Charlie'])
|
|
85
|
+
|
|
86
|
+
The *filter* can be a *dict-like* object and the extracted items are kept if and only if all
|
|
87
|
+
corresponding key-value pairs are equal:
|
|
88
|
+
|
|
89
|
+
assert_that(users).extracting('user', filter={'active': False}).is_equal_to(['Bob'])
|
|
90
|
+
assert_that(users).extracting('user', filter={'age': 36, 'active': True}).is_equal_to(['Alice'])
|
|
91
|
+
|
|
92
|
+
Or a *filter* can be any function (including an in-line ``lambda``) that accepts as its single
|
|
93
|
+
argument each item in the collection, and the extracted items are kept if the function
|
|
94
|
+
evaluates to ``True``:
|
|
95
|
+
|
|
96
|
+
assert_that(users).extracting('user', filter=lambda x: x['age'] > 20)
|
|
97
|
+
.is_equal_to(['Alice', 'Bob'])
|
|
98
|
+
|
|
99
|
+
**Sorting**
|
|
100
|
+
|
|
101
|
+
The ``extracting()`` helper can include a *sort* to enforce order on the extracted items.
|
|
102
|
+
|
|
103
|
+
The *sort* can be the name of a key (or attribute, or property, or zero-argument method) and
|
|
104
|
+
the extracted items are ordered by the corresponding values:
|
|
105
|
+
|
|
106
|
+
assert_that(users).extracting('user', sort='age').is_equal_to(['Charlie', 'Alice', 'Bob'])
|
|
107
|
+
|
|
108
|
+
The *sort* can be an iterable of names and the extracted items are ordered by
|
|
109
|
+
corresponding value of the first name, ties are broken by the corresponding values of the
|
|
110
|
+
second name, and so on:
|
|
111
|
+
|
|
112
|
+
assert_that(users).extracting('user', sort=['active', 'age']).is_equal_to(['Bob', 'Charlie', 'Alice'])
|
|
113
|
+
|
|
114
|
+
The *sort* can be any function (including an in-line ``lambda``) that accepts as its single
|
|
115
|
+
argument each item in the collection, and the extracted items are ordered by the corresponding
|
|
116
|
+
function return values:
|
|
117
|
+
|
|
118
|
+
assert_that(users).extracting('user', sort=lambda x: -x['age']).is_equal_to(['Bob', 'Alice', 'Charlie'])
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def extracting(self, *names: object, **kwargs: object) -> Self:
|
|
122
|
+
"""Asserts that val is iterable, then extracts the named attributes, properties, or
|
|
123
|
+
zero-arg methods into a list (or list of tuples if multiple names are given).
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
*names: the attribute to be extracted (or property or zero-arg method)
|
|
127
|
+
**kwargs (object): see below
|
|
128
|
+
|
|
129
|
+
Keyword Args:
|
|
130
|
+
filter (str | dict | Callable | None): extract only those items where filter is truthy
|
|
131
|
+
sort (str | Iterable | Callable | None): order the extracted items by the sort key
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
Usage:
|
|
135
|
+
|
|
136
|
+
alice = User('Alice', 20, True)
|
|
137
|
+
bob = User('Bob', 30, False)
|
|
138
|
+
charlie = User('Charlie', 10, True)
|
|
139
|
+
users = [alice, bob, charlie]
|
|
140
|
+
|
|
141
|
+
assert_that(users).extracting('user').contains('Alice', 'Bob', 'Charlie')
|
|
142
|
+
|
|
143
|
+
Works with *dict-like* objects too:
|
|
144
|
+
|
|
145
|
+
users = [
|
|
146
|
+
{'user': 'Alice', 'age': 20, 'active': True},
|
|
147
|
+
{'user': 'Bob', 'age': 30, 'active': False},
|
|
148
|
+
{'user': 'Charlie', 'age': 10, 'active': True}
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
assert_that(people).extracting('user').contains('Alice', 'Bob', 'Charlie')
|
|
152
|
+
|
|
153
|
+
Filter:
|
|
154
|
+
|
|
155
|
+
assert_that(users).extracting('user', filter='active').is_equal_to(['Alice', 'Charlie'])
|
|
156
|
+
|
|
157
|
+
Sort:
|
|
158
|
+
|
|
159
|
+
assert_that(users).extracting('user', sort='age').is_equal_to(['Charlie', 'Alice', 'Bob'])
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
AssertionBuilder: returns a new instance (extracted list as val) to chain the next assertion
|
|
163
|
+
"""
|
|
164
|
+
reject_unknown_kwargs(kwargs, _EXTRACTING_OPTIONS, "extracting")
|
|
165
|
+
require_type(self.val, collections.abc.Iterable, "iterable")
|
|
166
|
+
if isinstance(self.val, str):
|
|
167
|
+
refuse(self.val, "a collection rather than a string")
|
|
168
|
+
if len(names) == 0:
|
|
169
|
+
raise ValueError("one or more name args must be given")
|
|
170
|
+
|
|
171
|
+
def _attr_value(item, name):
|
|
172
|
+
attr = getattr(item, name)
|
|
173
|
+
if not callable(attr):
|
|
174
|
+
return attr
|
|
175
|
+
try:
|
|
176
|
+
inspect.signature(attr).bind()
|
|
177
|
+
except TypeError: # the callable needs arguments, so it is not a zero-arg method
|
|
178
|
+
raise _extraction_error(f"item method <{name}()> exists, but is not zero-arg method") from None
|
|
179
|
+
except ValueError: # some builtins expose no introspectable signature; fall back to calling
|
|
180
|
+
pass
|
|
181
|
+
return attr() # a TypeError from here comes from the method body, not an arity mismatch
|
|
182
|
+
|
|
183
|
+
def _extract(item, name):
|
|
184
|
+
if mapping_shaped(item, check_values=False):
|
|
185
|
+
if name in item:
|
|
186
|
+
return item[name]
|
|
187
|
+
raise _extraction_error(f"item keys {list(item.keys())} did not contain key <{name}>")
|
|
188
|
+
if is_namedtuple(item) and type(name) is str:
|
|
189
|
+
if name in item._fields:
|
|
190
|
+
return getattr(item, name)
|
|
191
|
+
if hasattr(item, name): # a property or zero-arg method on the NamedTuple subclass
|
|
192
|
+
return _attr_value(item, name)
|
|
193
|
+
raise _extraction_error(f"item attributes {item._fields} did not contain attribute <{name}>")
|
|
194
|
+
if isinstance(item, collections.abc.Iterable) and not is_model_dump_object(item):
|
|
195
|
+
self._check_iterable(item, name="item")
|
|
196
|
+
return item[name]
|
|
197
|
+
try:
|
|
198
|
+
return _attr_value(item, name)
|
|
199
|
+
except AttributeError as exc:
|
|
200
|
+
# hasattr() reports a raising accessor as a missing one, and the two want different messages
|
|
201
|
+
if hasattr(type(item), name):
|
|
202
|
+
raise _extraction_error(
|
|
203
|
+
f"item has property or zero-arg method <{name}>, but reading it raised AttributeError: {exc}"
|
|
204
|
+
) from exc
|
|
205
|
+
try:
|
|
206
|
+
available = sorted(attr for attr in dir(item) if not attr.startswith("_"))
|
|
207
|
+
except Exception: # a broken __dir__ must not replace the real diagnostic with its own
|
|
208
|
+
available = []
|
|
209
|
+
# one suggestion: measured typos score ~0.9 while wrong neighbours sit at ~0.65
|
|
210
|
+
close = difflib.get_close_matches(str(name), available, n=1)
|
|
211
|
+
hint = f"; did you mean {close[0]!r}?" if close else ""
|
|
212
|
+
raise _extraction_error(f"item does not have property or zero-arg method <{name}>{hint}") from None
|
|
213
|
+
|
|
214
|
+
def _filter(item):
|
|
215
|
+
if "filter" in kwargs:
|
|
216
|
+
chosen = kwargs["filter"]
|
|
217
|
+
if isinstance(chosen, str):
|
|
218
|
+
return bool(_extract(item, chosen))
|
|
219
|
+
elif mapping_shaped(chosen, check_values=False):
|
|
220
|
+
mapping = cast("MappingLike", chosen)
|
|
221
|
+
return all(_extract(item, key) == mapping[key] for key in mapping if isinstance(key, str))
|
|
222
|
+
elif callable(chosen):
|
|
223
|
+
return cast("Callable[[object], object]", chosen)(item)
|
|
224
|
+
elif chosen is None:
|
|
225
|
+
return True
|
|
226
|
+
refuse(chosen, "a str, a dict, or a callable", subject=argument("filter"))
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
def _sort(item):
|
|
230
|
+
# an explicit `sort=None` means no ordering, and 0 is a stable no-op for it
|
|
231
|
+
sort = kwargs["sort"]
|
|
232
|
+
if isinstance(sort, str):
|
|
233
|
+
return _extract(item, sort)
|
|
234
|
+
if isinstance(sort, collections.abc.Iterable):
|
|
235
|
+
return tuple(_extract(item, key) for key in sort if isinstance(key, str))
|
|
236
|
+
if callable(sort):
|
|
237
|
+
return cast("Callable[[object], object]", sort)(item)
|
|
238
|
+
if sort is None:
|
|
239
|
+
return 0
|
|
240
|
+
# silently returning unsorted items would surface as a confusing order mismatch further down the chain
|
|
241
|
+
refuse(sort, "a str, an iterable, or a callable", subject=argument("sort"))
|
|
242
|
+
|
|
243
|
+
# only pay the sort when one was requested, so the extractor sees the source at the same point
|
|
244
|
+
source = sorted(self._walked(), key=_sort) if "sort" in kwargs else self._walked()
|
|
245
|
+
extracted = []
|
|
246
|
+
# counted here: the walk spends a generator, and asking a spent one said an emptied source had been empty
|
|
247
|
+
seen = 0
|
|
248
|
+
for index, item in enumerate(source):
|
|
249
|
+
seen += 1
|
|
250
|
+
if verdict(_filter(item), subject="the filter"):
|
|
251
|
+
try:
|
|
252
|
+
extracted_values = [_extract(item, name) for name in names]
|
|
253
|
+
except ValueError as exc:
|
|
254
|
+
if not getattr(exc, _OURS, False):
|
|
255
|
+
raise
|
|
256
|
+
localized = f"{exc} (at index {index}, item is <{type(item).__name__}>)"
|
|
257
|
+
raise _extraction_error(localized) from exc.__cause__
|
|
258
|
+
extracted.append(tuple(extracted_values) if len(extracted_values) > 1 else extracted_values[0])
|
|
259
|
+
|
|
260
|
+
return self.builder(
|
|
261
|
+
extracted,
|
|
262
|
+
self.description,
|
|
263
|
+
self.kind,
|
|
264
|
+
logger=self.logger,
|
|
265
|
+
origin=f"extracting() produced {len(extracted)} of {seen} items",
|
|
266
|
+
)
|
assertpy/file.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import TYPE_CHECKING, cast
|
|
5
|
+
|
|
6
|
+
from ._engine._mixin_base import _MixinBase
|
|
7
|
+
from ._engine._require import argument, require_type
|
|
8
|
+
from .errors import _capped
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ._engine._compat import Self
|
|
12
|
+
from ._engine._introspection import Readable
|
|
13
|
+
|
|
14
|
+
__tracebackhide__ = True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def contents_of(file: str | bytes | os.PathLike[str] | os.PathLike[bytes] | Readable, encoding: str = "utf-8") -> str:
|
|
18
|
+
"""Helper to read the contents of the given file or path into a string with the given encoding.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
file (str | os.PathLike | IO): a *path-like object* (aka a file name) or a *file-like object* (aka a file)
|
|
22
|
+
encoding (str): the target encoding. Defaults to ``utf-8`` (others: ``ascii``, ``latin-1``).
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
Usage:
|
|
26
|
+
|
|
27
|
+
from assertpy import assert_that, contents_of
|
|
28
|
+
|
|
29
|
+
contents = contents_of('foo.txt')
|
|
30
|
+
assert_that(contents).starts_with('foo').ends_with('bar').contains('oob')
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
str: returns the file contents as a string
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
IOError: if file not found
|
|
37
|
+
TypeError: if file is not a *path-like object* or a *file-like object*
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
# the `except` is what decides this: a path has no `read`, and asking first would not tell us more
|
|
41
|
+
contents = cast("Readable", file).read()
|
|
42
|
+
except AttributeError:
|
|
43
|
+
try:
|
|
44
|
+
path = cast("str | bytes | os.PathLike[str] | os.PathLike[bytes]", file)
|
|
45
|
+
with open(path, encoding=encoding, errors="replace") as file_handle:
|
|
46
|
+
contents = file_handle.read()
|
|
47
|
+
except TypeError:
|
|
48
|
+
raise ValueError(f"val must be file or path, but was type <{type(file).__name__}>") from None
|
|
49
|
+
except OSError:
|
|
50
|
+
if not isinstance(file, (str, bytes, os.PathLike)):
|
|
51
|
+
raise ValueError(f"val must be file or path, but was type <{type(file).__name__}>") from None
|
|
52
|
+
raise
|
|
53
|
+
|
|
54
|
+
if isinstance(contents, (bytes, bytearray)):
|
|
55
|
+
return contents.decode(encoding, "replace")
|
|
56
|
+
return contents
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FileMixin(_MixinBase):
|
|
60
|
+
"""File assertions mixin."""
|
|
61
|
+
|
|
62
|
+
def exists(self) -> Self:
|
|
63
|
+
"""Asserts that val is a path and that it exists.
|
|
64
|
+
|
|
65
|
+
Examples:
|
|
66
|
+
Usage:
|
|
67
|
+
|
|
68
|
+
assert_that('myfile.txt').exists()
|
|
69
|
+
assert_that('mydir').exists()
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
AssertionError: if val does **not** exist
|
|
76
|
+
"""
|
|
77
|
+
missing = self._missing()
|
|
78
|
+
if missing is not None:
|
|
79
|
+
return self.error(missing)
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def does_not_exist(self) -> Self:
|
|
83
|
+
"""Asserts that val is a path and that it does *not* exist.
|
|
84
|
+
|
|
85
|
+
Examples:
|
|
86
|
+
Usage:
|
|
87
|
+
|
|
88
|
+
assert_that('missing.txt').does_not_exist()
|
|
89
|
+
assert_that('missing_dir').does_not_exist()
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
AssertionError: if val **does** exist
|
|
96
|
+
"""
|
|
97
|
+
require_type(self.val, (str, os.PathLike), "a path")
|
|
98
|
+
if os.path.exists(self.val):
|
|
99
|
+
return self.error(f"Expected <{_capped(self.val)}> to not exist, but was found.")
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def is_file(self) -> Self:
|
|
103
|
+
"""Asserts that val is a *file* and that it exists.
|
|
104
|
+
|
|
105
|
+
Nothing at the path is one of the answers rather than a question left unasked, so
|
|
106
|
+
``not_.is_file()`` holds for a path that does not exist.
|
|
107
|
+
|
|
108
|
+
Examples:
|
|
109
|
+
Usage:
|
|
110
|
+
|
|
111
|
+
assert_that('myfile.txt').is_file()
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
AssertionError: if val does **not** exist, or is **not** a file
|
|
118
|
+
"""
|
|
119
|
+
not_a_file = self._not_a_file()
|
|
120
|
+
if not_a_file is not None:
|
|
121
|
+
return self.error(not_a_file)
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def is_directory(self) -> Self:
|
|
125
|
+
"""Asserts that val is a *directory* and that it exists.
|
|
126
|
+
|
|
127
|
+
Nothing at the path is one of the answers rather than a question left unasked, so
|
|
128
|
+
``not_.is_directory()`` holds for a path that does not exist.
|
|
129
|
+
|
|
130
|
+
Examples:
|
|
131
|
+
Usage:
|
|
132
|
+
|
|
133
|
+
assert_that('mydir').is_directory()
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
AssertionError: if val does **not** exist, or is **not** a directory
|
|
140
|
+
"""
|
|
141
|
+
missing = self._missing()
|
|
142
|
+
if missing is not None:
|
|
143
|
+
return self.error(missing)
|
|
144
|
+
if not os.path.isdir(self.val):
|
|
145
|
+
return self.error(f"Expected <{_capped(self.val)}> to be a directory, but was not.")
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
def is_named(self, filename: str) -> Self:
|
|
149
|
+
"""Asserts that val is an existing path to a file and that file is named filename.
|
|
150
|
+
|
|
151
|
+
A path that is not an existing file fails it with or without ``not_``: the name is asked of the file.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
filename: the expected filename
|
|
155
|
+
|
|
156
|
+
Examples:
|
|
157
|
+
Usage:
|
|
158
|
+
|
|
159
|
+
assert_that('/path/to/mydir/myfile.txt').is_named('myfile.txt')
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
AssertionError: if val does **not** exist, or is **not** a file, or is **not** named the given filename
|
|
166
|
+
TypeError: if filename is not a path, whatever val is
|
|
167
|
+
"""
|
|
168
|
+
require_type(filename, (str, os.PathLike), "a path", subject=argument("filename"))
|
|
169
|
+
if not self._require_file():
|
|
170
|
+
return self
|
|
171
|
+
val_filename = os.path.basename(os.path.abspath(self.val))
|
|
172
|
+
expected_filename = os.fspath(filename) # normalize an os.PathLike arg to its string form
|
|
173
|
+
if val_filename == expected_filename:
|
|
174
|
+
return self
|
|
175
|
+
return self.error(
|
|
176
|
+
f"Expected filename <{val_filename}> to be equal to <{expected_filename}>, but was not.",
|
|
177
|
+
expected=filename,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def is_child_of(self, parent: object) -> Self:
|
|
181
|
+
"""Asserts that val is an existing path to a file and that file is a child of parent.
|
|
182
|
+
|
|
183
|
+
A path that is not an existing file fails it with or without ``not_``: the parent is asked of the file.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
parent: the expected parent directory
|
|
187
|
+
|
|
188
|
+
Examples:
|
|
189
|
+
Usage:
|
|
190
|
+
|
|
191
|
+
assert_that('/path/to/mydir/myfile.txt').is_child_of('/path/to/mydir')
|
|
192
|
+
assert_that('/path/to/mydir/myfile.txt').is_child_of('/path/to')
|
|
193
|
+
assert_that('/path/to/mydir/myfile.txt').is_child_of('/path')
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
AssertionError: if val does **not** exist, is **not** a file, or is **not** a child of given directory
|
|
200
|
+
TypeError: if parent is not a path, whatever val is
|
|
201
|
+
"""
|
|
202
|
+
parent_path = require_type(parent, (str, os.PathLike), "a path", subject=argument("parent directory"))
|
|
203
|
+
if not self._require_file():
|
|
204
|
+
return self
|
|
205
|
+
val_abspath = os.path.abspath(self.val)
|
|
206
|
+
parent_abspath = os.path.abspath(parent_path)
|
|
207
|
+
try:
|
|
208
|
+
is_child = os.path.commonpath([val_abspath, parent_abspath]) == parent_abspath != val_abspath
|
|
209
|
+
except ValueError: # pragma: no cover - Windows-only: paths on different drives share no common path
|
|
210
|
+
is_child = False
|
|
211
|
+
if not is_child:
|
|
212
|
+
return self.error(
|
|
213
|
+
f"Expected file <{val_abspath}> to be a child of <{parent_abspath}>, but was not.", expected=parent_path
|
|
214
|
+
)
|
|
215
|
+
return self
|
|
216
|
+
|
|
217
|
+
def is_readable(self) -> Self:
|
|
218
|
+
"""Asserts that val is an existing path and is readable.
|
|
219
|
+
|
|
220
|
+
A path that does not exist fails it with or without ``not_``: a permission is asked of what is at
|
|
221
|
+
the path.
|
|
222
|
+
|
|
223
|
+
Examples:
|
|
224
|
+
Usage:
|
|
225
|
+
|
|
226
|
+
assert_that('/path/to/file.txt').is_readable()
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
AssertionError: if val does **not** exist, or is **not** readable
|
|
233
|
+
"""
|
|
234
|
+
if not self._require_existing():
|
|
235
|
+
return self
|
|
236
|
+
if not os.access(self.val, os.R_OK):
|
|
237
|
+
return self.error(f"Expected <{_capped(self.val)}> to be readable, but was not.")
|
|
238
|
+
return self
|
|
239
|
+
|
|
240
|
+
def is_writable(self) -> Self:
|
|
241
|
+
"""Asserts that val is an existing path and is writable.
|
|
242
|
+
|
|
243
|
+
A path that does not exist fails it with or without ``not_``: a permission is asked of what is at
|
|
244
|
+
the path.
|
|
245
|
+
|
|
246
|
+
Examples:
|
|
247
|
+
Usage:
|
|
248
|
+
|
|
249
|
+
assert_that('/path/to/file.txt').is_writable()
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
AssertionError: if val does **not** exist, or is **not** writable
|
|
256
|
+
"""
|
|
257
|
+
if not self._require_existing():
|
|
258
|
+
return self
|
|
259
|
+
if not os.access(self.val, os.W_OK):
|
|
260
|
+
return self.error(f"Expected <{_capped(self.val)}> to be writable, but was not.")
|
|
261
|
+
return self
|
|
262
|
+
|
|
263
|
+
def is_executable(self) -> Self:
|
|
264
|
+
"""Asserts that val is an existing path and is executable.
|
|
265
|
+
|
|
266
|
+
A path that does not exist fails it with or without ``not_``: a permission is asked of what is at
|
|
267
|
+
the path.
|
|
268
|
+
|
|
269
|
+
Examples:
|
|
270
|
+
Usage:
|
|
271
|
+
|
|
272
|
+
assert_that('/path/to/script.sh').is_executable()
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
276
|
+
|
|
277
|
+
Raises:
|
|
278
|
+
AssertionError: if val does **not** exist, or is **not** executable
|
|
279
|
+
"""
|
|
280
|
+
if not self._require_existing():
|
|
281
|
+
return self
|
|
282
|
+
if not os.access(self.val, os.X_OK):
|
|
283
|
+
return self.error(f"Expected <{_capped(self.val)}> to be executable, but was not.")
|
|
284
|
+
return self
|
|
285
|
+
|
|
286
|
+
def _missing(self) -> str | None:
|
|
287
|
+
"""The failure for a path that does not exist, or ``None`` when it does.
|
|
288
|
+
|
|
289
|
+
Composed once for both of its readings: `exists()` answers with it, and an assertion about what is
|
|
290
|
+
at the path reports it as the prerequisite its question presupposes.
|
|
291
|
+
"""
|
|
292
|
+
require_type(self.val, (str, os.PathLike), "a path")
|
|
293
|
+
if os.path.exists(self.val):
|
|
294
|
+
return None
|
|
295
|
+
return f"Expected <{_capped(self.val)}> to exist, but was not found."
|
|
296
|
+
|
|
297
|
+
def _not_a_file(self) -> str | None:
|
|
298
|
+
"""The failure for a path that is not an existing file, or ``None`` when it is one."""
|
|
299
|
+
missing = self._missing()
|
|
300
|
+
if missing is not None or os.path.isfile(self.val):
|
|
301
|
+
return missing
|
|
302
|
+
return f"Expected <{_capped(self.val)}> to be a file, but was not."
|
|
303
|
+
|
|
304
|
+
def _require_existing(self) -> bool:
|
|
305
|
+
"""Whether val exists, after reporting as a prerequisite that it does not.
|
|
306
|
+
|
|
307
|
+
A permission belongs to what is at the path, so with nothing there the question has no answer.
|
|
308
|
+
Inverted, a mistyped path passed ``not_.is_readable()`` as a file nobody may read.
|
|
309
|
+
"""
|
|
310
|
+
missing = self._missing()
|
|
311
|
+
if missing is not None:
|
|
312
|
+
self._unmet(missing)
|
|
313
|
+
return missing is None
|
|
314
|
+
|
|
315
|
+
def _require_file(self) -> bool:
|
|
316
|
+
"""Whether val is an existing file, after reporting as a prerequisite that it is not.
|
|
317
|
+
|
|
318
|
+
A name and a parent are asked of the file at the path, so a missing path or a directory answers
|
|
319
|
+
neither the question nor its negation.
|
|
320
|
+
"""
|
|
321
|
+
not_a_file = self._not_a_file()
|
|
322
|
+
if not_a_file is not None:
|
|
323
|
+
self._unmet(not_a_file)
|
|
324
|
+
return not_a_file is None
|