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 ADDED
@@ -0,0 +1,5 @@
1
+ """Zig progress protocol client for Python."""
2
+
3
+ from .progress import Node, Progress
4
+
5
+ __all__ = ["Node", "Progress"]
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
+ ![zpg displaying a task tree](https://raw.githubusercontent.com/asciimoth/zpg/master/demo.gif)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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