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.
- grpc_server_kit/__init__.py +34 -0
- grpc_server_kit/__version__.py +1 -0
- grpc_server_kit/aio/__init__.py +44 -0
- grpc_server_kit/aio/app.py +368 -0
- grpc_server_kit/aio/builder.py +63 -0
- grpc_server_kit/aio/dishka/__init__.py +28 -0
- grpc_server_kit/aio/dishka/bundle.py +70 -0
- grpc_server_kit/aio/dishka/interceptors.py +116 -0
- grpc_server_kit/aio/dishka/metrics.py +50 -0
- grpc_server_kit/aio/dishka/observability.py +86 -0
- grpc_server_kit/aio/dishka/server.py +51 -0
- grpc_server_kit/aio/health/__init__.py +36 -0
- grpc_server_kit/aio/health/cache.py +89 -0
- grpc_server_kit/aio/health/checkers/__init__.py +16 -0
- grpc_server_kit/aio/health/checkers/postgres.py +115 -0
- grpc_server_kit/aio/health/checkers/redis.py +88 -0
- grpc_server_kit/aio/health/checkers/utils.py +97 -0
- grpc_server_kit/aio/health/orchestrator.py +122 -0
- grpc_server_kit/aio/health/protocols.py +43 -0
- grpc_server_kit/aio/health/servicer.py +229 -0
- grpc_server_kit/aio/interceptors/__init__.py +62 -0
- grpc_server_kit/aio/interceptors/base.py +235 -0
- grpc_server_kit/aio/interceptors/context.py +179 -0
- grpc_server_kit/aio/interceptors/exception_handler.py +167 -0
- grpc_server_kit/aio/interceptors/metadata.py +57 -0
- grpc_server_kit/aio/interceptors/metrics.py +104 -0
- grpc_server_kit/aio/interceptors/protocols.py +54 -0
- grpc_server_kit/aio/interceptors/request_logger.py +103 -0
- grpc_server_kit/aio/interceptors/sentry.py +102 -0
- grpc_server_kit/aio/interceptors/tracing.py +130 -0
- grpc_server_kit/aio/observability/__init__.py +5 -0
- grpc_server_kit/aio/observability/tracing.py +48 -0
- grpc_server_kit/aio/runtime.py +206 -0
- grpc_server_kit/aio/server.py +191 -0
- grpc_server_kit/config.py +100 -0
- grpc_server_kit/constants.py +34 -0
- grpc_server_kit/credentials.py +112 -0
- grpc_server_kit/dishka/__init__.py +38 -0
- grpc_server_kit/dishka/settings.py +41 -0
- grpc_server_kit/interceptors/__init__.py +15 -0
- grpc_server_kit/interceptors/constants.py +44 -0
- grpc_server_kit/interceptors/utils.py +74 -0
- grpc_server_kit/observability/__init__.py +12 -0
- grpc_server_kit/observability/metrics.py +116 -0
- grpc_server_kit/observability/naming.py +26 -0
- grpc_server_kit/observability/protocols.py +86 -0
- grpc_server_kit/observability/sentry.py +63 -0
- grpc_server_kit/options.py +136 -0
- grpc_server_kit/protocols.py +114 -0
- grpc_server_kit/py.typed +0 -0
- grpc_server_kit/server.py +59 -0
- grpc_server_kit/settings.py +151 -0
- grpc_server_kit/signals.py +228 -0
- grpc_server_kit-0.1.0.dist-info/METADATA +484 -0
- grpc_server_kit-0.1.0.dist-info/RECORD +57 -0
- grpc_server_kit-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|
grpc_server_kit/py.typed
ADDED
|
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()
|