Package: pymacros4py
pymacros4pyBrief descriptionpymacros4py is a templating system for Python code. It is based on a source-level macro
preprocessor. Expressions, statements, and functions in the macro domain are also
written in Python.Why and when?Typical Python software is developed without using macros. And that for good reasons:
Macros are not necessary. And they have serious disadvantages.But in some cases, you might want to write code that generates or manipulates
code. Then, using a template system might be a good alternative.Here are some examples:You maintain a common code base for two different platforms, e.g., CPython and
Cython. There are places in your code where you want to differentiate
between the platforms already on the source level, e.g., in order to hide code
for one platform from users of the other platform.There is this function that is called in performance critical code, and you
want to inline it - but without spreading its implementation all over the project, and
without larger modifications of your project, e.g., like switching to PyPy.You need to automatically generate parts of the content of some
configuration file, and you want to make this process more explicit than using some
generator script hidden somewhere in your project.You need a templating system for Python code? Welcome to pymacros4py!(Note: pymacros4py expands macros at the source-level. For extending
the language Python itself, in order to get a programming language with
additional capabilities, macro expansion
on the level of the abstract syntax tree, in the build phase of the
interpretation process, is better suited. See, e.g., the language labmcpyrate.)Propertiespymacros4pyis a templating system with the following characteristics:It ismade for templates for Python files, e.g., macro-generated code
respects indentation.Statements, functions, and expressions of the macro domainare defined as Python code-
You do not need to learn a special macro language.
And you can directly use all the capabilities of Python.The preprocessor workson the source level- You see the resulting code before
any bytecode generation / compilation / interpretation is done. And no dependency on
external code, libraries or executables is introduced in these execution phases.Macro variables and functionscan be defined directly in the files where they are used, or
in separate files.Macro expansion can bemulti-level, e.g., when macro code includes templates
that contain macro code. Expansion results are cached in order to avoid unnecessary
computations.No replacements outside explicitly marked macro sections take place-
This avoids the problem that a macro definition may automatically apply to future
code added by other maintainers who do not know about the macro, with unexpected
results.pymacros4pyis implemented aspure Python code, with a really tiny code base.
It could be an acceptable pre-build dependency for your project, even if you aim at
avoiding dependencies. And if it is not acceptable, just copy its code directly into
your project.The code ofpymacros4pyis tested with 100% code coverage.
All examples in this README, except for a single line (excluded for
technical reasons), are covered by tests.So far,SemVeris not used, and the package is marked asbeta. The reason is
not a lack of quality, but the practical experience is not sufficient to guarantee
stability of the API.UsageInstallationYou can get and installpymacros4pyas follows by usingpip:pipinstallpymacros4pyCalling pymacros4pyThe preprocessor can be accessed in a Python script as follows:>>>frompymacros4pyimportPreProcessor>>>pp=PreProcessor()Then, the macros in a template file can be expanded as follows:pp.expand_file_to_file('my_code.template.py','my_code.py')Warning: Whenpymacros4pyexpands macros, it executes the expressions, statements
and functions of your macro code. You might want to apply it only to files that you
have fully under your control. If you write macro code that includes unsafe file
content, you can disable macro expansion for this content.The methodexpand_file_to_fileoffers the following keyword parameter:diffs_to_result_file: bool = False. True means that the result is not written to
the output file, but compared to the content of the file, and the result
of the comparison is returned.>>>pp.expand_file_to_file('tpl/README.rst','README.rst',diffs_to_result_file=True)''If you need specific arguments for theencoding,errors, ornewlinesparameters used for opening files (see Python functionopen), you can set these
as attributes of the global objectfile_options:>>>frompymacros4pyimportfile_options>>>file_options.encoding="utf-8"Templates and template expansionAtemplateconsists of macro sections and text sections. A single line
of a template can already contain several such sections.A macro section contains Python code intended to be executed during the macro
expansion.A text section can be anything. In case of a template for a Python file,
it is normal Python code. It is used as-is (except for a possible adaptation
of the indentation).For expanding the macros in a template,pymacros4pyseparates the macro and the
text sections. Then, it generates and executes a so-calledtemplate scriptas follows:Code of macro sections of the template is directly taken into thetemplate script. When this code is executed, it can insert text into the outputof the macro expansion by calling functioninsert().For text sections, a statement that inserts the text into the resultsis automatically appended to the template script.Example:The following template for application code contains a full-line macro
section (the first line) and a macro section embedded in a line of the application
code# $$ v = 2 * 3x='$$ insert(v) $$'From this template, pymacros4py generates a template script that looks roughly as
follows:v=2*3insert('x = ')insert(v)insert('\n')This template script will be executed by pymacros4py. It generates the following
application code as result:x=6Application code written in Python and macro code written in Python can
be mixed like this, and the macro code extends and manipulates the application code.This explanation and example already gives a good impression of how templates
can be written. Further details are described in the following sections.Quoted macro code in templatesOne way to mark macro code in a template looks similar to astring starting and ending with two dollar characters.
Single or double quotes, or triple single or double quotes can be used.Example:The following lines each show a macro section with ‘v = 0’ as
macro code within the macro section.'$$ v = 0 $$'"$$ v = 0 $$"'''$$ v = 0 $$'''"""$$ v = 0 $$"""Start and end of macro code is identified only by the special combinationof quoting and dollar characters.
Thus, both the quotes and the dollars can be freely used in macro code
and in application code, as long as they do not occur together. This makes the
macro recognition quite robust.Example:Some dollar characters and quotes in application code and in macro
sections, but not combined in the special syntax that starts or ends a macro sectionprint('This is application code with $$ characters and quotes')'$$ v = 'Thisisaquotedstringwithinmacrocode' $$'Amacro sectionspans quoting, dollars and code together.If before and after the quotes, there are only space or tab characters,
the macro section is ablock macro section(otherwise: aninline macro section)
and spans the whole line(s), including a trailing line break if present.Example:Macro section that spans the whole line, including the trailing line break.# This is a comment in application code'$$ v = 0  # This macro section spans the whole line $$'# This is a second comment in application codeMacro code can span several lines. All four possible quoting types can be used for
this, but triple quotes are more pythonic here.Example:Macro section that spans several lines'''$$ # This comment belongs to the macro code
      v = 'a string'
$$'''Whitespace and line breaks in the macro section before and after the macro code
are ignored.Example: Identical macro code (‘v = 0’), surrounded by different whitespace and/or
line breaks.'''$$      v = 0       $$''''''$$
      v = 0
$$'''Indentation in macro codeMacro code in a macro section can be indented to an arbitrary local level, independently
of other macro sections and surrounding application code. Locally, indentation needs
to follow Python syntax. Globally,pymacros4pywill establish a valid indentation when
combining code of several macro sections, and code generated bymymacros4pyitself.The first (non-whitespace) character of the macro codein a macro sectiondefines the base indentationof the code. Subsequent lines of the macro code need to
be indented accordingly: equally indented (by literally the same characters, but
with each non-whitespace character replaced by a space character),
or with additional indentation characters (following the base indentation), or not
indented at all. Whenpymacros4pyre-indents code, it changes only the base
indentation, and it keeps non-indented lines non-indented.Note: This concept supports indentation by space characters, by tabs, and even
mixed forms, and does not require fixing the amount of indentation resulting from a tab.
But there is one limitation:If macro code is indented by tabs, it needs to start in its own line.Example:Macro code starts in its own line.
Indentation is done by space and/or tab characters.
The indentation of the first
non-whitespace character (here: ‘v’) defines the base indentation of the
macro section, and subsequent lines are indented equally (by an identical indentation
string). The third line is locally indented, relative to this base indentation.'''$$
        v1 = 0
        for i in range(3):
            v1 += i
$$'''Example:Macro code starts in the first line of the macro section.
All indentation is done by space characters.
The number of characters left to the ‘v’ determines
the base indentation that the second line follows.
The third line is locally indented, relative to this base indentation.'''$       v1 = 0
           for i in range(3):
               v1 += i
$$'''Example:Multi-line string with non-indented content'''$$
    if True:  # enforce indentation
        v1 = """
    First line of string. No indentation. This will be preserved.
    Second line of string. No indentation. This will be preserved.
    """
        # We continue the indented suite of the if statement
        v2 = 0
$$'''Macro code in a commentA second option to mark macro code in a template has theform of a comment,starting with a hash character, optionally
followed by spaces or tabs,and two dollar characters. The macro code ends with
the line. If there are only space and/or tab characters before the hash,
the macro section spans the whole line, including a trailing line break.# $$ v = 0Arbitrary Python code as macro codeMacro code is regular Python code. A call to the predefinedfunctioninsertinserts the results of applying the functionstrto the arguments ofinsertat the place of the macro section.Example:Macro code defining a function that generates codedefa_function_of_our_application():'''$$
    # Here, we define a function in macro code
    def return_print_n_times(n, s):
        statement = f'print("{s}")\n'
        return statement * n
    # Now, we call it
    insert(return_print_n_times(3, "Yep."))
    $$'''The template script derived from this template generates the following result:defa_function_of_our_application():print("Yep.")print("Yep.")print("Yep.")Example:Macro code inserting a computation resultdefexample_function(i:int)->int:# $$ v = 2 * 2return'$$ insert(v) $$'It evaluates to:defexample_function(i:int)->int:return4Indentation of macro resultsThe results of the expansion of a macro section,
e.g., the output of calls of functioninsert,are indented relative to theindentation of the first non-whitespace character of the macro section(i.e.,
the hash character for macro code in a comment, resp., the first quote in quoted
macro code).Example:Macro sections with different indentation levelsv=True# $$ # Macro expansion result will be indented to this level# $$ insert(f'print({1+1})\n')ifv:# $$ # Macro expansion result will be indended to this higher level# $$ insert(f'print({2+2})\n')This template is evaluated to the following result:v=Trueprint(2)ifv:print(4)For inline macro sections, the first line of the results is inserted withoutadding indentation.For block macro sections, each line is (re-) indented.Example:An inline macro section and a block macro section, both with multi-line
results# $$ v = 2y=1+'$$ insert("(\n", v, "\n* w\n)") $$'z=11+('''$$ insert(v+1, "\n*w\n") $$''')This template is evaluated to the following result:y=1+(2*w)z=11+(3*w)In the first case, the inline macro section, the expansion result (starting with the
opening bracket) is inserted directly after the application code ‘y = 1 + ‘, without
indentation.In the second case, the block macro section, the expansion result (starting with the
‘3’) is inserted with indentation.If the library detects zero indentation in macro output, this zero indentationis preserved, i.e., no re-indentation happens.Example:Recognizable zero indentation in macro output is preserved.ifTrue:"""$$
        insert("    v = '''\ntext\n'''\n")
    $$"""This template is evaluated to the following result:ifTrue:v='''
