#!/usr/bin/env python3
#
# Copyright © 2018 Tomasz Miąsko
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2 of the licence, or (at your option) any later version.
#
# This library 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, see .
import os
import subprocess
import sys
import tempfile
import time
import unittest
from textwrap import dedent
DAEMON_CONFIG = '''
session
unix:tmpdir=/tmp
{servicedir}
EXTERNAL
'''
SERVICE_CONFIG = '''\
[D-BUS Service]
Name={name}
Exec={exec}
'''
def dconf(*args, **kwargs):
argv = [dconf_exe]
argv.extend(args)
# Setup convenient defaults:
kwargs.setdefault('check', True)
kwargs.setdefault('stdout', subprocess.PIPE)
kwargs.setdefault('universal_newlines', True)
return subprocess.run(argv, **kwargs)
def dconf_read(key):
return dconf('read', key).stdout.rstrip('\n')
def dconf_write(key, value):
dconf('write', key, value)
def dconf_list(key):
lines = dconf('list', key).stdout.splitlines()
# FIXME: Change dconf to produce sorted output.
lines.sort()
return lines
def dconf_watch(path):
args = [dconf_exe, 'watch', path]
return subprocess.Popen(args,
stdout=subprocess.PIPE,
universal_newlines=True)
class DBusTest(unittest.TestCase):
def setUp(self):
self.temporary_dir = tempfile.TemporaryDirectory()
self.runtime_dir = os.path.join(self.temporary_dir.name, 'run')
self.config_home = os.path.join(self.temporary_dir.name, 'config')
self.dbus_dir = os.path.join(self.temporary_dir.name, 'dbus-1')
os.mkdir(self.runtime_dir, mode=0o700)
os.mkdir(self.config_home, mode=0o700)
os.mkdir(self.dbus_dir, mode=0o700)
os.environ['XDG_RUNTIME_DIR'] = self.runtime_dir
os.environ['XDG_CONFIG_HOME'] = self.config_home
# Prepare dbus-daemon config.
dbus_daemon_config = os.path.join(self.dbus_dir, 'session.conf')
with open(dbus_daemon_config, 'w') as file:
file.write(DAEMON_CONFIG.format(servicedir=self.dbus_dir))
# Prepare service config.
name = 'ca.desrt.dconf'
path = os.path.join(self.dbus_dir, '{}.service'.format(name))
with open(path, 'w') as file:
config = SERVICE_CONFIG.format(name=name, exec=dconf_service_exe)
file.write(config)
# Pipe where daemon will write its address.
read_fd, write_fd = os.pipe2(0)
args = ['dbus-daemon',
'--config-file={}'.format(dbus_daemon_config),
'--nofork',
'--print-address={}'.format(write_fd)]
# Start daemon
self.dbus_daemon_process = subprocess.Popen(args, pass_fds=[write_fd])
# Close our writing end of pipe. Daemon closes its own writing end of
# pipe after printing address, so subsequent reads shouldn't block.
os.close(write_fd)
with os.fdopen(read_fd) as f:
dbus_address = f.read().rstrip()
# Prepare environment
os.environ['DBUS_SESSION_BUS_ADDRESS'] = dbus_address
def tearDown(self):
# Terminate dbus-daemon.
p = self.dbus_daemon_process
try:
p.terminate()
p.wait(timeout=0.5)
except subprocess.TimeoutExpired:
p.kill()
p.wait()
self.temporary_dir.cleanup()
def test_read_nonexisiting(self):
"""Reading missing key produces no output. """
self.assertEqual('', dconf_read('/key'))
def test_write_read(self):
"""Read returns previously written value."""
dconf('write', '/key', '0')
self.assertEqual('0', dconf_read('/key'))
dconf('write', '/key', '"hello there"')
self.assertEqual("'hello there'", dconf_read('/key'))
def test_list(self):
"""List returns a list of names inside given directory.
Results include both keys and subdirectories.
"""
dconf('write', '/org/gnome/app/fullscreen', 'true')
dconf('write', '/org/gnome/terminal/profile', '"default"')
dconf('write', '/key', '42')
self.assertEqual(['key', 'org/'], dconf_list('/'))
self.assertEqual(['gnome/'], dconf_list('/org/'))
def test_list_missing(self):
"""List can be used successfully with non existing directories. """
self.assertEqual([], dconf_list('/no-existing/directory/'))
def test_reset_key(self):
"""Reset can be used to reset a value of a single key."""
dconf('write', '/app/width', '1024')
dconf('write', '/app/height', '768')
dconf('write', '/app/fullscreen', 'true')
# Sanity check.
self.assertEqual(['fullscreen', 'height', 'width'], dconf_list('/app/'))
# Reset one key after another:
dconf('reset', '/app/fullscreen')
self.assertEqual(['height', 'width'], dconf_list('/app/'))
dconf('reset', '/app/width')
self.assertEqual(['height'], dconf_list('/app/'))
dconf('reset', '/app/height')
self.assertEqual([], dconf_list('/app/'))
def test_reset_dir(self):
"""Reseting whole directory is possible with -f option.
It is an error not to use -f when resetting a dir.
"""
dconf('write', '/app/a', '1')
dconf('write', '/app/b', '2')
dconf('write', '/app/c/d', '3')
dconf('write', '/x', '4')
dconf('write', '/y/z', '5')
with self.assertRaises(subprocess.CalledProcessError) as cm:
dconf('reset', '/app/', stderr=subprocess.PIPE)
self.assertRegex(cm.exception.stderr, '-f must be given')
self.assertRegex(cm.exception.stderr, 'Usage:')
# Nothing should be removed just yet.
self.assertTrue(['a', 'b', 'c'], dconf_list('/app/'))
# Try again with -f.
dconf('reset', '-f', '/app/')
# /app/ should be gone now:
self.assertEqual(['x', 'y/'], dconf_list('/'))
def test_watch(self):
"""Watch reports changes made using write command.
Only changes made inside given subdirectory should be reported.
"""
watch_root = dconf_watch('/')
watch_org = dconf_watch('/org/')
# Arbitrary delay to give "dconf watch" time to set-up the watch.
# In the case this turns out to be problematic, dconf tool could be
# changed to produce debug message after `dconf_client_watch_sync`,
# so that we could synchronize on its output.
time.sleep(0.2)
dconf('write', '/com/a', '1')
dconf('write', '/org/b', '2')
dconf('write', '/organ/c', '3')
dconf('write', '/c', '4')
# Again, give "dconf watch" some time to pick-up changes.
time.sleep(0.2)
watch_root.terminate()
watch_org.terminate()
watch_root.wait()
watch_org.wait()
# Watch for '/' should capture all writes.
expected = '''\
/com/a
1
/org/b
2
/organ/c
3
/c
4
'''
self.assertEqual(dedent(expected), watch_root.stdout.read())
# Watch for '/org/' should capture only a subset of all writes:
expected = '''\
/org/b
2
'''
self.assertEqual(dedent(expected), watch_org.stdout.read())
def test_dump_load(self):
"""Checks that output produced with dump can be used with load and
vice versa.
"""
# FIXME: This test depends on:
# * order of groups
# * order of items within groups
# Change dconf to produce output in sorted order.
keyfile = dedent('''\
[/]
password='secret'
[org/editor/language/c-sharp]
tab-width=8
[org/editor/language/c]
tab-width=2
[org/editor]
window-size=(1280, 977)
''')
# Load and dump is identity.
dconf('load', '/', input=keyfile)
self.assertEqual(dconf('dump', '/').stdout, keyfile)
# Copy /org/ directory to /com/.
keyfile = dconf('dump', '/org/').stdout
dconf('load', '/com/', input=keyfile)
# Verify that /org/ and /com/ are now exactly the same.
keyfile_org = dconf('dump', '/org/').stdout
keyfile_com = dconf('dump', '/com/').stdout
self.assertEqual(keyfile_org, keyfile_com)
if __name__ == '__main__':
# Make sure we don't pick up mandatory profile.
mandatory_profile = '/run/dconf/user/{}'.format(os.getuid())
assert not os.path.isfile(mandatory_profile)
# Avoid profile sourced from environment
os.environ.pop('DCONF_PROFILE', None)
if len(sys.argv) < 3:
message = 'Usage: {} path-to-dconf path-to-dconf-service'.format(
sys.argv[0])
raise RuntimeError(message)
dconf_exe, dconf_service_exe = sys.argv[1:3]
del sys.argv[1:3]
# Run tests!
unittest.main()