provide-foundation 0.0.0.dev2__py3-none-any.whl → 0.0.0.dev3__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 (155) hide show
  1. provide/foundation/__init__.py +20 -20
  2. provide/foundation/archive/__init__.py +1 -1
  3. provide/foundation/archive/base.py +15 -14
  4. provide/foundation/archive/bzip2.py +40 -40
  5. provide/foundation/archive/gzip.py +42 -42
  6. provide/foundation/archive/operations.py +90 -91
  7. provide/foundation/archive/tar.py +33 -31
  8. provide/foundation/archive/zip.py +52 -50
  9. provide/foundation/asynctools/__init__.py +20 -0
  10. provide/foundation/asynctools/core.py +126 -0
  11. provide/foundation/cli/__init__.py +2 -2
  12. provide/foundation/cli/commands/deps.py +4 -4
  13. provide/foundation/cli/commands/logs/__init__.py +2 -2
  14. provide/foundation/cli/commands/logs/generate.py +2 -2
  15. provide/foundation/cli/commands/logs/query.py +3 -3
  16. provide/foundation/cli/commands/logs/send.py +2 -2
  17. provide/foundation/cli/commands/logs/tail.py +2 -2
  18. provide/foundation/cli/decorators.py +0 -1
  19. provide/foundation/cli/testing.py +0 -5
  20. provide/foundation/cli/utils.py +1 -2
  21. provide/foundation/config/__init__.py +19 -19
  22. provide/foundation/config/base.py +2 -2
  23. provide/foundation/config/converters.py +81 -83
  24. provide/foundation/config/defaults.py +1 -1
  25. provide/foundation/config/env.py +2 -1
  26. provide/foundation/config/loader.py +1 -1
  27. provide/foundation/config/sync.py +8 -6
  28. provide/foundation/config/types.py +5 -5
  29. provide/foundation/config/validators.py +4 -4
  30. provide/foundation/console/output.py +7 -7
  31. provide/foundation/context/core.py +19 -17
  32. provide/foundation/crypto/certificates/__init__.py +9 -5
  33. provide/foundation/crypto/certificates/base.py +2 -2
  34. provide/foundation/crypto/certificates/certificate.py +48 -19
  35. provide/foundation/crypto/certificates/factory.py +26 -18
  36. provide/foundation/crypto/certificates/generator.py +24 -23
  37. provide/foundation/crypto/certificates/loader.py +24 -16
  38. provide/foundation/crypto/certificates/operations.py +17 -10
  39. provide/foundation/crypto/certificates/trust.py +21 -21
  40. provide/foundation/env/__init__.py +28 -0
  41. provide/foundation/env/core.py +218 -0
  42. provide/foundation/errors/__init__.py +3 -2
  43. provide/foundation/errors/decorators.py +0 -3
  44. provide/foundation/errors/types.py +0 -1
  45. provide/foundation/eventsets/display.py +13 -14
  46. provide/foundation/eventsets/registry.py +61 -31
  47. provide/foundation/eventsets/resolver.py +50 -46
  48. provide/foundation/eventsets/sets/das.py +8 -8
  49. provide/foundation/eventsets/sets/database.py +14 -14
  50. provide/foundation/eventsets/sets/http.py +21 -21
  51. provide/foundation/eventsets/sets/llm.py +16 -16
  52. provide/foundation/eventsets/sets/task_queue.py +13 -13
  53. provide/foundation/eventsets/types.py +7 -7
  54. provide/foundation/file/directory.py +1 -1
  55. provide/foundation/file/lock.py +2 -3
  56. provide/foundation/hub/components.py +19 -21
  57. provide/foundation/hub/config.py +25 -19
  58. provide/foundation/hub/discovery.py +5 -4
  59. provide/foundation/hub/handlers.py +13 -5
  60. provide/foundation/hub/lifecycle.py +10 -9
  61. provide/foundation/hub/manager.py +3 -0
  62. provide/foundation/hub/processors.py +8 -3
  63. provide/foundation/integrations/__init__.py +1 -1
  64. provide/foundation/integrations/openobserve/client.py +2 -2
  65. provide/foundation/integrations/openobserve/commands.py +9 -9
  66. provide/foundation/integrations/openobserve/config.py +2 -2
  67. provide/foundation/integrations/openobserve/otlp.py +2 -2
  68. provide/foundation/integrations/openobserve/search.py +1 -2
  69. provide/foundation/integrations/openobserve/streaming.py +1 -1
  70. provide/foundation/logger/__init__.py +0 -1
  71. provide/foundation/logger/config/base.py +1 -1
  72. provide/foundation/logger/config/logging.py +19 -19
  73. provide/foundation/logger/config/telemetry.py +11 -13
  74. provide/foundation/logger/factories.py +2 -2
  75. provide/foundation/logger/processors/main.py +12 -10
  76. provide/foundation/logger/ratelimit/limiters.py +4 -4
  77. provide/foundation/logger/ratelimit/processor.py +1 -1
  78. provide/foundation/logger/setup/coordinator.py +38 -24
  79. provide/foundation/logger/setup/processors.py +3 -3
  80. provide/foundation/logger/setup/testing.py +14 -0
  81. provide/foundation/logger/trace.py +5 -5
  82. provide/foundation/metrics/__init__.py +1 -1
  83. provide/foundation/metrics/otel.py +3 -1
  84. provide/foundation/observability/__init__.py +1 -1
  85. provide/foundation/process/__init__.py +1 -1
  86. provide/foundation/process/exit.py +6 -5
  87. provide/foundation/process/lifecycle.py +41 -18
  88. provide/foundation/resilience/__init__.py +6 -5
  89. provide/foundation/resilience/circuit.py +32 -30
  90. provide/foundation/resilience/decorators.py +58 -42
  91. provide/foundation/resilience/fallback.py +55 -40
  92. provide/foundation/resilience/retry.py +67 -65
  93. provide/foundation/serialization/__init__.py +16 -0
  94. provide/foundation/serialization/core.py +70 -0
  95. provide/foundation/streams/config.py +8 -9
  96. provide/foundation/streams/console.py +3 -3
  97. provide/foundation/streams/core.py +2 -2
  98. provide/foundation/streams/file.py +1 -1
  99. provide/foundation/testing/__init__.py +22 -7
  100. provide/foundation/testing/archive/__init__.py +7 -7
  101. provide/foundation/testing/archive/fixtures.py +58 -54
  102. provide/foundation/testing/cli.py +3 -6
  103. provide/foundation/testing/common/__init__.py +13 -13
  104. provide/foundation/testing/common/fixtures.py +27 -30
  105. provide/foundation/testing/file/__init__.py +15 -15
  106. provide/foundation/testing/file/content_fixtures.py +65 -92
  107. provide/foundation/testing/file/directory_fixtures.py +19 -19
  108. provide/foundation/testing/file/fixtures.py +14 -17
  109. provide/foundation/testing/file/special_fixtures.py +34 -42
  110. provide/foundation/testing/logger.py +28 -23
  111. provide/foundation/testing/mocking/__init__.py +21 -21
  112. provide/foundation/testing/mocking/fixtures.py +80 -67
  113. provide/foundation/testing/process/__init__.py +23 -23
  114. provide/foundation/testing/process/async_fixtures.py +89 -80
  115. provide/foundation/testing/process/fixtures.py +11 -13
  116. provide/foundation/testing/process/subprocess_fixtures.py +41 -40
  117. provide/foundation/testing/threading/__init__.py +17 -17
  118. provide/foundation/testing/threading/basic_fixtures.py +21 -17
  119. provide/foundation/testing/threading/data_fixtures.py +18 -16
  120. provide/foundation/testing/threading/execution_fixtures.py +67 -52
  121. provide/foundation/testing/threading/fixtures.py +10 -14
  122. provide/foundation/testing/threading/sync_fixtures.py +21 -18
  123. provide/foundation/testing/time/__init__.py +11 -11
  124. provide/foundation/testing/time/fixtures.py +91 -79
  125. provide/foundation/testing/transport/__init__.py +9 -9
  126. provide/foundation/testing/transport/fixtures.py +54 -54
  127. provide/foundation/time/__init__.py +18 -0
  128. provide/foundation/time/core.py +63 -0
  129. provide/foundation/tools/__init__.py +2 -2
  130. provide/foundation/tools/base.py +68 -67
  131. provide/foundation/tools/cache.py +62 -69
  132. provide/foundation/tools/downloader.py +51 -56
  133. provide/foundation/tools/installer.py +51 -57
  134. provide/foundation/tools/registry.py +38 -45
  135. provide/foundation/tools/resolver.py +70 -68
  136. provide/foundation/tools/verifier.py +39 -50
  137. provide/foundation/tracer/spans.py +1 -13
  138. provide/foundation/transport/__init__.py +26 -33
  139. provide/foundation/transport/base.py +32 -30
  140. provide/foundation/transport/client.py +44 -49
  141. provide/foundation/transport/config.py +11 -13
  142. provide/foundation/transport/errors.py +13 -27
  143. provide/foundation/transport/http.py +69 -55
  144. provide/foundation/transport/middleware.py +86 -81
  145. provide/foundation/transport/registry.py +29 -27
  146. provide/foundation/transport/types.py +6 -6
  147. provide/foundation/utils/deps.py +3 -2
  148. provide/foundation/utils/parsing.py +7 -7
  149. {provide_foundation-0.0.0.dev2.dist-info → provide_foundation-0.0.0.dev3.dist-info}/METADATA +2 -2
  150. provide_foundation-0.0.0.dev3.dist-info/RECORD +233 -0
  151. provide_foundation-0.0.0.dev2.dist-info/RECORD +0 -225
  152. {provide_foundation-0.0.0.dev2.dist-info → provide_foundation-0.0.0.dev3.dist-info}/WHEEL +0 -0
  153. {provide_foundation-0.0.0.dev2.dist-info → provide_foundation-0.0.0.dev3.dist-info}/entry_points.txt +0 -0
  154. {provide_foundation-0.0.0.dev2.dist-info → provide_foundation-0.0.0.dev3.dist-info}/licenses/LICENSE +0 -0
  155. {provide_foundation-0.0.0.dev2.dist-info → provide_foundation-0.0.0.dev3.dist-info}/top_level.txt +0 -0
