provide-foundation 0.0.0.dev0__py3-none-any.whl → 0.0.0.dev2__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 (161) hide show
  1. provide/foundation/__init__.py +41 -23
  2. provide/foundation/archive/__init__.py +23 -0
  3. provide/foundation/archive/base.py +70 -0
  4. provide/foundation/archive/bzip2.py +157 -0
  5. provide/foundation/archive/gzip.py +159 -0
  6. provide/foundation/archive/operations.py +334 -0
  7. provide/foundation/archive/tar.py +164 -0
  8. provide/foundation/archive/zip.py +203 -0
  9. provide/foundation/cli/__init__.py +2 -2
  10. provide/foundation/cli/commands/deps.py +13 -7
  11. provide/foundation/cli/commands/logs/__init__.py +1 -1
  12. provide/foundation/cli/commands/logs/query.py +1 -1
  13. provide/foundation/cli/commands/logs/send.py +1 -1
  14. provide/foundation/cli/commands/logs/tail.py +1 -1
  15. provide/foundation/cli/decorators.py +11 -10
  16. provide/foundation/cli/main.py +1 -1
  17. provide/foundation/cli/testing.py +2 -35
  18. provide/foundation/cli/utils.py +21 -17
  19. provide/foundation/config/__init__.py +35 -2
  20. provide/foundation/config/base.py +2 -2
  21. provide/foundation/config/converters.py +479 -0
  22. provide/foundation/config/defaults.py +67 -0
  23. provide/foundation/config/env.py +4 -19
  24. provide/foundation/config/loader.py +9 -3
  25. provide/foundation/config/sync.py +19 -4
  26. provide/foundation/console/input.py +5 -5
  27. provide/foundation/console/output.py +35 -13
  28. provide/foundation/context/__init__.py +8 -4
  29. provide/foundation/context/core.py +85 -109
  30. provide/foundation/core.py +1 -2
  31. provide/foundation/crypto/__init__.py +2 -0
  32. provide/foundation/crypto/certificates/__init__.py +34 -0
  33. provide/foundation/crypto/certificates/base.py +173 -0
  34. provide/foundation/crypto/certificates/certificate.py +290 -0
  35. provide/foundation/crypto/certificates/factory.py +213 -0
  36. provide/foundation/crypto/certificates/generator.py +138 -0
  37. provide/foundation/crypto/certificates/loader.py +130 -0
  38. provide/foundation/crypto/certificates/operations.py +198 -0
  39. provide/foundation/crypto/certificates/trust.py +107 -0
  40. provide/foundation/errors/__init__.py +2 -3
  41. provide/foundation/errors/decorators.py +0 -231
  42. provide/foundation/errors/types.py +0 -97
  43. provide/foundation/eventsets/__init__.py +0 -0
  44. provide/foundation/eventsets/display.py +84 -0
  45. provide/foundation/eventsets/registry.py +160 -0
  46. provide/foundation/eventsets/resolver.py +192 -0
  47. provide/foundation/eventsets/sets/das.py +128 -0
  48. provide/foundation/eventsets/sets/database.py +125 -0
  49. provide/foundation/eventsets/sets/http.py +153 -0
  50. provide/foundation/eventsets/sets/llm.py +139 -0
  51. provide/foundation/eventsets/sets/task_queue.py +107 -0
  52. provide/foundation/eventsets/types.py +70 -0
  53. provide/foundation/file/directory.py +13 -22
  54. provide/foundation/file/lock.py +3 -1
  55. provide/foundation/hub/components.py +77 -515
  56. provide/foundation/hub/config.py +151 -0
  57. provide/foundation/hub/discovery.py +62 -0
  58. provide/foundation/hub/handlers.py +81 -0
  59. provide/foundation/hub/lifecycle.py +194 -0
  60. provide/foundation/hub/manager.py +4 -4
  61. provide/foundation/hub/processors.py +44 -0
  62. provide/foundation/integrations/__init__.py +11 -0
  63. provide/foundation/{observability → integrations}/openobserve/__init__.py +10 -7
  64. provide/foundation/{observability → integrations}/openobserve/auth.py +1 -1
  65. provide/foundation/{observability → integrations}/openobserve/client.py +12 -12
  66. provide/foundation/{observability → integrations}/openobserve/commands.py +3 -3
  67. provide/foundation/integrations/openobserve/config.py +37 -0
  68. provide/foundation/{observability → integrations}/openobserve/formatters.py +1 -1
  69. provide/foundation/{observability → integrations}/openobserve/otlp.py +1 -1
  70. provide/foundation/{observability → integrations}/openobserve/search.py +2 -2
  71. provide/foundation/{observability → integrations}/openobserve/streaming.py +4 -4
  72. provide/foundation/logger/__init__.py +3 -10
  73. provide/foundation/logger/config/logging.py +68 -298
  74. provide/foundation/logger/config/telemetry.py +41 -121
  75. provide/foundation/logger/core.py +0 -2
  76. provide/foundation/logger/custom_processors.py +1 -0
  77. provide/foundation/logger/factories.py +11 -2
  78. provide/foundation/logger/processors/main.py +20 -84
  79. provide/foundation/logger/setup/__init__.py +5 -1
  80. provide/foundation/logger/setup/coordinator.py +76 -24
  81. provide/foundation/logger/setup/processors.py +2 -9
  82. provide/foundation/logger/trace.py +27 -0
  83. provide/foundation/metrics/otel.py +10 -10
  84. provide/foundation/observability/__init__.py +2 -2
  85. provide/foundation/process/__init__.py +9 -0
  86. provide/foundation/process/exit.py +47 -0
  87. provide/foundation/process/lifecycle.py +115 -59
  88. provide/foundation/resilience/__init__.py +35 -0
  89. provide/foundation/resilience/circuit.py +164 -0
  90. provide/foundation/resilience/decorators.py +220 -0
  91. provide/foundation/resilience/fallback.py +193 -0
  92. provide/foundation/resilience/retry.py +325 -0
  93. provide/foundation/streams/config.py +79 -0
  94. provide/foundation/streams/console.py +7 -8
  95. provide/foundation/streams/core.py +6 -3
  96. provide/foundation/streams/file.py +12 -2
  97. provide/foundation/testing/__init__.py +84 -2
  98. provide/foundation/testing/archive/__init__.py +24 -0
  99. provide/foundation/testing/archive/fixtures.py +217 -0
  100. provide/foundation/testing/cli.py +30 -17
  101. provide/foundation/testing/common/__init__.py +32 -0
  102. provide/foundation/testing/common/fixtures.py +236 -0
  103. provide/foundation/testing/file/__init__.py +40 -0
  104. provide/foundation/testing/file/content_fixtures.py +316 -0
  105. provide/foundation/testing/file/directory_fixtures.py +107 -0
  106. provide/foundation/testing/file/fixtures.py +52 -0
  107. provide/foundation/testing/file/special_fixtures.py +153 -0
  108. provide/foundation/testing/logger.py +117 -11
  109. provide/foundation/testing/mocking/__init__.py +46 -0
  110. provide/foundation/testing/mocking/fixtures.py +331 -0
  111. provide/foundation/testing/process/__init__.py +48 -0
  112. provide/foundation/testing/process/async_fixtures.py +405 -0
  113. provide/foundation/testing/process/fixtures.py +56 -0
  114. provide/foundation/testing/process/subprocess_fixtures.py +209 -0
  115. provide/foundation/testing/threading/__init__.py +38 -0
  116. provide/foundation/testing/threading/basic_fixtures.py +101 -0
  117. provide/foundation/testing/threading/data_fixtures.py +99 -0
  118. provide/foundation/testing/threading/execution_fixtures.py +263 -0
  119. provide/foundation/testing/threading/fixtures.py +54 -0
  120. provide/foundation/testing/threading/sync_fixtures.py +97 -0
  121. provide/foundation/testing/time/__init__.py +32 -0
  122. provide/foundation/testing/time/fixtures.py +409 -0
  123. provide/foundation/testing/transport/__init__.py +30 -0
  124. provide/foundation/testing/transport/fixtures.py +280 -0
  125. provide/foundation/tools/__init__.py +58 -0
  126. provide/foundation/tools/base.py +348 -0
  127. provide/foundation/tools/cache.py +268 -0
  128. provide/foundation/tools/downloader.py +224 -0
  129. provide/foundation/tools/installer.py +254 -0
  130. provide/foundation/tools/registry.py +223 -0
  131. provide/foundation/tools/resolver.py +321 -0
  132. provide/foundation/tools/verifier.py +186 -0
  133. provide/foundation/tracer/otel.py +7 -11
  134. provide/foundation/tracer/spans.py +2 -2
  135. provide/foundation/transport/__init__.py +155 -0
  136. provide/foundation/transport/base.py +171 -0
  137. provide/foundation/transport/client.py +266 -0
  138. provide/foundation/transport/config.py +140 -0
  139. provide/foundation/transport/errors.py +79 -0
  140. provide/foundation/transport/http.py +232 -0
  141. provide/foundation/transport/middleware.py +360 -0
  142. provide/foundation/transport/registry.py +167 -0
  143. provide/foundation/transport/types.py +45 -0
  144. provide/foundation/utils/deps.py +14 -12
  145. provide/foundation/utils/parsing.py +49 -4
  146. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/METADATA +5 -28
  147. provide_foundation-0.0.0.dev2.dist-info/RECORD +225 -0
  148. provide/foundation/cli/commands/logs/generate_old.py +0 -569
  149. provide/foundation/crypto/certificates.py +0 -896
  150. provide/foundation/logger/emoji/__init__.py +0 -44
  151. provide/foundation/logger/emoji/matrix.py +0 -209
  152. provide/foundation/logger/emoji/sets.py +0 -458
  153. provide/foundation/logger/emoji/types.py +0 -56
  154. provide/foundation/logger/setup/emoji_resolver.py +0 -64
  155. provide_foundation-0.0.0.dev0.dist-info/RECORD +0 -149
  156. /provide/foundation/{observability → integrations}/openobserve/exceptions.py +0 -0
  157. /provide/foundation/{observability → integrations}/openobserve/models.py +0 -0
  158. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/WHEEL +0 -0
  159. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/entry_points.txt +0 -0
  160. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/licenses/LICENSE +0 -0
  161. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/top_level.txt +0 -0
