omdev 0.0.0.dev500__py3-none-any.whl → 0.0.0.dev509__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.
Potentially problematic release.
This version of omdev might be problematic. Click here for more details.
- omdev/.omlish-manifests.json +2 -2
- omdev/__about__.py +2 -2
- omdev/cache/data/specs.py +1 -1
- omdev/cexts/cmake.py +41 -1
- omdev/imgur.py +2 -2
- omdev/markdown/incparse.py +392 -0
- omdev/py/tools/pipdepup.py +16 -0
- omdev/pyproject/cli.py +1 -35
- omdev/pyproject/configs.py +1 -1
- omdev/pyproject/tools/buildrs.py +85 -0
- omdev/pyproject/tools/pyversions.py +47 -0
- omdev/pyproject/versions.py +40 -0
- omdev/scripts/ci.py +8 -1
- omdev/scripts/interp.py +8 -1
- omdev/scripts/lib/inject.py +8 -1
- omdev/scripts/pyproject.py +66 -39
- omdev/tools/sqlrepl.py +189 -78
- omdev/tui/rich/markdown2.py +219 -18
- {omdev-0.0.0.dev500.dist-info → omdev-0.0.0.dev509.dist-info}/METADATA +6 -6
- {omdev-0.0.0.dev500.dist-info → omdev-0.0.0.dev509.dist-info}/RECORD +24 -21
- {omdev-0.0.0.dev500.dist-info → omdev-0.0.0.dev509.dist-info}/WHEEL +0 -0
- {omdev-0.0.0.dev500.dist-info → omdev-0.0.0.dev509.dist-info}/entry_points.txt +0 -0
- {omdev-0.0.0.dev500.dist-info → omdev-0.0.0.dev509.dist-info}/licenses/LICENSE +0 -0
- {omdev-0.0.0.dev500.dist-info → omdev-0.0.0.dev509.dist-info}/top_level.txt +0 -0
omdev/tools/sqlrepl.py
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TODO:
|
|
3
|
+
- sqlite
|
|
4
|
+
- unify-ish with omlish.sql
|
|
5
|
+
"""
|
|
6
|
+
import abc
|
|
7
|
+
import configparser
|
|
1
8
|
import dataclasses as dc
|
|
2
9
|
import os.path
|
|
3
10
|
import shutil
|
|
@@ -76,104 +83,197 @@ def spec_from_cfg(cfg: ta.Mapping[str, ta.Any], prefix: str) -> ServerSpec:
|
|
|
76
83
|
)
|
|
77
84
|
|
|
78
85
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
##
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dc.dataclass(frozen=True)
|
|
90
|
+
class ReplArgs:
|
|
91
|
+
spec: ServerSpec
|
|
92
|
+
extra_args: ta.Sequence[str] | None = None # noqa
|
|
93
|
+
|
|
94
|
+
_: dc.KW_ONLY
|
|
95
|
+
|
|
96
|
+
exe: ta.Sequence[str] | None = None
|
|
97
|
+
|
|
98
|
+
no_dbcli: bool = False
|
|
99
|
+
no_import: bool = False
|
|
100
|
+
no_uv: bool = False
|
|
101
|
+
dbcli_version: str | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ReplRunner(lang.Abstract):
|
|
105
|
+
def __init__(self, args: ReplArgs) -> None:
|
|
106
|
+
super().__init__()
|
|
107
|
+
|
|
108
|
+
self._args = args
|
|
109
|
+
|
|
110
|
+
exe_name: ta.ClassVar[str]
|
|
111
|
+
dbcli_name: ta.ClassVar[str | None] = None
|
|
112
|
+
|
|
113
|
+
#
|
|
114
|
+
|
|
115
|
+
class Exe(ta.NamedTuple):
|
|
116
|
+
args: ta.Sequence[str]
|
|
117
|
+
is_dbcli: bool
|
|
118
|
+
|
|
119
|
+
@lang.cached_function
|
|
120
|
+
def exe(self) -> Exe:
|
|
121
|
+
if self._args.exe is not None:
|
|
122
|
+
if isinstance(self._args.exe, str):
|
|
123
|
+
return ReplRunner.Exe([self._args.exe], False)
|
|
124
|
+
else:
|
|
125
|
+
return ReplRunner.Exe(list(self._args.exe), False)
|
|
126
|
+
|
|
127
|
+
def default():
|
|
128
|
+
return ReplRunner.Exe([check.not_none(shutil.which(self.exe_name))], False)
|
|
129
|
+
|
|
130
|
+
if self._args.no_dbcli or self.dbcli_name is None:
|
|
131
|
+
return default()
|
|
132
|
+
|
|
133
|
+
if not self._args.no_import:
|
|
134
|
+
main_mod = self.dbcli_name + '.main'
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
__import__(main_mod)
|
|
138
|
+
except ImportError:
|
|
139
|
+
pass
|
|
140
|
+
else:
|
|
141
|
+
return ReplRunner.Exe([sys.executable, '-m', main_mod], True)
|
|
142
|
+
|
|
143
|
+
if not self._args.no_uv and (uv_exe := shutil.which('uv')) is not None:
|
|
144
|
+
uv_arg = self.dbcli_name
|
|
145
|
+
if self._args.dbcli_version is not None:
|
|
146
|
+
uv_arg += f'@{self._args.dbcli_version}'
|
|
147
|
+
|
|
148
|
+
return ReplRunner.Exe([uv_exe, 'tool', 'run', uv_arg], True)
|
|
149
|
+
|
|
150
|
+
return default()
|
|
151
|
+
|
|
152
|
+
#
|
|
153
|
+
|
|
154
|
+
@abc.abstractmethod
|
|
155
|
+
def build_args(self) -> ta.Sequence[str]:
|
|
156
|
+
raise NotImplementedError
|
|
157
|
+
|
|
158
|
+
def pre_exec(self) -> None:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
#
|
|
162
|
+
|
|
163
|
+
def run(self) -> ta.NoReturn:
|
|
164
|
+
lst: list[str] = [
|
|
165
|
+
*self.exe().args,
|
|
166
|
+
*self.build_args(),
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
self.pre_exec()
|
|
170
|
+
|
|
171
|
+
os.execvp(lst[0], lst)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class MysqlReplRunner(ReplRunner):
|
|
175
|
+
dbcli_name = 'mycli'
|
|
176
|
+
exe_name = 'mysql'
|
|
177
|
+
|
|
178
|
+
def build_args(self) -> ta.Sequence[str]:
|
|
179
|
+
lst: list[str] = []
|
|
180
|
+
|
|
181
|
+
if self._args.spec.username:
|
|
182
|
+
lst.extend(['--user', self._args.spec.username])
|
|
183
|
+
|
|
184
|
+
lst.extend(['--host', self._args.spec.host])
|
|
185
|
+
if not self.exe().is_dbcli:
|
|
186
|
+
lst.append('--protocol=TCP')
|
|
187
|
+
if self._args.spec.port:
|
|
188
|
+
lst.extend(['--port', str(self._args.spec.port)])
|
|
189
|
+
|
|
190
|
+
if self._args.spec.db:
|
|
191
|
+
lst.append(self._args.spec.db)
|
|
192
|
+
|
|
193
|
+
lst.extend(self._args.extra_args or [])
|
|
194
|
+
|
|
195
|
+
return lst
|
|
196
|
+
|
|
197
|
+
def pre_exec(self) -> None:
|
|
198
|
+
super().pre_exec()
|
|
199
|
+
|
|
200
|
+
if self._args.spec.password:
|
|
201
|
+
os.environ['MYSQL_PWD'] = self._args.spec.password
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class PostgresReplRunner(ReplRunner):
|
|
205
|
+
dbcli_name = 'pgcli'
|
|
206
|
+
exe_name = 'psql'
|
|
207
|
+
|
|
208
|
+
def build_args(self) -> ta.Sequence[str]:
|
|
209
|
+
lst: list[str] = []
|
|
210
|
+
|
|
211
|
+
if self._args.spec.username:
|
|
212
|
+
lst.extend(['--username', self._args.spec.username])
|
|
213
|
+
|
|
214
|
+
if self._args.spec.host:
|
|
215
|
+
lst.extend(['--host', self._args.spec.host])
|
|
216
|
+
if self._args.spec.port:
|
|
217
|
+
lst.extend(['--port', str(self._args.spec.port)])
|
|
218
|
+
|
|
219
|
+
if self._args.spec.db:
|
|
220
|
+
lst.append(self._args.spec.db)
|
|
221
|
+
|
|
222
|
+
lst.extend(self._args.extra_args or [])
|
|
223
|
+
|
|
224
|
+
return lst
|
|
225
|
+
|
|
226
|
+
def _maybe_warn_keyring(self) -> None:
|
|
227
|
+
if 'XDG_CONFIG_HOME' in os.environ:
|
|
228
|
+
cfg_dir = f'{os.path.expanduser(os.environ["XDG_CONFIG_HOME"])}/pgcli/'
|
|
229
|
+
else:
|
|
230
|
+
cfg_dir = os.path.expanduser('~/.config/pgcli/')
|
|
231
|
+
cfg_path = os.path.join(cfg_dir, 'config')
|
|
232
|
+
|
|
233
|
+
if os.path.exists(cfg_path):
|
|
234
|
+
cfg = configparser.ConfigParser()
|
|
235
|
+
cfg.read(cfg_path)
|
|
236
|
+
|
|
237
|
+
if cfg.has_section('main') and not cfg.getboolean('main', 'keyring', fallback=True):
|
|
238
|
+
return
|
|
82
239
|
|
|
83
|
-
c = pgcli.config.get_config()
|
|
84
|
-
if c['main'].as_bool('keyring'):
|
|
85
240
|
warnings.warn(
|
|
86
241
|
'pgcli keyring is not disabled, it will try to store credentials. '
|
|
87
|
-
'set `keyring = False` in ~/.config/pgcli/config',
|
|
242
|
+
'set `[main] keyring = False` in ~/.config/pgcli/config',
|
|
88
243
|
)
|
|
89
244
|
|
|
245
|
+
def pre_exec(self) -> None:
|
|
246
|
+
super().pre_exec()
|
|
90
247
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
else:
|
|
99
|
-
if dbcli_mod == 'pgcli':
|
|
100
|
-
_maybe_warn_pgcli_keyring()
|
|
101
|
-
return [sys.executable, '-m', main_mod], True
|
|
102
|
-
return [check.not_none(shutil.which(default_exe))], False
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def exec_mysql_cli(
|
|
106
|
-
spec: ServerSpec,
|
|
107
|
-
*extra_args: str,
|
|
108
|
-
exe: ta.Iterable[str] | None = None,
|
|
109
|
-
no_dbcli: bool = False,
|
|
110
|
-
) -> ta.NoReturn:
|
|
111
|
-
if exe is not None:
|
|
112
|
-
args, is_dbcli = list(exe), False
|
|
113
|
-
else:
|
|
114
|
-
argsx, is_dbcli = _dbcli_or_fallback_exe(
|
|
115
|
-
'mycli' if not no_dbcli else None,
|
|
116
|
-
'mysql',
|
|
117
|
-
)
|
|
118
|
-
args = list(argsx)
|
|
119
|
-
if spec.username:
|
|
120
|
-
args.extend(['--user', spec.username])
|
|
121
|
-
if spec.password:
|
|
122
|
-
os.environ['MYSQL_PWD'] = spec.password
|
|
123
|
-
args.extend(['--host', spec.host])
|
|
124
|
-
if not is_dbcli:
|
|
125
|
-
args.append('--protocol=TCP')
|
|
126
|
-
if spec.port:
|
|
127
|
-
args.extend(['--port', str(spec.port)])
|
|
128
|
-
if spec.db:
|
|
129
|
-
args.append(spec.db)
|
|
130
|
-
args.extend(extra_args)
|
|
131
|
-
os.execvp(args[0], args)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def exec_postgres_cli(
|
|
135
|
-
spec: ServerSpec,
|
|
136
|
-
*extra_args: str,
|
|
137
|
-
exe: ta.Iterable[str] | None = None,
|
|
138
|
-
no_dbcli: bool = False,
|
|
139
|
-
) -> ta.NoReturn:
|
|
140
|
-
if exe is not None:
|
|
141
|
-
args, is_dbcli = list(exe), False
|
|
142
|
-
else:
|
|
143
|
-
argsx, is_dbcli = _dbcli_or_fallback_exe(
|
|
144
|
-
'pgcli' if not no_dbcli else None,
|
|
145
|
-
'psql',
|
|
146
|
-
)
|
|
147
|
-
args = list(argsx)
|
|
148
|
-
if spec.username:
|
|
149
|
-
args.extend(['--username', spec.username])
|
|
150
|
-
if spec.password:
|
|
151
|
-
os.environ['PGPASSWORD'] = spec.password
|
|
152
|
-
if spec.host:
|
|
153
|
-
args.extend(['--host', spec.host])
|
|
154
|
-
if spec.port:
|
|
155
|
-
args.extend(['--port', str(spec.port)])
|
|
156
|
-
if spec.db:
|
|
157
|
-
args.append(spec.db)
|
|
158
|
-
args.extend(extra_args)
|
|
159
|
-
os.execvp(args[0], args)
|
|
248
|
+
self._maybe_warn_keyring()
|
|
249
|
+
|
|
250
|
+
if self._args.spec.password:
|
|
251
|
+
os.environ['PGPASSWORD'] = self._args.spec.password
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
##
|
|
160
255
|
|
|
161
256
|
|
|
162
257
|
class Cli(ap.Cli):
|
|
163
258
|
@ap.cmd(
|
|
164
|
-
ap.arg('--no-dbcli', action='store_true'),
|
|
165
259
|
ap.arg('dialect'),
|
|
166
260
|
ap.arg('target'),
|
|
167
261
|
ap.arg('args', nargs='*'),
|
|
262
|
+
ap.arg('--no-dbcli', action='store_true'),
|
|
263
|
+
ap.arg('--no-import', action='store_true'),
|
|
264
|
+
ap.arg('--no-uv', action='store_true'),
|
|
168
265
|
)
|
|
169
266
|
def repl(self) -> None:
|
|
170
267
|
l, _, r = (target := self.args.target).partition(':')
|
|
171
268
|
_, lf = os.path.dirname(l), os.path.basename(l)
|
|
172
269
|
if not lf.endswith('.yml'):
|
|
173
270
|
raise Exception(f'unhandled target: {target=}')
|
|
271
|
+
|
|
174
272
|
with open(l) as f:
|
|
175
273
|
cfg = yaml.safe_load(f.read())
|
|
274
|
+
|
|
176
275
|
dialect = self.args.dialect
|
|
276
|
+
|
|
177
277
|
if lf == 'compose.yml':
|
|
178
278
|
svc = cfg['services'][r]
|
|
179
279
|
if dialect == 'mysql':
|
|
@@ -185,13 +285,24 @@ class Cli(ap.Cli):
|
|
|
185
285
|
else:
|
|
186
286
|
spec = spec_from_cfg(cfg, r)
|
|
187
287
|
|
|
288
|
+
repl_args = ReplArgs(
|
|
289
|
+
spec,
|
|
290
|
+
self.args.args,
|
|
291
|
+
no_dbcli=self.args.no_dbcli,
|
|
292
|
+
no_import=self.args.no_import,
|
|
293
|
+
no_uv=self.args.no_uv,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
repl_run: ReplRunner
|
|
188
297
|
if dialect == 'mysql':
|
|
189
|
-
|
|
298
|
+
repl_run = MysqlReplRunner(repl_args)
|
|
190
299
|
elif dialect == 'postgres':
|
|
191
|
-
|
|
300
|
+
repl_run = PostgresReplRunner(repl_args)
|
|
192
301
|
else:
|
|
193
302
|
raise Exception(f'unhandled dialect: {dialect=}')
|
|
194
303
|
|
|
304
|
+
repl_run.run()
|
|
305
|
+
|
|
195
306
|
|
|
196
307
|
# @omlish-manifest
|
|
197
308
|
_CLI_MODULE = CliModule('sqlrepl', __name__)
|
omdev/tui/rich/markdown2.py
CHANGED
|
@@ -130,6 +130,8 @@ class NaiveMarkdownLiveStream(MarkdownLiveStream):
|
|
|
130
130
|
|
|
131
131
|
|
|
132
132
|
class IncrementalMarkdownLiveStream(MarkdownLiveStream):
|
|
133
|
+
# @omlish-llm-author "gemini-3-pro"
|
|
134
|
+
|
|
133
135
|
def __init__(
|
|
134
136
|
self,
|
|
135
137
|
*,
|
|
@@ -147,40 +149,239 @@ class IncrementalMarkdownLiveStream(MarkdownLiveStream):
|
|
|
147
149
|
ip_out = self._inc_parser.feed2(s)
|
|
148
150
|
|
|
149
151
|
if ip_out.new_stable:
|
|
150
|
-
#
|
|
151
|
-
# srs = getattr(self, '_srs')
|
|
152
|
-
# except AttributeError:
|
|
153
|
-
# setattr(self, '_srs', srs := [])
|
|
154
|
-
# from ...markdown.tokens import token_repr, flatten_tokens
|
|
155
|
-
# srs.extend(map(token_repr, flatten_tokens(ip_out.new_stable)))
|
|
156
|
-
|
|
152
|
+
# Render the stable lines
|
|
157
153
|
stable_lines = self._console_render(markdown_from_tokens(ip_out.new_stable))
|
|
158
|
-
|
|
159
|
-
|
|
154
|
+
|
|
155
|
+
# Overlap Detection
|
|
156
|
+
already_printed_count = min(self._lines_printed_to_scrollback, len(stable_lines))
|
|
157
|
+
|
|
158
|
+
if already_printed_count < len(stable_lines):
|
|
159
|
+
new_stable_lines = stable_lines[already_printed_count:]
|
|
160
|
+
# Ensure no_wrap=True is used here to match strict line counting
|
|
161
|
+
self._live.console.print(rich.text.Text.from_ansi('\n'.join(new_stable_lines), no_wrap=True))
|
|
162
|
+
|
|
163
|
+
# Adjust counter
|
|
160
164
|
self._lines_printed_to_scrollback = max(0, self._lines_printed_to_scrollback - len(stable_lines))
|
|
161
165
|
|
|
162
166
|
unstable_lines = self._console_render(markdown_from_tokens(ip_out.unstable))
|
|
163
167
|
|
|
164
|
-
# Calculate how many lines fit in the live window
|
|
165
168
|
available_height = self._console.height - 2
|
|
166
|
-
|
|
167
|
-
# Determine which lines overflow and need to be printed to scrollback
|
|
168
169
|
total_lines = len(unstable_lines)
|
|
170
|
+
|
|
169
171
|
if total_lines > available_height:
|
|
170
|
-
#
|
|
171
|
-
|
|
172
|
+
# We calculate lines allowed in scrollback, but we subtract 1. This ensures the very bottom line (often a
|
|
173
|
+
# transient border) stays in the Live window and is not committed to history until more content pushes it
|
|
174
|
+
# up.
|
|
175
|
+
lines_for_scrollback = max(0, total_lines - available_height - 1)
|
|
172
176
|
|
|
173
|
-
# Print any new lines that weren't already printed
|
|
174
177
|
if lines_for_scrollback > self._lines_printed_to_scrollback:
|
|
175
178
|
new_lines_to_print = unstable_lines[self._lines_printed_to_scrollback:lines_for_scrollback]
|
|
176
|
-
|
|
179
|
+
|
|
180
|
+
# Ensure no_wrap=True is used here to prevent auto-wrap from creating "phantom" lines that desync our
|
|
181
|
+
# line counts.
|
|
182
|
+
self._live.console.print(rich.text.Text.from_ansi('\n'.join(new_lines_to_print), no_wrap=True))
|
|
183
|
+
|
|
177
184
|
self._lines_printed_to_scrollback = lines_for_scrollback
|
|
178
185
|
|
|
179
|
-
# Show only the bottom portion in the live window
|
|
180
186
|
visible_lines = unstable_lines[-available_height:]
|
|
181
187
|
|
|
182
188
|
else:
|
|
183
189
|
visible_lines = unstable_lines
|
|
184
190
|
|
|
185
|
-
# Update the live display
|
|
186
191
|
self._live.update(rich.text.Text.from_ansi('\n'.join(visible_lines)))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class SteppedIncrementalMarkdownLiveStream(MarkdownLiveStream):
|
|
195
|
+
# @omlish-llm-author "gemini-3-pro"
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
*,
|
|
200
|
+
parser: ta.Optional['md.MarkdownIt'] = None,
|
|
201
|
+
console: ta.Optional['rich.console.Console'] = None,
|
|
202
|
+
scroll_step: int | None = None,
|
|
203
|
+
) -> None:
|
|
204
|
+
super().__init__(
|
|
205
|
+
parser=parser,
|
|
206
|
+
console=console,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
self._inc_parser = IncrementalMarkdownParser(parser=self._parser)
|
|
210
|
+
self._scroll_step = scroll_step
|
|
211
|
+
|
|
212
|
+
def feed(self, s: str) -> None:
|
|
213
|
+
ip_out = self._inc_parser.feed2(s)
|
|
214
|
+
|
|
215
|
+
##
|
|
216
|
+
# Handle stable content
|
|
217
|
+
|
|
218
|
+
if ip_out.new_stable:
|
|
219
|
+
stable_lines = self._console_render(markdown_from_tokens(ip_out.new_stable))
|
|
220
|
+
|
|
221
|
+
# Overlap Detection: Determine how many lines of this now-stable block were already pushed to scrollback
|
|
222
|
+
# while they were in the "unstable" phase.
|
|
223
|
+
already_printed_count = min(self._lines_printed_to_scrollback, len(stable_lines))
|
|
224
|
+
|
|
225
|
+
if already_printed_count < len(stable_lines):
|
|
226
|
+
new_stable_lines = stable_lines[already_printed_count:]
|
|
227
|
+
# Force no_wrap=True to ensure 1:1 line counting
|
|
228
|
+
self._live.console.print(rich.text.Text.from_ansi('\n'.join(new_stable_lines), no_wrap=True))
|
|
229
|
+
|
|
230
|
+
# Adjust the global scrollback counter. We effectively shift the "start" of the unstable window down by the
|
|
231
|
+
# size of the stable block.
|
|
232
|
+
self._lines_printed_to_scrollback = max(0, self._lines_printed_to_scrollback - len(stable_lines))
|
|
233
|
+
|
|
234
|
+
##
|
|
235
|
+
# Handle unstable content
|
|
236
|
+
|
|
237
|
+
unstable_lines = self._console_render(markdown_from_tokens(ip_out.unstable))
|
|
238
|
+
total_lines = len(unstable_lines)
|
|
239
|
+
available_height = self._console.height - 2
|
|
240
|
+
|
|
241
|
+
# Calculate the absolute minimum lines that MUST be in scrollback to fit the current content. We subtract 1 to
|
|
242
|
+
# hold back the bottom-most line (e.g., table borders) from history until it is pushed up by further content.
|
|
243
|
+
min_needed_scrollback = max(0, total_lines - available_height - 1)
|
|
244
|
+
|
|
245
|
+
if min_needed_scrollback > self._lines_printed_to_scrollback:
|
|
246
|
+
# We need to scroll. Calculate how much.
|
|
247
|
+
diff = min_needed_scrollback - self._lines_printed_to_scrollback
|
|
248
|
+
|
|
249
|
+
# Use the step size if configured, otherwise just satisfy the immediate difference. If the difference is
|
|
250
|
+
# larger than the step (e.g., big paste), we jump the full difference.
|
|
251
|
+
step = self._scroll_step if self._scroll_step is not None else 1
|
|
252
|
+
jump_size = max(diff, step)
|
|
253
|
+
|
|
254
|
+
# Calculate the new target scrollback index. We must clamp this to 'total_lines - 1' to ensure we never push
|
|
255
|
+
# the strictly held-back last line into history.
|
|
256
|
+
max_pushable_index = max(0, total_lines - 1)
|
|
257
|
+
target_scrollback = min(self._lines_printed_to_scrollback + jump_size, max_pushable_index)
|
|
258
|
+
|
|
259
|
+
if target_scrollback > self._lines_printed_to_scrollback:
|
|
260
|
+
new_lines_to_print = unstable_lines[self._lines_printed_to_scrollback:target_scrollback]
|
|
261
|
+
self._live.console.print(rich.text.Text.from_ansi('\n'.join(new_lines_to_print), no_wrap=True))
|
|
262
|
+
self._lines_printed_to_scrollback = target_scrollback
|
|
263
|
+
|
|
264
|
+
# Update the live display. We slice from '_lines_printed_to_scrollback' to the end. If we just performed a large
|
|
265
|
+
# 'jump', this will naturally result in fewer lines than 'available_height', creating the desired visual "void"
|
|
266
|
+
# at the bottom.
|
|
267
|
+
visible_lines = unstable_lines[self._lines_printed_to_scrollback:]
|
|
268
|
+
self._live.update(rich.text.Text.from_ansi('\n'.join(visible_lines)))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
##
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class ClaudeIncrementalMarkdownLiveStream(MarkdownLiveStream):
|
|
275
|
+
# @omlish-llm-author "claude-opus-4-5"
|
|
276
|
+
|
|
277
|
+
def __init__(
|
|
278
|
+
self,
|
|
279
|
+
*,
|
|
280
|
+
parser: ta.Optional['md.MarkdownIt'] = None,
|
|
281
|
+
console: ta.Optional['rich.console.Console'] = None,
|
|
282
|
+
) -> None:
|
|
283
|
+
super().__init__(
|
|
284
|
+
parser=parser,
|
|
285
|
+
console=console,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
from ...markdown.incparse import ClaudeIncrementalMarkdownParser # noqa
|
|
289
|
+
self._inc_parser = ClaudeIncrementalMarkdownParser(parser=self._parser)
|
|
290
|
+
self._last_unstable_line_count = 0
|
|
291
|
+
|
|
292
|
+
def _enter_contexts(self) -> None:
|
|
293
|
+
super()._enter_contexts()
|
|
294
|
+
|
|
295
|
+
# Override to configure Live with explicit vertical overflow handling
|
|
296
|
+
self._live = self._enter_context(rich.live.Live(
|
|
297
|
+
rich.text.Text(''),
|
|
298
|
+
console=self._console,
|
|
299
|
+
refresh_per_second=10,
|
|
300
|
+
vertical_overflow='crop',
|
|
301
|
+
))
|
|
302
|
+
|
|
303
|
+
def feed(self, s: str) -> None:
|
|
304
|
+
ip_out = self._inc_parser.feed2(s)
|
|
305
|
+
|
|
306
|
+
if ip_out.new_stable:
|
|
307
|
+
# Stop live display to commit stable content cleanly
|
|
308
|
+
self._live.stop()
|
|
309
|
+
|
|
310
|
+
# Render and print stable content to true scrollback
|
|
311
|
+
stable_lines = self._console_render(markdown_from_tokens(ip_out.new_stable))
|
|
312
|
+
for line in stable_lines:
|
|
313
|
+
self._console.print(rich.text.Text.from_ansi(line), highlight=False)
|
|
314
|
+
|
|
315
|
+
# Reset tracking state since we're starting fresh with new unstable content
|
|
316
|
+
self._last_unstable_line_count = 0
|
|
317
|
+
self._lines_printed_to_scrollback = 0
|
|
318
|
+
|
|
319
|
+
# Restart live display
|
|
320
|
+
self._live.start()
|
|
321
|
+
|
|
322
|
+
# Render current unstable content
|
|
323
|
+
unstable_lines = self._console_render(markdown_from_tokens(ip_out.unstable))
|
|
324
|
+
current_line_count = len(unstable_lines)
|
|
325
|
+
|
|
326
|
+
# Calculate available display height
|
|
327
|
+
available_height = self._console.height - 2
|
|
328
|
+
|
|
329
|
+
# Handle content that exceeds available height. Key insight: never commit unstable content to scrollback since
|
|
330
|
+
# it may change.
|
|
331
|
+
if current_line_count > available_height:
|
|
332
|
+
# Only show the bottom portion that fits
|
|
333
|
+
visible_lines = unstable_lines[-available_height:]
|
|
334
|
+
else:
|
|
335
|
+
visible_lines = unstable_lines
|
|
336
|
+
|
|
337
|
+
# Handle shrinking content: if we had more lines before and now have fewer, we need to ensure the live region is
|
|
338
|
+
# properly cleared.
|
|
339
|
+
if current_line_count < self._last_unstable_line_count:
|
|
340
|
+
# Pad with empty lines to prevent artifacts from previous longer content. This ensures the live region fully
|
|
341
|
+
# overwrites its previous state.
|
|
342
|
+
lines_to_clear = min(
|
|
343
|
+
self._last_unstable_line_count - current_line_count,
|
|
344
|
+
available_height - len(visible_lines),
|
|
345
|
+
)
|
|
346
|
+
if lines_to_clear > 0:
|
|
347
|
+
visible_lines = visible_lines + [''] * lines_to_clear
|
|
348
|
+
|
|
349
|
+
self._last_unstable_line_count = current_line_count
|
|
350
|
+
|
|
351
|
+
# Update the live display
|
|
352
|
+
display_text = '\n'.join(visible_lines)
|
|
353
|
+
self._live.update(rich.text.Text.from_ansi(display_text))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class GptIncrementalMarkdownLiveStream(MarkdownLiveStream):
|
|
357
|
+
# @omlish-llm-author "gpt-5.2"
|
|
358
|
+
|
|
359
|
+
def __init__(
|
|
360
|
+
self,
|
|
361
|
+
*,
|
|
362
|
+
parser: ta.Optional['md.MarkdownIt'] = None,
|
|
363
|
+
console: ta.Optional['rich.console.Console'] = None,
|
|
364
|
+
) -> None:
|
|
365
|
+
super().__init__(
|
|
366
|
+
parser=parser,
|
|
367
|
+
console=console,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
from ...markdown.incparse import GptIncrementalMarkdownParser # noqa
|
|
371
|
+
self._inc_parser = GptIncrementalMarkdownParser(parser=self._parser)
|
|
372
|
+
|
|
373
|
+
def feed(self, s: str) -> None:
|
|
374
|
+
ip_out = self._inc_parser.feed2(s)
|
|
375
|
+
|
|
376
|
+
# Permanently commit only content the parser marked as stable. This avoids *all* "scrollback delta accounting"
|
|
377
|
+
# and the associated duplication/gap bugs when the rendered tail shrinks / reflows / reinterprets (streaming
|
|
378
|
+
# markdown is non-monotonic).
|
|
379
|
+
if ip_out.new_stable:
|
|
380
|
+
# Print stable renderables directly through Rich (avoid ANSI round-tripping / splitlines). Use end="" so we
|
|
381
|
+
# don't inject extra blank lines beyond what the markdown renderable produces.
|
|
382
|
+
self._live.console.print(markdown_from_tokens(ip_out.new_stable), end='')
|
|
383
|
+
|
|
384
|
+
# Show the remaining (unstable) tail in the live region. We intentionally do *not* try to "spill overflow of
|
|
385
|
+
# unstable to scrollback", since those lines are not provably stable and may retroactively change; printing them
|
|
386
|
+
# would violate correctness.
|
|
387
|
+
self._live.update(markdown_from_tokens(ip_out.unstable))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: omdev
|
|
3
|
-
Version: 0.0.0.
|
|
3
|
+
Version: 0.0.0.dev509
|
|
4
4
|
Summary: omdev
|
|
5
5
|
Author: wrmsr
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -14,9 +14,9 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
14
14
|
Requires-Python: >=3.13
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
|
-
Requires-Dist: omlish==0.0.0.
|
|
17
|
+
Requires-Dist: omlish==0.0.0.dev509
|
|
18
18
|
Provides-Extra: all
|
|
19
|
-
Requires-Dist: black~=
|
|
19
|
+
Requires-Dist: black~=26.1; extra == "all"
|
|
20
20
|
Requires-Dist: pycparser~=2.23; extra == "all"
|
|
21
21
|
Requires-Dist: pcpp~=1.30; extra == "all"
|
|
22
22
|
Requires-Dist: docutils~=0.22; extra == "all"
|
|
@@ -27,11 +27,11 @@ Requires-Dist: mypy~=1.19; extra == "all"
|
|
|
27
27
|
Requires-Dist: gprof2dot~=2025.4; extra == "all"
|
|
28
28
|
Requires-Dist: segno~=1.6; extra == "all"
|
|
29
29
|
Requires-Dist: rich~=14.2; extra == "all"
|
|
30
|
-
Requires-Dist: textual~=7.
|
|
30
|
+
Requires-Dist: textual~=7.3; extra == "all"
|
|
31
31
|
Requires-Dist: textual-dev~=1.8; extra == "all"
|
|
32
32
|
Requires-Dist: textual-speedups~=0.2; extra == "all"
|
|
33
33
|
Provides-Extra: black
|
|
34
|
-
Requires-Dist: black~=
|
|
34
|
+
Requires-Dist: black~=26.1; extra == "black"
|
|
35
35
|
Provides-Extra: c
|
|
36
36
|
Requires-Dist: pycparser~=2.23; extra == "c"
|
|
37
37
|
Requires-Dist: pcpp~=1.30; extra == "c"
|
|
@@ -48,7 +48,7 @@ Provides-Extra: qr
|
|
|
48
48
|
Requires-Dist: segno~=1.6; extra == "qr"
|
|
49
49
|
Provides-Extra: tui
|
|
50
50
|
Requires-Dist: rich~=14.2; extra == "tui"
|
|
51
|
-
Requires-Dist: textual~=7.
|
|
51
|
+
Requires-Dist: textual~=7.3; extra == "tui"
|
|
52
52
|
Requires-Dist: textual-dev~=1.8; extra == "tui"
|
|
53
53
|
Requires-Dist: textual-speedups~=0.2; extra == "tui"
|
|
54
54
|
Dynamic: license-file
|