diff --git a/docs/capabilities/INDEX.md b/docs/capabilities/INDEX.md index ae0bfb8f..15d43b33 100644 --- a/docs/capabilities/INDEX.md +++ b/docs/capabilities/INDEX.md @@ -57,6 +57,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu | [duckdb](duckdb.md) | 10 | Operar bases DuckDB: open (Go), query/execute/upsert, introspeccion (list_tables, table_schema), CSV->Parquet, dedup, OHLCV, e ingesta desde Excel (excel_to_duckdb) + salida a Postgres (duckdb_to_postgres). Motor analitico del stack de datos Excel->DuckDB->Postgres->viz | | [excel](excel.md) | 6 | CRUD de hojas Excel (.xlsx) con openpyxl: escribir multi-hoja, upsert no destructivo (preserva columnas manuales), leer a memoria, leer a markdown, graficos nativos (bar/line/pie/scatter), e ingesta a DuckDB. Round-trip de datos con humanos | | [postgres](postgres.md) | 7 | CRUD de PostgreSQL via psycopg2 (dsn): connect (Go), query read-only, insert append-only, upsert idempotente, crear tabla inferida, introspeccion, aplicar .sql. Capa que sirve datos a Metabase/Grafana (que no hablan DuckDB nativo) | +| [sql-connect](sql-connect.md) | 3 | Conexion directa y consulta a Microsoft SQL Server (Navision) via pymssql: abrir conexion (login_timeout), SELECT parametrizada con binding seguro -> {columns, rows, row_count}, y pipeline one-shot run_mssql_query (CLI JSON/CSV). Elimina el copia-pega manual de CSV de Navision. Credenciales desde pass, host = IP LAN de Windows desde WSL2 | | [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados | | [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks | | [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments | diff --git a/docs/capabilities/sql-connect.md b/docs/capabilities/sql-connect.md new file mode 100644 index 00000000..e73a6249 --- /dev/null +++ b/docs/capabilities/sql-connect.md @@ -0,0 +1,70 @@ +# Capability: sql-connect + +Conexión directa y consulta a un **Microsoft SQL Server** desde el registry, con el caso prioritario de **Navision** (el ERP corre sobre SQL Server). Las funciones Python usan el driver **pymssql** (más simple en Linux/WSL que pyodbc: trae FreeTDS embebido, no necesita ODBC driver manager). + +Existe para **eliminar el ida y vuelta manual** con Navision: en vez de escribir una query, que el usuario la ejecute en su SGBD y pegue el CSV, estas funciones se conectan al servidor y devuelven las filas — iteración rápida sobre una query en un solo comando. + +## Funciones + +| ID | Firma | Que hace | +|---|---|---| +| `mssql_connect_py_infra` | `mssql_connect(host, database, user, password, port=1433, login_timeout=15, query_timeout=30) -> pymssql.Connection` | Abre una conexión a SQL Server vía pymssql. Credenciales por argumento (nunca hardcodeadas). `login_timeout` acota la fase de login para que un host inalcanzable no cuelgue. Devuelve la conexión abierta; el caller la cierra con `.close()`. Lanza `RuntimeError` claro (host:port/db) si falla. | +| `mssql_query_py_infra` | `mssql_query(conn, sql, params=None, max_rows=None) -> dict` | Ejecuta una SELECT parametrizada sobre una conexión abierta y mapea las filas a dicts. Binding seguro del driver (placeholders `%s`/`%(nombre)s`, sin inyección). Devuelve `{columns, rows:[{col:val}], row_count}`. 0 filas → lista vacía sin error. `max_rows` limita con `fetchmany`. Read-only (no commit), no cierra la conexión. | +| `run_mssql_query_py_pipelines` | `run_mssql_query(host, database, user, password, sql, params=None, port=1433, max_rows=None, login_timeout=15, query_timeout=30) -> dict` | **Pipeline one-shot**: compone `mssql_connect` + `mssql_query` y cierra siempre la conexión (try/finally). CLI imprime JSON o CSV. Para iterar sobre una query de Navision en un solo `fn run`. | + +## Ejemplo canónico + +One-shot para iterar sobre Navision (la contraseña se lee de una env var, nunca se pasa por la línea de comandos): + +```bash +cd /home/egutierrez/fn_registry +MSSQL_PASSWORD=$(pass navision/password) \ + ./fn run run_mssql_query \ + --host 10.0.0.5 --database navdb --user sa \ + --sql "SELECT TOP 5 [No_], [Amount] FROM [dbo].[Cartera] WHERE [Customer No_] = %s" \ + --param CLI-0001 \ + --format csv +``` + +Conexión persistente para muchas queries seguidas (abrir una vez, consultar N veces): + +```python +import os, sys +sys.path.insert(0, "python/functions") +from infra.mssql_connect import mssql_connect +from infra.mssql_query import mssql_query + +conn = mssql_connect("10.0.0.5", "navdb", "sa", os.environ["MSSQL_PASSWORD"]) +try: + abiertos = mssql_query( + conn, + "SELECT [No_], [Amount] FROM [dbo].[Cartera] WHERE [Open] = 1 AND [Customer No_] = %s", + params=("CLI-0001",), + ) + print(abiertos["row_count"], abiertos["columns"]) + posted = mssql_query(conn, "SELECT TOP 10 [Document No_], [Amount] FROM [dbo].[Posted Cartera]") + print(posted["rows"]) +finally: + conn.close() +``` + +## Gotchas del grupo + +- **Conectividad WSL2 → Windows**: el `host` debe ser la **IP LAN del Windows** que corre SQL Server, NO `localhost` (desde WSL2 localhost no alcanza al host Windows). Ver memoria `wsl2-localhost-forwarding`. Probablemente el servidor real de Navision no sea alcanzable desde un entorno aislado sin red a la oficina + credenciales. +- **Credenciales desde `pass`, nunca hardcodeadas.** Patrón: `MSSQL_PASSWORD=$(pass navision/password) ./fn run run_mssql_query ...`. La función recibe la contraseña como argumento; el caller la resuelve. `--password` literal existe pero queda visible en la lista de procesos — usa `--password-env`. +- **Placeholders pymssql** son `%s` (posicional) y `%(nombre)s` (nombrado), NO `?` (eso es pyodbc). Pasa los valores como `params`, jamás concatenados en el SQL (inyección). +- **`mssql_query` no abre ni cierra la conexión** — la toma prestada. Para ráfagas de queries, abre con `mssql_connect` una vez y reúsala; el pipeline `run_mssql_query` abre y cierra por llamada (cómodo, no eficiente en ráfaga). +- **Read-only por uso**: pensado para SELECT (Navision: cartera, posted cartera, movimientos). No hace commit. +- **Requiere `pymssql`** instalado en el venv (`uv add pymssql`). Import perezoso: el módulo carga sin la dependencia, pero la llamada falla con `RuntimeError` claro si falta. +- **Datos sintéticos en ejemplos** [POL-MMNSEG-001-1.0]: los `No_`/`Customer No_` de los ejemplos son ficticios. Sobre datos reales de Navision aplica la política de protección de datos. + +## Fronteras + +- **Solo SQL Server (Navision)**. No es una capa SQL genérica: para PostgreSQL usa el grupo `postgres`; para DuckDB el grupo `duckdb`. Generalizar a MySQL/otros engines sería especulativo (KISS) hasta que haya un caso real. +- **No es ETL ni BI**: solo conecta y devuelve filas. Para llevar datos de Navision a un destino analítico, compón con los grupos `duckdb`/`postgres` (cargar las filas) o léelas en un notebook. +- **No gestiona el servidor** (no crea bases, no administra logins). Solo cliente de lectura. + +## Relación con otros grupos + +- `postgres` / `duckdb` — capas CRUD para otros engines; mismo espíritu (conectar + consultar), distinto motor. SQL Server (Navision) es la fuente; esos son destinos analíticos/BI. +- `metabase` / `bigquery` — el trabajo Aurgi consume datos ya en BigQuery/Metabase; este grupo abre la puerta a leer Navision en origen para iterar queries antes de modelarlas. diff --git a/python/functions/infra/mssql_connect.md b/python/functions/infra/mssql_connect.md new file mode 100644 index 00000000..2f34bb23 --- /dev/null +++ b/python/functions/infra/mssql_connect.md @@ -0,0 +1,81 @@ +--- +name: mssql_connect +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def mssql_connect(host: str, database: str, user: str, password: str, port: int = 1433, login_timeout: int = 15, query_timeout: int = 30) -> pymssql.Connection" +description: "Abre una conexion pymssql a un Microsoft SQL Server (donde corre Navision). Las credenciales llegan siempre por argumento (el caller las saca de pass/env), nunca hardcodeadas. login_timeout acota la fase de conexion/login para evitar cuelgues con un host inalcanzable. Devuelve el objeto conexion pymssql para iterar queries despues." +tags: [mssql, sqlserver, navision, sql-connect, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [pymssql] +params: + - name: host + desc: "Host o IP del servidor SQL Server. Desde WSL2 debe ser la IP LAN de Windows (ej. 10.0.0.5), no localhost." + - name: database + desc: "Nombre de la base de datos a la que conectar (ej. navdb)." + - name: user + desc: "Usuario de login de SQL Server (ej. sa)." + - name: password + desc: "Contrasena del usuario de login. Se pasa desde pass/env, nunca como literal." + - name: port + desc: "Puerto TCP del SQL Server. Por defecto 1433. La funcion lo convierte a string porque pymssql lo exige asi." + - name: login_timeout + desc: "Segundos permitidos para la fase de conexion/login antes de fallar. Por defecto 15. Evita que un host inalcanzable cuelgue indefinidamente." + - name: query_timeout + desc: "Segundos permitidos para cada query ejecutada sobre la conexion devuelta antes de hacer timeout. Por defecto 30." +output: "Un objeto pymssql.Connection abierto. El caller es responsable de cerrarlo con .close() al terminar." +tested: true +tests: ["test_golden_connect_passes_string_port_and_kwargs", "test_error_path_wraps_failure_with_host"] +test_file_path: "python/functions/infra/mssql_connect_test.py" +file_path: "python/functions/infra/mssql_connect.py" +--- + +## Ejemplo + +```python +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions")) +from infra.mssql_connect import mssql_connect + +# La IP debe ser la IP LAN del servidor Windows: desde WSL2 "localhost" NO +# llega al host Windows. La contrasena llega del entorno, nunca literal. +conn = mssql_connect( + host="10.0.0.5", + database="navdb", + user="sa", + password=os.environ["MSSQL_PASSWORD"], + port=1433, + login_timeout=15, +) +try: + with conn.cursor() as cur: + cur.execute("SELECT TOP 1 name FROM sys.databases") + print(cur.fetchone()) +finally: + conn.close() +``` + +## Cuando usarla + +Usala cuando necesites abrir una conexion a un Microsoft SQL Server (donde +corre Navision) antes de iterar queries con `mssql_query`. Es el primer paso +de cualquier pipeline que lea datos de Navision: abre la conexion una vez, +reutilizala para varias queries, y cierrala al final. Triggers: "conecta a +Navision", "lee de SQL Server", "abre conexion mssql". + +## Gotchas + +- WSL2 -> Windows: usa la IP LAN del servidor Windows, NUNCA `localhost`. Desde dentro de WSL2 `localhost` no alcanza el host Windows (el reenvio de localhost solo funciona Windows -> WSL, no al reves). +- pymssql necesita el puerto como string. La funcion ya convierte `port` a `str(port)` internamente, asi que tu pasas un int normal. +- `login_timeout` esta acotado (15s por defecto) precisamente para que un host inalcanzable o mal configurado falle con un RuntimeError claro en vez de colgarse indefinidamente. Ajustalo si la red es lenta, pero no lo dejes sin limite. +- Credenciales NUNCA hardcodeadas: `user`/`password` llegan por argumento desde `pass`/env. No las escribas literales en el codigo del caller. +- Cierra la conexion con `.close()` al terminar (idealmente en un `finally`). La funcion devuelve un handle abierto y no gestiona su ciclo de vida. +- Requiere `pymssql` instalado en el venv (import perezoso: el modulo importa sin la dependencia, pero la llamada falla con RuntimeError claro si falta). diff --git a/python/functions/infra/mssql_connect.py b/python/functions/infra/mssql_connect.py new file mode 100644 index 00000000..e0b22efc --- /dev/null +++ b/python/functions/infra/mssql_connect.py @@ -0,0 +1,65 @@ +"""Open a connection to a Microsoft SQL Server (Navision) via pymssql.""" + +from __future__ import annotations + + +def mssql_connect(host: str, database: str, user: str, password: str, + port: int = 1433, login_timeout: int = 15, + query_timeout: int = 30): + """Open a connection to a Microsoft SQL Server instance (e.g. Navision). + + Uses the pymssql driver. Credentials are always supplied by the caller + (typically read from `pass`/env) and never hardcoded. The connection is + impure I/O: it touches the network and the database server. + + pymssql expects the TCP port as a string, so `port` is converted before + being passed through. `login_timeout` bounds the connect/login phase, which + is what keeps an invalid host from hanging indefinitely; `query_timeout` + bounds individual queries run on the resulting connection. + + Args: + host: SQL Server host or IP. From WSL2 this must be the Windows LAN IP + (e.g. "10.0.0.5"), not "localhost" — localhost does not reach the + Windows host from inside WSL2. + database: Name of the database to connect to (e.g. "navdb"). + user: SQL Server login user (e.g. "sa"). + password: Password for the login user. Pass it from `pass`/env, never + as a string literal. + port: TCP port of the SQL Server instance. Defaults to 1433. Converted + to a string internally because pymssql requires a string port. + login_timeout: Seconds allowed for the connect/login phase before it + fails. Defaults to 15. Keeps an unreachable host from hanging. + query_timeout: Seconds allowed for each query executed on the returned + connection before it times out. Defaults to 30. + + Returns: + An open pymssql.Connection. The caller is responsible for closing it + with `.close()` when done. + + Raises: + RuntimeError: If pymssql is not installed, or if the connection/login + fails. The message includes host:port and database for context and + the original exception is chained for debugging. + """ + # Lazy import so the module loads even without pymssql installed. + try: + import pymssql + except ImportError as exc: # pragma: no cover - exercised only without dep + raise RuntimeError( + "pymssql is required for mssql_connect; install pymssql" + ) from exc + + try: + return pymssql.connect( + server=host, + user=user, + password=password, + database=database, + port=str(port), + login_timeout=login_timeout, + timeout=query_timeout, + ) + except Exception as exc: + raise RuntimeError( + f"mssql_connect failed connecting to {host}:{port}/{database}: {exc}" + ) from exc diff --git a/python/functions/infra/mssql_connect_test.py b/python/functions/infra/mssql_connect_test.py new file mode 100644 index 00000000..b85f8331 --- /dev/null +++ b/python/functions/infra/mssql_connect_test.py @@ -0,0 +1,59 @@ +"""Tests for mssql_connect (mock-based, no real SQL Server).""" + +from __future__ import annotations + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) + +from mssql_connect import mssql_connect + + +def test_golden_connect_passes_string_port_and_kwargs(monkeypatch): + """Golden path: returns the driver connection and forwards the right kwargs. + + The TCP port must reach pymssql as a STRING, and login_timeout must default + to 15 when not supplied. + """ + captured: dict = {} + sentinel = object() + + def fake_connect(**kwargs): + captured.update(kwargs) + return sentinel + + monkeypatch.setattr("pymssql.connect", fake_connect) + + result = mssql_connect("10.0.0.5", "navdb", "sa", "pw", port=1433) + + assert result is sentinel + assert captured["server"] == "10.0.0.5" + assert captured["database"] == "navdb" + assert captured["user"] == "sa" + assert captured["password"] == "pw" + assert captured["port"] == "1433" + assert isinstance(captured["port"], str) + assert captured["login_timeout"] == 15 + assert captured["timeout"] == 30 + + +def test_error_path_wraps_failure_with_host(monkeypatch): + """Error path: a driver failure becomes a clear RuntimeError, not a hang. + + The wrapped message must include the host and the phrase 'failed connecting' + so callers can diagnose connectivity problems. + """ + def fake_connect(**kwargs): + raise Exception("login timeout") + + monkeypatch.setattr("pymssql.connect", fake_connect) + + with pytest.raises(RuntimeError) as excinfo: + mssql_connect("10.0.0.5", "navdb", "sa", "pw", port=1433) + + message = str(excinfo.value) + assert "10.0.0.5" in message + assert "failed connecting" in message diff --git a/python/functions/infra/mssql_query.md b/python/functions/infra/mssql_query.md new file mode 100644 index 00000000..14ff525e --- /dev/null +++ b/python/functions/infra/mssql_query.md @@ -0,0 +1,78 @@ +--- +name: mssql_query +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def mssql_query(conn, sql: str, params=None, max_rows: int | None = None) -> dict" +description: "Ejecuta una SELECT parametrizada (binding seguro de pymssql, sin inyeccion) sobre una conexion SQL Server/Navision ya abierta y devuelve {columns, rows como lista de dicts, row_count}. Opcion max_rows para limitar las filas." +tags: [mssql, sqlserver, navision, sql-connect, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: true +tests: ["test_golden_maps_rows_to_dicts", "test_binding_passes_params_to_driver", "test_zero_rows_no_error", "test_max_rows_uses_fetchmany", "test_description_none_empty_columns", "test_execution_error_raises_runtimeerror"] +test_file_path: "python/functions/infra/mssql_query_test.py" +params: + - name: conn + desc: "Conexion abierta (la que devuelve mssql_connect). No se abre ni cierra aqui; se reutiliza por duck typing via conn.cursor()." + - name: sql + desc: "Sentencia SELECT con placeholders pymssql %s (posicional) o %(nombre)s (nombrado) para los valores a vincular." + - name: params + desc: "Tuple/list para placeholders posicionales, dict para nombrados, o None. Se pasa a cursor.execute(sql, params) para binding seguro del driver (nunca interpolacion)." + - name: max_rows + desc: "Si es int>0, limita a las primeras max_rows filas (fetchmany). Si None, devuelve todas (fetchall)." +output: "Dict con tres claves: 'columns' (lista de nombres de columna en orden, vacia si no hubo result set), 'rows' (lista de dicts columna->valor, una por fila), 'row_count' (int len(rows))." +file_path: "python/functions/infra/mssql_query.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from infra.mssql_connect import mssql_connect +from infra.mssql_query import mssql_query + +conn = mssql_connect( + host="10.0.0.5", database="navdb", user="readonly", password="" +) +try: + res = mssql_query( + conn, + "SELECT TOP 10 No_, Amount FROM [dbo].[Cartera] WHERE [Customer No_] = %s", + ("CLI-0001",), + ) + print(res["columns"]) # ['No_', 'Amount'] + print(res["row_count"]) # numero de filas devueltas + for fila in res["rows"]: + print(fila["No_"], fila["Amount"]) +finally: + conn.close() +``` + +## Cuando usarla + +Cuando ya tienes una conexion abierta con `mssql_connect` y quieres iterar +consultas SELECT sobre Navision / SQL Server sin reabrir la conexion en cada +una. Pasa los valores variables como `params` para que el driver los vincule de +forma segura (sin inyeccion) en lugar de construir el SQL con f-strings. + +## Gotchas + +- Los placeholders de pymssql son `%s` (posicional) y `%(nombre)s` (nombrado), + NO el `?` de pyodbc. Si usas el placeholder equivocado, el binding falla. +- Pasa los valores SIEMPRE por el argumento `params`, jamas con f-string o `%` + dentro del SQL: interpolar abre la puerta a inyeccion SQL. +- No hace commit: es read-only, pensada para SELECT. +- No cierra la conexion — la gestiona el caller (abrir una vez, consultar + muchas, cerrar al final). +- `max_rows` usa `cursor.fetchmany(max_rows)`; con None usa `fetchall()`. +- Si la sentencia no produce result set (`cursor.description is None`), + `columns` y `rows` vuelven como listas vacias en lugar de fallar. +- El mensaje de error es generico a proposito: no incluye el SQL ni los params + para no filtrar datos sensibles. diff --git a/python/functions/infra/mssql_query.py b/python/functions/infra/mssql_query.py new file mode 100644 index 00000000..c7e85443 --- /dev/null +++ b/python/functions/infra/mssql_query.py @@ -0,0 +1,77 @@ +"""Run a parameterized SELECT over an open pymssql (SQL Server / Navision) connection.""" + +from __future__ import annotations + + +def mssql_query(conn, sql: str, params=None, max_rows: int | None = None) -> dict: + """Execute a SELECT on an already-open connection and map rows to dicts. + + The connection is supplied by the caller (typically from `mssql_connect`), + so a single connection can be opened once and reused for many queries. This + function never opens or closes the connection — it only borrows it. It is + impure I/O: it touches the database over an existing connection. + + Parameter binding is delegated to the driver: `params` is passed straight to + `cursor.execute(sql, params)`. NEVER interpolate values into `sql` with + f-strings or `%` formatting — that opens the door to SQL injection. Use the + pymssql placeholders `%s` (positional) or `%(name)s` (named) in `sql` and + let the driver bind safely. When `params is None`, the SQL is executed with + no bound parameters. + + The query runs read-only: no commit is issued. The cursor opened here is + always closed before returning (try/finally), even on error. + + Args: + conn: An open connection object (e.g. the one returned by + `mssql_connect`). Used by duck typing via `conn.cursor()`, so the + concrete driver does not matter and the function stays testable. + sql: The SELECT statement, using pymssql placeholders `%s` (positional) + or `%(name)s` (named) for any bound values. + params: A tuple/list for positional placeholders, a dict for named + placeholders, or None for a query with no parameters. Passed to + `cursor.execute(sql, params)` for safe driver-side binding. + max_rows: If a positive int, only the first `max_rows` rows are fetched + (via `cursor.fetchmany(max_rows)`). If None, all rows are fetched + (via `cursor.fetchall()`). + + Returns: + A dict with three keys: + - "columns": list of column names in result order (empty list if the + statement produced no result set, i.e. `cursor.description is None`). + - "rows": list of dicts, one per row, mapping each column name to its + value. Empty list when the query returned no rows. + - "row_count": int, equal to `len(rows)`. + + Raises: + RuntimeError: If executing or fetching the query fails. The message is + deliberately generic (it does not include the SQL or the params, + which may carry sensitive data) and the original exception is + chained for debugging. + """ + cur = conn.cursor() + try: + try: + if params is None: + cur.execute(sql) + else: + cur.execute(sql, params) + + description = cur.description + if description is None: + columns: list = [] + raw_rows: list = [] + else: + columns = [d[0] for d in description] + if max_rows is not None and max_rows > 0: + raw_rows = cur.fetchmany(max_rows) + else: + raw_rows = cur.fetchall() + except Exception as exc: + raise RuntimeError( + f"mssql_query failed executing query: {exc}" + ) from exc + finally: + cur.close() + + rows = [dict(zip(columns, row)) for row in raw_rows] + return {"columns": columns, "rows": rows, "row_count": len(rows)} diff --git a/python/functions/infra/mssql_query_test.py b/python/functions/infra/mssql_query_test.py new file mode 100644 index 00000000..5dc6be84 --- /dev/null +++ b/python/functions/infra/mssql_query_test.py @@ -0,0 +1,133 @@ +"""Tests para mssql_query usando un doble de prueba (sin servidor real).""" + +from __future__ import annotations + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from functions.infra.mssql_query import mssql_query + + +def _desc(*names): + """Construye una description estilo DB-API: una tupla 7-elem por columna.""" + return [(name, None, None, None, None, None, None) for name in names] + + +class FakeCursor: + """Doble de prueba de un cursor DB-API (pymssql-like).""" + + def __init__(self, description=None, rows=None): + self.description = description + self._rows = list(rows or []) + self.executed = None # (sql, params) de la ultima execute + self.fetchmany_calls = [] # tamaños pedidos a fetchmany + self.closed = False + + def execute(self, sql, params=None): + self.executed = (sql, params) + + def fetchall(self): + return list(self._rows) + + def fetchmany(self, size): + self.fetchmany_calls.append(size) + return list(self._rows[:size]) + + def close(self): + self.closed = True + + +class FakeConn: + """Doble de prueba de una conexion: devuelve un FakeCursor fijo.""" + + def __init__(self, cursor): + self._cursor = cursor + + def cursor(self): + return self._cursor + + +def test_golden_maps_rows_to_dicts(): + cur = FakeCursor( + description=_desc("No_", "Amount"), + rows=[("CLI-1", 100), ("CLI-2", 200)], + ) + conn = FakeConn(cur) + + result = mssql_query(conn, "SELECT No_, Amount FROM Cartera") + + assert result == { + "columns": ["No_", "Amount"], + "rows": [ + {"No_": "CLI-1", "Amount": 100}, + {"No_": "CLI-2", "Amount": 200}, + ], + "row_count": 2, + } + assert cur.closed is True + + +def test_binding_passes_params_to_driver(): + cur = FakeCursor(description=_desc("No_"), rows=[("CLI-0001",)]) + conn = FakeConn(cur) + sql = "SELECT No_ FROM Cartera WHERE [Customer No_] = %s" + + mssql_query(conn, sql, params=("CLI-0001",)) + + # El SQL y los params llegan al driver tal cual: binding, no interpolacion. + assert cur.executed == (sql, ("CLI-0001",)) + + +def test_zero_rows_no_error(): + cur = FakeCursor(description=_desc("No_", "Amount"), rows=[]) + conn = FakeConn(cur) + + result = mssql_query(conn, "SELECT No_, Amount FROM Cartera WHERE 1 = 0") + + assert result["rows"] == [] + assert result["row_count"] == 0 + assert result["columns"] == ["No_", "Amount"] + + +def test_max_rows_uses_fetchmany(): + cur = FakeCursor( + description=_desc("No_"), + rows=[("CLI-1",), ("CLI-2",), ("CLI-3",)], + ) + conn = FakeConn(cur) + + result = mssql_query(conn, "SELECT No_ FROM Cartera", max_rows=1) + + assert cur.fetchmany_calls == [1] + assert result["row_count"] == 1 + assert result["rows"] == [{"No_": "CLI-1"}] + + +def test_description_none_empty_columns(): + cur = FakeCursor(description=None, rows=[]) + conn = FakeConn(cur) + + result = mssql_query(conn, "SET NOCOUNT ON") + + assert result["columns"] == [] + assert result["rows"] == [] + assert result["row_count"] == 0 + + +def test_execution_error_raises_runtimeerror(): + class BoomCursor(FakeCursor): + def execute(self, sql, params=None): + raise ValueError("boom") + + cur = BoomCursor() + conn = FakeConn(cur) + + with pytest.raises(RuntimeError, match="mssql_query failed executing query"): + mssql_query(conn, "SELECT 1") + + # El cursor se cierra incluso en error (try/finally). + assert cur.closed is True diff --git a/python/functions/pipelines/run_mssql_query.md b/python/functions/pipelines/run_mssql_query.md new file mode 100644 index 00000000..e6a8e3c6 --- /dev/null +++ b/python/functions/pipelines/run_mssql_query.md @@ -0,0 +1,100 @@ +--- +name: run_mssql_query +kind: pipeline +lang: py +domain: pipelines +version: "1.0.0" +purity: impure +signature: "def run_mssql_query(host: str, database: str, user: str, password: str, sql: str, params=None, port: int = 1433, max_rows: int | None = None, login_timeout: int = 15, query_timeout: int = 30) -> dict" +description: "One-shot contra SQL Server (Navision): abre conexion, ejecuta UNA SELECT parametrizada y cierra, devolviendo {columns, rows, row_count}. Compone mssql_connect + mssql_query. Pensado para iterar queries de Navision en un solo comando (fn run run_mssql_query ...) en vez del copia-pega manual. CLI imprime JSON o CSV; la contrasena se lee de una env var (recomendado: MSSQL_PASSWORD=$(pass navision/password)), nunca hardcodeada." +tags: [mssql, sqlserver, navision, sql-connect, pipelines] +uses_functions: + - mssql_connect_py_infra + - mssql_query_py_infra +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: host + desc: "Host o IP del SQL Server. Desde WSL2 debe ser la IP LAN de Windows, no localhost." + - name: database + desc: "Nombre de la base de datos a la que conectar (p.ej. la BD de Navision)." + - name: user + desc: "Usuario de login de SQL Server." + - name: password + desc: "Contrasena del usuario. Se pasa desde pass/env, nunca como literal en codigo." + - name: sql + desc: "Sentencia SELECT con placeholders pymssql %s (posicional) o %(nombre)s (nombrado) para los valores." + - name: params + desc: "Tuple/list (posicional), dict (nombrado) o None. Binding seguro del driver (sin inyeccion)." + - name: port + desc: "Puerto TCP del SQL Server. Default 1433." + - name: max_rows + desc: "Si es int positivo, devuelve solo las primeras max_rows filas; None devuelve todas." + - name: login_timeout + desc: "Segundos para la fase de conexion/login. Default 15. Evita que un host inalcanzable cuelgue." + - name: query_timeout + desc: "Segundos de timeout por query. Default 30." +output: "Dict {columns: [nombres], rows: [{col: val}, ...], row_count: int} con el resultado de la SELECT. La conexion se cierra siempre antes de devolver." +tested: true +tests: + - test_run_mssql_query_composes_connect_and_query + - test_run_mssql_query_closes_connection_on_error + - test_to_csv_renders_header_and_rows +test_file_path: "python/functions/pipelines/run_mssql_query_test.py" +file_path: "python/functions/pipelines/run_mssql_query.py" +--- + +## Ejemplo + +Como API programatica (compone conexion + query + cierre): + +```python +import sys, os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions")) +from pipelines.run_mssql_query import run_mssql_query + +res = run_mssql_query( + host="10.0.0.5", # IP LAN del Windows con SQL Server (no localhost desde WSL2) + database="navdb", + user="sa", + password=os.environ["MSSQL_PASSWORD"], # nunca literal: viene de pass/env + sql="SELECT TOP 10 [No_], [Amount] FROM [dbo].[Cartera] WHERE [Customer No_] = %s", + params=("CLI-0001",), # binding seguro del driver, sin inyeccion +) +print(res["row_count"], res["columns"]) +for fila in res["rows"]: + print(fila) +``` + +Como comando one-shot para iterar sobre Navision (imprime JSON o CSV): + +```bash +# La contrasena se lee de la env var, nunca se pasa por la linea de comandos +MSSQL_PASSWORD=$(pass navision/password) \ + ./fn run run_mssql_query \ + --host 10.0.0.5 --database navdb --user sa \ + --sql "SELECT TOP 5 [No_], [Amount] FROM [dbo].[Cartera] WHERE [Customer No_] = %s" \ + --param CLI-0001 \ + --format csv +``` + +## Cuando usarla + +Cuando quieras ejecutar una SELECT contra Navision (SQL Server) y ver las filas en un +solo paso, sin abrir y cerrar la conexion a mano. Es la via rapida para iterar sobre +una query (cartera / posted cartera, etc.): cambias el `--sql`, vuelves a lanzar, y lees +el resultado. Para muchas queries seguidas sobre la misma conexion, usa directamente +`mssql_connect` una vez + `mssql_query` N veces (este pipeline abre y cierra por llamada). + +## Gotchas + +- **Conectividad WSL2 → Windows**: `--host` debe ser la IP LAN del Windows que corre SQL Server, NO `localhost` (desde WSL2 localhost no alcanza al host Windows). Ver memoria `wsl2-localhost-forwarding`. +- **Credenciales**: la contrasena se lee de la env var indicada por `--password-env` (default `MSSQL_PASSWORD`). Patron: `MSSQL_PASSWORD=$(pass navision/password) ./fn run run_mssql_query ...`. `--password` literal existe pero esta DESACONSEJADO (queda visible en la lista de procesos). Nunca hardcodees credenciales. +- **Placeholders**: usa `%s` / `%(nombre)s` (pymssql), NO `?`. Pasa los valores por `--param` (posicional, repetible y en orden), jamas concatenados en el `--sql` (inyeccion). +- **Abre y cierra por llamada**: cada invocacion abre una conexion nueva y la cierra al terminar (incluso si la query falla). No es eficiente para rafagas de muchas queries — para eso compon `mssql_connect` + `mssql_query` tu mismo. +- **Read-only**: no hace commit. Pensado para SELECT. No lo uses para INSERT/UPDATE/DELETE. +- **Requiere pymssql** instalado en el venv (lo importa `mssql_connect`). +- **CSV**: `--format csv` serializa con el modulo `csv` estandar; valores no-string se convierten con `str` en JSON (`default=str`) para fechas/decimales de SQL Server. diff --git a/python/functions/pipelines/run_mssql_query.py b/python/functions/pipelines/run_mssql_query.py new file mode 100644 index 00000000..44dfd59f --- /dev/null +++ b/python/functions/pipelines/run_mssql_query.py @@ -0,0 +1,140 @@ +"""One-shot SQL Server (Navision) query: connect, run a SELECT, print rows. + +Composes the registry functions `mssql_connect` and `mssql_query` so a single +`fn run run_mssql_query ...` opens a connection, runs one parameterized SELECT, +closes the connection, and prints the rows as JSON or CSV. Built to make +iterating over Navision queries a one-command loop instead of a manual +copy-paste round trip. +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from infra.mssql_connect import mssql_connect +from infra.mssql_query import mssql_query + + +def run_mssql_query(host: str, database: str, user: str, password: str, + sql: str, params=None, port: int = 1433, + max_rows: int | None = None, login_timeout: int = 15, + query_timeout: int = 30) -> dict: + """Open a SQL Server connection, run one SELECT, close, return the rows. + + Thin impure composition of `mssql_connect` + `mssql_query`. The connection + is always closed (try/finally), even on error. Credentials are supplied by + the caller (read from `pass`/env) and never hardcoded. + + Args: + host: SQL Server host or IP. From WSL2 use the Windows LAN IP, not + "localhost". + database: Database name to connect to. + user: SQL Server login user. + password: Password for the login user. Pass it from `pass`/env, never a + string literal in code. + sql: The SELECT statement, using pymssql placeholders `%s` (positional) + or `%(name)s` (named) for any bound values. + params: Tuple/list for positional placeholders, dict for named + placeholders, or None. Bound safely by the driver (no injection). + port: TCP port of the SQL Server instance. Defaults to 1433. + max_rows: If a positive int, only the first `max_rows` rows are + returned. If None, all rows are returned. + login_timeout: Seconds allowed for the connect/login phase. Defaults to + 15. Keeps an unreachable host from hanging. + query_timeout: Seconds allowed for the query. Defaults to 30. + + Returns: + The dict returned by `mssql_query`: {"columns": [...], "rows": [...], + "row_count": int}. + + Raises: + RuntimeError: If the connection or the query fails. The original + exception (from `mssql_connect` / `mssql_query`) is chained. + """ + conn = mssql_connect( + host, database, user, password, + port=port, login_timeout=login_timeout, query_timeout=query_timeout, + ) + try: + return mssql_query(conn, sql, params=params, max_rows=max_rows) + finally: + try: + conn.close() + except Exception: # pragma: no cover - close errors are non-fatal here + pass + + +def _to_csv(result: dict) -> str: + """Render a query result dict as CSV text (header + rows).""" + import csv + import io + + buf = io.StringIO() + writer = csv.writer(buf) + columns = result.get("columns", []) + writer.writerow(columns) + for row in result.get("rows", []): + writer.writerow([row.get(col) for col in columns]) + return buf.getvalue() + + +if __name__ == "__main__": + import argparse + import json + + parser = argparse.ArgumentParser( + description=( + "Ejecuta una SELECT contra un SQL Server (Navision) e imprime las " + "filas. Compone mssql_connect + mssql_query." + ) + ) + parser.add_argument("--host", required=True, help="Host/IP del SQL Server (IP LAN de Windows desde WSL2).") + parser.add_argument("--database", required=True, help="Nombre de la base de datos.") + parser.add_argument("--user", required=True, help="Usuario de login.") + parser.add_argument( + "--password-env", default="MSSQL_PASSWORD", + help="Variable de entorno de la que leer la contrasena (default MSSQL_PASSWORD). " + "Uso recomendado: MSSQL_PASSWORD=$(pass navision/password) fn run run_mssql_query ...", + ) + parser.add_argument( + "--password", default="", + help="Contrasena literal (DESACONSEJADO: visible en la lista de procesos). " + "Prefiere --password-env.", + ) + parser.add_argument("--sql", required=True, help="Sentencia SELECT (placeholders %%s o %%(nombre)s).") + parser.add_argument( + "--param", action="append", default=None, dest="params", + help="Parametro posicional para los placeholders %%s. Repetible y en orden.", + ) + parser.add_argument("--port", type=int, default=1433, help="Puerto TCP. Default 1433.") + parser.add_argument("--max-rows", type=int, default=None, help="Limite de filas devueltas.") + parser.add_argument("--login-timeout", type=int, default=15, help="Timeout de login en segundos.") + parser.add_argument("--query-timeout", type=int, default=30, help="Timeout de query en segundos.") + parser.add_argument( + "--format", choices=["json", "csv"], default="json", dest="fmt", + help="Formato de salida. Default json.", + ) + args = parser.parse_args() + + password = args.password or os.environ.get(args.password_env, "") + if not password: + parser.error( + f"sin contrasena: define la env var {args.password_env!r} " + f"(p.ej. MSSQL_PASSWORD=$(pass navision/password)) o pasa --password." + ) + + params = tuple(args.params) if args.params else None + + result = run_mssql_query( + args.host, args.database, args.user, password, + args.sql, params=params, port=args.port, max_rows=args.max_rows, + login_timeout=args.login_timeout, query_timeout=args.query_timeout, + ) + + if args.fmt == "csv": + sys.stdout.write(_to_csv(result)) + else: + print(json.dumps(result, default=str, ensure_ascii=False)) diff --git a/python/functions/pipelines/run_mssql_query_test.py b/python/functions/pipelines/run_mssql_query_test.py new file mode 100644 index 00000000..cf9ebe0a --- /dev/null +++ b/python/functions/pipelines/run_mssql_query_test.py @@ -0,0 +1,94 @@ +"""Tests for run_mssql_query: composition of mssql_connect + mssql_query. + +Mock-based, no real SQL Server. The pipeline binds `mssql_connect` and +`mssql_query` as module-level names, so we monkeypatch them in the pipeline's +namespace and assert the orchestration (connect -> query -> always close). +""" + +from __future__ import annotations + +import pytest + +import pipelines.run_mssql_query as mod +from pipelines.run_mssql_query import run_mssql_query, _to_csv + + +class FakeConn: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + +def test_run_mssql_query_composes_connect_and_query(monkeypatch): + fake_conn = FakeConn() + connect_calls = {} + query_calls = {} + + def fake_connect(host, database, user, password, **kwargs): + connect_calls.update( + host=host, database=database, user=user, password=password, **kwargs + ) + return fake_conn + + sentinel = {"columns": ["No_"], "rows": [{"No_": "CLI-1"}], "row_count": 1} + + def fake_query(conn, sql, params=None, max_rows=None): + query_calls.update(conn=conn, sql=sql, params=params, max_rows=max_rows) + return sentinel + + monkeypatch.setattr(mod, "mssql_connect", fake_connect) + monkeypatch.setattr(mod, "mssql_query", fake_query) + + result = run_mssql_query( + "10.0.0.5", "navdb", "sa", "pw", + "SELECT [No_] FROM [dbo].[Cartera] WHERE [Customer No_] = %s", + params=("CLI-0001",), port=1433, max_rows=5, + ) + + # Returns exactly what mssql_query produced. + assert result is sentinel + # Connection was opened with the supplied params. + assert connect_calls["host"] == "10.0.0.5" + assert connect_calls["database"] == "navdb" + assert connect_calls["port"] == 1433 + # Query borrowed the open connection and got the bound params (no interpolation). + assert query_calls["conn"] is fake_conn + assert query_calls["params"] == ("CLI-0001",) + assert query_calls["max_rows"] == 5 + # Connection is always closed. + assert fake_conn.closed is True + + +def test_run_mssql_query_closes_connection_on_error(monkeypatch): + fake_conn = FakeConn() + + monkeypatch.setattr(mod, "mssql_connect", lambda *a, **k: fake_conn) + + def boom(conn, sql, params=None, max_rows=None): + raise RuntimeError("mssql_query failed executing query: boom") + + monkeypatch.setattr(mod, "mssql_query", boom) + + with pytest.raises(RuntimeError, match="failed executing query"): + run_mssql_query("10.0.0.5", "navdb", "sa", "pw", "SELECT 1") + + # Even on a query error, the connection is closed (try/finally). + assert fake_conn.closed is True + + +def test_to_csv_renders_header_and_rows(): + result = { + "columns": ["No_", "Amount"], + "rows": [ + {"No_": "CLI-1", "Amount": 100}, + {"No_": "CLI-2", "Amount": 200}, + ], + "row_count": 2, + } + csv_text = _to_csv(result) + lines = csv_text.strip().splitlines() + assert lines[0] == "No_,Amount" + assert lines[1] == "CLI-1,100" + assert lines[2] == "CLI-2,200" diff --git a/python/pyproject.toml b/python/pyproject.toml index 87b6636e..a566fe9c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "matplotlib>=3.10.9", "openpyxl>=3.1.5", "polars>=1.40.1", + "pymssql>=2.3.13", "pypdf>=6.10.0", "pyproj>=3.7.2", "python-docx>=1.2.0", diff --git a/python/uv.lock b/python/uv.lock index 70e35b93..de84b9fa 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -902,6 +902,7 @@ dependencies = [ { name = "matplotlib" }, { name = "openpyxl" }, { name = "polars" }, + { name = "pymssql" }, { name = "pypdf" }, { name = "pyproj" }, { name = "python-docx" }, @@ -954,6 +955,7 @@ requires-dist = [ { name = "matplotlib", specifier = ">=3.10.9" }, { name = "openpyxl", specifier = ">=3.1.5" }, { name = "polars", specifier = ">=1.40.1" }, + { name = "pymssql", specifier = ">=2.3.13" }, { name = "pypdf", specifier = ">=6.10.0" }, { name = "pyproj", specifier = ">=3.7.2" }, { name = "python-docx", specifier = ">=1.2.0" }, @@ -3625,6 +3627,35 @@ crypto = [ { name = "cryptography" }, ] +[[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/ba/60/a2e8a8a38f7be21d54402e2b3365cd56f1761ce9f2706c97f864e8aa8300/pymssql-2.3.13-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cf4f32b4a05b66f02cb7d55a0f3bcb0574a6f8cf0bee4bea6f7b104038364733", size = 3158689, upload-time = "2026-02-14T04:59:46.982Z" }, + { url = "https://files.pythonhosted.org/packages/43/9e/0cf0ffb9e2f73238baf766d8e31d7237b5bee3cc1bb29a376b404610994a/pymssql-2.3.13-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:2b056eb175955f7fb715b60dc1c0c624969f4d24dbdcf804b41ab1e640a2b131", size = 2960018, upload-time = "2026-02-14T04:59:48.668Z" }, + { url = "https://files.pythonhosted.org/packages/93/ea/bc27354feaca717faa4626911f6b19bb62985c87dda28957c63de4de5895/pymssql-2.3.13-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:319810b89aa64b99d9c5c01518752c813938df230496fa2c4c6dda0603f04c4c", size = 3065719, upload-time = "2026-02-14T04:59:50.369Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7a/8028681c96241fb5fc850b87c8959402c353e4b83c6e049a99ffa67ded54/pymssql-2.3.13-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0ea72641cb0f8bce7ad8565dbdbda4a7437aa58bce045f2a3a788d71af2e4be", size = 3190567, upload-time = "2026-02-14T04:59:52.202Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f1/ab5b76adbbd6db9ce746d448db34b044683522e7e7b95053f9dd0165297b/pymssql-2.3.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1493f63d213607f708a5722aa230776ada726ccdb94097fab090a1717a2534e0", size = 3710481, upload-time = "2026-02-14T04:59:54.01Z" }, + { url = "https://files.pythonhosted.org/packages/59/aa/2fa0951475cd0a1829e0b8bfbe334d04ece4bce11546a556b005c4100689/pymssql-2.3.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb3275985c23479e952d6462ae6c8b2b6993ab6b99a92805a9c17942cf3d5b3d", size = 3453789, upload-time = "2026-02-14T04:59:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/78/08/8cd2af9003f9fc03912b658a64f5a4919dcd68f0dd3bbc822b49a3d14fd9/pymssql-2.3.13-cp312-cp312-win_amd64.whl", hash = "sha256:a930adda87bdd8351a5637cf73d6491936f34e525a5e513068a6eac742f69cdb", size = 1994709, upload-time = "2026-02-14T04:59:58.972Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4f/ee15b1f6b11e7c3accdc7da7840a019b63f12ba09eaa008acc601182f516/pymssql-2.3.13-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:30918bb044242865c01838909777ef5e0f1b9ecd7f5882346aefa57f4414b29c", size = 3156333, upload-time = "2026-02-14T05:00:01.21Z" }, + { url = "https://files.pythonhosted.org/packages/79/03/aea5c77bad4a52649a1d9f786a1d9ce1c83d50f1a75df288e292737b6d80/pymssql-2.3.13-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:1c6d0b2d7961f159a07e4f0d8cc81f70ceab83f5e7fd1e832a2d069e1d67ee4e", size = 2957990, upload-time = "2026-02-14T05:00:03.11Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f8/30ac16fba32ff066b05f12c392d7b812fe11f06cb62d1d86ca5177c50a8b/pymssql-2.3.13-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16c5957a3c9e51a03276bfd76a22431e2bc4c565e2e95f2cbb3559312edda230", size = 3065264, upload-time = "2026-02-14T05:00:05.377Z" }, + { url = "https://files.pythonhosted.org/packages/a9/98/7568447bf85921d21453fd56e19b6c9591d595fde0546c5a569f3ae937a8/pymssql-2.3.13-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fddd24efe9d18bbf174fab7c6745b0927773718387f5517cf8082241f721a68", size = 3190039, upload-time = "2026-02-14T05:00:06.925Z" }, + { url = "https://files.pythonhosted.org/packages/35/f1/4d9d275ebaac42cdd49d40d504ccb648f27710660c8b60cc427752438c09/pymssql-2.3.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:123c55ee41bc7a82c76db12e2eb189b50d0d7a11222b4f8789206d1cda3b33b9", size = 3710151, upload-time = "2026-02-14T05:00:08.424Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bd/a5cc6244fd27d3ea0cc82f12a7d38a24d7fd90b0022afd250014e8bfba15/pymssql-2.3.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e053b443e842f9e1698fcb2b23a4bff1ff3d410894d880064e754ad823d541e5", size = 3453156, upload-time = "2026-02-14T05:00:09.978Z" }, + { url = "https://files.pythonhosted.org/packages/26/d0/c20ff0bbffd18db528bcc7b0c68b25c12ad563ed67c56ceca87c58f7399e/pymssql-2.3.13-cp313-cp313-win_amd64.whl", hash = "sha256:5c045c0f1977a679cc30d5acd9da3f8aeb2dc6e744895b26444b4a2f20dad9a0", size = 1995236, upload-time = "2026-02-14T05:00:11.495Z" }, + { 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 = "pyogrio" version = "0.12.1"