Skip to Content
SourcesCustom ConnectorsNotebook Reference

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, ctx

The 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.

FieldTypeNotes
idstrRequired. 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.
namestrDisplay name. Defaults to id.
urlstrWhere a person would go to see this. Defaults to a generated custom:// reference.
contentstrThe text detectors scan.
kindstrrecord (default), document, page, file, or table.
metadatadictAnything useful about the item. Free-form for Custom sources.
linkslist[str]Other assets’ ids, resolved into graph edges for you.
urnstrWhat the platform calls this object. Only for things another source also sees — see Lineage.
content_typestrUsually leave unset — inferred from the payload.
content_bytesbytesFor file connectors — see below.
mime_typestrContent type of content_bytes. Detected when omitted.
created_at / updated_atdatetimeDefaults to now.
locationstrWhere 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
BuilderMeansIn the lineage graph?
flow(upstream=, downstream=)the values in one came from the otheryes — this is lineage
contains(parent, child)one is a part of the otherno — used to collapse it
same_as(a, b)the same real thing, twiceno — used to merge nodes
references(a, b)one points at the otherno
uses(actor, target)someone touched itas 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.

FieldIs
.textExtracted text, ready for Asset(content=...).
.mime_typeDetected content type.
.is_binaryWhether the payload is binary rather than plain text.
.size_bytesSize of what was parsed.
.errorWhy 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}")
    continue

Handled 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.filesEvery file, ordered by name.
ctx.file(name)One by name. Raises naming the files that are there.

Each entry is a NotebookFile:

Is
.nameThe name it was uploaded under.
.pathA pathlib.Path on local disk.
.size_bytesSize 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.foldersAll 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.variablesAll variables, as a dict.
ctx.secret_namesSecret names only — never the values.

This run

Returns
ctx.strategy"ALL", "AUTOMATIC", "LATEST" or "RANDOM".
ctx.limitHow many assets this run wants, or None under All.
ctx.offsetWhere to start. Reading it means you apply it — see below.
ctx.page_sizeThe configured rows-per-page.
ctx.samplingThe whole sampling config.
ctx.cursorWhat 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_abortTrue 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:

LimitDefaultDoes
Timeout900sStops an execution that runs too long.
Max assets(none)Caps how many assets one scan ingests.
Max output2 MBCaps 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_connection and extract have 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 install cell; the declared list is what gets installed before your cells run.

When something goes wrong

What you seeUsually 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 highlightedThat’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 assetsextract() returned without yielding. Check filters, and that it’s a generator (yield, not return).
•••• where you expected a valueWorking as intended — that’s a secret being redacted from output.
A scan is slower each runUnder 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.

Last updated on