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?
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]

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.

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))

pip install -e .
parse_log_pytest
pytest
pytest mock
3.6
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs @ file:///opt/conda/conda-bld/attrs_1642510447205/work
certifi==2021.5.30
importlib-metadata @ file:///tmp/build/80754af9/importlib-metadata_1631916693255/work
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
mock==5.2.0
more-itertools @ file:///tmp/build/80754af9/more-itertools_1637733554872/work
packaging @ file:///tmp/build/80754af9/packaging_1637314298585/work
pluggy @ file:///tmp/build/80754af9/pluggy_1615976315926/work
py @ file:///opt/conda/conda-bld/py_1644396412707/work
pyparsing @ file:///tmp/build/80754af9/pyparsing_1635766073266/work
pytest==6.2.4
-e git+https://github.com/keleshev/schema.git@f463ab62d4d5078484e97fec071bac6876b9beef#egg=schema
toml @ file:///tmp/build/80754af9/toml_1616166611790/work
typing_extensions @ file:///opt/conda/conda-bld/typing_extensions_1647553014482/work
zipp @ file:///tmp/build/80754af9/zipp_1633618647012/work

name: schema
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - attrs=21.4.0=pyhd3eb1b0_0
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - importlib-metadata=4.8.1=py36h06a4308_0
  - importlib_metadata=4.8.1=hd3eb1b0_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - more-itertools=8.12.0=pyhd3eb1b0_0
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - packaging=21.3=pyhd3eb1b0_0
  - pip=21.2.2=py36h06a4308_0
  - pluggy=0.13.1=py36h06a4308_0
  - py=1.11.0=pyhd3eb1b0_0
  - pyparsing=3.0.4=pyhd3eb1b0_0
  - pytest=6.2.4=py36h06a4308_2
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - toml=0.10.2=pyhd3eb1b0_0
  - typing_extensions=4.1.1=pyh06a4308_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zipp=3.6.0=pyhd3eb1b0_0
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - mock==5.2.0
prefix: /opt/conda/envs/schema

test_schema.py::test_or_only_one
test_schema.py::test_schema test_schema.py::test_validate_file test_schema.py::test_and test_schema.py::test_or test_schema.py::test_test test_schema.py::test_regex test_schema.py::test_validate_list test_schema.py::test_list_tuple_set_frozenset test_schema.py::test_strictly test_schema.py::test_dict test_schema.py::test_dict_keys test_schema.py::test_ignore_extra_keys test_schema.py::test_ignore_extra_keys_validation_and_return_keys test_schema.py::test_dict_forbidden_keys test_schema.py::test_dict_hook test_schema.py::test_dict_optional_keys test_schema.py::test_dict_optional_defaults test_schema.py::test_dict_subtypes test_schema.py::test_dict_key_error test_schema.py::test_complex test_schema.py::test_nice_errors test_schema.py::test_use_error_handling test_schema.py::test_or_error_handling test_schema.py::test_and_error_handling test_schema.py::test_schema_error_handling test_schema.py::test_use_json test_schema.py::test_error_reporting test_schema.py::test_schema_repr test_schema.py::test_validate_object test_schema.py::test_issue_9_prioritized_key_comparison test_schema.py::test_issue_9_prioritized_key_comparison_in_dicts test_schema.py::test_missing_keys_exception_with_non_str_dict_keys test_schema.py::test_issue_56_cant_rely_on_callables_to_have_name test_schema.py::test_exception_handling_with_bad_validators test_schema.py::test_issue_83_iterable_validation_return_type test_schema.py::test_optional_key_convert_failed_randomly_while_with_another_optional_object test_schema.py::test_copy test_schema.py::test_inheritance
README.rst schema.py
README.rst schema.py