fix(metadata): stop masking local metadata write errors with UnboundLocalError - #3369
fix(metadata): stop masking local metadata write errors with UnboundLocalError#3369kevin9327 wants to merge 1 commit into
Conversation
…ocalError
_dump_json_to_file opens its temp file inside the try block, so when the
NamedTemporaryFile constructor is what fails, `f` is never bound and the
finally clause raises
UnboundLocalError: cannot access local variable 'f' where it is not
associated with a value
over the top of the real OSError. A user whose .metaflow directory is
read-only or owned by another account sees that instead of the
PermissionError naming the path.
Bind f = None before the try so the cleanup runs only when there is
something to clean up and the original error propagates. This matches the
sibling temp-file-then-rename helper, LocalStorage._atomic_write, which
creates its temp file outside the try for the same reason.
Greptile SummaryThis PR initializes the local metadata helper's temporary-file variable before construction so filesystem errors propagate without being replaced by
Confidence Score: 4/5The functional fix appears safe to merge; only non-blocking misleading test commentary should be corrected. The initialization correctly preserves the original filesystem exception without changing successful writes or cleanup, and the added tests cover the relevant paths; the sole finding concerns stale explanatory wording. Files Needing Attention: test/unit/test_local_metadata_provider.py
|
| Filename | Overview |
|---|---|
| metaflow/plugins/metadata_providers/local.py | Safely initializes the cleanup variable so the original temporary-file creation error propagates. |
| test/unit/test_local_metadata_provider.py | Adds appropriate regression and behavior tests, but two explanatory comments inaccurately describe the fixed behavior as current. |
Reviews (1): Last reviewed commit: "fix(metadata): stop masking local metada..." | Re-trigger Greptile
| # _dump_json_to_file opens its temp file *inside* the try, so when the | ||
| # NamedTemporaryFile constructor is the thing that fails, `f` is never | ||
| # bound and the finally clause raises UnboundLocalError over the top of | ||
| # the real OSError. The caller then has no idea what actually went wrong. |
There was a problem hiding this comment.
These comments describe the old masking behavior in the present tense, even though f is now initialized before the try. Rephrase them as regression history so future maintainers are not led to believe the current implementation still raises UnboundLocalError. The permission-error test below has the same stale wording.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Shriprasad-P
left a comment
There was a problem hiding this comment.
The implementation itself is correct: I reproduced master masking both FileNotFoundError and PermissionError as UnboundLocalError, and verified the PR preserves the original exceptions and cleanup behavior across the requested failure and success paths.
There is, however, a repository-process blocker separate from source correctness. This change is under metaflow/plugins/metadata_providers/, which CONTRIBUTING.md classifies as Core Runtime. The PR currently marks only Bug fix, says “No existing issue,” and does not link an open, maintainer-acknowledged issue. The Core Runtime policy and external-contributor guidance require that issue and approval before acceptance. Please link the approved issue and update the Core Runtime classification/checklist.
The existing Greptile thread on test/unit/test_local_metadata_provider.py:81 already covers the stale present-tense regression comments, so I am not duplicating that inline comment.
Shriprasad-P
left a comment
There was a problem hiding this comment.
Review
Looks like a solid fix for the UnboundLocalError masking real I/O failures in _dump_json_to_file.
What works well
- Initializing
f = Nonebefore thetryis the minimal correct fix for thefinallypath. - Regression tests cover constructor failure, mocked
PermissionError, happy-path write, and no-overwrite behavior.
LGTM from a correctness standpoint.
Shriprasad-P
left a comment
There was a problem hiding this comment.
Review
Looks like a solid fix for the UnboundLocalError masking real I/O failures in _dump_json_to_file.
What works well
- Initializing
f = Nonebefore thetryis the minimal correct fix for thefinallypath. - Regression tests cover constructor failure, mocked
PermissionError, happy-path write, and no-overwrite behavior.
LGTM from a correctness standpoint.
PR Type
Summary
When writing local metadata fails, the user gets
UnboundLocalError: cannot access local variable 'f'from inside Metaflowinstead of the
PermissionError/FileNotFoundErrorthat names the path theycannot write to.
Issue
No existing issue -- happy to open one first if you'd rather have it linked.
Reproduction
Runtime: local (
--metadata local, the default)LocalMetadataProvider._dump_json_to_fileis on the hot path of every localrun:
_save_metacalls it forregister_run_id,register_task_id,register_metadataandregister_data_artifacts. Any failure to create thetemp file it writes through -- unwritable
.metaflowdirectory, read-onlymount, directory removed underneath us -- takes this path.
Commands to run:
pip install -e . python repro.pyrepro.py:Where evidence shows up: parent console / the traceback the user sees.
Before (on master)
After
Root Cause
metaflow/plugins/metadata_providers/local.py:607:The name
fis bound by thewithstatement inside thetry. If theNamedTemporaryFile(...)call is itself what raises,fis never assigned, sothe
finallyclause -- which always runs -- evaluatesif fagainst an unboundlocal and raises
UnboundLocalError. That exception is raised while theoriginal
OSErroris propagating, so it replaces it as the visible error andthe real cause is only reachable through
__context__.The invariant the
finallyblock assumes ("by the time cleanup runs,fnames a temp file") does not hold on the one path where cleanup has nothing to
do.
Why This Fix Is Correct
One line,
f = None, ahead of thetry. The cleanup conditionif f and ...was already written to tolerate a falsy
f; it just never had a value to readwhen the constructor failed. With
fpre-bound:finallyseesNone, skips cleanup, the originalOSErrorpropagates untouchedjson.dumporos.renameraises ->fis bound, the temp file is removedexactly as before
This is also what the sibling helper in this codebase already does:
LocalStorage._atomic_write(metaflow/plugins/datastores/local_storage.py:113)creates its temp file before the
try, so a failingmkstemppropagatescleanly. This change brings
_dump_json_to_filein line with it withoutrestructuring it.
Failure Modes Considered
fis stillNoneinfinallyis ifNamedTemporaryFileraised, and in that case notemp file was created --
delete=Falseonly matters once constructionsucceeds. Nothing leaks.
test_dump_json_to_file_writes_and_leaves_no_temp_filepins that the success path still leaves the directory with just the target.
visible where previously an
UnboundLocalErrorshadowed it. Callers of_save_metado not catchUnboundLocalErroranywhere (I grepped), so nohandler is relying on the current behaviour; anything catching
OSErrorstarts working as intended.
write-then-rename sequence or the
allow_overwriteshort-circuit, both ofwhich are pinned by the new tests.
Tests
Added to
test/unit/test_local_metadata_provider.py:test_dump_json_to_file_reports_the_real_error_when_temp_file_fails-- realmissing directory, asserts an
OSErrornaming the pathtest_dump_json_to_file_reports_permission_error-- the case a user actuallyhits, via
mocker.patchon the constructortest_dump_json_to_file_writes_and_leaves_no_temp_file(pin)test_dump_json_to_file_does_not_overwrite_by_default(pin)Whole of
test/unit, same invocation, before and after:The delta is exactly the four new tests.
What I could not run: I develop on Windows, which Metaflow does not support
(
setup.pyclassifiers list macOS and Linux only) -- several core modulesimport fcntlat module scope, so every test that spawns a real run fails hereregardless of this patch. That is the 21/103 above; the counts are identical
before and after. Concretely for this file, I could not exercise the
allow_overwrite=Truebranch locally, becauseos.renameonto an existing pathraises
FileExistsErroron Windows -- unrelated to this change, and the reasonthat pin only covers the default
allow_overwrite=Falsebehaviour. I am relyingon CI for the Linux/macOS matrix.
black25.12.0 with the target list from.pre-commit-config.yamlreports bothfiles unchanged.
Non-Goals
I did not restructure the helper to match
_atomic_writemore closely, did nottouch
os.renamevsos.replace, and did not change what_save_metadoeswith the error.
AI Tool Usage
Claude Code, used to search for callers and to draft this description. The
reproduction and the before/after runs above are commands I actually ran; I
understand why
fis unbound only on the constructor-failure path and whypre-binding it is sufficient.