From f4ef03b2e7414d9f77cf99f44974a1bab123fa3a Mon Sep 17 00:00:00 2001 From: Erik Zigo Date: Fri, 12 Dec 2025 09:07:27 +0100 Subject: [PATCH 1/8] refactor: delegate autosave logic from hook to data-loader-api --- notebookUtils.py | 33 ++++++++++++------- tests/notebookUtils.py | 75 +++++++++++++++++++++--------------------- 2 files changed, 59 insertions(+), 49 deletions(-) diff --git a/notebookUtils.py b/notebookUtils.py index 9c6f89a..51029e2 100644 --- a/notebookUtils.py +++ b/notebookUtils.py @@ -144,25 +144,34 @@ def saveFolder(folder_path, sandbox_id, token, log): os.remove(gz_path) +def _get_internal_autosave_url(): + """Get the URL for the internal autosave endpoint.""" + base = os.environ.get('DATA_LOADER_API_URL', 'data-loader-api') + return f'http://{base}/data-loader-api/internal/autosave' + + def scriptPostSave(model, os_path, contents_manager, **kwargs): """ - Hook on notebook save - - Saves the notebook file to Keboola Storage - - Saves .git folder to Keboola Storage if initialized - - Updates lastAutosaveTimestamp in the API record + Hook on notebook save - delegates to data-loader-api internal endpoint. """ if model['type'] != 'notebook': return - log = contents_manager.log - sandbox_id = os.environ['SANDBOX_ID'] - token = getStorageTokenFromEnv(log) - updateApiTimestamp(sandbox_id, log) + log = contents_manager.log + log.info(f'Notebook saved: {os_path}, triggering autosave') - has_persistent_storage = os.getenv('HAS_PERSISTENT_STORAGE', 'False').lower() in ('true', '1') - if not has_persistent_storage: - saveFile(os_path, sandbox_id, token, log) - saveFolder('/data/.git', sandbox_id, token, log) + url = _get_internal_autosave_url() + try: + response = retrySession().post( + url, + json={'file_path': os_path}, + headers={'Content-Type': 'application/json'}, + timeout=300, # 5 min for large notebooks + git + ) + response.raise_for_status() + log.info('Autosave completed successfully') + except Exception as e: + log.exception(f'Autosave failed: {e}') def notebookSetup(c): diff --git a/tests/notebookUtils.py b/tests/notebookUtils.py index 74174b8..7f68cd4 100644 --- a/tests/notebookUtils.py +++ b/tests/notebookUtils.py @@ -8,7 +8,7 @@ import tempfile from notebookUtils import compressFolder, getStorageTokenFromEnv, notebookSetup, saveFile, saveFolder, \ - scriptPostSave, updateApiTimestamp + scriptPostSave, updateApiTimestamp, _get_internal_autosave_url def generate_random_string(): return ''.join(random.choices(string.ascii_uppercase + string.digits, k=5)) @@ -38,64 +38,63 @@ def test_notebookSetup(self): assert "object has no attribute 'password'" in str(attribute_error.value) def test_scriptPostSave(self): + """Test that scriptPostSave calls the internal autosave endpoint.""" with requests_mock.Mocker() as m: - os.environ['SANDBOX_ID'] = '123' os.environ['DATA_LOADER_API_URL'] = 'dataloader' - os.environ['KBC_TOKEN'] = 'token' - if 'HAS_PERSISTENT_STORAGE' in os.environ: - del os.environ['HAS_PERSISTENT_STORAGE'] - dataLoaderMock = m.post('http://dataloader/data-loader-api/save', json={'result': 'ok'}) - dataLoaderActivityMock = m.post('http://dataloader/data-loader-api/internal/activity', json={'result': 'ok'}) + autosaveMock = m.post('http://dataloader/data-loader-api/internal/autosave', json={'status': 'ok'}) contentsManager = type('', (), {})() contentsManager.log = logging - scriptPostSave({'type': 'notebook'}, '/path', contentsManager) - - assert dataLoaderMock.call_count == 1 - assert 'file' in dataLoaderMock.last_request.text - assert 'tags' in dataLoaderMock.last_request.text + scriptPostSave({'type': 'notebook'}, '/data/notebook.ipynb', contentsManager) - assert dataLoaderActivityMock.call_count == 1 + assert autosaveMock.call_count == 1 + request_json = json.loads(autosaveMock.last_request.text) + assert request_json['file_path'] == '/data/notebook.ipynb' - def test_scriptPostSave_disabledPersistentStorage(self): + def test_scriptPostSave_skipsNonNotebook(self): + """Test that scriptPostSave skips non-notebook files.""" with requests_mock.Mocker() as m: - os.environ['SANDBOX_ID'] = '123' os.environ['DATA_LOADER_API_URL'] = 'dataloader' - os.environ['KBC_TOKEN'] = 'token' - os.environ['HAS_PERSISTENT_STORAGE'] = '0' - dataLoaderMock = m.post('http://dataloader/data-loader-api/save', json={'result': 'ok'}) - dataLoaderActivityMock = m.post('http://dataloader/data-loader-api/internal/activity', json={'result': 'ok'}) + + autosaveMock = m.post('http://dataloader/data-loader-api/internal/autosave', json={'status': 'ok'}) contentsManager = type('', (), {})() contentsManager.log = logging - scriptPostSave({'type': 'notebook'}, '/path', contentsManager) - - assert dataLoaderMock.call_count == 1 - assert 'file' in dataLoaderMock.last_request.text - assert 'tags' in dataLoaderMock.last_request.text - - assert dataLoaderActivityMock.call_count == 1 + scriptPostSave({'type': 'file'}, '/data/script.py', contentsManager) + assert autosaveMock.call_count == 0 - def test_scriptPostSave_enabledPersistentStorage(self): + def test_scriptPostSave_handlesError(self): + """Test that scriptPostSave handles errors gracefully.""" with requests_mock.Mocker() as m: - os.environ['SANDBOX_ID'] = '123' os.environ['DATA_LOADER_API_URL'] = 'dataloader' - os.environ['KBC_TOKEN'] = 'token' - os.environ['HAS_PERSISTENT_STORAGE'] = '1' - dataLoaderMock = m.post('http://dataloader/data-loader-api/save', json={'result': 'ok'}) - dataLoaderActivityMock = m.post('http://dataloader/data-loader-api/internal/activity', json={'result': 'ok'}) + + autosaveMock = m.post('http://dataloader/data-loader-api/internal/autosave', status_code=500) contentsManager = type('', (), {})() contentsManager.log = logging - scriptPostSave({'type': 'notebook'}, '/path', contentsManager) + # Should not raise, just log the error + scriptPostSave({'type': 'notebook'}, '/data/notebook.ipynb', contentsManager) - assert dataLoaderMock.call_count == 0 - assert dataLoaderActivityMock.call_count == 1 + assert autosaveMock.call_count == 1 + + def test_get_internal_autosave_url_with_env(self): + """Test URL construction with DATA_LOADER_API_URL set.""" + os.environ['DATA_LOADER_API_URL'] = 'custom-api-host' + url = _get_internal_autosave_url() + assert url == 'http://custom-api-host/data-loader-api/internal/autosave' + + def test_get_internal_autosave_url_default(self): + """Test URL construction with default value.""" + if 'DATA_LOADER_API_URL' in os.environ: + del os.environ['DATA_LOADER_API_URL'] + url = _get_internal_autosave_url() + assert url == 'http://data-loader-api/data-loader-api/internal/autosave' def test_getStorageTokenFromEnvMissing(self): - os.environ.pop('KBC_TOKEN') + if 'KBC_TOKEN' in os.environ: + del os.environ['KBC_TOKEN'] with pytest.raises(Exception): getStorageTokenFromEnv(logging) @@ -106,7 +105,9 @@ def test_getStorageTokenFromEnvOk(self): def test_updateApiTimestamp(self): with requests_mock.Mocker() as m: - dataLoaderActivityMock = m.post('http://dataloader/data-loader-api/internal/activity', json={'result': 'ok'}) + os.environ['DATA_LOADER_API_URL'] = 'dataloader' + url = 'http://dataloader/data-loader-api/internal/activity' + dataLoaderActivityMock = m.post(url, json={'result': 'ok'}) updateApiTimestamp('123', logging) From f5fc98a7d7436c39b6f22155507723863e568f12 Mon Sep 17 00:00:00 2001 From: Erik Zigo Date: Mon, 9 Feb 2026 14:58:33 +0100 Subject: [PATCH 2/8] chore: upgrade minimum python version from 3.8 to 3.10 --- .github/workflows/test.yml | 2 +- requirements.txt | 2 +- setup.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8376a1a..8e0a287 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8] + python-version: ['3.10'] steps: - uses: actions/checkout@v2 diff --git a/requirements.txt b/requirements.txt index d4b5763..03f0228 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ pycodestyle==2.6.0 pyflakes==2.2.0 Pygments==2.7.4 pyparsing==2.4.7 -pytest==5.4.3 +pytest==6.2.5 requests==2.25.1 requests-mock==1.8.0 six==1.15.0 diff --git a/setup.py b/setup.py index bcea3ee..ab466e8 100644 --- a/setup.py +++ b/setup.py @@ -6,5 +6,6 @@ url='https://github.com/keboola/sandboxes-notebook-utils', packages=['keboola_notebook_utils'], package_dir={'keboola_notebook_utils': ''}, - requires=['pip'] + python_requires='>=3.10', + install_requires=['requests'], ) From 4923edb1e2ed7e19e2dd969d5935ad245f0ffe16 Mon Sep 17 00:00:00 2001 From: Erik Zigo Date: Mon, 9 Feb 2026 21:39:06 +0100 Subject: [PATCH 3/8] refactor: use /internal/save endpoint for autosave, eliminating KBC_TOKEN requirement --- notebookUtils.py | 28 ++++++---------------------- tests/notebookUtils.py | 21 +++++---------------- 2 files changed, 11 insertions(+), 38 deletions(-) diff --git a/notebookUtils.py b/notebookUtils.py index 51029e2..d0a62a4 100644 --- a/notebookUtils.py +++ b/notebookUtils.py @@ -37,14 +37,13 @@ def retrySession( return session -def saveFile(file_path, sandbox_id, token, log, tags=None): +def saveFile(file_path, sandbox_id, log, tags=None): """ Construct a requests POST call with args and kwargs and process the results. Args: file_path: The relative path to the file from the datadir, including filename and extension sandbox_id: Id of the sandbox - token: Keboola Storage token log: Logger instance tags: Additional tags for the file Returns: @@ -57,10 +56,10 @@ def saveFile(file_path, sandbox_id, token, log, tags=None): if tags is None: tags = [] if 'DATA_LOADER_API_URL' in os.environ and os.environ['DATA_LOADER_API_URL']: - url = 'http://' + os.environ['DATA_LOADER_API_URL'] + '/data-loader-api/save' + url = 'http://' + os.environ['DATA_LOADER_API_URL'] + '/data-loader-api/internal/save' else: - url = 'http://data-loader-api/data-loader-api/save' - headers = {'X-StorageApi-Token': token, 'User-Agent': 'Keboola Sandbox Autosave Request'} + url = 'http://data-loader-api/data-loader-api/internal/save' + headers = {'Content-Type': 'application/json', 'User-Agent': 'Keboola Sandbox Autosave Request'} payload = {'file': {'source': os.path.relpath(file_path), 'tags': ['autosave', 'sandbox-' + sandbox_id] + tags}} # the timeout is set to > 3min because of the delay on 400 level exception responses @@ -98,20 +97,6 @@ def updateApiTimestamp(sandbox_id, log): log.error('Saving autosave to Sandboxes API errored: ' + result.text) -def getStorageTokenFromEnv(log): - """ - Find Keboola token in env vars - Args: - log: Logger instance - """ - - if 'KBC_TOKEN' in os.environ: - return os.environ['KBC_TOKEN'] - else: - log.error('Could not find Keboola Storage API token.') - raise Exception('Could not find Keboola Storage API token.') - - def compressFolder(folder_path): """ Gzip folder @@ -126,19 +111,18 @@ def compressFolder(folder_path): return gz_path -def saveFolder(folder_path, sandbox_id, token, log): +def saveFolder(folder_path, sandbox_id, log): """ Gzip folder and save it to Keboola Storage Args: folder_path: Path to the folder sandbox_id: Id of the sandbox - token: Keboola Storage token log: Logger instance """ if os.path.exists(folder_path): gz_path = compressFolder(folder_path) try: - saveFile(gz_path, sandbox_id, token, log, ['git']) + saveFile(gz_path, sandbox_id, log, ['git']) finally: if os.path.exists(gz_path): os.remove(gz_path) diff --git a/tests/notebookUtils.py b/tests/notebookUtils.py index 7f68cd4..0b4db6d 100644 --- a/tests/notebookUtils.py +++ b/tests/notebookUtils.py @@ -7,7 +7,7 @@ import string import tempfile -from notebookUtils import compressFolder, getStorageTokenFromEnv, notebookSetup, saveFile, saveFolder, \ +from notebookUtils import compressFolder, notebookSetup, saveFile, saveFolder, \ scriptPostSave, updateApiTimestamp, _get_internal_autosave_url def generate_random_string(): @@ -92,17 +92,6 @@ def test_get_internal_autosave_url_default(self): url = _get_internal_autosave_url() assert url == 'http://data-loader-api/data-loader-api/internal/autosave' - def test_getStorageTokenFromEnvMissing(self): - if 'KBC_TOKEN' in os.environ: - del os.environ['KBC_TOKEN'] - with pytest.raises(Exception): - getStorageTokenFromEnv(logging) - - def test_getStorageTokenFromEnvOk(self): - token = generate_random_string() - os.environ['KBC_TOKEN'] = token - assert getStorageTokenFromEnv(logging) == token - def test_updateApiTimestamp(self): with requests_mock.Mocker() as m: os.environ['DATA_LOADER_API_URL'] = 'dataloader' @@ -116,9 +105,9 @@ def test_updateApiTimestamp(self): def test_saveFile(self): with requests_mock.Mocker() as m: os.environ['DATA_LOADER_API_URL'] = 'dataloader' - dataLoaderMock = m.post('http://dataloader/data-loader-api/save', json={'result': 'ok'}) + dataLoaderMock = m.post('http://dataloader/data-loader-api/internal/save', json={'result': 'ok'}) - saveFile('/file/path', '123', 'token', logging) + saveFile('/file/path', '123', logging) assert dataLoaderMock.call_count == 1 response = json.loads(dataLoaderMock.last_request.text) @@ -145,14 +134,14 @@ def test_compressFolder(self): def test_saveFolder(self): with requests_mock.Mocker() as m: os.environ['DATA_LOADER_API_URL'] = 'dataloader' - dataLoaderMock = m.post('http://dataloader/data-loader-api/save', json={'result': 'ok'}) + dataLoaderMock = m.post('http://dataloader/data-loader-api/internal/save', json={'result': 'ok'}) folder_prepare = tempfile.mkdtemp() + '/.git' os.mkdir(folder_prepare) f = open(folder_prepare + '/file.txt', 'a') f.write('content') f.close() - saveFolder(folder_prepare, '123', 'token', logging) + saveFolder(folder_prepare, '123', logging) assert dataLoaderMock.call_count == 1 response = json.loads(dataLoaderMock.last_request.text) From cfecf9ef22042441809758d0618e4f2d7709a7b4 Mon Sep 17 00:00:00 2001 From: Erik Zigo Date: Tue, 10 Feb 2026 11:10:10 +0100 Subject: [PATCH 4/8] chore: bump version to 2.3.0.dev2 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ab466e8..e678b4f 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='keboola-sandboxes-notebook-utils', - version='2.2.0', + version='2.3.0.dev2', url='https://github.com/keboola/sandboxes-notebook-utils', packages=['keboola_notebook_utils'], package_dir={'keboola_notebook_utils': ''}, From 37e259977cc84f0472dbcc2e71c6b63f974c105e Mon Sep 17 00:00:00 2001 From: Erik Zigo Date: Tue, 10 Feb 2026 15:34:52 +0100 Subject: [PATCH 5/8] fix: use /internal/save endpoint for autosave hook --- notebookUtils.py | 8 ++++---- setup.py | 2 +- tests/notebookUtils.py | 22 +++++++++++----------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/notebookUtils.py b/notebookUtils.py index d0a62a4..3a1f77e 100644 --- a/notebookUtils.py +++ b/notebookUtils.py @@ -128,10 +128,10 @@ def saveFolder(folder_path, sandbox_id, log): os.remove(gz_path) -def _get_internal_autosave_url(): - """Get the URL for the internal autosave endpoint.""" +def _get_internal_save_url(): + """Get the URL for the internal save endpoint.""" base = os.environ.get('DATA_LOADER_API_URL', 'data-loader-api') - return f'http://{base}/data-loader-api/internal/autosave' + return f'http://{base}/data-loader-api/internal/save' def scriptPostSave(model, os_path, contents_manager, **kwargs): @@ -144,7 +144,7 @@ def scriptPostSave(model, os_path, contents_manager, **kwargs): log = contents_manager.log log.info(f'Notebook saved: {os_path}, triggering autosave') - url = _get_internal_autosave_url() + url = _get_internal_save_url() try: response = retrySession().post( url, diff --git a/setup.py b/setup.py index e678b4f..3cb60c0 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='keboola-sandboxes-notebook-utils', - version='2.3.0.dev2', + version='2.3.0.dev3', url='https://github.com/keboola/sandboxes-notebook-utils', packages=['keboola_notebook_utils'], package_dir={'keboola_notebook_utils': ''}, diff --git a/tests/notebookUtils.py b/tests/notebookUtils.py index 0b4db6d..38c8009 100644 --- a/tests/notebookUtils.py +++ b/tests/notebookUtils.py @@ -8,7 +8,7 @@ import tempfile from notebookUtils import compressFolder, notebookSetup, saveFile, saveFolder, \ - scriptPostSave, updateApiTimestamp, _get_internal_autosave_url + scriptPostSave, updateApiTimestamp, _get_internal_save_url def generate_random_string(): return ''.join(random.choices(string.ascii_uppercase + string.digits, k=5)) @@ -38,11 +38,11 @@ def test_notebookSetup(self): assert "object has no attribute 'password'" in str(attribute_error.value) def test_scriptPostSave(self): - """Test that scriptPostSave calls the internal autosave endpoint.""" + """Test that scriptPostSave calls the internal save endpoint.""" with requests_mock.Mocker() as m: os.environ['DATA_LOADER_API_URL'] = 'dataloader' - autosaveMock = m.post('http://dataloader/data-loader-api/internal/autosave', json={'status': 'ok'}) + autosaveMock = m.post('http://dataloader/data-loader-api/internal/save', json={'status': 'ok'}) contentsManager = type('', (), {})() contentsManager.log = logging @@ -57,7 +57,7 @@ def test_scriptPostSave_skipsNonNotebook(self): with requests_mock.Mocker() as m: os.environ['DATA_LOADER_API_URL'] = 'dataloader' - autosaveMock = m.post('http://dataloader/data-loader-api/internal/autosave', json={'status': 'ok'}) + autosaveMock = m.post('http://dataloader/data-loader-api/internal/save', json={'status': 'ok'}) contentsManager = type('', (), {})() contentsManager.log = logging @@ -70,7 +70,7 @@ def test_scriptPostSave_handlesError(self): with requests_mock.Mocker() as m: os.environ['DATA_LOADER_API_URL'] = 'dataloader' - autosaveMock = m.post('http://dataloader/data-loader-api/internal/autosave', status_code=500) + autosaveMock = m.post('http://dataloader/data-loader-api/internal/save', status_code=500) contentsManager = type('', (), {})() contentsManager.log = logging @@ -79,18 +79,18 @@ def test_scriptPostSave_handlesError(self): assert autosaveMock.call_count == 1 - def test_get_internal_autosave_url_with_env(self): + def test_get_internal_save_url_with_env(self): """Test URL construction with DATA_LOADER_API_URL set.""" os.environ['DATA_LOADER_API_URL'] = 'custom-api-host' - url = _get_internal_autosave_url() - assert url == 'http://custom-api-host/data-loader-api/internal/autosave' + url = _get_internal_save_url() + assert url == 'http://custom-api-host/data-loader-api/internal/save' - def test_get_internal_autosave_url_default(self): + def test_get_internal_save_url_default(self): """Test URL construction with default value.""" if 'DATA_LOADER_API_URL' in os.environ: del os.environ['DATA_LOADER_API_URL'] - url = _get_internal_autosave_url() - assert url == 'http://data-loader-api/data-loader-api/internal/autosave' + url = _get_internal_save_url() + assert url == 'http://data-loader-api/data-loader-api/internal/save' def test_updateApiTimestamp(self): with requests_mock.Mocker() as m: From 2c9a449da1128c7cedbd6e8c6a8d37a9d4b0dab1 Mon Sep 17 00:00:00 2001 From: Erik Zigo Date: Tue, 10 Feb 2026 18:03:07 +0100 Subject: [PATCH 6/8] fix: restore original scriptPostSave logic, only remove KBC_TOKEN and use /internal/save endpoint --- notebookUtils.py | 32 +++++++------------- setup.py | 2 +- tests/notebookUtils.py | 66 ++++++++++++++++++++---------------------- 3 files changed, 44 insertions(+), 56 deletions(-) diff --git a/notebookUtils.py b/notebookUtils.py index 3a1f77e..8917ea8 100644 --- a/notebookUtils.py +++ b/notebookUtils.py @@ -128,34 +128,24 @@ def saveFolder(folder_path, sandbox_id, log): os.remove(gz_path) -def _get_internal_save_url(): - """Get the URL for the internal save endpoint.""" - base = os.environ.get('DATA_LOADER_API_URL', 'data-loader-api') - return f'http://{base}/data-loader-api/internal/save' - - def scriptPostSave(model, os_path, contents_manager, **kwargs): """ - Hook on notebook save - delegates to data-loader-api internal endpoint. + Hook on notebook save + - Saves the notebook file to Keboola Storage + - Saves .git folder to Keboola Storage if initialized + - Updates lastAutosaveTimestamp in the API record """ if model['type'] != 'notebook': return - log = contents_manager.log - log.info(f'Notebook saved: {os_path}, triggering autosave') - url = _get_internal_save_url() - try: - response = retrySession().post( - url, - json={'file_path': os_path}, - headers={'Content-Type': 'application/json'}, - timeout=300, # 5 min for large notebooks + git - ) - response.raise_for_status() - log.info('Autosave completed successfully') - except Exception as e: - log.exception(f'Autosave failed: {e}') + sandbox_id = os.environ['SANDBOX_ID'] + updateApiTimestamp(sandbox_id, log) + + has_persistent_storage = os.getenv('HAS_PERSISTENT_STORAGE', 'False').lower() in ('true', '1') + if not has_persistent_storage: + saveFile(os_path, sandbox_id, log) + saveFolder('/data/.git', sandbox_id, log) def notebookSetup(c): diff --git a/setup.py b/setup.py index 3cb60c0..ebb56a4 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='keboola-sandboxes-notebook-utils', - version='2.3.0.dev3', + version='2.3.0.dev4', url='https://github.com/keboola/sandboxes-notebook-utils', packages=['keboola_notebook_utils'], package_dir={'keboola_notebook_utils': ''}, diff --git a/tests/notebookUtils.py b/tests/notebookUtils.py index 38c8009..66d5a58 100644 --- a/tests/notebookUtils.py +++ b/tests/notebookUtils.py @@ -8,7 +8,7 @@ import tempfile from notebookUtils import compressFolder, notebookSetup, saveFile, saveFolder, \ - scriptPostSave, updateApiTimestamp, _get_internal_save_url + scriptPostSave, updateApiTimestamp def generate_random_string(): return ''.join(random.choices(string.ascii_uppercase + string.digits, k=5)) @@ -38,59 +38,57 @@ def test_notebookSetup(self): assert "object has no attribute 'password'" in str(attribute_error.value) def test_scriptPostSave(self): - """Test that scriptPostSave calls the internal save endpoint.""" with requests_mock.Mocker() as m: + os.environ['SANDBOX_ID'] = '123' os.environ['DATA_LOADER_API_URL'] = 'dataloader' + if 'HAS_PERSISTENT_STORAGE' in os.environ: + del os.environ['HAS_PERSISTENT_STORAGE'] - autosaveMock = m.post('http://dataloader/data-loader-api/internal/save', json={'status': 'ok'}) + dataLoaderMock = m.post('http://dataloader/data-loader-api/internal/save', json={'result': 'ok'}) + dataLoaderActivityMock = m.post('http://dataloader/data-loader-api/internal/activity', json={'result': 'ok'}) contentsManager = type('', (), {})() contentsManager.log = logging - scriptPostSave({'type': 'notebook'}, '/data/notebook.ipynb', contentsManager) + scriptPostSave({'type': 'notebook'}, '/path', contentsManager) + + assert dataLoaderMock.call_count == 1 + assert 'file' in dataLoaderMock.last_request.text + assert 'tags' in dataLoaderMock.last_request.text - assert autosaveMock.call_count == 1 - request_json = json.loads(autosaveMock.last_request.text) - assert request_json['file_path'] == '/data/notebook.ipynb' + assert dataLoaderActivityMock.call_count == 1 - def test_scriptPostSave_skipsNonNotebook(self): - """Test that scriptPostSave skips non-notebook files.""" + def test_scriptPostSave_disabledPersistentStorage(self): with requests_mock.Mocker() as m: + os.environ['SANDBOX_ID'] = '123' os.environ['DATA_LOADER_API_URL'] = 'dataloader' - - autosaveMock = m.post('http://dataloader/data-loader-api/internal/save', json={'status': 'ok'}) + os.environ['HAS_PERSISTENT_STORAGE'] = '0' + dataLoaderMock = m.post('http://dataloader/data-loader-api/internal/save', json={'result': 'ok'}) + dataLoaderActivityMock = m.post('http://dataloader/data-loader-api/internal/activity', json={'result': 'ok'}) contentsManager = type('', (), {})() contentsManager.log = logging - scriptPostSave({'type': 'file'}, '/data/script.py', contentsManager) + scriptPostSave({'type': 'notebook'}, '/path', contentsManager) - assert autosaveMock.call_count == 0 + assert dataLoaderMock.call_count == 1 + assert 'file' in dataLoaderMock.last_request.text + assert 'tags' in dataLoaderMock.last_request.text - def test_scriptPostSave_handlesError(self): - """Test that scriptPostSave handles errors gracefully.""" + assert dataLoaderActivityMock.call_count == 1 + + def test_scriptPostSave_enabledPersistentStorage(self): with requests_mock.Mocker() as m: + os.environ['SANDBOX_ID'] = '123' os.environ['DATA_LOADER_API_URL'] = 'dataloader' - - autosaveMock = m.post('http://dataloader/data-loader-api/internal/save', status_code=500) + os.environ['HAS_PERSISTENT_STORAGE'] = '1' + dataLoaderMock = m.post('http://dataloader/data-loader-api/internal/save', json={'result': 'ok'}) + dataLoaderActivityMock = m.post('http://dataloader/data-loader-api/internal/activity', json={'result': 'ok'}) contentsManager = type('', (), {})() contentsManager.log = logging - # Should not raise, just log the error - scriptPostSave({'type': 'notebook'}, '/data/notebook.ipynb', contentsManager) - - assert autosaveMock.call_count == 1 - - def test_get_internal_save_url_with_env(self): - """Test URL construction with DATA_LOADER_API_URL set.""" - os.environ['DATA_LOADER_API_URL'] = 'custom-api-host' - url = _get_internal_save_url() - assert url == 'http://custom-api-host/data-loader-api/internal/save' - - def test_get_internal_save_url_default(self): - """Test URL construction with default value.""" - if 'DATA_LOADER_API_URL' in os.environ: - del os.environ['DATA_LOADER_API_URL'] - url = _get_internal_save_url() - assert url == 'http://data-loader-api/data-loader-api/internal/save' + scriptPostSave({'type': 'notebook'}, '/path', contentsManager) + + assert dataLoaderMock.call_count == 0 + assert dataLoaderActivityMock.call_count == 1 def test_updateApiTimestamp(self): with requests_mock.Mocker() as m: From 7cd3214c286bdce2ae4e7530826ec49ae6e17006 Mon Sep 17 00:00:00 2001 From: Erik Zigo Date: Sun, 15 Feb 2026 17:06:24 +0100 Subject: [PATCH 7/8] chore: bump version to 2.3.0.dev5 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ebb56a4..161cba5 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='keboola-sandboxes-notebook-utils', - version='2.3.0.dev4', + version='2.3.0.dev5', url='https://github.com/keboola/sandboxes-notebook-utils', packages=['keboola_notebook_utils'], package_dir={'keboola_notebook_utils': ''}, From e9175e88a7b2caaae8185c26c6dc63c51a02bac0 Mon Sep 17 00:00:00 2001 From: Erik Zigo Date: Tue, 17 Feb 2026 09:23:04 +0100 Subject: [PATCH 8/8] chore: bump version to 2.3.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 161cba5..66c6489 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='keboola-sandboxes-notebook-utils', - version='2.3.0.dev5', + version='2.3.0', url='https://github.com/keboola/sandboxes-notebook-utils', packages=['keboola_notebook_utils'], package_dir={'keboola_notebook_utils': ''},