Repository: keleshev/schema

Issue:
XOr implementation
First of all, this library is awesome.

I've implemented a `XOr` class for exclusive schemas (shamelessly copying from the `Or` class). Maybe this is naively wrong, but so far this has worked as expected.

``` python
class XOr(And):

    def validate(self, data):
        accepted = False
        x = SchemaError([], [])
        for s in [Schema(s, error=self._error) for s in self._args]:
            try:
                validated = s.validate(data)
                if accepted:
                    raise SchemaError(['%r did not validate %r' % (self, data)],
                                      [self._error])
                accepted = True
            except SchemaError as _x:
                x = _x
        if not accepted:
            raise SchemaError(['%r did not validate %r' % (self, data)] + x.autos,
                              [self._error] + x.errors)
        return validated
```

If you are interested in this, I can open a PR.


Discussion:
codecov-io: # [Codecov](https://codecov.io/gh/keleshev/schema/pull/174?src=pr&el=h1) Report
> Merging [#174](https://codecov.io/gh/keleshev/schema/pull/174?src=pr&el=desc) into [master](https://codecov.io/gh/keleshev/schema/commit/23b729ec91cf12cbaf076e3171b68dd6ed49de0a?src=pr&el=desc) will **increase** coverage by `0.1%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/keleshev/schema/pull/174/graphs/tree.svg?width=650&token=kaGotSf549&height=150&src=pr)](https://codecov.io/gh/keleshev/schema/pull/174?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##           master     #174     +/-   ##
=========================================
+ Coverage   98.15%   98.26%   +0.1%     
=========================================
  Files           1        1             
  Lines         217      230     +13     
=========================================
+ Hits          213      226     +13     
  Misses          4        4
```


| [Impacted Files](https://codecov.io/gh/keleshev/schema/pull/174?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [schema.py](https://codecov.io/gh/keleshev/schema/pull/174/diff?src=pr&el=tree#diff-c2NoZW1hLnB5) | `98.26% <100%> (+0.1%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/keleshev/schema/pull/174?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/keleshev/schema/pull/174?src=pr&el=footer). Last update [23b729e...aeb5226](https://codecov.io/gh/keleshev/schema/pull/174?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

skorokithakis: Sorry, can you post a bit of code that illustrates the problem? I'm not sure I got it from the description...
julienduchesne: Oh sorry, it's mostly from #106. In short, Or("key", "newkey") in a dict will accept 
```{
  "key": value",
  "newkey": "value"
}```
This PR allows for an exclusive Or. This is useful in a deprecation use case. You want the user to switch to the new key name but you still want to support the old one. However, setting both can cause issues so it should be rejected.

julienduchesne: Oh sorry, it's mostly from #106. In short, Or("key", "newkey") in a dict will accept 
```{
  "key": value",
  "newkey": "value"
}```
This PR allows for an exclusive Or. This is useful in a deprecation use case. You want the user to switch to the new key name but you still want to support the old one. However, setting both can cause issues so it should be rejected.

julienduchesne: Oh sorry, it's mostly from #106. In short, Or("key", "newkey") in a dict will accept 
```{
  "key": value",
  "newkey": "value"
}```
This PR allows for an exclusive Or. This is useful in a deprecation use case. You want the user to switch to the new key name but you still want to support the old one. However, setting both can cause issues so it should be rejected.

julienduchesne: Oh sorry, it's mostly from #106. In short, Or("key", "newkey") in a dict will accept 
```{
  "key": value",
  "newkey": "value"
}```
This PR allows for an exclusive Or. This is useful in a deprecation use case. You want the user to switch to the new key name but you still want to support the old one. However, setting both can cause issues so it should be rejected.

julienduchesne: Oh sorry, it's mostly from #106. In short, Or("key", "newkey") in a dict will accept 
```{
  "key": value",
  "newkey": "value"
}```
This PR allows for an exclusive Or. This is useful in a deprecation use case. You want the user to switch to the new key name but you still want to support the old one. However, setting both can cause issues so it should be rejected.

skorokithakis: Oh sorry, I see what you mean. I thought your comment was the motivation for this PR, but I now realize it's a comment on the implementation. From a first look, this looks good, I will review in more depth soon!
skorokithakis: Github is having some trouble, I saw your comment posted ten times, I tried to delete a few but they went away again. I'll return later when Github is feeling better.
skorokithakis: Also, can you remove the [WIP] if this is good to merge?
julienduchesne: Github did not trigger travis but it should be ready now
skorokithakis: Hmm, I can't access the online conflict resolution UI either... I just merged your other PR, but it conflicts with this one, can you resolve so I can merge this too?

Patch:
diff --git a/README.rst b/README.rst
index 0d1855c..28186f8 100644
--- a/README.rst
+++ b/README.rst
@@ -298,6 +298,18 @@ for the same data:
     >>> Schema(And(Or(int, float), lambda x: x > 0)).validate(3.1415)
     3.1415
 
+In a dictionary, you can also combine two keys in a "one or the other" manner. To do
+so, use the `Or` class as a key:
+
+.. code:: python
+    from schema import Or, Schema
+    schema = Schema({
+        Or("key1", "key2", only_one=True): str
+    })
+
+    schema.validate({"key1": "test"}) # Ok
+    schema.validate({"key1": "test", "key2": "test"}) # SchemaWrongKeyError
+
 Hooks
 ~~~~~~~~~~
 You can define hooks which are functions that are executed whenever a valid key:value is found. 
diff --git a/schema.py b/schema.py
index 03e6909..cfae8b9 100644
--- a/schema.py
+++ b/schema.py
@@ -71,6 +71,7 @@ class And(object):
     """
     Utility function to combine validation directives in AND Boolean fashion.
     """
+
     def __init__(self, *args, **kw):
         self._args = args
         if not set(kw).issubset({'error', 'schema', 'ignore_extra_keys'}):
@@ -102,6 +103,15 @@ class And(object):
 class Or(And):
     """Utility function to combine validation directives in a OR Boolean
     fashion."""
+
+    def __init__(self, *args, **kwargs):
+        self.only_one = kwargs.pop('only_one', False)
+        self.reset()
+        super(Or, self).__init__(*args, **kwargs)
+
+    def reset(self):
+        self.got_one = False
+
     def validate(self, data):
         """
         Validate data using sub defined schema/expressions ensuring at least
@@ -114,7 +124,11 @@ class Or(And):
                                ignore_extra_keys=self._ignore_extra_keys)
                   for s in self._args]:
             try:
-                return s.validate(data)
+                validation = s.validate(data)
+                if self.got_one and self.only_one:
+                    break
+                self.got_one = True
+                return validation
             except SchemaError as _x:
                 autos, errors = _x.autos, _x.errors
         raise SchemaError(['%r did not validate %r' % (self, data)] + autos,
@@ -170,6 +184,7 @@ class Use(object):
     For more general use cases, you can use the Use class to transform
     the data while it is being validate.
     """
+
     def __init__(self, callable_, error=None):
         if not callable(callable_):
             raise TypeError('Expected a callable, not %r' % callable_)
@@ -217,6 +232,7 @@ class Schema(object):
     Entry point of the library, use this class to instantiate validation
     schema for the data that will be validated.
     """
+
     def __init__(self, schema, error=None, ignore_extra_keys=False):
         self._schema = schema
         self._error = error
@@ -266,6 +282,10 @@ class Schema(object):
             coverage = set()  # matched schema keys
             # for each key and value find a schema entry matching them, if any
             sorted_skeys = sorted(s, key=self._dict_key_priority)
+            for skey in sorted_skeys:
+                if isinstance(skey, Or):
+                    skey.reset()
+
             for key, value in data.items():
                 for skey in sorted_skeys:
                     svalue = s[skey]


Test patch:
diff --git a/test_schema.py b/test_schema.py
index 553e841..e230ba1 100644
--- a/test_schema.py
+++ b/test_schema.py
@@ -81,6 +81,16 @@ def test_or():
     with SE: Or().validate(2)
 
 
+def test_or_only_one():
+    schema = Schema({
+        Or("test1", "test2", only_one=True): str
+    })
+    assert schema.validate({"test1": "value"})
+    assert schema.validate({"test2": "other_value"})
+    with SE: schema.validate({"test1": "value", "test2": "other_value"})
+    with SE: schema.validate({"othertest": "value"})
+
+
 def test_test():
     def unique_list(_list):
         return len(_list) == len(set(_list))