Skip to content

Instantly share code, notes, and snippets.

@elpaso
Created January 20, 2017 13:48
Show Gist options
  • Save elpaso/67dd55a3e38532498291747b153ed2a6 to your computer and use it in GitHub Desktop.
Save elpaso/67dd55a3e38532498291747b153ed2a6 to your computer and use it in GitHub Desktop.
QGIS overridable settings

QGIS Enhancement: QGIS overridable settings

Date 2017/01/17

Author Alessandro Pasotti (@elpaso)

Contact apasotti at boundlessgeo dot com

maintainer @elpaso

Version QGIS 3.x

Summary

This QEP proposes to move all default settings outside C++ code into a (configurable) ini file.

The motivation is doublefold:

  1. hard-coding configuration in C++ code makes maintainance harder
  2. sysadmins in large organizations need an easy way to customize or pre-configure the application settings before deployment, this includes for example:
    • plugin repositories
    • WMS/WFS endpoints
    • pre-installed and/or pre-enabled plugins
    • default and preferred folders

There is also another useful side-effect of having our own implementation of the settings: we could easily manipulate the settings values if we need to, for instance by applying some sort of variable substitution.

Long story

The actual QGIS setting implementation is based on QSettings. In order to provide defaults and/or initial configurations, inline values are provided inside the C++ code, such as in

void QgisApp::reportaBug()
{
  QSettings settings;
  QString reportaBugUrl = settings.value( "Qgis/reportaBugUrl", tr( "https://qgis.org/en/site/getinvolved/development/bugreporting.html" ) ).toString();
  openURL( reportaBugUrl, false );
}

In other parts of the codebase, some logic is implemented in order to populate the settings with default values, for example this happens in plugin manager when the official plugin repo is added, or (following a user action) in WMS for example servers.

Proposed Solution

Introduce a new QgsSettings class that subclasses the actual QSettings but can optionally provide default values taken from another ini file.

Values contained in the "default" ini file will provide the initial configuration and override possible inline defaults.

The override will work in a similar fashion like in CSSs: a setting value in the default ini file will be overridden by a user setting.

The default ini file path will be searched in a standard location that will be overridable through an environment variable.

For example:

; defaults
[group]
key=value1

; user settings
[group]
key=value2

; will return key=value2

Groups will be inheritable, for example:

; defaults
[group]
key1=value1
key2=value2

; user settings
[group]
key3=value2

; a call to allKeys() will return [key1, key2, key3]

A secondary effect of the proposed implementation is the possibility to add some "magic" (like variable replacements) inside the QgsSettings class.

Alternate implementation

The subclass solution has the advantage of being a drop-in replacement of QSettings while maintaining the same API, but it has some disadvantages: for example it would not be easy (without a singleton) to pass the path of the default file from the QGIS application (i.e. implement a --defaultsinipath command line option).

An alternate implementation would be adding a QgsSettings instance to QgsApplication and use a new method (settings()) to access the QgsSettings instance from QGIS and plugins.

Python prototype:

"""
QSettings subclass that reads default values from an ini file
"""
import os
import unittest

from PyQt5.QtCore import QSettings


class QgsSettings(QSettings):
    """
    A QSettings subclass that reads default values from an ini file

    FIXME: this is a partial implementation: many methods are missing
    """

    defaults = None

    def __init__(self, orgname, appname, defaults_file=None):
        super(QgsSettings, self).__init__(orgname, appname)
        if self.defaults is None:
            # Note: it will be SystemScope in the real implementation,
            #       here it is UserScope for testing
            if defaults_file is not None:
                if os.path.isfile(defaults_file):
                    self.defaults = QSettings(defaults_file, QSettings.IniFormat)
                else:
                    self.defaults = None
            else:
                self.defaults = QSettings(QSettings.IniFormat, QSettings.UserScope, orgname, appname + '_defaults')

    def beginGroup(self, group):
        self.group = group
        self.defaults.beginGroup(group)
        super(QgsSettings, self).beginGroup(group)

    def endGroup(self):
        self.group = None
        self.defaults.endGroup()
        super(QgsSettings, self).endGroup()

    def allKeys(self):
        keys = set(super(QgsSettings, self).allKeys())
        keys = keys.union(self.defaults.allKeys())
        return keys

    def childKeys(self):
        keys = set(super(QgsSettings, self).childKeys())
        keys = keys.union(self.defaults.childKeys())
        return keys

    def childGroups(self):
        groups = set(super(QgsSettings, self).childGroups())
        groups = groups.union(self.defaults.childGroups())
        return groups

    def value(self, key, default=None):
        value = super(QgsSettings, self).value(key)
        if value is None and self.defaults is not None:
            value = self.defaults.value(key, default)
        return value


