Import files from a remote connector¶
A connector is remote object storage your organization already uses — a team S3 bucket — registered with Nexus once by an administrator. Importing from one gets a dataset into a collection without downloading it to your laptop first, and without your notebook ever handling cloud credentials.
The calls are the ones you already know from Bring simulation files into a
collection: collection.plan(datasource) to validate, plan.apply() to
import. Only the datasource changes. What is different is where the file list comes from — Nexus
lists the remote prefix server-side, seals the result, and validates that sealed list against your
collection schema before a single byte moves.
Before you start¶
Someone with connector administration rights registers the connector, and hands you the first three values below. The other two are a project and collection that already exist — Bring simulation files into a collection makes those.
| You need | Looks like | Environment variable | Where it comes from |
|---|---|---|---|
connector_id |
conn_5329aaa438a94473 |
NEXUS_CONNECTOR_ID |
The registered connector |
source_prefix |
s3://customer-bucket/for_dev |
NEXUS_SOURCE_PREFIX |
The prefix to import, at or under the connector's root |
transfer_binding_id |
miura-producer-copy |
NEXUS_TRANSFER_BINDING_ID |
Platform transfer routing for your deployment |
| project name | simulation-campaigns |
NEXUS_PROJECT_NAME |
A project you can write to |
| collection name | cylinder-runs |
NEXUS_COLLECTION_NAME |
A collection in that project, with a schema |
The runnable scripts read all five from those variables, and authenticate the same way every other
example does — see Authentication for MIURA_NEXUS_API_KEY and the deployment URL.
Connector import is async only. Inventory and transfer both poll a remote job, so
collection.plan() raises TypeError for a ConnectorDataSource on the sync client
(from miura import Nexus), and the connector administration and inventory namespaces are async
too. Every snippet on this page therefore uses AsyncNexus — that is the one place where a
connector import differs from the local upload it otherwise mirrors.
Snippets continue one session. The first opens it; the rest run inside that async with block,
against a collection you fetched from your project:
from miura.api import AsyncNexus
async with AsyncNexus() as nexus:
project = await nexus.get_project(project_name)
collection = await project.get_collection(collection_name)
How the flow fits together¶
Two services split the work, and the split explains most of the API's shape: the data source service owns what a connector is, the ingestion service owns what you are about to import.
| Stage | What it produces | Your call |
|---|---|---|
| Connector | A registration plus a credential reference | nexus.connectors.get_connector(...) |
| Connection test | Proof the credentials still reach the storage | nexus.connectors.test_and_wait(...) |
| Inventory scan | A sealed list of remote files | collection.plan(...), or nexus.connector_inventory.start(...) |
| Import plan | A validation report bound to that list | collection.plan(...) |
| Import run | The transfer itself | plan.apply() |
| Collection items | Readable files | collection.walk_items(...) |
Importing never puts credentials in your process. They stay with the platform, which materializes
them inside the workers that do the listing and copying — nothing you pass to ConnectorDataSource
is secret. Registering a connector is the one place you handle them, and
get_credentials() hands them back; both are administration, not import.
Find and test a connector¶
Start from the types your deployment supports — a type declares which capabilities a connector of that type can be asked to prove.
from miura.api import AsyncNexus
async with AsyncNexus() as nexus:
for connector_type in await nexus.connectors.list_types():
capabilities = ",".join(connector_type["capabilities"])
print(f"{connector_type['key']:<16} {connector_type['display_name']:<44} {capabilities}")
rclone_http HTTP(S) endpoint (rclone) test_connection,list_objects,read_files
rclone_ovh_s3 OVHcloud Object Storage (rclone S3) test_connection,list_objects,read_files,write_files
rclone_s3 S3-compatible object storage (rclone) test_connection,list_objects,read_files,write_files
Then probe the connector you were given. test_and_wait submits a test, polls until it reaches a
terminal state, and raises on anything that is not green — it fails closed rather than letting a
broken connector reach an inventory job.
status = await nexus.connectors.test_and_wait(
connector_id,
{"requested_capabilities": ["test_connection", "list_objects"]},
)
print(status["status"], status["pinned_revision"], status["capabilities_verified"])
Ask for list_objects when you are about to inventory: test_connection reaches the connector's
root, which proves the endpoint answers and the credentials are accepted, but not that they can walk
the prefix you are about to import. requested_capabilities is required — the service rejects an
empty test body — so the SDK defaults it to ["test_connection"] when you pass nothing. It is
recorded on the test run and comes back on the response beside the result.
capabilities_verified reports what the probe established, never what the connector's type declares.
A capability that fails takes the whole test down with it, so on a green result the list is exactly
what you asked for — and on a red one test_and_wait raises rather than hand you a partial pass.
Two capabilities are refused rather than tested
read_files and write_files are declared by connector types but cannot be proven without
reading your objects or writing one into your bucket, so the test refuses to claim them:
requesting either fails with CONNECTOR_CAPABILITY_PROBE_UNSUPPORTED. Ask only for
test_connection and list_objects — between them they cover what an import needs.
The test pins a connector revision. Every edit to a connector bumps that revision, and the
revision recorded at inventory time is checked again at import time. If the connector is rotated in
between, the import is refused with PLAN_SOURCE_EVIDENCE_MISMATCH rather than transferring against
configuration nobody validated.
Register a connector¶
Registration is a single call — there is no separate enrollment step. config describes where the
storage is; secrets is the only place credentials appear.
created = await nexus.connectors.create_connector(
{
"name": "team-openfoam-bucket",
"type_key": "rclone_s3",
"driver_kind": "rclone",
"config": {
"provider": "Other",
"region": "gra",
"bucket": "customer-bucket",
"endpoint": "https://s3.gra.io.cloud.ovh.net",
"prefix": "for_dev",
},
"secrets": {
"access_key_id": access_key_id,
"secret_access_key": secret_access_key,
},
}
)
print(created["connector_id"], created["revision"], created["secret_ref"])
conn_5329aaa438a94473 1 openbao://kv/nexus/data-source-service/connectors/<org>/conn_5329aaa438a94473/<ref>
The secret does not come back from create_connector. What you get is a secret_ref scoped to your
organization and that connector, which the inventory and transfer workers re-validate before they
are allowed to read it.
get_credentials() returns the secret itself
get_credentials(connector_id) is not the same kind of metadata: it returns the stored
access_key_id and secret_access_key in plaintext. Treat its response as key material —
do not print it, log it, or leave it in notebook output.
update_connector(connector_id, payload) patches a registration; delete_connector(connector_id)
removes it. Each connector type takes a different config shape — read it from
get_type(type_key) rather than guessing.
A patch bumps the revision when it changes something an import depends on — config, secrets,
driver_kind, type_key. Renaming a connector does not: the revision tracks what the workers
materialize, and a display name is not part of that.
Choose what to import¶
ConnectorInventoryScope is the question "what does everything mean here". It is answered once,
server-side, and the answer is frozen.
| Field | Default | What it does |
|---|---|---|
source_prefix |
required | The prefix to list. Must be the connector's root or a path under it |
include_glob |
**/* |
Narrows the listing |
max_keys |
20000 |
Upper bound on objects. Also the hard cap |
force_refresh |
False |
Lists again instead of reusing a recent identical scan |
from miura.sdk import ConnectorDataSource, ConnectorInventoryScope
datasource = ConnectorDataSource(
connector_id="conn_5329aaa438a94473",
inventory_scope=ConnectorInventoryScope(
source_prefix="s3://customer-bucket/for_dev",
include_glob="**/*.h5",
),
transfer_binding_id=transfer_binding_id,
)
Two properties of that scan are worth knowing, because they are what makes the later validation report trustworthy:
Sealed. The platform re-reads the listing the worker produced and checks it independently —
schema, path safety, no duplicates, exact object count, the manifest's own byte count and SHA-256 —
before marking the job successful. The worker's claim that it finished is not taken as proof. The
scan also totals the sizes of the objects it listed; that total comes back as object_bytes on the
manifest, and it is what the import reports as its size. Do not confuse it with the byte count the
sealing check verifies — that one measures the manifest document, a few kilobytes for a scan of 27
objects whatever those objects weigh, and it stays server-side.
Bounded, never truncated. A prefix with more objects than max_keys fails the inventory with
INVENTORY_LIMIT_EXCEEDED. It does not silently import the first 20 000 files. Narrow
include_glob or import a deeper prefix instead.
Running the same scope again within the reuse window returns an alias of the earlier scan rather
than re-listing — same sealed manifest, new auditable job row, reused: true and
reused_from_job_id pointing at the scan it aliases. Changing the connector's revision invalidates
the reuse, and force_refresh=True bypasses it.
Those two fields are on the job, not the manifest, so they come back from
nexus.connector_inventory.start() and get() — the raw job dicts. resolve(), and plan()
through it, hand you the sealed manifest and the job binding without them; reach for start() when
whether a scan was reused is the thing you are trying to find out. connector_inventory.py Steps
4-5 show both halves.
Review before anything moves¶
collection.plan() takes either datasource and returns the same handle. For a connector it runs the
inventory scan, seals the manifest, and validates it against the collection schema. No bytes move.
from miura.display import print_ingestion_plan
from miura.sdk import UploadMode
plan = await collection.plan(datasource, mode=UploadMode.INSERT)
print_ingestion_plan(plan)
Ingestion plan
──────────────────────────────────────────────────
Plan ID: igplan_4f2…4c3a8f16 igplan_4f2c1a9db7e04c3a8f16
Mode: INSERT
Type: CONNECTOR_TRANSFER
Gate: passed
State: SUCCEEDED
Admissible: True
Candidates: 27
The plan id appears twice on purpose: an abbreviated form to read, then the full one to copy. Every output on this page comes from the same run against the same 27-object prefix, so the identifiers and counts line up across all four.
plan.print_validation_report() gives the record-level detail — which remote paths the schema
admitted and which it rejected, and why.
report = await plan.print_validation_report(max_issues=20)
print(len(report.passed_records), "admitted;", len(report.failed_records), "rejected")
Plan validation report (failed)
──────────────────────────────────────────────────
Plan ID: igplan_4f2…4c3a8f16 igplan_4f2c1a9db7e04c3a8f16
Manifest: 3b9a4c17e2f8d05a…
Passed (27):
• /case_000001/system/controlDict
• /case_000001/0/U
• /case_000001/0/p
• /case_000001/post/post000001_+0.00000e+00.h5
• /case_000001/post/post000002_+1.00000e-01.h5
… and 22 more
27 admitted; 0 rejected
The (failed) in that title is the report's default heading, not a verdict on your plan — read
plan.is_admissible and report.failed_records for the answer. Gate the transfer on the former:
if not plan.is_admissible:
raise SystemExit(f"{plan.blocking_failure_count} blocking failures — nothing imported")
Preview: describe cases before import¶
If each remote folder represents one simulation, pass the same case_grouping= you
would use for a local folder. Connector case grouping is on by default (ADR-0021
Phase A). Opt out for a process with MIURA_CONNECTOR_CASE_GROUPING=false only when
your deployment does not yet support it.
The grouping API is the same one used for local files:
from miura.nexus.case_grouping import CaseGrouping
case_grouping = CaseGrouping.root_path("/", depth=1)
plan = await collection.plan(
datasource,
mode=UploadMode.INSERT,
case_grouping=case_grouping,
)
Inspect plan.validation_gates and plan.is_admissible before applying. A successful grouping
exposes plan.case_grouping_result and plan.cases; a failed grouping blocks plan.apply() and
names case_grouping in the error. When the client has opted out, collection.plan() raises a
ValueError rather than silently ignoring the grouping request.
Import¶
plan.apply() is where bytes move, and it is the same call as a local upload because the routing it
needs travels on the datasource.
from miura.display import print_upload_result
result = await plan.apply()
print_upload_result(result)
Upload result
──────────────────────────────────────────────────
Status: SUCCEEDED
Run: ingr_9c74b…f0a34d5b ingr_9c74b2e1f0a34d5b
Plan: igplan_4f2…4c3a8f16 igplan_4f2c1a9db7e04c3a8f16
Uploaded: 27 Failed: 0 Size: 1.5 GiB
The size comes from the sealed manifest, not the transfer. Against a deployment whose inventory
never recorded object_bytes, result.total_size_bytes is None and the Size field is left out
rather than shown as 0 B.
| Routing field | Purpose |
|---|---|
transfer_binding_id |
Which platform transfer path carries the copy |
asset_metadata |
Free-form metadata recorded against the imported asset |
Routing is checked when you call apply(), not when you build the datasource — so a datasource
built only to inventory a prefix and read the report stays valid without it. Call
datasource.require_transfer_routing() if you want that check earlier; it returns
(transfer_binding_id, airflow_dag_id, asset_metadata) or raises naming what is missing. Only the
binding is required. The middle value is an escape hatch for redirecting a single run onto a
different transfer DAG — the ingestion service picks the DAG from its own settings, so leave it
unset and expect None back.
The transfer copies exactly the admitted set from the sealed manifest. Objects added to the
bucket after the inventory scan ran are not picked up — the run and the report you approved describe
the same files. Re-run plan() to pick up later arrivals.
await plan.apply() resolves when the transfer reaches a terminal state, and raises if that state
is not green.
Read what was imported¶
Nothing here is connector-specific — imported files are collection items like any other, so the whole of Read recipe results for analysis applies.
print(await collection.count_items(), "items")
async for item, _depth in collection.walk_items(path="/", include_dirs=False):
print(item.item_uri, item.file_size)
27 items
/case_000001/system/controlDict 1284
/case_000001/0/U 8402
/case_000001/0/p 8380
/case_000001/post/post000001_+0.00000e+00.h5 87421184
/case_000001/post/post000002_+1.00000e-01.h5 89112576
Three cases of nine files each: a small control and field set, then six result files per case. The result files are what make up almost all of the 1.5 GiB the transfer reported.
Local and connector plans compared¶
Everything you need to decide whether to apply is on both kinds of plan:
| Shared | |
|---|---|
run_type |
LOCAL_UPLOAD or CONNECTOR_TRANSFER |
ingestion_plan_id, state |
Plan identity and terminal state |
object_count, blocking_failure_count |
Size of the candidate set and of the problem |
collection_mode, is_admissible |
Mode requested, and whether apply() is allowed |
Properties that only describe a local tree raise AttributeError on a connector plan rather than
report a misleading zero: data_root, total_size_bytes, gate_status, work_summary,
ignored_extras, requires_execution. Grouping properties are shared:
case_grouping_spec_id, case_grouping_spec_sha256, validation_gates,
case_grouping_result, and cases. Branch on plan.run_type (or
hasattr) when you handle both origins. The display helpers already do — print_ingestion_plan
works on either.
max_files= is refused for connectors because the file list is sealed remotely:
max_files=— planning a subset of a sealed manifest would break the binding between the plan and the scan it was validated against. Bound the candidate set withConnectorInventoryScope(max_keys=…)at inventory time instead.
It raises ValueError at plan() time rather than being silently ignored.
Replay a sealed manifest¶
A sealed manifest can be planned again without re-listing the remote prefix — useful when you want to retry an import against exactly the file set you reviewed.
resolved = await nexus.connector_inventory.resolve(datasource)
replay = datasource.with_manifest(resolved.manifest)
plan = await collection.plan(replay, mode=UploadMode.INSERT)
with_manifest() carries transfer_binding_id and asset_metadata across, so
the rebuilt datasource can still apply. A replayed manifest must carry the inventory_job_id it came
from — that binding is what the plan is admitted against, so a ConnectorDataSource built on a
CandidateManifest without one raises ValueError. Manifests from
nexus.connector_inventory.get_manifest() always carry it.
Note what the replayed datasource does not carry: an inventory scope, and so no source_prefix.
That is deliberate — a replay lists nothing, and a datasource holds either a scope or a manifest,
never both. plan() reads the canonical prefix off the inventory job the manifest is bound to, so
you do not have to supply one.
You can also drive the scan yourself with nexus.connector_inventory: start(connector_id, scope),
wait(job_id), get(job_id), get_manifest(job_id). That is the same work plan() does for you,
exposed for when you want to inspect reuse, scope hashes, or expiry before committing to a plan —
and get() is what returns them, the job row with its state, reused, scope.scope_hash and
expires_at. get_manifest() returns the sealed artifact instead: counts, checksum, provenance.
When something fails¶
Errors carry a typed code, and it is not in the message — plan and transfer rejections raise
ValidationError, whose .code is the SDK's own coarse validation_error. The platform code is in
.details:
from miura.sdk.exceptions import NexusError
try:
result = await plan.apply()
except NexusError as exc:
print(exc.details.get("code")) # PLAN_SOURCE_EVIDENCE_MISMATCH
print(exc.details.get("ingestion_run_id"))
Inventory and transfer polling are the exception: those raise RuntimeError with the code inside
the message text, because the failure is reported on a job row rather than an HTTP response.
The codes worth recognizing:
| Code | Meaning |
|---|---|
INVENTORY_LIMIT_EXCEEDED |
The prefix holds more objects than max_keys. Narrow the scope |
INVENTORY_MANIFEST_NOT_READY |
The manifest was requested before the scan finished |
CONNECTOR_INVENTORY_NOT_READY |
A plan referenced an inventory job that has not succeeded |
CONNECTOR_INVENTORY_PROVENANCE_MISMATCH |
The plan's connector, revision, or prefix disagrees with the sealed scan |
PLAN_SOURCE_EVIDENCE_MISMATCH |
The connector changed between inventory and import |
PLAN_EXPIRED |
The plan aged out. Re-run plan() |
PLAN_ALREADY_CONSUMED |
A second run was created against a plan already consumed. Re-run plan() |
INGESTION_PLAN_REQUIRED |
The environment requires a validated plan before a connector transfer |
CONNECTOR_TYPE_UNSUPPORTED |
The connector's type cannot be inventoried on this deployment |
CONNECTOR_CAPABILITY_PROBE_UNSUPPORTED |
The test was asked to verify a capability it cannot prove — see above |
CONNECTOR_CAPABILITY_PROBE_FAILED |
The connector's storage refused a probe. The credentials or the endpoint, not the registration |
INGESTION_PLAN_REQUIRED is a policy, not a bug: some environments refuse connector transfers that
were not planned first. Going through collection.plan() always satisfies it.
PLAN_ALREADY_CONSUMED is a rule about plans, not about apply(). Awaiting the same plan handle
twice does not raise it — the run's idempotency key is fixed when the plan is created, so the second
call returns the first run's result rather than starting a second transfer. You reach the code by
creating a fresh run against a spent plan; from this API, just call plan() again.
One failure carries no code at all. A prefix that exists but holds nothing is a successful scan of
zero objects, so plan() refuses it locally with ValueError: connector import requires a non-empty
candidate manifest, naming the prefix and glob that found nothing. It is almost always a mistyped
source_prefix or an include_glob that matches none of the files that are there.
Runnable scripts¶
| Topic | Script |
|---|---|
| End-to-end import | connector_import.py |
| Inventory scope, reuse, and replay | connector_inventory.py |
| Connector types, registration, and tests | connector_admin.py |
Each link opens that script's full source. API reference: Connectors, Datasources, Ingestion.