13 Commits

Author SHA1 Message Date
egutierrez f34badb500 Merge remote-tracking branch 'origin/master' 2026-06-23 17:49:49 +02:00
egutierrez 3289c67986 chore: auto-commit (2 archivos)
- .claude/settings.local.json
- cpp/framework/app_base.cpp

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 17:49:47 +02:00
egutierrez bcc1fe1738 feat(captacion_clientes): scraping freelance en perfil headless dedicado, no chromium-personal
El monitor de captación scrapeaba Workana sobre el navegador personal del
usuario (chromium-personal, CDP 9222), interfiriendo con su navegación. El
scraping CDP debe correr siempre en un perfil headless dedicado.

- Nuevo pipeline monitor_freelance_projects_headless: levanta un Chromium
  headless aislado con perfil dedicado (~/.config/fn_scrape_chrome, CDP 9334)
  vía systemd-run, ejecuta monitor_freelance_projects contra ese puerto y
  cierra la instancia al terminar (finally). Reutiliza el patrón de lifecycle
  de ingest_market_trends_headless. Reutiliza un CDP vivo si el puerto ya
  responde (no cierra lo ajeno).
- scrape_workana_projects y monitor_freelance_projects: default de `port`
  cambiado de 9222 (chromium-personal) a 9334 (perfil dedicado). Default seguro:
  correr a pelo sin Chrome en 9334 falla limpio, no contamina el 9222 personal.

Verificado: el wrapper arranca headless en 9334, scrapea 8 proyectos reales de
Workana, cierra la instancia (9334 muerto, sin proceso colgado) y deja el 9222
personal intacto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:11:26 +02:00
egutierrez 7619347be8 Merge orq/mcp-crud-ids: doc orchestration fleet_list identifica por pane_id (report 0008) 2026-06-22 12:07:54 +02:00
egutierrez f55e41cf74 docs(orchestration): fleet_list identifies agents by pane_id, not tmux_window
Reflects the orchestrator_mcp change: the MCP fleet_list payload now surfaces
pane_id ("%N", the stable per-pane id) and omits tmux_window ("@N"), which
migrates with the focus swap. Documents that focus/send-keys(nudge)/kill
resolve the live window on demand against tmux, and that the nudge reads
tmux_window from the fleetview binary (which keeps it as an internal field),
never from the MCP payload. The binary's list --json field list now mentions
pane_id as the identifier alongside the internal tmux_window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:03:50 +02:00
egutierrez e2e8669edf Merge orq/sql-connect: mssql_connect + mssql_query + run_mssql_query pipeline, grupo sql-connect (report 0007) 2026-06-22 11:32:47 +02:00
egutierrez 86d68dc9f0 feat(infra): conexion y consulta directa a SQL Server (Navision) via pymssql
Grupo de capacidad nuevo 'sql-connect' (3 funciones) para conectar a un
Microsoft SQL Server (donde corre Navision) y consultar directamente, en
lugar del ida y vuelta manual de pegar CSVs.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:29:49 +02:00
egutierrez b18759823d Merge orq/equal-skill: /equal espejo de requisitos (report 0005) 2026-06-22 11:21:31 +02:00
egutierrez a59d50238d feat(commands): añadir /equal — espejo de requisitos para confirmar alineación
Reformula la última tarea pedida de forma detallada y estructurada
(objetivo, alcance, entregables, supuestos, criterios de aceptación,
fuera de alcance, dudas) para que usuario y Claude confirmen alineación
antes de ejecutar. No ejecuta la tarea: solo refleja y pregunta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:19:30 +02:00
egutierrez f17d957a8f docs(orquestador): nombrar cada secundario (--title + goal del sidebar) para distinguirlos
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:19:21 +02:00
egutierrez c1f355ffa5 Merge orq/fleet-detect: detect_fleet_context ($TMUX) + spawn auto-detecta socket + hook CONTEXTO FLEET + doctrina (report 0041) 2026-06-21 21:55:11 +02:00
egutierrez 237f763c19 Merge orq/img3d-registry-funcs: promover remove_background al registry + mask en depth_to_relief_glb (grupo img-to-3d, report 0040) 2026-06-21 21:51:13 +02:00
egutierrez 3cf8b21fea feat(datascience): promover remove_background al registry + mask en depth_to_relief_glb (grupo img-to-3d)
Completa la promoción del flujo imagen->3D al registry (grupo de capacidad
img-to-3d), extraído de la app img_to_3d_webapp.

- remove_background_py_datascience (nueva): elimina el fondo con cascada
  rembg/U2Net -> OpenCV GrabCut -> umbral NumPy, compone el objeto sobre gris
  neutro y devuelve image + mask + engine. Impura, nunca lanza. Adaptada de
  backend/bg_removal.py con firma de ruta (image_path) y salida dict, demo CLI
  JSON-serializable.
- depth_to_relief_glb_py_datascience (v1.1.0): añade el parámetro opcional mask
  para recortar la malla de relieve al objeto (descarta las caras del fondo),
  cerrando la cadena con remove_background. Aditivo (mask=None = comportamiento
  previo), fiel al original de backend/depth.py.
