osc7501 1.0.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.
osc7501/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """Report terminal program status with OSC 7501."""
2
+
3
+ from osc7501.encode import Kind, State, encode
4
+ from osc7501.terminal import (
5
+ Detection,
6
+ Job,
7
+ Support,
8
+ blocked,
9
+ clear,
10
+ detect,
11
+ done,
12
+ error,
13
+ idle,
14
+ parse_reply,
15
+ query,
16
+ working,
17
+ )
18
+
19
+ __all__ = [
20
+ "Detection",
21
+ "Job",
22
+ "Kind",
23
+ "State",
24
+ "Support",
25
+ "blocked",
26
+ "clear",
27
+ "detect",
28
+ "done",
29
+ "encode",
30
+ "error",
31
+ "idle",
32
+ "parse_reply",
33
+ "query",
34
+ "working",
35
+ ]
osc7501/encode.py ADDED
@@ -0,0 +1,118 @@
1
+ """Build one OSC 7501 report. This module does not write to a terminal."""
2
+
3
+ import base64
4
+ import re
5
+ import unicodedata
6
+ from enum import StrEnum
7
+
8
+ _APP = re.compile(r"[A-Za-z0-9_.+-]{1,32}")
9
+ _ID_BYTES = 128
10
+ _ID_SEGMENTS = 8
11
+ _MSG_BYTES = 2048
12
+ _TITLE_BYTES = 192
13
+ _SEQUENCE_BYTES = 4096
14
+
15
+
16
+ class State(StrEnum):
17
+ IDLE = "idle"
18
+ WORKING = "working"
19
+ DONE = "done"
20
+ BLOCKED = "blocked"
21
+ ERROR = "error"
22
+ CLEAR = "clear"
23
+
24
+
25
+ class Kind(StrEnum):
26
+ PERMISSION = "permission"
27
+ QUESTION = "question"
28
+ AUTH = "auth"
29
+
30
+
31
+ def encode(
32
+ state: str,
33
+ *,
34
+ msg: str | None = None,
35
+ title: str | None = None,
36
+ app: str | None = None,
37
+ id: str | None = None,
38
+ kind: str | None = None,
39
+ progress: int | None = None,
40
+ ) -> str:
41
+ """Return one report. The terminator is ST.
42
+
43
+ ``re.fullmatch`` is required for ``app`` and each ``id`` segment.
44
+ ``re.match`` with ``$`` also matches before a trailing newline.
45
+
46
+ Control characters in ``msg`` and ``title`` are replaced with a space.
47
+ A terminal discards the whole report when those fields decode to a control.
48
+ """
49
+ try:
50
+ parsed = State(state)
51
+ except ValueError:
52
+ raise ValueError(f"unknown state: {state!r}") from None
53
+
54
+ if parsed is State.CLEAR and any(
55
+ value is not None for value in (msg, title, app, kind, progress)
56
+ ):
57
+ raise ValueError("clear accepts only id")
58
+
59
+ parsed_kind: Kind | None = None
60
+ if kind is not None:
61
+ if parsed is not State.BLOCKED:
62
+ raise ValueError("kind is only valid with blocked")
63
+ try:
64
+ parsed_kind = Kind(kind)
65
+ except ValueError:
66
+ raise ValueError(f"unknown kind: {kind!r}") from None
67
+
68
+ if progress is not None:
69
+ if parsed not in (State.WORKING, State.BLOCKED):
70
+ raise ValueError("progress is only valid with working or blocked")
71
+ # bool is a subclass of int. True would otherwise encode as progress=1.
72
+ if isinstance(progress, bool) or not isinstance(progress, int) or not 0 <= progress <= 100:
73
+ raise ValueError(f"invalid progress: {progress!r}")
74
+
75
+ if app is not None and _APP.fullmatch(app) is None:
76
+ raise ValueError(f"invalid app: {app!r}")
77
+ if id is not None:
78
+ _check_id(id)
79
+
80
+ pairs = [f"state={parsed.value}"]
81
+ if id is not None:
82
+ pairs.append(f"id={id}")
83
+ if parsed_kind is not None:
84
+ pairs.append(f"kind={parsed_kind.value}")
85
+ if progress is not None:
86
+ pairs.append(f"progress={progress}")
87
+ if app is not None:
88
+ pairs.append(f"app={app}")
89
+ if title is not None and (encoded := _text(title, _TITLE_BYTES)) is not None:
90
+ pairs.append(f"title={encoded}")
91
+ if msg is not None and (encoded := _text(msg, _MSG_BYTES)) is not None:
92
+ pairs.append(f"msg={encoded}")
93
+
94
+ sequence = f"\x1b]7501;{':'.join(pairs)}\x1b\\"
95
+ if len(sequence.encode("ascii")) > _SEQUENCE_BYTES:
96
+ raise ValueError("sequence longer than 4096 bytes")
97
+ return sequence
98
+
99
+
100
+ def _check_id(record_id: str) -> None:
101
+ if len(record_id.encode("utf-8")) > _ID_BYTES:
102
+ raise ValueError("id longer than 128 bytes")
103
+ segments = record_id.split("/")
104
+ if len(segments) > _ID_SEGMENTS:
105
+ raise ValueError("id has more than 8 segments")
106
+ for segment in segments:
107
+ if _APP.fullmatch(segment) is None:
108
+ raise ValueError(f"invalid id segment: {segment!r}")
109
+
110
+
111
+ def _text(text: str, limit: int) -> str | None:
112
+ cleaned = "".join(" " if unicodedata.category(char) == "Cc" else char for char in text)
113
+ raw = cleaned.encode("utf-8", errors="replace")
114
+ if len(raw) > limit:
115
+ raw = raw[:limit].decode("utf-8", errors="ignore").encode("utf-8")
116
+ if not raw:
117
+ return None
118
+ return base64.b64encode(raw).decode("ascii")
osc7501/py.typed ADDED
File without changes
osc7501/terminal.py ADDED
@@ -0,0 +1,467 @@
1
+ """Write an OSC 7501 report to a terminal, and read a support reply."""
2
+
3
+ import os
4
+ import sys
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ from typing import IO, Any
10
+
11
+ from osc7501.encode import Kind, State, encode
12
+
13
+ _ESC = 0x1B
14
+ _BEL = 0x07
15
+ _CAN = 0x18
16
+ _SUB = 0x1A
17
+ _GROUND = "ground"
18
+ _ESCAPE = "esc"
19
+ _OSC = "osc"
20
+ _OSC_ESC = "osc-esc"
21
+ _CSI = "csi"
22
+ _REPLY_PREFIX = b"7501;?"
23
+ _BODY_LIMIT = 4096
24
+
25
+ _lock = threading.Lock()
26
+
27
+
28
+ def idle(
29
+ *,
30
+ msg: str | None = None,
31
+ title: str | None = None,
32
+ app: str | None = None,
33
+ id: str | None = None,
34
+ stream: IO[str] | None = None,
35
+ enabled: bool | None = None,
36
+ ) -> bool:
37
+ return _send(State.IDLE, msg=msg, title=title, app=app, id=id, stream=stream, enabled=enabled)
38
+
39
+
40
+ def done(
41
+ *,
42
+ msg: str | None = None,
43
+ title: str | None = None,
44
+ app: str | None = None,
45
+ id: str | None = None,
46
+ stream: IO[str] | None = None,
47
+ enabled: bool | None = None,
48
+ ) -> bool:
49
+ return _send(State.DONE, msg=msg, title=title, app=app, id=id, stream=stream, enabled=enabled)
50
+
51
+
52
+ def error(
53
+ *,
54
+ msg: str | None = None,
55
+ title: str | None = None,
56
+ app: str | None = None,
57
+ id: str | None = None,
58
+ stream: IO[str] | None = None,
59
+ enabled: bool | None = None,
60
+ ) -> bool:
61
+ return _send(State.ERROR, msg=msg, title=title, app=app, id=id, stream=stream, enabled=enabled)
62
+
63
+
64
+ def blocked(
65
+ *,
66
+ kind: str | None = None,
67
+ msg: str | None = None,
68
+ title: str | None = None,
69
+ app: str | None = None,
70
+ id: str | None = None,
71
+ progress: int | None = None,
72
+ stream: IO[str] | None = None,
73
+ enabled: bool | None = None,
74
+ ) -> bool:
75
+ return _send(
76
+ State.BLOCKED,
77
+ kind=kind,
78
+ msg=msg,
79
+ title=title,
80
+ app=app,
81
+ id=id,
82
+ progress=progress,
83
+ stream=stream,
84
+ enabled=enabled,
85
+ )
86
+
87
+
88
+ def working(
89
+ *,
90
+ msg: str | None = None,
91
+ title: str | None = None,
92
+ app: str | None = None,
93
+ id: str | None = None,
94
+ progress: int | None = None,
95
+ stream: IO[str] | None = None,
96
+ enabled: bool | None = None,
97
+ ) -> "Job":
98
+ """Write ``working`` immediately and return a handle for later reports."""
99
+ job = Job(
100
+ app=app,
101
+ id=id,
102
+ title=title,
103
+ stream=sys.stderr if stream is None else stream,
104
+ enabled=enabled,
105
+ )
106
+ job.update(msg=msg, progress=progress)
107
+ return job
108
+
109
+
110
+ def clear(
111
+ *,
112
+ id: str | None = None,
113
+ stream: IO[str] | None = None,
114
+ enabled: bool | None = None,
115
+ ) -> bool:
116
+ return _send(State.CLEAR, id=id, stream=stream, enabled=enabled)
117
+
118
+
119
+ class Job:
120
+ """One task. Repeats ``app``, ``id``, and ``title`` because a report replaces the record."""
121
+
122
+ def __init__(
123
+ self,
124
+ *,
125
+ app: str | None,
126
+ id: str | None,
127
+ title: str | None,
128
+ stream: Any,
129
+ enabled: bool | None,
130
+ ) -> None:
131
+ self._app = app
132
+ self._id = id
133
+ self._title = title
134
+ self._stream = stream
135
+ self._enabled = enabled
136
+ self._last_state: State | None = None
137
+ self._last_bytes: str | None = None
138
+
139
+ def __enter__(self) -> "Job":
140
+ return self
141
+
142
+ def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: object) -> bool:
143
+ if self._last_state not in (State.WORKING, State.BLOCKED):
144
+ return False
145
+ if exc_type is None:
146
+ self.done()
147
+ elif issubclass(exc_type, SystemExit):
148
+ code = exc.code if isinstance(exc, SystemExit) else None
149
+ if code in (0, None):
150
+ self.done()
151
+ else:
152
+ self.error()
153
+ elif issubclass(exc_type, KeyboardInterrupt):
154
+ self.idle()
155
+ elif issubclass(exc_type, Exception):
156
+ self.error()
157
+ else:
158
+ # asyncio.CancelledError and other non-Exception BaseExceptions.
159
+ self.idle()
160
+ return False
161
+
162
+ def update(self, *, progress: int | None = None, msg: str | None = None) -> bool:
163
+ return self._emit(State.WORKING, progress=progress, msg=msg)
164
+
165
+ def blocked(
166
+ self,
167
+ *,
168
+ kind: str | None = None,
169
+ msg: str | None = None,
170
+ progress: int | None = None,
171
+ ) -> bool:
172
+ return self._emit(State.BLOCKED, kind=kind, msg=msg, progress=progress)
173
+
174
+ def done(self, *, msg: str | None = None) -> bool:
175
+ return self._emit(State.DONE, msg=msg)
176
+
177
+ def error(self, *, msg: str | None = None) -> bool:
178
+ return self._emit(State.ERROR, msg=msg)
179
+
180
+ def idle(self, *, msg: str | None = None) -> bool:
181
+ return self._emit(State.IDLE, msg=msg)
182
+
183
+ def _emit(
184
+ self,
185
+ state: str,
186
+ *,
187
+ msg: str | None = None,
188
+ kind: str | None = None,
189
+ progress: int | None = None,
190
+ ) -> bool:
191
+ sequence = encode(
192
+ state,
193
+ msg=msg,
194
+ title=self._title,
195
+ app=self._app,
196
+ id=self._id,
197
+ kind=kind,
198
+ progress=progress,
199
+ )
200
+ self._last_state = State(state)
201
+ if sequence == self._last_bytes:
202
+ return False
203
+ wrote = _write(sequence, self._stream, self._enabled)
204
+ if wrote:
205
+ self._last_bytes = sequence
206
+ return wrote
207
+
208
+
209
+ def _send(
210
+ state: str,
211
+ *,
212
+ msg: str | None = None,
213
+ title: str | None = None,
214
+ app: str | None = None,
215
+ id: str | None = None,
216
+ kind: str | None = None,
217
+ progress: int | None = None,
218
+ stream: IO[str] | None = None,
219
+ enabled: bool | None = None,
220
+ ) -> bool:
221
+ sequence = encode(
222
+ state,
223
+ msg=msg,
224
+ title=title,
225
+ app=app,
226
+ id=id,
227
+ kind=kind,
228
+ progress=progress,
229
+ )
230
+ return _write(sequence, sys.stderr if stream is None else stream, enabled)
231
+
232
+
233
+ class Detection(Enum):
234
+ SUPPORTED = "supported"
235
+ UNSUPPORTED = "unsupported"
236
+ UNKNOWN = "unknown"
237
+
238
+
239
+ _DEFAULT_STATES = frozenset(state.value for state in State if state is not State.CLEAR)
240
+ _DEFAULT_KINDS = frozenset(kind.value for kind in Kind)
241
+
242
+
243
+ @dataclass(frozen=True)
244
+ class Support:
245
+ result: Detection
246
+ states: frozenset[str]
247
+ kinds: frozenset[str]
248
+
249
+ @property
250
+ def supported(self) -> bool:
251
+ return self.result is Detection.SUPPORTED
252
+
253
+
254
+ def detect(*, timeout: float = 0.2) -> Support:
255
+ """Ask ``/dev/tty`` whether it supports OSC 7501.
256
+
257
+ Call this before reading input. Keystrokes typed during the wait can be
258
+ consumed, and a reply that arrives after the timeout can leak into later
259
+ input. Emit paths never call this.
260
+ """
261
+ try:
262
+ import select
263
+ import termios
264
+ except ImportError:
265
+ return _unknown()
266
+ try:
267
+ fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
268
+ except OSError:
269
+ return _unknown()
270
+ try:
271
+ try:
272
+ old = termios.tcgetattr(fd)
273
+ except termios.error:
274
+ return _unknown()
275
+ new = [old[0], old[1], old[2], old[3], old[4], old[5], list(old[6])]
276
+ new[3] &= ~(termios.ICANON | termios.ECHO | termios.ISIG | termios.IEXTEN)
277
+ new[0] &= ~(termios.IXON | termios.ICRNL)
278
+ new[6][termios.VMIN] = 0
279
+ new[6][termios.VTIME] = 1
280
+ termios.tcsetattr(fd, termios.TCSANOW, new)
281
+ try:
282
+ os.write(fd, query().encode("ascii"))
283
+ os.write(fd, b"\x1b[c")
284
+ reader = _ReplyReader()
285
+ deadline = time.monotonic() + timeout
286
+ while True:
287
+ remaining = deadline - time.monotonic()
288
+ if remaining <= 0:
289
+ break
290
+ ready, _, _ = select.select([fd], [], [], remaining)
291
+ if not ready:
292
+ break
293
+ chunk = os.read(fd, 256)
294
+ if not chunk:
295
+ break
296
+ reader.feed(chunk)
297
+ if reader._done:
298
+ break
299
+ return _support_from(reader)
300
+ finally:
301
+ termios.tcsetattr(fd, termios.TCSANOW, old)
302
+ finally:
303
+ os.close(fd)
304
+
305
+
306
+ def _unknown() -> Support:
307
+ return Support(Detection.UNKNOWN, frozenset(), frozenset())
308
+
309
+
310
+ def _support_from(reader: "_ReplyReader") -> Support:
311
+ if reader._result is Detection.SUPPORTED and reader.reply is not None:
312
+ parsed = parse_reply(reader.reply)
313
+ if parsed is not None:
314
+ return parsed
315
+ if reader._result is Detection.SUPPORTED:
316
+ return Support(Detection.SUPPORTED, _DEFAULT_STATES, _DEFAULT_KINDS)
317
+ return Support(reader._result, frozenset(), frozenset())
318
+
319
+
320
+ def query() -> str:
321
+ """The feature-detection query, ``OSC 7501 ; ? ST``."""
322
+ return "\x1b]7501;?\x1b\\"
323
+
324
+
325
+ def parse_reply(sequence: str) -> Support | None:
326
+ """Parse one support reply. Anything else, including DA1, returns ``None``."""
327
+ if sequence.endswith("\x1b\\"):
328
+ body = sequence[: -2]
329
+ elif sequence.endswith("\x07"):
330
+ body = sequence[: -1]
331
+ else:
332
+ return None
333
+ if not body.startswith("\x1b]7501;?"):
334
+ return None
335
+ rest = body[len("\x1b]7501;?") :]
336
+ if rest.startswith(":"):
337
+ rest = rest[1:]
338
+ elif rest != "":
339
+ return None
340
+
341
+ states: frozenset[str] | None = None
342
+ kinds: frozenset[str] | None = None
343
+ for pair in rest.split(":") if rest else ():
344
+ if "=" not in pair:
345
+ continue
346
+ key, value = pair.split("=", 1)
347
+ key = key.strip()
348
+ value = value.strip()
349
+ if key == "states":
350
+ states = _csv(value)
351
+ elif key == "kinds":
352
+ kinds = _csv(value)
353
+ return Support(
354
+ result=Detection.SUPPORTED,
355
+ states=_DEFAULT_STATES if states is None else states,
356
+ kinds=_DEFAULT_KINDS if kinds is None else kinds,
357
+ )
358
+
359
+
360
+ def _csv(value: str) -> frozenset[str]:
361
+ return frozenset(part.strip() for part in value.split(",") if part.strip())
362
+
363
+
364
+ class _ReplyReader:
365
+ """Streaming scan for a 7501 support reply and the following DA1 reply.
366
+
367
+ DA1 is a CSI whose first parameter byte is ``?`` and whose final byte is ``c``.
368
+ OSC ends on BEL or ST. CAN and SUB cancel. ESC restarts a sequence.
369
+ """
370
+
371
+ def __init__(self) -> None:
372
+ self._state = _GROUND
373
+ self._buf = bytearray()
374
+ self._overflow = False
375
+ self._csi_first = 0
376
+ self._result = Detection.UNKNOWN
377
+ self._done = False
378
+ self.reply: str | None = None
379
+
380
+ def feed(self, data: bytes) -> tuple[Detection, bool]:
381
+ for byte in data:
382
+ if self._done:
383
+ break
384
+ self._feed_byte(byte)
385
+ return self._result, self._done
386
+
387
+ def _feed_byte(self, byte: int) -> None:
388
+ if self._state == _GROUND:
389
+ if byte == _ESC:
390
+ self._state = _ESCAPE
391
+ return
392
+ if self._state == _ESCAPE:
393
+ self._after_esc(byte)
394
+ return
395
+ if self._state == _OSC:
396
+ if byte == _BEL:
397
+ self._end_osc(b"\x07")
398
+ elif byte == _ESC:
399
+ self._state = _OSC_ESC
400
+ elif byte in (_CAN, _SUB):
401
+ self._state = _GROUND
402
+ self._buf.clear()
403
+ self._overflow = False
404
+ elif len(self._buf) < _BODY_LIMIT:
405
+ self._buf.append(byte)
406
+ else:
407
+ self._overflow = True
408
+ return
409
+ if self._state == _OSC_ESC:
410
+ if byte == ord("\\"):
411
+ self._end_osc(b"\x1b\\")
412
+ else:
413
+ self._after_esc(byte)
414
+ return
415
+ if byte == _ESC:
416
+ self._state = _ESCAPE
417
+ elif byte in (_CAN, _SUB):
418
+ self._state = _GROUND
419
+ elif 0x40 <= byte <= 0x7E:
420
+ if byte == ord("c") and self._csi_first == ord("?"):
421
+ if self._result is Detection.UNKNOWN:
422
+ self._result = Detection.UNSUPPORTED
423
+ self._done = True
424
+ self._state = _GROUND
425
+ elif 0x20 <= byte <= 0x3F:
426
+ if self._csi_first == 0:
427
+ self._csi_first = byte
428
+ else:
429
+ self._state = _GROUND
430
+
431
+ def _after_esc(self, byte: int) -> None:
432
+ if byte == ord("]"):
433
+ self._buf.clear()
434
+ self._overflow = False
435
+ self._state = _OSC
436
+ elif byte == ord("["):
437
+ self._csi_first = 0
438
+ self._state = _CSI
439
+ elif byte == _ESC:
440
+ self._state = _ESCAPE
441
+ else:
442
+ self._state = _GROUND
443
+
444
+ def _end_osc(self, terminator: bytes) -> None:
445
+ self._state = _GROUND
446
+ body = bytes(self._buf)
447
+ self._buf.clear()
448
+ overflow = self._overflow
449
+ self._overflow = False
450
+ if overflow or self._result is not Detection.UNKNOWN or not body.startswith(_REPLY_PREFIX):
451
+ return
452
+ self._result = Detection.SUPPORTED
453
+ self.reply = "\x1b]" + body.decode("ascii") + terminator.decode("ascii")
454
+
455
+
456
+ def _write(sequence: str, stream: Any, enabled: bool | None) -> bool:
457
+ if enabled is None:
458
+ isatty = getattr(stream, "isatty", None)
459
+ enabled = bool(isatty()) if isatty is not None else False
460
+ if not enabled:
461
+ return False
462
+ with _lock:
463
+ stream.write(sequence)
464
+ flush = getattr(stream, "flush", None)
465
+ if flush is not None:
466
+ flush()
467
+ return True
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.5
2
+ Name: osc7501
3
+ Version: 1.0.0
4
+ Summary: Report terminal program status with OSC 7501.
5
+ Project-URL: Homepage, https://github.com/ysskrishna/osc7501
6
+ Project-URL: Repository, https://github.com/ysskrishna/osc7501.git
7
+ Project-URL: Issues, https://github.com/ysskrishna/osc7501/issues
8
+ Project-URL: Changelog, https://github.com/ysskrishna/osc7501/blob/main/CHANGELOG.md
9
+ Author-email: "Y. Siva Sai Krishna" <sivasaikrishnassk@gmail.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: cli,osc,osc7501,status,terminal
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: Terminals
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.11
26
+ Description-Content-Type: text/markdown
27
+
28
+ # OSC 7501
29
+
30
+
31
+ [![Python Version](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
32
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/ysskrishna/osc7501/blob/main/LICENSE)
33
+ ![Tests](https://github.com/ysskrishna/osc7501/actions/workflows/test.yml/badge.svg)
34
+ [![PyPI](https://img.shields.io/pypi/v/osc7501)](https://pypi.org/project/osc7501/)
35
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/osc7501?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=BLUE&left_text=downloads)](https://pepy.tech/projects/osc7501)
36
+
37
+ Report terminal program status with OSC 7501. Inspired by the [osc-status](https://github.com/jonahsnider/osc-status) npm package.
38
+
39
+ ## Features
40
+
41
+ - **States**: `idle`, `working`, `done`, `blocked`, `error`, and `clear`
42
+ - **One job**: a context manager that repeats `app`, `id`, and `title` and closes the job for you
43
+ - **Safe text**: control characters become spaces before base64, so one newline cannot drop the report
44
+ - **Optional probe**: ask the terminal whether it implements revision 0.4
45
+ - **Zero dependencies**: the library only uses the Python standard library
46
+ - **TTY gate**: reports are skipped when stderr is a pipe, unless you pass `enabled=True`
47
+
48
+ ## Why OSC 7501?
49
+
50
+ OSC 9;4 progress can say that a program is busy, at a percentage, or failed. It has no message and no way to say the program is waiting on the user. A window title is one free-form string.
51
+
52
+ OSC 7501 adds a state, a reason (`permission`, `question`, or `auth`), and a stable `app` name. The terminal decides how to show that. A terminal that does not know the sequence ignores it, so sending a report is safe.
53
+
54
+ Spec: [Program Status Protocol, revision 0.4](https://control-codes.page/proposals/osc-7501/) (2026-10-10).
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install osc7501
60
+ ```
61
+
62
+ ## Usage Examples
63
+
64
+ ### Helpers
65
+
66
+ ```python
67
+ from osc7501 import blocked, done, working
68
+
69
+ working(progress=40, msg="Building docs")
70
+ blocked(kind="permission", msg="Approve deploy?")
71
+ done(msg="Docs built")
72
+ ```
73
+
74
+ Each report replaces the terminal's record. Repeat every field you still want. `clear()` with no `id` removes every record in the terminal.
75
+
76
+ Reports go to stderr, and only when it is a terminal.
77
+
78
+ ### One job
79
+
80
+ ```python
81
+ from osc7501 import working
82
+
83
+ with working(app="make", msg="Building docs") as job:
84
+ job.blocked(kind="question", msg="Overwrite config.toml?")
85
+ job.done(msg="Docs built")
86
+ ```
87
+
88
+ Enter writes `working`. A clean exit writes `done` with no message. An exception writes `error`. `KeyboardInterrupt` writes `idle`. If you already called `job.done()` or `job.error()`, the exit writes nothing.
89
+
90
+ `msg` for `kind="auth"` is the prompt, never the secret.
91
+
92
+ ### Detection
93
+
94
+ ```python
95
+ from osc7501 import detect
96
+
97
+ print(detect().result)
98
+ ```
99
+
100
+ Call `detect()` before reading input. It can consume keystrokes typed during the wait. On Windows, or when `/dev/tty` cannot be opened, the result is `Detection.UNKNOWN`. Helpers never probe on their own.
101
+
102
+ ## Where reports show up
103
+
104
+ This list is from the [awesome-osc7501 matrix](https://github.com/hyeebeen/awesome-osc7501) dated 2026-10-11. It was not re-checked for this release.
105
+
106
+ Shows reports: Rex, TUIOS, termio, SwiftTerm, Termy, con-terminal, phux, thurm, wietty, Prowl.
107
+
108
+ Ignores reports: the Ghostty app (libghostty parses them; the app does not opt in), tmux (drops them unless DCS passthrough and `allow-passthrough` are on; this release does not wrap them), Kitty, WezTerm, Alacritty, iTerm2, Windows Terminal, VS Code, Zed, Warp, zellij.
109
+
110
+ ## API Reference
111
+
112
+ | Name | Description |
113
+ |------|-------------|
114
+ | `encode(state, *, msg, title, app, id, kind, progress) -> str` | Build one sequence. Does not write. |
115
+ | `working(*, msg, title, app, id, progress, stream, enabled) -> Job` | Write `working` and return a `Job`. |
116
+ | `Job.update(*, progress, msg) -> bool` | Write another `working` report. Skips a byte-identical repeat. |
117
+ | `Job.blocked(*, kind, msg, progress) -> bool` | Write `blocked`, repeating `app`, `id`, and `title`. |
118
+ | `Job.done(*, msg) -> bool` / `Job.error` / `Job.idle` | Write that state. |
119
+ | `idle`, `done`, `error`, `blocked`, `clear` | Write one report. Return `True` only when bytes were written. |
120
+ | `query() -> str` | The `OSC 7501 ; ? ST` query. |
121
+ | `parse_reply(sequence) -> Support \| None` | Parse one reply. Missing lists mean the five states and three kinds. |
122
+ | `detect(*, timeout=0.2) -> Support` | Probe `/dev/tty`. `Support.supported` is true only for `Detection.SUPPORTED`. |
123
+ | `State`, `Kind` | `StrEnum` values. Plain strings are accepted too. |
124
+
125
+ ## Learn More
126
+
127
+ - [Program Status Protocol (OSC 7501)](https://control-codes.page/proposals/osc-7501/)
128
+ - [Mitchell Hashimoto: A Terminal Protocol for Program Status](https://mitchellh.com/writing/program-status-osc7501)
129
+ - [awesome-osc7501](https://github.com/hyeebeen/awesome-osc7501)
130
+
131
+ ## Acknowledgments
132
+
133
+ - [Mitchell Hashimoto](https://github.com/mitchellh), for the specification
134
+ - [Jonah Snider](https://github.com/jonahsnider), for [osc-status](https://github.com/jonahsnider/osc-status)
135
+ - [Adam Duke](https://github.com/adamvduke), for [pst](https://github.com/adamvduke/pst)
136
+
137
+ ## Built and maintained by
138
+
139
+ osc7501 is built and maintained by:
140
+
141
+ | | Name | GitHub | Role |
142
+ |---|------|--------|------|
143
+ | <img src="https://github.com/ysskrishna.png?size=120" width="60" height="60" alt="Y. Siva Sai Krishna"> | **Y. Siva Sai Krishna** | [@ysskrishna](https://github.com/ysskrishna) | Creator |
144
+
145
+ ## Changelog
146
+
147
+ See [CHANGELOG.md](https://github.com/ysskrishna/osc7501/blob/main/CHANGELOG.md) for a detailed list of changes and version history.
148
+
149
+ ## Contributing
150
+
151
+ We welcome contributions! Please see our [Contributing Guide](https://github.com/ysskrishna/osc7501/blob/main/CONTRIBUTING.md) for details.
152
+
153
+ ## Support
154
+
155
+ If you find this library helpful:
156
+
157
+ - ⭐ Star the repository
158
+ - 🐛 Report issues
159
+ - 🔀 Submit pull requests
160
+ - 💝 [Sponsor on GitHub](https://github.com/sponsors/ysskrishna)
161
+
162
+ ## License
163
+
164
+ MIT © [Y. Siva Sai Krishna](https://github.com/ysskrishna) - see [LICENSE](https://github.com/ysskrishna/osc7501/blob/main/LICENSE) file for details.
165
+
166
+ ---
167
+
168
+ <p align="left">
169
+ <a href="https://github.com/ysskrishna">Author's GitHub</a> •
170
+ <a href="https://linkedin.com/in/ysskrishna">Author's LinkedIn</a> •
171
+ <a href="https://github.com/ysskrishna/osc7501/issues">Report Issues</a> •
172
+ <a href="https://pypi.org/project/osc7501/">Package on PyPI</a>
173
+ </p>
@@ -0,0 +1,8 @@
1
+ osc7501/__init__.py,sha256=b6E9XqBc0Vi-43TBxlPdOVdHi32C_NdYL2yVWsZiGjU,501
2
+ osc7501/encode.py,sha256=5r93P-V5rWoxiW7YRXpqC94j4TeHrutFffWDickT9u4,3794
3
+ osc7501/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ osc7501/terminal.py,sha256=WmdTxXrlARd3vzXBJRT9bjK7GbTizKdL6SFb5LAEUjc,13329
5
+ osc7501-1.0.0.dist-info/METADATA,sha256=IG2O_o8jwrK5yn2IZHADsy2I8vrdHkpMUwTjf_4RhLg,7655
6
+ osc7501-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ osc7501-1.0.0.dist-info/licenses/LICENSE,sha256=Szx3imxJ9EtUI6_eYtQa6jNMVK_1jhrbjMSaHOzIsVE,1076
8
+ osc7501-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Y. Siva Sai Krishna
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.