omdev 0.0.0.dev440__py3-none-any.whl → 0.0.0.dev495__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 (148) hide show
  1. omdev/.omlish-manifests.json +18 -30
  2. omdev/README.md +51 -0
  3. omdev/__about__.py +11 -7
  4. omdev/amalg/gen/gen.py +49 -6
  5. omdev/amalg/gen/imports.py +1 -1
  6. omdev/amalg/gen/manifests.py +1 -1
  7. omdev/amalg/gen/resources.py +1 -1
  8. omdev/amalg/gen/srcfiles.py +13 -3
  9. omdev/amalg/gen/strip.py +1 -1
  10. omdev/amalg/gen/types.py +1 -1
  11. omdev/amalg/gen/typing.py +1 -1
  12. omdev/amalg/info.py +32 -0
  13. omdev/cache/data/actions.py +1 -1
  14. omdev/cache/data/specs.py +1 -1
  15. omdev/cexts/_boilerplate.cc +2 -3
  16. omdev/cexts/cmake.py +4 -1
  17. omdev/ci/cli.py +2 -3
  18. omdev/cli/clicli.py +37 -7
  19. omdev/cmdlog/cli.py +1 -2
  20. omdev/dataclasses/_dumping.py +1960 -0
  21. omdev/dataclasses/_template.py +22 -0
  22. omdev/dataclasses/cli.py +7 -2
  23. omdev/dataclasses/codegen.py +340 -60
  24. omdev/dataclasses/dumping.py +200 -0
  25. omdev/interp/cli.py +1 -1
  26. omdev/interp/types.py +3 -2
  27. omdev/interp/uv/provider.py +37 -0
  28. omdev/interp/venvs.py +1 -0
  29. omdev/irc/messages/base.py +50 -0
  30. omdev/irc/messages/formats.py +92 -0
  31. omdev/irc/messages/messages.py +775 -0
  32. omdev/irc/messages/parsing.py +99 -0
  33. omdev/irc/numerics/__init__.py +0 -0
  34. omdev/irc/numerics/formats.py +97 -0
  35. omdev/irc/numerics/numerics.py +865 -0
  36. omdev/irc/numerics/types.py +59 -0
  37. omdev/irc/protocol/LICENSE +11 -0
  38. omdev/irc/protocol/__init__.py +61 -0
  39. omdev/irc/protocol/consts.py +6 -0
  40. omdev/irc/protocol/errors.py +30 -0
  41. omdev/irc/protocol/message.py +21 -0
  42. omdev/irc/protocol/nuh.py +55 -0
  43. omdev/irc/protocol/parsing.py +158 -0
  44. omdev/irc/protocol/rendering.py +153 -0
  45. omdev/irc/protocol/tags.py +102 -0
  46. omdev/irc/protocol/utils.py +30 -0
  47. omdev/manifests/_dumping.py +125 -25
  48. omdev/manifests/main.py +1 -1
  49. omdev/markdown/__init__.py +0 -0
  50. omdev/markdown/incparse.py +116 -0
  51. omdev/markdown/tokens.py +51 -0
  52. omdev/packaging/marshal.py +8 -8
  53. omdev/packaging/requires.py +6 -6
  54. omdev/packaging/revisions.py +1 -1
  55. omdev/packaging/specifiers.py +2 -1
  56. omdev/packaging/versions.py +4 -4
  57. omdev/packaging/wheelfile.py +2 -0
  58. omdev/precheck/blanklines.py +66 -0
  59. omdev/precheck/caches.py +1 -1
  60. omdev/precheck/imports.py +14 -1
  61. omdev/precheck/main.py +4 -3
  62. omdev/precheck/unicode.py +39 -15
  63. omdev/py/asts/__init__.py +0 -0
  64. omdev/py/asts/parents.py +28 -0
  65. omdev/py/asts/toplevel.py +123 -0
  66. omdev/py/asts/visitors.py +18 -0
  67. omdev/py/attrdocs.py +1 -1
  68. omdev/py/bracepy.py +12 -4
  69. omdev/py/reprs.py +32 -0
  70. omdev/py/srcheaders.py +1 -1
  71. omdev/py/tokens/__init__.py +0 -0
  72. omdev/py/tools/mkrelimp.py +1 -1
  73. omdev/py/tools/pipdepup.py +686 -0
  74. omdev/pyproject/cli.py +1 -1
  75. omdev/pyproject/pkg.py +190 -45
  76. omdev/pyproject/reqs.py +31 -9
  77. omdev/pyproject/tools/__init__.py +0 -0
  78. omdev/pyproject/tools/aboutdeps.py +60 -0
  79. omdev/pyproject/venvs.py +8 -1
  80. omdev/rs/__init__.py +0 -0
  81. omdev/scripts/ci.py +752 -98
  82. omdev/scripts/interp.py +232 -39
  83. omdev/scripts/lib/inject.py +74 -27
  84. omdev/scripts/lib/logs.py +187 -43
  85. omdev/scripts/lib/marshal.py +67 -25
  86. omdev/scripts/pyproject.py +1369 -143
  87. omdev/tools/git/cli.py +10 -0
  88. omdev/tools/json/formats.py +2 -0
  89. omdev/tools/json/processing.py +5 -2
  90. omdev/tools/jsonview/cli.py +49 -65
  91. omdev/tools/jsonview/resources/jsonview.html.j2 +43 -0
  92. omdev/tools/pawk/README.md +195 -0
  93. omdev/tools/pawk/pawk.py +2 -2
  94. omdev/tools/pip.py +8 -0
  95. omdev/tui/__init__.py +0 -0
  96. omdev/tui/apps/__init__.py +0 -0
  97. omdev/tui/apps/edit/__init__.py +0 -0
  98. omdev/tui/apps/edit/main.py +167 -0
  99. omdev/tui/apps/irc/__init__.py +0 -0
  100. omdev/tui/apps/irc/__main__.py +4 -0
  101. omdev/tui/apps/irc/app.py +286 -0
  102. omdev/tui/apps/irc/client.py +187 -0
  103. omdev/tui/apps/irc/commands.py +175 -0
  104. omdev/tui/apps/irc/main.py +26 -0
  105. omdev/tui/apps/markdown/__init__.py +0 -0
  106. omdev/tui/apps/markdown/__main__.py +11 -0
  107. omdev/{ptk → tui/apps}/markdown/cli.py +5 -7
  108. omdev/tui/rich/__init__.py +46 -0
  109. omdev/tui/rich/console2.py +20 -0
  110. omdev/tui/rich/markdown2.py +186 -0
  111. omdev/tui/textual/__init__.py +265 -0
  112. omdev/tui/textual/app2.py +16 -0
  113. omdev/tui/textual/autocomplete/LICENSE +21 -0
  114. omdev/tui/textual/autocomplete/__init__.py +33 -0
  115. omdev/tui/textual/autocomplete/matching.py +226 -0
  116. omdev/tui/textual/autocomplete/paths.py +202 -0
  117. omdev/tui/textual/autocomplete/widget.py +612 -0
  118. omdev/tui/textual/debug/__init__.py +10 -0
  119. omdev/tui/textual/debug/dominfo.py +151 -0
  120. omdev/tui/textual/debug/screen.py +24 -0
  121. omdev/tui/textual/devtools.py +187 -0
  122. omdev/tui/textual/drivers2.py +55 -0
  123. omdev/tui/textual/logging2.py +20 -0
  124. omdev/tui/textual/types.py +45 -0
  125. {omdev-0.0.0.dev440.dist-info → omdev-0.0.0.dev495.dist-info}/METADATA +15 -9
  126. {omdev-0.0.0.dev440.dist-info → omdev-0.0.0.dev495.dist-info}/RECORD +135 -80
  127. omdev/ptk/__init__.py +0 -103
  128. omdev/ptk/apps/ncdu.py +0 -167
  129. omdev/ptk/confirm.py +0 -60
  130. omdev/ptk/markdown/LICENSE +0 -22
  131. omdev/ptk/markdown/__init__.py +0 -10
  132. omdev/ptk/markdown/__main__.py +0 -11
  133. omdev/ptk/markdown/border.py +0 -94
  134. omdev/ptk/markdown/markdown.py +0 -390
  135. omdev/ptk/markdown/parser.py +0 -42
  136. omdev/ptk/markdown/styles.py +0 -29
  137. omdev/ptk/markdown/tags.py +0 -299
  138. omdev/ptk/markdown/utils.py +0 -366
  139. omdev/pyproject/cexts.py +0 -110
  140. /omdev/{ptk/apps → irc}/__init__.py +0 -0
  141. /omdev/{tokens → irc/messages}/__init__.py +0 -0
  142. /omdev/{tokens → py/tokens}/all.py +0 -0
  143. /omdev/{tokens → py/tokens}/tokenizert.py +0 -0
  144. /omdev/{tokens → py/tokens}/utils.py +0 -0
  145. {omdev-0.0.0.dev440.dist-info → omdev-0.0.0.dev495.dist-info}/WHEEL +0 -0
  146. {omdev-0.0.0.dev440.dist-info → omdev-0.0.0.dev495.dist-info}/entry_points.txt +0 -0
  147. {omdev-0.0.0.dev440.dist-info → omdev-0.0.0.dev495.dist-info}/licenses/LICENSE +0 -0
  148. {omdev-0.0.0.dev440.dist-info → omdev-0.0.0.dev495.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,286 @@
1
+ """
2
+ TODO:
3
+ - use omdev.irc obv
4
+ - readliney input
5
+ - command suggest / autocomplete
6
+ - disable command palette
7
+ - grow input box for multiline
8
+ - styled text
9
+ """
10
+ import shlex
11
+ import typing as ta
12
+
13
+ from omlish import lang
14
+
15
+ from ... import textual as tx
16
+ from .client import IrcClient
17
+ from .commands import ALL_COMMANDS
18
+ from .commands import IrcCommand
19
+
20
+
21
+ ##
22
+
23
+
24
+ class IrcWindow:
25
+ """Represents a chat window."""
26
+
27
+ def __init__(self, name: str) -> None:
28
+ super().__init__()
29
+
30
+ self.name: str = name
31
+ self.lines: list[str] = []
32
+ self.unread: int = 0
33
+ self.displayed_line_count: int = 0 # Track how many lines are currently displayed
34
+
35
+ def add_line(self, line: str) -> None:
36
+ self.lines.append(line)
37
+ self.unread += 1
38
+
39
+
40
+ class IrcApp(tx.App):
41
+ """IRC client application."""
42
+
43
+ _commands: ta.ClassVar[ta.Mapping[str, IrcCommand]] = ALL_COMMANDS
44
+
45
+ CSS = """
46
+ #messages {
47
+ height: 1fr;
48
+
49
+ border: none;
50
+
51
+ padding: 0;
52
+
53
+ overflow-y: auto;
54
+ }
55
+
56
+ #status {
57
+ height: 1;
58
+
59
+ border: none;
60
+
61
+ padding: 0;
62
+
63
+ color: $text;
64
+ background: $primary;
65
+ }
66
+
67
+ #input {
68
+ border: none;
69
+
70
+ padding: 0;
71
+
72
+ dock: bottom;
73
+ }
74
+ """
75
+
76
+ BINDINGS: ta.ClassVar[ta.Sequence[tx.Binding]] = [
77
+ tx.Binding('ctrl+n', 'next_window', 'Next Window', show=False),
78
+ tx.Binding('ctrl+p', 'prev_window', 'Previous Window', show=False),
79
+ ]
80
+
81
+ def __init__(
82
+ self,
83
+ *,
84
+ startup_commands: ta.Sequence[str] | None = None,
85
+ ) -> None:
86
+ super().__init__()
87
+
88
+ self._client: IrcClient | None = None
89
+ self._windows: dict[str, IrcWindow] = {'system': IrcWindow('system')}
90
+ self._window_order: list[str] = ['system']
91
+ self._current_window_idx: int = 0
92
+ self._current_channel: str | None = None
93
+ self._startup_commands: ta.Sequence[str] = startup_commands or []
94
+
95
+ @property
96
+ def client(self) -> IrcClient | None:
97
+ return self._client
98
+
99
+ @property
100
+ def current_channel(self) -> str | None:
101
+ return self._current_channel
102
+
103
+ #
104
+
105
+ def compose(self) -> tx.ComposeResult:
106
+ text_area = tx.TextArea(id='messages', read_only=True, show_line_numbers=False)
107
+ text_area.cursor_blink = False
108
+ yield text_area
109
+ yield tx.Static('', id='status')
110
+ yield tx.Input(placeholder='Enter command or message', id='input', select_on_focus=False)
111
+
112
+ async def on_mount(self) -> None:
113
+ """Initialize on mount."""
114
+
115
+ self._client = IrcClient(self.on_irc_message)
116
+ self.update_display()
117
+ self.query_one('#input').focus()
118
+
119
+ # Show connection prompt
120
+ await self.add_message('system', 'IRC Client - Use /connect <server> <port> <nickname>')
121
+ await self.add_message('system', 'Example: /connect irc.libera.chat 6667 mynick')
122
+
123
+ # Execute startup commands
124
+ for cmd in self._startup_commands:
125
+ # Add leading slash if not present
126
+ if not cmd.startswith('/'):
127
+ cmd = '/' + cmd
128
+ await self.add_message('system', f'Executing: {cmd}')
129
+ await self.handle_command(cmd)
130
+
131
+ async def on_key(self, event: tx.Key) -> None:
132
+ """Handle key events - redirect typing to input when messages area is focused."""
133
+
134
+ focused = self.focused
135
+ if focused and focused.id == 'messages':
136
+ # If a printable character or common input key is pressed, focus the input and forward event
137
+ if event.is_printable or event.key in ('space', 'backspace', 'delete'):
138
+ input_widget = self.query_one('#input', tx.Input)
139
+ input_widget.focus()
140
+ # Post the key event to the input widget so it handles it naturally
141
+ input_widget.post_message(tx.Key(event.key, event.character))
142
+ # Stop the event from being processed by the messages widget
143
+ event.stop()
144
+
145
+ async def on_input_submitted(self, event: tx.Input.Submitted) -> None:
146
+ """Handle user input."""
147
+
148
+ text = event.value.strip()
149
+ event.input.value = ''
150
+
151
+ if not text:
152
+ return
153
+
154
+ # Handle commands
155
+ if text.startswith('/'):
156
+ await self.handle_command(text)
157
+ else:
158
+ # Send message to current channel
159
+ if self._current_channel and self._client and self._client.connected:
160
+ await self._client.privmsg(self._current_channel, text)
161
+ await self.add_message(self._current_channel, f'<{self._client.nickname}> {text}')
162
+ else:
163
+ await self.add_message('system', 'Not in a channel or not connected')
164
+
165
+ async def handle_command(self, text: str) -> None:
166
+ """Handle IRC commands."""
167
+
168
+ try:
169
+ parts = shlex.split(text)
170
+ except ValueError as e:
171
+ await self.add_message('system', f'Invalid command syntax: {e}')
172
+ return
173
+
174
+ if not parts:
175
+ return
176
+
177
+ cmd = parts[0].lstrip('/').lower()
178
+ argv = parts[1:]
179
+
180
+ command = self._commands.get(cmd)
181
+ if command:
182
+ await command.run(self, argv)
183
+ else:
184
+ await self.add_message('system', f'Unknown command: /{cmd}')
185
+
186
+ def action_next_window(self) -> None:
187
+ """Switch to next window."""
188
+
189
+ if len(self._window_order) > 1:
190
+ self._current_window_idx = (self._current_window_idx + 1) % len(self._window_order)
191
+ self.update_display()
192
+
193
+ def action_prev_window(self) -> None:
194
+ """Switch to previous window."""
195
+
196
+ if len(self._window_order) > 1:
197
+ self._current_window_idx = (self._current_window_idx - 1) % len(self._window_order)
198
+ self.update_display()
199
+
200
+ def get_or_create_window(self, name: str) -> IrcWindow:
201
+ """Get or create a window."""
202
+
203
+ if name not in self._windows:
204
+ self._windows[name] = IrcWindow(name)
205
+ self._window_order.append(name)
206
+ return self._windows[name]
207
+
208
+ def switch_to_window(self, name: str) -> None:
209
+ """Switch to a specific window."""
210
+
211
+ if name in self._window_order:
212
+ self._current_window_idx = self._window_order.index(name)
213
+ self.update_display()
214
+
215
+ async def add_message(self, window_name: str, message: str) -> None:
216
+ """Add a message to a window."""
217
+
218
+ window = self.get_or_create_window(window_name)
219
+ timestamp = lang.utcnow().strftime('%H:%M')
220
+ window.add_line(f'[{timestamp}] {message}')
221
+ self.update_display()
222
+
223
+ async def on_irc_message(self, window_name: str, message: str) -> None:
224
+ """Callback for IRC messages."""
225
+
226
+ await self.add_message(window_name, message)
227
+
228
+ _last_window: str | None = None
229
+
230
+ def update_display(self) -> None:
231
+ """Update the display."""
232
+
233
+ current_window_name = self._window_order[self._current_window_idx]
234
+ current_window = self._windows[current_window_name]
235
+
236
+ # Update current channel for sending messages
237
+ self._current_channel = current_window_name if current_window_name.startswith('#') else None
238
+
239
+ # Mark as read
240
+ current_window.unread = 0
241
+
242
+ # Update messages display
243
+ messages_widget = self.query_one('#messages', tx.TextArea)
244
+
245
+ # Check if we switched windows or need full reload
246
+ window_changed = self._last_window != current_window_name
247
+ self._last_window = current_window_name
248
+
249
+ lines_to_show = current_window.lines[-100:] # Last 100 lines
250
+
251
+ if window_changed:
252
+ # Full reload when switching windows
253
+ messages_widget.load_text('\n'.join(lines_to_show))
254
+ current_window.displayed_line_count = len(lines_to_show)
255
+
256
+ else:
257
+ # Append only new lines
258
+ new_line_count = len(lines_to_show) - current_window.displayed_line_count
259
+ if new_line_count > 0:
260
+ new_lines = lines_to_show[-new_line_count:]
261
+ # Get the end position
262
+ doc = messages_widget.document
263
+ end_line = doc.line_count - 1
264
+ end_col = len(doc.get_line(end_line))
265
+ # Append new lines using insert
266
+ prefix = '\n' if len(doc.text) > 0 else ''
267
+ messages_widget.insert(prefix + '\n'.join(new_lines), location=(end_line, end_col))
268
+ current_window.displayed_line_count = len(lines_to_show)
269
+
270
+ # Update status line
271
+ window_list = []
272
+ for i, name in enumerate(self._window_order):
273
+ win = self._windows[name]
274
+ indicator = f'[{i + 1}:{name}]'
275
+ if i == self._current_window_idx:
276
+ indicator = f'[{i + 1}:{name}*]'
277
+ elif win.unread > 0:
278
+ indicator = f'[{i + 1}:{name}({win.unread})]'
279
+ window_list.append(indicator)
280
+
281
+ status_text = ' '.join(window_list)
282
+ self.query_one('#status', tx.Static).update(status_text)
283
+
284
+ async def on_unmount(self) -> None:
285
+ if (cl := self._client) is not None:
286
+ await cl.shutdown()
@@ -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(lang.Abstract):
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()