@@ -1,9 +1,9 @@
1
1
  """OpenTelemetry metrics integration."""
2
2
 
3
- from provide.foundation.logger import get_logger
4
3
  from provide.foundation.logger.config.telemetry import TelemetryConfig
4
+ from provide.foundation.logger.setup import get_vanilla_logger
5
5
 
6
- log = get_logger(__name__)
6
+ slog = get_vanilla_logger(__name__)
7
7
 
8
8
  # Feature detection
9
9
  try:
@@ -47,15 +47,15 @@ def setup_opentelemetry_metrics(config: TelemetryConfig) -> None:
47
47
  """
48
48
  # Check if metrics are disabled first, before checking dependencies
49
49
  if not config.metrics_enabled or config.globally_disabled:
50
- log.debug("📊 OpenTelemetry metrics disabled")
50
+ slog.debug("📊 OpenTelemetry metrics disabled")
51
51
  return
52
52
 
53
53
  # Check if OpenTelemetry metrics are available
54
54
  if not _HAS_OTEL_METRICS:
55
- log.debug("📊 OpenTelemetry metrics not available (dependencies not installed)")
55
+ slog.debug("📊 OpenTelemetry metrics not available (dependencies not installed)")
56
56
  return
57
57
 
58
- log.debug("📊🚀 Setting up OpenTelemetry metrics")
58
+ slog.debug("📊🚀 Setting up OpenTelemetry metrics")
59
59
 
60
60
  # Create resource with service information
61
61
  resource_attrs = {}
@@ -73,7 +73,7 @@ def setup_opentelemetry_metrics(config: TelemetryConfig) -> None:
73
73
  endpoint = config.otlp_endpoint
74
74
  headers = config.get_otlp_headers_dict()
75
75
 
76
- log.debug(f"📊📤 Configuring OTLP metrics exporter: {endpoint}")
76
+ slog.debug(f"📊📤 Configuring OTLP metrics exporter: {endpoint}")
77
77
 
78
78
  # Choose exporter based on protocol
79
79
  if config.otlp_protocol == "grpc":
@@ -91,7 +91,7 @@ def setup_opentelemetry_metrics(config: TelemetryConfig) -> None:
91
91
  reader = PeriodicExportingMetricReader(exporter, export_interval_millis=60000)
92
92
  readers.append(reader)
93
93
 
94
- log.debug(f"✅ OTLP metrics exporter configured: {config.otlp_protocol}")
94
+ slog.debug(f"✅ OTLP metrics exporter configured: {config.otlp_protocol}")
95
95
 
96
96
  # Create meter provider
97
97
  meter_provider = MeterProvider(resource=resource, metric_readers=readers)
@@ -105,7 +105,7 @@ def setup_opentelemetry_metrics(config: TelemetryConfig) -> None:
105
105
  meter = otel_metrics.get_meter(__name__)
106
106
  _set_meter(meter)
107
107
 
108
- log.info("📊✅ OpenTelemetry metrics setup complete")
108
+ slog.info("📊✅ OpenTelemetry metrics setup complete")
109
109
 
110
110
 
111
111
  def shutdown_opentelemetry_metrics() -> None:
@@ -117,6 +117,6 @@ def shutdown_opentelemetry_metrics() -> None:
117
117
  meter_provider = otel_metrics.get_meter_provider()
118
118
  if hasattr(meter_provider, "shutdown"):
119
119
  meter_provider.shutdown()
120
- log.debug("📊🛑 OpenTelemetry meter provider shutdown")
120
+ slog.debug("📊🛑 OpenTelemetry meter provider shutdown")
121
121
  except Exception as e:
122
- log.warning(f"⚠️ Error shutting down OpenTelemetry metrics: {e}")
122
+ slog.warning(f"⚠️ Error shutting down OpenTelemetry metrics: {e}")
@@ -17,7 +17,7 @@ except ImportError:
17
17
  # Only import OpenObserve if OpenTelemetry is available
18
18
  if _HAS_OTEL:
19
19
  try:
20
- from provide.foundation.observability.openobserve import (
20
+ from provide.foundation.integrations.openobserve import (
21
21
  OpenObserveClient,
22
22
  search_logs,
23
23
  stream_logs,
@@ -25,7 +25,7 @@ if _HAS_OTEL:
25
25
 
26
26
  # Commands will auto-register if click is available
27
27
  try:
28
- from provide.foundation.observability.openobserve.commands import (
28
+ from provide.foundation.integrations.openobserve.commands import (
29
29
  openobserve_group,
30
30
  )
31
31
  except ImportError:
@@ -10,6 +10,11 @@ from provide.foundation.process.async_runner import (
10
10
  async_run_shell,
11
11
  async_stream_command,
12
12
  )
13
+ from provide.foundation.process.exit import (
14
+ exit_success,
15
+ exit_error,
16
+ exit_interrupted,
17
+ )
13
18
  from provide.foundation.process.lifecycle import (
14
19
  ManagedProcess,
15
20
  wait_for_process_output,
@@ -36,4 +41,8 @@ __all__ = [
36
41
  # Process lifecycle management
37
42
  "ManagedProcess",
38
43
  "wait_for_process_output",
44
+ # Exit utilities
45
+ "exit_success",
46
+ "exit_error",
47
+ "exit_interrupted",
39
48
  ]
@@ -0,0 +1,47 @@
1
+ """Process exit utilities for standardized exit handling."""
2
+
3
+ import sys
4
+
5
+ from provide.foundation.config.defaults import EXIT_SUCCESS, EXIT_ERROR, EXIT_SIGINT
6
+
7
+
8
+ def _get_logger():
9
+ """Get logger instance lazily to avoid circular imports."""
10
+ from provide.foundation.logger import logger
11
+ return logger
12
+
13
+
14
+ def exit_success(message: str | None = None) -> None:
15
+ """Exit with success status.
16
+
17
+ Args:
18
+ message: Optional message to log before exiting
19
+ """
20
+ if message:
21
+ logger = _get_logger()
22
+ logger.info(f"Exiting successfully: {message}")
23
+ sys.exit(EXIT_SUCCESS)
24
+
25
+
26
+ def exit_error(message: str | None = None, code: int = EXIT_ERROR) -> None:
27
+ """Exit with error status.
28
+
29
+ Args:
30
+ message: Optional error message to log before exiting
31
+ code: Exit code to use (defaults to EXIT_ERROR)
32
+ """
33
+ if message:
34
+ logger = _get_logger()
35
+ logger.error(f"Exiting with error: {message}", exit_code=code)
36
+ sys.exit(code)
37
+
38
+
39
+ def exit_interrupted(message: str = "Process interrupted") -> None:
40
+ """Exit due to interrupt signal (SIGINT).
41
+
42
+ Args:
43
+ message: Message to log before exiting
44
+ """
45
+ logger = _get_logger()
46
+ logger.warning(f"Exiting due to interrupt: {message}")
47
+ sys.exit(EXIT_SIGINT)
@@ -16,6 +16,13 @@ import threading
16
16
  import traceback
17
17
  from typing import Any
18
18
 
19
+ from provide.foundation.config.defaults import (
20
+ DEFAULT_PROCESS_READLINE_TIMEOUT,
21
+ DEFAULT_PROCESS_READCHAR_TIMEOUT,
22
+ DEFAULT_PROCESS_TERMINATE_TIMEOUT,
23
+ DEFAULT_PROCESS_WAIT_TIMEOUT,
24
+ )
25
+ from provide.foundation.errors.decorators import with_error_handling
19
26
  from provide.foundation.logger import get_logger
20
27
  from provide.foundation.process.runner import ProcessError
21
28
 
@@ -67,8 +74,16 @@ class ManagedProcess:
67
74
  self.stderr_relay = stderr_relay
68
75
  self.kwargs = kwargs
69
76
 
70
- # Build environment
77
+ # Build environment - always start with current environment
71
78
  self._env = os.environ.copy()
79
+
80
+ # Clean coverage-related environment variables from subprocess
81
+ # to prevent interference with output capture during testing
82
+ for key in list(self._env.keys()):
83
+ if key.startswith(('COVERAGE', 'COV_CORE')):
84
+ self._env.pop(key, None)
85
+
86
+ # Merge in any provided environment variables
72
87
  if env:
73
88
  self._env.update(env)
74
89
 
@@ -104,6 +119,9 @@ class ManagedProcess:
104
119
  return False
105
120
  return self._process.poll() is None
106
121
 
122
+ @with_error_handling(
123
+ error_mapper=lambda e: ProcessError(f"Failed to launch process: {e}") if not isinstance(e, (ProcessError, RuntimeError)) else e
124
+ )
107
125
  def launch(self) -> None:
108
126
  """