text
'''The macro section of the example starts in an indented suite, here, of anifstatement. Thus, macro output of the following macro code will be re-indented
to this level - except for the case that zero indentation of output is explicitly
demanded. So, we can check in the results, if this exception works.Then, in the macro code, we start with inserting output at a non-zero base
indentation, as reference (the spaces before the assignment). So, the library
can detect that the subsequent lines require zero-indentation (the text of
the string literal is given with zero indentation).In the expansion result, we see that the macro output starts indented to the
level of the start of the macro section: re-indentation happened here. But then,
the zero indentation of the lines of the string literal is detected and thus
preserved.Including and importing filesMacro code can insert expansion results or import attributes, e.g.,
function definitions, from other template files. pymacros4py offers the following
functions for this:insert_from(self, template_file: str, globals_dict: Optional[dict]=None) -> None:Perform a macro expansion oftemplate_filewithin a new namespace, andinsert the resultsinto the results of the current macro expansion.globalscan be given to initialize the namespace like in a call ofeval().When called a second time with an identical argument forfile,
andglobalsisNonein both calls, re-use the output of the previous run.(Ifglobalsis notNone, and you want to re-use results in cases of
equivalent content ofglobals, this has to be implemented manually.)import_from(self, template_file: str) -> None:Perform a macro expansion oftemplate_filein the namespace of the current macro expansion(attributes that have already been set can be used by macro code intemplate_file,
and attributes set by such code can be used in macro code following
the call).Discard the output of the expansion run.When called a second time with an identical argument fortemplate_file,
ignore the call.Example for insert_from:The following call ofinsert_from:defexample_function()->int:# $$ i = 3# $$ insert_from("tests/data/file_with_output_macro.py")return"$$ insert(i) $$"with the following content of the file:# $$ i = 2print('some text')evaluates to:defexample_function()->int:print('some text')return3The output of theincludestatement is added to the results,
but the content of the global namespace (here: the value of variablei) is not
changed.Example for import_from:The following template:# $$ import_from("tests/data/file_with_definition_macro.py")# $$ insert(return_print_n_times(3, "Yep."))with the following content of the file:'''$$
    def return_print_n_times(n, s):
        statement = f'print("{s}")\n'
        return statement * n
