provide-foundation 0.0.0.dev0__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.
- provide/__init__.py +15 -0
- provide/foundation/__init__.py +155 -0
- provide/foundation/_version.py +58 -0
- provide/foundation/cli/__init__.py +67 -0
- provide/foundation/cli/commands/__init__.py +3 -0
- provide/foundation/cli/commands/deps.py +71 -0
- provide/foundation/cli/commands/logs/__init__.py +63 -0
- provide/foundation/cli/commands/logs/generate.py +357 -0
- provide/foundation/cli/commands/logs/generate_old.py +569 -0
- provide/foundation/cli/commands/logs/query.py +174 -0
- provide/foundation/cli/commands/logs/send.py +166 -0
- provide/foundation/cli/commands/logs/tail.py +112 -0
- provide/foundation/cli/decorators.py +262 -0
- provide/foundation/cli/main.py +65 -0
- provide/foundation/cli/testing.py +220 -0
- provide/foundation/cli/utils.py +210 -0
- provide/foundation/config/__init__.py +106 -0
- provide/foundation/config/base.py +295 -0
- provide/foundation/config/env.py +369 -0
- provide/foundation/config/loader.py +311 -0
- provide/foundation/config/manager.py +387 -0
- provide/foundation/config/schema.py +284 -0
- provide/foundation/config/sync.py +281 -0
- provide/foundation/config/types.py +78 -0
- provide/foundation/config/validators.py +80 -0
- provide/foundation/console/__init__.py +29 -0
- provide/foundation/console/input.py +364 -0
- provide/foundation/console/output.py +178 -0
- provide/foundation/context/__init__.py +12 -0
- provide/foundation/context/core.py +356 -0
- provide/foundation/core.py +20 -0
- provide/foundation/crypto/__init__.py +182 -0
- provide/foundation/crypto/algorithms.py +111 -0
- provide/foundation/crypto/certificates.py +896 -0
- provide/foundation/crypto/checksums.py +301 -0
- provide/foundation/crypto/constants.py +57 -0
- provide/foundation/crypto/hashing.py +265 -0
- provide/foundation/crypto/keys.py +188 -0
- provide/foundation/crypto/signatures.py +144 -0
- provide/foundation/crypto/utils.py +164 -0
- provide/foundation/errors/__init__.py +96 -0
- provide/foundation/errors/auth.py +73 -0
- provide/foundation/errors/base.py +81 -0
- provide/foundation/errors/config.py +103 -0
- provide/foundation/errors/context.py +299 -0
- provide/foundation/errors/decorators.py +484 -0
- provide/foundation/errors/handlers.py +360 -0
- provide/foundation/errors/integration.py +105 -0
- provide/foundation/errors/platform.py +37 -0
- provide/foundation/errors/process.py +140 -0
- provide/foundation/errors/resources.py +133 -0
- provide/foundation/errors/runtime.py +160 -0
- provide/foundation/errors/safe_decorators.py +133 -0
- provide/foundation/errors/types.py +276 -0
- provide/foundation/file/__init__.py +79 -0
- provide/foundation/file/atomic.py +157 -0
- provide/foundation/file/directory.py +134 -0
- provide/foundation/file/formats.py +236 -0
- provide/foundation/file/lock.py +175 -0
- provide/foundation/file/safe.py +179 -0
- provide/foundation/file/utils.py +170 -0
- provide/foundation/hub/__init__.py +88 -0
- provide/foundation/hub/click_builder.py +310 -0
- provide/foundation/hub/commands.py +42 -0
- provide/foundation/hub/components.py +640 -0
- provide/foundation/hub/decorators.py +244 -0
- provide/foundation/hub/info.py +32 -0
- provide/foundation/hub/manager.py +446 -0
- provide/foundation/hub/registry.py +279 -0
- provide/foundation/hub/type_mapping.py +54 -0
- provide/foundation/hub/types.py +28 -0
- provide/foundation/logger/__init__.py +41 -0
- provide/foundation/logger/base.py +22 -0
- provide/foundation/logger/config/__init__.py +16 -0
- provide/foundation/logger/config/base.py +40 -0
- provide/foundation/logger/config/logging.py +394 -0
- provide/foundation/logger/config/telemetry.py +188 -0
- provide/foundation/logger/core.py +239 -0
- provide/foundation/logger/custom_processors.py +172 -0
- provide/foundation/logger/emoji/__init__.py +44 -0
- provide/foundation/logger/emoji/matrix.py +209 -0
- provide/foundation/logger/emoji/sets.py +458 -0
- provide/foundation/logger/emoji/types.py +56 -0
- provide/foundation/logger/factories.py +56 -0
- provide/foundation/logger/processors/__init__.py +13 -0
- provide/foundation/logger/processors/main.py +254 -0
- provide/foundation/logger/processors/trace.py +113 -0
- provide/foundation/logger/ratelimit/__init__.py +31 -0
- provide/foundation/logger/ratelimit/limiters.py +294 -0
- provide/foundation/logger/ratelimit/processor.py +203 -0
- provide/foundation/logger/ratelimit/queue_limiter.py +305 -0
- provide/foundation/logger/setup/__init__.py +29 -0
- provide/foundation/logger/setup/coordinator.py +138 -0
- provide/foundation/logger/setup/emoji_resolver.py +64 -0
- provide/foundation/logger/setup/processors.py +85 -0
- provide/foundation/logger/setup/testing.py +39 -0
- provide/foundation/logger/trace.py +38 -0
- provide/foundation/metrics/__init__.py +119 -0
- provide/foundation/metrics/otel.py +122 -0
- provide/foundation/metrics/simple.py +165 -0
- provide/foundation/observability/__init__.py +53 -0
- provide/foundation/observability/openobserve/__init__.py +79 -0
- provide/foundation/observability/openobserve/auth.py +72 -0
- provide/foundation/observability/openobserve/client.py +307 -0
- provide/foundation/observability/openobserve/commands.py +357 -0
- provide/foundation/observability/openobserve/exceptions.py +41 -0
- provide/foundation/observability/openobserve/formatters.py +298 -0
- provide/foundation/observability/openobserve/models.py +134 -0
- provide/foundation/observability/openobserve/otlp.py +320 -0
- provide/foundation/observability/openobserve/search.py +222 -0
- provide/foundation/observability/openobserve/streaming.py +235 -0
- provide/foundation/platform/__init__.py +44 -0
- provide/foundation/platform/detection.py +193 -0
- provide/foundation/platform/info.py +157 -0
- provide/foundation/process/__init__.py +39 -0
- provide/foundation/process/async_runner.py +373 -0
- provide/foundation/process/lifecycle.py +406 -0
- provide/foundation/process/runner.py +390 -0
- provide/foundation/setup/__init__.py +101 -0
- provide/foundation/streams/__init__.py +44 -0
- provide/foundation/streams/console.py +57 -0
- provide/foundation/streams/core.py +65 -0
- provide/foundation/streams/file.py +104 -0
- provide/foundation/testing/__init__.py +166 -0
- provide/foundation/testing/cli.py +227 -0
- provide/foundation/testing/crypto.py +163 -0
- provide/foundation/testing/fixtures.py +49 -0
- provide/foundation/testing/hub.py +23 -0
- provide/foundation/testing/logger.py +106 -0
- provide/foundation/testing/streams.py +54 -0
- provide/foundation/tracer/__init__.py +49 -0
- provide/foundation/tracer/context.py +115 -0
- provide/foundation/tracer/otel.py +135 -0
- provide/foundation/tracer/spans.py +174 -0
- provide/foundation/types.py +32 -0
- provide/foundation/utils/__init__.py +97 -0
- provide/foundation/utils/deps.py +195 -0
- provide/foundation/utils/env.py +491 -0
- provide/foundation/utils/formatting.py +483 -0
- provide/foundation/utils/parsing.py +235 -0
- provide/foundation/utils/rate_limiting.py +112 -0
- provide/foundation/utils/streams.py +67 -0
- provide/foundation/utils/timing.py +93 -0
- provide_foundation-0.0.0.dev0.dist-info/METADATA +469 -0
- provide_foundation-0.0.0.dev0.dist-info/RECORD +149 -0
- provide_foundation-0.0.0.dev0.dist-info/WHEEL +5 -0
- provide_foundation-0.0.0.dev0.dist-info/entry_points.txt +2 -0
- provide_foundation-0.0.0.dev0.dist-info/licenses/LICENSE +201 -0
- provide_foundation-0.0.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,406 @@
|
|
1
|
+
"""
|
2
|
+
Process lifecycle management utilities.
|
3
|
+
|
4
|
+
This module provides utilities for managing long-running subprocesses with
|
5
|
+
proper lifecycle management, monitoring, and graceful shutdown capabilities.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import asyncio
|
9
|
+
from collections.abc import Mapping
|
10
|
+
import functools
|
11
|
+
import os
|
12
|
+
from pathlib import Path
|
13
|
+
import subprocess
|
14
|
+
import sys
|
15
|
+
import threading
|
16
|
+
import traceback
|
17
|
+
from typing import Any
|
18
|
+
|
19
|
+
from provide.foundation.logger import get_logger
|
20
|
+
from provide.foundation.process.runner import ProcessError
|
21
|
+
|
22
|
+
plog = get_logger(__name__)
|
23
|
+
|
24
|
+
|
25
|
+
class ManagedProcess:
|
26
|
+
"""
|
27
|
+
A managed subprocess with lifecycle support, monitoring, and graceful shutdown.
|
28
|
+
|
29
|
+
This class wraps subprocess.Popen with additional functionality for:
|
30
|
+
- Environment management
|
31
|
+
- Output streaming and monitoring
|
32
|
+
- Health checks and process monitoring
|
33
|
+
- Graceful shutdown with timeouts
|
34
|
+
- Background stderr relaying
|
35
|
+
"""
|
36
|
+
|
37
|
+
def __init__(
|
38
|
+
self,
|
39
|
+
command: list[str],
|
40
|
+
*,
|
41
|
+
cwd: str | Path | None = None,
|
42
|
+
env: Mapping[str, str] | None = None,
|
43
|
+
capture_output: bool = True,
|
44
|
+
text_mode: bool = False,
|
45
|
+
bufsize: int = 0,
|
46
|
+
stderr_relay: bool = True,
|
47
|
+
**kwargs: Any,
|
48
|
+
) -> None:
|
49
|
+
"""
|
50
|
+
Initialize a ManagedProcess.
|
51
|
+
|
52
|
+
Args:
|
53
|
+
command: Command and arguments to execute
|
54
|
+
cwd: Working directory for the process
|
55
|
+
env: Environment variables (merged with current environment)
|
56
|
+
capture_output: Whether to capture stdout/stderr
|
57
|
+
text_mode: Whether to use text mode for streams
|
58
|
+
bufsize: Buffer size for streams
|
59
|
+
stderr_relay: Whether to relay stderr to current process stderr
|
60
|
+
**kwargs: Additional arguments passed to subprocess.Popen
|
61
|
+
"""
|
62
|
+
self.command = command
|
63
|
+
self.cwd = str(cwd) if cwd else None
|
64
|
+
self.capture_output = capture_output
|
65
|
+
self.text_mode = text_mode
|
66
|
+
self.bufsize = bufsize
|
67
|
+
self.stderr_relay = stderr_relay
|
68
|
+
self.kwargs = kwargs
|
69
|
+
|
70
|
+
# Build environment
|
71
|
+
self._env = os.environ.copy()
|
72
|
+
if env:
|
73
|
+
self._env.update(env)
|
74
|
+
|
75
|
+
# Process state
|
76
|
+
self._process: subprocess.Popen[bytes] | None = None
|
77
|
+
self._stderr_thread: threading.Thread | None = None
|
78
|
+
self._started = False
|
79
|
+
|
80
|
+
plog.debug(
|
81
|
+
"๐ ManagedProcess initialized",
|
82
|
+
command=" ".join(command),
|
83
|
+
cwd=self.cwd,
|
84
|
+
)
|
85
|
+
|
86
|
+
@property
|
87
|
+
def process(self) -> subprocess.Popen[bytes] | None:
|
88
|
+
"""Get the underlying subprocess.Popen instance."""
|
89
|
+
return self._process
|
90
|
+
|
91
|
+
@property
|
92
|
+
def pid(self) -> int | None:
|
93
|
+
"""Get the process ID, if process is running."""
|
94
|
+
return self._process.pid if self._process else None
|
95
|
+
|
96
|
+
@property
|
97
|
+
def returncode(self) -> int | None:
|
98
|
+
"""Get the return code, if process has terminated."""
|
99
|
+
return self._process.returncode if self._process else None
|
100
|
+
|
101
|
+
def is_running(self) -> bool:
|
102
|
+
"""Check if the process is currently running."""
|
103
|
+
if not self._process:
|
104
|
+
return False
|
105
|
+
return self._process.poll() is None
|
106
|
+
|
107
|
+
def launch(self) -> None:
|
108
|
+
"""
|
109
|
+
Launch the managed process.
|
110
|
+
|
111
|
+
Raises:
|
112
|
+
ProcessError: If the process fails to launch
|
113
|
+
RuntimeError: If the process is already started
|
114
|
+
"""
|
115
|
+
if self._started:
|
116
|
+
raise RuntimeError("Process has already been started")
|
117
|
+
|
118
|
+
plog.debug("๐ Launching managed process", command=" ".join(self.command))
|
119
|
+
|
120
|
+
try:
|
121
|
+
self._process = subprocess.Popen(
|
122
|
+
self.command,
|
123
|
+
cwd=self.cwd,
|
124
|
+
env=self._env,
|
125
|
+
stdout=subprocess.PIPE if self.capture_output else None,
|
126
|
+
stderr=subprocess.PIPE if self.capture_output else None,
|
127
|
+
text=self.text_mode,
|
128
|
+
bufsize=self.bufsize,
|
129
|
+
**self.kwargs,
|
130
|
+
)
|
131
|
+
self._started = True
|
132
|
+
|
133
|
+
plog.info(
|
134
|
+
"๐ Managed process started successfully",
|
135
|
+
pid=self._process.pid,
|
136
|
+
command=" ".join(self.command),
|
137
|
+
)
|
138
|
+
|
139
|
+
# Start stderr relay if enabled
|
140
|
+
if self.stderr_relay and self._process.stderr:
|
141
|
+
self._start_stderr_relay()
|
142
|
+
|
143
|
+
except Exception as e:
|
144
|
+
plog.error(
|
145
|
+
"๐โ Failed to launch managed process",
|
146
|
+
command=" ".join(self.command),
|
147
|
+
error=str(e),
|
148
|
+
trace=traceback.format_exc(),
|
149
|
+
)
|
150
|
+
raise ProcessError(f"Failed to launch process: {e}") from e
|
151
|
+
|
152
|
+
def _start_stderr_relay(self) -> None:
|
153
|
+
"""Start a background thread to relay stderr output."""
|
154
|
+
if not self._process or not self._process.stderr:
|
155
|
+
return
|
156
|
+
|
157
|
+
def relay_stderr() -> None:
|
158
|
+
"""Relay stderr output to the current process stderr."""
|
159
|
+
process = self._process
|
160
|
+
if not process or not process.stderr:
|
161
|
+
return
|
162
|
+
|
163
|
+
try:
|
164
|
+
while True:
|
165
|
+
line = process.stderr.readline()
|
166
|
+
if not line:
|
167
|
+
break
|
168
|
+
# Handle both bytes and string returns from subprocess
|
169
|
+
if isinstance(line, bytes):
|
170
|
+
sys.stderr.write(line.decode("utf-8", errors="replace"))
|
171
|
+
else:
|
172
|
+
sys.stderr.write(str(line))
|
173
|
+
sys.stderr.flush()
|
174
|
+
except Exception as e:
|
175
|
+
plog.debug("Error in stderr relay", error=str(e))
|
176
|
+
|
177
|
+
self._stderr_thread = threading.Thread(target=relay_stderr, daemon=True)
|
178
|
+
self._stderr_thread.start()
|
179
|
+
plog.debug("๐ Started stderr relay thread")
|
180
|
+
|
181
|
+
async def read_line_async(self, timeout: float = 2.0) -> str:
|
182
|
+
"""
|
183
|
+
Read a line from stdout asynchronously with timeout.
|
184
|
+
|
185
|
+
Args:
|
186
|
+
timeout: Timeout for reading operation
|
187
|
+
|
188
|
+
Returns:
|
189
|
+
Decoded line from stdout
|
190
|
+
|
191
|
+
Raises:
|
192
|
+
ProcessError: If process is not running or stdout not available
|
193
|
+
TimeoutError: If read operation times out
|
194
|
+
"""
|
195
|
+
if not self._process or not self._process.stdout:
|
196
|
+
raise ProcessError("Process not running or stdout not available")
|
197
|
+
|
198
|
+
loop = asyncio.get_event_loop()
|
199
|
+
|
200
|
+
# Use functools.partial to avoid closure issues
|
201
|
+
read_func = functools.partial(self._process.stdout.readline)
|
202
|
+
|
203
|
+
try:
|
204
|
+
line_data = await asyncio.wait_for(
|
205
|
+
loop.run_in_executor(None, read_func), timeout=timeout
|
206
|
+
)
|
207
|
+
# Handle both bytes and string returns from subprocess
|
208
|
+
if isinstance(line_data, bytes):
|
209
|
+
return line_data.decode("utf-8", errors="replace").strip()
|
210
|
+
else:
|
211
|
+
return str(line_data).strip()
|
212
|
+
except TimeoutError as e:
|
213
|
+
plog.debug("Read timeout on managed process stdout")
|
214
|
+
raise TimeoutError(f"Read timeout after {timeout}s") from e
|
215
|
+
|
216
|
+
async def read_char_async(self, timeout: float = 1.0) -> str:
|
217
|
+
"""
|
218
|
+
Read a single character from stdout asynchronously.
|
219
|
+
|
220
|
+
Args:
|
221
|
+
timeout: Timeout for reading operation
|
222
|
+
|
223
|
+
Returns:
|
224
|
+
Single decoded character
|
225
|
+
|
226
|
+
Raises:
|
227
|
+
ProcessError: If process is not running or stdout not available
|
228
|
+
TimeoutError: If read operation times out
|
229
|
+
"""
|
230
|
+
if not self._process or not self._process.stdout:
|
231
|
+
raise ProcessError("Process not running or stdout not available")
|
232
|
+
|
233
|
+
loop = asyncio.get_event_loop()
|
234
|
+
|
235
|
+
# Use functools.partial to avoid closure issues
|
236
|
+
read_func = functools.partial(self._process.stdout.read, 1)
|
237
|
+
|
238
|
+
try:
|
239
|
+
char_data = await asyncio.wait_for(
|
240
|
+
loop.run_in_executor(None, read_func), timeout=timeout
|
241
|
+
)
|
242
|
+
if not char_data:
|
243
|
+
return ""
|
244
|
+
# Handle both bytes and string returns from subprocess
|
245
|
+
if isinstance(char_data, bytes):
|
246
|
+
return char_data.decode("utf-8", errors="replace")
|
247
|
+
else:
|
248
|
+
return str(char_data)
|
249
|
+
except TimeoutError as e:
|
250
|
+
plog.debug("Character read timeout on managed process stdout")
|
251
|
+
raise TimeoutError(f"Character read timeout after {timeout}s") from e
|
252
|
+
|
253
|
+
def terminate_gracefully(self, timeout: float = 7.0) -> bool:
|
254
|
+
"""
|
255
|
+
Terminate the process gracefully with a timeout.
|
256
|
+
|
257
|
+
Args:
|
258
|
+
timeout: Maximum time to wait for graceful termination
|
259
|
+
|
260
|
+
Returns:
|
261
|
+
True if process terminated gracefully, False if force-killed
|
262
|
+
"""
|
263
|
+
if not self._process:
|
264
|
+
return True
|
265
|
+
|
266
|
+
if self._process.poll() is not None:
|
267
|
+
plog.debug(
|
268
|
+
"Process already terminated", returncode=self._process.returncode
|
269
|
+
)
|
270
|
+
return True
|
271
|
+
|
272
|
+
plog.debug("๐ Terminating managed process gracefully", pid=self._process.pid)
|
273
|
+
|
274
|
+
try:
|
275
|
+
# Send SIGTERM
|
276
|
+
self._process.terminate()
|
277
|
+
plog.debug("๐ Sent SIGTERM to process", pid=self._process.pid)
|
278
|
+
|
279
|
+
# Wait for graceful termination
|
280
|
+
try:
|
281
|
+
self._process.wait(timeout=timeout)
|
282
|
+
plog.info("๐ Process terminated gracefully", pid=self._process.pid)
|
283
|
+
return True
|
284
|
+
except subprocess.TimeoutExpired:
|
285
|
+
plog.warning(
|
286
|
+
"๐ Process did not terminate gracefully, force killing",
|
287
|
+
pid=self._process.pid,
|
288
|
+
)
|
289
|
+
# Force kill
|
290
|
+
self._process.kill()
|
291
|
+
try:
|
292
|
+
self._process.wait(timeout=2.0)
|
293
|
+
plog.info("๐ Process force killed", pid=self._process.pid)
|
294
|
+
return False
|
295
|
+
except subprocess.TimeoutExpired:
|
296
|
+
plog.error("๐ Process could not be killed", pid=self._process.pid)
|
297
|
+
return False
|
298
|
+
|
299
|
+
except Exception as e:
|
300
|
+
plog.error(
|
301
|
+
"๐โ Error terminating process",
|
302
|
+
pid=self._process.pid if self._process else None,
|
303
|
+
error=str(e),
|
304
|
+
trace=traceback.format_exc(),
|
305
|
+
)
|
306
|
+
return False
|
307
|
+
|
308
|
+
def cleanup(self) -> None:
|
309
|
+
"""Clean up process resources."""
|
310
|
+
# Join stderr relay thread
|
311
|
+
if self._stderr_thread and self._stderr_thread.is_alive():
|
312
|
+
# Give it a moment to finish
|
313
|
+
self._stderr_thread.join(timeout=1.0)
|
314
|
+
|
315
|
+
# Clean up process reference
|
316
|
+
if self._process:
|
317
|
+
self._process = None
|
318
|
+
|
319
|
+
plog.debug("๐งน Managed process cleanup completed")
|
320
|
+
|
321
|
+
def __enter__(self) -> "ManagedProcess":
|
322
|
+
"""Context manager entry."""
|
323
|
+
self.launch()
|
324
|
+
return self
|
325
|
+
|
326
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
327
|
+
"""Context manager exit with cleanup."""
|
328
|
+
self.terminate_gracefully()
|
329
|
+
self.cleanup()
|
330
|
+
|
331
|
+
|
332
|
+
async def wait_for_process_output(
|
333
|
+
process: ManagedProcess,
|
334
|
+
expected_parts: list[str],
|
335
|
+
timeout: float = 10.0,
|
336
|
+
buffer_size: int = 1024,
|
337
|
+
) -> str:
|
338
|
+
"""
|
339
|
+
Wait for specific output pattern from a managed process.
|
340
|
+
|
341
|
+
This utility reads from a process stdout until a specific pattern
|
342
|
+
(e.g., handshake string with multiple pipe separators) appears.
|
343
|
+
|
344
|
+
Args:
|
345
|
+
process: The managed process to read from
|
346
|
+
expected_parts: List of expected parts/separators in the output
|
347
|
+
timeout: Maximum time to wait for the pattern
|
348
|
+
buffer_size: Size of read buffer
|
349
|
+
|
350
|
+
Returns:
|
351
|
+
The complete output buffer containing the expected pattern
|
352
|
+
|
353
|
+
Raises:
|
354
|
+
ProcessError: If process exits unexpectedly
|
355
|
+
TimeoutError: If pattern is not found within timeout
|
356
|
+
"""
|
357
|
+
loop = asyncio.get_event_loop()
|
358
|
+
start_time = loop.time()
|
359
|
+
buffer = ""
|
360
|
+
|
361
|
+
plog.debug(
|
362
|
+
"โณ Waiting for process output pattern",
|
363
|
+
expected_parts=expected_parts,
|
364
|
+
timeout=timeout,
|
365
|
+
)
|
366
|
+
|
367
|
+
while (loop.time() - start_time) < timeout:
|
368
|
+
# Check if process is still running
|
369
|
+
if not process.is_running():
|
370
|
+
returncode = process.returncode
|
371
|
+
plog.error("Process exited unexpectedly", returncode=returncode)
|
372
|
+
raise ProcessError(f"Process exited with code {returncode}")
|
373
|
+
|
374
|
+
try:
|
375
|
+
# Try to read a line first
|
376
|
+
line = await process.read_line_async(timeout=2.0)
|
377
|
+
if line:
|
378
|
+
buffer += line
|
379
|
+
plog.debug("Read line from process", line=line[:100])
|
380
|
+
|
381
|
+
# Check if we have all expected parts
|
382
|
+
if all(part in buffer for part in expected_parts):
|
383
|
+
plog.debug("Found expected pattern in buffer")
|
384
|
+
return buffer
|
385
|
+
|
386
|
+
except TimeoutError:
|
387
|
+
plog.debug("Line read timeout, trying character-by-character")
|
388
|
+
|
389
|
+
try:
|
390
|
+
# Fall back to character-by-character reading
|
391
|
+
char = await process.read_char_async(timeout=1.0)
|
392
|
+
if char:
|
393
|
+
buffer += char
|
394
|
+
plog.debug("Read character", char=repr(char), buffer_size=len(buffer))
|
395
|
+
|
396
|
+
# Check pattern again
|
397
|
+
if all(part in buffer for part in expected_parts):
|
398
|
+
plog.debug("Found expected pattern in buffer (char mode)")
|
399
|
+
return buffer
|
400
|
+
|
401
|
+
except TimeoutError:
|
402
|
+
await asyncio.sleep(0.25)
|
403
|
+
|
404
|
+
raise TimeoutError(
|
405
|
+
f"Expected pattern {expected_parts} not found within {timeout}s timeout"
|
406
|
+
)
|