Settings.py 23.5 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
# -*- coding: utf-8 -*-
"""
$Id$

Copyright 2010 Lars Kruse <devel@sumpfralle.de>

This file is part of PyCAM.

PyCAM is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

PyCAM is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with PyCAM.  If not, see <http://www.gnu.org/licenses/>.
"""

23
from pycam.Toolpath import Bounds
24
import pycam.Cutters
25
import pycam.Utils.log
26
import pycam.Utils
27
import pycam.Toolpath
28 29 30 31 32 33
import ConfigParser
import StringIO
import os

CONFIG_DIR = "pycam"

34 35
log = pycam.Utils.log.get_logger()

36

37 38 39 40 41
def get_config_dirname():
    try:
        from win32com.shell import shellcon, shell            
        homedir = shell.SHGetFolderPath(0, shellcon.CSIDL_APPDATA, 0, 0)
        config_dir = os.path.join(homedir, CONFIG_DIR)
sumpfralle's avatar
sumpfralle committed
42 43
    except ImportError:
        # quick semi-nasty fallback for non-windows/win32com case
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
        homedir = os.path.expanduser("~")
        # hide the config directory for unixes
        config_dir = os.path.join(homedir, "." + CONFIG_DIR)
    if not os.path.isdir(config_dir):
        try:
            os.makedirs(config_dir)
        except OSError:
            config_dir = None
    return config_dir

def get_config_filename(filename=None):
    if filename is None:
        filename = "preferences.conf"
    config_dir = get_config_dirname()
    if config_dir is None:
        return None
    else:
        return os.path.join(config_dir, filename)


64
class Settings(dict):
65 66 67 68

    GET_INDEX = 0
    SET_INDEX = 1
    VALUE_INDEX = 2
69 70 71 72 73 74

    def __getitem_orig(self, key):
        return super(Settings, self).__getitem__(key)

    def __setitem_orig(self, key, value):
        super(Settings, self).__setitem__(key, value)
75 76

    def add_item(self, key, get_func=None, set_func=None):
77
        self.__setitem_orig(key, [None, None, None])
78 79
        self.define_get_func(key, get_func)
        self.define_set_func(key, set_func)
80
        self.__getitem_orig(key)[self.VALUE_INDEX] = None
81

82 83 84 85 86 87 88 89
    def set(self, key, value):
        self[key] = value

    def get(self, key, default=None):
        try:
            return self.__getitem__(key)
        except KeyError:
            return default
90

91
    def define_get_func(self, key, get_func=None):
92
        if not self.has_key(key):
93 94
            return
        if get_func is None:
95 96
            get_func = lambda: self.__getitem_orig(key)[self.VALUE_INDEX]
        self.__getitem_orig(key)[self.GET_INDEX] = get_func
97 98

    def define_set_func(self, key, set_func=None):
99
        if not self.has_key(key):
100 101
            return
        def default_set_func(value):
102
            self.__getitem_orig(key)[self.VALUE_INDEX] = value
103 104
        if set_func is None:
            set_func = default_set_func
105
        self.__getitem_orig(key)[self.SET_INDEX] = set_func
106

107 108 109 110 111 112
    def __getitem__(self, key):
        try:
            return self.__getitem_orig(key)[self.GET_INDEX]()
        except TypeError, err_msg:
            log.info("Failed to retrieve setting '%s': %s" % (key, err_msg))
            return None
113

114 115
    def __setitem__(self, key, value):
        if not self.has_key(key):
116
            self.add_item(key)
117 118
        self.__getitem_orig(key)[self.SET_INDEX](value)
        self.__getitem_orig(key)[self.VALUE_INDEX] = value
119

120

121
class ProcessSettings(object):
122

