aws-ssh-utils 0.2.7__tar.gz → 0.3.1__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 (23) hide show
  1. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/PKG-INFO +26 -7
  2. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/README.md +131 -114
  3. aws_ssh_utils-0.3.1/aws_ssh_utils/__init__.py +1 -0
  4. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/cli_utils.py +3 -4
  5. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/commands/app.py +20 -11
  6. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/commands/ec2.py +1 -1
  7. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/commands/emr.py +5 -3
  8. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/connection.py +41 -38
  9. aws_ssh_utils-0.3.1/aws_ssh_utils/logging_utils.py +37 -0
  10. aws_ssh_utils-0.3.1/aws_ssh_utils/session.py +128 -0
  11. aws_ssh_utils-0.3.1/aws_ssh_utils/ssh.py +124 -0
  12. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/terminal.py +30 -10
  13. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/pyproject.toml +2 -0
  14. aws_ssh_utils-0.2.7/aws_ssh_utils/__init__.py +0 -1
  15. aws_ssh_utils-0.2.7/aws_ssh_utils/ssh.py +0 -65
  16. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/.gitignore +0 -0
  17. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/LICENSE +0 -0
  18. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/commands/__init__.py +0 -0
  19. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/commands/emr_all.py +0 -0
  20. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/commands/healthcheck.py +0 -0
  21. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/ec2_utils.py +0 -0
  22. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/emr_utils.py +0 -0
  23. {aws_ssh_utils-0.2.7 → aws_ssh_utils-0.3.1}/aws_ssh_utils/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: aws-ssh-utils
3
- Version: 0.2.7
3
+ Version: 0.3.1
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>
@@ -26,7 +26,9 @@ Requires-Dist: boto3
26
26
  Requires-Dist: click
27
27
  Requires-Dist: loguru
28
28
  Requires-Dist: paramiko
29
+ Requires-Dist: ptyprocess>=0.7.0; sys_platform != 'win32'
29
30
  Requires-Dist: pyte>=0.8.2
31
+ Requires-Dist: pywinpty>=3.0.0; sys_platform == 'win32'
30
32
  Requires-Dist: questionary
31
33
  Requires-Dist: requests>=2.34.2
32
34
  Requires-Dist: rich>=15.0.0
