feat(transfer): exclude_names + duplicate linking for uploads, plus instrument folder uploader - #581
Merged
Merged
Conversation
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.
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
force-pushed
the
feat/upload-exclude-names
branch
from
August 10, 2026 12:02
1eba9fc to
4ef5e88
Compare
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.
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
force-pushed
the
feat/upload-exclude-names
branch
from
August 10, 2026 14:22
e1eb7ab to
d0efd47
Compare
Caushi
marked this pull request as ready for review
August 11, 2026 07:21
…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
reviewed
Aug 12, 2026
leoschwarz
left a comment
Member
There was a problem hiding this comment.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three related upload changes, plus an example that exercises them.
1.
exclude_namesonupload_files/collect_file_infosDrops 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_Storeand friends).Filtering has to happen inside
collect_file_infosrather than in the caller's path list.compute_file_infoonly appliesbase_diron the directory branch, so pre-filtering into a flat file list silently changes resource names:It also breaks folders with same-named files in different subdirectories, which
_pair_resources_to_filesrejects as duplicate resource names.A directory whose contents are entirely excluded raises
ValueErrorthrough 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 anexistingResourceIdhas that id passed back aslinkFromResourceId; the server creates anAVAILABLEresource 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
skipfor every duplicate category, so the trigger is "a verdict carrying anexistingResourceId", not an explicitlinkaction. Without the flag, behaviour is unchanged.UploadSummaryreportslinked(a count, alongsideuploaded/skipped/failed) with the detail inlinks, mirroringuploads/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_dirinsidewatch_dir(resolved, so symlinks and..cannot smuggle it back in).Atomic state write.
write_texttruncates 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=Trueon 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.--helpis operator-facing rather than a dump of the module docstring: it names the marker file, shows thetouchcommand, 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:
AVAILABLEResources 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:
Also checked directly against the REST layer: mixed link+plain batches flag
linkedper file, and a batch containing one bad id registers nothing (all-or-nothing).Full
tests/bfabricsuite passes (868); basedpyright clean onbfabricandbfabric_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 reportsrenamed_duplicaterather thanexact_duplicate(name matching misses on a subpath, so detection falls back to MD5) — both carryskip, so branch onaction, notcategory.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 instate_dirtoo.The lazy-
polarschange that was briefly on this branch now lives in #585, since it touches core entity modules unrelated to uploads.