diff --git a/changelog/6856.improvement.md b/changelog/6856.improvement.md new file mode 100644 index 000000000000..20350f1e0416 --- /dev/null +++ b/changelog/6856.improvement.md @@ -0,0 +1,16 @@ +Improved exception handling within Rasa Open Source. + +All exceptions that are somewhat expected (e.g. errors in file formats like +configurations or training data) will share a common base class +`RasaException`. + +::warning Backwards Incompatibility +Base class for the exception raised when an action can not be found has been changed +from a `NameError` to a `ValueError`. +:: + +Some other exceptions have also slightly changed: +- raise `YamlSyntaxException` instead of YAMLError (from ruamel) when + failing to load a yaml file with information about the line where loading failed +- introduced `MissingDependencyException` as an exception raised if packages + need to be installed diff --git a/rasa/__main__.py b/rasa/__main__.py index d64c78f8aee5..fc5315632e66 100644 --- a/rasa/__main__.py +++ b/rasa/__main__.py @@ -1,29 +1,33 @@ -import sys import argparse import logging +import os import platform +import sys + +from rasa_sdk import __version__ as rasa_sdk_version -import rasa.utils.io from rasa import version -import rasa.telemetry from rasa.cli import ( - scaffold, - run, - train, + data, + export, interactive, + run, + scaffold, shell, telemetry, test, + train, visualize, - data, x, - export, ) from rasa.cli.arguments.default_arguments import add_logging_options from rasa.cli.utils import parse_last_positional_argument_as_model_path -from rasa.utils.common import set_log_level, set_log_and_warnings_filters +from rasa.shared.exceptions import RasaException +from rasa.shared.utils.cli import print_error +import rasa.telemetry +from rasa.utils.common import set_log_and_warnings_filters, set_log_level +import rasa.utils.io import rasa.utils.tensorflow.environment as tf_env -from rasa_sdk import __version__ as rasa_sdk_version logger = logging.getLogger(__name__) @@ -89,8 +93,6 @@ def print_version() -> None: def main() -> None: # Running as standalone python application - import os - import sys parse_last_positional_argument_as_model_path() arg_parser = create_argument_parser() @@ -106,17 +108,25 @@ def main() -> None: # insert current path in syspath so custom modules are found sys.path.insert(1, os.getcwd()) - if hasattr(cmdline_arguments, "func"): - rasa.utils.io.configure_colored_logging(log_level) - set_log_and_warnings_filters() - rasa.telemetry.initialize_error_reporting() - cmdline_arguments.func(cmdline_arguments) - elif hasattr(cmdline_arguments, "version"): - print_version() - else: - # user has not provided a subcommand, let's print the help - logger.error("No command specified.") - arg_parser.print_help() + try: + if hasattr(cmdline_arguments, "func"): + rasa.utils.io.configure_colored_logging(log_level) + set_log_and_warnings_filters() + rasa.telemetry.initialize_error_reporting() + cmdline_arguments.func(cmdline_arguments) + elif hasattr(cmdline_arguments, "version"): + print_version() + else: + # user has not provided a subcommand, let's print the help + logger.error("No command specified.") + arg_parser.print_help() + sys.exit(1) + except RasaException as e: + # these are exceptions we expect to happen (e.g. invalid training data format) + # it doesn't make sense to print a stacktrace for these if we are not in + # debug mode + logger.debug("Failed to run CLI command due to an exception.", exc_info=e) + print_error(f"{e.__class__.__name__}: {e}") sys.exit(1) diff --git a/rasa/cli/test.py b/rasa/cli/test.py index 8858ed7d793f..0151926ac35b 100644 --- a/rasa/cli/test.py +++ b/rasa/cli/test.py @@ -127,12 +127,10 @@ def run_nlu_test(args: argparse.Namespace) -> None: for file in args.config: try: validation_utils.validate_yaml_schema( - rasa.shared.utils.io.read_file(file), - CONFIG_SCHEMA_FILE, - show_validation_errors=False, + rasa.shared.utils.io.read_file(file), CONFIG_SCHEMA_FILE, ) config_files.append(file) - except validation_utils.InvalidYamlFileError: + except validation_utils.YamlValidationException: logger.debug( f"Ignoring file '{file}' as it is not a valid config file." ) diff --git a/rasa/core/actions/action.py b/rasa/core/actions/action.py index 684ed298e6be..8b4972e70bea 100644 --- a/rasa/core/actions/action.py +++ b/rasa/core/actions/action.py @@ -35,6 +35,7 @@ ACTION_BACK_NAME, REQUESTED_SLOT, ) +from rasa.shared.exceptions import RasaException from rasa.shared.nlu.constants import INTENT_NAME_KEY, INTENT_RANKING_KEY from rasa.shared.core.events import ( UserUtteranceReverted, @@ -661,7 +662,7 @@ def name(self) -> Text: return self._name -class ActionExecutionRejection(Exception): +class ActionExecutionRejection(RasaException): """Raising this exception will allow other policies to predict a different action""" diff --git a/rasa/core/evaluate.py b/rasa/core/evaluate.py deleted file mode 100644 index 6f9a10838256..000000000000 --- a/rasa/core/evaluate.py +++ /dev/null @@ -1,10 +0,0 @@ -import logging - -logger = logging.getLogger(__name__) - -if __name__ == "__main__": # pragma: no cover - raise RuntimeError( - "Calling `rasa.core.evaluate` directly is no longer supported. Please use " - "`rasa test` to test a combined Core and NLU model or `rasa test core` " - "to test a Core model." - ) diff --git a/rasa/core/featurizers/tracker_featurizers.py b/rasa/core/featurizers/tracker_featurizers.py index 4fe45d57c8e4..7d42e9f5ea25 100644 --- a/rasa/core/featurizers/tracker_featurizers.py +++ b/rasa/core/featurizers/tracker_featurizers.py @@ -3,6 +3,7 @@ import jsonpickle import logging +from rasa.shared.exceptions import RasaException from rasa.shared.nlu.constants import TEXT from tqdm import tqdm from typing import Tuple, List, Optional, Dict, Text, Union @@ -22,7 +23,7 @@ logger = logging.getLogger(__name__) -class InvalidStory(Exception): +class InvalidStory(RasaException): """Exception that can be raised if story cannot be featurized.""" def __init__(self, message) -> None: @@ -30,10 +31,7 @@ def __init__(self, message) -> None: super(InvalidStory, self).__init__() def __str__(self) -> Text: - # return message in error colours - return rasa.shared.utils.io.wrap_with_color( - self.message, color=rasa.shared.utils.io.bcolors.FAIL - ) + return self.message class TrackerFeaturizer: diff --git a/rasa/core/policies/ensemble.py b/rasa/core/policies/ensemble.py index b59bc1f0c40a..a9f39c9e37d3 100644 --- a/rasa/core/policies/ensemble.py +++ b/rasa/core/policies/ensemble.py @@ -9,6 +9,7 @@ from typing import Text, Optional, Any, List, Dict, Tuple, Set, NamedTuple, Union import rasa.core +from rasa.shared.exceptions import RasaException import rasa.shared.utils.common import rasa.shared.utils.io import rasa.utils.io @@ -380,7 +381,6 @@ def from_dict(cls, policy_configuration: Dict[Text, Any]) -> List[Policy]: parsed_policies = [] for policy in policies: - policy_name = policy.pop("name") if policy.get("featurizer"): featurizer_func, featurizer_config = cls.get_featurizer_from_dict( policy @@ -401,6 +401,7 @@ def from_dict(cls, policy_configuration: Dict[Text, Any]) -> List[Policy]: # override policy's featurizer with real featurizer class policy["featurizer"] = featurizer_func(**featurizer_config) + policy_name = policy.pop("name") try: constr_func = registry.policy_from_module_path(policy_name) try: @@ -424,7 +425,11 @@ def from_dict(cls, policy_configuration: Dict[Text, Any]) -> List[Policy]: def get_featurizer_from_dict(cls, policy) -> Tuple[Any, Any]: # policy can have only 1 featurizer if len(policy["featurizer"]) > 1: - raise InvalidPolicyConfig("policy can have only 1 featurizer") + raise InvalidPolicyConfig( + f"Every policy can only have 1 featurizer " + f"but '{policy.get('name')}' " + f"uses {len(policy['featurizer'])} featurizers." + ) featurizer_config = policy["featurizer"][0] featurizer_name = featurizer_config.pop("name") featurizer_func = registry.featurizer_from_module_path(featurizer_name) @@ -435,7 +440,11 @@ def get_featurizer_from_dict(cls, policy) -> Tuple[Any, Any]: def get_state_featurizer_from_dict(cls, featurizer_config) -> Tuple[Any, Any]: # featurizer can have only 1 state featurizer if len(featurizer_config["state_featurizer"]) > 1: - raise InvalidPolicyConfig("featurizer can have only 1 state featurizer") + raise InvalidPolicyConfig( + f"Every featurizer can only have 1 state " + f"featurizer but one of the featurizers uses " + f"{len(featurizer_config['state_featurizer'])}." + ) state_featurizer_config = featurizer_config["state_featurizer"][0] state_featurizer_name = state_featurizer_config.pop("name") state_featurizer_func = registry.state_featurizer_from_module_path( @@ -737,7 +746,7 @@ def _check_policy_for_forms_available( ) -class InvalidPolicyConfig(Exception): +class InvalidPolicyConfig(RasaException): """Exception that can be raised when policy config is not valid.""" pass diff --git a/rasa/core/policies/rule_policy.py b/rasa/core/policies/rule_policy.py index 14c24095c22b..5d1ef365f5e9 100644 --- a/rasa/core/policies/rule_policy.py +++ b/rasa/core/policies/rule_policy.py @@ -6,6 +6,7 @@ import json from rasa.shared.constants import DOCS_URL_RULES +from rasa.shared.exceptions import RasaException import rasa.shared.utils.io from rasa.shared.core.events import LoopInterrupted, UserUttered, ActionExecuted from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer @@ -56,20 +57,17 @@ DO_NOT_PREDICT_LOOP_ACTION = "do_not_predict_loop_action" -class InvalidRule(Exception): +class InvalidRule(RasaException): """Exception that can be raised when rules are not valid.""" def __init__(self, message: Text) -> None: super().__init__() - self.message = message + ( - f"\nYou can find more information about the usage of " - f"rules at {DOCS_URL_RULES}. " - ) + self.message = message def __str__(self) -> Text: - # return message in error colours - return rasa.shared.utils.io.wrap_with_color( - self.message, color=rasa.shared.utils.io.bcolors.FAIL + return self.message + ( + f"\nYou can find more information about the usage of " + f"rules at {DOCS_URL_RULES}. " ) @@ -370,7 +368,7 @@ def _find_contradicting_rules( if error_messages: error_messages = "\n".join(error_messages) raise InvalidRule( - f"\nContradicting rules or stories found🚨\n\n{error_messages}\n" + f"\nContradicting rules or stories found 🚨\n\n{error_messages}\n" f"Please update your stories and rules so that they don't contradict " f"each other." ) diff --git a/rasa/core/run.py b/rasa/core/run.py index 027b097df45d..58f62ba12257 100644 --- a/rasa/core/run.py +++ b/rasa/core/run.py @@ -290,11 +290,3 @@ async def load_agent_on_start( logger.info("Rasa server is up and running.") return app.agent - - -if __name__ == "__main__": - raise RuntimeError( - "Calling `rasa.core.run` directly is no longer supported. " - "Please use `rasa run` to start a Rasa server or `rasa shell` to chat with " - "your bot on the command line." - ) diff --git a/rasa/core/test.py b/rasa/core/test.py index 8ca3113d0a84..25fe7d2e4875 100644 --- a/rasa/core/test.py +++ b/rasa/core/test.py @@ -876,11 +876,3 @@ async def _evaluate_core_model(model: Text, stories_file: Text) -> int: ) failed_stories = story_eval_store.failed_stories return number_of_stories - len(failed_stories) - - -if __name__ == "__main__": - raise RuntimeError( - "Calling `rasa.core.test` directly is no longer supported. Please use " - "`rasa test` to test a combined Core and NLU model or `rasa test core` " - "to test a Core model." - ) diff --git a/rasa/core/train.py b/rasa/core/train.py index b1375070669b..4074884ac7d8 100644 --- a/rasa/core/train.py +++ b/rasa/core/train.py @@ -179,11 +179,3 @@ def do_interactive_learning( conversation_id=args.conversation_id, server_args=args.__dict__, ) - - -if __name__ == "__main__": - raise RuntimeError( - "Calling `rasa.core.train` directly is no longer supported. Please use " - "`rasa train` to train a combined Core and NLU model or `rasa train core` " - "to train a Core model." - ) diff --git a/rasa/core/visualize.py b/rasa/core/visualize.py index 1ac6ec413283..b436249afda2 100644 --- a/rasa/core/visualize.py +++ b/rasa/core/visualize.py @@ -63,11 +63,3 @@ async def visualize( import webbrowser webbrowser.open(full_output_path) - - -if __name__ == "__main__": - raise RuntimeError( - "Calling `rasa.core.visualize` directly is " - "no longer supported. " - "Please use `rasa visualize` instead." - ) diff --git a/rasa/nlu/components.py b/rasa/nlu/components.py index 0b35cf9e5bd8..ec35e7fa1ca1 100644 --- a/rasa/nlu/components.py +++ b/rasa/nlu/components.py @@ -1,8 +1,10 @@ +from collections import defaultdict import itertools import logging import typing from typing import Any, Dict, Hashable, List, Optional, Set, Text, Tuple, Type, Iterable +from rasa.shared.exceptions import RasaException from rasa.shared.nlu.constants import TRAINABLE_EXTRACTORS from rasa.nlu.config import RasaNLUModelConfig, override_defaults, InvalidConfigError from rasa.shared.nlu.training_data.training_data import TrainingData @@ -15,6 +17,10 @@ logger = logging.getLogger(__name__) +class MissingDependencyException(RasaException): + """Raised if a python package dependency is needed, but not installed.""" + + def find_unavailable_packages(package_names: List[Text]) -> Set[Text]: """Tries to import all package names and returns the packages where it failed. @@ -46,21 +52,32 @@ def validate_requirements(component_names: List[Text]) -> None: from rasa.nlu import registry # Validate that all required packages are installed - failed_imports = set() + failed_imports = {} for component_name in component_names: component_class = registry.get_component_class(component_name) - failed_imports.update( - find_unavailable_packages(component_class.required_packages()) + unavailable_packages = find_unavailable_packages( + component_class.required_packages() ) + if unavailable_packages: + failed_imports[component_name] = unavailable_packages if failed_imports: # pragma: no cover - # if available, use the development file to figure out the correct - # version numbers for each requirement - raise Exception( - f"Not all required importable packages are installed. " + dependency_component_map = defaultdict(list) + for component, missing_dependencies in failed_imports.items(): + for dependency in missing_dependencies: + dependency_component_map[dependency].append(component) + + missing_lines = [ + f"{d} (needed for {', '.join(cs)})" + for d, cs in dependency_component_map.items() + ] + missing = "\n - ".join(missing_lines) + raise MissingDependencyException( + f"Not all required importable packages are installed to use " + f"the configured NLU pipeline. " 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)}" + f"missing modules: \n" + f" - {missing}\n" + f"Please install the packages that contain the missing modules." ) @@ -75,9 +92,7 @@ def validate_empty_pipeline(pipeline: List["Component"]) -> None: raise InvalidConfigError( "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 " - "NOT supported anymore." + "the configuration using the 'pipeline' key." ) @@ -97,8 +112,9 @@ def validate_only_one_tokenizer_is_used(pipeline: List["Component"]) -> None: if len(tokenizer_names) > 1: raise InvalidConfigError( - f"More than one tokenizer is used: {tokenizer_names}. " - f"You can use only one tokenizer." + f"The pipeline configuration contains more than one tokenizer, " + f"which is not possible at this time. You can only use one tokenizer. " + f"The pipeline contains the following tokenizers: {tokenizer_names}. " ) @@ -135,10 +151,14 @@ def validate_required_components(pipeline: List["Component"]) -> None: if not _required_component_in_pipeline(required_component, pipeline[:i]): missing_components.append(required_component.name) + missing_components_str = ", ".join(f"'{c}'" for c in missing_components) + if missing_components: raise InvalidConfigError( - f"'{component.name}' requires {missing_components}. " - f"Add required components to the pipeline." + f"The pipeline configuration contains errors. The component " + f"'{component.name}' requires {missing_components_str} to be " + f"placed before it in the pipeline. Please " + f"add the required components to the pipeline." ) @@ -283,7 +303,7 @@ def __str__(self) -> Text: return self.message -class UnsupportedLanguageError(Exception): +class UnsupportedLanguageError(RasaException): """Raised when a component is created but the language is not supported. Attributes: diff --git a/rasa/nlu/config.py b/rasa/nlu/config.py index 5c7aa40cc9d2..a9fd4dbf953e 100644 --- a/rasa/nlu/config.py +++ b/rasa/nlu/config.py @@ -4,6 +4,7 @@ import ruamel.yaml as yaml from typing import Any, Dict, List, Optional, Text, Union +from rasa.shared.exceptions import RasaException import rasa.shared.utils.io import rasa.utils.io from rasa.shared.constants import ( @@ -16,12 +17,9 @@ logger = logging.getLogger(__name__) -class InvalidConfigError(ValueError): +class InvalidConfigError(ValueError, RasaException): """Raised if an invalid configuration is encountered.""" - def __init__(self, message: Text) -> None: - super().__init__(message) - def load( config: Optional[Union[Text, Dict]] = None, **kwargs: Any @@ -34,12 +32,7 @@ def load( config = DEFAULT_CONFIG_PATH if config is not None: - try: - file_config = rasa.shared.utils.io.read_config_file(config) - except yaml.parser.ParserError as e: - raise InvalidConfigError( - f"Failed to read configuration file '{config}'. Error: {e}" - ) + file_config = rasa.shared.utils.io.read_config_file(config) return _load_from_dict(file_config, **kwargs) diff --git a/rasa/nlu/convert.py b/rasa/nlu/convert.py index 637d3220b5f8..4cc0c576ae1c 100644 --- a/rasa/nlu/convert.py +++ b/rasa/nlu/convert.py @@ -35,11 +35,3 @@ def convert_training_data( def main(args: argparse.Namespace): convert_training_data(args.data, args.out, args.format, args.language) - - -if __name__ == "__main__": - raise RuntimeError( - "Calling `rasa.nlu.convert` directly is " - "no longer supported. " - "Please use `rasa data` instead." - ) diff --git a/rasa/nlu/evaluate.py b/rasa/nlu/evaluate.py deleted file mode 100644 index 2e465599b065..000000000000 --- a/rasa/nlu/evaluate.py +++ /dev/null @@ -1,12 +0,0 @@ -import logging - - -logger = logging.getLogger(__name__) - - -if __name__ == "__main__": # pragma: no cover - raise RuntimeError( - "Calling `rasa.nlu.evaluate` directly is no longer supported. Please use " - "`rasa test` to test a combined Core and NLU model or `rasa test nlu` " - "to test an NLU model." - ) diff --git a/rasa/nlu/model.py b/rasa/nlu/model.py index d6354acc3237..231c8c26f0da 100644 --- a/rasa/nlu/model.py +++ b/rasa/nlu/model.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional, Text import rasa.nlu +from rasa.shared.exceptions import RasaException import rasa.shared.utils.io import rasa.utils.io from rasa.constants import MINIMUM_COMPATIBLE_VERSION, NLU_MODEL_NAME_PREFIX @@ -31,7 +32,7 @@ logger = logging.getLogger(__name__) -class InvalidModelError(Exception): +class InvalidModelError(RasaException): """Raised when a model failed to load. Attributes: @@ -46,7 +47,7 @@ def __str__(self) -> Text: return self.message -class UnsupportedModelError(Exception): +class UnsupportedModelError(RasaException): """Raised when a model is too old to be loaded. Attributes: diff --git a/rasa/nlu/registry.py b/rasa/nlu/registry.py index f0993b4b3e65..4f0891ddda9f 100644 --- a/rasa/nlu/registry.py +++ b/rasa/nlu/registry.py @@ -5,6 +5,7 @@ import this in module scope.""" import logging +import traceback import typing from typing import Any, Dict, Optional, Text, Type @@ -41,7 +42,9 @@ from rasa.nlu.utils.mitie_utils import MitieNLP from rasa.nlu.utils.spacy_utils import SpacyNLP from rasa.nlu.utils.hugging_face.hf_transformers import HFTransformersNLP +from rasa.shared.exceptions import RasaException import rasa.shared.utils.common +import rasa.shared.utils.io import rasa.utils.io from rasa.shared.constants import DOCS_URL_COMPONENTS @@ -95,13 +98,20 @@ registered_components = {c.name: c for c in component_classes} +class ComponentNotFoundException(ModuleNotFoundError, RasaException): + """Raised if a module referenced by name can not be imported.""" + + pass + + def get_component_class(component_name: Text) -> Type["Component"]: """Resolve component name to a registered components class.""" if component_name == "DucklingHTTPExtractor": rasa.shared.utils.io.raise_deprecation_warning( - "The component 'DucklingHTTPExtractor' has been renamed to 'DucklingEntityExtractor'." - "Update your pipeline to use 'DucklingEntityExtractor'.", + "The component 'DucklingHTTPExtractor' has been renamed to " + "'DucklingEntityExtractor'. Update your pipeline to use " + "'DucklingEntityExtractor'.", docs=DOCS_URL_COMPONENTS, ) component_name = "DucklingEntityExtractor" @@ -110,31 +120,41 @@ def get_component_class(component_name: Text) -> Type["Component"]: try: return rasa.shared.utils.common.class_from_module_path(component_name) - except AttributeError: - # when component_name is a path to a class but the path does not contain - # that class - module_name, _, class_name = component_name.rpartition(".") - raise Exception( - f"Failed to find class '{class_name}' in module '{module_name}'.\n" - ) - except ImportError as e: + except (ImportError, AttributeError) as e: # when component_name is a path to a class but that path is invalid or # when component_name is a class name and not part of old_style_names is_path = "." in component_name if is_path: - module_name, _, _ = component_name.rpartition(".") - exception_message = f"Failed to find module '{module_name}'. \n{e}" + module_name, _, class_name = component_name.rpartition(".") + if isinstance(e, ImportError): + exception_message = f"Failed to find module '{module_name}'." + else: + # when component_name is a path to a class but the path does + # not contain that class + exception_message = ( + f"The class '{class_name}' could not be " + f"found in module '{module_name}'." + ) else: exception_message = ( - f"Cannot find class '{component_name}' from global namespace. " + f"Cannot find class '{component_name}' in global namespace. " f"Please check that there is no typo in the class " f"name and that you have imported the class into the global " f"namespace." ) - raise ModuleNotFoundError(exception_message) + raise ComponentNotFoundException( + f"Failed to load the component " + f"'{component_name}'. " + f"{exception_message} Either your " + f"pipeline configuration contains an error " + f"or the module you are trying to import " + f"is broken (e.g. the module is trying " + f"to import a package that is not " + f"installed). {traceback.format_exc()}" + ) return registered_components[component_name] diff --git a/rasa/nlu/run.py b/rasa/nlu/run.py index 515c15cbc12f..4471ea706e30 100644 --- a/rasa/nlu/run.py +++ b/rasa/nlu/run.py @@ -2,7 +2,7 @@ import typing from typing import Optional, Text -from rasa.shared.utils.cli import print_success +from rasa.shared.utils.cli import print_info, print_success from rasa.shared.nlu.interpreter import RegexInterpreter from rasa.shared.constants import INTENT_MESSAGE_PREFIX from rasa.nlu.model import Interpreter @@ -24,18 +24,15 @@ def run_cmdline( print_success("NLU model loaded. Type a message and press enter to parse it.") while True: print_success("Next message:") - message = input().strip() + try: + message = input().strip() + except (EOFError, KeyboardInterrupt): + print_info("Wrapping up command line chat...") + break + if message.startswith(INTENT_MESSAGE_PREFIX): result = rasa.utils.common.run_in_loop(regex_interpreter.parse(message)) else: result = interpreter.parse(message) print(json_to_string(result)) - - -if __name__ == "__main__": - raise RuntimeError( - "Calling `rasa.nlu.run` directly is no longer supported. " - "Please use `rasa run` to start a Rasa server or `rasa shell` to use your " - "NLU model to interpret text via the command line." - ) diff --git a/rasa/nlu/test.py b/rasa/nlu/test.py index 43ce4b9e74d8..52e672dedff6 100644 --- a/rasa/nlu/test.py +++ b/rasa/nlu/test.py @@ -1998,11 +1998,3 @@ def log_entity_results(results: EntityMetrics, dataset_name: Text) -> None: for extractor, result in results.items(): logger.info(f"Entity extractor: {extractor}") log_results(result, dataset_name) - - -if __name__ == "__main__": - raise RuntimeError( - "Calling `rasa.nlu.test` directly is no longer supported. Please use " - "`rasa test` to test a combined Core and NLU model or `rasa test nlu` " - "to test an NLU model." - ) diff --git a/rasa/nlu/train.py b/rasa/nlu/train.py index 93f400d7eafc..abbf3f00294e 100644 --- a/rasa/nlu/train.py +++ b/rasa/nlu/train.py @@ -121,11 +121,3 @@ async def train( persisted_path = None return trainer, interpreter, persisted_path - - -if __name__ == "__main__": - raise RuntimeError( - "Calling `rasa.nlu.train` directly is no longer supported. Please use " - "`rasa train` to train a combined Core and NLU model or `rasa train nlu` " - "to train an NLU model." - ) diff --git a/rasa/server.py b/rasa/server.py index 6aef3bc81c32..939233106141 100644 --- a/rasa/server.py +++ b/rasa/server.py @@ -1219,7 +1219,7 @@ def _model_output_directory(save_to_default_model_directory: bool) -> Text: def _validate_yaml_training_payload(yaml_text: Text) -> None: try: - RasaYAMLReader.validate(yaml_text) + RasaYAMLReader().validate(yaml_text) except Exception as e: raise ErrorResponse( 400, diff --git a/rasa/shared/core/domain.py b/rasa/shared/core/domain.py index 7448e24497c6..fe0685ff1a78 100644 --- a/rasa/shared/core/domain.py +++ b/rasa/shared/core/domain.py @@ -22,6 +22,7 @@ import rasa.shared.constants import rasa.shared.core.constants +from rasa.shared.exceptions import RasaException import rasa.shared.nlu.constants import rasa.shared.utils.validation import rasa.shared.utils.io @@ -69,18 +70,12 @@ logger = logging.getLogger(__name__) -class InvalidDomain(Exception): +class InvalidDomain(RasaException): """Exception that can be raised when domain is not valid.""" - def __init__(self, message) -> None: - self.message = message - super(InvalidDomain, self).__init__() - def __str__(self) -> Text: - # return message in error colours - return rasa.shared.utils.io.wrap_with_color( - self.message, color=rasa.shared.utils.io.bcolors.FAIL - ) +class ActionNotFoundException(ValueError, RasaException): + """Raised when an action name could not be found.""" class SessionConfig(NamedTuple): @@ -153,7 +148,8 @@ def from_yaml(cls, yaml: Text, original_filename: Text = "") -> "Domain": rasa.shared.utils.validation.validate_yaml_schema( yaml, rasa.shared.constants.DOMAIN_SCHEMA_FILE ) - except rasa.shared.utils.validation.InvalidYamlFileError as e: + except rasa.shared.utils.validation.YamlValidationException as e: + e.filename = original_filename raise InvalidDomain(str(e)) data = rasa.shared.utils.io.read_yaml(yaml) @@ -601,7 +597,7 @@ def index_for_action(self, action_name: Text) -> Optional[int]: def raise_action_not_found_exception(self, action_name) -> NoReturn: action_names = "\n".join([f"\t - {a}" for a in self.action_names]) - raise NameError( + raise ActionNotFoundException( f"Cannot access action '{action_name}', " f"as that name is not a registered " f"action for this domain. " diff --git a/rasa/shared/core/training_data/story_reader/yaml_story_reader.py b/rasa/shared/core/training_data/story_reader/yaml_story_reader.py index 48d25f331771..f7b595e1d71c 100644 --- a/rasa/shared/core/training_data/story_reader/yaml_story_reader.py +++ b/rasa/shared/core/training_data/story_reader/yaml_story_reader.py @@ -129,7 +129,7 @@ def read_from_parsed_yaml( KEY_STORIES: StoryParser, KEY_RULES: RuleParser, }.items(): - data = parsed_content.get(key, []) + data = parsed_content.get(key) or [] parser = parser_class.from_reader(self) parser.parse_data(data) self.story_steps.extend(parser.get_steps()) diff --git a/rasa/shared/exceptions.py b/rasa/shared/exceptions.py index 2fb2e32ecc6d..ff77c251852e 100644 --- a/rasa/shared/exceptions.py +++ b/rasa/shared/exceptions.py @@ -1,5 +1,8 @@ +from typing import Text + + class RasaException(Exception): - """Base exception class for all errors raised by Rasa.""" + """Base exception class for all errors raised by Rasa Open Source.""" class RasaCoreException(RasaException): @@ -8,3 +11,20 @@ class RasaCoreException(RasaException): class RasaXTermsError(RasaException): """Error in case the user didn't accept the Rasa X terms.""" + + +class YamlSyntaxException(RasaException): + """Raised when a YAML file can not be parsed properly due to a syntax error.""" + + def __init__(self, filename: Text, underlying_yaml_exception: Exception): + self.underlying_yaml_exception = underlying_yaml_exception + self.filename = filename + + def __str__(self) -> Text: + exception_text = ( + f"Failed to read '{self.filename}'. " f"{self.underlying_yaml_exception}" + ) + exception_text = exception_text.replace( + 'in ""', f'in "{self.filename}"' + ) + return exception_text diff --git a/rasa/shared/importers/rasa.py b/rasa/shared/importers/rasa.py index 4d7c0bb4fdad..341b2e1aef11 100644 --- a/rasa/shared/importers/rasa.py +++ b/rasa/shared/importers/rasa.py @@ -69,7 +69,7 @@ async def get_domain(self) -> Domain: except InvalidDomain as e: rasa.shared.utils.io.raise_warning( f"Loading domain from '{self._domain_path}' failed. Using " - f"empty domain. Error: '{e.message}'" + f"empty domain. Error: '{e}'" ) return domain diff --git a/rasa/shared/nlu/training_data/formats/rasa_yaml.py b/rasa/shared/nlu/training_data/formats/rasa_yaml.py index d5ce9e0cdb03..121819357900 100644 --- a/rasa/shared/nlu/training_data/formats/rasa_yaml.py +++ b/rasa/shared/nlu/training_data/formats/rasa_yaml.py @@ -4,6 +4,7 @@ from typing import Text, Any, List, Dict, Tuple, Union, Iterator, Optional import rasa.shared.data +from rasa.shared.exceptions import RasaException from rasa.shared.utils import validation from ruamel.yaml import YAMLError, StringIO @@ -53,15 +54,15 @@ def __init__(self) -> None: self.lookup_tables: List[Dict[Text, Any]] = [] self.responses: Dict[Text, List[Dict[Text, Any]]] = {} - @staticmethod - def validate(string: Text) -> None: + def validate(self, string: Text) -> None: """Check if the string adheres to the NLU yaml data schema. If the string is not in the right format, an exception will be raised.""" try: validation.validate_yaml_schema(string, NLU_SCHEMA_FILE) - except validation.InvalidYamlFileError as e: - raise ValueError from e + except validation.YamlValidationException as e: + e.filename = self.filename + raise e def reads(self, string: Text, **kwargs: Any) -> "TrainingData": """Reads TrainingData in YAML format from a string. diff --git a/rasa/shared/utils/io.py b/rasa/shared/utils/io.py index 73975e11ba5f..50199ee442cb 100644 --- a/rasa/shared/utils/io.py +++ b/rasa/shared/utils/io.py @@ -1,23 +1,24 @@ +from collections import OrderedDict import errno import glob +from hashlib import md5 +from io import StringIO import json import os +from pathlib import Path import re +from typing import Any, Dict, List, Optional, Text, Type, Union import warnings -from collections import OrderedDict -from hashlib import md5 -from io import StringIO -from pathlib import Path -from typing import Any, Text, Optional, Type, Union, List, Dict -import rasa.shared +from ruamel import yaml as yaml +from ruamel.yaml import RoundTripRepresenter, YAMLError + from rasa.shared.constants import ( DEFAULT_LOG_LEVEL, ENV_LOG_LEVEL, NEXT_MAJOR_VERSION_FOR_DEPRECATIONS, ) -from ruamel import yaml as yaml -from ruamel.yaml import RoundTripRepresenter +from rasa.shared.exceptions import YamlSyntaxException DEFAULT_ENCODING = "utf-8" YAML_VERSION = (1, 2) @@ -228,11 +229,12 @@ def env_var_constructor(loader, node): yaml.SafeConstructor.add_constructor("!env_var", env_var_constructor) -def read_yaml(content: Text) -> Any: +def read_yaml(content: Text, reader_type: Union[Text, List[Text]] = "safe") -> Any: """Parses yaml from a text. Args: content: A text containing yaml content. + reader_type: Reader type to use. By default "safe" will be used Raises: ruamel.yaml.parser.ParserError: If there was an error when parsing the YAML. @@ -241,7 +243,7 @@ def read_yaml(content: Text) -> Any: replace_environment_variables() - yaml_parser = yaml.YAML(typ="safe") + yaml_parser = yaml.YAML(typ=reader_type) yaml_parser.version = YAML_VERSION yaml_parser.preserve_quotes = True @@ -423,17 +425,23 @@ def read_config_file(filename: Text) -> Dict[Text, Any]: Args: filename: The path to the file which should be read. """ - content = read_yaml(read_file(filename)) + try: + content = read_yaml(read_file(filename)) + except YAMLError as e: + raise YamlSyntaxException(filename, e) if content is None: return {} elif isinstance(content, dict): return content else: - raise ValueError( - "Tried to load invalid config file '{}'. " - "Expected a key value mapping but found {}" - ".".format(filename, type(content)) + raise YamlSyntaxException( + filename, + ValueError( + "Tried to load configuration file '{}'. " + "Expected a key value mapping but found a {}" + ".".format(filename, type(content).__name__) + ), ) diff --git a/rasa/shared/utils/validation.py b/rasa/shared/utils/validation.py index 66c397d1d864..808fac0fcc08 100644 --- a/rasa/shared/utils/validation.py +++ b/rasa/shared/utils/validation.py @@ -1,12 +1,14 @@ import logging -from typing import Text, Dict, Any +from typing import Text, Dict, List, Optional, Any from packaging import version from packaging.version import LegacyVersion +from pykwalify.errors import SchemaError from ruamel.yaml.constructor import DuplicateKeyError import rasa.shared +from rasa.shared.exceptions import RasaException import rasa.shared.utils.io from rasa.shared.constants import ( DOCS_URL_TRAINING_DATA_NLU, @@ -22,23 +24,102 @@ KEY_TRAINING_DATA_FORMAT_VERSION = "version" -class InvalidYamlFileError(ValueError): - """Raised if an invalid yaml file was provided.""" +class YamlValidationException(ValueError, RasaException): + """Raised if a yaml file does not correspond to the expected schema.""" - def __init__(self, message: Text) -> None: - super().__init__(message) + def __init__( + self, + message: Text, + validation_errors: Optional[List[SchemaError.SchemaErrorEntry]] = None, + filename: Optional[Text] = None, + content: Any = None, + ) -> None: + """Create The Error. + Args: + message: error message + validation_errors: validation errors + filename: name of the file which was validated + content: yaml content loaded from the file (used for line information) + """ + self.message = message + self.filename = filename + self.validation_errors = validation_errors + self.content = content + super(YamlValidationException, self).__init__() -def validate_yaml_schema( - yaml_file_content: Text, schema_path: Text, show_validation_errors: bool = True -) -> None: + def __str__(self) -> Text: + msg = "" + if self.filename: + msg += f"Failed to validate '{self.filename}'. " + else: + msg += "Failed to validate yaml. " + msg += self.message + if self.validation_errors: + unique_errors = {} + for error in self.validation_errors: + line_number = self._line_number_for_path(self.content, error.path) + + if line_number and self.filename: + error_representation = f" in {self.filename}:{line_number}:\n" + elif line_number: + error_representation = f" in Line {line_number}:\n" + else: + error_representation = "" + + error_representation += f" {error}" + unique_errors[str(error)] = error_representation + error_msg = "\n".join(unique_errors.values()) + msg += f":\n{error_msg}" + return msg + + def _line_number_for_path(self, current: Any, path: Text) -> Optional[int]: + """Get line number for a yaml path in the current content. + + Implemented using recursion: algorithm goes down the path navigating to the + leaf in the YAML tree. Unfortunately, not all nodes returned from the + ruamel yaml parser have line numbers attached (arrays have them, dicts have + them), e.g. strings don't have attached line numbers. + If we arrive at a node that has no line number attached, we'll return the + line number of the parent - that is as close as it gets. + + Args: + current: current content + path: path to traverse within the content + + Returns: + the line number of the path in the content. + """ + if not current: + return None + + this_line = current.lc.line + 1 if hasattr(current, "lc") else None + + if not path: + return this_line + + if "/" in path: + head, tail = path.split("/", 1) + else: + head, tail = path, "" + + if head: + if isinstance(current, dict): + return self._line_number_for_path(current[head], tail) or this_line + elif isinstance(current, list) and head.isdigit(): + return self._line_number_for_path(current[int(head)], tail) or this_line + else: + return this_line + return self._line_number_for_path(current, tail) or this_line + + +def validate_yaml_schema(yaml_file_content: Text, schema_path: Text) -> None: """ Validate yaml content. Args: yaml_file_content: the content of the yaml file to be validated schema_path: the schema of the yaml file - show_validation_errors: if true, validation errors are shown """ from pykwalify.core import Core from pykwalify.errors import SchemaError @@ -47,48 +128,53 @@ def validate_yaml_schema( import logging log = logging.getLogger("pykwalify") - if show_validation_errors: - log.setLevel(logging.WARN) - else: - log.setLevel(logging.CRITICAL) + log.setLevel(logging.CRITICAL) try: - source_data = rasa.shared.utils.io.read_yaml(yaml_file_content) + # we need "rt" since + # it will add meta information to the parsed output. this meta information + # will include e.g. at which line an object was parsed. this is very + # helpful when we validate files later on and want to point the user to the + # right line + source_data = rasa.shared.utils.io.read_yaml( + yaml_file_content, reader_type=["safe", "rt"] + ) except YAMLError: - raise InvalidYamlFileError( + raise YamlValidationException( "The provided yaml file is invalid. You can use " "http://www.yamllint.com/ to validate the yaml syntax " "of your file." ) except DuplicateKeyError as e: - raise InvalidYamlFileError( + raise YamlValidationException( "The provided yaml file contains a duplicated key: '{}'. You can use " "http://www.yamllint.com/ to validate the yaml syntax " "of your file.".format(str(e)) ) - try: - schema_file = pkg_resources.resource_filename(PACKAGE_NAME, schema_path) - schema_utils_file = pkg_resources.resource_filename( - PACKAGE_NAME, RESPONSES_SCHEMA_FILE - ) - schema_extensions = pkg_resources.resource_filename( - PACKAGE_NAME, SCHEMA_EXTENSIONS_FILE - ) + schema_file = pkg_resources.resource_filename(PACKAGE_NAME, schema_path) + schema_utils_file = pkg_resources.resource_filename( + PACKAGE_NAME, RESPONSES_SCHEMA_FILE + ) + schema_extensions = pkg_resources.resource_filename( + PACKAGE_NAME, SCHEMA_EXTENSIONS_FILE + ) - c = Core( - source_data=source_data, - schema_files=[schema_file, schema_utils_file], - extensions=[schema_extensions], - ) + c = Core( + source_data=source_data, + schema_files=[schema_file, schema_utils_file], + extensions=[schema_extensions], + ) + + try: c.validate(raise_exception=True) except SchemaError: - raise InvalidYamlFileError( - "Failed to validate yaml file. " + raise YamlValidationException( "Please make sure the file is correct and all " - "mandatory parameters are specified; to do so, " - "take a look at the errors logged during " - "validation previous to this exception." + "mandatory parameters are specified. Here are the errors " + "found during validation", + c.errors, + content=source_data, ) diff --git a/rasa/telemetry.py b/rasa/telemetry.py index c23f70ece76a..7a3733b0fb0d 100644 --- a/rasa/telemetry.py +++ b/rasa/telemetry.py @@ -28,6 +28,7 @@ CONFIG_TELEMETRY_ID, ) from rasa.shared.constants import DOCS_URL_TELEMETRY +from rasa.shared.exceptions import RasaException import rasa.shared.utils.io from rasa.utils import common as rasa_utils import rasa.utils.io @@ -575,7 +576,11 @@ def strip_sensitive_data_from_sentry_event( for frame in value.get("stacktrace", {}).get("frames", []): frame["abs_path"] = "" - if "site-packages" in frame["filename"]: + if "rasa_sdk/executor.py" in frame["filename"]: + # this looks a lot like an exception in the SDK and hence custom code + # no need for us to deal with that + return None + elif "site-packages" in frame["filename"]: # drop site-packages and following slash / backslash relative_name = frame["filename"].split("site-packages")[-1][1:] frame["filename"] = os.path.join("site-packages", relative_name) @@ -625,7 +630,7 @@ def initialize_error_reporting() -> None: ], send_default_pii=False, # activate PII filter server_name=get_telemetry_id() or "UNKNOWN", - ignore_errors=[KeyboardInterrupt], + ignore_errors=[KeyboardInterrupt, RasaException], in_app_include=["rasa"], # only submit errors in this package with_locals=False, # don't submit local variables release=f"rasa-{rasa.__version__}", diff --git a/tests/core/test_actions.py b/tests/core/test_actions.py index a316d4a56c7b..9aa40ce32d23 100644 --- a/tests/core/test_actions.py +++ b/tests/core/test_actions.py @@ -20,7 +20,7 @@ ) from rasa.core.actions.forms import FormAction from rasa.core.channels import CollectingOutputChannel -from rasa.shared.core.domain import SessionConfig, Domain +from rasa.shared.core.domain import ActionNotFoundException, SessionConfig, Domain from rasa.shared.core.events import ( Restarted, SlotSet, @@ -734,5 +734,5 @@ def test_get_form_action_if_not_in_forms(): """ ) - with pytest.raises(NameError): + with pytest.raises(ActionNotFoundException): assert not action.action_for_name(form_action_name, domain, None) diff --git a/tests/core/test_agent.py b/tests/core/test_agent.py index 54c800c801a9..71fac039b0e4 100644 --- a/tests/core/test_agent.py +++ b/tests/core/test_agent.py @@ -327,7 +327,7 @@ def test_rule_policy_without_fallback_action_present( policies=PolicyEnsemble.from_dict(policy_config), ) - assert RulePolicy.__name__ in execinfo.value.message + assert RulePolicy.__name__ in str(execinfo.value) @pytest.mark.parametrize( diff --git a/tests/nlu/classifiers/test_diet_classifier.py b/tests/nlu/classifiers/test_diet_classifier.py index 40189b4e4259..dbf6e3ab11f0 100644 --- a/tests/nlu/classifiers/test_diet_classifier.py +++ b/tests/nlu/classifiers/test_diet_classifier.py @@ -176,10 +176,7 @@ async def test_raise_error_on_incorrect_pipeline(component_builder, tmp_path: Pa component_builder=component_builder, ) - assert ( - "'DIETClassifier' requires ['Featurizer']. " - "Add required components to the pipeline." in str(e.value) - ) + assert "'DIETClassifier' requires 'Featurizer'" in str(e.value) def as_pipeline(*components): diff --git a/tests/nlu/test_config.py b/tests/nlu/test_config.py index a960c3fc46fd..0b052c9a6286 100644 --- a/tests/nlu/test_config.py +++ b/tests/nlu/test_config.py @@ -5,6 +5,7 @@ import pytest from _pytest.monkeypatch import MonkeyPatch +from rasa.shared.exceptions import YamlSyntaxException from rasa.shared.importers import autoconfig from rasa.shared.importers.rasa import RasaFileImporter from rasa.nlu.config import RasaNLUModelConfig @@ -31,7 +32,7 @@ def test_invalid_config_json(tmp_path): f = tmp_path / "tmp_config_file.json" f.write_text(file_config) - with pytest.raises(config.InvalidConfigError): + with pytest.raises(YamlSyntaxException): config.load(str(f)) @@ -42,7 +43,7 @@ def test_invalid_many_tokenizers_in_config(): with pytest.raises(config.InvalidConfigError) as execinfo: Trainer(config.RasaNLUModelConfig(nlu_config)) - assert "More than one tokenizer is used" in str(execinfo.value) + assert "The pipeline configuration contains more than one" in str(execinfo.value) @pytest.mark.parametrize( @@ -63,7 +64,7 @@ def test_invalid_many_tokenizers_in_config(): def test_missing_required_component(_config): with pytest.raises(config.InvalidConfigError) as execinfo: Trainer(config.RasaNLUModelConfig(_config)) - assert "Add required components to the pipeline" in str(execinfo.value) + assert "The pipeline configuration contains errors" in str(execinfo.value) @pytest.mark.parametrize( @@ -72,7 +73,7 @@ def test_missing_required_component(_config): def test_missing_property(pipeline_config): with pytest.raises(config.InvalidConfigError) as execinfo: Trainer(config.RasaNLUModelConfig(pipeline_config)) - assert "Add required components to the pipeline" in str(execinfo.value) + assert "The pipeline configuration contains errors" in str(execinfo.value) def test_default_config_file(): diff --git a/tests/shared/utils/test_validation.py b/tests/shared/utils/test_validation.py index 4f7f250e8004..9ac3b00c171c 100644 --- a/tests/shared/utils/test_validation.py +++ b/tests/shared/utils/test_validation.py @@ -40,7 +40,7 @@ def test_validate_yaml_schema(file, schema): ], ) def test_validate_yaml_schema_raise_exception(file, schema): - with pytest.raises(validation_utils.InvalidYamlFileError): + with pytest.raises(validation_utils.YamlValidationException): validation_utils.validate_yaml_schema( rasa.shared.utils.io.read_file(file), schema )