omlish 0.0.0.dev225__py3-none-any.whl → 0.0.0.dev227__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,202 @@
1
+ # ruff: noqa: UP006 UP007
2
+ """
3
+ TODO:
4
+ - Event (/ Predicate)
5
+ """
6
+ import abc
7
+ import time
8
+ import typing as ta
9
+
10
+
11
+ TimeoutLike = ta.Union['Timeout', 'Timeout.Default', ta.Iterable['TimeoutLike'], float] # ta.TypeAlias
12
+
13
+
14
+ ##
15
+
16
+
17
+ class Timeout(abc.ABC):
18
+ @property
19
+ @abc.abstractmethod
20
+ def can_expire(self) -> bool:
21
+ """Indicates whether or not this timeout will ever expire."""
22
+
23
+ raise NotImplementedError
24
+
25
+ @abc.abstractmethod
26
+ def expired(self) -> bool:
27
+ """Return whether or not this timeout has expired."""
28
+
29
+ raise NotImplementedError
30
+
31
+ @abc.abstractmethod
32
+ def remaining(self) -> float:
33
+ """Returns the time (in seconds) remaining until the timeout expires. May be negative and/or infinite."""
34
+
35
+ raise NotImplementedError
36
+
37
+ @abc.abstractmethod
38
+ def __call__(self) -> float:
39
+ """Returns the time (in seconds) remaining until the timeout expires, or raises if the timeout has expired."""
40
+
41
+ raise NotImplementedError
42
+
43
+ @abc.abstractmethod
44
+ def or_(self, o: ta.Any) -> ta.Any:
45
+ """Evaluates time remaining via remaining() if this timeout can expire, otherwise returns `o`."""
46
+
47
+ raise NotImplementedError
48
+
49
+ #
50
+
51
+ @classmethod
52
+ def _now(cls) -> float:
53
+ return time.time()
54
+
55
+ #
56
+
57
+ class Default:
58
+ def __new__(cls, *args, **kwargs): # noqa
59
+ raise TypeError
60
+
61
+ class _NOT_SPECIFIED: # noqa
62
+ def __new__(cls, *args, **kwargs): # noqa
63
+ raise TypeError
64
+
65
+ @classmethod
66
+ def of(
67
+ cls,
68
+ obj: ta.Optional[TimeoutLike],
69
+ default: ta.Union[TimeoutLike, ta.Type[_NOT_SPECIFIED]] = _NOT_SPECIFIED,
70
+ ) -> 'Timeout':
71
+ if obj is None:
72
+ return InfiniteTimeout()
73
+
74
+ elif isinstance(obj, Timeout):
75
+ return obj
76
+
77
+ elif isinstance(obj, (float, int)):
78
+ return DeadlineTimeout(cls._now() + obj)
79
+
80
+ elif isinstance(obj, ta.Iterable):
81
+ return CompositeTimeout(*[Timeout.of(c) for c in obj])
82
+
83
+ elif obj is Timeout.Default:
84
+ if default is Timeout._NOT_SPECIFIED or default is Timeout.Default:
85
+ raise RuntimeError('Must specify a default timeout')
86
+
87
+ else:
88
+ return Timeout.of(default) # type: ignore[arg-type]
89
+
90
+ else:
91
+ raise TypeError(obj)
92
+
93
+ @classmethod
94
+ def of_deadline(cls, deadline: float) -> 'DeadlineTimeout':
95
+ return DeadlineTimeout(deadline)
96
+
97
+ @classmethod
98
+ def of_predicate(cls, expired_fn: ta.Callable[[], bool]) -> 'PredicateTimeout':
99
+ return PredicateTimeout(expired_fn)
100
+
101
+
102
+ class DeadlineTimeout(Timeout):
103
+ def __init__(
104
+ self,
105
+ deadline: float,
106
+ exc: ta.Union[ta.Type[BaseException], BaseException] = TimeoutError,
107
+ ) -> None:
108
+ super().__init__()
109
+
110
+ self.deadline = deadline
111
+ self.exc = exc
112
+
113
+ @property
114
+ def can_expire(self) -> bool:
115
+ return True
116
+
117
+ def expired(self) -> bool:
118
+ return not (self.remaining() > 0)
119
+
120
+ def remaining(self) -> float:
121
+ return self.deadline - self._now()
122
+
123
+ def __call__(self) -> float:
124
+ if (rem := self.remaining()) > 0:
125
+ return rem
126
+ raise self.exc
127
+
128
+ def or_(self, o: ta.Any) -> ta.Any:
129
+ return self()
130
+
131
+
132
+ class InfiniteTimeout(Timeout):
133
+ @property
134
+ def can_expire(self) -> bool:
135
+ return False
136
+
137
+ def expired(self) -> bool:
138
+ return False
139
+
140
+ def remaining(self) -> float:
141
+ return float('inf')
142
+
143
+ def __call__(self) -> float:
144
+ return float('inf')
145
+
146
+ def or_(self, o: ta.Any) -> ta.Any:
147
+ return o
148
+
149
+
150
+ class CompositeTimeout(Timeout):
151
+ def __init__(self, *children: Timeout) -> None:
152
+ super().__init__()
153
+
154
+ self.children = children
155
+
156
+ @property
157
+ def can_expire(self) -> bool:
158
+ return any(c.can_expire for c in self.children)
159
+
160
+ def expired(self) -> bool:
161
+ return any(c.expired() for c in self.children)
162
+
163
+ def remaining(self) -> float:
164
+ return min(c.remaining() for c in self.children)
165
+
166
+ def __call__(self) -> float:
167
+ return min(c() for c in self.children)
168
+
169
+ def or_(self, o: ta.Any) -> ta.Any:
170
+ if self.can_expire:
171
+ return self()
172
+ return o
173
+
174
+
175
+ class PredicateTimeout(Timeout):
176
+ def __init__(
177
+ self,
178
+ expired_fn: ta.Callable[[], bool],
179
+ exc: ta.Union[ta.Type[BaseException], BaseException] = TimeoutError,
180
+ ) -> None:
181
+ super().__init__()
182
+
183
+ self.expired_fn = expired_fn
184
+ self.exc = exc
185
+
186
+ @property
187
+ def can_expire(self) -> bool:
188
+ return True
189
+
190
+ def expired(self) -> bool:
191
+ return self.expired_fn()
192
+
193
+ def remaining(self) -> float:
194
+ return float('inf')
195
+
196
+ def __call__(self) -> float:
197
+ if not self.expired_fn():
198
+ return float('inf')
199
+ raise self.exc
200
+
201
+ def or_(self, o: ta.Any) -> ta.Any:
202
+ return self()
omlish/os/fcntl.py ADDED
@@ -0,0 +1,59 @@
1
+ # ruff: noqa: UP006 UP007
2
+ # @omlish-lite
3
+ import dataclasses as dc
4
+ import os
5
+ import struct
6
+ import sys
7
+ import typing as ta
8
+
9
+
10
+ @dc.dataclass(frozen=True)
11
+ class FcntlLockData:
12
+ # cmd = {F_SETLK, F_SETLKW, F_GETLK}
13
+
14
+ type: int # {F_RDLCK, F_WRLCK, F_UNLCK}
15
+ whence: int = os.SEEK_SET
16
+ start: int = 0
17
+ len: int = 0
18
+ pid: int = 0
19
+
20
+ #
21
+
22
+ _STRUCT_PACK_BY_PLATFORM: ta.ClassVar[ta.Mapping[str, ta.Sequence[ta.Tuple[str, str]]]] = {
23
+ 'linux': [
24
+ ('type', 'h'),
25
+ ('whence', 'h'),
26
+ ('start', 'q'),
27
+ ('len', 'q'),
28
+ ('pid', 'i'),
29
+ ],
30
+ 'darwin': [
31
+ ('start', 'q'),
32
+ ('len', 'q'),
33
+ ('pid', 'i'),
34
+ ('type', 'h'),
35
+ ('whence', 'h'),
36
+ ],
37
+ }
38
+
39
+ def pack(self) -> bytes:
40
+ try:
41
+ pack = self._STRUCT_PACK_BY_PLATFORM[sys.platform]
42
+ except KeyError:
43
+ raise OSError from None
44
+
45
+ fmt = ''.join(f for _, f in pack)
46
+ tup = [getattr(self, a) for a, _ in pack]
47
+ return struct.pack(fmt, *tup)
48
+
49
+ @classmethod
50
+ def unpack(cls, data: bytes) -> 'FcntlLockData':
51
+ try:
52
+ pack = cls._STRUCT_PACK_BY_PLATFORM[sys.platform]
53
+ except KeyError:
54
+ raise OSError from None
55
+
56
+ fmt = ''.join(f for _, f in pack)
57
+ tup = struct.unpack(fmt, data)
58
+ kw = {a: v for (a, _), v in zip(pack, tup)}
59
+ return FcntlLockData(**kw)
@@ -14,6 +14,17 @@ from .pidfile import Pidfile
14
14
 
