commit 2dd73490db3ba073884dde866248700b193e74fa Author: Ankit Malik Date: Tue Jun 2 18:23:18 2026 +0530 first commit diff --git a/.env b/.env new file mode 100644 index 0000000..6697f17 --- /dev/null +++ b/.env @@ -0,0 +1,14 @@ +# ── .env template ── copy to .env and fill in real values ────────────────── +# SQL Server (OneApp_KelloggsMT source) +MSSQL_SERVER=43.242.212.54,21443 +MSSQL_DB=OneApp_KelloggsMT +MSSQL_USER=ankit_malik +MSSQL_PASS=M4l!k#Ank1t001 + +# ClickHouse (destination) +CH_HOST=172.188.12.194 +CH_PORT=8123 +CH_USER=default +CH_PASS=dipanshu_k +CH_DB=kelloggs + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..4f09cbb --- /dev/null +++ b/main.py @@ -0,0 +1,465 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "polars>=0.20.0", +# "sqlalchemy>=2.0.0", +# "pyodbc>=5.0.0", +# "clickhouse-connect>=0.7.0", +# "python-dotenv>=1.0.0", +# "pyarrow>=18.0.0", +# ] +# /// + +""" +ETL Pipeline: Coverage KPI — SQL Server → Polars → ClickHouse +-------------------------------------------------------------- +Run with: uv run etl_coverage_kpi.py + +Why SQLAlchemy instead of raw pyodbc? + - pl.read_database() speaks SQLAlchemy Engine natively + - Connection pooling built-in (reuses connections, no reconnect overhead) + - Parameterised queries use :param syntax — cleaner and SQL-injection safe + - One engine object shared across all steps — no passing conn handles around + - Works with ANY database backend, not just SQL Server + + + 1. Build one SQLAlchemy engine (the "pipe" to SQL Server) + 2. Ask: which store visits happened yesterday? → get MIDs + 3. Pull Coverage rows for those MIDs in chunks → Polars DataFrames + 4. Clean + type-cast everything in Polars + 5. Delete stale ClickHouse rows, insert fresh batch + 6. Verify row count matches +""" + +import os +import pyarrow +import sys +import logging +from datetime import date, timedelta + +import polars as pl +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine, URL +import clickhouse_connect +from dotenv import load_dotenv + +load_dotenv() + +# ── Logging ─────────────────────────────────────────────────────────────────── +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s | %(levelname)-8s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +log = logging.getLogger("etl_coverage") + +# ── Config ──────────────────────────────────────────────────────────────────── +# SQL Server credentials — read from .env, never hardcoded + +MSSQL_SERVER = os.getenv("MSSQL_SERVER", "your_server") +MSSQL_DB = os.getenv("MSSQL_DB", "CPMIndiaBusinessInsight") +MSSQL_USER = os.getenv("MSSQL_USER", "sa") +MSSQL_PASS = os.getenv("MSSQL_PASS", "") + +# pyodbc connection string — built separately so special characters in +# passwords (@ / + # etc.) never corrupt the URL. +# The safe pattern: build the full pyodbc DSN as a plain string, +# then hand it to URL.create() via the "odbc_connect" query key. +# URL.create() percent-encodes it internally — no manual urllib needed. +_ODBC_DSN = ( + f"DRIVER={{ODBC Driver 18 for SQL Server}};" + f"SERVER={MSSQL_SERVER};" + f"DATABASE={MSSQL_DB};" + f"UID={MSSQL_USER};" + f"PWD={MSSQL_PASS};" + "TrustServerCertificate=yes;" + "timeout=30;" +) +# Final SQLAlchemy URL — credentials live inside odbc_connect, not in the URL +# This is the officially recommended pattern in SQLAlchemy docs for mssql+pyodbc +MSSQL_SA_URL = URL.create( + drivername="mssql+pyodbc", + query={"odbc_connect": _ODBC_DSN}, # URL.create handles encoding internally +) + +CH_HOST = os.getenv("CH_HOST", "localhost") +CH_PORT = int(os.getenv("CH_PORT", "8123")) +CH_USER = os.getenv("CH_USER", "default") +CH_PASS = os.getenv("CH_PASS", "") +CH_DB = os.getenv("CH_DB", "kelloggs") +CH_TABLE = "coverage_kpi" + +BATCH_SIZE = 5000 # rows per ClickHouse insert +MID_CHUNK = 1000 # SQL Server IN-clause chunk size +PROJECT_ID = 40148 + + +# ── Step 1: Build SQLAlchemy Engine ─────────────────────────────────────────── +def build_engine() -> Engine: + """ + The Engine is not a connection — it's the factory that makes connections. + Think of it as the water main under the street, not the tap in your kitchen. + + How pyodbc + SQLAlchemy work together here: + - SQLAlchemy's mssql+pyodbc dialect is the translator + (it knows how to speak SQL Server's dialect) + - pyodbc is the actual wire — it talks ODBC to the SQL Server driver + - URL.create(drivername="mssql+pyodbc", query={"odbc_connect": DSN}) + is the safe way to pass the pyodbc DSN through SQLAlchemy without + URL-encoding bugs when passwords have special characters + + fast_executemany=True: + pyodbc has a mode where it sends many rows to SQL Server in one + network round-trip instead of one-row-at-a-time. We're only reading + here, but it's good practice to enable it on the engine level. + + pool_size=3, max_overflow=2: + Keep 3 live connections in the pool, allow 2 extras under burst load. + After each `with engine.connect()` block exits, the connection goes + back to the pool — not closed — so the next query reuses it instantly. + """ + log.info("Building SQLAlchemy + pyodbc engine for SQL Server...") + engine = create_engine( + MSSQL_SA_URL, + pool_size=3, + max_overflow=2, + pool_pre_ping=True, # verify connection is alive before use + echo=False, # True = print every SQL to console (debug) + connect_args={ + "fast_executemany": True, # pyodbc bulk-send optimisation + }, + ) + # Smoke test — borrow one connection, run SELECT 1, return it to pool + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + log.info("SQL Server engine ready.") + return engine + + +# ── Step 2: Collect MIDs ────────────────────────────────────────────────────── +def collect_mids(engine: Engine, target_date: date) -> list[int]: + """ + SQLAlchemy parameterised query using :param syntax. + This is SQL-injection safe — SQLAlchemy never concatenates + the date value into the string directly. + + We use engine.connect() as a context manager — + the connection is returned to the pool automatically when done. + """ + sql = text(""" + SELECT MID FROM OneApp_KelloggsMT.dbo.T_StoreCoverage + WHERE CONVERT(date, CreateDate) = :target_date + UNION + SELECT MID FROM OneApp_KelloggsMT.dbo.T_StoreCoverage + WHERE CONVERT(date, UpdateDate) = :target_date + """) + log.info(f"Collecting MIDs for: {target_date}") + with engine.connect() as conn: + result = conn.execute(sql, {"target_date": target_date}) + mids = [row[0] for row in result.fetchall()] + log.info(f"Found {len(mids):,} MIDs") + return mids + + +# ── Step 3: Fetch Coverage data via pl.read_database() ─────────────────────── +def fetch_coverage_data(engine: Engine, mids: list[int]) -> pl.DataFrame: + """ + This is the key upgrade from pyodbc to SQLAlchemy+Polars. + + pl.read_database(query, connection) accepts a SQLAlchemy Engine directly. + Polars handles the cursor, column name detection, and type inference + automatically — no more manual cursor.fetchall() loops. + + We still chunk the MID list (SQL Server IN clause limit ~2100 params), + but each chunk is now one clean pl.read_database() call. + + Feynman analogy: instead of manually scooping water bucket-by-bucket + (pyodbc cursor loop), we turn on a tap and let it fill the container + (pl.read_database manages the stream). + """ + if not mids: + log.warning("No MIDs — nothing to fetch.") + return pl.DataFrame() + + # SQL mirrors the Coverage INSERT from the stored procedure + # :mid_list is replaced per chunk below + coverage_sql = """ + SELECT + {project_id} AS project_id, + JP.MID, + sm.StoreId AS store_id, + JP.EmpId AS employee_id, + CONVERT(date, JP.VisitDate) AS visit_date, + JP.InTime AS in_time, + JP.OutTime AS out_time, + CASE + WHEN JP.OutTime IS NULL OR JP.InTime IS NULL THEN NULL + WHEN JP.OutTime < JP.InTime THEN 0 + ELSE DATEDIFF(SECOND, JP.InTime, JP.OutTime) / 60 + END AS duration_minutes, + CASE + WHEN ( + SELECT TOP 1 EmpId + FROM OneApp_KelloggsMT.dbo.T_StoreCoverage SC + WHERE SC.EmpId = JP.EmpId + AND SC.StoreId = JP.StoreId + AND SC.VisitDate = JP.VisitDate + AND SC.ReasonId IN (0,1,3,9,10,19,20) + ) > 0 + THEN 'Y' ELSE 'N' + END AS is_covered, + CASE JP.Deviation + WHEN 0 THEN 'Planned' + WHEN 1 THEN 'Adhoc' + WHEN 2 THEN 'Beat Plan' + WHEN 3 THEN 'Non Merchandised' + WHEN 4 THEN 'Add New Store' + WHEN 5 THEN 'Non Program' + ELSE '' + END AS coverage_type, + sm.StoreTypeId AS storetype_id, + Em.SupervisorId AS supervisor_id, + ISNULL(JP.ReasonId, 0) AS reason_id, + sm.CameraAllow AS camera_allow, + CAST( + CASE + WHEN sm.Latitude IS NULL OR sm.Latitude = 0 THEN 0 + WHEN sm.Longitude IS NULL OR sm.Longitude = 0 THEN 0 + WHEN JP.Latitude IS NULL OR JP.Latitude = 0 THEN 0 + WHEN JP.Longitude IS NULL OR JP.Longitude = 0 THEN 0 + ELSE SQRT( + POWER(69.1 * (JP.Latitude - sm.Latitude), 2) + + POWER(69.1 * (sm.Longitude - JP.Longitude) + * COS(JP.Latitude / 57.3), 2) + ) * 1000 + END AS FLOAT + ) AS distance_meters, + GETDATE() AS update_date, + 'ETL-SQLAlchemy' AS update_by + FROM OneApp_KelloggsMT.dbo.T_StoreCoverage JP WITH (NOLOCK) + INNER JOIN OneApp_KelloggsMT.dbo.vw_StoreDetail sm + ON JP.StoreId = sm.StoreId + INNER JOIN OneApp_KelloggsMT.dbo.vw_Employee_Detail Em + ON JP.EmpId = Em.EmpId + WHERE JP.MID IN ({mid_placeholders}) + AND Em.UserName NOT LIKE 'test%' + """ + + frames: list[pl.DataFrame] = [] + + for i in range(0, len(mids), MID_CHUNK): + chunk = mids[i : i + MID_CHUNK] + # Build the IN list as a literal — SQLAlchemy text() for IN-list with + # variable length is cleanest done this way (all values are integers, + # so no injection risk) + mid_list = ",".join(str(m) for m in chunk) + sql = coverage_sql.format( + project_id=PROJECT_ID, + mid_placeholders=mid_list, + ) + + log.info( + f"Fetching chunk {i // MID_CHUNK + 1} " + f"(MIDs {i+1}–{min(i+MID_CHUNK, len(mids))} of {len(mids)})..." + ) + + # pl.read_database() takes the raw SQL string + the SQLAlchemy engine. + # It opens a connection from the pool, streams the result into a + # Polars DataFrame, then releases the connection back automatically. + df_chunk = pl.read_database(query=sql, connection=engine) + if len(df_chunk) > 0: + frames.append(df_chunk) + + if not frames: + log.warning("No rows returned from SQL Server.") + return pl.DataFrame() + + df = pl.concat(frames) + log.info(f"Fetched {len(df):,} total rows from SQL Server") + return df + + +# ── Step 4: Transform with Polars ───────────────────────────────────────────── +def transform(df: pl.DataFrame) -> pl.DataFrame: + """ + SQLAlchemy + Polars gives us richer type inference than raw pyodbc — + but we still normalise everything explicitly so ClickHouse never + gets a surprise type. + + The chain of .with_columns() calls is like an assembly line: + each station does one job and passes the belt to the next. + """ + log.info("Transforming...") + + df = ( + df + # ── IDs ────────────────────────────────────────────────────────────── + .with_columns([ + pl.col("project_id").cast(pl.Int32), + pl.col("MID").cast(pl.Int64), + pl.col("store_id").cast(pl.Int64), + pl.col("employee_id").cast(pl.Int64), + pl.col("supervisor_id").cast(pl.Int64).fill_null(0), + pl.col("storetype_id").cast(pl.Int32).fill_null(0), + pl.col("reason_id").cast(pl.Int32).fill_null(0), + ]) + + # ── Dates ───────────────────────────────────────────────────────────── + # SQLAlchemy may return visit_date as datetime — cast to Date + .with_columns( + pl.col("visit_date").cast(pl.Date) + ) + + # ── Numerics ────────────────────────────────────────────────────────── + # DATEDIFF/CAST in SQL Server comes through as Decimal via ODBC + # → cast to Float64 first, then to Int32 (avoids Decimal overflow) + .with_columns( + pl.col("duration_minutes") + .cast(pl.Float64) + .fill_null(0.0) + .cast(pl.Int32) + ) + .with_columns( + pl.col("distance_meters") + .cast(pl.Float64) + .fill_null(0.0) + .round(2) + ) + + # ── Flags ───────────────────────────────────────────────────────────── + # is_covered: 'Y'/'N' → UInt8 (1/0) — ClickHouse has no Bool type + .with_columns( + pl.when(pl.col("is_covered") == "Y") + .then(pl.lit(1, dtype=pl.UInt8)) + .otherwise(pl.lit(0, dtype=pl.UInt8)) + .alias("is_covered") + ) + # camera_allow: SQL Server BIT → UInt8 + .with_columns( + pl.col("camera_allow").cast(pl.UInt8).fill_null(0) + ) + + # ── Strings ─────────────────────────────────────────────────────────── + # ClickHouse String column rejects NULL — replace with empty string + .with_columns([ + pl.col("coverage_type").fill_null(""), + pl.col("update_by").fill_null("ETL-SQLAlchemy"), + ]) + + # ── Timestamps ──────────────────────────────────────────────────────── + .with_columns( + pl.col("update_date").cast(pl.Datetime) + ) + + # ── Dedup ───────────────────────────────────────────────────────────── + # MID is unique per project — drop any duplicates from chunked joins + .unique(subset=["project_id", "MID"]) + ) + + log.info(f"After transform: {len(df):,} rows | schema: {df.schema}") + return df + + +# ── Step 5: Load to ClickHouse ──────────────────────────────────────────────── +def load_to_clickhouse(df: pl.DataFrame) -> tuple[int, date]: + """ + clickhouse-connect v0.7+ supports insert_arrow() which accepts + a Polars DataFrame via its Arrow IPC backing — no pandas bridge needed. + + Delete-then-insert mirrors the stored procedure's pattern: + wipe the day's data, reload fresh. Idempotent = safe to re-run. + """ + target_date: date = df["visit_date"].min() # always yesterday + + log.info(f"Connecting to ClickHouse {CH_HOST}:{CH_PORT} db={CH_DB}...") + client = clickhouse_connect.get_client( + host=CH_HOST, port=CH_PORT, + username=CH_USER, password=CH_PASS, + database=CH_DB, + ) + + # Delete stale rows for this date + project + log.info(f"Deleting existing rows: project_id={PROJECT_ID} visit_date={target_date}") + client.command( + f"ALTER TABLE {CH_TABLE} DELETE " + f"WHERE project_id = {PROJECT_ID} " + f"AND visit_date = '{target_date}'" + ) + + # Insert in batches using Arrow — Polars → Arrow is zero-copy + total = 0 + for start in range(0, len(df), BATCH_SIZE): + batch = df.slice(start, BATCH_SIZE) + # to_arrow() converts the Polars DataFrame to Apache Arrow Table + # insert_arrow() sends it directly — no pandas, no row-by-row overhead + client.insert_arrow(CH_TABLE, batch.to_arrow()) + total += len(batch) + log.info(f" Inserted {total:,} / {len(df):,} rows") + + log.info(f"ClickHouse load complete: {total:,} rows") + return total, target_date + + +# ── Step 6: Verify ──────────────────────────────────────────────────────────── +def verify(expected: int, target_date: date) -> bool: + """ + Count rows in ClickHouse for this date and compare. + If they don't match — the ETL failed silently somewhere. + Exit code 2 so your scheduler/cron knows it's a data problem, not a crash. + """ + client = clickhouse_connect.get_client( + host=CH_HOST, port=CH_PORT, + username=CH_USER, password=CH_PASS, + database=CH_DB, + ) + result = client.query( + f"SELECT count() FROM {CH_TABLE} " + f"WHERE project_id = {PROJECT_ID} " + f"AND visit_date = '{target_date}'" + ) + actual = result.result_rows[0][0] + log.info(f"Verify — expected={expected:,} clickhouse={actual:,}") + if actual != expected: + log.error(f"MISMATCH: sent {expected:,}, ClickHouse has {actual:,}") + return False + log.info("Verify passed.") + return True + + +# ── Main ────────────────────────────────────────────────────────────────────── +def main() -> None: + target_date = date.today() - timedelta(days=1) + log.info(f"=== Coverage KPI ETL | date={target_date} ===") + + # Build the SQLAlchemy engine once — shared across all steps + engine = build_engine() + + # Collect which MIDs need processing + mids = collect_mids(engine, target_date) + if not mids: + log.info("No MIDs for yesterday. Nothing to do.") + sys.exit(0) + + # Fetch raw data using pl.read_database() + df_raw = fetch_coverage_data(engine, mids) + engine.dispose() # return all pooled connections to OS cleanly + + if df_raw.is_empty(): + log.warning("Empty result set. Exiting.") + sys.exit(0) + + # Transform + df_clean = transform(df_raw) + + # Load + rows_inserted, inserted_date = load_to_clickhouse(df_clean) + + # Verify + if not verify(rows_inserted, inserted_date): + sys.exit(2) + + log.info(f"=== Done. {rows_inserted:,} rows loaded for {inserted_date} ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ed5c681 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "kpi" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "dotenv>=0.9.9", + "polars>=1.41.2", + "pyarrow>=24.0.0", + "pymssql>=2.3.13", + "pyodbc>=5.3.0", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3b810bf --- /dev/null +++ b/uv.lock @@ -0,0 +1,135 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, +] + +[[package]] +name = "kpi" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "dotenv" }, + { name = "polars" }, + { name = "pyarrow" }, + { name = "pymssql" }, + { name = "pyodbc" }, +] + +[package.metadata] +requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "polars", specifier = ">=1.41.2" }, + { name = "pyarrow", specifier = ">=24.0.0" }, + { name = "pymssql", specifier = ">=2.3.13" }, + { name = "pyodbc", specifier = ">=5.3.0" }, +] + +[[package]] +name = "polars" +version = "1.41.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/f9/aeda46259b0669247a160315d2d51269de9504b9dd2f70acadbcb22f46b7/polars-1.41.2.tar.gz", hash = "sha256:256d6731162371b77f3f29a55eacb8c0fc740ddb1a293a01d2ef5b5393c5c708", size = 737996, upload-time = "2026-05-29T17:39:15.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/22/28f62d24f7db56ac4343588f9362d49b7b4177e55ac47a466fe696b0099b/polars-1.41.2-py3-none-any.whl", hash = "sha256:23ce9a2910b6e3e8d4258770bf44aa17170958df7af6e85feedf4458a04d8d29", size = 833445, upload-time = "2026-05-29T17:37:05.576Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.41.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/56/54e3ea0e9b64f327179049e4742241cc6b1d3e8fa414b05a057dd26df367/polars_runtime_32-1.41.2.tar.gz", hash = "sha256:7af09ec1ab053da2c9669e8d15f809a4083a29be05db57111688b8051062af56", size = 2989474, upload-time = "2026-05-29T17:39:17.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/9b/fe72a3811c0357cdb06c67bdc7695fa1623ad47948fc523195f5ac31037f/polars_runtime_32-1.41.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:95a08346dac337357cdb825c8076df7d36da54c4caa59a5cb41d0a30691c5edd", size = 52265283, upload-time = "2026-05-29T17:37:09.407Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/fab9da803fd80d9e83ef88c20932f637a10bc611b20415fc322eec84bc44/polars_runtime_32-1.41.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:dedfaeec2c7f995298da7319dd9431d662e5dd1d0ec51b1459df4a0234ceff52", size = 46571222, upload-time = "2026-05-29T17:37:13.698Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/8843f34a8ac57acd058a39b87b03b580dd352a490e9dae0415e02033bdd4/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18eea22c5cc34e27f8a60950458ad81e6a9ea75e89363ca1367e14e7e7f781fc", size = 50409372, upload-time = "2026-05-29T17:37:17.875Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c6/92b352fe88cf51bd0a19fb99e1c0cbe46aa26c14dcf7995b89869cd932ae/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2630540dfdfb0f36f9b04a07c7c2e3f50bf2ad384113263c1c812007ee9141e0", size = 56405484, upload-time = "2026-05-29T17:37:22.684Z" }, + { url = "https://files.pythonhosted.org/packages/74/c4/bae3174c3b02f6b441d2e58594387abcd509f67a098f682a83b195f08966/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:20e969e08f9b137e233c04cc04de73d9795f89eb77d34854e40a025965a43763", size = 50603512, upload-time = "2026-05-29T17:37:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ed/f2d26ae02d92c2689056838ed59e2a626326ad23c2831d58637d25f6c82a/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e7016a3deb641b64a31447abbbee0f34bd020a6a9ae34ee6b743837def15e2a4", size = 54328561, upload-time = "2026-05-29T17:37:32.587Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c4/9c3831cc885dc7769e59abf8f583821a5fb4403fd0e4eba0ccc6d47a3d4b/polars_runtime_32-1.41.2-cp310-abi3-win_amd64.whl", hash = "sha256:1e5e5377c315e0dcafdfb2a31adc546abbaeb3f9cb1864e6536523d2af473265", size = 51978643, upload-time = "2026-05-29T17:37:37.443Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c6/79e9f3f270270d7ed5575d92b7bfef49f01abd9275447161275b23b553a8/polars_runtime_32-1.41.2-cp310-abi3-win_arm64.whl", hash = "sha256:843d96f69d18eca53429c1198e58891db7f18111f83b9c419bb45ad9d73eaed5", size = 46006901, upload-time = "2026-05-29T17:37:42.522Z" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, +] + +[[package]] +name = "pymssql" +version = "2.3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/cc/843c044b7f71ee329436b7327c578383e2f2499313899f88ad267cdf1f33/pymssql-2.3.13.tar.gz", hash = "sha256:2137e904b1a65546be4ccb96730a391fcd5a85aab8a0632721feb5d7e39cfbce", size = 203153, upload-time = "2026-02-14T05:00:36.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/5f/6b64f78181d680f655ab40ba7b34cb68c045a2f4e04a10a70d768cd383b7/pymssql-2.3.13-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fc5482969c813b0a45ce51c41844ae5bfa8044ad5ef8b4820ef6de7d4545b7f2", size = 3158377, upload-time = "2026-02-14T05:00:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/ff/24/155dbb0992c431496d440f47fb9d587cd0059ee20baf65e3d891794d862a/pymssql-2.3.13-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:ff5be7ab1d643dbce2ee3424d2ef9ae8e4146cf75bd20946bc7a6108e3ad1e47", size = 2959039, upload-time = "2026-02-14T05:00:15.883Z" }, + { url = "https://files.pythonhosted.org/packages/c9/89/b453dd1b1188779621fb974ac715ab2e738f4a0b69f7291ab014298bd80d/pymssql-2.3.13-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d66ce0a249d2e3b57369048d71e1f00d08dfb90a758d134da0250ae7bc739c1", size = 3063862, upload-time = "2026-02-14T05:00:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/02/e5/96f57c78162013678ecc3f3f7e5fb52c83ee07beef26906d0870770c3ef6/pymssql-2.3.13-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d663c908414a6a032f04d17628138b1782af916afc0df9fefac4751fa394c3ac", size = 3188155, upload-time = "2026-02-14T05:00:19.011Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a2/4bee9484734ae0c55d10a2f6ff82dd4e416f52420755161b8760c817ad64/pymssql-2.3.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aa5e07eff7e6e8bd4ba22c30e4cb8dd073e138cd272090603609a15cc5dbc75b", size = 3709344, upload-time = "2026-02-14T05:00:21.139Z" }, + { url = "https://files.pythonhosted.org/packages/37/cf/3520d96afa213c88db4f4a1988199db476d869a62afdd5d9c4635c184631/pymssql-2.3.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db77da1a3fc9b5b5c5400639d79d7658ba7ad620957100c5b025be608b562193", size = 3451799, upload-time = "2026-02-14T05:00:22.504Z" }, + { url = "https://files.pythonhosted.org/packages/25/50/4be9bd9cf4b43208a7175117a533ece200cfe4131a39f9909bdc7560ddeb/pymssql-2.3.13-cp314-cp314-win_amd64.whl", hash = "sha256:7d7037d2b5b907acc7906d0479924db2935a70c720450c41339146a4ada2b93d", size = 2049139, upload-time = "2026-02-14T05:00:23.951Z" }, +] + +[[package]] +name = "pyodbc" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/85/44b10070a769a56bd910009bb185c0c0a82daff8d567cd1a116d7d730c7d/pyodbc-5.3.0.tar.gz", hash = "sha256:2fe0e063d8fb66efd0ac6dc39236c4de1a45f17c33eaded0d553d21c199f4d05", size = 121770, upload-time = "2025-10-17T18:04:09.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/f2/c26d82a7ce1e90b8bbb8731d3d53de73814e2f6606b9db9d978303aa8d5f/pyodbc-5.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3f1bdb3ce6480a17afaaef4b5242b356d4997a872f39e96f015cabef00613797", size = 73513, upload-time = "2025-10-17T18:03:40.536Z" }, + { url = "https://files.pythonhosted.org/packages/82/d5/1ab1b7c4708cbd701990a8f7183c5bb5e0712d5e8479b919934e46dadab4/pyodbc-5.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7713c740a10f33df3cb08f49a023b7e1e25de0c7c99650876bbe717bc95ee780", size = 72631, upload-time = "2025-10-17T18:03:41.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/7e3831eeac2b09b31a77e6b3495491ce162035ff2903d7261b49d35aa3c2/pyodbc-5.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf18797a12e70474e1b7f5027deeeccea816372497e3ff2d46b15bec2d18a0cc", size = 344580, upload-time = "2025-10-17T18:03:42.67Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a6/71d26d626a3c45951620b7ff356ec920e420f0e09b0a924123682aa5e4ab/pyodbc-5.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:08b2439500e212625471d32f8fde418075a5ddec556e095e5a4ba56d61df2dc6", size = 350224, upload-time = "2025-10-17T18:03:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/93/14/f702c5e8c2d595776266934498505f11b7f1545baf21ffec1d32c258e9d3/pyodbc-5.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:729c535341bb09c476f219d6f7ab194bcb683c4a0a368010f1cb821a35136f05", size = 1301503, upload-time = "2025-10-17T18:03:45.013Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b2/ad92ebdd1b5c7fec36b065e586d1d34b57881e17ba5beec5c705f1031058/pyodbc-5.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c67e7f2ce649155ea89beb54d3b42d83770488f025cf3b6f39ca82e9c598a02e", size = 1361050, upload-time = "2025-10-17T18:03:46.298Z" }, + { url = "https://files.pythonhosted.org/packages/19/40/dc84e232da07056cb5aaaf5f759ba4c874bc12f37569f7f1670fc71e7ae1/pyodbc-5.3.0-cp314-cp314-win32.whl", hash = "sha256:a48d731432abaee5256ed6a19a3e1528b8881f9cb25cb9cf72d8318146ea991b", size = 65670, upload-time = "2025-10-17T18:03:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/b8/79/c48be07e8634f764662d7a279ac204f93d64172162dbf90f215e2398b0bd/pyodbc-5.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:58635a1cc859d5af3f878c85910e5d7228fe5c406d4571bffcdd281375a54b39", size = 72177, upload-time = "2025-10-17T18:03:57.296Z" }, + { url = "https://files.pythonhosted.org/packages/fc/79/e304574446b2263f428ce14df590ba52c2e0e0205e8d34b235b582b7d57e/pyodbc-5.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:754d052030d00c3ac38da09ceb9f3e240e8dd1c11da8906f482d5419c65b9ef5", size = 66668, upload-time = "2025-10-17T18:03:58.174Z" }, + { url = "https://files.pythonhosted.org/packages/43/17/f4eabf443b838a2728773554017d08eee3aca353102934a7e3ba96fb0e31/pyodbc-5.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f927b440c38ade1668f0da64047ffd20ec34e32d817f9a60d07553301324b364", size = 75780, upload-time = "2025-10-17T18:03:47.273Z" }, + { url = "https://files.pythonhosted.org/packages/59/ea/e79e168c3d38c27d59d5d96273fd9e3c3ba55937cc944c4e60618f51de90/pyodbc-5.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:25c4cfb2c08e77bc6e82f666d7acd52f0e52a0401b1876e60f03c73c3b8aedc0", size = 75503, upload-time = "2025-10-17T18:03:48.171Z" }, + { url = "https://files.pythonhosted.org/packages/90/81/d1d7c125ec4a20e83fdc28e119b8321192b2bd694f432cf63e1199b2b929/pyodbc-5.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc834567c2990584b9726cba365834d039380c9dbbcef3030ddeb00c6541b943", size = 398356, upload-time = "2025-10-17T18:03:49.131Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fc/f6be4b3cc3910f8c2aba37aa41671121fd6f37b402ae0fefe53a70ac7cd5/pyodbc-5.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8339d3094858893c1a68ee1af93efc4dff18b8b65de54d99104b99af6306320d", size = 397291, upload-time = "2025-10-17T18:03:50.18Z" }, + { url = "https://files.pythonhosted.org/packages/03/2e/0610b1ed05a5625528d52f6cece9610e84617d35f475c89c2a52f66d13f7/pyodbc-5.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74528fe148980d0c735c0ebb4a4dc74643ac4574337c43c1006ac4d09593f92d", size = 1353900, upload-time = "2025-10-17T18:03:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f1/43497e1d37f9f71b43b2b3172e7b1bdf50851e278390c3fb6b46a3630c53/pyodbc-5.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d89a7f2e24227150c13be8164774b7e1f9678321a4248f1356a465b9cc17d31e", size = 1406062, upload-time = "2025-10-17T18:03:52.546Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/88a1277c2f7d9ab1cec0a71e074ba24fd4a1710a43974682546da90a1343/pyodbc-5.3.0-cp314-cp314t-win32.whl", hash = "sha256:af4d8c9842fc4a6360c31c35508d6594d5a3b39922f61b282c2b4c9d9da99514", size = 70132, upload-time = "2025-10-17T18:03:53.715Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c7/ee98c62050de4aa8bafb6eb1e11b95e0b0c898bd5930137c6dc776e06a9b/pyodbc-5.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bfeb3e34795d53b7d37e66dd54891d4f9c13a3889a8f5fe9640e56a82d770955", size = 79452, upload-time = "2025-10-17T18:03:54.664Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8f/d8889efd96bbe8e5d43ff9701f6b1565a8e09c3e1f58c388d550724f777b/pyodbc-5.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:13656184faa3f2d5c6f19b701b8f247342ed581484f58bf39af7315c054e69db", size = 70142, upload-time = "2025-10-17T18:03:55.551Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +]