chore: auto-commit (9 archivos)
- docs/capabilities/INDEX.md - docs/capabilities/obsidian.md - python/functions/core/render_markdown_table.md - python/functions/core/render_markdown_table.py - python/functions/core/render_markdown_table_test.py - python/functions/core/upsert_sentinel_block.md - python/functions/core/upsert_sentinel_block.py - python/functions/core/upsert_sentinel_block_test.py - python/functions/infra/duckdb_query_readonly.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: render_markdown_table
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def render_markdown_table(rows: list, columns: list = None, max_rows: int = 0) -> str"
|
||||
description: "Renderiza una lista de diccionarios como tabla Markdown GitHub-flavored. Resuelve columnas desde las claves del primer dict o desde un orden explicito, normaliza None/bool, escapa pipes, convierte saltos de linea a <br> y permite truncar filas."
|
||||
tags: [markdown, table, render, obsidian]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: rows
|
||||
desc: "lista de diccionarios, uno por fila; cada dict mapea nombre de columna a valor de celda"
|
||||
- name: columns
|
||||
desc: "orden explicito de columnas; si None usa las claves del primer dict preservando orden de insercion; con rows vacio y None devuelve string vacio"
|
||||
- name: max_rows
|
||||
desc: "numero maximo de filas a renderizar; 0 = todas; si N>0 y hay mas de N filas trunca a N y anade una linea final '_... N de M filas_' fuera de la tabla"
|
||||
output: "string con la tabla Markdown (header + separador |---| + filas); string vacio cuando no hay filas ni columnas explicitas"
|
||||
tested: true
|
||||
tests: ["caso normal", "rows vacio", "rows vacio con columns", "columns explicitas ordenan", "escape de pipes", "none bool y saltos de linea", "max rows truncado", "max rows sin truncar"]
|
||||
test_file_path: "python/functions/core/render_markdown_table_test.py"
|
||||
file_path: "python/functions/core/render_markdown_table.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
rows = [
|
||||
{"nombre": "Alice", "edad": 30, "activo": True},
|
||||
{"nombre": "Bob", "edad": 25, "activo": False},
|
||||
]
|
||||
md = render_markdown_table(rows)
|
||||
# | nombre | edad | activo |
|
||||
# |---|---|---|
|
||||
# | Alice | 30 | true |
|
||||
# | Bob | 25 | false |
|
||||
|
||||
# Con columnas explicitas y truncado:
|
||||
muchas = [{"n": i} for i in range(100)]
|
||||
md2 = render_markdown_table(muchas, columns=["n"], max_rows=10)
|
||||
# Renderiza solo 10 filas y termina con: _... 10 de 100 filas_
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tengas una lista de registros (`list[dict]`) y quieras volcarla a una tabla Markdown lista para Obsidian, un README o un comentario de PR. Util para resumir consultas o datasets en notas: pasa `columns` para fijar el orden y `max_rows` para no saturar la nota con cientos de filas.
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Render a list of dict rows as a GitHub-flavored Markdown table."""
|
||||
|
||||
|
||||
def render_markdown_table(rows: list, columns: list = None, max_rows: int = 0) -> str:
|
||||
"""Render a list of dict rows as a GitHub-flavored Markdown table.
|
||||
|
||||
Args:
|
||||
rows: List of dictionaries, one per row. Each dictionary maps a column
|
||||
name to its cell value.
|
||||
columns: Explicit column order. When None, the keys of the first row are
|
||||
used preserving insertion order. When rows is empty and columns is
|
||||
None, the result is an empty string.
|
||||
max_rows: Maximum number of rows to render. 0 means all rows. When N > 0
|
||||
and the number of rows exceeds N, the table is truncated to N rows
|
||||
and a trailing line `\n_... N de M filas_` is appended after the
|
||||
table indicating how many of the total rows are shown.
|
||||
|
||||
Returns:
|
||||
str: The Markdown table as a string. Returns an empty string when there
|
||||
are no rows and no explicit columns.
|
||||
"""
|
||||
|
||||
def render_cell(value) -> str:
|
||||
"""Convert a cell value to its Markdown-safe string representation."""
|
||||
if value is None:
|
||||
text = ""
|
||||
elif isinstance(value, bool):
|
||||
text = "true" if value else "false"
|
||||
else:
|
||||
text = str(value)
|
||||
# Escape pipe characters so they do not break the table layout and turn
|
||||
# newlines into <br> tags to keep each row on a single Markdown line.
|
||||
text = text.replace("|", "\\|")
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "<br>")
|
||||
return text
|
||||
|
||||
# Resolve the column order.
|
||||
if columns is None:
|
||||
if not rows:
|
||||
return ""
|
||||
cols = list(rows[0].keys())
|
||||
else:
|
||||
cols = list(columns)
|
||||
|
||||
# Decide how many rows to render and whether truncation applies.
|
||||
total = len(rows)
|
||||
if max_rows > 0 and total > max_rows:
|
||||
visible = rows[:max_rows]
|
||||
truncated = True
|
||||
else:
|
||||
visible = rows
|
||||
truncated = False
|
||||
|
||||
header = "| " + " | ".join(render_cell(col) for col in cols) + " |"
|
||||
separator = "|" + "---|" * len(cols)
|
||||
|
||||
lines = [header, separator]
|
||||
for row in visible:
|
||||
cells = [render_cell(row.get(col)) for col in cols]
|
||||
lines.append("| " + " | ".join(cells) + " |")
|
||||
|
||||
table = "\n".join(lines)
|
||||
|
||||
if truncated:
|
||||
table += f"\n_... {max_rows} de {total} filas_"
|
||||
|
||||
return table
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests para render_markdown_table."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from render_markdown_table import render_markdown_table
|
||||
|
||||
|
||||
def test_caso_normal():
|
||||
rows = [
|
||||
{"nombre": "Alice", "edad": 30},
|
||||
{"nombre": "Bob", "edad": 25},
|
||||
]
|
||||
result = render_markdown_table(rows)
|
||||
lines = result.split("\n")
|
||||
assert lines[0] == "| nombre | edad |"
|
||||
assert lines[1] == "|---|---|"
|
||||
assert lines[2] == "| Alice | 30 |"
|
||||
assert lines[3] == "| Bob | 25 |"
|
||||
assert len(lines) == 4
|
||||
|
||||
|
||||
def test_rows_vacio():
|
||||
assert render_markdown_table([]) == ""
|
||||
|
||||
|
||||
def test_rows_vacio_con_columns():
|
||||
result = render_markdown_table([], columns=["a", "b"])
|
||||
assert result == "| a | b |\n|---|---|"
|
||||
|
||||
|
||||
def test_columns_explicitas_ordenan():
|
||||
rows = [{"b": 2, "a": 1}]
|
||||
result = render_markdown_table(rows, columns=["a", "b"])
|
||||
lines = result.split("\n")
|
||||
assert lines[0] == "| a | b |"
|
||||
assert lines[2] == "| 1 | 2 |"
|
||||
|
||||
|
||||
def test_escape_de_pipes():
|
||||
rows = [{"col": "x|y|z"}]
|
||||
result = render_markdown_table(rows)
|
||||
assert "x\\|y\\|z" in result
|
||||
|
||||
|
||||
def test_none_bool_y_saltos_de_linea():
|
||||
rows = [{"a": None, "b": True, "c": False, "d": "line1\nline2"}]
|
||||
result = render_markdown_table(rows)
|
||||
data_line = result.split("\n")[2]
|
||||
assert data_line == "| | true | false | line1<br>line2 |"
|
||||
|
||||
|
||||
def test_max_rows_truncado():
|
||||
rows = [{"n": i} for i in range(5)]
|
||||
result = render_markdown_table(rows, max_rows=2)
|
||||
lines = result.split("\n")
|
||||
# header + separator + 2 data rows + truncation note
|
||||
assert len(lines) == 5
|
||||
assert lines[-1] == "_... 2 de 5 filas_"
|
||||
|
||||
|
||||
def test_max_rows_sin_truncar():
|
||||
rows = [{"n": 1}, {"n": 2}]
|
||||
result = render_markdown_table(rows, max_rows=5)
|
||||
assert "filas_" not in result
|
||||
assert len(result.split("\n")) == 4
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_caso_normal()
|
||||
test_rows_vacio()
|
||||
test_rows_vacio_con_columns()
|
||||
test_columns_explicitas_ordenan()
|
||||
test_escape_de_pipes()
|
||||
test_none_bool_y_saltos_de_linea()
|
||||
test_max_rows_truncado()
|
||||
test_max_rows_sin_truncar()
|
||||
print("All tests passed.")
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: upsert_sentinel_block
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def upsert_sentinel_block(text: str, block_id: str, content: str, marker: str = \"osintdb\") -> str"
|
||||
description: "Inserta o reemplaza de forma idempotente un bloque gestionado delimitado por sentinels HTML-comment (<!-- {marker}:begin id={block_id} --> ... <!-- {marker}:end id={block_id} -->) dentro de un texto. Util para mantener secciones auto-generadas en notas Markdown de Obsidian sin pisar el resto del cuerpo."
|
||||
tags: [markdown, sentinel, managed-block, obsidian]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [re]
|
||||
params:
|
||||
- name: text
|
||||
desc: "Texto sobre el que operar (tipicamente el body de una nota Markdown de Obsidian). Puede contener otros bloques gestionados con ids distintos, que no se tocan."
|
||||
- name: block_id
|
||||
desc: "Identificador unico del bloque dentro del texto. Se usa literal en la regex (se escapa con re.escape), asi que puede contener caracteres especiales."
|
||||
- name: content
|
||||
desc: "Contenido a colocar entre los sentinels. NO debe contener los propios sentinels o se corromperia el bloque."
|
||||
- name: marker
|
||||
desc: "Prefijo del namespace de los comentarios sentinel. Default 'osintdb'. Se usa literal en la regex (se escapa con re.escape)."
|
||||
output: "El texto resultante con el bloque insertado al final (si no existia) o con su contenido reemplazado entre sentinels (si ya existia). Los sentinels siempre se conservan."
|
||||
tested: true
|
||||
tests:
|
||||
- "insercion nueva anade bloque al final"
|
||||
- "reemplazo existente sustituye solo el contenido"
|
||||
- "idempotencia dos aplicaciones mismo resultado"
|
||||
- "bloque corrupto solo inicio lanza valueerror"
|
||||
- "bloque corrupto solo fin lanza valueerror"
|
||||
- "multiples bloques ids distintos en mismo texto"
|
||||
- "marker personalizado"
|
||||
test_file_path: "python/functions/core/upsert_sentinel_block_test.py"
|
||||
file_path: "python/functions/core/upsert_sentinel_block.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from core.upsert_sentinel_block import upsert_sentinel_block
|
||||
|
||||
nota = "# Dossier OSINT\n\nNotas manuales del investigador."
|
||||
|
||||
# Primera vez: inserta el bloque al final.
|
||||
nota = upsert_sentinel_block(nota, "persons", "- Alice\n- Bob")
|
||||
|
||||
# Segunda vez con nuevo contenido: reemplaza solo lo que hay entre sentinels.
|
||||
nota = upsert_sentinel_block(nota, "persons", "- Alice\n- Bob\n- Carol")
|
||||
|
||||
print(nota)
|
||||
# # Dossier OSINT
|
||||
#
|
||||
# Notas manuales del investigador.
|
||||
#
|
||||
# <!-- osintdb:begin id=persons -->
|
||||
# - Alice
|
||||
# - Bob
|
||||
# - Carol
|
||||
# <!-- osintdb:end id=persons -->
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites mantener una seccion auto-generada dentro de un documento de texto (tipicamente una nota Markdown de Obsidian) sin sobreescribir lo que el usuario haya escrito a mano. Llamala cada vez que regeneres los datos: el primer uso inserta el bloque y los siguientes solo refrescan su contenido, dejando intacto el resto de la nota y otros bloques gestionados con ids distintos.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Si en `text` aparece solo uno de los dos sentinels del `block_id` (bloque corrupto, por ejemplo si alguien borro el sentinel de fin a mano) la funcion lanza `ValueError` con un mensaje que indica cuantos sentinels de inicio y de fin se encontraron. Lo mismo si aparece mas de un par de sentinels para el mismo `block_id` (bloque duplicado).
|
||||
- `content` NO debe contener los propios sentinels (`<!-- {marker}:begin id={block_id} -->` / `<!-- {marker}:end id={block_id} -->`). Si los contiene, el siguiente upsert vera sentinels de sobra y fallara o reemplazara de forma incorrecta.
|
||||
- Es funcion pura: no escribe en disco. Lee/devuelve strings; la persistencia de la nota (lectura del fichero, escritura del resultado) corre por cuenta del llamante.
|
||||
- `block_id` y `marker` se escapan con `re.escape`, por lo que es seguro usar valores con caracteres especiales de regex. Aun asi, conviene mantenerlos en formato simple (alfanumerico, guiones) para que los comentarios HTML sean legibles en la nota.
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Gestion idempotente de bloques delimitados por sentinels HTML-comment dentro de un texto."""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def upsert_sentinel_block(
|
||||
text: str, block_id: str, content: str, marker: str = "osintdb"
|
||||
) -> str:
|
||||
"""Inserta o reemplaza un bloque gestionado delimitado por sentinels HTML-comment.
|
||||
|
||||
Un bloque gestionado queda envuelto entre dos comentarios HTML que actuan como
|
||||
sentinels:
|
||||
|
||||
<!-- {marker}:begin id={block_id} -->
|
||||
...contenido gestionado...
|
||||
<!-- {marker}:end id={block_id} -->
|
||||
|
||||
Si el bloque (ambos sentinels con ese block_id) ya existe en el texto, su contenido
|
||||
se reemplaza por el nuevo manteniendo los sentinels. Si no existe, el bloque se anade
|
||||
al final del texto. La operacion es idempotente: aplicarla dos veces con el mismo
|
||||
content produce el mismo resultado.
|
||||
|
||||
Args:
|
||||
text: Texto sobre el que operar (tipicamente el body de una nota Markdown de
|
||||
Obsidian). Puede contener otros bloques gestionados con ids distintos.
|
||||
block_id: Identificador unico del bloque dentro del texto. Se usa literal en
|
||||
la expresion regular, asi que se escapa con re.escape.
|
||||
content: Contenido a colocar entre los sentinels. No debe contener los propios
|
||||
sentinels.
|
||||
marker: Prefijo del namespace de los comentarios sentinel. Default "osintdb".
|
||||
Se usa literal en la expresion regular, asi que se escapa con re.escape.
|
||||
|
||||
Returns:
|
||||
El texto resultante con el bloque insertado o reemplazado.
|
||||
|
||||
Raises:
|
||||
ValueError: Si aparece solo uno de los dos sentinels del block_id (bloque
|
||||
corrupto) o si aparecen sentinels duplicados.
|
||||
"""
|
||||
begin = f"<!-- {marker}:begin id={block_id} -->"
|
||||
end = f"<!-- {marker}:end id={block_id} -->"
|
||||
|
||||
begin_re = re.escape(begin)
|
||||
end_re = re.escape(end)
|
||||
|
||||
n_begin = len(re.findall(begin_re, text))
|
||||
n_end = len(re.findall(end_re, text))
|
||||
|
||||
if n_begin != n_end:
|
||||
raise ValueError(
|
||||
f"Bloque corrupto para block_id={block_id!r} con marker={marker!r}: "
|
||||
f"se encontraron {n_begin} sentinel(s) de inicio y {n_end} de fin "
|
||||
f"(deben coincidir)."
|
||||
)
|
||||
|
||||
if n_begin > 1:
|
||||
raise ValueError(
|
||||
f"Bloque duplicado para block_id={block_id!r} con marker={marker!r}: "
|
||||
f"se encontraron {n_begin} pares de sentinels (debe haber como maximo uno)."
|
||||
)
|
||||
|
||||
if n_begin == 1:
|
||||
# El bloque existe: reemplazar todo lo que hay entre los sentinels.
|
||||
pattern = re.compile(begin_re + r".*?" + end_re, re.DOTALL)
|
||||
replacement = f"{begin}\n{content}\n{end}"
|
||||
return pattern.sub(lambda _: replacement, text, count=1)
|
||||
|
||||
# El bloque no existe: anadirlo al final.
|
||||
block = f"{begin}\n{content}\n{end}\n"
|
||||
if text == "":
|
||||
return block
|
||||
if text.endswith("\n"):
|
||||
return f"{text}\n{block}"
|
||||
return f"{text}\n\n{block}"
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests para upsert_sentinel_block."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from upsert_sentinel_block import upsert_sentinel_block
|
||||
|
||||
|
||||
def test_insercion_nueva_anade_bloque_al_final():
|
||||
text = "# Nota\n\nTexto previo del usuario."
|
||||
result = upsert_sentinel_block(text, "persons", "- Alice\n- Bob")
|
||||
|
||||
assert "<!-- osintdb:begin id=persons -->" in result
|
||||
assert "<!-- osintdb:end id=persons -->" in result
|
||||
assert "- Alice\n- Bob" in result
|
||||
# El texto previo se conserva intacto al inicio.
|
||||
assert result.startswith("# Nota\n\nTexto previo del usuario.")
|
||||
# El bloque va al final.
|
||||
assert result.index("Texto previo") < result.index("osintdb:begin")
|
||||
|
||||
|
||||
def test_reemplazo_existente_sustituye_solo_el_contenido():
|
||||
text = (
|
||||
"Intro\n\n"
|
||||
"<!-- osintdb:begin id=persons -->\n"
|
||||
"contenido viejo\n"
|
||||
"<!-- osintdb:end id=persons -->\n"
|
||||
)
|
||||
result = upsert_sentinel_block(text, "persons", "contenido nuevo")
|
||||
|
||||
assert "contenido viejo" not in result
|
||||
assert "contenido nuevo" in result
|
||||
# Los sentinels siguen presentes una sola vez.
|
||||
assert result.count("<!-- osintdb:begin id=persons -->") == 1
|
||||
assert result.count("<!-- osintdb:end id=persons -->") == 1
|
||||
# La intro se conserva.
|
||||
assert result.startswith("Intro\n\n")
|
||||
|
||||
|
||||
def test_idempotencia_dos_aplicaciones_mismo_resultado():
|
||||
text = "# Nota\n\nCuerpo."
|
||||
once = upsert_sentinel_block(text, "persons", "- Alice\n- Bob")
|
||||
twice = upsert_sentinel_block(once, "persons", "- Alice\n- Bob")
|
||||
|
||||
assert once == twice
|
||||
assert twice.count("<!-- osintdb:begin id=persons -->") == 1
|
||||
assert twice.count("<!-- osintdb:end id=persons -->") == 1
|
||||
|
||||
|
||||
def test_bloque_corrupto_solo_inicio_lanza_valueerror():
|
||||
text = "Texto\n<!-- osintdb:begin id=persons -->\nhuerfano\n"
|
||||
try:
|
||||
upsert_sentinel_block(text, "persons", "x")
|
||||
except ValueError as e:
|
||||
assert "persons" in str(e)
|
||||
assert "corrupto" in str(e)
|
||||
else:
|
||||
raise AssertionError("Esperaba ValueError por bloque corrupto (solo inicio)")
|
||||
|
||||
|
||||
def test_bloque_corrupto_solo_fin_lanza_valueerror():
|
||||
text = "Texto\n<!-- osintdb:end id=persons -->\nhuerfano\n"
|
||||
try:
|
||||
upsert_sentinel_block(text, "persons", "x")
|
||||
except ValueError as e:
|
||||
assert "persons" in str(e)
|
||||
assert "corrupto" in str(e)
|
||||
else:
|
||||
raise AssertionError("Esperaba ValueError por bloque corrupto (solo fin)")
|
||||
|
||||
|
||||
def test_multiples_bloques_ids_distintos_en_mismo_texto():
|
||||
text = "# Dossier\n\nResumen."
|
||||
# Insertar dos bloques con ids distintos.
|
||||
text = upsert_sentinel_block(text, "persons", "- Alice")
|
||||
text = upsert_sentinel_block(text, "places", "- Madrid")
|
||||
|
||||
assert text.count("<!-- osintdb:begin id=persons -->") == 1
|
||||
assert text.count("<!-- osintdb:begin id=places -->") == 1
|
||||
assert "- Alice" in text
|
||||
assert "- Madrid" in text
|
||||
|
||||
# Reemplazar solo el bloque persons no afecta al de places.
|
||||
updated = upsert_sentinel_block(text, "persons", "- Alice\n- Bob")
|
||||
assert "- Alice\n- Bob" in updated
|
||||
assert "- Madrid" in updated
|
||||
assert updated.count("<!-- osintdb:begin id=places -->") == 1
|
||||
# El contenido de places sigue intacto.
|
||||
assert "places -->\n- Madrid\n<!-- osintdb:end id=places -->" in updated
|
||||
|
||||
|
||||
def test_marker_personalizado():
|
||||
text = "Cuerpo."
|
||||
result = upsert_sentinel_block(text, "tags", "- osint", marker="mydb")
|
||||
|
||||
assert "<!-- mydb:begin id=tags -->" in result
|
||||
assert "<!-- mydb:end id=tags -->" in result
|
||||
# Reemplazo idempotente con el mismo marker.
|
||||
again = upsert_sentinel_block(result, "tags", "- osint", marker="mydb")
|
||||
assert again == result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_insercion_nueva_anade_bloque_al_final()
|
||||
test_reemplazo_existente_sustituye_solo_el_contenido()
|
||||
test_idempotencia_dos_aplicaciones_mismo_resultado()
|
||||
test_bloque_corrupto_solo_inicio_lanza_valueerror()
|
||||
test_bloque_corrupto_solo_fin_lanza_valueerror()
|
||||
test_multiples_bloques_ids_distintos_en_mismo_texto()
|
||||
test_marker_personalizado()
|
||||
print("All tests passed.")
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: duckdb_query_readonly
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def duckdb_query_readonly(db_path: str, sql: str, params: list = None, max_rows: int = 10000) -> dict"
|
||||
description: "Ejecuta una query SELECT contra una base DuckDB abierta en modo solo lectura (duckdb.connect(db_path, read_only=True)), de modo que nunca crea ni modifica la base. La conexion se cierra siempre en try/finally. Usa parametros posicionales con el marcador '?'. Devuelve un dict sin lanzar (estilo del grupo dav): {status:'ok', columns, rows, row_count, truncated} en exito y {status:'error', error} en fallo. Las filas son list[dict]. Trunca a max_rows para proteger memoria. Convierte valores no serializables: date/datetime/time a isoformat(), Decimal a float, bytes a base64, UUID a str. Depende del paquete duckdb (1.5.2 en python/.venv)."
|
||||
tags: [duckdb, sql, query, readonly]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [base64, datetime, decimal, uuid, duckdb]
|
||||
params:
|
||||
- name: db_path
|
||||
desc: "ruta al archivo DuckDB. Debe existir: el modo read_only NO crea la base. Un path inexistente devuelve {status:'error'}."
|
||||
- name: sql
|
||||
desc: "sentencia SQL a ejecutar (pensada para SELECT). Usa el marcador '?' para parametros posicionales."
|
||||
- name: params
|
||||
desc: "lista de parametros posicionales para el SQL en orden. None (default) significa sin parametros. Pasar valores aqui en vez de interpolarlos en el SQL evita inyeccion."
|
||||
- name: max_rows
|
||||
desc: "numero maximo de filas a materializar en memoria. Default 10000. Si la query produce mas, el resultado se trunca y truncated queda en True."
|
||||
output: "dict. En exito: {status:'ok', columns:[str,...], rows:[{col:val,...},...], row_count:int, truncated:bool}. En error (sin lanzar): {status:'error', error:str}. Los valores de las filas estan normalizados a tipos JSON-serializables."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_query_ok_devuelve_filas_como_dicts"
|
||||
- "test_query_con_params_posicionales"
|
||||
- "test_sql_invalido_devuelve_status_error"
|
||||
- "test_db_inexistente_devuelve_status_error"
|
||||
- "test_truncado_a_max_rows"
|
||||
- "test_valores_no_serializables_se_convierten"
|
||||
test_file_path: "python/functions/infra/duckdb_query_readonly_test.py"
|
||||
file_path: "python/functions/infra/duckdb_query_readonly.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
import duckdb
|
||||
from infra.duckdb_query_readonly import duckdb_query_readonly
|
||||
|
||||
# Preparamos una base de ejemplo (esto seria un proceso separado en la realidad).
|
||||
db = "/tmp/ventas.duckdb"
|
||||
con = duckdb.connect(db)
|
||||
con.execute("CREATE TABLE ventas (id INTEGER, region VARCHAR, total DECIMAL(10,2))")
|
||||
con.execute("INSERT INTO ventas VALUES (1, 'norte', 120.50), (2, 'sur', 80.00), (3, 'norte', 45.25)")
|
||||
con.close()
|
||||
|
||||
# Lectura solo-lectura con parametro posicional.
|
||||
res = duckdb_query_readonly(
|
||||
db,
|
||||
"SELECT region, SUM(total) AS total FROM ventas WHERE region = ? GROUP BY region",
|
||||
params=["norte"],
|
||||
)
|
||||
print(res["status"]) # ok
|
||||
print(res["columns"]) # ['region', 'total']
|
||||
print(res["rows"]) # [{'region': 'norte', 'total': 165.75}]
|
||||
print(res["truncated"]) # False
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas leer datos de un archivo DuckDB sin riesgo de modificarlo:
|
||||
inspeccionar una base materializada, validar el resultado de un pipeline,
|
||||
alimentar un dashboard o un report, o consultar tablas/Parquet exportados por
|
||||
otra funcion del registry. El modo read_only garantiza que la consulta nunca
|
||||
crea ni altera la base, y el dict de salida es directamente serializable a JSON
|
||||
para pasarlo al siguiente paso de una composicion.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Lectura real de un archivo en disco (impura). El modo `read_only=True` exige
|
||||
que el archivo **ya exista**: a diferencia del modo escritura, no crea la base.
|
||||
Si `db_path` no existe, devuelve `{status:'error', error:...}`.
|
||||
- Conflicto de lock: si otro proceso tiene la misma base abierta en escritura
|
||||
con una version de DuckDB distinta, la apertura puede fallar (DuckDB no permite
|
||||
abrir un archivo bloqueado por otra version del motor). El error se devuelve
|
||||
como `{status:'error', ...}`, no se lanza.
|
||||
- `max_rows` protege la memoria: una query que devuelve millones de filas se
|
||||
trunca a `max_rows` y marca `truncated=True`. Si necesitas todas las filas,
|
||||
pagina con LIMIT/OFFSET en el SQL o sube `max_rows` conscientemente.
|
||||
- Los parametros van en `params` con el marcador `?`, nunca interpolados en el
|
||||
string del SQL (previene inyeccion).
|
||||
- Valores no JSON-serializables se normalizan en la salida: date/datetime/time a
|
||||
`isoformat()`, Decimal a float (puede haber perdida de precision frente al
|
||||
decimal exacto), bytes a base64 y UUID a str.
|
||||
Reference in New Issue
Block a user