kuberich 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.
Files changed (120) hide show
  1. kuberich/__init__.py +1 -0
  2. kuberich/__main__.py +6 -0
  3. kuberich/adapters/__init__.py +1 -0
  4. kuberich/adapters/credentials.py +229 -0
  5. kuberich/adapters/editing.py +71 -0
  6. kuberich/adapters/emulator.py +258 -0
  7. kuberich/adapters/kubernetes.py +774 -0
  8. kuberich/adapters/mutations.py +197 -0
  9. kuberich/adapters/operations.py +72 -0
  10. kuberich/adapters/pty.py +107 -0
  11. kuberich/adapters/pty_child.py +25 -0
  12. kuberich/adapters/terminal.py +142 -0
  13. kuberich/adapters/transfer_files.py +392 -0
  14. kuberich/cli.py +419 -0
  15. kuberich/config/__init__.py +1 -0
  16. kuberich/config/catalog.py +184 -0
  17. kuberich/config/launch.py +58 -0
  18. kuberich/config/paths.py +51 -0
  19. kuberich/config/schema.py +165 -0
  20. kuberich/config/store.py +139 -0
  21. kuberich/diagnostics/__init__.py +1 -0
  22. kuberich/diagnostics/logging.py +132 -0
  23. kuberich/diagnostics/redaction.py +34 -0
  24. kuberich/domain/__init__.py +1 -0
  25. kuberich/domain/aggregate_logs.py +360 -0
  26. kuberich/domain/attach.py +113 -0
  27. kuberich/domain/connection_overrides.py +142 -0
  28. kuberich/domain/connections.py +106 -0
  29. kuberich/domain/containers.py +145 -0
  30. kuberich/domain/credential_helpers.py +157 -0
  31. kuberich/domain/custom.py +131 -0
  32. kuberich/domain/editing.py +162 -0
  33. kuberich/domain/exec_credentials.py +78 -0
  34. kuberich/domain/inspection.py +121 -0
  35. kuberich/domain/log_json.py +68 -0
  36. kuberich/domain/log_view.py +99 -0
  37. kuberich/domain/logs.py +167 -0
  38. kuberich/domain/mutations.py +260 -0
  39. kuberich/domain/namespaces.py +29 -0
  40. kuberich/domain/navigation.py +71 -0
  41. kuberich/domain/operations.py +278 -0
  42. kuberich/domain/pods.py +223 -0
  43. kuberich/domain/port_forwards.py +242 -0
  44. kuberich/domain/processes.py +205 -0
  45. kuberich/domain/proxies.py +102 -0
  46. kuberich/domain/registry.py +392 -0
  47. kuberich/domain/resources.py +281 -0
  48. kuberich/domain/shell.py +55 -0
  49. kuberich/domain/tables.py +167 -0
  50. kuberich/domain/targets.py +59 -0
  51. kuberich/domain/terminal.py +68 -0
  52. kuberich/domain/terminal_history.py +106 -0
  53. kuberich/domain/transfers.py +205 -0
  54. kuberich/domain/views.py +180 -0
  55. kuberich/domain/watches.py +215 -0
  56. kuberich/domain/workloads.py +184 -0
  57. kuberich/errors.py +25 -0
  58. kuberich/py.typed +0 -0
  59. kuberich/runtime.py +96 -0
  60. kuberich/security/__init__.py +1 -0
  61. kuberich/security/arguments.py +23 -0
  62. kuberich/security/controls.py +19 -0
  63. kuberich/security/presentation.py +19 -0
  64. kuberich/services/__init__.py +1 -0
  65. kuberich/services/access.py +32 -0
  66. kuberich/services/aggregate_logs.py +380 -0
  67. kuberich/services/attach.py +87 -0
  68. kuberich/services/commands.py +301 -0
  69. kuberich/services/delegation.py +91 -0
  70. kuberich/services/editing.py +154 -0
  71. kuberich/services/filtering.py +86 -0
  72. kuberich/services/inspection.py +89 -0
  73. kuberich/services/log_export.py +52 -0
  74. kuberich/services/logs.py +128 -0
  75. kuberich/services/mutations.py +249 -0
  76. kuberich/services/operations.py +309 -0
  77. kuberich/services/pods.py +89 -0
  78. kuberich/services/port_forwards.py +417 -0
  79. kuberich/services/processes.py +418 -0
  80. kuberich/services/resources.py +430 -0
  81. kuberich/services/sessions.py +112 -0
  82. kuberich/services/shell.py +94 -0
  83. kuberich/services/transfers.py +356 -0
  84. kuberich/services/watches.py +186 -0
  85. kuberich/services/workloads.py +268 -0
  86. kuberich/services/workspace.py +402 -0
  87. kuberich/ui/__init__.py +1 -0
  88. kuberich/ui/aggregate_logs.py +478 -0
  89. kuberich/ui/app.py +1970 -0
  90. kuberich/ui/chrome.py +346 -0
  91. kuberich/ui/commands.py +175 -0
  92. kuberich/ui/containers.py +229 -0
  93. kuberich/ui/contexts.py +108 -0
  94. kuberich/ui/custom.py +71 -0
  95. kuberich/ui/editing.py +201 -0
  96. kuberich/ui/handoff.py +85 -0
  97. kuberich/ui/inspection.py +215 -0
  98. kuberich/ui/kuberich.tcss +87 -0
  99. kuberich/ui/launch.py +45 -0
  100. kuberich/ui/log_body.py +440 -0
  101. kuberich/ui/logs.py +625 -0
  102. kuberich/ui/mutations.py +267 -0
  103. kuberich/ui/namespaces.py +128 -0
  104. kuberich/ui/operations.py +260 -0
  105. kuberich/ui/pods.py +219 -0
  106. kuberich/ui/port_forwards.py +304 -0
  107. kuberich/ui/presentation.py +194 -0
  108. kuberich/ui/scopes.py +67 -0
  109. kuberich/ui/shutdown.py +45 -0
  110. kuberich/ui/standard.py +258 -0
  111. kuberich/ui/style_cache.py +66 -0
  112. kuberich/ui/terminal.py +435 -0
  113. kuberich/ui/transfers.py +192 -0
  114. kuberich/ui/workloads.py +196 -0
  115. kuberich-0.1.0.dist-info/METADATA +159 -0
  116. kuberich-0.1.0.dist-info/RECORD +120 -0
  117. kuberich-0.1.0.dist-info/WHEEL +4 -0
  118. kuberich-0.1.0.dist-info/entry_points.txt +3 -0
  119. kuberich-0.1.0.dist-info/licenses/LICENSE +202 -0
  120. kuberich-0.1.0.dist-info/licenses/NOTICE +32 -0
