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 +52 -0
- endia/_connection.py +261 -0
- endia/_dockerignore.py +282 -0
- endia/_environment.py +130 -0
- endia/_fleet_archive.py +156 -0
- endia/_query.py +60 -0
- endia/_tar.py +207 -0
- endia/_wire.py +136 -0
- endia/archives.py +339 -0
- endia/bases.py +84 -0
- endia/client.py +158 -0
- endia/desktop.py +374 -0
- endia/errors.py +191 -0
- endia/execs.py +328 -0
- endia/fleets.py +447 -0
- endia/images.py +247 -0
- endia/machines.py +317 -0
- endia/operations.py +190 -0
- endia/processes.py +157 -0
- endia/purposes.py +147 -0
- endia/py.typed +0 -0
- endia/stats.py +92 -0
- endia/system.py +222 -0
- endia/volumes.py +63 -0
- endia-0.0.1.dist-info/METADATA +37 -0
- endia-0.0.1.dist-info/RECORD +28 -0
- endia-0.0.1.dist-info/WHEEL +4 -0
- endia-0.0.1.dist-info/licenses/LICENSE +202 -0
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
|