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.
Files changed (149) hide show
  1. provide/__init__.py +15 -0
  2. provide/foundation/__init__.py +155 -0
  3. provide/foundation/_version.py +58 -0
  4. provide/foundation/cli/__init__.py +67 -0
  5. provide/foundation/cli/commands/__init__.py +3 -0
  6. provide/foundation/cli/commands/deps.py +71 -0
  7. provide/foundation/cli/commands/logs/__init__.py +63 -0
  8. provide/foundation/cli/commands/logs/generate.py +357 -0
  9. provide/foundation/cli/commands/logs/generate_old.py +569 -0
  10. provide/foundation/cli/commands/logs/query.py +174 -0
  11. provide/foundation/cli/commands/logs/send.py +166 -0
  12. provide/foundation/cli/commands/logs/tail.py +112 -0
  13. provide/foundation/cli/decorators.py +262 -0
  14. provide/foundation/cli/main.py +65 -0
  15. provide/foundation/cli/testing.py +220 -0
  16. provide/foundation/cli/utils.py +210 -0
  17. provide/foundation/config/__init__.py +106 -0
  18. provide/foundation/config/base.py +295 -0
  19. provide/foundation/config/env.py +369 -0
  20. provide/foundation/config/loader.py +311 -0
  21. provide/foundation/config/manager.py +387 -0
  22. provide/foundation/config/schema.py +284 -0
  23. provide/foundation/config/sync.py +281 -0
  24. provide/foundation/config/types.py +78 -0
  25. provide/foundation/config/validators.py +80 -0
  26. provide/foundation/console/__init__.py +29 -0
  27. provide/foundation/console/input.py +364 -0
  28. provide/foundation/console/output.py +178 -0
  29. provide/foundation/context/__init__.py +12 -0
  30. provide/foundation/context/core.py +356 -0
  31. provide/foundation/core.py +20 -0
  32. provide/foundation/crypto/__init__.py +182 -0
  33. provide/foundation/crypto/algorithms.py +111 -0
  34. provide/foundation/crypto/certificates.py +896 -0
  35. provide/foundation/crypto/checksums.py +301 -0
  36. provide/foundation/crypto/constants.py +57 -0
  37. provide/foundation/crypto/hashing.py +265 -0
  38. provide/foundation/crypto/keys.py +188 -0
  39. provide/foundation/crypto/signatures.py +144 -0
  40. provide/foundation/crypto/utils.py +164 -0
  41. provide/foundation/errors/__init__.py +96 -0
  42. provide/foundation/errors/auth.py +73 -0
  43. provide/foundation/errors/base.py +81 -0
  44. provide/foundation/errors/config.py +103 -0
  45. provide/foundation/errors/context.py +299 -0
  46. provide/foundation/errors/decorators.py +484 -0
  47. provide/foundation/errors/handlers.py +360 -0
  48. provide/foundation/errors/integration.py +105 -0
  49. provide/foundation/errors/platform.py +37 -0
  50. provide/foundation/errors/process.py +140 -0
  51. provide/foundation/errors/resources.py +133 -0
  52. provide/foundation/errors/runtime.py +160 -0
  53. provide/foundation/errors/safe_decorators.py +133 -0
  54. provide/foundation/errors/types.py +276 -0
  55. provide/foundation/file/__init__.py +79 -0
  56. provide/foundation/file/atomic.py +157 -0
  57. provide/foundation/file/directory.py +134 -0
  58. provide/foundation/file/formats.py +236 -0
  59. provide/foundation/file/lock.py +175 -0
  60. provide/foundation/file/safe.py +179 -0
  61. provide/foundation/file/utils.py +170 -0
  62. provide/foundation/hub/__init__.py +88 -0
  63. provide/foundation/hub/click_builder.py +310 -0
  64. provide/foundation/hub/commands.py +42 -0
  65. provide/foundation/hub/components.py +640 -0
  66. provide/foundation/hub/decorators.py +244 -0
  67. provide/foundation/hub/info.py +32 -0
  68. provide/foundation/hub/manager.py +446 -0
  69. provide/foundation/hub/registry.py +279 -0
  70. provide/foundation/hub/type_mapping.py +54 -0
  71. provide/foundation/hub/types.py +28 -0
  72. provide/foundation/logger/__init__.py +41 -0
  73. provide/foundation/logger/base.py +22 -0
  74. provide/foundation/logger/config/__init__.py +16 -0
  75. provide/foundation/logger/config/base.py +40 -0
  76. provide/foundation/logger/config/logging.py +394 -0
  77. provide/foundation/logger/config/telemetry.py +188 -0
  78. provide/foundation/logger/core.py +239 -0
  79. provide/foundation/logger/custom_processors.py +172 -0
  80. provide/foundation/logger/emoji/__init__.py +44 -0
  81. provide/foundation/logger/emoji/matrix.py +209 -0
  82. provide/foundation/logger/emoji/sets.py +458 -0
  83. provide/foundation/logger/emoji/types.py +56 -0
  84. provide/foundation/logger/factories.py +56 -0
  85. provide/foundation/logger/processors/__init__.py +13 -0
  86. provide/foundation/logger/processors/main.py +254 -0
  87. provide/foundation/logger/processors/trace.py +113 -0
  88. provide/foundation/logger/ratelimit/__init__.py +31 -0
  89. provide/foundation/logger/ratelimit/limiters.py +294 -0
  90. provide/foundation/logger/ratelimit/processor.py +203 -0
  91. provide/foundation/logger/ratelimit/queue_limiter.py +305 -0
  92. provide/foundation/logger/setup/__init__.py +29 -0
  93. provide/foundation/logger/setup/coordinator.py +138 -0
  94. provide/foundation/logger/setup/emoji_resolver.py +64 -0
  95. provide/foundation/logger/setup/processors.py +85 -0
  96. provide/foundation/logger/setup/testing.py +39 -0
  97. provide/foundation/logger/trace.py +38 -0
  98. provide/foundation/metrics/__init__.py +119 -0
  99. provide/foundation/metrics/otel.py +122 -0
  100. provide/foundation/metrics/simple.py +165 -0
  101. provide/foundation/observability/__init__.py +53 -0
  102. provide/foundation/observability/openobserve/__init__.py +79 -0
  103. provide/foundation/observability/openobserve/auth.py +72 -0
  104. provide/foundation/observability/openobserve/client.py +307 -0
  105. provide/foundation/observability/openobserve/commands.py +357 -0
  106. provide/foundation/observability/openobserve/exceptions.py +41 -0
  107. provide/foundation/observability/openobserve/formatters.py +298 -0
  108. provide/foundation/observability/openobserve/models.py +134 -0
  109. provide/foundation/observability/openobserve/otlp.py +320 -0
  110. provide/foundation/observability/openobserve/search.py +222 -0
  111. provide/foundation/observability/openobserve/streaming.py +235 -0
  112. provide/foundation/platform/__init__.py +44 -0
  113. provide/foundation/platform/detection.py +193 -0
  114. provide/foundation/platform/info.py +157 -0
  115. provide/foundation/process/__init__.py +39 -0
  116. provide/foundation/process/async_runner.py +373 -0
  117. provide/foundation/process/lifecycle.py +406 -0
  118. provide/foundation/process/runner.py +390 -0
  119. provide/foundation/setup/__init__.py +101 -0
  120. provide/foundation/streams/__init__.py +44 -0
  121. provide/foundation/streams/console.py +57 -0
  122. provide/foundation/streams/core.py +65 -0
  123. provide/foundation/streams/file.py +104 -0
  124. provide/foundation/testing/__init__.py +166 -0
  125. provide/foundation/testing/cli.py +227 -0
  126. provide/foundation/testing/crypto.py +163 -0
  127. provide/foundation/testing/fixtures.py +49 -0
  128. provide/foundation/testing/hub.py +23 -0
  129. provide/foundation/testing/logger.py +106 -0
  130. provide/foundation/testing/streams.py +54 -0
  131. provide/foundation/tracer/__init__.py +49 -0
  132. provide/foundation/tracer/context.py +115 -0
  133. provide/foundation/tracer/otel.py +135 -0
  134. provide/foundation/tracer/spans.py +174 -0
  135. provide/foundation/types.py +32 -0
  136. provide/foundation/utils/__init__.py +97 -0
  137. provide/foundation/utils/deps.py +195 -0
  138. provide/foundation/utils/env.py +491 -0
  139. provide/foundation/utils/formatting.py +483 -0
  140. provide/foundation/utils/parsing.py +235 -0
  141. provide/foundation/utils/rate_limiting.py +112 -0
  142. provide/foundation/utils/streams.py +67 -0
  143. provide/foundation/utils/timing.py +93 -0
  144. provide_foundation-0.0.0.dev0.dist-info/METADATA +469 -0
  145. provide_foundation-0.0.0.dev0.dist-info/RECORD +149 -0
  146. provide_foundation-0.0.0.dev0.dist-info/WHEEL +5 -0
  147. provide_foundation-0.0.0.dev0.dist-info/entry_points.txt +2 -0
  148. provide_foundation-0.0.0.dev0.dist-info/licenses/LICENSE +201 -0
  149. 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
+ )