411 lines
11 KiB
Python
411 lines
11 KiB
Python
###############################################################################
|
|
#
|
|
# The MIT License (MIT)
|
|
#
|
|
# Copyright (c) Crossbar.io Technologies GmbH
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
# THE SOFTWARE.
|
|
#
|
|
###############################################################################
|
|
|
|
# this module is available as the 'wamp' command-line tool or as
|
|
# 'python -m autobahn'
|
|
|
|
import os
|
|
import sys
|
|
import argparse
|
|
import json
|
|
from copy import copy
|
|
|
|
try:
|
|
from autobahn.twisted.component import Component
|
|
except ImportError:
|
|
print("The 'wamp' command-line tool requires Twisted.")
|
|
print(" pip install autobahn[twisted]")
|
|
sys.exit(1)
|
|
|
|
from twisted.internet.defer import Deferred, inlineCallbacks
|
|
from twisted.internet.task import react
|
|
from twisted.internet.protocol import ProcessProtocol
|
|
|
|
from autobahn.wamp.exception import ApplicationError
|
|
from autobahn.wamp.types import PublishOptions
|
|
from autobahn.wamp.types import SubscribeOptions
|
|
|
|
import txaio
|
|
txaio.use_twisted()
|
|
|
|
|
|
# XXX other ideas to get 'connection config':
|
|
# - if there .crossbar/ here, load that config and accept a --name or
|
|
# so to idicate which transport to use
|
|
|
|
# wamp [options] {call,publish,subscribe,register} wamp-uri [args] [kwargs]
|
|
#
|
|
# kwargs are spec'd with a 2-value-consuming --keyword option:
|
|
# --keyword name value
|
|
|
|
|
|
top = argparse.ArgumentParser(prog="wamp")
|
|
top.add_argument(
|
|
'--url',
|
|
action='store',
|
|
help='A WAMP URL to connect to, like ws://127.0.0.1:8080/ws or rs://localhost:1234',
|
|
required=True,
|
|
)
|
|
top.add_argument(
|
|
'--realm', '-r',
|
|
action='store',
|
|
help='The realm to join',
|
|
default='default',
|
|
)
|
|
top.add_argument(
|
|
'--private-key', '-k',
|
|
action='store',
|
|
help='Hex-encoded private key (via WAMP_PRIVATE_KEY if not provided here)',
|
|
default=os.environ.get('WAMP_PRIVATE_KEY', None),
|
|
)
|
|
top.add_argument(
|
|
'--authid',
|
|
action='store',
|
|
help='The authid to use, if authenticating',
|
|
default=None,
|
|
)
|
|
top.add_argument(
|
|
'--authrole',
|
|
action='store',
|
|
help='The role to use, if authenticating',
|
|
default=None,
|
|
)
|
|
top.add_argument(
|
|
'--max-failures', '-m',
|
|
action='store',
|
|
type=int,
|
|
help='Failures before giving up (0 forever)',
|
|
default=0,
|
|
)
|
|
sub = top.add_subparsers(
|
|
title="subcommands",
|
|
dest="subcommand_name",
|
|
)
|
|
|
|
|
|
call = sub.add_parser(
|
|
'call',
|
|
help='Do a WAMP call() and print any results',
|
|
)
|
|
call.add_argument(
|
|
'uri',
|
|
type=str,
|
|
help="A WAMP URI to call"
|
|
)
|
|
call.add_argument(
|
|
'call_args',
|
|
nargs='*',
|
|
help="All additional arguments are positional args",
|
|
)
|
|
call.add_argument(
|
|
'--keyword',
|
|
nargs=2,
|
|
action='append',
|
|
help="Specify a keyword argument to send: name value",
|
|
)
|
|
|
|
|
|
publish = sub.add_parser(
|
|
'publish',
|
|
help='Do a WAMP publish() with the given args, kwargs',
|
|
)
|
|
publish.add_argument(
|
|
'uri',
|
|
type=str,
|
|
help="A WAMP URI to publish"
|
|
)
|
|
publish.add_argument(
|
|
'publish_args',
|
|
nargs='*',
|
|
help="All additional arguments are positional args",
|
|
)
|
|
publish.add_argument(
|
|
'--keyword',
|
|
nargs=2,
|
|
action='append',
|
|
help="Specify a keyword argument to send: name value",
|
|
)
|
|
|
|
|
|
register = sub.add_parser(
|
|
'register',
|
|
help='Do a WAMP register() and run a command when called',
|
|
)
|
|
register.add_argument(
|
|
'uri',
|
|
type=str,
|
|
help="A WAMP URI to call"
|
|
)
|
|
register.add_argument(
|
|
'--times',
|
|
type=int,
|
|
default=0,
|
|
help="Listen for this number of events, then exit. Default: forever",
|
|
)
|
|
register.add_argument(
|
|
'command',
|
|
type=str,
|
|
nargs='*',
|
|
help=(
|
|
"Takes one or more args: the executable to call, and any positional "
|
|
"arguments. As well, the following environment variables are set: "
|
|
"WAMP_ARGS, WAMP_KWARGS and _JSON variants."
|
|
)
|
|
)
|
|
|
|
|
|
subscribe = sub.add_parser(
|
|
'subscribe',
|
|
help='Do a WAMP subscribe() and print one line of JSON per event',
|
|
)
|
|
subscribe.add_argument(
|
|
'uri',
|
|
type=str,
|
|
help="A WAMP URI to call"
|
|
)
|
|
subscribe.add_argument(
|
|
'--times',
|
|
type=int,
|
|
default=0,
|
|
help="Listen for this number of events, then exit. Default: forever",
|
|
)
|
|
subscribe.add_argument(
|
|
'--match',
|
|
type=str,
|
|
default='exact',
|
|
choices=['exact', 'prefix'],
|
|
help="Massed in the SubscribeOptions, how to match the URI",
|
|
)
|
|
|
|
|
|
def _create_component(options):
|
|
"""
|
|
Configure and return a Component instance according to the given
|
|
`options`
|
|
"""
|
|
if options.url.startswith('ws://'):
|
|
kind = 'websocket'
|
|
elif options.url.startswith('rs://'):
|
|
kind = 'rawsocket'
|
|
else:
|
|
raise ValueError(
|
|
"URL should start with ws:// or rs://"
|
|
)
|
|
|
|
authentication = dict()
|
|
if options.private_key:
|
|
if not options.authid:
|
|
raise ValueError(
|
|
"Require --authid and --authrole if --private-key (or WAMP_PRIVATE_KEY) is provided"
|
|
)
|
|
authentication["cryptosign"] = {
|
|
"authid": options.authid,
|
|
"authrole": options.authrole,
|
|
"privkey": options.private_key,
|
|
}
|
|
|
|
return Component(
|
|
transports=[{
|
|
"type": kind,
|
|
"url": options.url,
|
|
}],
|
|
authentication=authentication if authentication else None,
|
|
realm=options.realm,
|
|
)
|
|
|
|
|
|
@inlineCallbacks
|
|
def do_call(reactor, session, options):
|
|
call_args = list(options.call_args)
|
|
call_kwargs = dict()
|
|
if options.keyword is not None:
|
|
call_kwargs = {
|
|
k: v
|
|
for k, v in options.keyword
|
|
}
|
|
|
|
results = yield session.call(options.uri, *call_args, **call_kwargs)
|
|
print("result: {}".format(results))
|
|
|
|
|
|
@inlineCallbacks
|
|
def do_publish(reactor, session, options):
|
|
publish_args = list(options.publish_args)
|
|
publish_kwargs = {} if options.keyword is None else {
|
|
k: v
|
|
for k, v in options.keyword
|
|
}
|
|
|
|
yield session.publish(
|
|
options.uri,
|
|
*publish_args,
|
|
options=PublishOptions(acknowledge=True),
|
|
**publish_kwargs
|
|
)
|
|
|
|
|
|
@inlineCallbacks
|
|
def do_register(reactor, session, options):
|
|
"""
|
|
run a command-line upon an RPC call
|
|
"""
|
|
|
|
all_done = Deferred()
|
|
countdown = [options.times]
|
|
|
|
@inlineCallbacks
|
|
def called(*args, **kw):
|
|
print("called: args={}, kwargs={}".format(args, kw), file=sys.stderr)
|
|
env = copy(os.environ)
|
|
env['WAMP_ARGS'] = ' '.join(args)
|
|
env['WAMP_ARGS_JSON'] = json.dumps(args)
|
|
env['WAMP_KWARGS'] = ' '.join('{}={}'.format(k, v) for k, v in kw.items())
|
|
env['WAMP_KWARGS_JSON'] = json.dumps(kw)
|
|
|
|
exe = os.path.abspath(options.command[0])
|
|
args = options.command
|
|
done = Deferred()
|
|
|
|
class DumpOutput(ProcessProtocol):
|
|
def outReceived(self, data):
|
|
sys.stdout.write(data.decode('utf8'))
|
|
|
|
def errReceived(self, data):
|
|
sys.stderr.write(data.decode('utf8'))
|
|
|
|
def processExited(self, reason):
|
|
done.callback(reason.value.exitCode)
|
|
|
|
proto = DumpOutput()
|
|
reactor.spawnProcess(
|
|
proto, exe, args, env=env, path="."
|
|
)
|
|
code = yield done
|
|
|
|
if code != 0:
|
|
print("Failed with exit-code {}".format(code))
|
|
if countdown[0]:
|
|
countdown[0] -= 1
|
|
if countdown[0] <= 0:
|
|
reactor.callLater(0, all_done.callback, None)
|
|
|
|
yield session.register(called, options.uri)
|
|
yield all_done
|
|
|
|
|
|
@inlineCallbacks
|
|
def do_subscribe(reactor, session, options):
|
|
"""
|
|
print events (one line of JSON per event)
|
|
"""
|
|
|
|
all_done = Deferred()
|
|
countdown = [options.times]
|
|
|
|
@inlineCallbacks
|
|
def published(*args, **kw):
|
|
print(
|
|
json.dumps({
|
|
"args": args,
|
|
"kwargs": kw,
|
|
})
|
|
)
|
|
if countdown[0]:
|
|
countdown[0] -= 1
|
|
if countdown[0] <= 0:
|
|
reactor.callLater(0, all_done.callback, None)
|
|
|
|
yield session.subscribe(published, options.uri, options=SubscribeOptions(match=options.match))
|
|
yield all_done
|
|
|
|
|
|
def _main():
|
|
"""
|
|
This is a magic name for `python -m autobahn`, and specified as
|
|
our entry_point in setup.py
|
|
"""
|
|
react(_real_main)
|
|
|
|
|
|
@inlineCallbacks
|
|
def _real_main(reactor):
|
|
"""
|
|
Sanity check options, create a connection and run our subcommand
|
|
"""
|
|
options = top.parse_args()
|
|
component = _create_component(options)
|
|
|
|
if options.subcommand_name is None:
|
|
print("Must select a subcommand")
|
|
sys.exit(1)
|
|
|
|
if options.subcommand_name == "register":
|
|
exe = options.command[0]
|
|
if not os.path.isabs(exe):
|
|
print("Full path to the executable required. Found: {}".format(exe), file=sys.stderr)
|
|
sys.exit(1)
|
|
if not os.path.exists(exe):
|
|
print("Executable not found: {}".format(exe), file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
subcommands = {
|
|
"call": do_call,
|
|
"register": do_register,
|
|
"subscribe": do_subscribe,
|
|
"publish": do_publish,
|
|
}
|
|
command_fn = subcommands[options.subcommand_name]
|
|
|
|
exit_code = [0]
|
|
|
|
@component.on_join
|
|
@inlineCallbacks
|
|
def _(session, details):
|
|
print("connected: authrole={} authmethod={}".format(details.authrole, details.authmethod), file=sys.stderr)
|
|
try:
|
|
yield command_fn(reactor, session, options)
|
|
except ApplicationError as e:
|
|
print("\n{}: {}\n".format(e.error, ''.join(e.args)))
|
|
exit_code[0] = 5
|
|
yield session.leave()
|
|
|
|
failures = []
|
|
|
|
@component.on_connectfailure
|
|
def _(comp, fail):
|
|
print("connect failure: {}".format(fail))
|
|
failures.append(fail)
|
|
if options.max_failures > 0 and len(failures) > options.max_failures:
|
|
print("Too many failures ({}). Exiting".format(len(failures)))
|
|
reactor.stop()
|
|
|
|
yield component.start(reactor)
|
|
# sys.exit(exit_code[0])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_main()
|