Skip to content

Commit 2a6cfb2

Browse files
committed
[general] Refactored backends, renderers, added converters #70
This is the first step in implementing #70. This patch does not introduce changes of behaviour in the library, infact tests remain unaltered, except for their names (because they were named after renderer classes which now are a much smaller part of the library). This patch introduces the concept of converter classes, which are responsible for converting the NetJSON configuration dictionary to an intermediate data structure that is then used by renderers to be rendered. The backward conversion will then implement the reverse process: Parsers will be responsible of converting native configuration to the intermediate data structure; Converters will have a to_netjson method that will be responsible of converting the intermediate data structure to the NetJSON configuration dictionary.
1 parent d340d33 commit 2a6cfb2

32 files changed

Lines changed: 515 additions & 569 deletions
File renamed without changes.
Lines changed: 33 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
11
import gzip
22
import json
33
import tarfile
4+
from collections import OrderedDict
45
from copy import deepcopy
56
from io import BytesIO
67

78
import six
8-
from jinja2 import Environment, PackageLoader
99
from jsonschema import FormatChecker, validate
1010
from jsonschema.exceptions import ValidationError as JsonSchemaError
1111

12-
from ..exceptions import ValidationError
13-
from ..schema import DEFAULT_FILE_MODE
14-
from ..utils import evaluate_vars, merge_config
12+
from ...exceptions import ValidationError
13+
from ...schema import DEFAULT_FILE_MODE
14+
from ...utils import evaluate_vars, merge_config
1515

1616

1717
class BaseBackend(object):
1818
"""
1919
Base Backend class
2020
"""
2121
schema = None
22-
env_path = 'netjsonconfig.backends.base'
2322
FILE_SECTION_DELIMITER = '# ---------- files ---------- #'
23+
intermediate_data = None
2424

