aws-ssh-utils 0.2.0__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-ssh-utils
3
- Version: 0.2.0
3
+ Version: 0.2.2
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>
@@ -29,6 +29,7 @@ Requires-Dist: environs
29
29
  Requires-Dist: loguru
30
30
  Requires-Dist: paramiko
31
31
  Requires-Dist: questionary
32
+ Requires-Dist: typing-extensions
32
33
  Provides-Extra: dev
33
34
  Requires-Dist: boto3-stubs[ec2,emr]; extra == 'dev'
34
35
  Provides-Extra: publish
@@ -0,0 +1 @@
1
+ __version__ = "0.2.2"
@@ -1,25 +1,27 @@
1
+ from collections.abc import MutableMapping, Sequence
1
2
  from dataclasses import dataclass
2
- from typing import TYPE_CHECKING, MutableMapping
3
+ from typing import TYPE_CHECKING, Literal
3
4
 
4
5
  import click_spinner
5
6
  import questionary
6
7
 
7
8
  if TYPE_CHECKING:
8
9
  from mypy_boto3_emr import EMRClient
9
- from mypy_boto3_emr.type_defs import ClusterSummaryTypeDef, InstanceTypeDef
10
+ from mypy_boto3_emr.literals import ClusterStateType
11
+ from mypy_boto3_emr.type_defs import ClusterSummaryTypeDef, InstanceFleetTypeDef, InstanceGroupTypeDef, InstanceTypeDef
10
12
 
11
13
 
12
14
  @dataclass
13
15
  class IP:
14
- private: str
15
- public: str = None
16
+ private: str | None = None
17
+ public: str | None = None
16
18
 
17
19
 
18
20
  def prompt_for_emr_cluster(
19
21
  emr: "EMRClient",
20
- prompt="Which cluster do you want to connect to?",
22
+ prompt: str = "Which cluster do you want to connect to?",
21
23
  # Either a list of application names, or a dict[name, version]
22
- applications: dict[str, str] | list[str] = None,
24
+ applications: dict[str, str] | list[str] | None = None,
23
25
  ) -> tuple[str, str]:
24
26
  """Discovers the available EMR clusters and asks the user which to use.
25
27
 
@@ -41,7 +43,8 @@ def prompt_for_emr_cluster(
41
43
 
42
44
  def prompt_for_emr_instance_group(
43
45
  emr: "EMRClient",
44
- cluster_id: str, prompt="Which instance group do you want to connect to?"
46
+ cluster_id: str,
47
+ prompt: str = "Which instance group do you want to connect to?",
45
48
  ) -> tuple[list[IP], str]:
46
49
  """Discovers the available instance groups for the selected EMR cluster.
47
50
  Note that instance group and instance fleet are both treated the same here.
