Skip to content

Commit 08697fa

Browse files
committed
Make LoginManager close its clients on close()
Now that clients provide a `close()` method, the LoginManager can use it to implement a better closing semantic. When a login manager creates a client object, the client is tracked in a set. When the login manager is closed, all its clients are closed and the set is cleared.
1 parent bb591ff commit 08697fa

File tree

3 files changed

+75
-12
lines changed

3 files changed

+75
-12
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
### Bugfixes
2+
3+
* Improved internal resource cleanup for network connections.

src/globus_cli/login_manager/manager.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,26 @@
4848
P = ParamSpec("P")
4949
R = t.TypeVar("R")
5050

51+
# string-ized annotation to avoid triggering eager imports
52+
CLIENT_T = t.TypeVar("CLIENT_T", bound="globus_sdk.BaseClient")
53+
5154

5255
class LoginManager:
5356
def __init__(self) -> None:
5457
self.storage = CLIStorage()
5558
self._nonstatic_requirements: dict[str, list[Scope]] = {}
5659

60+
self._client_pool: set[globus_sdk.BaseClient] = set()
61+
5762
def close(self) -> None:
5863
self.storage.close()
64+
for c in self._client_pool:
65+
c.close()
66+
self._client_pool.clear()
67+
68+
def _into_client_pool(self, client: CLIENT_T) -> CLIENT_T:
69+
self._client_pool.add(client)
70+
return client
5971

6072
def add_requirement(self, rs_name: str, scopes: t.Sequence[Scope]) -> None:
6173
self._nonstatic_requirements[rs_name] = list(scopes)
@@ -373,25 +385,35 @@ def get_transfer_client(self) -> CustomTransferClient:
373385
from ..services.transfer import CustomTransferClient
374386

375387
authorizer = self._get_client_authorizer(TransferScopes.resource_server)
376-
return CustomTransferClient(authorizer=authorizer, app_name=version.app_name)
388+
return self._into_client_pool(
389+
CustomTransferClient(authorizer=authorizer, app_name=version.app_name)
390+
)
377391

378392
def get_auth_client(self) -> CustomAuthClient:
379393
from ..services.auth import CustomAuthClient
380394

381395
authorizer = self._get_client_authorizer(AuthScopes.resource_server)
382-
return CustomAuthClient(authorizer=authorizer, app_name=version.app_name)
396+
return self._into_client_pool(
397+
CustomAuthClient(authorizer=authorizer, app_name=version.app_name)
398+
)
383399

384400
def get_groups_client(self) -> globus_sdk.GroupsClient:
385401
authorizer = self._get_client_authorizer(GroupsScopes.resource_server)
386-
return globus_sdk.GroupsClient(authorizer=authorizer, app_name=version.app_name)
402+
return self._into_client_pool(
403+
globus_sdk.GroupsClient(authorizer=authorizer, app_name=version.app_name)
404+
)
387405

388406
def get_flows_client(self) -> globus_sdk.FlowsClient:
389407
authorizer = self._get_client_authorizer(FlowsScopes.resource_server)
390-
return globus_sdk.FlowsClient(authorizer=authorizer, app_name=version.app_name)
408+
return self._into_client_pool(
409+
globus_sdk.FlowsClient(authorizer=authorizer, app_name=version.app_name)
410+
)
391411

392412
def get_search_client(self) -> globus_sdk.SearchClient:
393413
authorizer = self._get_client_authorizer(SearchScopes.resource_server)
394-
return globus_sdk.SearchClient(authorizer=authorizer, app_name=version.app_name)
414+
return self._into_client_pool(
415+
globus_sdk.SearchClient(authorizer=authorizer, app_name=version.app_name)
416+
)
395417

396418
def get_timer_client(
397419
self, *, flow_id: uuid.UUID | None = None
@@ -404,7 +426,9 @@ def get_timer_client(
404426
self._assert_requester_has_timer_flow_consent(flow_id)
405427

406428
authorizer = self._get_client_authorizer(TimersScopes.resource_server)
407-
return globus_sdk.TimersClient(authorizer=authorizer, app_name=version.app_name)
429+
return self._into_client_pool(
430+
globus_sdk.TimersClient(authorizer=authorizer, app_name=version.app_name)
431+
)
408432

409433
def _assert_requester_has_timer_flow_consent(self, flow_id: uuid.UUID) -> None:
410434
flow_scope = SpecificFlowScopes(flow_id).user
@@ -445,7 +469,9 @@ def get_specific_flow_client(
445469
) -> globus_sdk.SpecificFlowClient:
446470
# Create a SpecificFlowClient without an authorizer
447471
# to take advantage of its scope creation code.
448-
client = globus_sdk.SpecificFlowClient(flow_id, app_name=version.app_name)
472+
client = self._into_client_pool(
473+
globus_sdk.SpecificFlowClient(flow_id, app_name=version.app_name)
474+
)
449475
assert client.scopes is not None
450476
self.add_requirement(client.scopes.resource_server, [client.scopes.user])
451477

@@ -531,11 +557,13 @@ def get_gcs_client(
531557
f"Please run:\n\n {login_context.login_command}\n"
532558
),
533559
)
534-
return CustomGCSClient(
535-
epish.get_gcs_address(),
536-
source_epish=epish,
537-
authorizer=authorizer,
538-
app_name=version.app_name,
560+
return self._into_client_pool(
561+
CustomGCSClient(
562+
epish.get_gcs_address(),
563+
source_epish=epish,
564+
authorizer=authorizer,
565+
app_name=version.app_name,
566+
)
539567
)
540568

541569
def get_current_identity_id(self) -> str:

tests/unit/test_login_manager.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import uuid
55
from unittest import mock
66

7+
import click
78
import globus_sdk
89
import globus_sdk.scopes
910
import jwt
@@ -428,3 +429,34 @@ def test_immature_signature_during_jwt_decode_skips_notice_if_date_cannot_parse(
428429

429430
stderr = capsys.readouterr().err
430431
assert "This may indicate a clock skew problem." not in stderr
432+
433+
434+
def test_login_manager_closes_created_clients_after_context_exit(test_token_storage):
435+
from globus_cli.commands import main
436+
437+
def add_close_spy(client):
438+
real_close = client.close
439+
spy = mock.Mock(side_effect=real_close)
440+
client.close = spy
441+
return spy
442+
443+
with click.Context(main):
444+
spy1, spy2 = None, None
445+
446+
@LoginManager.requires_login("auth", "transfer")
447+
def dummy_command(login_manager):
448+
transfer_client = login_manager.get_transfer_client()
449+
auth_client = login_manager.get_auth_client()
450+
451+
nonlocal spy1, spy2
452+
spy1 = add_close_spy(transfer_client)
453+
spy2 = add_close_spy(auth_client)
454+
return True
455+
456+
assert dummy_command()
457+
458+
spy1.assert_not_called()
459+
spy2.assert_not_called()
460+
461+
spy1.assert_called_once()
462+
spy2.assert_called_once()

0 commit comments

Comments
 (0)