feat(infra): conexion y consulta directa a SQL Server (Navision) via pymssql

Grupo de capacidad nuevo 'sql-connect' (3 funciones) para conectar a un
Microsoft SQL Server (donde corre Navision) y consultar directamente, en
lugar del ida y vuelta manual de pegar CSVs.

- mssql_connect_py_infra: abre conexion pymssql (login_timeout acotado,
  credenciales por argumento, RuntimeError claro si falla).
- mssql_query_py_infra: SELECT parametrizada con binding seguro (sin
  inyeccion) sobre conexion abierta; devuelve {columns, rows, row_count};
  0 filas -> lista vacia; max_rows con fetchmany; read-only.
- run_mssql_query_py_pipelines: one-shot que compone connect+query y cierra
  siempre; CLI imprime JSON o CSV; contrasena desde env var (pass).

Pagina madre docs/capabilities/sql-connect.md + fila en INDEX.md.
Dependencia pymssql>=2.3.13 anadida a python/pyproject.toml + uv.lock.
Tests mock-based (11) verdes; error path verificado end-to-end contra el
driver real (host inalcanzable -> RuntimeError, acotado por login_timeout).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 11:29:49 +02:00
parent c1f355ffa5
commit 86d68dc9f0
13 changed files with 930 additions and 0 deletions
+1
View File
@@ -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 |
+70
View File
@@ -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.
+81
View File
@@ -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).
+65
View File
@@ -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
@@ -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
+78
View File
@@ -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="<desde pass>"
)
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.
+77
View File
@@ -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)}
+133
View File
@@ -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
@@ -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.
@@ -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))
@@ -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"
+1
View File
@@ -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",
+31
View File
@@ -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"