123 124 125
    BASIC_DEFAULT_CONFIG = """
[ToolDefault]
shape: CylindricalCutter
126
name: Cylindrical
127 128 129 130 131 132 133 134
tool_radius: 1.5
torus_radius: 0.25
feedrate: 200
speed: 1000

[ProcessDefault]
name: Remove material
engrave_offset: 0.0
135 136 137 138
path_strategy: PushCutter
path_direction: x
milling_style: ignore
material_allowance: 0.0
139 140
step_down: 3.0
overlap_percent: 0
141
pocketing_type: none
142 143 144 145 146 147 148 149 150 151

[BoundsDefault]
name: No Margin
type: relative_margin
x_low: 0.0
x_high: 0.0
y_low: 0.0
y_high: 0.0
z_low: 0.0
z_high: 0.0
152 153 154 155 156 157 158

[TaskDefault]
name: Default
enabled: yes
tool: 0
process: 0
bounds: 0
159 160
"""

161 162 163
    DEFAULT_CONFIG = """
[ToolDefault]
torus_radius: 0.25
164 165
feedrate: 200
speed: 1000
166 167

[Tool0]
168
name: Cylindrical
169
shape: CylindricalCutter
170
tool_radius: 1.5
171 172

[Tool1]
173
name: Toroidal
174
shape: ToroidalCutter
175
tool_radius: 1
176 177 178
torus_radius: 0.2

[Tool2]
179
name: Spherical
180
shape: SphericalCutter
181
tool_radius: 0.5
182 183 184

[ProcessDefault]
path_direction: x
185 186
path_strategy: SurfaceStrategy
milling_style: ignore
187
engrave_offset: 0.0
188 189
step_down: 3.0
material_allowance: 0.0
190 191
overlap_percent: 0
pocketing_type: none
192 193 194

[Process0]
name: Remove material
195
path_strategy: PushRemoveStrategy
196 197 198 199 200
material_allowance: 0.5
step_down: 3.0

[Process1]
name: Carve contour
201
path_strategy: ContourFollowStrategy
202 203
material_allowance: 0.2
step_down: 1.5
204
milling_style: conventional
205 206 207

[Process2]
name: Cleanup
208
path_strategy: SurfaceStrategy
209 210 211 212 213
material_allowance: 0.0
overlap_percent: 60

[Process3]
name: Gravure
214
path_strategy: EngraveStrategy
215
step_down: 1.0
216
milling_style: conventional
217
pocketing_type: none
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232

[BoundsDefault]
type: relative_margin
x_low: 0.0
x_high: 0.0
y_low: 0.0
y_high: 0.0
z_low: 0.0
z_high: 0.0

[Bounds0]
name: Minimum

[Bounds1]
name: 10% margin
233 234 235 236
x_low: 0.10
x_high: 0.10
y_low: 0.10
y_high: 0.10
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271

[TaskDefault]
enabled: yes
bounds: 1

[Task0]
name: Rough
tool: 0
process: 0

[Task1]
name: Semi-finish
tool: 1
process: 1

[Task2]
name: Finish
tool: 2
process: 2

[Task3]
name: Gravure
enabled: no
tool: 2
process: 3

"""

    SETTING_TYPES = {
            "name": str,
            "shape": str,
            "tool_radius": float,
            "torus_radius": float,
            "speed": float,
            "feedrate": float,
272
            "path_strategy": str,
273
            "path_direction": str,
274
            "milling_style": str,
275 276 277 278
            "material_allowance": float,
            "overlap_percent": int,
            "step_down": float,
            "engrave_offset": float,
279
            "pocketing_type": str,
280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295
            "tool": object,
            "process": object,
            "bounds": object,
            "enabled": bool,
            "type": str,
            "x_low": float,
            "x_high": float,
            "y_low": float,
            "y_high": float,
            "z_low": float,
            "z_high": float,
    }

    CATEGORY_KEYS = {
            "tool": ("name", "shape", "tool_radius", "torus_radius", "feedrate",
                    "speed"),
296 297
            "process": ("name", "path_strategy", "path_direction",
                    "milling_style", "material_allowance",
298 299
                    "overlap_percent", "step_down", "engrave_offset",
                    "pocketing_type"),
300 301 302 303 304 305 306 307 308 309 310 311 312
            "bounds": ("name", "type", "x_low", "x_high", "y_low",
                    "y_high", "z_low", "z_high"),
            "task": ("name", "tool", "process", "bounds", "enabled"),
    }

    SECTION_PREFIXES = {
        "tool": "Tool",
        "process": "Process",
        "task": "Task",
        "bounds": "Bounds",
    }

    DEFAULT_SUFFIX = "Default"
