-
Notifications
You must be signed in to change notification settings - Fork 334
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
Update Plugin API [v2] #1179
Update Plugin API [v2] #1179
Conversation
@@ -90,7 +90,8 @@ | |||
}, | |||
"hydrogen.provider": { | |||
"versions": { | |||
"1.1.0": "provideHydrogen" | |||
"1.1.0": "provideHydrogen", | |||
"1.2.0": "provideHydrogen" |
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.
Note that keeping around multiple versions is intentional. They can still point to the same place because the new version is a strict superset of the old one.
lib/kernel.js
Outdated
execute: (code: string, callWatches: boolean, onResults: Function) => void; | ||
complete: (code: string, onResults: Function) => void; | ||
inspect: (code: string, cursorPos: number, onResults: Function) => void; | ||
} |
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 you think that callbacks are too old-fashioned, it may be possible to convert these to async syntax. Note however that onResults
gets called multiple times, so this will rely on async iterator features that are quite recent.
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 like the idea of moving to async and promises as much as possible. Though I don't know if it'd be worth the effort in that case.
I'd rather prefer us moving to @nteract/messaging
, enchannel-zmq-backend
and rx-jupyter
using RxJS which provides excellent primitives for handling streaming data.
Alternatively we could rely more on MobX for these kind of things.
lib/kernel.js
Outdated
@@ -74,31 +99,58 @@ export default class Kernel { | |||
} | |||
|
|||
interrupt() { | |||
throw new Error("Kernel: interrupt method not implemented"); | |||
this.rawInterrupt(); |
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 should call the middleware. I'll fix that in the next patchset.
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.
@nikitakit Thanks for continuing the effort in expanding our plugin API. I like the idea of using a middleware to interact with Hydrogen. Though I don't have any experience with designing rugged APIs for other people to use 😄
I'm happy to merge this soon to keep us moving forward. Since there are not many plugins using this API we can iterate on it and see how it feels when using it for real.
@rgbkrk @BenRussert I'd like to have your input on what you think about this approach.
lib/kernel.js
Outdated
if (restart === true) { | ||
this.middleware.shutdown(restart, onRestarted); | ||
} else { | ||
this.middleware.shutdown(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.
I'd prefer to keep shutdown and restart separate methods. The restart argument is coming from the jupyter messaging protocol and doesn't actually restart the kernel.
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 see that there's a restart argument in the messaging protocol, but does that argument even serve a useful purpose that hydrogen cares about?
If not, maybe it would still make sense to combine restart and shutdown in the middleware? By this I mean that restart: ?boolean
will differentiate between true restart/shutdown rather than controlling the messaging protocol argument.
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.
Actually what I said above is already the case in the code: setting restart=true here will initiate a true restart, not just toggle a flag in the shutdown message.
I'm not sure that exposing this restart flag to plugins would be useful in any way. (That said, the case can be made that separating shutdown/restart middleware will avoid needless confusion.)
lib/plugin-api/hydrogen-kernel.js
Outdated
// yet another alternative would be: | ||
// kernel.addMiddleware({ | ||
// execute: (next, code, callWatches, onResults) => { | ||
// next.execute(code, callWatches, onResults); |
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 like the functional approach 👍
lib/kernel.js
Outdated
execute: (code: string, callWatches: boolean, onResults: Function) => void; | ||
complete: (code: string, onResults: Function) => void; | ||
inspect: (code: string, cursorPos: number, onResults: Function) => void; | ||
} |
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 like the idea of moving to async and promises as much as possible. Though I don't know if it'd be worth the effort in that case.
I'd rather prefer us moving to @nteract/messaging
, enchannel-zmq-backend
and rx-jupyter
using RxJS which provides excellent primitives for handling streaming data.
Alternatively we could rely more on MobX for these kind of things.
Thanks for taking a look at this! I did a quick round of updates to switch away from classes. In terms of using async code instead of result callbacks, I've decided that there are too many details to do this right now. Later on if plugins are using this we can figure out something based on actual usage patterns. Also I think I now have an idea of how to handle result objects in the API (though this is not implemented yet). Currently hydrogen takes objects formatted according to the jupyter message protocol, converts them to custom result objects, and then calls |
I like this approach a lot! Some docs to explain some usage would help. I think the example from |
fbd88ce
to
acd88f8
Compare
I just pushed an update that ensures that middleware With this change, it should be possible for plugins to modify replies that kernels send (for example, to inject their own messages into the result bubbles). Even though the middleware now operates on Jupyter messages, the ZMQKernel/WSKernel classes offer an important level of abstraction by matching replies with their associated requests. Once these changes have been reviewed I can drop the WIP tag and polish this up for being merged. |
Any thoughts on the latest round of changes? (Just want to clarify that this PR is out of my hands and waiting for review) |
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.
Thank you for this work, looks really good overall!
The only reason I have request changes on is to clarify the test block in main.js
, this does not need to be merged by my understanding.
The other thing I think we need is some better documentation so users know where to start with creating custom middleware. I wonder your thoughts on this as I could see that coming in a separate PR if you wanted to wait on making the API changes public.
Note that any time you make an api docs change you will have to npm run build:docs
to generate the gitbook markdown. The changes actually need to be committed in order to be visible in the gitbook.
lib/main.js
Outdated
kernel._has_been_seen_before = true; | ||
} | ||
}); | ||
// XXX(nikita): end testing |
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 hunk does not need to be merged since it is just for dev testing right?
|
||
/* | ||
* Add a kernel middleware, which allows intercepting and issuing commands to | ||
* the kernel. |
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.
You could add an @param
docstring here to document that this takes a Middleware
object.
We will also need to document somewhere the Middleware
object api users are supposed to provide. Maybe this deserves it's own gitbook page? I wonder if you have thoughts on this?
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 added the param docstring.
In terms of actually documenting the API in detail, I'd like to leave that to a future PR. I'm working on some plugins for my own use, and examples would help a lot in terms of explaining what things do.
lib/plugin-api/hydrogen-kernel.js
Outdated
// remove/replace some of its methods. The current plugin API is designed to | ||
// ignore any such changes, which requires making a copy here. We use local | ||
// variables instead of copying the entire object to make flow happy. | ||
if (!!middleware.interrupt) { |
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.
Out of curiosity, is there an advantage to the double negative here?
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.
Actually, I have no idea about this. I'm not very familiar with the edge cases of javascript typecasting.
@nikitakit I've gone quickly through the PR and I have a major concern about the design and minor points about naming and logging. Please, correct me if I misunderstood. This PR introduces the concept of middleware and changes the classes HydrogenKernel, Kernel, ZMQKernel and WSKernel to implement this concept. For the sake of separation of concerns, this middleware (or plugin) should be class of its own. And no changes should be required in Kernel, ZMQKernel and WSKernel. Here's an approach to keep this separation of concerns: class HydrogenKernel {
constructor(Kernel: kernel) {this.kernel = kernel; this.plugins = []; ...;}
prependPlugin(HydrogenPlugin: plugin) { this._plugins.unshift(plugin); }
appendPlugin(HydrogenPlugin: plugin) { this._plugins.push(plugin); }
removePlugin(HydrogenPlugin: plugin) { ... }
execute(...args) {
let newArgs = args;
this.plugins.forEach((HydrogenPlugin: plugin) => { newArgs = plugin.execute(...newArgs); });
this.kernel.execute(...newArgs);
}
inspect(...) {...}
complete(...) {...}
restart(...) {...}
interrupt(...) {...}
shutdown(...) {...}
} And about the minor points I mentioned above:
|
@n-riesco Thanks for your comments! Your feedback last version was super helpful, and I appreciate that you gave v2 a look. To add to your description of the concept of middleware, I'd like to mention that the purpose of this concept is to allow plugins to intercept and modify kernel requests/responses. Middleware needs to intercept hydrogen commands, so e.g. if you press I say difficult because there is technically the option of creating a PluginAwareKernel class that inherits from Kernel. This approach was rejected because (1) users with plugins installed will have 100% of their kernels converted to PluginAwareKernels, which puts them on a completely different codepath from vanilla hydrogen users and increases the potential for bugs, and (2) swapping out a ZMQKernel for a PluginAwareKernel when the kernel is already associated with multiple files is needlessly complex. The simple solution is to modify the base Kernel class to accept the optional installation of middleware. A given plugin may only wish to target particular kernels (e.g. if it implements language-specific features), so middleware is installed on a per-kernel basis instead of globally. Once the base Kernel class supports middleware, this functionality needs to be exposed to plugins that only have access to HydrogenKernel wrapper instances. This adds a layer of indirection which is a bit annoying but does provide an opportunity to provide some sanity checking. Regarding your chaining proposal -- execute(...args) {
let newArgs = args;
this.plugins.forEach((HydrogenPlugin: plugin) => { newArgs = plugin.execute(...newArgs); });
this.kernel.execute(...newArgs);
} this implementation is not sufficiently general to implement plugins that want to split one request into multiple. Consider moving hydrogen's watch functionality into a plugin: this PR enables the following approach: myWatches = ...
kernel.addMiddleware({
execute: (next, code, callWatches, onResults) => {
next.execute(code, callWatches, (message, channel) => {
onResults(message, channel);
if (myWatches.isIdleStatusMessage(message)) {
for (let watch of myWatches.getWatches()) {
next.execute(watch, false, myWatches.onWatchResults);
}
}
})
}
}); The fact that Regarding your minor points, I will address them when I update the patchset. |
Do you mean like this? execute(...args) {
let newArgs = this.kernel.execute(...args);
this.plugins.forEach((HydrogenPlugin: plugin) => { newArgs = plugin.execute(...newArgs); });
} |
I don't understand what you mean by PluginAwareKernel. Could you give an example? |
Something like this: class PluginForPythonKernel {
// ...
attach(HydrogenKernel: hydrogenKernel) {
if (hydrogenKernel.kernel.language === "python") {
hydrogenKernel.appendPlugin(this);
}
// ...
} |
why?
|
How do you avoid modifying any of those classes? Say I execute
To insert some middleware here, I must modify |
I assume the call stack would look like this:
|
I think that approach is inferior. As I said previously,
|
Since some functionality like watches will be implemented as a plugin, all users will have to use
Ditto. All users will have to use a The issue with this PR isn't the idea of midleware/plugin. This is an implementation issue. The classes This PR injects the middleware/plugin functionality into
In the end, it all boils down to separation of concerns: "do one thing and do it will". Until now, |
Here is my plan for when we need to release a backwards-incompatible plugin API:
Note that this requires that multiple Also, |
I see what you've done there! Failed to mention the changes in Anyway, that's not the main problem I have raised. The issue I've raised is one of design. I'm not going to repeat the problems that breaking that design will bring. The question is: Why does this PR need to break the design of |
See my point above about plugin backward-compatibility. I consider compatibility to be of utmost importance in API design. Edit: I actually don't care that much about changing vs. not changing Kernel (thought I think adding a new class is overkill). But I do care about being able to have multiple HydrogenKernel classes in parallel. |
Are you arguing that it's impossible to provide plugin backward-compatibility without touching the code in |
If we have both a |
If the logic to handle plugins is kept inside |
Note your call stack above:
How does HydrogenKernelV2 fit in? In my plan, HydrogenKernel is never part of the call stack for requests not initiated by a plugin, so you can have as many parallel versions as you want. |
This is only a naming issue. I understand you'd like to keep the class name With this new naming, the design would be:
It's rather late here. Let's continue the discussion tomorrow. |
@n-riesco Sorry for the delayed reply, since I was busy this weekend. I think we're on the same page now regarding the architecture discussion; in particular that the plugin wrappers won't be used internally in a way that hurts backwards-compatibility. I went ahead and implemented something similar to what you propose above (with the exception of naming: I use Ignoring the CI failures for a moment, let's take a look at what effect this has on the architecture. The first thing to notice is that These changes produce a massive diff throughout all of hydrogen, and really has nothing to do with plugins. And I'm not even done! (see all the If you'd like we can proceed down this path, though it looks like it will require quite a bit of code review to happen. But to be quite honest, I don't see the value of doing such a large-scale refactoring. Especially not in a PR that's ostensibly about plugins. |
lib/zmq-kernel.js
Outdated
@@ -297,7 +297,7 @@ export default class ZMQKernel extends KernelTransport { | |||
onStdinMessage(message: Message) { | |||
log("stdin message:", message); | |||
|
|||
if (!this.isValidMessage(message)) { | |||
if (!this._isValidMessage(message)) { |
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.
ZMQKernel
still needs some validity checks because it proceeds to do things like const { msg_type } = message.header;
, which assumes that message.header
exists.
41e7c29
to
d43b0e4
Compare
@n-riesco Please let me know how to proceed here. Regarding your earlier points about naming (plugin vs. middleware vs. something else): I think it makes sense to keep the concepts of "Atom package that extends hydrogen" and "object that provides additional kernel functionality" distinct. The word "plugin" is already used in the "Atom package" sense, e.g. in the hydrogen readme:
After searching for a different word for a "kernel functionality extender", I settled on the name middleware. I'm not particularly attached to any of these names, but I do want to make sure that distinct concepts have distinct names. Note that none of the methods |
@nikitakit sorry about the late reply. I'm planning to look at the latest changes tonight. |
While I haven't been part of the long ongoing review of this PR, I would like to say that I'm a fan of the name |
If we really think that the case of plugins that don't register a middleware will be common, then fair enough. But to my mind,
I wouldn't say that the middleware defined in this API extends the kernel functionality, but instead, I'd said this middleware transforms kernel requests and replies. So how about we call it
I had a another read of the the latest changes and here's a list of the remaining blocking points:
|
I just pushed an update that gets rid of the Note that watches have apparently always triggered on "status: idle" messages and not on "execute_reply" messages. I'm not in a position to know if changing this would break things, so I've retained the old behavior. I tested the There isn't currently an API for plugins to actively participate in the Before designing this plugin API, I went through the entire hydrogen issue tracker to try to cover a wide range of real use-cases that have been suggested. I didn't find anything that would require interacting with inputs, and I'm not a fan of designing APIs for "what-if" scenarios without any real applications in mind. |
The Jupyter messaging protocol requires that an Kernels, like IJavascript, that can run requests asynchronously are able to accept new requests (i.e. they can send Could you point me to code in master where the behaviour "Note that watches have apparently always triggered on "status: idle" messages and not on "execute_reply" messages." is implemented?
Could you explain how the new implementation works? I'm confused because the only code I've found that opens an input dialog has a comment that says it never runs:
I don't understand the meaning of this paragraph. The new API seems to call the plugins when an |
You're right, I wasn't quite precise. Plugins have the option of modifying the |
return result; | ||
// TODO(nikita): perhaps it would make sense to install middleware for | ||
// sending input replies | ||
const inputView = new InputView({ prompt }, (input: string) => |
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.
Input requests are handled here. (Note that GitHub by default doesn't render the diff for this file because it's too big)
Oh, so what you mean is that the Watch callbacks have always been triggered on 'status:idle`. I think that's the right thing to do. This PR keeps doing the same, doesn't it?
Couldn't a plugin hijack an
bad github! :( OK, now I understand how it works. I'd remove the lines https://github.com/nikitakit/hydrogen/blob/a00cb945eef6b37f8a73b2f0e4422ddaccceb70c/lib/zmq-kernel.js#L314-L331 . Everywhere else in |
Yes, the functionality is unchanged.
|
lib/zmq-kernel.js
Outdated
if (callback) { | ||
callback(message, "stdin"); | ||
} else { | ||
// This branch of code should technically never run, but we keep it just |
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.
@n-riesco So you think we should drop this code now, instead of waiting for a few releases?
I guess I thought a deprecation-notice approach will catch if there are any non-compliant kernels out there that would break as a result of removing this functionality. (In the master
branch, all input requests are replied to, even if they are not in response to an execute request.)
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.
Is this a hypothetical question? Are you aware of any kernels setting the wrong msg_id
in input_request
?
To my understanding this is a bug in the kernel, and I wouldn't like Hydrogen to corrupt the spec of input_request
any further (input_reply
messages are already inconsistent with the rest of the Jupyter protocol: in my opinion, input_reply
should set parent_header
as it's done for all the other reply messages).
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'm not aware of any kernels doing this. I'll push a commit to get rid of these lines.
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.
Side note: there's a race condition if the very first line of Python code you run in hydrogen is input()
. It looks unrelated to this PR, but worth looking into at some point.
Edit: can't reproduce anymore, but maybe it's still there
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.
there's a race condition if the very first line of Python code you run in hydrogen is input().
I don't see the race. Isn't this.executionCallbaks[msg_id]
created in the same method that sends the input()
execution request (and thus, before any replies)? Here.
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 have no idea because I can't trigger it anymore. But I swear it happened a few times in a row.
My best guess is that when a new kernel is created and a blue "Hydrogen Kernels updated:" box appears, the input box never gets rendered. But I can't reproduce anymore.
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.
So, how about leaving the log message so that we can debug the race the next time it happens?
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.
It's completely unrelated to this code change.
Here's a simple issue:
- Run
input()
in Python - Wait for input prompt to appear
- Activate the command panel using cmd-shift-p
- Watch the input prompt go away permanently
I think the race is probably with some graphics-related event (similar to the command panel activation via cmd-shift-p)
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.
Would you open an issue for it (so we don't forget)?
yes, you're right. Let me re-word the idea: a plugin could hijack an |
Yes, we could create a |
Again, you're right (I thought inputReply was part of the middleware interface).
yes, I understand. |
@nikitakit a00cb94 addresses the last of the blocking issues. I think this PR is ready to merge (but note I've only read the code; I haven't tested it in my computer). |
@n-riesco Thank you for your time in looking over this code! I've tested the code and not found any breakage to existing functionality. I also have a sample plugin that appears to run without issue. (Someone else will need to merge this PR since I'm not part of the nteract org) |
Oh, I thought you were! Since this is a significant PR, before merging, I'd like to confirm that @lgeiger @BenRussert and @rgbkrk are happy with the changes. |
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.
Great to see this coming along!
@nikitakit @n-riesco Thanks for the great work and review here 🎉
Let's get this merged! We can always iron out the details in future PRs.
Woo it's on 🎉 |
You're always welcome back with 2FA. In the meantime, I'll continue mentally thinking of you as in the org. 😄 |
The goal of these changes is to make the plugin API more generally useful, by allowing plugins to intercept or issue kernel commands.
Here are just some of the plugin ideas that these changes aim to make possible:
expr?
to opening the inspector forexpr
, for those of us with lots of muscle memory from using notebooksAn example of how to use the updated API is currently in the diff for
lib/main.js
(actual location for examples is TBD).These changes have similar goals to my previous approach, but I'm now basing them on the concept of middleware classes rather than event handlers. I think this is a more hassle-free approach.
Note that I'm enforcing separation between the plugin interface and hydrogen internals: in particular, the way to extend hydrogen does not involve inheriting from any classes defined in hydrogen core. This introduces an extra level of indirection, but I think it's the right approach in the long run because it allows hydrogen core to evolve at a different pace from the plugin API. It also allows multiple versions of the plugin API to coexist, which I think is good practice for maintaining backwards compatibility.
The WIP tag reflects the fact that there are still two unresolved questions that I'd like feedback on (assuming the overall approach is sound).
Figuring out the precise syntax for defining middleware (options are presented athydrogen-kernel.js
line 73)