grpc-server-kit 0.1.0__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 (57) hide show
  1. grpc_server_kit/__init__.py +34 -0
  2. grpc_server_kit/__version__.py +1 -0
  3. grpc_server_kit/aio/__init__.py +44 -0
  4. grpc_server_kit/aio/app.py +368 -0
  5. grpc_server_kit/aio/builder.py +63 -0
  6. grpc_server_kit/aio/dishka/__init__.py +28 -0
  7. grpc_server_kit/aio/dishka/bundle.py +70 -0
  8. grpc_server_kit/aio/dishka/interceptors.py +116 -0
  9. grpc_server_kit/aio/dishka/metrics.py +50 -0
  10. grpc_server_kit/aio/dishka/observability.py +86 -0
  11. grpc_server_kit/aio/dishka/server.py +51 -0
  12. grpc_server_kit/aio/health/__init__.py +36 -0
  13. grpc_server_kit/aio/health/cache.py +89 -0
  14. grpc_server_kit/aio/health/checkers/__init__.py +16 -0
  15. grpc_server_kit/aio/health/checkers/postgres.py +115 -0
  16. grpc_server_kit/aio/health/checkers/redis.py +88 -0
  17. grpc_server_kit/aio/health/checkers/utils.py +97 -0
  18. grpc_server_kit/aio/health/orchestrator.py +122 -0
  19. grpc_server_kit/aio/health/protocols.py +43 -0
  20. grpc_server_kit/aio/health/servicer.py +229 -0
  21. grpc_server_kit/aio/interceptors/__init__.py +62 -0
  22. grpc_server_kit/aio/interceptors/base.py +235 -0
  23. grpc_server_kit/aio/interceptors/context.py +179 -0
  24. grpc_server_kit/aio/interceptors/exception_handler.py +167 -0
  25. grpc_server_kit/aio/interceptors/metadata.py +57 -0
  26. grpc_server_kit/aio/interceptors/metrics.py +104 -0
  27. grpc_server_kit/aio/interceptors/protocols.py +54 -0
  28. grpc_server_kit/aio/interceptors/request_logger.py +103 -0
  29. grpc_server_kit/aio/interceptors/sentry.py +102 -0
  30. grpc_server_kit/aio/interceptors/tracing.py +130 -0
  31. grpc_server_kit/aio/observability/__init__.py +5 -0
  32. grpc_server_kit/aio/observability/tracing.py +48 -0
  33. grpc_server_kit/aio/runtime.py +206 -0
  34. grpc_server_kit/aio/server.py +191 -0
  35. grpc_server_kit/config.py +100 -0
  36. grpc_server_kit/constants.py +34 -0
  37. grpc_server_kit/credentials.py +112 -0
  38. grpc_server_kit/dishka/__init__.py +38 -0
  39. grpc_server_kit/dishka/settings.py +41 -0
  40. grpc_server_kit/interceptors/__init__.py +15 -0
  41. grpc_server_kit/interceptors/constants.py +44 -0
  42. grpc_server_kit/interceptors/utils.py +74 -0
  43. grpc_server_kit/observability/__init__.py +12 -0
  44. grpc_server_kit/observability/metrics.py +116 -0
  45. grpc_server_kit/observability/naming.py +26 -0
  46. grpc_server_kit/observability/protocols.py +86 -0
  47. grpc_server_kit/observability/sentry.py +63 -0
  48. grpc_server_kit/options.py +136 -0
  49. grpc_server_kit/protocols.py +114 -0
  50. grpc_server_kit/py.typed +0 -0
  51. grpc_server_kit/server.py +59 -0
  52. grpc_server_kit/settings.py +151 -0
  53. grpc_server_kit/signals.py +228 -0
  54. grpc_server_kit-0.1.0.dist-info/METADATA +484 -0
  55. grpc_server_kit-0.1.0.dist-info/RECORD +57 -0
  56. grpc_server_kit-0.1.0.dist-info/WHEEL +4 -0
  57. grpc_server_kit-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,114 @@
