stacs.verify¶
What a STAC API serves, compared with what is published.
Two comparisons, and neither is a count:
- id sets, in both directions. A
/searchon a list of ids silently omits the ids it does not have, so "asked for N, got N" can be true while the sets differ. - bodies, by digest. A rebuild keeps every id, so equal id sets say nothing about whether the API serves the body that was published.
Every way a digest can be absent raises rather than comparing: an unread body is an error, never "unchanged".
The API base URL and the collection id are always the caller's. Nothing here defaults to a deployment.
DigestError
¶
Bases: ValueError
A body that has no canonical form: NaN or Infinity, a number beyond a double, or a string that is not valid Unicode. Raised with the item's id, never compared.
ids_registered(collection_id, api, page_size=PAGE_SIZE, session=None)
¶
Every item id the API currently serves for a collection.
Keyset paging over POST /search with fields:{include:["id"]}.
bodies_registered(collection_id, api, page_size=PAGE_SIZE, session=None)
¶
{id: body_digest} for every item the API serves in a collection.
The same enumeration as ids_registered, but of FULL bodies -- no fields
include, since a digest of an id-only stub would compare equal to nothing and
differ from everything. Measured 2026-09-28: a 10,000-item page is 18 MB and 29 s,
so a 102k-item catalogue is ~5.5 min. Only the digest is kept, so memory is ~100
bytes an item rather than the body.
A repeated id raises. The API's primary key makes one impossible, so seeing one means the paging overlapped -- and a dict keyed by id would have collapsed it silently into a single entry.
search_body(ids, collection_id, ids_only=True)
¶
The POST /search body for an id lookup, scoped to one collection.
Pure, and separated out so it can be asserted on offline. Two parameters, each of which exists because omitting it fails silently.
limit: the API's DEFAULT LIMIT IS 10, so a body without one silently returns the
first 10 of however many ids were asked for. That reads as "590 of my 600 items are
missing". Measured against a live API: 600 registered ids, no limit -> 10 features;
limit=600 -> 600.
collections: without it a /search asks "is this id served ANYWHERE", which is a
different question from the one every caller means. Two collections sharing ids on
one endpoint (a rename, mid-cutover) make an unscoped verification pass on the old
collection's rows while zero items registered into the new one. The collection id
is required rather than defaulted, because a default is exactly the thing that
would go unnoticed.
ids_only=False drops the fields include, for a caller comparing content: the
digest of an id-only stub matches no real body.
Examples:
>>> search_body(["a", "b"], "my-collection")
{'collections': ['my-collection'], 'ids': ['a', 'b'], 'limit': 2,
'fields': {'include': ['id']}}
>>> search_body(["a"], "")
Traceback (most recent call last):
...
ValueError: collection_id is required: an unscoped /search answers about every ...
bodies_serving(ids, collection_id, api, chunk=500, session=None)
¶
{id: body_digest} for those of these ids the API serves IN THIS COLLECTION.
Batched because a very long id list is a real request-size ceiling. An id the API does not serve is simply absent from the result -- a /search omits ids that do not exist without erroring -- so the caller compares key SETS, never a count, and then the digests.
ids_diff(published, registered)
¶
(missing, orphaned) — reported in BOTH directions.
missing = published but not registered (the API is behind the catalogue) orphaned = registered but not published (a delete never propagated)
Set equality is the only sound check here: a /search on a list of ids returns the ones that exist and silently omits the rest, so asserting on the returned count passes vacuously.
Examples:
Equal counts, unequal sets:
>>> ids_diff(["a", "b"], ["b", "c"])
(['a'], ['c'])
canonical_json(doc)
¶
The bytes a STAC object's digest is taken over: RFC 8785 (JCS) serialisation of
the object with links removed and the round-trip differences canonicalised.
RFC 8785 because stacchain's Merkle tooling hashes JCS, so a later Merkle layer can reuse these leaf hashes. Nothing stores a digest -- both sides are recomputed from the published bodies and the API on every run -- so the form can change with no migration.
Examples:
Keys sorted, links gone, a null member dropped, 1.0 written as 1:
>>> canonical_json({"id": "a", "links": [{"rel": "self"}], "b": None, "a": 1.0})
b'{"a":1,"id":"a"}'
body_digest(doc)
¶
sha256 of canonical_json(doc).
links is the one member the API rewrites: it replaces the published collection
link with its own self/root/parent/collection set on its own host. Everything else
round-trips through pgstac apart from the differences _canonical absorbs.
Refuses anything but an object. A None that hashed would equal every other None.
Examples:
>>> body_digest({"id": "a"})
'8489a5deb454a360345c7868bca8672de92b446caf3d3b014af6a56e3d549d30'
>>> body_digest({"id": "a", "links": []}) == body_digest({"id": "a"})
True
>>> body_digest(None)
Traceback (most recent call last):
...
TypeError: expected a JSON object, got NoneType
content_diff(published, registered)
¶
(missing, orphaned, changed) from two {id: digest} maps.
changed = in both, digests differ. An empty or None digest on EITHER side raises:
it means a body was never read, and two unread bodies must not compare equal.
Examples:
>>> content_diff({"a": "d1", "b": "d2"}, {"b": "d3", "c": "d4"})
(['a'], ['c'], ['b'])
>>> content_diff({"a": ""}, {"a": ""})
Traceback (most recent call last):
...
ValueError: 1 published id(s) have no body digest, e.g. ['a']: a body was not read
collection_state(collection_file, collection_id, api, session=None)
¶
'same', 'changed' or 'missing': the registered collection vs the file.
404 is 'missing' (never registered). Any other failure raises after retries, so an unreachable API cannot read as 'same'.
queryables_served(collection_id, api, session=None)
¶
{name: fragment} the API serves at /collections/
A collection with no queryables at all is served "properties": null, which is
none. A 404 is not: for a collection that exists it means the API does not serve
queryables (stac-fastapi-pgstac without its filter extension), and read as "none" it
would report every declared queryable missing on every run. So it raises, as does
every other way of not reading the document.
collections_listed(api, session=None)
¶
Every collection id the API lists at /collections, following next links.
stac-fastapi pages /collections (10 to a page by default), so the first page alone is a sample. Every way out other than "no next link" raises, as in _search_pages.