autosh 0.0.0__py3-none-any.whl → 0.0.1__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.
- autosh/config.py +78 -0
- autosh/main.py +164 -0
- autosh/md.py +394 -0
- autosh/plugins/__init__.py +87 -0
- autosh/plugins/calc.py +22 -0
- autosh/plugins/cli.py +222 -0
- autosh/plugins/clock.py +20 -0
- autosh/plugins/code.py +68 -0
- autosh/plugins/search.py +90 -0
- autosh/plugins/web.py +73 -0
- autosh/session.py +193 -0
- autosh-0.0.1.dist-info/METADATA +77 -0
- autosh-0.0.1.dist-info/RECORD +17 -0
- {autosh-0.0.0.dist-info → autosh-0.0.1.dist-info}/WHEEL +1 -1
- autosh-0.0.1.dist-info/entry_points.txt +3 -0
- {autosh-0.0.0.dist-info → autosh-0.0.1.dist-info/licenses}/LICENSE +1 -1
- autosh-0.0.0.dist-info/METADATA +0 -16
- autosh-0.0.0.dist-info/RECORD +0 -5
- {reserved → autosh}/__init__.py +0 -0
autosh/config.py
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
import sys
|
2
|
+
from pydantic import BaseModel, Field
|
3
|
+
from pathlib import Path
|
4
|
+
import tomllib
|
5
|
+
|
6
|
+
USER_CONFIG_PATH = Path.home() / ".config" / "autosh" / "config.toml"
|
7
|
+
|
8
|
+
|
9
|
+
class EmptyConfig(BaseModel): ...
|
10
|
+
|
11
|
+
|
12
|
+
class SearchConfig(BaseModel):
|
13
|
+
tavily_api_key: str = Field(..., description="Tavily API key.")
|
14
|
+
|
15
|
+
|
16
|
+
class WebConfig(BaseModel):
|
17
|
+
tavily_api_key: str = Field(..., description="Tavily API key.")
|
18
|
+
|
19
|
+
|
20
|
+
class Plugins(BaseModel):
|
21
|
+
calc: EmptyConfig | None = None
|
22
|
+
cli: EmptyConfig | None = None
|
23
|
+
clock: EmptyConfig | None = None
|
24
|
+
code: EmptyConfig | None = None
|
25
|
+
search: SearchConfig | None = None
|
26
|
+
web: WebConfig | None = None
|
27
|
+
|
28
|
+
|
29
|
+
class Config(BaseModel):
|
30
|
+
model: str = Field(default="openai/gpt-4.1", description="The LLM model to use")
|
31
|
+
think_model: str = Field(
|
32
|
+
default="openai/o4-mini-high",
|
33
|
+
description="The LLM model to use for reasoning before executing commands",
|
34
|
+
)
|
35
|
+
api_key: str | None = Field(default=None, description="OpenRouter API key.")
|
36
|
+
|
37
|
+
plugins: Plugins = Field(
|
38
|
+
default_factory=Plugins,
|
39
|
+
description="Plugin configuration. Set to null to disable the plugin.",
|
40
|
+
)
|
41
|
+
|
42
|
+
@staticmethod
|
43
|
+
def load() -> "Config":
|
44
|
+
if USER_CONFIG_PATH.is_file():
|
45
|
+
doc = tomllib.loads(USER_CONFIG_PATH.read_text())
|
46
|
+
main = doc.get("autosh", {})
|
47
|
+
plugins = Plugins(**doc.get("plugins", {}))
|
48
|
+
config = Config.model_validate({**main, "plugins": plugins})
|
49
|
+
else:
|
50
|
+
config = Config()
|
51
|
+
return config
|
52
|
+
|
53
|
+
|
54
|
+
CONFIG = Config.load()
|
55
|
+
|
56
|
+
|
57
|
+
class CLIOptions(BaseModel):
|
58
|
+
yes: bool = False
|
59
|
+
quiet: bool = False
|
60
|
+
think: bool = False
|
61
|
+
|
62
|
+
prompt: str | None = None
|
63
|
+
"""The prompt to execute"""
|
64
|
+
|
65
|
+
script: Path | None = None
|
66
|
+
"""The scripe providing the prompt"""
|
67
|
+
|
68
|
+
stdin_is_script: bool = False
|
69
|
+
"""STDIN is a script, not a piped input."""
|
70
|
+
|
71
|
+
args: list[str] = Field(default_factory=list, description="Command line arguments")
|
72
|
+
|
73
|
+
def stdin_has_data(self) -> bool:
|
74
|
+
"""Check if stdin has data."""
|
75
|
+
return not sys.stdin.isatty() and not CLI_OPTIONS.stdin_is_script
|
76
|
+
|
77
|
+
|
78
|
+
CLI_OPTIONS = CLIOptions()
|
autosh/main.py
ADDED
@@ -0,0 +1,164 @@
|
|
1
|
+
import os
|
2
|
+
from pathlib import Path
|
3
|
+
import rich
|
4
|
+
import typer
|
5
|
+
import asyncio
|
6
|
+
import dotenv
|
7
|
+
from rich.columns import Columns
|
8
|
+
from rich.panel import Panel
|
9
|
+
import argparse
|
10
|
+
|
11
|
+
from autosh.config import CLI_OPTIONS, CONFIG
|
12
|
+
from .session import Session
|
13
|
+
import sys
|
14
|
+
|
15
|
+
|
16
|
+
app = typer.Typer(
|
17
|
+
no_args_is_help=False,
|
18
|
+
add_completion=False,
|
19
|
+
context_settings=dict(help_option_names=["-h", "--help"]),
|
20
|
+
pretty_exceptions_short=True,
|
21
|
+
pretty_exceptions_show_locals=False,
|
22
|
+
help="Autosh is a command line tool that helps you automate your tasks using LLMs.",
|
23
|
+
)
|
24
|
+
|
25
|
+
|
26
|
+
async def start_session(prompt: str | None, args: list[str]):
|
27
|
+
CLI_OPTIONS.args = args
|
28
|
+
session = Session()
|
29
|
+
await session.init()
|
30
|
+
piped_stdin = not sys.stdin.isatty()
|
31
|
+
if piped_stdin and not CLI_OPTIONS.yes:
|
32
|
+
rich.print(
|
33
|
+
"[bold red]Error:[/bold red] [red]--yes is required when using piped stdin.[/red]"
|
34
|
+
)
|
35
|
+
sys.exit(1)
|
36
|
+
if prompt:
|
37
|
+
# No piped stdin, just run the prompt
|
38
|
+
if Path(prompt).is_file():
|
39
|
+
# Prompt is a file, read it and execute it
|
40
|
+
await session.exec_script(Path(prompt))
|
41
|
+
else:
|
42
|
+
# Prompt is a string, execute it directly
|
43
|
+
await session.exec_prompt(prompt)
|
44
|
+
elif not prompt and not sys.stdin.isatty():
|
45
|
+
# Piped stdin without prompt, treat piped stdin as a prompt.
|
46
|
+
await session.exec_from_stdin()
|
47
|
+
else:
|
48
|
+
await session.run_repl()
|
49
|
+
|
50
|
+
|
51
|
+
def print_help():
|
52
|
+
cmd = Path(sys.argv[0]).name
|
53
|
+
rich.print(
|
54
|
+
f"\n [bold yellow]Usage:[/bold yellow] [bold]{cmd} [OPTIONS] [--] [PROMPT_OR_FILE] [ARGS]...[/bold]\n"
|
55
|
+
)
|
56
|
+
|
57
|
+
args = [
|
58
|
+
["prompt_or_file", "[PROMPT_OR_FILE]", "The prompt or file to execute."],
|
59
|
+
["args", "[ARGS]...", "The arguments to pass to the script."],
|
60
|
+
]
|
61
|
+
options = [
|
62
|
+
["--yes", "-y", "Auto confirm all prompts."],
|
63
|
+
["--quiet", "-q", "Suppress all output."],
|
64
|
+
[
|
65
|
+
"--model",
|
66
|
+
"-m",
|
67
|
+
f"The LLM model to use. [dim]Default: {CONFIG.model} ({CONFIG.think_model} for reasoning).[/dim]",
|
68
|
+
],
|
69
|
+
["--think", "", "Use the reasoning models to think more before operating."],
|
70
|
+
["--help", "-h", "Show this message and exit."],
|
71
|
+
]
|
72
|
+
|
73
|
+
rich.print(
|
74
|
+
Panel.fit(
|
75
|
+
Columns(
|
76
|
+
[
|
77
|
+
"\n".join([a[0] for a in args]),
|
78
|
+
"\n".join([f"[bold yellow]\\{a[1]}[/bold yellow]" for a in args]),
|
79
|
+
"\n".join([" " + a[2] for a in args]),
|
80
|
+
],
|
81
|
+
padding=(0, 3),
|
82
|
+
),
|
83
|
+
title="[dim]Arguments[/dim]",
|
84
|
+
title_align="left",
|
85
|
+
padding=(0, 3),
|
86
|
+
)
|
87
|
+
)
|
88
|
+
|
89
|
+
rich.print(
|
90
|
+
Panel.fit(
|
91
|
+
Columns(
|
92
|
+
[
|
93
|
+
"\n".join([f"[bold blue]{o[0]}[/bold blue]" for o in options]),
|
94
|
+
"\n".join([f"[bold green]{o[1]}[/bold green]" for o in options]),
|
95
|
+
"\n".join([" " + o[2] for o in options]),
|
96
|
+
],
|
97
|
+
padding=(0, 2),
|
98
|
+
),
|
99
|
+
title="[dim]Options[/dim]",
|
100
|
+
title_align="left",
|
101
|
+
padding=(0, 3),
|
102
|
+
)
|
103
|
+
)
|
104
|
+
|
105
|
+
|
106
|
+
def parse_args() -> tuple[str | None, list[str]]:
|
107
|
+
p = argparse.ArgumentParser(add_help=False, exit_on_error=False)
|
108
|
+
|
109
|
+
p.add_argument("--help", "-h", action="store_true")
|
110
|
+
p.add_argument("--yes", "-y", action="store_true")
|
111
|
+
p.add_argument("--quiet", "-q", action="store_true")
|
112
|
+
p.add_argument("--think", action="store_true")
|
113
|
+
p.add_argument("--model", "-m", type=str, default=None)
|
114
|
+
p.add_argument("PROMPT_OR_FILE", nargs="?", default=None)
|
115
|
+
p.add_argument("ARGS", nargs=argparse.REMAINDER)
|
116
|
+
|
117
|
+
try:
|
118
|
+
args = p.parse_args()
|
119
|
+
except argparse.ArgumentError as e:
|
120
|
+
rich.print(f"[bold red]Error:[/bold red] {str(e)}")
|
121
|
+
print_help()
|
122
|
+
sys.exit(1)
|
123
|
+
|
124
|
+
if args.help:
|
125
|
+
print_help()
|
126
|
+
sys.exit(0)
|
127
|
+
|
128
|
+
CLI_OPTIONS.yes = args.yes
|
129
|
+
CLI_OPTIONS.quiet = args.quiet
|
130
|
+
|
131
|
+
if args.model:
|
132
|
+
if args.think:
|
133
|
+
CONFIG.think_model = args.model
|
134
|
+
else:
|
135
|
+
CONFIG.model = args.model
|
136
|
+
|
137
|
+
if args.think:
|
138
|
+
CLI_OPTIONS.think = True
|
139
|
+
|
140
|
+
prompt = args.PROMPT_OR_FILE.strip() if args.PROMPT_OR_FILE else None
|
141
|
+
|
142
|
+
if prompt == "":
|
143
|
+
prompt = None
|
144
|
+
|
145
|
+
return prompt, (args.ARGS or [])
|
146
|
+
|
147
|
+
|
148
|
+
def main():
|
149
|
+
# dotenv.load_dotenv()
|
150
|
+
prompt, args = parse_args()
|
151
|
+
|
152
|
+
if CONFIG.api_key is None:
|
153
|
+
if key := os.getenv("OPENROUTER_API_KEY"):
|
154
|
+
CONFIG.api_key = key
|
155
|
+
else:
|
156
|
+
rich.print(
|
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)
|
160
|
+
try:
|
161
|
+
asyncio.run(start_session(prompt, args))
|
162
|
+
except (KeyboardInterrupt, EOFError):
|
163
|
+
rich.print("\n[red]Aborted.[/red]")
|
164
|
+
sys.exit(1)
|
autosh/md.py
ADDED
@@ -0,0 +1,394 @@
|
|
1
|
+
import os
|
2
|
+
from typing import AsyncGenerator, Literal
|
3
|
+
|
4
|
+
|
5
|
+
HIGHLIGHT_COLOR_START = "35"
|
6
|
+
HIGHLIGHT_COLOR_END = "0"
|
7
|
+
|
8
|
+
|
9
|
+
class MarkdowmPrinter:
|
10
|
+
def __init__(self, stream: AsyncGenerator[str, None]):
|
11
|
+
async def char_stream(stream: AsyncGenerator[str, None]):
|
12
|
+
async for chunk in stream:
|
13
|
+
for char in chunk:
|
14
|
+
yield char
|
15
|
+
|
16
|
+
self.stream = char_stream(stream)
|
17
|
+
self.__buf: str = ""
|
18
|
+
self.__eof = False
|
19
|
+
|
20
|
+
def peek(self):
|
21
|
+
c = self.__buf[0] if len(self.__buf) > 0 else None
|
22
|
+
return c
|
23
|
+
|
24
|
+
async def __ensure_length(self, n: int):
|
25
|
+
while (not self.__eof) and len(self.__buf) < n:
|
26
|
+
try:
|
27
|
+
self.__buf += await self.stream.__anext__()
|
28
|
+
except StopAsyncIteration:
|
29
|
+
self.__eof = True
|
30
|
+
|
31
|
+
async def __check_unordered_list_label(self) -> bool:
|
32
|
+
if self.__eof:
|
33
|
+
return False
|
34
|
+
await self.__ensure_length(2)
|
35
|
+
buf = self.__buf
|
36
|
+
if len(buf) < 2:
|
37
|
+
return False
|
38
|
+
if buf[0] in ["-", "+", "*"] and buf[1] == " ":
|
39
|
+
return True
|
40
|
+
return False
|
41
|
+
|
42
|
+
async def __check_ordered_list_label(self) -> bool:
|
43
|
+
if self.__eof:
|
44
|
+
return False
|
45
|
+
await self.__ensure_length(5)
|
46
|
+
buf = self.__buf
|
47
|
+
# \d+\.
|
48
|
+
if len(buf) == 0:
|
49
|
+
return False
|
50
|
+
if not buf[0].isnumeric():
|
51
|
+
return False
|
52
|
+
has_dot = False
|
53
|
+
for i in range(1, 5):
|
54
|
+
if i >= len(buf):
|
55
|
+
return False
|
56
|
+
c = buf[i]
|
57
|
+
if c == ".":
|
58
|
+
if has_dot:
|
59
|
+
return False
|
60
|
+
has_dot = True
|
61
|
+
continue
|
62
|
+
if c == " ":
|
63
|
+
if has_dot:
|
64
|
+
return True
|
65
|
+
return False
|
66
|
+
if c.isnumeric():
|
67
|
+
continue
|
68
|
+
return False
|
69
|
+
|
70
|
+
async def check(self, s: str):
|
71
|
+
if len(s) == 0:
|
72
|
+
return True
|
73
|
+
await self.__ensure_length(len(s))
|
74
|
+
if len(self.__buf) < len(s):
|
75
|
+
return False
|
76
|
+
return self.__buf[0 : len(s)] == s
|
77
|
+
|
78
|
+
async def check_non_paragraph_block_start(self):
|
79
|
+
await self.__ensure_length(3)
|
80
|
+
buf = self.__buf[:3] if len(self.__buf) >= 3 else self.__buf
|
81
|
+
if buf.startswith("```"):
|
82
|
+
return True
|
83
|
+
if buf.startswith("---"):
|
84
|
+
return True
|
85
|
+
if buf.startswith("> "):
|
86
|
+
return True
|
87
|
+
if await self.__check_ordered_list_label():
|
88
|
+
return True
|
89
|
+
if await self.__check_unordered_list_label():
|
90
|
+
return True
|
91
|
+
return False
|
92
|
+
|
93
|
+
async def next(self):
|
94
|
+
c = self.__buf[0] if len(self.__buf) > 0 else None
|
95
|
+
self.__buf = self.__buf[1:] if len(self.__buf) > 0 else ""
|
96
|
+
if c is None:
|
97
|
+
return None
|
98
|
+
if not self.__eof:
|
99
|
+
try:
|
100
|
+
self.__buf += await self.stream.__anext__()
|
101
|
+
except StopAsyncIteration:
|
102
|
+
self.__eof = True
|
103
|
+
return c
|
104
|
+
|
105
|
+
def print(self, s: str):
|
106
|
+
print(s, end="", flush=True)
|
107
|
+
|
108
|
+
async def parse_single_line_text(
|
109
|
+
self,
|
110
|
+
outer_is_italic: bool = False,
|
111
|
+
outer_is_bold: bool = False,
|
112
|
+
outer_is_dim: bool = False,
|
113
|
+
):
|
114
|
+
styles: list[Literal["code", "bold", "italic", "strike"]] = []
|
115
|
+
|
116
|
+
def find(s: Literal["code", "bold", "italic", "strike"]):
|
117
|
+
for i in range(len(styles) - 1, -1, -1):
|
118
|
+
if styles[i] == s:
|
119
|
+
return i
|
120
|
+
return None
|
121
|
+
|
122
|
+
def find_italic_first():
|
123
|
+
for i in range(len(styles) - 1, -1, -1):
|
124
|
+
if styles[i] == "bold":
|
125
|
+
return False
|
126
|
+
if styles[i] == "italic":
|
127
|
+
return True
|
128
|
+
return False
|
129
|
+
|
130
|
+
# Remove leading spaces
|
131
|
+
while self.peek() in [" ", "\t"]:
|
132
|
+
await self.next()
|
133
|
+
|
134
|
+
while True:
|
135
|
+
not_code = find("code") is None
|
136
|
+
c = self.peek()
|
137
|
+
if c == "\n" or c is None:
|
138
|
+
await self.next()
|
139
|
+
self.print("\x1b[0m\n") # Reset all and newline
|
140
|
+
return
|
141
|
+
match c:
|
142
|
+
case "`":
|
143
|
+
await self.next()
|
144
|
+
if (i := find("code")) is not None:
|
145
|
+
if not outer_is_dim:
|
146
|
+
self.print("\x1b[22m")
|
147
|
+
if find("bold") is not None or outer_is_bold:
|
148
|
+
self.print("\x1b[1m")
|
149
|
+
styles = styles[:i]
|
150
|
+
else:
|
151
|
+
self.print("\x1b[2m")
|
152
|
+
styles.append("code")
|
153
|
+
# Bold
|
154
|
+
case "*" if (
|
155
|
+
not_code and await self.check("**") and not find_italic_first()
|
156
|
+
):
|
157
|
+
await self.next()
|
158
|
+
await self.next()
|
159
|
+
# print(">", styles, find("bold"))
|
160
|
+
if (i := find("bold")) is not None:
|
161
|
+
if not outer_is_bold:
|
162
|
+
self.print("\x1b[22m")
|
163
|
+
styles = styles[:i]
|
164
|
+
else:
|
165
|
+
self.print("\x1b[1m")
|
166
|
+
styles.append("bold")
|
167
|
+
case "_" if (
|
168
|
+
not_code and await self.check("__") and not find_italic_first()
|
169
|
+
):
|
170
|
+
await self.next()
|
171
|
+
await self.next()
|
172
|
+
if (i := find("bold")) is not None:
|
173
|
+
if not outer_is_bold:
|
174
|
+
self.print("\x1b[22m")
|
175
|
+
styles = styles[:i]
|
176
|
+
else:
|
177
|
+
self.print("\x1b[1m")
|
178
|
+
styles.append("bold")
|
179
|
+
# Italic
|
180
|
+
case "*" | "_" if (
|
181
|
+
not_code
|
182
|
+
and not await self.check("* ")
|
183
|
+
and not await self.check("_ ")
|
184
|
+
):
|
185
|
+
await self.next()
|
186
|
+
if (i := find("italic")) is not None:
|
187
|
+
if not outer_is_italic:
|
188
|
+
self.print("\x1b[23m")
|
189
|
+
styles = styles[:i]
|
190
|
+
# print(styles, await self.check("**"))
|
191
|
+
else:
|
192
|
+
self.print("\x1b[3m")
|
193
|
+
styles.append("italic")
|
194
|
+
# Strike through
|
195
|
+
case "~" if not_code and await self.check("~~"):
|
196
|
+
await self.next()
|
197
|
+
await self.next()
|
198
|
+
if (i := find("strike")) is not None:
|
199
|
+
self.print("\x1b[29m")
|
200
|
+
styles = styles[:i]
|
201
|
+
else:
|
202
|
+
self.print("\x1b[9m")
|
203
|
+
styles.append("strike")
|
204
|
+
case _:
|
205
|
+
self.print(c)
|
206
|
+
await self.next()
|
207
|
+
|
208
|
+
async def parse_heading(self):
|
209
|
+
hashes = 0
|
210
|
+
while True:
|
211
|
+
c = await self.next()
|
212
|
+
if c == "#":
|
213
|
+
hashes += 1
|
214
|
+
else:
|
215
|
+
break
|
216
|
+
# Start control
|
217
|
+
match hashes:
|
218
|
+
case 1:
|
219
|
+
self.print("\x1b[45;1;2m") # Magenta background, bold, dim
|
220
|
+
self.print("#" * hashes)
|
221
|
+
self.print(" \x1b[22;1m") # Reset dim
|
222
|
+
await self.parse_single_line_text(outer_is_bold=True)
|
223
|
+
case 2:
|
224
|
+
self.print("\x1b[35;1;2;4m") # Magenta foreground, bold, dim, underline
|
225
|
+
self.print("#" * hashes)
|
226
|
+
self.print(" \x1b[22m\x1b[1m") # Reset dim
|
227
|
+
await self.parse_single_line_text(outer_is_bold=True)
|
228
|
+
case 3:
|
229
|
+
self.print("\x1b[35;1;2m") # Magenta foreground, bold, dim
|
230
|
+
self.print("#" * hashes)
|
231
|
+
self.print(" \x1b[22m\x1b[1m") # Reset dim
|
232
|
+
await self.parse_single_line_text(outer_is_bold=True)
|
233
|
+
case 4:
|
234
|
+
self.print("\x1b[35;2;3m") # Magenta foreground, dim, italic
|
235
|
+
self.print("#" * hashes)
|
236
|
+
self.print(" \x1b[22m") # Reset dim
|
237
|
+
await self.parse_single_line_text(outer_is_italic=True)
|
238
|
+
case _:
|
239
|
+
self.print("\x1b[2m") # dim
|
240
|
+
self.print("#" * hashes)
|
241
|
+
self.print(" \x1b[22m") # Reset dim
|
242
|
+
await self.parse_single_line_text()
|
243
|
+
# Stream title
|
244
|
+
|
245
|
+
async def parse_paragraph(self):
|
246
|
+
while True:
|
247
|
+
await self.parse_single_line_text()
|
248
|
+
if self.peek() != "\n" and not await self.check_non_paragraph_block_start():
|
249
|
+
await self.next()
|
250
|
+
break
|
251
|
+
else:
|
252
|
+
break
|
253
|
+
|
254
|
+
async def parse_multiline_code(self):
|
255
|
+
# dim
|
256
|
+
self.print("\x1b[2m")
|
257
|
+
self.print("```")
|
258
|
+
await self.next()
|
259
|
+
await self.next()
|
260
|
+
await self.next()
|
261
|
+
while not await self.check("\n```"):
|
262
|
+
c = await self.next()
|
263
|
+
if c is None:
|
264
|
+
self.print("\n")
|
265
|
+
return
|
266
|
+
self.print(c)
|
267
|
+
self.print("\n```\n")
|
268
|
+
await self.next()
|
269
|
+
await self.next()
|
270
|
+
await self.next()
|
271
|
+
await self.next()
|
272
|
+
|
273
|
+
async def parse_list(self, ordered: bool):
|
274
|
+
indents = [0]
|
275
|
+
counter = [1]
|
276
|
+
# first item
|
277
|
+
if ordered:
|
278
|
+
self.print("1. ")
|
279
|
+
await self.next()
|
280
|
+
else:
|
281
|
+
self.print("• ")
|
282
|
+
await self.next()
|
283
|
+
await self.parse_single_line_text()
|
284
|
+
while True:
|
285
|
+
indent = 0
|
286
|
+
while self.peek() in [" ", "\t", "\n"]:
|
287
|
+
if self.peek() in [" ", "\t"]:
|
288
|
+
indent += 1
|
289
|
+
if self.peek() == "\n":
|
290
|
+
indent = 0
|
291
|
+
await self.next()
|
292
|
+
if self.peek() is None:
|
293
|
+
return
|
294
|
+
if ordered and not await self.__check_ordered_list_label():
|
295
|
+
return
|
296
|
+
if not ordered and not await self.__check_unordered_list_label():
|
297
|
+
return
|
298
|
+
if not ordered:
|
299
|
+
await self.next()
|
300
|
+
else:
|
301
|
+
while self.peek() is not None and self.peek() != ".":
|
302
|
+
await self.next()
|
303
|
+
await self.next()
|
304
|
+
|
305
|
+
depth = None
|
306
|
+
for i in range(len(indents) - 1):
|
307
|
+
if indents[i] <= indent and indents[i + 1] > indent:
|
308
|
+
depth = i
|
309
|
+
break
|
310
|
+
if depth is None and indents[-1] + 2 <= indent:
|
311
|
+
# indent one more level
|
312
|
+
indents.append(indent)
|
313
|
+
depth = len(indents) - 1
|
314
|
+
counter.append(1)
|
315
|
+
elif depth is None:
|
316
|
+
# same as last level
|
317
|
+
depth = len(indents) - 1
|
318
|
+
counter[depth] += 1
|
319
|
+
else:
|
320
|
+
# dedent
|
321
|
+
indents = indents[: depth + 1]
|
322
|
+
counter = counter[: depth + 1]
|
323
|
+
counter[depth] += 1
|
324
|
+
if not ordered:
|
325
|
+
self.print(" " * depth + "• ")
|
326
|
+
else:
|
327
|
+
self.print(" " * depth + str(counter[depth]) + ". ")
|
328
|
+
await self.parse_single_line_text()
|
329
|
+
|
330
|
+
async def parse_blockquote(self):
|
331
|
+
while True:
|
332
|
+
while self.peek() in [" ", "\t"]:
|
333
|
+
await self.next()
|
334
|
+
if self.peek() != ">":
|
335
|
+
break
|
336
|
+
await self.next()
|
337
|
+
self.print("\x1b[1;2m|\x1b[22;2m ")
|
338
|
+
await self.parse_single_line_text(outer_is_dim=True)
|
339
|
+
|
340
|
+
async def parse_doc(self):
|
341
|
+
self.__buf = await self.stream.__anext__()
|
342
|
+
start = True
|
343
|
+
while True:
|
344
|
+
# Remove leading spaces and empty lines
|
345
|
+
indent = 0
|
346
|
+
while self.peek() in [" ", "\t", "\n"]:
|
347
|
+
if self.peek() in [" ", "\t"]:
|
348
|
+
indent += 1
|
349
|
+
if self.peek() == "\n":
|
350
|
+
indent = 0
|
351
|
+
await self.next()
|
352
|
+
if self.peek() is None:
|
353
|
+
break
|
354
|
+
if not start:
|
355
|
+
self.print("\n")
|
356
|
+
start = False
|
357
|
+
match c := self.peek():
|
358
|
+
case None:
|
359
|
+
break
|
360
|
+
# Heading
|
361
|
+
case "#":
|
362
|
+
await self.parse_heading()
|
363
|
+
# Code
|
364
|
+
case "`" if await self.check("```"):
|
365
|
+
await self.parse_multiline_code()
|
366
|
+
# Separator
|
367
|
+
case _ if await self.check("---"):
|
368
|
+
await self.next()
|
369
|
+
await self.next()
|
370
|
+
await self.next()
|
371
|
+
width = min(os.get_terminal_size().columns, 80)
|
372
|
+
self.print("\x1b[2m" + "─" * width + "\x1b[22m\n")
|
373
|
+
# Unordered list
|
374
|
+
case _ if await self.__check_unordered_list_label():
|
375
|
+
await self.parse_list(False)
|
376
|
+
# Ordered list
|
377
|
+
case _ if await self.__check_ordered_list_label():
|
378
|
+
await self.parse_list(True)
|
379
|
+
# Blockquote
|
380
|
+
case ">":
|
381
|
+
await self.parse_blockquote()
|
382
|
+
# Normal paragraph
|
383
|
+
case _:
|
384
|
+
await self.parse_paragraph()
|
385
|
+
self.print("\x1b[0m\x1b[0m\x1b[0m") # Reset all
|
386
|
+
self.print("\x1b[0m") # Reset all
|
387
|
+
|
388
|
+
def __await__(self):
|
389
|
+
return self.parse_doc().__await__()
|
390
|
+
|
391
|
+
|
392
|
+
async def stream_md(stream: AsyncGenerator[str, None]):
|
393
|
+
mp = MarkdowmPrinter(stream)
|
394
|
+
await mp.parse_doc()
|