changeset 276:af371f9c016d

Allow deletion of key-value pairs when merging is done. When encountering the "{{::DEL::}}" special value the corresponding key-value pair is deleted.
author Franz Glasner <fzglas.hg@dom66.de>
date Sat, 03 Oct 2020 17:11:41 +0200
parents e2fd8fea1a4c
children 0c330cc6135c
files CHANGES.txt configmix/__init__.py docs/introduction.rst tests/data/conf20.yml tests/data/conf21.yml tests/data/delete-in-dict.yml tests/test.py
diffstat 7 files changed, 127 insertions(+), 18 deletions(-) [+]
line wrap: on
line diff
--- a/CHANGES.txt	Sat Oct 03 15:52:30 2020 +0200
+++ b/CHANGES.txt	Sat Oct 03 17:11:41 2020 +0200
@@ -13,6 +13,15 @@
 --------------
 
 .. changelog::
+   :version: n/a
+   :released: n/a
+
+   .. change::
+      :tags: feature
+
+      Allow the deletion of key-value pairs while merging configurations.
+
+.. changelog::
    :version: 0.10
    :released: 2020-09-10
 
--- a/configmix/__init__.py	Sat Oct 03 15:52:30 2020 +0200
+++ b/configmix/__init__.py	Sat Oct 03 17:11:41 2020 +0200
@@ -13,7 +13,7 @@
 from __future__ import division, print_function, absolute_import
 
 
-__version__ = "0.10.1.dev1"
+__version__ = "0.11.0.dev1"
 
 __revision__ = "|VCSRevision|"
 __date__ = "|VCSJustDate|"
@@ -50,6 +50,12 @@
 
 """
 
+DEL_VALUE = u("{{::DEL::}}")
+"""Value for configuration items to signal that the corresponding
+key-value is to be deleted when configurations are merged
+
+"""
+
 
 def load(*files, **kwargs):
     """Load the given configuration files, merge them in the given order
@@ -418,12 +424,17 @@
               The configuration in `default` will be changed **inplace**
               when filtering out comments (which is the default).
 