$$'''print("Text not important")evaluates to:print("Yep.")print("Yep.")print("Yep.")The content of the global namespace is extended by functionreturn_print_n_times,
but the output of the imported template is ignored.Macro statement suites spanning multiple sectionsIf the code in a macro section ends within asuiteof a Pythoncompound statement(seehttps://docs.python.org/3/reference/compound_stmts.html)
e.g., an indented block of statements after statements likeif,for, ordef,
this suite ends with the macro code:Example:'''$$ v = 1
      if v == 0:
          insert("print('v == 0')")
$$'''# $$ insert("print('Always')\n")Result:print('Always')But a suite can also span over subsequent template ormacro sections. This case is supported in a limited form (!) as follows:Start of the suite: Macro section with just the introducing statementThe header of the compound statement (its introducing statement, ending with
a colon) needs to be the only content of the macro section. Not even
a comment is allowed after the colon.Reason: The beginning of a suite that is meant to span multiple sections is
recognized by the colon ending the macro code. The kind of compound statement is
recognized by the first word of the macro code.A suite is ended by a:endmacro sectionIf the code of a macro section just consists of the special statement:end,
the suite that has started most recently, ends. Whitespace is ignored.Macro sectionselif, else, except, finally,andcaseend a suite and start a new oneIf a macro section starts with one of the listed statements and ends with
a colon, the suite ends, that has started most recently, the macro code is handled,
and then a new suite starts.Such suites can be nested.Examples for conditionally discarding or using text sections:# $$ import datetime# $$ d = datetime.date.today()# $$ if d > datetime.date(2024, 1, 1):# $$ code_block = 1# This comes from the first macro code block, number '$$ insert(code_block) $$'print('January 1st, 2024, or later')# $$ else:# This comes from the second macro code block, number '$$ insert(code_block) $$'print('Earlier than January 1st, 2024')# $$ :endThe template script generated from this template looks roughly as follows:importdatetimed=datetime.date.today()ifd>datetime.date(2024,1,1):code_block=1insert('# This comes from the first macro code block, number ')insert(code_block)insert("\nprint('January 1st, 2024, or later')\n")else:insert('# This comes from the second macro code block, number ')insert(code_block)insert("\nprint('Earlier than January 1st, 2024')\n")Note thatpymacros4pyautomatically indents theinsertstatements and the
statementscode_block = …when generating the template script, because in
Python, suites of compound statements need to be indented.This template script evaluates to:# This comes from the first macro code block, number 1print('January 1st, 2024, or later')Examples for loops over text blocks:# $$ for i in range(3):print('Yep, i is "$$ insert(i) $$".')# $$ :end# $$ j = 5# $$ while j > 3:print('And, yep, j is "$$ insert(j) $$".')# $$ j -= 1# $$ :endThis template evaluates to:print('Yep, i is 0.')print('Yep, i is 1.')print('Yep, i is 2.')print('And, yep, j is 5.')print('And, yep, j is 4.')Example for a multi-section suite containingboth indented and non-indented macro code:# $$ for i in range(2):print('Code from the text section, variable i is "$$ insert(i) $$".')'''$$ # The macro code of this section is locally indented to this level,
      # but not the content of the following text literal
      more_text = """\
print('This first line is not indented.')
print('This second line is not indented.')
"""
      # We continue at the base indention, it is here
      insert(more_text)
