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

Implement PluginManager (3 of 3) #226

Closed
wants to merge 17 commits into from
Closed

Conversation

unode
Copy link
Collaborator

@unode unode commented May 30, 2021

Reopened #212 targeting main instead of master.

@unode unode marked this pull request as draft May 30, 2021 17:38
@unode
Copy link
Collaborator Author

unode commented May 31, 2021

Some significant changes from #212 - Tests are not yet in sync. More on that in the next push:

  • Plugin.help and Plugin.get_help_string is now HelpPlugin.help and HelpPlugin.get_help_string.
  • The logic to collect help information about plugins is now PluginManager.get_help.
  • HelpPlugin is enabled by default but needs to be explicitly enabled if you specify your own plugins.
  • PluginManager is now available to Plugin instances as self.manager initialized via Plugin.initialize
  • help_trigger and help_trigger_bang are no more. There's only settings.RESPOND_CHANNEL_HELP that defaults to False and defines if the bot should respond to !help in any channel.
  • Arbitrary information can be passed via listen_to and listen_webhook as kwargs and made available via PluginHelpInfo.annotations. (e.g. @listen_to(..., category="webhook", syntax="Command Arg1 Arg2")
    • Two attributes are currently recognized by HelpPlugin.get_help_string:
      • category - grouping functions with the same category label (see screenshot below)
      • syntax - providing an alternative to displaying the regex pattern. Instead of ^reply at (.*)$ you can get reply at TIMESTAMP or any arbitrary text.
  • All help information is now set via function/class docstrings instead of get_help_string. The first line of the docstring will be displayed in the help output. The entire docstring is available as PluginHelpInfo.function_docfull. Plugin level docstrings are also available, see plugin_docfull and plugin_docheader
  • get_help_string is no longer part of the base ABC Plugin class nor Function subclasses.
  • Docstrings were added to the ExamplePlugin where missing.

The current help output with plugins HelpPlugin, ExamplePlugin and WebHookExample looks like:
screenshot_2021-05-31_03-27-06_647421429

.pre-commit-config.yaml Outdated Show resolved Hide resolved
@unode
Copy link
Collaborator Author

unode commented Jun 13, 2021

I think this is ready for a final of review.

@unode unode marked this pull request as ready for review June 13, 2021 17:55
@unode
Copy link
Collaborator Author

unode commented Jun 13, 2021

This PR will also close #165

@jneeven jneeven self-requested a review June 14, 2021 07:37
@attzonko
Copy link
Owner

Again @unode thanks for putting in all this work. I have a quick request, can you elaborate on what you mean by the bullet below and what exactly does it mean for users who use their own plugins:

HelpPlugin is enabled by default but needs to be explicitly enabled if you specify your own plugins.

@unode
Copy link
Collaborator Author

unode commented Jun 15, 2021

Again @unode thanks for putting in all this work. I have a quick request, can you elaborate on what you mean by the bullet below and what exactly does it mean for users who use their own plugins:

HelpPlugin is enabled by default but needs to be explicitly enabled if you specify your own plugins.

I mean this

[HelpPlugin(), ExamplePlugin(), WebHookExample()]

If you run the bot without adding any custom plugins, it will be included among the default plugins.
If add custom plugins (the most likely case) you should include it, or a subclass of it, in the list of plugins to enable.

Initially I thought of having it always enabled (i.e. plugins + [HelpPlugin()]) but that would remove the ability to define or customize your own help plugin.

Copy link
Collaborator

@jneeven jneeven left a comment

Choose a reason for hiding this comment

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

Nice, thanks for all the work 🙌🏼! I think having a separate PluginManager is great, I just have a bunch of questions and suggestions here and there. It's a huge PR, so there are loads of comments 🙃 In the future it'd be nicer to split a big one like this into maybe ~4 PRs, that makes it much easier to see what's going on. I haven't look at all the details yet, so will probably have to do another round of reviewing later.

mmpy_bot/bot.py Outdated Show resolved Hide resolved
mmpy_bot/bot.py Outdated Show resolved Hide resolved
mmpy_bot/bot.py Outdated Show resolved Hide resolved
mmpy_bot/bot.py Outdated Show resolved Hide resolved
mmpy_bot/bot.py Outdated Show resolved Hide resolved
mmpy_bot/function.py Outdated Show resolved Hide resolved
mmpy_bot/function.py Outdated Show resolved Hide resolved
)

