diff --git a/.gitignore b/.gitignore
index 7672fdd..0bc0484 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@ __pycache__/
*.pyc
*~
domain.xml
+na
+dist/
diff --git a/README.md b/README.md
index d2226b6..93540d2 100644
--- a/README.md
+++ b/README.md
@@ -6,11 +6,11 @@
`node-agent` должен стать обычным DEB-пакетом. Вместе с самим приложением пойдут вспомагательные утилиты:
-- **na-vmctl** "Своя" версия virsh, которая дёргает код из Node Agent. Базовые операции с VM и также установка и миграция ВМ. Реализована частично.
-- **na-vmexec**. Обёртка для вызова QEMU guest agent на машинах, больше нчего уметь не должна. Реализована целиком.
-- **na-volctl**. Предполагается здесь оставить всю работу с дисками. Не реализовано.
+- `na-vmctl` virsh на минималках, который дёргает код из Node Agent. Выполняет базовые операции с VM, установку и миграцию и т.п. Реализована частично.
+- `na-vmexec`. Обёртка для вызова QEMU guest agent на машинах, больше нчего уметь не должна. Реализована целиком.
+- `na-volctl`. Предполагается здесь оставить всю работу с дисками. Не реализована.
-Этими утилитами Нет цели заменять virsh, нужно реализовать только специфичные для Node Agent вещи.
+Этими утилитами нет цели заменять virsh, бцдет реализован только специфичный для Node Agent функционал.
Зависимости (версии из APT репозитория Debian 12):
@@ -18,6 +18,8 @@
- `python3-docopt` 0.6.2
- `python3-libvirt` 9.0.0 (актуальная новее)
+Минимальная поддерживаемая версия Python — `3.11`, потому, что можем.
+
# Классы
Весь пакет разбит на модули, а основной функционал на классы.
@@ -49,25 +51,78 @@ with LibvirtSession() as session:
## `VirtualMachine`
-Класс для базового управления виртуалкой. В конструктор принимает объект LibvirtSession, который в себе содержит объект virConnect и конфиг в виде словаря.
+Класс для базового управления виртуалкой. В конструктор принимает объект LibvirtSession и создаёт объект `virDomain`.
## `QemuAgent`
-Класс для работы с агентом на гостях. Его можно считать законченным. Он умеет:
+Класс для работы с агентом на гостях. Инициализируется аналогично `VirtualMachine`. Его можно считать законченным. Он умеет:
- Выполнять шелл команды через метод `shellexec()`.
-- Выполнять команды через `execute()`.
+- Выполнять любые команды QEMU через `execute()`.
-Внутри также способен:
+Также способен:
- Поллить выполнение команды. То есть можно дождаться вывода долгой команды.
-- Декодирует base64 вывод STDERR и STDOUT если надо.
-- Принимать STDIN
+- Декодировать base64 вывод STDERR и STDOUT если надо.
+- Отправлять данные на STDIN.
+
+## `XMLConstructor`
+
+Класс для генерации XML конфигов для либвирта и редактирования XML. Пока умеет очень мало и требует перепиливания. Возможно стоит разбить его на несколько классов. Пример работы с ним:
+
+```python
+from node_agent.xml import XMLConstructor
+
+domain_xml = XMLConstructor()
+domain_xml.gen_domain_xml(
+ name='13',
+ title='',
+ vcpus=2,
+ cpu_vendor='Intel',
+ cpu_model='Broadwell',
+ memory=2048,
+ volume='/srv/vm-volumes/ef0bcd68-02c2-4f31-ae96-14d2bda5a97b.qcow2',
+)
+tags_meta = {
+ 'name': 'tags',
+ 'children': [
+ {'name': 'god_mode'},
+ {'name': 'service'}
+ ]
+}
+domain_xml.add_meta(tags_meta, namespace='http://half-it-stack.org/xmlns/tags-meta', nsprefix='tags')
+print(domain_xml.to_string())
+```
+
+В итоге должен получиться какой-то конфиг для ВМ.
+
+Имеет метод `construct_xml()`, который позволяет привести словарь Python в XML элемент (обхект `lxml.etree.Element`). Пример:
+
+```python
+>>> from lxml.etree import tostring
+>>> from na.xml import XMLConstructor
+>>> xml = XMLConstructor()
+>>> tag = {
+... 'name': 'mytag',
+... 'values': {
+... 'firstname': 'John',
+... 'lastname': 'Doe'
+... },
+... 'text': 'Hello!',
+... 'children': [{'name': 'okay'}]
+... }
+>>> element = xml.construct_xml(tag)
+>>> print(tostring(element).decode())
+'Hello!'
+>>>
+```
+
+Функция рекурсивная, так что теоретически можно положить бесконечное число вложенных элементов в `children`. С аргументами `namespace` и `nsprefix` будет сгенерирован XML с неймспейсом, Ваш кэп.
# TODO
- [ ] Установка ВМ
- - [ ] Конструктор XML
+ - [x] Конструктор XML (базовый)
- [ ] Управление дисками
- [ ] Удаление ВМ
- [ ] Изменение CPU
diff --git a/config.toml b/config.toml
index b6755fb..b6ec88f 100644
--- a/config.toml
+++ b/config.toml
@@ -1,4 +1,7 @@
[general]
+# Наверное стоит создавать локи в виде файлов во время операций с ВМ
+# /var/node-agent/locks/vms/{vm}
+locks_dir = '/var/node-agent/locks'
[libvirt]
uri = 'qemu:///system'
@@ -20,5 +23,13 @@ name = 'hdd'
enabled = true
path = '/srv/vm-volumes/hdd'
-[vm.images]
+[vms.defaults]
+# Какие-то значения по-умолчанию, используемые при создании/работе с ВМ
+# Эти параметры также будут аффектить на CLI утилиты
+autostart = true # ставить виртуалки в автостарт после установки
+start = true # запускать ВМ после установки
+cpu_vendor = 'Intel'
+cpu_model = 'Broadwell'
+
+[vms.images]
path = '/srv/vm-images'
diff --git a/node_agent/config.py b/node_agent/config.py
index 68e1faf..aa7b9f3 100644
--- a/node_agent/config.py
+++ b/node_agent/config.py
@@ -17,13 +17,14 @@ class ConfigLoader(UserDict):
file = NODEAGENT_CONFIG_FILE or NODEAGENT_DEFAULT_CONFIG_FILE
self.file = Path(file)
self.data = self._load()
+ # todo: load deafult configuration
def _load(self):
try:
with open(self.file, 'rb') as config:
return tomllib.load(config)
- # todo: schema validation
+ # todo: config schema validation
except (OSError, ValueError) as readerr:
- raise ConfigLoadError('Cannot read config file: %s: %s', (self.file, readerr)) from readerr
+ raise ConfigLoadError(f'Cannot read config file: {self.file}: {readerr}') from readerr
except tomllib.TOMLDecodeError as tomlerr:
- raise ConfigLoadError('Bad TOML syntax in config file: %s: %s', (self.file, tomlerr)) from tomlerr
+ raise ConfigLoadError(f'Bad TOML syntax in config file: {self.file}: {tomlerr}') from tomlerr
diff --git a/node_agent/main.py b/node_agent/main.py
index ba4adc0..9ff169e 100644
--- a/node_agent/main.py
+++ b/node_agent/main.py
@@ -23,8 +23,8 @@ class LibvirtSession(AbstractContextManager):
return libvirt.open(connection_uri)
except libvirt.libvirtError as err:
raise LibvirtSessionError(
- 'Failed to open connection to the hypervisor: %s' % err
- )
+ f'Failed to open connection to the hypervisor: {err}'
+ ) from err
def close(self) -> None:
self.session.close()
diff --git a/node_agent/utils/vmctl.py b/node_agent/utils/vmctl.py
index 7db4529..c949070 100644
--- a/node_agent/utils/vmctl.py
+++ b/node_agent/utils/vmctl.py
@@ -17,6 +17,7 @@ import sys
import pathlib
import logging
+import libvirt
from docopt import docopt
sys.path.append('/home/ge/Code/node-agent')
@@ -38,13 +39,14 @@ def cli():
args = docopt(__doc__)
config = pathlib.Path(args['--config']) or None
loglvl = args['--loglvl'].upper()
+ machine = args['']
if loglvl in levels:
logging.basicConfig(level=levels[loglvl])
with LibvirtSession(config) as session:
try:
- vm = VirtualMachine(session, args[''])
+ vm = VirtualMachine(session, machine)
if args['status']:
print(vm.status)
if args['is-running']:
@@ -58,7 +60,7 @@ def cli():
if args['shutdown']:
vm.shutdown(force=args['--force'], sigkill=args['sigkill'])
except VMNotFound as nferr:
- sys.exit(f'{Color.RED}VM {args[""]} not found.{Color.NONE}')
+ sys.exit(f'{Color.RED}VM {machine} not found.{Color.NONE}')
except VMError as vmerr:
sys.exit(f'{Color.RED}{vmerr}{Color.NONE}')
diff --git a/node_agent/utils/vmexec.py b/node_agent/utils/vmexec.py
index c4c4a88..2a0a339 100644
--- a/node_agent/utils/vmexec.py
+++ b/node_agent/utils/vmexec.py
@@ -35,6 +35,7 @@ def cli():
args = docopt(__doc__)
config = pathlib.Path(args['--config']) or None
loglvl = args['--loglvl'].upper()
+ machine = args['']
if loglvl in levels:
logging.basicConfig(level=levels[loglvl])
@@ -44,7 +45,7 @@ def cli():
cmd = args['']
try:
- ga = QemuAgent(session, args[''])
+ ga = QemuAgent(session, machine)
exited, exitcode, stdout, stderr = ga.shellexec(
cmd,
executable=shell,
@@ -63,14 +64,15 @@ def cli():
sys.exit(errmsg)
except VMNotFound as err:
sys.exit(
- f'{Color.RED}VM {args[""]} not found.{Color.NONE}'
+ f'{Color.RED}VM {machine} not found.{Color.NONE}'
)
if not exited:
print(
Color.YELLOW
+'[NOTE: command may still running]'
- + Color.NONE
+ + Color.NONE,
+ file=sys.stderr
)
else:
if exitcode == 0:
@@ -80,13 +82,14 @@ def cli():
print(
exitcolor
+ f'[command exited with exit code {exitcode}]'
- + Color.NONE
+ + Color.NONE,
+ file=sys.stderr
)
if stderr:
- print(Color.RED + stderr.strip() + Color.NONE)
+ print(Color.RED + stderr.strip() + Color.NONE, file=sys.stderr)
if stdout:
- print(Color.GREEN + stdout.strip() + Color.NONE)
+ print(Color.GREEN + stdout.strip() + Color.NONE, file=sys.stdout)
if __name__ == '__main__':
cli()
diff --git a/node_agent/vm/ga.py b/node_agent/vm/ga.py
index 6c3f65d..a7ed09e 100644
--- a/node_agent/vm/ga.py
+++ b/node_agent/vm/ga.py
@@ -14,8 +14,8 @@ from .base import VMBase
logger = logging.getLogger(__name__)
-DEFAULT_WAIT_TIMEOUT = 60 # seconds
-POLL_INTERVAL = 0.3
+QEMU_TIMEOUT = 60 # seconds
+POLL_INTERVAL = 0.3 # also seconds
class QemuAgent(VMBase):
@@ -33,7 +33,7 @@ class QemuAgent(VMBase):
_get_cmd_result()
Intended for long-time commands. This function loops and every
POLL_INTERVAL calls 'guest-exec-status' for specified guest PID.
- Polling ends on command exited or on timeout.
+ Polling ends if command exited or on timeout.
_return_tuple()
This method transforms JSON command output to tuple and decode
base64 encoded strings optionally.
@@ -46,7 +46,7 @@ class QemuAgent(VMBase):
flags: int | None = None
):
super().__init__(session, name)
- self.timeout = timeout or DEFAULT_WAIT_TIMEOUT # timeout for guest agent
+ self.timeout = timeout or QEMU_TIMEOUT # timeout for guest agent
self.flags = flags or libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT
def execute(
@@ -56,7 +56,7 @@ class QemuAgent(VMBase):
capture_output: bool = False,
decode_output: bool = False,
wait: bool = True,
- timeout: int = DEFAULT_WAIT_TIMEOUT,
+ timeout: int = QEMU_TIMEOUT,
):
"""
Execute command on guest and return output if capture_output is True.
@@ -99,7 +99,7 @@ class QemuAgent(VMBase):
capture_output: bool = False,
decode_output: bool = False,
wait: bool = True,
- timeout: int = DEFAULT_WAIT_TIMEOUT,
+ timeout: int = QEMU_TIMEOUT,
):
"""
Execute command on guest with selected shell. /bin/sh by default.
@@ -139,7 +139,7 @@ class QemuAgent(VMBase):
pid: int,
decode_output: bool = False,
wait: bool = True,
- timeout: int = DEFAULT_WAIT_TIMEOUT,
+ timeout: int = QEMU_TIMEOUT,
):
"""Get executed command result. See GuestAgent.execute() for info."""
exited = exitcode = stdout = stderr = None
diff --git a/node_agent/vm/main.py b/node_agent/vm/main.py
index 4a1d119..5109915 100644
--- a/node_agent/vm/main.py
+++ b/node_agent/vm/main.py
@@ -13,7 +13,7 @@ class VirtualMachine(VMBase):
@property
def name(self):
- return self.domain.name()
+ return self.domname
@property
def status(self) -> str:
@@ -21,7 +21,12 @@ class VirtualMachine(VMBase):
Return VM state: 'running', 'shutoff', etc. Reference:
https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainState
"""
- state = self.domain.info()[0]
+ try:
+ # libvirt returns list [state: int, reason: int]
+ # https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainGetState
+ state = self.domain.state()[0]
+ except libvirt.libvirtError as err:
+ raise VMError(f'Cannot fetch VM status vm={self.domname}: {err}') from err
match state:
case libvirt.VIR_DOMAIN_NOSTATE:
return 'nostate'
@@ -48,6 +53,16 @@ class VirtualMachine(VMBase):
return False
return True
+ @property
+ def is_autostart(self) -> bool:
+ """Return True if VM autostart is enabled, else return False."""
+ try:
+ if self.domain.autostart() == 1:
+ return True
+ return False
+ except libvirt.libvirtError as err:
+ raise VMError(f'Cannot get autostart status vm={self.domname}: {err}') from err
+
def start(self) -> None:
"""Start defined VM."""
logger.info('Starting VM: vm=%s', self.domname)
@@ -57,9 +72,7 @@ class VirtualMachine(VMBase):
try:
ret = self.domain.create()
except libvirt.libvirtError as err:
- raise VMError(err) from err
- if ret != 0:
- raise VMError('Cannot start VM: vm=%s exit_code=%s', self.domname, ret)
+ raise VMError(f'Cannot start vm={self.domname} return_code={ret}: {err}') from err
def shutdown(self, force=False, sigkill=False) -> None:
"""
@@ -71,41 +84,56 @@ class VirtualMachine(VMBase):
flags = libvirt.VIR_DOMAIN_DESTROY_DEFAULT
else:
flags = libvirt.VIR_DOMAIN_DESTROY_GRACEFUL
- if force:
- ret = self.domain.destroyFlags(flags=flags)
- else:
- # Normal VM shutdown via ACPI signal, OS may ignore this.
- ret = self.domain.shutdown()
- if ret != 0:
+ try:
+ if force:
+ self.domain.destroyFlags(flags=flags)
+ else:
+ # Normal VM shutdown via ACPI signal, OS may ignore this.
+ self.domain.shutdown()
+ except libvirt.libvirtError as err:
raise VMError(
- f'Cannot shutdown VM, try force or sigkill: %s', self.domname
- )
+ f'Cannot shutdown vm={self.domname} '
+ f'force={force} sigkill={sigkill}: {err}'
+ ) from err
def reset(self):
"""
- Copypaste from libvirt doc::
+ Copypaste from libvirt doc:
- Reset a domain immediately without any guest OS shutdown.
- Reset emulates the power reset button on a machine, where all
- hardware sees the RST line set and reinitializes internal state.
+ Reset a domain immediately without any guest OS shutdown.
+ Reset emulates the power reset button on a machine, where all
+ hardware sees the RST line set and reinitializes internal state.
- Note that there is a risk of data loss caused by reset without any
- guest OS shutdown.
+ Note that there is a risk of data loss caused by reset without any
+ guest OS shutdown.
"""
- ret = self.domian.reset()
- if ret != 0:
- raise VMError('Cannot reset VM: %s', self.domname)
+ try:
+ self.domian.reset()
+ except libvirt.libvirtError as err:
+ raise VMError(f'Cannot reset vm={self.domname}: {err}') from err
def reboot(self) -> None:
"""Send ACPI signal to guest OS to reboot. OS may ignore this."""
- ret = self.domain.reboot()
- if ret != 0:
- raise VMError('Cannot reboot: %s', self.domname)
+ try:
+ self.domain.reboot()
+ except libvirt.libvirtError as err:
+ raise VMError(f'Cannot reboot vm={self.domname}: {err}') from err
- def set_autostart(self) -> None:
- ret = self.domain.autostart()
- if ret != 0:
- raise VMError('Cannot set : %s', self.domname)
+ def autostart(self, enabled: bool) -> None:
+ """
+ Configure VM to be automatically started when the host machine boots.
+ """
+ if enabled:
+ autostart_flag = 1
+ else:
+ autostart_flag = 0
+ try:
+ self.domain.setAutostart(autostart_flag)
+ except libvirt.libvirtError as err:
+ raise VMError(
+ f'Cannot set autostart vm={self.domname} '
+ f'autostart={autostart_flag}: {err}'
+ ) from err
def vcpu_set(self, count: int):
pass
diff --git a/node_agent/xml.py b/node_agent/xml.py
index bf354e1..af4e267 100644
--- a/node_agent/xml.py
+++ b/node_agent/xml.py
@@ -1,91 +1,79 @@
-import pathlib
+from pathlib import Path
-from lxml import etree
+from lxml.etree import Element, SubElement, QName, tostring
from lxml.builder import E
-class NewXML:
- def __init__(
+class XMLConstructor:
+ """
+ The XML constructor. This class builds XML configs for libvirtd.
+ Features:
+ - Generate basic virtual machine XML. See gen_domain_xml()
+ - Generate virtual disk XML. See gen_volume_xml()
+ - Add arbitrary metadata to XML from special structured dict
+ """
+
+ def __init__(self, xml: str | None = None):
+ self.xml_string = xml
+ self.xml = None
+
+ @property
+ def domain_xml(self):
+ return self.xml
+
+ def gen_domain_xml(
self,
name: str,
title: str,
- memory: int,
vcpus: int,
cpu_vendor: str,
cpu_model: str,
- volume_path: str,
-
- desc: str | None = None,
- show_boot_menu: bool = False,
- ):
+ memory: int,
+ volume: Path,
+ desc: str = ""
+ ) -> None:
"""
- Initialise basic XML using lxml E-Factory. Ref:
-
- - https://lxml.de/tutorial.html#the-e-factory
- - https://libvirt.org/formatdomain.html
+ Generate default domain XML configuration for virtual machines.
+ See https://lxml.de/tutorial.html#the-e-factory for details.
"""
- DOMAIN = E.domain
- NAME = E.name
- TITLE = E.title
- DESCRIPTION = E.description
- METADATA = E.metadata
- MEMORY = E.memory
- CURRENTMEMORY = E.currentMemory
- VCPU = E.vcpu
- OS = E.os
- OS_TYPE = E.type
- OS_BOOT = E.boot
- FEATURES = E.features
- ACPI = E.acpi
- APIC = E.apic
- CPU = E.cpu
- CPU_VENDOR = E.vendor
- CPU_MODEL = E.model
- ON_POWEROFF = E.on_poweroff
- ON_REBOOT = E.on_reboot
- ON_CRASH = E.on_crash
- DEVICES = E.devices
- EMULATOR = E.emulator
- DISK = E.disk
- DISK_DRIVER = E.driver
- DISK_SOURCE = E.source
- DISK_TARGET = E.target
- INTERFACE = E.interface
- GRAPHICS = E.graphics
-
- self.domain = DOMAIN(
- NAME(name),
- TITLE(title),
- DESCRIPTION(desc or ""),
- METADATA(),
- MEMORY(str(memory), unit='MB'),
- CURRENTMEMORY(str(memory), unit='MB'),
- VCPU(str(vcpus), placement='static'),
- OS(
- OS_TYPE('hvm', arch='x86_64'),
- OS_BOOT(dev='cdrom'),
- OS_BOOT(dev='hd'),
+ self.xml = E.domain(
+ E.name(name),
+ E.title(title),
+ E.description(desc),
+ E.metadata(),
+ E.memory(str(memory), unit='MB'),
+ E.currentMemory(str(memory), unit='MB'),
+ E.vcpu(str(vcpus), placement='static'),
+ E.os(
+ E.type('hvm', arch='x86_64'),
+ E.boot(dev='cdrom'),
+ E.boot(dev='hd'),
),
- FEATURES(
- ACPI(),
- APIC(),
+ E.features(
+ E.acpi(),
+ E.apic(),
),
- CPU(
- CPU_VENDOR(cpu_vendor),
- CPU_MODEL(cpu_model, fallback='forbid'),
+ E.cpu(
+ E.vendor(cpu_vendor),
+ E.model(cpu_model, fallback='forbid'),
+ E.topology(sockets='1', dies='1', cores=str(vcpus), threads='1'),
mode='custom',
match='exact',
check='partial',
),
- ON_POWEROFF('destroy'),
- ON_REBOOT('restart'),
- ON_CRASH('restart'),
- DEVICES(
- EMULATOR('/usr/bin/qemu-system-x86_64'),
- DISK(
- DISK_DRIVER(name='qemu', type='qcow2', cache='writethrough'),
- DISK_SOURCE(file=volume_path),
- DISK_TARGET(dev='vda', bus='virtio'),
+ E.on_poweroff('destroy'),
+ E.on_reboot('restart'),
+ E.on_crash('restart'),
+ E.pm(
+ E('suspend-to-mem', enabled='no'),
+ E('suspend-to-disk', enabled='no'),
+ ),
+ E.devices(
+ E.emulator('/usr/bin/qemu-system-x86_64'),
+ E.disk(
+ E.driver(name='qemu', type='qcow2', cache='writethrough'),
+ E.source(file=volume),
+ E.target(dev='vda', bus='virtio'),
type='file',
device='disk',
),
@@ -93,23 +81,106 @@ class NewXML:
type='kvm',
)
- def add_volume(self, options: dict, params: dict):
- """Add disk device to domain."""
- DISK = E.disk
- DISK_DRIVER = E.driver
- DISK_SOURCE = E.source
- DISK_TARGET = E.target
+ def gen_volume_xml(
+ self,
+ device_name: str,
+ file: Path,
+ bus: str = 'virtio',
+ cache: str = 'writethrough',
+ disktype: str = 'file',
+ ):
+ return E.disk(
+ E.driver(name='qemu', type='qcow2', cache=cache),
+ E.source(file=file),
+ E.target(dev=device_name, bus=bus),
+ type=disktype,
+ device='disk'
+ )
-x = NewXML(
- name='1',
- title='first',
- memory=2048,
- vcpus=4,
- cpu_vendor='Intel',
- cpu_model='Broadwell',
- volume_path='/srv/vm-volumes/5031077f-f9ea-410b-8d84-ae6e79f8cde0.qcow2',
-)
+ def add_volume(self):
+ raise NotImplementedError()
-# x.add_volume()
-# print(x.domain)
-print(etree.tostring(x.domain, pretty_print=True).decode().strip())
+ def add_meta(self, data: dict, namespace: str, nsprefix: str) -> None:
+ """
+ Add metadata to domain. See:
+ https://libvirt.org/formatdomain.html#general-metadata
+ """
+ metadata = metadata_old = self.xml.xpath('/domain/metadata')[0]
+ metadata.append(
+ self.construct_xml(
+ data,
+ namespace=namespace,
+ nsprefix=nsprefix,
+ )
+ )
+ self.xml.replace(metadata_old, metadata)
+
+ def remove_meta(self, namespace: str):
+ """Remove metadata by namespace."""
+ raise NotImplementedError()
+
+ def construct_xml(
+ self,
+ tag: dict,
+ namespace: str | None = None,
+ nsprefix: str | None = None,
+ root: Element = None,
+ ) -> Element:
+ """
+ Shortly this recursive function transforms dictonary to XML.
+ Return etree.Element built from dict with following structure::
+
+ {
+ 'name': 'devices', # tag name
+ 'text': '', # optional key
+ 'values': { # optional key, must be a dict of key-value pairs
+ 'type': 'disk'
+ },
+ children: [] # optional key, must be a list of dicts
+ }
+
+ Child elements must have the same structure. Infinite `children` nesting
+ is allowed.
+ """
+ use_ns = False
+ if isinstance(namespace, str) and isinstance(nsprefix, str):
+ use_ns = True
+ # Create element
+ if root is None:
+ if use_ns:
+ element = Element(
+ QName(namespace, tag['name']),
+ nsmap={nsprefix: namespace},
+ )
+ else:
+ element = Element(tag['name'])
+ else:
+ if use_ns:
+ element = SubElement(
+ root,
+ QName(namespace, tag['name']),
+ )
+ else:
+ element = SubElement(root, tag['name'])
+ # Fill up element with content
+ if 'text' in tag.keys():
+ element.text = tag['text']
+ if 'values' in tag.keys():
+ for key in tag['values'].keys():
+ element.set(str(key), str(tag['values'][key]))
+ if 'children' in tag.keys():
+ for child in tag['children']:
+ element.append(
+ self.construct_xml(
+ child,
+ namespace=namespace,
+ nsprefix=nsprefix,
+ root=element,
+ )
+ )
+ return element
+
+ def to_string(self):
+ return tostring(
+ self.xml, pretty_print=True, encoding='utf-8'
+ ).decode().strip()