Skip to content
Registry StackDocsDevelopment (unreleased)

Query a registry from Python and Node

For the consumer or verifier

View as Markdown

In about twenty minutes you will have a handful of short programs, in Python and in Node, that read the contacts you added in Create your first registry and Change a registry with an Action, add one, change one, and register one more. Each step shows both languages: follow the one your application uses, or run both against the same registry.

Outcome
Python and Node programs that read, add, and change contacts in your local registry through the Registry Stack client.
Time
About 20 minutes
Level
Python or Node application developer
Prerequisites
Python 3.11 or later, or Node 24.11 or laterThe running registry from Change a registry with an Action

In the project directory, request a token for the editor test caller and put it, with the registry’s address, where your programs will read them:

Terminal window
bregctl dev token editor
export REGISTRY_URL=http://127.0.0.1:8090
export REGISTRY_TOKEN="$(sed 's/^Authorization: Bearer //' .breg/dev/secrets/editor.header)"
bregctl dev token succeeded.

Use the address bregctl dev start reported if it differs. The token lasts five minutes: when a program fails with status 401, run these three commands again in the project directory.

The client’s version must match the registry’s, so read it from breg rather than typing it. Make a directory beside the project for your programs and install the client there:

Terminal window
mkdir ../contacts-app
cd ../contacts-app
version="$(breg --version | awk '{print $2}')"

For Python, in a virtual environment:

Terminal window
python3 -m venv .venv
. .venv/bin/activate
python -m pip install --quiet "registry-stack-client==$version"

For Node:

Terminal window
npm install --silent "@registrystack/client@$version"

Each package carries a native build for Linux amd64, Linux arm64, and macOS arm64.

Every program starts the same way, so put that in one file. Create registry.py:

registry.py
import os
from registry_client import breg
PROFILE = "directory-editor"
client = breg.BaseRegistryClient(
base_url=os.environ["REGISTRY_URL"],
authorization={"static": os.environ["REGISTRY_TOKEN"]},
)
contract = client.registry_contract(access_profile=PROFILE)

Or, for Node, registry.mjs:

registry.mjs
import { breg } from '@registrystack/client';
export const PROFILE = 'directory-editor';
export const client = new breg.BaseRegistryClient({
baseUrl: process.env.REGISTRY_URL,
authorization: { static: process.env.REGISTRY_TOKEN },
});
export const contract = await client.registryContract(PROFILE);

static sends the token unchanged with every call. directory-editor is the access profile in registry.yaml that grants editor its reads and changes. The contract is what the registry publishes about itself for that profile: its name and version, and the operations and Actions the profile may use.

Create read.py:

read.py
from registry import PROFILE, client, contract
print(f"{contract.registry_identifier} version {contract.registry_version}")
page = client.list_records("contacts", access_profile=PROFILE)
for record in sorted(page["value"]["items"], key=lambda r: r["domainData"]["name"]):
print(record["domainData"]["name"], record["domainData"]["email"])
Terminal window
python read.py
local-directory version 0.1.0
Example contact contact@example.test
Moving contact new@example.test
Registered contact registered@example.test

Or create read.mjs:

read.mjs
import { PROFILE, client, contract } from './registry.mjs';
console.log(`${contract.registryIdentifier} version ${contract.registryVersion}`);
const page = await client.listRecords('contacts', { accessProfile: PROFILE });
const byName = (a, b) => a.domainData.name.localeCompare(b.domainData.name);
for (const record of page.value.items.toSorted(byName)) {
console.log(record.domainData.name, record.domainData.email);
}
Terminal window
node read.mjs
local-directory version 0.1.0
Example contact contact@example.test
Moving contact new@example.test
Registered contact registered@example.test

The first line is the registry block of registry.yaml: its id and the version you give your project. The others are the contacts from the first two tutorials, each a Record of the contact Record type. contacts is the Record type’s route, and each item carries its fields under domainData. The list comes back in no particular order, so the programs sort it by name.

