Skip to content

Commit 6c493bd

Browse files
committed
fix(client): add close() and context manager to prevent event loop leak
Event loops created internally were never closed, causing ResourceWarning in long-running applications. The client now tracks ownership and only closes loops it created. Tests updated to use context managers for proper cleanup.
1 parent 11bbfa8 commit 6c493bd

2 files changed

Lines changed: 61 additions & 40 deletions

File tree

kanboard.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,25 @@ def __init__(
9898

9999
if loop:
100100
self._event_loop = loop
101+
self._owns_event_loop = False
101102
else:
102103
try:
103104
self._event_loop = asyncio.get_event_loop()
105+
self._owns_event_loop = False
104106
except RuntimeError:
105107
self._event_loop = asyncio.new_event_loop()
108+
self._owns_event_loop = True
109+
110+
def __enter__(self) -> "Client":
111+
return self
112+
113+
def __exit__(self, *args: Any) -> None:
114+
self.close()
115+
116+
def close(self) -> None:
117+
"""Close the event loop if it was created by this client."""
118+
if self._owns_event_loop and not self._event_loop.is_closed():
119+
self._event_loop.close()
106120

107121
def __getattr__(self, name: str) -> Callable[..., Any]:
108122
if self.is_async_method_name(name):

tests/test_kanboard.py

Lines changed: 47 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@
2121
# THE SOFTWARE.
2222

2323
import asyncio
24-
import types
2524
import unittest
26-
import warnings
2725
from unittest import mock
2826

2927
import kanboard
@@ -35,13 +33,8 @@ def setUp(self):
3533
self.client = kanboard.Client(self.url, "username", "password")
3634
self.request, self.urlopen = self._create_mocks()
3735

38-
def ignore_warnings(test_func):
39-
def do_test(self, *args, **kwargs):
40-
with warnings.catch_warnings():
41-
warnings.simplefilter("ignore")
42-
test_func(self, *args, **kwargs)
43-
44-
return do_test
36+
def tearDown(self):
37+
self.client.close()
4538

4639
def test_api_call(self):
4740
body = b'{"jsonrpc": "2.0", "result": true, "id": 123}'
@@ -106,13 +99,6 @@ def test_method_name_extracted_from_async_name(self):
10699
result = self.client.get_funcname_from_async_name(async_method_name)
107100
self.assertEqual(expected_method_name, result)
108101

109-
# suppress a RuntimeWarning because coro is not awaited
110-
# this is done on purpose
111-
@ignore_warnings
112-
def test_async_call_generates_coro(self):
113-
method = self.client.my_method_async()
114-
self.assertIsInstance(method, types.CoroutineType)
115-
116102
def test_async_call_returns_result(self):
117103
body = b'{"jsonrpc": "2.0", "result": 42, "id": 123}'
118104
self.urlopen.return_value.read.return_value = body
@@ -125,16 +111,37 @@ def test_custom_event_loop(self):
125111
try:
126112
client = kanboard.Client(self.url, "username", "password", loop=custom_loop)
127113
self.assertIs(client._event_loop, custom_loop)
114+
self.assertFalse(client._owns_event_loop)
115+
finally:
116+
custom_loop.close()
117+
118+
def test_close_owned_event_loop(self):
119+
client = kanboard.Client(self.url, "username", "password")
120+
if client._owns_event_loop:
121+
self.assertFalse(client._event_loop.is_closed())
122+
client.close()
123+
self.assertTrue(client._event_loop.is_closed())
124+
125+
def test_close_does_not_close_external_loop(self):
126+
custom_loop = asyncio.new_event_loop()
127+
try:
128+
client = kanboard.Client(self.url, "username", "password", loop=custom_loop)
129+
client.close()
130+
self.assertFalse(custom_loop.is_closed())
128131
finally:
129132
custom_loop.close()
130133

134+
def test_context_manager(self):
135+
with kanboard.Client(self.url, "username", "password") as client:
136+
self.assertIsNotNone(client._event_loop)
137+
131138
def test_custom_user_agent(self):
132-
client = kanboard.Client(self.url, "username", "password", user_agent="CustomAgent/1.0")
133-
body = b'{"jsonrpc": "2.0", "result": true, "id": 123}'
134-
self.urlopen.return_value.read.return_value = body
135-
client.remote_procedure()
136-
_, kwargs = self.request.call_args
137-
self.assertEqual("CustomAgent/1.0", kwargs["headers"]["User-Agent"])
139+
with kanboard.Client(self.url, "username", "password", user_agent="CustomAgent/1.0") as client:
140+
body = b'{"jsonrpc": "2.0", "result": true, "id": 123}'
141+
self.urlopen.return_value.read.return_value = body
142+
client.remote_procedure()
143+
_, kwargs = self.request.call_args
144+
self.assertEqual("CustomAgent/1.0", kwargs["headers"]["User-Agent"])
138145

139146
def test_default_user_agent(self):
140147
body = b'{"jsonrpc": "2.0", "result": true, "id": 123}'
@@ -145,30 +152,30 @@ def test_default_user_agent(self):
145152

146153
@mock.patch("ssl.create_default_context")
147154
def test_insecure_disables_ssl_verification(self, mock_ssl_context):
148-
client = kanboard.Client(self.url, "username", "password", insecure=True)
149-
ctx = mock_ssl_context.return_value
150-
body = b'{"jsonrpc": "2.0", "result": true, "id": 123}'
151-
self.urlopen.return_value.read.return_value = body
152-
client.remote_procedure()
153-
self.assertFalse(ctx.check_hostname)
154-
self.assertEqual(ctx.verify_mode, __import__("ssl").CERT_NONE)
155+
with kanboard.Client(self.url, "username", "password", insecure=True) as client:
156+
ctx = mock_ssl_context.return_value
157+
body = b'{"jsonrpc": "2.0", "result": true, "id": 123}'
158+
self.urlopen.return_value.read.return_value = body
159+
client.remote_procedure()
160+
self.assertFalse(ctx.check_hostname)
161+
self.assertEqual(ctx.verify_mode, __import__("ssl").CERT_NONE)
155162

156163
@mock.patch("ssl.create_default_context")
157164
def test_ignore_hostname_verification(self, mock_ssl_context):
158-
client = kanboard.Client(self.url, "username", "password", ignore_hostname_verification=True)
159-
ctx = mock_ssl_context.return_value
160-
body = b'{"jsonrpc": "2.0", "result": true, "id": 123}'
161-
self.urlopen.return_value.read.return_value = body
162-
client.remote_procedure()
163-
self.assertFalse(ctx.check_hostname)
165+
with kanboard.Client(self.url, "username", "password", ignore_hostname_verification=True) as client:
166+
ctx = mock_ssl_context.return_value
167+
body = b'{"jsonrpc": "2.0", "result": true, "id": 123}'
168+
self.urlopen.return_value.read.return_value = body
169+
client.remote_procedure()
170+
self.assertFalse(ctx.check_hostname)
164171

165172
@mock.patch("ssl.create_default_context")
166173
def test_cafile_passed_to_ssl_context(self, mock_ssl_context):
167-
client = kanboard.Client(self.url, "username", "password", cafile="/path/to/ca.pem")
168-
body = b'{"jsonrpc": "2.0", "result": true, "id": 123}'
169-
self.urlopen.return_value.read.return_value = body
170-
client.remote_procedure()
171-
mock_ssl_context.assert_called_once_with(cafile="/path/to/ca.pem")
174+
with kanboard.Client(self.url, "username", "password", cafile="/path/to/ca.pem") as client:
175+
body = b'{"jsonrpc": "2.0", "result": true, "id": 123}'
176+
self.urlopen.return_value.read.return_value = body
177+
client.remote_procedure()
178+
mock_ssl_context.assert_called_once_with(cafile="/path/to/ca.pem")
172179

173180
@staticmethod
174181
def _create_mocks():

0 commit comments

Comments
 (0)