1
+ """Structural protocols (duck-typing seams) for gRPC settings and servers.
2
+
3
+ Settings fields are declared as read-only properties, not plain attributes:
4
+ protocol attributes are invariant under type checking, while read-only
5
+ properties are covariant — implementations may narrow field types (e.g. a
6
+ pydantic model typing ``compression_algorithm`` as a ``Literal``) and still
7
+ satisfy the protocol.
8
+ """
9
+
10
+ from typing import TYPE_CHECKING, Any, NewType, Protocol
11
+
12
+ import grpc
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Coroutine
16
+
17
+ # Typed DI key for the fully-qualified gRPC service name (avoids ambiguous bare `str`).
18
+ GrpcServiceName = NewType("GrpcServiceName", str)
19
+
20
+
21
+ class GrpcSettingsProtocol(Protocol):
22
+ """Protocol for gRPC channel-tuning settings."""
23
+
24
+ @property
25
+ def host(self) -> str: ...
26
+ @property
27
+ def port(self) -> int: ...
28
+ @property
29
+ def keepalive_time_ms(self) -> int: ...
30
+ @property
31
+ def keepalive_timeout_ms(self) -> int: ...
32
+ @property
33
+ def keepalive_permit_without_calls(self) -> bool: ...
34
+ @property
35
+ def http2_min_recv_ping_interval_without_data_ms(self) -> int: ...
36
+ @property
37
+ def http2_max_pings_without_data(self) -> int: ...
38
+ @property
39
+ def max_send_message_length(self) -> int: ...
40
+ @property
41
+ def max_receive_message_length(self) -> int: ...
42
+ @property
43
+ def max_metadata_size(self) -> int: ...
44
+ @property
45
+ def initial_stream_window_size(self) -> int: ...
46
+ @property
47
+ def initial_connection_window_size(self) -> int: ...
48
+ @property
49
+ def max_connection_idle_ms(self) -> int | None: ...
50
+ @property
51
+ def max_connection_age_ms(self) -> int | None: ...
52
+ @property
53
+ def max_connection_age_grace_ms(self) -> int | None: ...
54
+ @property
55
+ def compression_algorithm(self) -> str | None: ...
56
+ @property
57
+ def max_concurrent_rpcs(self) -> int | None: ...
58
+
59
+
60
+ class GrpcSslSettingsProtocol(Protocol):
61
+ """Protocol for gRPC SSL/TLS settings."""
62
+
63
+ @property
64
+ def ssl_enabled(self) -> bool: ...
65
+ @property
66
+ def ssl_cert_file(self) -> str | None: ...
67
+ @property
68
+ def ssl_key_file(self) -> str | None: ...
69
+ @property
70
+ def ssl_ca_file(self) -> str | None: ...
71
+ @property
72
+ def ssl_client_auth(self) -> bool: ...
73
+ @property
74
+ def ssl_max_cert_size(self) -> int | None: ...
75
+
76
+
77
+ class GrpcServerSettingsProtocol(GrpcSettingsProtocol, GrpcSslSettingsProtocol, Protocol):
78
+ """Protocol for the full gRPC server settings."""
79
+
80
+ @property
81
+ def grace_period(self) -> float: ...
82
+ @property
83
+ def enable_reflection(self) -> bool: ...
84
+ @property
85
+ def enable_channelz(self) -> bool: ...
86
+
87
+
88
+ class GrpcServerProtocol(Protocol):
89
+ """Protocol for a synchronous gRPC server."""
90
+
91
+ def add_insecure_port(self, address: str) -> int: ...
92
+ def add_secure_port(self, address: str, credentials: grpc.ServerCredentials) -> int: ...
93
+ def start(self) -> None: ...
94
+ def stop(self, grace: float | None) -> None: ...
95
+
96
+
97
+ class GrpcAsyncServerProtocol(Protocol):
98
+ """Protocol for an asynchronous gRPC server."""
99
+
100
+ def add_insecure_port(self, address: str) -> int: ...
101
+ def add_secure_port(self, address: str, credentials: grpc.ServerCredentials) -> int: ...
102
+ def start(self) -> "Coroutine[Any, Any, None]": ...
103
+ def stop(self, grace: float | None) -> "Coroutine[Any, Any, None]": ...
104
+ def wait_for_termination(self, timeout: float | None = None) -> "Coroutine[None, None, bool]": ...
105
+
106
+
107
+ __all__ = [
108
+ "GrpcAsyncServerProtocol",
109
+ "GrpcServerProtocol",
110
+ "GrpcServerSettingsProtocol",
111
+ "GrpcServiceName",
112
+ "GrpcSettingsProtocol",
113
+ "GrpcSslSettingsProtocol",
114
+ ]
File without changes
@@ -0,0 +1,59 @@
1
+ """Common gRPC server utilities (sync and async)."""
2
+
3
+ import logging
4
+ import string
5
+
6
+ from .credentials import load_server_credentials
7
+ from .protocols import GrpcAsyncServerProtocol, GrpcServerProtocol, GrpcServerSettingsProtocol
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # gRPC service names are dot-separated identifiers up to 253 characters
12
+ # (per the gRPC naming and Health Checking Protocol conventions). One shared
13
+ # validator serves reflection registration and the health servicer alike.
14
+ SERVICE_NAME_MAX_LENGTH = 253
15
+ _SERVICE_NAME_ALLOWED_CHARS = frozenset(string.ascii_letters + string.digits + "._-/")
16
+
17
+
18
+ def is_valid_service_name(service: str) -> bool:
19
+ """Check a fully-qualified gRPC service name (character whitelist, no regex)."""
20
+ if not service or len(service) > SERVICE_NAME_MAX_LENGTH:
21
+ return False
22
+ return all(c in _SERVICE_NAME_ALLOWED_CHARS for c in service)
23
+
24
+
25
+ def bind_server_port(
26
+ server: GrpcServerProtocol | GrpcAsyncServerProtocol,
27
+ settings: GrpcServerSettingsProtocol,
28
+ ) -> int:
29
+ """Bind gRPC server to the configured port with or without TLS.
30
+
31
+ Compatible with both sync and async gRPC servers.
32
+
33
+ Note:
34
+ If settings.port is 0, the OS will choose an available ephemeral port.
35
+ The actual port bound is returned by this function.
36
+
37
+ Args:
38
+ server: gRPC server instance (sync or async)
39
+ settings: Server settings including host, port, and SSL configuration
40
+
41
+ Returns:
42
+ The actual bound port number.
43
+ """
44
+ if not (0 <= settings.port <= 65535):
45
+ raise ValueError(f"Invalid port number: {settings.port}")
46
+
47
+ if not settings.host:
48
+ raise ValueError("Host cannot be empty")
49
+
50
+ bind_address = f"{settings.host}:{settings.port}"
51
+ if settings.ssl_enabled:
52
+ credentials = load_server_credentials(settings)
53
+ port = server.add_secure_port(bind_address, credentials)
54
+ logger.info("gRPC server with TLS", extra={"address": bind_address, "port": port})
55
+ else:
56
+ port = server.add_insecure_port(bind_address)
57
+ logger.info("gRPC server (insecure)", extra={"address": bind_address, "port": port})
58
+
59
+ return port
@@ -0,0 +1,151 @@
1
+ """Standardized gRPC server and health-check configuration models (pydantic).
2
+
3
+ These are plain :class:`pydantic.BaseModel` building blocks meant to be nested
4
+ inside a service's own ``BaseSettings`` (so environment loading and prefixes stay
5
+ under the service's control). They structurally satisfy
6
+ :class:`grpc_server_kit.protocols.GrpcServerSettingsProtocol`.
7
+
8
+ This module is gated behind the ``[settings]`` optional dependency
9
+ (``pip install grpc-server-kit[settings]``) which pulls in ``pydantic``.
10
+ For a zero-dependency alternative see :class:`grpc_server_kit.config.GrpcServerConfig`.
11
+ """
12
+
13
+ from typing import Literal
14
+
15
+ from pydantic import BaseModel, Field, model_validator
16
+
17
+ from grpc_server_kit.constants import (
18
+ DEFAULT_GRACE_PERIOD,
19
+ DEFAULT_HEALTH_CACHE_TTL,
20
+ DEFAULT_HEALTH_CHECK_TIMEOUT,
21
+ DEFAULT_HOST,
22
+ DEFAULT_HTTP2_MAX_PINGS_WITHOUT_DATA,
23
+ DEFAULT_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS,
24
+ DEFAULT_INITIAL_WINDOW_SIZE,
25
+ DEFAULT_KEEPALIVE_TIME_MS,
26
+ DEFAULT_KEEPALIVE_TIMEOUT_MS,
27
+ DEFAULT_MAX_METADATA_SIZE,
28
+ DEFAULT_MAX_RECEIVE_MESSAGE_LENGTH,
29
+ DEFAULT_MAX_SEND_MESSAGE_LENGTH,
30
+ DEFAULT_PORT,
31
+ )
32
+
33
+ GrpcCompressionAlgorithm = Literal["deflate", "gzip"]
34
+
35
+
36
+ class BaseHealthSettings(BaseModel):
37
+ """Base configuration for gRPC health checks.
38
+
39
+ Consumed by :meth:`grpc_server_kit.aio.GrpcApp.enable_health` as the source
40
+ of defaults when the app's settings object carries a ``health`` block.
41
+ """
42
+
43
+ cache_ttl: float = Field(
44
+ default=DEFAULT_HEALTH_CACHE_TTL,
45
+ ge=0,
46
+ description="Health check result cache TTL in seconds (0 disables caching)",
47
+ )
48
+ check_timeout: float = Field(
49
+ default=DEFAULT_HEALTH_CHECK_TIMEOUT, gt=0, description="Timeout for each health check run in seconds"
50
+ )
51
+
52
+
53
+ class BaseGrpcServerSettings(BaseModel):
54
+ """Base gRPC server configuration.
55
+
56
+ Standardized settings for gRPC servers, including TLS, keepalive, and limits.
57
+ """
58
+
59
+ # Basic
60
+ host: str = Field(default=DEFAULT_HOST, min_length=1, description="gRPC server host")
61
+ port: int = Field(default=DEFAULT_PORT, ge=0, le=65535, description="gRPC server port (0 = ephemeral)")
62
+
63
+ # TLS/SSL settings
64
+ ssl_enabled: bool = Field(default=False, description="Enable TLS/SSL")
65
+ ssl_cert_file: str | None = Field(default=None, description="Path to server certificate")
66
+ ssl_key_file: str | None = Field(default=None, description="Path to server private key")
67
+ ssl_ca_file: str | None = Field(default=None, description="Path to CA certificate (for mTLS)")
68
+ ssl_client_auth: bool = Field(default=False, description="Require client certificate (mTLS)")
69
+ ssl_max_cert_size: int | None = Field(default=None, ge=1, description="Maximum certificate file size (bytes)")
70
+
71
+ # Keepalive settings
72
+ keepalive_time_ms: int = Field(
73
+ default=DEFAULT_KEEPALIVE_TIME_MS, ge=0, description="Time between keepalive pings (ms)"
74
+ )
75
+ keepalive_timeout_ms: int = Field(
76
+ default=DEFAULT_KEEPALIVE_TIMEOUT_MS, ge=0, description="Timeout for keepalive ping response (ms)"
77
+ )
78
+ keepalive_permit_without_calls: bool = Field(
79
+ default=False,
80
+ description="Allow keepalive pings without active RPCs",
81
+ )
82
+ http2_min_recv_ping_interval_without_data_ms: int = Field(
83
+ default=DEFAULT_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS,
84
+ ge=0,
85
+ description="Minimum time between pings from client without data",
86
+ )
87
+ http2_max_pings_without_data: int = Field(
88
+ default=DEFAULT_HTTP2_MAX_PINGS_WITHOUT_DATA, ge=0, description="Max pings allowed without data before GOAWAY"
89
+ )
90
+
91
+ # Connection limits
92
+ max_concurrent_rpcs: int | None = Field(default=None, ge=1, description="Maximum concurrent RPCs")
93
+ max_connection_idle_ms: int | None = Field(default=None, ge=1, description="Max idle time (ms)")
94
+ max_connection_age_ms: int | None = Field(default=None, ge=1, description="Max connection age (ms)")
95
+ max_connection_age_grace_ms: int | None = Field(default=None, ge=1, description="Grace period (ms)")
96
+
97
+ # Message size limits
98
+ max_send_message_length: int = Field(
99
+ default=DEFAULT_MAX_SEND_MESSAGE_LENGTH, ge=1, description="Max send length (bytes)"
100
+ )
101
+ max_receive_message_length: int = Field(
102
+ default=DEFAULT_MAX_RECEIVE_MESSAGE_LENGTH, ge=1, description="Max receive length (bytes)"
103
+ )
104
+ max_metadata_size: int = Field(default=DEFAULT_MAX_METADATA_SIZE, ge=1, description="Max metadata size (bytes)")
105
+
106
+ # Compression
107
+ compression_algorithm: GrpcCompressionAlgorithm | None = Field(
108
+ default=None,
109
+ description="Default compression: deflate or gzip (None = no compression)",
110
+ )
111
+
112
+ # Flow control settings
113
+ initial_stream_window_size: int = Field(
114
+ default=DEFAULT_INITIAL_WINDOW_SIZE, ge=1, description="Initial stream window (bytes)"
115
+ )
116
+ initial_connection_window_size: int = Field(
117
+ default=DEFAULT_INITIAL_WINDOW_SIZE, ge=1, description="Initial connection window (bytes)"
118
+ )
119
+
120
+ # Features
121
+ enable_reflection: bool = Field(default=False, description="Enable gRPC reflection")
122
+ enable_channelz: bool = Field(default=False, description="Enable channelz for debugging")
123
+
124
+ # Graceful shutdown
125
+ grace_period: float = Field(
126
+ default=DEFAULT_GRACE_PERIOD, ge=0, description="Graceful shutdown grace period in seconds"
127
+ )
128
+
129
+ # Metrics (consumed by the dishka bundle / your own wiring)
130
+ metrics_enabled: bool = Field(default=False, description="Enable gRPC server metrics")
131
+
132
+ # Health (consumed by GrpcApp.enable_health as its defaults)
133
+ health: BaseHealthSettings = Field(default_factory=BaseHealthSettings, description="Health check configuration")
134
+
135
+ @model_validator(mode="after")
136
+ def _validate_ssl(self) -> "BaseGrpcServerSettings":
137
+ if self.ssl_enabled:
138
+ if not self.ssl_cert_file:
139
+ raise ValueError("gRPC ssl_cert_file is required when ssl_enabled=True")
140
+ if not self.ssl_key_file:
141
+ raise ValueError("gRPC ssl_key_file is required when ssl_enabled=True")
142
+ if self.ssl_client_auth and not self.ssl_ca_file:
143
+ raise ValueError("gRPC ssl_ca_file is required when ssl_client_auth=True")
144
+ return self
145
+
146
+
147
+ __all__ = [
148
+ "BaseGrpcServerSettings",
149
+ "BaseHealthSettings",
150
+ "GrpcCompressionAlgorithm",
151
+ ]
@@ -0,0 +1,228 @@
1
+ """Signal handling for graceful gRPC server shutdown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import functools
8
+ import inspect
9
+ import logging
10
+ import signal
11
+ import sys
12
+ import types
13
+ from typing import TYPE_CHECKING, Any, cast
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Awaitable, Callable, Coroutine
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _HANDLED_SIGNALS = (signal.SIGINT, signal.SIGTERM)
21
+
22
+ # Handler value used by the standard `signal` module (SIG_DFL/SIG_IGN/callable/None).
23
+ type _StdSignalHandler = Callable[[int, types.FrameType | None], Any] | int | signal.Handlers | None
24
+
25
+
26
+ class SignalManager:
27
+ """Manager for signal handlers to avoid global state.
28
+
29
+ A manager can be reused across sequential runs: :meth:`reset` /
30
+ :meth:`reset_async` restore the signal handlers that were installed before
31
+ :meth:`setup` (not the OS defaults) and re-arm the manager for the next
32
+ setup/signal cycle.
33
+ """
34
+
35
+ def __init__(self) -> None:
36
+ self._handlers_set_up = False
37
+ self._used_loop_handlers = False
38
+ self._saved_std_handlers: dict[int, _StdSignalHandler] = {}
39
+ self._background_tasks: set[asyncio.Future[None]] = set()
40
+ self._loop: asyncio.AbstractEventLoop | None = None
41
+ self._shutdown_in_progress = False
42
+
43
+ def setup(
44
+ self,
45
+ shutdown_callback: Callable[[str], None] | Callable[[str], Awaitable[None]],
46
+ ) -> None:
47
+ """Setup signal handlers for graceful shutdown.
48
+
49
+ Supports both synchronous and asynchronous callbacks. Degrades to a
50
+ warning (instead of crashing) when handlers cannot be installed — e.g.
51
+ when called from a non-main thread, where Python allows no signal
52
+ handling at all.
53
+ """
54
+ if self._handlers_set_up:
55
+ return
56
+
57
+ try:
58
+ self._loop = asyncio.get_running_loop()
59
+ except RuntimeError:
60
+ self._loop = None
61
+
62
+ is_async = inspect.iscoroutinefunction(shutdown_callback)
63
+
64
+ def handle_signal(sig_name: str) -> None:
65
+ if self._shutdown_in_progress:
66
+ logger.debug("Shutdown already in progress, ignoring signal", extra={"signal": sig_name})
67
+ return
68
+
69
+ self._shutdown_in_progress = True
70
+ logger.info("Received signal, starting shutdown", extra={"signal": sig_name})
71
+
72
+ if is_async:
73
+ # Always try to schedule on the captured loop if it exists
74
+ if self._loop and self._loop.is_running():
75
+
76
+ def _schedule_callback() -> None:
77
+ coro = shutdown_callback(sig_name)
78
+ if self._loop is None:
79
+ return
80
+
81
+ task: asyncio.Task[None] = self._loop.create_task(cast("Coroutine[Any, Any, None]", coro))
82
+ self._background_tasks.add(task)
83
+
84
+ def _done_cb(t: asyncio.Task[None]) -> None:
85
+ self._background_tasks.discard(t)
86
+ try:
87
+ t.result()
88
+ except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
89
+ pass
90
+ except Exception:
91
+ logger.exception("Error in async shutdown callback", extra={"signal": sig_name})
92
+
93
+ task.add_done_callback(_done_cb)
94
+
95
+ self._loop.call_soon_threadsafe(_schedule_callback)
96
+ else:
97
+ # No running loop, fallback to sync if possible (unlikely for async callback)
98
+ logger.warning("No running event loop to schedule async shutdown callback")
99
+ else:
100
+ # shutdown_callback is Callable[[str], None]
101
+ if self._loop and self._loop.is_running():
102
+ self._loop.call_soon_threadsafe(lambda: shutdown_callback(sig_name))
103
+ else:
104
+ shutdown_callback(sig_name)
105
+
106
+ if self._setup_loop_handlers(handle_signal):
107
+ self._used_loop_handlers = True
108
+ else:
109
+ self._setup_standard_handlers(handle_signal)
110
+
111
+ self._handlers_set_up = True
112
+
113
+ def _setup_loop_handlers(self, handle_signal: Callable[[str], None]) -> bool:
114
+ """Setup signal handlers using asyncio loop.
115
+
116
+ Returns:
117
+ True if handlers were successfully set up via loop, False otherwise.
118
+ """
119
+ if sys.platform != "win32" and self._loop:
120
+ try:
121
+ for sig in _HANDLED_SIGNALS:
122
+ self._loop.add_signal_handler(sig, functools.partial(handle_signal, sig.name))
123
+ except (RuntimeError, ValueError):
124
+ return False
125
+ else:
126
+ return True
127
+
128
+ return False
129
+
130
+ def _setup_standard_handlers(self, handle_signal: Callable[[str], None]) -> None:
131
+ """Setup handlers via the standard signal module, saving the previous ones.
132
+
133
+ ``signal.signal`` raises ValueError outside the main thread — degrade to
134
+ a warning instead of crashing the caller after the server has started.
135
+ """
136
+
137
+ def _handler(signum: int, _frame: types.FrameType | None) -> None:
138
+ sig_name = f"signal {signum}"
139
+ with contextlib.suppress(ValueError, AttributeError):
140
+ sig_name = signal.Signals(signum).name
141
+ handle_signal(sig_name)
142
+
143
+ signals = _HANDLED_SIGNALS if sys.platform != "win32" else (signal.SIGINT,)
144
+ for sig in signals:
145
+ try:
146
+ previous = signal.signal(sig, _handler)
147
+ except (ValueError, OSError):
148
+ logger.warning(
149
+ "Cannot install signal handler (non-main thread?); "
150
+ "graceful shutdown by signal is disabled for this run",
151
+ extra={"signal": sig.name},
152
+ )
153
+ return
154
+ self._saved_std_handlers[sig] = previous
155
+
156
+ def _restore_handlers(self) -> None:
157
+ """Restore the signal handlers that were in place before setup()."""
158
+ if self._used_loop_handlers and self._loop and self._loop.is_running():
159
+ for sig in _HANDLED_SIGNALS:
160
+ with contextlib.suppress(RuntimeError, ValueError):
161
+ self._loop.remove_signal_handler(sig)
162
+
163
+ # Restore the exact previous handlers (NOT SIG_DFL: Python's default
164
+ # SIGINT handler is default_int_handler, and the host application may
165
+ # have installed its own handlers before us).
166
+ for signum, previous in self._saved_std_handlers.items():
167
+ with contextlib.suppress(ValueError, OSError, TypeError):
168
+ signal.signal(signum, previous)
169
+
170
+ self._saved_std_handlers.clear()
171
+ self._used_loop_handlers = False
172
+ self._handlers_set_up = False
173
+ self._shutdown_in_progress = False
174
+ self._loop = None
175
+
176
+ async def reset_async(self) -> None:
177
+ """Restore previous signal handlers and await pending shutdown callbacks."""
178
+ if not self._handlers_set_up:
179
+ return
180
+
181
+ pending = list(self._background_tasks)
182
+ self._restore_handlers()
183
+ if pending:
184
+ # We don't cancel them, we wait for them to finish naturally (shutdown callbacks)
185
+ await asyncio.gather(*pending, return_exceptions=True)
186
+ self._background_tasks.clear()
187
+
188
+ def reset(self) -> None:
189
+ """Restore previous signal handlers synchronously.
190
+
191
+ Note: This cancels pending background tasks. Use reset_async to wait
192
+ for them instead.
193
+ """
194
+ if not self._handlers_set_up:
195
+ return
196
+
197
+ self._restore_handlers()
198
+ for task in self._background_tasks:
199
+ if not task.done():
200
+ task.cancel()
201
+ self._background_tasks.clear()
202
+
203
+
204
+ # Default instance for convenience, but allows creating independent managers
205
+ _default_manager = SignalManager()
206
+
207
+
208
+ def setup_signal_handlers(
209
+ shutdown_callback: Callable[[str], None] | Callable[[str], Awaitable[None]],
210
+ ) -> None:
211
+ """Setup signal handlers using the default manager."""
212
+ _default_manager.setup(shutdown_callback)
213
+
214
+
215
+ def reset_signal_handlers() -> None:
216
+ """Reset signal handlers using the default manager.
217
+
218
+ Note: This is synchronous and cancels pending tasks.
219
+ """
220
+ _default_manager.reset()
221
+
222
+
223
+ async def reset_signal_handlers_async() -> None:
224
+ """Reset signal handlers asynchronously using the default manager.
225
+
226
+ Awaits all pending background tasks.
227
+ """
228
+ await _default_manager.reset_async()