Ontology SDK

Python

Read objects and apply actions from Python.

The Python client needs Python 3.9 or newer. It uses only the standard library.

Install

Put your token in ~/.netrc once, so it never appears in a URL or in your shell history:

machine cnvrg.si login __token__ password <your token>

The login is always __token__. Then install:

pip install --index-url https://cnvrg.si/api/v2/osdk/pypi/simple/ converge-osdk-<ontology>

Use --index-url, never --extra-index-url, for the Converge index. With --extra-index-url, pip also asks public PyPI for converge-sdk and converge-osdk-<ontology>, and whoever registered those names there would run code on your machine at install time. The Converge index serves everything the package needs.

To install with conda instead, see conda.

Every generated client is the package ontology_sdk. Two ontology clients in one environment overwrite each other, with pip and with conda alike. Use one virtual environment (or conda environment) per ontology.

The package holds your ontology's types and depends on converge-sdk, the open-source runtime (Apache-2.0, source in the TANTIRA/CSDK-Python repository on GitHub). The same Converge index serves converge-sdk too, so one pip install pulls both. The JSON download (GET …/osdk/python) contains the runtime files as well, so a client you copy into a project works with nothing else installed.

Without generated code

converge-sdk on its own reaches datasets, SQL and any ontology by API name:

from converge_sdk import ConvergeClient, prop

client = ConvergeClient()   # CONVERGE_HOSTNAME and CONVERGE_TOKEN (or FOUNDRY_TOKEN), or ~/.converge/config
rows = client.sql.execute("SELECT count(*) AS n FROM `/Project/orders`")
o = client.ontology("<ontology>")
orders = o.objects("Order").where(prop("total") > 5).take(10)
o.action("update_tier")(customer="c1", tier="gold")
o.batch_action("update_tier")([{"customer": "c1", "tier": "gold"}, {"customer": "c2", "tier": "gold"}])
value = o.query("customer_lifetime_value")(customer_rid="customer/c1")

The untyped ontology client also has o.attachments and o.media, the same as client.ontology.attachments and client.ontology.media below. Parameters go to the server as you name them.

Create a client

import os
from ontology_sdk import FoundryClient, UserTokenAuth

client = FoundryClient("https://cnvrg.si/api", auth=UserTokenAuth(os.environ["FOUNDRY_TOKEN"]))

FoundryClient waits 60 seconds per request by default. Pass timeout= (in seconds) to change that.

  • FoundryClient() with no arguments reads the host from CONVERGE_HOSTNAME and the token from CONVERGE_TOKEN or FOUNDRY_TOKEN, then falls back to hostname and token in the [default] section of ~/.converge/config.
  • Include /api in the host. With hostname="cnvrg.si" the calls reach the web app instead of the API and fail with confusing errors (HTML instead of JSON, or 405).
  • UserTokenAuth also takes a zero-argument function that returns the token. It runs on each request, so a refreshed token is picked up.
  • Without a config=, proxies come from HTTPS_PROXY / NO_PROXY (or the OS proxy settings), and certificates from Python's default CA store (SSL_CERT_FILE overrides it).

The same client also reaches the platform API: client.datasets and client.sql work as on ConvergeClient, with the same host, token and settings:

client.datasets.get("ri.example.dataset.1")
rows = client.sql.execute("SELECT count(*) AS n FROM `/Project/orders`")

HTTP options

As in Palantir's foundry-platform-python, config=Config(...) sets headers, proxies, TLS verification and the timeout:

from converge_sdk import Config

client = FoundryClient("https://cnvrg.si/api", auth=UserTokenAuth(os.environ["FOUNDRY_TOKEN"]),
                       config=Config(default_headers={"X-Team": "ops"},
                                     proxies={"https": os.environ["CORP_PROXY_URL"]},
                                     verify="/etc/ssl/corp-ca.pem", timeout=30))
  • default_headers go on every request, with their names lowercased.
    • Authorization is refused with a ConfigError: the token comes from auth only.
    • So are Host (which could route the token to another site behind a shared gateway), Proxy-Authorization and the framing headers Content-Length, Transfer-Encoding, Connection, TE and Upgrade.
  • proxies maps a scheme to a proxy URL, as urllib's ProxyHandler takes it. {} turns the proxy environment variables off.
    • An http or all proxy must be on localhost, because it would see plain-http requests with the token in them.
    • Requests to https:// hosts go through a CONNECT tunnel, so the proxy never sees the token. The CONNECT line and any user:pass@ in the proxy URL still travel in plaintext, even with an https:// proxy URL.
  • verify is True (the system store), the path of a PEM CA bundle file or directory, or False.
    • A file that is not a PEM bundle raises ConfigError.
    • False checks no certificate, so anyone on the network path could read your token. It raises an InsecureConfigWarning when you create the Config and again on the first request. Use a CA bundle instead.
  • timeout, in seconds, overrides timeout=.

