yacta 0.0.1__tar.gz
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.
- yacta-0.0.1/PKG-INFO +14 -0
- yacta-0.0.1/README.md +3 -0
- yacta-0.0.1/pyproject.toml +20 -0
- yacta-0.0.1/src/yacta/__init__.py +5 -0
- yacta-0.0.1/src/yacta/ansi_builder.py +261 -0
- yacta-0.0.1/src/yacta/cmd.py +33 -0
- yacta-0.0.1/src/yacta/command_block_processor.py +150 -0
- yacta-0.0.1/src/yacta/common.py +596 -0
- yacta-0.0.1/src/yacta/css_parser.py +216 -0
- yacta-0.0.1/src/yacta/selector_parser.py +101 -0
yacta-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: yacta
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Yet Another Converter for Text to ANSI
|
|
5
|
+
Author: ars6502
|
|
6
|
+
Author-email: ars6502 <arun.r.sharma@proton.me>
|
|
7
|
+
Requires-Dist: pyhyphen>=4.0.4
|
|
8
|
+
Requires-Dist: typer>=0.24.1
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Yet Another Converter for Text to ANSI
|
|
13
|
+
|
|
14
|
+
For documents formatted specifically for terminal/console
|
yacta-0.0.1/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "yacta"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Yet Another Converter for Text to ANSI"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "ars6502", email = "arun.r.sharma@proton.me" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pyhyphen>=4.0.4",
|
|
12
|
+
"typer>=0.24.1",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
yacta = "yacta:cmd.main"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.11.3,<0.12.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
import shlex
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Dict, List, Optional, Tuple
|
|
8
|
+
from hyphen import Hyphenator
|
|
9
|
+
from .common import *
|
|
10
|
+
|
|
11
|
+
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
12
|
+
|
|
13
|
+
def get_padding(style: Dict[str, object]) -> Tuple[int, int, int, int]:
|
|
14
|
+
"""Return block padding as ``(top, bottom, left, right)`` integers.
|
|
15
|
+
|
|
16
|
+
Missing padding declarations default to zero.
|
|
17
|
+
"""
|
|
18
|
+
return (
|
|
19
|
+
int(style.get("padding-top", 0)),
|
|
20
|
+
int(style.get("padding-bottom", 0)),
|
|
21
|
+
int(style.get("padding-left", 0)),
|
|
22
|
+
int(style.get("padding-right", 0)),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_wrap_width(style: Dict[str, object]) -> Optional[int]:
|
|
27
|
+
"""Return the available text width inside a styled block.
|
|
28
|
+
|
|
29
|
+
When ``max-width`` is present, the method subtracts left and right padding to
|
|
30
|
+
produce the width available to the actual text content. ``None`` means the text
|
|
31
|
+
should not be wrapped to a specific width.
|
|
32
|
+
"""
|
|
33
|
+
max_width = style.get("max-width")
|
|
34
|
+
if max_width is None:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
_, _, padding_left, padding_right = get_padding(style)
|
|
38
|
+
return max(1, int(max_width) - padding_left - padding_right)
|
|
39
|
+
|
|
40
|
+
class ANSIBuilder:
|
|
41
|
+
"""Build ANSI escape sequences and styled terminal output fragments.
|
|
42
|
+
|
|
43
|
+
This class owns all knowledge of how colors, resets, inline emphasis, and
|
|
44
|
+
padded block output are translated into terminal control sequences. Keeping the
|
|
45
|
+
logic here makes the rest of the renderer operate mainly on plain text and
|
|
46
|
+
layout decisions.
|
|
47
|
+
"""
|
|
48
|
+
RESET = "\033[0m"
|
|
49
|
+
BOLD = "\033[1m"
|
|
50
|
+
BOLD_OFF = "\033[22m"
|
|
51
|
+
STYLE_CODES = {
|
|
52
|
+
"BOLD": "1",
|
|
53
|
+
"DIM": "2",
|
|
54
|
+
"ITALIC": "3",
|
|
55
|
+
"UNDERLINE": "4",
|
|
56
|
+
"REVERSE": "7",
|
|
57
|
+
"BLACK": "30",
|
|
58
|
+
"RED": "31",
|
|
59
|
+
"GREEN": "32",
|
|
60
|
+
"YELLOW": "33",
|
|
61
|
+
"BLUE": "34",
|
|
62
|
+
"MAGENTA": "35",
|
|
63
|
+
"CYAN": "36",
|
|
64
|
+
"WHITE": "37",
|
|
65
|
+
"BRIGHT_BLACK": "90",
|
|
66
|
+
"BRIGHT_RED": "91",
|
|
67
|
+
"BRIGHT_GREEN": "92",
|
|
68
|
+
"BRIGHT_YELLOW": "93",
|
|
69
|
+
"BRIGHT_BLUE": "94",
|
|
70
|
+
"BRIGHT_MAGENTA": "95",
|
|
71
|
+
"BRIGHT_CYAN": "96",
|
|
72
|
+
"BRIGHT_WHITE": "97",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def fg(self, rgb: Tuple[int, int, int]) -> str:
|
|
76
|
+
"""Return the ANSI escape sequence for a 24-bit foreground color."""
|
|
77
|
+
r, g, b = rgb
|
|
78
|
+
return f"\033[38;2;{r};{g};{b}m"
|
|
79
|
+
|
|
80
|
+
def bg(self, rgb: Tuple[int, int, int]) -> str:
|
|
81
|
+
"""Return the ANSI escape sequence for a 24-bit background color."""
|
|
82
|
+
r, g, b = rgb
|
|
83
|
+
return f"\033[48;2;{r};{g};{b}m"
|
|
84
|
+
|
|
85
|
+
def block_prefix(self, style: Dict[str, object]) -> str:
|
|
86
|
+
"""Build the base ANSI prefix for a rendered block.
|
|
87
|
+
|
|
88
|
+
The prefix applies the block-level foreground and background colors defined by
|
|
89
|
+
a style map. It is prepended to each physical output line in the block and also
|
|
90
|
+
used after inline resets so block colors continue to apply.
|
|
91
|
+
"""
|
|
92
|
+
parts: List[str] = []
|
|
93
|
+
if "color" in style:
|
|
94
|
+
parts.append(self.fg(style["color"])) # type: ignore[arg-type]
|
|
95
|
+
if "background-color" in style:
|
|
96
|
+
parts.append(self.bg(style["background-color"])) # type: ignore[arg-type]
|
|
97
|
+
return "".join(parts)
|
|
98
|
+
|
|
99
|
+
def visible_len(self, rendered: str) -> int:
|
|
100
|
+
"""Return the printable width of a rendered string.
|
|
101
|
+
|
|
102
|
+
ANSI escape sequences do not occupy terminal columns, so they are removed
|
|
103
|
+
before counting characters.
|
|
104
|
+
"""
|
|
105
|
+
return len(ANSI_RE.sub("", rendered))
|
|
106
|
+
|
|
107
|
+
def pad_rendered_line(self, rendered: str, target_width: int) -> str:
|
|
108
|
+
"""Pad a rendered string with spaces up to ``target_width`` visible columns.
|
|
109
|
+
|
|
110
|
+
The method measures width after stripping ANSI escapes so padding aligns with
|
|
111
|
+
what the terminal actually displays rather than with the raw string length.
|
|
112
|
+
"""
|
|
113
|
+
visible = self.visible_len(rendered)
|
|
114
|
+
return rendered + (" " * max(0, target_width - visible))
|
|
115
|
+
|
|
116
|
+
def render_token(self, token: Token, block_prefix: str = "") -> str:
|
|
117
|
+
"""Render a ``Token`` into ANSI text, restoring the block style afterward.
|
|
118
|
+
|
|
119
|
+
If the token has inline styles, the method emits a style escape sequence, the
|
|
120
|
+
token text, and then resets terminal state. When ``block_prefix`` is supplied,
|
|
121
|
+
it is re-applied after the reset so block colors remain active for the rest of
|
|
122
|
+
the line.
|
|
123
|
+
"""
|
|
124
|
+
if not token.styles:
|
|
125
|
+
return token.text
|
|
126
|
+
|
|
127
|
+
codes = [self.STYLE_CODES[style] for style in token.styles]
|
|
128
|
+
restore = f"{self.RESET}{block_prefix}" if block_prefix else self.RESET
|
|
129
|
+
return f"\033[{';'.join(codes)}m{token.text}{restore}"
|
|
130
|
+
|
|
131
|
+
def render_plain_line(self, parts: List[Token], block_prefix: str) -> str:
|
|
132
|
+
"""Render a sequence of tokens separated by single spaces.
|
|
133
|
+
|
|
134
|
+
This preserves the token order exactly as it appears in the wrapped line model
|
|
135
|
+
and applies inline ANSI styling token by token.
|
|
136
|
+
"""
|
|
137
|
+
return " ".join(self.render_token(part, block_prefix) for part in parts)
|
|
138
|
+
|
|
139
|
+
def justify_rendered_line(self, parts: List[Token], width: int, block_prefix: str) -> str:
|
|
140
|
+
"""Render and justify a non-final line to exactly ``width`` columns.
|
|
141
|
+
|
|
142
|
+
Visible spacing is distributed across the gaps between tokens while each token
|
|
143
|
+
continues to be rendered with its inline styles. The calculation is based on
|
|
144
|
+
visible character widths rather than raw string lengths.
|
|
145
|
+
"""
|
|
146
|
+
if not parts:
|
|
147
|
+
return " " * width
|
|
148
|
+
if len(parts) == 1:
|
|
149
|
+
return self.render_token(parts[0], block_prefix)
|
|
150
|
+
|
|
151
|
+
total_chars = sum(len(part.text) for part in parts)
|
|
152
|
+
gaps = len(parts) - 1
|
|
153
|
+
total_spaces = width - total_chars
|
|
154
|
+
base, extra = divmod(total_spaces, gaps)
|
|
155
|
+
|
|
156
|
+
output: List[str] = []
|
|
157
|
+
for index, part in enumerate(parts[:-1]):
|
|
158
|
+
output.append(self.render_token(part, block_prefix))
|
|
159
|
+
output.append(" " * (base + (1 if index < extra else 0)))
|
|
160
|
+
output.append(self.render_token(parts[-1], block_prefix))
|
|
161
|
+
return "".join(output)
|
|
162
|
+
|
|
163
|
+
def align_rendered_line(
|
|
164
|
+
self,
|
|
165
|
+
rendered: str,
|
|
166
|
+
visible: str,
|
|
167
|
+
width: Optional[int],
|
|
168
|
+
alignment: str,
|
|
169
|
+
) -> str:
|
|
170
|
+
"""Apply left, right, or center alignment to a rendered line.
|
|
171
|
+
|
|
172
|
+
``visible`` supplies the plain-text version of the line so alignment can be
|
|
173
|
+
computed correctly even when ``rendered`` contains ANSI escape sequences.
|
|
174
|
+
"""
|
|
175
|
+
if width is None:
|
|
176
|
+
return rendered
|
|
177
|
+
|
|
178
|
+
visible_len = len(visible)
|
|
179
|
+
if visible_len >= width:
|
|
180
|
+
return rendered
|
|
181
|
+
|
|
182
|
+
if alignment == "right":
|
|
183
|
+
return (" " * (width - visible_len)) + rendered
|
|
184
|
+
if alignment == "center":
|
|
185
|
+
left = (width - visible_len) // 2
|
|
186
|
+
right = width - visible_len - left
|
|
187
|
+
return (" " * left) + rendered + (" " * right)
|
|
188
|
+
return rendered + (" " * (width - visible_len))
|
|
189
|
+
|
|
190
|
+
def name_prefix(self, style: Dict[str, object]) -> str:
|
|
191
|
+
"""Build the ANSI prefix for description-list labels.
|
|
192
|
+
|
|
193
|
+
Description names may use ``name-color`` when present, otherwise they inherit
|
|
194
|
+
the normal text color. The background color, if any, is always preserved so the
|
|
195
|
+
label blends into the containing block.
|
|
196
|
+
"""
|
|
197
|
+
parts: List[str] = []
|
|
198
|
+
if "name-color" in style:
|
|
199
|
+
parts.append(self.fg(style["name-color"])) # type: ignore[arg-type]
|
|
200
|
+
elif "color" in style:
|
|
201
|
+
parts.append(self.fg(style["color"])) # type: ignore[arg-type]
|
|
202
|
+
if "background-color" in style:
|
|
203
|
+
parts.append(self.bg(style["background-color"])) # type: ignore[arg-type]
|
|
204
|
+
return "".join(parts)
|
|
205
|
+
|
|
206
|
+
def render_description_name(self, name: str, style: Dict[str, object], block_prefix: str) -> str:
|
|
207
|
+
"""Render the leading name of a description item in bold, with optional color.
|
|
208
|
+
|
|
209
|
+
The generated string resets styling at the end of the label and then restores
|
|
210
|
+
the surrounding block prefix so subsequent description text continues with the
|
|
211
|
+
block's normal styling.
|
|
212
|
+
"""
|
|
213
|
+
name_prefix = self.name_prefix(style)
|
|
214
|
+
if not name_prefix:
|
|
215
|
+
return f"{self.BOLD}{name}{self.BOLD_OFF}"
|
|
216
|
+
return f"{name_prefix}{self.BOLD}{name}{self.RESET}{block_prefix}"
|
|
217
|
+
|
|
218
|
+
def render_lines_block(
|
|
219
|
+
self,
|
|
220
|
+
rendered_lines: List[str],
|
|
221
|
+
style: Dict[str, object],
|
|
222
|
+
visible_lines: Optional[List[str]] = None,
|
|
223
|
+
) -> str:
|
|
224
|
+
"""Wrap already-rendered lines in block-level padding and color styling.
|
|
225
|
+
|
|
226
|
+
This method is responsible for adding top and bottom blank padding lines, left
|
|
227
|
+
and right space padding, and the block-level ANSI prefix/reset around every
|
|
228
|
+
physical output line.
|
|
229
|
+
"""
|
|
230
|
+
padding_top, padding_bottom, padding_left, padding_right = get_padding(style)
|
|
231
|
+
prefix = self.block_prefix(style)
|
|
232
|
+
|
|
233
|
+
if not rendered_lines:
|
|
234
|
+
rendered_lines = [""]
|
|
235
|
+
|
|
236
|
+
if visible_lines is None:
|
|
237
|
+
visible_lines = [ANSI_RE.sub("", line) for line in rendered_lines]
|
|
238
|
+
|
|
239
|
+
content_width = max(len(line) for line in visible_lines) + padding_left + padding_right
|
|
240
|
+
blank_line = " " * content_width
|
|
241
|
+
visible_width = max(len(line) for line in visible_lines)
|
|
242
|
+
|
|
243
|
+
lines: List[str] = []
|
|
244
|
+
|
|
245
|
+
for _ in range(padding_top):
|
|
246
|
+
lines.append(f"{prefix}{blank_line}{self.RESET}")
|
|
247
|
+
|
|
248
|
+
for rendered_line in rendered_lines:
|
|
249
|
+
padded = self.pad_rendered_line(rendered_line, visible_width)
|
|
250
|
+
content_line = f"{' ' * padding_left}{padded}{' ' * padding_right}"
|
|
251
|
+
lines.append(f"{prefix}{content_line}{self.RESET}")
|
|
252
|
+
|
|
253
|
+
for _ in range(padding_bottom):
|
|
254
|
+
lines.append(f"{prefix}{blank_line}{self.RESET}")
|
|
255
|
+
|
|
256
|
+
return "\n".join(lines)
|
|
257
|
+
|
|
258
|
+
def render_single_block(self, text: str, style: Dict[str, object]) -> str:
|
|
259
|
+
"""Render a one-line block using the same padding and color rules as multi-line blocks."""
|
|
260
|
+
return self.render_lines_block([text], style, visible_lines=[ANSI_RE.sub("", text)])
|
|
261
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from .common import *
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
app = typer.Typer()
|
|
8
|
+
|
|
9
|
+
@app.command()
|
|
10
|
+
def run(
|
|
11
|
+
stylesheet: str = typer.Argument(help="Path to CSS-like stylesheet"),
|
|
12
|
+
textfile: str = typer.Argument(help="Path to marked-up text file"),
|
|
13
|
+
language: str = typer.Option(
|
|
14
|
+
"en_US",
|
|
15
|
+
"--language",
|
|
16
|
+
help="Hyphenation language for all non-header selectors (default: en_US)"
|
|
17
|
+
)
|
|
18
|
+
) -> int:
|
|
19
|
+
"""Parse command-line arguments, render the requested files, and return a process exit code.
|
|
20
|
+
Exit code ``0`` indicates success. ``1`` reports a missing file, and ``2``
|
|
21
|
+
reports invalid input such as malformed stylesheet values.
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
render_file(stylesheet, textfile, language)
|
|
25
|
+
return 0
|
|
26
|
+
except FileNotFoundError as e:
|
|
27
|
+
print(f"File not found: {e.filename}", file=sys.stderr)
|
|
28
|
+
return 1
|
|
29
|
+
except ValueError as e:
|
|
30
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
31
|
+
return 2
|
|
32
|
+
|
|
33
|
+
app()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
import shlex
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Dict, List, Optional, Tuple
|
|
8
|
+
from hyphen import Hyphenator
|
|
9
|
+
|
|
10
|
+
COMMAND_BLOCK_OPEN = "{{{"
|
|
11
|
+
COMMAND_BLOCK_CLOSE = "}}}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ParsedCommandBlock:
|
|
17
|
+
"""Represent one parsed command-expansion block from the content file.
|
|
18
|
+
|
|
19
|
+
``command`` stores the command line extracted from the opening ``{{{ ...`` line.
|
|
20
|
+
``stdin_text`` stores the literal multiline payload found between the opening and
|
|
21
|
+
closing markers. That payload is later piped to the command's standard input and
|
|
22
|
+
the resulting standard output is fed back into the normal content parser.
|
|
23
|
+
"""
|
|
24
|
+
command: str
|
|
25
|
+
stdin_text: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CommandBlockProcessor:
|
|
29
|
+
"""Expand ``{{{ command ... }}}`` blocks before normal content parsing begins.
|
|
30
|
+
|
|
31
|
+
The processor scans raw input lines for opening markers that include a command on
|
|
32
|
+
the same line, collects all following lines up to the closing ``}}}`` marker,
|
|
33
|
+
pipes that collected text to the command's standard input, captures the command's
|
|
34
|
+
standard output, and reinserts the output lines into the parse stream in place of
|
|
35
|
+
the original block.
|
|
36
|
+
"""
|
|
37
|
+
OPEN_RE = re.compile(r"^\s*\{\{\{\s*(?P<command>.*?)\s*$")
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def parse_opening_line(cls, line: str) -> Optional[str]:
|
|
41
|
+
"""Return the command embedded in a command-block opening line.
|
|
42
|
+
|
|
43
|
+
The opening marker must appear on the same line as the command text, for
|
|
44
|
+
example ``{{{ sort -r``. Surrounding whitespace is ignored. When the line is
|
|
45
|
+
not a command-block opener, ``None`` is returned.
|
|
46
|
+
"""
|
|
47
|
+
match = cls.OPEN_RE.match(line.rstrip("\n"))
|
|
48
|
+
if not match:
|
|
49
|
+
return None
|
|
50
|
+
return match.group("command").strip()
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def is_closing_line(line: str) -> bool:
|
|
54
|
+
"""Return ``True`` when ``line`` is the closing ``}}}`` marker.
|
|
55
|
+
|
|
56
|
+
Surrounding whitespace is ignored so the marker can be indented in the input
|
|
57
|
+
file without affecting recognition.
|
|
58
|
+
"""
|
|
59
|
+
return line.rstrip("\n").strip() == COMMAND_BLOCK_CLOSE
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def parse_block(cls, raw_lines: List[str], start_index: int) -> Tuple[ParsedCommandBlock, int]:
|
|
63
|
+
"""Parse one command block starting at ``start_index``.
|
|
64
|
+
|
|
65
|
+
The opening line contributes the command, every following raw line up to the
|
|
66
|
+
closing marker becomes part of the command's standard input, and the returned
|
|
67
|
+
integer points to the first source line after the block. A ``ValueError`` is
|
|
68
|
+
raised for malformed blocks such as an empty command or a missing closing
|
|
69
|
+
marker.
|
|
70
|
+
"""
|
|
71
|
+
command = cls.parse_opening_line(raw_lines[start_index])
|
|
72
|
+
if command is None:
|
|
73
|
+
raise ValueError("Invalid command block: missing opening '{{{' marker")
|
|
74
|
+
if not command:
|
|
75
|
+
raise ValueError("Invalid command block: missing command after '{{{' marker")
|
|
76
|
+
|
|
77
|
+
stdin_lines: List[str] = []
|
|
78
|
+
index = start_index + 1
|
|
79
|
+
|
|
80
|
+
while index < len(raw_lines):
|
|
81
|
+
raw_line = raw_lines[index]
|
|
82
|
+
if cls.is_closing_line(raw_line):
|
|
83
|
+
return ParsedCommandBlock(command, "".join(stdin_lines)), index + 1
|
|
84
|
+
|
|
85
|
+
stdin_lines.append(raw_line)
|
|
86
|
+
index += 1
|
|
87
|
+
|
|
88
|
+
raise ValueError("Invalid command block: missing closing '}}}'")
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def run_command(command: str, stdin_text: str) -> List[str]:
|
|
92
|
+
"""Execute ``command`` with ``stdin_text`` piped to standard input.
|
|
93
|
+
|
|
94
|
+
The command line is split with ``shlex.split`` and executed with
|
|
95
|
+
``shell=False`` so arguments are parsed predictably and shell expansion is not
|
|
96
|
+
applied implicitly. Standard output is returned as a list of raw lines that
|
|
97
|
+
can be reinserted into the parser input stream. Non-zero exit codes and start
|
|
98
|
+
failures are reported as ``ValueError`` with the captured error details.
|
|
99
|
+
"""
|
|
100
|
+
argv = shlex.split(command)
|
|
101
|
+
if not argv:
|
|
102
|
+
raise ValueError("Invalid command block: empty command")
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
completed = subprocess.run(
|
|
106
|
+
argv,
|
|
107
|
+
input=stdin_text,
|
|
108
|
+
text=True,
|
|
109
|
+
capture_output=True,
|
|
110
|
+
check=False,
|
|
111
|
+
)
|
|
112
|
+
except OSError as e:
|
|
113
|
+
raise ValueError(f"Command block failed to start: {command}: {e}") from e
|
|
114
|
+
|
|
115
|
+
if completed.returncode != 0:
|
|
116
|
+
stderr = completed.stderr.rstrip("\n")
|
|
117
|
+
if stderr:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"Command block failed with exit code {completed.returncode}: {command}: {stderr}"
|
|
120
|
+
)
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Command block failed with exit code {completed.returncode}: {command}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return completed.stdout.splitlines(keepends=True)
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def expand_blocks(cls, raw_lines: List[str]) -> List[str]:
|
|
129
|
+
"""Replace every command block in ``raw_lines`` with the command's output.
|
|
130
|
+
|
|
131
|
+
The returned list can be fed directly into the rest of the parsing pipeline.
|
|
132
|
+
Lines that are not part of a command block are preserved unchanged. Command
|
|
133
|
+
output is inserted verbatim so it can contain normal selector lines, blank
|
|
134
|
+
lines, or even structural markers such as the two-column delimiters.
|
|
135
|
+
"""
|
|
136
|
+
expanded: List[str] = []
|
|
137
|
+
index = 0
|
|
138
|
+
|
|
139
|
+
while index < len(raw_lines):
|
|
140
|
+
command = cls.parse_opening_line(raw_lines[index])
|
|
141
|
+
if command is None:
|
|
142
|
+
expanded.append(raw_lines[index])
|
|
143
|
+
index += 1
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
block, index = cls.parse_block(raw_lines, index)
|
|
147
|
+
expanded.extend(cls.run_command(block.command, block.stdin_text))
|
|
148
|
+
|
|
149
|
+
return expanded
|
|
150
|
+
|