@@ -64,16 +67,20 @@ def prompt_for_emr_instance_group(
64
67
 
65
68
  def does_cluster_have_applications(
66
69
  emr: "EMRClient",
67
- cluster_id: str,
68
- applications: dict[str, str] | list[str] = None,
70
+ cluster_id: str | None = None,
71
+ applications: dict[str, str] | list[str] | None = None,
69
72
  ) -> bool:
70
- cluster_details = emr.describe_cluster(ClusterId=cluster_id).get("Cluster")
73
+ if applications is None or cluster_id is None:
74
+ return True
71
75
 
76
+ cluster_details = emr.describe_cluster(ClusterId=cluster_id).get("Cluster")
72
77
  if not cluster_details:
73
78
  return False
74
79
 
75
- cluster_applications = {a["Name"]: a["Version"] for a in cluster_details["Applications"]}
76
-
80
+ cluster_applications = {
81
+ a.get("Name"): a.get("Version")
82
+ for a in cluster_details.get("Applications", [])
83
+ }
77
84
  if isinstance(applications, MutableMapping):
78
85
  # If a map, check that all the required applications are in the cluster and at the version
79
86
  return all(v == cluster_applications.get(k) for k, v in applications.items())
@@ -83,9 +90,9 @@ def does_cluster_have_applications(
83
90
 
84
91
  def get_emr_clusters(
85
92
  emr: "EMRClient",
86
- states: list[str] = None,
93
+ states: Sequence["ClusterStateType"] | None = None,
87
94
  # Either a list of application names, or a dict[name, version]
88
- applications: dict[str, str] | list[str] = None,
95
+ applications: dict[str, str] | list[str] | None = None,
89
96
  ) -> dict[str, tuple[str, str]]:
90
97
  """Discover the available EMR cluster
91
98
 
@@ -99,25 +106,37 @@ def get_emr_clusters(
99
106
  ).get("Clusters", [])
100
107
 
101
108
  if applications:
102
- clusters = [c for c in clusters if does_cluster_have_applications(emr, c["Id"], applications=applications)]
109
+ clusters = [
110
+ c for c in clusters
111
+ if does_cluster_have_applications(emr, c.get("Id"), applications=applications)
112
+ ]
103
113
 
104
114
  def get_display_name(c: "ClusterSummaryTypeDef") -> str:
105
- created = c["Status"]["Timeline"]["CreationDateTime"].strftime("%Y-%m-%d %H:%M")
106
- return f"{c['Name']} - {c['Id']} - {created}"
115
+ created = c.get("Status", {}).get(
116
+ "Timeline", {},
117
+ ).get("CreationDateTime")
118
+ if created:
119
+ created = created.strftime("%Y-%m-%d %H:%M")
120
+ return f"{c.get('Name')} - {c.get('Id')} - {created}"
107
121
 
108
- return {f"{get_display_name(c)}": (c["Id"], c["Name"]) for c in clusters}
122
+ return {
123
+ f"{get_display_name(c)}": (c.get("Id", ""), c.get("Name", ""))
124
+ for c in clusters
125
+ }
109
126
 
110
127
 
111
128
  def get_emr_groups(
112
129
  emr: "EMRClient",
113
130
  cluster_id: str,
114
- ):
131
+ ) -> tuple[list["InstanceFleetTypeDef"] | list["InstanceGroupTypeDef"], Literal['InstanceFleetId', 'InstanceGroupId']]:
115
132
  """Discover the EMR groups.
116
133
 
117
134
  Returns the groups and the id_name to use for list_instances
118
135
  """
119
136
  cluster_details = emr.describe_cluster(ClusterId=cluster_id)
120
- instance_collection_type = cluster_details["Cluster"].get("InstanceCollectionType")
137
+ instance_collection_type = cluster_details["Cluster"].get(
138
+ "InstanceCollectionType",
139
+ )
121
140
 
122
141
  if instance_collection_type == "INSTANCE_FLEET":
123
142
  id_name = "InstanceFleetId"
@@ -129,6 +148,11 @@ def get_emr_groups(
129
148
  groups = emr.list_instance_groups(ClusterId=cluster_id)
130
149
  groups = groups["InstanceGroups"]
131
150
 
151
+ else:
152
+ raise ValueError(
153
+ f"Unknown instance collection type: {instance_collection_type}",
154
+ )
155
+
132
156
  return groups, id_name
133
157
 
134
158
 
@@ -139,12 +163,14 @@ def get_emr_instances(
139
163
  """Discover the running EMR instances per instance group"""
140
164
  groups, id_name = get_emr_groups(emr, cluster_id)
141
165
 
142
- groups = {g["Id"]: g["Name"] for g in groups}
166
+ groups = {g.get("Id", ""): g.get("Name", "") for g in groups}
143
167
  grouped_instances = {g: [] for g in groups.values()}
144
168
 
145
- instances = emr.list_instances(ClusterId=cluster_id, InstanceStates=["RUNNING"])
169
+ instances = emr.list_instances(
170
+ ClusterId=cluster_id, InstanceStates=["RUNNING"],
171
+ )
146
172
  for instance in instances["Instances"]:
147
- grouped_instances[groups[instance[id_name]]].append(instance)
173
+ grouped_instances[groups[instance.get(id_name, "")]].append(instance)
148
174
 
149
175
  return grouped_instances
150
176
 
@@ -156,7 +182,7 @@ def get_emr_instance_ips(
156
182
  '''Returns a dict of IPs per group'''
157
183
  return {
158
184
  k: [
159
- IP(x["PrivateIpAddress"], x.get("PublicIpAddress"))
185
+ IP(x.get("PrivateIpAddress"), x.get("PublicIpAddress"))
160
186
  for x in v
161
187
  ]
162
188
  for k, v in get_emr_instances(emr, cluster_id).items()
@@ -20,8 +20,10 @@
20
20
  import os
21
21
  import socket
22
22
  import sys
23
+ from importlib.util import find_spec
23
24
 
24
25
  import paramiko
26
+ from loguru import logger
25
27
 
26
28
  # https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Bracketed-Paste-Mode
27
29
  START_PASTE = "\x1B\x5B\x32\x30\x30\x7E" # ESC[200~
@@ -43,24 +45,15 @@ ALL_CODECS = [
43
45
  'kz1048', 'mac_cyrillic', 'mac_greek', 'mac_iceland', 'mac_latin2', 'mac_roman',
44
46
  'mac_turkish', 'ptcp154', 'shift_jis', 'shift_jis_2004', 'shift_jisx0213',
45
47
  'utf_32', 'utf_32_be', 'utf_32_le', 'utf_16', 'utf_16_be', 'utf_16_le', 'utf_7',
46
- 'utf_8', 'utf_8_sig'
48
+ 'utf_8', 'utf_8_sig',
47
49
  ]
48
50
 
49
51
  # windows does not have termios...
50
- try:
51
- import termios
52
- import tty
52
+ has_termios = find_spec("termios") is not None and find_spec("tty") is not None
53
53
 
54
- has_termios = True
55
- except ImportError:
56
- has_termios = False
57
54
 
58
- if os.getenv('TMUX'):
59
- _TITLE_START = '\x1bk'
60
- _TITLE_END = '\x1b\\'
61
- else:
62
- _TITLE_START = '\x1b]0;'
63
- _TITLE_END = '\x07'
55
+ _TITLE_START = '\x1bk' if os.getenv('TMUX') else '\x1b]0;'
56
+ _TITLE_END = '\x1b\\' if os.getenv('TMUX') else '\x07'
64
57
 
65
58
 
66
59
  def is_int(val: str) -> bool:
@@ -105,19 +98,21 @@ def decode(chars: bytes) -> str:
105
98
  return chars.decode('utf-8')
106
99
  except Exception:
107
100
  # Attempt to decode each individual character
108
- def decode_char(byte: bytes) -> str:
101
+ def decode_char(byte: int) -> str:
109
102
  for codec in ALL_CODECS:
110
103
  try:
111
- c = byte.decode(codec)
104
+ c = byte.to_bytes(1, 'big').decode(codec)
112
105
  return c
113
106
  except Exception:
114
- pass
107
+ logger.debug(
108
+ f'Failed to decode character {byte} as {codec}',
109
+ )
115
110
  else:
116
111
  # Failed to decode character, return replacement character.
117
112
  # https://www.fileformat.info/info/unicode/char/fffd/index.htm
118
113
  return '\uFFFD'
119
114
 
120
- return ''.join(decode_char(b for b in chars))
115
+ return ''.join(decode_char(b) for b in chars)
121
116
 
122
117
 
123
118
  def posix_readkey() -> str:
@@ -133,7 +128,7 @@ def posix_readkey() -> str:
133
128
  Reads one character and handles encoding errors
134
129
  `htop` scrolling for example uses cp037
135
130
  '''
136
- return decode(sys.stdin.buffer.raw.read(1))
131
+ return decode(sys.stdin.buffer.raw.read(1)) # pyright: ignore[reportAttributeAccessIssue, reportUnknownArgumentType]
137
132
 
138
133
  c1 = read()
139
134
 
@@ -183,20 +178,25 @@ def windows_readkey() -> str:
183
178
  return "\x00" + ch2
184
179
 
185
180
 
186
- def posix_shell(chan: paramiko.Channel, allow_title_changes: bool = True): # noqa: C901
181
+ def posix_shell(chan: paramiko.Channel, allow_title_changes: bool = True):
182
+ if not has_termios:
183
+ raise RuntimeError("Termios is not available on this system")
184
+
187
185
  import select
186
+ import termios
187
+ import tty
188
188
 
189
- oldtty = termios.tcgetattr(sys.stdin)
189
+ oldtty = termios.tcgetattr(sys.stdin) # pyright: ignore[reportAttributeAccessIssue]
190
190
 
191
191
  # input_history = []
192
192
  # output_history = []
193
193
 
194
194
  try:
195
- tty.setraw(sys.stdin.fileno())
196
- tty.setcbreak(sys.stdin.fileno())
195
+ tty.setraw(sys.stdin.fileno()) # pyright: ignore[reportAttributeAccessIssue]
196
+ tty.setcbreak(sys.stdin.fileno()) # pyright: ignore[reportAttributeAccessIssue]
197
197
  chan.settimeout(0.0)
198
198
  while True:
199
- r, w, e = select.select([chan, sys.stdin], [], [])
199
+ r, _, _ = select.select([chan, sys.stdin], [], [])
200
200
  if chan in r:
201
201
  try:
202
202
  data = decode(chan.recv(1024))
@@ -210,7 +210,7 @@ def posix_shell(chan: paramiko.Channel, allow_title_changes: bool = True): # no
210
210
  # output_history.append(data)
211
211
  sys.stdout.write(data)
212
212
  sys.stdout.flush()
213
- except socket.timeout:
213
+ except TimeoutError:
214
214
  pass
215
215
  if sys.stdin in r:
216
216
  key = posix_readkey()
@@ -223,7 +223,7 @@ def posix_shell(chan: paramiko.Channel, allow_title_changes: bool = True): # no
223
223
  key = posix_readkey()
224
224
  # Until we reach the end of the pasted text
225
225
  while key != END_PASTE:
226
- chan.send(key)
226
+ chan.send(key.encode())
227
227
  # input_history.append(key)
228
228
  key = posix_readkey()
229
229
  # We've exhausted the paste event, wait for next event
@@ -231,11 +231,11 @@ def posix_shell(chan: paramiko.Channel, allow_title_changes: bool = True): # no
231
231
 
232
232
  if len(key) == 0:
233
233
  break
234
- chan.send(key)
234
+ chan.send(key.encode())
235
235
  # input_history.append(key)
236
236
 
237
237
  finally:
238
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, oldtty)
238
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, oldtty) # pyright: ignore[reportAttributeAccessIssue]
239
239
 
240
240
  # Useful in debugging how control characters were send
241
241
  # from pprint import pprint
@@ -248,15 +248,17 @@ def windows_shell(chan: paramiko.Channel, allow_title_changes: bool = True):
248
248
  import threading
249
249
 
250
250
  sys.stdout.write(
251
- "Line-buffered terminal emulation. Press F6 or ^Z to send EOF.\r\n\r\n"
251
+ "Line-buffered terminal emulation. Press F6 or ^Z to send EOF.\r\n\r\n",
252
252
  )
253
253
 
254
- def writeall(sock):
254
+ def writeall(sock: socket.socket):
255
255
  while True:
256
256
  data = sock.recv(256).decode()
257
257
  if not data:
258
258
  # Need user to input any character so we sys.stdin.read(1) completes and unblocks
259
- sys.stdout.write("\r\n Connection closed. Press Enter to continue...\r\n")
259
+ sys.stdout.write(
260
+ "\r\n Connection closed. Press Enter to continue...\r\n",
261
+ )
260
262
  sys.stdout.flush()
261
263
  break
262
264
 
@@ -274,7 +276,7 @@ def windows_shell(chan: paramiko.Channel, allow_title_changes: bool = True):
274
276
  d = windows_readkey()
275
277
  if not d or chan.closed:
276
278
  break
277
- chan.send(d)
279
+ chan.send(d.encode())
278
280
  except EOFError:
279
281
  # user hit ^Z or F6
280
282
  pass
File without changes
@@ -5,18 +5,20 @@ import subprocess
5
5
  import sys
6
6
  import textwrap
7
7
  from dataclasses import dataclass, field
8
- from hashlib import sha1
9
- from typing import TYPE_CHECKING
8
+ from hashlib import sha256
9
+ from types import TracebackType
10
+ from typing import TYPE_CHECKING, Any
10
11
 
11
12
  import boto3
12
13
  import click
13
14
  import click_spinner
14
15
  import paramiko
15
- import paramiko.pkey
16
+ import paramiko.client
16
17
  import questionary
17
18
  from botocore.exceptions import ClientError
18
19
  from environs import Env
19
20
  from loguru import logger
21
+ from typing_extensions import override
20
22
 
21
23
  from .emr_utils import (
22
24
  IP,
@@ -31,20 +33,24 @@ if TYPE_CHECKING:
31
33
  from mypy_boto3_emr import EMRClient
32
34
 
33
35
  Env().read_env() # Load .env file
36
+ # CSV of issuer,client
34
37
  OPKSSH_PROVIDER_TAG = 'opkssh_provider'
38
+ # EMR doesn't support comma's in tags.
39
+ OPKSSH_ISSUER_TAG = 'opkssh_issuer'
40
+ OPKSSH_CLIENT_TAG = 'opkssh_client'
35
41
 
36
42
 
37
43
  class ShellError(Exception):
38
44
  def __init__(self, message: str, exit_code: int = 1):
39
45
  super().__init__(message, exit_code)
40
- self.message = message
41
- self.exit_code = exit_code
46
+ self.message: str = message
47
+ self.exit_code: int = exit_code
42
48
 
43
49
 
44
50
  def set_terminal_title(title: str = ''):
45
51
  if os.name == 'nt':
46
52
  # Windows - CMD
47
- os.system(f'title "{title}"')
53
+ os.system(f'title "{title}"') # noqa: S605
48
54
 
49
55
  # Windows - Powershell - But it seems that the scripts are always ran inside CMD anyway.
50
56
  # os.system(f'$host.UI.RawUI.WindowTitle = {title}')
@@ -64,7 +70,7 @@ class SelectedEMRInstance:
64
70
  cluster_id: str
65
71
  cluster_name: str
66
72
  group_name: str
67
- group_idx: str
73
+ group_idx: int
68
74
  ip: IP
69
75
 
70
76
 
@@ -76,9 +82,9 @@ def cli(
76
82
  long_log: bool = False,
77
83
  verbose: bool = False,
78
84
  quiet: bool = False,
79
- **kwargs,
85
+ **kwargs: Any,
80
86
  ):
81
- format = (
87
+ log_format = (
82
88
  "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
83
89
  "<level>{level: <8}</level> | "
84
90
  "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
@@ -86,7 +92,7 @@ def cli(
86
92
  level = "DEBUG" if verbose else 100 if quiet else "INFO"
87
93
 
88
94
  logger.remove()
89
- logger.add(sys.stdout, level=level, format=format, colorize=True)
95
+ logger.add(sys.stdout, level=level, format=log_format, colorize=True)
90
96
 
91
97
 
92
98
  @cli.command('ec2')
@@ -96,12 +102,12 @@ def cli(
96
102
  @click.option('--private/--public', default=True, help="Connect to the instance's private or public IP")
97
103
  @click.option('-k/', '--key-file', default=None, help="Which key file to use to connect")
98
104
  def ec2_ssh(
99
- profile: str = None,
100
- region: str = None,
101
- user: str = None,
105
+ profile: str | None = None,
106
+ region: str | None = None,
107
+ user: str | None = None,
102
108
  private: bool = True,
103
- key_file: str = None,
104
- **kwargs,
109
+ key_file: str | None = None,
110
+ **kwargs: Any,
105
111
  ):
106
112
  '''
107
113
  Asks user which EC2 instance they want to connect to,
@@ -111,16 +117,23 @@ def ec2_ssh(
111
117
  try:
112
118
  b3s = boto3.Session(profile_name=profile, region_name=region)
113
119
 
114
- ip, user, key_file, instance_name = get_ec2_ssh_options(b3s=b3s, user=user, use_private_ip=private, key_file=key_file)
120
+ ip, user, key_file, instance_name = get_ec2_ssh_options(
121
+ b3s=b3s, user=user, use_private_ip=private, key_file=key_file,
122
+ )
115
123
  terminal_title = f'{user}@{instance_name}'
116
124
 
117
- SSHShell(hostname=ip, username=user, key_filename=key_file, terminal_title=terminal_title).connect()
125
+ SSHShell(
126
+ hostname=ip, username=user, key_filename=key_file,
127
+ terminal_title=terminal_title,
128
+ ).connect()
118
129
  except ShellError as e:
119
130
  logger.error(e.message)
120
131
  exit(e.exit_code)
121
132
  except ClientError as e:
122
- if e.response.get('Error') and e.response['Error'].get('Code') == 'ExpiredTokenException':
123
- logger.log(logging.CRITICAL, 'Your AWS Token has expired. Please update and try again.')
133
+ if e.response.get('Error') and e.response.get('Error', {}).get('Code') == 'ExpiredTokenException':
134
+ logger.log(
135
+ logging.CRITICAL, 'Your AWS Token has expired. Please update and try again.',
136
+ )
124
137
  exit(1)
125
138
 
126
139
 
@@ -131,12 +144,12 @@ def ec2_ssh(
131
144
  @click.option('--private/--public', default=True, help="Connect to the instance's private or public IP")
132
145
  @click.option('-k/', '--key-file', default=None, help="Which key file to use to connect")
133
146
  def emr_ssh(
134
- profile: str = None,
135
- region: str = None,
136
- user: str = None,
147
+ profile: str | None = None,
148
+ region: str | None = None,
149
+ user: str | None = None,
137
150
  private: bool = True,
138
- key_file: str = None,
139
- **kwargs
151
+ key_file: str | None = None,
152
+ **kwargs: Any,
140
153
  ):
141
154
  '''
142
155
  Asks user which Cluster and EC2 instance they want to connect to,
@@ -152,23 +165,31 @@ def emr_ssh(
152
165
  user = 'hadoop'
153
166
 
154
167
  if key_file is None:
155
- key_file = get_emr_ssh_key_file_for_cluster(emr, emr_instance.cluster_id)
168
+ key_file = get_emr_ssh_key_file_for_cluster(
169
+ emr, emr_instance.cluster_id,
170
+ )
156
171
 
157
172
  if private:
158
173
  ip = emr_instance.ip.private
174
+ if ip is None:
175
+ raise ShellError(
176
+ 'The selected instance does not have a private IP',
177
+ )
159
178
  else:
160
179
  ip = emr_instance.ip.public
161
180
  if ip is None:
162
- raise ShellError('The selected instance does not have a public IP')
181
+ raise ShellError(
182
+ 'The selected instance does not have a public IP',
183
+ )
163
184
 
164
185
  group_name = emr_instance.group_name.split(' ')[0]
165
- postfix = f'[{emr_instance.group_idx}]' if emr_instance.group_idx is not None else ''
186
+ postfix = f'[{emr_instance.group_idx}]' or ''
166
187
  terminal_title = f'{emr_instance.cluster_name} - {group_name}{postfix}'
167
188
 
168
189
  with SSHShell(hostname=ip, username=user, key_filename=key_file, terminal_title=terminal_title) as shell:
169
190
  if group_name.lower().startswith('core'):
170
- shell.send('sudo su\r\n')
171
- shell.send('cd /var/log/hadoop-yarn/containers\r\n')
191
+ shell.send(b'sudo su\r\n')
192
+ shell.send(b'cd /var/log/hadoop-yarn/containers\r\n')
172
193
 
173
194
  set_terminal_title()
174
195
 
@@ -176,8 +197,10 @@ def emr_ssh(
176
197
  logger.error(e.message)
177
198
  exit(e.exit_code)
178
199
  except ClientError as e:
179
- if e.response.get('Error') and e.response['Error'].get('Code') == 'ExpiredTokenException':
180
- logger.log(logging.CRITICAL, 'Your AWS Token has expired. Please update and try again.')
200
+ if e.response.get('Error') and e.response.get('Error', {}).get('Code') == 'ExpiredTokenException':
201
+ logger.log(
202
+ logging.CRITICAL, 'Your AWS Token has expired. Please update and try again.',
203
+ )
181
204
  exit(1)
182
205
 
183
206
 
@@ -188,12 +211,12 @@ def emr_ssh(
188
211
  @click.option('--private/--public', default=True, help="Connect to the instance's private or public IP")
189
212
  @click.option('-k/', '--key-file', default=None, help="Which key file to use to connect")
190
213
  def emr_ssh_all(
191
- profile: str = None,
192
- region: str = None,
193
- user: str = None,
214
+ profile: str | None = None,
215
+ region: str | None = None,
216
+ user: str | None = None,
194
217
  private: bool = True,
195
- key_file: str = None,
196
- **kwargs,
218
+ key_file: str | None = None,
219
+ **kwargs: Any,
197
220
  ):
198
221
  '''
199
222
  Asks user which Cluster and EC2 instance they want to connect to,
@@ -217,18 +240,23 @@ def emr_ssh_all(
217
240
 
218
241
  window_order = ['Master', 'Primary', 'Core', 'Task']
219
242
 
220
- def get_ip(ip: IP) -> str:
243
+ def get_ip(ip: IP) -> str | None:
221
244
  if private:
222
245
  return ip.private
223
246
  else:
224
247
  if ip.public is None:
225
- raise ShellError(f'The instance with private IP {ip.private} does not have a public IP')
248
+ raise ShellError(
249
+ f'The instance with private IP {ip.private} does not have a public IP',
250
+ )
226
251
  return ip.public
227
252
 
228
253
  window_cmds = [
229
254
  # Add "|| $SHELL -i" so that if the ssh session fails, we still have a window so we can look at the error.
230
255
  # without this, tmux will close the window.
231
- (f'{group_name} - {instance_num}', f'ssh -i {key_file} {user}@{get_ip(instance_ip)} || $SHELL -i')
256
+ (
257
+ f'{group_name} - {instance_num}',
258
+ f'ssh -i {key_file} {user}@{get_ip(instance_ip)} || $SHELL -i',
259
+ )
232
260
  for group_name, instance_ips in sorted(grouped_instances.items(), key=lambda t: window_order.index(t[0]))
233
261
  for instance_num, instance_ip in enumerate(instance_ips, start=1)
234
262
  ]
@@ -236,10 +264,14 @@ def emr_ssh_all(
236
264
  session_name = cluster_name
237
265
  first_window_name, first_cmd = window_cmds[0]
238
266
  # Create a new tmux session with the EMR cluster name as the session name and open the first ssh connection
239
- tmux(f'new-session -d -s "{session_name}" -n "{first_window_name}" "{first_cmd}"')
267
+ tmux(
268
+ f'new-session -d -s "{session_name}" -n "{first_window_name}" "{first_cmd}"',
269
+ )
240
270
  # Create a new tmux window and open the ssh connections
241
271
  for window_cmd in window_cmds[1:]:
242
- tmux(f'new-window -t "{session_name}:" -n "{window_cmd[0]}" "{window_cmd[1]}"')
272
+ tmux(
273
+ f'new-window -t "{session_name}:" -n "{window_cmd[0]}" "{window_cmd[1]}"',
274
+ )
243
275
  # Now switch to the session's first window
244
276
  tmux(f'switch-client -t "{session_name}:{first_window_name}"')
245
277
 
@@ -247,8 +279,10 @@ def emr_ssh_all(
247
279
  logger.error(e.message)
248
280
  exit(e.exit_code)
249
281
  except ClientError as e:
250
- if e.response.get('Error') and e.response['Error'].get('Code') == 'ExpiredTokenException':
251
- logger.log(logging.CRITICAL, 'Your AWS Token has expired. Please update and try again.')
282
+ if e.response.get('Error') and e.response.get('Error', {}).get('Code') == 'ExpiredTokenException':
283
+ logger.log(
284
+ logging.CRITICAL, 'Your AWS Token has expired. Please update and try again.',
285
+ )
252
286
  exit(1)
253
287
 
254
288
 
@@ -256,27 +290,32 @@ def emr_ssh_all(
256
290
  class SSHShell:
257
291
  hostname: str
258
292
  username: str
259
- key_filename: str
260
- terminal_title: str = None
293
+ key_filename: str | None = None
294
+ terminal_title: str | None = None
261
295
 
262
296
  private_key: paramiko.PKey = field(init=False)
297
+ _ssh_client: paramiko.SSHClient = field(init=False)
298
+ _channel: paramiko.Channel = field(init=False)
263
299
 
264
300
  def __post_init__(self):
265
- if not os.path.isfile(self.key_filename):
266
- raise ValueError(f'File {self.key_filename} does not exist')
301
+ if self.key_filename is not None:
302
+ if not os.path.isfile(self.key_filename):
303
+ raise ValueError(f'File {self.key_filename} does not exist')
267
304
 
268
- self.private_key = paramiko.PKey.from_path(self.key_filename)
269
- public_key_path = f'{self.key_filename}.pub'
270
- if os.path.isfile(public_key_path):
271
- self.private_key.load_certificate(public_key_path)
305
+ self.private_key = paramiko.PKey.from_path(self.key_filename)
306
+ public_key_path = f'{self.key_filename}.pub'
307
+ if os.path.isfile(public_key_path):
308
+ self.private_key.load_certificate(public_key_path)
272
309
 
273
310
  def connect(self):
274
311
  self._open()
275
312
  self._launch()
276
313
  self._close()
277
314
 
278
- def _open(self):
279
- logger.info(f'Opening SSH: ssh -i {self.key_filename} {self.username}@{self.hostname}')
315
+ def _open(self) -> paramiko.Channel:
316
+ logger.info(
317
+ f'Opening SSH: ssh -i {self.key_filename} {self.username}@{self.hostname}',
318
+ )
280
319
 
281
320
  terminal_size = os.get_terminal_size()
282
321
 
@@ -313,8 +352,11 @@ class SSHShell:
313
352
  '''
314
353
  raise ShellError(textwrap.dedent(error_message), 255) from None
315
354
 
316
- channel = ssh_client.get_transport().open_session()
317
- channel.get_pty(term=os.getenv('TERM', 'xterm-256color'), width=terminal_size.columns, height=terminal_size.lines)
355
+ channel = ssh_client.get_transport().open_session() # pyright: ignore[reportOptionalMemberAccess]
356
+ channel.get_pty(
357
+ term=os.getenv('TERM', 'xterm-256color'),
358
+ width=terminal_size.columns, height=terminal_size.lines,
359
+ )
318
360
  channel.invoke_shell()
319
361
 
320
362
  self._ssh_client = ssh_client
@@ -325,7 +367,9 @@ class SSHShell:
325
367
  return channel
326
368
 
327
369
  def _launch(self):
328
- interactive_shell(self._channel, allow_title_changes=not self.terminal_title)
370
+ interactive_shell(
371
+ self._channel, allow_title_changes=not self.terminal_title,
372
+ )
329
373
 
330
374
  def _close(self):
331
375
  self._ssh_client.close()
@@ -336,16 +380,16 @@ class SSHShell:
336
380
  def __enter__(self):
337
381
  return self._open()
338
382
 
339
- def __exit__(self, exception_type, exception_value, traceback):
383
+ def __exit__(self, exception_type: type[BaseException] | None, exception_value: BaseException | None, traceback: TracebackType | None):
340
384
  self._launch()
341
385
  self._close()
342
386
 
343
387
 
344
388
  def tmux(command: str):
345
- os.system(f'tmux {command}')
389
+ os.system(f'tmux {command}') # noqa: S605
346
390
 
347
391
 
348
- def try_to_find_ssh_key_file(key_name: str) -> str:
392
+ def try_to_find_ssh_key_file(key_name: str) -> str | None:
349
393
  '''Recursively iterate over the `~/.ssh/` folder to find the matching key'''
350
394
  if key_name:
351
395
  for dirpath, _, filenames in os.walk(os.path.expanduser('~/.ssh/')):
@@ -357,7 +401,7 @@ def try_to_find_ssh_key_file(key_name: str) -> str:
357
401
  return None
358
402
 
359
403
 
360
- def get_ec2_image_name(instance) -> str:
404
+ def get_ec2_image_name(instance: "Instance") -> str | None:
361
405
  try:
362
406
  return instance.image.name
363
407
  except AttributeError:
@@ -366,31 +410,34 @@ def get_ec2_image_name(instance) -> str:
366
410
 
367
411
  def get_ec2_ssh_options(
368
412
  b3s: boto3.Session,
369
- user: str = None,
413
+ user: str | None = None,
370
414
  use_private_ip: bool = True,
371
- key_file: str = None
372
- ) -> tuple[str, str, str, str]:
415
+ key_file: str | None = None,
416
+ ) -> tuple[str, str, str | None, str]:
373
417
  instance = prompt_for_ec2_instance(b3s)
374
418
  instance_tags = {
375
- tag['Key'].lower(): tag['Value']
419
+ tag.get('Key', '').lower(): tag.get('Value', '')
376
420
  for tag in instance.tags
377
421
  }
378
422
 
379
423
  if key_file is None:
380
424
  # Support https://github.com/openpubkey/opkssh via tags
381
425
  if OPKSSH_PROVIDER_TAG in instance_tags:
382
- key_file = get_opkssh_key_file(instance_tags[OPKSSH_PROVIDER_TAG])
383
-
426
+ key_file = get_opkssh_key_file(instance_tags[OPKSSH_PROVIDER_TAG])
427
+ elif OPKSSH_ISSUER_TAG in instance_tags and OPKSSH_CLIENT_TAG in instance_tags:
428
+ key_file = get_opkssh_key_file(
429
+ ','.join([
430
+ instance_tags[OPKSSH_ISSUER_TAG],
431
+ instance_tags[OPKSSH_CLIENT_TAG],
432
+ ]),
433
+ )
384
434
  else:
385
435
  key_file = try_to_find_ssh_key_file(instance.key_name)
386
436
 
387
437
  if user is None:
388
438
  logger.info('No user specified, attempting to detect required user...')
389
439
  image_name = get_ec2_image_name(instance)
390
- if 'ubuntu' in image_name:
391
- user = 'ubuntu'
392
- else:
393
- user = 'ec2-user'
440
+ user = 'ubuntu' if image_name and 'ubuntu' in image_name.lower() else 'ec2-user'
394
441
 
395
442
  if use_private_ip:
396
443
  ip = instance.private_ip_address
@@ -408,10 +455,10 @@ def get_emr_ssh_options(
408
455
  cluster_id, cluster_name = prompt_for_emr_cluster(emr)
409
456
  instance_ips, group_name = prompt_for_emr_instance_group(emr, cluster_id)
410
457
 
411
- instance_ips = sorted(instance_ips, key=lambda ips: ips.private)
458
+ instance_ips = sorted(instance_ips, key=lambda ips: ips.private or "")
412
459
 
413
460
  instance_options = [
414
- f'{ip.private} ({ip.public})' if ip.public else ip.private
461
+ f'{ip.private} ({ip.public})' if ip.public else f'{ip.private}'
415
462
  for ip in instance_ips
416
463
  ]
417
464
  instance_ip = questionary.select(
@@ -426,24 +473,38 @@ def get_emr_ssh_options(
426
473
  def get_emr_ssh_key_file_for_cluster(
427
474
  emr: "EMRClient",
428
475
  cluster_id: str,
429
- ) -> str:
476
+ ) -> str | None:
430
477
  with click_spinner.spinner():
431
478
  cluster = emr.describe_cluster(ClusterId=cluster_id)
432
479
  cluster_tags = {
433
- tag['Key'].lower(): tag['Value']
434
- for tag in cluster['Cluster']['Tags']
480
+ tag.get('Key', '').lower(): tag.get('Value', '')
481
+ for tag in cluster['Cluster'].get('Tags', [])
435
482
  }
483
+
484
+ key_file = key_name = None
436
485
  # Support https://github.com/openpubkey/opkssh via tags
437
486
  if OPKSSH_PROVIDER_TAG in cluster_tags:
438
- key_file = get_opkssh_key_file(cluster_tags[OPKSSH_PROVIDER_TAG])
439
- else:
440
- key_name = cluster["Cluster"]["Ec2InstanceAttributes"]["Ec2KeyName"]
487
+ key_file = get_opkssh_key_file(cluster_tags[OPKSSH_PROVIDER_TAG])
488
+ elif OPKSSH_ISSUER_TAG in cluster_tags and OPKSSH_CLIENT_TAG in cluster_tags:
489
+ key_file = get_opkssh_key_file(
490
+ ','.join([
491
+ cluster_tags[OPKSSH_ISSUER_TAG],
492
+ cluster_tags[OPKSSH_CLIENT_TAG],
493
+ ]),
494
+ )
495
+
496
+ if key_file is None:
497
+ key_name = cluster["Cluster"].get(
498
+ "Ec2InstanceAttributes", {},
499
+ ).get("Ec2KeyName", "")
441
500
  key_file = try_to_find_ssh_key_file(key_name)
442
501
 
443
- if key_file is None:
444
- should_continue = questionary.confirm(f'Could not find the ssh key {key_name}, would you like to continue?').unsafe_ask()
445
- if not should_continue:
446
- exit(1)
502
+ if key_file is None:
503
+ should_continue = questionary.confirm(
504
+ f'Could not find the ssh key {key_name}, would you like to continue?',
505
+ ).unsafe_ask()
506
+ if not should_continue:
507
+ exit(1)
447
508
 
448
509
  return key_file
449
510
 
@@ -497,21 +558,32 @@ def prompt_for_ec2_instance(
497
558
  def get_ec2_name(ec2_instance: "Instance") -> str:
498
559
  '''Takes in the boto3 EC2 resource instance object'''
499
560
  for tag in ec2_instance.tags:
500
- if tag['Key'] == 'Name':
501
- return tag['Value']
561
+ if tag.get('Key') == 'Name':
562
+ return tag.get('Value', '')
502
563
 
503
564
  return ec2_instance.instance_id
504
565
 
505
566
 
506
567
  def get_opkssh_key_file(provider: str) -> str:
507
- opkssh_key_file = os.path.join(os.path.expanduser('~/.ssh/'), f'opkssh_{sha1(provider.encode()).hexdigest()}')
568
+ opkssh_key_file = os.path.join(
569
+ os.path.expanduser(
570
+ '~/.ssh/',
571
+ ), f'opkssh_{sha256(provider.encode()).hexdigest()}',
572
+ )
508
573
 
509
574
  if os.path.exists(opkssh_key_file) and os.path.getmtime(opkssh_key_file) > dt.datetime.now().timestamp() - 86400:
510
575
  logger.info('Found existing opkssh key')
511
576
  else:
512
577
  logger.info('Detected opkssh, logging in')
513
- cmd = f"opkssh login --provider '{provider}' -i '{opkssh_key_file}'"
514
- process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
578
+ cmd = [
579
+ "opkssh",
580
+ "login",
581
+ "--provider",
582
+ provider,
583
+ "-i",
584
+ opkssh_key_file,
585
+ ]
586
+ process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE) # noqa: S602
515
587
  process.wait()
516
588
  if process.returncode != 0:
517
589
  logger.error('Failed to authenticate using opkssh')
@@ -526,16 +598,28 @@ class ConfirmAddPolicy(paramiko.client.MissingHostKeyPolicy):
526
598
  local `.HostKeys` object, and saving it. This is used by `.SSHClient`.
527
599
  """
528
600
 
529
- def missing_host_key(self, client, hostname, key):
530
- logger.warning(f"Unknown {key.get_name()} host key for {hostname}: {key.fingerprint}")
531
- should_add = questionary.confirm("Continue and add host key?").unsafe_ask()
601
+ @override
602
+ def missing_host_key(self, client: paramiko.SSHClient, hostname: str, key: paramiko.PKey):
603
+ logger.warning(
604
+ f"Unknown {key.get_name()} host key for {hostname}: {key.fingerprint}",
605
+ )
606
+ should_add = questionary.confirm(
607
+ "Continue and add host key?",
608
+ ).unsafe_ask()
532
609
  if should_add:
533
- client._host_keys.add(hostname, key.get_name(), key)
534
- if client._host_keys_filename is not None:
535
- client.save_host_keys(client._host_keys_filename)
610
+ client.get_host_keys().add(hostname, key.get_name(), key)
611
+ host_key_filename: str | None = (
612
+ client._host_keys_filename # pyright: ignore[reportAttributeAccessIssue]
613
+ )
614
+ if host_key_filename is not None:
615
+ client.save_host_keys(host_key_filename)
536
616
  logger.info('Added host key')
537
617
  else:
538
- logger.warning('Failed to add host key. No host key file defined!')
618
+ logger.warning(
619
+ 'Failed to add host key. No host key file defined!',
620
+ )
539
621
 
540
622
  else:
541
- raise paramiko.SSHException(f"Server {hostname!r} not found in known_hosts")
623
+ raise paramiko.SSHException(
624
+ f"Server {hostname!r} not found in known_hosts",
625
+ )
@@ -0,0 +1,161 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "aws-ssh-utils"
7
+ description = "Easy AWS SSHing"
8
+ readme = "README.md"
9
+ requires-python = ">=3.11"
10
+ license = "MIT"
11
+ authors = [
12
+ { name = "Michiel Vanderlee", email = "jmt.vanderlee@gmail.com" },
13
+ ]
14
+ classifiers = [
15
+ "Intended Audience :: Information Technology",
16
+ "Intended Audience :: System Administrators",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ "Topic :: Software Development :: Libraries",
22
+ "Topic :: Software Development",
23
+ "Typing :: Typed",
24
+ "Intended Audience :: Developers",
25
+ "License :: OSI Approved :: MIT License",
26
+ "Programming Language :: Python :: 3 :: Only",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ ]
31
+ dependencies = [
32
+ "boto3",
33
+ "click",
34
+ "click_spinner",
35
+ "coloredlogs",
36
+ "environs",
37
+ "loguru",
38
+ "paramiko",
39
+ "questionary",
40
+ "typing_extensions",
41
+ ]
42
+ dynamic = ["version"]
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/mvanderlee/aws-ssh-utils"
46
+
47
+ [project.scripts]
48
+ aws_ssh = "aws_ssh_utils.ssh:cli"
49
+
50
+ [project.optional-dependencies]
51
+
52
+ dev = [
53
+ "boto3-stubs[ec2,emr]",
54
+ ]
55
+ test = [
56
+ ]
57
+ publish = [
58
+ "hatch >= 1.7.0",
59
+ ]
60
+
61
+ [tool.hatch.build]
62
+ include = [
63
+ "aws_ssh_utils",
64
+ ]
65
+
66
+ [tool.hatch.version]
67
+ path = "aws_ssh_utils/__init__.py"
68
+
69
+
70
+ [tool.coverage.run]
71
+ parallel = true
72
+ source = [
73
+ "aws_ssh_utils"
74
+ ]
75
+
76
+ [tool.autopep8]
77
+ max_line_length = 130
78
+
79
+ [tool.ruff]
80
+ exclude = [
81
+ "*.ipynb",
82
+ "**/_version.py",
83
+ ]
84
+ lint.select = [
85
+ "E", # pycodestyle errors
86
+ "W", # pycodestyle warnings
87
+ "F", # pyflakes
88
+ "I", # isort
89
+ "C", # flake8-comprehensions
90
+ "B", # flake8-bugbear
91
+ "A", # flake8-builtins
92
+ "N", # pep8-naming
93
+ "UP", # pyupgrade
94
+ "S", # flake8-bandit
95
+ "COM", # flake8-commas
96
+ "ISC", # flake8-implicit-str-concat
97
+ "INP", # flake8-no-pep420
98
+ "PIE", # flake8-pie
99
+ "SIM", # flake8-simplify
100
+ "RUF", # Ruff-specific rules
101
+ "T20", # flake8-print
102
+ "PT", # flake8-pytest-style
103
+ ]
104
+ lint.ignore = [
105
+ "B008", # do not perform function calls in argument defaults
106
+ "B028", # No explicit stacklevel argument found.
107
+ "C408", # unnecessary-collection-call - Rewrite as a literal
108
+ "C901", # too complex
109
+ "PT003", # pytest-extraneous-scope-function
110
+
111
+ # TODO: remove
112
+ "A002", # builtin-argument-shadowing
113
+ "PT011", # pytest-raises-too-broad
114
+ "N818", # error-suffix-on-exception-name
115
+
116
+ # Unsure, need more data
117
+ "SIM117", # multiple-with-statements - In general good, but sometimes becomes harder to read, i.e.: aiohttp?
118
+ ]
119
+ # The formatter wraps lines at a length of 88.
120
+ line-length = 130 # Enforce 130. But ~100 is recommended.
121
+
122
+ [tool.ruff.lint.pycodestyle]
123
+ max-line-length = 150 # E501 reports lines that exceed the length of 150.
124
+
125
+ [tool.ruff.format]
126
+ quote-style = "preserve" # Don't change quotes - https://docs.astral.sh/ruff/settings/#format_quote-style
127
+
128
+ [tool.ruff.lint.per-file-ignores]
129
+ "__init__.py" = ["F401"]
130
+
131
+ [tool.ruff.lint.flake8-bugbear]
132
+ # Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
133
+ extend-immutable-calls = []
134
+
135
+ [tool.pytest.ini_options]
136
+ # https://pytest-asyncio.readthedocs.io/en/latest/reference/configuration.html
137
+ asyncio_mode = "auto"
138
+ asyncio_default_test_loop_scope = "session"
139
+ asyncio_default_fixture_loop_scope = "session"
140
+
141
+ [tool.pyright]
142
+ venvPath = "."
143
+ venv = ".venv"
144
+
145
+ include = [
146
+ "aws_ssh_utils/",
147
+ ]
148
+
149
+ pythonVersion = "3.11"
150
+
151
+ # basedpyright exclusions
152
+ reportAny = "none"
153
+ reportUnreachable = "none"
154
+ reportExplicitAny = "none"
155
+ reportUnusedCallResult = "none"
156
+ reportUnknownMemberType = "none"
157
+ reportUnusedParameter = "none"
158
+ reportUntypedFunctionDecorator = "none"
159
+ reportMissingTypeStubs = "none"
160
+ reportUnknownVariableType = "none"
161
+ reportImplicitStringConcatenation = "none"
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"
@@ -1,102 +0,0 @@
1
- [build-system]
2
- requires = ["hatchling"]
3
- build-backend = "hatchling.build"
4
-
5
- [project]
6
- name = "aws-ssh-utils"
7
- description = "Easy AWS SSHing"
8
- readme = "README.md"
9
- requires-python = ">=3.11"
10
- license = "MIT"
11
- authors = [
12
- { name = "Michiel Vanderlee", email = "jmt.vanderlee@gmail.com" },
13
- ]
14
- classifiers = [
15
- "Intended Audience :: Information Technology",
16
- "Intended Audience :: System Administrators",
17
- "Operating System :: OS Independent",
18
- "Programming Language :: Python :: 3 :: Only",
19
- "Programming Language :: Python",
20
- "Topic :: Software Development :: Libraries :: Python Modules",
21
- "Topic :: Software Development :: Libraries",
22
- "Topic :: Software Development",
23
- "Typing :: Typed",
24
- "Intended Audience :: Developers",
25
- "License :: OSI Approved :: MIT License",
26
- "Programming Language :: Python :: 3 :: Only",
27
- "Programming Language :: Python :: 3.11",
28
- "Programming Language :: Python :: 3.12",
29
- "Programming Language :: Python :: 3.13",
30
- ]
31
- dependencies = [
32
- "boto3",
33
- "click",
34
- "click_spinner",
35
- "coloredlogs",
36
- "environs",
37
- "loguru",
38
- "paramiko",
39
- "questionary",
40
- ]
41
- dynamic = ["version"]
42
-
43
- [project.urls]
44
- Homepage = "https://github.com/mvanderlee/aws-ssh-utils"
45
-
46
- [project.scripts]
47
- aws_ssh = "aws_ssh_utils.ssh:cli"
48
-
49
- [project.optional-dependencies]
50
-
51
- dev = [
52
- "boto3-stubs[ec2,emr]",
53
- ]
54
- test = [
55
- ]
56
- publish = [
57
- "hatch >= 1.7.0",
58
- ]
59
-
60
- [tool.hatch.build]
61
- include = [
62
- "aws_ssh_utils",
63
- ]
64
-
65
- [tool.hatch.version]
66
- path = "aws_ssh_utils/__init__.py"
67
-
68
- [tool.isort]
69
- profile = "hug"
70
- line_length = 100
71
-
72
- [tool.coverage.run]
73
- parallel = true
74
- source = [
75
- "aws_ssh_utils"
76
- ]
77
-
78
- [tool.ruff]
79
- select = [
80
- "E", # pycodestyle errors
81
- "W", # pycodestyle warnings
82
- "F", # pyflakes
83
- # "I", # isort
84
- "C", # flake8-comprehensions
85
- "B", # flake8-bugbear
86
- ]
87
- ignore = [
88
- "B008", # do not perform function calls in argument defaults
89
- "B028", # No explicit stacklevel argument found.
90
- "C901", # too complex
91
- ]
92
- line-length = 130 # Enforce 130. But ~100 is recommended.
93
-
94
-
95
- [tool.ruff.per-file-ignores]
96
- "__init__.py" = ["F401"]
97
- "migrations/env.py" = ["E402"]
98
-
99
- [tool.pytest.ini_options]
100
- # https://pytest-asyncio.readthedocs.io/en/latest/reference/configuration.html
101
- asyncio_mode = "auto"
102
- asyncio_default_fixture_loop_scope = "session"
File without changes
File without changes
File without changes