SG Ground Truth

query fields and pages

Resolve a query field's value, and run the rows a saved Page shows

API

A query field and a saved Page are the same problem twice. Neither stores a result: both store a condition tree in the web interface's representation, with tokens standing for the row being read. Nothing on the REST side accepts that tree. Read it, rewrite it, run it yourself.

Call

import json
import re
import sys

sys.path.insert(0, "src")                      # or PYTHONPATH=src
from sg_groundtruth.client import FPT          # adds the bearer token and the /api/v1 prefix
from sg_groundtruth.env import load

c = FPT.from_env(load("."))                    # FPT_API_SITE_URL, FPT_API_SCRIPT_NAME, FPT_API_API_KEY
HASH = {"Content-Type": "application/vnd+shotgun.api3_hash+json"}   # nested groups need this one

# A relation whose value is a list. `in_last` names its unit second, so values[0] alone is a 400.
LIST_RELATIONS = {"in", "not_in", "between", "in_last", "not_in_last", "in_next", "not_in_next"}


def convert(node, tokens):
    """The stored condition tree -> the `filters` value of an api3_hash _search."""
    if "conditions" in node:                   # a group holds leaves and sub-groups as siblings
        return {"logical_operator": node.get("logical_operator", "and"),
                "conditions": [convert(child, tokens) for child in node["conditions"]]}
    values = [substitute(v, tokens) for v in (node.get("values") or [])]
    relation = node["relation"]
    # path, relation, values and nothing else: a leaf's `active` key is a 400.
    return [node["path"], relation,
            values if relation in LIST_RELATIONS else (values[0] if values else None)]


def substitute(value, tokens):
    """`valid` names a token. "valid" means a real row, whose name and uuid are labels, not inputs."""
    if not isinstance(value, dict):
        return value
    token = value.get("valid")
    if token in tokens:
        return tokens[token]
    if token and token != "valid":
        raise KeyError(f"no substitution for token {token!r}")
    return {"type": value["type"], "id": value["id"]}


def slug(entity_type):
    """Schema name -> URL slug. A Connection type already holds an underscore, so collapse the pair."""
    s = re.sub(r"(?<!^)(?=[A-Z])", "_", entity_type).lower().replace("__", "_")
    return s + ("es" if s.endswith(("s", "x", "ch", "sh")) else "s")


def search(entity_type, filters, fields, sort=None, size=500):
    out, page = [], 1
    while True:
        body = {"filters": filters, "fields": fields, "page": {"size": size, "number": page}}
        if sort:
            body["sort"] = sort
        r = c.post(f"/entity/{slug(entity_type)}/_search", headers=HASH, json=body)
        if not r.ok:
            raise SystemExit(r.text)           # never truncate an error body
        rows = r.json()["data"]
        out += rows
        if not rows:                           # links.next is emitted forever (probe 006)
            return out
        page += 1


def summarize(entity_type, filters, field, aggregate):
    r = c.post(f"/entity/{slug(entity_type)}/_summarize", headers=HASH,
               json={"filters": filters, "summary_fields": [{"field": field, "type": aggregate}]})
    if not r.ok:
        raise SystemExit(r.text)
    return r.json()["data"]["summaries"][field]


# ---- 1. resolve a query field --------------------------------------------------------------

def resolve_query_field(entity_type, field, row_id, tokens=None):
    """What the field would show, computed from the query the schema stores under it."""
    props = c.get(f"/schema/{entity_type}/fields/{field}").json()["data"]["properties"]
    query, aggregate = props["query"]["value"], props["summary_default"]["value"]
    target, column = query["entity_type"], props["summary_field"]["value"]
    filters = convert(query["filters"],
                      dict(tokens or {}, parent_entity_token={"type": entity_type, "id": row_id}))
    if aggregate != "single_record":
        return summarize(target, filters, column, aggregate)
    # single_record is not a _summarize type: sort the rows and take the first.
    order = props["summary_value"]["value"]
    rows = search(target, filters, [column, order["column"]],
                  sort=("-" if order["direction"] == "desc" else "") + order["column"], size=1)
    return rows[0]["attributes"][column] if rows else None


