Skip to content
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

Add process execution simulation to test harness #993

Merged
merged 26 commits into from
Aug 24, 2023

Conversation

weiiwang01
Copy link
Contributor

@weiiwang01 weiiwang01 commented Aug 22, 2023

The Pebble process execution plays an important role in a lot of charms. However, the ops framework testing module currently lacks built-in functionality to simulate this process, leading many charms to roll their own patching and mocking mechanisms for unit tests.

To address this problem, this pull request introduces ops.testing.Harness.handle_exec, a new feature that enables the simulation of Pebble process execution within the Ops framework testing module.

See spec OP039 for discussion.

@benhoyt
Copy link
Collaborator

benhoyt commented Aug 22, 2023

@weiiwang01 Is this ready for review? If so, I can take a look tomorrow.

Thanks for the pebble.py None-safety fix. Are you able to separate that out into a separate PR, with a regression test of that code path if possible?

One other thing: it looks like the docs are failing right now. You can generate those locally with tox -e docs and then open docs/_build/html/index.html to review the docs locally.

@weiiwang01
Copy link
Contributor Author

@weiiwang01 Is this ready for review? If so, I can take a look tomorrow.

Thanks for the pebble.py None-safety fix. Are you able to separate that out into a separate PR, with a regression test of that code path if possible?

One other thing: it looks like the docs are failing right now. You can generate those locally with tox -e docs and then open docs/_build/html/index.html to review the docs locally.

Yes, this is ready for review. I have fixed the document issue and reverted changes in pebble.py. I will create a new PR for the None safety issue later.

Copy link
Collaborator

@benhoyt benhoyt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great work again, thanks @weiiwang01. I've left a bunch of comments, but most of them are minor -- structurally it seems fine.

ops/pebble.py Outdated
t = _start_thread(shutil.copyfileobj, self.stdout, out)
self._threads.append(t)
# self.stdout can be None, when exec is invoked with the stdout argument
if self.stdout is not None:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the correct fix here is to raise an exception in this case (at the top of the method), because ExecProcess.wait_output shouldn't be called if exec is invoked with the stdout argument. wait_output is intended to be used when you want to collect output as a string. If using exec's stdout argument, you'd use ExecProcess.wait. I'd probably raise TypeError with a nice message.

We can tweak the docstring to be more explicit about that too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've rolled back the changes. Let's address this issue specifically in the next pull request. I hope there's a way so we can use some type gymnastics to avoid using exceptions while keep the typing.

ops/testing.py Outdated Show resolved Hide resolved
ops/testing.py Outdated Show resolved Hide resolved
ops/testing.py Show resolved Hide resolved
ops/testing.py Outdated Show resolved Hide resolved
ops/testing.py Show resolved Hide resolved
ops/testing.py Outdated Show resolved Hide resolved
ops/testing.py Outdated
if self._exit_code != 0:
raise pebble.ExecError(self._command, cast(int, self._exit_code), None, None)

def wait_output(self) -> Tuple[Optional[AnyStr], Optional[AnyStr]]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should change this back to the same signature (non-optional stdout) and check that they didn't pass stdout in to exec -- similar to my first comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a private class within the testing module. I believe this function signature has no effect. I'll update it in the next pull request to align with pebble.py.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it has no effect, but let's change it to the same signature as the real wait_output in this PR to avoid confusion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated, thanks!

test/test_testing.py Outdated Show resolved Hide resolved
test/test_testing.py Outdated Show resolved Hide resolved
Copy link
Collaborator

@benhoyt benhoyt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates! This is very close now. Let's just make the simplification to use a dict for the registered handlers, and I've added a couple of other minor suggestions.

I also tweaked the handle_exec docstring and fixed it in a couple of places, and reverted the line-break change to ops/pebble.py to avoid that in the diff.

ops/testing.py Outdated
Comment on lines 1664 to 1667
if isinstance(result, int):
result = ExecResult(exit_code=result)
if isinstance(result, (str, bytes)):
result = ExecResult(stdout=result)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about this, to help people not using static type checking? The "not bool" test is because bools are ints, but we don't want someone confusing things by returning True (1) or False (0).

Suggested change
if isinstance(result, int):
result = ExecResult(exit_code=result)
if isinstance(result, (str, bytes)):
result = ExecResult(stdout=result)
if isinstance(result, int) and not isinstance(result, bool):
result = ExecResult(exit_code=result)
elif isinstance(result, (str, bytes)):
result = ExecResult(stdout=result)
elif not isinstance(result, ExecResult):
raise TypeError(f"result must be int, str, bytes, or ExecResult, not {result.__class__.__name__}")

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never know the bool is int, but I guess it makes sense, updated, thanks!

ops/testing.py Outdated
def wait(self):
if self._is_timeout:
raise pebble.TimeoutError(
f'timed out waiting for change 123 ({self._timeout} seconds)'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I was suggesting here is that we simply remove the change ID, the " 123" part, from the error message entirely. I think it will be confusing to have a hard-coded value here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha, removed, thanks!

ops/testing.py Outdated
if self._exit_code != 0:
raise pebble.ExecError(self._command, cast(int, self._exit_code), None, None)

def wait_output(self) -> Tuple[Optional[AnyStr], Optional[AnyStr]]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it has no effect, but let's change it to the same signature as the real wait_output in this PR to avoid confusion.

ops/testing.py Outdated
self._exec_handlers.insert(idx, (prefix, handler))
inserted = True
if not inserted:
self._exec_handlers.append((prefix, handler))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think we should -- the current code is fairly tricky and complex, for no practical benefit in this use case. We're only ever going to have a small handful of registered handlers, so whizzing through them linearly each time is fine. To get simple code, let's use a dict and a _find_exec_handler along the lines of what I suggested.

test/test_testing.py Outdated Show resolved Hide resolved
Copy link
Collaborator

@benhoyt benhoyt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent, thanks. Love the new simple lookup technique! Just a couple of nits and then we're good to go.

ops/testing.py Outdated
raise TypeError("Either handler or result must be provided, but not both.")
container_name = container if isinstance(container, str) else container.name
if result is None:
pass
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, thanks. Though this confused me a bit at first. How about we put this in an indented block and perform the checks and set the handlers to lambda _: result only if result is not None (then we can skip the slightly awkward inline if-else below):

if result is not None:
    if isinstance(result, int) and not isinstance(result, bool):
        result = ExecResult(exit_code=result)
    elif isinstance(result, (str, bytes)):
        result = ExecResult(stdout=result)
    elif not isinstance(result, ExecResult):
        raise TypeError(
            f"result must be int, str, bytes, or ExecResult, not {result.__class__.__name__}")
    handler = lambda _: result

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood, updated. However, I can't change handler = lambda _: result because of the linting checks.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh, I hate it when our tools get in the way of clean simple code. I guess we could do this, but I'm not sure it's any better. I leave it up to you. I'll probably merge this tomorrow!

            # autopep8: off
            handler = lambda _: result  # noqa
            # autopep8: on

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three annotations for a single statement might be a little bit too much. I'll probably keep the original statement, thanks.

ops/testing.py Show resolved Hide resolved
ops/testing.py Show resolved Hide resolved
@benhoyt
Copy link
Collaborator

benhoyt commented Aug 24, 2023

prometheus-k8s-operator unit tests are failing, but that's not related to this PR -- I've made them aware.

Merging! Thanks for your work on this, @weiiwang01.

@benhoyt benhoyt merged commit b1c14d7 into canonical:main Aug 24, 2023
17 of 18 checks passed
@weiiwang01 weiiwang01 deleted the feat-handle-exec branch August 27, 2023 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants