diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml new file mode 100644 index 00000000..d9954d05 --- /dev/null +++ b/.github/workflows/code-coverage.yml @@ -0,0 +1,136 @@ +name: Code Coverage + +permissions: + contents: read + +on: [pull_request, workflow_dispatch] + +jobs: + test-with-coverage: + runs-on: ubuntu-latest + environment: azure-prod + env: + DATABRICKS_SERVER_HOSTNAME: ${{ secrets.DATABRICKS_HOST }} + DATABRICKS_HTTP_PATH: ${{ secrets.TEST_PECO_WAREHOUSE_HTTP_PATH }} + DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} + DATABRICKS_CATALOG: peco + DATABRICKS_USER: ${{ secrets.TEST_PECO_SP_ID }} + steps: + #---------------------------------------------- + # check-out repo and set-up python + #---------------------------------------------- + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.ref || github.ref_name }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + - name: Set up python + id: setup-python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + #---------------------------------------------- + # ----- install & configure poetry ----- + #---------------------------------------------- + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + #---------------------------------------------- + # load cached venv if cache exists + #---------------------------------------------- + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ github.event.repository.name }}-${{ hashFiles('**/poetry.lock') }} + #---------------------------------------------- + # install dependencies if cache does not exist + #---------------------------------------------- + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: poetry install --no-interaction --no-root + #---------------------------------------------- + # install your root project, if required + #---------------------------------------------- + - name: Install library + run: poetry install --no-interaction --all-extras + #---------------------------------------------- + # run all tests with coverage + #---------------------------------------------- + - name: Run all tests with coverage + continue-on-error: false + run: | + poetry run pytest tests/unit tests/e2e \ + -n auto \ + --cov=src \ + --cov-report=xml \ + --cov-report=term \ + -v + + #---------------------------------------------- + # check for coverage override + #---------------------------------------------- + - name: Check for coverage override + id: override + run: | + OVERRIDE_COMMENT=$(echo "${{ github.event.pull_request.body }}" | grep -E "SKIP_COVERAGE_CHECK\s*=" || echo "") + if [ -n "$OVERRIDE_COMMENT" ]; then + echo "override=true" >> $GITHUB_OUTPUT + REASON=$(echo "$OVERRIDE_COMMENT" | sed -E 's/.*SKIP_COVERAGE_CHECK\s*=\s*(.+)/\1/') + echo "reason=$REASON" >> $GITHUB_OUTPUT + echo "Coverage override found in PR description: $REASON" + else + echo "override=false" >> $GITHUB_OUTPUT + echo "No coverage override found" + fi + #---------------------------------------------- + # check coverage percentage + #---------------------------------------------- + - name: Check coverage percentage + if: steps.override.outputs.override == 'false' + run: | + COVERAGE_FILE="coverage.xml" + if [ ! -f "$COVERAGE_FILE" ]; then + echo "ERROR: Coverage file not found at $COVERAGE_FILE" + exit 1 + fi + + # Install xmllint if not available + if ! command -v xmllint &> /dev/null; then + sudo apt-get update && sudo apt-get install -y libxml2-utils + fi + + COVERED=$(xmllint --xpath "string(//coverage/@lines-covered)" "$COVERAGE_FILE") + TOTAL=$(xmllint --xpath "string(//coverage/@lines-valid)" "$COVERAGE_FILE") + PERCENTAGE=$(python3 -c "covered=${COVERED}; total=${TOTAL}; print(round((covered/total)*100, 2))") + + echo "Branch Coverage: $PERCENTAGE%" + echo "Required Coverage: 85%" + + # Use Python to compare the coverage with 85 + python3 -c "import sys; sys.exit(0 if float('$PERCENTAGE') >= 85 else 1)" + if [ $? -eq 1 ]; then + echo "ERROR: Coverage is $PERCENTAGE%, which is less than the required 85%" + exit 1 + else + echo "SUCCESS: Coverage is $PERCENTAGE%, which meets the required 85%" + fi + + #---------------------------------------------- + # coverage enforcement summary + #---------------------------------------------- + - name: Coverage enforcement summary + run: | + if [ "${{ steps.override.outputs.override }}" == "true" ]; then + echo "⚠️ Coverage checks bypassed: ${{ steps.override.outputs.reason }}" + echo "Please ensure this override is justified and temporary" + else + echo "✅ Coverage checks enforced - minimum 85% required" + fi + diff --git a/poetry.lock b/poetry.lock index 5fd21633..1a8074c2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -70,7 +70,7 @@ description = "Foreign Function Interface for Python calling C code." optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"true\" and platform_python_implementation != \"PyPy\"" +markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -475,7 +475,7 @@ description = "cryptography is a package which provides cryptographic recipes an optional = true python-versions = ">=3.7" groups = ["main"] -markers = "python_version < \"3.10\" and extra == \"true\"" +markers = "python_version < \"3.10\"" files = [ {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, @@ -526,7 +526,7 @@ description = "cryptography is a package which provides cryptographic recipes an optional = true python-versions = "!=3.9.0,!=3.9.1,>=3.7" groups = ["main"] -markers = "python_version >= \"3.10\" and extra == \"true\"" +markers = "python_version >= \"3.10\"" files = [ {file = "cryptography-45.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74"}, {file = "cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f"}, @@ -587,7 +587,7 @@ description = "Decorators for Humans" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"true\" and sys_platform != \"win32\"" +markers = "sys_platform != \"win32\"" files = [ {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"}, {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, @@ -637,6 +637,21 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "execnet" +version = "2.1.1" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, + {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + [[package]] name = "gssapi" version = "1.9.0" @@ -644,7 +659,7 @@ description = "Python GSSAPI Wrapper" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"true\" and sys_platform != \"win32\"" +markers = "sys_platform != \"win32\"" files = [ {file = "gssapi-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:261e00ac426d840055ddb2199f4989db7e3ce70fa18b1538f53e392b4823e8f1"}, {file = "gssapi-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:14a1ae12fdf1e4c8889206195ba1843de09fe82587fa113112887cd5894587c6"}, @@ -725,7 +740,7 @@ description = "Kerberos API bindings for Python" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"true\" and sys_platform != \"win32\"" +markers = "sys_platform != \"win32\"" files = [ {file = "krb5-0.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cbdcd2c4514af5ca32d189bc31f30fee2ab297dcbff74a53bd82f92ad1f6e0ef"}, {file = "krb5-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40ad837d563865946cffd65a588f24876da2809aa5ce4412de49442d7cf11d50"}, @@ -1340,7 +1355,7 @@ description = "C parser in Python" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"true\" and platform_python_implementation != \"PyPy\"" +markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -1422,7 +1437,6 @@ description = "Windows Negotiate Authentication Client and Server" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"true\"" files = [ {file = "pyspnego-0.11.2-py3-none-any.whl", hash = "sha256:74abc1fb51e59360eb5c5c9086e5962174f1072c7a50cf6da0bda9a4bcfdfbd4"}, {file = "pyspnego-0.11.2.tar.gz", hash = "sha256:994388d308fb06e4498365ce78d222bf4f3570b6df4ec95738431f61510c971b"}, @@ -1496,6 +1510,50 @@ files = [ pytest = ">=5.0.0" python-dotenv = ">=0.9.1" +[[package]] +name = "pytest-xdist" +version = "3.6.1" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.10\"" +files = [ + {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, + {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, +] + +[package.dependencies] +execnet = ">=2.1" +pytest = ">=7.0.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"}, + {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"}, +] + +[package.dependencies] +execnet = ">=2.1" +pytest = ">=7.0.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1567,7 +1625,6 @@ description = "A Kerberos authentication handler for python-requests" optional = true python-versions = ">=3.6" groups = ["main"] -markers = "extra == \"true\"" files = [ {file = "requests_kerberos-0.15.0-py2.py3-none-any.whl", hash = "sha256:ba9b0980b8489c93bfb13854fd118834e576d6700bfea3745cb2e62278cd16a6"}, {file = "requests_kerberos-0.15.0.tar.gz", hash = "sha256:437512e424413d8113181d696e56694ffa4259eb9a5fc4e803926963864eaf4e"}, @@ -1597,7 +1654,7 @@ description = "SSPI API bindings for Python" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"true\" and sys_platform == \"win32\" and python_version < \"3.10\"" +markers = "python_version < \"3.10\" and sys_platform == \"win32\"" files = [ {file = "sspilib-0.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34f566ba8b332c91594e21a71200de2d4ce55ca5a205541d4128ed23e3c98777"}, {file = "sspilib-0.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5b11e4f030de5c5de0f29bcf41a6e87c9fd90cb3b0f64e446a6e1d1aef4d08f5"}, @@ -1644,7 +1701,7 @@ description = "SSPI API bindings for Python" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"true\" and sys_platform == \"win32\" and python_version >= \"3.10\"" +markers = "python_version >= \"3.10\" and sys_platform == \"win32\"" files = [ {file = "sspilib-0.3.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c45860bdc4793af572d365434020ff5a1ef78c42a2fc2c7a7d8e44eacaf475b6"}, {file = "sspilib-0.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:62cc4de547503dec13b81a6af82b398e9ef53ea82c3535418d7d069c7a05d5cd"}, @@ -1797,9 +1854,8 @@ zstd = ["zstandard (>=0.18.0)"] [extras] pyarrow = ["pyarrow", "pyarrow"] -true = ["requests-kerberos"] [metadata] lock-version = "2.1" python-versions = "^3.8.0" -content-hash = "ddc7354d47a940fa40b4d34c43a1c42488b01258d09d771d58d64a0dfaf0b955" +content-hash = "0a3f611ef8747376f018c1df0a1ea7873368851873cc4bd3a4d51bba0bba847c" diff --git a/pyproject.toml b/pyproject.toml index a1f43bc7..4e6af7d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ pylint = ">=2.12.0" black = "^22.3.0" pytest-dotenv = "^0.5.2" pytest-cov = "^4.0.0" +pytest-xdist = "^3.0.0" numpy = [ { version = ">=1.16.6", python = ">=3.8,<3.11" }, { version = ">=1.23.4", python = ">=3.11" }, diff --git a/tests/e2e/test_complex_types.py b/tests/e2e/test_complex_types.py index 212ddf91..d075a567 100644 --- a/tests/e2e/test_complex_types.py +++ b/tests/e2e/test_complex_types.py @@ -1,6 +1,7 @@ import pytest from numpy import ndarray from typing import Sequence +from uuid import uuid4 from tests.e2e.test_driver import PySQLPytestTestCase @@ -10,12 +11,15 @@ class TestComplexTypes(PySQLPytestTestCase): def table_fixture(self, connection_details): self.arguments = connection_details.copy() """A pytest fixture that creates a table with a complex type, inserts a record, yields, and then drops the table""" + + table_name = f"pysql_test_complex_types_table_{str(uuid4()).replace('-', '_')}" + self.table_name = table_name with self.cursor() as cursor: # Create the table cursor.execute( - """ - CREATE TABLE IF NOT EXISTS pysql_test_complex_types_table ( + f""" + CREATE TABLE IF NOT EXISTS {table_name} ( array_col ARRAY, map_col MAP, struct_col STRUCT, @@ -27,8 +31,8 @@ def table_fixture(self, connection_details): ) # Insert a record cursor.execute( - """ - INSERT INTO pysql_test_complex_types_table + f""" + INSERT INTO {table_name} VALUES ( ARRAY('a', 'b', 'c'), MAP('a', 1, 'b', 2, 'c', 3), @@ -40,10 +44,10 @@ def table_fixture(self, connection_details): """ ) try: - yield + yield table_name finally: # Clean up the table after the test - cursor.execute("DELETE FROM pysql_test_complex_types_table") + cursor.execute(f"DROP TABLE IF EXISTS {table_name}") @pytest.mark.parametrize( "field,expected_type", @@ -61,7 +65,7 @@ def test_read_complex_types_as_arrow(self, field, expected_type, table_fixture): with self.cursor() as cursor: result = cursor.execute( - "SELECT * FROM pysql_test_complex_types_table LIMIT 1" + f"SELECT * FROM {table_fixture} LIMIT 1" ).fetchone() assert isinstance(result[field], expected_type) @@ -83,7 +87,7 @@ def test_read_complex_types_as_string(self, field, table_fixture): extra_params={"_use_arrow_native_complex_types": False} ) as cursor: result = cursor.execute( - "SELECT * FROM pysql_test_complex_types_table LIMIT 1" + f"SELECT * FROM {table_fixture} LIMIT 1" ).fetchone() assert isinstance(result[field], str) diff --git a/tests/e2e/test_parameterized_queries.py b/tests/e2e/test_parameterized_queries.py index 79def9b7..6fda5136 100644 --- a/tests/e2e/test_parameterized_queries.py +++ b/tests/e2e/test_parameterized_queries.py @@ -4,6 +4,7 @@ from enum import Enum from typing import Dict, List, Type, Union from unittest.mock import patch +from uuid import uuid4 import time import numpy as np @@ -118,21 +119,10 @@ class TestParameterizedQueries(PySQLPytestTestCase): def _get_inline_table_column(self, value): return self.inline_type_map[Primitive(value)] - @pytest.fixture(scope="class") - def inline_table(self, connection_details): - self.arguments = connection_details.copy() - """This table is necessary to verify that a parameter sent with INLINE - approach can actually write to its analogous data type. - - For example, a Python Decimal(), when rendered inline, should be able - to read/write into a DECIMAL column in Databricks - - Note that this fixture doesn't clean itself up. So the table will remain - in the schema for use by subsequent test runs. - """ - - query = """ - CREATE TABLE IF NOT EXISTS pysql_e2e_inline_param_test_table ( + def _create_inline_table(self, table_name): + """Create the inline test table with all necessary columns""" + query = f""" + CREATE TABLE IF NOT EXISTS {table_name} ( null_col INT, int_col INT, bigint_col BIGINT, @@ -155,6 +145,24 @@ def inline_table(self, connection_details): with conn.cursor() as cursor: cursor.execute(query) + @pytest.fixture(scope="class") + def inline_table(self, connection_details): + self.arguments = connection_details.copy() + """This table is necessary to verify that a parameter sent with INLINE + approach can actually write to its analogous data type. + + For example, a Python Decimal(), when rendered inline, should be able + to read/write into a DECIMAL column in Databricks + + Note that this fixture doesn't clean itself up. So the table will remain + in the schema for use by subsequent test runs. + """ + + # Generate unique table name to avoid conflicts in parallel execution + table_name = f"pysql_e2e_inline_param_test_table_{str(uuid4()).replace('-', '_')}" + self.inline_table_name = table_name + self._create_inline_table(table_name) + @contextmanager def patch_server_supports_native_params(self, supports_native_params: bool = True): """Applies a patch so we can test the connector's behaviour under different SPARK_CLI_SERVICE_PROTOCOL_VERSION conditions.""" @@ -179,9 +187,15 @@ def _inline_roundtrip(self, params: dict, paramstyle: ParamStyle, target_column) :paramstyle: This is a no-op but is included to make the test-code easier to read. """ - INSERT_QUERY = f"INSERT INTO pysql_e2e_inline_param_test_table (`{target_column}`) VALUES (%(p)s)" - SELECT_QUERY = f"SELECT {target_column} `col` FROM pysql_e2e_inline_param_test_table LIMIT 1" - DELETE_QUERY = "DELETE FROM pysql_e2e_inline_param_test_table" + if not hasattr(self, 'inline_table_name'): + table_name = f"pysql_e2e_inline_param_test_table_{str(uuid4()).replace('-', '_')}" + self.inline_table_name = table_name + self._create_inline_table(table_name) + + table_name = self.inline_table_name + INSERT_QUERY = f"INSERT INTO {table_name} (`{target_column}`) VALUES (%(p)s)" + SELECT_QUERY = f"SELECT {target_column} `col` FROM {table_name} LIMIT 1" + DELETE_QUERY = f"DELETE FROM {table_name}" with self.connection(extra_params={"use_inline_params": True}) as conn: with conn.cursor() as cursor: diff --git a/tests/e2e/test_variant_types.py b/tests/e2e/test_variant_types.py index b5dc1f42..14be3aa3 100644 --- a/tests/e2e/test_variant_types.py +++ b/tests/e2e/test_variant_types.py @@ -1,6 +1,7 @@ import pytest from datetime import datetime import json +from uuid import uuid4 try: import pyarrow @@ -19,14 +20,14 @@ class TestVariantTypes(PySQLPytestTestCase): def variant_table(self, connection_details): """A pytest fixture that creates a test table and cleans up after tests""" self.arguments = connection_details.copy() - table_name = "pysql_test_variant_types_table" + table_name = f"pysql_test_variant_types_table_{str(uuid4()).replace('-', '_')}" with self.cursor() as cursor: try: # Create the table with variant columns cursor.execute( - """ - CREATE TABLE IF NOT EXISTS pysql_test_variant_types_table ( + f""" + CREATE TABLE IF NOT EXISTS {table_name} ( id INTEGER, variant_col VARIANT, regular_string_col STRING @@ -36,10 +37,10 @@ def variant_table(self, connection_details): # Insert test records with different variant values cursor.execute( - """ - INSERT INTO pysql_test_variant_types_table + f""" + INSERT INTO {table_name} VALUES - (1, PARSE_JSON('{"name": "John", "age": 30}'), 'regular string'), + (1, PARSE_JSON('{{\"name\": \"John\", \"age\": 30}}'), 'regular string'), (2, PARSE_JSON('[1, 2, 3, 4]'), 'another string') """ )