SHOT_ID = 862                                  # the caller supplies these
ASSET_ID = 1230
print("Shot.open_notes_count      field:",
      c.get(f"/entity/shots/{SHOT_ID}", params={"fields": "open_notes_count"})
      .json()["data"]["attributes"]["open_notes_count"],
      " resolved:", resolve_query_field("Shot", "open_notes_count", SHOT_ID))
print("Asset.sg_latest_version    field:",
      c.get(f"/entity/assets/{ASSET_ID}", params={"fields": "sg_latest_version"})
      .json()["data"]["attributes"]["sg_latest_version"],
      " resolved:", resolve_query_field("Asset", "sg_latest_version", ASSET_ID))


# ---- 2. run a saved page -------------------------------------------------------------------

def page_query(page_id):
    """A Page's shared layout: the entity type, the columns, and filters ready to send."""
    page = c.get(f"/entity/pages/{page_id}",
                 params={"fields": "name,entity_type,project"}).json()["data"]
    project = (page["relationships"]["project"] or {}).get("data")
    settings = search("PageSetting",
                      {"logical_operator": "and",
                       "conditions": [["page", "is", {"type": "Page", "id": page_id}],
                                      ["user", "is", None]]},   # the shared row, not a personal override
                      ["settings_json"])
    body = settings[0]["attributes"]["settings_json"]["children"]["body"]
    sorts = body["settings"].get("sorts") or []
    return {
        "entity_type": body["settings"]["entity_type"],
        "columns": body["children"]["list_content"]["settings"].get("columns") or [],
        "filters": convert(body["settings"]["filters"],
                           {"project_token": {"type": "Project", "id": project["id"]}} if project else {}),
        "sort": (("-" if sorts[0]["direction"] == "desc" else "") + sorts[0]["column"]) if sorts else None,
    }


PAGE_ID = 5099                                 # the caller supplies this
q = page_query(PAGE_ID)
print("\npage", PAGE_ID, q["entity_type"], "sort", q["sort"])
print("filters ", json.dumps(q["filters"]))
print("columns ", q["columns"])
rows = search(q["entity_type"], q["filters"], q["columns"], sort=q["sort"])
print(len(rows), "rows;", summarize(q["entity_type"], q["filters"], "id", "record_count"), "by record_count")
print(json.dumps(rows[0]))

Response

Shot.open_notes_count      field: 13    resolved: 13
Asset.sg_latest_version    field: None  resolved: charA_v001

page 5099 Shot sort code
filters  {"logical_operator": "and", "conditions": [
           {"logical_operator": "and", "conditions": [["project", "is", {"type": "Project", "id": 70}]]},
           {"logical_operator": "and", "conditions": [
             {"logical_operator": "and", "conditions": [["sg_status_list", "is", "wtg"]]}]}]}
columns  ['image', 'sg_status_list', 'code', 'sg_sequence', 'description', 'created_by']
183 rows; 183 by record_count
{"type": "Shot",
 "attributes": {"image": null, "sg_status_list": "wtg", "code": "sh010", "description": "..."},
 "relationships": {
   "sg_sequence": {"data": {"id": 23, "name": "seq01", "type": "Sequence"},
                   "links": {"self": "/api/v1/entity/shots/868/relationships/sg_sequence",
                             "related": "/api/v1/entity/sequences/23"}},
   "created_by":  {"data": {"id": 24, "name": "<user>", "type": "HumanUser"}, "links": {...}}},
 "id": 868, "links": {"self": "/api/v1/entity/shots/868"}}

That page's stored tree, before translation, and the two controls it is checked against:

body.settings.filters as stored
  {"logical_operator": "and", "conditions": [
    {"logical_operator": "and", "top_level_project_filter": true, "conditions": [
      {"path": "project", "relation": "is", "active": true, "top_level_project_filter": true,
       "values": [{"type": "Project", "id": 70, "name": "<project>"}]}]},
    {"logical_operator": "and", "conditions": [
      {"logical_operator": "and", "conditions": [
        {"path": "sg_status_list", "relation": "is", "values": ["wtg"], "active": "true"}]}]}],
   "filter_name": "<saved filter>", "selected": true, "filter_id": 2}

