Skip to content

Commit 3d3d8df

Browse files
committed
Improve structure of resource closure in GlobusApp
In order to accurately track resources and `close()` when appropriate, a new list tracks the resources to close. The client is adapted to this same pattern for consistency. Both objects log debug level messages when closing to indicate what is being closed. Some indirection is still needed so that the creation flow for `GlobusApp._token_storage` can determine whether or not `GlobusApp.token_storage` will be closed.
1 parent e9fb543 commit 3d3d8df

File tree

6 files changed

+89
-16
lines changed

6 files changed

+89
-16
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fixed
2+
-----
3+
4+
- Fixed a resource leak in which a ``GlobusApp`` would create internal client
5+
objects and never close the associated transports. (:pr:`NUMBER`)

src/globus_sdk/_internal/type_definitions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,7 @@ def text(self) -> str: ...
2626

2727
@property
2828
def binary_content(self) -> bytes: ...
29+
30+
31+
class Closable(t.Protocol):
32+
def close(self) -> None: ...

src/globus_sdk/client.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from globus_sdk import GlobusSDKUsageError, config, exc
1010
from globus_sdk._internal.classprop import classproperty
11+
from globus_sdk._internal.type_definitions import Closable
1112
from globus_sdk._internal.utils import slash_join
1213
from globus_sdk.authorizers import GlobusAuthorizer
1314
from globus_sdk.paging import PaginatorTable
@@ -99,6 +100,8 @@ def __init__(
99100
f"A {type(self).__name__} cannot use both an 'app' and an 'authorizer'."
100101
)
101102

