Custom URL adapters¶
3LC reads and writes data through URL adapters. There are built-in adapters for
file://, s3://, gs://, abfs://, and http(s)://. You can add support
for a new scheme, or override a built-in one, by registering your own
adapter.
A custom adapter is a subclass of UrlAdapter. The base
class ships working defaults for the optional operations and the
string-encoding forwarders, so a subclass implements only what its backing
store needs — at minimum the three required methods below.
Writing an adapter¶
Subclass UrlAdapter and implement the three required
methods; override optional methods to add capabilities. Methods receive
Url values, and content I/O is bytes-only — text
encoding/decoding lives in the base class.
from tlc import Url
from tlc.url import UrlAdapter, UrlAdapterDirEntry
class MyAdapter(UrlAdapter):
# --- required ---
def schemes(self) -> list[str]:
return ["my"]
def read_binary_content_from_url(self, url: Url) -> bytes:
...
def exists(self, url: Url) -> bool:
...
# --- optional: override to add capabilities ---
def write_binary_content_to_url(self, url: Url, content: bytes) -> Url:
# Return the URL the content landed at — usually `url`, or a different
# URL for a store that relocates (e.g. a versioning backend).
...
def list_dir(self, url: Url):
# Yield UrlAdapterDirEntry(name, path, is_dir=..., size=..., mtime=...).
# Required for a scheme the indexer should discover.
...
def is_dir(self, url: Url) -> bool: ...
def stat(self, url: Url) -> UrlAdapterDirEntry: ...
def is_writable(self, url: Url) -> bool: ...
def delete_url(self, url: Url) -> None: ...
Only schemes, read_binary_content_from_url, and exists are required —
every other method has a default (e.g. get_file_size reads the whole object,
copy_url is read-then-write, make_dirs is a no-op for flat stores,
is_writable returns False). Override the ones your store supports.
Signal failure by raising: FileNotFoundError for missing content,
NotImplementedError for an unsupported operation. Exceptions propagate to the
calling operation — including calls the indexer makes while scanning the scheme.
The adapter never sees credentials and never expands aliases: the registry resolves aliases and enforces activation/licensing around these calls, so enforcement stays in one place rather than in every adapter.
Capabilities — opt in by overriding¶
The required surface (schemes / read_binary_content_from_url / exists)
plus list_dir is what makes a scheme discoverable and readable — the
indexer lists a scan root and reads objects under it. A read-only reference
scheme that is never scanned can leave list_dir at its default. Everything
else is opt-in: override the relevant method and the capability turns on, since
the registry routes each operation to the matching method (using the base
default when the subclass doesn’t override it).
Capability |
Override |
Applies when |
|---|---|---|
read (required) |
|
always |
listing |
|
the scheme is discovered/scanned by the indexer |
metadata |
|
per-URL metadata beyond |
writing |
|
the backend is writable |
signed URLs |
|
browser-fetchable URLs (feature, not perf) |
skip-if-unchanged |
|
the indexer should skip scopes whose content is unchanged |
list_dir and stat return UrlAdapterDirEntry instances —
construct them as UrlAdapterDirEntry(name, path, is_dir=..., size=..., mtime=...).
Override change_token(self, url: Url) -> str to opt into change-signalling:
return a stable token that only advances when the scope’s content changes, and
the indexer skips re-scanning a scope whose token is unchanged. Without it, the
scheme is treated as always-changed (re-scanned every poll). An adapter may
additionally define mark_changed(self, url: Url) to receive change-marker
writes for the scopes it serves; without it those writes are no-ops.
Registering an adapter¶
Three ways to register. Each scheme has exactly one adapter; conflicts are
resolved at registration time, not at lookup — a registration for a scheme that
already has an adapter is rejected unless it carries force = True, in which
case it replaces the existing adapter.
Decorator — apply
register_url_adapter()to your subclass. It is instantiated with no arguments and registered for every scheme it returns fromschemes():from tlc.url import register_url_adapter, UrlAdapter @register_url_adapter class MyAdapter(UrlAdapter): def schemes(self) -> list[str]: return ["my"] ...
Entry point — declare it in your package’s
pyproject.tomlso it is discovered when your package is installed:[project.entry-points."tlc.url_adapters"] my = "mypackage.adapters:MyAdapter"
Configuration — list it in the 3LC configuration file under
extensions.url-adapters:extensions: url-adapters: - module: mypackage.adapters class: MyAdapter
Adding scan locations for the indexer¶
Registering an adapter teaches 3LC how to read your scheme — it does not, by
itself, tell the indexer where to look. To have your objects discovered, add
the scan locations to the configuration’s scan-urls. Each entry is a plain
URL string (scanned as a project tree) or a dict with explicit layout
(project or flat) and object_type (table / run).
Set it wherever suits — in the 3LC config file:
scan-urls:
- url: my://root
layout: flat
object_type: table
via the TLC_SCAN_URLS environment variable, or at runtime through
tlc.config:
import tlc
tlc.config.scan_urls = [*tlc.config.scan_urls, {"url": "my://root", "layout": "flat", "object_type": "table"}]
Adapter-owned scan locations¶
For a virtual scheme whose locations are fixed and known to the adapter —
e.g. pxt3lc:// over a Pixeltable catalog, where the scan root
pxt3lc://pixeltable is not something a user would (or could) put in their
config — have the adapter contribute the scan URL itself, on construction,
so it is always present without any user setup. Do it idempotently, since the
adapter may be instantiated more than once (entry-point discovery, tests):
import tlc
from tlc.url import UrlAdapter
class PxtAdapter(UrlAdapter):
_SCAN_URL = {"url": "pxt3lc://pixeltable", "layout": "single_dir", "object_type": "table"}
def __init__(self) -> None:
super().__init__()
if self._SCAN_URL not in tlc.config.scan_urls:
tlc.config.scan_urls = [*tlc.config.scan_urls, self._SCAN_URL]
def schemes(self) -> list[str]:
return ["pxt3lc"]
...
scan-urls mutations are thread-safe and take effect on the indexer’s next
cycle, so contributing at construction time means registering the adapter and
making its objects discoverable happen together.
Important
Keep this __init__ minimal and exception-safe. Discovery instantiates each adapter
inside a try/except, so if __init__ raises, the whole adapter is dropped — losing
both its I/O and its scan-url. Do the idempotent check-and-append shown above and nothing
heavier (no network, no backend handshake).
How a custom adapter wins over a built-in one¶
Built-in adapters are the default, not a monopoly. When you register an
adapter for a scheme, it is installed in the dispatch table and takes
precedence over the built-in handler for that scheme — including a built-in
scheme such as s3. Every operation for that scheme is then routed to your
adapter, not the built-in one.
This means you can:
Add a brand-new scheme (e.g.
my://) that 3LC has no built-in support for.Override a built-in scheme to change how 3LC talks to it — for example, replacing the built-in
s3://handler with one that adds custom request signing, routes through a proxy, or talks to an S3-compatible store with non-standard semantics.
Overriding a built-in scheme requires force = True, because it shadows a
handler 3LC ships. Set it as a class attribute:
from tlc.url import register_url_adapter, UrlAdapter
@register_url_adapter
class MyS3Adapter(UrlAdapter):
force = True
def schemes(self) -> list[str]:
return ["s3"]
def read_binary_content_from_url(self, url: Url) -> bytes:
...
Once registered, a call such as Url("s3://bucket/key").read_bytes() is served
by MyS3Adapter.read_binary_content_from_url — the built-in S3 adapter is not
taken. The precedence holds everywhere the registry is consulted, including
from the indexing subsystem.