mvx-common 0.2.1__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.
- mvx/common/__init__.py +1 -0
- mvx/common/errors/__init__.py +14 -0
- mvx/common/errors/invalid_function_argument_error.py +54 -0
- mvx/common/errors/reasoned_error.py +56 -0
- mvx/common/errors/runtime_errors.py +79 -0
- mvx/common/errors/structured_error.py +85 -0
- mvx/common/helpers/__init__.py +18 -0
- mvx/common/helpers/api_error_processor.py +122 -0
- mvx/common/helpers/document_enum.py +18 -0
- mvx/common/helpers/introspection.py +21 -0
- mvx/common/helpers/run_with_cancellation_policy.py +161 -0
- mvx/common/logger/__init__.py +694 -0
- mvx/common/logger/adapter_logging/__init__.py +16 -0
- mvx/common/logger/adapter_logging/log_record_factory.py +137 -0
- mvx/common/logger/adapter_logging/logging_configs.py +371 -0
- mvx/common/logger/adapter_logging/logging_file_sink.py +205 -0
- mvx/common/logger/adapter_logging/logging_stream_sink.py +187 -0
- mvx/common/logger/asyncio_log_sink/__init__.py +42 -0
- mvx/common/logger/asyncio_log_sink/common.py +39 -0
- mvx/common/logger/asyncio_log_sink/errors.py +208 -0
- mvx/common/logger/asyncio_log_sink/log_sink.py +846 -0
- mvx/common/logger/errors.py +332 -0
- mvx/common/logger/helpers.py +34 -0
- mvx/common/logger/log_components/__init__.py +16 -0
- mvx/common/logger/log_components/log_invocation.py +878 -0
- mvx/common/logger/log_components/protocols.py +157 -0
- mvx/common/logger/log_context/__init__.py +8 -0
- mvx/common/logger/log_context/log_context.py +800 -0
- mvx/common/logger/log_payload_processor/__init__.py +19 -0
- mvx/common/logger/log_payload_processor/log_payload_processor.py +514 -0
- mvx/common/logger/log_payload_processor/types.py +52 -0
- mvx/common/logger/models.py +279 -0
- mvx/common/py.typed +0 -0
- mvx_common-0.2.1.dist-info/METADATA +208 -0
- mvx_common-0.2.1.dist-info/RECORD +38 -0
- mvx_common-0.2.1.dist-info/WHEEL +4 -0
- mvx_common-0.2.1.dist-info/licenses/LICENSE +42 -0
- mvx_common-0.2.1.dist-info/licenses/NOTICE +4 -0
mvx/common/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# common/src/mvx/common/__init__.py
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# common/src/mvx/common/errors/__init__.py
|
|
2
|
+
|
|
3
|
+
from .structured_error import StructuredError
|
|
4
|
+
from .reasoned_error import ReasonedError
|
|
5
|
+
from .runtime_errors import RuntimeExtendedError, RuntimeUnexpectedError
|
|
6
|
+
from .invalid_function_argument_error import InvalidFunctionArgumentError
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"StructuredError",
|
|
10
|
+
"ReasonedError",
|
|
11
|
+
"RuntimeExtendedError",
|
|
12
|
+
"RuntimeUnexpectedError",
|
|
13
|
+
"InvalidFunctionArgumentError",
|
|
14
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# common/src/mvx/common/errors/invalid_function_argument_error.py
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any, Mapping, Optional
|
|
5
|
+
|
|
6
|
+
from .structured_error import StructuredError
|
|
7
|
+
|
|
8
|
+
__all__ = ("InvalidFunctionArgumentError",)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InvalidFunctionArgumentError(StructuredError):
|
|
12
|
+
"""
|
|
13
|
+
Error raised when a function argument fails validation.
|
|
14
|
+
|
|
15
|
+
Wraps an underlying validation exception and adds structured context about
|
|
16
|
+
the function, argument, offending value, and validation error type.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
func: Function name where validation failed.
|
|
20
|
+
arg: Argument name that failed validation.
|
|
21
|
+
value: Offending argument value, when safe and useful to log.
|
|
22
|
+
cause: Underlying validation exception.
|
|
23
|
+
details: Additional log-friendly diagnostic context.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
func: Optional[str],
|
|
30
|
+
arg: Optional[str],
|
|
31
|
+
value: Optional[Any] = None,
|
|
32
|
+
cause: Exception,
|
|
33
|
+
details: Optional[Mapping[str, Any]] = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
msg = f"invalid argument -> {str(cause)}"
|
|
36
|
+
|
|
37
|
+
func = func or "<unknown>"
|
|
38
|
+
arg = arg or "<unknown>"
|
|
39
|
+
|
|
40
|
+
base_details: dict[str, Any] = {
|
|
41
|
+
"func": func,
|
|
42
|
+
"arg": arg,
|
|
43
|
+
"error_type": type(cause).__name__,
|
|
44
|
+
}
|
|
45
|
+
if value is not None:
|
|
46
|
+
base_details["value"] = value
|
|
47
|
+
if details:
|
|
48
|
+
base_details.update(details)
|
|
49
|
+
|
|
50
|
+
super().__init__(
|
|
51
|
+
message=msg,
|
|
52
|
+
details=base_details,
|
|
53
|
+
cause=cause,
|
|
54
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# common/src/mvx/common/errors/reasoned_error.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import Any, Optional, Mapping
|
|
4
|
+
|
|
5
|
+
from .structured_error import StructuredError
|
|
6
|
+
|
|
7
|
+
__all__ = ("ReasonedError",)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ReasonedError(StructuredError):
|
|
11
|
+
"""
|
|
12
|
+
Structured error with an optional stable reason code.
|
|
13
|
+
|
|
14
|
+
Extends StructuredError by adding `reason_code`, which can be used as a
|
|
15
|
+
machine-readable classifier for logging, metrics, and tests.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
message: Human-readable error message.
|
|
19
|
+
details: Optional log-friendly diagnostic context.
|
|
20
|
+
cause: Optional underlying exception.
|
|
21
|
+
reason: Optional stable reason code.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
message: str,
|
|
28
|
+
details: Optional[Mapping[str, Any]] = None,
|
|
29
|
+
cause: Optional[Exception] = None,
|
|
30
|
+
reason: Optional[str] = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
|
|
33
|
+
self.reason_code: Optional[str] = reason
|
|
34
|
+
|
|
35
|
+
super().__init__(
|
|
36
|
+
message=message,
|
|
37
|
+
details=details,
|
|
38
|
+
cause=cause,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def to_log_payload(self) -> dict[str, Any]:
|
|
42
|
+
"""
|
|
43
|
+
Extend base log payload with the reason code.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Log payload including "reason" when present.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
payload: dict[str, Any] = {}
|
|
50
|
+
|
|
51
|
+
if self.reason_code is not None:
|
|
52
|
+
payload["reason"] = self.reason_code
|
|
53
|
+
|
|
54
|
+
payload.update(super().to_log_payload())
|
|
55
|
+
|
|
56
|
+
return payload
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# common/src/mvx/common/errors/runtime_errors.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional, Mapping, Any
|
|
5
|
+
|
|
6
|
+
from .structured_error import StructuredError
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"RuntimeExtendedError",
|
|
10
|
+
"RuntimeUnexpectedError",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RuntimeExtendedError(StructuredError, RuntimeError):
|
|
15
|
+
"""
|
|
16
|
+
RuntimeError variant with structured diagnostic context.
|
|
17
|
+
|
|
18
|
+
Extends RuntimeError with the structured error payload provided by
|
|
19
|
+
StructuredError and optional source metadata.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
message: Human-readable error message.
|
|
23
|
+
details: Optional log-friendly diagnostic context.
|
|
24
|
+
cause: Optional underlying exception.
|
|
25
|
+
module: Optional module name associated with the error.
|
|
26
|
+
qualname: Optional qualified name associated with the error.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
message: str,
|
|
33
|
+
details: Optional[Mapping[str, Any]] = None,
|
|
34
|
+
cause: Optional[Exception] = None,
|
|
35
|
+
module: Optional[str] = None,
|
|
36
|
+
qualname: Optional[str] = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
|
|
39
|
+
self.module = None if module is None else (module.strip() or None)
|
|
40
|
+
self.qualname = None if qualname is None else (qualname.strip() or None)
|
|
41
|
+
|
|
42
|
+
RuntimeError.__init__(self, str(message))
|
|
43
|
+
|
|
44
|
+
StructuredError.__init__(
|
|
45
|
+
self,
|
|
46
|
+
message=message,
|
|
47
|
+
details=details,
|
|
48
|
+
cause=cause,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def to_log_payload(self) -> dict[str, Any]:
|
|
52
|
+
"""
|
|
53
|
+
Return a structured logging payload with optional source metadata.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Log-friendly error payload.
|
|
57
|
+
"""
|
|
58
|
+
base = StructuredError.to_log_payload(self)
|
|
59
|
+
|
|
60
|
+
payload: dict[str, Any] = {}
|
|
61
|
+
if self.module is not None:
|
|
62
|
+
payload["module"] = self.module
|
|
63
|
+
if self.qualname is not None:
|
|
64
|
+
payload["qualname"] = self.qualname
|
|
65
|
+
|
|
66
|
+
payload.update(base)
|
|
67
|
+
return payload
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RuntimeUnexpectedError(Exception):
|
|
71
|
+
"""
|
|
72
|
+
Marker base class for runtime errors classified as unexpected.
|
|
73
|
+
|
|
74
|
+
This class is intended for multiple inheritance with concrete domain
|
|
75
|
+
errors. It marks an error as unexpected without replacing the domain-specific
|
|
76
|
+
error hierarchy.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
...
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# common/src/mvx/common/errors/structured_error.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Mapping, Optional, Self
|
|
5
|
+
|
|
6
|
+
__all__ = ("StructuredError",)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StructuredError(Exception):
|
|
10
|
+
"""
|
|
11
|
+
Base exception class for errors with structured diagnostic context.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
message: Human-readable error message.
|
|
15
|
+
details: Optional log-friendly diagnostic context.
|
|
16
|
+
cause: Optional underlying exception.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
message: str,
|
|
23
|
+
details: Optional[Mapping[str, Any]] = None,
|
|
24
|
+
cause: Optional[Exception] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.message: str = message
|
|
27
|
+
self.details: dict[str, Any] = dict(details or {})
|
|
28
|
+
|
|
29
|
+
self.cause: Optional[Exception] = cause
|
|
30
|
+
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str:
|
|
34
|
+
base = f"{self.__class__.__name__}: {self.message}"
|
|
35
|
+
if self.details:
|
|
36
|
+
return f"{base} | details={self.details!r}"
|
|
37
|
+
return base
|
|
38
|
+
|
|
39
|
+
def with_detail(self, key: str, value: Any) -> Self:
|
|
40
|
+
"""
|
|
41
|
+
Add or replace one detail entry.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
key: Detail key.
|
|
45
|
+
value: Detail value.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
This error instance.
|
|
49
|
+
"""
|
|
50
|
+
self.details[key] = value
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def with_details(self, extra: Mapping[str, Any]) -> Self:
|
|
54
|
+
"""
|
|
55
|
+
Merge multiple detail entries.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
extra: Detail entries to merge.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
This error instance.
|
|
62
|
+
"""
|
|
63
|
+
self.details.update(extra)
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def to_log_payload(self) -> dict[str, Any]:
|
|
67
|
+
"""
|
|
68
|
+
Return a stable dictionary representation for structured logging.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Log-friendly error payload.
|
|
72
|
+
"""
|
|
73
|
+
payload: dict[str, Any] = {
|
|
74
|
+
"kind": self.__class__.__name__,
|
|
75
|
+
"message": self.message,
|
|
76
|
+
"details": dict(self.details),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if self.cause is not None:
|
|
80
|
+
payload["cause"] = {
|
|
81
|
+
"kind": self.cause.__class__.__name__,
|
|
82
|
+
"message": str(self.cause),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return payload
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# common/src/mvx/common/helpers/__init__.py
|
|
2
|
+
from .introspection import (
|
|
3
|
+
get_func_module_and_qualname,
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
from .api_error_processor import api_error_processor
|
|
7
|
+
|
|
8
|
+
from .run_with_cancellation_policy import run_with_cancellation_policy, CancellationPolicy
|
|
9
|
+
|
|
10
|
+
__all__ = (
|
|
11
|
+
# from introspection.py
|
|
12
|
+
"get_func_module_and_qualname",
|
|
13
|
+
# from api_error_processor.py
|
|
14
|
+
"api_error_processor",
|
|
15
|
+
# from run_with_cancellation_policy.py
|
|
16
|
+
"run_with_cancellation_policy",
|
|
17
|
+
"CancellationPolicy",
|
|
18
|
+
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# common/src/mvx/common/helpers/api_error_processor.py
|
|
2
|
+
"""
|
|
3
|
+
Decorator for normalizing public API errors.
|
|
4
|
+
|
|
5
|
+
The decorator lets declared API errors pass through unchanged and wraps
|
|
6
|
+
unexpected exceptions into a configured RuntimeExtendedError subclass.
|
|
7
|
+
Cancellation is always propagated unchanged.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Callable, TypeVar, Any, cast
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from functools import wraps
|
|
16
|
+
import inspect
|
|
17
|
+
|
|
18
|
+
from ..errors import RuntimeExtendedError
|
|
19
|
+
|
|
20
|
+
__all__ = ("api_error_processor",)
|
|
21
|
+
|
|
22
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def api_error_processor(
|
|
26
|
+
*,
|
|
27
|
+
passthrough_error_types: tuple[type[Exception], ...],
|
|
28
|
+
raise_error_type: type[RuntimeExtendedError],
|
|
29
|
+
) -> Callable[[F], F]:
|
|
30
|
+
"""
|
|
31
|
+
Build a decorator for public API exception normalization.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
passthrough_error_types: Exception types that must pass through unchanged.
|
|
35
|
+
raise_error_type: RuntimeExtendedError subclass used to wrap unexpected
|
|
36
|
+
exceptions.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Decorator that applies the public API error policy to sync or async
|
|
40
|
+
callables.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def decorate(func: F) -> F:
|
|
44
|
+
module = getattr(func, "__module__", "<unknown>")
|
|
45
|
+
qualname = getattr(func, "__qualname__", getattr(func, "__name__", "<unknown>"))
|
|
46
|
+
|
|
47
|
+
target = inspect.unwrap(func)
|
|
48
|
+
|
|
49
|
+
if inspect.iscoroutinefunction(target):
|
|
50
|
+
|
|
51
|
+
@wraps(func)
|
|
52
|
+
async def wrapped_async(*args: Any, **kwargs: Any) -> Any:
|
|
53
|
+
try:
|
|
54
|
+
return await func(*args, **kwargs)
|
|
55
|
+
except asyncio.CancelledError:
|
|
56
|
+
raise
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
if isinstance(exc, passthrough_error_types):
|
|
59
|
+
raise
|
|
60
|
+
|
|
61
|
+
if isinstance(exc, RuntimeExtendedError):
|
|
62
|
+
if exc.module is None:
|
|
63
|
+
exc.module = module
|
|
64
|
+
if exc.qualname is None:
|
|
65
|
+
exc.qualname = qualname
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
# noinspection PyBroadException
|
|
69
|
+
try:
|
|
70
|
+
err = raise_error_type(
|
|
71
|
+
message=f"runtime unexpected error: {str(exc)}",
|
|
72
|
+
module=module,
|
|
73
|
+
qualname=qualname,
|
|
74
|
+
cause=exc,
|
|
75
|
+
)
|
|
76
|
+
except Exception:
|
|
77
|
+
err = raise_error_type(message=str(exc))
|
|
78
|
+
err.cause = exc
|
|
79
|
+
err.module = module
|
|
80
|
+
err.qualname = qualname
|
|
81
|
+
|
|
82
|
+
raise err from exc
|
|
83
|
+
|
|
84
|
+
# noinspection PyUnnecessaryCast
|
|
85
|
+
return cast(F, wrapped_async)
|
|
86
|
+
|
|
87
|
+
@wraps(func)
|
|
88
|
+
def wrapped_sync(*args: Any, **kwargs: Any) -> Any:
|
|
89
|
+
try:
|
|
90
|
+
return func(*args, **kwargs)
|
|
91
|
+
except asyncio.CancelledError:
|
|
92
|
+
raise
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
if isinstance(exc, passthrough_error_types):
|
|
95
|
+
raise
|
|
96
|
+
if isinstance(exc, RuntimeExtendedError):
|
|
97
|
+
if exc.module is None:
|
|
98
|
+
exc.module = module
|
|
99
|
+
if exc.qualname is None:
|
|
100
|
+
exc.qualname = qualname
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
# noinspection PyBroadException
|
|
104
|
+
try:
|
|
105
|
+
err = raise_error_type(
|
|
106
|
+
message=f"runtime unexpected error: {str(exc)}",
|
|
107
|
+
module=module,
|
|
108
|
+
qualname=qualname,
|
|
109
|
+
cause=exc,
|
|
110
|
+
)
|
|
111
|
+
except Exception:
|
|
112
|
+
err = raise_error_type(message=str(exc))
|
|
113
|
+
err.cause = exc
|
|
114
|
+
err.module = module
|
|
115
|
+
err.qualname = qualname
|
|
116
|
+
|
|
117
|
+
raise err from exc
|
|
118
|
+
|
|
119
|
+
# noinspection PyUnnecessaryCast
|
|
120
|
+
return cast(F, wrapped_sync)
|
|
121
|
+
|
|
122
|
+
return decorate
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import TypeVar, cast
|
|
5
|
+
|
|
6
|
+
EnumClassT = TypeVar("EnumClassT", bound=type[Enum])
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from enum_tools.documentation import document_enum as _document_enum
|
|
10
|
+
except ImportError:
|
|
11
|
+
|
|
12
|
+
def document_enum(enum_class: EnumClassT) -> EnumClassT:
|
|
13
|
+
return enum_class
|
|
14
|
+
|
|
15
|
+
else:
|
|
16
|
+
|
|
17
|
+
def document_enum(enum_class: EnumClassT) -> EnumClassT:
|
|
18
|
+
return cast(EnumClassT, _document_enum(enum_class))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# common/src/mvx/common/helpers/introspection.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
__all__ = ("get_func_module_and_qualname",)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_func_module_and_qualname(func: Callable) -> tuple[str, str]:
|
|
10
|
+
"""
|
|
11
|
+
Return module and qualified name of a callable.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
func: Callable object.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Tuple of (module, qualname).
|
|
18
|
+
"""
|
|
19
|
+
module = getattr(func, "__module__", "<unknown>")
|
|
20
|
+
qualname = getattr(func, "__qualname__", getattr(func, "__name__", "<unknown>"))
|
|
21
|
+
return module, qualname
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# common/src/mvx/common/helpers/run_with_cancellation_policy.py
|
|
2
|
+
"""
|
|
3
|
+
Utilities for running awaitables under explicit cancellation policies.
|
|
4
|
+
|
|
5
|
+
The module provides a small primitive for cases where cancellation of the
|
|
6
|
+
caller task must either be handled normally, deferred and reported as a flag,
|
|
7
|
+
or deferred and re-raised after the protected operation completes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Coroutine, Literal, TypeVar, Tuple, overload, Union
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
import asyncio
|
|
17
|
+
|
|
18
|
+
__all__ = (
|
|
19
|
+
"CancellationPolicy",
|
|
20
|
+
"run_with_cancellation_policy",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CancellationPolicy(StrEnum):
|
|
27
|
+
"""
|
|
28
|
+
Cancellation handling policy for run_with_cancellation_policy().
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
PLAIN = "PLAIN"
|
|
32
|
+
DEFER_RERAISE = "DEFER_RERAISE"
|
|
33
|
+
DEFER_FLAG = "DEFER_FLAG"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _as_coro(a: Awaitable[T]) -> T:
|
|
37
|
+
"""
|
|
38
|
+
Wrap an arbitrary awaitable into a coroutine.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
a: Awaitable to execute.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Result produced by the awaitable.
|
|
45
|
+
"""
|
|
46
|
+
return await a
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _start_core_task(core_func: Callable[[], Awaitable[T]], op_name: str) -> asyncio.Task[T]:
|
|
50
|
+
"""
|
|
51
|
+
Start the core awaitable as a named asyncio task.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
core_func: Zero-argument callable returning the awaitable to run.
|
|
55
|
+
op_name: Name assigned to the created task.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Started asyncio task.
|
|
59
|
+
"""
|
|
60
|
+
a = core_func()
|
|
61
|
+
coro: Coroutine[Any, Any, T] = _as_coro(a)
|
|
62
|
+
return asyncio.create_task(coro, name=op_name)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@overload
|
|
66
|
+
async def run_with_cancellation_policy(
|
|
67
|
+
core_func: Callable[[], Awaitable[T]],
|
|
68
|
+
*,
|
|
69
|
+
policy: Literal[CancellationPolicy.PLAIN],
|
|
70
|
+
) -> T: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@overload
|
|
74
|
+
async def run_with_cancellation_policy(
|
|
75
|
+
core_func: Callable[[], Awaitable[T]],
|
|
76
|
+
*,
|
|
77
|
+
policy: Literal[CancellationPolicy.DEFER_RERAISE],
|
|
78
|
+
) -> T: ...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@overload
|
|
82
|
+
async def run_with_cancellation_policy(
|
|
83
|
+
core_func: Callable[[], Awaitable[T]],
|
|
84
|
+
*,
|
|
85
|
+
policy: Literal[CancellationPolicy.DEFER_FLAG] = CancellationPolicy.DEFER_FLAG,
|
|
86
|
+
) -> Tuple[bool, T]: ...
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@overload
|
|
90
|
+
async def run_with_cancellation_policy(
|
|
91
|
+
core_func: Callable[[], Awaitable[T]],
|
|
92
|
+
*,
|
|
93
|
+
policy: CancellationPolicy,
|
|
94
|
+
op_name: str = "unknown",
|
|
95
|
+
) -> Union[T, Tuple[bool, T]]: ...
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def run_with_cancellation_policy(
|
|
99
|
+
core_func: Callable[[], Awaitable[T]],
|
|
100
|
+
*,
|
|
101
|
+
policy: CancellationPolicy = CancellationPolicy.DEFER_FLAG,
|
|
102
|
+
op_name: str = "unknown",
|
|
103
|
+
) -> Union[T, Tuple[bool, T]]:
|
|
104
|
+
"""
|
|
105
|
+
Run one awaitable under the selected cancellation policy.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
core_func: Zero-argument callable returning the awaitable to run.
|
|
109
|
+
policy: Cancellation policy applied while awaiting the operation.
|
|
110
|
+
op_name: Name assigned to the internal task in deferred policies.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The operation result, or ``(cancel_requested, result)`` when using
|
|
114
|
+
``CancellationPolicy.DEFER_FLAG``.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
asyncio.CancelledError: Raised according to the selected cancellation
|
|
118
|
+
policy or when the core task itself is cancelled.
|
|
119
|
+
Exception: Propagates exceptions raised by the core awaitable.
|
|
120
|
+
"""
|
|
121
|
+
if policy is CancellationPolicy.PLAIN:
|
|
122
|
+
return await core_func()
|
|
123
|
+
|
|
124
|
+
core_task: asyncio.Task[T] = _start_core_task(core_func, op_name)
|
|
125
|
+
cancel_requested = False
|
|
126
|
+
|
|
127
|
+
while True:
|
|
128
|
+
try:
|
|
129
|
+
result = await asyncio.shield(core_task)
|
|
130
|
+
break
|
|
131
|
+
except asyncio.CancelledError:
|
|
132
|
+
# If the core task itself was cancelled, propagate core cancellation.
|
|
133
|
+
if core_task.cancelled():
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
# Otherwise this is caller-task cancellation while core is still running (deferrable).
|
|
137
|
+
cancel_requested = True
|
|
138
|
+
|
|
139
|
+
current = asyncio.current_task()
|
|
140
|
+
if current is not None:
|
|
141
|
+
# Drop all pending cancellation requests for this caller task so that
|
|
142
|
+
# the next await does not immediately raise again.
|
|
143
|
+
while current.uncancel() > 0:
|
|
144
|
+
pass
|
|
145
|
+
# Keep waiting for core_task to finish.
|
|
146
|
+
|
|
147
|
+
# Drain any pending cancellation that might have arrived in a tight race window
|
|
148
|
+
# after the await resumed but before we return/raise.
|
|
149
|
+
current = asyncio.current_task()
|
|
150
|
+
if current is not None and current.cancelling() > 0:
|
|
151
|
+
cancel_requested = True
|
|
152
|
+
while current.uncancel() > 0:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
if policy is CancellationPolicy.DEFER_FLAG:
|
|
156
|
+
return cancel_requested, result
|
|
157
|
+
|
|
158
|
+
# policy is DEFER_RERAISE
|
|
159
|
+
if cancel_requested:
|
|
160
|
+
raise asyncio.CancelledError()
|
|
161
|
+
return result
|