zstdlib 0.8.1__tar.gz → 0.10.0__tar.gz
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.
- {zstdlib-0.8.1 → zstdlib-0.10.0}/PKG-INFO +1 -1
- zstdlib-0.10.0/tests/test_exc_capture.py +70 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib/__init__.py +4 -1
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib/ansi.py +11 -6
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib/enum.py +4 -2
- zstdlib-0.10.0/zstdlib/exc_capture.py +69 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib/io.py +2 -2
- zstdlib-0.10.0/zstdlib/macos.py +252 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib.egg-info/PKG-INFO +1 -1
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib.egg-info/SOURCES.txt +2 -0
- zstdlib-0.8.1/zstdlib/macos.py +0 -198
- {zstdlib-0.8.1 → zstdlib-0.10.0}/LICENSE +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/README.md +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/pyproject.toml +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/setup.cfg +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/tests/__init__.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/tests/log/__init__.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/tests/log/base.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/tests/log/test_cute.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/tests/log/test_trace.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/tests/test_ansi.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/tests/test_enum.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/tests/test_frozen.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/tests/test_io.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/tests/test_lazy.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/tests/test_not_set.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/tests/test_singleton.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib/frozen.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib/lazy.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib/log/__init__.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib/log/cute.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib/log/trace.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib/not_set.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib/py.typed +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib/signal_cm.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib/singleton.py +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib.egg-info/dependency_links.txt +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib.egg-info/requires.txt +0 -0
- {zstdlib-0.8.1 → zstdlib-0.10.0}/zstdlib.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# pylint: disable=missing-module-docstring,missing-class-docstring,missing-function-docstring
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
from zstdlib import ExcCapture, ExcDelay
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestLazy(unittest.TestCase):
|
|
8
|
+
|
|
9
|
+
def test_capture(self) -> None:
|
|
10
|
+
# Capture nothing
|
|
11
|
+
rt = ExcCapture(RuntimeError)
|
|
12
|
+
with rt.capture():
|
|
13
|
+
pass
|
|
14
|
+
self.assertEqual(len(rt.errors), 0)
|
|
15
|
+
# Capture only Runtime
|
|
16
|
+
with rt.capture():
|
|
17
|
+
raise RuntimeError()
|
|
18
|
+
self.assertEqual(len(rt.errors), 1)
|
|
19
|
+
with self.assertRaises(ValueError), rt.capture():
|
|
20
|
+
raise ValueError()
|
|
21
|
+
self.assertEqual(len(rt.errors), 1)
|
|
22
|
+
# capture's exc_t
|
|
23
|
+
rt = ExcCapture(RuntimeError)
|
|
24
|
+
with self.assertRaises(RuntimeError), rt.capture(ValueError):
|
|
25
|
+
raise RuntimeError()
|
|
26
|
+
self.assertEqual(len(rt.errors), 0)
|
|
27
|
+
with rt.capture(ValueError):
|
|
28
|
+
raise ValueError()
|
|
29
|
+
self.assertEqual(len(rt.errors), 1)
|
|
30
|
+
del rt
|
|
31
|
+
# exc_t = None
|
|
32
|
+
non = ExcCapture()
|
|
33
|
+
with self.assertRaises(RuntimeError), non.capture(ValueError):
|
|
34
|
+
raise RuntimeError()
|
|
35
|
+
self.assertEqual(len(non.errors), 0)
|
|
36
|
+
with self.assertRaises(ValueError), non.capture():
|
|
37
|
+
pass
|
|
38
|
+
self.assertEqual(len(non.errors), 0)
|
|
39
|
+
with non.capture(RuntimeError):
|
|
40
|
+
raise RuntimeError()
|
|
41
|
+
self.assertEqual(len(non.errors), 1)
|
|
42
|
+
|
|
43
|
+
def test_delay(self) -> None:
|
|
44
|
+
# No raise
|
|
45
|
+
with ExcDelay("msg", RuntimeError) as capture, capture():
|
|
46
|
+
pass
|
|
47
|
+
# Raise
|
|
48
|
+
ed = ExcDelay("msg", RuntimeError)
|
|
49
|
+
try:
|
|
50
|
+
with ed as capture:
|
|
51
|
+
with capture():
|
|
52
|
+
raise RuntimeError()
|
|
53
|
+
with capture():
|
|
54
|
+
raise RuntimeError()
|
|
55
|
+
self.assertTrue(False)
|
|
56
|
+
except BaseExceptionGroup as grp:
|
|
57
|
+
self.assertIsInstance(grp, ExceptionGroup) # Test auto-conversion
|
|
58
|
+
self.assertEqual(len(grp.exceptions), 2)
|
|
59
|
+
self.assertEqual(len(ed.errors), 2)
|
|
60
|
+
# No block error
|
|
61
|
+
with self.assertRaises(ValueError), ExcDelay("msg", RuntimeError) as capture, capture():
|
|
62
|
+
raise ValueError()
|
|
63
|
+
with self.assertRaises(ValueError), ExcDelay("msg", RuntimeError) as capture:
|
|
64
|
+
with capture():
|
|
65
|
+
raise RuntimeError()
|
|
66
|
+
raise ValueError()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
unittest.main()
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.10.0"
|
|
2
2
|
|
|
3
|
+
from .exc_capture import ExcCapture, ExcDelay
|
|
3
4
|
from .not_set import NotSetType, NotSet
|
|
4
5
|
from .frozen import Freezable, frozen
|
|
5
6
|
from .singleton import Singleton
|
|
@@ -9,6 +10,8 @@ from . import macos, lazy, log, io
|
|
|
9
10
|
|
|
10
11
|
__all__ = (
|
|
11
12
|
"Enum",
|
|
13
|
+
"ExcCapture",
|
|
14
|
+
"ExcDelay",
|
|
12
15
|
"Freezable",
|
|
13
16
|
"NotSet",
|
|
14
17
|
"NotSetType",
|
|
@@ -117,7 +117,8 @@ class Color(metaclass=_ColorMeta):
|
|
|
117
117
|
self.code = RawColor._parse_color(fmt) if isinstance(fmt, str) else fmt.code
|
|
118
118
|
|
|
119
119
|
def __call__(self, string: str) -> str:
|
|
120
|
-
"""
|
|
120
|
+
"""
|
|
121
|
+
Args:
|
|
121
122
|
string: The string to color
|
|
122
123
|
|
|
123
124
|
Returns:
|
|
@@ -126,7 +127,8 @@ class Color(metaclass=_ColorMeta):
|
|
|
126
127
|
return f"{self.code}{string}{self.RESET}"
|
|
127
128
|
|
|
128
129
|
def __add__(self, other: Self) -> Self:
|
|
129
|
-
"""
|
|
130
|
+
"""
|
|
131
|
+
Args:
|
|
130
132
|
other: The other string to color
|
|
131
133
|
|
|
132
134
|
Returns:
|
|
@@ -137,7 +139,8 @@ class Color(metaclass=_ColorMeta):
|
|
|
137
139
|
return type(self)(code=f"{self.code[:-1]};{other.code[len(_PREFIX):]}")
|
|
138
140
|
|
|
139
141
|
def __eq__(self, other: object) -> bool:
|
|
140
|
-
"""
|
|
142
|
+
"""
|
|
143
|
+
Args:
|
|
141
144
|
other: The color to compare against
|
|
142
145
|
|
|
143
146
|
Returns:
|
|
@@ -156,7 +159,8 @@ class Color(metaclass=_ColorMeta):
|
|
|
156
159
|
def factory(
|
|
157
160
|
cls, *, foreground: RawColor | None = None, background: RawColor | None = None, **modifiers: bool
|
|
158
161
|
) -> Self:
|
|
159
|
-
"""
|
|
162
|
+
"""
|
|
163
|
+
Args:
|
|
160
164
|
foreground: The foreground color
|
|
161
165
|
background: The background color
|
|
162
166
|
**modifiers: Color modifiers to apply
|
|
@@ -170,10 +174,11 @@ class Color(metaclass=_ColorMeta):
|
|
|
170
174
|
def _generate_code(
|
|
171
175
|
foreground: RawColor | None, background: RawColor | None, modifiers: dict[str, bool]
|
|
172
176
|
) -> str:
|
|
173
|
-
"""
|
|
177
|
+
"""
|
|
178
|
+
Args:
|
|
174
179
|
foreground: The foreground color
|
|
175
180
|
background: The background color
|
|
176
|
-
|
|
181
|
+
modifiers: Color modifiers to apply
|
|
177
182
|
|
|
178
183
|
Returns:
|
|
179
184
|
The Ansi given the chosen color and various modifiers
|
|
@@ -136,7 +136,8 @@ class Enum(metaclass=EnumType, empty_ok=True):
|
|
|
136
136
|
|
|
137
137
|
|
|
138
138
|
def entries(enum: EnumType) -> dict[str, tuple[type | None, Any]]:
|
|
139
|
-
"""
|
|
139
|
+
"""
|
|
140
|
+
Args:
|
|
140
141
|
enum: A class whose metaclass is EnumType
|
|
141
142
|
|
|
142
143
|
Returns:
|
|
@@ -149,7 +150,8 @@ def entries(enum: EnumType) -> dict[str, tuple[type | None, Any]]:
|
|
|
149
150
|
|
|
150
151
|
|
|
151
152
|
def values(enum: Any) -> tuple[Any, ...]:
|
|
152
|
-
"""
|
|
153
|
+
"""
|
|
154
|
+
Args:
|
|
153
155
|
enum: A class whose metaclass is EnumType
|
|
154
156
|
|
|
155
157
|
Returns:
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
__all__ = ("ExcCapture", "ExcDelay")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
type EXC_T = type[BaseException] | tuple[type[BaseException]]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CaptureFType(Protocol):
|
|
12
|
+
"""The type of the capture function (used for type checking)"""
|
|
13
|
+
|
|
14
|
+
def __call__(self, exc_t: EXC_T | None = None) -> AbstractContextManager[None]: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ExcCapture:
|
|
18
|
+
"""A class that exposes a context manager which captures and suppresses any raised exception of a given type / tuple of types"""
|
|
19
|
+
|
|
20
|
+
__slots__ = ("errors", "exc_t")
|
|
21
|
+
|
|
22
|
+
def __init__(self, exc_t: EXC_T | None = None) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Args:
|
|
25
|
+
msg: The exception group message
|
|
26
|
+
exc_t: The exception type the capture function should capture, if None it must be passed to the capture function directly
|
|
27
|
+
"""
|
|
28
|
+
self.exc_t: EXC_T | None = exc_t
|
|
29
|
+
self.errors: list[BaseException] = []
|
|
30
|
+
|
|
31
|
+
@contextmanager
|
|
32
|
+
def capture(self, exc_t: EXC_T | None = None) -> Iterator[None]:
|
|
33
|
+
"""Capture and suppress any exception if type/tuple of types exc_t (self.exc_t if None)"""
|
|
34
|
+
exc_t = self.exc_t if exc_t is None else exc_t
|
|
35
|
+
if exc_t is None:
|
|
36
|
+
raise ValueError("No exception type provided here nor during construction")
|
|
37
|
+
try:
|
|
38
|
+
yield
|
|
39
|
+
except exc_t as e:
|
|
40
|
+
self.errors.append(e)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ExcDelay(ExcCapture):
|
|
44
|
+
"""A context manager subclass of ExcCapture that raises on exit if errors are captured"""
|
|
45
|
+
|
|
46
|
+
__slots__ = ("_msg",)
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
msg: str,
|
|
51
|
+
exc_t: type[BaseException] | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Args:
|
|
55
|
+
exc_t: The exception type the capture function should capture, if None it must be passed to the capture function directly
|
|
56
|
+
msg: The exception group message
|
|
57
|
+
"""
|
|
58
|
+
super().__init__(exc_t)
|
|
59
|
+
self._msg = msg
|
|
60
|
+
|
|
61
|
+
def __enter__(self) -> CaptureFType:
|
|
62
|
+
"""Return the capture function to use"""
|
|
63
|
+
return self.capture
|
|
64
|
+
|
|
65
|
+
def __exit__(self, type_: type[BaseException] | None, *_: object) -> None:
|
|
66
|
+
"""Raise an ExceptionGroup (or BaseExceptionGroup depending on exc_t) if self.errors"""
|
|
67
|
+
if type_ is None and self.errors:
|
|
68
|
+
# Auto-converts to ExceptionGroup if all items are Exceptions
|
|
69
|
+
raise BaseExceptionGroup(self._msg, self.errors)
|
|
@@ -41,8 +41,8 @@ class _IOWrapperBase[T: (str, bytes)]:
|
|
|
41
41
|
"""
|
|
42
42
|
|
|
43
43
|
__slots__ = ("_buffer", "_eof", "f", "lock")
|
|
44
|
-
_instances: WeakKeyDictionary[IOBase, Self] = WeakKeyDictionary()
|
|
45
|
-
_wr_lock = RLock()
|
|
44
|
+
_instances: WeakKeyDictionary[IOBase, Self] = WeakKeyDictionary() # noqa: RUF012
|
|
45
|
+
_wr_lock: RLock = RLock()
|
|
46
46
|
|
|
47
47
|
def __new__(cls, f: IOBase) -> Self:
|
|
48
48
|
"""Create a new instance of cls for raw, or return an existing instance if one exists"""
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
from typing import NamedTuple, ParamSpec, ClassVar, TypeVar, Self
|
|
2
|
+
from collections.abc import Callable, Iterable
|
|
3
|
+
from subprocess import TimeoutExpired, Popen
|
|
4
|
+
from multiprocessing import Process
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from logging import getLogger
|
|
7
|
+
from functools import wraps
|
|
8
|
+
from threading import Lock
|
|
9
|
+
from signal import Signals
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from os import getpid
|
|
12
|
+
import atexit
|
|
13
|
+
|
|
14
|
+
import osascript
|
|
15
|
+
|
|
16
|
+
__all__ = ("Caffeinate", "osascript")
|
|
17
|
+
|
|
18
|
+
CAFFEINATE = Path("/usr/bin/caffeinate") # This is a hard-coded location on macOS
|
|
19
|
+
KILL_WAIT_SECONDS: int = 60 # How long to .wait after .send_signal
|
|
20
|
+
|
|
21
|
+
P = ParamSpec("P")
|
|
22
|
+
R = TypeVar("R")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Caffeinate:
|
|
26
|
+
"""A small wrapper class around macOS' CLI tool Caffeinate"""
|
|
27
|
+
|
|
28
|
+
_finalizing: ClassVar[bool] = False
|
|
29
|
+
_instances_lock: ClassVar[Lock] = Lock()
|
|
30
|
+
_instances: ClassVar[set[Caffeinate]] = set() # Self makes mypy upset here; maybe b/c of subclassing? :/
|
|
31
|
+
_log = getLogger(f"{__name__}.Caffeinate")
|
|
32
|
+
|
|
33
|
+
__slots__ = ("_cmd", "_data", "_lock", "block", "signal")
|
|
34
|
+
|
|
35
|
+
class _Data(NamedTuple):
|
|
36
|
+
owner: int
|
|
37
|
+
proc: Popen
|
|
38
|
+
|
|
39
|
+
class SignalZeroError(ValueError):
|
|
40
|
+
"""Raised when calling __enter__ or __exit__ on a Caffeinate instance with .signal == 0"""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
msg = "The context manager is meant for managing the subprocess' lifetime, which is disable by .signal == 0, consider using .start() / .stop() instead."
|
|
44
|
+
super().__init__(msg)
|
|
45
|
+
|
|
46
|
+
# pylint: disable=too-many-arguments
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
# Passed through to CLI tool
|
|
50
|
+
cmd: Iterable[Path | str | None] | None = None,
|
|
51
|
+
*,
|
|
52
|
+
display: bool = False,
|
|
53
|
+
idle: bool = False,
|
|
54
|
+
disk: bool = False,
|
|
55
|
+
system: bool = False,
|
|
56
|
+
user: bool = False,
|
|
57
|
+
timeout: int | None = None,
|
|
58
|
+
wait: int | Popen | Process | None = None,
|
|
59
|
+
# For Caeffinate class itself
|
|
60
|
+
signal: Signals | int = Signals.SIGTERM,
|
|
61
|
+
block: bool = False,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Invokes the macOS Caffeinate CLI tool to prevent sleeping.
|
|
64
|
+
Arguments are passed through to Caffeinate, descriptions are taken from the CLI tool; if they disagree with those found in `man caffeinate`, theirs are more likely to be correct.
|
|
65
|
+
No Caffeinate subprocess is started until .start() is called (or .__enter__ calls it)
|
|
66
|
+
All created children subprocesses will be cleaned up during atexit unless they have .signal == 0
|
|
67
|
+
After .stop()'d processes can .start() once more if desired
|
|
68
|
+
Warning: If cmd is passed, re-invoking __enter__ after an __exit__ will cause Caffeinate to run again and thus the command will run again
|
|
69
|
+
Warning: Trying to use a Caffeinate with signal == 0 will raise a SignalZeroError as it is likely undesired to try to manage the lifetime of a process excluded from lifetime management
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
cmd: An optional CLI command (and arguments) to run under the Caffeinate process
|
|
73
|
+
display: If True: Create an assertion to prevent the display from sleeping.
|
|
74
|
+
idle: If True: Create an assertion to prevent the system from idle sleeping.
|
|
75
|
+
disk: If True: Create an assertion to prevent the disk from idle sleeping.
|
|
76
|
+
system: If True: Create an assertion to prevent the system from sleeping. This assertion is valid only when system is running on AC power.
|
|
77
|
+
user: If True: Create an assertion to declare that user is active. If the display is off, this option turns the display on and prevents the display from going into idle sleep. If a timeout is not specified with '-t' option, then this assertion is taken with a default of 5 second timeout.
|
|
78
|
+
timeout: Specifies the timeout value in seconds for which this assertion has to be valid. The assertion is dropped after the specified timeout. Timeout value is not used by the Caeffinate CLI when cmd is passed. Note: this timeout is passed to the Caffeinate CLI command, it is not enforced by python
|
|
79
|
+
wait: Waits for the process with the specified pid to exit. Once the the process exits, the assertion is also released. This option is ignored by the Caeffinate CLI when cmd is passed.
|
|
80
|
+
This option conflicts with cmd.
|
|
81
|
+
signal: The signal used to kill the child process (if cmd likely the process Caffeinate execs into). If 0, do not kill it
|
|
82
|
+
block: If True: Block until cmd has finished executing before returning from __enter__
|
|
83
|
+
"""
|
|
84
|
+
if not CAFFEINATE.exists():
|
|
85
|
+
raise NotImplementedError(f"This class is only available on macOS via: {CAFFEINATE}")
|
|
86
|
+
if (
|
|
87
|
+
isinstance(timeout, float)
|
|
88
|
+
or not isinstance(timeout, int | None)
|
|
89
|
+
or (timeout is not None and timeout < 1)
|
|
90
|
+
):
|
|
91
|
+
raise TypeError("timeout must be a positive integer or None")
|
|
92
|
+
if not isinstance(wait, int | Popen | Process | None):
|
|
93
|
+
raise TypeError(f"wait is of the wrong type: {type(wait)}")
|
|
94
|
+
if not isinstance(signal, int | Signals):
|
|
95
|
+
msg = f"signal is of the wrong type: {type(signal)}{"; did you mean signal=0?" if signal is None else ""}"
|
|
96
|
+
raise TypeError(msg)
|
|
97
|
+
if isinstance(cmd, str | Path):
|
|
98
|
+
raise TypeError(f"cmd is of the wrong type: {type(cmd)}")
|
|
99
|
+
if cmd is None:
|
|
100
|
+
if block:
|
|
101
|
+
raise ValueError("block requires cmd")
|
|
102
|
+
cmd = []
|
|
103
|
+
else:
|
|
104
|
+
cmd = list(cmd)
|
|
105
|
+
if not cmd:
|
|
106
|
+
raise ValueError("Empty cmd iterable; did you mean to use cmd=None?")
|
|
107
|
+
self._lock = Lock()
|
|
108
|
+
self._data: Caffeinate._Data | None = None # Have to hardcode "Caffeinate" for mypy :/
|
|
109
|
+
wait = wait.pid if isinstance(wait, Popen | Process) else wait
|
|
110
|
+
flags = [
|
|
111
|
+
"-d" if display else None,
|
|
112
|
+
"-i" if idle else None,
|
|
113
|
+
"-m" if disk else None,
|
|
114
|
+
"-s" if system else None,
|
|
115
|
+
"-u" if user else None,
|
|
116
|
+
None if timeout is None else f"-t {timeout}",
|
|
117
|
+
None if wait is None else f"-w {wait}",
|
|
118
|
+
]
|
|
119
|
+
self._cmd = list(map(str, [CAFFEINATE] + [k for i in flags if i for k in i.split(" ")] + cmd))
|
|
120
|
+
self.signal = signal
|
|
121
|
+
self.block = block
|
|
122
|
+
|
|
123
|
+
def start(self) -> None:
|
|
124
|
+
"""Start Caffeinate-ing; nop if already started"""
|
|
125
|
+
with self._lock:
|
|
126
|
+
if self._data is not None:
|
|
127
|
+
return
|
|
128
|
+
pid = getpid()
|
|
129
|
+
with self._instances_lock:
|
|
130
|
+
if self._finalizing:
|
|
131
|
+
raise PythonFinalizationError("No more new Caeffinate subprocesses are allowed")
|
|
132
|
+
self._data = self._Data(pid, Popen(self._cmd)) # pylint: disable=consider-using-with
|
|
133
|
+
self._instances.add(self)
|
|
134
|
+
self._log.debug("Created Popen(%s), pid=%s", self._cmd, self._data.proc.pid)
|
|
135
|
+
if self.block:
|
|
136
|
+
self._data.proc.wait()
|
|
137
|
+
|
|
138
|
+
def stop(self) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Terminate the Caffeinate subprocess via self.signal; nop if not .stop()'d
|
|
141
|
+
.wait() will timeout after KILL_WAIT_SECONDS
|
|
142
|
+
"""
|
|
143
|
+
self._stop(self.signal)
|
|
144
|
+
|
|
145
|
+
def __enter__(self) -> Self:
|
|
146
|
+
"""Call .start(), if .signal == 0, raise a SignalZeroError, returns self"""
|
|
147
|
+
if not self.signal:
|
|
148
|
+
raise self.SignalZeroError()
|
|
149
|
+
self.start()
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def __exit__(self, *_: object) -> None:
|
|
153
|
+
"""Calls .stop(); if called with .signal == 0, raise an SignalZeroError"""
|
|
154
|
+
signal = self.signal # So user can't change it on us from another thread!
|
|
155
|
+
if not signal: # Before atexit removal (so users change .signal later if desired)
|
|
156
|
+
raise self.SignalZeroError()
|
|
157
|
+
self._stop(signal)
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def decorate(
|
|
161
|
+
cls, cmd: list[Path | str | None] | None = None, **init_kwargs: object
|
|
162
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
163
|
+
"""A decorator to wrap a function with a Caffeinate instance
|
|
164
|
+
Example:
|
|
165
|
+
@decorate(idle=True)
|
|
166
|
+
def foo():
|
|
167
|
+
"""
|
|
168
|
+
if cmd and callable(cmd[0]):
|
|
169
|
+
raise ValueError(f"Invalid usage of .decorate; see help({cls.__name__}.decorate)")
|
|
170
|
+
|
|
171
|
+
def outer(f: Callable[P, R]) -> Callable[P, R]:
|
|
172
|
+
@wraps(f)
|
|
173
|
+
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
174
|
+
with cls(cmd, **init_kwargs): # type: ignore[arg-type]
|
|
175
|
+
return f(*args, **kwargs)
|
|
176
|
+
|
|
177
|
+
return inner
|
|
178
|
+
|
|
179
|
+
return outer
|
|
180
|
+
|
|
181
|
+
def __repr__(self) -> str:
|
|
182
|
+
pid = "NOT RUNNING" if self._data is None else self._data.proc.pid
|
|
183
|
+
return f"<Caffeinate: PID={pid}, flags={self._cmd[1:]}, signal={self.signal}, block={self.block}>"
|
|
184
|
+
|
|
185
|
+
# Rarely used methods
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def kill_children(cls) -> None:
|
|
189
|
+
"""
|
|
190
|
+
Clean up child Caffeinate subprocesses started by this class using their requested signals
|
|
191
|
+
If any child has .signal == 0, that process is not killed
|
|
192
|
+
"""
|
|
193
|
+
with cls._instances_lock:
|
|
194
|
+
remove = list(cls._instances)
|
|
195
|
+
cls._log.debug("Caffeinate: Cleaning up child subprocesses (if any)")
|
|
196
|
+
for i in remove:
|
|
197
|
+
with suppress(cls.SignalZeroError):
|
|
198
|
+
i.__exit__() # modifies cls._instances
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def forget_signal_zero_children(cls) -> None:
|
|
202
|
+
"""
|
|
203
|
+
This method exists to allow long running processes that
|
|
204
|
+
endlessly spawns children with .signal == 0 to avoid memory leaks
|
|
205
|
+
Normally these instances are stored until atexit in case .signal changes before then
|
|
206
|
+
This methods forgets about all children with .signal == 0 (frees up the memory used storing them)
|
|
207
|
+
This cannot be undone; avoid callng this unless you need the memory
|
|
208
|
+
Warning: This does not remove any other references or destroy the instance
|
|
209
|
+
"""
|
|
210
|
+
with cls._instances_lock:
|
|
211
|
+
cls._instances -= {i for i in cls._instances if not i.signal}
|
|
212
|
+
|
|
213
|
+
# Private methods
|
|
214
|
+
|
|
215
|
+
def _stop(self, signal: int | Signals) -> None:
|
|
216
|
+
"""Workhose of .stop()"""
|
|
217
|
+
with self._lock:
|
|
218
|
+
if self._data is None:
|
|
219
|
+
return
|
|
220
|
+
# After this point, ensure no second removal attempt happens at atexit
|
|
221
|
+
with self._instances_lock:
|
|
222
|
+
self._instances.discard(self)
|
|
223
|
+
if getpid() != self._data.owner: # We don't care about these
|
|
224
|
+
return
|
|
225
|
+
if self._data.proc.poll() is None: # No need to kill it if it's already dead
|
|
226
|
+
self._log.debug("Cleaning up process %s with signal %s", self._data.proc.pid, signal)
|
|
227
|
+
self._data.proc.send_signal(signal)
|
|
228
|
+
try:
|
|
229
|
+
self._data.proc.wait(timeout=KILL_WAIT_SECONDS)
|
|
230
|
+
except TimeoutExpired:
|
|
231
|
+
# Technically, Caffeinate itself forks and its child remains Caffeinate while the
|
|
232
|
+
# parent becomes the cmd process, so say subprocess instead of Caffeinate processes
|
|
233
|
+
self._log.error(
|
|
234
|
+
"subprocess (PID=%s) didn't exit on .send_signal(%s)", self._data.proc.pid, signal
|
|
235
|
+
)
|
|
236
|
+
raise
|
|
237
|
+
self._data = None
|
|
238
|
+
|
|
239
|
+
@classmethod
|
|
240
|
+
def _atexit(cls) -> None:
|
|
241
|
+
"""
|
|
242
|
+
Never invoke this manually; it is called by atexit
|
|
243
|
+
Disallows instance constructon then invokes kill_chilren()
|
|
244
|
+
"""
|
|
245
|
+
with cls._instances_lock:
|
|
246
|
+
cls._finalizing = True
|
|
247
|
+
cls._log.debug("Disallowing future child Caffeinate subprocesses")
|
|
248
|
+
cls.kill_children()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
assert 0 not in Signals, "0 not expected in signal.Signals as it's a nop"
|
|
252
|
+
atexit.register(Caffeinate._atexit) # pylint: disable=protected-access
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
tests/__init__.py
|
|
5
5
|
tests/test_ansi.py
|
|
6
6
|
tests/test_enum.py
|
|
7
|
+
tests/test_exc_capture.py
|
|
7
8
|
tests/test_frozen.py
|
|
8
9
|
tests/test_io.py
|
|
9
10
|
tests/test_lazy.py
|
|
@@ -16,6 +17,7 @@ tests/log/test_trace.py
|
|
|
16
17
|
zstdlib/__init__.py
|
|
17
18
|
zstdlib/ansi.py
|
|
18
19
|
zstdlib/enum.py
|
|
20
|
+
zstdlib/exc_capture.py
|
|
19
21
|
zstdlib/frozen.py
|
|
20
22
|
zstdlib/io.py
|
|
21
23
|
zstdlib/lazy.py
|
zstdlib-0.8.1/zstdlib/macos.py
DELETED
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
from typing import NamedTuple, ParamSpec, ClassVar, TypeVar, Self
|
|
2
|
-
from subprocess import TimeoutExpired, Popen
|
|
3
|
-
from collections.abc import Callable
|
|
4
|
-
from multiprocessing import Process
|
|
5
|
-
from logging import getLogger
|
|
6
|
-
from functools import wraps
|
|
7
|
-
from threading import Lock
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from os import getpid
|
|
10
|
-
import atexit
|
|
11
|
-
|
|
12
|
-
import osascript
|
|
13
|
-
|
|
14
|
-
CAFFEINATE = "/usr/bin/caffeinate" # This is a hard-coded location on macOS
|
|
15
|
-
P = ParamSpec("P")
|
|
16
|
-
R = TypeVar("R")
|
|
17
|
-
_log = getLogger(__name__)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
__all__ = ("Caffeinate", "osascript")
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class Caffeinate:
|
|
24
|
-
"""A small wrapper class around macOS' CLI tool Caffeinate"""
|
|
25
|
-
|
|
26
|
-
_armed_lock: ClassVar[Lock] = Lock()
|
|
27
|
-
_armed: ClassVar[set[Self]] = set()
|
|
28
|
-
__slots__ = ("_cmd", "_data", "_lock", "_wait")
|
|
29
|
-
|
|
30
|
-
class _Data(NamedTuple):
|
|
31
|
-
owner: int
|
|
32
|
-
proc: Popen
|
|
33
|
-
|
|
34
|
-
# pylint: disable=too-many-arguments
|
|
35
|
-
def __init__(
|
|
36
|
-
self,
|
|
37
|
-
cmd: list[Path | str | None] | None = None,
|
|
38
|
-
*,
|
|
39
|
-
wait_for_cmd: bool = False,
|
|
40
|
-
display: bool = False,
|
|
41
|
-
idle: bool = False,
|
|
42
|
-
disk: bool = False,
|
|
43
|
-
system: bool = False,
|
|
44
|
-
user: bool = False,
|
|
45
|
-
timeout: int | None = None,
|
|
46
|
-
wait: int | Popen | Process | None = None,
|
|
47
|
-
) -> None:
|
|
48
|
-
"""Invokes the macOS Caffeinate CLI tool to prevent sleeping.
|
|
49
|
-
Arguments are passed through to Caffeinate, descriptions are taken from the CLI tool; if they disagree with those found in `man caffeinate`, theirs are more likely to be correct.
|
|
50
|
-
Warning: If cmd is passed, re-invoking __enter__ after an __exit__ will cause Caffeinate to run again and thus the command will run again
|
|
51
|
-
|
|
52
|
-
Args:
|
|
53
|
-
cmd: An optional CLI command (and arguments) to run under the Caffeinate process
|
|
54
|
-
wait_for_cmd: If True: Wait until cmd has finished executing before returning from __enter__
|
|
55
|
-
display: If True: Create an assertion to prevent the display from sleeping.
|
|
56
|
-
idle: If True: Create an assertion to prevent the system from idle sleeping.
|
|
57
|
-
disk: If True: Create an assertion to prevent the disk from idle sleeping.
|
|
58
|
-
system: If True: Create an assertion to prevent the system from sleeping. This assertion is valid only when system is running on AC power.
|
|
59
|
-
user: If True: Create an assertion to declare that user is active. If the display is off, this option turns the display on and prevents the display from going into idle sleep. If a timeout is not specified with '-t' option, then this assertion is taken with a default of 5 second timeout.
|
|
60
|
-
timeout: Specifies the timeout value in seconds for which this assertion has to be valid. The assertion is dropped after the specified timeout. This option conflicts with cmd. Note: this timeout is passed to the Caffeinate CLI command, it is not enforced by python
|
|
61
|
-
with this command.
|
|
62
|
-
wait: Waits for the process with the specified pid to exit. Once the the process exits, the assertion is also released. This option conflicts with cmd.
|
|
63
|
-
"""
|
|
64
|
-
if cmd is None:
|
|
65
|
-
if wait_for_cmd:
|
|
66
|
-
raise ValueError("wait_for_cmd requires cmd not be None")
|
|
67
|
-
cmd = []
|
|
68
|
-
elif not cmd:
|
|
69
|
-
raise ValueError("Empty cmd; did you mean to use None?")
|
|
70
|
-
elif timeout is not None:
|
|
71
|
-
raise ValueError("timeout may not be used with cmd")
|
|
72
|
-
elif wait is not None:
|
|
73
|
-
raise ValueError("wait may not be used with cmd")
|
|
74
|
-
else:
|
|
75
|
-
cmd = list(cmd)
|
|
76
|
-
self._lock = Lock()
|
|
77
|
-
self._data: Caffeinate._Data | None = None # Have to hardcode "Caffeinate" for mypy :/
|
|
78
|
-
if isinstance(timeout, float):
|
|
79
|
-
raise TypeError("Float timeout not supported")
|
|
80
|
-
if timeout is not None and timeout < 1:
|
|
81
|
-
raise ValueError("Timeout should be a positive integer")
|
|
82
|
-
wait = wait.pid if isinstance(wait, Popen | Process) else wait
|
|
83
|
-
flags = [
|
|
84
|
-
"-d" if display else None,
|
|
85
|
-
"-i" if idle else None,
|
|
86
|
-
"-m" if disk else None,
|
|
87
|
-
"-s" if system else None,
|
|
88
|
-
"-u" if user else None,
|
|
89
|
-
None if timeout is None else f"-t {timeout}",
|
|
90
|
-
None if wait is None else f"-w {wait}",
|
|
91
|
-
]
|
|
92
|
-
self._cmd = list(map(str, [CAFFEINATE] + [k for i in flags if i for k in i.split(" ")] + cmd))
|
|
93
|
-
self._wait = wait_for_cmd
|
|
94
|
-
|
|
95
|
-
def __enter__(self) -> Self:
|
|
96
|
-
"""Start Caffeinate-ing"""
|
|
97
|
-
with self._lock:
|
|
98
|
-
if self._data is not None:
|
|
99
|
-
raise RuntimeError("This context manager has already been entered")
|
|
100
|
-
self._data = self._Data(getpid(), Popen(self._cmd))
|
|
101
|
-
_log.debug("Created Popen(%s), pid=%s", self._cmd, self._data.proc.pid)
|
|
102
|
-
if self._wait:
|
|
103
|
-
self._data.proc.wait()
|
|
104
|
-
return self
|
|
105
|
-
|
|
106
|
-
def __exit__(self, *_: object) -> None:
|
|
107
|
-
with self._lock:
|
|
108
|
-
if self._data is None:
|
|
109
|
-
raise RuntimeError("Context managernever __enter__'d or already __exit__'d")
|
|
110
|
-
if getpid() != self._data.owner:
|
|
111
|
-
return
|
|
112
|
-
_log.debug("Reaping Caffeinate process: %s", self._data.proc.pid)
|
|
113
|
-
self._data.proc.terminate()
|
|
114
|
-
try:
|
|
115
|
-
self._data.proc.wait(timeout=1)
|
|
116
|
-
except TimeoutExpired:
|
|
117
|
-
_log.error("Caffeinate process didn't exit on .terminate()")
|
|
118
|
-
self._data.proc.kill()
|
|
119
|
-
self._data = None
|
|
120
|
-
|
|
121
|
-
@classmethod
|
|
122
|
-
def decorate(
|
|
123
|
-
cls, cmd: list[Path | str | None] | None = None, **caffeinate_kwargs: object
|
|
124
|
-
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
125
|
-
"""A decorator to wrap a function with a Caffeinate instance
|
|
126
|
-
Example:
|
|
127
|
-
@decorate(idle=True)
|
|
128
|
-
def foo():
|
|
129
|
-
"""
|
|
130
|
-
if cmd and callable(cmd[0]):
|
|
131
|
-
raise ValueError(f"Invalid usage of .decorate; see help({cls.__name__}.decorate)")
|
|
132
|
-
|
|
133
|
-
def outer(f: Callable[P, R]) -> Callable[P, R]:
|
|
134
|
-
@wraps(f)
|
|
135
|
-
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
136
|
-
with cls(cmd, **caffeinate_kwargs): # type: ignore[arg-type]
|
|
137
|
-
return f(*args, **kwargs)
|
|
138
|
-
|
|
139
|
-
return inner
|
|
140
|
-
|
|
141
|
-
return outer
|
|
142
|
-
|
|
143
|
-
@classmethod
|
|
144
|
-
def new(
|
|
145
|
-
cls,
|
|
146
|
-
cmd: list[Path | str | None] | None = None,
|
|
147
|
-
*,
|
|
148
|
-
survive_exit: bool = False,
|
|
149
|
-
**kwargs: object,
|
|
150
|
-
) -> Self:
|
|
151
|
-
"""Return an __enter__'d Caffeinate, whose __exit__ will run atexit
|
|
152
|
-
cmd and **kwargs are passed to __init__
|
|
153
|
-
|
|
154
|
-
Args:
|
|
155
|
-
cmd: Passed to Caffeinate
|
|
156
|
-
survive_exit: Do not kill this Caffeinate instance when this process exits
|
|
157
|
-
kwargs: Passed to Caffeinate
|
|
158
|
-
"""
|
|
159
|
-
ret = cls(cmd, **kwargs) # type: ignore[arg-type]
|
|
160
|
-
_ = ret.__enter__() # pylint: disable=unnecessary-dunder-call
|
|
161
|
-
if not survive_exit:
|
|
162
|
-
with cls._armed_lock:
|
|
163
|
-
cls._armed.add(ret)
|
|
164
|
-
return ret
|
|
165
|
-
|
|
166
|
-
def survive_exit(self) -> None:
|
|
167
|
-
"""Disable atexit death; only permitted for instances created by .new()"""
|
|
168
|
-
# pylint: disable=protected-access
|
|
169
|
-
with self._armed_lock:
|
|
170
|
-
if self not in self._armed:
|
|
171
|
-
msg = f"This instance was not created by {self.__class__.__name__}.new() or already called .survive_exit()"
|
|
172
|
-
raise ValueError(msg)
|
|
173
|
-
self._armed.remove(self)
|
|
174
|
-
assert self._data is not None
|
|
175
|
-
_log.debug("Disabled cleanup of caffeinate subprocess PID=%s", self._data.proc.pid)
|
|
176
|
-
|
|
177
|
-
@classmethod
|
|
178
|
-
def _atexit(cls) -> None:
|
|
179
|
-
"""Clean up child Caffeinate subprocesses started by this class"""
|
|
180
|
-
# pylint: disable=protected-access
|
|
181
|
-
pid = getpid()
|
|
182
|
-
_log.debug("Cleaning up all owned Caffeinate subprocesses started this process (PID=%s)", pid)
|
|
183
|
-
with cls._armed_lock:
|
|
184
|
-
for i in cls._armed:
|
|
185
|
-
if i._data is None:
|
|
186
|
-
_log.error("Unentered Caffeinate process in ._armed; this should never happen")
|
|
187
|
-
continue
|
|
188
|
-
if i._data.owner == pid:
|
|
189
|
-
i.__exit__()
|
|
190
|
-
cls._armed.clear()
|
|
191
|
-
|
|
192
|
-
def __repr__(self) -> str:
|
|
193
|
-
if self._data is None:
|
|
194
|
-
return f"<Caffeinate: NOT RUNNING, flags={self._cmd[1:]}>"
|
|
195
|
-
return f"<Caffeinate: PID={self._data.proc.pid}, flags={self._cmd[1:]}>"
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
atexit.register(Caffeinate._atexit) # pylint: disable=protected-access
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|