mirror of https://github.com/ansible/ansible.git
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
192 lines
7.0 KiB
Python
192 lines
7.0 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import pickle
|
|
|
|
import pytest
|
|
import jinja2
|
|
import jinja2.utils
|
|
|
|
from ansible._internal._templating._jinja_common import MarkerError, Marker
|
|
|
|
|
|
# NB: this module is named with a trailing underscore to work around a PyCharm/pydevd bug:
|
|
# https://youtrack.jetbrains.com/issue/PY-70408/Debugger-skips-all-breakpoints-in-a-single-test-file
|
|
|
|
|
|
def get_dunder_methods_to_intercept() -> list[str]:
|
|
"""
|
|
Return a list of dunder methods on Jinja's StrictUndefined that should be overridden by Ansible's Marker.
|
|
When new methods are added in future Python/Jinja versions, they need to be added to Marker or the ignore_names list below.
|
|
"""
|
|
dunder_names = set(name for name in dir(jinja2.StrictUndefined) if name.startswith('__') and name.endswith('__'))
|
|
|
|
strict_undefined_intercepted_method_names = set(
|
|
name for name in dir(jinja2.StrictUndefined)
|
|
if getattr(jinja2.StrictUndefined, name) is jinja2.StrictUndefined._fail_with_undefined_error and name != '_fail_with_undefined_error'
|
|
)
|
|
|
|
# Some attributes/methods are necessary for core Python interactions and must not be intercepted, thus they are excluded from this test.
|
|
ignore_names = {
|
|
'__class__',
|
|
'__dir__',
|
|
'__doc__',
|
|
'__firstlineno__',
|
|
'__getattr__', # tested separately since it is intercepted with a custom method
|
|
'__getattribute__',
|
|
'__getitem__', # tested separately since it is intercepted with a custom method
|
|
'__getstate__',
|
|
'__init__',
|
|
'__init_subclass__',
|
|
'__module__',
|
|
'__new__',
|
|
'__reduce__',
|
|
'__reduce_ex__',
|
|
'__setattr__', # tested separately since it is intercepted with a custom method
|
|
'__slots__', # tested separately since it's not a method
|
|
'__sizeof__',
|
|
'__static_attributes__',
|
|
'__subclasshook__',
|
|
}
|
|
|
|
# Some methods not intercepted by Jinja's StrictUndefined should be intercepted by Marker.
|
|
additional_method_names = {
|
|
'__aiter__',
|
|
'__delattr__',
|
|
'__format__',
|
|
'__repr__',
|
|
'__setitem__',
|
|
}
|
|
|
|
assert not strict_undefined_intercepted_method_names - dunder_names # ensure Jinja intercepted methods have not been overlooked
|
|
assert not ignore_names & additional_method_names # ensure no overap between ignore_names and additional_method_names
|
|
|
|
return sorted(dunder_names - ignore_names | additional_method_names)
|
|
|
|
|
|
def test_jinja_undefined_shape():
|
|
"""
|
|
Assert that the internal shape of Jinja's StrictUndefined matches Marker's expectations.
|
|
If this test fails, it likely means Jinja has changed something about the internals of Undefined in a way we'll need to address.
|
|
"""
|
|
assert jinja2.StrictUndefined in Marker.__bases__ # ensure we're directly inheriting the base we're validating
|
|
|
|
required_attrs = (
|
|
'_undefined_message',
|
|
'_undefined_name',
|
|
'_fail_with_undefined_error',
|
|
)
|
|
|
|
assert all(hasattr(jinja2.StrictUndefined, a) for a in required_attrs)
|
|
|
|
assert isinstance(jinja2.StrictUndefined.__slots__, tuple) # we don't actually care what's slotted, just that the attr exists (or has been patched back on)
|
|
|
|
|
|
@pytest.mark.parametrize("name", get_dunder_methods_to_intercept())
|
|
def test_marker_methods(marker: Marker, name: str) -> None:
|
|
"""Verify all expected dunder methods on Marker raise a MarkerError."""
|
|
method = getattr(marker, name)
|
|
|
|
with pytest.raises(MarkerError) as err:
|
|
method()
|
|
|
|
assert err.value.source is marker
|
|
|
|
|
|
def test_getattr_attribute_error(marker: Marker) -> None:
|
|
"""Verify unknown dunder attributes on Marker raise an AttributeError."""
|
|
with pytest.raises(AttributeError) as err:
|
|
getattr(marker, '__does_not_exist__')
|
|
|
|
assert err.value.obj is marker
|
|
assert err.value.name == '__does_not_exist__'
|
|
|
|
|
|
def test_getattr_propagation(marker: Marker) -> None:
|
|
"""Verify unknown non-dunder attributes on Marker return self."""
|
|
assert marker.does_not_exist is marker
|
|
|
|
|
|
def test_getitem_propagation(marker: Marker) -> None:
|
|
"""Verify items return self."""
|
|
assert marker['does_not_exist'] is marker
|
|
|
|
|
|
def test_setattr(marker: Marker) -> None:
|
|
"""Verify setattr raises MarkerError."""
|
|
with pytest.raises(MarkerError) as err:
|
|
marker.something = True
|
|
|
|
assert err.value.source is marker
|
|
|
|
|
|
def test_setitem(marker: Marker) -> None:
|
|
"""Verify setitem raises MarkerError."""
|
|
with pytest.raises(MarkerError) as err:
|
|
marker['something'] = True
|
|
|
|
assert err.value.source is marker
|
|
|
|
|
|
def test_slots(marker: Marker) -> None:
|
|
"""Verify all Marker instances are slotted."""
|
|
assert isinstance(marker.__slots__, tuple)
|
|
assert not hasattr(marker, '__dict__')
|
|
|
|
|
|
def marker_attrs(marker: Marker) -> list[str]:
|
|
"""Returns a list of internal attributes for a Marker type (using known prefixes)."""
|
|
return [name for name in dir(marker) if name.startswith('_undefined_') or name.startswith('_marker_')]
|
|
|
|
|
|
def test_copy(marker: Marker) -> None:
|
|
"""Verify copying an Marker works."""
|
|
copied = copy.copy(marker)
|
|
|
|
assert copied is not marker
|
|
|
|
for attribute_name in marker_attrs(marker):
|
|
assert getattr(copied, attribute_name) is getattr(marker, attribute_name), attribute_name
|
|
|
|
|
|
def test_deepcopy(marker: Marker) -> None:
|
|
"""Verify deep copying an Marker works."""
|
|
copied = copy.deepcopy(marker)
|
|
|
|
assert copied is not marker
|
|
|
|
for attribute_name in marker_attrs(marker):
|
|
copied_value = getattr(copied, attribute_name)
|
|
marker_value = getattr(marker, attribute_name)
|
|
|
|
if attribute_name == '_undefined_exception':
|
|
# The `_undefined_exception` attribute is a type, so the identity remains unchanged.
|
|
assert copied_value is marker_value, attribute_name
|
|
elif attribute_name == '_undefined_obj' and marker_value is jinja2.utils.missing:
|
|
# The `_undefined_obj` attribute defaults to the `jinja2.utils.missing`, a singleton of `jinja2.utils.MissingType`.
|
|
assert copied_value is marker_value, attribute_name
|
|
elif type(marker_value) in (str, type(None)):
|
|
# Values of type `str` or `None` should be equal.
|
|
assert copied_value == marker_value, attribute_name
|
|
else:
|
|
# All other types should be actual copies.
|
|
assert copied_value is not marker_value, attribute_name
|
|
|
|
|
|
def test_pickle(marker: Marker) -> None:
|
|
"""Verify pickling a Marker works."""
|
|
pickled = pickle.loads(pickle.dumps(marker))
|
|
|
|
assert pickled is not marker
|
|
|
|
for attribute_name in marker_attrs(marker):
|
|
pickled_value = getattr(pickled, attribute_name)
|
|
marker_value = getattr(marker, attribute_name)
|
|
|
|
if attribute_name == '_marker_captured_exception':
|
|
# The `_marker_captured_exception` attribute is an `Exception` instance, which doesn't support equality comparisons, compare as a `str` instead.
|
|
pickled_value = str(pickled_value)
|
|
marker_value = str(marker_value)
|
|
|
|
assert pickled_value == marker_value, attribute_name
|