aws-annoying 0.4.0__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. aws_annoying/cli/app.py +81 -0
  2. aws_annoying/cli/ecs/__init__.py +3 -0
  3. aws_annoying/cli/ecs/_app.py +9 -0
  4. aws_annoying/cli/{ecs_task_definition_lifecycle.py → ecs/task_definition_lifecycle.py} +18 -13
  5. aws_annoying/cli/ecs/wait_for_deployment.py +94 -0
  6. aws_annoying/cli/load_variables.py +22 -22
  7. aws_annoying/cli/logging_handler.py +52 -0
  8. aws_annoying/cli/main.py +1 -1
  9. aws_annoying/cli/mfa/configure.py +21 -12
  10. aws_annoying/cli/session_manager/_common.py +1 -2
  11. aws_annoying/cli/session_manager/install.py +10 -7
  12. aws_annoying/cli/session_manager/port_forward.py +41 -38
  13. aws_annoying/cli/session_manager/start.py +48 -2
  14. aws_annoying/cli/session_manager/stop.py +9 -7
  15. aws_annoying/ecs/__init__.py +17 -0
  16. aws_annoying/ecs/common.py +8 -0
  17. aws_annoying/ecs/deployment_waiter.py +274 -0
  18. aws_annoying/ecs/errors.py +14 -0
  19. aws_annoying/{mfa.py → mfa_config.py} +7 -2
  20. aws_annoying/session_manager/__init__.py +8 -1
  21. aws_annoying/session_manager/session_manager.py +26 -39
  22. aws_annoying/session_manager/shortcuts.py +76 -0
  23. aws_annoying/utils/ec2.py +36 -0
  24. aws_annoying/utils/platform.py +11 -0
  25. aws_annoying/utils/timeout.py +88 -0
  26. aws_annoying/{variables.py → variable_loader.py} +11 -16
  27. {aws_annoying-0.4.0.dist-info → aws_annoying-0.6.0.dist-info}/METADATA +47 -2
  28. aws_annoying-0.6.0.dist-info/RECORD +41 -0
  29. aws_annoying-0.4.0.dist-info/RECORD +0 -30
  30. {aws_annoying-0.4.0.dist-info → aws_annoying-0.6.0.dist-info}/WHEEL +0 -0
  31. {aws_annoying-0.4.0.dist-info → aws_annoying-0.6.0.dist-info}/entry_points.txt +0 -0
  32. {aws_annoying-0.4.0.dist-info → aws_annoying-0.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,19 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import os
4
- import re
5
5
  import signal
6
+ import subprocess
6
7
  from pathlib import Path # noqa: TC003
7
8
 
8
- import boto3
9
9
  import typer
10
- from rich import print # noqa: A004
11
10
 
12
- from aws_annoying.utils.downloader import TQDMDownloader
11
+ from aws_annoying.utils.ec2 import get_instance_id_by_name
13
12
 
14
13
  from ._app import session_manager_app
15
14
  from ._common import SessionManager
16
15
 
16
+ logger = logging.getLogger(__name__)
17
+
17
18
 
18
19
  # https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html
19
20
  @session_manager_app.command()
@@ -57,44 +58,39 @@ def port_forward( # noqa: PLR0913
57
58
  ),
58
59
  ) -> None:
59
60
  """Start a port forwarding session using AWS Session Manager."""
60
- session_manager = SessionManager(downloader=TQDMDownloader())
61
+ session_manager = SessionManager()
61
62
 
62
63
  # Check if the PID file already exists
63
64
  if pid_file.exists():
64
65
  if not terminate_running_process:
65
- print("🚫 PID file already exists.")
66
+ logger.error("PID file already exists.")
66
67
  raise typer.Exit(1)
67
68
 
68
69
  pid_content = pid_file.read_text()
69
70
  try:
70
71
  existing_pid = int(pid_content)
71
72
  except ValueError:
72
- print(f"🚫 PID file content is invalid; expected integer, but got: {type(pid_content)}")
73
+ logger.error("PID file content is invalid; expected integer, but got: %r", type(pid_content)) # noqa: TRY400
73
74
  raise typer.Exit(1) from None
74
75
 
75
76
  try:
76
- print(f"⚠️ Terminating running process with PID {existing_pid}.")
77
+ logger.warning("Terminating running process with PID %d.", existing_pid)
77
78
  os.kill(existing_pid, signal.SIGTERM)
78
79
  pid_file.write_text("") # Clear the PID file
79
80
  except ProcessLookupError:
80
- print(f"⚠️ Tried to terminate process with PID {existing_pid} but does not exist.")
81
+ logger.warning("Tried to terminate process with PID %d but does not exist.", existing_pid)
81
82
 
82
83
  # Resolve the instance name or ID
83
- if re.match(r"m?i-.+", through):
84
- target = through
85
- else:
86
- # If the instance name is provided, get the instance ID
87
- instance_id = _get_instance_id_by_name(through)
88
- if instance_id:
89
- print(f"❗ Instance ID resolved: [bold]{instance_id}[/bold]")
90
- else:
91
- print(f"🚫 Instance with name '{through}' not found.")
92
- raise typer.Exit(1)
93
-
84
+ instance_id = get_instance_id_by_name(through)
85
+ if instance_id:
86
+ logger.info("Instance ID resolved: [bold]%s[/bold]", instance_id)
94
87
  target = instance_id
88
+ else:
89
+ logger.info("Instance with name '%s' not found.", through)
90
+ raise typer.Exit(1)
95
91
 
96
92
  # Initiate the session
