amoffat / sh

Python process launching

Home Page:https://sh.readthedocs.io/en/latest/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Feature proposal: _except callback

rgasper opened this issue · comments

I think a new callback that's very similar to the _done callback would be useful, but fires only if a certain exception is raised. The use case I encountered and wanted this feature was when trying to run ssh commands on a remote EC2 VM where I'm logging in use ephemeral SSH keys. It's not hard to wrap the sh.ssh command inside another function that handles calling boto3 to generate the ephemeral SSH keys, but I think it'd fit the sh design to add an _except callback where I could bake in running the login only when necessary (the ssh command failed due to auth error)

Interesting idea, thanks for sharing. I will leave this issue open to collect more thoughts and feedback

This sounds very close to a call for support of the full try/except/else/finally syntax. finally is already supported via _done, _except would need some decisionmaking around multiple exception types and unhandled exceptions, and _else would support a callback in case no exceptions were raised.

I'm not sure how much I like it, though. The amount of code would neither be significantly lower, at least in the examples of code I can envision, nor would it be significantly easier to understand. But feel free to post some code examples showing how it could improve the current state.

Good points Erik.

An additional point to mention about _done is that I believe it was originally added to simplify handling the termination of a backgrounded process (one launched with _bg=True), because we don't know when it would complete. In that specific use case, _except could have more merit to give backgrounded processes a Promise interface, but since @rgasper was not asking about it in that context, and we haven't had a request for that formal interface yet, it probably doesn't make sense to implement it at this time.

In the context that the request was made, I agree with @ecederstrand that the feature doesn't seem to really buy much.

So I think it does improve code quality in quite a nice way for my imagined use case, but please let me know if I'm coming at this in an unintended way and there's something better. I think this is useful where there is a very short timeout on a login, or a rate limit, and you want to run a bunch of commands in a row efficiently by avoiding redundant work - e.g. calling the login overly often, or waiting excessively to avoid hitting the rate limit. I am currently running into this issue over SSH w/ ephemeral access.

w/o an _except callback, psuedocode, only login once:

import sh
import boto3

def login():
    client = boto3.client("ec2-remote-connect")
    client.upload_ssh_key('~/.ssh/id_rsa.pub')

login()
ssh = sh.ssh.bake("some.host")
ssh.bash("-c", "somescript.sh")
ssh.tail("-1", "somescript_log.txt")
# etc etc many more ssh commands

The problem is this script fails unexpectedly when the ephemeral login token expires, or we hit a rate limit, or some other expected error that can happen at an unexpected time.

We can catch errors and run login when necessary using eval:

def remote_cmd(remote_args: Optional[List[str]], errors: Tuple[Exception, ...] = (Exception,)):
    remote = sh.ssh.bake("some.host")
    try:
        eval(f"remote.{remote_args[0]}")(*remote_args[1:])
    except errors:
        login()
        eval(f"remote.{remote_args[0]}")(*remote_args[1:])

# code 255 on auth failure
remote_cmd(["bash", "-c", "somescript.sh"], (sh.ErrorReturnCode_255,))
remote_cmd(["tail", "-1", "somescript_log.txt"], (sh.ErrorReturnCode_255,))
# etc etc many more ssh commands

But I think this syntax is quite a bit nicer and safer:

import sh
import boto3

def login():
    client = boto3.client("ec2-remote-connect")
    client.upload_ssh_key('~/.ssh/id_rsa.pub')

login()
remote_cmd = sh.ssh.bake("some.host", _except=(login, (sh.ErrorReturnCode_255,)))
# or perhaps an interface like this instead:
remote_cmd = sh.ssh.bake("some.host", _except=login, _except_catch=(sh.ErrorReturnCode_255,))

# then run commands normally the sh way
remote_cmd.bash("-c", "somescript.sh")
remote_cmd.tail("-1", "somescript_log.txt")
# etc etc many more ssh commands

so my issue is definitely solvable, but I like the sh library for being so spectacularly clean to use, and I had the thought that my code wasn't as clean as I'd like it to be when working around this error catching behavior.

Adding the _except callback will make working with sh much nicer for when you don't realize that you've put yourself in this kind of situation. You can write code as normal with baked functions, then once you encounter the issue add a _except=fix_the_problem to your bake. It's a lot less of a refactor than converting a bunch of lines from the normal baked usage to a new function call.

Thank you for explaining with some examples. I agree with you that if sh had some kind of convenience utility for flow control wrt error handling, it would make the situation you described require less boilerplate. That said, it steps outside of the relatively-narrow realm that sh is meant to manage, which is executing system processes with function calls.

In an abstract sense, the problem that you are describing is that a set of function calls can emit a recoverable error non-deterministically, and you would like to make all of the calls successfully. This is a general problem, and the general solution for this is in the realm of the language's flow control constructs. I understand that because python's flow control is clunky in this scenario, it is tempting to want to add convenience utilities to sh, but they fundamentally don't belong there, as convenient as they would be.

An alternative solution to eval is to define your list of calls and iterate them:

def login():
    client = boto3.client("ec2-remote-connect")
    client.upload_ssh_key('~/.ssh/id_rsa.pub')

def get_calls():
    ssh = sh.ssh.bake("some.host")
    yield ssh.bash.bake("-c", "somescript.sh"),
    yield ssh.tail.bake("-1", "somescript_log.txt"),
    # ... etc

login()
for call in get_calls():
    try:
        call()
    except sh.ErrorReturnCode_255:
        login()
        call()