aws-ssh-utils 0.3.2__tar.gz → 0.4.0__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.
Files changed (22) hide show
  1. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/PKG-INFO +1 -1
  2. aws_ssh_utils-0.4.0/aws_ssh_utils/__init__.py +1 -0
  3. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/app.py +492 -380
  4. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/ec2_utils.py +77 -68
  5. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/terminal.py +60 -5
  6. aws_ssh_utils-0.3.2/aws_ssh_utils/__init__.py +0 -1
  7. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/.gitignore +0 -0
  8. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/LICENSE +0 -0
  9. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/README.md +0 -0
  10. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/cli_utils.py +0 -0
  11. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/__init__.py +0 -0
  12. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/ec2.py +0 -0
  13. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/emr.py +0 -0
  14. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/emr_all.py +0 -0
  15. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/commands/healthcheck.py +0 -0
  16. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/connection.py +0 -0
  17. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/emr_utils.py +0 -0
  18. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/logging_utils.py +0 -0
  19. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/py.typed +0 -0
  20. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/session.py +0 -0
  21. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/aws_ssh_utils/ssh.py +0 -0
  22. {aws_ssh_utils-0.3.2 → aws_ssh_utils-0.4.0}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: aws-ssh-utils
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Easy AWS SSHing
5
5
  Project-URL: Homepage, https://github.com/mvanderlee/aws-ssh-utils
6
6
  Author-email: Michiel Vanderlee <jmt.vanderlee@gmail.com>
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -1,380 +1,492 @@
1
- import sys
2
- import time
3
- from collections.abc import Callable
4
- from dataclasses import dataclass
5
- from itertools import count
6
- from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
7
-
8
- import boto3
9
- import click
10
- from botocore.exceptions import BotoCoreError, ClientError
11
- from loguru import logger
12
- from rich.console import Console
13
- from rich.markup import escape
14
- from rich.spinner import Spinner
15
- from rich.text import Text
16
- from textual import work
17
- from textual.app import ComposeResult
18
- from textual.binding import Binding, BindingType
19
- from textual.containers import Horizontal
20
- from textual.widgets import Footer, Input, OptionList, RichLog, Static, TabbedContent, TabPane, Tree
21
- from textual.widgets.option_list import Option
22
- from textual.widgets.tree import TreeNode
23
-
24
- from aws_ssh_utils.cli_utils import detect_environment, detect_scrollback, render_attempts, scrollback_option
25
- from aws_ssh_utils.connection import Attempt, Environment, Target, connect, open_shell_channel
26
- from aws_ssh_utils.ec2_utils import ec2_name, ec2_target, get_running_ec2_instances
27
- from aws_ssh_utils.emr_utils import emr_target, get_emr_clusters, get_emr_instances, group_role, group_sort_key
28
- from aws_ssh_utils.logging_utils import LoggedApp, terminal_logging
29
- from aws_ssh_utils.terminal import ShellStatus, Terminal
30
-
31
- if TYPE_CHECKING:
32
- from mypy_boto3_ec2 import EC2Client
33
- from mypy_boto3_ec2.type_defs import InstanceTypeDef as EC2InstanceTypeDef
34
- from mypy_boto3_emr import EMRClient
35
- from mypy_boto3_emr.type_defs import InstanceTypeDef as EMRInstanceTypeDef
36
-
37
- Status = Literal['connecting', 'waiting', 'connected', 'failed']
38
- STATUS_ICONS: dict[Status, Text] = {
39
- 'waiting': Text('⚠', style='yellow'),
40
- 'connected': Text('●', style='green'),
41
- 'failed': Text('●', style='red'),
42
- }
43
- ANIMATION_INTERVAL = 1 / 12
44
-
45
-
46
- @click.command("app")
47
- @click.option('-p', '--profile', default=None, help='Which AWS profile to use')
48
- @click.option('-r', '--region', default=None, help='Which AWS region to use')
49
- @scrollback_option
50
- def app(profile: str | None = None, region: str | None = None, scrollback: int | None = None, **kwargs: Any):
51
- """Browse EC2 instances and EMR clusters, and open SSM or SSH shells in tabs."""
52
- try:
53
- session = boto3.Session(profile_name=profile, region_name=region)
54
- session.client('sts').get_caller_identity()
55
- except (BotoCoreError, ClientError) as e:
56
- logger.bind(file_only=True).opt(exception=e).error('AWS authentication failed')
57
- Console().print(f"[red]✗[/] {escape(str(e))}")
58
- sys.exit(1)
59
-
60
- env = detect_environment(profile, session.region_name)
61
- scrollback = detect_scrollback() if scrollback is None else scrollback
62
- with terminal_logging():
63
- SSHApp(session.client('ec2'), session.client('emr'), env, scrollback).run()
64
-
65
-
66
- def muted_label(name: str, instance_id: str) -> Text:
67
- return Text.assemble(name, (f" - {instance_id}", 'dim'))
68
-
69
-
70
- @dataclass(frozen=True)
71
- class Cluster:
72
- id: str
73
- name: str
74
-
75
-
76
- @dataclass(frozen=True)
77
- class EMRNode:
78
- cluster_id: str
79
- group_name: str
80
- instance: "EMRInstanceTypeDef"
81
-
82
-
83
- class BouncingBall(Static):
84
- def on_mount(self):
85
- spinner = Spinner('bouncingBall')
86
- self.set_interval(ANIMATION_INTERVAL, lambda: self.update(spinner.render(time.monotonic())))
87
-
88
-
89
- class ShellPane(TabPane):
90
- """Connects in the background, then swaps its log for a Terminal."""
91
-
92
- def __init__(self, title: str, resolve: Callable[[], Target], env: Environment, scrollback: int, **kwargs: Any):
93
- super().__init__(title, **kwargs)
94
- self.title_ = title
95
- self.resolve = resolve
96
- self.env = env
97
- self.scrollback = scrollback
98
- self.status: Status = 'connecting'
99
- self.spinner = Spinner('dots')
100
- self.attempts: list[Attempt] = []
101
-
102
- def compose(self) -> ComposeResult:
103
- yield Static(classes='attempts')
104
- yield RichLog(wrap=True, markup=True)
105
-
106
- def on_mount(self):
107
- self.animation = self.set_interval(ANIMATION_INTERVAL, self.update_label)
108
- self.connect()
109
-
110
- def update_label(self):
111
- icon = cast(Text, self.spinner.render(time.monotonic())) if self.status == 'connecting' else STATUS_ICONS[self.status]
112
- self.query_ancestor(TabbedContent).get_tab(self).label = Text.assemble(icon, ' ', self.title_)
113
-
114
- def set_attempts(self, attempts: list[Attempt]):
115
- self.attempts = attempts
116
- self.query_one('.attempts', Static).update(render_attempts(self.attempts))
117
- self.update_label()
118
-
119
- def set_status(self, status: Status):
120
- self.status = status
121
- if status != 'connecting':
122
- self.animation.stop()
123
- self.update_label()
124
-
125
- def write(self, markup: str):
126
- self.query_one(RichLog).write(markup)
127
-
128
- @work(thread=True, exit_on_error=False)
129
- def connect(self):
130
- def write(line: str):
131
- self.app.call_from_thread(self.write, escape(line))
132
-
133
- try:
134
- result = connect(
135
- self.resolve(),
136
- self.env,
137
- on_output=write,
138
- on_waiting=lambda: self.app.call_from_thread(self.set_status, 'waiting'),
139
- on_progress=lambda attempts: self.app.call_from_thread(self.set_attempts, attempts),
140
- )
141
- if result is None:
142
- self.app.call_from_thread(self.set_status, 'failed')
143
- return
144
-
145
- channel = open_shell_channel(result.client)
146
- except Exception as e:
147
- logger.exception("Connection failed")
148
- self.app.call_from_thread(self.write, f"[red]✗ {escape(str(e) or type(e).__name__)}[/]")
149
- self.app.call_from_thread(self.set_status, 'failed')
150
- return
151
-
152
- self.app.call_from_thread(self.attach, result.via, Terminal(result.client, channel, self.scrollback))
153
-
154
- async def attach(self, via: str, terminal: Terminal):
155
- logger.info('Shell tab {} connected via {}', self.id, via)
156
- # Stop animation before yielding to widget removal or mounting a reader
157
- # that may immediately close the session.
158
- self.set_status('connected')
159
- await self.query_one(RichLog).remove()
160
- await self.query_one('.attempts').remove()
161
- await self.mount(ShellStatus(via), terminal)
162
-
163
- def on_terminal_closed(self):
164
- logger.info('Shell tab {} closed', self.id)
165
- self.query_ancestor(TabbedContent).remove_pane(self.id or '')
166
-
167
-
168
- class SSHApp(LoggedApp):
169
- """The textual app"""
170
-
171
- TITLE = "AWS SSH"
172
- CSS = """
173
- #sidebar { width: 60; }
174
- #shells { width: 1fr; }
175
- Footer { background: $surface; }
176
- TabbedContent, TabbedContent > ContentSwitcher, TabPane { height: 1fr; }
177
- #ec2-list, #emr-tree { height: 1fr; }
178
- #emr-tree {
179
- border: tall $border-blurred;
180
- padding: 0 1;
181
- background: $surface;
182
- &:focus { border: tall $border; }
183
- }
184
- .error { color: $error; }
185
- """
186
- # Its priority ctrl+p binding would steal shell history navigation from terminals.
187
- ENABLE_COMMAND_PALETTE = False
188
- BINDINGS: ClassVar[list[BindingType]] = [
189
- Binding('ctrl+q', 'quit', 'Quit', priority=True),
190
- Binding('f12', 'toggle_sidebar', 'Toggle sidebar', priority=True),
191
- Binding('ctrl+w', 'close_tab', 'Close tab'),
192
- ]
193
-
194
- def __init__(self, ec2: "EC2Client", emr: "EMRClient", env: Environment, scrollback: int):
195
- super().__init__()
196
- self.ec2 = ec2
197
- self.emr = emr
198
- self.env = env
199
- self.scrollback = scrollback
200
- self.ec2_instances: list[EC2InstanceTypeDef] = []
201
- self.clusters: list[Cluster] = []
202
- self.cluster_instances: dict[str, dict[str, list[EMRInstanceTypeDef]]] = {}
203
- self.emr_loading = 0
204
- self.pane_ids = count()
205
-
206
- def compose(self) -> ComposeResult:
207
- with Horizontal():
208
- with TabbedContent(id='sidebar'):
209
- with TabPane('EC2'):
210
- yield Input(placeholder='Filter by name', id='ec2-filter')
211
- yield BouncingBall(id='ec2-loading')
212
- yield Static(id='ec2-error', classes='error')
213
- yield OptionList(id='ec2-list')
214
- with TabPane('EMR'):
215
- yield Input(placeholder='Filter by name', id='emr-filter')
216
- yield BouncingBall(id='emr-loading')
217
- yield Static(id='emr-error', classes='error')
218
- yield Tree('Clusters', id='emr-tree')
219
- yield TabbedContent(id='shells')
220
- yield Footer()
221
-
222
- def on_mount(self):
223
- self.query_one('#ec2-error').display = False
224
- self.query_one('#emr-error').display = False
225
- self.query_one('#emr-tree', Tree).show_root = False
226
- self.load_ec2()
227
- self.load_emr()
228
-
229
- def show_error(self, prefix: str, error: Exception):
230
- self.query_one(f'#{prefix}-loading').display = False
231
- error_widget = self.query_one(f'#{prefix}-error', Static)
232
- error_widget.update(Text(str(error)))
233
- error_widget.display = True
234
-
235
- def set_emr_loading(self, delta: int):
236
- self.emr_loading += delta
237
- self.query_one('#emr-loading').display = self.emr_loading > 0
238
-
239
- def on_input_changed(self, event: Input.Changed):
240
- if event.input.id == 'ec2-filter':
241
- self.filter_ec2()
242
- else:
243
- self.filter_emr()
244
-
245
- def action_toggle_sidebar(self):
246
- sidebar = self.query_one('#sidebar')
247
- sidebar.display = not sidebar.display
248
- if not sidebar.display and self.focused and sidebar in self.focused.ancestors_with_self:
249
- pane = self.query_one('#shells', TabbedContent).active_pane
250
- self.set_focus(pane.query(Terminal).first() if pane and pane.query(Terminal) else None)
251
-
252
- def action_close_tab(self):
253
- shells = self.query_one('#shells', TabbedContent)
254
- if shells.active:
255
- shells.remove_pane(shells.active)
256
-
257
- def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated):
258
- if event.tabbed_content.id != 'shells' or event.pane.id != event.tabbed_content.active:
259
- return
260
- if terminals := event.pane.query(Terminal):
261
- terminals.first().focus()
262
-
263
- def open_shell(self, title: str, resolve: Callable[[], Target]):
264
- shells = self.query_one('#shells', TabbedContent)
265
- pane = ShellPane(title, resolve, self.env, self.scrollback, id=f'shell-{next(self.pane_ids)}')
266
- logger.info('Opening shell tab {} ({})', pane.id, title)
267
- shells.add_pane(pane)
268
- shells.active = pane.id or ''
269
-
270
- # region - EC2
271
- @work(thread=True, exit_on_error=False)
272
- def load_ec2(self):
273
- try:
274
- instances = get_running_ec2_instances(self.ec2)
275
- except (BotoCoreError, ClientError) as e:
276
- self.call_from_thread(self.show_error, 'ec2', e)
277
- return
278
- self.call_from_thread(self.set_ec2_instances, sorted(instances, key=lambda i: ec2_name(i).lower()))
279
-
280
- def set_ec2_instances(self, instances: list["EC2InstanceTypeDef"]):
281
- self.ec2_instances = instances
282
- self.query_one('#ec2-loading').display = False
283
- self.filter_ec2()
284
-
285
- def filter_ec2(self):
286
- name_filter = self.query_one('#ec2-filter', Input).value.lower()
287
- option_list = self.query_one('#ec2-list', OptionList)
288
- option_list.clear_options()
289
- option_list.add_options(
290
- Option(muted_label(ec2_name(i), i.get('InstanceId', '')), id=i.get('InstanceId', ''))
291
- for i in self.ec2_instances
292
- if name_filter in ec2_name(i).lower()
293
- )
294
-
295
- def on_option_list_option_selected(self, event: OptionList.OptionSelected):
296
- instance = next(i for i in self.ec2_instances if i.get('InstanceId', '') == event.option_id)
297
- self.open_shell(ec2_name(instance), lambda: ec2_target(self.ec2, instance))
298
-
299
- # endregion - EC2
300
-
301
- # region - EMR
302
- @work(thread=True, exit_on_error=False)
303
- def load_emr(self):
304
- self.call_from_thread(self.set_emr_loading, 1)
305
- try:
306
- clusters = [Cluster(cluster_id, name) for cluster_id, name in get_emr_clusters(self.emr).values()]
307
- except (BotoCoreError, ClientError) as e:
308
- self.call_from_thread(self.show_error, 'emr', e)
309
- return
310
- finally:
311
- self.call_from_thread(self.set_emr_loading, -1)
312
- self.call_from_thread(self.set_clusters, sorted(clusters, key=lambda c: c.name.lower()))
313
-
314
- def set_clusters(self, clusters: list[Cluster]):
315
- self.clusters = clusters
316
- self.filter_emr()
317
-
318
- def filter_emr(self):
319
- name_filter = self.query_one('#emr-filter', Input).value.lower()
320
- tree = self.query_one('#emr-tree', Tree)
321
- tree.clear()
322
- for cluster in self.clusters:
323
- if name_filter in cluster.name.lower():
324
- node = tree.root.add(muted_label(cluster.name, cluster.id), data=cluster)
325
- if cluster.id in self.cluster_instances:
326
- self.add_groups(node, cluster.id)
327
-
328
- def on_tree_node_expanded(self, event: Tree.NodeExpanded[Any]):
329
- cluster = event.node.data
330
- if isinstance(cluster, Cluster) and cluster.id not in self.cluster_instances and not event.node.children:
331
- self.load_cluster_instances(cluster.id)
332
-
333
- @work(thread=True, exit_on_error=False)
334
- def load_cluster_instances(self, cluster_id: str):
335
- self.call_from_thread(self.set_emr_loading, 1)
336
- try:
337
- groups = get_emr_instances(self.emr, cluster_id)
338
- except (BotoCoreError, ClientError, ValueError) as e:
339
- self.call_from_thread(self.show_cluster_error, cluster_id, e)
340
- return
341
- finally:
342
- self.call_from_thread(self.set_emr_loading, -1)
343
- self.call_from_thread(self.set_cluster_instances, cluster_id, groups)
344
-
345
- def cluster_node(self, cluster_id: str) -> TreeNode[Any] | None:
346
- tree = self.query_one('#emr-tree', Tree)
347
- return next((n for n in tree.root.children if isinstance(n.data, Cluster) and n.data.id == cluster_id), None)
348
-
349
- def show_cluster_error(self, cluster_id: str, error: Exception):
350
- if node := self.cluster_node(cluster_id):
351
- node.add_leaf(Text(str(error), style='red'))
352
-
353
- def set_cluster_instances(self, cluster_id: str, groups: dict[str, list["EMRInstanceTypeDef"]]):
354
- self.cluster_instances[cluster_id] = groups
355
- if node := self.cluster_node(cluster_id):
356
- self.add_groups(node, cluster_id)
357
-
358
- def add_groups(self, node: TreeNode[Any], cluster_id: str):
359
- groups = self.cluster_instances[cluster_id]
360
- for group_name in sorted(groups, key=group_sort_key):
361
- group = node.add(group_name, expand=True)
362
- for instance in sorted(groups[group_name], key=lambda i: i.get('PrivateIpAddress', '')):
363
- label = muted_label(instance.get('PrivateIpAddress', ''), instance.get('Ec2InstanceId', ''))
364
- group.add_leaf(label, data=EMRNode(cluster_id, group_name, instance))
365
-
366
- def on_tree_node_selected(self, event: Tree.NodeSelected[Any]):
367
- selected = event.node.data
368
- if isinstance(selected, EMRNode):
369
- title = f"{group_role(selected.group_name)} {selected.instance.get('PrivateIpAddress', '')}"
370
- self.open_shell(
371
- title,
372
- lambda: emr_target(
373
- self.emr,
374
- selected.cluster_id,
375
- selected.instance.get('Ec2InstanceId', ''),
376
- selected.instance.get('PrivateIpAddress', ''),
377
- ),
378
- )
379
-
380
- # endregion - EMR
1
+ import sys
2
+ import time
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, replace
5
+ from itertools import count
6
+ from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
7
+
8
+ import boto3
9
+ import click
10
+ from botocore.exceptions import BotoCoreError, ClientError
11
+ from loguru import logger
12
+ from rich.console import Console, Group
13
+ from rich.markup import escape
14
+ from rich.spinner import Spinner
15
+ from rich.table import Table
16
+ from rich.text import Text
17
+ from textual import events, work
18
+ from textual.app import ComposeResult
19
+ from textual.binding import Binding, BindingType
20
+ from textual.containers import Horizontal
21
+ from textual.geometry import Offset
22
+ from textual.screen import ModalScreen
23
+ from textual.widgets import Footer, Input, OptionList, RichLog, Static, TabbedContent, TabPane, Tree
24
+ from textual.widgets.option_list import Option
25
+ from textual.widgets.tree import TreeNode
26
+
27
+ from aws_ssh_utils.cli_utils import detect_environment, detect_scrollback, render_attempts, scrollback_option
28
+ from aws_ssh_utils.connection import Attempt, Environment, Target, connect, open_shell_channel
29
+ from aws_ssh_utils.ec2_utils import ec2_name, ec2_target, get_running_ec2_instances, get_vpc_names
30
+ from aws_ssh_utils.emr_utils import emr_target, get_emr_clusters, get_emr_instances, group_role, group_sort_key
31
+ from aws_ssh_utils.logging_utils import terminal_logging
32
+ from aws_ssh_utils.terminal import ShellStatus, Terminal, TerminalApp
33
+
34
+ if TYPE_CHECKING:
35
+ from mypy_boto3_ec2 import EC2Client
36
+ from mypy_boto3_ec2.type_defs import InstanceTypeDef as EC2InstanceTypeDef
37
+ from mypy_boto3_emr import EMRClient
38
+ from mypy_boto3_emr.type_defs import InstanceTypeDef as EMRInstanceTypeDef
39
+
40
+ Status = Literal['connecting', 'waiting', 'connected', 'failed']
41
+ STATUS_ICONS: dict[Status, Text] = {
42
+ 'waiting': Text('⚠', style='yellow'),
43
+ 'connected': Text('●', style='green'),
44
+ 'failed': Text('●', style='red'),
45
+ }
46
+ ANIMATION_INTERVAL = 1 / 12
47
+ RIGHT_MOUSE_BUTTON = 3
48
+ PUBLIC_IP_ICON = '\U000f01e7' # nf-md-earth
49
+ PRIVATE_IP_ICON = '' # nf-oct-lock
50
+ INSTANCE_TYPE_ICON = '' # nf-oct-cpu
51
+
52
+
53
+ @click.command("app")
54
+ @click.option('-p', '--profile', default=None, help='Which AWS profile to use')
55
+ @click.option('-r', '--region', default=None, help='Which AWS region to use')
56
+ @scrollback_option
57
+ def app(profile: str | None = None, region: str | None = None, scrollback: int | None = None, **kwargs: Any):
58
+ """Browse EC2 instances and EMR clusters, and open SSM or SSH shells in tabs."""
59
+ try:
60
+ session = boto3.Session(profile_name=profile, region_name=region)
61
+ session.client('sts').get_caller_identity()
62
+ except (BotoCoreError, ClientError) as e:
63
+ logger.bind(file_only=True).opt(exception=e).error('AWS authentication failed')
64
+ Console().print(f"[red]✗[/] {escape(str(e))}")
65
+ sys.exit(1)
66
+
67
+ env = detect_environment(profile, session.region_name)
68
+ scrollback = detect_scrollback() if scrollback is None else scrollback
69
+ with terminal_logging():
70
+ SSHApp(session.client('ec2'), session.client('emr'), env, scrollback).run()
71
+
72
+
73
+ def muted_label(name: str, instance_id: str) -> Text:
74
+ return Text.assemble(name, (f" - {instance_id}", 'dim'))
75
+
76
+
77
+ def ec2_label(instance: "EC2InstanceTypeDef") -> Table:
78
+ """Name and id, with a right-aligned globe when the instance has a public IP."""
79
+ grid = Table.grid(expand=True)
80
+ grid.add_column(ratio=1)
81
+ grid.add_column(justify='right')
82
+ grid.add_row(
83
+ muted_label(ec2_name(instance), instance.get('InstanceId', '')),
84
+ PUBLIC_IP_ICON if instance.get('PublicIpAddress') else '',
85
+ )
86
+ return grid
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Cluster:
91
+ id: str
92
+ name: str
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class EMRNode:
97
+ cluster_id: str
98
+ group_name: str
99
+ instance: "EMRInstanceTypeDef"
100
+
101
+
102
+ class BouncingBall(Static):
103
+ def on_mount(self):
104
+ spinner = Spinner('bouncingBall')
105
+ self.set_interval(ANIMATION_INTERVAL, lambda: self.update(spinner.render(time.monotonic())))
106
+
107
+
108
+ class EC2List(OptionList):
109
+ """Only the left button selects; right clicks bubble up to open a context menu."""
110
+
111
+ async def _on_click(self, event: events.Click) -> None:
112
+ if event.button == RIGHT_MOUSE_BUTTON:
113
+ event.prevent_default()
114
+
115
+
116
+ class ContextMenu(ModalScreen[str | None]):
117
+ """Options at the mouse position. Dismisses with the chosen option id, or None."""
118
+
119
+ DEFAULT_CSS = """
120
+ ContextMenu { background: transparent; }
121
+ ContextMenu > OptionList {
122
+ position: absolute;
123
+ constrain: inside inside;
124
+ width: auto;
125
+ height: auto;
126
+ padding: 0;
127
+ &, &:focus { border: round $border; }
128
+ }
129
+ """
130
+ BINDINGS: ClassVar[list[BindingType]] = [Binding('escape', 'dismiss', 'Close')]
131
+
132
+ def __init__(self, offset: Offset, options: list[Option]):
133
+ super().__init__()
134
+ self.menu_offset = offset
135
+ self.menu_options = options
136
+
137
+ def compose(self) -> ComposeResult:
138
+ yield OptionList(*self.menu_options)
139
+
140
+ def on_mount(self):
141
+ menu = self.query_one(OptionList)
142
+ menu.styles.offset = self.menu_offset
143
+ menu.focus()
144
+
145
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected):
146
+ event.stop()
147
+ self.dismiss(event.option_id)
148
+
149
+ def on_click(self, event: events.Click):
150
+ if event.widget is self:
151
+ self.dismiss()
152
+
153
+
154
+ class ShellPane(TabPane):
155
+ """Connects in the background, then swaps its log for a Terminal."""
156
+
157
+ def __init__(self, title: str, resolve: Callable[[], Target], env: Environment, scrollback: int, **kwargs: Any):
158
+ super().__init__(title, **kwargs)
159
+ self.title_ = title
160
+ self.resolve = resolve
161
+ self.env = env
162
+ self.scrollback = scrollback
163
+ self.status: Status = 'connecting'
164
+ self.spinner = Spinner('dots')
165
+ self.attempts: list[Attempt] = []
166
+
167
+ def compose(self) -> ComposeResult:
168
+ yield Static(classes='attempts')
169
+ yield RichLog(wrap=True, markup=True)
170
+
171
+ def on_mount(self):
172
+ self.animation = self.set_interval(ANIMATION_INTERVAL, self.update_label)
173
+ self.connect()
174
+
175
+ def update_label(self):
176
+ icon = cast(Text, self.spinner.render(time.monotonic())) if self.status == 'connecting' else STATUS_ICONS[self.status]
177
+ self.query_ancestor(TabbedContent).get_tab(self).label = Text.assemble(icon, ' ', self.title_)
178
+
179
+ def set_attempts(self, attempts: list[Attempt]):
180
+ self.attempts = attempts
181
+ self.query_one('.attempts', Static).update(render_attempts(self.attempts))
182
+ self.update_label()
183
+
184
+ def set_status(self, status: Status):
185
+ self.status = status
186
+ if status != 'connecting':
187
+ self.animation.stop()
188
+ self.update_label()
189
+
190
+ def write(self, markup: str):
191
+ self.query_one(RichLog).write(markup)
192
+
193
+ @work(thread=True, exit_on_error=False)
194
+ def connect(self):
195
+ def write(line: str):
196
+ self.app.call_from_thread(self.write, escape(line))
197
+
198
+ try:
199
+ result = connect(
200
+ self.resolve(),
201
+ self.env,
202
+ on_output=write,
203
+ on_waiting=lambda: self.app.call_from_thread(self.set_status, 'waiting'),
204
+ on_progress=lambda attempts: self.app.call_from_thread(self.set_attempts, attempts),
205
+ )
206
+ if result is None:
207
+ self.app.call_from_thread(self.set_status, 'failed')
208
+ return
209
+
210
+ channel = open_shell_channel(result.client)
211
+ except Exception as e:
212
+ logger.exception("Connection failed")
213
+ self.app.call_from_thread(self.write, f"[red]✗ {escape(str(e) or type(e).__name__)}[/]")
214
+ self.app.call_from_thread(self.set_status, 'failed')
215
+ return
216
+
217
+ self.app.call_from_thread(self.attach, result.via, Terminal(result.client, channel, self.scrollback))
218
+
219
+ async def attach(self, via: str, terminal: Terminal):
220
+ logger.info('Shell tab {} connected via {}', self.id, via)
221
+ # Stop animation before yielding to widget removal or mounting a reader
222
+ # that may immediately close the session.
223
+ self.set_status('connected')
224
+ await self.query_one(RichLog).remove()
225
+ await self.query_one('.attempts').remove()
226
+ await self.mount(ShellStatus(via), terminal)
227
+
228
+ def on_terminal_closed(self):
229
+ logger.info('Shell tab {} closed', self.id)
230
+ self.query_ancestor(TabbedContent).remove_pane(self.id or '')
231
+
232
+
233
+ class SSHApp(TerminalApp):
234
+ """The textual app"""
235
+
236
+ TITLE = "AWS SSH"
237
+ CSS = """
238
+ #sidebar { width: 60; }
239
+ #shells { width: 1fr; }
240
+ Footer { background: $surface; }
241
+ TabbedContent, TabbedContent > ContentSwitcher, TabPane { height: 1fr; }
242
+ #ec2-list, #emr-tree { height: 1fr; }
243
+ #ec2-details { height: auto; }
244
+ #emr-tree, #ec2-details {
245
+ border: tall $border-blurred;
246
+ padding: 0 1;
247
+ background: $surface;
248
+ &:focus { border: tall $border; }
249
+ }
250
+ .error { color: $error; }
251
+ """
252
+ BINDINGS: ClassVar[list[BindingType]] = [
253
+ Binding('ctrl+q', 'quit', 'Quit', priority=True),
254
+ Binding('f12', 'toggle_sidebar', 'Toggle sidebar', priority=True),
255
+ Binding('ctrl+w', 'close_tab', 'Close tab'),
256
+ ]
257
+
258
+ def __init__(self, ec2: "EC2Client", emr: "EMRClient", env: Environment, scrollback: int):
259
+ super().__init__()
260
+ self.ec2 = ec2
261
+ self.emr = emr
262
+ self.env = env
263
+ self.scrollback = scrollback
264
+ self.ec2_instances: list[EC2InstanceTypeDef] = []
265
+ self.vpc_names: dict[str, str] = {}
266
+ self.clusters: list[Cluster] = []
267
+ self.cluster_instances: dict[str, dict[str, list[EMRInstanceTypeDef]]] = {}
268
+ self.emr_loading = 0
269
+ self.pane_ids = count()
270
+
271
+ def compose(self) -> ComposeResult:
272
+ with Horizontal():
273
+ with TabbedContent(id='sidebar'):
274
+ with TabPane('EC2'):
275
+ yield Input(placeholder='Filter by name', id='ec2-filter')
276
+ yield BouncingBall(id='ec2-loading')
277
+ yield Static(id='ec2-error', classes='error')
278
+ yield EC2List(id='ec2-list')
279
+ yield Static(id='ec2-details')
280
+ with TabPane('EMR'):
281
+ yield Input(placeholder='Filter by name', id='emr-filter')
282
+ yield BouncingBall(id='emr-loading')
283
+ yield Static(id='emr-error', classes='error')
284
+ yield Tree('Clusters', id='emr-tree')
285
+ yield TabbedContent(id='shells')
286
+ yield Footer()
287
+
288
+ def on_mount(self):
289
+ self.query_one('#ec2-error').display = False
290
+ self.query_one('#emr-error').display = False
291
+ self.query_one('#emr-tree', Tree).show_root = False
292
+ self.load_ec2()
293
+ self.load_emr()
294
+
295
+ def show_error(self, prefix: str, error: Exception):
296
+ self.query_one(f'#{prefix}-loading').display = False
297
+ error_widget = self.query_one(f'#{prefix}-error', Static)
298
+ error_widget.update(Text(str(error)))
299
+ error_widget.display = True
300
+
301
+ def set_emr_loading(self, delta: int):
302
+ self.emr_loading += delta
303
+ self.query_one('#emr-loading').display = self.emr_loading > 0
304
+
305
+ def on_input_changed(self, event: Input.Changed):
306
+ if event.input.id == 'ec2-filter':
307
+ self.filter_ec2()
308
+ else:
309
+ self.filter_emr()
310
+
311
+ def action_toggle_sidebar(self):
312
+ sidebar = self.query_one('#sidebar')
313
+ sidebar.display = not sidebar.display
314
+ if not sidebar.display and self.focused and sidebar in self.focused.ancestors_with_self:
315
+ pane = self.query_one('#shells', TabbedContent).active_pane
316
+ self.set_focus(pane.query(Terminal).first() if pane and pane.query(Terminal) else None)
317
+
318
+ def action_close_tab(self):
319
+ shells = self.query_one('#shells', TabbedContent)
320
+ if shells.active:
321
+ shells.remove_pane(shells.active)
322
+
323
+ def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated):
324
+ if event.tabbed_content.id != 'shells' or event.pane.id != event.tabbed_content.active:
325
+ return
326
+ if terminals := event.pane.query(Terminal):
327
+ terminals.first().focus()
328
+
329
+ def open_shell(self, title: str, resolve: Callable[[], Target]):
330
+ shells = self.query_one('#shells', TabbedContent)
331
+ pane = ShellPane(title, resolve, self.env, self.scrollback, id=f'shell-{next(self.pane_ids)}')
332
+ logger.info('Opening shell tab {} ({})', pane.id, title)
333
+ shells.add_pane(pane)
334
+ shells.active = pane.id or ''
335
+
336
+ # region - EC2
337
+ @work(thread=True, exit_on_error=False)
338
+ def load_ec2(self):
339
+ try:
340
+ instances = get_running_ec2_instances(self.ec2)
341
+ except (BotoCoreError, ClientError) as e:
342
+ self.call_from_thread(self.show_error, 'ec2', e)
343
+ return
344
+ try:
345
+ vpc_names = get_vpc_names(self.ec2, sorted({vpc_id for i in instances if (vpc_id := i.get('VpcId'))}))
346
+ except (BotoCoreError, ClientError) as e:
347
+ logger.warning('Could not load VPC names: {!r}', e)
348
+ vpc_names = {}
349
+ self.call_from_thread(self.set_ec2_instances, sorted(instances, key=lambda i: ec2_name(i).lower()), vpc_names)
350
+
351
+ def set_ec2_instances(self, instances: list["EC2InstanceTypeDef"], vpc_names: dict[str, str] | None = None):
352
+ self.ec2_instances = instances
353
+ self.vpc_names = vpc_names or {}
354
+ self.query_one('#ec2-loading').display = False
355
+ self.filter_ec2()
356
+
357
+ def filter_ec2(self):
358
+ name_filter = self.query_one('#ec2-filter', Input).value.lower()
359
+ option_list = self.query_one('#ec2-list', OptionList)
360
+ option_list.clear_options()
361
+ self.query_one('#ec2-details', Static).update('')
362
+ option_list.add_options(
363
+ Option(ec2_label(i), id=i.get('InstanceId', '')) for i in self.ec2_instances if name_filter in ec2_name(i).lower()
364
+ )
365
+
366
+ def ec2_instance(self, instance_id: str | None) -> "EC2InstanceTypeDef":
367
+ return next(i for i in self.ec2_instances if i.get('InstanceId', '') == instance_id)
368
+
369
+ def open_ec2_shell(self, instance: "EC2InstanceTypeDef", public: bool = False):
370
+ def resolve() -> Target:
371
+ target = ec2_target(self.ec2, instance)
372
+ return replace(target, ip=instance.get('PublicIpAddress', '')) if public else target
373
+
374
+ self.open_shell(f'{ec2_name(instance)} (public)' if public else ec2_name(instance), resolve)
375
+
376
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected):
377
+ self.open_ec2_shell(self.ec2_instance(event.option_id))
378
+
379
+ def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted):
380
+ if not isinstance(event.option_list, EC2List):
381
+ return
382
+ instance = self.ec2_instance(event.option_id)
383
+ vpc_id = instance.get('VpcId', '')
384
+ fields = Table.grid(padding=(0, 1))
385
+ for label, value in (
386
+ (INSTANCE_TYPE_ICON, instance.get('InstanceType', '')),
387
+ ('VPC:', self.vpc_names.get(vpc_id, vpc_id)),
388
+ (PRIVATE_IP_ICON, instance.get('PrivateIpAddress', '')),
389
+ (PUBLIC_IP_ICON, instance.get('PublicIpAddress', '')),
390
+ ):
391
+ if value:
392
+ fields.add_row(label, value)
393
+ self.query_one('#ec2-details', Static).update(Group(Text(ec2_name(instance), style='bold'), fields))
394
+
395
+ def on_click(self, event: events.Click):
396
+ index = event.style.meta.get('option')
397
+ if event.button != RIGHT_MOUSE_BUTTON or not isinstance(event.widget, EC2List) or index is None:
398
+ return
399
+ instance = self.ec2_instance(event.widget.get_option_at_index(index).id)
400
+ options = [
401
+ Option('Connect via private IP', id='private'),
402
+ Option('Connect via public IP', id='public', disabled=not instance.get('PublicIpAddress')),
403
+ ]
404
+
405
+ def on_choice(choice: str | None):
406
+ if choice:
407
+ self.open_ec2_shell(instance, public=choice == 'public')
408
+
409
+ self.push_screen(ContextMenu(event.screen_offset, options), on_choice)
410
+
411
+ # endregion - EC2
412
+
413
+ # region - EMR
414
+ @work(thread=True, exit_on_error=False)
415
+ def load_emr(self):
416
+ self.call_from_thread(self.set_emr_loading, 1)
417
+ try:
418
+ clusters = [Cluster(cluster_id, name) for cluster_id, name in get_emr_clusters(self.emr).values()]
419
+ except (BotoCoreError, ClientError) as e:
420
+ self.call_from_thread(self.show_error, 'emr', e)
421
+ return
422
+ finally:
423
+ self.call_from_thread(self.set_emr_loading, -1)
424
+ self.call_from_thread(self.set_clusters, sorted(clusters, key=lambda c: c.name.lower()))
425
+
426
+ def set_clusters(self, clusters: list[Cluster]):
427
+ self.clusters = clusters
428
+ self.filter_emr()
429
+
430
+ def filter_emr(self):
431
+ name_filter = self.query_one('#emr-filter', Input).value.lower()
432
+ tree = self.query_one('#emr-tree', Tree)
433
+ tree.clear()
434
+ for cluster in self.clusters:
435
+ if name_filter in cluster.name.lower():
436
+ node = tree.root.add(muted_label(cluster.name, cluster.id), data=cluster)
437
+ if cluster.id in self.cluster_instances:
438
+ self.add_groups(node, cluster.id)
439
+
440
+ def on_tree_node_expanded(self, event: Tree.NodeExpanded[Any]):
441
+ cluster = event.node.data
442
+ if isinstance(cluster, Cluster) and cluster.id not in self.cluster_instances and not event.node.children:
443
+ self.load_cluster_instances(cluster.id)
444
+
445
+ @work(thread=True, exit_on_error=False)
446
+ def load_cluster_instances(self, cluster_id: str):
447
+ self.call_from_thread(self.set_emr_loading, 1)
448
+ try:
449
+ groups = get_emr_instances(self.emr, cluster_id)
450
+ except (BotoCoreError, ClientError, ValueError) as e:
451
+ self.call_from_thread(self.show_cluster_error, cluster_id, e)
452
+ return
453
+ finally:
454
+ self.call_from_thread(self.set_emr_loading, -1)
455
+ self.call_from_thread(self.set_cluster_instances, cluster_id, groups)
456
+
457
+ def cluster_node(self, cluster_id: str) -> TreeNode[Any] | None:
458
+ tree = self.query_one('#emr-tree', Tree)
459
+ return next((n for n in tree.root.children if isinstance(n.data, Cluster) and n.data.id == cluster_id), None)
460
+
461
+ def show_cluster_error(self, cluster_id: str, error: Exception):
462
+ if node := self.cluster_node(cluster_id):
463
+ node.add_leaf(Text(str(error), style='red'))
464
+
465
+ def set_cluster_instances(self, cluster_id: str, groups: dict[str, list["EMRInstanceTypeDef"]]):
466
+ self.cluster_instances[cluster_id] = groups
467
+ if node := self.cluster_node(cluster_id):
468
+ self.add_groups(node, cluster_id)
469
+
470
+ def add_groups(self, node: TreeNode[Any], cluster_id: str):
471
+ groups = self.cluster_instances[cluster_id]
472
+ for group_name in sorted(groups, key=group_sort_key):
473
+ group = node.add(group_name, expand=True)
474
+ for instance in sorted(groups[group_name], key=lambda i: i.get('PrivateIpAddress', '')):
475
+ label = muted_label(instance.get('PrivateIpAddress', ''), instance.get('Ec2InstanceId', ''))
476
+ group.add_leaf(label, data=EMRNode(cluster_id, group_name, instance))
477
+
478
+ def on_tree_node_selected(self, event: Tree.NodeSelected[Any]):
479
+ selected = event.node.data
480
+ if isinstance(selected, EMRNode):
481
+ title = f"{group_role(selected.group_name)} {selected.instance.get('PrivateIpAddress', '')}"
482
+ self.open_shell(
483
+ title,
484
+ lambda: emr_target(
485
+ self.emr,
486
+ selected.cluster_id,
487
+ selected.instance.get('Ec2InstanceId', ''),
488
+ selected.instance.get('PrivateIpAddress', ''),
489
+ ),
490
+ )
491
+
492
+ # endregion - EMR
@@ -1,68 +1,77 @@
1
- from typing import TYPE_CHECKING
2
-
3
- import questionary
4
-
5
- from aws_ssh_utils.cli_utils import ShellError, spinner
6
- from aws_ssh_utils.connection import Target, opkssh_provider
7
-
8
- if TYPE_CHECKING:
9
- from mypy_boto3_ec2 import EC2Client
10
- from mypy_boto3_ec2.type_defs import InstanceTypeDef
11
-
12
-
13
- def get_running_ec2_instances(ec2: "EC2Client") -> list["InstanceTypeDef"]:
14
- pages = ec2.get_paginator('describe_instances').paginate(
15
- Filters=[{'Name': 'instance-state-name', 'Values': ['running']}],
16
- )
17
- return [i for page in pages for r in page['Reservations'] for i in r.get('Instances', [])]
18
-
19
-
20
- def ec2_name(instance: "InstanceTypeDef") -> str:
21
- """The Name tag, or the instance id when there is none."""
22
- return next((t.get('Value', '') for t in instance.get('Tags', []) if t.get('Key') == 'Name'), instance.get('InstanceId', ''))
23
-
24
-
25
- def guess_ec2_user(ec2: "EC2Client", instance: "InstanceTypeDef") -> str:
26
- image_id = instance.get('ImageId')
27
- images = ec2.describe_images(ImageIds=[image_id]).get('Images', []) if image_id else []
28
- image_name = images[0].get('Name', '') if images else ''
29
- return 'ubuntu' if 'ubuntu' in image_name.lower() else 'ec2-user'
30
-
31
-
32
- def ec2_target(ec2: "EC2Client", instance: "InstanceTypeDef", user: str | None = None) -> Target:
33
- """Connection target on the private IP. Guesses the user from the AMI name when not given."""
34
- tags = {t.get('Key', '').lower(): t.get('Value', '') for t in instance.get('Tags', [])}
35
- return Target(
36
- instance_id=instance.get('InstanceId', ''),
37
- ip=instance.get('PrivateIpAddress', ''),
38
- user=user or guess_ec2_user(ec2, instance),
39
- aliases=(ec2_name(instance),),
40
- key_name=instance.get('KeyName'),
41
- opkssh_provider=opkssh_provider(tags),
42
- )
43
-
44
-
45
- def prompt_for_ec2_instance(
46
- ec2: "EC2Client",
47
- prompt: str = 'Which EC2 instance do you want to connect to?',
48
- ) -> "InstanceTypeDef":
49
- """Asks for a name filter, then which of the matching running instances to use."""
50
- with spinner():
51
- instances = get_running_ec2_instances(ec2)
52
-
53
- name_contains = questionary.text('Provide a name filter or leave blank to show all').unsafe_ask().lower()
54
-
55
- by_display_name: dict[str, InstanceTypeDef] = {}
56
- for instance in instances:
57
- name = ec2_name(instance)
58
- if name_contains and name_contains not in name.lower():
59
- continue
60
- # Disambiguate duplicate Name tags (e.g. ASG members) by appending the instance id
61
- display = name if name not in by_display_name else f'{name} ({instance.get("InstanceId")})'
62
- by_display_name[display] = instance
63
-
64
- if not by_display_name:
65
- raise ShellError('No matching EC2 instances found!')
66
-
67
- choice = questionary.select(prompt, choices=sorted(by_display_name)).unsafe_ask()
68
- return by_display_name[choice]
1
+ from typing import TYPE_CHECKING
2
+
3
+ import questionary
4
+
5
+ from aws_ssh_utils.cli_utils import ShellError, spinner
6
+ from aws_ssh_utils.connection import Target, opkssh_provider
7
+
8
+ if TYPE_CHECKING:
9
+ from mypy_boto3_ec2 import EC2Client
10
+ from mypy_boto3_ec2.type_defs import InstanceTypeDef
11
+
12
+
13
+ def get_running_ec2_instances(ec2: "EC2Client") -> list["InstanceTypeDef"]:
14
+ pages = ec2.get_paginator('describe_instances').paginate(
15
+ Filters=[{'Name': 'instance-state-name', 'Values': ['running']}],
16
+ )
17
+ return [i for page in pages for r in page['Reservations'] for i in r.get('Instances', [])]
18
+
19
+
20
+ def get_vpc_names(ec2: "EC2Client", vpc_ids: list[str]) -> dict[str, str]:
21
+ """VPC id to its Name tag, or the id when there is none."""
22
+ vpcs = ec2.describe_vpcs(VpcIds=vpc_ids).get('Vpcs', []) if vpc_ids else []
23
+ return {
24
+ v.get('VpcId', ''): next((t.get('Value', '') for t in v.get('Tags', []) if t.get('Key') == 'Name'), v.get('VpcId', ''))
25
+ for v in vpcs
26
+ }
27
+
28
+
29
+ def ec2_name(instance: "InstanceTypeDef") -> str:
30
+ """The Name tag, or the instance id when there is none."""
31
+ return next((t.get('Value', '') for t in instance.get('Tags', []) if t.get('Key') == 'Name'), instance.get('InstanceId', ''))
32
+
33
+
34
+ def guess_ec2_user(ec2: "EC2Client", instance: "InstanceTypeDef") -> str:
35
+ image_id = instance.get('ImageId')
36
+ images = ec2.describe_images(ImageIds=[image_id]).get('Images', []) if image_id else []
37
+ image_name = images[0].get('Name', '') if images else ''
38
+ return 'ubuntu' if 'ubuntu' in image_name.lower() else 'ec2-user'
39
+
40
+
41
+ def ec2_target(ec2: "EC2Client", instance: "InstanceTypeDef", user: str | None = None) -> Target:
42
+ """Connection target on the private IP. Guesses the user from the AMI name when not given."""
43
+ tags = {t.get('Key', '').lower(): t.get('Value', '') for t in instance.get('Tags', [])}
44
+ return Target(
45
+ instance_id=instance.get('InstanceId', ''),
46
+ ip=instance.get('PrivateIpAddress', ''),
47
+ user=user or guess_ec2_user(ec2, instance),
48
+ aliases=(ec2_name(instance),),
49
+ key_name=instance.get('KeyName'),
50
+ opkssh_provider=opkssh_provider(tags),
51
+ )
52
+
53
+
54
+ def prompt_for_ec2_instance(
55
+ ec2: "EC2Client",
56
+ prompt: str = 'Which EC2 instance do you want to connect to?',
57
+ ) -> "InstanceTypeDef":
58
+ """Asks for a name filter, then which of the matching running instances to use."""
59
+ with spinner():
60
+ instances = get_running_ec2_instances(ec2)
61
+
62
+ name_contains = questionary.text('Provide a name filter or leave blank to show all').unsafe_ask().lower()
63
+
64
+ by_display_name: dict[str, InstanceTypeDef] = {}
65
+ for instance in instances:
66
+ name = ec2_name(instance)
67
+ if name_contains and name_contains not in name.lower():
68
+ continue
69
+ # Disambiguate duplicate Name tags (e.g. ASG members) by appending the instance id
70
+ display = name if name not in by_display_name else f'{name} ({instance.get("InstanceId")})'
71
+ by_display_name[display] = instance
72
+
73
+ if not by_display_name:
74
+ raise ShellError('No matching EC2 instances found!')
75
+
76
+ choice = questionary.select(prompt, choices=sorted(by_display_name)).unsafe_ask()
77
+ return by_display_name[choice]
@@ -1,10 +1,12 @@
1
1
  """Textual widgets rendering SSH and SSM shell channels through the pyte VT100 emulator."""
