Getting Started¶
The example directory of the rpcplugin Python implementation repository
contains example plugin client (host application) and server (the plugins
themselves) implementations for a simple RPC service that just counts calls
and returns how many times it has been called.
From within that directory, running python client.py will initialize a
plugin client, launch the server implementation in server.py, and then call
it in a loop to illustrate the counting behavior. In this case both the client
and server are written in Python, but that is not a requirement: the server
could’ve been written in any language with an RPCPlugin implementation.
In the remainder of this section, we’ll look at how that example is put
together, and thus show how you might use rpcplugin to write your own
plugins for RPCPlugin-based applications or integrate plugins into your
application.
gRPC and Protocol Buffers¶
In order to communicate with plugins written in various languages, RPCPlugin uses the languge-agnostic RPC framework gRPC, with the plugin RPC protocol defined using Protocol Buffers.
For our counting example, the service is defined in countplugin1.proto.
In that file we can find the language-agnostic definition of the count
service using the Protocol Buffers schema language:
syntax = "proto3";
package countplugin1;
service Counter {
rpc Count(Count.Request) returns (Count.Response);
rpc GetCount(GetCount.Request) returns (GetCount.Response);
}
message Count {
message Request {
}
message Response {
}
}
message GetCount {
message Request {
}
message Response {
int64 count = 1;
}
}
The service block defines which RPC functions are expected for a “counter”
plugin, which in this example are Count and GetCount. Each of those
functions accepts arguments and returns values using Protocol Buffers
“message” types, where in this case the interface is relatively simple and
only includes a count result from GetCount.
The example directory has a generate.sh file which will translate this
service definition into importable Python code that we use in our client and
server implementations, by running the Protocol Buffers compiler:
python -m grpc_tools.protoc -I. \
--python_out=. --grpc_python_out=. \
countplugin1.proto
The above command generates two files: countplugin1_pb2.py and
countplugin1_pb2_grpc.py. These files contain generated Python classes
for both RPC clients (“stubs”) and RPC servers (“servicers”), derived from
the service definition and message types in the .proto file.
Inside the Server¶
In RPCPlugin terminology, the “server” is the plugin itself. This terminology
indicates that the plugin starts up a gRPC server ready for the calling
application (the “client”) to connect to. Because of that, our server
implementation consists mainly of implementing the Counter gRPC service in
Python.
The source file server.py is the entry point for our count plugin server
written in Python. We’ll just look at some snippets from that file here, but
please do refer to the whole file to see how it all fits together into a
working example.
import rpcplugin
rpcplugin.serve(
handshake=rpcplugin.Handshake(
cookie_key="COUNT_PLUGIN_COOKIE",
cookie_value="e8f9c7d7-20fd-55c7-83f9-bee91db2922b",
),
proto_versions={
1: CountPlugin1,
},
)
rpcplugin.serve() is the entry-point for plugin servers, and accepts
a number of arguments describing the intended plugin behavior. The above is
a minimal example using only two arguments:
handshakedefines an environment variable and its associated value which the client will set to allow this server to detect whether or not it is running as a plugin server. These values must agree exactly with the client and are normally defined as part of the plugin protocol definition of a particular application.proto_versionsrefers to the server implementation for each major version of the “counter” plugin protocol. As systems evolve it may eventually be necessary to introduce breaking changes via a new major protocol version, so this mapping allows for multiple versions to be implemented at once for a more graceful deprecation path.
There is only one major version (1) defined for the “count” protocol so far,
and its implementation is CountPlugin1 which we will look at next.
import countplugin1_pb2
import countplugin1_pb2_grpc
class CountPlugin1(countplugin1_pb2_grpc.CounterServicer):
def __init__(self, grpc_server):
countplugin1_pb2_grpc.add_CounterServicer_to_server(
self, grpc_server,
)
self.count = 0
def Count(self, request, context):
self.count += 1
return countplugin1_pb2.Count.Response()
def GetCount(self, request, context):
return countplugin1_pb2.GetCount.Response(count=self.count)
CountPlugin1 is our class implementing the server side of the Counter
plugin service definition. We inherit from
countplugin1_pb2_grpc.CounterServicer, which is a class that was generated
by the protocol buffers compiler earlier.
The __init__ method ensures that this class, when called, has the signature
expected for the callables in the proto_versions map in the serve call
above, which is:
callable(grpc_server)
Its responsibility then is to register CountPlugin1 as the implementation
of CounterServicer for this server, which means that subsequent calls
to either Count or GetCount will be served by the methods of those
same names on our CountPlugin1 instance.
The requirements of the Counter service are straightforward: each call
to Count increments the counter, and GetCount retrieves the current
counter value. We’ve implemented both of those behaviors here and ensured
that each implementation returns the appropriate Python type corresponding to
the expected response message type.
If you run python server.py directly you’ll see it print an error message:
Exception: calling program is not an rpcplugin host
That’s because it’s expecting to find the environment variable we declared
in handshake above, which would normally be set by the plugin client to
help detect when a plugin server is inadvertently used with the wrong calling
program, or run directly like this. To actually try out the server.py
functionality we need a plugin client program, which we’ll see in the
next section.
Inside the Client¶
As noted above, the “client” in RPCPlugin is the host program that is
activating and calling into the plugins, which are the “servers”. In our
example directory the source file client.py contains a simplistic
client for the “Counter” plugin type, which by default launches the server
implementation we discussed in the previous section.
Again we’ll just look at some snippets from the file; please refer to the file itself to see how it all fits together.
import rpcplugin
plugin = rpcplugin.start(
args=('python', 'server.py'),
handshake=rpcplugin.Handshake(
cookie_key="COUNT_PLUGIN_COOKIE",
cookie_value="e8f9c7d7-20fd-55c7-83f9-bee91db2922b",
),
proto_versions={
1: countplugin1_pb2_grpc.CounterStub,
}
)
rpcplugin.start() launches a child program (given by args) and
expects it to produce the rpcplugin handshake, returning a
rpcplugin.Plugin object representing that child process.
Similarly to the server, we must provide a map of protocol version
implementations, this time in the form of a callable that takes a gRPC
channel and returns a client stub object. CounterStub is another class
generated by the protocol buffers compiler, this time providing a method
for each service function that forwards each call to the plugin server.
Once the plugin has started up, we can retrieve the client object for the protocol version that the client and server negotiated:
proto_version, client = plugin.client()
assert(proto_version == 1) # only one version is supported
In an application supporting multiple protocol versions at once, we could use the returned protocol version to know which service interface to expect, but in our simple example there is only one version so we just assert that it was selected.
client.Count(countplugin1_pb2.Count.Request())
resp = client.GetCount(countplugin1_pb2.GetCount.Request())
logging.info("counter value is now %d" % resp.count)
We can then call methods on the client object to call into the plugin.
Once we don’t need the plugin anymore, we must shut it down by calling
rpcplugin.Plugin.close():
plugin.close()
Once close returns successfully, the child process for the plugin has
been terminated. The plugin and client objects are then no longer
usable.
Cross-language Calls¶
Our client.py defaults to running the server.py in the same directory
as its plugin server, but if you pass it at least one argument then it will
use those arguments as the server command line.
There are no other language servers in the Python library repository, but if
you have a countplugin1 server example in another language available to run
then you can pass it to client.py to see a cross-language call. For
example, if you have the Go server example in the default Go binary directory,
you could run the Go server in the Python client like this:
python client.py ~/go/bin/count-plugin-server
Conversely, you can try the Python server with the Go client.