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 +35 -0
- osc7501/encode.py +118 -0
- osc7501/py.typed +0 -0
- osc7501/terminal.py +467 -0
- osc7501-1.0.0.dist-info/METADATA +173 -0
- osc7501-1.0.0.dist-info/RECORD +8 -0
- osc7501-1.0.0.dist-info/WHEEL +4 -0
- osc7501-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|
+
[](https://www.python.org/downloads/)
|
|
32
|
+
[](https://github.com/ysskrishna/osc7501/blob/main/LICENSE)
|
|
33
|
+

|
|
34
|
+
[](https://pypi.org/project/osc7501/)
|
|
35
|
+
[](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,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.
|