313
    REFERENCE_TAG = "_reference_"
314 315 316 317 318 319 320 321 322 323 324 325

    def __init__(self):
        self.config = None
        self._cache = {}
        self.reset()

    def reset(self, config_text=None):
        self._cache = {}
        self.config = ConfigParser.SafeConfigParser()
        if config_text is None:
            config_text = StringIO.StringIO(self.DEFAULT_CONFIG)
        else:
326 327 328 329 330
            # Read the basic default config first - in case some new options
            # are missing in an older config file.
            basic_default_config = StringIO.StringIO(self.BASIC_DEFAULT_CONFIG)
            self.config.readfp(basic_default_config)
            # Read the real config afterwards.
331 332 333 334
            config_text = StringIO.StringIO(config_text)
        self.config.readfp(config_text)

    def load_file(self, filename):
335
        uri = pycam.Utils.URIHandler(filename)
336
        try:
337 338
            handle = uri.open()
            content = handle.read()
339 340
        except IOError, err_msg:
            log.error("Settings: Failed to read config file '%s': %s" \
341
                    % (uri, err_msg))
342 343 344
            return False
        try:
            self.reset(content)
345
        except ConfigParser.ParsingError, err_msg:
346
            log.error("Settings: Failed to parse config file '%s': %s" \
347
                    % (uri, err_msg))
348 349 350 351 352 353 354 355
            return False
        return True

    def load_from_string(self, config_text):
        input_text = StringIO.StringIO(config_text)
        try:
            self.reset(input_text)
        except ConfigParser.ParsingError, err_msg:
356 357
            log.error("Settings: Failed to parse config data: %s" % \
                    str(err_msg))
358 359 360
            return False
        return True

sumpfralle's avatar
sumpfralle committed
361 362
    def write_to_file(self, filename, tools=None, processes=None, bounds=None,
            tasks=None):
363
        uri = pycam.Utils.URIHandler(filename)
364 365
        text = self.get_config_text(tools, processes, bounds, tasks)
        try:
366
            handle = open(uri.get_local_path(), "w")
sumpfralle's avatar
sumpfralle committed
367 368
            handle.write(text)
            handle.close()
369
        except IOError, err_msg:
370 371
            log.error("Settings: Failed to write configuration to file " \
                    + "(%s): %s" % (filename, err_msg))
372 373 374 375 376 377 378 379 380
            return False
        return True

    def get_tools(self):
        return self._get_category_items("tool")

    def get_processes(self):
        return self._get_category_items("process")

381
    def _get_bounds_instance_from_dict(self, indict):
382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399
        """ get Bounds instances for each bounds definition
        @value model: the model that should be used for relative margins
        @type model: pycam.Geometry.Model.Model or callable
        @returns: list of Bounds instances
        @rtype: list(Bounds)
        """
        low_bounds = (indict["x_low"], indict["y_low"], indict["z_low"])
        high_bounds = (indict["x_high"], indict["y_high"], indict["z_high"])
        if indict["type"] == "relative_margin":
            bounds_type = Bounds.TYPE_RELATIVE_MARGIN
        elif indict["type"] == "fixed_margin":
            bounds_type = Bounds.TYPE_FIXED_MARGIN
        else:
            bounds_type = Bounds.TYPE_CUSTOM
        new_bound = Bounds(bounds_type, low_bounds, high_bounds)
        new_bound.set_name(indict["name"])
        return new_bound

400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417
    def get_bounds(self):
        return self._get_category_items("bounds")

    def get_tasks(self):
        return self._get_category_items("task")

    def _get_category_items(self, type_name):
        if not self._cache.has_key(type_name):
            item_list = []
            index = 0
            prefix = self.SECTION_PREFIXES[type_name]
            current_section_name = "%s%d" % (prefix, index)
            while current_section_name in self.config.sections():
                item = {}
                for key in self.CATEGORY_KEYS[type_name]:
                    value_type = self.SETTING_TYPES[key]
                    raw = value_type == str
                    try:
sumpfralle's avatar
sumpfralle committed
418 419
                        value_raw = self.config.get(current_section_name, key,
                                raw=raw)
420 421
                    except ConfigParser.NoOptionError:
                        try:
422 423
                            try:
                                value_raw = self.config.get(
sumpfralle's avatar
sumpfralle committed
424 425
                                        prefix + self.DEFAULT_SUFFIX, key,
                                        raw=raw)
426 427 428
                            except (ConfigParser.NoSectionError,
                                    ConfigParser.NoOptionError):
                                value_raw = None
429 430 431 432 433 434
                        except ConfigParser.NoOptionError:
                            value_raw = None
                    if not value_raw is None:
                        try:
                            if value_type == object:
                                # try to get the referenced object
sumpfralle's avatar
sumpfralle committed
435 436
                                value = self._get_category_items(key)[
                                        int(value_raw)]
437
                            elif value_type == bool:
sumpfralle's avatar
sumpfralle committed
438 439
                                if value_raw.lower() in (
                                        "1", "true", "yes", "on"):
440 441 442 443 444 445 446 447 448 449
                                    value = True
                                else:
                                    value = False
                            else:
                                # just do a simple type cast
                                value = value_type(value_raw)
                        except (ValueError, IndexError):
                            value = None
                        if not value is None:
                            item[key] = value
450 451
                if type_name == "bounds":
                    # don't add the pure dictionary, but the "bounds" instance
452
                    item_list.append(self._get_bounds_instance_from_dict(item))
453 454
                else:
                    item_list.append(item)
455 456 457 458 459 460 461 462 463 464 465 466 467 468 469
                index += 1
                current_section_name = "%s%d" % (prefix, index)
            self._cache[type_name] = item_list
        return self._cache[type_name][:]

    def _value_to_string(self, lists, key, value):
        value_type = self.SETTING_TYPES[key]
        if value_type == bool:
            if value:
                return "1"
            else:
                return "0"
        elif value_type == object:
            try:
                return lists[key].index(value)
470
            except ValueError:
471 472 473 474 475
                # special handling for non-direct object references ("bounds")
                for index, item in enumerate(lists[key]):
                    if (self.REFERENCE_TAG in item) \
                            and (value is item[self.REFERENCE_TAG]):
                        return index
476 477 478 479
                return None
        else:
            return str(value_type(value))

sumpfralle's avatar
sumpfralle committed
480 481
    def get_config_text(self, tools=None, processes=None, bounds=None,
            tasks=None):
sumpfralle's avatar
sumpfralle committed
482
        def get_dictionary_of_bounds(boundary):
483 484
            """ this function should be the inverse operation of 
            '_get_bounds_instance_from_dict'
485 486
            """
            result = {}
sumpfralle's avatar
sumpfralle committed
487 488
            result["name"] = boundary.get_name()
            bounds_type_num = boundary.get_type()
489 490 491 492 493 494 495
            if bounds_type_num == Bounds.TYPE_RELATIVE_MARGIN:
                bounds_type_name = "relative_margin"
            elif bounds_type_num == Bounds.TYPE_FIXED_MARGIN:
                bounds_type_name = "fixed_margin"
            else:
                bounds_type_name = "custom"
            result["type"] = bounds_type_name
sumpfralle's avatar
sumpfralle committed
496
            low, high = boundary.get_bounds()
497
            for index, axis in enumerate("xyz"):
498 499
                result["%s_low" % axis] = low[index]
                result["%s_high" % axis] = high[index]
500
            # special handler to allow tasks to track this new object
sumpfralle's avatar
sumpfralle committed
501
            result[self.REFERENCE_TAG] = boundary
502
            return result
503 504 505 506 507 508 509 510 511 512 513 514
        result = []
        if tools is None:
            tools = []
        if processes is None:
            processes = []
        if bounds is None:
            bounds = []
        if tasks is None:
            tasks = []
        lists = {}
        lists["tool"] = tools
        lists["process"] = processes