control, written by hand rather than translated
  api3_array [["project", "is", {"type": "Project", "id": 70}], ["sg_status_list", "is", "wtg"]]  -> 183
  _summarize record_count on the translated tree                                                 -> 183
  every Shot in the project                                                                      -> 300

Notes

The field is not a shortcut

GET the field and you get a value with no way to tell whether it was computed. On the probed site the stock open_notes_count agrees with its own query and every custom query field reads null while its query matches rows:

field summary_default the field reads its own query returns
Shot.open_notes_count, rows 862, 863, 864 record_count 13, 12, 12 13, 12, 12
CustomEntity01.sg_test_results, its three busiest rows record_count null 1166, 718, 717
Asset.sg_latest_version single_record null 1 Version, whose code is charA_v001
Project.sg_latest_version, two projects single_record null 100 and 53 Versions

On the probed site a computed record_count reads an integer including 0, so null on one of them means uncomputed rather than empty. single_record has no such marker: an uncomputed field and a query with no rows both read null. Resolve rather than read whenever the answer matters (field_types/summary).

The four flavours

summary_default names the aggregate. single_record is a sorted _search taking row 0; every other value seen here is also a _summarize type, so one call covers the rest. The vocabulary is the endpoint's own, from the 400 a bogus type returns:

type must be one of: record_count, count, sum, maximum, minimum, average, earliest, latest,
percentage, status_percentage, status_percentage_as_float, status_list, checked, unchecked
flavour summary_default resolved by on the probed site
count record_count _summarize {"field": summary_field, "type": summary_default} 39 fields, all open_notes_count but one
single-record lookup single_record _search, sort built from summary_value, row 0's summary_field 3 fields
aggregate sum, average, minimum, maximum, count, earliest, latest the same _summarize call no field declares one
percentage percentage, status_percentage, status_percentage_as_float the same _summarize call no field declares one

Two of the four have rows here. For the other two the call shape was exercised without a stored field to compare against: _summarize on Shot.sg_cut_duration answered 200 for sum (30636), average (102.12), minimum (45), maximum (160), count (300) and percentage (0), and 500 Shotgun Server Error for status_percentage on that number field. Confirm the aggregate against a real field of that flavour before trusting it.

single_record is not in the list above: _summarize with "type": "single_record" is 400. Sorting is the only route, and summary_value holds the sort: {"column": "created_at", "direction": "desc", "detail_link": true} becomes ?sort=-created_at. summary_field names the column to read off the row that comes back, which is code on all three single-record fields here and id on every record_count one.

The tree runs nowhere as stored

Both refusals, verbatim, from sending Shot.open_notes_count's own properties.query.filters:

sent result
the tree as filters under api3_hash 400 Missing logical operator: {"path" => "note_links", "relation" => "is", "values" => [{"id" => 0, "name" => "Current Entity", "type" => "Entity", "valid" => "parent_entity_token"}]}
its conditions as filters under api3_array 400 Invalid filter. Expected array of basic condition arrays but received: [...]

Translation is the only path (probe 030). What survives and what does not:

part of the stored tree in the request
logical_operator and conditions on a group kept as they stand
a group's filter_name, filter_id, selected, active, top_level_project_filter, qb_condition_subgroup tolerated, and dropped anyway
a leaf's active and top_level_project_filter must be dropped: an extra key on a leaf is 400
{"path": p, "relation": r, "values": v} [p, r, v[0]], or [p, r, v] for a LIST_RELATIONS relation
an entity value's name, uuid, subtype, valid dropped; keep {type, id}
a group whose conditions is [] keep or drop under and; it is no filter, not a no-match

An empty group matches every row: {"logical_operator": "and", "conditions": [{"logical_operator": "and", "conditions": []}]} returned 749, against 749 Shots site-wide. Under and that is the identity, and every empty group on the probed site sits under one. Dropping it under an or would narrow the query.

Tokens