# Preserve docstring
new_func.__doc__ = func.__doc__
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why is this necessary? We can definitely explicitly store the __doc__ of the wrapped function somewhere, but I'd prefer not to modify the __doc__ of the wrapping Function since __doc__ is normally class-specific, not instance-specific.

Copy link
Collaborator Author

@unode unode Jun 20, 2021

Choose a reason for hiding this comment

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

This one was tricky. We now have PluginHelpInfo.function_doc[header|full] as well as PluginHelpInfo.plugin_doc[header|full] which contain the method and class level Plugin docstring, respectively.
With the way decorated functions are transformed into MessageFunction or WebHookFunction and the fact that methods can be decorated multiple times (multiple listen patterns, click decorators, ...), this was the simplest way to propagate the docstring without having to do some kind of conditional code using isinstance().

The test tests/unit_tests/plugin_manager_test.py::TestPluginManager::test_get_help verifies if the docstring is propagated correctly. Commenting these lines breaks it.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I still don't understand this. The base Function class has self.docstring = self.function.__doc__ or "", where self.function is always the absolute innermost function (i.e. if you pass it a Function, it will keep unwrapping it until it reaches the callable that this Function wraps). Why then would we need to change self.__doc__ as well, and can't we just use self.docstring?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

There might be some confusion on my part. When I tried to capture this in a test, the test fails unless I included the line above.

Can you perhaps have a look at the test tests/unit_tests/plugin_manager_test.py::TestPluginManager::test_get_help and confirm that the test and its assumptions are correct?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I can't judge what's going on just from looking at the code, so will have to try running it later

mmpy_bot/plugins/base.py Outdated Show resolved Hide resolved
tests/unit_tests/plugin_manager_test.py Outdated Show resolved Hide resolved
unode added a commit to unode/mmpy_bot that referenced this pull request Jun 20, 2021
See attzonko#226 (comment)

(cherry picked from commit c863a9e1d03dfbe1c3d72dcd0ae6f22403e0fde6)
Copy link
Collaborator Author

@unode unode left a comment

Choose a reason for hiding this comment

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

Nice, thanks for all the work 🙌🏼! I think having a separate PluginManager is great, I just have a bunch of questions and suggestions here and there. It's a huge PR, so there are loads of comments upside_down_face In the future it'd be nicer to split a big one like this into maybe ~4 PRs, that makes it much easier to see what's going on. I haven't look at all the details yet, so will probably have to do another round of reviewing later.

Thanks a lot Jelmer. Your detailed review is super appreciated.
Indeed this grew more than expected so, take your time. I also took mine 😅 .

I tried to get different PRs at some point but wasn't clear how to go about dependencies between PRs. I can pull some things out if you think it would make your life easier.

def __iter__(self):
return iter(self.plugins)

def initialize_manager(self, driver: Driver, settings: Settings):
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This one I did intentionally to avoid mixing up with Plugin.initialize(). This was quite confusing when PluginManager was also behaving as a Plugin. It's no longer but I still kept it to highlight the distinction. Particularly relevant since they now receive different sets of arguments (Plugin.initialize() also receives a reference to PluginManager).

