From 7cb03f23f3c9cecd813ded367072b56728fed1c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:20:40 +0000 Subject: [PATCH 1/3] Initial plan From 3785acf90bd0598488d5e633961d8b622e697687 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:29:06 +0000 Subject: [PATCH 2/3] Add Oracle-to-PostgreSQL DWH migration tool with daily GitHub Actions schedule Agent-Logs-Url: https://github.com/eng-dedalus/test/sessions/7f993e48-6f10-4fe2-9101-17b4a0803c20 Co-authored-by: eng-dedalus <260377058+eng-dedalus@users.noreply.github.com> --- .github/workflows/ci.yml | 30 ++ .github/workflows/daily_migration.yml | 86 +++++ README.md | 106 +++++- __pycache__/config.cpython-312.pyc | Bin 0 -> 5721 bytes __pycache__/migrate.cpython-312.pyc | Bin 0 -> 12988 bytes __pycache__/type_mapping.cpython-312.pyc | Bin 0 -> 2398 bytes config.py | 117 +++++++ migrate.py | 319 ++++++++++++++++++ requirements.txt | 8 + .../test_config.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 14230 bytes .../test_migrate.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 16773 bytes ..._type_mapping.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 19738 bytes tests/test_config.py | 102 ++++++ tests/test_migrate.py | 163 +++++++++ tests/test_type_mapping.py | 92 +++++ type_mapping.py | 86 +++++ 16 files changed, 1108 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/daily_migration.yml create mode 100644 __pycache__/config.cpython-312.pyc create mode 100644 __pycache__/migrate.cpython-312.pyc create mode 100644 __pycache__/type_mapping.cpython-312.pyc create mode 100644 config.py create mode 100644 migrate.py create mode 100644 requirements.txt create mode 100644 tests/__pycache__/test_config.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_migrate.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_type_mapping.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/test_config.py create mode 100644 tests/test_migrate.py create mode 100644 tests/test_type_mapping.py create mode 100644 type_mapping.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5663e60 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + test: + name: Lint & Test + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run tests + run: pytest tests/ -v --tb=short diff --git a/.github/workflows/daily_migration.yml b/.github/workflows/daily_migration.yml new file mode 100644 index 0000000..5019b85 --- /dev/null +++ b/.github/workflows/daily_migration.yml @@ -0,0 +1,86 @@ +name: Daily DWH Migration (Oracle → PostgreSQL) + +on: + # Run every day at 02:00 UTC + schedule: + - cron: "0 2 * * *" + + # Allow manual runs from the GitHub UI / API + workflow_dispatch: + inputs: + tables: + description: "Comma-separated list of tables to migrate (leave empty for all)" + required: false + default: "" + mode: + description: "Migration mode: full or incremental" + required: false + default: "full" + dry_run: + description: "Dry run (no data written)" + required: false + default: "false" + +env: + # Oracle connection — stored as repository / environment secrets + ORACLE_HOST: ${{ secrets.ORACLE_HOST }} + ORACLE_PORT: ${{ secrets.ORACLE_PORT }} + ORACLE_SERVICE: ${{ secrets.ORACLE_SERVICE }} + ORACLE_USER: ${{ secrets.ORACLE_USER }} + ORACLE_PASSWORD: ${{ secrets.ORACLE_PASSWORD }} + + # PostgreSQL connection — stored as repository / environment secrets + PG_HOST: ${{ secrets.PG_HOST }} + PG_PORT: ${{ secrets.PG_PORT }} + PG_DATABASE: ${{ secrets.PG_DATABASE }} + PG_USER: ${{ secrets.PG_USER }} + PG_PASSWORD: ${{ secrets.PG_PASSWORD }} + + # Migration settings — can be overridden at runtime + MIGRATION_MODE: ${{ github.event.inputs.mode || 'full' }} + MIGRATION_TABLES: ${{ github.event.inputs.tables || '' }} + LOG_LEVEL: INFO + +jobs: + migrate: + name: Migrate Oracle DWH → PostgreSQL + runs-on: ubuntu-latest + environment: production # use a protected environment for secrets approval + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run migration + run: | + EXTRA_FLAGS="" + if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then + EXTRA_FLAGS="--dry-run" + fi + if [ -n "${{ github.event.inputs.tables }}" ]; then + EXTRA_FLAGS="$EXTRA_FLAGS --tables ${{ github.event.inputs.tables }}" + fi + if [ -n "${{ github.event.inputs.mode }}" ]; then + EXTRA_FLAGS="$EXTRA_FLAGS --mode ${{ github.event.inputs.mode }}" + fi + python migrate.py $EXTRA_FLAGS + + - name: Upload migration log + if: always() + uses: actions/upload-artifact@v4 + with: + name: migration-log-${{ github.run_id }} + path: "*.log" + if-no-files-found: ignore + retention-days: 30 diff --git a/README.md b/README.md index 00bcb6e..9f81b0e 100644 --- a/README.md +++ b/README.md @@ -1 +1,105 @@ -# test \ No newline at end of file +# Oracle → PostgreSQL DWH Migration Tool + +A Python script that migrates tables from an Oracle Data Warehouse to PostgreSQL, +with a daily GitHub Actions schedule. + +## Project structure + +``` +├── migrate.py – Main migration script +├── type_mapping.py – Oracle-to-PostgreSQL type conversion +├── config.py – Configuration via environment variables +├── requirements.txt – Python dependencies +├── tests/ +│ ├── test_type_mapping.py +│ ├── test_config.py +│ └── test_migrate.py +└── .github/ + └── workflows/ + ├── daily_migration.yml – Scheduled migration (daily @ 02:00 UTC) + └── ci.yml – CI: run tests on every push / PR +``` + +## Quick start + +### 1. Install dependencies + +```bash +pip install -r requirements.txt +``` + +### 2. Set environment variables + +| Variable | Description | Default | +|---|---|---| +| `ORACLE_HOST` | Oracle server hostname | **required** | +| `ORACLE_PORT` | Oracle listener port | `1521` | +| `ORACLE_SERVICE` | Oracle service name or SID | **required** | +| `ORACLE_USER` | Oracle schema/user | **required** | +| `ORACLE_PASSWORD` | Oracle password | **required** | +| `PG_HOST` | PostgreSQL hostname | **required** | +| `PG_PORT` | PostgreSQL port | `5432` | +| `PG_DATABASE` | PostgreSQL database name | **required** | +| `PG_SCHEMA` | Target PostgreSQL schema | `public` | +| `PG_USER` | PostgreSQL user | **required** | +| `PG_PASSWORD` | PostgreSQL password | **required** | +| `MIGRATION_TABLES` | Comma-separated table list (empty = all) | *(all tables)* | +| `MIGRATION_BATCH_SIZE` | Rows fetched per batch | `10000` | +| `MIGRATION_MODE` | `full` (truncate+reload) or `incremental` | `full` | +| `LOG_LEVEL` | Python logging level | `INFO` | + +### 3. Run the migration + +```bash +# Migrate all tables (full reload) +python migrate.py + +# Migrate specific tables +python migrate.py --tables ORDERS,CUSTOMERS,PRODUCTS + +# Incremental mode (append only) +python migrate.py --mode incremental + +# Dry run – connect to both DBs but write nothing +python migrate.py --dry-run +``` + +## GitHub Actions + +### Daily schedule + +The workflow `.github/workflows/daily_migration.yml` runs automatically every day +at **02:00 UTC** (`cron: "0 2 * * *"`). + +You can also trigger it manually from the **Actions** tab, optionally overriding +the table list, mode, and dry-run flag. + +### Secrets required + +Add the following secrets to your repository (or the `production` environment): + +- `ORACLE_HOST`, `ORACLE_PORT`, `ORACLE_SERVICE`, `ORACLE_USER`, `ORACLE_PASSWORD` +- `PG_HOST`, `PG_PORT`, `PG_DATABASE`, `PG_USER`, `PG_PASSWORD` + +### CI + +The workflow `.github/workflows/ci.yml` runs unit tests on every push and pull +request. + +## Running tests + +```bash +pytest tests/ -v +``` + +## How it works + +1. **Schema discovery** – the script queries `ALL_TAB_COLUMNS` in Oracle to + retrieve the full column list (name, data type, precision, scale, nullability). +2. **Type mapping** – Oracle types are converted to the closest PostgreSQL + equivalents (see `type_mapping.py`). +3. **DDL** – `CREATE TABLE IF NOT EXISTS` is executed in PostgreSQL. +4. **Data transfer** – rows are streamed from Oracle in configurable batches and + bulk-inserted into PostgreSQL using `psycopg2.extras.execute_values`. +5. **Full vs incremental** – in `full` mode the target table is truncated before + loading; in `incremental` mode rows are appended. \ No newline at end of file diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a296d91b1ae9f78ee58d9cc19819b58acb949d70 GIT binary patch literal 5721 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 F{Tpb2JzoF- literal 0 HcmV?d00001 diff --git a/__pycache__/migrate.cpython-312.pyc b/__pycache__/migrate.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..280fde868a79f01059d4af01cde4fabb736b31b9 GIT binary patch literal 12988 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 literal 0 HcmV?d00001 diff --git a/__pycache__/type_mapping.cpython-312.pyc b/__pycache__/type_mapping.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1d9a0d11b4c1b7a3576ebfa0030a2423e51bc485 GIT binary patch literal 2398 zcmZXUO>Eo96@Z5#Da!h>EdR=mW7DzMF_SoPvQB^8peSwAwsvLFE6K^)JrJsfag^AS zFru@Nplx@79*jM?=%E7I1&jg(QtTzY_1IpD0L21yQy|(o1=wD4BbuIaDEemf6Ky&q z-hA)zy_w;gdGq&3B#5B>_sZ9Wt09EGWlE=o+wl5t0KY-I2%~*eKv=-OUkg~o5=a^Q zK`NXBH~`$oWe^8}LpTI7%w+`k14p?Wz%k%KE{C`r#v@=E<#LS65gdnS9G(eyF5pRg z5l{Ut_y=KMuzj{@i}!uwur6D|(|4f9sx9IfTgI1a3TU%kngh=Oe(#skzQ1}&o3C9q zNiKhAt4>MXDOH>UVrM=|syOH5RHs_D)uUXwTqqt~r~cIL_NtMl3iL)gu}0;Mq^W0X z1MBAQYSK`5(#BdMlQ2^>yqZYq={<{or&8L`lQhz{wVbJvNM(&p7)=DAfy}m^Ofr(z zw)8ZWQ@jh@)zb`aQKe0^FV(e4Ye{pJN?GIMEDPXD(oAhqF|FUD^6FmJ&}#u^QJFzn zWgdOm#I}*i>f1YS;$2klC9+#8Q`LPlWzYayT>37oTKsrBSqrtNT)6oItrqDh%S%g3 znihnKHWEpLO6z*oAY+hu5(iP6dS~>cxuz%I&QTlf%%ScXdRonz>b9B6ZuQvL^*v_J z7;9!~ohq=B?B0$+<+OP(u}=NFt$xr<>uX8F%9_@WnaOUZjSLNy_?B91|7c&C*!e=G zP%5UVT*>DSZR#hsb4-eP5f<8MG62(GuL$gL*U>}h-)&#D4G()G+qTZPPsO^3g+~jJ zh--D>^Rc=Q`?xk(ha!120d}D(0n2Un11oI~K*Rv((>%ed^T6&cs(k72_^8+_tNSIQ=G242W4oxf zE3o|;+m$>ilpWZy>%pK3O(zGH6;4d++1wR13p0E-oEL2|cVw#z$K|q37W27^jkW9D z`0jaFQB%i9HYw!QuIZXuxUWLN9~SbtpB>t7gZROD8^G27bLLaDe!XYCdT$E&>M9Ol zHwt$rK1k^+meyAi2*R%4S1jEmz-SNz^{*y2pQ1g)pbB}_k zb#9CWu)_6?2)nauGP^_;cyZSHzgz3IVXnVZoxFre3}Q8z$~=uwJFx094OW~SaVnn_ zoCib&2Pz)ejwX{CU~-9xS!Rv(?2^?Qz|U?{8&}v#Wc#STxq9P4>Bzo8j*CT`-1wwK z9^G*4iqjTQyjJJ(Emvt2*lHaSUfAS)7ze5hRb)7DpCNA)jnBP^Yv*z8dHm{&_|5b9 z&FAs=U1`MgqriA$>mSNoGc@dm)U(|ux!>{t$y)~=Gb)06Cy{a z8imGBpZJ~{?y~XZwtHp!*+=e>>4wc`H+vL_Yi=KaG-uF@ppAZP{lS&#}3_@4d%(o{gh;ml-cGpG{9=d str: + value = os.environ.get(name) + if not value: + raise EnvironmentError( + f"Required environment variable '{name}' is not set." + ) + return value + + +def _optional_env(name: str, default: str) -> str: + return os.environ.get(name, default) + + +@dataclass(frozen=True) +class OracleConfig: + host: str + port: int + service: str + user: str + password: str + + @classmethod + def from_env(cls) -> "OracleConfig": + return cls( + host=_require_env("ORACLE_HOST"), + port=int(_optional_env("ORACLE_PORT", "1521")), + service=_require_env("ORACLE_SERVICE"), + user=_require_env("ORACLE_USER"), + password=_require_env("ORACLE_PASSWORD"), + ) + + @property + def dsn(self) -> str: + return f"{self.host}:{self.port}/{self.service}" + + +@dataclass(frozen=True) +class PostgresConfig: + host: str + port: int + database: str + user: str + password: str + schema: str = "public" + + @classmethod + def from_env(cls) -> "PostgresConfig": + return cls( + host=_require_env("PG_HOST"), + port=int(_optional_env("PG_PORT", "5432")), + database=_require_env("PG_DATABASE"), + user=_require_env("PG_USER"), + password=_require_env("PG_PASSWORD"), + schema=_optional_env("PG_SCHEMA", "public"), + ) + + +@dataclass(frozen=True) +class MigrationConfig: + oracle: OracleConfig + postgres: PostgresConfig + tables: list[str] = field(default_factory=list) + batch_size: int = 10_000 + mode: str = "full" # 'full' | 'incremental' + log_level: str = "INFO" + + @classmethod + def from_env(cls) -> "MigrationConfig": + tables_raw = _optional_env("MIGRATION_TABLES", "") + tables = [t.strip() for t in tables_raw.split(",") if t.strip()] + batch_size = int(_optional_env("MIGRATION_BATCH_SIZE", "10000")) + mode = _optional_env("MIGRATION_MODE", "full").lower() + if mode not in ("full", "incremental"): + raise ValueError(f"MIGRATION_MODE must be 'full' or 'incremental', got '{mode}'") + return cls( + oracle=OracleConfig.from_env(), + postgres=PostgresConfig.from_env(), + tables=tables, + batch_size=batch_size, + mode=mode, + log_level=_optional_env("LOG_LEVEL", "INFO").upper(), + ) diff --git a/migrate.py b/migrate.py new file mode 100644 index 0000000..b8bb364 --- /dev/null +++ b/migrate.py @@ -0,0 +1,319 @@ +"""Oracle to PostgreSQL DWH migration script. + +Usage +----- + python migrate.py [--tables TABLE1,TABLE2] [--mode full|incremental] + [--batch-size N] [--dry-run] + +All database connection parameters are supplied through environment variables +(see config.py). Pass --help for a full list of command-line options. +""" + +from __future__ import annotations + +import argparse +import logging +import sys +import time +from contextlib import contextmanager +from typing import Generator + +import oracledb +import psycopg2 +import psycopg2.extras + +from config import MigrationConfig, OracleConfig, PostgresConfig +from type_mapping import map_column_type + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Connection helpers +# --------------------------------------------------------------------------- + +@contextmanager +def oracle_connection(cfg: OracleConfig) -> Generator[oracledb.Connection, None, None]: + """Yield an Oracle connection and close it when done.""" + conn = oracledb.connect(user=cfg.user, password=cfg.password, dsn=cfg.dsn) + try: + yield conn + finally: + conn.close() + + +@contextmanager +def postgres_connection(cfg: PostgresConfig) -> Generator[psycopg2.extensions.connection, None, None]: + """Yield a PostgreSQL connection and close it when done.""" + conn = psycopg2.connect( + host=cfg.host, + port=cfg.port, + dbname=cfg.database, + user=cfg.user, + password=cfg.password, + ) + conn.autocommit = False + try: + yield conn + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Schema helpers +# --------------------------------------------------------------------------- + +def get_tables(oracle_cur: oracledb.Cursor, schema: str) -> list[str]: + """Return all user-visible table names in *schema* (upper-cased).""" + oracle_cur.execute( + "SELECT table_name FROM all_tables WHERE owner = :owner ORDER BY table_name", + owner=schema.upper(), + ) + return [row[0] for row in oracle_cur.fetchall()] + + +ColumnInfo = tuple[str, str, int | None, int | None, bool] +# (name, oracle_type, precision, scale, nullable) + + +def get_columns(oracle_cur: oracledb.Cursor, schema: str, table: str) -> list[ColumnInfo]: + """Return column metadata for *schema*.*table* from Oracle.""" + oracle_cur.execute( + """ + SELECT column_name, + data_type, + data_precision, + data_scale, + nullable + FROM all_tab_columns + WHERE owner = :owner + AND table_name = :table_name + ORDER BY column_id + """, + owner=schema.upper(), + table_name=table.upper(), + ) + return [ + (row[0], row[1], row[2], row[3], row[4] == "Y") + for row in oracle_cur.fetchall() + ] + + +def build_create_table_sql(pg_schema: str, table: str, columns: list[ColumnInfo]) -> str: + """Generate a PostgreSQL CREATE TABLE statement from Oracle column metadata.""" + col_defs: list[str] = [] + for name, oracle_type, precision, scale, nullable in columns: + pg_type = map_column_type(oracle_type, precision, scale) + null_clause = "" if nullable else " NOT NULL" + col_defs.append(f' "{name.lower()}" {pg_type}{null_clause}') + cols_sql = ",\n".join(col_defs) + return f'CREATE TABLE IF NOT EXISTS "{pg_schema}"."{table.lower()}" (\n{cols_sql}\n);' + + +# --------------------------------------------------------------------------- +# Migration logic +# --------------------------------------------------------------------------- + +def migrate_table( + oracle_conn: oracledb.Connection, + pg_conn: psycopg2.extensions.connection, + oracle_schema: str, + pg_schema: str, + table: str, + batch_size: int, + mode: str, + dry_run: bool, +) -> int: + """Migrate a single table from Oracle to PostgreSQL. + + Returns the total number of rows copied. + """ + oracle_cur = oracle_conn.cursor() + pg_cur = pg_conn.cursor() + + # Fetch column metadata from Oracle + columns = get_columns(oracle_cur, oracle_schema, table) + if not columns: + logger.warning("Table %s.%s not found in Oracle; skipping.", oracle_schema, table) + return 0 + + col_names = [col[0] for col in columns] + pg_col_names = [f'"{c.lower()}"' for c in col_names] + + # Ensure target table exists in PostgreSQL + create_sql = build_create_table_sql(pg_schema, table, columns) + logger.debug("DDL:\n%s", create_sql) + if not dry_run: + pg_cur.execute(f'CREATE SCHEMA IF NOT EXISTS "{pg_schema}";') + pg_cur.execute(create_sql) + pg_conn.commit() + + # Truncate on full reload + if mode == "full" and not dry_run: + pg_cur.execute(f'TRUNCATE TABLE "{pg_schema}"."{table.lower()}";') + pg_conn.commit() + + # Stream data from Oracle and bulk-insert into PostgreSQL + # Column names are double-quoted to handle reserved words and special characters. + quoted_col_names = [f'"{c}"' for c in col_names] + select_sql = ( + f'SELECT {", ".join(quoted_col_names)} ' + f'FROM "{oracle_schema}"."{table}"' + ) + oracle_cur.arraysize = batch_size + oracle_cur.execute(select_sql) + + insert_sql = ( + f'INSERT INTO "{pg_schema}"."{table.lower()}" ' + f"({', '.join(pg_col_names)}) " + f"VALUES %s" + ) + + total_rows = 0 + while True: + rows = oracle_cur.fetchmany(batch_size) + if not rows: + break + total_rows += len(rows) + if not dry_run: + psycopg2.extras.execute_values(pg_cur, insert_sql, rows, page_size=batch_size) + pg_conn.commit() + logger.debug(" … %d rows inserted into %s.%s", total_rows, pg_schema, table) + + oracle_cur.close() + pg_cur.close() + return total_rows + + +def run_migration(cfg: MigrationConfig, dry_run: bool = False) -> dict[str, int]: + """Run the full migration according to *cfg*. + + Returns a dict mapping table name → row count. + """ + results: dict[str, int] = {} + + with oracle_connection(cfg.oracle) as oracle_conn, \ + postgres_connection(cfg.postgres) as pg_conn: + + oracle_cur = oracle_conn.cursor() + + # Resolve which tables to migrate + if cfg.tables: + tables = [t.upper() for t in cfg.tables] + else: + tables = get_tables(oracle_cur, cfg.oracle.user) + logger.info("Discovered %d tables in schema %s", len(tables), cfg.oracle.user) + + oracle_cur.close() + + for table in tables: + logger.info("Migrating table %s …", table) + start = time.monotonic() + rows = migrate_table( + oracle_conn=oracle_conn, + pg_conn=pg_conn, + oracle_schema=cfg.oracle.user, + pg_schema=cfg.postgres.schema, + table=table, + batch_size=cfg.batch_size, + mode=cfg.mode, + dry_run=dry_run, + ) + elapsed = time.monotonic() - start + logger.info( + " ✓ %s – %d rows in %.1f s (%.0f rows/s)", + table, + rows, + elapsed, + rows / elapsed if elapsed > 0 else 0, + ) + results[table] = rows + + return results + + +# --------------------------------------------------------------------------- +# CLI entry-point +# --------------------------------------------------------------------------- + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Migrate tables from Oracle DWH to PostgreSQL." + ) + parser.add_argument( + "--tables", + default="", + help="Comma-separated list of tables to migrate (default: all tables).", + ) + parser.add_argument( + "--mode", + choices=["full", "incremental"], + default=None, + help="Migration mode: 'full' (truncate+reload) or 'incremental'. " + "Overrides MIGRATION_MODE env var.", + ) + parser.add_argument( + "--batch-size", + type=int, + default=None, + help="Number of rows per batch. Overrides MIGRATION_BATCH_SIZE env var.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Connect to both databases and enumerate tables/columns but do not write data.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + + cfg = MigrationConfig.from_env() + + # CLI arguments override environment variables + if args.tables: + cfg = cfg.__class__( + oracle=cfg.oracle, + postgres=cfg.postgres, + tables=[t.strip() for t in args.tables.split(",") if t.strip()], + batch_size=args.batch_size or cfg.batch_size, + mode=args.mode or cfg.mode, + log_level=cfg.log_level, + ) + elif args.mode or args.batch_size: + cfg = cfg.__class__( + oracle=cfg.oracle, + postgres=cfg.postgres, + tables=cfg.tables, + batch_size=args.batch_size or cfg.batch_size, + mode=args.mode or cfg.mode, + log_level=cfg.log_level, + ) + + logging.basicConfig( + level=getattr(logging, cfg.log_level, logging.INFO), + format="%(asctime)s %(levelname)-8s %(name)s – %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", + ) + + if args.dry_run: + logger.info("DRY RUN – no data will be written to PostgreSQL.") + + logger.info( + "Starting migration: Oracle %s → PostgreSQL %s (mode=%s, batch=%d)", + cfg.oracle.dsn, + cfg.postgres.database, + cfg.mode, + cfg.batch_size, + ) + + results = run_migration(cfg, dry_run=args.dry_run) + + total = sum(results.values()) + logger.info("Migration complete. %d tables, %d total rows.", len(results), total) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..982c3ab --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +# Oracle to PostgreSQL DWH migration tool +# Runtime dependencies +oracledb>=2.0.0 +psycopg2-binary>=2.9.0 + +# Testing +pytest>=8.0.0 +pytest-cov>=5.0.0 diff --git a/tests/__pycache__/test_config.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_config.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a0b15e9ad6a2249fecc1d68aae97ca0f9d53f54b GIT binary patch literal 14230 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 KD+~}a^!@|eR~p0s literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_migrate.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_migrate.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae43a6fd8e06bcd9501e7cf1438e755eee32207d GIT binary patch literal 16773 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_type_mapping.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_type_mapping.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..41194a386eae61e1b8016ab848eebd741506b077 GIT binary patch literal 19738 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 literal 0 HcmV?d00001 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d653b43 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,102 @@ +"""Tests for the configuration module.""" + +import os +import pytest + +from config import MigrationConfig, OracleConfig, PostgresConfig + + +ORACLE_ENV = { + "ORACLE_HOST": "oracle-host", + "ORACLE_PORT": "1521", + "ORACLE_SERVICE": "ORCL", + "ORACLE_USER": "dwh_user", + "ORACLE_PASSWORD": "secret", +} + +PG_ENV = { + "PG_HOST": "pg-host", + "PG_PORT": "5432", + "PG_DATABASE": "dwh_db", + "PG_USER": "pg_user", + "PG_PASSWORD": "pg_secret", +} + + +class TestOracleConfig: + def test_from_env_success(self, monkeypatch): + for k, v in ORACLE_ENV.items(): + monkeypatch.setenv(k, v) + cfg = OracleConfig.from_env() + assert cfg.host == "oracle-host" + assert cfg.port == 1521 + assert cfg.service == "ORCL" + assert cfg.dsn == "oracle-host:1521/ORCL" + + def test_missing_required_raises(self, monkeypatch): + monkeypatch.delenv("ORACLE_HOST", raising=False) + with pytest.raises(EnvironmentError, match="ORACLE_HOST"): + OracleConfig.from_env() + + def test_default_port(self, monkeypatch): + for k, v in ORACLE_ENV.items(): + monkeypatch.setenv(k, v) + monkeypatch.delenv("ORACLE_PORT", raising=False) + cfg = OracleConfig.from_env() + assert cfg.port == 1521 + + +class TestPostgresConfig: + def test_from_env_success(self, monkeypatch): + for k, v in PG_ENV.items(): + monkeypatch.setenv(k, v) + cfg = PostgresConfig.from_env() + assert cfg.host == "pg-host" + assert cfg.port == 5432 + assert cfg.database == "dwh_db" + + def test_missing_required_raises(self, monkeypatch): + monkeypatch.delenv("PG_HOST", raising=False) + with pytest.raises(EnvironmentError, match="PG_HOST"): + PostgresConfig.from_env() + + def test_default_port(self, monkeypatch): + for k, v in PG_ENV.items(): + monkeypatch.setenv(k, v) + monkeypatch.delenv("PG_PORT", raising=False) + cfg = PostgresConfig.from_env() + assert cfg.port == 5432 + + +class TestMigrationConfig: + def _set_all_env(self, monkeypatch): + for k, v in {**ORACLE_ENV, **PG_ENV}.items(): + monkeypatch.setenv(k, v) + + def test_defaults(self, monkeypatch): + self._set_all_env(monkeypatch) + monkeypatch.delenv("MIGRATION_TABLES", raising=False) + monkeypatch.delenv("MIGRATION_BATCH_SIZE", raising=False) + monkeypatch.delenv("MIGRATION_MODE", raising=False) + cfg = MigrationConfig.from_env() + assert cfg.tables == [] + assert cfg.batch_size == 10_000 + assert cfg.mode == "full" + + def test_custom_tables(self, monkeypatch): + self._set_all_env(monkeypatch) + monkeypatch.setenv("MIGRATION_TABLES", "ORDERS, CUSTOMERS, PRODUCTS") + cfg = MigrationConfig.from_env() + assert cfg.tables == ["ORDERS", "CUSTOMERS", "PRODUCTS"] + + def test_incremental_mode(self, monkeypatch): + self._set_all_env(monkeypatch) + monkeypatch.setenv("MIGRATION_MODE", "incremental") + cfg = MigrationConfig.from_env() + assert cfg.mode == "incremental" + + def test_invalid_mode_raises(self, monkeypatch): + self._set_all_env(monkeypatch) + monkeypatch.setenv("MIGRATION_MODE", "delta") + with pytest.raises(ValueError, match="MIGRATION_MODE"): + MigrationConfig.from_env() diff --git a/tests/test_migrate.py b/tests/test_migrate.py new file mode 100644 index 0000000..fbaaa99 --- /dev/null +++ b/tests/test_migrate.py @@ -0,0 +1,163 @@ +"""Tests for migrate.py – uses mocks to avoid real DB connections.""" + +from __future__ import annotations + +import pytest +from unittest.mock import MagicMock, patch, call + +import migrate +from migrate import ( + build_create_table_sql, + get_columns, + get_tables, + migrate_table, +) + + +# --------------------------------------------------------------------------- +# build_create_table_sql +# --------------------------------------------------------------------------- + +class TestBuildCreateTableSql: + def test_basic_table(self): + columns = [ + ("ID", "NUMBER", 10, 0, False), + ("NAME", "VARCHAR2", 100, None, True), + ("CREATED_AT", "DATE", None, None, True), + ] + sql = build_create_table_sql("public", "ORDERS", columns) + assert 'CREATE TABLE IF NOT EXISTS "public"."orders"' in sql + assert '"id" BIGINT NOT NULL' in sql + assert '"name" VARCHAR(100)' in sql + assert '"created_at" TIMESTAMP' in sql + + def test_nullable_column_has_no_not_null(self): + columns = [("NOTES", "CLOB", None, None, True)] + sql = build_create_table_sql("public", "T", columns) + assert "NOT NULL" not in sql + + def test_non_nullable_column_has_not_null(self): + columns = [("CODE", "CHAR", 3, None, False)] + sql = build_create_table_sql("public", "T", columns) + assert '"code" CHAR(3) NOT NULL' in sql + + +# --------------------------------------------------------------------------- +# get_tables +# --------------------------------------------------------------------------- + +class TestGetTables: + def test_returns_table_names(self): + mock_cur = MagicMock() + mock_cur.fetchall.return_value = [("ORDERS",), ("CUSTOMERS",)] + result = get_tables(mock_cur, "DWH") + assert result == ["ORDERS", "CUSTOMERS"] + mock_cur.execute.assert_called_once() + # Schema should be upper-cased in query + args = mock_cur.execute.call_args + assert args[1]["owner"] == "DWH" + + +# --------------------------------------------------------------------------- +# get_columns +# --------------------------------------------------------------------------- + +class TestGetColumns: + def test_returns_column_info(self): + mock_cur = MagicMock() + mock_cur.fetchall.return_value = [ + ("ID", "NUMBER", 10, 0, "N"), + ("NAME", "VARCHAR2", 200, None, "Y"), + ] + result = get_columns(mock_cur, "DWH", "ORDERS") + assert len(result) == 2 + assert result[0] == ("ID", "NUMBER", 10, 0, False) + assert result[1] == ("NAME", "VARCHAR2", 200, None, True) + + +# --------------------------------------------------------------------------- +# migrate_table – dry run +# --------------------------------------------------------------------------- + +class TestMigrateTableDryRun: + def _make_oracle_conn(self, columns, rows): + mock_cur = MagicMock() + mock_cur.fetchall.return_value = columns + mock_cur.fetchmany.side_effect = [rows, []] + mock_conn = MagicMock() + mock_conn.cursor.return_value = mock_cur + return mock_conn, mock_cur + + def test_dry_run_does_not_execute_write(self): + columns = [("ID", "NUMBER", 10, 0, "N"), ("VAL", "VARCHAR2", 50, None, "Y")] + rows = [(1, "alpha"), (2, "beta")] + + mock_ora_cur = MagicMock() + # get_columns call + mock_ora_cur.fetchall.return_value = columns + # data fetch + mock_ora_cur.fetchmany.side_effect = [rows, []] + mock_ora_conn = MagicMock() + mock_ora_conn.cursor.return_value = mock_ora_cur + + mock_pg_cur = MagicMock() + mock_pg_conn = MagicMock() + mock_pg_conn.cursor.return_value = mock_pg_cur + + count = migrate_table( + oracle_conn=mock_ora_conn, + pg_conn=mock_pg_conn, + oracle_schema="DWH", + pg_schema="public", + table="ORDERS", + batch_size=1000, + mode="full", + dry_run=True, + ) + + assert count == 2 + # In dry-run mode, no DDL or DML should be executed on Postgres + mock_pg_cur.execute.assert_not_called() + mock_pg_conn.commit.assert_not_called() + + def test_empty_table_skipped_gracefully(self): + mock_ora_cur = MagicMock() + mock_ora_cur.fetchall.return_value = [] # no columns → table not found + mock_ora_conn = MagicMock() + mock_ora_conn.cursor.return_value = mock_ora_cur + + mock_pg_conn = MagicMock() + + count = migrate_table( + oracle_conn=mock_ora_conn, + pg_conn=mock_pg_conn, + oracle_schema="DWH", + pg_schema="public", + table="MISSING_TABLE", + batch_size=1000, + mode="full", + dry_run=True, + ) + assert count == 0 + + +# --------------------------------------------------------------------------- +# CLI argument parsing +# --------------------------------------------------------------------------- + +class TestParseArgs: + def test_defaults(self): + args = migrate.parse_args([]) + assert args.tables == "" + assert args.mode is None + assert args.batch_size is None + assert args.dry_run is False + + def test_dry_run_flag(self): + args = migrate.parse_args(["--dry-run"]) + assert args.dry_run is True + + def test_tables_and_mode(self): + args = migrate.parse_args(["--tables", "A,B", "--mode", "incremental"]) + assert args.tables == "A,B" + assert args.mode == "incremental" diff --git a/tests/test_type_mapping.py b/tests/test_type_mapping.py new file mode 100644 index 0000000..8d4e522 --- /dev/null +++ b/tests/test_type_mapping.py @@ -0,0 +1,92 @@ +"""Tests for the Oracle → PostgreSQL type mapping module.""" + +import pytest + +from type_mapping import map_column_type + + +# --------------------------------------------------------------------------- +# NUMBER mappings +# --------------------------------------------------------------------------- + +class TestNumberMapping: + def test_number_no_precision_returns_numeric(self): + assert map_column_type("NUMBER", None, None) == "NUMERIC" + + def test_number_with_fractional_scale(self): + assert map_column_type("NUMBER", 10, 2) == "NUMERIC(10, 2)" + + def test_number_precision_4_scale_0_is_smallint(self): + assert map_column_type("NUMBER", 4, 0) == "SMALLINT" + + def test_number_precision_9_scale_0_is_integer(self): + assert map_column_type("NUMBER", 9, 0) == "INTEGER" + + def test_number_precision_18_scale_0_is_bigint(self): + assert map_column_type("NUMBER", 18, 0) == "BIGINT" + + def test_number_precision_19_scale_0_is_numeric(self): + result = map_column_type("NUMBER", 19, 0) + assert result == "NUMERIC(19, 0)" + + +# --------------------------------------------------------------------------- +# Character type mappings +# --------------------------------------------------------------------------- + +class TestCharacterMapping: + def test_varchar2_with_length(self): + assert map_column_type("VARCHAR2", 100, None) == "VARCHAR(100)" + + def test_nvarchar2_with_length(self): + assert map_column_type("NVARCHAR2", 200, None) == "VARCHAR(200)" + + def test_char_with_length(self): + assert map_column_type("CHAR", 10, None) == "CHAR(10)" + + def test_clob_maps_to_text(self): + assert map_column_type("CLOB", None, None) == "TEXT" + + def test_long_maps_to_text(self): + assert map_column_type("LONG", None, None) == "TEXT" + + +# --------------------------------------------------------------------------- +# Date / time mappings +# --------------------------------------------------------------------------- + +class TestDateTimeMapping: + def test_date_maps_to_timestamp(self): + assert map_column_type("DATE", None, None) == "TIMESTAMP" + + def test_timestamp_maps_to_timestamp(self): + assert map_column_type("TIMESTAMP", None, None) == "TIMESTAMP" + + def test_timestamp_with_tz(self): + result = map_column_type("TIMESTAMP WITH TIME ZONE", None, None) + assert result == "TIMESTAMP WITH TIME ZONE" + + def test_timestamp_with_local_tz(self): + result = map_column_type("TIMESTAMP WITH LOCAL TIME ZONE", None, None) + assert result == "TIMESTAMP WITH TIME ZONE" + + +# --------------------------------------------------------------------------- +# Binary / other mappings +# --------------------------------------------------------------------------- + +class TestOtherMappings: + def test_blob_maps_to_bytea(self): + assert map_column_type("BLOB", None, None) == "BYTEA" + + def test_float_maps_to_double_precision(self): + assert map_column_type("FLOAT", None, None) == "DOUBLE PRECISION" + + def test_binary_double(self): + assert map_column_type("BINARY_DOUBLE", None, None) == "DOUBLE PRECISION" + + def test_unknown_type_falls_back_to_text(self): + assert map_column_type("SOME_UNKNOWN_TYPE", None, None) == "TEXT" + + def test_case_insensitive(self): + assert map_column_type("varchar2", 50, None) == "VARCHAR(50)" diff --git a/type_mapping.py b/type_mapping.py new file mode 100644 index 0000000..ed88ab1 --- /dev/null +++ b/type_mapping.py @@ -0,0 +1,86 @@ +"""Oracle to PostgreSQL data type mapping.""" + +# Maps Oracle SQL types to PostgreSQL equivalents +ORACLE_TO_POSTGRES: dict[str, str] = { + # Numeric types + "NUMBER": "NUMERIC", + "FLOAT": "DOUBLE PRECISION", + "BINARY_FLOAT": "REAL", + "BINARY_DOUBLE": "DOUBLE PRECISION", + "INTEGER": "INTEGER", + "INT": "INTEGER", + "SMALLINT": "SMALLINT", + # Character types + "CHAR": "CHAR", + "NCHAR": "CHAR", + "VARCHAR2": "VARCHAR", + "NVARCHAR2": "VARCHAR", + "CLOB": "TEXT", + "NCLOB": "TEXT", + "LONG": "TEXT", + # Binary types + "RAW": "BYTEA", + "LONG RAW": "BYTEA", + "BLOB": "BYTEA", + "BFILE": "BYTEA", + # Date / time types + "DATE": "TIMESTAMP", + "TIMESTAMP": "TIMESTAMP", + "TIMESTAMP WITH TIME ZONE": "TIMESTAMP WITH TIME ZONE", + "TIMESTAMP WITH LOCAL TIME ZONE": "TIMESTAMP WITH TIME ZONE", + "INTERVAL YEAR TO MONTH": "INTERVAL", + "INTERVAL DAY TO SECOND": "INTERVAL", + # Other + "XMLTYPE": "XML", + "ROWID": "VARCHAR(18)", + "UROWID": "VARCHAR(4000)", +} + + +def map_column_type(oracle_type: str, precision: int | None, scale: int | None) -> str: + """Return the PostgreSQL column type for a given Oracle column description. + + Args: + oracle_type: The Oracle column type name (upper-cased). + precision: Numeric precision, if applicable. + scale: Numeric scale, if applicable. + + Returns: + A PostgreSQL type string. + """ + oracle_type_upper = oracle_type.upper().strip() + + # NUMBER with scale 0 (or no fractional part) → prefer integer types + if oracle_type_upper == "NUMBER": + if precision is not None and scale == 0: + if precision <= 4: + return "SMALLINT" + if precision <= 9: + return "INTEGER" + if precision <= 18: + return "BIGINT" + if precision is not None: + # scale can be None when Oracle reports precision without scale (e.g. NUMBER(10)); + # default to 0 in that case to produce a valid NUMERIC declaration. + return f"NUMERIC({precision}, {scale if scale is not None else 0})" + return "NUMERIC" + + # VARCHAR2 / NVARCHAR2 / CHAR / NCHAR with length + if oracle_type_upper in ("VARCHAR2", "NVARCHAR2") and precision is not None: + return f"VARCHAR({precision})" + + if oracle_type_upper in ("CHAR", "NCHAR") and precision is not None: + return f"CHAR({precision})" + + # TIMESTAMP with fractional-second precision + if oracle_type_upper.startswith("TIMESTAMP"): + if "WITH TIME ZONE" in oracle_type_upper or "WITH LOCAL TIME ZONE" in oracle_type_upper: + return "TIMESTAMP WITH TIME ZONE" + return "TIMESTAMP" + + mapped = ORACLE_TO_POSTGRES.get(oracle_type_upper) + if mapped: + return mapped + + # Fall back to TEXT for unknown types + return "TEXT" From 126761608e8c84fa10a5a750f7d83a1809b61348 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:29:24 +0000 Subject: [PATCH 3/3] Add .gitignore to exclude __pycache__ and other build artifacts Agent-Logs-Url: https://github.com/eng-dedalus/test/sessions/7f993e48-6f10-4fe2-9101-17b4a0803c20 Co-authored-by: eng-dedalus <260377058+eng-dedalus@users.noreply.github.com> --- .gitignore | 19 ++++++++++++++++++ __pycache__/config.cpython-312.pyc | Bin 5721 -> 0 bytes __pycache__/migrate.cpython-312.pyc | Bin 12988 -> 0 bytes __pycache__/type_mapping.cpython-312.pyc | Bin 2398 -> 0 bytes .../test_config.cpython-312-pytest-9.0.2.pyc | Bin 14230 -> 0 bytes .../test_migrate.cpython-312-pytest-9.0.2.pyc | Bin 16773 -> 0 bytes ..._type_mapping.cpython-312-pytest-9.0.2.pyc | Bin 19738 -> 0 bytes 7 files changed, 19 insertions(+) create mode 100644 .gitignore delete mode 100644 __pycache__/config.cpython-312.pyc delete mode 100644 __pycache__/migrate.cpython-312.pyc delete mode 100644 __pycache__/type_mapping.cpython-312.pyc delete mode 100644 tests/__pycache__/test_config.cpython-312-pytest-9.0.2.pyc delete mode 100644 tests/__pycache__/test_migrate.cpython-312-pytest-9.0.2.pyc delete mode 100644 tests/__pycache__/test_type_mapping.cpython-312-pytest-9.0.2.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58df291 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Python bytecode +__pycache__/ +*.py[cod] +*.pyo + +# Virtual environments +.venv/ +venv/ +env/ + +# Pytest +.pytest_cache/ +.coverage +htmlcov/ + +# Distribution / packaging +dist/ +build/ +*.egg-info/ diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc deleted file mode 100644 index a296d91b1ae9f78ee58d9cc19819b58acb949d70..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5721 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 F{Tpb2JzoF- diff --git a/__pycache__/migrate.cpython-312.pyc b/__pycache__/migrate.cpython-312.pyc deleted file mode 100644 index 280fde868a79f01059d4af01cde4fabb736b31b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12988 zcmcgSYj7LKd3V48I2_&tKvEF(SqHg18d}3J>P) zU|KMcFdk1$I&MkYNl4Z8h;C+TYOK`iOlRsQnO17k#F@4e(qv5dqReO#&$RBJB9hX? zn*Qkb?HxdnhNYy_>0+?ExBKn;+wZym>~L@tgsnUOHTujBiuxsf6oOgH(6=UNikhVa zYLF6W!4RY4hCu_3d1K5tXe22!$dJ@DXdFsQTHUeiK zw3F09Qgh51cMZDY?m>6lGw30(mRLpHJLsh;10^ye&4tOWrK0~`m>-70N|6;R1oma- zU9Er`tb!7+U@a-B7JLHtGBdbItPyMBS0@hm^Nc z_x=f0f&*Z;3Dv?T!TGXjuwJMUY6TbM8X#9ExFOdl`UTH9Scy~(Ych=-)+bw%KzUnM}YGRJ(R0`j=Bb z7Fqj-)~L#FBsxQH38nD<*g$nGl1pzf4LNC6yh8BOOah zf@&1xL~p>XvPpvZ!jNjw`;l8&c*&p|BWH$H2BBo!L7u;}eb?D!T-*iN z2nV?f`aQQx5oJY-*6xGq(FTFzD$G>!Ll1C5l-vc;zf->*r*4}myKmK2yJV}K_r6p8 zcJ-Cczd7<(N3KSGG<!=R!7L-eth>Uj!z?DfPsglM8n||3fN|xK%cL@AfoQ7CXi`#C zvoMqZ>a&JL2YLfmKs@<;Bsn^KzvP0+N>vzDL!`0=0=Cvb<-#c?iPR;kNN$A0Md&yu z`LLCDRHM>zDN1dInl^;|Lx?t_RLJLgK2J%1mYoUmo9V-AvtWo7rv#EOSy- zb>m$x_uU=cw!k1Sb@-m3>1$O?M=N!$)`R)hijK#O*B&!MK2l0&@H~M&(6^=`GfTm_ z7wHic=s}=?Ug%V$P=;yYR4*Ie)%yg>LXGG+QVz0@ z3|s}kBEWluKrnr{zxzmc=YR$tLU8`T@xG(jUr6VL&m8JL-pwb+fL8F2^6grt?|4`D zasG*uB@KbNn@FXEi@ju2E#eDeB&CR|iHxbTXGGwM&@<3Au0UE+l4{oGrW&Q>n2g&9 z%v8e{Zdg?GX-GSu;tBW@$}>*gV4SN=pz0>kU=QREG10UMc(a+{jj@O~M!*|u zdcTf08YkvK>O(a4+g9-m$~o`cu?v;+ z-uYv1S1x!Lj$Nv}vggv~57`HA8Ua3!_v^Ep6l7%-v#uHWj_vF35XxEP4*dsYW~pJs zTtgRi8t(5YxOG`3Ys#9lmMps_-DOM}bH-AX#jF`tG6-}6_-K~P7|Y!z!I)w0MoZtM z1SVsBlge;!Qg4~w)#)}(z1GE0f@!44n_-H2$QTPyS({+aP|$+~DEnRLH9)W#8^qSP zEd5k~OZVu)vB-x@*>UIb?v8QF^g-bSmD&ph~5!d~e?X-+SW7k+ca> zp+0S>=hKE(E?u{-!=3{Ku>0Ab{(*kJ{*JLeSf6I<`DQL{0IYrAu~>cJr|iMJ1v>+#;r36ZC)0Dy%dQ3( z#SvHtEYx1>{v!5K z5Dc;`pjR1zqF8TLbVdQBp3N9CO!0zn8BVTNkVueF4xK?PDQSU9ONIjh!&V|-*fKW3 zfDA(uFtYZnEn^25hYnK-m(>9m%`t))v>_B`3FvQp%LqMGGgQ`@bttw1UQyH&<=tx4wSLiDZ*pl_ax$IGD3biA3h1^;?g6wxg$XGKK8DGY9+5N75 zTZnppS6sLUo_aD~#O$^HUK@q=ZQC#~d=_p9!Oy$bdy4{^ZLA>TJ4$nbinO2^TadTN z(Zq03$}f@6)|>V-5`ll5dUB%!Gc>{fgt6(0g|9;`tqa|7=g1w&Ji0H<39P_*Kk zE;eypSE;$9cky9OR`R zZ{SS8zaCZ@w4$Zm1IJJFcCInSdT9@ASlXgXl)&Zqv_;cJ>eG&%-u~|61AI^KKp${H zBe1r#Ex-Lz zM7IGW*?H9%6B81mscHthnG}{)r>+Qvo)5=TqD++IfWOF3QCBBClVlt^m(^MFgE$Ss zmpNimBui8oG)JlyuqlSXibEtw)eKx0RwV7mp^d=1RVy-Zq5`UPM73jmv4GS0EO1!W z{pC~=cBiyf7Gq$QMEBWJQE9_v-<-XC-@{xPzTqlaIZ&WV2caHZ!lnXxik$3-sXF5DbU$k7X zylI_&aemu}zQ#rG{_!IaLH8BS3)U<9-ruq8d1CzVb%uN8$k&cc*USyBFwHPnP9JR3 z16R&pId`>Uv8ro@?bb`D(<^L!j&S1QK>N#9|!@T&;xwp@KJ3b$R zj`rDecK1}zWY2VL+1{{dYlN~UU*2f2`nCS-RD~A~gmdOxn^s*7ORk3b!^^H+6U@gh zZ_e(?u@&El%r(9dU1GORa38xWuiJf7eUp8w_Le1k%fbU!+TT|`NH5x3R_ud0yLYO0 zvUkN^hX-h%v@iPizu$5-x_IpA7516DhqAfyK3Kyl<6B~UE1=SAXYq1x&gNfa{GZ)? zj;1`VpQ+ycpWUpds`tqifI`>o_Cr<7wJo)W>X;u?ISy@O)){V4z5v;YzV#;%&r(q^ zrj#ffR5@dEf!pi@bww!({a~;uV@xiAWk5Z-i!_imK@UX^471XM)LgzCq%X5BeZexb z=8SoyjI31PEFg9HKt8Z!&4~e}C@TTiFT27KG|O!W1&J$bQE*#H7{Ob;Aqccmy*J_Fu8!HmDPo!$7ISu;YK$G{%H zo091Axs}@O=Q!zy@StsJCN-NSZ z0ujl>1QrWPk%q9sBF4g_U_1T-CEfwzNJ!)PVAGS5(B%8@moq>wfZub})@|blKe5)# z?YY=~p?%r9Jx?{5>KArix7N+|Up#f;)UvfDZ-k;;aNi9-zv|zyH`ZMf4*~?nI3!Hd1W`aHyloIZfvQaI5^$?YVWUh!W4hA8Mf#*xOI7O zgWJ91y`SBDl&akK8y6N~`JHvOcjSF=3UieQ+Kqp?^FYvaZBx~O0DJAeuI=#g!zvca ze;6=fK4?1dp!J7)ELhTRI>=boIpB~260Qmt9k}WmM=i%s1sWV5I-kg*i=@a?p%*;} zV8|9sm(2wm0Z^mza2A0TtiuL@61dB@HO=b_pzTVLRznNNW#>IxxCFQ0xm>Ys9%ZMPZaQJ0(!13k+aEY1hgFqhL&VppS|>1M7pDQy%Gv;DW0e}<@wAfJtS;(l8tvSUH^?(lB(EP@ck1 zNwtRsAq2IgOf`Z6s&WJp3uWkuf%JJ$-UxW9w^YjF#$oiI@Rz>>(KvP8;a_z$E;$-= zzRuf5gVQ{I00^VWw#rm3F;yUy=iC)JM_=ATnW}G73>f^ml3I7pQ#H4F(Y*sGP2NB| zcjv7H&J1wQ61d9i4mT1b$5T3h2>|RR0N$LdHs?6CZWw#Ts;zFxRyQ}WY^z^n>ZMMY z@jB{(_lZ!C6=ps4Si4nOdd=cl?rBF+jsy34IeARF46e6w_tsEO1_#(OZv65+m$RG+ zbb%Ux)LwAZHGpIWj=H;98)f=6-`ycVu5Z#DcQ6}d*BinMv}^}jX((3*EzsM(ArxDb zse^t{2-*;Udh1j)Ye>9OzE{~^etuH&|KJ=9GdtK(t*0d94XJJgxQs{E}*5#rWm}Qiu$eQl46INw+p$oA1>iY+FYoKEoz^lUP4>HiPZtJ~6w+74->NnCEjEEFn z)D=`Km>EMc@p&Is#7+LLgm`{Zy?XbSlbn zD73cI;D&_fk_sdWJosEwcchoF0}GmWVJ)f+UR_2bAP(STMGJVQ&NNjL_^Np;dvXF{)8c#Z|Lz!tH}%IOnfGLO8gr=~OXDgx^wP^Ut3UovEvqGUdo{9&^BpX^c`AZdp-dJ;ger`GRGdxu{L~2h;~@af^l(` z-MqwZ#t(Lq#RRHAhccOOyJ2~AoeRGA>Mqr-I3EB#)b5(L%sOYB%Wi(zzHPi4jlj_F zqWcei;Jy0HKh}MaT47G)Z1&G?_Ry5?2=H7uxN9x`o+kT0P!$lXY$${$8VZ4v9m$x4 z&k9r^0Qf2^-BNri`<$lW3qwI&62%uUd@sE6)Z){H_Gk%`z5!8s-&*6sE4&~$6vPC+ zmXU+#EYKbfukXi9ya9JyYXfRt4rB;`!-}N!ZbjYVIruycMvg`t^qUwVl=Ll(ka%i2 zoXs6Kf$t=8aSs?Buo*FiYyd{@gEWf~HX|JbDu{IWrmW;m^Kfr4$ueS%92w;qM}{*In0s$cA{Acccc~FM5hCEFPsTp zaDDB#SA2$?nbx2JBzGdV2YZ2L%cJ3lsJD-eDgBMv;n%k4XyvfGkzMA7XH3(Dbhi zR+{;BB}F& zY;7DzHOAn+X)x3NTSmy`F_*6(WsYJQ=9LQN1Ja2S^7CTzom<&D|4R4yb&`u96R$S%m9V7O#-siBw}vZwz8X3JMI diff --git a/__pycache__/type_mapping.cpython-312.pyc b/__pycache__/type_mapping.cpython-312.pyc deleted file mode 100644 index 1d9a0d11b4c1b7a3576ebfa0030a2423e51bc485..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2398 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 KD+~}a^!@|eR~p0s diff --git a/tests/__pycache__/test_migrate.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_migrate.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index ae43a6fd8e06bcd9501e7cf1438e755eee32207d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16773 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 diff --git a/tests/__pycache__/test_type_mapping.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_type_mapping.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index 41194a386eae61e1b8016ab848eebd741506b077..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19738 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