omlish 0.0.0.dev225__py3-none-any.whl → 0.0.0.dev227__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.
@@ -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,