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.
- provide/foundation/__init__.py +41 -23
- provide/foundation/archive/__init__.py +23 -0
- provide/foundation/archive/base.py +70 -0
- provide/foundation/archive/bzip2.py +157 -0
- provide/foundation/archive/gzip.py +159 -0
- provide/foundation/archive/operations.py +334 -0
- provide/foundation/archive/tar.py +164 -0
- provide/foundation/archive/zip.py +203 -0
- provide/foundation/cli/__init__.py +2 -2
- provide/foundation/cli/commands/deps.py +13 -7
- provide/foundation/cli/commands/logs/__init__.py +1 -1
- provide/foundation/cli/commands/logs/query.py +1 -1
- provide/foundation/cli/commands/logs/send.py +1 -1
- provide/foundation/cli/commands/logs/tail.py +1 -1
- provide/foundation/cli/decorators.py +11 -10
- provide/foundation/cli/main.py +1 -1
- provide/foundation/cli/testing.py +2 -35
- provide/foundation/cli/utils.py +21 -17
- provide/foundation/config/__init__.py +35 -2
- provide/foundation/config/base.py +2 -2
- provide/foundation/config/converters.py +479 -0
- provide/foundation/config/defaults.py +67 -0
- provide/foundation/config/env.py +4 -19
- provide/foundation/config/loader.py +9 -3
- provide/foundation/config/sync.py +19 -4
- provide/foundation/console/input.py +5 -5
- provide/foundation/console/output.py +35 -13
- provide/foundation/context/__init__.py +8 -4
- provide/foundation/context/core.py +85 -109
- provide/foundation/core.py +1 -2
- provide/foundation/crypto/__init__.py +2 -0
- provide/foundation/crypto/certificates/__init__.py +34 -0
- provide/foundation/crypto/certificates/base.py +173 -0
- provide/foundation/crypto/certificates/certificate.py +290 -0
- provide/foundation/crypto/certificates/factory.py +213 -0
- provide/foundation/crypto/certificates/generator.py +138 -0
- provide/foundation/crypto/certificates/loader.py +130 -0
- provide/foundation/crypto/certificates/operations.py +198 -0
- provide/foundation/crypto/certificates/trust.py +107 -0
- provide/foundation/errors/__init__.py +2 -3
- provide/foundation/errors/decorators.py +0 -231
- provide/foundation/errors/types.py +0 -97
- provide/foundation/eventsets/__init__.py +0 -0
- provide/foundation/eventsets/display.py +84 -0
- provide/foundation/eventsets/registry.py +160 -0
- provide/foundation/eventsets/resolver.py +192 -0
- provide/foundation/eventsets/sets/das.py +128 -0
- provide/foundation/eventsets/sets/database.py +125 -0
- provide/foundation/eventsets/sets/http.py +153 -0
- provide/foundation/eventsets/sets/llm.py +139 -0
- provide/foundation/eventsets/sets/task_queue.py +107 -0
- provide/foundation/eventsets/types.py +70 -0
- provide/foundation/file/directory.py +13 -22
- provide/foundation/file/lock.py +3 -1
- provide/foundation/hub/components.py +77 -515
- provide/foundation/hub/config.py +151 -0
- provide/foundation/hub/discovery.py +62 -0
- provide/foundation/hub/handlers.py +81 -0
- provide/foundation/hub/lifecycle.py +194 -0
- provide/foundation/hub/manager.py +4 -4
- provide/foundation/hub/processors.py +44 -0
- provide/foundation/integrations/__init__.py +11 -0
- provide/foundation/{observability → integrations}/openobserve/__init__.py +10 -7
- provide/foundation/{observability → integrations}/openobserve/auth.py +1 -1
- provide/foundation/{observability → integrations}/openobserve/client.py +12 -12
- provide/foundation/{observability → integrations}/openobserve/commands.py +3 -3
- provide/foundation/integrations/openobserve/config.py +37 -0
- provide/foundation/{observability → integrations}/openobserve/formatters.py +1 -1
- provide/foundation/{observability → integrations}/openobserve/otlp.py +1 -1
- provide/foundation/{observability → integrations}/openobserve/search.py +2 -2
- provide/foundation/{observability → integrations}/openobserve/streaming.py +4 -4
- provide/foundation/logger/__init__.py +3 -10
- provide/foundation/logger/config/logging.py +68 -298
- provide/foundation/logger/config/telemetry.py +41 -121
- provide/foundation/logger/core.py +0 -2
- provide/foundation/logger/custom_processors.py +1 -0
- provide/foundation/logger/factories.py +11 -2
- provide/foundation/logger/processors/main.py +20 -84
- provide/foundation/logger/setup/__init__.py +5 -1
- provide/foundation/logger/setup/coordinator.py +76 -24
- provide/foundation/logger/setup/processors.py +2 -9
- provide/foundation/logger/trace.py +27 -0
- provide/foundation/metrics/otel.py +10 -10
- provide/foundation/observability/__init__.py +2 -2
- provide/foundation/process/__init__.py +9 -0
- provide/foundation/process/exit.py +47 -0
- provide/foundation/process/lifecycle.py +115 -59
- provide/foundation/resilience/__init__.py +35 -0
- provide/foundation/resilience/circuit.py +164 -0
- provide/foundation/resilience/decorators.py +220 -0
- provide/foundation/resilience/fallback.py +193 -0
- provide/foundation/resilience/retry.py +325 -0
- provide/foundation/streams/config.py +79 -0
- provide/foundation/streams/console.py +7 -8
- provide/foundation/streams/core.py +6 -3
- provide/foundation/streams/file.py +12 -2
- provide/foundation/testing/__init__.py +84 -2
- provide/foundation/testing/archive/__init__.py +24 -0
- provide/foundation/testing/archive/fixtures.py +217 -0
- provide/foundation/testing/cli.py +30 -17
- provide/foundation/testing/common/__init__.py +32 -0
- provide/foundation/testing/common/fixtures.py +236 -0
- provide/foundation/testing/file/__init__.py +40 -0
- provide/foundation/testing/file/content_fixtures.py +316 -0
- provide/foundation/testing/file/directory_fixtures.py +107 -0
- provide/foundation/testing/file/fixtures.py +52 -0
- provide/foundation/testing/file/special_fixtures.py +153 -0
- provide/foundation/testing/logger.py +117 -11
- provide/foundation/testing/mocking/__init__.py +46 -0
- provide/foundation/testing/mocking/fixtures.py +331 -0
- provide/foundation/testing/process/__init__.py +48 -0
- provide/foundation/testing/process/async_fixtures.py +405 -0
- provide/foundation/testing/process/fixtures.py +56 -0
- provide/foundation/testing/process/subprocess_fixtures.py +209 -0
- provide/foundation/testing/threading/__init__.py +38 -0
- provide/foundation/testing/threading/basic_fixtures.py +101 -0
- provide/foundation/testing/threading/data_fixtures.py +99 -0
- provide/foundation/testing/threading/execution_fixtures.py +263 -0
- provide/foundation/testing/threading/fixtures.py +54 -0
- provide/foundation/testing/threading/sync_fixtures.py +97 -0
- provide/foundation/testing/time/__init__.py +32 -0
- provide/foundation/testing/time/fixtures.py +409 -0
- provide/foundation/testing/transport/__init__.py +30 -0
- provide/foundation/testing/transport/fixtures.py +280 -0
- provide/foundation/tools/__init__.py +58 -0
- provide/foundation/tools/base.py +348 -0
- provide/foundation/tools/cache.py +268 -0
- provide/foundation/tools/downloader.py +224 -0
- provide/foundation/tools/installer.py +254 -0
- provide/foundation/tools/registry.py +223 -0
- provide/foundation/tools/resolver.py +321 -0
- provide/foundation/tools/verifier.py +186 -0
- provide/foundation/tracer/otel.py +7 -11
- provide/foundation/tracer/spans.py +2 -2
- provide/foundation/transport/__init__.py +155 -0
- provide/foundation/transport/base.py +171 -0
- provide/foundation/transport/client.py +266 -0
- provide/foundation/transport/config.py +140 -0
- provide/foundation/transport/errors.py +79 -0
- provide/foundation/transport/http.py +232 -0
- provide/foundation/transport/middleware.py +360 -0
- provide/foundation/transport/registry.py +167 -0
- provide/foundation/transport/types.py +45 -0
- provide/foundation/utils/deps.py +14 -12
- provide/foundation/utils/parsing.py +49 -4
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/METADATA +5 -28
- provide_foundation-0.0.0.dev2.dist-info/RECORD +225 -0
- provide/foundation/cli/commands/logs/generate_old.py +0 -569
- provide/foundation/crypto/certificates.py +0 -896
- provide/foundation/logger/emoji/__init__.py +0 -44
- provide/foundation/logger/emoji/matrix.py +0 -209
- provide/foundation/logger/emoji/sets.py +0 -458
- provide/foundation/logger/emoji/types.py +0 -56
- provide/foundation/logger/setup/emoji_resolver.py +0 -64
- provide_foundation-0.0.0.dev0.dist-info/RECORD +0 -149
- /provide/foundation/{observability → integrations}/openobserve/exceptions.py +0 -0
- /provide/foundation/{observability → integrations}/openobserve/models.py +0 -0
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/WHEEL +0 -0
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/entry_points.txt +0 -0
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
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
|
-
|
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
|
-
|
55
|
+
slog.debug("📊 OpenTelemetry metrics not available (dependencies not installed)")
|
56
56
|
return
|
57
57
|
|
58
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
120
|
+
slog.debug("📊🛑 OpenTelemetry meter provider shutdown")
|
121
121
|
except Exception as e:
|
122
|
-
|
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.
|
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.
|
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
|
-
|
121
|
-
self.
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
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
|
-
|
140
|
-
|
141
|
-
|
150
|
+
plog.info(
|
151
|
+
"🚀 Managed process started successfully",
|
152
|
+
pid=self._process.pid,
|
153
|
+
command=" ".join(self.command),
|
154
|
+
)
|
142
155
|
|
143
|
-
|
144
|
-
|
145
|
-
|
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 =
|
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 =
|
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 =
|
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 =
|
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
|
377
|
+
# Check if process has exited
|
369
378
|
if not process.is_running():
|
370
|
-
|
371
|
-
plog.
|
372
|
-
|
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
|
376
|
-
line = await process.read_line_async(timeout=
|
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
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
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
|