@@ -28,6 +28,7 @@ _PROVIDE_SETUP_LOCK = threading.Lock()
28
28
  _CORE_SETUP_LOGGER_NAME = "provide.foundation.core_setup"
29
29
  _EXPLICIT_SETUP_DONE = False
30
30
  _FOUNDATION_LOG_LEVEL: int | None = None
31
+ _CACHED_SETUP_LOGGER: Any | None = None
31
32
 
32
33
 
33
34
  def get_foundation_log_level() -> int:
@@ -35,10 +36,10 @@ def get_foundation_log_level() -> int:
35
36
  global _FOUNDATION_LOG_LEVEL
36
37
  if _FOUNDATION_LOG_LEVEL is None:
37
38
  import os
38
-
39
+
39
40
  # Direct env read - avoid config imports that cause circular deps
40
41
  level_str = os.environ.get("FOUNDATION_LOG_LEVEL", "INFO").upper()
41
-
42
+
42
43
  # Validate and map to numeric level
43
44
  valid_levels = {
44
45
  "CRITICAL": stdlib_logging.CRITICAL,
@@ -48,7 +49,7 @@ def get_foundation_log_level() -> int:
48
49
  "DEBUG": stdlib_logging.DEBUG,
49
50
  "NOTSET": stdlib_logging.NOTSET,
50
51
  }
51
-
52
+
52
53
  _FOUNDATION_LOG_LEVEL = valid_levels.get(level_str, stdlib_logging.INFO)
53
54
  return _FOUNDATION_LOG_LEVEL
54
55
 
@@ -56,10 +57,17 @@ def get_foundation_log_level() -> int:
56
57
  def create_foundation_internal_logger(globally_disabled: bool = False) -> Any:
57
58
  """
58
59
  Create Foundation's internal setup logger (structlog).
59
-
60
+
60
61
  This is used internally by Foundation during its own initialization.
61
62
  Components should use get_vanilla_logger() instead.
63
+
64
+ Returns the same logger instance when called multiple times (singleton pattern).
62
65
  """
66
+ global _CACHED_SETUP_LOGGER
67
+
68
+ # Return cached logger if already created
69
+ if _CACHED_SETUP_LOGGER is not None:
70
+ return _CACHED_SETUP_LOGGER
63
71
  if globally_disabled:
64
72
  # Configure structlog to be a no-op for core setup logger
65
73
  structlog.configure(
@@ -68,7 +76,8 @@ def create_foundation_internal_logger(globally_disabled: bool = False) -> Any:
68
76
  wrapper_class=structlog.BoundLogger,
69
77
  cache_logger_on_first_use=True,
70
78
  )
71
- return structlog.get_logger(_CORE_SETUP_LOGGER_NAME)
79
+ _CACHED_SETUP_LOGGER = structlog.get_logger(_CORE_SETUP_LOGGER_NAME)
80
+ return _CACHED_SETUP_LOGGER
72
81
  else:
73
82
  # Get the foundation log output stream
74
83
  try:
@@ -92,55 +101,61 @@ def create_foundation_internal_logger(globally_disabled: bool = False) -> Any:
92
101
  cache_logger_on_first_use=True,
93
102
  )
94
103
 
95
- return structlog.get_logger(_CORE_SETUP_LOGGER_NAME)
104
+ _CACHED_SETUP_LOGGER = structlog.get_logger(_CORE_SETUP_LOGGER_NAME)
105
+ return _CACHED_SETUP_LOGGER
106
+
96
107
 
108
+ def reset_setup_logger_cache() -> None:
109
+ """Reset the cached setup logger for testing."""
110
+ global _CACHED_SETUP_LOGGER
111
+ _CACHED_SETUP_LOGGER = None
97
112
 
