Skip to content

Commit 340570d

Browse files
authored
Templates (themes) integration (#1224)
* Add layout.template codegen and validation logic * Updated codegen to add support for elementdefaults properties. e.g. `layout.template.layout.annotationdefaults` * Added template acceptance/validation tests * Implementation of plotly.io.templates configuration object to supports registering/unregistering templates and setting default template * Added plotly.io.template tests * Added plotly.io.to_templated function. This inputs a figure and outputs a new figure where all eligible properties have been moved into the new figure's template definition * Added plotly.io.templates.merge_templates utility function * Support specifying flaglist of named templates to be merged together. e.g. fig.layout.template = 'template1+template2' * Added 5 built-in themes: 'ggplot2', 'seaborn', 'plotly', 'plotly_white', and 'plotly_dark' * Added 'presentation' template that can be used to increase the size of text and lines/markers for several trace types, and 'xgridoff' template to remove x-grid lines * Update orca tests to only compare EPS images. Something changed in CircleCI mid-development that broke the reproducibility of other image formats.
1 parent f69d9ca commit 340570d

File tree

366 files changed

+9364
-135
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

366 files changed

+9364
-135
lines changed

_plotly_utils/basevalidators.py

+61-3
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ def __init__(self, plotly_name, parent_name, role=None, **_):
206206
self.parent_name = parent_name
207207
self.plotly_name = plotly_name
208208
self.role = role
209+
self.array_ok = False
209210

210211
def description(self):
211212
"""
@@ -322,6 +323,8 @@ def __init__(self, plotly_name, parent_name, **kwargs):
322323
super(DataArrayValidator, self).__init__(
323324
plotly_name=plotly_name, parent_name=parent_name, **kwargs)
324325

326+
self.array_ok = True
327+
325328
def description(self):
326329
return ("""\
327330
The '{plotly_name}' property is an array that may be specified as a tuple,
@@ -1908,7 +1911,7 @@ def validate_coerce(self, v, skip_invalid=False):
19081911
v = self.data_class()
19091912

19101913
elif isinstance(v, dict):
1911-
v = self.data_class(skip_invalid=skip_invalid, **v)
1914+
v = self.data_class(v, skip_invalid=skip_invalid)
19121915

19131916
elif isinstance(v, self.data_class):
19141917
# Copy object
@@ -1976,8 +1979,8 @@ def validate_coerce(self, v, skip_invalid=False):
19761979
if isinstance(v_el, self.data_class):
19771980
res.append(self.data_class(v_el))
19781981
elif isinstance(v_el, dict):
1979-
res.append(self.data_class(skip_invalid=skip_invalid,
1980-
**v_el))
1982+
res.append(self.data_class(v_el,
1983+
skip_invalid=skip_invalid))
19811984
else:
19821985
if skip_invalid:
19831986
res.append(self.data_class())
@@ -2123,3 +2126,58 @@ def validate_coerce(self, v, skip_invalid=False):
21232126
self.raise_invalid_val(v)
21242127

21252128
return v
2129+
2130+
2131+
class BaseTemplateValidator(CompoundValidator):
2132+
2133+
def __init__(self,
2134+
plotly_name,
2135+
parent_name,
2136+
data_class_str,
2137+
data_docs,
2138+
**kwargs):
2139+
2140+
super(BaseTemplateValidator, self).__init__(
2141+
plotly_name=plotly_name,
2142+
parent_name=parent_name,
2143+
data_class_str=data_class_str,
2144+
data_docs=data_docs,
2145+
**kwargs
2146+
)
2147+
2148+
def description(self):
2149+
compound_description = super(BaseTemplateValidator, self).description()
2150+
compound_description += """
2151+
- The name of a registered template where current registered templates
2152+
are stored in the plotly.io.templates configuration object. The names
2153+
of all registered templates can be retrieved with:
2154+
>>> import plotly.io as pio
2155+
>>> list(pio.templates)
2156+
- A string containing multiple registered template names, joined on '+'
2157+
characters (e.g. 'template1+template2'). In this case the resulting
2158+
template is computed by merging together the collection of registered
2159+
templates"""
2160+
2161+
return compound_description
2162+
2163+
def validate_coerce(self, v, skip_invalid=False):
2164+
import plotly.io as pio
2165+
2166+
try:
2167+
# Check if v is a template identifier
2168+
# (could be any hashable object)
2169+
if v in pio.templates:
2170+
return copy.deepcopy(pio.templates[v])
2171+
# Otherwise, if v is a string, check to see if it consists of
2172+
# multiple template names joined on '+' characters
2173+
elif isinstance(v, string_types):
2174+
template_names = v.split('+')
2175+
if all([name in pio.templates for name in template_names]):
2176+
return pio.templates.merge_templates(*template_names)
2177+
2178+
except TypeError:
2179+
# v is un-hashable
2180+
pass
2181+
2182+
return super(BaseTemplateValidator, self).validate_coerce(
2183+
v, skip_invalid=skip_invalid)

codegen/__init__.py

+54-2
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
DEPRECATED_DATATYPES)
99
from codegen.figure import write_figure_classes
1010
from codegen.utils import (TraceNode, PlotlyNode, LayoutNode, FrameNode,
11-
write_init_py)
11+
write_init_py, ElementDefaultsNode)
1212
from codegen.validators import (write_validator_py,
1313
write_data_validator_py,
1414
get_data_validator_instance)
1515