515
        lists["bounds"] = [get_dictionary_of_bounds(b) for b in bounds]
516 517 518 519 520 521 522 523
        lists["task"] = tasks
        for type_name in lists.keys():
            type_list = lists[type_name]
            # generate "Default" section
            common_keys = []
            for key in self.CATEGORY_KEYS[type_name]:
                try:
                    values = [item[key] for item in type_list]
sumpfralle's avatar
sumpfralle committed
524
                except KeyError:
525 526 527 528 529
                    values = None
                # check if there are values and if they all have the same value
                if values and (values.count(values[0]) == len(values)):
                    common_keys.append(key)
            if common_keys:
530 531
                section = "[%s%s]" % (self.SECTION_PREFIXES[type_name],
                        self.DEFAULT_SUFFIX)
532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558
                result.append(section)
                for key in common_keys:
                    value = type_list[0][key]
                    value_string = self._value_to_string(lists, key, value)
                    if not value_string is None:
                        result.append("%s: %s" % (key, value_string))
                # add an empty line to separate sections
                result.append("")
            # generate individual sections
            for index in range(len(type_list)):
                section = "[%s%d]" % (self.SECTION_PREFIXES[type_name], index)
                result.append(section)
                item = type_list[index]
                for key in self.CATEGORY_KEYS[type_name]:
                    if key in common_keys:
                        # skip keys, that are listed in the "Default" section
                        continue
                    if item.has_key(key):
                        value = item[key]
                        value_string = self._value_to_string(lists, key, value)
                        if not value_string is None:
                            result.append("%s: %s" % (key, value_string))
                # add an empty line to separate sections
                result.append("")
        return os.linesep.join(result)


559
class ToolpathSettings(object):
560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585

    SECTIONS = {
        "Bounds": {
            "minx": float,
            "maxx": float,
            "miny": float,
            "maxy": float,
            "minz": float,
            "maxz": float,
        },
        "Tool": {
            "shape": str,
            "tool_radius": float,
            "torus_radius": float,
            "speed": float,
            "feedrate": float,
        },
        "Program": {
            "unit": str,
            "enable_ode": bool,
        },
        "Process": {
            "generator": str,
            "postprocessor": str,
            "path_direction": str,
            "material_allowance": float,
586
            "overlap_percent": int,
587 588
            "step_down": float,
            "engrave_offset": float,
589
            "milling_style": str,
590
            "pocketing_type": str,
591 592 593 594 595 596 597 598 599 600 601
        },
    }

    META_MARKER_START = "PYCAM_TOOLPATH_SETTINGS: START"
    META_MARKER_END = "PYCAM_TOOLPATH_SETTINGS: END"

    def __init__(self):
        self.program = {}
        self.bounds = {}
        self.tool_settings = {}
        self.process_settings = {}
sumpfralle's avatar
sumpfralle committed
602
        self.support_model = None
603

604 605
    def set_bounds(self, bounds):
        low, high = bounds.get_absolute_limits()
606
        self.bounds = {
607 608 609 610 611 612
                "minx": low[0],
                "maxx": high[0],
                "miny": low[1],
                "maxy": high[1],
                "minz": low[2],
                "maxz": high[2],
613 614 615
        }

    def get_bounds(self):
616 617 618
        low = (self.bounds["minx"], self.bounds["miny"], self.bounds["minz"])
        high = (self.bounds["maxx"], self.bounds["maxy"], self.bounds["maxz"])
        return Bounds(Bounds.TYPE_CUSTOM, low, high)
619

sumpfralle's avatar
sumpfralle committed
620 621
    def set_tool(self, index, shape, tool_radius, torus_radius=None, speed=0.0,
            feedrate=0.0):
622 623 624 625 626 627 628 629 630 631 632 633 634 635
        self.tool_settings = {"id": index,
                "shape": shape,
                "tool_radius": tool_radius,
                "torus_radius": torus_radius,
                "speed": speed,
                "feedrate": feedrate,
        }

    def get_tool(self):
        return pycam.Cutters.get_tool_from_settings(self.tool_settings)

    def get_tool_settings(self):
        return self.tool_settings