A value whose valid key is anything other than the string "valid" is a placeholder the caller has to fill in. id is 0 and name is a label written for the web interface, so neither is usable.

valid stands for substitute
parent_entity_token the row the query field is being read on {"type": <the field's own type>, "id": <row id>}
project_token the project the page belongs to Page.project, read from the Page itself
logged_in_user_token the person viewing a script has no viewing user; supply one or refuse the page

The token's own type is not always the type to send: Shot.open_notes_count stores {"id": 0, "name": "Current Entity", "type": "Entity", "valid": "parent_entity_token"} and the substitution that reproduces the number is {"type": "Shot", "id": 862}. On the probed site, over the 438 stored page filter trees, valid took four values: "valid" 345 times, project_token 20, absent 14, logged_in_user_token twice.

Relations whose value is a list

values is always an array in storage and almost always one element long. in_last and in_next name a unit as a second element, and flattening to values[0] loses it:

sent result
["created_at", "in_last", [4, "WEEK"]] 200
["created_at", "in_last", 4] 400 API summarize() 'in_last' 'relation' expects a 2-element array: [4]

On the probed site the 438 stored trees used three relations: is 394 times with one value, is_not 4 times with one, and in_last 9 times with two. Any site can store more, so key the decision on the relation rather than on how many values happen to be present.

Reading the page

PageSetting rows come in two shapes and only one is the page's (probe 023): filter on ["user", "is", None] or a personal column order reads as the page's own. The tree path is fixed: children.body.settings holds entity_type, filters, sorts and grouping; children.body.children.list_content.settings holds columns.

  • columns are schema field names in display order and go straight into ?fields. All six on the page above were returned. On the probed site another Shot page lists the pivot columns step_35 and step_106, which are real fields in /schema/Shot/fields and were returned like any other.
  • id is a legal column and is not a field. On an EventLogEntry page listing it, ?fields=id,user answered 200 with attributes: {} and the id under the row's own id key. Drop id from the list and read row["id"].
  • A column absent from /schema/<Type>/fields is dropped at 200 with no error (probe 004), so check the list against the schema to know which columns you lost (probe 023).
  • sorts and grouping are lists of {column, direction}. ?sort takes one field, so the second and later sort keys and the grouping have to be applied client-side.

The stored project can be a project that is gone

The top_level_project_filter condition duplicates the page's project scope and is not maintained. On the probed site 19 pages store a filter naming a project other than their own Page.project. All 19 name the same id, and GET /entity/projects/<that id> is 404 on every one: the project was deleted and the stored filter kept its id. Translated verbatim, those pages return 0 rows at 200. Substitute Page.project over whatever the tree names, which is what page_query above does through project_token.

On a live row the id decides and a stored name is a stale label the server never reads. ["project", "is", {"type": "Project", "id": 70}], the same with "name": "zzz_not_the_projects_name", and the same again with valid and uuid attached all returned 300.

Once translated, the field is still unusable as a field

Re-running the query is the only way to select on one of these:

attempt result
[["open_notes_count", "greater_than", 3]] 400 API read() Shot.open_notes_count's 'summary' data type cannot be used in a filter.
?sort=open_notes_count, ?sort=-open_notes_count 200, both returning the same order as ?sort=code
?fields=entity.Shot.open_notes_count on a Version 200, 13, the linked Shot's stored value

The dotted read returns what the linked row stores, so it inherits the same trap: null there means uncomputed. Resolve the hop first, then resolve the field on the row you land on.

The URL slug

_search needs a URL slug and the schema gives a type name. Underscore before each capital, lowercase, then pluralise. The <Type>_<field>_Connection names already hold an underscore and the naive rule doubles it, giving 404 Entity type 'asset_linked_proj...' does not exist. on 10 of the 114 types here. Collapsing the pair makes all 114 answer 200.

Every entry on this site is the output of a probe in probes/. The corpus is generated by running those probes against a live Flow Production Tracking site, not written from memory.

Not affiliated with or endorsed by Autodesk. Flow Production Tracking is their product; this is an independent record of how its REST API answers.