mmpy_bot/plugins/base.py Show resolved Hide resolved
mmpy_bot/plugins/base.py Outdated Show resolved Hide resolved
mmpy_bot/plugins/base.py Outdated Show resolved Hide resolved
mmpy_bot/plugins/example.py Outdated Show resolved Hide resolved
mmpy_bot/plugins/example.py Outdated Show resolved Hide resolved
Comment on lines +61 to +71
msg_listeners = {
re.compile("pattern"): expand_func_names(FakePlugin.my_function),
re.compile("direct_pattern"): expand_func_names(FakePlugin.direct_function),
re.compile("another_async_pattern"): expand_func_names(
FakePlugin.my_async_function
),
re.compile("async_pattern"): expand_func_names(FakePlugin.my_async_function),
re.compile("hi_custom"): expand_func_names(FakePlugin.hi_custom),
# Click commands construct a regex pattern from the listen_to pattern
re.compile("^click_command (.*)?"): expand_func_names(FakePlugin.click_commmand),
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is how it was before. That part of the code wasn't touched. See

reg = f"^{reg.strip('^')} (.*)?" # noqa

mmpy_bot/plugins/base.py Outdated Show resolved Hide resolved
@unode
Copy link
Collaborator Author

unode commented Jun 20, 2021

Pulled #233 out of this one. That's a pretty self contained feature.

unode added a commit to unode/mmpy_bot that referenced this pull request Jun 24, 2021
See attzonko#226 (comment)

(cherry picked from commit c863a9e1d03dfbe1c3d72dcd0ae6f22403e0fde6)
@unode
Copy link
Collaborator Author

unode commented Jun 25, 2021

After trying to customize a little the help info I noticed the current approach actually forces you to modify PluginManager.get_help if you want to filter out some functions and HelpPlugin.get_help_string if you want to customize the format of the help.

To simplify the use-case of filtering listeners, I now introduced a HelpPlugin.get_help method. It currently only calls to PluginManager.get_help but provides a location for users to override what is included without having to redefine HelpPlugin.get_help_string entirely.
See also https://github.com/attzonko/mmpy_bot/pull/226/files#diff-6cc66ec79b3b320572cf875b2e985afab611933a46d625218fc030c3a000131dR188 as an example of such use-case.

Copy link
Collaborator

@jneeven jneeven left a comment

Choose a reason for hiding this comment

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

Round two 😅 These reviews are taking me a long time, so I would be grateful if you could either make PRs into this branch and/or not force-push to it, so I can look only at the latest changes

self.docstring = self.function.get_help(ctx).replace(
"\n", f"\n{spaces(8)}"
)
self.docstring += f"\n\n{self.function.get_help(ctx)}"
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 this is what causes those duplicate docstrings you showed earlier in Discord. click already includes the docstring in get_help, so this shouldn't be +=.

Suggested change
self.docstring += f"\n\n{self.function.get_help(ctx)}"
self.docstring = f"\n\n{self.function.get_help(ctx)}"

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I tried = before but then we lose all of the help from click.
I'm not happy with either of the solutions but I think we'll need to address click UI/UX in another PR.
I agree that click is convenient for argument parsing but the help information is (IMO) too verbose for a chat-bot.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I tried = before but then we lose all of the help from click.

What do you mean? The "help from click" is what is returned by self.function.get_help(ctx) right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes to self.function.get_help(ctx). If memory serves the issue was functions decorated with both @listen_to and @click.... The inner click decorated is processed and function.get_help(ctx) is called but as the outer decorator (listen_to) is processed later, this was overwriting what was done before, hence the +=.

mmpy_bot/function.py Outdated Show resolved Hide resolved
)

# Preserve docstring
new_func.__doc__ = func.__doc__
Copy link
Collaborator

Choose a reason for hiding this comment

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

I still don't understand this. The base Function class has self.docstring = self.function.__doc__ or "", where self.function is always the absolute innermost function (i.e. if you pass it a Function, it will keep unwrapping it until it reaches the callable that this Function wraps). Why then would we need to change self.__doc__ as well, and can't we just use self.docstring?