636 637 638 639 640
    def set_support_model(self, model):
        self.support_model = model

    def get_support_model(self):
        return self.support_model
641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663

    def set_calculation_backend(self, backend=None):
        self.program["enable_ode"] = (backend.upper() == "ODE")

    def get_calculation_backend(self):
        if self.program.has_key("enable_ode"):
            if self.program["enable_ode"]:
                return "ODE"
            else:
                return None
        else:
            return None

    def set_unit_size(self, unit_size):
        self.program["unit"] = unit_size

    def get_unit_size(self):
        if self.program.has_key("unit"):
            return self.program["unit"]
        else:
            return "mm"

    def set_process_settings(self, generator, postprocessor, path_direction,
664 665
            material_allowance=0.0, overlap_percent=0, step_down=1.0,
            engrave_offset=0.0, milling_style="ignore", pocketing_type="none"):
666 667 668
        # TODO: this hack should be somewhere else, I guess
        if generator in ("ContourFollow", "EngraveCutter"):
            material_allowance = 0.0
669 670 671 672 673
        self.process_settings = {
                "generator": generator,
                "postprocessor": postprocessor,
                "path_direction": path_direction,
                "material_allowance": material_allowance,
674
                "overlap_percent": overlap_percent,
675 676
                "step_down": step_down,
                "engrave_offset": engrave_offset,
677
                "milling_style": milling_style,
678
                "pocketing_type": pocketing_type,
679 680 681 682 683 684 685 686 687 688 689 690 691
        }

    def get_process_settings(self):
        return self.process_settings

    def parse(self, text):
        text_stream = StringIO.StringIO(text)
        config = ConfigParser.SafeConfigParser()
        config.readfp(text_stream)
        for config_dict, section in ((self.bounds, "Bounds"),
                (self.tool_settings, "Tool"),
                (self.process_settings, "Process")):
            for key, value_type in self.SECTIONS[section].items():
sumpfralle's avatar
sumpfralle committed
692 693
                value_raw = config.get(section, key, None)
                if value_raw is None:
694 695 696
                    continue
                elif value_type == bool:
                    value = value_raw.lower() in ("1", "true", "yes", "on")
697 698 699 700 701 702 703 704 705 706 707 708 709 710
                elif isinstance(value_type, basestring) \
                        and (value_type.startswith("list_of_")):
                    item_type = value_type[len("list_of_"):]
                    if item_type == "float":
                        item_type = float
                    else:
                        continue
                    try:
                        value = [item_type(one_val)
                                for one_val in value_raw.split(",")]
                    except ValueError:
                        log.warn("Settings: Ignored invalid setting due to " \
                                + "a failed list type parsing: " \
                                + "(%s -> %s): %s" % (section, key, value_raw))
711 712
                else:
                    try:
sumpfralle's avatar
sumpfralle committed
713
                        value = value_type(value_raw)
714
                    except ValueError:
715
                        log.warn("Settings: Ignored invalid setting " \
716
                                + "(%s -> %s): %s" % (section, key, value_raw))
717 718
                config_dict[key] = value

719 720 721
    def __str__(self):
        return self.get_string()

722 723 724 725 726 727 728 729 730 731 732 733
    def get_string(self):
        result = []
        for config_dict, section in ((self.bounds, "Bounds"),
                (self.tool_settings, "Tool"),
                (self.process_settings, "Process")):
            # skip empty sections
            if not config_dict:
                continue
            result.append("[%s]" % section)
            for key, value_type in self.SECTIONS[section].items():
                if config_dict.has_key(key):
                    value = config_dict[key]
734 735 736 737 738
                    if isinstance(value_type, basestring) \
                            and (value_type.startswith("list_of_")):
                        result.append("%s = %s" % (key,
                                ",".join([str(val) for val in value])))
                    elif type(value) == value_type:
739 740 741 742 743
                        result.append("%s = %s" % (key, value))
                # add one empty line after each section
            result.append("")
        return os.linesep.join(result)