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.
- kuberich/__init__.py +1 -0
- kuberich/__main__.py +6 -0
- kuberich/adapters/__init__.py +1 -0
- kuberich/adapters/credentials.py +229 -0
- kuberich/adapters/editing.py +71 -0
- kuberich/adapters/emulator.py +258 -0
- kuberich/adapters/kubernetes.py +774 -0
- kuberich/adapters/mutations.py +197 -0
- kuberich/adapters/operations.py +72 -0
- kuberich/adapters/pty.py +107 -0
- kuberich/adapters/pty_child.py +25 -0
- kuberich/adapters/terminal.py +142 -0
- kuberich/adapters/transfer_files.py +392 -0
- kuberich/cli.py +419 -0
- kuberich/config/__init__.py +1 -0
- kuberich/config/catalog.py +184 -0
- kuberich/config/launch.py +58 -0
- kuberich/config/paths.py +51 -0
- kuberich/config/schema.py +165 -0
- kuberich/config/store.py +139 -0
- kuberich/diagnostics/__init__.py +1 -0
- kuberich/diagnostics/logging.py +132 -0
- kuberich/diagnostics/redaction.py +34 -0
- kuberich/domain/__init__.py +1 -0
- kuberich/domain/aggregate_logs.py +360 -0
- kuberich/domain/attach.py +113 -0
- kuberich/domain/connection_overrides.py +142 -0
- kuberich/domain/connections.py +106 -0
- kuberich/domain/containers.py +145 -0
- kuberich/domain/credential_helpers.py +157 -0
- kuberich/domain/custom.py +131 -0
- kuberich/domain/editing.py +162 -0
- kuberich/domain/exec_credentials.py +78 -0
- kuberich/domain/inspection.py +121 -0
- kuberich/domain/log_json.py +68 -0
- kuberich/domain/log_view.py +99 -0
- kuberich/domain/logs.py +167 -0
- kuberich/domain/mutations.py +260 -0
- kuberich/domain/namespaces.py +29 -0
- kuberich/domain/navigation.py +71 -0
- kuberich/domain/operations.py +278 -0
- kuberich/domain/pods.py +223 -0
- kuberich/domain/port_forwards.py +242 -0
- kuberich/domain/processes.py +205 -0
- kuberich/domain/proxies.py +102 -0
- kuberich/domain/registry.py +392 -0
- kuberich/domain/resources.py +281 -0
- kuberich/domain/shell.py +55 -0
- kuberich/domain/tables.py +167 -0
- kuberich/domain/targets.py +59 -0
- kuberich/domain/terminal.py +68 -0
- kuberich/domain/terminal_history.py +106 -0
- kuberich/domain/transfers.py +205 -0
- kuberich/domain/views.py +180 -0
- kuberich/domain/watches.py +215 -0
- kuberich/domain/workloads.py +184 -0
- kuberich/errors.py +25 -0
- kuberich/py.typed +0 -0
- kuberich/runtime.py +96 -0
- kuberich/security/__init__.py +1 -0
- kuberich/security/arguments.py +23 -0
- kuberich/security/controls.py +19 -0
- kuberich/security/presentation.py +19 -0
- kuberich/services/__init__.py +1 -0
- kuberich/services/access.py +32 -0
- kuberich/services/aggregate_logs.py +380 -0
- kuberich/services/attach.py +87 -0
- kuberich/services/commands.py +301 -0
- kuberich/services/delegation.py +91 -0
- kuberich/services/editing.py +154 -0
- kuberich/services/filtering.py +86 -0
- kuberich/services/inspection.py +89 -0
- kuberich/services/log_export.py +52 -0
- kuberich/services/logs.py +128 -0
- kuberich/services/mutations.py +249 -0
- kuberich/services/operations.py +309 -0
- kuberich/services/pods.py +89 -0
- kuberich/services/port_forwards.py +417 -0
- kuberich/services/processes.py +418 -0
- kuberich/services/resources.py +430 -0
- kuberich/services/sessions.py +112 -0
- kuberich/services/shell.py +94 -0
- kuberich/services/transfers.py +356 -0
- kuberich/services/watches.py +186 -0
- kuberich/services/workloads.py +268 -0
- kuberich/services/workspace.py +402 -0
- kuberich/ui/__init__.py +1 -0
- kuberich/ui/aggregate_logs.py +478 -0
- kuberich/ui/app.py +1970 -0
- kuberich/ui/chrome.py +346 -0
- kuberich/ui/commands.py +175 -0
- kuberich/ui/containers.py +229 -0
- kuberich/ui/contexts.py +108 -0
- kuberich/ui/custom.py +71 -0
- kuberich/ui/editing.py +201 -0
- kuberich/ui/handoff.py +85 -0
- kuberich/ui/inspection.py +215 -0
- kuberich/ui/kuberich.tcss +87 -0
- kuberich/ui/launch.py +45 -0
- kuberich/ui/log_body.py +440 -0
- kuberich/ui/logs.py +625 -0
- kuberich/ui/mutations.py +267 -0
- kuberich/ui/namespaces.py +128 -0
- kuberich/ui/operations.py +260 -0
- kuberich/ui/pods.py +219 -0
- kuberich/ui/port_forwards.py +304 -0
- kuberich/ui/presentation.py +194 -0
- kuberich/ui/scopes.py +67 -0
- kuberich/ui/shutdown.py +45 -0
- kuberich/ui/standard.py +258 -0
- kuberich/ui/style_cache.py +66 -0
- kuberich/ui/terminal.py +435 -0
- kuberich/ui/transfers.py +192 -0
- kuberich/ui/workloads.py +196 -0
- kuberich-0.1.0.dist-info/METADATA +159 -0
- kuberich-0.1.0.dist-info/RECORD +120 -0
- kuberich-0.1.0.dist-info/WHEEL +4 -0
- kuberich-0.1.0.dist-info/entry_points.txt +3 -0
- kuberich-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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 @@
|
|
|
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
|