109
127
  Launch the managed process.
@@ -117,37 +135,27 @@ class ManagedProcess:
117
135
 
118
136
  plog.debug("🚀 Launching managed process", command=" ".join(self.command))
119
137
 
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
+ self._process = subprocess.Popen(
139
+ self.command,
140
+ cwd=self.cwd,
141
+ env=self._env,
142
+ stdout=subprocess.PIPE if self.capture_output else None,
143
+ stderr=subprocess.PIPE if self.capture_output else None,
144
+ text=self.text_mode,
145
+ bufsize=self.bufsize,
146
+ **self.kwargs,
147
+ )
148
+ self._started = True
138
149
 
139
- # Start stderr relay if enabled
140
- if self.stderr_relay and self._process.stderr:
141
- self._start_stderr_relay()
150
+ plog.info(
151
+ "🚀 Managed process started successfully",
152
+ pid=self._process.pid,
153
+ command=" ".join(self.command),
154
+ )
142
155
 
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
156
+ # Start stderr relay if enabled
157
+ if self.stderr_relay and self._process.stderr:
158
+ self._start_stderr_relay()
151
159
 
152
160
  def _start_stderr_relay(self) -> None:
153
161
  """Start a background thread to relay stderr output."""
@@ -178,7 +186,7 @@ class ManagedProcess:
178
186
  self._stderr_thread.start()
