From 30fd7209f827e18f9fc4baa9b06b3004e6be8d96 Mon Sep 17 00:00:00 2001 From: Chi Kai Lam Date: Mon, 20 Jul 2026 14:48:45 +0100 Subject: [PATCH 1/8] feat: extract Oracle source and load to Iceberg Implement the elt-pipeline isisuserdb to extract data from the Oracle database source and load it into the Iceberg destination. --- .../fase/ingest/fase/isisuserdb/isisuserdb.py | 82 +++++++++ .../fase/ingest/fase/utils/oracle.py | 160 ++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py create mode 100644 elt-pipelines/fase/ingest/fase/utils/oracle.py diff --git a/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py b/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py new file mode 100644 index 00000000..c40d8981 --- /dev/null +++ b/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py @@ -0,0 +1,82 @@ +import logging +from typing import Optional +from pydantic_settings import BaseSettings, SettingsConfigDict + +from elt_common.extract import Watermark +from elt_common.sources.sqldatabase import SqlDatabaseExtract, TableInfo, ResourceProperties +from elt_common.extract import ResourceWriteProperties + +from fase.utils.oracle import OracleExtractor + +LOGGER = logging.getLogger(__name__) + +class PipelineOracleConfig(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="isisuserdb__", + env_nested_delimiter="__", + extra="ignore", + protected_namespaces=() + ) + + drivername: str = "oracle+cx_oracle" + host: str + port: int + username: str + password: str + schema: str + table: str + + database: str + + @property + def target_tables(self) -> list[str]: + return [t.strip() for t in self.table.split(",") if t.strip()] + + @property + def connection_uri(self) -> str: + return f"{self.drivername}://{self.username}:{self.password}@{self.host}:{self.port}/{self.database}" + +class ExtendedResourceProperties: + def __init__(self, base_props: ResourceProperties, name: str, columns: dict): + self._base_props = base_props + self.name = name + self.columns = columns + + def __getattr__(self, name): + return getattr(self._base_props, name) + +class Extract(SqlDatabaseExtract): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.oracle_config = PipelineOracleConfig() + self.extractor = OracleExtractor(self.oracle_config) + + def table_info(self) -> dict[str, Optional[TableInfo]]: + return { + t: TableInfo(write_properties=ResourceWriteProperties(write_mode="replace")) + for t in self.oracle_config.target_tables + } + + def extract_resource_properties(self): + for name in self.oracle_config.target_tables: + col_hints = self.extractor.get_table_schema(name) + + def make_extractor_func(t_name=name): + def extractor(watermark: Optional[Watermark] = None): + yield from self.extractor.fetch_as_arrow(t_name) + return extractor + + base_properties = ResourceProperties( + extractor=make_extractor_func(name), + write_properties=ResourceWriteProperties(write_mode="replace"), + watermark_column=None + ) + + properties = ExtendedResourceProperties( + base_props=base_properties, + name=name, + columns=col_hints + ) + + yield name.lower(), properties \ No newline at end of file diff --git a/elt-pipelines/fase/ingest/fase/utils/oracle.py b/elt-pipelines/fase/ingest/fase/utils/oracle.py new file mode 100644 index 00000000..f0cfba56 --- /dev/null +++ b/elt-pipelines/fase/ingest/fase/utils/oracle.py @@ -0,0 +1,160 @@ +import logging +import os + +import cx_Oracle +import pandas as pd +import pyarrow as pa +from sqlalchemy import create_engine, event, inspect + +LOGGER = logging.getLogger(__name__) + +os.environ["ORA_SDTZ"] = "UTC" + + +def oracle_output_type_handler(cursor, name, default_type, size, precision, scale): + if default_type in ( + cx_Oracle.DATETIME, + cx_Oracle.TIMESTAMP, + getattr(cx_Oracle, "TIMESTAMPTZ", -1), + ): + return cursor.var(str, 128, arraysize=cursor.arraysize) + if default_type == cx_Oracle.CLOB: + return cursor.var(cx_Oracle.LONG_STRING, arraysize=cursor.arraysize) + if default_type == cx_Oracle.BLOB: + return cursor.var(cx_Oracle.LONG_BINARY, arraysize=cursor.arraysize) + + +class OracleExtractor: + def __init__(self, config): + self.config = config + self.engine = create_engine(config.connection_uri) + + def custom_connection_creator(): + return cx_Oracle.connect( + config.username, + config.password, + f"{config.host}:{config.port}/{config.database}", + ) + + self.engine.pool._creator = custom_connection_creator + + @event.listens_for(self.engine, "connect") + def on_connect(dbapi_conn, _): + dbapi_conn.outputtypehandler = oracle_output_type_handler + cursor = dbapi_conn.cursor() + cursor.execute(""" + ALTER SESSION SET + NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS' + NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF' + NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF TZH:TZM' + """) + cursor.close() + + def map_oracle_to_pq_type(self, oracle_type) -> str: + """Maps incoming Oracle internal types to canonical parquet string representations.""" + t = str(oracle_type).lower() + if "number" in t or "int" in t: + if "," in t: + return "double" + return "bigint" + if "float" in t or "double" in t or "decimal" in t: + return "double" + if "timestamp" in t or "date" in t: + return "timestamp" + if "clob" in t or "blob" in t: + return "text" + return "text" + + def get_table_schema(self, table_name: str) -> dict: + """Inspects structural column parameters directly out of the Oracle data dictionary.""" + inspector = inspect(self.engine) + columns = inspector.get_columns(table_name, schema=self.config.schema) + + col_hints = {} + for c in columns: + col_name = c["name"] + col_hints[col_name] = { + "name": col_name, + "data_type": self.map_oracle_to_pq_type(c["type"]), + "nullable": not c.get("primary_key", False), + } + return col_hints + + def _get_lob_columns(self, table_name: str) -> list[str]: + inspector = inspect(self.engine) + columns = inspector.get_columns(table_name, schema=self.config.schema) + return [ + c["name"] + for c in columns + if any(kw in str(c["type"]).lower() for kw in ["clob", "blob"]) + ] + + def fetch_as_arrow(self, table_name: str): + """Streams Oracle segments into structured PyArrow tables using unified schema alignment.""" + col_hints = self.get_table_schema(table_name) + lob_cols = self._get_lob_columns(table_name) + + arrow_fields = [] + for col_name, hint in col_hints.items(): + pq_type_str = hint["data_type"] + if pq_type_str == "bigint": + pa_type = pa.int64() + elif pq_type_str == "bool": + pa_type = pa.bool_() + elif pq_type_str == "double": + pa_type = pa.float64() + elif pq_type_str == "timestamp": + pa_type = pa.timestamp("us") + elif pq_type_str == "date": + pa_type = pa.date32() + else: + pa_type = pa.string() + arrow_fields.append(pa.field(col_name.lower(), pa_type, nullable=True)) + + target_schema = pa.schema(arrow_fields) + + with self.engine.connect() as conn: + query = ( + f'SELECT * FROM "{self.config.schema.upper()}"."{table_name.upper()}"' + ) + df = pd.read_sql(query, conn) + + if not df.empty: + df.columns = [c.lower() for c in df.columns] + + for col_name, hint in col_hints.items(): + col_lower = col_name.lower() + if col_lower in df.columns: + if hint["data_type"] == "timestamp": + df[col_lower] = pd.to_datetime( + df[col_lower], utc=True, errors="coerce" + ).dt.tz_localize(None) + + elif col_lower in lob_cols: + df[col_lower] = df[col_lower].apply( + lambda x: str(x) if x is not None else None + ) + + non_ts_cols = [c for c in df.columns if df[c].dtype.kind != "M"] + df[non_ts_cols] = ( + df[non_ts_cols].astype(object).where(df[non_ts_cols].notna(), None) + ) + + arrow_table = pa.Table.from_pandas(df) + + aligned_columns = [] + for field in target_schema: + if field.name in arrow_table.column_names: + aligned_columns.append( + arrow_table.column(field.name).cast(field.type) + ) + else: + aligned_columns.append( + pa.array([None] * len(arrow_table), type=field.type) + ) + + yield pa.Table.from_arrays(aligned_columns, schema=target_schema) + else: + dummy_row = {col_name.lower(): [None] for col_name in col_hints.keys()} + empty_df = pd.DataFrame(dummy_row) + yield pa.Table.from_pandas(empty_df, schema=target_schema) From b8bee3f9accde907c508e2b347c40bfba062d021 Mon Sep 17 00:00:00 2001 From: Chi Kai Lam Date: Mon, 20 Jul 2026 15:22:20 +0100 Subject: [PATCH 2/8] Fix format for prek --- .../fase/ingest/fase/isisuserdb/isisuserdb.py | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py b/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py index c40d8981..844cb5d1 100644 --- a/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py +++ b/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py @@ -1,32 +1,35 @@ import logging from typing import Optional -from pydantic_settings import BaseSettings, SettingsConfigDict - -from elt_common.extract import Watermark -from elt_common.sources.sqldatabase import SqlDatabaseExtract, TableInfo, ResourceProperties -from elt_common.extract import ResourceWriteProperties +from elt_common.extract import ResourceWriteProperties, Watermark +from elt_common.sources.sqldatabase import ( + ResourceProperties, + SqlDatabaseExtract, + TableInfo, +) from fase.utils.oracle import OracleExtractor +from pydantic_settings import BaseSettings, SettingsConfigDict LOGGER = logging.getLogger(__name__) + class PipelineOracleConfig(BaseSettings): model_config = SettingsConfigDict( - env_prefix="isisuserdb__", + env_prefix="isisuserdb__", env_nested_delimiter="__", extra="ignore", - protected_namespaces=() + protected_namespaces=(), ) drivername: str = "oracle+cx_oracle" - host: str - port: int - username: str - password: str - schema: str - table: str - - database: str + host: str + port: int + username: str + password: str + schema: str + table: str + + database: str @property def target_tables(self) -> list[str]: @@ -36,6 +39,7 @@ def target_tables(self) -> list[str]: def connection_uri(self) -> str: return f"{self.drivername}://{self.username}:{self.password}@{self.host}:{self.port}/{self.database}" + class ExtendedResourceProperties: def __init__(self, base_props: ResourceProperties, name: str, columns: dict): self._base_props = base_props @@ -45,10 +49,11 @@ def __init__(self, base_props: ResourceProperties, name: str, columns: dict): def __getattr__(self, name): return getattr(self._base_props, name) + class Extract(SqlDatabaseExtract): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + self.oracle_config = PipelineOracleConfig() self.extractor = OracleExtractor(self.oracle_config) @@ -61,22 +66,21 @@ def table_info(self) -> dict[str, Optional[TableInfo]]: def extract_resource_properties(self): for name in self.oracle_config.target_tables: col_hints = self.extractor.get_table_schema(name) - + def make_extractor_func(t_name=name): def extractor(watermark: Optional[Watermark] = None): yield from self.extractor.fetch_as_arrow(t_name) + return extractor base_properties = ResourceProperties( extractor=make_extractor_func(name), write_properties=ResourceWriteProperties(write_mode="replace"), - watermark_column=None + watermark_column=None, ) - + properties = ExtendedResourceProperties( - base_props=base_properties, - name=name, - columns=col_hints + base_props=base_properties, name=name, columns=col_hints ) - yield name.lower(), properties \ No newline at end of file + yield name.lower(), properties From dbd2d264df8c74fccfa493cdefe9c7618081d67f Mon Sep 17 00:00:00 2001 From: Chi Kai Lam Date: Mon, 20 Jul 2026 17:16:21 +0100 Subject: [PATCH 3/8] Remove dead code. --- elt-pipelines/fase/ingest/fase/utils/oracle.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/elt-pipelines/fase/ingest/fase/utils/oracle.py b/elt-pipelines/fase/ingest/fase/utils/oracle.py index f0cfba56..e980b715 100644 --- a/elt-pipelines/fase/ingest/fase/utils/oracle.py +++ b/elt-pipelines/fase/ingest/fase/utils/oracle.py @@ -99,14 +99,10 @@ def fetch_as_arrow(self, table_name: str): pq_type_str = hint["data_type"] if pq_type_str == "bigint": pa_type = pa.int64() - elif pq_type_str == "bool": - pa_type = pa.bool_() elif pq_type_str == "double": pa_type = pa.float64() elif pq_type_str == "timestamp": pa_type = pa.timestamp("us") - elif pq_type_str == "date": - pa_type = pa.date32() else: pa_type = pa.string() arrow_fields.append(pa.field(col_name.lower(), pa_type, nullable=True)) From ff1bf3425faffbd5200c5216fe48a4085a33be47 Mon Sep 17 00:00:00 2001 From: Chi Kai Lam Date: Fri, 4 Sep 2026 16:24:06 +0100 Subject: [PATCH 4/8] refactor(isisuserdb): align with PostgreSQL sources pipeline proposal (#396) --- .../sources/sqldatabase/__init__.py | 25 ++++- .../fase/ingest/fase/isisuserdb/isisuserdb.py | 95 +++++-------------- elt-pipelines/pyproject.toml | 6 ++ elt-pipelines/uv.lock | 36 ++++++- 4 files changed, 84 insertions(+), 78 deletions(-) diff --git a/elt-common/src/elt_common/sources/sqldatabase/__init__.py b/elt-common/src/elt_common/sources/sqldatabase/__init__.py index 0773f276..52c0a88d 100644 --- a/elt-common/src/elt_common/sources/sqldatabase/__init__.py +++ b/elt-common/src/elt_common/sources/sqldatabase/__init__.py @@ -205,7 +205,24 @@ def _extract_table( self._metadata, autoload_with=self._engine, ) - query = sa.select(table) + + # Apply UTC extraction strictly to Oracle databases to avoid Thin mode DPY-3022 errors + is_oracle = self._engine.dialect.name == "oracle" + + selected_cols = [] + for col in table.columns: + if is_oracle: + col_type_str = str(col.type).upper() + is_tz = getattr(col.type, "timezone", False) or "WITH TIME ZONE" in col_type_str + + if is_tz: + selected_cols.append(sa.func.sys_extract_utc(col).label(col.name)) + else: + selected_cols.append(col) + else: + selected_cols.append(col) + + query = sa.select(*selected_cols) if watermark is not None: column, max_value = watermark.column, watermark.value LOGGER.debug(f"Cursor value detected. Limiting query to {column} > {max_value}") @@ -214,11 +231,9 @@ def _extract_table( if query_filter: query = query_filter(query) - query = query.limit(self.config.row_limit) + if self.config.row_limit: + query = query.limit(self.config.row_limit) - # If all the values in a column are null pyarrow won't know what type - # the column should be, so we need to explicitly create a schema from - # the table pa_schema = to_pyarrow_schema(table) result = conn.execution_options(yield_per=self.config.chunk_size).execute(query) diff --git a/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py b/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py index 844cb5d1..9e3e90b3 100644 --- a/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py +++ b/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py @@ -1,86 +1,37 @@ -import logging -from typing import Optional - -from elt_common.extract import ResourceWriteProperties, Watermark +import sqlalchemy as sa +from elt_common.extract import ResourceWriteProperties from elt_common.sources.sqldatabase import ( - ResourceProperties, SqlDatabaseExtract, + SqlDatabaseSourceConfig, TableInfo, ) -from fase.utils.oracle import OracleExtractor -from pydantic_settings import BaseSettings, SettingsConfigDict - -LOGGER = logging.getLogger(__name__) - - -class PipelineOracleConfig(BaseSettings): - model_config = SettingsConfigDict( - env_prefix="isisuserdb__", - env_nested_delimiter="__", - extra="ignore", - protected_namespaces=(), - ) - - drivername: str = "oracle+cx_oracle" - host: str - port: int - username: str - password: str - schema: str - table: str - database: str - @property - def target_tables(self) -> list[str]: - return [t.strip() for t in self.table.split(",") if t.strip()] +class PipelineOracleConfig(SqlDatabaseSourceConfig): + drivername: str = "oracle+oracledb" + database_schema: str = "isisuserdb" # Source schema of this pipeline + tables: list[str] @property - def connection_uri(self) -> str: - return f"{self.drivername}://{self.username}:{self.password}@{self.host}:{self.port}/{self.database}" - - -class ExtendedResourceProperties: - def __init__(self, base_props: ResourceProperties, name: str, columns: dict): - self._base_props = base_props - self.name = name - self.columns = columns - - def __getattr__(self, name): - return getattr(self._base_props, name) + def connection_url(self) -> sa.URL: + return sa.URL.create( + drivername=self.drivername, + username=self.username, + password=self.password.get_secret_value() if self.password else None, + host=self.host, + port=self.port, + query={"service_name": self.database}, + ) class Extract(SqlDatabaseExtract): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.oracle_config = PipelineOracleConfig() - self.extractor = OracleExtractor(self.oracle_config) + config_cls = PipelineOracleConfig - def table_info(self) -> dict[str, Optional[TableInfo]]: + def table_info(self) -> dict[str, TableInfo]: + """Defines the target tables and their ingestion strategy.""" return { - t: TableInfo(write_properties=ResourceWriteProperties(write_mode="replace")) - for t in self.oracle_config.target_tables - } - - def extract_resource_properties(self): - for name in self.oracle_config.target_tables: - col_hints = self.extractor.get_table_schema(name) - - def make_extractor_func(t_name=name): - def extractor(watermark: Optional[Watermark] = None): - yield from self.extractor.fetch_as_arrow(t_name) - - return extractor - - base_properties = ResourceProperties( - extractor=make_extractor_func(name), - write_properties=ResourceWriteProperties(write_mode="replace"), - watermark_column=None, + table_name: TableInfo( + write_properties=ResourceWriteProperties(write_mode="replace") ) - - properties = ExtendedResourceProperties( - base_props=base_properties, name=name, columns=col_hints - ) - - yield name.lower(), properties + for table_name in self.config.tables + } diff --git a/elt-pipelines/pyproject.toml b/elt-pipelines/pyproject.toml index 5bdc8562..afc7f1df 100644 --- a/elt-pipelines/pyproject.toml +++ b/elt-pipelines/pyproject.toml @@ -13,6 +13,12 @@ dependencies = [ proposal = [ "sqlalchemy[postgresql-psycopgbinary]>=2.0.0", ] + +isisuserdb = [ + "oracledb>=2.0.0", + "sqlalchemy>=2.0.0", +] + statusdisplay = [ "requests>=2.34.2", ] diff --git a/elt-pipelines/uv.lock b/elt-pipelines/uv.lock index 3e604567..ef1b2f13 100644 --- a/elt-pipelines/uv.lock +++ b/elt-pipelines/uv.lock @@ -756,6 +756,10 @@ dependencies = [ ] [package.optional-dependencies] +isisuserdb = [ + { name = "oracledb" }, + { name = "sqlalchemy" }, +] jira = [ { name = "atlassian-python-api" }, ] @@ -794,14 +798,16 @@ requires-dist = [ { name = "html2text", marker = "extra == 'opralogweb'", specifier = ">=2025.4.15" }, { name = "numpy", marker = "extra == 'moderator-performance'", specifier = ">=2.4.2,<3" }, { name = "openpyxl", marker = "extra == 'sharepoint'", specifier = ">=3.1.5" }, + { name = "oracledb", marker = "extra == 'isisuserdb'", specifier = ">=2.0.0" }, { name = "pandas", marker = "extra == 'sharepoint'", specifier = ">=3.0.3" }, { name = "pydantic-settings", specifier = ">=2.14.2" }, { name = "requests", marker = "extra == 'statusdisplay'", specifier = ">=2.34.2" }, { name = "scipy", marker = "extra == 'moderator-performance'", specifier = ">=1.17.1,<2" }, + { name = "sqlalchemy", marker = "extra == 'isisuserdb'", specifier = ">=2.0.0" }, { name = "sqlalchemy", extras = ["mssql-pymssql"], marker = "extra == 'opralogweb'", specifier = ">=2.0.51" }, { name = "sqlalchemy", extras = ["postgresql-psycopgbinary"], marker = "extra == 'proposal'", specifier = ">=2.0.0" }, ] -provides-extras = ["proposal", "statusdisplay", "sharepoint", "opralogweb", "moderator-performance", "jira"] +provides-extras = ["proposal", "isisuserdb", "statusdisplay", "sharepoint", "opralogweb", "moderator-performance", "jira"] [package.metadata.requires-dev] dev = [{ name = "prek", specifier = ">=0.4.5" }] @@ -1634,6 +1640,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, ] +[[package]] +name = "oracledb" +version = "4.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/ae/4576e5df7b8eadec51bb7d981a2bcc0d8387d7d6998a51d146c0886a523e/oracledb-4.0.2.tar.gz", hash = "sha256:0a380ab72853487ea2764c5df772f35026b4219868fc3eba68e193c9aea230ac", size = 881658, upload-time = "2026-07-14T17:21:28.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/cb/e9ad24c2fa20ff977ca52b5a68a2010a054f724d420681be75e7e3d92b57/oracledb-4.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af4121112f0b68e8d61ce46a086c6aec8256fa2ca32d2e6467195a6c575c207b", size = 4352202, upload-time = "2026-07-14T17:23:13.202Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ad/583d85906b5c4b344600be2bf30077f3a6c2bf04ee01b4fee9856d1da269/oracledb-4.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a814307ca8a6bf0649d8bf0a650a72f23030d9af052a2d4a481b94b6ab50d5a4", size = 2274847, upload-time = "2026-07-14T17:23:14.967Z" }, + { url = "https://files.pythonhosted.org/packages/cf/53/badcc7e29ba9e9f2158f2e18214197d63c53d99c4720d436d8fc0b6b175f/oracledb-4.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b89c02670bafc9b1fcc0d15175f097bdc1968bc36ee4d66aa63aee09bfbbe30", size = 2474482, upload-time = "2026-07-14T17:23:16.605Z" }, + { url = "https://files.pythonhosted.org/packages/b9/76/0bc64bdfc7ca794f4576c757da89617922be402f9fcf434bf5de7e44235a/oracledb-4.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:db0d76199b6dffd721bda48dad4eeea6603942ce2e246f3b9301d85af6bda0ee", size = 2327237, upload-time = "2026-07-14T17:23:18.602Z" }, + { url = "https://files.pythonhosted.org/packages/15/f5/2fc4e30b24a60e40fa6419dbadd8f869878a3ac49d28b7d16bca59dd2519/oracledb-4.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f5ed684ab419603d53981e0f32b51da673e21a42d7dce1bf3f215a9301c7c108", size = 2504800, upload-time = "2026-07-14T17:23:20.025Z" }, + { url = "https://files.pythonhosted.org/packages/2e/03/0a15a43a88addfb7b4f8d8343f0c47df68173510a27e2196b6b7347efb06/oracledb-4.0.2-cp313-cp313-win32.whl", hash = "sha256:a45a13e33509db5bd3622a05cb2e4a6cb41d56d1bfd2a32caeacd26f4ce8b988", size = 1493052, upload-time = "2026-07-14T17:23:21.603Z" }, + { url = "https://files.pythonhosted.org/packages/bb/4a/9895cb5a1fffa68f2d9fb1cfb43de88628c049d313734de7ecef17099460/oracledb-4.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:6444be4991f33754cd98f624cecef3eb98db3e87f8ac14e9b65bd3591c9dd252", size = 1864126, upload-time = "2026-07-14T17:23:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/0b/14/5c7c44a8f441783d461b9657994e03f8798e28bebe67395951c53c86c270/oracledb-4.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:087fdf5bc36b03dc3c55f7abc944a163ba8edc9c802bc8baf91698a52041ee13", size = 1519906, upload-time = "2026-07-14T17:23:24.338Z" }, + { url = "https://files.pythonhosted.org/packages/d8/96/30cdbc8399f8edd5cd8bc28bae89fba0eaa40c529168360e9aa84fa21e97/oracledb-4.0.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:58e71a3c1e294a99e39b086adf42074d48287548bf8edfa720c9507106186bbb", size = 4389290, upload-time = "2026-07-14T17:23:25.769Z" }, + { url = "https://files.pythonhosted.org/packages/1f/35/eec834f01d84f6974ff9b397b83cc80b67064410476049aaf9f88269590d/oracledb-4.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78d4a1cc1482b5927a9962c7766aeeae616a85eedbe49af8b797148ac1c1ac32", size = 2315809, upload-time = "2026-07-14T17:23:27.265Z" }, + { url = "https://files.pythonhosted.org/packages/fd/09/5185ea1270987fa3514ff6bf99540e23befefdb511759137f57848e1ab72/oracledb-4.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14124b5e8e4ba087f105ee389ee43e407840220a0ba2504de68eed903f51a711", size = 2490090, upload-time = "2026-07-14T17:23:28.995Z" }, + { url = "https://files.pythonhosted.org/packages/c2/63/2547b13274f95d2eb073af2c936a37d7da3025f910937dc2e411b7160784/oracledb-4.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:36e72ffaaf589041dac72ddf589d694c0a9ac4576662a04628966d4ea089d6ea", size = 2365819, upload-time = "2026-07-14T17:23:30.691Z" }, + { url = "https://files.pythonhosted.org/packages/23/bd/f52b69a5d86d62cf9cb3992a7cafd1d46c418ae64eaa8a256a3d1eee9e51/oracledb-4.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0101b88c15ae628af556506840ae876e12bdb75506ef342e4990ac1758bad1eb", size = 2518689, upload-time = "2026-07-14T17:23:32.566Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6d15fedcbd67fabd6c9c345554e184ba6617e389bfea348358f8aacc0e5f/oracledb-4.0.2-cp314-cp314-win32.whl", hash = "sha256:b14d52c09b3c7a8273b3640ba06675acec5000dab0356ef288587c46c30bbbb4", size = 1514946, upload-time = "2026-07-14T17:23:33.981Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b9/d6e2c3ee3d9fb3585f04910ecf6ff91dbfd84e8dafc06084717f6a134bca/oracledb-4.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:5c1f13ba535d9f0fc61e1987720988612cf9424667d491fc344ba681f3336a17", size = 1915171, upload-time = "2026-07-14T17:23:35.574Z" }, + { url = "https://files.pythonhosted.org/packages/0d/6b/47f5c76223798ebfb5b6d41e9c3a451d296d6d90123584d85159e4236ff5/oracledb-4.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:93b4d2cd17a93224711fadd28f77e956e5c6575cd923f24bfe0b1a2a581beb79", size = 1574652, upload-time = "2026-07-14T17:23:36.982Z" }, +] + [[package]] name = "orderly-set" version = "5.5.0" From d449e13530d30f8b17e6530794ca4db0d20a9eda Mon Sep 17 00:00:00 2001 From: Chi Kai Lam Date: Mon, 7 Sep 2026 09:42:18 +0100 Subject: [PATCH 5/8] refactor(isisuserdb): No longer need --- .../fase/ingest/fase/utils/oracle.py | 156 ------------------ 1 file changed, 156 deletions(-) delete mode 100644 elt-pipelines/fase/ingest/fase/utils/oracle.py diff --git a/elt-pipelines/fase/ingest/fase/utils/oracle.py b/elt-pipelines/fase/ingest/fase/utils/oracle.py deleted file mode 100644 index e980b715..00000000 --- a/elt-pipelines/fase/ingest/fase/utils/oracle.py +++ /dev/null @@ -1,156 +0,0 @@ -import logging -import os - -import cx_Oracle -import pandas as pd -import pyarrow as pa -from sqlalchemy import create_engine, event, inspect - -LOGGER = logging.getLogger(__name__) - -os.environ["ORA_SDTZ"] = "UTC" - - -def oracle_output_type_handler(cursor, name, default_type, size, precision, scale): - if default_type in ( - cx_Oracle.DATETIME, - cx_Oracle.TIMESTAMP, - getattr(cx_Oracle, "TIMESTAMPTZ", -1), - ): - return cursor.var(str, 128, arraysize=cursor.arraysize) - if default_type == cx_Oracle.CLOB: - return cursor.var(cx_Oracle.LONG_STRING, arraysize=cursor.arraysize) - if default_type == cx_Oracle.BLOB: - return cursor.var(cx_Oracle.LONG_BINARY, arraysize=cursor.arraysize) - - -class OracleExtractor: - def __init__(self, config): - self.config = config - self.engine = create_engine(config.connection_uri) - - def custom_connection_creator(): - return cx_Oracle.connect( - config.username, - config.password, - f"{config.host}:{config.port}/{config.database}", - ) - - self.engine.pool._creator = custom_connection_creator - - @event.listens_for(self.engine, "connect") - def on_connect(dbapi_conn, _): - dbapi_conn.outputtypehandler = oracle_output_type_handler - cursor = dbapi_conn.cursor() - cursor.execute(""" - ALTER SESSION SET - NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS' - NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF' - NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF TZH:TZM' - """) - cursor.close() - - def map_oracle_to_pq_type(self, oracle_type) -> str: - """Maps incoming Oracle internal types to canonical parquet string representations.""" - t = str(oracle_type).lower() - if "number" in t or "int" in t: - if "," in t: - return "double" - return "bigint" - if "float" in t or "double" in t or "decimal" in t: - return "double" - if "timestamp" in t or "date" in t: - return "timestamp" - if "clob" in t or "blob" in t: - return "text" - return "text" - - def get_table_schema(self, table_name: str) -> dict: - """Inspects structural column parameters directly out of the Oracle data dictionary.""" - inspector = inspect(self.engine) - columns = inspector.get_columns(table_name, schema=self.config.schema) - - col_hints = {} - for c in columns: - col_name = c["name"] - col_hints[col_name] = { - "name": col_name, - "data_type": self.map_oracle_to_pq_type(c["type"]), - "nullable": not c.get("primary_key", False), - } - return col_hints - - def _get_lob_columns(self, table_name: str) -> list[str]: - inspector = inspect(self.engine) - columns = inspector.get_columns(table_name, schema=self.config.schema) - return [ - c["name"] - for c in columns - if any(kw in str(c["type"]).lower() for kw in ["clob", "blob"]) - ] - - def fetch_as_arrow(self, table_name: str): - """Streams Oracle segments into structured PyArrow tables using unified schema alignment.""" - col_hints = self.get_table_schema(table_name) - lob_cols = self._get_lob_columns(table_name) - - arrow_fields = [] - for col_name, hint in col_hints.items(): - pq_type_str = hint["data_type"] - if pq_type_str == "bigint": - pa_type = pa.int64() - elif pq_type_str == "double": - pa_type = pa.float64() - elif pq_type_str == "timestamp": - pa_type = pa.timestamp("us") - else: - pa_type = pa.string() - arrow_fields.append(pa.field(col_name.lower(), pa_type, nullable=True)) - - target_schema = pa.schema(arrow_fields) - - with self.engine.connect() as conn: - query = ( - f'SELECT * FROM "{self.config.schema.upper()}"."{table_name.upper()}"' - ) - df = pd.read_sql(query, conn) - - if not df.empty: - df.columns = [c.lower() for c in df.columns] - - for col_name, hint in col_hints.items(): - col_lower = col_name.lower() - if col_lower in df.columns: - if hint["data_type"] == "timestamp": - df[col_lower] = pd.to_datetime( - df[col_lower], utc=True, errors="coerce" - ).dt.tz_localize(None) - - elif col_lower in lob_cols: - df[col_lower] = df[col_lower].apply( - lambda x: str(x) if x is not None else None - ) - - non_ts_cols = [c for c in df.columns if df[c].dtype.kind != "M"] - df[non_ts_cols] = ( - df[non_ts_cols].astype(object).where(df[non_ts_cols].notna(), None) - ) - - arrow_table = pa.Table.from_pandas(df) - - aligned_columns = [] - for field in target_schema: - if field.name in arrow_table.column_names: - aligned_columns.append( - arrow_table.column(field.name).cast(field.type) - ) - else: - aligned_columns.append( - pa.array([None] * len(arrow_table), type=field.type) - ) - - yield pa.Table.from_arrays(aligned_columns, schema=target_schema) - else: - dummy_row = {col_name.lower(): [None] for col_name in col_hints.keys()} - empty_df = pd.DataFrame(dummy_row) - yield pa.Table.from_pandas(empty_df, schema=target_schema) From 0383139f07c048fddf3d5e0190efa9861a4eae66 Mon Sep 17 00:00:00 2001 From: Chi Kai Lam Date: Thu, 10 Sep 2026 17:33:09 +0100 Subject: [PATCH 6/8] Handle Oracle RAW data type --- elt-common/src/elt_common/sources/sqldatabase/schema.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/elt-common/src/elt_common/sources/sqldatabase/schema.py b/elt-common/src/elt_common/sources/sqldatabase/schema.py index 1b081969..aed9979a 100644 --- a/elt-common/src/elt_common/sources/sqldatabase/schema.py +++ b/elt-common/src/elt_common/sources/sqldatabase/schema.py @@ -6,6 +6,7 @@ import pyarrow as pa import sqlalchemy as sa from sqlalchemy.dialects import postgresql +from sqlalchemy.dialects.oracle import RAW def to_pyarrow_schema(table: sa.Table) -> pa.Schema: @@ -63,6 +64,7 @@ def _to_pyarrow_field(column: sa.Column) -> pa.Field: sa.VARCHAR: _SQL_ROOT_TYPES[sa.String], postgresql.JSON: _SQL_ROOT_TYPES[sa.JSON], postgresql.JSONB: _SQL_ROOT_TYPES[sa.JSON], + RAW: _SQL_ROOT_TYPES[sa.LargeBinary], } _SQL_TYPE_MAP = _SQL_ROOT_TYPES | _EXTENDED_SQL_TYPES From 843f644f368db56c454f6940e5b6d17a9a63c092 Mon Sep 17 00:00:00 2001 From: Chi Kai Lam Date: Fri, 11 Sep 2026 16:14:28 +0100 Subject: [PATCH 7/8] Resolve followings: 1. Set database sources by external environment variable database_schema. 2. Satisfy the type checker. 3. Pull out the retrieval of the columns into _columns_for_select method. --- .../sources/sqldatabase/__init__.py | 35 +++++++++---------- .../fase/ingest/fase/isisuserdb/isisuserdb.py | 3 +- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/elt-common/src/elt_common/sources/sqldatabase/__init__.py b/elt-common/src/elt_common/sources/sqldatabase/__init__.py index 52c0a88d..8f9536eb 100644 --- a/elt-common/src/elt_common/sources/sqldatabase/__init__.py +++ b/elt-common/src/elt_common/sources/sqldatabase/__init__.py @@ -4,7 +4,7 @@ import logging from abc import abstractmethod from collections.abc import Callable, Generator, Iterable, Iterator -from typing import NamedTuple +from typing import Any, NamedTuple import pyarrow as pa import pyarrow.compute as pc @@ -191,6 +191,21 @@ def extractor(watermark, *, _name=name): yield resource_name, properties + def _columns_for_select(self, table: sa.Table) -> Iterable[Any]: + """Retrieve columns for selection applying any DB-specific requirements.""" + if self._engine.dialect.name == "oracle": + # Apply UTC extraction strictly to Oracle databases to avoid Thin mode DPY-3022 errors + selected_cols = [] + for col in table.columns: + col_type_str = str(col.type).upper() + if getattr(col.type, "timezone", False) or "WITH TIME ZONE" in col_type_str: + selected_cols.append(sa.func.sys_extract_utc(col).label(col.name)) + else: + selected_cols.append(col) + return selected_cols + else: + return table.columns + def _extract_table( self, name: str, @@ -206,23 +221,7 @@ def _extract_table( autoload_with=self._engine, ) - # Apply UTC extraction strictly to Oracle databases to avoid Thin mode DPY-3022 errors - is_oracle = self._engine.dialect.name == "oracle" - - selected_cols = [] - for col in table.columns: - if is_oracle: - col_type_str = str(col.type).upper() - is_tz = getattr(col.type, "timezone", False) or "WITH TIME ZONE" in col_type_str - - if is_tz: - selected_cols.append(sa.func.sys_extract_utc(col).label(col.name)) - else: - selected_cols.append(col) - else: - selected_cols.append(col) - - query = sa.select(*selected_cols) + query = sa.select(*self._columns_for_select(table)) if watermark is not None: column, max_value = watermark.column, watermark.value LOGGER.debug(f"Cursor value detected. Limiting query to {column} > {max_value}") diff --git a/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py b/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py index 9e3e90b3..99fd8cb3 100644 --- a/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py +++ b/elt-pipelines/fase/ingest/fase/isisuserdb/isisuserdb.py @@ -9,7 +9,6 @@ class PipelineOracleConfig(SqlDatabaseSourceConfig): drivername: str = "oracle+oracledb" - database_schema: str = "isisuserdb" # Source schema of this pipeline tables: list[str] @property @@ -27,7 +26,7 @@ def connection_url(self) -> sa.URL: class Extract(SqlDatabaseExtract): config_cls = PipelineOracleConfig - def table_info(self) -> dict[str, TableInfo]: + def table_info(self) -> dict[str, TableInfo | None]: """Defines the target tables and their ingestion strategy.""" return { table_name: TableInfo( From ac925246337e17a334ec6591c15db9c19bc7518c Mon Sep 17 00:00:00 2001 From: Chi Kai Lam Date: Fri, 11 Sep 2026 16:54:33 +0100 Subject: [PATCH 8/8] Simplify dependencies of isisuserdb. --- elt-pipelines/pyproject.toml | 3 +- elt-pipelines/uv.lock | 55 +++++++++++++++++++++--------------- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/elt-pipelines/pyproject.toml b/elt-pipelines/pyproject.toml index afc7f1df..ec61eae3 100644 --- a/elt-pipelines/pyproject.toml +++ b/elt-pipelines/pyproject.toml @@ -15,8 +15,7 @@ proposal = [ ] isisuserdb = [ - "oracledb>=2.0.0", - "sqlalchemy>=2.0.0", + "sqlalchemy[oracle-oracledb]>=2.0.0", ] statusdisplay = [ diff --git a/elt-pipelines/uv.lock b/elt-pipelines/uv.lock index ef1b2f13..dc991f8b 100644 --- a/elt-pipelines/uv.lock +++ b/elt-pipelines/uv.lock @@ -757,8 +757,7 @@ dependencies = [ [package.optional-dependencies] isisuserdb = [ - { name = "oracledb" }, - { name = "sqlalchemy" }, + { name = "sqlalchemy", extra = ["oracle-oracledb"] }, ] jira = [ { name = "atlassian-python-api" }, @@ -798,13 +797,12 @@ requires-dist = [ { name = "html2text", marker = "extra == 'opralogweb'", specifier = ">=2025.4.15" }, { name = "numpy", marker = "extra == 'moderator-performance'", specifier = ">=2.4.2,<3" }, { name = "openpyxl", marker = "extra == 'sharepoint'", specifier = ">=3.1.5" }, - { name = "oracledb", marker = "extra == 'isisuserdb'", specifier = ">=2.0.0" }, { name = "pandas", marker = "extra == 'sharepoint'", specifier = ">=3.0.3" }, { name = "pydantic-settings", specifier = ">=2.14.2" }, { name = "requests", marker = "extra == 'statusdisplay'", specifier = ">=2.34.2" }, { name = "scipy", marker = "extra == 'moderator-performance'", specifier = ">=1.17.1,<2" }, - { name = "sqlalchemy", marker = "extra == 'isisuserdb'", specifier = ">=2.0.0" }, { name = "sqlalchemy", extras = ["mssql-pymssql"], marker = "extra == 'opralogweb'", specifier = ">=2.0.51" }, + { name = "sqlalchemy", extras = ["oracle-oracledb"], marker = "extra == 'isisuserdb'", specifier = ">=2.0.0" }, { name = "sqlalchemy", extras = ["postgresql-psycopgbinary"], marker = "extra == 'proposal'", specifier = ">=2.0.0" }, ] provides-extras = ["proposal", "isisuserdb", "statusdisplay", "sharepoint", "opralogweb", "moderator-performance", "jira"] @@ -1642,30 +1640,38 @@ wheels = [ [[package]] name = "oracledb" -version = "4.0.2" +version = "26.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/ae/4576e5df7b8eadec51bb7d981a2bcc0d8387d7d6998a51d146c0886a523e/oracledb-4.0.2.tar.gz", hash = "sha256:0a380ab72853487ea2764c5df772f35026b4219868fc3eba68e193c9aea230ac", size = 881658, upload-time = "2026-07-14T17:21:28.876Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/cb/e9ad24c2fa20ff977ca52b5a68a2010a054f724d420681be75e7e3d92b57/oracledb-4.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af4121112f0b68e8d61ce46a086c6aec8256fa2ca32d2e6467195a6c575c207b", size = 4352202, upload-time = "2026-07-14T17:23:13.202Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ad/583d85906b5c4b344600be2bf30077f3a6c2bf04ee01b4fee9856d1da269/oracledb-4.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a814307ca8a6bf0649d8bf0a650a72f23030d9af052a2d4a481b94b6ab50d5a4", size = 2274847, upload-time = "2026-07-14T17:23:14.967Z" }, - { url = "https://files.pythonhosted.org/packages/cf/53/badcc7e29ba9e9f2158f2e18214197d63c53d99c4720d436d8fc0b6b175f/oracledb-4.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b89c02670bafc9b1fcc0d15175f097bdc1968bc36ee4d66aa63aee09bfbbe30", size = 2474482, upload-time = "2026-07-14T17:23:16.605Z" }, - { url = "https://files.pythonhosted.org/packages/b9/76/0bc64bdfc7ca794f4576c757da89617922be402f9fcf434bf5de7e44235a/oracledb-4.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:db0d76199b6dffd721bda48dad4eeea6603942ce2e246f3b9301d85af6bda0ee", size = 2327237, upload-time = "2026-07-14T17:23:18.602Z" }, - { url = "https://files.pythonhosted.org/packages/15/f5/2fc4e30b24a60e40fa6419dbadd8f869878a3ac49d28b7d16bca59dd2519/oracledb-4.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f5ed684ab419603d53981e0f32b51da673e21a42d7dce1bf3f215a9301c7c108", size = 2504800, upload-time = "2026-07-14T17:23:20.025Z" }, - { url = "https://files.pythonhosted.org/packages/2e/03/0a15a43a88addfb7b4f8d8343f0c47df68173510a27e2196b6b7347efb06/oracledb-4.0.2-cp313-cp313-win32.whl", hash = "sha256:a45a13e33509db5bd3622a05cb2e4a6cb41d56d1bfd2a32caeacd26f4ce8b988", size = 1493052, upload-time = "2026-07-14T17:23:21.603Z" }, - { url = "https://files.pythonhosted.org/packages/bb/4a/9895cb5a1fffa68f2d9fb1cfb43de88628c049d313734de7ecef17099460/oracledb-4.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:6444be4991f33754cd98f624cecef3eb98db3e87f8ac14e9b65bd3591c9dd252", size = 1864126, upload-time = "2026-07-14T17:23:22.985Z" }, - { url = "https://files.pythonhosted.org/packages/0b/14/5c7c44a8f441783d461b9657994e03f8798e28bebe67395951c53c86c270/oracledb-4.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:087fdf5bc36b03dc3c55f7abc944a163ba8edc9c802bc8baf91698a52041ee13", size = 1519906, upload-time = "2026-07-14T17:23:24.338Z" }, - { url = "https://files.pythonhosted.org/packages/d8/96/30cdbc8399f8edd5cd8bc28bae89fba0eaa40c529168360e9aa84fa21e97/oracledb-4.0.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:58e71a3c1e294a99e39b086adf42074d48287548bf8edfa720c9507106186bbb", size = 4389290, upload-time = "2026-07-14T17:23:25.769Z" }, - { url = "https://files.pythonhosted.org/packages/1f/35/eec834f01d84f6974ff9b397b83cc80b67064410476049aaf9f88269590d/oracledb-4.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78d4a1cc1482b5927a9962c7766aeeae616a85eedbe49af8b797148ac1c1ac32", size = 2315809, upload-time = "2026-07-14T17:23:27.265Z" }, - { url = "https://files.pythonhosted.org/packages/fd/09/5185ea1270987fa3514ff6bf99540e23befefdb511759137f57848e1ab72/oracledb-4.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14124b5e8e4ba087f105ee389ee43e407840220a0ba2504de68eed903f51a711", size = 2490090, upload-time = "2026-07-14T17:23:28.995Z" }, - { url = "https://files.pythonhosted.org/packages/c2/63/2547b13274f95d2eb073af2c936a37d7da3025f910937dc2e411b7160784/oracledb-4.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:36e72ffaaf589041dac72ddf589d694c0a9ac4576662a04628966d4ea089d6ea", size = 2365819, upload-time = "2026-07-14T17:23:30.691Z" }, - { url = "https://files.pythonhosted.org/packages/23/bd/f52b69a5d86d62cf9cb3992a7cafd1d46c418ae64eaa8a256a3d1eee9e51/oracledb-4.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0101b88c15ae628af556506840ae876e12bdb75506ef342e4990ac1758bad1eb", size = 2518689, upload-time = "2026-07-14T17:23:32.566Z" }, - { url = "https://files.pythonhosted.org/packages/2e/c5/6d15fedcbd67fabd6c9c345554e184ba6617e389bfea348358f8aacc0e5f/oracledb-4.0.2-cp314-cp314-win32.whl", hash = "sha256:b14d52c09b3c7a8273b3640ba06675acec5000dab0356ef288587c46c30bbbb4", size = 1514946, upload-time = "2026-07-14T17:23:33.981Z" }, - { url = "https://files.pythonhosted.org/packages/2d/b9/d6e2c3ee3d9fb3585f04910ecf6ff91dbfd84e8dafc06084717f6a134bca/oracledb-4.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:5c1f13ba535d9f0fc61e1987720988612cf9424667d491fc344ba681f3336a17", size = 1915171, upload-time = "2026-07-14T17:23:35.574Z" }, - { url = "https://files.pythonhosted.org/packages/0d/6b/47f5c76223798ebfb5b6d41e9c3a451d296d6d90123584d85159e4236ff5/oracledb-4.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:93b4d2cd17a93224711fadd28f77e956e5c6575cd923f24bfe0b1a2a581beb79", size = 1574652, upload-time = "2026-07-14T17:23:36.982Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ad/0f/7eefd0e10328d94109180a877f9f1297b8b8dcc80f0bcccb0b0efea2ca9b/oracledb-26.0.0.tar.gz", hash = "sha256:d15728bae7546984203ae17f8296153d52d10ffdaf4929bebe46b70d5819d270", size = 912239, upload-time = "2026-09-10T16:39:01.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/09/0c388ff9bf418abad68f492780d3cef8a33ca8a109ccdc71ff0e06344319/oracledb-26.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f69bafe46bf854c70f19e2f1d068c1b9f3d3066ff79cf90354e0adfeafac1b08", size = 4907337, upload-time = "2026-09-10T16:39:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c5/74/f689dffb9abd3f008f081795677658ce7750371f4ec8718c78663648c19f/oracledb-26.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26b9934aa9a2c431e9e3126b524a8154b78f1f3a12c258a9e7ee311b78135a78", size = 2442648, upload-time = "2026-09-10T16:39:48.349Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/1bc5d3061ba4d35a70489757d2308865490b0a9abc5116cddfdf19954030/oracledb-26.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:587acb63db873dc9784284e02b8c690730289255791bfa1e1ca70cbad50ac130", size = 2643188, upload-time = "2026-09-10T16:39:49.933Z" }, + { url = "https://files.pythonhosted.org/packages/44/09/db87288a174b1b03d36de305e96801f808e740d7986db96624ab5541c4eb/oracledb-26.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:65275fc0f3e2c8f52549b6b8099d6eb5efe4748e6d62d4bd07bc3b006044aa60", size = 2497863, upload-time = "2026-09-10T16:39:51.392Z" }, + { url = "https://files.pythonhosted.org/packages/2a/78/82fd386fcc679eae6f65b2866ea96d8b06ae0eecf17b176f847e8ece9aa5/oracledb-26.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:184869bbb43a1b074e4783b0da5b1793681e3129f4b02c305ad1ba4c6f931621", size = 2671060, upload-time = "2026-09-10T16:39:52.871Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fe/f4c5825ccd7e8c221dad1d16405e02fb57cf7f965837ff74715fc865cbe6/oracledb-26.0.0-cp313-cp313-win32.whl", hash = "sha256:d82d5cea25884ab76db46173e1469588093f35e066a3e972abf1a6a91775edec", size = 1575954, upload-time = "2026-09-10T16:39:54.282Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1a/7410ebf575438e2439827dc38be3851640b4406f880e69f78d92141892bc/oracledb-26.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:747ec61dc933db33db4814b24c8cf0a57c7583482ca444b124e44c910f25bd54", size = 1969227, upload-time = "2026-09-10T16:39:55.578Z" }, + { url = "https://files.pythonhosted.org/packages/5f/dc/1a5bd6233d3de25be8efe48c96077078e7cf940154096dea789e17c8af88/oracledb-26.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:85c775fd2b9f93c2a6ff48cabc2be307ba9fa951b7556e5149dc2ee62c1fbfbe", size = 1620768, upload-time = "2026-09-10T16:39:56.901Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ad/72720d3a3066e0202deb4a4fb0640aa93c661ee0af486a23f4b8a5e92f4f/oracledb-26.0.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:79b612ae836b2182d4c980e1670eb1b689cbe8a68fe5002104dc410fbe16e568", size = 4960030, upload-time = "2026-09-10T16:39:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/e1/03/af2b24c0c099db043bc567d268adf194aa6db1f7867b9798932da5e8981d/oracledb-26.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d24dfacbff11a4b3c15b72b446a56b8f83a4a4013ed694ab2719c79227dc81a", size = 2485508, upload-time = "2026-09-10T16:40:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/25/30/3b913e044b04c72608cf00dc11c382ed8c3c11bf30114862adf0b0301e5a/oracledb-26.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4380a300f4094efe0282f9990929b9189a8e1aa52677d023dd9bdeb0163948e1", size = 2662863, upload-time = "2026-09-10T16:40:01.841Z" }, + { url = "https://files.pythonhosted.org/packages/b7/01/9d114dade656c6f4477ebdd1a1e3c3310ca675c6ae2af59b4fe466f58c33/oracledb-26.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa365bb1f9016d1a688af06dd021481d63d49be00f4e5dc91cf7370d4a2adef8", size = 2542811, upload-time = "2026-09-10T16:40:03.495Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9a/b634a2afc9dd025d93f25843a91a368a7726134b73999c55acc804b47f71/oracledb-26.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a4779752a3ea31558088a306d346964ef4107f8589266ce625651066f80972e7", size = 2690239, upload-time = "2026-09-10T16:40:05.509Z" }, + { url = "https://files.pythonhosted.org/packages/1d/80/cc0eeb56900792f6135d423b69133f2186869ccb9f998ab0d4eb2a5f2a8d/oracledb-26.0.0-cp314-cp314-win32.whl", hash = "sha256:ada309ac4a5f23f98d6b74627d1508f5fa47ad3a681fe42c7769d7ade281d04d", size = 1601816, upload-time = "2026-09-10T16:40:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ac/c03f4fda9bda88fb694ccac78e6894331928d8b5c1244c34e8678cf644f5/oracledb-26.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:7a92c2b002abf425078269c9d7db8544458c5dc6b59affb5a21ff075315d9468", size = 2026664, upload-time = "2026-09-10T16:40:08.684Z" }, + { url = "https://files.pythonhosted.org/packages/f6/01/399c6267811e38f0ac74c1a048ec1588431dcf80306392cf9da656f65f1c/oracledb-26.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a77c5ab67ec3b2e2953bcca2b9fa31491b94c9985e02861e306e01ddc61737d", size = 1679554, upload-time = "2026-09-10T16:40:10.14Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ff/44dd56f1f6f93f5d8efbfd6cf82235357d8408d36706664f46b8ccb57302/oracledb-26.0.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:02b725959a714a911d0b03264d825ad9e631c68c9601e4d753b207e2245ce13d", size = 4958274, upload-time = "2026-09-10T16:40:11.701Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b4/f1c3dae696a146b072332dd5bd8149e5d384a6089cf592dff2fe54aa6b50/oracledb-26.0.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98a38d5a82ae587f5d8dbe7c624eb59380fe5bb761c39ac7529cc6c5eafd83c8", size = 2487339, upload-time = "2026-09-10T16:40:13.422Z" }, + { url = "https://files.pythonhosted.org/packages/c1/cc/f202034fe4c8ecce3eac980b9f1c6bbd4e460bf660c2c248e22e56b84339/oracledb-26.0.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ab05b254671ae1d12a3699f3cd9b9d28a6fba19db0e82842c38ea3bf587f9a1", size = 2683681, upload-time = "2026-09-10T16:40:15.318Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/fe2035571ac695c80db6b5fcd8b2b18b0f9b8e20c913261b8f756876fc93/oracledb-26.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:342a63514e59bc0860c0050bf9be56d0220ae3b0bd32a0e4d22a86dec532bc78", size = 2541907, upload-time = "2026-09-10T16:40:16.923Z" }, + { url = "https://files.pythonhosted.org/packages/51/de/6c8bfa247a9825b5511bca1e329468e3bb583f440d848b924e44eaedf594/oracledb-26.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:262dacf9d90450e29f971ae68a8bcb81edad94194628c3f2d1afa50ec8b42140", size = 2712297, upload-time = "2026-09-10T16:40:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b0/6c8af7bd9216ec55ba67a38ce6e10fd05fd399f884792d1b20e45efb3820/oracledb-26.0.0-cp315-cp315-win32.whl", hash = "sha256:64a9d42dbea41c1f0d45e890b57404e5fc1b1dfeb3c0939128ecaddac9eaacd6", size = 1600829, upload-time = "2026-09-10T16:40:19.969Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/a3b51eefaaedbb49d23ea437d019d7eafcd2e65249be9090c1223543cb45/oracledb-26.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:90794a9ba26ee6a6ccfaf47ae88b5d1bfe3b6192e3591fa6c0851f9ee9cabc7d", size = 2025725, upload-time = "2026-09-10T16:40:21.569Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fb/48be38127faacd006c28ac2773cc1dbe41f42f89a38e79c8ec217bfba595/oracledb-26.0.0-cp315-cp315-win_arm64.whl", hash = "sha256:0fae924f6d07d33dbf267f52d686b0eeb79640853b749cdce1da513241eed1da", size = 1679377, upload-time = "2026-09-10T16:40:23.067Z" }, ] [[package]] @@ -2790,6 +2796,9 @@ wheels = [ mssql-pymssql = [ { name = "pymssql" }, ] +oracle-oracledb = [ + { name = "oracledb" }, +] postgresql-psycopgbinary = [ { name = "psycopg", extra = ["binary"] }, ]