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.
Files changed (63) hide show
  1. assertpy/__init__.py +87 -0
  2. assertpy/_clustering.py +511 -0
  3. assertpy/_dangling.py +505 -0
  4. assertpy/_engine/__init__.py +1 -0
  5. assertpy/_engine/_builder_check_typing.py +1365 -0
  6. assertpy/_engine/_capable_typing.py +772 -0
  7. assertpy/_engine/_check_typing.py +753 -0
  8. assertpy/_engine/_compare.py +983 -0
  9. assertpy/_engine/_compat.py +38 -0
  10. assertpy/_engine/_contract.py +1926 -0
  11. assertpy/_engine/_diff.py +1058 -0
  12. assertpy/_engine/_equality.py +843 -0
  13. assertpy/_engine/_introspection.py +729 -0
  14. assertpy/_engine/_membership.py +522 -0
  15. assertpy/_engine/_mixin_base.py +258 -0
  16. assertpy/_engine/_negated_typing.py +2176 -0
  17. assertpy/_engine/_operations.py +75 -0
  18. assertpy/_engine/_ordering.py +884 -0
  19. assertpy/_engine/_pairing.py +70 -0
  20. assertpy/_engine/_path.py +158 -0
  21. assertpy/_engine/_poll_typing.py +6039 -0
  22. assertpy/_engine/_require.py +312 -0
  23. assertpy/_engine/_size.py +31 -0
  24. assertpy/_engine/_text.py +39 -0
  25. assertpy/_engine/_typing.py +1226 -0
  26. assertpy/_hints.py +1213 -0
  27. assertpy/_inline.py +484 -0
  28. assertpy/_matcher_impls.py +1876 -0
  29. assertpy/_passes.py +249 -0
  30. assertpy/_renamed.py +64 -0
  31. assertpy/_satisfies.py +835 -0
  32. assertpy/_snapshot_codec.py +290 -0
  33. assertpy/assertpy.py +2640 -0
  34. assertpy/async_assertions.py +864 -0
  35. assertpy/base.py +862 -0
  36. assertpy/behave_matchers.py +96 -0
  37. assertpy/bytes_mixin.py +158 -0
  38. assertpy/collection.py +528 -0
  39. assertpy/contains.py +1068 -0
  40. assertpy/dataframe.py +200 -0
  41. assertpy/date.py +327 -0
  42. assertpy/dict.py +249 -0
  43. assertpy/dynamic.py +222 -0
  44. assertpy/errors.py +782 -0
  45. assertpy/exception.py +838 -0
  46. assertpy/extracting.py +266 -0
  47. assertpy/file.py +324 -0
  48. assertpy/helpers.py +747 -0
  49. assertpy/http_mixin.py +218 -0
  50. assertpy/json_mixin.py +1265 -0
  51. assertpy/matchers.py +639 -0
  52. assertpy/numeric.py +696 -0
  53. assertpy/outcome.py +163 -0
  54. assertpy/py.typed +0 -0
  55. assertpy/pytest_plugin.py +1650 -0
  56. assertpy/snapshot.py +1031 -0
  57. assertpy/string.py +803 -0
  58. assertpy/warning.py +137 -0
  59. assertpy-2.35.0.dist-info/METADATA +324 -0
  60. assertpy-2.35.0.dist-info/RECORD +63 -0
  61. assertpy-2.35.0.dist-info/WHEEL +4 -0
  62. assertpy-2.35.0.dist-info/entry_points.txt +2 -0
  63. 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