Repository: omry/omegaconf

Issue:
[Feature Request] Add ability to get parts (sub-trees) of the config
Currently in OmegaConf, for the following config:
```
server:
  host: localhost
  port: 80

client:
  url: http://${server.host}:${server.port}/
  server_port: ${server.port}
```

We can get `conf["server"]` and `conf["client"]`, but then we lose the parent component name (e.g., "server" or "client").


This feature is to be able to get complete subtrees, rooted at the specified head.

Example config:
```
server:
  host: localhost
  port: 80

client:
  url: http://${server.host}:${server.port}/
  server_port: ${server.port}
```

Usage:
```
print(conf.root_from("server"))
```

Outputs
```
server:
  host: localhost
  port: 80
```

Discussion:
coveralls: ## Pull Request Test Coverage Report for [Build 184](https://coveralls.io/builds/26893534)

* **15** of **16**   **(93.75%)**  changed or added relevant lines in **2** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.09%**) to **97.976%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [omegaconf/omegaconf.py](https://coveralls.io/builds/26893534/source?filename=omegaconf%2Fomegaconf.py#L198) | 7 | 8 | 87.5%
<!-- | **Total:** | **15** | **16** | **93.75%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/26893534/badge)](https://coveralls.io/builds/26893534) |
| :-- | --: |
| Change from base [Build 177](https://coveralls.io/builds/26882001): |  -0.09% |
| Covered Lines: | 823 |
| Relevant Lines: | 840 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index 6a52702..e1872d1 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -393,4 +393,24 @@ You can temporarily remove the struct flag from a config object:
     >>> with omegaconf.open_dict(conf):
     ...   conf.a.cc = 30
     >>> conf.a.cc
-    30
\ No newline at end of file
+    30
+
+Utility functions
+-----------------
+
+OmegaConf.masked_copy
+^^^^^^^^^^^^^^^^^^^^^
+Creates a copy of a DictConfig that contains only specific keys.
+.. doctest:: loaded
+
+    >>> conf = OmegaConf.create(dict(a=dict(b=10), c=20))
+    >>> print(conf.pretty())
+    a:
+      b: 10
+    c: 20
+    <BLANKLINE>
+    >>> c = OmegaConf.masked_copy(conf, ["a"])
+    >>> print(c.pretty())
+    a:
+      b: 10
+    <BLANKLINE>
diff --git a/news/42.feature b/news/42.feature
new file mode 100644
index 0000000..b2283bc
--- /dev/null
+++ b/news/42.feature
@@ -0,0 +1,1 @@
+Add OmegaConf.masked_copy(keys) function that returns a copy of a config with a subset of the keys
diff --git a/omegaconf/dictconfig.py b/omegaconf/dictconfig.py
index 9f81527..7c180f8 100644
--- a/omegaconf/dictconfig.py
+++ b/omegaconf/dictconfig.py
@@ -139,7 +139,7 @@ class DictConfig(Config):
     def __iter__(self):
         return iter(self.keys())
 
-    def items(self, resolve=True):
+    def items(self, resolve=True, keys=None):
         class MyItems(object):
             def __init__(self, m):
                 self.map = m
@@ -148,11 +148,20 @@ class DictConfig(Config):
             def __iter__(self):
                 return self
 
-            # Python 3 compatibility
             def __next__(self):
-                return self.next()
+                k, v = self._next_pair()
+                if keys is not None:
+                    while True:
+                        if k not in keys:
+                            k, v = self._next_pair()
+                        else:
+                            break
+                return k, v
 
             def next(self):
+                return self.__next__()
+
+            def _next_pair(self):
                 k = next(self.iterator)
                 if resolve:
                     v = self.map.get(k)
diff --git a/omegaconf/listconfig.py b/omegaconf/listconfig.py
index d2696b5..993ba31 100644
--- a/omegaconf/listconfig.py
+++ b/omegaconf/listconfig.py
@@ -60,9 +60,6 @@ class ListConfig(Config):
 
         value = self._prepare_value_to_add(index, value)
 
-        if self._get_flag("readonly"):
-            raise ReadonlyConfigError(self.get_full_key(index))
-
         if not isinstance(value, BaseNode):
             self.__dict__["content"][index].set_value(value)
         else:
@@ -149,7 +146,7 @@ class ListConfig(Config):
 
     def pop(self, index=-1):
         if self._get_flag("readonly"):
-            raise ReadonlyConfigError(self.get_full_key(index))
+            raise ReadonlyConfigError(self.get_full_key(index if index != -1 else ""))
         return self._resolve_with_default(
             key=index, value=self.content.pop(index), default_value=None
         )
diff --git a/omegaconf/omegaconf.py b/omegaconf/omegaconf.py
index 3699447..df50a2e 100644
--- a/omegaconf/omegaconf.py
+++ b/omegaconf/omegaconf.py
@@ -184,6 +184,24 @@ class OmegaConf:
         # noinspection PyProtectedMember
         return conf._get_flag("struct")
 
+    @staticmethod
+    def masked_copy(conf, keys):
+        """
+        Create a masked copy of of this config that contains a subset of the keys
+        :param conf: DictConfig object
+        :param keys: keys to preserve in the copy
+        :return:
+        """
+        from .dictconfig import DictConfig
+
+        if not isinstance(conf, DictConfig):
+            raise ValueError("masked_copy is only supported for DictConfig")
+
+        if isinstance(keys, str):
+            keys = [keys]
+        content = {key: value for key, value in conf.items(resolve=False, keys=keys)}
+        return DictConfig(content=content)
+
 
 # register all default resolvers
 register_default_resolvers()


Test patch:
diff --git a/tests/test_basic_ops_dict.py b/tests/test_basic_ops_dict.py
index d968908..126175c 100644
--- a/tests/test_basic_ops_dict.py
+++ b/tests/test_basic_ops_dict.py
@@ -397,3 +397,28 @@ def test_members():
     # Make sure accessing __members__ does not return None or throw.
     c = OmegaConf.create({"foo": {}})
     assert c.__members__ == {}
+
+
+@pytest.mark.parametrize(
+    "cfg, mask_keys, expected",
+    [
+        ({}, [], {}),
+        ({"a": 1}, "a", {"a": 1}),
+        ({"a": 1}, ["b"], {}),
+        ({"a": 1, "b": 2}, "b", {"b": 2}),
+        ({"a": 1, "b": 2}, ["a", "b"], {"a": 1, "b": 2}),
+    ],
+)
+def test_masked_copy(cfg, mask_keys, expected):
+    cfg = OmegaConf.create(cfg)
+    masked = OmegaConf.masked_copy(cfg, keys=mask_keys)
+    assert masked == expected
+
+
+def test_masked_copy_is_deep():
+    cfg = OmegaConf.create({"a": {"b": 1, "c": 2}})
+    expected = {"a": {"b": 1, "c": 2}}
+    masked = OmegaConf.masked_copy(cfg, keys=["a"])
+    assert masked == expected
+    cfg.a.b = 2
+    assert cfg != expected
diff --git a/tests/test_readonly.py b/tests/test_readonly.py
index 2a138b9..fe0521e 100644
--- a/tests/test_readonly.py
+++ b/tests/test_readonly.py
@@ -1,43 +1,36 @@
 import re
 
 import pytest
-
+from pytest import raises
 from omegaconf import ReadonlyConfigError, OmegaConf
 
 
 @pytest.mark.parametrize(
     "src, func, expectation",
     [
-        (
-            {},
-            lambda c: c.__setitem__("a", 1),
-            pytest.raises(ReadonlyConfigError, match="a"),
-        ),
+        ({}, lambda c: c.__setitem__("a", 1), raises(ReadonlyConfigError, match="a"),),
         (
             dict(a=dict(b=dict(c=1))),
             lambda c: c.__getattr__("a").__getattr__("b").__setitem__("c", 1),
-            pytest.raises(ReadonlyConfigError, match="a.b.c"),
-        ),
-        (
-            {},
-            lambda c: c.update("a.b", 10),
-            pytest.raises(ReadonlyConfigError, match="a"),
+            raises(ReadonlyConfigError, match="a.b.c"),
         ),
+        ({}, lambda c: c.update("a.b", 10), raises(ReadonlyConfigError, match="a"),),
         (
             dict(a=10),
             lambda c: c.__setattr__("a", 1),
-            pytest.raises(ReadonlyConfigError, match="a"),
-        ),
-        (
-            dict(a=10),
-            lambda c: c.pop("a"),
-            pytest.raises(ReadonlyConfigError, match="a"),
+            raises(ReadonlyConfigError, match="a"),
         ),
+        (dict(a=10), lambda c: c.pop("a"), raises(ReadonlyConfigError, match="a"),),
         (
             dict(a=10),
             lambda c: c.__delitem__("a"),
-            pytest.raises(ReadonlyConfigError, match="a"),
+            raises(ReadonlyConfigError, match="a"),
         ),
+        # list
+        ([], lambda c: c.__setitem__(0, 1), raises(ReadonlyConfigError, match="0"),),
+        ([], lambda c: c.update("0.b", 10), raises(ReadonlyConfigError, match="[0]"),),
+        ([10], lambda c: c.pop(), raises(ReadonlyConfigError)),
+        ([0], lambda c: c.__delitem__(0), raises(ReadonlyConfigError, match="[0]"),),
     ],
 )
 def test_readonly(src, func, expectation):
@@ -81,7 +74,7 @@ def test_readonly_nested_list():
 def test_readonly_list_insert():
     c = OmegaConf.create([])
     OmegaConf.set_readonly(c, True)
-    with pytest.raises(ReadonlyConfigError, match="[0]"):
+    with raises(ReadonlyConfigError, match="[0]"):
         c.insert(0, 10)
     assert c == []
 
@@ -90,7 +83,7 @@ def test_readonly_list_insert_deep():
     src = [dict(a=[dict(b=[])])]
     c = OmegaConf.create(src)
     OmegaConf.set_readonly(c, True)
-    with pytest.raises(ReadonlyConfigError, match=re.escape("[0].a[0].b[0]")):
+    with raises(ReadonlyConfigError, match=re.escape("[0].a[0].b[0]")):
         c[0].a[0].b.insert(0, 10)
     assert c == src
 
@@ -98,7 +91,7 @@ def test_readonly_list_insert_deep():
 def test_readonly_list_append():
     c = OmegaConf.create([])
     OmegaConf.set_readonly(c, True)
-    with pytest.raises(ReadonlyConfigError, match="[0]"):
+    with raises(ReadonlyConfigError, match="[0]"):
         c.append(10)
     assert c == []
 
@@ -106,7 +99,7 @@ def test_readonly_list_append():
 def test_readonly_list_change_item():
     c = OmegaConf.create([1, 2, 3])
     OmegaConf.set_readonly(c, True)
-    with pytest.raises(ReadonlyConfigError, match="[1]"):
+    with raises(ReadonlyConfigError, match="[1]"):
         c[1] = 10
     assert c == [1, 2, 3]
 
@@ -114,7 +107,7 @@ def test_readonly_list_change_item():
 def test_readonly_list_pop():
     c = OmegaConf.create([1, 2, 3])
     OmegaConf.set_readonly(c, True)
-    with pytest.raises(ReadonlyConfigError, match="[1]"):
+    with raises(ReadonlyConfigError, match="[1]"):
         c.pop(1)
     assert c == [1, 2, 3]
 
@@ -122,7 +115,7 @@ def test_readonly_list_pop():
 def test_readonly_list_del():
     c = OmegaConf.create([1, 2, 3])
     OmegaConf.set_readonly(c, True)
-    with pytest.raises(ReadonlyConfigError, match="[1]"):
+    with raises(ReadonlyConfigError, match="[1]"):
         del c[1]
     assert c == [1, 2, 3]
 
@@ -130,7 +123,7 @@ def test_readonly_list_del():
 def test_readonly_list_sort():
     c = OmegaConf.create([3, 1, 2])
     OmegaConf.set_readonly(c, True)
-    with pytest.raises(ReadonlyConfigError):
+    with raises(ReadonlyConfigError):
         c.sort()
     assert c == [3, 1, 2]
 
@@ -139,5 +132,5 @@ def test_readonly_from_cli():
     c = OmegaConf.create({"foo": {"bar": [1]}})
     OmegaConf.set_readonly(c, True)
     cli = OmegaConf.from_dotlist(["foo.bar=[2]"])
-    with pytest.raises(ReadonlyConfigError, match="foo.bar"):
+    with raises(ReadonlyConfigError, match="foo.bar"):
         OmegaConf.merge(c, cli)