Skip to content

feat(transfer): exclude_names + duplicate linking for uploads, plus instrument folder uploader - #581

Merged
leoschwarz merged 26 commits into
mainfrom
feat/upload-exclude-names
Aug 12, 2026
Merged

feat(transfer): exclude_names + duplicate linking for uploads, plus instrument folder uploader#581
leoschwarz merged 26 commits into
mainfrom
feat/upload-exclude-names

Conversation

@Caushi

@Caushi Caushi commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Three related upload changes, plus an example that exercises them.

Stacked on #579#573. Review those first; this retargets to main once they land.

1. exclude_names on upload_files / collect_file_infos

Drops files by basename at any depth during the folder expansion, for callers that keep a sentinel file inside the directory they upload (or want to skip .DS_Store and friends).

Filtering has to happen inside collect_file_infos rather than in the caller's path list. compute_file_info only applies base_dir on the directory branch, so pre-filtering into a flat file list silently changes resource names:

whole folder      : ['.marker', 'sub/a.raw', 'top.raw']
children filtered : ['a.raw', 'top.raw']        # sub/ lost, silently

It also breaks folders with same-named files in different subdirectories, which _pair_resources_to_files rejects as duplicate resource names.

A directory whose contents are entirely excluded raises ValueError through the existing empty-directory branch, rather than creating a workunit with no resources.

2. Linking content-duplicates instead of skipping them

Opt-in via UploadFilesParams.link_duplicates / bfabric-cli workunit upload --link-duplicates. Any duplicate verdict naming an existingResourceId has that id passed back as linkFromResourceId; the server creates an AVAILABLE resource pointing at the existing bytes with no transfer. The workunit then holds a resource for every input file rather than silently omitting duplicates.

Linking is deliberately the caller's decision, not the server's: this server reports skip for every duplicate category, so the trigger is "a verdict carrying an existingResourceId", not an explicit link action. Without the flag, behaviour is unchanged.

UploadSummary reports linked (a count, alongside uploaded/skipped/failed) with the detail in links, mirroring uploads / failures. A workunit whose files were all linked now completes instead of tripping the "nothing uploaded" check.

3. Instrument folder uploader (example)

A cron-friendly uploader: each subfolder of a watched directory becomes one workunit, uploaded once its marker file appears. One YAML config per machine, so a fleet shares the script and differs only in config.

Three load-bearing details:

Sidecar state file. The folder → workunit-id memory lives outside the watched tree. Inside the run folder it would be uploaded as a resource — and since its content changes after every upload, its md5 would change too, re-uploading it on every scan and defeating the dedup the design depends on. A config guard rejects a state_dir inside watch_dir (resolved, so symlinks and .. cannot smuggle it back in).

Atomic state write. write_text truncates before writing, so a crash mid-write leaves an empty file — which reads back as "not yet uploaded" and creates a duplicate workunit, exactly the failure the state file exists to prevent. Written temp-then-os.replace.

A new run uploads with force=True. B-Fabric's duplicate check is container-wide, not per-run. An unrelated run that produced byte-identical content (a calibration file, a blank) would otherwise suppress this run's copy, leaving the folder with no workunit of its own — and with no id to remember, stranded on the create path forever. Instrument runs are events, not content. The reuse path keeps dedup, which is what makes repeated scans cheap: force=True on an unchanged folder 409s on the server's per-workunit path uniqueness.

The uploader deliberately does not use linking — for automatic instrument capture, each run wants its own resources.

The marker filename is configurable (marker_name, default .bfabric_upload). Sites whose acquisition software already drops a done-flag can point the uploader at that instead of training operators to create ours — the difference between an operator doing nothing and an operator opening a terminal. Validated at load, since a name containing / would never match the per-folder check and would silently skip every run forever.

--help is operator-facing rather than a dump of the module docstring: it names the marker file, shows the touch command, and points at the secret env var.

Verified end to end

Against a live B-Fabric + tus instance (application 588, container 403).

Nested folder through the uploader:

Scan Result Workunit
initial uploaded 3, skipped 0, failed 0 created, AVAILABLE
unchanged re-scan uploaded 0, skipped 3 reused
new file in a new subdir uploaded 1, skipped 3 reused

Resources kept their relative names (sub/deeper/deep.raw), the marker never appeared as a resource, and exactly one workunit existed per run.

Linking, on a folder mixing duplicates with new content:

uploaded 1, linked 2, skipped 0, failed 0
dup_a.raw  available  ->  p403/w346619/top.raw          (linked, no transfer)
dup_b.raw  available  ->  p403/w346619/sub/nested.raw   (linked, no transfer)
fresh.raw  available  ->  p403/w346637/fresh.raw        (transferred)

