Skip to content

Writing Gramplets

Doug Blank edited this page Oct 19, 2026 · 1 revision

🌐 Also available in: Deutsch · Español · Français · Nederlands · 简体中文

Writing Gramplets

How to write your own Gramplet and publish it to the Gramplet Store. Only a tree's Owner or a site Admin can create or edit Gramplets (see Who can use Gramplets).

Getting started

A Gramplet's code runs in a sandbox with a set of functions and objects already available, so no imports are needed to reach the tree.

The gramplet_examples/ directory in the repo is a numbered tour through the API, from simplest to most advanced. The same reference is available inside the app, from the (i) "Writing a Gramplet" help button in the Gramplet editor.

The smallest useful Gramplet

matches = people(
    "gender == Person.MALE and birth.date.sortval >= Date('Jan 1, 1900')",
    limit=25,
)

columns("Person")
for person in matches:
    row(person)

Fetching records

people(where=None, what=None, order=None, limit=50) fetches People matching a query, as full records, in one call. With what=["gramps_id", "primary_name"] it fetches just those fields.

There's a families() / events() / places() / repositories() / sources() / citations() / media() / notes() / tags() for every other record type, with the same signature.

Writing the query

where= is written in GOQL, the same syntax as the search box on any list. It can reach across relationships (birth.date.sortval), even when the field you're filtering on isn't one you're displaying.

No await is needed, even though this is a real network call under the hood. It's inserted for you automatically.

Showing results

  • row(*values) adds one row to the table. Passing a whole Person/Event/Place/... object, rather than one of its fields, renders it as a clickable link showing its full name or title and Gramps ID. Clicking it opens a popup to view that record in List, Map, Graph or Timeline.
  • name_of(person) is a person's name as text, given name then surnames ("Root Ancestor"), the way row() labels them, for a heading or a sentence. It also takes one of their names, e.g. name_of(person.alternate_names[0]).
  • row(), html() and print() can each be called as many times as you like, in any order. Everything shows up in the order you added it.

Working with records

Every record returned by people(), families() and the rest is a Gramps "DataDict": a plain dict of the record's fields, with dot access on top. person.primary_name.first_name is the same value as person["primary_name"]["first_name"]. Use it whenever you want to compute something from a field, or show one that row() doesn't.

Printing a record

Printing a record never fails. print(person) or f"{person}" shows a whole record the way row() labels it ("Root Ancestor [I0001]"), a date as a date, and anything else (a filter() result, person.primary_name) as its plain fields.

Counting without downloading the tree

total_people = db.get_number_of_people()
women = db.get_number_of("person", where="gender == Person.FEMALE")
no_birth_date = db.get_number_of("person", where="birth.date.sortval is None")

db.get_number_of_<type>() is a cheap, whole-tree count. The total comes back in a response header, so no records are downloaded. db.get_number_of(object_type, where=...) is the same with a filter.

Reaching across relationships

In a query

A where= condition can reach across a relationship to filter on it: a person's birth event, a family's father, an event's place.

born_and_died_same_place = people(
    "birth.place.title == death.place.title and birth.place.title is not None",
    order=[{"column": "surname", "direction": "asc"}],
    limit=50,
)

For display

people(), families() and the rest always hand back the record itself, with no way to ask for a related record's field directly. Getting one back for display is a separate lookup through db's own methods, such as db.get_event_from_handle(...), following the same handles Gramps' own data model uses.

Collections

any(c.given_name == 'Steve' for c in children) matches a family if any child satisfies a condition, and len([c for c in children if c.gender == Person.MALE]) > 1 counts just the sons. See GOQL for more, including the current limit: one hop into a collection, no further chaining past what's inside it.

Reacting to the selected record

record = get_selected()
if record is not None:
    if isinstance(record, Person):
        ...

get_selected() returns whichever record is open in the view's detail pane. It's None when nothing is selected, or when running from the editor preview, which has no view. It makes one network fetch on its first call in a run, then remembers the result for the rest of it.

isinstance(record, Person) (etc.) tells you what kind of record you got, which matters for a Gramplet attached to "All" views.

Updating as the selection changes

For the Gramplet to update live as you click through the list, turn on "Re-run automatically when the selected record changes" in its editor. With that off (the default), get_selected() still works, but only reflects whatever was selected the last time the Gramplet ran.

Reacting to the active filter

A Gramplet can layer its own query on top of the filter applied to the view, whether typed into the search box or picked under Filters (a saved filter or a custom rule). Filtering the list to one branch of the tree then narrows the Gramplet's output the same way:

rows = filter(
    "person",
    where=and_filters(get_filter(), "birth.date.sortval is not None and death.date.sortval is not None"),
)

This needs two manifest settings:

  • views: [...], since get_filter() only returns a where_expr that matches the view's own record type;
  • listensToFilter: true, so the Gramplet re-runs when the filter changes.

Charts

pygal, matplotlib and plotly are bundled, so a plain import (or, for plotly, from plotly.subplots import make_subplots) works offline, with no %pip install.

print(fig) on a plotly Figure, an SVG string from pygal, or a matplotlib figure is recognized and drawn as a chart, with no manual to_html() or embedding.

For chart data, pass what=[...] when you only need a field or two. people() on its own downloads every field of every record, which is wasteful for a chart that only plots, say, age at death.

Side-by-side layout

col1, col2 = st.columns([2, 1])
with col1:
    row("Wider column")
with col2:
    row("Narrower column")

st.columns(spec) lays out regions side by side: an int for that many equal-width columns, or a list of weights (st.columns([2, 1])) for proportional widths. It returns one region per column.

