diff --git a/errors.py b/errors.py deleted file mode 100644 index 4541405c253ca16e602f0438f682705a19660179..0000000000000000000000000000000000000000 --- a/errors.py +++ /dev/null @@ -1,67 +0,0 @@ -# GNU Lesser General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/lgpl-3.0.txt) -# (c) 2019, Christof Schulze -# -# This file is part of Ammsml -# -# Ammsml is free software: you can redistribute it and/or modify it -# under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ammsml is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Ammsml. If not, see . - -from ammsml.utils.parsing import to_text -from ammsml.utils.parsing_yaml import AMMSMLBaseYAMLObject - - -class AMMSMLError(Exception): - """ - This is the base class for all errors raised from Ammsml code, - and can be instantiated with two optional parameters beyond the - error message to control whether detailed information is displayed - when the error occurred while parsing a data file of some kind. - - Usage: - raise AMMSMLError('some message here', obj=obj, show_content=True) - - Where "obj" is some subclass of ammsml.utils.parsing.yaml.objects.AMMSMLBaseYAMLObject, - which should be returned by the DataLoader() class. - - Something like this, TODO to be improved - """ - - def __init__(self, message="", obj=None, show_content=True, suppress_extended_error=False, orig_exc=None): - super(AMMSMLError, self).__init__(message) - - self._obj = obj - self._show_content = show_content - - if obj and isinstance(obj, AMMSMLBaseYAMLObject): - extended_error = self._get_extended_error() - if extended_error and not suppress_extended_error: - self.message = '%s\n\n%s' % (to_text(message), to_text(extended_error)) - else: - self.message = '%s' % to_text(message) - else: - self.message = '%s' % to_text(message) - - -class AMMSMLParserError(AMMSMLError): - - pass - - -class TemplateParsingError(AMMSMLError): - """a templating failure""" - pass - - -class AMMSMLOptionsError(AMMSMLError): - - pass diff --git a/errors/__init__.py b/errors/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7596195c08db81fbc1f06854df37d7a54e542a5a --- /dev/null +++ b/errors/__init__.py @@ -0,0 +1,197 @@ +# GNU Lesser General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/lgpl-3.0.txt) +# (c) 2019, Christof Schulze +# +# This file is part of Ammsml +# +# Ammsml is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ammsml is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Ammsml. If not, see . + +import re + +from ammsml.utils.parsing import to_text +from ammsml.utils.parsing.yaml.objects import AMMSMLBaseYAMLObject + +from ammsml.utils.errors.yaml_strings import ( + YAML_COMMON_DICT_ERROR, + YAML_COMMON_LEADING_TAB_ERROR, + YAML_COMMON_PARTIALLY_QUOTED_LINE_ERROR, + YAML_COMMON_UNBALANCED_QUOTES_ERROR, + YAML_COMMON_UNQUOTED_COLON_ERROR, + YAML_COMMON_UNQUOTED_VARIABLE_ERROR, + YAML_POSITION_DETAILS, + YAML_AND_SHORTHAND_ERROR, +) + + +class AMMSMLError(Exception): + """ + This is the base class for all errors raised from Ammsml code, + and can be instantiated with two optional parameters beyond the + error message to control whether detailed information is displayed + when the error occurred while parsing a data file of some kind. + + Usage: + raise AMMSMLError('some message here', obj=obj, show_content=True) + + Where "obj" is some subclass of ammsml.utils.parsing.yaml.objects.AMMSMLBaseYAMLObject, + which should be returned by the DataLoader() class. + + Something like this, TODO to be improved + """ + + def __init__(self, message="", obj=None, show_content=True, suppress_extended_error=False, orig_exc=None): + super(AMMSMLError, self).__init__(message) + + self._obj = obj + self._show_content = show_content + + if obj and isinstance(obj, AMMSMLBaseYAMLObject): + extended_error = self._get_extended_error() + if extended_error and not suppress_extended_error: + self.message = '%s\n\n%s' % (to_text(message), to_text(extended_error)) + else: + self.message = '%s' % to_text(message) + else: + self.message = '%s' % to_text(message) + if orig_exc: + self.orig_exc = orig_exc + + def __str__(self): + return self.message + + def __repr__(self): + return self.message + + def _get_error_lines_from_file(self, file_name, line_number): + """ + Returns the line in the file which corresponds to the reported error + location, as well as the line preceding it (if the error did not + occur on the first line), to provide context to the error. + """ + + target_line = '' + prev_line = '' + + with open(file_name, 'r') as f: + lines = f.readlines() + + target_line = lines[line_number] + if line_number > 0: + prev_line = lines[line_number - 1] + + return target_line, prev_line + + def _get_extended_error(self): + """ + Given an object reporting the location of the exception in a file, return + detailed information regarding it including: + * the line which caused the error as well as the one preceding it + * causes and suggested remedies for common syntax errors + If this error was created with show_content=False, the reporting of content + is suppressed, as the file contents may be sensitive (ie. vault data). + TODO clean this up, much to long + """ + + error_message = '' + + try: + (src_file, line_number, col_number) = self._obj.ammsml_pos + error_message += YAML_POSITION_DETAILS % (src_file, line_number, col_number) + if src_file not in ('', '') and self._show_content: + (target_line, prev_line) = self._get_error_lines_from_file(src_file, line_number - 1) + target_line = to_text(target_line) + prev_line = to_text(prev_line) + if target_line: + stripped_line = target_line.replace(" ", "") + + # Check for k=v syntax in addition to YAML syntax and set the appropriate error position, + # arrow index + if re.search(r'\w+(\s+)?=(\s+)?[\w/-]+', prev_line): + error_position = prev_line.rstrip().find('=') + arrow_line = (" " * error_position) + "^ here" + error_message = YAML_POSITION_DETAILS % (src_file, line_number - 1, error_position + 1) + error_message += "\nThe offending line appears to be:\n\n%s\n%s\n\n" %\ + (prev_line.rstrip(), arrow_line) + error_message += YAML_AND_SHORTHAND_ERROR + else: + arrow_line = (" " * (col_number - 1)) + "^ here" + error_message += "\nThe offending line appears to be:\n\n%s\n%s\n%s\n" % ( + prev_line.rstrip(), target_line.rstrip(), arrow_line) + + # TODO: There may be cases where there is a valid tab in a line that has other errors. + if '\t' in target_line: + error_message += YAML_COMMON_LEADING_TAB_ERROR + # common error/remediation checking here: + # check for unquoted vars starting lines + if ('{{' in target_line and '}}' in target_line) and ( + '"{{' not in target_line or "'{{" not in target_line): + error_message += YAML_COMMON_UNQUOTED_VARIABLE_ERROR + # check for common dictionary mistakes + elif ":{{" in stripped_line and "}}" in stripped_line: + error_message += YAML_COMMON_DICT_ERROR + # check for common unquoted colon mistakes + elif (len(target_line) and + len(target_line) > 1 and + len(target_line) > col_number and + target_line[col_number] == ":" and + target_line.count(':') > 1): + error_message += YAML_COMMON_UNQUOTED_COLON_ERROR + # otherwise, check for some common quoting mistakes + else: + # FIXME: This needs to split on the first ':' to account for modules like lineinfile + # that may have lines that contain legitimate colons, e.g., line: 'i ALL= (ALL) NOPASSWD: ALL' + # and throw off the quote matching logic. + parts = target_line.split(":") + if len(parts) > 1: + middle = parts[1].strip() + match = False + unbalanced = False + + if middle.startswith("'") and not middle.endswith("'"): + match = True + elif middle.startswith('"') and not middle.endswith('"'): + match = True + + if (len(middle) > 0 and + middle[0] in ['"', "'"] and + middle[-1] in ['"', "'"] and + target_line.count("'") > 2 or + target_line.count('"') > 2): + unbalanced = True + + if match: + error_message += YAML_COMMON_PARTIALLY_QUOTED_LINE_ERROR + if unbalanced: + error_message += YAML_COMMON_UNBALANCED_QUOTES_ERROR + + except (IOError, TypeError): + error_message += '\n(could not open file to display line)' + except IndexError: + error_message += '\n(specified line no longer in file, maybe it changed?)' + + return error_message + + +class AMMSMLParserError(AMMSMLError): + + pass + + +class TemplateParsingError(AMMSMLError): + """a templating failure""" + pass + + +class AMMSMLOptionsError(AMMSMLError): + + pass diff --git a/errors/yaml_strings.py b/errors/yaml_strings.py new file mode 100644 index 0000000000000000000000000000000000000000..f778de9704685583430f6a794f30019364381b47 --- /dev/null +++ b/errors/yaml_strings.py @@ -0,0 +1,95 @@ +__all__ = [ + 'YAML_SYNTAX_ERROR', + 'YAML_POSITION_DETAILS', + 'YAML_COMMON_DICT_ERROR', + 'YAML_COMMON_UNQUOTED_VARIABLE_ERROR', + 'YAML_COMMON_UNQUOTED_COLON_ERROR', + 'YAML_COMMON_PARTIALLY_QUOTED_LINE_ERROR', + 'YAML_COMMON_UNBALANCED_QUOTES_ERROR', +] + +YAML_SYNTAX_ERROR = """\ +Syntax Error while loading YAML. + %s""" + +YAML_POSITION_DETAILS = """\ +The error appears to be in '%s': line %s, column %s, but may +be elsewhere in the file depending on the exact syntax problem. +""" + +YAML_COMMON_DICT_ERROR = """\ +This one looks easy to fix. YAML thought it was looking for the start of a +hash/dictionary and was confused to see a second "{". Most likely this was +meant to be an ammsml template evaluation instead, so we have to give the +parser a small hint that we wanted a string instead. The solution here is to +just quote the entire value. +For instance, if the original line was: + app_path: {{ base_path }}/foo +It should be written as: + app_path: "{{ base_path }}/foo" +""" + +YAML_COMMON_UNQUOTED_VARIABLE_ERROR = """\ +We could be wrong, but this one looks like it might be an issue with +missing quotes. Always quote template expression brackets when they +start a value. For instance: + with_items: + - {{ foo }} +Should be written as: + with_items: + - "{{ foo }}" +""" + +YAML_COMMON_UNQUOTED_COLON_ERROR = """\ +This one looks easy to fix. There seems to be an extra unquoted colon in the line +and this is confusing the parser. It was only expecting to find one free +colon. The solution is just add some quotes around the colon, or quote the +entire line after the first colon. +For instance, if the original line was: + copy: src=file.txt dest=/path/filename:with_colon.txt +It can be written as: + copy: src=file.txt dest='/path/filename:with_colon.txt' +Or: + copy: 'src=file.txt dest=/path/filename:with_colon.txt' +""" + +YAML_COMMON_PARTIALLY_QUOTED_LINE_ERROR = """\ +This one looks easy to fix. It seems that there is a value started +with a quote, and the YAML parser is expecting to see the line ended +with the same kind of quote. For instance: + when: "ok" in result.stdout +Could be written as: + when: '"ok" in result.stdout' +Or equivalently: + when: "'ok' in result.stdout" +""" + +YAML_COMMON_UNBALANCED_QUOTES_ERROR = """\ +We could be wrong, but this one looks like it might be an issue with +unbalanced quotes. If starting a value with a quote, make sure the +line ends with the same set of quotes. For instance this arbitrary +example: + foo: "bad" "wolf" +Could be written as: + foo: '"bad" "wolf"' +""" + +YAML_COMMON_LEADING_TAB_ERROR = """\ +There appears to be a tab character at the start of the line. +YAML does not use tabs for formatting. Tabs should be replaced with spaces. +For example: + - name: update tooling + vars: + version: 1.2.3 +# ^--- there is a tab there. +Should be written as: + - name: update tooling + vars: + version: 1.2.3 +# ^--- all spaces here. +""" + +YAML_AND_SHORTHAND_ERROR = """\ +There appears to be both 'k=v' shorthand syntax and YAML in this task. \ +Only one syntax may be used. +""" \ No newline at end of file diff --git a/parsing/yaml/__init__.py b/parsing/yaml/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/parsing/yaml/dumper.py b/parsing/yaml/dumper.py new file mode 100644 index 0000000000000000000000000000000000000000..947c8a59090f8d38e097f6795a2fd5893a4b93d4 --- /dev/null +++ b/parsing/yaml/dumper.py @@ -0,0 +1,28 @@ +import yaml + + +# from ammsml.vars.hostvars import HostVars, HostVarsVars +from ammsml.utils.parsing.yaml.objects import AMMSMLUnicode, AMMSMLSequence, AMMSMLMapping + + +class AmmsmlDumper(yaml.SafeDumper): + """ + A simple stub class that allows us to add representers + for our overridden object types. + """ + pass + + +def represent_hostvars(self, data): + return self.represent_dict(dict(data)) + + +represent_unicode = yaml.representer.SafeRepresenter.represent_str +represent_binary = yaml.representer.SafeRepresenter.represent_binary + +AmmsmlDumper.add_representer( + AMMSMLUnicode, + represent_unicode, +) + +# TODO add more representers \ No newline at end of file diff --git a/parsing_yaml.py b/parsing/yaml/objects.py similarity index 100% rename from parsing_yaml.py rename to parsing/yaml/objects.py