$$'''# $$ :endThe template script generated from this template looks roughly as follows:foriinrange(2):insert("print('Code from the text section, variable i is ")insert(i)insert(".')\n")# The macro code of this section is locally indented to this level,# but not the content of the following text literalmore_text="""\print('This first line is not indented.')
print('This second line is not indented.')
"""# We continue at the base indention, it is hereinsert(more_text)insert('\n')This template script shows: The implementation of multi-section suites bypymacros4pymeets two requirements:In Python, code in suites of compound statements needs to be indented. So,pymacros4pygenerates this indentation synthetically (re-indentation) when
generating the template script.It must be possible to define unindented string literals. So,pymacros4pydistinguishes unindented code from indented code, re-indents only the indented
code, but uses the unindented code as-is.The template evaluates to:print('Code from the text section, variable i is 0.')print('This first line is not indented.')print('This second line is not indented.')print('Code from the text section, variable i is 1.')print('This first line is not indented.')print('This second line is not indented.')def-statement-suites spanning multiple sectionsIf the suite of adef-statement spans multiple sections, indentation of
generated results of the macro expansion is special-cased as follows:Macro sections: Generated code is indented as part of the calling macro section,
not the defining macro section.Text sections: The content is also indented as part of the generated results(whereas outside the suite of adefstatement, it is interpreted as literal).
And the same rules apply: Zero indentation is kept, other indentation is interpreted
relative to the indentation of the first content character, and the indentation
is adapted to the indentation of the calling macro section.Examples:# $$ def some_inlined_computation(times, acc):formacro_var_iinrange('$$ insert(times) $$'):'$$ insert(acc) $$'=1# $$ :endj=k=0# $$ some_inlined_computation(3, "j")ifTrue:# $$ some_inlined_computation(2, "k")This template evaluates to:j=k=0formacro_var_iinrange(3):j=1ifTrue:formacro_var_iinrange(2):k=1Note, that the indentation of the results of the two calls of the function is defined
by the indentation of the calling macro sections, and not the defining macro
section. And this holds both for the macro sections and the text sections within the
suite of thedefstatement. Like that, valid indentation is established.DebuggingError messagesIn case something goes wrong,pymacros4pytries to give helpful error messages.Example: Wrong indentation within macro code'''$$
    # first line
  # indentation of second line below base indentation, but not zero