Create add_contact.py, which adds a test contact and prints its identifier:

add_contact.py
from registry import PROFILE, client, contract
create = contract.select_create("records.contact.create", PROFILE)
created = client.create_record(
create,
{"name": "Ada Lovelace", "email": "ada@example.test", "jurisdiction": "zone-a"},
"add-ada-lovelace",
)
print(created["value"]["data"]["recordIdentifier"])

Or add_contact.mjs:

add_contact.mjs
import { PROFILE, client, contract } from './registry.mjs';
const create = contract.selectCreate('records.contact.create', PROFILE);
const created = await client.createRecord(
create,
{ name: 'Ada Lovelace', email: 'ada@example.test', jurisdiction: 'zone-a' },
'add-ada-lovelace',
);
console.log(created.value.data.recordIdentifier);

select_create finds the create operation in the contract and fails at once if the profile may not use it. The last argument, add-ada-lovelace, is the idempotency key: send the same request under the same key again, after a timeout or a crash, and the registry returns the contact it already created instead of adding a second one. Run the program and keep the identifier:

Terminal window
contact_id="$(python add_contact.py)"
echo "$contact_id"
<record-id>

The Node program prints the same identifier, because it sends the same request under the same key:

Terminal window
node add_contact.mjs
<record-id>
Terminal window
python read.py
local-directory version 0.1.0
Ada Lovelace ada@example.test
Example contact contact@example.test
Moving contact new@example.test
Registered contact registered@example.test

The project allows one change to an existing contact: the Action update-contact-email, which the registry applies in the same request. Create change_email.py, which takes a contact’s identifier and a new address:

change_email.py
import sys
from registry_client import breg
from registry import PROFILE, client, contract
record_id, email = sys.argv[1], sys.argv[2]
action = contract.select_action("update-contact-email", PROFILE)
try:
record = client.get_record("contacts", record_id, access_profile=PROFILE)
revision = record["value"]["data"]["revisionIdentifier"]
conditions = client.action_target_conditions(action, {"contactId": record_id})
outcome = client.invoke_action(
action,
{"contactId": record_id, "email": email},
f"change-email-{record_id}-{revision}",
conditions,
)
except breg.BaseRegistryClientError as error:
sys.exit(f"refused: {error.kind} ({error.code})")
print(outcome["value"])

Or change_email.mjs:

change_email.mjs
import { breg } from '@registrystack/client';
import { PROFILE, client, contract } from './registry.mjs';
const [recordId, email] = process.argv.slice(2);
const action = contract.selectAction('update-contact-email', PROFILE);
try {
const record = await client.getRecord('contacts', recordId, { accessProfile: PROFILE });
const revision = record.value.data.revisionIdentifier;
const conditions = await client.actionTargetConditions(action, { contactId: recordId });
const outcome = await client.invokeAction(
action,
{ contactId: recordId, email },
`change-email-${recordId}-${revision}`,
conditions,
);
console.log(outcome.value);
} catch (error) {
if (!(error instanceof breg.BaseRegistryClientError)) throw error;
console.error(`refused: ${error.kind} (${error.code})`);
process.exit(1);
}

The target conditions are the contact’s current state as the registry sees it. The registry makes the change only if the contact still matches them, so two programs changing the same contact at once cannot overwrite each other unseen. The idempotency key includes the contact’s revision: a retry of this change reuses it, and the next change gets a new one.

Terminal window
python change_email.py "$contact_id" ada@lovelace.example.test
{'action': 'update-contact-email', 'applicationId': '<application-id>', 'results': {'contact-email': {'recordId': '<record-id>', 'recordType': 'contact', 'revision': 2}}}
Terminal window
node change_email.mjs "$contact_id" ada@analytical.example.test
{
action: 'update-contact-email',
applicationId: '<application-id>',
results: {
'contact-email': {
recordId: '<record-id>',
recordType: 'contact',
revision: 3
}
}
}

The registry answered each call with an ActionReceipt. It names the Action, gives the change an applicationId, and lists under each effect of the Action’s Plan, here the single effect contact-email, the Record it changed and that Record’s new revision.

When the registry refuses, the client raises BaseRegistryClientError, and its kind says what went wrong. Ask for a contact that does not exist:

Terminal window
python change_email.py 00000000-0000-4000-8000-000000000000 someone@example.test
refused: not-found (resource.not_found)

The Node program prints the same line. kind is what a program branches on; code is the registry’s own reason. An address the Action does not accept, such as an empty one, is refused with invalid-request before anything is sent. The client capabilities reference lists every operation, and the client API reference every error kind.

The other Action, register-contact, changes nothing when it starts. It opens a Proposal: a draft you submit, which the registry then applies. This project requires no review, so the registry applies it straight after the submit. Create register.py:

register.py
import time
from registry import PROFILE, client, contract
action = contract.select_action("register-contact", PROFILE)
proposal = client.start_proposal(
action,
{"name": "Grace Hopper", "email": "grace@example.test", "jurisdiction": "zone-a"},
"register-grace-hopper",
)
print(proposal.identifier, proposal.document["state"])
submit = client.prepare_proposal(proposal, "submit", {}, "submit-grace-hopper")
proposal = client.execute_proposal(submit)
while proposal.document["state"] != "applied":
time.sleep(1)
proposal = client.read_proposal(proposal)
print(proposal.document["state"], proposal.document["application"]["resultLinks"][0])
print(proposal.document["links"]["cancel"]["availability"])

Or register.mjs. The Node client takes the inputs and the submit body as JSON text, and returns the Proposal’s document the same way:

register.mjs
import { setTimeout } from 'node:timers/promises';
import { PROFILE, client, contract } from './registry.mjs';
const action = contract.selectAction('register-contact', PROFILE);
let proposal = await client.startProposal(
action,
JSON.stringify({ name: 'Grace Hopper', email: 'grace@example.test', jurisdiction: 'zone-a' }),
'register-grace-hopper',
);
let document = JSON.parse(proposal.documentJson);
console.log(proposal.identifier, document.state);
const submit = client.prepareProposal(proposal, 'submit', '{}', 'submit-grace-hopper');
proposal = await client.executeProposal(submit);
document = JSON.parse(proposal.documentJson);
while (document.state !== 'applied') {
await setTimeout(1000);
proposal = await client.readProposal(proposal);
document = JSON.parse(proposal.documentJson);
}
console.log(document.state, document.application.resultLinks[0]);
console.log(document.links.cancel.availability);
Terminal window
python register.py
<proposal-id> draft
applied {'recordId': '<record-id>', 'recordType': 'contact', 'revision': 1}
{'available': False, 'observedRevision': 3, 'reason': 'already-applied', 'recovery': 'receipt-only'}

The Proposal started as a draft. After the submit, the program reads it until the registry has applied it, then prints the Record the registry created. The last line is the availability of the Proposal’s cancel link. A Proposal lists the moves it allows, each with an availability: whether the move is open now and, if not, the reason and the recovery, what you can still do. receipt-only means the Proposal is finished and only its record of what happened remains.

Terminal window
node register.mjs
<proposal-id> draft
applied {
recordType: 'contact',
recordId: '<record-id>',
revision: 1
}
{
available: false,
observedRevision: 3,
reason: 'already-applied',
recovery: 'receipt-only'
}

The Node program used the same keys, so the registry handed back the same Proposal rather than opening a second one. List the contacts once more:

Terminal window
python read.py
local-directory version 0.1.0
Ada Lovelace ada@analytical.example.test
Example contact contact@example.test
Grace Hopper grace@example.test
Moving contact new@example.test
Registered contact registered@example.test

Extend a registry with a module