2
2
 
3
+ import asyncio
3
4
  import datetime as dt
4
5
  import time
5
6
  from collections import deque
6
7
  from collections.abc import Sequence
7
8
  from functools import lru_cache
9
+ from importlib.metadata import version
8
10
  from typing import Any
9
11
 
10
12
  import pyte
@@ -228,17 +230,45 @@ class Terminal(Widget, can_focus=True):
228
230
  event.prevent_default()
229
231
  self.send(data)
230
232
 
231
- def on_paste(self, event: events.Paste):
233
+ async def on_paste(self, event: events.Paste):
234
+ event.stop()
232
235
  text = event.text
236
+ started = time.monotonic()
237
+ logger.bind(file_only=True).info(
238
+ 'Paste shell received: chars={}; bracketed={}',
239
+ len(text),
240
+ BRACKETED_PASTE in self.vt.mode,
241
+ )
233
242
  if BRACKETED_PASTE in self.vt.mode:
234
243
  text = f'\x1b[200~{text}\x1b[201~'
235
- self.send(text)
244
+ if self.scrolled:
245
+ self.scroll_history(-self.scrolled)
246
+ try:
247
+ # SSH flow control may block a large paste. Keep the app loop free
248
+ # so the reader can deliver output while input is still being sent.
249
+ await asyncio.to_thread(self._send_all, text.encode())
250
+ logger.bind(file_only=True).info(
251
+ 'Paste shell sent: bytes={}; elapsed={:.3f}s',
252
+ len(text.encode()),
253
+ time.monotonic() - started,
254
+ )
255
+ except OSError:
256
+ logger.exception('Terminal paste failed')
257
+ self.post_message(self.Closed())
236
258
 