15
15
 
16
16
  class _PidfileManager:
17
+ """
18
+ Manager for controlled inheritance of Pidfiles across forks in the presence of multiple threads. There is of course
19
+ no safe or correct way to mix the use of fork and multiple active threads, and one should never write code which
20
+ does so, but in the Real World one may still find oneself in such a situation outside of their control (such as when
21
+ running under Pycharm's debugger which forces the use of forked multiprocessing).
22
+
23
+ Not implemented as an instantiated class as there is no way to unregister at_fork listeners, and because Pidfiles
24
+ may be pickled and there must be no possibility of accidentally unpickling and instantiating a new instance of the
25
+ manager.
26
+ """
27
+
17
28
  def __new__(cls, *args, **kwargs): # noqa
18
29
  raise TypeError
19
30
 
@@ -71,6 +82,11 @@ class _PidfileManager:
71
82
  inheritable: bool = True,
72
83
  **kwargs: ta.Any,
73
84
  ) -> ta.Iterator[Pidfile]:
85
+ """
86
+ A contextmanager for creating and managing a Pidfile which will only be inherited by forks of the calling /
87
+ creating thread.
88
+ """
89
+
74
90
  check.arg(inheritable)
75
91
 
76
92
  cls.install()
@@ -84,6 +100,7 @@ class _PidfileManager:
84
100
  with cls._lock:
