mrok 0.3.0__py3-none-any.whl → 0.4.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.
Files changed (47) hide show
  1. mrok/agent/devtools/__init__.py +0 -0
  2. mrok/agent/devtools/__main__.py +34 -0
  3. mrok/agent/devtools/inspector/__init__.py +0 -0
  4. mrok/agent/devtools/inspector/__main__.py +25 -0
  5. mrok/agent/devtools/inspector/app.py +556 -0
  6. mrok/agent/devtools/inspector/server.py +18 -0
  7. mrok/agent/sidecar/app.py +9 -10
  8. mrok/agent/sidecar/main.py +35 -16
  9. mrok/agent/ziticorn.py +27 -18
  10. mrok/cli/commands/__init__.py +2 -1
  11. mrok/cli/commands/agent/__init__.py +2 -0
  12. mrok/cli/commands/agent/dev/__init__.py +7 -0
  13. mrok/cli/commands/agent/dev/console.py +25 -0
  14. mrok/cli/commands/agent/dev/web.py +37 -0
  15. mrok/cli/commands/agent/run/asgi.py +35 -16
  16. mrok/cli/commands/agent/run/sidecar.py +29 -13
  17. mrok/cli/commands/agent/utils.py +5 -0
  18. mrok/cli/commands/controller/run.py +1 -5
  19. mrok/cli/commands/proxy/__init__.py +6 -0
  20. mrok/cli/commands/proxy/run.py +49 -0
  21. mrok/cli/utils.py +5 -0
  22. mrok/conf.py +6 -0
  23. mrok/controller/auth.py +2 -2
  24. mrok/datastructures.py +159 -0
  25. mrok/http/config.py +3 -6
  26. mrok/http/constants.py +22 -0
  27. mrok/http/forwarder.py +62 -23
  28. mrok/http/lifespan.py +29 -0
  29. mrok/http/middlewares.py +143 -0
  30. mrok/http/types.py +43 -0
  31. mrok/http/utils.py +90 -0
  32. mrok/logging.py +22 -0
  33. mrok/master.py +272 -0
  34. mrok/metrics.py +139 -0
  35. mrok/proxy/__init__.py +3 -0
  36. mrok/proxy/app.py +77 -0
  37. mrok/proxy/dataclasses.py +12 -0
  38. mrok/proxy/main.py +58 -0
  39. mrok/proxy/streams.py +124 -0
  40. mrok/proxy/types.py +12 -0
  41. mrok/proxy/ziti.py +173 -0
  42. {mrok-0.3.0.dist-info → mrok-0.4.1.dist-info}/METADATA +7 -1
  43. {mrok-0.3.0.dist-info → mrok-0.4.1.dist-info}/RECORD +46 -20
  44. mrok/http/master.py +0 -132
  45. {mrok-0.3.0.dist-info → mrok-0.4.1.dist-info}/WHEEL +0 -0
  46. {mrok-0.3.0.dist-info → mrok-0.4.1.dist-info}/entry_points.txt +0 -0
  47. {mrok-0.3.0.dist-info → mrok-0.4.1.dist-info}/licenses/LICENSE.txt +0 -0
mrok/http/forwarder.py CHANGED
@@ -1,14 +1,10 @@
1
1
  import abc
2
2
  import asyncio
3
3
  import logging
4
- from collections.abc import Awaitable, Callable
5
- from typing import Any
6
4
 
7
- logger = logging.getLogger("mrok.proxy")
5
+ from mrok.http.types import ASGIReceive, ASGISend, Scope, StreamReader, StreamWriter
8
6
 
9
- Scope = dict[str, Any]
10
- ASGIReceive = Callable[[], Awaitable[dict[str, Any]]]
11
- ASGISend = Callable[[dict[str, Any]], Awaitable[None]]
7
+ logger = logging.getLogger("mrok.proxy")
12
8
 
13
9
 
14
10
  class BackendNotFoundError(Exception):
@@ -24,24 +20,71 @@ class ForwardAppBase(abc.ABC):
24
20
  and streaming logic (requests and responses).