16+
1617
# Import notes
1718
# ------------
1819
# Nothing from the plotly/ package should be imported during code
@@ -22,6 +23,52 @@
2223
# codegen/ package, and helpers used both during code generation and at
2324
# runtime should reside in the _plotly_utils/ package.
2425
# ----------------------------------------------------------------------------
26+
def preprocess_schema(plotly_schema):
27+
"""
28+
Central location to make changes to schema before it's seen by the
29+
PlotlyNode classes
30+
"""
31+
32+
# Update template
33+
# ---------------
34+
layout = plotly_schema['layout']['layoutAttributes']
35+
36+
# Create codegen-friendly template scheme
37+
template = {
38+
"data": {
39+
trace + 's': {
40+
'items': {
41+
trace: {
42+
},
43+
},
44+
"role": "object"
45+
}
46+
for trace in plotly_schema['traces']
47+
},
48+
"layout": {
49+
},
50+
"description": """\
51+
Default attributes to be applied to the plot.
52+
This should be a dict with format: `{'layout': layoutTemplate, 'data':
53+
{trace_type: [traceTemplate, ...], ...}}` where `layoutTemplate` is a dict
54+
matching the structure of `figure.layout` and `traceTemplate` is a dict
55+
matching the structure of the trace with type `trace_type` (e.g. 'scatter').
56+
Alternatively, this may be specified as an instance of
57+
plotly.graph_objs.layout.Template.
58+
59+
Trace templates are applied cyclically to
60+
traces of each type. Container arrays (eg `annotations`) have special
61+
handling: An object ending in `defaults` (eg `annotationdefaults`) is
62+
applied to each array item. But if an item has a `templateitemname`
63+
key we look in the template array for an item with matching `name` and
64+
apply that instead. If no matching `name` is found we mark the item
65+
invisible. Any named template item not referenced is appended to the
66+
end of the array, so this can be used to add a watermark annotation or a
67+
logo image, for example. To omit one of these items on the plot, make
68+
an item with matching `templateitemname` and `visible: false`."""
69+
}
70+
71+
layout['template'] = template
2572

2673

2774
def perform_codegen():
@@ -52,6 +99,10 @@ def perform_codegen():
5299
with open('plotly/package_data/plot-schema.json', 'r') as f:
53100
plotly_schema = json.load(f)
54101

102+
# Preprocess Schema
103+
# -----------------
104+
preprocess_schema(plotly_schema)
105+
55106
# Build node lists
56107
# ----------------
57108
# ### TraceNode ###
@@ -81,7 +132,8 @@ def perform_codegen():
81132
all_frame_nodes)
82133

