jj-stack 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.
- jj_stack/__init__.py +11 -0
- jj_stack/__main__.py +8 -0
- jj_stack/bootstrap.py +213 -0
- jj_stack/cli.py +1297 -0
- jj_stack/cli_help.py +680 -0
- jj_stack/commands/__init__.py +1 -0
- jj_stack/commands/_cleanup_actions.py +389 -0
- jj_stack/commands/_json_status.py +84 -0
- jj_stack/commands/checkout.py +668 -0
- jj_stack/commands/cleanup/command.py +702 -0
- jj_stack/commands/cleanup/shared.py +62 -0
- jj_stack/commands/cleanup/stale.py +79 -0
- jj_stack/commands/doctor.py +358 -0
- jj_stack/commands/in_use.py +31 -0
- jj_stack/commands/list_.py +667 -0
- jj_stack/commands/merge/__init__.py +1 -0
- jj_stack/commands/merge/command.py +385 -0
- jj_stack/commands/merge/github_stack.py +333 -0
- jj_stack/commands/merge/models.py +97 -0
- jj_stack/commands/merge/plan.py +109 -0
- jj_stack/commands/merge/preconditions.py +162 -0
- jj_stack/commands/merge/render.py +52 -0
- jj_stack/commands/relink.py +210 -0
- jj_stack/commands/submit/__init__.py +1 -0
- jj_stack/commands/submit/auto_close.py +108 -0
- jj_stack/commands/submit/changes.py +90 -0
- jj_stack/commands/submit/command.py +780 -0
- jj_stack/commands/submit/descriptions.py +570 -0
- jj_stack/commands/submit/github_stack.py +139 -0
- jj_stack/commands/submit/inputs.py +168 -0
- jj_stack/commands/submit/models.py +198 -0
- jj_stack/commands/submit/overview_comments.py +178 -0
- jj_stack/commands/submit/prs.py +461 -0
- jj_stack/commands/submit/render.py +129 -0
- jj_stack/commands/sync.py +510 -0
- jj_stack/commands/sync_apply.py +474 -0
- jj_stack/commands/unstack.py +321 -0
- jj_stack/commands/view.py +1151 -0
- jj_stack/completion.py +484 -0
- jj_stack/concurrency.py +93 -0
- jj_stack/config.py +170 -0
- jj_stack/console.py +708 -0
- jj_stack/errors.py +152 -0
- jj_stack/formatting.py +95 -0
- jj_stack/github/__init__.py +1 -0
- jj_stack/github/auth.py +34 -0
- jj_stack/github/client.py +1098 -0
- jj_stack/github/error_messages.py +89 -0
- jj_stack/github/overview_comments.py +35 -0
- jj_stack/github/pr_refs.py +70 -0
- jj_stack/github/resolution.py +208 -0
- jj_stack/github/stack_availability.py +30 -0
- jj_stack/identifiers.py +7 -0
- jj_stack/jj/__init__.py +1 -0
- jj_stack/jj/cli_args.py +23 -0
- jj_stack/jj/client.py +1459 -0
- jj_stack/jj/colors.py +199 -0
- jj_stack/models/__init__.py +1 -0
- jj_stack/models/git.py +15 -0
- jj_stack/models/github.py +243 -0
- jj_stack/models/stack.py +70 -0
- jj_stack/models/tracking.py +115 -0
- jj_stack/pr_branch_namespace.py +78 -0
- jj_stack/stack/__init__.py +1 -0
- jj_stack/stack/change_status.py +216 -0
- jj_stack/stack/convergence.py +501 -0
- jj_stack/stack/convergence_models.py +69 -0
- jj_stack/stack/convergence_observation.py +162 -0
- jj_stack/stack/github_stack_safety.py +132 -0
- jj_stack/stack/global_convergence.py +270 -0
- jj_stack/stack/path.py +235 -0
- jj_stack/stack/pr_branches.py +126 -0
- jj_stack/stack/pr_facts.py +165 -0
- jj_stack/stack/repo.py +95 -0
- jj_stack/stack/selected.py +358 -0
- jj_stack/stack/selection.py +124 -0
- jj_stack/stack/status.py +711 -0
- jj_stack/stack/trunk_evidence.py +174 -0
- jj_stack/state/__init__.py +1 -0
- jj_stack/state/operation_lock.py +220 -0
- jj_stack/state/store.py +260 -0
- jj_stack/ui.py +230 -0
- jj_stack-0.1.0.dist-info/METADATA +170 -0
- jj_stack-0.1.0.dist-info/RECORD +88 -0
- jj_stack-0.1.0.dist-info/WHEEL +4 -0
- jj_stack-0.1.0.dist-info/entry_points.txt +2 -0
- jj_stack-0.1.0.dist-info/licenses/LICENSE +201 -0
- jj_stack-0.1.0.dist-info/licenses/NOTICE +2 -0
jj_stack/console.py
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"""Rich-backed terminal output helpers."""
|
|
2
|
+
|
|
3
|
+
# Design notes:
|
|
4
|
+
#
|
|
5
|
+
# - The public API is intentionally just the top-level helper functions and the
|
|
6
|
+
# `configured_console()` context manager. Command modules should not need to manage
|
|
7
|
+
# console objects directly.
|
|
8
|
+
#
|
|
9
|
+
# - The module keeps stdout and stderr console setup in one place so command
|
|
10
|
+
# modules do not spread Rich policy across the codebase.
|
|
11
|
+
#
|
|
12
|
+
# - `markup=False` remains the default so arbitrary user-facing text does not
|
|
13
|
+
# need per-call Rich escaping.
|
|
14
|
+
#
|
|
15
|
+
# - Optional time-prefixing lives here alongside the output it affects.
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Generator
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from string.templatelib import Template
|
|
26
|
+
from typing import IO, Literal, Protocol
|
|
27
|
+
|
|
28
|
+
from rich import box as rich_box
|
|
29
|
+
from rich.console import Console, ConsoleRenderable, Group, NewLine, RenderableType, RichCast
|
|
30
|
+
from rich.progress import (
|
|
31
|
+
BarColumn,
|
|
32
|
+
Progress,
|
|
33
|
+
SpinnerColumn,
|
|
34
|
+
TaskID,
|
|
35
|
+
TaskProgressColumn,
|
|
36
|
+
TextColumn,
|
|
37
|
+
)
|
|
38
|
+
from rich.segment import Segment
|
|
39
|
+
from rich.status import Status
|
|
40
|
+
from rich.style import Style
|
|
41
|
+
from rich.table import Table
|
|
42
|
+
from rich.text import Text
|
|
43
|
+
|
|
44
|
+
import jj_stack
|
|
45
|
+
import jj_stack.ui as ui
|
|
46
|
+
from jj_stack.jj.cli_args import JjCliArgs
|
|
47
|
+
from jj_stack.jj.colors import SemanticStyles, load_semantic_styles
|
|
48
|
+
|
|
49
|
+
_NO_CLI_ARGS = JjCliArgs()
|
|
50
|
+
|
|
51
|
+
SIMPLE = rich_box.SIMPLE
|
|
52
|
+
|
|
53
|
+
ColorMode = Literal["auto", "always", "never"]
|
|
54
|
+
RequestedColorMode = Literal["always", "auto", "debug", "never"]
|
|
55
|
+
StyleArg = Style | str
|
|
56
|
+
type ConsoleObject = ui.Renderable | ConsoleRenderable | RichCast
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ProgressLike(Protocol):
|
|
60
|
+
"""Minimal progress-handle protocol used by command helpers."""
|
|
61
|
+
|
|
62
|
+
def advance(self, amount: int = 1) -> None: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SpinnerLike(Protocol):
|
|
66
|
+
"""Minimal spinner-handle protocol used by command helpers."""
|
|
67
|
+
|
|
68
|
+
def update(self, description: str) -> None: ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(slots=True)
|
|
72
|
+
class _TimePrefixedRenderable:
|
|
73
|
+
"""Render content with a Rich-managed elapsed-time prefix on every line."""
|
|
74
|
+
|
|
75
|
+
renderable: RenderableType
|
|
76
|
+
end: str
|
|
77
|
+
prefix_style: Style | None
|
|
78
|
+
start: float
|
|
79
|
+
|
|
80
|
+
def __rich_console__(self, console, options):
|
|
81
|
+
prefix = f"[{time.perf_counter() - self.start:0.6f}] "
|
|
82
|
+
prefix_width = len(prefix)
|
|
83
|
+
inner_width = max(1, options.max_width - prefix_width)
|
|
84
|
+
inner_options = options.update(width=inner_width, max_width=inner_width)
|
|
85
|
+
lines = console.render_lines(self.renderable, inner_options, pad=False)
|
|
86
|
+
prefix_segment = Segment(prefix, self.prefix_style)
|
|
87
|
+
for index, line in enumerate(lines):
|
|
88
|
+
yield prefix_segment
|
|
89
|
+
yield from line
|
|
90
|
+
if index < len(lines) - 1:
|
|
91
|
+
yield Segment.line()
|
|
92
|
+
if self.end == "\n":
|
|
93
|
+
yield Segment.line()
|
|
94
|
+
elif self.end:
|
|
95
|
+
yield from console.render(self.end, options)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(slots=True)
|
|
99
|
+
class _HangingIndentRenderable:
|
|
100
|
+
"""Render a prefix once and indent wrapped body lines to the same column."""
|
|
101
|
+
|
|
102
|
+
prefix: RenderableType
|
|
103
|
+
prefix_width: int
|
|
104
|
+
body: RenderableType
|
|
105
|
+
end: str = "\n"
|
|
106
|
+
|
|
107
|
+
def __rich_console__(self, console, options):
|
|
108
|
+
if options.no_wrap:
|
|
109
|
+
no_wrap_options = options.update(no_wrap=True, overflow="ignore")
|
|
110
|
+
yield from console.render(self.prefix, no_wrap_options)
|
|
111
|
+
yield from console.render(self.body, no_wrap_options)
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
prefix_options = options.update(width=self.prefix_width, max_width=self.prefix_width)
|
|
115
|
+
body_width = max(1, options.max_width - self.prefix_width)
|
|
116
|
+
body_options = options.update(width=body_width, max_width=body_width)
|
|
117
|
+
|
|
118
|
+
prefix_lines = console.render_lines(self.prefix, prefix_options, pad=False)
|
|
119
|
+
prefix_line = prefix_lines[0] if prefix_lines else []
|
|
120
|
+
body_lines = console.render_lines(self.body, body_options, pad=False)
|
|
121
|
+
if not body_lines:
|
|
122
|
+
body_lines = [[]]
|
|
123
|
+
|
|
124
|
+
indent = Segment(" " * self.prefix_width)
|
|
125
|
+
for index, line in enumerate(body_lines):
|
|
126
|
+
if index == 0:
|
|
127
|
+
yield from prefix_line
|
|
128
|
+
else:
|
|
129
|
+
yield indent
|
|
130
|
+
yield from line
|
|
131
|
+
if index < len(body_lines) - 1:
|
|
132
|
+
yield Segment.line()
|
|
133
|
+
if self.end == "\n":
|
|
134
|
+
yield Segment.line()
|
|
135
|
+
elif self.end:
|
|
136
|
+
yield from console.render(self.end, options)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(slots=True)
|
|
140
|
+
class _TrimmedRenderable:
|
|
141
|
+
"""Render content with trailing whitespace removed from each line."""
|
|
142
|
+
|
|
143
|
+
renderable: RenderableType
|
|
144
|
+
end: str = "\n"
|
|
145
|
+
|
|
146
|
+
def __rich_console__(self, console, options):
|
|
147
|
+
lines = [
|
|
148
|
+
_rstrip_line_segments(line)
|
|
149
|
+
for line in console.render_lines(self.renderable, options, pad=False)
|
|
150
|
+
]
|
|
151
|
+
while lines and not _line_text(lines[0]):
|
|
152
|
+
lines.pop(0)
|
|
153
|
+
while lines and not _line_text(lines[-1]):
|
|
154
|
+
lines.pop()
|
|
155
|
+
|
|
156
|
+
for index, line in enumerate(lines):
|
|
157
|
+
yield from line
|
|
158
|
+
if index < len(lines) - 1:
|
|
159
|
+
yield Segment.line()
|
|
160
|
+
if self.end == "\n":
|
|
161
|
+
yield Segment.line()
|
|
162
|
+
elif self.end:
|
|
163
|
+
yield from console.render(self.end, options)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class _ConfiguredConsole:
|
|
167
|
+
"""Wrap a Rich console with optional jj-style elapsed-time prefixes."""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
console: Console,
|
|
172
|
+
*,
|
|
173
|
+
prefix_style: Style | None,
|
|
174
|
+
start: float | None,
|
|
175
|
+
) -> None:
|
|
176
|
+
self._console = console
|
|
177
|
+
self._prefix_style = prefix_style
|
|
178
|
+
self._start = start
|
|
179
|
+
|
|
180
|
+
def print(
|
|
181
|
+
self,
|
|
182
|
+
*objects,
|
|
183
|
+
sep: str = " ",
|
|
184
|
+
end: str = "\n",
|
|
185
|
+
style=None,
|
|
186
|
+
justify=None,
|
|
187
|
+
overflow=None,
|
|
188
|
+
no_wrap=None,
|
|
189
|
+
emoji=None,
|
|
190
|
+
markup=None,
|
|
191
|
+
highlight=None,
|
|
192
|
+
width=None,
|
|
193
|
+
height=None,
|
|
194
|
+
crop: bool = True,
|
|
195
|
+
soft_wrap=None,
|
|
196
|
+
new_line_start: bool = False,
|
|
197
|
+
) -> None:
|
|
198
|
+
if self._start is None:
|
|
199
|
+
self._console.print(
|
|
200
|
+
*objects,
|
|
201
|
+
sep=sep,
|
|
202
|
+
end=end,
|
|
203
|
+
style=style,
|
|
204
|
+
justify=justify,
|
|
205
|
+
overflow=overflow,
|
|
206
|
+
no_wrap=no_wrap,
|
|
207
|
+
emoji=emoji,
|
|
208
|
+
markup=markup,
|
|
209
|
+
highlight=highlight,
|
|
210
|
+
width=width,
|
|
211
|
+
height=height,
|
|
212
|
+
crop=crop,
|
|
213
|
+
soft_wrap=soft_wrap,
|
|
214
|
+
new_line_start=new_line_start,
|
|
215
|
+
)
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
if not objects:
|
|
219
|
+
objects = (NewLine(),)
|
|
220
|
+
|
|
221
|
+
renderables = self._console._collect_renderables(
|
|
222
|
+
objects,
|
|
223
|
+
sep,
|
|
224
|
+
"",
|
|
225
|
+
justify=justify,
|
|
226
|
+
emoji=emoji,
|
|
227
|
+
markup=markup,
|
|
228
|
+
highlight=highlight,
|
|
229
|
+
)
|
|
230
|
+
wrapped = _TimePrefixedRenderable(
|
|
231
|
+
renderable=Group(*renderables),
|
|
232
|
+
end=end,
|
|
233
|
+
prefix_style=self._prefix_style,
|
|
234
|
+
start=self._start,
|
|
235
|
+
)
|
|
236
|
+
self._console.print(
|
|
237
|
+
wrapped,
|
|
238
|
+
end="",
|
|
239
|
+
style=style,
|
|
240
|
+
justify=justify,
|
|
241
|
+
overflow=overflow,
|
|
242
|
+
no_wrap=no_wrap,
|
|
243
|
+
width=width,
|
|
244
|
+
height=height,
|
|
245
|
+
crop=crop,
|
|
246
|
+
soft_wrap=soft_wrap,
|
|
247
|
+
new_line_start=new_line_start,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class _NullProgress:
|
|
252
|
+
"""No-op progress handle returned when live progress is disabled."""
|
|
253
|
+
|
|
254
|
+
def advance(self, amount: int = 1) -> None:
|
|
255
|
+
del amount
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class _NullSpinner:
|
|
259
|
+
"""No-op spinner handle returned when live progress is disabled."""
|
|
260
|
+
|
|
261
|
+
def update(self, description: str) -> None:
|
|
262
|
+
del description
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(slots=True)
|
|
266
|
+
class _RichProgressHandle:
|
|
267
|
+
"""Advance one Rich progress task."""
|
|
268
|
+
|
|
269
|
+
progress: Progress
|
|
270
|
+
task_id: TaskID
|
|
271
|
+
|
|
272
|
+
def advance(self, amount: int = 1) -> None:
|
|
273
|
+
self.progress.advance(self.task_id, amount)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@dataclass(slots=True)
|
|
277
|
+
class _RichSpinnerHandle:
|
|
278
|
+
"""Update one Rich status spinner."""
|
|
279
|
+
|
|
280
|
+
status: Status
|
|
281
|
+
|
|
282
|
+
def update(self, description: str) -> None:
|
|
283
|
+
self.status.update(description)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _raw_console(stream: IO[str], *, color_mode: ColorMode) -> Console:
|
|
287
|
+
if color_mode == "always":
|
|
288
|
+
return Console(file=stream, force_terminal=True, no_color=False)
|
|
289
|
+
if color_mode == "never":
|
|
290
|
+
return Console(file=stream, no_color=True)
|
|
291
|
+
return Console(file=stream)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _console_emits_color(console: Console) -> bool:
|
|
295
|
+
return console.is_terminal and not console.no_color
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _build_console(
|
|
299
|
+
console: Console,
|
|
300
|
+
*,
|
|
301
|
+
semantic_styles: SemanticStyles | None,
|
|
302
|
+
time_output: bool,
|
|
303
|
+
) -> _ConfiguredConsole:
|
|
304
|
+
return _ConfiguredConsole(
|
|
305
|
+
console,
|
|
306
|
+
prefix_style=(
|
|
307
|
+
None
|
|
308
|
+
if semantic_styles is None
|
|
309
|
+
else semantic_styles.for_labels(("prefix", "timestamp"))
|
|
310
|
+
),
|
|
311
|
+
start=jj_stack.PROCESS_START if time_output else None,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _build_consoles(
|
|
316
|
+
*,
|
|
317
|
+
cli_args: JjCliArgs,
|
|
318
|
+
color_mode: ColorMode = "auto",
|
|
319
|
+
load_styles: bool = True,
|
|
320
|
+
repo: Path | None = None,
|
|
321
|
+
stderr: IO[str] | None = None,
|
|
322
|
+
stdout: IO[str] | None = None,
|
|
323
|
+
time_output: bool = False,
|
|
324
|
+
) -> tuple[_ConfiguredConsole, _ConfiguredConsole, SemanticStyles | None]:
|
|
325
|
+
stdout_console = _raw_console(sys.stdout if stdout is None else stdout, color_mode=color_mode)
|
|
326
|
+
stderr_console = _raw_console(sys.stderr if stderr is None else stderr, color_mode=color_mode)
|
|
327
|
+
# Semantic styles exist only to color output, and reading them costs one
|
|
328
|
+
# jj invocation, so skip the read when neither console can emit color
|
|
329
|
+
# (piped or captured output).
|
|
330
|
+
semantic_styles = None
|
|
331
|
+
if load_styles and (
|
|
332
|
+
_console_emits_color(stdout_console) or _console_emits_color(stderr_console)
|
|
333
|
+
):
|
|
334
|
+
semantic_styles = load_semantic_styles(repo=repo, cli_args=cli_args)
|
|
335
|
+
return (
|
|
336
|
+
_build_console(
|
|
337
|
+
stdout_console,
|
|
338
|
+
semantic_styles=semantic_styles,
|
|
339
|
+
time_output=time_output,
|
|
340
|
+
),
|
|
341
|
+
_build_console(
|
|
342
|
+
stderr_console,
|
|
343
|
+
semantic_styles=semantic_styles,
|
|
344
|
+
time_output=time_output,
|
|
345
|
+
),
|
|
346
|
+
semantic_styles,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
_STDOUT_CONSOLE: _ConfiguredConsole
|
|
351
|
+
_STDERR_CONSOLE: _ConfiguredConsole
|
|
352
|
+
_SEMANTIC_STYLES: SemanticStyles | None
|
|
353
|
+
_REQUESTED_COLOR_MODE: RequestedColorMode | None = None
|
|
354
|
+
_ACTIVE_COLOR_MODE: ColorMode = "auto"
|
|
355
|
+
_STDOUT_STREAM: IO[str] = sys.stdout
|
|
356
|
+
_STDERR_STREAM: IO[str] = sys.stderr
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def rich_color_mode(color_mode: RequestedColorMode | None) -> ColorMode:
|
|
360
|
+
"""Map `jj`-style color modes onto Rich's supported console modes."""
|
|
361
|
+
|
|
362
|
+
if color_mode in {"always", "debug"}:
|
|
363
|
+
return "always"
|
|
364
|
+
if color_mode == "never":
|
|
365
|
+
return "never"
|
|
366
|
+
return "auto"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@contextmanager
|
|
370
|
+
def configured_console(
|
|
371
|
+
*,
|
|
372
|
+
cli_args: JjCliArgs = _NO_CLI_ARGS,
|
|
373
|
+
color_mode: ColorMode = "auto",
|
|
374
|
+
repo: Path | None = None,
|
|
375
|
+
requested_color_mode: RequestedColorMode | None = None,
|
|
376
|
+
stderr: IO[str] | None = None,
|
|
377
|
+
stdout: IO[str] | None = None,
|
|
378
|
+
time_output: bool = False,
|
|
379
|
+
):
|
|
380
|
+
"""Temporarily install shared stdout and stderr consoles."""
|
|
381
|
+
|
|
382
|
+
global _STDOUT_CONSOLE
|
|
383
|
+
global _STDERR_CONSOLE
|
|
384
|
+
global _SEMANTIC_STYLES
|
|
385
|
+
global _REQUESTED_COLOR_MODE
|
|
386
|
+
global _ACTIVE_COLOR_MODE
|
|
387
|
+
global _STDOUT_STREAM
|
|
388
|
+
global _STDERR_STREAM
|
|
389
|
+
previous_stdout = _STDOUT_CONSOLE
|
|
390
|
+
previous_stderr = _STDERR_CONSOLE
|
|
391
|
+
previous_semantic_styles = _SEMANTIC_STYLES
|
|
392
|
+
previous_requested_color_mode = _REQUESTED_COLOR_MODE
|
|
393
|
+
previous_active_color_mode = _ACTIVE_COLOR_MODE
|
|
394
|
+
previous_stdout_stream = _STDOUT_STREAM
|
|
395
|
+
previous_stderr_stream = _STDERR_STREAM
|
|
396
|
+
_STDOUT_CONSOLE, _STDERR_CONSOLE, _SEMANTIC_STYLES = _build_consoles(
|
|
397
|
+
cli_args=cli_args,
|
|
398
|
+
color_mode=color_mode,
|
|
399
|
+
repo=repo,
|
|
400
|
+
stderr=stderr,
|
|
401
|
+
stdout=stdout,
|
|
402
|
+
time_output=time_output,
|
|
403
|
+
)
|
|
404
|
+
_REQUESTED_COLOR_MODE = requested_color_mode
|
|
405
|
+
_ACTIVE_COLOR_MODE = color_mode
|
|
406
|
+
_STDOUT_STREAM = sys.stdout if stdout is None else stdout
|
|
407
|
+
_STDERR_STREAM = sys.stderr if stderr is None else stderr
|
|
408
|
+
try:
|
|
409
|
+
yield
|
|
410
|
+
finally:
|
|
411
|
+
_STDOUT_CONSOLE = previous_stdout
|
|
412
|
+
_STDERR_CONSOLE = previous_stderr
|
|
413
|
+
_SEMANTIC_STYLES = previous_semantic_styles
|
|
414
|
+
_REQUESTED_COLOR_MODE = previous_requested_color_mode
|
|
415
|
+
_ACTIVE_COLOR_MODE = previous_active_color_mode
|
|
416
|
+
_STDOUT_STREAM = previous_stdout_stream
|
|
417
|
+
_STDERR_STREAM = previous_stderr_stream
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def requested_color_mode() -> RequestedColorMode | None:
|
|
421
|
+
"""Return the active CLI `--color` override, if one was supplied."""
|
|
422
|
+
|
|
423
|
+
return _REQUESTED_COLOR_MODE
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def semantic_style(*labels: str) -> Style | None:
|
|
427
|
+
"""Resolve jj semantic color labels into the active Rich style."""
|
|
428
|
+
|
|
429
|
+
if _SEMANTIC_STYLES is None:
|
|
430
|
+
return None
|
|
431
|
+
return _SEMANTIC_STYLES.for_labels(labels)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _render_status_badge(status: ui.StatusBadge) -> Text:
|
|
435
|
+
"""Render a semantic status badge into Rich text."""
|
|
436
|
+
|
|
437
|
+
labels = {
|
|
438
|
+
"ok": ("hint heading",),
|
|
439
|
+
"warn": ("warning heading",),
|
|
440
|
+
"fail": ("error heading",),
|
|
441
|
+
"fixed": ("hint heading",),
|
|
442
|
+
"skip": ("hint heading",),
|
|
443
|
+
}
|
|
444
|
+
return rich_text(ui.semantic_text(status.value, *labels[status.value]))
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def rich_text(
|
|
448
|
+
content: ui.Message,
|
|
449
|
+
*,
|
|
450
|
+
style: StyleArg | None = None,
|
|
451
|
+
) -> Text:
|
|
452
|
+
"""Render semantic template content into Rich `Text`."""
|
|
453
|
+
|
|
454
|
+
rendered = Text("") if style is None else Text("", style=style)
|
|
455
|
+
_append_rich_text(rendered, content, base_style=style)
|
|
456
|
+
return rendered
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def style_time_prefix(text: str) -> str:
|
|
460
|
+
"""Style the `--time-output` prefix using the active semantic theme."""
|
|
461
|
+
|
|
462
|
+
style = semantic_style("prefix", "timestamp")
|
|
463
|
+
if style is None:
|
|
464
|
+
return text
|
|
465
|
+
if not isinstance(_STDERR_CONSOLE, _ConfiguredConsole):
|
|
466
|
+
return text
|
|
467
|
+
rich_console = _STDERR_CONSOLE._console
|
|
468
|
+
with rich_console.capture() as capture:
|
|
469
|
+
rich_console.print(Text(text, style=style), end="")
|
|
470
|
+
return capture.get()
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _coerce_renderable(value: ConsoleObject) -> RenderableType:
|
|
474
|
+
if isinstance(value, str) and "\x1b[" in value:
|
|
475
|
+
return Text.from_ansi(value)
|
|
476
|
+
if isinstance(value, ui.StatusBadge):
|
|
477
|
+
return _render_status_badge(value)
|
|
478
|
+
if isinstance(value, ui.PrefixedLine):
|
|
479
|
+
return _render_prefixed_line(value)
|
|
480
|
+
if isinstance(value, ui.DataTable):
|
|
481
|
+
return _render_data_table(value)
|
|
482
|
+
if isinstance(value, str | Template | ui.SemanticText | tuple):
|
|
483
|
+
return rich_text(value)
|
|
484
|
+
if isinstance(value, str | ConsoleRenderable | RichCast):
|
|
485
|
+
return value
|
|
486
|
+
return str(value)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def output(*objects: ConsoleObject, **kwargs) -> None:
|
|
490
|
+
"""Write plain user-facing output to stdout."""
|
|
491
|
+
|
|
492
|
+
kwargs.setdefault("markup", False)
|
|
493
|
+
_STDOUT_CONSOLE.print(*(_coerce_renderable(obj) for obj in objects), **kwargs)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def machine_output(text: str) -> None:
|
|
497
|
+
"""Write one machine-readable payload without terminal rendering."""
|
|
498
|
+
|
|
499
|
+
_STDOUT_STREAM.write(text)
|
|
500
|
+
if not text.endswith("\n"):
|
|
501
|
+
_STDOUT_STREAM.write("\n")
|
|
502
|
+
_STDOUT_STREAM.flush()
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def error(*objects: ConsoleObject, **kwargs) -> None:
|
|
506
|
+
"""Write styled error output to stderr."""
|
|
507
|
+
|
|
508
|
+
kwargs.setdefault("markup", False)
|
|
509
|
+
kwargs.setdefault("style", semantic_style("error heading") or "red")
|
|
510
|
+
_STDERR_CONSOLE.print(*(_coerce_renderable(obj) for obj in objects), **kwargs)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def stderr_output(*objects: ConsoleObject, **kwargs) -> None:
|
|
514
|
+
"""Write plain user-facing output to stderr."""
|
|
515
|
+
|
|
516
|
+
kwargs.setdefault("markup", False)
|
|
517
|
+
_STDERR_CONSOLE.print(*(_coerce_renderable(obj) for obj in objects), **kwargs)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def warning(*objects: ConsoleObject, **kwargs) -> None:
|
|
521
|
+
"""Write styled warning output to stderr."""
|
|
522
|
+
|
|
523
|
+
kwargs.setdefault("markup", False)
|
|
524
|
+
kwargs.setdefault("style", semantic_style("warning heading") or "yellow")
|
|
525
|
+
_STDERR_CONSOLE.print(*(_coerce_renderable(obj) for obj in objects), **kwargs)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def note(*objects: ConsoleObject, **kwargs) -> None:
|
|
529
|
+
"""Write styled note output to stdout."""
|
|
530
|
+
|
|
531
|
+
kwargs.setdefault("markup", False)
|
|
532
|
+
kwargs.setdefault("style", semantic_style("hint heading") or "cyan")
|
|
533
|
+
_STDOUT_CONSOLE.print(*(_coerce_renderable(obj) for obj in objects), **kwargs)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@contextmanager
|
|
537
|
+
def spinner(*, description: str) -> Generator[SpinnerLike]:
|
|
538
|
+
"""Render a TTY-only transient spinner on stderr."""
|
|
539
|
+
|
|
540
|
+
if not _stream_supports_live_progress(_STDERR_STREAM):
|
|
541
|
+
yield _NullSpinner()
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
progress_console = _progress_console(stream=_STDERR_STREAM, color_mode=_ACTIVE_COLOR_MODE)
|
|
545
|
+
with progress_console.status(description) as status:
|
|
546
|
+
yield _RichSpinnerHandle(status=status)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@contextmanager
|
|
550
|
+
def progress(*, description: str, total: int) -> Generator[ProgressLike]:
|
|
551
|
+
"""Render a TTY-only transient progress bar on stderr."""
|
|
552
|
+
|
|
553
|
+
if total <= 0 or not _stream_supports_live_progress(_STDERR_STREAM):
|
|
554
|
+
yield _NullProgress()
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
progress_console = _progress_console(stream=_STDERR_STREAM, color_mode=_ACTIVE_COLOR_MODE)
|
|
558
|
+
with Progress(
|
|
559
|
+
SpinnerColumn(),
|
|
560
|
+
TextColumn("{task.description}"),
|
|
561
|
+
BarColumn(),
|
|
562
|
+
TaskProgressColumn(),
|
|
563
|
+
console=progress_console,
|
|
564
|
+
transient=True,
|
|
565
|
+
) as progress_render:
|
|
566
|
+
task_id = progress_render.add_task(description, total=total)
|
|
567
|
+
yield _RichProgressHandle(progress=progress_render, task_id=task_id)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _stream_supports_live_progress(stream: IO[str]) -> bool:
|
|
571
|
+
try:
|
|
572
|
+
return bool(stream.isatty())
|
|
573
|
+
except OSError:
|
|
574
|
+
return False
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _progress_console(*, stream: IO[str], color_mode: ColorMode) -> Console:
|
|
578
|
+
if color_mode == "always":
|
|
579
|
+
return Console(file=stream, force_terminal=True, no_color=False)
|
|
580
|
+
if color_mode == "never":
|
|
581
|
+
return Console(file=stream, no_color=True)
|
|
582
|
+
return Console(file=stream)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _append_rich_text(
|
|
586
|
+
rendered: Text,
|
|
587
|
+
content: ui.Message,
|
|
588
|
+
*,
|
|
589
|
+
base_style: StyleArg | None,
|
|
590
|
+
) -> None:
|
|
591
|
+
if isinstance(content, tuple):
|
|
592
|
+
for item in content:
|
|
593
|
+
_append_rich_text(rendered, item, base_style=base_style)
|
|
594
|
+
return
|
|
595
|
+
if isinstance(content, Template):
|
|
596
|
+
for part in content:
|
|
597
|
+
if isinstance(part, str):
|
|
598
|
+
rendered.append(part, style=base_style)
|
|
599
|
+
else:
|
|
600
|
+
_append_rich_text(
|
|
601
|
+
rendered,
|
|
602
|
+
ui.resolve_interpolation(part),
|
|
603
|
+
base_style=base_style,
|
|
604
|
+
)
|
|
605
|
+
return
|
|
606
|
+
if isinstance(content, ui.SemanticText):
|
|
607
|
+
rendered.append(
|
|
608
|
+
content.text,
|
|
609
|
+
style=_combine_styles(base_style, semantic_style(*content.labels)),
|
|
610
|
+
)
|
|
611
|
+
return
|
|
612
|
+
if isinstance(content, Text):
|
|
613
|
+
appended = content.copy()
|
|
614
|
+
if base_style is not None and appended.plain:
|
|
615
|
+
appended.stylize(base_style, 0, len(appended.plain))
|
|
616
|
+
rendered.append_text(appended)
|
|
617
|
+
return
|
|
618
|
+
rendered.append(content, style=base_style)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _combine_styles(
|
|
622
|
+
base_style: StyleArg | None,
|
|
623
|
+
extra_style: StyleArg | None,
|
|
624
|
+
) -> StyleArg | None:
|
|
625
|
+
if base_style is None:
|
|
626
|
+
return extra_style
|
|
627
|
+
if extra_style is None:
|
|
628
|
+
return base_style
|
|
629
|
+
return _to_rich_style(base_style) + _to_rich_style(extra_style)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _to_rich_style(style: StyleArg) -> Style:
|
|
633
|
+
if isinstance(style, Style):
|
|
634
|
+
return style
|
|
635
|
+
return Style.parse(style)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _render_prefixed_line(line: ui.PrefixedLine) -> _HangingIndentRenderable:
|
|
639
|
+
"""Render one semantic hanging-indent line."""
|
|
640
|
+
|
|
641
|
+
prefix_width = max(1, len(ui.plain_text(line.prefix)))
|
|
642
|
+
message_style = (
|
|
643
|
+
semantic_style(*line.message_labels) if line.message_labels is not None else None
|
|
644
|
+
)
|
|
645
|
+
if isinstance(line.body, ui.StatusBadge):
|
|
646
|
+
message_cell = _coerce_renderable(line.body)
|
|
647
|
+
else:
|
|
648
|
+
message_cell: RenderableType = rich_text(line.body, style=message_style)
|
|
649
|
+
|
|
650
|
+
prefix_style = semantic_style(*line.prefix_labels) if line.prefix_labels is not None else None
|
|
651
|
+
prefix_cell: RenderableType = rich_text(line.prefix, style=prefix_style)
|
|
652
|
+
return _HangingIndentRenderable(
|
|
653
|
+
prefix=prefix_cell,
|
|
654
|
+
prefix_width=prefix_width,
|
|
655
|
+
body=message_cell,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _render_data_table(table_data: ui.DataTable) -> ConsoleRenderable:
|
|
660
|
+
"""Render a lightweight semantic table into a Rich table."""
|
|
661
|
+
|
|
662
|
+
box = SIMPLE if table_data.box == "simple" else None
|
|
663
|
+
table = Table(
|
|
664
|
+
box=box,
|
|
665
|
+
expand=table_data.expand,
|
|
666
|
+
header_style=table_data.header_style,
|
|
667
|
+
pad_edge=table_data.pad_edge,
|
|
668
|
+
padding=table_data.padding,
|
|
669
|
+
show_edge=table_data.show_edge,
|
|
670
|
+
show_header=table_data.show_header,
|
|
671
|
+
)
|
|
672
|
+
for column in table_data.columns:
|
|
673
|
+
table.add_column(column.header, no_wrap=column.no_wrap, width=column.width)
|
|
674
|
+
for row in table_data.rows:
|
|
675
|
+
table.add_row(*(_coerce_renderable(cell) for cell in row))
|
|
676
|
+
return _TrimmedRenderable(table)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _line_text(line: list[Segment]) -> str:
|
|
680
|
+
return "".join(segment.text for segment in line if not segment.control)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _rstrip_line_segments(line: list[Segment]) -> list[Segment]:
|
|
684
|
+
trimmed = list(line)
|
|
685
|
+
while trimmed and not trimmed[-1].control and not trimmed[-1].text.strip():
|
|
686
|
+
trimmed.pop()
|
|
687
|
+
if not trimmed:
|
|
688
|
+
return []
|
|
689
|
+
|
|
690
|
+
last = trimmed[-1]
|
|
691
|
+
if not last.control:
|
|
692
|
+
right_trimmed = last.text.rstrip()
|
|
693
|
+
if right_trimmed != last.text:
|
|
694
|
+
if right_trimmed:
|
|
695
|
+
trimmed[-1] = Segment(right_trimmed, last.style, last.control)
|
|
696
|
+
else:
|
|
697
|
+
trimmed.pop()
|
|
698
|
+
return trimmed
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
# The import-time consoles only serve output emitted before a command
|
|
702
|
+
# installs repo-scoped consoles via `configured_console`, so they never load
|
|
703
|
+
# jj semantic styles: that would cost a jj invocation in the wrong directory
|
|
704
|
+
# on every import.
|
|
705
|
+
_STDOUT_CONSOLE, _STDERR_CONSOLE, _SEMANTIC_STYLES = _build_consoles(
|
|
706
|
+
cli_args=_NO_CLI_ARGS,
|
|
707
|
+
load_styles=False,
|
|
708
|
+
)
|