97
- proc = session_manager.start(
93
+ command = session_manager.build_command(
98
94
  target=target,
99
95
  document_name="AWS-StartPortForwardingSessionToRemoteHost",
100
96
  parameters={
@@ -104,23 +100,30 @@ def port_forward( # noqa: PLR0913
104
100
  },
105
101
  reason=reason,
106
102
  )
107
- print(f"✅ Session Manager Plugin started with PID {proc.pid}. Outputs will be logged to {log_file.absolute()}.")
103
+ stdout: subprocess._FILE
104
+ if log_file is not None: # noqa: SIM108
105
+ stdout = log_file.open(mode="at+", buffering=1)
106
+ else:
107
+ stdout = subprocess.DEVNULL
108
+
109
+ logger.info(
110
+ "Starting port forwarding session through [bold]%s[/bold] with reason: [italic]%r[/italic].",
111
+ through,
112
+ reason,
113
+ )
114
+ proc = subprocess.Popen( # noqa: S603
115
+ command,
116
+ stdout=stdout,
117
+ stderr=subprocess.STDOUT,
118
+ text=True,
119
+ close_fds=False, # FD inherited from parent process
120
+ )
121
+ logger.info(
122
+ "Session Manager Plugin started with PID %d. Outputs will be logged to %s.",
123
+ proc.pid,
124
+ log_file.absolute(),
125
+ )
108
126
 
109
127
  # Write the PID to the file
110
128
  pid_file.write_text(str(proc.pid))
111
- print(f"💾 PID file written to {pid_file.absolute()}.")
112
-
113
-
114
- def _get_instance_id_by_name(name: str) -> str | None:
115
- """Get the EC2 instance ID by name."""
116
- ec2 = boto3.client("ec2")
117
- response = ec2.describe_instances(Filters=[{"Name": "tag:Name", "Values": [name]}])
118
- reservations = response["Reservations"]
119
- if not reservations:
120
- return None
121
-
122
- instances = reservations[0]["Instances"]
123
- if not instances:
124
- return None
125
-
126
- return str(instances[0]["InstanceId"])
129
+ logger.info("PID file written to %s.", pid_file.absolute())
@@ -1,9 +1,55 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
4
+ import os
5
+
6
+ import typer
7
+
8
+ from aws_annoying.utils.ec2 import get_instance_id_by_name
9
+
3
10
  from ._app import session_manager_app
11
+ from ._common import SessionManager
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # TODO(lasuillard): ECS support (#24)
16
+ # TODO(lasuillard): Interactive instance selection
4
17
 
5
18
 
6
19
  @session_manager_app.command()
7
- def start() -> None:
20
+ def start(
21
+ target: str = typer.Option(
22
+ ...,
23
+ show_default=False,
24
+ help="The name or ID of the EC2 instance to connect to.",
25
+ ),
26
+ reason: str = typer.Option(
27
+ "",
28
+ help="The reason for starting the session.",
29
+ ),
30
+ ) -> None:
8
31
  """Start new session."""
9
- # TODO(lasuillard): To be implemented (maybe in #24?)
32
+ session_manager = SessionManager()
33
+
34
+ # Resolve the instance name or ID
35
+ instance_id = get_instance_id_by_name(target)
36
+ if instance_id:
37
+ logger.info("Instance ID resolved: [bold]%s[/bold]", instance_id)
38
+ target = instance_id
39
+ else:
40
+ logger.info("Instance with name '%s' not found.", target)
41
+ raise typer.Exit(1)
42
+
43
+ # Start the session, replacing the current process
44
+ logger.info(
45
+ "Starting session to target [bold]%s[/bold] with reason: [italic]%r[/italic].",
46
+ target,
47
+ reason,
48
+ )
49
+ command = session_manager.build_command(
50
+ target=target,
51
+ document_name="SSM-SessionManagerRunShell",
52
+ parameters={},
53
+ reason=reason,
54
+ )
55
+ os.execvp(command[0], command) # noqa: S606
@@ -1,14 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import os
4
5
  import signal
5
6
  from pathlib import Path # noqa: TC003
6
7
 
7
8
  import typer
8
- from rich import print # noqa: A004
9
9
 
10
10
  from ._app import session_manager_app
11
11
 
12
+ logger = logging.getLogger(__name__)
13
+
12
14
 
13
15
  @session_manager_app.command()
14
16
  def stop(
@@ -24,7 +26,7 @@ def stop(
24
26
  """Stop running session for PID file."""
25
27
  # Check if PID file exists
26
28
  if not pid_file.is_file():
27
- print(f"PID file not found: {pid_file}")
29
+ logger.error("PID file not found: %s", pid_file)
28
30
  raise typer.Exit(1)
29
31
 
30
32
  # Read PID from file
@@ -32,19 +34,19 @@ def stop(
32
34
  try:
33
35
  pid = int(pid_content)
34
36
  except ValueError:
35
- print(f"🚫 PID file content is invalid; expected integer, but got: {type(pid_content)}")
37
+ logger.error("PID file content is invalid; expected integer, but got: %s", type(pid_content)) # noqa: TRY400
36
38
  raise typer.Exit(1) from None
37
39
 
38
40
  # Send SIGTERM to the process
39
41
  try:
40
- print(f"⚠️ Terminating running process with PID {pid}.")
42
+ logger.warning("Terminating running process with PID %d.", pid)
41
43
  os.kill(pid, signal.SIGTERM)
42
44
  except ProcessLookupError:
43
- print(f"Tried to terminate process with PID {pid} but does not exist.")
45
+ logger.warning("Tried to terminate process with PID %d but does not exist.", pid)
44
46
 
45
47
  # Remove the PID file
46
48
  if remove:
47
- print(f"Removed the PID file {pid_file}.")
49
+ logger.info("Removed the PID file %s.", pid_file)
48
50
  pid_file.unlink()
49
51
 
50
- print("Terminated the session successfully.")
52
+ logger.info("Terminated the session successfully.")
@@ -0,0 +1,17 @@
1
+ from .common import ECSServiceRef
2
+ from .deployment_waiter import ECSDeploymentWaiter
3
+ from .errors import (
4
+ DeploymentFailedError,
5
+ NoRunningDeploymentError,
6
+ ServiceTaskDefinitionAssertionError,
7
+ WaitForDeploymentError,
8
+ )
9
+
10
+ __all__ = (
11
+ "DeploymentFailedError",
12
+ "ECSDeploymentWaiter",
13
+ "ECSServiceRef",
14
+ "NoRunningDeploymentError",
15
+ "ServiceTaskDefinitionAssertionError",
16
+ "WaitForDeploymentError",
17
+ )
@@ -0,0 +1,8 @@
1
+ from typing import NamedTuple
2
+
3
+
4
+ class ECSServiceRef(NamedTuple):
5
+ """Reference to an ECS service."""
6
+
7
+ cluster: str
8
+ service: str
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from operator import itemgetter
5
+ from time import sleep
6
+ from typing import TYPE_CHECKING, Optional
7
+
8
+ import boto3
9
+ import botocore.exceptions
10
+ from pydantic import PositiveInt, validate_call
11
+
12
+ from .errors import DeploymentFailedError, NoRunningDeploymentError, ServiceTaskDefinitionAssertionError
13
+
14
+ if TYPE_CHECKING:
15
+ from .common import ECSServiceRef
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class ECSDeploymentWaiter:
21
+ """ECS service deployment waiter."""
22
+
23
+ def __init__(self, service_ref: ECSServiceRef, *, session: boto3.session.Session | None = None) -> None:
24
+ """Initialize instance.
25
+
26
+ Args:
27
+ service_ref: Reference to the ECS service.
28
+ session: Boto3 session to use for AWS operations.
29
+
30
+ """
31
+ self.service_ref = service_ref
32
+ self.session = session or boto3.session.Session()
33
+
34
+ @validate_call
35
+ def wait(
36
+ self,
37
+ *,
38
+ wait_for_start: bool,
39
+ polling_interval: PositiveInt = 5,
40
+ wait_for_stability: bool,
41
+ expected_task_definition: Optional[str] = None,
42
+ ) -> None:
43
+ """Wait for the ECS deployment to complete.
44
+
45
+ Args:
46
+ wait_for_start: Whether to wait for the deployment to start.
47
+ polling_interval: The interval between any polling attempts, in seconds.
48
+ wait_for_stability: Whether to wait for the service to be stable after the deployment.
49
+ expected_task_definition: The service's task definition expected after deployment.
50
+ """
51
+ # Find current deployment for the service
52
+ logger.info(
53
+ "Looking up running deployment for service %s",
54
+ self.service_ref.service,
55
+ )
56
+ latest_deployment_arn = self.get_latest_deployment_arn(
57
+ wait_for_start=wait_for_start,
58
+ polling_interval=polling_interval,
59
+ )
60
+
61
+ # Polling for the deployment to finish (successfully or unsuccessfully)
62
+ logger.info(
63
+ "Start waiting for deployment %s to finish.",
64
+ latest_deployment_arn,
65
+ )
66
+ ok, status = self.wait_for_deployment_complete(latest_deployment_arn, polling_interval=polling_interval)
67
+ if ok:
68
+ logger.info(
69
+ "Deployment succeeded with status %s",
70
+ status,
71
+ )
72
+ else:
73
+ msg = f"Deployment failed with status: {status}"
74
+ raise DeploymentFailedError(msg)
75
+
76
+ # Wait for the service to be stable
77
+ if wait_for_stability:
78
+ logger.debug(
79
+ "Start waiting for service %s to be stable.",
80
+ self.service_ref.service,
81
+ )
82
+ self.wait_for_service_stability(polling_interval=polling_interval)
83
+
84
+ # Check if the service task definition matches the expected one
85
+ if expected_task_definition:
86
+ logger.info(
87
+ "Checking if the service task definition is the expected one: %s",
88
+ expected_task_definition,
89
+ )
90
+ ok, actual = self.check_service_task_definition_is(expect=expected_task_definition)
91
+ if not ok:
92
+ msg = f"The service task definition is not the expected one; got: {actual!r}"
93
+ raise ServiceTaskDefinitionAssertionError(msg)
94
+
95
+ logger.info("The service task definition matches the expected one.")
96
+
97
+ @validate_call
98
+ def get_latest_deployment_arn(
99
+ self,
100
+ *,
101
+ wait_for_start: bool,
102
+ polling_interval: PositiveInt,
103
+ max_attempts: Optional[PositiveInt] = None,
104
+ ) -> str:
105
+ """Get the most recently started deployment ARN for the service.
106
+
107
+ Args:
108
+ wait_for_start: Whether to wait for the deployment to start.
109
+ polling_interval: The interval between any polling attempts, in seconds.
110
+ max_attempts: The maximum number of attempts to wait for the deployment to start.
111
+
112
+ Raises:
113
+ NoRunningDeploymentError: If no running deployments are found and `wait_for_start` is False.
114
+
115
+ Returns:
116
+ The ARN of the latest deployment for the service.
117
+ """
118
+ ecs = self.session.client("ecs")
119
+ if wait_for_start:
120
+ logger.warning("`wait_for_start` is set, will wait for a new deployment to start.")
121
+
122
+ attempts = 0
123
+ while True: # do-while
124
+ # Do
125
+ running_deployments = ecs.list_service_deployments(
126
+ cluster=self.service_ref.cluster,
127
+ service=self.service_ref.service,
128
+ status=["PENDING", "IN_PROGRESS"],
129
+ )["serviceDeployments"]
130
+
131
+ # While
132
+ if running_deployments:
133
+ logger.debug("Found %d running deployments for service. Exiting loop.", len(running_deployments))
134
+ break
135
+
136
+ if not wait_for_start:
137
+ logger.debug("`wait_for_start` is off, no need to wait for a new deployment to start.")
138
+ break
139
+
140
+ if max_attempts and attempts >= max_attempts:
141
+ logger.debug("Max attempts exceeded while waiting for a new deployment to start.")
142
+ break
143
+
144
+ logger.debug(
145
+ "(%d-th attempt) No running deployments found for service. Start waiting for a new deployment.",
146
+ attempts + 1,
147
+ )
148
+
149
+ sleep(polling_interval)
150
+ attempts += 1
151
+
152
+ if not running_deployments:
153
+ msg = "No running deployments found for service."
154
+ raise NoRunningDeploymentError(msg)
155
+
156
+ latest_deployment = sorted(running_deployments, key=itemgetter("startedAt"))[-1]
157
+ if len(running_deployments) > 1:
158
+ logger.warning(
159
+ "%d running deployments found for service. Using most recently started deployment: %s",
160
+ len(running_deployments),
161
+ latest_deployment["serviceDeploymentArn"],
162
+ )
163
+
164
+ return latest_deployment["serviceDeploymentArn"]
165
+
166
+ @validate_call
167
+ def wait_for_deployment_complete(
168
+ self,
169
+ deployment_arn: str,
170
+ *,
171
+ polling_interval: PositiveInt,
172
+ max_attempts: Optional[PositiveInt] = None,
173
+ ) -> tuple[bool, str]:
174
+ """Wait for the ECS deployment to complete.
175
+
176
+ Args:
177
+ deployment_arn: The ARN of the deployment to wait for.
178
+ polling_interval: The interval between any polling attempts, in seconds.
179
+ max_attempts: The maximum number of attempts to wait for the deployment to complete.
180
+
181
+ Returns:
182
+ A tuple containing a boolean indicating whether the deployment succeeded and the status of the deployment.
183
+ """
184
+ ecs = self.session.client("ecs")
185
+
186
+ attempts = 0
187
+ while (max_attempts is None) or (attempts <= max_attempts):
188
+ latest_deployment = ecs.describe_service_deployments(serviceDeploymentArns=[deployment_arn])[
189
+ "serviceDeployments"
190
+ ][0]
191
+ status = latest_deployment["status"]
192
+ if status == "SUCCESSFUL":
193
+ return (True, status)
194
+
195
+ if status in ("PENDING", "IN_PROGRESS"):
196
+ logger.debug(
197
+ "(%d-th attempt) Deployment in progress... (%s)",
198
+ attempts + 1,
199
+ status,
200
+ )
201
+ else:
202
+ break
203
+
204
+ sleep(polling_interval)
205
+ attempts += 1
206
+
207
+ return (False, status)
208
+
209
+ @validate_call
210
+ def wait_for_service_stability(
211
+ self,
212
+ *,
213
+ polling_interval: PositiveInt,
214
+ max_attempts: Optional[PositiveInt] = None,
215
+ ) -> bool:
216
+ """Wait for the ECS service to be stable.
217
+
218
+ Args:
219
+ polling_interval: The interval between any polling attempts, in seconds.
220
+ max_attempts: The maximum number of attempts to wait for the service to be stable.
221
+
222
+ Returns:
223
+ A boolean indicating whether the service is stable.
224
+ """
225
+ ecs = self.session.client("ecs")
226
+
227
+ # TODO(lasuillard): Likely to be a problem in some cases: https://github.com/boto/botocore/issues/3314
228
+ stability_waiter = ecs.get_waiter("services_stable")
229
+
230
+ attempts = 0
231
+ while (max_attempts is None) or (attempts <= max_attempts):
232
+ logger.debug(
233
+ "(%d-th attempt) Waiting for service %s to be stable...",
234
+ attempts + 1,
235
+ self.service_ref.service,
236
+ )
237
+ try:
238
+ stability_waiter.wait(
239
+ cluster=self.service_ref.cluster,
240
+ services=[self.service_ref.service],
241
+ WaiterConfig={"Delay": polling_interval, "MaxAttempts": 1},
242
+ )
243
+ except botocore.exceptions.WaiterError as err:
244
+ if err.kwargs["reason"] != "Max attempts exceeded":
245
+ raise
246
+ else:
247
+ return True
248
+
249
+ sleep(polling_interval)
250
+ attempts += 1
251
+
252
+ return False
253
+
254
+ @validate_call
255
+ def check_service_task_definition_is(self, expect: str) -> tuple[bool, str]:
256
+ """Check the service's current task definition matches the expected one.
257
+
258
+ Args:
259
+ expect: The ARN of expected task definition.
260
+
261
+ Returns:
262
+ A tuple containing a boolean indicating whether the task definition matches the expected one
263
+ and the current task definition ARN.
264
+ """
265
+ ecs = self.session.client("ecs")
266
+
267
+ service_detail = ecs.describe_services(cluster=self.service_ref.cluster, services=[self.service_ref.service])[
268
+ "services"
269
+ ][0]
270
+ current_task_definition_arn = service_detail["taskDefinition"]
271
+ if current_task_definition_arn != expect:
272
+ return (False, current_task_definition_arn)
273
+
274
+ return (True, current_task_definition_arn)
@@ -0,0 +1,14 @@
1
+ class WaitForDeploymentError(Exception):
2
+ """Base class for all deployment waiter errors."""
3
+
4
+
5
+ class NoRunningDeploymentError(WaitForDeploymentError):
6
+ """No running deployment found for the service."""
7
+
8
+
9
+ class DeploymentFailedError(WaitForDeploymentError):
10
+ """Deployment failed."""
11
+
12
+
13
+ class ServiceTaskDefinitionAssertionError(WaitForDeploymentError):
14
+ """Service task definition does not match the expected one."""
@@ -1,13 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import configparser
4
+ import logging
4
5
  from pathlib import Path # noqa: TC003
5
6
  from typing import Optional
6
7
 
7
8
  from pydantic import BaseModel, ConfigDict
8
9
 
9
- # TODO(lasuillard): Need some refactoring (configurator class)
10
- # TODO(lasuillard): Put some logging
10
+ logger = logging.getLogger(__name__)
11
11
 
12
12
 
13
13
  class MfaConfig(BaseModel):
@@ -30,9 +30,12 @@ class MfaConfig(BaseModel):
30
30
  with path.open("w") as f:
31
31
  config_ini.write(f)
32
32
 
33
+ logger.debug("Saved config to %s with section %s", path, section_key)
34
+
33
35
  @classmethod
34
36
  def from_ini_file(cls, path: Path, section_key: str) -> tuple[MfaConfig, bool]:
35
37
  """Load configuration from an AWS config file, with boolean indicating if the config already exists."""
38
+ logger.debug("Loading config from %s with section %s", path, section_key)
36
39
  config_ini = configparser.ConfigParser()
37
40
  config_ini.read(path)
38
41
  if config_ini.has_section(section_key):
@@ -52,3 +55,5 @@ def update_credentials(path: Path, profile: str, *, access_key: str, secret_key:
52
55
  credentials_ini[profile]["aws_session_token"] = session_token
53
56
  with path.open("w") as f:
54
57
  credentials_ini.write(f)
58
+
59
+ logger.debug("Updated credentials file %s with profile %s", path, profile)
@@ -1,4 +1,11 @@
1
1
  from .errors import PluginNotInstalledError, SessionManagerError, UnsupportedPlatformError
2
2
  from .session_manager import SessionManager
3
+ from .shortcuts import port_forward
3
4
 
4
- __all__ = ("PluginNotInstalledError", "SessionManager", "SessionManagerError", "UnsupportedPlatformError")
5
+ __all__ = (
6
+ "PluginNotInstalledError",
7
+ "SessionManager",
8
+ "SessionManagerError",
9
+ "UnsupportedPlatformError",
10
+ "port_forward",
11
+ )