autosh 0.0.1__tar.gz → 0.0.3__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.
- {autosh-0.0.1 → autosh-0.0.3}/.gitignore +1 -0
- {autosh-0.0.1 → autosh-0.0.3}/PKG-INFO +8 -4
- {autosh-0.0.1 → autosh-0.0.3}/README.md +7 -3
- autosh-0.0.3/autosh/config-template.toml +13 -0
- {autosh-0.0.1 → autosh-0.0.3}/autosh/config.py +15 -4
- {autosh-0.0.1 → autosh-0.0.3}/autosh/main.py +7 -8
- autosh-0.0.3/autosh/md/__init__.py +8 -0
- autosh-0.0.3/autosh/md/inline_text.py +214 -0
- autosh-0.0.3/autosh/md/printer.py +301 -0
- autosh-0.0.3/autosh/md/state.py +136 -0
- autosh-0.0.3/autosh/md/stream.py +107 -0
- {autosh-0.0.1 → autosh-0.0.3}/autosh/plugins/__init__.py +2 -2
- {autosh-0.0.1 → autosh-0.0.3}/autosh/plugins/cli.py +42 -0
- {autosh-0.0.1 → autosh-0.0.3}/autosh/session.py +74 -7
- {autosh-0.0.1 → autosh-0.0.3}/pyproject.toml +1 -1
- autosh-0.0.1/autosh/md.py +0 -394
- {autosh-0.0.1 → autosh-0.0.3}/LICENSE +0 -0
- {autosh-0.0.1 → autosh-0.0.3}/autosh/__init__.py +0 -0
- {autosh-0.0.1 → autosh-0.0.3}/autosh/plugins/calc.py +0 -0
- {autosh-0.0.1 → autosh-0.0.3}/autosh/plugins/clock.py +0 -0
- {autosh-0.0.1 → autosh-0.0.3}/autosh/plugins/code.py +0 -0
- {autosh-0.0.1 → autosh-0.0.3}/autosh/plugins/search.py +0 -0
- {autosh-0.0.1 → autosh-0.0.3}/autosh/plugins/web.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: autosh
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.3
|
4
4
|
Summary: Add your description here
|
5
5
|
License-File: LICENSE
|
6
6
|
Requires-Python: >=3.13
|
@@ -31,7 +31,9 @@ As an interactive shell: `ash` (alternatively, `autosh`)
|
|
31
31
|
|
32
32
|
Execute a single prompt: `ash "list current directory"`
|
33
33
|
|
34
|
-
Process piped data:
|
34
|
+
Process piped data:
|
35
|
+
* `cat README.md | ash -y "summarise"`
|
36
|
+
* `cat in.csv | ash -y -q "double the first numeric column" > out.csv`
|
35
37
|
|
36
38
|
## Scripting
|
37
39
|
|
@@ -72,6 +74,8 @@ Write "Hello, world" to _test.log
|
|
72
74
|
|
73
75
|
# TODO
|
74
76
|
|
75
|
-
- [ ] Image generation
|
76
|
-
- [ ] Image input
|
77
|
+
- [ ] Image input, generation, and editing
|
77
78
|
- [ ] RAG for non-text files
|
79
|
+
- [ ] Plugin system
|
80
|
+
- [ ] MCP support
|
81
|
+
- [ ] A better input widget with history and auto completion
|
@@ -14,7 +14,9 @@ As an interactive shell: `ash` (alternatively, `autosh`)
|
|
14
14
|
|
15
15
|
Execute a single prompt: `ash "list current directory"`
|
16
16
|
|
17
|
-
Process piped data:
|
17
|
+
Process piped data:
|
18
|
+
* `cat README.md | ash -y "summarise"`
|
19
|
+
* `cat in.csv | ash -y -q "double the first numeric column" > out.csv`
|
18
20
|
|
19
21
|
## Scripting
|
20
22
|
|
@@ -55,6 +57,8 @@ Write "Hello, world" to _test.log
|
|
55
57
|
|
56
58
|
# TODO
|
57
59
|
|
58
|
-
- [ ] Image generation
|
59
|
-
- [ ] Image input
|
60
|
+
- [ ] Image input, generation, and editing
|
60
61
|
- [ ] RAG for non-text files
|
62
|
+
- [ ] Plugin system
|
63
|
+
- [ ] MCP support
|
64
|
+
- [ ] A better input widget with history and auto completion
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# autosh configuration file
|
2
|
+
[autosh]
|
3
|
+
api_key = "sk-or-v1-..."
|
4
|
+
# model = "openai/gpt-4.1"
|
5
|
+
# think_model = "openai/o4-mini-high"
|
6
|
+
|
7
|
+
[plugins]
|
8
|
+
calc = {}
|
9
|
+
cli = {}
|
10
|
+
clock = {}
|
11
|
+
code = {}
|
12
|
+
# search = { tavily_api_key = "tvly-dev-..." }
|
13
|
+
# web = { tavily_api_key = "tvly-dev-..." }
|
@@ -3,6 +3,8 @@ from pydantic import BaseModel, Field
|
|
3
3
|
from pathlib import Path
|
4
4
|
import tomllib
|
5
5
|
|
6
|
+
import rich
|
7
|
+
|
6
8
|
USER_CONFIG_PATH = Path.home() / ".config" / "autosh" / "config.toml"
|
7
9
|
|
8
10
|
|
@@ -41,11 +43,20 @@ class Config(BaseModel):
|
|
41
43
|
|
42
44
|
@staticmethod
|
43
45
|
def load() -> "Config":
|
46
|
+
if not USER_CONFIG_PATH.is_file():
|
47
|
+
# Copy config.template.toml to USER_CONFIG_PATH
|
48
|
+
USER_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
49
|
+
template = Path(__file__).parent / "config.template.toml"
|
50
|
+
USER_CONFIG_PATH.write_text(template.read_text())
|
44
51
|
if USER_CONFIG_PATH.is_file():
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
52
|
+
try:
|
53
|
+
doc = tomllib.loads(USER_CONFIG_PATH.read_text())
|
54
|
+
main = doc.get("autosh", {})
|
55
|
+
plugins = Plugins(**doc.get("plugins", {}))
|
56
|
+
config = Config.model_validate({**main, "plugins": plugins})
|
57
|
+
except tomllib.TOMLDecodeError as e:
|
58
|
+
rich.print(f"[bold red]Error:[/bold red] invalid config file: {e}")
|
59
|
+
sys.exit(1)
|
49
60
|
else:
|
50
61
|
config = Config()
|
51
62
|
return config
|
@@ -8,7 +8,7 @@ from rich.columns import Columns
|
|
8
8
|
from rich.panel import Panel
|
9
9
|
import argparse
|
10
10
|
|
11
|
-
from autosh.config import CLI_OPTIONS, CONFIG
|
11
|
+
from autosh.config import CLI_OPTIONS, CONFIG, USER_CONFIG_PATH
|
12
12
|
from .session import Session
|
13
13
|
import sys
|
14
14
|
|
@@ -149,14 +149,13 @@ def main():
|
|
149
149
|
# dotenv.load_dotenv()
|
150
150
|
prompt, args = parse_args()
|
151
151
|
|
152
|
+
if key := os.getenv("OPENROUTER_API_KEY"):
|
153
|
+
CONFIG.api_key = key
|
152
154
|
if CONFIG.api_key is None:
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
"[bold red]Error:[/bold red] [red]No API key found. Please set the OPENROUTER_API_KEY environment variable or add it to your config file.[/red]"
|
158
|
-
)
|
159
|
-
sys.exit(1)
|
155
|
+
rich.print(
|
156
|
+
f"[bold red]Error:[/bold red] [red]OpenRouter API key not found.\nPlease set the OPENROUTER_API_KEY environment variable or add it to your config file: {USER_CONFIG_PATH}.[/red]"
|
157
|
+
)
|
158
|
+
sys.exit(1)
|
160
159
|
try:
|
161
160
|
asyncio.run(start_session(prompt, args))
|
162
161
|
except (KeyboardInterrupt, EOFError):
|
@@ -0,0 +1,214 @@
|
|
1
|
+
from dataclasses import dataclass, field
|
2
|
+
from autosh.md.printer import StreamedMarkdownPrinter
|
3
|
+
from autosh.md.state import State
|
4
|
+
|
5
|
+
|
6
|
+
@dataclass
|
7
|
+
class Keyword:
|
8
|
+
token: str
|
9
|
+
|
10
|
+
def is_bold(self) -> bool:
|
11
|
+
return self.token[0] in ["*", "_"] and len(self.token) == 2
|
12
|
+
|
13
|
+
def is_italic(self) -> bool:
|
14
|
+
return self.token[0] in ["*", "_"] and len(self.token) == 1
|
15
|
+
|
16
|
+
def is_bold_and_italic(self) -> bool:
|
17
|
+
return self.token[0] in ["*", "_"] and len(self.token) > 2
|
18
|
+
|
19
|
+
def is_bold_or_italic(self) -> bool:
|
20
|
+
return self.token[0] in ["*", "_"]
|
21
|
+
|
22
|
+
|
23
|
+
class InvalidState(Exception):
|
24
|
+
def __init__(self, token: str):
|
25
|
+
super().__init__(token)
|
26
|
+
self.token = token
|
27
|
+
|
28
|
+
|
29
|
+
@dataclass
|
30
|
+
class InlineScope:
|
31
|
+
state: State
|
32
|
+
|
33
|
+
stack: list[tuple[Keyword, State]] = field(default_factory=list)
|
34
|
+
|
35
|
+
@property
|
36
|
+
def last(self) -> str | None:
|
37
|
+
return self.stack[-1][0].token if len(self.stack) > 0 else None
|
38
|
+
|
39
|
+
@property
|
40
|
+
def strike(self) -> bool:
|
41
|
+
return self.last == "~~"
|
42
|
+
|
43
|
+
@property
|
44
|
+
def code(self) -> bool:
|
45
|
+
return self.last == "`"
|
46
|
+
|
47
|
+
def enter(self, kind: Keyword):
|
48
|
+
match kind:
|
49
|
+
case "`":
|
50
|
+
state = self.state._enter_scope(dim=True)
|
51
|
+
case "~~":
|
52
|
+
state = self.state._enter_scope(strike=True)
|
53
|
+
case _ if kind.is_bold():
|
54
|
+
state = self.state._enter_scope(bold=True)
|
55
|
+
case _ if kind.is_italic():
|
56
|
+
state = self.state._enter_scope(italic=True)
|
57
|
+
case _ if kind.is_bold_and_italic():
|
58
|
+
state = self.state._enter_scope(bold=True, italic=True)
|
59
|
+
case _:
|
60
|
+
state = self.state._enter_scope()
|
61
|
+
self.stack.append((kind, state))
|
62
|
+
|
63
|
+
def exit(self):
|
64
|
+
t = self.stack.pop()
|
65
|
+
state = t[1]
|
66
|
+
self.state._exit_scope(state)
|
67
|
+
|
68
|
+
|
69
|
+
class InlineTextPrinter:
|
70
|
+
def __init__(self, p: StreamedMarkdownPrinter, table: bool = False):
|
71
|
+
self.p = p
|
72
|
+
self.terminator = ["\n", None]
|
73
|
+
if table:
|
74
|
+
self.terminator.append("|")
|
75
|
+
|
76
|
+
def emit(self, s: str):
|
77
|
+
self.p.emit(s)
|
78
|
+
|
79
|
+
def peek(self):
|
80
|
+
return self.p.peek()
|
81
|
+
|
82
|
+
async def consume(self, n: int = 1):
|
83
|
+
return await self.p.consume(n)
|
84
|
+
|
85
|
+
async def check(self, s: str, eof: bool | None = None) -> bool:
|
86
|
+
return await self.p.check(s, eof)
|
87
|
+
|
88
|
+
async def parse_inline_unformatted(self):
|
89
|
+
# Parse until another "`" or a newline, or EOF
|
90
|
+
while not self.peek() in self.terminator:
|
91
|
+
c = await self.p.consume()
|
92
|
+
self.emit(c)
|
93
|
+
|
94
|
+
async def parse_inline_code(self):
|
95
|
+
self.emit("`")
|
96
|
+
# Parse until another "`" or a newline, or EOF
|
97
|
+
while not await self.check("`"):
|
98
|
+
c = await self.p.consume()
|
99
|
+
if c in self.terminator:
|
100
|
+
return
|
101
|
+
self.emit(c)
|
102
|
+
if c == "\\":
|
103
|
+
c = await self.p.consume()
|
104
|
+
if c in self.terminator:
|
105
|
+
return
|
106
|
+
self.emit(c)
|
107
|
+
self.emit("`")
|
108
|
+
await self.p.consume()
|
109
|
+
|
110
|
+
async def get_next_token(self) -> str | Keyword | None:
|
111
|
+
c = self.peek()
|
112
|
+
if c in self.terminator:
|
113
|
+
return None
|
114
|
+
if c == "`":
|
115
|
+
await self.consume()
|
116
|
+
return Keyword("`")
|
117
|
+
if c == "*":
|
118
|
+
s = ""
|
119
|
+
while self.peek() == "*":
|
120
|
+
s += c
|
121
|
+
await self.consume()
|
122
|
+
return Keyword(s)
|
123
|
+
if c == "_":
|
124
|
+
s = ""
|
125
|
+
while self.peek() == "_":
|
126
|
+
s += c
|
127
|
+
await self.consume()
|
128
|
+
return Keyword(s)
|
129
|
+
if c == "~" and await self.check("~~"):
|
130
|
+
s = ""
|
131
|
+
while self.peek() == "~":
|
132
|
+
s += c
|
133
|
+
await self.consume()
|
134
|
+
return Keyword(s)
|
135
|
+
if c == " " or c == "\t":
|
136
|
+
s = ""
|
137
|
+
while self.peek() == " " or self.peek() == "\t":
|
138
|
+
s += c
|
139
|
+
await self.consume()
|
140
|
+
return s
|
141
|
+
s = c or ""
|
142
|
+
if c == "\\":
|
143
|
+
await self.consume()
|
144
|
+
c = self.peek()
|
145
|
+
if c in self.terminator:
|
146
|
+
return None if len(s) == 0 else s
|
147
|
+
s += c or ""
|
148
|
+
await self.consume()
|
149
|
+
else:
|
150
|
+
await self.consume()
|
151
|
+
if len(s) == 0:
|
152
|
+
return None
|
153
|
+
return s
|
154
|
+
|
155
|
+
async def parse_inline(self, consume_trailing_newline: bool = True):
|
156
|
+
last_is_space = False
|
157
|
+
start = True
|
158
|
+
scope = InlineScope(self.p.state)
|
159
|
+
|
160
|
+
while True:
|
161
|
+
t = await self.get_next_token()
|
162
|
+
curr_is_space = False
|
163
|
+
|
164
|
+
if t is None:
|
165
|
+
if consume_trailing_newline:
|
166
|
+
if self.peek() == "\n":
|
167
|
+
await self.consume()
|
168
|
+
self.emit("\n")
|
169
|
+
return
|
170
|
+
elif isinstance(t, str):
|
171
|
+
# Space or normal text
|
172
|
+
if t[0] == " ":
|
173
|
+
curr_is_space = True
|
174
|
+
self.emit(" ")
|
175
|
+
else:
|
176
|
+
self.emit(t)
|
177
|
+
elif t.token == "`":
|
178
|
+
# Inline code
|
179
|
+
scope.enter(t)
|
180
|
+
with self.p.state.style(dim=True):
|
181
|
+
await self.parse_inline_code()
|
182
|
+
scope.exit()
|
183
|
+
elif t.token == "~~":
|
184
|
+
# Strike through
|
185
|
+
if not scope.strike:
|
186
|
+
scope.enter(t)
|
187
|
+
self.emit("~~")
|
188
|
+
else:
|
189
|
+
self.emit("~~")
|
190
|
+
scope.exit()
|
191
|
+
elif (
|
192
|
+
t.is_bold_or_italic()
|
193
|
+
and (last_is_space or start)
|
194
|
+
and scope.last != t.token
|
195
|
+
):
|
196
|
+
# Start bold or italics
|
197
|
+
scope.enter(t)
|
198
|
+
self.emit(t.token)
|
199
|
+
elif (
|
200
|
+
t.is_bold_or_italic()
|
201
|
+
and self.peek() in [" ", "\t", *self.terminator]
|
202
|
+
and scope.last == t.token
|
203
|
+
):
|
204
|
+
# End bold or italics
|
205
|
+
self.emit(t.token)
|
206
|
+
scope.exit()
|
207
|
+
else:
|
208
|
+
# print(
|
209
|
+
# "Invalid token:", t.token, f"[{self.peek()}]", scope.last == t.token
|
210
|
+
# )
|
211
|
+
raise InvalidState(t.token)
|
212
|
+
|
213
|
+
last_is_space = curr_is_space
|
214
|
+
start = False
|
@@ -0,0 +1,301 @@
|
|
1
|
+
import os
|
2
|
+
from typing import AsyncGenerator, Literal
|
3
|
+
import unicodedata
|
4
|
+
|
5
|
+
from autosh.md.state import Color, State
|
6
|
+
from autosh.md.stream import TextStream
|
7
|
+
|
8
|
+
|
9
|
+
class StreamedMarkdownPrinter:
|
10
|
+
def __init__(self, gen: AsyncGenerator[str, None]):
|
11
|
+
self.stream = TextStream(gen)
|
12
|
+
self.state = State()
|
13
|
+
|
14
|
+
def emit(self, s: str):
|
15
|
+
self.state.emit(s)
|
16
|
+
|
17
|
+
def peek(self):
|
18
|
+
return self.stream.peek()
|
19
|
+
|
20
|
+
async def consume(self, n: int = 1):
|
21
|
+
return await self.stream.consume(n)
|
22
|
+
|
23
|
+
async def check(self, s: str, eof: bool | None = None) -> bool:
|
24
|
+
return await self.stream.check(s, eof)
|
25
|
+
|
26
|
+
async def parse_inline(self, consume_trailing_newline: bool = True):
|
27
|
+
from autosh.md.inline_text import InlineTextPrinter
|
28
|
+
|
29
|
+
itp = InlineTextPrinter(self)
|
30
|
+
await itp.parse_inline(consume_trailing_newline)
|
31
|
+
|
32
|
+
async def parse_heading(self):
|
33
|
+
hashes = 0
|
34
|
+
while True:
|
35
|
+
c = await self.consume()
|
36
|
+
if c == "#":
|
37
|
+
hashes += 1
|
38
|
+
else:
|
39
|
+
break
|
40
|
+
match hashes:
|
41
|
+
case 1:
|
42
|
+
with self.state.style(bold=True, bg=Color.MAGENTA):
|
43
|
+
with self.state.style(dim=True):
|
44
|
+
self.emit("#" * hashes + " ")
|
45
|
+
await self.parse_inline(consume_trailing_newline=False)
|
46
|
+
case 2:
|
47
|
+
with self.state.style(bold=True, underline=True, color=Color.MAGENTA):
|
48
|
+
with self.state.style(dim=True):
|
49
|
+
self.emit("#" * hashes + " ")
|
50
|
+
await self.parse_inline(consume_trailing_newline=False)
|
51
|
+
case 3:
|
52
|
+
with self.state.style(bold=True, color=Color.MAGENTA):
|
53
|
+
with self.state.style(dim=True):
|
54
|
+
self.emit("#" * hashes + " ")
|
55
|
+
await self.parse_inline(consume_trailing_newline=False)
|
56
|
+
case 4:
|
57
|
+
with self.state.style(bold=True, italic=True, color=Color.MAGENTA):
|
58
|
+
with self.state.style(dim=True):
|
59
|
+
self.emit("#" * hashes + " ")
|
60
|
+
await self.parse_inline(consume_trailing_newline=False)
|
61
|
+
case _:
|
62
|
+
with self.state.style(bold=True):
|
63
|
+
with self.state.style(dim=True):
|
64
|
+
self.emit("#" * hashes + " ")
|
65
|
+
await self.parse_inline(consume_trailing_newline=False)
|
66
|
+
await self.consume() # consume the newline
|
67
|
+
self.emit("\n")
|
68
|
+
|
69
|
+
async def parse_paragraph(self):
|
70
|
+
while True:
|
71
|
+
await self.parse_inline()
|
72
|
+
if (
|
73
|
+
self.peek() != "\n"
|
74
|
+
and not await self.stream.non_paragraph_block_start()
|
75
|
+
):
|
76
|
+
if self.peek() == "\n":
|
77
|
+
await self.consume()
|
78
|
+
break
|
79
|
+
else:
|
80
|
+
break
|
81
|
+
|
82
|
+
async def parse_multiline_code(self):
|
83
|
+
with self.state.style(dim=True):
|
84
|
+
self.emit("```")
|
85
|
+
await self.consume(3)
|
86
|
+
while not await self.check("\n```"):
|
87
|
+
c = await self.consume()
|
88
|
+
if c is None:
|
89
|
+
self.emit("\n")
|
90
|
+
return
|
91
|
+
self.emit(c)
|
92
|
+
self.emit("\n```\n")
|
93
|
+
await self.consume(4)
|
94
|
+
|
95
|
+
async def parse_list(self, ordered: bool):
|
96
|
+
indents = [0]
|
97
|
+
counter = [1]
|
98
|
+
# first item
|
99
|
+
if ordered:
|
100
|
+
self.emit("1. ")
|
101
|
+
await self.consume()
|
102
|
+
else:
|
103
|
+
self.emit("• ")
|
104
|
+
await self.consume()
|
105
|
+
await self.parse_inline()
|
106
|
+
while True:
|
107
|
+
indent = 0
|
108
|
+
while self.peek() in [" ", "\t", "\n"]:
|
109
|
+
if self.peek() in [" ", "\t"]:
|
110
|
+
indent += 1
|
111
|
+
if self.peek() == "\n":
|
112
|
+
indent = 0
|
113
|
+
await self.consume()
|
114
|
+
if self.peek() is None:
|
115
|
+
return
|
116
|
+
if ordered and not await self.stream.ordered_list_label():
|
117
|
+
return
|
118
|
+
if not ordered and not await self.stream.unordered_list_label():
|
119
|
+
return
|
120
|
+
if not ordered:
|
121
|
+
await self.consume()
|
122
|
+
else:
|
123
|
+
while self.peek() is not None and self.peek() != ".":
|
124
|
+
await self.consume()
|
125
|
+
await self.consume()
|
126
|
+
|
127
|
+
depth = None
|
128
|
+
for i in range(len(indents) - 1):
|
129
|
+
if indents[i] <= indent and indents[i + 1] > indent:
|
130
|
+
depth = i
|
131
|
+
break
|
132
|
+
if depth is None and indents[-1] + 2 <= indent:
|
133
|
+
# indent one more level
|
134
|
+
indents.append(indent)
|
135
|
+
depth = len(indents) - 1
|
136
|
+
counter.append(1)
|
137
|
+
elif depth is None:
|
138
|
+
# same as last level
|
139
|
+
depth = len(indents) - 1
|
140
|
+
counter[depth] += 1
|
141
|
+
else:
|
142
|
+
# dedent
|
143
|
+
indents = indents[: depth + 1]
|
144
|
+
counter = counter[: depth + 1]
|
145
|
+
counter[depth] += 1
|
146
|
+
if not ordered:
|
147
|
+
self.emit(" " * depth + "• ")
|
148
|
+
else:
|
149
|
+
self.emit(" " * depth + str(counter[depth]) + ". ")
|
150
|
+
await self.parse_inline()
|
151
|
+
|
152
|
+
async def parse_blockquote(self):
|
153
|
+
from autosh.md.inline_text import InlineTextPrinter
|
154
|
+
|
155
|
+
while True:
|
156
|
+
while self.peek() in [" ", "\t"]:
|
157
|
+
await self.consume()
|
158
|
+
if self.peek() != ">":
|
159
|
+
break
|
160
|
+
await self.consume()
|
161
|
+
with self.state.style(bold=True, dim=True):
|
162
|
+
self.emit(">")
|
163
|
+
with self.state.style(dim=True):
|
164
|
+
itp = InlineTextPrinter(self)
|
165
|
+
await itp.parse_inline_unformatted()
|
166
|
+
if self.peek() == "\n":
|
167
|
+
self.emit("\n")
|
168
|
+
await self.consume()
|
169
|
+
|
170
|
+
async def parse_table(self):
|
171
|
+
def str_size(s: str) -> int:
|
172
|
+
size = 0
|
173
|
+
for c in s:
|
174
|
+
match unicodedata.east_asian_width(c):
|
175
|
+
case "F" | "W":
|
176
|
+
size += 2
|
177
|
+
case _:
|
178
|
+
size += 1
|
179
|
+
return size
|
180
|
+
|
181
|
+
rows: list[list[str]] = []
|
182
|
+
while True:
|
183
|
+
if self.peek() != "|":
|
184
|
+
break
|
185
|
+
s = "|"
|
186
|
+
await self.consume()
|
187
|
+
while self.peek() not in ["\n", None]:
|
188
|
+
s += self.peek() or ""
|
189
|
+
await self.consume()
|
190
|
+
if self.peek() == "\n":
|
191
|
+
await self.consume()
|
192
|
+
s = s.strip("|")
|
193
|
+
rows.append([c.strip() for c in s.split("|")])
|
194
|
+
# not enough rows
|
195
|
+
if len(rows) < 2:
|
196
|
+
self.emit("| " + " | ".join(rows[0]) + " |\n")
|
197
|
+
# do some simple formatting
|
198
|
+
cols = len(rows[0])
|
199
|
+
col_widths = [0] * cols
|
200
|
+
aligns: list[Literal["left", "right", "center"]] = ["left"] * cols
|
201
|
+
for i, row in enumerate(rows):
|
202
|
+
if i == 1:
|
203
|
+
# check for alignment
|
204
|
+
for j, c in enumerate(row):
|
205
|
+
if c.startswith(":") and c.endswith(":"):
|
206
|
+
aligns[j] = "center"
|
207
|
+
elif c.startswith(":"):
|
208
|
+
aligns[j] = "left"
|
209
|
+
elif c.endswith(":"):
|
210
|
+
aligns[j] = "right"
|
211
|
+
continue
|
212
|
+
for j, c in enumerate(row):
|
213
|
+
col_widths[j] = max(col_widths[j], str_size(c))
|
214
|
+
# print top border
|
215
|
+
with self.state.style(dim=True):
|
216
|
+
for j, c in enumerate(rows[0]):
|
217
|
+
self.emit("┌" if j == 0 else "┬")
|
218
|
+
self.emit("─" * (col_widths[j] + 2))
|
219
|
+
self.emit("┐\n")
|
220
|
+
# print the table
|
221
|
+
for i, row in enumerate(rows):
|
222
|
+
for j, c in enumerate(row):
|
223
|
+
with self.state.style(dim=True):
|
224
|
+
self.emit("│" if i != 1 else ("├" if j == 0 else "┼"))
|
225
|
+
if i == 1:
|
226
|
+
text = "─" * (col_widths[j] + 2)
|
227
|
+
else:
|
228
|
+
s = str_size(c)
|
229
|
+
align = aligns[j] if i != 0 else "center"
|
230
|
+
match align:
|
231
|
+
case "left":
|
232
|
+
text = c + " " * (col_widths[j] - s)
|
233
|
+
case "right":
|
234
|
+
text = " " * (col_widths[j] - s) + c
|
235
|
+
case "center":
|
236
|
+
padding = col_widths[j] - s
|
237
|
+
text = " " * (padding // 2) + c
|
238
|
+
text += " " * (padding - padding // 2)
|
239
|
+
text = f" {text} "
|
240
|
+
with self.state.style(dim=i == 1):
|
241
|
+
self.emit(text)
|
242
|
+
with self.state.style(dim=True):
|
243
|
+
self.emit("│\n" if i != 1 else "┤\n")
|
244
|
+
# print bottom border
|
245
|
+
with self.state.style(dim=True):
|
246
|
+
for j, c in enumerate(rows[0]):
|
247
|
+
self.emit("└" if j == 0 else "┴")
|
248
|
+
self.emit("─" * (col_widths[j] + 2))
|
249
|
+
self.emit("┘\n")
|
250
|
+
|
251
|
+
async def parse_doc(self):
|
252
|
+
await self.stream.init()
|
253
|
+
start = True
|
254
|
+
while True:
|
255
|
+
# Remove leading spaces and empty lines
|
256
|
+
indent = 0
|
257
|
+
while self.peek() in [" ", "\t", "\n"]:
|
258
|
+
if self.peek() in [" ", "\t"]:
|
259
|
+
indent += 1
|
260
|
+
if self.peek() == "\n":
|
261
|
+
indent = 0
|
262
|
+
await self.consume()
|
263
|
+
if self.peek() is None:
|
264
|
+
break
|
265
|
+
if not start:
|
266
|
+
self.emit("\n")
|
267
|
+
start = False
|
268
|
+
match c := self.peek():
|
269
|
+
case None:
|
270
|
+
break
|
271
|
+
# Heading
|
272
|
+
case "#":
|
273
|
+
await self.parse_heading()
|
274
|
+
# Code
|
275
|
+
case "`" if await self.check("```"):
|
276
|
+
await self.parse_multiline_code()
|
277
|
+
# Separator
|
278
|
+
case _ if await self.check("---"):
|
279
|
+
await self.consume(3)
|
280
|
+
width = min(os.get_terminal_size().columns, 80)
|
281
|
+
with self.state.style(dim=True):
|
282
|
+
self.emit("─" * width)
|
283
|
+
# Unordered list
|
284
|
+
case _ if await self.stream.unordered_list_label():
|
285
|
+
await self.parse_list(False)
|
286
|
+
# Ordered list
|
287
|
+
case _ if await self.stream.ordered_list_label():
|
288
|
+
await self.parse_list(True)
|
289
|
+
# Blockquote
|
290
|
+
case ">":
|
291
|
+
await self.parse_blockquote()
|
292
|
+
# Table
|
293
|
+
case "|":
|
294
|
+
await self.parse_table()
|
295
|
+
# Normal paragraph
|
296
|
+
case _:
|
297
|
+
await self.parse_paragraph()
|
298
|
+
self.emit("\x1b[0m") # Reset all
|
299
|
+
|
300
|
+
def __await__(self):
|
301
|
+
return self.parse_doc().__await__()
|