85
101
  cls._pidfile_threads[pf] = threading.current_thread()
86
102
  try:
103
+
87
104
  with pf:
88
105
  os.set_inheritable(check.not_none(pf.fileno()), True)
89
106
  yield pf
@@ -10,6 +10,7 @@ TODO:
10
10
  - 3) recheck current pid of flock holder == that pid
11
11
  - racy as to if it's a different actual process as initial check, just with same pid, but due to 'identity' / semantic
12
12
  meaning of the named pidfile the processes are considered equivalent
13
+ - read_checked(), contextmanager
13
14
  """
14
15
  import fcntl
15
16
  import os
@@ -32,6 +33,14 @@ class Pidfile:
32
33
  self._path = path
33
34
  self._inheritable = inheritable
34
35
 
36
+ @property
37
+ def path(self) -> str:
38
+ return self._path
39
+
40
+ @property
41
+ def inheritable(self) -> bool:
42
+ return self._inheritable
43
+
35
44
  def __repr__(self) -> str:
36
45
  return f'{self.__class__.__name__}({self._path!r})'
37
46
 
@@ -75,6 +84,7 @@ class Pidfile:
75
84
  state = self.__dict__.copy()
76
85
 
77
86
  if '_f' in state:
87
+ # self._inheritable may be decoupled from actual file inheritability - for example when using the manager.
78
88
  if os.get_inheritable(fd := state.pop('_f').fileno()):
79
89
  state['__fd'] = fd
80
90
 
@@ -99,7 +109,7 @@ class Pidfile:
99
109
  del self._f
100
110
  return True
101
111
 
102
- def try_lock(self) -> bool:
112
+ def try_acquire_lock(self) -> bool:
103
113
  try:
104
114
  fcntl.flock(self._f, fcntl.LOCK_EX | fcntl.LOCK_NB)
105
115
  return True
@@ -107,14 +117,29 @@ class Pidfile:
107
117
  except OSError:
108
118
  return False
109
119
 
110
- def ensure_locked(self) -> None:
111
- if not self.try_lock():
112
- raise RuntimeError('Could not get lock')
120
+ #
121
+
122
+ class Error(Exception):
123
+ pass
124
+
125
+ class LockedError(Error):
126
+ pass
127
+
128
+ def acquire_lock(self) -> None:
129
+ if not self.try_acquire_lock():
130
+ raise self.LockedError
131
+
132
+ class NotLockedError(Error):
133
+ pass
134
+
135
+ def ensure_cannot_lock(self) -> None:
136
+ if self.try_acquire_lock():
137
+ raise self.NotLockedError
113
138
 
114
139
  #
115
140
 
116
141
  def write(self, pid: ta.Optional[int] = None) -> None:
117
- self.ensure_locked()
142
+ self.acquire_lock()
118
143
 
119
144
  if pid is None:
120
145
  pid = os.getpid()
@@ -125,18 +150,23 @@ class Pidfile:
125
150
  self._f.flush()
126
151
 
127
152
  def clear(self) -> None:
128
- self.ensure_locked()
153
+ self.acquire_lock()
129
154
 
130
155
  self._f.seek(0)
131
156
  self._f.truncate()
132
157
 
133
- def read(self) -> int:
134
- if self.try_lock():
135
- raise RuntimeError('Got lock')
158
+ #
159
+
160
+ def read(self) -> ta.Optional[int]:
161
+ self.ensure_cannot_lock()
136
162
 
137
163
  self._f.seek(0)
138
- return int(self._f.read()) # FIXME: could be empty or hold old value, race w proc start
164
+ buf = self._f.read()
165
+ if not buf:
166
+ return None
167
+ return int(buf)
139
168
 
140
169
  def kill(self, sig: int = signal.SIGTERM) -> None:
141
- pid = self.read()
142
- os.kill(pid, sig) # FIXME: Still racy - pidfd_send_signal?
170
+ if (pid := self.read()) is None:
171
+ raise self.Error(f'Pidfile locked but empty')
172
+ os.kill(pid, sig)
@@ -0,0 +1,246 @@
1
+ # ruff: noqa: UP006 UP007
2
+ # @omlish-lite
3
+ """
4
+ Strategies:
5
+ - linux
6
+ - get pid of owner (lslocks or F_GETLK)
7
+ - open pidfd to owner pid
8
+ - re-check pid of owner
9
+ - darwin
10
+ - get pids of referrers (lsof)
11
+ - read pid from file
12
+ - ensure pid is in referrers
13
+ - optionally loop
14
+ - ? setup pid death watcher? still a race
15
+ """
16
+ import abc
17
+ import contextlib
18
+ import os.path
19
+ import shutil
20
+ import sys
21
+ import time
22
+ import typing as ta
23
+
24
+ from ...diag.lslocks import LslocksCommand
25
+ from ...diag.lsof import LsofCommand
26
+ from ...lite.timeouts import Timeout
27
+ from ...lite.timeouts import TimeoutLike
28
+ from ...subprocesses.sync import subprocesses # noqa
29
+ from .pidfile import Pidfile
30
+
31
+
32
+ ##
33
+
34
+
35
+ class PidfilePinner(abc.ABC):
36
+ def __init__(
37
+ self,
38
+ *,
39
+ sleep_s: float = .1,
40
+ ) -> None:
41
+ super().__init__()
42
+
43
+ self._sleep_s = sleep_s
44
+
45
+ @classmethod
46
+ @abc.abstractmethod
47
+ def is_available(cls) -> bool:
48
+ raise NotImplementedError
49
+
50
+ class NoOwnerError(Exception):
51
+ pass
52
+
53
+ @abc.abstractmethod
54
+ def _pin_pidfile_owner(self, pidfile: Pidfile, timeout: Timeout) -> ta.ContextManager[int]:
55
+ raise NotImplementedError
56
+
57
+ @contextlib.contextmanager
58
+ def pin_pidfile_owner(
59
+ self,
60
+ path: str,
61
+ *,
62
+ timeout: ta.Optional[TimeoutLike] = None,
63
+ ) -> ta.Iterator[int]:
64
+ timeout = Timeout.of(timeout)
65
+
66
+ if not os.path.isfile(path):
67
+ raise self.NoOwnerError
68
+
69
+ with Pidfile(path, inheritable=False) as pf:
70
+ try:
71
+ with self._pin_pidfile_owner(pf, timeout) as pid:
72
+ yield pid
73
+
74
+ except Pidfile.NotLockedError:
75
+ raise self.NoOwnerError from None
76
+
77
+ @classmethod
78
+ def default_impl(cls) -> ta.Type['PidfilePinner']:
79
+ for impl in [
80
+ LslocksPidfdPidfilePinner,
81
+ LsofPidfilePinner,
82
+ ]:
83
+ if impl.is_available():
84
+ return impl
85
+ return UnverifiedPidfilePinner
86
+
87
+
88
+ ##
89
+
90
+
91
+ class UnverifiedPidfilePinner(PidfilePinner):
92
+ @classmethod
93
+ def is_available(cls) -> bool:
94
+ return True
95
+
96
+ @contextlib.contextmanager
97
+ def _pin_pidfile_owner(self, pidfile: Pidfile, timeout: Timeout) -> ta.Iterator[int]:
98
+ while (pid := pidfile.read()) is None:
99
+ time.sleep(self._sleep_s)
100
+ timeout()
101
+
102
+ yield pid
103
+
104
+
105
+ ##
106
+
107
+
108
+ class LsofPidfilePinner(PidfilePinner):
109
+ """
110
+ Fundamentally wrong, but still better than nothing. Simply reads the file contents and ensures a valid contained pid
111
+ has the file open via `lsof`.
112
+ """
113
+
114
+ @classmethod
115
+ def is_available(cls) -> bool:
116
+ return shutil.which('lsof') is not None
117
+
118
+ def _try_read_and_verify(self, pf: Pidfile, timeout: Timeout) -> ta.Optional[int]:
119
+ if (initial_pid := pf.read()) is None:
120
+ return None
121
+
122
+ lsof_output = LsofCommand(
123
+ # pid=initial_pid,
124
+ file=os.path.abspath(pf.path),
125
+ ).run(
126
+ timeout=timeout,
127
+ )
128
+
129
+ lsof_pids: ta.Set[int] = set()
130
+ for li in lsof_output:
131
+ if li.pid is None:
132
+ continue
133
+ try:
134
+ li_pid = int(li.pid)
135
+ except ValueError:
136
+ continue
137
+ lsof_pids.add(li_pid)
138
+
139
+ if initial_pid not in lsof_pids:
140
+ return None
141
+
142
+ if (reread_pid := pf.read()) is None or reread_pid != initial_pid:
143
+ return None
144
+
145
+ return reread_pid
146
+
147
+ @contextlib.contextmanager
148
+ def _pin_pidfile_owner(self, pidfile: Pidfile, timeout: Timeout) -> ta.Iterator[int]:
149
+ while (pid := self._try_read_and_verify(pidfile, timeout)) is None:
150
+ time.sleep(self._sleep_s)
151
+ timeout()
152
+
153
+ yield pid
154
+
155
+
156
+ ##
157
+
158
+
159
+ class LslocksPidfdPidfilePinner(PidfilePinner):
160
+ """
161
+ Finds the locking pid via `lslocks`, opens a pidfd, then re-runs `lslocks` and rechecks the locking pid is the same.
162
+ """
163
+
164
+ @classmethod
165
+ def is_available(cls) -> bool:
166
+ return sys.platform == 'linux' and shutil.which('lslocks') is not None
167
+
168
+ def _read_locking_pid(self, path: str, timeout: Timeout) -> int:
169
+ lsl_output = LslocksCommand().run(timeout=timeout)
170
+
171
+ lsl_pids = {
172
+ li.pid
173
+ for li in lsl_output
174
+ if li.path == path
175
+ and li.type == 'FLOCK'
176
+ }
177
+ if not lsl_pids:
178
+ raise self.NoOwnerError
179
+ if len(lsl_pids) != 1:
180
+ raise RuntimeError(f'Multiple locks on file: {path}')
181
+
182
+ [pid] = lsl_pids
183
+ return pid
184
+
185
+ class _Result(ta.NamedTuple):
186
+ pid: int
187
+ pidfd: int
188
+
189
+ def _try_read_and_verify(
190
+ self,
191
+ pidfile: Pidfile,
192
+ timeout: Timeout,
193
+ ) -> ta.Optional[_Result]:
194
+ path = os.path.abspath(pidfile.path)
195
+ initial_pid = self._read_locking_pid(path, timeout)
196
+
197
+ try:
198
+ pidfd = os.open(f'/proc/{initial_pid}', os.O_RDONLY)
199
+ except FileNotFoundError:
200
+ raise self.NoOwnerError from None
201
+
202
+ try:
203
+ reread_pid = self._read_locking_pid(path, timeout)
204
+ if reread_pid != initial_pid:
205
+ os.close(pidfd)
206
+ return None
207
+
208
+ return self._Result(initial_pid, pidfd)
209
+
210
+ except BaseException:
211
+ os.close(pidfd)
212
+ raise
213
+
214
+ @contextlib.contextmanager
215
+ def _pin_pidfile_owner(
216
+ self,
217
+ pidfile: Pidfile,
218
+ timeout: Timeout,
219
+ ) -> ta.Iterator[int]:
220
+ while (res := self._try_read_and_verify(pidfile, timeout)) is None:
221
+ time.sleep(self._sleep_s)
222
+ timeout()
223
+
224
+ try:
225
+ yield res.pid
226
+ finally:
227
+ os.close(res.pidfd)
228
+
229
+
230
+ ##
231
+
232
+
233
+ if __name__ == '__main__':
234
+ def _main() -> None:
235
+ argparse = __import__('argparse')
236
+ parser = argparse.ArgumentParser()
237
+ parser.add_argument('file')
238
+ args = parser.parse_args()
239
+
240
+ with PidfilePinner.default_impl()().pin_pidfile_owner(
241
+ args.file,
242
+ timeout=5.,
243
+ ) as pid:
244
+ print(pid)
245
+
246
+ _main()
@@ -4,6 +4,7 @@ import abc
4
4
  import sys
5
5
  import typing as ta
6
6
 
7
+ from ..lite.timeouts import TimeoutLike
7
8
  from .base import BaseSubprocesses
8
9
  from .run import SubprocessRun
9
10
  from .run import SubprocessRunOutput
@@ -21,7 +22,7 @@ class AbstractAsyncSubprocesses(BaseSubprocesses):
21
22
  self,
22
23
  *cmd: str,
23
24
  input: ta.Any = None, # noqa
24
- timeout: ta.Optional[float] = None,
25
+ timeout: ta.Optional[TimeoutLike] = None,
25
26
  check: bool = False,
26
27
  capture_output: ta.Optional[bool] = None,
27
28
  **kwargs: ta.Any,
@@ -8,6 +8,7 @@ import subprocess
8
8
  import time
9
9
  import typing as ta
10
10
 
11
+ from ..lite.timeouts import Timeout
11
12
  from .wrap import subprocess_maybe_shell_wrap_exec
12
13
 
13
14
 
@@ -111,6 +112,11 @@ class BaseSubprocesses(abc.ABC): # noqa
111
112
 
112
113
  #
113
114
 
115
+ if 'timeout' in kwargs:
116
+ kwargs['timeout'] = Timeout.of(kwargs['timeout']).or_(None)
117
+
118
+ #
119
+
114
120
  return cmd, dict(
115
121
  env=env,
116
122
  shell=shell,