@@ -58,17 +60,21 @@ It utilizes [questionary](https://pypi.org/project/questionary/) to ask you whic
58
60
 
59
61
  ## App
60
62
 
61
- Terminal UI to browse EC2 instances and EMR clusters, with SSH shells in tabs.
63
+ Terminal UI to browse EC2 instances and EMR clusters, with SSM or SSH shells in tabs.
62
64
  It's the default command, so `aws_ssh` and `aws_ssh -p my-profile` launch it too.
63
- Each connection tries, in order: `~/.ssh/config`, SSH over AWS SSM (`session-manager-plugin`), opkssh (via instance/cluster tags),
65
+ Each connection tries, in order: `~/.ssh/config`, a keyless AWS SSM shell, opkssh (via instance/cluster tags),
64
66
  the instance's EC2 key found in `~/.ssh`, then your default keys/ssh-agent.
65
- Unknown host keys are accepted and appended to `~/.ssh/known_hosts`; changed host keys are rejected.
67
+ SSM uses `aws ssm start-session --target INSTANCE_ID` with the selected profile and region. It requires the AWS CLI,
68
+ `session-manager-plugin`, and Session Manager access to the instance, but no SSH key, SSH server, or inbound port 22.
69
+ The shell runs as the Session Manager configured user (usually `ssm-user`); SSH usernames and key options only apply
70
+ to SSH connections. The `ec2` and `emr` commands use the same connection order; `emr-all` still uses SSH in tmux.
71
+ For SSH connections, unknown host keys are accepted and appended to `~/.ssh/known_hosts`; changed host keys are rejected.
66
72
 
67
73
  ```shell
68
74
  $ aws_ssh app --help
69
75
  Usage: aws_ssh app [OPTIONS]
70
76
 
71
- Browse EC2 instances and EMR clusters, and open SSH shells in tabs.
77
+ Browse EC2 instances and EMR clusters, and open SSM or SSH shells in tabs.
72
78
 
73
79
  Options:
74
80
  -p, --profile TEXT Which AWS profile to use
@@ -83,6 +89,19 @@ Scroll back with the mouse wheel or `shift+PgUp`/`shift+PgDn`; typing jumps back
83
89
  `F12` shows/hides the sidebar, `ctrl+w` closes a failed tab, `ctrl+q` quits. Every other key goes to the focused shell.
84
90
  Logs are viewable with `textual console`.
85
91
 
92
+ To keep diagnostics and crash tracebacks in a file, use the global `--log-file` option:
93
+
94
+ ```shell
95
+ aws-ssh-utils --log-file ./aws-ssh.log --verbose --profile my-profile
96
+ # Also works before an explicit command:
97
+ aws-ssh-utils --log-file ./aws-ssh.log ec2 --profile my-profile
98
+ ```
99
+
100
+ Alternatively, set `AWS_SSH_LOG_FILE` to the file path. Logs append across runs and rotate at 10 MB,
101
+ keeping up to three rotated files. `--verbose` includes debug diagnostics; `--quiet` only suppresses
102
+ console logging. File logs include the version, source location, and error tracebacks, without local
103
+ variable dumps or a recording of shell input/output.
104
+
86
105
  ## EC2
87
106
 
88
107
  Select an instance from an interactive list. You can filter the instances by name.
@@ -92,7 +111,7 @@ $ aws_auth ec2 --help
92
111
  Usage: aws_ssh ec2 [OPTIONS]
93
112
 
94
113
  Asks user which EC2 instance they want to connect to, then opens an
95
- interactive SSH session to the instance
114
+ interactive SSM or SSH session to the instance
96
115
 
97
116
  Options:
98
117
  -p, --profile TEXT Which AWS profile to use
@@ -114,7 +133,7 @@ Select the EMR cluster, instance group, and instance to connect to.
114
133
  $ aws_ssh emr --help
115
134
  Usage: aws_ssh emr [OPTIONS]
116
135
 
117
- Asks user which Cluster and EC2 instance they want to connect to, then opens an interactive SSH session to the instance
136
+ Asks user which Cluster and EC2 instance they want to connect to, then opens an interactive SSM or SSH session to the instance
118
137
 
119
138
  Options:
120
139
  -p, --profile TEXT Which AWS profile to use
@@ -1,114 +1,131 @@
1
- # AWS SSH Utils
2
-
3
- [![Version](https://img.shields.io/pypi/v/aws-ssh-utils.svg)](https://pypi.org/project/aws-ssh-utils/)
4
- [![License](https://img.shields.io/pypi/l/aws-ssh-utils.svg)](#)
5
- [![Supported Python Versions](https://img.shields.io/pypi/pyversions/aws-ssh-utils.svg)](https://pypi.org/project/aws-ssh-utils/)
6
-
7
- ```shell
8
- uvx aws-ssh-utils
9
-
10
- uvx aws-ssh-utils app # optional - `app` is default.
11
- uvx aws-ssh-utils ec2
12
- uvx aws-ssh-utils emr
13
- uvx aws-ssh-utils emr-all
14
-
15
- # Also exposed as `aws_ssh`
16
- uvx --from aws-ssh-utils aws_ssh
17
- ```
18
-
19
- This allows you to interactively SSH to an EC2 instance, EMR instance, or all EMR instances with TMUX.
20
-
21
- It utilizes [questionary](https://pypi.org/project/questionary/) to ask you which instance you want to connect to.
22
-
23
- ## App
24
-
25
- Terminal UI to browse EC2 instances and EMR clusters, with SSH shells in tabs.
26
- It's the default command, so `aws_ssh` and `aws_ssh -p my-profile` launch it too.
27
- Each connection tries, in order: `~/.ssh/config`, SSH over AWS SSM (`session-manager-plugin`), opkssh (via instance/cluster tags),
28
- the instance's EC2 key found in `~/.ssh`, then your default keys/ssh-agent.
29
- Unknown host keys are accepted and appended to `~/.ssh/known_hosts`; changed host keys are rejected.
30
-
31
- ```shell
32
- $ aws_ssh app --help
33
- Usage: aws_ssh app [OPTIONS]
34
-
35
- Browse EC2 instances and EMR clusters, and open SSH shells in tabs.
36
-
37
- Options:
38
- -p, --profile TEXT Which AWS profile to use
39
- -r, --region TEXT Which AWS region to use
40
- --scrollback INTEGER RANGE Lines of scrollback per shell. Defaults to your
41
- tmux or Windows Terminal setting, else 1000.
42
- [x>=0]
43
- --help Show this message and exit.
44
- ```
45
-
46
- Scroll back with the mouse wheel or `shift+PgUp`/`shift+PgDn`; typing jumps back to the prompt.
47
- `F12` shows/hides the sidebar, `ctrl+w` closes a failed tab, `ctrl+q` quits. Every other key goes to the focused shell.
48
- Logs are viewable with `textual console`.
49
-
50
- ## EC2
51
-
52
- Select an instance from an interactive list. You can filter the instances by name.
53
-
54
- ```shell
55
- $ aws_auth ec2 --help
56
- Usage: aws_ssh ec2 [OPTIONS]
57
-
58
- Asks user which EC2 instance they want to connect to, then opens an
59
- interactive SSH session to the instance
60
-
61
- Options:
62
- -p, --profile TEXT Which AWS profile to use
63
- -r, --region TEXT Which AWS region to use
64
- -u, --user TEXT Which user to connect as
65
- --private / --public Connect to the instance's private or public IP
66
- -k, --key-file FILE Which key file to use to connect
67
- --scrollback INTEGER RANGE
68
- Lines of scrollback per shell. Defaults to your tmux or
69
- Windows Terminal setting, else 1000. [x>=0]
70
- --help Show this message and exit.
71
- ```
72
-
73
- ## EMR
74
-
75
- Select the EMR cluster, instance group, and instance to connect to.
76
-
77
- ```shell
78
- $ aws_ssh emr --help
79
- Usage: aws_ssh emr [OPTIONS]
80
-
81
- Asks user which Cluster and EC2 instance they want to connect to, then opens an interactive SSH session to the instance
82
-
83
- Options:
84
- -p, --profile TEXT Which AWS profile to use
85
- -r, --region TEXT Which AWS region to use
86
- -u, --user TEXT Which user to connect as
87
- --private / --public Connect to the instance's private or public IP
88
- -k, --key-file FILE Which key file to use to connect
89
- --scrollback INTEGER RANGE
90
- Lines of scrollback per shell. Defaults to your tmux or
91
- Windows Terminal setting, else 1000. [x>=0]
92
- --help Show this message and exit.
93
- ```
94
-
95
- ## EMR All
96
-
97
- Select the EMR cluster to connect to. Then creates a new TMUX session with a window per instance. Each window will have an SSH connection to that instance open.
98
-
99
- ```shell
100
- $ aws_ssh emr-all --help
101
- Usage: aws_ssh emr-all [OPTIONS]
102
-
103
- Asks user which Cluster and EC2 instance they want to connect to, Then
104
- prints a tmux cli statement that will open a new session with a window per
105
- ec2 instance with ssh shell already opened.
106
-
107
- Options:
108
- -p, --profile TEXT Which AWS profile to use
109
- -r, --region TEXT Which AWS region to use
110
- -u, --user TEXT Which user to connect as
111
- --private / --public Connect to the instance's private or public IP
112
- -k, --key-file FILE Which key file to use to connect
113
- --help Show this message and exit.
114
- ```
1
+ # AWS SSH Utils
2
+
3
+ [![Version](https://img.shields.io/pypi/v/aws-ssh-utils.svg)](https://pypi.org/project/aws-ssh-utils/)
4
+ [![License](https://img.shields.io/pypi/l/aws-ssh-utils.svg)](#)
5
+ [![Supported Python Versions](https://img.shields.io/pypi/pyversions/aws-ssh-utils.svg)](https://pypi.org/project/aws-ssh-utils/)
6
+
7
+ ```shell
8
+ uvx aws-ssh-utils
9
+
10
+ uvx aws-ssh-utils app # optional - `app` is default.
11
+ uvx aws-ssh-utils ec2
12
+ uvx aws-ssh-utils emr
13
+ uvx aws-ssh-utils emr-all
14
+
15
+ # Also exposed as `aws_ssh`
16
+ uvx --from aws-ssh-utils aws_ssh
17
+ ```
18
+
19
+ This allows you to interactively SSH to an EC2 instance, EMR instance, or all EMR instances with TMUX.
20
+
21
+ It utilizes [questionary](https://pypi.org/project/questionary/) to ask you which instance you want to connect to.
22
+
23
+ ## App
24
+
25
+ Terminal UI to browse EC2 instances and EMR clusters, with SSM or SSH shells in tabs.
26
+ It's the default command, so `aws_ssh` and `aws_ssh -p my-profile` launch it too.
27
+ Each connection tries, in order: `~/.ssh/config`, a keyless AWS SSM shell, opkssh (via instance/cluster tags),
28
+ the instance's EC2 key found in `~/.ssh`, then your default keys/ssh-agent.
29
+ SSM uses `aws ssm start-session --target INSTANCE_ID` with the selected profile and region. It requires the AWS CLI,
30
+ `session-manager-plugin`, and Session Manager access to the instance, but no SSH key, SSH server, or inbound port 22.
31
+ The shell runs as the Session Manager configured user (usually `ssm-user`); SSH usernames and key options only apply
32
+ to SSH connections. The `ec2` and `emr` commands use the same connection order; `emr-all` still uses SSH in tmux.
33
+ For SSH connections, unknown host keys are accepted and appended to `~/.ssh/known_hosts`; changed host keys are rejected.
34
+
35
+ ```shell
36
+ $ aws_ssh app --help
37
+ Usage: aws_ssh app [OPTIONS]
38
+
39
+ Browse EC2 instances and EMR clusters, and open SSM or SSH shells in tabs.
40
+
41
+ Options:
42
+ -p, --profile TEXT Which AWS profile to use
43
+ -r, --region TEXT Which AWS region to use
44
+ --scrollback INTEGER RANGE Lines of scrollback per shell. Defaults to your
45
+ tmux or Windows Terminal setting, else 1000.
46
+ [x>=0]
47
+ --help Show this message and exit.
48
+ ```
49
+
50
+ Scroll back with the mouse wheel or `shift+PgUp`/`shift+PgDn`; typing jumps back to the prompt.
51
+ `F12` shows/hides the sidebar, `ctrl+w` closes a failed tab, `ctrl+q` quits. Every other key goes to the focused shell.
52
+ Logs are viewable with `textual console`.
53
+
54
+ To keep diagnostics and crash tracebacks in a file, use the global `--log-file` option:
55
+
56
+ ```shell
57
+ aws-ssh-utils --log-file ./aws-ssh.log --verbose --profile my-profile
58
+ # Also works before an explicit command:
59
+ aws-ssh-utils --log-file ./aws-ssh.log ec2 --profile my-profile
60
+ ```
61
+
62
+ Alternatively, set `AWS_SSH_LOG_FILE` to the file path. Logs append across runs and rotate at 10 MB,
63
+ keeping up to three rotated files. `--verbose` includes debug diagnostics; `--quiet` only suppresses
64
+ console logging. File logs include the version, source location, and error tracebacks, without local
65
+ variable dumps or a recording of shell input/output.
66
+
67
+ ## EC2
68
+
69
+ Select an instance from an interactive list. You can filter the instances by name.
70
+
71
+ ```shell
72
+ $ aws_auth ec2 --help
73
+ Usage: aws_ssh ec2 [OPTIONS]
74
+
75
+ Asks user which EC2 instance they want to connect to, then opens an
76
+ interactive SSM or SSH session to the instance
77
+
78
+ Options:
79
+ -p, --profile TEXT Which AWS profile to use
80
+ -r, --region TEXT Which AWS region to use
81
+ -u, --user TEXT Which user to connect as
82
+ --private / --public Connect to the instance's private or public IP
83
+ -k, --key-file FILE Which key file to use to connect
84
+ --scrollback INTEGER RANGE
85
+ Lines of scrollback per shell. Defaults to your tmux or
86
+ Windows Terminal setting, else 1000. [x>=0]
87
+ --help Show this message and exit.
88
+ ```
89
+
90
+ ## EMR
91
+
92
+ Select the EMR cluster, instance group, and instance to connect to.
93
+
94
+ ```shell
95
+ $ aws_ssh emr --help
96
+ Usage: aws_ssh emr [OPTIONS]
97
+
98
+ Asks user which Cluster and EC2 instance they want to connect to, then opens an interactive SSM or SSH session to the instance
99
+
100
+ Options:
101
+ -p, --profile TEXT Which AWS profile to use
102
+ -r, --region TEXT Which AWS region to use
103
+ -u, --user TEXT Which user to connect as
104
+ --private / --public Connect to the instance's private or public IP
105
+ -k, --key-file FILE Which key file to use to connect
106
+ --scrollback INTEGER RANGE
107
+ Lines of scrollback per shell. Defaults to your tmux or
108
+ Windows Terminal setting, else 1000. [x>=0]
109
+ --help Show this message and exit.
110
+ ```
111
+
112
+ ## EMR All
113
+
114
+ Select the EMR cluster to connect to. Then creates a new TMUX session with a window per instance. Each window will have an SSH connection to that instance open.
115
+
116
+ ```shell
117
+ $ aws_ssh emr-all --help
118
+ Usage: aws_ssh emr-all [OPTIONS]
119
+
120
+ Asks user which Cluster and EC2 instance they want to connect to, Then
121
+ prints a tmux cli statement that will open a new session with a window per
122
+ ec2 instance with ssh shell already opened.
123
+
124
+ Options:
125
+ -p, --profile TEXT Which AWS profile to use
126
+ -r, --region TEXT Which AWS region to use
127
+ -u, --user TEXT Which user to connect as
128
+ --private / --public Connect to the instance's private or public IP
129
+ -k, --key-file FILE Which key file to use to connect
130
+ --help Show this message and exit.
131
+ ```
@@ -0,0 +1 @@
1
+ __version__ = "0.3.1"
@@ -26,6 +26,7 @@ from rich.text import Text
26
26
 
27
27
  from aws_ssh_utils.commands.healthcheck import is_aws_ssm_installed, is_opkssh_installed
28
28
  from aws_ssh_utils.connection import Attempt, Environment, Target, connect, open_shell_channel
29
+ from aws_ssh_utils.logging_utils import terminal_logging
29
30
  from aws_ssh_utils.terminal import ShellApp
30
31
 
31
32
  P = ParamSpec('P')
@@ -202,10 +203,8 @@ def open_shell(target: Target, env: Environment, title: str, scrollback: int | N
202
203
  channel.send(initial_input)
203
204
 
204
205
  set_terminal_title(title)
205
- # Logs, e.g. AWS SSM's stderr, would draw over the full-screen shell.
206
- logger.disable('aws_ssh_utils')
207
206
  try:
208
- ShellApp(result.via, result.client, channel, detect_scrollback() if scrollback is None else scrollback).run()
207
+ with terminal_logging():
208
+ ShellApp(result.via, result.client, channel, detect_scrollback() if scrollback is None else scrollback).run()
209
209
  finally:
210
- logger.enable('aws_ssh_utils')
211
210
  set_terminal_title()
@@ -14,10 +14,9 @@ from rich.markup import escape
14
14
  from rich.spinner import Spinner
15
15
  from rich.text import Text
16
16
  from textual import work
17
- from textual.app import App, ComposeResult
17
+ from textual.app import ComposeResult
18
18
  from textual.binding import Binding, BindingType
19
19
  from textual.containers import Horizontal
20
- from textual.logging import TextualHandler
21
20
  from textual.widgets import Footer, Input, OptionList, RichLog, Static, TabbedContent, TabPane, Tree
22
21
  from textual.widgets.option_list import Option
23
22
  from textual.widgets.tree import TreeNode
@@ -26,6 +25,7 @@ from aws_ssh_utils.cli_utils import detect_environment, detect_scrollback, rende
26
25
  from aws_ssh_utils.connection import Attempt, Environment, Target, connect, open_shell_channel
27
26
  from aws_ssh_utils.ec2_utils import ec2_name, ec2_target, get_running_ec2_instances
28
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
29
  from aws_ssh_utils.terminal import ShellStatus, Terminal
30
30
 
31
31
  if TYPE_CHECKING:
@@ -48,21 +48,19 @@ ANIMATION_INTERVAL = 1 / 12
48
48
  @click.option('-r', '--region', default=None, help='Which AWS region to use')
49
49
  @scrollback_option
50
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 SSH shells in tabs."""
51
+ """Browse EC2 instances and EMR clusters, and open SSM or SSH shells in tabs."""
52
52
  try:
53
53
  session = boto3.Session(profile_name=profile, region_name=region)
54
54
  session.client('sts').get_caller_identity()
55
55
  except (BotoCoreError, ClientError) as e:
56
+ logger.bind(file_only=True).opt(exception=e).error('AWS authentication failed')
56
57
  Console().print(f"[red]✗[/] {escape(str(e))}")
57
58
  sys.exit(1)
58
59
 
59
- # loguru holds on to the real stdout, which would draw over the TUI. View logs with `textual console`.
60
- logger.remove()
61
- logger.add(TextualHandler(), format="{message}")
62
-
63
60
  env = detect_environment(profile, session.region_name)
64
61
  scrollback = detect_scrollback() if scrollback is None else scrollback
65
- SSHApp(session.client('ec2'), session.client('emr'), env, scrollback).run()
62
+ with terminal_logging():
63
+ SSHApp(session.client('ec2'), session.client('emr'), env, scrollback).run()
66
64
 
67
65
 
68
66
  def muted_label(name: str, instance_id: str) -> Text:
@@ -112,10 +110,10 @@ class ShellPane(TabPane):
112
110
  def update_label(self):
113
111
  icon = cast(Text, self.spinner.render(time.monotonic())) if self.status == 'connecting' else STATUS_ICONS[self.status]
114
112
  self.query_ancestor(TabbedContent).get_tab(self).label = Text.assemble(icon, ' ', self.title_)
115
- self.query_one('.attempts', Static).update(render_attempts(self.attempts))
116
113
 
117
114
  def set_attempts(self, attempts: list[Attempt]):
118
115
  self.attempts = attempts
116
+ self.query_one('.attempts', Static).update(render_attempts(self.attempts))
119
117
  self.update_label()
120
118
 
121
119
  def set_status(self, status: Status):
@@ -154,16 +152,20 @@ class ShellPane(TabPane):
154
152
  self.app.call_from_thread(self.attach, result.via, Terminal(result.client, channel, self.scrollback))
155
153
 
156
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')
157
159
  await self.query_one(RichLog).remove()
158
160
  await self.query_one('.attempts').remove()
159
161
  await self.mount(ShellStatus(via), terminal)
160
- self.set_status('connected')
161
162
 
162
163
  def on_terminal_closed(self):
164
+ logger.info('Shell tab {} closed', self.id)
163
165
  self.query_ancestor(TabbedContent).remove_pane(self.id or '')
164
166
 
165
167
 
166
- class SSHApp(App[None]):
168
+ class SSHApp(LoggedApp):
167
169
  """The textual app"""
168
170
 
169
171
  TITLE = "AWS SSH"
@@ -252,9 +254,16 @@ class SSHApp(App[None]):
252
254
  if shells.active:
253
255
  shells.remove_pane(shells.active)
254
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
+
255
263
  def open_shell(self, title: str, resolve: Callable[[], Target]):
256
264
  shells = self.query_one('#shells', TabbedContent)
257
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)
258
267
  shells.add_pane(pane)
259
268
  shells.active = pane.id or ''
260
269
 
@@ -41,7 +41,7 @@ def ec2(
41
41
  ):
42
42
  """
43
43
  Asks user which EC2 instance they want to connect to,
44
- then opens an interactive SSH session to the instance
44
+ then opens an interactive SSM or SSH session to the instance
45
45
  """
46
46
  session = boto3.Session(profile_name=profile, region_name=region)
47
47
  ec2_client = session.client('ec2')
@@ -45,7 +45,7 @@ def emr(
45
45
  ):
46
46
  """
47
47
  Asks user which Cluster and EC2 instance they want to connect to,
48
- then opens an interactive SSH session to the instance
48
+ then opens an interactive SSM or SSH session to the instance
49
49
  """
50
50
  session = boto3.Session(profile_name=profile, region_name=region)
51
51
  emr_client = session.client('emr')
@@ -65,9 +65,11 @@ def emr(
65
65
  with spinner():
66
66
  target = emr_target(emr_client, cluster_id, instance_ip.instance_id or '', ip)
67
67
  target = replace(target, user=user or target.user, key_file=key_file)
68
+ env = detect_environment(profile, session.region_name)
68
69
 
69
70
  if (
70
- key_file is None
71
+ not env.ssm_installed
72
+ and key_file is None
71
73
  and target.opkssh_provider is None
72
74
  and find_ssh_key_file(target.key_name) is None
73
75
  and not questionary.confirm(f'Could not find the ssh key {target.key_name}, would you like to continue?').unsafe_ask()
@@ -78,7 +80,7 @@ def emr(
78
80
  postfix = f'[{group_idx}]' if group_idx else ''
79
81
  open_shell(
80
82
  target,
81
- detect_environment(profile, session.region_name),
83
+ env,
82
84
  title=f'{cluster_name} - {group}{postfix}',
83
85
  scrollback=scrollback,
84
86
  initial_input=CORE_NODE_INPUT if group.lower().startswith('core') else b'',
@@ -1,4 +1,4 @@
1
- """SSH connection strategies, tried in order until one succeeds."""
1
+ """SSM and SSH connection strategies, tried in order until one succeeds."""
2
2
 
3
3
  import os
4
4
  import shlex
@@ -18,6 +18,8 @@ import paramiko
18
18
  from loguru import logger
19
19
  from typing_extensions import override
20
20
 
21
+ from aws_ssh_utils.session import ShellChannel, SSMSession
22
+
21
23
  # CSV of issuer,client
22
24
  OPKSSH_PROVIDER_TAG = 'opkssh_provider'
23
25
  # EMR doesn't support comma's in tags.
@@ -54,7 +56,7 @@ class Environment:
54
56
 
55
57
  @dataclass(frozen=True)
56
58
  class Connection:
57
- client: paramiko.SSHClient
59
+ client: paramiko.SSHClient | SSMSession
58
60
  via: str
59
61
  """How the connection was made: AWS SSM, opkssh, a key name, ..."""
60
62
 
@@ -64,7 +66,7 @@ class Attempt:
64
66
  via: str
65
67
  command: str
66
68
  """The equivalent command line, to retry by hand."""
67
- open_client: Callable[[], paramiko.SSHClient] = field(repr=False, compare=False)
69
+ open_client: Callable[[], paramiko.SSHClient | SSMSession] = field(repr=False, compare=False)
68
70
  state: Literal['pending', 'connecting', 'connected', 'failed', 'skipped'] = 'pending'
69
71
  detail: str = ''
70
72
  """The error when failed, the reason when skipped."""
@@ -85,7 +87,7 @@ def connect(
85
87
 
86
88
  Tried in order:
87
89
  1. ~/.ssh/config, when a Host pattern (other than `*`) matches the instance id, an alias or the IP.
88
- 2. SSH over AWS SSM to the instance id, with any existing opkssh/EC2 key plus the default keys.
90
+ 2. A keyless AWS SSM shell, authenticated with the AWS profile.
89
91
  3. opkssh, when the instance/cluster has opkssh tags. May open a browser login.
90
92
  4. The EC2 key pair (or `target.key_file`), when a matching file is found in ~/.ssh.
91
93
  5. The ssh-agent and default ~/.ssh/id_* keys.
@@ -96,6 +98,24 @@ def connect(
96
98
  open_ssh_ = partial(open_ssh, confirm_host_key=confirm_host_key)
97
99
  attempts: list[Attempt] = []
98
100
 
101
+ def try_attempt(i: int) -> Connection | None:
102
+ attempt = attempts[i]
103
+ if attempt.state == 'skipped':
104
+ on_progress(list(attempts))
105
+ return None
106
+ attempts[i] = replace(attempt, state='connecting')
107
+ on_progress(list(attempts))
108
+ try:
109
+ client = attempt.open_client()
110
+ except Exception as e:
111
+ logger.debug(f"Failed to connect via {attempt.via}: {e!r}")
112
+ attempts[i] = replace(attempt, state='failed', detail=str(e) or type(e).__name__)
113
+ on_progress(list(attempts))
114
+ return None
115
+ attempts[i] = replace(attempt, state='connected')
116
+ on_progress(list(attempts))
117
+ return Connection(client, attempt.via)
118
+
99
119
  config = find_ssh_config(target)
100
120
  if config is not None:
101
121
  alias, options = config
@@ -107,21 +127,18 @@ def connect(
107
127
  ),
108
128
  )
109
129
 
110
- ec2_key = target.key_file or find_ssh_key_file(target.key_name)
111
- known_keys = [k for k in (valid_opkssh_key_file(target.opkssh_provider), ec2_key) if k]
112
- ssm_proxy = ssm_command(target.instance_id, env)
113
- ssm = Attempt(
114
- "AWS SSM",
115
- ssh_command(target.user, target.instance_id, known_keys, proxy=ssm_proxy),
116
- lambda: open_ssh_(
117
- target.instance_id,
118
- target.user,
119
- known_keys,
120
- use_defaults=True,
121
- sock=proxy_socket(ssm_proxy, on_output),
122
- ),
130
+ command = ssm_command(target.instance_id, env)
131
+ ssm = Attempt('AWS SSM', shlex.join(command), lambda: SSMSession.open(command))
132
+ attempts.append(
133
+ ssm if env.ssm_installed else replace(ssm, state='skipped', detail="AWS CLI or session-manager-plugin not installed"),
123
134
  )
124
- attempts.append(ssm if env.ssm_installed else replace(ssm, state='skipped', detail="session-manager-plugin not installed"))
135
+ for i in range(len(attempts)):
136
+ if result := try_attempt(i):
137
+ return result
138
+
139
+ # Key discovery is only needed for the remaining SSH fallbacks.
140
+ fallback_start = len(attempts)
141
+ ec2_key = target.key_file or find_ssh_key_file(target.key_name)
125
142
 
126
143
  if (provider := target.opkssh_provider) is not None:
127
144
  opkssh_key = str(opkssh_key_path(provider))
@@ -150,21 +167,9 @@ def connect(
150
167
  ),
151
168
  )
152
169
 
153
- for i, attempt in enumerate(attempts):
154
- if attempt.state == 'skipped':
155
- continue
156
- attempts[i] = replace(attempt, state='connecting')
157
- on_progress(list(attempts))
158
- try:
159
- client = attempt.open_client()
160
- except Exception as e:
161
- logger.debug(f"Failed to connect via {attempt.via}: {e!r}")
162
- attempts[i] = replace(attempt, state='failed', detail=str(e) or type(e).__name__)
163
- on_progress(list(attempts))
164
- continue
165
- attempts[i] = replace(attempt, state='connected')
166
- on_progress(list(attempts))
167
- return Connection(client, attempt.via)
170
+ for i in range(fallback_start, len(attempts)):
171
+ if result := try_attempt(i):
172
+ return result
168
173
  return None
169
174
 
170
175
 
@@ -226,7 +231,9 @@ def ssh_command(
226
231
  return shlex.join([*cmd, f'{user}@{host}'])
227
232
 
228
233
 
229
- def open_shell_channel(client: paramiko.SSHClient) -> paramiko.Channel:
234
+ def open_shell_channel(client: paramiko.SSHClient | SSMSession) -> ShellChannel:
235
+ if isinstance(client, SSMSession):
236
+ return client
230
237
  channel = client.get_transport().open_session() # pyright: ignore[reportOptionalMemberAccess]
231
238
  channel.get_pty(term='xterm-256color')
232
239
  channel.invoke_shell()
@@ -301,10 +308,6 @@ def ssm_command(instance_id: str, env: Environment) -> list[str]:
301
308
  'start-session',
302
309
  '--target',
303
310
  instance_id,
304
- '--document-name',
305
- 'AWS-StartSSHSession',
306
- '--parameters',
307
- 'portNumber=22',
308
311
  ]
309
312
  if env.profile:
310
313
  cmd += ['--profile', env.profile]
@@ -0,0 +1,37 @@
1
+ """Keep diagnostics available without drawing over the full-screen terminal."""
2
+
3
+ import sys
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+
7
+ from loguru import logger
8
+ from textual.app import App
9
+ from typing_extensions import override
10
+
11
+ _terminal_active = False
12
+
13
+
14
+ def write_console(message: str) -> None:
15
+ # Worker threads don't inherit Textual's active-app context. Suppress the
16
+ # console process-wide while the UI runs; file logging remains enabled.
17
+ if not _terminal_active:
18
+ sys.stdout.write(message)
19
+
20
+
21
+ @contextmanager
22
+ def terminal_logging() -> Iterator[None]:
23
+ global _terminal_active
24
+ previous = _terminal_active
25
+ _terminal_active = True
26
+ try:
27
+ yield
28
+ finally:
29
+ _terminal_active = previous
30
+
31
+
32
+ class LoggedApp(App[None]):
33
+ @override
34
+ def _handle_exception(self, error: Exception) -> None:
35
+ # Textual handles UI errors internally instead of raising from run().
36
+ logger.opt(exception=error).error('Unhandled {} error', type(self).__name__)
37
+ super()._handle_exception(error)
@@ -0,0 +1,128 @@
1
+ """Terminal channels for native, keyless Session Manager shells."""
2
+
3
+ import os
4
+ import queue
5
+ import re
6
+ import threading
7
+ import time
8
+ from typing import Protocol, cast
9
+
10
+
11
+ class ShellChannel(Protocol):
12
+ def recv(self, nbytes: int, /) -> bytes: ...
13
+ def send(self, data: bytes, /) -> int: ...
14
+ def resize_pty(self, width: int, height: int) -> None: ...
15
+
16
+
17
+ class ShellClient(Protocol):
18
+ def close(self) -> None: ...
19
+
20
+
21
+ class Pty(Protocol):
22
+ def read(self, size: int) -> str: ...
23
+ def write(self, data: str) -> int: ...
24
+ def setwinsize(self, rows: int, cols: int) -> None: ...
25
+ def close(self, force: bool = False) -> None: ...
26
+
27
+
28
+ def spawn_pty(command: list[str]) -> Pty:
29
+ """SSM needs a real terminal for raw input, control keys, and window sizes."""
30
+ env = {**os.environ, 'TERM': 'xterm-256color', 'AWS_PAGER': '', 'AWS_CLI_AUTO_PROMPT': 'off'}
31
+ if os.name == 'nt':
32
+ from winpty import PtyProcess
33
+
34
+ return cast(Pty, PtyProcess.spawn(command, env=env, dimensions=(24, 80)))
35
+
36
+ from ptyprocess import PtyProcessUnicode
37
+
38
+ return cast(Pty, PtyProcessUnicode.spawn(command, env=env, dimensions=(24, 80), echo=False))
39
+
40
+
41
+ class SSMSession:
42
+ """Adapt an AWS CLI pseudo-terminal to the terminal widget's channel interface."""
43
+
44
+ def __init__(self, process: Pty):
45
+ self.process = process
46
+ self.closed = False
47
+ self._output: queue.Queue[bytes | Exception | None] = queue.Queue()
48
+ self._pending = b''
49
+ self._eof = False
50
+ threading.Thread(target=self._read, daemon=True).start()
51
+
52
+ @classmethod
53
+ def open(cls, command: list[str], timeout: float = 30) -> 'SSMSession':
54
+ session = cls(spawn_pty(command))
55
+ try:
56
+ session._wait_started(timeout)
57
+ except BaseException:
58
+ session.close()
59
+ raise
60
+ return session
61
+
62
+ def _read(self):
63
+ try:
64
+ while not self.closed:
65
+ data = self.process.read(65536)
66
+ if data:
67
+ self._output.put(data.encode('utf-8'))
68
+ except EOFError:
69
+ pass
70
+ except Exception as e:
71
+ if not self.closed:
72
+ self._output.put(e)
73
+ finally:
74
+ self._output.put(None)
75
+
76
+ def _wait_started(self, timeout: float):
77
+ """Keep startup output for the terminal, but report CLI failures to the fallback list."""
78
+ deadline = time.monotonic() + timeout
79
+ output = bytearray()
80
+ try:
81
+ while True:
82
+ data = self._output.get(timeout=max(0, deadline - time.monotonic()))
83
+ if isinstance(data, Exception):
84
+ raise OSError(str(data)) from data
85
+ if data is None:
86
+ raise OSError(output.decode('utf-8', errors='replace').strip() or 'SSM exited before starting a session')
87
+ output.extend(data)
88
+ # ConPTY adds terminal escape sequences to console output on Windows.
89
+ text = re.sub(rb'\x1b\[[0-?]*[ -/]*[@-~]', b'', output)
90
+ if re.search(rb'Starting session with SessionId:\s*\S+', text):
91
+ self._pending = bytes(output)
92
+ return
93
+ except queue.Empty:
94
+ detail = output.decode('utf-8', errors='replace').strip()
95
+ raise TimeoutError(f'Timed out starting SSM session{": " + detail if detail else ""}') from None
96
+
97
+ def recv(self, nbytes: int) -> bytes:
98
+ if nbytes <= 0:
99
+ return b''
100
+ while not self._pending and not self._eof:
101
+ data = self._output.get()
102
+ if isinstance(data, Exception):
103
+ raise OSError(str(data)) from data
104
+ if data is None:
105
+ self._eof = True
106
+ else:
107
+ self._pending = data
108
+ data, self._pending = self._pending[:nbytes], self._pending[nbytes:]
109
+ return data
110
+
111
+ def send(self, data: bytes) -> int:
112
+ try:
113
+ self.process.write(data.decode('utf-8'))
114
+ except EOFError as e:
115
+ raise OSError('SSM session is closed') from e
116
+ return len(data)
117
+
118
+ def resize_pty(self, width: int, height: int):
119
+ if not self.closed:
120
+ self.process.setwinsize(height, width)
121
+
122
+ def close(self):
123
+ if not self.closed:
124
+ self.closed = True
125
+ try:
126
+ self.process.close(force=True)
127
+ finally:
128
+ self._output.put(None)
@@ -0,0 +1,124 @@
1
+ import sys
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ import click
6
+ from loguru import logger
7
+ from textual.logging import TextualHandler
8
+ from typing_extensions import override
9
+
10
+ import aws_ssh_utils
11
+ from aws_ssh_utils.commands.app import app
12
+ from aws_ssh_utils.commands.ec2 import ec2
13
+ from aws_ssh_utils.commands.emr import emr
14
+ from aws_ssh_utils.commands.emr_all import emr_all
15
+ from aws_ssh_utils.commands.healthcheck import healthcheck
16
+ from aws_ssh_utils.logging_utils import write_console
17
+
18
+
19
+ class DefaultCommandGroup(click.Group):
20
+ """Runs `default_command` when no subcommand is given, e.g. `aws_ssh -p prod` -> `aws_ssh app -p prod`.
21
+
22
+ Skip group options and their values before looking for the subcommand.
23
+ """
24
+
25
+ def __init__(self, *args: Any, default_command: str, **kwargs: Any):
26
+ super().__init__(*args, **kwargs)
27
+ self.default_command = default_command
28
+
29
+ @override
30
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
31
+ group_options = {opt: param for param in self.get_params(ctx) for opt in (*param.opts, *param.secondary_opts)}
32
+ idx = 0
33
+ while idx < len(args):
34
+ name, separator, _ = args[idx].partition('=')
35
+ param = group_options.get(name)
36
+ if not isinstance(param, click.Option):
37
+ break
38
+ idx += 1
39
+ if not param.is_flag and not param.count and not separator:
40
+ if len(args) < idx + param.nargs:
41
+ return super().parse_args(ctx, args)
42
+ idx += param.nargs
43
+ if idx == len(args) or args[idx].startswith('-'):
44
+ args = [*args[:idx], self.default_command, *args[idx:]]
45
+ return super().parse_args(ctx, args)
46
+
47
+
48
+ @click.group(cls=DefaultCommandGroup, default_command='app')
49
+ @click.option('-ll/', '--long-log/--no-long-log', default=False, help='Enable long logging')
50
+ @click.option('--verbose/--no-verbose', default=False, help='Enable debug logging')
51
+ @click.option('--quiet/--no-quiet', default=False, help='Disable console logging')
52
+ @click.option(
53
+ '--log-file',
54
+ type=click.Path(dir_okay=False, path_type=Path),
55
+ envvar='AWS_SSH_LOG_FILE',
56
+ help='Append diagnostics and tracebacks to this file. Also settable with AWS_SSH_LOG_FILE.',
57
+ )
58
+ @click.pass_context
59
+ def cli(
60
+ ctx: click.Context,
61
+ long_log: bool = False,
62
+ verbose: bool = False,
63
+ quiet: bool = False,
64
+ log_file: Path | None = None,
65
+ **kwargs: Any,
66
+ ):
67
+ """Runs `app` when no command is given."""
68
+ log_format = (
69
+ (
70
+ "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
71
+ "<level>{level: <8}</level> | "
72
+ "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
73
+ )
74
+ if long_log
75
+ else "<level>{message}</level>"
76
+ )
77
+ level = 100 if quiet else "DEBUG" if verbose else "INFO"
78
+
79
+ logger.remove()
80
+ # Route to Textual's console when an app is active, and stdout otherwise.
81
+ # Keep the file sink active through both full-screen and questionary flows.
82
+ logger.add(
83
+ write_console,
84
+ level=level,
85
+ format=log_format,
86
+ colorize=True,
87
+ diagnose=False,
88
+ backtrace=False,
89
+ filter=lambda record: not record['extra'].get('file_only'),
90
+ )
91
+ logger.add(
92
+ TextualHandler(stderr=False, stdout=False),
93
+ level=level,
94
+ format='{message}',
95
+ diagnose=False,
96
+ backtrace=False,
97
+ filter=lambda record: not record['extra'].get('file_only'),
98
+ )
99
+ if log_file is not None:
100
+ try:
101
+ sink = logger.add(
102
+ log_file,
103
+ level='DEBUG' if verbose else 'INFO',
104
+ format='{time:YYYY-MM-DD HH:mm:ss.SSS Z} | {level: <8} | {name}:{function}:{line} | {message}',
105
+ encoding='utf-8',
106
+ colorize=False,
107
+ diagnose=False,
108
+ backtrace=False,
109
+ rotation='10 MB',
110
+ retention=3,
111
+ )
112
+ except OSError as error:
113
+ raise click.ClickException(f'Cannot open log file {log_file}: {error}') from error
114
+ ctx.call_on_close(lambda: logger.remove(sink))
115
+ logger.bind(file_only=True).info(
116
+ 'aws-ssh-utils {}; Python {}; source={}',
117
+ aws_ssh_utils.__version__,
118
+ sys.version.split()[0],
119
+ aws_ssh_utils.__file__,
120
+ )
121
+
122
+
123
+ for command in (app, ec2, emr, emr_all, healthcheck):
124
+ cli.add_command(command)
@@ -1,4 +1,4 @@
1
- """Textual widgets rendering a paramiko shell channel through the pyte VT100 emulator."""
1
+ """Textual widgets rendering SSH and SSM shell channels through the pyte VT100 emulator."""
2
2
 
3
3
  import datetime as dt
4
4
  import time
@@ -7,14 +7,14 @@ from collections.abc import Sequence
7
7
  from functools import lru_cache
8
8
  from typing import Any
9
9
 
10
- import paramiko
11
10
  import pyte
11
+ from loguru import logger
12
12
  from pyte.screens import Char, Margins
13
13
  from rich.segment import Segment
14
14
  from rich.style import Style
15
15
  from rich.text import Text
16
16
  from textual import events, work
17
- from textual.app import App, ComposeResult
17
+ from textual.app import ComposeResult
18
18
  from textual.containers import Horizontal
19
19
  from textual.message import Message
20
20
  from textual.strip import Strip
@@ -22,6 +22,9 @@ from textual.widget import Widget
22
22
  from textual.widgets import Static
23
23
  from typing_extensions import override
24
24
 
25
+ from aws_ssh_utils.logging_utils import LoggedApp
26
+ from aws_ssh_utils.session import ShellChannel, ShellClient
27
+
25
28
  KEYS = {
26
29
  'enter': '\r',
27
30
  'tab': '\t',
@@ -108,7 +111,7 @@ def cell_style(
108
111
  class ChannelScreen(pyte.Screen):
109
112
  """Answers terminal queries over the channel, and keeps the lines scrolled off the top as history."""
110
113
 
111
- def __init__(self, channel: paramiko.Channel, columns: int, lines: int, scrollback: int = 0):
114
+ def __init__(self, channel: ShellChannel, columns: int, lines: int, scrollback: int = 0):
112
115
  super().__init__(columns, lines)
113
116
  self.channel = channel
114
117
  self.history: deque[tuple[Char, ...]] = deque(maxlen=scrollback)
@@ -119,6 +122,14 @@ class ChannelScreen(pyte.Screen):
119
122
  def write_process_input(self, data: str):
120
123
  self.channel.send(data.encode())
121
124
 
125
+ @override
126
+ def select_graphic_rendition(self, *attrs: int, private: bool = False):
127
+ # Vim queries modifyOtherKeys with CSI ? 4 m. pyte 0.8.2 dispatches it
128
+ # here, but its SGR handler rejects `private`, killing the reader worker.
129
+ # Ignore unsupported keyboard queries without changing text attributes.
130
+ if not private:
131
+ super().select_graphic_rendition(*attrs)
132
+
122
133
  @override
123
134
  def index(self):
124
135
  top, bottom = self.margins or Margins(0, self.lines - 1)
@@ -145,7 +156,7 @@ class Terminal(Widget, can_focus=True):
145
156
  class Closed(Message):
146
157
  pass
147
158
 
148
- def __init__(self, client: paramiko.SSHClient, channel: paramiko.Channel, scrollback: int = 0, **kwargs: object):
159
+ def __init__(self, client: ShellClient, channel: ShellChannel, scrollback: int = 0, **kwargs: object):
149
160
  super().__init__(**kwargs) # pyright: ignore[reportArgumentType]
150
161
  self.client = client
151
162
  self.channel = channel
@@ -167,8 +178,15 @@ class Terminal(Widget, can_focus=True):
167
178
  while data := self.channel.recv(65536):
168
179
  self.app.call_from_thread(self.feed, data)
169
180
  except OSError:
170
- pass
171
- self.post_message(self.Closed())
181
+ logger.exception('Terminal channel read failed')
182
+ except Exception:
183
+ logger.exception('Terminal reader failed')
184
+ raise
185
+ finally:
186
+ # Unexpected parser errors must also tear down the dead session.
187
+ # Textual records the worker exception, but exit_on_error=False
188
+ # would otherwise leave the last frame looking like a live terminal.
189
+ self.post_message(self.Closed())
172
190
 
173
191
  def feed(self, data: bytes):
174
192
  history_size = len(self.vt.history)
@@ -226,7 +244,9 @@ class Terminal(Widget, can_focus=True):
226
244
  def render_line(self, y: int) -> Strip:
227
245
  vt = self.vt
228
246
  if y >= vt.lines:
229
- return Strip.blank(self.size.width)
247
+ # Layout may grow before on_resize updates pyte. Textual's color
248
+ # filters require a Style even for these temporary blank rows.
249
+ return Strip.blank(self.size.width, Style())
230
250
 
231
251
  index = len(vt.history) - self.scrolled + y
232
252
  if index < len(vt.history):
@@ -281,13 +301,13 @@ class ShellStatus(Horizontal):
281
301
  self.query_one('.duration', Static).update(f"{hours:02}:{minutes:02}:{seconds:02}")
282
302
 
283
303
 
284
- class ShellApp(App[None]):
304
+ class ShellApp(LoggedApp):
285
305
  """A single full-screen shell, exits when the shell closes."""
286
306
 
287
307
  # Its priority ctrl+p binding would steal shell history navigation from the terminal.
288
308
  ENABLE_COMMAND_PALETTE = False
289
309
 
290
- def __init__(self, via: str, client: paramiko.SSHClient, channel: paramiko.Channel, scrollback: int = 0):
310
+ def __init__(self, via: str, client: ShellClient, channel: ShellChannel, scrollback: int = 0):
291
311
  super().__init__()
292
312
  self.via = via
293
313
  self.client = client
@@ -34,6 +34,8 @@ dependencies = [
34
34
  "loguru",
35
35
  "paramiko",
36
36
  "pyte>=0.8.2",
37
+ "ptyprocess>=0.7.0; sys_platform != 'win32'",
38
+ "pywinpty>=3.0.0; sys_platform == 'win32'",
37
39
  "questionary",
38
40
  "requests>=2.34.2",
39
41
  "rich>=15.0.0",
@@ -1 +0,0 @@
1
- __version__ = "0.2.7"
@@ -1,65 +0,0 @@
1
- import sys
2
- from typing import Any
3
-
4
- import click
5
- from loguru import logger
6
- from typing_extensions import override
7
-
8
- from aws_ssh_utils.commands.app import app
9
- from aws_ssh_utils.commands.ec2 import ec2
10
- from aws_ssh_utils.commands.emr import emr
11
- from aws_ssh_utils.commands.emr_all import emr_all
12
- from aws_ssh_utils.commands.healthcheck import healthcheck
13
-
14
-
15
- class DefaultCommandGroup(click.Group):
16
- """Runs `default_command` when no subcommand is given, e.g. `aws_ssh -p prod` -> `aws_ssh app -p prod`.
17
-
18
- Assumes all group options are flags, so the first argument that isn't one is where the subcommand goes.
19
- """
20
-
21
- def __init__(self, *args: Any, default_command: str, **kwargs: Any):
22
- super().__init__(*args, **kwargs)
23
- self.default_command = default_command
24
-
25
- @override
26
- def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
27
- group_options = {opt for param in self.get_params(ctx) for opt in (*param.opts, *param.secondary_opts)}
28
- idx = next((i for i, arg in enumerate(args) if arg not in group_options), len(args))
29
- if idx == len(args) or args[idx].startswith('-'):
30
- args = [*args[:idx], self.default_command, *args[idx:]]
31
- return super().parse_args(ctx, args)
32
-
33
-
34
- @click.group(cls=DefaultCommandGroup, default_command='app')
35
- @click.option('-ll/', '--long-log/--no-long-log', default=False, help='Enable long logging')
36
- @click.option('--verbose/--no-verbose', default=False, help='Enable debug logging')
37
- @click.option('--quiet/--no-quiet', default=False, help='Disable logging')
38
- def cli(
39
- long_log: bool = False,
40
- verbose: bool = False,
41
- quiet: bool = False,
42
- **kwargs: Any,
43
- ):
44
- """Runs `app` when no command is given."""
45
- log_format = (
46
- (
47
- "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
48
- "<level>{level: <8}</level> | "
49
- "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
50
- )
51
- if long_log
52
- else "<level>{message}</level>"
53
- )
54
- level = "DEBUG" if verbose else 100 if quiet else "INFO"
55
-
56
- def to_stdout(message: str):
57
- # Look up sys.stdout per message, so rich.live can redirect it above its display.
58
- sys.stdout.write(message)
59
-
60
- logger.remove()
61
- logger.add(to_stdout, level=level, format=log_format, colorize=True)
62
-
63
-
64
- for command in (app, ec2, emr, emr_all, healthcheck):
65
- cli.add_command(command)
File without changes
File without changes