Skip to content

Auth and cache

Credentials

connecto reads tokens from wherever they already live. See Credentials for the guided version, including what to do when something 401s.

cn.auth_status()                  # validates against every service
cn.auth_status(validate=False)    # instant, presence-only
cn.set_token("cave", "<token>")

auth

Credentials.

A facade, not a competing secret store. connecto reads tokens from wherever they already live and never breaks an existing caveclient or neuprint-python setup.

Resolution order, per service:

  1. an explicit token= argument
  2. $CONNECTO_<SERVICE>_TOKEN
  3. the service's own environment variable ($NEUPRINT_APPLICATION_CREDENTIALS)
  4. ~/.connecto/config.toml
  5. the service's native secret store (for CAVE, whatever caveclient itself resolves)

Never the reverse - a token you already have working must keep working.

Two things beyond mere lookup live here, and they matter more than the lookup does:

  • :func:auth_errors turns the 401s and 403s that the upstream libraries throw into a :class:~connecto.exceptions.ConnectoAuthError that names the service, the server, which source the token came from, and the actual fix.
  • :func:auth_status validates - it asks each service who you are, rather than merely noting that some string exists. "Present" and "works" are different claims, and a status function that conflates them will cheerfully confirm the wrong hypothesis for someone whose token expired last week.

auth_status

auth_status(validate: bool = True)

Which credentials connecto can see, whether they work, and who you are.

validate=True (the default) asks each service. It costs about a second, and it is the whole point: a presence-only check reports found for a token that expired last week, which is precisely the wrong answer for the one person who ever runs this function.

Source code in connecto/auth.py
def auth_status(validate: bool = True):
    """Which credentials connecto can see, whether they *work*, and who you are.

    ``validate=True`` (the default) asks each service. It costs about a second, and
    it is the whole point: a presence-only check reports ``found`` for a token that
    expired last week, which is precisely the wrong answer for the one person who
    ever runs this function.
    """
    import pandas as pd

    rows = []

    def add(service, server, info, ident=None):
        if not info.ok:
            status = "MISSING"
        elif ident is None or not ident.validated:
            # Present, but nobody asked the server. Not the same as OK.
            status = "FOUND"
        elif ident.valid:
            status = "OK"
        else:
            status = "INVALID"
        rows.append(
            {
                "service": service,
                "server": server or "-",
                "status": status,
                "source": info.source,
                "identity": (ident.email if ident else None) or "-",
                "access": (ident.access if ident else None)
                or (ident.detail if ident else "")
                or "-",
            }
        )

    cave = get_token("cave")
    add("cave", "global.daf-apis.com", cave,
        validate_token("cave") if (validate and cave.ok) else None)

    for server in _neuprint_servers():
        info = get_token("neuprint", server=server)
        add("neuprint", server, info,
            validate_token("neuprint", server=server) if (validate and info.ok) else None)

    for service in ("clio", "seatable"):
        info = get_token(service)
        add(service, None, info,
            validate_token(service) if (validate and info.ok) else None)

    df = pd.DataFrame(rows)

    # If connecto and caveclient have resolved *different* CAVE tokens, say so.
    # Two secret stores that quietly disagree is the fafbseg bug; it should never
    # be something you have to discover for yourself.
    native, _ = _cave_token()
    if cave.ok and native and native != cave.token:
        df.attrs["warning"] = (
            f"connecto is using the CAVE token from {cave.source}, but caveclient "
            f"resolves a different one from ~/.cloudvolume/secrets. They disagree; "
            f'run co.set_token("cave", ...) to make them match.'
        )
        print("!! " + df.attrs["warning"])

    return df

get_token

get_token(service: str, *, token: str | None = None, server: str | None = None) -> TokenInfo

Resolve a token for service ("cave", "neuprint", "clio", "seatable").