$$'''This template leads to the following exception:>>>pp.expand_file_to_file("tests/data/error_wrong_indentation_in_macro.tpl.py","out.py"...)# doctest: +NORMALIZE_WHITESPACETraceback(mostrecentcalllast):RuntimeError:File"tests/data/error_wrong_indentation_in_macro.tpl.py",line2:Syntaxerror:indentationofline1ofthemacrocodeisnotanextensionofthebaseindentation.Example: Macro section started, but not ended'''$$This template leads to the following exception:>>>pp.expand_file_to_file("tests/data/error_macro_section_not_ended.tpl.py","out.py"...)# doctest: +NORMALIZE_WHITESPACETraceback(mostrecentcalllast):RuntimeError:---File"tests/data/error_macro_section_not_ended.tpl.py",line1:Syntaxerrorinmacrosection,macrostartedbutnotended:'''$$
<BLANKLINE>Example: Nesting of multi-section suites of compound statements wrong,unexpected suite end#$$ if True:#$$ :end#$$ :endThis template leads to the following exception:>>>pp.expand_file_to_file("tests/data/error_unexpected_end.tpl.py","out.py"...)# doctest: +NORMALIZE_WHITESPACETraceback(mostrecentcalllast):RuntimeError:---File"tests/data/error_unexpected_end.tpl.py",line3:Nestingerrorincompoundstatementswithsuitesspanningseveralsections,inmacrosection::endExample: Nesting of multi-section suites of compound statements wrong,suite end missing#$$ if True:This template leads to the following exception:>>>pp.expand_file_to_file("tests/data/error_end_missing.tpl.py","out.py"...)# doctest: +NORMALIZE_WHITESPACETraceback(mostrecentcalllast):RuntimeError:Syntaxerror:blocknesting(indentation)notcorrect,is:endsomewheremissing?Example: Wrong indentation of expansion results'''$$
  insert("    # First line indented\n")
  insert("  # Second line indented, but less than the first\n")