mmpy_bot/plugins/base.py Outdated Show resolved Hide resolved
mmpy_bot/plugins/base.py Outdated Show resolved Hide resolved
mmpy_bot/plugins/base.py Show resolved Hide resolved
mmpy_bot/plugins/base.py Outdated Show resolved Hide resolved
mmpy_bot/plugins/base.py Outdated Show resolved Hide resolved
mmpy_bot/plugins/base.py Outdated Show resolved Hide resolved
Comment on lines +61 to +71
msg_listeners = {
re.compile("pattern"): expand_func_names(FakePlugin.my_function),
re.compile("direct_pattern"): expand_func_names(FakePlugin.direct_function),
re.compile("another_async_pattern"): expand_func_names(
FakePlugin.my_async_function
),
re.compile("async_pattern"): expand_func_names(FakePlugin.my_async_function),
re.compile("hi_custom"): expand_func_names(FakePlugin.hi_custom),
# Click commands construct a regex pattern from the listen_to pattern
re.compile("^click_command (.*)?"): expand_func_names(FakePlugin.click_commmand),
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Did you accidentally link the wrong file? I don't see the relevance of that line here. I think the relevant test was this one, and it did not require duplicating the listeners like this

@unode
Copy link
Collaborator Author

unode commented Jul 5, 2021

Round two sweat_smile These reviews are taking me a long time, so I would be grateful if you could either make PRs into this branch and/or not force-push to it, so I can look only at the latest changes

Sorry, I will stop pulling things in/out until the review is complete.

Comment on lines +137 to +138
# Prior to initialization there is no help
assert self.plugin_manager.get_help() == []
Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems to rely on self.plugin_manager not having been initialized, but it's initialized first thing in test_init?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Doesn't pytest run the two tests independently? i.e.

t = TestPluginManager()
t.setup_method()
t.test_get_help()

t = TestPluginManager()
t.setup_method()
t.test_get_init()

Copy link
Collaborator

@jneeven jneeven left a comment

Choose a reason for hiding this comment

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

Your recent changes look good to me and I've resolved some comments. Some comments I left earlier are still relevant though, could you have another look at those?

I think we're nearing convergence here, so I propose the following:

  • Let's try to first resolve all comments open in this PR. I won't add any new ones, but we can continue discussing the ones that already have some discussion going on.
  • Once everything is resolved, I'll need to have a fresh look at all the changes here, and since this is an enormous PR and there have been months in between each time I looked at it, it would be best to split this into several easily reviewable PRs so it becomes very clear what changes are being made in what context, and maybe @attzonko can then have a look at it as well if he wants. I think there are easily several PRs worth of changes here, such as the introduction of metadata, the introduction of the FunctionInfo, etc. If the interdependence between all these things is an issue, feel free to open one initial PR of very limited scope, and then make the others PR merge into that one rather than directly into main. We can then wait with merging that central PR until all the individual changes have been reviewed and merged into it without needing to look at 17 files at a time 😅

@unode
Copy link
Collaborator Author

unode commented Aug 16, 2021

Thanks @jneeven!

I'll give it another try at pulling out different PRs. Will likely go with your suggestion of making the PRs against the previous.

@aconitumnapellus
Copy link
Contributor

Im eagerly awaiting this PR! Currently have a .help command to show what commands are available and what they do

@attzonko attzonko marked this pull request as draft June 18, 2022 18:39
@unode unode changed the title Implement PluginManager Implement PluginManager (3 of 3) Jul 15, 2022
@unode
Copy link
Collaborator Author

unode commented Jul 15, 2022

See #323 and #324 for an attempt to split this PR.
The current history will be rebased on the other two PRs once merged.

Allows passing custom function annotations that can be used
by the help plugin to format or group functions

(cherry picked from commit 66bb750)
(cherry picked from commit 8500e031ab34de5ead988e68fb41d4de6a4968a0)
Defines an interface for listeners to allow easier collection and formatting of help information
@codeclimate
Copy link

codeclimate bot commented Jul 15, 2022

Code Climate has analyzed commit ef7180a and detected 3 issues on this pull request.

Here's the issue category breakdown:

Category Count
Complexity 3

View more on Code Climate.

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.

4 participants