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:
2026-06-13 21:56:56 +02:00
parent 83f1d7c8d3
commit d89da1292d
9 changed files with 560 additions and 2 deletions
@@ -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.")