Also checked directly against the REST layer: mixed link+plain batches flag linked per file, and a batch containing one bad id registers nothing (all-or-nothing).

Full tests/bfabric suite passes (868); basedpyright clean on bfabric and bfabric_scripts.

Note

Uploading a folder with sub-directories required a server-side fix: /rest/upload/* previously echoed the basename, so nested names failed the duplicate-check and resource-pairing guards, and stored paths were flattened. That is fixed and verified; the guards were right and needed no change. A nested re-upload now reports renamed_duplicate rather than exact_duplicate (name matching misses on a subpath, so detection falls back to MD5) — both carry skip, so branch on action, not category.

Open question for reviewers

The uploader's marker stays inside the run folder — that is where the operator is when the run ends, and a done-flag in a distant directory is one that gets skipped or misspelled. It is excluded via exclude_names, so it is never uploaded. Say the word if you would rather it lived in state_dir too.


The lazy-polars change that was briefly on this branch now lives in #585, since it touches core entity modules unrelated to uploads.

leoschwarz and others added 18 commits August 6, 2026 11:40
Design document for the bfabric-cli auth redesign: the two-modes diagnosis,
the decisions taken and their justification, and the implementation order.

Committed so the design can be reviewed before any code is written; removed
before this PR is ready to merge.
An expired token was renewed by retyping the base URL and scope, because a
config env stored only half a login: `scope` was written nowhere durable and
`base_url` had no fallback. The env now records the requested scope, so
`bfabric-cli login` with no arguments replays it and prompts for nothing.

- Config: new `scope` env field (the *requested* value, kept separate from the
  granted scope in the token cache so drift stays visible).
- `write_environment_to_config` merges instead of replacing, so hand-written
  keys survive a re-login; auth-owned keys are replaced wholesale so a stale
  `pat` can't outlive the method that wrote it. Round-trip validation moves to
  the merged env.
- New `clear_environment_credentials` + `auth logout` / `auth remove` split:
  logout drops credentials per auth method (token cache, or inline pat /
  password) and keeps the env replayable; remove deletes the env.
- `auth default` -> `auth activate`; handlers normalised to `cmd_auth_*`;
  top-level `bfabric-cli login` alias.
- `auth list` groups by instance with account / scope / expiry; `list` and
  `status` say why an env is active. Account read from the access token
  locally, so no extra scope is needed.
- Every auth command resolves --config-env > BFABRICPY_CONFIG_ENV > default;
  config-writing commands refuse under BFABRICPY_CONFIG_OVERRIDE.
- Refuse to silently repoint an env's base_url; normalise URLs offline and
  pre-flight them against OIDC discovery before the browser opens (advisory:
  a miss warns and proceeds).
- PKCE fallback and timeout messages name the loopback redirect and point at
  device-code; `--no-browser` wired through.

`logout` states that B-Fabric has no revocation endpoint, so an issued token
stays valid until expiry. No deprecation shims: `auth` shipped 3 days ago and
is marked experimental.
Remove the OIDC discovery pre-flight from login flows and the JWT-based
account
identity display from auth list/status. Base URLs are now normalized
purely
offline in the new _urls.py, and auth list/status show scope and expiry
only.
- Inline single-use helpers (`select_instance`, `print_environments`,
  `_scope_menu_label`) into their callers
- Move `require_mutable_config()` checks into
  `_load_config(mutating=...)`
  and `_resolve_params` so mutating commands check once
- Cache the known-instance host reverse index in `_urls`
Centralize file reading and validation; `_load_for_edit` now returns the
raw mapping directly, and validation runs on a copy to avoid mutating
the write data.
Unify the selection-with-custom-entry flow in resolve_base_url and
resolve_scope behind a single helper; behavior is unchanged.
Both Unreleased sections had grown into design-doc prose: multi-sentence
bullets carrying the rationale for each decision. Cut ~40% of the words,
keeping one line per user-visible change. The reasoning already lives in
docs/design/oauth_integration.md, oauth_usage_and_troubleshooting.md and
the new authentication user guide.
Instances do advertise one: trace's OIDC discovery document publishes
revocation_endpoint = {base_url}/rest/oauth/revoke. Whether it is
implemented is unverified, so logout now states what the command does
(it does not revoke server-side) instead of what the server cannot do.

Affects the notice printed on every logout, 'auth logout --help', the
authentication user guide and the OAuth design doc.
`auth register` prompted for an "Employee Bearer token" whenever neither
--token nor --config-env was given, so a fresh `bfabric-cli login` did not
help: the user had to paste a token by hand. Resolve the environment the
same way every other `auth` command does (--config-env >
BFABRICPY_CONFIG_ENV > configured default) and reuse its token.

Obtain that token via `Bfabric.connect()` rather than rebuilding the
credential provider and token-cache lookup locally -- `connect()` already
resolves the environment (including the "default" sentinel), loads the
cache and raises the "log in first" errors, so ~40 lines of duplicated
logic go away along with the divergent error messages.

Also require an explicit --service-user / --no-service-user choice, as
register-webapp already does: silently defaulting to no service user
registers a client without the client_credentials grant.

One behaviour is deliberately not preserved: a non-OAuth (password)
environment is no longer pre-screened, so its SOAP password is sent and
rejected by the server instead of failing locally. Validating that
belongs in connect(), not bolted onto this command.
@Caushi Caushi changed the title feat(transfer): add exclude_names to upload_files and collect_file_infos feat(transfer): exclude_names for uploads, plus instrument folder uploader example Aug 10, 2026
Claudio Cannizzaro added 2 commits August 10, 2026 14:02
Drop files by basename at any depth during the folder expansion, for callers
that keep a sentinel file inside the directory they upload (or want to skip
.DS_Store and friends).

Filtering has to happen inside collect_file_infos rather than in the caller's
path list: base_dir is only applied on the directory branch, so pre-filtering
into a flat file list silently renames "sub/file.raw" to "file.raw" and makes
two same-named files in different subdirectories collide in
_pair_resources_to_files.

A directory whose contents are entirely excluded raises ValueError via the
existing empty-directory branch, rather than creating a workunit with no
resources.
A cron-friendly uploader for instrument output: each finished run folder
becomes one workunit, uploaded once its marker file appears.

Three details are load-bearing:

- The folder -> workunit-id memory is a sidecar state file outside the watched
  tree. Keeping it inside the run folder would upload it as a resource, and
  since its content changes after every upload its md5 would change too,
  re-uploading it on every scan. A config guard rejects a state_dir inside
  watch_dir.

- The state file is written atomically (temp + os.replace). A bare write_text
  truncates first, so a crash mid-write would leave an empty file, which reads
  as "not yet uploaded" and creates a duplicate workunit for the run.

- A new run uploads with force=True. B-Fabric's duplicate check is
  container-wide, so an unrelated run that produced byte-identical content
  would otherwise suppress this run's copy and leave it with no workunit at
  all -- and with no id to remember, stranded on the create path forever.
  Instrument runs are events, not content. The reuse path keeps dedup, which
  is what makes repeated scans cheap.

The operator's marker stays in the run folder (that is where the operator is
when the run ends) and is excluded via exclude_names.
@Caushi
Caushi force-pushed the feat/upload-exclude-names branch from 1eba9fc to 4ef5e88 Compare August 10, 2026 12:02
@Caushi
Caushi changed the base branch from main to fix/auth-register-uses-login August 10, 2026 12:02
Claudio Cannizzaro added 2 commits August 10, 2026 14:50
The upload REST API gained a `linked` field on create-resources responses and
accepts `linkFromResourceId` on requests, letting a resource point at bytes the
instance already stores instead of transferring them again.

Handle both directions:

- `CreatedResource.linked` marks a resource created AVAILABLE with no bytes to
  send. Such resources are excluded from the ids passed to /upload/initiate and
  never handed to the mover; a workunit whose files were all linked completes
  instead of tripping the "nothing uploaded" failure branch.
- `FileInfo.link_from_resource_id` drives the request side, emitted only for
  create-resources (never on check-duplicates, and omitted rather than sent as
  null for an ordinary upload).
- New opt-in `UploadFilesParams.link_duplicates` / `--link-duplicates` links a
  duplicate rather than skipping it, so the workunit holds a resource for every
  input file. The server reports a content-duplicate as action "skip" with an
  existingResourceId (category exact_duplicate/renamed_duplicate), so linking
  keys off that, not off action "link"; a duplicate with no existingResourceId
  stays a plain skip. Off by default, since linking registers bytes this caller
  never uploaded.
- `UploadSummary.linked` / `linked_count` report linked files separately: a
  linked file has a resource but transferred nothing, whereas a skipped one has
  no resource at all.

Nested resource names now round-trip verbatim, so the duplicate-check and
resource-pairing guards no longer fire on subdirectory uploads and force=True is
no longer needed to work around them. Tests cover that, since the fix is
server-side and nothing here changed.
linked was a list[FileUpload] while uploaded/skipped/failed were ints, so
f"linked {summary.linked}" dumped a list of objects instead of a number, and
callers needed the separate linked_count property to get the count.

Every counter is now an int with a matching plural detail list:

    uploaded -> uploads
    failed   -> failures
    linked   -> links
    skipped

linked_count is dropped, since linked is now the count it provided.
@Caushi Caushi changed the title feat(transfer): exclude_names for uploads, plus instrument folder uploader example feat(transfer): exclude_names + duplicate linking for uploads, plus instrument folder uploader Aug 10, 2026
@Caushi Caushi changed the title feat(transfer): exclude_names + duplicate linking for uploads, plus instrument folder uploader feat(transfer): exclude_names + duplicate linking, lazy polars, instrument folder uploader Aug 10, 2026
The marker filename was hardcoded to .bfabric_upload, and --help printed the
whole module docstring -- pages of design rationale that never once named the
file an operator has to create.

- New optional `marker_name` config key (default unchanged). Sites whose
  acquisition software already drops a done-flag can point the uploader at it
  instead of training operators to create ours, which is the difference
  between an operator doing nothing and an operator using a terminal.
  Validated at load: a name containing "/" would never match the per-folder
  existence check, so every run would be silently skipped forever.
- --help is now operator-facing: it names the marker file, shows the touch
  command, and points at the secret env var. The design rationale stays in
  the module docstring, for whoever reads the source.
@Caushi
Caushi force-pushed the feat/upload-exclude-names branch from e1eb7ab to d0efd47 Compare August 10, 2026 14:22
@Caushi Caushi changed the title feat(transfer): exclude_names + duplicate linking, lazy polars, instrument folder uploader feat(transfer): exclude_names + duplicate linking for uploads, plus instrument folder uploader Aug 10, 2026
@Caushi
Caushi marked this pull request as ready for review August 11, 2026 07:21
Base automatically changed from fix/auth-register-uses-login to main August 11, 2026 10:23
…ames

# Conflicts:
#	bfabric/docs/changelog.md
#	bfabric_scripts/docs/changelog.md
Replaces the two interacting booleans on UploadFilesParams (force,
link_duplicates -- which had a "no effect together" caveat) with a single
tri-state carried per file, and moves the file list into the params object.

on_duplicate takes upload / skip / link: the same three words as the
server's check-duplicates verdict (DuplicateResult.action), so the caller's
intent and the server's answer compare directly instead of via a
translation table. A directory entry applies its policy to every file under
it, and an "upload" file is left out of the check-duplicates request
entirely.

Also hoists the duplicate-resource-name guard ahead of workunit creation:
per-file policy makes a shared name genuinely ambiguous (verdicts are keyed
by name alone), and failing early means a rejected upload no longer leaves
a "failed" workunit behind.

BREAKING CHANGE: upload_files(client, files, params) is now
upload_files(client, params), with the files in UploadFilesParams.files as
UploadFileParam entries. force and link_duplicates are removed, as is the
CLI's --force (replaced by --on-duplicate). on_duplicate defaults to
"upload", so the duplicate check no longer runs unless asked for -- callers
relying on duplicates being skipped must now pass on_duplicate="skip".
…ters

UploadSummary carried each outcome twice -- uploaded/uploads,
failed/failures, linked/links -- a count and the list it was the length of,
with the invariant held up only by upload_files remembering to pass len()
at the single construction site. There was even a test whose whole job was
to assert the redundancy held.

No counter was needed independently of its list: every production read is a
display f-string in the CLI, the folder-uploader example and the user
guide, and each is exactly len(list). So they are dropped rather than kept
as derived properties, which would only reintroduce two ways to ask one
question.

skipped was the odd one out, having no list at all -- a skipped file
existed only as a number. It becomes FileSkip(filename, category,
existing_resource_id), built from the verdict the duplicate check already
returned, so all four outcomes read the same way and a caller can finally
see which files were dropped and what stored bytes they matched.

The CLI's summary lines are unchanged word for word.

BREAKING CHANGE: UploadSummary.uploaded, .skipped and .failed (released in
1.20.0) and the unreleased .linked are removed; use len(summary.uploads),
len(summary.skips), len(summary.failures), len(summary.links). The skipped
count is now the skips list of FileSkip records.

@leoschwarz leoschwarz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Claudio, I generated some more code yesterday implementing the changes we discussed. I left two comments, which you might want to check before we merge it.

Comment thread bfabric/src/bfabric/operations/workunit/upload.py
Comment thread bfabric/src/bfabric/operations/workunit/upload.py
@leoschwarz
leoschwarz merged commit 4006253 into main Aug 12, 2026
24 checks passed
@leoschwarz
leoschwarz deleted the feat/upload-exclude-names branch August 12, 2026 07:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants