Write a source adapter
A source is what lets the console list the tables under a root. LanceScope ships
two — a local directory and hf://datasets/… — and a third-party package can add any
other by declaring one entry point.
You do not need one to open a table. Lance opens s3://, gs:// and az:// URIs
on its own, and the console can already read a table by its full URI. What a source
adds is everything that starts from "what is in there": the table list, the honest
capability report, and the join between a root and a name.
The shortest useful one
"""my_adapter.py"""
from server.sources import (
AVAILABLE, UNSUPPORTED, Capability, Discovery, RootCapabilities, Target,
)
class WidgetSource:
api = 1
scheme = "widget" # the prefix alone, with no "://"
remote = True
def handles(self, root):
return root.startswith("widget://")
def capabilities(self, root):
return RootCapabilities(
remote=True,
discover=Capability(AVAILABLE, "Listed over the Widget API."),
inspect=Capability(AVAILABLE),
io_meter=Capability(AVAILABLE),
column_bytes=Capability(AVAILABLE),
disk_split=Capability(
UNSUPPORTED,
"The blob and metadata split comes from walking a directory, "
"and there is no directory here."),
)
def list_tables(self, root):
names = my_client.list(root) # your call
return Discovery(sorted(names), None)
def target_for(self, root, name):
return Target(uri=f"{root.rstrip('/')}/{name}.lance")
def exists(self, root, name):
return TrueRegister it in your package's pyproject.toml:
[project.entry-points."lancescope.sources"]
widget = "my_adapter:WidgetSource"pip install it beside LanceScope and widget://host/db becomes a connection the
console can browse. Nothing in LanceScope mentions your scheme.
The five methods
| Method | Returns | Must never |
|---|---|---|
handles(root) |
bool |
be expensive — it runs on every dispatch |
capabilities(root) |
RootCapabilities |
claim something you have not measured |
list_tables(root) |
Discovery |
return an empty list to mean "failed" |
target_for(root, name) |
Target |
guess a URI it cannot build |
exists(root, name) |
bool |
return False without proof |
Two of those deserve their reasons spelled out, because they are where the console's whole posture lives.
list_tables must not flatten failure into emptiness
A store that could not be reached and a store with nothing in it are different facts. Return the reason:
try:
return Discovery(sorted(my_client.list(root)), None)
except MyTimeout as e:
return Discovery([], f"the Widget API did not answer in 15s: {e}")The console prints that sentence. If you return Discovery([]) on failure, a user
sees "this database is empty" and goes looking for the wrong problem.
capabilities is a claim, and there are three answers
AVAILABLE, UNSUPPORTED, and UNVERIFIED — the third being "this should work and
nobody has run it". Use it. Shipping UNVERIFIED with an honest sentence is better
than shipping AVAILABLE and being wrong, and the console renders it as a working
feature with a caveat rather than as a broken one.
disk_split is almost certainly UNSUPPORTED for a remote store: it comes from
walking the directory a table sits in, and a number derived from the manifest instead
would look the same and mean something else.
Credentials
If the operator exports AWS_*, AZURE_* or GOOGLE_*, Lance's object store reads
them and you need to do nothing. If your adapter mints its own token — a scoped
credential per bucket, something out of a vault — put it on the target:
def target_for(self, root, name):
return Target(
uri=f"{root.rstrip('/')}/{name}.lance",
storage_options={"aws_access_key_id": ..., "aws_session_token": ...},
)Per target rather than per process, so two roots in one console can use two accounts.
Target.open_args() is what turns a target into arguments for the reader. Build a
Target; never call lance.dataset yourself, and the reader's signature stays
something an adapter does not have to track.
If your store is a catalog rather than a bucket
If what you are adding answers "what tables are here" and "where does this one live",
it is a namespace, and there is a shorter road. Subclass NamespaceSource and
supply a client:
from lance.namespace import RestNamespace
from server.sources.namespace import NamespaceSource
class GlueSource(NamespaceSource):
scheme = "glue"
def namespace(self, root):
return RestNamespace(uri=...)Listing, opening, capabilities and error wording all come from the base; db:// is
this and about sixty lines of endpoint construction. A namespace table opens through
lance.dataset(namespace_client=…, table_id=…), so the location and any credentials
the service vends are resolved at the moment of opening rather than frozen into a URL
that expires.
Consider writing a LanceNamespace rather than a LanceScope Source where the two
overlap: a catalog written as a namespace works in every Lance tool, and this console
picks it up either way.
What happens when your adapter breaks
Nothing catastrophic, by design. Every source — including the two built in — is wrapped so that a raised exception becomes the honest answer to the question that was asked, carrying your error text and your distribution's name:
capabilitiesraising → every capabilityUNSUPPORTED, reason names your packagelist_tablesraising →Discovery([], "…failed in list_tables(): …")handlesraising →Falseexistsraising →True, because a broken adapter has not earned the right to say a table is absenttarget_forraising →FileNotFoundError, which the routes turn into a 404
Returning the wrong type is caught at the same boundary. This is a safety net, not
a licence: an adapter that relies on it reports its own failures worse than one that
returns a Discovery with a sentence you wrote.
Rules the registry enforces
- A scheme has one source. Built-ins win; a plugin claiming
hfis refused and recorded rather than swapped in, so two installs of the same version behave alike. - Two plugins claiming one scheme: first registered wins, the other is recorded as rejected. Do not rely on the order — pick a scheme nobody else uses.
apimust match. Declareapi = 1. A source declaring a version this build does not speak is rejected with a sentence instead of being called and hoped for.- A rejected adapter is kept, not dropped. Whatever went wrong — a missing
method, an import error, a scheme collision — appears in
server.sources.loaded()with the reason and your package name.
Check what loaded, from Python:
from server import sources
for r in sources.loaded():
print(r.as_dict())or from the running console, which reports the same list including failures:
curl -s localhost:8000/catalog/runtime | jq .sourcesWhen adapters are not loaded at all
Two cases, both deliberate:
LANCESCOPE_NO_PLUGINS=1— an operator switch, for starting the console when an installed adapter is misbehaving.- Kiosk mode (
LANCESCOPE_KIOSK=1). A public demo serves strangers and does not execute code that arrived from one.
The built-ins are unaffected by both.
Testing yours
server/sources/hf.py is the reference implementation: it is a working adapter
written the way this page describes, reaching for nothing private. Point your tests
at the same contract —
from server.sources import adapt
def test_it_is_a_source():
source = adapt(WidgetSource, provider="my-adapter 1.0")
assert source.scheme == "widget"adapt raises SourceRejected with the reason if it is not one, which is the same
check the registry runs at load.