Browse Source

Graph beutification + pie working

pull/8/head
Florian Mounier 14 years ago
parent
commit
59a73c6f05
  1. 40
      pygal/bar.py
  2. 109
      pygal/css/bar.css
  3. 111
      pygal/css/graph.css
  4. 78
      pygal/css/pie.css
  5. 77
      pygal/graph.py
  6. 59
      pygal/pie.py
  7. 59
      test/moulinrouge/__init__.py
  8. 2
      test/moulinrouge/static/css.css
  9. 1
      test/moulinrouge/templates/index.jinja2
  10. 2
      test/moulinrouge/templates/svgs.jinja2
  11. 2
      test/tests.py

40
pygal/bar.py

@ -90,38 +90,6 @@ class VerticalBar(Bar):
""" Vertical bar graph """ """ Vertical bar graph """
top_align = top_font = 1 top_align = top_font = 1
def add_defs(self, defs):
"""
Override and place code to add defs here. TODO: what are defs?
"""
for id in range(12):
idn = 'light%d' % (id + 1)
light = node(defs, 'linearGradient', {
'id': idn,
'x1': 0,
'x2': '50%',
'y1': 0,
'y2': '100%'})
node(light, 'stop',
{'class': 'upGradientLight %s' % idn, 'offset': 0})
node(light, 'stop',
{'class': 'downGradientLight %s' % idn, 'offset': '100%'})
shadow = node(defs, 'linearGradient', {
'id': 'shadow',
'x1': 0,
'x2': '100%',
'y1': 0,
'y2': 0})
node(shadow, 'stop',
{'offset': 0, 'stop-color': '#aaa', 'stop-opacity': 0.7})
node(shadow, 'stop',
{'offset': '1%', 'stop-color': '#fff', 'stop-opacity': 1})
node(shadow, 'stop',
{'offset': '99%', 'stop-color': '#fff', 'stop-opacity': 1})
node(shadow, 'stop',
{'offset': '100%', 'stop-color': '#aaa', 'stop-opacity': .7})
def get_x_labels(self): def get_x_labels(self):
return self.get_field_labels() return self.get_field_labels()
@ -168,7 +136,7 @@ class VerticalBar(Bar):
left += bar_width * dataset_count left += bar_width * dataset_count
rect_group = node(self.graph, "g", rect_group = node(self.graph, "g",
{'class': 'bar'}) {'class': 'bar vbar'})
node(rect_group, 'rect', { node(rect_group, 'rect', {
'x': left, 'x': left,
'y': top, 'y': top,
@ -229,7 +197,9 @@ class HorizontalBar(Bar):
# left is 0 if value is negative # left is 0 if value is negative
left = (abs(min_value) + min(value, 0)) * unit_size left = (abs(min_value) + min(value, 0)) * unit_size
node(self.graph, 'rect', { rect_group = node(self.graph, "g",
{'class': 'bar hbar'})
node(rect_group, 'rect', {
'x': left, 'x': left,
'y': top, 'y': top,
'width': length, 'width': length,
@ -237,6 +207,6 @@ class HorizontalBar(Bar):
'class': 'fill fill%s' % (dataset_count + 1), 'class': 'fill fill%s' % (dataset_count + 1),
}) })
self.make_datapoint_text( self.make_datapoint_text(rect_group,
left + length + 5, top + y_mod, value, left + length + 5, top + y_mod, value,
"text-anchor: start; ") "text-anchor: start; ")

109
pygal/css/bar.css

@ -17,112 +17,3 @@
.downGradientLight { .downGradientLight {
stop-opacity: 0.9; stop-opacity: 0.9;
} }
.key, .fill {
fill-opacity: 0.9;
stroke: #fff;
stroke-width: 2px;
-webkit-transition: 250ms;
}
.fill:hover {
stroke: #ddd;
fill-opacity: 0.7;
}
.key1, .fill1 {
fill: url(#light1);
}
.key2, .fill2 {
fill: url(#light2);
}
.key3, .fill3 {
fill: url(#light3);
}
.key4, .fill4 {
fill: url(#light4);
}
.key5, .fill5 {
fill: url(#light5);
}
.key6, .fill6 {
fill: url(#light6);
}
.key7, .fill7 {
fill: url(#light7);
}
.key8, .fill8 {
fill: url(#light8);
}
.key9, .fill9 {
fill: url(#light9);
}
.key10, .fill10 {
fill: url(#light10);
}
.key11, .fill11 {
fill: url(#light11);
}
.key12, .fill12 {
fill: url(#light12);
}
.light1 {
stop-color: #2a4269;
}
.light2 {
stop-color: #38588e;
}
.light3 {
stop-color: #476fb2;
}
.light4 {
stop-color: #698bc3;
}
.light5 {
stop-color: #00ccff;
}
.light6 {
stop-color: #ff00ff;
}
.light7 {
stop-color: #00ffff;
}
.light8 {
stop-color: #ffff00;
}
.light9 {
stop-color: #cc6666;
}
.light10 {
stop-color: #663399;
}
.light11 {
stop-color: #339900;
}
.light12 {
stop-color: #9966FF;
}

111
pygal/css/graph.css

@ -83,3 +83,114 @@
font-size: %(key_font_size)dpx; font-size: %(key_font_size)dpx;
font-weight: normal; font-weight: normal;
} }
.key, .fill {
fill-opacity: 0.9;
stroke: #fff;
stroke-width: 2px;
-webkit-transition: 250ms;
}
.fill:hover {
stroke: #ddd;
fill-opacity: 0.7;
}
.key1, .fill1 {
fill: url(#light1);
}
.key2, .fill2 {
fill: url(#light2);
}
.key3, .fill3 {
fill: url(#light3);
}
.key4, .fill4 {
fill: url(#light4);
}
.key5, .fill5 {
fill: url(#light5);
}
.key6, .fill6 {
fill: url(#light6);
}
.key7, .fill7 {
fill: url(#light7);
}
.key8, .fill8 {
fill: url(#light8);
}
.key9, .fill9 {
fill: url(#light9);
}
.key10, .fill10 {
fill: url(#light10);
}
.key11, .fill11 {
fill: url(#light11);
}
.key12, .fill12 {
fill: url(#light12);
}
.light1 {
stop-color: #2a4269;
}
.light2 {
stop-color: #38588e;
}
.light3 {
stop-color: #476fb2;
}
.light4 {
stop-color: #698bc3;
}
.light5 {
stop-color: #00ccff;
}
.light6 {
stop-color: #ff00ff;
}
.light7 {
stop-color: #00ffff;
}
.light8 {
stop-color: #ffff00;
}
.light9 {
stop-color: #cc6666;
}
.light10 {
stop-color: #663399;
}
.light11 {
stop-color: #339900;
}
.light12 {
stop-color: #9966FF;
}

78
pygal/css/pie.css

@ -2,80 +2,10 @@
fill: #000000; fill: #000000;
text-anchor:middle; text-anchor:middle;
font-size: %(datapoint_font_size)spx; font-size: %(datapoint_font_size)spx;
font-family: "Arial", sans-serif; fill-opacity: 0;
font-weight: normal;
} }
/* key - MUST match fill styles */ .pie:hover .dataPointLabel {
.key1,.fill1{ fill-opacity: 0.9;
fill: #ff0000; fill: #000000;
fill-opacity: 0.7;
stroke: none;
stroke-width: 1px;
}
.key2,.fill2{
fill: #0000ff;
fill-opacity: 0.7;
stroke: none;
stroke-width: 1px;
}
.key3,.fill3{
fill-opacity: 0.7;
fill: #00ff00;
stroke: none;
stroke-width: 1px;
}
.key4,.fill4{
fill-opacity: 0.7;
fill: #ffcc00;
stroke: none;
stroke-width: 1px;
}
.key5,.fill5{
fill-opacity: 0.7;
fill: #00ccff;
stroke: none;
stroke-width: 1px;
}
.key6,.fill6{
fill-opacity: 0.7;
fill: #ff00ff;
stroke: none;
stroke-width: 1px;
}
.key7,.fill7{
fill-opacity: 0.7;
fill: #00ff99;
stroke: none;
stroke-width: 1px;
}
.key8,.fill8{
fill-opacity: 0.7;
fill: #ffff00;
stroke: none;
stroke-width: 1px;
}
.key9,.fill9{
fill-opacity: 0.7;
fill: #cc6666;
stroke: none;
stroke-width: 1px;
}
.key10,.fill10{
fill-opacity: 0.7;
fill: #663399;
stroke: none;
stroke-width: 1px;
}
.key11,.fill11{
fill-opacity: 0.7;
fill: #339900;
stroke: none;
stroke-width: 1px;
}
.key12,.fill12{
fill-opacity: 0.7;
fill: #9966FF;
stroke: none;
stroke-width: 1px;
} }

77
pygal/graph.py

@ -8,6 +8,7 @@ The base module for `pygal` classes.
from operator import itemgetter from operator import itemgetter
from itertools import islice from itertools import islice
from logging import getLogger
import os import os
from lxml import etree from lxml import etree
@ -16,6 +17,8 @@ from pygal.util.boundary import (calculate_right_margin, calculate_left_margin,
calculate_bottom_margin, calculate_top_margin, calculate_bottom_margin, calculate_top_margin,
calculate_offsets_bottom) calculate_offsets_bottom)
log = getLogger('pygal')
def sort_multiple(arrays): def sort_multiple(arrays):
"sort multiple lists (of equal size) " "sort multiple lists (of equal size) "
@ -145,6 +148,9 @@ class Graph(object):
Raises ValueError when no data set has Raises ValueError when no data set has
been added to the graph object. been added to the graph object.
""" """
log.info("Burning %s graph" % self.__class__.__name__)
if not self.data: if not self.data:
raise ValueError("No data available") raise ValueError("No data available")
@ -154,10 +160,12 @@ class Graph(object):
self.start_svg() self.start_svg()
self.calculate_graph_dimensions() self.calculate_graph_dimensions()
self.foreground = etree.Element("g") self.foreground = etree.Element("g")
self.draw_graph() self.draw_graph()
self.draw_titles() self.draw_titles()
self.draw_legend() self.draw_legend()
self.draw_data() self.draw_data()
self.graph.append(self.foreground) self.graph.append(self.foreground)
data = etree.tostring( data = etree.tostring(
@ -195,12 +203,12 @@ class Graph(object):
}) })
#Axis #Axis
node(self.back, 'path', { node(self.foreground, 'path', {
'd': 'M 0 0 v%s' % self.graph_height, 'd': 'M 0 0 v%s' % self.graph_height,
'class': 'axis', 'class': 'axis',
'id': 'xAxis' 'id': 'xAxis'
}) })
node(self.back, 'path', { node(self.foreground, 'path', {
'd': 'M 0 %s h%s' % (self.graph_height, self.graph_width), 'd': 'M 0 %s h%s' % (self.graph_height, self.graph_width),
'class': 'axis', 'class': 'axis',
'id': 'yAxis' 'id': 'yAxis'
@ -224,18 +232,23 @@ class Graph(object):
if style: if style:
e.set('style', style) e.set('style', style)
def x_label_offset(self, width):
return 0
def draw_x_labels(self): def draw_x_labels(self):
"Draw the X axis labels" "Draw the X axis labels"
if self.show_x_labels: if not self.show_x_labels:
self.xlabels = node(self.graph, 'g', {'class': 'xLabels'}) return
labels = self.get_x_labels()
count = len(labels)
labels = enumerate(iter(labels)) log.debug("Drawing x labels")
start = int(not self.step_include_first_x_label) self.xlabels = node(self.graph, 'g', {'class': 'xLabels'})
labels = islice(labels, start, None, self.step_x_labels) labels = self.get_x_labels()
map(self.draw_x_label, labels) count = len(labels)
self.draw_x_guidelines(self.field_width(), count) labels = enumerate(iter(labels))
start = int(not self.step_include_first_x_label)
labels = islice(labels, start, None, self.step_x_labels)
map(self.draw_x_label, labels)
self.draw_x_guidelines(self.field_width(), count)
def draw_x_label(self, label): def draw_x_label(self, label):
label_width = self.field_width() label_width = self.field_width()
@ -289,6 +302,8 @@ class Graph(object):
"Draw the Y axis labels" "Draw the Y axis labels"
if not self.show_y_labels: if not self.show_y_labels:
return return
log.debug("Drawing y labels")
self.ylabels = node(self.graph, 'g', {'class': 'yLabels'}) self.ylabels = node(self.graph, 'g', {'class': 'yLabels'})
labels = self.get_y_labels() labels = self.get_y_labels()
count = len(labels) count = len(labels)
@ -339,6 +354,7 @@ class Graph(object):
"Draw the X-axis guidelines" "Draw the X-axis guidelines"
if not self.show_x_guidelines: if not self.show_x_guidelines:
return return
log.debug("Drawing x guidelines")
self.xguidelines = node(self.graph, 'g', {'class': 'xGuideLines'}) self.xguidelines = node(self.graph, 'g', {'class': 'xGuideLines'})
# skip the first one # skip the first one
for count in range(1, count): for count in range(1, count):
@ -352,6 +368,7 @@ class Graph(object):
"Draw the Y-axis guidelines" "Draw the Y-axis guidelines"
if not self.show_y_guidelines: if not self.show_y_guidelines:
return return
log.debug("Drawing y guidelines")
self.yguidelines = node(self.graph, 'g', {'class': 'yGuideLines'}) self.yguidelines = node(self.graph, 'g', {'class': 'yGuideLines'})
for count in range(1, count): for count in range(1, count):
start = self.graph_height - label_height * count start = self.graph_height - label_height * count
@ -362,6 +379,7 @@ class Graph(object):
def draw_titles(self): def draw_titles(self):
"Draws the graph title and subtitle" "Draws the graph title and subtitle"
log.debug("Drawing titles")
if self.show_graph_title: if self.show_graph_title:
self.draw_graph_title() self.draw_graph_title()
if self.show_graph_subtitle: if self.show_graph_subtitle:
@ -390,6 +408,7 @@ class Graph(object):
text.text = self.graph_title text.text = self.graph_title
def draw_x_title(self): def draw_x_title(self):
log.debug("Drawing x title")
y = self.graph_height + self.border_top + self.x_title_font_size y = self.graph_height + self.border_top + self.x_title_font_size
if self.show_x_labels: if self.show_x_labels:
y_size = self.x_label_font_size + 5 y_size = self.x_label_font_size + 5
@ -406,6 +425,7 @@ class Graph(object):
text.text = self.x_title text.text = self.x_title
def draw_y_title(self): def draw_y_title(self):
log.debug("Drawing y title")
x = self.y_title_font_size x = self.y_title_font_size
if self.y_title_text_direction == 'bt': if self.y_title_text_direction == 'bt':
x += 3 x += 3
@ -428,6 +448,7 @@ class Graph(object):
def draw_legend(self): def draw_legend(self):
if not self.key: if not self.key:
return return
log.debug("Drawing legend")
group = node(self.root, 'g') group = node(self.root, 'g')
@ -457,9 +478,37 @@ class Graph(object):
""" """
Override and place code to add defs here. TODO: what are defs? Override and place code to add defs here. TODO: what are defs?
""" """
for id in range(12):
idn = 'light%d' % (id + 1)
light = node(defs, 'linearGradient', {
'id': idn,
'x1': 0,
'x2': '50%',
'y1': 0,
'y2': '100%'})
node(light, 'stop',
{'class': 'upGradientLight %s' % idn, 'offset': 0})
node(light, 'stop',
{'class': 'downGradientLight %s' % idn, 'offset': '100%'})
shadow = node(defs, 'linearGradient', {
'id': 'shadow',
'x1': 0,
'x2': '100%',
'y1': 0,
'y2': 0})
node(shadow, 'stop',
{'offset': 0, 'stop-color': '#aaa', 'stop-opacity': 0.7})
node(shadow, 'stop',
{'offset': '1%', 'stop-color': '#fff', 'stop-opacity': 1})
node(shadow, 'stop',
{'offset': '99%', 'stop-color': '#fff', 'stop-opacity': 1})
node(shadow, 'stop',
{'offset': '100%', 'stop-color': '#aaa', 'stop-opacity': .7})
def start_svg(self): def start_svg(self):
"Base SVG Document Creation" "Base SVG Document Creation"
log.debug("Creating root node")
svg_ns = 'http://www.w3.org/2000/svg' svg_ns = 'http://www.w3.org/2000/svg'
nsmap = { nsmap = {
None: svg_ns, None: svg_ns,
@ -490,8 +539,9 @@ class Graph(object):
' include default stylesheet if none specified ')) ' include default stylesheet if none specified '))
style = node(defs, 'style', type='text/css') style = node(defs, 'style', type='text/css')
style.text = '' style.text = ''
opts = dict(Graph.__dict__) opts = self.__dict__.copy()
opts.update(self.__dict__) opts.update(Graph.__dict__)
opts.update(self.__class__.__dict__)
for stylesheet in self.stylesheet_names: for stylesheet in self.stylesheet_names:
with open( with open(
os.path.join(os.path.dirname(__file__), 'css', os.path.join(os.path.dirname(__file__), 'css',
@ -507,6 +557,7 @@ class Graph(object):
'class': 'svgBackground'}) 'class': 'svgBackground'})
def calculate_graph_dimensions(self): def calculate_graph_dimensions(self):
log.debug("Computing sizes")
self.border_right = calculate_right_margin(self) self.border_right = calculate_right_margin(self)
self.border_top = calculate_top_margin(self) self.border_top = calculate_top_margin(self)
self.border_left = calculate_left_margin(self) self.border_left = calculate_left_margin(self)

59
pygal/pie.py

@ -46,11 +46,11 @@ class Pie(Graph):
""" """
"if true, displays a drop shadow for the chart" "if true, displays a drop shadow for the chart"
show_shadow = True show_shadow = False
"Sets the offset of the shadow from the pie chart" "Sets the offset of the shadow from the pie chart"
shadow_offset = 10 shadow_offset = 10
show_data_labels = False show_data_labels = True
"If true, display the actual field values in the data labels" "If true, display the actual field values in the data labels"
show_actual_values = False show_actual_values = False
@ -116,21 +116,21 @@ class Pie(Graph):
pairs = itertools.izip_longest(self.data, data_descriptor['data']) pairs = itertools.izip_longest(self.data, data_descriptor['data'])
self.data = list(itertools.starmap(robust_add, pairs)) self.data = list(itertools.starmap(robust_add, pairs))
def add_defs(self, defs): # def add_defs(self, defs):
"Add svg definitions" # "Add svg definitions"
node( # node(
defs, # defs,
'filter', # 'filter',
id='dropshadow', # id='dropshadow',
width='1.2', # width='1.2',
height='1.2', # height='1.2',
) # )
node( # node(
defs, # defs,
'feGaussianBlur', # 'feGaussianBlur',
stdDeviation='4', # stdDeviation='4',
result='blur', # result='blur',
) # )
def draw_graph(self): def draw_graph(self):
"Here we don't need the graph (consider refactoring)" "Here we don't need the graph (consider refactoring)"
@ -146,7 +146,6 @@ class Pie(Graph):
def keys(self): def keys(self):
total = sum(self.data) total = sum(self.data)
percent_scale = 100.0 / total
def key(field, value): def key(field, value):
result = [field] result = [field]
@ -177,10 +176,6 @@ class Pie(Graph):
transform = 'translate(%s %s)' % (xoff, yoff) transform = 'translate(%s %s)' % (xoff, yoff)
self.graph.set('transform', transform) self.graph.set('transform', transform)
wedge_text_pad = 5
wedge_text_pad = (20 * int(self.show_percent) *
int(self.show_data_labels))
total = sum(self.data) total = sum(self.data)
max_value = max(self.data) max_value = max(self.data)
@ -202,11 +197,13 @@ class Pie(Graph):
radius, radius, x_start, y_start, radius, radius, radius, radius, x_start, y_start, radius, radius,
percent_greater_fifty, x_end, y_end) percent_greater_fifty, x_end, y_end)
wedge_group = node(self.foreground, "g",
{'class': 'pie'})
wedge = node( wedge = node(
self.foreground, wedge_group,
'path', { 'path', {
'd': path, 'd': path,
'class': 'fill%s' % (index + 1)} 'class': 'fill fill%s' % (index + 1)}
) )
translate = None translate = None
@ -267,19 +264,7 @@ class Pie(Graph):
ty -= (mcr * self.expand_gap) ty -= (mcr * self.expand_gap)
label_node = node( label_node = node(
self.foreground, wedge_group,
'text',
{
'x': tx,
'y': ty,
'class': 'dataPointLabel',
'style': 'stroke: #fff; stroke-width: 2;'
}
)
label_node.text = label
label_node = node(
self.foreground,
'text', 'text',
{ {
'x': tx, 'x': tx,

59
test/moulinrouge/__init__.py

@ -3,7 +3,22 @@ from flask import Flask, Response, render_template, url_for
from log_colorizer import make_colored_stream_handler from log_colorizer import make_colored_stream_handler
from logging import getLogger, INFO, WARN, DEBUG from logging import getLogger, INFO, WARN, DEBUG
from moulinrouge.data import labels, series from moulinrouge.data import labels, series
from pygal.bar import VerticalBar from pygal.bar import VerticalBar, HorizontalBar
from pygal.pie import Pie
import string
import random
def random_label():
chars = string.letters + string.digits + u' àéèçêâäëï'
return ''.join(
[random.choice(chars)
for i in range(
random.randrange(4, 30))])
def random_value():
return random.randrange(0, 15, 1)
def generate_vbar(**opts): def generate_vbar(**opts):
@ -25,12 +40,46 @@ def create_app():
getLogger('werkzeug').addHandler(handler) getLogger('werkzeug').addHandler(handler)
getLogger('werkzeug').setLevel(INFO) getLogger('werkzeug').setLevel(INFO)
getLogger('pygal').addHandler(handler) getLogger('pygal').addHandler(handler)
getLogger('pygal').setLevel(INFO) getLogger('pygal').setLevel(DEBUG)
@app.route("/") @app.route("/")
def index(): def index():
return render_template('index.jinja2') return render_template('index.jinja2')
@app.route("/all-<type>.svg")
def all_svg(type):
width, height = 800, 600
series = random.randrange(1, 10)
data = random.randrange(1, 10)
labels = [random_label() for i in range(data)]
if type == 'vbar':
g = VerticalBar(labels)
elif type == 'hbar':
g = HorizontalBar(labels)
elif type == 'pie':
series = 1
g = Pie({'fields': labels})
g.width, g.height = width, height
for i in range(series):
values = [random_value() for i in range(data)]
g.add_data({'data': values, 'title': random_label()})
return Response(g.burn(), mimetype='image/svg+xml')
@app.route("/all")
def all():
width, height = 800, 600
svgs = [url_for('all_svg', type=type) for type in
('vbar', 'hbar', 'pie')]
return render_template('svgs.jinja2',
svgs=svgs,
width=width,
height=height)
@app.route("/rotation[<int:angle>].svg") @app.route("/rotation[<int:angle>].svg")
def rotation_svg(angle): def rotation_svg(angle):
return generate_vbar( return generate_vbar(
@ -40,8 +89,12 @@ def create_app():
@app.route("/rotation") @app.route("/rotation")
def rotation(): def rotation():
width, height = 375, 245
svgs = [url_for('rotation_svg', angle=angle) svgs = [url_for('rotation_svg', angle=angle)
for angle in range(0, 91, 5)] for angle in range(0, 91, 5)]
return render_template('svgs.jinja2', svgs=svgs) return render_template('svgs.jinja2',
svgs=svgs,
width=width,
height=height)
return app return app

2
test/moulinrouge/static/css.css

@ -4,8 +4,6 @@ html, body, section, figure {
} }
embed { embed {
width: 375px;
height: 245px;
float: left; float: left;
border: 1px solid #ccc; border: 1px solid #ccc;
} }

1
test/moulinrouge/templates/index.jinja2

@ -1,5 +1,6 @@
{% extends '_layout.jinja2' %} {% extends '_layout.jinja2' %}
{% block section %} {% block section %}
<a href="{{ url_for('all') }}">All types</a>
<a href="{{ url_for('rotation') }}">Rotations test</a> <a href="{{ url_for('rotation') }}">Rotations test</a>
{% endblock section %} {% endblock section %}

2
test/moulinrouge/templates/svgs.jinja2

@ -3,7 +3,7 @@
{% block section %} {% block section %}
{% for svg in svgs %} {% for svg in svgs %}
<figure> <figure>
<embed src="{{ svg }}" type="image/svg+xml" /> <embed src="{{ svg }}" type="image/svg+xml" width="{{ width }}" height="{{ height }}" />
<figcaption></figcaption> <figcaption></figcaption>
</figure> </figure>
{% endfor %} {% endfor %}

2
test/tests.py

@ -5,4 +5,4 @@ from moulinrouge import create_app
app = create_app() app = create_app()
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=True, threaded=True, port=21112) app.run(debug=True, threaded=True, host='0.0.0.0', port=21112)

Loading…
Cancel
Save