From 57069db149c7e469855f78a18448ed0e4a7619b6 Mon Sep 17 00:00:00 2001 From: Pablo Estevez Date: Tue, 13 Oct 2026 15:50:07 +0100 Subject: [PATCH 1/5] reuse the same request session on same instance --- ChangeLog.md | 3 +++ learnosity_sdk/request/dataapi.py | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/ChangeLog.md b/ChangeLog.md index 05a84f2..ba11091 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [unreleased] - 2025-12-11 +### Changed +- Reuse the same request session on same Data API instance + ### Added - Added Data API configuration to request signatures - Added Data API Demo to standalone demos diff --git a/learnosity_sdk/request/dataapi.py b/learnosity_sdk/request/dataapi.py index e8cf645..3a3f991 100644 --- a/learnosity_sdk/request/dataapi.py +++ b/learnosity_sdk/request/dataapi.py @@ -12,6 +12,14 @@ class DataApi(object): + def __init__(self) -> None: + self._session = requests.Session() + + def __del__(self) -> None: + session = getattr(self, '_session', None) + if session is not None: + session.close() + def _extract_consumer(self, security_packet: Dict[str, str]) -> str: """ Extract the consumer key from the security packet. @@ -92,7 +100,7 @@ def request(self, endpoint: str, security_packet: Dict[str, str], 'X-Learnosity-SDK': f'Python:{sdk_version}' } - return requests.post(endpoint, data=init.generate(), headers=headers) + return self._session.post(endpoint, data=init.generate(), headers=headers) def results_iter(self, endpoint: str, security_packet: Dict[str, str], secret: str, request_packet: Dict[str, Any] = {}, From a26482e4833d687c9590cc233080101838e9d5b3 Mon Sep 17 00:00:00 2001 From: Pablo Estevez Date: Tue, 13 Oct 2026 16:04:33 +0100 Subject: [PATCH 2/5] Added test and a close() method --- ChangeLog.md | 3 ++- learnosity_sdk/request/dataapi.py | 9 ++++--- tests/integration/test_dataapi.py | 23 ++++++++++++++++ tests/unit/test_dataapi.py | 44 +++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index ba11091..3b7782f 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -7,11 +7,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [unreleased] - 2025-12-11 ### Changed -- Reuse the same request session on same Data API instance +- Reuse the same request session on the same Data API instance ### Added - Added Data API configuration to request signatures - Added Data API Demo to standalone demos +- Added a close() public method to close the session ### Fixed - Fixed mypy errors failing lint check on ci build diff --git a/learnosity_sdk/request/dataapi.py b/learnosity_sdk/request/dataapi.py index 3a3f991..2a2b5de 100644 --- a/learnosity_sdk/request/dataapi.py +++ b/learnosity_sdk/request/dataapi.py @@ -16,9 +16,7 @@ def __init__(self) -> None: self._session = requests.Session() def __del__(self) -> None: - session = getattr(self, '_session', None) - if session is not None: - session.close() + self.close() def _extract_consumer(self, security_packet: Dict[str, str]) -> str: """ @@ -63,6 +61,11 @@ def _derive_action(self, endpoint: str, action: str) -> str: return f"{action}_{path}" + def close(self) -> None: + session = getattr(self, '_session', None) + if session is not None: + session.close() + def request(self, endpoint: str, security_packet: Dict[str, str], secret: str, request_packet:Dict[str, Any] = {}, action: str = 'get') -> Response: """ diff --git a/tests/integration/test_dataapi.py b/tests/integration/test_dataapi.py index 7693ae3..8a89278 100644 --- a/tests/integration/test_dataapi.py +++ b/tests/integration/test_dataapi.py @@ -117,6 +117,29 @@ def test_real_question_request(self) -> None: assert keys == {'py-sdk-test-2019-1', 'py-sdk-test-2019-2'} + def test_request_after_close(self) -> None: + """Verify that a client can still make requests after close() is called + + Two requests are made to warm the pooled session, close() releases it, + and a third request on the same client must still succeed because the + session lazily rebuilds its connection pool. + """ + client = DataApi() + endpoint = self.__build_base_url() + items_endpoint + + references: List[str] = cast(List[str], items_request['references']) + + first = client.request(endpoint, security, consumer_secret, items_request, action) + assert first.json()['data'][0]['reference'] in references + + second = client.request(endpoint, security, consumer_secret, items_request, action) + assert second.json()['data'][0]['reference'] in references + + client.close() + + third = client.request(endpoint, security, consumer_secret, items_request, action) + assert third.json()['data'][0]['reference'] in references + def test_question_paging(self) -> None: """Verify that paging works""" client = DataApi() diff --git a/tests/unit/test_dataapi.py b/tests/unit/test_dataapi.py index fd8127d..9a128bf 100644 --- a/tests/unit/test_dataapi.py +++ b/tests/unit/test_dataapi.py @@ -1,5 +1,7 @@ from typing import Any, Dict, cast import unittest +from unittest import mock +import requests import responses from learnosity_sdk.request import DataApi from learnosity_sdk.exceptions import DataApiException @@ -221,6 +223,48 @@ def test_derive_action_with_preview_version_multi_digit(self) -> None: assert action == 'get_/itembank/questions' + @responses.activate + def test_session_reused_across_requests(self) -> None: + """Verify that the same requests.Session is reused across requests on one instance""" + for dummy in self.dummy_responses: + responses.add(responses.POST, self.endpoint, json=dummy) + client = DataApi() + + # The instance holds a single Session... + assert isinstance(client._session, requests.Session) + session = client._session + + # ...and spying on that session's post shows every paginated request + # goes through the same object rather than a fresh connection each time. + original_post = session.post + call_count = 0 + + def counting_post(*args: Any, **kwargs: Any) -> Any: + nonlocal call_count + call_count += 1 + return original_post(*args, **kwargs) + + session.post = counting_post # type: ignore[method-assign] + + list(client.request_iter(self.endpoint, self.security, self.consumer_secret, + self.request, self.action)) + + assert call_count == 2 + assert client._session is session + + def test_close_closes_session(self) -> None: + """Verify that close() closes the underlying session""" + client = DataApi() + with mock.patch.object(client._session, 'close') as mock_close: + client.close() + mock_close.assert_called_once_with() + + def test_close_without_session_is_safe(self) -> None: + """Verify that close() does not raise if the session was never created""" + client = DataApi.__new__(DataApi) + # No __init__ ran, so _session does not exist; close() must not raise. + client.close() + @responses.activate def test_metadata_headers_in_paginated_requests(self) -> None: """Verify that metadata headers are sent in all paginated requests""" From ae6487e2c0057450d942c5bbe5a3ba784cec4d5c Mon Sep 17 00:00:00 2001 From: pablo-estevez-lrn Date: Tue, 13 Oct 2026 16:13:10 +0100 Subject: [PATCH 3/5] Add docstring to close method Add docstring to the close method for clarity. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- learnosity_sdk/request/dataapi.py | 1 + 1 file changed, 1 insertion(+) diff --git a/learnosity_sdk/request/dataapi.py b/learnosity_sdk/request/dataapi.py index 2a2b5de..7cc224d 100644 --- a/learnosity_sdk/request/dataapi.py +++ b/learnosity_sdk/request/dataapi.py @@ -62,6 +62,7 @@ def _derive_action(self, endpoint: str, action: str) -> str: return f"{action}_{path}" def close(self) -> None: + """Close pooled connections; later requests on this client remain supported.""" session = getattr(self, '_session', None) if session is not None: session.close() From f2b51172e279dc482d8a75c1b40cb0deabdffacf Mon Sep 17 00:00:00 2001 From: Pablo Estevez Date: Tue, 13 Oct 2026 17:07:07 +0100 Subject: [PATCH 4/5] lazily create session for subclasses that don't call init --- learnosity_sdk/request/dataapi.py | 4 ++++ tests/unit/test_dataapi.py | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/learnosity_sdk/request/dataapi.py b/learnosity_sdk/request/dataapi.py index 7cc224d..69ca528 100644 --- a/learnosity_sdk/request/dataapi.py +++ b/learnosity_sdk/request/dataapi.py @@ -103,6 +103,10 @@ def request(self, endpoint: str, security_packet: Dict[str, str], 'X-Learnosity-Action': derived_action, 'X-Learnosity-SDK': f'Python:{sdk_version}' } + # Lazily create the session so subclasses that override __init__ + # without calling super().__init__() still benefit from connection reuse. + if getattr(self, '_session', None) is None: + self._session = requests.Session() return self._session.post(endpoint, data=init.generate(), headers=headers) diff --git a/tests/unit/test_dataapi.py b/tests/unit/test_dataapi.py index 9a128bf..c0f5a9a 100644 --- a/tests/unit/test_dataapi.py +++ b/tests/unit/test_dataapi.py @@ -265,6 +265,30 @@ def test_close_without_session_is_safe(self) -> None: # No __init__ ran, so _session does not exist; close() must not raise. client.close() + @responses.activate + def test_request_lazily_creates_session_for_subclass_skipping_super(self) -> None: + """Verify a subclass that overrides __init__ without super() still works + + Before session reuse, DataApi had no __init__, so such a subclass was + valid. request() must lazily create the session so these subclasses keep + working and still benefit from connection reuse. + """ + class LegacySubclass(DataApi): + def __init__(self) -> None: + # Intentionally does not call super().__init__() + pass + + client = LegacySubclass() + assert getattr(client, '_session', None) is None + + responses.add(responses.POST, self.endpoint, json=self.dummy_responses[0]) + response = client.request(self.endpoint, self.security, self.consumer_secret, self.request, self.action) + + assert response.json() == self.dummy_responses[0] + # The session was created on demand, so subsequent calls reuse it. + assert isinstance(client._session, requests.Session) + + @responses.activate def test_metadata_headers_in_paginated_requests(self) -> None: """Verify that metadata headers are sent in all paginated requests""" From 606538d9bb9a22ccf3d5e3d2ac45b055e4984633 Mon Sep 17 00:00:00 2001 From: Pablo Estevez Date: Thu, 15 Oct 2026 16:08:17 +0100 Subject: [PATCH 5/5] change _session to _http_session --- learnosity_sdk/request/dataapi.py | 10 +++++----- tests/unit/test_dataapi.py | 14 +++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/learnosity_sdk/request/dataapi.py b/learnosity_sdk/request/dataapi.py index 69ca528..64217dc 100644 --- a/learnosity_sdk/request/dataapi.py +++ b/learnosity_sdk/request/dataapi.py @@ -13,7 +13,7 @@ class DataApi(object): def __init__(self) -> None: - self._session = requests.Session() + self._http_session = requests.Session() def __del__(self) -> None: self.close() @@ -63,7 +63,7 @@ def _derive_action(self, endpoint: str, action: str) -> str: def close(self) -> None: """Close pooled connections; later requests on this client remain supported.""" - session = getattr(self, '_session', None) + session = getattr(self, '_http_session', None) if session is not None: session.close() @@ -105,10 +105,10 @@ def request(self, endpoint: str, security_packet: Dict[str, str], } # Lazily create the session so subclasses that override __init__ # without calling super().__init__() still benefit from connection reuse. - if getattr(self, '_session', None) is None: - self._session = requests.Session() + if getattr(self, '_http_session', None) is None: + self._http_session = requests.Session() - return self._session.post(endpoint, data=init.generate(), headers=headers) + return self._http_session.post(endpoint, data=init.generate(), headers=headers) def results_iter(self, endpoint: str, security_packet: Dict[str, str], secret: str, request_packet: Dict[str, Any] = {}, diff --git a/tests/unit/test_dataapi.py b/tests/unit/test_dataapi.py index c0f5a9a..4cfc009 100644 --- a/tests/unit/test_dataapi.py +++ b/tests/unit/test_dataapi.py @@ -231,8 +231,8 @@ def test_session_reused_across_requests(self) -> None: client = DataApi() # The instance holds a single Session... - assert isinstance(client._session, requests.Session) - session = client._session + assert isinstance(client._http_session, requests.Session) + session = client._http_session # ...and spying on that session's post shows every paginated request # goes through the same object rather than a fresh connection each time. @@ -250,19 +250,19 @@ def counting_post(*args: Any, **kwargs: Any) -> Any: self.request, self.action)) assert call_count == 2 - assert client._session is session + assert client._http_session is session def test_close_closes_session(self) -> None: """Verify that close() closes the underlying session""" client = DataApi() - with mock.patch.object(client._session, 'close') as mock_close: + with mock.patch.object(client._http_session, 'close') as mock_close: client.close() mock_close.assert_called_once_with() def test_close_without_session_is_safe(self) -> None: """Verify that close() does not raise if the session was never created""" client = DataApi.__new__(DataApi) - # No __init__ ran, so _session does not exist; close() must not raise. + # No __init__ ran, so _http_session does not exist; close() must not raise. client.close() @responses.activate @@ -279,14 +279,14 @@ def __init__(self) -> None: pass client = LegacySubclass() - assert getattr(client, '_session', None) is None + assert getattr(client, '_http_session', None) is None responses.add(responses.POST, self.endpoint, json=self.dummy_responses[0]) response = client.request(self.endpoint, self.security, self.consumer_secret, self.request, self.action) assert response.json() == self.dummy_responses[0] # The session was created on demand, so subsequent calls reuse it. - assert isinstance(client._session, requests.Session) + assert isinstance(client._http_session, requests.Session) @responses.activate