83134
all_compound_nodes = [node for node in all_datatype_nodes
84-
if node.is_compound]
135+
if node.is_compound and
136+
not isinstance(node, ElementDefaultsNode)]
85137

86138
# Write out validators
87139
# --------------------

codegen/datatypes.py

+25-2
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,18 @@ def build_datatype_py(node):
6666
# ---------------
6767
assert node.is_compound
6868

69+
# Handle template traces
70+
# ----------------------
71+
# We want template trace/layout classes like
72+
# plotly.graph_objs.layout.template.data.Scatter to map to the
73+
# corresponding trace/layout class (e.g. plotly.graph_objs.Scatter).
74+
# So rather than generate a class definition, we just import the
75+
# corresponding trace/layout class
76+
if node.parent_path_str == 'layout.template.data':
77+
return f"from plotly.graph_objs import {node.name_datatype_class}"
78+
elif node.path_str == 'layout.template.layout':
79+
return "from plotly.graph_objs import Layout"
80+
6981
# Extract node properties
7082
# -----------------------
7183
undercase = node.name_undercase
@@ -244,7 +256,17 @@ def __init__(self""")
244256
# ----------------------------------""")
245257
for subtype_node in subtype_nodes:
246258
name_prop = subtype_node.name_property
247-
buffer.write(f"""
259+
if name_prop == 'template':
260+
# Special handling for layout.template to avoid infinite
261+
# recursion. Only initialize layout.template object if non-None
262+
# value specified
263+
buffer.write(f"""
264+
_v = arg.pop('{name_prop}', None)
265+
_v = {name_prop} if {name_prop} is not None else _v
266+
if _v is not None:
267+
self['{name_prop}'] = _v""")
268+
else:
269+
buffer.write(f"""
248270
_v = arg.pop('{name_prop}', None)
249271
self['{name_prop}'] = {name_prop} \
250272
if {name_prop} is not None else _v""")
@@ -264,7 +286,8 @@ def __init__(self""")
264286
self._props['{lit_name}'] = {lit_val}
265287
self._validators['{lit_name}'] =\
266288
LiteralValidator(plotly_name='{lit_name}',\
267-
parent_name='{lit_parent}', val={lit_val})""")
289+
parent_name='{lit_parent}', val={lit_val})
290+
arg.pop('{lit_name}', None)""")
268291

269292
buffer.write(f"""
270293

codegen/utils.py

+93-5
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ def format_description(desc):
174174
# Mapping from full property paths to custom validator classes
175175
CUSTOM_VALIDATOR_DATATYPES = {
176176
'layout.image.source': '_plotly_utils.basevalidators.ImageUriValidator',
177+
'layout.template': '_plotly_utils.basevalidators.BaseTemplateValidator',
177178
'frame.data': 'plotly.validators.DataValidator',
178179
'frame.layout': 'plotly.validators.LayoutValidator'
179180
}
@@ -257,9 +258,14 @@ def __init__(self, plotly_schema, node_path=(), parent=None):
257258
# Note the node_data is a property that must be computed by the
258259
# subclass based on plotly_schema and node_path
259260
if isinstance(self.node_data, dict_like):
261+
childs_parent = (
262+
parent
263+
if self.node_path and self.node_path[-1] == 'items'
264+
else self)
265+
260266
self._children = [self.__class__(self.plotly_schema,
261267
node_path=self.node_path + (c,),
262-
parent=self)
268+
parent=childs_parent)
263269
for c in self.node_data if c and c[0] != '_']
264270

265271
# Sort by plotly name
@@ -387,7 +393,15 @@ def name_property(self):
387393
-------
388394
str
389395
"""
390-
return self.plotly_name + ('s' if self.is_array_element else '')
396+
397+
return self.plotly_name + (
398+
's' if self.is_array_element and
399+
# Don't add 's' to layout.template.data.scatter etc.
400+
not (self.parent and
401+
self.parent.parent and
402+
self.parent.parent.parent and
403+
self.parent.parent.parent.name_property == 'template')
404+
else '')
391405

392406
@property
393407
def name_validator_class(self) -> str:
@@ -600,8 +614,8 @@ def is_array_element(self):
600614
-------
601615
bool
602616
"""
603-
if self.parent and self.parent.parent:
604-
return self.parent.parent.is_array
617+
if self.parent:
618+
return self.parent.is_array
605619
else:
606620
return False
607621

@@ -774,7 +788,16 @@ def child_datatypes(self):
774788
nodes = []
775789
for n in self.children:
776790
if n.is_array:
791+
# Add array element node
777792
nodes.append(n.children[0].children[0])
793+
794+
# Add elementdefaults node. Require parent_path_parts not
795+
# empty to avoid creating defaults classes for traces
796+
if (n.parent_path_parts and
797+
n.parent_path_parts != ('layout', 'template', 'data')):
798+
799+
nodes.append(ElementDefaultsNode(n, self.plotly_schema))
800+
778801
elif n.is_datatype:
779802
nodes.append(n)
780803

@@ -885,7 +908,11 @@ def get_all_compound_datatype_nodes(plotly_schema, node_class):
885908
if node.plotly_name and not node.is_array:
886909
nodes.append(node)
887910

888-
nodes_to_process.extend(node.child_compound_datatypes)
911+
non_defaults_compound_children = [
912+
node for node in node.child_compound_datatypes
913+
if not isinstance(node, ElementDefaultsNode)]
914+
915+
nodes_to_process.extend(non_defaults_compound_children)
889916

890917
return nodes
891918

@@ -1088,3 +1115,64 @@ def node_data(self) -> dict:
10881115
node_data = node_data[prop_name]
10891116

10901117
return node_data
1118+
1119+
1120+
class ElementDefaultsNode(PlotlyNode):
1121+
1122+
def __init__(self, array_node, plotly_schema):
1123+
"""
1124+
Create node that represents element defaults properties
1125+
(e.g. layout.annotationdefaults). Construct as a wrapper around the
1126+
corresponding array property node (e.g. layout.annotations)
1127+
1128+
Parameters
1129+
----------
1130+
array_node: PlotlyNode
1131+
"""
1132+
super().__init__(plotly_schema,
1133+
node_path=array_node.node_path,
1134+
parent=array_node.parent)
1135+
1136+
assert array_node.is_array
1137+
self.array_node = array_node
1138+
self.element_node = array_node.children[0].children[0]
1139+
1140+
@property
1141+
def node_data(self):
1142+
return {}
1143+
1144+
@property
1145+
def description(self):
1146+
array_property_path = (self.parent_path_str +
1147+
'.' + self.array_node.name_property)
1148+
1149+
if isinstance(self.array_node, TraceNode):
1150+
data_path = 'data.'
1151+
else:
1152+
data_path = ''
1153+
1154+
defaults_property_path = ('layout.template.' +
1155+
data_path +
1156+
self.parent_path_str +
1157+
'.' + self.plotly_name)
1158+
return f"""\
1159+
When used in a template
1160+
(as {defaults_property_path}),
1161+
sets the default property values to use for elements
1162+
of {array_property_path}"""
1163+
1164+
@property
1165+
def name_base_datatype(self):
1166+
return self.element_node.name_base_datatype
1167+
1168+
@property
1169+
def root_name(self):
1170+
return self.array_node.root_name
1171+
1172+
@property
1173+
def plotly_name(self):
1174+
return self.element_node.plotly_name + 'defaults'
1175+
1176+
@property
1177+
def name_datatype_class(self):
1178+
return self.element_node.name_datatype_class

optional-requirements.txt

+3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ psutil
2424
## codegen dependencies ##
2525
yapf
2626

27+
## template generation ##
28+
colorcet
29+
2730
## ipython ##
2831
ipython
2932

0 commit comments

Comments
 (0)