zppy 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.
- zppy/__init__.py +5 -0
- zppy/progress.py +587 -0
- zppy-0.1.0.dist-info/METADATA +359 -0
- zppy-0.1.0.dist-info/RECORD +7 -0
- zppy-0.1.0.dist-info/WHEEL +5 -0
- zppy-0.1.0.dist-info/licenses/LICENSE +121 -0
- zppy-0.1.0.dist-info/top_level.txt +1 -0
zppy/__init__.py
ADDED
zppy/progress.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""Report task trees through a Zig progress pipe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import struct
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
import weakref
|
|
11
|
+
from contextlib import ExitStack
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
|
|
15
|
+
if os.name == "posix":
|
|
16
|
+
import fcntl
|
|
17
|
+
|
|
18
|
+
_MAX_NODES = 253
|
|
19
|
+
_MAX_COMPLETED = 0xFFFFFFFF
|
|
20
|
+
# Zig uses this value plus one to mark an internal IPC node.
|
|
21
|
+
_MAX_TOTAL = 0xFFFFFFFE
|
|
22
|
+
|
|
23
|
+
# Keep construction and explicit forks from racing over reporter descriptors.
|
|
24
|
+
_fork_lock = threading.Lock()
|
|
25
|
+
_reporters: weakref.WeakSet[Progress] = weakref.WeakSet()
|
|
26
|
+
# In a fork child this owns the source descriptor exposed through ZIG_PROGRESS.
|
|
27
|
+
_fork_channel: int | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _interval(value: float, name: str, *, allow_zero: bool = False) -> float:
|
|
31
|
+
if not isinstance(value, (int, float)):
|
|
32
|
+
raise TypeError(f"{name} must be a number")
|
|
33
|
+
if not 0 <= value <= threading.TIMEOUT_MAX or (value == 0 and not allow_zero):
|
|
34
|
+
lower = "nonnegative" if allow_zero else "positive"
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"{name} must be {lower} and at most {threading.TIMEOUT_MAX} seconds"
|
|
37
|
+
)
|
|
38
|
+
return float(value)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _count(value: int) -> int:
|
|
42
|
+
if not isinstance(value, int):
|
|
43
|
+
raise TypeError("item counts must be integers")
|
|
44
|
+
if value < 0:
|
|
45
|
+
raise ValueError("item counts must be nonnegative")
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _name(value: str | bytes, size: int) -> bytes:
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
# Do not split a UTF-8 character at the wire limit.
|
|
52
|
+
return (
|
|
53
|
+
value.split("\0", 1)[0]
|
|
54
|
+
.encode("utf-8")[:size]
|
|
55
|
+
.decode("utf-8", errors="ignore")
|
|
56
|
+
.encode("utf-8")
|
|
57
|
+
)
|
|
58
|
+
if isinstance(value, bytes):
|
|
59
|
+
return value.split(b"\0", 1)[0][:size]
|
|
60
|
+
raise TypeError("name must be str or bytes")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _environment_fd() -> int | None:
|
|
64
|
+
value = os.environ.get("ZIG_PROGRESS", "")
|
|
65
|
+
if not value or not value.isascii() or not value.isdecimal():
|
|
66
|
+
return None
|
|
67
|
+
# Avoid Python's limit on the number of digits in an integer string.
|
|
68
|
+
value = value.lstrip("0") or "0"
|
|
69
|
+
if len(value) > 5:
|
|
70
|
+
return None
|
|
71
|
+
fd = int(value)
|
|
72
|
+
return fd if fd <= 65535 else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class _State:
|
|
77
|
+
parent: Node | None
|
|
78
|
+
name: bytes
|
|
79
|
+
total: int
|
|
80
|
+
completed: int = 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class _Child:
|
|
85
|
+
fd: int | None
|
|
86
|
+
pending: bytearray = field(default_factory=bytearray)
|
|
87
|
+
# Records are in parent-before-child order, with remapped parent indices.
|
|
88
|
+
snapshot: list[tuple[int, int, bytes, int]] = field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
def close(self) -> None:
|
|
91
|
+
if self.fd is not None:
|
|
92
|
+
os.close(self.fd)
|
|
93
|
+
self.fd = None
|
|
94
|
+
self.pending.clear()
|
|
95
|
+
|
|
96
|
+
def read(self, record: struct.Struct) -> None:
|
|
97
|
+
# Bound work per update even if a child writes without pause. Keep at
|
|
98
|
+
# most one incomplete packet, and retain the last complete valid tree.
|
|
99
|
+
budget = 65536
|
|
100
|
+
while self.fd is not None and budget:
|
|
101
|
+
try:
|
|
102
|
+
chunk = os.read(self.fd, min(4096, budget))
|
|
103
|
+
except (BlockingIOError, InterruptedError):
|
|
104
|
+
return
|
|
105
|
+
except OSError:
|
|
106
|
+
self.close()
|
|
107
|
+
return
|
|
108
|
+
if not chunk:
|
|
109
|
+
self.close()
|
|
110
|
+
return
|
|
111
|
+
budget -= len(chunk)
|
|
112
|
+
self.pending.extend(chunk)
|
|
113
|
+
while self.pending:
|
|
114
|
+
count = self.pending[0]
|
|
115
|
+
if not 1 <= count <= _MAX_NODES:
|
|
116
|
+
self.close()
|
|
117
|
+
return
|
|
118
|
+
size = 1 + count * (record.size + 1)
|
|
119
|
+
if len(self.pending) < size:
|
|
120
|
+
break
|
|
121
|
+
packet = self.pending[:size]
|
|
122
|
+
del self.pending[:size]
|
|
123
|
+
snapshot = self._decode(packet, count, record)
|
|
124
|
+
if snapshot is None:
|
|
125
|
+
self.close()
|
|
126
|
+
return
|
|
127
|
+
self.snapshot = snapshot
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _decode(
|
|
131
|
+
packet: bytes, count: int, record: struct.Struct
|
|
132
|
+
) -> list[tuple[int, int, bytes, int]] | None:
|
|
133
|
+
parents = packet[1 + count * record.size :]
|
|
134
|
+
if parents[0] != 255:
|
|
135
|
+
return None
|
|
136
|
+
children: list[list[int]] = [[] for _ in range(count)]
|
|
137
|
+
for index in range(1, count):
|
|
138
|
+
parent = parents[index]
|
|
139
|
+
if parent >= count:
|
|
140
|
+
return None
|
|
141
|
+
children[parent].append(index)
|
|
142
|
+
# A tree may use forward parent references. Traversal both orders the
|
|
143
|
+
# records and detects cycles or branches not connected to the root.
|
|
144
|
+
order = [0]
|
|
145
|
+
for index in order:
|
|
146
|
+
order.extend(children[index])
|
|
147
|
+
if len(order) != count:
|
|
148
|
+
return None
|
|
149
|
+
indices = {old: new for new, old in enumerate(order)}
|
|
150
|
+
result = []
|
|
151
|
+
for index in order:
|
|
152
|
+
completed, total, name = record.unpack_from(packet, 1 + index * record.size)
|
|
153
|
+
if total > _MAX_TOTAL:
|
|
154
|
+
return None
|
|
155
|
+
parent = 255 if index == 0 else indices[parents[index]]
|
|
156
|
+
result.append((completed, total, name.split(b"\0", 1)[0], parent))
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Node:
|
|
161
|
+
"""A task in a Progress tree. Use start() to create a child task.
|
|
162
|
+
|
|
163
|
+
Operations are thread-safe. end() removes the task and its descendants,
|
|
164
|
+
then completes one item in its parent. Repeated end() calls and task updates
|
|
165
|
+
on ended or disabled nodes have no effect. spawn() and fork() still start
|
|
166
|
+
a process.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self, progress: Progress | None = None) -> None:
|
|
170
|
+
self._progress = progress
|
|
171
|
+
|
|
172
|
+
def start(self, name: str | bytes, estimated_total_items: int = 0) -> Node:
|
|
173
|
+
"""Start a child task. Zero means the total is unknown."""
|
|
174
|
+
progress = self._progress
|
|
175
|
+
name_bytes = _name(name, progress.name_size if progress else 120)
|
|
176
|
+
total = min(_count(estimated_total_items), _MAX_TOTAL)
|
|
177
|
+
if progress is not None:
|
|
178
|
+
with progress._lock:
|
|
179
|
+
if self in progress._nodes and len(progress._nodes) < _MAX_NODES:
|
|
180
|
+
node = Node(progress)
|
|
181
|
+
progress._nodes[node] = _State(self, name_bytes, total)
|
|
182
|
+
return node
|
|
183
|
+
return Node()
|
|
184
|
+
|
|
185
|
+
def spawn(self, args, **kwargs) -> subprocess.Popen:
|
|
186
|
+
"""Start a process whose progress tree replaces this task in reports.
|
|
187
|
+
|
|
188
|
+
Arguments follow subprocess.Popen. A private nonblocking pipe is added
|
|
189
|
+
to pass_fds and ZIG_PROGRESS is set in a copy of the child environment.
|
|
190
|
+
close_fds must be True. Use one process per node. Wait for the process,
|
|
191
|
+
then end this node; neither operation is automatic. Disabled or ended
|
|
192
|
+
nodes still start the process, without a progress channel.
|
|
193
|
+
"""
|
|
194
|
+
if not kwargs.get("close_fds", True):
|
|
195
|
+
raise ValueError("spawn requires close_fds=True")
|
|
196
|
+
source_env = kwargs.pop("env", None)
|
|
197
|
+
env = dict(os.environ if source_env is None else source_env)
|
|
198
|
+
env.pop("ZIG_PROGRESS", None)
|
|
199
|
+
env.pop(b"ZIG_PROGRESS", None)
|
|
200
|
+
kwargs["env"] = env
|
|
201
|
+
progress = self._progress
|
|
202
|
+
if progress is not None:
|
|
203
|
+
with progress._lock:
|
|
204
|
+
if self in progress._nodes and os.name == "posix":
|
|
205
|
+
if self in progress._children:
|
|
206
|
+
raise ValueError("a process is already attached to this node")
|
|
207
|
+
read_fd, write_fd = os.pipe()
|
|
208
|
+
child = _Child(read_fd)
|
|
209
|
+
try:
|
|
210
|
+
os.set_blocking(read_fd, False)
|
|
211
|
+
os.set_blocking(write_fd, False)
|
|
212
|
+
if write_fd > 65535:
|
|
213
|
+
raise ValueError("child progress fd exceeds 65535")
|
|
214
|
+
env["ZIG_PROGRESS"] = str(write_fd)
|
|
215
|
+
kwargs["pass_fds"] = (
|
|
216
|
+
*kwargs.get("pass_fds", ()),
|
|
217
|
+
write_fd,
|
|
218
|
+
)
|
|
219
|
+
process = subprocess.Popen(args, **kwargs)
|
|
220
|
+
except BaseException:
|
|
221
|
+
child.close()
|
|
222
|
+
raise
|
|
223
|
+
finally:
|
|
224
|
+
os.close(write_fd)
|
|
225
|
+
progress._children[self] = child
|
|
226
|
+
return process
|
|
227
|
+
return subprocess.Popen(args, **kwargs)
|
|
228
|
+
|
|
229
|
+
def fork(self) -> int:
|
|
230
|
+
"""Fork with a private progress channel attached to this node.
|
|
231
|
+
|
|
232
|
+
Return the child's PID in the parent and zero in the child, like
|
|
233
|
+
os.fork(). In the child, inherited reporters are disabled; create a
|
|
234
|
+
fresh Progress with the same name_size to use the new ZIG_PROGRESS
|
|
235
|
+
channel. The parent must waitpid() and end this node. Disabled or ended
|
|
236
|
+
nodes still fork, but supply no channel. Requires os.fork().
|
|
237
|
+
"""
|
|
238
|
+
global _fork_channel
|
|
239
|
+
if not hasattr(os, "fork"):
|
|
240
|
+
raise NotImplementedError("fork requires os.fork()")
|
|
241
|
+
# Freeze every reporter, including unrelated ones: the child must not
|
|
242
|
+
# inherit an in-flight write, descriptor change, or locked reporter.
|
|
243
|
+
# ExitStack retains the original locks so resetting child objects does
|
|
244
|
+
# not change which locks are released when this method returns.
|
|
245
|
+
with _fork_lock, ExitStack() as stack:
|
|
246
|
+
reporters = list(_reporters)
|
|
247
|
+
for reporter in reporters:
|
|
248
|
+
stack.enter_context(reporter._lock)
|
|
249
|
+
progress = self._progress
|
|
250
|
+
child = None
|
|
251
|
+
write_fd = None
|
|
252
|
+
if progress is not None and self in progress._nodes:
|
|
253
|
+
if self in progress._children:
|
|
254
|
+
raise ValueError("a process is already attached to this node")
|
|
255
|
+
read_fd, write_fd = os.pipe()
|
|
256
|
+
child = _Child(read_fd)
|
|
257
|
+
try:
|
|
258
|
+
if child is not None:
|
|
259
|
+
os.set_blocking(child.fd, False)
|
|
260
|
+
os.set_blocking(write_fd, False)
|
|
261
|
+
if write_fd > 65535:
|
|
262
|
+
raise ValueError("child progress fd exceeds 65535")
|
|
263
|
+
progress._children[self] = child
|
|
264
|
+
pid = os.fork()
|
|
265
|
+
except BaseException:
|
|
266
|
+
if child is not None:
|
|
267
|
+
progress._children.pop(self, None)
|
|
268
|
+
child.close()
|
|
269
|
+
os.close(write_fd)
|
|
270
|
+
raise
|
|
271
|
+
if pid:
|
|
272
|
+
if write_fd is not None:
|
|
273
|
+
os.close(write_fd)
|
|
274
|
+
return pid
|
|
275
|
+
|
|
276
|
+
# Only the calling thread survives. Do not join old threads or
|
|
277
|
+
# touch their Events, which can contain locks held at fork time.
|
|
278
|
+
for reporter in reporters:
|
|
279
|
+
reporter._lock = threading.Lock()
|
|
280
|
+
reporter._stop = threading.Event()
|
|
281
|
+
reporter._thread = None
|
|
282
|
+
reporter._closed = True
|
|
283
|
+
reporter._disconnect()
|
|
284
|
+
if _fork_channel is not None:
|
|
285
|
+
os.close(_fork_channel)
|
|
286
|
+
_fork_channel = write_fd
|
|
287
|
+
if write_fd is None:
|
|
288
|
+
os.environ.pop("ZIG_PROGRESS", None)
|
|
289
|
+
else:
|
|
290
|
+
os.environ["ZIG_PROGRESS"] = str(write_fd)
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
def complete_one(self) -> None:
|
|
294
|
+
"""Complete one item, up to the protocol's counter limit."""
|
|
295
|
+
progress = self._progress
|
|
296
|
+
if progress is not None:
|
|
297
|
+
with progress._lock:
|
|
298
|
+
if state := progress._nodes.get(self):
|
|
299
|
+
state.completed = min(state.completed + 1, _MAX_COMPLETED)
|
|
300
|
+
|
|
301
|
+
def set_completed_items(self, count: int) -> None:
|
|
302
|
+
"""Set an absolute completed count. Large counts saturate at u32 max."""
|
|
303
|
+
count = min(_count(count), _MAX_COMPLETED)
|
|
304
|
+
progress = self._progress
|
|
305
|
+
if progress is not None:
|
|
306
|
+
with progress._lock:
|
|
307
|
+
if state := progress._nodes.get(self):
|
|
308
|
+
state.completed = count
|
|
309
|
+
|
|
310
|
+
def set_estimated_total_items(self, count: int) -> None:
|
|
311
|
+
"""Set the total; zero means unknown. Large totals saturate at 2**32-2."""
|
|
312
|
+
count = min(_count(count), _MAX_TOTAL)
|
|
313
|
+
progress = self._progress
|
|
314
|
+
if progress is not None:
|
|
315
|
+
with progress._lock:
|
|
316
|
+
if state := progress._nodes.get(self):
|
|
317
|
+
state.total = count
|
|
318
|
+
|
|
319
|
+
def increase_estimated_total_items(self, count: int) -> None:
|
|
320
|
+
"""Add to the estimated total, up to 2**32-2."""
|
|
321
|
+
count = _count(count)
|
|
322
|
+
progress = self._progress
|
|
323
|
+
if progress is not None:
|
|
324
|
+
with progress._lock:
|
|
325
|
+
if state := progress._nodes.get(self):
|
|
326
|
+
state.total = min(state.total + count, _MAX_TOTAL)
|
|
327
|
+
|
|
328
|
+
def set_name(self, name: str | bytes) -> None:
|
|
329
|
+
"""Set the task label, limited to the reporter's name_size in bytes."""
|
|
330
|
+
progress = self._progress
|
|
331
|
+
name_bytes = _name(name, progress.name_size if progress else 120)
|
|
332
|
+
if progress is not None:
|
|
333
|
+
with progress._lock:
|
|
334
|
+
if state := progress._nodes.get(self):
|
|
335
|
+
state.name = name_bytes
|
|
336
|
+
|
|
337
|
+
def end(self) -> None:
|
|
338
|
+
"""End this task. Ending the root closes its reporter."""
|
|
339
|
+
progress = self._progress
|
|
340
|
+
if progress is None:
|
|
341
|
+
return
|
|
342
|
+
if self is progress.root:
|
|
343
|
+
progress.close()
|
|
344
|
+
return
|
|
345
|
+
with progress._lock:
|
|
346
|
+
state = progress._nodes.get(self)
|
|
347
|
+
if state is None:
|
|
348
|
+
return
|
|
349
|
+
# Parents precede children in insertion order. Remove the whole
|
|
350
|
+
# branch so no packet can contain an edge to a missing parent.
|
|
351
|
+
removed = {self}
|
|
352
|
+
for node, child in list(progress._nodes.items()):
|
|
353
|
+
if node in removed or child.parent in removed:
|
|
354
|
+
removed.add(node)
|
|
355
|
+
del progress._nodes[node]
|
|
356
|
+
if child_process := progress._children.pop(node, None):
|
|
357
|
+
child_process.close()
|
|
358
|
+
if parent := progress._nodes.get(state.parent):
|
|
359
|
+
parent.completed = min(parent.completed + 1, _MAX_COMPLETED)
|
|
360
|
+
|
|
361
|
+
def __enter__(self) -> Node:
|
|
362
|
+
return self
|
|
363
|
+
|
|
364
|
+
def __exit__(
|
|
365
|
+
self,
|
|
366
|
+
exc_type: type[BaseException] | None,
|
|
367
|
+
exc_value: BaseException | None,
|
|
368
|
+
traceback: TracebackType | None,
|
|
369
|
+
) -> None:
|
|
370
|
+
self.end()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class Progress:
|
|
374
|
+
"""A reporter for the process-wide ZIG_PROGRESS channel.
|
|
375
|
+
|
|
376
|
+
Construction starts a daemon thread unless refresh_rate is None. Times
|
|
377
|
+
are in seconds. Missing, malformed, or unusable channels disable reporting.
|
|
378
|
+
No terminal output is produced. Use one reporter per pipe and close it
|
|
379
|
+
with a context manager or close(). The caller retains ownership of fd.
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
def __init__(
|
|
383
|
+
self,
|
|
384
|
+
root_name: str | bytes = "",
|
|
385
|
+
estimated_total_items: int = 0,
|
|
386
|
+
*,
|
|
387
|
+
refresh_rate: float | None = 0.08,
|
|
388
|
+
initial_delay: float = 0.2,
|
|
389
|
+
fd: int | None = None,
|
|
390
|
+
disable: bool = False,
|
|
391
|
+
name_size: int = 120,
|
|
392
|
+
) -> None:
|
|
393
|
+
if not isinstance(name_size, int) or name_size not in (40, 120):
|
|
394
|
+
raise ValueError("name_size must be 40 (published) or 120 (Zig 0.17)")
|
|
395
|
+
self._record = struct.Struct(f"<II{name_size}s")
|
|
396
|
+
self._name_size = name_size
|
|
397
|
+
name = _name(root_name, name_size)
|
|
398
|
+
total = min(_count(estimated_total_items), _MAX_TOTAL)
|
|
399
|
+
if refresh_rate is not None:
|
|
400
|
+
refresh_rate = _interval(refresh_rate, "refresh_rate")
|
|
401
|
+
initial_delay = _interval(initial_delay, "initial_delay", allow_zero=True)
|
|
402
|
+
if fd is not None and (not isinstance(fd, int) or not 0 <= fd <= 65535):
|
|
403
|
+
raise ValueError("fd must be an integer from 0 to 65535")
|
|
404
|
+
self._lock = threading.Lock()
|
|
405
|
+
self._stop = threading.Event()
|
|
406
|
+
self._thread: threading.Thread | None = None
|
|
407
|
+
self._fd: int | None = None
|
|
408
|
+
self._pending = b""
|
|
409
|
+
self._pending_snapshot = b""
|
|
410
|
+
self._last_snapshot = b""
|
|
411
|
+
self._closed = False
|
|
412
|
+
self._nodes: dict[Node, _State] = {}
|
|
413
|
+
self._children: dict[Node, _Child] = {}
|
|
414
|
+
self.root = Node(self)
|
|
415
|
+
with _fork_lock:
|
|
416
|
+
if not disable and os.name == "posix":
|
|
417
|
+
source = _environment_fd() if fd is None else fd
|
|
418
|
+
if source is not None:
|
|
419
|
+
try:
|
|
420
|
+
self._fd = os.dup(source)
|
|
421
|
+
# Check the duplicate before changing shared flags or
|
|
422
|
+
# writing. A stale environment entry can name a file.
|
|
423
|
+
if (
|
|
424
|
+
not stat.S_ISFIFO(os.fstat(self._fd).st_mode)
|
|
425
|
+
or fcntl.fcntl(self._fd, fcntl.F_GETFL) & os.O_ACCMODE
|
|
426
|
+
== os.O_RDONLY
|
|
427
|
+
):
|
|
428
|
+
self._disconnect()
|
|
429
|
+
else:
|
|
430
|
+
# Duplicates share this flag with the caller's fd.
|
|
431
|
+
os.set_blocking(self._fd, False)
|
|
432
|
+
except OSError:
|
|
433
|
+
self._disconnect()
|
|
434
|
+
if self._fd is not None:
|
|
435
|
+
self._nodes[self.root] = _State(None, name, total)
|
|
436
|
+
if refresh_rate is not None:
|
|
437
|
+
self._thread = threading.Thread(
|
|
438
|
+
target=self._run,
|
|
439
|
+
args=(initial_delay, refresh_rate),
|
|
440
|
+
name="zppy-progress",
|
|
441
|
+
daemon=True,
|
|
442
|
+
)
|
|
443
|
+
try:
|
|
444
|
+
self._thread.start()
|
|
445
|
+
except RuntimeError:
|
|
446
|
+
self._thread = None
|
|
447
|
+
self._disconnect()
|
|
448
|
+
_reporters.add(self)
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def name_size(self) -> int:
|
|
452
|
+
"""Name field size: 120 by default, or 40 for the published protocol."""
|
|
453
|
+
return self._name_size
|
|
454
|
+
|
|
455
|
+
@property
|
|
456
|
+
def enabled(self) -> bool:
|
|
457
|
+
"""Whether the reporter has an open progress channel."""
|
|
458
|
+
with self._lock:
|
|
459
|
+
return self._fd is not None
|
|
460
|
+
|
|
461
|
+
def _snapshot(self) -> bytes:
|
|
462
|
+
indices = {node: index for index, node in enumerate(self._nodes)}
|
|
463
|
+
records = bytearray()
|
|
464
|
+
parents = bytearray()
|
|
465
|
+
for state in self._nodes.values():
|
|
466
|
+
records.extend(self._record.pack(state.completed, state.total, state.name))
|
|
467
|
+
parents.append(255 if state.parent is None else indices[state.parent])
|
|
468
|
+
for node, child in self._children.items():
|
|
469
|
+
child.read(self._record)
|
|
470
|
+
if not child.snapshot:
|
|
471
|
+
continue
|
|
472
|
+
root_index = indices[node]
|
|
473
|
+
completed, total, name, _ = child.snapshot[0]
|
|
474
|
+
# Zig retains the task label if the child root has no name.
|
|
475
|
+
name = name or self._nodes[node].name
|
|
476
|
+
self._record.pack_into(
|
|
477
|
+
records, root_index * self._record.size, completed, total, name
|
|
478
|
+
)
|
|
479
|
+
mapped = {0: root_index}
|
|
480
|
+
for index, (completed, total, name, parent) in enumerate(
|
|
481
|
+
child.snapshot[1:], 1
|
|
482
|
+
):
|
|
483
|
+
if len(parents) == _MAX_NODES:
|
|
484
|
+
break
|
|
485
|
+
mapped[index] = len(parents)
|
|
486
|
+
records.extend(self._record.pack(completed, total, name))
|
|
487
|
+
parents.append(mapped[parent])
|
|
488
|
+
return bytes([len(parents)]) + records + parents
|
|
489
|
+
|
|
490
|
+
def _disconnect(self) -> None:
|
|
491
|
+
if self._fd is not None:
|
|
492
|
+
try:
|
|
493
|
+
os.close(self._fd)
|
|
494
|
+
except OSError:
|
|
495
|
+
pass
|
|
496
|
+
self._fd = None
|
|
497
|
+
self._pending = b""
|
|
498
|
+
self._pending_snapshot = b""
|
|
499
|
+
self._last_snapshot = b""
|
|
500
|
+
for child in self._children.values():
|
|
501
|
+
child.close()
|
|
502
|
+
self._children.clear()
|
|
503
|
+
self._nodes.clear()
|
|
504
|
+
self._stop.set()
|
|
505
|
+
|
|
506
|
+
def _write(self, packet: bytes) -> bool:
|
|
507
|
+
try:
|
|
508
|
+
written = os.write(self._fd, packet)
|
|
509
|
+
except (BlockingIOError, InterruptedError):
|
|
510
|
+
# Keep an existing partial packet, but discard a new snapshot if
|
|
511
|
+
# none of its bytes reached the pipe. The next one supersedes it.
|
|
512
|
+
return False
|
|
513
|
+
except OSError:
|
|
514
|
+
self._disconnect()
|
|
515
|
+
return False
|
|
516
|
+
if written == 0:
|
|
517
|
+
self._disconnect()
|
|
518
|
+
return False
|
|
519
|
+
if not self._pending:
|
|
520
|
+
self._pending_snapshot = packet
|
|
521
|
+
self._pending = packet[written:]
|
|
522
|
+
if not self._pending:
|
|
523
|
+
self._last_snapshot = self._pending_snapshot
|
|
524
|
+
self._pending_snapshot = b""
|
|
525
|
+
return not self._pending
|
|
526
|
+
|
|
527
|
+
def flush(self) -> bool:
|
|
528
|
+
"""Try to send the current tree without waiting for pipe space.
|
|
529
|
+
|
|
530
|
+
Return True if the current snapshot was sent in full. Partial writes
|
|
531
|
+
resume before any newer snapshot. A full pipe delays or drops updates;
|
|
532
|
+
a permanent I/O error disables reporting without raising an exception.
|
|
533
|
+
"""
|
|
534
|
+
with self._lock:
|
|
535
|
+
if self._fd is None or self._closed:
|
|
536
|
+
return False
|
|
537
|
+
snapshot = self._snapshot()
|
|
538
|
+
if self._pending:
|
|
539
|
+
pending_snapshot = self._pending_snapshot
|
|
540
|
+
if not self._write(self._pending):
|
|
541
|
+
return False
|
|
542
|
+
if pending_snapshot == snapshot:
|
|
543
|
+
return True
|
|
544
|
+
return self._write(snapshot)
|
|
545
|
+
|
|
546
|
+
def _run(self, initial_delay: float, refresh_rate: float) -> None:
|
|
547
|
+
if self._stop.wait(initial_delay):
|
|
548
|
+
return
|
|
549
|
+
while not self._stop.is_set():
|
|
550
|
+
self.flush()
|
|
551
|
+
if self._stop.wait(refresh_rate):
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
def close(self) -> None:
|
|
555
|
+
"""Stop reports and try to send the final state. Safe to call again.
|
|
556
|
+
|
|
557
|
+
This does not wait for a receiver to drain a full pipe. Final delivery
|
|
558
|
+
is best effort; a pending large packet can remain incomplete if the
|
|
559
|
+
pipe is still full. The original descriptor is never closed.
|
|
560
|
+
"""
|
|
561
|
+
self._stop.set()
|
|
562
|
+
if self._thread is not None:
|
|
563
|
+
self._thread.join()
|
|
564
|
+
with self._lock:
|
|
565
|
+
if self._closed:
|
|
566
|
+
return
|
|
567
|
+
if self._fd is not None:
|
|
568
|
+
snapshot = self._snapshot()
|
|
569
|
+
pending_snapshot = self._pending_snapshot
|
|
570
|
+
if not self._pending:
|
|
571
|
+
if snapshot != self._last_snapshot:
|
|
572
|
+
self._write(snapshot)
|
|
573
|
+
elif self._write(self._pending) and pending_snapshot != snapshot:
|
|
574
|
+
self._write(snapshot)
|
|
575
|
+
self._closed = True
|
|
576
|
+
self._disconnect()
|
|
577
|
+
|
|
578
|
+
def __enter__(self) -> Progress:
|
|
579
|
+
return self
|
|
580
|
+
|
|
581
|
+
def __exit__(
|
|
582
|
+
self,
|
|
583
|
+
exc_type: type[BaseException] | None,
|
|
584
|
+
exc_value: BaseException | None,
|
|
585
|
+
traceback: TracebackType | None,
|
|
586
|
+
) -> None:
|
|
587
|
+
self.close()
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zppy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zig progress protocol client for Python
|
|
5
|
+
License-Expression: CC0-1.0
|
|
6
|
+
Project-URL: Repository, https://github.com/asciimoth/zppy
|
|
7
|
+
Project-URL: Issues, https://github.com/asciimoth/zppy/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/asciimoth/zppy#readme
|
|
9
|
+
Keywords: zig,progress,zig-progress-protocol
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# zppy
|
|
22
|
+
|
|
23
|
+
`zppy` is a Python library that reports task progress through the Zig progress
|
|
24
|
+
protocol. Use it to report tasks, subtasks, and progress from child processes.
|
|
25
|
+
|
|
26
|
+
`zppy` requires Python 3.11 or later. The library has no runtime dependencies.
|
|
27
|
+
The pipe transport supports POSIX systems, including Linux and macOS.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
python -m pip install zppy
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The package uses the [CC0 1.0 Universal license](LICENSE).
|
|
36
|
+
|
|
37
|
+
## Zig progress protocol
|
|
38
|
+
|
|
39
|
+
The Zig progress protocol lets a process send a tree of tasks to its parent
|
|
40
|
+
through a pipe. Each task has a name, a completed item count, and an estimated
|
|
41
|
+
total. The `ZIG_PROGRESS` environment variable identifies the pipe. The parent
|
|
42
|
+
can combine progress from child processes and display it in one place.
|
|
43
|
+
|
|
44
|
+
You can use the protocol outside the Zig toolchain. For example,
|
|
45
|
+
[`zpg`](https://github.com/asciimoth/zpg) runs commands and displays their
|
|
46
|
+
progress in a terminal. With `zppy`, Python programs can send progress to
|
|
47
|
+
`zpg` or another compatible receiver.
|
|
48
|
+
|
|
49
|
+
See Andrew Kelley's [Zig progress protocol specification][protocol] for the
|
|
50
|
+
protocol design.
|
|
51
|
+
|
|
52
|
+

|
|
53
|
+
|
|
54
|
+
## Library usage
|
|
55
|
+
|
|
56
|
+
If you use this library, [open an issue][issues] or [send a pull
|
|
57
|
+
request][pulls] with a link to your project. This helps us track use of the
|
|
58
|
+
Zig progress protocol outside the Zig toolchain.
|
|
59
|
+
|
|
60
|
+
Add the [`zig-progress-protocol` topic][topic] to your GitHub repository. Use
|
|
61
|
+
the same tag on other platforms so people can find related projects.
|
|
62
|
+
|
|
63
|
+
[issues]: https://github.com/asciimoth/zppy/issues
|
|
64
|
+
[pulls]: https://github.com/asciimoth/zppy/pulls
|
|
65
|
+
[topic]: https://github.com/topics/zig-progress-protocol
|
|
66
|
+
|
|
67
|
+
### Report tasks
|
|
68
|
+
|
|
69
|
+
Save this example as `build.py`:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import time
|
|
73
|
+
|
|
74
|
+
from zppy import Progress
|
|
75
|
+
|
|
76
|
+
with Progress("build", estimated_total_items=2) as progress:
|
|
77
|
+
for name in ("assets", "code"):
|
|
78
|
+
with progress.root.start(name, estimated_total_items=10) as task:
|
|
79
|
+
for _ in range(10):
|
|
80
|
+
time.sleep(0.1) # Replace this with one item of work.
|
|
81
|
+
task.complete_one()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Run `build.py` with a progress display:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
zpg python build.py
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Each child task can start more child tasks. When a child task ends, it leaves
|
|
91
|
+
the tree and adds one completed item to its parent task. Do not also call
|
|
92
|
+
`parent.complete_one()` for the same child task.
|
|
93
|
+
|
|
94
|
+
A context manager ends its task on normal exit and on exceptions. The context
|
|
95
|
+
manager does not suppress exceptions or report task success or failure.
|
|
96
|
+
|
|
97
|
+
Without a valid `ZIG_PROGRESS` channel, reporting is disabled. Task operations
|
|
98
|
+
have no effect. The library does not print to stdout or stderr. It does not
|
|
99
|
+
draw its own terminal display.
|
|
100
|
+
|
|
101
|
+
### Run the combined example
|
|
102
|
+
|
|
103
|
+
From the repository root, run:
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
PYTHONPATH=src zpg python -u examples/mock.py
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
This example requires `zpg` on `PATH` and a system with `os.fork()`. It runs
|
|
110
|
+
`zpg --mock` as a subprocess and a Python worker as a forked process at the
|
|
111
|
+
same time. Both task trees appear under `demo`. Python task labels start with
|
|
112
|
+
`PY`. Both workers also write messages to stdout and stderr.
|
|
113
|
+
|
|
114
|
+
### Child processes
|
|
115
|
+
|
|
116
|
+
Use `Node.spawn()` to start a process with its own progress pipe.
|
|
117
|
+
`Node.spawn()` accepts the arguments to `subprocess.Popen` and returns a
|
|
118
|
+
`Popen` object:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
import sys
|
|
122
|
+
|
|
123
|
+
from zppy import Progress
|
|
124
|
+
|
|
125
|
+
with Progress("build", estimated_total_items=1) as progress:
|
|
126
|
+
with progress.root.start("compile") as task:
|
|
127
|
+
with task.spawn([sys.executable, "worker.py"]) as process:
|
|
128
|
+
returncode = process.wait()
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
In `worker.py`, use `Progress`. The parent process combines the child's task
|
|
132
|
+
tree with its own tasks and sends the combined tree upstream. A child process
|
|
133
|
+
can use `Node.spawn()` to start more processes. Use a separate task node for
|
|
134
|
+
each process. Processes on different nodes can run at the same time.
|
|
135
|
+
|
|
136
|
+
`Node.spawn()` follows [Zig's child progress behavior][zig-child]. The child's
|
|
137
|
+
root replaces the task node in each report. The child root's descendants
|
|
138
|
+
appear below that node. If the child root has no name, the task keeps its
|
|
139
|
+
local name. Until the first complete report arrives, the task keeps its local
|
|
140
|
+
state. The last complete tree stays visible after the child closes its pipe or
|
|
141
|
+
exits.
|
|
142
|
+
|
|
143
|
+
Wait for the process, then end its task, as in the example above. Process exit
|
|
144
|
+
does not end the task or mark success or failure. Ending the task completes
|
|
145
|
+
one item in its parent.
|
|
146
|
+
|
|
147
|
+
Ending a task or closing the reporter closes its child progress pipes. Neither
|
|
148
|
+
operation waits for or terminates processes. Use one process per active node.
|
|
149
|
+
A second `spawn()` or `fork()` on the same node raises `ValueError`.
|
|
150
|
+
|
|
151
|
+
The update thread collects child reports. With `refresh_rate=None`, call
|
|
152
|
+
`flush()` regularly while children run.
|
|
153
|
+
|
|
154
|
+
`spawn()` copies the child environment and sets `ZIG_PROGRESS` to the new
|
|
155
|
+
pipe. It preserves other environment entries and adds the pipe to `pass_fds`.
|
|
156
|
+
`close_fds` must be true. `spawn()` does not change the parent environment.
|
|
157
|
+
Disabled, ended, or closed nodes still start processes, but remove
|
|
158
|
+
`ZIG_PROGRESS` from the child environment. Do not pass the upstream progress
|
|
159
|
+
descriptor to children.
|
|
160
|
+
|
|
161
|
+
All processes in the tree must use the same `name_size`. There is no format
|
|
162
|
+
negotiation.
|
|
163
|
+
|
|
164
|
+
[zig-child]: https://github.com/ziglang/zig/blob/0.15.2/lib/std/process/Child.zig#L96-L104
|
|
165
|
+
|
|
166
|
+
### Forked processes
|
|
167
|
+
|
|
168
|
+
On systems with `os.fork()`, use `Node.fork()` to attach a forked process to a
|
|
169
|
+
task. It returns the child's PID in the parent and `0` in the child:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
import os
|
|
173
|
+
|
|
174
|
+
from zppy import Progress
|
|
175
|
+
|
|
176
|
+
with Progress("parent", estimated_total_items=1) as progress:
|
|
177
|
+
with progress.root.start("worker") as task:
|
|
178
|
+
pid = task.fork()
|
|
179
|
+
if pid == 0:
|
|
180
|
+
with Progress("worker", estimated_total_items=10) as child:
|
|
181
|
+
for _ in range(10):
|
|
182
|
+
child.root.complete_one()
|
|
183
|
+
os._exit(0)
|
|
184
|
+
_, status = os.waitpid(pid, 0)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The child gets a private `ZIG_PROGRESS` pipe. Create a new `Progress` in the
|
|
188
|
+
child, with the same `name_size` as its parent. All inherited reporters and
|
|
189
|
+
their nodes are disabled in the child. Caller-owned descriptors remain the
|
|
190
|
+
caller's responsibility. The parent's reporters and environment stay
|
|
191
|
+
unchanged.
|
|
192
|
+
|
|
193
|
+
The forked child's tree replaces its task node, as for `spawn()`. Forked
|
|
194
|
+
children can use either `fork()` or `spawn()` to create more children. Wait
|
|
195
|
+
for the child with `os.waitpid()`, then end its task in the parent. Close the
|
|
196
|
+
child's reporter before `os._exit()`, which skips normal cleanup. Handle child
|
|
197
|
+
errors and exit status as required by your application.
|
|
198
|
+
|
|
199
|
+
Forks on disabled, ended, or closed nodes still run, but the child has no
|
|
200
|
+
`ZIG_PROGRESS` channel. A failed fork leaves the parent reporter usable.
|
|
201
|
+
|
|
202
|
+
Use `Node.fork()` to connect a forked process to a task. Direct `os.fork()`
|
|
203
|
+
calls and `multiprocessing` do not create this connection. `Node.fork()`
|
|
204
|
+
protects `zppy`'s own state. Application code and other libraries must also
|
|
205
|
+
meet the platform's [fork
|
|
206
|
+
requirements](https://docs.python.org/3/library/os.html#os.fork).
|
|
207
|
+
|
|
208
|
+
### API reference
|
|
209
|
+
|
|
210
|
+
`Progress(root_name="", estimated_total_items=0, *, refresh_rate=0.08,
|
|
211
|
+
initial_delay=0.2, fd=None, disable=False, name_size=120)` creates a reporter.
|
|
212
|
+
Use `progress.root` to access its root task.
|
|
213
|
+
|
|
214
|
+
| Option | Meaning |
|
|
215
|
+
| --- | --- |
|
|
216
|
+
| `root_name` | Root label, as `str` or `bytes`. |
|
|
217
|
+
| `estimated_total_items` | Estimated root item count. Zero means unknown. |
|
|
218
|
+
| `refresh_rate` | Seconds between reports. Use `None` for manual reports. |
|
|
219
|
+
| `initial_delay` | Seconds before the first automatic report. |
|
|
220
|
+
| `fd` | Explicit pipe descriptor. `None` reads `ZIG_PROGRESS`. |
|
|
221
|
+
| `disable` | Disable all reporting when true. |
|
|
222
|
+
| `name_size` | Name field size: `120` by default, or `40` for the old format. |
|
|
223
|
+
|
|
224
|
+
Timer values must be finite numbers no greater than `threading.TIMEOUT_MAX`.
|
|
225
|
+
`refresh_rate` must be positive, and `initial_delay` must be nonnegative.
|
|
226
|
+
|
|
227
|
+
`progress.enabled` shows whether the channel is open. `progress.flush()` tries
|
|
228
|
+
to send a snapshot immediately. It returns `True` if the current snapshot was
|
|
229
|
+
sent in full, or `False` if reporting is disabled or the write could not
|
|
230
|
+
finish. It does not wait for pipe space.
|
|
231
|
+
|
|
232
|
+
`progress.close()` stops the update thread, tries to send the final state, and
|
|
233
|
+
releases its descriptor. Use a context manager or call `close()` directly.
|
|
234
|
+
Closing the reporter does not set counts to their totals or remove the final
|
|
235
|
+
tree from the receiver. Ending the root also closes the reporter.
|
|
236
|
+
|
|
237
|
+
Nodes provide these methods:
|
|
238
|
+
|
|
239
|
+
| Method | Action |
|
|
240
|
+
| --- | --- |
|
|
241
|
+
| `start(name, estimated_total_items=0)` | Create a child task. |
|
|
242
|
+
| `spawn(args, **kwargs)` | Start a process and collect its progress. |
|
|
243
|
+
| `fork()` | Fork and collect the child's progress at this node. |
|
|
244
|
+
| `complete_one()` | Add one completed item. |
|
|
245
|
+
| `set_completed_items(count)` | Set the absolute completed count. |
|
|
246
|
+
| `set_estimated_total_items(count)` | Set the estimated total. |
|
|
247
|
+
| `increase_estimated_total_items(count)` | Add to the estimated total. |
|
|
248
|
+
| `set_name(name)` | Change the task label. |
|
|
249
|
+
| `end()` | Remove this branch and complete one item in its parent. |
|
|
250
|
+
|
|
251
|
+
Calls are thread-safe. Repeated `end()` calls have no effect. Task updates on
|
|
252
|
+
ended nodes also have no effect. Calls to `spawn()` and `fork()` still start a
|
|
253
|
+
process on ended nodes, as described in the child process sections. Invalid
|
|
254
|
+
arguments raise `TypeError` or `ValueError`, including when reporting is
|
|
255
|
+
disabled.
|
|
256
|
+
|
|
257
|
+
Counts must be nonnegative integers. Completed counts are limited to
|
|
258
|
+
`4294967295`. Totals are limited to `4294967294`, because Zig reserves the
|
|
259
|
+
next value. The library reduces larger values to these limits. Completed
|
|
260
|
+
counts can exceed totals.
|
|
261
|
+
|
|
262
|
+
Names are zero-padded to the field size. Text is encoded as UTF-8 and
|
|
263
|
+
shortened at a character boundary. Byte names are shortened without decoding.
|
|
264
|
+
Bytes after the first NUL are ignored.
|
|
265
|
+
|
|
266
|
+
The reporter tracks up to 253 active nodes, including the root. Additional
|
|
267
|
+
nodes have no effect. Ending nodes makes space for new nodes. Combined reports
|
|
268
|
+
also have a 253-node limit. Local nodes take priority, followed by child trees
|
|
269
|
+
in process creation order. Child roots replace local nodes without using more
|
|
270
|
+
slots. The reporter omits excess descendants and their branches. These
|
|
271
|
+
descendants can appear in later reports when space is available.
|
|
272
|
+
|
|
273
|
+
## Technical details
|
|
274
|
+
|
|
275
|
+
### Protocol compatibility
|
|
276
|
+
|
|
277
|
+
The default uses a 120-byte name field. Each node has two little-endian 32-bit
|
|
278
|
+
counters and 120 name bytes. A packet contains the node count, all node
|
|
279
|
+
records, then all parent indices. The root's parent is `255`.
|
|
280
|
+
|
|
281
|
+
The [published specification][protocol] uses 40-byte names. Select that format
|
|
282
|
+
explicitly with `Progress(name_size=40)`. The two formats are not wire
|
|
283
|
+
compatible. Both use `ZIG_PROGRESS`, and there is no version handshake. Select
|
|
284
|
+
the format that matches your receiver.
|
|
285
|
+
|
|
286
|
+
[protocol]: https://andrewkelley.me/post/zig-new-cli-progress-bar-explained.html#the-zig-progress-protocol-specification
|
|
287
|
+
|
|
288
|
+
Use one reporter per pipe. Each packet replaces the previous tree. A packet
|
|
289
|
+
does not add tasks to the previous tree. Multiple writers can replace each
|
|
290
|
+
other's progress or interleave packet bytes. Use `Node.spawn()` or
|
|
291
|
+
`Node.fork()` to combine progress from separate processes through private
|
|
292
|
+
pipes. Create a new reporter in each child, and use the same `name_size`
|
|
293
|
+
throughout the process tree.
|
|
294
|
+
|
|
295
|
+
### Platforms and pipe handling
|
|
296
|
+
|
|
297
|
+
The reporter accepts only writable POSIX pipes, such as those on Linux and
|
|
298
|
+
macOS. Other descriptors, including regular files and read-only pipes, disable
|
|
299
|
+
reporting without changing their data or blocking mode. Reporting is disabled
|
|
300
|
+
on other platforms.
|
|
301
|
+
|
|
302
|
+
The reporter duplicates the supplied descriptor and never closes the original.
|
|
303
|
+
The duplicate is not inherited across exec. The reporter sets the channel to
|
|
304
|
+
nonblocking mode. This flag also applies to the original descriptor. When you
|
|
305
|
+
create a reporter, it does not change `ZIG_PROGRESS` in the environment.
|
|
306
|
+
`Node.fork()` sets `ZIG_PROGRESS` in the forked child only.
|
|
307
|
+
|
|
308
|
+
A full pipe can delay or drop updates. The reporter resumes partial packets
|
|
309
|
+
before it sends a new snapshot. A permanent write error disables reporting.
|
|
310
|
+
|
|
311
|
+
Closing the reporter never waits for pipe space. The final report can be lost.
|
|
312
|
+
If the receiver stops reading, a large partial packet can remain incomplete.
|
|
313
|
+
If final delivery is required, retry `flush()` while the receiver is active
|
|
314
|
+
before you close the reporter.
|
|
315
|
+
|
|
316
|
+
## Release notes
|
|
317
|
+
|
|
318
|
+
### 0.1.0
|
|
319
|
+
|
|
320
|
+
Initial release:
|
|
321
|
+
|
|
322
|
+
- Report tasks and nested tasks through the Zig progress protocol.
|
|
323
|
+
- Combine progress from subprocesses and forked Python workers.
|
|
324
|
+
- Support both 120-byte and 40-byte task names.
|
|
325
|
+
- Send updates through POSIX pipes without runtime dependencies.
|
|
326
|
+
|
|
327
|
+
## Manual releases
|
|
328
|
+
|
|
329
|
+
Use the Nix development shell on this machine:
|
|
330
|
+
|
|
331
|
+
```sh
|
|
332
|
+
nix develop
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The shell includes the build tools and Twine. Before each release:
|
|
336
|
+
|
|
337
|
+
1. Set the version in `pyproject.toml`. The Nix package reads this version too.
|
|
338
|
+
2. Update the release notes above.
|
|
339
|
+
3. Run `just check`, then `just build` and `just check-dist`.
|
|
340
|
+
4. Install the wheel in a fresh virtual environment outside the source tree
|
|
341
|
+
and check that it works.
|
|
342
|
+
5. Commit the release changes and create an annotated tag, for example
|
|
343
|
+
`git tag -a v0.1.0 -m "Release 0.1.0"`.
|
|
344
|
+
6. Run `just publish` to upload the checked source archive and wheel to PyPI.
|
|
345
|
+
7. Push the release commit and tag to the repository.
|
|
346
|
+
|
|
347
|
+
`just publish` checks the files again but does not rebuild them. It selects
|
|
348
|
+
only the two files for the version in `pyproject.toml`, so older releases in
|
|
349
|
+
`dist/` are not uploaded. If you change the source, rebuild and check it before
|
|
350
|
+
you publish.
|
|
351
|
+
|
|
352
|
+
For the first upload, create a PyPI account, verify its email address, enable
|
|
353
|
+
two-factor authentication, and create an API token with account-wide scope.
|
|
354
|
+
Enter the token at Twine's password prompt. After the first upload, replace
|
|
355
|
+
that token with a token scoped to `zppy`. Do not commit tokens to the
|
|
356
|
+
repository. See the [PyPI publishing guide][pypi-publishing] for account and
|
|
357
|
+
authentication details.
|
|
358
|
+
|
|
359
|
+
[pypi-publishing]: https://docs.pypi.org/publishing/publishing-to-pypi/
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
zppy/__init__.py,sha256=ZEUdkeMKBTfcBig5hyigCRLPGlEbooO0srGdn-FxGTI,117
|
|
2
|
+
zppy/progress.py,sha256=53Gf31eRvxUYGMGT-tLU-ZJmT31sqzDgZgEfE1z8mBc,23138
|
|
3
|
+
zppy-0.1.0.dist-info/licenses/LICENSE,sha256=ogEPNDSH0_dhiv_lT3ifVIdgIzHAqNA_SemnxUfPBJk,7048
|
|
4
|
+
zppy-0.1.0.dist-info/METADATA,sha256=LQJxeC0IxfKze6qtd4InRYlyJ2CqIt68zlPmC1_zpX8,15011
|
|
5
|
+
zppy-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
6
|
+
zppy-0.1.0.dist-info/top_level.txt,sha256=C91XdzScKNBjWOHxpZIBOSd-t8EWJlwjEjKWIQmn-2E,5
|
|
7
|
+
zppy-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Creative Commons Legal Code
|
|
2
|
+
|
|
3
|
+
CC0 1.0 Universal
|
|
4
|
+
|
|
5
|
+
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
|
6
|
+
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
|
|
7
|
+
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
|
|
8
|
+
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
|
|
9
|
+
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
|
|
10
|
+
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
|
|
11
|
+
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
|
|
12
|
+
HEREUNDER.
|
|
13
|
+
|
|
14
|
+
Statement of Purpose
|
|
15
|
+
|
|
16
|
+
The laws of most jurisdictions throughout the world automatically confer
|
|
17
|
+
exclusive Copyright and Related Rights (defined below) upon the creator
|
|
18
|
+
and subsequent owner(s) (each and all, an "owner") of an original work of
|
|
19
|
+
authorship and/or a database (each, a "Work").
|
|
20
|
+
|
|
21
|
+
Certain owners wish to permanently relinquish those rights to a Work for
|
|
22
|
+
the purpose of contributing to a commons of creative, cultural and
|
|
23
|
+
scientific works ("Commons") that the public can reliably and without fear
|
|
24
|
+
of later claims of infringement build upon, modify, incorporate in other
|
|
25
|
+
works, reuse and redistribute as freely as possible in any form whatsoever
|
|
26
|
+
and for any purposes, including without limitation commercial purposes.
|
|
27
|
+
These owners may contribute to the Commons to promote the ideal of a free
|
|
28
|
+
culture and the further production of creative, cultural and scientific
|
|
29
|
+
works, or to gain reputation or greater distribution for their Work in
|
|
30
|
+
part through the use and efforts of others.
|
|
31
|
+
|
|
32
|
+
For these and/or other purposes and motivations, and without any
|
|
33
|
+
expectation of additional consideration or compensation, the person
|
|
34
|
+
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
|
|
35
|
+
is an owner of Copyright and Related Rights in the Work, voluntarily
|
|
36
|
+
elects to apply CC0 to the Work and publicly distribute the Work under its
|
|
37
|
+
terms, with knowledge of his or her Copyright and Related Rights in the
|
|
38
|
+
Work and the meaning and intended legal effect of CC0 on those rights.
|
|
39
|
+
|
|
40
|
+
1. Copyright and Related Rights. A Work made available under CC0 may be
|
|
41
|
+
protected by copyright and related or neighboring rights ("Copyright and
|
|
42
|
+
Related Rights"). Copyright and Related Rights include, but are not
|
|
43
|
+
limited to, the following:
|
|
44
|
+
|
|
45
|
+
i. the right to reproduce, adapt, distribute, perform, display,
|
|
46
|
+
communicate, and translate a Work;
|
|
47
|
+
ii. moral rights retained by the original author(s) and/or performer(s);
|
|
48
|
+
iii. publicity and privacy rights pertaining to a person's image or
|
|
49
|
+
likeness depicted in a Work;
|
|
50
|
+
iv. rights protecting against unfair competition in regards to a Work,
|
|
51
|
+
subject to the limitations in paragraph 4(a), below;
|
|
52
|
+
v. rights protecting the extraction, dissemination, use and reuse of data
|
|
53
|
+
in a Work;
|
|
54
|
+
vi. database rights (such as those arising under Directive 96/9/EC of the
|
|
55
|
+
European Parliament and of the Council of 11 March 1996 on the legal
|
|
56
|
+
protection of databases, and under any national implementation
|
|
57
|
+
thereof, including any amended or successor version of such
|
|
58
|
+
directive); and
|
|
59
|
+
vii. other similar, equivalent or corresponding rights throughout the
|
|
60
|
+
world based on applicable law or treaty, and any national
|
|
61
|
+
implementations thereof.
|
|
62
|
+
|
|
63
|
+
2. Waiver. To the greatest extent permitted by, but not in contravention
|
|
64
|
+
of, applicable law, Affirmer hereby overtly, fully, permanently,
|
|
65
|
+
irrevocably and unconditionally waives, abandons, and surrenders all of
|
|
66
|
+
Affirmer's Copyright and Related Rights and associated claims and causes
|
|
67
|
+
of action, whether now known or unknown (including existing as well as
|
|
68
|
+
future claims and causes of action), in the Work (i) in all territories
|
|
69
|
+
worldwide, (ii) for the maximum duration provided by applicable law or
|
|
70
|
+
treaty (including future time extensions), (iii) in any current or future
|
|
71
|
+
medium and for any number of copies, and (iv) for any purpose whatsoever,
|
|
72
|
+
including without limitation commercial, advertising or promotional
|
|
73
|
+
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
|
|
74
|
+
member of the public at large and to the detriment of Affirmer's heirs and
|
|
75
|
+
successors, fully intending that such Waiver shall not be subject to
|
|
76
|
+
revocation, rescission, cancellation, termination, or any other legal or
|
|
77
|
+
equitable action to disrupt the quiet enjoyment of the Work by the public
|
|
78
|
+
as contemplated by Affirmer's express Statement of Purpose.
|
|
79
|
+
|
|
80
|
+
3. Public License Fallback. Should any part of the Waiver for any reason
|
|
81
|
+
be judged legally invalid or ineffective under applicable law, then the
|
|
82
|
+
Waiver shall be preserved to the maximum extent permitted taking into
|
|
83
|
+
account Affirmer's express Statement of Purpose. In addition, to the
|
|
84
|
+
extent the Waiver is so judged Affirmer hereby grants to each affected
|
|
85
|
+
person a royalty-free, non transferable, non sublicensable, non exclusive,
|
|
86
|
+
irrevocable and unconditional license to exercise Affirmer's Copyright and
|
|
87
|
+
Related Rights in the Work (i) in all territories worldwide, (ii) for the
|
|
88
|
+
maximum duration provided by applicable law or treaty (including future
|
|
89
|
+
time extensions), (iii) in any current or future medium and for any number
|
|
90
|
+
of copies, and (iv) for any purpose whatsoever, including without
|
|
91
|
+
limitation commercial, advertising or promotional purposes (the
|
|
92
|
+
"License"). The License shall be deemed effective as of the date CC0 was
|
|
93
|
+
applied by Affirmer to the Work. Should any part of the License for any
|
|
94
|
+
reason be judged legally invalid or ineffective under applicable law, such
|
|
95
|
+
partial invalidity or ineffectiveness shall not invalidate the remainder
|
|
96
|
+
of the License, and in such case Affirmer hereby affirms that he or she
|
|
97
|
+
will not (i) exercise any of his or her remaining Copyright and Related
|
|
98
|
+
Rights in the Work or (ii) assert any associated claims and causes of
|
|
99
|
+
action with respect to the Work, in either case contrary to Affirmer's
|
|
100
|
+
express Statement of Purpose.
|
|
101
|
+
|
|
102
|
+
4. Limitations and Disclaimers.
|
|
103
|
+
|
|
104
|
+
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
|
105
|
+
surrendered, licensed or otherwise affected by this document.
|
|
106
|
+
b. Affirmer offers the Work as-is and makes no representations or
|
|
107
|
+
warranties of any kind concerning the Work, express, implied,
|
|
108
|
+
statutory or otherwise, including without limitation warranties of
|
|
109
|
+
title, merchantability, fitness for a particular purpose, non
|
|
110
|
+
infringement, or the absence of latent or other defects, accuracy, or
|
|
111
|
+
the present or absence of errors, whether or not discoverable, all to
|
|
112
|
+
the greatest extent permissible under applicable law.
|
|
113
|
+
c. Affirmer disclaims responsibility for clearing rights of other persons
|
|
114
|
+
that may apply to the Work or any use thereof, including without
|
|
115
|
+
limitation any person's Copyright and Related Rights in the Work.
|
|
116
|
+
Further, Affirmer disclaims responsibility for obtaining any necessary
|
|
117
|
+
consents, permissions or other rights required for any use of the
|
|
118
|
+
Work.
|
|
119
|
+
d. Affirmer understands and acknowledges that Creative Commons is not a
|
|
120
|
+
party to this document and has no duty or obligation with respect to
|
|
121
|
+
this CC0 or use of the Work.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zppy
|