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.
- provide/foundation/__init__.py +20 -20
- provide/foundation/archive/__init__.py +1 -1
- provide/foundation/archive/base.py +15 -14
- provide/foundation/archive/bzip2.py +40 -40
- provide/foundation/archive/gzip.py +42 -42
- provide/foundation/archive/operations.py +90 -91
- provide/foundation/archive/tar.py +33 -31
- provide/foundation/archive/zip.py +52 -50
- provide/foundation/asynctools/__init__.py +20 -0
- provide/foundation/asynctools/core.py +126 -0
- provide/foundation/cli/__init__.py +2 -2
- provide/foundation/cli/commands/deps.py +4 -4
- provide/foundation/cli/commands/logs/__init__.py +2 -2
- provide/foundation/cli/commands/logs/generate.py +2 -2
- provide/foundation/cli/commands/logs/query.py +3 -3
- provide/foundation/cli/commands/logs/send.py +2 -2
- provide/foundation/cli/commands/logs/tail.py +2 -2
- provide/foundation/cli/decorators.py +0 -1
- provide/foundation/cli/testing.py +0 -5
- provide/foundation/cli/utils.py +1 -2
- provide/foundation/config/__init__.py +19 -19
- provide/foundation/config/base.py +2 -2
- provide/foundation/config/converters.py +81 -83
- provide/foundation/config/defaults.py +1 -1
- provide/foundation/config/env.py +2 -1
- provide/foundation/config/loader.py +1 -1
- provide/foundation/config/sync.py +8 -6
- provide/foundation/config/types.py +5 -5
- provide/foundation/config/validators.py +4 -4
- provide/foundation/console/output.py +7 -7
- provide/foundation/context/core.py +19 -17
- provide/foundation/crypto/certificates/__init__.py +9 -5
- provide/foundation/crypto/certificates/base.py +2 -2
- provide/foundation/crypto/certificates/certificate.py +48 -19
- provide/foundation/crypto/certificates/factory.py +26 -18
- provide/foundation/crypto/certificates/generator.py +24 -23
- provide/foundation/crypto/certificates/loader.py +24 -16
- provide/foundation/crypto/certificates/operations.py +17 -10
- provide/foundation/crypto/certificates/trust.py +21 -21
- provide/foundation/env/__init__.py +28 -0
- provide/foundation/env/core.py +218 -0
- provide/foundation/errors/__init__.py +3 -2
- provide/foundation/errors/decorators.py +0 -3
- provide/foundation/errors/types.py +0 -1
- provide/foundation/eventsets/display.py +13 -14
- provide/foundation/eventsets/registry.py +61 -31
- provide/foundation/eventsets/resolver.py +50 -46
- provide/foundation/eventsets/sets/das.py +8 -8
- provide/foundation/eventsets/sets/database.py +14 -14
- provide/foundation/eventsets/sets/http.py +21 -21
- provide/foundation/eventsets/sets/llm.py +16 -16
- provide/foundation/eventsets/sets/task_queue.py +13 -13
- provide/foundation/eventsets/types.py +7 -7
- provide/foundation/file/directory.py +1 -1
- provide/foundation/file/lock.py +2 -3
- provide/foundation/hub/components.py +19 -21
- provide/foundation/hub/config.py +25 -19
- provide/foundation/hub/discovery.py +5 -4
- provide/foundation/hub/handlers.py +13 -5
- provide/foundation/hub/lifecycle.py +10 -9
- provide/foundation/hub/manager.py +3 -0
- provide/foundation/hub/processors.py +8 -3
- provide/foundation/integrations/__init__.py +1 -1
- provide/foundation/integrations/openobserve/client.py +2 -2
- provide/foundation/integrations/openobserve/commands.py +9 -9
- provide/foundation/integrations/openobserve/config.py +2 -2
- provide/foundation/integrations/openobserve/otlp.py +2 -2
- provide/foundation/integrations/openobserve/search.py +1 -2
- provide/foundation/integrations/openobserve/streaming.py +1 -1
- provide/foundation/logger/__init__.py +0 -1
- provide/foundation/logger/config/base.py +1 -1
- provide/foundation/logger/config/logging.py +19 -19
- provide/foundation/logger/config/telemetry.py +11 -13
- provide/foundation/logger/factories.py +2 -2
- provide/foundation/logger/processors/main.py +12 -10
- provide/foundation/logger/ratelimit/limiters.py +4 -4
- provide/foundation/logger/ratelimit/processor.py +1 -1
- provide/foundation/logger/setup/coordinator.py +38 -24
- provide/foundation/logger/setup/processors.py +3 -3
- provide/foundation/logger/setup/testing.py +14 -0
- provide/foundation/logger/trace.py +5 -5
- provide/foundation/metrics/__init__.py +1 -1
- provide/foundation/metrics/otel.py +3 -1
- provide/foundation/observability/__init__.py +1 -1
- provide/foundation/process/__init__.py +1 -1
- provide/foundation/process/exit.py +6 -5
- provide/foundation/process/lifecycle.py +41 -18
- provide/foundation/resilience/__init__.py +6 -5
- provide/foundation/resilience/circuit.py +32 -30
- provide/foundation/resilience/decorators.py +58 -42
- provide/foundation/resilience/fallback.py +55 -40
- provide/foundation/resilience/retry.py +67 -65
- provide/foundation/serialization/__init__.py +16 -0
- provide/foundation/serialization/core.py +70 -0
- provide/foundation/streams/config.py +8 -9
- provide/foundation/streams/console.py +3 -3
- provide/foundation/streams/core.py +2 -2
- provide/foundation/streams/file.py +1 -1
- provide/foundation/testing/__init__.py +22 -7
- provide/foundation/testing/archive/__init__.py +7 -7
- provide/foundation/testing/archive/fixtures.py +58 -54
- provide/foundation/testing/cli.py +3 -6
- provide/foundation/testing/common/__init__.py +13 -13
- provide/foundation/testing/common/fixtures.py +27 -30
- provide/foundation/testing/file/__init__.py +15 -15
- provide/foundation/testing/file/content_fixtures.py +65 -92
- provide/foundation/testing/file/directory_fixtures.py +19 -19
- provide/foundation/testing/file/fixtures.py +14 -17
- provide/foundation/testing/file/special_fixtures.py +34 -42
- provide/foundation/testing/logger.py +28 -23
- provide/foundation/testing/mocking/__init__.py +21 -21
- provide/foundation/testing/mocking/fixtures.py +80 -67
- provide/foundation/testing/process/__init__.py +23 -23
- provide/foundation/testing/process/async_fixtures.py +89 -80
- provide/foundation/testing/process/fixtures.py +11 -13
- provide/foundation/testing/process/subprocess_fixtures.py +41 -40
- provide/foundation/testing/threading/__init__.py +17 -17
- provide/foundation/testing/threading/basic_fixtures.py +21 -17
- provide/foundation/testing/threading/data_fixtures.py +18 -16
- provide/foundation/testing/threading/execution_fixtures.py +67 -52
- provide/foundation/testing/threading/fixtures.py +10 -14
- provide/foundation/testing/threading/sync_fixtures.py +21 -18
- provide/foundation/testing/time/__init__.py +11 -11
- provide/foundation/testing/time/fixtures.py +91 -79
- provide/foundation/testing/transport/__init__.py +9 -9
- provide/foundation/testing/transport/fixtures.py +54 -54
- provide/foundation/time/__init__.py +18 -0
- provide/foundation/time/core.py +63 -0
- provide/foundation/tools/__init__.py +2 -2
- provide/foundation/tools/base.py +68 -67
- provide/foundation/tools/cache.py +62 -69
- provide/foundation/tools/downloader.py +51 -56
- provide/foundation/tools/installer.py +51 -57
- provide/foundation/tools/registry.py +38 -45
- provide/foundation/tools/resolver.py +70 -68
- provide/foundation/tools/verifier.py +39 -50
- provide/foundation/tracer/spans.py +1 -13
- provide/foundation/transport/__init__.py +26 -33
- provide/foundation/transport/base.py +32 -30
- provide/foundation/transport/client.py +44 -49
- provide/foundation/transport/config.py +11 -13
- provide/foundation/transport/errors.py +13 -27
- provide/foundation/transport/http.py +69 -55
- provide/foundation/transport/middleware.py +86 -81
- provide/foundation/transport/registry.py +29 -27
- provide/foundation/transport/types.py +6 -6
- provide/foundation/utils/deps.py +3 -2
- provide/foundation/utils/parsing.py +7 -7
- {provide_foundation-0.0.0.dev2.dist-info → provide_foundation-0.0.0.dev3.dist-info}/METADATA +2 -2
- provide_foundation-0.0.0.dev3.dist-info/RECORD +233 -0
- provide_foundation-0.0.0.dev2.dist-info/RECORD +0 -225
- {provide_foundation-0.0.0.dev2.dist-info → provide_foundation-0.0.0.dev3.dist-info}/WHEEL +0 -0
- {provide_foundation-0.0.0.dev2.dist-info → provide_foundation-0.0.0.dev3.dist-info}/entry_points.txt +0 -0
- {provide_foundation-0.0.0.dev2.dist-info → provide_foundation-0.0.0.dev3.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
178
|
-
|
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(
|
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")
|
@@ -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
|
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((
|
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}")
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
426
|
-
|
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
|
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
|
-
|
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
|
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
|
-
|
20
|
-
|
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
|