Repository: RasaHQ/rasa
Pull request: Add any_of helper method to component
**Proposed changes**:
- Add `one_of` to component. It allows you to say that the component requires either X, Y, or Z.

closes #5029

**Status (please check what you already did)**:
- [x] added some tests for the functionality
- [x] updated the documentation
- [ ] updated the changelog (please check [changelog](https://github.com/RasaHQ/rasa/tree/master/changelog) for instructions)
- [x] reformat files using `black` (please check [Readme](https://github.com/RasaHQ/rasa#code-style) for instructions)


Problem statement:
Add requries_one_of to custom NLU components
**Description of Problem**:
The `EmbeddingIntentClassifier` requires either `text_sparse_features` or `text_dense_features`. As long as one of them is present the classifier can be trained. However, if none is present, training will fail.
Currently, we only have the field `requires` to check if specific attributes are set on a message. However, all of them need to be set. It is not possible to say either X or Y need to be set.

**Overview of the Solution**:
Add a field `require_one_of` or something similar, that allows the user to easily define that either X or Y is required for a component. If the requirement is not fulfilled an error should be raised and inform the user that some values are missing.

Hints from issue discussion:

Commit message: update example component in test

Patch to review:
diff --git a/changelog/5029.feature.rst b/changelog/5029.feature.rst
new file mode 100644
index 000000000000..986f85ce7a1b
--- /dev/null
+++ b/changelog/5029.feature.rst
@@ -0,0 +1,5 @@
+Add ``one_of`` to component. Should be used inside ``requires``.
+
+``one_of`` allows you to define multiple attributes of a message for a component.
+At least one of those attributes needs to be present.
+Otherwise, the component cannot be trained and an error is raised.
\ No newline at end of file
diff --git a/rasa/nlu/classifiers/embedding_intent_classifier.py b/rasa/nlu/classifiers/embedding_intent_classifier.py
index 2c2ea1ef764b..422ea2587194 100644
--- a/rasa/nlu/classifiers/embedding_intent_classifier.py
+++ b/rasa/nlu/classifiers/embedding_intent_classifier.py
@@ -9,7 +9,7 @@
 import warnings
 
 from rasa.nlu.classifiers import LABEL_RANKING_LENGTH
-from rasa.nlu.components import Component
+from rasa.nlu.components import Component, one_of
 from rasa.utils import train_utils
 from rasa.utils.train_utils import SessionDataType
 from rasa.nlu.constants import (
@@ -54,7 +54,11 @@ class EmbeddingIntentClassifier(Component):
 
     provides = ["intent", "intent_ranking"]
 
-    requires = []
+    requires = [
+        one_of(
+            DENSE_FEATURE_NAMES[TEXT_ATTRIBUTE], SPARSE_FEATURE_NAMES[TEXT_ATTRIBUTE]
+        )
+    ]
 
     # default properties (DOC MARKER - don't remove)
     defaults = {
diff --git a/rasa/nlu/components.py b/rasa/nlu/components.py
index d5c3786887b2..6cc2c2927846 100644
--- a/rasa/nlu/components.py
+++ b/rasa/nlu/components.py
@@ -43,12 +43,11 @@ def validate_requirements(component_names: List[Text]) -> None:
         # if available, use the development file to figure out the correct
         # version numbers for each requirement
         raise Exception(
-            "Not all required importable packages are installed. "
-            + "To use this pipeline, you need to install the "
-            "missing dependencies. "
-            + "Please install the package(s) that contain the module(s): {}".format(
-                ", ".join(failed_imports)
-            )
+            f"Not all required importable packages are installed. "
+            f"To use this pipeline, you need to install the "
+            f"missing dependencies. "
+            f"Please install the package(s) that contain the module(s): "
+            f"{', '.join(failed_imports)}"
         )
 
 
@@ -65,8 +64,8 @@ def validate_arguments(
         raise ValueError(
             "Can not train an empty pipeline. "
             "Make sure to specify a proper pipeline in "
-            "the configuration using the `pipeline` key."
-            + "The `backend` configuration key is "
+            "the configuration using the `pipeline` key. "
+            "The `backend` configuration key is "
             "NOT supported anymore."
         )
 
@@ -74,15 +73,47 @@ def validate_arguments(
 
     for component in pipeline:
         for r in component.requires:
-            if r not in provided_properties:
-                raise Exception(
-                    "Failed to validate at component "
-                    "'{}'. Missing property: '{}'"
-                    "".format(component.name, r)
-                )
+            if isinstance(r, Tuple):
+                validate_requires_one_of(r, provided_properties, str(component.name))
+            else:
+                if r not in provided_properties:
+                    raise Exception(
+                        f"Failed to validate component {component.name}. "
+                        f"Missing property: '{r}'"
+                    )
+
         provided_properties.update(component.provides)
 
 
+def one_of(*args):
+    """Helper function to define that one of the given arguments is required
+    by a component.
+
+    Should be used inside `requires`."""
+    return args
+
+
+def validate_requires_one_of(
+    properties: Tuple[Text], provided_properties: Set[Text], component_name: Text
+):
+    """Validates that at least one of the given properties is present in
+    the provided properties."""
+
+    property_present = False
+
+    for property in properties:
+        if property in provided_properties:
+            property_present = True
+            break
+
+    if not property_present:
+        raise Exception(
+            f"Failed to validate component {component_name}. "
+            f"Missing one of the following properties: "
+            f"{properties}."
+        )
+
+
 def validate_required_components_from_data(
     pipeline: List["Component"], data: TrainingData
 ):
@@ -96,7 +127,7 @@ def validate_required_components_from_data(
     if len(data.response_examples) and not response_selector_exists:
         warnings.warn(
             "Training data consists examples for training a response selector but "
-            "no response selector component specified inside NLU pipeline"
+            "no response selector component specified inside NLU pipeline."
         )
 
 
@@ -131,9 +162,7 @@ def __init__(self, component: Text, language: Text) -> None:
         super().__init__(component, language)
 
     def __str__(self) -> Text:
-        return "component {} does not support language {}".format(
-            self.component, self.language
-        )
+        return f"component {self.component} does not support language {self.language}"
 
 
 class ComponentMetaclass(type):
@@ -185,9 +214,12 @@ def name(self):
     provides = []
 
     # Which attributes on a message are required by this
-    # component. e.g. if requires contains "tokens", than a
+    # component. E.g. if requires contains "tokens", than a
     # previous component in the pipeline needs to have "tokens"
     # within the above described `provides` property.
+    # Use `one_of("option_1", "option_2")` to define that either
+    # "option_1" or "option_2" needs to be present in the
+    # provided properties from the previous components.
     requires = []
 
     # Defines the default configuration parameters of a component
@@ -420,8 +452,7 @@ def __add_to_cache(self, component: Component, cache_key: Optional[Text]) -> Non
         if cache_key is not None and self.use_cache:
             self.component_cache[cache_key] = component
             logger.info(
-                "Added '{}' to component cache. Key '{}'."
-                "".format(component.name, cache_key)
+                f"Added '{component.name}' to component cache. Key '{cache_key}'."
             )
 
     def load_component(
@@ -462,8 +493,8 @@ def load_component(
             return component
         except MissingArgumentError as e:  # pragma: no cover
             raise Exception(
-                "Failed to load component from file `{}`. "
-                "{}".format(component_meta.get("file"), e)
+                f"Failed to load component from file `{component_meta.get('file')}`. "
+                f"Error: {e}"
             )
 
     def create_component(
@@ -484,6 +515,6 @@ def create_component(
             return component
         except MissingArgumentError as e:  # pragma: no cover
             raise Exception(
-                "Failed to create component `{}`. "
-                "{}".format(component_config["name"], e)
+                f"Failed to create component `{component_config['name']}`. "
+                f"Error: {e}"
             )
diff --git a/tests/nlu/classifiers/test_embedding_intent_classifier.py b/tests/nlu/classifiers/test_embedding_intent_classifier.py
index 1c2c1edc237f..f55bb8ae76af 100644
--- a/tests/nlu/classifiers/test_embedding_intent_classifier.py
+++ b/tests/nlu/classifiers/test_embedding_intent_classifier.py
@@ -2,6 +2,7 @@
 import pytest
 import scipy.sparse
 
+from rasa.nlu.config import RasaNLUModelConfig
 from rasa.nlu.constants import (
     TEXT_ATTRIBUTE,
     SPARSE_FEATURE_NAMES,
@@ -10,6 +11,7 @@
 )
 from rasa.nlu.classifiers.embedding_intent_classifier import EmbeddingIntentClassifier
 from rasa.nlu.training_data import Message
+from tests.nlu.conftest import DEFAULT_DATA_PATH
 
 
 def test_compute_default_label_features():
@@ -104,3 +106,25 @@ def test_check_labels_features_exist(messages, expected):
         EmbeddingIntentClassifier._check_labels_features_exist(messages, attribute)
         == expected
     )
+
+
+async def test_raise_error_on_incorrect_pipeline(component_builder, tmpdir):
+    from rasa.nlu import train
+
+    _config = RasaNLUModelConfig(
+        {
+            "pipeline": [
+                {"name": "WhitespaceTokenizer"},
+                {"name": "EmbeddingIntentClassifier"},
+            ],
+            "language": "en",
+        }
+    )
+
+    with pytest.raises(Exception):
+        await train(
+            _config,
+            path=tmpdir.strpath,
+            data=DEFAULT_DATA_PATH,
+            component_builder=component_builder,
+        )
diff --git a/tests/nlu/example_component.py b/tests/nlu/example_component.py
index 0c40757228ae..2dbb8e947a87 100644
--- a/tests/nlu/example_component.py
+++ b/tests/nlu/example_component.py
@@ -17,9 +17,12 @@ class MyComponent(Component):
     provides = []
 
     # Which attributes on a message are required by this
-    # component. e.g. if requires contains "tokens", than a
+    # component. E.g. if requires contains "tokens", than a
     # previous component in the pipeline needs to have "tokens"
     # within the above described `provides` property.
+    # Use `one_of("option_1", "option_2")` to define that either
+    # "option_1" or "option_2" needs to be present in the
+    # provided properties from the previous components.
     requires = []
 
     # Defines the default configuration parameters of a component


Code review comments:
diff_hunk: @@ -0,0 +1,5 @@
+Add ``one_of`` to component. Should be used inside ``requires``.
+
+``one_of`` allows you to define multiple attributes of a message for a component.
+At least one of those attributes needs to be present.
+Otherwise, the component cannot be trained and an error is raised.
path: changelog/5029.feature.rst
text: @user1:
My inspiration was this: https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/

in which case I guess this use case would actually be `any_of` if at least one but possibly multiple must be present. That makes me think -- can we make use of the python native `any()` function somehow?

@author:
Will rename it to `any_of`. I don't think we can use the builtin function `any`, at least I don't see how.

diff_hunk: @@ -65,24 +64,56 @@ def validate_arguments(
         raise ValueError(
             "Can not train an empty pipeline. "
             "Make sure to specify a proper pipeline in "
-            "the configuration using the `pipeline` key."
-            + "The `backend` configuration key is "
+            "the configuration using the `pipeline` key. "
+            "The `backend` configuration key is "
             "NOT supported anymore."
         )
 
     provided_properties = set(context.keys())
 
     for component in pipeline:
         for r in component.requires:
-            if r not in provided_properties:
-                raise Exception(
-                    "Failed to validate at component "
-                    "'{}'. Missing property: '{}'"
-                    "".format(component.name, r)
-                )
+            if isinstance(r, Tuple):
+                validate_requires_one_of(r, provided_properties, str(component.name))
+            else:
+                if r not in provided_properties:
+                    raise Exception(
+                        f"Failed to validate component {component.name}. "
+                        f"Missing property: '{r}'"
+                    )
+
         provided_properties.update(component.provides)
 
 
+def one_of(*args):
+    """Helper function to define that one of the given arguments is required
+    by a component.
+
+    Should be used inside `requires`."""
+    return args
+
+
+def validate_requires_one_of(
+    properties: Tuple[Text], provided_properties: Set[Text], component_name: Text
path: rasa/nlu/components.py
text: @user1:
```suggestion
    required_properties: Tuple[Text], provided_properties: Set[Text], component_name: Text
```