25
21
  """
26
22
 
27
- def __init__(self, read_chunk_size: int = 65536) -> None:
28
- # number of bytes to read per iteration when streaming bodies
23
+ def __init__(
24
+ self,
25
+ read_chunk_size: int = 65536,
26
+ lifespan_timeout: float = 10.0,
27
+ ) -> None:
29
28
  self._read_chunk_size: int = int(read_chunk_size)
29
+ self._lifespan_timeout = lifespan_timeout
30
+
31
+ async def handle_lifespan(self, receive: ASGIReceive, send: ASGISend) -> None:
32
+ while True:
33
+ event = await receive()
34
+ etype = event.get("type")
35
+
36
+ if etype == "lifespan.startup":
37
+ try:
38
+ await asyncio.wait_for(self.startup(), self._lifespan_timeout)
39
+ except TimeoutError:
40
+ logger.exception("Lifespan startup timed out")
41
+ await send({"type": "lifespan.startup.failed", "message": "startup timeout"})
42
+ continue
43
+ except Exception as e:
44
+ logger.exception("Exception during lifespan startup")
45
+ await send({"type": "lifespan.startup.failed", "message": str(e)})
46
+ continue
47
+ await send({"type": "lifespan.startup.complete"})
48
+
49
+ elif etype == "lifespan.shutdown":
50
+ try:
51
+ await asyncio.wait_for(self.shutdown(), self._lifespan_timeout)
52
+ except TimeoutError:
53
+ logger.exception("Lifespan shutdown timed out")
54
+ await send({"type": "lifespan.shutdown.failed", "message": "shutdown timeout"})
55
+ return
56
+ except Exception as exc:
57
+ logger.exception("Exception during lifespan shutdown")
58
+ await send({"type": "lifespan.shutdown.failed", "message": str(exc)})
59
+ return
60
+ await send({"type": "lifespan.shutdown.complete"})
61
+ return
30
62
 
31
63
  @abc.abstractmethod
32
64
  async def select_backend(
33
65
  self,
34
66
  scope: Scope,
35
67
  headers: dict[str, str],
36
- ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter] | tuple[None, None]:
68
+ ) -> tuple[StreamReader, StreamWriter] | tuple[None, None]:
37
69
  """Return (reader, writer) connected to the target backend."""
38
70
 
71
+ async def startup(self):
72
+ return
73
+
74
+ async def shutdown(self):
75
+ return
76
+
39
77
  async def __call__(self, scope: Scope, receive: ASGIReceive, send: ASGISend) -> None:
40
78
  """ASGI callable entry point.
41
79
 
42
80
  Delegates to smaller helper methods for readability. Subclasses only
43
81
  need to implement backend selection.
44
82
  """
83
+ scope_type = scope.get("type")
84
+ if scope_type == "lifespan":
85
+ await self.handle_lifespan(receive, send)
86
+ return
87
+
45
88
  if scope.get("type") != "http":
46
89
  await send({"type": "http.response.start", "status": 500, "headers": []})
47
90
  await send({"type": "http.response.body", "body": b"Unsupported"})
@@ -116,7 +159,7 @@ class ForwardAppBase(abc.ABC):
116
159
 
117
160
  async def write_request_line_and_headers(
118
161
  self,
119
- writer: asyncio.StreamWriter,
162
+ writer: StreamWriter,
120
163
  method: str,
121
164
  path_qs: str,
122
165
  headers: list[tuple[bytes, bytes]],
@@ -130,7 +173,7 @@ class ForwardAppBase(abc.ABC):
130
173
  await writer.drain()
131
174
 
132
175
  async def stream_request_body(
133
- self, receive: ASGIReceive, writer: asyncio.StreamWriter, use_chunked: bool
176
+ self, receive: ASGIReceive, writer: StreamWriter, use_chunked: bool
134
177
  ) -> None:
135
178
  if use_chunked:
136
179
  await self.stream_request_chunked(receive, writer)
@@ -138,9 +181,7 @@ class ForwardAppBase(abc.ABC):
138
181
 
139
182
  await self.stream_request_until_end(receive, writer)
140
183
 
141
- async def stream_request_chunked(
142
- self, receive: ASGIReceive, writer: asyncio.StreamWriter
143
- ) -> None:
184
+ async def stream_request_chunked(self, receive: ASGIReceive, writer: StreamWriter) -> None:
144
185
  """Send request body to backend using HTTP/1.1 chunked encoding."""
145
186
  while True:
146
187
  event = await receive()
@@ -160,9 +201,7 @@ class ForwardAppBase(abc.ABC):
160
201
  writer.write(b"0\r\n\r\n")
161
202
  await writer.drain()
162
203
 
163
- async def stream_request_until_end(
164
- self, receive: ASGIReceive, writer: asyncio.StreamWriter
165
- ) -> None:
204
+ async def stream_request_until_end(self, receive: ASGIReceive, writer: StreamWriter) -> None:
166
205
  """Send request body to backend when content length/transfer-encoding