179
187
  plog.debug("🚀 Started stderr relay thread")
180
188
 
181
- async def read_line_async(self, timeout: float = 2.0) -> str:
189
+ async def read_line_async(self, timeout: float = DEFAULT_PROCESS_READLINE_TIMEOUT) -> str:
182
190
  """
183
191
  Read a line from stdout asynchronously with timeout.
184
192
 
@@ -213,7 +221,7 @@ class ManagedProcess:
213
221
  plog.debug("Read timeout on managed process stdout")
214
222
  raise TimeoutError(f"Read timeout after {timeout}s") from e
215
223
 
216
- async def read_char_async(self, timeout: float = 1.0) -> str:
224
+ async def read_char_async(self, timeout: float = DEFAULT_PROCESS_READCHAR_TIMEOUT) -> str:
217
225
  """
218
226
  Read a single character from stdout asynchronously.
219
227
 
@@ -250,7 +258,7 @@ class ManagedProcess:
250
258
  plog.debug("Character read timeout on managed process stdout")
251
259
  raise TimeoutError(f"Character read timeout after {timeout}s") from e
252
260
 
253
- def terminate_gracefully(self, timeout: float = 7.0) -> bool:
261
+ def terminate_gracefully(self, timeout: float = DEFAULT_PROCESS_TERMINATE_TIMEOUT) -> bool:
254
262
  """
255
263
  Terminate the process gracefully with a timeout.
256
264
 
@@ -332,7 +340,7 @@ class ManagedProcess:
332
340
  async def wait_for_process_output(
333
341
  process: ManagedProcess,
334
342
  expected_parts: list[str],
335
- timeout: float = 10.0,
343
+ timeout: float = DEFAULT_PROCESS_WAIT_TIMEOUT,
336
344
  buffer_size: int = 1024,
337
345
  ) -> str:
338
346
  """
@@ -357,6 +365,7 @@ async def wait_for_process_output(
357
365
  loop = asyncio.get_event_loop()
358
366
  start_time = loop.time()
359
367
  buffer = ""
368
+ last_exit_code = None
360
369
 
361
370
  plog.debug(
362
371
  "⏳ Waiting for process output pattern",
@@ -365,17 +374,62 @@ async def wait_for_process_output(
365
374
  )
366
375
 
367
376
  while (loop.time() - start_time) < timeout:
368
- # Check if process is still running
377
+ # Check if process has exited
369
378
  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
-
379
+ last_exit_code = process.returncode
380
+ plog.debug("Process exited", returncode=last_exit_code)
381
+
382
+ # Try to drain any remaining output from the pipes
383
+ if process._process and process._process.stdout:
384
+ try:
385
+ # Non-blocking read of any remaining data
386
+ remaining = process._process.stdout.read()
387
+ if remaining:
388
+ if isinstance(remaining, bytes):
389
+ buffer += remaining.decode("utf-8", errors="replace")
390
+ else:
391
+ buffer += str(remaining)
392
+ plog.debug("Read remaining output from exited process", size=len(remaining))
393
+ except Exception:
394
+ pass
395
+
396
+ # Check buffer after draining
397
+ if all(part in buffer for part in expected_parts):
398
+ plog.debug("Found expected pattern after process exit")
399
+ return buffer
400
+
401
+ # If process exited and we don't have the pattern, fail
402
+ if last_exit_code is not None:
403
+ if last_exit_code != 0:
404
+ plog.error("Process exited with error", returncode=last_exit_code, buffer=buffer[:200])
405
+ raise ProcessError(f"Process exited with code {last_exit_code}")
406
+ else:
407
+ # For exit code 0, give it a small window to collect buffered output
408
+ await asyncio.sleep(0.1)
409
+ # Try one more time to drain output
410
+ if process._process and process._process.stdout:
411
+ try:
412
+ remaining = process._process.stdout.read()
413
+ if remaining:
414
+ if isinstance(remaining, bytes):
415
+ buffer += remaining.decode("utf-8", errors="replace")
416
+ else:
417
+ buffer += str(remaining)
418
+ except Exception:
419
+ pass
420
+ # Final check
421
+ if all(part in buffer for part in expected_parts):
422
+ plog.debug("Found expected pattern after final drain")
423
+ return buffer
424
+ # Process exited cleanly but pattern not found
425
+ plog.error("Process exited without expected output", returncode=0, buffer=buffer[:200])
426
+ raise ProcessError(f"Process exited with code {last_exit_code} before expected output found")
427
+
374
428
  try:
375
- # Try to read a line first
376
- line = await process.read_line_async(timeout=2.0)
429
+ # Try to read a line with short timeout
430
+ line = await process.read_line_async(timeout=0.1)
377
431
  if line:
378
- buffer += line
432
+ buffer += line + "\n" # Add newline back since readline strips it
379
433
  plog.debug("Read line from process", line=line[:100])
380
434
 
381
435
  # Check if we have all expected parts
@@ -384,23 +438,25 @@ async def wait_for_process_output(
384
438
  return buffer
385
439
 
386
440
  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
-
441
+ pass
442
+ except Exception:
443
+ # Process might have exited, continue
444
+ pass
445
+
446
+ # Short sleep to avoid busy loop
447
+ await asyncio.sleep(0.01)
448
+
449
+ # Final check of buffer before timeout error
450
+ if all(part in buffer for part in expected_parts):
451
+ return buffer
452
+
453
+ # If process exited with 0 but we didn't get output, that's still a timeout
454
+ plog.error(
455
+ "Timeout waiting for pattern",
456
+ expected_parts=expected_parts,
457
+ buffer=buffer[:200],
458
+ last_exit_code=last_exit_code,
459
+ )
404
460
  raise TimeoutError(
405
461
  f"Expected pattern {expected_parts} not found within {timeout}s timeout"
406
462
  )
@@ -0,0 +1,35 @@
1
+ """
2
+ Resilience patterns for handling failures and improving reliability.
3
+
4
+ This module provides unified implementations of common resilience patterns:
5
+ - Retry with configurable backoff strategies
6
+ - Circuit breaker for failing fast
7
+ - Fallback for graceful degradation
8
+
9
+ These patterns are used throughout foundation to eliminate code duplication
10
+ and provide consistent failure handling.
11
+ """
12
+
13
+ from provide.foundation.resilience.circuit import CircuitBreaker, CircuitState
14
+ from provide.foundation.resilience.decorators import circuit_breaker, fallback, retry
15
+ from provide.foundation.resilience.fallback import FallbackChain
16
+ from provide.foundation.resilience.retry import BackoffStrategy, RetryExecutor, RetryPolicy
17
+
18
+ __all__ = [
19
+ # Core retry functionality
20
+ "RetryPolicy",
21
+ "RetryExecutor",
22
+ "BackoffStrategy",
23
+
24
+ # Circuit breaker
25
+ "CircuitBreaker",
26
+ "CircuitState",
27
+
28
+ # Fallback
29
+ "FallbackChain",
30
+
31
+ # Decorators
32
+ "retry",
33
+ "circuit_breaker",
34
+ "fallback",
35
+ ]
@@ -0,0 +1,164 @@
1
+ """
2
+ Circuit breaker implementation for preventing cascading failures.
3
+ """
4
+
5
+ import asyncio
6
+ import time
7
+ from enum import Enum
8
+ from typing import Any, Callable, TypeVar
9
+
10
+ from attrs import define, field
11
+
12
+ from provide.foundation.logger import logger
13
+
14
+ T = TypeVar("T")
15
+
16
+
17
+ class CircuitState(Enum):
18
+ """Circuit breaker states."""
19
+ CLOSED = "closed" # Normal operation
20
+ OPEN = "open" # Circuit is open, failing fast
21
+ HALF_OPEN = "half_open" # Testing if service has recovered
22
+
23
+
24
+ @define(kw_only=True, slots=True)
25
+ class CircuitBreaker:
26
+ """Circuit breaker pattern for preventing cascading failures.
27
+
28
+ Tracks failures and opens the circuit when threshold is exceeded.
29
+ Periodically allows test requests to check if service has recovered.
30
+ """
31
+
32
+ failure_threshold: int = field(default=5)
33
+ recovery_timeout: float = field(default=60.0) # seconds
34
+ expected_exception: tuple[type[Exception], ...] = field(
35
+ factory=lambda: (Exception,)
36
+ )
37
+
38
+ # Internal state
39
+ _state: CircuitState = field(default=CircuitState.CLOSED, init=False)
40
+ _failure_count: int = field(default=0, init=False)
41
+ _last_failure_time: float | None = field(default=None, init=False)
42
+ _next_attempt_time: float = field(default=0.0, init=False)
43
+
44
+ @property
45
+ def state(self) -> CircuitState:
46
+ """Current circuit breaker state."""
47
+ return self._state
48
+
49
+ @property
50
+ def failure_count(self) -> int:
51
+ """Current failure count."""
52
+ return self._failure_count
53
+
54
+ def _should_attempt_reset(self) -> bool:
55
+ """Check if we should attempt to reset the circuit."""
56
+ if self._state != CircuitState.OPEN:
57
+ return False
58
+
59
+ current_time = time.time()
60
+ return current_time >= self._next_attempt_time
61
+
62
+ def _record_success(self) -> None:
63
+ """Record successful execution."""
64
+ if self._state == CircuitState.HALF_OPEN:
65
+ logger.info(
66
+ "Circuit breaker recovered - closing circuit",
67
+ state="half_open->closed",
68
+ failure_count=self._failure_count
69
+ )
70
+
71
+ self._failure_count = 0
72
+ self._last_failure_time = None
73
+ self._state = CircuitState.CLOSED
74
+
75
+ def _record_failure(self, exception: Exception) -> None:
76
+ """Record failed execution."""
77
+ self._failure_count += 1
78
+ self._last_failure_time = time.time()
79
+
80
+ if self._state == CircuitState.HALF_OPEN:
81
+ # Failed during recovery attempt
82
+ self._state = CircuitState.OPEN
83
+ self._next_attempt_time = self._last_failure_time + self.recovery_timeout
84
+ logger.warning(
85
+ "Circuit breaker recovery failed - opening circuit",
86
+ state="half_open->open",
87
+ failure_count=self._failure_count,
88
+ next_attempt_in=self.recovery_timeout
89
+ )
90
+ elif self._failure_count >= self.failure_threshold:
91
+ # Threshold exceeded, open circuit
92
+ self._state = CircuitState.OPEN
93
+ self._next_attempt_time = self._last_failure_time + self.recovery_timeout
94
+ logger.error(
95
+ "Circuit breaker opened due to failures",
96
+ state="closed->open",
97
+ failure_count=self._failure_count,
98
+ failure_threshold=self.failure_threshold,
99
+ next_attempt_in=self.recovery_timeout
100
+ )
101
+
102
+ def call(self, func: Callable[..., T], *args, **kwargs) -> T:
103
+ """Execute function with circuit breaker protection (sync)."""
104
+ # Check if circuit is open
105
+ if self._state == CircuitState.OPEN:
106
+ if self._should_attempt_reset():
107
+ self._state = CircuitState.HALF_OPEN
108
+ logger.info(
109
+ "Circuit breaker attempting recovery",
110
+ state="open->half_open",
111
+ failure_count=self._failure_count
112
+ )
113
+ else:
114
+ raise RuntimeError(
115
+ f"Circuit breaker is open. Next attempt in "
116
+ f"{self._next_attempt_time - time.time():.1f} seconds"
117
+ )
118
+
119
+ try:
120
+ result = func(*args, **kwargs)
121
+ self._record_success()
122
+ return result
123
+ except Exception as e:
124
+ if isinstance(e, self.expected_exception):
125
+ self._record_failure(e)
126
+ raise
127
+
128
+ async def call_async(self, func: Callable[..., T], *args, **kwargs) -> T:
129
+ """Execute async function with circuit breaker protection."""
130
+ # Check if circuit is open
131
+ if self._state == CircuitState.OPEN:
132
+ if self._should_attempt_reset():
133
+ self._state = CircuitState.HALF_OPEN
134
+ logger.info(
135
+ "Circuit breaker attempting recovery",
136
+ state="open->half_open",
137
+ failure_count=self._failure_count
138
+ )
139
+ else:
140
+ raise RuntimeError(
141
+ f"Circuit breaker is open. Next attempt in "
142
+ f"{self._next_attempt_time - time.time():.1f} seconds"
143
+ )
144
+
145
+ try:
146
+ result = await func(*args, **kwargs)
147
+ self._record_success()
148
+ return result
149
+ except Exception as e:
150
+ if isinstance(e, self.expected_exception):
151
+ self._record_failure(e)
152
+ raise
153
+
154
+ def reset(self) -> None:
155
+ """Manually reset the circuit breaker."""
156
+ logger.info(
157
+ "Circuit breaker manually reset",
158
+ previous_state=self._state.value,
159
+ failure_count=self._failure_count
160
+ )
161
+ self._state = CircuitState.CLOSED
162
+ self._failure_count = 0
163
+ self._last_failure_time = None
164
+ self._next_attempt_time = 0.0