Skip to content
This repository has been archived by the owner on Feb 22, 2020. It is now read-only.

Commit

Permalink
feat(compose): add interactive mode of GNES board using Flask
Browse files Browse the repository at this point in the history
  • Loading branch information
hanhxiao committed Jul 20, 2019
1 parent 5876c15 commit b34a765
Show file tree
Hide file tree
Showing 9 changed files with 156 additions and 47 deletions.
9 changes: 7 additions & 2 deletions gnes/cli/api.py
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -35,8 +35,13 @@ def route(args):




def compose(args): def compose(args):
from ..composer.base import YamlGraph from ..composer.base import YamlComposer
YamlGraph(args).build_all() from ..composer.flask import YamlComposerFlask

if args.flask:
YamlComposerFlask(args).run()
else:
YamlComposer(args).build_all()




def frontend(args): def frontend(args):
Expand Down
22 changes: 19 additions & 3 deletions gnes/cli/parser.py
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ def set_base_parser():




def set_composer_parser(parser=None): def set_composer_parser(parser=None):
from pkg_resources import resource_stream

if not parser: if not parser:
parser = set_base_parser() parser = set_base_parser()
parser.add_argument('--port', parser.add_argument('--port',
Expand All @@ -45,8 +47,9 @@ def set_composer_parser(parser=None):
default='GNES instance', default='GNES instance',
help='name of the instance') help='name of the instance')
parser.add_argument('--yaml_path', type=argparse.FileType('r'), parser.add_argument('--yaml_path', type=argparse.FileType('r'),
required=True, default=resource_stream(
help='yaml config of the service') 'gnes', '/'.join(('resources', 'config', 'compose', 'default.yml'))),
help='yaml config of the service')
parser.add_argument('--html_path', type=argparse.FileType('w', encoding='utf8'), parser.add_argument('--html_path', type=argparse.FileType('w', encoding='utf8'),
default='./gnes-board.html', default='./gnes-board.html',
help='output path of the HTML file, will contain all possible generations') help='output path of the HTML file, will contain all possible generations')
Expand All @@ -69,6 +72,19 @@ def set_composer_parser(parser=None):
return parser return parser




def set_composer_flask_parser(parser=None):
if not parser:
parser = set_base_parser()
set_composer_parser(parser)
parser.add_argument('--flask', action='store_true', default=False,
help='using Flask to serve GNES composer in interactive mode')
parser.add_argument('--cors', type=str, default='*',
help='setting "Access-Control-Allow-Origin" for HTTP requests')
parser.add_argument('--http_port', type=int, default=8080,
help='server port for receiving HTTP requests')
return parser


def set_service_parser(parser=None): def set_service_parser(parser=None):
from ..service.base import SocketType, BaseService from ..service.base import SocketType, BaseService
if not parser: if not parser:
Expand Down Expand Up @@ -253,5 +269,5 @@ def get_main_parser():
set_preprocessor_service_parser(sp.add_parser('preprocess', help='start a preprocessor service')) set_preprocessor_service_parser(sp.add_parser('preprocess', help='start a preprocessor service'))
set_http_service_parser(sp.add_parser('client_http', help='start a http service')) set_http_service_parser(sp.add_parser('client_http', help='start a http service'))
set_cli_client_parser(sp.add_parser('client_cli', help='start a grpc client')) set_cli_client_parser(sp.add_parser('client_cli', help='start a grpc client'))
set_composer_parser(sp.add_parser('compose', help='start a GNES composer to simplify config generation')) set_composer_flask_parser(sp.add_parser('compose', help='start a GNES composer to simplify config generation'))
return parser return parser
2 changes: 1 addition & 1 deletion gnes/client/http.py
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor


import grpc import grpc
from aiohttp import web
from google.protobuf.json_format import MessageToJson from google.protobuf.json_format import MessageToJson


from ..helper import set_logger from ..helper import set_logger
Expand All @@ -34,6 +33,7 @@ def __init__(self, args=None):
self.logger = set_logger(self.__class__.__name__, self.args.verbose) self.logger = set_logger(self.__class__.__name__, self.args.verbose)


