diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 05a9376..7060aca 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,11 +12,11 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v6 with: - python-version: '3.9' + python-version: '3.10' - name: Install dependencies run: | pip install poetry diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 2636b2c..43bc1b2 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -14,16 +14,16 @@ jobs: fail-fast: false matrix: python-version: + - '3.14' + - '3.13' + - '3.12' - '3.11' - '3.10' - - '3.9' - - '3.8' - - '3.7' steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Add ODBC repo diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e47f54f..c86048e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,25 +11,23 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v6 with: - python-version: "3.9" + python-version: "3.10" - name: Install Poetry run: | - curl -sSL https://install.python-poetry.org | python - -y --version 1.6.1 - - - name: Update PATH - run: echo "$HOME/.local/bin" >> $GITHUB_PATH + curl -sSL https://install.python-poetry.org | python - -y --version 2.4 + echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Build project for distribution run: poetry build - name: Create Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3 with: files: "dist/*" generate_release_notes: true diff --git a/README.md b/README.md index 7291876..ae9d8fe 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ interface to Db2 for [IBM i](https://en.wikipedia.org/wiki/IBM_i). ### Python -Python 3.7 - 3.12+ are supported. +Python 3.10+ is supported. ### IBM i Access ODBC Driver diff --git a/pyproject.toml b/pyproject.toml index fc2bb08..9fa0695 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,23 +1,66 @@ -[tool.poetry] +[project] name = "sqlalchemy-ibmi" version = "0.9.4-dev" description = "SQLAlchemy support for Db2 on IBM i" readme = "README.md" -authors = [ - "Naveen Ram ", - "Kevin Adler " -] license = "Apache-2.0" keywords = ["sqlalchemy", "database", "ibm", "ibmi", "db2"] +dynamic = ["classifiers"] +authors = [ + {name = "Korinne Adler", email = "kadler@us.ibm.com"}, + {name = "Naveen Ram", email = "naveen.ram@ibm.com"} +] +requires-python = ">=3.10" +dependencies = [ + "sqlalchemy (>=1.4.0,<3.0)", + "pyodbc (>=5.0); python_version>='3.13'", + "pyodbc (>=4.0); python_version<'3.13'", +] + +[dependency-groups] +test = [ + "pytest >=7.2.1; python_version>='3.11'", + "pytest >=6.2.5; python_version~='3.10'", +] +lint = [ + "flake8", + "black ~=22.8.0", +] +dev = [ + { include-group = "test" }, + { include-group = "lint" }, + "bumpversion ~=0.5.0", +] +docs = [ + "sphinx >1.0.0", + "sphinx-rtd-theme >0.4.0", +] + +[project.urls] +Homepage = "https://github.com/IBM/sqlalchemy-ibmi" +Repository = "https://github.com/IBM/sqlalchemy-ibmi" +Documentation = "https://sqlalchemy-ibmi.readthedocs.io" + +[project.entry-points."sqlalchemy.dialects"] +ibmi = "sqlalchemy_ibmi.base:IBMiDb2Dialect" +"ibmi.pyodbc" = "sqlalchemy_ibmi.base:IBMiDb2Dialect" + +[build-system] +requires = ["poetry-core (>=2.0.0,<3.0.0)"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] classifiers =[ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Operating System :: OS Independent", "Topic :: Database :: Front-Ends", ] + packages = [ {include = "sqlalchemy_ibmi"} ] + include = [ { path = "test/*", format = "sdist" }, { path = "contributing/*", format = "sdist" }, @@ -27,40 +70,8 @@ include = [ { path = "LICENSE", format = "sdist" }, ] +requires-poetry = ">=2.0.0,<3.0.0" + [tool.black] line-length = 88 required-version = 22 -target-version = [ - 'py37', - 'py38', - 'py39', - 'py310', - 'py311', - 'py312', -] - -[tool.poetry.plugins."sqlalchemy.dialects"] -ibmi = "sqlalchemy_ibmi.base:IBMiDb2Dialect" -"ibmi.pyodbc" = "sqlalchemy_ibmi.base:IBMiDb2Dialect" - -[tool.poetry.dependencies] -python = ">=3.7,<3.13" -sqlalchemy = ">=1.4.0, <3.0" -pyodbc = ">=4.0" - -[tool.poetry.dev-dependencies] -pytest = [ - { version = ">=7.2.1", python = ">=3.11" }, - { version = ">=6.2.5", python = ">=3.10, <3.11" }, - { version = "<5.4", python = "<3.10" }, -] -flake8 = "^3.7.9" -bumpversion = "^0.5.0" -sphinx = ">1.0.0" -sphinx-rtd-theme = ">0.4.0" -black = "^22.8.0" - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" - diff --git a/sqlalchemy_ibmi/base.py b/sqlalchemy_ibmi/base.py index 2feb3bf..f4156d1 100644 --- a/sqlalchemy_ibmi/base.py +++ b/sqlalchemy_ibmi/base.py @@ -560,13 +560,13 @@ def visit_empty_set_expr(self, element_types, **kw): def visit_over(self, over, **kw): """Override window function handling to avoid CAST in frame clause. - + IBM i DB2 doesn't support CAST expressions in ROWS BETWEEN clauses. We need to render literal values directly instead of using bind parameters. """ # Render with literal binds to avoid CAST(? AS BIGINT) in frame clause kw = kw.copy() - kw['literal_binds'] = True + kw["literal_binds"] = True return super().visit_over(over, **kw) def visit_null(self, expr, **kw): @@ -644,11 +644,7 @@ def visit_bindparam( if not type_.precision: # Render this value as a literal in post-process use_cast = False - try: - bindparam = bindparam.render_literal_execute() - except AttributeError: - # SQLAlchemy 1.3 doesn't have render_literal_execute - literal_binds = True + bindparam = bindparam.render_literal_execute() elif isinstance(type_, sa_types.String): if not type_.length: type_ = type_.copy() @@ -741,7 +737,10 @@ def visit_create_index( self, create, include_schema=True, include_table_schema=True, **kw ): sql = super().visit_create_index( - create, include_schema=include_schema, include_table_schema=include_table_schema, **kw + create, + include_schema=include_schema, + include_table_schema=include_table_schema, + **kw, ) if getattr(create.element, "uConstraint_as_index", None): sql += " EXCLUDE NULL KEYS" @@ -794,7 +793,7 @@ def post_exec(self): row = self.cursor.fetchall()[0] if row[0] is not None: self._lastrowid = int(row[0]) - + # Mark this as a DML statement with no user-facing cursor # This ensures returns_rows is False even though we fetched lastrowid self.cursor_fetch_strategy = _cursor._NO_CURSOR_DML @@ -809,18 +808,18 @@ def fire_sequence(self, seq, type_): def _strtobool(val): """Convert a string representation of truth to boolean. - + This replaces distutils.util.strtobool which was removed in Python 3.12. True values are y, yes, t, true, on and 1. False values are n, no, f, false, off and 0. Raises ValueError if val is anything else. - + This implementation follows PEP 632 guidance for replacing distutils functions. """ val = str(val).lower() - if val in {'y', 'yes', 't', 'true', 'on', '1'}: + if val in {"y", "yes", "t", "true", "on", "1"}: return True - elif val in {'n', 'no', 'f', 'false', 'off', '0'}: + elif val in {"n", "no", "f", "false", "off", "0"}: return False else: raise ValueError(f"Invalid truth value: {val}") @@ -884,26 +883,28 @@ def initialize(self, connection): def get_check_constraints(self, connection, table_name, schema=None, **kw): current_schema = self.denormalize_name(schema or self.default_schema_name) table_name = self.denormalize_name(table_name) - + # Check if table exists if not self.has_table(connection, table_name, schema): raise exc.NoSuchTableError( f"Table '{table_name}' not found in schema '{current_schema}'" ) - + sysconst = self.sys_table_constraints syschkconst = self.sys_check_constraints - query = select( - syschkconst.c.conname, syschkconst.c.chkclause - ).where( - and_( - syschkconst.c.conschema == sysconst.c.conschema, - syschkconst.c.conname == sysconst.c.conname, - sysconst.c.tabschema == current_schema, - sysconst.c.tabname == table_name, + query = ( + select(syschkconst.c.conname, syschkconst.c.chkclause) + .where( + and_( + syschkconst.c.conschema == sysconst.c.conschema, + syschkconst.c.conname == sysconst.c.conname, + sysconst.c.tabschema == current_schema, + sysconst.c.tabname == table_name, + ) ) - ).order_by(syschkconst.c.conname) + .order_by(syschkconst.c.conname) + ) check_consts = [] for res in connection.execute(query): @@ -954,7 +955,7 @@ def get_isolation_level(self, dbapi_conn): def set_isolation_level(self, connection, level): """Set the isolation level for this connection. - + This method attempts to set the isolation level using ODBC attributes. Due to IBM i ODBC driver limitations, this may fail with error HY011 if called during a transaction. @@ -978,7 +979,7 @@ def reset_isolation_level(self, connection): @classmethod def import_dbapi(cls): return __import__("pyodbc") - + # Backwards compatibility alias @classmethod def dbapi(cls): @@ -1000,7 +1001,7 @@ def dbapi(cls): "use_system_naming": ("NAM", to_bool, False), "trim_char_fields": ("TRIMCHAR", to_bool, None), "lob_threshold_kb": ("MAXFIELDLEN", int, None), - "ssl": ("SSL", to_bool, False) + "ssl": ("SSL", to_bool, False), } DRIVER_KEYWORDS_SPECIAL = { @@ -1097,7 +1098,9 @@ def _get_server_version_info(self, connection, allow_chars=True): return tuple(version[0:2]) def _get_default_schema_name(self, connection): - return self.normalize_name(connection.execute(text("VALUES CURRENT_SCHEMA")).scalar()) + return self.normalize_name( + connection.execute(text("VALUES CURRENT_SCHEMA")).scalar() + ) # Driver version for IBM i Access ODBC Driver is given as # VV.RR.SSSF where VV (major), RR (release), and SSS (service pack) @@ -1341,13 +1344,13 @@ def get_view_definition(self, connection, viewname, schema=None, **kw): def get_columns(self, connection, table_name, schema=None, **kw): current_schema = self.denormalize_name(schema or self.default_schema_name) table_name = self.denormalize_name(table_name) - + # Check if table exists if not self.has_table(connection, table_name, schema): raise exc.NoSuchTableError( f"Table '{table_name}' not found in schema '{current_schema}'" ) - + syscols = self.sys_columns query = ( @@ -1363,7 +1366,8 @@ def get_columns(self, connection, table_name, schema=None, **kw): ) .where( and_( - syscols.c.tabschema == current_schema, syscols.c.tabname == table_name + syscols.c.tabschema == current_schema, + syscols.c.tabname == table_name, ) ) .order_by(syscols.c.colno) @@ -1401,13 +1405,13 @@ def get_columns(self, connection, table_name, schema=None, **kw): def get_pk_constraint(self, connection, table_name, schema=None, **kw): current_schema = self.denormalize_name(schema or self.default_schema_name) table_name = self.denormalize_name(table_name) - + # Check if table exists if not self.has_table(connection, table_name, schema): raise exc.NoSuchTableError( f"Table '{table_name}' not found in schema '{current_schema}'" ) - + sysconst = self.sys_table_constraints syskeyconst = self.sys_key_constraints @@ -1462,13 +1466,13 @@ def get_foreign_keys(self, connection, table_name, schema=None, **kw): current_schema = self.denormalize_name(schema or default_schema) default_schema = self.normalize_name(default_schema) table_name = self.denormalize_name(table_name) - + # Check if table exists if not self.has_table(connection, table_name, schema): raise exc.NoSuchTableError( f"Table '{table_name}' not found in schema '{current_schema}'" ) - + sysfkeys = self.sys_foreignkeys query = ( select( @@ -1518,13 +1522,13 @@ def get_foreign_keys(self, connection, table_name, schema=None, **kw): def get_indexes(self, connection, table_name, schema=None, **kw): current_schema = self.denormalize_name(schema or self.default_schema_name) table_name = self.denormalize_name(table_name) - + # Check if table exists if not self.has_table(connection, table_name, schema): raise exc.NoSuchTableError( f"Table '{table_name}' not found in schema '{current_schema}'" ) - + sysidx = self.sys_indexes syskey = self.sys_keys @@ -1549,7 +1553,7 @@ def get_indexes(self, connection, table_name, schema=None, **kw): indexes[key] = { "name": self.normalize_name(row[0]), "column_names": [self.normalize_name(row[2])], - "unique": row[1] in ('U', 'P'), # U=unique, P=primary key + "unique": row[1] in ("U", "P"), # U=unique, P=primary key } return [value for key, value in indexes.items()] @@ -1557,13 +1561,13 @@ def get_indexes(self, connection, table_name, schema=None, **kw): def get_unique_constraints(self, connection, table_name, schema=None, **kw): current_schema = self.denormalize_name(schema or self.default_schema_name) table_name = self.denormalize_name(table_name) - + # Check if table exists if not self.has_table(connection, table_name, schema): raise exc.NoSuchTableError( f"Table '{table_name}' not found in schema '{current_schema}'" ) - + sysconst = self.sys_table_constraints sysconstcol = self.sys_constraints_columns diff --git a/test/test_cache.py b/test/test_cache.py index b9a295c..d2a6de0 100644 --- a/test/test_cache.py +++ b/test/test_cache.py @@ -2,19 +2,10 @@ from datetime import date, time, datetime from sqlalchemy import literal +from sqlalchemy import select from sqlalchemy import testing from sqlalchemy.testing import fixtures -from .util import SA_Version - -if SA_Version >= [1, 4]: - from sqlalchemy import select -else: - from sqlalchemy import select as _select - - def select(*args): - return _select(args) - class CachingTest(fixtures.TestBase): @testing.combinations( diff --git a/test/test_suite.py b/test/test_suite.py index 14eb724..37d0812 100644 --- a/test/test_suite.py +++ b/test/test_suite.py @@ -1,4 +1,3 @@ -from .util import SA_Version from sqlalchemy.testing.suite import * # noqa - need * to import test suite from sqlalchemy.testing.suite import testing @@ -10,30 +9,15 @@ from sqlalchemy.testing.suite import UnicodeTextTest as _UnicodeTextTest from sqlalchemy.testing.suite import UnicodeVarcharTest as _UnicodeVarcharTest -if SA_Version < [1, 4]: - - class ComponentReflectionTest(_ComponentReflectionTest): - @testing.requires.unique_constraint_reflection - @testing.requires.schemas - @testing.skip("ibmi", "Db2 doesn't support duplicate constraints") - def test_get_unique_constraints_with_schema(self): - pass - - @testing.requires.unique_constraint_reflection - @testing.skip("ibmi", "Db2 doesn't support duplicate constraints") - def test_get_unique_constraints(self): - pass - -else: - - class ComponentReflectionTest(_ComponentReflectionTest): - @testing.combinations( - (True, testing.requires.schemas), (False,), argnames="use_schema" - ) - @testing.requires.unique_constraint_reflection - @testing.skip("ibmi", "Db2 doesn't support duplicate constraints") - def test_get_unique_constraints(self, metadata, connection, use_schema): - pass + +class ComponentReflectionTest(_ComponentReflectionTest): + @testing.combinations( + (True, testing.requires.schemas), (False,), argnames="use_schema" + ) + @testing.requires.unique_constraint_reflection + @testing.skip("ibmi", "Db2 doesn't support duplicate constraints") + def test_get_unique_constraints(self, metadata, connection, use_schema): + pass # empty set tests not possible on DB2 for i