scripts: rewrite gen-atlas with autotrimming support

Uses PIL/Pillow exclusively now, without imagemagick, since we don't
really need 16bpc support.

Overrides have been replaced with more flexible sprite configs. There is
a conversion mechanism for migration, to be applied in the following
commit.
This commit is contained in:
Andrei Alexeyev 2022-11-29 20:56:24 +01:00
parent 1c029671ae
commit 960b38f8e5
No known key found for this signature in database
GPG key ID: 72D26128040B9690
5 changed files with 500 additions and 213 deletions

View file

@ -1,6 +1,6 @@
atlases_dir = meson.current_source_dir()
atlases_overrides_dir = join_paths(atlases_dir, 'overrides')
atlases_config_dir = join_paths(atlases_dir, 'config')
resources_gfx_dir = join_paths(resources_pkg_main, 'gfx')
# Args are applied in this order:
@ -66,7 +66,7 @@ foreach profile : atlas_profiles
run_target(atlas_target,
command : [
gen_atlas_command,
atlases_overrides_dir,
atlases_config_dir,
join_paths(atlases_dir, atlas_name),
resources_gfx_dir,
atlas_common_args,

View file

@ -1,12 +1,12 @@
#!/usr/bin/env python3
import rectpack
import argparse
import shutil
import subprocess
import re
import functools
import itertools
import re
import rectpack
import shutil
import subprocess
from pathlib import (
Path,
@ -18,12 +18,12 @@ from tempfile import (
from contextlib import (
ExitStack,
suppress,
)
from concurrent.futures import (
ThreadPoolExecutor,
ProcessPoolExecutor,
ThreadPoolExecutor,
as_completed,
)
from PIL import (
@ -31,37 +31,17 @@ from PIL import (
)
from taiseilib.common import (
TaiseiError,
run_main,
update_text_file,
TaiseiError,
wait_for_futures,
)
#
# To the reasonable person wondering why this god-awful script invokes
# an ImageMagick subprocess even though I've just imported PIL:
#
# It used to just use PIL to handle image composition. That is, until
# I discovered the fact that PIL/Pillow does not support multichannel
# 16-bits per pixel images.
# https://github.com/python-pillow/Pillow/issues/2107#issuecomment-246007526
#
# Now PIL is only used to extract the image size, though I guess I might
# as well just parse the output of `identify` at this point.
#
# Fuck software tbqh.
#
import taiseilib.atlas as atlas
import taiseilib.keyval as keyval
texture_formats = ['png', 'webp']
re_comment = re.compile(r'#.*')
re_keyval = re.compile(r'([a-z0-9_-]+)\s*=\s*(.+)', re.I)
class ConfigSyntaxError(Exception):
pass
texture_formats = [s[1:] for s in atlas.SPRITE_SUFFIXES]
def ceildiv(x, y):
@ -72,101 +52,34 @@ def align_size(size, alignment=4):
return tuple(ceildiv(x, alignment) * alignment for x in size)
def write_sprite_def(dst, texture, region, texture_dimensions, overrides=None):
def write_sprite_def(dst, data):
dst.parent.mkdir(exist_ok=True, parents=True)
text = (
'# Autogenerated by the atlas packer, do not modify\n\n'
f'texture = {texture}\n'
'region_x = {region_x:.17f}\n'
'region_y = {region_y:.17f}\n'
'region_w = {region_w:.17f}\n'
'region_h = {region_h:.17f}\n'
).format(
region_x=region[0] / texture_dimensions[0],
region_y=region[1] / texture_dimensions[1],
region_w=region[2] / texture_dimensions[0],
region_h=region[3] / texture_dimensions[1],
)
if overrides is not None:
text += f'\n# -- Pasted from the override file --\n\n{overrides.strip()}\n'
text = '# Autogenerated by the atlas packer, do not modify\n\n'
text += keyval.dump(data)
update_text_file(dst, text)
def write_texture_def(dst, texture, texture_fmt, global_overrides=None, local_overrides=None, alphamap_tex_fmt=None):
dst.parent.mkdir(exist_ok=True, parents=True)
text = (
'# Autogenerated by the atlas packer, do not modify\n\n'
f'source = res/gfx/{texture}.{texture_fmt}\n'
)
data = {
'source': f'res/gfx/{texture}.{texture_fmt}',
}
if alphamap_tex_fmt is not None:
text += f'alphamap = res/gfx/{texture}.alphamap.{alphamap_tex_fmt}\n'
data['alphamap'] = f'res/gfx/{texture}.alphamap.{alphamap_tex_fmt}'
if global_overrides is not None:
text += f'\n# -- Pasted from the global override file --\n\n{global_overrides.strip()}\n'
data.update(global_overrides)
if local_overrides is not None:
text += f'\n# -- Pasted from the local override file --\n\n{local_overrides.strip()}\n'
data.update(local_overrides)
text = '# Autogenerated by the atlas packer, do not modify\n\n'
text += keyval.dump(data)
update_text_file(dst, text)
def write_override_template(dst, size):
dst.parent.mkdir(exist_ok=True, parents=True)
text = (
'# This file was generated automatically, because this sprite doesn\'t have a custom override file.\n'
'# To override this sprite\'s parameters, edit this file, remove the .renameme suffix and this comment, then commit it to the repository.\n\n'
'# Modify these to change the virtual size of the sprite\n'
f'w = {size[0]}\n'
f'h = {size[1]}\n'
)
update_text_file(dst.with_suffix(dst.suffix + '.renameme'), text)
def parse_sprite_conf(path):
conf = {}
with suppress(FileNotFoundError):
with open(path, 'r') as f:
for line in f.readlines():
line = re_comment.sub('', line)
line = line.strip()
if not line:
continue
try:
key, val = re_keyval.findall(line)[0]
except IndexError:
raise ConfigSyntaxError(line)
conf[key] = val
return conf
def get_override_file_name(basename):
if re.match(r'.*\.frame\d{4}$', basename):
basename, _ = basename.rsplit('.', 1)
basename += '.framegroup'
return f'{basename}.spr'
def find_alphamap(basepath):
for fmt in texture_formats:
mpath = basepath.with_suffix(f'.alphamap.{fmt}')
if mpath.is_file():
return mpath
def get_bin_occupied_bounds(bin):
xmin, xmax, ymin, ymax = 0xffffffffffffffff, 0, 0xffffffffffffffff, 0
@ -370,18 +283,22 @@ def pack_rects_brute_force(rects, bin_size, single_bin):
with ProcessPoolExecutor(initializer=bruteforcer_init, initargs=(rects, bin_size, single_bin)) as ex:
variants = tuple(itertools.product(algos, sorts))
results = zip(variants, ex.map(bruteforcer_pack, variants, chunksize=4))
print('results', results)
futures = {}
for (algo_class, (sort_func, sort_name)), result in results:
verbose(f'Trying {algo_class} with {sort_name} sort...')
for variant in variants:
f = ex.submit(bruteforcer_pack, variant)
futures[f] = variant
for future in as_completed(futures):
algo_class, (sort_func, sort_name) = futures[future]
result = future.result()
if best is None or result < best:
verbose(f'\tResult: {result} (best yet)')
verbose(' * {:>20} with {:20} | {!s}'.format(algo_class, sort_name + ' sort', result))
best = result
best_algos = (algo_class, sort_name)
else:
verbose(f'\tResult: {result}')
verbose(' {:>20} with {:20} | {!s}'.format(algo_class, sort_name + ' sort', result))
verbose('*' * 64)
verbose(f'WINNER: {best_algos[0]} with {best_algos[1]} sort')
@ -390,44 +307,41 @@ def pack_rects_brute_force(rects, bin_size, single_bin):
return best
def gen_atlas(overrides, src, dst, binsize, atlasname, tex_format=texture_formats[0], border=1, force_single=False, crop=True, leanify=True):
overrides = Path(overrides).resolve()
src = Path(src).resolve()
dst = Path(dst).resolve()
def sprite_sources_iter(srcroot):
def imgfilter(path):
return (
path.is_file() and path.suffix.lower() in atlas.SPRITE_SUFFIXES and
path.with_suffix('').suffix != '.alphamap'
)
sprite_configs = {}
for path in sorted(filter(imgfilter, srcroot.glob('**/*.*'))):
yield path
def get_border(sprite, default_border=border):
return max(default_border, int(sprite_configs[sprite].get('border', default_border)))
try:
texture_local_overrides = (src / 'atlas.tex').read_text()
except FileNotFoundError:
texture_local_overrides = None
def gen_atlas(config_dir, src, dst, binsize, atlasname, tex_format=texture_formats[0], border=1, force_single=False, crop=True, leanify=True):
def get_sprite_border(sprite, default_border=border):
return max(default_border, int(sprite.config.get('border', default_border)))
try:
texture_global_overrides = (overrides / 'atlas.tex').read_text()
except FileNotFoundError:
texture_global_overrides = None
texture_local_overrides = keyval.parse(src / 'atlas.tex', missing_ok=True)
texture_global_overrides = keyval.parse(config_dir / 'atlas.tex', missing_ok=True)
total_images = 0
packed_images = 0
rects = []
def imgfilter(path):
return (
path.is_file() and path.suffix[1:].lower() in texture_formats and
path.with_suffix('').suffix != '.alphamap'
)
sprites = {}
for path in sorted(filter(imgfilter, src.glob('**/*.*'))):
img = Image.open(path)
for path in sprite_sources_iter(src):
sprite_name = path.relative_to(src).with_suffix('').as_posix()
sprite_config_path = overrides / (get_override_file_name(sprite_name) + '.conf')
sprite_configs[sprite_name] = parse_sprite_conf(sprite_config_path)
border = get_border(sprite_name)
rects.append((img.size[0]+border*2, img.size[1]+border*2, (path, sprite_name)))
img.close()
sprite_config_path = config_dir / atlas.get_sprite_config_file_name(sprite_name)
sprite = atlas.Sprite.load(sprite_name, path, sprite_config_path)
sprites[sprite.name] = sprite
border = get_sprite_border(sprite)
rects.append((
sprite.image.size[0] + border*2,
sprite.image.size[1] + border*2,
sprite.name,
))
pack_result = pack_rects_brute_force(rects=rects, bin_size=binsize, single_bin=force_single)
@ -449,12 +363,9 @@ def gen_atlas(overrides, src, dst, binsize, atlasname, tex_format=texture_format
for i, bin in enumerate(pack_result.bins):
textureid = f'atlas_{atlasname}_{i}'
# dstfile = temp_dst / f'{textureid}.{tex_format}'
# NOTE: we always save PNG first and convert with an external tool later if needed.
dstfile = temp_dst / f'{textureid}.png'
dstfile_alphamap = temp_dst / f'{textureid}.alphamap.png'
print(dstfile)
dstfile_meta = temp_dst / f'{textureid}.tex'
if crop:
@ -465,58 +376,31 @@ def gen_atlas(overrides, src, dst, binsize, atlasname, tex_format=texture_format
actual_size = (bin.width, bin.height)
bin_offset = (0, 0)
base_composite_cmd = [
'convert',
'-verbose',
'-size', f'{actual_size[0]}x{actual_size[1]}',
]
composite_cmd = base_composite_cmd.copy() + ['xc:none']
alphamap_composite_cmd = None
bin_image = Image.new('RGBA', actual_size, (0, 0, 0, 0))
bin_alphamap_image = None
for rect in bin:
rect.move(rect.x + bin_offset[0], rect.y + bin_offset[1])
sprite = sprites[rect.rid]
border = get_sprite_border(sprite)
paste_coord = (rect.x + border, rect.y + border)
bin_image.paste(sprite.image, paste_coord)
img_path, name = rect.rid
border = get_border(name)
alphamap_path = find_alphamap(img_path)
if sprite.alphamap_image is not None:
if bin_alphamap_image is None:
bin_alphamap_image = Image.new('L', actual_size, 255)
bin_alphamap_image.paste(sprite.alphamap_image, paste_coord)
composite_cmd += [
str(img_path), '-geometry', '{:+}{:+}'.format(rect.x + border, rect.y + border), '-composite'
]
sprite.texture_id = textureid
sprite.texture_region = atlas.Geometry(
rect.width - border * 2,
rect.height - border * 2,
rect.x + border,
rect.y + border,
) / actual_size
if alphamap_path:
if alphamap_composite_cmd is None:
alphamap_composite_cmd = base_composite_cmd.copy() + [
'xc:white',
'-colorspace', 'Gray',
]
alphamap_composite_cmd += [
str(alphamap_path),
'-geometry', '{:+}{:+}'.format(rect.x + border, rect.y + border),
'-channel', 'R',
'-separate',
'-composite',
]
override_path = overrides / get_override_file_name(name)
if override_path.exists():
override_contents = override_path.read_text()
else:
override_contents = None
write_override_template(override_path, (rect.width - border * 2, rect.height - border * 2))
write_sprite_def(
temp_dst / f'{name}.spr',
textureid,
(rect.x + border, rect.y + border, rect.width - border * 2, rect.height - border * 2),
actual_size,
overrides=override_contents
)
composite_cmd += [str(dstfile)]
write_sprite_def(temp_dst / f'{sprite.name}.spr', sprite.dump_spritedef_dict())
sprite.unload()
write_texture_def(
dstfile_meta,
@ -524,11 +408,16 @@ def gen_atlas(overrides, src, dst, binsize, atlasname, tex_format=texture_format
tex_format,
texture_global_overrides,
texture_local_overrides,
alphamap_tex_fmt=None if alphamap_composite_cmd is None else 'png'
alphamap_tex_fmt=None if bin_alphamap_image is None else 'png'
)
def process(composite_cmd, dstfile, tex_format=tex_format):
subprocess.check_call(composite_cmd)
def process(
bin_image=bin_image,
dstfile=dstfile,
tex_format=tex_format
):
bin_image.save(dstfile)
bin_image.close()
oldfmt = dstfile.suffix[1:].lower()
@ -556,11 +445,20 @@ def gen_atlas(overrides, src, dst, binsize, atlasname, tex_format=texture_format
if leanify:
subprocess.check_call(['leanify', '-v', str(dstfile)])
futures.append(executor.submit(lambda: process(composite_cmd, dstfile)))
futures.append(executor.submit(process))
if alphamap_composite_cmd is not None:
alphamap_composite_cmd += [str(dstfile_alphamap)]
futures.append(executor.submit(lambda: process(alphamap_composite_cmd, dstfile_alphamap, tex_format='png')))
def process_alphamap(
bin_alphamap_image=bin_alphamap_image,
dstfile_alphamap=dstfile_alphamap,
):
bin_alphamap_image.save(dstfile_alphamap)
bin_alphamap_image.close()
if leanify:
subprocess.check_call(['leanify', '-v', str(dstfile_alphamap)])
if bin_alphamap_image is not None:
futures.append(executor.submit(process_alphamap))
# Wait for subprocesses to complete.
wait_for_futures(futures)
@ -581,11 +479,32 @@ def gen_atlas(overrides, src, dst, binsize, atlasname, tex_format=texture_format
shutil.copyfile(str(temp_dst / file), str(dst / file))
def upgrade_configs(overrides_dir, src_dir, config_dir):
for path in sprite_sources_iter(src_dir):
sprite_name = path.relative_to(src_dir).with_suffix('').as_posix()
config_fname = atlas.get_sprite_config_file_name(sprite_name)
sprite_config_path = config_dir / config_fname
sprite_override_path = overrides_dir / config_fname
if sprite_config_path.is_file():
continue
sprite = atlas.Sprite.load(sprite_name, path, sprite_override_path, force_autotrim=False)
sprite.upgrade_config()
if sprite.config:
sprite_config_path.parent.mkdir(parents=True, exist_ok=True)
update_text_file(sprite_config_path, keyval.dump(sprite.config))
print('{}: {!r}'.format(sprite.name, sprite.config))
sprite.unload()
def main(args):
parser = argparse.ArgumentParser(description='Generate texture atlases and sprite definitions', prog=args[0])
parser.add_argument('overrides_dir',
help='Directory containing per-sprite override files; templates are created automatically for missing files',
parser.add_argument('config_dir',
help='Directory containing configuration files for individual sprites',
type=Path,
)
@ -673,23 +592,40 @@ def main(args):
default=texture_formats[0],
)
parser.add_argument('--upgrade-configs',
help='Generate sprite configs from old-style "overrides". In this mode, config_dir specifies the old "overrides" root, and new configs will be dumped into dest_dir. Existing configs in dest_dir will not be replaced',
action='store_true',
default=False,
)
args = parser.parse_args()
if args.name is None:
args.name = args.source_dir.name
gen_atlas(
args.overrides_dir,
args.source_dir,
args.dest_dir,
align_size((args.width, args.height)),
tex_format=args.format,
atlasname=args.name,
border=args.border,
force_single=args.single,
crop=args.crop,
leanify=args.leanify
)
args.config_dir = args.config_dir.resolve()
args.source_dir = args.source_dir.resolve()
args.dest_dir = args.dest_dir.resolve()
if args.upgrade_configs:
upgrade_configs(
args.config_dir,
args.source_dir,
args.dest_dir,
)
else:
gen_atlas(
args.config_dir,
args.source_dir,
args.dest_dir,
align_size((args.width, args.height)),
tex_format=args.format,
atlasname=args.name,
border=args.border,
force_single=args.single,
crop=args.crop,
leanify=args.leanify,
)
if __name__ == '__main__':

259
scripts/taiseilib/atlas.py Normal file
View file

@ -0,0 +1,259 @@
import PIL
import collections.abc
import dataclasses
import operator
import re
import typing
from . import keyval
SPRITE_SUFFIXES = ('.png', '.webp')
DEFAULT_SPRITE_SCALE = 0.5
@dataclasses.dataclass(slots=True, frozen=True)
class Geometry:
width: float
height: float
offset_x: float = 0.0
offset_y: float = 0.0
regex: typing.ClassVar = re.compile(r'(\d+)x(\d+)([+-]\d+)([+-]\d+)')
@classmethod
def from_string(cls, string):
return cls(*(int(s) for s in cls.regex.findall(string)[0]))
@classmethod
def from_bbox(cls, bbox):
left, upper, right, lower = bbox
return cls(right - left, lower - upper, left, upper)
def _apply_op(self, other, op):
if isinstance(other, collections.abc.Sequence) and len(other) == 2:
mx = other[0]
my = other[1]
return Geometry(
width=op(self.width, mx),
height=op(self.height, my),
offset_x=op(self.offset_x, mx),
offset_y=op(self.offset_y, my),
)
return Geometry(
width=op(self.width, other),
height=op(self.height, other),
offset_x=op(self.offset_x, other),
offset_y=op(self.offset_y, other),
)
def __mul__(self, other): return self._apply_op(other, operator.mul)
def __truediv__(self, other): return self._apply_op(other, operator.truediv)
def __str__(self):
return '{}x{}{:+}{:+}'.format(self.width, self.height, self.offset_x, self.offset_y)
@dataclasses.dataclass(slots=True, frozen=True)
class Padding:
top: float = 0.0
bottom: float = 0.0
left: float = 0.0
right: float = 0.0
@classmethod
def from_offset(cls, x=0, y=0):
return cls(top=y, bottom=-y, left=x, right=-x)
@classmethod
def relative(cls, g_base, g_object):
top = g_object.offset_y - g_base.offset_y
bottom = (g_base.offset_y + g_base.height) - (g_object.offset_y + g_object.height)
left = g_object.offset_x - g_base.offset_x
right = (g_base.offset_x + g_base.width) - (g_object.offset_x + g_object.width)
return cls(top, bottom, left, right)
@classmethod
def from_config(cls, conf):
return cls(
top=float(conf.get('padding_top', 0.0)),
bottom=float(conf.get('padding_bottom', 0.0)),
left=float(conf.get('padding_left', 0.0)),
right=float(conf.get('padding_right', 0.0))
)
def _apply_op(self, other, op):
if not isinstance(other, Padding):
other = Padding(other, other, other, other)
return Padding(
top=op(self.top, other.top),
bottom=op(self.bottom, other.bottom),
left=op(self.left, other.left),
right=op(self.right, other.right)
)
def __add__(self, other): return self._apply_op(other, operator.add)
def __sub__(self, other): return self._apply_op(other, operator.sub)
def __mul__(self, other): return self._apply_op(other, operator.mul)
def __truediv__(self, other): return self._apply_op(other, operator.truediv)
def export(self, conf):
for side in ('top', 'bottom', 'left', 'right'):
key = 'padding_' + side
val = getattr(self, side)
if val:
conf[key] = '{:.17g}'.format(val)
elif key in conf:
del conf[key]
def find_alphamap(basepath):
for fmt in SPRITE_SUFFIXES:
mpath = basepath.with_suffix(f'.alphamap{fmt}')
if mpath.is_file():
return mpath
def get_sprite_config_file_name(basename):
if re.match(r'.*\.frame\d{4}$', basename):
gname, _ = basename.rsplit('.', 1)
gname += '.framegroup'
return f'{gname}.spr'
return f'{basename}.spr'
@dataclasses.dataclass(eq=False)
class Sprite:
name: str
image: PIL.Image.Image
alphamap_image: PIL.Image.Image = None
padding: Padding = dataclasses.field(default_factory=Padding)
config: dict[str, str] = dataclasses.field(default_factory=dict)
texture_id: str = None
texture_region: Geometry = None
def __post_init__(self):
self._trimmed = False
self._check_alphamap()
def _check_alphamap(self):
if self.alphamap_image is not None:
assert self.image.size == self.alphamap_image.size
@classmethod
def load(cls, name, image_path, config_path, force_autotrim=None, default_autotrim=True):
config = keyval.parse(config_path, missing_ok=True)
image = PIL.Image.open(image_path)
alphamap_path = find_alphamap(image_path)
if alphamap_path is not None:
tmp = PIL.Image.open(alphamap_path)
alphamap_image, *_ = tmp.split() # Extract red channel
tmp.close()
else:
alphamap_image = None
sprite = cls(
name=name,
image=image,
alphamap_image=alphamap_image,
padding=Padding.from_config(config),
config=config,
)
if force_autotrim is not None:
autotrim = force_autotrim
else:
autotrim = keyval.strbool(config.get('autotrim', default_autotrim))
if autotrim:
sprite.trim()
return sprite
def upgrade_config(self):
image_w = self.image.size[0]
virtual_w = float(self.config.get('w', image_w))
scale_factor = virtual_w / image_w
if scale_factor != DEFAULT_SPRITE_SCALE:
self.config['scale'] = '{:.17g}'.format(scale_factor)
def trydel(k):
try:
del self.config[k]
except KeyError:
pass
for key in ('w', 'h'):
trydel(key)
self.padding.export(self.config)
@property
def scale(self):
return float(self.config.get('scale', DEFAULT_SPRITE_SCALE))
def trim(self):
if self._trimmed:
return
bbox = self.image.getbbox()
trimmed = self.image.crop(bbox)
g_base = Geometry(*self.image.size)
g_trimmed = Geometry.from_bbox(bbox)
self.padding = self.padding + Padding.relative(g_base, g_trimmed) * self.scale
self.image.close()
self.image = trimmed
if self.alphamap_image is not None:
alphamap_trimmed = self.alphamap_image.crop(bbox)
self.alphamap_image.close()
self.alphamap_image = alphamap_trimmed
self._check_alphamap()
self._trimmed = True
def dump_spritedef_dict(self):
d = {}
if self.texture_id is not None:
d['texture'] = str(self.texture_id)
if self.texture_region is not None:
d['region_x'] = '{:.17f}'.format(self.texture_region.offset_x)
d['region_y'] = '{:.17f}'.format(self.texture_region.offset_y)
d['region_w'] = '{:.17f}'.format(self.texture_region.width)
d['region_h'] = '{:.17f}'.format(self.texture_region.height)
w, h = self.image.size
scale = self.scale
w *= scale
h *= scale
d['w'] = '{:g}'.format(w)
d['h'] = '{:g}'.format(h)
self.padding.export(d)
return d
def unload(self):
if self.image is not None:
self.image.close()
self.image = None
if self.alphamap_image is not None:
self.alphamap_image.close()
self.alphamap_image = None
def __del__(self):
self.unload()

View file

@ -136,3 +136,4 @@ def DirPathType(arg):
raise ValueError('Not a directory: %s' % p)
return p

View file

@ -0,0 +1,91 @@
import os
import re
import string
from .common import TaiseiError
KV_REGEX = re.compile(r'^\s*(.*?)\s*=\s*(.*?)\s*$')
"""
Very dumb configuration parser compatible with Taisei's kvparser.c
"""
class KeyvalSyntaxError(TaiseiError):
pass
def parse(fobj, filename='<unknown>', dest=None, missing_ok=False):
if dest is None:
dest = {}
if isinstance(fobj, (str, os.PathLike)):
try:
with fobj.open('r') as real_fobj:
return parse(real_fobj, filename=fobj, dest=dest)
except FileNotFoundError:
if missing_ok:
return dest
raise
lnum = 0
for line in fobj.readlines():
lnum += 1
line = line.lstrip()
if not line or line.startswith('#'):
continue
m = KV_REGEX.match(line)
if not m:
raise KeyvalSyntaxError('{!s}:{}'.format(filename, lnum))
key, val = m.groups()
dest[key] = val
return dest
def dump(src):
lines = []
for rkey, rval in src.items():
key, val = str(rkey), str(rval)
line = '{} = {}'.format(key, val)
ok = True
m = KV_REGEX.match(line)
if not m:
ok = False
else:
nkey, nval = m.groups()
if key != nkey or val != nval:
ok = False
if not ok:
raise KeyvalSyntaxError('Pair {!r} can\'t be represented'.format((rkey, rval)))
lines.append(line)
return '\n'.join(lines)
def strbool(s):
try:
return bool(int(s))
except ValueError:
pass
s = str(s).lower()
if s in ('on', 'yes', 'true'):
return True
if s in ('off', 'no', 'false'):
return False
raise TaiseiError('Invalid boolean value: {}'.format(s))