diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 03aa81a843..5a2da08a8b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -44,6 +44,7 @@ jobs: - firestore - grpc - kafka + - oracledb - memcached - mongodb3 - mongodb8 @@ -800,6 +801,73 @@ jobs: if-no-files-found: error retention-days: 1 + oracledb: + env: + TOTAL_GROUPS: 1 + + strategy: + fail-fast: false + matrix: + group-number: [1] + + runs-on: ubuntu-24.04 + container: + image: ghcr.io/newrelic/newrelic-python-agent-ci:latest + options: >- + --add-host=host.docker.internal:host-gateway + timeout-minutes: 30 + services: + oracledb: + image: container-registry.oracle.com/database/free:latest-lite + ports: + - 8080:1521 + - 8081:1521 + env: + ORACLE_CHARACTERSET: utf8 + ORACLE_PWD: oracle + # Set health checks to wait until container has started + options: >- + --health-cmd "timeout 5 bash -c 'cat < /dev/null > /dev/udp/127.0.0.1/11211'" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + + - name: Fetch git tags + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + git fetch --tags origin + + - name: Configure pip cache + run: | + mkdir -p /github/home/.cache/pip + chown -R "$(whoami)" /github/home/.cache/pip + + - name: Get Environments + id: get-envs + run: | + echo "envs=$(tox -l | grep '^${{ github.job }}\-' | ./.github/workflows/get-envs.py)" >> "$GITHUB_OUTPUT" + env: + GROUP_NUMBER: ${{ matrix.group-number }} + + - name: Test + run: | + tox -vv -e ${{ steps.get-envs.outputs.envs }} -p auto + env: + TOX_PARALLEL_NO_SPINNER: 1 + PY_COLORS: 0 + + - name: Upload Coverage Artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + with: + name: coverage-${{ github.job }}-${{ strategy.job-index }} + path: ./**/.coverage.* + include-hidden-files: true + if-no-files-found: error + retention-days: 1 + memcached: env: TOTAL_GROUPS: 2 diff --git a/newrelic/config.py b/newrelic/config.py index 996c3e977d..b5b47b4f3a 100644 --- a/newrelic/config.py +++ b/newrelic/config.py @@ -2878,6 +2878,8 @@ def _process_module_builtin_defaults(): _process_module_definition("cx_Oracle", "newrelic.hooks.database_cx_oracle", "instrument_cx_oracle") + _process_module_definition("oracledb", "newrelic.hooks.database_oracledb", "instrument_oracledb") + _process_module_definition("ibm_db_dbi", "newrelic.hooks.database_ibm_db_dbi", "instrument_ibm_db_dbi") _process_module_definition("mysql.connector", "newrelic.hooks.database_mysql", "instrument_mysql_connector") diff --git a/newrelic/hooks/database_oracledb.py b/newrelic/hooks/database_oracledb.py new file mode 100644 index 0000000000..b2888de464 --- /dev/null +++ b/newrelic/hooks/database_oracledb.py @@ -0,0 +1,143 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from newrelic.api.database_trace import register_database_client +from newrelic.common.object_wrapper import wrap_object +from newrelic.hooks.database_dbapi2 import ConnectionFactory as DBAPI2ConnectionFactory +from newrelic.hooks.database_dbapi2 import ConnectionWrapper as DBAPI2ConnectionWrapper +from newrelic.hooks.database_dbapi2 import CursorWrapper as DBAPI2CursorWrapper +from newrelic.hooks.database_dbapi2_async import AsyncConnectionFactory as DBAPI2AsyncConnectionFactory +from newrelic.hooks.database_dbapi2_async import AsyncConnectionWrapper as DBAPI2AsyncConnectionWrapper +from newrelic.hooks.database_dbapi2_async import AsyncCursorWrapper as DBAPI2AsyncCursorWrapper + + +class CursorWrapper(DBAPI2CursorWrapper): + def __enter__(self): + self.__wrapped__.__enter__() + return self + + +class ConnectionWrapper(DBAPI2ConnectionWrapper): + __cursor_wrapper__ = CursorWrapper + + def __enter__(self): + self.__wrapped__.__enter__() + return self + + +class ConnectionFactory(DBAPI2ConnectionFactory): + __connection_wrapper__ = ConnectionWrapper + + +class AsyncCursorWrapper(DBAPI2AsyncCursorWrapper): + async def __aenter__(self): + await self.__wrapped__.__aenter__() + return self + + +class AsyncConnectionWrapper(DBAPI2AsyncConnectionWrapper): + __cursor_wrapper__ = AsyncCursorWrapper + + async def __aenter__(self): + await self.__wrapped__.__aenter__() + return self + + def __await__(self): + # Handle bidirectional generator protocol using code from generator_wrapper + g = self.__wrapped__.__await__() + try: + yielded = g.send(None) + while True: + try: + sent = yield yielded + except GeneratorExit: + g.close() + raise + except BaseException as e: + yielded = g.throw(e) + else: + yielded = g.send(sent) + except StopIteration as e: + # Catch the StopIteration and return the wrapped connection instead of the unwrapped. + if e.value is self.__wrapped__: + connection = self + else: + connection = e.value + + # Return here instead of raising StopIteration to properly follow generator protocol + return connection + + +class AsyncConnectionFactory(DBAPI2AsyncConnectionFactory): + __connection_wrapper__ = AsyncConnectionWrapper + + # Use the synchronous __call__ method as connection_async() is synchronous in oracledb. + __call__ = DBAPI2ConnectionFactory.__call__ + + +def instance_info(args, kwargs): + from oracledb import ConnectParams + + dsn = args[0] if args else None + + host = None + port = None + service_name = None + + params_from_kwarg = kwargs.pop("params", None) + + params_from_dsn = None + if dsn: + try: + params_from_dsn = ConnectParams() + if "@" in dsn: + _, _, connect_string = params_from_dsn.parse_dsn_with_credentials(dsn) + else: + connect_string = dsn + params_from_dsn.parse_connect_string(connect_string) + except Exception: + params_from_dsn = None + + host = ( + getattr(params_from_kwarg, "host", None) + or kwargs.get("host", None) + or getattr(params_from_dsn, "host", None) + or "unknown" + ) + port = str( + getattr(params_from_kwarg, "port", None) + or kwargs.get("port", None) + or getattr(params_from_dsn, "port", None) + or "1521" + ) + service_name = ( + getattr(params_from_kwarg, "service_name", None) + or kwargs.get("service_name", None) + or getattr(params_from_dsn, "service_name", None) + or "unknown" + ) + + return host, port, service_name + + +def instrument_oracledb(module): + register_database_client( + module, database_product="Oracle", quoting_style="single+oracle", instance_info=instance_info + ) + + if hasattr(module, "connect"): + wrap_object(module, "connect", ConnectionFactory, (module,)) + + if hasattr(module, "connect_async"): + wrap_object(module, "connect_async", AsyncConnectionFactory, (module,)) diff --git a/tests/datastore_oracledb/conftest.py b/tests/datastore_oracledb/conftest.py new file mode 100644 index 0000000000..52c0c84cdc --- /dev/null +++ b/tests/datastore_oracledb/conftest.py @@ -0,0 +1,33 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from testing_support.fixture.event_loop import event_loop as loop +from testing_support.fixtures import collector_agent_registration_fixture, collector_available_fixture + +_default_settings = { + "package_reporting.enabled": False, # Turn off package reporting for testing as it causes slow downs. + "transaction_tracer.explain_threshold": 0.0, + "transaction_tracer.transaction_threshold": 0.0, + "transaction_tracer.stack_trace_threshold": 0.0, + "debug.log_data_collector_payloads": True, + "debug.record_transaction_failure": True, + "debug.log_explain_plan_queries": True, +} + +collector_agent_registration = collector_agent_registration_fixture( + app_name="Python Agent Test (datastore_oracledb)", + default_settings=_default_settings, + linked_applications=["Python Agent Test (datastore)"], +) diff --git a/tests/datastore_oracledb/test_async_connection.py b/tests/datastore_oracledb/test_async_connection.py new file mode 100644 index 0000000000..60b2d088a4 --- /dev/null +++ b/tests/datastore_oracledb/test_async_connection.py @@ -0,0 +1,159 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import oracledb +import pytest +from testing_support.db_settings import oracledb_settings +from testing_support.util import instance_hostname +from testing_support.validators.validate_database_trace_inputs import validate_database_trace_inputs +from testing_support.validators.validate_transaction_metrics import validate_transaction_metrics + +from newrelic.api.background_task import background_task +from newrelic.common.package_version_utils import get_package_version_tuple + +ORACLEDB_VERSION = get_package_version_tuple("oracledb") +if ORACLEDB_VERSION < (2,): + pytest.skip(reason="OracleDB version does not contain async APIs.", allow_module_level=True) + +DB_SETTINGS = oracledb_settings()[0] +TABLE_NAME = DB_SETTINGS["table_name"] +PROCEDURE_NAME = DB_SETTINGS["procedure_name"] + +HOST = instance_hostname(DB_SETTINGS["host"]) +PORT = DB_SETTINGS["port"] + + +async def execute_db_calls_with_cursor(cursor): + await cursor.execute(f"""drop table if exists {TABLE_NAME}""") + + await cursor.execute(f"create table {TABLE_NAME} (a INT, b BINARY_FLOAT, c VARCHAR2(10) )") + + await cursor.executemany( + f"insert into {TABLE_NAME} values (:1, :2, :3)", [(1, 1.0, "1.0"), (2, 2.2, "2.2"), (3, 3.3, "3.3")] + ) + + await cursor.execute(f"""select * from {TABLE_NAME}""") + + async for _row in cursor: + pass + + await cursor.execute(f"update {TABLE_NAME} set a=:1, b=:2, c=:3 where a=:4", (4, 4.0, "4.0", 1)) + + await cursor.execute(f"""delete from {TABLE_NAME} where a=2""") + + await cursor.execute(f"""drop procedure if exists {PROCEDURE_NAME}""") + await cursor.execute( + f"""CREATE PROCEDURE {PROCEDURE_NAME} (hello OUT VARCHAR2) AS + BEGIN + hello := 'Hello World!'; + END; + """ + ) + await cursor.callproc(PROCEDURE_NAME, [cursor.var(str)]) # Must specify a container for the OUT parameter + + +_test_execute_scoped_metrics = [ + ("Function/oracledb.connection:connect_async", 1), + (f"Datastore/statement/Oracle/{TABLE_NAME}/select", 1), + (f"Datastore/statement/Oracle/{TABLE_NAME}/insert", 1), + (f"Datastore/statement/Oracle/{TABLE_NAME}/update", 1), + (f"Datastore/statement/Oracle/{TABLE_NAME}/delete", 1), + ("Datastore/operation/Oracle/drop", 2), + ("Datastore/operation/Oracle/create", 2), + (f"Datastore/statement/Oracle/{PROCEDURE_NAME}/call", 1), + ("Datastore/operation/Oracle/commit", 2), + ("Datastore/operation/Oracle/rollback", 1), +] + +_test_execute_rollup_metrics = [ + ("Datastore/all", 13), + ("Datastore/allOther", 13), + ("Datastore/Oracle/all", 13), + ("Datastore/Oracle/allOther", 13), + (f"Datastore/statement/Oracle/{TABLE_NAME}/select", 1), + (f"Datastore/statement/Oracle/{TABLE_NAME}/insert", 1), + (f"Datastore/statement/Oracle/{TABLE_NAME}/update", 1), + (f"Datastore/statement/Oracle/{TABLE_NAME}/delete", 1), + ("Datastore/operation/Oracle/select", 1), + ("Datastore/operation/Oracle/insert", 1), + ("Datastore/operation/Oracle/update", 1), + ("Datastore/operation/Oracle/delete", 1), + (f"Datastore/statement/Oracle/{PROCEDURE_NAME}/call", 1), + ("Datastore/operation/Oracle/call", 1), + ("Datastore/operation/Oracle/drop", 2), + ("Datastore/operation/Oracle/create", 2), + ("Datastore/operation/Oracle/commit", 2), + ("Datastore/operation/Oracle/rollback", 1), + (f"Datastore/instance/Oracle/{HOST}/{PORT}", 12), +] + + +@validate_transaction_metrics( + "test_async_connection:test_execute_via_async_connection_aenter", + scoped_metrics=_test_execute_scoped_metrics, + rollup_metrics=_test_execute_rollup_metrics, + background_task=True, +) +@validate_database_trace_inputs(sql_parameters_type=tuple) +@background_task() +def test_execute_via_async_connection_aenter(loop): + async def _test(): + connection = oracledb.connect_async( + user=DB_SETTINGS["user"], + password=DB_SETTINGS["password"], + host=DB_SETTINGS["host"], + port=DB_SETTINGS["port"], + service_name=DB_SETTINGS["service_name"], + ) + + # Use async with and don't await connection directly + async with connection: + async with connection.cursor() as cursor: + await execute_db_calls_with_cursor(cursor) + + await connection.commit() + await connection.rollback() + await connection.commit() + + loop.run_until_complete(_test()) + + +@validate_transaction_metrics( + "test_async_connection:test_execute_via_async_connection_await", + scoped_metrics=_test_execute_scoped_metrics, + rollup_metrics=_test_execute_rollup_metrics, + background_task=True, +) +@validate_database_trace_inputs(sql_parameters_type=tuple) +@background_task() +def test_execute_via_async_connection_await(loop): + async def _test(): + connection = oracledb.connect_async( + user=DB_SETTINGS["user"], + password=DB_SETTINGS["password"], + host=DB_SETTINGS["host"], + port=DB_SETTINGS["port"], + service_name=DB_SETTINGS["service_name"], + ) + + # Await connection instead of using async with + connection = await connection + async with connection.cursor() as cursor: + await execute_db_calls_with_cursor(cursor) + + await connection.commit() + await connection.rollback() + await connection.commit() + + loop.run_until_complete(_test()) diff --git a/tests/datastore_oracledb/test_connection.py b/tests/datastore_oracledb/test_connection.py new file mode 100644 index 0000000000..f8789e78ff --- /dev/null +++ b/tests/datastore_oracledb/test_connection.py @@ -0,0 +1,119 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import oracledb +from testing_support.db_settings import oracledb_settings +from testing_support.util import instance_hostname +from testing_support.validators.validate_database_trace_inputs import validate_database_trace_inputs +from testing_support.validators.validate_transaction_metrics import validate_transaction_metrics + +from newrelic.api.background_task import background_task + +DB_SETTINGS = oracledb_settings()[0] +TABLE_NAME = DB_SETTINGS["table_name"] +PROCEDURE_NAME = DB_SETTINGS["procedure_name"] + +HOST = instance_hostname(DB_SETTINGS["host"]) +PORT = DB_SETTINGS["port"] + + +def execute_db_calls_with_cursor(cursor): + cursor.execute(f"""drop table if exists {TABLE_NAME}""") + + cursor.execute(f"create table {TABLE_NAME} (a INT, b BINARY_FLOAT, c VARCHAR2(10) )") + + cursor.executemany( + f"insert into {TABLE_NAME} values (:1, :2, :3)", [(1, 1.0, "1.0"), (2, 2.2, "2.2"), (3, 3.3, "3.3")] + ) + + cursor.execute(f"""select * from {TABLE_NAME}""") + + for _row in cursor: + pass + + cursor.execute(f"update {TABLE_NAME} set a=:1, b=:2, c=:3 where a=:4", (4, 4.0, "4.0", 1)) + + cursor.execute(f"""delete from {TABLE_NAME} where a=2""") + + cursor.execute(f"""drop procedure if exists {PROCEDURE_NAME}""") + cursor.execute( + f"""CREATE PROCEDURE {PROCEDURE_NAME} (hello OUT VARCHAR2) AS + BEGIN + hello := 'Hello World!'; + END; + """ + ) + cursor.callproc(PROCEDURE_NAME, [cursor.var(str)]) # Must specify a container for the OUT parameter + + +_test_execute_scoped_metrics = [ + ("Function/oracledb.connection:connect", 1), + (f"Datastore/statement/Oracle/{TABLE_NAME}/select", 1), + (f"Datastore/statement/Oracle/{TABLE_NAME}/insert", 1), + (f"Datastore/statement/Oracle/{TABLE_NAME}/update", 1), + (f"Datastore/statement/Oracle/{TABLE_NAME}/delete", 1), + ("Datastore/operation/Oracle/drop", 2), + ("Datastore/operation/Oracle/create", 2), + (f"Datastore/statement/Oracle/{PROCEDURE_NAME}/call", 1), + ("Datastore/operation/Oracle/commit", 2), + ("Datastore/operation/Oracle/rollback", 1), +] + +_test_execute_rollup_metrics = [ + ("Datastore/all", 13), + ("Datastore/allOther", 13), + ("Datastore/Oracle/all", 13), + ("Datastore/Oracle/allOther", 13), + (f"Datastore/statement/Oracle/{TABLE_NAME}/select", 1), + (f"Datastore/statement/Oracle/{TABLE_NAME}/insert", 1), + (f"Datastore/statement/Oracle/{TABLE_NAME}/update", 1), + (f"Datastore/statement/Oracle/{TABLE_NAME}/delete", 1), + ("Datastore/operation/Oracle/select", 1), + ("Datastore/operation/Oracle/insert", 1), + ("Datastore/operation/Oracle/update", 1), + ("Datastore/operation/Oracle/delete", 1), + (f"Datastore/statement/Oracle/{PROCEDURE_NAME}/call", 1), + ("Datastore/operation/Oracle/call", 1), + ("Datastore/operation/Oracle/drop", 2), + ("Datastore/operation/Oracle/create", 2), + ("Datastore/operation/Oracle/commit", 2), + ("Datastore/operation/Oracle/rollback", 1), + (f"Datastore/instance/Oracle/{HOST}/{PORT}", 12), +] + + +@validate_transaction_metrics( + "test_connection:test_execute_via_connection_enter", + scoped_metrics=_test_execute_scoped_metrics, + rollup_metrics=_test_execute_rollup_metrics, + background_task=True, +) +@validate_database_trace_inputs(sql_parameters_type=tuple) +@background_task() +def test_execute_via_connection_enter(): + connection = oracledb.connect( + user=DB_SETTINGS["user"], + password=DB_SETTINGS["password"], + host=DB_SETTINGS["host"], + port=DB_SETTINGS["port"], + service_name=DB_SETTINGS["service_name"], + ) + + with connection: + with connection.cursor() as cursor: + execute_db_calls_with_cursor(cursor) + + connection.commit() + connection.rollback() + connection.commit() diff --git a/tests/datastore_oracledb/test_instance_info.py b/tests/datastore_oracledb/test_instance_info.py new file mode 100644 index 0000000000..d463b5d608 --- /dev/null +++ b/tests/datastore_oracledb/test_instance_info.py @@ -0,0 +1,47 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import oracledb +import pytest + +from newrelic.hooks.database_oracledb import instance_info + +_instance_info_tests = [ + pytest.param( + {"host": "localhost", "port": 8080, "service_name": "FREEPDB1"}, ("localhost", "8080", "FREEPDB1"), id="kwargs" + ), + pytest.param({"host": "localhost"}, ("localhost", "1521", "unknown"), id="kwargs_host_only"), + pytest.param( + {"params": oracledb.ConnectParams(host="localhost", port=8080, service_name="FREEPDB1")}, + ("localhost", "8080", "FREEPDB1"), + id="connect_params_kwarg", + ), + pytest.param({"dsn": "user/password@localhost:8080/FREEPDB1"}, ("localhost", "8080", "FREEPDB1"), id="full_dsn"), + pytest.param({"dsn": "localhost:8080/FREEPDB1"}, ("localhost", "8080", "FREEPDB1"), id="connect_string"), + pytest.param({"dsn": "localhost:8080/"}, ("localhost", "8080", "unknown"), id="connect_string_no_service_name"), + # These 2 will use the default port + pytest.param({"dsn": "localhost/"}, ("localhost", "1521", "unknown"), id="connect_string_host_only"), + pytest.param( + {"dsn": "user/password@localhost/"}, ("localhost", "1521", "unknown"), id="dsn_credentials_and_host_only" + ), +] + + +@pytest.mark.parametrize("kwargs,expected", _instance_info_tests) +def test_oracledb_instance_info(kwargs, expected): + dsn = kwargs.pop("dsn", None) # This is only a posarg + args = (dsn,) if dsn else () + + output = instance_info(args, kwargs) + assert output == expected diff --git a/tests/testing_support/db_settings.py b/tests/testing_support/db_settings.py index beaaf465c6..cb51a01e23 100644 --- a/tests/testing_support/db_settings.py +++ b/tests/testing_support/db_settings.py @@ -100,6 +100,35 @@ def mssql_settings(): return settings +def oracledb_settings(): + """Return a list of dict of settings for connecting to oracledb. + + Will return the correct settings, depending on which of the environments it + is running in. It attempts to set variables in the following order, where + later environments override earlier ones. + + 1. Local + 2. Github Actions + """ + + host = "host.docker.internal" if "GITHUB_ACTIONS" in os.environ else "localhost" + instances = 2 + identifier = str(os.getpid()) + settings = [ + { + "user": "SYSTEM", + "password": "oracle", + "host": host, + "port": 8080 + instance_num, + "table_name": f"oracledb_table_{identifier}", + "procedure_name": f"oracledb_procedure_{identifier}", + "service_name": "FREEPDB1", + } + for instance_num in range(instances) + ] + return settings + + def redis_settings(): """Return a list of dict of settings for connecting to redis. diff --git a/tox.ini b/tox.ini index f99102a0f1..37849112e0 100644 --- a/tox.ini +++ b/tox.ini @@ -67,6 +67,9 @@ envlist = mysql-datastore_mysql-mysqllatest-{py37,py38,py39,py310,py311,py312,py313}, mysql-datastore_mysqldb-{py38,py39,py310,py311,py312,py313}, mysql-datastore_pymysql-{py37,py38,py39,py310,py311,py312,py313,pypy310}, + oracledb-datastore_oracledb-{py39,py310,py311,py312,py313}-oracledblatest, + oracledb-datastore_oracledb-{py39,py313}-oracledb02, + oracledb-datastore_oracledb-{py39,py312}-oracledb01, nginx-external_httpx-{py37,py38,py39,py310,py311,py312,py313}, postgres16-datastore_asyncpg-{py37,py38,py39,py310,py311,py312,py313}, postgres16-datastore_psycopg-{py38,py39,py310,py311,py312,py313,pypy310}-psycopglatest, @@ -279,6 +282,9 @@ deps = datastore_mysql: protobuf<4 # mysqlclient is the Python 3 replacement for MySQLdb datastore_mysqldb: mysqlclient + datastore_oracledb-oracledblatest: oracledb + datastore_oracledb-oracledb02: oracledb<3 + datastore_oracledb-oracledb01: oracledb<2 datastore_postgresql: py-postgresql datastore_psycopg-psycopglatest: psycopg[binary]>=3 datastore_psycopg-psycopg_purepython0301: psycopg<3.2 @@ -506,6 +512,7 @@ changedir = datastore_cassandradriver: tests/datastore_cassandradriver datastore_elasticsearch: tests/datastore_elasticsearch datastore_firestore: tests/datastore_firestore + datastore_oracledb: tests/datastore_oracledb datastore_memcache: tests/datastore_memcache datastore_mysql: tests/datastore_mysql datastore_mysqldb: tests/datastore_mysqldb