endia 0.0.1__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.
endia/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """The Python client of EastIndia, which runs Hyper-V Windows virtual machines for tests, driven as Docker drives containers.
2
+
3
+ Start from ``EastIndiaClient``, or ``EastIndiaClient.from_env()`` to connect where the ``endia`` CLI would. The records of
4
+ each area live in the area's module, such as ``endia.desktop.UiElement``.
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError
8
+ from importlib.metadata import version as _version
9
+
10
+ from ._dockerignore import IgnoreFileError
11
+ from ._wire import Timestamp
12
+ from .client import EastIndiaClient
13
+ from .errors import (
14
+ ApiError,
15
+ ConnectionFailure,
16
+ EastIndiaApiError,
17
+ EastIndiaConnectionError,
18
+ EastIndiaError,
19
+ ErrorCode,
20
+ ExecTimeoutError,
21
+ MachineFailedError,
22
+ OperationFailedError,
23
+ )
24
+ from .execs import ExecResult, ExecSession, StreamKind
25
+ from .operations import Operation, OperationHandle, OperationState
26
+
27
+ try:
28
+ __version__ = _version("endia")
29
+ except PackageNotFoundError:
30
+ __version__ = "0.0.0"
31
+
32
+ __all__ = [
33
+ "ApiError",
34
+ "ConnectionFailure",
35
+ "EastIndiaApiError",
36
+ "EastIndiaClient",
37
+ "EastIndiaConnectionError",
38
+ "EastIndiaError",
39
+ "ErrorCode",
40
+ "ExecResult",
41
+ "ExecSession",
42
+ "ExecTimeoutError",
43
+ "IgnoreFileError",
44
+ "MachineFailedError",
45
+ "Operation",
46
+ "OperationFailedError",
47
+ "OperationHandle",
48
+ "OperationState",
49
+ "StreamKind",
50
+ "Timestamp",
51
+ "__version__",
52
+ ]
endia/_connection.py ADDED
@@ -0,0 +1,261 @@
1
+ """The one connection that every route group of a client shares.
2
+
3
+ Over HTTPS it presents the caller's client certificate and accepts the server only through EastIndia's own authority. A request
4
+ that changes something carries an ``Idempotency-Key`` and is sent again with the same key when its connection fails before any
5
+ reply. This module is also the one place where a refusal becomes ``EastIndiaApiError`` and a failure to connect becomes
6
+ ``EastIndiaConnectionError``.
7
+ """
8
+
9
+ import base64
10
+ import binascii
11
+ import json
12
+ import ssl
13
+ import time
14
+ import uuid
15
+ from collections.abc import Iterator
16
+ from contextlib import ExitStack
17
+ from dataclasses import dataclass
18
+ from typing import IO, Any
19
+
20
+ import httpx
21
+ from websockets.exceptions import InvalidHandshake, InvalidStatus
22
+ from websockets.sync.client import ClientConnection
23
+ from websockets.sync.client import connect as connect_websocket
24
+
25
+ from . import _wire
26
+ from .errors import ApiError, ConnectionFailure, EastIndiaApiError, EastIndiaConnectionError
27
+
28
+ IDEMPOTENCY_HEADER = "Idempotency-Key"
29
+ ERROR_HEADER = "X-EastIndia-Error"
30
+ """The header that carries, as Base64 of its JSON, the error of a refusal whose answer has no body, such as a HEAD's."""
31
+
32
+ JSON_MEDIA_TYPE = "application/json"
33
+ TAR_MEDIA_TYPE = "application/x-tar"
34
+
35
+ # An upload's body is read from the caller's stream, and sent, one piece of this many bytes at a time.
36
+ _UPLOAD_CHUNK_BYTES = 1024 * 1024
37
+
38
+
39
+ @dataclass(frozen=True, kw_only=True)
40
+ class ConnectionSettings:
41
+ """Where the service is, how it is reached, and how patiently."""
42
+
43
+ host: str
44
+ """The service's host name or address, which its certificate must name."""
45
+
46
+ port: int
47
+ """The service's TCP port."""
48
+
49
+ tls: ssl.SSLContext
50
+ """The TLS settings: the client certificate, and the authority that the server's certificate must chain to."""
51
+
52
+ request_timeout: float
53
+ """The seconds allowed to connect, and to wait for each read of an answer, where a time limit applies."""
54
+
55
+ request_retries: int
56
+ """How many times a changing request is sent again after its connection failed before any reply."""
57
+
58
+ retry_delay: float
59
+ """The seconds between those attempts."""
60
+
61
+ @property
62
+ def address(self) -> str:
63
+ """The host and port, as a URL and messages write them, with an IPv6 address in brackets."""
64
+ return f"[{self.host}]:{self.port}" if ":" in self.host else f"{self.host}:{self.port}"
65
+
66
+
67
+ def tls_context(ca_file: str, cert_file: str, key_file: str) -> ssl.SSLContext:
68
+ """Builds the TLS settings of mutual TLS with EastIndia.
69
+
70
+ The server must present a certificate that chains to the authority in ``ca_file``, is meant for server authentication and
71
+ names the address the client connects to. The operating system's trust store is never consulted, and verification cannot be
72
+ turned off. The client presents the certificate in ``cert_file`` with its unencrypted key in ``key_file``.
73
+
74
+ Raises:
75
+ FileNotFoundError: One of the three files does not exist.
76
+ ssl.SSLError: A file does not hold a certificate, or the key does not belong to the certificate.
77
+ """
78
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
79
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
80
+ context.load_verify_locations(cafile=ca_file)
81
+ context.load_cert_chain(certfile=cert_file, keyfile=key_file)
82
+ return context
83
+
84
+
85
+ def classify(error: BaseException) -> ConnectionFailure:
86
+ """Gives the reason no answer came, from the exception that sending the request raised.
87
+
88
+ The exception and its causes are examined from the outermost inwards. A failed check of the server's certificate is
89
+ ``SERVER_NOT_TRUSTED``. Any other TLS failure is ``CERTIFICATE_REFUSED``: the service refuses a client certificate with a TLS
90
+ alert, which under TLS 1.2 ends the handshake and under TLS 1.3 arrives on the first read. Anything else is ``UNREACHABLE``.
91
+ """
92
+ cause: BaseException | None = error
93
+ while cause is not None:
94
+ if isinstance(cause, ssl.SSLCertVerificationError):
95
+ return ConnectionFailure.SERVER_NOT_TRUSTED
96
+ if isinstance(cause, ssl.SSLError) or "alert" in str(cause).lower():
97
+ return ConnectionFailure.CERTIFICATE_REFUSED
98
+ cause = cause.__cause__ or cause.__context__
99
+ return ConnectionFailure.UNREACHABLE
100
+
101
+
102
+ def read_error(body: bytes | bytearray, header: str | None) -> ApiError | None:
103
+ """Reads a refusal's error from its body, or from the ``X-EastIndia-Error`` header when the body holds none."""
104
+ try:
105
+ return _wire.read(json.loads(body), ApiError)
106
+ except ValueError:
107
+ pass
108
+ try:
109
+ return _wire.read(json.loads(base64.b64decode(header or "", validate=True)), ApiError)
110
+ except (ValueError, binascii.Error):
111
+ return None
112
+
113
+
114
+ class Connection:
115
+ """Sends the requests of every route group, and turns every failure into one of the SDK's two exceptions."""
116
+
117
+ def __init__(self, settings: ConnectionSettings, transport: httpx.BaseTransport | None = None) -> None:
118
+ self.settings = settings
119
+ self._http = httpx.Client(
120
+ base_url=f"https://{settings.address}",
121
+ verify=settings.tls,
122
+ timeout=settings.request_timeout,
123
+ transport=transport,
124
+ )
125
+
126
+ def close(self) -> None:
127
+ """Closes the connection's sockets."""
128
+ self._http.close()
129
+
130
+ def read(self, path: str) -> Any:
131
+ """Sends a ``GET`` and gives its parsed JSON answer. A read is never sent again."""
132
+ return self._send(self._http.build_request("GET", path), timed=True).json()
133
+
134
+ def head(self, path: str) -> httpx.Headers:
135
+ """Sends a ``HEAD`` and gives the answer's headers; a refusal carries its error in the ``X-EastIndia-Error`` header."""
136
+ return self._send(self._http.build_request("HEAD", path), timed=True).headers
137
+
138
+ def change(
139
+ self,
140
+ method: str,
141
+ path: str,
142
+ *,
143
+ json_body: Any = None,
144
+ archive: IO[bytes] | None = None,
145
+ timed: bool = True,
146
+ ) -> httpx.Response:
147
+ """Sends a request that changes something, and gives the answer with its body read.
148
+
149
+ The request carries a new ``Idempotency-Key``. When its connection fails before any reply, it is sent again with the same key
150
+ up to ``request_retries`` times, ``retry_delay`` seconds apart, so that the service does the work once. An ``archive``, the
151
+ body of an upload, must be seekable: each attempt sends it again from where it started.
152
+
153
+ Args:
154
+ timed: Whether the request time-out applies. It does not for an upload, whose body can take longer to send, nor for a
155
+ wait that the service answers only when the wait ends.
156
+ """
157
+ key = uuid.uuid4().hex
158
+ start = archive.tell() if archive is not None else 0
159
+ attempt = 0
160
+ while True:
161
+ request = self._build(method, path, json_body, archive, start)
162
+ request.headers[IDEMPOTENCY_HEADER] = key
163
+ try:
164
+ return self._send(request, timed=timed)
165
+ except EastIndiaConnectionError as failed:
166
+ if failed.failure is not ConnectionFailure.UNREACHABLE or attempt >= self.settings.request_retries:
167
+ raise
168
+ attempt += 1
169
+ time.sleep(self.settings.retry_delay)
170
+
171
+ def once(self, path: str, json_body: Any) -> httpx.Response:
172
+ """Sends a ``POST`` exactly once, without an ``Idempotency-Key`` and without a time limit.
173
+
174
+ A request whose body carries a password or a secret goes this way: the service stores no reply to it, and a repeat could
175
+ answer an elevation prompt twice.
176
+ """
177
+ return self._send(self._build("POST", path, json_body, None, 0), timed=False)
178
+
179
+ def unkeyed(self, path: str, json_body: Any) -> httpx.Response:
180
+ """Sends a ``POST`` that changes nothing, such as a screenshot, without an ``Idempotency-Key``, and gives the whole answer."""
181
+ return self._send(self._build("POST", path, json_body, None, 0), timed=True)
182
+
183
+ def open_stream(self, path: str) -> httpx.Response:
184
+ """Sends a ``GET`` whose answer is read as it arrives, such as a followed log, with no time limit on the reading. The caller
185
+ closes the answer.
186
+ """
187
+ return self._send(self._http.build_request("GET", path), timed=False, stream=True)
188
+
189
+ def lines(self, path: str) -> Iterator[str]:
190
+ """Reads a streamed answer line by line, until the service ends it."""
191
+ response = self.open_stream(path)
192
+ try:
193
+ yield from response.iter_lines()
194
+ except httpx.TransportError as failed:
195
+ raise EastIndiaConnectionError(self.settings.address, classify(failed)) from failed
196
+ finally:
197
+ response.close()
198
+
199
+ def websocket(self, path: str, owner: ExitStack) -> ClientConnection:
200
+ """Opens a route as a WebSocket, over the same mutual TLS as every other request. Closing ``owner`` closes it.
201
+
202
+ Raises:
203
+ EastIndiaApiError: The service refused the upgrade; the refusal's body says why.
204
+ EastIndiaConnectionError: No answer came.
205
+ """
206
+ try:
207
+ opened = owner.enter_context(
208
+ connect_websocket(
209
+ f"wss://{self.settings.address}{path}",
210
+ ssl=self.settings.tls,
211
+ open_timeout=self.settings.request_timeout,
212
+ max_size=None,
213
+ # The client sends a ping every 20 seconds, so that a proxy does not close a connection whose program prints nothing
214
+ # for a while. The client never closes the connection because a ping went unanswered, because the program may still
215
+ # be running.
216
+ ping_timeout=None,
217
+ ),
218
+ )
219
+ except InvalidStatus as refused:
220
+ response = refused.response
221
+ raise EastIndiaApiError(response.status_code, read_error(response.body or b"", response.headers.get(ERROR_HEADER))) from refused
222
+ except (OSError, InvalidHandshake, TimeoutError) as failed:
223
+ raise EastIndiaConnectionError(self.settings.address, classify(failed)) from failed
224
+ # websockets 15 and 16 declare the entered connection as their base type; the synchronous client always enters a
225
+ # ClientConnection.
226
+ assert isinstance(opened, ClientConnection)
227
+ return opened
228
+
229
+ def _build(self, method: str, path: str, json_body: Any, archive: IO[bytes] | None, start: int) -> httpx.Request:
230
+ if archive is not None:
231
+ length = archive.seek(0, 2) - start
232
+ headers = {"Content-Type": TAR_MEDIA_TYPE, "Content-Length": str(length)}
233
+ return self._http.build_request(method, path, content=_from_start(archive, start), headers=headers, timeout=self._untimed())
234
+ if json_body is not None:
235
+ content = json.dumps(json_body, separators=(",", ":")).encode()
236
+ return self._http.build_request(method, path, content=content, headers={"Content-Type": JSON_MEDIA_TYPE})
237
+ return self._http.build_request(method, path)
238
+
239
+ def _untimed(self) -> httpx.Timeout:
240
+ return httpx.Timeout(None, connect=self.settings.request_timeout)
241
+
242
+ def _send(self, request: httpx.Request, *, timed: bool, stream: bool = False) -> httpx.Response:
243
+ if not timed:
244
+ request.extensions["timeout"] = self._untimed().as_dict()
245
+ try:
246
+ response = self._http.send(request, stream=stream)
247
+ except httpx.TimeoutException as failed:
248
+ raise EastIndiaConnectionError(self.settings.address, ConnectionFailure.TIMED_OUT) from failed
249
+ except httpx.TransportError as failed:
250
+ raise EastIndiaConnectionError(self.settings.address, classify(failed)) from failed
251
+ if response.is_success:
252
+ return response
253
+ body = response.read()
254
+ response.close()
255
+ raise EastIndiaApiError(response.status_code, read_error(body, response.headers.get(ERROR_HEADER)))
256
+
257
+
258
+ def _from_start(archive: IO[bytes], start: int) -> Iterator[bytes]:
259
+ archive.seek(start)
260
+ while chunk := archive.read(_UPLOAD_CHUNK_BYTES):
261
+ yield chunk
endia/_dockerignore.py ADDED
@@ -0,0 +1,282 @@
1
+ """The ``.dockerignore`` rules by which a build leaves files of its context folder out of the archive it sends.
2
+
3
+ The rules are those the ``endia`` CLI applies, which are Docker's: each line is a pattern anchored at the context's root; a
4
+ leading ``/`` says nothing more; ``*`` and ``?`` match within one name and never match ``/``; ``**`` matches any number of whole
5
+ folders, or anything at the end of a pattern; ``[...]`` is a character class; a backslash makes the next character literal; a
6
+ line starting with ``!`` takes back what earlier lines excluded; the last line that matches decides; and a path is left out when
7
+ any folder above it is.
8
+
9
+ Matching steps through the path one character at a time, keeping every place in the pattern the text so far could have reached,
10
+ so a pattern of many ``**`` takes time in proportion to the path's length and never stalls a build.
11
+ """
12
+
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+
16
+ IGNORE_FILE_NAME = ".dockerignore"
17
+ # EastIndia's name for an ignore file, which a build reads in preference to a ``.dockerignore`` in the same place.
18
+ ENDIA_IGNORE_FILE_NAME = ".endiaignore"
19
+
20
+
21
+ class IgnoreFileError(ValueError):
22
+ """An ignore file holds a pattern that is not valid. The message names the file, the line and the column of each fault."""
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class _Literal:
27
+ character: str
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class _OneCharacter:
32
+ """``?``: any one character but ``/``."""
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class _Characters:
37
+ """``*``: any run of characters, none of them ``/``."""
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class _Folders:
42
+ """``**`` followed by more of the pattern: any number of whole folders, each ending in ``/``, including none."""
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class _Rest:
47
+ """``**`` at the end of the pattern: anything at all."""
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class _Class:
52
+ """``[...]``: one character among ranges, or with ``^`` one character outside them."""
53
+
54
+ ranges: tuple[tuple[str, str], ...]
55
+ negated: bool
56
+
57
+ def accepts(self, character: str) -> bool:
58
+ inside = any(low <= character <= high for low, high in self.ranges)
59
+ return inside != self.negated
60
+
61
+
62
+ _Token = _Literal | _OneCharacter | _Characters | _Folders | _Rest | _Class
63
+
64
+ # A place in a pattern is a pair. The first member is the index of the pattern's next piece to match. The second says whether
65
+ # the text read so far has started the folders of a '**' at that index; once started, the match leaves those folders only at a
66
+ # '/'.
67
+ _Place = tuple[int, bool]
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class IgnorePattern:
72
+ """One line of an ignore file."""
73
+
74
+ tokens: tuple[_Token, ...]
75
+ reincludes: bool
76
+ """Whether the line started with ``!`` and so takes back what earlier lines excluded."""
77
+
78
+ def matches(self, path: str) -> bool:
79
+ """Tells whether the pattern matches the whole of a cleaned context path."""
80
+ places = self._closure({(0, False)})
81
+ for character in path:
82
+ places = self._closure({after for place in places for after in self._step(place, character)})
83
+ if not places:
84
+ return False
85
+ return (len(self.tokens), False) in places
86
+
87
+ def _step(self, place: _Place, character: str) -> list[_Place]:
88
+ index = place[0]
89
+ if index == len(self.tokens):
90
+ return []
91
+ token = self.tokens[index]
92
+ if isinstance(token, _Folders):
93
+ return [(index, True), (index + 1, False)] if character == "/" else [(index, True)]
94
+ if isinstance(token, (_Characters, _Rest)):
95
+ return [place] if isinstance(token, _Rest) or character != "/" else []
96
+ if isinstance(token, _Literal):
97
+ accepted = character == token.character
98
+ elif isinstance(token, _OneCharacter):
99
+ accepted = character != "/"
100
+ else:
101
+ accepted = token.accepts(character)
102
+ return [(index + 1, False)] if accepted else []
103
+
104
+ def _closure(self, places: set[_Place]) -> set[_Place]:
105
+ # A token that can match no text at all lets the pattern move past it without reading a character: a '*', a '**' at the
106
+ # end, and a '**' whose folders have not started.
107
+ pending = list(places)
108
+ reached = set(places)
109
+ while pending:
110
+ index, inside = pending.pop()
111
+ if index < len(self.tokens) and not inside and isinstance(self.tokens[index], (_Characters, _Folders, _Rest)):
112
+ after = (index + 1, False)
113
+ if after not in reached:
114
+ reached.add(after)
115
+ pending.append(after)
116
+ return reached
117
+
118
+
119
+ def compile_pattern(pattern: str, reincludes: bool) -> IgnorePattern:
120
+ """Reads a cleaned pattern, without its leading ``/`` or ``!``.
121
+
122
+ Raises:
123
+ ValueError: A ``[`` has no closing ``]``, or a backslash ends the pattern.
124
+ """
125
+ tokens: list[_Token] = []
126
+ index = 0
127
+ while index < len(pattern):
128
+ character = pattern[index]
129
+ if character == "*":
130
+ index = _read_stars(pattern, index, tokens)
131
+ continue
132
+ if character == "[":
133
+ index = _read_class(pattern, index, tokens)
134
+ continue
135
+ if character == "?":
136
+ tokens.append(_OneCharacter())
137
+ elif character == "\\":
138
+ if index + 1 >= len(pattern):
139
+ raise ValueError("a trailing '\\' escapes nothing")
140
+ index += 1
141
+ tokens.append(_Literal(pattern[index]))
142
+ else:
143
+ tokens.append(_Literal(character))
144
+ index += 1
145
+ return IgnorePattern(tuple(tokens), reincludes)
146
+
147
+
148
+ def _read_stars(pattern: str, index: int, tokens: list[_Token]) -> int:
149
+ # A single '*' stays within one name. A '**' also matches the '/' after it, so that 'src/**/obj' matches 'src/obj'.
150
+ if index + 1 >= len(pattern) or pattern[index + 1] != "*":
151
+ tokens.append(_Characters())
152
+ return index + 1
153
+ following = index + 2
154
+ if following < len(pattern) and pattern[following] == "/":
155
+ following += 1
156
+ tokens.append(_Rest() if following >= len(pattern) else _Folders())
157
+ return following
158
+
159
+
160
+ def _read_class(pattern: str, index: int, tokens: list[_Token]) -> int:
161
+ # In a class, '^' first negates, a backslash makes the next character literal, 'a-z' is a range, and a ']' first in the class
162
+ # is a literal.
163
+ position = index + 1
164
+ negated = position < len(pattern) and pattern[position] == "^"
165
+ if negated:
166
+ position += 1
167
+ ranges: list[tuple[str, str]] = []
168
+ members = 0
169
+ range_open = False
170
+ while position < len(pattern) and (pattern[position] != "]" or members == 0):
171
+ character = pattern[position]
172
+ if character == "\\":
173
+ position += 1
174
+ if position >= len(pattern):
175
+ break
176
+ character = pattern[position]
177
+ elif character == "-" and members > 0 and position + 1 < len(pattern) and pattern[position + 1] != "]":
178
+ range_open = True
179
+ members += 1
180
+ position += 1
181
+ continue
182
+ if range_open:
183
+ ranges[-1] = (ranges[-1][0], character)
184
+ range_open = False
185
+ else:
186
+ ranges.append((character, character))
187
+ members += 1
188
+ position += 1
189
+ if position >= len(pattern):
190
+ raise ValueError("a '[' has no closing ']'")
191
+ tokens.append(_Class(tuple(ranges), negated))
192
+ return position + 1
193
+
194
+
195
+ def clean(path: str) -> str:
196
+ """Cleans a ``/``-separated path by its text alone: empty and ``.`` names are dropped, and ``..`` removes the name before it.
197
+
198
+ A path starting with ``/`` never climbs above its root; a relative one keeps its leading ``..`` names. An empty result is
199
+ ``.`` for a relative path and ``/`` for a rooted one.
200
+ """
201
+ rooted = path.startswith("/")
202
+ names: list[str] = []
203
+ for name in path.split("/"):
204
+ if name in ("", "."):
205
+ continue
206
+ if name == "..":
207
+ if names and names[-1] != "..":
208
+ names.pop()
209
+ elif not rooted:
210
+ names.append("..")
211
+ continue
212
+ names.append(name)
213
+ joined = "/".join(names)
214
+ if rooted:
215
+ return "/" + joined
216
+ return joined or "."
217
+
218
+
219
+ class DockerIgnore:
220
+ """The patterns of one ignore file, which decide whether a path of the context is left out."""
221
+
222
+ def __init__(self, patterns: tuple[IgnorePattern, ...] = ()) -> None:
223
+ self._patterns = patterns
224
+
225
+ @classmethod
226
+ def parse(cls, text: str, file_name: str = IGNORE_FILE_NAME) -> "DockerIgnore":
227
+ """Reads an ignore file's text.
228
+
229
+ Lines end in LF or CRLF, and a leading byte-order mark is dropped. Blank lines and lines whose first non-blank character is
230
+ ``#`` are skipped.
231
+
232
+ Raises:
233
+ IgnoreFileError: A line holds a pattern that is not valid, or a ``!`` with no pattern after it.
234
+ """
235
+ patterns: list[IgnorePattern] = []
236
+ faults: list[str] = []
237
+ for number, raw in enumerate(text.removeprefix("").split("\n"), start=1):
238
+ line = raw.rstrip("\r")
239
+ trimmed = line.strip()
240
+ if not trimmed or trimmed.startswith("#"):
241
+ continue
242
+ column = len(line) - len(line.lstrip()) + 1
243
+ reincludes = trimmed.startswith("!")
244
+ body = trimmed[1:].strip() if reincludes else trimmed
245
+ if not body:
246
+ faults.append(f"{file_name}:{number}:{column}: A '!' needs a pattern after it.")
247
+ continue
248
+ cleaned = clean(body)
249
+ if len(cleaned) > 1 and cleaned.startswith("/"):
250
+ cleaned = cleaned[1:]
251
+ try:
252
+ patterns.append(compile_pattern(cleaned, reincludes))
253
+ except ValueError as problem:
254
+ faults.append(f"{file_name}:{number}:{column}: The pattern '{trimmed}' is not valid: {problem}.")
255
+ if faults:
256
+ raise IgnoreFileError("\n".join(faults))
257
+ return cls(tuple(patterns))
258
+
259
+ def is_ignored(self, path: str) -> bool:
260
+ """Tells whether a context path, relative to the context's root and written with ``/``, is left out.
261
+
262
+ The patterns apply in file order, and the last one that matches the path or any folder above it decides. The context's
263
+ root itself is never left out.
264
+
265
+ Raises:
266
+ ValueError: The path points outside the context.
267
+ """
268
+ cleaned = clean(path).lstrip("/")
269
+ if cleaned == ".." or cleaned.startswith("../"):
270
+ raise ValueError(f"The path '{path}' points outside the build context.")
271
+ if cleaned in ("", "."):
272
+ return False
273
+ names = cleaned.split("/")
274
+ folders = ["/".join(names[:count]) for count in range(1, len(names))]
275
+ matches: Callable[[IgnorePattern], bool] = lambda pattern: pattern.matches(cleaned) or any(map(pattern.matches, folders))
276
+ ignored = False
277
+ for pattern in self._patterns:
278
+ # A line that takes back can only undo a match, and an excluding line can only add one, so a line that could change
279
+ # nothing is skipped.
280
+ if pattern.reincludes == ignored and matches(pattern):
281
+ ignored = not pattern.reincludes
282
+ return ignored