2525
def __init__(self, config, templates=[], context={}):
2626
"""
@@ -35,8 +35,6 @@ def __init__(self, config, templates=[], context={}):
3535
config = deepcopy(self._load(config))
3636
self.config = self._merge_config(config, templates)
3737
self.config = self._evaluate_vars(self.config, context)
38-
self.env = Environment(loader=PackageLoader(self.env_path, 'templates'),
39-
trim_blocks=True)
4038

4139
def _load(self, config):
4240
"""
@@ -113,29 +111,23 @@ def render(self, files=True):
113111
:returns: string with output
114112
"""
115113
self.validate()
116-
output = ''
117-
# iterate over the available renderers
118-
# to build the target configuration
119-
# from the configuration dictionary
120-
for renderer_class in self.renderers:
121-
renderer = renderer_class(self)
122-
additional_output = renderer.render()
123-
# add an additional new line
124-
# to separate blocks of configuration
125-
# generated by different renderers
126-
if output and additional_output:
127-
output += '\n'
128-
# concatenate the render configuration
129-
output += additional_output
130-
114+
# convert NetJSON config to intermediate data structure
115+
if self.intermediate_data is None:
116+
self.to_intermediate()
117+
# render intermediate data structure into native configuration
118+
renderer = self.renderer(self)
119+
output = renderer.render()
120+
# remove reference to renderer instance (not needed anymore)
121+
del renderer
131122
# are we required to include
132123
# additional files?
133124
if files:
134125
# render additional files
135126
files_output = self._render_files()
136127
if files_output:
137-
output += files_output.replace('\n\n\n', '\n\n') # max 3 \n
138-
# finally return the whole configuration
128+
# max 2 new lines
129+
output += files_output.replace('\n\n\n', '\n\n')
130+
# return the configuration
139131
return output
140132

141133
def json(self, validate=True, *args, **kwargs):
@@ -235,59 +227,22 @@ def _add_file(self, tar, name, contents, mode=DEFAULT_FILE_MODE):
235227
info.mode = int(mode, 8) # permissions converted to decimal notation
236228
tar.addfile(tarinfo=info, fileobj=byte_contents)
237229

238-
239-
class BaseRenderer(object):
240-
"""
241-
Renderers are used to generate specific configuration blocks.
242-
"""
243-
block_name = None
244-
245-
def __init__(self, backend):
246-
self.config = backend.config
247-
self.env = backend.env
248-
self.backend = backend
249-
250-
@classmethod
251-
def get_name(cls):
230+
def to_intermediate(self):
252231
"""
253-
Get the name of the rendered without prefix
232+
Converts the NetJSON configuration dictionary (self.config)
233+
to the intermediate data structure (self.intermediate_data) that will
234+
be then used by the renderer class to generate the router configuration
254235
"""
255-
return str(cls.__name__).replace('Renderer', '').lower()
256-
257-
def cleanup(self, output):
258-
"""
259-
Performs cleanup of output (indentation, new lines)
260-
261-
:param output: string representation of the client configuration
262-
"""
263-
return output
264-
265-
def render(self):
266-
"""
267-
Renders config block with jinja2 templating engine
268-
"""
269-
# get jinja2 template
270-
template_name = '{0}.jinja2'.format(self.get_name())
271-
template = self.env.get_template(template_name)
272-
# render template and cleanup
273-
context = self.get_context()
274-
output = template.render(**context)
275-
return self.cleanup(output)
276-
277-
def get_context(self):
278-
"""
279-
Builds context dictionary to be used in jinja2 templates
280-
For every method prefixed with `__get`, such as ``_get_name``, creates an entry in
281-
the context dictionary whose key is ``name`` and it's value
282-
it the output of ``_get_name``
283-
"""
284-
# get list of private methods that start with "_get_"
285-
methods = [method for method in dir(self) if method.startswith('_get_')]
286-
context = {}
287-
# build context
288-
for method in methods:
289-
key = method.replace('_get_', '')
290-
context[key] = getattr(self, method)()
291-
# determine if all context values are empty
292-
context['is_empty'] = not any(context.values())
293-
return context
236+
self.validate()
237+
data = OrderedDict()
238+
for converter_class in self.converters:
239+
# skip unnecessary loop cycles
240+
if not converter_class.should_run(self.config):
241+
continue
242+
converter = converter_class(self)
243+
for item in converter.to_intermediate():
244+
key, value = item
245+
if value:
246+
data.setdefault(key, [])
247+
data[key] += value
248+
self.intermediate_data = data
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
class BaseConverter(object):
2+
"""
3+
Base Converter class
4+
Converters are used to convert a configuration dictionary
5+
which represent a NetJSON object to a data structure that
6+
can be easily rendered as the final router configuration
7+
and vice versa.
8+
"""
9+
netjson_key = None
10+
11+
def __init__(self, backend):
12+
self.backend = backend
13+
self.netjson = backend.config
14+
self.intermediate_data = backend.intermediate_data
15+
16+
@classmethod
17+
def should_run(cls, config):
18+
"""
19+
Returns True if Converter should be instantiated and run
20+
Used to skip processing if the configuration part related to
21+
the converter is not present in the configuration dictionary.
22+
"""
23+
netjson_key = cls.netjson_key or cls.__name__.lower()
24+
return netjson_key in config
25+
26+
def to_intermediate(self): # pragma: no cover
27+
raise NotImplementedError()
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from jinja2 import Environment, PackageLoader
2+
3+
4+
class BaseRenderer(object):
5+
"""
6+
Base Renderer class
7+
Renderers are used to generate a string
8+
which represents the router configuration
9+
"""
10+
def __init__(self, backend):
11+
self.config = backend.config
12+
self.backend = backend
13+
14+
@property
15+
def env_path(self):
16+
return self.__module__
17+
18+
@property
19+
def template_env(self):
20+
return Environment(loader=PackageLoader(self.env_path, 'templates'), trim_blocks=True)
21+
22+
@classmethod
23+
def get_name(cls):
24+
"""
25+
Returns the name of the render class without its prefix
26+
"""
27+
return str(cls.__name__).replace('Renderer', '').lower()
28+
29+
def cleanup(self, output):
30+
"""
31+
Performs cleanup of output (indentation, new lines)
32+
33+
:param output: string representation of the client configuration
34+
"""
35+
return output
36+
37+
def render(self):
38+
"""
39+
Renders configuration by using the jinja2 templating engine
40+
"""
41+
# get jinja2 template
42+
template_name = '{0}.jinja2'.format(self.get_name())
43+
template = self.template_env.get_template(template_name)
44+
# render template and cleanup
45+
context = getattr(self.backend, 'intermediate_data', {})
46+
output = template.render(data=context)
47+
return self.cleanup(output)
Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,15 @@
1-
from copy import deepcopy
1+
from ...utils import get_copy, sorted_dict
2+
from ..base.converter import BaseConverter
23

3-
from ...utils import sorted_dict
4-
from ..base import BaseRenderer
54

5+
class OpenVpn(BaseConverter):
6+
def to_intermediate(self):
7+
result = []
8+
for vpn in get_copy(self.netjson, 'openvpn'):
9+
result.append(sorted_dict(self.__get_vpn(vpn)))
10+
return (('openvpn', result),)
611

7-
class OpenVpnRenderer(BaseRenderer):
8-
"""
9-
Produces an OpenVPN configuration string
10-
"""
11-
def cleanup(self, output):
12-
# remove indentations
13-
output = output.replace(' ', '')
14-
# remove last newline
15-
if output.endswith('\n\n'):
16-
output = output[0:-1]
17-
return output
18-
19-
def _transform_vpn(self, vpn):
20-
config = deepcopy(vpn)
12+
def __get_vpn(self, config):
2113
skip_keys = ['script_security', 'remote']
2214
delete_keys = []
2315
# allow server_bridge to be empty and still rendered
@@ -41,10 +33,3 @@ def _transform_vpn(self, vpn):
4133
if 'status' not in config and 'status_version' in config:
4234
del config['status_version']
4335
return config
44-
45-
def _get_openvpn(self):
46-
openvpn = []
47-
for vpn in self.config.get('openvpn', []):
48-
config = self._transform_vpn(vpn)
49-
openvpn.append(sorted_dict(config))
50-
return openvpn

netjsonconfig/backends/openvpn/openvpn.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import re
22

3-
from ..base import BaseBackend
4-
from .renderers import OpenVpnRenderer
3+
from . import converters
4+
from ..base.backend import BaseBackend
5+
from .renderer import OpenVpnRenderer
56
from .schema import schema
67

78

89
class OpenVpn(BaseBackend):
910
"""
10-
OpenVPN 2.3 backend
11+
OpenVPN 2.x Configuration Backend
1112
"""
1213
schema = schema
13-
env_path = 'netjsonconfig.backends.openvpn'
14-
renderers = [OpenVpnRenderer]
14+
converters = [converters.OpenVpn]
15+
renderer = OpenVpnRenderer
1516
VPN_REGEXP = re.compile('# openvpn config: ')
1617

1718
def _generate_contents(self, tar):
@@ -26,7 +27,7 @@ def _generate_contents(self, tar):
2627
vpn_instances = self.VPN_REGEXP.split(text)
2728
if '' in vpn_instances:
2829
vpn_instances.remove('')
29-
# for each package create a file with its contents in /etc/config
30+
# create a file for each VPN
3031
for vpn in vpn_instances:
3132
lines = vpn.split('\n')
3233
vpn_name = lines[0]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from ..base.renderer import BaseRenderer
2+
3+
4+
class OpenVpnRenderer(BaseRenderer):
5+
"""
6+
OpenVPN Renderer
7+
"""
8+
def cleanup(self, output):
9+
# remove indentations
10+
output = output.replace(' ', '')
11+
# remove last newline
12+
if output.endswith('\n\n'):
13+
output = output[0:-1]
14+
return output

netjsonconfig/backends/openvpn/templates/openvpn.jinja2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{% for vpn in openvpn %}
1+
{% for vpn in data.openvpn %}
22
# openvpn config: {{ vpn.pop('name') }}
33

44
{% for key, value in vpn.items() %}

netjsonconfig/backends/openwisp/openwisp.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77

88

99
class OpenWisp(OpenWrt):
10-
""" OpenWisp 1.x Backend """
10+
"""
11+
OpenWISP 1.x Firmware (legacy) Configuration Backend
12+
"""
1113
schema = schema
12-
openwisp_env = Environment(loader=PackageLoader('netjsonconfig.backends.openwisp', 'templates'),
13-
trim_blocks=True)
1414

1515
def validate(self):
1616
self._sanitize_radios()
@@ -26,7 +26,9 @@ def _sanitize_radios(self):
2626
radio.setdefault('disabled', False)
2727

2828
def _render_template(self, template, context={}):
29-
template = self.openwisp_env.get_template(template)
29+
openwisp_env = Environment(loader=PackageLoader(self.__module__, 'templates'),
30+
trim_blocks=True)
31+
template = openwisp_env.get_template(template)
3032
return template.render(**context)
3133

3234
def _add_unique_file(self, item):
@@ -140,14 +142,6 @@ def _add_tc_script(self):
140142
"mode": "755"
141143
})
142144

143-
def generate(self):
144-
"""
145-
Generates an openwisp configuration archive.
146-
147-
:returns: in-memory tar.gz archive, instance of ``BytesIO``
148-
"""
149-
return super(OpenWisp, self).generate()
150-
151145
def _generate_contents(self, tar):
152146
"""
153147
Adds configuration files to tarfile instance.
@@ -160,7 +154,7 @@ def _generate_contents(self, tar):
160154
packages = re.split('package ', uci)
161155
if '' in packages:
162156
packages.remove('')
163-
# for each package create a file with its contents in /etc/config
157+
# create a file for each configuration package used
164158
for package in packages:
165159
lines = package.split('\n')
166160
package_name = lines[0]

0 commit comments

Comments
 (0)