Read objects

from ontology_sdk import Customer

customers = client.ontology.objects.Customer

top = (customers
       .where((Customer.object_type.revenue > 1000) & Customer.object_type.tier.in_(["gold", "platinum"]))
       .order_by(Customer.object_type.revenue.desc())
       .take(10))

for c in customers.iterate(page_size=100):
    print(c.name)

one = customers.get("c1")          # or customers.get("customer/c1")
n = customers.count().compute()

get works for keys made of letters, digits, _ and -. For any other key, filter on the key property instead. In an == filter, a value made of letters, a /, then letters, digits, _ or - (such as a/b or customer/c-1) is read as an object reference, so it matches nothing. Match such a key with a full-value regex instead: customers.where(Customer.object_type.id.regex("a/b")). A / needs no escaping. Other keys with a /, such as a1/b, 1/2 or a/b/c, work with ==.

page(page_size=…) takes 1 to 1000 and defaults to 50. A larger size, such as Palantir's 10,000, answers 422. iterate() and take() page for you.

customers.select(Customer.object_type.name) loads only those properties; the others come back None.

page, iterate and list take apply_modifiers={"<property>": "<modifier>"}, the same modifiers as the TypeScript $applyModifiers: applyReducers returns an array's reduced element, applyMainValue a struct's main value, and applyReducersAndExtractMainValue the main value of the reduced struct of a struct array. Filters and order_by still use the full value, and get has no such option. A key is a property name. A Property is unhashable, so pass it as (property, modifier) pairs: apply_modifiers=[(Site.object_type.readings, "applyReducers")]. A modifier name the SDK does not know raises ValueError. The server answers 400 when the property has no matching configuration and 403 when it is masked.

A geopoint property stored as "lat,lon" is a GeoPoint (from converge_sdk). It is that text, so it compares and serialises as before, with latitude and longitude as floats. A geopoint the server sends as GeoJSON stays as sent.

Properties support ==, <, <=, >, >=, in_, range, regex, starts_with and is_null(), and text search with contains_any_term, contains_all_terms, contains_all_terms_in_order and contains_all_terms_in_order_prefix_last_term: whole words, ignoring case, by the rules in the TypeScript page. Combine filters with & and |.

  • <, <=, >, >= and range take a number or an ISO date or timestamp ("2026-10-31", "2026-10-31T09:00:00Z"). Any other string answers 422.
  • starts_with is case-sensitive. It is a full-value regex, unlike the text-search operators, which ignore case.

Follow links

orders = client.ontology.objects.Customer.linked("c1", "orders")
first = orders.page(page_size=50)
for o in orders.iterate():
    print(o.rid)
owner = client.ontology.objects.Order.linked("o1", "placedBy").get("c1")

Linked objects come back as their type's class. A type that is not in your client comes back as a plain dict. An unknown link name raises ValueError.

Aggregate

by_tier = customers.aggregate("sum", field=Customer.object_type.revenue,
                              group_by=[Customer.object_type.tier])
for row in by_tier["data"]:
    print(row["group"], row["metrics"])

type is one of count, sum, avg, min, max, exactDistinct or approximateDistinct, and a where on the set applies. Distinct counts work on up to 10,000 visible objects (400 above that). A group_by entry can bucket a number: {"field": Customer.object_type.revenue, "fixedWidth": 500} or {"field": ..., "ranges": [[None, 1000], [1000, None]]}. regex() matches the whole value: ^ and $ are refused.

Several metrics at once, with time buckets and the largest groups only:

from converge_sdk.ontologies import duration, max_group_count, metric

customers.aggregate(
    metrics=[metric("count"), metric("sum", Customer.object_type.revenue, "revenue")],
    group_by=[duration(Customer.object_type.signed_up, 1, "MONTHS"), max_group_count(Customer.object_type.tier, 5)],
    order=("revenue", "desc"),
)

The rules are the same as in TypeScript: UTC buckets, weeks from Monday, APPROXIMATE when max_group_count drops groups, at most 10 metrics and 10,000 groups.

Nearest neighbors

hits = client.ontology.objects.Doc.where(Doc.object_type.lang == "en").nearest_neighbors(embedding, 10, Doc.object_type.embedding)
hits["data"][0]["$score"]   # cosine similarity, best first

The answer is the server's dict. skipped counts the readable objects with no comparable vector. The query must have exactly the property's vectorDimension numbers. The rules are the same as in TypeScript.

Apply an action

from ontology_sdk import ActionConfig, ActionMode, ReturnEditsMode

