Notebook Reference
Everything a Custom connector notebook can use. For how notebooks work — running cells, previewing, packages and secrets — start with Custom Connectors.
Two names are available in every cell, with or without the import:
from classifyre import Asset, ctxThe editor’s autocomplete is generated from this same SDK, so ctx. and
Asset( always offer what actually exists. If something here disagrees with
what the editor shows, trust the editor.
The functions you define
test_connection() -> dict
Called by Test connection, and once at the start of every scan — a scan stops before ingesting anything if this fails.
def test_connection() -> dict:
response = httpx.get(f"{ctx.var('api_base')}/health", timeout=10)
if response.is_success:
return {"status": "SUCCESS", "message": "API reachable."}
return {"status": "FAILURE", "message": f"HTTP {response.status_code}"}Return {"status": "SUCCESS" | "FAILURE", "message": str}. Anything else is
treated as a failure, with a message saying so. Raising works too — the
traceback becomes the failure message.
extract()
The heart of a connector. Yields the things to scan.
def extract():
for row in api.list(offset=ctx.offset, limit=ctx.limit):
yield Asset(id=str(row["id"]), name=row["title"], content=row["body"])Yield as you go. A generator lets a large source start being scanned before it has finished listing; building a list first means holding all of it in memory and waiting.
discover() -> dict (optional)
A summary of what this source can see — indexes, projects, buckets. Free-form.
fetch_content(asset_id) -> tuple[str, str] | None (optional)
Content on demand for one asset. Only reached when the asset’s content wasn’t
already supplied by extract(), so most connectors never need it. Return
(raw, text).
Asset
What extract() yields.
| Field | Type | Notes |
|---|---|---|
id | str | Required. Must be stable across runs — it’s what ties this run’s asset to the same asset last run, so findings and history stay attached. |
name | str | Display name. Defaults to id. |
url | str | Where a person would go to see this. Defaults to a generated custom:// reference. |
content | str | The text detectors scan. |
kind | str | record (default), document, page, file, or table. |
metadata | dict | Anything useful about the item. Free-form for Custom sources. |
links | list[str] | Other assets’ ids, resolved into graph edges for you. |
urn | str | What the platform calls this object. Only for things another source also sees — see Lineage. |
content_type | str | Usually leave unset — inferred from the payload. |
content_bytes | bytes | For file connectors — see below. |
mime_type | str | Content type of content_bytes. Detected when omitted. |
created_at / updated_at | datetime | Defaults to now. |
location | str | Where inside the system this came from. |
Linking assets
links takes the ids you already use, and Classifyre resolves them into the
investigation graph:
yield Asset(
id="ticket-42",
name="Login fails on mobile",
content=body,
links=["ticket-17", "user-8801"], # ids, not hashes
)Lineage
links says two assets are connected. It does not say how, and that turns
out to be the whole question: an attachment, a foreign key and a derived table
are three different relationships, and only one of them answers “if I change
this, what breaks?”.
Define a relationships() function and pick the builder that matches what you
mean. Assets are named by the same ids you gave them in extract().
from classifyre import Ref, FieldMapping, FlowType, flow, contains, references, same_as
def relationships():
# Lineage. The only class an impact question follows.
yield flow(
upstream=Ref.asset("orders"),
downstream=Ref.asset("top_deliveries"),
type=FlowType.TRANSFORM,
fields=[
FieldMapping("delivery_time", ["placed_on", "delivered_on"],
"DATEDIFF(placed_on, delivered_on)"),
# A null downstream is an *indirect* dependency: it shaped which
# rows came out without feeding any one output column.
FieldMapping(None, ["placed_on"]),
],
)
yield contains(Ref.asset("orders"), Ref.asset("orders_2024")) # part of
yield references(Ref.asset("orders"), Ref.asset("customers")) # points at| Builder | Means | In the lineage graph? |
|---|---|---|
flow(upstream=, downstream=) | the values in one came from the other | yes — this is lineage |
contains(parent, child) | one is a part of the other | no — used to collapse it |
same_as(a, b) | the same real thing, twice | no — used to merge nodes |
references(a, b) | one points at the other | no |
uses(actor, target) | someone touched it | as weight, not as a path |
flow takes both ends as keyword arguments on purpose. A reversed lineage edge
is silently wrong rather than loudly broken, so it is not something you can
write by accident.
Lineage across systems
Ref.urn(...) names an object by what its platform calls it, so you can point
at a table this connector does not produce and has never seen. If a source for
that system is scanned — before this run or a month after it — the two halves
find each other and the edge completes itself.
from classifyre import urn_for
yield flow(
upstream=Ref.urn(urn_for("snowflake", "acme", "PROD", "PUBLIC", "RAW_ORDERS")),
downstream=Ref.asset("orders"),
type=FlowType.COPY,
)Build the URN with urn_for(...) rather than writing the string yourself: each
connector folds capitalisation and default ports its own way, and a URN that
differs by one letter never matches. Setting Asset.urn does the same thing in
the other direction — it lets another source’s lineage point at your asset.
Start from the Lineage and links template, which has all of this running.
Files and images
Set content_bytes and a connector that fetches files gets everything a
built-in file source gets: text extraction, normalized file metadata, and the
binary and image detectors.
def extract():
for item in bucket.list():
yield Asset(
id=item.key,
name=item.filename,
content_bytes=bucket.download(item.key),
mime_type=item.content_type, # detected if you omit it
kind="file",
)You don’t need to parse anything — a PDF’s text, an image’s dimensions and the file’s size all get filled in for you.
parse and pages
from classifyre import pages, parse — the same extractor every built-in file
source uses, so a notebook never implements format handling.
parse(source, *, name="", mime_type=None) -> ParsedContent
source may be a path, bytes, an open binary handle, or a NotebookFile from
ctx.files. mime_type is a hint, trusted over sniffing when you already know
it — useful for a payload an API declared.
| Field | Is |
|---|---|
.text | Extracted text, ready for Asset(content=...). |
.mime_type | Detected content type. |
.is_binary | Whether the payload is binary rather than plain text. |
.size_bytes | Size of what was parsed. |
.error | Why extraction produced nothing. None on success. |
It never raises. A corrupt or unreadable file comes back with .error set
and empty text, so one bad attachment costs one asset rather than the run. The
object is falsey when .error is set:
parsed = parse(path)
if not parsed:
ctx.log(f"skipping {path.name}: {parsed.error}")
continueHandled formats include PDF, DOCX, XLSX, PPTX, the OpenDocument formats, EML and MSG, RTF, HTML, JSON, CSV/TSV, Parquet and Arrow, archives, and images — images and scanned PDFs are OCR’d.
pages(source, *, page_size=100) -> Iterator[str]
Reads a payload a page at a time instead of whole: rows for a tabular file, lines for everything else. A multi-gigabyte dump becomes many assets and is never in memory at once.
for index, page in enumerate(pages(ctx.file("dump.parquet"), page_size=500)):
yield Asset(id=f"dump-{index}", content=page, kind="table")Files the source carries
ctx.files — uploaded files
Files uploaded to the source, downloaded to local disk before any cell runs. Empty is a normal state, not an error. Available in every deployment.
| Returns | |
|---|---|
ctx.files | Every file, ordered by name. |
ctx.file(name) | One by name. Raises naming the files that are there. |
Each entry is a NotebookFile:
| Is | |
|---|---|
.name | The name it was uploaded under. |
.path | A pathlib.Path on local disk. |
.size_bytes | Size on disk. |
.read_bytes() | Every byte, in memory. |
.read_text() | Decoded as UTF-8, replacing what won’t decode. |
.open() | A binary handle. You close it. |
.parse() | ParsedContent for this file. |
.pages(page_size) | Read it a page at a time. |
ctx.folders — local folders (desktop only)
Folders configured on the source, as pathlib.Path. Nothing is copied — the
notebook opens files where they are.
| Returns | |
|---|---|
ctx.folders | All configured folders, as {name: Path}. |
ctx.folder(name) | One by name. Raises naming the folders that are configured. |
Empty in a Kubernetes deployment: there is no such machine there, and the API refuses to save a source that carries folders. Upload the files instead.
This is not a sandbox. The notebook process runs as you and can already open any path you can. The folder list exists so a connector refers to a folder by name instead of by a hard-coded path, and so the deployment can refuse what it cannot honour.
ctx
Configuration
| Returns | |
|---|---|
ctx.var(name, default=None) | A variable. Raises if it isn’t configured and no default is given. |
ctx.secret(name, default=None) | A secret. Same behaviour, and the value is redacted from logs and output. |
ctx.has_var(name) / ctx.has_secret(name) | Whether one is configured. |
ctx.variables | All variables, as a dict. |
ctx.secret_names | Secret names only — never the values. |
This run
| Returns | |
|---|---|
ctx.strategy | "ALL", "AUTOMATIC", "LATEST" or "RANDOM". |
ctx.limit | How many assets this run wants, or None under All. |
ctx.offset | Where to start. Reading it means you apply it — see below. |
ctx.page_size | The configured rows-per-page. |
ctx.sampling | The whole sampling config. |
ctx.cursor | What the previous run recorded. Empty on the first run. |
ctx.set_cursor(dict) | Record where this run got to, for the next one. |
ctx.should_abort | True once the run has been asked to stop. |
ctx.log(*parts) | Write to the scan log. |
ctx.now() | Current UTC time. |
ctx.offset is a hand-off, not just a number. Reading it tells Classifyre
you’ve applied it yourself, so it stops skipping on your behalf. Read it
before you yield anything, and use it in your query. Ignore it entirely and
paging still works — just less efficiently.
Resuming with your own cursor
If your system pages by something other than a count — a token, a timestamp — record it yourself and it wins over the positional offset:
def extract():
token = ctx.cursor.get("page_token")
page = api.list(page_token=token, limit=ctx.limit)
for row in page.items:
yield Asset(id=str(row["id"]), name=row["title"], content=row["body"])
ctx.set_cursor({"page_token": page.next_token})Long-running work
ctx.should_abort turns True when someone presses Stop or the run is
cancelled. Python can’t be interrupted from outside, so a loop that never checks
it only ends when the execution times out.
for row in huge_result_set:
if ctx.should_abort:
return
yield Asset(...)Limits
Set under the notebook’s options:
| Limit | Default | Does |
|---|---|---|
| Timeout | 900s | Stops an execution that runs too long. |
| Max assets | (none) | Caps how many assets one scan ingests. |
| Max output | 2 MB | Caps stored cell output; larger output is truncated, not dropped. |
Rules worth knowing
- Cells must be valid standard Python. IPython magics (
%time,!pip install) are rejected, because the notebook has to run as an ordinary module in production. - Top-level functions only.
test_connectionandextracthave to be defined at the top level of a cell — a function nested inside a class or another function isn’t found. - Packages are declared, not installed in code. Use the packages table
rather than a
pip installcell; the declared list is what gets installed before your cells run.
When something goes wrong
| What you see | Usually means |
|---|---|
| “‘extract’ is not defined” | The function isn’t at the top level of a code cell, or is spelled differently. |
| A cell you didn’t run is highlighted | That’s the cell that raised while rebuilding state — the error is there, not in the one you clicked. |
| ”Could not install the notebook’s packages” | A name or version in the packages table doesn’t resolve. The installer’s own message names which. |
| ”No variable named …” | The key isn’t in the variables table, or is spelled differently. Keys are case-sensitive. |
| Preview shows no assets | extract() returned without yielding. Check filters, and that it’s a generator (yield, not return). |
•••• where you expected a value | Working as intended — that’s a secret being redacted from output. |
| A scan is slower each run | Under Automatic, extract() is probably producing and discarding earlier items. Use ctx.offset. |
The schema behind all of this — every field, with types and examples — is at Custom Connector.