$$'''This template leads to an exception:>>>try:...pp.expand_file_to_file("tests/data/error_result_indentation_inconsistent.tpl.py",..."out.py")...exceptExceptionase:...print(type(e).__name__)# doctest: +NORMALIZE_WHITESPACE, +ELLIPSISRuntimeError(Depending on the used Python version, the exception contains notes. If there
are notes, the doctest module cannot correctly parse them. And if not, the doctest
cannot handle this version-specific deviation of the results. So, above, we only
check that the expected exception occurs.)Comparing resultsMethodexpand_file_to_fileoffers an optiondiffs_to_result_filethat returns
the differences between the results of the macro expansion and the current content
of the result file. If there are no differences, the empty string is returned.Example:Showing results of a change in a templateIn the following template, we changed the expression with respect to the example of
sectionTemplates and template expansion.# $$ # In the following line, we changed the expression w.r.t. the example of# $$ # section Templates and template expansion# $$ v = 3 * 3x='$$ insert(v) $$'Now, we compare against the result we have gotten there:>>>print(pp.expand_file_to_file("tests/data/diff_templ_and_templ_exp.tpl.py",..."tests/data/doc_templ_and_templ_exp.py",...diffs_to_result_file=True))---currentcontent+++expansionresult@@-1+1@@-x=6+x=9<BLANKLINE>Viewing the template scriptWhen an exception is raised during the execution of a generated template script,
e.g., if there is an error in your Python macro code, the
script will be automatically stored (as temporary file, with the platform specific
Python mechanisms) and its path will be given in the error message.Additionally, the methodtemplate_scriptofpymacros4pycan be used to
see the generated template script anytime.Example:Getting the template script>>>print(pp.template_script("tests/data/doc_templ_and_templ_exp.tpl.py")...)# doctest: +NORMALIZE_WHITESPACE_macro_starts(indentation='',embedded=False,content_line='File "tests/data/doc_templ_and_templ_exp.tpl.py", line 1')v=2*3_macro_ends('File "tests/data/doc_templ_and_templ_exp.tpl.py", line 1')insert('x = ')_macro_starts(indentation='    ',embedded=True,content_line='File "tests/data/doc_templ_and_templ_exp.tpl.py", line 2')insert(v)_macro_ends('File "tests/data/doc_templ_and_templ_exp.tpl.py", line 2')insert('\n')<BLANKLINE>Here, we used the template from sectionTemplates and template expansion.
As can be seen, the real template script looks like the one shown there, but has some
additional bookkeeping code that marks when macro code starts and ends during
the execution of the template script.Tracingpymacros4pycan write a trace log during parsing of a template and during
execution of a template script: The optionstrace_parsingandtrace_evaluationof methodexpand_file_to_fileactivate this functionality. We demonstrate
this in the following example with methodexpand_file, which returns
the expansion result instead of storing it to a file.Example:Tracing of the parsing process>>>r=pp.expand_file("tests/data/doc_templ_and_templ_exp.tpl.py",...trace_parsing=True)# doctest: +NORMALIZE_WHITESPACE---File"tests/data/doc_templ_and_templ_exp.tpl.py",line1:line_block_macro:>v=2*3<<BLANKLINE><BLANKLINE>---File"tests/data/doc_templ_and_templ_exp.tpl.py",line2:text:>x=<<BLANKLINE><BLANKLINE>---File"tests/data/doc_templ_and_templ_exp.tpl.py",line2:embedded_macro:>insert(v)<<BLANKLINE><BLANKLINE>---File"tests/data/doc_templ_and_templ_exp.tpl.py",line2:text:><<BLANKLINE><BLANKLINE>Example:Tracing of the evaluation process>>>r=pp.expand_file("tests/data/doc_templ_and_templ_exp.tpl.py",...trace_evaluation=True)# doctest: +NORMALIZE_WHITESPACE'File "tests/data/doc_templ_and_templ_exp.tpl.py", line 1':line_block_macro>v=2*3<<BLANKLINE><BLANKLINE>'File "tests/data/doc_templ_and_templ_exp.tpl.py", line 2':text>x=<<BLANKLINE><BLANKLINE>'File "tests/data/doc_templ_and_templ_exp.tpl.py", line 2':embedded_macro>insert(v)<<BLANKLINE><BLANKLINE>'File "tests/data/doc_templ_and_templ_exp.tpl.py", line 2':text><<BLANKLINE><BLANKLINE>Changelogv0.8.1(2024-02-11)Error messages and format of text differences improvedSource formatted with black default 2024v0.8.0(2024-01-21)First published version