Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f34badb500 | |||
| 3289c67986 | |||
| bcc1fe1738 | |||
| 7619347be8 | |||
| f55e41cf74 | |||
| e2e8669edf | |||
| 86d68dc9f0 | |||
| b18759823d | |||
| a59d50238d | |||
| f17d957a8f | |||
| c1f355ffa5 | |||
| 237f763c19 | |||
| 3cf8b21fea |
@@ -0,0 +1,81 @@
|
||||
---
|
||||
description: "Espejo de requisitos: Claude reformula con detalle la última tarea pedida (objetivo, alcance, entregables, supuestos, criterios de aceptación, fuera de alcance y dudas) para confirmar alineación antes de ejecutar. No ejecuta nada."
|
||||
argument-hint: "[opcional: matiz o foco a tener en cuenta al reformular]"
|
||||
---
|
||||
|
||||
# /equal — confirmar alineación reformulando la tarea pedida
|
||||
|
||||
Mecanismo de **espejo de requisitos**. Cuando el usuario invoca `/equal`, NO ejecutas la tarea: devuelves tu interpretación detallada y estructurada del encargo más reciente, para que el usuario confirme o corrija antes de que empieces a trabajar.
|
||||
|
||||
El objetivo es eliminar el malentendido silencioso: prefieres gastar un turno reflejando lo que crees que se te pide que arrancar en la dirección equivocada.
|
||||
|
||||
## Qué hacer al invocarse
|
||||
|
||||
1. **Identifica la tarea más reciente que el usuario te ha pedido** en la conversación actual: la última petición de trabajo real, no el `/equal` en sí ni un comando de utilidad anterior. Si hay `$ARGUMENTS`, úsalos como matiz o foco adicional al reformular (p. ej. "céntrate en el alcance" o "asume que es solo el backend"), no como la tarea nueva.
|
||||
|
||||
2. **Reformula esa tarea de forma detallada y estructurada**, con estas secciones (omite una sección solo si es genuinamente no aplicable, no para abreviar):
|
||||
|
||||
- **Objetivo** — qué se quiere conseguir, en una o dos frases claras. El "para qué", no solo el "qué".
|
||||
- **Alcance / qué incluye** — los trozos concretos de trabajo que entiendes incluidos. Lista, no párrafo.
|
||||
- **Entregables** — qué archivos, cambios, salidas o artefactos concretos vas a producir.
|
||||
- **Supuestos** — lo que estás asumiendo por defecto al no estar dicho explícitamente (stack, ubicación, convenciones, datos, alcance temporal). Hazlos visibles para que el usuario los pueda tumbar.
|
||||
- **Criterios de aceptación** — cómo sabremos que está bien hecho. Condiciones verificables, no deseos vagos. Cuando aplique, golden + edge + caso de error (alineado con `dod_quality.md`).
|
||||
- **Fuera de alcance** — lo que NO vas a hacer, para acotar expectativas y evitar scope creep.
|
||||
- **Dudas / ambigüedades a confirmar** — preguntas concretas sobre lo que no está claro. Numéralas para que el usuario pueda responder por número. Si no hay dudas reales, dilo explícitamente ("sin dudas bloqueantes").
|
||||
|
||||
3. **Cierra pidiendo validación**: una línea clara del tipo "¿Alineado? Corrige lo que no cuadre y arranco." No empieces a trabajar hasta que el usuario confirme.
|
||||
|
||||
## Caso sin tarea previa
|
||||
|
||||
Si **no hay** una petición de trabajo clara que reformular (la conversación está vacía de encargos, o lo último fueron solo comandos de utilidad / charla), NO inventes una tarea. Dilo directamente y pide al usuario que describa qué quiere hacer, ofreciéndole la misma estructura como guía de qué información es útil (objetivo, entregables, criterios).
|
||||
|
||||
## Reglas duras
|
||||
|
||||
- **No ejecutas la tarea.** `/equal` solo refleja y pregunta. Nada de editar archivos, lanzar comandos de trabajo ni delegar a subagentes para "ir avanzando".
|
||||
- **No inventes requisitos.** Lo que no se ha pedido va a *Supuestos* (marcado como asunción) o a *Dudas* (como pregunta), nunca colado como hecho.
|
||||
- **Refleja lo que se pidió, no lo que crees que debería pedirse.** Si detectas una mejora obvia, propónla como una duda al final, no la incorpores al objetivo.
|
||||
- **Sé concreto.** Evita reformulaciones genéricas que valdrían para cualquier tarea. Cita los detalles reales del encargo (nombres de archivos, rutas, tecnologías, cifras que el usuario mencionó).
|
||||
- Mantén el comando **simple**: es un reformulador de requisitos, no un planificador de implementación ni un generador de tareas.
|
||||
|
||||
## Ejemplo de salida
|
||||
|
||||
Para una petición previa como *"añade un endpoint `/health` al servicio de deploy que devuelva 200 y la versión"*:
|
||||
|
||||
```
|
||||
## Objetivo
|
||||
Añadir un health check HTTP al deploy_server para poder verificar de forma
|
||||
programática que el servicio está vivo y saber qué versión corre.
|
||||
|
||||
## Alcance / qué incluye
|
||||
- Nuevo handler GET `/health` en el servidor HTTP de deploy_server.
|
||||
- Respuesta 200 con un cuerpo JSON que incluya la versión del binario.
|
||||
- Registro de la ruta en el router existente.
|
||||
|
||||
## Entregables
|
||||
- Cambio en el código del servidor de `apps/deploy_server/` (handler + ruta).
|
||||
- Sin migración de BD (no toca schema).
|
||||
|
||||
## Supuestos
|
||||
- La versión a devolver es la que ya expone el binario (no hay que crear un
|
||||
sistema de versionado nuevo).
|
||||
- Formato de respuesta JSON `{"status":"ok","version":"x.y.z"}`.
|
||||
- Sin autenticación en `/health` (endpoint público de liveness).
|
||||
|
||||
## Criterios de aceptación
|
||||
- Golden: `GET /health` con el servicio arriba → 200 + JSON con `version`.
|
||||
- Edge: la versión se lee correctamente aunque el binario se compile sin tag.
|
||||
- Error: si un subsistema crítico no está listo, devuelve 503, no 200 falso.
|
||||
|
||||
## Fuera de alcance
|
||||
- Readiness check de dependencias remotas (VPS, SSH).
|
||||
- Métricas / observabilidad más allá del 200.
|
||||
|
||||
## Dudas / ambigüedades a confirmar
|
||||
1. ¿`/health` debe comprobar algo real (DB, disco) o basta con responder vivo?
|
||||
2. ¿La versión sale de un ldflag de build, de un fichero, o de constante?
|
||||
3. ¿Puerto y router son los que ya usa `deploy_server serve`?
|
||||
|
||||
¿Alineado? Corrige lo que no cuadre y arranco.
|
||||
```
|
||||
|
||||
El ejemplo es ilustrativo del **formato y el nivel de detalle**; el contenido real sale siempre de la tarea concreta que el usuario haya pedido en la conversación.
|
||||
@@ -86,6 +86,19 @@ de tmux.
|
||||
Siempre con `--dangerously-skip-permissions` (memoria `lanzar-agentes-skip-permissions`): los
|
||||
secundarios trabajan autónomos y desatendidos; los prompts de permiso en cada Bash los atascarían.
|
||||
|
||||
**Nombra cada secundario para diferenciarlo de un vistazo (regla dura).** Cuando lances varios a la
|
||||
vez, el humano tiene que poder distinguirlos rápido en el sidebar de fleetview. Dos cosas:
|
||||
|
||||
1. **`--title` descriptivo y prefijado** en cada `spawn_fleet_agent`: un slug corto y único que diga
|
||||
QUÉ hace ese agente, idealmente con una letra/índice para ordenarlos (`A·mcp-rename`,
|
||||
`B·sql-navision`, `C·kanban`, `D·equal-skill`). Esto nombra la window tmux.
|
||||
2. **El nombre del sidebar fleetview = el campo `goal`** del `~/.claude/goals/<sid>.json`. En cuanto
|
||||
resuelvas el `sessionId` del secundario, fíjale un nombre claro con la tool
|
||||
`mcp__orchestrator__fleet_set_name` (o `./fn run set_fleet_name` cuando exista el fallback CLI) —
|
||||
mismo slug descriptivo que el `--title`. Si esa capacidad aún no está disponible en la sesión,
|
||||
apóyate solo en `--title` y en que el `goal` autogenerado del prompt sea descriptivo, pero el
|
||||
objetivo es que el sidebar liste nombres legibles, no objetivos genéricos repetidos.
|
||||
|
||||
#### En la flota tmux (PREFERIDO siempre que estés en tmux)
|
||||
|
||||
Si estás dentro de tmux/una flota (`$TMUX` seteada — compruébalo con `detect_fleet_context`, **no**
|
||||
|
||||
@@ -27,7 +27,7 @@ La fuente de verdad del mapeo PID→sessionId→cwd son los archivos `~/.claude/
|
||||
`goal`, `phase`, `status`, `tmux_window` y `age`/`idle_seconds` la da el CLI de la app fleetview:
|
||||
|
||||
```bash
|
||||
apps/fleetview/fleetview list --json # flota tipada: session_id, goal, phase, status, tmux_window, age, idle_seconds
|
||||
apps/fleetview/fleetview list --json # flota tipada: session_id, goal, phase, status, pane_id ("%N", el id estable), tmux_window ("@N", interno para focus/send-keys), age, idle_seconds
|
||||
apps/fleetview/fleetview list # tabla legible (incluye columna AGE)
|
||||
```
|
||||
|
||||
@@ -58,7 +58,7 @@ devuelven salida estructurada y se registran en la telemetría como cualquier MC
|
||||
|
||||
| Operación de la flota | Tool MCP (preferido) | Fallback `./fn run` / binario |
|
||||
|---|---|---|
|
||||
| Listar la flota tipada (session_id, goal, phase, status, **role, dod_contract, dod_status**, tmux_window, age, idle_seconds) | `mcp__orchestrator__fleet_list` | `apps/fleetview/fleetview list --json` (NO `./fn run list_claude_fleet`) |
|
||||
| Listar la flota tipada (session_id, goal, phase, status, **role, dod_contract, dod_status**, **pane_id** (el id estable), age, idle_seconds) | `mcp__orchestrator__fleet_list` | `apps/fleetview/fleetview list --json` (NO `./fn run list_claude_fleet`) |
|
||||
| Drenar la cola de transiciones del watcher (agrupada por clasificación + urgentes) | `mcp__orchestrator__fleet_drain` (`advance` true consume, false hace peek) | `./fn run drain_fleet_events` |
|
||||
| Clasificar el estado de terminación de UN agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) | `mcp__orchestrator__fleet_classify` | (Go con tests; lo consume el watcher, no se invoca a mano) |
|
||||
| Escribir el DoD-contrato fijo (`dod_contract`/`dod_status`) en el `goal.json` de un agente | `mcp__orchestrator__fleet_set_dod` | `./fn run set_dod_contract` |
|
||||
@@ -69,6 +69,15 @@ Ventaja extra de `fleet_list`: expone `role`/`dod_contract`/`dod_status` directa
|
||||
vacíos desde el sidecar `goal.json`), así que la regla "No te vigiles a ti mismo" se resuelve sin leer
|
||||
el sidecar a mano — filtra por el `role` que ya trae cada fila.
|
||||
|
||||
**Identifica a cada agente por su `pane_id` ("%N").** Es el id ESTABLE de por vida del pane: el
|
||||
`fleet_list` del MCP lo expone como el único identificador y **omite a propósito el `tmux_window`
|
||||
("@N")**, que migra cuando el focus-swap mueve el pane entre windows y por eso nunca debe usarse ni
|
||||
mostrarse como id (la persona no tiene referencia mental de "@4"). Las operaciones internas que sí
|
||||
necesitan la window viva — `focus`, `send-keys`/nudge y `kill` — la resuelven BAJO DEMANDA contra
|
||||
tmux a partir del session_id/PID (`kill_fleet_agent` y `fleetview focus` la recalculan por llamada);
|
||||
para el nudge, lee `tmux_window` del binario `fleetview list --json` (que sí lo conserva como campo
|
||||
interno), nunca del payload del MCP.
|
||||
|
||||
Mantén una **tabla de seguimiento**, una fila por secundario, y actualízala en cada turno:
|
||||
|
||||
| slug | título kitty | PID | cwd / dir aislado | rama | log | report | estado |
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"registry",
|
||||
"jupyter"
|
||||
"jupyter",
|
||||
"orchestrator"
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
|
||||
@@ -114,6 +114,24 @@ static LRESULT CALLBACK fn_subclass_wndproc(HWND hwnd, UINT msg, WPARAM wp, LPAR
|
||||
case WM_EXITSIZEMOVE:
|
||||
g_in_sizemove.store(false, std::memory_order_release);
|
||||
break;
|
||||
case WM_SYSKEYDOWN:
|
||||
// Alt+Enter would otherwise toggle a borderless-fullscreen mode
|
||||
// (driven by some GPU drivers' OpenGL/Vulkan hotkey, or by
|
||||
// DefWindowProc on certain window styles). We never want that:
|
||||
// these are docked tool windows, not games. Consume the keystroke
|
||||
// so the window stays in its normal decorated state. Every other
|
||||
// Alt+key combo chains through to GLFW/DefWindowProc untouched.
|
||||
if (wp == VK_RETURN) {
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_SYSCHAR:
|
||||
// Swallow the system "ding" beep that the suppressed Alt+Enter
|
||||
// above would otherwise trigger via the default char handler.
|
||||
if (wp == VK_RETURN) {
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_LBUTTONDOWN:
|
||||
// Alt + LMB anywhere on the window initiates a native modal MOVE
|
||||
// via WM_SYSCOMMAND, SC_MOVE | HTCAPTION. Same pattern as our
|
||||
|
||||
@@ -42,7 +42,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
|
||||
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
|
||||
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
|
||||
| [img-to-3d](img-to-3d.md) | 3 | Imagen 2D -> modelo 3D: profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
|
||||
| [img-to-3d](img-to-3d.md) | 4 | Imagen 2D -> modelo 3D: recorte de fondo (rembg/GrabCut/umbral) + profundidad monocular (Depth-Anything-V2) + malla de relieve texturizada exportada a .glb, con pipeline one-shot. Produce el glb que mesh-3d consume/renderiza |
|
||||
| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E |
|
||||
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
|
||||
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
|
||||
@@ -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 |
|
||||
|
||||
@@ -10,24 +10,27 @@ partir de una sola foto se estima un mapa de profundidad monocular con un modelo
|
||||
reconstruye una malla de relieve (heightmap) texturizada con la imagen original, exportada como
|
||||
`.glb` cargable por cualquier visor glTF (three.js `useGLTF`/`GLTFLoader`, Babylon, model-viewer).
|
||||
|
||||
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas dos funciones; ver su
|
||||
`backend/depth.py`). El flujo canonico es de **dos pasos encadenados**:
|
||||
Promovido desde la app `img_to_3d_webapp` (su backend incrustaba estas funciones; ver
|
||||
`backend/depth.py` y `backend/bg_removal.py`). El flujo canonico encadena un pre-proceso opcional
|
||||
de fondo con los dos pasos de reconstruccion:
|
||||
|
||||
```
|
||||
estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image -> .glb)
|
||||
[remove_background (imagen -> rgb+mask)] -> estimate_image_depth (imagen -> depth+image) -> depth_to_relief_glb (depth+image[+mask] -> .glb)
|
||||
```
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma corta | Que hace |
|
||||
|---|---|---|
|
||||
| `remove_background_py_datascience` | `remove_background(image_path, engine?) -> dict` | **Pre-proceso (paso 0).** Elimina el fondo en cascada rembg -> GrabCut -> umbral y compone el objeto sobre gris neutro. Devuelve `image` PIL + `mask` ndarray. La `mask` se pasa a `depth_to_relief_glb` para recortar la malla al objeto. |
|
||||
| `estimate_image_depth_py_datascience` | `estimate_image_depth(image_path, model_name?, device?, use_cache?) -> dict` | Estima profundidad monocular con Depth-Anything-V2 (GPU/CPU). Devuelve `depth` ndarray [0,1] + `image` PIL. Paso 1. |
|
||||
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Paso 2. |
|
||||
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone los dos pasos en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
|
||||
| `depth_to_relief_glb_py_datascience` | `depth_to_relief_glb(image, depth, out_glb_path, z_scale?, max_dim?, mask?) -> dict` | Convierte `depth`+`image` en una malla de relieve texturizada y la exporta a `.glb`. Con `mask` opcional recorta las caras del fondo. Paso 2. |
|
||||
| `build_relief_glb_from_image_py_pipelines` | `build_relief_glb_from_image(image_path, out_glb_path, model_name?, device?, z_scale?, max_dim?) -> dict` | **Pipeline one-shot**: compone estimacion + relieve en una sola llamada (imagen -> .glb). Salida JSON-serializable, apta para `fn run`. |
|
||||
|
||||
Las tres son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
|
||||
Las cuatro son **impuras** (cargan modelo / GPU / escriben archivo), devuelven `dict` con `status`
|
||||
(`ok`/`error`) y **nunca lanzan**: los fallos vuelven como `{status:'error', error:str}`. El
|
||||
pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
|
||||
pipeline ademas marca `stage` (`estimate`/`relief`) en el error. `remove_background` en
|
||||
`engine="auto"` nunca falla (cae al umbral NumPy puro sin deps externas).
|
||||
|
||||
## Ejemplo canonico (end-to-end imagen → glb)
|
||||
|
||||
@@ -37,17 +40,24 @@ pipeline ademas marca `stage` (`estimate`/`relief`) en el error.
|
||||
# ausentes en el venv de vision. Ver "Fronteras / gotchas".
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/datascience")
|
||||
from remove_background import remove_background
|
||||
from estimate_image_depth import estimate_image_depth
|
||||
from depth_to_relief_glb import depth_to_relief_glb
|
||||
|
||||
IMG = "apps/img_to_3d_webapp/samples/cats.jpg"
|
||||
OUT = "/tmp/cats_relief.glb"
|
||||
|
||||
# Paso 0 (opcional pero recomendado): aislar el objeto del fondo. La mask recorta la malla.
|
||||
cut = remove_background(IMG) # engine='auto' -> rembg -> grabcut -> umbral
|
||||
assert cut["status"] == "ok"
|
||||
print(cut["engine"], cut["fg_fraction"]) # p.ej. rembg:u2net 0.42
|
||||
|
||||
est = estimate_image_depth(IMG) # device='auto' -> GPU si hay
|
||||
assert est["status"] == "ok"
|
||||
# est["depth"]: ndarray HxW float32 [0,1] (1=mas cerca) | est["image"]: PIL.Image RGB
|
||||
|
||||
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220)
|
||||
# Pasando la mask del paso 0, las caras del fondo se descartan: malla solo del objeto.
|
||||
res = depth_to_relief_glb(est["image"], est["depth"], OUT, z_scale=0.35, max_dim=220, mask=cut["mask"])
|
||||
assert res["status"] == "ok"
|
||||
print(res["glb_path"], res["vertices"], res["faces"]) # /tmp/cats_relief.glb 36300 71832
|
||||
# OUT es un glTF binario valido: trimesh.load(OUT) devuelve una Scene texturizada.
|
||||
@@ -70,15 +80,19 @@ O en una sola llamada con el pipeline (recomendado para fn run / Launcher TUI):
|
||||
- **No cubre el render/visualizacion.** Producir el `.glb` es el limite del grupo. Cargarlo y
|
||||
subirlo a GPU (OpenGL) en una app C++/ImGui es el grupo **`mesh-3d`** (`gltf_load_mesh_cpp_gfx`
|
||||
carga justamente este tipo de `.glb`). img-to-3d **produce**; mesh-3d **consume/renderiza**.
|
||||
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision) y `trimesh` (mesh),
|
||||
que hoy viven en el venv de `img_to_3d_webapp`, NO en el venv del registry. Ademas el
|
||||
`datascience.__init__` arrastra deps de scrapers (`bs4`...) que no estan en el venv de vision,
|
||||
por eso el import es **plano** (al modulo) y no via el paquete. `fn run` de estas funciones
|
||||
exige un venv que combine ambos mundos (torch + transformers + trimesh + las deps del dominio
|
||||
datascience). Ver gotchas en cada `.md`.
|
||||
- **Deps pesadas y de dos mundos.** Requiere `torch`+`transformers` (vision), `trimesh` (mesh) y,
|
||||
para `remove_background`, `rembg`+`onnxruntime` (segmentacion) y `opencv-python` (GrabCut) —
|
||||
todas opcionales: el umbral de `remove_background` es NumPy puro. Hoy viven en el venv de
|
||||
`img_to_3d_webapp`, NO en el venv del registry. Ademas el `datascience.__init__` arrastra deps
|
||||
de scrapers (`bs4`...) que no estan en el venv de vision, por eso el import es **plano** (al
|
||||
modulo) y no via el paquete. `fn run` de estas funciones exige un venv que combine ambos mundos
|
||||
(torch + transformers + trimesh + rembg/opencv + las deps del dominio datascience). Ver gotchas
|
||||
en cada `.md`.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- GPU NVIDIA + CUDA recomendada (corre en CPU pero lento). Primera ejecucion descarga los pesos
|
||||
del modelo a `~/.cache/huggingface/` (cientos de MB segun la variante).
|
||||
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`.
|
||||
del modelo de profundidad a `~/.cache/huggingface/` y el de `rembg` (U2Net ~170 MB) a su cache.
|
||||
- Paquetes: `torch`, `transformers`, `trimesh`, `pillow`, `numpy`. Para el recorte de fondo de
|
||||
mayor calidad: `rembg` (+`onnxruntime`) y `opencv-python` (ambos opcionales; sin ellos
|
||||
`remove_background` cae al umbral NumPy).
|
||||
|
||||
@@ -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.
|
||||
@@ -5,7 +5,7 @@ lang: py
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def scrape_workana_projects(category: str = 'it-programming', language: str = 'es', extra_query: str = '', pages: int = 1, port: int = 9222, timeout_s: float = 20.0) -> dict"
|
||||
signature: "def scrape_workana_projects(category: str = 'it-programming', language: str = 'es', extra_query: str = '', pages: int = 1, port: int = 9334, timeout_s: float = 20.0) -> dict"
|
||||
description: "Scraper de proyectos freelance de Workana (https://www.workana.com/jobs) via Chrome DevTools Protocol (CDP). Workana es una SPA Vue: el GET HTTP NO trae los proyectos (0 cards en el HTML inicial), hay que renderizar con JS. Navega con un Chrome remoto, espera a que los cards monten async y extrae cada proyecto con un evaluador JS validado. Pieza 1 de un monitor de captacion de clientes: detecta proyectos freelance nuevos sin abrir el navegador a mano. Shape unificado con el scraper hermano de Upwork. Devuelve un dict con count + lista de proyectos; nunca lanza ni inventa datos."
|
||||
tags: [market-intel, recon, flow-replay, browser, cdp, workana, scraper, freelance, spa, vue, captacion]
|
||||
uses_functions: ["cdp_open_url_and_wait_py_pipelines", "cdp_eval_py_browser"]
|
||||
@@ -24,7 +24,7 @@ params:
|
||||
- name: pages
|
||||
desc: "Numero de paginas de listado a recorrer. Default 1. Cada pagina adicional se navega con &page=N."
|
||||
- name: port
|
||||
desc: "Puerto de remote debugging del Chrome a usar. Default 9222 (chromium-personal de produccion). Para un Chrome aislado (smoke / recon sin mezclar sesion personal) apuntar a 9333 (el del browser_mcp)."
|
||||
desc: "Puerto de remote debugging del Chrome a usar. Default 9334 (perfil headless dedicado del scraping, ~/.config/fn_scrape_chrome, que levanta y cierra el wrapper monitor_freelance_projects_headless). NUNCA 9222 por defecto: ese es el chromium-personal del usuario y el scraping no debe abrir pestanas ahi. Para un Chrome aislado interactivo (smoke/recon) tambien sirve 9333 (browser_mcp)."
|
||||
- name: timeout_s
|
||||
desc: "Timeout (segundos) por pagina, tanto para la navegacion como para el polling de aparicion de cards. Default 20.0."
|
||||
output: "dict siempre (nunca lanza). En exito: {status:'ok', source:'workana', count:N, projects:[{...}]}. Cada project_dict con claves EXACTAS: source ('workana'), job_id (slug), url (absoluta), title, budget (str|None), posted (str ej 'Hace 4 horas'), bids (str|None nº propuestas), skills (list[str]), snippet (str), country (str|None), scraped_at (ISO8601 UTC). En error (sin cards tras timeout, Chrome muerto, DOM cambiado): {status:'error', error:<mensaje claro>, source:'workana', projects:[]}. NUNCA devuelve filas falsas."
|
||||
@@ -40,17 +40,17 @@ file_path: "python/functions/browser/scrape_workana_projects.py"
|
||||
# fn run mapea args POSICIONALMENTE a la firma (category language extra_query pages port timeout_s).
|
||||
# NO uses flags --category/--language con fn run: el runner los toma como valores posicionales.
|
||||
|
||||
# Smoke contra el Chrome aislado del browser_mcp (port 9333, sin login):
|
||||
fn run scrape_workana_projects it-programming es "" 1 9333 25
|
||||
# Perfil headless dedicado (port 9334, lo levanta el wrapper monitor_freelance_projects_headless):
|
||||
fn run scrape_workana_projects it-programming es "" 1 9334 25
|
||||
|
||||
# Produccion (chromium-personal, port 9222 por defecto):
|
||||
fn run scrape_workana_projects it-programming es "" 1 9222 20
|
||||
# Smoke contra el Chrome aislado interactivo del browser_mcp (port 9333, sin login):
|
||||
fn run scrape_workana_projects it-programming es "" 1 9333 25
|
||||
```
|
||||
|
||||
```bash
|
||||
# Ejecucion directa del modulo SI acepta flags --... (argparse del __main__):
|
||||
python/.venv/bin/python3 python/functions/browser/scrape_workana_projects.py \
|
||||
--category it-programming --language es --port 9222
|
||||
--category it-programming --language es --port 9334
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -78,9 +78,12 @@ porque la pagina es una SPA Vue que monta los cards en runtime.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere un Chrome con remote debugging vivo en `port`**: 9222 (chromium-personal
|
||||
de produccion, ya activado global) o 9333 (Chrome aislado del browser_mcp). Sin
|
||||
Chrome escuchando devuelve `{status:'error', error:'no hay Chrome en el puerto N...'}` — no lanza.
|
||||
- **Requiere un Chrome con remote debugging vivo en `port`**: por defecto 9334 (el
|
||||
perfil headless dedicado del scraping, que levanta/cierra el wrapper
|
||||
`monitor_freelance_projects_headless`). NO usa 9222 (chromium-personal del usuario)
|
||||
por defecto: el scraping no abre pestanas en el navegador diario. 9333 (browser_mcp)
|
||||
sirve para smoke interactivo. Sin Chrome escuchando devuelve
|
||||
`{status:'error', error:'no hay Chrome en el puerto N...'}` — no lanza.
|
||||
- **Workana es una SPA Vue: los cards montan ASYNC** tras la hidratacion. El load
|
||||
event NO garantiza que esten en el DOM, por eso la funcion hace polling de
|
||||
`document.querySelectorAll('div.project-item.js-project').length` hasta >0 o timeout.
|
||||
|
||||
@@ -198,7 +198,7 @@ def scrape_workana_projects(
|
||||
language: str = "es",
|
||||
extra_query: str = "",
|
||||
pages: int = 1,
|
||||
port: int = 9222,
|
||||
port: int = 9334,
|
||||
timeout_s: float = 20.0,
|
||||
) -> dict:
|
||||
"""Scrapea proyectos freelance de Workana renderizando la SPA via CDP.
|
||||
@@ -217,9 +217,12 @@ def scrape_workana_projects(
|
||||
filtrar por palabra clave (ej. "python", "scraping").
|
||||
pages: Numero de paginas de listado a recorrer (1 por defecto). Cada pagina
|
||||
adicional se navega con &page=N.
|
||||
port: Puerto de remote debugging del Chrome a usar. Default 9222 (el
|
||||
chromium-personal de produccion). Para un Chrome aislado (smoke / recon
|
||||
sin mezclar sesion personal) apunta a 9333 (el del browser_mcp).
|
||||
port: Puerto de remote debugging del Chrome a usar. Default 9334 (el
|
||||
perfil headless dedicado del scraping, ~/.config/fn_scrape_chrome, que
|
||||
levanta y cierra el wrapper monitor_freelance_projects_headless). NUNCA
|
||||
9222 por defecto: ese es el chromium-personal del usuario y el scraping
|
||||
no debe abrir pestanas ahi. Para un Chrome aislado interactivo (smoke /
|
||||
recon) tambien sirve 9333 (el del browser_mcp).
|
||||
timeout_s: Timeout (segundos) por pagina, tanto para la navegacion como para
|
||||
el polling de aparicion de cards. Default 20.0.
|
||||
|
||||
@@ -293,7 +296,7 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--language", default="es")
|
||||
parser.add_argument("--extra-query", default="")
|
||||
parser.add_argument("--pages", type=int, default=1)
|
||||
parser.add_argument("--port", type=int, default=9222)
|
||||
parser.add_argument("--port", type=int, default=9334)
|
||||
parser.add_argument("--timeout-s", type=float, default=20.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ name: depth_to_relief_glb
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220) -> dict"
|
||||
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth."
|
||||
signature: "def depth_to_relief_glb(image: Image.Image, depth: np.ndarray, out_glb_path: str, z_scale: float = 0.35, max_dim: int = 220, mask: np.ndarray | None = None) -> dict"
|
||||
description: "Construye una malla de relieve (heightmap) texturizada a partir de un mapa de profundidad + la imagen original y la exporta como glTF binario (.glb). El depth se vuelve el eje Z de un grid regular de vertices y la imagen se mapea como textura UV. Con mask opcional recorta la malla al objeto (descarta las caras del fondo). Paso 2 del flujo img->3D (grupo img-to-3d): consume la salida de estimate_image_depth y, opcionalmente, la mask de remove_background."
|
||||
tags: [img-to-3d, datascience, mesh, glb, gltf, relief, heightmap, trimesh, 3d, texture]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
@@ -25,7 +25,9 @@ params:
|
||||
desc: "Amplitud del relieve como fraccion del lado de la malla (default 0.35). Mayor = relieve mas pronunciado/exagerado."
|
||||
- name: max_dim
|
||||
desc: "Lado maximo del grid tras downsample bilineal (default 220, ~48k vertices / ~96k caras). Controla resolucion de la malla vs tamano del .glb. Imagenes mayores se reducen; menores se dejan igual."
|
||||
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
|
||||
- name: mask
|
||||
desc: "Mascara opcional HxW (0..255, 255=objeto), tipicamente la 'mask' de remove_background. Si se pasa, se reescala al grid (NEAREST), el fondo se aplana a Z=0 y las caras cuyos tres vertices caen en el fondo se descartan: la malla queda recortada al objeto. None (default) = malla del frame completo (relieve incluido el fondo)."
|
||||
output: "dict. Exito: {status:'ok', glb_path:str, vertices:int, faces:int, height:int, width:int}. Con mask, 'faces' es menor (solo caras del objeto); 'vertices' no cambia (el grid completo se conserva). Error: {status:'error', error:str} (depth con forma invalida, directorio de salida inexistente, fallo de trimesh.export). No lanza."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
@@ -81,3 +83,14 @@ suavizar el relieve.
|
||||
- **Import plano**: importa el modulo directo, NO `from datascience import ...` (el `__init__` del
|
||||
paquete arrastra deps de otros dominios ausentes en el venv de vision). Ver misma gotcha en
|
||||
`estimate_image_depth`.
|
||||
- **mask opcional (v1.1.0)**: pasa la `mask` de `remove_background` para recortar la malla al
|
||||
objeto. Se reescala con NEAREST (sin interpolar, preserva el borde binario), el fondo se aplana
|
||||
a Z=0 y sus caras se eliminan. El nº de `vertices` no baja (el grid completo se conserva para no
|
||||
romper el mapeo UV 1:1); solo baja `faces`. Una mask degenerada (todo objeto) deja la malla
|
||||
intacta; una mask vacia (todo fondo) deja la malla sin caras (glb valido pero vacio).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-21) — anade parametro opcional `mask` para recortar la malla al objeto
|
||||
(descarta las caras del fondo), cerrando la cadena con `remove_background` del grupo img-to-3d.
|
||||
Aditivo: `mask=None` mantiene el comportamiento previo. Fiel al original de `backend/depth.py`.
|
||||
|
||||
@@ -22,6 +22,7 @@ def depth_to_relief_glb(
|
||||
out_glb_path: str,
|
||||
z_scale: float = 0.35,
|
||||
max_dim: int = 220,
|
||||
mask: "np.ndarray | None" = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Construye una malla de relieve texturizada y la exporta como .glb.
|
||||
@@ -33,6 +34,9 @@ def depth_to_relief_glb(
|
||||
z_scale: amplitud del relieve (fracción del lado de la malla). Default 0.35.
|
||||
max_dim: lado máximo del grid tras downsample (controla nº de vértices/caras).
|
||||
Default 220 (~48k vértices, ~96k caras).
|
||||
mask: máscara opcional HxW (0..255, 255 = objeto), típicamente la "mask" devuelta por
|
||||
remove_background. Si se pasa, el fondo se aplana y las caras cuyos vértices caigan
|
||||
en el fondo se descartan: la malla contiene solo el objeto, sin el plano de fondo.
|
||||
|
||||
Devuelve (dict, nunca lanza):
|
||||
Éxito: {"status": "ok", "glb_path": out_glb_path, "vertices": int, "faces": int,
|
||||
@@ -58,6 +62,14 @@ def depth_to_relief_glb(
|
||||
depth = np.asarray(depth_img, dtype=np.float32) / 255.0
|
||||
H, W = depth.shape
|
||||
|
||||
# Si se pasó máscara (objeto vs fondo), reescalarla al grid ya downsampleado: el fondo
|
||||
# no aporta relieve (se aplana a 0) y luego sus caras se descartan, dejando solo el objeto.
|
||||
fg = None
|
||||
if mask is not None:
|
||||
mask_img = Image.fromarray(np.asarray(mask).astype(np.uint8)).resize((W, H), Image.NEAREST)
|
||||
fg = np.asarray(mask_img) >= 128
|
||||
depth = np.where(fg, depth, 0.0).astype(np.float32)
|
||||
|
||||
# Coordenadas del grid: X corrige aspect ratio, Y hacia abajo, Z = profundidad.
|
||||
aspect = W / float(H)
|
||||
xs = np.linspace(-aspect / 2.0, aspect / 2.0, W, dtype=np.float32)
|
||||
@@ -79,6 +91,12 @@ def depth_to_relief_glb(
|
||||
]
|
||||
)
|
||||
|
||||
# Con máscara: conservar solo las caras cuyos tres vértices son objeto. La malla queda
|
||||
# recortada al objeto, sin el plano de fondo que deformaría el relieve.
|
||||
if fg is not None:
|
||||
keep = fg.ravel()[faces].all(axis=1)
|
||||
faces = faces[keep]
|
||||
|
||||
# UV mapeando cada vértice al pixel de la imagen (V invertido para convención glTF).
|
||||
u = np.linspace(0.0, 1.0, W, dtype=np.float32)
|
||||
v = np.linspace(0.0, 1.0, H, dtype=np.float32)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: remove_background
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def remove_background(image_path: str, engine: str = 'auto') -> dict"
|
||||
description: "Elimina el fondo de una imagen con cascada de motores (rembg/U2Net -> OpenCV GrabCut -> umbral NumPy), compone el objeto sobre fondo gris neutro y devuelve image+mask+engine. Paso de pre-proceso del flujo img->3D (grupo img-to-3d): su mask alimenta depth_to_relief_glb para recortar la malla de relieve al objeto."
|
||||
tags: [img-to-3d, datascience, background-removal, segmentation, rembg, grabcut, opencv, computer-vision, mask]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: image_path
|
||||
desc: "Ruta a la imagen de entrada. Cualquier formato que PIL.Image.open abra (jpg, png, webp, RGBA...). Si no existe o no es imagen valida, se devuelve status error. Un PNG RGBA ya recortado se reaprovecha en modo auto (passthrough:alpha)."
|
||||
- name: engine
|
||||
desc: "Motor de segmentacion. 'auto' (default) prueba en cascada rembg:u2net -> opencv:grabcut -> threshold:border y NUNCA falla (cae al umbral NumPy puro sin deps externas). Forzar uno: 'rembg' (red neuronal U2Net, mejor calidad, deps pesadas), 'grabcut' (OpenCV, rectangulo central), 'threshold' (distancia al color medio de los bordes, NumPy puro, objeto centrado). Si se fuerza un motor y no esta disponible/falla o produce mascara degenerada -> status error."
|
||||
output: "dict. Exito: {status:'ok', image: PIL.Image RGB del objeto compuesto sobre fondo gris neutro (127,127,127), mask: ndarray HxW uint8 (0..255, 255=objeto), engine: str del motor usado ('rembg:u2net' | 'opencv:grabcut' | 'threshold:border' | 'passthrough:alpha'), height:int, width:int, fg_fraction: float (fraccion de pixeles objeto, redondeada a 4 decimales)}. Error: {status:'error', error:str} (ruta invalida, motor desconocido, motor forzado no disponible/fallido, o ningun motor produjo una mascara valida). No lanza nunca. El demo CLI (__main__) imprime un resumen JSON sin el ndarray ni la imagen y, si se pasa out_dir, guarda rgb.png + mask.png."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/datascience/remove_background.py"
|
||||
source_file: "apps/img_to_3d_webapp/backend/bg_removal.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
# Requiere un venv con pillow + numpy (rembg/opencv solo si fuerzas esos motores; el umbral es NumPy puro).
|
||||
# Import PLANO al modulo: el paquete datascience.__init__ arrastra deps de otros dominios
|
||||
# (bs4, duckdb...) que no estan en ese venv. Ver Gotchas.
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/datascience")
|
||||
from remove_background import remove_background
|
||||
|
||||
res = remove_background("apps/img_to_3d_webapp/samples/cats.jpg", engine="auto")
|
||||
assert res["status"] == "ok"
|
||||
print(res["engine"]) # p.ej. "rembg:u2net" (o "opencv:grabcut" / "threshold:border")
|
||||
print(res["height"], res["width"]) # p.ej. 1024 768
|
||||
print(res["mask"].shape, res["mask"].dtype) # (1024, 768) uint8 (255=objeto)
|
||||
assert 0.0 < res["fg_fraction"] < 1.0
|
||||
# res["mask"] (ndarray HxW uint8) alimenta depth_to_relief_glb para recortar la malla al objeto.
|
||||
# res["image"] es el objeto compuesto sobre gris neutro, listo para estimar profundidad.
|
||||
```
|
||||
|
||||
Lanzable como demo (imprime resumen JSON, sin serializar el ndarray; guarda PNGs si das out_dir):
|
||||
|
||||
```bash
|
||||
./fn run remove_background_py_datascience apps/img_to_3d_webapp/samples/cats.jpg auto /tmp/cut
|
||||
# {"status": "ok", "engine": "rembg:u2net", "height": 1024, "width": 768,
|
||||
# "fg_fraction": 0.4123, "rgb_path": "/tmp/cut/rgb.png", "mask_path": "/tmp/cut/mask.png"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Como pre-proceso ANTES de estimar profundidad en el flujo img->3D: aislar el objeto evita que el
|
||||
modelo de profundidad estire el fondo plano, y la `mask` permite recortar la malla de relieve al
|
||||
objeto (se pasa a `depth_to_relief_glb`). Tambien para segmentacion de primer plano generica
|
||||
cuando necesitas separar un objeto de su fondo y componerlo sobre un color neutro (recortes para
|
||||
catalogos, datasets, miniaturas).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: segun el motor carga modelos neuronales y lee disco. `rembg`/`onnxruntime` (~170MB)
|
||||
DESCARGA el modelo U2Net la primera vez a su cache (`~/.u2net/`), requiere red en esa primera
|
||||
carga; `opencv-python` para GrabCut; el umbral (`threshold:border`) es NumPy puro sin deps externas.
|
||||
- **Estado de proceso**: `_REMBG_SESSION` cachea la sesion rembg a nivel de modulo para no recargar
|
||||
los pesos en cada llamada. Es estado mutable compartido del proceso y ocupa RAM hasta que el
|
||||
interprete muere.
|
||||
- **engine='auto' nunca lanza**: prueba rembg -> grabcut -> threshold y siempre cae al umbral NumPy
|
||||
puro si los anteriores no estan disponibles o fallan. Forzar un motor concreto SI puede devolver
|
||||
status error (motor no instalado, fallo, o mascara degenerada).
|
||||
- **Mascara degenerada**: si la fraccion de objeto resulta `< 0.01` o `> 0.995` la mascara se
|
||||
descarta (casi todo fondo o casi todo objeto) y en modo auto se prueba el siguiente motor.
|
||||
- **threshold:border es de baja calidad**: asume objeto centrado con los bordes de la imagen siendo
|
||||
fondo (calcula la distancia al color medio de los bordes). Es el fallback de ultimo recurso.
|
||||
- **passthrough:alpha**: si la imagen ya viene recortada (PNG RGBA con alfa por debajo de 128) se
|
||||
reutiliza su canal alfa como mascara, SOLO en modo auto. Si fuerzas un motor concreto se respeta
|
||||
esa eleccion e ignora el alfa existente.
|
||||
- **Import plano**: importa el modulo directo (`sys.path` a `python/functions/datascience` +
|
||||
`from remove_background import remove_background`), NO `from datascience import ...`. El
|
||||
`datascience.__init__` carga todo el dominio (scrapers con bs4, duckdb...) con deps ajenas a esta
|
||||
funcion que romperian el import del paquete en el venv de vision.
|
||||
- Nunca lanza: errores (ruta invalida, motor forzado no disponible, OOM) vuelven como
|
||||
`{status:'error', error:str}`.
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Eliminación de fondo de una imagen con cascada de motores (rembg -> GrabCut -> umbral).
|
||||
|
||||
Función del registry (grupo de capacidad `img-to-3d`, dominio `datascience`). Promovida desde
|
||||
la app `img_to_3d_webapp` (backend/bg_removal.py) para que cualquier artefacto pueda aislar el
|
||||
objeto de primer plano sin reimplementar la cascada de segmentación ni la composición sobre fondo
|
||||
neutro.
|
||||
|
||||
Impura: carga modelos neuronales (rembg/U2Net), usa GPU/CPU vía onnxruntime, lee disco y mantiene
|
||||
una caché de sesión rembg a nivel de proceso para no recargar los pesos en cada llamada. Las deps
|
||||
pesadas (rembg, opencv) se importan dentro de los helpers (lazy) para que el módulo se pueda
|
||||
importar sin ellas; el motor de umbral es NumPy puro sin deps externas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Fondo gris neutro sobre el que se compone el objeto recortado.
|
||||
NEUTRAL_BG = (127, 127, 127)
|
||||
# Umbral de alfa para considerar un PNG RGBA "ya recortado" (passthrough).
|
||||
_ALPHA_THRESH = 128
|
||||
# Sesión rembg cacheada a nivel de proceso (estado mutable: ver .md "Gotchas").
|
||||
_REMBG_SESSION = None
|
||||
|
||||
|
||||
def _existing_alpha_mask(image):
|
||||
"""Devuelve el canal alfa como máscara HxW uint8 si la imagen ya viene recortada, si no None."""
|
||||
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
|
||||
alpha = np.asarray(image.convert("RGBA"))[:, :, 3]
|
||||
if alpha.min() < _ALPHA_THRESH:
|
||||
return alpha
|
||||
return None
|
||||
|
||||
|
||||
def _composite_over_neutral(image_rgb, mask):
|
||||
"""Compone la imagen RGB sobre el fondo gris neutro usando la máscara como alfa."""
|
||||
rgb = np.asarray(image_rgb.convert("RGB"), dtype=np.float32)
|
||||
alpha = (mask.astype(np.float32) / 255.0)[:, :, None]
|
||||
bg = np.empty_like(rgb)
|
||||
bg[:] = NEUTRAL_BG
|
||||
out = rgb * alpha + bg * (1.0 - alpha)
|
||||
return Image.fromarray(out.clip(0, 255).astype(np.uint8), mode="RGB")
|
||||
|
||||
|
||||
def _remove_with_rembg(image):
|
||||
"""Segmenta con rembg (modelo U2Net). Devuelve (mask HxW uint8, engine_str)."""
|
||||
global _REMBG_SESSION
|
||||
from rembg import new_session, remove
|
||||
|
||||
if _REMBG_SESSION is None:
|
||||
_REMBG_SESSION = new_session("u2net")
|
||||
cut = remove(image.convert("RGB"), session=_REMBG_SESSION)
|
||||
mask = np.asarray(cut.convert("RGBA"))[:, :, 3]
|
||||
return mask, "rembg:u2net"
|
||||
|
||||
|
||||
def _remove_with_grabcut(image):
|
||||
"""Segmenta con OpenCV GrabCut (rectángulo central). Devuelve (mask HxW uint8, engine_str)."""
|
||||
import cv2
|
||||
|
||||
rgb = np.asarray(image.convert("RGB"))
|
||||
h, w = rgb.shape[:2]
|
||||
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
||||
gc_mask = np.zeros((h, w), np.uint8)
|
||||
bgd_model = np.zeros((1, 65), np.float64)
|
||||
fgd_model = np.zeros((1, 65), np.float64)
|
||||
margin_x, margin_y = int(0.08 * w), int(0.08 * h)
|
||||
rect = (margin_x, margin_y, max(1, w - 2 * margin_x), max(1, h - 2 * margin_y))
|
||||
cv2.grabCut(bgr, gc_mask, rect, bgd_model, fgd_model, 5, cv2.GC_INIT_WITH_RECT)
|
||||
fg = np.where((gc_mask == cv2.GC_FGD) | (gc_mask == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
|
||||
return fg, "opencv:grabcut"
|
||||
|
||||
|
||||
def _remove_with_threshold(image):
|
||||
"""Segmenta por distancia al color medio de los bordes (NumPy puro). Devuelve (mask, engine_str)."""
|
||||
rgb = np.asarray(image.convert("RGB"), dtype=np.float32)
|
||||
h, w = rgb.shape[:2]
|
||||
border = np.concatenate([rgb[0, :, :], rgb[-1, :, :], rgb[:, 0, :], rgb[:, -1, :]], axis=0)
|
||||
bg_color = border.mean(axis=0)
|
||||
dist = np.linalg.norm(rgb - bg_color, axis=2)
|
||||
thresh = max(30.0, float(dist.mean()))
|
||||
fg = (dist > thresh).astype(np.uint8) * 255
|
||||
return fg, "threshold:border"
|
||||
|
||||
|
||||
def remove_background(image_path: str, engine: str = "auto") -> dict:
|
||||
"""
|
||||
Elimina el fondo de una imagen y compone el objeto sobre un fondo gris neutro.
|
||||
|
||||
Parámetros:
|
||||
image_path: ruta a la imagen de entrada (cualquier formato que PIL abra).
|
||||
engine: "auto" (default) prueba rembg -> GrabCut -> umbral en cascada y NUNCA falla
|
||||
(cae al umbral NumPy puro sin deps externas); también admite forzar un motor concreto:
|
||||
"rembg", "grabcut" o "threshold". Si se fuerza un motor y no está disponible/falla,
|
||||
o la máscara resulta degenerada, se devuelve status error.
|
||||
|
||||
Devuelve (dict, nunca lanza):
|
||||
Éxito: {"status": "ok", "image": PIL.Image RGB del objeto compuesto sobre gris neutro,
|
||||
"mask": ndarray HxW uint8 (0..255, 255=objeto), "engine": str del motor usado
|
||||
("rembg:u2net" | "opencv:grabcut" | "threshold:border" | "passthrough:alpha"),
|
||||
"height": int, "width": int, "fg_fraction": float (fracción de píxeles objeto,
|
||||
redondeada a 4 decimales)}.
|
||||
Error: {"status": "error", "error": str} (ruta inválida, motor desconocido, motor forzado
|
||||
no disponible/fallido, o ningún motor produjo una máscara válida).
|
||||
"""
|
||||
try:
|
||||
image = Image.open(image_path)
|
||||
|
||||
# Passthrough: si la imagen ya viene recortada (PNG RGBA con alfa), reutiliza su alfa.
|
||||
# Solo en modo auto; si se fuerza un motor concreto se respeta esa elección.
|
||||
if engine == "auto":
|
||||
existing = _existing_alpha_mask(image)
|
||||
if existing is not None:
|
||||
composed = _composite_over_neutral(image, existing)
|
||||
frac = float((existing >= 128).mean())
|
||||
h, w = existing.shape[:2]
|
||||
return {
|
||||
"status": "ok",
|
||||
"image": composed,
|
||||
"mask": existing,
|
||||
"engine": "passthrough:alpha",
|
||||
"height": int(h),
|
||||
"width": int(w),
|
||||
"fg_fraction": round(frac, 4),
|
||||
}
|
||||
|
||||
# Construir la lista de motores a probar según el engine pedido.
|
||||
if engine == "auto":
|
||||
attempts = [_remove_with_rembg, _remove_with_grabcut, _remove_with_threshold]
|
||||
elif engine == "rembg":
|
||||
attempts = [_remove_with_rembg]
|
||||
elif engine == "grabcut":
|
||||
attempts = [_remove_with_grabcut]
|
||||
elif engine == "threshold":
|
||||
attempts = [_remove_with_threshold]
|
||||
else:
|
||||
attempts = []
|
||||
|
||||
if not attempts:
|
||||
return {"status": "error", "error": f"Motor desconocido: {engine!r}"}
|
||||
|
||||
last_exc = None
|
||||
for attempt in attempts:
|
||||
try:
|
||||
mask, used = attempt(image)
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_exc = e
|
||||
continue
|
||||
|
||||
# Rechazar máscaras degeneradas (casi todo fondo o casi todo objeto).
|
||||
frac = float((mask >= 128).mean())
|
||||
if frac < 0.01 or frac > 0.995:
|
||||
last_exc = f"mascara degenerada (fg_fraction={round(frac, 4)}) con {used}"
|
||||
continue
|
||||
|
||||
composed = _composite_over_neutral(image, mask)
|
||||
h, w = mask.shape[:2]
|
||||
return {
|
||||
"status": "ok",
|
||||
"image": composed,
|
||||
"mask": mask,
|
||||
"engine": used,
|
||||
"height": int(h),
|
||||
"width": int(w),
|
||||
"fg_fraction": round(frac, 4),
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"No se pudo eliminar el fondo con engine={engine!r}: {last_exc}",
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Demo runner para `fn run remove_background_py_datascience <image_path> [engine] [out_dir]`.
|
||||
# Imprime un resumen JSON-serializable (el ndarray y la PIL.Image no se serializan).
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"status": "error", "error": "uso: <image_path> [engine] [out_dir]"}))
|
||||
sys.exit(1)
|
||||
|
||||
path = sys.argv[1]
|
||||
eng = sys.argv[2] if len(sys.argv) > 2 else "auto"
|
||||
out_dir = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
|
||||
res = remove_background(path, engine=eng)
|
||||
if res["status"] == "ok":
|
||||
summary = {
|
||||
"status": "ok",
|
||||
"engine": res["engine"],
|
||||
"height": res["height"],
|
||||
"width": res["width"],
|
||||
"fg_fraction": res["fg_fraction"],
|
||||
}
|
||||
if out_dir:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
rgb_path = os.path.join(out_dir, "rgb.png")
|
||||
mask_path = os.path.join(out_dir, "mask.png")
|
||||
res["image"].save(rgb_path)
|
||||
Image.fromarray(res["mask"]).save(mask_path)
|
||||
summary["rgb_path"] = rgb_path
|
||||
summary["mask_path"] = mask_path
|
||||
print(json.dumps(summary))
|
||||
else:
|
||||
print(json.dumps(res))
|
||||
sys.exit(1)
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)}
|
||||
@@ -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
|
||||
@@ -5,7 +5,7 @@ lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def monitor_freelance_projects(category: str = 'it-programming', language: str = 'es', query: str = '', pages: int = 1, include_upwork: bool = False, upwork_query: str = 'custom software', duckdb_path: str = '', xlsx_path: str = '', port: int = 9222, timeout_s: float = 25.0) -> dict"
|
||||
signature: "def monitor_freelance_projects(category: str = 'it-programming', language: str = 'es', query: str = '', pages: int = 1, include_upwork: bool = False, upwork_query: str = 'custom software', duckdb_path: str = '', xlsx_path: str = '', port: int = 9334, timeout_s: float = 25.0) -> dict"
|
||||
description: "Monitor de captacion de clientes freelance: scrapea proyectos nuevos de Workana (+ Upwork opcional) via CDP, los persiste en DuckDB con dedup por url, marca los de software a medida y exporta a Excel (hojas Nuevos y Todos)."
|
||||
tags: [market-intel, recon, launcher, pipelines, freelance, workana, upwork, duckdb, excel]
|
||||
uses_functions:
|
||||
@@ -42,7 +42,7 @@ params:
|
||||
- name: xlsx_path
|
||||
desc: "Ruta del .xlsx de salida. Si vacia, usa ~/.fn_freelance/freelance_projects.xlsx (crea el directorio). Se sobrescribe en cada corrida."
|
||||
- name: port
|
||||
desc: "Puerto de remote debugging del Chrome que usan los scrapers (CDP). Default 9222 (chromium-personal logueado). Usa 9333 para el Chrome aislado del browser_mcp."
|
||||
desc: "Puerto de remote debugging del Chrome que usan los scrapers (CDP). Default 9334 (perfil headless dedicado del scraping). NUNCA 9222 por defecto: ese es el chromium-personal del usuario. Para la corrida programada usa el wrapper monitor_freelance_projects_headless (levanta el Chrome headless en 9334 y lo cierra). 9333 = Chrome aislado interactivo del browser_mcp."
|
||||
- name: timeout_s
|
||||
desc: "Timeout en segundos por pagina para los scrapers (navegacion + espera de cards). Default 25.0."
|
||||
output: "dict. En exito: {status:'ok', new_count:int (proyectos nuevos de esta corrida), total_in_db:int, new_projects:[...], xlsx_path:'<abs>', duckdb_path:'<abs>', sources:{workana:{count,status}, upwork:{count,status}|'skipped'}}. En error (sin lanzar): {status:'error', error:str, sources:{...}}."
|
||||
@@ -51,11 +51,14 @@ output: "dict. En exito: {status:'ok', new_count:int (proyectos nuevos de esta c
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Requiere un Chrome con remote debugging vivo en el puerto indicado.
|
||||
# Produccion (chromium-personal logueado, port 9222) con los paths por defecto:
|
||||
# Para la corrida programada usa el wrapper headless (levanta Chrome en 9334 y lo
|
||||
# cierra): fn run monitor_freelance_projects_headless. Este pipeline asume que YA hay
|
||||
# un Chrome con remote debugging vivo en `port`.
|
||||
|
||||
# Contra el perfil headless dedicado (port 9334 por defecto), paths por defecto:
|
||||
fn run monitor_freelance_projects
|
||||
|
||||
# Probar contra el Chrome aislado del browser_mcp (port 9333) con paths efimeros:
|
||||
# Probar contra el Chrome aislado interactivo del browser_mcp (port 9333), paths efimeros:
|
||||
fn run monitor_freelance_projects --port 9333 \
|
||||
--duckdb-path /tmp/freelance.duckdb --xlsx-path /tmp/freelance.xlsx
|
||||
```
|
||||
@@ -88,8 +91,10 @@ oportunidades nuevas.
|
||||
|
||||
- **Requiere un Chrome con CDP vivo en `port`**: los scrapers (Workana/Upwork son
|
||||
SPAs) renderizan via Chrome DevTools Protocol. Sin remote debugging escuchando en
|
||||
ese puerto el pipeline devuelve `status:'error'` con el detalle. Produccion = 9222
|
||||
(chromium-personal logueado); Chrome aislado = 9333 (browser_mcp).
|
||||
ese puerto el pipeline devuelve `status:'error'` con el detalle. Por defecto 9334
|
||||
(perfil headless dedicado, lo levanta/cierra `monitor_freelance_projects_headless`).
|
||||
NO usa 9222 (chromium-personal del usuario) por defecto. 9333 = browser_mcp para
|
||||
smoke interactivo.
|
||||
- **Upwork OFF por defecto**: sus selectores no estan validados en vivo (sin sesion
|
||||
Upwork). Con `include_upwork=True`, si Upwork devuelve `status:'error'` el pipeline
|
||||
loguea un WARN a stderr y sigue solo con Workana — nunca aborta por Upwork.
|
||||
|
||||
@@ -226,7 +226,7 @@ def monitor_freelance_projects(
|
||||
upwork_query: str = "custom software",
|
||||
duckdb_path: str = "",
|
||||
xlsx_path: str = "",
|
||||
port: int = 9222,
|
||||
port: int = 9334,
|
||||
timeout_s: float = 25.0,
|
||||
) -> dict:
|
||||
"""Detecta proyectos freelance nuevos, los persiste con dedup y exporta a Excel.
|
||||
@@ -262,7 +262,10 @@ def monitor_freelance_projects(
|
||||
xlsx_path: ruta del .xlsx de salida. Si "", usa
|
||||
~/.fn_freelance/freelance_projects.xlsx (creando el directorio).
|
||||
port: puerto de remote debugging del Chrome a usar por los scrapers.
|
||||
Default 9222 (chromium-personal logueado).
|
||||
Default 9334 (perfil headless dedicado del scraping). NUNCA 9222 por
|
||||
defecto: ese es el chromium-personal del usuario. Para la corrida
|
||||
programada usa el wrapper monitor_freelance_projects_headless, que
|
||||
levanta el Chrome headless en 9334 y lo cierra al terminar.
|
||||
timeout_s: timeout en segundos por pagina para los scrapers. Default 25.0.
|
||||
|
||||
Returns:
|
||||
@@ -454,7 +457,7 @@ def main() -> int:
|
||||
ap.add_argument("--upwork-query", default="custom software")
|
||||
ap.add_argument("--duckdb-path", default="")
|
||||
ap.add_argument("--xlsx-path", default="")
|
||||
ap.add_argument("--port", type=int, default=9222)
|
||||
ap.add_argument("--port", type=int, default=9334)
|
||||
ap.add_argument("--timeout-s", type=float, default=25.0)
|
||||
args = ap.parse_args()
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: monitor_freelance_projects_headless
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def monitor_freelance_projects_headless(category: str = 'it-programming', language: str = 'es', query: str = '', pages: int = 1, include_upwork: bool = False, upwork_query: str = 'custom software', duckdb_path: str = '', xlsx_path: str = '', port: int = 9334, profile_dir: str = '', timeout_s: float = 25.0) -> dict"
|
||||
description: "Monitor de captacion de clientes freelance (Workana + Upwork -> DuckDB + Excel) en un Chrome headless AISLADO con perfil dedicado, lanzandolo y cerrandolo en cada corrida. Evita abrir pestanas en el navegador diario del usuario (chromium-personal, CDP 9222). Wrapper de monitor_freelance_projects que solo gestiona el ciclo de vida del navegador. Proyecto captacion_clientes."
|
||||
tags: [market-intel, captacion_clientes, headless, cdp, freelance, scraper, recon]
|
||||
uses_functions: [monitor_freelance_projects_py_pipelines]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/pipelines/monitor_freelance_projects_headless.py"
|
||||
params:
|
||||
- name: category
|
||||
desc: "Categoria de Workana (?category=). Default 'it-programming'."
|
||||
- name: language
|
||||
desc: "Idioma de los proyectos de Workana (?language=). Default 'es'."
|
||||
- name: query
|
||||
desc: "Query libre aplicada a ambas fuentes (extra_query en Workana; sobrescribe upwork_query en Upwork si no esta vacia). Default vacio."
|
||||
- name: pages
|
||||
desc: "Numero de paginas de listado a recorrer por fuente. Default 1."
|
||||
- name: include_upwork
|
||||
desc: "Si True, scrapea Upwork ademas de Workana (tolerante a fallo). Default False (sus selectores no estan validados en vivo y requiere login)."
|
||||
- name: upwork_query
|
||||
desc: "Query para Upwork cuando include_upwork. Default 'custom software'. `query` lo sobrescribe si se pasa."
|
||||
- name: duckdb_path
|
||||
desc: "Ruta del archivo DuckDB de persistencia con dedup por url. Vacio -> ~/.fn_freelance/freelance.duckdb (se crea el directorio)."
|
||||
- name: xlsx_path
|
||||
desc: "Ruta del .xlsx de salida (hojas 'Nuevos' y 'Todos'). Vacio -> ~/.fn_freelance/freelance_projects.xlsx (se crea el directorio)."
|
||||
- name: port
|
||||
desc: "Puerto de remote-debugging del Chrome headless aislado que este wrapper lanza y al que apunta el monitor. Default 9334 (NO el 9222 del navegador diario)."
|
||||
- name: profile_dir
|
||||
desc: "user-data-dir dedicado del Chrome aislado. Vacio -> ~/.config/fn_scrape_chrome (se crea si no existe). Perfil persistente entre corridas."
|
||||
- name: timeout_s
|
||||
desc: "Timeout en segundos por pagina para los scrapers. Default 25.0."
|
||||
output: "dict que SIEMPRE incluye {status: 'ok'|'error', port, profile_dir, launched: bool, closed: bool} y, en exito, las claves del resultado de monitor_freelance_projects (new_count, total_in_db, new_projects, xlsx_path, duckdb_path, sources). En error sin lanzar incluye `error`. El finally cierra siempre la instancia que lanzo (closed=True); si reutiliza un CDP ya vivo en el puerto, launched=False y closed=False (no cierra lo ajeno). Nunca lanza excepcion al caller."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Monitor freelance en Chrome headless aislado (lanzar -> scrape -> cerrar).
|
||||
# OJO: fn run pasa los args POSICIONALES, en el orden de la firma:
|
||||
# category, language, query, pages, ...
|
||||
fn run monitor_freelance_projects_headless it-programming es "" 1
|
||||
# -> {"status":"ok","port":9334,"profile_dir":"/home/<user>/.config/fn_scrape_chrome",
|
||||
# "launched":true,"closed":true,"new_count":N,"total_in_db":M,
|
||||
# "xlsx_path":"/home/<user>/.fn_freelance/freelance_projects.xlsx",
|
||||
# "duckdb_path":"/home/<user>/.fn_freelance/freelance.duckdb",
|
||||
# "sources":{"workana":{"count":N,"status":"ok"},"upwork":"skipped"}}
|
||||
```
|
||||
|
||||
Invocacion directa del modulo (acepta flags `--category`/`--language`/`--pages`/...):
|
||||
|
||||
```bash
|
||||
python/.venv/bin/python3 python/functions/pipelines/monitor_freelance_projects_headless.py \
|
||||
--category it-programming --language es --pages 2
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala para la ingesta diaria/programada (dag_engine) del monitor de captacion freelance del
|
||||
proyecto captacion_clientes cuando NO quieras que el scraping abra pestanas en tu navegador
|
||||
diario. Levanta su propio Chromium headless con perfil dedicado (puerto 9334) y lo cierra al
|
||||
terminar — el navegador personal (`chromium-personal`, CDP 9222) queda intacto. Es el
|
||||
reemplazo de llamar `monitor_freelance_projects` con `--port 9222` a pelo (que usaria el
|
||||
navegador interactivo logueado).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: lanza y mata Chrome.** Arranca un Chromium headless via `systemd-run --user`
|
||||
(scope `fnscrape_dag_<port>`); si `systemd-run` no esta, cae a `subprocess.Popen` con grupo
|
||||
de proceso propio. Lanzarlo con `exec` directo desde el agente da **exit-144** — por eso
|
||||
systemd-run. En el `finally` siempre cierra lo que lanzo (`systemctl --user stop` del
|
||||
scope/service + respaldo `pkill -f "user-data-dir=<perfil>"`) y verifica con un GET final
|
||||
que el puerto ya no responde (`closed`).
|
||||
- **Perfil dedicado persistente.** `~/.config/fn_scrape_chrome` sobrevive entre corridas
|
||||
(cookies/cache del scraping). No se borra. Borralo a mano si quieres sesion limpia.
|
||||
- **Reutiliza CDP existente.** Si el puerto ya responde al arrancar, NO lanza otro Chrome:
|
||||
reutiliza el vivo y `launched=False` + `closed=False` (no cierra algo que no abrio).
|
||||
- **Workana puede cambiar selectores o bloquear.** Workana es una SPA Vue: si cambia sus
|
||||
selectores o aplica anti-bot, el monitor devuelve `status: error` (sin inventar datos),
|
||||
pero el Chrome aislado **igual se cierra** en el finally. Upwork esta en `skipped` por
|
||||
defecto (selectores no validados en vivo + login).
|
||||
@@ -0,0 +1,335 @@
|
||||
"""monitor_freelance_projects_headless — monitor freelance en un Chrome headless aislado.
|
||||
|
||||
Wrapper de `monitor_freelance_projects` (pipeline del proyecto captacion_clientes) que lanza
|
||||
un Chromium **headless** con un **perfil dedicado** y un puerto de remote-debugging propio,
|
||||
corre el monitor de proyectos freelance apuntando a ESE puerto, y **cierra la instancia al
|
||||
terminar** — siempre, incluso si el scraping falla.
|
||||
|
||||
Motivo: el scraping NO debe abrir pestañas en el navegador diario del usuario
|
||||
(`chromium-personal`, puerto 9222). Norma: perfil dedicado + headless + cerrar al terminar.
|
||||
|
||||
El Chrome se lanza vía `systemd-run --user` (un scope transitorio), porque lanzar chromium
|
||||
con un `exec`/`Popen` directo desde el proceso del agente da exit-144 cuando hereda el grupo
|
||||
de control del agente. Si `systemd-run` no está disponible, se cae a `subprocess.Popen` en un
|
||||
grupo de proceso nuevo (`start_new_session=True`).
|
||||
|
||||
A diferencia de `ingest_market_trends_headless` (que itera fuentes CDP), este wrapper llama
|
||||
UNA sola vez al pipeline `monitor_freelance_projects`, pasándole el puerto del Chrome aislado.
|
||||
El pipeline scrapea Workana (y opcionalmente Upwork) por CDP, deduplica en DuckDB y exporta a
|
||||
Excel; este wrapper solo gestiona el ciclo de vida del navegador.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
sys.path.insert(0, os.path.join(ROOT, "python", "functions"))
|
||||
|
||||
from pipelines.monitor_freelance_projects import monitor_freelance_projects # noqa: E402
|
||||
|
||||
DEFAULT_PORT = 9334
|
||||
DEFAULT_PROFILE = "~/.config/fn_scrape_chrome"
|
||||
|
||||
# Candidatos de binario chromium/chrome. shutil.which primero (respeta PATH), luego
|
||||
# rutas absolutas conocidas del sistema (el `chromium` del usuario suele ser un alias de
|
||||
# shell no visible a subprocess, y el binario real vive en /usr/lib/chromium/chromium).
|
||||
_CHROME_NAMES = ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable")
|
||||
_CHROME_ABS = (
|
||||
"/usr/bin/chromium",
|
||||
"/usr/lib/chromium/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/snap/bin/chromium",
|
||||
)
|
||||
|
||||
|
||||
def _find_chrome() -> str | None:
|
||||
"""Devuelve la ruta a un binario chromium/chrome ejecutable, o None."""
|
||||
for name in _CHROME_NAMES:
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
for path in _CHROME_ABS:
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _cdp_alive(port: int, timeout: float = 1.0) -> bool:
|
||||
"""True si el endpoint CDP responde en 127.0.0.1:<port>/json/version."""
|
||||
url = f"http://127.0.0.1:{port}/json/version"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
||||
return 200 <= resp.status < 300
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _wait_cdp(port: int, deadline_s: float = 12.0) -> bool:
|
||||
"""Espera a que el CDP responda hasta `deadline_s` (sondea cada 0.5s)."""
|
||||
end = time.time() + deadline_s
|
||||
while time.time() < end:
|
||||
if _cdp_alive(port):
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def _chrome_args(chrome_bin: str, port: int, profile_dir: str) -> list[str]:
|
||||
return [
|
||||
chrome_bin,
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
f"--remote-debugging-port={port}",
|
||||
f"--user-data-dir={profile_dir}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--remote-allow-origins=*",
|
||||
"--disable-extensions",
|
||||
]
|
||||
|
||||
|
||||
def _launch(chrome_bin: str, port: int, profile_dir: str) -> tuple[str, int | None]:
|
||||
"""Lanza Chrome headless aislado. Devuelve (mecanismo, pid).
|
||||
|
||||
mecanismo: 'systemd' (scope transitorio) o 'popen' (grupo de proceso propio).
|
||||
pid: solo poblado en modo 'popen' (para poder matar el grupo en el cierre).
|
||||
"""
|
||||
unit = f"fnscrape_dag_{port}"
|
||||
systemd_run = shutil.which("systemd-run")
|
||||
if systemd_run:
|
||||
cmd = [
|
||||
systemd_run, "--user", "--quiet", "--collect", f"--unit={unit}",
|
||||
*_chrome_args(chrome_bin, port, profile_dir),
|
||||
]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, timeout=15,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return "systemd", None
|
||||
except Exception: # noqa: BLE001
|
||||
# systemd-run falló (sin --user bus, etc.) -> fallback a Popen.
|
||||
pass
|
||||
|
||||
proc = subprocess.Popen(
|
||||
_chrome_args(chrome_bin, port, profile_dir),
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return "popen", proc.pid
|
||||
|
||||
|
||||
def _close(mechanism: str, pid: int | None, port: int, profile_dir: str) -> bool:
|
||||
"""Cierra la instancia que ESTE wrapper lanzó. Devuelve True si el puerto ya no responde."""
|
||||
unit = f"fnscrape_dag_{port}"
|
||||
if mechanism == "systemd":
|
||||
systemctl = shutil.which("systemctl")
|
||||
if systemctl:
|
||||
for kind in (f"{unit}.scope", f"{unit}.service"):
|
||||
try:
|
||||
subprocess.run([systemctl, "--user", "stop", kind],
|
||||
timeout=10, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
elif mechanism == "popen" and pid is not None:
|
||||
try:
|
||||
pgid = os.getpgid(pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
for _ in range(20): # hasta ~2s para salida limpia
|
||||
time.sleep(0.1)
|
||||
if not _cdp_alive(port):
|
||||
break
|
||||
if _cdp_alive(port):
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# Respaldo: matar cualquier chromium colgado de este perfil concreto.
|
||||
pkill = shutil.which("pkill")
|
||||
if pkill:
|
||||
try:
|
||||
subprocess.run([pkill, "-f", f"user-data-dir={profile_dir}"],
|
||||
timeout=10, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# Esperar a que el puerto deje de responder (cierre asíncrono del cgroup).
|
||||
for _ in range(20): # hasta ~2s
|
||||
if not _cdp_alive(port):
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return not _cdp_alive(port)
|
||||
|
||||
|
||||
def monitor_freelance_projects_headless(
|
||||
category: str = "it-programming",
|
||||
language: str = "es",
|
||||
query: str = "",
|
||||
pages: int = 1,
|
||||
include_upwork: bool = False,
|
||||
upwork_query: str = "custom software",
|
||||
duckdb_path: str = "",
|
||||
xlsx_path: str = "",
|
||||
port: int = DEFAULT_PORT,
|
||||
profile_dir: str = "",
|
||||
timeout_s: float = 25.0,
|
||||
) -> dict:
|
||||
"""Lanza un Chrome headless aislado, corre el monitor freelance y lo cierra al terminar.
|
||||
|
||||
Pipeline IMPURO: arranca su propio Chromium headless con perfil dedicado, ejecuta
|
||||
`monitor_freelance_projects` apuntando a ESE puerto, y en el `finally` cierra la
|
||||
instancia que lanzó. Nunca abre pestañas en el navegador diario del usuario
|
||||
(`chromium-personal`, CDP 9222). NUNCA lanza excepción al caller: cualquier fallo se
|
||||
refleja en `status`/`error` y el navegador se cierra igual.
|
||||
|
||||
Args:
|
||||
category: categoría de Workana (?category=). Default "it-programming".
|
||||
language: idioma de los proyectos de Workana (?language=). Default "es".
|
||||
query: query libre aplicada a ambas fuentes (extra_query en Workana; sobrescribe
|
||||
upwork_query en Upwork si no está vacía).
|
||||
pages: número de páginas de listado a recorrer por fuente. Default 1.
|
||||
include_upwork: si True, scrapea Upwork además de Workana. Default False.
|
||||
upwork_query: query para Upwork cuando include_upwork. Default "custom software".
|
||||
duckdb_path: ruta del archivo DuckDB. Vacío -> ~/.fn_freelance/freelance.duckdb.
|
||||
xlsx_path: ruta del .xlsx de salida. Vacío -> ~/.fn_freelance/freelance_projects.xlsx.
|
||||
port: puerto de remote-debugging del Chrome headless aislado. Default 9334.
|
||||
profile_dir: user-data-dir dedicado. Vacío -> ~/.config/fn_scrape_chrome.
|
||||
timeout_s: timeout en segundos por página para los scrapers. Default 25.0.
|
||||
|
||||
Returns:
|
||||
dict que SIEMPRE incluye {status, port, profile_dir, launched, closed} y, en éxito,
|
||||
las claves del resultado de `monitor_freelance_projects` (new_count, total_in_db,
|
||||
new_projects, xlsx_path, duckdb_path, sources, ...). En error sin lanzar incluye
|
||||
`error`. El finally cierra siempre la instancia que lanzó (no la que reutiliza).
|
||||
"""
|
||||
if not profile_dir:
|
||||
profile_dir = os.path.expanduser(DEFAULT_PROFILE)
|
||||
profile_dir = os.path.abspath(os.path.expanduser(profile_dir))
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
|
||||
out: dict = {
|
||||
"status": "error",
|
||||
"port": port,
|
||||
"profile_dir": profile_dir,
|
||||
"launched": False,
|
||||
"closed": False,
|
||||
}
|
||||
|
||||
mechanism = ""
|
||||
pid: int | None = None
|
||||
reuse = False
|
||||
|
||||
# 1) Si ya hay un CDP vivo en el puerto, reutilizarlo (no lo cerraremos).
|
||||
if _cdp_alive(port):
|
||||
reuse = True
|
||||
else:
|
||||
chrome_bin = _find_chrome()
|
||||
if not chrome_bin:
|
||||
out["error"] = (
|
||||
"no se encontró binario chromium/chrome "
|
||||
f"(probados: {', '.join(_CHROME_NAMES)} + rutas absolutas conocidas)"
|
||||
)
|
||||
return out
|
||||
try:
|
||||
mechanism, pid = _launch(chrome_bin, port, profile_dir)
|
||||
out["launched"] = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
out["error"] = f"fallo al lanzar chromium: {exc}"
|
||||
return out
|
||||
|
||||
# 2) Esperar a que el CDP responda.
|
||||
if not _wait_cdp(port, deadline_s=12.0):
|
||||
out["error"] = f"el CDP no respondió en 127.0.0.1:{port} tras 12s"
|
||||
out["closed"] = _close(mechanism, pid, port, profile_dir)
|
||||
return out
|
||||
|
||||
# 3) Correr el monitor freelance contra el puerto del Chrome aislado.
|
||||
try:
|
||||
res = monitor_freelance_projects(
|
||||
category=category,
|
||||
language=language,
|
||||
query=query,
|
||||
pages=pages,
|
||||
include_upwork=include_upwork,
|
||||
upwork_query=upwork_query,
|
||||
duckdb_path=duckdb_path,
|
||||
xlsx_path=xlsx_path,
|
||||
port=port,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
if isinstance(res, dict):
|
||||
# Mezclar el resultado del monitor; las claves de lifecycle (status, port,
|
||||
# profile_dir, launched, closed) se restauran/recalculan abajo.
|
||||
out.update(res)
|
||||
else:
|
||||
out["error"] = f"monitor_freelance_projects devolvió un tipo inesperado: {type(res).__name__}"
|
||||
out["status"] = "error"
|
||||
except Exception as exc: # noqa: BLE001 — el wrapper nunca lanza al caller
|
||||
out["error"] = f"{type(exc).__name__}: {exc}"
|
||||
out["status"] = "error"
|
||||
finally:
|
||||
# 4) Restaurar las claves de lifecycle que `out.update(res)` pudo pisar.
|
||||
out["port"] = port
|
||||
out["profile_dir"] = profile_dir
|
||||
out["launched"] = bool(out.get("launched"))
|
||||
# 5) Cerrar SIEMPRE lo que nosotros lanzamos (no si reutilizamos uno ajeno).
|
||||
if out["launched"] and not reuse:
|
||||
out["closed"] = _close(mechanism, pid, port, profile_dir)
|
||||
else:
|
||||
out["closed"] = False
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Monitor de captacion freelance (Workana + Upwork -> DuckDB + Excel) en un "
|
||||
"Chrome headless AISLADO con perfil dedicado."
|
||||
)
|
||||
)
|
||||
ap.add_argument("--category", default="it-programming")
|
||||
ap.add_argument("--language", default="es")
|
||||
ap.add_argument("--query", default="")
|
||||
ap.add_argument("--pages", type=int, default=1)
|
||||
ap.add_argument("--include-upwork", action="store_true")
|
||||
ap.add_argument("--upwork-query", default="custom software")
|
||||
ap.add_argument("--duckdb-path", default="")
|
||||
ap.add_argument("--xlsx-path", default="")
|
||||
ap.add_argument("--port", type=int, default=DEFAULT_PORT,
|
||||
help="Puerto remote-debugging del Chrome aislado (default 9334).")
|
||||
ap.add_argument("--profile-dir", default="",
|
||||
help="user-data-dir dedicado (vacío -> ~/.config/fn_scrape_chrome).")
|
||||
ap.add_argument("--timeout-s", type=float, default=25.0)
|
||||
args = ap.parse_args()
|
||||
|
||||
result = monitor_freelance_projects_headless(
|
||||
category=args.category,
|
||||
language=args.language,
|
||||
query=args.query,
|
||||
pages=args.pages,
|
||||
include_upwork=args.include_upwork,
|
||||
upwork_query=args.upwork_query,
|
||||
duckdb_path=args.duckdb_path,
|
||||
xlsx_path=args.xlsx_path,
|
||||
port=args.port,
|
||||
profile_dir=args.profile_dir,
|
||||
timeout_s=args.timeout_s,
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0 if result.get("status") == "ok" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
|
||||
Generated
+31
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user