167
206
  already provided (no chunking).
168
207
  """
@@ -180,7 +219,7 @@ class ForwardAppBase(abc.ABC):
180
219
  return
181
220
 
182
221
  async def read_status_and_headers(
183
- self, reader: asyncio.StreamReader, first_line: bytes
222
+ self, reader: StreamReader, first_line: bytes
184
223
  ) -> tuple[int, list[tuple[bytes, bytes]], dict[bytes, bytes]]:
185
224
  parts = first_line.decode(errors="ignore").split(" ", 2)
186
225
  status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else 502
@@ -217,14 +256,14 @@ class ForwardAppBase(abc.ABC):
217
256
  except Exception:
218
257
  return None
219
258
 
220
- async def drain_trailers(self, reader: asyncio.StreamReader) -> None:
259
+ async def drain_trailers(self, reader: StreamReader) -> None:
221
260
  """Consume trailer header lines until an empty line is encountered."""
222
261
  while True:
223
262
  trailer = await reader.readline()
224
263
  if trailer in (b"\r\n", b"\n", b""):
225
264
  break
226
265
 
227
- async def stream_response_chunked(self, reader: asyncio.StreamReader, send: ASGISend) -> None:
266
+ async def stream_response_chunked(self, reader: StreamReader, send: ASGISend) -> None:
228
267
  """Read chunked-encoded response from reader, decode and forward to ASGI send."""
229
268
  while True:
230
269
  size_line = await reader.readline()
@@ -253,7 +292,7 @@ class ForwardAppBase(abc.ABC):
253
292
  await send({"type": "http.response.body", "body": b"", "more_body": False})
254
293
 
255
294
  async def stream_response_with_content_length(
256
- self, reader: asyncio.StreamReader, send: ASGISend, content_length: int
295
+ self, reader: StreamReader, send: ASGISend, content_length: int
257
296
  ) -> None:
258
297
  """Read exactly content_length bytes and forward to ASGI send events."""
259
298
  remaining = content_length
@@ -272,7 +311,7 @@ class ForwardAppBase(abc.ABC):
272
311
  if not sent_final:
273
312
  await send({"type": "http.response.body", "body": b"", "more_body": False})
274
313
 
275
- async def stream_response_until_eof(self, reader: asyncio.StreamReader, send: ASGISend) -> None:
314
+ async def stream_response_until_eof(self, reader: StreamReader, send: ASGISend) -> None:
276
315
  """Read from reader until EOF and forward chunks to ASGI send events."""
277
316
  while True:
278
317
  chunk = await reader.read(self._read_chunk_size)
@@ -282,7 +321,7 @@ class ForwardAppBase(abc.ABC):
282
321
  await send({"type": "http.response.body", "body": b"", "more_body": False})
283
322
 
284
323
  async def stream_response_body(
285
- self, reader: asyncio.StreamReader, send: ASGISend, raw_headers: dict[bytes, bytes]
324
+ self, reader: StreamReader, send: ASGISend, raw_headers: dict[bytes, bytes]
286
325
  ) -> None:
287
326
  te = raw_headers.get(b"transfer-encoding", b"").lower()
288
327
  cl = raw_headers.get(b"content-length")
mrok/http/lifespan.py CHANGED
@@ -1,10 +1,39 @@
1
1
  import logging
2
+ from collections.abc import Awaitable, Callable
2
3
 
3
4
  from uvicorn.config import Config
4
5
  from uvicorn.lifespan.on import LifespanOn
5
6
 
7
+ AsyncCallback = Callable[[], Awaitable[None]]
8
+
6
9
 
7
10
  class MrokLifespan(LifespanOn):
8
11
  def __init__(self, config: Config) -> None:
9
12
  super().__init__(config)
10
13
  self.logger = logging.getLogger("mrok.proxy")
14
+
15
+
16
+ class LifespanWrapper:
17
+ def __init__(
18
+ self, app, on_startup: AsyncCallback | None = None, on_shutdown: AsyncCallback | None = None
19
+ ):
20
+ self.app = app
21
+ self.on_startup = on_startup
22
+ self.on_shutdown = on_shutdown
23
+
24
+ async def __call__(self, scope, receive, send):
25
+ if scope["type"] == "lifespan":
26
+ while True:
27
+ event = await receive()
28
+ if event["type"] == "lifespan.startup":
29
+ if self.on_startup:
30
+ await self.on_startup()
31
+ await send({"type": "lifespan.startup.complete"})
32
+
33
+ elif event["type"] == "lifespan.shutdown":
34
+ if self.on_shutdown:
35
+ await self.on_shutdown()
36
+ await send({"type": "lifespan.shutdown.complete"})
37
+ break
38
+ else:
39
+ await self.app(scope, receive, send)
@@ -0,0 +1,143 @@
1
+ import asyncio
2
+ import inspect
3
+ import logging
4
+ import time
5
+ from collections.abc import Callable, Coroutine
6
+ from typing import Any
7
+
8
+ from mrok.datastructures import FixedSizeByteBuffer, HTTPHeaders, HTTPRequest, HTTPResponse
9
+ from mrok.http.constants import MAX_REQUEST_BODY_BYTES, MAX_RESPONSE_BODY_BYTES
10
+ from mrok.http.types import ASGIApp, ASGIReceive, ASGISend, Message, Scope
11
+ from mrok.http.utils import must_capture_request, must_capture_response
12
+ from mrok.metrics import WorkerMetricsCollector
13
+
14
+ logger = logging.getLogger("mrok.proxy")
15
+
16
+ ResponseCompleteCallback = Callable[[HTTPResponse], Coroutine[Any, Any, None] | None]
17
+
18
+
19
+ class CaptureMiddleware:
20
+ def __init__(
21
+ self,
22
+ app: ASGIApp,
23
+ on_response_complete: ResponseCompleteCallback,
24
+ ):
25
+ self.app = app
26
+ self._on_response_complete = on_response_complete
27
+
28
+ async def __call__(self, scope: Scope, receive: ASGIReceive, send: ASGISend):
29
+ if scope["type"] != "http":
30
+ await self.app(scope, receive, send)
31
+ return
32
+
33
+ start_time = time.time()
34
+ method = scope["method"]
35
+ path = scope["path"]
36
+ query_string = scope.get("query_string", b"")
37
+ req_headers_raw = scope.get("headers", [])
38
+ req_headers = HTTPHeaders.from_asgi(req_headers_raw)
39
+
40
+ state = {}
41
+
42
+ req_buf = FixedSizeByteBuffer(MAX_REQUEST_BODY_BYTES)
43
+ capture_req_body = must_capture_request(method, req_headers)
44
+
45
+ request = HTTPRequest(
46
+ method=method,
47
+ url=path,
48
+ headers=req_headers,
49
+ query_string=query_string,
50
+ start_time=start_time,
51
+ )
52
+
53
+ # Response capture
54
+ resp_buf = FixedSizeByteBuffer(MAX_RESPONSE_BODY_BYTES)
55
+
56
+ async def receive_wrapper() -> Message:
57
+ msg = await receive()
58
+ if capture_req_body and msg["type"] == "http.request":
59
+ body = msg.get("body", b"")
60
+ req_buf.write(body)
61
+ return msg
62
+
63
+ async def send_wrapper(msg: Message):
64
+ if msg["type"] == "http.response.start":
65
+ state["status"] = msg["status"]
66
+ resp_headers = HTTPHeaders.from_asgi(msg.get("headers", []))
67
+ state["resp_headers_raw"] = resp_headers
68
+
69
+ state["capture_resp_body"] = must_capture_response(resp_headers)
70
+
71
+ if state["capture_resp_body"] and msg["type"] == "http.response.body":
72
+ body = msg.get("body", b"")
73
+ resp_buf.write(body)
74
+
75
+ await send(msg)
76
+
77
+ await self.app(scope, receive_wrapper, send_wrapper)
78
+
79
+ # Finalise request
80
+ request.body = req_buf.getvalue() if capture_req_body else None
81
+ request.body_truncated = req_buf.overflow if capture_req_body else None
82
+
83
+ # Finalise response
84
+ end_time = time.time()
85
+ duration = end_time - start_time
86
+
87
+ response = HTTPResponse(
88
+ request=request,
89
+ status=state["status"] or 0,
90
+ headers=state["resp_headers_raw"],
91
+ duration=duration,
92
+ body=resp_buf.getvalue() if state["capture_resp_body"] else None,
93
+ body_truncated=resp_buf.overflow if state["capture_resp_body"] else None,
94
+ )
95
+ await asyncio.create_task(self.handle_callback(response))
96
+
97
+ async def handle_callback(self, response: HTTPResponse):
98
+ try:
99
+ if inspect.iscoroutinefunction(self._on_response_complete):
100
+ await self._on_response_complete(response)
101
+ else:
102
+ await asyncio.get_running_loop().run_in_executor(
103
+ None, self._on_response_complete, response
104
+ )
105
+ except Exception:
106
+ logger.exception("Error invoking callback")
107
+
108
+
109
+ class MetricsMiddleware:
110
+ def __init__(self, app, metrics: WorkerMetricsCollector):
111
+ self.app = app
112
+ self.metrics = metrics
113
+
114
+ async def __call__(self, scope, receive, send):
115
+ if scope["type"] != "http":
116
+ return await self.app(scope, receive, send)
117
+
118
+ start_time = await self.metrics.on_request_start(scope)
119
+ status_code = 500 # default if app errors early
120
+
121
+ async def wrapped_receive():
122
+ msg = await receive()
123
+ if msg["type"] == "http.request" and msg.get("body"):
124
+ await self.metrics.on_request_body(len(msg["body"]))
125
+ return msg
126
+
127
+ async def wrapped_send(msg):
128
+ nonlocal status_code
129
+
130
+ if msg["type"] == "http.response.start":
131
+ status_code = msg["status"]
132
+ await self.metrics.on_response_start(status_code)
133
+
134
+ elif msg["type"] == "http.response.body":
135
+ body = msg.get("body", b"")
136
+ await self.metrics.on_response_chunk(len(body))
137
+
138
+ return await send(msg)
139
+
140
+ try:
141
+ await self.app(scope, wrapped_receive, wrapped_send)
142
+ finally:
143
+ await self.metrics.on_request_end(start_time, status_code)
mrok/http/types.py ADDED
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable, MutableMapping
5
+ from typing import Any, Protocol
6
+
7
+ from mrok.datastructures import HTTPRequest, HTTPResponse
8
+
9
+ Scope = MutableMapping[str, Any]
10
+ Message = MutableMapping[str, Any]
11
+
12
+ ASGIReceive = Callable[[], Awaitable[Message]]
13
+ ASGISend = Callable[[Message], Awaitable[None]]
14
+ ASGIApp = Callable[[Scope, ASGIReceive, ASGISend], Awaitable[None]]
15
+ RequestCompleteCallback = Callable[[HTTPRequest], Awaitable | None]
16
+ ResponseCompleteCallback = Callable[[HTTPResponse], Awaitable | None]
17
+
18
+
19
+ class StreamReaderWrapper(Protocol):
20
+ async def read(self, n: int = -1) -> bytes: ...
21
+ async def readexactly(self, n: int) -> bytes: ...
22
+ async def readline(self) -> bytes: ...
23
+ def at_eof(self) -> bool: ...
24
+
25
+ @property
26
+ def underlying(self) -> asyncio.StreamReader: ...
27
+
28
+
29
+ class StreamWriterWrapper(Protocol):
30
+ def write(self, data: bytes) -> None: ...
31
+ async def drain(self) -> None: ...
32
+ def close(self) -> None: ...
33
+ async def wait_closed(self) -> None: ...
34
+
35
+ @property
36
+ def transport(self): ...
37
+
38
+ @property
39
+ def underlying(self) -> asyncio.StreamWriter: ...
40
+
41
+
42
+ StreamReader = StreamReaderWrapper | asyncio.StreamReader
43
+ StreamWriter = StreamWriterWrapper | asyncio.StreamWriter
mrok/http/utils.py ADDED
@@ -0,0 +1,90 @@
1
+ from collections.abc import Mapping
2
+
3
+ from mrok.http.constants import (
4
+ BINARY_CONTENT_TYPES,
5
+ BINARY_PREFIXES,
6
+ MAX_REQUEST_BODY_BYTES,
7
+ MAX_RESPONSE_BODY_BYTES,
8
+ TEXTUAL_CONTENT_TYPES,
9
+ TEXTUAL_PREFIXES,
10
+ )
11
+
12
+
13
+ def is_binary(content_type: str) -> bool:
14
+ ct = content_type.lower()
15
+ if ct in BINARY_CONTENT_TYPES:
16
+ return True
17
+ if any(ct.startswith(p) for p in BINARY_PREFIXES):
18
+ return True
19
+ return False
20
+
21
+
22
+ def is_textual(content_type: str) -> bool:
23
+ ct = content_type.lower()
24
+ if ct in TEXTUAL_CONTENT_TYPES:
25
+ return True
26
+ if any(ct.startswith(p) for p in TEXTUAL_PREFIXES):
27
+ return True
28
+ return False
29
+
30
+
31
+ def must_capture_request(
32
+ method: str,
33
+ headers: Mapping,
34
+ ) -> bool:
35
+ method = method.upper()
36
+
37
+ # No body expected
38
+ if method in ("GET", "HEAD", "OPTIONS", "TRACE"):
39
+ return False
40
+
41
+ content_type = headers.get("content-type", "").lower()
42
+
43
+ content_length = None
44
+ if "content-length" in headers:
45
+ content_length = int(headers["content-length"])
46
+
47
+ if is_binary(content_type):
48
+ return False
49
+
50
+ if content_type.startswith("multipart/form-data"):
51
+ return False
52
+
53
+ if content_length is not None and content_length > MAX_REQUEST_BODY_BYTES:
54
+ return False
55
+
56
+ if is_textual(content_type):
57
+ return True
58
+
59
+ if content_length is None:
60
+ return True
61
+
62
+ return content_length <= MAX_REQUEST_BODY_BYTES
63
+
64
+
65
+ def must_capture_response(
66
+ headers: Mapping,
67
+ ) -> bool:
68
+ content_type = headers.get("content-type", "").lower()
69
+ disposition = headers.get("content-disposition", "").lower()
70
+
71
+ content_length = None
72
+ if "content-length" in headers:
73
+ content_length = int(headers["content-length"])
74
+
75
+ if "attachment" in disposition:
76
+ return False
77
+
78
+ if is_binary(content_type):
79
+ return False
80
+
81
+ if content_length is not None and content_length > MAX_RESPONSE_BODY_BYTES:
82
+ return False
83
+
84
+ if is_textual(content_type):
85
+ return True
86
+
87
+ if content_length is None:
88
+ return True
89
+
90
+ return content_length <= MAX_RESPONSE_BODY_BYTES
mrok/logging.py CHANGED
@@ -1,5 +1,9 @@
1
1
  import logging.config
2
2
 
3
+ from rich.console import Console
4
+ from rich.logging import RichHandler
5
+ from textual_serve.server import LogHighlighter
6
+
3
7
  from mrok.conf import Settings
4
8
 
5
9
 
@@ -74,3 +78,21 @@ def get_logging_config(settings: Settings, cli_mode: bool = False) -> dict:
74
78
  def setup_logging(settings: Settings, cli_mode: bool = False) -> None:
75
79
  logging_config = get_logging_config(settings, cli_mode)
76
80
  logging.config.dictConfig(logging_config)
81
+
82
+
83
+ def setup_inspector_logging(console: Console) -> None:
84
+ logging.basicConfig(
85
+ level="WARNING",
86
+ format="%(message)s",
87
+ datefmt="%Y-%m-%d %H:%M:%S",
88
+ handlers=[
89
+ RichHandler(
90
+ show_path=False,
91
+ show_time=True,
92
+ rich_tracebacks=True,
93
+ tracebacks_show_locals=True,
94
+ highlighter=LogHighlighter(),
95
+ console=console,
96
+ )
97
+ ],
98
+ )