237
259
  def send(self, text: str):
238
260
  """Send user input, jumping back to the live screen like other terminals do."""
239
261
  if self.scrolled:
240
262
  self.scroll_history(-self.scrolled)
241
- self.channel.send(text.encode())
263
+ self._send_all(text.encode())
264
+
265
+ def _send_all(self, data: bytes):
266
+ """A channel write may accept only one SSH packet or the available window."""
267
+ while data:
268
+ sent = self.channel.send(data)
269
+ if sent <= 0:
270
+ raise OSError('Terminal channel closed while sending input')
271
+ data = data[sent:]
242
272
 
243
273
  @override
244
274
  def render_line(self, y: int) -> Strip:
@@ -301,12 +331,37 @@ class ShellStatus(Horizontal):
301
331
  self.query_one('.duration', Static).update(f"{hours:02}:{minutes:02}:{seconds:02}")
302
332
 
303
333
 
304
- class ShellApp(LoggedApp):
305
- """A single full-screen shell, exits when the shell closes."""
334
+ class TerminalApp(LoggedApp):
335
+ """Shared input handling for apps containing interactive shell terminals."""
306
336
 
307
337
  # Its priority ctrl+p binding would steal shell history navigation from the terminal.
308
338
  ENABLE_COMMAND_PALETTE = False