Source code in connecto/auth.py
def get_token(
    service: str, *, token: str | None = None, server: str | None = None
) -> TokenInfo:
    """Resolve a token for ``service`` ("cave", "neuprint", "clio", "seatable")."""
    service = service.lower()

    if token:
        return TokenInfo(service, token, "argument")

    env = f"CONNECTO_{service.upper()}_TOKEN"
    if os.environ.get(env):
        return TokenInfo(service, os.environ[env], f"${env}")

    native_env = {
        "neuprint": "NEUPRINT_APPLICATION_CREDENTIALS",
        "clio": "CLIO_TOKEN",
        "seatable": "SEATABLE_TOKEN",
    }.get(service)
    if native_env and os.environ.get(native_env):
        raw = os.environ[native_env]
        # neuprint accepts either the bare token or the whole JSON doc.
        if raw.strip().startswith("{"):
            try:
                raw = json.loads(raw)["token"]
            except (json.JSONDecodeError, KeyError):
                pass
        return TokenInfo(service, raw, f"${native_env}")

    cfg = _read_config().get("auth", {})
    if server and cfg.get(server):
        return TokenInfo(service, cfg[server], f"{CONFIG_PATH.name} [{server}]")
    if cfg.get(service):
        return TokenInfo(service, cfg[service], CONFIG_PATH.name)

    if service == "clio":
        # clio piggybacks on the Google-auth token clio-py manages itself.
        return TokenInfo(service, None, "clio-py (managed)")

    if service == "cave":
        tok, src = _cave_token(server)
        return TokenInfo(service, tok, src)

    return TokenInfo(service, None, "not found")

set_token

set_token(service: str, token: str, *, write_native: bool = True)

Store a token.

With write_native=True (the default) the token is written to both connecto's config and the service's native location, so that caveclient, cloud-volume and connecto can never disagree about which token is current - which is the failure mode fafbseg papers over by reading from cloud-volume and writing via caveclient.

Source code in connecto/auth.py
def set_token(service: str, token: str, *, write_native: bool = True):
    """Store a token.

    With ``write_native=True`` (the default) the token is written to *both*
    connecto's config and the service's native location, so that caveclient,
    cloud-volume and connecto can never disagree about which token is current -
    which is the failure mode fafbseg papers over by reading from cloud-volume and
    writing via caveclient.
    """
    service = service.lower()

    CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
    cfg = _read_config()
    cfg.setdefault("auth", {})[service] = token

    lines = ["[auth]"]
    lines += [f'{k} = "{v}"' for k, v in cfg["auth"].items()]
    for section, values in cfg.items():
        if section == "auth":
            continue
        lines.append(f"\n[{section}]")
        lines += [f"{k} = {v!r}" for k, v in values.items()]
    CONFIG_PATH.write_text("\n".join(lines) + "\n")

    if write_native and service == "cave":
        from caveclient import CAVEclient

        CAVEclient().auth.save_token(token, overwrite=True)

validate_token

validate_token(service: str, *, token=None, server=None) -> Identity

Ask the service whether a token actually works, and who it belongs to.

One cheap request (~0.4s). This is the difference between "a string exists" and "you are authenticated", and only the second one is worth reporting.

Source code in connecto/auth.py
def validate_token(service: str, *, token=None, server=None) -> Identity:
    """Ask the service whether a token actually works, and who it belongs to.

    One cheap request (~0.4s). This is the difference between "a string exists"
    and "you are authenticated", and only the second one is worth reporting.
    """
    service = service.lower()
    info = get_token(service, token=token, server=server)

    if not info.ok:
        return Identity(False, detail="no token found")

    headers = {"Authorization": f"Bearer {info.token}"}

    if service == "cave":
        url = CAVE_IDENTITY.format(server=(server or CAVE_GLOBAL_SERVER).rstrip("/"))
    elif service == "neuprint":
        url = NEUPRINT_IDENTITY.format(server=(server or "neuprint.janelia.org"))
    else:
        # No cheap probe for clio/seatable. Say so rather than implying one ran.
        return Identity(True, detail="present, not validated", validated=False)

    try:
        r = requests.get(url, headers=headers, timeout=TIMEOUT)
    except requests.RequestException as e:
        return Identity(False, detail=f"could not reach server ({type(e).__name__})")

    if r.status_code in (401, 403):
        return Identity(False, detail="server rejected this token")
    if not r.ok:
        return Identity(False, detail=f"HTTP {r.status_code}")

    try:
        data = r.json()
    except ValueError:
        return Identity(True, detail="ok")

    if service == "cave":
        groups = data.get("groups") or []
        return Identity(
            True,
            email=data.get("email"),
            access=", ".join(groups) if groups else None,
        )

    return Identity(True, email=data.get("Email"), access=data.get("AuthLevel"))

auth_errors

auth_errors(service: str, *, server=None, resource=None, dataset=None)