kuberich/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """KubeRich's Kubernetes terminal application package."""
kuberich/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Run KubeRich with ``python -m kuberich``."""
2
+
3
+ from kuberich.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Owned Kubernetes and local process boundaries."""
@@ -0,0 +1,229 @@
1
+ """Noninteractive, bounded exec credentials with owned process cleanup."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import shutil
7
+ import signal
8
+ from collections.abc import Awaitable, Callable, Mapping
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from kuberich.config.catalog import Entry, mapping, text
14
+ from kuberich.domain.connections import ConnectionProblem, ConnectionState
15
+ from kuberich.domain.credential_helpers import (
16
+ AZURE_LOGIN_REQUIRED,
17
+ azure_login_mode,
18
+ azure_prompt,
19
+ helper_failure,
20
+ helper_start_failure,
21
+ is_azure_helper,
22
+ is_eks_helper,
23
+ )
24
+ from kuberich.domain.exec_credentials import parse_credentials
25
+ from kuberich.domain.processes import ProcessCommand, ProcessMode, ProcessPurpose
26
+ from kuberich.errors import AppError
27
+ from kuberich.runtime import external_environment, signal_process_group
28
+
29
+
30
+ def auth_problem(message: str) -> ConnectionProblem:
31
+ return ConnectionProblem(ConnectionState.AUTH_ERROR, message)
32
+
33
+
34
+ async def _drain(stream: asyncio.StreamReader, limit: int, *, azure: bool = False) -> bytes:
35
+ output = bytearray()
36
+ while chunk := await stream.read(16384):
37
+ output.extend(chunk)
38
+ if len(output) > limit:
39
+ raise auth_problem("Credential helper output exceeds its size limit.")
40
+ if azure and azure_prompt(output):
41
+ raise auth_problem(AZURE_LOGIN_REQUIRED)
42
+ return bytes(output)
43
+
44
+
45
+ async def _execute(
46
+ argv: list[str], environment: dict[str, str], directory: Path, timeout: float
47
+ ) -> bytes:
48
+ try:
49
+ spawning = asyncio.create_task(
50
+ asyncio.create_subprocess_exec(
51
+ *argv,
52
+ env=external_environment(environment),
53
+ cwd=directory,
54
+ stdin=asyncio.subprocess.DEVNULL,
55
+ stdout=asyncio.subprocess.PIPE,
56
+ stderr=asyncio.subprocess.PIPE,
57
+ start_new_session=True,
58
+ )
59
+ )
60
+ try:
61
+ process = await asyncio.shield(spawning)
62
+ except asyncio.CancelledError:
63
+ process = await spawning
64
+ await signal_process_group(process.pid, signal.SIGKILL)
65
+ await process.wait()
66
+ raise
67
+ except OSError as error:
68
+ raise auth_problem(
69
+ helper_start_failure(argv, missing=isinstance(error, FileNotFoundError))
70
+ ) from None
71
+ assert process.stdout is not None and process.stderr is not None
72
+ readers = [
73
+ asyncio.create_task(_drain(process.stdout, 1024 * 1024)),
74
+ asyncio.create_task(_drain(process.stderr, 65536, azure=is_azure_helper(argv))),
75
+ ]
76
+ try:
77
+ async with asyncio.timeout(timeout):
78
+ stdout, stderr = await asyncio.gather(*readers)
79
+ if await process.wait() != 0:
80
+ raise auth_problem(helper_failure(argv, stderr))
81
+ return stdout
82
+ except TimeoutError:
83
+ raise auth_problem(
84
+ "Credential helper timed out. Complete provider login and retry."
85
+ ) from None
86
+ finally:
87
+ # Kill the owned process group even if the direct child has exited:
88
+ # descendants can still hold the pipe descriptors open.
89
+ await signal_process_group(process.pid, signal.SIGKILL)
90
+ for reader in readers:
91
+ reader.cancel()
92
+ await asyncio.gather(*readers, return_exceptions=True)
93
+ await process.wait()
94
+
95
+
96
+ class ExecToken:
97
+ """Session-local cache. Never logs helper output or inherits a terminal input."""
98
+
99
+ def __init__(self, entry: Entry, cluster: dict[str, Any], timeout: float) -> None:
100
+ self.entry = entry
101
+ self.cluster = cluster
102
+ self.timeout = timeout
103
+ self.cached: str | None = None
104
+ self.certificate: tuple[str, str] | None = None
105
+ self.expiration: datetime | None = None
106
+ self.lock = asyncio.Lock()
107
+ self.revision = 0
108
+ self.environment = dict(os.environ)
109
+ self.eks = False
110
+ self.azure = False
111
+ self.command: str | None = None
112
+
113
+ def invalidate(self, rejected_revision: int | None = None) -> None:
114
+ # A delayed 401 for an older request must not erase an already refreshed
115
+ # token. This synchronous comparison contains no scheduling boundary.
116
+ if rejected_revision is None or self.revision == rejected_revision:
117
+ self.cached = None
118
+ self.certificate = None
119
+
120
+ def delegated_environment(self, inherited: Mapping[str, str]) -> dict[str, str]:
121
+ # Freeze inherited helper identity, including previously absent variables.
122
+ # The command builder pins the private KUBECONFIG independently.
123
+ return dict(self.environment)
124
+
125
+ def invocation(self, *, interactive: bool = False) -> ProcessCommand:
126
+ try:
127
+ spec = mapping(self.entry.data)
128
+ version = text(spec.get("apiVersion"))
129
+ if version not in {
130
+ "client.authentication.k8s.io/v1",
131
+ "client.authentication.k8s.io/v1beta1",
132
+ }:
133
+ raise auth_problem("Unsupported exec credential API version. Use v1 or v1beta1.")
134
+ mode = spec.get("interactiveMode", None if version.endswith("/v1") else "IfAvailable")
135
+ if mode not in {"Never", "IfAvailable", "Always"}:
136
+ raise auth_problem(
137
+ "Exec v1 requires interactiveMode: Never, IfAvailable, or Always."
138
+ )
139
+ command = text(spec.get("command"))
140
+ if "/" in command and not Path(command).is_absolute():
141
+ command = str(self.entry.directory / command)
142
+ args = spec.get("args", [])
143
+ if args is None:
144
+ args = []
145
+ if not isinstance(args, list) or len(args) > 256:
146
+ raise auth_problem("Credential helper args must be a list of at most 256 strings.")
147
+ argv = [command, *(text(arg) for arg in args)]
148
+ environment = external_environment(self.environment)
149
+ variables = spec.get("env", [])
150
+ if variables is None:
151
+ variables = []
152
+ if not isinstance(variables, list) or len(variables) > 256:
153
+ raise auth_problem("Credential helper env must be a list of at most 256 entries.")
154
+ for value in variables:
155
+ variable = mapping(value)
156
+ name = text(variable.get("name"))
157
+ if "=" in name:
158
+ raise auth_problem("Credential helper environment names cannot contain '='.")
159
+ value = variable.get("value")
160
+ environment[name] = "" if value == "" else text(value)
161
+ self.eks, self.azure = is_eks_helper(argv), is_azure_helper(argv)
162
+ if not interactive and mode == "Always":
163
+ raise auth_problem(
164
+ AZURE_LOGIN_REQUIRED
165
+ if self.azure
166
+ else "This helper requires terminal input. Use explicit :login or log in externally, then retry."
167
+ )
168
+ if (
169
+ self.azure
170
+ and not interactive
171
+ and azure_login_mode(argv, environment) == "interactive"
172
+ ):
173
+ raise auth_problem(AZURE_LOGIN_REQUIRED)
174
+ provide = spec.get("provideClusterInfo", False)
175
+ if type(provide) is not bool:
176
+ raise auth_problem("provideClusterInfo must be true or false.")
177
+ available = interactive and mode != "Never"
178
+ info: dict[str, Any] = {"interactive": available}
179
+ if provide:
180
+ info["cluster"] = self.cluster
181
+ environment["KUBERNETES_EXEC_INFO"] = json.dumps(
182
+ {"apiVersion": version, "kind": "ExecCredential", "spec": info}
183
+ )
184
+ if "/" not in command:
185
+ search_path = os.pathsep.join(
186
+ str(self.entry.directory / path) if not Path(path).is_absolute() else path
187
+ for path in environment.get("PATH", os.defpath).split(os.pathsep)
188
+ )
189
+ argv[0] = shutil.which(command, path=search_path) or command
190
+ return ProcessCommand(
191
+ tuple(argv),
192
+ tuple(environment.items()),
193
+ self.entry.directory,
194
+ ProcessMode.FOREGROUND if interactive else ProcessMode.CAPTURE,
195
+ ProcessPurpose.AUTHENTICATE,
196
+ terminal_input=available,
197
+ )
198
+ except (AppError, ValueError, UnicodeError, RecursionError):
199
+ raise auth_problem(
200
+ "Invalid credential helper configuration or response. Check its ExecCredential contract."
201
+ ) from None
202
+
203
+ def accept(self, output: bytes, command: ProcessCommand) -> str | None:
204
+ try:
205
+ version = json.loads(dict(command.environment)["KUBERNETES_EXEC_INFO"])["apiVersion"]
206
+ result = parse_credentials(output, version, datetime.now(UTC))
207
+ self.cached, self.certificate = result.token, result.certificate
208
+ self.expiration, self.command = result.expiration, command.argv[0]
209
+ self.revision += 1
210
+ return result.token
211
+ except (AppError, ValueError, UnicodeError, RecursionError):
212
+ raise auth_problem(
213
+ "Invalid credential helper configuration or response. Check its ExecCredential contract."
214
+ ) from None
215
+
216
+ async def token(self) -> str | None:
217
+ async with self.lock:
218
+ if (self.cached is not None or self.certificate is not None) and (
219
+ self.expiration is None or self.expiration > datetime.now(UTC)
220
+ ):
221
+ return self.cached
222
+ command = self.invocation()
223
+ output = await _execute(
224
+ list(command.argv), dict(command.environment), command.directory, self.timeout
225
+ )
226
+ return self.accept(output, command)
227
+
228
+
229
+ CredentialLogin = Callable[[ExecToken], Awaitable[str | None]]
@@ -0,0 +1,71 @@
1
+ """Private owned editor files, bounded no-follow reads and drained cleanup."""
2
+
3
+ import asyncio
4
+ import os
5
+ import stat
6
+ import tempfile
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+
10
+ from kuberich.domain.editing import MAX_EDIT_BYTES
11
+ from kuberich.errors import AppError, ExitCode
12
+
13
+
14
+ async def _drain[T](task: asyncio.Future[T]) -> None:
15
+ while not task.done():
16
+ try:
17
+ await asyncio.shield(task)
18
+ except asyncio.CancelledError:
19
+ continue
20
+ task.result()
21
+
22
+
23
+ async def file_worker[T](
24
+ operation: Callable[[], T], *, discard: Callable[[T], None] | None = None
25
+ ) -> T:
26
+ """Cancellation waits for filesystem ownership; newly created files are discarded."""
27
+ task = asyncio.create_task(asyncio.to_thread(operation))
28
+ try:
29
+ return await asyncio.shield(task)
30
+ except asyncio.CancelledError:
31
+ await _drain(asyncio.gather(task, return_exceptions=True))
32
+ if discard is not None and not task.cancelled() and task.exception() is None:
33
+ await _drain(asyncio.create_task(asyncio.to_thread(discard, task.result())))
34
+ raise
35
+
36
+
37
+ class ManifestFile:
38
+ def __init__(self, data: bytes) -> None:
39
+ self.directory = tempfile.TemporaryDirectory(prefix="kuberich-edit-")
40
+ self.path = Path(self.directory.name) / "manifest.yaml"
41
+ try:
42
+ descriptor = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
43
+ try:
44
+ stream = os.fdopen(descriptor, "wb")
45
+ except BaseException:
46
+ os.close(descriptor)
47
+ raise
48
+ with stream:
49
+ stream.write(data)
50
+ except BaseException:
51
+ self.directory.cleanup()
52
+ raise
53
+
54
+ def read(self) -> bytes:
55
+ descriptor = os.open(self.path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
56
+ try:
57
+ info = os.fstat(descriptor)
58
+ if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_EDIT_BYTES:
59
+ raise AppError("Editor output must be a regular file of at most 1 MiB.")
60
+ with os.fdopen(descriptor, "rb", closefd=False) as stream:
61
+ data = stream.read(MAX_EDIT_BYTES + 1)
62
+ if len(data) > MAX_EDIT_BYTES:
63
+ raise AppError("Editor output exceeds 1 MiB.")
64
+ return data
65
+ except OSError:
66
+ raise AppError("Cannot read the private editor output.", ExitCode.LOCAL_IO) from None
67
+ finally:
68
+ os.close(descriptor)
69
+
70
+ def close(self) -> None:
71
+ self.directory.cleanup()
@@ -0,0 +1,258 @@
1
+ """Pinned Pyte adapter; remote terminal protocol never reaches the host driver."""
2
+
3
+ import codecs
4
+ import re
5
+ import unicodedata
6
+ from collections.abc import Callable
7
+ from typing import cast
8
+
9
+ import pyte
10
+ from pyte.modes import DECOM
11
+ from pyte.screens import Char, Screen
12
+
13
+ from kuberich.domain.terminal import terminal_size
14
+ from kuberich.domain.terminal_history import TerminalCell, TerminalHistory, TerminalRow
15
+
16
+ # Controls has already bounded/framed every escape. Dispatch each frame separately
17
+ # so a rejected Pyte parameter list cannot discard the later text in that read.
18
+ _FRAMES = re.compile(r"\x1b(?:\[[0-9;? >]*[@-~]|[()%#].|.)|[^\x1b]+", re.DOTALL)
19
+
20
+
21
+ class _Controls:
22
+ """Frame bounded escape sequences and discard all control strings, including OSC52."""
23
+
24
+ def __init__(self) -> None:
25
+ self.state = "text"
26
+ self.sequence = ""
27
+
28
+ def feed(self, text: str) -> str:
29
+ output: list[str] = []
30
+ for char in text:
31
+ if self.state == "string":
32
+ if char in "\x07\x9c":
33
+ self.state = "text"
34
+ elif char == "\x1b":
35
+ self.state = "string_escape"
36
+ elif self.state == "string_escape":
37
+ self.state = "text" if char == "\\" else "string"
38
+ elif char == "\x1b":
39
+ self.sequence, self.state = char, "escape"
40
+ elif self.state == "escape":
41
+ if char in "]PX^_":
42
+ self.sequence, self.state = "", "string"
43
+ elif char == "[":
44
+ self.sequence += char
45
+ self.state = "csi"
46
+ elif char in "()%#":
47
+ self.sequence += char
48
+ self.state = "charset"
49
+ else:
50
+ output.append(self.sequence + char)
51
+ self.sequence, self.state = "", "text"
52
+ elif self.state == "charset":
53
+ output.append(self.sequence + char)
54
+ self.sequence, self.state = "", "text"
55
+ elif self.state in {"csi", "discard_csi"}:
56
+ if "@" <= char <= "~":
57
+ if self.state == "csi":
58
+ output.append(self.sequence + char)
59
+ self.sequence, self.state = "", "text"
60
+ elif char in "0123456789;? >" and len(self.sequence) < 128:
61
+ self.sequence += char
62
+ else:
63
+ self.sequence, self.state = "", "discard_csi"
64
+ elif char in "\x90\x98\x9d\x9e\x9f":
65
+ self.state = "string"
66
+ elif char == "\x9b":
67
+ self.sequence, self.state = "\x1b[", "csi"
68
+ elif char in "\x07\b\t\n\v\f\r" or char.isprintable():
69
+ output.append(char)
70
+ return "".join(output)
71
+
72
+
73
+ class _Screen(Screen):
74
+ def __init__(self, width: int, height: int, reply: Callable[[bytes], None]) -> None:
75
+ super().__init__(width, height)
76
+ self.reply = reply
77
+ self.history: TerminalHistory | None = None
78
+ self._row_cells: dict[Char, TerminalCell] = {}
79
+
80
+ def _row_cell(self, char: Char) -> TerminalCell:
81
+ cell = self._row_cells.get(char)
82
+ if cell is None:
83
+ if len(self._row_cells) >= 1024:
84
+ self._row_cells.clear()
85
+ cell = TerminalCell(
86
+ char.data,
87
+ char.fg,
88
+ char.bg,
89
+ char.bold,
90
+ char.italics,
91
+ char.underscore,
92
+ char.strikethrough,
93
+ char.reverse,
94
+ )
95
+ self._row_cells[char] = cell
96
+ return cell
97
+
98
+ def row(self, index: int) -> TerminalRow:
99
+ cells = tuple(
100
+ self._row_cell(char) for x in range(self.columns) for char in (self.buffer[index][x],)
101
+ )
102
+ return TerminalRow(cells, "".join(cell.data for cell in cells).rstrip())
103
+
104
+ def index(self) -> None:
105
+ top, bottom = self.margins or (0, self.lines - 1)
106
+ if (
107
+ self.history is not None
108
+ and self.cursor.y == bottom
109
+ and top == 0
110
+ and bottom == self.lines - 1
111
+ ):
112
+ self.history.append(self.row(0))
113
+ super().index()
114
+
115
+ def write_process_input(self, data: str) -> None:
116
+ self.reply(data.encode("ascii"))
117
+
118
+ def report_device_status(self, mode: int, *, private: bool = False) -> None:
119
+ if mode == 6:
120
+ row = self.cursor.y + 1
121
+ if DECOM in self.mode and self.margins is not None:
122
+ row -= self.margins.top
123
+ prefix = "\x1b[?" if private else "\x1b["
124
+ self.write_process_input(f"{prefix}{row};{self.cursor.x + 1}R")
125
+ elif not private:
126
+ super().report_device_status(mode)
127
+
128
+ def resize(self, lines: int | None = None, columns: int | None = None) -> None:
129
+ height = lines or self.lines
130
+ width = columns or self.columns
131
+ if height < self.lines:
132
+ # Trim the bottom first. Scroll only far enough to retain the live
133
+ # cursor when it would fall below the new viewport.
134
+ shift = max(0, self.cursor.y - height + 1)
135
+ if self.history is not None:
136
+ for index in range(shift):
137
+ self.history.append(self.row(index))
138
+ retained = {
139
+ row - shift: cells
140
+ for row, cells in self.buffer.items()
141
+ if shift <= row < shift + height
142
+ }
143
+ self.buffer.clear()
144
+ self.buffer.update(retained)
145
+ self.cursor.y -= shift
146
+ self.lines = height
147
+ self.set_margins()
148
+ self.dirty.update(range(height))
149
+ super().resize(lines=height, columns=width)
150
+
151
+ def save_cursor(self) -> None:
152
+ # DEC defines one saved cursor; an unbounded stack is unnecessary here.
153
+ self.savepoints.clear()
154
+ super().save_cursor()
155
+
156
+ def draw(self, data: str) -> None:
157
+ if not any(unicodedata.combining(char) for char in data):
158
+ super().draw(data)
159
+ return
160
+ for char in data:
161
+ if unicodedata.combining(char):
162
+ x, y = self.cursor.x - 1, self.cursor.y
163
+ if x < 0:
164
+ x, y = self.columns - 1, y - 1
165
+ if y >= 0 and len(self.buffer[y][x].data) >= 32:
166
+ continue
167
+ super().draw(char)
168
+
169
+
170
+ class _Router:
171
+ """Pyte's documented duck-typed event listener, routing cached callbacks dynamically."""
172
+
173
+ def __init__(self, model: "TerminalModel") -> None:
174
+ self.model = model
175
+
176
+ def __getattr__(self, event: str) -> Callable[..., None]:
177
+ if event not in pyte.Stream.events:
178
+ raise AttributeError(event)
179
+
180
+ def dispatch(*args: object, **kwargs: object) -> None:
181
+ callback: Callable[..., None] = getattr(self.model.screen, event)
182
+ callback(*args, **kwargs)
183
+
184
+ return dispatch
185
+
186
+ def set_mode(self, *modes: int, **kwargs: bool) -> None:
187
+ self._mode(True, modes, kwargs.get("private", False))
188
+
189
+ def reset_mode(self, *modes: int, **kwargs: bool) -> None:
190
+ self._mode(False, modes, kwargs.get("private", False))
191
+
192
+ def reset(self) -> None:
193
+ self.model.normal.reset()
194
+ self.model.secondary.reset()
195
+ self.model.screen = self.model.normal
196
+ self.model.application_cursor = False
197
+ self.model.bracketed_paste = False
198
+
199
+ def _mode(self, enabled: bool, modes: tuple[int, ...], private: bool) -> None:
200
+ for mode in modes:
201
+ if private and mode in {47, 1047, 1049}:
202
+ self.model.alternate(enabled, clear=mode != 47)
203
+ elif private and mode == 2004:
204
+ self.model.bracketed_paste = enabled
205
+ elif private and mode == 1:
206
+ self.model.application_cursor = enabled
207
+ elif (private and mode in {6, 7, 25}) or (not private and mode in {4, 20}):
208
+ callback = self.model.screen.set_mode if enabled else self.model.screen.reset_mode
209
+ callback(mode, private=private)
210
+
211
+
212
+ class TerminalModel:
213
+ def __init__(self, width: int, height: int, reply: Callable[[bytes], None]) -> None:
214
+ width, height = terminal_size(width, height)
215
+ self.normal = _Screen(width, height, reply)
216
+ self.history = TerminalHistory()
217
+ self.normal.history = self.history
218
+ self.secondary = _Screen(width, height, reply)
219
+ self.screen = self.normal
220
+ self.application_cursor = False
221
+ self.bracketed_paste = False
222
+ self.controls = _Controls()
223
+ self.decoder = codecs.getincrementaldecoder("utf-8")("replace")
224
+ # Stream checks every documented event; the adapter supplies them through routing.
225
+ self.stream = pyte.Stream(cast(Screen, _Router(self)))
226
+
227
+ def review_rows(self) -> tuple[TerminalRow, ...]:
228
+ return (*self.history.rows, *(self.normal.row(y) for y in range(self.normal.lines)))
229
+
230
+ def clear_history(self) -> None:
231
+ self.history.clear()
232
+ self.normal._row_cells.clear()
233
+ self.secondary._row_cells.clear()
234
+
235
+ def alternate(self, enabled: bool, *, clear: bool) -> None:
236
+ if enabled and self.screen is self.normal:
237
+ if clear:
238
+ self.secondary.reset()
239
+ self.screen = self.secondary
240
+ elif not enabled:
241
+ self.screen = self.normal
242
+
243
+ def resize(self, width: int, height: int) -> None:
244
+ width, height = terminal_size(width, height)
245
+ for screen in (self.normal, self.secondary):
246
+ screen.resize(lines=height, columns=width)
247
+ screen.ensure_hbounds()
248
+ screen.ensure_vbounds()
249
+
250
+ def feed(self, data: bytes) -> None:
251
+ value = self.controls.feed(self.decoder.decode(data))
252
+ for frame in _FRAMES.finditer(value):
253
+ try:
254
+ self.stream.feed(frame.group())
255
+ except (ValueError, IndexError, TypeError):
256
+ # Pyte resets its parser after a dispatch error. Continue with
257
+ # the next frame instead of losing the rest of this PTY read.
258
+ continue