Status: canonical. Updated 2026-09-04.
This document collects two rules that bit us in production enough to be worth making explicit:
io.echo() interprets {token} escape sequences: {restorecursor},
{savecursor}, {promptcolor}, {valuecolor}, etc. For those
sequences to be processed, the call must use an f-string. A plain
string is passed through verbatim and the escape sequences never get
expanded.
io.echo(f"{{restorecursor}}{{promptcolor}}{prompt}{{valuecolor}}{result}")
io.echo(f"{{labelcolor}}Item: {{valuecolor}}{item.content}{{/all}}\n")
io.echo("{restorecursor}{promptcolor}...") # escape sequences not processed
io.echo(f"{{labelcolor}}Item: {{valuecolor}}{{item.content}}{{/all}}\n") # braces inside braces
The double-brace / single-brace distinction is the load-bearing part:
| Braces | Meaning |
|---|---|
{{token}} |
io.echo escape sequence (e.g. {labelcolor}) |
{varname} |
Python f-string substitution |
The linter rule F541 (f-string without any placeholders) is disabled
in the project configuration because io.echo calls like
io.echo(f"{{savecursor}}") have no Python placeholders but still
must be f-strings for the io.echo layer to see the escape sequences.
Database conversions are handled by bbsengine6.database. Never call
json.dumps() before passing data to a database function.
Note:
convert_for_jsonb()wraps only the top-level dict/list inJsonb. Inner dicts/lists are returned as plain Python objects to avoid theObject of type Jsonb is not JSON serializableerror that psycopg's dumper raises on nestedJsonbinstances. Seedatabase.mdfor the function reference.
# WRONG - double conversion, creates non-serializable Jsonb object
rec["flags"] = json.dumps(database.convert_for_jsonb(member["flags"]))
database.update(args, table, pk, rec)
# CORRECT - let the database module handle conversion
rec["flags"] = member["flags"] # keep as dict
database.update(args, table, pk, rec)
# database.update() calls convert_for_jsonb(rec["flags"]) internally
The correct flow:
member dict
-> buildrec(member) # keeps dicts as dicts
-> rec (dict with dicts)
-> database.update()
-> convert_for_jsonb(rec["flags"])
-> wraps dict in psycopg3.Jsonb
-> cur.execute(sql, [Jsonb(...)])
-> psycopg3 serializes Jsonb -> JSONB bytes
-> PostgreSQL stores JSONB
What goes wrong if you call json.dumps upstream:
buildrec() called json.dumps(convert_for_jsonb(dict))
-> Jsonb({...}) object created
-> json.dumps() tries to serialize it
-> ERROR: Object of type Jsonb is not JSON serializable
Application layer (member.py, console/member.py, etc.) is allowed
to transform data structures (filter, rename, format fields), convert
lists to strings where the schema calls for it, and pass the result to
a database function. Application code must not:
json.dumps() on values going to database.update().convert_for_jsonb() before database.update().Jsonb(Jsonb(...)) constructions.Database layer (database.py) is responsible for:
convert_for_jsonb() on all values in update(),
insert(), upsert(), execute(), executemany().Jsonb and leaving inner
structures plain.Simple values (strings, numbers, booleans) pass through unchanged:
rec = {
"moniker": "testuser",
"credits": 500,
"verified": True,
}
database.update(args, table, pk, rec)
Dict values stay as dicts; the database layer wraps them:
rec = {
"flags": {
"APPROVED": {"value": True},
"VERIFIED": {"value": False},
}
}
database.update(args, table, pk, rec)
List values stay as lists. The exception is the ui column on
engine.__member, which is stored as a comma-separated string and is
converted by buildrec():
# input
rec = {"ui": ["telnet", "web", "ssh"]}
# after buildrec()
rec = {"ui": "telnet, web, ssh"}
database.update(args, table, pk, rec)
Nested dicts stay nested:
rec = {
"settings": {
"theme": {"dark_mode": True, "color_scheme": "solarized"},
"notifications": {"email": True, "push": False},
}
}
database.update(args, table, pk, rec)
# Internally: convert_for_jsonb wraps the top-level dict in Jsonb only;
# inner dicts are plain, so psycopg's dumper can serialize the whole tree.
buildrec(member) -- bbsengine6.memberTransforms a member dict for database operations:
datecreatedepoch, etc.).ui list to a comma-separated string.input = {
"moniker": "test",
"flags": {"APPROVED": {"value": True}},
"ui": ["telnet", "web"],
"datecreatedepoch": 1234567, # removed
}
output = buildrec(input)
# {
# "moniker": "test",
# "flags": {"APPROVED": {"value": True}}, # dict, not string
# "ui": "telnet, web",
# }
database.convert_for_jsonb(value, *, wrap=True)Wraps Python objects for psycopg3. Top-level dicts/lists are wrapped
in Jsonb; inner dicts/lists stay plain. wrap=False is the
recursion path used internally; callers should use the default.
dict_value = {"outer": {"inner": 1}}
jsonb_value = database.convert_for_jsonb(dict_value)
type(jsonb_value) # psycopg.types.json.Jsonb
type(jsonb_value.obj["outer"]) # dict (plain, not Jsonb)
jsonb_value.obj # {'outer': {'inner': 1}}
database.update(args, table, pk, items, ...)Updates rows; iterates over items, calls convert_for_jsonb() on
each value, and executes the resulting SQL. Pass plain Python dicts.
database.update(args, "engine.__member", "test", {"flags": {"APPROVED": True}})
# convert_for_jsonb({"APPROVED": True}) -> Jsonb({"APPROVED": True})
# psycopg3 serializes the Jsonb to JSONB bytes
If you see TypeError: Object of type Jsonb is not JSON serializable:
json.dumps() calls from application code before passing to
a database function.convert_for_jsonb() calls in application code;
database.update() / database.insert() call it internally.Jsonb(Jsonb(...)) with a single
Jsonb({...}) over the outer dict, and let convert_for_jsonb
handle the wrap.buildrec() returns dicts as dicts (not JSON strings, not
Jsonb objects).database.update() is called with plain dicts.database.md -- the canonical
convert_for_jsonb reference.py/src/bbsengine6/member/lib.py -- buildrec definition.py/tests/test_buildrec.py,
py/tests/test_member_update_with_flags.py -- regression coverage.