Translate upstream 401/403s into something the user can act on.

caveclient raises its own AuthException when no token is configured, and a bare requests.HTTPError when the server rejects one. neuprint-python (and connecto's own server_datasets) raise a bare 401 either way, so a missing token and a wrong token are indistinguishable. Both become a :class:ConnectoAuthError here, and the three kinds stay distinct.

Source code in connecto/auth.py
@contextmanager
def auth_errors(service: str, *, server=None, resource=None, dataset=None):
    """Translate upstream 401/403s into something the user can act on.

    caveclient raises its own ``AuthException`` when no token is configured, and a
    bare ``requests.HTTPError`` when the server rejects one. neuprint-python (and
    connecto's own ``server_datasets``) raise a bare 401 either way, so a missing
    token and a wrong token are indistinguishable. Both become a
    :class:`ConnectoAuthError` here, and the three kinds stay distinct.
    """
    try:
        yield
    except ConnectoAuthError:
        raise
    except requests.HTTPError as e:
        code = getattr(e.response, "status_code", None)
        if code not in (401, 403):
            raise
        kind = (
            ConnectoAuthError.FORBIDDEN
            if code == 403
            else ConnectoAuthError.INVALID
        )
        info = get_token(service, server=server)
        # A 401 with no token at all is a *missing* token, not an invalid one.
        if kind == ConnectoAuthError.INVALID and not info.ok:
            kind = ConnectoAuthError.MISSING
        raise ConnectoAuthError(
            _message(kind, service, info, server, resource, dataset),
            kind=kind, service=service, server=server,
        ) from e
    except Exception as e:  # noqa: BLE001
        if type(e).__name__ != "AuthException":  # caveclient's "no token" path
            raise
        info = get_token(service, server=server)
        raise ConnectoAuthError(
            _message(
                ConnectoAuthError.MISSING, service, info, server, resource, dataset
            ),
            kind=ConnectoAuthError.MISSING, service=service, server=server,
        ) from e

Servers

CAVE answers 503 for hours at a time while it builds a new materialization. These are the two functions that make that survivable — and When the server is down is the guide.

cn.server_status("flywire")          # which services are answering, right now
cn.wait_until_available("flywire")   # block until they are (explicitly — never automatic)

servers

Server outages, and how to say something useful about them.

CAVE deployments answer 503 while they build a new materialization version. That is not a blip: it runs for hours. caveclient retries such a request three times over about 0.7 seconds (status_forcelist=(502, 503, 504), backoff_factor=0.1) and then raises - so what reaches the user is a requests.HTTPError whose message carries a page of nginx HTML, and whose only real content is the number 503.

Two questions matter at that moment, and the traceback answers neither.

Is it just this service, or is everything down? A materialization takes out the materialize service and leaves the chunkedgraph, skeleton and info services answering normally. A dead VPN takes out all four. Those have completely different fixes, and one cheap probe of each service tells you which you are looking at. That distinction is the main thing this module exists to provide, and connecto is well placed to provide it: it already knows every service its backends depend on.

How long? Nobody can say - but hours is the right order of magnitude, and someone who knows that will go and do something else rather than hammer the server in a while True. So connecto does not silently retry 503s for you: no honest backoff outlasts a materialization, and a library that sleeps for hours pretending to work is worse than one that tells you to come back later. What it offers instead is :func:wait_until_available, which does the waiting explicitly, and says so.

One thing deliberately not attempted: guessing which of your calls would still succeed. It is tempting to print "segmentation still works!", but it is not reliably true - segmentation.update_ids needs a materialization timestamp, and every version lookup goes through the materialize service, so even a pinned ds.at(783) hits it. Claiming otherwise would send people down a path that dead-ends one call later. This module reports which services answer, and what each one backs; the mapping from that to your code is left honest rather than clever.

server_status

server_status(dataset, *, timeout: int = PROBE_TIMEOUT)

Which servers behind dataset are answering, right now.

Probes each service that the dataset's backend depends on and reports what it said. The point is the shape of the result rather than any single row: one service down means that service is having a bad day (on CAVE, almost always a materialization in progress); every service down means the deployment or your own network is gone, which is a different problem with a different fix.

Parameters

dataset : str | Dataset A registered dataset name ("flywire") or a dataset object. timeout : int Seconds to wait for each probe.

Returns

pandas.DataFrame One row per service: service, server, status, http, seconds, backs. status is OK, UNAVAILABLE, ERROR, UNREACHABLE or NO AUTH - never a bare boolean, because "answered" and "answered usefully" are different claims.

Examples

co.server_status("flywire") # doctest: +SKIP service server status http seconds backs 0 info global.daf-apis.com OK 200 0.31 client setup, ... 1 materialize prod.flywire-daf.com UNAVAILABLE 503 0.44 annotations, ...

Source code in connecto/servers.py
def server_status(dataset, *, timeout: int = PROBE_TIMEOUT):
    """Which servers behind ``dataset`` are answering, right now.

    Probes each service that the dataset's backend depends on and reports what it
    said. The point is the *shape* of the result rather than any single row: one
    service down means that service is having a bad day (on CAVE, almost always a
    materialization in progress); every service down means the deployment or your
    own network is gone, which is a different problem with a different fix.

    Parameters
    ----------
    dataset :   str | Dataset
                A registered dataset name (``"flywire"``) or a dataset object.
    timeout :   int
                Seconds to wait for each probe.

    Returns
    -------
    pandas.DataFrame
                One row per service: ``service, server, status, http, seconds, backs``.
                ``status`` is ``OK``, ``UNAVAILABLE``, ``ERROR``, ``UNREACHABLE`` or
                ``NO AUTH`` - never a bare boolean, because "answered" and "answered
                usefully" are different claims.

    Examples
    --------
    >>> co.server_status("flywire")                             # doctest: +SKIP
       service              server  status  http  seconds  backs
    0     info  global.daf-apis.com     OK   200     0.31  client setup, ...
    1  materialize  prod.flywire-daf.com  UNAVAILABLE  503  0.44  annotations, ...
    """
    import pandas as pd

    from .core.registry import get_dataset

    ds = get_dataset(dataset) if isinstance(dataset, str) else dataset
    service = ds.backend_kind

    client = None
    if service == "cave":
        # Unlike the error path, a cold status check may construct a client - but it
        # must not explode when the thing it is diagnosing is exactly that failing.
        try:
            client = ds.client
        except Exception:  # noqa: BLE001
            client = None

    token = get_token(service, server=ds._auth_server).token
    services = _services_for(service, dataset=ds, client=client)
    return pd.DataFrame(_probe_all(services, token, timeout))

wait_until_available

wait_until_available(dataset, *, service: str | None = None, interval: int = 300, timeout: int = 6 * 3600, verbose: bool = True)

Block until dataset's servers are serving again.

This is the loop you would otherwise write yourself around a materialization, with a poll interval that will not get you rate-limited. It is a separate, explicit call rather than an automatic retry precisely because it can take hours: a library that quietly sleeps that long, pretending to work, is worse than one that raises and lets you decide.

Parameters

dataset : str | Dataset service : str, optional Wait only for this service (e.g. "materialize"). By default, waits until every service the dataset needs is answering. interval : int Seconds between polls. Floored at 30 - a materialization does not finish in less time than that, and hammering a struggling server is not neighbourly. timeout : int Give up after this many seconds. Default 6 hours.

Returns

pandas.DataFrame The final (healthy) status table.

Raises

TimeoutError If the servers are still down when timeout expires.

Source code in connecto/servers.py
def wait_until_available(
    dataset,
    *,
    service: str | None = None,
    interval: int = 300,
    timeout: int = 6 * 3600,
    verbose: bool = True,
):
    """Block until ``dataset``'s servers are serving again.

    This is the loop you would otherwise write yourself around a materialization,
    with a poll interval that will not get you rate-limited. It is a separate,
    explicit call rather than an automatic retry precisely because it can take
    hours: a library that quietly sleeps that long, pretending to work, is worse
    than one that raises and lets you decide.

    Parameters
    ----------
    dataset :   str | Dataset
    service :   str, optional
                Wait only for this service (e.g. ``"materialize"``). By default,
                waits until *every* service the dataset needs is answering.
    interval :  int
                Seconds between polls. Floored at 30 - a materialization does not
                finish in less time than that, and hammering a struggling server is
                not neighbourly.
    timeout :   int
                Give up after this many seconds. Default 6 hours.

    Returns
    -------
    pandas.DataFrame
                The final (healthy) status table.

    Raises
    ------
    TimeoutError
                If the servers are still down when ``timeout`` expires.
    """
    interval = max(30, int(interval))
    deadline = time.monotonic() + timeout
    n = 0

    while True:
        df = server_status(dataset)

        rows = df
        if service:
            rows = df[df["service"] == service]
            if rows.empty:
                # Otherwise a typo filters the table down to nothing, nothing is
                # down, and we cheerfully report "healthy" and return - having
                # waited for a service that does not exist.
                raise ValueError(
                    f"{_name(dataset)} has no service {service!r}. "
                    f"Known: {', '.join(df['service'])}."
                )

        # NO AUTH means the service is up and your token is the problem; waiting will
        # not fix that, so it does not count as "still down".
        down = rows[~rows["status"].isin([OK, NO_AUTH])]

        if down.empty:
            if verbose and n:
                print(f"\n{_name(dataset)} is back ({_hms(n * interval)} waited).")
            return df

        n += 1
        left = deadline - time.monotonic()
        if left <= 0:
            raise TimeoutError(
                f"{_name(dataset)}: still {', '.join(sorted(set(down['status'])))} "
                f"after {_hms(timeout)} "
                f"({', '.join(down['service'])}). Not waiting any longer."
            )

        if verbose:
            print(
                f"[{time.strftime('%H:%M:%S')}] "
                f"{', '.join(f'{r.service}={r.status}' for r in down.itertuples())} "
                f"- retrying in {interval}s "
                f"(giving up in {_hms(left)})",
                flush=True,
            )
        time.sleep(min(interval, max(1, left)))

Cache

Keyed on (dataset, backend, version, query) — so a cached frame can never be served for a different materialization. See Caching.

cache

On-disk cache.

Layout::

~/.connecto/cache/<dataset>/<version>/<kind>_<hash>.feather
~/.connecto/cache/_http/<sha1(url)>.<ext>

Staleness is handled by what goes into the key, and that differs by what the source is (see :func:connecto.sources.freshness and Annotations._table):

  • A materialization is frozen, so a CAVE annotation table and a neuPrint version string are content-stable at a given version - the version in the key is enough, and a frozen dataset caches forever.
  • The live sources are not: FlyTable (SeaTable) is edited daily and is independent of any materialization, and a neuPrint instance can be re-curated under the same version tag. For those a freshness token joins the key - SeaTable's COUNT(*)/MAX(_mtime), neuPrint's lastDatabaseEdit - so it turns over exactly when the source does. CacheEntry.supersede then drops the file it replaced, so a live source keeps one entry, not one per edit.

Frames are written as Feather, falling back to pickle for the ones Arrow cannot hold - a SeaTable table with a mixed int/str object column, say. Without that fallback such a frame silently never cached: the write raised, the entry was deleted, and the next call re-downloaded the lot (aedes' FlyTable annotations, 17k rows, on every .annotations.get()). Pickle round-trips the frame exactly, so a cache hit is byte-for-byte what a miss would have returned.

We cache annotation tables, ROI hierarchies and HTTP downloads - things that are large, slow, and fetched over and over. We deliberately do not cache connectivity or synapse queries by default: the key space is unbounded and it would quietly fill the user's disk. Pass cache=True to opt in.

cache_dir

cache_dir(*parts) -> Path
Source code in connecto/cache.py
def cache_dir(*parts) -> Path:
    d = CACHE_ROOT.joinpath(*[_safe(p) for p in parts])
    d.mkdir(parents=True, exist_ok=True)
    return d

clear

clear(dataset: str | None = None)

Delete cached data - for one dataset, or all of it.

Source code in connecto/cache.py
def clear(dataset: str | None = None):
    """Delete cached data - for one dataset, or all of it."""
    target = CACHE_ROOT / dataset if dataset else CACHE_ROOT
    if target.exists():
        shutil.rmtree(target)

size

size() -> int

Total size of the cache in bytes.

Source code in connecto/cache.py
def size() -> int:
    """Total size of the cache in bytes."""
    if not CACHE_ROOT.exists():
        return 0
    return sum(f.stat().st_size for f in CACHE_ROOT.rglob("*") if f.is_file())

enabled

enabled() -> bool
Source code in connecto/cache.py
def enabled() -> bool:
    return not os.environ.get("CONNECTO_NO_CACHE")

download

download(url: str, *, force: bool = False, verbose: bool = True) -> Path

Download a URL into the cache and return the local path.

The filename is derived from a hash of the whole URL, not its basename, so two sources that both serve annotations.tsv don't collide.

Source code in connecto/cache.py
def download(url: str, *, force: bool = False, verbose: bool = True) -> Path:
    """Download a URL into the cache and return the local path.

    The filename is derived from a hash of the *whole* URL, not its basename, so
    two sources that both serve ``annotations.tsv`` don't collide.
    """
    suffix = "".join(Path(urlparse(url).path).suffixes[-1:]) or ".dat"
    fp = cache_dir("_http") / f"{_hash(url)}{suffix}"

    if fp.exists() and not force and enabled():
        return fp

    if verbose:
        print(f"Caching {Path(urlparse(url).path).name} from {urlparse(url).netloc}...",
              end=" ", flush=True)

    with requests.get(url, allow_redirects=True, stream=True, timeout=60) as r:
        r.raise_for_status()
        tmp = fp.with_suffix(fp.suffix + ".part")
        with open(tmp, "wb") as f:  # always binary: text mode corrupts anything else
            for chunk in r.iter_content(chunk_size=1 << 20):
                f.write(chunk)
        tmp.replace(fp)

    if verbose:
        print("Done.")
    return fp

Exceptions

exceptions

Exceptions raised by connecto.

ConnectoError

Bases: Exception

Base class for all connecto errors.

ConnectoServerError

ConnectoServerError(message: str, *, kind: str, service: str, server=None, status=None)

Bases: ConnectoError

The server could not serve the request - and it is not your fault.

Three kinds, because they call for three different responses:

UNAVAILABLE HTTP 502/503/504. The service is deliberately refusing work. On CAVE this is overwhelmingly a materialization in progress, which runs for hours, not seconds. The single most useful thing to tell someone here is that waiting in a tight loop will not outlast it - go and do something else - which is the opposite of what a bare 503 traceback suggests. ERROR HTTP 500. Something broke server-side. Retrying rarely helps; this is worth reporting to whoever runs the server. UNREACHABLE No HTTP response at all: DNS, TLS, timeout, no route. Very often this is the user's own network rather than the server, and saying so saves them filing a bug against the wrong project.

Source code in connecto/exceptions.py
def __init__(self, message: str, *, kind: str, service: str, server=None, status=None):
    super().__init__(message)
    self.kind = kind
    self.service = service
    self.server = server
    self.status = status  # the HTTP status, when there was one

ConnectoAuthError

ConnectoAuthError(message: str, *, kind: str, service: str, server=None)

Bases: ConnectoError

A credential problem, stated in terms the user can act on.

Three kinds, because they have three different fixes:

MISSING No token found anywhere. Go and get one. INVALID A token was found and the server rejected it (HTTP 401). The useful thing to say here is which of the five possible sources it came from - an env var, a config file, or one of three secret files - because that is the one the user has to go and fix. FORBIDDEN The token is fine; you just aren't allowed near this resource (HTTP 403). Usually a missing group membership. A new token will not help, and telling someone to get one sends them down the wrong path entirely.

Source code in connecto/exceptions.py
def __init__(self, message: str, *, kind: str, service: str, server=None):
    super().__init__(message)
    self.kind = kind
    self.service = service
    self.server = server

CapabilityError

Bases: ConnectoError, NotImplementedError, AttributeError

A dataset does not support a requested feature.

Subclasses AttributeError on purpose: this makes hasattr(ds, "segmentation") return False for datasets without a segmentation, so duck-typed feature detection works, while direct access still raises with a useful message.

NoSuchDatasetError

Bases: ConnectoError, KeyError

No dataset with that name is registered.

NoSuchBodyError

Bases: ConnectoError, KeyError

The requested neuron does not exist in the volume being read.

Also a KeyError, because that is what "this ID is not in there" already means in Python, so except KeyError around a per-neuron loop keeps working.

NoSuchVersionError

Bases: ConnectoError, ValueError

The requested version does not exist for this dataset.

AmbiguousVersionError

Bases: ConnectoError, ValueError

No single version contains all the requested IDs.

Raised by version="auto" when the given IDs never co-existed in any materialization.

MissingDependencyError

Bases: ConnectoError, ImportError

An optional dependency is required for this operation.