Anything written inside with col: (row(), html(), print(), or another st.* call, including a nested st.columns()) lands in that column. col.write(x) does the same without a with block.

A Gramplet in its own window

Gramplets → New Gramplet… opens the Gramplet editor ready for this kind. In an existing Gramplet, set Kind to Gramplet — opens in a window from the Gramplets menu.

In place of the View Gramplet settings there's a Category field, which groups it with others in the Gramplets menu (e.g. chart). Left blank, it goes under Other.

Asking for input

The code runs as soon as the window opens. To ask for input first, draw it with the st.* widgets and do the real work behind an st.button. They follow Streamlit's names and arguments, and work the same in View Gramplets:

  • st.button, st.text_input, st.number_input, st.checkbox, st.selectbox, st.radio
  • st.columns, st.progress
  • the message boxes st.info / st.success / st.warning / st.error
  • st.session_state

Showing progress

For a long-running Gramplet, show a bar with st.progress(value, text), as in Streamlit. value is 0–100 (or 0.0–1.0). The returned bar's .progress(...) moves it in place and .empty() removes it, cheap enough to call once per row.

people(..., limit=None) (and the other list functions) return every match rather than the first 50:

surname = st.text_input("Surname", "Smith")
if st.button("Run"):
    everyone = people(f"primary_name.surname_list[0].surname == '{surname}'", limit=None)
    bar = st.progress(0, text="Checking people...")
    for i, person in enumerate(everyone):
        bar.progress((i + 1) / len(everyone))
        ...
    bar.empty()

Long runs

A Gramplet can run for as long as it needs, and a page left open for hours can still run them. Every request a Gramplet makes uses your current sign-in, renewed in the background like the rest of the app. Only if the sign-in itself has ended does a run stop, with Your session has expired. Sign in again, then rerun this Gramplet.

Installing a third-party package

%pip install unidecode
from unidecode import unidecode

%pip install is Jupyter-style syntax, rewritten into a real await micropip.install([...]) call before your code runs. It must be on its own line, at the top, as in a notebook; inside an if, for instance, it won't work.

This only works for pure-Python packages with no compiled or C extension code. Most small pure-Python utilities on PyPI install fine this way.

Changing data from code

A Gramplet (not a View Gramplet, which never changes data) can change and delete records once Allow data changes is turned on in the Gramplet editor. The API is the one Gram.py Script uses on Gramps desktop: change a record the way you'd change any Python object.

for person in people("len(citation_list) == 0", limit=None):
    person.private = True

Examples in the Store

  • Mark Unsourced People Private finds people cheaply with filter() first, then fetches and changes only the ones that need it, inside one named begin_changes() group.
  • Tag People adds to a list (person.tag_list.append(tag.handle); records store tags as handles, not names) and uses an st.selectbox() you must choose from before anything changes.

Changing, deleting and grouping

  • record.field = value changes a field, nested ones too (person.primary_name.first_name = "Chris"). So do list edits (.append(), .pop(), ...) and any set_*() method, e.g. person.set_privacy(True).
  • delete(record) deletes a record. It doesn't remove references to it from other records.
  • begin_changes("message") / end_changes() group the changes in between under that message. They're optional: without them the whole run is one group, named after the Gramplet.

Which records can be changed

Records from people() and the other plural functions, get_raw_object(), get_object(), get_selected() and db.* can be changed. filter() results can't, since they hold only some of a record's fields.

Asking for the same record twice in a run gives back the same object, with your changes in it.

Dry runs while you develop

In the Gramplet editor every run is a dry run. Each changed record is first checked against its Gramps schema, the same check the server makes; an invalid value stops the run and names the field.

One way a dry run can differ from the real thing: searches (filter(), count(), where=) run on the server, so they still see the tree as it was before the run's changes.

When changes are saved

In a real run, Run saves each group as the code closes it, at end_changes() or when the run ends. See Changing data for what the user sees, including Undo.

Widgets in the output (st.button("Apply") and so on) rerun the code in whichever mode the current result came from. So a Gramplet can show what it would change in a dry run, and apply it after Run.

Publishing to the Store

Folder layout

One folder per Gramplet in gramplet-store/, named by its id. The folder name and the manifest's own id field must match.

gramplet-store/
  <slug>/
    manifest.json   # required
    code.py         # required -- the Gramplet's Python source
    icon.png        # optional (png/jpg/jpeg/svg/webp)

manifest.json

Field Required Meaning
id yes Must match the folder name.
name yes Shown as the entry's title in the Store.
description yes Shown under the name in the Store.
version yes Plain semver, e.g. "1.0.0" — bump it whenever code.py or the manifest changes, so installed copies can detect an update is available.
author yes Shown in the Store.
category yes A short tag ("example", "detail", "utility", "chart", ...) used to group/filter entries.
views no Which object-type views ("person", "family", ...) this Gramplet can be added to. Omit for "every type".
listensToSelection no Whether this Gramplet should re-run when the selected record changes.
listensToFilter no Whether this Gramplet should re-run when the active filter changes (search box or Filters picker).
kind no "view" (the default) for a View Gramplet; "window" for a Gramplet run from the Gramplets menu. views/listensTo* only apply to View Gramplets.
allowChanges no true to install it with Allow data changes on (the Store shows a Changes your data badge). Only allowed with "kind": "window".

Rebuilding the catalog

After adding or editing an entry, regenerate catalog.json, the single file the app fetches:

npm --prefix app run build:gramplet-catalog

This checks every entry (required fields present, id matches its folder, version looks like semver, code.py not empty, kind valid) and fails loudly on the first problem rather than publishing a broken catalog.

Clone this wiki locally