103+
self._resources_to_close: list[Closable] = []
104+
102105
# Determine the client's environment
103106
# Either the provided kwarg or derived from the app used
104107
#
@@ -121,10 +124,9 @@ def __init__(
121124
# if and only if the client creates the Transport
122125
if transport is not None:
123126
self.transport = transport
124-
self._owns_transport = False
125127
else:
126128
self.transport = RequestsTransport()
127-
self._owns_transport = True
129+
self._resources_to_close.append(self.transport)
128130

129131
log.debug(f"initialized transport of type {type(self.transport)}")
130132

@@ -352,9 +354,12 @@ def close(self) -> None:
352354
This only closes transports which are created implicitly via client init.
353355
Externally constructed transports will not be closed.
354356
"""
355-
if self._owns_transport:
356-
log.debug(f"closing transport for {type(self).__name__}")
357-
self.transport.close()
357+
for resource in self._resources_to_close:
358+
log.debug(
359+
f"closing resource of type {type(resource).__name__} "
360+
f"for {type(self).__name__}"
361+
)
362+
resource.close()
358363

359364
# clients can act as context managers, and such usage calls close()
360365
def __enter__(self) -> Self:

src/globus_sdk/globus_app/app.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
GlobusSDKUsageError,
1616
IDTokenDecoder,
1717
)
18+
from globus_sdk._internal.type_definitions import Closable
1819
from globus_sdk.authorizers import GlobusAuthorizer
1920
from globus_sdk.gare import GlobusAuthorizationParameters
2021
from globus_sdk.scopes import AuthScopes, Scope, ScopeParser
@@ -67,6 +68,7 @@ class GlobusApp(metaclass=abc.ABCMeta):
6768
# this allows code during init call into otherwise unsafe codepaths in the app,
6869
# namely those which manipulate scope requirements
6970
_authorizer_factory: AuthorizerFactory[GlobusAuthorizer]
71+
_token_storage: TokenStorage
7072
token_storage: ValidatingTokenStorage
7173

7274
def __init__(
@@ -84,6 +86,7 @@ def __init__(
8486
self.app_name = app_name
8587
self.config = config
8688
self._token_validation_error_handling_enabled = True
89+
self._resources_to_close: list[Closable] = []
8790

8891
self.client_id, self._login_client = self._resolve_client_info(
8992
app_name=self.app_name,
@@ -96,27 +99,29 @@ def __init__(
9699

97100
# create the inner token storage object, and pick up on whether or not this
98101
# call handled creation or got a value from the outside
99-
# if creation was done here, then this app "owns" the storage
100-
inner_token_storage, token_storage_created_here = self._resolve_token_storage(
102+
self._token_storage, token_storage_created_here = self._resolve_token_storage(
101103
app_name=self.app_name,
102104
client_id=self.client_id,
103105
config=self.config,
104106
)
105-
self._token_storage = inner_token_storage
106-
self._owns_token_storage = token_storage_created_here
107107

108108
# create a consent client for token validation
109109
# this client won't be ready for immediate use, but will have the app attached
110110
# at the end of init
111111
consent_client = AuthClient(environment=config.environment)
112+
self._resources_to_close.append(consent_client)
112113

113114
# create the requisite token storage for the app, with validation based on
114115
# the provided parameters
116+
# if the token storage was created by the app, the validating wrapper is added
117+
# to the resources to close when closed
115118
self.token_storage = self._initialize_validating_token_storage(
116119
token_storage=self._token_storage,
117120
consent_client=consent_client,
118121
scope_requirements=self._scope_requirements,
119122
)
123+
if token_storage_created_here:
124+
self._resources_to_close.append(self.token_storage)
120125

121126
# setup an ID Token Decoder based on config; build one if it was not provided
122127
self._id_token_decoder = self._initialize_id_token_decoder(
@@ -168,6 +173,9 @@ def _resolve_client_info(
168173
abstract method: _initialize_login_client``.
169174
2. Extract the client_id from a supplied login_client.
170175
176+
If a new client is created here, it is also added to the set of resources to
177+
close when this app is closed.
178+
171179
:returns: tuple of client_id and login_client
172180
:raises: GlobusSDKUsageError if a single client ID or login client could not be
173181
definitively resolved.
@@ -195,6 +203,7 @@ def _resolve_client_info(
195203
login_client = self._initialize_login_client(
196204
app_name, config, client_id, client_secret
197205
)
206+
self._resources_to_close.append(login_client)
198207
return client_id, login_client
199208

200209
else:
@@ -389,11 +398,12 @@ def close(self) -> None:
389398
Close all resources currently held by the app.
390399
This does not trigger a logout.
391400
"""
392-
# if the app owns the token storage (meaning it was created by this app on init)
393-
# then it will close the storage
394-
if self._owns_token_storage:
395-
log.debug("closing app associated token storage")
396-
self.token_storage.close()
401+
for resource in self._resources_to_close:
402+
log.debug(
403+
f"closing resource of type {type(resource).__name__} "
404+
f"for {type(self).__name__}(app_name={self.app_name!r})"
405+
)
406+
resource.close()
397407

398408
# apps can act as context managers, and such usage calls close()
399409
def __enter__(self) -> Self:

tests/unit/globus_app/test_globus_app.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import logging
34
import os
45
import time
56
from unittest import mock
@@ -606,13 +607,29 @@ def run_login_flow(
606607
return super().run_login_flow(auth_parameters)
607608

608609

609-
def test_closing_app_closes_implicitly_created_token_storage():
610-
user_app = UserApp("test-app", client_id="mock_client_id")
610+
@pytest.mark.parametrize("config", (None, GlobusAppConfig(token_storage="memory")))
611+
def test_closing_app_closes_implicitly_created_token_storage(config):
612+
if config is None:
613+
user_app = UserApp("test-app", client_id="mock_client_id")
614+
else:
615+
user_app = UserApp("test-app", client_id="mock_client_id", config=config)
611616

612617
with mock.patch.object(user_app.token_storage, "close") as token_storage_close:
613618
user_app.close()
614619
token_storage_close.assert_called_once()
615620

621+
user_app.token_storage.close() # cleanup
622+
623+
624+
def test_closing_app_closes_implicitly_created_login_client():
625+
user_app = UserApp("test-app", client_id="mock_client_id")
626+
627+
with mock.patch.object(user_app._login_client, "close") as login_client_close:
628+
user_app.close()
629+
login_client_close.assert_called_once()
630+
631+
user_app._login_client.close() # cleanup
632+
616633

617634
def test_closing_app_does_not_close_explicitly_passed_token_storage():
618635
user_app = UserApp(
@@ -631,3 +648,25 @@ def test_app_context_manager_exit_calls_close():
631648
with UserApp("test-app", client_id="mock_client_id"):
632649
app_close_method.assert_not_called()
633650
app_close_method.assert_called_once()
651+
652+
653+
def test_app_close_debug_logs_closure_of_resources(caplog):
654+
"""Debug logs show both of the internal clients, plus the token storage"""
655+
caplog.set_level(logging.DEBUG)
656+
657+
user_app = UserApp("test-app", client_id="mock_client_id")
658+
user_app.close()
659+
660+
# 3 resources at least: consent_client, login_client, token_storage
661+
assert (
662+
"closing resource of type AuthClient for UserApp(app_name='test-app')"
663+
in caplog.text
664+
)
665+
assert (
666+
"closing resource of type NativeAppAuthClient for "
667+
"UserApp(app_name='test-app')"
668+
) in caplog.text
669+
assert (
670+
"closing resource of type ValidatingTokenStorage for "
671+
"UserApp(app_name='test-app')"
672+
) in caplog.text

tests/unit/test_base_client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import logging
23
import os
34
import uuid
45
from unittest import mock
@@ -367,6 +368,15 @@ def test_client_close_implicitly_closes_internal_transport(base_client_class):
367368
transport_close.assert_called_once()
368369

369370

371+
def test_client_close_debug_logs_internal_transport_close(base_client_class, caplog):
372+
caplog.set_level(logging.DEBUG)
373+
374+
client = base_client_class()
375+
client.close()
376+
377+
assert "closing resource of type RequestsTransport for CustomClient" in caplog.text
378+
379+
370380
def test_client_close_does_not_close_explicitly_passed_transport(base_client_class):
371381
# test the private _close() method directly
372382
client = base_client_class(transport=RequestsTransport())

0 commit comments

Comments
 (0)