def start(self): def start(self):
from aiohttp import web
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
executor = ThreadPoolExecutor(max_workers=self.args.max_workers) executor = ThreadPoolExecutor(max_workers=self.args.max_workers)


Expand Down
62 changes: 33 additions & 29 deletions gnes/composer/base.py
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
_yaml = YAML() _yaml = YAML()




class YamlGraph: class YamlComposer:
comp2file = { comp2file = {
'Encoder': 'encode', 'Encoder': 'encode',
'Router': 'route', 'Router': 'route',
Expand Down Expand Up @@ -50,7 +50,7 @@ def __init__(self, layer_id: int = 0):


@staticmethod @staticmethod
def get_value(comp: Dict, key: str): def get_value(comp: Dict, key: str):
return comp.get(key, YamlGraph.Layer.default_values[key]) return comp.get(key, YamlComposer.Layer.default_values[key])


@property @property
def is_homogenous(self): def is_homogenous(self):
Expand Down Expand Up @@ -83,7 +83,7 @@ def __repr__(self):


def __init__(self, args): def __init__(self, args):


self._layers = [] # type: List['YamlGraph.Layer'] self._layers = [] # type: List['YamlComposer.Layer']
self.logger = set_logger(self.__class__.__name__) self.logger = set_logger(self.__class__.__name__)
with args.yaml_path: with args.yaml_path:
tmp = _yaml.load(args.yaml_path) tmp = _yaml.load(args.yaml_path)
Expand Down Expand Up @@ -136,8 +136,8 @@ def add_layer(self, layer: 'Layer' = None) -> None:
def add_comp(self, comp: Dict) -> None: def add_comp(self, comp: Dict) -> None:
self._layers[-1].append(comp) self._layers[-1].append(comp)


def build_layers(self) -> List['YamlGraph.Layer']: def build_layers(self) -> List['YamlComposer.Layer']:
all_layers = [] # type: List['YamlGraph.Layer'] all_layers = [] # type: List['YamlComposer.Layer']
for idx, layer in enumerate(self._layers[1:] + [self._layers[0]], 1): for idx, layer in enumerate(self._layers[1:] + [self._layers[0]], 1):
last_layer = self._layers[idx - 1] last_layer = self._layers[idx - 1]
for l in self._add_router(last_layer, layer): for l in self._add_router(last_layer, layer):
Expand All @@ -149,7 +149,7 @@ def build_layers(self) -> List['YamlGraph.Layer']:
return all_layers return all_layers


@staticmethod @staticmethod
def build_dockerswarm(all_layers: List['YamlGraph.Layer'], docker_img: str = 'gnes/gnes:latest', def build_dockerswarm(all_layers: List['YamlComposer.Layer'], docker_img: str = 'gnes/gnes:latest',
volumes: Dict = None, networks: Dict = None) -> str: volumes: Dict = None, networks: Dict = None) -> str:
with resource_stream('gnes', '/'.join(('resources', 'compose', 'gnes-swarm.yml'))) as r: with resource_stream('gnes', '/'.join(('resources', 'compose', 'gnes-swarm.yml'))) as r:
swarm_lines = _yaml.load(r) swarm_lines = _yaml.load(r)
Expand All @@ -158,7 +158,7 @@ def build_dockerswarm(all_layers: List['YamlGraph.Layer'], docker_img: str = 'gn
for c_idx, c in enumerate(layer.components): for c_idx, c in enumerate(layer.components):
c_name = '%s%d%d' % (c['name'], l_idx, c_idx) c_name = '%s%d%d' % (c['name'], l_idx, c_idx)
args = ['--%s %s' % (a, str(v) if ' ' not in str(v) else ('"%s"' % str(v))) for a, v in c.items() if args = ['--%s %s' % (a, str(v) if ' ' not in str(v) else ('"%s"' % str(v))) for a, v in c.items() if
a in YamlGraph.comp2args[c['name']] and v] a in YamlComposer.comp2args[c['name']] and v]
if 'yaml_path' in c and c['yaml_path'] is not None: if 'yaml_path' in c and c['yaml_path'] is not None:
args.append('--yaml_path /%s_yaml' % c_name) args.append('--yaml_path /%s_yaml' % c_name)
config_dict['%s_yaml' % c_name] = {'file': c['yaml_path']} config_dict['%s_yaml' % c_name] = {'file': c['yaml_path']}
Expand Down Expand Up @@ -191,16 +191,16 @@ def build_dockerswarm(all_layers: List['YamlGraph.Layer'], docker_img: str = 'gn
args += ['--host_in %s' % host_in_name] args += ['--host_in %s' % host_in_name]
# '--host_out %s' % host_out_name] # '--host_out %s' % host_out_name]


cmd = '%s %s' % (YamlGraph.comp2file[c['name']], ' '.join(args)) cmd = '%s %s' % (YamlComposer.comp2file[c['name']], ' '.join(args))
swarm_lines['services'][c_name] = CommentedMap({ swarm_lines['services'][c_name] = CommentedMap({
'image': docker_img, 'image': docker_img,
'command': cmd, 'command': cmd,
}) })


rep_c = YamlGraph.Layer.get_value(c, 'replicas') rep_c = YamlComposer.Layer.get_value(c, 'replicas')
if rep_c > 1: if rep_c > 1:
swarm_lines['services'][c_name]['deploy'] = CommentedMap({ swarm_lines['services'][c_name]['deploy'] = CommentedMap({
'replicas': YamlGraph.Layer.get_value(c, 'replicas'), 'replicas': YamlComposer.Layer.get_value(c, 'replicas'),
'restart_policy': { 'restart_policy': {
'condition': 'on-failure', 'condition': 'on-failure',
'max_attempts': 3, 'max_attempts': 3,
Expand All @@ -223,30 +223,30 @@ def build_dockerswarm(all_layers: List['YamlGraph.Layer'], docker_img: str = 'gn
return stream.getvalue() return stream.getvalue()


@staticmethod @staticmethod
def build_kubernetes(all_layers: List['YamlGraph.Layer'], *args, **kwargs): def build_kubernetes(all_layers: List['YamlComposer.Layer'], *args, **kwargs):
pass pass


@staticmethod @staticmethod
def build_shell(all_layers: List['YamlGraph.Layer'], log_redirect: str = None) -> str: def build_shell(all_layers: List['YamlComposer.Layer'], log_redirect: str = None) -> str:
shell_lines = [] shell_lines = []
for layer in all_layers: for layer in all_layers:
for c in layer.components: for c in layer.components:
rep_c = YamlGraph.Layer.get_value(c, 'replicas') rep_c = YamlComposer.Layer.get_value(c, 'replicas')
shell_lines.append('printf "starting service %s with %s replicas...\\n"' % ( shell_lines.append('printf "starting service %s with %s replicas...\\n"' % (
colored(c['name'], 'green'), colored(rep_c, 'yellow'))) colored(c['name'], 'green'), colored(rep_c, 'yellow')))
for _ in range(rep_c): for _ in range(rep_c):
cmd = YamlGraph.comp2file[c['name']] cmd = YamlComposer.comp2file[c['name']]
args = ' '.join( args = ' '.join(
['--%s %s' % (a, str(v) if ' ' not in str(v) else ('"%s"' % str(v))) for a, v in c.items() if ['--%s %s' % (a, str(v) if ' ' not in str(v) else ('"%s"' % str(v))) for a, v in c.items() if
a in YamlGraph.comp2args[c['name']] and v]) a in YamlComposer.comp2args[c['name']] and v])
shell_lines.append('gnes %s %s %s &' % ( shell_lines.append('gnes %s %s %s &' % (
cmd, args, '>> %s 2>&1' % log_redirect if log_redirect else '')) cmd, args, '>> %s 2>&1' % log_redirect if log_redirect else ''))


with resource_stream('gnes', '/'.join(('resources', 'compose', 'gnes-shell.sh'))) as r: with resource_stream('gnes', '/'.join(('resources', 'compose', 'gnes-shell.sh'))) as r:
return r.read().decode().replace('{{gnes-template}}', '\n'.join(shell_lines)) return r.read().decode().replace('{{gnes-template}}', '\n'.join(shell_lines))


@staticmethod @staticmethod
def build_mermaid(all_layers: List['YamlGraph.Layer'], mermaid_leftright: bool = False) -> str: def build_mermaid(all_layers: List['YamlComposer.Layer'], mermaid_leftright: bool = False) -> str:
mermaid_graph = [] mermaid_graph = []
cls_dict = defaultdict(set) cls_dict = defaultdict(set)
for l_idx, layer in enumerate(all_layers[1:] + [all_layers[0]], 1): for l_idx, layer in enumerate(all_layers[1:] + [all_layers[0]], 1):
Expand All @@ -255,20 +255,20 @@ def build_mermaid(all_layers: List['YamlGraph.Layer'], mermaid_leftright: bool =
for c_idx, c in enumerate(last_layer.components): for c_idx, c in enumerate(last_layer.components):
# if len(last_layer.components) > 1: # if len(last_layer.components) > 1:
# self.mermaid_graph.append('\tsubgraph %s%d' % (c['name'], c_idx)) # self.mermaid_graph.append('\tsubgraph %s%d' % (c['name'], c_idx))
for j in range(YamlGraph.Layer.get_value(c, 'replicas')): for j in range(YamlComposer.Layer.get_value(c, 'replicas')):
for c1_idx, c1 in enumerate(layer.components): for c1_idx, c1 in enumerate(layer.components):
if c1['port_in'] == c['port_out']: if c1['port_in'] == c['port_out']:
p = '((%s%s))' if c['name'] == 'Router' else '(%s%s)' p = '((%s%s))' if c['name'] == 'Router' else '(%s%s)'
p1 = '((%s%s))' if c1['name'] == 'Router' else '(%s%s)' p1 = '((%s%s))' if c1['name'] == 'Router' else '(%s%s)'
for j1 in range(YamlGraph.Layer.get_value(c1, 'replicas')): for j1 in range(YamlComposer.Layer.get_value(c1, 'replicas')):
_id, _id1 = '%s%s%s' % (last_layer.layer_id, c_idx, j), '%s%s%s' % ( _id, _id1 = '%s%s%s' % (last_layer.layer_id, c_idx, j), '%s%s%s' % (
layer.layer_id, c1_idx, j1) layer.layer_id, c1_idx, j1)
conn_type = ( conn_type = (
c['socket_out'].split('_')[0] + '/' + c1['socket_in'].split('_')[0]).lower() c['socket_out'].split('_')[0] + '/' + c1['socket_in'].split('_')[0]).lower()
s_id = '%s%s' % (c_idx if len(last_layer.components) > 1 else '', s_id = '%s%s' % (c_idx if len(last_layer.components) > 1 else '',
j if YamlGraph.Layer.get_value(c, 'replicas') > 1 else '') j if YamlComposer.Layer.get_value(c, 'replicas') > 1 else '')
s1_id = '%s%s' % (c1_idx if len(layer.components) > 1 else '', s1_id = '%s%s' % (c1_idx if len(layer.components) > 1 else '',
j1 if YamlGraph.Layer.get_value(c1, 'replicas') > 1 else '') j1 if YamlComposer.Layer.get_value(c1, 'replicas') > 1 else '')
mermaid_graph.append( mermaid_graph.append(
'\t%s%s%s-- %s -->%s%s%s' % ( '\t%s%s%s-- %s -->%s%s%s' % (
c['name'], _id, p % (c['name'], s_id), conn_type, c1['name'], _id1, c['name'], _id, p % (c['name'], s_id), conn_type, c1['name'], _id1,
Expand Down Expand Up @@ -319,11 +319,15 @@ def std_or_print(f, content):
'timestamp': time.strftime("%a, %d %b %Y %H:%M:%S"), 'timestamp': time.strftime("%a, %d %b %Y %H:%M:%S"),
'version': __version__ 'version': __version__
} }

cmds['html'] = self.build_html(cmds)

std_or_print(self.args.graph_path, cmds['mermaid']) std_or_print(self.args.graph_path, cmds['mermaid'])
std_or_print(self.args.shell_path, cmds['shell']) std_or_print(self.args.shell_path, cmds['shell'])
std_or_print(self.args.swarm_path, cmds['docker']) std_or_print(self.args.swarm_path, cmds['docker'])
std_or_print(self.args.k8s_path, cmds['k8s']) std_or_print(self.args.k8s_path, cmds['k8s'])
std_or_print(self.args.html_path, self.build_html(cmds)) std_or_print(self.args.html_path, cmds['html'])
return cmds


@staticmethod @staticmethod
def _get_random_port(min_port: int = 49152, max_port: int = 65536) -> str: def _get_random_port(min_port: int = 49152, max_port: int = 65536) -> str:
Expand All @@ -333,7 +337,7 @@ def _get_random_port(min_port: int = 49152, max_port: int = 65536) -> str:
def _get_random_host(comp_name: str) -> str: def _get_random_host(comp_name: str) -> str:
return str(comp_name + str(random.randrange(0, 100))) return str(comp_name + str(random.randrange(0, 100)))


def _add_router(self, last_layer: 'YamlGraph.Layer', layer: 'YamlGraph.Layer') -> List['YamlGraph.Layer']: def _add_router(self, last_layer: 'YamlComposer.Layer', layer: 'YamlComposer.Layer') -> List['YamlComposer.Layer']:
def rule1(): def rule1():
# a shortcut fn: push connect the last and current # a shortcut fn: push connect the last and current
last_layer.components[0]['socket_out'] = str(SocketType.PUSH_BIND) last_layer.components[0]['socket_out'] = str(SocketType.PUSH_BIND)
Expand All @@ -346,7 +350,7 @@ def rule2():


def rule3(): def rule3():
# a shortcut fn: (N)-2-(N) with push pull connection # a shortcut fn: (N)-2-(N) with push pull connection
router_layer = YamlGraph.Layer(layer_id=self._num_layer) router_layer = YamlComposer.Layer(layer_id=self._num_layer)
self._num_layer += 1 self._num_layer += 1
last_layer.components[0]['socket_out'] = str(SocketType.PUSH_CONNECT) last_layer.components[0]['socket_out'] = str(SocketType.PUSH_CONNECT)
r = CommentedMap({'name': 'Router', r = CommentedMap({'name': 'Router',
Expand Down Expand Up @@ -375,7 +379,7 @@ def rule5():


def rule6(): def rule6():
last_layer.components[0]['socket_out'] = str(SocketType.PUB_BIND) last_layer.components[0]['socket_out'] = str(SocketType.PUB_BIND)
router_layer = YamlGraph.Layer(layer_id=self._num_layer) router_layer = YamlComposer.Layer(layer_id=self._num_layer)
self._num_layer += 1 self._num_layer += 1
for c in layer.components: for c in layer.components:
income = self.Layer.get_value(c, 'income') income = self.Layer.get_value(c, 'income')
Expand All @@ -394,7 +398,7 @@ def rule6():
def rule7(): def rule7():
last_layer.components[0]['socket_out'] = str(SocketType.PUSH_CONNECT) last_layer.components[0]['socket_out'] = str(SocketType.PUSH_CONNECT)


router_layer = YamlGraph.Layer(layer_id=self._num_layer) router_layer = YamlComposer.Layer(layer_id=self._num_layer)
self._num_layer += 1 self._num_layer += 1
r0 = CommentedMap({'name': 'Router', r0 = CommentedMap({'name': 'Router',
'yaml_path': None, 'yaml_path': None,
Expand All @@ -406,7 +410,7 @@ def rule7():
router_layers.append(router_layer) router_layers.append(router_layer)
last_layer.components[0]['port_out'] = r0['port_in'] last_layer.components[0]['port_out'] = r0['port_in']


router_layer = YamlGraph.Layer(layer_id=self._num_layer) router_layer = YamlComposer.Layer(layer_id=self._num_layer)
self._num_layer += 1 self._num_layer += 1
for c in layer.components: for c in layer.components:
r = CommentedMap({'name': 'Router', r = CommentedMap({'name': 'Router',
Expand All @@ -423,7 +427,7 @@ def rule7():
def rule10(): def rule10():
last_layer.components[0]['socket_out'] = str(SocketType.PUSH_CONNECT) last_layer.components[0]['socket_out'] = str(SocketType.PUSH_CONNECT)


router_layer = YamlGraph.Layer(layer_id=self._num_layer) router_layer = YamlComposer.Layer(layer_id=self._num_layer)
self._num_layer += 1 self._num_layer += 1
r0 = CommentedMap({'name': 'Router', r0 = CommentedMap({'name': 'Router',
'yaml_path': None, 'yaml_path': None,
Expand All @@ -441,7 +445,7 @@ def rule10():


def rule8(): def rule8():
last_layer.components[0]['socket_out'] = str(SocketType.PUSH_CONNECT) last_layer.components[0]['socket_out'] = str(SocketType.PUSH_CONNECT)
router_layer = YamlGraph.Layer(layer_id=self._num_layer) router_layer = YamlComposer.Layer(layer_id=self._num_layer)
self._num_layer += 1 self._num_layer += 1
r = CommentedMap({'name': 'Router', r = CommentedMap({'name': 'Router',
'yaml_path': None, 'yaml_path': None,
Expand Down Expand Up @@ -475,7 +479,7 @@ def rule8():
else: else:
self._num_layer -= 1 self._num_layer -= 1


router_layer = YamlGraph.Layer(layer_id=self._num_layer) router_layer = YamlComposer.Layer(layer_id=self._num_layer)
self._num_layer += 1 self._num_layer += 1
router_layer.append(r) router_layer.append(r)
router_layers.append(router_layer) router_layers.append(router_layer)
Expand Down
47 changes: 47 additions & 0 deletions gnes/composer/flask.py
Original file line number Original file line Diff line number Diff line change
@@ -0,0 +1,47 @@
import tempfile

from .base import YamlComposer
from ..cli.parser import set_composer_parser


class YamlComposerFlask:
def __init__(self, args):
self.args = args

def create_flask_app(self):
try:
from flask import Flask, request, abort, redirect, url_for
from flask_compress import Compress
from flask_cors import CORS
except ImportError:
raise ImportError('Flask or its dependencies are not fully installed, '
'they are required for serving HTTP requests.'
'Please use "pip install Flask" to install it.')

# support up to 10 concurrent HTTP requests
app = Flask(__name__)

@app.route('/', methods=['GET'])
def get_homepage():
return YamlComposer(set_composer_parser().parse_args([])).build_all()['html']

@app.route('/refresh', methods=['POST'])
def regenerate():
data = request.form if request.form else request.json
f = tempfile.NamedTemporaryFile('w', delete=False).name
with open(f, 'w', encoding='utf8') as fp:
fp.write(data['yaml-config'])
try:
return YamlComposer(set_composer_parser().parse_args([
'--yaml_path', f
])).build_all()['html']
except Exception:
return 'Bad YAML input, please kindly check the format, indent and content of your YAML file!'

CORS(app, origins=self.args.cors)
Compress().init_app(app)
return app

def run(self):
app = self.create_flask_app()
app.run(port=self.args.http_port, threaded=True, host='0.0.0.0')
30 changes: 22 additions & 8 deletions gnes/resources/compose/gnes-board.html
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -200,14 +200,28 @@
YAML config YAML config
</div> </div>
<div class="card-body"> <div class="card-body">
<button type="button" class="btn btn-primary" data-clipboard-target="#yaml-code"> <form action="/refresh" method="post">
Copy to Clipboard <div class="card-title">
</button> <div class="btn-group" role="group" aria-label="Basic example">
<pre> <button type="button" class="btn btn-secondary"
<code class="yaml" id="yaml-code"> data-clipboard-target="#simple-yaml-config">
Copy
</button>
<input type="submit" class="btn btn-primary" value="Generate">
</div>
</div>
<div class="card-text">


<div class="form-group">
<textarea name="yaml-config" class="form-control" id="simple-yaml-config" rows="15"
placeholder="your YAML config here" required autofocus>
{{gnes-yaml}} {{gnes-yaml}}
</code> </textarea>
</pre> </div>

</div>
</form>
</div> </div>
</div> </div>
</div> </div>
Expand All @@ -218,7 +232,7 @@
<div class="jumbotron"> <div class="jumbotron">


<p class="lead">This is the workflow generated from your input YAML config, which helps you <p class="lead">This is the workflow generated from your input YAML config, which helps you
to understand how microservices work together in GNES.</p> to understand how microservices work together in GNES.</p>
</div> </div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
Expand Down
Loading

0 comments on commit b34a765

Please sign in to comment.