98
- def get_vanilla_logger(name: str):
113
+
114
+ def get_vanilla_logger(name: str) -> object:
99
115
  """
100
116
  Get a vanilla Python logger without Foundation enhancements.
101
-
117
+
102
118
  This provides a plain Python logger that respects FOUNDATION_LOG_LEVEL
103
119
  but doesn't trigger Foundation's initialization. Use this for logging
104
120
  during Foundation's setup phase or when you need to avoid circular
105
121
  dependencies.
106
-
122
+
107
123
  Args:
108
124
  name: Logger name (e.g., "provide.foundation.otel.setup")
109
-
125
+
110
126
  Returns:
111
127
  A standard Python logging.Logger instance
112
-
128
+
113
129
  Note:
114
130
  "Vanilla" means plain/unmodified Python logging, without
115
131
  Foundation's features like emoji prefixes or structured logging.
116
132
  """
117
133
  import logging
118
- import sys
119
134
  import os
120
-
135
+ import sys
136
+
121
137
  slog = logging.getLogger(name)
122
-
138
+
123
139
  # Configure only once per logger
124
140
  if not slog.handlers:
125
141
  log_level = get_foundation_log_level()
126
142
  slog.setLevel(log_level)
127
-
143
+
128
144
  # Respect FOUNDATION_LOG_OUTPUT setting
129
145
  output = os.environ.get("FOUNDATION_LOG_OUTPUT", "stderr").lower()
130
146
  stream = sys.stderr if output != "stdout" else sys.stdout
131
-
147
+
132
148
  handler = logging.StreamHandler(stream)
133
149
  handler.setLevel(log_level)
134
150
  formatter = logging.Formatter(
135
- '%(asctime)s [%(levelname)-5s] %(message)s',
136
- datefmt='%Y-%m-%dT%H:%M:%S'
151
+ "%(asctime)s [%(levelname)-5s] %(message)s", datefmt="%Y-%m-%dT%H:%M:%S"
137
152
  )
138
153
  handler.setFormatter(formatter)
139
154
  slog.addHandler(handler)
140
-
155
+
141
156
  # Don't propagate to avoid duplicate messages
142
157
  slog.propagate = False
143
-
158
+
144
159
  return slog
145
160
 
146
161
 
@@ -170,13 +185,12 @@ def internal_setup(
170
185
  formatter=current_config.logging.console_formatter,
171
186
  )
172
187
 
173
-
174
188
  if current_config.globally_disabled:
189
+ core_setup_logger.trace("Setting up globally disabled telemetry")
175
190
  handle_globally_disabled_setup()
176
191
  else:
177
- configure_structlog_output(
178
- current_config, get_log_stream()
179
- )
192
+ core_setup_logger.trace("Configuring structlog output processors")
193
+ configure_structlog_output(current_config, get_log_stream())
180
194
 
181
195
  foundation_logger._is_configured_by_setup = is_explicit_call
182
196
  foundation_logger._active_config = current_config
@@ -15,6 +15,8 @@ from provide.foundation.logger.processors import (
15
15
  _build_core_processors_list,
16
16
  _build_formatter_processors_list,
17
17
  )
