diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..c5c8ab81 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Publish to PyPI + +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+*' + +jobs: + publish: + name: Build and publish to PyPI + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.12 + + - name: Build package + run: uv build + + - name: Smoke test wheel + run: | + uv run --isolated --no-project --with dist/*.whl python -c "import sqlalchemy_redshift; print(sqlalchemy_redshift.__version__)" + + - name: Publish to PyPI + run: uv publish diff --git a/.gitignore b/.gitignore index efcc2a3d..74f2b508 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,7 @@ target/ # IDE .idea/ + +# uv +.venv +.python-version diff --git a/README.rst b/README.rst index 1693649b..9a9ddb23 100644 --- a/README.rst +++ b/README.rst @@ -18,7 +18,6 @@ The package is available on PyPI:: * psycopg2 - standard distribution of psycopg2, requires compilation so few system dependencies are required for it * psycopg2-binary - already compiled distribution (no system dependencies are required) - * psycopg2cffi - pypy compatible version See `Psycopg2's binary install docs `_ for more context on choosing a distribution. diff --git a/docs/conf.py b/docs/conf.py index 0a36dbf5..b118c81e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -73,7 +73,7 @@ def _warn_node(self, msg, node, **kwargs): # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = 'en' # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..4cbb0d34 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,64 @@ +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" + +[project] +name = "deepnote-sqlalchemy-redshift" +dynamic = ["version"] +description = "Amazon Redshift Dialect for sqlalchemy" +readme = "README.rst" +requires-python = ">=3.9,<3.14" +license = "MIT" +authors = [ + {name = "Matt George", email = "mgeorge@gmail.com"}, +] +maintainers = [ + {name = "Deepnote", email = "product-engineers@deepnote.com"}, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "SQLAlchemy>=1.4.15,<3.0.0", + "packaging", +] + +[project.urls] +Homepage = "https://github.com/deepnote/sqlalchemy-redshift" + +[project.entry-points."sqlalchemy.dialects"] +redshift = "sqlalchemy_redshift.dialect:RedshiftDialect_psycopg2" +"redshift.psycopg2" = "sqlalchemy_redshift.dialect:RedshiftDialect_psycopg2" +"redshift.redshift_connector" = "sqlalchemy_redshift.dialect:RedshiftDialect_redshift_connector" + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.1" +vcs = "git" +style = "pep440" + +[tool.hatch.build.targets.wheel] +packages = ["sqlalchemy_redshift"] + +[tool.hatch.build.targets.wheel.force-include] +"sqlalchemy_redshift/redshift-ca-bundle.crt" = "sqlalchemy_redshift/redshift-ca-bundle.crt" + +[tool.hatch.build.targets.sdist] +include = [ + "sqlalchemy_redshift/", + "README.rst", + "CHANGES.rst", + "LICENSE", +] diff --git a/redshift_sqlalchemy/__init__.py b/redshift_sqlalchemy/__init__.py deleted file mode 100644 index f86c9061..00000000 --- a/redshift_sqlalchemy/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -Compatibility module for projects referencing sqlalchemy_redshift -by its old name "redshift_sqlalchemy". -""" - -import sys -import warnings - -import sqlalchemy_redshift - - -DEPRECATION_MESSAGE = """\ -redshift_sqlalchemy has been renamed to sqlalchemy_redshift. - -The redshift_sqlalchemy compatibility package will be removed in -a future release, so it is recommended to update all package references. -""" - -warnings.warn(DEPRECATION_MESSAGE, DeprecationWarning) - -# All references to module redshift_sqlalchemy will map to sqlalchemy_redshift -sys.modules['redshift_sqlalchemy'] = sqlalchemy_redshift diff --git a/requirements-docs.txt b/requirements-docs.txt index da0a3e3f..f4aa8107 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,5 +1,6 @@ -e . -sphinx==1.6.3 -numpydoc==0.6.0 -psycopg2-binary==2.9.1 -jinja2<3.1.0 +sphinx>=7.0.0,<8.0.0 +numpydoc>=1.5.0,<2.0.0 +psycopg2-binary>=2.9.9,<3.0.0 +jinja2>=3.1.0,<4.0.0 +setuptools>=65.0.0,<76.0.0 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 3c6e79cf..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[bdist_wheel] -universal=1 diff --git a/setup.py b/setup.py deleted file mode 100644 index ef5c2618..00000000 --- a/setup.py +++ /dev/null @@ -1,53 +0,0 @@ -from setuptools import setup - -readme = open('README.rst').read() -history = open('CHANGES.rst').read().replace('.. :changelog:', '') - -setup( - name='sqlalchemy-redshift', - version='0.8.15', - description='Amazon Redshift Dialect for sqlalchemy', - long_description=readme + '\n\n' + history, - long_description_content_type='text/x-rst', - author='Matt George', - author_email='mgeorge@gmail.com', - maintainer='Thomas Grainger', - maintainer_email='sqlalchemy-redshift@graingert.co.uk', - license="MIT", - url='https://github.com/sqlalchemy-redshift/sqlalchemy-redshift', - packages=['sqlalchemy_redshift', 'redshift_sqlalchemy'], - package_data={'sqlalchemy_redshift': ['redshift-ca-bundle.crt']}, - python_requires='>=3.4', - install_requires=[ - # requires sqlalchemy.sql.base.DialectKWArgs.dialect_options, new in - # version 0.9.2 - 'SQLAlchemy>=0.9.2,<3.0.0', - 'packaging', - ], - classifiers=[ - "Development Status :: 4 - Beta", - "Environment :: Console", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - ], - entry_points={ - 'sqlalchemy.dialects': [ - 'redshift = sqlalchemy_redshift.dialect:RedshiftDialect_psycopg2', - 'redshift.psycopg2 = sqlalchemy_redshift.dialect:RedshiftDialect_psycopg2', - 'redshift.psycopg2cffi = sqlalchemy_redshift.dialect:RedshiftDialect_psycopg2cffi', - 'redshift.redshift_connector = sqlalchemy_redshift.dialect:RedshiftDialect_redshift_connector', - ] - }, -) diff --git a/sqlalchemy_redshift/__init__.py b/sqlalchemy_redshift/__init__.py index 934002cb..89b54d74 100644 --- a/sqlalchemy_redshift/__init__.py +++ b/sqlalchemy_redshift/__init__.py @@ -1,14 +1,17 @@ -from pkg_resources import DistributionNotFound, get_distribution, parse_version +from importlib.metadata import version, PackageNotFoundError +from packaging.version import parse as parse_version -for package in ['psycopg2', 'psycopg2-binary', 'psycopg2cffi']: +MIN_PSYCOPG2_VERSION = parse_version('2.5') + +for package in ['psycopg2', 'psycopg2-binary']: try: - if get_distribution(package).parsed_version < parse_version('2.5'): + if parse_version(version(package)) < MIN_PSYCOPG2_VERSION: raise ImportError('Minimum required version for psycopg2 is 2.5') break - except DistributionNotFound: + except PackageNotFoundError: pass -__version__ = get_distribution('sqlalchemy-redshift').version +__version__ = version('deepnote-sqlalchemy-redshift') from sqlalchemy.dialects import registry # noqa @@ -20,10 +23,6 @@ "redshift.psycopg2", "sqlalchemy_redshift.dialect", "RedshiftDialect_psycopg2" ) -registry.register( - 'redshift+psycopg2cffi', 'sqlalchemy_redshift.dialect', - 'RedshiftDialect_psycopg2cffi', -) registry.register( "redshift+redshift_connector", "sqlalchemy_redshift.dialect", diff --git a/sqlalchemy_redshift/commands.py b/sqlalchemy_redshift/commands.py index af7754da..69604d00 100644 --- a/sqlalchemy_redshift/commands.py +++ b/sqlalchemy_redshift/commands.py @@ -29,9 +29,9 @@ TOKEN_RE = re.compile('[A-Za-z0-9/+=]+') AWS_PARTITIONS = frozenset({'aws', 'aws-cn', 'aws-us-gov'}) AWS_ACCOUNT_ID_RE = re.compile('[0-9]{12}') -IAM_ROLE_NAME_RE = re.compile('[A-Za-z0-9+=,.@\-_]{1,64}') # noqa -IAM_ROLE_ARN_RE = re.compile('arn:(aws|aws-cn|aws-us-gov):iam::' - '[0-9]{12}:role/[A-Za-z0-9+=,.@\-_]{1,64}') # noqa +IAM_ROLE_NAME_RE = re.compile(r'[A-Za-z0-9+=,.@\-_]{1,64}') +IAM_ROLE_ARN_RE = re.compile(r'arn:(aws|aws-cn|aws-us-gov):iam::' + r'[0-9]{12}:role/[A-Za-z0-9+=,.@\-_]{1,64}') def _process_aws_credentials(access_key_id=None, secret_access_key=None, @@ -501,7 +501,7 @@ class CopyCommand(_ExecutableClause): aws_account_id: str, optional AWS account ID for role-based credentials. Required unless you supply key based credentials (``access_key_id`` and ``secret_access_key``) - or role arns (``iam_role_arns``) directly. + or role arns (``iam_role_arns``) directly. iam_role_name: str, optional IAM role name for role-based credentials. Required unless you supply key based credentials (``access_key_id`` and ``secret_access_key``) @@ -930,7 +930,7 @@ class CreateLibraryCommand(_ExecutableClause): aws_account_id: str, optional AWS account ID for role-based credentials. Required unless you supply key based credentials (``access_key_id`` and ``secret_access_key``) - or role arns (``iam_role_arns``) directly. + or role arns (``iam_role_arns``) directly. iam_role_name: str, optional IAM role name for role-based credentials. Required unless you supply key based credentials (``access_key_id`` and ``secret_access_key``) diff --git a/sqlalchemy_redshift/ddl.py b/sqlalchemy_redshift/ddl.py index 4f0cd659..6caca0ef 100644 --- a/sqlalchemy_redshift/ddl.py +++ b/sqlalchemy_redshift/ddl.py @@ -116,7 +116,7 @@ class CreateMaterializedView(DDLElement): ... sa.Column('id', sa.Integer, primary_key=True), ... sa.Column('name', sa.String) ... ) - >>> selectable = sa.select([user.c.id, user.c.name], from_obj=user) + >>> selectable = sa.select(user.c.id, user.c.name) >>> view = CreateMaterializedView( ... 'materialized_view_of_users', ... selectable, @@ -124,13 +124,10 @@ class CreateMaterializedView(DDLElement): ... sortkey='name' ... ) >>> print(view.compile(engine)) - CREATE MATERIALIZED VIEW materialized_view_of_users DISTKEY (id) SORTKEY (name) AS SELECT "user".id, "user".name FROM "user" - - The materialized view can take full advantage of Redshift's distributed architecture via distribution styles and sort keys. @@ -224,10 +221,7 @@ class DropMaterializedView(DDLElement): ... if_exists=True ... ) >>> print(drop.compile(engine)) - DROP MATERIALIZED VIEW IF EXISTS materialized_view_of_users - - This can be included in any execute() statement. """ diff --git a/sqlalchemy_redshift/dialect.py b/sqlalchemy_redshift/dialect.py index a12128fb..f586aff9 100644 --- a/sqlalchemy_redshift/dialect.py +++ b/sqlalchemy_redshift/dialect.py @@ -3,25 +3,25 @@ import re from collections import defaultdict, namedtuple from logging import getLogger +from importlib.resources import files -import pkg_resources import sqlalchemy as sa from sqlalchemy.sql import text from packaging.version import Version -from sqlalchemy import inspect -from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION +from sqlalchemy import inspect, util +from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM from sqlalchemy.dialects.postgresql.base import (PGCompiler, PGDDLCompiler, PGDialect, PGExecutionContext, PGIdentifierPreparer, PGTypeCompiler) from sqlalchemy.dialects.postgresql.psycopg2 import PGDialect_psycopg2 -from sqlalchemy.dialects.postgresql.psycopg2cffi import PGDialect_psycopg2cffi from sqlalchemy.engine import reflection from sqlalchemy.engine.default import DefaultDialect from sqlalchemy.ext.compiler import compiles from sqlalchemy.sql.expression import (BinaryExpression, BooleanClauseList, Delete) from sqlalchemy.sql.type_api import TypeEngine +from sqlalchemy.sql import sqltypes from sqlalchemy.types import (BIGINT, BOOLEAN, CHAR, DATE, DECIMAL, INTEGER, REAL, SMALLINT, TIMESTAMP, VARCHAR, NullType) @@ -72,7 +72,7 @@ class RedshiftImpl(postgresql.PostgresqlImpl): 'HLLSKETCH', 'RedshiftDialect', 'RedshiftDialect_psycopg2', - 'RedshiftDialect_psycopg2cffi', 'RedshiftDialect_redshift_connector', + 'RedshiftDialect_redshift_connector', 'CopyCommand', 'UnloadFromSelect', 'Compression', 'Encoding', 'Format', 'CreateLibraryCommand', 'AlterTableAppendCommand', @@ -981,6 +981,191 @@ def _get_table_or_view_names(self, relkind, connection, schema=None, **kw): relation_names.append(key.name) return relation_names + # Copied from SQLAlchemy 1.4 to support 1.4 and 2.0 simultaneously + # https://github.com/sqlalchemy/sqlalchemy/blob/rel_1_4/lib/sqlalchemy/dialects/postgresql/base.py#L4007 + def __get_column_info( + self, + name, + format_type, + default, + notnull, + domains, + enums, + schema, + comment, + generated, + identity, + ): + def _handle_array_type(attype): + return ( + # strip '[]' from integer[], etc. + re.sub(r"\[\]$", "", attype), + attype.endswith("[]"), + ) + + if format_type is None: + no_format_type = True + attype = format_type = "no format_type()" + is_array = False + else: + no_format_type = False + + # strip (*) from character varying(5), timestamp(5) + # with time zone, geometry(POLYGON), etc. + attype = re.sub(r"\(.*\)", "", format_type) + + # strip '[]' from integer[], etc. and check if an array + attype, is_array = _handle_array_type(attype) + + # strip quotes from case sensitive enum or domain names + enum_or_domain_key = tuple(util.quoted_token_parser(attype)) + + nullable = not notnull + + charlen = re.search(r"\(([\d,]+)\)", format_type) + if charlen: + charlen = charlen.group(1) + args = re.search(r"\((.*)\)", format_type) + if args and args.group(1): + args = tuple(re.split(r"\s*,\s*", args.group(1))) + else: + args = () + kwargs = {} + + if attype == "numeric": + if charlen: + prec, scale = charlen.split(",") + args = (int(prec), int(scale)) + else: + args = () + elif attype == "double precision": + args = (53,) + elif attype == "integer": + args = () + elif attype in ("timestamp with time zone", "time with time zone"): + kwargs["timezone"] = True + if charlen: + kwargs["precision"] = int(charlen) + args = () + elif attype in ( + "timestamp without time zone", + "time without time zone", + "time", + ): + kwargs["timezone"] = False + if charlen: + kwargs["precision"] = int(charlen) + args = () + elif attype == "bit varying": + kwargs["varying"] = True + if charlen: + args = (int(charlen),) + else: + args = () + elif attype.startswith("interval"): + field_match = re.match(r"interval (.+)", attype, re.I) + if charlen: + kwargs["precision"] = int(charlen) + if field_match: + kwargs["fields"] = field_match.group(1) + attype = "interval" + args = () + elif charlen: + args = (int(charlen),) + + while True: + # looping here to suit nested domains + if attype in self.ischema_names: + coltype = self.ischema_names[attype] + break + elif enum_or_domain_key in enums: + enum = enums[enum_or_domain_key] + coltype = ENUM + kwargs["name"] = enum["name"] + if not enum["visible"]: + kwargs["schema"] = enum["schema"] + args = tuple(enum["labels"]) + break + elif enum_or_domain_key in domains: + domain = domains[enum_or_domain_key] + attype = domain["attype"] + attype, is_array = _handle_array_type(attype) + # strip quotes from case sensitive enum or domain names + enum_or_domain_key = tuple(util.quoted_token_parser(attype)) + # A table can't override a not null on the domain, + # but can override nullable + nullable = nullable and domain["nullable"] + if domain["default"] and not default: + # It can, however, override the default + # value, but can't set it to null. + default = domain["default"] + continue + else: + coltype = None + break + + if coltype: + coltype = coltype(*args, **kwargs) + if is_array: + coltype = self.ischema_names["_array"](coltype) + elif no_format_type: + util.warn( + "PostgreSQL format_type() returned NULL for column '%s'" + % (name,) + ) + coltype = sqltypes.NULLTYPE + else: + util.warn( + "Did not recognize type '%s' of column '%s'" % (attype, name) + ) + coltype = sqltypes.NULLTYPE + + # If a zero byte or blank string depending on driver (is also absent + # for older PG versions), then not a generated column. Otherwise, s = + # stored. (Other values might be added in the future.) + if generated not in (None, "", b"\x00"): + computed = dict( + sqltext=default, persisted=generated in ("s", b"s") + ) + default = None + else: + computed = None + + # adjust the default value + autoincrement = False + if default is not None: + match = re.search(r"""(nextval\(')([^']+)('.*$)""", default) + if match is not None: + if issubclass(coltype._type_affinity, sqltypes.Integer): + autoincrement = True + # the default is related to a Sequence + sch = schema + if "." not in match.group(2) and sch is not None: + # unconditionally quote the schema name. this could + # later be enhanced to obey quoting rules / + # "quote schema" + default = ( + match.group(1) + + ('"%s"' % sch) + + "." + + match.group(2) + + match.group(3) + ) + + column_info = dict( + name=name, + type=coltype, + nullable=nullable, + default=default, + autoincrement=autoincrement or identity is not None, + comment=comment, + ) + if computed is not None: + column_info["computed"] = computed + if identity is not None: + column_info["identity"] = identity + return column_info + def _get_column_info(self, *args, **kwargs): kw = kwargs.copy() encode = kw.pop('encode', None) @@ -994,10 +1179,11 @@ def _get_column_info(self, *args, **kwargs): elif sa_version >= Version('1.4.0') and 'identity' not in kw: kw['identity'] = None - column_info = super(RedshiftDialectMixin, self)._get_column_info( + column_info = self.__get_column_info( *args, **kw ) + if isinstance(column_info['type'], VARCHAR): if column_info['type'].length is None: column_info['type'] = NullType() @@ -1213,9 +1399,8 @@ def create_connect_args(self, *args, **kwargs): """ default_args = { 'sslmode': 'verify-full', - 'sslrootcert': pkg_resources.resource_filename( - __name__, - 'redshift-ca-bundle.crt' + 'sslrootcert': str( + files('sqlalchemy_redshift').joinpath('redshift-ca-bundle.crt') ), } cargs, cparams = ( @@ -1227,13 +1412,18 @@ def create_connect_args(self, *args, **kwargs): return cargs, default_args @classmethod - def dbapi(cls): + def import_dbapi(cls): try: return importlib.import_module(cls.driver) except ImportError: raise ImportError( 'No module named {}'.format(cls.driver) - ) + ) from None + + @classmethod + def dbapi(cls): + # Backwards compatibility with SQLAlchemy < 2.0 + return cls.import_dbapi() class RedshiftDialect_psycopg2( @@ -1241,6 +1431,16 @@ class RedshiftDialect_psycopg2( ): supports_statement_cache = False + @classmethod + def import_dbapi(cls): + # Use super() to properly call the mixin's implementation + return super().import_dbapi() + + @classmethod + def dbapi(cls): + # Use super() for backwards compatibility with SQLAlchemy < 2.0 + return super().dbapi() + def _set_backslash_escapes(self, connection): self._backslash_escapes = "off" @@ -1249,15 +1449,6 @@ def _set_backslash_escapes(self, connection): RedshiftDialect = RedshiftDialect_psycopg2 -class RedshiftDialect_psycopg2cffi( - Psycopg2RedshiftDialectMixin, PGDialect_psycopg2cffi -): - supports_statement_cache = False - - def _set_backslash_escapes(self, connection): - self._backslash_escapes = "off" - - class RedshiftDialect_redshift_connector(RedshiftDialectMixin, PGDialect): class RedshiftCompiler_redshift_connector(RedshiftCompiler, PGCompiler): @@ -1316,7 +1507,7 @@ def __init__(self, client_encoding=None, **kwargs): self.client_encoding = client_encoding @classmethod - def dbapi(cls): + def import_dbapi(cls): try: driver_module = importlib.import_module(cls.driver) @@ -1333,6 +1524,11 @@ def dbapi(cls): 'redshift_connector to use this sqlalchemy dialect.' ) + @classmethod + def dbapi(cls): + # Backwards compatibility with SQLAlchemy < 2.0 + return cls.import_dbapi() + def set_client_encoding(self, connection, client_encoding): """ Sets the client-side encoding using the provided connection object. diff --git a/tests/conftest.py b/tests/conftest.py index 2f7a72d8..b8105429 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,8 +63,8 @@ def connection_kwargs(redshift_dialect_flavor): @pytest.fixture(scope="session") def iam_role_arn(): - """ The iam_role_arn fixture constructs the ARN for the IAM role. If provided, - the following environment variable will be used. + """ The iam_role_arn fixture constructs the ARN for the IAM role. + If provided, the following environment variable will be used. - REDSHIFT_IAM_ROLE_ARN """ @@ -175,21 +175,10 @@ def migrate(self, engine): @contextlib.contextmanager def _database(self): - from sqlalchemy_redshift.dialect import \ - RedshiftDialect_psycopg2cffi - db_name = database_name() - opts = ( - {"isolation_level": "AUTOCOMMIT"} - if not isinstance( - self.engine.dialect, RedshiftDialect_psycopg2cffi - ) - else {} - ) + opts = {"isolation_level": "AUTOCOMMIT"} with self.engine.connect().execution_options(**opts) as conn: - if isinstance(self.engine.dialect, RedshiftDialect_psycopg2cffi): - conn.execute(sa.text("COMMIT")) conn.execute( sa.text('CREATE DATABASE {db_name}'.format(db_name=db_name)) ) @@ -207,10 +196,6 @@ def _database(self): ) finally: with self.engine.connect().execution_options(**opts) as conn: - if isinstance( - self.engine.dialect, RedshiftDialect_psycopg2cffi - ): - conn.execute(sa.text("COMMIT")) conn.execute( sa.text('DROP DATABASE {db_name}'.format(db_name=db_name)) ) @@ -247,7 +232,7 @@ class DriverParameterizedTests: Helper class for generating fixture params using pytest config opts. """ - DEFAULT_DRIVERS = ['psycopg2', 'psycopg2cffi'] + DEFAULT_DRIVERS = ['psycopg2'] redshift_dialect_flavors = None @classmethod diff --git a/tests/rs_sqla_test_utils/models.py b/tests/rs_sqla_test_utils/models.py index c594fa0a..42b8b905 100644 --- a/tests/rs_sqla_test_utils/models.py +++ b/tests/rs_sqla_test_utils/models.py @@ -2,10 +2,14 @@ from sqlalchemy import event from sqlalchemy import DDL -from sqlalchemy.ext import declarative +try: + from sqlalchemy.orm import declarative_base +except ImportError: + from sqlalchemy.ext.declarative import declarative_base -Base = declarative.declarative_base() + +Base = declarative_base() event.listen( Base.metadata, 'before_create', diff --git a/tests/test_column_loading.py b/tests/test_column_loading.py index 349a31cc..0eab2f86 100644 --- a/tests/test_column_loading.py +++ b/tests/test_column_loading.py @@ -5,7 +5,7 @@ from sqlalchemy.types import NullType, VARCHAR from sqlalchemy_redshift.dialect import ( - RedshiftDialect_psycopg2, RedshiftDialect_psycopg2cffi + RedshiftDialect_psycopg2 ) sa_version = Version(sa.__version__) @@ -17,7 +17,7 @@ def test_varchar_as_nulltype(self): Varchar columns with no length should be considered NullType columns """ for dialect in [ - RedshiftDialect_psycopg2(), RedshiftDialect_psycopg2cffi() + RedshiftDialect_psycopg2() ]: null_info = dialect._get_column_info( diff --git a/tests/test_compiler.py b/tests/test_compiler.py index 9f02f005..c811cae4 100644 --- a/tests/test_compiler.py +++ b/tests/test_compiler.py @@ -3,6 +3,6 @@ def test_func_now(stub_redshift_dialect): dialect = stub_redshift_dialect - s = select([func.NOW().label("time")]) + s = select(func.NOW().label("time")) compiled = s.compile(dialect=dialect) assert str(compiled) == "SELECT SYSDATE AS time" diff --git a/tests/test_copy_command.py b/tests/test_copy_command.py index 8783978d..f1b5f662 100644 --- a/tests/test_copy_command.py +++ b/tests/test_copy_command.py @@ -174,7 +174,7 @@ def test_format(stub_redshift_dialect): data_location='s3://mybucket/data/listing/', access_key_id=access_key_id, secret_access_key=secret_access_key, - format='JSON', + format=dialect.Format.json, truncate_columns=True, delimiter=',', ignore_header=0, @@ -246,7 +246,7 @@ def test_compression(stub_redshift_dialect): data_location='s3://mybucket/data/listing/', access_key_id=access_key_id, secret_access_key=secret_access_key, - compression='LZOP', + compression=dialect.Compression.lzop, truncate_columns=True, delimiter=',', ignore_header=0, @@ -285,7 +285,7 @@ def test_ascii_nul_as_redshift_null(stub_redshift_dialect): data_location='s3://mybucket/data/listing/', access_key_id=access_key_id, secret_access_key=secret_access_key, - compression='BZIP2', + compression=dialect.Compression.bzip2, dangerous_null_delimiter=u'\000', truncate_columns=True, delimiter=',', @@ -313,9 +313,9 @@ def test_json_upload_with_manifest_ordered_columns(stub_redshift_dialect): access_key_id=access_key_id, secret_access_key=secret_access_key, manifest=True, - format='JSON', + format=dialect.Format.json, path_file='s3://mybucket/data/jsonpath.json', - compression='GZIP', + compression=dialect.Compression.gzip, time_format='auto', accept_any_date=True, ) diff --git a/tests/test_default_ssl.py b/tests/test_default_ssl.py index f5586d30..aab49da8 100644 --- a/tests/test_default_ssl.py +++ b/tests/test_default_ssl.py @@ -1,11 +1,13 @@ import sqlalchemy as sa -from pkg_resources import resource_filename +from importlib.resources import files from sqlalchemy_redshift.dialect import ( Psycopg2RedshiftDialectMixin, RedshiftDialect_redshift_connector ) -CERT_PATH = resource_filename("sqlalchemy_redshift", "redshift-ca-bundle.crt") +CERT_PATH = str( + files("sqlalchemy_redshift").joinpath("redshift-ca-bundle.crt") +) def test_ssl_args(redshift_dialect_flavor): diff --git a/tests/test_delete_stmt.py b/tests/test_delete_stmt.py index 62089c04..bfccaf0d 100644 --- a/tests/test_delete_stmt.py +++ b/tests/test_delete_stmt.py @@ -175,7 +175,7 @@ def test_delete_stmt_subqueryplusjoin(stub_redshift_dialect): ).where( orders.c.customer_id.in_( sa.select( - [customers.c.id] + customers.c.id ).where(customers.c.email.endswith('test.com')) ) ).where( @@ -202,7 +202,7 @@ def test_delete_stmt_subquery(stub_redshift_dialect): ).where( orders.c.customer_id.in_( sa.select( - [customers.c.id] + customers.c.id ).where(customers.c.email.endswith('test.com')) ) ) @@ -222,7 +222,7 @@ def test_delete_stmt_on_subquerycomma(stub_redshift_dialect): ).where( ham.c.id.in_( sa.select( - [hammy_spam.c.ham_id] + hammy_spam.c.ham_id ) ) ) @@ -266,7 +266,7 @@ def test_delete_stmt_with_comma_subquery_alias_join(stub_redshift_dialect): items.c.order_id == orders.c.id ).where( orders.c.customer_id.in_( - sa.select([customers.c.id]).where( + sa.select(customers.c.id).where( customers.c.email.endswith('test.com') ) ) diff --git a/tests/test_dialects.py b/tests/test_dialects.py index 0b8e522a..e903a19e 100644 --- a/tests/test_dialects.py +++ b/tests/test_dialects.py @@ -1,18 +1,15 @@ import pytest -from sqlalchemy.dialects.postgresql import ( - psycopg2, psycopg2cffi -) +from sqlalchemy.dialects.postgresql import psycopg2 from sqlalchemy.dialects.postgresql.base import PGDialect -from redshift_sqlalchemy import dialect +from sqlalchemy_redshift import dialect from rs_sqla_test_utils.utils import make_mock_engine @pytest.mark.parametrize('name, expected_dialect', [ ('redshift', psycopg2.dialect), ('redshift+psycopg2', psycopg2.dialect), - ('redshift+psycopg2cffi', psycopg2cffi.dialect), ('redshift+redshift_connector', PGDialect), ]) def test_dialect_inherits_from_sqlalchemy_dialect(name, expected_dialect): @@ -24,7 +21,6 @@ def test_dialect_inherits_from_sqlalchemy_dialect(name, expected_dialect): @pytest.mark.parametrize('name, expected_dialect', [ ('redshift', dialect.Psycopg2RedshiftDialectMixin), ('redshift+psycopg2', dialect.Psycopg2RedshiftDialectMixin), - ('redshift+psycopg2cffi', dialect.Psycopg2RedshiftDialectMixin), ]) def test_dialect_inherits_from_redshift_mixin(name, expected_dialect): engine = make_mock_engine(name) @@ -35,7 +31,6 @@ def test_dialect_inherits_from_redshift_mixin(name, expected_dialect): @pytest.mark.parametrize('name, expected_dialect', [ ('redshift', dialect.RedshiftDialect_psycopg2), ('redshift+psycopg2', dialect.RedshiftDialect_psycopg2), - ('redshift+psycopg2cffi', dialect.RedshiftDialect_psycopg2cffi), ]) def test_dialect_registered_correct_class(name, expected_dialect): engine = make_mock_engine(name) diff --git a/tests/test_materialized_views.py b/tests/test_materialized_views.py index b8558d67..3423be46 100644 --- a/tests/test_materialized_views.py +++ b/tests/test_materialized_views.py @@ -11,7 +11,7 @@ def selectable(): MetaData(), Column('id', Integer, primary_key=True), Column('name', String)) - return select([table.c.id, table.c.name], from_obj=table) + return select(table.c.id, table.c.name) def test_basic_materialized_view(selectable, stub_redshift_dialect): @@ -63,7 +63,7 @@ def test_distkey_materialized_view(selectable, stub_redshift_dialect): DISTKEY (id) AS SELECT t1.id, t1.name FROM t1 """ - for key in ("id", selectable.c.id): + for key in ("id", selectable.selected_columns.id): view = dialect.CreateMaterializedView( "test_view", selectable, @@ -79,7 +79,7 @@ def test_sortkey_materialized_view(selectable, stub_redshift_dialect): SORTKEY (id) AS SELECT t1.id, t1.name FROM t1 """ - for key in ("id", selectable.c.id): + for key in ("id", selectable.selected_columns.id): view = dialect.CreateMaterializedView( "test_view", selectable, @@ -97,7 +97,7 @@ def test_interleaved_sortkey_materialized_view( INTERLEAVED SORTKEY (id) AS SELECT t1.id, t1.name FROM t1 """ - for key in ("id", selectable.c.id): + for key in ("id", selectable.selected_columns.id): view = dialect.CreateMaterializedView( "test_view", selectable, diff --git a/tests/test_reflection.py b/tests/test_reflection.py index c0238ca9..a93be168 100644 --- a/tests/test_reflection.py +++ b/tests/test_reflection.py @@ -226,19 +226,10 @@ def test_external_table_reflection(redshift_engine, iam_role_arn): location 's3://awssampledbuswest2/tickit/spectrum/sales/' table properties ('numRows'='172000'); """ - from sqlalchemy_redshift.dialect import \ - RedshiftDialect_psycopg2cffi - opts = ( - {"isolation_level": "AUTOCOMMIT"} - if not isinstance( - redshift_engine.dialect, RedshiftDialect_psycopg2cffi - ) else {} - ) + opts = {"isolation_level": "AUTOCOMMIT"} with redshift_engine.connect().execution_options(**opts) as conn: conn.execute(sa.text(table_ddl)) - if isinstance(redshift_engine.dialect, RedshiftDialect_psycopg2cffi): - conn.execute(sa.text("COMMIT")) insp = inspect(redshift_engine) table_columns_definition = insp.get_columns( @@ -258,5 +249,3 @@ def test_external_table_reflection(redshift_engine, iam_role_arn): conn.execute( sa.text("DROP SCHEMA IF EXISTS bananas DROP EXTERNAL DATABASE") ) - if isinstance(redshift_engine.dialect, RedshiftDialect_psycopg2cffi): - conn.execute(sa.text("COMMIT")) diff --git a/tests/test_reflection_views.py b/tests/test_reflection_views.py index d15470f6..47d1ad6b 100644 --- a/tests/test_reflection_views.py +++ b/tests/test_reflection_views.py @@ -26,7 +26,7 @@ def test_view_reflection(redshift_engine): ) == clean(view_query) view = Table('my_view', MetaData(), autoload=True, autoload_with=redshift_engine) - assert(len(view.columns) == 2) + assert (len(view.columns) == 2) finally: conn.execute(sa.text('DROP TABLE IF EXISTS my_table CASCADE')) conn.execute(sa.text('DROP VIEW IF EXISTS my_view CASCADE')) @@ -53,7 +53,7 @@ def test_late_binding_view_reflection(redshift_engine): ) == clean(view_ddl) view = Table('my_late_view', MetaData(), autoload=True, autoload_with=redshift_engine) - assert(len(view.columns) == 2) + assert (len(view.columns) == 2) finally: conn.execute(sa.text('DROP TABLE IF EXISTS my_table CASCADE')) conn.execute(sa.text('DROP VIEW IF EXISTS my_late_view CASCADE')) diff --git a/tests/test_unload_from_select.py b/tests/test_unload_from_select.py index 48cee5d5..c8373921 100644 --- a/tests/test_unload_from_select.py +++ b/tests/test_unload_from_select.py @@ -30,7 +30,7 @@ def test_basic_unload_case(stub_redshift_dialect): """Tests that the simplest type of UnloadFromSelect works.""" unload = dialect.UnloadFromSelect( - select=sa.select([sa.func.count(table.c.id)]), + select=sa.select(sa.func.count(table.c.id)), unload_location='s3://bucket/key', access_key_id=access_key_id, secret_access_key=secret_access_key, @@ -57,7 +57,7 @@ def test_iam_role( creds = f'aws_iam_role={iam_role_arn}' unload = dialect.UnloadFromSelect( - select=sa.select([sa.func.count(table.c.id)]), + select=sa.select(sa.func.count(table.c.id)), unload_location='s3://bucket/key', aws_account_id=aws_account_id, iam_role_name=iam_role_name, @@ -81,7 +81,7 @@ def test_iam_role_partition( creds = f'aws_iam_role={iam_role_arn_with_aws_partition}' unload = dialect.UnloadFromSelect( - select=sa.select([sa.func.count(table.c.id)]), + select=sa.select(sa.func.count(table.c.id)), unload_location='s3://bucket/key', aws_partition='aws-us-gov', aws_account_id='000123456789', @@ -107,7 +107,7 @@ def test_iam_role_partition_validation(): with pytest.raises(ValueError): dialect.UnloadFromSelect( - select=sa.select([sa.func.count(table.c.id)]), + select=sa.select(sa.func.count(table.c.id)), unload_location='s3://bucket/key', aws_partition=aws_partition, aws_account_id=aws_account_id, @@ -121,7 +121,7 @@ def test_iam_role_arns_list(stub_redshift_dialect, iam_role_arns): creds = f'aws_iam_role={",".join(iam_role_arns)}' unload = dialect.UnloadFromSelect( - select=sa.select([sa.func.count(table.c.id)]), + select=sa.select(sa.func.count(table.c.id)), unload_location='s3://bucket/key', iam_role_arns=iam_role_arns, ) @@ -142,7 +142,7 @@ def test_iam_role_arns_single(stub_redshift_dialect, iam_role_arn): creds = f'aws_iam_role={iam_role_arn}' unload = dialect.UnloadFromSelect( - select=sa.select([sa.func.count(table.c.id)]), + select=sa.select(sa.func.count(table.c.id)), unload_location='s3://bucket/key', iam_role_arns=iam_role_arn, ) @@ -161,7 +161,7 @@ def test_all_redshift_options(stub_redshift_dialect): """Tests that UnloadFromSelect handles all options correctly.""" unload = dialect.UnloadFromSelect( - sa.select([sa.func.count(table.c.id)]), + sa.select(sa.func.count(table.c.id)), unload_location='s3://bucket/key', access_key_id=access_key_id, secret_access_key=secret_access_key, @@ -205,7 +205,7 @@ def test_all_redshift_options_with_header(stub_redshift_dialect): """Tests that UnloadFromSelect handles all options correctly.""" unload = dialect.UnloadFromSelect( - sa.select([sa.func.count(table.c.id)]), + sa.select(sa.func.count(table.c.id)), unload_location='s3://bucket/key', access_key_id=access_key_id, secret_access_key=secret_access_key, @@ -249,7 +249,7 @@ def test_csv_format__basic(stub_redshift_dialect): """Tests that UnloadFromSelect uses the format option correctly.""" unload = dialect.UnloadFromSelect( - select=sa.select([sa.func.count(table.c.id)]), + select=sa.select(sa.func.count(table.c.id)), unload_location='s3://bucket/key', access_key_id=access_key_id, secret_access_key=secret_access_key, @@ -279,7 +279,7 @@ def test_csv_format__bad_options_crash( FIXEDWIDTH with the CSV format. """ unload = dialect.UnloadFromSelect( - select=sa.select([sa.func.count(table.c.id)]), + select=sa.select(sa.func.count(table.c.id)), unload_location='s3://bucket/key', access_key_id=access_key_id, secret_access_key=secret_access_key, @@ -295,7 +295,7 @@ def test_csv_format__bad_options_crash( def test_parquet_format__basic(stub_redshift_dialect): """Basic successful test of unloading with the Parquet format.""" unload = dialect.UnloadFromSelect( - select=sa.select([sa.func.count(table.c.id)]), + select=sa.select(sa.func.count(table.c.id)), unload_location='s3://bucket/key', access_key_id=access_key_id, secret_access_key=secret_access_key, @@ -324,7 +324,7 @@ def test_parquet_format__basic(stub_redshift_dialect): def test_parquet_format__bad_options_crash(kwargs, stub_redshift_dialect): """Verify we crash if we use the Parquet format with a bad option.""" unload = dialect.UnloadFromSelect( - select=sa.select([sa.func.count(table.c.id)]), + select=sa.select(sa.func.count(table.c.id)), unload_location='s3://bucket/key', access_key_id=access_key_id, secret_access_key=secret_access_key, diff --git a/tox.ini b/tox.ini index 4d30f448..7c8e2fc8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,40 +1,41 @@ [tox] +requires = tox>=4.2 envlist = - py39-pg28-sa13 - py39-pg28-sa14 - py310-pg28-sa13 - py310-pg28-sa14 - py310-pg28-sa20 - py311-pg28-sa20 - py312-pg28-sa20 + py39-pg29-sa14 + py310-pg29-sa14 + py310-pg29-sa20 + py311-pg29-sa14 + py311-pg29-sa20 + py312-pg29-sa14 + py312-pg29-sa20 + py313-pg29-sa14 + py313-pg29-sa20 lint docs [testenv] -commands = pytest {posargs} --dbdriver psycopg2 --dbdriver psycopg2cffi --dbdriver redshift_connector +commands = pytest {posargs} --dbdriver psycopg2 --dbdriver redshift_connector passenv = PGPASSWORD,REDSHIFT_USERNAME,REDSHIFT_HOST,REDSHIFT_PORT,REDSHIFT_DATABASE,REDSHIFT_IAM_ROLE_ARN,AWS_ACCOUNT_ID,REDSHIFT_IAM_ROLE_NAME deps = - sa13: sqlalchemy==1.3.24 - sa14: sqlalchemy==1.4.15 - sa20: sqlalchemy==2.0.23 - pg28: psycopg2==2.8.6 - pg29: psycopg2==2.9.5 - alembic==1.9.2 - packaging==20.4 - psycopg2cffi==2.8.1 - pytest==7.2.1 - requests==2.25.0 - redshift_connector==2.0.907 - requests==2.25.0 + sa14: sqlalchemy>=1.4.15,<1.5.0 + sa20: sqlalchemy>=2.0.36,<3.0.0 + pg28: psycopg2-binary>=2.8.6,<2.9.0 + pg29: psycopg2-binary>=2.9.0,<3.0.0 + alembic>=1.9.2,<2.0.0 + packaging>=20.4 + pytest>=7.2.1,<8.0.0 + requests>=2.25.0 + redshift_connector>=2.0.907,<3.0.0 [testenv:lint] deps = - flake8==4.0.1 - psycopg2 + flake8>=7.0.0,<8.0.0 + psycopg2-binary redshift_connector commands=flake8 sqlalchemy_redshift tests [testenv:docs] +basepython = python3.12 changedir=docs deps= -rrequirements-docs.txt diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..eae5e8da --- /dev/null +++ b/uv.lock @@ -0,0 +1,142 @@ +version = 1 +revision = 2 +requires-python = ">=3.9, <3.14" + +[[package]] +name = "deepnote-sqlalchemy-redshift" +source = { editable = "." } +dependencies = [ + { name = "packaging" }, + { name = "sqlalchemy" }, +] + +[package.metadata] +requires-dist = [ + { name = "packaging" }, + { name = "sqlalchemy", specifier = ">=1.4.15,<3.0.0" }, +] + +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ed/6bfa4109fcb23a58819600392564fea69cdc6551ffd5e69ccf1d52a40cbc/greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c", size = 271061, upload-time = "2025-08-07T13:17:15.373Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fc/102ec1a2fc015b3a7652abab7acf3541d58c04d3d17a8d3d6a44adae1eb1/greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590", size = 629475, upload-time = "2025-08-07T13:42:54.009Z" }, + { url = "https://files.pythonhosted.org/packages/c5/26/80383131d55a4ac0fb08d71660fd77e7660b9db6bdb4e8884f46d9f2cc04/greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c", size = 640802, upload-time = "2025-08-07T13:45:25.52Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7c/e7833dbcd8f376f3326bd728c845d31dcde4c84268d3921afcae77d90d08/greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b", size = 636703, upload-time = "2025-08-07T13:53:12.622Z" }, + { url = "https://files.pythonhosted.org/packages/e9/49/547b93b7c0428ede7b3f309bc965986874759f7d89e4e04aeddbc9699acb/greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31", size = 635417, upload-time = "2025-08-07T13:18:25.189Z" }, + { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, + { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, + { url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" }, + { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, + { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" }, + { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, + { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, + { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, + { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, + { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, + { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, + { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c0/93885c4106d2626bf51fdec377d6aef740dfa5c4877461889a7cf8e565cc/greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c", size = 269859, upload-time = "2025-08-07T13:16:16.003Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f5/33f05dc3ba10a02dedb1485870cf81c109227d3d3aa280f0e48486cac248/greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d", size = 627610, upload-time = "2025-08-07T13:43:01.345Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a7/9476decef51a0844195f99ed5dc611d212e9b3515512ecdf7321543a7225/greenlet-3.2.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58", size = 639417, upload-time = "2025-08-07T13:45:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e0/849b9159cbb176f8c0af5caaff1faffdece7a8417fcc6fe1869770e33e21/greenlet-3.2.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4", size = 634751, upload-time = "2025-08-07T13:53:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d3/844e714a9bbd39034144dca8b658dcd01839b72bb0ec7d8014e33e3705f0/greenlet-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433", size = 634020, upload-time = "2025-08-07T13:18:36.841Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4c/f3de2a8de0e840ecb0253ad0dc7e2bb3747348e798ec7e397d783a3cb380/greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df", size = 582817, upload-time = "2025-08-07T13:18:35.48Z" }, + { url = "https://files.pythonhosted.org/packages/89/80/7332915adc766035c8980b161c2e5d50b2f941f453af232c164cff5e0aeb/greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594", size = 1111985, upload-time = "2025-08-07T13:42:42.425Z" }, + { url = "https://files.pythonhosted.org/packages/66/71/1928e2c80197353bcb9b50aa19c4d8e26ee6d7a900c564907665cf4b9a41/greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98", size = 1136137, upload-time = "2025-08-07T13:18:26.168Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/a5dc74dde38aeb2b15d418cec76ed50e1dd3d620ccda84d8199703248968/greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b", size = 281400, upload-time = "2025-08-07T14:02:20.263Z" }, + { url = "https://files.pythonhosted.org/packages/e5/44/342c4591db50db1076b8bda86ed0ad59240e3e1da17806a4cf10a6d0e447/greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb", size = 298533, upload-time = "2025-08-07T13:56:34.168Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.44" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/a7/e9ccfa7eecaf34c6f57d8cb0bb7cbdeeff27017cc0f5d0ca90fdde7a7c0d/sqlalchemy-2.0.44-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c77f3080674fc529b1bd99489378c7f63fcb4ba7f8322b79732e0258f0ea3ce", size = 2137282, upload-time = "2025-10-10T15:36:10.965Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/50bc121885bdf10833a4f65ecbe9fe229a3215f4d65a58da8a181734cae3/sqlalchemy-2.0.44-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26ef74ba842d61635b0152763d057c8d48215d5be9bb8b7604116a059e9985", size = 2127322, upload-time = "2025-10-10T15:36:12.428Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/a8573b7230a3ce5ee4b961a2d510d71b43872513647398e595b744344664/sqlalchemy-2.0.44-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4a172b31785e2f00780eccab00bc240ccdbfdb8345f1e6063175b3ff12ad1b0", size = 3214772, upload-time = "2025-10-10T15:34:15.09Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d8/c63d8adb6a7edaf8dcb6f75a2b1e9f8577960a1e489606859c4d73e7d32b/sqlalchemy-2.0.44-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9480c0740aabd8cb29c329b422fb65358049840b34aba0adf63162371d2a96e", size = 3214434, upload-time = "2025-10-10T15:47:00.473Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a6/243d277a4b54fae74d4797957a7320a5c210c293487f931cbe036debb697/sqlalchemy-2.0.44-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:17835885016b9e4d0135720160db3095dc78c583e7b902b6be799fb21035e749", size = 3155365, upload-time = "2025-10-10T15:34:17.932Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f8/6a39516ddd75429fd4ee5a0d72e4c80639fab329b2467c75f363c2ed9751/sqlalchemy-2.0.44-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cbe4f85f50c656d753890f39468fcd8190c5f08282caf19219f684225bfd5fd2", size = 3178910, upload-time = "2025-10-10T15:47:02.346Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/118355d4ad3c39d9a2f5ee4c7304a9665b3571482777357fa9920cd7a6b4/sqlalchemy-2.0.44-cp310-cp310-win32.whl", hash = "sha256:2fcc4901a86ed81dc76703f3b93ff881e08761c63263c46991081fd7f034b165", size = 2105624, upload-time = "2025-10-10T15:38:15.552Z" }, + { url = "https://files.pythonhosted.org/packages/61/83/6ae5f9466f8aa5d0dcebfff8c9c33b98b27ce23292df3b990454b3d434fd/sqlalchemy-2.0.44-cp310-cp310-win_amd64.whl", hash = "sha256:9919e77403a483ab81e3423151e8ffc9dd992c20d2603bf17e4a8161111e55f5", size = 2129240, upload-time = "2025-10-10T15:38:17.175Z" }, + { url = "https://files.pythonhosted.org/packages/e3/81/15d7c161c9ddf0900b076b55345872ed04ff1ed6a0666e5e94ab44b0163c/sqlalchemy-2.0.44-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fe3917059c7ab2ee3f35e77757062b1bea10a0b6ca633c58391e3f3c6c488dd", size = 2140517, upload-time = "2025-10-10T15:36:15.64Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d5/4abd13b245c7d91bdf131d4916fd9e96a584dac74215f8b5bc945206a974/sqlalchemy-2.0.44-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:de4387a354ff230bc979b46b2207af841dc8bf29847b6c7dbe60af186d97aefa", size = 2130738, upload-time = "2025-10-10T15:36:16.91Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3c/8418969879c26522019c1025171cefbb2a8586b6789ea13254ac602986c0/sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3678a0fb72c8a6a29422b2732fe423db3ce119c34421b5f9955873eb9b62c1e", size = 3304145, upload-time = "2025-10-10T15:34:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/94/2d/fdb9246d9d32518bda5d90f4b65030b9bf403a935cfe4c36a474846517cb/sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cf6872a23601672d61a68f390e44703442639a12ee9dd5a88bbce52a695e46e", size = 3304511, upload-time = "2025-10-10T15:47:05.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/40f2ad1da97d5c83f6c1269664678293d3fe28e90ad17a1093b735420549/sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:329aa42d1be9929603f406186630135be1e7a42569540577ba2c69952b7cf399", size = 3235161, upload-time = "2025-10-10T15:34:21.193Z" }, + { url = "https://files.pythonhosted.org/packages/95/cb/7cf4078b46752dca917d18cf31910d4eff6076e5b513c2d66100c4293d83/sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:70e03833faca7166e6a9927fbee7c27e6ecde436774cd0b24bbcc96353bce06b", size = 3261426, upload-time = "2025-10-10T15:47:07.196Z" }, + { url = "https://files.pythonhosted.org/packages/f8/3b/55c09b285cb2d55bdfa711e778bdffdd0dc3ffa052b0af41f1c5d6e582fa/sqlalchemy-2.0.44-cp311-cp311-win32.whl", hash = "sha256:253e2f29843fb303eca6b2fc645aca91fa7aa0aa70b38b6950da92d44ff267f3", size = 2105392, upload-time = "2025-10-10T15:38:20.051Z" }, + { url = "https://files.pythonhosted.org/packages/c7/23/907193c2f4d680aedbfbdf7bf24c13925e3c7c292e813326c1b84a0b878e/sqlalchemy-2.0.44-cp311-cp311-win_amd64.whl", hash = "sha256:7a8694107eb4308a13b425ca8c0e67112f8134c846b6e1f722698708741215d5", size = 2130293, upload-time = "2025-10-10T15:38:21.601Z" }, + { url = "https://files.pythonhosted.org/packages/62/c4/59c7c9b068e6813c898b771204aad36683c96318ed12d4233e1b18762164/sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250", size = 2139675, upload-time = "2025-10-10T16:03:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ae/eeb0920537a6f9c5a3708e4a5fc55af25900216bdb4847ec29cfddf3bf3a/sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29", size = 2127726, upload-time = "2025-10-10T16:03:35.934Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d5/2ebbabe0379418eda8041c06b0b551f213576bfe4c2f09d77c06c07c8cc5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44", size = 3327603, upload-time = "2025-10-10T15:35:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/5aa65852dadc24b7d8ae75b7efb8d19303ed6ac93482e60c44a585930ea5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1", size = 3337842, upload-time = "2025-10-10T15:43:45.431Z" }, + { url = "https://files.pythonhosted.org/packages/41/92/648f1afd3f20b71e880ca797a960f638d39d243e233a7082c93093c22378/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7", size = 3264558, upload-time = "2025-10-10T15:35:29.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/e27d7ee61a10f74b17740918e23cbc5bc62011b48282170dc4c66da8ec0f/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d", size = 3301570, upload-time = "2025-10-10T15:43:48.407Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3d/3116a9a7b63e780fb402799b6da227435be878b6846b192f076d2f838654/sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4", size = 2103447, upload-time = "2025-10-10T15:03:21.678Z" }, + { url = "https://files.pythonhosted.org/packages/25/83/24690e9dfc241e6ab062df82cc0df7f4231c79ba98b273fa496fb3dd78ed/sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e", size = 2130912, upload-time = "2025-10-10T15:03:24.656Z" }, + { url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" }, + { url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" }, + { url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/819435b7cb66dac6192e6af8b7d0896b9507edf798f3826b9590c7e980db/sqlalchemy-2.0.44-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7027414f2b88992877573ab780c19ecb54d3a536bef3397933573d6b5068be4", size = 2138850, upload-time = "2025-10-10T16:20:45.841Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/e8637ce5c4fdc027c00a9611052329169ef5a99feb22efd38866e27caf27/sqlalchemy-2.0.44-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fe166c7d00912e8c10d3a9a0ce105569a31a3d0db1a6e82c4e0f4bf16d5eca9", size = 2128842, upload-time = "2025-10-10T16:20:47.209Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5b/86f7cc573254bbfa50b339d8c72c5c026ceaa0adaa114237884886a0e14b/sqlalchemy-2.0.44-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3caef1ff89b1caefc28f0368b3bde21a7e3e630c2eddac16abd9e47bd27cc36a", size = 3216858, upload-time = "2025-10-10T15:38:33.535Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ee/c9e582288edb41a47df7525f9fcae775d9f0b7da8eda8732f9d22f0c383e/sqlalchemy-2.0.44-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc2856d24afa44295735e72f3c75d6ee7fdd4336d8d3a8f3d44de7aa6b766df2", size = 3217019, upload-time = "2025-10-10T15:45:13.678Z" }, + { url = "https://files.pythonhosted.org/packages/71/a1/449f3bea46769b31443eac4152452af684c0ddd64e3c4719b636f85a5604/sqlalchemy-2.0.44-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:11bac86b0deada30b6b5f93382712ff0e911fe8d31cb9bf46e6b149ae175eff0", size = 3155491, upload-time = "2025-10-10T15:38:35.317Z" }, + { url = "https://files.pythonhosted.org/packages/0e/34/f99b584a0bf94ff2e822bcb4951dcc24a7967476b35b9b3c35bc11cbd6bc/sqlalchemy-2.0.44-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4d18cd0e9a0f37c9f4088e50e3839fcb69a380a0ec957408e0b57cff08ee0a26", size = 3179978, upload-time = "2025-10-10T15:45:15.262Z" }, + { url = "https://files.pythonhosted.org/packages/ae/43/71a22aa66a1ef974f4e34982ce55d5f38d4770d2f0091eb210374e860b8e/sqlalchemy-2.0.44-cp39-cp39-win32.whl", hash = "sha256:9e9018544ab07614d591a26c1bd4293ddf40752cc435caf69196740516af7100", size = 2106888, upload-time = "2025-10-10T15:45:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/08/fb/cc98eb1ab3c5ad92b51c0db17ee86d1c33379a7490da376567b902222dcf/sqlalchemy-2.0.44-cp39-cp39-win_amd64.whl", hash = "sha256:8e0e4e66fd80f277a8c3de016a81a554e76ccf6b8d881ee0b53200305a8433f6", size = 2130728, upload-time = "2025-10-10T15:45:59.7Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]