-
Notifications
You must be signed in to change notification settings - Fork 413
cli: hide traceback on interrupt, use os.execvp on supported systems #9805
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
| try: | ||
| print(f'running {binary_path}...') | ||
| proc = subprocess.run( | ||
| [binary_path] + args, check=False, env=os.environ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
check=False is the default. Same with env.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| proc = subprocess.run( | ||
| [binary_path] + args, check=False, env=os.environ) | ||
| return proc.returncode | ||
| except subprocess.CalledProcessError as e: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This does not get raised when check=False.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch!
| if not binary_path: | ||
| raise RuntimeError("binary not found") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed this if condition from here and moved to find_or_download_binary, because the typehint says it cannot return None:
| def find_or_download_binary(binary_name: str) -> str: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you're right that the typing is wrong! find_or_download_binary may easily return null: it calls _find_binary and fails, then download_binaries does something wrong, and then _find_binary fails again. For instance, if I delete the downloaded lakefs right after you download the binary, then there is no binary to be found.
So please keep this check; consider correcting the types instead. Or correct find_or_download_binary to do check the second return. Or "cast" it there with an appropriate comment that f_o_d_b always returns an actual str.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or correct find_or_download_binary to do check the second return.
yes, that's what I have done. find_or_download_binary raises if they are falsy values.
lakeFS/clients/python-wrapper/lakefs/__main__.py
Lines 166 to 178 in 69c9b01
| def find_or_download_binary(binary_name: str) -> str: | |
| ''' | |
| Find the binary in PATH or ~/.lakefs/bin, | |
| or download it if not found | |
| Returns the path to the binary | |
| ''' | |
| binary_path = _find_binary(binary_name) | |
| if not binary_path: | |
| _download_binaries() | |
| binary_path = _find_binary(binary_name) | |
| if not binary_path: | |
| raise RuntimeError("binary not found") | |
| return binary_path |
b764484 to
69c9b01
Compare
| return proc.returncode | ||
| except subprocess.CalledProcessError as e: | ||
| raise RuntimeError(f"Error executing {binary_name}: {e}") from e | ||
| proc = subprocess.run([binary_path, *args], check=False) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could also use os.execvp on posix systems.
I see that ruff does that, which also has a python wrapper executing rust binary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would be much much better. It will avoid the whole 0.25s debacle - I was shocked by your link to the code showing that this was hard-coded.1
Footnotes
-
The code you showed appears to violate these Zen of Python principles:
- Beautiful is better than ugly.
- Explicit is better than implicit.
- Special cases aren't special enough to break the rules.
- In the face of ambiguity, refuse the temptation to guess.
- If the implementation is hard to explain, it's a bad idea.
Breaking 5 out of 19 aphorisms is... impressive. ↩
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If lakefs terminates immediately, it does not wait all 0.25s. A bigger problem is what follows after the _wait(), a SIGKILL, preventing a graceful termination.
arielshaqed
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Really solid work. But the 0.25s thing is a deal-breaker. Much safer and simpler just to call sys.exec*. Please do that; sorry.
| if not binary_path: | ||
| raise RuntimeError("binary not found") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you're right that the typing is wrong! find_or_download_binary may easily return null: it calls _find_binary and fails, then download_binaries does something wrong, and then _find_binary fails again. For instance, if I delete the downloaded lakefs right after you download the binary, then there is no binary to be found.
So please keep this check; consider correcting the types instead. Or correct find_or_download_binary to do check the second return. Or "cast" it there with an appropriate comment that f_o_d_b always returns an actual str.
| proc = subprocess.run( | ||
| [binary_path] + args, check=False, env=os.environ) | ||
| return proc.returncode | ||
| except subprocess.CalledProcessError as e: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch!
| return proc.returncode | ||
| except subprocess.CalledProcessError as e: | ||
| raise RuntimeError(f"Error executing {binary_name}: {e}") from e | ||
| proc = subprocess.run([binary_path, *args], check=False) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would be much much better. It will avoid the whole 0.25s debacle - I was shocked by your link to the code showing that this was hard-coded.1
Footnotes
-
The code you showed appears to violate these Zen of Python principles:
- Beautiful is better than ugly.
- Explicit is better than implicit.
- Special cases aren't special enough to break the rules.
- In the face of ambiguity, refuse the temptation to guess.
- If the implementation is hard to explain, it's a bad idea.
Breaking 5 out of 19 aphorisms is... impressive. ↩
arielshaqed
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Better and improves typing.
| if sys.platform == 'win32': | ||
| try: | ||
| proc = subprocess.run([binary_path, *args], check=False) | ||
| except KeyboardInterrupt: | ||
| sys.exit(1) | ||
| sys.exit(proc.returncode) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Poor Windows users. But since it's unclear how to improve matters, and since the existing experience is certainly more than good enough, I see no reason to add work here.
|
|
||
|
|
||
| def cli_run() -> int: | ||
| def run(binary_name: Literal['lakefs', 'lakectl'], args: Optional[list[str]] = None) -> NoReturn: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: This function will work perfectly well with any binary downloadable from our repo. I see no reason to limit its typing beyond str.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not really, as we only extract lakefs and lakectl.
lakeFS/clients/python-wrapper/lakefs/__main__.py
Lines 102 to 103 in 7aaa374
| tar.extract('lakefs', target_dir, filter='data') | |
| tar.extract('lakectl', target_dir, filter='data') |
But I get your point. Fixed in 01be1b3.
01f2d28 to
01be1b3
Compare
Closes #9460.
EDIT: This patch has been updated to use
os.execvpon non-Windows systems. The discussion below is only applicable on Windows due to lack of POSIXexec*()functions.This fix treats
python -m lakefs ...as a convenient entry point, primarily intended for interactive use and limited workflows. So, this patch has two issues which I think is acceptable for that scenario:ttydriver delivers SIGINT to all foreground processes. As a result, pressing Ctrl + C already sends SIGINT to both the Python interpreter and the lakefs binary. However,subprocess.run()waits for a short period, 0.25 seconds by default, before forcefully terminating the child process withSIGKILL. So, this may not allow thelakefsbinary to exit gracefully.SIGKILLwhen the Python interpreter receives aSIGINT(after waiting for 0.25 second). The recommended way is to interrupt the binary rather than thelakefsmodule itself. So I think this is only a minor inconvenience.Alternatively, we could
SIG_IGNthe interrupt, but wanted to propose something simpler.Let me know if my understanding is incorrect.