client.ontology.actions.update_tier(
    customer="c1", tier="platinum",
    action_config=ActionConfig(mode=ActionMode.VALIDATE_ONLY),
)

To get the edits back, pass ActionConfig(return_edits=ReturnEditsMode.ALL).

Leave out action_config to apply the action. A refused call raises PalantirRPCException, which holds the HTTP status and the server's reason.

The answer is a SyncApplyActionResponse, under its Python OSDK 2.x name. It is a dict, so response.validation["result"] and json.dumps(response) work, and its keys also read as snake_case attributes: response.validation.result, response.validation.submission_criteria, response.edits. A key named like a dict method (items, keys, get) reads only by index, as response["items"].

An action that needs approval is not applied yet. The server answers 202 with {"status": "pending_approval", "approval_id": "…"}. Then response.status is "pending_approval", response.approval_id names the request, and response.validation and response.edits are None, so check response.status before you read validation. Both fields are None for an applied action.

client.ontology.actions.update_tier.validate(customer="c1", tier="platinum") runs the checks only, writes nothing and returns a ValidateActionResponse (result, submission_criteria, parameters).

Batches

from ontology_sdk import BatchActionConfig, ReturnEditsMode

client.ontology.batch_actions.update_tier(
    [{"customer": "c1", "tier": "gold"}, {"customer": "c2", "tier": "gold"}],
    batch_action_config=BatchActionConfig(return_edits=ReturnEditsMode.ALL),
)

It returns a BatchApplyActionResponse (results, edits). A batch takes 1 to 100 parameter sets. For a plain action it is all-or-nothing. Authored actions and actions that need approval run one at a time and stop at the first failure, so the earlier ones stay applied (the error says how many).

Attachments

att = client.ontology.attachments.upload(open("invoice.pdf", "rb").read(), "invoice.pdf", "application/pdf")
client.ontology.actions.attach_invoice(order="o1", invoice=att["rid"])
data = client.ontology.attachments.read(att["rid"])   # bytes

get(rid) returns the metadata. Who can read an attachment works the same as in TypeScript.

Media

A media reference property of a generated object is a Media:

order = client.ontology.objects.Order.get("o1")
data = order.photo.get_media_content()        # bytes
meta = order.photo.get_media_metadata()       # .path, .size_bytes, .media_type
ref = order.photo.get_media_reference()       # the MediaReference the property holds

ref = client.ontology.media.upload("Order", "photo", png_bytes, media_item_path="orders/o9.png")
client.ontology.actions.set_photo(order_id="o9", photo=ref)

client.ontology.media.get("Order", "o1", "photo") reads one without loading the object type. The server checks that you can read the object (its visibility, markings and masking) and the media set. The content is untrusted, because the uploader chose its type. Showing an image, video or audio in an <img>, <video> or <audio> element is fine; never open other content (HTML, SVG) as a blob: or data: URL in a frame, tab or link, where it would run on your app's origin.

Run a query

value = client.ontology.queries.customer_lifetime_value(customer_rid="customer/c1")

Queries run as you and are read-only. Parameters are checked like action parameters (TypeError for an unknown or missing one).

The return value follows the query's output type:

  • An object comes back as its class. It is read again as you, so it is None when you can't read it.
  • An object set comes back as an ObjectSet, read as you when you iterate it.
  • A list or set of objects comes back as a list of the class.
  • A geopoint comes back as a GeoPoint.
  • Anything else, a struct (a dict) included, comes back as the server sent it.

Moving existing code

The client uses the same names as the Python OSDK 2.x: FoundryClient, UserTokenAuth, Config, ActionConfig, ActionMode, SyncApplyActionResponse, PalantirRPCException, Type.object_type.<property>, where, order_by, page, iterate, take, count().compute() and get. To move existing code:

  1. Change the import from your old package name to ontology_sdk.
  2. Point FoundryClient at https://cnvrg.si/api.
  3. Fix the four places where Converge differs from the Python OSDK 2.x:
    • Aggregates are one aggregate(...) call that returns the server's dict (see Aggregate). There is no .group_by(...).count().compute(), and ranges are lists [[lo, hi], ...], not sets of tuples.
    • Links go through client.ontology.objects.Customer.linked(pk, "orders"). Objects have no customer.orders() link methods.
    • attachments.upload(data, filename, media_type) returns a dict: pass att["rid"] to the action, not an Attachment object.
    • get, page and iterate take no include_rid or properties keyword: either raises TypeError. .rid is always set, and .select(...) replaces properties=.

Negation works as in the OSDK: ~clause, != and is_null(False). != also matches objects with no value. On one object type, | is a union, & an intersection and a & ~b subtracts b from a.

page() returns a Page with data, next_page_token and total_count.

Converge