leasepool 0.1.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.
leasepool/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ import logging
2
+
3
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
4
+
5
+ from .backends import ExecutorBackend
6
+ from .exceptions import (
7
+ LeaseExpiredError,
8
+ LeasePoolError,
9
+ LeasePoolNotStartedError,
10
+ LeaseUnavailableError,
11
+ UnsupportedBackendError,
12
+ )
13
+ from .grinder import WorkGrinder
14
+ from ._process_logging import ProcessLoggingConfig
15
+ from .manager import ExecutorLease, LeasedExecutorManager
16
+
17
+ __all__ = [
18
+ "ExecutorBackend",
19
+ "ExecutorLease",
20
+ "LeasedExecutorManager",
21
+ "LeaseExpiredError",
22
+ "LeasePoolError",
23
+ "LeasePoolNotStartedError",
24
+ "LeaseUnavailableError",
25
+ "UnsupportedBackendError",
26
+ "WorkGrinder",
27
+ "ProcessLoggingConfig",
28
+ ]
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from logging.handlers import QueueHandler, QueueListener
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ProcessLoggingConfig:
12
+ """Configuration for forwarding ProcessPoolExecutor worker logs.
13
+
14
+ This is intentionally opt-in. Normal leasepool logs are emitted in the parent
15
+ process through normal Python loggers and do not need this bridge.
16
+
17
+ Attributes:
18
+ enabled: Enable child-process log forwarding.
19
+ level: Minimum level configured on the child-process root logger.
20
+ target_logger: Parent-process logger that receives records from workers.
21
+ If omitted, LeasedExecutorManager's logger is used.
22
+ clear_child_handlers: Remove inherited/preconfigured child handlers before
23
+ installing the queue handler. This avoids duplicate child logs after
24
+ fork and prevents children from writing directly to stderr.
25
+ """
26
+ enabled: bool = False
27
+ level: int | str = logging.INFO
28
+ target_logger: logging.Logger | None = None
29
+ clear_child_handlers: bool = True
30
+
31
+
32
+ def coerce_log_level(level: int | str) -> int:
33
+ """Return a numeric logging level from an int or standard level name."""
34
+ if isinstance(level, str):
35
+ try:
36
+ resolved = logging.getLevelName(level.upper())
37
+ if isinstance(resolved, int):
38
+ return resolved
39
+ raise ValueError(f"Unknown logging level: {level!r}")
40
+ except Exception as e:
41
+ raise ValueError(f"Invalid logging level: {level!r}") from e
42
+
43
+ return int(level)
44
+
45
+
46
+ class LoggerForwardingHandler(logging.Handler):
47
+ """QueueListener target that forwards records into a parent logger.
48
+
49
+ QueueListener normally writes records directly to concrete handlers. This
50
+ handler instead re-enters the parent's logger hierarchy, so the application
51
+ keeps control over formatters, handlers, filters, propagation, and levels.
52
+ """
53
+
54
+ def __init__(self, target_logger: logging.Logger):
55
+ """Initialize the handler with a target logger.
56
+
57
+ Args:
58
+ target_logger (logging.Logger): The parent logger to which records will be forwarded.
59
+ """
60
+ super().__init__(level=logging.NOTSET)
61
+ self._target_logger = target_logger
62
+
63
+ def emit(self, record: logging.LogRecord) -> None:
64
+ """Forward a log record to the target logger.
65
+
66
+ Args:
67
+ record (logging.LogRecord): The log record to be forwarded.
68
+ """
69
+ if not self._target_logger.isEnabledFor(record.levelno):
70
+ return
71
+ self._target_logger.handle(record)
72
+
73
+
74
+ def configure_process_worker_logging(
75
+ log_queue: Any,
76
+ *,
77
+ level: int | str,
78
+ clear_existing_handlers: bool,
79
+ ) -> None:
80
+ """Install QueueHandler on the worker process root logger.
81
+
82
+ Args:
83
+ log_queue: The multiprocessing queue to which log records will be sent. If None, logging is not configured.
84
+ level: Minimum logging level for the worker process root logger.
85
+ clear_existing_handlers: Whether to remove existing handlers from the root logger before adding the QueueHandler.
86
+ """
87
+ root_logger = logging.getLogger()
88
+
89
+ if clear_existing_handlers:
90
+ root_logger.handlers.clear()
91
+
92
+ root_logger.addHandler(QueueHandler(log_queue))
93
+ root_logger.setLevel(coerce_log_level(level))
94
+
95
+
96
+ def process_worker_initializer(
97
+ log_queue: Any | None,
98
+ level: int | str,
99
+ clear_existing_handlers: bool,
100
+ user_initializer: Callable[..., Any] | None,
101
+ user_initargs: tuple[Any, ...],
102
+ ) -> None:
103
+ """ProcessPoolExecutor initializer used by leasepool.
104
+
105
+ It first configures child-process logging, then calls the user's original
106
+ initializer, if one was supplied to ProcessPoolExecutor.
107
+
108
+ Args:
109
+ log_queue: The multiprocessing queue to which log records will be sent. If None, logging is not configured.
110
+ level: Minimum logging level for the worker process root logger.
111
+ clear_existing_handlers: Whether to remove existing handlers from the root logger before adding the QueueHandler.
112
+ user_initializer: The original initializer function supplied by the user to ProcessPoolExecutor, if any.
113
+ user_initargs: The original initializer arguments supplied by the user to ProcessPoolExecutor, if any.
114
+ """
115
+ if log_queue is not None:
116
+ configure_process_worker_logging(
117
+ log_queue,
118
+ level=level,
119
+ clear_existing_handlers=clear_existing_handlers,
120
+ )
121
+
122
+ if user_initializer is not None:
123
+ user_initializer(*user_initargs)
124
+
125
+
126
+ def build_queue_listener(
127
+ *,
128
+ log_queue: Any,
129
+ target_logger: logging.Logger,
130
+ ) -> QueueListener:
131
+ """Create the parent-side listener for child-process log records.
132
+
133
+ Args:
134
+ log_queue: The multiprocessing queue from which log records will be received.
135
+ target_logger: The parent logger to which received log records will be forwarded.
136
+ """
137
+ return QueueListener(
138
+ log_queue,
139
+ LoggerForwardingHandler(target_logger),
140
+ respect_handler_level=True,
141
+ )
leasepool/backends.py ADDED
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from enum import StrEnum
5
+ from concurrent.futures import Executor, ProcessPoolExecutor, ThreadPoolExecutor
6
+
7
+
8
+ class ExecutorBackend(StrEnum):
9
+ """Enum representing the available executor backends.
10
+
11
+ Args:
12
+ StrEnum (_type_): String-based enum for executor backends.
13
+ """
14
+ THREAD = "thread"
15
+ PROCESS = "process"
16
+ INTERPRETER = "interpreter"
17
+
18
+
19
+ def normalize_backend(backend: ExecutorBackend | str) -> ExecutorBackend:
20
+ """Normalize the backend to an ExecutorBackend enum.
21
+
22
+ Args:
23
+ backend (ExecutorBackend | str): The backend to normalize.
24
+
25
+ Raises:
26
+ ValueError: If the backend is not supported.
27
+
28
+ Returns:
29
+ ExecutorBackend: The normalized backend.
30
+ """
31
+ if isinstance(backend, ExecutorBackend):
32
+ return backend
33
+ try:
34
+ return ExecutorBackend(str(backend).lower())
35
+ except ValueError as exc:
36
+ supported = ", ".join(item.value for item in ExecutorBackend)
37
+ raise ValueError(f"Unsupported backend {backend!r}. Supported: {supported}") from exc
38
+
39
+
40
+ def resolve_executor_cls(backend: ExecutorBackend | str) -> type[Executor]:
41
+ """Resolve the executor class for the given backend.
42
+
43
+ Args:
44
+ backend (ExecutorBackend | str): The backend to resolve.
45
+
46
+ Raises:
47
+ UnsupportedBackendError: If the backend is not supported.
48
+ UnsupportedBackendError: If the InterpreterPoolExecutor is not available.
49
+
50
+ Returns:
51
+ type[Executor]: The executor class for the given backend.
52
+ """
53
+ from .exceptions import UnsupportedBackendError
54
+
55
+ normalized = normalize_backend(backend)
56
+
57
+ if normalized is ExecutorBackend.THREAD:
58
+ return ThreadPoolExecutor
59
+
60
+ if normalized is ExecutorBackend.PROCESS:
61
+ return ProcessPoolExecutor
62
+
63
+ if normalized is ExecutorBackend.INTERPRETER:
64
+ try:
65
+ from concurrent.futures import InterpreterPoolExecutor # type: ignore[attr-defined]
66
+ except ImportError as exc:
67
+ raise UnsupportedBackendError(
68
+ "InterpreterPoolExecutor is available only on Python 3.14+."
69
+ ) from exc
70
+
71
+ return InterpreterPoolExecutor
72
+
73
+ # Defensive fallback for static analyzers.
74
+ raise UnsupportedBackendError(f"Unsupported backend: {backend!r}")
75
+
76
+
77
+ def build_executor(
78
+ *,
79
+ backend: ExecutorBackend | str,
80
+ max_workers: int,
81
+ name_prefix: str,
82
+ executor_seq: int,
83
+ executor_kwargs: dict[str, Any],
84
+ ) -> Executor:
85
+ """Build an executor instance for the selected backend.
86
+
87
+ `thread_name_prefix` is valid for ThreadPoolExecutor and InterpreterPoolExecutor,
88
+ but not for ProcessPoolExecutor on Python 3.11.
89
+ """
90
+ executor_cls = resolve_executor_cls(backend)
91
+ kwargs = dict(executor_kwargs)
92
+
93
+ normalized = normalize_backend(backend)
94
+
95
+ if normalized in {ExecutorBackend.THREAD, ExecutorBackend.INTERPRETER}:
96
+ kwargs.setdefault("thread_name_prefix", f"{name_prefix}-{executor_seq}")
97
+
98
+ return executor_cls(max_workers=max_workers, **kwargs)
@@ -0,0 +1,18 @@
1
+ class LeasePoolError(Exception):
2
+ """Base exception for leasepool."""
3
+
4
+
5
+ class LeasePoolNotStartedError(LeasePoolError):
6
+ """Raised when the manager has not been started."""
7
+
8
+
9
+ class LeaseUnavailableError(LeasePoolError):
10
+ """Raised when no executor can be acquired."""
11
+
12
+
13
+ class LeaseExpiredError(LeasePoolError):
14
+ """Raised when a lease has expired or has been revoked."""
15
+
16
+
17
+ class UnsupportedBackendError(LeasePoolError):
18
+ """Raised when the selected executor backend is unavailable."""