Checkpoint commit / importlib-inspect approach
[salty_linter.git] / salty_linter / __init__.py
index a5cf233..11e70ca 100644 (file)
@@ -1,10 +1,12 @@
-import pprint
+import importlib
+import inspect
 
-def lint(sls_yaml, types):
+from salt.state import STATE_INTERNAL_KEYWORDS
+
+def lint(sls_yaml):
     """
     Check the sls_yaml for Salt errors / warnings
 
-    typedb : Nested dictionary of Salt states/functions/parameters (partial)
     sls_yaml:  ruamel.yaml[jinja2].load()'d output for some Salt state file.
 
     Returns: (errors, warnings)
@@ -12,67 +14,50 @@ def lint(sls_yaml, types):
     warnings: List of (line#, warnings) tuples
     """
 
-    pp = pprint.PrettyPrinter(indent=4)
-    #pp.pprint(dir(sls_yaml))
-    #pp.pprint(sls_yaml.__dict__)
+    module_cache = {}
 
-    
-    def lint_parameters(function_name, parameters, parameter_types):
+    def lint_parameters(state_name, function_name, actual_parameters, state_module):
         """
-        Input:
-          function_name:   String of the form "<state_name>.<function>" currently being linted
-          parameters:      Map of {paramter_name: parameter_data} items currently being linted
-          parameter_types: Map of {parameter_name: parameter_type} items
-        Output:
-          errors:          List of error string
-          warnings:        List of warning strings
         """
-        errors = []
-        warnings = []
-        for parameter in parameters:
-            parameter_name = next(iter(parameter.keys()))
-
-            ## parameter is either just a string or a {'parameter' : data} map, or always a map ?
-            
-            if parameter_name in types['globals']:
-                continue
-            elif parameter_name not in parameter_types:
-                s_warning = "Unexpected parameter name {} for {}"
-                warnings.append((parameter.lc.line + 1, s_warning.format(parameter_name, function_name)))
-            else:
-                ## TODO:  Add that parameter.values()[0]  matches the against parameter_types[parameter] type
-                pass
-        return (errors, warnings)
+        perrors = []
+        pwarnings = []
+        seen_parameters = []
 
-    
-    def lint_function(state_name, function_calls, function_types):
-        """
-        Input:
-          state_name:      String of the state_name currently being linted
-          function_calls:  Map of {function_name: parameters} items currently being linteds
-          function_types;  Map of {function_name: parameter_types}  items
-         Output:
-          errors:          List of error string
-          warnings:        List of warning strings
-        """
-        errors = []
-        warnings = []
-        for function_name, parameters in function_calls.items():
-            if function_name not in function_types:
-                warnings.append((parameters.lc.line+1, "Unexpected function name {}.{}.".format(state_name, function_name)))
+        try:
+            allowable_parameters = inspect.signature(vars(state_module)[function_name]).parameters
+        except Exception as e:
+            s_error = "Unexpected error looking up {}.{} parameters: {}."
+            perrors.append((1, s_error.format(state_name, function_name, e)))
+            return (perrors, pwarnings)
+
+        for a_parameter in actual_parameters:
+
+            # TODO: A comment explaining this line...
+            parameter_name = next(iter(a_parameter.keys()))
+
+            # Accept any of the global state arguments
+            # (maybe ignore 'state' and 'fun' ??)
+            if parameter_name in STATE_INTERNAL_KEYWORDS:
                 continue
+            elif parameter_name not in allowable_parameters:
+                s_warning = "Unexpected parameter '{}' for function '{}.{}'."
+                pwarnings.append((a_parameter.lc.line + 1, s_warning.format(parameter_name, state_name, function_name)))
+
+            # If there are duplicate parameters in the state, it looks like salt will use the last seen parameter,
+            # but it looks like that's undefined behavior, but its more likely some copypasta error on your part.
+            if parameter_name in seen_parameters:
+                s_error = "Duplicate parameter '{}' for function '{}.{}'."
+                perrors.append((a_parameter.lc.line + 1, s_error.format(parameter_name, state_name, function_name)))
             else:
-                (parameter_errors, parameter_warnings) = lint_parameters(state_name + "." + function_name, parameters, function_types[function_name])
-                errors += parameter_errors
-                warnings += parameter_warnings            
-        return (errors, warnings)
+                seen_parameters.append(parameter_name)
 
-    ## ------------------------------
+        return (perrors, pwarnings)
 
-    state_types = types['states']
+
+    ## ------------------------------
 
     errors = []
-    warnings = []    
+    warnings = []
     for label, label_value in sls_yaml.items():
 
         # It's possible the label_value is just a 'state.function'.  See example_sls/two-items.sls
@@ -80,60 +65,70 @@ def lint(sls_yaml, types):
             value = {label_value: []}
         else:
             value = label_value
-        
+
         for state_function_name, function_calls_or_parameters in value.items():
 
             # state_function_name should be 'state_name' or 'state_name.function_name'
             # sosf => 'state or state.function'
-            # = [state_name] or [state_name, function_name] 
+            # = [state_name] or [state_name, function_name]
             sosf = state_function_name.split('.')
 
-            # Verify that sosf[0] is an expected state:
+            # Load the state module if needed.
+            # If it's already been loaded, use the cached version
             state_name = ''
-            if sosf[0] not in state_types:
-                warnings.append((value.lc.line + 1, "Unexpected state name {}.".format(sosf[0])))
-                continue
-            else:
+            if sosf[0] in module_cache:
                 state_name = sosf[0]
+                state_mod = module_cache[state_name]
+            else:
+                try:
+                    state_mod = importlib.import_module('salt.states.' + sosf[0])
+                except ModuleNotFoundError:
+                    warnings.append((value.lc.line + 1, "Unexpected state name {}.".format(sosf[0])))
+                    continue
+                else:
+                    state_name = sosf[0]
+                    module_cache[state_name] = state_mod
 
             # "state" case
             # function_calls_or_parameters[0] will be the function name
-            # function_call_or_parameters[1:]  will be the parameters
+            # function_calls_or_parameters[1:]  will be the parameters
             if len(sosf) == 1:
 
                 if not function_calls_or_parameters:
                     errors.append(label_value.lc.line + 1, "Missing function name in parameter list for state {}.".format(state_name))
                     continue
-                
+
                 function_name = function_calls_or_parameters[0]
-                if function_name not in state_types[state_name]:
-                    warnings.append((state_function_name.lc.line + 1, "Unexpected function name {} for {}.".format(function_name, state_name)))
-                    continue                
-                parameters = function_call_or_parameters[1:]
-                (function_errors, function_warnings) = lint_function(state_name + "." + function_name, parameters, state_types[state_name][function_name])
-                errors += function_errors
-                warnings += function_warnings
+                parameters = function_calls_or_parameters[1:]
+
+                if function_name not in vars(state_mod):
+                    warnings.append((state_function_name.lc.line + 1, "Unexpected function name '{}' for state {}.".format(function_name, state_name)))
+                    continue
+
+                (perrors, pwarnings) = lint_parameters(state_name, function_name, parameters, state_mod)
+                errors += perrors
+                warnings += pwarnings
 
             # "state.function" case
             # function_calls_or_parameters will be a list of parameters
             elif len(sosf) == 2:
                 function_name = sosf[1]
-                if function_name not in state_types[state_name]:
+                if function_name not in vars(state_mod):
 
                     # TODO: This gives the line number at the top of the label block which isn't great.
                     # It would be better to give the exact like number of the invalid function
-                    warnings.append((sls_yaml.lc.key(label)[0] + 1, "Unexpected function name {} for {}.".format(function_name, state_name)))
+                    warnings.append((sls_yaml.lc.key(label)[0] + 1, "Unexpected function name '{}' for state {}.".format(function_name, state_name)))
                     continue
 
-                # Children should be parameters of "state.function"
-                (parameter_errors, parameter_warnings) = lint_parameters(state_name + "." + function_name, function_calls_or_parameters, state_types[state_name][function_name])
-                errors += parameter_errors
-                warnings += parameter_warnings
+                parameters = function_calls_or_parameters
+                (perrors, pwarnings) = lint_parameters(state_name, function_name, parameters, state_mod)
+                errors += perrors
+                warnings += pwarnings
 
             # Anything other than "state" or "state.function" is an error.
             else:
-                errors.append((value.lc.line + 1, "Expected <state> or <state>.<function>: Too many '.'s in {}".format(state_function_name)))
+                errors.append((value.lc.line + 1, "Expected <state> or <state>.<function>: Too many '.'s in {}.".format(state_function_name)))
                 continue
 
     return(errors, warnings)
-    
+