class TestQgsSettings(unittest.TestCase):

    cnt = 0

    def setUp(self):
        self.cnt += 1
        self.settings = QgsSettings('itopen', 'itopen%s' % self.cnt)

    def tearDown(self):
        settings_file = self.settings.fileName()
        settings_default_file = self.settings.defaults.fileName()
        del(self.settings)
        try:
            os.unlink(settings_file)
        except:
            pass
        try:
            os.unlink(settings_default_file)
        except:
            pass

    def addToDefaults(self, key, value):
        defaults = QSettings(self.settings.defaults.fileName(), QSettings.IniFormat)
        defaults.setValue(key, value)
        defaults.sync()

    def test_basic_functionality(self):
        self.settings.setValue('itopen/name', 'elpaso')
        self.settings.sync()
        self.assertEqual(self.settings.value('itopen/name'), 'elpaso')

    def test_defaults(self):
        self.assertIsNone(self.settings.value('itopen/name'))
        self.addToDefaults('itopen/name', 'elpaso')
        self.assertEqual(self.settings.value('itopen/name'), 'elpaso')

    def test_allkeys(self):
        self.assertEqual(self.settings.allKeys(), set())
        self.addToDefaults('itopen/name', 'elpaso')
        self.settings.setValue('nepoti/eman', 'osaple')

        self.assertEqual(2, len(self.settings.allKeys()))
        self.assertIn('itopen/name', self.settings.allKeys())
        self.assertIn('nepoti/eman', self.settings.allKeys())
        self.assertEqual('elpaso', self.settings.value('itopen/name'))
        self.assertEqual('osaple', self.settings.value('nepoti/eman'))

    def test_precedence(self):
        self.assertEqual(self.settings.allKeys(), set())
        self.addToDefaults('itopen/names/name1', 'elpaso1')
        self.settings.setValue('itopen/names/name1', 'elpaso-1')

        self.assertEqual(self.settings.value('itopen/names/name1'), 'elpaso-1')

    def test_groups(self):
        self.assertEqual(self.settings.allKeys(), set())
        self.addToDefaults('itopen/names/name1', 'elpaso1')
        self.addToDefaults('itopen/names/name2', 'elpaso2')
        self.addToDefaults('itopen/names/name3', 'elpaso3')
        self.addToDefaults('itopen/name', 'elpaso')

        self.settings.beginGroup('itopen')
        self.assertEquals({'names'}, self.settings.childGroups())

        self.settings.setValue('surnames/name1', 'elpaso-1')
        self.assertEquals({'names', 'surnames'}, self.settings.childGroups())

        self.settings.setValue('names/name1', 'elpaso-1')
        self.assertEqual('elpaso-1', self.settings.value('names/name1'))
        self.settings.endGroup()
        self.settings.beginGroup('itopen/names')
        self.settings.setValue('name4', 'elpaso-4')
        keys = list(self.settings.childKeys())
        keys.sort()
        self.assertEqual(keys, ['name1', 'name2', 'name3', 'name4'])
        self.settings.endGroup()
        self.assertEqual('elpaso-1', self.settings.value('itopen/names/name1'))
        self.assertEqual('elpaso-4', self.settings.value('itopen/names/name4'))


if __name__ == '__main__':
    unittest.main()

The QGIS project will provide such a file with all the necessary entries to maintain the current behavior of the application but it will be possible for packagers to ship different default setting files and for sysadmins to provide a default configuration file.

Example(s)

/*
; defaults
[group]
key1=value1
key2=value2

[group2]
key4=value4

; user settings
[group]
key1=value-1
key3=value-3
*/

QgsSettings settings;

settings.value('group/key1', 'my value 1') == "value-1";  // true
settings.value('group/key3', 'my value 3') == "value-3";  // true
settings.value('group2/key4', 'my value 4') == "value4";  // true
settings.value('notexists/key8', 'my value 8') == "my value 8";  // true

Affected Files

All files where QSettings is used (and there are many)

Performance Implications

Probably negligible, provided that there are no setting read in loops (that would probably be a bad design anyway).

Backwards Compatibility

No problems are expectedin this regard.

Votes

(required)

@jgrocha
Copy link

jgrocha commented Apr 8, 2017

Thanks @elpaso
I see that a QgsSettings class already exist in src/core/qgssettings.cpp.
Can you write a quick update on this QEP? I would like to add some default values (a few URL of slippy maps to let them be added quickly from the browser panel). Should I add those URL in C++ (as usual) or can I use some ini file already?

@elpaso
Copy link
Author

elpaso commented Jun 19, 2017

@jgrocha sorry I missed your message, the QgsSettings class is now merged into master but there is not yet an ini file that we ship, so the settings are still all hardcoded.

But we need to start somewhere, so you can probably create an ini file to be shipped with QGIS, and tell Juerghen to package it in the default location, see: https://github.com/qgis/QGIS/blob/master/src/app/main.cpp#L810

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment