From b47703ba0990c78def01904db6d93fffa734ff1a Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 8 Dec 2025 10:06:23 +0100 Subject: [PATCH 01/33] #125: Made operation delete wait for SaaS instance to enable deletion --- doc/changes/unreleased.md | 4 ++++ exasol/saas/client/api_access.py | 12 +----------- test/integration/databases_test.py | 3 +-- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 79e701b..52116b7 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -1 +1,5 @@ # Unreleased + +## Bugfixes + +* #125: Made operation delete wait for SaaS instance to enable deletion diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 2319c43..784f9d0 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -55,16 +55,6 @@ def timestamp_name(project_short_tag: str | None = None) -> str: return candidate[: Limits.MAX_DATABASE_NAME_LENGTH] -def wait_for_delete_clearance(start: dt.datetime): - lifetime = datetime.now() - start - if lifetime < Limits.MIN_DATABASE_LIFETIME: - wait = Limits.MIN_DATABASE_LIFETIME - lifetime - LOG.info( - f"Waiting {int(wait.seconds)} seconds" " before deleting the database." - ) - time.sleep(wait.seconds) - - class DatabaseStartupFailure(Exception): """ If a SaaS database instance during startup reports a status other than @@ -274,7 +264,7 @@ def database( try: db = self.create_database(name, idle_time=idle_time) yield db - wait_for_delete_clearance(start) + self.wait_until_running(db.id) finally: db_repr = f"{db.name} with ID {db.id}" if db else None if db and not keep: diff --git a/test/integration/databases_test.py b/test/integration/databases_test.py index b6399d6..c23c314 100644 --- a/test/integration/databases_test.py +++ b/test/integration/databases_test.py @@ -9,7 +9,6 @@ from exasol.saas.client import PROMISING_STATES from exasol.saas.client.api_access import ( get_connection_params, - wait_for_delete_clearance, ) @@ -34,7 +33,7 @@ def test_lifecycle(api_access, database_name): assert db.id in testee.list_database_ids() # delete database and verify database is not listed anymore - wait_for_delete_clearance(start) + testee.wait_until_running(db.id) testee.delete_database(db.id) testee.wait_until_deleted(db.id) assert db.id not in testee.list_database_ids() From d9ff70a692cc0f4162c497461eb7e111162bc22f Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 8 Dec 2025 10:16:28 +0100 Subject: [PATCH 02/33] Moved wait_until_running to method delete_database() --- doc/changes/unreleased.md | 4 ++++ exasol/saas/client/api_access.py | 2 +- test/integration/databases_test.py | 1 - 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 52116b7..d694d63 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -1,5 +1,9 @@ # Unreleased +This release changes the precondition for deleting a SaaS database instance. + +Before, the SAPIPY used only a fixed waiting time. With the changed behavior of the actual SaaS instances the precondition needed to be adapted. SAPIPY now waits for the SaaS instance to be running before attempting to delete it. + ## Bugfixes * #125: Made operation delete wait for SaaS instance to enable deletion diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 784f9d0..808a2c8 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -243,6 +243,7 @@ def still_exists() -> bool: def delete_database(self, database_id: str, ignore_failures=False): with self._ignore_failures(ignore_failures) as client: + self.wait_until_running(database_id) return delete_database.sync_detailed( self._account_id, database_id, client=client ) @@ -264,7 +265,6 @@ def database( try: db = self.create_database(name, idle_time=idle_time) yield db - self.wait_until_running(db.id) finally: db_repr = f"{db.name} with ID {db.id}" if db else None if db and not keep: diff --git a/test/integration/databases_test.py b/test/integration/databases_test.py index c23c314..f3b14a8 100644 --- a/test/integration/databases_test.py +++ b/test/integration/databases_test.py @@ -33,7 +33,6 @@ def test_lifecycle(api_access, database_name): assert db.id in testee.list_database_ids() # delete database and verify database is not listed anymore - testee.wait_until_running(db.id) testee.delete_database(db.id) testee.wait_until_deleted(db.id) assert db.id not in testee.list_database_ids() From 174537075b34dce759210f7685b6198e9af9ccfd Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 8 Dec 2025 10:32:31 +0100 Subject: [PATCH 03/33] Prepare release 2.6.0 --- doc/changes/changelog.md | 2 ++ doc/changes/changes_2.6.0.md | 8 ++++++++ doc/changes/unreleased.md | 8 -------- pyproject.toml | 2 +- version.py | 2 +- 5 files changed, 12 insertions(+), 10 deletions(-) create mode 100644 doc/changes/changes_2.6.0.md diff --git a/doc/changes/changelog.md b/doc/changes/changelog.md index 0355385..a5a21e1 100644 --- a/doc/changes/changelog.md +++ b/doc/changes/changelog.md @@ -1,6 +1,7 @@ # Changes * [unreleased](unreleased.md) +* [2.6.0](changes_2.6.0.md) * [2.5.0](changes_2.5.0.md) * [2.4.0](changes_2.4.0.md) * [2.3.0](changes_2.3.0.md) @@ -27,6 +28,7 @@ hidden: --- unreleased +changes_2.6.0 changes_2.5.0 changes_2.4.0 changes_2.3.0 diff --git a/doc/changes/changes_2.6.0.md b/doc/changes/changes_2.6.0.md new file mode 100644 index 0000000..ca4eafc --- /dev/null +++ b/doc/changes/changes_2.6.0.md @@ -0,0 +1,8 @@ +# 2.6.0 - 2025-12-08 +This release changes the precondition for deleting a SaaS database instance. + +Before, the SAPIPY used only a fixed waiting time. With the changed behavior of the actual SaaS instances the precondition needed to be adapted. SAPIPY now waits for the SaaS instance to be running before attempting to delete it. + +## Bugfixes + +* #125: Made operation delete wait for SaaS instance to enable deletion diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index d694d63..79e701b 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -1,9 +1 @@ # Unreleased - -This release changes the precondition for deleting a SaaS database instance. - -Before, the SAPIPY used only a fixed waiting time. With the changed behavior of the actual SaaS instances the precondition needed to be adapted. SAPIPY now waits for the SaaS instance to be running before attempting to delete it. - -## Bugfixes - -* #125: Made operation delete wait for SaaS instance to enable deletion diff --git a/pyproject.toml b/pyproject.toml index d51b347..ca36aac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "exasol-saas-api" -version = "2.5.0" +version = "2.6.0" requires-python = ">=3.10.0,<4.0" description = "API enabling Python applications connecting to Exasol database SaaS instances and using their SaaS services" authors = [ diff --git a/version.py b/version.py index 05ae1c5..d182308 100644 --- a/version.py +++ b/version.py @@ -9,7 +9,7 @@ """ MAJOR = 2 -MINOR = 5 +MINOR = 6 PATCH = 0 VERSION = f"{MAJOR}.{MINOR}.{PATCH}" __version__ = VERSION From da2da36e37b25e7c0bd68a3ace6b2624d2f30f96 Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 8 Dec 2025 12:54:15 +0100 Subject: [PATCH 04/33] Added wait for delete Marked all tests als slow --- exasol/saas/client/api_access.py | 82 ++++++++++++++++++++++++----- test/integration/allowed_ip_test.py | 3 ++ test/integration/databases_test.py | 3 ++ 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 808a2c8..e2dddfc 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -14,8 +14,8 @@ from tenacity import ( TryAgain, - retry, ) +import tenacity from tenacity.stop import stop_after_delay from tenacity.wait import wait_fixed @@ -38,6 +38,7 @@ delete_allowed_ip, list_allowed_i_ps, ) +from exasol.saas.client.openapi.models.exasol_database import ExasolDatabase from exasol.saas.client.openapi.models.status import Status from exasol.saas.client.openapi.types import UNSET @@ -45,6 +46,13 @@ LOG.setLevel(logging.INFO) +def interval_retry(interval: timedelta, timeout: timedelta): + return tenacity.retry( + wait=wait_fixed(interval), + stop=stop_after_delay(timeout) + ) + + def timestamp_name(project_short_tag: str | None = None) -> str: """ project_short_tag: Abbreviation of your project @@ -55,6 +63,16 @@ def timestamp_name(project_short_tag: str | None = None) -> str: return candidate[: Limits.MAX_DATABASE_NAME_LENGTH] +def wait_for_delete_clearance(start: dt.datetime): + lifetime = datetime.now() - start + if lifetime < Limits.MIN_DATABASE_LIFETIME: + wait = Limits.MIN_DATABASE_LIFETIME - lifetime + LOG.info( + f"Waiting {int(wait.seconds)} seconds" " before deleting the database." + ) + time.sleep(wait.seconds) + + class DatabaseStartupFailure(Exception): """ If a SaaS database instance during startup reports a status other than @@ -192,7 +210,7 @@ def create_database( cluster_size: str = "XS", region: str = "eu-central-1", idle_time: timedelta | None = None, - ) -> openapi.models.exasol_database.ExasolDatabase | None: + ) -> ExasolDatabase | None: def minutes(x: timedelta) -> int: return x.seconds // 60 @@ -231,7 +249,7 @@ def wait_until_deleted( timeout: timedelta = timedelta(seconds=1), interval: timedelta = timedelta(minutes=1), ): - @retry(wait=wait_fixed(interval), stop=stop_after_delay(timeout)) + @interval_retry(interval, timeout) def still_exists() -> bool: result = database_id in self.list_database_ids() if result: @@ -241,9 +259,52 @@ def still_exists() -> bool: if still_exists(): raise DatabaseDeleteTimeout + def _wait_for_delete(self, database_id: str): + deletable = [ + Status.RUNNING, + Status.SCALING, + Status.STOPPED, + Status.STOPPING, + Status.TODELETE, + Status.TOSCALE, + Status.TOSTOP, + ] + + @interval_retry(timedelta(seconds=1), timeout=timedelta(minutes=1)) + def find_existing(id: str) -> ExasolDatabase: + if result := self.get_database(id): + return result + raise TryAgain + + @interval_retry(timedelta(minutes=2), timeout=timedelta(minutes=30)) + def poll_status(db: ExasolDatabase) -> Status: + if db.status in deletable: + return db.status + LOG.info("- Database status: %s ...", db.status) + raise TryAgain + + db = find_existing(database_id) + if not db: + raise DatabaseDeleteTimeout( + "Couldn't find database with ID %s", + database_id, + ) + + if not poll_status(db): + raise DatabaseDeleteTimeout( + "Cannot delete Database in state %s", + db.status, + ) + def delete_database(self, database_id: str, ignore_failures=False): + try: + self._wait_for_delete(database_id) + except DatabaseDeleteTimeout: + if ignore_failures: + LOG.exception() + else: + raise with self._ignore_failures(ignore_failures) as client: - self.wait_until_running(database_id) return delete_database.sync_detailed( self._account_id, database_id, client=client ) @@ -279,10 +340,7 @@ def database( else: LOG.info(f"Keeping database {db_repr} as keep = {keep}") - def get_database( - self, - database_id: str, - ) -> openapi.models.exasol_database.ExasolDatabase | None: + def get_database(self, database_id: str) -> ExasolDatabase | None: return get_database.sync( self._account_id, database_id, @@ -299,15 +357,15 @@ def wait_until_running( Status.RUNNING, ] - @retry(wait=wait_fixed(interval), stop=stop_after_delay(timeout)) - def poll_status(): - db = self.get_database(database_id) + @interval_retry(interval, timeout) + def poll_status(db: ExasolDatabase) -> Status: if db.status not in success: LOG.info("- Database status: %s ...", db.status) raise TryAgain return db.status - if poll_status() not in success: + db = self.get_database(database_id) + if not db or poll_status(db) not in success: raise DatabaseStartupFailure() def clusters( diff --git a/test/integration/allowed_ip_test.py b/test/integration/allowed_ip_test.py index 1aa5472..79bbfb2 100644 --- a/test/integration/allowed_ip_test.py +++ b/test/integration/allowed_ip_test.py @@ -1,3 +1,6 @@ +import pytest + +@pytest.mark.slow def test_lifecycle(api_access): testee = api_access with testee.allowed_ip(ignore_delete_failure=True) as ip: diff --git a/test/integration/databases_test.py b/test/integration/databases_test.py index f3b14a8..c6c1ef5 100644 --- a/test/integration/databases_test.py +++ b/test/integration/databases_test.py @@ -12,6 +12,7 @@ ) +@pytest.mark.slow def test_lifecycle(api_access, database_name): """ This integration test uses the database created and provided by pytest @@ -38,6 +39,7 @@ def test_lifecycle(api_access, database_name): assert db.id not in testee.list_database_ids() +@pytest.mark.slow def test_poll(api_access, database_name): with api_access.database(database_name) as db: with pytest.raises(RetryError): @@ -48,6 +50,7 @@ def test_poll(api_access, database_name): ) +@pytest.mark.slow def test_get_connection(api_access, database_name): with api_access.database(database_name) as db: clusters = api_access.clusters(db.id) From 37629cb3782b6409e39895dae47d3ab8e5132c58 Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 8 Dec 2025 12:55:21 +0100 Subject: [PATCH 05/33] project:fix --- exasol/saas/client/api_access.py | 7 ++----- test/integration/allowed_ip_test.py | 1 + 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index e2dddfc..3d1aad8 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -12,10 +12,10 @@ ) from typing import Any +import tenacity from tenacity import ( TryAgain, ) -import tenacity from tenacity.stop import stop_after_delay from tenacity.wait import wait_fixed @@ -47,10 +47,7 @@ def interval_retry(interval: timedelta, timeout: timedelta): - return tenacity.retry( - wait=wait_fixed(interval), - stop=stop_after_delay(timeout) - ) + return tenacity.retry(wait=wait_fixed(interval), stop=stop_after_delay(timeout)) def timestamp_name(project_short_tag: str | None = None) -> str: diff --git a/test/integration/allowed_ip_test.py b/test/integration/allowed_ip_test.py index 79bbfb2..8b92637 100644 --- a/test/integration/allowed_ip_test.py +++ b/test/integration/allowed_ip_test.py @@ -1,5 +1,6 @@ import pytest + @pytest.mark.slow def test_lifecycle(api_access): testee = api_access From b6d2a9794d6a9d63f4edc8db54b2ec338d7aedce Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 8 Dec 2025 13:03:37 +0100 Subject: [PATCH 06/33] Fixed type error --- exasol/saas/client/api_access.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 3d1aad8..f5ea4f5 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -298,7 +298,7 @@ def delete_database(self, database_id: str, ignore_failures=False): self._wait_for_delete(database_id) except DatabaseDeleteTimeout: if ignore_failures: - LOG.exception() + LOG.exception("Failed waiting for delete preconditions") else: raise with self._ignore_failures(ignore_failures) as client: From 1ea900359c23f52846486545b602fa04f6ef2e7b Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 8 Dec 2025 14:24:59 +0100 Subject: [PATCH 07/33] Modified tests --- exasol/saas/client/api_access.py | 13 +++++++------ test/integration/conftest.py | 4 ++-- test/integration/databases_test.py | 30 ++++++++++++++++++++++++------ 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index f5ea4f5..faee4a6 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -65,7 +65,8 @@ def wait_for_delete_clearance(start: dt.datetime): if lifetime < Limits.MIN_DATABASE_LIFETIME: wait = Limits.MIN_DATABASE_LIFETIME - lifetime LOG.info( - f"Waiting {int(wait.seconds)} seconds" " before deleting the database." + "Waiting %d seconds before deleting the database.", + int(wait.seconds) ) time.sleep(wait.seconds) @@ -220,7 +221,7 @@ def minutes(x: timedelta) -> int: idle_time=minutes(idle_time), ), ) - LOG.info(f"Creating database {name}") + LOG.info("Creating database %s", name) return create_database.sync( self._account_id, client=self._client, @@ -326,16 +327,16 @@ def database( finally: db_repr = f"{db.name} with ID {db.id}" if db else None if db and not keep: - LOG.info(f"Deleting database {db_repr}") + LOG.info("Deleting database %s", db_repr) response = self.delete_database(db.id, ignore_delete_failure) if response.status_code == 200: - LOG.info(f"Successfully deleted database {db_repr}.") + LOG.info("Successfully deleted database %s.", db_repr) else: - LOG.warning(f"Ignoring status code {response.status_code}.") + LOG.warning("Ignoring status code %s.", response.status_code) elif not db: LOG.warning("Cannot delete db None") else: - LOG.info(f"Keeping database {db_repr} as keep = {keep}") + LOG.info("Keeping database %s as keep = %s.", db_repr, keep) def get_database(self, database_id: str) -> ExasolDatabase | None: return get_database.sync( diff --git a/test/integration/conftest.py b/test/integration/conftest.py index b8b2228..dbb3bb2 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -59,12 +59,12 @@ def operational_saas_database_id(api_access, database_name) -> str: @pytest.fixture(scope="session") -def project_short_tag(): +def project_short_tag() -> str | None: return os.environ.get("PROJECT_SHORT_TAG") @pytest.fixture(scope="session") -def database_name(project_short_tag): +def database_name(project_short_tag) -> str: return timestamp_name(project_short_tag) diff --git a/test/integration/databases_test.py b/test/integration/databases_test.py index c6c1ef5..a07d3de 100644 --- a/test/integration/databases_test.py +++ b/test/integration/databases_test.py @@ -12,8 +12,18 @@ ) +@pytest.fixture +def local_name(project_short_tag: str | None) -> str: + """ + Other than global fixture database_name this fixture uses scope + "function" to generate an individual name for each test case in this file. + """ + return timestamp_name(project_short_tag) + + @pytest.mark.slow -def test_lifecycle(api_access, database_name): +@pytest.mark.skip +def test_lifecycle(api_access, local_name): """ This integration test uses the database created and provided by pytest context ``_OpenApiAccess.database()`` to verify @@ -25,7 +35,7 @@ def test_lifecycle(api_access, database_name): """ testee = api_access - with testee.database(database_name, ignore_delete_failure=True) as db: + with testee.database(local_name, ignore_delete_failure=True) as db: start = datetime.now() # verify state and clusters of created database assert db.status in PROMISING_STATES and db.clusters.total == 1 @@ -40,8 +50,8 @@ def test_lifecycle(api_access, database_name): @pytest.mark.slow -def test_poll(api_access, database_name): - with api_access.database(database_name) as db: +def test_poll(api_access, local_name): + with api_access.database(local_name) as db: with pytest.raises(RetryError): api_access.wait_until_running( db.id, @@ -51,8 +61,16 @@ def test_poll(api_access, database_name): @pytest.mark.slow -def test_get_connection(api_access, database_name): - with api_access.database(database_name) as db: +def test_get_connection(api_access, local_name): + with api_access.database(local_name) as db: clusters = api_access.clusters(db.id) connection = api_access.get_connection(db.id, clusters[0].id) assert connection.db_username is not None and connection.port == 8563 + + +def test_dummy(): + """ + Dummy test to prevent empty set of test cases to be interpreted as + failure. + """ + pass From dc4d3f172ee20ea069092554c9e417112114e55e Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 8 Dec 2025 14:26:19 +0100 Subject: [PATCH 08/33] project:fix --- exasol/saas/client/api_access.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index faee4a6..543e4bb 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -64,10 +64,7 @@ def wait_for_delete_clearance(start: dt.datetime): lifetime = datetime.now() - start if lifetime < Limits.MIN_DATABASE_LIFETIME: wait = Limits.MIN_DATABASE_LIFETIME - lifetime - LOG.info( - "Waiting %d seconds before deleting the database.", - int(wait.seconds) - ) + LOG.info("Waiting %d seconds before deleting the database.", int(wait.seconds)) time.sleep(wait.seconds) From 63aeff826940dcb2dc83c2ab87413ca5d5684d8a Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 8 Dec 2025 15:09:09 +0100 Subject: [PATCH 09/33] Fixed tests --- exasol/saas/client/api_access.py | 5 +++++ test/integration/connection_test.py | 2 -- test/integration/databases_test.py | 9 +-------- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 543e4bb..3394a2d 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -255,6 +255,10 @@ def still_exists() -> bool: raise DatabaseDeleteTimeout def _wait_for_delete(self, database_id: str): + # if http + # "status": 400, + # "message": "Operation is not allowed:The cluster is not in a proper state!", + # then wait a bit an retry deletable = [ Status.RUNNING, Status.SCALING, @@ -321,6 +325,7 @@ def database( try: db = self.create_database(name, idle_time=idle_time) yield db + wait_for_delete_clearance() finally: db_repr = f"{db.name} with ID {db.id}" if db else None if db and not keep: diff --git a/test/integration/connection_test.py b/test/integration/connection_test.py index 0f6695f..4330014 100644 --- a/test/integration/connection_test.py +++ b/test/integration/connection_test.py @@ -4,7 +4,6 @@ from exasol.saas.client.api_access import get_connection_params -@pytest.mark.slow def test_get_connection_params_with_id( saas_host, saas_pat, saas_account_id, operational_saas_database_id, allow_connection ): @@ -23,7 +22,6 @@ def test_get_connection_params_with_id( assert result == [(1,)] -@pytest.mark.slow def test_get_connection_params_with_name( saas_host, saas_pat, diff --git a/test/integration/databases_test.py b/test/integration/databases_test.py index a07d3de..8e2666c 100644 --- a/test/integration/databases_test.py +++ b/test/integration/databases_test.py @@ -9,6 +9,7 @@ from exasol.saas.client import PROMISING_STATES from exasol.saas.client.api_access import ( get_connection_params, + timestamp_name, ) @@ -66,11 +67,3 @@ def test_get_connection(api_access, local_name): clusters = api_access.clusters(db.id) connection = api_access.get_connection(db.id, clusters[0].id) assert connection.db_username is not None and connection.port == 8563 - - -def test_dummy(): - """ - Dummy test to prevent empty set of test cases to be interpreted as - failure. - """ - pass From 7180fc067594c63cd7b862078587466050373f0e Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 8 Dec 2025 15:44:59 +0100 Subject: [PATCH 10/33] Fixed tests 2 --- exasol/saas/client/api_access.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 3394a2d..5f18fe7 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -325,7 +325,7 @@ def database( try: db = self.create_database(name, idle_time=idle_time) yield db - wait_for_delete_clearance() + # wait_for_delete_clearance(start) finally: db_repr = f"{db.name} with ID {db.id}" if db else None if db and not keep: From 7875c4c1c9ef6d172446652252e4de18aad17a6b Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 8 Dec 2025 17:28:31 +0100 Subject: [PATCH 11/33] Fixed tests 3 --- exasol/saas/client/api_access.py | 145 ++++++++++++++++++------------- 1 file changed, 86 insertions(+), 59 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 5f18fe7..52a83ad 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -3,6 +3,7 @@ import datetime as dt import getpass import logging +import re import time from collections.abc import Iterable from contextlib import contextmanager @@ -14,6 +15,7 @@ import tenacity from tenacity import ( + RetryError, TryAgain, ) from tenacity.stop import stop_after_delay @@ -23,6 +25,8 @@ Limits, openapi, ) + +# from exasol.saas.client.openapi.errors import UnexpectedStatus from exasol.saas.client.openapi.api.clusters import ( get_cluster_connection, list_clusters, @@ -60,12 +64,12 @@ def timestamp_name(project_short_tag: str | None = None) -> str: return candidate[: Limits.MAX_DATABASE_NAME_LENGTH] -def wait_for_delete_clearance(start: dt.datetime): - lifetime = datetime.now() - start - if lifetime < Limits.MIN_DATABASE_LIFETIME: - wait = Limits.MIN_DATABASE_LIFETIME - lifetime - LOG.info("Waiting %d seconds before deleting the database.", int(wait.seconds)) - time.sleep(wait.seconds) +# def wait_for_delete_clearance(start: dt.datetime): +# lifetime = datetime.now() - start +# if lifetime < Limits.MIN_DATABASE_LIFETIME: +# wait = Limits.MIN_DATABASE_LIFETIME - lifetime +# LOG.info("Waiting %d seconds before deleting the database.", int(wait.seconds)) +# time.sleep(wait.seconds) class DatabaseStartupFailure(Exception): @@ -254,59 +258,84 @@ def still_exists() -> bool: if still_exists(): raise DatabaseDeleteTimeout - def _wait_for_delete(self, database_id: str): - # if http - # "status": 400, - # "message": "Operation is not allowed:The cluster is not in a proper state!", - # then wait a bit an retry - deletable = [ - Status.RUNNING, - Status.SCALING, - Status.STOPPED, - Status.STOPPING, - Status.TODELETE, - Status.TOSCALE, - Status.TOSTOP, - ] - - @interval_retry(timedelta(seconds=1), timeout=timedelta(minutes=1)) - def find_existing(id: str) -> ExasolDatabase: - if result := self.get_database(id): - return result - raise TryAgain - - @interval_retry(timedelta(minutes=2), timeout=timedelta(minutes=30)) - def poll_status(db: ExasolDatabase) -> Status: - if db.status in deletable: - return db.status - LOG.info("- Database status: %s ...", db.status) - raise TryAgain - - db = find_existing(database_id) - if not db: - raise DatabaseDeleteTimeout( - "Couldn't find database with ID %s", - database_id, - ) - - if not poll_status(db): - raise DatabaseDeleteTimeout( - "Cannot delete Database in state %s", - db.status, + # def _wait_for_delete(self, database_id: str): + # deletable = [ + # Status.RUNNING, + # Status.SCALING, + # Status.STOPPED, + # Status.STOPPING, + # Status.TODELETE, + # Status.TOSCALE, + # Status.TOSTOP, + # ] + # + # @interval_retry(timedelta(seconds=1), timeout=timedelta(minutes=1)) + # def find_existing(id: str) -> ExasolDatabase: + # if result := self.get_database(id): + # return result + # raise TryAgain + # + # @interval_retry(timedelta(minutes=2), timeout=timedelta(minutes=30)) + # def poll_status(db: ExasolDatabase) -> Status: + # if db.status in deletable: + # return db.status + # LOG.info("- Database status: %s ...", db.status) + # raise TryAgain + # + # db = find_existing(database_id) + # if not db: + # raise DatabaseDeleteTimeout( + # "Couldn't find database with ID %s", + # database_id, + # ) + # + # if not poll_status(db): + # raise DatabaseDeleteTimeout( + # "Cannot delete Database in state %s", + # db.status, + # ) + + def delete_database( + self, + database_id: str, + ignore_failures=False, + ) -> openapi.types.Response: + pattern = re.compile("Operation.*not allowed.*cluster not.*in proper state") + + @interval_retry(timedelta(seconds=5), timeout=timedelta(minutes=5)) + def _delete(client: openapi.AuthenticatedClient) -> openapi.types.Response: + response = delete_database.sync_detailed( + self._account_id, database_id, client=client, ) + message = response.content.decode("utf-8") + if response.status_code == 400 and pattern.search(message): + raise TryAgain + return response - def delete_database(self, database_id: str, ignore_failures=False): try: - self._wait_for_delete(database_id) - except DatabaseDeleteTimeout: - if ignore_failures: - LOG.exception("Failed waiting for delete preconditions") - else: - raise - with self._ignore_failures(ignore_failures) as client: - return delete_database.sync_detailed( - self._account_id, database_id, client=client - ) + with self._ignore_failures(ignore_failures) as client: + response = _delete(client) + except RetryError as ex: + if not ignore_failures: + raise DatabaseDeleteTimeout( + "Failed to delete database with ID %s: HTTP %s, %s.", + database_id, response.status_code, response.content + ) + + return response + + # def delete_database_old(self, database_id: str, ignore_failures=False): + # try: + # self._wait_for_delete(database_id) + # except DatabaseDeleteTimeout: + # if ignore_failures: + # LOG.exception("Failed waiting for delete preconditions") + # else: + # raise + # with self._ignore_failures(ignore_failures) as client: + # return delete_database.sync_detailed( + # self._account_id, database_id, client=client + # ) def list_database_ids(self) -> Iterable[str]: dbs = list_databases.sync(self._account_id, client=self._client) or [] @@ -353,9 +382,7 @@ def wait_until_running( timeout: timedelta = timedelta(minutes=30), interval: timedelta = timedelta(minutes=2), ): - success = [ - Status.RUNNING, - ] + success = [Status.RUNNING] @interval_retry(interval, timeout) def poll_status(db: ExasolDatabase) -> Status: From faa125e842b244b9467e8cc8b6fe51db6133b5d9 Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 8 Dec 2025 17:28:56 +0100 Subject: [PATCH 12/33] project:fix --- exasol/saas/client/api_access.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 52a83ad..210b44c 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -305,7 +305,9 @@ def delete_database( @interval_retry(timedelta(seconds=5), timeout=timedelta(minutes=5)) def _delete(client: openapi.AuthenticatedClient) -> openapi.types.Response: response = delete_database.sync_detailed( - self._account_id, database_id, client=client, + self._account_id, + database_id, + client=client, ) message = response.content.decode("utf-8") if response.status_code == 400 and pattern.search(message): @@ -319,7 +321,9 @@ def _delete(client: openapi.AuthenticatedClient) -> openapi.types.Response: if not ignore_failures: raise DatabaseDeleteTimeout( "Failed to delete database with ID %s: HTTP %s, %s.", - database_id, response.status_code, response.content + database_id, + response.status_code, + response.content, ) return response From 5e4bb901db78104503994b137e5688e95cb260ad Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 8 Dec 2025 18:00:45 +0100 Subject: [PATCH 13/33] Fixed tests 4 --- exasol/saas/client/api_access.py | 24 +++++++++++++++++------- test/integration/databases_test.py | 6 ++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 210b44c..01bbfca 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -49,6 +49,12 @@ LOG = logging.getLogger(__name__) LOG.setLevel(logging.INFO) +logging.basicConfig( + level=logging.INFO, + datefmt="[%X]", + format="%(asctime)s [%(levelname)s] %(message)s", + ) + def interval_retry(interval: timedelta, timeout: timedelta): return tenacity.retry(wait=wait_fixed(interval), stop=stop_after_delay(timeout)) @@ -58,7 +64,7 @@ def timestamp_name(project_short_tag: str | None = None) -> str: """ project_short_tag: Abbreviation of your project """ - timestamp = f"{datetime.now().timestamp():.0f}" + timestamp = f"{datetime.utcnow().timestamp():.0f}" owner = getpass.getuser() candidate = f"{timestamp}{project_short_tag or ''}-{owner}" return candidate[: Limits.MAX_DATABASE_NAME_LENGTH] @@ -309,11 +315,14 @@ def _delete(client: openapi.AuthenticatedClient) -> openapi.types.Response: database_id, client=client, ) + # creating 1765211656-chku Mon 12-08 17:34 message = response.content.decode("utf-8") if response.status_code == 400 and pattern.search(message): + LOG.info("- Response: %s %s", response.status_code, message) raise TryAgain return response + LOG.info("Deleting database with ID %s ...", database_id) try: with self._ignore_failures(ignore_failures) as client: response = _delete(client) @@ -389,14 +398,15 @@ def wait_until_running( success = [Status.RUNNING] @interval_retry(interval, timeout) - def poll_status(db: ExasolDatabase) -> Status: - if db.status not in success: - LOG.info("- Database status: %s ...", db.status) + def poll_status() -> Status: + db = self.get_database(database_id) + status = db.status if db else None + if status not in success: + LOG.info("- Database status: %s ...", status) raise TryAgain - return db.status + return status - db = self.get_database(database_id) - if not db or poll_status(db) not in success: + if poll_status() not in success: raise DatabaseStartupFailure() def clusters( diff --git a/test/integration/databases_test.py b/test/integration/databases_test.py index 8e2666c..6700461 100644 --- a/test/integration/databases_test.py +++ b/test/integration/databases_test.py @@ -67,3 +67,9 @@ def test_get_connection(api_access, local_name): clusters = api_access.clusters(db.id) connection = api_access.get_connection(db.id, clusters[0].id) assert connection.db_username is not None and connection.port == 8563 + + +def test_x2(api_access, local_name): + with api_access.database(local_name) as db: + api_access.wait_until_running(db.id) + print(f'{db.id}') From ce0b67c36019ad40a78d03955b367d2676d69786 Mon Sep 17 00:00:00 2001 From: ckunki Date: Mon, 8 Dec 2025 18:04:40 +0100 Subject: [PATCH 14/33] project:fix --- exasol/saas/client/api_access.py | 2 +- test/integration/databases_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 01bbfca..26f9fa9 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -53,7 +53,7 @@ level=logging.INFO, datefmt="[%X]", format="%(asctime)s [%(levelname)s] %(message)s", - ) +) def interval_retry(interval: timedelta, timeout: timedelta): diff --git a/test/integration/databases_test.py b/test/integration/databases_test.py index 6700461..c110619 100644 --- a/test/integration/databases_test.py +++ b/test/integration/databases_test.py @@ -72,4 +72,4 @@ def test_get_connection(api_access, local_name): def test_x2(api_access, local_name): with api_access.database(local_name) as db: api_access.wait_until_running(db.id) - print(f'{db.id}') + print(f"{db.id}") From ae24e7f7c05f8214874129859cbded60a8ed1b85 Mon Sep 17 00:00:00 2001 From: ckunki Date: Tue, 9 Dec 2025 09:35:45 +0100 Subject: [PATCH 15/33] Fixed tests 5 --- exasol/saas/client/api_access.py | 156 ++++++++++++++++--------------- 1 file changed, 81 insertions(+), 75 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 26f9fa9..0028bba 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -26,7 +26,7 @@ openapi, ) -# from exasol.saas.client.openapi.errors import UnexpectedStatus +from exasol.saas.client.openapi.errors import UnexpectedStatus from exasol.saas.client.openapi.api.clusters import ( get_cluster_connection, list_clusters, @@ -70,6 +70,24 @@ def timestamp_name(project_short_tag: str | None = None) -> str: return candidate[: Limits.MAX_DATABASE_NAME_LENGTH] +RETRY_PATTERN = re.compile( + "Operation.*not allowed.*cluster.*not.*in.*proper state" +) + + +def indicates_retry(ex: Exception) -> bool: + """ + Check whether an the specified exception raised during deleting a + database instance indicates to retry deletion. + """ + # LOG.info(f"indicates_retry: {ex}") + return bool( + isinstance(ex, UnexpectedStatus) + and ex.status_code == 400 + and RETRY_PATTERN.search(ex.content.decode("utf-8")) + ) + + # def wait_for_delete_clearance(start: dt.datetime): # lifetime = datetime.now() - start # if lifetime < Limits.MIN_DATABASE_LIFETIME: @@ -264,78 +282,69 @@ def still_exists() -> bool: if still_exists(): raise DatabaseDeleteTimeout - # def _wait_for_delete(self, database_id: str): - # deletable = [ - # Status.RUNNING, - # Status.SCALING, - # Status.STOPPED, - # Status.STOPPING, - # Status.TODELETE, - # Status.TOSCALE, - # Status.TOSTOP, - # ] - # - # @interval_retry(timedelta(seconds=1), timeout=timedelta(minutes=1)) - # def find_existing(id: str) -> ExasolDatabase: - # if result := self.get_database(id): - # return result - # raise TryAgain - # - # @interval_retry(timedelta(minutes=2), timeout=timedelta(minutes=30)) - # def poll_status(db: ExasolDatabase) -> Status: - # if db.status in deletable: - # return db.status - # LOG.info("- Database status: %s ...", db.status) - # raise TryAgain - # - # db = find_existing(database_id) - # if not db: - # raise DatabaseDeleteTimeout( - # "Couldn't find database with ID %s", - # database_id, - # ) - # - # if not poll_status(db): - # raise DatabaseDeleteTimeout( - # "Cannot delete Database in state %s", - # db.status, - # ) - def delete_database( self, database_id: str, - ignore_failures=False, + ignore_failures: bool = False, + timeout: timedelta = timedelta(minutes=5), + interval: timedelta = timedelta(seconds=1), ) -> openapi.types.Response: - pattern = re.compile("Operation.*not allowed.*cluster not.*in proper state") - - @interval_retry(timedelta(seconds=5), timeout=timedelta(minutes=5)) - def _delete(client: openapi.AuthenticatedClient) -> openapi.types.Response: - response = delete_database.sync_detailed( - self._account_id, - database_id, - client=client, - ) - # creating 1765211656-chku Mon 12-08 17:34 - message = response.content.decode("utf-8") - if response.status_code == 400 and pattern.search(message): - LOG.info("- Response: %s %s", response.status_code, message) - raise TryAgain - return response + @retry( + reraise=True, + wait=wait_fixed(interval), + stop=stop_after_delay(timeout), + retry=retry_if_exception(indicates_retry), + ) + def delete_with_retry(): + delete_database.sync_detailed(self._account_id, database_id) - LOG.info("Deleting database with ID %s ...", database_id) try: - with self._ignore_failures(ignore_failures) as client: - response = _delete(client) - except RetryError as ex: - if not ignore_failures: - raise DatabaseDeleteTimeout( - "Failed to delete database with ID %s: HTTP %s, %s.", + delete_with_retry() + except Exception as ex: + if ignore_failures: + LOG.error( + f"Ignoring failure when deleting database with ID %s: %s", database_id, - response.status_code, - response.content, + ex, ) - - return response + else: + raise + + # def delete_database2( + # self, + # database_id: str, + # ignore_failures=False, + # ) -> openapi.types.Response: + # pattern = re.compile("Operation.*not allowed.*cluster not.*in proper state") + # + # @interval_retry(timedelta(seconds=5), timeout=timedelta(minutes=5)) + # def _delete(client: openapi.AuthenticatedClient) -> openapi.types.Response: + # response = delete_database.sync_detailed( + # self._account_id, + # database_id, + # client=client, + # ) + # # creating 1765211656-chku Mon 12-08 17:34 + # message = response.content.decode("utf-8") + # if response.status_code == 400 and pattern.search(message): + # LOG.info("- Response: %s %s", response.status_code, message) + # raise TryAgain + # return response + # + # LOG.info("Deleting database with ID %s ...", database_id) + # try: + # with self._ignore_failures(ignore_failures) as client: + # response = _delete(client) + # except RetryError as ex: + # if not ignore_failures: + # raise DatabaseDeleteTimeout( + # "Failed to delete database with ID %s: HTTP %s, %s.", + # database_id, + # response.status_code, + # response.content, + # ) + # + # return response # def delete_database_old(self, database_id: str, ignore_failures=False): # try: @@ -370,17 +379,14 @@ def database( # wait_for_delete_clearance(start) finally: db_repr = f"{db.name} with ID {db.id}" if db else None - if db and not keep: - LOG.info("Deleting database %s", db_repr) - response = self.delete_database(db.id, ignore_delete_failure) - if response.status_code == 200: - LOG.info("Successfully deleted database %s.", db_repr) - else: - LOG.warning("Ignoring status code %s.", response.status_code) - elif not db: - LOG.warning("Cannot delete db None") - else: + if not db: + LOG.warning("Cannot delete database None") + elif keep: LOG.info("Keeping database %s as keep = %s.", db_repr, keep) + else: + LOG.info("Deleting database %s", db_repr) + self.delete_database(db.id, ignore_delete_failure) + LOG.info("Successfully deleted database %s.", db_repr) def get_database(self, database_id: str) -> ExasolDatabase | None: return get_database.sync( From 7f59a81bdc74bb35c0b5d5ec6bb1c2c27088ef78 Mon Sep 17 00:00:00 2001 From: ckunki Date: Tue, 9 Dec 2025 09:40:15 +0100 Subject: [PATCH 16/33] Fixed mypy errors --- exasol/saas/client/api_access.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 0028bba..963d538 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -15,11 +15,12 @@ import tenacity from tenacity import ( - RetryError, TryAgain, + retry, ) from tenacity.stop import stop_after_delay from tenacity.wait import wait_fixed +from tenacity.retry import retry_if_exception from exasol.saas.client import ( Limits, @@ -27,6 +28,7 @@ ) from exasol.saas.client.openapi.errors import UnexpectedStatus + from exasol.saas.client.openapi.api.clusters import ( get_cluster_connection, list_clusters, @@ -75,12 +77,11 @@ def timestamp_name(project_short_tag: str | None = None) -> str: ) -def indicates_retry(ex: Exception) -> bool: +def indicates_retry(ex: BaseException) -> bool: """ Check whether an the specified exception raised during deleting a database instance indicates to retry deletion. """ - # LOG.info(f"indicates_retry: {ex}") return bool( isinstance(ex, UnexpectedStatus) and ex.status_code == 400 @@ -288,7 +289,7 @@ def delete_database( ignore_failures: bool = False, timeout: timedelta = timedelta(minutes=5), interval: timedelta = timedelta(seconds=1), - ) -> openapi.types.Response: + ) -> None: @retry( reraise=True, wait=wait_fixed(interval), From 9b4536f51c8e0c0196eab571aa5d5e6717cf2f23 Mon Sep 17 00:00:00 2001 From: ckunki Date: Tue, 9 Dec 2025 10:08:49 +0100 Subject: [PATCH 17/33] project:fix --- exasol/saas/client/api_access.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 963d538..e16489d 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -18,17 +18,14 @@ TryAgain, retry, ) +from tenacity.retry import retry_if_exception from tenacity.stop import stop_after_delay from tenacity.wait import wait_fixed -from tenacity.retry import retry_if_exception from exasol.saas.client import ( Limits, openapi, ) - -from exasol.saas.client.openapi.errors import UnexpectedStatus - from exasol.saas.client.openapi.api.clusters import ( get_cluster_connection, list_clusters, @@ -44,6 +41,7 @@ delete_allowed_ip, list_allowed_i_ps, ) +from exasol.saas.client.openapi.errors import UnexpectedStatus from exasol.saas.client.openapi.models.exasol_database import ExasolDatabase from exasol.saas.client.openapi.models.status import Status from exasol.saas.client.openapi.types import UNSET @@ -72,9 +70,7 @@ def timestamp_name(project_short_tag: str | None = None) -> str: return candidate[: Limits.MAX_DATABASE_NAME_LENGTH] -RETRY_PATTERN = re.compile( - "Operation.*not allowed.*cluster.*not.*in.*proper state" -) +RETRY_PATTERN = re.compile("Operation.*not allowed.*cluster.*not.*in.*proper state") def indicates_retry(ex: BaseException) -> bool: From 41ecbcd4a980ccdfab88088e04b5344bfc9e86b6 Mon Sep 17 00:00:00 2001 From: ckunki Date: Tue, 9 Dec 2025 11:03:12 +0100 Subject: [PATCH 18/33] Fixed pyexasol connection to use websocket_sslopt={"cert_reqs": ssl.CERT_NONE} --- exasol/saas/client/api_access.py | 58 ----------------------------- test/integration/connection_test.py | 1 + 2 files changed, 1 insertion(+), 58 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index e16489d..a3fd1d3 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -85,14 +85,6 @@ def indicates_retry(ex: BaseException) -> bool: ) -# def wait_for_delete_clearance(start: dt.datetime): -# lifetime = datetime.now() - start -# if lifetime < Limits.MIN_DATABASE_LIFETIME: -# wait = Limits.MIN_DATABASE_LIFETIME - lifetime -# LOG.info("Waiting %d seconds before deleting the database.", int(wait.seconds)) -# time.sleep(wait.seconds) - - class DatabaseStartupFailure(Exception): """ If a SaaS database instance during startup reports a status other than @@ -307,55 +299,6 @@ def delete_with_retry(): else: raise - # def delete_database2( - # self, - # database_id: str, - # ignore_failures=False, - # ) -> openapi.types.Response: - # pattern = re.compile("Operation.*not allowed.*cluster not.*in proper state") - # - # @interval_retry(timedelta(seconds=5), timeout=timedelta(minutes=5)) - # def _delete(client: openapi.AuthenticatedClient) -> openapi.types.Response: - # response = delete_database.sync_detailed( - # self._account_id, - # database_id, - # client=client, - # ) - # # creating 1765211656-chku Mon 12-08 17:34 - # message = response.content.decode("utf-8") - # if response.status_code == 400 and pattern.search(message): - # LOG.info("- Response: %s %s", response.status_code, message) - # raise TryAgain - # return response - # - # LOG.info("Deleting database with ID %s ...", database_id) - # try: - # with self._ignore_failures(ignore_failures) as client: - # response = _delete(client) - # except RetryError as ex: - # if not ignore_failures: - # raise DatabaseDeleteTimeout( - # "Failed to delete database with ID %s: HTTP %s, %s.", - # database_id, - # response.status_code, - # response.content, - # ) - # - # return response - - # def delete_database_old(self, database_id: str, ignore_failures=False): - # try: - # self._wait_for_delete(database_id) - # except DatabaseDeleteTimeout: - # if ignore_failures: - # LOG.exception("Failed waiting for delete preconditions") - # else: - # raise - # with self._ignore_failures(ignore_failures) as client: - # return delete_database.sync_detailed( - # self._account_id, database_id, client=client - # ) - def list_database_ids(self) -> Iterable[str]: dbs = list_databases.sync(self._account_id, client=self._client) or [] return (db.id for db in dbs) @@ -373,7 +316,6 @@ def database( try: db = self.create_database(name, idle_time=idle_time) yield db - # wait_for_delete_clearance(start) finally: db_repr = f"{db.name} with ID {db.id}" if db else None if not db: diff --git a/test/integration/connection_test.py b/test/integration/connection_test.py index 4330014..1ecbec3 100644 --- a/test/integration/connection_test.py +++ b/test/integration/connection_test.py @@ -16,6 +16,7 @@ def test_get_connection_params_with_id( account_id=saas_account_id, pat=saas_pat, database_id=operational_saas_database_id, + websocket_sslopt={"cert_reqs": ssl.CERT_NONE}, ) with pyexasol.connect(**connection_params) as pyconn: result = pyconn.execute("SELECT 1;").fetchall() From 846b81f485f0ff85dee4ca47610306a0af59f69e Mon Sep 17 00:00:00 2001 From: ckunki Date: Tue, 9 Dec 2025 12:25:56 +0100 Subject: [PATCH 19/33] Added client argument to delete --- exasol/saas/client/api_access.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index a3fd1d3..f32a45e 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -285,7 +285,11 @@ def delete_database( retry=retry_if_exception(indicates_retry), ) def delete_with_retry(): - delete_database.sync_detailed(self._account_id, database_id) + delete_database.sync_detailed( + self._account_id, + database_id, + client=self._client, + ) try: delete_with_retry() From 1523047f722d6fcc24591d5ff5277e006fd6d2d4 Mon Sep 17 00:00:00 2001 From: ckunki Date: Tue, 9 Dec 2025 13:57:21 +0100 Subject: [PATCH 20/33] Added import for ssl --- test/integration/connection_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/integration/connection_test.py b/test/integration/connection_test.py index 1ecbec3..70c7d2f 100644 --- a/test/integration/connection_test.py +++ b/test/integration/connection_test.py @@ -1,3 +1,5 @@ +import ssl + import pyexasol import pytest From 474a1d5e14bc720bde6b3227e1de49ef4f44ac46 Mon Sep 17 00:00:00 2001 From: ckunki Date: Tue, 9 Dec 2025 14:44:35 +0100 Subject: [PATCH 21/33] Fixed database connection to ignore SSL removed obsolete test case re-activated test_lifecycle in database_test --- test/integration/connection_test.py | 1 + test/integration/databases_test.py | 7 ------- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/test/integration/connection_test.py b/test/integration/connection_test.py index 70c7d2f..a2ee270 100644 --- a/test/integration/connection_test.py +++ b/test/integration/connection_test.py @@ -42,6 +42,7 @@ def test_get_connection_params_with_name( account_id=saas_account_id, pat=saas_pat, database_name=database_name, + websocket_sslopt={"cert_reqs": ssl.CERT_NONE}, ) with pyexasol.connect(**connection_params) as pyconn: result = pyconn.execute("SELECT 1;").fetchall() diff --git a/test/integration/databases_test.py b/test/integration/databases_test.py index c110619..d10324e 100644 --- a/test/integration/databases_test.py +++ b/test/integration/databases_test.py @@ -23,7 +23,6 @@ def local_name(project_short_tag: str | None) -> str: @pytest.mark.slow -@pytest.mark.skip def test_lifecycle(api_access, local_name): """ This integration test uses the database created and provided by pytest @@ -67,9 +66,3 @@ def test_get_connection(api_access, local_name): clusters = api_access.clusters(db.id) connection = api_access.get_connection(db.id, clusters[0].id) assert connection.db_username is not None and connection.port == 8563 - - -def test_x2(api_access, local_name): - with api_access.database(local_name) as db: - api_access.wait_until_running(db.id) - print(f"{db.id}") From 73c53a3ab4d491067eec2e96f3eee06921b980c8 Mon Sep 17 00:00:00 2001 From: ckunki Date: Tue, 9 Dec 2025 15:10:01 +0100 Subject: [PATCH 22/33] Removed SSL option --- test/integration/connection_test.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/integration/connection_test.py b/test/integration/connection_test.py index a2ee270..4330014 100644 --- a/test/integration/connection_test.py +++ b/test/integration/connection_test.py @@ -1,5 +1,3 @@ -import ssl - import pyexasol import pytest @@ -18,7 +16,6 @@ def test_get_connection_params_with_id( account_id=saas_account_id, pat=saas_pat, database_id=operational_saas_database_id, - websocket_sslopt={"cert_reqs": ssl.CERT_NONE}, ) with pyexasol.connect(**connection_params) as pyconn: result = pyconn.execute("SELECT 1;").fetchall() @@ -42,7 +39,6 @@ def test_get_connection_params_with_name( account_id=saas_account_id, pat=saas_pat, database_name=database_name, - websocket_sslopt={"cert_reqs": ssl.CERT_NONE}, ) with pyexasol.connect(**connection_params) as pyconn: result = pyconn.execute("SELECT 1;").fetchall() From d75aac333e840241e5f997ccb99093602a33b94d Mon Sep 17 00:00:00 2001 From: ckunki Date: Tue, 9 Dec 2025 17:17:13 +0100 Subject: [PATCH 23/33] Added unit tests --- test/.placeholder | 0 test/unit/test_api_access.py | 106 +++++++++++++++++++++++++++++++++++ test/util.py | 11 ++++ 3 files changed, 117 insertions(+) delete mode 100644 test/.placeholder create mode 100644 test/unit/test_api_access.py create mode 100644 test/util.py diff --git a/test/.placeholder b/test/.placeholder deleted file mode 100644 index e69de29..0000000 diff --git a/test/unit/test_api_access.py b/test/unit/test_api_access.py new file mode 100644 index 0000000..96aacf8 --- /dev/null +++ b/test/unit/test_api_access.py @@ -0,0 +1,106 @@ +from datetime import timedelta +from test.util import not_raises +from unittest.mock import Mock + +import pytest + +from exasol.saas.client.api_access import ( + OpenApiAccess, + indicates_retry, +) +from exasol.saas.client.openapi.errors import UnexpectedStatus + +RETRY_EXCEPTION = UnexpectedStatus( + 400, b"Operation is not allowed:The cluster is not in a proper state!" +) + + +@pytest.mark.parametrize( + "exception, expected", + [ + pytest.param(RuntimeError("bla"), False, id="other_exception"), + pytest.param(UnexpectedStatus(404, b"bla"), False, id="other_status_code"), + pytest.param(UnexpectedStatus(400, b"bla"), False, id="other_message"), + pytest.param(RETRY_EXCEPTION, True, id="indicates_retry"), + ], +) +def test_indicates_retry(exception, expected): + assert indicates_retry(exception) == expected + + +class ApiRunner: + def __init__(self, mocker): + self.api = OpenApiAccess(Mock(), account_id="A1") + self._mocker = mocker + self.mock = None + + def mock_delete(self, side_effect): + self.mock = Mock(side_effect=side_effect) + self._mocker.patch( + "exasol.saas.client.api_access." "delete_database.sync_detailed", self.mock + ) + + +@pytest.fixture +def api_runner(mocker) -> ApiRunner: + return ApiRunner(mocker) + + +@pytest.fixture +def retry_timings() -> dict[str, timedelta]: + return { + "interval": timedelta(seconds=0.1), + "timeout": timedelta(seconds=0.5), + } + + +@pytest.mark.parametrize( + "side_effect", + [ + pytest.param([UnexpectedStatus(400, b"bla")], id="immediate_failure"), + pytest.param( + [RETRY_EXCEPTION, RETRY_EXCEPTION, UnexpectedStatus(400, b"bla")], + id="failure_after_retry", + ), + ], +) +def test_delete_fail(side_effect, api_runner, retry_timings) -> None: + api_runner.mock_delete(side_effect) + with pytest.raises(UnexpectedStatus): + api_runner.api.delete_database("123", **retry_timings) + + +@pytest.mark.parametrize( + "side_effect, ignore_failures, expected_log_message", + [ + pytest.param([RETRY_EXCEPTION, None], False, "", id="success"), + pytest.param( + [UnexpectedStatus(400, b"bla")], + True, + ( + "Ignoring failure when deleting database with" + " ID 123: Unexpected status code: 400" + ), + id="success", + ), + ], +) +def test_delete_success( + side_effect, + ignore_failures, + expected_log_message, + api_runner, + retry_timings, + caplog, +) -> None: + api_runner.mock_delete(side_effect) + interval = timedelta(seconds=0.1) + timeout = timedelta(seconds=0.5) + with not_raises(Exception): + api_runner.api.delete_database( + database_id="123", + **retry_timings, + ignore_failures=ignore_failures, + ) + assert api_runner.mock.called + assert expected_log_message in caplog.text diff --git a/test/util.py b/test/util.py new file mode 100644 index 0000000..60b0003 --- /dev/null +++ b/test/util.py @@ -0,0 +1,11 @@ +import contextlib + +import pytest + + +@contextlib.contextmanager +def not_raises(exception): + try: + yield + except exception: + raise pytest.fail(f"Did raise {exception}") From f5e192c69d4e9e33987884583dc36a3ce141b7dc Mon Sep 17 00:00:00 2001 From: ckunki Date: Tue, 9 Dec 2025 17:20:11 +0100 Subject: [PATCH 24/33] Added test/__init__ --- test/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 test/__init__.py diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 From ae4f6bbd2f330cfdc1e32f0e74038c6bb98aae68 Mon Sep 17 00:00:00 2001 From: ckunki Date: Wed, 10 Dec 2025 09:34:29 +0100 Subject: [PATCH 25/33] Updated max timeout for deleting a datbase --- doc/changes/changes_2.6.0.md | 9 ++++++--- exasol/saas/client/api_access.py | 26 ++++++++++++++++++++++---- test/unit/test_api_access.py | 10 +++++++--- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/doc/changes/changes_2.6.0.md b/doc/changes/changes_2.6.0.md index ca4eafc..78b1c9a 100644 --- a/doc/changes/changes_2.6.0.md +++ b/doc/changes/changes_2.6.0.md @@ -1,8 +1,11 @@ # 2.6.0 - 2025-12-08 -This release changes the precondition for deleting a SaaS database instance. -Before, the SAPIPY used only a fixed waiting time. With the changed behavior of the actual SaaS instances the precondition needed to be adapted. SAPIPY now waits for the SaaS instance to be running before attempting to delete it. +## Summary + +This release changes the strategy for deleting a SaaS database instance, as required by the changed behavior of the actual SaaS backend. + +Before, SAPIPY used only a fixed waiting time. In case of HTTP responses with status code 400 and message `Operation.*not allowed.*cluster.*not.*in.*proper state` SAPIPY now retries deleting the SaaS instance for max. 5 minutes. ## Bugfixes -* #125: Made operation delete wait for SaaS instance to enable deletion +* #125: Added retry when deleting SaaS instance diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index f32a45e..62cfcac 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -20,7 +20,7 @@ ) from tenacity.retry import retry_if_exception from tenacity.stop import stop_after_delay -from tenacity.wait import wait_fixed +from tenacity.wait import wait_fixed, wait_exponential from exasol.saas.client import ( Limits, @@ -78,6 +78,7 @@ def indicates_retry(ex: BaseException) -> bool: Check whether an the specified exception raised during deleting a database instance indicates to retry deletion. """ + LOG.info(f"checking exception {ex}") return bool( isinstance(ex, UnexpectedStatus) and ex.status_code == 400 @@ -275,12 +276,17 @@ def delete_database( self, database_id: str, ignore_failures: bool = False, - timeout: timedelta = timedelta(minutes=5), - interval: timedelta = timedelta(seconds=1), + timeout: timedelta = timedelta(minutes=30), + min_interval: timedelta = timedelta(seconds=1), + max_interval: timedelta = timedelta(minutes=2), ) -> None: @retry( reraise=True, - wait=wait_fixed(interval), + wait=wait_exponential( + multiplier=1, + min=min_interval, + max=max_interval, + ), stop=stop_after_delay(timeout), retry=retry_if_exception(indicates_retry), ) @@ -428,3 +434,15 @@ def allowed_ip( finally: if ip and not keep: self.delete_allowed_ip(ip.id, ignore_delete_failure) + + + + +if __name__ == "__main__": + import os + host = os.getenv("SAAS_HOST") + pat = os.getenv("SAAS_PAT") + client = create_saas_client(host, pat) + account_id = os.getenv("SAAS_ACCOUNT_ID") + api = OpenApiAccess(client, account_id) + # api.delete_database("zo0ZtL9VQL-vqjYSNOdudA") diff --git a/test/unit/test_api_access.py b/test/unit/test_api_access.py index 96aacf8..69c6439 100644 --- a/test/unit/test_api_access.py +++ b/test/unit/test_api_access.py @@ -48,8 +48,10 @@ def api_runner(mocker) -> ApiRunner: @pytest.fixture def retry_timings() -> dict[str, timedelta]: + interval = timedelta(seconds=0.2) return { - "interval": timedelta(seconds=0.1), + "min_interval": interval, + "max_interval": interval, "timeout": timedelta(seconds=0.5), } @@ -62,6 +64,10 @@ def retry_timings() -> dict[str, timedelta]: [RETRY_EXCEPTION, RETRY_EXCEPTION, UnexpectedStatus(400, b"bla")], id="failure_after_retry", ), + pytest.param( + [RETRY_EXCEPTION for _ in range(4)], + id="timeout_after_too_many_retries", + ), ], ) def test_delete_fail(side_effect, api_runner, retry_timings) -> None: @@ -94,8 +100,6 @@ def test_delete_success( caplog, ) -> None: api_runner.mock_delete(side_effect) - interval = timedelta(seconds=0.1) - timeout = timedelta(seconds=0.5) with not_raises(Exception): api_runner.api.delete_database( database_id="123", From beeae0716edac3f61af9dcb24e6af1246ecd26fe Mon Sep 17 00:00:00 2001 From: ckunki Date: Wed, 10 Dec 2025 09:35:51 +0100 Subject: [PATCH 26/33] project:fix --- exasol/saas/client/api_access.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 62cfcac..5fb6b92 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -20,7 +20,10 @@ ) from tenacity.retry import retry_if_exception from tenacity.stop import stop_after_delay -from tenacity.wait import wait_fixed, wait_exponential +from tenacity.wait import ( + wait_exponential, + wait_fixed, +) from exasol.saas.client import ( Limits, @@ -436,10 +439,9 @@ def allowed_ip( self.delete_allowed_ip(ip.id, ignore_delete_failure) - - if __name__ == "__main__": import os + host = os.getenv("SAAS_HOST") pat = os.getenv("SAAS_PAT") client = create_saas_client(host, pat) From ae9411b7967e4b3ef0fbf859909269aafdbbe1a9 Mon Sep 17 00:00:00 2001 From: ckunki Date: Wed, 10 Dec 2025 10:25:28 +0100 Subject: [PATCH 27/33] Tried to shorten duration of integration tests --- .github/workflows/run-tests.yml | 6 +-- exasol/saas/client/api_access.py | 13 +++--- test/integration/databases_test.py | 63 ++++++++++++++++++++---------- 3 files changed, 52 insertions(+), 30 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 06b8391..5056304 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -39,10 +39,8 @@ jobs: SAAS_ACCOUNT_ID: ${{ secrets.INTEGRATION_TEAM_SAAS_STAGING_ACCOUNT_ID }} SAAS_PAT: ${{ secrets.INTEGRATION_TEAM_SAAS_STAGING_PAT }} PYTEST_ADDOPTS: '-o log_cli=true -o log_cli_level=INFO ${{ steps.pytest-markers.outputs.slow-tests }}' - run: | - echo "PYTEST_ADDOPTS = $PYTEST_ADDOPTS" - export PROJECT_SHORT_TAG=$(poetry run -- nox -s project:get-short-tag) - poetry run -- nox -s test:coverage -- + PROJECT_SHORT_TAG: SAPIPY + run: poetry run -- nox -s test:coverage -- - name: Upload Artifacts uses: actions/upload-artifact@v4.6.2 diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 5fb6b92..abc3383 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -52,6 +52,8 @@ LOG = logging.getLogger(__name__) LOG.setLevel(logging.INFO) +logging.getLogger("httpx").setLevel(logging.WARN) + logging.basicConfig( level=logging.INFO, datefmt="[%X]", @@ -81,7 +83,6 @@ def indicates_retry(ex: BaseException) -> bool: Check whether an the specified exception raised during deleting a database instance indicates to retry deletion. """ - LOG.info(f"checking exception {ex}") return bool( isinstance(ex, UnexpectedStatus) and ex.status_code == 400 @@ -294,6 +295,7 @@ def delete_database( retry=retry_if_exception(indicates_retry), ) def delete_with_retry(): + LOG.info("Trying to delete database with ID %s ...", database_id) delete_database.sync_detailed( self._account_id, database_id, @@ -302,6 +304,7 @@ def delete_with_retry(): try: delete_with_retry() + LOG.info("Deleted database with ID %s", database_id) except Exception as ex: if ignore_failures: LOG.error( @@ -442,9 +445,9 @@ def allowed_ip( if __name__ == "__main__": import os - host = os.getenv("SAAS_HOST") - pat = os.getenv("SAAS_PAT") + host = os.getenv("SAAS_HOST", "") + pat = os.getenv("SAAS_PAT", "") client = create_saas_client(host, pat) - account_id = os.getenv("SAAS_ACCOUNT_ID") + account_id = os.getenv("SAAS_ACCOUNT_ID", "") api = OpenApiAccess(client, account_id) - # api.delete_database("zo0ZtL9VQL-vqjYSNOdudA") + api.delete_database("zo0ZtL9VQL-vqjYSNOdudA") diff --git a/test/integration/databases_test.py b/test/integration/databases_test.py index d10324e..5211a58 100644 --- a/test/integration/databases_test.py +++ b/test/integration/databases_test.py @@ -11,6 +11,7 @@ get_connection_params, timestamp_name, ) +from exasol.saas.client.openapi.models.exasol_database import ExasolDatabase @pytest.fixture @@ -34,35 +35,55 @@ def test_lifecycle(api_access, local_name): - list_databases does not include the deleted database anymore """ - testee = api_access + def wait_until_running_too_short(db: ExasolDatabase): + api_access.wait_until_running( + db.id, + timeout=timedelta(seconds=3), + interval=timedelta(seconds=1), + ) + + def get_connection(db: ExasolDatabase): + clusters = api_access.clusters(db.id) + return api_access.get_connection(db.id, clusters[0].id) + with testee.database(local_name, ignore_delete_failure=True) as db: start = datetime.now() # verify state and clusters of created database assert db.status in PROMISING_STATES and db.clusters.total == 1 + with pytest.raises(RetryError): + wait_until_running_too_short(db) + # verify database is listed - assert db.id in testee.list_database_ids() + assert db.id in api_access.list_database_ids() - # delete database and verify database is not listed anymore - testee.delete_database(db.id) - testee.wait_until_deleted(db.id) - assert db.id not in testee.list_database_ids() + con = get_connection(db) + assert con.db_username is not None and con.port == 8563 + # delete database and verify database is not listed anymore + api_access.delete_database(db.id) + api_access.wait_until_deleted(db.id) + assert db.id not in api_access.list_database_ids() -@pytest.mark.slow -def test_poll(api_access, local_name): - with api_access.database(local_name) as db: - with pytest.raises(RetryError): - api_access.wait_until_running( - db.id, - timeout=timedelta(seconds=3), - interval=timedelta(seconds=1), - ) +# in order to avoid spinning up multiple saas_instances including long time +# for deleting them, I integrated test_poll and test_get_connection into +# test_lifecycle. -@pytest.mark.slow -def test_get_connection(api_access, local_name): - with api_access.database(local_name) as db: - clusters = api_access.clusters(db.id) - connection = api_access.get_connection(db.id, clusters[0].id) - assert connection.db_username is not None and connection.port == 8563 +# @pytest.mark.slow +# def test_poll(api_access, local_name): +# with api_access.database(local_name) as db: +# with pytest.raises(RetryError): +# api_access.wait_until_running( +# db.id, +# timeout=timedelta(seconds=3), +# interval=timedelta(seconds=1), +# ) +# +# +# @pytest.mark.slow +# def test_get_connection(api_access, local_name): +# with api_access.database(local_name) as db: +# clusters = api_access.clusters(db.id) +# connection = api_access.get_connection(db.id, clusters[0].id) +# assert connection.db_username is not None and connection.port == 8563 From c6659dafebe8caefc21656c47d1338358f86ca37 Mon Sep 17 00:00:00 2001 From: ckunki Date: Wed, 10 Dec 2025 11:23:53 +0100 Subject: [PATCH 28/33] Updated connection tests --- exasol/saas/client/api_access.py | 10 ++-- test/integration/connection_test.py | 78 +++++++++++++++++++++-------- test/integration/databases_test.py | 26 ++-------- test/unit/test_placeholder.py | 5 -- 4 files changed, 68 insertions(+), 51 deletions(-) delete mode 100644 test/unit/test_placeholder.py diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index abc3383..6ab7937 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -8,6 +8,7 @@ from collections.abc import Iterable from contextlib import contextmanager from datetime import ( + UTC, datetime, timedelta, ) @@ -52,7 +53,7 @@ LOG = logging.getLogger(__name__) LOG.setLevel(logging.INFO) -logging.getLogger("httpx").setLevel(logging.WARN) +# logging.getLogger("httpx").setLevel(logging.WARN) logging.basicConfig( level=logging.INFO, @@ -69,7 +70,7 @@ def timestamp_name(project_short_tag: str | None = None) -> str: """ project_short_tag: Abbreviation of your project """ - timestamp = f"{datetime.utcnow().timestamp():.0f}" + timestamp = f"{datetime.now(UTC).timestamp():.0f}" owner = getpass.getuser() candidate = f"{timestamp}{project_short_tag or ''}-{owner}" return candidate[: Limits.MAX_DATABASE_NAME_LENGTH] @@ -328,7 +329,6 @@ def database( idle_time: timedelta | None = None, ): db = None - start = datetime.now() try: db = self.create_database(name, idle_time=idle_time) yield db @@ -450,4 +450,6 @@ def allowed_ip( client = create_saas_client(host, pat) account_id = os.getenv("SAAS_ACCOUNT_ID", "") api = OpenApiAccess(client, account_id) - api.delete_database("zo0ZtL9VQL-vqjYSNOdudA") + # api.delete_database("zo0ZtL9VQL-vqjYSNOdudA") + name = timestamp_name("abc") + print(f"{name}") diff --git a/test/integration/connection_test.py b/test/integration/connection_test.py index 4330014..0e8b69b 100644 --- a/test/integration/connection_test.py +++ b/test/integration/connection_test.py @@ -1,45 +1,83 @@ +import contextlib +import ssl + import pyexasol import pytest from exasol.saas.client.api_access import get_connection_params +SSL_OPTIONS = {"websocket_sslopt": {"cert_reqs": ssl.CERT_NONE}} + + +@pytest.fixture +def pyexasol_connection( + saas_host, + saas_pat, + saas_account_id, + allow_connection, + operational_saas_database_id, +): + @contextlib.contextmanager + def connect(**kwargs): + params = ( + get_connection_params( + host=saas_host, + account_id=saas_account_id, + pat=saas_pat, + **kwargs, + ) + | SSL_OPTIONS + ) + with pyexasol.connect(**params) as con: + yield con + + return connect + def test_get_connection_params_with_id( - saas_host, saas_pat, saas_account_id, operational_saas_database_id, allow_connection + # saas_host, + # saas_pat, + # saas_account_id, + # allow_connection, + pyexasol_connection, + operational_saas_database_id, ): """ This integration test checks that opening a pyexasol connection to a SaaS DB with known id and executing a query works. """ - connection_params = get_connection_params( - host=saas_host, - account_id=saas_account_id, - pat=saas_pat, - database_id=operational_saas_database_id, - ) - with pyexasol.connect(**connection_params) as pyconn: + # connection_params = get_connection_params( + # host=saas_host, + # account_id=saas_account_id, + # pat=saas_pat, + # database_id=operational_saas_database_id, + # ) | SSL_OPTIONS + # with pyexasol.connect(**connection_params) as pyconn: + with pyexasol_connection(database_id=operational_saas_database_id) as pyconn: result = pyconn.execute("SELECT 1;").fetchall() assert result == [(1,)] def test_get_connection_params_with_name( - saas_host, - saas_pat, - saas_account_id, - operational_saas_database_id, + # saas_host, + # saas_pat, + # saas_account_id, + # operational_saas_database_id, + # allow_connection, database_name, - allow_connection, + pyexasol_connection, ): """ This integration test checks that opening a pyexasol connection to a SaaS DB with known name and executing a query works. """ - connection_params = get_connection_params( - host=saas_host, - account_id=saas_account_id, - pat=saas_pat, - database_name=database_name, - ) - with pyexasol.connect(**connection_params) as pyconn: + # connection_params = get_connection_params( + # host=saas_host, + # account_id=saas_account_id, + # pat=saas_pat, + # database_name=database_name, + # ) + # with pyexasol.connect(**connection_params) as pyconn: + with pyexasol_connection(database_name=database_name) as pyconn: result = pyconn.execute("SELECT 1;").fetchall() assert result == [(1,)] diff --git a/test/integration/databases_test.py b/test/integration/databases_test.py index 5211a58..4e4e0ef 100644 --- a/test/integration/databases_test.py +++ b/test/integration/databases_test.py @@ -46,7 +46,7 @@ def get_connection(db: ExasolDatabase): clusters = api_access.clusters(db.id) return api_access.get_connection(db.id, clusters[0].id) - with testee.database(local_name, ignore_delete_failure=True) as db: + with api_access.database(local_name, ignore_delete_failure=True) as db: start = datetime.now() # verify state and clusters of created database assert db.status in PROMISING_STATES and db.clusters.total == 1 @@ -66,24 +66,6 @@ def get_connection(db: ExasolDatabase): assert db.id not in api_access.list_database_ids() -# in order to avoid spinning up multiple saas_instances including long time -# for deleting them, I integrated test_poll and test_get_connection into -# test_lifecycle. - -# @pytest.mark.slow -# def test_poll(api_access, local_name): -# with api_access.database(local_name) as db: -# with pytest.raises(RetryError): -# api_access.wait_until_running( -# db.id, -# timeout=timedelta(seconds=3), -# interval=timedelta(seconds=1), -# ) -# -# -# @pytest.mark.slow -# def test_get_connection(api_access, local_name): -# with api_access.database(local_name) as db: -# clusters = api_access.clusters(db.id) -# connection = api_access.get_connection(db.id, clusters[0].id) -# assert connection.db_username is not None and connection.port == 8563 +def test_x1(): + name = timestamp_name("abc") + print(f"{name}") diff --git a/test/unit/test_placeholder.py b/test/unit/test_placeholder.py deleted file mode 100644 index 9fb9833..0000000 --- a/test/unit/test_placeholder.py +++ /dev/null @@ -1,5 +0,0 @@ -"""unit tests""" - - -def test_placeholder(): - """doc""" From 9d970051ff1b9e805a4c992dc0aa386a18f9b4f0 Mon Sep 17 00:00:00 2001 From: ckunki Date: Wed, 10 Dec 2025 12:01:16 +0100 Subject: [PATCH 29/33] Cleanup --- exasol/saas/client/api_access.py | 21 -------------- test/integration/connection_test.py | 44 ++++++----------------------- 2 files changed, 9 insertions(+), 56 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 6ab7937..a360438 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -53,14 +53,6 @@ LOG = logging.getLogger(__name__) LOG.setLevel(logging.INFO) -# logging.getLogger("httpx").setLevel(logging.WARN) - -logging.basicConfig( - level=logging.INFO, - datefmt="[%X]", - format="%(asctime)s [%(levelname)s] %(message)s", -) - def interval_retry(interval: timedelta, timeout: timedelta): return tenacity.retry(wait=wait_fixed(interval), stop=stop_after_delay(timeout)) @@ -440,16 +432,3 @@ def allowed_ip( finally: if ip and not keep: self.delete_allowed_ip(ip.id, ignore_delete_failure) - - -if __name__ == "__main__": - import os - - host = os.getenv("SAAS_HOST", "") - pat = os.getenv("SAAS_PAT", "") - client = create_saas_client(host, pat) - account_id = os.getenv("SAAS_ACCOUNT_ID", "") - api = OpenApiAccess(client, account_id) - # api.delete_database("zo0ZtL9VQL-vqjYSNOdudA") - name = timestamp_name("abc") - print(f"{name}") diff --git a/test/integration/connection_test.py b/test/integration/connection_test.py index 0e8b69b..4cbcb53 100644 --- a/test/integration/connection_test.py +++ b/test/integration/connection_test.py @@ -19,26 +19,21 @@ def pyexasol_connection( ): @contextlib.contextmanager def connect(**kwargs): - params = ( - get_connection_params( - host=saas_host, - account_id=saas_account_id, - pat=saas_pat, - **kwargs, - ) - | SSL_OPTIONS + params = get_connection_params( + host=saas_host, + account_id=saas_account_id, + pat=saas_pat, + **kwargs, ) + params.update(SSL_OPTIONS) with pyexasol.connect(**params) as con: yield con return connect +@pytest.mark.slow def test_get_connection_params_with_id( - # saas_host, - # saas_pat, - # saas_account_id, - # allow_connection, pyexasol_connection, operational_saas_database_id, ): @@ -46,38 +41,17 @@ def test_get_connection_params_with_id( This integration test checks that opening a pyexasol connection to a SaaS DB with known id and executing a query works. """ - # connection_params = get_connection_params( - # host=saas_host, - # account_id=saas_account_id, - # pat=saas_pat, - # database_id=operational_saas_database_id, - # ) | SSL_OPTIONS - # with pyexasol.connect(**connection_params) as pyconn: with pyexasol_connection(database_id=operational_saas_database_id) as pyconn: result = pyconn.execute("SELECT 1;").fetchall() assert result == [(1,)] -def test_get_connection_params_with_name( - # saas_host, - # saas_pat, - # saas_account_id, - # operational_saas_database_id, - # allow_connection, - database_name, - pyexasol_connection, -): +@pytest.mark.slow +def test_get_connection_params_with_name(pyexasol_connection, database_name): """ This integration test checks that opening a pyexasol connection to a SaaS DB with known name and executing a query works. """ - # connection_params = get_connection_params( - # host=saas_host, - # account_id=saas_account_id, - # pat=saas_pat, - # database_name=database_name, - # ) - # with pyexasol.connect(**connection_params) as pyconn: with pyexasol_connection(database_name=database_name) as pyconn: result = pyconn.execute("SELECT 1;").fetchall() assert result == [(1,)] From d44cc2a0adfbae467c66efa442f0bdb8d0fad272 Mon Sep 17 00:00:00 2001 From: ckunki Date: Wed, 10 Dec 2025 12:17:05 +0100 Subject: [PATCH 30/33] More cleanup and updated docstrings --- exasol/saas/client/api_access.py | 4 ++-- test/integration/databases_test.py | 5 ----- test/unit/test_api_access.py | 17 +++++++++++++++-- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index a360438..7bcd821 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -73,8 +73,8 @@ def timestamp_name(project_short_tag: str | None = None) -> str: def indicates_retry(ex: BaseException) -> bool: """ - Check whether an the specified exception raised during deleting a - database instance indicates to retry deletion. + When deleting a SaaS instance raises an UnexpectedStatus, then this + function decides whether we should retry to delete the database instance. """ return bool( isinstance(ex, UnexpectedStatus) diff --git a/test/integration/databases_test.py b/test/integration/databases_test.py index 4e4e0ef..ec1383f 100644 --- a/test/integration/databases_test.py +++ b/test/integration/databases_test.py @@ -64,8 +64,3 @@ def get_connection(db: ExasolDatabase): api_access.delete_database(db.id) api_access.wait_until_deleted(db.id) assert db.id not in api_access.list_database_ids() - - -def test_x1(): - name = timestamp_name("abc") - print(f"{name}") diff --git a/test/unit/test_api_access.py b/test/unit/test_api_access.py index 69c6439..f936705 100644 --- a/test/unit/test_api_access.py +++ b/test/unit/test_api_access.py @@ -25,6 +25,11 @@ ], ) def test_indicates_retry(exception, expected): + """ + Call function api_access.indicates_retry() with different exceptions + in order to verify if it correctly rates the current exception as + indicating to retry deleting a SaaS database instance. + """ assert indicates_retry(exception) == expected @@ -48,6 +53,9 @@ def api_runner(mocker) -> ApiRunner: @pytest.fixture def retry_timings() -> dict[str, timedelta]: + """ + Common timings, used by some of the test cases in this file. + """ interval = timedelta(seconds=0.2) return { "min_interval": interval, @@ -79,7 +87,12 @@ def test_delete_fail(side_effect, api_runner, retry_timings) -> None: @pytest.mark.parametrize( "side_effect, ignore_failures, expected_log_message", [ - pytest.param([RETRY_EXCEPTION, None], False, "", id="success"), + pytest.param( + [RETRY_EXCEPTION, None], + False, + "", + id="success_after_retry", + ), pytest.param( [UnexpectedStatus(400, b"bla")], True, @@ -87,7 +100,7 @@ def test_delete_fail(side_effect, api_runner, retry_timings) -> None: "Ignoring failure when deleting database with" " ID 123: Unexpected status code: 400" ), - id="success", + id="success_by_ignoring_failures", ), ], ) From 9fdf7e45dab6c6276ff46782e01a2d831bf85fe5 Mon Sep 17 00:00:00 2001 From: ckunki Date: Wed, 10 Dec 2025 12:24:17 +0100 Subject: [PATCH 31/33] Added dummy integration test To prevent failure when call pytest "-m not slow" test/integration. --- test/integration/test_placeholder.py | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 test/integration/test_placeholder.py diff --git a/test/integration/test_placeholder.py b/test/integration/test_placeholder.py new file mode 100644 index 0000000..1d09f30 --- /dev/null +++ b/test/integration/test_placeholder.py @@ -0,0 +1,4 @@ +def test_placeholder(): + """ + Prevent failure when call pytest "-m not slow" test/integration. + """ From f1dbf07eabaded18004b6fd281a9ed867a3ff1b2 Mon Sep 17 00:00:00 2001 From: ckunki Date: Wed, 10 Dec 2025 13:57:15 +0100 Subject: [PATCH 32/33] Removed obsolete nox session set PROJECT_SHORT_TAG to static string for SAPIPY --- doc/changes/changes_2.6.0.md | 2 +- exasol/saas/client/api_access.py | 4 ++-- noxfile.py | 15 --------------- test/integration/conftest.py | 4 ++-- 4 files changed, 5 insertions(+), 20 deletions(-) diff --git a/doc/changes/changes_2.6.0.md b/doc/changes/changes_2.6.0.md index 78b1c9a..7b885a4 100644 --- a/doc/changes/changes_2.6.0.md +++ b/doc/changes/changes_2.6.0.md @@ -1,4 +1,4 @@ -# 2.6.0 - 2025-12-08 +# 2.6.0 - 2025-12-10 ## Summary diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 7bcd821..8bd8e25 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -8,9 +8,9 @@ from collections.abc import Iterable from contextlib import contextmanager from datetime import ( - UTC, datetime, timedelta, + timezone, ) from typing import Any @@ -62,7 +62,7 @@ def timestamp_name(project_short_tag: str | None = None) -> str: """ project_short_tag: Abbreviation of your project """ - timestamp = f"{datetime.now(UTC).timestamp():.0f}" + timestamp = f"{datetime.now(timezone.utc).timestamp():.0f}" owner = getpass.getuser() candidate = f"{timestamp}{project_short_tag or ''}-{owner}" return candidate[: Limits.MAX_DATABASE_NAME_LENGTH] diff --git a/noxfile.py b/noxfile.py index ddb18ce..96cb616 100644 --- a/noxfile.py +++ b/noxfile.py @@ -127,18 +127,3 @@ def check_api_outdated(session: Session): """ generate_api(session) session.run("git", "diff", "--exit-code", DEST_DIR) - - -@nox.session(name="project:get-short-tag", python=False) -def get_project_short_tag(session: Session): - config_file = Path("error_code_config.yml") - content = config_file.read_text() - header = False - for line in content.splitlines(): - line = line.strip() - if header: - print(line.strip().replace(":", "")) - return - if line.startswith("error-tags:"): - header = True - raise RuntimeError(f"Could not read project short tag from file {config_file}") diff --git a/test/integration/conftest.py b/test/integration/conftest.py index dbb3bb2..2fc825e 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -59,8 +59,8 @@ def operational_saas_database_id(api_access, database_name) -> str: @pytest.fixture(scope="session") -def project_short_tag() -> str | None: - return os.environ.get("PROJECT_SHORT_TAG") +def project_short_tag() -> str: + return "SAPIPY" @pytest.fixture(scope="session") From 535ee43f75e573dbdd4597a47dae25ad322fac2e Mon Sep 17 00:00:00 2001 From: ckunki Date: Wed, 10 Dec 2025 17:39:00 +0100 Subject: [PATCH 33/33] Fixed sonar findings --- doc/changes/changes_2.6.0.md | 2 +- exasol/saas/client/api_access.py | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/doc/changes/changes_2.6.0.md b/doc/changes/changes_2.6.0.md index 7b885a4..dbc5013 100644 --- a/doc/changes/changes_2.6.0.md +++ b/doc/changes/changes_2.6.0.md @@ -1,4 +1,4 @@ -# 2.6.0 - 2025-12-10 +# 2.6.0 - 2025-12-11 ## Summary diff --git a/exasol/saas/client/api_access.py b/exasol/saas/client/api_access.py index 8bd8e25..e8eaafb 100644 --- a/exasol/saas/client/api_access.py +++ b/exasol/saas/client/api_access.py @@ -68,9 +68,6 @@ def timestamp_name(project_short_tag: str | None = None) -> str: return candidate[: Limits.MAX_DATABASE_NAME_LENGTH] -RETRY_PATTERN = re.compile("Operation.*not allowed.*cluster.*not.*in.*proper state") - - def indicates_retry(ex: BaseException) -> bool: """ When deleting a SaaS instance raises an UnexpectedStatus, then this @@ -79,7 +76,7 @@ def indicates_retry(ex: BaseException) -> bool: return bool( isinstance(ex, UnexpectedStatus) and ex.status_code == 400 - and RETRY_PATTERN.search(ex.content.decode("utf-8")) + and "cluster is not in a proper state" in ex.content.decode("utf-8") ) @@ -301,7 +298,7 @@ def delete_with_retry(): except Exception as ex: if ignore_failures: LOG.error( - f"Ignoring failure when deleting database with ID %s: %s", + "Ignoring failure when deleting database with ID %s: %s", database_id, ex, )