first commit
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user