18
+
19
+
18
20
  def build_complete_processor_chain(
19
21
  config: TelemetryConfig,
20
22
  log_stream: TextIO,
@@ -61,9 +63,7 @@ def configure_structlog_output(
61
63
  config: Telemetry configuration
62
64
  log_stream: Output stream for logging
63
65
  """
64
- processors = build_complete_processor_chain(
65
- config, log_stream
66
- )
66
+ processors = build_complete_processor_chain(config, log_stream)
67
67
  apply_structlog_configuration(processors, log_stream)
68
68
 
69
69
 
@@ -30,6 +30,20 @@ def reset_foundation_state() -> None:
30
30
  foundation_logger._active_config = None
31
31
  foundation_logger._active_resolved_emoji_config = None
32
32
  _LAZY_SETUP_STATE.update({"done": False, "error": None, "in_progress": False})
33
+
34
+ # Reset event set registry and discovery state
35
+ try:
36
+ from provide.foundation.eventsets.registry import clear_registry
37
+ clear_registry()
38
+ except ImportError:
39
+ pass # Event sets may not be available in all test environments
40
+
41
+ # Reset setup logger cache
42
+ try:
43
+ from provide.foundation.logger.setup.coordinator import reset_setup_logger_cache
44
+ reset_setup_logger_cache()
45
+ except ImportError:
46
+ pass
33
47
 
34
48
 
35
49
  def reset_foundation_setup_for_testing() -> None:
@@ -39,10 +39,10 @@ if not hasattr(stdlib_logging, TRACE_LEVEL_NAME): # pragma: no cover
39
39
 
40
40
  # Also patch PrintLogger from structlog to support trace method
41
41
  try:
42
- import structlog
43
42
  from structlog import PrintLogger
44
-
43
+
45
44
  if not hasattr(PrintLogger, "trace"): # pragma: no cover
45
+
46
46
  def trace_for_print_logger(
47
47
  self: PrintLogger, msg: object, *args: object, **kwargs: object
48
48
  ) -> None: # pragma: no cover
@@ -54,12 +54,12 @@ try:
54
54
  formatted_msg = f"{msg} {args}"
55
55
  else:
56
56
  formatted_msg = str(msg)
57
-
57
+
58
58
  # Use the same output mechanism as other PrintLogger methods
59
59
  self._file.write(formatted_msg + "\n")
60
60
  self._file.flush()
61
-
61
+
62
62
  PrintLogger.trace = trace_for_print_logger # type: ignore[attr-defined]
63
-
63
+
64
64
  except ImportError: # pragma: no cover
65
65
  pass
@@ -113,7 +113,7 @@ def histogram(name: str, description: str = "", unit: str = "") -> "SimpleHistog
113
113
  return SimpleHistogram(name)
114
114
 
115
115
 
116
- def _set_meter(meter) -> None:
116
+ def _set_meter(meter: object) -> None:
117
117
  """Set the global meter instance (internal use only)."""
118
118
  global _meter
119
119
  _meter = meter
@@ -52,7 +52,9 @@ def setup_opentelemetry_metrics(config: TelemetryConfig) -> None:
52
52
 
53
53
  # Check if OpenTelemetry metrics are available
54
54
  if not _HAS_OTEL_METRICS:
55
- slog.debug("📊 OpenTelemetry metrics not available (dependencies not installed)")
55
+ slog.debug(
56
+ "📊 OpenTelemetry metrics not available (dependencies not installed)"
57
+ )
56
58
  return
57
59
 
58
60
  slog.debug("📊🚀 Setting up OpenTelemetry metrics")
@@ -27,7 +27,7 @@ if _HAS_OTEL:
27
27
  try:
28
28
  from provide.foundation.integrations.openobserve.commands import (
29
29
  openobserve_group,
30
- )
30
+ ) # noqa: F401
31
31
  except ImportError:
32
32
  # Click not available, skip command registration
33
33
  pass
@@ -11,9 +11,9 @@ from provide.foundation.process.async_runner import (
11
11
  async_stream_command,
12
12
  )
13
13
  from provide.foundation.process.exit import (
14
- exit_success,
15
14
  exit_error,
16
15
  exit_interrupted,
16
+ exit_success,
17
17
  )
18
18
  from provide.foundation.process.lifecycle import (
19
19
  ManagedProcess,
@@ -2,18 +2,19 @@
2
2
 
3
3
  import sys
4
4
 
5
- from provide.foundation.config.defaults import EXIT_SUCCESS, EXIT_ERROR, EXIT_SIGINT
5
+ from provide.foundation.config.defaults import EXIT_ERROR, EXIT_SIGINT, EXIT_SUCCESS
6
6
 
7
7
 
8
8
  def _get_logger():
9
9
  """Get logger instance lazily to avoid circular imports."""
10
10
  from provide.foundation.logger import logger
11
+
11
12
  return logger
12
13
 
13
14
 
14
15
  def exit_success(message: str | None = None) -> None:
15
16
  """Exit with success status.
16
-
17
+
17
18
  Args:
18
19
  message: Optional message to log before exiting
19
20
  """
@@ -25,7 +26,7 @@ def exit_success(message: str | None = None) -> None:
25
26
 
26
27
  def exit_error(message: str | None = None, code: int = EXIT_ERROR) -> None:
27
28
  """Exit with error status.
28
-
29
+
29
30
  Args:
30
31
  message: Optional error message to log before exiting
31
32
  code: Exit code to use (defaults to EXIT_ERROR)
@@ -38,10 +39,10 @@ def exit_error(message: str | None = None, code: int = EXIT_ERROR) -> None:
38
39
 
39
40
  def exit_interrupted(message: str = "Process interrupted") -> None:
40
41
  """Exit due to interrupt signal (SIGINT).
41
-
42
+
42
43
  Args:
43
44
  message: Message to log before exiting
44
45
  """
45
46
  logger = _get_logger()
46
47
  logger.warning(f"Exiting due to interrupt: {message}")
47
- sys.exit(EXIT_SIGINT)
48
+ sys.exit(EXIT_SIGINT)
@@ -17,8 +17,8 @@ import traceback
17
17
  from typing import Any
18
18
 
19
19
  from provide.foundation.config.defaults import (
20
- DEFAULT_PROCESS_READLINE_TIMEOUT,
21
20
  DEFAULT_PROCESS_READCHAR_TIMEOUT,
21
+ DEFAULT_PROCESS_READLINE_TIMEOUT,
22
22
  DEFAULT_PROCESS_TERMINATE_TIMEOUT,
23
23
  DEFAULT_PROCESS_WAIT_TIMEOUT,
24
24
  )
@@ -76,13 +76,13 @@ class ManagedProcess:
76
76
 
77
77
  # Build environment - always start with current environment
78
78
  self._env = os.environ.copy()
79
-
79
+
80
80
  # Clean coverage-related environment variables from subprocess
81
81
  # to prevent interference with output capture during testing
82
82
  for key in list(self._env.keys()):
83
- if key.startswith(('COVERAGE', 'COV_CORE')):
83
+ if key.startswith(("COVERAGE", "COV_CORE")):
84
84
  self._env.pop(key, None)
85
-
85
+
86
86
  # Merge in any provided environment variables
87
87
  if env:
88
88
  self._env.update(env)
@@ -120,7 +120,9 @@ class ManagedProcess:
120
120
  return self._process.poll() is None
121
121
 
122
122
  @with_error_handling(
123
- error_mapper=lambda e: ProcessError(f"Failed to launch process: {e}") if not isinstance(e, (ProcessError, RuntimeError)) else e
123
+ error_mapper=lambda e: ProcessError(f"Failed to launch process: {e}")
124
+ if not isinstance(e, (ProcessError, RuntimeError))
125
+ else e
124
126
  )
125
127
  def launch(self) -> None:
126
128
  """
@@ -186,7 +188,9 @@ class ManagedProcess:
186
188
  self._stderr_thread.start()
187
189
  plog.debug("🚀 Started stderr relay thread")
188
190
 
189
- async def read_line_async(self, timeout: float = DEFAULT_PROCESS_READLINE_TIMEOUT) -> str:
191
+ async def read_line_async(
192
+ self, timeout: float = DEFAULT_PROCESS_READLINE_TIMEOUT
193
+ ) -> str:
190
194
  """
191
195
  Read a line from stdout asynchronously with timeout.
192
196
 
@@ -221,7 +225,9 @@ class ManagedProcess:
221
225
  plog.debug("Read timeout on managed process stdout")
222
226
  raise TimeoutError(f"Read timeout after {timeout}s") from e
223
227
 
224
- async def read_char_async(self, timeout: float = DEFAULT_PROCESS_READCHAR_TIMEOUT) -> str:
228
+ async def read_char_async(
229
+ self, timeout: float = DEFAULT_PROCESS_READCHAR_TIMEOUT
230
+ ) -> str:
225
231
  """
226
232
  Read a single character from stdout asynchronously.
227
233
 
@@ -258,7 +264,9 @@ class ManagedProcess:
258
264
  plog.debug("Character read timeout on managed process stdout")
259
265
  raise TimeoutError(f"Character read timeout after {timeout}s") from e
260
266
 
261
- def terminate_gracefully(self, timeout: float = DEFAULT_PROCESS_TERMINATE_TIMEOUT) -> bool:
267
+ def terminate_gracefully(
268
+ self, timeout: float = DEFAULT_PROCESS_TERMINATE_TIMEOUT
269
+ ) -> bool:
262
270
  """
263
271
  Terminate the process gracefully with a timeout.
264
272
 
@@ -378,7 +386,7 @@ async def wait_for_process_output(
378
386
  if not process.is_running():
379
387
  last_exit_code = process.returncode
380
388
  plog.debug("Process exited", returncode=last_exit_code)
381
-
389
+
382
390
  # Try to drain any remaining output from the pipes
383
391
  if process._process and process._process.stdout:
384
392
  try:
@@ -389,19 +397,26 @@ async def wait_for_process_output(
389
397
  buffer += remaining.decode("utf-8", errors="replace")
390
398
  else:
391
399
  buffer += str(remaining)
392
- plog.debug("Read remaining output from exited process", size=len(remaining))
400
+ plog.debug(
401
+ "Read remaining output from exited process",
402
+ size=len(remaining),
403
+ )
393
404
  except Exception:
394
405
  pass
395
-
406
+
396
407
  # Check buffer after draining
397
408
  if all(part in buffer for part in expected_parts):
398
409
  plog.debug("Found expected pattern after process exit")
399
410
  return buffer
400
-
411
+
401
412
  # If process exited and we don't have the pattern, fail
402
413
  if last_exit_code is not None:
403
414
  if last_exit_code != 0:
404
- plog.error("Process exited with error", returncode=last_exit_code, buffer=buffer[:200])
415
+ plog.error(
416
+ "Process exited with error",
417
+ returncode=last_exit_code,
418
+ buffer=buffer[:200],
419
+ )
405
420
  raise ProcessError(f"Process exited with code {last_exit_code}")
406
421
  else:
407
422
  # For exit code 0, give it a small window to collect buffered output
@@ -412,7 +427,9 @@ async def wait_for_process_output(
412
427
  remaining = process._process.stdout.read()
413
428
  if remaining:
414
429
  if isinstance(remaining, bytes):
415
- buffer += remaining.decode("utf-8", errors="replace")
430
+ buffer += remaining.decode(
431
+ "utf-8", errors="replace"
432
+ )
416
433
  else:
417
434
  buffer += str(remaining)
418
435
  except Exception:
@@ -422,9 +439,15 @@ async def wait_for_process_output(
422
439
  plog.debug("Found expected pattern after final drain")
423
440
  return buffer
424
441
  # 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
-
442
+ plog.error(
443
+ "Process exited without expected output",
444
+ returncode=0,
445
+ buffer=buffer[:200],
446
+ )
447
+ raise ProcessError(
448
+ f"Process exited with code {last_exit_code} before expected output found"
449
+ )
450
+
428
451
  try:
429
452
  # Try to read a line with short timeout
430
453
  line = await process.read_line_async(timeout=0.1)
@@ -449,7 +472,7 @@ async def wait_for_process_output(
449
472
  # Final check of buffer before timeout error
450
473
  if all(part in buffer for part in expected_parts):
451
474
  return buffer
452
-
475
+
453
476
  # If process exited with 0 but we didn't get output, that's still a timeout
454
477
  plog.error(
455
478
  "Timeout waiting for pattern",
@@ -13,23 +13,24 @@ and provide consistent failure handling.
13
13
  from provide.foundation.resilience.circuit import CircuitBreaker, CircuitState
14
14
  from provide.foundation.resilience.decorators import circuit_breaker, fallback, retry
15
15
  from provide.foundation.resilience.fallback import FallbackChain
16
- from provide.foundation.resilience.retry import BackoffStrategy, RetryExecutor, RetryPolicy
16
+ from provide.foundation.resilience.retry import (
17
+ BackoffStrategy,
18
+ RetryExecutor,
19
+ RetryPolicy,
20
+ )
17
21
 
18
22
  __all__ = [
19
23
  # Core retry functionality
20
24
  "RetryPolicy",
21
25
  "RetryExecutor",
22
26
  "BackoffStrategy",
23
-
24
27
  # Circuit breaker
25
28
  "CircuitBreaker",
26
29
  "CircuitState",
27
-
28
30
  # Fallback
29
31
  "FallbackChain",
30
-
31
32
  # Decorators
32
33
  "retry",
33
34
  "circuit_breaker",
34
35
  "fallback",
35
- ]
36
+ ]
@@ -2,81 +2,83 @@
2
2
  Circuit breaker implementation for preventing cascading failures.
3
3
  """
4
4
 
5
- import asyncio
6
- import time
7
5
  from enum import Enum
8
- from typing import Any, Callable, TypeVar
6
+ import time
7
+ from typing import Callable, TypeVar
9
8
 
10
9
  from attrs import define, field
11
10
 
12
- from provide.foundation.logger import logger
11
+ from provide.foundation.logger import get_logger
12
+
13
+ logger = get_logger(__name__)
13
14
 
14
15
  T = TypeVar("T")
15
16
 
16
17
 
17
18
  class CircuitState(Enum):
18
19
  """Circuit breaker states."""
19
- CLOSED = "closed" # Normal operation
20
- OPEN = "open" # Circuit is open, failing fast
20
+
21
+ CLOSED = "closed" # Normal operation
22
+ OPEN = "open" # Circuit is open, failing fast
21
23
  HALF_OPEN = "half_open" # Testing if service has recovered
22
24
 
23
25
 
24
26
  @define(kw_only=True, slots=True)
25
27
  class CircuitBreaker:
26
28
  """Circuit breaker pattern for preventing cascading failures.
27
-
29
+
28
30
  Tracks failures and opens the circuit when threshold is exceeded.
29
31
  Periodically allows test requests to check if service has recovered.
30
32
  """
31
-
33
+
32
34
  failure_threshold: int = field(default=5)
33
35
  recovery_timeout: float = field(default=60.0) # seconds
34
36
  expected_exception: tuple[type[Exception], ...] = field(
35
37
  factory=lambda: (Exception,)
36
38
  )
37
-
39
+
38
40
  # Internal state
39
41
  _state: CircuitState = field(default=CircuitState.CLOSED, init=False)
40
42
  _failure_count: int = field(default=0, init=False)
41
43
  _last_failure_time: float | None = field(default=None, init=False)
42
44
  _next_attempt_time: float = field(default=0.0, init=False)
43
-
45
+
44
46
  @property
45
47
  def state(self) -> CircuitState:
46
48
  """Current circuit breaker state."""
47
49
  return self._state
48
-
50
+
49
51
  @property
50
52
  def failure_count(self) -> int:
51
53
  """Current failure count."""
52
54
  return self._failure_count
53
-
55
+
54
56
  def _should_attempt_reset(self) -> bool:
55
57
  """Check if we should attempt to reset the circuit."""
56
58
  if self._state != CircuitState.OPEN:
57
59
  return False
58
-
60
+
59
61
  current_time = time.time()
60
62
  return current_time >= self._next_attempt_time
61
-
63
+
62
64
  def _record_success(self) -> None:
63
65
  """Record successful execution."""
64
66
  if self._state == CircuitState.HALF_OPEN:
65
67
  logger.info(
66
68
  "Circuit breaker recovered - closing circuit",
67
69
  state="half_open->closed",
68
- failure_count=self._failure_count
70
+ failure_count=self._failure_count,
69
71
  )
70
-
72
+
71
73
  self._failure_count = 0
72
74
  self._last_failure_time = None
73
75
  self._state = CircuitState.CLOSED
74
-
76
+
75
77
  def _record_failure(self, exception: Exception) -> None:
76
78
  """Record failed execution."""
77
79
  self._failure_count += 1
78
80
  self._last_failure_time = time.time()
79
-
81
+
80
82
  if self._state == CircuitState.HALF_OPEN:
81
83
  # Failed during recovery attempt
82
84
  self._state = CircuitState.OPEN
@@ -85,7 +87,7 @@ class CircuitBreaker:
85
87
  "Circuit breaker recovery failed - opening circuit",
86
88
  state="half_open->open",
87
89
  failure_count=self._failure_count,
88
- next_attempt_in=self.recovery_timeout
90
+ next_attempt_in=self.recovery_timeout,
89
91
  )
90
92
  elif self._failure_count >= self.failure_threshold:
91
93
  # Threshold exceeded, open circuit
@@ -93,12 +95,12 @@ class CircuitBreaker:
93
95
  self._next_attempt_time = self._last_failure_time + self.recovery_timeout
94
96
  logger.error(
95
97
  "Circuit breaker opened due to failures",
96
- state="closed->open",
98
+ state="closed->open",
97
99
  failure_count=self._failure_count,
98
100
  failure_threshold=self.failure_threshold,
99
- next_attempt_in=self.recovery_timeout
101
+ next_attempt_in=self.recovery_timeout,
100
102
  )
101
-
103
+
102
104
  def call(self, func: Callable[..., T], *args, **kwargs) -> T:
103
105
  """Execute function with circuit breaker protection (sync)."""
104
106
  # Check if circuit is open
@@ -108,14 +110,14 @@ class CircuitBreaker:
108
110
  logger.info(
109
111
  "Circuit breaker attempting recovery",
110
112
  state="open->half_open",
111
- failure_count=self._failure_count
113
+ failure_count=self._failure_count,
112
114
  )
113
115
  else:
114
116
  raise RuntimeError(
115
117
  f"Circuit breaker is open. Next attempt in "
116
118
  f"{self._next_attempt_time - time.time():.1f} seconds"
117
119
  )
118
-
120
+
119
121
  try:
120
122
  result = func(*args, **kwargs)
121
123
  self._record_success()
@@ -124,7 +126,7 @@ class CircuitBreaker:
124
126
  if isinstance(e, self.expected_exception):
125
127
  self._record_failure(e)
126
128
  raise
127
-
129
+
128
130
  async def call_async(self, func: Callable[..., T], *args, **kwargs) -> T:
129
131
  """Execute async function with circuit breaker protection."""
130
132
  # Check if circuit is open
@@ -134,14 +136,14 @@ class CircuitBreaker:
134
136
  logger.info(
135
137
  "Circuit breaker attempting recovery",
136
138
  state="open->half_open",
137
- failure_count=self._failure_count
139
+ failure_count=self._failure_count,
138
140
  )
139
141
  else:
140
142
  raise RuntimeError(
141
143
  f"Circuit breaker is open. Next attempt in "
142
144
  f"{self._next_attempt_time - time.time():.1f} seconds"
143
145
  )
144
-
146
+
145
147
  try:
146
148
  result = await func(*args, **kwargs)
147
149
  self._record_success()
@@ -150,15 +152,15 @@ class CircuitBreaker:
150
152
  if isinstance(e, self.expected_exception):
151
153
  self._record_failure(e)
152
154
  raise
153
-
155
+
154
156
  def reset(self) -> None:
155
157
  """Manually reset the circuit breaker."""
156
158
  logger.info(
157
159
  "Circuit breaker manually reset",
158
160
  previous_state=self._state.value,
159
- failure_count=self._failure_count
161
+ failure_count=self._failure_count,
160
162
  )
161
163
  self._state = CircuitState.CLOSED
162
164
  self._failure_count = 0
163
165
  self._last_failure_time = None
164
- self._next_attempt_time = 0.0
166
+ self._next_attempt_time = 0.0