+    If a value in `user` is equal to :data:`.DEL_VALUE`
+    (``{{::DEL::}}``) the corresponding key will be deleted from the
+    merged output.
+
     From http://stackoverflow.com/questions/823196/yaml-merge-in-python
 
     """
     if user is None:
         if filter_comments:
             _filter_comments(default)
+        _filter_deletions(default)
         return default
     if filter_comments:
         _filter_comments(user)
@@ -431,10 +442,15 @@
         for k, v in default.items():
             if filter_comments and _is_comment(k):
                 continue
-            if k not in user:
+            if k in user:
+                if user[k] == DEL_VALUE:
+                    # do not copy
+                    del user[k]
+                else:
+                    user[k] = _merge(user[k], v, filter_comments)
+            else:
                 user[k] = v
-            else:
-                user[k] = _merge(user[k], v, filter_comments)
+    _filter_deletions(user)
     return user
 
 
@@ -446,10 +462,14 @@
         for k, v in default.items():
             if filter_comments and _is_comment(k):
                 continue
-            if k not in user:
+            if k in user:
+                if user[k] == DEL_VALUE:
+                    # do not copy
+                    del user[k]
+                else:
+                    user[k] = _merge(user[k], v, filter_comments)
+            else:
                 user[k] = v
-            else:
-                user[k] = _merge(user[k], v, filter_comments)
     return user
 
 
@@ -466,6 +486,7 @@
     if user is None:
         if filter_comments:
             _filter_comments(default)
+        _filter_deletions(default)
         return copy.deepcopy(default)
     user = copy.deepcopy(user)
     if filter_comments:
@@ -474,10 +495,15 @@
         for k, v in default.items():
             if filter_comments and _is_comment(k):
                 continue
-            if k not in user:
+            if k in user:
+                if user[k] == DEL_VALUE:
+                    # do not copy
+                    del user[k]
+                else:
+                    user[k] = _safe_merge(user[k], v, filter_comments)
+            else:
                 user[k] = copy.deepcopy(v)
-            else:
-                user[k] = _safe_merge(user[k], v, filter_comments)
+    _filter_deletions(user)
     return user
 
 
@@ -489,10 +515,14 @@
         for k, v in default.items():
             if filter_comments and _is_comment(k):
                 continue
-            if k not in user:
+            if k in user:
+                if user[k] == DEL_VALUE:
+                    # do not copy
+                    del user[k]
+                else:
+                    user[k] = _safe_merge(user[k], v, filter_comments)
+            else:
                 user[k] = copy.deepcopy(v)
-            else:
-                user[k] = _safe_merge(user[k], v, filter_comments)
     return user
 
 
@@ -525,6 +555,23 @@
     return False
 
 
+def _filter_deletions(d):
+    """Recursively filter deletions in the dict `d`.
+
+    Deletions have values that equal :data:`.DEL_VALUE`.
+
+    """
+    if not isinstance(d, dict):
+        return
+    # use a copy of the items because we change `d` while iterating
+    for k, v in list(d.items()):
+        if v == DEL_VALUE:
+            del d[k]
+        else:
+            if isinstance(d[k], dict):
+                _filter_deletions(d[k])
+
+
 #
 # Init loader defaults: mode->loader and extension->mode
 #
--- a/docs/introduction.rst	Sat Oct 03 15:52:30 2020 +0200
+++ b/docs/introduction.rst	Sat Oct 03 17:11:41 2020 +0200
@@ -227,6 +227,15 @@
 ``[namespace:]variable``.
 
 
+.. _merging-deletions:
+
+Deletions
+---------
+
+By using the special value ``{{::DEL::}}`` the corresponding key-value
+pair is deleted when merging is done.
+
+
 .. _comments:
 
 Comments
--- a/tests/data/conf20.yml	Sat Oct 03 15:52:30 2020 +0200
+++ b/tests/data/conf20.yml	Sat Oct 03 17:11:41 2020 +0200
@@ -100,3 +100,9 @@
     - 3
 
   Str: a string
+
+
+to-be-deleted: 'a value'
+to-be-deleted-but-reassigned: 'another value'
+# not touched in later config: should be cleaned up
+not-deleted: '{{::DEL::}}'
--- a/tests/data/conf21.yml	Sat Oct 03 15:52:30 2020 +0200
+++ b/tests/data/conf21.yml	Sat Oct 03 17:11:41 2020 +0200
@@ -26,3 +26,7 @@
     - 0
     - 1
     - 2
+
+
+to-be-deleted: '{{::DEL::}}'
+to-be-deleted-but-reassigned: '{{::DEL::}}'
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/data/delete-in-dict.yml	Sat Oct 03 17:11:41 2020 +0200
@@ -0,0 +1,16 @@
+# -*- coding: utf-8; mode: yaml; indent-tabs-mode: nil; -*-
+#
+# Part of deletion check
+#
+%YAML 1.1
+---
+
+db:
+  user:
+    name: '{{::DEL::}}'
+
+test:
+  Str: '{{::DEL::}}'
+
+
+to-be-deleted-but-reassigned: 'the last value'
--- a/tests/test.py	Sat Oct 03 15:52:30 2020 +0200
+++ b/tests/test.py	Sat Oct 03 17:11:41 2020 +0200
@@ -308,18 +308,36 @@
 
         _check(cfg)
 
+    def test07_deletions(self):
+        cfg = self._load(os.path.join(TESTDATADIR, "conf20.yml"),
+                         os.path.join(TESTDATADIR, "conf21.yml"),
+                         os.path.join(TESTDATADIR, "conf22.ini"),
+                         os.path.join(TESTDATADIR, "conf23.json"),
+                         os.path.join(TESTDATADIR, "conf24.toml"),
+                         os.path.join(TESTDATADIR, "delete-in-dict.yml"))
+        # automatic clean-up
+        self.assertRaises(KeyError, cfg.getvar_s, "not-deleted")
+        # explicit deletion
+        self.assertRaises(KeyError, cfg.getvar_s, "to-be-deleted")
+        self.assertRaises(KeyError, cfg.getvar_s, "db.user.name")
+        self.assertEqual("the-database-password-2",cfg.getvar_s("db.user.pwd"))
+        self.assertRaises(KeyError, cfg.getvar_s, "test.Str")
+        self.assertEqual("not a list any more", cfg.getvar_s("test.List"))
+        self.assertEqual("the last value",
+                         cfg.getvar_s("to-be-deleted-but-reassigned"))
+
 
 class T02LoadAndMerge(_T02MixinLoadAndMerge, unittest.TestCase):
 
     def setUp(self):
         self._load = configmix.load
 
-    def test07_identity(self):
+    def test08_identity(self):
         cfg = configmix.ini.load(os.path.join(TESTDATADIR, "conf1.ini"))
         cfg2 = configmix.merge(cfg, None)
         self.assertEqual(id(cfg), id(cfg2))
 
-    def test08_identity(self):
+    def test09_identity(self):
         cfg = configmix.ini.load(os.path.join(TESTDATADIR, "conf1.ini"))
         cfg2 = configmix.merge(cfg, {})
         self.assertEqual(id(cfg), id(cfg2))
@@ -330,12 +348,12 @@
     def setUp(self):
         self._load = configmix.safe_load
 
-    def test07_deepcopy(self):
+    def test08_deepcopy(self):
         cfg = configmix.ini.load(os.path.join(TESTDATADIR, "conf1.ini"))
         cfg2 = configmix.safe_merge(cfg, None)
         self.assertNotEqual(id(cfg), id(cfg2))
 
-    def test08_deepcopy(self):
+    def test09_deepcopy(self):
         cfg = configmix.ini.load(os.path.join(TESTDATADIR, "conf1.ini"))
         cfg2 = configmix.safe_merge(cfg, {})
         self.assertNotEqual(id(cfg), id(cfg2))
@@ -458,7 +476,7 @@
 
     def test04_expand_intint2str_ini(self):
         cfg = configmix.load(os.path.join(TESTDATADIR, "conf1.ini"))
-        self.assertEqual("22", cfg.getvar_s("key103"))        
+        self.assertEqual("22", cfg.getvar_s("key103"))
 
 
 if __name__ == "__main__":