omdev 0.0.0.dev439__py3-none-any.whl → 0.0.0.dev486__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of omdev might be problematic. Click here for more details.

Files changed (134) hide show
  1. omdev/.omlish-manifests.json +18 -30
  2. omdev/__about__.py +9 -7
  3. omdev/amalg/gen/gen.py +49 -6
  4. omdev/amalg/gen/imports.py +1 -1
  5. omdev/amalg/gen/manifests.py +1 -1
  6. omdev/amalg/gen/resources.py +1 -1
  7. omdev/amalg/gen/srcfiles.py +13 -3
  8. omdev/amalg/gen/strip.py +1 -1
  9. omdev/amalg/gen/types.py +1 -1
  10. omdev/amalg/gen/typing.py +1 -1
  11. omdev/amalg/info.py +32 -0
  12. omdev/cache/data/actions.py +1 -1
  13. omdev/cache/data/specs.py +1 -1
  14. omdev/cexts/_boilerplate.cc +2 -3
  15. omdev/cexts/cmake.py +4 -1
  16. omdev/ci/cli.py +1 -2
  17. omdev/ci/github/api/v2/api.py +2 -0
  18. omdev/cmdlog/cli.py +1 -2
  19. omdev/dataclasses/_dumping.py +1960 -0
  20. omdev/dataclasses/_template.py +22 -0
  21. omdev/dataclasses/cli.py +6 -1
  22. omdev/dataclasses/codegen.py +340 -60
  23. omdev/dataclasses/dumping.py +200 -0
  24. omdev/interp/uv/provider.py +1 -0
  25. omdev/interp/venvs.py +1 -0
  26. omdev/irc/messages/base.py +50 -0
  27. omdev/irc/messages/formats.py +92 -0
  28. omdev/irc/messages/messages.py +775 -0
  29. omdev/irc/messages/parsing.py +99 -0
  30. omdev/irc/numerics/__init__.py +0 -0
  31. omdev/irc/numerics/formats.py +97 -0
  32. omdev/irc/numerics/numerics.py +865 -0
  33. omdev/irc/numerics/types.py +59 -0
  34. omdev/irc/protocol/LICENSE +11 -0
  35. omdev/irc/protocol/__init__.py +61 -0
  36. omdev/irc/protocol/consts.py +6 -0
  37. omdev/irc/protocol/errors.py +30 -0
  38. omdev/irc/protocol/message.py +21 -0
  39. omdev/irc/protocol/nuh.py +55 -0
  40. omdev/irc/protocol/parsing.py +158 -0
  41. omdev/irc/protocol/rendering.py +153 -0
  42. omdev/irc/protocol/tags.py +102 -0
  43. omdev/irc/protocol/utils.py +30 -0
  44. omdev/manifests/_dumping.py +125 -25
  45. omdev/markdown/__init__.py +0 -0
  46. omdev/markdown/incparse.py +116 -0
  47. omdev/markdown/tokens.py +51 -0
  48. omdev/packaging/marshal.py +8 -8
  49. omdev/packaging/requires.py +6 -6
  50. omdev/packaging/specifiers.py +2 -1
  51. omdev/packaging/versions.py +4 -4
  52. omdev/packaging/wheelfile.py +2 -0
  53. omdev/precheck/blanklines.py +66 -0
  54. omdev/precheck/caches.py +1 -1
  55. omdev/precheck/imports.py +14 -1
  56. omdev/precheck/main.py +4 -3
  57. omdev/precheck/unicode.py +39 -15
  58. omdev/py/asts/__init__.py +0 -0
  59. omdev/py/asts/parents.py +28 -0
  60. omdev/py/asts/toplevel.py +123 -0
  61. omdev/py/asts/visitors.py +18 -0
  62. omdev/py/attrdocs.py +6 -7
  63. omdev/py/bracepy.py +12 -4
  64. omdev/py/reprs.py +32 -0
  65. omdev/py/srcheaders.py +1 -1
  66. omdev/py/tokens/__init__.py +0 -0
  67. omdev/py/tools/mkrelimp.py +1 -1
  68. omdev/py/tools/pipdepup.py +629 -0
  69. omdev/pyproject/pkg.py +190 -45
  70. omdev/pyproject/reqs.py +31 -9
  71. omdev/pyproject/tools/__init__.py +0 -0
  72. omdev/pyproject/tools/aboutdeps.py +55 -0
  73. omdev/pyproject/venvs.py +8 -1
  74. omdev/rs/__init__.py +0 -0
  75. omdev/scripts/ci.py +400 -80
  76. omdev/scripts/interp.py +193 -35
  77. omdev/scripts/lib/__init__.py +0 -0
  78. omdev/scripts/{inject.py → lib/inject.py} +75 -28
  79. omdev/scripts/lib/logs.py +2079 -0
  80. omdev/scripts/{marshal.py → lib/marshal.py} +68 -26
  81. omdev/scripts/pyproject.py +941 -90
  82. omdev/tools/git/cli.py +12 -1
  83. omdev/tools/json/processing.py +5 -2
  84. omdev/tools/jsonview/cli.py +31 -5
  85. omdev/tools/pawk/pawk.py +2 -2
  86. omdev/tools/pip.py +8 -0
  87. omdev/tui/__init__.py +0 -0
  88. omdev/tui/apps/__init__.py +0 -0
  89. omdev/tui/apps/edit/__init__.py +0 -0
  90. omdev/tui/apps/edit/main.py +163 -0
  91. omdev/tui/apps/irc/__init__.py +0 -0
  92. omdev/tui/apps/irc/__main__.py +4 -0
  93. omdev/tui/apps/irc/app.py +278 -0
  94. omdev/tui/apps/irc/client.py +187 -0
  95. omdev/tui/apps/irc/commands.py +175 -0
  96. omdev/tui/apps/irc/main.py +26 -0
  97. omdev/tui/apps/markdown/__init__.py +0 -0
  98. omdev/tui/apps/markdown/__main__.py +11 -0
  99. omdev/{ptk → tui/apps}/markdown/cli.py +5 -7
  100. omdev/tui/rich/__init__.py +34 -0
  101. omdev/tui/rich/console2.py +20 -0
  102. omdev/tui/rich/markdown2.py +186 -0
  103. omdev/tui/textual/__init__.py +226 -0
  104. omdev/tui/textual/app2.py +11 -0
  105. omdev/tui/textual/autocomplete/LICENSE +21 -0
  106. omdev/tui/textual/autocomplete/__init__.py +33 -0
  107. omdev/tui/textual/autocomplete/matching.py +226 -0
  108. omdev/tui/textual/autocomplete/paths.py +202 -0
  109. omdev/tui/textual/autocomplete/widget.py +612 -0
  110. omdev/tui/textual/drivers2.py +55 -0
  111. {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/METADATA +11 -9
  112. {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/RECORD +121 -73
  113. omdev/ptk/__init__.py +0 -103
  114. omdev/ptk/apps/ncdu.py +0 -167
  115. omdev/ptk/confirm.py +0 -60
  116. omdev/ptk/markdown/LICENSE +0 -22
  117. omdev/ptk/markdown/__init__.py +0 -10
  118. omdev/ptk/markdown/__main__.py +0 -11
  119. omdev/ptk/markdown/border.py +0 -94
  120. omdev/ptk/markdown/markdown.py +0 -390
  121. omdev/ptk/markdown/parser.py +0 -42
  122. omdev/ptk/markdown/styles.py +0 -29
  123. omdev/ptk/markdown/tags.py +0 -299
  124. omdev/ptk/markdown/utils.py +0 -366
  125. omdev/pyproject/cexts.py +0 -110
  126. /omdev/{ptk/apps → irc}/__init__.py +0 -0
  127. /omdev/{tokens → irc/messages}/__init__.py +0 -0
  128. /omdev/{tokens → py/tokens}/all.py +0 -0
  129. /omdev/{tokens → py/tokens}/tokenizert.py +0 -0
  130. /omdev/{tokens → py/tokens}/utils.py +0 -0
  131. {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/WHEEL +0 -0
  132. {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/entry_points.txt +0 -0
  133. {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/licenses/LICENSE +0 -0
  134. {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,187 @@
1
+ import asyncio
2
+ import ssl
3
+ import typing as ta
4
+
5
+ from omlish import check
6
+
7
+
8
+ ##
9
+
10
+
11
+ class IrcClient:
12
+ """Simple asyncio-based IRC client."""
13
+
14
+ def __init__(
15
+ self,
16
+ callback: ta.Callable[[str, str], ta.Awaitable[None]],
17
+ ) -> None:
18
+ super().__init__()
19
+
20
+ self.reader: asyncio.StreamReader | None = None
21
+ self.writer: asyncio.StreamWriter | None = None
22
+ self.callback: ta.Callable[[str, str], ta.Awaitable[None]] = callback
23
+ self.nickname: str = ''
24
+ self.connected: bool = False
25
+ self.read_task: asyncio.Task | None = None
26
+
27
+ async def shutdown(self) -> None:
28
+ if self.read_task is not None:
29
+ self.read_task.cancel()
30
+ await self.read_task
31
+
32
+ async def connect(
33
+ self,
34
+ server: str,
35
+ port: int,
36
+ nickname: str,
37
+ realname: str = 'Textual IRC',
38
+ use_ssl: bool | None = None,
39
+ ) -> bool:
40
+ """Connect to IRC server."""
41
+
42
+ check.none(self.read_task)
43
+
44
+ try:
45
+ # Auto-detect SSL for common SSL ports if not explicitly specified
46
+ if use_ssl is None:
47
+ use_ssl = port in (6697, 6698, 7000, 7070, 9999)
48
+
49
+ ssl_context = None
50
+ if use_ssl:
51
+ ssl_context = ssl.create_default_context()
52
+
53
+ self.reader, self.writer = await asyncio.open_connection(
54
+ server, port, ssl=ssl_context,
55
+ )
56
+ self.nickname = nickname
57
+ self.connected = True
58
+
59
+ # Send initial IRC handshake
60
+ await self.send_raw(f'NICK {nickname}')
61
+ await self.send_raw(f'USER {nickname} 0 * :{realname}')
62
+
63
+ # Start reading messages
64
+ self.read_task = asyncio.create_task(self._read_loop())
65
+
66
+ return True
67
+
68
+ except Exception as e: # noqa # FIXME
69
+ await self.callback('system', f'Connection error: {e}')
70
+ return False
71
+
72
+ async def send_raw(self, message: str) -> None:
73
+ """Send raw IRC message."""
74
+
75
+ if self.writer:
76
+ self.writer.write(f'{message}\r\n'.encode())
77
+ await self.writer.drain()
78
+
79
+ async def join(self, channel: str) -> None:
80
+ """Join a channel."""
81
+
82
+ await self.send_raw(f'JOIN {channel}')
83
+
84
+ async def part(self, channel: str, message: str = 'Leaving') -> None:
85
+ """Leave a channel."""
86
+
87
+ await self.send_raw(f'PART {channel} :{message}')
88
+
89
+ async def privmsg(self, target: str, message: str) -> None:
90
+ """Send a message to a channel or user."""
91
+
92
+ await self.send_raw(f'PRIVMSG {target} :{message}')
93
+
94
+ async def names(self, channel: str) -> None:
95
+ """Request names list for a channel."""
96
+
97
+ await self.send_raw(f'NAMES {channel}')
98
+
99
+ async def quit(self, message: str = 'Quit') -> None:
100
+ """Quit IRC."""
101
+
102
+ await self.send_raw(f'QUIT :{message}')
103
+ if self.writer:
104
+ self.writer.close()
105
+ await self.writer.wait_closed()
106
+ self.connected = False
107
+
108
+ async def _read_loop(self) -> None:
109
+ """Read messages from server."""
110
+
111
+ try:
112
+ while self.connected and self.reader:
113
+ line = await self.reader.readline()
114
+ if not line:
115
+ break
116
+
117
+ message = line.decode('utf-8', errors='ignore').strip()
118
+ if message:
119
+ await self._handle_message(message)
120
+
121
+ except Exception as e: # noqa # FIXME
122
+ await self.callback('system', f'Read error: {e}')
123
+
124
+ finally:
125
+ self.connected = False
126
+ await self.callback('system', 'Disconnected from server')
127
+
128
+ async def _handle_message(self, message: str) -> None:
129
+ """Parse and handle IRC messages."""
130
+
131
+ # Handle PING
132
+ if message.startswith('PING'):
133
+ pong = message.replace('PING', 'PONG', 1)
134
+ await self.send_raw(pong)
135
+ return
136
+
137
+ # Parse IRC message
138
+ prefix = ''
139
+ if message.startswith(':'):
140
+ prefix, _, message = message[1:].partition(' ')
141
+
142
+ parts = message.split(' ', 2)
143
+ command = parts[0] if parts else ''
144
+ params = parts[1:] if len(parts) > 1 else []
145
+
146
+ # Extract nickname from prefix
147
+ nick = prefix.split('!')[0] if '!' in prefix else prefix
148
+
149
+ # Handle different message types
150
+ if command == 'PRIVMSG' and len(params) >= 2:
151
+ target = params[0]
152
+ text = params[1]
153
+ text = text.removeprefix(':')
154
+
155
+ window = target if target.startswith('#') else nick
156
+ await self.callback(window, f'<{nick}> {text}')
157
+
158
+ elif command == 'JOIN' and params:
159
+ channel = params[0].lstrip(':')
160
+ await self.callback(channel, f'* {nick} has joined {channel}')
161
+
162
+ elif command == 'PART' and params:
163
+ channel = params[0]
164
+ reason = params[1].lstrip(':') if len(params) > 1 else ''
165
+ msg = f'* {nick} has left {channel}'
166
+ if reason:
167
+ msg += f' ({reason})'
168
+ await self.callback(channel, msg)
169
+
170
+ elif command == 'QUIT':
171
+ reason = params[0].lstrip(':') if params else ''
172
+ msg = f'* {nick} has quit'
173
+ if reason:
174
+ msg += f' ({reason})'
175
+ await self.callback('system', msg)
176
+
177
+ elif command == 'NICK' and params:
178
+ new_nick = params[0].lstrip(':')
179
+ await self.callback('system', f'* {nick} is now known as {new_nick}')
180
+
181
+ elif command.isdigit():
182
+ # Numeric replies
183
+ reply_text = ' '.join(params).lstrip(':')
184
+ await self.callback('system', f'[{command}] {reply_text}')
185
+ else:
186
+ # Unhandled messages go to system
187
+ await self.callback('system', message)
@@ -0,0 +1,175 @@
1
+ import abc
2
+ import typing as ta
3
+
4
+ from omlish import check
5
+ from omlish import lang
6
+ from omlish.argparse import all as argparse
7
+
8
+
9
+ if ta.TYPE_CHECKING:
10
+ from .app import IrcApp
11
+
12
+
13
+ ##
14
+
15
+
16
+ class IrcCommand(abc.ABC):
17
+ """Abstract base class for IRC commands."""
18
+
19
+ def __init__(self) -> None:
20
+ super().__init__()
21
+
22
+ self.__parser: argparse.ArgumentParser = argparse.NoExitArgumentParser(
23
+ prog=self.name,
24
+ description=self.description,
25
+ formatter_class=self._HelpFormatter,
26
+ )
27
+ self._configure_parser(self.__parser)
28
+
29
+ #
30
+
31
+ @property
32
+ def name(self) -> str:
33
+ return lang.camel_to_snake(type(self).__name__.removesuffix('IrcCommand'))
34
+
35
+ @property
36
+ def description(self) -> str | None:
37
+ return None
38
+
39
+ class _HelpFormatter(argparse.HelpFormatter):
40
+ def start_section(self, heading):
41
+ return super().start_section(heading.title())
42
+
43
+ def add_usage(self, usage, actions, groups, prefix=None):
44
+ if prefix is None:
45
+ prefix = 'Usage: '
46
+ return super().add_usage(usage, actions, groups, prefix)
47
+
48
+ def _configure_parser(self, parser: argparse.ArgumentParser) -> None:
49
+ pass
50
+
51
+ #
52
+
53
+ @ta.final
54
+ async def run(self, app: 'IrcApp', argv: list[str]) -> None:
55
+ try:
56
+ args = self.__parser.parse_args(argv)
57
+ except argparse.ArgumentError:
58
+ await app.add_message('system', self.__parser.format_help())
59
+ return
60
+
61
+ await self._run_args(app, args)
62
+
63
+ @abc.abstractmethod
64
+ async def _run_args(self, app: 'IrcApp', args: argparse.Namespace) -> None:
65
+ raise NotImplementedError
66
+
67
+
68
+ class ConnectIrcCommand(IrcCommand):
69
+ """Connect to an IRC server."""
70
+
71
+ description: ta.ClassVar[str] = 'Connect to an IRC server'
72
+
73
+ def _configure_parser(self, parser: argparse.ArgumentParser) -> None:
74
+ parser.add_argument('server', help='IRC server hostname')
75
+ parser.add_argument('port', type=int, help='Port number')
76
+ parser.add_argument('nickname', help='Your nickname')
77
+ parser.add_argument(
78
+ '--ssl',
79
+ action='store_true',
80
+ help='Use SSL/TLS (auto-detected for common ports)',
81
+ )
82
+
83
+ async def _run_args(self, app: 'IrcApp', args: argparse.Namespace) -> None:
84
+ use_ssl = args.ssl or None # None triggers auto-detection
85
+ ssl_msg = ' (SSL)' if args.ssl or args.port in (6697, 6698, 7000, 7070, 9999) else ''
86
+ await app.add_message('system', f'Connecting to {args.server}:{args.port}{ssl_msg} as {args.nickname}...')
87
+ success = await check.not_none(app.client).connect(
88
+ args.server,
89
+ args.port,
90
+ args.nickname,
91
+ use_ssl=use_ssl if args.ssl else None,
92
+ )
93
+ if success:
94
+ await app.add_message('system', 'Connected!')
95
+
96
+
97
+ class JoinIrcCommand(IrcCommand):
98
+ """Join a channel."""
99
+
100
+ description: ta.ClassVar[str] = 'Join an IRC channel'
101
+
102
+ def _configure_parser(self, parser: argparse.ArgumentParser) -> None:
103
+ parser.add_argument('channel', help='Channel name (# prefix optional)')
104
+
105
+ async def _run_args(self, app: 'IrcApp', args: argparse.Namespace) -> None:
106
+ channel = args.channel
107
+ if not channel.startswith('#'):
108
+ channel = '#' + channel
109
+
110
+ if app.client and app.client.connected:
111
+ await app.client.join(channel)
112
+ app.get_or_create_window(channel)
113
+ app.switch_to_window(channel)
114
+ else:
115
+ await app.add_message('system', 'Not connected to server')
116
+
117
+
118
+ class PartIrcCommand(IrcCommand):
119
+ """Leave a channel."""
120
+
121
+ description: ta.ClassVar[str] = 'Leave an IRC channel'
122
+
123
+ def _configure_parser(self, parser: argparse.ArgumentParser) -> None:
124
+ parser.add_argument('reason', nargs='*', help='Part message (optional)')
125
+
126
+ async def _run_args(self, app: 'IrcApp', args: argparse.Namespace) -> None:
127
+ if app.current_channel:
128
+ reason = ' '.join(args.reason) if args.reason else 'Leaving'
129
+ if app.client and app.client.connected:
130
+ await app.client.part(app.current_channel, reason)
131
+ else:
132
+ await app.add_message('system', 'Not in a channel')
133
+
134
+
135
+ class NamesIrcCommand(IrcCommand):
136
+ """Request names list for current channel."""
137
+
138
+ description: ta.ClassVar[str] = 'Request names list for current channel'
139
+
140
+ async def _run_args(self, app: 'IrcApp', args: argparse.Namespace) -> None:
141
+ if app.current_channel:
142
+ if app.client and app.client.connected:
143
+ await app.client.names(app.current_channel)
144
+ else:
145
+ await app.add_message('system', 'Not connected to server')
146
+ else:
147
+ await app.add_message('system', 'Not in a channel')
148
+
149
+
150
+ class QuitIrcCommand(IrcCommand):
151
+ """Quit IRC."""
152
+
153
+ description: ta.ClassVar[str] = 'Quit IRC and exit the application'
154
+
155
+ def _configure_parser(self, parser: argparse.ArgumentParser) -> None:
156
+ parser.add_argument('message', nargs='*', help='Quit message (optional)')
157
+
158
+ async def _run_args(self, app: 'IrcApp', args: argparse.Namespace) -> None:
159
+ reason = ' '.join(args.message) if args.message else 'Quit'
160
+ if app.client and app.client.connected:
161
+ await app.client.quit(reason)
162
+ await app.add_message('system', 'Goodbye!')
163
+ app.exit()
164
+
165
+
166
+ ##
167
+
168
+
169
+ ALL_COMMANDS: ta.Mapping[str, IrcCommand] = {
170
+ 'connect': ConnectIrcCommand(),
171
+ 'join': JoinIrcCommand(),
172
+ 'part': PartIrcCommand(),
173
+ 'names': NamesIrcCommand(),
174
+ 'quit': QuitIrcCommand(),
175
+ }
@@ -0,0 +1,26 @@
1
+ from omlish.argparse import all as argparse
2
+
3
+ from .app import IrcApp
4
+
5
+
6
+ ##
7
+
8
+
9
+ def _main() -> None:
10
+ parser = argparse.ArgumentParser(description='Simple IRC client using Textual')
11
+ parser.add_argument(
12
+ '-x',
13
+ action='append',
14
+ dest='commands',
15
+ help='Execute slash command on startup (can be specified multiple times)',
16
+ )
17
+ args = parser.parse_args()
18
+
19
+ app = IrcApp(
20
+ startup_commands=args.commands,
21
+ )
22
+ app.run()
23
+
24
+
25
+ if __name__ == '__main__':
26
+ _main()
File without changes
@@ -0,0 +1,11 @@
1
+ from ....cli import CliModule
2
+
3
+
4
+ # @omlish-manifest
5
+ _CLI_MODULE = CliModule(['tui/markdown', 'tui/md'], __name__)
6
+
7
+
8
+ if __name__ == '__main__':
9
+ from .cli import _main
10
+
11
+ _main()
@@ -1,9 +1,7 @@
1
1
  import argparse
2
2
  import sys
3
3
 
4
- from ... import ptk
5
- from .markdown import Markdown
6
- from .styles import MARKDOWN_STYLE
4
+ from ... import rich
7
5
 
8
6
 
9
7
  ##
@@ -20,10 +18,10 @@ def _main() -> None:
20
18
  else:
21
19
  src = sys.stdin.read()
22
20
 
23
- ptk.print_formatted_text(
24
- Markdown(src),
25
- style=ptk.Style(list(MARKDOWN_STYLE)),
26
- )
21
+ console = rich.Console()
22
+ markdown = rich.Markdown(src)
23
+ console.print(markdown)
24
+ print()
27
25
 
28
26
 
29
27
  if __name__ == '__main__':
@@ -0,0 +1,34 @@
1
+ # ruff: noqa: F401
2
+ # flake8: noqa: F401
3
+ from omlish import lang as _lang
4
+
5
+
6
+ with _lang.auto_proxy_init(globals()):
7
+ ##
8
+
9
+ from rich import console # noqa
10
+ from rich import live # noqa
11
+ from rich import markdown # noqa
12
+ from rich import repr # noqa
13
+ from rich import text # noqa
14
+ from rich.console import Console # noqa
15
+ from rich.live import Live # noqa
16
+ from rich.markdown import Markdown # noqa
17
+ from rich.text import Text # noqa
18
+
19
+ ##
20
+
21
+ from .console2 import ( # noqa
22
+ console_render,
23
+ )
24
+
25
+ from .markdown2 import ( # noqa
26
+ configure_markdown_parser,
27
+ markdown_from_tokens,
28
+ flatten_tokens_filter,
29
+ flatten_tokens,
30
+
31
+ MarkdownLiveStream,
32
+ NaiveMarkdownLiveStream,
33
+ IncrementalMarkdownLiveStream,
34
+ )
@@ -0,0 +1,20 @@
1
+ import io
2
+ import typing as ta
3
+
4
+ from omlish import lang
5
+
6
+
7
+ with lang.auto_proxy_import(globals()):
8
+ import rich.console
9
+
10
+
11
+ ##
12
+
13
+
14
+ def console_render(obj: ta.Any, **kwargs: ta.Any) -> str:
15
+ temp_console = rich.console.Console(
16
+ file=(out := io.StringIO()),
17
+ **kwargs,
18
+ )
19
+ temp_console.print(obj)
20
+ return out.getvalue()
@@ -0,0 +1,186 @@
1
+ import abc
2
+ import typing as ta
3
+
4
+ from omlish import lang
5
+
6
+ from ...markdown.incparse import IncrementalMarkdownParser
7
+ from ...markdown.tokens import flatten_tokens as _flatten_tokens
8
+ from .console2 import console_render
9
+
10
+
11
+ with lang.auto_proxy_import(globals()):
12
+ import markdown_it as md # noqa
13
+ import markdown_it.token # noqa
14
+ import rich.console
15
+ import rich.live
16
+ import rich.markdown
17
+ import rich.text
18
+
19
+
20
+ ##
21
+
22
+
23
+ def configure_markdown_parser(parser: ta.Optional['md.MarkdownIt'] = None) -> 'md.MarkdownIt':
24
+ if parser is None:
25
+ parser = md.MarkdownIt()
26
+
27
+ return (
28
+ parser
29
+ .enable('strikethrough')
30
+ .enable('table')
31
+ )
32
+
33
+
34
+ def markdown_from_tokens(tokens: ta.Sequence['md.token.Token']) -> 'rich.markdown.Markdown':
35
+ rmd = rich.markdown.Markdown('')
36
+ rmd.parsed = tokens # type: ignore[assignment]
37
+ return rmd
38
+
39
+
40
+ def flatten_tokens_filter(token: 'md.token.Token') -> bool:
41
+ return (
42
+ token.type != 'fence' and
43
+ token.tag != 'img'
44
+ )
45
+
46
+
47
+ def flatten_tokens(tokens: ta.Iterable['md.token.Token']) -> ta.Iterable['md.token.Token']:
48
+ return _flatten_tokens(tokens, filter=flatten_tokens_filter)
49
+
50
+
51
+ ##
52
+
53
+
54
+ class MarkdownLiveStream(lang.ExitStacked, lang.Abstract):
55
+ def __init__(
56
+ self,
57
+ *,
58
+ parser: ta.Optional['md.MarkdownIt'] = None,
59
+ console: ta.Optional['rich.console.Console'] = None,
60
+ ) -> None:
61
+ super().__init__()
62
+
63
+ if console is None:
64
+ console = rich.console.Console()
65
+ self._console = console
66
+
67
+ if parser is None:
68
+ parser = configure_markdown_parser()
69
+ self._parser = parser
70
+
71
+ self._lines_printed_to_scrollback = 0
72
+
73
+ _live: 'rich.live.Live' # noqa
74
+
75
+ def _enter_contexts(self) -> None:
76
+ super()._enter_contexts()
77
+
78
+ self._live = self._enter_context(rich.live.Live(
79
+ rich.text.Text(''),
80
+ console=self._console,
81
+ refresh_per_second=10,
82
+ ))
83
+
84
+ def _console_render(self, obj: ta.Any) -> list[str]:
85
+ return console_render(
86
+ obj,
87
+ force_terminal=True,
88
+ width=self._console.width,
89
+ ).splitlines()
90
+
91
+ def _console_render_markdown(self, src: str) -> list[str]:
92
+ return self._console_render(markdown_from_tokens(self._parser.parse(src)))
93
+
94
+ @abc.abstractmethod
95
+ def feed(self, s: str) -> None:
96
+ raise NotImplementedError
97
+
98
+
99
+ class NaiveMarkdownLiveStream(MarkdownLiveStream):
100
+ _accumulated = ''
101
+
102
+ def feed(self, s: str) -> None:
103
+ self._accumulated += s
104
+ all_lines = self._console_render_markdown(self._accumulated)
105
+
106
+ # Calculate how many lines fit in the live window
107
+ available_height = self._console.height - 2
108
+
109
+ # Determine which lines overflow and need to be printed to scrollback
110
+ total_lines = len(all_lines)
111
+ if total_lines > available_height:
112
+ # Lines that should be in scrollback
113
+ lines_for_scrollback = total_lines - available_height
114
+
115
+ # Print any new lines that weren't already printed
116
+ if lines_for_scrollback > self._lines_printed_to_scrollback:
117
+ new_lines_to_print = all_lines[self._lines_printed_to_scrollback:lines_for_scrollback]
118
+ for line in new_lines_to_print:
119
+ self._live.console.print(rich.text.Text.from_ansi(line))
120
+ self._lines_printed_to_scrollback = lines_for_scrollback
121
+
122
+ # Show only the bottom portion in the live window
123
+ visible_lines = all_lines[-available_height:]
124
+
125
+ else:
126
+ visible_lines = all_lines
127
+
128
+ # Update the live display
129
+ self._live.update(rich.text.Text.from_ansi('\n'.join(visible_lines)))
130
+
131
+
132
+ class IncrementalMarkdownLiveStream(MarkdownLiveStream):
133
+ def __init__(
134
+ self,
135
+ *,
136
+ parser: ta.Optional['md.MarkdownIt'] = None,
137
+ console: ta.Optional['rich.console.Console'] = None,
138
+ ) -> None:
139
+ super().__init__(
140
+ parser=parser,
141
+ console=console,
142
+ )
143
+
144
+ self._inc_parser = IncrementalMarkdownParser(parser=self._parser)
145
+
146
+ def feed(self, s: str) -> None:
147
+ ip_out = self._inc_parser.feed2(s)
148
+
149
+ if ip_out.new_stable:
150
+ # try:
151
+ # srs = getattr(self, '_srs')
152
+ # except AttributeError:
153
+ # setattr(self, '_srs', srs := [])
154
+ # from ...markdown.tokens import token_repr, flatten_tokens
155
+ # srs.extend(map(token_repr, flatten_tokens(ip_out.new_stable)))
156
+
157
+ stable_lines = self._console_render(markdown_from_tokens(ip_out.new_stable))
158
+ stable_lines.append(' ') # FIXME: lame hack
159
+ self._live.console.print(rich.text.Text.from_ansi('\n'.join(stable_lines), no_wrap=True))
160
+ self._lines_printed_to_scrollback = max(0, self._lines_printed_to_scrollback - len(stable_lines))
161
+
162
+ unstable_lines = self._console_render(markdown_from_tokens(ip_out.unstable))
163
+
164
+ # Calculate how many lines fit in the live window
165
+ available_height = self._console.height - 2
166
+
167
+ # Determine which lines overflow and need to be printed to scrollback
168
+ total_lines = len(unstable_lines)
169
+ if total_lines > available_height:
170
+ # Lines that should be in scrollback
171
+ lines_for_scrollback = total_lines - available_height
172
+
173
+ # Print any new lines that weren't already printed
174
+ if lines_for_scrollback > self._lines_printed_to_scrollback:
175
+ new_lines_to_print = unstable_lines[self._lines_printed_to_scrollback:lines_for_scrollback]
176
+ self._live.console.print(rich.text.Text.from_ansi('\n'.join(new_lines_to_print)))
177
+ self._lines_printed_to_scrollback = lines_for_scrollback
178
+
179
+ # Show only the bottom portion in the live window
180
+ visible_lines = unstable_lines[-available_height:]
181
+
182
+ else:
183
+ visible_lines = unstable_lines
184
+
185
+ # Update the live display
186
+ self._live.update(rich.text.Text.from_ansi('\n'.join(visible_lines)))