Fix #3010: Warn/prompt before persisting empty environment config#3014
Fix #3010: Warn/prompt before persisting empty environment config#3014Mustafa11300 wants to merge 5 commits intoNetflix:masterfrom
Conversation
- Add guard in persist_env() to detect empty env_dict - Display a yellow warning when config is empty - Show interactive confirmation prompt on TTY - Auto-abort in non-interactive contexts (CI, piped input) - Return True/False to indicate whether write succeeded - Resolve existing TODO in persist_env - Add 14 comprehensive unit tests - Add 3 example scenarios in examples/empty_config_guard/
Greptile SummaryThis PR adds a safety guard to Key issues found:
Confidence Score: 2/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["persist_env(env_dict, profile)"] --> B{env_dict is empty\nor None?}
B -- No --> G["Write JSON to config file"]
G --> H["echo success message"]
H --> I["return True"]
B -- Yes --> C["echo yellow warning"]
C --> D{"sys.stdin.isatty()?"}
D -- No\nnon-interactive --> E["echo red abort message"]
E --> F["return False"]
D -- Yes\ninteractive --> J{"click.confirm\ndefault=False"}
J -- User confirms --> G
J -- User declines --> K["echo 'Operation aborted'"]
K --> F
style F fill:#f55,color:#fff
style I fill:#5a5,color:#fff
style C fill:#fa0,color:#000
style E fill:#f55,color:#fff
|
| # TODO: Should we persist empty env_dict or notify user differently? | ||
| path = get_config_path(profile) | ||
|
|
||
| if not env_dict: |
There was a problem hiding this comment.
None passes guard and writes null to config file
if not env_dict: is truthy for both {} and None. Test 5 passes None and only catches the non-interactive branch (auto-abort). In an interactive TTY where the user confirms, the code falls through to json.dump(None, ...), which serialises as JSON null. When get_env() reads that file it returns None rather than {}, and any subsequent call to existing_env.get(...) in the configure helpers raises AttributeError.
Consider normalising None to {} before the guard:
env_dict = env_dict or {}
if not env_dict:
... # existing warning / abort / confirm logicCo-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
|
|
||
| # In non-interactive contexts (e.g. piped input, CI) abort by default | ||
| # to prevent accidental configuration loss. | ||
| if not sys.stdin.isatty(): |
There was a problem hiding this comment.
sys.stdin.isatty() may not reflect Click's own stdin stream
The rest of configure_cmd.py routes all user interaction through Click's click.confirm / click.prompt (which respect Click's internal stdin stream). Checking sys.stdin.isatty() directly bypasses Click's stream handling: if a caller overrides Click's input (e.g. via CliRunner(mix_stderr=False) in integration tests, or a plugin that wraps sys.stdin), the TTY check may return the wrong result.
A more consistent approach would be to use Click's own context or stream helpers:
if not click.get_text_stream("stdin").isatty():Or, if Click's vendored copy doesn't expose that helper, use click.utils._default_text_stdin().isatty() — or simply rely on click.confirm's built-in behaviour with a timeout/default rather than pre-checking TTY manually.
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
| def tearDown(self): | ||
| if os.path.exists(self.config_path): | ||
| os.remove(self.config_path) | ||
| os.rmdir(self.tmpdir) |
There was a problem hiding this comment.
Brittle tearDown with os.rmdir
os.rmdir only removes an empty directory. Test 6 (test_named_profile_path) creates an extra file (config_production.json) in self.tmpdir and cleans it up inline — but if that test raises an exception before reaching os.remove(profile_path), the file will remain, and os.rmdir(self.tmpdir) will raise OSError: [Errno 66] Directory not empty. This masks the original test failure with a confusing teardown error.
Consider using shutil.rmtree:
import shutil
def tearDown(self):
shutil.rmtree(self.tmpdir, ignore_errors=True)Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
| with open(path, "w") as f: | ||
| json.dump(env_dict, f, indent=4, sort_keys=True) | ||
|
|
||
| echo("\nConfiguration successfully written to ", nl=False, bold=True) | ||
| echo('"%s"' % path, fg="cyan") | ||
| return True |
There was a problem hiding this comment.
Callers don't handle the new False return value
persist_env now returns False when the write is aborted, but every call site in this file discards that return value. This means an aborted write is silently swallowed — the configure command exits with code 0 (success) even though no config was written.
This is a realistic scenario: a user runs metaflow configure aws, declines both the S3 and Metadata Service prompts, and the dict comprehension {k: v for k, v in env.items() if v} produces {}. persist_env aborts, returns False, and the command exits as if everything succeeded.
Affected call sites:
azurecommand — line 837:persist_env({k: v for k, v in env.items() if v}, profile)gcpcommand — line 878: same patternawscommand — line 936: same patternkubernetescommand — line 1000: same patternsandboxcommand — line 261:persist_env(env_dict, profile)import_from— line 218:persist_env(env_dict, profile)
Each of these should check the return value and either abort the command or inform the user that nothing was saved.
| mock_stdin.isatty.return_value = True | ||
| mock_click.confirm.return_value = True |
There was a problem hiding this comment.
isatty() not explicitly set — relies on implicit MagicMock truthiness
mock_stdin.isatty.return_value is never set in this test, so it defaults to a MagicMock() instance. That instance is truthy, which happens to make not sys.stdin.isatty() evaluate to False (i.e., "interactive"), allowing the test to reach the click.confirm path and pass.
This is fragile: the test passes by accident rather than by intent. A future change (e.g., switching to if sys.stdin.isatty() instead of if not sys.stdin.isatty()) or a different Mock configuration could silently break the behaviour being tested without a clear failure message.
| mock_stdin.isatty.return_value = True | |
| mock_click.confirm.return_value = True | |
| mock_stdin.isatty.return_value = True | |
| mock_click.confirm.return_value = True |
Summary
Closes #3010.
persist_env()in configure_cmd.py previously wroteenv_dicttoconfig.jsonwithout checking if it was empty. This silently wiped the user's existing configuration.Changes
Core fix – configure_cmd.py
Added a guard in
persist_env()that detects an emptyenv_dictand:Trueon successful write,Falseon abort) so callers can react if needed.The existing TODO comment (
# TODO: Should we persist empty env_dict or notify user differently?) has been resolved.Tests – test_configure_cmd.py
14 unit tests covering:
Nonetreated as falsy/empty → triggers the guardclick.confirmget_config_pathhelper for default and named profilesExamples – empty_config_guard
Three runnable scenarios with a README:
01_interactive_empty_config_confirm.py02_non_interactive_empty_config_abort.py03_nonempty_config_no_prompt.pyHow to test