diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 16:06:13 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 16:06:13 +0000 |
commit | ce859b2defe1fc90c7a16551291799fc37284add (patch) | |
tree | 7e6b04791662f8f268c7abc9c07d41e98a261a84 | |
parent | Initial commit. (diff) | |
download | jinja2-upstream.tar.xz jinja2-upstream.zip |
Adding upstream version 3.1.2.upstream/3.1.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
105 files changed, 28235 insertions, 0 deletions
diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 0000000..fede06e --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,975 @@ +.. currentmodule:: jinja2 + +Version 3.1.2 +------------- + +Released 2022-04-28 + +- Add parameters to ``Environment.overlay`` to match ``__init__``. + :issue:`1645` +- Handle race condition in ``FileSystemBytecodeCache``. :issue:`1654` + + +Version 3.1.1 +------------- + +Released 2022-03-25 + +- The template filename on Windows uses the primary path separator. + :issue:`1637` + + +Version 3.1.0 +------------- + +Released 2022-03-24 + +- Drop support for Python 3.6. :pr:`1534` +- Remove previously deprecated code. :pr:`1544` + + - ``WithExtension`` and ``AutoEscapeExtension`` are built-in now. + - ``contextfilter`` and ``contextfunction`` are replaced by + ``pass_context``. ``evalcontextfilter`` and + ``evalcontextfunction`` are replaced by ``pass_eval_context``. + ``environmentfilter`` and ``environmentfunction`` are replaced + by ``pass_environment``. + - ``Markup`` and ``escape`` should be imported from MarkupSafe. + - Compiled templates from very old Jinja versions may need to be + recompiled. + - Legacy resolve mode for ``Context`` subclasses is no longer + supported. Override ``resolve_or_missing`` instead of + ``resolve``. + - ``unicode_urlencode`` is renamed to ``url_quote``. + +- Add support for native types in macros. :issue:`1510` +- The ``{% trans %}`` tag can use ``pgettext`` and ``npgettext`` by + passing a context string as the first token in the tag, like + ``{% trans "title" %}``. :issue:`1430` +- Update valid identifier characters from Python 3.6 to 3.7. + :pr:`1571` +- Filters and tests decorated with ``@async_variant`` are pickleable. + :pr:`1612` +- Add ``items`` filter. :issue:`1561` +- Subscriptions (``[0]``, etc.) can be used after filters, tests, and + calls when the environment is in async mode. :issue:`1573` +- The ``groupby`` filter is case-insensitive by default, matching + other comparison filters. Added the ``case_sensitive`` parameter to + control this. :issue:`1463` +- Windows drive-relative path segments in template names will not + result in ``FileSystemLoader`` and ``PackageLoader`` loading from + drive-relative paths. :pr:`1621` + + +Version 3.0.3 +------------- + +Released 2021-11-09 + +- Fix traceback rewriting internals for Python 3.10 and 3.11. + :issue:`1535` +- Fix how the native environment treats leading and trailing spaces + when parsing values on Python 3.10. :pr:`1537` +- Improve async performance by avoiding checks for common types. + :issue:`1514` +- Revert change to ``hash(Node)`` behavior. Nodes are hashed by id + again :issue:`1521` +- ``PackageLoader`` works when the package is a single module file. + :issue:`1512` + + +Version 3.0.2 +------------- + +Released 2021-10-04 + +- Fix a loop scoping bug that caused assignments in nested loops + to still be referenced outside of it. :issue:`1427` +- Make ``compile_templates`` deterministic for filter and import + names. :issue:`1452, 1453` +- Revert an unintended change that caused ``Undefined`` to act like + ``StrictUndefined`` for the ``in`` operator. :issue:`1448` +- Imported macros have access to the current template globals in async + environments. :issue:`1494` +- ``PackageLoader`` will not include a current directory (.) path + segment. This allows loading templates from the root of a zip + import. :issue:`1467` + + +Version 3.0.1 +------------- + +Released 2021-05-18 + +- Update MarkupSafe dependency to >= 2.0. :pr:`1418` +- Mark top-level names as exported so type checking understands + imports in user projects. :issue:`1426` +- Fix some types that weren't available in Python 3.6.0. :issue:`1433` +- The deprecation warning for unneeded ``autoescape`` and ``with_`` + extensions shows more relevant context. :issue:`1429` +- Fixed calling deprecated ``jinja2.Markup`` without an argument. + Use ``markupsafe.Markup`` instead. :issue:`1438` +- Calling sync ``render`` for an async template uses ``asyncio.run`` + on Python >= 3.7. This fixes a deprecation that Python 3.10 + introduces. :issue:`1443` + + +Version 3.0.0 +------------- + +Released 2021-05-11 + +- Drop support for Python 2.7 and 3.5. +- Bump MarkupSafe dependency to >=1.1. +- Bump Babel optional dependency to >=2.1. +- Remove code that was marked deprecated. +- Add type hinting. :pr:`1412` +- Use :pep:`451` API to load templates with + :class:`~loaders.PackageLoader`. :issue:`1168` +- Fix a bug that caused imported macros to not have access to the + current template's globals. :issue:`688` +- Add ability to ignore ``trim_blocks`` using ``+%}``. :issue:`1036` +- Fix a bug that caused custom async-only filters to fail with + constant input. :issue:`1279` +- Fix UndefinedError incorrectly being thrown on an undefined variable + instead of ``Undefined`` being returned on + ``NativeEnvironment`` on Python 3.10. :issue:`1335` +- Blocks can be marked as ``required``. They must be overridden at + some point, but not necessarily by the direct child. :issue:`1147` +- Deprecate the ``autoescape`` and ``with`` extensions, they are + built-in to the compiler. :issue:`1203` +- The ``urlize`` filter recognizes ``mailto:`` links and takes + ``extra_schemes`` (or ``env.policies["urlize.extra_schemes"]``) to + recognize other schemes. It tries to balance parentheses within a + URL instead of ignoring trailing characters. The parsing in general + has been updated to be more efficient and match more cases. URLs + without a scheme are linked as ``https://`` instead of ``http://``. + :issue:`522, 827, 1172`, :pr:`1195` +- Filters that get attributes, such as ``map`` and ``groupby``, can + use a false or empty value as a default. :issue:`1331` +- Fix a bug that prevented variables set in blocks or loops from + being accessed in custom context functions. :issue:`768` +- Fix a bug that caused scoped blocks from accessing special loop + variables. :issue:`1088` +- Update the template globals when calling + ``Environment.get_template(globals=...)`` even if the template was + already loaded. :issue:`295` +- Do not raise an error for undefined filters in unexecuted + if-statements and conditional expressions. :issue:`842` +- Add ``is filter`` and ``is test`` tests to test if a name is a + registered filter or test. This allows checking if a filter is + available in a template before using it. Test functions can be + decorated with ``@pass_environment``, ``@pass_eval_context``, + or ``@pass_context``. :issue:`842`, :pr:`1248` +- Support ``pgettext`` and ``npgettext`` (message contexts) in i18n + extension. :issue:`441` +- The ``|indent`` filter's ``width`` argument can be a string to + indent by. :pr:`1167` +- The parser understands hex, octal, and binary integer literals. + :issue:`1170` +- ``Undefined.__contains__`` (``in``) raises an ``UndefinedError`` + instead of a ``TypeError``. :issue:`1198` +- ``Undefined`` is iterable in an async environment. :issue:`1294` +- ``NativeEnvironment`` supports async mode. :issue:`1362` +- Template rendering only treats ``\n``, ``\r\n`` and ``\r`` as line + breaks. Other characters are left unchanged. :issue:`769, 952, 1313` +- ``|groupby`` filter takes an optional ``default`` argument. + :issue:`1359` +- The function and filter decorators have been renamed and unified. + The old names are deprecated. :issue:`1381` + + - ``pass_context`` replaces ``contextfunction`` and + ``contextfilter``. + - ``pass_eval_context`` replaces ``evalcontextfunction`` and + ``evalcontextfilter`` + - ``pass_environment`` replaces ``environmentfunction`` and + ``environmentfilter``. + +- Async support no longer requires Jinja to patch itself. It must + still be enabled with ``Environment(enable_async=True)``. + :issue:`1390` +- Overriding ``Context.resolve`` is deprecated, override + ``resolve_or_missing`` instead. :issue:`1380` + + +Version 2.11.3 +-------------- + +Released 2021-01-31 + +- Improve the speed of the ``urlize`` filter by reducing regex + backtracking. Email matching requires a word character at the start + of the domain part, and only word characters in the TLD. :pr:`1343` + + +Version 2.11.2 +-------------- + +Released 2020-04-13 + +- Fix a bug that caused callable objects with ``__getattr__``, like + :class:`~unittest.mock.Mock` to be treated as a + :func:`contextfunction`. :issue:`1145` +- Update ``wordcount`` filter to trigger :class:`Undefined` methods + by wrapping the input in :func:`soft_str`. :pr:`1160` +- Fix a hang when displaying tracebacks on Python 32-bit. + :issue:`1162` +- Showing an undefined error for an object that raises + ``AttributeError`` on access doesn't cause a recursion error. + :issue:`1177` +- Revert changes to :class:`~loaders.PackageLoader` from 2.10 which + removed the dependency on setuptools and pkg_resources, and added + limited support for namespace packages. The changes caused issues + when using Pytest. Due to the difficulty in supporting Python 2 and + :pep:`451` simultaneously, the changes are reverted until 3.0. + :pr:`1182` +- Fix line numbers in error messages when newlines are stripped. + :pr:`1178` +- The special ``namespace()`` assignment object in templates works in + async environments. :issue:`1180` +- Fix whitespace being removed before tags in the middle of lines when + ``lstrip_blocks`` is enabled. :issue:`1138` +- :class:`~nativetypes.NativeEnvironment` doesn't evaluate + intermediate strings during rendering. This prevents early + evaluation which could change the value of an expression. + :issue:`1186` + + +Version 2.11.1 +-------------- + +Released 2020-01-30 + +- Fix a bug that prevented looking up a key after an attribute + (``{{ data.items[1:] }}``) in an async template. :issue:`1141` + + +Version 2.11.0 +-------------- + +Released 2020-01-27 + +- Drop support for Python 2.6, 3.3, and 3.4. This will be the last + version to support Python 2.7 and 3.5. +- Added a new ``ChainableUndefined`` class to support getitem and + getattr on an undefined object. :issue:`977` +- Allow ``{%+`` syntax (with NOP behavior) when ``lstrip_blocks`` is + disabled. :issue:`748` +- Added a ``default`` parameter for the ``map`` filter. :issue:`557` +- Exclude environment globals from + :func:`meta.find_undeclared_variables`. :issue:`931` +- Float literals can be written with scientific notation, like + 2.56e-3. :issue:`912`, :pr:`922` +- Int and float literals can be written with the '_' separator for + legibility, like 12_345. :pr:`923` +- Fix a bug causing deadlocks in ``LRUCache.setdefault``. :pr:`1000` +- The ``trim`` filter takes an optional string of characters to trim. + :pr:`828` +- A new ``jinja2.ext.debug`` extension adds a ``{% debug %}`` tag to + quickly dump the current context and available filters and tests. + :issue:`174`, :pr:`798, 983` +- Lexing templates with large amounts of whitespace is much faster. + :issue:`857`, :pr:`858` +- Parentheses around comparisons are preserved, so + ``{{ 2 * (3 < 5) }}`` outputs "2" instead of "False". + :issue:`755`, :pr:`938` +- Add new ``boolean``, ``false``, ``true``, ``integer`` and ``float`` + tests. :pr:`824` +- The environment's ``finalize`` function is only applied to the + output of expressions (constant or not), not static template data. + :issue:`63` +- When providing multiple paths to ``FileSystemLoader``, a template + can have the same name as a directory. :issue:`821` +- Always return :class:`Undefined` when omitting the ``else`` clause + in a ``{{ 'foo' if bar }}`` expression, regardless of the + environment's ``undefined`` class. Omitting the ``else`` clause is a + valid shortcut and should not raise an error when using + :class:`StrictUndefined`. :issue:`710`, :pr:`1079` +- Fix behavior of ``loop`` control variables such as ``length`` and + ``revindex0`` when looping over a generator. :issue:`459, 751, 794`, + :pr:`993` +- Async support is only loaded the first time an environment enables + it, in order to avoid a slow initial import. :issue:`765` +- In async environments, the ``|map`` filter will await the filter + call if needed. :pr:`913` +- In for loops that access ``loop`` attributes, the iterator is not + advanced ahead of the current iteration unless ``length``, + ``revindex``, ``nextitem``, or ``last`` are accessed. This makes it + less likely to break ``groupby`` results. :issue:`555`, :pr:`1101` +- In async environments, the ``loop`` attributes ``length`` and + ``revindex`` work for async iterators. :pr:`1101` +- In async environments, values from attribute/property access will + be awaited if needed. :pr:`1101` +- :class:`~loader.PackageLoader` doesn't depend on setuptools or + pkg_resources. :issue:`970` +- ``PackageLoader`` has limited support for :pep:`420` namespace + packages. :issue:`1097` +- Support :class:`os.PathLike` objects in + :class:`~loader.FileSystemLoader` and :class:`~loader.ModuleLoader`. + :issue:`870` +- :class:`~nativetypes.NativeTemplate` correctly handles quotes + between expressions. ``"'{{ a }}', '{{ b }}'"`` renders as the tuple + ``('1', '2')`` rather than the string ``'1, 2'``. :issue:`1020` +- Creating a :class:`~nativetypes.NativeTemplate` directly creates a + :class:`~nativetypes.NativeEnvironment` instead of a default + :class:`Environment`. :issue:`1091` +- After calling ``LRUCache.copy()``, the copy's queue methods point to + the correct queue. :issue:`843` +- Compiling templates always writes UTF-8 instead of defaulting to the + system encoding. :issue:`889` +- ``|wordwrap`` filter treats existing newlines as separate paragraphs + to be wrapped individually, rather than creating short intermediate + lines. :issue:`175` +- Add ``break_on_hyphens`` parameter to ``|wordwrap`` filter. + :issue:`550` +- Cython compiled functions decorated as context functions will be + passed the context. :pr:`1108` +- When chained comparisons of constants are evaluated at compile time, + the result follows Python's behavior of returning ``False`` if any + comparison returns ``False``, rather than only the last one. + :issue:`1102` +- Tracebacks for exceptions in templates show the correct line numbers + and source for Python >= 3.7. :issue:`1104` +- Tracebacks for template syntax errors in Python 3 no longer show + internal compiler frames. :issue:`763` +- Add a ``DerivedContextReference`` node that can be used by + extensions to get the current context and local variables such as + ``loop``. :issue:`860` +- Constant folding during compilation is applied to some node types + that were previously overlooked. :issue:`733` +- ``TemplateSyntaxError.source`` is not empty when raised from an + included template. :issue:`457` +- Passing an ``Undefined`` value to ``get_template`` (such as through + ``extends``, ``import``, or ``include``), raises an + ``UndefinedError`` consistently. ``select_template`` will show the + undefined message in the list of attempts rather than the empty + string. :issue:`1037` +- ``TemplateSyntaxError`` can be pickled. :pr:`1117` + + +Version 2.10.3 +-------------- + +Released 2019-10-04 + +- Fix a typo in Babel entry point in ``setup.py`` that was preventing + installation. + + +Version 2.10.2 +-------------- + +Released 2019-10-04 + +- Fix Python 3.7 deprecation warnings. +- Using ``range`` in the sandboxed environment uses ``xrange`` on + Python 2 to avoid memory use. :issue:`933` +- Use Python 3.7's better traceback support to avoid a core dump when + using debug builds of Python 3.7. :issue:`1050` + + +Version 2.10.1 +-------------- + +Released 2019-04-06 + +- ``SandboxedEnvironment`` securely handles ``str.format_map`` in + order to prevent code execution through untrusted format strings. + The sandbox already handled ``str.format``. + + +Version 2.10 +------------ + +Released 2017-11-08 + +- Added a new extension node called ``OverlayScope`` which can be used + to create an unoptimized scope that will look up all variables from + a derived context. +- Added an ``in`` test that works like the in operator. This can be + used in combination with ``reject`` and ``select``. +- Added ``previtem`` and ``nextitem`` to loop contexts, providing + access to the previous/next item in the loop. If such an item does + not exist, the value is undefined. +- Added ``changed(*values)`` to loop contexts, providing an easy way + of checking whether a value has changed since the last iteration (or + rather since the last call of the method) +- Added a ``namespace`` function that creates a special object which + allows attribute assignment using the ``set`` tag. This can be used + to carry data across scopes, e.g. from a loop body to code that + comes after the loop. +- Added a ``trimmed`` modifier to ``{% trans %}`` to strip linebreaks + and surrounding whitespace. Also added a new policy to enable this + for all ``trans`` blocks. +- The ``random`` filter is no longer incorrectly constant folded and + will produce a new random choice each time the template is rendered. + :pr:`478` +- Added a ``unique`` filter. :pr:`469` +- Added ``min`` and ``max`` filters. :pr:`475` +- Added tests for all comparison operators: ``eq``, ``ne``, ``lt``, + ``le``, ``gt``, ``ge``. :pr:`665` +- ``import`` statement cannot end with a trailing comma. :pr:`617`, + :pr:`618` +- ``indent`` filter will not indent blank lines by default. :pr:`685` +- Add ``reverse`` argument for ``dictsort`` filter. :pr:`692` +- Add a ``NativeEnvironment`` that renders templates to native Python + types instead of strings. :pr:`708` +- Added filter support to the block ``set`` tag. :pr:`489` +- ``tojson`` filter marks output as safe to match documented behavior. + :pr:`718` +- Resolved a bug where getting debug locals for tracebacks could + modify template context. +- Fixed a bug where having many ``{% elif ... %}`` blocks resulted in + a "too many levels of indentation" error. These blocks now compile + to native ``elif ..:`` instead of ``else: if ..:`` :issue:`759` + + +Version 2.9.6 +------------- + +Released 2017-04-03 + +- Fixed custom context behavior in fast resolve mode :issue:`675` + + +Version 2.9.5 +------------- + +Released 2017-01-28 + +- Restored the original repr of the internal ``_GroupTuple`` because + this caused issues with ansible and it was an unintended change. + :issue:`654` +- Added back support for custom contexts that override the old + ``resolve`` method since it was hard for people to spot that this + could cause a regression. +- Correctly use the buffer for the else block of for loops. This + caused invalid syntax errors to be caused on 2.x and completely + wrong behavior on Python 3 :issue:`669` +- Resolve an issue where the ``{% extends %}`` tag could not be used + with async environments. :issue:`668` +- Reduce memory footprint slightly by reducing our unicode database + dump we use for identifier matching on Python 3 :issue:`666` +- Fixed autoescaping not working for macros in async compilation mode. + :issue:`671` + + +Version 2.9.4 +------------- + +Released 2017-01-10 + +- Solved some warnings for string literals. :issue:`646` +- Increment the bytecode cache version which was not done due to an + oversight before. +- Corrected bad code generation and scoping for filtered loops. + :issue:`649` +- Resolved an issue where top-level output silencing after known + extend blocks could generate invalid code when blocks where + contained in if statements. :issue:`651` +- Made the ``truncate.leeway`` default configurable to improve + compatibility with older templates. + + +Version 2.9.3 +------------- + +Released 2017-01-08 + +- Restored the use of blocks in macros to the extend that was possible + before. On Python 3 it would render a generator repr instead of the + block contents. :issue:`645` +- Set a consistent behavior for assigning of variables in inner scopes + when the variable is also read from an outer scope. This now sets + the intended behavior in all situations however it does not restore + the old behavior where limited assignments to outer scopes was + possible. For more information and a discussion see :issue:`641` +- Resolved an issue where ``block scoped`` would not take advantage of + the new scoping rules. In some more exotic cases a variable + overridden in a local scope would not make it into a block. +- Change the code generation of the ``with`` statement to be in line + with the new scoping rules. This resolves some unlikely bugs in edge + cases. This also introduces a new internal ``With`` node that can be + used by extensions. + + +Version 2.9.2 +------------- + +Released 2017-01-08 + +- Fixed a regression that caused for loops to not be able to use the + same variable for the target as well as source iterator. + :issue:`640` +- Add support for a previously unknown behavior of macros. It used to + be possible in some circumstances to explicitly provide a caller + argument to macros. While badly buggy and unintended it turns out + that this is a common case that gets copy pasted around. To not + completely break backwards compatibility with the most common cases + it's now possible to provide an explicit keyword argument for caller + if it's given an explicit default. :issue:`642` + + +Version 2.9.1 +------------- + +Released 2017-01-07 + +- Resolved a regression with call block scoping for macros. Nested + caller blocks that used the same identifiers as outer macros could + refer to the wrong variable incorrectly. + + +Version 2.9 +----------- + +Released 2017-01-07, codename Derivation + +- Change cache key definition in environment. This fixes a performance + regression introduced in 2.8. +- Added support for ``generator_stop`` on supported Python versions + (Python 3.5 and later) +- Corrected a long standing issue with operator precedence of math + operations not being what was expected. +- Added support for Python 3.6 async iterators through a new async + mode. +- Added policies for filter defaults and similar things. +- Urlize now sets "rel noopener" by default. +- Support attribute fallback for old-style classes in 2.x. +- Support toplevel set statements in extend situations. +- Restored behavior of Cycler for Python 3 users. +- Subtraction now follows the same behavior as other operators on + undefined values. +- ``map`` and friends will now give better error messages if you + forgot to quote the parameter. +- Depend on MarkupSafe 0.23 or higher. +- Improved the ``truncate`` filter to support better truncation in + case the string is barely truncated at all. +- Change the logic for macro autoescaping to be based on the runtime + autoescaping information at call time instead of macro define time. +- Ported a modified version of the ``tojson`` filter from Flask to + Jinja and hooked it up with the new policy framework. +- Block sets are now marked ``safe`` by default. +- On Python 2 the asciification of ASCII strings can now be disabled + with the ``compiler.ascii_str`` policy. +- Tests now no longer accept an arbitrary expression as first argument + but a restricted one. This means that you can now properly use + multiple tests in one expression without extra parentheses. In + particular you can now write ``foo is divisibleby 2 or foo is + divisibleby 3`` as you would expect. +- Greatly changed the scoping system to be more consistent with what + template designers and developers expect. There is now no more magic + difference between the different include and import constructs. + Context is now always propagated the same way. The only remaining + differences is the defaults for ``with context`` and ``without + context``. +- The ``with`` and ``autoescape`` tags are now built-in. +- Added the new ``select_autoescape`` function which helps configuring + better autoescaping easier. +- Fixed a runtime error in the sandbox when attributes of async + generators were accessed. + + +Version 2.8.1 +------------- + +Released 2016-12-29 + +- Fixed the ``for_qs`` flag for ``urlencode``. +- Fixed regression when applying ``int`` to non-string values. +- SECURITY: if the sandbox mode is used format expressions are now + sandboxed with the same rules as in Jinja. This solves various + information leakage problems that can occur with format strings. + + +Version 2.8 +----------- + +Released 2015-07-26, codename Replacement + +- Added ``target`` parameter to urlize function. +- Added support for ``followsymlinks`` to the file system loader. +- The truncate filter now counts the length. +- Added equalto filter that helps with select filters. +- Changed cache keys to use absolute file names if available instead + of load names. +- Fixed loop length calculation for some iterators. +- Changed how Jinja enforces strings to be native strings in Python 2 + to work when people break their default encoding. +- Added ``make_logging_undefined`` which returns an undefined + object that logs failures into a logger. +- If unmarshalling of cached data fails the template will be reloaded + now. +- Implemented a block ``set`` tag. +- Default cache size was increased to 400 from a low 50. +- Fixed ``is number`` test to accept long integers in all Python + versions. +- Changed ``is number`` to accept Decimal as a number. +- Added a check for default arguments followed by non-default + arguments. This change makes ``{% macro m(x, y=1, z) %}`` a syntax + error. The previous behavior for this code was broken anyway + (resulting in the default value being applied to ``y``). +- Add ability to use custom subclasses of + ``jinja2.compiler.CodeGenerator`` and ``jinja2.runtime.Context`` by + adding two new attributes to the environment + (``code_generator_class`` and ``context_class``). :pr:`404` +- Added support for context/environment/evalctx decorator functions on + the finalize callback of the environment. +- Escape query strings for urlencode properly. Previously slashes were + not escaped in that place. +- Add 'base' parameter to 'int' filter. + + +Version 2.7.3 +------------- + +Released 2014-06-06 + +- Security issue: Corrected the security fix for the cache folder. + This fix was provided by RedHat. + + +Version 2.7.2 +------------- + +Released 2014-01-10 + +- Prefix loader was not forwarding the locals properly to inner + loaders. This is now fixed. +- Security issue: Changed the default folder for the filesystem cache + to be user specific and read and write protected on UNIX systems. + See `Debian bug 734747`_ for more information. + +.. _Debian bug 734747: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=734747 + + +Version 2.7.1 +------------- + +Released 2013-08-07 + +- Fixed a bug with ``call_filter`` not working properly on environment + and context filters. +- Fixed lack of Python 3 support for bytecode caches. +- Reverted support for defining blocks in included templates as this + broke existing templates for users. +- Fixed some warnings with hashing of undefineds and nodes if Python + is run with warnings for Python 3. +- Added support for properly hashing undefined objects. +- Fixed a bug with the title filter not working on already uppercase + strings. + + +Version 2.7 +----------- + +Released 2013-05-20, codename Translation + +- Choice and prefix loaders now dispatch source and template lookup + separately in order to work in combination with module loaders as + advertised. +- Fixed filesizeformat. +- Added a non-silent option for babel extraction. +- Added ``urlencode`` filter that automatically quotes values for URL + safe usage with utf-8 as only supported encoding. If applications + want to change this encoding they can override the filter. +- Added ``keep-trailing-newline`` configuration to environments and + templates to optionally preserve the final trailing newline. +- Accessing ``last`` on the loop context no longer causes the iterator + to be consumed into a list. +- Python requirement changed: 2.6, 2.7 or >= 3.3 are required now, + supported by same source code, using the "six" compatibility + library. +- Allow ``contextfunction`` and other decorators to be applied to + ``__call__``. +- Added support for changing from newline to different signs in the + ``wordwrap`` filter. +- Added support for ignoring memcache errors silently. +- Added support for keeping the trailing newline in templates. +- Added finer grained support for stripping whitespace on the left + side of blocks. +- Added ``map``, ``select``, ``reject``, ``selectattr`` and + ``rejectattr`` filters. +- Added support for ``loop.depth`` to figure out how deep inside a + recursive loop the code is. +- Disabled py_compile for pypy and python 3. + + +Version 2.6 +----------- + +Released 2011-07-24, codename Convolution + +- Internal attributes now raise an internal attribute error now + instead of returning an undefined. This fixes problems when passing + undefined objects to Python semantics expecting APIs. +- Traceback support now works properly for PyPy. (Tested with 1.4) +- Implemented operator intercepting for sandboxed environments. This + allows application developers to disable builtin operators for + better security. (For instance limit the mathematical operators to + actual integers instead of longs) +- Groupby filter now supports dotted notation for grouping by + attributes of attributes. +- Scoped blocks now properly treat toplevel assignments and imports. + Previously an import suddenly "disappeared" in a scoped block. +- Automatically detect newer Python interpreter versions before + loading code from bytecode caches to prevent segfaults on invalid + opcodes. The segfault in earlier Jinja versions here was not a + Jinja bug but a limitation in the underlying Python interpreter. If + you notice Jinja segfaulting in earlier versions after an upgrade + of the Python interpreter you don't have to upgrade, it's enough to + flush the bytecode cache. This just no longer makes this necessary, + Jinja will automatically detect these cases now. +- The sum filter can now sum up values by attribute. This is a + backwards incompatible change. The argument to the filter previously + was the optional starting index which defaults to zero. This now + became the second argument to the function because it's rarely used. +- Like sum, sort now also makes it possible to order items by + attribute. +- Like sum and sort, join now also is able to join attributes of + objects as string. +- The internal eval context now has a reference to the environment. +- Added a mapping test to see if an object is a dict or an object with + a similar interface. + + +Version 2.5.5 +------------- + +Released 2010-10-18 + +- Built documentation is no longer part of release. + + +Version 2.5.4 +------------- + +Released 2010-10-17 + +- Fixed extensions not loading properly with overlays. +- Work around a bug in cpython for the debugger that causes segfaults + on 64bit big-endian architectures. + + +Version 2.5.3 +------------- + +Released 2010-10-17 + +- Fixed an operator precedence error introduced in 2.5.2. Statements + like "-foo.bar" had their implicit parentheses applied around the + first part of the expression ("(-foo).bar") instead of the more + correct "-(foo.bar)". + + +Version 2.5.2 +------------- + +Released 2010-08-18 + +- Improved setup.py script to better work with assumptions people + might still have from it (``--with-speedups``). +- Fixed a packaging error that excluded the new debug support. + + +Version 2.5.1 +------------- + +Released 2010-08-17 + +- StopIteration exceptions raised by functions called from templates + are now intercepted and converted to undefineds. This solves a lot + of debugging grief. (StopIteration is used internally to abort + template execution) +- Improved performance of macro calls slightly. +- Babel extraction can now properly extract newstyle gettext calls. +- Using the variable ``num`` in newstyle gettext for something else + than the pluralize count will no longer raise a :exc:`KeyError`. +- Removed builtin markup class and switched to markupsafe. For + backwards compatibility the pure Python implementation still exists + but is pulled from markupsafe by the Jinja developers. The debug + support went into a separate feature called "debugsupport" and is + disabled by default because it is only relevant for Python 2.4 +- Fixed an issue with unary operators having the wrong precedence. + + +Version 2.5 +----------- + +Released 2010-05-29, codename Incoherence + +- Improved the sort filter (should have worked like this for a long + time) by adding support for case insensitive searches. +- Fixed a bug for getattribute constant folding. +- Support for newstyle gettext translations which result in a nicer + in-template user interface and more consistent catalogs. +- It's now possible to register extensions after an environment was + created. + + +Version 2.4.1 +------------- + +Released 2010-04-20 + +- Fixed an error reporting bug for undefined. + + +Version 2.4 +----------- + +Released 2010-04-13, codename Correlation + +- The environment template loading functions now transparently pass + through a template object if it was passed to it. This makes it + possible to import or extend from a template object that was passed + to the template. +- Added a ``ModuleLoader`` that can load templates from + precompiled sources. The environment now features a method to + compile the templates from a configured loader into a zip file or + folder. +- The _speedups C extension now supports Python 3. +- Added support for autoescaping toggling sections and support for + evaluation contexts. +- Extensions have a priority now. + + +Version 2.3.1 +------------- + +Released 2010-02-19 + +- Fixed an error reporting bug on all python versions +- Fixed an error reporting bug on Python 2.4 + + +Version 2.3 +----------- + +Released 2010-02-10, codename 3000 Pythons + +- Fixes issue with code generator that causes unbound variables to be + generated if set was used in if-blocks and other small identifier + problems. +- Include tags are now able to select between multiple templates and + take the first that exists, if a list of templates is given. +- Fixed a problem with having call blocks in outer scopes that have an + argument that is also used as local variable in an inner frame + :issue:`360`. +- Greatly improved error message reporting :pr:`339` +- Implicit tuple expressions can no longer be totally empty. This + change makes ``{% if %}`` a syntax error now. :issue:`364` +- Added support for translator comments if extracted via babel. +- Added with-statement extension. +- Experimental Python 3 support. + + +Version 2.2.1 +------------- + +Released 2009-09-14 + +- Fixes some smaller problems for Jinja on Jython. + + +Version 2.2 +----------- + +Released 2009-09-13, codename Kong + +- Include statements can now be marked with ``ignore missing`` to skip + non existing templates. +- Priority of ``not`` raised. It's now possible to write ``not foo in + bar`` as an alias to ``foo not in bar`` like in python. Previously + the grammar required parentheses (``not (foo in bar)``) which was + odd. +- Fixed a bug that caused syntax errors when defining macros or using + the ``{% call %}`` tag inside loops. +- Fixed a bug in the parser that made ``{{ foo[1, 2] }}`` impossible. +- Made it possible to refer to names from outer scopes in included + templates that were unused in the callers frame :issue:`327` +- Fixed a bug that caused internal errors if names where used as + iteration variable and regular variable *after* the loop if that + variable was unused *before* the loop. :pr:`331` +- Added support for optional ``scoped`` modifier to blocks. +- Added support for line-comments. +- Added the ``meta`` module. +- Renamed (undocumented) attribute "overlay" to "overlayed" on the + environment because it was clashing with a method of the same name. +- Speedup extension is now disabled by default. + + +Version 2.1.1 +------------- + +Released 2008-12-25 + +- Fixed a translation error caused by looping over empty recursive + loops. + + +Version 2.1 +----------- + +Released 2008-11-23, codename Yasuzō + +- Fixed a bug with nested loops and the special loop variable. Before + the change an inner loop overwrote the loop variable from the outer + one after iteration. +- Fixed a bug with the i18n extension that caused the explicit + pluralization block to look up the wrong variable. +- Fixed a limitation in the lexer that made ``{{ foo.0.0 }}`` + impossible. +- Index based subscribing of variables with a constant value returns + an undefined object now instead of raising an index error. This was + a bug caused by eager optimizing. +- The i18n extension looks up ``foo.ugettext`` now followed by + ``foo.gettext`` if an translations object is installed. This makes + dealing with custom translations classes easier. +- Fixed a confusing behavior with conditional extending. loops were + partially executed under some conditions even though they were not + part of a visible area. +- Added ``sort`` filter that works like ``dictsort`` but for arbitrary + sequences. +- Fixed a bug with empty statements in macros. +- Implemented a bytecode cache system. +- The template context is now weakref-able +- Inclusions and imports "with context" forward all variables now, not + only the initial context. +- Added a cycle helper called ``cycler``. +- Added a joining helper called ``joiner``. +- Added a ``compile_expression`` method to the environment that allows + compiling of Jinja expressions into callable Python objects. +- Fixed an escaping bug in urlize + + +Version 2.0 +----------- + +Released 2008-07-17, codename Jinjavitus + +- The subscribing of objects (looking up attributes and items) changed + from slightly. It's now possible to give attributes or items a + higher priority by either using dot-notation lookup or the bracket + syntax. This also changed the AST slightly. ``Subscript`` is gone + and was replaced with ``Getitem`` and ``Getattr``. +- Added support for preprocessing and token stream filtering for + extensions. This would allow extensions to allow simplified gettext + calls in template data and something similar. +- Added ``TemplateStream.dump``. +- Added missing support for implicit string literal concatenation. + ``{{ "foo" "bar" }}`` is equivalent to ``{{ "foobar" }}`` +- ``else`` is optional for conditional expressions. If not given it + evaluates to ``false``. +- Improved error reporting for undefined values by providing a + position. +- ``filesizeformat`` filter uses decimal prefixes now per default and + can be set to binary mode with the second parameter. +- Fixed bug in finalizer + + +Version 2.0rc1 +-------------- + +Released 2008-06-09 + +- First release of Jinja 2. diff --git a/LICENSE.rst b/LICENSE.rst new file mode 100644 index 0000000..c37cae4 --- /dev/null +++ b/LICENSE.rst @@ -0,0 +1,28 @@ +Copyright 2007 Pallets + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..88fe6b1 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,10 @@ +include CHANGES.rst +include tox.ini +include requirements/*.txt +graft artwork +graft docs +prune docs/_build +graft examples +graft tests +include src/jinja2/py.typed +global-exclude *.pyc diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..7b92c21 --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,111 @@ +Metadata-Version: 2.1 +Name: Jinja2 +Version: 3.1.2 +Summary: A very fast and expressive template engine. +Home-page: https://palletsprojects.com/p/jinja/ +Author: Armin Ronacher +Author-email: armin.ronacher@active-4.com +Maintainer: Pallets +Maintainer-email: contact@palletsprojects.com +License: BSD-3-Clause +Project-URL: Donate, https://palletsprojects.com/donate +Project-URL: Documentation, https://jinja.palletsprojects.com/ +Project-URL: Changes, https://jinja.palletsprojects.com/changes/ +Project-URL: Source Code, https://github.com/pallets/jinja/ +Project-URL: Issue Tracker, https://github.com/pallets/jinja/issues/ +Project-URL: Twitter, https://twitter.com/PalletsTeam +Project-URL: Chat, https://discord.gg/pallets +Platform: UNKNOWN +Classifier: Development Status :: 5 - Production/Stable +Classifier: Environment :: Web Environment +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: BSD License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content +Classifier: Topic :: Text Processing :: Markup :: HTML +Requires-Python: >=3.7 +Description-Content-Type: text/x-rst +Provides-Extra: i18n +License-File: LICENSE.rst + +Jinja +===== + +Jinja is a fast, expressive, extensible templating engine. Special +placeholders in the template allow writing code similar to Python +syntax. Then the template is passed data to render the final document. + +It includes: + +- Template inheritance and inclusion. +- Define and import macros within templates. +- HTML templates can use autoescaping to prevent XSS from untrusted + user input. +- A sandboxed environment can safely render untrusted templates. +- AsyncIO support for generating templates and calling async + functions. +- I18N support with Babel. +- Templates are compiled to optimized Python code just-in-time and + cached, or can be compiled ahead-of-time. +- Exceptions point to the correct line in templates to make debugging + easier. +- Extensible filters, tests, functions, and even syntax. + +Jinja's philosophy is that while application logic belongs in Python if +possible, it shouldn't make the template designer's job difficult by +restricting functionality too much. + + +Installing +---------- + +Install and update using `pip`_: + +.. code-block:: text + + $ pip install -U Jinja2 + +.. _pip: https://pip.pypa.io/en/stable/getting-started/ + + +In A Nutshell +------------- + +.. code-block:: jinja + + {% extends "base.html" %} + {% block title %}Members{% endblock %} + {% block content %} + <ul> + {% for user in users %} + <li><a href="{{ user.url }}">{{ user.username }}</a></li> + {% endfor %} + </ul> + {% endblock %} + + +Donate +------ + +The Pallets organization develops and supports Jinja and other popular +packages. In order to grow the community of contributors and users, and +allow the maintainers to devote more time to the projects, `please +donate today`_. + +.. _please donate today: https://palletsprojects.com/donate + + +Links +----- + +- Documentation: https://jinja.palletsprojects.com/ +- Changes: https://jinja.palletsprojects.com/changes/ +- PyPI Releases: https://pypi.org/project/Jinja2/ +- Source Code: https://github.com/pallets/jinja/ +- Issue Tracker: https://github.com/pallets/jinja/issues/ +- Website: https://palletsprojects.com/p/jinja/ +- Twitter: https://twitter.com/PalletsTeam +- Chat: https://discord.gg/pallets + + diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..a197aea --- /dev/null +++ b/README.rst @@ -0,0 +1,78 @@ +Jinja +===== + +Jinja is a fast, expressive, extensible templating engine. Special +placeholders in the template allow writing code similar to Python +syntax. Then the template is passed data to render the final document. + +It includes: + +- Template inheritance and inclusion. +- Define and import macros within templates. +- HTML templates can use autoescaping to prevent XSS from untrusted + user input. +- A sandboxed environment can safely render untrusted templates. +- AsyncIO support for generating templates and calling async + functions. +- I18N support with Babel. +- Templates are compiled to optimized Python code just-in-time and + cached, or can be compiled ahead-of-time. +- Exceptions point to the correct line in templates to make debugging + easier. +- Extensible filters, tests, functions, and even syntax. + +Jinja's philosophy is that while application logic belongs in Python if +possible, it shouldn't make the template designer's job difficult by +restricting functionality too much. + + +Installing +---------- + +Install and update using `pip`_: + +.. code-block:: text + + $ pip install -U Jinja2 + +.. _pip: https://pip.pypa.io/en/stable/getting-started/ + + +In A Nutshell +------------- + +.. code-block:: jinja + + {% extends "base.html" %} + {% block title %}Members{% endblock %} + {% block content %} + <ul> + {% for user in users %} + <li><a href="{{ user.url }}">{{ user.username }}</a></li> + {% endfor %} + </ul> + {% endblock %} + + +Donate +------ + +The Pallets organization develops and supports Jinja and other popular +packages. In order to grow the community of contributors and users, and +allow the maintainers to devote more time to the projects, `please +donate today`_. + +.. _please donate today: https://palletsprojects.com/donate + + +Links +----- + +- Documentation: https://jinja.palletsprojects.com/ +- Changes: https://jinja.palletsprojects.com/changes/ +- PyPI Releases: https://pypi.org/project/Jinja2/ +- Source Code: https://github.com/pallets/jinja/ +- Issue Tracker: https://github.com/pallets/jinja/issues/ +- Website: https://palletsprojects.com/p/jinja/ +- Twitter: https://twitter.com/PalletsTeam +- Chat: https://discord.gg/pallets diff --git a/artwork/jinjalogo.svg b/artwork/jinjalogo.svg new file mode 100644 index 0000000..0bc9ea4 --- /dev/null +++ b/artwork/jinjalogo.svg @@ -0,0 +1,132 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://web.resource.org/cc/" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="300" + height="120" + id="svg2" + sodipodi:version="0.32" + inkscape:version="0.45.1" + version="1.0" + sodipodi:docbase="/Users/mitsuhiko/Development/jinja2/artwork" + sodipodi:docname="jinjalogo.svg" + inkscape:export-filename="/Users/mitsuhiko/Development/jinja2/docs/_static/jinjabanner.png" + inkscape:export-xdpi="60" + inkscape:export-ydpi="60" + inkscape:output_extension="org.inkscape.output.svg.inkscape"> + <defs + id="defs4"> + <linearGradient + id="linearGradient6558"> + <stop + style="stop-color:#575757;stop-opacity:1;" + offset="0" + id="stop6560" /> + <stop + style="stop-color:#2f2f2f;stop-opacity:1;" + offset="1" + id="stop6562" /> + </linearGradient> + <radialGradient + inkscape:collect="always" + xlink:href="#linearGradient6558" + id="radialGradient6564" + cx="61.297766" + cy="60.910986" + fx="61.297766" + fy="60.910986" + r="44.688254" + gradientTransform="matrix(1,0,0,0.945104,0,3.343747)" + gradientUnits="userSpaceOnUse" /> + <radialGradient + inkscape:collect="always" + xlink:href="#linearGradient6558" + id="radialGradient6580" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(1,0,0,0.945104,0.355158,3.334402)" + cx="61.297766" + cy="60.910986" + fx="61.297766" + fy="60.910986" + r="44.688254" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient6558" + id="linearGradient4173" + x1="255.15521" + y1="32.347946" + x2="279.8912" + y2="32.347946" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.8073249,0,0,0.8073249,57.960878,7.4036303)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient6558" + id="linearGradient5145" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.7902775,0,0,0.82474,60.019977,8.0684132)" + x1="255.15521" + y1="32.347946" + x2="279.8912" + y2="32.347946" /> + </defs> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + gridtolerance="10000" + guidetolerance="10" + objecttolerance="10" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="2.8" + inkscape:cx="137.4752" + inkscape:cy="57.574575" + inkscape:document-units="px" + inkscape:current-layer="layer1" + width="300px" + height="120px" + showguides="true" + inkscape:guide-bbox="true" + inkscape:window-width="1396" + inkscape:window-height="900" + inkscape:window-x="0" + inkscape:window-y="22" /> + <metadata + id="metadata7"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1"> + <path + style="font-size:12px;font-style:normal;font-weight:normal;fill:#f4f4f4;fill-opacity:1;stroke:#e7e7e7;stroke-width:0.8;stroke-linecap:butt;stroke-linejoin:round;stroke-opacity:1;font-family:Bitstream Vera Sans;stroke-miterlimit:4;stroke-dasharray:none" + d="M 165.36463,80.874808 L 165.36463,80.874808 L 153.32556,80.874808 L 153.32556,81.8344 L 147.64994,81.8344 L 147.64994,36.035583 L 165.36463,36.035583 L 165.36463,20.333129 C 170.58154,21.031083 173.07533,22.077914 172.84609,23.473621 C 172.78871,24.055258 172.21545,24.549594 171.12624,24.956624 L 171.12624,36.035583 L 189.09895,36.035583 L 189.09895,82.532286 L 183.33733,82.532286 L 183.33733,80.874808 L 171.12624,80.874808 L 171.12624,102.94548 L 165.36463,102.94548 L 165.36463,80.874808 M 153.32556,55.489173 L 153.32556,55.489173 L 165.36463,55.489173 L 165.36463,41.793146 L 153.32556,41.793146 L 153.32556,55.489173 M 171.12624,55.489173 L 171.12624,55.489173 L 183.33733,55.489173 L 183.33733,41.793146 L 171.12624,41.793146 L 171.12624,55.489173 M 183.33733,61.333977 L 183.33733,61.333977 L 171.12624,61.333977 L 171.12624,75.030006 L 183.33733,75.030006 L 183.33733,61.333977 M 165.36463,61.333977 L 165.36463,61.333977 L 153.32556,61.333977 L 153.32556,75.030006 L 165.36463,75.030006 L 165.36463,61.333977 M 132.85897,59.414792 C 137.33069,63.136883 140.99969,67.934848 143.86618,73.808701 L 139.13654,77.385372 C 137.24467,72.965445 134.6362,69.12707 131.31114,65.87024 L 131.31114,102.94548 L 125.63554,102.94548 L 125.63554,68.57455 C 122.31042,71.947693 118.52671,74.913707 114.28436,77.47261 L 109.64069,73.372526 C 121.50782,67.091566 130.62312,55.489212 136.98668,38.565417 L 116.26221,38.565417 L 116.26221,32.720615 L 125.80754,32.720615 L 125.80754,20.333129 C 130.85245,21.031083 133.31761,22.048838 133.20299,23.386383 C 133.14561,24.026183 132.57235,24.549594 131.48307,24.956624 L 131.48307,32.720615 L 140.77043,32.720615 L 143.60824,36.733469 C 140.68444,45.51526 137.10137,53.075692 132.85897,59.414792 M 254.11016,49.469901 L 254.11016,49.469901 L 254.11016,20.333129 C 259.21243,21.031083 261.67755,22.048838 261.50562,23.386383 C 261.44823,23.909869 261.04699,24.346044 260.30172,24.694917 C 260.30164,24.694986 260.30164,24.694986 260.30172,24.694917 L 260.30172,24.694917 L 259.78578,24.956624 L 259.78578,49.469901 L 277.15652,49.469901 L 277.15652,55.227471 L 259.78578,55.227471 L 259.78578,93.785712 L 281.45616,93.785712 L 281.45616,99.63051 L 232.35378,99.63051 L 232.35378,93.785712 L 254.11016,93.785712 L 254.11016,55.227471 L 236.22346,55.227471 L 236.22346,49.469901 L 254.11016,49.469901 M 225.5603,59.327554 C 231.12111,63.107798 235.62145,67.876693 239.06127,73.634235 L 234.76157,77.647079 C 231.60845,72.180322 227.82475,67.934848 223.41044,64.910648 L 223.41044,102.94548 L 217.73484,102.94548 L 217.73484,67.44049 C 212.91919,71.627831 207.70222,75.030021 202.084,77.647079 L 197.87027,73.198053 C 212.66118,66.917101 224.01239,55.372897 231.92377,38.565417 L 205.35172,38.565417 L 205.35172,32.720615 L 217.99283,32.720615 L 217.99283,20.333129 C 223.03774,21.031083 225.50291,22.048838 225.38829,23.386383 C 225.33089,24.026183 224.75765,24.549594 223.66837,24.956624 L 223.66837,32.720615 L 236.22346,32.720615 L 238.80326,36.733469 C 235.13421,45.51526 230.71987,53.046611 225.5603,59.327554" + id="text4761" /> + <path + style="font-size:44.09793472px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;fill:#b41717;fill-opacity:1;stroke:#7f2828;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;font-family:Candara;stroke-miterlimit:4;stroke-dasharray:none" + d="M 149.14708,37.774469 C 148.97807,41.117899 148.84526,44.824225 148.74871,48.893456 C 148.67626,52.962754 148.3818,70.641328 148.38184,75.524422 C 148.3818,79.065795 148.05588,81.991266 147.40406,84.300835 C 146.75219,86.610422 145.72612,88.557071 144.32585,90.140779 C 142.94969,91.724494 141.17522,92.901283 139.00239,93.671139 C 136.82953,94.440996 134.22211,94.825935 131.18014,94.825935 C 128.83828,94.825935 126.73787,94.59498 124.87889,94.133049 L 125.4221,89.31593 C 127.13623,90.0418 128.92278,90.404734 130.78177,90.404733 C 132.85805,90.404734 134.66875,90.140782 136.2139,89.612876 C 137.78315,89.062981 139.02651,88.216133 139.94396,87.072335 C 140.8855,85.928548 141.54942,84.520804 141.93572,82.8491 C 142.34613,81.177412 142.55134,78.988811 142.55136,76.283285 C 142.55134,66.297119 142.62852,44.659257 142.26641,37.774469 L 149.14708,37.774469 M 166.38498,80.732697 L 159.83024,80.732697 C 160.16821,76.333498 160.33723,71.307412 160.33723,65.654424 C 160.33723,59.2976 159.91471,53.963567 159.06973,49.652319 L 166.31257,48.761483 C 166.02284,53.358679 165.87799,58.98965 165.87799,65.654424 C 165.87799,70.933479 166.04699,75.959565 166.38498,80.732697 M 167.90601,39.490159 C 167.90598,40.611994 167.5076,41.590815 166.7109,42.42662 C 165.91418,43.240515 164.79155,43.647442 163.343,43.647399 C 162.11172,43.647442 161.146,43.295504 160.44588,42.591595 C 159.76988,41.865769 159.43188,40.996927 159.43188,39.98507 C 159.43188,38.885304 159.84231,37.928485 160.66315,37.114591 C 161.48399,36.30078 162.61869,35.893853 164.06727,35.893811 C 165.25023,35.893853 166.17975,36.256783 166.85575,36.982609 C 167.55588,37.686526 167.90598,38.522373 167.90601,39.490159 M 206.72748,80.732697 L 200.13651,80.732697 C 200.66763,74.947749 200.93319,68.634899 200.9332,61.794122 C 200.93319,58.406756 200.1727,56.097177 198.65174,54.865371 C 197.15487,53.61163 195.00619,52.984747 192.20564,52.984714 C 188.77731,52.984747 185.61465,54.117535 182.71753,56.383099 C 182.71753,63.883761 182.76583,72.000287 182.86238,80.732697 L 176.27142,80.732697 C 176.68182,73.254058 176.88707,67.843042 176.88707,64.499632 C 176.88707,59.352589 176.3559,54.359493 175.29363,49.520339 L 181.66734,48.695493 L 182.35539,52.720761 L 182.64511,52.720761 C 186.21823,49.773323 190.04483,48.299592 194.12499,48.299567 C 198.13265,48.299592 201.23499,49.113454 203.43201,50.741118 C 205.62895,52.346863 206.72747,55.217334 206.72748,59.352563 C 206.72747,59.770507 206.70331,60.595362 206.65507,61.827118 C 206.60675,63.058915 206.5826,63.883761 206.58262,64.30167 C 206.5826,67.975018 206.63088,73.452022 206.72748,80.732697 M 222.69791,48.695493 C 222.28747,55.514282 222.08225,62.355041 222.08225,69.21778 C 222.08225,71.043461 222.14262,73.463019 222.26332,76.476468 C 222.40822,79.467925 222.4806,81.502559 222.48063,82.580363 C 222.4806,89.685068 219.51105,93.996287 213.57195,95.514024 L 211.76124,93.006484 C 213.90995,91.356766 215.2378,89.597085 215.74478,87.727431 C 216.49321,85.043912 216.86743,79.324953 216.86743,70.570535 C 216.86743,61.178248 216.3846,54.16153 215.41887,49.520339 L 222.69791,48.695493 M 224.2551,39.490159 C 224.2551,40.611994 223.85673,41.590815 223.06006,42.42662 C 222.26332,43.240515 221.14069,43.647442 219.69213,43.647399 C 218.46084,43.647442 217.49515,43.295504 216.795,42.591595 C 216.119,41.865769 215.781,40.996927 215.781,39.98507 C 215.781,38.885304 216.19144,37.928485 217.01231,37.114591 C 217.83316,36.30078 218.96785,35.893853 220.4164,35.893811 C 221.5994,35.893853 222.52889,36.256783 223.20492,36.982609 C 223.90503,37.686526 224.2551,38.522373 224.2551,39.490159 M 259.60008,80.732697 L 253.91446,80.930661 C 253.62473,79.852857 253.47987,78.830045 253.4799,77.862216 L 253.11774,77.862216 C 250.14817,80.325772 246.10427,81.557546 240.98606,81.557547 C 238.20962,81.557546 235.8195,80.820682 233.81563,79.346948 C 231.81178,77.851221 230.80988,75.728607 230.80988,72.979099 C 230.80988,69.591724 232.37914,66.875216 235.51769,64.829574 C 238.65625,62.761967 244.48667,61.67316 253.00913,61.563165 C 253.08155,61.035275 253.11772,60.430386 253.11774,59.748497 C 253.11772,57.043003 252.32104,55.239336 250.72765,54.337474 C 249.15832,53.435661 246.76819,52.984747 243.55721,52.984714 C 239.76681,52.984747 236.03678,53.413668 232.3671,54.271484 L 232.9827,49.718301 C 236.60411,48.77251 240.76873,48.299592 245.47658,48.299567 C 249.77395,48.299592 253.09359,49.113454 255.43545,50.741118 C 257.77728,52.346863 258.94819,55.096363 258.94824,58.989625 C 258.94819,60.023469 258.88785,61.904117 258.76715,64.631608 C 258.67054,67.337133 258.62228,69.140806 258.6223,70.042632 C 258.62228,74.045913 258.94819,77.609265 259.60008,80.732697 M 253.19019,74.331856 C 253.06945,70.988469 253.00909,67.986016 253.00913,65.324484 C 248.47027,65.324498 245.01786,65.632443 242.65187,66.248318 C 238.69248,67.348131 236.71278,69.448748 236.71278,72.550177 C 236.71278,75.541643 239.03044,77.037371 243.66588,77.037366 C 247.64942,77.037371 250.82416,76.135534 253.19019,74.331856" + id="text3736" + sodipodi:nodetypes="ccsscssccscccsccccsccsccsscsssccccscscccsccccscsssccscscccscccsscsssccccccscsccscsccscscsccccssc" /> + <path + style="fill:url(#radialGradient6564);fill-opacity:1.0;fill-rule:evenodd;stroke:#323232;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none" + d="M 105.45673,18.675923 C 105.45673,18.675923 88.211949,26.918461 74.172834,28.737898 C 60.133727,30.557333 33.360434,32.377571 28.045622,31.093256 C 22.730818,29.808941 18.915645,28.309196 18.915645,28.309196 L 20.021441,32.056583 L 16.609513,35.052471 L 17.2144,36.121726 L 18.61792,36.22764 L 22.92773,36.762252 L 23.532621,38.688909 L 25.937975,38.905784 L 27.143021,42.970927 C 27.143021,42.970927 32.254764,43.399628 33.758953,43.399628 C 35.263142,43.399628 38.271966,43.187802 38.271966,43.187802 L 38.371202,44.791657 L 39.477002,45.003495 L 39.477002,46.824227 L 37.066917,48.967759 L 37.671807,49.073671 L 37.671807,49.820127 C 37.671807,49.820127 32.255457,50.252157 30.049301,49.93109 C 27.843157,49.610006 27.440747,49.608286 27.440747,49.608286 L 27.242258,49.820127 L 27.143021,50.783455 L 27.643946,50.783455 L 27.84242,54.959544 L 38.976091,54.530844 L 38.172728,68.980747 L 38.073481,70.796442 L 28.645781,70.261816 L 28.546544,66.408513 L 30.649462,66.408513 L 30.852673,64.910557 L 32.757107,64.481857 L 33.059555,64.058192 L 25.937975,62.343374 L 20.522364,63.947229 L 21.42496,64.698732 L 22.327572,64.698732 L 22.426809,65.984848 L 24.331254,66.09076 L 24.331254,69.838147 L 22.228335,70.372777 L 22.630009,71.225146 L 23.130934,71.547931 L 23.130934,74.437917 L 24.435218,74.437917 L 24.435218,87.813529 L 22.327572,88.13632 L 22.630009,91.989617 L 23.929569,92.206492 L 23.731093,100.98236 L 29.449141,101.08826 L 28.244105,92.418334 L 36.868446,92.206492 L 36.268285,96.912181 L 35.464925,100.23086 L 44.188501,100.33677 L 44.287739,91.777793 L 50.303506,91.243181 L 50.005786,96.700351 L 49.802585,99.90807 L 54.920484,99.90807 L 54.717274,91.132217 L 55.421397,91.243181 L 55.619882,87.067076 L 54.816521,87.067076 L 54.518798,85.352258 L 54.017874,80.429702 L 54.216359,74.760706 L 55.31743,74.760706 L 55.31743,71.336105 L 53.913913,71.442015 L 54.117112,67.402096 L 55.747469,67.240708 L 55.823083,65.929374 L 56.749319,65.793192 L 57.699176,65.071956 L 51.985842,63.896802 L 46.31977,65.15265 L 46.872668,66.060507 L 47.47283,66.010066 L 48.172228,65.984848 L 48.299828,67.639144 L 49.878196,67.563497 L 49.906548,71.144447 L 43.111042,70.988097 L 43.337879,67.160002 L 43.559978,63.679927 L 43.559978,59.105378 L 43.763188,54.288748 L 57.373101,53.592733 L 73.567955,52.659674 L 73.71917,55.736265 L 73.142647,63.120082 L 72.892183,69.9945 L 66.928387,69.888585 L 66.900039,65.071956 L 69.106918,64.991267 L 69.206169,63.629486 L 70.108765,63.493308 L 70.061506,63.226006 L 70.964116,63.175568 L 71.465028,62.504773 L 64.721507,60.926122 L 58.001612,62.368592 L 58.4789,63.200785 L 59.230285,63.1453 L 59.230285,63.523577 L 60.156518,63.523577 L 60.156518,65.046738 L 62.136575,65.071956 L 62.112937,69.298485 L 60.109259,69.298485 L 60.080907,70.261816 L 60.785031,70.342507 L 60.70942,74.009202 L 62.188552,74.089909 L 62.013701,88.620507 L 60.057282,89.018952 L 60.080907,89.714967 L 60.761406,89.714967 L 60.761406,93.437137 L 61.886113,93.437137 L 61.588391,98.52109 L 61.210343,102.95945 L 68.331912,103.14605 L 68.105084,99.29275 L 67.580538,96.085028 L 67.476575,93.300955 L 73.520696,93.195041 L 73.345845,97.502272 L 73.317494,102.05159 L 76.729426,102.3189 L 81.3653,102.1323 L 82.820807,101.70358 L 82.017437,99.26753 L 81.818959,95.439438 L 81.440912,92.710853 L 87.206218,92.499027 L 86.955759,95.842931 L 86.932133,101.08826 L 89.238253,101.30009 L 91.520751,101.24965 L 92.621828,100.90165 L 91.969693,95.923633 L 91.747577,92.176239 L 92.725793,92.070324 L 92.749427,88.726422 L 93.02352,88.670945 L 92.976244,87.949712 L 91.846823,87.949712 L 91.619996,85.488427 L 91.520751,74.811143 L 92.371377,74.785924 L 92.371377,71.280616 L 92.725793,71.336105 L 92.725793,70.640088 L 91.468773,70.529127 L 91.497126,66.463987 L 93.600043,66.277382 L 93.477182,64.910557 L 94.403419,64.829863 L 94.351424,64.562549 L 95.580099,63.947229 L 89.337489,62.69138 L 82.995657,63.977495 L 83.39733,64.723951 L 84.375543,64.643256 L 84.427528,64.966046 L 85.254515,64.966046 L 85.301775,66.569901 L 87.357445,66.544681 L 87.532293,70.478688 L 80.264217,70.423216 L 79.413593,64.512124 L 78.733106,61.380041 L 78.184923,55.761484 L 78.510996,52.473053 L 92.999878,51.373557 L 93.047136,46.476221 L 93.774891,46.289613 L 93.727651,45.543159 L 93.174743,45.220372 C 93.174629,45.220372 85.252181,46.395266 82.745197,46.66284 C 82.0389,46.738209 82.09239,46.733258 81.516524,46.79397 L 81.440912,45.886118 L 78.444837,44.317564 L 78.482644,42.491786 L 79.512842,42.461518 L 79.588444,39.949808 C 79.588444,39.949808 85.728225,39.546834 88.009582,39.0117 C 90.290937,38.476559 93.524432,37.942456 93.524432,37.942456 L 95.055545,33.79662 L 98.089437,32.913987 L 98.339888,32.217972 L 105.20628,30.316548 L 105.98602,29.676006 L 103.37744,23.976741 L 103.62792,22.690624 L 104.95584,21.994611 L 105.91041,19.079404 L 105.45673,18.675923 z M 72.466874,40.403728 L 72.429067,42.476654 L 73.983813,42.542211 L 73.884576,44.509221 L 70.836515,46.506487 L 70.647496,47.081457 L 71.876167,47.091543 L 71.866712,47.575729 L 62.552432,48.029652 L 62.613863,46.652742 L 63.039175,45.966809 L 63.067524,45.528025 L 63.07698,44.579832 L 63.341609,43.949374 L 63.440849,43.439982 L 63.440849,43.076841 L 63.842533,41.47297 L 72.466874,40.403728 z M 52.987688,42.168984 L 52.760853,43.561027 L 53.488599,44.418431 L 53.441349,45.916386 L 54.117112,46.960408 L 53.942262,48.191039 L 54.443185,48.912273 L 44.939872,49.2855 L 44.916247,48.967759 L 46.017333,48.831579 L 46.069307,48.428097 L 43.66394,47.121797 L 43.536351,45.03375 L 44.689411,44.978276 L 44.788661,42.72883 L 52.987688,42.168984 z M 67.051262,74.276518 L 72.81657,74.649742 L 72.618099,82.411833 L 73.36947,88.776857 L 67.254465,88.565018 L 67.051262,74.276518 z M 28.44258,74.599304 L 37.671807,75.078442 L 36.868446,80.429702 L 36.868446,84.928593 L 37.520583,87.440302 L 28.494569,87.869006 L 28.44258,74.599304 z M 87.508658,74.649742 L 87.508658,87.924488 L 81.644113,88.353194 L 81.440912,81.342592 L 80.788764,74.811143 L 87.508658,74.649742 z M 43.087416,74.947312 L 49.906548,74.972531 L 49.977434,87.278902 L 43.611966,87.389863 L 43.285891,83.400379 L 43.262266,79.441156 L 43.087416,74.947312 z " + id="path4735" /> + </g> +</svg> diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..5128596 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,19 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/jinja-logo-sidebar.png b/docs/_static/jinja-logo-sidebar.png Binary files differnew file mode 100644 index 0000000..455b4c3 --- /dev/null +++ b/docs/_static/jinja-logo-sidebar.png diff --git a/docs/_static/jinja-logo.png b/docs/_static/jinja-logo.png Binary files differnew file mode 100644 index 0000000..7f8ca5b --- /dev/null +++ b/docs/_static/jinja-logo.png diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..b2db537 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,922 @@ +API +=== + +.. module:: jinja2 + :noindex: + :synopsis: public Jinja API + +This document describes the API to Jinja and not the template language +(for that, see :doc:`/templates`). It will be most useful as reference +to those implementing the template interface to the application and not +those who are creating Jinja templates. + +Basics +------ + +Jinja uses a central object called the template :class:`Environment`. +Instances of this class are used to store the configuration and global objects, +and are used to load templates from the file system or other locations. +Even if you are creating templates from strings by using the constructor of +:class:`Template` class, an environment is created automatically for you, +albeit a shared one. + +Most applications will create one :class:`Environment` object on application +initialization and use that to load templates. In some cases however, it's +useful to have multiple environments side by side, if different configurations +are in use. + +The simplest way to configure Jinja to load templates for your +application is to use :class:`~loaders.PackageLoader`. + +.. code-block:: python + + from jinja2 import Environment, PackageLoader, select_autoescape + env = Environment( + loader=PackageLoader("yourapp"), + autoescape=select_autoescape() + ) + +This will create a template environment with a loader that looks up +templates in the ``templates`` folder inside the ``yourapp`` Python +package (or next to the ``yourapp.py`` Python module). It also enables +autoescaping for HTML files. This loader only requires that ``yourapp`` +is importable, it figures out the absolute path to the folder for you. + +Different loaders are available to load templates in other ways or from +other locations. They're listed in the `Loaders`_ section below. You can +also write your own if you want to load templates from a source that's +more specialized to your project. + +To load a template from this environment, call the :meth:`get_template` +method, which returns the loaded :class:`Template`. + +.. code-block:: python + + template = env.get_template("mytemplate.html") + +To render it with some variables, call the :meth:`render` method. + +.. code-block:: python + + print(template.render(the="variables", go="here")) + +Using a template loader rather than passing strings to :class:`Template` +or :meth:`Environment.from_string` has multiple advantages. Besides being +a lot easier to use it also enables template inheritance. + +.. admonition:: Notes on Autoescaping + + In future versions of Jinja we might enable autoescaping by default + for security reasons. As such you are encouraged to explicitly + configure autoescaping now instead of relying on the default. + + +High Level API +-------------- + +The high-level API is the API you will use in the application to load and +render Jinja templates. The :ref:`low-level-api` on the other side is only +useful if you want to dig deeper into Jinja or :ref:`develop extensions +<jinja-extensions>`. + +.. autoclass:: Environment([options]) + :members: from_string, get_template, select_template, + get_or_select_template, join_path, extend, compile_expression, + compile_templates, list_templates, add_extension + + .. attribute:: shared + + If a template was created by using the :class:`Template` constructor + an environment is created automatically. These environments are + created as shared environments which means that multiple templates + may have the same anonymous environment. For all shared environments + this attribute is `True`, else `False`. + + .. attribute:: sandboxed + + If the environment is sandboxed this attribute is `True`. For the + sandbox mode have a look at the documentation for the + :class:`~jinja2.sandbox.SandboxedEnvironment`. + + .. attribute:: filters + + A dict of filters for this environment. As long as no template was + loaded it's safe to add new filters or remove old. For custom filters + see :ref:`writing-filters`. For valid filter names have a look at + :ref:`identifier-naming`. + + .. attribute:: tests + + A dict of test functions for this environment. As long as no + template was loaded it's safe to modify this dict. For custom tests + see :ref:`writing-tests`. For valid test names have a look at + :ref:`identifier-naming`. + + .. attribute:: globals + + A dict of variables that are available in every template loaded + by the environment. As long as no template was loaded it's safe + to modify this. For more details see :ref:`global-namespace`. + For valid object names see :ref:`identifier-naming`. + + .. attribute:: policies + + A dictionary with :ref:`policies`. These can be reconfigured to + change the runtime behavior or certain template features. Usually + these are security related. + + .. attribute:: code_generator_class + + The class used for code generation. This should not be changed + in most cases, unless you need to modify the Python code a + template compiles to. + + .. attribute:: context_class + + The context used for templates. This should not be changed + in most cases, unless you need to modify internals of how + template variables are handled. For details, see + :class:`~jinja2.runtime.Context`. + + .. automethod:: overlay([options]) + + .. method:: undefined([hint, obj, name, exc]) + + Creates a new :class:`Undefined` object for `name`. This is useful + for filters or functions that may return undefined objects for + some operations. All parameters except of `hint` should be provided + as keyword parameters for better readability. The `hint` is used as + error message for the exception if provided, otherwise the error + message will be generated from `obj` and `name` automatically. The exception + provided as `exc` is raised if something with the generated undefined + object is done that the undefined object does not allow. The default + exception is :exc:`UndefinedError`. If a `hint` is provided the + `name` may be omitted. + + The most common way to create an undefined object is by providing + a name only:: + + return environment.undefined(name='some_name') + + This means that the name `some_name` is not defined. If the name + was from an attribute of an object it makes sense to tell the + undefined object the holder object to improve the error message:: + + if not hasattr(obj, 'attr'): + return environment.undefined(obj=obj, name='attr') + + For a more complex example you can provide a hint. For example + the :func:`first` filter creates an undefined object that way:: + + return environment.undefined('no first item, sequence was empty') + + If it the `name` or `obj` is known (for example because an attribute + was accessed) it should be passed to the undefined object, even if + a custom `hint` is provided. This gives undefined objects the + possibility to enhance the error message. + +.. autoclass:: Template + :members: module, make_module + + .. attribute:: globals + + A dict of variables that are available every time the template + is rendered, without needing to pass them during render. This + should not be modified, as depending on how the template was + loaded it may be shared with the environment and other + templates. + + Defaults to :attr:`Environment.globals` unless extra values are + passed to :meth:`Environment.get_template`. + + Globals are only intended for data that is common to every + render of the template. Specific data should be passed to + :meth:`render`. + + See :ref:`global-namespace`. + + .. attribute:: name + + The loading name of the template. If the template was loaded from a + string this is `None`. + + .. attribute:: filename + + The filename of the template on the file system if it was loaded from + there. Otherwise this is `None`. + + .. automethod:: render([context]) + + .. automethod:: generate([context]) + + .. automethod:: stream([context]) + + .. automethod:: render_async([context]) + + .. automethod:: generate_async([context]) + + +.. autoclass:: jinja2.environment.TemplateStream() + :members: disable_buffering, enable_buffering, dump + + +Autoescaping +------------ + +.. versionchanged:: 2.4 + +Jinja now comes with autoescaping support. As of Jinja 2.9 the +autoescape extension is removed and built-in. However autoescaping is +not yet enabled by default though this will most likely change in the +future. It's recommended to configure a sensible default for +autoescaping. This makes it possible to enable and disable autoescaping +on a per-template basis (HTML versus text for instance). + +.. autofunction:: jinja2.select_autoescape + +Here a recommended setup that enables autoescaping for templates ending +in ``'.html'``, ``'.htm'`` and ``'.xml'`` and disabling it by default +for all other extensions. You can use the :func:`~jinja2.select_autoescape` +function for this:: + + from jinja2 import Environment, PackageLoader, select_autoescape + env = Environment(autoescape=select_autoescape(['html', 'htm', 'xml']), + loader=PackageLoader('mypackage')) + +The :func:`~jinja.select_autoescape` function returns a function that +works roughly like this:: + + def autoescape(template_name): + if template_name is None: + return False + if template_name.endswith(('.html', '.htm', '.xml')) + +When implementing a guessing autoescape function, make sure you also +accept `None` as valid template name. This will be passed when generating +templates from strings. You should always configure autoescaping as +defaults in the future might change. + +Inside the templates the behaviour can be temporarily changed by using +the `autoescape` block (see :ref:`autoescape-overrides`). + + +.. _identifier-naming: + +Notes on Identifiers +-------------------- + +Jinja uses Python naming rules. Valid identifiers can be any combination +of characters accepted by Python. + +Filters and tests are looked up in separate namespaces and have slightly +modified identifier syntax. Filters and tests may contain dots to group +filters and tests by topic. For example it's perfectly valid to add a +function into the filter dict and call it `to.str`. The regular +expression for filter and test identifiers is +``[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*``. + + +Undefined Types +--------------- + +These classes can be used as undefined types. The :class:`Environment` +constructor takes an `undefined` parameter that can be one of those classes +or a custom subclass of :class:`Undefined`. Whenever the template engine is +unable to look up a name or access an attribute one of those objects is +created and returned. Some operations on undefined values are then allowed, +others fail. + +The closest to regular Python behavior is the :class:`StrictUndefined` which +disallows all operations beside testing if it's an undefined object. + +.. autoclass:: jinja2.Undefined() + + .. attribute:: _undefined_hint + + Either `None` or a string with the error message for the + undefined object. + + .. attribute:: _undefined_obj + + Either `None` or the owner object that caused the undefined object + to be created (for example because an attribute does not exist). + + .. attribute:: _undefined_name + + The name for the undefined variable / attribute or just `None` + if no such information exists. + + .. attribute:: _undefined_exception + + The exception that the undefined object wants to raise. This + is usually one of :exc:`UndefinedError` or :exc:`SecurityError`. + + .. method:: _fail_with_undefined_error(\*args, \**kwargs) + + When called with any arguments this method raises + :attr:`_undefined_exception` with an error message generated + from the undefined hints stored on the undefined object. + +.. autoclass:: jinja2.ChainableUndefined() + +.. autoclass:: jinja2.DebugUndefined() + +.. autoclass:: jinja2.StrictUndefined() + +There is also a factory function that can decorate undefined objects to +implement logging on failures: + +.. autofunction:: jinja2.make_logging_undefined + +Undefined objects are created by calling :attr:`undefined`. + +.. admonition:: Implementation + + :class:`Undefined` is implemented by overriding the special + ``__underscore__`` methods. For example the default + :class:`Undefined` class implements ``__str__`` to returns an empty + string, while ``__int__`` and others fail with an exception. To + allow conversion to int by returning ``0`` you can implement your + own subclass. + + .. code-block:: python + + class NullUndefined(Undefined): + def __int__(self): + return 0 + + def __float__(self): + return 0.0 + + To disallow a method, override it and raise + :attr:`~Undefined._undefined_exception`. Because this is very + common there is the helper method + :meth:`~Undefined._fail_with_undefined_error` that raises the error + with the correct information. Here's a class that works like the + regular :class:`Undefined` but fails on iteration:: + + class NonIterableUndefined(Undefined): + def __iter__(self): + self._fail_with_undefined_error() + + +The Context +----------- + +.. autoclass:: jinja2.runtime.Context() + :members: get, resolve, resolve_or_missing, get_exported, get_all + + .. attribute:: parent + + A dict of read only, global variables the template looks up. These + can either come from another :class:`Context`, from the + :attr:`Environment.globals` or :attr:`Template.globals` or points + to a dict created by combining the globals with the variables + passed to the render function. It must not be altered. + + .. attribute:: vars + + The template local variables. This list contains environment and + context functions from the :attr:`parent` scope as well as local + modifications and exported variables from the template. The template + will modify this dict during template evaluation but filters and + context functions are not allowed to modify it. + + .. attribute:: environment + + The environment that loaded the template. + + .. attribute:: exported_vars + + This set contains all the names the template exports. The values for + the names are in the :attr:`vars` dict. In order to get a copy of the + exported variables as dict, :meth:`get_exported` can be used. + + .. attribute:: name + + The load name of the template owning this context. + + .. attribute:: blocks + + A dict with the current mapping of blocks in the template. The keys + in this dict are the names of the blocks, and the values a list of + blocks registered. The last item in each list is the current active + block (latest in the inheritance chain). + + .. attribute:: eval_ctx + + The current :ref:`eval-context`. + + .. automethod:: jinja2.runtime.Context.call(callable, \*args, \**kwargs) + + +The context is immutable, it prevents modifications, and if it is +modified somehow despite that those changes may not show up. For +performance, Jinja does not use the context as data storage for, only as +a primary data source. Variables that the template does not define are +looked up in the context, but variables the template does define are +stored locally. + +Instead of modifying the context directly, a function should return +a value that can be assigned to a variable within the template itself. + +.. code-block:: jinja + + {% set comments = get_latest_comments() %} + + +.. _loaders: + +Loaders +------- + +Loaders are responsible for loading templates from a resource such as the +file system. The environment will keep the compiled modules in memory like +Python's `sys.modules`. Unlike `sys.modules` however this cache is limited in +size by default and templates are automatically reloaded. +All loaders are subclasses of :class:`BaseLoader`. If you want to create your +own loader, subclass :class:`BaseLoader` and override `get_source`. + +.. autoclass:: jinja2.BaseLoader + :members: get_source, load + +Here a list of the builtin loaders Jinja provides: + +.. autoclass:: jinja2.FileSystemLoader + +.. autoclass:: jinja2.PackageLoader + +.. autoclass:: jinja2.DictLoader + +.. autoclass:: jinja2.FunctionLoader + +.. autoclass:: jinja2.PrefixLoader + +.. autoclass:: jinja2.ChoiceLoader + +.. autoclass:: jinja2.ModuleLoader + + +.. _bytecode-cache: + +Bytecode Cache +-------------- + +Jinja 2.1 and higher support external bytecode caching. Bytecode caches make +it possible to store the generated bytecode on the file system or a different +location to avoid parsing the templates on first use. + +This is especially useful if you have a web application that is initialized on +the first request and Jinja compiles many templates at once which slows down +the application. + +To use a bytecode cache, instantiate it and pass it to the :class:`Environment`. + +.. autoclass:: jinja2.BytecodeCache + :members: load_bytecode, dump_bytecode, clear + +.. autoclass:: jinja2.bccache.Bucket + :members: write_bytecode, load_bytecode, bytecode_from_string, + bytecode_to_string, reset + + .. attribute:: environment + + The :class:`Environment` that created the bucket. + + .. attribute:: key + + The unique cache key for this bucket + + .. attribute:: code + + The bytecode if it's loaded, otherwise `None`. + + +Builtin bytecode caches: + +.. autoclass:: jinja2.FileSystemBytecodeCache + +.. autoclass:: jinja2.MemcachedBytecodeCache + + +Async Support +------------- + +.. versionadded:: 2.9 + +Jinja supports the Python ``async`` and ``await`` syntax. For the +template designer, this support (when enabled) is entirely transparent, +templates continue to look exactly the same. However, developers should +be aware of the implementation as it affects what types of APIs you can +use. + +By default, async support is disabled. Enabling it will cause the +environment to compile different code behind the scenes in order to +handle async and sync code in an asyncio event loop. This has the +following implications: + +- Template rendering requires an event loop to be available to the + current thread. :func:`asyncio.get_running_loop` must return an + event loop. +- The compiled code uses ``await`` for functions and attributes, and + uses ``async for`` loops. In order to support using both async and + sync functions in this context, a small wrapper is placed around + all calls and access, which adds overhead compared to purely async + code. +- Sync methods and filters become wrappers around their corresponding + async implementations where needed. For example, ``render`` invokes + ``async_render``, and ``|map`` supports async iterables. + +Awaitable objects can be returned from functions in templates and any +function call in a template will automatically await the result. The +``await`` you would normally add in Python is implied. For example, you +can provide a method that asynchronously loads data from a database, and +from the template designer's point of view it can be called like any +other function. + + +.. _policies: + +Policies +-------- + +Starting with Jinja 2.9 policies can be configured on the environment +which can slightly influence how filters and other template constructs +behave. They can be configured with the +:attr:`~jinja2.Environment.policies` attribute. + +Example:: + + env.policies['urlize.rel'] = 'nofollow noopener' + +``truncate.leeway``: + Configures the leeway default for the `truncate` filter. Leeway as + introduced in 2.9 but to restore compatibility with older templates + it can be configured to `0` to get the old behavior back. The default + is `5`. + +``urlize.rel``: + A string that defines the items for the `rel` attribute of generated + links with the `urlize` filter. These items are always added. The + default is `noopener`. + +``urlize.target``: + The default target that is issued for links from the `urlize` filter + if no other target is defined by the call explicitly. + +``urlize.extra_schemes``: + Recognize URLs that start with these schemes in addition to the + default ``http://``, ``https://``, and ``mailto:``. + +``json.dumps_function``: + If this is set to a value other than `None` then the `tojson` filter + will dump with this function instead of the default one. Note that + this function should accept arbitrary extra arguments which might be + passed in the future from the filter. Currently the only argument + that might be passed is `indent`. The default dump function is + ``json.dumps``. + +``json.dumps_kwargs``: + Keyword arguments to be passed to the dump function. The default is + ``{'sort_keys': True}``. + +.. _ext-i18n-trimmed: + +``ext.i18n.trimmed``: + If this is set to `True`, ``{% trans %}`` blocks of the + :ref:`i18n-extension` will always unify linebreaks and surrounding + whitespace as if the `trimmed` modifier was used. + + +Utilities +--------- + +These helper functions and classes are useful if you add custom filters or +functions to a Jinja environment. + +.. autofunction:: jinja2.pass_context + +.. autofunction:: jinja2.pass_eval_context + +.. autofunction:: jinja2.pass_environment + +.. autofunction:: jinja2.clear_caches + +.. autofunction:: jinja2.is_undefined + + +Exceptions +---------- + +.. autoexception:: jinja2.TemplateError + +.. autoexception:: jinja2.UndefinedError + +.. autoexception:: jinja2.TemplateNotFound + +.. autoexception:: jinja2.TemplatesNotFound + +.. autoexception:: jinja2.TemplateSyntaxError + + .. attribute:: message + + The error message. + + .. attribute:: lineno + + The line number where the error occurred. + + .. attribute:: name + + The load name for the template. + + .. attribute:: filename + + The filename that loaded the template in the encoding of the + file system (most likely utf-8, or mbcs on Windows systems). + +.. autoexception:: jinja2.TemplateRuntimeError + +.. autoexception:: jinja2.TemplateAssertionError + + +.. _writing-filters: + +Custom Filters +-------------- + +Filters are Python functions that take the value to the left of the +filter as the first argument and produce a new value. Arguments passed +to the filter are passed after the value. + +For example, the filter ``{{ 42|myfilter(23) }}`` is called behind the +scenes as ``myfilter(42, 23)``. + +Jinja comes with some :ref:`built-in filters <builtin-filters>`. To use +a custom filter, write a function that takes at least a ``value`` +argument, then register it in :attr:`Environment.filters`. + +Here's a filter that formats datetime objects: + +.. code-block:: python + + def datetime_format(value, format="%H:%M %d-%m-%y"): + return value.strftime(format) + + environment.filters["datetime_format"] = datetime_format + +Now it can be used in templates: + +.. sourcecode:: jinja + + {{ article.pub_date|datetimeformat }} + {{ article.pub_date|datetimeformat("%B %Y") }} + +Some decorators are available to tell Jinja to pass extra information to +the filter. The object is passed as the first argument, making the value +being filtered the second argument. + +- :func:`pass_environment` passes the :class:`Environment`. +- :func:`pass_eval_context` passes the :ref:`eval-context`. +- :func:`pass_context` passes the current + :class:`~jinja2.runtime.Context`. + +Here's a filter that converts line breaks into HTML ``<br>`` and ``<p>`` +tags. It uses the eval context to check if autoescape is currently +enabled before escaping the input and marking the output safe. + +.. code-block:: python + + import re + from jinja2 import pass_eval_context + from markupsafe import Markup, escape + + @pass_eval_context + def nl2br(eval_ctx, value): + br = "<br>\n" + + if eval_ctx.autoescape: + value = escape(value) + br = Markup(br) + + result = "\n\n".join( + f"<p>{br.join(p.splitlines())}<\p>" + for p in re.split(r"(?:\r\n|\r(?!\n)|\n){2,}", value) + ) + return Markup(result) if autoescape else result + + +.. _writing-tests: + +Custom Tests +------------ + +Test are Python functions that take the value to the left of the test as +the first argument, and return ``True`` or ``False``. Arguments passed +to the test are passed after the value. + +For example, the test ``{{ 42 is even }}`` is called behind the scenes +as ``is_even(42)``. + +Jinja comes with some :ref:`built-in tests <builtin-tests>`. To use a +custom tests, write a function that takes at least a ``value`` argument, +then register it in :attr:`Environment.tests`. + +Here's a test that checks if a value is a prime number: + +.. code-block:: python + + import math + + def is_prime(n): + if n == 2: + return True + + for i in range(2, int(math.ceil(math.sqrt(n))) + 1): + if n % i == 0: + return False + + return True + + environment.tests["prime"] = is_prime + +Now it can be used in templates: + +.. sourcecode:: jinja + + {% if value is prime %} + {{ value }} is a prime number + {% else %} + {{ value }} is not a prime number + {% endif %} + +Some decorators are available to tell Jinja to pass extra information to +the filter. The object is passed as the first argument, making the value +being filtered the second argument. + +- :func:`pass_environment` passes the :class:`Environment`. +- :func:`pass_eval_context` passes the :ref:`eval-context`. +- :func:`pass_context` passes the current + :class:`~jinja2.runtime.Context`. + + +.. _eval-context: + +Evaluation Context +------------------ + +The evaluation context (short eval context or eval ctx) makes it +possible to activate and deactivate compiled features at runtime. + +Currently it is only used to enable and disable automatic escaping, but +it can be used by extensions as well. + +The ``autoescape`` setting should be checked on the evaluation context, +not the environment. The evaluation context will have the computed value +for the current template. + +Instead of ``pass_environment``: + +.. code-block:: python + + @pass_environment + def filter(env, value): + result = do_something(value) + + if env.autoescape: + result = Markup(result) + + return result + +Use ``pass_eval_context`` if you only need the setting: + +.. code-block:: python + + @pass_eval_context + def filter(eval_ctx, value): + result = do_something(value) + + if eval_ctx.autoescape: + result = Markup(result) + + return result + +Or use ``pass_context`` if you need other context behavior as well: + +.. code-block:: python + + @pass_context + def filter(context, value): + result = do_something(value) + + if context.eval_ctx.autoescape: + result = Markup(result) + + return result + +The evaluation context must not be modified at runtime. Modifications +must only happen with a :class:`nodes.EvalContextModifier` and +:class:`nodes.ScopedEvalContextModifier` from an extension, not on the +eval context object itself. + +.. autoclass:: jinja2.nodes.EvalContext + + .. attribute:: autoescape + + `True` or `False` depending on if autoescaping is active or not. + + .. attribute:: volatile + + `True` if the compiler cannot evaluate some expressions at compile + time. At runtime this should always be `False`. + + +.. _global-namespace: + +The Global Namespace +-------------------- + +The global namespace stores variables and functions that should be +available without needing to pass them to :meth:`Template.render`. They +are also available to templates that are imported or included without +context. Most applications should only use :attr:`Environment.globals`. + +:attr:`Environment.globals` are intended for data that is common to all +templates loaded by that environment. :attr:`Template.globals` are +intended for data that is common to all renders of that template, and +default to :attr:`Environment.globals` unless they're given in +:meth:`Environment.get_template`, etc. Data that is specific to a +render should be passed as context to :meth:`Template.render`. + +Only one set of globals is used during any specific rendering. If +templates A and B both have template globals, and B extends A, then +only B's globals are used for both when using ``b.render()``. + +Environment globals should not be changed after loading any templates, +and template globals should not be changed at any time after loading the +template. Changing globals after loading a template will result in +unexpected behavior as they may be shared between the environment and +other templates. + + +.. _low-level-api: + +Low Level API +------------- + +The low level API exposes functionality that can be useful to understand some +implementation details, debugging purposes or advanced :ref:`extension +<jinja-extensions>` techniques. Unless you know exactly what you are doing we +don't recommend using any of those. + +.. automethod:: Environment.lex + +.. automethod:: Environment.parse + +.. automethod:: Environment.preprocess + +.. automethod:: Template.new_context + +.. method:: Template.root_render_func(context) + + This is the low level render function. It's passed a :class:`Context` + that has to be created by :meth:`new_context` of the same template or + a compatible template. This render function is generated by the + compiler from the template code and returns a generator that yields + strings. + + If an exception in the template code happens the template engine will + not rewrite the exception but pass through the original one. As a + matter of fact this function should only be called from within a + :meth:`render` / :meth:`generate` / :meth:`stream` call. + +.. attribute:: Template.blocks + + A dict of block render functions. Each of these functions works exactly + like the :meth:`root_render_func` with the same limitations. + +.. attribute:: Template.is_up_to_date + + This attribute is `False` if there is a newer version of the template + available, otherwise `True`. + +.. admonition:: Note + + The low-level API is fragile. Future Jinja versions will try not to + change it in a backwards incompatible way but modifications in the Jinja + core may shine through. For example if Jinja introduces a new AST node + in later versions that may be returned by :meth:`~Environment.parse`. + +The Meta API +------------ + +.. versionadded:: 2.2 + +The meta API returns some information about abstract syntax trees that +could help applications to implement more advanced template concepts. All +the functions of the meta API operate on an abstract syntax tree as +returned by the :meth:`Environment.parse` method. + +.. autofunction:: jinja2.meta.find_undeclared_variables + +.. autofunction:: jinja2.meta.find_referenced_templates diff --git a/docs/changes.rst b/docs/changes.rst new file mode 100644 index 0000000..955deaf --- /dev/null +++ b/docs/changes.rst @@ -0,0 +1,4 @@ +Changes +======= + +.. include:: ../CHANGES.rst diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..f65d462 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,53 @@ +from pallets_sphinx_themes import get_version +from pallets_sphinx_themes import ProjectLink + +# Project -------------------------------------------------------------- + +project = "Jinja" +copyright = "2007 Pallets" +author = "Pallets" +release, version = get_version("Jinja2") + +# General -------------------------------------------------------------- + +master_doc = "index" +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "pallets_sphinx_themes", + "sphinxcontrib.log_cabinet", + "sphinx_issues", +] +autodoc_typehints = "description" +intersphinx_mapping = {"python": ("https://docs.python.org/3/", None)} +issues_github_path = "pallets/jinja" + +# HTML ----------------------------------------------------------------- + +html_theme = "jinja" +html_theme_options = {"index_sidebar_logo": False} +html_context = { + "project_links": [ + ProjectLink("Donate", "https://palletsprojects.com/donate"), + ProjectLink("PyPI Releases", "https://pypi.org/project/Jinja2/"), + ProjectLink("Source Code", "https://github.com/pallets/jinja/"), + ProjectLink("Issue Tracker", "https://github.com/pallets/jinja/issues/"), + ProjectLink("Website", "https://palletsprojects.com/p/jinja/"), + ProjectLink("Twitter", "https://twitter.com/PalletsTeam"), + ProjectLink("Chat", "https://discord.gg/pallets"), + ] +} +html_sidebars = { + "index": ["project.html", "localtoc.html", "searchbox.html", "ethicalads.html"], + "**": ["localtoc.html", "relations.html", "searchbox.html", "ethicalads.html"], +} +singlehtml_sidebars = {"index": ["project.html", "localtoc.html", "ethicalads.html"]} +html_static_path = ["_static"] +html_favicon = "_static/jinja-logo-sidebar.png" +html_logo = "_static/jinja-logo-sidebar.png" +html_title = f"Jinja Documentation ({version})" +html_show_sourcelink = False + +# LaTeX ---------------------------------------------------------------- + +latex_documents = [(master_doc, f"Jinja-{version}.tex", html_title, author, "manual")] diff --git a/docs/examples/cache_extension.py b/docs/examples/cache_extension.py new file mode 100644 index 0000000..46af67c --- /dev/null +++ b/docs/examples/cache_extension.py @@ -0,0 +1,54 @@ +from jinja2 import nodes +from jinja2.ext import Extension + + +class FragmentCacheExtension(Extension): + # a set of names that trigger the extension. + tags = {"cache"} + + def __init__(self, environment): + super().__init__(environment) + + # add the defaults to the environment + environment.extend(fragment_cache_prefix="", fragment_cache=None) + + def parse(self, parser): + # the first token is the token that started the tag. In our case + # we only listen to ``'cache'`` so this will be a name token with + # `cache` as value. We get the line number so that we can give + # that line number to the nodes we create by hand. + lineno = next(parser.stream).lineno + + # now we parse a single expression that is used as cache key. + args = [parser.parse_expression()] + + # if there is a comma, the user provided a timeout. If not use + # None as second parameter. + if parser.stream.skip_if("comma"): + args.append(parser.parse_expression()) + else: + args.append(nodes.Const(None)) + + # now we parse the body of the cache block up to `endcache` and + # drop the needle (which would always be `endcache` in that case) + body = parser.parse_statements(["name:endcache"], drop_needle=True) + + # now return a `CallBlock` node that calls our _cache_support + # helper method on this extension. + return nodes.CallBlock( + self.call_method("_cache_support", args), [], [], body + ).set_lineno(lineno) + + def _cache_support(self, name, timeout, caller): + """Helper callback.""" + key = self.environment.fragment_cache_prefix + name + + # try to load the block from the cache + # if there is no fragment in the cache, render it and store + # it in the cache. + rv = self.environment.fragment_cache.get(key) + if rv is not None: + return rv + rv = caller() + self.environment.fragment_cache.add(key, rv, timeout) + return rv diff --git a/docs/examples/inline_gettext_extension.py b/docs/examples/inline_gettext_extension.py new file mode 100644 index 0000000..bf8b9db --- /dev/null +++ b/docs/examples/inline_gettext_extension.py @@ -0,0 +1,72 @@ +import re + +from jinja2.exceptions import TemplateSyntaxError +from jinja2.ext import Extension +from jinja2.lexer import count_newlines +from jinja2.lexer import Token + + +_outside_re = re.compile(r"\\?(gettext|_)\(") +_inside_re = re.compile(r"\\?[()]") + + +class InlineGettext(Extension): + """This extension implements support for inline gettext blocks:: + + <h1>_(Welcome)</h1> + <p>_(This is a paragraph)</p> + + Requires the i18n extension to be loaded and configured. + """ + + def filter_stream(self, stream): + paren_stack = 0 + + for token in stream: + if token.type != "data": + yield token + continue + + pos = 0 + lineno = token.lineno + + while True: + if not paren_stack: + match = _outside_re.search(token.value, pos) + else: + match = _inside_re.search(token.value, pos) + if match is None: + break + new_pos = match.start() + if new_pos > pos: + preval = token.value[pos:new_pos] + yield Token(lineno, "data", preval) + lineno += count_newlines(preval) + gtok = match.group() + if gtok[0] == "\\": + yield Token(lineno, "data", gtok[1:]) + elif not paren_stack: + yield Token(lineno, "block_begin", None) + yield Token(lineno, "name", "trans") + yield Token(lineno, "block_end", None) + paren_stack = 1 + else: + if gtok == "(" or paren_stack > 1: + yield Token(lineno, "data", gtok) + paren_stack += -1 if gtok == ")" else 1 + if not paren_stack: + yield Token(lineno, "block_begin", None) + yield Token(lineno, "name", "endtrans") + yield Token(lineno, "block_end", None) + pos = match.end() + + if pos < len(token.value): + yield Token(lineno, "data", token.value[pos:]) + + if paren_stack: + raise TemplateSyntaxError( + "unclosed gettext expression", + token.lineno, + stream.name, + stream.filename, + ) diff --git a/docs/extensions.rst b/docs/extensions.rst new file mode 100644 index 0000000..45ead3b --- /dev/null +++ b/docs/extensions.rst @@ -0,0 +1,423 @@ +.. _jinja-extensions: + +Extensions +========== + +Jinja supports extensions that can add extra filters, tests, globals or even +extend the parser. The main motivation of extensions is to move often used +code into a reusable class like adding support for internationalization. + + +Adding Extensions +----------------- + +Extensions are added to the Jinja environment at creation time. To add an +extension pass a list of extension classes or import paths to the +``extensions`` parameter of the :class:`~jinja2.Environment` constructor. The following +example creates a Jinja environment with the i18n extension loaded:: + + jinja_env = Environment(extensions=['jinja2.ext.i18n']) + +To add extensions after creation time, use the :meth:`~jinja2.Environment.add_extension` method:: + + jinja_env.add_extension('jinja2.ext.debug') + + +.. _i18n-extension: + +i18n Extension +-------------- + +**Import name:** ``jinja2.ext.i18n`` + +The i18n extension can be used in combination with `gettext`_ or +`Babel`_. When it's enabled, Jinja provides a ``trans`` statement that +marks a block as translatable and calls ``gettext``. + +After enabling, an application has to provide functions for ``gettext``, +``ngettext``, and optionally ``pgettext`` and ``npgettext``, either +globally or when rendering. A ``_()`` function is added as an alias to +the ``gettext`` function. + + +Environment Methods +~~~~~~~~~~~~~~~~~~~ + +After enabling the extension, the environment provides the following +additional methods: + +.. method:: jinja2.Environment.install_gettext_translations(translations, newstyle=False) + + Installs a translation globally for the environment. The + ``translations`` object must implement ``gettext``, ``ngettext``, + and optionally ``pgettext`` and ``npgettext``. + :class:`gettext.NullTranslations`, :class:`gettext.GNUTranslations`, + and `Babel`_\s ``Translations`` are supported. + + .. versionchanged:: 3.0 + Added ``pgettext`` and ``npgettext``. + + .. versionchanged:: 2.5 + Added new-style gettext support. + +.. method:: jinja2.Environment.install_null_translations(newstyle=False) + + Install no-op gettext functions. This is useful if you want to + prepare the application for internationalization but don't want to + implement the full system yet. + + .. versionchanged:: 2.5 Added new-style gettext support. + +.. method:: jinja2.Environment.install_gettext_callables(gettext, ngettext, newstyle=False, pgettext=None, npgettext=None) + + Install the given ``gettext``, ``ngettext``, ``pgettext``, and + ``npgettext`` callables into the environment. They should behave + exactly like :func:`gettext.gettext`, :func:`gettext.ngettext`, + :func:`gettext.pgettext` and :func:`gettext.npgettext`. + + If ``newstyle`` is activated, the callables are wrapped to work like + newstyle callables. See :ref:`newstyle-gettext` for more information. + + .. versionchanged:: 3.0 + Added ``pgettext`` and ``npgettext``. + + .. versionadded:: 2.5 + Added new-style gettext support. + +.. method:: jinja2.Environment.uninstall_gettext_translations() + + Uninstall the environment's globally installed translation. + +.. method:: jinja2.Environment.extract_translations(source) + + Extract localizable strings from the given template node or source. + + For every string found this function yields a ``(lineno, function, + message)`` tuple, where: + + - ``lineno`` is the number of the line on which the string was + found. + - ``function`` is the name of the ``gettext`` function used (if + the string was extracted from embedded Python code). + - ``message`` is the string itself, or a tuple of strings for + functions with multiple arguments. + + If `Babel`_ is installed, see :ref:`babel-integration` to extract + the strings. + +For a web application that is available in multiple languages but gives +all the users the same language (for example, multilingual forum +software installed for a French community), the translation may be +installed when the environment is created. + +.. code-block:: python + + translations = get_gettext_translations() + env = Environment(extensions=["jinja2.ext.i18n"]) + env.install_gettext_translations(translations) + +The ``get_gettext_translations`` function would return the translator +for the current configuration, for example by using ``gettext.find``. + +The usage of the ``i18n`` extension for template designers is covered in +:ref:`the template documentation <i18n-in-templates>`. + +.. _gettext: https://docs.python.org/3/library/gettext.html +.. _Babel: https://babel.pocoo.org/ + + +Whitespace Trimming +~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.10 + +Within ``{% trans %}`` blocks, it can be useful to trim line breaks and +whitespace so that the block of text looks like a simple string with +single spaces in the translation file. + +Linebreaks and surrounding whitespace can be automatically trimmed by +enabling the ``ext.i18n.trimmed`` :ref:`policy <ext-i18n-trimmed>`. + + +.. _newstyle-gettext: + +New Style Gettext +~~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.5 + +New style gettext calls are less to type, less error prone, and support +autoescaping better. + +You can use "new style" gettext calls by setting +``env.newstyle_gettext = True`` or passing ``newstyle=True`` to +``env.install_translations``. They are fully supported by the Babel +extraction tool, but might not work as expected with other extraction +tools. + +With standard ``gettext`` calls, string formatting is a separate step +done with the ``|format`` filter. This requires duplicating work for +``ngettext`` calls. + +.. sourcecode:: jinja + + {{ gettext("Hello, World!") }} + {{ gettext("Hello, %(name)s!")|format(name=name) }} + {{ ngettext( + "%(num)d apple", "%(num)d apples", apples|count + )|format(num=apples|count) }} + {{ pgettext("greeting", "Hello, World!") }} + {{ npgettext( + "fruit", "%(num)d apple", "%(num)d apples", apples|count + )|format(num=apples|count) }} + +New style ``gettext`` make formatting part of the call, and behind the +scenes enforce more consistency. + +.. sourcecode:: jinja + + {{ gettext("Hello, World!") }} + {{ gettext("Hello, %(name)s!", name=name) }} + {{ ngettext("%(num)d apple", "%(num)d apples", apples|count) }} + {{ pgettext("greeting", "Hello, World!") }} + {{ npgettext("fruit", "%(num)d apple", "%(num)d apples", apples|count) }} + +The advantages of newstyle gettext are: + +- There's no separate formatting step, you don't have to remember to + use the ``|format`` filter. +- Only named placeholders are allowed. This solves a common problem + translators face because positional placeholders can't switch + positions meaningfully. Named placeholders always carry semantic + information about what value goes where. +- String formatting is used even if no placeholders are used, which + makes all strings use a consistent format. Remember to escape any + raw percent signs as ``%%``, such as ``100%%``. +- The translated string is marked safe, formatting performs escaping + as needed. Mark a parameter as ``|safe`` if it has already been + escaped. + + +Expression Statement +-------------------- + +**Import name:** ``jinja2.ext.do`` + +The "do" aka expression-statement extension adds a simple ``do`` tag to the +template engine that works like a variable expression but ignores the +return value. + +.. _loopcontrols-extension: + +Loop Controls +------------- + +**Import name:** ``jinja2.ext.loopcontrols`` + +This extension adds support for ``break`` and ``continue`` in loops. After +enabling, Jinja provides those two keywords which work exactly like in +Python. + +.. _with-extension: + +With Statement +-------------- + +**Import name:** ``jinja2.ext.with_`` + +.. versionchanged:: 2.9 + + This extension is now built-in and no longer does anything. + +.. _autoescape-extension: + +Autoescape Extension +-------------------- + +**Import name:** ``jinja2.ext.autoescape`` + +.. versionchanged:: 2.9 + + This extension was removed and is now built-in. Enabling the + extension no longer does anything. + + +.. _debug-extension: + +Debug Extension +--------------- + +**Import name:** ``jinja2.ext.debug`` + +Adds a ``{% debug %}`` tag to dump the current context as well as the +available filters and tests. This is useful to see what's available to +use in the template without setting up a debugger. + + +.. _writing-extensions: + +Writing Extensions +------------------ + +.. module:: jinja2.ext + +By writing extensions you can add custom tags to Jinja. This is a non-trivial +task and usually not needed as the default tags and expressions cover all +common use cases. The i18n extension is a good example of why extensions are +useful. Another one would be fragment caching. + +When writing extensions you have to keep in mind that you are working with the +Jinja template compiler which does not validate the node tree you are passing +to it. If the AST is malformed you will get all kinds of compiler or runtime +errors that are horrible to debug. Always make sure you are using the nodes +you create correctly. The API documentation below shows which nodes exist and +how to use them. + + +Example Extensions +------------------ + +Cache +~~~~~ + +The following example implements a ``cache`` tag for Jinja by using the +`cachelib`_ library: + +.. literalinclude:: examples/cache_extension.py + :language: python + +And here is how you use it in an environment:: + + from jinja2 import Environment + from cachelib import SimpleCache + + env = Environment(extensions=[FragmentCacheExtension]) + env.fragment_cache = SimpleCache() + +Inside the template it's then possible to mark blocks as cacheable. The +following example caches a sidebar for 300 seconds: + +.. sourcecode:: html+jinja + + {% cache 'sidebar', 300 %} + <div class="sidebar"> + ... + </div> + {% endcache %} + +.. _cachelib: https://github.com/pallets/cachelib + + +Inline ``gettext`` +~~~~~~~~~~~~~~~~~~ + +The following example demonstrates using :meth:`Extension.filter_stream` +to parse calls to the ``_()`` gettext function inline with static data +without needing Jinja blocks. + +.. code-block:: html + + <h1>_(Welcome)</h1> + <p>_(This is a paragraph)</p> + +It requires the i18n extension to be loaded and configured. + +.. literalinclude:: examples/inline_gettext_extension.py + :language: python + + +Extension API +------------- + +Extension +~~~~~~~~~ + +Extensions always have to extend the :class:`jinja2.ext.Extension` class: + +.. autoclass:: Extension + :members: preprocess, filter_stream, parse, attr, call_method + + .. attribute:: identifier + + The identifier of the extension. This is always the true import name + of the extension class and must not be changed. + + .. attribute:: tags + + If the extension implements custom tags this is a set of tag names + the extension is listening for. + + +Parser +~~~~~~ + +The parser passed to :meth:`Extension.parse` provides ways to parse +expressions of different types. The following methods may be used by +extensions: + +.. autoclass:: jinja2.parser.Parser + :members: parse_expression, parse_tuple, parse_assign_target, + parse_statements, free_identifier, fail + + .. attribute:: filename + + The filename of the template the parser processes. This is **not** + the load name of the template. For the load name see :attr:`name`. + For templates that were not loaded form the file system this is + ``None``. + + .. attribute:: name + + The load name of the template. + + .. attribute:: stream + + The current :class:`~jinja2.lexer.TokenStream` + +.. autoclass:: jinja2.lexer.TokenStream + :members: push, look, eos, skip, __next__, next_if, skip_if, expect + + .. attribute:: current + + The current :class:`~jinja2.lexer.Token`. + +.. autoclass:: jinja2.lexer.Token + :members: test, test_any + + .. attribute:: lineno + + The line number of the token + + .. attribute:: type + + The type of the token. This string is interned so you may compare + it with arbitrary strings using the ``is`` operator. + + .. attribute:: value + + The value of the token. + +There is also a utility function in the lexer module that can count newline +characters in strings: + +.. autofunction:: jinja2.lexer.count_newlines + + +AST +~~~ + +The AST (Abstract Syntax Tree) is used to represent a template after parsing. +It's build of nodes that the compiler then converts into executable Python +code objects. Extensions that provide custom statements can return nodes to +execute custom Python code. + +The list below describes all nodes that are currently available. The AST may +change between Jinja versions but will stay backwards compatible. + +For more information have a look at the repr of :meth:`jinja2.Environment.parse`. + +.. module:: jinja2.nodes + +.. jinja:nodes:: jinja2.nodes.Node + +.. autoexception:: Impossible diff --git a/docs/faq.rst b/docs/faq.rst new file mode 100644 index 0000000..493dc38 --- /dev/null +++ b/docs/faq.rst @@ -0,0 +1,75 @@ +Frequently Asked Questions +========================== + + +Why is it called Jinja? +----------------------- + +"Jinja" is a Japanese `Shinto shrine`_, or temple, and temple and +template share a similar English pronunciation. It is not named after +the `city in Uganda`_. + +.. _Shinto shrine: https://en.wikipedia.org/wiki/Shinto_shrine +.. _city in Uganda: https://en.wikipedia.org/wiki/Jinja%2C_Uganda + + +How fast is Jinja? +------------------ + +Jinja is relatively fast among template engines because it compiles and +caches template code to Python code, so that the template does not need +to be parsed and interpreted each time. Rendering a template becomes as +close to executing a Python function as possible. + +Jinja also makes extensive use of caching. Templates are cached by name +after loading, so future uses of the template avoid loading. The +template loading itself uses a bytecode cache to avoid repeated +compiling. The caches can be external to persist across restarts. +Templates can also be precompiled and loaded as fast Python imports. + +We dislike benchmarks because they don't reflect real use. Performance +depends on many factors. Different engines have different default +configurations and tradeoffs that make it unclear how to set up a useful +comparison. Often, database access, API calls, and data processing have +a much larger effect on performance than the template engine. + + +Isn't it a bad idea to put logic in templates? +---------------------------------------------- + +Without a doubt you should try to remove as much logic from templates as +possible. With less logic, the template is easier to understand, has +fewer potential side effects, and is faster to compile and render. But a +template without any logic means processing must be done in code before +rendering. A template engine that does that is shipped with Python, +called :class:`string.Template`, and while it's definitely fast it's not +convenient. + +Jinja's features such as blocks, statements, filters, and function calls +make it much easier to write expressive templates, with very few +restrictions. Jinja doesn't allow arbitrary Python code in templates, or +every feature available in the Python language. This keeps the engine +easier to maintain, and keeps templates more readable. + +Some amount of logic is required in templates to keep everyone happy. +Too much logic in the template can make it complex to reason about and +maintain. It's up to you to decide how your application will work and +balance how much logic you want to put in the template. + + +Why is HTML escaping not the default? +------------------------------------- + +Jinja provides a feature that can be enabled to escape HTML syntax in +rendered templates. However, it is disabled by default. + +Jinja is a general purpose template engine, it is not only used for HTML +documents. You can generate plain text, LaTeX, emails, CSS, JavaScript, +configuration files, etc. HTML escaping wouldn't make sense for any of +these document types. + +While automatic escaping means that you are less likely have an XSS +problem, it also requires significant extra processing during compiling +and rendering, which can reduce performance. Jinja uses MarkupSafe for +escaping, which provides optimized C code for speed, but it still +introduces overhead to track escaping across methods and formatting. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..4ce2071 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,29 @@ +.. rst-class:: hide-header + +Jinja +===== + +.. image:: _static/jinja-logo.png + :align: center + :target: https://palletsprojects.com/p/jinja/ + +Jinja is a fast, expressive, extensible templating engine. Special +placeholders in the template allow writing code similar to Python +syntax. Then the template is passed data to render the final document. + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + intro + api + sandbox + nativetypes + templates + extensions + integration + switching + tricks + faq + license + changes diff --git a/docs/integration.rst b/docs/integration.rst new file mode 100644 index 0000000..b945fb3 --- /dev/null +++ b/docs/integration.rst @@ -0,0 +1,94 @@ +Integration +=========== + + +Flask +----- + +The `Flask`_ web application framework, also maintained by Pallets, uses +Jinja templates by default. Flask sets up a Jinja environment and +template loader for you, and provides functions to easily render +templates from view functions. + +.. _Flask: https://flask.palletsprojects.com + + +Django +------ + +Django supports using Jinja as its template engine, see +https://docs.djangoproject.com/en/stable/topics/templates/#support-for-template-engines. + + +.. _babel-integration: + +Babel +----- + +Jinja provides support for extracting gettext messages from templates +via a `Babel`_ extractor entry point called +``jinja2.ext.babel_extract``. The support is implemented as part of the +:ref:`i18n-extension` extension. + +Gettext messages are extracted from both ``trans`` tags and code +expressions. + +To extract gettext messages from templates, the project needs a Jinja +section in its Babel extraction method `mapping file`_: + +.. sourcecode:: ini + + [jinja2: **/templates/**.html] + encoding = utf-8 + +The syntax related options of the :class:`Environment` are also +available as configuration values in the mapping file. For example, to +tell the extractor that templates use ``%`` as +``line_statement_prefix`` you can use this code: + +.. sourcecode:: ini + + [jinja2: **/templates/**.html] + encoding = utf-8 + line_statement_prefix = % + +:ref:`jinja-extensions` may also be defined by passing a comma separated +list of import paths as the ``extensions`` value. The i18n extension is +added automatically. + +Template syntax errors are ignored by default. The assumption is that +tests will catch syntax errors in templates. If you don't want to ignore +errors, add ``silent = false`` to the settings. + +.. _Babel: https://babel.readthedocs.io/ +.. _mapping file: https://babel.readthedocs.io/en/latest/messages.html#extraction-method-mapping-and-configuration + + +Pylons +------ + +It's easy to integrate Jinja into a `Pylons`_ application. + +The template engine is configured in ``config/environment.py``. The +configuration for Jinja looks something like this: + +.. code-block:: python + + from jinja2 import Environment, PackageLoader + config['pylons.app_globals'].jinja_env = Environment( + loader=PackageLoader('yourapplication', 'templates') + ) + +After that you can render Jinja templates by using the ``render_jinja`` +function from the ``pylons.templating`` module. + +Additionally it's a good idea to set the Pylons ``c`` object to strict +mode. By default attribute access on missing attributes on the ``c`` +object returns an empty string and not an undefined object. To change +this add this to ``config/environment.py``: + +.. code-block:: python + + config['pylons.strict_c'] = True + +.. _Pylons: https://pylonshq.com/ diff --git a/docs/intro.rst b/docs/intro.rst new file mode 100644 index 0000000..fd6f84f --- /dev/null +++ b/docs/intro.rst @@ -0,0 +1,63 @@ +Introduction +============ + +Jinja is a fast, expressive, extensible templating engine. Special +placeholders in the template allow writing code similar to Python +syntax. Then the template is passed data to render the final document. + +It includes: + +- Template inheritance and inclusion. +- Define and import macros within templates. +- HTML templates can use autoescaping to prevent XSS from untrusted + user input. +- A sandboxed environment can safely render untrusted templates. +- Async support for generating templates that automatically handle + sync and async functions without extra syntax. +- I18N support with Babel. +- Templates are compiled to optimized Python code just-in-time and + cached, or can be compiled ahead-of-time. +- Exceptions point to the correct line in templates to make debugging + easier. +- Extensible filters, tests, functions, and even syntax. + +Jinja's philosophy is that while application logic belongs in Python if +possible, it shouldn't make the template designer's job difficult by +restricting functionality too much. + + +Installation +------------ + +We recommend using the latest version of Python. Jinja supports Python +3.7 and newer. We also recommend using a `virtual environment`_ in order +to isolate your project dependencies from other projects and the system. + +.. _virtual environment: https://packaging.python.org/tutorials/installing-packages/#creating-virtual-environments + +Install the most recent Jinja version using pip: + +.. code-block:: text + + $ pip install Jinja2 + + +Dependencies +~~~~~~~~~~~~ + +These will be installed automatically when installing Jinja. + +- `MarkupSafe`_ escapes untrusted input when rendering templates to + avoid injection attacks. + +.. _MarkupSafe: https://markupsafe.palletsprojects.com/ + + +Optional Dependencies +~~~~~~~~~~~~~~~~~~~~~ + +These distributions will not be installed automatically. + +- `Babel`_ provides translation support in templates. + +.. _Babel: https://babel.pocoo.org/ diff --git a/docs/license.rst b/docs/license.rst new file mode 100644 index 0000000..a53a98c --- /dev/null +++ b/docs/license.rst @@ -0,0 +1,4 @@ +BSD-3-Clause License +==================== + +.. include:: ../LICENSE.rst diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..b162255 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/docs/nativetypes.rst b/docs/nativetypes.rst new file mode 100644 index 0000000..1a08700 --- /dev/null +++ b/docs/nativetypes.rst @@ -0,0 +1,64 @@ +.. module:: jinja2.nativetypes + +.. _nativetypes: + +Native Python Types +=================== + +The default :class:`~jinja2.Environment` renders templates to strings. With +:class:`NativeEnvironment`, rendering a template produces a native Python type. +This is useful if you are using Jinja outside the context of creating text +files. For example, your code may have an intermediate step where users may use +templates to define values that will then be passed to a traditional string +environment. + +Examples +-------- + +Adding two values results in an integer, not a string with a number: + +>>> env = NativeEnvironment() +>>> t = env.from_string('{{ x + y }}') +>>> result = t.render(x=4, y=2) +>>> print(result) +6 +>>> print(type(result)) +int + +Rendering list syntax produces a list: + +>>> t = env.from_string('[{% for item in data %}{{ item + 1 }},{% endfor %}]') +>>> result = t.render(data=range(5)) +>>> print(result) +[1, 2, 3, 4, 5] +>>> print(type(result)) +list + +Rendering something that doesn't look like a Python literal produces a string: + +>>> t = env.from_string('{{ x }} * {{ y }}') +>>> result = t.render(x=4, y=2) +>>> print(result) +4 * 2 +>>> print(type(result)) +str + +Rendering a Python object produces that object as long as it is the only node: + +>>> class Foo: +... def __init__(self, value): +... self.value = value +... +>>> result = env.from_string('{{ x }}').render(x=Foo(15)) +>>> print(type(result).__name__) +Foo +>>> print(result.value) +15 + +API +--- + +.. autoclass:: NativeEnvironment([options]) + +.. autoclass:: NativeTemplate([options]) + :members: render diff --git a/docs/sandbox.rst b/docs/sandbox.rst new file mode 100644 index 0000000..fc9c31f --- /dev/null +++ b/docs/sandbox.rst @@ -0,0 +1,111 @@ +Sandbox +======= + +The Jinja sandbox can be used to render untrusted templates. Access to +attributes, method calls, operators, mutating data structures, and +string formatting can be intercepted and prohibited. + +.. code-block:: pycon + + >>> from jinja2.sandbox import SandboxedEnvironment + >>> env = SandboxedEnvironment() + >>> func = lambda: "Hello, Sandbox!" + >>> env.from_string("{{ func() }}").render(func=func) + 'Hello, Sandbox!' + >>> env.from_string("{{ func.__code__.co_code }}").render(func=func) + Traceback (most recent call last): + ... + SecurityError: access to attribute '__code__' of 'function' object is unsafe. + +A sandboxed environment can be useful, for example, to allow users of an +internal reporting system to create custom emails. You would document +what data is available in the templates, then the user would write a +template using that information. Your code would generate the report +data and pass it to the user's sandboxed template to render. + + +Security Considerations +----------------------- + +The sandbox alone is not a solution for perfect security. Keep these +things in mind when using the sandbox. + +Templates can still raise errors when compiled or rendered. Your code +should attempt to catch errors instead of crashing. + +It is possible to construct a relatively small template that renders to +a very large amount of output, which could correspond to a high use of +CPU or memory. You should run your application with limits on resources +such as CPU and memory to mitigate this. + +Jinja only renders text, it does not understand, for example, JavaScript +code. Depending on how the rendered template will be used, you may need +to do other postprocessing to restrict the output. + +Pass only the data that is relevant to the template. Avoid passing +global data, or objects with methods that have side effects. By default +the sandbox prevents private and internal attribute access. You can +override :meth:`~SandboxedEnvironment.is_safe_attribute` to further +restrict attributes access. Decorate methods with :func:`unsafe` to +prevent calling them from templates when passing objects as data. Use +:class:`ImmutableSandboxedEnvironment` to prevent modifying lists and +dictionaries. + + +API +--- + +.. module:: jinja2.sandbox + +.. autoclass:: SandboxedEnvironment([options]) + :members: is_safe_attribute, is_safe_callable, default_binop_table, + default_unop_table, intercepted_binops, intercepted_unops, + call_binop, call_unop + +.. autoclass:: ImmutableSandboxedEnvironment([options]) + +.. autoexception:: SecurityError + +.. autofunction:: unsafe + +.. autofunction:: is_internal_attribute + +.. autofunction:: modifies_known_mutable + + +Operator Intercepting +--------------------- + +For performance, Jinja outputs operators directly when compiling. This +means it's not possible to intercept operator behavior by overriding +:meth:`SandboxEnvironment.call <Environment.call>` by default, because +operator special methods are handled by the Python interpreter, and +might not correspond with exactly one method depending on the operator's +use. + +The sandbox can instruct the compiler to output a function to intercept +certain operators instead. Override +:attr:`SandboxedEnvironment.intercepted_binops` and +:attr:`SandboxedEnvironment.intercepted_unops` with the operator symbols +you want to intercept. The compiler will replace the symbols with calls +to :meth:`SandboxedEnvironment.call_binop` and +:meth:`SandboxedEnvironment.call_unop` instead. The default +implementation of those methods will use +:attr:`SandboxedEnvironment.binop_table` and +:attr:`SandboxedEnvironment.unop_table` to translate operator symbols +into :mod:`operator` functions. + +For example, the power (``**``) operator can be disabled: + +.. code-block:: python + + from jinja2.sandbox import SandboxedEnvironment + + class MyEnvironment(SandboxedEnvironment): + intercepted_binops = frozenset(["**"]) + + def call_binop(self, context, operator, left, right): + if operator == "**": + return self.undefined("The power (**) operator is unavailable.") + + return super().call_binop(self, context, operator, left, right) diff --git a/docs/switching.rst b/docs/switching.rst new file mode 100644 index 0000000..caa35c3 --- /dev/null +++ b/docs/switching.rst @@ -0,0 +1,181 @@ +Switching From Other Template Engines +===================================== + +This is a brief guide on some of the differences between Jinja syntax +and other template languages. See :doc:`/templates` for a comprehensive +guide to Jinja syntax and features. + + +Django +------ + +If you have previously worked with Django templates, you should find +Jinja very familiar. Many of the syntax elements look and work the same. +However, Jinja provides some more syntax elements, and some work a bit +differently. + +This section covers the template changes. The API, including extension +support, is fundamentally different so it won't be covered here. + +Django supports using Jinja as its template engine, see +https://docs.djangoproject.com/en/stable/topics/templates/#support-for-template-engines. + + +Method Calls +~~~~~~~~~~~~ + +In Django, methods are called implicitly, without parentheses. + +.. code-block:: django + + {% for page in user.get_created_pages %} + ... + {% endfor %} + +In Jinja, using parentheses is required for calls, like in Python. This +allows you to pass variables to the method, which is not possible +in Django. This syntax is also used for calling macros. + +.. code-block:: jinja + + {% for page in user.get_created_pages() %} + ... + {% endfor %} + + +Filter Arguments +~~~~~~~~~~~~~~~~ + +In Django, one literal value can be passed to a filter after a colon. + +.. code-block:: django + + {{ items|join:", " }} + +In Jinja, filters can take any number of positional and keyword +arguments in parentheses, like function calls. Arguments can also be +variables instead of literal values. + +.. code-block:: jinja + + {{ items|join(", ") }} + + +Tests +~~~~~ + +In addition to filters, Jinja also has "tests" used with the ``is`` +operator. This operator is not the same as the Python operator. + +.. code-block:: jinja + + {% if user.user_id is odd %} + {{ user.username|e }} is odd + {% else %} + hmm. {{ user.username|e }} looks pretty normal + {% endif %} + +Loops +~~~~~ + +In Django, the special variable for the loop context is called +``forloop``, and the ``empty`` is used for no loop items. + +.. code-block:: django + + {% for item in items %} + {{ item }} + {% empty %} + No items! + {% endfor %} + +In Jinja, the special variable for the loop context is called ``loop``, +and the ``else`` block is used for no loop items. + +.. code-block:: jinja + + {% for item in items %} + {{ loop.index}}. {{ item }} + {% else %} + No items! + {% endfor %} + + +Cycle +~~~~~ + +In Django, the ``{% cycle %}`` can be used in a for loop to alternate +between values per loop. + +.. code-block:: django + + {% for user in users %} + <li class="{% cycle 'odd' 'even' %}">{{ user }}</li> + {% endfor %} + +In Jinja, the ``loop`` context has a ``cycle`` method. + +.. code-block:: jinja + + {% for user in users %} + <li class="{{ loop.cycle('odd', 'even') }}">{{ user }}</li> + {% endfor %} + +A cycler can also be assigned to a variable and used outside or across +loops with the ``cycle()`` global function. + + +Mako +---- + +You can configure Jinja to look more like Mako: + +.. code-block:: python + + env = Environment( + block_start_string="<%", + block_end_string="%>", + variable_start_string="${", + variable_end_string="}", + comment_start_string="<%doc>", + commend_end_string="</%doc>", + line_statement_prefix="%", + line_comment_prefix="##", + ) + +With an environment configured like that, Jinja should be able to +interpret a small subset of Mako templates without any changes. + +Jinja does not support embedded Python code, so you would have to move +that out of the template. You could either process the data with the +same code before rendering, or add a global function or filter to the +Jinja environment. + +The syntax for defs (which are called macros in Jinja) and template +inheritance is different too. + +The following Mako template: + +.. code-block:: mako + + <%inherit file="layout.html" /> + <%def name="title()">Page Title</%def> + <ul> + % for item in list: + <li>${item}</li> + % endfor + </ul> + +Looks like this in Jinja with the above configuration: + +.. code-block:: jinja + + <% extends "layout.html" %> + <% block title %>Page Title<% endblock %> + <% block body %> + <ul> + % for item in list: + <li>${item}</li> + % endfor + </ul> + <% endblock %> diff --git a/docs/templates.rst b/docs/templates.rst new file mode 100644 index 0000000..7a64750 --- /dev/null +++ b/docs/templates.rst @@ -0,0 +1,1949 @@ +.. py:currentmodule:: jinja2 +.. highlight:: html+jinja + +Template Designer Documentation +=============================== + +This document describes the syntax and semantics of the template engine and +will be most useful as reference to those creating Jinja templates. As the +template engine is very flexible, the configuration from the application can +be slightly different from the code presented here in terms of delimiters and +behavior of undefined values. + + +Synopsis +-------- + +A Jinja template is simply a text file. Jinja can generate any text-based +format (HTML, XML, CSV, LaTeX, etc.). A Jinja template doesn't need to have a +specific extension: ``.html``, ``.xml``, or any other extension is just fine. + +A template contains **variables** and/or **expressions**, which get replaced +with values when a template is *rendered*; and **tags**, which control the +logic of the template. The template syntax is heavily inspired by Django and +Python. + +Below is a minimal template that illustrates a few basics using the default +Jinja configuration. We will cover the details later in this document:: + + <!DOCTYPE html> + <html lang="en"> + <head> + <title>My Webpage</title> + </head> + <body> + <ul id="navigation"> + {% for item in navigation %} + <li><a href="{{ item.href }}">{{ item.caption }}</a></li> + {% endfor %} + </ul> + + <h1>My Webpage</h1> + {{ a_variable }} + + {# a comment #} + </body> + </html> + +The following example shows the default configuration settings. An application +developer can change the syntax configuration from ``{% foo %}`` to ``<% foo +%>``, or something similar. + +There are a few kinds of delimiters. The default Jinja delimiters are +configured as follows: + +* ``{% ... %}`` for :ref:`Statements <list-of-control-structures>` +* ``{{ ... }}`` for :ref:`Expressions` to print to the template output +* ``{# ... #}`` for :ref:`Comments` not included in the template output + +:ref:`Line Statements and Comments <line-statements>` are also possible, +though they don't have default prefix characters. To use them, set +``line_statement_prefix`` and ``line_comment_prefix`` when creating the +:class:`~jinja2.Environment`. + + +Template File Extension +~~~~~~~~~~~~~~~~~~~~~~~ + +As stated above, any file can be loaded as a template, regardless of +file extension. Adding a ``.jinja`` extension, like ``user.html.jinja`` +may make it easier for some IDEs or editor plugins, but is not required. +Autoescaping, introduced later, can be applied based on file extension, +so you'll need to take the extra suffix into account in that case. + +Another good heuristic for identifying templates is that they are in a +``templates`` folder, regardless of extension. This is a common layout +for projects. + + +.. _variables: + +Variables +--------- + +Template variables are defined by the context dictionary passed to the +template. + +You can mess around with the variables in templates provided they are passed in +by the application. Variables may have attributes or elements on them you can +access too. What attributes a variable has depends heavily on the application +providing that variable. + +You can use a dot (``.``) to access attributes of a variable in addition +to the standard Python ``__getitem__`` "subscript" syntax (``[]``). + +The following lines do the same thing:: + + {{ foo.bar }} + {{ foo['bar'] }} + +It's important to know that the outer double-curly braces are *not* part of the +variable, but the print statement. If you access variables inside tags don't +put the braces around them. + +If a variable or attribute does not exist, you will get back an undefined +value. What you can do with that kind of value depends on the application +configuration: the default behavior is to evaluate to an empty string if +printed or iterated over, and to fail for every other operation. + +.. _notes-on-subscriptions: + +.. admonition:: Implementation + + For the sake of convenience, ``foo.bar`` in Jinja does the following + things on the Python layer: + + - check for an attribute called `bar` on `foo` + (``getattr(foo, 'bar')``) + - if there is not, check for an item ``'bar'`` in `foo` + (``foo.__getitem__('bar')``) + - if there is not, return an undefined object. + + ``foo['bar']`` works mostly the same with a small difference in sequence: + + - check for an item ``'bar'`` in `foo`. + (``foo.__getitem__('bar')``) + - if there is not, check for an attribute called `bar` on `foo`. + (``getattr(foo, 'bar')``) + - if there is not, return an undefined object. + + This is important if an object has an item and attribute with the same + name. Additionally, the :func:`attr` filter only looks up attributes. + +.. _filters: + +Filters +------- + +Variables can be modified by **filters**. Filters are separated from the +variable by a pipe symbol (``|``) and may have optional arguments in +parentheses. Multiple filters can be chained. The output of one filter is +applied to the next. + +For example, ``{{ name|striptags|title }}`` will remove all HTML Tags from +variable `name` and title-case the output (``title(striptags(name))``). + +Filters that accept arguments have parentheses around the arguments, just like +a function call. For example: ``{{ listx|join(', ') }}`` will join a list with +commas (``str.join(', ', listx)``). + +The :ref:`builtin-filters` below describes all the builtin filters. + +.. _tests: + +Tests +----- + +Beside filters, there are also so-called "tests" available. Tests can be used +to test a variable against a common expression. To test a variable or +expression, you add `is` plus the name of the test after the variable. For +example, to find out if a variable is defined, you can do ``name is defined``, +which will then return true or false depending on whether `name` is defined +in the current template context. + +Tests can accept arguments, too. If the test only takes one argument, you can +leave out the parentheses. For example, the following two +expressions do the same thing:: + + {% if loop.index is divisibleby 3 %} + {% if loop.index is divisibleby(3) %} + +The :ref:`builtin-tests` below describes all the builtin tests. + + +.. _comments: + +Comments +-------- + +To comment-out part of a line in a template, use the comment syntax which is +by default set to ``{# ... #}``. This is useful to comment out parts of the +template for debugging or to add information for other template designers or +yourself:: + + {# note: commented-out template because we no longer use this + {% for user in users %} + ... + {% endfor %} + #} + + +Whitespace Control +------------------ + +In the default configuration: + +* a single trailing newline is stripped if present +* other whitespace (spaces, tabs, newlines etc.) is returned unchanged + +If an application configures Jinja to `trim_blocks`, the first newline after a +template tag is removed automatically (like in PHP). The `lstrip_blocks` +option can also be set to strip tabs and spaces from the beginning of a +line to the start of a block. (Nothing will be stripped if there are +other characters before the start of the block.) + +With both `trim_blocks` and `lstrip_blocks` enabled, you can put block tags +on their own lines, and the entire block line will be removed when +rendered, preserving the whitespace of the contents. For example, +without the `trim_blocks` and `lstrip_blocks` options, this template:: + + <div> + {% if True %} + yay + {% endif %} + </div> + +gets rendered with blank lines inside the div:: + + <div> + + yay + + </div> + +But with both `trim_blocks` and `lstrip_blocks` enabled, the template block +lines are removed and other whitespace is preserved:: + + <div> + yay + </div> + +You can manually disable the `lstrip_blocks` behavior by putting a +plus sign (``+``) at the start of a block:: + + <div> + {%+ if something %}yay{% endif %} + </div> + +Similarly, you can manually disable the ``trim_blocks`` behavior by +putting a plus sign (``+``) at the end of a block:: + + <div> + {% if something +%} + yay + {% endif %} + </div> + +You can also strip whitespace in templates by hand. If you add a minus +sign (``-``) to the start or end of a block (e.g. a :ref:`for-loop` tag), a +comment, or a variable expression, the whitespaces before or after +that block will be removed:: + + {% for item in seq -%} + {{ item }} + {%- endfor %} + +This will yield all elements without whitespace between them. If `seq` was +a list of numbers from ``1`` to ``9``, the output would be ``123456789``. + +If :ref:`line-statements` are enabled, they strip leading whitespace +automatically up to the beginning of the line. + +By default, Jinja also removes trailing newlines. To keep single +trailing newlines, configure Jinja to `keep_trailing_newline`. + +.. admonition:: Note + + You must not add whitespace between the tag and the minus sign. + + **valid**:: + + {%- if foo -%}...{% endif %} + + **invalid**:: + + {% - if foo - %}...{% endif %} + + +Escaping +-------- + +It is sometimes desirable -- even necessary -- to have Jinja ignore parts +it would otherwise handle as variables or blocks. For example, if, with +the default syntax, you want to use ``{{`` as a raw string in a template and +not start a variable, you have to use a trick. + +The easiest way to output a literal variable delimiter (``{{``) is by using a +variable expression:: + + {{ '{{' }} + +For bigger sections, it makes sense to mark a block `raw`. For example, to +include example Jinja syntax in a template, you can use this snippet:: + + {% raw %} + <ul> + {% for item in seq %} + <li>{{ item }}</li> + {% endfor %} + </ul> + {% endraw %} + +.. admonition:: Note + + Minus sign at the end of ``{% raw -%}`` tag cleans all the spaces and newlines + preceding the first character of your raw data. + + +.. _line-statements: + +Line Statements +--------------- + +If line statements are enabled by the application, it's possible to mark a +line as a statement. For example, if the line statement prefix is configured +to ``#``, the following two examples are equivalent:: + + <ul> + # for item in seq + <li>{{ item }}</li> + # endfor + </ul> + + <ul> + {% for item in seq %} + <li>{{ item }}</li> + {% endfor %} + </ul> + +The line statement prefix can appear anywhere on the line as long as no text +precedes it. For better readability, statements that start a block (such as +`for`, `if`, `elif` etc.) may end with a colon:: + + # for item in seq: + ... + # endfor + + +.. admonition:: Note + + Line statements can span multiple lines if there are open parentheses, + braces or brackets:: + + <ul> + # for href, caption in [('index.html', 'Index'), + ('about.html', 'About')]: + <li><a href="{{ href }}">{{ caption }}</a></li> + # endfor + </ul> + +Since Jinja 2.2, line-based comments are available as well. For example, if +the line-comment prefix is configured to be ``##``, everything from ``##`` to +the end of the line is ignored (excluding the newline sign):: + + # for item in seq: + <li>{{ item }}</li> ## this comment is ignored + # endfor + + +.. _template-inheritance: + +Template Inheritance +-------------------- + +The most powerful part of Jinja is template inheritance. Template inheritance +allows you to build a base "skeleton" template that contains all the common +elements of your site and defines **blocks** that child templates can override. + +Sounds complicated but is very basic. It's easiest to understand it by starting +with an example. + + +Base Template +~~~~~~~~~~~~~ + +This template, which we'll call ``base.html``, defines a simple HTML skeleton +document that you might use for a simple two-column page. It's the job of +"child" templates to fill the empty blocks with content:: + + <!DOCTYPE html> + <html lang="en"> + <head> + {% block head %} + <link rel="stylesheet" href="style.css" /> + <title>{% block title %}{% endblock %} - My Webpage</title> + {% endblock %} + </head> + <body> + <div id="content">{% block content %}{% endblock %}</div> + <div id="footer"> + {% block footer %} + © Copyright 2008 by <a href="http://domain.invalid/">you</a>. + {% endblock %} + </div> + </body> + </html> + +In this example, the ``{% block %}`` tags define four blocks that child templates +can fill in. All the `block` tag does is tell the template engine that a +child template may override those placeholders in the template. + +``block`` tags can be inside other blocks such as ``if``, but they will +always be executed regardless of if the ``if`` block is actually +rendered. + +Child Template +~~~~~~~~~~~~~~ + +A child template might look like this:: + + {% extends "base.html" %} + {% block title %}Index{% endblock %} + {% block head %} + {{ super() }} + <style type="text/css"> + .important { color: #336699; } + </style> + {% endblock %} + {% block content %} + <h1>Index</h1> + <p class="important"> + Welcome to my awesome homepage. + </p> + {% endblock %} + +The ``{% extends %}`` tag is the key here. It tells the template engine that +this template "extends" another template. When the template system evaluates +this template, it first locates the parent. The extends tag should be the +first tag in the template. Everything before it is printed out normally and +may cause confusion. For details about this behavior and how to take +advantage of it, see :ref:`null-default-fallback`. Also a block will always be +filled in regardless of whether the surrounding condition is evaluated to be true +or false. + +The filename of the template depends on the template loader. For example, the +:class:`FileSystemLoader` allows you to access other templates by giving the +filename. You can access templates in subdirectories with a slash:: + + {% extends "layout/default.html" %} + +But this behavior can depend on the application embedding Jinja. Note that +since the child template doesn't define the ``footer`` block, the value from +the parent template is used instead. + +You can't define multiple ``{% block %}`` tags with the same name in the +same template. This limitation exists because a block tag works in "both" +directions. That is, a block tag doesn't just provide a placeholder to fill +- it also defines the content that fills the placeholder in the *parent*. +If there were two similarly-named ``{% block %}`` tags in a template, +that template's parent wouldn't know which one of the blocks' content to use. + +If you want to print a block multiple times, you can, however, use the special +`self` variable and call the block with that name:: + + <title>{% block title %}{% endblock %}</title> + <h1>{{ self.title() }}</h1> + {% block body %}{% endblock %} + + +Super Blocks +~~~~~~~~~~~~ + +It's possible to render the contents of the parent block by calling ``super()``. +This gives back the results of the parent block:: + + {% block sidebar %} + <h3>Table Of Contents</h3> + ... + {{ super() }} + {% endblock %} + + +Nesting extends +~~~~~~~~~~~~~~~ + +In the case of multiple levels of ``{% extends %}``, +``super`` references may be chained (as in ``super.super()``) +to skip levels in the inheritance tree. + +For example:: + + # parent.tmpl + body: {% block body %}Hi from parent.{% endblock %} + + # child.tmpl + {% extends "parent.tmpl" %} + {% block body %}Hi from child. {{ super() }}{% endblock %} + + # grandchild1.tmpl + {% extends "child.tmpl" %} + {% block body %}Hi from grandchild1.{% endblock %} + + # grandchild2.tmpl + {% extends "child.tmpl" %} + {% block body %}Hi from grandchild2. {{ super.super() }} {% endblock %} + + +Rendering ``child.tmpl`` will give +``body: Hi from child. Hi from parent.`` + +Rendering ``grandchild1.tmpl`` will give +``body: Hi from grandchild1.`` + +Rendering ``grandchild2.tmpl`` will give +``body: Hi from grandchild2. Hi from parent.`` + + +Named Block End-Tags +~~~~~~~~~~~~~~~~~~~~ + +Jinja allows you to put the name of the block after the end tag for better +readability:: + + {% block sidebar %} + {% block inner_sidebar %} + ... + {% endblock inner_sidebar %} + {% endblock sidebar %} + +However, the name after the `endblock` word must match the block name. + + +Block Nesting and Scope +~~~~~~~~~~~~~~~~~~~~~~~ + +Blocks can be nested for more complex layouts. However, per default blocks +may not access variables from outer scopes:: + + {% for item in seq %} + <li>{% block loop_item %}{{ item }}{% endblock %}</li> + {% endfor %} + +This example would output empty ``<li>`` items because `item` is unavailable +inside the block. The reason for this is that if the block is replaced by +a child template, a variable would appear that was not defined in the block or +passed to the context. + +Starting with Jinja 2.2, you can explicitly specify that variables are +available in a block by setting the block to "scoped" by adding the `scoped` +modifier to a block declaration:: + + {% for item in seq %} + <li>{% block loop_item scoped %}{{ item }}{% endblock %}</li> + {% endfor %} + +When overriding a block, the `scoped` modifier does not have to be provided. + + +Required Blocks +~~~~~~~~~~~~~~~ + +Blocks can be marked as ``required``. They must be overridden at some +point, but not necessarily by the direct child template. Required blocks +may only contain space and comments, and they cannot be rendered +directly. + +.. code-block:: jinja + :caption: ``page.txt`` + + {% block body required %}{% endblock %} + +.. code-block:: jinja + :caption: ``issue.txt`` + + {% extends "page.txt" %} + +.. code-block:: jinja + :caption: ``bug_report.txt`` + + {% extends "issue.txt" %} + {% block body %}Provide steps to demonstrate the bug.{% endblock %} + +Rendering ``page.txt`` or ``issue.txt`` will raise +``TemplateRuntimeError`` because they don't override the ``body`` block. +Rendering ``bug_report.txt`` will succeed because it does override the +block. + +When combined with ``scoped``, the ``required`` modifier must be placed +*after* the scoped modifier. Here are some valid examples: + +.. code-block:: jinja + + {% block body scoped %}{% endblock %} + {% block body required %}{% endblock %} + {% block body scoped required %}{% endblock %} + + +Template Objects +~~~~~~~~~~~~~~~~ + +``extends``, ``include``, and ``import`` can take a template object +instead of the name of a template to load. This could be useful in some +advanced situations, since you can use Python code to load a template +first and pass it in to ``render``. + +.. code-block:: python + + if debug_mode: + layout = env.get_template("debug_layout.html") + else: + layout = env.get_template("layout.html") + + user_detail = env.get_template("user/detail.html", layout=layout) + +.. code-block:: jinja + + {% extends layout %} + +Note how ``extends`` is passed the variable with the template object +that was passed to ``render``, instead of a string. + + +HTML Escaping +------------- + +When generating HTML from templates, there's always a risk that a variable will +include characters that affect the resulting HTML. There are two approaches: + +a. manually escaping each variable; or +b. automatically escaping everything by default. + +Jinja supports both. What is used depends on the application configuration. +The default configuration is no automatic escaping; for various reasons: + +- Escaping everything except for safe values will also mean that Jinja is + escaping variables known to not include HTML (e.g. numbers, booleans) + which can be a huge performance hit. + +- The information about the safety of a variable is very fragile. It could + happen that by coercing safe and unsafe values, the return value is + double-escaped HTML. + +Working with Manual Escaping +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If manual escaping is enabled, it's **your** responsibility to escape +variables if needed. What to escape? If you have a variable that *may* +include any of the following chars (``>``, ``<``, ``&``, or ``"``) you +**SHOULD** escape it unless the variable contains well-formed and trusted +HTML. Escaping works by piping the variable through the ``|e`` filter:: + + {{ user.username|e }} + +Working with Automatic Escaping +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When automatic escaping is enabled, everything is escaped by default except +for values explicitly marked as safe. Variables and expressions +can be marked as safe either in: + +a. The context dictionary by the application with + :class:`markupsafe.Markup` +b. The template, with the ``|safe`` filter. + +If a string that you marked safe is passed through other Python code +that doesn't understand that mark, it may get lost. Be aware of when +your data is marked safe and how it is processed before arriving at the +template. + +If a value has been escaped but is not marked safe, auto-escaping will +still take place and result in double-escaped characters. If you know +you have data that is already safe but not marked, be sure to wrap it in +``Markup`` or use the ``|safe`` filter. + +Jinja functions (macros, `super`, `self.BLOCKNAME`) always return template +data that is marked as safe. + +String literals in templates with automatic escaping are considered +unsafe because native Python strings are not safe. + +.. _list-of-control-structures: + +List of Control Structures +-------------------------- + +A control structure refers to all those things that control the flow of a +program - conditionals (i.e. if/elif/else), for-loops, as well as things like +macros and blocks. With the default syntax, control structures appear inside +``{% ... %}`` blocks. + +.. _for-loop: + +For +~~~ + +Loop over each item in a sequence. For example, to display a list of users +provided in a variable called `users`:: + + <h1>Members</h1> + <ul> + {% for user in users %} + <li>{{ user.username|e }}</li> + {% endfor %} + </ul> + +As variables in templates retain their object properties, it is possible to +iterate over containers like `dict`:: + + <dl> + {% for key, value in my_dict.items() %} + <dt>{{ key|e }}</dt> + <dd>{{ value|e }}</dd> + {% endfor %} + </dl> + +Python dicts may not be in the order you want to display them in. If +order matters, use the ``|dictsort`` filter. + +.. code-block:: jinja + + <dl> + {% for key, value in my_dict | dictsort %} + <dt>{{ key|e }}</dt> + <dd>{{ value|e }}</dd> + {% endfor %} + </dl> + +Inside of a for-loop block, you can access some special variables: + ++-----------------------+---------------------------------------------------+ +| Variable | Description | ++=======================+===================================================+ +| `loop.index` | The current iteration of the loop. (1 indexed) | ++-----------------------+---------------------------------------------------+ +| `loop.index0` | The current iteration of the loop. (0 indexed) | ++-----------------------+---------------------------------------------------+ +| `loop.revindex` | The number of iterations from the end of the loop | +| | (1 indexed) | ++-----------------------+---------------------------------------------------+ +| `loop.revindex0` | The number of iterations from the end of the loop | +| | (0 indexed) | ++-----------------------+---------------------------------------------------+ +| `loop.first` | True if first iteration. | ++-----------------------+---------------------------------------------------+ +| `loop.last` | True if last iteration. | ++-----------------------+---------------------------------------------------+ +| `loop.length` | The number of items in the sequence. | ++-----------------------+---------------------------------------------------+ +| `loop.cycle` | A helper function to cycle between a list of | +| | sequences. See the explanation below. | ++-----------------------+---------------------------------------------------+ +| `loop.depth` | Indicates how deep in a recursive loop | +| | the rendering currently is. Starts at level 1 | ++-----------------------+---------------------------------------------------+ +| `loop.depth0` | Indicates how deep in a recursive loop | +| | the rendering currently is. Starts at level 0 | ++-----------------------+---------------------------------------------------+ +| `loop.previtem` | The item from the previous iteration of the loop. | +| | Undefined during the first iteration. | ++-----------------------+---------------------------------------------------+ +| `loop.nextitem` | The item from the following iteration of the loop.| +| | Undefined during the last iteration. | ++-----------------------+---------------------------------------------------+ +| `loop.changed(*val)` | True if previously called with a different value | +| | (or not called at all). | ++-----------------------+---------------------------------------------------+ + +Within a for-loop, it's possible to cycle among a list of strings/variables +each time through the loop by using the special `loop.cycle` helper:: + + {% for row in rows %} + <li class="{{ loop.cycle('odd', 'even') }}">{{ row }}</li> + {% endfor %} + +Since Jinja 2.1, an extra `cycle` helper exists that allows loop-unbound +cycling. For more information, have a look at the :ref:`builtin-globals`. + +.. _loop-filtering: + +Unlike in Python, it's not possible to `break` or `continue` in a loop. You +can, however, filter the sequence during iteration, which allows you to skip +items. The following example skips all the users which are hidden:: + + {% for user in users if not user.hidden %} + <li>{{ user.username|e }}</li> + {% endfor %} + +The advantage is that the special `loop` variable will count correctly; thus +not counting the users not iterated over. + +If no iteration took place because the sequence was empty or the filtering +removed all the items from the sequence, you can render a default block +by using `else`:: + + <ul> + {% for user in users %} + <li>{{ user.username|e }}</li> + {% else %} + <li><em>no users found</em></li> + {% endfor %} + </ul> + +Note that, in Python, `else` blocks are executed whenever the corresponding +loop **did not** `break`. Since Jinja loops cannot `break` anyway, +a slightly different behavior of the `else` keyword was chosen. + +It is also possible to use loops recursively. This is useful if you are +dealing with recursive data such as sitemaps or RDFa. +To use loops recursively, you basically have to add the `recursive` modifier +to the loop definition and call the `loop` variable with the new iterable +where you want to recurse. + +The following example implements a sitemap with recursive loops:: + + <ul class="sitemap"> + {%- for item in sitemap recursive %} + <li><a href="{{ item.href|e }}">{{ item.title }}</a> + {%- if item.children -%} + <ul class="submenu">{{ loop(item.children) }}</ul> + {%- endif %}</li> + {%- endfor %} + </ul> + +The `loop` variable always refers to the closest (innermost) loop. If we +have more than one level of loops, we can rebind the variable `loop` by +writing `{% set outer_loop = loop %}` after the loop that we want to +use recursively. Then, we can call it using `{{ outer_loop(...) }}` + +Please note that assignments in loops will be cleared at the end of the +iteration and cannot outlive the loop scope. Older versions of Jinja had +a bug where in some circumstances it appeared that assignments would work. +This is not supported. See :ref:`assignments` for more information about +how to deal with this. + +If all you want to do is check whether some value has changed since the +last iteration or will change in the next iteration, you can use `previtem` +and `nextitem`:: + + {% for value in values %} + {% if loop.previtem is defined and value > loop.previtem %} + The value just increased! + {% endif %} + {{ value }} + {% if loop.nextitem is defined and loop.nextitem > value %} + The value will increase even more! + {% endif %} + {% endfor %} + +If you only care whether the value changed at all, using `changed` is even +easier:: + + {% for entry in entries %} + {% if loop.changed(entry.category) %} + <h2>{{ entry.category }}</h2> + {% endif %} + <p>{{ entry.message }}</p> + {% endfor %} + +.. _if: + +If +~~ + +The `if` statement in Jinja is comparable with the Python if statement. +In the simplest form, you can use it to test if a variable is defined, not +empty and not false:: + + {% if users %} + <ul> + {% for user in users %} + <li>{{ user.username|e }}</li> + {% endfor %} + </ul> + {% endif %} + +For multiple branches, `elif` and `else` can be used like in Python. You can +use more complex :ref:`expressions` there, too:: + + {% if kenny.sick %} + Kenny is sick. + {% elif kenny.dead %} + You killed Kenny! You bastard!!! + {% else %} + Kenny looks okay --- so far + {% endif %} + +If can also be used as an :ref:`inline expression <if-expression>` and for +:ref:`loop filtering <loop-filtering>`. + +.. _macros: + +Macros +~~~~~~ + +Macros are comparable with functions in regular programming languages. They +are useful to put often used idioms into reusable functions to not repeat +yourself ("DRY"). + +Here's a small example of a macro that renders a form element:: + + {% macro input(name, value='', type='text', size=20) -%} + <input type="{{ type }}" name="{{ name }}" value="{{ + value|e }}" size="{{ size }}"> + {%- endmacro %} + +The macro can then be called like a function in the namespace:: + + <p>{{ input('username') }}</p> + <p>{{ input('password', type='password') }}</p> + +If the macro was defined in a different template, you have to +:ref:`import <import>` it first. + +Inside macros, you have access to three special variables: + +`varargs` + If more positional arguments are passed to the macro than accepted by the + macro, they end up in the special `varargs` variable as a list of values. + +`kwargs` + Like `varargs` but for keyword arguments. All unconsumed keyword + arguments are stored in this special variable. + +`caller` + If the macro was called from a :ref:`call<call>` tag, the caller is stored + in this variable as a callable macro. + +Macros also expose some of their internal details. The following attributes +are available on a macro object: + +`name` + The name of the macro. ``{{ input.name }}`` will print ``input``. + +`arguments` + A tuple of the names of arguments the macro accepts. + +`catch_kwargs` + This is `true` if the macro accepts extra keyword arguments (i.e.: accesses + the special `kwargs` variable). + +`catch_varargs` + This is `true` if the macro accepts extra positional arguments (i.e.: + accesses the special `varargs` variable). + +`caller` + This is `true` if the macro accesses the special `caller` variable and may + be called from a :ref:`call<call>` tag. + +If a macro name starts with an underscore, it's not exported and can't +be imported. + +Due to how scopes work in Jinja, a macro in a child template does not +override a macro in a parent template. The following will output +"LAYOUT", not "CHILD". + +.. code-block:: jinja + :caption: ``layout.txt`` + + {% macro foo() %}LAYOUT{% endmacro %} + {% block body %}{% endblock %} + +.. code-block:: jinja + :caption: ``child.txt`` + + {% extends 'layout.txt' %} + {% macro foo() %}CHILD{% endmacro %} + {% block body %}{{ foo() }}{% endblock %} + + +.. _call: + +Call +~~~~ + +In some cases it can be useful to pass a macro to another macro. For this +purpose, you can use the special `call` block. The following example shows +a macro that takes advantage of the call functionality and how it can be +used:: + + {% macro render_dialog(title, class='dialog') -%} + <div class="{{ class }}"> + <h2>{{ title }}</h2> + <div class="contents"> + {{ caller() }} + </div> + </div> + {%- endmacro %} + + {% call render_dialog('Hello World') %} + This is a simple dialog rendered by using a macro and + a call block. + {% endcall %} + +It's also possible to pass arguments back to the call block. This makes it +useful as a replacement for loops. Generally speaking, a call block works +exactly like a macro without a name. + +Here's an example of how a call block can be used with arguments:: + + {% macro dump_users(users) -%} + <ul> + {%- for user in users %} + <li><p>{{ user.username|e }}</p>{{ caller(user) }}</li> + {%- endfor %} + </ul> + {%- endmacro %} + + {% call(user) dump_users(list_of_user) %} + <dl> + <dt>Realname</dt> + <dd>{{ user.realname|e }}</dd> + <dt>Description</dt> + <dd>{{ user.description }}</dd> + </dl> + {% endcall %} + + +Filters +~~~~~~~ + +Filter sections allow you to apply regular Jinja filters on a block of +template data. Just wrap the code in the special `filter` section:: + + {% filter upper %} + This text becomes uppercase + {% endfilter %} + + +.. _assignments: + +Assignments +~~~~~~~~~~~ + +Inside code blocks, you can also assign values to variables. Assignments at +top level (outside of blocks, macros or loops) are exported from the template +like top level macros and can be imported by other templates. + +Assignments use the `set` tag and can have multiple targets:: + + {% set navigation = [('index.html', 'Index'), ('about.html', 'About')] %} + {% set key, value = call_something() %} + +.. admonition:: Scoping Behavior + + Please keep in mind that it is not possible to set variables inside a + block and have them show up outside of it. This also applies to + loops. The only exception to that rule are if statements which do not + introduce a scope. As a result the following template is not going + to do what you might expect:: + + {% set iterated = false %} + {% for item in seq %} + {{ item }} + {% set iterated = true %} + {% endfor %} + {% if not iterated %} did not iterate {% endif %} + + It is not possible with Jinja syntax to do this. Instead use + alternative constructs like the loop else block or the special `loop` + variable:: + + {% for item in seq %} + {{ item }} + {% else %} + did not iterate + {% endfor %} + + As of version 2.10 more complex use cases can be handled using namespace + objects which allow propagating of changes across scopes:: + + {% set ns = namespace(found=false) %} + {% for item in items %} + {% if item.check_something() %} + {% set ns.found = true %} + {% endif %} + * {{ item.title }} + {% endfor %} + Found item having something: {{ ns.found }} + + Note that the ``obj.attr`` notation in the `set` tag is only allowed for + namespace objects; attempting to assign an attribute on any other object + will raise an exception. + + .. versionadded:: 2.10 Added support for namespace objects + + +Block Assignments +~~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.8 + +Starting with Jinja 2.8, it's possible to also use block assignments to +capture the contents of a block into a variable name. This can be useful +in some situations as an alternative for macros. In that case, instead of +using an equals sign and a value, you just write the variable name and then +everything until ``{% endset %}`` is captured. + +Example:: + + {% set navigation %} + <li><a href="/">Index</a> + <li><a href="/downloads">Downloads</a> + {% endset %} + +The `navigation` variable then contains the navigation HTML source. + +.. versionchanged:: 2.10 + +Starting with Jinja 2.10, the block assignment supports filters. + +Example:: + + {% set reply | wordwrap %} + You wrote: + {{ message }} + {% endset %} + + +.. _extends: + +Extends +~~~~~~~ + +The `extends` tag can be used to extend one template from another. You can +have multiple `extends` tags in a file, but only one of them may be executed at +a time. + +See the section about :ref:`template-inheritance` above. + + +.. _blocks: + +Blocks +~~~~~~ + +Blocks are used for inheritance and act as both placeholders and replacements +at the same time. They are documented in detail in the +:ref:`template-inheritance` section. + + +Include +~~~~~~~ + +The ``include`` tag renders another template and outputs the result into +the current template. + +.. code-block:: jinja + + {% include 'header.html' %} + Body goes here. + {% include 'footer.html' %} + +The included template has access to context of the current template by +default. Use ``without context`` to use a separate context instead. +``with context`` is also valid, but is the default behavior. See +:ref:`import-visibility`. + +The included template can ``extend`` another template and override +blocks in that template. However, the current template cannot override +any blocks that the included template outputs. + +Use ``ignore missing`` to ignore the statement if the template does not +exist. It must be placed *before* a context visibility statement. + +.. code-block:: jinja + + {% include "sidebar.html" without context %} + {% include "sidebar.html" ignore missing %} + {% include "sidebar.html" ignore missing with context %} + {% include "sidebar.html" ignore missing without context %} + +If a list of templates is given, each will be tried in order until one +is not missing. This can be used with ``ignore missing`` to ignore if +none of the templates exist. + +.. code-block:: jinja + + {% include ['page_detailed.html', 'page.html'] %} + {% include ['special_sidebar.html', 'sidebar.html'] ignore missing %} + +A variable, with either a template name or template object, can also be +passed to the statment. + +.. _import: + +Import +~~~~~~ + +Jinja supports putting often used code into macros. These macros can go into +different templates and get imported from there. This works similarly to the +import statements in Python. It's important to know that imports are cached +and imported templates don't have access to the current template variables, +just the globals by default. For more details about context behavior of +imports and includes, see :ref:`import-visibility`. + +There are two ways to import templates. You can import a complete template +into a variable or request specific macros / exported variables from it. + +Imagine we have a helper module that renders forms (called `forms.html`):: + + {% macro input(name, value='', type='text') -%} + <input type="{{ type }}" value="{{ value|e }}" name="{{ name }}"> + {%- endmacro %} + + {%- macro textarea(name, value='', rows=10, cols=40) -%} + <textarea name="{{ name }}" rows="{{ rows }}" cols="{{ cols + }}">{{ value|e }}</textarea> + {%- endmacro %} + +The easiest and most flexible way to access a template's variables +and macros is to import the whole template module into a variable. +That way, you can access the attributes:: + + {% import 'forms.html' as forms %} + <dl> + <dt>Username</dt> + <dd>{{ forms.input('username') }}</dd> + <dt>Password</dt> + <dd>{{ forms.input('password', type='password') }}</dd> + </dl> + <p>{{ forms.textarea('comment') }}</p> + + +Alternatively, you can import specific names from a template into the current +namespace:: + + {% from 'forms.html' import input as input_field, textarea %} + <dl> + <dt>Username</dt> + <dd>{{ input_field('username') }}</dd> + <dt>Password</dt> + <dd>{{ input_field('password', type='password') }}</dd> + </dl> + <p>{{ textarea('comment') }}</p> + +Macros and variables starting with one or more underscores are private and +cannot be imported. + +.. versionchanged:: 2.4 + If a template object was passed to the template context, you can + import from that object. + + +.. _import-visibility: + +Import Context Behavior +----------------------- + +By default, included templates are passed the current context and imported +templates are not. The reason for this is that imports, unlike includes, +are cached; as imports are often used just as a module that holds macros. + +This behavior can be changed explicitly: by adding `with context` +or `without context` to the import/include directive, the current context +can be passed to the template and caching is disabled automatically. + +Here are two examples:: + + {% from 'forms.html' import input with context %} + {% include 'header.html' without context %} + +.. admonition:: Note + + In Jinja 2.0, the context that was passed to the included template + did not include variables defined in the template. As a matter of + fact, this did not work:: + + {% for box in boxes %} + {% include "render_box.html" %} + {% endfor %} + + The included template ``render_box.html`` is *not* able to access + `box` in Jinja 2.0. As of Jinja 2.1, ``render_box.html`` *is* able + to do so. + + +.. _expressions: + +Expressions +----------- + +Jinja allows basic expressions everywhere. These work very similarly to +regular Python; even if you're not working with Python +you should feel comfortable with it. + +Literals +~~~~~~~~ + +The simplest form of expressions are literals. Literals are representations +for Python objects such as strings and numbers. The following literals exist: + +``"Hello World"`` + Everything between two double or single quotes is a string. They are + useful whenever you need a string in the template (e.g. as + arguments to function calls and filters, or just to extend or include a + template). + +``42`` / ``123_456`` + Integers are whole numbers without a decimal part. The '_' character + can be used to separate groups for legibility. + +``42.23`` / ``42.1e2`` / ``123_456.789`` + Floating point numbers can be written using a '.' as a decimal mark. + They can also be written in scientific notation with an upper or + lower case 'e' to indicate the exponent part. The '_' character can + be used to separate groups for legibility, but cannot be used in the + exponent part. + +``['list', 'of', 'objects']`` + Everything between two brackets is a list. Lists are useful for storing + sequential data to be iterated over. For example, you can easily + create a list of links using lists and tuples for (and with) a for loop:: + + <ul> + {% for href, caption in [('index.html', 'Index'), ('about.html', 'About'), + ('downloads.html', 'Downloads')] %} + <li><a href="{{ href }}">{{ caption }}</a></li> + {% endfor %} + </ul> + +``('tuple', 'of', 'values')`` + Tuples are like lists that cannot be modified ("immutable"). If a tuple + only has one item, it must be followed by a comma (``('1-tuple',)``). + Tuples are usually used to represent items of two or more elements. + See the list example above for more details. + +``{'dict': 'of', 'key': 'and', 'value': 'pairs'}`` + A dict in Python is a structure that combines keys and values. Keys must + be unique and always have exactly one value. Dicts are rarely used in + templates; they are useful in some rare cases such as the :func:`xmlattr` + filter. + +``true`` / ``false`` + ``true`` is always true and ``false`` is always false. + +.. admonition:: Note + + The special constants `true`, `false`, and `none` are indeed lowercase. + Because that caused confusion in the past, (`True` used to expand + to an undefined variable that was considered false), + all three can now also be written in title case + (`True`, `False`, and `None`). + However, for consistency, (all Jinja identifiers are lowercase) + you should use the lowercase versions. + +Math +~~~~ + +Jinja allows you to calculate with values. This is rarely useful in templates +but exists for completeness' sake. The following operators are supported: + +``+`` + Adds two objects together. Usually the objects are numbers, but if both are + strings or lists, you can concatenate them this way. This, however, is not + the preferred way to concatenate strings! For string concatenation, have + a look-see at the ``~`` operator. ``{{ 1 + 1 }}`` is ``2``. + +``-`` + Subtract the second number from the first one. ``{{ 3 - 2 }}`` is ``1``. + +``/`` + Divide two numbers. The return value will be a floating point number. + ``{{ 1 / 2 }}`` is ``{{ 0.5 }}``. + +``//`` + Divide two numbers and return the truncated integer result. + ``{{ 20 // 7 }}`` is ``2``. + +``%`` + Calculate the remainder of an integer division. ``{{ 11 % 7 }}`` is ``4``. + +``*`` + Multiply the left operand with the right one. ``{{ 2 * 2 }}`` would + return ``4``. This can also be used to repeat a string multiple times. + ``{{ '=' * 80 }}`` would print a bar of 80 equal signs. + +``**`` + Raise the left operand to the power of the right operand. + ``{{ 2**3 }}`` would return ``8``. + + Unlike Python, chained pow is evaluated left to right. + ``{{ 3**3**3 }}`` is evaluated as ``(3**3)**3`` in Jinja, but would + be evaluated as ``3**(3**3)`` in Python. Use parentheses in Jinja + to be explicit about what order you want. It is usually preferable + to do extended math in Python and pass the results to ``render`` + rather than doing it in the template. + + This behavior may be changed in the future to match Python, if it's + possible to introduce an upgrade path. + + +Comparisons +~~~~~~~~~~~ + +``==`` + Compares two objects for equality. + +``!=`` + Compares two objects for inequality. + +``>`` + ``true`` if the left hand side is greater than the right hand side. + +``>=`` + ``true`` if the left hand side is greater or equal to the right hand side. + +``<`` + ``true`` if the left hand side is lower than the right hand side. + +``<=`` + ``true`` if the left hand side is lower or equal to the right hand side. + +Logic +~~~~~ + +For ``if`` statements, ``for`` filtering, and ``if`` expressions, it can be useful to +combine multiple expressions: + +``and`` + Return true if the left and the right operand are true. + +``or`` + Return true if the left or the right operand are true. + +``not`` + negate a statement (see below). + +``(expr)`` + Parentheses group an expression. + +.. admonition:: Note + + The ``is`` and ``in`` operators support negation using an infix notation, + too: ``foo is not bar`` and ``foo not in bar`` instead of ``not foo is bar`` + and ``not foo in bar``. All other expressions require a prefix notation: + ``not (foo and bar).`` + + +Other Operators +~~~~~~~~~~~~~~~ + +The following operators are very useful but don't fit into any of the other +two categories: + +``in`` + Perform a sequence / mapping containment test. Returns true if the left + operand is contained in the right. ``{{ 1 in [1, 2, 3] }}`` would, for + example, return true. + +``is`` + Performs a :ref:`test <tests>`. + +``|`` (pipe, vertical bar) + Applies a :ref:`filter <filters>`. + +``~`` (tilde) + Converts all operands into strings and concatenates them. + + ``{{ "Hello " ~ name ~ "!" }}`` would return (assuming `name` is set + to ``'John'``) ``Hello John!``. + +``()`` + Call a callable: ``{{ post.render() }}``. Inside of the parentheses you + can use positional arguments and keyword arguments like in Python: + + ``{{ post.render(user, full=true) }}``. + +``.`` / ``[]`` + Get an attribute of an object. (See :ref:`variables`) + + +.. _if-expression: + +If Expression +~~~~~~~~~~~~~ + +It is also possible to use inline `if` expressions. These are useful in some +situations. For example, you can use this to extend from one template if a +variable is defined, otherwise from the default layout template:: + + {% extends layout_template if layout_template is defined else 'default.html' %} + +The general syntax is ``<do something> if <something is true> else <do +something else>``. + +The `else` part is optional. If not provided, the else block implicitly +evaluates into an :class:`Undefined` object (regardless of what ``undefined`` +in the environment is set to): + +.. code-block:: jinja + + {{ "[{}]".format(page.title) if page.title }} + + +.. _python-methods: + +Python Methods +~~~~~~~~~~~~~~ + +You can also use any of the methods defined on a variable's type. +The value returned from the method invocation is used as the value of the expression. +Here is an example that uses methods defined on strings (where ``page.title`` is a string): + +.. code-block:: text + + {{ page.title.capitalize() }} + +This works for methods on user-defined types. For example, if variable +``f`` of type ``Foo`` has a method ``bar`` defined on it, you can do the +following: + +.. code-block:: text + + {{ f.bar(value) }} + +Operator methods also work as expected. For example, ``%`` implements +printf-style for strings: + +.. code-block:: text + + {{ "Hello, %s!" % name }} + +Although you should prefer the ``.format`` method for that case (which +is a bit contrived in the context of rendering a template): + +.. code-block:: text + + {{ "Hello, {}!".format(name) }} + + +.. _builtin-filters: + +List of Builtin Filters +----------------------- + +.. py:currentmodule:: jinja-filters + +.. jinja:filters:: jinja2.defaults.DEFAULT_FILTERS + + +.. _builtin-tests: + +List of Builtin Tests +--------------------- + +.. py:currentmodule:: jinja-tests + +.. jinja:tests:: jinja2.defaults.DEFAULT_TESTS + + +.. _builtin-globals: + +List of Global Functions +------------------------ + +The following functions are available in the global scope by default: + +.. py:currentmodule:: jinja-globals + +.. function:: range([start,] stop[, step]) + + Return a list containing an arithmetic progression of integers. + ``range(i, j)`` returns ``[i, i+1, i+2, ..., j-1]``; + start (!) defaults to ``0``. + When step is given, it specifies the increment (or decrement). + For example, ``range(4)`` and ``range(0, 4, 1)`` return ``[0, 1, 2, 3]``. + The end point is omitted! + These are exactly the valid indices for a list of 4 elements. + + This is useful to repeat a template block multiple times, e.g. + to fill a list. Imagine you have 7 users in the list but you want to + render three empty items to enforce a height with CSS:: + + <ul> + {% for user in users %} + <li>{{ user.username }}</li> + {% endfor %} + {% for number in range(10 - users|count) %} + <li class="empty"><span>...</span></li> + {% endfor %} + </ul> + +.. function:: lipsum(n=5, html=True, min=20, max=100) + + Generates some lorem ipsum for the template. By default, five paragraphs + of HTML are generated with each paragraph between 20 and 100 words. + If html is False, regular text is returned. This is useful to generate simple + contents for layout testing. + +.. function:: dict(\**items) + + A convenient alternative to dict literals. ``{'foo': 'bar'}`` is the same + as ``dict(foo='bar')``. + +.. class:: cycler(\*items) + + Cycle through values by yielding them one at a time, then restarting + once the end is reached. + + Similar to ``loop.cycle``, but can be used outside loops or across + multiple loops. For example, render a list of folders and files in a + list, alternating giving them "odd" and "even" classes. + + .. code-block:: html+jinja + + {% set row_class = cycler("odd", "even") %} + <ul class="browser"> + {% for folder in folders %} + <li class="folder {{ row_class.next() }}">{{ folder }} + {% endfor %} + {% for file in files %} + <li class="file {{ row_class.next() }}">{{ file }} + {% endfor %} + </ul> + + :param items: Each positional argument will be yielded in the order + given for each cycle. + + .. versionadded:: 2.1 + + .. method:: current + :property: + + Return the current item. Equivalent to the item that will be + returned next time :meth:`next` is called. + + .. method:: next() + + Return the current item, then advance :attr:`current` to the + next item. + + .. method:: reset() + + Resets the current item to the first item. + +.. class:: joiner(sep=', ') + + A tiny helper that can be used to "join" multiple sections. A joiner is + passed a string and will return that string every time it's called, except + the first time (in which case it returns an empty string). You can + use this to join things:: + + {% set pipe = joiner("|") %} + {% if categories %} {{ pipe() }} + Categories: {{ categories|join(", ") }} + {% endif %} + {% if author %} {{ pipe() }} + Author: {{ author() }} + {% endif %} + {% if can_edit %} {{ pipe() }} + <a href="?action=edit">Edit</a> + {% endif %} + + .. versionadded:: 2.1 + +.. class:: namespace(...) + + Creates a new container that allows attribute assignment using the + ``{% set %}`` tag:: + + {% set ns = namespace() %} + {% set ns.foo = 'bar' %} + + The main purpose of this is to allow carrying a value from within a loop + body to an outer scope. Initial values can be provided as a dict, as + keyword arguments, or both (same behavior as Python's `dict` constructor):: + + {% set ns = namespace(found=false) %} + {% for item in items %} + {% if item.check_something() %} + {% set ns.found = true %} + {% endif %} + * {{ item.title }} + {% endfor %} + Found item having something: {{ ns.found }} + + .. versionadded:: 2.10 + + +Extensions +---------- + +.. py:currentmodule:: jinja2 + +The following sections cover the built-in Jinja extensions that may be +enabled by an application. An application could also provide further +extensions not covered by this documentation; in which case there should +be a separate document explaining said :ref:`extensions +<jinja-extensions>`. + + +.. _i18n-in-templates: + +i18n +~~~~ + +If the :ref:`i18n-extension` is enabled, it's possible to mark text in +the template as translatable. To mark a section as translatable, use a +``trans`` block: + +.. code-block:: jinja + + {% trans %}Hello, {{ user }}!{% endtrans %} + +Inside the block, no statements are allowed, only text and simple +variable tags. + +Variable tags can only be a name, not attribute access, filters, or +other expressions. To use an expression, bind it to a name in the +``trans`` tag for use in the block. + +.. code-block:: jinja + + {% trans user=user.username %}Hello, {{ user }}!{% endtrans %} + +To bind more than one expression, separate each with a comma (``,``). + +.. code-block:: jinja + + {% trans book_title=book.title, author=author.name %} + This is {{ book_title }} by {{ author }} + {% endtrans %} + +To pluralize, specify both the singular and plural forms separated by +the ``pluralize`` tag. + +.. code-block:: jinja + + {% trans count=list|length %} + There is {{ count }} {{ name }} object. + {% pluralize %} + There are {{ count }} {{ name }} objects. + {% endtrans %} + +By default, the first variable in a block is used to determine whether +to use singular or plural form. If that isn't correct, specify the +variable used for pluralizing as a parameter to ``pluralize``. + +.. code-block:: jinja + + {% trans ..., user_count=users|length %}... + {% pluralize user_count %}...{% endtrans %} + +When translating blocks of text, whitespace and linebreaks result in +hard to read and error-prone translation strings. To avoid this, a trans +block can be marked as trimmed, which will replace all linebreaks and +the whitespace surrounding them with a single space and remove leading +and trailing whitespace. + +.. code-block:: jinja + + {% trans trimmed book_title=book.title %} + This is {{ book_title }}. + You should read it! + {% endtrans %} + +This results in ``This is %(book_title)s. You should read it!`` in the +translation file. + +If trimming is enabled globally, the ``notrimmed`` modifier can be used +to disable it for a block. + +.. versionadded:: 2.10 + The ``trimmed`` and ``notrimmed`` modifiers have been added. + +If the translation depends on the context that the message appears in, +the ``pgettext`` and ``npgettext`` functions take a ``context`` string +as the first argument, which is used to select the appropriate +translation. To specify a context with the ``{% trans %}`` tag, provide +a string as the first token after ``trans``. + +.. code-block:: jinja + + {% trans "fruit" %}apple{% endtrans %} + {% trans "fruit" trimmed count -%} + 1 apple + {%- pluralize -%} + {{ count }} apples + {%- endtrans %} + +.. versionadded:: 3.1 + A context can be passed to the ``trans`` tag to use ``pgettext`` and + ``npgettext``. + +It's possible to translate strings in expressions with these functions: + +- ``_(message)``: Alias for ``gettext``. +- ``gettext(message)``: Translate a message. +- ``ngettext(singluar, plural, n)``: Translate a singular or plural + message based on a count variable. +- ``pgettext(context, message)``: Like ``gettext()``, but picks the + translation based on the context string. +- ``npgettext(context, singular, plural, n)``: Like ``npgettext()``, + but picks the translation based on the context string. + +You can print a translated string like this: + +.. code-block:: jinja + + {{ _("Hello, World!") }} + +To use placeholders, use the ``format`` filter. + +.. code-block:: jinja + + {{ _("Hello, %(user)s!")|format(user=user.username) }} + +Always use keyword arguments to ``format``, as other languages may not +use the words in the same order. + +If :ref:`newstyle-gettext` calls are activated, using placeholders is +easier. Formatting is part of the ``gettext`` call instead of using the +``format`` filter. + +.. sourcecode:: jinja + + {{ gettext('Hello World!') }} + {{ gettext('Hello %(name)s!', name='World') }} + {{ ngettext('%(num)d apple', '%(num)d apples', apples|count) }} + +The ``ngettext`` function's format string automatically receives the +count as a ``num`` parameter in addition to the given parameters. + + +Expression Statement +~~~~~~~~~~~~~~~~~~~~ + +If the expression-statement extension is loaded, a tag called `do` is available +that works exactly like the regular variable expression (``{{ ... }}``); except +it doesn't print anything. This can be used to modify lists:: + + {% do navigation.append('a string') %} + + +Loop Controls +~~~~~~~~~~~~~ + +If the application enables the :ref:`loopcontrols-extension`, it's possible to +use `break` and `continue` in loops. When `break` is reached, the loop is +terminated; if `continue` is reached, the processing is stopped and continues +with the next iteration. + +Here's a loop that skips every second item:: + + {% for user in users %} + {%- if loop.index is even %}{% continue %}{% endif %} + ... + {% endfor %} + +Likewise, a loop that stops processing after the 10th iteration:: + + {% for user in users %} + {%- if loop.index >= 10 %}{% break %}{% endif %} + {%- endfor %} + +Note that ``loop.index`` starts with 1, and ``loop.index0`` starts with 0 +(See: :ref:`for-loop`). + + +Debug Statement +~~~~~~~~~~~~~~~ + +If the :ref:`debug-extension` is enabled, a ``{% debug %}`` tag will be +available to dump the current context as well as the available filters +and tests. This is useful to see what's available to use in the template +without setting up a debugger. + +.. code-block:: html+jinja + + <pre>{% debug %}</pre> + +.. code-block:: text + + {'context': {'cycler': <class 'jinja2.utils.Cycler'>, + ..., + 'namespace': <class 'jinja2.utils.Namespace'>}, + 'filters': ['abs', 'attr', 'batch', 'capitalize', 'center', 'count', 'd', + ..., 'urlencode', 'urlize', 'wordcount', 'wordwrap', 'xmlattr'], + 'tests': ['!=', '<', '<=', '==', '>', '>=', 'callable', 'defined', + ..., 'odd', 'sameas', 'sequence', 'string', 'undefined', 'upper']} + + +With Statement +~~~~~~~~~~~~~~ + +.. versionadded:: 2.3 + +The with statement makes it possible to create a new inner scope. +Variables set within this scope are not visible outside of the scope. + +With in a nutshell:: + + {% with %} + {% set foo = 42 %} + {{ foo }} foo is 42 here + {% endwith %} + foo is not visible here any longer + +Because it is common to set variables at the beginning of the scope, +you can do that within the `with` statement. The following two examples +are equivalent:: + + {% with foo = 42 %} + {{ foo }} + {% endwith %} + + {% with %} + {% set foo = 42 %} + {{ foo }} + {% endwith %} + +An important note on scoping here. In Jinja versions before 2.9 the +behavior of referencing one variable to another had some unintended +consequences. In particular one variable could refer to another defined +in the same with block's opening statement. This caused issues with the +cleaned up scoping behavior and has since been improved. In particular +in newer Jinja versions the following code always refers to the variable +`a` from outside the `with` block:: + + {% with a={}, b=a.attribute %}...{% endwith %} + +In earlier Jinja versions the `b` attribute would refer to the results of +the first attribute. If you depend on this behavior you can rewrite it to +use the ``set`` tag:: + + {% with a={} %} + {% set b = a.attribute %} + {% endwith %} + +.. admonition:: Extension + + In older versions of Jinja (before 2.9) it was required to enable this + feature with an extension. It's now enabled by default. + +.. _autoescape-overrides: + +Autoescape Overrides +-------------------- + +.. versionadded:: 2.4 + +If you want you can activate and deactivate the autoescaping from within +the templates. + +Example:: + + {% autoescape true %} + Autoescaping is active within this block + {% endautoescape %} + + {% autoescape false %} + Autoescaping is inactive within this block + {% endautoescape %} + +After an `endautoescape` the behavior is reverted to what it was before. + +.. admonition:: Extension + + In older versions of Jinja (before 2.9) it was required to enable this + feature with an extension. It's now enabled by default. diff --git a/docs/tricks.rst b/docs/tricks.rst new file mode 100644 index 0000000..b58c5bb --- /dev/null +++ b/docs/tricks.rst @@ -0,0 +1,100 @@ +Tips and Tricks +=============== + +.. highlight:: html+jinja + +This part of the documentation shows some tips and tricks for Jinja +templates. + + +.. _null-default-fallback: + +Null-Default Fallback +--------------------- + +Jinja supports dynamic inheritance and does not distinguish between parent +and child template as long as no `extends` tag is visited. While this leads +to the surprising behavior that everything before the first `extends` tag +including whitespace is printed out instead of being ignored, it can be used +for a neat trick. + +Usually child templates extend from one template that adds a basic HTML +skeleton. However it's possible to put the `extends` tag into an `if` tag to +only extend from the layout template if the `standalone` variable evaluates +to false which it does per default if it's not defined. Additionally a very +basic skeleton is added to the file so that if it's indeed rendered with +`standalone` set to `True` a very basic HTML skeleton is added:: + + {% if not standalone %}{% extends 'default.html' %}{% endif -%} + <!DOCTYPE html> + <title>{% block title %}The Page Title{% endblock %}</title> + <link rel="stylesheet" href="style.css" type="text/css"> + {% block body %} + <p>This is the page body.</p> + {% endblock %} + + +Alternating Rows +---------------- + +If you want to have different styles for each row of a table or +list you can use the `cycle` method on the `loop` object:: + + <ul> + {% for row in rows %} + <li class="{{ loop.cycle('odd', 'even') }}">{{ row }}</li> + {% endfor %} + </ul> + +`cycle` can take an unlimited number of strings. Each time this +tag is encountered the next item from the list is rendered. + + +Highlighting Active Menu Items +------------------------------ + +Often you want to have a navigation bar with an active navigation +item. This is really simple to achieve. Because assignments outside +of `block`\s in child templates are global and executed before the layout +template is evaluated it's possible to define the active menu item in the +child template:: + + {% extends "layout.html" %} + {% set active_page = "index" %} + +The layout template can then access `active_page`. Additionally it makes +sense to define a default for that variable:: + + {% set navigation_bar = [ + ('/', 'index', 'Index'), + ('/downloads/', 'downloads', 'Downloads'), + ('/about/', 'about', 'About') + ] -%} + {% set active_page = active_page|default('index') -%} + ... + <ul id="navigation"> + {% for href, id, caption in navigation_bar %} + <li{% if id == active_page %} class="active"{% endif %}> + <a href="{{ href|e }}">{{ caption|e }}</a></li> + {% endfor %} + </ul> + ... + +.. _accessing-the-parent-loop: + +Accessing the parent Loop +------------------------- + +The special `loop` variable always points to the innermost loop. If it's +desired to have access to an outer loop it's possible to alias it:: + + <table> + {% for row in table %} + <tr> + {% set rowloop = loop %} + {% for cell in row %} + <td id="cell-{{ rowloop.index }}-{{ loop.index }}">{{ cell }}</td> + {% endfor %} + </tr> + {% endfor %} + </table> diff --git a/examples/basic/cycle.py b/examples/basic/cycle.py new file mode 100644 index 0000000..1f97e37 --- /dev/null +++ b/examples/basic/cycle.py @@ -0,0 +1,16 @@ +from jinja2 import Environment + +env = Environment( + line_statement_prefix="#", variable_start_string="${", variable_end_string="}" +) +print( + env.from_string( + """\ +<ul> +# for item in range(10) + <li class="${loop.cycle('odd', 'even')}">${item}</li> +# endfor +</ul>\ +""" + ).render() +) diff --git a/examples/basic/debugger.py b/examples/basic/debugger.py new file mode 100644 index 0000000..f6a9627 --- /dev/null +++ b/examples/basic/debugger.py @@ -0,0 +1,6 @@ +from jinja2 import Environment +from jinja2.loaders import FileSystemLoader + +env = Environment(loader=FileSystemLoader("templates")) +tmpl = env.get_template("broken.html") +print(tmpl.render(seq=[3, 2, 4, 5, 3, 2, 0, 2, 1])) diff --git a/examples/basic/inheritance.py b/examples/basic/inheritance.py new file mode 100644 index 0000000..6d928df --- /dev/null +++ b/examples/basic/inheritance.py @@ -0,0 +1,13 @@ +from jinja2 import Environment +from jinja2.loaders import DictLoader + +env = Environment( + loader=DictLoader( + { + "a": "[A[{% block body %}{% endblock %}]]", + "b": "{% extends 'a' %}{% block body %}[B]{% endblock %}", + "c": "{% extends 'b' %}{% block body %}###{{ super() }}###{% endblock %}", + } + ) +) +print(env.get_template("c").render()) diff --git a/examples/basic/templates/broken.html b/examples/basic/templates/broken.html new file mode 100644 index 0000000..294d5c9 --- /dev/null +++ b/examples/basic/templates/broken.html @@ -0,0 +1,6 @@ +{% from 'subbroken.html' import may_break %} +<ul> +{% for item in seq %} + <li>{{ may_break(item) }}</li> +{% endfor %} +</ul> diff --git a/examples/basic/templates/subbroken.html b/examples/basic/templates/subbroken.html new file mode 100644 index 0000000..245eb7e --- /dev/null +++ b/examples/basic/templates/subbroken.html @@ -0,0 +1,3 @@ +{% macro may_break(item) -%} + [{{ item / 0 }}] +{%- endmacro %} diff --git a/examples/basic/test.py b/examples/basic/test.py new file mode 100644 index 0000000..7a58e1a --- /dev/null +++ b/examples/basic/test.py @@ -0,0 +1,29 @@ +from jinja2 import Environment +from jinja2.loaders import DictLoader + +env = Environment( + loader=DictLoader( + { + "child.html": """\ +{% extends default_layout or 'default.html' %} +{% include helpers = 'helpers.html' %} +{% macro get_the_answer() %}42{% endmacro %} +{% title = 'Hello World' %} +{% block body %} + {{ get_the_answer() }} + {{ helpers.conspirate() }} +{% endblock %} +""", + "default.html": """\ +<!doctype html> +<title>{{ title }}</title> +{% block body %}{% endblock %} +""", + "helpers.html": """\ +{% macro conspirate() %}23{% endmacro %} +""", + } + ) +) +tmpl = env.get_template("child.html") +print(tmpl.render()) diff --git a/examples/basic/test_filter_and_linestatements.py b/examples/basic/test_filter_and_linestatements.py new file mode 100644 index 0000000..9bbcbca --- /dev/null +++ b/examples/basic/test_filter_and_linestatements.py @@ -0,0 +1,27 @@ +from jinja2 import Environment + +env = Environment( + line_statement_prefix="%", variable_start_string="${", variable_end_string="}" +) +tmpl = env.from_string( + """\ +% macro foo() + ${caller(42)} +% endmacro +<ul> +% for item in seq + <li>${item}</li> +% endfor +</ul> +% call(var) foo() + [${var}] +% endcall +% filter escape + <hello world> + % for item in [1, 2, 3] + - ${item} + % endfor +% endfilter +""" +) +print(tmpl.render(seq=range(10))) diff --git a/examples/basic/test_loop_filter.py b/examples/basic/test_loop_filter.py new file mode 100644 index 0000000..6bd89fd --- /dev/null +++ b/examples/basic/test_loop_filter.py @@ -0,0 +1,13 @@ +from jinja2 import Environment + +tmpl = Environment().from_string( + """\ +<ul> +{%- for item in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] if item % 2 == 0 %} + <li>{{ loop.index }} / {{ loop.length }}: {{ item }}</li> +{%- endfor %} +</ul> +if condition: {{ 1 if foo else 0 }} +""" +) +print(tmpl.render(foo=True)) diff --git a/examples/basic/translate.py b/examples/basic/translate.py new file mode 100644 index 0000000..e659681 --- /dev/null +++ b/examples/basic/translate.py @@ -0,0 +1,18 @@ +from jinja2 import Environment + +env = Environment(extensions=["jinja2.ext.i18n"]) +env.globals["gettext"] = {"Hello %(user)s!": "Hallo %(user)s!"}.__getitem__ +env.globals["ngettext"] = lambda s, p, n: { + "%(count)s user": "%(count)d Benutzer", + "%(count)s users": "%(count)d Benutzer", +}[s if n == 1 else p] +print( + env.from_string( + """\ +{% trans %}Hello {{ user }}!{% endtrans %} +{% trans count=users|count -%} +{{ count }} user{% pluralize %}{{ count }} users +{% endtrans %} +""" + ).render(user="someone", users=[1, 2, 3]) +) diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..c9bcd60 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,60 @@ +# SHA1:54b5b77ec8c7a0064ffa93b2fd16cb0130ba177c +# +# This file is autogenerated by pip-compile-multi +# To update, run: +# +# pip-compile-multi +# +-r docs.txt +-r tests.txt +-r typing.txt +cfgv==3.3.1 + # via pre-commit +click==8.1.2 + # via + # pip-compile-multi + # pip-tools +distlib==0.3.4 + # via virtualenv +filelock==3.6.0 + # via + # tox + # virtualenv +identify==2.5.0 + # via pre-commit +nodeenv==1.6.0 + # via pre-commit +pep517==0.12.0 + # via pip-tools +pip-compile-multi==2.4.5 + # via -r requirements/dev.in +pip-tools==6.6.0 + # via pip-compile-multi +platformdirs==2.5.2 + # via virtualenv +pre-commit==2.18.1 + # via -r requirements/dev.in +pyyaml==6.0 + # via pre-commit +six==1.16.0 + # via + # tox + # virtualenv +toml==0.10.2 + # via + # pre-commit + # tox +toposort==1.7 + # via pip-compile-multi +tox==3.25.0 + # via -r requirements/dev.in +virtualenv==20.14.1 + # via + # pre-commit + # tox +wheel==0.37.1 + # via pip-tools + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements/docs.txt b/requirements/docs.txt new file mode 100644 index 0000000..88f6279 --- /dev/null +++ b/requirements/docs.txt @@ -0,0 +1,65 @@ +# SHA1:45c590f97fe95b8bdc755eef796e91adf5fbe4ea +# +# This file is autogenerated by pip-compile-multi +# To update, run: +# +# pip-compile-multi +# +alabaster==0.7.12 + # via sphinx +babel==2.10.1 + # via sphinx +certifi==2021.10.8 + # via requests +charset-normalizer==2.0.12 + # via requests +docutils==0.17.1 + # via sphinx +idna==3.3 + # via requests +imagesize==1.3.0 + # via sphinx +jinja2==3.1.1 + # via sphinx +markupsafe==2.1.1 + # via jinja2 +packaging==21.3 + # via + # pallets-sphinx-themes + # sphinx +pallets-sphinx-themes==2.0.2 + # via -r requirements/docs.in +pygments==2.12.0 + # via sphinx +pyparsing==3.0.8 + # via packaging +pytz==2022.1 + # via babel +requests==2.27.1 + # via sphinx +snowballstemmer==2.2.0 + # via sphinx +sphinx==4.5.0 + # via + # -r requirements/docs.in + # pallets-sphinx-themes + # sphinx-issues + # sphinxcontrib-log-cabinet +sphinx-issues==3.0.1 + # via -r requirements/docs.in +sphinxcontrib-applehelp==1.0.2 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==2.0.0 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-log-cabinet==1.0.1 + # via -r requirements/docs.in +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via sphinx +urllib3==1.26.9 + # via requests diff --git a/requirements/tests.txt b/requirements/tests.txt new file mode 100644 index 0000000..4cd3fe9 --- /dev/null +++ b/requirements/tests.txt @@ -0,0 +1,23 @@ +# SHA1:0eaa389e1fdb3a1917c0f987514bd561be5718ee +# +# This file is autogenerated by pip-compile-multi +# To update, run: +# +# pip-compile-multi +# +attrs==21.4.0 + # via pytest +iniconfig==1.1.1 + # via pytest +packaging==21.3 + # via pytest +pluggy==1.0.0 + # via pytest +py==1.11.0 + # via pytest +pyparsing==3.0.8 + # via packaging +pytest==7.1.2 + # via -r requirements/tests.in +tomli==2.0.1 + # via pytest diff --git a/requirements/typing.txt b/requirements/typing.txt new file mode 100644 index 0000000..2d97fef --- /dev/null +++ b/requirements/typing.txt @@ -0,0 +1,15 @@ +# SHA1:7983aaa01d64547827c20395d77e248c41b2572f +# +# This file is autogenerated by pip-compile-multi +# To update, run: +# +# pip-compile-multi +# +mypy==0.950 + # via -r requirements/typing.in +mypy-extensions==0.4.3 + # via mypy +tomli==2.0.1 + # via mypy +typing-extensions==4.2.0 + # via mypy diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6c86169 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,100 @@ +[metadata] +name = Jinja2 +version = attr: jinja2.__version__ +url = https://palletsprojects.com/p/jinja/ +project_urls = + Donate = https://palletsprojects.com/donate + Documentation = https://jinja.palletsprojects.com/ + Changes = https://jinja.palletsprojects.com/changes/ + Source Code = https://github.com/pallets/jinja/ + Issue Tracker = https://github.com/pallets/jinja/issues/ + Twitter = https://twitter.com/PalletsTeam + Chat = https://discord.gg/pallets +license = BSD-3-Clause +license_files = LICENSE.rst +author = Armin Ronacher +author_email = armin.ronacher@active-4.com +maintainer = Pallets +maintainer_email = contact@palletsprojects.com +description = A very fast and expressive template engine. +long_description = file: README.rst +long_description_content_type = text/x-rst +classifiers = + Development Status :: 5 - Production/Stable + Environment :: Web Environment + Intended Audience :: Developers + License :: OSI Approved :: BSD License + Operating System :: OS Independent + Programming Language :: Python + Topic :: Internet :: WWW/HTTP :: Dynamic Content + Topic :: Text Processing :: Markup :: HTML + +[options] +packages = find: +package_dir = = src +include_package_data = True +python_requires = >= 3.7 + +[options.packages.find] +where = src + +[options.entry_points] +babel.extractors = + jinja2 = jinja2.ext:babel_extract[i18n] + +[tool:pytest] +testpaths = tests +filterwarnings = + error + ignore:The loop argument:DeprecationWarning:asyncio[.]base_events:542 + +[coverage:run] +branch = True +source = + jinja2 + tests + +[coverage:paths] +source = + src + */site-packages + +[flake8] +select = B, E, F, W, B9, ISC +ignore = + E203 + E501 + E722 + W503 +max-line-length = 80 +per-file-ignores = + src/jinja2/__init__.py: F401 + +[mypy] +files = src/jinja2 +python_version = 3.7 +show_error_codes = True +disallow_subclassing_any = True +disallow_untyped_calls = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +no_implicit_optional = True +local_partial_types = True +no_implicit_reexport = True +strict_equality = True +warn_redundant_casts = True +warn_unused_configs = True +warn_unused_ignores = True +warn_return_any = True +warn_unreachable = True + +[mypy-jinja2.defaults] +no_implicit_reexport = False + +[mypy-markupsafe] +no_implicit_reexport = False + +[egg_info] +tag_build = +tag_date = 0 + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..79d0708 --- /dev/null +++ b/setup.py @@ -0,0 +1,8 @@ +from setuptools import setup + +# Metadata goes in setup.cfg. These are here for GitHub's dependency graph. +setup( + name="Jinja2", + install_requires=["MarkupSafe>=2.0"], + extras_require={"i18n": ["Babel>=2.7"]}, +) diff --git a/src/Jinja2.egg-info/PKG-INFO b/src/Jinja2.egg-info/PKG-INFO new file mode 100644 index 0000000..7b92c21 --- /dev/null +++ b/src/Jinja2.egg-info/PKG-INFO @@ -0,0 +1,111 @@ +Metadata-Version: 2.1 +Name: Jinja2 +Version: 3.1.2 +Summary: A very fast and expressive template engine. +Home-page: https://palletsprojects.com/p/jinja/ +Author: Armin Ronacher +Author-email: armin.ronacher@active-4.com +Maintainer: Pallets +Maintainer-email: contact@palletsprojects.com +License: BSD-3-Clause +Project-URL: Donate, https://palletsprojects.com/donate +Project-URL: Documentation, https://jinja.palletsprojects.com/ +Project-URL: Changes, https://jinja.palletsprojects.com/changes/ +Project-URL: Source Code, https://github.com/pallets/jinja/ +Project-URL: Issue Tracker, https://github.com/pallets/jinja/issues/ +Project-URL: Twitter, https://twitter.com/PalletsTeam +Project-URL: Chat, https://discord.gg/pallets +Platform: UNKNOWN +Classifier: Development Status :: 5 - Production/Stable +Classifier: Environment :: Web Environment +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: BSD License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content +Classifier: Topic :: Text Processing :: Markup :: HTML +Requires-Python: >=3.7 +Description-Content-Type: text/x-rst +Provides-Extra: i18n +License-File: LICENSE.rst + +Jinja +===== + +Jinja is a fast, expressive, extensible templating engine. Special +placeholders in the template allow writing code similar to Python +syntax. Then the template is passed data to render the final document. + +It includes: + +- Template inheritance and inclusion. +- Define and import macros within templates. +- HTML templates can use autoescaping to prevent XSS from untrusted + user input. +- A sandboxed environment can safely render untrusted templates. +- AsyncIO support for generating templates and calling async + functions. +- I18N support with Babel. +- Templates are compiled to optimized Python code just-in-time and + cached, or can be compiled ahead-of-time. +- Exceptions point to the correct line in templates to make debugging + easier. +- Extensible filters, tests, functions, and even syntax. + +Jinja's philosophy is that while application logic belongs in Python if +possible, it shouldn't make the template designer's job difficult by +restricting functionality too much. + + +Installing +---------- + +Install and update using `pip`_: + +.. code-block:: text + + $ pip install -U Jinja2 + +.. _pip: https://pip.pypa.io/en/stable/getting-started/ + + +In A Nutshell +------------- + +.. code-block:: jinja + + {% extends "base.html" %} + {% block title %}Members{% endblock %} + {% block content %} + <ul> + {% for user in users %} + <li><a href="{{ user.url }}">{{ user.username }}</a></li> + {% endfor %} + </ul> + {% endblock %} + + +Donate +------ + +The Pallets organization develops and supports Jinja and other popular +packages. In order to grow the community of contributors and users, and +allow the maintainers to devote more time to the projects, `please +donate today`_. + +.. _please donate today: https://palletsprojects.com/donate + + +Links +----- + +- Documentation: https://jinja.palletsprojects.com/ +- Changes: https://jinja.palletsprojects.com/changes/ +- PyPI Releases: https://pypi.org/project/Jinja2/ +- Source Code: https://github.com/pallets/jinja/ +- Issue Tracker: https://github.com/pallets/jinja/issues/ +- Website: https://palletsprojects.com/p/jinja/ +- Twitter: https://twitter.com/PalletsTeam +- Chat: https://discord.gg/pallets + + diff --git a/src/Jinja2.egg-info/SOURCES.txt b/src/Jinja2.egg-info/SOURCES.txt new file mode 100644 index 0000000..7f7a21f --- /dev/null +++ b/src/Jinja2.egg-info/SOURCES.txt @@ -0,0 +1,104 @@ +CHANGES.rst +LICENSE.rst +MANIFEST.in +README.rst +setup.cfg +setup.py +tox.ini +artwork/jinjalogo.svg +docs/Makefile +docs/api.rst +docs/changes.rst +docs/conf.py +docs/extensions.rst +docs/faq.rst +docs/index.rst +docs/integration.rst +docs/intro.rst +docs/license.rst +docs/make.bat +docs/nativetypes.rst +docs/sandbox.rst +docs/switching.rst +docs/templates.rst +docs/tricks.rst +docs/_static/jinja-logo-sidebar.png +docs/_static/jinja-logo.png +docs/examples/cache_extension.py +docs/examples/inline_gettext_extension.py +examples/basic/cycle.py +examples/basic/debugger.py +examples/basic/inheritance.py +examples/basic/test.py +examples/basic/test_filter_and_linestatements.py +examples/basic/test_loop_filter.py +examples/basic/translate.py +examples/basic/templates/broken.html +examples/basic/templates/subbroken.html +requirements/dev.txt +requirements/docs.txt +requirements/tests.txt +requirements/typing.txt +src/Jinja2.egg-info/PKG-INFO +src/Jinja2.egg-info/SOURCES.txt +src/Jinja2.egg-info/dependency_links.txt +src/Jinja2.egg-info/entry_points.txt +src/Jinja2.egg-info/requires.txt +src/Jinja2.egg-info/top_level.txt +src/jinja2/__init__.py +src/jinja2/_identifier.py +src/jinja2/async_utils.py +src/jinja2/bccache.py +src/jinja2/compiler.py +src/jinja2/constants.py +src/jinja2/debug.py +src/jinja2/defaults.py +src/jinja2/environment.py +src/jinja2/exceptions.py +src/jinja2/ext.py +src/jinja2/filters.py +src/jinja2/idtracking.py +src/jinja2/lexer.py +src/jinja2/loaders.py +src/jinja2/meta.py +src/jinja2/nativetypes.py +src/jinja2/nodes.py +src/jinja2/optimizer.py +src/jinja2/parser.py +src/jinja2/py.typed +src/jinja2/runtime.py +src/jinja2/sandbox.py +src/jinja2/tests.py +src/jinja2/utils.py +src/jinja2/visitor.py +tests/conftest.py +tests/test_api.py +tests/test_async.py +tests/test_async_filters.py +tests/test_bytecode_cache.py +tests/test_compile.py +tests/test_core_tags.py +tests/test_debug.py +tests/test_ext.py +tests/test_filters.py +tests/test_idtracking.py +tests/test_imports.py +tests/test_inheritance.py +tests/test_lexnparse.py +tests/test_loader.py +tests/test_nativetypes.py +tests/test_nodes.py +tests/test_pickle.py +tests/test_regression.py +tests/test_runtime.py +tests/test_security.py +tests/test_tests.py +tests/test_utils.py +tests/res/__init__.py +tests/res/package.zip +tests/res/templates/broken.html +tests/res/templates/mojibake.txt +tests/res/templates/syntaxerror.html +tests/res/templates/test.html +tests/res/templates/foo/test.html +tests/res/templates2/foo
\ No newline at end of file diff --git a/src/Jinja2.egg-info/dependency_links.txt b/src/Jinja2.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Jinja2.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/Jinja2.egg-info/entry_points.txt b/src/Jinja2.egg-info/entry_points.txt new file mode 100644 index 0000000..7b9666c --- /dev/null +++ b/src/Jinja2.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[babel.extractors] +jinja2 = jinja2.ext:babel_extract[i18n] diff --git a/src/Jinja2.egg-info/requires.txt b/src/Jinja2.egg-info/requires.txt new file mode 100644 index 0000000..fbc43b7 --- /dev/null +++ b/src/Jinja2.egg-info/requires.txt @@ -0,0 +1,4 @@ +MarkupSafe>=2.0 + +[i18n] +Babel>=2.7 diff --git a/src/Jinja2.egg-info/top_level.txt b/src/Jinja2.egg-info/top_level.txt new file mode 100644 index 0000000..7f7afbf --- /dev/null +++ b/src/Jinja2.egg-info/top_level.txt @@ -0,0 +1 @@ +jinja2 diff --git a/src/jinja2/__init__.py b/src/jinja2/__init__.py new file mode 100644 index 0000000..e323926 --- /dev/null +++ b/src/jinja2/__init__.py @@ -0,0 +1,37 @@ +"""Jinja is a template engine written in pure Python. It provides a +non-XML syntax that supports inline expressions and an optional +sandboxed environment. +""" +from .bccache import BytecodeCache as BytecodeCache +from .bccache import FileSystemBytecodeCache as FileSystemBytecodeCache +from .bccache import MemcachedBytecodeCache as MemcachedBytecodeCache +from .environment import Environment as Environment +from .environment import Template as Template +from .exceptions import TemplateAssertionError as TemplateAssertionError +from .exceptions import TemplateError as TemplateError +from .exceptions import TemplateNotFound as TemplateNotFound +from .exceptions import TemplateRuntimeError as TemplateRuntimeError +from .exceptions import TemplatesNotFound as TemplatesNotFound +from .exceptions import TemplateSyntaxError as TemplateSyntaxError +from .exceptions import UndefinedError as UndefinedError +from .loaders import BaseLoader as BaseLoader +from .loaders import ChoiceLoader as ChoiceLoader +from .loaders import DictLoader as DictLoader +from .loaders import FileSystemLoader as FileSystemLoader +from .loaders import FunctionLoader as FunctionLoader +from .loaders import ModuleLoader as ModuleLoader +from .loaders import PackageLoader as PackageLoader +from .loaders import PrefixLoader as PrefixLoader +from .runtime import ChainableUndefined as ChainableUndefined +from .runtime import DebugUndefined as DebugUndefined +from .runtime import make_logging_undefined as make_logging_undefined +from .runtime import StrictUndefined as StrictUndefined +from .runtime import Undefined as Undefined +from .utils import clear_caches as clear_caches +from .utils import is_undefined as is_undefined +from .utils import pass_context as pass_context +from .utils import pass_environment as pass_environment +from .utils import pass_eval_context as pass_eval_context +from .utils import select_autoescape as select_autoescape + +__version__ = "3.1.2" diff --git a/src/jinja2/_identifier.py b/src/jinja2/_identifier.py new file mode 100644 index 0000000..928c150 --- /dev/null +++ b/src/jinja2/_identifier.py @@ -0,0 +1,6 @@ +import re + +# generated by scripts/generate_identifier_pattern.py +pattern = re.compile( + r"[\w·̀-ͯ·҃-֑҇-ׇֽֿׁׂׅׄؐ-ًؚ-ٰٟۖ-ۜ۟-۪ۤۧۨ-ܑۭܰ-݊ަ-ް߫-߽߳ࠖ-࠙ࠛ-ࠣࠥ-ࠧࠩ-࡙࠭-࡛࣓-ࣣ࣡-ःऺ-़ा-ॏ॑-ॗॢॣঁ-ঃ়া-ৄেৈো-্ৗৢৣ৾ਁ-ਃ਼ਾ-ੂੇੈੋ-੍ੑੰੱੵઁ-ઃ઼ા-ૅે-ૉો-્ૢૣૺ-૿ଁ-ଃ଼ା-ୄେୈୋ-୍ୖୗୢୣஂா-ூெ-ைொ-்ௗఀ-ఄా-ౄె-ైొ-్ౕౖౢౣಁ-ಃ಼ಾ-ೄೆ-ೈೊ-್ೕೖೢೣഀ-ഃ഻഼ാ-ൄെ-ൈൊ-്ൗൢൣංඃ්ා-ුූෘ-ෟෲෳัิ-ฺ็-๎ັິ-ູົຼ່-ໍ༹༘༙༵༷༾༿ཱ-྄྆྇ྍ-ྗྙ-ྼ࿆ါ-ှၖ-ၙၞ-ၠၢ-ၤၧ-ၭၱ-ၴႂ-ႍႏႚ-ႝ፝-፟ᜒ-᜔ᜲ-᜴ᝒᝓᝲᝳ឴-៓៝᠋-᠍ᢅᢆᢩᤠ-ᤫᤰ-᤻ᨗ-ᨛᩕ-ᩞ᩠-᩿᩼᪰-᪽ᬀ-ᬄ᬴-᭄᭫-᭳ᮀ-ᮂᮡ-ᮭ᯦-᯳ᰤ-᰷᳐-᳔᳒-᳨᳭ᳲ-᳴᳷-᳹᷀-᷹᷻-᷿‿⁀⁔⃐-⃥⃜⃡-⃰℘℮⳯-⵿⳱ⷠ-〪ⷿ-゙゚〯꙯ꙴ-꙽ꚞꚟ꛰꛱ꠂ꠆ꠋꠣ-ꠧꢀꢁꢴ-ꣅ꣠-꣱ꣿꤦ-꤭ꥇ-꥓ꦀ-ꦃ꦳-꧀ꧥꨩ-ꨶꩃꩌꩍꩻ-ꩽꪰꪲ-ꪴꪷꪸꪾ꪿꫁ꫫ-ꫯꫵ꫶ꯣ-ꯪ꯬꯭ﬞ︀-️︠-︯︳︴﹍-﹏_𐇽𐋠𐍶-𐍺𐨁-𐨃𐨅𐨆𐨌-𐨏𐨸-𐨿𐨺𐫦𐫥𐴤-𐽆𐴧-𐽐𑀀-𑀂𑀸-𑁆𑁿-𑂂𑂰-𑂺𑄀-𑄂𑄧-𑄴𑅅𑅆𑅳𑆀-𑆂𑆳-𑇀𑇉-𑇌𑈬-𑈷𑈾𑋟-𑋪𑌀-𑌃𑌻𑌼𑌾-𑍄𑍇𑍈𑍋-𑍍𑍗𑍢𑍣𑍦-𑍬𑍰-𑍴𑐵-𑑆𑑞𑒰-𑓃𑖯-𑖵𑖸-𑗀𑗜𑗝𑘰-𑙀𑚫-𑚷𑜝-𑜫𑠬-𑠺𑨁-𑨊𑨳-𑨹𑨻-𑨾𑩇𑩑-𑩛𑪊-𑪙𑰯-𑰶𑰸-𑰿𑲒-𑲧𑲩-𑲶𑴱-𑴶𑴺𑴼𑴽𑴿-𑵅𑵇𑶊-𑶎𑶐𑶑𑶓-𑶗𑻳-𑻶𖫰-𖫴𖬰-𖬶𖽑-𖽾𖾏-𖾒𛲝𛲞𝅥-𝅩𝅭-𝅲𝅻-𝆂𝆅-𝆋𝆪-𝆭𝉂-𝉄𝨀-𝨶𝨻-𝩬𝩵𝪄𝪛-𝪟𝪡-𝪯𞀀-𞀆𞀈-𞀘𞀛-𞀡𞀣𞀤𞀦-𞣐𞀪-𞣖𞥄-𞥊󠄀-󠇯]+" # noqa: B950 +) diff --git a/src/jinja2/async_utils.py b/src/jinja2/async_utils.py new file mode 100644 index 0000000..1a4f389 --- /dev/null +++ b/src/jinja2/async_utils.py @@ -0,0 +1,84 @@ +import inspect +import typing as t +from functools import WRAPPER_ASSIGNMENTS +from functools import wraps + +from .utils import _PassArg +from .utils import pass_eval_context + +V = t.TypeVar("V") + + +def async_variant(normal_func): # type: ignore + def decorator(async_func): # type: ignore + pass_arg = _PassArg.from_obj(normal_func) + need_eval_context = pass_arg is None + + if pass_arg is _PassArg.environment: + + def is_async(args: t.Any) -> bool: + return t.cast(bool, args[0].is_async) + + else: + + def is_async(args: t.Any) -> bool: + return t.cast(bool, args[0].environment.is_async) + + # Take the doc and annotations from the sync function, but the + # name from the async function. Pallets-Sphinx-Themes + # build_function_directive expects __wrapped__ to point to the + # sync function. + async_func_attrs = ("__module__", "__name__", "__qualname__") + normal_func_attrs = tuple(set(WRAPPER_ASSIGNMENTS).difference(async_func_attrs)) + + @wraps(normal_func, assigned=normal_func_attrs) + @wraps(async_func, assigned=async_func_attrs, updated=()) + def wrapper(*args, **kwargs): # type: ignore + b = is_async(args) + + if need_eval_context: + args = args[1:] + + if b: + return async_func(*args, **kwargs) + + return normal_func(*args, **kwargs) + + if need_eval_context: + wrapper = pass_eval_context(wrapper) + + wrapper.jinja_async_variant = True + return wrapper + + return decorator + + +_common_primitives = {int, float, bool, str, list, dict, tuple, type(None)} + + +async def auto_await(value: t.Union[t.Awaitable["V"], "V"]) -> "V": + # Avoid a costly call to isawaitable + if type(value) in _common_primitives: + return t.cast("V", value) + + if inspect.isawaitable(value): + return await t.cast("t.Awaitable[V]", value) + + return t.cast("V", value) + + +async def auto_aiter( + iterable: "t.Union[t.AsyncIterable[V], t.Iterable[V]]", +) -> "t.AsyncIterator[V]": + if hasattr(iterable, "__aiter__"): + async for item in t.cast("t.AsyncIterable[V]", iterable): + yield item + else: + for item in t.cast("t.Iterable[V]", iterable): + yield item + + +async def auto_to_list( + value: "t.Union[t.AsyncIterable[V], t.Iterable[V]]", +) -> t.List["V"]: + return [x async for x in auto_aiter(value)] diff --git a/src/jinja2/bccache.py b/src/jinja2/bccache.py new file mode 100644 index 0000000..d0ddf56 --- /dev/null +++ b/src/jinja2/bccache.py @@ -0,0 +1,406 @@ +"""The optional bytecode cache system. This is useful if you have very +complex template situations and the compilation of all those templates +slows down your application too much. + +Situations where this is useful are often forking web applications that +are initialized on the first request. +""" +import errno +import fnmatch +import marshal +import os +import pickle +import stat +import sys +import tempfile +import typing as t +from hashlib import sha1 +from io import BytesIO +from types import CodeType + +if t.TYPE_CHECKING: + import typing_extensions as te + from .environment import Environment + + class _MemcachedClient(te.Protocol): + def get(self, key: str) -> bytes: + ... + + def set(self, key: str, value: bytes, timeout: t.Optional[int] = None) -> None: + ... + + +bc_version = 5 +# Magic bytes to identify Jinja bytecode cache files. Contains the +# Python major and minor version to avoid loading incompatible bytecode +# if a project upgrades its Python version. +bc_magic = ( + b"j2" + + pickle.dumps(bc_version, 2) + + pickle.dumps((sys.version_info[0] << 24) | sys.version_info[1], 2) +) + + +class Bucket: + """Buckets are used to store the bytecode for one template. It's created + and initialized by the bytecode cache and passed to the loading functions. + + The buckets get an internal checksum from the cache assigned and use this + to automatically reject outdated cache material. Individual bytecode + cache subclasses don't have to care about cache invalidation. + """ + + def __init__(self, environment: "Environment", key: str, checksum: str) -> None: + self.environment = environment + self.key = key + self.checksum = checksum + self.reset() + + def reset(self) -> None: + """Resets the bucket (unloads the bytecode).""" + self.code: t.Optional[CodeType] = None + + def load_bytecode(self, f: t.BinaryIO) -> None: + """Loads bytecode from a file or file like object.""" + # make sure the magic header is correct + magic = f.read(len(bc_magic)) + if magic != bc_magic: + self.reset() + return + # the source code of the file changed, we need to reload + checksum = pickle.load(f) + if self.checksum != checksum: + self.reset() + return + # if marshal_load fails then we need to reload + try: + self.code = marshal.load(f) + except (EOFError, ValueError, TypeError): + self.reset() + return + + def write_bytecode(self, f: t.IO[bytes]) -> None: + """Dump the bytecode into the file or file like object passed.""" + if self.code is None: + raise TypeError("can't write empty bucket") + f.write(bc_magic) + pickle.dump(self.checksum, f, 2) + marshal.dump(self.code, f) + + def bytecode_from_string(self, string: bytes) -> None: + """Load bytecode from bytes.""" + self.load_bytecode(BytesIO(string)) + + def bytecode_to_string(self) -> bytes: + """Return the bytecode as bytes.""" + out = BytesIO() + self.write_bytecode(out) + return out.getvalue() + + +class BytecodeCache: + """To implement your own bytecode cache you have to subclass this class + and override :meth:`load_bytecode` and :meth:`dump_bytecode`. Both of + these methods are passed a :class:`~jinja2.bccache.Bucket`. + + A very basic bytecode cache that saves the bytecode on the file system:: + + from os import path + + class MyCache(BytecodeCache): + + def __init__(self, directory): + self.directory = directory + + def load_bytecode(self, bucket): + filename = path.join(self.directory, bucket.key) + if path.exists(filename): + with open(filename, 'rb') as f: + bucket.load_bytecode(f) + + def dump_bytecode(self, bucket): + filename = path.join(self.directory, bucket.key) + with open(filename, 'wb') as f: + bucket.write_bytecode(f) + + A more advanced version of a filesystem based bytecode cache is part of + Jinja. + """ + + def load_bytecode(self, bucket: Bucket) -> None: + """Subclasses have to override this method to load bytecode into a + bucket. If they are not able to find code in the cache for the + bucket, it must not do anything. + """ + raise NotImplementedError() + + def dump_bytecode(self, bucket: Bucket) -> None: + """Subclasses have to override this method to write the bytecode + from a bucket back to the cache. If it unable to do so it must not + fail silently but raise an exception. + """ + raise NotImplementedError() + + def clear(self) -> None: + """Clears the cache. This method is not used by Jinja but should be + implemented to allow applications to clear the bytecode cache used + by a particular environment. + """ + + def get_cache_key( + self, name: str, filename: t.Optional[t.Union[str]] = None + ) -> str: + """Returns the unique hash key for this template name.""" + hash = sha1(name.encode("utf-8")) + + if filename is not None: + hash.update(f"|{filename}".encode()) + + return hash.hexdigest() + + def get_source_checksum(self, source: str) -> str: + """Returns a checksum for the source.""" + return sha1(source.encode("utf-8")).hexdigest() + + def get_bucket( + self, + environment: "Environment", + name: str, + filename: t.Optional[str], + source: str, + ) -> Bucket: + """Return a cache bucket for the given template. All arguments are + mandatory but filename may be `None`. + """ + key = self.get_cache_key(name, filename) + checksum = self.get_source_checksum(source) + bucket = Bucket(environment, key, checksum) + self.load_bytecode(bucket) + return bucket + + def set_bucket(self, bucket: Bucket) -> None: + """Put the bucket into the cache.""" + self.dump_bytecode(bucket) + + +class FileSystemBytecodeCache(BytecodeCache): + """A bytecode cache that stores bytecode on the filesystem. It accepts + two arguments: The directory where the cache items are stored and a + pattern string that is used to build the filename. + + If no directory is specified a default cache directory is selected. On + Windows the user's temp directory is used, on UNIX systems a directory + is created for the user in the system temp directory. + + The pattern can be used to have multiple separate caches operate on the + same directory. The default pattern is ``'__jinja2_%s.cache'``. ``%s`` + is replaced with the cache key. + + >>> bcc = FileSystemBytecodeCache('/tmp/jinja_cache', '%s.cache') + + This bytecode cache supports clearing of the cache using the clear method. + """ + + def __init__( + self, directory: t.Optional[str] = None, pattern: str = "__jinja2_%s.cache" + ) -> None: + if directory is None: + directory = self._get_default_cache_dir() + self.directory = directory + self.pattern = pattern + + def _get_default_cache_dir(self) -> str: + def _unsafe_dir() -> "te.NoReturn": + raise RuntimeError( + "Cannot determine safe temp directory. You " + "need to explicitly provide one." + ) + + tmpdir = tempfile.gettempdir() + + # On windows the temporary directory is used specific unless + # explicitly forced otherwise. We can just use that. + if os.name == "nt": + return tmpdir + if not hasattr(os, "getuid"): + _unsafe_dir() + + dirname = f"_jinja2-cache-{os.getuid()}" + actual_dir = os.path.join(tmpdir, dirname) + + try: + os.mkdir(actual_dir, stat.S_IRWXU) + except OSError as e: + if e.errno != errno.EEXIST: + raise + try: + os.chmod(actual_dir, stat.S_IRWXU) + actual_dir_stat = os.lstat(actual_dir) + if ( + actual_dir_stat.st_uid != os.getuid() + or not stat.S_ISDIR(actual_dir_stat.st_mode) + or stat.S_IMODE(actual_dir_stat.st_mode) != stat.S_IRWXU + ): + _unsafe_dir() + except OSError as e: + if e.errno != errno.EEXIST: + raise + + actual_dir_stat = os.lstat(actual_dir) + if ( + actual_dir_stat.st_uid != os.getuid() + or not stat.S_ISDIR(actual_dir_stat.st_mode) + or stat.S_IMODE(actual_dir_stat.st_mode) != stat.S_IRWXU + ): + _unsafe_dir() + + return actual_dir + + def _get_cache_filename(self, bucket: Bucket) -> str: + return os.path.join(self.directory, self.pattern % (bucket.key,)) + + def load_bytecode(self, bucket: Bucket) -> None: + filename = self._get_cache_filename(bucket) + + # Don't test for existence before opening the file, since the + # file could disappear after the test before the open. + try: + f = open(filename, "rb") + except (FileNotFoundError, IsADirectoryError, PermissionError): + # PermissionError can occur on Windows when an operation is + # in progress, such as calling clear(). + return + + with f: + bucket.load_bytecode(f) + + def dump_bytecode(self, bucket: Bucket) -> None: + # Write to a temporary file, then rename to the real name after + # writing. This avoids another process reading the file before + # it is fully written. + name = self._get_cache_filename(bucket) + f = tempfile.NamedTemporaryFile( + mode="wb", + dir=os.path.dirname(name), + prefix=os.path.basename(name), + suffix=".tmp", + delete=False, + ) + + def remove_silent() -> None: + try: + os.remove(f.name) + except OSError: + # Another process may have called clear(). On Windows, + # another program may be holding the file open. + pass + + try: + with f: + bucket.write_bytecode(f) + except BaseException: + remove_silent() + raise + + try: + os.replace(f.name, name) + except OSError: + # Another process may have called clear(). On Windows, + # another program may be holding the file open. + remove_silent() + except BaseException: + remove_silent() + raise + + def clear(self) -> None: + # imported lazily here because google app-engine doesn't support + # write access on the file system and the function does not exist + # normally. + from os import remove + + files = fnmatch.filter(os.listdir(self.directory), self.pattern % ("*",)) + for filename in files: + try: + remove(os.path.join(self.directory, filename)) + except OSError: + pass + + +class MemcachedBytecodeCache(BytecodeCache): + """This class implements a bytecode cache that uses a memcache cache for + storing the information. It does not enforce a specific memcache library + (tummy's memcache or cmemcache) but will accept any class that provides + the minimal interface required. + + Libraries compatible with this class: + + - `cachelib <https://github.com/pallets/cachelib>`_ + - `python-memcached <https://pypi.org/project/python-memcached/>`_ + + (Unfortunately the django cache interface is not compatible because it + does not support storing binary data, only text. You can however pass + the underlying cache client to the bytecode cache which is available + as `django.core.cache.cache._client`.) + + The minimal interface for the client passed to the constructor is this: + + .. class:: MinimalClientInterface + + .. method:: set(key, value[, timeout]) + + Stores the bytecode in the cache. `value` is a string and + `timeout` the timeout of the key. If timeout is not provided + a default timeout or no timeout should be assumed, if it's + provided it's an integer with the number of seconds the cache + item should exist. + + .. method:: get(key) + + Returns the value for the cache key. If the item does not + exist in the cache the return value must be `None`. + + The other arguments to the constructor are the prefix for all keys that + is added before the actual cache key and the timeout for the bytecode in + the cache system. We recommend a high (or no) timeout. + + This bytecode cache does not support clearing of used items in the cache. + The clear method is a no-operation function. + + .. versionadded:: 2.7 + Added support for ignoring memcache errors through the + `ignore_memcache_errors` parameter. + """ + + def __init__( + self, + client: "_MemcachedClient", + prefix: str = "jinja2/bytecode/", + timeout: t.Optional[int] = None, + ignore_memcache_errors: bool = True, + ): + self.client = client + self.prefix = prefix + self.timeout = timeout + self.ignore_memcache_errors = ignore_memcache_errors + + def load_bytecode(self, bucket: Bucket) -> None: + try: + code = self.client.get(self.prefix + bucket.key) + except Exception: + if not self.ignore_memcache_errors: + raise + else: + bucket.bytecode_from_string(code) + + def dump_bytecode(self, bucket: Bucket) -> None: + key = self.prefix + bucket.key + value = bucket.bytecode_to_string() + + try: + if self.timeout is not None: + self.client.set(key, value, self.timeout) + else: + self.client.set(key, value) + except Exception: + if not self.ignore_memcache_errors: + raise diff --git a/src/jinja2/compiler.py b/src/jinja2/compiler.py new file mode 100644 index 0000000..3458095 --- /dev/null +++ b/src/jinja2/compiler.py @@ -0,0 +1,1957 @@ +"""Compiles nodes from the parser into Python code.""" +import typing as t +from contextlib import contextmanager +from functools import update_wrapper +from io import StringIO +from itertools import chain +from keyword import iskeyword as is_python_keyword + +from markupsafe import escape +from markupsafe import Markup + +from . import nodes +from .exceptions import TemplateAssertionError +from .idtracking import Symbols +from .idtracking import VAR_LOAD_ALIAS +from .idtracking import VAR_LOAD_PARAMETER +from .idtracking import VAR_LOAD_RESOLVE +from .idtracking import VAR_LOAD_UNDEFINED +from .nodes import EvalContext +from .optimizer import Optimizer +from .utils import _PassArg +from .utils import concat +from .visitor import NodeVisitor + +if t.TYPE_CHECKING: + import typing_extensions as te + from .environment import Environment + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + +operators = { + "eq": "==", + "ne": "!=", + "gt": ">", + "gteq": ">=", + "lt": "<", + "lteq": "<=", + "in": "in", + "notin": "not in", +} + + +def optimizeconst(f: F) -> F: + def new_func( + self: "CodeGenerator", node: nodes.Expr, frame: "Frame", **kwargs: t.Any + ) -> t.Any: + # Only optimize if the frame is not volatile + if self.optimizer is not None and not frame.eval_ctx.volatile: + new_node = self.optimizer.visit(node, frame.eval_ctx) + + if new_node != node: + return self.visit(new_node, frame) + + return f(self, node, frame, **kwargs) + + return update_wrapper(t.cast(F, new_func), f) + + +def _make_binop(op: str) -> t.Callable[["CodeGenerator", nodes.BinExpr, "Frame"], None]: + @optimizeconst + def visitor(self: "CodeGenerator", node: nodes.BinExpr, frame: Frame) -> None: + if ( + self.environment.sandboxed + and op in self.environment.intercepted_binops # type: ignore + ): + self.write(f"environment.call_binop(context, {op!r}, ") + self.visit(node.left, frame) + self.write(", ") + self.visit(node.right, frame) + else: + self.write("(") + self.visit(node.left, frame) + self.write(f" {op} ") + self.visit(node.right, frame) + + self.write(")") + + return visitor + + +def _make_unop( + op: str, +) -> t.Callable[["CodeGenerator", nodes.UnaryExpr, "Frame"], None]: + @optimizeconst + def visitor(self: "CodeGenerator", node: nodes.UnaryExpr, frame: Frame) -> None: + if ( + self.environment.sandboxed + and op in self.environment.intercepted_unops # type: ignore + ): + self.write(f"environment.call_unop(context, {op!r}, ") + self.visit(node.node, frame) + else: + self.write("(" + op) + self.visit(node.node, frame) + + self.write(")") + + return visitor + + +def generate( + node: nodes.Template, + environment: "Environment", + name: t.Optional[str], + filename: t.Optional[str], + stream: t.Optional[t.TextIO] = None, + defer_init: bool = False, + optimized: bool = True, +) -> t.Optional[str]: + """Generate the python source for a node tree.""" + if not isinstance(node, nodes.Template): + raise TypeError("Can't compile non template nodes") + + generator = environment.code_generator_class( + environment, name, filename, stream, defer_init, optimized + ) + generator.visit(node) + + if stream is None: + return generator.stream.getvalue() # type: ignore + + return None + + +def has_safe_repr(value: t.Any) -> bool: + """Does the node have a safe representation?""" + if value is None or value is NotImplemented or value is Ellipsis: + return True + + if type(value) in {bool, int, float, complex, range, str, Markup}: + return True + + if type(value) in {tuple, list, set, frozenset}: + return all(has_safe_repr(v) for v in value) + + if type(value) is dict: + return all(has_safe_repr(k) and has_safe_repr(v) for k, v in value.items()) + + return False + + +def find_undeclared( + nodes: t.Iterable[nodes.Node], names: t.Iterable[str] +) -> t.Set[str]: + """Check if the names passed are accessed undeclared. The return value + is a set of all the undeclared names from the sequence of names found. + """ + visitor = UndeclaredNameVisitor(names) + try: + for node in nodes: + visitor.visit(node) + except VisitorExit: + pass + return visitor.undeclared + + +class MacroRef: + def __init__(self, node: t.Union[nodes.Macro, nodes.CallBlock]) -> None: + self.node = node + self.accesses_caller = False + self.accesses_kwargs = False + self.accesses_varargs = False + + +class Frame: + """Holds compile time information for us.""" + + def __init__( + self, + eval_ctx: EvalContext, + parent: t.Optional["Frame"] = None, + level: t.Optional[int] = None, + ) -> None: + self.eval_ctx = eval_ctx + + # the parent of this frame + self.parent = parent + + if parent is None: + self.symbols = Symbols(level=level) + + # in some dynamic inheritance situations the compiler needs to add + # write tests around output statements. + self.require_output_check = False + + # inside some tags we are using a buffer rather than yield statements. + # this for example affects {% filter %} or {% macro %}. If a frame + # is buffered this variable points to the name of the list used as + # buffer. + self.buffer: t.Optional[str] = None + + # the name of the block we're in, otherwise None. + self.block: t.Optional[str] = None + + else: + self.symbols = Symbols(parent.symbols, level=level) + self.require_output_check = parent.require_output_check + self.buffer = parent.buffer + self.block = parent.block + + # a toplevel frame is the root + soft frames such as if conditions. + self.toplevel = False + + # the root frame is basically just the outermost frame, so no if + # conditions. This information is used to optimize inheritance + # situations. + self.rootlevel = False + + # variables set inside of loops and blocks should not affect outer frames, + # but they still needs to be kept track of as part of the active context. + self.loop_frame = False + self.block_frame = False + + # track whether the frame is being used in an if-statement or conditional + # expression as it determines which errors should be raised during runtime + # or compile time. + self.soft_frame = False + + def copy(self) -> "Frame": + """Create a copy of the current one.""" + rv = object.__new__(self.__class__) + rv.__dict__.update(self.__dict__) + rv.symbols = self.symbols.copy() + return rv + + def inner(self, isolated: bool = False) -> "Frame": + """Return an inner frame.""" + if isolated: + return Frame(self.eval_ctx, level=self.symbols.level + 1) + return Frame(self.eval_ctx, self) + + def soft(self) -> "Frame": + """Return a soft frame. A soft frame may not be modified as + standalone thing as it shares the resources with the frame it + was created of, but it's not a rootlevel frame any longer. + + This is only used to implement if-statements and conditional + expressions. + """ + rv = self.copy() + rv.rootlevel = False + rv.soft_frame = True + return rv + + __copy__ = copy + + +class VisitorExit(RuntimeError): + """Exception used by the `UndeclaredNameVisitor` to signal a stop.""" + + +class DependencyFinderVisitor(NodeVisitor): + """A visitor that collects filter and test calls.""" + + def __init__(self) -> None: + self.filters: t.Set[str] = set() + self.tests: t.Set[str] = set() + + def visit_Filter(self, node: nodes.Filter) -> None: + self.generic_visit(node) + self.filters.add(node.name) + + def visit_Test(self, node: nodes.Test) -> None: + self.generic_visit(node) + self.tests.add(node.name) + + def visit_Block(self, node: nodes.Block) -> None: + """Stop visiting at blocks.""" + + +class UndeclaredNameVisitor(NodeVisitor): + """A visitor that checks if a name is accessed without being + declared. This is different from the frame visitor as it will + not stop at closure frames. + """ + + def __init__(self, names: t.Iterable[str]) -> None: + self.names = set(names) + self.undeclared: t.Set[str] = set() + + def visit_Name(self, node: nodes.Name) -> None: + if node.ctx == "load" and node.name in self.names: + self.undeclared.add(node.name) + if self.undeclared == self.names: + raise VisitorExit() + else: + self.names.discard(node.name) + + def visit_Block(self, node: nodes.Block) -> None: + """Stop visiting a blocks.""" + + +class CompilerExit(Exception): + """Raised if the compiler encountered a situation where it just + doesn't make sense to further process the code. Any block that + raises such an exception is not further processed. + """ + + +class CodeGenerator(NodeVisitor): + def __init__( + self, + environment: "Environment", + name: t.Optional[str], + filename: t.Optional[str], + stream: t.Optional[t.TextIO] = None, + defer_init: bool = False, + optimized: bool = True, + ) -> None: + if stream is None: + stream = StringIO() + self.environment = environment + self.name = name + self.filename = filename + self.stream = stream + self.created_block_context = False + self.defer_init = defer_init + self.optimizer: t.Optional[Optimizer] = None + + if optimized: + self.optimizer = Optimizer(environment) + + # aliases for imports + self.import_aliases: t.Dict[str, str] = {} + + # a registry for all blocks. Because blocks are moved out + # into the global python scope they are registered here + self.blocks: t.Dict[str, nodes.Block] = {} + + # the number of extends statements so far + self.extends_so_far = 0 + + # some templates have a rootlevel extends. In this case we + # can safely assume that we're a child template and do some + # more optimizations. + self.has_known_extends = False + + # the current line number + self.code_lineno = 1 + + # registry of all filters and tests (global, not block local) + self.tests: t.Dict[str, str] = {} + self.filters: t.Dict[str, str] = {} + + # the debug information + self.debug_info: t.List[t.Tuple[int, int]] = [] + self._write_debug_info: t.Optional[int] = None + + # the number of new lines before the next write() + self._new_lines = 0 + + # the line number of the last written statement + self._last_line = 0 + + # true if nothing was written so far. + self._first_write = True + + # used by the `temporary_identifier` method to get new + # unique, temporary identifier + self._last_identifier = 0 + + # the current indentation + self._indentation = 0 + + # Tracks toplevel assignments + self._assign_stack: t.List[t.Set[str]] = [] + + # Tracks parameter definition blocks + self._param_def_block: t.List[t.Set[str]] = [] + + # Tracks the current context. + self._context_reference_stack = ["context"] + + @property + def optimized(self) -> bool: + return self.optimizer is not None + + # -- Various compilation helpers + + def fail(self, msg: str, lineno: int) -> "te.NoReturn": + """Fail with a :exc:`TemplateAssertionError`.""" + raise TemplateAssertionError(msg, lineno, self.name, self.filename) + + def temporary_identifier(self) -> str: + """Get a new unique identifier.""" + self._last_identifier += 1 + return f"t_{self._last_identifier}" + + def buffer(self, frame: Frame) -> None: + """Enable buffering for the frame from that point onwards.""" + frame.buffer = self.temporary_identifier() + self.writeline(f"{frame.buffer} = []") + + def return_buffer_contents( + self, frame: Frame, force_unescaped: bool = False + ) -> None: + """Return the buffer contents of the frame.""" + if not force_unescaped: + if frame.eval_ctx.volatile: + self.writeline("if context.eval_ctx.autoescape:") + self.indent() + self.writeline(f"return Markup(concat({frame.buffer}))") + self.outdent() + self.writeline("else:") + self.indent() + self.writeline(f"return concat({frame.buffer})") + self.outdent() + return + elif frame.eval_ctx.autoescape: + self.writeline(f"return Markup(concat({frame.buffer}))") + return + self.writeline(f"return concat({frame.buffer})") + + def indent(self) -> None: + """Indent by one.""" + self._indentation += 1 + + def outdent(self, step: int = 1) -> None: + """Outdent by step.""" + self._indentation -= step + + def start_write(self, frame: Frame, node: t.Optional[nodes.Node] = None) -> None: + """Yield or write into the frame buffer.""" + if frame.buffer is None: + self.writeline("yield ", node) + else: + self.writeline(f"{frame.buffer}.append(", node) + + def end_write(self, frame: Frame) -> None: + """End the writing process started by `start_write`.""" + if frame.buffer is not None: + self.write(")") + + def simple_write( + self, s: str, frame: Frame, node: t.Optional[nodes.Node] = None + ) -> None: + """Simple shortcut for start_write + write + end_write.""" + self.start_write(frame, node) + self.write(s) + self.end_write(frame) + + def blockvisit(self, nodes: t.Iterable[nodes.Node], frame: Frame) -> None: + """Visit a list of nodes as block in a frame. If the current frame + is no buffer a dummy ``if 0: yield None`` is written automatically. + """ + try: + self.writeline("pass") + for node in nodes: + self.visit(node, frame) + except CompilerExit: + pass + + def write(self, x: str) -> None: + """Write a string into the output stream.""" + if self._new_lines: + if not self._first_write: + self.stream.write("\n" * self._new_lines) + self.code_lineno += self._new_lines + if self._write_debug_info is not None: + self.debug_info.append((self._write_debug_info, self.code_lineno)) + self._write_debug_info = None + self._first_write = False + self.stream.write(" " * self._indentation) + self._new_lines = 0 + self.stream.write(x) + + def writeline( + self, x: str, node: t.Optional[nodes.Node] = None, extra: int = 0 + ) -> None: + """Combination of newline and write.""" + self.newline(node, extra) + self.write(x) + + def newline(self, node: t.Optional[nodes.Node] = None, extra: int = 0) -> None: + """Add one or more newlines before the next write.""" + self._new_lines = max(self._new_lines, 1 + extra) + if node is not None and node.lineno != self._last_line: + self._write_debug_info = node.lineno + self._last_line = node.lineno + + def signature( + self, + node: t.Union[nodes.Call, nodes.Filter, nodes.Test], + frame: Frame, + extra_kwargs: t.Optional[t.Mapping[str, t.Any]] = None, + ) -> None: + """Writes a function call to the stream for the current node. + A leading comma is added automatically. The extra keyword + arguments may not include python keywords otherwise a syntax + error could occur. The extra keyword arguments should be given + as python dict. + """ + # if any of the given keyword arguments is a python keyword + # we have to make sure that no invalid call is created. + kwarg_workaround = any( + is_python_keyword(t.cast(str, k)) + for k in chain((x.key for x in node.kwargs), extra_kwargs or ()) + ) + + for arg in node.args: + self.write(", ") + self.visit(arg, frame) + + if not kwarg_workaround: + for kwarg in node.kwargs: + self.write(", ") + self.visit(kwarg, frame) + if extra_kwargs is not None: + for key, value in extra_kwargs.items(): + self.write(f", {key}={value}") + if node.dyn_args: + self.write(", *") + self.visit(node.dyn_args, frame) + + if kwarg_workaround: + if node.dyn_kwargs is not None: + self.write(", **dict({") + else: + self.write(", **{") + for kwarg in node.kwargs: + self.write(f"{kwarg.key!r}: ") + self.visit(kwarg.value, frame) + self.write(", ") + if extra_kwargs is not None: + for key, value in extra_kwargs.items(): + self.write(f"{key!r}: {value}, ") + if node.dyn_kwargs is not None: + self.write("}, **") + self.visit(node.dyn_kwargs, frame) + self.write(")") + else: + self.write("}") + + elif node.dyn_kwargs is not None: + self.write(", **") + self.visit(node.dyn_kwargs, frame) + + def pull_dependencies(self, nodes: t.Iterable[nodes.Node]) -> None: + """Find all filter and test names used in the template and + assign them to variables in the compiled namespace. Checking + that the names are registered with the environment is done when + compiling the Filter and Test nodes. If the node is in an If or + CondExpr node, the check is done at runtime instead. + + .. versionchanged:: 3.0 + Filters and tests in If and CondExpr nodes are checked at + runtime instead of compile time. + """ + visitor = DependencyFinderVisitor() + + for node in nodes: + visitor.visit(node) + + for id_map, names, dependency in (self.filters, visitor.filters, "filters"), ( + self.tests, + visitor.tests, + "tests", + ): + for name in sorted(names): + if name not in id_map: + id_map[name] = self.temporary_identifier() + + # add check during runtime that dependencies used inside of executed + # blocks are defined, as this step may be skipped during compile time + self.writeline("try:") + self.indent() + self.writeline(f"{id_map[name]} = environment.{dependency}[{name!r}]") + self.outdent() + self.writeline("except KeyError:") + self.indent() + self.writeline("@internalcode") + self.writeline(f"def {id_map[name]}(*unused):") + self.indent() + self.writeline( + f'raise TemplateRuntimeError("No {dependency[:-1]}' + f' named {name!r} found.")' + ) + self.outdent() + self.outdent() + + def enter_frame(self, frame: Frame) -> None: + undefs = [] + for target, (action, param) in frame.symbols.loads.items(): + if action == VAR_LOAD_PARAMETER: + pass + elif action == VAR_LOAD_RESOLVE: + self.writeline(f"{target} = {self.get_resolve_func()}({param!r})") + elif action == VAR_LOAD_ALIAS: + self.writeline(f"{target} = {param}") + elif action == VAR_LOAD_UNDEFINED: + undefs.append(target) + else: + raise NotImplementedError("unknown load instruction") + if undefs: + self.writeline(f"{' = '.join(undefs)} = missing") + + def leave_frame(self, frame: Frame, with_python_scope: bool = False) -> None: + if not with_python_scope: + undefs = [] + for target in frame.symbols.loads: + undefs.append(target) + if undefs: + self.writeline(f"{' = '.join(undefs)} = missing") + + def choose_async(self, async_value: str = "async ", sync_value: str = "") -> str: + return async_value if self.environment.is_async else sync_value + + def func(self, name: str) -> str: + return f"{self.choose_async()}def {name}" + + def macro_body( + self, node: t.Union[nodes.Macro, nodes.CallBlock], frame: Frame + ) -> t.Tuple[Frame, MacroRef]: + """Dump the function def of a macro or call block.""" + frame = frame.inner() + frame.symbols.analyze_node(node) + macro_ref = MacroRef(node) + + explicit_caller = None + skip_special_params = set() + args = [] + + for idx, arg in enumerate(node.args): + if arg.name == "caller": + explicit_caller = idx + if arg.name in ("kwargs", "varargs"): + skip_special_params.add(arg.name) + args.append(frame.symbols.ref(arg.name)) + + undeclared = find_undeclared(node.body, ("caller", "kwargs", "varargs")) + + if "caller" in undeclared: + # In older Jinja versions there was a bug that allowed caller + # to retain the special behavior even if it was mentioned in + # the argument list. However thankfully this was only really + # working if it was the last argument. So we are explicitly + # checking this now and error out if it is anywhere else in + # the argument list. + if explicit_caller is not None: + try: + node.defaults[explicit_caller - len(node.args)] + except IndexError: + self.fail( + "When defining macros or call blocks the " + 'special "caller" argument must be omitted ' + "or be given a default.", + node.lineno, + ) + else: + args.append(frame.symbols.declare_parameter("caller")) + macro_ref.accesses_caller = True + if "kwargs" in undeclared and "kwargs" not in skip_special_params: + args.append(frame.symbols.declare_parameter("kwargs")) + macro_ref.accesses_kwargs = True + if "varargs" in undeclared and "varargs" not in skip_special_params: + args.append(frame.symbols.declare_parameter("varargs")) + macro_ref.accesses_varargs = True + + # macros are delayed, they never require output checks + frame.require_output_check = False + frame.symbols.analyze_node(node) + self.writeline(f"{self.func('macro')}({', '.join(args)}):", node) + self.indent() + + self.buffer(frame) + self.enter_frame(frame) + + self.push_parameter_definitions(frame) + for idx, arg in enumerate(node.args): + ref = frame.symbols.ref(arg.name) + self.writeline(f"if {ref} is missing:") + self.indent() + try: + default = node.defaults[idx - len(node.args)] + except IndexError: + self.writeline( + f'{ref} = undefined("parameter {arg.name!r} was not provided",' + f" name={arg.name!r})" + ) + else: + self.writeline(f"{ref} = ") + self.visit(default, frame) + self.mark_parameter_stored(ref) + self.outdent() + self.pop_parameter_definitions() + + self.blockvisit(node.body, frame) + self.return_buffer_contents(frame, force_unescaped=True) + self.leave_frame(frame, with_python_scope=True) + self.outdent() + + return frame, macro_ref + + def macro_def(self, macro_ref: MacroRef, frame: Frame) -> None: + """Dump the macro definition for the def created by macro_body.""" + arg_tuple = ", ".join(repr(x.name) for x in macro_ref.node.args) + name = getattr(macro_ref.node, "name", None) + if len(macro_ref.node.args) == 1: + arg_tuple += "," + self.write( + f"Macro(environment, macro, {name!r}, ({arg_tuple})," + f" {macro_ref.accesses_kwargs!r}, {macro_ref.accesses_varargs!r}," + f" {macro_ref.accesses_caller!r}, context.eval_ctx.autoescape)" + ) + + def position(self, node: nodes.Node) -> str: + """Return a human readable position for the node.""" + rv = f"line {node.lineno}" + if self.name is not None: + rv = f"{rv} in {self.name!r}" + return rv + + def dump_local_context(self, frame: Frame) -> str: + items_kv = ", ".join( + f"{name!r}: {target}" + for name, target in frame.symbols.dump_stores().items() + ) + return f"{{{items_kv}}}" + + def write_commons(self) -> None: + """Writes a common preamble that is used by root and block functions. + Primarily this sets up common local helpers and enforces a generator + through a dead branch. + """ + self.writeline("resolve = context.resolve_or_missing") + self.writeline("undefined = environment.undefined") + self.writeline("concat = environment.concat") + # always use the standard Undefined class for the implicit else of + # conditional expressions + self.writeline("cond_expr_undefined = Undefined") + self.writeline("if 0: yield None") + + def push_parameter_definitions(self, frame: Frame) -> None: + """Pushes all parameter targets from the given frame into a local + stack that permits tracking of yet to be assigned parameters. In + particular this enables the optimization from `visit_Name` to skip + undefined expressions for parameters in macros as macros can reference + otherwise unbound parameters. + """ + self._param_def_block.append(frame.symbols.dump_param_targets()) + + def pop_parameter_definitions(self) -> None: + """Pops the current parameter definitions set.""" + self._param_def_block.pop() + + def mark_parameter_stored(self, target: str) -> None: + """Marks a parameter in the current parameter definitions as stored. + This will skip the enforced undefined checks. + """ + if self._param_def_block: + self._param_def_block[-1].discard(target) + + def push_context_reference(self, target: str) -> None: + self._context_reference_stack.append(target) + + def pop_context_reference(self) -> None: + self._context_reference_stack.pop() + + def get_context_ref(self) -> str: + return self._context_reference_stack[-1] + + def get_resolve_func(self) -> str: + target = self._context_reference_stack[-1] + if target == "context": + return "resolve" + return f"{target}.resolve" + + def derive_context(self, frame: Frame) -> str: + return f"{self.get_context_ref()}.derived({self.dump_local_context(frame)})" + + def parameter_is_undeclared(self, target: str) -> bool: + """Checks if a given target is an undeclared parameter.""" + if not self._param_def_block: + return False + return target in self._param_def_block[-1] + + def push_assign_tracking(self) -> None: + """Pushes a new layer for assignment tracking.""" + self._assign_stack.append(set()) + + def pop_assign_tracking(self, frame: Frame) -> None: + """Pops the topmost level for assignment tracking and updates the + context variables if necessary. + """ + vars = self._assign_stack.pop() + if ( + not frame.block_frame + and not frame.loop_frame + and not frame.toplevel + or not vars + ): + return + public_names = [x for x in vars if x[:1] != "_"] + if len(vars) == 1: + name = next(iter(vars)) + ref = frame.symbols.ref(name) + if frame.loop_frame: + self.writeline(f"_loop_vars[{name!r}] = {ref}") + return + if frame.block_frame: + self.writeline(f"_block_vars[{name!r}] = {ref}") + return + self.writeline(f"context.vars[{name!r}] = {ref}") + else: + if frame.loop_frame: + self.writeline("_loop_vars.update({") + elif frame.block_frame: + self.writeline("_block_vars.update({") + else: + self.writeline("context.vars.update({") + for idx, name in enumerate(vars): + if idx: + self.write(", ") + ref = frame.symbols.ref(name) + self.write(f"{name!r}: {ref}") + self.write("})") + if not frame.block_frame and not frame.loop_frame and public_names: + if len(public_names) == 1: + self.writeline(f"context.exported_vars.add({public_names[0]!r})") + else: + names_str = ", ".join(map(repr, public_names)) + self.writeline(f"context.exported_vars.update(({names_str}))") + + # -- Statement Visitors + + def visit_Template( + self, node: nodes.Template, frame: t.Optional[Frame] = None + ) -> None: + assert frame is None, "no root frame allowed" + eval_ctx = EvalContext(self.environment, self.name) + + from .runtime import exported, async_exported + + if self.environment.is_async: + exported_names = sorted(exported + async_exported) + else: + exported_names = sorted(exported) + + self.writeline("from jinja2.runtime import " + ", ".join(exported_names)) + + # if we want a deferred initialization we cannot move the + # environment into a local name + envenv = "" if self.defer_init else ", environment=environment" + + # do we have an extends tag at all? If not, we can save some + # overhead by just not processing any inheritance code. + have_extends = node.find(nodes.Extends) is not None + + # find all blocks + for block in node.find_all(nodes.Block): + if block.name in self.blocks: + self.fail(f"block {block.name!r} defined twice", block.lineno) + self.blocks[block.name] = block + + # find all imports and import them + for import_ in node.find_all(nodes.ImportedName): + if import_.importname not in self.import_aliases: + imp = import_.importname + self.import_aliases[imp] = alias = self.temporary_identifier() + if "." in imp: + module, obj = imp.rsplit(".", 1) + self.writeline(f"from {module} import {obj} as {alias}") + else: + self.writeline(f"import {imp} as {alias}") + + # add the load name + self.writeline(f"name = {self.name!r}") + + # generate the root render function. + self.writeline( + f"{self.func('root')}(context, missing=missing{envenv}):", extra=1 + ) + self.indent() + self.write_commons() + + # process the root + frame = Frame(eval_ctx) + if "self" in find_undeclared(node.body, ("self",)): + ref = frame.symbols.declare_parameter("self") + self.writeline(f"{ref} = TemplateReference(context)") + frame.symbols.analyze_node(node) + frame.toplevel = frame.rootlevel = True + frame.require_output_check = have_extends and not self.has_known_extends + if have_extends: + self.writeline("parent_template = None") + self.enter_frame(frame) + self.pull_dependencies(node.body) + self.blockvisit(node.body, frame) + self.leave_frame(frame, with_python_scope=True) + self.outdent() + + # make sure that the parent root is called. + if have_extends: + if not self.has_known_extends: + self.indent() + self.writeline("if parent_template is not None:") + self.indent() + if not self.environment.is_async: + self.writeline("yield from parent_template.root_render_func(context)") + else: + self.writeline( + "async for event in parent_template.root_render_func(context):" + ) + self.indent() + self.writeline("yield event") + self.outdent() + self.outdent(1 + (not self.has_known_extends)) + + # at this point we now have the blocks collected and can visit them too. + for name, block in self.blocks.items(): + self.writeline( + f"{self.func('block_' + name)}(context, missing=missing{envenv}):", + block, + 1, + ) + self.indent() + self.write_commons() + # It's important that we do not make this frame a child of the + # toplevel template. This would cause a variety of + # interesting issues with identifier tracking. + block_frame = Frame(eval_ctx) + block_frame.block_frame = True + undeclared = find_undeclared(block.body, ("self", "super")) + if "self" in undeclared: + ref = block_frame.symbols.declare_parameter("self") + self.writeline(f"{ref} = TemplateReference(context)") + if "super" in undeclared: + ref = block_frame.symbols.declare_parameter("super") + self.writeline(f"{ref} = context.super({name!r}, block_{name})") + block_frame.symbols.analyze_node(block) + block_frame.block = name + self.writeline("_block_vars = {}") + self.enter_frame(block_frame) + self.pull_dependencies(block.body) + self.blockvisit(block.body, block_frame) + self.leave_frame(block_frame, with_python_scope=True) + self.outdent() + + blocks_kv_str = ", ".join(f"{x!r}: block_{x}" for x in self.blocks) + self.writeline(f"blocks = {{{blocks_kv_str}}}", extra=1) + debug_kv_str = "&".join(f"{k}={v}" for k, v in self.debug_info) + self.writeline(f"debug_info = {debug_kv_str!r}") + + def visit_Block(self, node: nodes.Block, frame: Frame) -> None: + """Call a block and register it for the template.""" + level = 0 + if frame.toplevel: + # if we know that we are a child template, there is no need to + # check if we are one + if self.has_known_extends: + return + if self.extends_so_far > 0: + self.writeline("if parent_template is None:") + self.indent() + level += 1 + + if node.scoped: + context = self.derive_context(frame) + else: + context = self.get_context_ref() + + if node.required: + self.writeline(f"if len(context.blocks[{node.name!r}]) <= 1:", node) + self.indent() + self.writeline( + f'raise TemplateRuntimeError("Required block {node.name!r} not found")', + node, + ) + self.outdent() + + if not self.environment.is_async and frame.buffer is None: + self.writeline( + f"yield from context.blocks[{node.name!r}][0]({context})", node + ) + else: + self.writeline( + f"{self.choose_async()}for event in" + f" context.blocks[{node.name!r}][0]({context}):", + node, + ) + self.indent() + self.simple_write("event", frame) + self.outdent() + + self.outdent(level) + + def visit_Extends(self, node: nodes.Extends, frame: Frame) -> None: + """Calls the extender.""" + if not frame.toplevel: + self.fail("cannot use extend from a non top-level scope", node.lineno) + + # if the number of extends statements in general is zero so + # far, we don't have to add a check if something extended + # the template before this one. + if self.extends_so_far > 0: + + # if we have a known extends we just add a template runtime + # error into the generated code. We could catch that at compile + # time too, but i welcome it not to confuse users by throwing the + # same error at different times just "because we can". + if not self.has_known_extends: + self.writeline("if parent_template is not None:") + self.indent() + self.writeline('raise TemplateRuntimeError("extended multiple times")') + + # if we have a known extends already we don't need that code here + # as we know that the template execution will end here. + if self.has_known_extends: + raise CompilerExit() + else: + self.outdent() + + self.writeline("parent_template = environment.get_template(", node) + self.visit(node.template, frame) + self.write(f", {self.name!r})") + self.writeline("for name, parent_block in parent_template.blocks.items():") + self.indent() + self.writeline("context.blocks.setdefault(name, []).append(parent_block)") + self.outdent() + + # if this extends statement was in the root level we can take + # advantage of that information and simplify the generated code + # in the top level from this point onwards + if frame.rootlevel: + self.has_known_extends = True + + # and now we have one more + self.extends_so_far += 1 + + def visit_Include(self, node: nodes.Include, frame: Frame) -> None: + """Handles includes.""" + if node.ignore_missing: + self.writeline("try:") + self.indent() + + func_name = "get_or_select_template" + if isinstance(node.template, nodes.Const): + if isinstance(node.template.value, str): + func_name = "get_template" + elif isinstance(node.template.value, (tuple, list)): + func_name = "select_template" + elif isinstance(node.template, (nodes.Tuple, nodes.List)): + func_name = "select_template" + + self.writeline(f"template = environment.{func_name}(", node) + self.visit(node.template, frame) + self.write(f", {self.name!r})") + if node.ignore_missing: + self.outdent() + self.writeline("except TemplateNotFound:") + self.indent() + self.writeline("pass") + self.outdent() + self.writeline("else:") + self.indent() + + skip_event_yield = False + if node.with_context: + self.writeline( + f"{self.choose_async()}for event in template.root_render_func(" + "template.new_context(context.get_all(), True," + f" {self.dump_local_context(frame)})):" + ) + elif self.environment.is_async: + self.writeline( + "for event in (await template._get_default_module_async())" + "._body_stream:" + ) + else: + self.writeline("yield from template._get_default_module()._body_stream") + skip_event_yield = True + + if not skip_event_yield: + self.indent() + self.simple_write("event", frame) + self.outdent() + + if node.ignore_missing: + self.outdent() + + def _import_common( + self, node: t.Union[nodes.Import, nodes.FromImport], frame: Frame + ) -> None: + self.write(f"{self.choose_async('await ')}environment.get_template(") + self.visit(node.template, frame) + self.write(f", {self.name!r}).") + + if node.with_context: + f_name = f"make_module{self.choose_async('_async')}" + self.write( + f"{f_name}(context.get_all(), True, {self.dump_local_context(frame)})" + ) + else: + self.write(f"_get_default_module{self.choose_async('_async')}(context)") + + def visit_Import(self, node: nodes.Import, frame: Frame) -> None: + """Visit regular imports.""" + self.writeline(f"{frame.symbols.ref(node.target)} = ", node) + if frame.toplevel: + self.write(f"context.vars[{node.target!r}] = ") + + self._import_common(node, frame) + + if frame.toplevel and not node.target.startswith("_"): + self.writeline(f"context.exported_vars.discard({node.target!r})") + + def visit_FromImport(self, node: nodes.FromImport, frame: Frame) -> None: + """Visit named imports.""" + self.newline(node) + self.write("included_template = ") + self._import_common(node, frame) + var_names = [] + discarded_names = [] + for name in node.names: + if isinstance(name, tuple): + name, alias = name + else: + alias = name + self.writeline( + f"{frame.symbols.ref(alias)} =" + f" getattr(included_template, {name!r}, missing)" + ) + self.writeline(f"if {frame.symbols.ref(alias)} is missing:") + self.indent() + message = ( + "the template {included_template.__name__!r}" + f" (imported on {self.position(node)})" + f" does not export the requested name {name!r}" + ) + self.writeline( + f"{frame.symbols.ref(alias)} = undefined(f{message!r}, name={name!r})" + ) + self.outdent() + if frame.toplevel: + var_names.append(alias) + if not alias.startswith("_"): + discarded_names.append(alias) + + if var_names: + if len(var_names) == 1: + name = var_names[0] + self.writeline(f"context.vars[{name!r}] = {frame.symbols.ref(name)}") + else: + names_kv = ", ".join( + f"{name!r}: {frame.symbols.ref(name)}" for name in var_names + ) + self.writeline(f"context.vars.update({{{names_kv}}})") + if discarded_names: + if len(discarded_names) == 1: + self.writeline(f"context.exported_vars.discard({discarded_names[0]!r})") + else: + names_str = ", ".join(map(repr, discarded_names)) + self.writeline( + f"context.exported_vars.difference_update(({names_str}))" + ) + + def visit_For(self, node: nodes.For, frame: Frame) -> None: + loop_frame = frame.inner() + loop_frame.loop_frame = True + test_frame = frame.inner() + else_frame = frame.inner() + + # try to figure out if we have an extended loop. An extended loop + # is necessary if the loop is in recursive mode if the special loop + # variable is accessed in the body if the body is a scoped block. + extended_loop = ( + node.recursive + or "loop" + in find_undeclared(node.iter_child_nodes(only=("body",)), ("loop",)) + or any(block.scoped for block in node.find_all(nodes.Block)) + ) + + loop_ref = None + if extended_loop: + loop_ref = loop_frame.symbols.declare_parameter("loop") + + loop_frame.symbols.analyze_node(node, for_branch="body") + if node.else_: + else_frame.symbols.analyze_node(node, for_branch="else") + + if node.test: + loop_filter_func = self.temporary_identifier() + test_frame.symbols.analyze_node(node, for_branch="test") + self.writeline(f"{self.func(loop_filter_func)}(fiter):", node.test) + self.indent() + self.enter_frame(test_frame) + self.writeline(self.choose_async("async for ", "for ")) + self.visit(node.target, loop_frame) + self.write(" in ") + self.write(self.choose_async("auto_aiter(fiter)", "fiter")) + self.write(":") + self.indent() + self.writeline("if ", node.test) + self.visit(node.test, test_frame) + self.write(":") + self.indent() + self.writeline("yield ") + self.visit(node.target, loop_frame) + self.outdent(3) + self.leave_frame(test_frame, with_python_scope=True) + + # if we don't have an recursive loop we have to find the shadowed + # variables at that point. Because loops can be nested but the loop + # variable is a special one we have to enforce aliasing for it. + if node.recursive: + self.writeline( + f"{self.func('loop')}(reciter, loop_render_func, depth=0):", node + ) + self.indent() + self.buffer(loop_frame) + + # Use the same buffer for the else frame + else_frame.buffer = loop_frame.buffer + + # make sure the loop variable is a special one and raise a template + # assertion error if a loop tries to write to loop + if extended_loop: + self.writeline(f"{loop_ref} = missing") + + for name in node.find_all(nodes.Name): + if name.ctx == "store" and name.name == "loop": + self.fail( + "Can't assign to special loop variable in for-loop target", + name.lineno, + ) + + if node.else_: + iteration_indicator = self.temporary_identifier() + self.writeline(f"{iteration_indicator} = 1") + + self.writeline(self.choose_async("async for ", "for "), node) + self.visit(node.target, loop_frame) + if extended_loop: + self.write(f", {loop_ref} in {self.choose_async('Async')}LoopContext(") + else: + self.write(" in ") + + if node.test: + self.write(f"{loop_filter_func}(") + if node.recursive: + self.write("reciter") + else: + if self.environment.is_async and not extended_loop: + self.write("auto_aiter(") + self.visit(node.iter, frame) + if self.environment.is_async and not extended_loop: + self.write(")") + if node.test: + self.write(")") + + if node.recursive: + self.write(", undefined, loop_render_func, depth):") + else: + self.write(", undefined):" if extended_loop else ":") + + self.indent() + self.enter_frame(loop_frame) + + self.writeline("_loop_vars = {}") + self.blockvisit(node.body, loop_frame) + if node.else_: + self.writeline(f"{iteration_indicator} = 0") + self.outdent() + self.leave_frame( + loop_frame, with_python_scope=node.recursive and not node.else_ + ) + + if node.else_: + self.writeline(f"if {iteration_indicator}:") + self.indent() + self.enter_frame(else_frame) + self.blockvisit(node.else_, else_frame) + self.leave_frame(else_frame) + self.outdent() + + # if the node was recursive we have to return the buffer contents + # and start the iteration code + if node.recursive: + self.return_buffer_contents(loop_frame) + self.outdent() + self.start_write(frame, node) + self.write(f"{self.choose_async('await ')}loop(") + if self.environment.is_async: + self.write("auto_aiter(") + self.visit(node.iter, frame) + if self.environment.is_async: + self.write(")") + self.write(", loop)") + self.end_write(frame) + + # at the end of the iteration, clear any assignments made in the + # loop from the top level + if self._assign_stack: + self._assign_stack[-1].difference_update(loop_frame.symbols.stores) + + def visit_If(self, node: nodes.If, frame: Frame) -> None: + if_frame = frame.soft() + self.writeline("if ", node) + self.visit(node.test, if_frame) + self.write(":") + self.indent() + self.blockvisit(node.body, if_frame) + self.outdent() + for elif_ in node.elif_: + self.writeline("elif ", elif_) + self.visit(elif_.test, if_frame) + self.write(":") + self.indent() + self.blockvisit(elif_.body, if_frame) + self.outdent() + if node.else_: + self.writeline("else:") + self.indent() + self.blockvisit(node.else_, if_frame) + self.outdent() + + def visit_Macro(self, node: nodes.Macro, frame: Frame) -> None: + macro_frame, macro_ref = self.macro_body(node, frame) + self.newline() + if frame.toplevel: + if not node.name.startswith("_"): + self.write(f"context.exported_vars.add({node.name!r})") + self.writeline(f"context.vars[{node.name!r}] = ") + self.write(f"{frame.symbols.ref(node.name)} = ") + self.macro_def(macro_ref, macro_frame) + + def visit_CallBlock(self, node: nodes.CallBlock, frame: Frame) -> None: + call_frame, macro_ref = self.macro_body(node, frame) + self.writeline("caller = ") + self.macro_def(macro_ref, call_frame) + self.start_write(frame, node) + self.visit_Call(node.call, frame, forward_caller=True) + self.end_write(frame) + + def visit_FilterBlock(self, node: nodes.FilterBlock, frame: Frame) -> None: + filter_frame = frame.inner() + filter_frame.symbols.analyze_node(node) + self.enter_frame(filter_frame) + self.buffer(filter_frame) + self.blockvisit(node.body, filter_frame) + self.start_write(frame, node) + self.visit_Filter(node.filter, filter_frame) + self.end_write(frame) + self.leave_frame(filter_frame) + + def visit_With(self, node: nodes.With, frame: Frame) -> None: + with_frame = frame.inner() + with_frame.symbols.analyze_node(node) + self.enter_frame(with_frame) + for target, expr in zip(node.targets, node.values): + self.newline() + self.visit(target, with_frame) + self.write(" = ") + self.visit(expr, frame) + self.blockvisit(node.body, with_frame) + self.leave_frame(with_frame) + + def visit_ExprStmt(self, node: nodes.ExprStmt, frame: Frame) -> None: + self.newline(node) + self.visit(node.node, frame) + + class _FinalizeInfo(t.NamedTuple): + const: t.Optional[t.Callable[..., str]] + src: t.Optional[str] + + @staticmethod + def _default_finalize(value: t.Any) -> t.Any: + """The default finalize function if the environment isn't + configured with one. Or, if the environment has one, this is + called on that function's output for constants. + """ + return str(value) + + _finalize: t.Optional[_FinalizeInfo] = None + + def _make_finalize(self) -> _FinalizeInfo: + """Build the finalize function to be used on constants and at + runtime. Cached so it's only created once for all output nodes. + + Returns a ``namedtuple`` with the following attributes: + + ``const`` + A function to finalize constant data at compile time. + + ``src`` + Source code to output around nodes to be evaluated at + runtime. + """ + if self._finalize is not None: + return self._finalize + + finalize: t.Optional[t.Callable[..., t.Any]] + finalize = default = self._default_finalize + src = None + + if self.environment.finalize: + src = "environment.finalize(" + env_finalize = self.environment.finalize + pass_arg = { + _PassArg.context: "context", + _PassArg.eval_context: "context.eval_ctx", + _PassArg.environment: "environment", + }.get( + _PassArg.from_obj(env_finalize) # type: ignore + ) + finalize = None + + if pass_arg is None: + + def finalize(value: t.Any) -> t.Any: + return default(env_finalize(value)) + + else: + src = f"{src}{pass_arg}, " + + if pass_arg == "environment": + + def finalize(value: t.Any) -> t.Any: + return default(env_finalize(self.environment, value)) + + self._finalize = self._FinalizeInfo(finalize, src) + return self._finalize + + def _output_const_repr(self, group: t.Iterable[t.Any]) -> str: + """Given a group of constant values converted from ``Output`` + child nodes, produce a string to write to the template module + source. + """ + return repr(concat(group)) + + def _output_child_to_const( + self, node: nodes.Expr, frame: Frame, finalize: _FinalizeInfo + ) -> str: + """Try to optimize a child of an ``Output`` node by trying to + convert it to constant, finalized data at compile time. + + If :exc:`Impossible` is raised, the node is not constant and + will be evaluated at runtime. Any other exception will also be + evaluated at runtime for easier debugging. + """ + const = node.as_const(frame.eval_ctx) + + if frame.eval_ctx.autoescape: + const = escape(const) + + # Template data doesn't go through finalize. + if isinstance(node, nodes.TemplateData): + return str(const) + + return finalize.const(const) # type: ignore + + def _output_child_pre( + self, node: nodes.Expr, frame: Frame, finalize: _FinalizeInfo + ) -> None: + """Output extra source code before visiting a child of an + ``Output`` node. + """ + if frame.eval_ctx.volatile: + self.write("(escape if context.eval_ctx.autoescape else str)(") + elif frame.eval_ctx.autoescape: + self.write("escape(") + else: + self.write("str(") + + if finalize.src is not None: + self.write(finalize.src) + + def _output_child_post( + self, node: nodes.Expr, frame: Frame, finalize: _FinalizeInfo + ) -> None: + """Output extra source code after visiting a child of an + ``Output`` node. + """ + self.write(")") + + if finalize.src is not None: + self.write(")") + + def visit_Output(self, node: nodes.Output, frame: Frame) -> None: + # If an extends is active, don't render outside a block. + if frame.require_output_check: + # A top-level extends is known to exist at compile time. + if self.has_known_extends: + return + + self.writeline("if parent_template is None:") + self.indent() + + finalize = self._make_finalize() + body: t.List[t.Union[t.List[t.Any], nodes.Expr]] = [] + + # Evaluate constants at compile time if possible. Each item in + # body will be either a list of static data or a node to be + # evaluated at runtime. + for child in node.nodes: + try: + if not ( + # If the finalize function requires runtime context, + # constants can't be evaluated at compile time. + finalize.const + # Unless it's basic template data that won't be + # finalized anyway. + or isinstance(child, nodes.TemplateData) + ): + raise nodes.Impossible() + + const = self._output_child_to_const(child, frame, finalize) + except (nodes.Impossible, Exception): + # The node was not constant and needs to be evaluated at + # runtime. Or another error was raised, which is easier + # to debug at runtime. + body.append(child) + continue + + if body and isinstance(body[-1], list): + body[-1].append(const) + else: + body.append([const]) + + if frame.buffer is not None: + if len(body) == 1: + self.writeline(f"{frame.buffer}.append(") + else: + self.writeline(f"{frame.buffer}.extend((") + + self.indent() + + for item in body: + if isinstance(item, list): + # A group of constant data to join and output. + val = self._output_const_repr(item) + + if frame.buffer is None: + self.writeline("yield " + val) + else: + self.writeline(val + ",") + else: + if frame.buffer is None: + self.writeline("yield ", item) + else: + self.newline(item) + + # A node to be evaluated at runtime. + self._output_child_pre(item, frame, finalize) + self.visit(item, frame) + self._output_child_post(item, frame, finalize) + + if frame.buffer is not None: + self.write(",") + + if frame.buffer is not None: + self.outdent() + self.writeline(")" if len(body) == 1 else "))") + + if frame.require_output_check: + self.outdent() + + def visit_Assign(self, node: nodes.Assign, frame: Frame) -> None: + self.push_assign_tracking() + self.newline(node) + self.visit(node.target, frame) + self.write(" = ") + self.visit(node.node, frame) + self.pop_assign_tracking(frame) + + def visit_AssignBlock(self, node: nodes.AssignBlock, frame: Frame) -> None: + self.push_assign_tracking() + block_frame = frame.inner() + # This is a special case. Since a set block always captures we + # will disable output checks. This way one can use set blocks + # toplevel even in extended templates. + block_frame.require_output_check = False + block_frame.symbols.analyze_node(node) + self.enter_frame(block_frame) + self.buffer(block_frame) + self.blockvisit(node.body, block_frame) + self.newline(node) + self.visit(node.target, frame) + self.write(" = (Markup if context.eval_ctx.autoescape else identity)(") + if node.filter is not None: + self.visit_Filter(node.filter, block_frame) + else: + self.write(f"concat({block_frame.buffer})") + self.write(")") + self.pop_assign_tracking(frame) + self.leave_frame(block_frame) + + # -- Expression Visitors + + def visit_Name(self, node: nodes.Name, frame: Frame) -> None: + if node.ctx == "store" and ( + frame.toplevel or frame.loop_frame or frame.block_frame + ): + if self._assign_stack: + self._assign_stack[-1].add(node.name) + ref = frame.symbols.ref(node.name) + + # If we are looking up a variable we might have to deal with the + # case where it's undefined. We can skip that case if the load + # instruction indicates a parameter which are always defined. + if node.ctx == "load": + load = frame.symbols.find_load(ref) + if not ( + load is not None + and load[0] == VAR_LOAD_PARAMETER + and not self.parameter_is_undeclared(ref) + ): + self.write( + f"(undefined(name={node.name!r}) if {ref} is missing else {ref})" + ) + return + + self.write(ref) + + def visit_NSRef(self, node: nodes.NSRef, frame: Frame) -> None: + # NSRefs can only be used to store values; since they use the normal + # `foo.bar` notation they will be parsed as a normal attribute access + # when used anywhere but in a `set` context + ref = frame.symbols.ref(node.name) + self.writeline(f"if not isinstance({ref}, Namespace):") + self.indent() + self.writeline( + "raise TemplateRuntimeError" + '("cannot assign attribute on non-namespace object")' + ) + self.outdent() + self.writeline(f"{ref}[{node.attr!r}]") + + def visit_Const(self, node: nodes.Const, frame: Frame) -> None: + val = node.as_const(frame.eval_ctx) + if isinstance(val, float): + self.write(str(val)) + else: + self.write(repr(val)) + + def visit_TemplateData(self, node: nodes.TemplateData, frame: Frame) -> None: + try: + self.write(repr(node.as_const(frame.eval_ctx))) + except nodes.Impossible: + self.write( + f"(Markup if context.eval_ctx.autoescape else identity)({node.data!r})" + ) + + def visit_Tuple(self, node: nodes.Tuple, frame: Frame) -> None: + self.write("(") + idx = -1 + for idx, item in enumerate(node.items): + if idx: + self.write(", ") + self.visit(item, frame) + self.write(",)" if idx == 0 else ")") + + def visit_List(self, node: nodes.List, frame: Frame) -> None: + self.write("[") + for idx, item in enumerate(node.items): + if idx: + self.write(", ") + self.visit(item, frame) + self.write("]") + + def visit_Dict(self, node: nodes.Dict, frame: Frame) -> None: + self.write("{") + for idx, item in enumerate(node.items): + if idx: + self.write(", ") + self.visit(item.key, frame) + self.write(": ") + self.visit(item.value, frame) + self.write("}") + + visit_Add = _make_binop("+") + visit_Sub = _make_binop("-") + visit_Mul = _make_binop("*") + visit_Div = _make_binop("/") + visit_FloorDiv = _make_binop("//") + visit_Pow = _make_binop("**") + visit_Mod = _make_binop("%") + visit_And = _make_binop("and") + visit_Or = _make_binop("or") + visit_Pos = _make_unop("+") + visit_Neg = _make_unop("-") + visit_Not = _make_unop("not ") + + @optimizeconst + def visit_Concat(self, node: nodes.Concat, frame: Frame) -> None: + if frame.eval_ctx.volatile: + func_name = "(markup_join if context.eval_ctx.volatile else str_join)" + elif frame.eval_ctx.autoescape: + func_name = "markup_join" + else: + func_name = "str_join" + self.write(f"{func_name}((") + for arg in node.nodes: + self.visit(arg, frame) + self.write(", ") + self.write("))") + + @optimizeconst + def visit_Compare(self, node: nodes.Compare, frame: Frame) -> None: + self.write("(") + self.visit(node.expr, frame) + for op in node.ops: + self.visit(op, frame) + self.write(")") + + def visit_Operand(self, node: nodes.Operand, frame: Frame) -> None: + self.write(f" {operators[node.op]} ") + self.visit(node.expr, frame) + + @optimizeconst + def visit_Getattr(self, node: nodes.Getattr, frame: Frame) -> None: + if self.environment.is_async: + self.write("(await auto_await(") + + self.write("environment.getattr(") + self.visit(node.node, frame) + self.write(f", {node.attr!r})") + + if self.environment.is_async: + self.write("))") + + @optimizeconst + def visit_Getitem(self, node: nodes.Getitem, frame: Frame) -> None: + # slices bypass the environment getitem method. + if isinstance(node.arg, nodes.Slice): + self.visit(node.node, frame) + self.write("[") + self.visit(node.arg, frame) + self.write("]") + else: + if self.environment.is_async: + self.write("(await auto_await(") + + self.write("environment.getitem(") + self.visit(node.node, frame) + self.write(", ") + self.visit(node.arg, frame) + self.write(")") + + if self.environment.is_async: + self.write("))") + + def visit_Slice(self, node: nodes.Slice, frame: Frame) -> None: + if node.start is not None: + self.visit(node.start, frame) + self.write(":") + if node.stop is not None: + self.visit(node.stop, frame) + if node.step is not None: + self.write(":") + self.visit(node.step, frame) + + @contextmanager + def _filter_test_common( + self, node: t.Union[nodes.Filter, nodes.Test], frame: Frame, is_filter: bool + ) -> t.Iterator[None]: + if self.environment.is_async: + self.write("(await auto_await(") + + if is_filter: + self.write(f"{self.filters[node.name]}(") + func = self.environment.filters.get(node.name) + else: + self.write(f"{self.tests[node.name]}(") + func = self.environment.tests.get(node.name) + + # When inside an If or CondExpr frame, allow the filter to be + # undefined at compile time and only raise an error if it's + # actually called at runtime. See pull_dependencies. + if func is None and not frame.soft_frame: + type_name = "filter" if is_filter else "test" + self.fail(f"No {type_name} named {node.name!r}.", node.lineno) + + pass_arg = { + _PassArg.context: "context", + _PassArg.eval_context: "context.eval_ctx", + _PassArg.environment: "environment", + }.get( + _PassArg.from_obj(func) # type: ignore + ) + + if pass_arg is not None: + self.write(f"{pass_arg}, ") + + # Back to the visitor function to handle visiting the target of + # the filter or test. + yield + + self.signature(node, frame) + self.write(")") + + if self.environment.is_async: + self.write("))") + + @optimizeconst + def visit_Filter(self, node: nodes.Filter, frame: Frame) -> None: + with self._filter_test_common(node, frame, True): + # if the filter node is None we are inside a filter block + # and want to write to the current buffer + if node.node is not None: + self.visit(node.node, frame) + elif frame.eval_ctx.volatile: + self.write( + f"(Markup(concat({frame.buffer}))" + f" if context.eval_ctx.autoescape else concat({frame.buffer}))" + ) + elif frame.eval_ctx.autoescape: + self.write(f"Markup(concat({frame.buffer}))") + else: + self.write(f"concat({frame.buffer})") + + @optimizeconst + def visit_Test(self, node: nodes.Test, frame: Frame) -> None: + with self._filter_test_common(node, frame, False): + self.visit(node.node, frame) + + @optimizeconst + def visit_CondExpr(self, node: nodes.CondExpr, frame: Frame) -> None: + frame = frame.soft() + + def write_expr2() -> None: + if node.expr2 is not None: + self.visit(node.expr2, frame) + return + + self.write( + f'cond_expr_undefined("the inline if-expression on' + f" {self.position(node)} evaluated to false and no else" + f' section was defined.")' + ) + + self.write("(") + self.visit(node.expr1, frame) + self.write(" if ") + self.visit(node.test, frame) + self.write(" else ") + write_expr2() + self.write(")") + + @optimizeconst + def visit_Call( + self, node: nodes.Call, frame: Frame, forward_caller: bool = False + ) -> None: + if self.environment.is_async: + self.write("(await auto_await(") + if self.environment.sandboxed: + self.write("environment.call(context, ") + else: + self.write("context.call(") + self.visit(node.node, frame) + extra_kwargs = {"caller": "caller"} if forward_caller else None + loop_kwargs = {"_loop_vars": "_loop_vars"} if frame.loop_frame else {} + block_kwargs = {"_block_vars": "_block_vars"} if frame.block_frame else {} + if extra_kwargs: + extra_kwargs.update(loop_kwargs, **block_kwargs) + elif loop_kwargs or block_kwargs: + extra_kwargs = dict(loop_kwargs, **block_kwargs) + self.signature(node, frame, extra_kwargs) + self.write(")") + if self.environment.is_async: + self.write("))") + + def visit_Keyword(self, node: nodes.Keyword, frame: Frame) -> None: + self.write(node.key + "=") + self.visit(node.value, frame) + + # -- Unused nodes for extensions + + def visit_MarkSafe(self, node: nodes.MarkSafe, frame: Frame) -> None: + self.write("Markup(") + self.visit(node.expr, frame) + self.write(")") + + def visit_MarkSafeIfAutoescape( + self, node: nodes.MarkSafeIfAutoescape, frame: Frame + ) -> None: + self.write("(Markup if context.eval_ctx.autoescape else identity)(") + self.visit(node.expr, frame) + self.write(")") + + def visit_EnvironmentAttribute( + self, node: nodes.EnvironmentAttribute, frame: Frame + ) -> None: + self.write("environment." + node.name) + + def visit_ExtensionAttribute( + self, node: nodes.ExtensionAttribute, frame: Frame + ) -> None: + self.write(f"environment.extensions[{node.identifier!r}].{node.name}") + + def visit_ImportedName(self, node: nodes.ImportedName, frame: Frame) -> None: + self.write(self.import_aliases[node.importname]) + + def visit_InternalName(self, node: nodes.InternalName, frame: Frame) -> None: + self.write(node.name) + + def visit_ContextReference( + self, node: nodes.ContextReference, frame: Frame + ) -> None: + self.write("context") + + def visit_DerivedContextReference( + self, node: nodes.DerivedContextReference, frame: Frame + ) -> None: + self.write(self.derive_context(frame)) + + def visit_Continue(self, node: nodes.Continue, frame: Frame) -> None: + self.writeline("continue", node) + + def visit_Break(self, node: nodes.Break, frame: Frame) -> None: + self.writeline("break", node) + + def visit_Scope(self, node: nodes.Scope, frame: Frame) -> None: + scope_frame = frame.inner() + scope_frame.symbols.analyze_node(node) + self.enter_frame(scope_frame) + self.blockvisit(node.body, scope_frame) + self.leave_frame(scope_frame) + + def visit_OverlayScope(self, node: nodes.OverlayScope, frame: Frame) -> None: + ctx = self.temporary_identifier() + self.writeline(f"{ctx} = {self.derive_context(frame)}") + self.writeline(f"{ctx}.vars = ") + self.visit(node.context, frame) + self.push_context_reference(ctx) + + scope_frame = frame.inner(isolated=True) + scope_frame.symbols.analyze_node(node) + self.enter_frame(scope_frame) + self.blockvisit(node.body, scope_frame) + self.leave_frame(scope_frame) + self.pop_context_reference() + + def visit_EvalContextModifier( + self, node: nodes.EvalContextModifier, frame: Frame + ) -> None: + for keyword in node.options: + self.writeline(f"context.eval_ctx.{keyword.key} = ") + self.visit(keyword.value, frame) + try: + val = keyword.value.as_const(frame.eval_ctx) + except nodes.Impossible: + frame.eval_ctx.volatile = True + else: + setattr(frame.eval_ctx, keyword.key, val) + + def visit_ScopedEvalContextModifier( + self, node: nodes.ScopedEvalContextModifier, frame: Frame + ) -> None: + old_ctx_name = self.temporary_identifier() + saved_ctx = frame.eval_ctx.save() + self.writeline(f"{old_ctx_name} = context.eval_ctx.save()") + self.visit_EvalContextModifier(node, frame) + for child in node.body: + self.visit(child, frame) + frame.eval_ctx.revert(saved_ctx) + self.writeline(f"context.eval_ctx.revert({old_ctx_name})") diff --git a/src/jinja2/constants.py b/src/jinja2/constants.py new file mode 100644 index 0000000..41a1c23 --- /dev/null +++ b/src/jinja2/constants.py @@ -0,0 +1,20 @@ +#: list of lorem ipsum words used by the lipsum() helper function +LOREM_IPSUM_WORDS = """\ +a ac accumsan ad adipiscing aenean aliquam aliquet amet ante aptent arcu at +auctor augue bibendum blandit class commodo condimentum congue consectetuer +consequat conubia convallis cras cubilia cum curabitur curae cursus dapibus +diam dictum dictumst dignissim dis dolor donec dui duis egestas eget eleifend +elementum elit enim erat eros est et etiam eu euismod facilisi facilisis fames +faucibus felis fermentum feugiat fringilla fusce gravida habitant habitasse hac +hendrerit hymenaeos iaculis id imperdiet in inceptos integer interdum ipsum +justo lacinia lacus laoreet lectus leo libero ligula litora lobortis lorem +luctus maecenas magna magnis malesuada massa mattis mauris metus mi molestie +mollis montes morbi mus nam nascetur natoque nec neque netus nibh nisi nisl non +nonummy nostra nulla nullam nunc odio orci ornare parturient pede pellentesque +penatibus per pharetra phasellus placerat platea porta porttitor posuere +potenti praesent pretium primis proin pulvinar purus quam quis quisque rhoncus +ridiculus risus rutrum sagittis sapien scelerisque sed sem semper senectus sit +sociis sociosqu sodales sollicitudin suscipit suspendisse taciti tellus tempor +tempus tincidunt torquent tortor tristique turpis ullamcorper ultrices +ultricies urna ut varius vehicula vel velit venenatis vestibulum vitae vivamus +viverra volutpat vulputate""" diff --git a/src/jinja2/debug.py b/src/jinja2/debug.py new file mode 100644 index 0000000..7ed7e92 --- /dev/null +++ b/src/jinja2/debug.py @@ -0,0 +1,191 @@ +import sys +import typing as t +from types import CodeType +from types import TracebackType + +from .exceptions import TemplateSyntaxError +from .utils import internal_code +from .utils import missing + +if t.TYPE_CHECKING: + from .runtime import Context + + +def rewrite_traceback_stack(source: t.Optional[str] = None) -> BaseException: + """Rewrite the current exception to replace any tracebacks from + within compiled template code with tracebacks that look like they + came from the template source. + + This must be called within an ``except`` block. + + :param source: For ``TemplateSyntaxError``, the original source if + known. + :return: The original exception with the rewritten traceback. + """ + _, exc_value, tb = sys.exc_info() + exc_value = t.cast(BaseException, exc_value) + tb = t.cast(TracebackType, tb) + + if isinstance(exc_value, TemplateSyntaxError) and not exc_value.translated: + exc_value.translated = True + exc_value.source = source + # Remove the old traceback, otherwise the frames from the + # compiler still show up. + exc_value.with_traceback(None) + # Outside of runtime, so the frame isn't executing template + # code, but it still needs to point at the template. + tb = fake_traceback( + exc_value, None, exc_value.filename or "<unknown>", exc_value.lineno + ) + else: + # Skip the frame for the render function. + tb = tb.tb_next + + stack = [] + + # Build the stack of traceback object, replacing any in template + # code with the source file and line information. + while tb is not None: + # Skip frames decorated with @internalcode. These are internal + # calls that aren't useful in template debugging output. + if tb.tb_frame.f_code in internal_code: + tb = tb.tb_next + continue + + template = tb.tb_frame.f_globals.get("__jinja_template__") + + if template is not None: + lineno = template.get_corresponding_lineno(tb.tb_lineno) + fake_tb = fake_traceback(exc_value, tb, template.filename, lineno) + stack.append(fake_tb) + else: + stack.append(tb) + + tb = tb.tb_next + + tb_next = None + + # Assign tb_next in reverse to avoid circular references. + for tb in reversed(stack): + tb.tb_next = tb_next + tb_next = tb + + return exc_value.with_traceback(tb_next) + + +def fake_traceback( # type: ignore + exc_value: BaseException, tb: t.Optional[TracebackType], filename: str, lineno: int +) -> TracebackType: + """Produce a new traceback object that looks like it came from the + template source instead of the compiled code. The filename, line + number, and location name will point to the template, and the local + variables will be the current template context. + + :param exc_value: The original exception to be re-raised to create + the new traceback. + :param tb: The original traceback to get the local variables and + code info from. + :param filename: The template filename. + :param lineno: The line number in the template source. + """ + if tb is not None: + # Replace the real locals with the context that would be + # available at that point in the template. + locals = get_template_locals(tb.tb_frame.f_locals) + locals.pop("__jinja_exception__", None) + else: + locals = {} + + globals = { + "__name__": filename, + "__file__": filename, + "__jinja_exception__": exc_value, + } + # Raise an exception at the correct line number. + code: CodeType = compile( + "\n" * (lineno - 1) + "raise __jinja_exception__", filename, "exec" + ) + + # Build a new code object that points to the template file and + # replaces the location with a block name. + location = "template" + + if tb is not None: + function = tb.tb_frame.f_code.co_name + + if function == "root": + location = "top-level template code" + elif function.startswith("block_"): + location = f"block {function[6:]!r}" + + if sys.version_info >= (3, 8): + code = code.replace(co_name=location) + else: + code = CodeType( + code.co_argcount, + code.co_kwonlyargcount, + code.co_nlocals, + code.co_stacksize, + code.co_flags, + code.co_code, + code.co_consts, + code.co_names, + code.co_varnames, + code.co_filename, + location, + code.co_firstlineno, + code.co_lnotab, + code.co_freevars, + code.co_cellvars, + ) + + # Execute the new code, which is guaranteed to raise, and return + # the new traceback without this frame. + try: + exec(code, globals, locals) + except BaseException: + return sys.exc_info()[2].tb_next # type: ignore + + +def get_template_locals(real_locals: t.Mapping[str, t.Any]) -> t.Dict[str, t.Any]: + """Based on the runtime locals, get the context that would be + available at that point in the template. + """ + # Start with the current template context. + ctx: "t.Optional[Context]" = real_locals.get("context") + + if ctx is not None: + data: t.Dict[str, t.Any] = ctx.get_all().copy() + else: + data = {} + + # Might be in a derived context that only sets local variables + # rather than pushing a context. Local variables follow the scheme + # l_depth_name. Find the highest-depth local that has a value for + # each name. + local_overrides: t.Dict[str, t.Tuple[int, t.Any]] = {} + + for name, value in real_locals.items(): + if not name.startswith("l_") or value is missing: + # Not a template variable, or no longer relevant. + continue + + try: + _, depth_str, name = name.split("_", 2) + depth = int(depth_str) + except ValueError: + continue + + cur_depth = local_overrides.get(name, (-1,))[0] + + if cur_depth < depth: + local_overrides[name] = (depth, value) + + # Modify the context with any derived context. + for name, (_, value) in local_overrides.items(): + if value is missing: + data.pop(name, None) + else: + data[name] = value + + return data diff --git a/src/jinja2/defaults.py b/src/jinja2/defaults.py new file mode 100644 index 0000000..638cad3 --- /dev/null +++ b/src/jinja2/defaults.py @@ -0,0 +1,48 @@ +import typing as t + +from .filters import FILTERS as DEFAULT_FILTERS # noqa: F401 +from .tests import TESTS as DEFAULT_TESTS # noqa: F401 +from .utils import Cycler +from .utils import generate_lorem_ipsum +from .utils import Joiner +from .utils import Namespace + +if t.TYPE_CHECKING: + import typing_extensions as te + +# defaults for the parser / lexer +BLOCK_START_STRING = "{%" +BLOCK_END_STRING = "%}" +VARIABLE_START_STRING = "{{" +VARIABLE_END_STRING = "}}" +COMMENT_START_STRING = "{#" +COMMENT_END_STRING = "#}" +LINE_STATEMENT_PREFIX: t.Optional[str] = None +LINE_COMMENT_PREFIX: t.Optional[str] = None +TRIM_BLOCKS = False +LSTRIP_BLOCKS = False +NEWLINE_SEQUENCE: "te.Literal['\\n', '\\r\\n', '\\r']" = "\n" +KEEP_TRAILING_NEWLINE = False + +# default filters, tests and namespace + +DEFAULT_NAMESPACE = { + "range": range, + "dict": dict, + "lipsum": generate_lorem_ipsum, + "cycler": Cycler, + "joiner": Joiner, + "namespace": Namespace, +} + +# default policies +DEFAULT_POLICIES: t.Dict[str, t.Any] = { + "compiler.ascii_str": True, + "urlize.rel": "noopener", + "urlize.target": None, + "urlize.extra_schemes": None, + "truncate.leeway": 5, + "json.dumps_function": None, + "json.dumps_kwargs": {"sort_keys": True}, + "ext.i18n.trimmed": False, +} diff --git a/src/jinja2/environment.py b/src/jinja2/environment.py new file mode 100644 index 0000000..ea04e8b --- /dev/null +++ b/src/jinja2/environment.py @@ -0,0 +1,1667 @@ +"""Classes for managing templates and their runtime and compile time +options. +""" +import os +import typing +import typing as t +import weakref +from collections import ChainMap +from functools import lru_cache +from functools import partial +from functools import reduce +from types import CodeType + +from markupsafe import Markup + +from . import nodes +from .compiler import CodeGenerator +from .compiler import generate +from .defaults import BLOCK_END_STRING +from .defaults import BLOCK_START_STRING +from .defaults import COMMENT_END_STRING +from .defaults import COMMENT_START_STRING +from .defaults import DEFAULT_FILTERS +from .defaults import DEFAULT_NAMESPACE +from .defaults import DEFAULT_POLICIES +from .defaults import DEFAULT_TESTS +from .defaults import KEEP_TRAILING_NEWLINE +from .defaults import LINE_COMMENT_PREFIX +from .defaults import LINE_STATEMENT_PREFIX +from .defaults import LSTRIP_BLOCKS +from .defaults import NEWLINE_SEQUENCE +from .defaults import TRIM_BLOCKS +from .defaults import VARIABLE_END_STRING +from .defaults import VARIABLE_START_STRING +from .exceptions import TemplateNotFound +from .exceptions import TemplateRuntimeError +from .exceptions import TemplatesNotFound +from .exceptions import TemplateSyntaxError +from .exceptions import UndefinedError +from .lexer import get_lexer +from .lexer import Lexer +from .lexer import TokenStream +from .nodes import EvalContext +from .parser import Parser +from .runtime import Context +from .runtime import new_context +from .runtime import Undefined +from .utils import _PassArg +from .utils import concat +from .utils import consume +from .utils import import_string +from .utils import internalcode +from .utils import LRUCache +from .utils import missing + +if t.TYPE_CHECKING: + import typing_extensions as te + from .bccache import BytecodeCache + from .ext import Extension + from .loaders import BaseLoader + +_env_bound = t.TypeVar("_env_bound", bound="Environment") + + +# for direct template usage we have up to ten living environments +@lru_cache(maxsize=10) +def get_spontaneous_environment(cls: t.Type[_env_bound], *args: t.Any) -> _env_bound: + """Return a new spontaneous environment. A spontaneous environment + is used for templates created directly rather than through an + existing environment. + + :param cls: Environment class to create. + :param args: Positional arguments passed to environment. + """ + env = cls(*args) + env.shared = True + return env + + +def create_cache( + size: int, +) -> t.Optional[t.MutableMapping[t.Tuple[weakref.ref, str], "Template"]]: + """Return the cache class for the given size.""" + if size == 0: + return None + + if size < 0: + return {} + + return LRUCache(size) # type: ignore + + +def copy_cache( + cache: t.Optional[t.MutableMapping], +) -> t.Optional[t.MutableMapping[t.Tuple[weakref.ref, str], "Template"]]: + """Create an empty copy of the given cache.""" + if cache is None: + return None + + if type(cache) is dict: + return {} + + return LRUCache(cache.capacity) # type: ignore + + +def load_extensions( + environment: "Environment", + extensions: t.Sequence[t.Union[str, t.Type["Extension"]]], +) -> t.Dict[str, "Extension"]: + """Load the extensions from the list and bind it to the environment. + Returns a dict of instantiated extensions. + """ + result = {} + + for extension in extensions: + if isinstance(extension, str): + extension = t.cast(t.Type["Extension"], import_string(extension)) + + result[extension.identifier] = extension(environment) + + return result + + +def _environment_config_check(environment: "Environment") -> "Environment": + """Perform a sanity check on the environment.""" + assert issubclass( + environment.undefined, Undefined + ), "'undefined' must be a subclass of 'jinja2.Undefined'." + assert ( + environment.block_start_string + != environment.variable_start_string + != environment.comment_start_string + ), "block, variable and comment start strings must be different." + assert environment.newline_sequence in { + "\r", + "\r\n", + "\n", + }, "'newline_sequence' must be one of '\\n', '\\r\\n', or '\\r'." + return environment + + +class Environment: + r"""The core component of Jinja is the `Environment`. It contains + important shared variables like configuration, filters, tests, + globals and others. Instances of this class may be modified if + they are not shared and if no template was loaded so far. + Modifications on environments after the first template was loaded + will lead to surprising effects and undefined behavior. + + Here are the possible initialization parameters: + + `block_start_string` + The string marking the beginning of a block. Defaults to ``'{%'``. + + `block_end_string` + The string marking the end of a block. Defaults to ``'%}'``. + + `variable_start_string` + The string marking the beginning of a print statement. + Defaults to ``'{{'``. + + `variable_end_string` + The string marking the end of a print statement. Defaults to + ``'}}'``. + + `comment_start_string` + The string marking the beginning of a comment. Defaults to ``'{#'``. + + `comment_end_string` + The string marking the end of a comment. Defaults to ``'#}'``. + + `line_statement_prefix` + If given and a string, this will be used as prefix for line based + statements. See also :ref:`line-statements`. + + `line_comment_prefix` + If given and a string, this will be used as prefix for line based + comments. See also :ref:`line-statements`. + + .. versionadded:: 2.2 + + `trim_blocks` + If this is set to ``True`` the first newline after a block is + removed (block, not variable tag!). Defaults to `False`. + + `lstrip_blocks` + If this is set to ``True`` leading spaces and tabs are stripped + from the start of a line to a block. Defaults to `False`. + + `newline_sequence` + The sequence that starts a newline. Must be one of ``'\r'``, + ``'\n'`` or ``'\r\n'``. The default is ``'\n'`` which is a + useful default for Linux and OS X systems as well as web + applications. + + `keep_trailing_newline` + Preserve the trailing newline when rendering templates. + The default is ``False``, which causes a single newline, + if present, to be stripped from the end of the template. + + .. versionadded:: 2.7 + + `extensions` + List of Jinja extensions to use. This can either be import paths + as strings or extension classes. For more information have a + look at :ref:`the extensions documentation <jinja-extensions>`. + + `optimized` + should the optimizer be enabled? Default is ``True``. + + `undefined` + :class:`Undefined` or a subclass of it that is used to represent + undefined values in the template. + + `finalize` + A callable that can be used to process the result of a variable + expression before it is output. For example one can convert + ``None`` implicitly into an empty string here. + + `autoescape` + If set to ``True`` the XML/HTML autoescaping feature is enabled by + default. For more details about autoescaping see + :class:`~markupsafe.Markup`. As of Jinja 2.4 this can also + be a callable that is passed the template name and has to + return ``True`` or ``False`` depending on autoescape should be + enabled by default. + + .. versionchanged:: 2.4 + `autoescape` can now be a function + + `loader` + The template loader for this environment. + + `cache_size` + The size of the cache. Per default this is ``400`` which means + that if more than 400 templates are loaded the loader will clean + out the least recently used template. If the cache size is set to + ``0`` templates are recompiled all the time, if the cache size is + ``-1`` the cache will not be cleaned. + + .. versionchanged:: 2.8 + The cache size was increased to 400 from a low 50. + + `auto_reload` + Some loaders load templates from locations where the template + sources may change (ie: file system or database). If + ``auto_reload`` is set to ``True`` (default) every time a template is + requested the loader checks if the source changed and if yes, it + will reload the template. For higher performance it's possible to + disable that. + + `bytecode_cache` + If set to a bytecode cache object, this object will provide a + cache for the internal Jinja bytecode so that templates don't + have to be parsed if they were not changed. + + See :ref:`bytecode-cache` for more information. + + `enable_async` + If set to true this enables async template execution which + allows using async functions and generators. + """ + + #: if this environment is sandboxed. Modifying this variable won't make + #: the environment sandboxed though. For a real sandboxed environment + #: have a look at jinja2.sandbox. This flag alone controls the code + #: generation by the compiler. + sandboxed = False + + #: True if the environment is just an overlay + overlayed = False + + #: the environment this environment is linked to if it is an overlay + linked_to: t.Optional["Environment"] = None + + #: shared environments have this set to `True`. A shared environment + #: must not be modified + shared = False + + #: the class that is used for code generation. See + #: :class:`~jinja2.compiler.CodeGenerator` for more information. + code_generator_class: t.Type["CodeGenerator"] = CodeGenerator + + concat = "".join + + #: the context class that is used for templates. See + #: :class:`~jinja2.runtime.Context` for more information. + context_class: t.Type[Context] = Context + + template_class: t.Type["Template"] + + def __init__( + self, + block_start_string: str = BLOCK_START_STRING, + block_end_string: str = BLOCK_END_STRING, + variable_start_string: str = VARIABLE_START_STRING, + variable_end_string: str = VARIABLE_END_STRING, + comment_start_string: str = COMMENT_START_STRING, + comment_end_string: str = COMMENT_END_STRING, + line_statement_prefix: t.Optional[str] = LINE_STATEMENT_PREFIX, + line_comment_prefix: t.Optional[str] = LINE_COMMENT_PREFIX, + trim_blocks: bool = TRIM_BLOCKS, + lstrip_blocks: bool = LSTRIP_BLOCKS, + newline_sequence: "te.Literal['\\n', '\\r\\n', '\\r']" = NEWLINE_SEQUENCE, + keep_trailing_newline: bool = KEEP_TRAILING_NEWLINE, + extensions: t.Sequence[t.Union[str, t.Type["Extension"]]] = (), + optimized: bool = True, + undefined: t.Type[Undefined] = Undefined, + finalize: t.Optional[t.Callable[..., t.Any]] = None, + autoescape: t.Union[bool, t.Callable[[t.Optional[str]], bool]] = False, + loader: t.Optional["BaseLoader"] = None, + cache_size: int = 400, + auto_reload: bool = True, + bytecode_cache: t.Optional["BytecodeCache"] = None, + enable_async: bool = False, + ): + # !!Important notice!! + # The constructor accepts quite a few arguments that should be + # passed by keyword rather than position. However it's important to + # not change the order of arguments because it's used at least + # internally in those cases: + # - spontaneous environments (i18n extension and Template) + # - unittests + # If parameter changes are required only add parameters at the end + # and don't change the arguments (or the defaults!) of the arguments + # existing already. + + # lexer / parser information + self.block_start_string = block_start_string + self.block_end_string = block_end_string + self.variable_start_string = variable_start_string + self.variable_end_string = variable_end_string + self.comment_start_string = comment_start_string + self.comment_end_string = comment_end_string + self.line_statement_prefix = line_statement_prefix + self.line_comment_prefix = line_comment_prefix + self.trim_blocks = trim_blocks + self.lstrip_blocks = lstrip_blocks + self.newline_sequence = newline_sequence + self.keep_trailing_newline = keep_trailing_newline + + # runtime information + self.undefined: t.Type[Undefined] = undefined + self.optimized = optimized + self.finalize = finalize + self.autoescape = autoescape + + # defaults + self.filters = DEFAULT_FILTERS.copy() + self.tests = DEFAULT_TESTS.copy() + self.globals = DEFAULT_NAMESPACE.copy() + + # set the loader provided + self.loader = loader + self.cache = create_cache(cache_size) + self.bytecode_cache = bytecode_cache + self.auto_reload = auto_reload + + # configurable policies + self.policies = DEFAULT_POLICIES.copy() + + # load extensions + self.extensions = load_extensions(self, extensions) + + self.is_async = enable_async + _environment_config_check(self) + + def add_extension(self, extension: t.Union[str, t.Type["Extension"]]) -> None: + """Adds an extension after the environment was created. + + .. versionadded:: 2.5 + """ + self.extensions.update(load_extensions(self, [extension])) + + def extend(self, **attributes: t.Any) -> None: + """Add the items to the instance of the environment if they do not exist + yet. This is used by :ref:`extensions <writing-extensions>` to register + callbacks and configuration values without breaking inheritance. + """ + for key, value in attributes.items(): + if not hasattr(self, key): + setattr(self, key, value) + + def overlay( + self, + block_start_string: str = missing, + block_end_string: str = missing, + variable_start_string: str = missing, + variable_end_string: str = missing, + comment_start_string: str = missing, + comment_end_string: str = missing, + line_statement_prefix: t.Optional[str] = missing, + line_comment_prefix: t.Optional[str] = missing, + trim_blocks: bool = missing, + lstrip_blocks: bool = missing, + newline_sequence: "te.Literal['\\n', '\\r\\n', '\\r']" = missing, + keep_trailing_newline: bool = missing, + extensions: t.Sequence[t.Union[str, t.Type["Extension"]]] = missing, + optimized: bool = missing, + undefined: t.Type[Undefined] = missing, + finalize: t.Optional[t.Callable[..., t.Any]] = missing, + autoescape: t.Union[bool, t.Callable[[t.Optional[str]], bool]] = missing, + loader: t.Optional["BaseLoader"] = missing, + cache_size: int = missing, + auto_reload: bool = missing, + bytecode_cache: t.Optional["BytecodeCache"] = missing, + enable_async: bool = False, + ) -> "Environment": + """Create a new overlay environment that shares all the data with the + current environment except for cache and the overridden attributes. + Extensions cannot be removed for an overlayed environment. An overlayed + environment automatically gets all the extensions of the environment it + is linked to plus optional extra extensions. + + Creating overlays should happen after the initial environment was set + up completely. Not all attributes are truly linked, some are just + copied over so modifications on the original environment may not shine + through. + + .. versionchanged:: 3.1.2 + Added the ``newline_sequence``,, ``keep_trailing_newline``, + and ``enable_async`` parameters to match ``__init__``. + """ + args = dict(locals()) + del args["self"], args["cache_size"], args["extensions"], args["enable_async"] + + rv = object.__new__(self.__class__) + rv.__dict__.update(self.__dict__) + rv.overlayed = True + rv.linked_to = self + + for key, value in args.items(): + if value is not missing: + setattr(rv, key, value) + + if cache_size is not missing: + rv.cache = create_cache(cache_size) + else: + rv.cache = copy_cache(self.cache) + + rv.extensions = {} + for key, value in self.extensions.items(): + rv.extensions[key] = value.bind(rv) + if extensions is not missing: + rv.extensions.update(load_extensions(rv, extensions)) + + if enable_async is not missing: + rv.is_async = enable_async + + return _environment_config_check(rv) + + @property + def lexer(self) -> Lexer: + """The lexer for this environment.""" + return get_lexer(self) + + def iter_extensions(self) -> t.Iterator["Extension"]: + """Iterates over the extensions by priority.""" + return iter(sorted(self.extensions.values(), key=lambda x: x.priority)) + + def getitem( + self, obj: t.Any, argument: t.Union[str, t.Any] + ) -> t.Union[t.Any, Undefined]: + """Get an item or attribute of an object but prefer the item.""" + try: + return obj[argument] + except (AttributeError, TypeError, LookupError): + if isinstance(argument, str): + try: + attr = str(argument) + except Exception: + pass + else: + try: + return getattr(obj, attr) + except AttributeError: + pass + return self.undefined(obj=obj, name=argument) + + def getattr(self, obj: t.Any, attribute: str) -> t.Any: + """Get an item or attribute of an object but prefer the attribute. + Unlike :meth:`getitem` the attribute *must* be a string. + """ + try: + return getattr(obj, attribute) + except AttributeError: + pass + try: + return obj[attribute] + except (TypeError, LookupError, AttributeError): + return self.undefined(obj=obj, name=attribute) + + def _filter_test_common( + self, + name: t.Union[str, Undefined], + value: t.Any, + args: t.Optional[t.Sequence[t.Any]], + kwargs: t.Optional[t.Mapping[str, t.Any]], + context: t.Optional[Context], + eval_ctx: t.Optional[EvalContext], + is_filter: bool, + ) -> t.Any: + if is_filter: + env_map = self.filters + type_name = "filter" + else: + env_map = self.tests + type_name = "test" + + func = env_map.get(name) # type: ignore + + if func is None: + msg = f"No {type_name} named {name!r}." + + if isinstance(name, Undefined): + try: + name._fail_with_undefined_error() + except Exception as e: + msg = f"{msg} ({e}; did you forget to quote the callable name?)" + + raise TemplateRuntimeError(msg) + + args = [value, *(args if args is not None else ())] + kwargs = kwargs if kwargs is not None else {} + pass_arg = _PassArg.from_obj(func) + + if pass_arg is _PassArg.context: + if context is None: + raise TemplateRuntimeError( + f"Attempted to invoke a context {type_name} without context." + ) + + args.insert(0, context) + elif pass_arg is _PassArg.eval_context: + if eval_ctx is None: + if context is not None: + eval_ctx = context.eval_ctx + else: + eval_ctx = EvalContext(self) + + args.insert(0, eval_ctx) + elif pass_arg is _PassArg.environment: + args.insert(0, self) + + return func(*args, **kwargs) + + def call_filter( + self, + name: str, + value: t.Any, + args: t.Optional[t.Sequence[t.Any]] = None, + kwargs: t.Optional[t.Mapping[str, t.Any]] = None, + context: t.Optional[Context] = None, + eval_ctx: t.Optional[EvalContext] = None, + ) -> t.Any: + """Invoke a filter on a value the same way the compiler does. + + This might return a coroutine if the filter is running from an + environment in async mode and the filter supports async + execution. It's your responsibility to await this if needed. + + .. versionadded:: 2.7 + """ + return self._filter_test_common( + name, value, args, kwargs, context, eval_ctx, True + ) + + def call_test( + self, + name: str, + value: t.Any, + args: t.Optional[t.Sequence[t.Any]] = None, + kwargs: t.Optional[t.Mapping[str, t.Any]] = None, + context: t.Optional[Context] = None, + eval_ctx: t.Optional[EvalContext] = None, + ) -> t.Any: + """Invoke a test on a value the same way the compiler does. + + This might return a coroutine if the test is running from an + environment in async mode and the test supports async execution. + It's your responsibility to await this if needed. + + .. versionchanged:: 3.0 + Tests support ``@pass_context``, etc. decorators. Added + the ``context`` and ``eval_ctx`` parameters. + + .. versionadded:: 2.7 + """ + return self._filter_test_common( + name, value, args, kwargs, context, eval_ctx, False + ) + + @internalcode + def parse( + self, + source: str, + name: t.Optional[str] = None, + filename: t.Optional[str] = None, + ) -> nodes.Template: + """Parse the sourcecode and return the abstract syntax tree. This + tree of nodes is used by the compiler to convert the template into + executable source- or bytecode. This is useful for debugging or to + extract information from templates. + + If you are :ref:`developing Jinja extensions <writing-extensions>` + this gives you a good overview of the node tree generated. + """ + try: + return self._parse(source, name, filename) + except TemplateSyntaxError: + self.handle_exception(source=source) + + def _parse( + self, source: str, name: t.Optional[str], filename: t.Optional[str] + ) -> nodes.Template: + """Internal parsing function used by `parse` and `compile`.""" + return Parser(self, source, name, filename).parse() + + def lex( + self, + source: str, + name: t.Optional[str] = None, + filename: t.Optional[str] = None, + ) -> t.Iterator[t.Tuple[int, str, str]]: + """Lex the given sourcecode and return a generator that yields + tokens as tuples in the form ``(lineno, token_type, value)``. + This can be useful for :ref:`extension development <writing-extensions>` + and debugging templates. + + This does not perform preprocessing. If you want the preprocessing + of the extensions to be applied you have to filter source through + the :meth:`preprocess` method. + """ + source = str(source) + try: + return self.lexer.tokeniter(source, name, filename) + except TemplateSyntaxError: + self.handle_exception(source=source) + + def preprocess( + self, + source: str, + name: t.Optional[str] = None, + filename: t.Optional[str] = None, + ) -> str: + """Preprocesses the source with all extensions. This is automatically + called for all parsing and compiling methods but *not* for :meth:`lex` + because there you usually only want the actual source tokenized. + """ + return reduce( + lambda s, e: e.preprocess(s, name, filename), + self.iter_extensions(), + str(source), + ) + + def _tokenize( + self, + source: str, + name: t.Optional[str], + filename: t.Optional[str] = None, + state: t.Optional[str] = None, + ) -> TokenStream: + """Called by the parser to do the preprocessing and filtering + for all the extensions. Returns a :class:`~jinja2.lexer.TokenStream`. + """ + source = self.preprocess(source, name, filename) + stream = self.lexer.tokenize(source, name, filename, state) + + for ext in self.iter_extensions(): + stream = ext.filter_stream(stream) # type: ignore + + if not isinstance(stream, TokenStream): + stream = TokenStream(stream, name, filename) # type: ignore + + return stream + + def _generate( + self, + source: nodes.Template, + name: t.Optional[str], + filename: t.Optional[str], + defer_init: bool = False, + ) -> str: + """Internal hook that can be overridden to hook a different generate + method in. + + .. versionadded:: 2.5 + """ + return generate( # type: ignore + source, + self, + name, + filename, + defer_init=defer_init, + optimized=self.optimized, + ) + + def _compile(self, source: str, filename: str) -> CodeType: + """Internal hook that can be overridden to hook a different compile + method in. + + .. versionadded:: 2.5 + """ + return compile(source, filename, "exec") # type: ignore + + @typing.overload + def compile( # type: ignore + self, + source: t.Union[str, nodes.Template], + name: t.Optional[str] = None, + filename: t.Optional[str] = None, + raw: "te.Literal[False]" = False, + defer_init: bool = False, + ) -> CodeType: + ... + + @typing.overload + def compile( + self, + source: t.Union[str, nodes.Template], + name: t.Optional[str] = None, + filename: t.Optional[str] = None, + raw: "te.Literal[True]" = ..., + defer_init: bool = False, + ) -> str: + ... + + @internalcode + def compile( + self, + source: t.Union[str, nodes.Template], + name: t.Optional[str] = None, + filename: t.Optional[str] = None, + raw: bool = False, + defer_init: bool = False, + ) -> t.Union[str, CodeType]: + """Compile a node or template source code. The `name` parameter is + the load name of the template after it was joined using + :meth:`join_path` if necessary, not the filename on the file system. + the `filename` parameter is the estimated filename of the template on + the file system. If the template came from a database or memory this + can be omitted. + + The return value of this method is a python code object. If the `raw` + parameter is `True` the return value will be a string with python + code equivalent to the bytecode returned otherwise. This method is + mainly used internally. + + `defer_init` is use internally to aid the module code generator. This + causes the generated code to be able to import without the global + environment variable to be set. + + .. versionadded:: 2.4 + `defer_init` parameter added. + """ + source_hint = None + try: + if isinstance(source, str): + source_hint = source + source = self._parse(source, name, filename) + source = self._generate(source, name, filename, defer_init=defer_init) + if raw: + return source + if filename is None: + filename = "<template>" + return self._compile(source, filename) + except TemplateSyntaxError: + self.handle_exception(source=source_hint) + + def compile_expression( + self, source: str, undefined_to_none: bool = True + ) -> "TemplateExpression": + """A handy helper method that returns a callable that accepts keyword + arguments that appear as variables in the expression. If called it + returns the result of the expression. + + This is useful if applications want to use the same rules as Jinja + in template "configuration files" or similar situations. + + Example usage: + + >>> env = Environment() + >>> expr = env.compile_expression('foo == 42') + >>> expr(foo=23) + False + >>> expr(foo=42) + True + + Per default the return value is converted to `None` if the + expression returns an undefined value. This can be changed + by setting `undefined_to_none` to `False`. + + >>> env.compile_expression('var')() is None + True + >>> env.compile_expression('var', undefined_to_none=False)() + Undefined + + .. versionadded:: 2.1 + """ + parser = Parser(self, source, state="variable") + try: + expr = parser.parse_expression() + if not parser.stream.eos: + raise TemplateSyntaxError( + "chunk after expression", parser.stream.current.lineno, None, None + ) + expr.set_environment(self) + except TemplateSyntaxError: + self.handle_exception(source=source) + + body = [nodes.Assign(nodes.Name("result", "store"), expr, lineno=1)] + template = self.from_string(nodes.Template(body, lineno=1)) + return TemplateExpression(template, undefined_to_none) + + def compile_templates( + self, + target: t.Union[str, os.PathLike], + extensions: t.Optional[t.Collection[str]] = None, + filter_func: t.Optional[t.Callable[[str], bool]] = None, + zip: t.Optional[str] = "deflated", + log_function: t.Optional[t.Callable[[str], None]] = None, + ignore_errors: bool = True, + ) -> None: + """Finds all the templates the loader can find, compiles them + and stores them in `target`. If `zip` is `None`, instead of in a + zipfile, the templates will be stored in a directory. + By default a deflate zip algorithm is used. To switch to + the stored algorithm, `zip` can be set to ``'stored'``. + + `extensions` and `filter_func` are passed to :meth:`list_templates`. + Each template returned will be compiled to the target folder or + zipfile. + + By default template compilation errors are ignored. In case a + log function is provided, errors are logged. If you want template + syntax errors to abort the compilation you can set `ignore_errors` + to `False` and you will get an exception on syntax errors. + + .. versionadded:: 2.4 + """ + from .loaders import ModuleLoader + + if log_function is None: + + def log_function(x: str) -> None: + pass + + assert log_function is not None + assert self.loader is not None, "No loader configured." + + def write_file(filename: str, data: str) -> None: + if zip: + info = ZipInfo(filename) + info.external_attr = 0o755 << 16 + zip_file.writestr(info, data) + else: + with open(os.path.join(target, filename), "wb") as f: + f.write(data.encode("utf8")) + + if zip is not None: + from zipfile import ZipFile, ZipInfo, ZIP_DEFLATED, ZIP_STORED + + zip_file = ZipFile( + target, "w", dict(deflated=ZIP_DEFLATED, stored=ZIP_STORED)[zip] + ) + log_function(f"Compiling into Zip archive {target!r}") + else: + if not os.path.isdir(target): + os.makedirs(target) + log_function(f"Compiling into folder {target!r}") + + try: + for name in self.list_templates(extensions, filter_func): + source, filename, _ = self.loader.get_source(self, name) + try: + code = self.compile(source, name, filename, True, True) + except TemplateSyntaxError as e: + if not ignore_errors: + raise + log_function(f'Could not compile "{name}": {e}') + continue + + filename = ModuleLoader.get_module_filename(name) + + write_file(filename, code) + log_function(f'Compiled "{name}" as {filename}') + finally: + if zip: + zip_file.close() + + log_function("Finished compiling templates") + + def list_templates( + self, + extensions: t.Optional[t.Collection[str]] = None, + filter_func: t.Optional[t.Callable[[str], bool]] = None, + ) -> t.List[str]: + """Returns a list of templates for this environment. This requires + that the loader supports the loader's + :meth:`~BaseLoader.list_templates` method. + + If there are other files in the template folder besides the + actual templates, the returned list can be filtered. There are two + ways: either `extensions` is set to a list of file extensions for + templates, or a `filter_func` can be provided which is a callable that + is passed a template name and should return `True` if it should end up + in the result list. + + If the loader does not support that, a :exc:`TypeError` is raised. + + .. versionadded:: 2.4 + """ + assert self.loader is not None, "No loader configured." + names = self.loader.list_templates() + + if extensions is not None: + if filter_func is not None: + raise TypeError( + "either extensions or filter_func can be passed, but not both" + ) + + def filter_func(x: str) -> bool: + return "." in x and x.rsplit(".", 1)[1] in extensions # type: ignore + + if filter_func is not None: + names = [name for name in names if filter_func(name)] + + return names + + def handle_exception(self, source: t.Optional[str] = None) -> "te.NoReturn": + """Exception handling helper. This is used internally to either raise + rewritten exceptions or return a rendered traceback for the template. + """ + from .debug import rewrite_traceback_stack + + raise rewrite_traceback_stack(source=source) + + def join_path(self, template: str, parent: str) -> str: + """Join a template with the parent. By default all the lookups are + relative to the loader root so this method returns the `template` + parameter unchanged, but if the paths should be relative to the + parent template, this function can be used to calculate the real + template name. + + Subclasses may override this method and implement template path + joining here. + """ + return template + + @internalcode + def _load_template( + self, name: str, globals: t.Optional[t.MutableMapping[str, t.Any]] + ) -> "Template": + if self.loader is None: + raise TypeError("no loader for this environment specified") + cache_key = (weakref.ref(self.loader), name) + if self.cache is not None: + template = self.cache.get(cache_key) + if template is not None and ( + not self.auto_reload or template.is_up_to_date + ): + # template.globals is a ChainMap, modifying it will only + # affect the template, not the environment globals. + if globals: + template.globals.update(globals) + + return template + + template = self.loader.load(self, name, self.make_globals(globals)) + + if self.cache is not None: + self.cache[cache_key] = template + return template + + @internalcode + def get_template( + self, + name: t.Union[str, "Template"], + parent: t.Optional[str] = None, + globals: t.Optional[t.MutableMapping[str, t.Any]] = None, + ) -> "Template": + """Load a template by name with :attr:`loader` and return a + :class:`Template`. If the template does not exist a + :exc:`TemplateNotFound` exception is raised. + + :param name: Name of the template to load. When loading + templates from the filesystem, "/" is used as the path + separator, even on Windows. + :param parent: The name of the parent template importing this + template. :meth:`join_path` can be used to implement name + transformations with this. + :param globals: Extend the environment :attr:`globals` with + these extra variables available for all renders of this + template. If the template has already been loaded and + cached, its globals are updated with any new items. + + .. versionchanged:: 3.0 + If a template is loaded from cache, ``globals`` will update + the template's globals instead of ignoring the new values. + + .. versionchanged:: 2.4 + If ``name`` is a :class:`Template` object it is returned + unchanged. + """ + if isinstance(name, Template): + return name + if parent is not None: + name = self.join_path(name, parent) + + return self._load_template(name, globals) + + @internalcode + def select_template( + self, + names: t.Iterable[t.Union[str, "Template"]], + parent: t.Optional[str] = None, + globals: t.Optional[t.MutableMapping[str, t.Any]] = None, + ) -> "Template": + """Like :meth:`get_template`, but tries loading multiple names. + If none of the names can be loaded a :exc:`TemplatesNotFound` + exception is raised. + + :param names: List of template names to try loading in order. + :param parent: The name of the parent template importing this + template. :meth:`join_path` can be used to implement name + transformations with this. + :param globals: Extend the environment :attr:`globals` with + these extra variables available for all renders of this + template. If the template has already been loaded and + cached, its globals are updated with any new items. + + .. versionchanged:: 3.0 + If a template is loaded from cache, ``globals`` will update + the template's globals instead of ignoring the new values. + + .. versionchanged:: 2.11 + If ``names`` is :class:`Undefined`, an :exc:`UndefinedError` + is raised instead. If no templates were found and ``names`` + contains :class:`Undefined`, the message is more helpful. + + .. versionchanged:: 2.4 + If ``names`` contains a :class:`Template` object it is + returned unchanged. + + .. versionadded:: 2.3 + """ + if isinstance(names, Undefined): + names._fail_with_undefined_error() + + if not names: + raise TemplatesNotFound( + message="Tried to select from an empty list of templates." + ) + + for name in names: + if isinstance(name, Template): + return name + if parent is not None: + name = self.join_path(name, parent) + try: + return self._load_template(name, globals) + except (TemplateNotFound, UndefinedError): + pass + raise TemplatesNotFound(names) # type: ignore + + @internalcode + def get_or_select_template( + self, + template_name_or_list: t.Union[ + str, "Template", t.List[t.Union[str, "Template"]] + ], + parent: t.Optional[str] = None, + globals: t.Optional[t.MutableMapping[str, t.Any]] = None, + ) -> "Template": + """Use :meth:`select_template` if an iterable of template names + is given, or :meth:`get_template` if one name is given. + + .. versionadded:: 2.3 + """ + if isinstance(template_name_or_list, (str, Undefined)): + return self.get_template(template_name_or_list, parent, globals) + elif isinstance(template_name_or_list, Template): + return template_name_or_list + return self.select_template(template_name_or_list, parent, globals) + + def from_string( + self, + source: t.Union[str, nodes.Template], + globals: t.Optional[t.MutableMapping[str, t.Any]] = None, + template_class: t.Optional[t.Type["Template"]] = None, + ) -> "Template": + """Load a template from a source string without using + :attr:`loader`. + + :param source: Jinja source to compile into a template. + :param globals: Extend the environment :attr:`globals` with + these extra variables available for all renders of this + template. If the template has already been loaded and + cached, its globals are updated with any new items. + :param template_class: Return an instance of this + :class:`Template` class. + """ + gs = self.make_globals(globals) + cls = template_class or self.template_class + return cls.from_code(self, self.compile(source), gs, None) + + def make_globals( + self, d: t.Optional[t.MutableMapping[str, t.Any]] + ) -> t.MutableMapping[str, t.Any]: + """Make the globals map for a template. Any given template + globals overlay the environment :attr:`globals`. + + Returns a :class:`collections.ChainMap`. This allows any changes + to a template's globals to only affect that template, while + changes to the environment's globals are still reflected. + However, avoid modifying any globals after a template is loaded. + + :param d: Dict of template-specific globals. + + .. versionchanged:: 3.0 + Use :class:`collections.ChainMap` to always prevent mutating + environment globals. + """ + if d is None: + d = {} + + return ChainMap(d, self.globals) + + +class Template: + """A compiled template that can be rendered. + + Use the methods on :class:`Environment` to create or load templates. + The environment is used to configure how templates are compiled and + behave. + + It is also possible to create a template object directly. This is + not usually recommended. The constructor takes most of the same + arguments as :class:`Environment`. All templates created with the + same environment arguments share the same ephemeral ``Environment`` + instance behind the scenes. + + A template object should be considered immutable. Modifications on + the object are not supported. + """ + + #: Type of environment to create when creating a template directly + #: rather than through an existing environment. + environment_class: t.Type[Environment] = Environment + + environment: Environment + globals: t.MutableMapping[str, t.Any] + name: t.Optional[str] + filename: t.Optional[str] + blocks: t.Dict[str, t.Callable[[Context], t.Iterator[str]]] + root_render_func: t.Callable[[Context], t.Iterator[str]] + _module: t.Optional["TemplateModule"] + _debug_info: str + _uptodate: t.Optional[t.Callable[[], bool]] + + def __new__( + cls, + source: t.Union[str, nodes.Template], + block_start_string: str = BLOCK_START_STRING, + block_end_string: str = BLOCK_END_STRING, + variable_start_string: str = VARIABLE_START_STRING, + variable_end_string: str = VARIABLE_END_STRING, + comment_start_string: str = COMMENT_START_STRING, + comment_end_string: str = COMMENT_END_STRING, + line_statement_prefix: t.Optional[str] = LINE_STATEMENT_PREFIX, + line_comment_prefix: t.Optional[str] = LINE_COMMENT_PREFIX, + trim_blocks: bool = TRIM_BLOCKS, + lstrip_blocks: bool = LSTRIP_BLOCKS, + newline_sequence: "te.Literal['\\n', '\\r\\n', '\\r']" = NEWLINE_SEQUENCE, + keep_trailing_newline: bool = KEEP_TRAILING_NEWLINE, + extensions: t.Sequence[t.Union[str, t.Type["Extension"]]] = (), + optimized: bool = True, + undefined: t.Type[Undefined] = Undefined, + finalize: t.Optional[t.Callable[..., t.Any]] = None, + autoescape: t.Union[bool, t.Callable[[t.Optional[str]], bool]] = False, + enable_async: bool = False, + ) -> t.Any: # it returns a `Template`, but this breaks the sphinx build... + env = get_spontaneous_environment( + cls.environment_class, # type: ignore + block_start_string, + block_end_string, + variable_start_string, + variable_end_string, + comment_start_string, + comment_end_string, + line_statement_prefix, + line_comment_prefix, + trim_blocks, + lstrip_blocks, + newline_sequence, + keep_trailing_newline, + frozenset(extensions), + optimized, + undefined, # type: ignore + finalize, + autoescape, + None, + 0, + False, + None, + enable_async, + ) + return env.from_string(source, template_class=cls) + + @classmethod + def from_code( + cls, + environment: Environment, + code: CodeType, + globals: t.MutableMapping[str, t.Any], + uptodate: t.Optional[t.Callable[[], bool]] = None, + ) -> "Template": + """Creates a template object from compiled code and the globals. This + is used by the loaders and environment to create a template object. + """ + namespace = {"environment": environment, "__file__": code.co_filename} + exec(code, namespace) + rv = cls._from_namespace(environment, namespace, globals) + rv._uptodate = uptodate + return rv + + @classmethod + def from_module_dict( + cls, + environment: Environment, + module_dict: t.MutableMapping[str, t.Any], + globals: t.MutableMapping[str, t.Any], + ) -> "Template": + """Creates a template object from a module. This is used by the + module loader to create a template object. + + .. versionadded:: 2.4 + """ + return cls._from_namespace(environment, module_dict, globals) + + @classmethod + def _from_namespace( + cls, + environment: Environment, + namespace: t.MutableMapping[str, t.Any], + globals: t.MutableMapping[str, t.Any], + ) -> "Template": + t: "Template" = object.__new__(cls) + t.environment = environment + t.globals = globals + t.name = namespace["name"] + t.filename = namespace["__file__"] + t.blocks = namespace["blocks"] + + # render function and module + t.root_render_func = namespace["root"] # type: ignore + t._module = None + + # debug and loader helpers + t._debug_info = namespace["debug_info"] + t._uptodate = None + + # store the reference + namespace["environment"] = environment + namespace["__jinja_template__"] = t + + return t + + def render(self, *args: t.Any, **kwargs: t.Any) -> str: + """This method accepts the same arguments as the `dict` constructor: + A dict, a dict subclass or some keyword arguments. If no arguments + are given the context will be empty. These two calls do the same:: + + template.render(knights='that say nih') + template.render({'knights': 'that say nih'}) + + This will return the rendered template as a string. + """ + if self.environment.is_async: + import asyncio + + close = False + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + close = True + + try: + return loop.run_until_complete(self.render_async(*args, **kwargs)) + finally: + if close: + loop.close() + + ctx = self.new_context(dict(*args, **kwargs)) + + try: + return self.environment.concat(self.root_render_func(ctx)) # type: ignore + except Exception: + self.environment.handle_exception() + + async def render_async(self, *args: t.Any, **kwargs: t.Any) -> str: + """This works similar to :meth:`render` but returns a coroutine + that when awaited returns the entire rendered template string. This + requires the async feature to be enabled. + + Example usage:: + + await template.render_async(knights='that say nih; asynchronously') + """ + if not self.environment.is_async: + raise RuntimeError( + "The environment was not created with async mode enabled." + ) + + ctx = self.new_context(dict(*args, **kwargs)) + + try: + return self.environment.concat( # type: ignore + [n async for n in self.root_render_func(ctx)] # type: ignore + ) + except Exception: + return self.environment.handle_exception() + + def stream(self, *args: t.Any, **kwargs: t.Any) -> "TemplateStream": + """Works exactly like :meth:`generate` but returns a + :class:`TemplateStream`. + """ + return TemplateStream(self.generate(*args, **kwargs)) + + def generate(self, *args: t.Any, **kwargs: t.Any) -> t.Iterator[str]: + """For very large templates it can be useful to not render the whole + template at once but evaluate each statement after another and yield + piece for piece. This method basically does exactly that and returns + a generator that yields one item after another as strings. + + It accepts the same arguments as :meth:`render`. + """ + if self.environment.is_async: + import asyncio + + async def to_list() -> t.List[str]: + return [x async for x in self.generate_async(*args, **kwargs)] + + yield from asyncio.run(to_list()) + return + + ctx = self.new_context(dict(*args, **kwargs)) + + try: + yield from self.root_render_func(ctx) # type: ignore + except Exception: + yield self.environment.handle_exception() + + async def generate_async( + self, *args: t.Any, **kwargs: t.Any + ) -> t.AsyncIterator[str]: + """An async version of :meth:`generate`. Works very similarly but + returns an async iterator instead. + """ + if not self.environment.is_async: + raise RuntimeError( + "The environment was not created with async mode enabled." + ) + + ctx = self.new_context(dict(*args, **kwargs)) + + try: + async for event in self.root_render_func(ctx): # type: ignore + yield event + except Exception: + yield self.environment.handle_exception() + + def new_context( + self, + vars: t.Optional[t.Dict[str, t.Any]] = None, + shared: bool = False, + locals: t.Optional[t.Mapping[str, t.Any]] = None, + ) -> Context: + """Create a new :class:`Context` for this template. The vars + provided will be passed to the template. Per default the globals + are added to the context. If shared is set to `True` the data + is passed as is to the context without adding the globals. + + `locals` can be a dict of local variables for internal usage. + """ + return new_context( + self.environment, self.name, self.blocks, vars, shared, self.globals, locals + ) + + def make_module( + self, + vars: t.Optional[t.Dict[str, t.Any]] = None, + shared: bool = False, + locals: t.Optional[t.Mapping[str, t.Any]] = None, + ) -> "TemplateModule": + """This method works like the :attr:`module` attribute when called + without arguments but it will evaluate the template on every call + rather than caching it. It's also possible to provide + a dict which is then used as context. The arguments are the same + as for the :meth:`new_context` method. + """ + ctx = self.new_context(vars, shared, locals) + return TemplateModule(self, ctx) + + async def make_module_async( + self, + vars: t.Optional[t.Dict[str, t.Any]] = None, + shared: bool = False, + locals: t.Optional[t.Mapping[str, t.Any]] = None, + ) -> "TemplateModule": + """As template module creation can invoke template code for + asynchronous executions this method must be used instead of the + normal :meth:`make_module` one. Likewise the module attribute + becomes unavailable in async mode. + """ + ctx = self.new_context(vars, shared, locals) + return TemplateModule( + self, ctx, [x async for x in self.root_render_func(ctx)] # type: ignore + ) + + @internalcode + def _get_default_module(self, ctx: t.Optional[Context] = None) -> "TemplateModule": + """If a context is passed in, this means that the template was + imported. Imported templates have access to the current + template's globals by default, but they can only be accessed via + the context during runtime. + + If there are new globals, we need to create a new module because + the cached module is already rendered and will not have access + to globals from the current context. This new module is not + cached because the template can be imported elsewhere, and it + should have access to only the current template's globals. + """ + if self.environment.is_async: + raise RuntimeError("Module is not available in async mode.") + + if ctx is not None: + keys = ctx.globals_keys - self.globals.keys() + + if keys: + return self.make_module({k: ctx.parent[k] for k in keys}) + + if self._module is None: + self._module = self.make_module() + + return self._module + + async def _get_default_module_async( + self, ctx: t.Optional[Context] = None + ) -> "TemplateModule": + if ctx is not None: + keys = ctx.globals_keys - self.globals.keys() + + if keys: + return await self.make_module_async({k: ctx.parent[k] for k in keys}) + + if self._module is None: + self._module = await self.make_module_async() + + return self._module + + @property + def module(self) -> "TemplateModule": + """The template as module. This is used for imports in the + template runtime but is also useful if one wants to access + exported template variables from the Python layer: + + >>> t = Template('{% macro foo() %}42{% endmacro %}23') + >>> str(t.module) + '23' + >>> t.module.foo() == u'42' + True + + This attribute is not available if async mode is enabled. + """ + return self._get_default_module() + + def get_corresponding_lineno(self, lineno: int) -> int: + """Return the source line number of a line number in the + generated bytecode as they are not in sync. + """ + for template_line, code_line in reversed(self.debug_info): + if code_line <= lineno: + return template_line + return 1 + + @property + def is_up_to_date(self) -> bool: + """If this variable is `False` there is a newer version available.""" + if self._uptodate is None: + return True + return self._uptodate() + + @property + def debug_info(self) -> t.List[t.Tuple[int, int]]: + """The debug info mapping.""" + if self._debug_info: + return [ + tuple(map(int, x.split("="))) # type: ignore + for x in self._debug_info.split("&") + ] + + return [] + + def __repr__(self) -> str: + if self.name is None: + name = f"memory:{id(self):x}" + else: + name = repr(self.name) + return f"<{type(self).__name__} {name}>" + + +class TemplateModule: + """Represents an imported template. All the exported names of the + template are available as attributes on this object. Additionally + converting it into a string renders the contents. + """ + + def __init__( + self, + template: Template, + context: Context, + body_stream: t.Optional[t.Iterable[str]] = None, + ) -> None: + if body_stream is None: + if context.environment.is_async: + raise RuntimeError( + "Async mode requires a body stream to be passed to" + " a template module. Use the async methods of the" + " API you are using." + ) + + body_stream = list(template.root_render_func(context)) # type: ignore + + self._body_stream = body_stream + self.__dict__.update(context.get_exported()) + self.__name__ = template.name + + def __html__(self) -> Markup: + return Markup(concat(self._body_stream)) + + def __str__(self) -> str: + return concat(self._body_stream) + + def __repr__(self) -> str: + if self.__name__ is None: + name = f"memory:{id(self):x}" + else: + name = repr(self.__name__) + return f"<{type(self).__name__} {name}>" + + +class TemplateExpression: + """The :meth:`jinja2.Environment.compile_expression` method returns an + instance of this object. It encapsulates the expression-like access + to the template with an expression it wraps. + """ + + def __init__(self, template: Template, undefined_to_none: bool) -> None: + self._template = template + self._undefined_to_none = undefined_to_none + + def __call__(self, *args: t.Any, **kwargs: t.Any) -> t.Optional[t.Any]: + context = self._template.new_context(dict(*args, **kwargs)) + consume(self._template.root_render_func(context)) # type: ignore + rv = context.vars["result"] + if self._undefined_to_none and isinstance(rv, Undefined): + rv = None + return rv + + +class TemplateStream: + """A template stream works pretty much like an ordinary python generator + but it can buffer multiple items to reduce the number of total iterations. + Per default the output is unbuffered which means that for every unbuffered + instruction in the template one string is yielded. + + If buffering is enabled with a buffer size of 5, five items are combined + into a new string. This is mainly useful if you are streaming + big templates to a client via WSGI which flushes after each iteration. + """ + + def __init__(self, gen: t.Iterator[str]) -> None: + self._gen = gen + self.disable_buffering() + + def dump( + self, + fp: t.Union[str, t.IO], + encoding: t.Optional[str] = None, + errors: t.Optional[str] = "strict", + ) -> None: + """Dump the complete stream into a file or file-like object. + Per default strings are written, if you want to encode + before writing specify an `encoding`. + + Example usage:: + + Template('Hello {{ name }}!').stream(name='foo').dump('hello.html') + """ + close = False + + if isinstance(fp, str): + if encoding is None: + encoding = "utf-8" + + fp = open(fp, "wb") + close = True + try: + if encoding is not None: + iterable = (x.encode(encoding, errors) for x in self) # type: ignore + else: + iterable = self # type: ignore + + if hasattr(fp, "writelines"): + fp.writelines(iterable) + else: + for item in iterable: + fp.write(item) + finally: + if close: + fp.close() + + def disable_buffering(self) -> None: + """Disable the output buffering.""" + self._next = partial(next, self._gen) + self.buffered = False + + def _buffered_generator(self, size: int) -> t.Iterator[str]: + buf: t.List[str] = [] + c_size = 0 + push = buf.append + + while True: + try: + while c_size < size: + c = next(self._gen) + push(c) + if c: + c_size += 1 + except StopIteration: + if not c_size: + return + yield concat(buf) + del buf[:] + c_size = 0 + + def enable_buffering(self, size: int = 5) -> None: + """Enable buffering. Buffer `size` items before yielding them.""" + if size <= 1: + raise ValueError("buffer size too small") + + self.buffered = True + self._next = partial(next, self._buffered_generator(size)) + + def __iter__(self) -> "TemplateStream": + return self + + def __next__(self) -> str: + return self._next() # type: ignore + + +# hook in default template class. if anyone reads this comment: ignore that +# it's possible to use custom templates ;-) +Environment.template_class = Template diff --git a/src/jinja2/exceptions.py b/src/jinja2/exceptions.py new file mode 100644 index 0000000..082ebe8 --- /dev/null +++ b/src/jinja2/exceptions.py @@ -0,0 +1,166 @@ +import typing as t + +if t.TYPE_CHECKING: + from .runtime import Undefined + + +class TemplateError(Exception): + """Baseclass for all template errors.""" + + def __init__(self, message: t.Optional[str] = None) -> None: + super().__init__(message) + + @property + def message(self) -> t.Optional[str]: + return self.args[0] if self.args else None + + +class TemplateNotFound(IOError, LookupError, TemplateError): + """Raised if a template does not exist. + + .. versionchanged:: 2.11 + If the given name is :class:`Undefined` and no message was + provided, an :exc:`UndefinedError` is raised. + """ + + # Silence the Python warning about message being deprecated since + # it's not valid here. + message: t.Optional[str] = None + + def __init__( + self, + name: t.Optional[t.Union[str, "Undefined"]], + message: t.Optional[str] = None, + ) -> None: + IOError.__init__(self, name) + + if message is None: + from .runtime import Undefined + + if isinstance(name, Undefined): + name._fail_with_undefined_error() + + message = name + + self.message = message + self.name = name + self.templates = [name] + + def __str__(self) -> str: + return str(self.message) + + +class TemplatesNotFound(TemplateNotFound): + """Like :class:`TemplateNotFound` but raised if multiple templates + are selected. This is a subclass of :class:`TemplateNotFound` + exception, so just catching the base exception will catch both. + + .. versionchanged:: 2.11 + If a name in the list of names is :class:`Undefined`, a message + about it being undefined is shown rather than the empty string. + + .. versionadded:: 2.2 + """ + + def __init__( + self, + names: t.Sequence[t.Union[str, "Undefined"]] = (), + message: t.Optional[str] = None, + ) -> None: + if message is None: + from .runtime import Undefined + + parts = [] + + for name in names: + if isinstance(name, Undefined): + parts.append(name._undefined_message) + else: + parts.append(name) + + parts_str = ", ".join(map(str, parts)) + message = f"none of the templates given were found: {parts_str}" + + super().__init__(names[-1] if names else None, message) + self.templates = list(names) + + +class TemplateSyntaxError(TemplateError): + """Raised to tell the user that there is a problem with the template.""" + + def __init__( + self, + message: str, + lineno: int, + name: t.Optional[str] = None, + filename: t.Optional[str] = None, + ) -> None: + super().__init__(message) + self.lineno = lineno + self.name = name + self.filename = filename + self.source: t.Optional[str] = None + + # this is set to True if the debug.translate_syntax_error + # function translated the syntax error into a new traceback + self.translated = False + + def __str__(self) -> str: + # for translated errors we only return the message + if self.translated: + return t.cast(str, self.message) + + # otherwise attach some stuff + location = f"line {self.lineno}" + name = self.filename or self.name + if name: + location = f'File "{name}", {location}' + lines = [t.cast(str, self.message), " " + location] + + # if the source is set, add the line to the output + if self.source is not None: + try: + line = self.source.splitlines()[self.lineno - 1] + except IndexError: + pass + else: + lines.append(" " + line.strip()) + + return "\n".join(lines) + + def __reduce__(self): # type: ignore + # https://bugs.python.org/issue1692335 Exceptions that take + # multiple required arguments have problems with pickling. + # Without this, raises TypeError: __init__() missing 1 required + # positional argument: 'lineno' + return self.__class__, (self.message, self.lineno, self.name, self.filename) + + +class TemplateAssertionError(TemplateSyntaxError): + """Like a template syntax error, but covers cases where something in the + template caused an error at compile time that wasn't necessarily caused + by a syntax error. However it's a direct subclass of + :exc:`TemplateSyntaxError` and has the same attributes. + """ + + +class TemplateRuntimeError(TemplateError): + """A generic runtime error in the template engine. Under some situations + Jinja may raise this exception. + """ + + +class UndefinedError(TemplateRuntimeError): + """Raised if a template tries to operate on :class:`Undefined`.""" + + +class SecurityError(TemplateRuntimeError): + """Raised if a template tries to do something insecure if the + sandbox is enabled. + """ + + +class FilterArgumentError(TemplateRuntimeError): + """This error is raised if a filter was called with inappropriate + arguments + """ diff --git a/src/jinja2/ext.py b/src/jinja2/ext.py new file mode 100644 index 0000000..d555054 --- /dev/null +++ b/src/jinja2/ext.py @@ -0,0 +1,859 @@ +"""Extension API for adding custom tags and behavior.""" +import pprint +import re +import typing as t + +from markupsafe import Markup + +from . import defaults +from . import nodes +from .environment import Environment +from .exceptions import TemplateAssertionError +from .exceptions import TemplateSyntaxError +from .runtime import concat # type: ignore +from .runtime import Context +from .runtime import Undefined +from .utils import import_string +from .utils import pass_context + +if t.TYPE_CHECKING: + import typing_extensions as te + from .lexer import Token + from .lexer import TokenStream + from .parser import Parser + + class _TranslationsBasic(te.Protocol): + def gettext(self, message: str) -> str: + ... + + def ngettext(self, singular: str, plural: str, n: int) -> str: + pass + + class _TranslationsContext(_TranslationsBasic): + def pgettext(self, context: str, message: str) -> str: + ... + + def npgettext(self, context: str, singular: str, plural: str, n: int) -> str: + ... + + _SupportedTranslations = t.Union[_TranslationsBasic, _TranslationsContext] + + +# I18N functions available in Jinja templates. If the I18N library +# provides ugettext, it will be assigned to gettext. +GETTEXT_FUNCTIONS: t.Tuple[str, ...] = ( + "_", + "gettext", + "ngettext", + "pgettext", + "npgettext", +) +_ws_re = re.compile(r"\s*\n\s*") + + +class Extension: + """Extensions can be used to add extra functionality to the Jinja template + system at the parser level. Custom extensions are bound to an environment + but may not store environment specific data on `self`. The reason for + this is that an extension can be bound to another environment (for + overlays) by creating a copy and reassigning the `environment` attribute. + + As extensions are created by the environment they cannot accept any + arguments for configuration. One may want to work around that by using + a factory function, but that is not possible as extensions are identified + by their import name. The correct way to configure the extension is + storing the configuration values on the environment. Because this way the + environment ends up acting as central configuration storage the + attributes may clash which is why extensions have to ensure that the names + they choose for configuration are not too generic. ``prefix`` for example + is a terrible name, ``fragment_cache_prefix`` on the other hand is a good + name as includes the name of the extension (fragment cache). + """ + + identifier: t.ClassVar[str] + + def __init_subclass__(cls) -> None: + cls.identifier = f"{cls.__module__}.{cls.__name__}" + + #: if this extension parses this is the list of tags it's listening to. + tags: t.Set[str] = set() + + #: the priority of that extension. This is especially useful for + #: extensions that preprocess values. A lower value means higher + #: priority. + #: + #: .. versionadded:: 2.4 + priority = 100 + + def __init__(self, environment: Environment) -> None: + self.environment = environment + + def bind(self, environment: Environment) -> "Extension": + """Create a copy of this extension bound to another environment.""" + rv = object.__new__(self.__class__) + rv.__dict__.update(self.__dict__) + rv.environment = environment + return rv + + def preprocess( + self, source: str, name: t.Optional[str], filename: t.Optional[str] = None + ) -> str: + """This method is called before the actual lexing and can be used to + preprocess the source. The `filename` is optional. The return value + must be the preprocessed source. + """ + return source + + def filter_stream( + self, stream: "TokenStream" + ) -> t.Union["TokenStream", t.Iterable["Token"]]: + """It's passed a :class:`~jinja2.lexer.TokenStream` that can be used + to filter tokens returned. This method has to return an iterable of + :class:`~jinja2.lexer.Token`\\s, but it doesn't have to return a + :class:`~jinja2.lexer.TokenStream`. + """ + return stream + + def parse(self, parser: "Parser") -> t.Union[nodes.Node, t.List[nodes.Node]]: + """If any of the :attr:`tags` matched this method is called with the + parser as first argument. The token the parser stream is pointing at + is the name token that matched. This method has to return one or a + list of multiple nodes. + """ + raise NotImplementedError() + + def attr( + self, name: str, lineno: t.Optional[int] = None + ) -> nodes.ExtensionAttribute: + """Return an attribute node for the current extension. This is useful + to pass constants on extensions to generated template code. + + :: + + self.attr('_my_attribute', lineno=lineno) + """ + return nodes.ExtensionAttribute(self.identifier, name, lineno=lineno) + + def call_method( + self, + name: str, + args: t.Optional[t.List[nodes.Expr]] = None, + kwargs: t.Optional[t.List[nodes.Keyword]] = None, + dyn_args: t.Optional[nodes.Expr] = None, + dyn_kwargs: t.Optional[nodes.Expr] = None, + lineno: t.Optional[int] = None, + ) -> nodes.Call: + """Call a method of the extension. This is a shortcut for + :meth:`attr` + :class:`jinja2.nodes.Call`. + """ + if args is None: + args = [] + if kwargs is None: + kwargs = [] + return nodes.Call( + self.attr(name, lineno=lineno), + args, + kwargs, + dyn_args, + dyn_kwargs, + lineno=lineno, + ) + + +@pass_context +def _gettext_alias( + __context: Context, *args: t.Any, **kwargs: t.Any +) -> t.Union[t.Any, Undefined]: + return __context.call(__context.resolve("gettext"), *args, **kwargs) + + +def _make_new_gettext(func: t.Callable[[str], str]) -> t.Callable[..., str]: + @pass_context + def gettext(__context: Context, __string: str, **variables: t.Any) -> str: + rv = __context.call(func, __string) + if __context.eval_ctx.autoescape: + rv = Markup(rv) + # Always treat as a format string, even if there are no + # variables. This makes translation strings more consistent + # and predictable. This requires escaping + return rv % variables # type: ignore + + return gettext + + +def _make_new_ngettext(func: t.Callable[[str, str, int], str]) -> t.Callable[..., str]: + @pass_context + def ngettext( + __context: Context, + __singular: str, + __plural: str, + __num: int, + **variables: t.Any, + ) -> str: + variables.setdefault("num", __num) + rv = __context.call(func, __singular, __plural, __num) + if __context.eval_ctx.autoescape: + rv = Markup(rv) + # Always treat as a format string, see gettext comment above. + return rv % variables # type: ignore + + return ngettext + + +def _make_new_pgettext(func: t.Callable[[str, str], str]) -> t.Callable[..., str]: + @pass_context + def pgettext( + __context: Context, __string_ctx: str, __string: str, **variables: t.Any + ) -> str: + variables.setdefault("context", __string_ctx) + rv = __context.call(func, __string_ctx, __string) + + if __context.eval_ctx.autoescape: + rv = Markup(rv) + + # Always treat as a format string, see gettext comment above. + return rv % variables # type: ignore + + return pgettext + + +def _make_new_npgettext( + func: t.Callable[[str, str, str, int], str] +) -> t.Callable[..., str]: + @pass_context + def npgettext( + __context: Context, + __string_ctx: str, + __singular: str, + __plural: str, + __num: int, + **variables: t.Any, + ) -> str: + variables.setdefault("context", __string_ctx) + variables.setdefault("num", __num) + rv = __context.call(func, __string_ctx, __singular, __plural, __num) + + if __context.eval_ctx.autoescape: + rv = Markup(rv) + + # Always treat as a format string, see gettext comment above. + return rv % variables # type: ignore + + return npgettext + + +class InternationalizationExtension(Extension): + """This extension adds gettext support to Jinja.""" + + tags = {"trans"} + + # TODO: the i18n extension is currently reevaluating values in a few + # situations. Take this example: + # {% trans count=something() %}{{ count }} foo{% pluralize + # %}{{ count }} fooss{% endtrans %} + # something is called twice here. One time for the gettext value and + # the other time for the n-parameter of the ngettext function. + + def __init__(self, environment: Environment) -> None: + super().__init__(environment) + environment.globals["_"] = _gettext_alias + environment.extend( + install_gettext_translations=self._install, + install_null_translations=self._install_null, + install_gettext_callables=self._install_callables, + uninstall_gettext_translations=self._uninstall, + extract_translations=self._extract, + newstyle_gettext=False, + ) + + def _install( + self, translations: "_SupportedTranslations", newstyle: t.Optional[bool] = None + ) -> None: + # ugettext and ungettext are preferred in case the I18N library + # is providing compatibility with older Python versions. + gettext = getattr(translations, "ugettext", None) + if gettext is None: + gettext = translations.gettext + ngettext = getattr(translations, "ungettext", None) + if ngettext is None: + ngettext = translations.ngettext + + pgettext = getattr(translations, "pgettext", None) + npgettext = getattr(translations, "npgettext", None) + self._install_callables( + gettext, ngettext, newstyle=newstyle, pgettext=pgettext, npgettext=npgettext + ) + + def _install_null(self, newstyle: t.Optional[bool] = None) -> None: + import gettext + + translations = gettext.NullTranslations() + + if hasattr(translations, "pgettext"): + # Python < 3.8 + pgettext = translations.pgettext # type: ignore + else: + + def pgettext(c: str, s: str) -> str: + return s + + if hasattr(translations, "npgettext"): + npgettext = translations.npgettext # type: ignore + else: + + def npgettext(c: str, s: str, p: str, n: int) -> str: + return s if n == 1 else p + + self._install_callables( + gettext=translations.gettext, + ngettext=translations.ngettext, + newstyle=newstyle, + pgettext=pgettext, + npgettext=npgettext, + ) + + def _install_callables( + self, + gettext: t.Callable[[str], str], + ngettext: t.Callable[[str, str, int], str], + newstyle: t.Optional[bool] = None, + pgettext: t.Optional[t.Callable[[str, str], str]] = None, + npgettext: t.Optional[t.Callable[[str, str, str, int], str]] = None, + ) -> None: + if newstyle is not None: + self.environment.newstyle_gettext = newstyle # type: ignore + if self.environment.newstyle_gettext: # type: ignore + gettext = _make_new_gettext(gettext) + ngettext = _make_new_ngettext(ngettext) + + if pgettext is not None: + pgettext = _make_new_pgettext(pgettext) + + if npgettext is not None: + npgettext = _make_new_npgettext(npgettext) + + self.environment.globals.update( + gettext=gettext, ngettext=ngettext, pgettext=pgettext, npgettext=npgettext + ) + + def _uninstall(self, translations: "_SupportedTranslations") -> None: + for key in ("gettext", "ngettext", "pgettext", "npgettext"): + self.environment.globals.pop(key, None) + + def _extract( + self, + source: t.Union[str, nodes.Template], + gettext_functions: t.Sequence[str] = GETTEXT_FUNCTIONS, + ) -> t.Iterator[ + t.Tuple[int, str, t.Union[t.Optional[str], t.Tuple[t.Optional[str], ...]]] + ]: + if isinstance(source, str): + source = self.environment.parse(source) + return extract_from_ast(source, gettext_functions) + + def parse(self, parser: "Parser") -> t.Union[nodes.Node, t.List[nodes.Node]]: + """Parse a translatable tag.""" + lineno = next(parser.stream).lineno + + context = None + context_token = parser.stream.next_if("string") + + if context_token is not None: + context = context_token.value + + # find all the variables referenced. Additionally a variable can be + # defined in the body of the trans block too, but this is checked at + # a later state. + plural_expr: t.Optional[nodes.Expr] = None + plural_expr_assignment: t.Optional[nodes.Assign] = None + num_called_num = False + variables: t.Dict[str, nodes.Expr] = {} + trimmed = None + while parser.stream.current.type != "block_end": + if variables: + parser.stream.expect("comma") + + # skip colon for python compatibility + if parser.stream.skip_if("colon"): + break + + token = parser.stream.expect("name") + if token.value in variables: + parser.fail( + f"translatable variable {token.value!r} defined twice.", + token.lineno, + exc=TemplateAssertionError, + ) + + # expressions + if parser.stream.current.type == "assign": + next(parser.stream) + variables[token.value] = var = parser.parse_expression() + elif trimmed is None and token.value in ("trimmed", "notrimmed"): + trimmed = token.value == "trimmed" + continue + else: + variables[token.value] = var = nodes.Name(token.value, "load") + + if plural_expr is None: + if isinstance(var, nodes.Call): + plural_expr = nodes.Name("_trans", "load") + variables[token.value] = plural_expr + plural_expr_assignment = nodes.Assign( + nodes.Name("_trans", "store"), var + ) + else: + plural_expr = var + num_called_num = token.value == "num" + + parser.stream.expect("block_end") + + plural = None + have_plural = False + referenced = set() + + # now parse until endtrans or pluralize + singular_names, singular = self._parse_block(parser, True) + if singular_names: + referenced.update(singular_names) + if plural_expr is None: + plural_expr = nodes.Name(singular_names[0], "load") + num_called_num = singular_names[0] == "num" + + # if we have a pluralize block, we parse that too + if parser.stream.current.test("name:pluralize"): + have_plural = True + next(parser.stream) + if parser.stream.current.type != "block_end": + token = parser.stream.expect("name") + if token.value not in variables: + parser.fail( + f"unknown variable {token.value!r} for pluralization", + token.lineno, + exc=TemplateAssertionError, + ) + plural_expr = variables[token.value] + num_called_num = token.value == "num" + parser.stream.expect("block_end") + plural_names, plural = self._parse_block(parser, False) + next(parser.stream) + referenced.update(plural_names) + else: + next(parser.stream) + + # register free names as simple name expressions + for name in referenced: + if name not in variables: + variables[name] = nodes.Name(name, "load") + + if not have_plural: + plural_expr = None + elif plural_expr is None: + parser.fail("pluralize without variables", lineno) + + if trimmed is None: + trimmed = self.environment.policies["ext.i18n.trimmed"] + if trimmed: + singular = self._trim_whitespace(singular) + if plural: + plural = self._trim_whitespace(plural) + + node = self._make_node( + singular, + plural, + context, + variables, + plural_expr, + bool(referenced), + num_called_num and have_plural, + ) + node.set_lineno(lineno) + if plural_expr_assignment is not None: + return [plural_expr_assignment, node] + else: + return node + + def _trim_whitespace(self, string: str, _ws_re: t.Pattern[str] = _ws_re) -> str: + return _ws_re.sub(" ", string.strip()) + + def _parse_block( + self, parser: "Parser", allow_pluralize: bool + ) -> t.Tuple[t.List[str], str]: + """Parse until the next block tag with a given name.""" + referenced = [] + buf = [] + + while True: + if parser.stream.current.type == "data": + buf.append(parser.stream.current.value.replace("%", "%%")) + next(parser.stream) + elif parser.stream.current.type == "variable_begin": + next(parser.stream) + name = parser.stream.expect("name").value + referenced.append(name) + buf.append(f"%({name})s") + parser.stream.expect("variable_end") + elif parser.stream.current.type == "block_begin": + next(parser.stream) + if parser.stream.current.test("name:endtrans"): + break + elif parser.stream.current.test("name:pluralize"): + if allow_pluralize: + break + parser.fail( + "a translatable section can have only one pluralize section" + ) + parser.fail( + "control structures in translatable sections are not allowed" + ) + elif parser.stream.eos: + parser.fail("unclosed translation block") + else: + raise RuntimeError("internal parser error") + + return referenced, concat(buf) + + def _make_node( + self, + singular: str, + plural: t.Optional[str], + context: t.Optional[str], + variables: t.Dict[str, nodes.Expr], + plural_expr: t.Optional[nodes.Expr], + vars_referenced: bool, + num_called_num: bool, + ) -> nodes.Output: + """Generates a useful node from the data provided.""" + newstyle = self.environment.newstyle_gettext # type: ignore + node: nodes.Expr + + # no variables referenced? no need to escape for old style + # gettext invocations only if there are vars. + if not vars_referenced and not newstyle: + singular = singular.replace("%%", "%") + if plural: + plural = plural.replace("%%", "%") + + func_name = "gettext" + func_args: t.List[nodes.Expr] = [nodes.Const(singular)] + + if context is not None: + func_args.insert(0, nodes.Const(context)) + func_name = f"p{func_name}" + + if plural_expr is not None: + func_name = f"n{func_name}" + func_args.extend((nodes.Const(plural), plural_expr)) + + node = nodes.Call(nodes.Name(func_name, "load"), func_args, [], None, None) + + # in case newstyle gettext is used, the method is powerful + # enough to handle the variable expansion and autoescape + # handling itself + if newstyle: + for key, value in variables.items(): + # the function adds that later anyways in case num was + # called num, so just skip it. + if num_called_num and key == "num": + continue + node.kwargs.append(nodes.Keyword(key, value)) + + # otherwise do that here + else: + # mark the return value as safe if we are in an + # environment with autoescaping turned on + node = nodes.MarkSafeIfAutoescape(node) + if variables: + node = nodes.Mod( + node, + nodes.Dict( + [ + nodes.Pair(nodes.Const(key), value) + for key, value in variables.items() + ] + ), + ) + return nodes.Output([node]) + + +class ExprStmtExtension(Extension): + """Adds a `do` tag to Jinja that works like the print statement just + that it doesn't print the return value. + """ + + tags = {"do"} + + def parse(self, parser: "Parser") -> nodes.ExprStmt: + node = nodes.ExprStmt(lineno=next(parser.stream).lineno) + node.node = parser.parse_tuple() + return node + + +class LoopControlExtension(Extension): + """Adds break and continue to the template engine.""" + + tags = {"break", "continue"} + + def parse(self, parser: "Parser") -> t.Union[nodes.Break, nodes.Continue]: + token = next(parser.stream) + if token.value == "break": + return nodes.Break(lineno=token.lineno) + return nodes.Continue(lineno=token.lineno) + + +class DebugExtension(Extension): + """A ``{% debug %}`` tag that dumps the available variables, + filters, and tests. + + .. code-block:: html+jinja + + <pre>{% debug %}</pre> + + .. code-block:: text + + {'context': {'cycler': <class 'jinja2.utils.Cycler'>, + ..., + 'namespace': <class 'jinja2.utils.Namespace'>}, + 'filters': ['abs', 'attr', 'batch', 'capitalize', 'center', 'count', 'd', + ..., 'urlencode', 'urlize', 'wordcount', 'wordwrap', 'xmlattr'], + 'tests': ['!=', '<', '<=', '==', '>', '>=', 'callable', 'defined', + ..., 'odd', 'sameas', 'sequence', 'string', 'undefined', 'upper']} + + .. versionadded:: 2.11.0 + """ + + tags = {"debug"} + + def parse(self, parser: "Parser") -> nodes.Output: + lineno = parser.stream.expect("name:debug").lineno + context = nodes.ContextReference() + result = self.call_method("_render", [context], lineno=lineno) + return nodes.Output([result], lineno=lineno) + + def _render(self, context: Context) -> str: + result = { + "context": context.get_all(), + "filters": sorted(self.environment.filters.keys()), + "tests": sorted(self.environment.tests.keys()), + } + + # Set the depth since the intent is to show the top few names. + return pprint.pformat(result, depth=3, compact=True) + + +def extract_from_ast( + ast: nodes.Template, + gettext_functions: t.Sequence[str] = GETTEXT_FUNCTIONS, + babel_style: bool = True, +) -> t.Iterator[ + t.Tuple[int, str, t.Union[t.Optional[str], t.Tuple[t.Optional[str], ...]]] +]: + """Extract localizable strings from the given template node. Per + default this function returns matches in babel style that means non string + parameters as well as keyword arguments are returned as `None`. This + allows Babel to figure out what you really meant if you are using + gettext functions that allow keyword arguments for placeholder expansion. + If you don't want that behavior set the `babel_style` parameter to `False` + which causes only strings to be returned and parameters are always stored + in tuples. As a consequence invalid gettext calls (calls without a single + string parameter or string parameters after non-string parameters) are + skipped. + + This example explains the behavior: + + >>> from jinja2 import Environment + >>> env = Environment() + >>> node = env.parse('{{ (_("foo"), _(), ngettext("foo", "bar", 42)) }}') + >>> list(extract_from_ast(node)) + [(1, '_', 'foo'), (1, '_', ()), (1, 'ngettext', ('foo', 'bar', None))] + >>> list(extract_from_ast(node, babel_style=False)) + [(1, '_', ('foo',)), (1, 'ngettext', ('foo', 'bar'))] + + For every string found this function yields a ``(lineno, function, + message)`` tuple, where: + + * ``lineno`` is the number of the line on which the string was found, + * ``function`` is the name of the ``gettext`` function used (if the + string was extracted from embedded Python code), and + * ``message`` is the string, or a tuple of strings for functions + with multiple string arguments. + + This extraction function operates on the AST and is because of that unable + to extract any comments. For comment support you have to use the babel + extraction interface or extract comments yourself. + """ + out: t.Union[t.Optional[str], t.Tuple[t.Optional[str], ...]] + + for node in ast.find_all(nodes.Call): + if ( + not isinstance(node.node, nodes.Name) + or node.node.name not in gettext_functions + ): + continue + + strings: t.List[t.Optional[str]] = [] + + for arg in node.args: + if isinstance(arg, nodes.Const) and isinstance(arg.value, str): + strings.append(arg.value) + else: + strings.append(None) + + for _ in node.kwargs: + strings.append(None) + if node.dyn_args is not None: + strings.append(None) + if node.dyn_kwargs is not None: + strings.append(None) + + if not babel_style: + out = tuple(x for x in strings if x is not None) + + if not out: + continue + else: + if len(strings) == 1: + out = strings[0] + else: + out = tuple(strings) + + yield node.lineno, node.node.name, out + + +class _CommentFinder: + """Helper class to find comments in a token stream. Can only + find comments for gettext calls forwards. Once the comment + from line 4 is found, a comment for line 1 will not return a + usable value. + """ + + def __init__( + self, tokens: t.Sequence[t.Tuple[int, str, str]], comment_tags: t.Sequence[str] + ) -> None: + self.tokens = tokens + self.comment_tags = comment_tags + self.offset = 0 + self.last_lineno = 0 + + def find_backwards(self, offset: int) -> t.List[str]: + try: + for _, token_type, token_value in reversed( + self.tokens[self.offset : offset] + ): + if token_type in ("comment", "linecomment"): + try: + prefix, comment = token_value.split(None, 1) + except ValueError: + continue + if prefix in self.comment_tags: + return [comment.rstrip()] + return [] + finally: + self.offset = offset + + def find_comments(self, lineno: int) -> t.List[str]: + if not self.comment_tags or self.last_lineno > lineno: + return [] + for idx, (token_lineno, _, _) in enumerate(self.tokens[self.offset :]): + if token_lineno > lineno: + return self.find_backwards(self.offset + idx) + return self.find_backwards(len(self.tokens)) + + +def babel_extract( + fileobj: t.BinaryIO, + keywords: t.Sequence[str], + comment_tags: t.Sequence[str], + options: t.Dict[str, t.Any], +) -> t.Iterator[ + t.Tuple[ + int, str, t.Union[t.Optional[str], t.Tuple[t.Optional[str], ...]], t.List[str] + ] +]: + """Babel extraction method for Jinja templates. + + .. versionchanged:: 2.3 + Basic support for translation comments was added. If `comment_tags` + is now set to a list of keywords for extraction, the extractor will + try to find the best preceding comment that begins with one of the + keywords. For best results, make sure to not have more than one + gettext call in one line of code and the matching comment in the + same line or the line before. + + .. versionchanged:: 2.5.1 + The `newstyle_gettext` flag can be set to `True` to enable newstyle + gettext calls. + + .. versionchanged:: 2.7 + A `silent` option can now be provided. If set to `False` template + syntax errors are propagated instead of being ignored. + + :param fileobj: the file-like object the messages should be extracted from + :param keywords: a list of keywords (i.e. function names) that should be + recognized as translation functions + :param comment_tags: a list of translator tags to search for and include + in the results. + :param options: a dictionary of additional options (optional) + :return: an iterator over ``(lineno, funcname, message, comments)`` tuples. + (comments will be empty currently) + """ + extensions: t.Dict[t.Type[Extension], None] = {} + + for extension_name in options.get("extensions", "").split(","): + extension_name = extension_name.strip() + + if not extension_name: + continue + + extensions[import_string(extension_name)] = None + + if InternationalizationExtension not in extensions: + extensions[InternationalizationExtension] = None + + def getbool(options: t.Mapping[str, str], key: str, default: bool = False) -> bool: + return options.get(key, str(default)).lower() in {"1", "on", "yes", "true"} + + silent = getbool(options, "silent", True) + environment = Environment( + options.get("block_start_string", defaults.BLOCK_START_STRING), + options.get("block_end_string", defaults.BLOCK_END_STRING), + options.get("variable_start_string", defaults.VARIABLE_START_STRING), + options.get("variable_end_string", defaults.VARIABLE_END_STRING), + options.get("comment_start_string", defaults.COMMENT_START_STRING), + options.get("comment_end_string", defaults.COMMENT_END_STRING), + options.get("line_statement_prefix") or defaults.LINE_STATEMENT_PREFIX, + options.get("line_comment_prefix") or defaults.LINE_COMMENT_PREFIX, + getbool(options, "trim_blocks", defaults.TRIM_BLOCKS), + getbool(options, "lstrip_blocks", defaults.LSTRIP_BLOCKS), + defaults.NEWLINE_SEQUENCE, + getbool(options, "keep_trailing_newline", defaults.KEEP_TRAILING_NEWLINE), + tuple(extensions), + cache_size=0, + auto_reload=False, + ) + + if getbool(options, "trimmed"): + environment.policies["ext.i18n.trimmed"] = True + if getbool(options, "newstyle_gettext"): + environment.newstyle_gettext = True # type: ignore + + source = fileobj.read().decode(options.get("encoding", "utf-8")) + try: + node = environment.parse(source) + tokens = list(environment.lex(environment.preprocess(source))) + except TemplateSyntaxError: + if not silent: + raise + # skip templates with syntax errors + return + + finder = _CommentFinder(tokens, comment_tags) + for lineno, func, message in extract_from_ast(node, keywords): + yield lineno, func, message, finder.find_comments(lineno) + + +#: nicer import names +i18n = InternationalizationExtension +do = ExprStmtExtension +loopcontrols = LoopControlExtension +debug = DebugExtension diff --git a/src/jinja2/filters.py b/src/jinja2/filters.py new file mode 100644 index 0000000..ed07c4c --- /dev/null +++ b/src/jinja2/filters.py @@ -0,0 +1,1840 @@ +"""Built-in template filters used with the ``|`` operator.""" +import math +import random +import re +import typing +import typing as t +from collections import abc +from itertools import chain +from itertools import groupby + +from markupsafe import escape +from markupsafe import Markup +from markupsafe import soft_str + +from .async_utils import async_variant +from .async_utils import auto_aiter +from .async_utils import auto_await +from .async_utils import auto_to_list +from .exceptions import FilterArgumentError +from .runtime import Undefined +from .utils import htmlsafe_json_dumps +from .utils import pass_context +from .utils import pass_environment +from .utils import pass_eval_context +from .utils import pformat +from .utils import url_quote +from .utils import urlize + +if t.TYPE_CHECKING: + import typing_extensions as te + from .environment import Environment + from .nodes import EvalContext + from .runtime import Context + from .sandbox import SandboxedEnvironment # noqa: F401 + + class HasHTML(te.Protocol): + def __html__(self) -> str: + pass + + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) +K = t.TypeVar("K") +V = t.TypeVar("V") + + +def ignore_case(value: V) -> V: + """For use as a postprocessor for :func:`make_attrgetter`. Converts strings + to lowercase and returns other types as-is.""" + if isinstance(value, str): + return t.cast(V, value.lower()) + + return value + + +def make_attrgetter( + environment: "Environment", + attribute: t.Optional[t.Union[str, int]], + postprocess: t.Optional[t.Callable[[t.Any], t.Any]] = None, + default: t.Optional[t.Any] = None, +) -> t.Callable[[t.Any], t.Any]: + """Returns a callable that looks up the given attribute from a + passed object with the rules of the environment. Dots are allowed + to access attributes of attributes. Integer parts in paths are + looked up as integers. + """ + parts = _prepare_attribute_parts(attribute) + + def attrgetter(item: t.Any) -> t.Any: + for part in parts: + item = environment.getitem(item, part) + + if default is not None and isinstance(item, Undefined): + item = default + + if postprocess is not None: + item = postprocess(item) + + return item + + return attrgetter + + +def make_multi_attrgetter( + environment: "Environment", + attribute: t.Optional[t.Union[str, int]], + postprocess: t.Optional[t.Callable[[t.Any], t.Any]] = None, +) -> t.Callable[[t.Any], t.List[t.Any]]: + """Returns a callable that looks up the given comma separated + attributes from a passed object with the rules of the environment. + Dots are allowed to access attributes of each attribute. Integer + parts in paths are looked up as integers. + + The value returned by the returned callable is a list of extracted + attribute values. + + Examples of attribute: "attr1,attr2", "attr1.inner1.0,attr2.inner2.0", etc. + """ + if isinstance(attribute, str): + split: t.Sequence[t.Union[str, int, None]] = attribute.split(",") + else: + split = [attribute] + + parts = [_prepare_attribute_parts(item) for item in split] + + def attrgetter(item: t.Any) -> t.List[t.Any]: + items = [None] * len(parts) + + for i, attribute_part in enumerate(parts): + item_i = item + + for part in attribute_part: + item_i = environment.getitem(item_i, part) + + if postprocess is not None: + item_i = postprocess(item_i) + + items[i] = item_i + + return items + + return attrgetter + + +def _prepare_attribute_parts( + attr: t.Optional[t.Union[str, int]] +) -> t.List[t.Union[str, int]]: + if attr is None: + return [] + + if isinstance(attr, str): + return [int(x) if x.isdigit() else x for x in attr.split(".")] + + return [attr] + + +def do_forceescape(value: "t.Union[str, HasHTML]") -> Markup: + """Enforce HTML escaping. This will probably double escape variables.""" + if hasattr(value, "__html__"): + value = t.cast("HasHTML", value).__html__() + + return escape(str(value)) + + +def do_urlencode( + value: t.Union[str, t.Mapping[str, t.Any], t.Iterable[t.Tuple[str, t.Any]]] +) -> str: + """Quote data for use in a URL path or query using UTF-8. + + Basic wrapper around :func:`urllib.parse.quote` when given a + string, or :func:`urllib.parse.urlencode` for a dict or iterable. + + :param value: Data to quote. A string will be quoted directly. A + dict or iterable of ``(key, value)`` pairs will be joined as a + query string. + + When given a string, "/" is not quoted. HTTP servers treat "/" and + "%2F" equivalently in paths. If you need quoted slashes, use the + ``|replace("/", "%2F")`` filter. + + .. versionadded:: 2.7 + """ + if isinstance(value, str) or not isinstance(value, abc.Iterable): + return url_quote(value) + + if isinstance(value, dict): + items: t.Iterable[t.Tuple[str, t.Any]] = value.items() + else: + items = value # type: ignore + + return "&".join( + f"{url_quote(k, for_qs=True)}={url_quote(v, for_qs=True)}" for k, v in items + ) + + +@pass_eval_context +def do_replace( + eval_ctx: "EvalContext", s: str, old: str, new: str, count: t.Optional[int] = None +) -> str: + """Return a copy of the value with all occurrences of a substring + replaced with a new one. The first argument is the substring + that should be replaced, the second is the replacement string. + If the optional third argument ``count`` is given, only the first + ``count`` occurrences are replaced: + + .. sourcecode:: jinja + + {{ "Hello World"|replace("Hello", "Goodbye") }} + -> Goodbye World + + {{ "aaaaargh"|replace("a", "d'oh, ", 2) }} + -> d'oh, d'oh, aaargh + """ + if count is None: + count = -1 + + if not eval_ctx.autoescape: + return str(s).replace(str(old), str(new), count) + + if ( + hasattr(old, "__html__") + or hasattr(new, "__html__") + and not hasattr(s, "__html__") + ): + s = escape(s) + else: + s = soft_str(s) + + return s.replace(soft_str(old), soft_str(new), count) + + +def do_upper(s: str) -> str: + """Convert a value to uppercase.""" + return soft_str(s).upper() + + +def do_lower(s: str) -> str: + """Convert a value to lowercase.""" + return soft_str(s).lower() + + +def do_items(value: t.Union[t.Mapping[K, V], Undefined]) -> t.Iterator[t.Tuple[K, V]]: + """Return an iterator over the ``(key, value)`` items of a mapping. + + ``x|items`` is the same as ``x.items()``, except if ``x`` is + undefined an empty iterator is returned. + + This filter is useful if you expect the template to be rendered with + an implementation of Jinja in another programming language that does + not have a ``.items()`` method on its mapping type. + + .. code-block:: html+jinja + + <dl> + {% for key, value in my_dict|items %} + <dt>{{ key }} + <dd>{{ value }} + {% endfor %} + </dl> + + .. versionadded:: 3.1 + """ + if isinstance(value, Undefined): + return + + if not isinstance(value, abc.Mapping): + raise TypeError("Can only get item pairs from a mapping.") + + yield from value.items() + + +@pass_eval_context +def do_xmlattr( + eval_ctx: "EvalContext", d: t.Mapping[str, t.Any], autospace: bool = True +) -> str: + """Create an SGML/XML attribute string based on the items in a dict. + All values that are neither `none` nor `undefined` are automatically + escaped: + + .. sourcecode:: html+jinja + + <ul{{ {'class': 'my_list', 'missing': none, + 'id': 'list-%d'|format(variable)}|xmlattr }}> + ... + </ul> + + Results in something like this: + + .. sourcecode:: html + + <ul class="my_list" id="list-42"> + ... + </ul> + + As you can see it automatically prepends a space in front of the item + if the filter returned something unless the second parameter is false. + """ + rv = " ".join( + f'{escape(key)}="{escape(value)}"' + for key, value in d.items() + if value is not None and not isinstance(value, Undefined) + ) + + if autospace and rv: + rv = " " + rv + + if eval_ctx.autoescape: + rv = Markup(rv) + + return rv + + +def do_capitalize(s: str) -> str: + """Capitalize a value. The first character will be uppercase, all others + lowercase. + """ + return soft_str(s).capitalize() + + +_word_beginning_split_re = re.compile(r"([-\s({\[<]+)") + + +def do_title(s: str) -> str: + """Return a titlecased version of the value. I.e. words will start with + uppercase letters, all remaining characters are lowercase. + """ + return "".join( + [ + item[0].upper() + item[1:].lower() + for item in _word_beginning_split_re.split(soft_str(s)) + if item + ] + ) + + +def do_dictsort( + value: t.Mapping[K, V], + case_sensitive: bool = False, + by: 'te.Literal["key", "value"]' = "key", + reverse: bool = False, +) -> t.List[t.Tuple[K, V]]: + """Sort a dict and yield (key, value) pairs. Python dicts may not + be in the order you want to display them in, so sort them first. + + .. sourcecode:: jinja + + {% for key, value in mydict|dictsort %} + sort the dict by key, case insensitive + + {% for key, value in mydict|dictsort(reverse=true) %} + sort the dict by key, case insensitive, reverse order + + {% for key, value in mydict|dictsort(true) %} + sort the dict by key, case sensitive + + {% for key, value in mydict|dictsort(false, 'value') %} + sort the dict by value, case insensitive + """ + if by == "key": + pos = 0 + elif by == "value": + pos = 1 + else: + raise FilterArgumentError('You can only sort by either "key" or "value"') + + def sort_func(item: t.Tuple[t.Any, t.Any]) -> t.Any: + value = item[pos] + + if not case_sensitive: + value = ignore_case(value) + + return value + + return sorted(value.items(), key=sort_func, reverse=reverse) + + +@pass_environment +def do_sort( + environment: "Environment", + value: "t.Iterable[V]", + reverse: bool = False, + case_sensitive: bool = False, + attribute: t.Optional[t.Union[str, int]] = None, +) -> "t.List[V]": + """Sort an iterable using Python's :func:`sorted`. + + .. sourcecode:: jinja + + {% for city in cities|sort %} + ... + {% endfor %} + + :param reverse: Sort descending instead of ascending. + :param case_sensitive: When sorting strings, sort upper and lower + case separately. + :param attribute: When sorting objects or dicts, an attribute or + key to sort by. Can use dot notation like ``"address.city"``. + Can be a list of attributes like ``"age,name"``. + + The sort is stable, it does not change the relative order of + elements that compare equal. This makes it is possible to chain + sorts on different attributes and ordering. + + .. sourcecode:: jinja + + {% for user in users|sort(attribute="name") + |sort(reverse=true, attribute="age") %} + ... + {% endfor %} + + As a shortcut to chaining when the direction is the same for all + attributes, pass a comma separate list of attributes. + + .. sourcecode:: jinja + + {% for user in users|sort(attribute="age,name") %} + ... + {% endfor %} + + .. versionchanged:: 2.11.0 + The ``attribute`` parameter can be a comma separated list of + attributes, e.g. ``"age,name"``. + + .. versionchanged:: 2.6 + The ``attribute`` parameter was added. + """ + key_func = make_multi_attrgetter( + environment, attribute, postprocess=ignore_case if not case_sensitive else None + ) + return sorted(value, key=key_func, reverse=reverse) + + +@pass_environment +def do_unique( + environment: "Environment", + value: "t.Iterable[V]", + case_sensitive: bool = False, + attribute: t.Optional[t.Union[str, int]] = None, +) -> "t.Iterator[V]": + """Returns a list of unique items from the given iterable. + + .. sourcecode:: jinja + + {{ ['foo', 'bar', 'foobar', 'FooBar']|unique|list }} + -> ['foo', 'bar', 'foobar'] + + The unique items are yielded in the same order as their first occurrence in + the iterable passed to the filter. + + :param case_sensitive: Treat upper and lower case strings as distinct. + :param attribute: Filter objects with unique values for this attribute. + """ + getter = make_attrgetter( + environment, attribute, postprocess=ignore_case if not case_sensitive else None + ) + seen = set() + + for item in value: + key = getter(item) + + if key not in seen: + seen.add(key) + yield item + + +def _min_or_max( + environment: "Environment", + value: "t.Iterable[V]", + func: "t.Callable[..., V]", + case_sensitive: bool, + attribute: t.Optional[t.Union[str, int]], +) -> "t.Union[V, Undefined]": + it = iter(value) + + try: + first = next(it) + except StopIteration: + return environment.undefined("No aggregated item, sequence was empty.") + + key_func = make_attrgetter( + environment, attribute, postprocess=ignore_case if not case_sensitive else None + ) + return func(chain([first], it), key=key_func) + + +@pass_environment +def do_min( + environment: "Environment", + value: "t.Iterable[V]", + case_sensitive: bool = False, + attribute: t.Optional[t.Union[str, int]] = None, +) -> "t.Union[V, Undefined]": + """Return the smallest item from the sequence. + + .. sourcecode:: jinja + + {{ [1, 2, 3]|min }} + -> 1 + + :param case_sensitive: Treat upper and lower case strings as distinct. + :param attribute: Get the object with the min value of this attribute. + """ + return _min_or_max(environment, value, min, case_sensitive, attribute) + + +@pass_environment +def do_max( + environment: "Environment", + value: "t.Iterable[V]", + case_sensitive: bool = False, + attribute: t.Optional[t.Union[str, int]] = None, +) -> "t.Union[V, Undefined]": + """Return the largest item from the sequence. + + .. sourcecode:: jinja + + {{ [1, 2, 3]|max }} + -> 3 + + :param case_sensitive: Treat upper and lower case strings as distinct. + :param attribute: Get the object with the max value of this attribute. + """ + return _min_or_max(environment, value, max, case_sensitive, attribute) + + +def do_default( + value: V, + default_value: V = "", # type: ignore + boolean: bool = False, +) -> V: + """If the value is undefined it will return the passed default value, + otherwise the value of the variable: + + .. sourcecode:: jinja + + {{ my_variable|default('my_variable is not defined') }} + + This will output the value of ``my_variable`` if the variable was + defined, otherwise ``'my_variable is not defined'``. If you want + to use default with variables that evaluate to false you have to + set the second parameter to `true`: + + .. sourcecode:: jinja + + {{ ''|default('the string was empty', true) }} + + .. versionchanged:: 2.11 + It's now possible to configure the :class:`~jinja2.Environment` with + :class:`~jinja2.ChainableUndefined` to make the `default` filter work + on nested elements and attributes that may contain undefined values + in the chain without getting an :exc:`~jinja2.UndefinedError`. + """ + if isinstance(value, Undefined) or (boolean and not value): + return default_value + + return value + + +@pass_eval_context +def sync_do_join( + eval_ctx: "EvalContext", + value: t.Iterable, + d: str = "", + attribute: t.Optional[t.Union[str, int]] = None, +) -> str: + """Return a string which is the concatenation of the strings in the + sequence. The separator between elements is an empty string per + default, you can define it with the optional parameter: + + .. sourcecode:: jinja + + {{ [1, 2, 3]|join('|') }} + -> 1|2|3 + + {{ [1, 2, 3]|join }} + -> 123 + + It is also possible to join certain attributes of an object: + + .. sourcecode:: jinja + + {{ users|join(', ', attribute='username') }} + + .. versionadded:: 2.6 + The `attribute` parameter was added. + """ + if attribute is not None: + value = map(make_attrgetter(eval_ctx.environment, attribute), value) + + # no automatic escaping? joining is a lot easier then + if not eval_ctx.autoescape: + return str(d).join(map(str, value)) + + # if the delimiter doesn't have an html representation we check + # if any of the items has. If yes we do a coercion to Markup + if not hasattr(d, "__html__"): + value = list(value) + do_escape = False + + for idx, item in enumerate(value): + if hasattr(item, "__html__"): + do_escape = True + else: + value[idx] = str(item) + + if do_escape: + d = escape(d) + else: + d = str(d) + + return d.join(value) + + # no html involved, to normal joining + return soft_str(d).join(map(soft_str, value)) + + +@async_variant(sync_do_join) # type: ignore +async def do_join( + eval_ctx: "EvalContext", + value: t.Union[t.AsyncIterable, t.Iterable], + d: str = "", + attribute: t.Optional[t.Union[str, int]] = None, +) -> str: + return sync_do_join(eval_ctx, await auto_to_list(value), d, attribute) + + +def do_center(value: str, width: int = 80) -> str: + """Centers the value in a field of a given width.""" + return soft_str(value).center(width) + + +@pass_environment +def sync_do_first( + environment: "Environment", seq: "t.Iterable[V]" +) -> "t.Union[V, Undefined]": + """Return the first item of a sequence.""" + try: + return next(iter(seq)) + except StopIteration: + return environment.undefined("No first item, sequence was empty.") + + +@async_variant(sync_do_first) # type: ignore +async def do_first( + environment: "Environment", seq: "t.Union[t.AsyncIterable[V], t.Iterable[V]]" +) -> "t.Union[V, Undefined]": + try: + return await auto_aiter(seq).__anext__() + except StopAsyncIteration: + return environment.undefined("No first item, sequence was empty.") + + +@pass_environment +def do_last( + environment: "Environment", seq: "t.Reversible[V]" +) -> "t.Union[V, Undefined]": + """Return the last item of a sequence. + + Note: Does not work with generators. You may want to explicitly + convert it to a list: + + .. sourcecode:: jinja + + {{ data | selectattr('name', '==', 'Jinja') | list | last }} + """ + try: + return next(iter(reversed(seq))) + except StopIteration: + return environment.undefined("No last item, sequence was empty.") + + +# No async do_last, it may not be safe in async mode. + + +@pass_context +def do_random(context: "Context", seq: "t.Sequence[V]") -> "t.Union[V, Undefined]": + """Return a random item from the sequence.""" + try: + return random.choice(seq) + except IndexError: + return context.environment.undefined("No random item, sequence was empty.") + + +def do_filesizeformat(value: t.Union[str, float, int], binary: bool = False) -> str: + """Format the value like a 'human-readable' file size (i.e. 13 kB, + 4.1 MB, 102 Bytes, etc). Per default decimal prefixes are used (Mega, + Giga, etc.), if the second parameter is set to `True` the binary + prefixes are used (Mebi, Gibi). + """ + bytes = float(value) + base = 1024 if binary else 1000 + prefixes = [ + ("KiB" if binary else "kB"), + ("MiB" if binary else "MB"), + ("GiB" if binary else "GB"), + ("TiB" if binary else "TB"), + ("PiB" if binary else "PB"), + ("EiB" if binary else "EB"), + ("ZiB" if binary else "ZB"), + ("YiB" if binary else "YB"), + ] + + if bytes == 1: + return "1 Byte" + elif bytes < base: + return f"{int(bytes)} Bytes" + else: + for i, prefix in enumerate(prefixes): + unit = base ** (i + 2) + + if bytes < unit: + return f"{base * bytes / unit:.1f} {prefix}" + + return f"{base * bytes / unit:.1f} {prefix}" + + +def do_pprint(value: t.Any) -> str: + """Pretty print a variable. Useful for debugging.""" + return pformat(value) + + +_uri_scheme_re = re.compile(r"^([\w.+-]{2,}:(/){0,2})$") + + +@pass_eval_context +def do_urlize( + eval_ctx: "EvalContext", + value: str, + trim_url_limit: t.Optional[int] = None, + nofollow: bool = False, + target: t.Optional[str] = None, + rel: t.Optional[str] = None, + extra_schemes: t.Optional[t.Iterable[str]] = None, +) -> str: + """Convert URLs in text into clickable links. + + This may not recognize links in some situations. Usually, a more + comprehensive formatter, such as a Markdown library, is a better + choice. + + Works on ``http://``, ``https://``, ``www.``, ``mailto:``, and email + addresses. Links with trailing punctuation (periods, commas, closing + parentheses) and leading punctuation (opening parentheses) are + recognized excluding the punctuation. Email addresses that include + header fields are not recognized (for example, + ``mailto:address@example.com?cc=copy@example.com``). + + :param value: Original text containing URLs to link. + :param trim_url_limit: Shorten displayed URL values to this length. + :param nofollow: Add the ``rel=nofollow`` attribute to links. + :param target: Add the ``target`` attribute to links. + :param rel: Add the ``rel`` attribute to links. + :param extra_schemes: Recognize URLs that start with these schemes + in addition to the default behavior. Defaults to + ``env.policies["urlize.extra_schemes"]``, which defaults to no + extra schemes. + + .. versionchanged:: 3.0 + The ``extra_schemes`` parameter was added. + + .. versionchanged:: 3.0 + Generate ``https://`` links for URLs without a scheme. + + .. versionchanged:: 3.0 + The parsing rules were updated. Recognize email addresses with + or without the ``mailto:`` scheme. Validate IP addresses. Ignore + parentheses and brackets in more cases. + + .. versionchanged:: 2.8 + The ``target`` parameter was added. + """ + policies = eval_ctx.environment.policies + rel_parts = set((rel or "").split()) + + if nofollow: + rel_parts.add("nofollow") + + rel_parts.update((policies["urlize.rel"] or "").split()) + rel = " ".join(sorted(rel_parts)) or None + + if target is None: + target = policies["urlize.target"] + + if extra_schemes is None: + extra_schemes = policies["urlize.extra_schemes"] or () + + for scheme in extra_schemes: + if _uri_scheme_re.fullmatch(scheme) is None: + raise FilterArgumentError(f"{scheme!r} is not a valid URI scheme prefix.") + + rv = urlize( + value, + trim_url_limit=trim_url_limit, + rel=rel, + target=target, + extra_schemes=extra_schemes, + ) + + if eval_ctx.autoescape: + rv = Markup(rv) + + return rv + + +def do_indent( + s: str, width: t.Union[int, str] = 4, first: bool = False, blank: bool = False +) -> str: + """Return a copy of the string with each line indented by 4 spaces. The + first line and blank lines are not indented by default. + + :param width: Number of spaces, or a string, to indent by. + :param first: Don't skip indenting the first line. + :param blank: Don't skip indenting empty lines. + + .. versionchanged:: 3.0 + ``width`` can be a string. + + .. versionchanged:: 2.10 + Blank lines are not indented by default. + + Rename the ``indentfirst`` argument to ``first``. + """ + if isinstance(width, str): + indention = width + else: + indention = " " * width + + newline = "\n" + + if isinstance(s, Markup): + indention = Markup(indention) + newline = Markup(newline) + + s += newline # this quirk is necessary for splitlines method + + if blank: + rv = (newline + indention).join(s.splitlines()) + else: + lines = s.splitlines() + rv = lines.pop(0) + + if lines: + rv += newline + newline.join( + indention + line if line else line for line in lines + ) + + if first: + rv = indention + rv + + return rv + + +@pass_environment +def do_truncate( + env: "Environment", + s: str, + length: int = 255, + killwords: bool = False, + end: str = "...", + leeway: t.Optional[int] = None, +) -> str: + """Return a truncated copy of the string. The length is specified + with the first parameter which defaults to ``255``. If the second + parameter is ``true`` the filter will cut the text at length. Otherwise + it will discard the last word. If the text was in fact + truncated it will append an ellipsis sign (``"..."``). If you want a + different ellipsis sign than ``"..."`` you can specify it using the + third parameter. Strings that only exceed the length by the tolerance + margin given in the fourth parameter will not be truncated. + + .. sourcecode:: jinja + + {{ "foo bar baz qux"|truncate(9) }} + -> "foo..." + {{ "foo bar baz qux"|truncate(9, True) }} + -> "foo ba..." + {{ "foo bar baz qux"|truncate(11) }} + -> "foo bar baz qux" + {{ "foo bar baz qux"|truncate(11, False, '...', 0) }} + -> "foo bar..." + + The default leeway on newer Jinja versions is 5 and was 0 before but + can be reconfigured globally. + """ + if leeway is None: + leeway = env.policies["truncate.leeway"] + + assert length >= len(end), f"expected length >= {len(end)}, got {length}" + assert leeway >= 0, f"expected leeway >= 0, got {leeway}" + + if len(s) <= length + leeway: + return s + + if killwords: + return s[: length - len(end)] + end + + result = s[: length - len(end)].rsplit(" ", 1)[0] + return result + end + + +@pass_environment +def do_wordwrap( + environment: "Environment", + s: str, + width: int = 79, + break_long_words: bool = True, + wrapstring: t.Optional[str] = None, + break_on_hyphens: bool = True, +) -> str: + """Wrap a string to the given width. Existing newlines are treated + as paragraphs to be wrapped separately. + + :param s: Original text to wrap. + :param width: Maximum length of wrapped lines. + :param break_long_words: If a word is longer than ``width``, break + it across lines. + :param break_on_hyphens: If a word contains hyphens, it may be split + across lines. + :param wrapstring: String to join each wrapped line. Defaults to + :attr:`Environment.newline_sequence`. + + .. versionchanged:: 2.11 + Existing newlines are treated as paragraphs wrapped separately. + + .. versionchanged:: 2.11 + Added the ``break_on_hyphens`` parameter. + + .. versionchanged:: 2.7 + Added the ``wrapstring`` parameter. + """ + import textwrap + + if wrapstring is None: + wrapstring = environment.newline_sequence + + # textwrap.wrap doesn't consider existing newlines when wrapping. + # If the string has a newline before width, wrap will still insert + # a newline at width, resulting in a short line. Instead, split and + # wrap each paragraph individually. + return wrapstring.join( + [ + wrapstring.join( + textwrap.wrap( + line, + width=width, + expand_tabs=False, + replace_whitespace=False, + break_long_words=break_long_words, + break_on_hyphens=break_on_hyphens, + ) + ) + for line in s.splitlines() + ] + ) + + +_word_re = re.compile(r"\w+") + + +def do_wordcount(s: str) -> int: + """Count the words in that string.""" + return len(_word_re.findall(soft_str(s))) + + +def do_int(value: t.Any, default: int = 0, base: int = 10) -> int: + """Convert the value into an integer. If the + conversion doesn't work it will return ``0``. You can + override this default using the first parameter. You + can also override the default base (10) in the second + parameter, which handles input with prefixes such as + 0b, 0o and 0x for bases 2, 8 and 16 respectively. + The base is ignored for decimal numbers and non-string values. + """ + try: + if isinstance(value, str): + return int(value, base) + + return int(value) + except (TypeError, ValueError): + # this quirk is necessary so that "42.23"|int gives 42. + try: + return int(float(value)) + except (TypeError, ValueError): + return default + + +def do_float(value: t.Any, default: float = 0.0) -> float: + """Convert the value into a floating point number. If the + conversion doesn't work it will return ``0.0``. You can + override this default using the first parameter. + """ + try: + return float(value) + except (TypeError, ValueError): + return default + + +def do_format(value: str, *args: t.Any, **kwargs: t.Any) -> str: + """Apply the given values to a `printf-style`_ format string, like + ``string % values``. + + .. sourcecode:: jinja + + {{ "%s, %s!"|format(greeting, name) }} + Hello, World! + + In most cases it should be more convenient and efficient to use the + ``%`` operator or :meth:`str.format`. + + .. code-block:: text + + {{ "%s, %s!" % (greeting, name) }} + {{ "{}, {}!".format(greeting, name) }} + + .. _printf-style: https://docs.python.org/library/stdtypes.html + #printf-style-string-formatting + """ + if args and kwargs: + raise FilterArgumentError( + "can't handle positional and keyword arguments at the same time" + ) + + return soft_str(value) % (kwargs or args) + + +def do_trim(value: str, chars: t.Optional[str] = None) -> str: + """Strip leading and trailing characters, by default whitespace.""" + return soft_str(value).strip(chars) + + +def do_striptags(value: "t.Union[str, HasHTML]") -> str: + """Strip SGML/XML tags and replace adjacent whitespace by one space.""" + if hasattr(value, "__html__"): + value = t.cast("HasHTML", value).__html__() + + return Markup(str(value)).striptags() + + +def sync_do_slice( + value: "t.Collection[V]", slices: int, fill_with: "t.Optional[V]" = None +) -> "t.Iterator[t.List[V]]": + """Slice an iterator and return a list of lists containing + those items. Useful if you want to create a div containing + three ul tags that represent columns: + + .. sourcecode:: html+jinja + + <div class="columnwrapper"> + {%- for column in items|slice(3) %} + <ul class="column-{{ loop.index }}"> + {%- for item in column %} + <li>{{ item }}</li> + {%- endfor %} + </ul> + {%- endfor %} + </div> + + If you pass it a second argument it's used to fill missing + values on the last iteration. + """ + seq = list(value) + length = len(seq) + items_per_slice = length // slices + slices_with_extra = length % slices + offset = 0 + + for slice_number in range(slices): + start = offset + slice_number * items_per_slice + + if slice_number < slices_with_extra: + offset += 1 + + end = offset + (slice_number + 1) * items_per_slice + tmp = seq[start:end] + + if fill_with is not None and slice_number >= slices_with_extra: + tmp.append(fill_with) + + yield tmp + + +@async_variant(sync_do_slice) # type: ignore +async def do_slice( + value: "t.Union[t.AsyncIterable[V], t.Iterable[V]]", + slices: int, + fill_with: t.Optional[t.Any] = None, +) -> "t.Iterator[t.List[V]]": + return sync_do_slice(await auto_to_list(value), slices, fill_with) + + +def do_batch( + value: "t.Iterable[V]", linecount: int, fill_with: "t.Optional[V]" = None +) -> "t.Iterator[t.List[V]]": + """ + A filter that batches items. It works pretty much like `slice` + just the other way round. It returns a list of lists with the + given number of items. If you provide a second parameter this + is used to fill up missing items. See this example: + + .. sourcecode:: html+jinja + + <table> + {%- for row in items|batch(3, ' ') %} + <tr> + {%- for column in row %} + <td>{{ column }}</td> + {%- endfor %} + </tr> + {%- endfor %} + </table> + """ + tmp: "t.List[V]" = [] + + for item in value: + if len(tmp) == linecount: + yield tmp + tmp = [] + + tmp.append(item) + + if tmp: + if fill_with is not None and len(tmp) < linecount: + tmp += [fill_with] * (linecount - len(tmp)) + + yield tmp + + +def do_round( + value: float, + precision: int = 0, + method: 'te.Literal["common", "ceil", "floor"]' = "common", +) -> float: + """Round the number to a given precision. The first + parameter specifies the precision (default is ``0``), the + second the rounding method: + + - ``'common'`` rounds either up or down + - ``'ceil'`` always rounds up + - ``'floor'`` always rounds down + + If you don't specify a method ``'common'`` is used. + + .. sourcecode:: jinja + + {{ 42.55|round }} + -> 43.0 + {{ 42.55|round(1, 'floor') }} + -> 42.5 + + Note that even if rounded to 0 precision, a float is returned. If + you need a real integer, pipe it through `int`: + + .. sourcecode:: jinja + + {{ 42.55|round|int }} + -> 43 + """ + if method not in {"common", "ceil", "floor"}: + raise FilterArgumentError("method must be common, ceil or floor") + + if method == "common": + return round(value, precision) + + func = getattr(math, method) + return t.cast(float, func(value * (10**precision)) / (10**precision)) + + +class _GroupTuple(t.NamedTuple): + grouper: t.Any + list: t.List + + # Use the regular tuple repr to hide this subclass if users print + # out the value during debugging. + def __repr__(self) -> str: + return tuple.__repr__(self) + + def __str__(self) -> str: + return tuple.__str__(self) + + +@pass_environment +def sync_do_groupby( + environment: "Environment", + value: "t.Iterable[V]", + attribute: t.Union[str, int], + default: t.Optional[t.Any] = None, + case_sensitive: bool = False, +) -> "t.List[_GroupTuple]": + """Group a sequence of objects by an attribute using Python's + :func:`itertools.groupby`. The attribute can use dot notation for + nested access, like ``"address.city"``. Unlike Python's ``groupby``, + the values are sorted first so only one group is returned for each + unique value. + + For example, a list of ``User`` objects with a ``city`` attribute + can be rendered in groups. In this example, ``grouper`` refers to + the ``city`` value of the group. + + .. sourcecode:: html+jinja + + <ul>{% for city, items in users|groupby("city") %} + <li>{{ city }} + <ul>{% for user in items %} + <li>{{ user.name }} + {% endfor %}</ul> + </li> + {% endfor %}</ul> + + ``groupby`` yields namedtuples of ``(grouper, list)``, which + can be used instead of the tuple unpacking above. ``grouper`` is the + value of the attribute, and ``list`` is the items with that value. + + .. sourcecode:: html+jinja + + <ul>{% for group in users|groupby("city") %} + <li>{{ group.grouper }}: {{ group.list|join(", ") }} + {% endfor %}</ul> + + You can specify a ``default`` value to use if an object in the list + does not have the given attribute. + + .. sourcecode:: jinja + + <ul>{% for city, items in users|groupby("city", default="NY") %} + <li>{{ city }}: {{ items|map(attribute="name")|join(", ") }}</li> + {% endfor %}</ul> + + Like the :func:`~jinja-filters.sort` filter, sorting and grouping is + case-insensitive by default. The ``key`` for each group will have + the case of the first item in that group of values. For example, if + a list of users has cities ``["CA", "NY", "ca"]``, the "CA" group + will have two values. This can be disabled by passing + ``case_sensitive=True``. + + .. versionchanged:: 3.1 + Added the ``case_sensitive`` parameter. Sorting and grouping is + case-insensitive by default, matching other filters that do + comparisons. + + .. versionchanged:: 3.0 + Added the ``default`` parameter. + + .. versionchanged:: 2.6 + The attribute supports dot notation for nested access. + """ + expr = make_attrgetter( + environment, + attribute, + postprocess=ignore_case if not case_sensitive else None, + default=default, + ) + out = [ + _GroupTuple(key, list(values)) + for key, values in groupby(sorted(value, key=expr), expr) + ] + + if not case_sensitive: + # Return the real key from the first value instead of the lowercase key. + output_expr = make_attrgetter(environment, attribute, default=default) + out = [_GroupTuple(output_expr(values[0]), values) for _, values in out] + + return out + + +@async_variant(sync_do_groupby) # type: ignore +async def do_groupby( + environment: "Environment", + value: "t.Union[t.AsyncIterable[V], t.Iterable[V]]", + attribute: t.Union[str, int], + default: t.Optional[t.Any] = None, + case_sensitive: bool = False, +) -> "t.List[_GroupTuple]": + expr = make_attrgetter( + environment, + attribute, + postprocess=ignore_case if not case_sensitive else None, + default=default, + ) + out = [ + _GroupTuple(key, await auto_to_list(values)) + for key, values in groupby(sorted(await auto_to_list(value), key=expr), expr) + ] + + if not case_sensitive: + # Return the real key from the first value instead of the lowercase key. + output_expr = make_attrgetter(environment, attribute, default=default) + out = [_GroupTuple(output_expr(values[0]), values) for _, values in out] + + return out + + +@pass_environment +def sync_do_sum( + environment: "Environment", + iterable: "t.Iterable[V]", + attribute: t.Optional[t.Union[str, int]] = None, + start: V = 0, # type: ignore +) -> V: + """Returns the sum of a sequence of numbers plus the value of parameter + 'start' (which defaults to 0). When the sequence is empty it returns + start. + + It is also possible to sum up only certain attributes: + + .. sourcecode:: jinja + + Total: {{ items|sum(attribute='price') }} + + .. versionchanged:: 2.6 + The ``attribute`` parameter was added to allow summing up over + attributes. Also the ``start`` parameter was moved on to the right. + """ + if attribute is not None: + iterable = map(make_attrgetter(environment, attribute), iterable) + + return sum(iterable, start) # type: ignore[no-any-return, call-overload] + + +@async_variant(sync_do_sum) # type: ignore +async def do_sum( + environment: "Environment", + iterable: "t.Union[t.AsyncIterable[V], t.Iterable[V]]", + attribute: t.Optional[t.Union[str, int]] = None, + start: V = 0, # type: ignore +) -> V: + rv = start + + if attribute is not None: + func = make_attrgetter(environment, attribute) + else: + + def func(x: V) -> V: + return x + + async for item in auto_aiter(iterable): + rv += func(item) + + return rv + + +def sync_do_list(value: "t.Iterable[V]") -> "t.List[V]": + """Convert the value into a list. If it was a string the returned list + will be a list of characters. + """ + return list(value) + + +@async_variant(sync_do_list) # type: ignore +async def do_list(value: "t.Union[t.AsyncIterable[V], t.Iterable[V]]") -> "t.List[V]": + return await auto_to_list(value) + + +def do_mark_safe(value: str) -> Markup: + """Mark the value as safe which means that in an environment with automatic + escaping enabled this variable will not be escaped. + """ + return Markup(value) + + +def do_mark_unsafe(value: str) -> str: + """Mark a value as unsafe. This is the reverse operation for :func:`safe`.""" + return str(value) + + +@typing.overload +def do_reverse(value: str) -> str: + ... + + +@typing.overload +def do_reverse(value: "t.Iterable[V]") -> "t.Iterable[V]": + ... + + +def do_reverse(value: t.Union[str, t.Iterable[V]]) -> t.Union[str, t.Iterable[V]]: + """Reverse the object or return an iterator that iterates over it the other + way round. + """ + if isinstance(value, str): + return value[::-1] + + try: + return reversed(value) # type: ignore + except TypeError: + try: + rv = list(value) + rv.reverse() + return rv + except TypeError as e: + raise FilterArgumentError("argument must be iterable") from e + + +@pass_environment +def do_attr( + environment: "Environment", obj: t.Any, name: str +) -> t.Union[Undefined, t.Any]: + """Get an attribute of an object. ``foo|attr("bar")`` works like + ``foo.bar`` just that always an attribute is returned and items are not + looked up. + + See :ref:`Notes on subscriptions <notes-on-subscriptions>` for more details. + """ + try: + name = str(name) + except UnicodeError: + pass + else: + try: + value = getattr(obj, name) + except AttributeError: + pass + else: + if environment.sandboxed: + environment = t.cast("SandboxedEnvironment", environment) + + if not environment.is_safe_attribute(obj, name, value): + return environment.unsafe_undefined(obj, name) + + return value + + return environment.undefined(obj=obj, name=name) + + +@typing.overload +def sync_do_map( + context: "Context", value: t.Iterable, name: str, *args: t.Any, **kwargs: t.Any +) -> t.Iterable: + ... + + +@typing.overload +def sync_do_map( + context: "Context", + value: t.Iterable, + *, + attribute: str = ..., + default: t.Optional[t.Any] = None, +) -> t.Iterable: + ... + + +@pass_context +def sync_do_map( + context: "Context", value: t.Iterable, *args: t.Any, **kwargs: t.Any +) -> t.Iterable: + """Applies a filter on a sequence of objects or looks up an attribute. + This is useful when dealing with lists of objects but you are really + only interested in a certain value of it. + + The basic usage is mapping on an attribute. Imagine you have a list + of users but you are only interested in a list of usernames: + + .. sourcecode:: jinja + + Users on this page: {{ users|map(attribute='username')|join(', ') }} + + You can specify a ``default`` value to use if an object in the list + does not have the given attribute. + + .. sourcecode:: jinja + + {{ users|map(attribute="username", default="Anonymous")|join(", ") }} + + Alternatively you can let it invoke a filter by passing the name of the + filter and the arguments afterwards. A good example would be applying a + text conversion filter on a sequence: + + .. sourcecode:: jinja + + Users on this page: {{ titles|map('lower')|join(', ') }} + + Similar to a generator comprehension such as: + + .. code-block:: python + + (u.username for u in users) + (getattr(u, "username", "Anonymous") for u in users) + (do_lower(x) for x in titles) + + .. versionchanged:: 2.11.0 + Added the ``default`` parameter. + + .. versionadded:: 2.7 + """ + if value: + func = prepare_map(context, args, kwargs) + + for item in value: + yield func(item) + + +@typing.overload +def do_map( + context: "Context", + value: t.Union[t.AsyncIterable, t.Iterable], + name: str, + *args: t.Any, + **kwargs: t.Any, +) -> t.Iterable: + ... + + +@typing.overload +def do_map( + context: "Context", + value: t.Union[t.AsyncIterable, t.Iterable], + *, + attribute: str = ..., + default: t.Optional[t.Any] = None, +) -> t.Iterable: + ... + + +@async_variant(sync_do_map) # type: ignore +async def do_map( + context: "Context", + value: t.Union[t.AsyncIterable, t.Iterable], + *args: t.Any, + **kwargs: t.Any, +) -> t.AsyncIterable: + if value: + func = prepare_map(context, args, kwargs) + + async for item in auto_aiter(value): + yield await auto_await(func(item)) + + +@pass_context +def sync_do_select( + context: "Context", value: "t.Iterable[V]", *args: t.Any, **kwargs: t.Any +) -> "t.Iterator[V]": + """Filters a sequence of objects by applying a test to each object, + and only selecting the objects with the test succeeding. + + If no test is specified, each object will be evaluated as a boolean. + + Example usage: + + .. sourcecode:: jinja + + {{ numbers|select("odd") }} + {{ numbers|select("odd") }} + {{ numbers|select("divisibleby", 3) }} + {{ numbers|select("lessthan", 42) }} + {{ strings|select("equalto", "mystring") }} + + Similar to a generator comprehension such as: + + .. code-block:: python + + (n for n in numbers if test_odd(n)) + (n for n in numbers if test_divisibleby(n, 3)) + + .. versionadded:: 2.7 + """ + return select_or_reject(context, value, args, kwargs, lambda x: x, False) + + +@async_variant(sync_do_select) # type: ignore +async def do_select( + context: "Context", + value: "t.Union[t.AsyncIterable[V], t.Iterable[V]]", + *args: t.Any, + **kwargs: t.Any, +) -> "t.AsyncIterator[V]": + return async_select_or_reject(context, value, args, kwargs, lambda x: x, False) + + +@pass_context +def sync_do_reject( + context: "Context", value: "t.Iterable[V]", *args: t.Any, **kwargs: t.Any +) -> "t.Iterator[V]": + """Filters a sequence of objects by applying a test to each object, + and rejecting the objects with the test succeeding. + + If no test is specified, each object will be evaluated as a boolean. + + Example usage: + + .. sourcecode:: jinja + + {{ numbers|reject("odd") }} + + Similar to a generator comprehension such as: + + .. code-block:: python + + (n for n in numbers if not test_odd(n)) + + .. versionadded:: 2.7 + """ + return select_or_reject(context, value, args, kwargs, lambda x: not x, False) + + +@async_variant(sync_do_reject) # type: ignore +async def do_reject( + context: "Context", + value: "t.Union[t.AsyncIterable[V], t.Iterable[V]]", + *args: t.Any, + **kwargs: t.Any, +) -> "t.AsyncIterator[V]": + return async_select_or_reject(context, value, args, kwargs, lambda x: not x, False) + + +@pass_context +def sync_do_selectattr( + context: "Context", value: "t.Iterable[V]", *args: t.Any, **kwargs: t.Any +) -> "t.Iterator[V]": + """Filters a sequence of objects by applying a test to the specified + attribute of each object, and only selecting the objects with the + test succeeding. + + If no test is specified, the attribute's value will be evaluated as + a boolean. + + Example usage: + + .. sourcecode:: jinja + + {{ users|selectattr("is_active") }} + {{ users|selectattr("email", "none") }} + + Similar to a generator comprehension such as: + + .. code-block:: python + + (u for user in users if user.is_active) + (u for user in users if test_none(user.email)) + + .. versionadded:: 2.7 + """ + return select_or_reject(context, value, args, kwargs, lambda x: x, True) + + +@async_variant(sync_do_selectattr) # type: ignore +async def do_selectattr( + context: "Context", + value: "t.Union[t.AsyncIterable[V], t.Iterable[V]]", + *args: t.Any, + **kwargs: t.Any, +) -> "t.AsyncIterator[V]": + return async_select_or_reject(context, value, args, kwargs, lambda x: x, True) + + +@pass_context +def sync_do_rejectattr( + context: "Context", value: "t.Iterable[V]", *args: t.Any, **kwargs: t.Any +) -> "t.Iterator[V]": + """Filters a sequence of objects by applying a test to the specified + attribute of each object, and rejecting the objects with the test + succeeding. + + If no test is specified, the attribute's value will be evaluated as + a boolean. + + .. sourcecode:: jinja + + {{ users|rejectattr("is_active") }} + {{ users|rejectattr("email", "none") }} + + Similar to a generator comprehension such as: + + .. code-block:: python + + (u for user in users if not user.is_active) + (u for user in users if not test_none(user.email)) + + .. versionadded:: 2.7 + """ + return select_or_reject(context, value, args, kwargs, lambda x: not x, True) + + +@async_variant(sync_do_rejectattr) # type: ignore +async def do_rejectattr( + context: "Context", + value: "t.Union[t.AsyncIterable[V], t.Iterable[V]]", + *args: t.Any, + **kwargs: t.Any, +) -> "t.AsyncIterator[V]": + return async_select_or_reject(context, value, args, kwargs, lambda x: not x, True) + + +@pass_eval_context +def do_tojson( + eval_ctx: "EvalContext", value: t.Any, indent: t.Optional[int] = None +) -> Markup: + """Serialize an object to a string of JSON, and mark it safe to + render in HTML. This filter is only for use in HTML documents. + + The returned string is safe to render in HTML documents and + ``<script>`` tags. The exception is in HTML attributes that are + double quoted; either use single quotes or the ``|forceescape`` + filter. + + :param value: The object to serialize to JSON. + :param indent: The ``indent`` parameter passed to ``dumps``, for + pretty-printing the value. + + .. versionadded:: 2.9 + """ + policies = eval_ctx.environment.policies + dumps = policies["json.dumps_function"] + kwargs = policies["json.dumps_kwargs"] + + if indent is not None: + kwargs = kwargs.copy() + kwargs["indent"] = indent + + return htmlsafe_json_dumps(value, dumps=dumps, **kwargs) + + +def prepare_map( + context: "Context", args: t.Tuple, kwargs: t.Dict[str, t.Any] +) -> t.Callable[[t.Any], t.Any]: + if not args and "attribute" in kwargs: + attribute = kwargs.pop("attribute") + default = kwargs.pop("default", None) + + if kwargs: + raise FilterArgumentError( + f"Unexpected keyword argument {next(iter(kwargs))!r}" + ) + + func = make_attrgetter(context.environment, attribute, default=default) + else: + try: + name = args[0] + args = args[1:] + except LookupError: + raise FilterArgumentError("map requires a filter argument") from None + + def func(item: t.Any) -> t.Any: + return context.environment.call_filter( + name, item, args, kwargs, context=context + ) + + return func + + +def prepare_select_or_reject( + context: "Context", + args: t.Tuple, + kwargs: t.Dict[str, t.Any], + modfunc: t.Callable[[t.Any], t.Any], + lookup_attr: bool, +) -> t.Callable[[t.Any], t.Any]: + if lookup_attr: + try: + attr = args[0] + except LookupError: + raise FilterArgumentError("Missing parameter for attribute name") from None + + transfunc = make_attrgetter(context.environment, attr) + off = 1 + else: + off = 0 + + def transfunc(x: V) -> V: + return x + + try: + name = args[off] + args = args[1 + off :] + + def func(item: t.Any) -> t.Any: + return context.environment.call_test(name, item, args, kwargs) + + except LookupError: + func = bool # type: ignore + + return lambda item: modfunc(func(transfunc(item))) + + +def select_or_reject( + context: "Context", + value: "t.Iterable[V]", + args: t.Tuple, + kwargs: t.Dict[str, t.Any], + modfunc: t.Callable[[t.Any], t.Any], + lookup_attr: bool, +) -> "t.Iterator[V]": + if value: + func = prepare_select_or_reject(context, args, kwargs, modfunc, lookup_attr) + + for item in value: + if func(item): + yield item + + +async def async_select_or_reject( + context: "Context", + value: "t.Union[t.AsyncIterable[V], t.Iterable[V]]", + args: t.Tuple, + kwargs: t.Dict[str, t.Any], + modfunc: t.Callable[[t.Any], t.Any], + lookup_attr: bool, +) -> "t.AsyncIterator[V]": + if value: + func = prepare_select_or_reject(context, args, kwargs, modfunc, lookup_attr) + + async for item in auto_aiter(value): + if func(item): + yield item + + +FILTERS = { + "abs": abs, + "attr": do_attr, + "batch": do_batch, + "capitalize": do_capitalize, + "center": do_center, + "count": len, + "d": do_default, + "default": do_default, + "dictsort": do_dictsort, + "e": escape, + "escape": escape, + "filesizeformat": do_filesizeformat, + "first": do_first, + "float": do_float, + "forceescape": do_forceescape, + "format": do_format, + "groupby": do_groupby, + "indent": do_indent, + "int": do_int, + "join": do_join, + "last": do_last, + "length": len, + "list": do_list, + "lower": do_lower, + "items": do_items, + "map": do_map, + "min": do_min, + "max": do_max, + "pprint": do_pprint, + "random": do_random, + "reject": do_reject, + "rejectattr": do_rejectattr, + "replace": do_replace, + "reverse": do_reverse, + "round": do_round, + "safe": do_mark_safe, + "select": do_select, + "selectattr": do_selectattr, + "slice": do_slice, + "sort": do_sort, + "string": soft_str, + "striptags": do_striptags, + "sum": do_sum, + "title": do_title, + "trim": do_trim, + "truncate": do_truncate, + "unique": do_unique, + "upper": do_upper, + "urlencode": do_urlencode, + "urlize": do_urlize, + "wordcount": do_wordcount, + "wordwrap": do_wordwrap, + "xmlattr": do_xmlattr, + "tojson": do_tojson, +} diff --git a/src/jinja2/idtracking.py b/src/jinja2/idtracking.py new file mode 100644 index 0000000..995ebaa --- /dev/null +++ b/src/jinja2/idtracking.py @@ -0,0 +1,318 @@ +import typing as t + +from . import nodes +from .visitor import NodeVisitor + +VAR_LOAD_PARAMETER = "param" +VAR_LOAD_RESOLVE = "resolve" +VAR_LOAD_ALIAS = "alias" +VAR_LOAD_UNDEFINED = "undefined" + + +def find_symbols( + nodes: t.Iterable[nodes.Node], parent_symbols: t.Optional["Symbols"] = None +) -> "Symbols": + sym = Symbols(parent=parent_symbols) + visitor = FrameSymbolVisitor(sym) + for node in nodes: + visitor.visit(node) + return sym + + +def symbols_for_node( + node: nodes.Node, parent_symbols: t.Optional["Symbols"] = None +) -> "Symbols": + sym = Symbols(parent=parent_symbols) + sym.analyze_node(node) + return sym + + +class Symbols: + def __init__( + self, parent: t.Optional["Symbols"] = None, level: t.Optional[int] = None + ) -> None: + if level is None: + if parent is None: + level = 0 + else: + level = parent.level + 1 + + self.level: int = level + self.parent = parent + self.refs: t.Dict[str, str] = {} + self.loads: t.Dict[str, t.Any] = {} + self.stores: t.Set[str] = set() + + def analyze_node(self, node: nodes.Node, **kwargs: t.Any) -> None: + visitor = RootVisitor(self) + visitor.visit(node, **kwargs) + + def _define_ref( + self, name: str, load: t.Optional[t.Tuple[str, t.Optional[str]]] = None + ) -> str: + ident = f"l_{self.level}_{name}" + self.refs[name] = ident + if load is not None: + self.loads[ident] = load + return ident + + def find_load(self, target: str) -> t.Optional[t.Any]: + if target in self.loads: + return self.loads[target] + + if self.parent is not None: + return self.parent.find_load(target) + + return None + + def find_ref(self, name: str) -> t.Optional[str]: + if name in self.refs: + return self.refs[name] + + if self.parent is not None: + return self.parent.find_ref(name) + + return None + + def ref(self, name: str) -> str: + rv = self.find_ref(name) + if rv is None: + raise AssertionError( + "Tried to resolve a name to a reference that was" + f" unknown to the frame ({name!r})" + ) + return rv + + def copy(self) -> "Symbols": + rv = object.__new__(self.__class__) + rv.__dict__.update(self.__dict__) + rv.refs = self.refs.copy() + rv.loads = self.loads.copy() + rv.stores = self.stores.copy() + return rv + + def store(self, name: str) -> None: + self.stores.add(name) + + # If we have not see the name referenced yet, we need to figure + # out what to set it to. + if name not in self.refs: + # If there is a parent scope we check if the name has a + # reference there. If it does it means we might have to alias + # to a variable there. + if self.parent is not None: + outer_ref = self.parent.find_ref(name) + if outer_ref is not None: + self._define_ref(name, load=(VAR_LOAD_ALIAS, outer_ref)) + return + + # Otherwise we can just set it to undefined. + self._define_ref(name, load=(VAR_LOAD_UNDEFINED, None)) + + def declare_parameter(self, name: str) -> str: + self.stores.add(name) + return self._define_ref(name, load=(VAR_LOAD_PARAMETER, None)) + + def load(self, name: str) -> None: + if self.find_ref(name) is None: + self._define_ref(name, load=(VAR_LOAD_RESOLVE, name)) + + def branch_update(self, branch_symbols: t.Sequence["Symbols"]) -> None: + stores: t.Dict[str, int] = {} + for branch in branch_symbols: + for target in branch.stores: + if target in self.stores: + continue + stores[target] = stores.get(target, 0) + 1 + + for sym in branch_symbols: + self.refs.update(sym.refs) + self.loads.update(sym.loads) + self.stores.update(sym.stores) + + for name, branch_count in stores.items(): + if branch_count == len(branch_symbols): + continue + + target = self.find_ref(name) # type: ignore + assert target is not None, "should not happen" + + if self.parent is not None: + outer_target = self.parent.find_ref(name) + if outer_target is not None: + self.loads[target] = (VAR_LOAD_ALIAS, outer_target) + continue + self.loads[target] = (VAR_LOAD_RESOLVE, name) + + def dump_stores(self) -> t.Dict[str, str]: + rv: t.Dict[str, str] = {} + node: t.Optional["Symbols"] = self + + while node is not None: + for name in sorted(node.stores): + if name not in rv: + rv[name] = self.find_ref(name) # type: ignore + + node = node.parent + + return rv + + def dump_param_targets(self) -> t.Set[str]: + rv = set() + node: t.Optional["Symbols"] = self + + while node is not None: + for target, (instr, _) in self.loads.items(): + if instr == VAR_LOAD_PARAMETER: + rv.add(target) + + node = node.parent + + return rv + + +class RootVisitor(NodeVisitor): + def __init__(self, symbols: "Symbols") -> None: + self.sym_visitor = FrameSymbolVisitor(symbols) + + def _simple_visit(self, node: nodes.Node, **kwargs: t.Any) -> None: + for child in node.iter_child_nodes(): + self.sym_visitor.visit(child) + + visit_Template = _simple_visit + visit_Block = _simple_visit + visit_Macro = _simple_visit + visit_FilterBlock = _simple_visit + visit_Scope = _simple_visit + visit_If = _simple_visit + visit_ScopedEvalContextModifier = _simple_visit + + def visit_AssignBlock(self, node: nodes.AssignBlock, **kwargs: t.Any) -> None: + for child in node.body: + self.sym_visitor.visit(child) + + def visit_CallBlock(self, node: nodes.CallBlock, **kwargs: t.Any) -> None: + for child in node.iter_child_nodes(exclude=("call",)): + self.sym_visitor.visit(child) + + def visit_OverlayScope(self, node: nodes.OverlayScope, **kwargs: t.Any) -> None: + for child in node.body: + self.sym_visitor.visit(child) + + def visit_For( + self, node: nodes.For, for_branch: str = "body", **kwargs: t.Any + ) -> None: + if for_branch == "body": + self.sym_visitor.visit(node.target, store_as_param=True) + branch = node.body + elif for_branch == "else": + branch = node.else_ + elif for_branch == "test": + self.sym_visitor.visit(node.target, store_as_param=True) + if node.test is not None: + self.sym_visitor.visit(node.test) + return + else: + raise RuntimeError("Unknown for branch") + + if branch: + for item in branch: + self.sym_visitor.visit(item) + + def visit_With(self, node: nodes.With, **kwargs: t.Any) -> None: + for target in node.targets: + self.sym_visitor.visit(target) + for child in node.body: + self.sym_visitor.visit(child) + + def generic_visit(self, node: nodes.Node, *args: t.Any, **kwargs: t.Any) -> None: + raise NotImplementedError(f"Cannot find symbols for {type(node).__name__!r}") + + +class FrameSymbolVisitor(NodeVisitor): + """A visitor for `Frame.inspect`.""" + + def __init__(self, symbols: "Symbols") -> None: + self.symbols = symbols + + def visit_Name( + self, node: nodes.Name, store_as_param: bool = False, **kwargs: t.Any + ) -> None: + """All assignments to names go through this function.""" + if store_as_param or node.ctx == "param": + self.symbols.declare_parameter(node.name) + elif node.ctx == "store": + self.symbols.store(node.name) + elif node.ctx == "load": + self.symbols.load(node.name) + + def visit_NSRef(self, node: nodes.NSRef, **kwargs: t.Any) -> None: + self.symbols.load(node.name) + + def visit_If(self, node: nodes.If, **kwargs: t.Any) -> None: + self.visit(node.test, **kwargs) + original_symbols = self.symbols + + def inner_visit(nodes: t.Iterable[nodes.Node]) -> "Symbols": + self.symbols = rv = original_symbols.copy() + + for subnode in nodes: + self.visit(subnode, **kwargs) + + self.symbols = original_symbols + return rv + + body_symbols = inner_visit(node.body) + elif_symbols = inner_visit(node.elif_) + else_symbols = inner_visit(node.else_ or ()) + self.symbols.branch_update([body_symbols, elif_symbols, else_symbols]) + + def visit_Macro(self, node: nodes.Macro, **kwargs: t.Any) -> None: + self.symbols.store(node.name) + + def visit_Import(self, node: nodes.Import, **kwargs: t.Any) -> None: + self.generic_visit(node, **kwargs) + self.symbols.store(node.target) + + def visit_FromImport(self, node: nodes.FromImport, **kwargs: t.Any) -> None: + self.generic_visit(node, **kwargs) + + for name in node.names: + if isinstance(name, tuple): + self.symbols.store(name[1]) + else: + self.symbols.store(name) + + def visit_Assign(self, node: nodes.Assign, **kwargs: t.Any) -> None: + """Visit assignments in the correct order.""" + self.visit(node.node, **kwargs) + self.visit(node.target, **kwargs) + + def visit_For(self, node: nodes.For, **kwargs: t.Any) -> None: + """Visiting stops at for blocks. However the block sequence + is visited as part of the outer scope. + """ + self.visit(node.iter, **kwargs) + + def visit_CallBlock(self, node: nodes.CallBlock, **kwargs: t.Any) -> None: + self.visit(node.call, **kwargs) + + def visit_FilterBlock(self, node: nodes.FilterBlock, **kwargs: t.Any) -> None: + self.visit(node.filter, **kwargs) + + def visit_With(self, node: nodes.With, **kwargs: t.Any) -> None: + for target in node.values: + self.visit(target) + + def visit_AssignBlock(self, node: nodes.AssignBlock, **kwargs: t.Any) -> None: + """Stop visiting at block assigns.""" + self.visit(node.target, **kwargs) + + def visit_Scope(self, node: nodes.Scope, **kwargs: t.Any) -> None: + """Stop visiting at scopes.""" + + def visit_Block(self, node: nodes.Block, **kwargs: t.Any) -> None: + """Stop visiting at blocks.""" + + def visit_OverlayScope(self, node: nodes.OverlayScope, **kwargs: t.Any) -> None: + """Do not visit into overlay scopes.""" diff --git a/src/jinja2/lexer.py b/src/jinja2/lexer.py new file mode 100644 index 0000000..aff7e9f --- /dev/null +++ b/src/jinja2/lexer.py @@ -0,0 +1,866 @@ +"""Implements a Jinja / Python combination lexer. The ``Lexer`` class +is used to do some preprocessing. It filters out invalid operators like +the bitshift operators we don't allow in templates. It separates +template code and python code in expressions. +""" +import re +import typing as t +from ast import literal_eval +from collections import deque +from sys import intern + +from ._identifier import pattern as name_re +from .exceptions import TemplateSyntaxError +from .utils import LRUCache + +if t.TYPE_CHECKING: + import typing_extensions as te + from .environment import Environment + +# cache for the lexers. Exists in order to be able to have multiple +# environments with the same lexer +_lexer_cache: t.MutableMapping[t.Tuple, "Lexer"] = LRUCache(50) # type: ignore + +# static regular expressions +whitespace_re = re.compile(r"\s+") +newline_re = re.compile(r"(\r\n|\r|\n)") +string_re = re.compile( + r"('([^'\\]*(?:\\.[^'\\]*)*)'" r'|"([^"\\]*(?:\\.[^"\\]*)*)")', re.S +) +integer_re = re.compile( + r""" + ( + 0b(_?[0-1])+ # binary + | + 0o(_?[0-7])+ # octal + | + 0x(_?[\da-f])+ # hex + | + [1-9](_?\d)* # decimal + | + 0(_?0)* # decimal zero + ) + """, + re.IGNORECASE | re.VERBOSE, +) +float_re = re.compile( + r""" + (?<!\.) # doesn't start with a . + (\d+_)*\d+ # digits, possibly _ separated + ( + (\.(\d+_)*\d+)? # optional fractional part + e[+\-]?(\d+_)*\d+ # exponent part + | + \.(\d+_)*\d+ # required fractional part + ) + """, + re.IGNORECASE | re.VERBOSE, +) + +# internal the tokens and keep references to them +TOKEN_ADD = intern("add") +TOKEN_ASSIGN = intern("assign") +TOKEN_COLON = intern("colon") +TOKEN_COMMA = intern("comma") +TOKEN_DIV = intern("div") +TOKEN_DOT = intern("dot") +TOKEN_EQ = intern("eq") +TOKEN_FLOORDIV = intern("floordiv") +TOKEN_GT = intern("gt") +TOKEN_GTEQ = intern("gteq") +TOKEN_LBRACE = intern("lbrace") +TOKEN_LBRACKET = intern("lbracket") +TOKEN_LPAREN = intern("lparen") +TOKEN_LT = intern("lt") +TOKEN_LTEQ = intern("lteq") +TOKEN_MOD = intern("mod") +TOKEN_MUL = intern("mul") +TOKEN_NE = intern("ne") +TOKEN_PIPE = intern("pipe") +TOKEN_POW = intern("pow") +TOKEN_RBRACE = intern("rbrace") +TOKEN_RBRACKET = intern("rbracket") +TOKEN_RPAREN = intern("rparen") +TOKEN_SEMICOLON = intern("semicolon") +TOKEN_SUB = intern("sub") +TOKEN_TILDE = intern("tilde") +TOKEN_WHITESPACE = intern("whitespace") +TOKEN_FLOAT = intern("float") +TOKEN_INTEGER = intern("integer") +TOKEN_NAME = intern("name") +TOKEN_STRING = intern("string") +TOKEN_OPERATOR = intern("operator") +TOKEN_BLOCK_BEGIN = intern("block_begin") +TOKEN_BLOCK_END = intern("block_end") +TOKEN_VARIABLE_BEGIN = intern("variable_begin") +TOKEN_VARIABLE_END = intern("variable_end") +TOKEN_RAW_BEGIN = intern("raw_begin") +TOKEN_RAW_END = intern("raw_end") +TOKEN_COMMENT_BEGIN = intern("comment_begin") +TOKEN_COMMENT_END = intern("comment_end") +TOKEN_COMMENT = intern("comment") +TOKEN_LINESTATEMENT_BEGIN = intern("linestatement_begin") +TOKEN_LINESTATEMENT_END = intern("linestatement_end") +TOKEN_LINECOMMENT_BEGIN = intern("linecomment_begin") +TOKEN_LINECOMMENT_END = intern("linecomment_end") +TOKEN_LINECOMMENT = intern("linecomment") +TOKEN_DATA = intern("data") +TOKEN_INITIAL = intern("initial") +TOKEN_EOF = intern("eof") + +# bind operators to token types +operators = { + "+": TOKEN_ADD, + "-": TOKEN_SUB, + "/": TOKEN_DIV, + "//": TOKEN_FLOORDIV, + "*": TOKEN_MUL, + "%": TOKEN_MOD, + "**": TOKEN_POW, + "~": TOKEN_TILDE, + "[": TOKEN_LBRACKET, + "]": TOKEN_RBRACKET, + "(": TOKEN_LPAREN, + ")": TOKEN_RPAREN, + "{": TOKEN_LBRACE, + "}": TOKEN_RBRACE, + "==": TOKEN_EQ, + "!=": TOKEN_NE, + ">": TOKEN_GT, + ">=": TOKEN_GTEQ, + "<": TOKEN_LT, + "<=": TOKEN_LTEQ, + "=": TOKEN_ASSIGN, + ".": TOKEN_DOT, + ":": TOKEN_COLON, + "|": TOKEN_PIPE, + ",": TOKEN_COMMA, + ";": TOKEN_SEMICOLON, +} + +reverse_operators = {v: k for k, v in operators.items()} +assert len(operators) == len(reverse_operators), "operators dropped" +operator_re = re.compile( + f"({'|'.join(re.escape(x) for x in sorted(operators, key=lambda x: -len(x)))})" +) + +ignored_tokens = frozenset( + [ + TOKEN_COMMENT_BEGIN, + TOKEN_COMMENT, + TOKEN_COMMENT_END, + TOKEN_WHITESPACE, + TOKEN_LINECOMMENT_BEGIN, + TOKEN_LINECOMMENT_END, + TOKEN_LINECOMMENT, + ] +) +ignore_if_empty = frozenset( + [TOKEN_WHITESPACE, TOKEN_DATA, TOKEN_COMMENT, TOKEN_LINECOMMENT] +) + + +def _describe_token_type(token_type: str) -> str: + if token_type in reverse_operators: + return reverse_operators[token_type] + + return { + TOKEN_COMMENT_BEGIN: "begin of comment", + TOKEN_COMMENT_END: "end of comment", + TOKEN_COMMENT: "comment", + TOKEN_LINECOMMENT: "comment", + TOKEN_BLOCK_BEGIN: "begin of statement block", + TOKEN_BLOCK_END: "end of statement block", + TOKEN_VARIABLE_BEGIN: "begin of print statement", + TOKEN_VARIABLE_END: "end of print statement", + TOKEN_LINESTATEMENT_BEGIN: "begin of line statement", + TOKEN_LINESTATEMENT_END: "end of line statement", + TOKEN_DATA: "template data / text", + TOKEN_EOF: "end of template", + }.get(token_type, token_type) + + +def describe_token(token: "Token") -> str: + """Returns a description of the token.""" + if token.type == TOKEN_NAME: + return token.value + + return _describe_token_type(token.type) + + +def describe_token_expr(expr: str) -> str: + """Like `describe_token` but for token expressions.""" + if ":" in expr: + type, value = expr.split(":", 1) + + if type == TOKEN_NAME: + return value + else: + type = expr + + return _describe_token_type(type) + + +def count_newlines(value: str) -> int: + """Count the number of newline characters in the string. This is + useful for extensions that filter a stream. + """ + return len(newline_re.findall(value)) + + +def compile_rules(environment: "Environment") -> t.List[t.Tuple[str, str]]: + """Compiles all the rules from the environment into a list of rules.""" + e = re.escape + rules = [ + ( + len(environment.comment_start_string), + TOKEN_COMMENT_BEGIN, + e(environment.comment_start_string), + ), + ( + len(environment.block_start_string), + TOKEN_BLOCK_BEGIN, + e(environment.block_start_string), + ), + ( + len(environment.variable_start_string), + TOKEN_VARIABLE_BEGIN, + e(environment.variable_start_string), + ), + ] + + if environment.line_statement_prefix is not None: + rules.append( + ( + len(environment.line_statement_prefix), + TOKEN_LINESTATEMENT_BEGIN, + r"^[ \t\v]*" + e(environment.line_statement_prefix), + ) + ) + if environment.line_comment_prefix is not None: + rules.append( + ( + len(environment.line_comment_prefix), + TOKEN_LINECOMMENT_BEGIN, + r"(?:^|(?<=\S))[^\S\r\n]*" + e(environment.line_comment_prefix), + ) + ) + + return [x[1:] for x in sorted(rules, reverse=True)] + + +class Failure: + """Class that raises a `TemplateSyntaxError` if called. + Used by the `Lexer` to specify known errors. + """ + + def __init__( + self, message: str, cls: t.Type[TemplateSyntaxError] = TemplateSyntaxError + ) -> None: + self.message = message + self.error_class = cls + + def __call__(self, lineno: int, filename: str) -> "te.NoReturn": + raise self.error_class(self.message, lineno, filename) + + +class Token(t.NamedTuple): + lineno: int + type: str + value: str + + def __str__(self) -> str: + return describe_token(self) + + def test(self, expr: str) -> bool: + """Test a token against a token expression. This can either be a + token type or ``'token_type:token_value'``. This can only test + against string values and types. + """ + # here we do a regular string equality check as test_any is usually + # passed an iterable of not interned strings. + if self.type == expr: + return True + + if ":" in expr: + return expr.split(":", 1) == [self.type, self.value] + + return False + + def test_any(self, *iterable: str) -> bool: + """Test against multiple token expressions.""" + return any(self.test(expr) for expr in iterable) + + +class TokenStreamIterator: + """The iterator for tokenstreams. Iterate over the stream + until the eof token is reached. + """ + + def __init__(self, stream: "TokenStream") -> None: + self.stream = stream + + def __iter__(self) -> "TokenStreamIterator": + return self + + def __next__(self) -> Token: + token = self.stream.current + + if token.type is TOKEN_EOF: + self.stream.close() + raise StopIteration + + next(self.stream) + return token + + +class TokenStream: + """A token stream is an iterable that yields :class:`Token`\\s. The + parser however does not iterate over it but calls :meth:`next` to go + one token ahead. The current active token is stored as :attr:`current`. + """ + + def __init__( + self, + generator: t.Iterable[Token], + name: t.Optional[str], + filename: t.Optional[str], + ): + self._iter = iter(generator) + self._pushed: "te.Deque[Token]" = deque() + self.name = name + self.filename = filename + self.closed = False + self.current = Token(1, TOKEN_INITIAL, "") + next(self) + + def __iter__(self) -> TokenStreamIterator: + return TokenStreamIterator(self) + + def __bool__(self) -> bool: + return bool(self._pushed) or self.current.type is not TOKEN_EOF + + @property + def eos(self) -> bool: + """Are we at the end of the stream?""" + return not self + + def push(self, token: Token) -> None: + """Push a token back to the stream.""" + self._pushed.append(token) + + def look(self) -> Token: + """Look at the next token.""" + old_token = next(self) + result = self.current + self.push(result) + self.current = old_token + return result + + def skip(self, n: int = 1) -> None: + """Got n tokens ahead.""" + for _ in range(n): + next(self) + + def next_if(self, expr: str) -> t.Optional[Token]: + """Perform the token test and return the token if it matched. + Otherwise the return value is `None`. + """ + if self.current.test(expr): + return next(self) + + return None + + def skip_if(self, expr: str) -> bool: + """Like :meth:`next_if` but only returns `True` or `False`.""" + return self.next_if(expr) is not None + + def __next__(self) -> Token: + """Go one token ahead and return the old one. + + Use the built-in :func:`next` instead of calling this directly. + """ + rv = self.current + + if self._pushed: + self.current = self._pushed.popleft() + elif self.current.type is not TOKEN_EOF: + try: + self.current = next(self._iter) + except StopIteration: + self.close() + + return rv + + def close(self) -> None: + """Close the stream.""" + self.current = Token(self.current.lineno, TOKEN_EOF, "") + self._iter = iter(()) + self.closed = True + + def expect(self, expr: str) -> Token: + """Expect a given token type and return it. This accepts the same + argument as :meth:`jinja2.lexer.Token.test`. + """ + if not self.current.test(expr): + expr = describe_token_expr(expr) + + if self.current.type is TOKEN_EOF: + raise TemplateSyntaxError( + f"unexpected end of template, expected {expr!r}.", + self.current.lineno, + self.name, + self.filename, + ) + + raise TemplateSyntaxError( + f"expected token {expr!r}, got {describe_token(self.current)!r}", + self.current.lineno, + self.name, + self.filename, + ) + + return next(self) + + +def get_lexer(environment: "Environment") -> "Lexer": + """Return a lexer which is probably cached.""" + key = ( + environment.block_start_string, + environment.block_end_string, + environment.variable_start_string, + environment.variable_end_string, + environment.comment_start_string, + environment.comment_end_string, + environment.line_statement_prefix, + environment.line_comment_prefix, + environment.trim_blocks, + environment.lstrip_blocks, + environment.newline_sequence, + environment.keep_trailing_newline, + ) + lexer = _lexer_cache.get(key) + + if lexer is None: + _lexer_cache[key] = lexer = Lexer(environment) + + return lexer + + +class OptionalLStrip(tuple): + """A special tuple for marking a point in the state that can have + lstrip applied. + """ + + __slots__ = () + + # Even though it looks like a no-op, creating instances fails + # without this. + def __new__(cls, *members, **kwargs): # type: ignore + return super().__new__(cls, members) + + +class _Rule(t.NamedTuple): + pattern: t.Pattern[str] + tokens: t.Union[str, t.Tuple[str, ...], t.Tuple[Failure]] + command: t.Optional[str] + + +class Lexer: + """Class that implements a lexer for a given environment. Automatically + created by the environment class, usually you don't have to do that. + + Note that the lexer is not automatically bound to an environment. + Multiple environments can share the same lexer. + """ + + def __init__(self, environment: "Environment") -> None: + # shortcuts + e = re.escape + + def c(x: str) -> t.Pattern[str]: + return re.compile(x, re.M | re.S) + + # lexing rules for tags + tag_rules: t.List[_Rule] = [ + _Rule(whitespace_re, TOKEN_WHITESPACE, None), + _Rule(float_re, TOKEN_FLOAT, None), + _Rule(integer_re, TOKEN_INTEGER, None), + _Rule(name_re, TOKEN_NAME, None), + _Rule(string_re, TOKEN_STRING, None), + _Rule(operator_re, TOKEN_OPERATOR, None), + ] + + # assemble the root lexing rule. because "|" is ungreedy + # we have to sort by length so that the lexer continues working + # as expected when we have parsing rules like <% for block and + # <%= for variables. (if someone wants asp like syntax) + # variables are just part of the rules if variable processing + # is required. + root_tag_rules = compile_rules(environment) + + block_start_re = e(environment.block_start_string) + block_end_re = e(environment.block_end_string) + comment_end_re = e(environment.comment_end_string) + variable_end_re = e(environment.variable_end_string) + + # block suffix if trimming is enabled + block_suffix_re = "\\n?" if environment.trim_blocks else "" + + self.lstrip_blocks = environment.lstrip_blocks + + self.newline_sequence = environment.newline_sequence + self.keep_trailing_newline = environment.keep_trailing_newline + + root_raw_re = ( + rf"(?P<raw_begin>{block_start_re}(\-|\+|)\s*raw\s*" + rf"(?:\-{block_end_re}\s*|{block_end_re}))" + ) + root_parts_re = "|".join( + [root_raw_re] + [rf"(?P<{n}>{r}(\-|\+|))" for n, r in root_tag_rules] + ) + + # global lexing rules + self.rules: t.Dict[str, t.List[_Rule]] = { + "root": [ + # directives + _Rule( + c(rf"(.*?)(?:{root_parts_re})"), + OptionalLStrip(TOKEN_DATA, "#bygroup"), # type: ignore + "#bygroup", + ), + # data + _Rule(c(".+"), TOKEN_DATA, None), + ], + # comments + TOKEN_COMMENT_BEGIN: [ + _Rule( + c( + rf"(.*?)((?:\+{comment_end_re}|\-{comment_end_re}\s*" + rf"|{comment_end_re}{block_suffix_re}))" + ), + (TOKEN_COMMENT, TOKEN_COMMENT_END), + "#pop", + ), + _Rule(c(r"(.)"), (Failure("Missing end of comment tag"),), None), + ], + # blocks + TOKEN_BLOCK_BEGIN: [ + _Rule( + c( + rf"(?:\+{block_end_re}|\-{block_end_re}\s*" + rf"|{block_end_re}{block_suffix_re})" + ), + TOKEN_BLOCK_END, + "#pop", + ), + ] + + tag_rules, + # variables + TOKEN_VARIABLE_BEGIN: [ + _Rule( + c(rf"\-{variable_end_re}\s*|{variable_end_re}"), + TOKEN_VARIABLE_END, + "#pop", + ) + ] + + tag_rules, + # raw block + TOKEN_RAW_BEGIN: [ + _Rule( + c( + rf"(.*?)((?:{block_start_re}(\-|\+|))\s*endraw\s*" + rf"(?:\+{block_end_re}|\-{block_end_re}\s*" + rf"|{block_end_re}{block_suffix_re}))" + ), + OptionalLStrip(TOKEN_DATA, TOKEN_RAW_END), # type: ignore + "#pop", + ), + _Rule(c(r"(.)"), (Failure("Missing end of raw directive"),), None), + ], + # line statements + TOKEN_LINESTATEMENT_BEGIN: [ + _Rule(c(r"\s*(\n|$)"), TOKEN_LINESTATEMENT_END, "#pop") + ] + + tag_rules, + # line comments + TOKEN_LINECOMMENT_BEGIN: [ + _Rule( + c(r"(.*?)()(?=\n|$)"), + (TOKEN_LINECOMMENT, TOKEN_LINECOMMENT_END), + "#pop", + ) + ], + } + + def _normalize_newlines(self, value: str) -> str: + """Replace all newlines with the configured sequence in strings + and template data. + """ + return newline_re.sub(self.newline_sequence, value) + + def tokenize( + self, + source: str, + name: t.Optional[str] = None, + filename: t.Optional[str] = None, + state: t.Optional[str] = None, + ) -> TokenStream: + """Calls tokeniter + tokenize and wraps it in a token stream.""" + stream = self.tokeniter(source, name, filename, state) + return TokenStream(self.wrap(stream, name, filename), name, filename) + + def wrap( + self, + stream: t.Iterable[t.Tuple[int, str, str]], + name: t.Optional[str] = None, + filename: t.Optional[str] = None, + ) -> t.Iterator[Token]: + """This is called with the stream as returned by `tokenize` and wraps + every token in a :class:`Token` and converts the value. + """ + for lineno, token, value_str in stream: + if token in ignored_tokens: + continue + + value: t.Any = value_str + + if token == TOKEN_LINESTATEMENT_BEGIN: + token = TOKEN_BLOCK_BEGIN + elif token == TOKEN_LINESTATEMENT_END: + token = TOKEN_BLOCK_END + # we are not interested in those tokens in the parser + elif token in (TOKEN_RAW_BEGIN, TOKEN_RAW_END): + continue + elif token == TOKEN_DATA: + value = self._normalize_newlines(value_str) + elif token == "keyword": + token = value_str + elif token == TOKEN_NAME: + value = value_str + + if not value.isidentifier(): + raise TemplateSyntaxError( + "Invalid character in identifier", lineno, name, filename + ) + elif token == TOKEN_STRING: + # try to unescape string + try: + value = ( + self._normalize_newlines(value_str[1:-1]) + .encode("ascii", "backslashreplace") + .decode("unicode-escape") + ) + except Exception as e: + msg = str(e).split(":")[-1].strip() + raise TemplateSyntaxError(msg, lineno, name, filename) from e + elif token == TOKEN_INTEGER: + value = int(value_str.replace("_", ""), 0) + elif token == TOKEN_FLOAT: + # remove all "_" first to support more Python versions + value = literal_eval(value_str.replace("_", "")) + elif token == TOKEN_OPERATOR: + token = operators[value_str] + + yield Token(lineno, token, value) + + def tokeniter( + self, + source: str, + name: t.Optional[str], + filename: t.Optional[str] = None, + state: t.Optional[str] = None, + ) -> t.Iterator[t.Tuple[int, str, str]]: + """This method tokenizes the text and returns the tokens in a + generator. Use this method if you just want to tokenize a template. + + .. versionchanged:: 3.0 + Only ``\\n``, ``\\r\\n`` and ``\\r`` are treated as line + breaks. + """ + lines = newline_re.split(source)[::2] + + if not self.keep_trailing_newline and lines[-1] == "": + del lines[-1] + + source = "\n".join(lines) + pos = 0 + lineno = 1 + stack = ["root"] + + if state is not None and state != "root": + assert state in ("variable", "block"), "invalid state" + stack.append(state + "_begin") + + statetokens = self.rules[stack[-1]] + source_length = len(source) + balancing_stack: t.List[str] = [] + newlines_stripped = 0 + line_starting = True + + while True: + # tokenizer loop + for regex, tokens, new_state in statetokens: + m = regex.match(source, pos) + + # if no match we try again with the next rule + if m is None: + continue + + # we only match blocks and variables if braces / parentheses + # are balanced. continue parsing with the lower rule which + # is the operator rule. do this only if the end tags look + # like operators + if balancing_stack and tokens in ( + TOKEN_VARIABLE_END, + TOKEN_BLOCK_END, + TOKEN_LINESTATEMENT_END, + ): + continue + + # tuples support more options + if isinstance(tokens, tuple): + groups: t.Sequence[str] = m.groups() + + if isinstance(tokens, OptionalLStrip): + # Rule supports lstrip. Match will look like + # text, block type, whitespace control, type, control, ... + text = groups[0] + # Skipping the text and first type, every other group is the + # whitespace control for each type. One of the groups will be + # -, +, or empty string instead of None. + strip_sign = next(g for g in groups[2::2] if g is not None) + + if strip_sign == "-": + # Strip all whitespace between the text and the tag. + stripped = text.rstrip() + newlines_stripped = text[len(stripped) :].count("\n") + groups = [stripped, *groups[1:]] + elif ( + # Not marked for preserving whitespace. + strip_sign != "+" + # lstrip is enabled. + and self.lstrip_blocks + # Not a variable expression. + and not m.groupdict().get(TOKEN_VARIABLE_BEGIN) + ): + # The start of text between the last newline and the tag. + l_pos = text.rfind("\n") + 1 + + if l_pos > 0 or line_starting: + # If there's only whitespace between the newline and the + # tag, strip it. + if whitespace_re.fullmatch(text, l_pos): + groups = [text[:l_pos], *groups[1:]] + + for idx, token in enumerate(tokens): + # failure group + if token.__class__ is Failure: + raise token(lineno, filename) + # bygroup is a bit more complex, in that case we + # yield for the current token the first named + # group that matched + elif token == "#bygroup": + for key, value in m.groupdict().items(): + if value is not None: + yield lineno, key, value + lineno += value.count("\n") + break + else: + raise RuntimeError( + f"{regex!r} wanted to resolve the token dynamically" + " but no group matched" + ) + # normal group + else: + data = groups[idx] + + if data or token not in ignore_if_empty: + yield lineno, token, data + + lineno += data.count("\n") + newlines_stripped + newlines_stripped = 0 + + # strings as token just are yielded as it. + else: + data = m.group() + + # update brace/parentheses balance + if tokens == TOKEN_OPERATOR: + if data == "{": + balancing_stack.append("}") + elif data == "(": + balancing_stack.append(")") + elif data == "[": + balancing_stack.append("]") + elif data in ("}", ")", "]"): + if not balancing_stack: + raise TemplateSyntaxError( + f"unexpected '{data}'", lineno, name, filename + ) + + expected_op = balancing_stack.pop() + + if expected_op != data: + raise TemplateSyntaxError( + f"unexpected '{data}', expected '{expected_op}'", + lineno, + name, + filename, + ) + + # yield items + if data or tokens not in ignore_if_empty: + yield lineno, tokens, data + + lineno += data.count("\n") + + line_starting = m.group()[-1:] == "\n" + # fetch new position into new variable so that we can check + # if there is a internal parsing error which would result + # in an infinite loop + pos2 = m.end() + + # handle state changes + if new_state is not None: + # remove the uppermost state + if new_state == "#pop": + stack.pop() + # resolve the new state by group checking + elif new_state == "#bygroup": + for key, value in m.groupdict().items(): + if value is not None: + stack.append(key) + break + else: + raise RuntimeError( + f"{regex!r} wanted to resolve the new state dynamically" + f" but no group matched" + ) + # direct state name given + else: + stack.append(new_state) + + statetokens = self.rules[stack[-1]] + # we are still at the same position and no stack change. + # this means a loop without break condition, avoid that and + # raise error + elif pos2 == pos: + raise RuntimeError( + f"{regex!r} yielded empty string without stack change" + ) + + # publish new function and start again + pos = pos2 + break + # if loop terminated without break we haven't found a single match + # either we are at the end of the file or we have a problem + else: + # end of text + if pos >= source_length: + return + + # something went wrong + raise TemplateSyntaxError( + f"unexpected char {source[pos]!r} at {pos}", lineno, name, filename + ) diff --git a/src/jinja2/loaders.py b/src/jinja2/loaders.py new file mode 100644 index 0000000..d2f9809 --- /dev/null +++ b/src/jinja2/loaders.py @@ -0,0 +1,661 @@ +"""API and implementations for loading templates from different data +sources. +""" +import importlib.util +import os +import posixpath +import sys +import typing as t +import weakref +import zipimport +from collections import abc +from hashlib import sha1 +from importlib import import_module +from types import ModuleType + +from .exceptions import TemplateNotFound +from .utils import internalcode +from .utils import open_if_exists + +if t.TYPE_CHECKING: + from .environment import Environment + from .environment import Template + + +def split_template_path(template: str) -> t.List[str]: + """Split a path into segments and perform a sanity check. If it detects + '..' in the path it will raise a `TemplateNotFound` error. + """ + pieces = [] + for piece in template.split("/"): + if ( + os.path.sep in piece + or (os.path.altsep and os.path.altsep in piece) + or piece == os.path.pardir + ): + raise TemplateNotFound(template) + elif piece and piece != ".": + pieces.append(piece) + return pieces + + +class BaseLoader: + """Baseclass for all loaders. Subclass this and override `get_source` to + implement a custom loading mechanism. The environment provides a + `get_template` method that calls the loader's `load` method to get the + :class:`Template` object. + + A very basic example for a loader that looks up templates on the file + system could look like this:: + + from jinja2 import BaseLoader, TemplateNotFound + from os.path import join, exists, getmtime + + class MyLoader(BaseLoader): + + def __init__(self, path): + self.path = path + + def get_source(self, environment, template): + path = join(self.path, template) + if not exists(path): + raise TemplateNotFound(template) + mtime = getmtime(path) + with open(path) as f: + source = f.read() + return source, path, lambda: mtime == getmtime(path) + """ + + #: if set to `False` it indicates that the loader cannot provide access + #: to the source of templates. + #: + #: .. versionadded:: 2.4 + has_source_access = True + + def get_source( + self, environment: "Environment", template: str + ) -> t.Tuple[str, t.Optional[str], t.Optional[t.Callable[[], bool]]]: + """Get the template source, filename and reload helper for a template. + It's passed the environment and template name and has to return a + tuple in the form ``(source, filename, uptodate)`` or raise a + `TemplateNotFound` error if it can't locate the template. + + The source part of the returned tuple must be the source of the + template as a string. The filename should be the name of the + file on the filesystem if it was loaded from there, otherwise + ``None``. The filename is used by Python for the tracebacks + if no loader extension is used. + + The last item in the tuple is the `uptodate` function. If auto + reloading is enabled it's always called to check if the template + changed. No arguments are passed so the function must store the + old state somewhere (for example in a closure). If it returns `False` + the template will be reloaded. + """ + if not self.has_source_access: + raise RuntimeError( + f"{type(self).__name__} cannot provide access to the source" + ) + raise TemplateNotFound(template) + + def list_templates(self) -> t.List[str]: + """Iterates over all templates. If the loader does not support that + it should raise a :exc:`TypeError` which is the default behavior. + """ + raise TypeError("this loader cannot iterate over all templates") + + @internalcode + def load( + self, + environment: "Environment", + name: str, + globals: t.Optional[t.MutableMapping[str, t.Any]] = None, + ) -> "Template": + """Loads a template. This method looks up the template in the cache + or loads one by calling :meth:`get_source`. Subclasses should not + override this method as loaders working on collections of other + loaders (such as :class:`PrefixLoader` or :class:`ChoiceLoader`) + will not call this method but `get_source` directly. + """ + code = None + if globals is None: + globals = {} + + # first we try to get the source for this template together + # with the filename and the uptodate function. + source, filename, uptodate = self.get_source(environment, name) + + # try to load the code from the bytecode cache if there is a + # bytecode cache configured. + bcc = environment.bytecode_cache + if bcc is not None: + bucket = bcc.get_bucket(environment, name, filename, source) + code = bucket.code + + # if we don't have code so far (not cached, no longer up to + # date) etc. we compile the template + if code is None: + code = environment.compile(source, name, filename) + + # if the bytecode cache is available and the bucket doesn't + # have a code so far, we give the bucket the new code and put + # it back to the bytecode cache. + if bcc is not None and bucket.code is None: + bucket.code = code + bcc.set_bucket(bucket) + + return environment.template_class.from_code( + environment, code, globals, uptodate + ) + + +class FileSystemLoader(BaseLoader): + """Load templates from a directory in the file system. + + The path can be relative or absolute. Relative paths are relative to + the current working directory. + + .. code-block:: python + + loader = FileSystemLoader("templates") + + A list of paths can be given. The directories will be searched in + order, stopping at the first matching template. + + .. code-block:: python + + loader = FileSystemLoader(["/override/templates", "/default/templates"]) + + :param searchpath: A path, or list of paths, to the directory that + contains the templates. + :param encoding: Use this encoding to read the text from template + files. + :param followlinks: Follow symbolic links in the path. + + .. versionchanged:: 2.8 + Added the ``followlinks`` parameter. + """ + + def __init__( + self, + searchpath: t.Union[str, os.PathLike, t.Sequence[t.Union[str, os.PathLike]]], + encoding: str = "utf-8", + followlinks: bool = False, + ) -> None: + if not isinstance(searchpath, abc.Iterable) or isinstance(searchpath, str): + searchpath = [searchpath] + + self.searchpath = [os.fspath(p) for p in searchpath] + self.encoding = encoding + self.followlinks = followlinks + + def get_source( + self, environment: "Environment", template: str + ) -> t.Tuple[str, str, t.Callable[[], bool]]: + pieces = split_template_path(template) + for searchpath in self.searchpath: + # Use posixpath even on Windows to avoid "drive:" or UNC + # segments breaking out of the search directory. + filename = posixpath.join(searchpath, *pieces) + f = open_if_exists(filename) + if f is None: + continue + try: + contents = f.read().decode(self.encoding) + finally: + f.close() + + mtime = os.path.getmtime(filename) + + def uptodate() -> bool: + try: + return os.path.getmtime(filename) == mtime + except OSError: + return False + + # Use normpath to convert Windows altsep to sep. + return contents, os.path.normpath(filename), uptodate + raise TemplateNotFound(template) + + def list_templates(self) -> t.List[str]: + found = set() + for searchpath in self.searchpath: + walk_dir = os.walk(searchpath, followlinks=self.followlinks) + for dirpath, _, filenames in walk_dir: + for filename in filenames: + template = ( + os.path.join(dirpath, filename)[len(searchpath) :] + .strip(os.path.sep) + .replace(os.path.sep, "/") + ) + if template[:2] == "./": + template = template[2:] + if template not in found: + found.add(template) + return sorted(found) + + +class PackageLoader(BaseLoader): + """Load templates from a directory in a Python package. + + :param package_name: Import name of the package that contains the + template directory. + :param package_path: Directory within the imported package that + contains the templates. + :param encoding: Encoding of template files. + + The following example looks up templates in the ``pages`` directory + within the ``project.ui`` package. + + .. code-block:: python + + loader = PackageLoader("project.ui", "pages") + + Only packages installed as directories (standard pip behavior) or + zip/egg files (less common) are supported. The Python API for + introspecting data in packages is too limited to support other + installation methods the way this loader requires. + + There is limited support for :pep:`420` namespace packages. The + template directory is assumed to only be in one namespace + contributor. Zip files contributing to a namespace are not + supported. + + .. versionchanged:: 3.0 + No longer uses ``setuptools`` as a dependency. + + .. versionchanged:: 3.0 + Limited PEP 420 namespace package support. + """ + + def __init__( + self, + package_name: str, + package_path: "str" = "templates", + encoding: str = "utf-8", + ) -> None: + package_path = os.path.normpath(package_path).rstrip(os.path.sep) + + # normpath preserves ".", which isn't valid in zip paths. + if package_path == os.path.curdir: + package_path = "" + elif package_path[:2] == os.path.curdir + os.path.sep: + package_path = package_path[2:] + + self.package_path = package_path + self.package_name = package_name + self.encoding = encoding + + # Make sure the package exists. This also makes namespace + # packages work, otherwise get_loader returns None. + import_module(package_name) + spec = importlib.util.find_spec(package_name) + assert spec is not None, "An import spec was not found for the package." + loader = spec.loader + assert loader is not None, "A loader was not found for the package." + self._loader = loader + self._archive = None + template_root = None + + if isinstance(loader, zipimport.zipimporter): + self._archive = loader.archive + pkgdir = next(iter(spec.submodule_search_locations)) # type: ignore + template_root = os.path.join(pkgdir, package_path).rstrip(os.path.sep) + else: + roots: t.List[str] = [] + + # One element for regular packages, multiple for namespace + # packages, or None for single module file. + if spec.submodule_search_locations: + roots.extend(spec.submodule_search_locations) + # A single module file, use the parent directory instead. + elif spec.origin is not None: + roots.append(os.path.dirname(spec.origin)) + + for root in roots: + root = os.path.join(root, package_path) + + if os.path.isdir(root): + template_root = root + break + + if template_root is None: + raise ValueError( + f"The {package_name!r} package was not installed in a" + " way that PackageLoader understands." + ) + + self._template_root = template_root + + def get_source( + self, environment: "Environment", template: str + ) -> t.Tuple[str, str, t.Optional[t.Callable[[], bool]]]: + # Use posixpath even on Windows to avoid "drive:" or UNC + # segments breaking out of the search directory. Use normpath to + # convert Windows altsep to sep. + p = os.path.normpath( + posixpath.join(self._template_root, *split_template_path(template)) + ) + up_to_date: t.Optional[t.Callable[[], bool]] + + if self._archive is None: + # Package is a directory. + if not os.path.isfile(p): + raise TemplateNotFound(template) + + with open(p, "rb") as f: + source = f.read() + + mtime = os.path.getmtime(p) + + def up_to_date() -> bool: + return os.path.isfile(p) and os.path.getmtime(p) == mtime + + else: + # Package is a zip file. + try: + source = self._loader.get_data(p) # type: ignore + except OSError as e: + raise TemplateNotFound(template) from e + + # Could use the zip's mtime for all template mtimes, but + # would need to safely reload the module if it's out of + # date, so just report it as always current. + up_to_date = None + + return source.decode(self.encoding), p, up_to_date + + def list_templates(self) -> t.List[str]: + results: t.List[str] = [] + + if self._archive is None: + # Package is a directory. + offset = len(self._template_root) + + for dirpath, _, filenames in os.walk(self._template_root): + dirpath = dirpath[offset:].lstrip(os.path.sep) + results.extend( + os.path.join(dirpath, name).replace(os.path.sep, "/") + for name in filenames + ) + else: + if not hasattr(self._loader, "_files"): + raise TypeError( + "This zip import does not have the required" + " metadata to list templates." + ) + + # Package is a zip file. + prefix = ( + self._template_root[len(self._archive) :].lstrip(os.path.sep) + + os.path.sep + ) + offset = len(prefix) + + for name in self._loader._files.keys(): # type: ignore + # Find names under the templates directory that aren't directories. + if name.startswith(prefix) and name[-1] != os.path.sep: + results.append(name[offset:].replace(os.path.sep, "/")) + + results.sort() + return results + + +class DictLoader(BaseLoader): + """Loads a template from a Python dict mapping template names to + template source. This loader is useful for unittesting: + + >>> loader = DictLoader({'index.html': 'source here'}) + + Because auto reloading is rarely useful this is disabled per default. + """ + + def __init__(self, mapping: t.Mapping[str, str]) -> None: + self.mapping = mapping + + def get_source( + self, environment: "Environment", template: str + ) -> t.Tuple[str, None, t.Callable[[], bool]]: + if template in self.mapping: + source = self.mapping[template] + return source, None, lambda: source == self.mapping.get(template) + raise TemplateNotFound(template) + + def list_templates(self) -> t.List[str]: + return sorted(self.mapping) + + +class FunctionLoader(BaseLoader): + """A loader that is passed a function which does the loading. The + function receives the name of the template and has to return either + a string with the template source, a tuple in the form ``(source, + filename, uptodatefunc)`` or `None` if the template does not exist. + + >>> def load_template(name): + ... if name == 'index.html': + ... return '...' + ... + >>> loader = FunctionLoader(load_template) + + The `uptodatefunc` is a function that is called if autoreload is enabled + and has to return `True` if the template is still up to date. For more + details have a look at :meth:`BaseLoader.get_source` which has the same + return value. + """ + + def __init__( + self, + load_func: t.Callable[ + [str], + t.Optional[ + t.Union[ + str, t.Tuple[str, t.Optional[str], t.Optional[t.Callable[[], bool]]] + ] + ], + ], + ) -> None: + self.load_func = load_func + + def get_source( + self, environment: "Environment", template: str + ) -> t.Tuple[str, t.Optional[str], t.Optional[t.Callable[[], bool]]]: + rv = self.load_func(template) + + if rv is None: + raise TemplateNotFound(template) + + if isinstance(rv, str): + return rv, None, None + + return rv + + +class PrefixLoader(BaseLoader): + """A loader that is passed a dict of loaders where each loader is bound + to a prefix. The prefix is delimited from the template by a slash per + default, which can be changed by setting the `delimiter` argument to + something else:: + + loader = PrefixLoader({ + 'app1': PackageLoader('mypackage.app1'), + 'app2': PackageLoader('mypackage.app2') + }) + + By loading ``'app1/index.html'`` the file from the app1 package is loaded, + by loading ``'app2/index.html'`` the file from the second. + """ + + def __init__( + self, mapping: t.Mapping[str, BaseLoader], delimiter: str = "/" + ) -> None: + self.mapping = mapping + self.delimiter = delimiter + + def get_loader(self, template: str) -> t.Tuple[BaseLoader, str]: + try: + prefix, name = template.split(self.delimiter, 1) + loader = self.mapping[prefix] + except (ValueError, KeyError) as e: + raise TemplateNotFound(template) from e + return loader, name + + def get_source( + self, environment: "Environment", template: str + ) -> t.Tuple[str, t.Optional[str], t.Optional[t.Callable[[], bool]]]: + loader, name = self.get_loader(template) + try: + return loader.get_source(environment, name) + except TemplateNotFound as e: + # re-raise the exception with the correct filename here. + # (the one that includes the prefix) + raise TemplateNotFound(template) from e + + @internalcode + def load( + self, + environment: "Environment", + name: str, + globals: t.Optional[t.MutableMapping[str, t.Any]] = None, + ) -> "Template": + loader, local_name = self.get_loader(name) + try: + return loader.load(environment, local_name, globals) + except TemplateNotFound as e: + # re-raise the exception with the correct filename here. + # (the one that includes the prefix) + raise TemplateNotFound(name) from e + + def list_templates(self) -> t.List[str]: + result = [] + for prefix, loader in self.mapping.items(): + for template in loader.list_templates(): + result.append(prefix + self.delimiter + template) + return result + + +class ChoiceLoader(BaseLoader): + """This loader works like the `PrefixLoader` just that no prefix is + specified. If a template could not be found by one loader the next one + is tried. + + >>> loader = ChoiceLoader([ + ... FileSystemLoader('/path/to/user/templates'), + ... FileSystemLoader('/path/to/system/templates') + ... ]) + + This is useful if you want to allow users to override builtin templates + from a different location. + """ + + def __init__(self, loaders: t.Sequence[BaseLoader]) -> None: + self.loaders = loaders + + def get_source( + self, environment: "Environment", template: str + ) -> t.Tuple[str, t.Optional[str], t.Optional[t.Callable[[], bool]]]: + for loader in self.loaders: + try: + return loader.get_source(environment, template) + except TemplateNotFound: + pass + raise TemplateNotFound(template) + + @internalcode + def load( + self, + environment: "Environment", + name: str, + globals: t.Optional[t.MutableMapping[str, t.Any]] = None, + ) -> "Template": + for loader in self.loaders: + try: + return loader.load(environment, name, globals) + except TemplateNotFound: + pass + raise TemplateNotFound(name) + + def list_templates(self) -> t.List[str]: + found = set() + for loader in self.loaders: + found.update(loader.list_templates()) + return sorted(found) + + +class _TemplateModule(ModuleType): + """Like a normal module but with support for weak references""" + + +class ModuleLoader(BaseLoader): + """This loader loads templates from precompiled templates. + + Example usage: + + >>> loader = ChoiceLoader([ + ... ModuleLoader('/path/to/compiled/templates'), + ... FileSystemLoader('/path/to/templates') + ... ]) + + Templates can be precompiled with :meth:`Environment.compile_templates`. + """ + + has_source_access = False + + def __init__( + self, path: t.Union[str, os.PathLike, t.Sequence[t.Union[str, os.PathLike]]] + ) -> None: + package_name = f"_jinja2_module_templates_{id(self):x}" + + # create a fake module that looks for the templates in the + # path given. + mod = _TemplateModule(package_name) + + if not isinstance(path, abc.Iterable) or isinstance(path, str): + path = [path] + + mod.__path__ = [os.fspath(p) for p in path] + + sys.modules[package_name] = weakref.proxy( + mod, lambda x: sys.modules.pop(package_name, None) + ) + + # the only strong reference, the sys.modules entry is weak + # so that the garbage collector can remove it once the + # loader that created it goes out of business. + self.module = mod + self.package_name = package_name + + @staticmethod + def get_template_key(name: str) -> str: + return "tmpl_" + sha1(name.encode("utf-8")).hexdigest() + + @staticmethod + def get_module_filename(name: str) -> str: + return ModuleLoader.get_template_key(name) + ".py" + + @internalcode + def load( + self, + environment: "Environment", + name: str, + globals: t.Optional[t.MutableMapping[str, t.Any]] = None, + ) -> "Template": + key = self.get_template_key(name) + module = f"{self.package_name}.{key}" + mod = getattr(self.module, module, None) + + if mod is None: + try: + mod = __import__(module, None, None, ["root"]) + except ImportError as e: + raise TemplateNotFound(name) from e + + # remove the entry from sys.modules, we only want the attribute + # on the module object we have stored on the loader. + sys.modules.pop(module, None) + + if globals is None: + globals = {} + + return environment.template_class.from_module_dict( + environment, mod.__dict__, globals + ) diff --git a/src/jinja2/meta.py b/src/jinja2/meta.py new file mode 100644 index 0000000..0057d6e --- /dev/null +++ b/src/jinja2/meta.py @@ -0,0 +1,111 @@ +"""Functions that expose information about templates that might be +interesting for introspection. +""" +import typing as t + +from . import nodes +from .compiler import CodeGenerator +from .compiler import Frame + +if t.TYPE_CHECKING: + from .environment import Environment + + +class TrackingCodeGenerator(CodeGenerator): + """We abuse the code generator for introspection.""" + + def __init__(self, environment: "Environment") -> None: + super().__init__(environment, "<introspection>", "<introspection>") + self.undeclared_identifiers: t.Set[str] = set() + + def write(self, x: str) -> None: + """Don't write.""" + + def enter_frame(self, frame: Frame) -> None: + """Remember all undeclared identifiers.""" + super().enter_frame(frame) + + for _, (action, param) in frame.symbols.loads.items(): + if action == "resolve" and param not in self.environment.globals: + self.undeclared_identifiers.add(param) + + +def find_undeclared_variables(ast: nodes.Template) -> t.Set[str]: + """Returns a set of all variables in the AST that will be looked up from + the context at runtime. Because at compile time it's not known which + variables will be used depending on the path the execution takes at + runtime, all variables are returned. + + >>> from jinja2 import Environment, meta + >>> env = Environment() + >>> ast = env.parse('{% set foo = 42 %}{{ bar + foo }}') + >>> meta.find_undeclared_variables(ast) == {'bar'} + True + + .. admonition:: Implementation + + Internally the code generator is used for finding undeclared variables. + This is good to know because the code generator might raise a + :exc:`TemplateAssertionError` during compilation and as a matter of + fact this function can currently raise that exception as well. + """ + codegen = TrackingCodeGenerator(ast.environment) # type: ignore + codegen.visit(ast) + return codegen.undeclared_identifiers + + +_ref_types = (nodes.Extends, nodes.FromImport, nodes.Import, nodes.Include) +_RefType = t.Union[nodes.Extends, nodes.FromImport, nodes.Import, nodes.Include] + + +def find_referenced_templates(ast: nodes.Template) -> t.Iterator[t.Optional[str]]: + """Finds all the referenced templates from the AST. This will return an + iterator over all the hardcoded template extensions, inclusions and + imports. If dynamic inheritance or inclusion is used, `None` will be + yielded. + + >>> from jinja2 import Environment, meta + >>> env = Environment() + >>> ast = env.parse('{% extends "layout.html" %}{% include helper %}') + >>> list(meta.find_referenced_templates(ast)) + ['layout.html', None] + + This function is useful for dependency tracking. For example if you want + to rebuild parts of the website after a layout template has changed. + """ + template_name: t.Any + + for node in ast.find_all(_ref_types): + template: nodes.Expr = node.template # type: ignore + + if not isinstance(template, nodes.Const): + # a tuple with some non consts in there + if isinstance(template, (nodes.Tuple, nodes.List)): + for template_name in template.items: + # something const, only yield the strings and ignore + # non-string consts that really just make no sense + if isinstance(template_name, nodes.Const): + if isinstance(template_name.value, str): + yield template_name.value + # something dynamic in there + else: + yield None + # something dynamic we don't know about here + else: + yield None + continue + # constant is a basestring, direct template name + if isinstance(template.value, str): + yield template.value + # a tuple or list (latter *should* not happen) made of consts, + # yield the consts that are strings. We could warn here for + # non string values + elif isinstance(node, nodes.Include) and isinstance( + template.value, (tuple, list) + ): + for template_name in template.value: + if isinstance(template_name, str): + yield template_name + # something else we don't care about, we could warn here + else: + yield None diff --git a/src/jinja2/nativetypes.py b/src/jinja2/nativetypes.py new file mode 100644 index 0000000..ac08610 --- /dev/null +++ b/src/jinja2/nativetypes.py @@ -0,0 +1,130 @@ +import typing as t +from ast import literal_eval +from ast import parse +from itertools import chain +from itertools import islice +from types import GeneratorType + +from . import nodes +from .compiler import CodeGenerator +from .compiler import Frame +from .compiler import has_safe_repr +from .environment import Environment +from .environment import Template + + +def native_concat(values: t.Iterable[t.Any]) -> t.Optional[t.Any]: + """Return a native Python type from the list of compiled nodes. If + the result is a single node, its value is returned. Otherwise, the + nodes are concatenated as strings. If the result can be parsed with + :func:`ast.literal_eval`, the parsed value is returned. Otherwise, + the string is returned. + + :param values: Iterable of outputs to concatenate. + """ + head = list(islice(values, 2)) + + if not head: + return None + + if len(head) == 1: + raw = head[0] + if not isinstance(raw, str): + return raw + else: + if isinstance(values, GeneratorType): + values = chain(head, values) + raw = "".join([str(v) for v in values]) + + try: + return literal_eval( + # In Python 3.10+ ast.literal_eval removes leading spaces/tabs + # from the given string. For backwards compatibility we need to + # parse the string ourselves without removing leading spaces/tabs. + parse(raw, mode="eval") + ) + except (ValueError, SyntaxError, MemoryError): + return raw + + +class NativeCodeGenerator(CodeGenerator): + """A code generator which renders Python types by not adding + ``str()`` around output nodes. + """ + + @staticmethod + def _default_finalize(value: t.Any) -> t.Any: + return value + + def _output_const_repr(self, group: t.Iterable[t.Any]) -> str: + return repr("".join([str(v) for v in group])) + + def _output_child_to_const( + self, node: nodes.Expr, frame: Frame, finalize: CodeGenerator._FinalizeInfo + ) -> t.Any: + const = node.as_const(frame.eval_ctx) + + if not has_safe_repr(const): + raise nodes.Impossible() + + if isinstance(node, nodes.TemplateData): + return const + + return finalize.const(const) # type: ignore + + def _output_child_pre( + self, node: nodes.Expr, frame: Frame, finalize: CodeGenerator._FinalizeInfo + ) -> None: + if finalize.src is not None: + self.write(finalize.src) + + def _output_child_post( + self, node: nodes.Expr, frame: Frame, finalize: CodeGenerator._FinalizeInfo + ) -> None: + if finalize.src is not None: + self.write(")") + + +class NativeEnvironment(Environment): + """An environment that renders templates to native Python types.""" + + code_generator_class = NativeCodeGenerator + concat = staticmethod(native_concat) # type: ignore + + +class NativeTemplate(Template): + environment_class = NativeEnvironment + + def render(self, *args: t.Any, **kwargs: t.Any) -> t.Any: + """Render the template to produce a native Python type. If the + result is a single node, its value is returned. Otherwise, the + nodes are concatenated as strings. If the result can be parsed + with :func:`ast.literal_eval`, the parsed value is returned. + Otherwise, the string is returned. + """ + ctx = self.new_context(dict(*args, **kwargs)) + + try: + return self.environment_class.concat( # type: ignore + self.root_render_func(ctx) # type: ignore + ) + except Exception: + return self.environment.handle_exception() + + async def render_async(self, *args: t.Any, **kwargs: t.Any) -> t.Any: + if not self.environment.is_async: + raise RuntimeError( + "The environment was not created with async mode enabled." + ) + + ctx = self.new_context(dict(*args, **kwargs)) + + try: + return self.environment_class.concat( # type: ignore + [n async for n in self.root_render_func(ctx)] # type: ignore + ) + except Exception: + return self.environment.handle_exception() + + +NativeEnvironment.template_class = NativeTemplate diff --git a/src/jinja2/nodes.py b/src/jinja2/nodes.py new file mode 100644 index 0000000..b2f88d9 --- /dev/null +++ b/src/jinja2/nodes.py @@ -0,0 +1,1204 @@ +"""AST nodes generated by the parser for the compiler. Also provides +some node tree helper functions used by the parser and compiler in order +to normalize nodes. +""" +import inspect +import operator +import typing as t +from collections import deque + +from markupsafe import Markup + +from .utils import _PassArg + +if t.TYPE_CHECKING: + import typing_extensions as te + from .environment import Environment + +_NodeBound = t.TypeVar("_NodeBound", bound="Node") + +_binop_to_func: t.Dict[str, t.Callable[[t.Any, t.Any], t.Any]] = { + "*": operator.mul, + "/": operator.truediv, + "//": operator.floordiv, + "**": operator.pow, + "%": operator.mod, + "+": operator.add, + "-": operator.sub, +} + +_uaop_to_func: t.Dict[str, t.Callable[[t.Any], t.Any]] = { + "not": operator.not_, + "+": operator.pos, + "-": operator.neg, +} + +_cmpop_to_func: t.Dict[str, t.Callable[[t.Any, t.Any], t.Any]] = { + "eq": operator.eq, + "ne": operator.ne, + "gt": operator.gt, + "gteq": operator.ge, + "lt": operator.lt, + "lteq": operator.le, + "in": lambda a, b: a in b, + "notin": lambda a, b: a not in b, +} + + +class Impossible(Exception): + """Raised if the node could not perform a requested action.""" + + +class NodeType(type): + """A metaclass for nodes that handles the field and attribute + inheritance. fields and attributes from the parent class are + automatically forwarded to the child.""" + + def __new__(mcs, name, bases, d): # type: ignore + for attr in "fields", "attributes": + storage = [] + storage.extend(getattr(bases[0] if bases else object, attr, ())) + storage.extend(d.get(attr, ())) + assert len(bases) <= 1, "multiple inheritance not allowed" + assert len(storage) == len(set(storage)), "layout conflict" + d[attr] = tuple(storage) + d.setdefault("abstract", False) + return type.__new__(mcs, name, bases, d) + + +class EvalContext: + """Holds evaluation time information. Custom attributes can be attached + to it in extensions. + """ + + def __init__( + self, environment: "Environment", template_name: t.Optional[str] = None + ) -> None: + self.environment = environment + if callable(environment.autoescape): + self.autoescape = environment.autoescape(template_name) + else: + self.autoescape = environment.autoescape + self.volatile = False + + def save(self) -> t.Mapping[str, t.Any]: + return self.__dict__.copy() + + def revert(self, old: t.Mapping[str, t.Any]) -> None: + self.__dict__.clear() + self.__dict__.update(old) + + +def get_eval_context(node: "Node", ctx: t.Optional[EvalContext]) -> EvalContext: + if ctx is None: + if node.environment is None: + raise RuntimeError( + "if no eval context is passed, the node must have an" + " attached environment." + ) + return EvalContext(node.environment) + return ctx + + +class Node(metaclass=NodeType): + """Baseclass for all Jinja nodes. There are a number of nodes available + of different types. There are four major types: + + - :class:`Stmt`: statements + - :class:`Expr`: expressions + - :class:`Helper`: helper nodes + - :class:`Template`: the outermost wrapper node + + All nodes have fields and attributes. Fields may be other nodes, lists, + or arbitrary values. Fields are passed to the constructor as regular + positional arguments, attributes as keyword arguments. Each node has + two attributes: `lineno` (the line number of the node) and `environment`. + The `environment` attribute is set at the end of the parsing process for + all nodes automatically. + """ + + fields: t.Tuple[str, ...] = () + attributes: t.Tuple[str, ...] = ("lineno", "environment") + abstract = True + + lineno: int + environment: t.Optional["Environment"] + + def __init__(self, *fields: t.Any, **attributes: t.Any) -> None: + if self.abstract: + raise TypeError("abstract nodes are not instantiable") + if fields: + if len(fields) != len(self.fields): + if not self.fields: + raise TypeError(f"{type(self).__name__!r} takes 0 arguments") + raise TypeError( + f"{type(self).__name__!r} takes 0 or {len(self.fields)}" + f" argument{'s' if len(self.fields) != 1 else ''}" + ) + for name, arg in zip(self.fields, fields): + setattr(self, name, arg) + for attr in self.attributes: + setattr(self, attr, attributes.pop(attr, None)) + if attributes: + raise TypeError(f"unknown attribute {next(iter(attributes))!r}") + + def iter_fields( + self, + exclude: t.Optional[t.Container[str]] = None, + only: t.Optional[t.Container[str]] = None, + ) -> t.Iterator[t.Tuple[str, t.Any]]: + """This method iterates over all fields that are defined and yields + ``(key, value)`` tuples. Per default all fields are returned, but + it's possible to limit that to some fields by providing the `only` + parameter or to exclude some using the `exclude` parameter. Both + should be sets or tuples of field names. + """ + for name in self.fields: + if ( + (exclude is None and only is None) + or (exclude is not None and name not in exclude) + or (only is not None and name in only) + ): + try: + yield name, getattr(self, name) + except AttributeError: + pass + + def iter_child_nodes( + self, + exclude: t.Optional[t.Container[str]] = None, + only: t.Optional[t.Container[str]] = None, + ) -> t.Iterator["Node"]: + """Iterates over all direct child nodes of the node. This iterates + over all fields and yields the values of they are nodes. If the value + of a field is a list all the nodes in that list are returned. + """ + for _, item in self.iter_fields(exclude, only): + if isinstance(item, list): + for n in item: + if isinstance(n, Node): + yield n + elif isinstance(item, Node): + yield item + + def find(self, node_type: t.Type[_NodeBound]) -> t.Optional[_NodeBound]: + """Find the first node of a given type. If no such node exists the + return value is `None`. + """ + for result in self.find_all(node_type): + return result + + return None + + def find_all( + self, node_type: t.Union[t.Type[_NodeBound], t.Tuple[t.Type[_NodeBound], ...]] + ) -> t.Iterator[_NodeBound]: + """Find all the nodes of a given type. If the type is a tuple, + the check is performed for any of the tuple items. + """ + for child in self.iter_child_nodes(): + if isinstance(child, node_type): + yield child # type: ignore + yield from child.find_all(node_type) + + def set_ctx(self, ctx: str) -> "Node": + """Reset the context of a node and all child nodes. Per default the + parser will all generate nodes that have a 'load' context as it's the + most common one. This method is used in the parser to set assignment + targets and other nodes to a store context. + """ + todo = deque([self]) + while todo: + node = todo.popleft() + if "ctx" in node.fields: + node.ctx = ctx # type: ignore + todo.extend(node.iter_child_nodes()) + return self + + def set_lineno(self, lineno: int, override: bool = False) -> "Node": + """Set the line numbers of the node and children.""" + todo = deque([self]) + while todo: + node = todo.popleft() + if "lineno" in node.attributes: + if node.lineno is None or override: + node.lineno = lineno + todo.extend(node.iter_child_nodes()) + return self + + def set_environment(self, environment: "Environment") -> "Node": + """Set the environment for all nodes.""" + todo = deque([self]) + while todo: + node = todo.popleft() + node.environment = environment + todo.extend(node.iter_child_nodes()) + return self + + def __eq__(self, other: t.Any) -> bool: + if type(self) is not type(other): + return NotImplemented + + return tuple(self.iter_fields()) == tuple(other.iter_fields()) + + __hash__ = object.__hash__ + + def __repr__(self) -> str: + args_str = ", ".join(f"{a}={getattr(self, a, None)!r}" for a in self.fields) + return f"{type(self).__name__}({args_str})" + + def dump(self) -> str: + def _dump(node: t.Union[Node, t.Any]) -> None: + if not isinstance(node, Node): + buf.append(repr(node)) + return + + buf.append(f"nodes.{type(node).__name__}(") + if not node.fields: + buf.append(")") + return + for idx, field in enumerate(node.fields): + if idx: + buf.append(", ") + value = getattr(node, field) + if isinstance(value, list): + buf.append("[") + for idx, item in enumerate(value): + if idx: + buf.append(", ") + _dump(item) + buf.append("]") + else: + _dump(value) + buf.append(")") + + buf: t.List[str] = [] + _dump(self) + return "".join(buf) + + +class Stmt(Node): + """Base node for all statements.""" + + abstract = True + + +class Helper(Node): + """Nodes that exist in a specific context only.""" + + abstract = True + + +class Template(Node): + """Node that represents a template. This must be the outermost node that + is passed to the compiler. + """ + + fields = ("body",) + body: t.List[Node] + + +class Output(Stmt): + """A node that holds multiple expressions which are then printed out. + This is used both for the `print` statement and the regular template data. + """ + + fields = ("nodes",) + nodes: t.List["Expr"] + + +class Extends(Stmt): + """Represents an extends statement.""" + + fields = ("template",) + template: "Expr" + + +class For(Stmt): + """The for loop. `target` is the target for the iteration (usually a + :class:`Name` or :class:`Tuple`), `iter` the iterable. `body` is a list + of nodes that are used as loop-body, and `else_` a list of nodes for the + `else` block. If no else node exists it has to be an empty list. + + For filtered nodes an expression can be stored as `test`, otherwise `None`. + """ + + fields = ("target", "iter", "body", "else_", "test", "recursive") + target: Node + iter: Node + body: t.List[Node] + else_: t.List[Node] + test: t.Optional[Node] + recursive: bool + + +class If(Stmt): + """If `test` is true, `body` is rendered, else `else_`.""" + + fields = ("test", "body", "elif_", "else_") + test: Node + body: t.List[Node] + elif_: t.List["If"] + else_: t.List[Node] + + +class Macro(Stmt): + """A macro definition. `name` is the name of the macro, `args` a list of + arguments and `defaults` a list of defaults if there are any. `body` is + a list of nodes for the macro body. + """ + + fields = ("name", "args", "defaults", "body") + name: str + args: t.List["Name"] + defaults: t.List["Expr"] + body: t.List[Node] + + +class CallBlock(Stmt): + """Like a macro without a name but a call instead. `call` is called with + the unnamed macro as `caller` argument this node holds. + """ + + fields = ("call", "args", "defaults", "body") + call: "Call" + args: t.List["Name"] + defaults: t.List["Expr"] + body: t.List[Node] + + +class FilterBlock(Stmt): + """Node for filter sections.""" + + fields = ("body", "filter") + body: t.List[Node] + filter: "Filter" + + +class With(Stmt): + """Specific node for with statements. In older versions of Jinja the + with statement was implemented on the base of the `Scope` node instead. + + .. versionadded:: 2.9.3 + """ + + fields = ("targets", "values", "body") + targets: t.List["Expr"] + values: t.List["Expr"] + body: t.List[Node] + + +class Block(Stmt): + """A node that represents a block. + + .. versionchanged:: 3.0.0 + the `required` field was added. + """ + + fields = ("name", "body", "scoped", "required") + name: str + body: t.List[Node] + scoped: bool + required: bool + + +class Include(Stmt): + """A node that represents the include tag.""" + + fields = ("template", "with_context", "ignore_missing") + template: "Expr" + with_context: bool + ignore_missing: bool + + +class Import(Stmt): + """A node that represents the import tag.""" + + fields = ("template", "target", "with_context") + template: "Expr" + target: str + with_context: bool + + +class FromImport(Stmt): + """A node that represents the from import tag. It's important to not + pass unsafe names to the name attribute. The compiler translates the + attribute lookups directly into getattr calls and does *not* use the + subscript callback of the interface. As exported variables may not + start with double underscores (which the parser asserts) this is not a + problem for regular Jinja code, but if this node is used in an extension + extra care must be taken. + + The list of names may contain tuples if aliases are wanted. + """ + + fields = ("template", "names", "with_context") + template: "Expr" + names: t.List[t.Union[str, t.Tuple[str, str]]] + with_context: bool + + +class ExprStmt(Stmt): + """A statement that evaluates an expression and discards the result.""" + + fields = ("node",) + node: Node + + +class Assign(Stmt): + """Assigns an expression to a target.""" + + fields = ("target", "node") + target: "Expr" + node: Node + + +class AssignBlock(Stmt): + """Assigns a block to a target.""" + + fields = ("target", "filter", "body") + target: "Expr" + filter: t.Optional["Filter"] + body: t.List[Node] + + +class Expr(Node): + """Baseclass for all expressions.""" + + abstract = True + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.Any: + """Return the value of the expression as constant or raise + :exc:`Impossible` if this was not possible. + + An :class:`EvalContext` can be provided, if none is given + a default context is created which requires the nodes to have + an attached environment. + + .. versionchanged:: 2.4 + the `eval_ctx` parameter was added. + """ + raise Impossible() + + def can_assign(self) -> bool: + """Check if it's possible to assign something to this node.""" + return False + + +class BinExpr(Expr): + """Baseclass for all binary expressions.""" + + fields = ("left", "right") + left: Expr + right: Expr + operator: str + abstract = True + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.Any: + eval_ctx = get_eval_context(self, eval_ctx) + + # intercepted operators cannot be folded at compile time + if ( + eval_ctx.environment.sandboxed + and self.operator in eval_ctx.environment.intercepted_binops # type: ignore + ): + raise Impossible() + f = _binop_to_func[self.operator] + try: + return f(self.left.as_const(eval_ctx), self.right.as_const(eval_ctx)) + except Exception as e: + raise Impossible() from e + + +class UnaryExpr(Expr): + """Baseclass for all unary expressions.""" + + fields = ("node",) + node: Expr + operator: str + abstract = True + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.Any: + eval_ctx = get_eval_context(self, eval_ctx) + + # intercepted operators cannot be folded at compile time + if ( + eval_ctx.environment.sandboxed + and self.operator in eval_ctx.environment.intercepted_unops # type: ignore + ): + raise Impossible() + f = _uaop_to_func[self.operator] + try: + return f(self.node.as_const(eval_ctx)) + except Exception as e: + raise Impossible() from e + + +class Name(Expr): + """Looks up a name or stores a value in a name. + The `ctx` of the node can be one of the following values: + + - `store`: store a value in the name + - `load`: load that name + - `param`: like `store` but if the name was defined as function parameter. + """ + + fields = ("name", "ctx") + name: str + ctx: str + + def can_assign(self) -> bool: + return self.name not in {"true", "false", "none", "True", "False", "None"} + + +class NSRef(Expr): + """Reference to a namespace value assignment""" + + fields = ("name", "attr") + name: str + attr: str + + def can_assign(self) -> bool: + # We don't need any special checks here; NSRef assignments have a + # runtime check to ensure the target is a namespace object which will + # have been checked already as it is created using a normal assignment + # which goes through a `Name` node. + return True + + +class Literal(Expr): + """Baseclass for literals.""" + + abstract = True + + +class Const(Literal): + """All constant values. The parser will return this node for simple + constants such as ``42`` or ``"foo"`` but it can be used to store more + complex values such as lists too. Only constants with a safe + representation (objects where ``eval(repr(x)) == x`` is true). + """ + + fields = ("value",) + value: t.Any + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.Any: + return self.value + + @classmethod + def from_untrusted( + cls, + value: t.Any, + lineno: t.Optional[int] = None, + environment: "t.Optional[Environment]" = None, + ) -> "Const": + """Return a const object if the value is representable as + constant value in the generated code, otherwise it will raise + an `Impossible` exception. + """ + from .compiler import has_safe_repr + + if not has_safe_repr(value): + raise Impossible() + return cls(value, lineno=lineno, environment=environment) + + +class TemplateData(Literal): + """A constant template string.""" + + fields = ("data",) + data: str + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> str: + eval_ctx = get_eval_context(self, eval_ctx) + if eval_ctx.volatile: + raise Impossible() + if eval_ctx.autoescape: + return Markup(self.data) + return self.data + + +class Tuple(Literal): + """For loop unpacking and some other things like multiple arguments + for subscripts. Like for :class:`Name` `ctx` specifies if the tuple + is used for loading the names or storing. + """ + + fields = ("items", "ctx") + items: t.List[Expr] + ctx: str + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.Tuple[t.Any, ...]: + eval_ctx = get_eval_context(self, eval_ctx) + return tuple(x.as_const(eval_ctx) for x in self.items) + + def can_assign(self) -> bool: + for item in self.items: + if not item.can_assign(): + return False + return True + + +class List(Literal): + """Any list literal such as ``[1, 2, 3]``""" + + fields = ("items",) + items: t.List[Expr] + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.List[t.Any]: + eval_ctx = get_eval_context(self, eval_ctx) + return [x.as_const(eval_ctx) for x in self.items] + + +class Dict(Literal): + """Any dict literal such as ``{1: 2, 3: 4}``. The items must be a list of + :class:`Pair` nodes. + """ + + fields = ("items",) + items: t.List["Pair"] + + def as_const( + self, eval_ctx: t.Optional[EvalContext] = None + ) -> t.Dict[t.Any, t.Any]: + eval_ctx = get_eval_context(self, eval_ctx) + return dict(x.as_const(eval_ctx) for x in self.items) + + +class Pair(Helper): + """A key, value pair for dicts.""" + + fields = ("key", "value") + key: Expr + value: Expr + + def as_const( + self, eval_ctx: t.Optional[EvalContext] = None + ) -> t.Tuple[t.Any, t.Any]: + eval_ctx = get_eval_context(self, eval_ctx) + return self.key.as_const(eval_ctx), self.value.as_const(eval_ctx) + + +class Keyword(Helper): + """A key, value pair for keyword arguments where key is a string.""" + + fields = ("key", "value") + key: str + value: Expr + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.Tuple[str, t.Any]: + eval_ctx = get_eval_context(self, eval_ctx) + return self.key, self.value.as_const(eval_ctx) + + +class CondExpr(Expr): + """A conditional expression (inline if expression). (``{{ + foo if bar else baz }}``) + """ + + fields = ("test", "expr1", "expr2") + test: Expr + expr1: Expr + expr2: t.Optional[Expr] + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.Any: + eval_ctx = get_eval_context(self, eval_ctx) + if self.test.as_const(eval_ctx): + return self.expr1.as_const(eval_ctx) + + # if we evaluate to an undefined object, we better do that at runtime + if self.expr2 is None: + raise Impossible() + + return self.expr2.as_const(eval_ctx) + + +def args_as_const( + node: t.Union["_FilterTestCommon", "Call"], eval_ctx: t.Optional[EvalContext] +) -> t.Tuple[t.List[t.Any], t.Dict[t.Any, t.Any]]: + args = [x.as_const(eval_ctx) for x in node.args] + kwargs = dict(x.as_const(eval_ctx) for x in node.kwargs) + + if node.dyn_args is not None: + try: + args.extend(node.dyn_args.as_const(eval_ctx)) + except Exception as e: + raise Impossible() from e + + if node.dyn_kwargs is not None: + try: + kwargs.update(node.dyn_kwargs.as_const(eval_ctx)) + except Exception as e: + raise Impossible() from e + + return args, kwargs + + +class _FilterTestCommon(Expr): + fields = ("node", "name", "args", "kwargs", "dyn_args", "dyn_kwargs") + node: Expr + name: str + args: t.List[Expr] + kwargs: t.List[Pair] + dyn_args: t.Optional[Expr] + dyn_kwargs: t.Optional[Expr] + abstract = True + _is_filter = True + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.Any: + eval_ctx = get_eval_context(self, eval_ctx) + + if eval_ctx.volatile: + raise Impossible() + + if self._is_filter: + env_map = eval_ctx.environment.filters + else: + env_map = eval_ctx.environment.tests + + func = env_map.get(self.name) + pass_arg = _PassArg.from_obj(func) # type: ignore + + if func is None or pass_arg is _PassArg.context: + raise Impossible() + + if eval_ctx.environment.is_async and ( + getattr(func, "jinja_async_variant", False) is True + or inspect.iscoroutinefunction(func) + ): + raise Impossible() + + args, kwargs = args_as_const(self, eval_ctx) + args.insert(0, self.node.as_const(eval_ctx)) + + if pass_arg is _PassArg.eval_context: + args.insert(0, eval_ctx) + elif pass_arg is _PassArg.environment: + args.insert(0, eval_ctx.environment) + + try: + return func(*args, **kwargs) + except Exception as e: + raise Impossible() from e + + +class Filter(_FilterTestCommon): + """Apply a filter to an expression. ``name`` is the name of the + filter, the other fields are the same as :class:`Call`. + + If ``node`` is ``None``, the filter is being used in a filter block + and is applied to the content of the block. + """ + + node: t.Optional[Expr] # type: ignore + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.Any: + if self.node is None: + raise Impossible() + + return super().as_const(eval_ctx=eval_ctx) + + +class Test(_FilterTestCommon): + """Apply a test to an expression. ``name`` is the name of the test, + the other field are the same as :class:`Call`. + + .. versionchanged:: 3.0 + ``as_const`` shares the same logic for filters and tests. Tests + check for volatile, async, and ``@pass_context`` etc. + decorators. + """ + + _is_filter = False + + +class Call(Expr): + """Calls an expression. `args` is a list of arguments, `kwargs` a list + of keyword arguments (list of :class:`Keyword` nodes), and `dyn_args` + and `dyn_kwargs` has to be either `None` or a node that is used as + node for dynamic positional (``*args``) or keyword (``**kwargs``) + arguments. + """ + + fields = ("node", "args", "kwargs", "dyn_args", "dyn_kwargs") + node: Expr + args: t.List[Expr] + kwargs: t.List[Keyword] + dyn_args: t.Optional[Expr] + dyn_kwargs: t.Optional[Expr] + + +class Getitem(Expr): + """Get an attribute or item from an expression and prefer the item.""" + + fields = ("node", "arg", "ctx") + node: Expr + arg: Expr + ctx: str + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.Any: + if self.ctx != "load": + raise Impossible() + + eval_ctx = get_eval_context(self, eval_ctx) + + try: + return eval_ctx.environment.getitem( + self.node.as_const(eval_ctx), self.arg.as_const(eval_ctx) + ) + except Exception as e: + raise Impossible() from e + + +class Getattr(Expr): + """Get an attribute or item from an expression that is a ascii-only + bytestring and prefer the attribute. + """ + + fields = ("node", "attr", "ctx") + node: Expr + attr: str + ctx: str + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.Any: + if self.ctx != "load": + raise Impossible() + + eval_ctx = get_eval_context(self, eval_ctx) + + try: + return eval_ctx.environment.getattr(self.node.as_const(eval_ctx), self.attr) + except Exception as e: + raise Impossible() from e + + +class Slice(Expr): + """Represents a slice object. This must only be used as argument for + :class:`Subscript`. + """ + + fields = ("start", "stop", "step") + start: t.Optional[Expr] + stop: t.Optional[Expr] + step: t.Optional[Expr] + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> slice: + eval_ctx = get_eval_context(self, eval_ctx) + + def const(obj: t.Optional[Expr]) -> t.Optional[t.Any]: + if obj is None: + return None + return obj.as_const(eval_ctx) + + return slice(const(self.start), const(self.stop), const(self.step)) + + +class Concat(Expr): + """Concatenates the list of expressions provided after converting + them to strings. + """ + + fields = ("nodes",) + nodes: t.List[Expr] + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> str: + eval_ctx = get_eval_context(self, eval_ctx) + return "".join(str(x.as_const(eval_ctx)) for x in self.nodes) + + +class Compare(Expr): + """Compares an expression with some other expressions. `ops` must be a + list of :class:`Operand`\\s. + """ + + fields = ("expr", "ops") + expr: Expr + ops: t.List["Operand"] + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.Any: + eval_ctx = get_eval_context(self, eval_ctx) + result = value = self.expr.as_const(eval_ctx) + + try: + for op in self.ops: + new_value = op.expr.as_const(eval_ctx) + result = _cmpop_to_func[op.op](value, new_value) + + if not result: + return False + + value = new_value + except Exception as e: + raise Impossible() from e + + return result + + +class Operand(Helper): + """Holds an operator and an expression.""" + + fields = ("op", "expr") + op: str + expr: Expr + + +class Mul(BinExpr): + """Multiplies the left with the right node.""" + + operator = "*" + + +class Div(BinExpr): + """Divides the left by the right node.""" + + operator = "/" + + +class FloorDiv(BinExpr): + """Divides the left by the right node and converts the + result into an integer by truncating. + """ + + operator = "//" + + +class Add(BinExpr): + """Add the left to the right node.""" + + operator = "+" + + +class Sub(BinExpr): + """Subtract the right from the left node.""" + + operator = "-" + + +class Mod(BinExpr): + """Left modulo right.""" + + operator = "%" + + +class Pow(BinExpr): + """Left to the power of right.""" + + operator = "**" + + +class And(BinExpr): + """Short circuited AND.""" + + operator = "and" + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.Any: + eval_ctx = get_eval_context(self, eval_ctx) + return self.left.as_const(eval_ctx) and self.right.as_const(eval_ctx) + + +class Or(BinExpr): + """Short circuited OR.""" + + operator = "or" + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> t.Any: + eval_ctx = get_eval_context(self, eval_ctx) + return self.left.as_const(eval_ctx) or self.right.as_const(eval_ctx) + + +class Not(UnaryExpr): + """Negate the expression.""" + + operator = "not" + + +class Neg(UnaryExpr): + """Make the expression negative.""" + + operator = "-" + + +class Pos(UnaryExpr): + """Make the expression positive (noop for most expressions)""" + + operator = "+" + + +# Helpers for extensions + + +class EnvironmentAttribute(Expr): + """Loads an attribute from the environment object. This is useful for + extensions that want to call a callback stored on the environment. + """ + + fields = ("name",) + name: str + + +class ExtensionAttribute(Expr): + """Returns the attribute of an extension bound to the environment. + The identifier is the identifier of the :class:`Extension`. + + This node is usually constructed by calling the + :meth:`~jinja2.ext.Extension.attr` method on an extension. + """ + + fields = ("identifier", "name") + identifier: str + name: str + + +class ImportedName(Expr): + """If created with an import name the import name is returned on node + access. For example ``ImportedName('cgi.escape')`` returns the `escape` + function from the cgi module on evaluation. Imports are optimized by the + compiler so there is no need to assign them to local variables. + """ + + fields = ("importname",) + importname: str + + +class InternalName(Expr): + """An internal name in the compiler. You cannot create these nodes + yourself but the parser provides a + :meth:`~jinja2.parser.Parser.free_identifier` method that creates + a new identifier for you. This identifier is not available from the + template and is not treated specially by the compiler. + """ + + fields = ("name",) + name: str + + def __init__(self) -> None: + raise TypeError( + "Can't create internal names. Use the " + "`free_identifier` method on a parser." + ) + + +class MarkSafe(Expr): + """Mark the wrapped expression as safe (wrap it as `Markup`).""" + + fields = ("expr",) + expr: Expr + + def as_const(self, eval_ctx: t.Optional[EvalContext] = None) -> Markup: + eval_ctx = get_eval_context(self, eval_ctx) + return Markup(self.expr.as_const(eval_ctx)) + + +class MarkSafeIfAutoescape(Expr): + """Mark the wrapped expression as safe (wrap it as `Markup`) but + only if autoescaping is active. + + .. versionadded:: 2.5 + """ + + fields = ("expr",) + expr: Expr + + def as_const( + self, eval_ctx: t.Optional[EvalContext] = None + ) -> t.Union[Markup, t.Any]: + eval_ctx = get_eval_context(self, eval_ctx) + if eval_ctx.volatile: + raise Impossible() + expr = self.expr.as_const(eval_ctx) + if eval_ctx.autoescape: + return Markup(expr) + return expr + + +class ContextReference(Expr): + """Returns the current template context. It can be used like a + :class:`Name` node, with a ``'load'`` ctx and will return the + current :class:`~jinja2.runtime.Context` object. + + Here an example that assigns the current template name to a + variable named `foo`:: + + Assign(Name('foo', ctx='store'), + Getattr(ContextReference(), 'name')) + + This is basically equivalent to using the + :func:`~jinja2.pass_context` decorator when using the high-level + API, which causes a reference to the context to be passed as the + first argument to a function. + """ + + +class DerivedContextReference(Expr): + """Return the current template context including locals. Behaves + exactly like :class:`ContextReference`, but includes local + variables, such as from a ``for`` loop. + + .. versionadded:: 2.11 + """ + + +class Continue(Stmt): + """Continue a loop.""" + + +class Break(Stmt): + """Break a loop.""" + + +class Scope(Stmt): + """An artificial scope.""" + + fields = ("body",) + body: t.List[Node] + + +class OverlayScope(Stmt): + """An overlay scope for extensions. This is a largely unoptimized scope + that however can be used to introduce completely arbitrary variables into + a sub scope from a dictionary or dictionary like object. The `context` + field has to evaluate to a dictionary object. + + Example usage:: + + OverlayScope(context=self.call_method('get_context'), + body=[...]) + + .. versionadded:: 2.10 + """ + + fields = ("context", "body") + context: Expr + body: t.List[Node] + + +class EvalContextModifier(Stmt): + """Modifies the eval context. For each option that should be modified, + a :class:`Keyword` has to be added to the :attr:`options` list. + + Example to change the `autoescape` setting:: + + EvalContextModifier(options=[Keyword('autoescape', Const(True))]) + """ + + fields = ("options",) + options: t.List[Keyword] + + +class ScopedEvalContextModifier(EvalContextModifier): + """Modifies the eval context and reverts it later. Works exactly like + :class:`EvalContextModifier` but will only modify the + :class:`~jinja2.nodes.EvalContext` for nodes in the :attr:`body`. + """ + + fields = ("body",) + body: t.List[Node] + + +# make sure nobody creates custom nodes +def _failing_new(*args: t.Any, **kwargs: t.Any) -> "te.NoReturn": + raise TypeError("can't create custom node types") + + +NodeType.__new__ = staticmethod(_failing_new) # type: ignore +del _failing_new diff --git a/src/jinja2/optimizer.py b/src/jinja2/optimizer.py new file mode 100644 index 0000000..fe10107 --- /dev/null +++ b/src/jinja2/optimizer.py @@ -0,0 +1,47 @@ +"""The optimizer tries to constant fold expressions and modify the AST +in place so that it should be faster to evaluate. + +Because the AST does not contain all the scoping information and the +compiler has to find that out, we cannot do all the optimizations we +want. For example, loop unrolling doesn't work because unrolled loops +would have a different scope. The solution would be a second syntax tree +that stored the scoping rules. +""" +import typing as t + +from . import nodes +from .visitor import NodeTransformer + +if t.TYPE_CHECKING: + from .environment import Environment + + +def optimize(node: nodes.Node, environment: "Environment") -> nodes.Node: + """The context hint can be used to perform an static optimization + based on the context given.""" + optimizer = Optimizer(environment) + return t.cast(nodes.Node, optimizer.visit(node)) + + +class Optimizer(NodeTransformer): + def __init__(self, environment: "t.Optional[Environment]") -> None: + self.environment = environment + + def generic_visit( + self, node: nodes.Node, *args: t.Any, **kwargs: t.Any + ) -> nodes.Node: + node = super().generic_visit(node, *args, **kwargs) + + # Do constant folding. Some other nodes besides Expr have + # as_const, but folding them causes errors later on. + if isinstance(node, nodes.Expr): + try: + return nodes.Const.from_untrusted( + node.as_const(args[0] if args else None), + lineno=node.lineno, + environment=self.environment, + ) + except nodes.Impossible: + pass + + return node diff --git a/src/jinja2/parser.py b/src/jinja2/parser.py new file mode 100644 index 0000000..cefce2d --- /dev/null +++ b/src/jinja2/parser.py @@ -0,0 +1,1032 @@ +"""Parse tokens from the lexer into nodes for the compiler.""" +import typing +import typing as t + +from . import nodes +from .exceptions import TemplateAssertionError +from .exceptions import TemplateSyntaxError +from .lexer import describe_token +from .lexer import describe_token_expr + +if t.TYPE_CHECKING: + import typing_extensions as te + from .environment import Environment + +_ImportInclude = t.TypeVar("_ImportInclude", nodes.Import, nodes.Include) +_MacroCall = t.TypeVar("_MacroCall", nodes.Macro, nodes.CallBlock) + +_statement_keywords = frozenset( + [ + "for", + "if", + "block", + "extends", + "print", + "macro", + "include", + "from", + "import", + "set", + "with", + "autoescape", + ] +) +_compare_operators = frozenset(["eq", "ne", "lt", "lteq", "gt", "gteq"]) + +_math_nodes: t.Dict[str, t.Type[nodes.Expr]] = { + "add": nodes.Add, + "sub": nodes.Sub, + "mul": nodes.Mul, + "div": nodes.Div, + "floordiv": nodes.FloorDiv, + "mod": nodes.Mod, +} + + +class Parser: + """This is the central parsing class Jinja uses. It's passed to + extensions and can be used to parse expressions or statements. + """ + + def __init__( + self, + environment: "Environment", + source: str, + name: t.Optional[str] = None, + filename: t.Optional[str] = None, + state: t.Optional[str] = None, + ) -> None: + self.environment = environment + self.stream = environment._tokenize(source, name, filename, state) + self.name = name + self.filename = filename + self.closed = False + self.extensions: t.Dict[ + str, t.Callable[["Parser"], t.Union[nodes.Node, t.List[nodes.Node]]] + ] = {} + for extension in environment.iter_extensions(): + for tag in extension.tags: + self.extensions[tag] = extension.parse + self._last_identifier = 0 + self._tag_stack: t.List[str] = [] + self._end_token_stack: t.List[t.Tuple[str, ...]] = [] + + def fail( + self, + msg: str, + lineno: t.Optional[int] = None, + exc: t.Type[TemplateSyntaxError] = TemplateSyntaxError, + ) -> "te.NoReturn": + """Convenience method that raises `exc` with the message, passed + line number or last line number as well as the current name and + filename. + """ + if lineno is None: + lineno = self.stream.current.lineno + raise exc(msg, lineno, self.name, self.filename) + + def _fail_ut_eof( + self, + name: t.Optional[str], + end_token_stack: t.List[t.Tuple[str, ...]], + lineno: t.Optional[int], + ) -> "te.NoReturn": + expected: t.Set[str] = set() + for exprs in end_token_stack: + expected.update(map(describe_token_expr, exprs)) + if end_token_stack: + currently_looking: t.Optional[str] = " or ".join( + map(repr, map(describe_token_expr, end_token_stack[-1])) + ) + else: + currently_looking = None + + if name is None: + message = ["Unexpected end of template."] + else: + message = [f"Encountered unknown tag {name!r}."] + + if currently_looking: + if name is not None and name in expected: + message.append( + "You probably made a nesting mistake. Jinja is expecting this tag," + f" but currently looking for {currently_looking}." + ) + else: + message.append( + f"Jinja was looking for the following tags: {currently_looking}." + ) + + if self._tag_stack: + message.append( + "The innermost block that needs to be closed is" + f" {self._tag_stack[-1]!r}." + ) + + self.fail(" ".join(message), lineno) + + def fail_unknown_tag( + self, name: str, lineno: t.Optional[int] = None + ) -> "te.NoReturn": + """Called if the parser encounters an unknown tag. Tries to fail + with a human readable error message that could help to identify + the problem. + """ + self._fail_ut_eof(name, self._end_token_stack, lineno) + + def fail_eof( + self, + end_tokens: t.Optional[t.Tuple[str, ...]] = None, + lineno: t.Optional[int] = None, + ) -> "te.NoReturn": + """Like fail_unknown_tag but for end of template situations.""" + stack = list(self._end_token_stack) + if end_tokens is not None: + stack.append(end_tokens) + self._fail_ut_eof(None, stack, lineno) + + def is_tuple_end( + self, extra_end_rules: t.Optional[t.Tuple[str, ...]] = None + ) -> bool: + """Are we at the end of a tuple?""" + if self.stream.current.type in ("variable_end", "block_end", "rparen"): + return True + elif extra_end_rules is not None: + return self.stream.current.test_any(extra_end_rules) # type: ignore + return False + + def free_identifier(self, lineno: t.Optional[int] = None) -> nodes.InternalName: + """Return a new free identifier as :class:`~jinja2.nodes.InternalName`.""" + self._last_identifier += 1 + rv = object.__new__(nodes.InternalName) + nodes.Node.__init__(rv, f"fi{self._last_identifier}", lineno=lineno) + return rv + + def parse_statement(self) -> t.Union[nodes.Node, t.List[nodes.Node]]: + """Parse a single statement.""" + token = self.stream.current + if token.type != "name": + self.fail("tag name expected", token.lineno) + self._tag_stack.append(token.value) + pop_tag = True + try: + if token.value in _statement_keywords: + f = getattr(self, f"parse_{self.stream.current.value}") + return f() # type: ignore + if token.value == "call": + return self.parse_call_block() + if token.value == "filter": + return self.parse_filter_block() + ext = self.extensions.get(token.value) + if ext is not None: + return ext(self) + + # did not work out, remove the token we pushed by accident + # from the stack so that the unknown tag fail function can + # produce a proper error message. + self._tag_stack.pop() + pop_tag = False + self.fail_unknown_tag(token.value, token.lineno) + finally: + if pop_tag: + self._tag_stack.pop() + + def parse_statements( + self, end_tokens: t.Tuple[str, ...], drop_needle: bool = False + ) -> t.List[nodes.Node]: + """Parse multiple statements into a list until one of the end tokens + is reached. This is used to parse the body of statements as it also + parses template data if appropriate. The parser checks first if the + current token is a colon and skips it if there is one. Then it checks + for the block end and parses until if one of the `end_tokens` is + reached. Per default the active token in the stream at the end of + the call is the matched end token. If this is not wanted `drop_needle` + can be set to `True` and the end token is removed. + """ + # the first token may be a colon for python compatibility + self.stream.skip_if("colon") + + # in the future it would be possible to add whole code sections + # by adding some sort of end of statement token and parsing those here. + self.stream.expect("block_end") + result = self.subparse(end_tokens) + + # we reached the end of the template too early, the subparser + # does not check for this, so we do that now + if self.stream.current.type == "eof": + self.fail_eof(end_tokens) + + if drop_needle: + next(self.stream) + return result + + def parse_set(self) -> t.Union[nodes.Assign, nodes.AssignBlock]: + """Parse an assign statement.""" + lineno = next(self.stream).lineno + target = self.parse_assign_target(with_namespace=True) + if self.stream.skip_if("assign"): + expr = self.parse_tuple() + return nodes.Assign(target, expr, lineno=lineno) + filter_node = self.parse_filter(None) + body = self.parse_statements(("name:endset",), drop_needle=True) + return nodes.AssignBlock(target, filter_node, body, lineno=lineno) + + def parse_for(self) -> nodes.For: + """Parse a for loop.""" + lineno = self.stream.expect("name:for").lineno + target = self.parse_assign_target(extra_end_rules=("name:in",)) + self.stream.expect("name:in") + iter = self.parse_tuple( + with_condexpr=False, extra_end_rules=("name:recursive",) + ) + test = None + if self.stream.skip_if("name:if"): + test = self.parse_expression() + recursive = self.stream.skip_if("name:recursive") + body = self.parse_statements(("name:endfor", "name:else")) + if next(self.stream).value == "endfor": + else_ = [] + else: + else_ = self.parse_statements(("name:endfor",), drop_needle=True) + return nodes.For(target, iter, body, else_, test, recursive, lineno=lineno) + + def parse_if(self) -> nodes.If: + """Parse an if construct.""" + node = result = nodes.If(lineno=self.stream.expect("name:if").lineno) + while True: + node.test = self.parse_tuple(with_condexpr=False) + node.body = self.parse_statements(("name:elif", "name:else", "name:endif")) + node.elif_ = [] + node.else_ = [] + token = next(self.stream) + if token.test("name:elif"): + node = nodes.If(lineno=self.stream.current.lineno) + result.elif_.append(node) + continue + elif token.test("name:else"): + result.else_ = self.parse_statements(("name:endif",), drop_needle=True) + break + return result + + def parse_with(self) -> nodes.With: + node = nodes.With(lineno=next(self.stream).lineno) + targets: t.List[nodes.Expr] = [] + values: t.List[nodes.Expr] = [] + while self.stream.current.type != "block_end": + if targets: + self.stream.expect("comma") + target = self.parse_assign_target() + target.set_ctx("param") + targets.append(target) + self.stream.expect("assign") + values.append(self.parse_expression()) + node.targets = targets + node.values = values + node.body = self.parse_statements(("name:endwith",), drop_needle=True) + return node + + def parse_autoescape(self) -> nodes.Scope: + node = nodes.ScopedEvalContextModifier(lineno=next(self.stream).lineno) + node.options = [nodes.Keyword("autoescape", self.parse_expression())] + node.body = self.parse_statements(("name:endautoescape",), drop_needle=True) + return nodes.Scope([node]) + + def parse_block(self) -> nodes.Block: + node = nodes.Block(lineno=next(self.stream).lineno) + node.name = self.stream.expect("name").value + node.scoped = self.stream.skip_if("name:scoped") + node.required = self.stream.skip_if("name:required") + + # common problem people encounter when switching from django + # to jinja. we do not support hyphens in block names, so let's + # raise a nicer error message in that case. + if self.stream.current.type == "sub": + self.fail( + "Block names in Jinja have to be valid Python identifiers and may not" + " contain hyphens, use an underscore instead." + ) + + node.body = self.parse_statements(("name:endblock",), drop_needle=True) + + # enforce that required blocks only contain whitespace or comments + # by asserting that the body, if not empty, is just TemplateData nodes + # with whitespace data + if node.required and not all( + isinstance(child, nodes.TemplateData) and child.data.isspace() + for body in node.body + for child in body.nodes # type: ignore + ): + self.fail("Required blocks can only contain comments or whitespace") + + self.stream.skip_if("name:" + node.name) + return node + + def parse_extends(self) -> nodes.Extends: + node = nodes.Extends(lineno=next(self.stream).lineno) + node.template = self.parse_expression() + return node + + def parse_import_context( + self, node: _ImportInclude, default: bool + ) -> _ImportInclude: + if self.stream.current.test_any( + "name:with", "name:without" + ) and self.stream.look().test("name:context"): + node.with_context = next(self.stream).value == "with" + self.stream.skip() + else: + node.with_context = default + return node + + def parse_include(self) -> nodes.Include: + node = nodes.Include(lineno=next(self.stream).lineno) + node.template = self.parse_expression() + if self.stream.current.test("name:ignore") and self.stream.look().test( + "name:missing" + ): + node.ignore_missing = True + self.stream.skip(2) + else: + node.ignore_missing = False + return self.parse_import_context(node, True) + + def parse_import(self) -> nodes.Import: + node = nodes.Import(lineno=next(self.stream).lineno) + node.template = self.parse_expression() + self.stream.expect("name:as") + node.target = self.parse_assign_target(name_only=True).name + return self.parse_import_context(node, False) + + def parse_from(self) -> nodes.FromImport: + node = nodes.FromImport(lineno=next(self.stream).lineno) + node.template = self.parse_expression() + self.stream.expect("name:import") + node.names = [] + + def parse_context() -> bool: + if self.stream.current.value in { + "with", + "without", + } and self.stream.look().test("name:context"): + node.with_context = next(self.stream).value == "with" + self.stream.skip() + return True + return False + + while True: + if node.names: + self.stream.expect("comma") + if self.stream.current.type == "name": + if parse_context(): + break + target = self.parse_assign_target(name_only=True) + if target.name.startswith("_"): + self.fail( + "names starting with an underline can not be imported", + target.lineno, + exc=TemplateAssertionError, + ) + if self.stream.skip_if("name:as"): + alias = self.parse_assign_target(name_only=True) + node.names.append((target.name, alias.name)) + else: + node.names.append(target.name) + if parse_context() or self.stream.current.type != "comma": + break + else: + self.stream.expect("name") + if not hasattr(node, "with_context"): + node.with_context = False + return node + + def parse_signature(self, node: _MacroCall) -> None: + args = node.args = [] + defaults = node.defaults = [] + self.stream.expect("lparen") + while self.stream.current.type != "rparen": + if args: + self.stream.expect("comma") + arg = self.parse_assign_target(name_only=True) + arg.set_ctx("param") + if self.stream.skip_if("assign"): + defaults.append(self.parse_expression()) + elif defaults: + self.fail("non-default argument follows default argument") + args.append(arg) + self.stream.expect("rparen") + + def parse_call_block(self) -> nodes.CallBlock: + node = nodes.CallBlock(lineno=next(self.stream).lineno) + if self.stream.current.type == "lparen": + self.parse_signature(node) + else: + node.args = [] + node.defaults = [] + + call_node = self.parse_expression() + if not isinstance(call_node, nodes.Call): + self.fail("expected call", node.lineno) + node.call = call_node + node.body = self.parse_statements(("name:endcall",), drop_needle=True) + return node + + def parse_filter_block(self) -> nodes.FilterBlock: + node = nodes.FilterBlock(lineno=next(self.stream).lineno) + node.filter = self.parse_filter(None, start_inline=True) # type: ignore + node.body = self.parse_statements(("name:endfilter",), drop_needle=True) + return node + + def parse_macro(self) -> nodes.Macro: + node = nodes.Macro(lineno=next(self.stream).lineno) + node.name = self.parse_assign_target(name_only=True).name + self.parse_signature(node) + node.body = self.parse_statements(("name:endmacro",), drop_needle=True) + return node + + def parse_print(self) -> nodes.Output: + node = nodes.Output(lineno=next(self.stream).lineno) + node.nodes = [] + while self.stream.current.type != "block_end": + if node.nodes: + self.stream.expect("comma") + node.nodes.append(self.parse_expression()) + return node + + @typing.overload + def parse_assign_target( + self, with_tuple: bool = ..., name_only: "te.Literal[True]" = ... + ) -> nodes.Name: + ... + + @typing.overload + def parse_assign_target( + self, + with_tuple: bool = True, + name_only: bool = False, + extra_end_rules: t.Optional[t.Tuple[str, ...]] = None, + with_namespace: bool = False, + ) -> t.Union[nodes.NSRef, nodes.Name, nodes.Tuple]: + ... + + def parse_assign_target( + self, + with_tuple: bool = True, + name_only: bool = False, + extra_end_rules: t.Optional[t.Tuple[str, ...]] = None, + with_namespace: bool = False, + ) -> t.Union[nodes.NSRef, nodes.Name, nodes.Tuple]: + """Parse an assignment target. As Jinja allows assignments to + tuples, this function can parse all allowed assignment targets. Per + default assignments to tuples are parsed, that can be disable however + by setting `with_tuple` to `False`. If only assignments to names are + wanted `name_only` can be set to `True`. The `extra_end_rules` + parameter is forwarded to the tuple parsing function. If + `with_namespace` is enabled, a namespace assignment may be parsed. + """ + target: nodes.Expr + + if with_namespace and self.stream.look().type == "dot": + token = self.stream.expect("name") + next(self.stream) # dot + attr = self.stream.expect("name") + target = nodes.NSRef(token.value, attr.value, lineno=token.lineno) + elif name_only: + token = self.stream.expect("name") + target = nodes.Name(token.value, "store", lineno=token.lineno) + else: + if with_tuple: + target = self.parse_tuple( + simplified=True, extra_end_rules=extra_end_rules + ) + else: + target = self.parse_primary() + + target.set_ctx("store") + + if not target.can_assign(): + self.fail( + f"can't assign to {type(target).__name__.lower()!r}", target.lineno + ) + + return target # type: ignore + + def parse_expression(self, with_condexpr: bool = True) -> nodes.Expr: + """Parse an expression. Per default all expressions are parsed, if + the optional `with_condexpr` parameter is set to `False` conditional + expressions are not parsed. + """ + if with_condexpr: + return self.parse_condexpr() + return self.parse_or() + + def parse_condexpr(self) -> nodes.Expr: + lineno = self.stream.current.lineno + expr1 = self.parse_or() + expr3: t.Optional[nodes.Expr] + + while self.stream.skip_if("name:if"): + expr2 = self.parse_or() + if self.stream.skip_if("name:else"): + expr3 = self.parse_condexpr() + else: + expr3 = None + expr1 = nodes.CondExpr(expr2, expr1, expr3, lineno=lineno) + lineno = self.stream.current.lineno + return expr1 + + def parse_or(self) -> nodes.Expr: + lineno = self.stream.current.lineno + left = self.parse_and() + while self.stream.skip_if("name:or"): + right = self.parse_and() + left = nodes.Or(left, right, lineno=lineno) + lineno = self.stream.current.lineno + return left + + def parse_and(self) -> nodes.Expr: + lineno = self.stream.current.lineno + left = self.parse_not() + while self.stream.skip_if("name:and"): + right = self.parse_not() + left = nodes.And(left, right, lineno=lineno) + lineno = self.stream.current.lineno + return left + + def parse_not(self) -> nodes.Expr: + if self.stream.current.test("name:not"): + lineno = next(self.stream).lineno + return nodes.Not(self.parse_not(), lineno=lineno) + return self.parse_compare() + + def parse_compare(self) -> nodes.Expr: + lineno = self.stream.current.lineno + expr = self.parse_math1() + ops = [] + while True: + token_type = self.stream.current.type + if token_type in _compare_operators: + next(self.stream) + ops.append(nodes.Operand(token_type, self.parse_math1())) + elif self.stream.skip_if("name:in"): + ops.append(nodes.Operand("in", self.parse_math1())) + elif self.stream.current.test("name:not") and self.stream.look().test( + "name:in" + ): + self.stream.skip(2) + ops.append(nodes.Operand("notin", self.parse_math1())) + else: + break + lineno = self.stream.current.lineno + if not ops: + return expr + return nodes.Compare(expr, ops, lineno=lineno) + + def parse_math1(self) -> nodes.Expr: + lineno = self.stream.current.lineno + left = self.parse_concat() + while self.stream.current.type in ("add", "sub"): + cls = _math_nodes[self.stream.current.type] + next(self.stream) + right = self.parse_concat() + left = cls(left, right, lineno=lineno) + lineno = self.stream.current.lineno + return left + + def parse_concat(self) -> nodes.Expr: + lineno = self.stream.current.lineno + args = [self.parse_math2()] + while self.stream.current.type == "tilde": + next(self.stream) + args.append(self.parse_math2()) + if len(args) == 1: + return args[0] + return nodes.Concat(args, lineno=lineno) + + def parse_math2(self) -> nodes.Expr: + lineno = self.stream.current.lineno + left = self.parse_pow() + while self.stream.current.type in ("mul", "div", "floordiv", "mod"): + cls = _math_nodes[self.stream.current.type] + next(self.stream) + right = self.parse_pow() + left = cls(left, right, lineno=lineno) + lineno = self.stream.current.lineno + return left + + def parse_pow(self) -> nodes.Expr: + lineno = self.stream.current.lineno + left = self.parse_unary() + while self.stream.current.type == "pow": + next(self.stream) + right = self.parse_unary() + left = nodes.Pow(left, right, lineno=lineno) + lineno = self.stream.current.lineno + return left + + def parse_unary(self, with_filter: bool = True) -> nodes.Expr: + token_type = self.stream.current.type + lineno = self.stream.current.lineno + node: nodes.Expr + + if token_type == "sub": + next(self.stream) + node = nodes.Neg(self.parse_unary(False), lineno=lineno) + elif token_type == "add": + next(self.stream) + node = nodes.Pos(self.parse_unary(False), lineno=lineno) + else: + node = self.parse_primary() + node = self.parse_postfix(node) + if with_filter: + node = self.parse_filter_expr(node) + return node + + def parse_primary(self) -> nodes.Expr: + token = self.stream.current + node: nodes.Expr + if token.type == "name": + if token.value in ("true", "false", "True", "False"): + node = nodes.Const(token.value in ("true", "True"), lineno=token.lineno) + elif token.value in ("none", "None"): + node = nodes.Const(None, lineno=token.lineno) + else: + node = nodes.Name(token.value, "load", lineno=token.lineno) + next(self.stream) + elif token.type == "string": + next(self.stream) + buf = [token.value] + lineno = token.lineno + while self.stream.current.type == "string": + buf.append(self.stream.current.value) + next(self.stream) + node = nodes.Const("".join(buf), lineno=lineno) + elif token.type in ("integer", "float"): + next(self.stream) + node = nodes.Const(token.value, lineno=token.lineno) + elif token.type == "lparen": + next(self.stream) + node = self.parse_tuple(explicit_parentheses=True) + self.stream.expect("rparen") + elif token.type == "lbracket": + node = self.parse_list() + elif token.type == "lbrace": + node = self.parse_dict() + else: + self.fail(f"unexpected {describe_token(token)!r}", token.lineno) + return node + + def parse_tuple( + self, + simplified: bool = False, + with_condexpr: bool = True, + extra_end_rules: t.Optional[t.Tuple[str, ...]] = None, + explicit_parentheses: bool = False, + ) -> t.Union[nodes.Tuple, nodes.Expr]: + """Works like `parse_expression` but if multiple expressions are + delimited by a comma a :class:`~jinja2.nodes.Tuple` node is created. + This method could also return a regular expression instead of a tuple + if no commas where found. + + The default parsing mode is a full tuple. If `simplified` is `True` + only names and literals are parsed. The `no_condexpr` parameter is + forwarded to :meth:`parse_expression`. + + Because tuples do not require delimiters and may end in a bogus comma + an extra hint is needed that marks the end of a tuple. For example + for loops support tuples between `for` and `in`. In that case the + `extra_end_rules` is set to ``['name:in']``. + + `explicit_parentheses` is true if the parsing was triggered by an + expression in parentheses. This is used to figure out if an empty + tuple is a valid expression or not. + """ + lineno = self.stream.current.lineno + if simplified: + parse = self.parse_primary + elif with_condexpr: + parse = self.parse_expression + else: + + def parse() -> nodes.Expr: + return self.parse_expression(with_condexpr=False) + + args: t.List[nodes.Expr] = [] + is_tuple = False + + while True: + if args: + self.stream.expect("comma") + if self.is_tuple_end(extra_end_rules): + break + args.append(parse()) + if self.stream.current.type == "comma": + is_tuple = True + else: + break + lineno = self.stream.current.lineno + + if not is_tuple: + if args: + return args[0] + + # if we don't have explicit parentheses, an empty tuple is + # not a valid expression. This would mean nothing (literally + # nothing) in the spot of an expression would be an empty + # tuple. + if not explicit_parentheses: + self.fail( + "Expected an expression," + f" got {describe_token(self.stream.current)!r}" + ) + + return nodes.Tuple(args, "load", lineno=lineno) + + def parse_list(self) -> nodes.List: + token = self.stream.expect("lbracket") + items: t.List[nodes.Expr] = [] + while self.stream.current.type != "rbracket": + if items: + self.stream.expect("comma") + if self.stream.current.type == "rbracket": + break + items.append(self.parse_expression()) + self.stream.expect("rbracket") + return nodes.List(items, lineno=token.lineno) + + def parse_dict(self) -> nodes.Dict: + token = self.stream.expect("lbrace") + items: t.List[nodes.Pair] = [] + while self.stream.current.type != "rbrace": + if items: + self.stream.expect("comma") + if self.stream.current.type == "rbrace": + break + key = self.parse_expression() + self.stream.expect("colon") + value = self.parse_expression() + items.append(nodes.Pair(key, value, lineno=key.lineno)) + self.stream.expect("rbrace") + return nodes.Dict(items, lineno=token.lineno) + + def parse_postfix(self, node: nodes.Expr) -> nodes.Expr: + while True: + token_type = self.stream.current.type + if token_type == "dot" or token_type == "lbracket": + node = self.parse_subscript(node) + # calls are valid both after postfix expressions (getattr + # and getitem) as well as filters and tests + elif token_type == "lparen": + node = self.parse_call(node) + else: + break + return node + + def parse_filter_expr(self, node: nodes.Expr) -> nodes.Expr: + while True: + token_type = self.stream.current.type + if token_type == "pipe": + node = self.parse_filter(node) # type: ignore + elif token_type == "name" and self.stream.current.value == "is": + node = self.parse_test(node) + # calls are valid both after postfix expressions (getattr + # and getitem) as well as filters and tests + elif token_type == "lparen": + node = self.parse_call(node) + else: + break + return node + + def parse_subscript( + self, node: nodes.Expr + ) -> t.Union[nodes.Getattr, nodes.Getitem]: + token = next(self.stream) + arg: nodes.Expr + + if token.type == "dot": + attr_token = self.stream.current + next(self.stream) + if attr_token.type == "name": + return nodes.Getattr( + node, attr_token.value, "load", lineno=token.lineno + ) + elif attr_token.type != "integer": + self.fail("expected name or number", attr_token.lineno) + arg = nodes.Const(attr_token.value, lineno=attr_token.lineno) + return nodes.Getitem(node, arg, "load", lineno=token.lineno) + if token.type == "lbracket": + args: t.List[nodes.Expr] = [] + while self.stream.current.type != "rbracket": + if args: + self.stream.expect("comma") + args.append(self.parse_subscribed()) + self.stream.expect("rbracket") + if len(args) == 1: + arg = args[0] + else: + arg = nodes.Tuple(args, "load", lineno=token.lineno) + return nodes.Getitem(node, arg, "load", lineno=token.lineno) + self.fail("expected subscript expression", token.lineno) + + def parse_subscribed(self) -> nodes.Expr: + lineno = self.stream.current.lineno + args: t.List[t.Optional[nodes.Expr]] + + if self.stream.current.type == "colon": + next(self.stream) + args = [None] + else: + node = self.parse_expression() + if self.stream.current.type != "colon": + return node + next(self.stream) + args = [node] + + if self.stream.current.type == "colon": + args.append(None) + elif self.stream.current.type not in ("rbracket", "comma"): + args.append(self.parse_expression()) + else: + args.append(None) + + if self.stream.current.type == "colon": + next(self.stream) + if self.stream.current.type not in ("rbracket", "comma"): + args.append(self.parse_expression()) + else: + args.append(None) + else: + args.append(None) + + return nodes.Slice(lineno=lineno, *args) + + def parse_call_args(self) -> t.Tuple: + token = self.stream.expect("lparen") + args = [] + kwargs = [] + dyn_args = None + dyn_kwargs = None + require_comma = False + + def ensure(expr: bool) -> None: + if not expr: + self.fail("invalid syntax for function call expression", token.lineno) + + while self.stream.current.type != "rparen": + if require_comma: + self.stream.expect("comma") + + # support for trailing comma + if self.stream.current.type == "rparen": + break + + if self.stream.current.type == "mul": + ensure(dyn_args is None and dyn_kwargs is None) + next(self.stream) + dyn_args = self.parse_expression() + elif self.stream.current.type == "pow": + ensure(dyn_kwargs is None) + next(self.stream) + dyn_kwargs = self.parse_expression() + else: + if ( + self.stream.current.type == "name" + and self.stream.look().type == "assign" + ): + # Parsing a kwarg + ensure(dyn_kwargs is None) + key = self.stream.current.value + self.stream.skip(2) + value = self.parse_expression() + kwargs.append(nodes.Keyword(key, value, lineno=value.lineno)) + else: + # Parsing an arg + ensure(dyn_args is None and dyn_kwargs is None and not kwargs) + args.append(self.parse_expression()) + + require_comma = True + + self.stream.expect("rparen") + return args, kwargs, dyn_args, dyn_kwargs + + def parse_call(self, node: nodes.Expr) -> nodes.Call: + # The lparen will be expected in parse_call_args, but the lineno + # needs to be recorded before the stream is advanced. + token = self.stream.current + args, kwargs, dyn_args, dyn_kwargs = self.parse_call_args() + return nodes.Call(node, args, kwargs, dyn_args, dyn_kwargs, lineno=token.lineno) + + def parse_filter( + self, node: t.Optional[nodes.Expr], start_inline: bool = False + ) -> t.Optional[nodes.Expr]: + while self.stream.current.type == "pipe" or start_inline: + if not start_inline: + next(self.stream) + token = self.stream.expect("name") + name = token.value + while self.stream.current.type == "dot": + next(self.stream) + name += "." + self.stream.expect("name").value + if self.stream.current.type == "lparen": + args, kwargs, dyn_args, dyn_kwargs = self.parse_call_args() + else: + args = [] + kwargs = [] + dyn_args = dyn_kwargs = None + node = nodes.Filter( + node, name, args, kwargs, dyn_args, dyn_kwargs, lineno=token.lineno + ) + start_inline = False + return node + + def parse_test(self, node: nodes.Expr) -> nodes.Expr: + token = next(self.stream) + if self.stream.current.test("name:not"): + next(self.stream) + negated = True + else: + negated = False + name = self.stream.expect("name").value + while self.stream.current.type == "dot": + next(self.stream) + name += "." + self.stream.expect("name").value + dyn_args = dyn_kwargs = None + kwargs = [] + if self.stream.current.type == "lparen": + args, kwargs, dyn_args, dyn_kwargs = self.parse_call_args() + elif self.stream.current.type in { + "name", + "string", + "integer", + "float", + "lparen", + "lbracket", + "lbrace", + } and not self.stream.current.test_any("name:else", "name:or", "name:and"): + if self.stream.current.test("name:is"): + self.fail("You cannot chain multiple tests with is") + arg_node = self.parse_primary() + arg_node = self.parse_postfix(arg_node) + args = [arg_node] + else: + args = [] + node = nodes.Test( + node, name, args, kwargs, dyn_args, dyn_kwargs, lineno=token.lineno + ) + if negated: + node = nodes.Not(node, lineno=token.lineno) + return node + + def subparse( + self, end_tokens: t.Optional[t.Tuple[str, ...]] = None + ) -> t.List[nodes.Node]: + body: t.List[nodes.Node] = [] + data_buffer: t.List[nodes.Node] = [] + add_data = data_buffer.append + + if end_tokens is not None: + self._end_token_stack.append(end_tokens) + + def flush_data() -> None: + if data_buffer: + lineno = data_buffer[0].lineno + body.append(nodes.Output(data_buffer[:], lineno=lineno)) + del data_buffer[:] + + try: + while self.stream: + token = self.stream.current + if token.type == "data": + if token.value: + add_data(nodes.TemplateData(token.value, lineno=token.lineno)) + next(self.stream) + elif token.type == "variable_begin": + next(self.stream) + add_data(self.parse_tuple(with_condexpr=True)) + self.stream.expect("variable_end") + elif token.type == "block_begin": + flush_data() + next(self.stream) + if end_tokens is not None and self.stream.current.test_any( + *end_tokens + ): + return body + rv = self.parse_statement() + if isinstance(rv, list): + body.extend(rv) + else: + body.append(rv) + self.stream.expect("block_end") + else: + raise AssertionError("internal parsing error") + + flush_data() + finally: + if end_tokens is not None: + self._end_token_stack.pop() + return body + + def parse(self) -> nodes.Template: + """Parse the whole template into a `Template` node.""" + result = nodes.Template(self.subparse(), lineno=1) + result.set_environment(self.environment) + return result diff --git a/src/jinja2/py.typed b/src/jinja2/py.typed new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/jinja2/py.typed diff --git a/src/jinja2/runtime.py b/src/jinja2/runtime.py new file mode 100644 index 0000000..985842b --- /dev/null +++ b/src/jinja2/runtime.py @@ -0,0 +1,1053 @@ +"""The runtime functions and state used by compiled templates.""" +import functools +import sys +import typing as t +from collections import abc +from itertools import chain + +from markupsafe import escape # noqa: F401 +from markupsafe import Markup +from markupsafe import soft_str + +from .async_utils import auto_aiter +from .async_utils import auto_await # noqa: F401 +from .exceptions import TemplateNotFound # noqa: F401 +from .exceptions import TemplateRuntimeError # noqa: F401 +from .exceptions import UndefinedError +from .nodes import EvalContext +from .utils import _PassArg +from .utils import concat +from .utils import internalcode +from .utils import missing +from .utils import Namespace # noqa: F401 +from .utils import object_type_repr +from .utils import pass_eval_context + +V = t.TypeVar("V") +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + +if t.TYPE_CHECKING: + import logging + import typing_extensions as te + from .environment import Environment + + class LoopRenderFunc(te.Protocol): + def __call__( + self, + reciter: t.Iterable[V], + loop_render_func: "LoopRenderFunc", + depth: int = 0, + ) -> str: + ... + + +# these variables are exported to the template runtime +exported = [ + "LoopContext", + "TemplateReference", + "Macro", + "Markup", + "TemplateRuntimeError", + "missing", + "escape", + "markup_join", + "str_join", + "identity", + "TemplateNotFound", + "Namespace", + "Undefined", + "internalcode", +] +async_exported = [ + "AsyncLoopContext", + "auto_aiter", + "auto_await", +] + + +def identity(x: V) -> V: + """Returns its argument. Useful for certain things in the + environment. + """ + return x + + +def markup_join(seq: t.Iterable[t.Any]) -> str: + """Concatenation that escapes if necessary and converts to string.""" + buf = [] + iterator = map(soft_str, seq) + for arg in iterator: + buf.append(arg) + if hasattr(arg, "__html__"): + return Markup("").join(chain(buf, iterator)) + return concat(buf) + + +def str_join(seq: t.Iterable[t.Any]) -> str: + """Simple args to string conversion and concatenation.""" + return concat(map(str, seq)) + + +def new_context( + environment: "Environment", + template_name: t.Optional[str], + blocks: t.Dict[str, t.Callable[["Context"], t.Iterator[str]]], + vars: t.Optional[t.Dict[str, t.Any]] = None, + shared: bool = False, + globals: t.Optional[t.MutableMapping[str, t.Any]] = None, + locals: t.Optional[t.Mapping[str, t.Any]] = None, +) -> "Context": + """Internal helper for context creation.""" + if vars is None: + vars = {} + if shared: + parent = vars + else: + parent = dict(globals or (), **vars) + if locals: + # if the parent is shared a copy should be created because + # we don't want to modify the dict passed + if shared: + parent = dict(parent) + for key, value in locals.items(): + if value is not missing: + parent[key] = value + return environment.context_class( + environment, parent, template_name, blocks, globals=globals + ) + + +class TemplateReference: + """The `self` in templates.""" + + def __init__(self, context: "Context") -> None: + self.__context = context + + def __getitem__(self, name: str) -> t.Any: + blocks = self.__context.blocks[name] + return BlockReference(name, self.__context, blocks, 0) + + def __repr__(self) -> str: + return f"<{type(self).__name__} {self.__context.name!r}>" + + +def _dict_method_all(dict_method: F) -> F: + @functools.wraps(dict_method) + def f_all(self: "Context") -> t.Any: + return dict_method(self.get_all()) + + return t.cast(F, f_all) + + +@abc.Mapping.register +class Context: + """The template context holds the variables of a template. It stores the + values passed to the template and also the names the template exports. + Creating instances is neither supported nor useful as it's created + automatically at various stages of the template evaluation and should not + be created by hand. + + The context is immutable. Modifications on :attr:`parent` **must not** + happen and modifications on :attr:`vars` are allowed from generated + template code only. Template filters and global functions marked as + :func:`pass_context` get the active context passed as first argument + and are allowed to access the context read-only. + + The template context supports read only dict operations (`get`, + `keys`, `values`, `items`, `iterkeys`, `itervalues`, `iteritems`, + `__getitem__`, `__contains__`). Additionally there is a :meth:`resolve` + method that doesn't fail with a `KeyError` but returns an + :class:`Undefined` object for missing variables. + """ + + def __init__( + self, + environment: "Environment", + parent: t.Dict[str, t.Any], + name: t.Optional[str], + blocks: t.Dict[str, t.Callable[["Context"], t.Iterator[str]]], + globals: t.Optional[t.MutableMapping[str, t.Any]] = None, + ): + self.parent = parent + self.vars: t.Dict[str, t.Any] = {} + self.environment: "Environment" = environment + self.eval_ctx = EvalContext(self.environment, name) + self.exported_vars: t.Set[str] = set() + self.name = name + self.globals_keys = set() if globals is None else set(globals) + + # create the initial mapping of blocks. Whenever template inheritance + # takes place the runtime will update this mapping with the new blocks + # from the template. + self.blocks = {k: [v] for k, v in blocks.items()} + + def super( + self, name: str, current: t.Callable[["Context"], t.Iterator[str]] + ) -> t.Union["BlockReference", "Undefined"]: + """Render a parent block.""" + try: + blocks = self.blocks[name] + index = blocks.index(current) + 1 + blocks[index] + except LookupError: + return self.environment.undefined( + f"there is no parent block called {name!r}.", name="super" + ) + return BlockReference(name, self, blocks, index) + + def get(self, key: str, default: t.Any = None) -> t.Any: + """Look up a variable by name, or return a default if the key is + not found. + + :param key: The variable name to look up. + :param default: The value to return if the key is not found. + """ + try: + return self[key] + except KeyError: + return default + + def resolve(self, key: str) -> t.Union[t.Any, "Undefined"]: + """Look up a variable by name, or return an :class:`Undefined` + object if the key is not found. + + If you need to add custom behavior, override + :meth:`resolve_or_missing`, not this method. The various lookup + functions use that method, not this one. + + :param key: The variable name to look up. + """ + rv = self.resolve_or_missing(key) + + if rv is missing: + return self.environment.undefined(name=key) + + return rv + + def resolve_or_missing(self, key: str) -> t.Any: + """Look up a variable by name, or return a ``missing`` sentinel + if the key is not found. + + Override this method to add custom lookup behavior. + :meth:`resolve`, :meth:`get`, and :meth:`__getitem__` use this + method. Don't call this method directly. + + :param key: The variable name to look up. + """ + if key in self.vars: + return self.vars[key] + + if key in self.parent: + return self.parent[key] + + return missing + + def get_exported(self) -> t.Dict[str, t.Any]: + """Get a new dict with the exported variables.""" + return {k: self.vars[k] for k in self.exported_vars} + + def get_all(self) -> t.Dict[str, t.Any]: + """Return the complete context as dict including the exported + variables. For optimizations reasons this might not return an + actual copy so be careful with using it. + """ + if not self.vars: + return self.parent + if not self.parent: + return self.vars + return dict(self.parent, **self.vars) + + @internalcode + def call( + __self, __obj: t.Callable, *args: t.Any, **kwargs: t.Any # noqa: B902 + ) -> t.Union[t.Any, "Undefined"]: + """Call the callable with the arguments and keyword arguments + provided but inject the active context or environment as first + argument if the callable has :func:`pass_context` or + :func:`pass_environment`. + """ + if __debug__: + __traceback_hide__ = True # noqa + + # Allow callable classes to take a context + if ( + hasattr(__obj, "__call__") # noqa: B004 + and _PassArg.from_obj(__obj.__call__) is not None # type: ignore + ): + __obj = __obj.__call__ # type: ignore + + pass_arg = _PassArg.from_obj(__obj) + + if pass_arg is _PassArg.context: + # the active context should have access to variables set in + # loops and blocks without mutating the context itself + if kwargs.get("_loop_vars"): + __self = __self.derived(kwargs["_loop_vars"]) + if kwargs.get("_block_vars"): + __self = __self.derived(kwargs["_block_vars"]) + args = (__self,) + args + elif pass_arg is _PassArg.eval_context: + args = (__self.eval_ctx,) + args + elif pass_arg is _PassArg.environment: + args = (__self.environment,) + args + + kwargs.pop("_block_vars", None) + kwargs.pop("_loop_vars", None) + + try: + return __obj(*args, **kwargs) + except StopIteration: + return __self.environment.undefined( + "value was undefined because a callable raised a" + " StopIteration exception" + ) + + def derived(self, locals: t.Optional[t.Dict[str, t.Any]] = None) -> "Context": + """Internal helper function to create a derived context. This is + used in situations where the system needs a new context in the same + template that is independent. + """ + context = new_context( + self.environment, self.name, {}, self.get_all(), True, None, locals + ) + context.eval_ctx = self.eval_ctx + context.blocks.update((k, list(v)) for k, v in self.blocks.items()) + return context + + keys = _dict_method_all(dict.keys) + values = _dict_method_all(dict.values) + items = _dict_method_all(dict.items) + + def __contains__(self, name: str) -> bool: + return name in self.vars or name in self.parent + + def __getitem__(self, key: str) -> t.Any: + """Look up a variable by name with ``[]`` syntax, or raise a + ``KeyError`` if the key is not found. + """ + item = self.resolve_or_missing(key) + + if item is missing: + raise KeyError(key) + + return item + + def __repr__(self) -> str: + return f"<{type(self).__name__} {self.get_all()!r} of {self.name!r}>" + + +class BlockReference: + """One block on a template reference.""" + + def __init__( + self, + name: str, + context: "Context", + stack: t.List[t.Callable[["Context"], t.Iterator[str]]], + depth: int, + ) -> None: + self.name = name + self._context = context + self._stack = stack + self._depth = depth + + @property + def super(self) -> t.Union["BlockReference", "Undefined"]: + """Super the block.""" + if self._depth + 1 >= len(self._stack): + return self._context.environment.undefined( + f"there is no parent block called {self.name!r}.", name="super" + ) + return BlockReference(self.name, self._context, self._stack, self._depth + 1) + + @internalcode + async def _async_call(self) -> str: + rv = concat( + [x async for x in self._stack[self._depth](self._context)] # type: ignore + ) + + if self._context.eval_ctx.autoescape: + return Markup(rv) + + return rv + + @internalcode + def __call__(self) -> str: + if self._context.environment.is_async: + return self._async_call() # type: ignore + + rv = concat(self._stack[self._depth](self._context)) + + if self._context.eval_ctx.autoescape: + return Markup(rv) + + return rv + + +class LoopContext: + """A wrapper iterable for dynamic ``for`` loops, with information + about the loop and iteration. + """ + + #: Current iteration of the loop, starting at 0. + index0 = -1 + + _length: t.Optional[int] = None + _after: t.Any = missing + _current: t.Any = missing + _before: t.Any = missing + _last_changed_value: t.Any = missing + + def __init__( + self, + iterable: t.Iterable[V], + undefined: t.Type["Undefined"], + recurse: t.Optional["LoopRenderFunc"] = None, + depth0: int = 0, + ) -> None: + """ + :param iterable: Iterable to wrap. + :param undefined: :class:`Undefined` class to use for next and + previous items. + :param recurse: The function to render the loop body when the + loop is marked recursive. + :param depth0: Incremented when looping recursively. + """ + self._iterable = iterable + self._iterator = self._to_iterator(iterable) + self._undefined = undefined + self._recurse = recurse + #: How many levels deep a recursive loop currently is, starting at 0. + self.depth0 = depth0 + + @staticmethod + def _to_iterator(iterable: t.Iterable[V]) -> t.Iterator[V]: + return iter(iterable) + + @property + def length(self) -> int: + """Length of the iterable. + + If the iterable is a generator or otherwise does not have a + size, it is eagerly evaluated to get a size. + """ + if self._length is not None: + return self._length + + try: + self._length = len(self._iterable) # type: ignore + except TypeError: + iterable = list(self._iterator) + self._iterator = self._to_iterator(iterable) + self._length = len(iterable) + self.index + (self._after is not missing) + + return self._length + + def __len__(self) -> int: + return self.length + + @property + def depth(self) -> int: + """How many levels deep a recursive loop currently is, starting at 1.""" + return self.depth0 + 1 + + @property + def index(self) -> int: + """Current iteration of the loop, starting at 1.""" + return self.index0 + 1 + + @property + def revindex0(self) -> int: + """Number of iterations from the end of the loop, ending at 0. + + Requires calculating :attr:`length`. + """ + return self.length - self.index + + @property + def revindex(self) -> int: + """Number of iterations from the end of the loop, ending at 1. + + Requires calculating :attr:`length`. + """ + return self.length - self.index0 + + @property + def first(self) -> bool: + """Whether this is the first iteration of the loop.""" + return self.index0 == 0 + + def _peek_next(self) -> t.Any: + """Return the next element in the iterable, or :data:`missing` + if the iterable is exhausted. Only peeks one item ahead, caching + the result in :attr:`_last` for use in subsequent checks. The + cache is reset when :meth:`__next__` is called. + """ + if self._after is not missing: + return self._after + + self._after = next(self._iterator, missing) + return self._after + + @property + def last(self) -> bool: + """Whether this is the last iteration of the loop. + + Causes the iterable to advance early. See + :func:`itertools.groupby` for issues this can cause. + The :func:`groupby` filter avoids that issue. + """ + return self._peek_next() is missing + + @property + def previtem(self) -> t.Union[t.Any, "Undefined"]: + """The item in the previous iteration. Undefined during the + first iteration. + """ + if self.first: + return self._undefined("there is no previous item") + + return self._before + + @property + def nextitem(self) -> t.Union[t.Any, "Undefined"]: + """The item in the next iteration. Undefined during the last + iteration. + + Causes the iterable to advance early. See + :func:`itertools.groupby` for issues this can cause. + The :func:`jinja-filters.groupby` filter avoids that issue. + """ + rv = self._peek_next() + + if rv is missing: + return self._undefined("there is no next item") + + return rv + + def cycle(self, *args: V) -> V: + """Return a value from the given args, cycling through based on + the current :attr:`index0`. + + :param args: One or more values to cycle through. + """ + if not args: + raise TypeError("no items for cycling given") + + return args[self.index0 % len(args)] + + def changed(self, *value: t.Any) -> bool: + """Return ``True`` if previously called with a different value + (including when called for the first time). + + :param value: One or more values to compare to the last call. + """ + if self._last_changed_value != value: + self._last_changed_value = value + return True + + return False + + def __iter__(self) -> "LoopContext": + return self + + def __next__(self) -> t.Tuple[t.Any, "LoopContext"]: + if self._after is not missing: + rv = self._after + self._after = missing + else: + rv = next(self._iterator) + + self.index0 += 1 + self._before = self._current + self._current = rv + return rv, self + + @internalcode + def __call__(self, iterable: t.Iterable[V]) -> str: + """When iterating over nested data, render the body of the loop + recursively with the given inner iterable data. + + The loop must have the ``recursive`` marker for this to work. + """ + if self._recurse is None: + raise TypeError( + "The loop must have the 'recursive' marker to be called recursively." + ) + + return self._recurse(iterable, self._recurse, depth=self.depth) + + def __repr__(self) -> str: + return f"<{type(self).__name__} {self.index}/{self.length}>" + + +class AsyncLoopContext(LoopContext): + _iterator: t.AsyncIterator[t.Any] # type: ignore + + @staticmethod + def _to_iterator( # type: ignore + iterable: t.Union[t.Iterable[V], t.AsyncIterable[V]] + ) -> t.AsyncIterator[V]: + return auto_aiter(iterable) + + @property + async def length(self) -> int: # type: ignore + if self._length is not None: + return self._length + + try: + self._length = len(self._iterable) # type: ignore + except TypeError: + iterable = [x async for x in self._iterator] + self._iterator = self._to_iterator(iterable) + self._length = len(iterable) + self.index + (self._after is not missing) + + return self._length + + @property + async def revindex0(self) -> int: # type: ignore + return await self.length - self.index + + @property + async def revindex(self) -> int: # type: ignore + return await self.length - self.index0 + + async def _peek_next(self) -> t.Any: + if self._after is not missing: + return self._after + + try: + self._after = await self._iterator.__anext__() + except StopAsyncIteration: + self._after = missing + + return self._after + + @property + async def last(self) -> bool: # type: ignore + return await self._peek_next() is missing + + @property + async def nextitem(self) -> t.Union[t.Any, "Undefined"]: + rv = await self._peek_next() + + if rv is missing: + return self._undefined("there is no next item") + + return rv + + def __aiter__(self) -> "AsyncLoopContext": + return self + + async def __anext__(self) -> t.Tuple[t.Any, "AsyncLoopContext"]: + if self._after is not missing: + rv = self._after + self._after = missing + else: + rv = await self._iterator.__anext__() + + self.index0 += 1 + self._before = self._current + self._current = rv + return rv, self + + +class Macro: + """Wraps a macro function.""" + + def __init__( + self, + environment: "Environment", + func: t.Callable[..., str], + name: str, + arguments: t.List[str], + catch_kwargs: bool, + catch_varargs: bool, + caller: bool, + default_autoescape: t.Optional[bool] = None, + ): + self._environment = environment + self._func = func + self._argument_count = len(arguments) + self.name = name + self.arguments = arguments + self.catch_kwargs = catch_kwargs + self.catch_varargs = catch_varargs + self.caller = caller + self.explicit_caller = "caller" in arguments + + if default_autoescape is None: + if callable(environment.autoescape): + default_autoescape = environment.autoescape(None) + else: + default_autoescape = environment.autoescape + + self._default_autoescape = default_autoescape + + @internalcode + @pass_eval_context + def __call__(self, *args: t.Any, **kwargs: t.Any) -> str: + # This requires a bit of explanation, In the past we used to + # decide largely based on compile-time information if a macro is + # safe or unsafe. While there was a volatile mode it was largely + # unused for deciding on escaping. This turns out to be + # problematic for macros because whether a macro is safe depends not + # on the escape mode when it was defined, but rather when it was used. + # + # Because however we export macros from the module system and + # there are historic callers that do not pass an eval context (and + # will continue to not pass one), we need to perform an instance + # check here. + # + # This is considered safe because an eval context is not a valid + # argument to callables otherwise anyway. Worst case here is + # that if no eval context is passed we fall back to the compile + # time autoescape flag. + if args and isinstance(args[0], EvalContext): + autoescape = args[0].autoescape + args = args[1:] + else: + autoescape = self._default_autoescape + + # try to consume the positional arguments + arguments = list(args[: self._argument_count]) + off = len(arguments) + + # For information why this is necessary refer to the handling + # of caller in the `macro_body` handler in the compiler. + found_caller = False + + # if the number of arguments consumed is not the number of + # arguments expected we start filling in keyword arguments + # and defaults. + if off != self._argument_count: + for name in self.arguments[len(arguments) :]: + try: + value = kwargs.pop(name) + except KeyError: + value = missing + if name == "caller": + found_caller = True + arguments.append(value) + else: + found_caller = self.explicit_caller + + # it's important that the order of these arguments does not change + # if not also changed in the compiler's `function_scoping` method. + # the order is caller, keyword arguments, positional arguments! + if self.caller and not found_caller: + caller = kwargs.pop("caller", None) + if caller is None: + caller = self._environment.undefined("No caller defined", name="caller") + arguments.append(caller) + + if self.catch_kwargs: + arguments.append(kwargs) + elif kwargs: + if "caller" in kwargs: + raise TypeError( + f"macro {self.name!r} was invoked with two values for the special" + " caller argument. This is most likely a bug." + ) + raise TypeError( + f"macro {self.name!r} takes no keyword argument {next(iter(kwargs))!r}" + ) + if self.catch_varargs: + arguments.append(args[self._argument_count :]) + elif len(args) > self._argument_count: + raise TypeError( + f"macro {self.name!r} takes not more than" + f" {len(self.arguments)} argument(s)" + ) + + return self._invoke(arguments, autoescape) + + async def _async_invoke(self, arguments: t.List[t.Any], autoescape: bool) -> str: + rv = await self._func(*arguments) # type: ignore + + if autoescape: + return Markup(rv) + + return rv # type: ignore + + def _invoke(self, arguments: t.List[t.Any], autoescape: bool) -> str: + if self._environment.is_async: + return self._async_invoke(arguments, autoescape) # type: ignore + + rv = self._func(*arguments) + + if autoescape: + rv = Markup(rv) + + return rv + + def __repr__(self) -> str: + name = "anonymous" if self.name is None else repr(self.name) + return f"<{type(self).__name__} {name}>" + + +class Undefined: + """The default undefined type. This undefined type can be printed and + iterated over, but every other access will raise an :exc:`UndefinedError`: + + >>> foo = Undefined(name='foo') + >>> str(foo) + '' + >>> not foo + True + >>> foo + 42 + Traceback (most recent call last): + ... + jinja2.exceptions.UndefinedError: 'foo' is undefined + """ + + __slots__ = ( + "_undefined_hint", + "_undefined_obj", + "_undefined_name", + "_undefined_exception", + ) + + def __init__( + self, + hint: t.Optional[str] = None, + obj: t.Any = missing, + name: t.Optional[str] = None, + exc: t.Type[TemplateRuntimeError] = UndefinedError, + ) -> None: + self._undefined_hint = hint + self._undefined_obj = obj + self._undefined_name = name + self._undefined_exception = exc + + @property + def _undefined_message(self) -> str: + """Build a message about the undefined value based on how it was + accessed. + """ + if self._undefined_hint: + return self._undefined_hint + + if self._undefined_obj is missing: + return f"{self._undefined_name!r} is undefined" + + if not isinstance(self._undefined_name, str): + return ( + f"{object_type_repr(self._undefined_obj)} has no" + f" element {self._undefined_name!r}" + ) + + return ( + f"{object_type_repr(self._undefined_obj)!r} has no" + f" attribute {self._undefined_name!r}" + ) + + @internalcode + def _fail_with_undefined_error( + self, *args: t.Any, **kwargs: t.Any + ) -> "te.NoReturn": + """Raise an :exc:`UndefinedError` when operations are performed + on the undefined value. + """ + raise self._undefined_exception(self._undefined_message) + + @internalcode + def __getattr__(self, name: str) -> t.Any: + if name[:2] == "__": + raise AttributeError(name) + + return self._fail_with_undefined_error() + + __add__ = __radd__ = __sub__ = __rsub__ = _fail_with_undefined_error + __mul__ = __rmul__ = __div__ = __rdiv__ = _fail_with_undefined_error + __truediv__ = __rtruediv__ = _fail_with_undefined_error + __floordiv__ = __rfloordiv__ = _fail_with_undefined_error + __mod__ = __rmod__ = _fail_with_undefined_error + __pos__ = __neg__ = _fail_with_undefined_error + __call__ = __getitem__ = _fail_with_undefined_error + __lt__ = __le__ = __gt__ = __ge__ = _fail_with_undefined_error + __int__ = __float__ = __complex__ = _fail_with_undefined_error + __pow__ = __rpow__ = _fail_with_undefined_error + + def __eq__(self, other: t.Any) -> bool: + return type(self) is type(other) + + def __ne__(self, other: t.Any) -> bool: + return not self.__eq__(other) + + def __hash__(self) -> int: + return id(type(self)) + + def __str__(self) -> str: + return "" + + def __len__(self) -> int: + return 0 + + def __iter__(self) -> t.Iterator[t.Any]: + yield from () + + async def __aiter__(self) -> t.AsyncIterator[t.Any]: + for _ in (): + yield + + def __bool__(self) -> bool: + return False + + def __repr__(self) -> str: + return "Undefined" + + +def make_logging_undefined( + logger: t.Optional["logging.Logger"] = None, base: t.Type[Undefined] = Undefined +) -> t.Type[Undefined]: + """Given a logger object this returns a new undefined class that will + log certain failures. It will log iterations and printing. If no + logger is given a default logger is created. + + Example:: + + logger = logging.getLogger(__name__) + LoggingUndefined = make_logging_undefined( + logger=logger, + base=Undefined + ) + + .. versionadded:: 2.8 + + :param logger: the logger to use. If not provided, a default logger + is created. + :param base: the base class to add logging functionality to. This + defaults to :class:`Undefined`. + """ + if logger is None: + import logging + + logger = logging.getLogger(__name__) + logger.addHandler(logging.StreamHandler(sys.stderr)) + + def _log_message(undef: Undefined) -> None: + logger.warning( # type: ignore + "Template variable warning: %s", undef._undefined_message + ) + + class LoggingUndefined(base): # type: ignore + __slots__ = () + + def _fail_with_undefined_error( # type: ignore + self, *args: t.Any, **kwargs: t.Any + ) -> "te.NoReturn": + try: + super()._fail_with_undefined_error(*args, **kwargs) + except self._undefined_exception as e: + logger.error("Template variable error: %s", e) # type: ignore + raise e + + def __str__(self) -> str: + _log_message(self) + return super().__str__() # type: ignore + + def __iter__(self) -> t.Iterator[t.Any]: + _log_message(self) + return super().__iter__() # type: ignore + + def __bool__(self) -> bool: + _log_message(self) + return super().__bool__() # type: ignore + + return LoggingUndefined + + +class ChainableUndefined(Undefined): + """An undefined that is chainable, where both ``__getattr__`` and + ``__getitem__`` return itself rather than raising an + :exc:`UndefinedError`. + + >>> foo = ChainableUndefined(name='foo') + >>> str(foo.bar['baz']) + '' + >>> foo.bar['baz'] + 42 + Traceback (most recent call last): + ... + jinja2.exceptions.UndefinedError: 'foo' is undefined + + .. versionadded:: 2.11.0 + """ + + __slots__ = () + + def __html__(self) -> str: + return str(self) + + def __getattr__(self, _: str) -> "ChainableUndefined": + return self + + __getitem__ = __getattr__ # type: ignore + + +class DebugUndefined(Undefined): + """An undefined that returns the debug info when printed. + + >>> foo = DebugUndefined(name='foo') + >>> str(foo) + '{{ foo }}' + >>> not foo + True + >>> foo + 42 + Traceback (most recent call last): + ... + jinja2.exceptions.UndefinedError: 'foo' is undefined + """ + + __slots__ = () + + def __str__(self) -> str: + if self._undefined_hint: + message = f"undefined value printed: {self._undefined_hint}" + + elif self._undefined_obj is missing: + message = self._undefined_name # type: ignore + + else: + message = ( + f"no such element: {object_type_repr(self._undefined_obj)}" + f"[{self._undefined_name!r}]" + ) + + return f"{{{{ {message} }}}}" + + +class StrictUndefined(Undefined): + """An undefined that barks on print and iteration as well as boolean + tests and all kinds of comparisons. In other words: you can do nothing + with it except checking if it's defined using the `defined` test. + + >>> foo = StrictUndefined(name='foo') + >>> str(foo) + Traceback (most recent call last): + ... + jinja2.exceptions.UndefinedError: 'foo' is undefined + >>> not foo + Traceback (most recent call last): + ... + jinja2.exceptions.UndefinedError: 'foo' is undefined + >>> foo + 42 + Traceback (most recent call last): + ... + jinja2.exceptions.UndefinedError: 'foo' is undefined + """ + + __slots__ = () + __iter__ = __str__ = __len__ = Undefined._fail_with_undefined_error + __eq__ = __ne__ = __bool__ = __hash__ = Undefined._fail_with_undefined_error + __contains__ = Undefined._fail_with_undefined_error + + +# Remove slots attributes, after the metaclass is applied they are +# unneeded and contain wrong data for subclasses. +del ( + Undefined.__slots__, + ChainableUndefined.__slots__, + DebugUndefined.__slots__, + StrictUndefined.__slots__, +) diff --git a/src/jinja2/sandbox.py b/src/jinja2/sandbox.py new file mode 100644 index 0000000..06d7414 --- /dev/null +++ b/src/jinja2/sandbox.py @@ -0,0 +1,428 @@ +"""A sandbox layer that ensures unsafe operations cannot be performed. +Useful when the template itself comes from an untrusted source. +""" +import operator +import types +import typing as t +from _string import formatter_field_name_split # type: ignore +from collections import abc +from collections import deque +from string import Formatter + +from markupsafe import EscapeFormatter +from markupsafe import Markup + +from .environment import Environment +from .exceptions import SecurityError +from .runtime import Context +from .runtime import Undefined + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + +#: maximum number of items a range may produce +MAX_RANGE = 100000 + +#: Unsafe function attributes. +UNSAFE_FUNCTION_ATTRIBUTES: t.Set[str] = set() + +#: Unsafe method attributes. Function attributes are unsafe for methods too. +UNSAFE_METHOD_ATTRIBUTES: t.Set[str] = set() + +#: unsafe generator attributes. +UNSAFE_GENERATOR_ATTRIBUTES = {"gi_frame", "gi_code"} + +#: unsafe attributes on coroutines +UNSAFE_COROUTINE_ATTRIBUTES = {"cr_frame", "cr_code"} + +#: unsafe attributes on async generators +UNSAFE_ASYNC_GENERATOR_ATTRIBUTES = {"ag_code", "ag_frame"} + +_mutable_spec: t.Tuple[t.Tuple[t.Type, t.FrozenSet[str]], ...] = ( + ( + abc.MutableSet, + frozenset( + [ + "add", + "clear", + "difference_update", + "discard", + "pop", + "remove", + "symmetric_difference_update", + "update", + ] + ), + ), + ( + abc.MutableMapping, + frozenset(["clear", "pop", "popitem", "setdefault", "update"]), + ), + ( + abc.MutableSequence, + frozenset(["append", "reverse", "insert", "sort", "extend", "remove"]), + ), + ( + deque, + frozenset( + [ + "append", + "appendleft", + "clear", + "extend", + "extendleft", + "pop", + "popleft", + "remove", + "rotate", + ] + ), + ), +) + + +def inspect_format_method(callable: t.Callable) -> t.Optional[str]: + if not isinstance( + callable, (types.MethodType, types.BuiltinMethodType) + ) or callable.__name__ not in ("format", "format_map"): + return None + + obj = callable.__self__ + + if isinstance(obj, str): + return obj + + return None + + +def safe_range(*args: int) -> range: + """A range that can't generate ranges with a length of more than + MAX_RANGE items. + """ + rng = range(*args) + + if len(rng) > MAX_RANGE: + raise OverflowError( + "Range too big. The sandbox blocks ranges larger than" + f" MAX_RANGE ({MAX_RANGE})." + ) + + return rng + + +def unsafe(f: F) -> F: + """Marks a function or method as unsafe. + + .. code-block: python + + @unsafe + def delete(self): + pass + """ + f.unsafe_callable = True # type: ignore + return f + + +def is_internal_attribute(obj: t.Any, attr: str) -> bool: + """Test if the attribute given is an internal python attribute. For + example this function returns `True` for the `func_code` attribute of + python objects. This is useful if the environment method + :meth:`~SandboxedEnvironment.is_safe_attribute` is overridden. + + >>> from jinja2.sandbox import is_internal_attribute + >>> is_internal_attribute(str, "mro") + True + >>> is_internal_attribute(str, "upper") + False + """ + if isinstance(obj, types.FunctionType): + if attr in UNSAFE_FUNCTION_ATTRIBUTES: + return True + elif isinstance(obj, types.MethodType): + if attr in UNSAFE_FUNCTION_ATTRIBUTES or attr in UNSAFE_METHOD_ATTRIBUTES: + return True + elif isinstance(obj, type): + if attr == "mro": + return True + elif isinstance(obj, (types.CodeType, types.TracebackType, types.FrameType)): + return True + elif isinstance(obj, types.GeneratorType): + if attr in UNSAFE_GENERATOR_ATTRIBUTES: + return True + elif hasattr(types, "CoroutineType") and isinstance(obj, types.CoroutineType): + if attr in UNSAFE_COROUTINE_ATTRIBUTES: + return True + elif hasattr(types, "AsyncGeneratorType") and isinstance( + obj, types.AsyncGeneratorType + ): + if attr in UNSAFE_ASYNC_GENERATOR_ATTRIBUTES: + return True + return attr.startswith("__") + + +def modifies_known_mutable(obj: t.Any, attr: str) -> bool: + """This function checks if an attribute on a builtin mutable object + (list, dict, set or deque) or the corresponding ABCs would modify it + if called. + + >>> modifies_known_mutable({}, "clear") + True + >>> modifies_known_mutable({}, "keys") + False + >>> modifies_known_mutable([], "append") + True + >>> modifies_known_mutable([], "index") + False + + If called with an unsupported object, ``False`` is returned. + + >>> modifies_known_mutable("foo", "upper") + False + """ + for typespec, unsafe in _mutable_spec: + if isinstance(obj, typespec): + return attr in unsafe + return False + + +class SandboxedEnvironment(Environment): + """The sandboxed environment. It works like the regular environment but + tells the compiler to generate sandboxed code. Additionally subclasses of + this environment may override the methods that tell the runtime what + attributes or functions are safe to access. + + If the template tries to access insecure code a :exc:`SecurityError` is + raised. However also other exceptions may occur during the rendering so + the caller has to ensure that all exceptions are caught. + """ + + sandboxed = True + + #: default callback table for the binary operators. A copy of this is + #: available on each instance of a sandboxed environment as + #: :attr:`binop_table` + default_binop_table: t.Dict[str, t.Callable[[t.Any, t.Any], t.Any]] = { + "+": operator.add, + "-": operator.sub, + "*": operator.mul, + "/": operator.truediv, + "//": operator.floordiv, + "**": operator.pow, + "%": operator.mod, + } + + #: default callback table for the unary operators. A copy of this is + #: available on each instance of a sandboxed environment as + #: :attr:`unop_table` + default_unop_table: t.Dict[str, t.Callable[[t.Any], t.Any]] = { + "+": operator.pos, + "-": operator.neg, + } + + #: a set of binary operators that should be intercepted. Each operator + #: that is added to this set (empty by default) is delegated to the + #: :meth:`call_binop` method that will perform the operator. The default + #: operator callback is specified by :attr:`binop_table`. + #: + #: The following binary operators are interceptable: + #: ``//``, ``%``, ``+``, ``*``, ``-``, ``/``, and ``**`` + #: + #: The default operation form the operator table corresponds to the + #: builtin function. Intercepted calls are always slower than the native + #: operator call, so make sure only to intercept the ones you are + #: interested in. + #: + #: .. versionadded:: 2.6 + intercepted_binops: t.FrozenSet[str] = frozenset() + + #: a set of unary operators that should be intercepted. Each operator + #: that is added to this set (empty by default) is delegated to the + #: :meth:`call_unop` method that will perform the operator. The default + #: operator callback is specified by :attr:`unop_table`. + #: + #: The following unary operators are interceptable: ``+``, ``-`` + #: + #: The default operation form the operator table corresponds to the + #: builtin function. Intercepted calls are always slower than the native + #: operator call, so make sure only to intercept the ones you are + #: interested in. + #: + #: .. versionadded:: 2.6 + intercepted_unops: t.FrozenSet[str] = frozenset() + + def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: + super().__init__(*args, **kwargs) + self.globals["range"] = safe_range + self.binop_table = self.default_binop_table.copy() + self.unop_table = self.default_unop_table.copy() + + def is_safe_attribute(self, obj: t.Any, attr: str, value: t.Any) -> bool: + """The sandboxed environment will call this method to check if the + attribute of an object is safe to access. Per default all attributes + starting with an underscore are considered private as well as the + special attributes of internal python objects as returned by the + :func:`is_internal_attribute` function. + """ + return not (attr.startswith("_") or is_internal_attribute(obj, attr)) + + def is_safe_callable(self, obj: t.Any) -> bool: + """Check if an object is safely callable. By default callables + are considered safe unless decorated with :func:`unsafe`. + + This also recognizes the Django convention of setting + ``func.alters_data = True``. + """ + return not ( + getattr(obj, "unsafe_callable", False) or getattr(obj, "alters_data", False) + ) + + def call_binop( + self, context: Context, operator: str, left: t.Any, right: t.Any + ) -> t.Any: + """For intercepted binary operator calls (:meth:`intercepted_binops`) + this function is executed instead of the builtin operator. This can + be used to fine tune the behavior of certain operators. + + .. versionadded:: 2.6 + """ + return self.binop_table[operator](left, right) + + def call_unop(self, context: Context, operator: str, arg: t.Any) -> t.Any: + """For intercepted unary operator calls (:meth:`intercepted_unops`) + this function is executed instead of the builtin operator. This can + be used to fine tune the behavior of certain operators. + + .. versionadded:: 2.6 + """ + return self.unop_table[operator](arg) + + def getitem( + self, obj: t.Any, argument: t.Union[str, t.Any] + ) -> t.Union[t.Any, Undefined]: + """Subscribe an object from sandboxed code.""" + try: + return obj[argument] + except (TypeError, LookupError): + if isinstance(argument, str): + try: + attr = str(argument) + except Exception: + pass + else: + try: + value = getattr(obj, attr) + except AttributeError: + pass + else: + if self.is_safe_attribute(obj, argument, value): + return value + return self.unsafe_undefined(obj, argument) + return self.undefined(obj=obj, name=argument) + + def getattr(self, obj: t.Any, attribute: str) -> t.Union[t.Any, Undefined]: + """Subscribe an object from sandboxed code and prefer the + attribute. The attribute passed *must* be a bytestring. + """ + try: + value = getattr(obj, attribute) + except AttributeError: + try: + return obj[attribute] + except (TypeError, LookupError): + pass + else: + if self.is_safe_attribute(obj, attribute, value): + return value + return self.unsafe_undefined(obj, attribute) + return self.undefined(obj=obj, name=attribute) + + def unsafe_undefined(self, obj: t.Any, attribute: str) -> Undefined: + """Return an undefined object for unsafe attributes.""" + return self.undefined( + f"access to attribute {attribute!r} of" + f" {type(obj).__name__!r} object is unsafe.", + name=attribute, + obj=obj, + exc=SecurityError, + ) + + def format_string( + self, + s: str, + args: t.Tuple[t.Any, ...], + kwargs: t.Dict[str, t.Any], + format_func: t.Optional[t.Callable] = None, + ) -> str: + """If a format call is detected, then this is routed through this + method so that our safety sandbox can be used for it. + """ + formatter: SandboxedFormatter + if isinstance(s, Markup): + formatter = SandboxedEscapeFormatter(self, escape=s.escape) + else: + formatter = SandboxedFormatter(self) + + if format_func is not None and format_func.__name__ == "format_map": + if len(args) != 1 or kwargs: + raise TypeError( + "format_map() takes exactly one argument" + f" {len(args) + (kwargs is not None)} given" + ) + + kwargs = args[0] + args = () + + rv = formatter.vformat(s, args, kwargs) + return type(s)(rv) + + def call( + __self, # noqa: B902 + __context: Context, + __obj: t.Any, + *args: t.Any, + **kwargs: t.Any, + ) -> t.Any: + """Call an object from sandboxed code.""" + fmt = inspect_format_method(__obj) + if fmt is not None: + return __self.format_string(fmt, args, kwargs, __obj) + + # the double prefixes are to avoid double keyword argument + # errors when proxying the call. + if not __self.is_safe_callable(__obj): + raise SecurityError(f"{__obj!r} is not safely callable") + return __context.call(__obj, *args, **kwargs) + + +class ImmutableSandboxedEnvironment(SandboxedEnvironment): + """Works exactly like the regular `SandboxedEnvironment` but does not + permit modifications on the builtin mutable objects `list`, `set`, and + `dict` by using the :func:`modifies_known_mutable` function. + """ + + def is_safe_attribute(self, obj: t.Any, attr: str, value: t.Any) -> bool: + if not super().is_safe_attribute(obj, attr, value): + return False + + return not modifies_known_mutable(obj, attr) + + +class SandboxedFormatter(Formatter): + def __init__(self, env: Environment, **kwargs: t.Any) -> None: + self._env = env + super().__init__(**kwargs) + + def get_field( + self, field_name: str, args: t.Sequence[t.Any], kwargs: t.Mapping[str, t.Any] + ) -> t.Tuple[t.Any, str]: + first, rest = formatter_field_name_split(field_name) + obj = self.get_value(first, args, kwargs) + for is_attr, i in rest: + if is_attr: + obj = self._env.getattr(obj, i) + else: + obj = self._env.getitem(obj, i) + return obj, first + + +class SandboxedEscapeFormatter(SandboxedFormatter, EscapeFormatter): + pass diff --git a/src/jinja2/tests.py b/src/jinja2/tests.py new file mode 100644 index 0000000..a467cf0 --- /dev/null +++ b/src/jinja2/tests.py @@ -0,0 +1,255 @@ +"""Built-in template tests used with the ``is`` operator.""" +import operator +import typing as t +from collections import abc +from numbers import Number + +from .runtime import Undefined +from .utils import pass_environment + +if t.TYPE_CHECKING: + from .environment import Environment + + +def test_odd(value: int) -> bool: + """Return true if the variable is odd.""" + return value % 2 == 1 + + +def test_even(value: int) -> bool: + """Return true if the variable is even.""" + return value % 2 == 0 + + +def test_divisibleby(value: int, num: int) -> bool: + """Check if a variable is divisible by a number.""" + return value % num == 0 + + +def test_defined(value: t.Any) -> bool: + """Return true if the variable is defined: + + .. sourcecode:: jinja + + {% if variable is defined %} + value of variable: {{ variable }} + {% else %} + variable is not defined + {% endif %} + + See the :func:`default` filter for a simple way to set undefined + variables. + """ + return not isinstance(value, Undefined) + + +def test_undefined(value: t.Any) -> bool: + """Like :func:`defined` but the other way round.""" + return isinstance(value, Undefined) + + +@pass_environment +def test_filter(env: "Environment", value: str) -> bool: + """Check if a filter exists by name. Useful if a filter may be + optionally available. + + .. code-block:: jinja + + {% if 'markdown' is filter %} + {{ value | markdown }} + {% else %} + {{ value }} + {% endif %} + + .. versionadded:: 3.0 + """ + return value in env.filters + + +@pass_environment +def test_test(env: "Environment", value: str) -> bool: + """Check if a test exists by name. Useful if a test may be + optionally available. + + .. code-block:: jinja + + {% if 'loud' is test %} + {% if value is loud %} + {{ value|upper }} + {% else %} + {{ value|lower }} + {% endif %} + {% else %} + {{ value }} + {% endif %} + + .. versionadded:: 3.0 + """ + return value in env.tests + + +def test_none(value: t.Any) -> bool: + """Return true if the variable is none.""" + return value is None + + +def test_boolean(value: t.Any) -> bool: + """Return true if the object is a boolean value. + + .. versionadded:: 2.11 + """ + return value is True or value is False + + +def test_false(value: t.Any) -> bool: + """Return true if the object is False. + + .. versionadded:: 2.11 + """ + return value is False + + +def test_true(value: t.Any) -> bool: + """Return true if the object is True. + + .. versionadded:: 2.11 + """ + return value is True + + +# NOTE: The existing 'number' test matches booleans and floats +def test_integer(value: t.Any) -> bool: + """Return true if the object is an integer. + + .. versionadded:: 2.11 + """ + return isinstance(value, int) and value is not True and value is not False + + +# NOTE: The existing 'number' test matches booleans and integers +def test_float(value: t.Any) -> bool: + """Return true if the object is a float. + + .. versionadded:: 2.11 + """ + return isinstance(value, float) + + +def test_lower(value: str) -> bool: + """Return true if the variable is lowercased.""" + return str(value).islower() + + +def test_upper(value: str) -> bool: + """Return true if the variable is uppercased.""" + return str(value).isupper() + + +def test_string(value: t.Any) -> bool: + """Return true if the object is a string.""" + return isinstance(value, str) + + +def test_mapping(value: t.Any) -> bool: + """Return true if the object is a mapping (dict etc.). + + .. versionadded:: 2.6 + """ + return isinstance(value, abc.Mapping) + + +def test_number(value: t.Any) -> bool: + """Return true if the variable is a number.""" + return isinstance(value, Number) + + +def test_sequence(value: t.Any) -> bool: + """Return true if the variable is a sequence. Sequences are variables + that are iterable. + """ + try: + len(value) + value.__getitem__ + except Exception: + return False + + return True + + +def test_sameas(value: t.Any, other: t.Any) -> bool: + """Check if an object points to the same memory address than another + object: + + .. sourcecode:: jinja + + {% if foo.attribute is sameas false %} + the foo attribute really is the `False` singleton + {% endif %} + """ + return value is other + + +def test_iterable(value: t.Any) -> bool: + """Check if it's possible to iterate over an object.""" + try: + iter(value) + except TypeError: + return False + + return True + + +def test_escaped(value: t.Any) -> bool: + """Check if the value is escaped.""" + return hasattr(value, "__html__") + + +def test_in(value: t.Any, seq: t.Container) -> bool: + """Check if value is in seq. + + .. versionadded:: 2.10 + """ + return value in seq + + +TESTS = { + "odd": test_odd, + "even": test_even, + "divisibleby": test_divisibleby, + "defined": test_defined, + "undefined": test_undefined, + "filter": test_filter, + "test": test_test, + "none": test_none, + "boolean": test_boolean, + "false": test_false, + "true": test_true, + "integer": test_integer, + "float": test_float, + "lower": test_lower, + "upper": test_upper, + "string": test_string, + "mapping": test_mapping, + "number": test_number, + "sequence": test_sequence, + "iterable": test_iterable, + "callable": callable, + "sameas": test_sameas, + "escaped": test_escaped, + "in": test_in, + "==": operator.eq, + "eq": operator.eq, + "equalto": operator.eq, + "!=": operator.ne, + "ne": operator.ne, + ">": operator.gt, + "gt": operator.gt, + "greaterthan": operator.gt, + "ge": operator.ge, + ">=": operator.ge, + "<": operator.lt, + "lt": operator.lt, + "lessthan": operator.lt, + "<=": operator.le, + "le": operator.le, +} diff --git a/src/jinja2/utils.py b/src/jinja2/utils.py new file mode 100644 index 0000000..9b5f5a5 --- /dev/null +++ b/src/jinja2/utils.py @@ -0,0 +1,755 @@ +import enum +import json +import os +import re +import typing as t +from collections import abc +from collections import deque +from random import choice +from random import randrange +from threading import Lock +from types import CodeType +from urllib.parse import quote_from_bytes + +import markupsafe + +if t.TYPE_CHECKING: + import typing_extensions as te + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + +# special singleton representing missing values for the runtime +missing: t.Any = type("MissingType", (), {"__repr__": lambda x: "missing"})() + +internal_code: t.MutableSet[CodeType] = set() + +concat = "".join + + +def pass_context(f: F) -> F: + """Pass the :class:`~jinja2.runtime.Context` as the first argument + to the decorated function when called while rendering a template. + + Can be used on functions, filters, and tests. + + If only ``Context.eval_context`` is needed, use + :func:`pass_eval_context`. If only ``Context.environment`` is + needed, use :func:`pass_environment`. + + .. versionadded:: 3.0.0 + Replaces ``contextfunction`` and ``contextfilter``. + """ + f.jinja_pass_arg = _PassArg.context # type: ignore + return f + + +def pass_eval_context(f: F) -> F: + """Pass the :class:`~jinja2.nodes.EvalContext` as the first argument + to the decorated function when called while rendering a template. + See :ref:`eval-context`. + + Can be used on functions, filters, and tests. + + If only ``EvalContext.environment`` is needed, use + :func:`pass_environment`. + + .. versionadded:: 3.0.0 + Replaces ``evalcontextfunction`` and ``evalcontextfilter``. + """ + f.jinja_pass_arg = _PassArg.eval_context # type: ignore + return f + + +def pass_environment(f: F) -> F: + """Pass the :class:`~jinja2.Environment` as the first argument to + the decorated function when called while rendering a template. + + Can be used on functions, filters, and tests. + + .. versionadded:: 3.0.0 + Replaces ``environmentfunction`` and ``environmentfilter``. + """ + f.jinja_pass_arg = _PassArg.environment # type: ignore + return f + + +class _PassArg(enum.Enum): + context = enum.auto() + eval_context = enum.auto() + environment = enum.auto() + + @classmethod + def from_obj(cls, obj: F) -> t.Optional["_PassArg"]: + if hasattr(obj, "jinja_pass_arg"): + return obj.jinja_pass_arg # type: ignore + + return None + + +def internalcode(f: F) -> F: + """Marks the function as internally used""" + internal_code.add(f.__code__) + return f + + +def is_undefined(obj: t.Any) -> bool: + """Check if the object passed is undefined. This does nothing more than + performing an instance check against :class:`Undefined` but looks nicer. + This can be used for custom filters or tests that want to react to + undefined variables. For example a custom default filter can look like + this:: + + def default(var, default=''): + if is_undefined(var): + return default + return var + """ + from .runtime import Undefined + + return isinstance(obj, Undefined) + + +def consume(iterable: t.Iterable[t.Any]) -> None: + """Consumes an iterable without doing anything with it.""" + for _ in iterable: + pass + + +def clear_caches() -> None: + """Jinja keeps internal caches for environments and lexers. These are + used so that Jinja doesn't have to recreate environments and lexers all + the time. Normally you don't have to care about that but if you are + measuring memory consumption you may want to clean the caches. + """ + from .environment import get_spontaneous_environment + from .lexer import _lexer_cache + + get_spontaneous_environment.cache_clear() + _lexer_cache.clear() + + +def import_string(import_name: str, silent: bool = False) -> t.Any: + """Imports an object based on a string. This is useful if you want to + use import paths as endpoints or something similar. An import path can + be specified either in dotted notation (``xml.sax.saxutils.escape``) + or with a colon as object delimiter (``xml.sax.saxutils:escape``). + + If the `silent` is True the return value will be `None` if the import + fails. + + :return: imported object + """ + try: + if ":" in import_name: + module, obj = import_name.split(":", 1) + elif "." in import_name: + module, _, obj = import_name.rpartition(".") + else: + return __import__(import_name) + return getattr(__import__(module, None, None, [obj]), obj) + except (ImportError, AttributeError): + if not silent: + raise + + +def open_if_exists(filename: str, mode: str = "rb") -> t.Optional[t.IO]: + """Returns a file descriptor for the filename if that file exists, + otherwise ``None``. + """ + if not os.path.isfile(filename): + return None + + return open(filename, mode) + + +def object_type_repr(obj: t.Any) -> str: + """Returns the name of the object's type. For some recognized + singletons the name of the object is returned instead. (For + example for `None` and `Ellipsis`). + """ + if obj is None: + return "None" + elif obj is Ellipsis: + return "Ellipsis" + + cls = type(obj) + + if cls.__module__ == "builtins": + return f"{cls.__name__} object" + + return f"{cls.__module__}.{cls.__name__} object" + + +def pformat(obj: t.Any) -> str: + """Format an object using :func:`pprint.pformat`.""" + from pprint import pformat # type: ignore + + return pformat(obj) + + +_http_re = re.compile( + r""" + ^ + ( + (https?://|www\.) # scheme or www + (([\w%-]+\.)+)? # subdomain + ( + [a-z]{2,63} # basic tld + | + xn--[\w%]{2,59} # idna tld + ) + | + ([\w%-]{2,63}\.)+ # basic domain + (com|net|int|edu|gov|org|info|mil) # basic tld + | + (https?://) # scheme + ( + (([\d]{1,3})(\.[\d]{1,3}){3}) # IPv4 + | + (\[([\da-f]{0,4}:){2}([\da-f]{0,4}:?){1,6}]) # IPv6 + ) + ) + (?::[\d]{1,5})? # port + (?:[/?#]\S*)? # path, query, and fragment + $ + """, + re.IGNORECASE | re.VERBOSE, +) +_email_re = re.compile(r"^\S+@\w[\w.-]*\.\w+$") + + +def urlize( + text: str, + trim_url_limit: t.Optional[int] = None, + rel: t.Optional[str] = None, + target: t.Optional[str] = None, + extra_schemes: t.Optional[t.Iterable[str]] = None, +) -> str: + """Convert URLs in text into clickable links. + + This may not recognize links in some situations. Usually, a more + comprehensive formatter, such as a Markdown library, is a better + choice. + + Works on ``http://``, ``https://``, ``www.``, ``mailto:``, and email + addresses. Links with trailing punctuation (periods, commas, closing + parentheses) and leading punctuation (opening parentheses) are + recognized excluding the punctuation. Email addresses that include + header fields are not recognized (for example, + ``mailto:address@example.com?cc=copy@example.com``). + + :param text: Original text containing URLs to link. + :param trim_url_limit: Shorten displayed URL values to this length. + :param target: Add the ``target`` attribute to links. + :param rel: Add the ``rel`` attribute to links. + :param extra_schemes: Recognize URLs that start with these schemes + in addition to the default behavior. + + .. versionchanged:: 3.0 + The ``extra_schemes`` parameter was added. + + .. versionchanged:: 3.0 + Generate ``https://`` links for URLs without a scheme. + + .. versionchanged:: 3.0 + The parsing rules were updated. Recognize email addresses with + or without the ``mailto:`` scheme. Validate IP addresses. Ignore + parentheses and brackets in more cases. + """ + if trim_url_limit is not None: + + def trim_url(x: str) -> str: + if len(x) > trim_url_limit: # type: ignore + return f"{x[:trim_url_limit]}..." + + return x + + else: + + def trim_url(x: str) -> str: + return x + + words = re.split(r"(\s+)", str(markupsafe.escape(text))) + rel_attr = f' rel="{markupsafe.escape(rel)}"' if rel else "" + target_attr = f' target="{markupsafe.escape(target)}"' if target else "" + + for i, word in enumerate(words): + head, middle, tail = "", word, "" + match = re.match(r"^([(<]|<)+", middle) + + if match: + head = match.group() + middle = middle[match.end() :] + + # Unlike lead, which is anchored to the start of the string, + # need to check that the string ends with any of the characters + # before trying to match all of them, to avoid backtracking. + if middle.endswith((")", ">", ".", ",", "\n", ">")): + match = re.search(r"([)>.,\n]|>)+$", middle) + + if match: + tail = match.group() + middle = middle[: match.start()] + + # Prefer balancing parentheses in URLs instead of ignoring a + # trailing character. + for start_char, end_char in ("(", ")"), ("<", ">"), ("<", ">"): + start_count = middle.count(start_char) + + if start_count <= middle.count(end_char): + # Balanced, or lighter on the left + continue + + # Move as many as possible from the tail to balance + for _ in range(min(start_count, tail.count(end_char))): + end_index = tail.index(end_char) + len(end_char) + # Move anything in the tail before the end char too + middle += tail[:end_index] + tail = tail[end_index:] + + if _http_re.match(middle): + if middle.startswith("https://") or middle.startswith("http://"): + middle = ( + f'<a href="{middle}"{rel_attr}{target_attr}>{trim_url(middle)}</a>' + ) + else: + middle = ( + f'<a href="https://{middle}"{rel_attr}{target_attr}>' + f"{trim_url(middle)}</a>" + ) + + elif middle.startswith("mailto:") and _email_re.match(middle[7:]): + middle = f'<a href="{middle}">{middle[7:]}</a>' + + elif ( + "@" in middle + and not middle.startswith("www.") + and ":" not in middle + and _email_re.match(middle) + ): + middle = f'<a href="mailto:{middle}">{middle}</a>' + + elif extra_schemes is not None: + for scheme in extra_schemes: + if middle != scheme and middle.startswith(scheme): + middle = f'<a href="{middle}"{rel_attr}{target_attr}>{middle}</a>' + + words[i] = f"{head}{middle}{tail}" + + return "".join(words) + + +def generate_lorem_ipsum( + n: int = 5, html: bool = True, min: int = 20, max: int = 100 +) -> str: + """Generate some lorem ipsum for the template.""" + from .constants import LOREM_IPSUM_WORDS + + words = LOREM_IPSUM_WORDS.split() + result = [] + + for _ in range(n): + next_capitalized = True + last_comma = last_fullstop = 0 + word = None + last = None + p = [] + + # each paragraph contains out of 20 to 100 words. + for idx, _ in enumerate(range(randrange(min, max))): + while True: + word = choice(words) + if word != last: + last = word + break + if next_capitalized: + word = word.capitalize() + next_capitalized = False + # add commas + if idx - randrange(3, 8) > last_comma: + last_comma = idx + last_fullstop += 2 + word += "," + # add end of sentences + if idx - randrange(10, 20) > last_fullstop: + last_comma = last_fullstop = idx + word += "." + next_capitalized = True + p.append(word) + + # ensure that the paragraph ends with a dot. + p_str = " ".join(p) + + if p_str.endswith(","): + p_str = p_str[:-1] + "." + elif not p_str.endswith("."): + p_str += "." + + result.append(p_str) + + if not html: + return "\n\n".join(result) + return markupsafe.Markup( + "\n".join(f"<p>{markupsafe.escape(x)}</p>" for x in result) + ) + + +def url_quote(obj: t.Any, charset: str = "utf-8", for_qs: bool = False) -> str: + """Quote a string for use in a URL using the given charset. + + :param obj: String or bytes to quote. Other types are converted to + string then encoded to bytes using the given charset. + :param charset: Encode text to bytes using this charset. + :param for_qs: Quote "/" and use "+" for spaces. + """ + if not isinstance(obj, bytes): + if not isinstance(obj, str): + obj = str(obj) + + obj = obj.encode(charset) + + safe = b"" if for_qs else b"/" + rv = quote_from_bytes(obj, safe) + + if for_qs: + rv = rv.replace("%20", "+") + + return rv + + +@abc.MutableMapping.register +class LRUCache: + """A simple LRU Cache implementation.""" + + # this is fast for small capacities (something below 1000) but doesn't + # scale. But as long as it's only used as storage for templates this + # won't do any harm. + + def __init__(self, capacity: int) -> None: + self.capacity = capacity + self._mapping: t.Dict[t.Any, t.Any] = {} + self._queue: "te.Deque[t.Any]" = deque() + self._postinit() + + def _postinit(self) -> None: + # alias all queue methods for faster lookup + self._popleft = self._queue.popleft + self._pop = self._queue.pop + self._remove = self._queue.remove + self._wlock = Lock() + self._append = self._queue.append + + def __getstate__(self) -> t.Mapping[str, t.Any]: + return { + "capacity": self.capacity, + "_mapping": self._mapping, + "_queue": self._queue, + } + + def __setstate__(self, d: t.Mapping[str, t.Any]) -> None: + self.__dict__.update(d) + self._postinit() + + def __getnewargs__(self) -> t.Tuple: + return (self.capacity,) + + def copy(self) -> "LRUCache": + """Return a shallow copy of the instance.""" + rv = self.__class__(self.capacity) + rv._mapping.update(self._mapping) + rv._queue.extend(self._queue) + return rv + + def get(self, key: t.Any, default: t.Any = None) -> t.Any: + """Return an item from the cache dict or `default`""" + try: + return self[key] + except KeyError: + return default + + def setdefault(self, key: t.Any, default: t.Any = None) -> t.Any: + """Set `default` if the key is not in the cache otherwise + leave unchanged. Return the value of this key. + """ + try: + return self[key] + except KeyError: + self[key] = default + return default + + def clear(self) -> None: + """Clear the cache.""" + with self._wlock: + self._mapping.clear() + self._queue.clear() + + def __contains__(self, key: t.Any) -> bool: + """Check if a key exists in this cache.""" + return key in self._mapping + + def __len__(self) -> int: + """Return the current size of the cache.""" + return len(self._mapping) + + def __repr__(self) -> str: + return f"<{type(self).__name__} {self._mapping!r}>" + + def __getitem__(self, key: t.Any) -> t.Any: + """Get an item from the cache. Moves the item up so that it has the + highest priority then. + + Raise a `KeyError` if it does not exist. + """ + with self._wlock: + rv = self._mapping[key] + + if self._queue[-1] != key: + try: + self._remove(key) + except ValueError: + # if something removed the key from the container + # when we read, ignore the ValueError that we would + # get otherwise. + pass + + self._append(key) + + return rv + + def __setitem__(self, key: t.Any, value: t.Any) -> None: + """Sets the value for an item. Moves the item up so that it + has the highest priority then. + """ + with self._wlock: + if key in self._mapping: + self._remove(key) + elif len(self._mapping) == self.capacity: + del self._mapping[self._popleft()] + + self._append(key) + self._mapping[key] = value + + def __delitem__(self, key: t.Any) -> None: + """Remove an item from the cache dict. + Raise a `KeyError` if it does not exist. + """ + with self._wlock: + del self._mapping[key] + + try: + self._remove(key) + except ValueError: + pass + + def items(self) -> t.Iterable[t.Tuple[t.Any, t.Any]]: + """Return a list of items.""" + result = [(key, self._mapping[key]) for key in list(self._queue)] + result.reverse() + return result + + def values(self) -> t.Iterable[t.Any]: + """Return a list of all values.""" + return [x[1] for x in self.items()] + + def keys(self) -> t.Iterable[t.Any]: + """Return a list of all keys ordered by most recent usage.""" + return list(self) + + def __iter__(self) -> t.Iterator[t.Any]: + return reversed(tuple(self._queue)) + + def __reversed__(self) -> t.Iterator[t.Any]: + """Iterate over the keys in the cache dict, oldest items + coming first. + """ + return iter(tuple(self._queue)) + + __copy__ = copy + + +def select_autoescape( + enabled_extensions: t.Collection[str] = ("html", "htm", "xml"), + disabled_extensions: t.Collection[str] = (), + default_for_string: bool = True, + default: bool = False, +) -> t.Callable[[t.Optional[str]], bool]: + """Intelligently sets the initial value of autoescaping based on the + filename of the template. This is the recommended way to configure + autoescaping if you do not want to write a custom function yourself. + + If you want to enable it for all templates created from strings or + for all templates with `.html` and `.xml` extensions:: + + from jinja2 import Environment, select_autoescape + env = Environment(autoescape=select_autoescape( + enabled_extensions=('html', 'xml'), + default_for_string=True, + )) + + Example configuration to turn it on at all times except if the template + ends with `.txt`:: + + from jinja2 import Environment, select_autoescape + env = Environment(autoescape=select_autoescape( + disabled_extensions=('txt',), + default_for_string=True, + default=True, + )) + + The `enabled_extensions` is an iterable of all the extensions that + autoescaping should be enabled for. Likewise `disabled_extensions` is + a list of all templates it should be disabled for. If a template is + loaded from a string then the default from `default_for_string` is used. + If nothing matches then the initial value of autoescaping is set to the + value of `default`. + + For security reasons this function operates case insensitive. + + .. versionadded:: 2.9 + """ + enabled_patterns = tuple(f".{x.lstrip('.').lower()}" for x in enabled_extensions) + disabled_patterns = tuple(f".{x.lstrip('.').lower()}" for x in disabled_extensions) + + def autoescape(template_name: t.Optional[str]) -> bool: + if template_name is None: + return default_for_string + template_name = template_name.lower() + if template_name.endswith(enabled_patterns): + return True + if template_name.endswith(disabled_patterns): + return False + return default + + return autoescape + + +def htmlsafe_json_dumps( + obj: t.Any, dumps: t.Optional[t.Callable[..., str]] = None, **kwargs: t.Any +) -> markupsafe.Markup: + """Serialize an object to a string of JSON with :func:`json.dumps`, + then replace HTML-unsafe characters with Unicode escapes and mark + the result safe with :class:`~markupsafe.Markup`. + + This is available in templates as the ``|tojson`` filter. + + The following characters are escaped: ``<``, ``>``, ``&``, ``'``. + + The returned string is safe to render in HTML documents and + ``<script>`` tags. The exception is in HTML attributes that are + double quoted; either use single quotes or the ``|forceescape`` + filter. + + :param obj: The object to serialize to JSON. + :param dumps: The ``dumps`` function to use. Defaults to + ``env.policies["json.dumps_function"]``, which defaults to + :func:`json.dumps`. + :param kwargs: Extra arguments to pass to ``dumps``. Merged onto + ``env.policies["json.dumps_kwargs"]``. + + .. versionchanged:: 3.0 + The ``dumper`` parameter is renamed to ``dumps``. + + .. versionadded:: 2.9 + """ + if dumps is None: + dumps = json.dumps + + return markupsafe.Markup( + dumps(obj, **kwargs) + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("&", "\\u0026") + .replace("'", "\\u0027") + ) + + +class Cycler: + """Cycle through values by yield them one at a time, then restarting + once the end is reached. Available as ``cycler`` in templates. + + Similar to ``loop.cycle``, but can be used outside loops or across + multiple loops. For example, render a list of folders and files in a + list, alternating giving them "odd" and "even" classes. + + .. code-block:: html+jinja + + {% set row_class = cycler("odd", "even") %} + <ul class="browser"> + {% for folder in folders %} + <li class="folder {{ row_class.next() }}">{{ folder }} + {% endfor %} + {% for file in files %} + <li class="file {{ row_class.next() }}">{{ file }} + {% endfor %} + </ul> + + :param items: Each positional argument will be yielded in the order + given for each cycle. + + .. versionadded:: 2.1 + """ + + def __init__(self, *items: t.Any) -> None: + if not items: + raise RuntimeError("at least one item has to be provided") + self.items = items + self.pos = 0 + + def reset(self) -> None: + """Resets the current item to the first item.""" + self.pos = 0 + + @property + def current(self) -> t.Any: + """Return the current item. Equivalent to the item that will be + returned next time :meth:`next` is called. + """ + return self.items[self.pos] + + def next(self) -> t.Any: + """Return the current item, then advance :attr:`current` to the + next item. + """ + rv = self.current + self.pos = (self.pos + 1) % len(self.items) + return rv + + __next__ = next + + +class Joiner: + """A joining helper for templates.""" + + def __init__(self, sep: str = ", ") -> None: + self.sep = sep + self.used = False + + def __call__(self) -> str: + if not self.used: + self.used = True + return "" + return self.sep + + +class Namespace: + """A namespace object that can hold arbitrary attributes. It may be + initialized from a dictionary or with keyword arguments.""" + + def __init__(*args: t.Any, **kwargs: t.Any) -> None: # noqa: B902 + self, args = args[0], args[1:] + self.__attrs = dict(*args, **kwargs) + + def __getattribute__(self, name: str) -> t.Any: + # __class__ is needed for the awaitable check in async mode + if name in {"_Namespace__attrs", "__class__"}: + return object.__getattribute__(self, name) + try: + return self.__attrs[name] + except KeyError: + raise AttributeError(name) from None + + def __setitem__(self, name: str, value: t.Any) -> None: + self.__attrs[name] = value + + def __repr__(self) -> str: + return f"<Namespace {self.__attrs!r}>" diff --git a/src/jinja2/visitor.py b/src/jinja2/visitor.py new file mode 100644 index 0000000..17c6aab --- /dev/null +++ b/src/jinja2/visitor.py @@ -0,0 +1,92 @@ +"""API for traversing the AST nodes. Implemented by the compiler and +meta introspection. +""" +import typing as t + +from .nodes import Node + +if t.TYPE_CHECKING: + import typing_extensions as te + + class VisitCallable(te.Protocol): + def __call__(self, node: Node, *args: t.Any, **kwargs: t.Any) -> t.Any: + ... + + +class NodeVisitor: + """Walks the abstract syntax tree and call visitor functions for every + node found. The visitor functions may return values which will be + forwarded by the `visit` method. + + Per default the visitor functions for the nodes are ``'visit_'`` + + class name of the node. So a `TryFinally` node visit function would + be `visit_TryFinally`. This behavior can be changed by overriding + the `get_visitor` function. If no visitor function exists for a node + (return value `None`) the `generic_visit` visitor is used instead. + """ + + def get_visitor(self, node: Node) -> "t.Optional[VisitCallable]": + """Return the visitor function for this node or `None` if no visitor + exists for this node. In that case the generic visit function is + used instead. + """ + return getattr(self, f"visit_{type(node).__name__}", None) + + def visit(self, node: Node, *args: t.Any, **kwargs: t.Any) -> t.Any: + """Visit a node.""" + f = self.get_visitor(node) + + if f is not None: + return f(node, *args, **kwargs) + + return self.generic_visit(node, *args, **kwargs) + + def generic_visit(self, node: Node, *args: t.Any, **kwargs: t.Any) -> t.Any: + """Called if no explicit visitor function exists for a node.""" + for child_node in node.iter_child_nodes(): + self.visit(child_node, *args, **kwargs) + + +class NodeTransformer(NodeVisitor): + """Walks the abstract syntax tree and allows modifications of nodes. + + The `NodeTransformer` will walk the AST and use the return value of the + visitor functions to replace or remove the old node. If the return + value of the visitor function is `None` the node will be removed + from the previous location otherwise it's replaced with the return + value. The return value may be the original node in which case no + replacement takes place. + """ + + def generic_visit(self, node: Node, *args: t.Any, **kwargs: t.Any) -> Node: + for field, old_value in node.iter_fields(): + if isinstance(old_value, list): + new_values = [] + for value in old_value: + if isinstance(value, Node): + value = self.visit(value, *args, **kwargs) + if value is None: + continue + elif not isinstance(value, Node): + new_values.extend(value) + continue + new_values.append(value) + old_value[:] = new_values + elif isinstance(old_value, Node): + new_node = self.visit(old_value, *args, **kwargs) + if new_node is None: + delattr(node, field) + else: + setattr(node, field, new_node) + return node + + def visit_list(self, node: Node, *args: t.Any, **kwargs: t.Any) -> t.List[Node]: + """As transformers may return lists in some places this method + can be used to enforce a list as return value. + """ + rv = self.visit(node, *args, **kwargs) + + if not isinstance(rv, list): + return [rv] + + return rv diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e225ab9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,49 @@ +from pathlib import Path + +import pytest + +from jinja2 import loaders +from jinja2.environment import Environment + + +@pytest.fixture +def env(): + """returns a new environment.""" + return Environment() + + +@pytest.fixture +def dict_loader(): + """returns DictLoader""" + return loaders.DictLoader({"justdict.html": "FOO"}) + + +@pytest.fixture +def package_loader(): + """returns PackageLoader initialized from templates""" + return loaders.PackageLoader("res", "templates") + + +@pytest.fixture +def filesystem_loader(): + """returns FileSystemLoader initialized to res/templates directory""" + here = Path(__file__).parent.resolve() + return loaders.FileSystemLoader(here / "res" / "templates") + + +@pytest.fixture +def function_loader(): + """returns a FunctionLoader""" + return loaders.FunctionLoader({"justfunction.html": "FOO"}.get) + + +@pytest.fixture +def choice_loader(dict_loader, package_loader): + """returns a ChoiceLoader""" + return loaders.ChoiceLoader([dict_loader, package_loader]) + + +@pytest.fixture +def prefix_loader(filesystem_loader, dict_loader): + """returns a PrefixLoader""" + return loaders.PrefixLoader({"a": filesystem_loader, "b": dict_loader}) diff --git a/tests/res/__init__.py b/tests/res/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/res/__init__.py diff --git a/tests/res/package.zip b/tests/res/package.zip Binary files differnew file mode 100644 index 0000000..d4c9ce9 --- /dev/null +++ b/tests/res/package.zip diff --git a/tests/res/templates/broken.html b/tests/res/templates/broken.html new file mode 100644 index 0000000..77669fa --- /dev/null +++ b/tests/res/templates/broken.html @@ -0,0 +1,3 @@ +Before +{{ fail() }} +After diff --git a/tests/res/templates/foo/test.html b/tests/res/templates/foo/test.html new file mode 100644 index 0000000..b7d6715 --- /dev/null +++ b/tests/res/templates/foo/test.html @@ -0,0 +1 @@ +FOO diff --git a/tests/res/templates/mojibake.txt b/tests/res/templates/mojibake.txt new file mode 100644 index 0000000..4b94aa6 --- /dev/null +++ b/tests/res/templates/mojibake.txt @@ -0,0 +1 @@ +文字化け diff --git a/tests/res/templates/syntaxerror.html b/tests/res/templates/syntaxerror.html new file mode 100644 index 0000000..f21b817 --- /dev/null +++ b/tests/res/templates/syntaxerror.html @@ -0,0 +1,4 @@ +Foo +{% for item in broken %} + ... +{% endif %} diff --git a/tests/res/templates/test.html b/tests/res/templates/test.html new file mode 100644 index 0000000..ba578e4 --- /dev/null +++ b/tests/res/templates/test.html @@ -0,0 +1 @@ +BAR diff --git a/tests/res/templates2/foo b/tests/res/templates2/foo new file mode 100644 index 0000000..1c4ad3e --- /dev/null +++ b/tests/res/templates2/foo @@ -0,0 +1,2 @@ +Looks like the start of templates/foo/test.html +Tested by test_filesystem_loader_overlapping_names diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..4db3b4a --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,434 @@ +import shutil +import tempfile +from pathlib import Path + +import pytest + +from jinja2 import ChainableUndefined +from jinja2 import DebugUndefined +from jinja2 import DictLoader +from jinja2 import Environment +from jinja2 import is_undefined +from jinja2 import make_logging_undefined +from jinja2 import meta +from jinja2 import StrictUndefined +from jinja2 import Template +from jinja2 import TemplatesNotFound +from jinja2 import Undefined +from jinja2 import UndefinedError +from jinja2.compiler import CodeGenerator +from jinja2.runtime import Context +from jinja2.utils import Cycler +from jinja2.utils import pass_context +from jinja2.utils import pass_environment +from jinja2.utils import pass_eval_context + + +class TestExtendedAPI: + def test_item_and_attribute(self, env): + from jinja2.sandbox import SandboxedEnvironment + + for env in Environment(), SandboxedEnvironment(): + tmpl = env.from_string("{{ foo.items()|list }}") + assert tmpl.render(foo={"items": 42}) == "[('items', 42)]" + tmpl = env.from_string('{{ foo|attr("items")()|list }}') + assert tmpl.render(foo={"items": 42}) == "[('items', 42)]" + tmpl = env.from_string('{{ foo["items"] }}') + assert tmpl.render(foo={"items": 42}) == "42" + + def test_finalize(self): + e = Environment(finalize=lambda v: "" if v is None else v) + t = e.from_string("{% for item in seq %}|{{ item }}{% endfor %}") + assert t.render(seq=(None, 1, "foo")) == "||1|foo" + + def test_finalize_constant_expression(self): + e = Environment(finalize=lambda v: "" if v is None else v) + t = e.from_string("<{{ none }}>") + assert t.render() == "<>" + + def test_no_finalize_template_data(self): + e = Environment(finalize=lambda v: type(v).__name__) + t = e.from_string("<{{ value }}>") + # If template data was finalized, it would print "strintstr". + assert t.render(value=123) == "<int>" + + def test_context_finalize(self): + @pass_context + def finalize(context, value): + return value * context["scale"] + + e = Environment(finalize=finalize) + t = e.from_string("{{ value }}") + assert t.render(value=5, scale=3) == "15" + + def test_eval_finalize(self): + @pass_eval_context + def finalize(eval_ctx, value): + return str(eval_ctx.autoescape) + value + + e = Environment(finalize=finalize, autoescape=True) + t = e.from_string("{{ value }}") + assert t.render(value="<script>") == "True<script>" + + def test_env_autoescape(self): + @pass_environment + def finalize(env, value): + return " ".join( + (env.variable_start_string, repr(value), env.variable_end_string) + ) + + e = Environment(finalize=finalize) + t = e.from_string("{{ value }}") + assert t.render(value="hello") == "{{ 'hello' }}" + + def test_cycler(self, env): + items = 1, 2, 3 + c = Cycler(*items) + for item in items + items: + assert c.current == item + assert next(c) == item + next(c) + assert c.current == 2 + c.reset() + assert c.current == 1 + + def test_expressions(self, env): + expr = env.compile_expression("foo") + assert expr() is None + assert expr(foo=42) == 42 + expr2 = env.compile_expression("foo", undefined_to_none=False) + assert is_undefined(expr2()) + + expr = env.compile_expression("42 + foo") + assert expr(foo=42) == 84 + + def test_template_passthrough(self, env): + t = Template("Content") + assert env.get_template(t) is t + assert env.select_template([t]) is t + assert env.get_or_select_template([t]) is t + assert env.get_or_select_template(t) is t + + def test_get_template_undefined(self, env): + """Passing Undefined to get/select_template raises an + UndefinedError or shows the undefined message in the list. + """ + env.loader = DictLoader({}) + t = Undefined(name="no_name_1") + + with pytest.raises(UndefinedError): + env.get_template(t) + + with pytest.raises(UndefinedError): + env.get_or_select_template(t) + + with pytest.raises(UndefinedError): + env.select_template(t) + + with pytest.raises(TemplatesNotFound) as exc_info: + env.select_template([t, "no_name_2"]) + + exc_message = str(exc_info.value) + assert "'no_name_1' is undefined" in exc_message + assert "no_name_2" in exc_message + + def test_autoescape_autoselect(self, env): + def select_autoescape(name): + if name is None or "." not in name: + return False + return name.endswith(".html") + + env = Environment( + autoescape=select_autoescape, + loader=DictLoader({"test.txt": "{{ foo }}", "test.html": "{{ foo }}"}), + ) + t = env.get_template("test.txt") + assert t.render(foo="<foo>") == "<foo>" + t = env.get_template("test.html") + assert t.render(foo="<foo>") == "<foo>" + t = env.from_string("{{ foo }}") + assert t.render(foo="<foo>") == "<foo>" + + def test_sandbox_max_range(self, env): + from jinja2.sandbox import SandboxedEnvironment, MAX_RANGE + + env = SandboxedEnvironment() + t = env.from_string("{% for item in range(total) %}{{ item }}{% endfor %}") + + with pytest.raises(OverflowError): + t.render(total=MAX_RANGE + 1) + + +class TestMeta: + def test_find_undeclared_variables(self, env): + ast = env.parse("{% set foo = 42 %}{{ bar + foo }}") + x = meta.find_undeclared_variables(ast) + assert x == {"bar"} + + ast = env.parse( + "{% set foo = 42 %}{{ bar + foo }}" + "{% macro meh(x) %}{{ x }}{% endmacro %}" + "{% for item in seq %}{{ muh(item) + meh(seq) }}" + "{% endfor %}" + ) + x = meta.find_undeclared_variables(ast) + assert x == {"bar", "seq", "muh"} + + ast = env.parse("{% for x in range(5) %}{{ x }}{% endfor %}{{ foo }}") + x = meta.find_undeclared_variables(ast) + assert x == {"foo"} + + def test_find_refererenced_templates(self, env): + ast = env.parse('{% extends "layout.html" %}{% include helper %}') + i = meta.find_referenced_templates(ast) + assert next(i) == "layout.html" + assert next(i) is None + assert list(i) == [] + + ast = env.parse( + '{% extends "layout.html" %}' + '{% from "test.html" import a, b as c %}' + '{% import "meh.html" as meh %}' + '{% include "muh.html" %}' + ) + i = meta.find_referenced_templates(ast) + assert list(i) == ["layout.html", "test.html", "meh.html", "muh.html"] + + def test_find_included_templates(self, env): + ast = env.parse('{% include ["foo.html", "bar.html"] %}') + i = meta.find_referenced_templates(ast) + assert list(i) == ["foo.html", "bar.html"] + + ast = env.parse('{% include ("foo.html", "bar.html") %}') + i = meta.find_referenced_templates(ast) + assert list(i) == ["foo.html", "bar.html"] + + ast = env.parse('{% include ["foo.html", "bar.html", foo] %}') + i = meta.find_referenced_templates(ast) + assert list(i) == ["foo.html", "bar.html", None] + + ast = env.parse('{% include ("foo.html", "bar.html", foo) %}') + i = meta.find_referenced_templates(ast) + assert list(i) == ["foo.html", "bar.html", None] + + +class TestStreaming: + def test_basic_streaming(self, env): + t = env.from_string( + "<ul>{% for item in seq %}<li>{{ loop.index }} - {{ item }}</li>" + "{%- endfor %}</ul>" + ) + stream = t.stream(seq=list(range(3))) + assert next(stream) == "<ul>" + assert "".join(stream) == "<li>1 - 0</li><li>2 - 1</li><li>3 - 2</li></ul>" + + def test_buffered_streaming(self, env): + tmpl = env.from_string( + "<ul>{% for item in seq %}<li>{{ loop.index }} - {{ item }}</li>" + "{%- endfor %}</ul>" + ) + stream = tmpl.stream(seq=list(range(3))) + stream.enable_buffering(size=3) + assert next(stream) == "<ul><li>1" + assert next(stream) == " - 0</li>" + + def test_streaming_behavior(self, env): + tmpl = env.from_string("") + stream = tmpl.stream() + assert not stream.buffered + stream.enable_buffering(20) + assert stream.buffered + stream.disable_buffering() + assert not stream.buffered + + def test_dump_stream(self, env): + tmp = Path(tempfile.mkdtemp()) + try: + tmpl = env.from_string("\u2713") + stream = tmpl.stream() + stream.dump(str(tmp / "dump.txt"), "utf-8") + assert (tmp / "dump.txt").read_bytes() == b"\xe2\x9c\x93" + finally: + shutil.rmtree(tmp) + + +class TestUndefined: + def test_stopiteration_is_undefined(self): + def test(): + raise StopIteration() + + t = Template("A{{ test() }}B") + assert t.render(test=test) == "AB" + t = Template("A{{ test().missingattribute }}B") + pytest.raises(UndefinedError, t.render, test=test) + + def test_undefined_and_special_attributes(self): + with pytest.raises(AttributeError): + Undefined("Foo").__dict__ + + def test_undefined_attribute_error(self): + # Django's LazyObject turns the __class__ attribute into a + # property that resolves the wrapped function. If that wrapped + # function raises an AttributeError, printing the repr of the + # object in the undefined message would cause a RecursionError. + class Error: + @property # type: ignore + def __class__(self): + raise AttributeError() + + u = Undefined(obj=Error(), name="hello") + + with pytest.raises(UndefinedError): + getattr(u, "recursion", None) + + def test_logging_undefined(self): + _messages = [] + + class DebugLogger: + def warning(self, msg, *args): + _messages.append("W:" + msg % args) + + def error(self, msg, *args): + _messages.append("E:" + msg % args) + + logging_undefined = make_logging_undefined(DebugLogger()) + env = Environment(undefined=logging_undefined) + assert env.from_string("{{ missing }}").render() == "" + pytest.raises(UndefinedError, env.from_string("{{ missing.attribute }}").render) + assert env.from_string("{{ missing|list }}").render() == "[]" + assert env.from_string("{{ missing is not defined }}").render() == "True" + assert env.from_string("{{ foo.missing }}").render(foo=42) == "" + assert env.from_string("{{ not missing }}").render() == "True" + assert _messages == [ + "W:Template variable warning: 'missing' is undefined", + "E:Template variable error: 'missing' is undefined", + "W:Template variable warning: 'missing' is undefined", + "W:Template variable warning: 'int object' has no attribute 'missing'", + "W:Template variable warning: 'missing' is undefined", + ] + + def test_default_undefined(self): + env = Environment(undefined=Undefined) + assert env.from_string("{{ missing }}").render() == "" + pytest.raises(UndefinedError, env.from_string("{{ missing.attribute }}").render) + assert env.from_string("{{ missing|list }}").render() == "[]" + assert env.from_string("{{ missing is not defined }}").render() == "True" + assert env.from_string("{{ foo.missing }}").render(foo=42) == "" + assert env.from_string("{{ not missing }}").render() == "True" + pytest.raises(UndefinedError, env.from_string("{{ missing - 1}}").render) + assert env.from_string("{{ 'foo' in missing }}").render() == "False" + und1 = Undefined(name="x") + und2 = Undefined(name="y") + assert und1 == und2 + assert und1 != 42 + assert hash(und1) == hash(und2) == hash(Undefined()) + with pytest.raises(AttributeError): + getattr(Undefined, "__slots__") # noqa: B009 + + def test_chainable_undefined(self): + env = Environment(undefined=ChainableUndefined) + # The following tests are copied from test_default_undefined + assert env.from_string("{{ missing }}").render() == "" + assert env.from_string("{{ missing|list }}").render() == "[]" + assert env.from_string("{{ missing is not defined }}").render() == "True" + assert env.from_string("{{ foo.missing }}").render(foo=42) == "" + assert env.from_string("{{ not missing }}").render() == "True" + pytest.raises(UndefinedError, env.from_string("{{ missing - 1}}").render) + with pytest.raises(AttributeError): + getattr(ChainableUndefined, "__slots__") # noqa: B009 + + # The following tests ensure subclass functionality works as expected + assert env.from_string('{{ missing.bar["baz"] }}').render() == "" + assert env.from_string('{{ foo.bar["baz"]._undefined_name }}').render() == "foo" + assert ( + env.from_string('{{ foo.bar["baz"]._undefined_name }}').render(foo=42) + == "bar" + ) + assert ( + env.from_string('{{ foo.bar["baz"]._undefined_name }}').render( + foo={"bar": 42} + ) + == "baz" + ) + + def test_debug_undefined(self): + env = Environment(undefined=DebugUndefined) + assert env.from_string("{{ missing }}").render() == "{{ missing }}" + pytest.raises(UndefinedError, env.from_string("{{ missing.attribute }}").render) + assert env.from_string("{{ missing|list }}").render() == "[]" + assert env.from_string("{{ missing is not defined }}").render() == "True" + assert ( + env.from_string("{{ foo.missing }}").render(foo=42) + == "{{ no such element: int object['missing'] }}" + ) + assert env.from_string("{{ not missing }}").render() == "True" + undefined_hint = "this is testing undefined hint of DebugUndefined" + assert ( + str(DebugUndefined(hint=undefined_hint)) + == f"{{{{ undefined value printed: {undefined_hint} }}}}" + ) + with pytest.raises(AttributeError): + getattr(DebugUndefined, "__slots__") # noqa: B009 + + def test_strict_undefined(self): + env = Environment(undefined=StrictUndefined) + pytest.raises(UndefinedError, env.from_string("{{ missing }}").render) + pytest.raises(UndefinedError, env.from_string("{{ missing.attribute }}").render) + pytest.raises(UndefinedError, env.from_string("{{ missing|list }}").render) + pytest.raises(UndefinedError, env.from_string("{{ 'foo' in missing }}").render) + assert env.from_string("{{ missing is not defined }}").render() == "True" + pytest.raises( + UndefinedError, env.from_string("{{ foo.missing }}").render, foo=42 + ) + pytest.raises(UndefinedError, env.from_string("{{ not missing }}").render) + assert ( + env.from_string('{{ missing|default("default", true) }}').render() + == "default" + ) + with pytest.raises(AttributeError): + getattr(StrictUndefined, "__slots__") # noqa: B009 + assert env.from_string('{{ "foo" if false }}').render() == "" + + def test_indexing_gives_undefined(self): + t = Template("{{ var[42].foo }}") + pytest.raises(UndefinedError, t.render, var=0) + + def test_none_gives_proper_error(self): + with pytest.raises(UndefinedError, match="'None' has no attribute 'split'"): + Environment().getattr(None, "split")() + + def test_object_repr(self): + with pytest.raises( + UndefinedError, match="'int object' has no attribute 'upper'" + ): + Undefined(obj=42, name="upper")() + + +class TestLowLevel: + def test_custom_code_generator(self): + class CustomCodeGenerator(CodeGenerator): + def visit_Const(self, node, frame=None): + # This method is pure nonsense, but works fine for testing... + if node.value == "foo": + self.write(repr("bar")) + else: + super().visit_Const(node, frame) + + class CustomEnvironment(Environment): + code_generator_class = CustomCodeGenerator + + env = CustomEnvironment() + tmpl = env.from_string('{% set foo = "foo" %}{{ foo }}') + assert tmpl.render() == "bar" + + def test_custom_context(self): + class CustomContext(Context): + def resolve_or_missing(self, key): + return "resolve-" + key + + class CustomEnvironment(Environment): + context_class = CustomContext + + env = CustomEnvironment() + tmpl = env.from_string("{{ foo }}") + assert tmpl.render() == "resolve-foo" diff --git a/tests/test_async.py b/tests/test_async.py new file mode 100644 index 0000000..c9ba70c --- /dev/null +++ b/tests/test_async.py @@ -0,0 +1,660 @@ +import asyncio + +import pytest + +from jinja2 import ChainableUndefined +from jinja2 import DictLoader +from jinja2 import Environment +from jinja2 import Template +from jinja2.async_utils import auto_aiter +from jinja2.exceptions import TemplateNotFound +from jinja2.exceptions import TemplatesNotFound +from jinja2.exceptions import UndefinedError +from jinja2.nativetypes import NativeEnvironment + + +def test_basic_async(): + t = Template( + "{% for item in [1, 2, 3] %}[{{ item }}]{% endfor %}", enable_async=True + ) + + async def func(): + return await t.render_async() + + rv = asyncio.run(func()) + assert rv == "[1][2][3]" + + +def test_await_on_calls(): + t = Template("{{ async_func() + normal_func() }}", enable_async=True) + + async def async_func(): + return 42 + + def normal_func(): + return 23 + + async def func(): + return await t.render_async(async_func=async_func, normal_func=normal_func) + + rv = asyncio.run(func()) + assert rv == "65" + + +def test_await_on_calls_normal_render(): + t = Template("{{ async_func() + normal_func() }}", enable_async=True) + + async def async_func(): + return 42 + + def normal_func(): + return 23 + + rv = t.render(async_func=async_func, normal_func=normal_func) + assert rv == "65" + + +def test_await_and_macros(): + t = Template( + "{% macro foo(x) %}[{{ x }}][{{ async_func() }}]{% endmacro %}{{ foo(42) }}", + enable_async=True, + ) + + async def async_func(): + return 42 + + async def func(): + return await t.render_async(async_func=async_func) + + rv = asyncio.run(func()) + assert rv == "[42][42]" + + +def test_async_blocks(): + t = Template( + "{% block foo %}<Test>{% endblock %}{{ self.foo() }}", + enable_async=True, + autoescape=True, + ) + + async def func(): + return await t.render_async() + + rv = asyncio.run(func()) + assert rv == "<Test><Test>" + + +def test_async_generate(): + t = Template("{% for x in [1, 2, 3] %}{{ x }}{% endfor %}", enable_async=True) + rv = list(t.generate()) + assert rv == ["1", "2", "3"] + + +def test_async_iteration_in_templates(): + t = Template("{% for x in rng %}{{ x }}{% endfor %}", enable_async=True) + + async def async_iterator(): + for item in [1, 2, 3]: + yield item + + rv = list(t.generate(rng=async_iterator())) + assert rv == ["1", "2", "3"] + + +def test_async_iteration_in_templates_extended(): + t = Template( + "{% for x in rng %}{{ loop.index0 }}/{{ x }}{% endfor %}", enable_async=True + ) + stream = t.generate(rng=auto_aiter(range(1, 4))) + assert next(stream) == "0" + assert "".join(stream) == "/11/22/3" + + +@pytest.fixture +def test_env_async(): + env = Environment( + loader=DictLoader( + dict( + module="{% macro test() %}[{{ foo }}|{{ bar }}]{% endmacro %}", + header="[{{ foo }}|{{ 23 }}]", + o_printer="({{ o }})", + ) + ), + enable_async=True, + ) + env.globals["bar"] = 23 + return env + + +class TestAsyncImports: + def test_context_imports(self, test_env_async): + t = test_env_async.from_string('{% import "module" as m %}{{ m.test() }}') + assert t.render(foo=42) == "[|23]" + t = test_env_async.from_string( + '{% import "module" as m without context %}{{ m.test() }}' + ) + assert t.render(foo=42) == "[|23]" + t = test_env_async.from_string( + '{% import "module" as m with context %}{{ m.test() }}' + ) + assert t.render(foo=42) == "[42|23]" + t = test_env_async.from_string('{% from "module" import test %}{{ test() }}') + assert t.render(foo=42) == "[|23]" + t = test_env_async.from_string( + '{% from "module" import test without context %}{{ test() }}' + ) + assert t.render(foo=42) == "[|23]" + t = test_env_async.from_string( + '{% from "module" import test with context %}{{ test() }}' + ) + assert t.render(foo=42) == "[42|23]" + + def test_trailing_comma(self, test_env_async): + test_env_async.from_string('{% from "foo" import bar, baz with context %}') + test_env_async.from_string('{% from "foo" import bar, baz, with context %}') + test_env_async.from_string('{% from "foo" import bar, with context %}') + test_env_async.from_string('{% from "foo" import bar, with, context %}') + test_env_async.from_string('{% from "foo" import bar, with with context %}') + + def test_exports(self, test_env_async): + coro = test_env_async.from_string( + """ + {% macro toplevel() %}...{% endmacro %} + {% macro __private() %}...{% endmacro %} + {% set variable = 42 %} + {% for item in [1] %} + {% macro notthere() %}{% endmacro %} + {% endfor %} + """ + )._get_default_module_async() + m = asyncio.run(coro) + assert asyncio.run(m.toplevel()) == "..." + assert not hasattr(m, "__missing") + assert m.variable == 42 + assert not hasattr(m, "notthere") + + def test_import_with_globals(self, test_env_async): + t = test_env_async.from_string( + '{% import "module" as m %}{{ m.test() }}', globals={"foo": 42} + ) + assert t.render() == "[42|23]" + + t = test_env_async.from_string('{% import "module" as m %}{{ m.test() }}') + assert t.render() == "[|23]" + + def test_import_with_globals_override(self, test_env_async): + t = test_env_async.from_string( + '{% set foo = 41 %}{% import "module" as m %}{{ m.test() }}', + globals={"foo": 42}, + ) + assert t.render() == "[42|23]" + + def test_from_import_with_globals(self, test_env_async): + t = test_env_async.from_string( + '{% from "module" import test %}{{ test() }}', + globals={"foo": 42}, + ) + assert t.render() == "[42|23]" + + +class TestAsyncIncludes: + def test_context_include(self, test_env_async): + t = test_env_async.from_string('{% include "header" %}') + assert t.render(foo=42) == "[42|23]" + t = test_env_async.from_string('{% include "header" with context %}') + assert t.render(foo=42) == "[42|23]" + t = test_env_async.from_string('{% include "header" without context %}') + assert t.render(foo=42) == "[|23]" + + def test_choice_includes(self, test_env_async): + t = test_env_async.from_string('{% include ["missing", "header"] %}') + assert t.render(foo=42) == "[42|23]" + + t = test_env_async.from_string( + '{% include ["missing", "missing2"] ignore missing %}' + ) + assert t.render(foo=42) == "" + + t = test_env_async.from_string('{% include ["missing", "missing2"] %}') + pytest.raises(TemplateNotFound, t.render) + with pytest.raises(TemplatesNotFound) as e: + t.render() + + assert e.value.templates == ["missing", "missing2"] + assert e.value.name == "missing2" + + def test_includes(t, **ctx): + ctx["foo"] = 42 + assert t.render(ctx) == "[42|23]" + + t = test_env_async.from_string('{% include ["missing", "header"] %}') + test_includes(t) + t = test_env_async.from_string("{% include x %}") + test_includes(t, x=["missing", "header"]) + t = test_env_async.from_string('{% include [x, "header"] %}') + test_includes(t, x="missing") + t = test_env_async.from_string("{% include x %}") + test_includes(t, x="header") + t = test_env_async.from_string("{% include x %}") + test_includes(t, x="header") + t = test_env_async.from_string("{% include [x] %}") + test_includes(t, x="header") + + def test_include_ignoring_missing(self, test_env_async): + t = test_env_async.from_string('{% include "missing" %}') + pytest.raises(TemplateNotFound, t.render) + for extra in "", "with context", "without context": + t = test_env_async.from_string( + '{% include "missing" ignore missing ' + extra + " %}" + ) + assert t.render() == "" + + def test_context_include_with_overrides(self, test_env_async): + env = Environment( + loader=DictLoader( + dict( + main="{% for item in [1, 2, 3] %}{% include 'item' %}{% endfor %}", + item="{{ item }}", + ) + ) + ) + assert env.get_template("main").render() == "123" + + def test_unoptimized_scopes(self, test_env_async): + t = test_env_async.from_string( + """ + {% macro outer(o) %} + {% macro inner() %} + {% include "o_printer" %} + {% endmacro %} + {{ inner() }} + {% endmacro %} + {{ outer("FOO") }} + """ + ) + assert t.render().strip() == "(FOO)" + + def test_unoptimized_scopes_autoescape(self): + env = Environment( + loader=DictLoader({"o_printer": "({{ o }})"}), + autoescape=True, + enable_async=True, + ) + t = env.from_string( + """ + {% macro outer(o) %} + {% macro inner() %} + {% include "o_printer" %} + {% endmacro %} + {{ inner() }} + {% endmacro %} + {{ outer("FOO") }} + """ + ) + assert t.render().strip() == "(FOO)" + + +class TestAsyncForLoop: + def test_simple(self, test_env_async): + tmpl = test_env_async.from_string("{% for item in seq %}{{ item }}{% endfor %}") + assert tmpl.render(seq=list(range(10))) == "0123456789" + + def test_else(self, test_env_async): + tmpl = test_env_async.from_string( + "{% for item in seq %}XXX{% else %}...{% endfor %}" + ) + assert tmpl.render() == "..." + + def test_empty_blocks(self, test_env_async): + tmpl = test_env_async.from_string( + "<{% for item in seq %}{% else %}{% endfor %}>" + ) + assert tmpl.render() == "<>" + + @pytest.mark.parametrize( + "transform", [lambda x: x, iter, reversed, lambda x: (i for i in x), auto_aiter] + ) + def test_context_vars(self, test_env_async, transform): + t = test_env_async.from_string( + "{% for item in seq %}{{ loop.index }}|{{ loop.index0 }}" + "|{{ loop.revindex }}|{{ loop.revindex0 }}|{{ loop.first }}" + "|{{ loop.last }}|{{ loop.length }}\n{% endfor %}" + ) + out = t.render(seq=transform([42, 24])) + assert out == "1|0|2|1|True|False|2\n2|1|1|0|False|True|2\n" + + def test_cycling(self, test_env_async): + tmpl = test_env_async.from_string( + """{% for item in seq %}{{ + loop.cycle('<1>', '<2>') }}{% endfor %}{% + for item in seq %}{{ loop.cycle(*through) }}{% endfor %}""" + ) + output = tmpl.render(seq=list(range(4)), through=("<1>", "<2>")) + assert output == "<1><2>" * 4 + + def test_lookaround(self, test_env_async): + tmpl = test_env_async.from_string( + """{% for item in seq -%} + {{ loop.previtem|default('x') }}-{{ item }}-{{ + loop.nextitem|default('x') }}| + {%- endfor %}""" + ) + output = tmpl.render(seq=list(range(4))) + assert output == "x-0-1|0-1-2|1-2-3|2-3-x|" + + def test_changed(self, test_env_async): + tmpl = test_env_async.from_string( + """{% for item in seq -%} + {{ loop.changed(item) }}, + {%- endfor %}""" + ) + output = tmpl.render(seq=[None, None, 1, 2, 2, 3, 4, 4, 4]) + assert output == "True,False,True,True,False,True,True,False,False," + + def test_scope(self, test_env_async): + tmpl = test_env_async.from_string("{% for item in seq %}{% endfor %}{{ item }}") + output = tmpl.render(seq=list(range(10))) + assert not output + + def test_varlen(self, test_env_async): + def inner(): + yield from range(5) + + tmpl = test_env_async.from_string( + "{% for item in iter %}{{ item }}{% endfor %}" + ) + output = tmpl.render(iter=inner()) + assert output == "01234" + + def test_noniter(self, test_env_async): + tmpl = test_env_async.from_string("{% for item in none %}...{% endfor %}") + pytest.raises(TypeError, tmpl.render) + + def test_recursive(self, test_env_async): + tmpl = test_env_async.from_string( + """{% for item in seq recursive -%} + [{{ item.a }}{% if item.b %}<{{ loop(item.b) }}>{% endif %}] + {%- endfor %}""" + ) + assert ( + tmpl.render( + seq=[ + dict(a=1, b=[dict(a=1), dict(a=2)]), + dict(a=2, b=[dict(a=1), dict(a=2)]), + dict(a=3, b=[dict(a="a")]), + ] + ) + == "[1<[1][2]>][2<[1][2]>][3<[a]>]" + ) + + def test_recursive_lookaround(self, test_env_async): + tmpl = test_env_async.from_string( + """{% for item in seq recursive -%} + [{{ loop.previtem.a if loop.previtem is defined else 'x' }}.{{ + item.a }}.{{ loop.nextitem.a if loop.nextitem is defined else 'x' + }}{% if item.b %}<{{ loop(item.b) }}>{% endif %}] + {%- endfor %}""" + ) + assert ( + tmpl.render( + seq=[ + dict(a=1, b=[dict(a=1), dict(a=2)]), + dict(a=2, b=[dict(a=1), dict(a=2)]), + dict(a=3, b=[dict(a="a")]), + ] + ) + == "[x.1.2<[x.1.2][1.2.x]>][1.2.3<[x.1.2][1.2.x]>][2.3.x<[x.a.x]>]" + ) + + def test_recursive_depth0(self, test_env_async): + tmpl = test_env_async.from_string( + "{% for item in seq recursive %}[{{ loop.depth0 }}:{{ item.a }}" + "{% if item.b %}<{{ loop(item.b) }}>{% endif %}]{% endfor %}" + ) + assert ( + tmpl.render( + seq=[ + dict(a=1, b=[dict(a=1), dict(a=2)]), + dict(a=2, b=[dict(a=1), dict(a=2)]), + dict(a=3, b=[dict(a="a")]), + ] + ) + == "[0:1<[1:1][1:2]>][0:2<[1:1][1:2]>][0:3<[1:a]>]" + ) + + def test_recursive_depth(self, test_env_async): + tmpl = test_env_async.from_string( + "{% for item in seq recursive %}[{{ loop.depth }}:{{ item.a }}" + "{% if item.b %}<{{ loop(item.b) }}>{% endif %}]{% endfor %}" + ) + assert ( + tmpl.render( + seq=[ + dict(a=1, b=[dict(a=1), dict(a=2)]), + dict(a=2, b=[dict(a=1), dict(a=2)]), + dict(a=3, b=[dict(a="a")]), + ] + ) + == "[1:1<[2:1][2:2]>][1:2<[2:1][2:2]>][1:3<[2:a]>]" + ) + + def test_looploop(self, test_env_async): + tmpl = test_env_async.from_string( + """{% for row in table %} + {%- set rowloop = loop -%} + {% for cell in row -%} + [{{ rowloop.index }}|{{ loop.index }}] + {%- endfor %} + {%- endfor %}""" + ) + assert tmpl.render(table=["ab", "cd"]) == "[1|1][1|2][2|1][2|2]" + + def test_reversed_bug(self, test_env_async): + tmpl = test_env_async.from_string( + "{% for i in items %}{{ i }}" + "{% if not loop.last %}" + ",{% endif %}{% endfor %}" + ) + assert tmpl.render(items=reversed([3, 2, 1])) == "1,2,3" + + def test_loop_errors(self, test_env_async): + tmpl = test_env_async.from_string( + """{% for item in [1] if loop.index + == 0 %}...{% endfor %}""" + ) + pytest.raises(UndefinedError, tmpl.render) + tmpl = test_env_async.from_string( + """{% for item in [] %}...{% else + %}{{ loop }}{% endfor %}""" + ) + assert tmpl.render() == "" + + def test_loop_filter(self, test_env_async): + tmpl = test_env_async.from_string( + "{% for item in range(10) if item is even %}[{{ item }}]{% endfor %}" + ) + assert tmpl.render() == "[0][2][4][6][8]" + tmpl = test_env_async.from_string( + """ + {%- for item in range(10) if item is even %}[{{ + loop.index }}:{{ item }}]{% endfor %}""" + ) + assert tmpl.render() == "[1:0][2:2][3:4][4:6][5:8]" + + def test_scoped_special_var(self, test_env_async): + t = test_env_async.from_string( + "{% for s in seq %}[{{ loop.first }}{% for c in s %}" + "|{{ loop.first }}{% endfor %}]{% endfor %}" + ) + assert t.render(seq=("ab", "cd")) == "[True|True|False][False|True|False]" + + def test_scoped_loop_var(self, test_env_async): + t = test_env_async.from_string( + "{% for x in seq %}{{ loop.first }}" + "{% for y in seq %}{% endfor %}{% endfor %}" + ) + assert t.render(seq="ab") == "TrueFalse" + t = test_env_async.from_string( + "{% for x in seq %}{% for y in seq %}" + "{{ loop.first }}{% endfor %}{% endfor %}" + ) + assert t.render(seq="ab") == "TrueFalseTrueFalse" + + def test_recursive_empty_loop_iter(self, test_env_async): + t = test_env_async.from_string( + """ + {%- for item in foo recursive -%}{%- endfor -%} + """ + ) + assert t.render(dict(foo=[])) == "" + + def test_call_in_loop(self, test_env_async): + t = test_env_async.from_string( + """ + {%- macro do_something() -%} + [{{ caller() }}] + {%- endmacro %} + + {%- for i in [1, 2, 3] %} + {%- call do_something() -%} + {{ i }} + {%- endcall %} + {%- endfor -%} + """ + ) + assert t.render() == "[1][2][3]" + + def test_scoping_bug(self, test_env_async): + t = test_env_async.from_string( + """ + {%- for item in foo %}...{{ item }}...{% endfor %} + {%- macro item(a) %}...{{ a }}...{% endmacro %} + {{- item(2) -}} + """ + ) + assert t.render(foo=(1,)) == "...1......2..." + + def test_unpacking(self, test_env_async): + tmpl = test_env_async.from_string( + "{% for a, b, c in [[1, 2, 3]] %}{{ a }}|{{ b }}|{{ c }}{% endfor %}" + ) + assert tmpl.render() == "1|2|3" + + def test_recursive_loop_filter(self, test_env_async): + t = test_env_async.from_string( + """ + <?xml version="1.0" encoding="UTF-8"?> + <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + {%- for page in [site.root] if page.url != this recursive %} + <url><loc>{{ page.url }}</loc></url> + {{- loop(page.children) }} + {%- endfor %} + </urlset> + """ + ) + sm = t.render( + this="/foo", + site={"root": {"url": "/", "children": [{"url": "/foo"}, {"url": "/bar"}]}}, + ) + lines = [x.strip() for x in sm.splitlines() if x.strip()] + assert lines == [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', + "<url><loc>/</loc></url>", + "<url><loc>/bar</loc></url>", + "</urlset>", + ] + + def test_nonrecursive_loop_filter(self, test_env_async): + t = test_env_async.from_string( + """ + <?xml version="1.0" encoding="UTF-8"?> + <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + {%- for page in items if page.url != this %} + <url><loc>{{ page.url }}</loc></url> + {%- endfor %} + </urlset> + """ + ) + sm = t.render( + this="/foo", items=[{"url": "/"}, {"url": "/foo"}, {"url": "/bar"}] + ) + lines = [x.strip() for x in sm.splitlines() if x.strip()] + assert lines == [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', + "<url><loc>/</loc></url>", + "<url><loc>/bar</loc></url>", + "</urlset>", + ] + + def test_bare_async(self, test_env_async): + t = test_env_async.from_string('{% extends "header" %}') + assert t.render(foo=42) == "[42|23]" + + def test_awaitable_property_slicing(self, test_env_async): + t = test_env_async.from_string("{% for x in a.b[:1] %}{{ x }}{% endfor %}") + assert t.render(a=dict(b=[1, 2, 3])) == "1" + + +def test_namespace_awaitable(test_env_async): + async def _test(): + t = test_env_async.from_string( + '{% set ns = namespace(foo="Bar") %}{{ ns.foo }}' + ) + actual = await t.render_async() + assert actual == "Bar" + + asyncio.run(_test()) + + +def test_chainable_undefined_aiter(): + async def _test(): + t = Template( + "{% for x in a['b']['c'] %}{{ x }}{% endfor %}", + enable_async=True, + undefined=ChainableUndefined, + ) + rv = await t.render_async(a={}) + assert rv == "" + + asyncio.run(_test()) + + +@pytest.fixture +def async_native_env(): + return NativeEnvironment(enable_async=True) + + +def test_native_async(async_native_env): + async def _test(): + t = async_native_env.from_string("{{ x }}") + rv = await t.render_async(x=23) + assert rv == 23 + + asyncio.run(_test()) + + +def test_native_list_async(async_native_env): + async def _test(): + t = async_native_env.from_string("{{ x }}") + rv = await t.render_async(x=list(range(3))) + assert rv == [0, 1, 2] + + asyncio.run(_test()) + + +def test_getitem_after_filter(): + env = Environment(enable_async=True) + env.filters["add_each"] = lambda v, x: [i + x for i in v] + t = env.from_string("{{ (a|add_each(2))[1:] }}") + out = t.render(a=range(3)) + assert out == "[3, 4]" + + +def test_getitem_after_call(): + env = Environment(enable_async=True) + env.globals["add_each"] = lambda v, x: [i + x for i in v] + t = env.from_string("{{ add_each(a, 2)[1:] }}") + out = t.render(a=range(3)) + assert out == "[3, 4]" diff --git a/tests/test_async_filters.py b/tests/test_async_filters.py new file mode 100644 index 0000000..f5b2627 --- /dev/null +++ b/tests/test_async_filters.py @@ -0,0 +1,273 @@ +from collections import namedtuple + +import pytest +from markupsafe import Markup + +from jinja2 import Environment +from jinja2.async_utils import auto_aiter + + +async def make_aiter(iter): + for item in iter: + yield item + + +def mark_dualiter(parameter, factory): + def decorator(f): + return pytest.mark.parametrize( + parameter, [lambda: factory(), lambda: make_aiter(factory())] + )(f) + + return decorator + + +@pytest.fixture +def env_async(): + return Environment(enable_async=True) + + +@mark_dualiter("foo", lambda: range(10)) +def test_first(env_async, foo): + tmpl = env_async.from_string("{{ foo()|first }}") + out = tmpl.render(foo=foo) + assert out == "0" + + +@mark_dualiter( + "items", + lambda: [ + {"foo": 1, "bar": 2}, + {"foo": 2, "bar": 3}, + {"foo": 1, "bar": 1}, + {"foo": 3, "bar": 4}, + ], +) +def test_groupby(env_async, items): + tmpl = env_async.from_string( + """ + {%- for grouper, list in items()|groupby('foo') -%} + {{ grouper }}{% for x in list %}: {{ x.foo }}, {{ x.bar }}{% endfor %}| + {%- endfor %}""" + ) + assert tmpl.render(items=items).split("|") == [ + "1: 1, 2: 1, 1", + "2: 2, 3", + "3: 3, 4", + "", + ] + + +@pytest.mark.parametrize( + ("case_sensitive", "expect"), + [ + (False, "a: 1, 3\nb: 2\n"), + (True, "A: 3\na: 1\nb: 2\n"), + ], +) +def test_groupby_case(env_async, case_sensitive, expect): + tmpl = env_async.from_string( + "{% for k, vs in data|groupby('k', case_sensitive=cs) %}" + "{{ k }}: {{ vs|join(', ', attribute='v') }}\n" + "{% endfor %}" + ) + out = tmpl.render( + data=[{"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "A", "v": 3}], + cs=case_sensitive, + ) + assert out == expect + + +@mark_dualiter("items", lambda: [("a", 1), ("a", 2), ("b", 1)]) +def test_groupby_tuple_index(env_async, items): + tmpl = env_async.from_string( + """ + {%- for grouper, list in items()|groupby(0) -%} + {{ grouper }}{% for x in list %}:{{ x.1 }}{% endfor %}| + {%- endfor %}""" + ) + assert tmpl.render(items=items) == "a:1:2|b:1|" + + +def make_articles(): + Date = namedtuple("Date", "day,month,year") + Article = namedtuple("Article", "title,date") + return [ + Article("aha", Date(1, 1, 1970)), + Article("interesting", Date(2, 1, 1970)), + Article("really?", Date(3, 1, 1970)), + Article("totally not", Date(1, 1, 1971)), + ] + + +@mark_dualiter("articles", make_articles) +def test_groupby_multidot(env_async, articles): + tmpl = env_async.from_string( + """ + {%- for year, list in articles()|groupby('date.year') -%} + {{ year }}{% for x in list %}[{{ x.title }}]{% endfor %}| + {%- endfor %}""" + ) + assert tmpl.render(articles=articles).split("|") == [ + "1970[aha][interesting][really?]", + "1971[totally not]", + "", + ] + + +@mark_dualiter("int_items", lambda: [1, 2, 3]) +def test_join_env_int(env_async, int_items): + tmpl = env_async.from_string('{{ items()|join("|") }}') + out = tmpl.render(items=int_items) + assert out == "1|2|3" + + +@mark_dualiter("string_items", lambda: ["<foo>", Markup("<span>foo</span>")]) +def test_join_string_list(string_items): + env2 = Environment(autoescape=True, enable_async=True) + tmpl = env2.from_string('{{ ["<foo>", "<span>foo</span>"|safe]|join }}') + assert tmpl.render(items=string_items) == "<foo><span>foo</span>" + + +def make_users(): + User = namedtuple("User", "username") + return map(User, ["foo", "bar"]) + + +@mark_dualiter("users", make_users) +def test_join_attribute(env_async, users): + tmpl = env_async.from_string("""{{ users()|join(', ', 'username') }}""") + assert tmpl.render(users=users) == "foo, bar" + + +@mark_dualiter("items", lambda: [1, 2, 3, 4, 5]) +def test_simple_reject(env_async, items): + tmpl = env_async.from_string('{{ items()|reject("odd")|join("|") }}') + assert tmpl.render(items=items) == "2|4" + + +@mark_dualiter("items", lambda: [None, False, 0, 1, 2, 3, 4, 5]) +def test_bool_reject(env_async, items): + tmpl = env_async.from_string('{{ items()|reject|join("|") }}') + assert tmpl.render(items=items) == "None|False|0" + + +@mark_dualiter("items", lambda: [1, 2, 3, 4, 5]) +def test_simple_select(env_async, items): + tmpl = env_async.from_string('{{ items()|select("odd")|join("|") }}') + assert tmpl.render(items=items) == "1|3|5" + + +@mark_dualiter("items", lambda: [None, False, 0, 1, 2, 3, 4, 5]) +def test_bool_select(env_async, items): + tmpl = env_async.from_string('{{ items()|select|join("|") }}') + assert tmpl.render(items=items) == "1|2|3|4|5" + + +def make_users(): # type: ignore + User = namedtuple("User", "name,is_active") + return [ + User("john", True), + User("jane", True), + User("mike", False), + ] + + +@mark_dualiter("users", make_users) +def test_simple_select_attr(env_async, users): + tmpl = env_async.from_string( + '{{ users()|selectattr("is_active")|map(attribute="name")|join("|") }}' + ) + assert tmpl.render(users=users) == "john|jane" + + +@mark_dualiter("items", lambda: list("123")) +def test_simple_map(env_async, items): + tmpl = env_async.from_string('{{ items()|map("int")|sum }}') + assert tmpl.render(items=items) == "6" + + +def test_map_sum(env_async): # async map + async filter + tmpl = env_async.from_string('{{ [[1,2], [3], [4,5,6]]|map("sum")|list }}') + assert tmpl.render() == "[3, 3, 15]" + + +@mark_dualiter("users", make_users) +def test_attribute_map(env_async, users): + tmpl = env_async.from_string('{{ users()|map(attribute="name")|join("|") }}') + assert tmpl.render(users=users) == "john|jane|mike" + + +def test_empty_map(env_async): + tmpl = env_async.from_string('{{ none|map("upper")|list }}') + assert tmpl.render() == "[]" + + +@mark_dualiter("items", lambda: [1, 2, 3, 4, 5, 6]) +def test_sum(env_async, items): + tmpl = env_async.from_string("""{{ items()|sum }}""") + assert tmpl.render(items=items) == "21" + + +@mark_dualiter("items", lambda: [{"value": 23}, {"value": 1}, {"value": 18}]) +def test_sum_attributes(env_async, items): + tmpl = env_async.from_string("""{{ items()|sum('value') }}""") + assert tmpl.render(items=items) + + +def test_sum_attributes_nested(env_async): + tmpl = env_async.from_string("""{{ values|sum('real.value') }}""") + assert ( + tmpl.render( + values=[ + {"real": {"value": 23}}, + {"real": {"value": 1}}, + {"real": {"value": 18}}, + ] + ) + == "42" + ) + + +def test_sum_attributes_tuple(env_async): + tmpl = env_async.from_string("""{{ values.items()|sum('1') }}""") + assert tmpl.render(values={"foo": 23, "bar": 1, "baz": 18}) == "42" + + +@mark_dualiter("items", lambda: range(10)) +def test_slice(env_async, items): + tmpl = env_async.from_string( + "{{ items()|slice(3)|list }}|{{ items()|slice(3, 'X')|list }}" + ) + out = tmpl.render(items=items) + assert out == ( + "[[0, 1, 2, 3], [4, 5, 6], [7, 8, 9]]|" + "[[0, 1, 2, 3], [4, 5, 6, 'X'], [7, 8, 9, 'X']]" + ) + + +def test_custom_async_filter(env_async): + async def customfilter(val): + return str(val) + + env_async.filters["customfilter"] = customfilter + tmpl = env_async.from_string("{{ 'static'|customfilter }} {{ arg|customfilter }}") + out = tmpl.render(arg="dynamic") + assert out == "static dynamic" + + +@mark_dualiter("items", lambda: range(10)) +def test_custom_async_iteratable_filter(env_async, items): + async def customfilter(iterable): + items = [] + async for item in auto_aiter(iterable): + items.append(str(item)) + if len(items) == 3: + break + return ",".join(items) + + env_async.filters["customfilter"] = customfilter + tmpl = env_async.from_string( + "{{ items()|customfilter }} .. {{ [3, 4, 5, 6]|customfilter }}" + ) + out = tmpl.render(items=items) + assert out == "0,1,2 .. 3,4,5" diff --git a/tests/test_bytecode_cache.py b/tests/test_bytecode_cache.py new file mode 100644 index 0000000..5b9eb0f --- /dev/null +++ b/tests/test_bytecode_cache.py @@ -0,0 +1,77 @@ +import pytest + +from jinja2 import Environment +from jinja2.bccache import Bucket +from jinja2.bccache import FileSystemBytecodeCache +from jinja2.bccache import MemcachedBytecodeCache +from jinja2.exceptions import TemplateNotFound + + +@pytest.fixture +def env(package_loader, tmp_path): + bytecode_cache = FileSystemBytecodeCache(str(tmp_path)) + return Environment(loader=package_loader, bytecode_cache=bytecode_cache) + + +class TestByteCodeCache: + def test_simple(self, env): + tmpl = env.get_template("test.html") + assert tmpl.render().strip() == "BAR" + pytest.raises(TemplateNotFound, env.get_template, "missing.html") + + +class MockMemcached: + class Error(Exception): + pass + + key = None + value = None + timeout = None + + def get(self, key): + return self.value + + def set(self, key, value, timeout=None): + self.key = key + self.value = value + self.timeout = timeout + + def get_side_effect(self, key): + raise self.Error() + + def set_side_effect(self, *args): + raise self.Error() + + +class TestMemcachedBytecodeCache: + def test_dump_load(self): + memcached = MockMemcached() + m = MemcachedBytecodeCache(memcached) + + b = Bucket(None, "key", "") + b.code = "code" + m.dump_bytecode(b) + assert memcached.key == "jinja2/bytecode/key" + + b = Bucket(None, "key", "") + m.load_bytecode(b) + assert b.code == "code" + + def test_exception(self): + memcached = MockMemcached() + memcached.get = memcached.get_side_effect + memcached.set = memcached.set_side_effect + m = MemcachedBytecodeCache(memcached) + b = Bucket(None, "key", "") + b.code = "code" + + m.dump_bytecode(b) + m.load_bytecode(b) + + m.ignore_memcache_errors = False + + with pytest.raises(MockMemcached.Error): + m.dump_bytecode(b) + + with pytest.raises(MockMemcached.Error): + m.load_bytecode(b) diff --git a/tests/test_compile.py b/tests/test_compile.py new file mode 100644 index 0000000..42a773f --- /dev/null +++ b/tests/test_compile.py @@ -0,0 +1,28 @@ +import os +import re + +from jinja2.environment import Environment +from jinja2.loaders import DictLoader + + +def test_filters_deterministic(tmp_path): + src = "".join(f"{{{{ {i}|filter{i} }}}}" for i in range(10)) + env = Environment(loader=DictLoader({"foo": src})) + env.filters.update(dict.fromkeys((f"filter{i}" for i in range(10)), lambda: None)) + env.compile_templates(tmp_path, zip=None) + name = os.listdir(tmp_path)[0] + content = (tmp_path / name).read_text("utf8") + expect = [f"filters['filter{i}']" for i in range(10)] + found = re.findall(r"filters\['filter\d']", content) + assert found == expect + + +def test_import_as_with_context_deterministic(tmp_path): + src = "\n".join(f'{{% import "bar" as bar{i} with context %}}' for i in range(10)) + env = Environment(loader=DictLoader({"foo": src})) + env.compile_templates(tmp_path, zip=None) + name = os.listdir(tmp_path)[0] + content = (tmp_path / name).read_text("utf8") + expect = [f"'bar{i}': " for i in range(10)] + found = re.findall(r"'bar\d': ", content)[:10] + assert found == expect diff --git a/tests/test_core_tags.py b/tests/test_core_tags.py new file mode 100644 index 0000000..4bb95e0 --- /dev/null +++ b/tests/test_core_tags.py @@ -0,0 +1,595 @@ +import pytest + +from jinja2 import DictLoader +from jinja2 import Environment +from jinja2 import TemplateRuntimeError +from jinja2 import TemplateSyntaxError +from jinja2 import UndefinedError + + +@pytest.fixture +def env_trim(): + return Environment(trim_blocks=True) + + +class TestForLoop: + def test_simple(self, env): + tmpl = env.from_string("{% for item in seq %}{{ item }}{% endfor %}") + assert tmpl.render(seq=list(range(10))) == "0123456789" + + def test_else(self, env): + tmpl = env.from_string("{% for item in seq %}XXX{% else %}...{% endfor %}") + assert tmpl.render() == "..." + + def test_else_scoping_item(self, env): + tmpl = env.from_string("{% for item in [] %}{% else %}{{ item }}{% endfor %}") + assert tmpl.render(item=42) == "42" + + def test_empty_blocks(self, env): + tmpl = env.from_string("<{% for item in seq %}{% else %}{% endfor %}>") + assert tmpl.render() == "<>" + + def test_context_vars(self, env): + slist = [42, 24] + for seq in [slist, iter(slist), reversed(slist), (_ for _ in slist)]: + tmpl = env.from_string( + """{% for item in seq -%} + {{ loop.index }}|{{ loop.index0 }}|{{ loop.revindex }}|{{ + loop.revindex0 }}|{{ loop.first }}|{{ loop.last }}|{{ + loop.length }}###{% endfor %}""" + ) + one, two, _ = tmpl.render(seq=seq).split("###") + ( + one_index, + one_index0, + one_revindex, + one_revindex0, + one_first, + one_last, + one_length, + ) = one.split("|") + ( + two_index, + two_index0, + two_revindex, + two_revindex0, + two_first, + two_last, + two_length, + ) = two.split("|") + + assert int(one_index) == 1 and int(two_index) == 2 + assert int(one_index0) == 0 and int(two_index0) == 1 + assert int(one_revindex) == 2 and int(two_revindex) == 1 + assert int(one_revindex0) == 1 and int(two_revindex0) == 0 + assert one_first == "True" and two_first == "False" + assert one_last == "False" and two_last == "True" + assert one_length == two_length == "2" + + def test_cycling(self, env): + tmpl = env.from_string( + """{% for item in seq %}{{ + loop.cycle('<1>', '<2>') }}{% endfor %}{% + for item in seq %}{{ loop.cycle(*through) }}{% endfor %}""" + ) + output = tmpl.render(seq=list(range(4)), through=("<1>", "<2>")) + assert output == "<1><2>" * 4 + + def test_lookaround(self, env): + tmpl = env.from_string( + """{% for item in seq -%} + {{ loop.previtem|default('x') }}-{{ item }}-{{ + loop.nextitem|default('x') }}| + {%- endfor %}""" + ) + output = tmpl.render(seq=list(range(4))) + assert output == "x-0-1|0-1-2|1-2-3|2-3-x|" + + def test_changed(self, env): + tmpl = env.from_string( + """{% for item in seq -%} + {{ loop.changed(item) }}, + {%- endfor %}""" + ) + output = tmpl.render(seq=[None, None, 1, 2, 2, 3, 4, 4, 4]) + assert output == "True,False,True,True,False,True,True,False,False," + + def test_scope(self, env): + tmpl = env.from_string("{% for item in seq %}{% endfor %}{{ item }}") + output = tmpl.render(seq=list(range(10))) + assert not output + + def test_varlen(self, env): + tmpl = env.from_string("{% for item in iter %}{{ item }}{% endfor %}") + output = tmpl.render(iter=range(5)) + assert output == "01234" + + def test_noniter(self, env): + tmpl = env.from_string("{% for item in none %}...{% endfor %}") + pytest.raises(TypeError, tmpl.render) + + def test_recursive(self, env): + tmpl = env.from_string( + """{% for item in seq recursive -%} + [{{ item.a }}{% if item.b %}<{{ loop(item.b) }}>{% endif %}] + {%- endfor %}""" + ) + assert ( + tmpl.render( + seq=[ + dict(a=1, b=[dict(a=1), dict(a=2)]), + dict(a=2, b=[dict(a=1), dict(a=2)]), + dict(a=3, b=[dict(a="a")]), + ] + ) + == "[1<[1][2]>][2<[1][2]>][3<[a]>]" + ) + + def test_recursive_lookaround(self, env): + tmpl = env.from_string( + """{% for item in seq recursive -%} + [{{ loop.previtem.a if loop.previtem is defined else 'x' }}.{{ + item.a }}.{{ loop.nextitem.a if loop.nextitem is defined else 'x' + }}{% if item.b %}<{{ loop(item.b) }}>{% endif %}] + {%- endfor %}""" + ) + assert ( + tmpl.render( + seq=[ + dict(a=1, b=[dict(a=1), dict(a=2)]), + dict(a=2, b=[dict(a=1), dict(a=2)]), + dict(a=3, b=[dict(a="a")]), + ] + ) + == "[x.1.2<[x.1.2][1.2.x]>][1.2.3<[x.1.2][1.2.x]>][2.3.x<[x.a.x]>]" + ) + + def test_recursive_depth0(self, env): + tmpl = env.from_string( + """{% for item in seq recursive -%} + [{{ loop.depth0 }}:{{ item.a }}{% if item.b %}<{{ loop(item.b) }}>{% endif %}] + {%- endfor %}""" + ) + assert ( + tmpl.render( + seq=[ + dict(a=1, b=[dict(a=1), dict(a=2)]), + dict(a=2, b=[dict(a=1), dict(a=2)]), + dict(a=3, b=[dict(a="a")]), + ] + ) + == "[0:1<[1:1][1:2]>][0:2<[1:1][1:2]>][0:3<[1:a]>]" + ) + + def test_recursive_depth(self, env): + tmpl = env.from_string( + """{% for item in seq recursive -%} + [{{ loop.depth }}:{{ item.a }}{% if item.b %}<{{ loop(item.b) }}>{% endif %}] + {%- endfor %}""" + ) + assert ( + tmpl.render( + seq=[ + dict(a=1, b=[dict(a=1), dict(a=2)]), + dict(a=2, b=[dict(a=1), dict(a=2)]), + dict(a=3, b=[dict(a="a")]), + ] + ) + == "[1:1<[2:1][2:2]>][1:2<[2:1][2:2]>][1:3<[2:a]>]" + ) + + def test_looploop(self, env): + tmpl = env.from_string( + """{% for row in table %} + {%- set rowloop = loop -%} + {% for cell in row -%} + [{{ rowloop.index }}|{{ loop.index }}] + {%- endfor %} + {%- endfor %}""" + ) + assert tmpl.render(table=["ab", "cd"]) == "[1|1][1|2][2|1][2|2]" + + def test_reversed_bug(self, env): + tmpl = env.from_string( + "{% for i in items %}{{ i }}" + "{% if not loop.last %}" + ",{% endif %}{% endfor %}" + ) + assert tmpl.render(items=reversed([3, 2, 1])) == "1,2,3" + + def test_loop_errors(self, env): + tmpl = env.from_string( + """{% for item in [1] if loop.index + == 0 %}...{% endfor %}""" + ) + pytest.raises(UndefinedError, tmpl.render) + tmpl = env.from_string( + """{% for item in [] %}...{% else + %}{{ loop }}{% endfor %}""" + ) + assert tmpl.render() == "" + + def test_loop_filter(self, env): + tmpl = env.from_string( + "{% for item in range(10) if item is even %}[{{ item }}]{% endfor %}" + ) + assert tmpl.render() == "[0][2][4][6][8]" + tmpl = env.from_string( + """ + {%- for item in range(10) if item is even %}[{{ + loop.index }}:{{ item }}]{% endfor %}""" + ) + assert tmpl.render() == "[1:0][2:2][3:4][4:6][5:8]" + + def test_loop_unassignable(self, env): + pytest.raises( + TemplateSyntaxError, env.from_string, "{% for loop in seq %}...{% endfor %}" + ) + + def test_scoped_special_var(self, env): + t = env.from_string( + "{% for s in seq %}[{{ loop.first }}{% for c in s %}" + "|{{ loop.first }}{% endfor %}]{% endfor %}" + ) + assert t.render(seq=("ab", "cd")) == "[True|True|False][False|True|False]" + + def test_scoped_loop_var(self, env): + t = env.from_string( + "{% for x in seq %}{{ loop.first }}" + "{% for y in seq %}{% endfor %}{% endfor %}" + ) + assert t.render(seq="ab") == "TrueFalse" + t = env.from_string( + "{% for x in seq %}{% for y in seq %}" + "{{ loop.first }}{% endfor %}{% endfor %}" + ) + assert t.render(seq="ab") == "TrueFalseTrueFalse" + + def test_recursive_empty_loop_iter(self, env): + t = env.from_string( + """ + {%- for item in foo recursive -%}{%- endfor -%} + """ + ) + assert t.render(dict(foo=[])) == "" + + def test_call_in_loop(self, env): + t = env.from_string( + """ + {%- macro do_something() -%} + [{{ caller() }}] + {%- endmacro %} + + {%- for i in [1, 2, 3] %} + {%- call do_something() -%} + {{ i }} + {%- endcall %} + {%- endfor -%} + """ + ) + assert t.render() == "[1][2][3]" + + def test_scoping_bug(self, env): + t = env.from_string( + """ + {%- for item in foo %}...{{ item }}...{% endfor %} + {%- macro item(a) %}...{{ a }}...{% endmacro %} + {{- item(2) -}} + """ + ) + assert t.render(foo=(1,)) == "...1......2..." + + def test_unpacking(self, env): + tmpl = env.from_string( + "{% for a, b, c in [[1, 2, 3]] %}{{ a }}|{{ b }}|{{ c }}{% endfor %}" + ) + assert tmpl.render() == "1|2|3" + + def test_intended_scoping_with_set(self, env): + tmpl = env.from_string( + "{% for item in seq %}{{ x }}{% set x = item %}{{ x }}{% endfor %}" + ) + assert tmpl.render(x=0, seq=[1, 2, 3]) == "010203" + + tmpl = env.from_string( + "{% set x = 9 %}{% for item in seq %}{{ x }}" + "{% set x = item %}{{ x }}{% endfor %}" + ) + assert tmpl.render(x=0, seq=[1, 2, 3]) == "919293" + + +class TestIfCondition: + def test_simple(self, env): + tmpl = env.from_string("""{% if true %}...{% endif %}""") + assert tmpl.render() == "..." + + def test_elif(self, env): + tmpl = env.from_string( + """{% if false %}XXX{% elif true + %}...{% else %}XXX{% endif %}""" + ) + assert tmpl.render() == "..." + + def test_elif_deep(self, env): + elifs = "\n".join(f"{{% elif a == {i} %}}{i}" for i in range(1, 1000)) + tmpl = env.from_string(f"{{% if a == 0 %}}0{elifs}{{% else %}}x{{% endif %}}") + for x in (0, 10, 999): + assert tmpl.render(a=x).strip() == str(x) + assert tmpl.render(a=1000).strip() == "x" + + def test_else(self, env): + tmpl = env.from_string("{% if false %}XXX{% else %}...{% endif %}") + assert tmpl.render() == "..." + + def test_empty(self, env): + tmpl = env.from_string("[{% if true %}{% else %}{% endif %}]") + assert tmpl.render() == "[]" + + def test_complete(self, env): + tmpl = env.from_string( + "{% if a %}A{% elif b %}B{% elif c == d %}C{% else %}D{% endif %}" + ) + assert tmpl.render(a=0, b=False, c=42, d=42.0) == "C" + + def test_no_scope(self, env): + tmpl = env.from_string("{% if a %}{% set foo = 1 %}{% endif %}{{ foo }}") + assert tmpl.render(a=True) == "1" + tmpl = env.from_string("{% if true %}{% set foo = 1 %}{% endif %}{{ foo }}") + assert tmpl.render() == "1" + + +class TestMacros: + def test_simple(self, env_trim): + tmpl = env_trim.from_string( + """\ +{% macro say_hello(name) %}Hello {{ name }}!{% endmacro %} +{{ say_hello('Peter') }}""" + ) + assert tmpl.render() == "Hello Peter!" + + def test_scoping(self, env_trim): + tmpl = env_trim.from_string( + """\ +{% macro level1(data1) %} +{% macro level2(data2) %}{{ data1 }}|{{ data2 }}{% endmacro %} +{{ level2('bar') }}{% endmacro %} +{{ level1('foo') }}""" + ) + assert tmpl.render() == "foo|bar" + + def test_arguments(self, env_trim): + tmpl = env_trim.from_string( + """\ +{% macro m(a, b, c='c', d='d') %}{{ a }}|{{ b }}|{{ c }}|{{ d }}{% endmacro %} +{{ m() }}|{{ m('a') }}|{{ m('a', 'b') }}|{{ m(1, 2, 3) }}""" + ) + assert tmpl.render() == "||c|d|a||c|d|a|b|c|d|1|2|3|d" + + def test_arguments_defaults_nonsense(self, env_trim): + pytest.raises( + TemplateSyntaxError, + env_trim.from_string, + """\ +{% macro m(a, b=1, c) %}a={{ a }}, b={{ b }}, c={{ c }}{% endmacro %}""", + ) + + def test_caller_defaults_nonsense(self, env_trim): + pytest.raises( + TemplateSyntaxError, + env_trim.from_string, + """\ +{% macro a() %}{{ caller() }}{% endmacro %} +{% call(x, y=1, z) a() %}{% endcall %}""", + ) + + def test_varargs(self, env_trim): + tmpl = env_trim.from_string( + """\ +{% macro test() %}{{ varargs|join('|') }}{% endmacro %}\ +{{ test(1, 2, 3) }}""" + ) + assert tmpl.render() == "1|2|3" + + def test_simple_call(self, env_trim): + tmpl = env_trim.from_string( + """\ +{% macro test() %}[[{{ caller() }}]]{% endmacro %}\ +{% call test() %}data{% endcall %}""" + ) + assert tmpl.render() == "[[data]]" + + def test_complex_call(self, env_trim): + tmpl = env_trim.from_string( + """\ +{% macro test() %}[[{{ caller('data') }}]]{% endmacro %}\ +{% call(data) test() %}{{ data }}{% endcall %}""" + ) + assert tmpl.render() == "[[data]]" + + def test_caller_undefined(self, env_trim): + tmpl = env_trim.from_string( + """\ +{% set caller = 42 %}\ +{% macro test() %}{{ caller is not defined }}{% endmacro %}\ +{{ test() }}""" + ) + assert tmpl.render() == "True" + + def test_include(self, env_trim): + env_trim = Environment( + loader=DictLoader( + {"include": "{% macro test(foo) %}[{{ foo }}]{% endmacro %}"} + ) + ) + tmpl = env_trim.from_string('{% from "include" import test %}{{ test("foo") }}') + assert tmpl.render() == "[foo]" + + def test_macro_api(self, env_trim): + tmpl = env_trim.from_string( + "{% macro foo(a, b) %}{% endmacro %}" + "{% macro bar() %}{{ varargs }}{{ kwargs }}{% endmacro %}" + "{% macro baz() %}{{ caller() }}{% endmacro %}" + ) + assert tmpl.module.foo.arguments == ("a", "b") + assert tmpl.module.foo.name == "foo" + assert not tmpl.module.foo.caller + assert not tmpl.module.foo.catch_kwargs + assert not tmpl.module.foo.catch_varargs + assert tmpl.module.bar.arguments == () + assert not tmpl.module.bar.caller + assert tmpl.module.bar.catch_kwargs + assert tmpl.module.bar.catch_varargs + assert tmpl.module.baz.caller + + def test_callself(self, env_trim): + tmpl = env_trim.from_string( + "{% macro foo(x) %}{{ x }}{% if x > 1 %}|" + "{{ foo(x - 1) }}{% endif %}{% endmacro %}" + "{{ foo(5) }}" + ) + assert tmpl.render() == "5|4|3|2|1" + + def test_macro_defaults_self_ref(self, env): + tmpl = env.from_string( + """ + {%- set x = 42 %} + {%- macro m(a, b=x, x=23) %}{{ a }}|{{ b }}|{{ x }}{% endmacro -%} + """ + ) + assert tmpl.module.m(1) == "1||23" + assert tmpl.module.m(1, 2) == "1|2|23" + assert tmpl.module.m(1, 2, 3) == "1|2|3" + assert tmpl.module.m(1, x=7) == "1|7|7" + + +class TestSet: + def test_normal(self, env_trim): + tmpl = env_trim.from_string("{% set foo = 1 %}{{ foo }}") + assert tmpl.render() == "1" + assert tmpl.module.foo == 1 + + def test_block(self, env_trim): + tmpl = env_trim.from_string("{% set foo %}42{% endset %}{{ foo }}") + assert tmpl.render() == "42" + assert tmpl.module.foo == "42" + + def test_block_escaping(self): + env = Environment(autoescape=True) + tmpl = env.from_string( + "{% set foo %}<em>{{ test }}</em>{% endset %}foo: {{ foo }}" + ) + assert tmpl.render(test="<unsafe>") == "foo: <em><unsafe></em>" + + def test_set_invalid(self, env_trim): + pytest.raises( + TemplateSyntaxError, env_trim.from_string, "{% set foo['bar'] = 1 %}" + ) + tmpl = env_trim.from_string("{% set foo.bar = 1 %}") + exc_info = pytest.raises(TemplateRuntimeError, tmpl.render, foo={}) + assert "non-namespace object" in exc_info.value.message + + def test_namespace_redefined(self, env_trim): + tmpl = env_trim.from_string("{% set ns = namespace() %}{% set ns.bar = 'hi' %}") + exc_info = pytest.raises(TemplateRuntimeError, tmpl.render, namespace=dict) + assert "non-namespace object" in exc_info.value.message + + def test_namespace(self, env_trim): + tmpl = env_trim.from_string( + "{% set ns = namespace() %}{% set ns.bar = '42' %}{{ ns.bar }}" + ) + assert tmpl.render() == "42" + + def test_namespace_block(self, env_trim): + tmpl = env_trim.from_string( + "{% set ns = namespace() %}{% set ns.bar %}42{% endset %}{{ ns.bar }}" + ) + assert tmpl.render() == "42" + + def test_init_namespace(self, env_trim): + tmpl = env_trim.from_string( + "{% set ns = namespace(d, self=37) %}" + "{% set ns.b = 42 %}" + "{{ ns.a }}|{{ ns.self }}|{{ ns.b }}" + ) + assert tmpl.render(d={"a": 13}) == "13|37|42" + + def test_namespace_loop(self, env_trim): + tmpl = env_trim.from_string( + "{% set ns = namespace(found=false) %}" + "{% for x in range(4) %}" + "{% if x == v %}" + "{% set ns.found = true %}" + "{% endif %}" + "{% endfor %}" + "{{ ns.found }}" + ) + assert tmpl.render(v=3) == "True" + assert tmpl.render(v=4) == "False" + + def test_namespace_macro(self, env_trim): + tmpl = env_trim.from_string( + "{% set ns = namespace() %}" + "{% set ns.a = 13 %}" + "{% macro magic(x) %}" + "{% set x.b = 37 %}" + "{% endmacro %}" + "{{ magic(ns) }}" + "{{ ns.a }}|{{ ns.b }}" + ) + assert tmpl.render() == "13|37" + + def test_block_escaping_filtered(self): + env = Environment(autoescape=True) + tmpl = env.from_string( + "{% set foo | trim %}<em>{{ test }}</em> {% endset %}foo: {{ foo }}" + ) + assert tmpl.render(test="<unsafe>") == "foo: <em><unsafe></em>" + + def test_block_filtered(self, env_trim): + tmpl = env_trim.from_string( + "{% set foo | trim | length | string %} 42 {% endset %}{{ foo }}" + ) + assert tmpl.render() == "2" + assert tmpl.module.foo == "2" + + def test_block_filtered_set(self, env_trim): + def _myfilter(val, arg): + assert arg == " xxx " + return val + + env_trim.filters["myfilter"] = _myfilter + tmpl = env_trim.from_string( + '{% set a = " xxx " %}' + "{% set foo | myfilter(a) | trim | length | string %}" + ' {% set b = " yy " %} 42 {{ a }}{{ b }} ' + "{% endset %}" + "{{ foo }}" + ) + assert tmpl.render() == "11" + assert tmpl.module.foo == "11" + + +class TestWith: + def test_with(self, env): + tmpl = env.from_string( + """\ + {% with a=42, b=23 -%} + {{ a }} = {{ b }} + {% endwith -%} + {{ a }} = {{ b }}\ + """ + ) + assert [x.strip() for x in tmpl.render(a=1, b=2).splitlines()] == [ + "42 = 23", + "1 = 2", + ] + + def test_with_argument_scoping(self, env): + tmpl = env.from_string( + """\ + {%- with a=1, b=2, c=b, d=e, e=5 -%} + {{ a }}|{{ b }}|{{ c }}|{{ d }}|{{ e }} + {%- endwith -%} + """ + ) + assert tmpl.render(b=3, e=4) == "1|2|3|4|5" diff --git a/tests/test_debug.py b/tests/test_debug.py new file mode 100644 index 0000000..bc11f40 --- /dev/null +++ b/tests/test_debug.py @@ -0,0 +1,117 @@ +import pickle +import re +from traceback import format_exception + +import pytest + +from jinja2 import ChoiceLoader +from jinja2 import DictLoader +from jinja2 import Environment +from jinja2 import TemplateSyntaxError + + +@pytest.fixture +def fs_env(filesystem_loader): + """returns a new environment.""" + return Environment(loader=filesystem_loader) + + +class TestDebug: + def assert_traceback_matches(self, callback, expected_tb): + with pytest.raises(Exception) as exc_info: + callback() + + tb = format_exception(exc_info.type, exc_info.value, exc_info.tb) + m = re.search(expected_tb.strip(), "".join(tb)) + assert ( + m is not None + ), f"Traceback did not match:\n\n{''.join(tb)}\nexpected:\n{expected_tb}" + + def test_runtime_error(self, fs_env): + def test(): + tmpl.render(fail=lambda: 1 / 0) + + tmpl = fs_env.get_template("broken.html") + self.assert_traceback_matches( + test, + r""" + File ".*?broken.html", line 2, in (top-level template code|<module>) + \{\{ fail\(\) \}\}( + \^{12})? + File ".*debug?.pyc?", line \d+, in <lambda> + tmpl\.render\(fail=lambda: 1 / 0\)( + ~~\^~~)? +ZeroDivisionError: (int(eger)? )?division (or modulo )?by zero +""", + ) + + def test_syntax_error(self, fs_env): + # The trailing .*? is for PyPy 2 and 3, which don't seem to + # clear the exception's original traceback, leaving the syntax + # error in the middle of other compiler frames. + self.assert_traceback_matches( + lambda: fs_env.get_template("syntaxerror.html"), + """(?sm) + File ".*?syntaxerror.html", line 4, in (template|<module>) + \\{% endif %\\}.*? +(jinja2\\.exceptions\\.)?TemplateSyntaxError: Encountered unknown tag 'endif'. Jinja \ +was looking for the following tags: 'endfor' or 'else'. The innermost block that needs \ +to be closed is 'for'. + """, + ) + + def test_regular_syntax_error(self, fs_env): + def test(): + raise TemplateSyntaxError("wtf", 42) + + self.assert_traceback_matches( + test, + r""" + File ".*debug.pyc?", line \d+, in test + raise TemplateSyntaxError\("wtf", 42\)( + \^{36})? +(jinja2\.exceptions\.)?TemplateSyntaxError: wtf + line 42""", + ) + + def test_pickleable_syntax_error(self, fs_env): + original = TemplateSyntaxError("bad template", 42, "test", "test.txt") + unpickled = pickle.loads(pickle.dumps(original)) + assert str(original) == str(unpickled) + assert original.name == unpickled.name + + def test_include_syntax_error_source(self, filesystem_loader): + e = Environment( + loader=ChoiceLoader( + [ + filesystem_loader, + DictLoader({"inc": "a\n{% include 'syntaxerror.html' %}\nb"}), + ] + ) + ) + t = e.get_template("inc") + + with pytest.raises(TemplateSyntaxError) as exc_info: + t.render() + + assert exc_info.value.source is not None + + def test_local_extraction(self): + from jinja2.debug import get_template_locals + from jinja2.runtime import missing + + locals = get_template_locals( + { + "l_0_foo": 42, + "l_1_foo": 23, + "l_2_foo": 13, + "l_0_bar": 99, + "l_1_bar": missing, + "l_0_baz": missing, + } + ) + assert locals == {"foo": 13, "bar": 99} + + def test_get_corresponding_lineno_traceback(self, fs_env): + tmpl = fs_env.get_template("test.html") + assert tmpl.get_corresponding_lineno(1) == 1 diff --git a/tests/test_ext.py b/tests/test_ext.py new file mode 100644 index 0000000..2e842e0 --- /dev/null +++ b/tests/test_ext.py @@ -0,0 +1,726 @@ +import re +from io import BytesIO + +import pytest + +from jinja2 import DictLoader +from jinja2 import Environment +from jinja2 import nodes +from jinja2 import pass_context +from jinja2.exceptions import TemplateAssertionError +from jinja2.ext import Extension +from jinja2.lexer import count_newlines +from jinja2.lexer import Token + +importable_object = 23 + +_gettext_re = re.compile(r"_\((.*?)\)", re.DOTALL) + + +i18n_templates = { + "default.html": '<title>{{ page_title|default(_("missing")) }}</title>' + "{% block body %}{% endblock %}", + "child.html": '{% extends "default.html" %}{% block body %}' + "{% trans %}watch out{% endtrans %}{% endblock %}", + "plural.html": "{% trans user_count %}One user online{% pluralize %}" + "{{ user_count }} users online{% endtrans %}", + "plural2.html": "{% trans user_count=get_user_count() %}{{ user_count }}s" + "{% pluralize %}{{ user_count }}p{% endtrans %}", + "stringformat.html": '{{ _("User: %(num)s")|format(num=user_count) }}', +} + +newstyle_i18n_templates = { + "default.html": '<title>{{ page_title|default(_("missing")) }}</title>' + "{% block body %}{% endblock %}", + "child.html": '{% extends "default.html" %}{% block body %}' + "{% trans %}watch out{% endtrans %}{% endblock %}", + "plural.html": "{% trans user_count %}One user online{% pluralize %}" + "{{ user_count }} users online{% endtrans %}", + "stringformat.html": '{{ _("User: %(num)s", num=user_count) }}', + "ngettext.html": '{{ ngettext("%(num)s apple", "%(num)s apples", apples) }}', + "ngettext_long.html": "{% trans num=apples %}{{ num }} apple{% pluralize %}" + "{{ num }} apples{% endtrans %}", + "pgettext.html": '{{ pgettext("fruit", "Apple") }}', + "npgettext.html": '{{ npgettext("fruit", "%(num)s apple", "%(num)s apples",' + " apples) }}", + "pgettext_block": "{% trans 'fruit' num=apples %}Apple{% endtrans %}", + "npgettext_block": "{% trans 'fruit' num=apples %}{{ num }} apple" + "{% pluralize %}{{ num }} apples{% endtrans %}", + "transvars1.html": "{% trans %}User: {{ num }}{% endtrans %}", + "transvars2.html": "{% trans num=count %}User: {{ num }}{% endtrans %}", + "transvars3.html": "{% trans count=num %}User: {{ count }}{% endtrans %}", + "novars.html": "{% trans %}%(hello)s{% endtrans %}", + "vars.html": "{% trans %}{{ foo }}%(foo)s{% endtrans %}", + "explicitvars.html": '{% trans foo="42" %}%(foo)s{% endtrans %}', +} + + +languages = { + "de": { + "missing": "fehlend", + "watch out": "pass auf", + "One user online": "Ein Benutzer online", + "%(user_count)s users online": "%(user_count)s Benutzer online", + "User: %(num)s": "Benutzer: %(num)s", + "User: %(count)s": "Benutzer: %(count)s", + "Apple": {None: "Apfel", "fruit": "Apple"}, + "%(num)s apple": {None: "%(num)s Apfel", "fruit": "%(num)s Apple"}, + "%(num)s apples": {None: "%(num)s Äpfel", "fruit": "%(num)s Apples"}, + } +} + + +def _get_with_context(value, ctx=None): + if isinstance(value, dict): + return value.get(ctx, value) + + return value + + +@pass_context +def gettext(context, string): + language = context.get("LANGUAGE", "en") + value = languages.get(language, {}).get(string, string) + return _get_with_context(value) + + +@pass_context +def ngettext(context, s, p, n): + language = context.get("LANGUAGE", "en") + + if n != 1: + value = languages.get(language, {}).get(p, p) + return _get_with_context(value) + + value = languages.get(language, {}).get(s, s) + return _get_with_context(value) + + +@pass_context +def pgettext(context, c, s): + language = context.get("LANGUAGE", "en") + value = languages.get(language, {}).get(s, s) + return _get_with_context(value, c) + + +@pass_context +def npgettext(context, c, s, p, n): + language = context.get("LANGUAGE", "en") + + if n != 1: + value = languages.get(language, {}).get(p, p) + return _get_with_context(value, c) + + value = languages.get(language, {}).get(s, s) + return _get_with_context(value, c) + + +i18n_env = Environment( + loader=DictLoader(i18n_templates), extensions=["jinja2.ext.i18n"] +) +i18n_env.globals.update( + { + "_": gettext, + "gettext": gettext, + "ngettext": ngettext, + "pgettext": pgettext, + "npgettext": npgettext, + } +) +i18n_env_trimmed = Environment(extensions=["jinja2.ext.i18n"]) + +i18n_env_trimmed.policies["ext.i18n.trimmed"] = True +i18n_env_trimmed.globals.update( + { + "_": gettext, + "gettext": gettext, + "ngettext": ngettext, + "pgettext": pgettext, + "npgettext": npgettext, + } +) + +newstyle_i18n_env = Environment( + loader=DictLoader(newstyle_i18n_templates), extensions=["jinja2.ext.i18n"] +) +newstyle_i18n_env.install_gettext_callables( # type: ignore + gettext, ngettext, newstyle=True, pgettext=pgettext, npgettext=npgettext +) + + +class ExampleExtension(Extension): + tags = {"test"} + ext_attr = 42 + context_reference_node_cls = nodes.ContextReference + + def parse(self, parser): + return nodes.Output( + [ + self.call_method( + "_dump", + [ + nodes.EnvironmentAttribute("sandboxed"), + self.attr("ext_attr"), + nodes.ImportedName(__name__ + ".importable_object"), + self.context_reference_node_cls(), + ], + ) + ] + ).set_lineno(next(parser.stream).lineno) + + def _dump(self, sandboxed, ext_attr, imported_object, context): + return ( + f"{sandboxed}|{ext_attr}|{imported_object}|{context.blocks}" + f"|{context.get('test_var')}" + ) + + +class DerivedExampleExtension(ExampleExtension): + context_reference_node_cls = nodes.DerivedContextReference # type: ignore + + +class PreprocessorExtension(Extension): + def preprocess(self, source, name, filename=None): + return source.replace("[[TEST]]", "({{ foo }})") + + +class StreamFilterExtension(Extension): + def filter_stream(self, stream): + for token in stream: + if token.type == "data": + yield from self.interpolate(token) + else: + yield token + + def interpolate(self, token): + pos = 0 + end = len(token.value) + lineno = token.lineno + while True: + match = _gettext_re.search(token.value, pos) + if match is None: + break + value = token.value[pos : match.start()] + if value: + yield Token(lineno, "data", value) + lineno += count_newlines(token.value) + yield Token(lineno, "variable_begin", None) + yield Token(lineno, "name", "gettext") + yield Token(lineno, "lparen", None) + yield Token(lineno, "string", match.group(1)) + yield Token(lineno, "rparen", None) + yield Token(lineno, "variable_end", None) + pos = match.end() + if pos < end: + yield Token(lineno, "data", token.value[pos:]) + + +class TestExtensions: + def test_extend_late(self): + env = Environment() + t = env.from_string('{% autoescape true %}{{ "<test>" }}{% endautoescape %}') + assert t.render() == "<test>" + + def test_loop_controls(self): + env = Environment(extensions=["jinja2.ext.loopcontrols"]) + + tmpl = env.from_string( + """ + {%- for item in [1, 2, 3, 4] %} + {%- if item % 2 == 0 %}{% continue %}{% endif -%} + {{ item }} + {%- endfor %}""" + ) + assert tmpl.render() == "13" + + tmpl = env.from_string( + """ + {%- for item in [1, 2, 3, 4] %} + {%- if item > 2 %}{% break %}{% endif -%} + {{ item }} + {%- endfor %}""" + ) + assert tmpl.render() == "12" + + def test_do(self): + env = Environment(extensions=["jinja2.ext.do"]) + tmpl = env.from_string( + """ + {%- set items = [] %} + {%- for char in "foo" %} + {%- do items.append(loop.index0 ~ char) %} + {%- endfor %}{{ items|join(', ') }}""" + ) + assert tmpl.render() == "0f, 1o, 2o" + + def test_extension_nodes(self): + env = Environment(extensions=[ExampleExtension]) + tmpl = env.from_string("{% test %}") + assert tmpl.render() == "False|42|23|{}|None" + + def test_contextreference_node_passes_context(self): + env = Environment(extensions=[ExampleExtension]) + tmpl = env.from_string('{% set test_var="test_content" %}{% test %}') + assert tmpl.render() == "False|42|23|{}|test_content" + + def test_contextreference_node_can_pass_locals(self): + env = Environment(extensions=[DerivedExampleExtension]) + tmpl = env.from_string( + '{% for test_var in ["test_content"] %}{% test %}{% endfor %}' + ) + assert tmpl.render() == "False|42|23|{}|test_content" + + def test_identifier(self): + assert ExampleExtension.identifier == __name__ + ".ExampleExtension" + + def test_rebinding(self): + original = Environment(extensions=[ExampleExtension]) + overlay = original.overlay() + for env in original, overlay: + for ext in env.extensions.values(): + assert ext.environment is env + + def test_preprocessor_extension(self): + env = Environment(extensions=[PreprocessorExtension]) + tmpl = env.from_string("{[[TEST]]}") + assert tmpl.render(foo=42) == "{(42)}" + + def test_streamfilter_extension(self): + env = Environment(extensions=[StreamFilterExtension]) + env.globals["gettext"] = lambda x: x.upper() + tmpl = env.from_string("Foo _(bar) Baz") + out = tmpl.render() + assert out == "Foo BAR Baz" + + def test_extension_ordering(self): + class T1(Extension): + priority = 1 + + class T2(Extension): + priority = 2 + + env = Environment(extensions=[T1, T2]) + ext = list(env.iter_extensions()) + assert ext[0].__class__ is T1 + assert ext[1].__class__ is T2 + + def test_debug(self): + env = Environment(extensions=["jinja2.ext.debug"]) + t = env.from_string("Hello\n{% debug %}\nGoodbye") + out = t.render() + + for value in ("context", "cycler", "filters", "abs", "tests", "!="): + assert f"'{value}'" in out + + +class TestInternationalization: + def test_trans(self): + tmpl = i18n_env.get_template("child.html") + assert tmpl.render(LANGUAGE="de") == "<title>fehlend</title>pass auf" + + def test_trans_plural(self): + tmpl = i18n_env.get_template("plural.html") + assert tmpl.render(LANGUAGE="de", user_count=1) == "Ein Benutzer online" + assert tmpl.render(LANGUAGE="de", user_count=2) == "2 Benutzer online" + + def test_trans_plural_with_functions(self): + tmpl = i18n_env.get_template("plural2.html") + + def get_user_count(): + get_user_count.called += 1 + return 1 + + get_user_count.called = 0 + assert tmpl.render(LANGUAGE="de", get_user_count=get_user_count) == "1s" + assert get_user_count.called == 1 + + def test_complex_plural(self): + tmpl = i18n_env.from_string( + "{% trans foo=42, count=2 %}{{ count }} item{% " + "pluralize count %}{{ count }} items{% endtrans %}" + ) + assert tmpl.render() == "2 items" + pytest.raises( + TemplateAssertionError, + i18n_env.from_string, + "{% trans foo %}...{% pluralize bar %}...{% endtrans %}", + ) + + def test_trans_stringformatting(self): + tmpl = i18n_env.get_template("stringformat.html") + assert tmpl.render(LANGUAGE="de", user_count=5) == "Benutzer: 5" + + def test_trimmed(self): + tmpl = i18n_env.from_string( + "{%- trans trimmed %} hello\n world {% endtrans -%}" + ) + assert tmpl.render() == "hello world" + + def test_trimmed_policy(self): + s = "{%- trans %} hello\n world {% endtrans -%}" + tmpl = i18n_env.from_string(s) + trimmed_tmpl = i18n_env_trimmed.from_string(s) + assert tmpl.render() == " hello\n world " + assert trimmed_tmpl.render() == "hello world" + + def test_trimmed_policy_override(self): + tmpl = i18n_env_trimmed.from_string( + "{%- trans notrimmed %} hello\n world {% endtrans -%}" + ) + assert tmpl.render() == " hello\n world " + + def test_trimmed_vars(self): + tmpl = i18n_env.from_string( + '{%- trans trimmed x="world" %} hello\n {{ x }} {% endtrans -%}' + ) + assert tmpl.render() == "hello world" + + def test_trimmed_varname_trimmed(self): + # unlikely variable name, but when used as a variable + # it should not enable trimming + tmpl = i18n_env.from_string( + "{%- trans trimmed = 'world' %} hello\n {{ trimmed }} {% endtrans -%}" + ) + assert tmpl.render() == " hello\n world " + + def test_extract(self): + from jinja2.ext import babel_extract + + source = BytesIO( + b""" + {{ gettext('Hello World') }} + {% trans %}Hello World{% endtrans %} + {% trans %}{{ users }} user{% pluralize %}{{ users }} users{% endtrans %} + """ + ) + assert list(babel_extract(source, ("gettext", "ngettext", "_"), [], {})) == [ + (2, "gettext", "Hello World", []), + (3, "gettext", "Hello World", []), + (4, "ngettext", ("%(users)s user", "%(users)s users", None), []), + ] + + def test_extract_trimmed(self): + from jinja2.ext import babel_extract + + source = BytesIO( + b""" + {{ gettext(' Hello \n World') }} + {% trans trimmed %} Hello \n World{% endtrans %} + {% trans trimmed %}{{ users }} \n user + {%- pluralize %}{{ users }} \n users{% endtrans %} + """ + ) + assert list(babel_extract(source, ("gettext", "ngettext", "_"), [], {})) == [ + (2, "gettext", " Hello \n World", []), + (4, "gettext", "Hello World", []), + (6, "ngettext", ("%(users)s user", "%(users)s users", None), []), + ] + + def test_extract_trimmed_option(self): + from jinja2.ext import babel_extract + + source = BytesIO( + b""" + {{ gettext(' Hello \n World') }} + {% trans %} Hello \n World{% endtrans %} + {% trans %}{{ users }} \n user + {%- pluralize %}{{ users }} \n users{% endtrans %} + """ + ) + opts = {"trimmed": "true"} + assert list(babel_extract(source, ("gettext", "ngettext", "_"), [], opts)) == [ + (2, "gettext", " Hello \n World", []), + (4, "gettext", "Hello World", []), + (6, "ngettext", ("%(users)s user", "%(users)s users", None), []), + ] + + def test_comment_extract(self): + from jinja2.ext import babel_extract + + source = BytesIO( + b""" + {# trans first #} + {{ gettext('Hello World') }} + {% trans %}Hello World{% endtrans %}{# trans second #} + {#: third #} + {% trans %}{{ users }} user{% pluralize %}{{ users }} users{% endtrans %} + """ + ) + assert list( + babel_extract(source, ("gettext", "ngettext", "_"), ["trans", ":"], {}) + ) == [ + (3, "gettext", "Hello World", ["first"]), + (4, "gettext", "Hello World", ["second"]), + (6, "ngettext", ("%(users)s user", "%(users)s users", None), ["third"]), + ] + + def test_extract_context(self): + from jinja2.ext import babel_extract + + source = BytesIO( + b""" + {{ pgettext("babel", "Hello World") }} + {{ npgettext("babel", "%(users)s user", "%(users)s users", users) }} + """ + ) + assert list(babel_extract(source, ("pgettext", "npgettext", "_"), [], {})) == [ + (2, "pgettext", ("babel", "Hello World"), []), + (3, "npgettext", ("babel", "%(users)s user", "%(users)s users", None), []), + ] + + +class TestScope: + def test_basic_scope_behavior(self): + # This is what the old with statement compiled down to + class ScopeExt(Extension): + tags = {"scope"} + + def parse(self, parser): + node = nodes.Scope(lineno=next(parser.stream).lineno) + assignments = [] + while parser.stream.current.type != "block_end": + lineno = parser.stream.current.lineno + if assignments: + parser.stream.expect("comma") + target = parser.parse_assign_target() + parser.stream.expect("assign") + expr = parser.parse_expression() + assignments.append(nodes.Assign(target, expr, lineno=lineno)) + node.body = assignments + list( + parser.parse_statements(("name:endscope",), drop_needle=True) + ) + return node + + env = Environment(extensions=[ScopeExt]) + tmpl = env.from_string( + """\ + {%- scope a=1, b=2, c=b, d=e, e=5 -%} + {{ a }}|{{ b }}|{{ c }}|{{ d }}|{{ e }} + {%- endscope -%} + """ + ) + assert tmpl.render(b=3, e=4) == "1|2|2|4|5" + + +class TestNewstyleInternationalization: + def test_trans(self): + tmpl = newstyle_i18n_env.get_template("child.html") + assert tmpl.render(LANGUAGE="de") == "<title>fehlend</title>pass auf" + + def test_trans_plural(self): + tmpl = newstyle_i18n_env.get_template("plural.html") + assert tmpl.render(LANGUAGE="de", user_count=1) == "Ein Benutzer online" + assert tmpl.render(LANGUAGE="de", user_count=2) == "2 Benutzer online" + + def test_complex_plural(self): + tmpl = newstyle_i18n_env.from_string( + "{% trans foo=42, count=2 %}{{ count }} item{% " + "pluralize count %}{{ count }} items{% endtrans %}" + ) + assert tmpl.render() == "2 items" + pytest.raises( + TemplateAssertionError, + i18n_env.from_string, + "{% trans foo %}...{% pluralize bar %}...{% endtrans %}", + ) + + def test_trans_stringformatting(self): + tmpl = newstyle_i18n_env.get_template("stringformat.html") + assert tmpl.render(LANGUAGE="de", user_count=5) == "Benutzer: 5" + + def test_newstyle_plural(self): + tmpl = newstyle_i18n_env.get_template("ngettext.html") + assert tmpl.render(LANGUAGE="de", apples=1) == "1 Apfel" + assert tmpl.render(LANGUAGE="de", apples=5) == "5 Äpfel" + + def test_autoescape_support(self): + env = Environment(extensions=["jinja2.ext.i18n"]) + env.install_gettext_callables( + lambda x: "<strong>Wert: %(name)s</strong>", + lambda s, p, n: s, + newstyle=True, + ) + t = env.from_string( + '{% autoescape ae %}{{ gettext("foo", name=' + '"<test>") }}{% endautoescape %}' + ) + assert t.render(ae=True) == "<strong>Wert: <test></strong>" + assert t.render(ae=False) == "<strong>Wert: <test></strong>" + + def test_autoescape_macros(self): + env = Environment(autoescape=False) + template = ( + "{% macro m() %}<html>{% endmacro %}" + "{% autoescape true %}{{ m() }}{% endautoescape %}" + ) + assert env.from_string(template).render() == "<html>" + + def test_num_used_twice(self): + tmpl = newstyle_i18n_env.get_template("ngettext_long.html") + assert tmpl.render(apples=5, LANGUAGE="de") == "5 Äpfel" + + def test_num_called_num(self): + source = newstyle_i18n_env.compile( + """ + {% trans num=3 %}{{ num }} apple{% pluralize + %}{{ num }} apples{% endtrans %} + """, + raw=True, + ) + # quite hacky, but the only way to properly test that. The idea is + # that the generated code does not pass num twice (although that + # would work) for better performance. This only works on the + # newstyle gettext of course + assert ( + re.search(r"u?'%\(num\)s apple', u?'%\(num\)s apples', 3", source) + is not None + ) + + def test_trans_vars(self): + t1 = newstyle_i18n_env.get_template("transvars1.html") + t2 = newstyle_i18n_env.get_template("transvars2.html") + t3 = newstyle_i18n_env.get_template("transvars3.html") + assert t1.render(num=1, LANGUAGE="de") == "Benutzer: 1" + assert t2.render(count=23, LANGUAGE="de") == "Benutzer: 23" + assert t3.render(num=42, LANGUAGE="de") == "Benutzer: 42" + + def test_novars_vars_escaping(self): + t = newstyle_i18n_env.get_template("novars.html") + assert t.render() == "%(hello)s" + t = newstyle_i18n_env.get_template("vars.html") + assert t.render(foo="42") == "42%(foo)s" + t = newstyle_i18n_env.get_template("explicitvars.html") + assert t.render() == "%(foo)s" + + def test_context(self): + tmpl = newstyle_i18n_env.get_template("pgettext.html") + assert tmpl.render(LANGUAGE="de") == "Apple" + + def test_context_plural(self): + tmpl = newstyle_i18n_env.get_template("npgettext.html") + assert tmpl.render(LANGUAGE="de", apples=1) == "1 Apple" + assert tmpl.render(LANGUAGE="de", apples=5) == "5 Apples" + + def test_context_block(self): + tmpl = newstyle_i18n_env.get_template("pgettext_block") + assert tmpl.render(LANGUAGE="de") == "Apple" + + def test_context_plural_block(self): + tmpl = newstyle_i18n_env.get_template("npgettext_block") + assert tmpl.render(LANGUAGE="de", apples=1) == "1 Apple" + assert tmpl.render(LANGUAGE="de", apples=5) == "5 Apples" + + +class TestAutoEscape: + def test_scoped_setting(self): + env = Environment(autoescape=True) + tmpl = env.from_string( + """ + {{ "<HelloWorld>" }} + {% autoescape false %} + {{ "<HelloWorld>" }} + {% endautoescape %} + {{ "<HelloWorld>" }} + """ + ) + assert tmpl.render().split() == [ + "<HelloWorld>", + "<HelloWorld>", + "<HelloWorld>", + ] + + env = Environment(autoescape=False) + tmpl = env.from_string( + """ + {{ "<HelloWorld>" }} + {% autoescape true %} + {{ "<HelloWorld>" }} + {% endautoescape %} + {{ "<HelloWorld>" }} + """ + ) + assert tmpl.render().split() == [ + "<HelloWorld>", + "<HelloWorld>", + "<HelloWorld>", + ] + + def test_nonvolatile(self): + env = Environment(autoescape=True) + tmpl = env.from_string('{{ {"foo": "<test>"}|xmlattr|escape }}') + assert tmpl.render() == ' foo="<test>"' + tmpl = env.from_string( + '{% autoescape false %}{{ {"foo": "<test>"}' + "|xmlattr|escape }}{% endautoescape %}" + ) + assert tmpl.render() == " foo="&lt;test&gt;"" + + def test_volatile(self): + env = Environment(autoescape=True) + tmpl = env.from_string( + '{% autoescape foo %}{{ {"foo": "<test>"}' + "|xmlattr|escape }}{% endautoescape %}" + ) + assert tmpl.render(foo=False) == " foo="&lt;test&gt;"" + assert tmpl.render(foo=True) == ' foo="<test>"' + + def test_scoping(self): + env = Environment() + tmpl = env.from_string( + '{% autoescape true %}{% set x = "<x>" %}{{ x }}' + '{% endautoescape %}{{ x }}{{ "<y>" }}' + ) + assert tmpl.render(x=1) == "<x>1<y>" + + def test_volatile_scoping(self): + env = Environment() + tmplsource = """ + {% autoescape val %} + {% macro foo(x) %} + [{{ x }}] + {% endmacro %} + {{ foo().__class__.__name__ }} + {% endautoescape %} + {{ '<testing>' }} + """ + tmpl = env.from_string(tmplsource) + assert tmpl.render(val=True).split()[0] == "Markup" + assert tmpl.render(val=False).split()[0] == "str" + + # looking at the source we should see <testing> there in raw + # (and then escaped as well) + env = Environment() + pysource = env.compile(tmplsource, raw=True) + assert "<testing>\\n" in pysource + + env = Environment(autoescape=True) + pysource = env.compile(tmplsource, raw=True) + assert "<testing>\\n" in pysource + + def test_overlay_scopes(self): + class MagicScopeExtension(Extension): + tags = {"overlay"} + + def parse(self, parser): + node = nodes.OverlayScope(lineno=next(parser.stream).lineno) + node.body = list( + parser.parse_statements(("name:endoverlay",), drop_needle=True) + ) + node.context = self.call_method("get_scope") + return node + + def get_scope(self): + return {"x": [1, 2, 3]} + + env = Environment(extensions=[MagicScopeExtension]) + + tmpl = env.from_string( + """ + {{- x }}|{% set z = 99 %} + {%- overlay %} + {{- y }}|{{ z }}|{% for item in x %}[{{ item }}]{% endfor %} + {%- endoverlay %}| + {{- x -}} + """ + ) + assert tmpl.render(x=42, y=23) == "42|23|99|[1][2][3]|42" diff --git a/tests/test_filters.py b/tests/test_filters.py new file mode 100644 index 0000000..73f0f0b --- /dev/null +++ b/tests/test_filters.py @@ -0,0 +1,873 @@ +import random +from collections import namedtuple + +import pytest +from markupsafe import Markup + +from jinja2 import Environment +from jinja2 import StrictUndefined +from jinja2 import TemplateRuntimeError +from jinja2 import UndefinedError +from jinja2.exceptions import TemplateAssertionError + + +class Magic: + def __init__(self, value): + self.value = value + + def __str__(self): + return str(self.value) + + +class Magic2: + def __init__(self, value1, value2): + self.value1 = value1 + self.value2 = value2 + + def __str__(self): + return f"({self.value1},{self.value2})" + + +class TestFilter: + def test_filter_calling(self, env): + rv = env.call_filter("sum", [1, 2, 3]) + assert rv == 6 + + def test_capitalize(self, env): + tmpl = env.from_string('{{ "foo bar"|capitalize }}') + assert tmpl.render() == "Foo bar" + + def test_center(self, env): + tmpl = env.from_string('{{ "foo"|center(9) }}') + assert tmpl.render() == " foo " + + def test_default(self, env): + tmpl = env.from_string( + "{{ missing|default('no') }}|{{ false|default('no') }}|" + "{{ false|default('no', true) }}|{{ given|default('no') }}" + ) + assert tmpl.render(given="yes") == "no|False|no|yes" + + @pytest.mark.parametrize( + "args,expect", + ( + ("", "[('aa', 0), ('AB', 3), ('b', 1), ('c', 2)]"), + ("true", "[('AB', 3), ('aa', 0), ('b', 1), ('c', 2)]"), + ('by="value"', "[('aa', 0), ('b', 1), ('c', 2), ('AB', 3)]"), + ("reverse=true", "[('c', 2), ('b', 1), ('AB', 3), ('aa', 0)]"), + ), + ) + def test_dictsort(self, env, args, expect): + t = env.from_string(f"{{{{ foo|dictsort({args}) }}}}") + out = t.render(foo={"aa": 0, "b": 1, "c": 2, "AB": 3}) + assert out == expect + + def test_batch(self, env): + tmpl = env.from_string("{{ foo|batch(3)|list }}|{{ foo|batch(3, 'X')|list }}") + out = tmpl.render(foo=list(range(10))) + assert out == ( + "[[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]]|" + "[[0, 1, 2], [3, 4, 5], [6, 7, 8], [9, 'X', 'X']]" + ) + + def test_slice(self, env): + tmpl = env.from_string("{{ foo|slice(3)|list }}|{{ foo|slice(3, 'X')|list }}") + out = tmpl.render(foo=list(range(10))) + assert out == ( + "[[0, 1, 2, 3], [4, 5, 6], [7, 8, 9]]|" + "[[0, 1, 2, 3], [4, 5, 6, 'X'], [7, 8, 9, 'X']]" + ) + + def test_escape(self, env): + tmpl = env.from_string("""{{ '<">&'|escape }}""") + out = tmpl.render() + assert out == "<">&" + + @pytest.mark.parametrize( + ("chars", "expect"), [(None, "..stays.."), (".", " ..stays"), (" .", "stays")] + ) + def test_trim(self, env, chars, expect): + tmpl = env.from_string("{{ foo|trim(chars) }}") + out = tmpl.render(foo=" ..stays..", chars=chars) + assert out == expect + + def test_striptags(self, env): + tmpl = env.from_string("""{{ foo|striptags }}""") + out = tmpl.render( + foo=' <p>just a small \n <a href="#">' + "example</a> link</p>\n<p>to a webpage</p> " + "<!-- <p>and some commented stuff</p> -->" + ) + assert out == "just a small example link to a webpage" + + def test_filesizeformat(self, env): + tmpl = env.from_string( + "{{ 100|filesizeformat }}|" + "{{ 1000|filesizeformat }}|" + "{{ 1000000|filesizeformat }}|" + "{{ 1000000000|filesizeformat }}|" + "{{ 1000000000000|filesizeformat }}|" + "{{ 100|filesizeformat(true) }}|" + "{{ 1000|filesizeformat(true) }}|" + "{{ 1000000|filesizeformat(true) }}|" + "{{ 1000000000|filesizeformat(true) }}|" + "{{ 1000000000000|filesizeformat(true) }}" + ) + out = tmpl.render() + assert out == ( + "100 Bytes|1.0 kB|1.0 MB|1.0 GB|1.0 TB|100 Bytes|" + "1000 Bytes|976.6 KiB|953.7 MiB|931.3 GiB" + ) + + def test_filesizeformat_issue59(self, env): + tmpl = env.from_string( + "{{ 300|filesizeformat }}|" + "{{ 3000|filesizeformat }}|" + "{{ 3000000|filesizeformat }}|" + "{{ 3000000000|filesizeformat }}|" + "{{ 3000000000000|filesizeformat }}|" + "{{ 300|filesizeformat(true) }}|" + "{{ 3000|filesizeformat(true) }}|" + "{{ 3000000|filesizeformat(true) }}" + ) + out = tmpl.render() + assert out == ( + "300 Bytes|3.0 kB|3.0 MB|3.0 GB|3.0 TB|300 Bytes|2.9 KiB|2.9 MiB" + ) + + def test_first(self, env): + tmpl = env.from_string("{{ foo|first }}") + out = tmpl.render(foo=list(range(10))) + assert out == "0" + + @pytest.mark.parametrize( + ("value", "expect"), (("42", "42.0"), ("abc", "0.0"), ("32.32", "32.32")) + ) + def test_float(self, env, value, expect): + t = env.from_string("{{ value|float }}") + assert t.render(value=value) == expect + + def test_float_default(self, env): + t = env.from_string("{{ value|float(default=1.0) }}") + assert t.render(value="abc") == "1.0" + + def test_format(self, env): + tmpl = env.from_string("{{ '%s|%s'|format('a', 'b') }}") + out = tmpl.render() + assert out == "a|b" + + @staticmethod + def _test_indent_multiline_template(env, markup=False): + text = "\n".join(["", "foo bar", '"baz"', ""]) + if markup: + text = Markup(text) + t = env.from_string("{{ foo|indent(2, false, false) }}") + assert t.render(foo=text) == '\n foo bar\n "baz"\n' + t = env.from_string("{{ foo|indent(2, false, true) }}") + assert t.render(foo=text) == '\n foo bar\n "baz"\n ' + t = env.from_string("{{ foo|indent(2, true, false) }}") + assert t.render(foo=text) == ' \n foo bar\n "baz"\n' + t = env.from_string("{{ foo|indent(2, true, true) }}") + assert t.render(foo=text) == ' \n foo bar\n "baz"\n ' + + def test_indent(self, env): + self._test_indent_multiline_template(env) + t = env.from_string('{{ "jinja"|indent }}') + assert t.render() == "jinja" + t = env.from_string('{{ "jinja"|indent(first=true) }}') + assert t.render() == " jinja" + t = env.from_string('{{ "jinja"|indent(blank=true) }}') + assert t.render() == "jinja" + + def test_indent_markup_input(self, env): + """ + Tests cases where the filter input is a Markup type + """ + self._test_indent_multiline_template(env, markup=True) + + def test_indent_width_string(self, env): + t = env.from_string("{{ 'jinja\nflask'|indent(width='>>> ', first=True) }}") + assert t.render() == ">>> jinja\n>>> flask" + + @pytest.mark.parametrize( + ("value", "expect"), + ( + ("42", "42"), + ("abc", "0"), + ("32.32", "32"), + ("12345678901234567890", "12345678901234567890"), + ), + ) + def test_int(self, env, value, expect): + t = env.from_string("{{ value|int }}") + assert t.render(value=value) == expect + + @pytest.mark.parametrize( + ("value", "base", "expect"), + (("0x4d32", 16, "19762"), ("011", 8, "9"), ("0x33Z", 16, "0")), + ) + def test_int_base(self, env, value, base, expect): + t = env.from_string("{{ value|int(base=base) }}") + assert t.render(value=value, base=base) == expect + + def test_int_default(self, env): + t = env.from_string("{{ value|int(default=1) }}") + assert t.render(value="abc") == "1" + + def test_int_special_method(self, env): + class IntIsh: + def __int__(self): + return 42 + + t = env.from_string("{{ value|int }}") + assert t.render(value=IntIsh()) == "42" + + def test_join(self, env): + tmpl = env.from_string('{{ [1, 2, 3]|join("|") }}') + out = tmpl.render() + assert out == "1|2|3" + + env2 = Environment(autoescape=True) + tmpl = env2.from_string('{{ ["<foo>", "<span>foo</span>"|safe]|join }}') + assert tmpl.render() == "<foo><span>foo</span>" + + def test_join_attribute(self, env): + User = namedtuple("User", "username") + tmpl = env.from_string("""{{ users|join(', ', 'username') }}""") + assert tmpl.render(users=map(User, ["foo", "bar"])) == "foo, bar" + + def test_last(self, env): + tmpl = env.from_string("""{{ foo|last }}""") + out = tmpl.render(foo=list(range(10))) + assert out == "9" + + def test_length(self, env): + tmpl = env.from_string("""{{ "hello world"|length }}""") + out = tmpl.render() + assert out == "11" + + def test_lower(self, env): + tmpl = env.from_string("""{{ "FOO"|lower }}""") + out = tmpl.render() + assert out == "foo" + + def test_items(self, env): + d = {i: c for i, c in enumerate("abc")} + tmpl = env.from_string("""{{ d|items|list }}""") + out = tmpl.render(d=d) + assert out == "[(0, 'a'), (1, 'b'), (2, 'c')]" + + def test_items_undefined(self, env): + tmpl = env.from_string("""{{ d|items|list }}""") + out = tmpl.render() + assert out == "[]" + + def test_pprint(self, env): + from pprint import pformat + + tmpl = env.from_string("""{{ data|pprint }}""") + data = list(range(1000)) + assert tmpl.render(data=data) == pformat(data) + + def test_random(self, env, request): + # restore the random state when the test ends + state = random.getstate() + request.addfinalizer(lambda: random.setstate(state)) + # generate the random values from a known seed + random.seed("jinja") + expected = [random.choice("1234567890") for _ in range(10)] + + # check that the random sequence is generated again by a template + # ensures that filter result is not constant folded + random.seed("jinja") + t = env.from_string('{{ "1234567890"|random }}') + + for value in expected: + assert t.render() == value + + def test_reverse(self, env): + tmpl = env.from_string( + "{{ 'foobar'|reverse|join }}|{{ [1, 2, 3]|reverse|list }}" + ) + assert tmpl.render() == "raboof|[3, 2, 1]" + + def test_string(self, env): + x = [1, 2, 3, 4, 5] + tmpl = env.from_string("""{{ obj|string }}""") + assert tmpl.render(obj=x) == str(x) + + def test_title(self, env): + tmpl = env.from_string("""{{ "foo bar"|title }}""") + assert tmpl.render() == "Foo Bar" + tmpl = env.from_string("""{{ "foo's bar"|title }}""") + assert tmpl.render() == "Foo's Bar" + tmpl = env.from_string("""{{ "foo bar"|title }}""") + assert tmpl.render() == "Foo Bar" + tmpl = env.from_string("""{{ "f bar f"|title }}""") + assert tmpl.render() == "F Bar F" + tmpl = env.from_string("""{{ "foo-bar"|title }}""") + assert tmpl.render() == "Foo-Bar" + tmpl = env.from_string("""{{ "foo\tbar"|title }}""") + assert tmpl.render() == "Foo\tBar" + tmpl = env.from_string("""{{ "FOO\tBAR"|title }}""") + assert tmpl.render() == "Foo\tBar" + tmpl = env.from_string("""{{ "foo (bar)"|title }}""") + assert tmpl.render() == "Foo (Bar)" + tmpl = env.from_string("""{{ "foo {bar}"|title }}""") + assert tmpl.render() == "Foo {Bar}" + tmpl = env.from_string("""{{ "foo [bar]"|title }}""") + assert tmpl.render() == "Foo [Bar]" + tmpl = env.from_string("""{{ "foo <bar>"|title }}""") + assert tmpl.render() == "Foo <Bar>" + + class Foo: + def __str__(self): + return "foo-bar" + + tmpl = env.from_string("""{{ data|title }}""") + out = tmpl.render(data=Foo()) + assert out == "Foo-Bar" + + def test_truncate(self, env): + tmpl = env.from_string( + '{{ data|truncate(15, true, ">>>") }}|' + '{{ data|truncate(15, false, ">>>") }}|' + "{{ smalldata|truncate(15) }}" + ) + out = tmpl.render(data="foobar baz bar" * 1000, smalldata="foobar baz bar") + assert out == "foobar baz b>>>|foobar baz>>>|foobar baz bar" + + def test_truncate_very_short(self, env): + tmpl = env.from_string( + '{{ "foo bar baz"|truncate(9) }}|{{ "foo bar baz"|truncate(9, true) }}' + ) + out = tmpl.render() + assert out == "foo bar baz|foo bar baz" + + def test_truncate_end_length(self, env): + tmpl = env.from_string('{{ "Joel is a slug"|truncate(7, true) }}') + out = tmpl.render() + assert out == "Joel..." + + def test_upper(self, env): + tmpl = env.from_string('{{ "foo"|upper }}') + assert tmpl.render() == "FOO" + + def test_urlize(self, env): + tmpl = env.from_string('{{ "foo example.org bar"|urlize }}') + assert tmpl.render() == ( + 'foo <a href="https://example.org" rel="noopener">' "example.org</a> bar" + ) + tmpl = env.from_string('{{ "foo http://www.example.com/ bar"|urlize }}') + assert tmpl.render() == ( + 'foo <a href="http://www.example.com/" rel="noopener">' + "http://www.example.com/</a> bar" + ) + tmpl = env.from_string('{{ "foo mailto:email@example.com bar"|urlize }}') + assert tmpl.render() == ( + 'foo <a href="mailto:email@example.com">email@example.com</a> bar' + ) + tmpl = env.from_string('{{ "foo email@example.com bar"|urlize }}') + assert tmpl.render() == ( + 'foo <a href="mailto:email@example.com">email@example.com</a> bar' + ) + + def test_urlize_rel_policy(self): + env = Environment() + env.policies["urlize.rel"] = None + tmpl = env.from_string('{{ "foo http://www.example.com/ bar"|urlize }}') + assert tmpl.render() == ( + 'foo <a href="http://www.example.com/">http://www.example.com/</a> bar' + ) + + def test_urlize_target_parameter(self, env): + tmpl = env.from_string( + '{{ "foo http://www.example.com/ bar"|urlize(target="_blank") }}' + ) + assert ( + tmpl.render() + == 'foo <a href="http://www.example.com/" rel="noopener" target="_blank">' + "http://www.example.com/</a> bar" + ) + + def test_urlize_extra_schemes_parameter(self, env): + tmpl = env.from_string( + '{{ "foo tel:+1-514-555-1234 ftp://localhost bar"|' + 'urlize(extra_schemes=["tel:", "ftp:"]) }}' + ) + assert tmpl.render() == ( + 'foo <a href="tel:+1-514-555-1234" rel="noopener">' + 'tel:+1-514-555-1234</a> <a href="ftp://localhost" rel="noopener">' + "ftp://localhost</a> bar" + ) + + def test_wordcount(self, env): + tmpl = env.from_string('{{ "foo bar baz"|wordcount }}') + assert tmpl.render() == "3" + + strict_env = Environment(undefined=StrictUndefined) + t = strict_env.from_string("{{ s|wordcount }}") + with pytest.raises(UndefinedError): + t.render() + + def test_block(self, env): + tmpl = env.from_string("{% filter lower|escape %}<HEHE>{% endfilter %}") + assert tmpl.render() == "<hehe>" + + def test_chaining(self, env): + tmpl = env.from_string("""{{ ['<foo>', '<bar>']|first|upper|escape }}""") + assert tmpl.render() == "<FOO>" + + def test_sum(self, env): + tmpl = env.from_string("""{{ [1, 2, 3, 4, 5, 6]|sum }}""") + assert tmpl.render() == "21" + + def test_sum_attributes(self, env): + tmpl = env.from_string("""{{ values|sum('value') }}""") + assert tmpl.render(values=[{"value": 23}, {"value": 1}, {"value": 18}]) == "42" + + def test_sum_attributes_nested(self, env): + tmpl = env.from_string("""{{ values|sum('real.value') }}""") + assert ( + tmpl.render( + values=[ + {"real": {"value": 23}}, + {"real": {"value": 1}}, + {"real": {"value": 18}}, + ] + ) + == "42" + ) + + def test_sum_attributes_tuple(self, env): + tmpl = env.from_string("""{{ values.items()|sum('1') }}""") + assert tmpl.render(values={"foo": 23, "bar": 1, "baz": 18}) == "42" + + def test_abs(self, env): + tmpl = env.from_string("""{{ -1|abs }}|{{ 1|abs }}""") + assert tmpl.render() == "1|1", tmpl.render() + + def test_round_positive(self, env): + tmpl = env.from_string( + "{{ 2.7|round }}|{{ 2.1|round }}|" + "{{ 2.1234|round(3, 'floor') }}|" + "{{ 2.1|round(0, 'ceil') }}" + ) + assert tmpl.render() == "3.0|2.0|2.123|3.0", tmpl.render() + + def test_round_negative(self, env): + tmpl = env.from_string( + "{{ 21.3|round(-1)}}|" + "{{ 21.3|round(-1, 'ceil')}}|" + "{{ 21.3|round(-1, 'floor')}}" + ) + assert tmpl.render() == "20.0|30.0|20.0", tmpl.render() + + def test_xmlattr(self, env): + tmpl = env.from_string( + "{{ {'foo': 42, 'bar': 23, 'fish': none, " + "'spam': missing, 'blub:blub': '<?>'}|xmlattr }}" + ) + out = tmpl.render().split() + assert len(out) == 3 + assert 'foo="42"' in out + assert 'bar="23"' in out + assert 'blub:blub="<?>"' in out + + def test_sort1(self, env): + tmpl = env.from_string("{{ [2, 3, 1]|sort }}|{{ [2, 3, 1]|sort(true) }}") + assert tmpl.render() == "[1, 2, 3]|[3, 2, 1]" + + def test_sort2(self, env): + tmpl = env.from_string('{{ "".join(["c", "A", "b", "D"]|sort) }}') + assert tmpl.render() == "AbcD" + + def test_sort3(self, env): + tmpl = env.from_string("""{{ ['foo', 'Bar', 'blah']|sort }}""") + assert tmpl.render() == "['Bar', 'blah', 'foo']" + + def test_sort4(self, env): + tmpl = env.from_string("""{{ items|sort(attribute='value')|join }}""") + assert tmpl.render(items=map(Magic, [3, 2, 4, 1])) == "1234" + + def test_sort5(self, env): + tmpl = env.from_string("""{{ items|sort(attribute='value.0')|join }}""") + assert tmpl.render(items=map(Magic, [[3], [2], [4], [1]])) == "[1][2][3][4]" + + def test_sort6(self, env): + tmpl = env.from_string("""{{ items|sort(attribute='value1,value2')|join }}""") + assert ( + tmpl.render( + items=map( + lambda x: Magic2(x[0], x[1]), [(3, 1), (2, 2), (2, 1), (2, 5)] + ) + ) + == "(2,1)(2,2)(2,5)(3,1)" + ) + + def test_sort7(self, env): + tmpl = env.from_string("""{{ items|sort(attribute='value2,value1')|join }}""") + assert ( + tmpl.render( + items=map( + lambda x: Magic2(x[0], x[1]), [(3, 1), (2, 2), (2, 1), (2, 5)] + ) + ) + == "(2,1)(3,1)(2,2)(2,5)" + ) + + def test_sort8(self, env): + tmpl = env.from_string( + """{{ items|sort(attribute='value1.0,value2.0')|join }}""" + ) + assert ( + tmpl.render( + items=map( + lambda x: Magic2(x[0], x[1]), + [([3], [1]), ([2], [2]), ([2], [1]), ([2], [5])], + ) + ) + == "([2],[1])([2],[2])([2],[5])([3],[1])" + ) + + def test_unique(self, env): + t = env.from_string('{{ "".join(["b", "A", "a", "b"]|unique) }}') + assert t.render() == "bA" + + def test_unique_case_sensitive(self, env): + t = env.from_string('{{ "".join(["b", "A", "a", "b"]|unique(true)) }}') + assert t.render() == "bAa" + + def test_unique_attribute(self, env): + t = env.from_string("{{ items|unique(attribute='value')|join }}") + assert t.render(items=map(Magic, [3, 2, 4, 1, 2])) == "3241" + + @pytest.mark.parametrize( + "source,expect", + ( + ('{{ ["a", "B"]|min }}', "a"), + ('{{ ["a", "B"]|min(case_sensitive=true) }}', "B"), + ("{{ []|min }}", ""), + ('{{ ["a", "B"]|max }}', "B"), + ('{{ ["a", "B"]|max(case_sensitive=true) }}', "a"), + ("{{ []|max }}", ""), + ), + ) + def test_min_max(self, env, source, expect): + t = env.from_string(source) + assert t.render() == expect + + @pytest.mark.parametrize(("name", "expect"), [("min", "1"), ("max", "9")]) + def test_min_max_attribute(self, env, name, expect): + t = env.from_string("{{ items|" + name + '(attribute="value") }}') + assert t.render(items=map(Magic, [5, 1, 9])) == expect + + def test_groupby(self, env): + tmpl = env.from_string( + """ + {%- for grouper, list in [{'foo': 1, 'bar': 2}, + {'foo': 2, 'bar': 3}, + {'foo': 1, 'bar': 1}, + {'foo': 3, 'bar': 4}]|groupby('foo') -%} + {{ grouper }}{% for x in list %}: {{ x.foo }}, {{ x.bar }}{% endfor %}| + {%- endfor %}""" + ) + assert tmpl.render().split("|") == ["1: 1, 2: 1, 1", "2: 2, 3", "3: 3, 4", ""] + + def test_groupby_tuple_index(self, env): + tmpl = env.from_string( + """ + {%- for grouper, list in [('a', 1), ('a', 2), ('b', 1)]|groupby(0) -%} + {{ grouper }}{% for x in list %}:{{ x.1 }}{% endfor %}| + {%- endfor %}""" + ) + assert tmpl.render() == "a:1:2|b:1|" + + def test_groupby_multidot(self, env): + Date = namedtuple("Date", "day,month,year") + Article = namedtuple("Article", "title,date") + articles = [ + Article("aha", Date(1, 1, 1970)), + Article("interesting", Date(2, 1, 1970)), + Article("really?", Date(3, 1, 1970)), + Article("totally not", Date(1, 1, 1971)), + ] + tmpl = env.from_string( + """ + {%- for year, list in articles|groupby('date.year') -%} + {{ year }}{% for x in list %}[{{ x.title }}]{% endfor %}| + {%- endfor %}""" + ) + assert tmpl.render(articles=articles).split("|") == [ + "1970[aha][interesting][really?]", + "1971[totally not]", + "", + ] + + def test_groupby_default(self, env): + tmpl = env.from_string( + "{% for city, items in users|groupby('city', default='NY') %}" + "{{ city }}: {{ items|map(attribute='name')|join(', ') }}\n" + "{% endfor %}" + ) + out = tmpl.render( + users=[ + {"name": "emma", "city": "NY"}, + {"name": "smith", "city": "WA"}, + {"name": "john"}, + ] + ) + assert out == "NY: emma, john\nWA: smith\n" + + @pytest.mark.parametrize( + ("case_sensitive", "expect"), + [ + (False, "a: 1, 3\nb: 2\n"), + (True, "A: 3\na: 1\nb: 2\n"), + ], + ) + def test_groupby_case(self, env, case_sensitive, expect): + tmpl = env.from_string( + "{% for k, vs in data|groupby('k', case_sensitive=cs) %}" + "{{ k }}: {{ vs|join(', ', attribute='v') }}\n" + "{% endfor %}" + ) + out = tmpl.render( + data=[{"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "A", "v": 3}], + cs=case_sensitive, + ) + assert out == expect + + def test_filtertag(self, env): + tmpl = env.from_string( + "{% filter upper|replace('FOO', 'foo') %}foobar{% endfilter %}" + ) + assert tmpl.render() == "fooBAR" + + def test_replace(self, env): + env = Environment() + tmpl = env.from_string('{{ string|replace("o", 42) }}') + assert tmpl.render(string="<foo>") == "<f4242>" + env = Environment(autoescape=True) + tmpl = env.from_string('{{ string|replace("o", 42) }}') + assert tmpl.render(string="<foo>") == "<f4242>" + tmpl = env.from_string('{{ string|replace("<", 42) }}') + assert tmpl.render(string="<foo>") == "42foo>" + tmpl = env.from_string('{{ string|replace("o", ">x<") }}') + assert tmpl.render(string=Markup("foo")) == "f>x<>x<" + + def test_forceescape(self, env): + tmpl = env.from_string("{{ x|forceescape }}") + assert tmpl.render(x=Markup("<div />")) == "<div />" + + def test_safe(self, env): + env = Environment(autoescape=True) + tmpl = env.from_string('{{ "<div>foo</div>"|safe }}') + assert tmpl.render() == "<div>foo</div>" + tmpl = env.from_string('{{ "<div>foo</div>" }}') + assert tmpl.render() == "<div>foo</div>" + + @pytest.mark.parametrize( + ("value", "expect"), + [ + ("Hello, world!", "Hello%2C%20world%21"), + ("Hello, world\u203d", "Hello%2C%20world%E2%80%BD"), + ({"f": 1}, "f=1"), + ([("f", 1), ("z", 2)], "f=1&z=2"), + ({"\u203d": 1}, "%E2%80%BD=1"), + ({0: 1}, "0=1"), + ([("a b/c", "a b/c")], "a+b%2Fc=a+b%2Fc"), + ("a b/c", "a%20b/c"), + ], + ) + def test_urlencode(self, value, expect): + e = Environment(autoescape=True) + t = e.from_string("{{ value|urlencode }}") + assert t.render(value=value) == expect + + def test_simple_map(self, env): + env = Environment() + tmpl = env.from_string('{{ ["1", "2", "3"]|map("int")|sum }}') + assert tmpl.render() == "6" + + def test_map_sum(self, env): + tmpl = env.from_string('{{ [[1,2], [3], [4,5,6]]|map("sum")|list }}') + assert tmpl.render() == "[3, 3, 15]" + + def test_attribute_map(self, env): + User = namedtuple("User", "name") + env = Environment() + users = [ + User("john"), + User("jane"), + User("mike"), + ] + tmpl = env.from_string('{{ users|map(attribute="name")|join("|") }}') + assert tmpl.render(users=users) == "john|jane|mike" + + def test_empty_map(self, env): + env = Environment() + tmpl = env.from_string('{{ none|map("upper")|list }}') + assert tmpl.render() == "[]" + + def test_map_default(self, env): + Fullname = namedtuple("Fullname", "firstname,lastname") + Firstname = namedtuple("Firstname", "firstname") + env = Environment() + tmpl = env.from_string( + '{{ users|map(attribute="lastname", default="smith")|join(", ") }}' + ) + test_list = env.from_string( + '{{ users|map(attribute="lastname", default=["smith","x"])|join(", ") }}' + ) + test_str = env.from_string( + '{{ users|map(attribute="lastname", default="")|join(", ") }}' + ) + users = [ + Fullname("john", "lennon"), + Fullname("jane", "edwards"), + Fullname("jon", None), + Firstname("mike"), + ] + assert tmpl.render(users=users) == "lennon, edwards, None, smith" + assert test_list.render(users=users) == "lennon, edwards, None, ['smith', 'x']" + assert test_str.render(users=users) == "lennon, edwards, None, " + + def test_simple_select(self, env): + env = Environment() + tmpl = env.from_string('{{ [1, 2, 3, 4, 5]|select("odd")|join("|") }}') + assert tmpl.render() == "1|3|5" + + def test_bool_select(self, env): + env = Environment() + tmpl = env.from_string('{{ [none, false, 0, 1, 2, 3, 4, 5]|select|join("|") }}') + assert tmpl.render() == "1|2|3|4|5" + + def test_simple_reject(self, env): + env = Environment() + tmpl = env.from_string('{{ [1, 2, 3, 4, 5]|reject("odd")|join("|") }}') + assert tmpl.render() == "2|4" + + def test_bool_reject(self, env): + env = Environment() + tmpl = env.from_string('{{ [none, false, 0, 1, 2, 3, 4, 5]|reject|join("|") }}') + assert tmpl.render() == "None|False|0" + + def test_simple_select_attr(self, env): + User = namedtuple("User", "name,is_active") + env = Environment() + users = [ + User("john", True), + User("jane", True), + User("mike", False), + ] + tmpl = env.from_string( + '{{ users|selectattr("is_active")|map(attribute="name")|join("|") }}' + ) + assert tmpl.render(users=users) == "john|jane" + + def test_simple_reject_attr(self, env): + User = namedtuple("User", "name,is_active") + env = Environment() + users = [ + User("john", True), + User("jane", True), + User("mike", False), + ] + tmpl = env.from_string( + '{{ users|rejectattr("is_active")|map(attribute="name")|join("|") }}' + ) + assert tmpl.render(users=users) == "mike" + + def test_func_select_attr(self, env): + User = namedtuple("User", "id,name") + env = Environment() + users = [ + User(1, "john"), + User(2, "jane"), + User(3, "mike"), + ] + tmpl = env.from_string( + '{{ users|selectattr("id", "odd")|map(attribute="name")|join("|") }}' + ) + assert tmpl.render(users=users) == "john|mike" + + def test_func_reject_attr(self, env): + User = namedtuple("User", "id,name") + env = Environment() + users = [ + User(1, "john"), + User(2, "jane"), + User(3, "mike"), + ] + tmpl = env.from_string( + '{{ users|rejectattr("id", "odd")|map(attribute="name")|join("|") }}' + ) + assert tmpl.render(users=users) == "jane" + + def test_json_dump(self): + env = Environment(autoescape=True) + t = env.from_string("{{ x|tojson }}") + assert t.render(x={"foo": "bar"}) == '{"foo": "bar"}' + assert t.render(x="\"ba&r'") == r'"\"ba\u0026r\u0027"' + assert t.render(x="<bar>") == r'"\u003cbar\u003e"' + + def my_dumps(value, **options): + assert options == {"foo": "bar"} + return "42" + + env.policies["json.dumps_function"] = my_dumps + env.policies["json.dumps_kwargs"] = {"foo": "bar"} + assert t.render(x=23) == "42" + + def test_wordwrap(self, env): + env.newline_sequence = "\n" + t = env.from_string("{{ s|wordwrap(20) }}") + result = t.render(s="Hello!\nThis is Jinja saying something.") + assert result == "Hello!\nThis is Jinja saying\nsomething." + + def test_filter_undefined(self, env): + with pytest.raises(TemplateAssertionError, match="No filter named 'f'"): + env.from_string("{{ var|f }}") + + def test_filter_undefined_in_if(self, env): + t = env.from_string("{%- if x is defined -%}{{ x|f }}{%- else -%}x{% endif %}") + assert t.render() == "x" + with pytest.raises(TemplateRuntimeError, match="No filter named 'f'"): + t.render(x=42) + + def test_filter_undefined_in_elif(self, env): + t = env.from_string( + "{%- if x is defined -%}{{ x }}{%- elif y is defined -%}" + "{{ y|f }}{%- else -%}foo{%- endif -%}" + ) + assert t.render() == "foo" + with pytest.raises(TemplateRuntimeError, match="No filter named 'f'"): + t.render(y=42) + + def test_filter_undefined_in_else(self, env): + t = env.from_string( + "{%- if x is not defined -%}foo{%- else -%}{{ x|f }}{%- endif -%}" + ) + assert t.render() == "foo" + with pytest.raises(TemplateRuntimeError, match="No filter named 'f'"): + t.render(x=42) + + def test_filter_undefined_in_nested_if(self, env): + t = env.from_string( + "{%- if x is not defined -%}foo{%- else -%}{%- if y " + "is defined -%}{{ y|f }}{%- endif -%}{{ x }}{%- endif -%}" + ) + assert t.render() == "foo" + assert t.render(x=42) == "42" + with pytest.raises(TemplateRuntimeError, match="No filter named 'f'"): + t.render(x=24, y=42) + + def test_filter_undefined_in_condexpr(self, env): + t1 = env.from_string("{{ x|f if x is defined else 'foo' }}") + t2 = env.from_string("{{ 'foo' if x is not defined else x|f }}") + assert t1.render() == t2.render() == "foo" + + with pytest.raises(TemplateRuntimeError, match="No filter named 'f'"): + t1.render(x=42) + t2.render(x=42) diff --git a/tests/test_idtracking.py b/tests/test_idtracking.py new file mode 100644 index 0000000..4e1d2c3 --- /dev/null +++ b/tests/test_idtracking.py @@ -0,0 +1,290 @@ +from jinja2 import nodes +from jinja2.idtracking import symbols_for_node + + +def test_basics(): + for_loop = nodes.For( + nodes.Name("foo", "store"), + nodes.Name("seq", "load"), + [nodes.Output([nodes.Name("foo", "load")])], + [], + None, + False, + ) + tmpl = nodes.Template( + [nodes.Assign(nodes.Name("foo", "store"), nodes.Name("bar", "load")), for_loop] + ) + + sym = symbols_for_node(tmpl) + assert sym.refs == { + "foo": "l_0_foo", + "bar": "l_0_bar", + "seq": "l_0_seq", + } + assert sym.loads == { + "l_0_foo": ("undefined", None), + "l_0_bar": ("resolve", "bar"), + "l_0_seq": ("resolve", "seq"), + } + + sym = symbols_for_node(for_loop, sym) + assert sym.refs == { + "foo": "l_1_foo", + } + assert sym.loads == { + "l_1_foo": ("param", None), + } + + +def test_complex(): + title_block = nodes.Block( + "title", [nodes.Output([nodes.TemplateData("Page Title")])], False, False + ) + + render_title_macro = nodes.Macro( + "render_title", + [nodes.Name("title", "param")], + [], + [ + nodes.Output( + [ + nodes.TemplateData('\n <div class="title">\n <h1>'), + nodes.Name("title", "load"), + nodes.TemplateData("</h1>\n <p>"), + nodes.Name("subtitle", "load"), + nodes.TemplateData("</p>\n "), + ] + ), + nodes.Assign( + nodes.Name("subtitle", "store"), nodes.Const("something else") + ), + nodes.Output( + [ + nodes.TemplateData("\n <p>"), + nodes.Name("subtitle", "load"), + nodes.TemplateData("</p>\n </div>\n"), + nodes.If( + nodes.Name("something", "load"), + [ + nodes.Assign( + nodes.Name("title_upper", "store"), + nodes.Filter( + nodes.Name("title", "load"), + "upper", + [], + [], + None, + None, + ), + ), + nodes.Output( + [ + nodes.Name("title_upper", "load"), + nodes.Call( + nodes.Name("render_title", "load"), + [nodes.Const("Aha")], + [], + None, + None, + ), + ] + ), + ], + [], + [], + ), + ] + ), + ], + ) + + for_loop = nodes.For( + nodes.Name("item", "store"), + nodes.Name("seq", "load"), + [ + nodes.Output( + [ + nodes.TemplateData("\n <li>"), + nodes.Name("item", "load"), + nodes.TemplateData("</li>\n <span>"), + ] + ), + nodes.Include(nodes.Const("helper.html"), True, False), + nodes.Output([nodes.TemplateData("</span>\n ")]), + ], + [], + None, + False, + ) + + body_block = nodes.Block( + "body", + [ + nodes.Output( + [ + nodes.TemplateData("\n "), + nodes.Call( + nodes.Name("render_title", "load"), + [nodes.Name("item", "load")], + [], + None, + None, + ), + nodes.TemplateData("\n <ul>\n "), + ] + ), + for_loop, + nodes.Output([nodes.TemplateData("\n </ul>\n")]), + ], + False, + False, + ) + + tmpl = nodes.Template( + [ + nodes.Extends(nodes.Const("layout.html")), + title_block, + render_title_macro, + body_block, + ] + ) + + tmpl_sym = symbols_for_node(tmpl) + assert tmpl_sym.refs == { + "render_title": "l_0_render_title", + } + assert tmpl_sym.loads == { + "l_0_render_title": ("undefined", None), + } + assert tmpl_sym.stores == {"render_title"} + assert tmpl_sym.dump_stores() == { + "render_title": "l_0_render_title", + } + + macro_sym = symbols_for_node(render_title_macro, tmpl_sym) + assert macro_sym.refs == { + "subtitle": "l_1_subtitle", + "something": "l_1_something", + "title": "l_1_title", + "title_upper": "l_1_title_upper", + } + assert macro_sym.loads == { + "l_1_subtitle": ("resolve", "subtitle"), + "l_1_something": ("resolve", "something"), + "l_1_title": ("param", None), + "l_1_title_upper": ("resolve", "title_upper"), + } + assert macro_sym.stores == {"title", "title_upper", "subtitle"} + assert macro_sym.find_ref("render_title") == "l_0_render_title" + assert macro_sym.dump_stores() == { + "title": "l_1_title", + "title_upper": "l_1_title_upper", + "subtitle": "l_1_subtitle", + "render_title": "l_0_render_title", + } + + body_sym = symbols_for_node(body_block) + assert body_sym.refs == { + "item": "l_0_item", + "seq": "l_0_seq", + "render_title": "l_0_render_title", + } + assert body_sym.loads == { + "l_0_item": ("resolve", "item"), + "l_0_seq": ("resolve", "seq"), + "l_0_render_title": ("resolve", "render_title"), + } + assert body_sym.stores == set() + + for_sym = symbols_for_node(for_loop, body_sym) + assert for_sym.refs == { + "item": "l_1_item", + } + assert for_sym.loads == { + "l_1_item": ("param", None), + } + assert for_sym.stores == {"item"} + assert for_sym.dump_stores() == { + "item": "l_1_item", + } + + +def test_if_branching_stores(): + tmpl = nodes.Template( + [ + nodes.If( + nodes.Name("expression", "load"), + [nodes.Assign(nodes.Name("variable", "store"), nodes.Const(42))], + [], + [], + ) + ] + ) + + sym = symbols_for_node(tmpl) + assert sym.refs == {"variable": "l_0_variable", "expression": "l_0_expression"} + assert sym.stores == {"variable"} + assert sym.loads == { + "l_0_variable": ("resolve", "variable"), + "l_0_expression": ("resolve", "expression"), + } + assert sym.dump_stores() == { + "variable": "l_0_variable", + } + + +def test_if_branching_stores_undefined(): + tmpl = nodes.Template( + [ + nodes.Assign(nodes.Name("variable", "store"), nodes.Const(23)), + nodes.If( + nodes.Name("expression", "load"), + [nodes.Assign(nodes.Name("variable", "store"), nodes.Const(42))], + [], + [], + ), + ] + ) + + sym = symbols_for_node(tmpl) + assert sym.refs == {"variable": "l_0_variable", "expression": "l_0_expression"} + assert sym.stores == {"variable"} + assert sym.loads == { + "l_0_variable": ("undefined", None), + "l_0_expression": ("resolve", "expression"), + } + assert sym.dump_stores() == { + "variable": "l_0_variable", + } + + +def test_if_branching_multi_scope(): + for_loop = nodes.For( + nodes.Name("item", "store"), + nodes.Name("seq", "load"), + [ + nodes.If( + nodes.Name("expression", "load"), + [nodes.Assign(nodes.Name("x", "store"), nodes.Const(42))], + [], + [], + ), + nodes.Include(nodes.Const("helper.html"), True, False), + ], + [], + None, + False, + ) + + tmpl = nodes.Template( + [nodes.Assign(nodes.Name("x", "store"), nodes.Const(23)), for_loop] + ) + + tmpl_sym = symbols_for_node(tmpl) + for_sym = symbols_for_node(for_loop, tmpl_sym) + assert for_sym.stores == {"item", "x"} + assert for_sym.loads == { + "l_1_x": ("alias", "l_0_x"), + "l_1_item": ("param", None), + "l_1_expression": ("resolve", "expression"), + } diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..b59fb49 --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,205 @@ +import pytest + +from jinja2.environment import Environment +from jinja2.exceptions import TemplateNotFound +from jinja2.exceptions import TemplatesNotFound +from jinja2.exceptions import TemplateSyntaxError +from jinja2.exceptions import UndefinedError +from jinja2.loaders import DictLoader + + +@pytest.fixture +def test_env(): + env = Environment( + loader=DictLoader( + dict( + module="{% macro test() %}[{{ foo }}|{{ bar }}]{% endmacro %}", + header="[{{ foo }}|{{ 23 }}]", + o_printer="({{ o }})", + ) + ) + ) + env.globals["bar"] = 23 + return env + + +class TestImports: + def test_context_imports(self, test_env): + t = test_env.from_string('{% import "module" as m %}{{ m.test() }}') + assert t.render(foo=42) == "[|23]" + t = test_env.from_string( + '{% import "module" as m without context %}{{ m.test() }}' + ) + assert t.render(foo=42) == "[|23]" + t = test_env.from_string( + '{% import "module" as m with context %}{{ m.test() }}' + ) + assert t.render(foo=42) == "[42|23]" + t = test_env.from_string('{% from "module" import test %}{{ test() }}') + assert t.render(foo=42) == "[|23]" + t = test_env.from_string( + '{% from "module" import test without context %}{{ test() }}' + ) + assert t.render(foo=42) == "[|23]" + t = test_env.from_string( + '{% from "module" import test with context %}{{ test() }}' + ) + assert t.render(foo=42) == "[42|23]" + + def test_import_needs_name(self, test_env): + test_env.from_string('{% from "foo" import bar %}') + test_env.from_string('{% from "foo" import bar, baz %}') + + with pytest.raises(TemplateSyntaxError): + test_env.from_string('{% from "foo" import %}') + + def test_no_trailing_comma(self, test_env): + with pytest.raises(TemplateSyntaxError): + test_env.from_string('{% from "foo" import bar, %}') + + with pytest.raises(TemplateSyntaxError): + test_env.from_string('{% from "foo" import bar,, %}') + + with pytest.raises(TemplateSyntaxError): + test_env.from_string('{% from "foo" import, %}') + + def test_trailing_comma_with_context(self, test_env): + test_env.from_string('{% from "foo" import bar, baz with context %}') + test_env.from_string('{% from "foo" import bar, baz, with context %}') + test_env.from_string('{% from "foo" import bar, with context %}') + test_env.from_string('{% from "foo" import bar, with, context %}') + test_env.from_string('{% from "foo" import bar, with with context %}') + + with pytest.raises(TemplateSyntaxError): + test_env.from_string('{% from "foo" import bar,, with context %}') + + with pytest.raises(TemplateSyntaxError): + test_env.from_string('{% from "foo" import bar with context, %}') + + def test_exports(self, test_env): + m = test_env.from_string( + """ + {% macro toplevel() %}...{% endmacro %} + {% macro __private() %}...{% endmacro %} + {% set variable = 42 %} + {% for item in [1] %} + {% macro notthere() %}{% endmacro %} + {% endfor %} + """ + ).module + assert m.toplevel() == "..." + assert not hasattr(m, "__missing") + assert m.variable == 42 + assert not hasattr(m, "notthere") + + def test_not_exported(self, test_env): + t = test_env.from_string("{% from 'module' import nothing %}{{ nothing() }}") + + with pytest.raises(UndefinedError, match="does not export the requested name"): + t.render() + + def test_import_with_globals(self, test_env): + t = test_env.from_string( + '{% import "module" as m %}{{ m.test() }}', globals={"foo": 42} + ) + assert t.render() == "[42|23]" + + t = test_env.from_string('{% import "module" as m %}{{ m.test() }}') + assert t.render() == "[|23]" + + def test_import_with_globals_override(self, test_env): + t = test_env.from_string( + '{% set foo = 41 %}{% import "module" as m %}{{ m.test() }}', + globals={"foo": 42}, + ) + assert t.render() == "[42|23]" + + def test_from_import_with_globals(self, test_env): + t = test_env.from_string( + '{% from "module" import test %}{{ test() }}', + globals={"foo": 42}, + ) + assert t.render() == "[42|23]" + + +class TestIncludes: + def test_context_include(self, test_env): + t = test_env.from_string('{% include "header" %}') + assert t.render(foo=42) == "[42|23]" + t = test_env.from_string('{% include "header" with context %}') + assert t.render(foo=42) == "[42|23]" + t = test_env.from_string('{% include "header" without context %}') + assert t.render(foo=42) == "[|23]" + + def test_choice_includes(self, test_env): + t = test_env.from_string('{% include ["missing", "header"] %}') + assert t.render(foo=42) == "[42|23]" + + t = test_env.from_string('{% include ["missing", "missing2"] ignore missing %}') + assert t.render(foo=42) == "" + + t = test_env.from_string('{% include ["missing", "missing2"] %}') + pytest.raises(TemplateNotFound, t.render) + with pytest.raises(TemplatesNotFound) as e: + t.render() + + assert e.value.templates == ["missing", "missing2"] + assert e.value.name == "missing2" + + def test_includes(t, **ctx): + ctx["foo"] = 42 + assert t.render(ctx) == "[42|23]" + + t = test_env.from_string('{% include ["missing", "header"] %}') + test_includes(t) + t = test_env.from_string("{% include x %}") + test_includes(t, x=["missing", "header"]) + t = test_env.from_string('{% include [x, "header"] %}') + test_includes(t, x="missing") + t = test_env.from_string("{% include x %}") + test_includes(t, x="header") + t = test_env.from_string("{% include [x] %}") + test_includes(t, x="header") + + def test_include_ignoring_missing(self, test_env): + t = test_env.from_string('{% include "missing" %}') + pytest.raises(TemplateNotFound, t.render) + for extra in "", "with context", "without context": + t = test_env.from_string( + '{% include "missing" ignore missing ' + extra + " %}" + ) + assert t.render() == "" + + def test_context_include_with_overrides(self, test_env): + env = Environment( + loader=DictLoader( + dict( + main="{% for item in [1, 2, 3] %}{% include 'item' %}{% endfor %}", + item="{{ item }}", + ) + ) + ) + assert env.get_template("main").render() == "123" + + def test_unoptimized_scopes(self, test_env): + t = test_env.from_string( + """ + {% macro outer(o) %} + {% macro inner() %} + {% include "o_printer" %} + {% endmacro %} + {{ inner() }} + {% endmacro %} + {{ outer("FOO") }} + """ + ) + assert t.render().strip() == "(FOO)" + + def test_import_from_with_context(self): + env = Environment( + loader=DictLoader({"a": "{% macro x() %}{{ foobar }}{% endmacro %}"}) + ) + t = env.from_string( + "{% set foobar = 42 %}{% from 'a' import x with context %}{{ x() }}" + ) + assert t.render() == "42" diff --git a/tests/test_inheritance.py b/tests/test_inheritance.py new file mode 100644 index 0000000..0c20d4d --- /dev/null +++ b/tests/test_inheritance.py @@ -0,0 +1,405 @@ +import pytest + +from jinja2 import DictLoader +from jinja2 import Environment +from jinja2 import TemplateRuntimeError +from jinja2 import TemplateSyntaxError + +LAYOUTTEMPLATE = """\ +|{% block block1 %}block 1 from layout{% endblock %} +|{% block block2 %}block 2 from layout{% endblock %} +|{% block block3 %} +{% block block4 %}nested block 4 from layout{% endblock %} +{% endblock %}|""" + +LEVEL1TEMPLATE = """\ +{% extends "layout" %} +{% block block1 %}block 1 from level1{% endblock %}""" + +LEVEL2TEMPLATE = """\ +{% extends "level1" %} +{% block block2 %}{% block block5 %}nested block 5 from level2{% +endblock %}{% endblock %}""" + +LEVEL3TEMPLATE = """\ +{% extends "level2" %} +{% block block5 %}block 5 from level3{% endblock %} +{% block block4 %}block 4 from level3{% endblock %} +""" + +LEVEL4TEMPLATE = """\ +{% extends "level3" %} +{% block block3 %}block 3 from level4{% endblock %} +""" + +WORKINGTEMPLATE = """\ +{% extends "layout" %} +{% block block1 %} + {% if false %} + {% block block2 %} + this should work + {% endblock %} + {% endif %} +{% endblock %} +""" + +DOUBLEEXTENDS = """\ +{% extends "layout" %} +{% extends "layout" %} +{% block block1 %} + {% if false %} + {% block block2 %} + this should work + {% endblock %} + {% endif %} +{% endblock %} +""" + + +@pytest.fixture +def env(): + return Environment( + loader=DictLoader( + { + "layout": LAYOUTTEMPLATE, + "level1": LEVEL1TEMPLATE, + "level2": LEVEL2TEMPLATE, + "level3": LEVEL3TEMPLATE, + "level4": LEVEL4TEMPLATE, + "working": WORKINGTEMPLATE, + "doublee": DOUBLEEXTENDS, + } + ), + trim_blocks=True, + ) + + +class TestInheritance: + def test_layout(self, env): + tmpl = env.get_template("layout") + assert tmpl.render() == ( + "|block 1 from layout|block 2 from layout|nested block 4 from layout|" + ) + + def test_level1(self, env): + tmpl = env.get_template("level1") + assert tmpl.render() == ( + "|block 1 from level1|block 2 from layout|nested block 4 from layout|" + ) + + def test_level2(self, env): + tmpl = env.get_template("level2") + assert tmpl.render() == ( + "|block 1 from level1|nested block 5 from " + "level2|nested block 4 from layout|" + ) + + def test_level3(self, env): + tmpl = env.get_template("level3") + assert tmpl.render() == ( + "|block 1 from level1|block 5 from level3|block 4 from level3|" + ) + + def test_level4(self, env): + tmpl = env.get_template("level4") + assert tmpl.render() == ( + "|block 1 from level1|block 5 from level3|block 3 from level4|" + ) + + def test_super(self, env): + env = Environment( + loader=DictLoader( + { + "a": "{% block intro %}INTRO{% endblock %}|" + "BEFORE|{% block data %}INNER{% endblock %}|AFTER", + "b": '{% extends "a" %}{% block data %}({{ ' + "super() }}){% endblock %}", + "c": '{% extends "b" %}{% block intro %}--{{ ' + "super() }}--{% endblock %}\n{% block data " + "%}[{{ super() }}]{% endblock %}", + } + ) + ) + tmpl = env.get_template("c") + assert tmpl.render() == "--INTRO--|BEFORE|[(INNER)]|AFTER" + + def test_working(self, env): + env.get_template("working") + + def test_reuse_blocks(self, env): + tmpl = env.from_string( + "{{ self.foo() }}|{% block foo %}42{% endblock %}|{{ self.foo() }}" + ) + assert tmpl.render() == "42|42|42" + + def test_preserve_blocks(self, env): + env = Environment( + loader=DictLoader( + { + "a": "{% if false %}{% block x %}A{% endblock %}" + "{% endif %}{{ self.x() }}", + "b": '{% extends "a" %}{% block x %}B{{ super() }}{% endblock %}', + } + ) + ) + tmpl = env.get_template("b") + assert tmpl.render() == "BA" + + def test_dynamic_inheritance(self, env): + env = Environment( + loader=DictLoader( + { + "default1": "DEFAULT1{% block x %}{% endblock %}", + "default2": "DEFAULT2{% block x %}{% endblock %}", + "child": "{% extends default %}{% block x %}CHILD{% endblock %}", + } + ) + ) + tmpl = env.get_template("child") + for m in range(1, 3): + assert tmpl.render(default=f"default{m}") == f"DEFAULT{m}CHILD" + + def test_multi_inheritance(self, env): + env = Environment( + loader=DictLoader( + { + "default1": "DEFAULT1{% block x %}{% endblock %}", + "default2": "DEFAULT2{% block x %}{% endblock %}", + "child": ( + "{% if default %}{% extends default %}{% else %}" + "{% extends 'default1' %}{% endif %}" + "{% block x %}CHILD{% endblock %}" + ), + } + ) + ) + tmpl = env.get_template("child") + assert tmpl.render(default="default2") == "DEFAULT2CHILD" + assert tmpl.render(default="default1") == "DEFAULT1CHILD" + assert tmpl.render() == "DEFAULT1CHILD" + + def test_scoped_block(self, env): + env = Environment( + loader=DictLoader( + { + "default.html": "{% for item in seq %}[{% block item scoped %}" + "{% endblock %}]{% endfor %}" + } + ) + ) + t = env.from_string( + "{% extends 'default.html' %}{% block item %}{{ item }}{% endblock %}" + ) + assert t.render(seq=list(range(5))) == "[0][1][2][3][4]" + + def test_super_in_scoped_block(self, env): + env = Environment( + loader=DictLoader( + { + "default.html": "{% for item in seq %}[{% block item scoped %}" + "{{ item }}{% endblock %}]{% endfor %}" + } + ) + ) + t = env.from_string( + '{% extends "default.html" %}{% block item %}' + "{{ super() }}|{{ item * 2 }}{% endblock %}" + ) + assert t.render(seq=list(range(5))) == "[0|0][1|2][2|4][3|6][4|8]" + + def test_scoped_block_after_inheritance(self, env): + env = Environment( + loader=DictLoader( + { + "layout.html": """ + {% block useless %}{% endblock %} + """, + "index.html": """ + {%- extends 'layout.html' %} + {% from 'helpers.html' import foo with context %} + {% block useless %} + {% for x in [1, 2, 3] %} + {% block testing scoped %} + {{ foo(x) }} + {% endblock %} + {% endfor %} + {% endblock %} + """, + "helpers.html": """ + {% macro foo(x) %}{{ the_foo + x }}{% endmacro %} + """, + } + ) + ) + rv = env.get_template("index.html").render(the_foo=42).split() + assert rv == ["43", "44", "45"] + + def test_level1_required(self, env): + env = Environment( + loader=DictLoader( + { + "default": "{% block x required %}{# comment #}\n {% endblock %}", + "level1": "{% extends 'default' %}{% block x %}[1]{% endblock %}", + } + ) + ) + rv = env.get_template("level1").render() + assert rv == "[1]" + + def test_level2_required(self, env): + env = Environment( + loader=DictLoader( + { + "default": "{% block x required %}{% endblock %}", + "level1": "{% extends 'default' %}{% block x %}[1]{% endblock %}", + "level2": "{% extends 'default' %}{% block x %}[2]{% endblock %}", + } + ) + ) + rv1 = env.get_template("level1").render() + rv2 = env.get_template("level2").render() + + assert rv1 == "[1]" + assert rv2 == "[2]" + + def test_level3_required(self, env): + env = Environment( + loader=DictLoader( + { + "default": "{% block x required %}{% endblock %}", + "level1": "{% extends 'default' %}", + "level2": "{% extends 'level1' %}{% block x %}[2]{% endblock %}", + "level3": "{% extends 'level2' %}", + } + ) + ) + t1 = env.get_template("level1") + t2 = env.get_template("level2") + t3 = env.get_template("level3") + + with pytest.raises(TemplateRuntimeError, match="Required block 'x' not found"): + assert t1.render() + + assert t2.render() == "[2]" + assert t3.render() == "[2]" + + def test_invalid_required(self, env): + env = Environment( + loader=DictLoader( + { + "default": "{% block x required %}data {# #}{% endblock %}", + "default1": "{% block x required %}{% block y %}" + "{% endblock %} {% endblock %}", + "default2": "{% block x required %}{% if true %}" + "{% endif %} {% endblock %}", + "level1": "{% if default %}{% extends default %}" + "{% else %}{% extends 'default' %}{% endif %}" + "{%- block x %}CHILD{% endblock %}", + } + ) + ) + t = env.get_template("level1") + + with pytest.raises( + TemplateSyntaxError, + match="Required blocks can only contain comments or whitespace", + ): + assert t.render(default="default") + assert t.render(default="default2") + assert t.render(default="default3") + + def test_required_with_scope(self, env): + env = Environment( + loader=DictLoader( + { + "default1": "{% for item in seq %}[{% block item scoped required %}" + "{% endblock %}]{% endfor %}", + "child1": "{% extends 'default1' %}{% block item %}" + "{{ item }}{% endblock %}", + "default2": "{% for item in seq %}[{% block item required scoped %}" + "{% endblock %}]{% endfor %}", + "child2": "{% extends 'default2' %}{% block item %}" + "{{ item }}{% endblock %}", + } + ) + ) + t1 = env.get_template("child1") + t2 = env.get_template("child2") + + assert t1.render(seq=list(range(3))) == "[0][1][2]" + + # scoped must come before required + with pytest.raises(TemplateSyntaxError): + t2.render(seq=list(range(3))) + + def test_duplicate_required_or_scoped(self, env): + env = Environment( + loader=DictLoader( + { + "default1": "{% for item in seq %}[{% block item " + "scoped scoped %}}{{% endblock %}}]{{% endfor %}}", + "default2": "{% for item in seq %}[{% block item " + "required required %}}{{% endblock %}}]{{% endfor %}}", + "child": "{% if default %}{% extends default %}{% else %}" + "{% extends 'default1' %}{% endif %}{%- block x %}" + "CHILD{% endblock %}", + } + ) + ) + tmpl = env.get_template("child") + with pytest.raises(TemplateSyntaxError): + tmpl.render(default="default1", seq=list(range(3))) + tmpl.render(default="default2", seq=list(range(3))) + + +class TestBugFix: + def test_fixed_macro_scoping_bug(self, env): + assert ( + Environment( + loader=DictLoader( + { + "test.html": """\ + {% extends 'details.html' %} + + {% macro my_macro() %} + my_macro + {% endmacro %} + + {% block inner_box %} + {{ my_macro() }} + {% endblock %} + """, + "details.html": """\ + {% extends 'standard.html' %} + + {% macro my_macro() %} + my_macro + {% endmacro %} + + {% block content %} + {% block outer_box %} + outer_box + {% block inner_box %} + inner_box + {% endblock %} + {% endblock %} + {% endblock %} + """, + "standard.html": """ + {% block content %} {% endblock %} + """, + } + ) + ) + .get_template("test.html") + .render() + .split() + == ["outer_box", "my_macro"] + ) + + def test_double_extends(self, env): + """Ensures that a template with more than 1 {% extends ... %} usage + raises a ``TemplateError``. + """ + with pytest.raises(TemplateRuntimeError, match="extended multiple times"): + env.get_template("doublee").render() diff --git a/tests/test_lexnparse.py b/tests/test_lexnparse.py new file mode 100644 index 0000000..c02adad --- /dev/null +++ b/tests/test_lexnparse.py @@ -0,0 +1,1030 @@ +import pytest + +from jinja2 import Environment +from jinja2 import nodes +from jinja2 import Template +from jinja2 import TemplateSyntaxError +from jinja2 import UndefinedError +from jinja2.lexer import Token +from jinja2.lexer import TOKEN_BLOCK_BEGIN +from jinja2.lexer import TOKEN_BLOCK_END +from jinja2.lexer import TOKEN_EOF +from jinja2.lexer import TokenStream + + +class TestTokenStream: + test_tokens = [ + Token(1, TOKEN_BLOCK_BEGIN, ""), + Token(2, TOKEN_BLOCK_END, ""), + ] + + def test_simple(self, env): + ts = TokenStream(self.test_tokens, "foo", "bar") + assert ts.current.type is TOKEN_BLOCK_BEGIN + assert bool(ts) + assert not bool(ts.eos) + next(ts) + assert ts.current.type is TOKEN_BLOCK_END + assert bool(ts) + assert not bool(ts.eos) + next(ts) + assert ts.current.type is TOKEN_EOF + assert not bool(ts) + assert bool(ts.eos) + + def test_iter(self, env): + token_types = [t.type for t in TokenStream(self.test_tokens, "foo", "bar")] + assert token_types == [ + "block_begin", + "block_end", + ] + + +class TestLexer: + def test_raw1(self, env): + tmpl = env.from_string( + "{% raw %}foo{% endraw %}|" + "{%raw%}{{ bar }}|{% baz %}{% endraw %}" + ) + assert tmpl.render() == "foo|{{ bar }}|{% baz %}" + + def test_raw2(self, env): + tmpl = env.from_string("1 {%- raw -%} 2 {%- endraw -%} 3") + assert tmpl.render() == "123" + + def test_raw3(self, env): + # The second newline after baz exists because it is AFTER the + # {% raw %} and is ignored. + env = Environment(lstrip_blocks=True, trim_blocks=True) + tmpl = env.from_string("bar\n{% raw %}\n {{baz}}2 spaces\n{% endraw %}\nfoo") + assert tmpl.render(baz="test") == "bar\n\n {{baz}}2 spaces\nfoo" + + def test_raw4(self, env): + # The trailing dash of the {% raw -%} cleans both the spaces and + # newlines up to the first character of data. + env = Environment(lstrip_blocks=True, trim_blocks=False) + tmpl = env.from_string( + "bar\n{%- raw -%}\n\n \n 2 spaces\n space{%- endraw -%}\nfoo" + ) + assert tmpl.render() == "bar2 spaces\n spacefoo" + + def test_balancing(self, env): + env = Environment("{%", "%}", "${", "}") + tmpl = env.from_string( + """{% for item in seq + %}${{'foo': item}|upper}{% endfor %}""" + ) + assert tmpl.render(seq=list(range(3))) == "{'FOO': 0}{'FOO': 1}{'FOO': 2}" + + def test_comments(self, env): + env = Environment("<!--", "-->", "{", "}") + tmpl = env.from_string( + """\ +<ul> +<!--- for item in seq --> + <li>{item}</li> +<!--- endfor --> +</ul>""" + ) + assert tmpl.render(seq=list(range(3))) == ( + "<ul>\n <li>0</li>\n <li>1</li>\n <li>2</li>\n</ul>" + ) + + def test_string_escapes(self, env): + for char in "\0", "\u2668", "\xe4", "\t", "\r", "\n": + tmpl = env.from_string(f"{{{{ {char!r} }}}}") + assert tmpl.render() == char + assert env.from_string('{{ "\N{HOT SPRINGS}" }}').render() == "\u2668" + + def test_bytefallback(self, env): + from pprint import pformat + + tmpl = env.from_string("""{{ 'foo'|pprint }}|{{ 'bär'|pprint }}""") + assert tmpl.render() == pformat("foo") + "|" + pformat("bär") + + def test_operators(self, env): + from jinja2.lexer import operators + + for test, expect in operators.items(): + if test in "([{}])": + continue + stream = env.lexer.tokenize(f"{{{{ {test} }}}}") + next(stream) + assert stream.current.type == expect + + def test_normalizing(self, env): + for seq in "\r", "\r\n", "\n": + env = Environment(newline_sequence=seq) + tmpl = env.from_string("1\n2\r\n3\n4\n") + result = tmpl.render() + assert result.replace(seq, "X") == "1X2X3X4" + + def test_trailing_newline(self, env): + for keep in [True, False]: + env = Environment(keep_trailing_newline=keep) + for template, expected in [ + ("", {}), + ("no\nnewline", {}), + ("with\nnewline\n", {False: "with\nnewline"}), + ("with\nseveral\n\n\n", {False: "with\nseveral\n\n"}), + ]: + tmpl = env.from_string(template) + expect = expected.get(keep, template) + result = tmpl.render() + assert result == expect, (keep, template, result, expect) + + @pytest.mark.parametrize( + ("name", "valid"), + [ + ("foo", True), + ("föö", True), + ("き", True), + ("_", True), + ("1a", False), # invalid ascii start + ("a-", False), # invalid ascii continue + ("\U0001f40da", False), # invalid unicode start + ("a🐍\U0001f40d", False), # invalid unicode continue + # start characters not matched by \w + ("\u1885", True), + ("\u1886", True), + ("\u2118", True), + ("\u212e", True), + # continue character not matched by \w + ("\xb7", False), + ("a\xb7", True), + ], + ) + def test_name(self, env, name, valid): + t = "{{ " + name + " }}" + + if valid: + # valid for version being tested, shouldn't raise + env.from_string(t) + else: + pytest.raises(TemplateSyntaxError, env.from_string, t) + + def test_lineno_with_strip(self, env): + tokens = env.lex( + """\ +<html> + <body> + {%- block content -%} + <hr> + {{ item }} + {% endblock %} + </body> +</html>""" + ) + for tok in tokens: + lineno, token_type, value = tok + if token_type == "name" and value == "item": + assert lineno == 5 + break + + +class TestParser: + def test_php_syntax(self, env): + env = Environment("<?", "?>", "<?=", "?>", "<!--", "-->") + tmpl = env.from_string( + """\ +<!-- I'm a comment, I'm not interesting -->\ +<? for item in seq -?> + <?= item ?> +<?- endfor ?>""" + ) + assert tmpl.render(seq=list(range(5))) == "01234" + + def test_erb_syntax(self, env): + env = Environment("<%", "%>", "<%=", "%>", "<%#", "%>") + tmpl = env.from_string( + """\ +<%# I'm a comment, I'm not interesting %>\ +<% for item in seq -%> + <%= item %> +<%- endfor %>""" + ) + assert tmpl.render(seq=list(range(5))) == "01234" + + def test_comment_syntax(self, env): + env = Environment("<!--", "-->", "${", "}", "<!--#", "-->") + tmpl = env.from_string( + """\ +<!--# I'm a comment, I'm not interesting -->\ +<!-- for item in seq ---> + ${item} +<!--- endfor -->""" + ) + assert tmpl.render(seq=list(range(5))) == "01234" + + def test_balancing(self, env): + tmpl = env.from_string("""{{{'foo':'bar'}.foo}}""") + assert tmpl.render() == "bar" + + def test_start_comment(self, env): + tmpl = env.from_string( + """{# foo comment +and bar comment #} +{% macro blub() %}foo{% endmacro %} +{{ blub() }}""" + ) + assert tmpl.render().strip() == "foo" + + def test_line_syntax(self, env): + env = Environment("<%", "%>", "${", "}", "<%#", "%>", "%") + tmpl = env.from_string( + """\ +<%# regular comment %> +% for item in seq: + ${item} +% endfor""" + ) + assert [ + int(x.strip()) for x in tmpl.render(seq=list(range(5))).split() + ] == list(range(5)) + + env = Environment("<%", "%>", "${", "}", "<%#", "%>", "%", "##") + tmpl = env.from_string( + """\ +<%# regular comment %> +% for item in seq: + ${item} ## the rest of the stuff +% endfor""" + ) + assert [ + int(x.strip()) for x in tmpl.render(seq=list(range(5))).split() + ] == list(range(5)) + + def test_line_syntax_priority(self, env): + # XXX: why is the whitespace there in front of the newline? + env = Environment("{%", "%}", "${", "}", "/*", "*/", "##", "#") + tmpl = env.from_string( + """\ +/* ignore me. + I'm a multiline comment */ +## for item in seq: +* ${item} # this is just extra stuff +## endfor""" + ) + assert tmpl.render(seq=[1, 2]).strip() == "* 1\n* 2" + env = Environment("{%", "%}", "${", "}", "/*", "*/", "#", "##") + tmpl = env.from_string( + """\ +/* ignore me. + I'm a multiline comment */ +# for item in seq: +* ${item} ## this is just extra stuff + ## extra stuff i just want to ignore +# endfor""" + ) + assert tmpl.render(seq=[1, 2]).strip() == "* 1\n\n* 2" + + def test_error_messages(self, env): + def assert_error(code, expected): + with pytest.raises(TemplateSyntaxError, match=expected): + Template(code) + + assert_error( + "{% for item in seq %}...{% endif %}", + "Encountered unknown tag 'endif'. Jinja was looking " + "for the following tags: 'endfor' or 'else'. The " + "innermost block that needs to be closed is 'for'.", + ) + assert_error( + "{% if foo %}{% for item in seq %}...{% endfor %}{% endfor %}", + "Encountered unknown tag 'endfor'. Jinja was looking for " + "the following tags: 'elif' or 'else' or 'endif'. The " + "innermost block that needs to be closed is 'if'.", + ) + assert_error( + "{% if foo %}", + "Unexpected end of template. Jinja was looking for the " + "following tags: 'elif' or 'else' or 'endif'. The " + "innermost block that needs to be closed is 'if'.", + ) + assert_error( + "{% for item in seq %}", + "Unexpected end of template. Jinja was looking for the " + "following tags: 'endfor' or 'else'. The innermost block " + "that needs to be closed is 'for'.", + ) + assert_error( + "{% block foo-bar-baz %}", + "Block names in Jinja have to be valid Python identifiers " + "and may not contain hyphens, use an underscore instead.", + ) + assert_error("{% unknown_tag %}", "Encountered unknown tag 'unknown_tag'.") + + +class TestSyntax: + def test_call(self, env): + env = Environment() + env.globals["foo"] = lambda a, b, c, e, g: a + b + c + e + g + tmpl = env.from_string("{{ foo('a', c='d', e='f', *['b'], **{'g': 'h'}) }}") + assert tmpl.render() == "abdfh" + + def test_slicing(self, env): + tmpl = env.from_string("{{ [1, 2, 3][:] }}|{{ [1, 2, 3][::-1] }}") + assert tmpl.render() == "[1, 2, 3]|[3, 2, 1]" + + def test_attr(self, env): + tmpl = env.from_string("{{ foo.bar }}|{{ foo['bar'] }}") + assert tmpl.render(foo={"bar": 42}) == "42|42" + + def test_subscript(self, env): + tmpl = env.from_string("{{ foo[0] }}|{{ foo[-1] }}") + assert tmpl.render(foo=[0, 1, 2]) == "0|2" + + def test_tuple(self, env): + tmpl = env.from_string("{{ () }}|{{ (1,) }}|{{ (1, 2) }}") + assert tmpl.render() == "()|(1,)|(1, 2)" + + def test_math(self, env): + tmpl = env.from_string("{{ (1 + 1 * 2) - 3 / 2 }}|{{ 2**3 }}") + assert tmpl.render() == "1.5|8" + + def test_div(self, env): + tmpl = env.from_string("{{ 3 // 2 }}|{{ 3 / 2 }}|{{ 3 % 2 }}") + assert tmpl.render() == "1|1.5|1" + + def test_unary(self, env): + tmpl = env.from_string("{{ +3 }}|{{ -3 }}") + assert tmpl.render() == "3|-3" + + def test_concat(self, env): + tmpl = env.from_string("{{ [1, 2] ~ 'foo' }}") + assert tmpl.render() == "[1, 2]foo" + + @pytest.mark.parametrize( + ("a", "op", "b"), + [ + (1, ">", 0), + (1, ">=", 1), + (2, "<", 3), + (3, "<=", 4), + (4, "==", 4), + (4, "!=", 5), + ], + ) + def test_compare(self, env, a, op, b): + t = env.from_string(f"{{{{ {a} {op} {b} }}}}") + assert t.render() == "True" + + def test_compare_parens(self, env): + t = env.from_string("{{ i * (j < 5) }}") + assert t.render(i=2, j=3) == "2" + + @pytest.mark.parametrize( + ("src", "expect"), + [ + ("{{ 4 < 2 < 3 }}", "False"), + ("{{ a < b < c }}", "False"), + ("{{ 4 > 2 > 3 }}", "False"), + ("{{ a > b > c }}", "False"), + ("{{ 4 > 2 < 3 }}", "True"), + ("{{ a > b < c }}", "True"), + ], + ) + def test_compare_compound(self, env, src, expect): + t = env.from_string(src) + assert t.render(a=4, b=2, c=3) == expect + + def test_inop(self, env): + tmpl = env.from_string("{{ 1 in [1, 2, 3] }}|{{ 1 not in [1, 2, 3] }}") + assert tmpl.render() == "True|False" + + @pytest.mark.parametrize("value", ("[]", "{}", "()")) + def test_collection_literal(self, env, value): + t = env.from_string(f"{{{{ {value} }}}}") + assert t.render() == value + + @pytest.mark.parametrize( + ("value", "expect"), + ( + ("1", "1"), + ("123", "123"), + ("12_34_56", "123456"), + ("1.2", "1.2"), + ("34.56", "34.56"), + ("3_4.5_6", "34.56"), + ("1e0", "1.0"), + ("10e1", "100.0"), + ("2.5e100", "2.5e+100"), + ("2.5e+100", "2.5e+100"), + ("25.6e-10", "2.56e-09"), + ("1_2.3_4e5_6", "1.234e+57"), + ("0", "0"), + ("0_00", "0"), + ("0b1001_1111", "159"), + ("0o123", "83"), + ("0o1_23", "83"), + ("0x123abc", "1194684"), + ("0x12_3abc", "1194684"), + ), + ) + def test_numeric_literal(self, env, value, expect): + t = env.from_string(f"{{{{ {value} }}}}") + assert t.render() == expect + + def test_bool(self, env): + tmpl = env.from_string( + "{{ true and false }}|{{ false or true }}|{{ not false }}" + ) + assert tmpl.render() == "False|True|True" + + def test_grouping(self, env): + tmpl = env.from_string( + "{{ (true and false) or (false and true) and not false }}" + ) + assert tmpl.render() == "False" + + def test_django_attr(self, env): + tmpl = env.from_string("{{ [1, 2, 3].0 }}|{{ [[1]].0.0 }}") + assert tmpl.render() == "1|1" + + def test_conditional_expression(self, env): + tmpl = env.from_string("""{{ 0 if true else 1 }}""") + assert tmpl.render() == "0" + + def test_short_conditional_expression(self, env): + tmpl = env.from_string("<{{ 1 if false }}>") + assert tmpl.render() == "<>" + + tmpl = env.from_string("<{{ (1 if false).bar }}>") + pytest.raises(UndefinedError, tmpl.render) + + def test_filter_priority(self, env): + tmpl = env.from_string('{{ "foo"|upper + "bar"|upper }}') + assert tmpl.render() == "FOOBAR" + + def test_function_calls(self, env): + tests = [ + (True, "*foo, bar"), + (True, "*foo, *bar"), + (True, "**foo, *bar"), + (True, "**foo, bar"), + (True, "**foo, **bar"), + (True, "**foo, bar=42"), + (False, "foo, bar"), + (False, "foo, bar=42"), + (False, "foo, bar=23, *args"), + (False, "foo, *args, bar=23"), + (False, "a, b=c, *d, **e"), + (False, "*foo, bar=42"), + (False, "*foo, **bar"), + (False, "*foo, bar=42, **baz"), + (False, "foo, *args, bar=23, **baz"), + ] + for should_fail, sig in tests: + if should_fail: + with pytest.raises(TemplateSyntaxError): + env.from_string(f"{{{{ foo({sig}) }}}}") + else: + env.from_string(f"foo({sig})") + + def test_tuple_expr(self, env): + for tmpl in [ + "{{ () }}", + "{{ (1, 2) }}", + "{{ (1, 2,) }}", + "{{ 1, }}", + "{{ 1, 2 }}", + "{% for foo, bar in seq %}...{% endfor %}", + "{% for x in foo, bar %}...{% endfor %}", + "{% for x in foo, %}...{% endfor %}", + ]: + assert env.from_string(tmpl) + + def test_trailing_comma(self, env): + tmpl = env.from_string("{{ (1, 2,) }}|{{ [1, 2,] }}|{{ {1: 2,} }}") + assert tmpl.render().lower() == "(1, 2)|[1, 2]|{1: 2}" + + def test_block_end_name(self, env): + env.from_string("{% block foo %}...{% endblock foo %}") + pytest.raises( + TemplateSyntaxError, env.from_string, "{% block x %}{% endblock y %}" + ) + + def test_constant_casing(self, env): + for const in True, False, None: + const = str(const) + tmpl = env.from_string( + f"{{{{ {const} }}}}|{{{{ {const.lower()} }}}}|{{{{ {const.upper()} }}}}" + ) + assert tmpl.render() == f"{const}|{const}|" + + def test_test_chaining(self, env): + pytest.raises( + TemplateSyntaxError, env.from_string, "{{ foo is string is sequence }}" + ) + assert env.from_string("{{ 42 is string or 42 is number }}").render() == "True" + + def test_string_concatenation(self, env): + tmpl = env.from_string('{{ "foo" "bar" "baz" }}') + assert tmpl.render() == "foobarbaz" + + def test_notin(self, env): + bar = range(100) + tmpl = env.from_string("""{{ not 42 in bar }}""") + assert tmpl.render(bar=bar) == "False" + + def test_operator_precedence(self, env): + tmpl = env.from_string("""{{ 2 * 3 + 4 % 2 + 1 - 2 }}""") + assert tmpl.render() == "5" + + def test_implicit_subscribed_tuple(self, env): + class Foo: + def __getitem__(self, x): + return x + + t = env.from_string("{{ foo[1, 2] }}") + assert t.render(foo=Foo()) == "(1, 2)" + + def test_raw2(self, env): + tmpl = env.from_string("{% raw %}{{ FOO }} and {% BAR %}{% endraw %}") + assert tmpl.render() == "{{ FOO }} and {% BAR %}" + + def test_const(self, env): + tmpl = env.from_string( + "{{ true }}|{{ false }}|{{ none }}|" + "{{ none is defined }}|{{ missing is defined }}" + ) + assert tmpl.render() == "True|False|None|True|False" + + def test_neg_filter_priority(self, env): + node = env.parse("{{ -1|foo }}") + assert isinstance(node.body[0].nodes[0], nodes.Filter) + assert isinstance(node.body[0].nodes[0].node, nodes.Neg) + + def test_const_assign(self, env): + constass1 = """{% set true = 42 %}""" + constass2 = """{% for none in seq %}{% endfor %}""" + for tmpl in constass1, constass2: + pytest.raises(TemplateSyntaxError, env.from_string, tmpl) + + def test_localset(self, env): + tmpl = env.from_string( + """{% set foo = 0 %}\ +{% for item in [1, 2] %}{% set foo = 1 %}{% endfor %}\ +{{ foo }}""" + ) + assert tmpl.render() == "0" + + def test_parse_unary(self, env): + tmpl = env.from_string('{{ -foo["bar"] }}') + assert tmpl.render(foo={"bar": 42}) == "-42" + tmpl = env.from_string('{{ -foo["bar"]|abs }}') + assert tmpl.render(foo={"bar": 42}) == "42" + + +class TestLstripBlocks: + def test_lstrip(self, env): + env = Environment(lstrip_blocks=True, trim_blocks=False) + tmpl = env.from_string(""" {% if True %}\n {% endif %}""") + assert tmpl.render() == "\n" + + def test_lstrip_trim(self, env): + env = Environment(lstrip_blocks=True, trim_blocks=True) + tmpl = env.from_string(""" {% if True %}\n {% endif %}""") + assert tmpl.render() == "" + + def test_no_lstrip(self, env): + env = Environment(lstrip_blocks=True, trim_blocks=False) + tmpl = env.from_string(""" {%+ if True %}\n {%+ endif %}""") + assert tmpl.render() == " \n " + + def test_lstrip_blocks_false_with_no_lstrip(self, env): + # Test that + is a NOP (but does not cause an error) if lstrip_blocks=False + env = Environment(lstrip_blocks=False, trim_blocks=False) + tmpl = env.from_string(""" {% if True %}\n {% endif %}""") + assert tmpl.render() == " \n " + tmpl = env.from_string(""" {%+ if True %}\n {%+ endif %}""") + assert tmpl.render() == " \n " + + def test_lstrip_endline(self, env): + env = Environment(lstrip_blocks=True, trim_blocks=False) + tmpl = env.from_string(""" hello{% if True %}\n goodbye{% endif %}""") + assert tmpl.render() == " hello\n goodbye" + + def test_lstrip_inline(self, env): + env = Environment(lstrip_blocks=True, trim_blocks=False) + tmpl = env.from_string(""" {% if True %}hello {% endif %}""") + assert tmpl.render() == "hello " + + def test_lstrip_nested(self, env): + env = Environment(lstrip_blocks=True, trim_blocks=False) + tmpl = env.from_string( + """ {% if True %}a {% if True %}b {% endif %}c {% endif %}""" + ) + assert tmpl.render() == "a b c " + + def test_lstrip_left_chars(self, env): + env = Environment(lstrip_blocks=True, trim_blocks=False) + tmpl = env.from_string( + """ abc {% if True %} + hello{% endif %}""" + ) + assert tmpl.render() == " abc \n hello" + + def test_lstrip_embeded_strings(self, env): + env = Environment(lstrip_blocks=True, trim_blocks=False) + tmpl = env.from_string(""" {% set x = " {% str %} " %}{{ x }}""") + assert tmpl.render() == " {% str %} " + + def test_lstrip_preserve_leading_newlines(self, env): + env = Environment(lstrip_blocks=True, trim_blocks=False) + tmpl = env.from_string("""\n\n\n{% set hello = 1 %}""") + assert tmpl.render() == "\n\n\n" + + def test_lstrip_comment(self, env): + env = Environment(lstrip_blocks=True, trim_blocks=False) + tmpl = env.from_string( + """ {# if True #} +hello + {#endif#}""" + ) + assert tmpl.render() == "\nhello\n" + + def test_lstrip_angle_bracket_simple(self, env): + env = Environment( + "<%", + "%>", + "${", + "}", + "<%#", + "%>", + "%", + "##", + lstrip_blocks=True, + trim_blocks=True, + ) + tmpl = env.from_string(""" <% if True %>hello <% endif %>""") + assert tmpl.render() == "hello " + + def test_lstrip_angle_bracket_comment(self, env): + env = Environment( + "<%", + "%>", + "${", + "}", + "<%#", + "%>", + "%", + "##", + lstrip_blocks=True, + trim_blocks=True, + ) + tmpl = env.from_string(""" <%# if True %>hello <%# endif %>""") + assert tmpl.render() == "hello " + + def test_lstrip_angle_bracket(self, env): + env = Environment( + "<%", + "%>", + "${", + "}", + "<%#", + "%>", + "%", + "##", + lstrip_blocks=True, + trim_blocks=True, + ) + tmpl = env.from_string( + """\ + <%# regular comment %> + <% for item in seq %> +${item} ## the rest of the stuff + <% endfor %>""" + ) + assert tmpl.render(seq=range(5)) == "".join(f"{x}\n" for x in range(5)) + + def test_lstrip_angle_bracket_compact(self, env): + env = Environment( + "<%", + "%>", + "${", + "}", + "<%#", + "%>", + "%", + "##", + lstrip_blocks=True, + trim_blocks=True, + ) + tmpl = env.from_string( + """\ + <%#regular comment%> + <%for item in seq%> +${item} ## the rest of the stuff + <%endfor%>""" + ) + assert tmpl.render(seq=range(5)) == "".join(f"{x}\n" for x in range(5)) + + def test_lstrip_blocks_outside_with_new_line(self): + env = Environment(lstrip_blocks=True, trim_blocks=False) + tmpl = env.from_string( + " {% if kvs %}(\n" + " {% for k, v in kvs %}{{ k }}={{ v }} {% endfor %}\n" + " ){% endif %}" + ) + out = tmpl.render(kvs=[("a", 1), ("b", 2)]) + assert out == "(\na=1 b=2 \n )" + + def test_lstrip_trim_blocks_outside_with_new_line(self): + env = Environment(lstrip_blocks=True, trim_blocks=True) + tmpl = env.from_string( + " {% if kvs %}(\n" + " {% for k, v in kvs %}{{ k }}={{ v }} {% endfor %}\n" + " ){% endif %}" + ) + out = tmpl.render(kvs=[("a", 1), ("b", 2)]) + assert out == "(\na=1 b=2 )" + + def test_lstrip_blocks_inside_with_new_line(self): + env = Environment(lstrip_blocks=True, trim_blocks=False) + tmpl = env.from_string( + " ({% if kvs %}\n" + " {% for k, v in kvs %}{{ k }}={{ v }} {% endfor %}\n" + " {% endif %})" + ) + out = tmpl.render(kvs=[("a", 1), ("b", 2)]) + assert out == " (\na=1 b=2 \n)" + + def test_lstrip_trim_blocks_inside_with_new_line(self): + env = Environment(lstrip_blocks=True, trim_blocks=True) + tmpl = env.from_string( + " ({% if kvs %}\n" + " {% for k, v in kvs %}{{ k }}={{ v }} {% endfor %}\n" + " {% endif %})" + ) + out = tmpl.render(kvs=[("a", 1), ("b", 2)]) + assert out == " (a=1 b=2 )" + + def test_lstrip_blocks_without_new_line(self): + env = Environment(lstrip_blocks=True, trim_blocks=False) + tmpl = env.from_string( + " {% if kvs %}" + " {% for k, v in kvs %}{{ k }}={{ v }} {% endfor %}" + " {% endif %}" + ) + out = tmpl.render(kvs=[("a", 1), ("b", 2)]) + assert out == " a=1 b=2 " + + def test_lstrip_trim_blocks_without_new_line(self): + env = Environment(lstrip_blocks=True, trim_blocks=True) + tmpl = env.from_string( + " {% if kvs %}" + " {% for k, v in kvs %}{{ k }}={{ v }} {% endfor %}" + " {% endif %}" + ) + out = tmpl.render(kvs=[("a", 1), ("b", 2)]) + assert out == " a=1 b=2 " + + def test_lstrip_blocks_consume_after_without_new_line(self): + env = Environment(lstrip_blocks=True, trim_blocks=False) + tmpl = env.from_string( + " {% if kvs -%}" + " {% for k, v in kvs %}{{ k }}={{ v }} {% endfor -%}" + " {% endif -%}" + ) + out = tmpl.render(kvs=[("a", 1), ("b", 2)]) + assert out == "a=1 b=2 " + + def test_lstrip_trim_blocks_consume_before_without_new_line(self): + env = Environment(lstrip_blocks=False, trim_blocks=False) + tmpl = env.from_string( + " {%- if kvs %}" + " {%- for k, v in kvs %}{{ k }}={{ v }} {% endfor -%}" + " {%- endif %}" + ) + out = tmpl.render(kvs=[("a", 1), ("b", 2)]) + assert out == "a=1 b=2 " + + def test_lstrip_trim_blocks_comment(self): + env = Environment(lstrip_blocks=True, trim_blocks=True) + tmpl = env.from_string(" {# 1 space #}\n {# 2 spaces #} {# 4 spaces #}") + out = tmpl.render() + assert out == " " * 4 + + def test_lstrip_trim_blocks_raw(self): + env = Environment(lstrip_blocks=True, trim_blocks=True) + tmpl = env.from_string("{{x}}\n{%- raw %} {% endraw -%}\n{{ y }}") + out = tmpl.render(x=1, y=2) + assert out == "1 2" + + def test_php_syntax_with_manual(self, env): + env = Environment( + "<?", "?>", "<?=", "?>", "<!--", "-->", lstrip_blocks=True, trim_blocks=True + ) + tmpl = env.from_string( + """\ + <!-- I'm a comment, I'm not interesting --> + <? for item in seq -?> + <?= item ?> + <?- endfor ?>""" + ) + assert tmpl.render(seq=range(5)) == "01234" + + def test_php_syntax(self, env): + env = Environment( + "<?", "?>", "<?=", "?>", "<!--", "-->", lstrip_blocks=True, trim_blocks=True + ) + tmpl = env.from_string( + """\ + <!-- I'm a comment, I'm not interesting --> + <? for item in seq ?> + <?= item ?> + <? endfor ?>""" + ) + assert tmpl.render(seq=range(5)) == "".join(f" {x}\n" for x in range(5)) + + def test_php_syntax_compact(self, env): + env = Environment( + "<?", "?>", "<?=", "?>", "<!--", "-->", lstrip_blocks=True, trim_blocks=True + ) + tmpl = env.from_string( + """\ + <!-- I'm a comment, I'm not interesting --> + <?for item in seq?> + <?=item?> + <?endfor?>""" + ) + assert tmpl.render(seq=range(5)) == "".join(f" {x}\n" for x in range(5)) + + def test_erb_syntax(self, env): + env = Environment( + "<%", "%>", "<%=", "%>", "<%#", "%>", lstrip_blocks=True, trim_blocks=True + ) + tmpl = env.from_string( + """\ +<%# I'm a comment, I'm not interesting %> + <% for item in seq %> + <%= item %> + <% endfor %> +""" + ) + assert tmpl.render(seq=range(5)) == "".join(f" {x}\n" for x in range(5)) + + def test_erb_syntax_with_manual(self, env): + env = Environment( + "<%", "%>", "<%=", "%>", "<%#", "%>", lstrip_blocks=True, trim_blocks=True + ) + tmpl = env.from_string( + """\ +<%# I'm a comment, I'm not interesting %> + <% for item in seq -%> + <%= item %> + <%- endfor %>""" + ) + assert tmpl.render(seq=range(5)) == "01234" + + def test_erb_syntax_no_lstrip(self, env): + env = Environment( + "<%", "%>", "<%=", "%>", "<%#", "%>", lstrip_blocks=True, trim_blocks=True + ) + tmpl = env.from_string( + """\ +<%# I'm a comment, I'm not interesting %> + <%+ for item in seq -%> + <%= item %> + <%- endfor %>""" + ) + assert tmpl.render(seq=range(5)) == " 01234" + + def test_comment_syntax(self, env): + env = Environment( + "<!--", + "-->", + "${", + "}", + "<!--#", + "-->", + lstrip_blocks=True, + trim_blocks=True, + ) + tmpl = env.from_string( + """\ +<!--# I'm a comment, I'm not interesting -->\ +<!-- for item in seq ---> + ${item} +<!--- endfor -->""" + ) + assert tmpl.render(seq=range(5)) == "01234" + + +class TestTrimBlocks: + def test_trim(self, env): + env = Environment(trim_blocks=True, lstrip_blocks=False) + tmpl = env.from_string(" {% if True %}\n {% endif %}") + assert tmpl.render() == " " + + def test_no_trim(self, env): + env = Environment(trim_blocks=True, lstrip_blocks=False) + tmpl = env.from_string(" {% if True +%}\n {% endif %}") + assert tmpl.render() == " \n " + + def test_no_trim_outer(self, env): + env = Environment(trim_blocks=True, lstrip_blocks=False) + tmpl = env.from_string("{% if True %}X{% endif +%}\nmore things") + assert tmpl.render() == "X\nmore things" + + def test_lstrip_no_trim(self, env): + env = Environment(trim_blocks=True, lstrip_blocks=True) + tmpl = env.from_string(" {% if True +%}\n {% endif %}") + assert tmpl.render() == "\n" + + def test_trim_blocks_false_with_no_trim(self, env): + # Test that + is a NOP (but does not cause an error) if trim_blocks=False + env = Environment(trim_blocks=False, lstrip_blocks=False) + tmpl = env.from_string(" {% if True %}\n {% endif %}") + assert tmpl.render() == " \n " + tmpl = env.from_string(" {% if True +%}\n {% endif %}") + assert tmpl.render() == " \n " + + tmpl = env.from_string(" {# comment #}\n ") + assert tmpl.render() == " \n " + tmpl = env.from_string(" {# comment +#}\n ") + assert tmpl.render() == " \n " + + tmpl = env.from_string(" {% raw %}{% endraw %}\n ") + assert tmpl.render() == " \n " + tmpl = env.from_string(" {% raw %}{% endraw +%}\n ") + assert tmpl.render() == " \n " + + def test_trim_nested(self, env): + env = Environment(trim_blocks=True, lstrip_blocks=True) + tmpl = env.from_string( + " {% if True %}\na {% if True %}\nb {% endif %}\nc {% endif %}" + ) + assert tmpl.render() == "a b c " + + def test_no_trim_nested(self, env): + env = Environment(trim_blocks=True, lstrip_blocks=True) + tmpl = env.from_string( + " {% if True +%}\na {% if True +%}\nb {% endif +%}\nc {% endif %}" + ) + assert tmpl.render() == "\na \nb \nc " + + def test_comment_trim(self, env): + env = Environment(trim_blocks=True, lstrip_blocks=True) + tmpl = env.from_string(""" {# comment #}\n\n """) + assert tmpl.render() == "\n " + + def test_comment_no_trim(self, env): + env = Environment(trim_blocks=True, lstrip_blocks=True) + tmpl = env.from_string(""" {# comment +#}\n\n """) + assert tmpl.render() == "\n\n " + + def test_multiple_comment_trim_lstrip(self, env): + env = Environment(trim_blocks=True, lstrip_blocks=True) + tmpl = env.from_string( + " {# comment #}\n\n{# comment2 #}\n \n{# comment3 #}\n\n " + ) + assert tmpl.render() == "\n \n\n " + + def test_multiple_comment_no_trim_lstrip(self, env): + env = Environment(trim_blocks=True, lstrip_blocks=True) + tmpl = env.from_string( + " {# comment +#}\n\n{# comment2 +#}\n \n{# comment3 +#}\n\n " + ) + assert tmpl.render() == "\n\n\n \n\n\n " + + def test_raw_trim_lstrip(self, env): + env = Environment(trim_blocks=True, lstrip_blocks=True) + tmpl = env.from_string("{{x}}{% raw %}\n\n {% endraw %}\n\n{{ y }}") + assert tmpl.render(x=1, y=2) == "1\n\n\n2" + + def test_raw_no_trim_lstrip(self, env): + env = Environment(trim_blocks=False, lstrip_blocks=True) + tmpl = env.from_string("{{x}}{% raw %}\n\n {% endraw +%}\n\n{{ y }}") + assert tmpl.render(x=1, y=2) == "1\n\n\n\n2" + + # raw blocks do not process inner text, so start tag cannot ignore trim + with pytest.raises(TemplateSyntaxError): + tmpl = env.from_string("{{x}}{% raw +%}\n\n {% endraw +%}\n\n{{ y }}") + + def test_no_trim_angle_bracket(self, env): + env = Environment( + "<%", "%>", "${", "}", "<%#", "%>", lstrip_blocks=True, trim_blocks=True + ) + tmpl = env.from_string(" <% if True +%>\n\n <% endif %>") + assert tmpl.render() == "\n\n" + + tmpl = env.from_string(" <%# comment +%>\n\n ") + assert tmpl.render() == "\n\n " + + def test_no_trim_php_syntax(self, env): + env = Environment( + "<?", + "?>", + "<?=", + "?>", + "<!--", + "-->", + lstrip_blocks=False, + trim_blocks=True, + ) + tmpl = env.from_string(" <? if True +?>\n\n <? endif ?>") + assert tmpl.render() == " \n\n " + tmpl = env.from_string(" <!-- comment +-->\n\n ") + assert tmpl.render() == " \n\n " diff --git a/tests/test_loader.py b/tests/test_loader.py new file mode 100644 index 0000000..04c921d --- /dev/null +++ b/tests/test_loader.py @@ -0,0 +1,412 @@ +import importlib.abc +import importlib.machinery +import importlib.util +import os +import platform +import shutil +import sys +import tempfile +import time +import weakref +from pathlib import Path + +import pytest + +from jinja2 import Environment +from jinja2 import loaders +from jinja2 import PackageLoader +from jinja2.exceptions import TemplateNotFound +from jinja2.loaders import split_template_path + + +class TestLoaders: + def test_dict_loader(self, dict_loader): + env = Environment(loader=dict_loader) + tmpl = env.get_template("justdict.html") + assert tmpl.render().strip() == "FOO" + pytest.raises(TemplateNotFound, env.get_template, "missing.html") + + def test_package_loader(self, package_loader): + env = Environment(loader=package_loader) + tmpl = env.get_template("test.html") + assert tmpl.render().strip() == "BAR" + pytest.raises(TemplateNotFound, env.get_template, "missing.html") + + def test_filesystem_loader_overlapping_names(self, filesystem_loader): + t2_dir = Path(filesystem_loader.searchpath[0]) / ".." / "templates2" + # Make "foo" show up before "foo/test.html". + filesystem_loader.searchpath.insert(0, t2_dir) + e = Environment(loader=filesystem_loader) + e.get_template("foo") + # This would raise NotADirectoryError if "t2/foo" wasn't skipped. + e.get_template("foo/test.html") + + def test_choice_loader(self, choice_loader): + env = Environment(loader=choice_loader) + tmpl = env.get_template("justdict.html") + assert tmpl.render().strip() == "FOO" + tmpl = env.get_template("test.html") + assert tmpl.render().strip() == "BAR" + pytest.raises(TemplateNotFound, env.get_template, "missing.html") + + def test_function_loader(self, function_loader): + env = Environment(loader=function_loader) + tmpl = env.get_template("justfunction.html") + assert tmpl.render().strip() == "FOO" + pytest.raises(TemplateNotFound, env.get_template, "missing.html") + + def test_prefix_loader(self, prefix_loader): + env = Environment(loader=prefix_loader) + tmpl = env.get_template("a/test.html") + assert tmpl.render().strip() == "BAR" + tmpl = env.get_template("b/justdict.html") + assert tmpl.render().strip() == "FOO" + pytest.raises(TemplateNotFound, env.get_template, "missing") + + def test_caching(self): + changed = False + + class TestLoader(loaders.BaseLoader): + def get_source(self, environment, template): + return "foo", None, lambda: not changed + + env = Environment(loader=TestLoader(), cache_size=-1) + tmpl = env.get_template("template") + assert tmpl is env.get_template("template") + changed = True + assert tmpl is not env.get_template("template") + changed = False + + def test_no_cache(self): + mapping = {"foo": "one"} + env = Environment(loader=loaders.DictLoader(mapping), cache_size=0) + assert env.get_template("foo") is not env.get_template("foo") + + def test_limited_size_cache(self): + mapping = {"one": "foo", "two": "bar", "three": "baz"} + loader = loaders.DictLoader(mapping) + env = Environment(loader=loader, cache_size=2) + t1 = env.get_template("one") + t2 = env.get_template("two") + assert t2 is env.get_template("two") + assert t1 is env.get_template("one") + env.get_template("three") + loader_ref = weakref.ref(loader) + assert (loader_ref, "one") in env.cache + assert (loader_ref, "two") not in env.cache + assert (loader_ref, "three") in env.cache + + def test_cache_loader_change(self): + loader1 = loaders.DictLoader({"foo": "one"}) + loader2 = loaders.DictLoader({"foo": "two"}) + env = Environment(loader=loader1, cache_size=2) + assert env.get_template("foo").render() == "one" + env.loader = loader2 + assert env.get_template("foo").render() == "two" + + def test_dict_loader_cache_invalidates(self): + mapping = {"foo": "one"} + env = Environment(loader=loaders.DictLoader(mapping)) + assert env.get_template("foo").render() == "one" + mapping["foo"] = "two" + assert env.get_template("foo").render() == "two" + + def test_split_template_path(self): + assert split_template_path("foo/bar") == ["foo", "bar"] + assert split_template_path("./foo/bar") == ["foo", "bar"] + pytest.raises(TemplateNotFound, split_template_path, "../foo") + + +class TestFileSystemLoader: + searchpath = (Path(__file__) / ".." / "res" / "templates").resolve() + + @staticmethod + def _test_common(env): + tmpl = env.get_template("test.html") + assert tmpl.render().strip() == "BAR" + tmpl = env.get_template("foo/test.html") + assert tmpl.render().strip() == "FOO" + pytest.raises(TemplateNotFound, env.get_template, "missing.html") + + def test_searchpath_as_str(self): + filesystem_loader = loaders.FileSystemLoader(str(self.searchpath)) + + env = Environment(loader=filesystem_loader) + self._test_common(env) + + def test_searchpath_as_pathlib(self): + filesystem_loader = loaders.FileSystemLoader(self.searchpath) + env = Environment(loader=filesystem_loader) + self._test_common(env) + + def test_searchpath_as_list_including_pathlib(self): + filesystem_loader = loaders.FileSystemLoader( + ["/tmp/templates", self.searchpath] + ) + env = Environment(loader=filesystem_loader) + self._test_common(env) + + def test_caches_template_based_on_mtime(self): + filesystem_loader = loaders.FileSystemLoader(self.searchpath) + + env = Environment(loader=filesystem_loader) + tmpl1 = env.get_template("test.html") + tmpl2 = env.get_template("test.html") + assert tmpl1 is tmpl2 + + os.utime(self.searchpath / "test.html", (time.time(), time.time())) + tmpl3 = env.get_template("test.html") + assert tmpl1 is not tmpl3 + + @pytest.mark.parametrize( + ("encoding", "expect"), + [ + ("utf-8", "文字化け"), + ("iso-8859-1", "æ\x96\x87\xe5\xad\x97\xe5\x8c\x96\xe3\x81\x91"), + ], + ) + def test_uses_specified_encoding(self, encoding, expect): + loader = loaders.FileSystemLoader(self.searchpath, encoding=encoding) + e = Environment(loader=loader) + t = e.get_template("mojibake.txt") + assert t.render() == expect + + def test_filename_normpath(self): + """Nested template names should only contain ``os.sep`` in the + loaded filename. + """ + loader = loaders.FileSystemLoader(self.searchpath) + e = Environment(loader=loader) + t = e.get_template("foo/test.html") + assert t.filename == str(self.searchpath / "foo" / "test.html") + + +class TestModuleLoader: + archive = None + + def compile_down(self, prefix_loader, zip="deflated"): + log = [] + self.reg_env = Environment(loader=prefix_loader) + if zip is not None: + fd, self.archive = tempfile.mkstemp(suffix=".zip") + os.close(fd) + else: + self.archive = tempfile.mkdtemp() + self.reg_env.compile_templates(self.archive, zip=zip, log_function=log.append) + self.mod_env = Environment(loader=loaders.ModuleLoader(self.archive)) + return "".join(log) + + def teardown(self): + if hasattr(self, "mod_env"): + if os.path.isfile(self.archive): + os.remove(self.archive) + else: + shutil.rmtree(self.archive) + self.archive = None + + def test_log(self, prefix_loader): + log = self.compile_down(prefix_loader) + assert ( + 'Compiled "a/foo/test.html" as ' + "tmpl_a790caf9d669e39ea4d280d597ec891c4ef0404a" in log + ) + assert "Finished compiling templates" in log + assert ( + 'Could not compile "a/syntaxerror.html": ' + "Encountered unknown tag 'endif'" in log + ) + + def _test_common(self): + tmpl1 = self.reg_env.get_template("a/test.html") + tmpl2 = self.mod_env.get_template("a/test.html") + assert tmpl1.render() == tmpl2.render() + + tmpl1 = self.reg_env.get_template("b/justdict.html") + tmpl2 = self.mod_env.get_template("b/justdict.html") + assert tmpl1.render() == tmpl2.render() + + def test_deflated_zip_compile(self, prefix_loader): + self.compile_down(prefix_loader, zip="deflated") + self._test_common() + + def test_stored_zip_compile(self, prefix_loader): + self.compile_down(prefix_loader, zip="stored") + self._test_common() + + def test_filesystem_compile(self, prefix_loader): + self.compile_down(prefix_loader, zip=None) + self._test_common() + + def test_weak_references(self, prefix_loader): + self.compile_down(prefix_loader) + self.mod_env.get_template("a/test.html") + key = loaders.ModuleLoader.get_template_key("a/test.html") + name = self.mod_env.loader.module.__name__ + + assert hasattr(self.mod_env.loader.module, key) + assert name in sys.modules + + # unset all, ensure the module is gone from sys.modules + self.mod_env = None + + try: + import gc + + gc.collect() + except BaseException: + pass + + assert name not in sys.modules + + def test_choice_loader(self, prefix_loader): + self.compile_down(prefix_loader) + self.mod_env.loader = loaders.ChoiceLoader( + [self.mod_env.loader, loaders.DictLoader({"DICT_SOURCE": "DICT_TEMPLATE"})] + ) + tmpl1 = self.mod_env.get_template("a/test.html") + assert tmpl1.render() == "BAR" + tmpl2 = self.mod_env.get_template("DICT_SOURCE") + assert tmpl2.render() == "DICT_TEMPLATE" + + def test_prefix_loader(self, prefix_loader): + self.compile_down(prefix_loader) + self.mod_env.loader = loaders.PrefixLoader( + { + "MOD": self.mod_env.loader, + "DICT": loaders.DictLoader({"test.html": "DICT_TEMPLATE"}), + } + ) + tmpl1 = self.mod_env.get_template("MOD/a/test.html") + assert tmpl1.render() == "BAR" + tmpl2 = self.mod_env.get_template("DICT/test.html") + assert tmpl2.render() == "DICT_TEMPLATE" + + def test_path_as_pathlib(self, prefix_loader): + self.compile_down(prefix_loader) + + mod_path = self.mod_env.loader.module.__path__[0] + mod_loader = loaders.ModuleLoader(Path(mod_path)) + self.mod_env = Environment(loader=mod_loader) + + self._test_common() + + def test_supports_pathlib_in_list_of_paths(self, prefix_loader): + self.compile_down(prefix_loader) + + mod_path = self.mod_env.loader.module.__path__[0] + mod_loader = loaders.ModuleLoader([Path(mod_path), "/tmp/templates"]) + self.mod_env = Environment(loader=mod_loader) + + self._test_common() + + +@pytest.fixture() +def package_dir_loader(monkeypatch): + monkeypatch.syspath_prepend(Path(__file__).parent) + return PackageLoader("res") + + +@pytest.mark.parametrize( + ("template", "expect"), [("foo/test.html", "FOO"), ("test.html", "BAR")] +) +def test_package_dir_source(package_dir_loader, template, expect): + source, name, up_to_date = package_dir_loader.get_source(None, template) + assert source.rstrip() == expect + assert name.endswith(os.path.join(*split_template_path(template))) + assert up_to_date() + + +def test_package_dir_list(package_dir_loader): + templates = package_dir_loader.list_templates() + assert "foo/test.html" in templates + assert "test.html" in templates + + +@pytest.fixture() +def package_file_loader(monkeypatch): + monkeypatch.syspath_prepend(Path(__file__).parent / "res") + return PackageLoader("__init__") + + +@pytest.mark.parametrize( + ("template", "expect"), [("foo/test.html", "FOO"), ("test.html", "BAR")] +) +def test_package_file_source(package_file_loader, template, expect): + source, name, up_to_date = package_file_loader.get_source(None, template) + assert source.rstrip() == expect + assert name.endswith(os.path.join(*split_template_path(template))) + assert up_to_date() + + +def test_package_file_list(package_file_loader): + templates = package_file_loader.list_templates() + assert "foo/test.html" in templates + assert "test.html" in templates + + +@pytest.fixture() +def package_zip_loader(monkeypatch): + package_zip = (Path(__file__) / ".." / "res" / "package.zip").resolve() + monkeypatch.syspath_prepend(package_zip) + return PackageLoader("t_pack") + + +@pytest.mark.parametrize( + ("template", "expect"), [("foo/test.html", "FOO"), ("test.html", "BAR")] +) +def test_package_zip_source(package_zip_loader, template, expect): + source, name, up_to_date = package_zip_loader.get_source(None, template) + assert source.rstrip() == expect + assert name.endswith(os.path.join(*split_template_path(template))) + assert up_to_date is None + + +@pytest.mark.xfail( + platform.python_implementation() == "PyPy", + reason="PyPy's zipimporter doesn't have a '_files' attribute.", + raises=TypeError, +) +def test_package_zip_list(package_zip_loader): + assert package_zip_loader.list_templates() == ["foo/test.html", "test.html"] + + +@pytest.mark.parametrize("package_path", ["", ".", "./"]) +def test_package_zip_omit_curdir(package_zip_loader, package_path): + """PackageLoader should not add or include "." or "./" in the root + path, it is invalid in zip paths. + """ + loader = PackageLoader("t_pack", package_path) + assert loader.package_path == "" + source, _, _ = loader.get_source(None, "templates/foo/test.html") + assert source.rstrip() == "FOO" + + +def test_pep_451_import_hook(): + class ImportHook(importlib.abc.MetaPathFinder, importlib.abc.Loader): + def find_spec(self, name, path=None, target=None): + if name != "res": + return None + + spec = importlib.machinery.PathFinder.find_spec(name) + return importlib.util.spec_from_file_location( + name, + spec.origin, + loader=self, + submodule_search_locations=spec.submodule_search_locations, + ) + + def create_module(self, spec): + return None # default behaviour is fine + + def exec_module(self, module): + return None # we need this to satisfy the interface, it's wrong + + # ensure we restore `sys.meta_path` after putting in our loader + before = sys.meta_path[:] + + try: + sys.meta_path.insert(0, ImportHook()) + package_loader = PackageLoader("res") + assert "test.html" in package_loader.list_templates() + finally: + sys.meta_path[:] = before diff --git a/tests/test_nativetypes.py b/tests/test_nativetypes.py new file mode 100644 index 0000000..8c85252 --- /dev/null +++ b/tests/test_nativetypes.py @@ -0,0 +1,162 @@ +import math + +import pytest + +from jinja2.exceptions import UndefinedError +from jinja2.nativetypes import NativeEnvironment +from jinja2.nativetypes import NativeTemplate +from jinja2.runtime import Undefined + + +@pytest.fixture +def env(): + return NativeEnvironment() + + +def test_is_defined_native_return(env): + t = env.from_string("{{ missing is defined }}") + assert not t.render() + + +def test_undefined_native_return(env): + t = env.from_string("{{ missing }}") + assert isinstance(t.render(), Undefined) + + +def test_adding_undefined_native_return(env): + t = env.from_string("{{ 3 + missing }}") + + with pytest.raises(UndefinedError): + t.render() + + +def test_cast_int(env): + t = env.from_string("{{ value|int }}") + result = t.render(value="3") + assert isinstance(result, int) + assert result == 3 + + +def test_list_add(env): + t = env.from_string("{{ a + b }}") + result = t.render(a=["a", "b"], b=["c", "d"]) + assert isinstance(result, list) + assert result == ["a", "b", "c", "d"] + + +def test_multi_expression_add(env): + t = env.from_string("{{ a }} + {{ b }}") + result = t.render(a=["a", "b"], b=["c", "d"]) + assert not isinstance(result, list) + assert result == "['a', 'b'] + ['c', 'd']" + + +def test_loops(env): + t = env.from_string("{% for x in value %}{{ x }}{% endfor %}") + result = t.render(value=["a", "b", "c", "d"]) + assert isinstance(result, str) + assert result == "abcd" + + +def test_loops_with_ints(env): + t = env.from_string("{% for x in value %}{{ x }}{% endfor %}") + result = t.render(value=[1, 2, 3, 4]) + assert isinstance(result, int) + assert result == 1234 + + +def test_loop_look_alike(env): + t = env.from_string("{% for x in value %}{{ x }}{% endfor %}") + result = t.render(value=[1]) + assert isinstance(result, int) + assert result == 1 + + +@pytest.mark.parametrize( + ("source", "expect"), + ( + ("{{ value }}", True), + ("{{ value }}", False), + ("{{ 1 == 1 }}", True), + ("{{ 2 + 2 == 5 }}", False), + ("{{ None is none }}", True), + ("{{ '' == None }}", False), + ), +) +def test_booleans(env, source, expect): + t = env.from_string(source) + result = t.render(value=expect) + assert isinstance(result, bool) + assert result is expect + + +def test_variable_dunder(env): + t = env.from_string("{{ x.__class__ }}") + result = t.render(x=True) + assert isinstance(result, type) + + +def test_constant_dunder(env): + t = env.from_string("{{ true.__class__ }}") + result = t.render() + assert isinstance(result, type) + + +def test_constant_dunder_to_string(env): + t = env.from_string("{{ true.__class__|string }}") + result = t.render() + assert not isinstance(result, type) + assert result in {"<type 'bool'>", "<class 'bool'>"} + + +def test_string_literal_var(env): + t = env.from_string("[{{ 'all' }}]") + result = t.render() + assert isinstance(result, str) + assert result == "[all]" + + +def test_string_top_level(env): + t = env.from_string("'Jinja'") + result = t.render() + assert result == "Jinja" + + +def test_tuple_of_variable_strings(env): + t = env.from_string("'{{ a }}', 'data', '{{ b }}', b'{{ c }}'") + result = t.render(a=1, b=2, c="bytes") + assert isinstance(result, tuple) + assert result == ("1", "data", "2", b"bytes") + + +def test_concat_strings_with_quotes(env): + t = env.from_string("--host='{{ host }}' --user \"{{ user }}\"") + result = t.render(host="localhost", user="Jinja") + assert result == "--host='localhost' --user \"Jinja\"" + + +def test_no_intermediate_eval(env): + t = env.from_string("0.000{{ a }}") + result = t.render(a=7) + assert isinstance(result, float) + # If intermediate eval happened, 0.000 would render 0.0, then 7 + # would be appended, resulting in 0.07. + assert math.isclose(result, 0.0007) + + +def test_spontaneous_env(): + t = NativeTemplate("{{ true }}") + assert isinstance(t.environment, NativeEnvironment) + + +def test_leading_spaces(env): + t = env.from_string(" {{ True }}") + result = t.render() + assert result == " True" + + +def test_macro(env): + t = env.from_string("{%- macro x() -%}{{- [1,2] -}}{%- endmacro -%}{{- x()[1] -}}") + result = t.render() + assert result == 2 + assert isinstance(result, int) diff --git a/tests/test_nodes.py b/tests/test_nodes.py new file mode 100644 index 0000000..e910998 --- /dev/null +++ b/tests/test_nodes.py @@ -0,0 +1,3 @@ +def test_template_hash(env): + template = env.parse("hash test") + hash(template) diff --git a/tests/test_pickle.py b/tests/test_pickle.py new file mode 100644 index 0000000..b0f6bcf --- /dev/null +++ b/tests/test_pickle.py @@ -0,0 +1,6 @@ +import pickle + + +def test_environment(env): + env = pickle.loads(pickle.dumps(env)) + assert env.from_string("x={{ x }}").render(x=42) == "x=42" diff --git a/tests/test_regression.py b/tests/test_regression.py new file mode 100644 index 0000000..46e492b --- /dev/null +++ b/tests/test_regression.py @@ -0,0 +1,744 @@ +import pytest + +from jinja2 import DictLoader +from jinja2 import Environment +from jinja2 import PrefixLoader +from jinja2 import Template +from jinja2 import TemplateAssertionError +from jinja2 import TemplateNotFound +from jinja2 import TemplateSyntaxError +from jinja2.utils import pass_context + + +class TestCorner: + def test_assigned_scoping(self, env): + t = env.from_string( + """ + {%- for item in (1, 2, 3, 4) -%} + [{{ item }}] + {%- endfor %} + {{- item -}} + """ + ) + assert t.render(item=42) == "[1][2][3][4]42" + + t = env.from_string( + """ + {%- for item in (1, 2, 3, 4) -%} + [{{ item }}] + {%- endfor %} + {%- set item = 42 %} + {{- item -}} + """ + ) + assert t.render() == "[1][2][3][4]42" + + t = env.from_string( + """ + {%- set item = 42 %} + {%- for item in (1, 2, 3, 4) -%} + [{{ item }}] + {%- endfor %} + {{- item -}} + """ + ) + assert t.render() == "[1][2][3][4]42" + + def test_closure_scoping(self, env): + t = env.from_string( + """ + {%- set wrapper = "<FOO>" %} + {%- for item in (1, 2, 3, 4) %} + {%- macro wrapper() %}[{{ item }}]{% endmacro %} + {{- wrapper() }} + {%- endfor %} + {{- wrapper -}} + """ + ) + assert t.render() == "[1][2][3][4]<FOO>" + + t = env.from_string( + """ + {%- for item in (1, 2, 3, 4) %} + {%- macro wrapper() %}[{{ item }}]{% endmacro %} + {{- wrapper() }} + {%- endfor %} + {%- set wrapper = "<FOO>" %} + {{- wrapper -}} + """ + ) + assert t.render() == "[1][2][3][4]<FOO>" + + t = env.from_string( + """ + {%- for item in (1, 2, 3, 4) %} + {%- macro wrapper() %}[{{ item }}]{% endmacro %} + {{- wrapper() }} + {%- endfor %} + {{- wrapper -}} + """ + ) + assert t.render(wrapper=23) == "[1][2][3][4]23" + + +class TestBug: + def test_keyword_folding(self, env): + env = Environment() + env.filters["testing"] = lambda value, some: value + some + assert ( + env.from_string("{{ 'test'|testing(some='stuff') }}").render() + == "teststuff" + ) + + def test_extends_output_bugs(self, env): + env = Environment( + loader=DictLoader({"parent.html": "(({% block title %}{% endblock %}))"}) + ) + + t = env.from_string( + '{% if expr %}{% extends "parent.html" %}{% endif %}' + "[[{% block title %}title{% endblock %}]]" + "{% for item in [1, 2, 3] %}({{ item }}){% endfor %}" + ) + assert t.render(expr=False) == "[[title]](1)(2)(3)" + assert t.render(expr=True) == "((title))" + + def test_urlize_filter_escaping(self, env): + tmpl = env.from_string('{{ "http://www.example.org/<foo"|urlize }}') + assert ( + tmpl.render() == '<a href="http://www.example.org/<foo" rel="noopener">' + "http://www.example.org/<foo</a>" + ) + + def test_urlize_filter_closing_punctuation(self, env): + tmpl = env.from_string( + '{{ "(see http://www.example.org/?page=subj_<desc.h>)"|urlize }}' + ) + assert tmpl.render() == ( + '(see <a href="http://www.example.org/?page=subj_<desc.h>" ' + 'rel="noopener">http://www.example.org/?page=subj_<desc.h></a>)' + ) + + def test_loop_call_loop(self, env): + tmpl = env.from_string( + """ + + {% macro test() %} + {{ caller() }} + {% endmacro %} + + {% for num1 in range(5) %} + {% call test() %} + {% for num2 in range(10) %} + {{ loop.index }} + {% endfor %} + {% endcall %} + {% endfor %} + + """ + ) + + assert tmpl.render().split() == [str(x) for x in range(1, 11)] * 5 + + def test_weird_inline_comment(self, env): + env = Environment(line_statement_prefix="%") + pytest.raises( + TemplateSyntaxError, + env.from_string, + "% for item in seq {# missing #}\n...% endfor", + ) + + def test_old_macro_loop_scoping_bug(self, env): + tmpl = env.from_string( + "{% for i in (1, 2) %}{{ i }}{% endfor %}" + "{% macro i() %}3{% endmacro %}{{ i() }}" + ) + assert tmpl.render() == "123" + + def test_partial_conditional_assignments(self, env): + tmpl = env.from_string("{% if b %}{% set a = 42 %}{% endif %}{{ a }}") + assert tmpl.render(a=23) == "23" + assert tmpl.render(b=True) == "42" + + def test_stacked_locals_scoping_bug(self, env): + env = Environment(line_statement_prefix="#") + t = env.from_string( + """\ +# for j in [1, 2]: +# set x = 1 +# for i in [1, 2]: +# print x +# if i % 2 == 0: +# set x = x + 1 +# endif +# endfor +# endfor +# if a +# print 'A' +# elif b +# print 'B' +# elif c == d +# print 'C' +# else +# print 'D' +# endif + """ + ) + assert t.render(a=0, b=False, c=42, d=42.0) == "1111C" + + def test_stacked_locals_scoping_bug_twoframe(self, env): + t = Template( + """ + {% set x = 1 %} + {% for item in foo %} + {% if item == 1 %} + {% set x = 2 %} + {% endif %} + {% endfor %} + {{ x }} + """ + ) + rv = t.render(foo=[1]).strip() + assert rv == "1" + + def test_call_with_args(self, env): + t = Template( + """{% macro dump_users(users) -%} + <ul> + {%- for user in users -%} + <li><p>{{ user.username|e }}</p>{{ caller(user) }}</li> + {%- endfor -%} + </ul> + {%- endmacro -%} + + {% call(user) dump_users(list_of_user) -%} + <dl> + <dl>Realname</dl> + <dd>{{ user.realname|e }}</dd> + <dl>Description</dl> + <dd>{{ user.description }}</dd> + </dl> + {% endcall %}""" + ) + + assert [ + x.strip() + for x in t.render( + list_of_user=[ + { + "username": "apo", + "realname": "something else", + "description": "test", + } + ] + ).splitlines() + ] == [ + "<ul><li><p>apo</p><dl>", + "<dl>Realname</dl>", + "<dd>something else</dd>", + "<dl>Description</dl>", + "<dd>test</dd>", + "</dl>", + "</li></ul>", + ] + + def test_empty_if_condition_fails(self, env): + pytest.raises(TemplateSyntaxError, Template, "{% if %}....{% endif %}") + pytest.raises( + TemplateSyntaxError, Template, "{% if foo %}...{% elif %}...{% endif %}" + ) + pytest.raises(TemplateSyntaxError, Template, "{% for x in %}..{% endfor %}") + + def test_recursive_loop_compile(self, env): + Template( + """ + {% for p in foo recursive%} + {{p.bar}} + {% for f in p.fields recursive%} + {{f.baz}} + {{p.bar}} + {% if f.rec %} + {{ loop(f.sub) }} + {% endif %} + {% endfor %} + {% endfor %} + """ + ) + Template( + """ + {% for p in foo%} + {{p.bar}} + {% for f in p.fields recursive%} + {{f.baz}} + {{p.bar}} + {% if f.rec %} + {{ loop(f.sub) }} + {% endif %} + {% endfor %} + {% endfor %} + """ + ) + + def test_else_loop_bug(self, env): + t = Template( + """ + {% for x in y %} + {{ loop.index0 }} + {% else %} + {% for i in range(3) %}{{ i }}{% endfor %} + {% endfor %} + """ + ) + assert t.render(y=[]).strip() == "012" + + def test_correct_prefix_loader_name(self, env): + env = Environment(loader=PrefixLoader({"foo": DictLoader({})})) + with pytest.raises(TemplateNotFound) as e: + env.get_template("foo/bar.html") + + assert e.value.name == "foo/bar.html" + + def test_pass_context_callable_class(self, env): + class CallableClass: + @pass_context + def __call__(self, ctx): + return ctx.resolve("hello") + + tpl = Template("""{{ callableclass() }}""") + output = tpl.render(callableclass=CallableClass(), hello="TEST") + expected = "TEST" + + assert output == expected + + def test_block_set_with_extends(self): + env = Environment( + loader=DictLoader({"main": "{% block body %}[{{ x }}]{% endblock %}"}) + ) + t = env.from_string('{% extends "main" %}{% set x %}42{% endset %}') + assert t.render() == "[42]" + + def test_nested_for_else(self, env): + tmpl = env.from_string( + "{% for x in y %}{{ loop.index0 }}{% else %}" + "{% for i in range(3) %}{{ i }}{% endfor %}" + "{% endfor %}" + ) + assert tmpl.render() == "012" + + def test_macro_var_bug(self, env): + tmpl = env.from_string( + """ + {% set i = 1 %} + {% macro test() %} + {% for i in range(0, 10) %}{{ i }}{% endfor %} + {% endmacro %}{{ test() }} + """ + ) + assert tmpl.render().strip() == "0123456789" + + def test_macro_var_bug_advanced(self, env): + tmpl = env.from_string( + """ + {% macro outer() %} + {% set i = 1 %} + {% macro test() %} + {% for i in range(0, 10) %}{{ i }}{% endfor %} + {% endmacro %}{{ test() }} + {% endmacro %}{{ outer() }} + """ + ) + assert tmpl.render().strip() == "0123456789" + + def test_callable_defaults(self): + env = Environment() + env.globals["get_int"] = lambda: 42 + t = env.from_string( + """ + {% macro test(a, b, c=get_int()) -%} + {{ a + b + c }} + {%- endmacro %} + {{ test(1, 2) }}|{{ test(1, 2, 3) }} + """ + ) + assert t.render().strip() == "45|6" + + def test_macro_escaping(self): + env = Environment(autoescape=lambda x: False) + template = "{% macro m() %}<html>{% endmacro %}" + template += "{% autoescape true %}{{ m() }}{% endautoescape %}" + assert env.from_string(template).render() + + def test_macro_scoping(self, env): + tmpl = env.from_string( + """ + {% set n=[1,2,3,4,5] %} + {% for n in [[1,2,3], [3,4,5], [5,6,7]] %} + + {% macro x(l) %} + {{ l.pop() }} + {% if l %}{{ x(l) }}{% endif %} + {% endmacro %} + + {{ x(n) }} + + {% endfor %} + """ + ) + assert list(map(int, tmpl.render().split())) == [3, 2, 1, 5, 4, 3, 7, 6, 5] + + def test_scopes_and_blocks(self): + env = Environment( + loader=DictLoader( + { + "a.html": """ + {%- set foo = 'bar' -%} + {% include 'x.html' -%} + """, + "b.html": """ + {%- set foo = 'bar' -%} + {% block test %}{% include 'x.html' %}{% endblock -%} + """, + "c.html": """ + {%- set foo = 'bar' -%} + {% block test %}{% set foo = foo + %}{% include 'x.html' %}{% endblock -%} + """, + "x.html": """{{ foo }}|{{ test }}""", + } + ) + ) + + a = env.get_template("a.html") + b = env.get_template("b.html") + c = env.get_template("c.html") + + assert a.render(test="x").strip() == "bar|x" + assert b.render(test="x").strip() == "bar|x" + assert c.render(test="x").strip() == "bar|x" + + def test_scopes_and_include(self): + env = Environment( + loader=DictLoader( + { + "include.html": "{{ var }}", + "base.html": '{% include "include.html" %}', + "child.html": '{% extends "base.html" %}{% set var = 42 %}', + } + ) + ) + t = env.get_template("child.html") + assert t.render() == "42" + + def test_caller_scoping(self, env): + t = env.from_string( + """ + {% macro detail(icon, value) -%} + {% if value -%} + <p><span class="fa fa-fw fa-{{ icon }}"></span> + {%- if caller is undefined -%} + {{ value }} + {%- else -%} + {{ caller(value, *varargs) }} + {%- endif -%}</p> + {%- endif %} + {%- endmacro %} + + + {% macro link_detail(icon, value, href) -%} + {% call(value, href) detail(icon, value, href) -%} + <a href="{{ href }}">{{ value }}</a> + {%- endcall %} + {%- endmacro %} + """ + ) + + assert t.module.link_detail("circle", "Index", "/") == ( + '<p><span class="fa fa-fw fa-circle"></span><a href="/">Index</a></p>' + ) + + def test_variable_reuse(self, env): + t = env.from_string("{% for x in x.y %}{{ x }}{% endfor %}") + assert t.render(x={"y": [0, 1, 2]}) == "012" + + t = env.from_string("{% for x in x.y %}{{ loop.index0 }}|{{ x }}{% endfor %}") + assert t.render(x={"y": [0, 1, 2]}) == "0|01|12|2" + + t = env.from_string("{% for x in x.y recursive %}{{ x }}{% endfor %}") + assert t.render(x={"y": [0, 1, 2]}) == "012" + + def test_double_caller(self, env): + t = env.from_string( + "{% macro x(caller=none) %}[{% if caller %}" + "{{ caller() }}{% endif %}]{% endmacro %}" + "{{ x() }}{% call x() %}aha!{% endcall %}" + ) + assert t.render() == "[][aha!]" + + def test_double_caller_no_default(self, env): + with pytest.raises(TemplateAssertionError) as exc_info: + env.from_string( + "{% macro x(caller) %}[{% if caller %}" + "{{ caller() }}{% endif %}]{% endmacro %}" + ) + assert exc_info.match( + r'"caller" argument must be omitted or ' r"be given a default" + ) + + t = env.from_string( + "{% macro x(caller=none) %}[{% if caller %}" + "{{ caller() }}{% endif %}]{% endmacro %}" + ) + with pytest.raises(TypeError) as exc_info: + t.module.x(None, caller=lambda: 42) + assert exc_info.match( + r"\'x\' was invoked with two values for the " r"special caller argument" + ) + + def test_macro_blocks(self, env): + t = env.from_string( + "{% macro x() %}{% block foo %}x{% endblock %}{% endmacro %}{{ x() }}" + ) + assert t.render() == "x" + + def test_scoped_block(self, env): + t = env.from_string( + "{% set x = 1 %}{% with x = 2 %}{% block y scoped %}" + "{{ x }}{% endblock %}{% endwith %}" + ) + assert t.render() == "2" + + def test_recursive_loop_filter(self, env): + t = env.from_string( + """ + <?xml version="1.0" encoding="UTF-8"?> + <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + {%- for page in [site.root] if page.url != this recursive %} + <url><loc>{{ page.url }}</loc></url> + {{- loop(page.children) }} + {%- endfor %} + </urlset> + """ + ) + sm = t.render( + this="/foo", + site={"root": {"url": "/", "children": [{"url": "/foo"}, {"url": "/bar"}]}}, + ) + lines = [x.strip() for x in sm.splitlines() if x.strip()] + assert lines == [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', + "<url><loc>/</loc></url>", + "<url><loc>/bar</loc></url>", + "</urlset>", + ] + + def test_empty_if(self, env): + t = env.from_string("{% if foo %}{% else %}42{% endif %}") + assert t.render(foo=False) == "42" + + def test_subproperty_if(self, env): + t = env.from_string( + "{% if object1.subproperty1 is eq object2.subproperty2 %}42{% endif %}" + ) + assert ( + t.render( + object1={"subproperty1": "value"}, object2={"subproperty2": "value"} + ) + == "42" + ) + + def test_set_and_include(self): + env = Environment( + loader=DictLoader( + { + "inc": "bar", + "main": '{% set foo = "foo" %}{{ foo }}{% include "inc" %}', + } + ) + ) + assert env.get_template("main").render() == "foobar" + + def test_loop_include(self): + env = Environment( + loader=DictLoader( + { + "inc": "{{ i }}", + "main": '{% for i in [1, 2, 3] %}{% include "inc" %}{% endfor %}', + } + ) + ) + assert env.get_template("main").render() == "123" + + def test_grouper_repr(self): + from jinja2.filters import _GroupTuple + + t = _GroupTuple("foo", [1, 2]) + assert t.grouper == "foo" + assert t.list == [1, 2] + assert repr(t) == "('foo', [1, 2])" + assert str(t) == "('foo', [1, 2])" + + def test_custom_context(self, env): + from jinja2.runtime import Context + + class MyContext(Context): + pass + + class MyEnvironment(Environment): + context_class = MyContext + + loader = DictLoader({"base": "{{ foobar }}", "test": '{% extends "base" %}'}) + env = MyEnvironment(loader=loader) + assert env.get_template("test").render(foobar="test") == "test" + + def test_recursive_loop_bug(self, env): + tmpl = env.from_string( + "{%- for value in values recursive %}1{% else %}0{% endfor -%}" + ) + assert tmpl.render(values=[]) == "0" + + def test_markup_and_chainable_undefined(self): + from markupsafe import Markup + from jinja2.runtime import ChainableUndefined + + assert str(Markup(ChainableUndefined())) == "" + + def test_scoped_block_loop_vars(self, env): + tmpl = env.from_string( + """\ +Start +{% for i in ["foo", "bar"] -%} +{% block body scoped -%} +{{ loop.index }}) {{ i }}{% if loop.last %} last{% endif -%} +{%- endblock %} +{% endfor -%} +End""" + ) + assert tmpl.render() == "Start\n1) foo\n2) bar last\nEnd" + + def test_pass_context_loop_vars(self, env): + @pass_context + def test(ctx): + return f"{ctx['i']}{ctx['j']}" + + tmpl = env.from_string( + """\ +{% set i = 42 %} +{%- for idx in range(2) -%} +{{ i }}{{ j }} +{% set i = idx -%} +{%- set j = loop.index -%} +{{ test() }} +{{ i }}{{ j }} +{% endfor -%} +{{ i }}{{ j }}""" + ) + tmpl.globals["test"] = test + assert tmpl.render() == "42\n01\n01\n42\n12\n12\n42" + + def test_pass_context_scoped_loop_vars(self, env): + @pass_context + def test(ctx): + return f"{ctx['i']}" + + tmpl = env.from_string( + """\ +{% set i = 42 %} +{%- for idx in range(2) -%} +{{ i }} +{%- set i = loop.index0 -%} +{% block body scoped %} +{{ test() }} +{% endblock -%} +{% endfor -%} +{{ i }}""" + ) + tmpl.globals["test"] = test + assert tmpl.render() == "42\n0\n42\n1\n42" + + def test_pass_context_in_blocks(self, env): + @pass_context + def test(ctx): + return f"{ctx['i']}" + + tmpl = env.from_string( + """\ +{%- set i = 42 -%} +{{ i }} +{% block body -%} +{% set i = 24 -%} +{{ test() }} +{% endblock -%} +{{ i }}""" + ) + tmpl.globals["test"] = test + assert tmpl.render() == "42\n24\n42" + + def test_pass_context_block_and_loop(self, env): + @pass_context + def test(ctx): + return f"{ctx['i']}" + + tmpl = env.from_string( + """\ +{%- set i = 42 -%} +{% for idx in range(2) -%} +{{ test() }} +{%- set i = idx -%} +{% block body scoped %} +{{ test() }} +{% set i = 24 -%} +{{ test() }} +{% endblock -%} +{{ test() }} +{% endfor -%} +{{ test() }}""" + ) + tmpl.globals["test"] = test + + # values set within a block or loop should not + # show up outside of it + assert tmpl.render() == "42\n0\n24\n0\n42\n1\n24\n1\n42" + + @pytest.mark.parametrize("op", ["extends", "include"]) + def test_cached_extends(self, op): + env = Environment( + loader=DictLoader( + {"base": "{{ x }} {{ y }}", "main": f"{{% {op} 'base' %}}"} + ) + ) + env.globals["x"] = "x" + env.globals["y"] = "y" + + # template globals overlay env globals + tmpl = env.get_template("main", globals={"x": "bar"}) + assert tmpl.render() == "bar y" + + # base was loaded indirectly, it just has env globals + tmpl = env.get_template("base") + assert tmpl.render() == "x y" + + # set template globals for base, no longer uses env globals + tmpl = env.get_template("base", globals={"x": 42}) + assert tmpl.render() == "42 y" + + # templates are cached, they keep template globals set earlier + tmpl = env.get_template("main") + assert tmpl.render() == "bar y" + + tmpl = env.get_template("base") + assert tmpl.render() == "42 y" + + def test_nested_loop_scoping(self, env): + tmpl = env.from_string( + "{% set output %}{% for x in [1,2,3] %}hello{% endfor %}" + "{% endset %}{{ output }}" + ) + assert tmpl.render() == "hellohellohello" + + +@pytest.mark.parametrize("unicode_char", ["\N{FORM FEED}", "\x85"]) +def test_unicode_whitespace(env, unicode_char): + content = "Lorem ipsum\n" + unicode_char + "\nMore text" + tmpl = env.from_string(content) + assert tmpl.render() == content diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..1978c64 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,75 @@ +import itertools + +from jinja2 import Template +from jinja2.runtime import LoopContext + +TEST_IDX_TEMPLATE_STR_1 = ( + "[{% for i in lst|reverse %}(len={{ loop.length }}," + " revindex={{ loop.revindex }}, index={{ loop.index }}, val={{ i }}){% endfor %}]" +) +TEST_IDX0_TEMPLATE_STR_1 = ( + "[{% for i in lst|reverse %}(len={{ loop.length }}," + " revindex0={{ loop.revindex0 }}, index0={{ loop.index0 }}, val={{ i }})" + "{% endfor %}]" +) + + +def test_loop_idx(): + t = Template(TEST_IDX_TEMPLATE_STR_1) + lst = [10] + excepted_render = "[(len=1, revindex=1, index=1, val=10)]" + assert excepted_render == t.render(lst=lst) + + +def test_loop_idx0(): + t = Template(TEST_IDX0_TEMPLATE_STR_1) + lst = [10] + excepted_render = "[(len=1, revindex0=0, index0=0, val=10)]" + assert excepted_render == t.render(lst=lst) + + +def test_loopcontext0(): + in_lst = [] + lc = LoopContext(reversed(in_lst), None) + assert lc.length == len(in_lst) + + +def test_loopcontext1(): + in_lst = [10] + lc = LoopContext(reversed(in_lst), None) + assert lc.length == len(in_lst) + + +def test_loopcontext2(): + in_lst = [10, 11] + lc = LoopContext(reversed(in_lst), None) + assert lc.length == len(in_lst) + + +def test_iterator_not_advanced_early(): + t = Template("{% for _, g in gs %}{{ loop.index }} {{ g|list }}\n{% endfor %}") + out = t.render( + gs=itertools.groupby([(1, "a"), (1, "b"), (2, "c"), (3, "d")], lambda x: x[0]) + ) + # groupby groups depend on the current position of the iterator. If + # it was advanced early, the lists would appear empty. + assert out == "1 [(1, 'a'), (1, 'b')]\n2 [(2, 'c')]\n3 [(3, 'd')]\n" + + +def test_mock_not_pass_arg_marker(): + """If a callable class has a ``__getattr__`` that returns True-like + values for arbitrary attrs, it should not be incorrectly identified + as a ``pass_context`` function. + """ + + class Calc: + def __getattr__(self, item): + return object() + + def __call__(self, *args, **kwargs): + return len(args) + len(kwargs) + + t = Template("{{ calc() }}") + out = t.render(calc=Calc()) + # Would be "1" if context argument was passed. + assert out == "0" diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..0e8dc5c --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,173 @@ +import pytest +from markupsafe import escape + +from jinja2 import Environment +from jinja2.exceptions import SecurityError +from jinja2.exceptions import TemplateRuntimeError +from jinja2.exceptions import TemplateSyntaxError +from jinja2.nodes import EvalContext +from jinja2.sandbox import ImmutableSandboxedEnvironment +from jinja2.sandbox import SandboxedEnvironment +from jinja2.sandbox import unsafe + + +class PrivateStuff: + def bar(self): + return 23 + + @unsafe + def foo(self): + return 42 + + def __repr__(self): + return "PrivateStuff" + + +class PublicStuff: + def bar(self): + return 23 + + def _foo(self): + return 42 + + def __repr__(self): + return "PublicStuff" + + +class TestSandbox: + def test_unsafe(self, env): + env = SandboxedEnvironment() + pytest.raises( + SecurityError, env.from_string("{{ foo.foo() }}").render, foo=PrivateStuff() + ) + assert env.from_string("{{ foo.bar() }}").render(foo=PrivateStuff()) == "23" + + pytest.raises( + SecurityError, env.from_string("{{ foo._foo() }}").render, foo=PublicStuff() + ) + assert env.from_string("{{ foo.bar() }}").render(foo=PublicStuff()) == "23" + assert env.from_string("{{ foo.__class__ }}").render(foo=42) == "" + assert env.from_string("{{ foo.func_code }}").render(foo=lambda: None) == "" + # security error comes from __class__ already. + pytest.raises( + SecurityError, + env.from_string("{{ foo.__class__.__subclasses__() }}").render, + foo=42, + ) + + def test_immutable_environment(self, env): + env = ImmutableSandboxedEnvironment() + pytest.raises(SecurityError, env.from_string("{{ [].append(23) }}").render) + pytest.raises(SecurityError, env.from_string("{{ {1:2}.clear() }}").render) + + def test_restricted(self, env): + env = SandboxedEnvironment() + pytest.raises( + TemplateSyntaxError, + env.from_string, + "{% for item.attribute in seq %}...{% endfor %}", + ) + pytest.raises( + TemplateSyntaxError, + env.from_string, + "{% for foo, bar.baz in seq %}...{% endfor %}", + ) + + def test_template_data(self, env): + env = Environment(autoescape=True) + t = env.from_string( + "{% macro say_hello(name) %}" + "<p>Hello {{ name }}!</p>{% endmacro %}" + '{{ say_hello("<blink>foo</blink>") }}' + ) + escaped_out = "<p>Hello <blink>foo</blink>!</p>" + assert t.render() == escaped_out + assert str(t.module) == escaped_out + assert escape(t.module) == escaped_out + assert t.module.say_hello("<blink>foo</blink>") == escaped_out + assert ( + escape(t.module.say_hello(EvalContext(env), "<blink>foo</blink>")) + == escaped_out + ) + assert escape(t.module.say_hello("<blink>foo</blink>")) == escaped_out + + def test_attr_filter(self, env): + env = SandboxedEnvironment() + tmpl = env.from_string('{{ cls|attr("__subclasses__")() }}') + pytest.raises(SecurityError, tmpl.render, cls=int) + + def test_binary_operator_intercepting(self, env): + def disable_op(left, right): + raise TemplateRuntimeError("that operator so does not work") + + for expr, ctx, rv in ("1 + 2", {}, "3"), ("a + 2", {"a": 2}, "4"): + env = SandboxedEnvironment() + env.binop_table["+"] = disable_op + t = env.from_string(f"{{{{ {expr} }}}}") + assert t.render(ctx) == rv + env.intercepted_binops = frozenset(["+"]) + t = env.from_string(f"{{{{ {expr} }}}}") + with pytest.raises(TemplateRuntimeError): + t.render(ctx) + + def test_unary_operator_intercepting(self, env): + def disable_op(arg): + raise TemplateRuntimeError("that operator so does not work") + + for expr, ctx, rv in ("-1", {}, "-1"), ("-a", {"a": 2}, "-2"): + env = SandboxedEnvironment() + env.unop_table["-"] = disable_op + t = env.from_string(f"{{{{ {expr} }}}}") + assert t.render(ctx) == rv + env.intercepted_unops = frozenset(["-"]) + t = env.from_string(f"{{{{ {expr} }}}}") + with pytest.raises(TemplateRuntimeError): + t.render(ctx) + + +class TestStringFormat: + def test_basic_format_safety(self): + env = SandboxedEnvironment() + t = env.from_string('{{ "a{0.__class__}b".format(42) }}') + assert t.render() == "ab" + + def test_basic_format_all_okay(self): + env = SandboxedEnvironment() + t = env.from_string('{{ "a{0.foo}b".format({"foo": 42}) }}') + assert t.render() == "a42b" + + def test_safe_format_safety(self): + env = SandboxedEnvironment() + t = env.from_string('{{ ("a{0.__class__}b{1}"|safe).format(42, "<foo>") }}') + assert t.render() == "ab<foo>" + + def test_safe_format_all_okay(self): + env = SandboxedEnvironment() + t = env.from_string('{{ ("a{0.foo}b{1}"|safe).format({"foo": 42}, "<foo>") }}') + assert t.render() == "a42b<foo>" + + def test_empty_braces_format(self): + env = SandboxedEnvironment() + t1 = env.from_string('{{ ("a{}b{}").format("foo", "42")}}') + t2 = env.from_string('{{ ("a{}b{}"|safe).format(42, "<foo>") }}') + assert t1.render() == "afoob42" + assert t2.render() == "a42b<foo>" + + +class TestStringFormatMap: + def test_basic_format_safety(self): + env = SandboxedEnvironment() + t = env.from_string('{{ "a{x.__class__}b".format_map({"x":42}) }}') + assert t.render() == "ab" + + def test_basic_format_all_okay(self): + env = SandboxedEnvironment() + t = env.from_string('{{ "a{x.foo}b".format_map({"x":{"foo": 42}}) }}') + assert t.render() == "a42b" + + def test_safe_format_all_okay(self): + env = SandboxedEnvironment() + t = env.from_string( + '{{ ("a{x.foo}b{y}"|safe).format_map({"x":{"foo": 42}, "y":"<foo>"}) }}' + ) + assert t.render() == "a42b<foo>" diff --git a/tests/test_tests.py b/tests/test_tests.py new file mode 100644 index 0000000..75178d6 --- /dev/null +++ b/tests/test_tests.py @@ -0,0 +1,233 @@ +import pytest +from markupsafe import Markup + +from jinja2 import Environment +from jinja2 import TemplateAssertionError +from jinja2 import TemplateRuntimeError + + +class MyDict(dict): + pass + + +class TestTestsCase: + def test_defined(self, env): + tmpl = env.from_string("{{ missing is defined }}|{{ true is defined }}") + assert tmpl.render() == "False|True" + + def test_even(self, env): + tmpl = env.from_string("""{{ 1 is even }}|{{ 2 is even }}""") + assert tmpl.render() == "False|True" + + def test_odd(self, env): + tmpl = env.from_string("""{{ 1 is odd }}|{{ 2 is odd }}""") + assert tmpl.render() == "True|False" + + def test_lower(self, env): + tmpl = env.from_string("""{{ "foo" is lower }}|{{ "FOO" is lower }}""") + assert tmpl.render() == "True|False" + + # Test type checks + @pytest.mark.parametrize( + "op,expect", + ( + ("none is none", True), + ("false is none", False), + ("true is none", False), + ("42 is none", False), + ("none is true", False), + ("false is true", False), + ("true is true", True), + ("0 is true", False), + ("1 is true", False), + ("42 is true", False), + ("none is false", False), + ("false is false", True), + ("true is false", False), + ("0 is false", False), + ("1 is false", False), + ("42 is false", False), + ("none is boolean", False), + ("false is boolean", True), + ("true is boolean", True), + ("0 is boolean", False), + ("1 is boolean", False), + ("42 is boolean", False), + ("0.0 is boolean", False), + ("1.0 is boolean", False), + ("3.14159 is boolean", False), + ("none is integer", False), + ("false is integer", False), + ("true is integer", False), + ("42 is integer", True), + ("3.14159 is integer", False), + ("(10 ** 100) is integer", True), + ("none is float", False), + ("false is float", False), + ("true is float", False), + ("42 is float", False), + ("4.2 is float", True), + ("(10 ** 100) is float", False), + ("none is number", False), + ("false is number", True), + ("true is number", True), + ("42 is number", True), + ("3.14159 is number", True), + ("complex is number", True), + ("(10 ** 100) is number", True), + ("none is string", False), + ("false is string", False), + ("true is string", False), + ("42 is string", False), + ('"foo" is string', True), + ("none is sequence", False), + ("false is sequence", False), + ("42 is sequence", False), + ('"foo" is sequence', True), + ("[] is sequence", True), + ("[1, 2, 3] is sequence", True), + ("{} is sequence", True), + ("none is mapping", False), + ("false is mapping", False), + ("42 is mapping", False), + ('"foo" is mapping', False), + ("[] is mapping", False), + ("{} is mapping", True), + ("mydict is mapping", True), + ("none is iterable", False), + ("false is iterable", False), + ("42 is iterable", False), + ('"foo" is iterable', True), + ("[] is iterable", True), + ("{} is iterable", True), + ("range(5) is iterable", True), + ("none is callable", False), + ("false is callable", False), + ("42 is callable", False), + ('"foo" is callable', False), + ("[] is callable", False), + ("{} is callable", False), + ("range is callable", True), + ), + ) + def test_types(self, env, op, expect): + t = env.from_string(f"{{{{ {op} }}}}") + assert t.render(mydict=MyDict(), complex=complex(1, 2)) == str(expect) + + def test_upper(self, env): + tmpl = env.from_string('{{ "FOO" is upper }}|{{ "foo" is upper }}') + assert tmpl.render() == "True|False" + + def test_equalto(self, env): + tmpl = env.from_string( + "{{ foo is eq 12 }}|" + "{{ foo is eq 0 }}|" + "{{ foo is eq (3 * 4) }}|" + '{{ bar is eq "baz" }}|' + '{{ bar is eq "zab" }}|' + '{{ bar is eq ("ba" + "z") }}|' + "{{ bar is eq bar }}|" + "{{ bar is eq foo }}" + ) + assert ( + tmpl.render(foo=12, bar="baz") + == "True|False|True|True|False|True|True|False" + ) + + @pytest.mark.parametrize( + "op,expect", + ( + ("eq 2", True), + ("eq 3", False), + ("ne 3", True), + ("ne 2", False), + ("lt 3", True), + ("lt 2", False), + ("le 2", True), + ("le 1", False), + ("gt 1", True), + ("gt 2", False), + ("ge 2", True), + ("ge 3", False), + ), + ) + def test_compare_aliases(self, env, op, expect): + t = env.from_string(f"{{{{ 2 is {op} }}}}") + assert t.render() == str(expect) + + def test_sameas(self, env): + tmpl = env.from_string("{{ foo is sameas false }}|{{ 0 is sameas false }}") + assert tmpl.render(foo=False) == "True|False" + + def test_no_paren_for_arg1(self, env): + tmpl = env.from_string("{{ foo is sameas none }}") + assert tmpl.render(foo=None) == "True" + + def test_escaped(self, env): + env = Environment(autoescape=True) + tmpl = env.from_string("{{ x is escaped }}|{{ y is escaped }}") + assert tmpl.render(x="foo", y=Markup("foo")) == "False|True" + + def test_greaterthan(self, env): + tmpl = env.from_string("{{ 1 is greaterthan 0 }}|{{ 0 is greaterthan 1 }}") + assert tmpl.render() == "True|False" + + def test_lessthan(self, env): + tmpl = env.from_string("{{ 0 is lessthan 1 }}|{{ 1 is lessthan 0 }}") + assert tmpl.render() == "True|False" + + def test_multiple_tests(self): + items = [] + + def matching(x, y): + items.append((x, y)) + return False + + env = Environment() + env.tests["matching"] = matching + tmpl = env.from_string( + "{{ 'us-west-1' is matching '(us-east-1|ap-northeast-1)'" + " or 'stage' is matching '(dev|stage)' }}" + ) + assert tmpl.render() == "False" + assert items == [ + ("us-west-1", "(us-east-1|ap-northeast-1)"), + ("stage", "(dev|stage)"), + ] + + def test_in(self, env): + tmpl = env.from_string( + '{{ "o" is in "foo" }}|' + '{{ "foo" is in "foo" }}|' + '{{ "b" is in "foo" }}|' + "{{ 1 is in ((1, 2)) }}|" + "{{ 3 is in ((1, 2)) }}|" + "{{ 1 is in [1, 2] }}|" + "{{ 3 is in [1, 2] }}|" + '{{ "foo" is in {"foo": 1}}}|' + '{{ "baz" is in {"bar": 1}}}' + ) + assert tmpl.render() == "True|True|False|True|False|True|False|True|False" + + +def test_name_undefined(env): + with pytest.raises(TemplateAssertionError, match="No test named 'f'"): + env.from_string("{{ x is f }}") + + +def test_name_undefined_in_if(env): + t = env.from_string("{% if x is defined %}{{ x is f }}{% endif %}") + assert t.render() == "" + + with pytest.raises(TemplateRuntimeError, match="No test named 'f'"): + t.render(x=1) + + +def test_is_filter(env): + assert env.call_test("filter", "title") + assert not env.call_test("filter", "bad-name") + + +def test_is_test(env): + assert env.call_test("test", "number") + assert not env.call_test("test", "bad-name") diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..7b58af1 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,185 @@ +import pickle +import random +from collections import deque +from copy import copy as shallow_copy + +import pytest +from markupsafe import Markup + +from jinja2.utils import consume +from jinja2.utils import generate_lorem_ipsum +from jinja2.utils import LRUCache +from jinja2.utils import missing +from jinja2.utils import object_type_repr +from jinja2.utils import select_autoescape +from jinja2.utils import urlize + + +class TestLRUCache: + def test_simple(self): + d = LRUCache(3) + d["a"] = 1 + d["b"] = 2 + d["c"] = 3 + d["a"] + d["d"] = 4 + assert d.keys() == ["d", "a", "c"] + + def test_values(self): + cache = LRUCache(3) + cache["b"] = 1 + cache["a"] = 2 + assert cache.values() == [2, 1] + + def test_values_empty(self): + cache = LRUCache(2) + assert cache.values() == [] + + def test_pickleable(self): + cache = LRUCache(2) + cache["foo"] = 42 + cache["bar"] = 23 + cache["foo"] + + for protocol in range(3): + copy = pickle.loads(pickle.dumps(cache, protocol)) + assert copy.capacity == cache.capacity + assert copy._mapping == cache._mapping + assert copy._queue == cache._queue + + @pytest.mark.parametrize("copy_func", [LRUCache.copy, shallow_copy]) + def test_copy(self, copy_func): + cache = LRUCache(2) + cache["a"] = 1 + cache["b"] = 2 + copy = copy_func(cache) + assert copy._queue == cache._queue + copy["c"] = 3 + assert copy._queue != cache._queue + assert copy.keys() == ["c", "b"] + + def test_clear(self): + d = LRUCache(3) + d["a"] = 1 + d["b"] = 2 + d["c"] = 3 + d.clear() + assert d.__getstate__() == {"capacity": 3, "_mapping": {}, "_queue": deque([])} + + def test_repr(self): + d = LRUCache(3) + d["a"] = 1 + d["b"] = 2 + d["c"] = 3 + # Sort the strings - mapping is unordered + assert sorted(repr(d)) == sorted("<LRUCache {'a': 1, 'b': 2, 'c': 3}>") + + def test_items(self): + """Test various items, keys, values and iterators of LRUCache.""" + d = LRUCache(3) + d["a"] = 1 + d["b"] = 2 + d["c"] = 3 + assert d.items() == [("c", 3), ("b", 2), ("a", 1)] + assert d.keys() == ["c", "b", "a"] + assert d.values() == [3, 2, 1] + assert list(reversed(d)) == ["a", "b", "c"] + + # Change the cache a little + d["b"] + d["a"] = 4 + assert d.items() == [("a", 4), ("b", 2), ("c", 3)] + assert d.keys() == ["a", "b", "c"] + assert d.values() == [4, 2, 3] + assert list(reversed(d)) == ["c", "b", "a"] + + def test_setdefault(self): + d = LRUCache(3) + assert len(d) == 0 + assert d.setdefault("a") is None + assert d.setdefault("a", 1) is None + assert len(d) == 1 + assert d.setdefault("b", 2) == 2 + assert len(d) == 2 + + +class TestHelpers: + def test_object_type_repr(self): + class X: + pass + + assert object_type_repr(42) == "int object" + assert object_type_repr([]) == "list object" + assert object_type_repr(X()) == "test_utils.X object" + assert object_type_repr(None) == "None" + assert object_type_repr(Ellipsis) == "Ellipsis" + + def test_autoescape_select(self): + func = select_autoescape( + enabled_extensions=("html", ".htm"), + disabled_extensions=("txt",), + default_for_string="STRING", + default="NONE", + ) + + assert func(None) == "STRING" + assert func("unknown.foo") == "NONE" + assert func("foo.html") + assert func("foo.htm") + assert not func("foo.txt") + assert func("FOO.HTML") + assert not func("FOO.TXT") + + +class TestEscapeUrlizeTarget: + def test_escape_urlize_target(self): + url = "http://example.org" + target = "<script>" + assert urlize(url, target=target) == ( + '<a href="http://example.org"' + ' target="<script>">' + "http://example.org</a>" + ) + + +class TestLoremIpsum: + def test_lorem_ipsum_markup(self): + """Test that output of lorem_ipsum is Markup by default.""" + assert isinstance(generate_lorem_ipsum(), Markup) + + def test_lorem_ipsum_html(self): + """Test that output of lorem_ipsum is a string_type when not html.""" + assert isinstance(generate_lorem_ipsum(html=False), str) + + def test_lorem_ipsum_n(self): + """Test that the n (number of lines) works as expected.""" + assert generate_lorem_ipsum(n=0, html=False) == "" + for n in range(1, 50): + assert generate_lorem_ipsum(n=n, html=False).count("\n") == (n - 1) * 2 + + def test_lorem_ipsum_min(self): + """Test that at least min words are in the output of each line""" + for _ in range(5): + m = random.randrange(20, 99) + for _ in range(10): + assert generate_lorem_ipsum(n=1, min=m, html=False).count(" ") >= m - 1 + + def test_lorem_ipsum_max(self): + """Test that at least max words are in the output of each line""" + for _ in range(5): + m = random.randrange(21, 100) + for _ in range(10): + assert generate_lorem_ipsum(n=1, max=m, html=False).count(" ") < m - 1 + + +def test_missing(): + """Test the repr of missing.""" + assert repr(missing) == "missing" + + +def test_consume(): + """Test that consume consumes an iterator.""" + x = iter([1, 2, 3, 4, 5]) + consume(x) + with pytest.raises(StopIteration): + next(x) @@ -0,0 +1,24 @@ +[tox] +envlist = + py3{11,10,9,8,7},pypy3{8,7} + style + typing + docs +skip_missing_interpreters = true + +[testenv] +deps = -r requirements/tests.txt +commands = pytest -v --tb=short --basetemp={envtmpdir} {posargs} + +[testenv:style] +deps = pre-commit +skip_install = true +commands = pre-commit run --all-files --show-diff-on-failure + +[testenv:typing] +deps = -r requirements/typing.txt +commands = mypy + +[testenv:docs] +deps = -r requirements/docs.txt +commands = sphinx-build -W -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html |