1818from traceback import format_exc
1919
2020import ansible .module_utils .compat .typing as t
21+
22+ from .filter import AnsibleJinja2Filter
23+ from .test import AnsibleJinja2Test
24+
2125from ansible import __version__ as ansible_version
2226from ansible import constants as C
2327from ansible .errors import AnsibleError , AnsiblePluginCircularRedirect , AnsiblePluginRemovedError , AnsibleCollectionUnsupportedVersionError
@@ -1067,28 +1071,17 @@ class Jinja2Loader(PluginLoader):
10671071 We need to do a few things differently in the base class because of file == plugin
10681072 assumptions and dedupe logic.
10691073 """
1070- def __init__ (self , class_name , package , config , subdir , aliases = None , required_base_class = None ):
1071-
1074+ def __init__ (self , class_name , package , config , subdir , plugin_wrapper_type , aliases = None , required_base_class = None ):
10721075 super (Jinja2Loader , self ).__init__ (class_name , package , config , subdir , aliases = aliases , required_base_class = required_base_class )
1073- self ._loaded_j2_file_maps = []
1076+ self ._plugin_wrapper_type = plugin_wrapper_type
1077+ self ._cached_non_collection_wrappers = {}
10741078
10751079 def _clear_caches (self ):
10761080 super (Jinja2Loader , self )._clear_caches ()
1077- self ._loaded_j2_file_maps = []
1081+ self ._cached_non_collection_wrappers = {}
10781082
10791083 def find_plugin (self , name , mod_type = '' , ignore_deprecated = False , check_aliases = False , collection_list = None ):
1080-
1081- # TODO: handle collection plugin find, see 'get_with_context'
1082- # this can really 'find plugin file'
1083- plugin = super (Jinja2Loader , self ).find_plugin (name , mod_type = mod_type , ignore_deprecated = ignore_deprecated , check_aliases = check_aliases ,
1084- collection_list = collection_list )
1085-
1086- # if not found, try loading all non collection plugins and see if this in there
1087- if not plugin :
1088- all_plugins = self .all ()
1089- plugin = all_plugins .get (name , None )
1090-
1091- return plugin
1084+ raise NotImplementedError ('find_plugin is not supported on Jinja2Loader' )
10921085
10931086 @property
10941087 def method_map_name (self ):
@@ -1122,36 +1115,36 @@ def get_contained_plugins(self, collection, plugin_path, name):
11221115 for func_name , func in plugin_map :
11231116 fq_name = '.' .join ((collection , func_name ))
11241117 full = '.' .join ((full_name , func_name ))
1125- pclass = self ._load_jinja2_class ()
1126- plugin = pclass (func )
1118+ plugin = self ._plugin_wrapper_type (func )
11271119 if plugin in plugins :
11281120 continue
11291121 self ._update_object (plugin , full , plugin_path , resolved = fq_name )
11301122 plugins .append (plugin )
11311123
11321124 return plugins
11331125
1126+ # FUTURE: now that the resulting plugins are closer, refactor base class method with some extra
1127+ # hooks so we can avoid all the duplicated plugin metadata logic, and also cache the collection results properly here
11341128 def get_with_context (self , name , * args , ** kwargs ):
1135-
1136- # found_in_cache = True
1137- class_only = kwargs .pop ('class_only' , False ) # just pop it, dont want to pass through
1138- collection_list = kwargs .pop ('collection_list' , None )
1129+ # pop N/A kwargs to avoid passthrough to parent methods
1130+ kwargs .pop ('class_only' , False )
1131+ kwargs .pop ('collection_list' , None )
11391132
11401133 context = PluginLoadContext ()
11411134
11421135 # avoid collection path for legacy
11431136 name = name .removeprefix ('ansible.legacy.' )
11441137
1145- if '.' not in name :
1146- # Filter/tests must always be FQCN except builtin and legacy
1147- for known_plugin in self . all ( * args , ** kwargs ):
1148- if known_plugin . matches_name ([ name ] ):
1149- context .resolved = True
1150- context .plugin_resolved_name = name
1151- context .plugin_resolved_path = known_plugin ._original_path
1152- context .plugin_resolved_collection = 'ansible.builtin' if known_plugin .ansible_name .startswith ('ansible.builtin.' ) else ''
1153- context ._resolved_fqcn = known_plugin .ansible_name
1154- return get_with_context_result (known_plugin , context )
1138+ self . _ensure_non_collection_wrappers ( * args , ** kwargs )
1139+
1140+ # check for stuff loaded via legacy/builtin paths first
1141+ if known_plugin := self . _cached_non_collection_wrappers . get ( name ):
1142+ context .resolved = True
1143+ context .plugin_resolved_name = name
1144+ context .plugin_resolved_path = known_plugin ._original_path
1145+ context .plugin_resolved_collection = 'ansible.builtin' if known_plugin .ansible_name .startswith ('ansible.builtin.' ) else ''
1146+ context ._resolved_fqcn = known_plugin .ansible_name
1147+ return get_with_context_result (known_plugin , context )
11551148
11561149 plugin = None
11571150 key , leaf_key = get_fqcr_and_name (name )
@@ -1237,14 +1230,10 @@ def get_with_context(self, name, *args, **kwargs):
12371230 # use 'parent' loader class to find files, but cannot return this as it can contain
12381231 # multiple plugins per file
12391232 plugin_impl = super (Jinja2Loader , self ).get_with_context (module_name , * args , ** kwargs )
1240- except Exception as e :
1241- raise KeyError (to_native (e ))
1242-
1243- try :
12441233 method_map = getattr (plugin_impl .object , self .method_map_name )
12451234 plugin_map = method_map ().items ()
12461235 except Exception as e :
1247- display .warning ("Skipping %s plugins in '%s' as it seems to be invalid: %r" % ( self . type , to_text ( plugin_impl . object . _original_path ), e ) )
1236+ display .warning (f "Skipping { self . type } plugins in { module_name } '; an error occurred while loading: { e } " )
12481237 continue
12491238
12501239 for func_name , func in plugin_map :
@@ -1253,11 +1242,11 @@ def get_with_context(self, name, *args, **kwargs):
12531242 # TODO: load anyways into CACHE so we only match each at end of loop
12541243 # the files themseves should already be cached by base class caching of modules(python)
12551244 if key in (func_name , fq_name ):
1256- pclass = self ._load_jinja2_class ()
1257- plugin = pclass (func )
1245+ plugin = self ._plugin_wrapper_type (func )
12581246 if plugin :
12591247 context = plugin_impl .plugin_load_context
12601248 self ._update_object (plugin , src_name , plugin_impl .object ._original_path , resolved = fq_name )
1249+ # FIXME: once we start caching these results, we'll be missing functions that would have loaded later
12611250 break # go to next file as it can override if dupe (dont break both loops)
12621251
12631252 except AnsiblePluginRemovedError as apre :
@@ -1272,18 +1261,27 @@ def get_with_context(self, name, *args, **kwargs):
12721261 return get_with_context_result (plugin , context )
12731262
12741263 def all (self , * args , ** kwargs ):
1275-
1276- # inputs, we ignore 'dedupe' we always do, used in base class to find files for this one
1264+ kwargs .pop ('_dedupe' , None )
12771265 path_only = kwargs .pop ('path_only' , False )
12781266 class_only = kwargs .pop ('class_only' , False ) # basically ignored for test/filters since they are functions
12791267
12801268 # Having both path_only and class_only is a coding bug
12811269 if path_only and class_only :
12821270 raise AnsibleError ('Do not set both path_only and class_only when calling PluginLoader.all()' )
12831271
1284- found = set ()
1272+ self ._ensure_non_collection_wrappers (* args , ** kwargs )
1273+ if path_only :
1274+ yield from (w ._original_path for w in self ._cached_non_collection_wrappers .values ())
1275+ else :
1276+ yield from (w for w in self ._cached_non_collection_wrappers .values ())
1277+
1278+ def _ensure_non_collection_wrappers (self , * args , ** kwargs ):
1279+ if self ._cached_non_collection_wrappers :
1280+ return
1281+
12851282 # get plugins from files in configured paths (multiple in each)
1286- for p_map in self ._j2_all_file_maps (* args , ** kwargs ):
1283+ for p_map in super (Jinja2Loader , self ).all (* args , ** kwargs ):
1284+ is_builtin = p_map .ansible_name .startswith ('ansible.builtin.' )
12871285
12881286 # p_map is really object from file with class that holds multiple plugins
12891287 plugins_list = getattr (p_map , self .method_map_name )
@@ -1294,57 +1292,35 @@ def all(self, *args, **kwargs):
12941292 continue
12951293
12961294 for plugin_name in plugins .keys ():
1297- if plugin_name in _PLUGIN_FILTERS [ self . package ] :
1298- display .debug ("%s skipped due to a defined plugin filter" % plugin_name )
1295+ if '.' in plugin_name :
1296+ display .debug (f' { plugin_name } skipped in { p_map . _original_path } ; Jinja plugin short names may not contain "."' )
12991297 continue
13001298
1301- if plugin_name in found :
1302- display .debug ("%s skipped as duplicate " % plugin_name )
1299+ if plugin_name in _PLUGIN_FILTERS [ self . package ] :
1300+ display .debug ("%s skipped due to a defined plugin filter " % plugin_name )
13031301 continue
13041302
1305- if path_only :
1306- result = p_map ._original_path
1307- else :
1308- # loader class is for the file with multiple plugins, but each plugin now has it's own class
1309- pclass = self ._load_jinja2_class ()
1310- result = pclass (plugins [plugin_name ]) # if bad plugin, let exception rise
1311- found .add (plugin_name )
1312- fqcn = plugin_name
1313- collection = '.' .join (p_map .ansible_name .split ('.' )[:2 ]) if p_map .ansible_name .count ('.' ) >= 2 else ''
1314- if not plugin_name .startswith (collection ):
1315- fqcn = f"{ collection } .{ plugin_name } "
1316-
1317- self ._update_object (result , plugin_name , p_map ._original_path , resolved = fqcn )
1318- yield result
1319-
1320- def _load_jinja2_class (self ):
1321- """ override the normal method of plugin classname as these are used in the generic funciton
1322- to access the 'multimap' of filter/tests to function, this is a 'singular' plugin for
1323- each entry.
1324- """
1325- class_name = 'AnsibleJinja2%s' % get_plugin_class (self .class_name ).capitalize ()
1326- module = __import__ (self .package , fromlist = [class_name ])
1327-
1328- return getattr (module , class_name )
1303+ # the plugin class returned by the loader may host multiple Jinja plugins, but we wrap each plugin in
1304+ # its own surrogate wrapper instance here to ease the bookkeeping...
1305+ wrapper = self ._plugin_wrapper_type (plugins [plugin_name ])
1306+ fqcn = plugin_name
1307+ collection = '.' .join (p_map .ansible_name .split ('.' )[:2 ]) if p_map .ansible_name .count ('.' ) >= 2 else ''
1308+ if not plugin_name .startswith (collection ):
1309+ fqcn = f"{ collection } .{ plugin_name } "
13291310
1330- def _j2_all_file_maps (self , * args , ** kwargs ):
1331- """
1332- * Unlike other plugin types, file != plugin, a file can contain multiple plugins (of same type).
1333- This is why we do not deduplicate ansible file names at this point, we mostly care about
1334- the names of the actual jinja2 plugins which are inside of our files.
1335- * This method will NOT fetch collection plugin files, only those that would be expected under 'ansible.builtin/legacy'.
1336- """
1337- # populate cache if needed
1338- if not self ._loaded_j2_file_maps :
1311+ self ._update_object (wrapper , plugin_name , p_map ._original_path , resolved = fqcn )
13391312
1340- # We don't deduplicate ansible file names.
1341- # Instead, calling code deduplicates jinja2 plugin names when loading each file.
1342- kwargs [ '_dedupe' ] = False
1313+ target_names = { plugin_name , fqcn }
1314+ if is_builtin :
1315+ target_names . add ( f'ansible.builtin. { plugin_name } ' )
13431316
1344- # To match correct precedence, call base class' all() to get a list of files,
1345- self ._loaded_j2_file_maps = list (super (Jinja2Loader , self ).all (* args , ** kwargs ))
1317+ for target_name in target_names :
1318+ if existing_plugin := self ._cached_non_collection_wrappers .get (target_name ):
1319+ display .debug (f'Jinja plugin { target_name } from { p_map ._original_path } skipped; '
1320+ f'shadowed by plugin from { existing_plugin ._original_path } )' )
1321+ continue
13461322
1347- return self ._loaded_j2_file_maps
1323+ self ._cached_non_collection_wrappers [ target_name ] = wrapper
13481324
13491325
13501326def get_fqcr_and_name (resource , collection = 'ansible.builtin' ):
@@ -1572,13 +1548,15 @@ def init_plugin_loader(prefix_collections_path=None):
15721548 'ansible.plugins.filter' ,
15731549 C .DEFAULT_FILTER_PLUGIN_PATH ,
15741550 'filter_plugins' ,
1551+ AnsibleJinja2Filter
15751552)
15761553
15771554test_loader = Jinja2Loader (
15781555 'TestModule' ,
15791556 'ansible.plugins.test' ,
15801557 C .DEFAULT_TEST_PLUGIN_PATH ,
1581- 'test_plugins'
1558+ 'test_plugins' ,
1559+ AnsibleJinja2Test
15821560)
15831561
15841562strategy_loader = PluginLoader (
0 commit comments