- docs/capabilities/img-to-3d.md: incorpora remove_background como paso 0
  (pre-proceso), actualiza el flujo a 3 pasos encadenados, la tabla de funciones
  (4), el ejemplo end-to-end con mask y las deps (rembg/opencv).
- docs/capabilities/INDEX.md: conteo del grupo 3 -> 4.

Las dos funciones ya presentes (estimate_image_depth, depth_to_relief_glb) y el
pipeline build_relief_glb_from_image fueron promovidas en una ronda previa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:43:08 +02:00
29 changed files with 1890 additions and 50 deletions
+81
View File
@@ -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.
+13
View File
@@ -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**
+11 -2
View File
@@ -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 |
+2 -1
View File
@@ -8,7 +8,8 @@
},
"enabledMcpjsonServers": [
"registry",
"jupyter"
"jupyter",
"orchestrator"
],
"hooks": {
"PreToolUse": [
+18
View File
@@ -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
+2 -1
View File
@@ -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 |
+30 -16
View File
@@ -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).
+70
View File
@@ -0,0 +1,70 @@
# Capability: sql-connect
Conexión directa y consulta a un **Microsoft SQL Server** desde el registry, con el caso prioritario de **Navision** (el ERP corre sobre SQL Server). Las funciones Python usan el driver **pymssql** (más simple en Linux/WSL que pyodbc: trae FreeTDS embebido, no necesita ODBC driver manager).
Existe para **eliminar el ida y vuelta manual** con Navision: en vez de escribir una query, que el usuario la ejecute en su SGBD y pegue el CSV, estas funciones se conectan al servidor y devuelven las filas — iteración rápida sobre una query en un solo comando.
## Funciones
| ID | Firma | Que hace |
|---|---|---|
| `mssql_connect_py_infra` | `mssql_connect(host, database, user, password, port=1433, login_timeout=15, query_timeout=30) -> pymssql.Connection` | Abre una conexión a SQL Server vía pymssql. Credenciales por argumento (nunca hardcodeadas). `login_timeout` acota la fase de login para que un host inalcanzable no cuelgue. Devuelve la conexión abierta; el caller la cierra con `.close()`. Lanza `RuntimeError` claro (host:port/db) si falla. |
| `mssql_query_py_infra` | `mssql_query(conn, sql, params=None, max_rows=None) -> dict` | Ejecuta una SELECT parametrizada sobre una conexión abierta y mapea las filas a dicts. Binding seguro del driver (placeholders `%s`/`%(nombre)s`, sin inyección). Devuelve `{columns, rows:[{col:val}], row_count}`. 0 filas → lista vacía sin error. `max_rows` limita con `fetchmany`. Read-only (no commit), no cierra la conexión. |
| `run_mssql_query_py_pipelines` | `run_mssql_query(host, database, user, password, sql, params=None, port=1433, max_rows=None, login_timeout=15, query_timeout=30) -> dict` | **Pipeline one-shot**: compone `mssql_connect` + `mssql_query` y cierra siempre la conexión (try/finally). CLI imprime JSON o CSV. Para iterar sobre una query de Navision en un solo `fn run`. |
## Ejemplo canónico
One-shot para iterar sobre Navision (la contraseña se lee de una env var, nunca se pasa por la línea de comandos):
```bash
cd /home/egutierrez/fn_registry
MSSQL_PASSWORD=$(pass navision/password) \
./fn run run_mssql_query \
--host 10.0.0.5 --database navdb --user sa \
--sql "SELECT TOP 5 [No_], [Amount] FROM [dbo].[Cartera] WHERE [Customer No_] = %s" \
--param CLI-0001 \
--format csv
```
Conexión persistente para muchas queries seguidas (abrir una vez, consultar N veces):
```python
import os, sys
sys.path.insert(0, "python/functions")
from infra.mssql_connect import mssql_connect
from infra.mssql_query import mssql_query
conn = mssql_connect("10.0.0.5", "navdb", "sa", os.environ["MSSQL_PASSWORD"])
try:
abiertos = mssql_query(
conn,
"SELECT [No_], [Amount] FROM [dbo].[Cartera] WHERE [Open] = 1 AND [Customer No_] = %s",
params=("CLI-0001",),
)
print(abiertos["row_count"], abiertos["columns"])
posted = mssql_query(conn, "SELECT TOP 10 [Document No_], [Amount] FROM [dbo].[Posted Cartera]")
print(posted["rows"])
finally:
conn.close()
```
## Gotchas del grupo
- **Conectividad WSL2 → Windows**: el `host` debe ser la **IP LAN del Windows** que corre SQL Server, NO `localhost` (desde WSL2 localhost no alcanza al host Windows). Ver memoria `wsl2-localhost-forwarding`. Probablemente el servidor real de Navision no sea alcanzable desde un entorno aislado sin red a la oficina + credenciales.
- **Credenciales desde `pass`, nunca hardcodeadas.** Patrón: `MSSQL_PASSWORD=$(pass navision/password) ./fn run run_mssql_query ...`. La función recibe la contraseña como argumento; el caller la resuelve. `--password` literal existe pero queda visible en la lista de procesos — usa `--password-env`.
- **Placeholders pymssql** son `%s` (posicional) y `%(nombre)s` (nombrado), NO `?` (eso es pyodbc). Pasa los valores como `params`, jamás concatenados en el SQL (inyección).
- **`mssql_query` no abre ni cierra la conexión** — la toma prestada. Para ráfagas de queries, abre con `mssql_connect` una vez y reúsala; el pipeline `run_mssql_query` abre y cierra por llamada (cómodo, no eficiente en ráfaga).
- **Read-only por uso**: pensado para SELECT (Navision: cartera, posted cartera, movimientos). No hace commit.
- **Requiere `pymssql`** instalado en el venv (`uv add pymssql`). Import perezoso: el módulo carga sin la dependencia, pero la llamada falla con `RuntimeError` claro si falta.
- **Datos sintéticos en ejemplos** [POL-MMNSEG-001-1.0]: los `No_`/`Customer No_` de los ejemplos son ficticios. Sobre datos reales de Navision aplica la política de protección de datos.
## Fronteras
- **Solo SQL Server (Navision)**. No es una capa SQL genérica: para PostgreSQL usa el grupo `postgres`; para DuckDB el grupo `duckdb`. Generalizar a MySQL/otros engines sería especulativo (KISS) hasta que haya un caso real.
- **No es ETL ni BI**: solo conecta y devuelve filas. Para llevar datos de Navision a un destino analítico, compón con los grupos `duckdb`/`postgres` (cargar las filas) o léelas en un notebook.
- **No gestiona el servidor** (no crea bases, no administra logins). Solo cliente de lectura.
## Relación con otros grupos
- `postgres` / `duckdb` — capas CRUD para otros engines; mismo espíritu (conectar + consultar), distinto motor. SQL Server (Navision) es la fuente; esos son destinos analíticos/BI.
- `metabase` / `bigquery` — el trabajo Aurgi consume datos ya en BigQuery/Metabase; este grupo abre la puerta a leer Navision en origen para iterar queries antes de modelarlas.
@@ -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)
+81
View File
@@ -0,0 +1,81 @@
---
name: mssql_connect
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def mssql_connect(host: str, database: str, user: str, password: str, port: int = 1433, login_timeout: int = 15, query_timeout: int = 30) -> pymssql.Connection"
description: "Abre una conexion pymssql a un Microsoft SQL Server (donde corre Navision). Las credenciales llegan siempre por argumento (el caller las saca de pass/env), nunca hardcodeadas. login_timeout acota la fase de conexion/login para evitar cuelgues con un host inalcanzable. Devuelve el objeto conexion pymssql para iterar queries despues."
tags: [mssql, sqlserver, navision, sql-connect, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [pymssql]
params:
- name: host
desc: "Host o IP del servidor SQL Server. Desde WSL2 debe ser la IP LAN de Windows (ej. 10.0.0.5), no localhost."
- name: database
desc: "Nombre de la base de datos a la que conectar (ej. navdb)."
- name: user
desc: "Usuario de login de SQL Server (ej. sa)."
- name: password
desc: "Contrasena del usuario de login. Se pasa desde pass/env, nunca como literal."
- name: port
desc: "Puerto TCP del SQL Server. Por defecto 1433. La funcion lo convierte a string porque pymssql lo exige asi."
- name: login_timeout
desc: "Segundos permitidos para la fase de conexion/login antes de fallar. Por defecto 15. Evita que un host inalcanzable cuelgue indefinidamente."
- name: query_timeout
desc: "Segundos permitidos para cada query ejecutada sobre la conexion devuelta antes de hacer timeout. Por defecto 30."
output: "Un objeto pymssql.Connection abierto. El caller es responsable de cerrarlo con .close() al terminar."
tested: true
tests: ["test_golden_connect_passes_string_port_and_kwargs", "test_error_path_wraps_failure_with_host"]
test_file_path: "python/functions/infra/mssql_connect_test.py"
file_path: "python/functions/infra/mssql_connect.py"
---
## Ejemplo
```python
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
from infra.mssql_connect import mssql_connect
# La IP debe ser la IP LAN del servidor Windows: desde WSL2 "localhost" NO
# llega al host Windows. La contrasena llega del entorno, nunca literal.
conn = mssql_connect(
host="10.0.0.5",
database="navdb",
user="sa",
password=os.environ["MSSQL_PASSWORD"],
port=1433,
login_timeout=15,
)
try:
with conn.cursor() as cur:
cur.execute("SELECT TOP 1 name FROM sys.databases")
print(cur.fetchone())
finally:
conn.close()
```
## Cuando usarla
Usala cuando necesites abrir una conexion a un Microsoft SQL Server (donde
corre Navision) antes de iterar queries con `mssql_query`. Es el primer paso
de cualquier pipeline que lea datos de Navision: abre la conexion una vez,
reutilizala para varias queries, y cierrala al final. Triggers: "conecta a
Navision", "lee de SQL Server", "abre conexion mssql".
## Gotchas
- WSL2 -> Windows: usa la IP LAN del servidor Windows, NUNCA `localhost`. Desde dentro de WSL2 `localhost` no alcanza el host Windows (el reenvio de localhost solo funciona Windows -> WSL, no al reves).
- pymssql necesita el puerto como string. La funcion ya convierte `port` a `str(port)` internamente, asi que tu pasas un int normal.
- `login_timeout` esta acotado (15s por defecto) precisamente para que un host inalcanzable o mal configurado falle con un RuntimeError claro en vez de colgarse indefinidamente. Ajustalo si la red es lenta, pero no lo dejes sin limite.
- Credenciales NUNCA hardcodeadas: `user`/`password` llegan por argumento desde `pass`/env. No las escribas literales en el codigo del caller.
- Cierra la conexion con `.close()` al terminar (idealmente en un `finally`). La funcion devuelve un handle abierto y no gestiona su ciclo de vida.
- Requiere `pymssql` instalado en el venv (import perezoso: el modulo importa sin la dependencia, pero la llamada falla con RuntimeError claro si falta).
+65
View File
@@ -0,0 +1,65 @@
"""Open a connection to a Microsoft SQL Server (Navision) via pymssql."""
from __future__ import annotations
def mssql_connect(host: str, database: str, user: str, password: str,
port: int = 1433, login_timeout: int = 15,
query_timeout: int = 30):
"""Open a connection to a Microsoft SQL Server instance (e.g. Navision).
Uses the pymssql driver. Credentials are always supplied by the caller
(typically read from `pass`/env) and never hardcoded. The connection is
impure I/O: it touches the network and the database server.
pymssql expects the TCP port as a string, so `port` is converted before
being passed through. `login_timeout` bounds the connect/login phase, which
is what keeps an invalid host from hanging indefinitely; `query_timeout`
bounds individual queries run on the resulting connection.
Args:
host: SQL Server host or IP. From WSL2 this must be the Windows LAN IP
(e.g. "10.0.0.5"), not "localhost" — localhost does not reach the
Windows host from inside WSL2.
database: Name of the database to connect to (e.g. "navdb").
user: SQL Server login user (e.g. "sa").
password: Password for the login user. Pass it from `pass`/env, never
as a string literal.
port: TCP port of the SQL Server instance. Defaults to 1433. Converted
to a string internally because pymssql requires a string port.
login_timeout: Seconds allowed for the connect/login phase before it
fails. Defaults to 15. Keeps an unreachable host from hanging.
query_timeout: Seconds allowed for each query executed on the returned
connection before it times out. Defaults to 30.
Returns:
An open pymssql.Connection. The caller is responsible for closing it
with `.close()` when done.
Raises:
RuntimeError: If pymssql is not installed, or if the connection/login
fails. The message includes host:port and database for context and
the original exception is chained for debugging.
"""
# Lazy import so the module loads even without pymssql installed.
try:
import pymssql
except ImportError as exc: # pragma: no cover - exercised only without dep
raise RuntimeError(
"pymssql is required for mssql_connect; install pymssql"
) from exc
try:
return pymssql.connect(
server=host,
user=user,
password=password,
database=database,
port=str(port),
login_timeout=login_timeout,
timeout=query_timeout,
)
except Exception as exc:
raise RuntimeError(
f"mssql_connect failed connecting to {host}:{port}/{database}: {exc}"
) from exc
@@ -0,0 +1,59 @@
"""Tests for mssql_connect (mock-based, no real SQL Server)."""
from __future__ import annotations
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
from mssql_connect import mssql_connect
def test_golden_connect_passes_string_port_and_kwargs(monkeypatch):
"""Golden path: returns the driver connection and forwards the right kwargs.
The TCP port must reach pymssql as a STRING, and login_timeout must default
to 15 when not supplied.
"""
captured: dict = {}
sentinel = object()
def fake_connect(**kwargs):
captured.update(kwargs)
return sentinel
monkeypatch.setattr("pymssql.connect", fake_connect)
result = mssql_connect("10.0.0.5", "navdb", "sa", "pw", port=1433)
assert result is sentinel
assert captured["server"] == "10.0.0.5"
assert captured["database"] == "navdb"
assert captured["user"] == "sa"
assert captured["password"] == "pw"
assert captured["port"] == "1433"
assert isinstance(captured["port"], str)
assert captured["login_timeout"] == 15
assert captured["timeout"] == 30
def test_error_path_wraps_failure_with_host(monkeypatch):
"""Error path: a driver failure becomes a clear RuntimeError, not a hang.
The wrapped message must include the host and the phrase 'failed connecting'
so callers can diagnose connectivity problems.
"""
def fake_connect(**kwargs):
raise Exception("login timeout")
monkeypatch.setattr("pymssql.connect", fake_connect)
with pytest.raises(RuntimeError) as excinfo:
mssql_connect("10.0.0.5", "navdb", "sa", "pw", port=1433)
message = str(excinfo.value)
assert "10.0.0.5" in message
assert "failed connecting" in message
+78
View File
@@ -0,0 +1,78 @@
---
name: mssql_query
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def mssql_query(conn, sql: str, params=None, max_rows: int | None = None) -> dict"
description: "Ejecuta una SELECT parametrizada (binding seguro de pymssql, sin inyeccion) sobre una conexion SQL Server/Navision ya abierta y devuelve {columns, rows como lista de dicts, row_count}. Opcion max_rows para limitar las filas."
tags: [mssql, sqlserver, navision, sql-connect, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_golden_maps_rows_to_dicts", "test_binding_passes_params_to_driver", "test_zero_rows_no_error", "test_max_rows_uses_fetchmany", "test_description_none_empty_columns", "test_execution_error_raises_runtimeerror"]
test_file_path: "python/functions/infra/mssql_query_test.py"
params:
- name: conn
desc: "Conexion abierta (la que devuelve mssql_connect). No se abre ni cierra aqui; se reutiliza por duck typing via conn.cursor()."
- name: sql
desc: "Sentencia SELECT con placeholders pymssql %s (posicional) o %(nombre)s (nombrado) para los valores a vincular."
- name: params
desc: "Tuple/list para placeholders posicionales, dict para nombrados, o None. Se pasa a cursor.execute(sql, params) para binding seguro del driver (nunca interpolacion)."
- name: max_rows
desc: "Si es int>0, limita a las primeras max_rows filas (fetchmany). Si None, devuelve todas (fetchall)."
output: "Dict con tres claves: 'columns' (lista de nombres de columna en orden, vacia si no hubo result set), 'rows' (lista de dicts columna->valor, una por fila), 'row_count' (int len(rows))."
file_path: "python/functions/infra/mssql_query.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra.mssql_connect import mssql_connect
from infra.mssql_query import mssql_query
conn = mssql_connect(
host="10.0.0.5", database="navdb", user="readonly", password="<desde pass>"
)
try:
res = mssql_query(
conn,
"SELECT TOP 10 No_, Amount FROM [dbo].[Cartera] WHERE [Customer No_] = %s",
("CLI-0001",),
)
print(res["columns"]) # ['No_', 'Amount']
print(res["row_count"]) # numero de filas devueltas
for fila in res["rows"]:
print(fila["No_"], fila["Amount"])
finally:
conn.close()
```
## Cuando usarla
Cuando ya tienes una conexion abierta con `mssql_connect` y quieres iterar
consultas SELECT sobre Navision / SQL Server sin reabrir la conexion en cada
una. Pasa los valores variables como `params` para que el driver los vincule de
forma segura (sin inyeccion) en lugar de construir el SQL con f-strings.
## Gotchas
- Los placeholders de pymssql son `%s` (posicional) y `%(nombre)s` (nombrado),
NO el `?` de pyodbc. Si usas el placeholder equivocado, el binding falla.
- Pasa los valores SIEMPRE por el argumento `params`, jamas con f-string o `%`
dentro del SQL: interpolar abre la puerta a inyeccion SQL.
- No hace commit: es read-only, pensada para SELECT.
- No cierra la conexion — la gestiona el caller (abrir una vez, consultar
muchas, cerrar al final).
- `max_rows` usa `cursor.fetchmany(max_rows)`; con None usa `fetchall()`.
- Si la sentencia no produce result set (`cursor.description is None`),
`columns` y `rows` vuelven como listas vacias en lugar de fallar.
- El mensaje de error es generico a proposito: no incluye el SQL ni los params
para no filtrar datos sensibles.
+77
View File
@@ -0,0 +1,77 @@
"""Run a parameterized SELECT over an open pymssql (SQL Server / Navision) connection."""
from __future__ import annotations
def mssql_query(conn, sql: str, params=None, max_rows: int | None = None) -> dict:
"""Execute a SELECT on an already-open connection and map rows to dicts.
The connection is supplied by the caller (typically from `mssql_connect`),
so a single connection can be opened once and reused for many queries. This
function never opens or closes the connection — it only borrows it. It is
impure I/O: it touches the database over an existing connection.
Parameter binding is delegated to the driver: `params` is passed straight to
`cursor.execute(sql, params)`. NEVER interpolate values into `sql` with
f-strings or `%` formatting — that opens the door to SQL injection. Use the
pymssql placeholders `%s` (positional) or `%(name)s` (named) in `sql` and
let the driver bind safely. When `params is None`, the SQL is executed with
no bound parameters.
The query runs read-only: no commit is issued. The cursor opened here is
always closed before returning (try/finally), even on error.
Args:
conn: An open connection object (e.g. the one returned by
`mssql_connect`). Used by duck typing via `conn.cursor()`, so the
concrete driver does not matter and the function stays testable.
sql: The SELECT statement, using pymssql placeholders `%s` (positional)
or `%(name)s` (named) for any bound values.
params: A tuple/list for positional placeholders, a dict for named
placeholders, or None for a query with no parameters. Passed to
`cursor.execute(sql, params)` for safe driver-side binding.
max_rows: If a positive int, only the first `max_rows` rows are fetched
(via `cursor.fetchmany(max_rows)`). If None, all rows are fetched
(via `cursor.fetchall()`).
Returns:
A dict with three keys:
- "columns": list of column names in result order (empty list if the
statement produced no result set, i.e. `cursor.description is None`).
- "rows": list of dicts, one per row, mapping each column name to its
value. Empty list when the query returned no rows.
- "row_count": int, equal to `len(rows)`.
Raises:
RuntimeError: If executing or fetching the query fails. The message is
deliberately generic (it does not include the SQL or the params,
which may carry sensitive data) and the original exception is
chained for debugging.
"""
cur = conn.cursor()
try:
try:
if params is None:
cur.execute(sql)
else:
cur.execute(sql, params)
description = cur.description
if description is None:
columns: list = []
raw_rows: list = []
else:
columns = [d[0] for d in description]
if max_rows is not None and max_rows > 0:
raw_rows = cur.fetchmany(max_rows)
else:
raw_rows = cur.fetchall()
except Exception as exc:
raise RuntimeError(
f"mssql_query failed executing query: {exc}"
) from exc
finally:
cur.close()
rows = [dict(zip(columns, row)) for row in raw_rows]
return {"columns": columns, "rows": rows, "row_count": len(rows)}
+133
View File
@@ -0,0 +1,133 @@
"""Tests para mssql_query usando un doble de prueba (sin servidor real)."""
from __future__ import annotations
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from functions.infra.mssql_query import mssql_query
def _desc(*names):
"""Construye una description estilo DB-API: una tupla 7-elem por columna."""
return [(name, None, None, None, None, None, None) for name in names]
class FakeCursor:
"""Doble de prueba de un cursor DB-API (pymssql-like)."""
def __init__(self, description=None, rows=None):
self.description = description
self._rows = list(rows or [])
self.executed = None # (sql, params) de la ultima execute
self.fetchmany_calls = [] # tamaños pedidos a fetchmany
self.closed = False
def execute(self, sql, params=None):
self.executed = (sql, params)
def fetchall(self):
return list(self._rows)
def fetchmany(self, size):
self.fetchmany_calls.append(size)
return list(self._rows[:size])
def close(self):
self.closed = True
class FakeConn:
"""Doble de prueba de una conexion: devuelve un FakeCursor fijo."""
def __init__(self, cursor):
self._cursor = cursor
def cursor(self):
return self._cursor
def test_golden_maps_rows_to_dicts():
cur = FakeCursor(
description=_desc("No_", "Amount"),
rows=[("CLI-1", 100), ("CLI-2", 200)],
)
conn = FakeConn(cur)
result = mssql_query(conn, "SELECT No_, Amount FROM Cartera")
assert result == {
"columns": ["No_", "Amount"],
"rows": [
{"No_": "CLI-1", "Amount": 100},
{"No_": "CLI-2", "Amount": 200},
],
"row_count": 2,
}
assert cur.closed is True
def test_binding_passes_params_to_driver():
cur = FakeCursor(description=_desc("No_"), rows=[("CLI-0001",)])
conn = FakeConn(cur)
sql = "SELECT No_ FROM Cartera WHERE [Customer No_] = %s"
mssql_query(conn, sql, params=("CLI-0001",))
# El SQL y los params llegan al driver tal cual: binding, no interpolacion.
assert cur.executed == (sql, ("CLI-0001",))
def test_zero_rows_no_error():
cur = FakeCursor(description=_desc("No_", "Amount"), rows=[])
conn = FakeConn(cur)
result = mssql_query(conn, "SELECT No_, Amount FROM Cartera WHERE 1 = 0")
assert result["rows"] == []
assert result["row_count"] == 0
assert result["columns"] == ["No_", "Amount"]
def test_max_rows_uses_fetchmany():
cur = FakeCursor(
description=_desc("No_"),
rows=[("CLI-1",), ("CLI-2",), ("CLI-3",)],
)
conn = FakeConn(cur)
result = mssql_query(conn, "SELECT No_ FROM Cartera", max_rows=1)
assert cur.fetchmany_calls == [1]
assert result["row_count"] == 1
assert result["rows"] == [{"No_": "CLI-1"}]
def test_description_none_empty_columns():
cur = FakeCursor(description=None, rows=[])
conn = FakeConn(cur)
result = mssql_query(conn, "SET NOCOUNT ON")
assert result["columns"] == []
assert result["rows"] == []
assert result["row_count"] == 0
def test_execution_error_raises_runtimeerror():
class BoomCursor(FakeCursor):
def execute(self, sql, params=None):
raise ValueError("boom")
cur = BoomCursor()
conn = FakeConn(cur)
with pytest.raises(RuntimeError, match="mssql_query failed executing query"):
mssql_query(conn, "SELECT 1")
# El cursor se cierra incluso en error (try/finally).
assert cur.closed is True
@@ -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"
+1
View File
@@ -21,6 +21,7 @@ dependencies = [
"matplotlib>=3.10.9",
"openpyxl>=3.1.5",
"polars>=1.40.1",
"pymssql>=2.3.13",
"pypdf>=6.10.0",
"pyproj>=3.7.2",
"python-docx>=1.2.0",
+31
View File
@@ -902,6 +902,7 @@ dependencies = [
{ name = "matplotlib" },
{ name = "openpyxl" },
{ name = "polars" },
{ name = "pymssql" },
{ name = "pypdf" },
{ name = "pyproj" },
{ name = "python-docx" },
@@ -954,6 +955,7 @@ requires-dist = [
{ name = "matplotlib", specifier = ">=3.10.9" },
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "polars", specifier = ">=1.40.1" },
{ name = "pymssql", specifier = ">=2.3.13" },
{ name = "pypdf", specifier = ">=6.10.0" },
{ name = "pyproj", specifier = ">=3.7.2" },
{ name = "python-docx", specifier = ">=1.2.0" },
@@ -3625,6 +3627,35 @@ crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pymssql"
version = "2.3.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7a/cc/843c044b7f71ee329436b7327c578383e2f2499313899f88ad267cdf1f33/pymssql-2.3.13.tar.gz", hash = "sha256:2137e904b1a65546be4ccb96730a391fcd5a85aab8a0632721feb5d7e39cfbce", size = 203153, upload-time = "2026-02-14T05:00:36.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/60/a2e8a8a38f7be21d54402e2b3365cd56f1761ce9f2706c97f864e8aa8300/pymssql-2.3.13-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cf4f32b4a05b66f02cb7d55a0f3bcb0574a6f8cf0bee4bea6f7b104038364733", size = 3158689, upload-time = "2026-02-14T04:59:46.982Z" },
{ url = "https://files.pythonhosted.org/packages/43/9e/0cf0ffb9e2f73238baf766d8e31d7237b5bee3cc1bb29a376b404610994a/pymssql-2.3.13-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:2b056eb175955f7fb715b60dc1c0c624969f4d24dbdcf804b41ab1e640a2b131", size = 2960018, upload-time = "2026-02-14T04:59:48.668Z" },
{ url = "https://files.pythonhosted.org/packages/93/ea/bc27354feaca717faa4626911f6b19bb62985c87dda28957c63de4de5895/pymssql-2.3.13-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:319810b89aa64b99d9c5c01518752c813938df230496fa2c4c6dda0603f04c4c", size = 3065719, upload-time = "2026-02-14T04:59:50.369Z" },
{ url = "https://files.pythonhosted.org/packages/1e/7a/8028681c96241fb5fc850b87c8959402c353e4b83c6e049a99ffa67ded54/pymssql-2.3.13-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0ea72641cb0f8bce7ad8565dbdbda4a7437aa58bce045f2a3a788d71af2e4be", size = 3190567, upload-time = "2026-02-14T04:59:52.202Z" },
{ url = "https://files.pythonhosted.org/packages/aa/f1/ab5b76adbbd6db9ce746d448db34b044683522e7e7b95053f9dd0165297b/pymssql-2.3.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1493f63d213607f708a5722aa230776ada726ccdb94097fab090a1717a2534e0", size = 3710481, upload-time = "2026-02-14T04:59:54.01Z" },
{ url = "https://files.pythonhosted.org/packages/59/aa/2fa0951475cd0a1829e0b8bfbe334d04ece4bce11546a556b005c4100689/pymssql-2.3.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb3275985c23479e952d6462ae6c8b2b6993ab6b99a92805a9c17942cf3d5b3d", size = 3453789, upload-time = "2026-02-14T04:59:56.841Z" },
{ url = "https://files.pythonhosted.org/packages/78/08/8cd2af9003f9fc03912b658a64f5a4919dcd68f0dd3bbc822b49a3d14fd9/pymssql-2.3.13-cp312-cp312-win_amd64.whl", hash = "sha256:a930adda87bdd8351a5637cf73d6491936f34e525a5e513068a6eac742f69cdb", size = 1994709, upload-time = "2026-02-14T04:59:58.972Z" },
{ url = "https://files.pythonhosted.org/packages/d4/4f/ee15b1f6b11e7c3accdc7da7840a019b63f12ba09eaa008acc601182f516/pymssql-2.3.13-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:30918bb044242865c01838909777ef5e0f1b9ecd7f5882346aefa57f4414b29c", size = 3156333, upload-time = "2026-02-14T05:00:01.21Z" },
{ url = "https://files.pythonhosted.org/packages/79/03/aea5c77bad4a52649a1d9f786a1d9ce1c83d50f1a75df288e292737b6d80/pymssql-2.3.13-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:1c6d0b2d7961f159a07e4f0d8cc81f70ceab83f5e7fd1e832a2d069e1d67ee4e", size = 2957990, upload-time = "2026-02-14T05:00:03.11Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f8/30ac16fba32ff066b05f12c392d7b812fe11f06cb62d1d86ca5177c50a8b/pymssql-2.3.13-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16c5957a3c9e51a03276bfd76a22431e2bc4c565e2e95f2cbb3559312edda230", size = 3065264, upload-time = "2026-02-14T05:00:05.377Z" },
{ url = "https://files.pythonhosted.org/packages/a9/98/7568447bf85921d21453fd56e19b6c9591d595fde0546c5a569f3ae937a8/pymssql-2.3.13-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fddd24efe9d18bbf174fab7c6745b0927773718387f5517cf8082241f721a68", size = 3190039, upload-time = "2026-02-14T05:00:06.925Z" },
{ url = "https://files.pythonhosted.org/packages/35/f1/4d9d275ebaac42cdd49d40d504ccb648f27710660c8b60cc427752438c09/pymssql-2.3.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:123c55ee41bc7a82c76db12e2eb189b50d0d7a11222b4f8789206d1cda3b33b9", size = 3710151, upload-time = "2026-02-14T05:00:08.424Z" },
{ url = "https://files.pythonhosted.org/packages/6f/bd/a5cc6244fd27d3ea0cc82f12a7d38a24d7fd90b0022afd250014e8bfba15/pymssql-2.3.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e053b443e842f9e1698fcb2b23a4bff1ff3d410894d880064e754ad823d541e5", size = 3453156, upload-time = "2026-02-14T05:00:09.978Z" },
{ url = "https://files.pythonhosted.org/packages/26/d0/c20ff0bbffd18db528bcc7b0c68b25c12ad563ed67c56ceca87c58f7399e/pymssql-2.3.13-cp313-cp313-win_amd64.whl", hash = "sha256:5c045c0f1977a679cc30d5acd9da3f8aeb2dc6e744895b26444b4a2f20dad9a0", size = 1995236, upload-time = "2026-02-14T05:00:11.495Z" },
{ url = "https://files.pythonhosted.org/packages/ec/5f/6b64f78181d680f655ab40ba7b34cb68c045a2f4e04a10a70d768cd383b7/pymssql-2.3.13-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fc5482969c813b0a45ce51c41844ae5bfa8044ad5ef8b4820ef6de7d4545b7f2", size = 3158377, upload-time = "2026-02-14T05:00:13.581Z" },
{ url = "https://files.pythonhosted.org/packages/ff/24/155dbb0992c431496d440f47fb9d587cd0059ee20baf65e3d891794d862a/pymssql-2.3.13-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:ff5be7ab1d643dbce2ee3424d2ef9ae8e4146cf75bd20946bc7a6108e3ad1e47", size = 2959039, upload-time = "2026-02-14T05:00:15.883Z" },
{ url = "https://files.pythonhosted.org/packages/c9/89/b453dd1b1188779621fb974ac715ab2e738f4a0b69f7291ab014298bd80d/pymssql-2.3.13-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d66ce0a249d2e3b57369048d71e1f00d08dfb90a758d134da0250ae7bc739c1", size = 3063862, upload-time = "2026-02-14T05:00:17.537Z" },
{ url = "https://files.pythonhosted.org/packages/02/e5/96f57c78162013678ecc3f3f7e5fb52c83ee07beef26906d0870770c3ef6/pymssql-2.3.13-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d663c908414a6a032f04d17628138b1782af916afc0df9fefac4751fa394c3ac", size = 3188155, upload-time = "2026-02-14T05:00:19.011Z" },
{ url = "https://files.pythonhosted.org/packages/cd/a2/4bee9484734ae0c55d10a2f6ff82dd4e416f52420755161b8760c817ad64/pymssql-2.3.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aa5e07eff7e6e8bd4ba22c30e4cb8dd073e138cd272090603609a15cc5dbc75b", size = 3709344, upload-time = "2026-02-14T05:00:21.139Z" },
{ url = "https://files.pythonhosted.org/packages/37/cf/3520d96afa213c88db4f4a1988199db476d869a62afdd5d9c4635c184631/pymssql-2.3.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db77da1a3fc9b5b5c5400639d79d7658ba7ad620957100c5b025be608b562193", size = 3451799, upload-time = "2026-02-14T05:00:22.504Z" },
{ url = "https://files.pythonhosted.org/packages/25/50/4be9bd9cf4b43208a7175117a533ece200cfe4131a39f9909bdc7560ddeb/pymssql-2.3.13-cp314-cp314-win_amd64.whl", hash = "sha256:7d7037d2b5b907acc7906d0479924db2935a70c720450c41339146a4ada2b93d", size = 2049139, upload-time = "2026-02-14T05:00:23.951Z" },
]
[[package]]
name = "pyogrio"
version = "0.12.1"