309
339
 
340
+ def on_mount(self):
341
+ logger.bind(file_only=True).info('Terminal input diagnostics v1; textual={}', version('textual'))
342
+
343
+ @override
344
+ async def on_event(self, event: events.Event) -> None:
345
+ if isinstance(event, (events.AppFocus, events.AppBlur)):
346
+ logger.bind(file_only=True).info('Terminal focus event: {}', type(event).__name__)
347
+ if isinstance(event, events.Paste) and not event.is_forwarded and not self.app_focus:
348
+ # A terminal's paste confirmation dialog can send the paste before
349
+ # FocusIn. Textual restores the previous widget for keys/mouse, but
350
+ # not pastes, so they otherwise go to an unfocused screen and vanish.
351
+ self.app_focus = True
352
+ if isinstance(event, events.Paste) and not event.is_forwarded:
353
+ logger.bind(file_only=True).info(
354
+ 'Paste app received: chars={}; app_focus={}; target={}',
355
+ len(event.text),
356
+ self.app_focus,
357
+ type(self.focused).__name__,
358
+ )
359
+ await super().on_event(event)
360
+
361
+
362
+ class ShellApp(TerminalApp):
363
+ """A single full-screen shell, exits when the shell closes."""
364
+
310
365
  def __init__(self, via: str, client: ShellClient, channel: ShellChannel, scrollback: int = 0):
311
366
  super().__init__()
312
367
  self.via = via
@@ -1 +0,0 @@
1
- __version__ = "0.3.2"
File without changes
File without changes
File without changes