├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.rst ├── multigen ├── __init__.py ├── formatter.py ├── generator.py └── jinja.py ├── setup.py └── tests ├── conftest.py ├── input └── test.py.tpl ├── test_formatter.py ├── test_generator.py └── test_jinja.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = multigen 4 | 5 | [report] 6 | exclude_lines = 7 | raise NotImplementedError 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .eggs/ 3 | .cache/ 4 | **/__pycache__/ 5 | *.egg-info/ 6 | build/ 7 | dist/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.3" 4 | - "3.4" 5 | - "3.5" 6 | - "3.6" 7 | install: 8 | - pip install --upgrade setuptools 9 | - pip install --upgrade pytest pytest-cov coveralls 10 | - pip install -e . 11 | script: 12 | - python setup.py test -a -v -a --cov=multigen 13 | after_success: 14 | - coveralls 15 | deploy: 16 | provider: pypi 17 | user: $PYPI_USER 18 | password: $PYPI_PASSWORD 19 | distributions: "bdist_wheel" 20 | on: 21 | tags: true 22 | python: 3.6 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Mike Pagel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pymultigen - Multi-file frontend for single-file code generators 2 | ================================================================ 3 | 4 | .. image:: https://travis-ci.org/moltob/pymultigen.svg?branch=master 5 | :target: https://travis-ci.org/moltob/pymultigen 6 | 7 | .. image:: https://badge.fury.io/py/pymultigen.svg 8 | :target: https://badge.fury.io/py/pymultigen 9 | 10 | .. image:: https://coveralls.io/repos/github/moltob/pymultigen/badge.svg?branch=master 11 | :target: https://coveralls.io/github/moltob/pymultigen?branch=master 12 | 13 | .. image:: https://img.shields.io/badge/License-MIT-yellow.svg 14 | :target: https://opensource.org/licenses/MIT 15 | 16 | .. image:: https://img.shields.io/badge/contributions-welcome-brightgreen.svg 17 | 18 | This small library adds multi-file management on top of one or more existing single-file code 19 | generators. 20 | 21 | .. contents:: :depth: 2 22 | 23 | Why would I need pymultigen? 24 | ---------------------------- 25 | 26 | Code generators like `Mako `_ or `Jinja `_ 27 | are great and can be used to generate just about any kind of textual output from templates with a 28 | nice template language. They are very mature and battle-proven. However, most of those generators 29 | have their origin in the web application domain. The typical usecase is to dynamically render a 30 | single HTTP response (most of the time an HTML page) from one or more templates. *One* HTML page. 31 | 32 | If you want to use these generators in other scenarious, e.g. to generate code or reports, but not 33 | to *one* but to *multiple* files in different folders, pymultigen can help. It simply adds an easy 34 | to configure file and folder management layer on top of one or more existing code generators. 35 | 36 | Installation 37 | ------------ 38 | 39 | pymultigen comes in form or a regular Python distribution and can be installed from Github or PyPI 40 | with a simple: 41 | 42 | .. code-block:: shell 43 | 44 | $ pip install pymultigen 45 | 46 | The library works with any version of Python >= 3.3. 47 | 48 | Usage 49 | ----- 50 | 51 | The overall concept of pymultigen is simple: 52 | 53 | * A ``Generator`` class controls the overall generation workflow. The most important method it 54 | implements is ``generate(model, folder)``. This is the single method called by *users* of the 55 | created multi-file generator. 56 | * The ``Generator`` has a static list of ``Task`` objects. Each ``Task`` describes a step executed 57 | at generation time. 58 | * One ``Task`` is responsible for translating a specific set of elements in the input model to one 59 | output file in the output folder. The input set can be chosen arbitrarily, often this is the list 60 | of a certain model element type (e.g. instance of a ``Table`` class in a relational model from 61 | which SQL statements should be generated). 62 | 63 | Using pymultigen means therefore to create one ``Generator`` class for your new generator and one or 64 | more ``Task`` classes, one for each type of output artifact. If you are using a template-based code 65 | generator under the hood, you usually will have one ``Task`` per output template. 66 | 67 | Before you start, you need to check, whether pymultigen already has an integration for your 68 | single-file code generator built-in. Currently, the following integrations are available: 69 | 70 | * Jinja2 71 | 72 | If you want to use another generation engine, you can easily add support yourself (the current 73 | Jinja2 integration consists of less than 20 lines of code). If you've done so, please consider 74 | giving back to the community. Your contribution is welcome! Please see below for instructions how to 75 | extend pymultigen with a new integration. 76 | 77 | Using the Jinja2 integration 78 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 79 | 80 | You may want to check out `pyecoregen `_, a code generator 81 | from `pyecore `_-based models to Python classes. It is a 82 | concrete Jinja2-based code generator built with pymultigen. 83 | 84 | Jinja2 is a template-based text generator. Writing a file-generator with Jinja therefore involves 85 | writing a template for each type of output file. In pymultigen you will then implement a ``Task`` 86 | class per output file type, i.e. per Jinja template. 87 | 88 | The general form of such a ``Task`` looks like this: 89 | 90 | .. code-block:: python 91 | 92 | class MyTask(multigen.jinja.JinjaTask): 93 | 94 | # Name of template file used by this task. 95 | template_name = 'name-of-template.tpl' 96 | 97 | def filtered_elements(self, model): 98 | """Return iterator over elements in model that are passed to the above template.""" 99 | 100 | def relative_path_for_element(self, element): 101 | """Return relative file path receiving the generator output for given element.""" 102 | 103 | The workflow engine will initially call ``filtered_elements``. This method is expected to return an 104 | interator over model elements for which a single file needs to be generated. *Model* is meant here 105 | in an abstract way: It may be an instance of a formal metamodel, but it could be any Python object, 106 | like a dictionaries or lists. The contained elements being iterated over are accessible from within 107 | a template as ``element``. 108 | 109 | Once Jinja has produced a textual result it must be written to file. This is where 110 | ``relative_path_for_element`` comes into play. For a given element that was filtered from the model 111 | before, it returns the corresponding filepath. Note that this path is interpreted to be relative to 112 | the top-level output path of the overall generation (see below). If subfolders are mentioned here, 113 | they are created on demand. 114 | 115 | One or more task classes like this must then be registered with a top-level generator. Just like 116 | before, a new ``Generator`` class is derived from the appropriate base class: 117 | 118 | .. code-block:: python 119 | 120 | class MyGenerator(multigen.jinja.JinjaGenerator): 121 | 122 | # List of task objects to be processed by this generator. 123 | tasks = [ 124 | MyTask(), 125 | ] 126 | 127 | # Root path where Jinja templates are found. 128 | templates_path = os.path.join( 129 | os.path.abspath(os.path.dirname(__file__)), 130 | 'templates' 131 | ) 132 | 133 | def create_environment(self, **kwargs): 134 | """Create Jinja2 environment.""" 135 | environment = super().create_environment(**kwargs) 136 | # Do any customization of environment here, or delete this method. 137 | return environment 138 | 139 | The base class implementation of {{create_environment}} passes {{templates_path}} to the created 140 | environment object to allow Jinja to find the template names specified in a ``Tasks``'s 141 | ``template_name``. By overriding this method you can extend the environment, e.g. to add filters and 142 | tests. Of course you can also completely replace the implementation, e.g. to change the way how 143 | templates are looked up. 144 | 145 | The example above simply instantiates the new ``Task`` class. Here you can optionally pass a 146 | formatter function, that is then applied to the output of Jinja. Formatters are simple string 147 | transformations, some of which are built into the ``formatters.py`` module. If you actually are 148 | writing a Python code generator you may want to clean up the generated code according to pep8, 149 | so simply pass the appropriate formatter during task instantiation: 150 | 151 | .. code-block:: python 152 | 153 | class MyGeneratorWithPep8(multigen.jinja.JinjaGenerator): 154 | 155 | # List of task objects to be processed by this generator. 156 | tasks = [ 157 | MyTask(formatter=multigen.formatter.format_autopep8), 158 | ] 159 | 160 | ... 161 | 162 | Extending pymultigen 163 | -------------------- 164 | 165 | Contributions welcome! 166 | 167 | Below the most typical extension scenarios are described. Note that in theory pymultigen can be used 168 | with *any* code that produces text, not just a templating engine. Take a look at the class hierarchy 169 | in ``generator.py`` to get more insights or drop me a note if this is something you plan to do. 170 | 171 | Formatters 172 | ~~~~~~~~~~ 173 | 174 | Writing a new formatter is trivial: Simply create a function that transforms an input string into 175 | the nicely formatted output string. If you want to get your formatter added to pymultigen, please 176 | make sure that: 177 | 178 | * New dependencies (like autopep8 in the existing pep8 formatter) are only imported in the 179 | formatting function. This way user only pay for what they use. 180 | * Please write unittests and add your possible dependencies to the ``tests_require`` argument in 181 | ``setup.py``. 182 | 183 | There is not much more to it. 184 | 185 | Templating engine 186 | ~~~~~~~~~~~~~~~~~ 187 | 188 | For a live sample, look at the Jinja2 integration in ``jinja.py``. For your templating engine ``X``, 189 | you probably have to write small ``Generator`` and ``Task`` base classes like this: 190 | 191 | .. code-block:: python 192 | 193 | class XGenerator(TemplateGenerator): 194 | 195 | def __init__(self, environment=None, **kwargs): 196 | super().__init__(**kwargs) 197 | # Add any attributes to the generator that are static with respect to a full generation 198 | # run (over all files), like a Jinja2 environment. 199 | ... 200 | 201 | 202 | class XTask(TemplateFileTask): 203 | 204 | def generate_file(self, element, filepath): 205 | """Actual generation of element.""" 206 | 207 | Each element that is iterated over from the input model is eventually passed to the tasks's 208 | ``generate_file`` method. Here simply call you template engine to produce the output string. You 209 | also want to apply the optional formatter before writing the string to disk. This is how the Jinja 210 | task does it: 211 | 212 | .. code-block:: python 213 | 214 | def generate_file(self, element, filepath): 215 | template = self.environment.get_template(self.template_name) 216 | context = self.create_template_context(element=element) 217 | 218 | with open(filepath, 'wt') as file: 219 | file.write(self.formatter(template.render(**context))) 220 | 221 | The implementation shows two more things: 222 | 223 | * The template to be used is retrieved from an ``environment`` that is specific to the template 224 | engine. Such an environment is usually passed down from the ``Generator`` class to the ``Task``. 225 | * ``create_template_context`` is a function implemented in base class ``TemplateTask``. It 226 | implements the very common case of dictionaries being used as template context objects. Of course 227 | you can override this if it doesn't match your engine. 228 | -------------------------------------------------------------------------------- /multigen/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moltob/pymultigen/3095c7579ab29199cb421b2e70d3088067a450bd/multigen/__init__.py -------------------------------------------------------------------------------- /multigen/formatter.py: -------------------------------------------------------------------------------- 1 | """ 2 | Post-generation code formatters. 3 | 4 | The module provides simple functions taking the raw input string and returning the result string 5 | after application of the corresponding format. The function can be given to the `Generator` during 6 | construction. 7 | """ 8 | 9 | 10 | def format_raw(raw: str) -> str: 11 | return raw 12 | 13 | 14 | def format_autopep8(raw: str) -> str: 15 | import autopep8 16 | return autopep8.fix_code(raw, options={'max_line_length': 100}) 17 | -------------------------------------------------------------------------------- /multigen/generator.py: -------------------------------------------------------------------------------- 1 | """Small framework for multifile generation on top of another template code generator.""" 2 | import logging 3 | import os 4 | 5 | from .formatter import format_raw 6 | 7 | _logger = logging.getLogger(__name__) 8 | 9 | 10 | class Generator: 11 | """ 12 | Code generator from PyEcore models. 13 | 14 | Attributes: 15 | tasks: 16 | List of generator tasks to be processed as part of this generator. 17 | """ 18 | 19 | tasks = [] 20 | 21 | def __init__(self, **kwargs): 22 | if kwargs: 23 | raise AttributeError('Unexpected arguments: {!r}'.format(kwargs)) 24 | 25 | def generate(self, model, outfolder): 26 | """ 27 | Generate artifacts for given model. 28 | 29 | Attributes: 30 | model: 31 | Model for which to generate code. 32 | outfolder: 33 | Folder where code files are created. 34 | """ 35 | _logger.info('Generating code to {!r}.'.format(outfolder)) 36 | 37 | for task in self.tasks: 38 | for element in task.filtered_elements(model): 39 | task.run(element, outfolder) 40 | 41 | 42 | class Task: 43 | """ 44 | File generation task applied to a set of model elements. 45 | 46 | Attributes: 47 | formatter: Callable converting this generator tasks raw output into a nicely formatted 48 | string. 49 | """ 50 | 51 | def __init__(self, formatter=None, **kwargs): 52 | if kwargs: 53 | raise AttributeError('Unexpected arguments: {!r}'.format(kwargs)) 54 | self.formatter = formatter or format_raw 55 | 56 | def run(self, element, outfolder): 57 | """Apply this task to model element.""" 58 | filepath = self.relative_path_for_element(element) 59 | if outfolder and not os.path.isabs(filepath): 60 | filepath = os.path.join(outfolder, filepath) 61 | 62 | _logger.debug('{!r} --> {!r}'.format(element, filepath)) 63 | 64 | self.ensure_folder(filepath) 65 | self.generate_file(element, filepath) 66 | 67 | @staticmethod 68 | def ensure_folder(filepath): 69 | dirname = os.path.dirname(filepath) 70 | if not os.path.isdir(dirname): 71 | os.makedirs(dirname) 72 | 73 | def filtered_elements(self, model): 74 | """Iterator over model elements to execute this task for.""" 75 | raise NotImplementedError() 76 | 77 | def relative_path_for_element(self, element): 78 | """Returns relative file path receiving the generator output for given element.""" 79 | raise NotImplementedError() 80 | 81 | def generate_file(self, element, filepath): 82 | """Actual file generation from model element.""" 83 | raise NotImplementedError() 84 | 85 | 86 | class TemplateGenerator(Generator): 87 | templates_path = 'templates' 88 | 89 | def __init__(self, global_context=None, **kwargs): 90 | super().__init__(**kwargs) 91 | global_context = global_context or self.create_global_context() 92 | 93 | # pass optional global context to tasks: 94 | for task in self.tasks: 95 | task.global_context = global_context 96 | 97 | def create_global_context(self, **kwargs): 98 | """Model-wide code generation context, passed to all templates.""" 99 | context = dict(**kwargs) 100 | return context 101 | 102 | 103 | class TemplateFileTask(Task): 104 | """Task to generate code via a code-generator template. 105 | 106 | Attributes: 107 | template_name: Name of the template to use for this task. 108 | global_context: Template-independent context data, propagated by generator class. 109 | """ 110 | template_name = None 111 | global_context = None 112 | 113 | def create_template_context(self, element, **kwargs): 114 | """Code generation context, specific to template and current element.""" 115 | context = dict(element=element, **kwargs) 116 | if self.global_context: 117 | context.update(**self.global_context) 118 | return context 119 | -------------------------------------------------------------------------------- /multigen/jinja.py: -------------------------------------------------------------------------------- 1 | """Jinja2 support for multifile generation.""" 2 | import jinja2 3 | 4 | from .generator import TemplateGenerator, TemplateFileTask 5 | 6 | 7 | class JinjaGenerator(TemplateGenerator): 8 | """Jinja2 based code generator.""" 9 | 10 | def __init__(self, environment=None, **kwargs): 11 | super().__init__(**kwargs) 12 | environment_ = environment or self.create_environment() 13 | 14 | # pass Jinja environment to tasks: 15 | for task in self.tasks: 16 | task.environment = environment_ 17 | 18 | def create_environment(self, **kwargs): 19 | """ 20 | Return a new Jinja environment. 21 | 22 | Derived classes may override method to pass additional parameters or to change the template 23 | loader type. 24 | """ 25 | return jinja2.Environment( 26 | loader=jinja2.FileSystemLoader(self.templates_path), 27 | **kwargs 28 | ) 29 | 30 | 31 | class JinjaTask(TemplateFileTask): 32 | """ 33 | Base class for Jinja2 based code generator tasks. 34 | 35 | Attributes: 36 | environment: Jinja2 environment, to be set by generator. 37 | """ 38 | 39 | environment = None 40 | 41 | def generate_file(self, element, filepath): 42 | template = self.environment.get_template(self.template_name) 43 | context = self.create_template_context(element=element) 44 | 45 | with open(filepath, 'wt') as file: 46 | file.write(self.formatter(template.render(**context))) 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | from setuptools import setup, find_packages 5 | from setuptools.command.test import test as TestCommand 6 | 7 | if sys.version_info < (3, 3): 8 | sys.exit('Sorry, Python < 3.3 is not supported') 9 | 10 | 11 | class PyTest(TestCommand): 12 | user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")] 13 | 14 | def initialize_options(self): 15 | TestCommand.initialize_options(self) 16 | self.pytest_args = [] 17 | 18 | def finalize_options(self): 19 | TestCommand.finalize_options(self) 20 | self.test_args = [] 21 | self.test_suite = True 22 | 23 | def run_tests(self): 24 | import pytest 25 | args = self.pytest_args if isinstance(self.pytest_args, list) else [self.pytest_args] 26 | errno = pytest.main(args) 27 | sys.exit(errno) 28 | 29 | 30 | setup( 31 | name='pymultigen', 32 | version='0.2.0', 33 | description='Multi-file frontend for single-file code generators.', 34 | long_description=open('README.rst').read(), 35 | keywords='code generator jinja multi-file', 36 | url='https://github.com/moltob/pymultigen', 37 | author='Mike Pagel', 38 | author_email='mike@mpagel.de', 39 | 40 | packages=find_packages(exclude=['tests']), 41 | install_requires=[], 42 | tests_require=['pytest', 'jinja2', 'autopep8'], # optional packages tested 43 | cmdclass={'test': PyTest}, 44 | 45 | license='MIT License', 46 | classifiers=[ 47 | 'Development Status :: 4 - Beta', 48 | 'Programming Language :: Python', 49 | 'Programming Language :: Python :: 3 :: Only', 50 | 'Operating System :: OS Independent', 51 | 'Intended Audience :: Developers', 52 | 'Topic :: Software Development', 53 | 'Topic :: Software Development :: Libraries', 54 | 'License :: OSI Approved :: MIT License', 55 | ] 56 | ) 57 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Common fixtures and configurations for this test directory.""" 2 | import os 3 | import shutil 4 | import sys 5 | 6 | import pytest 7 | 8 | 9 | @pytest.fixture('module') 10 | def cwd_module_dir(): 11 | """Change current directory to this module's folder to access inputs and write outputs.""" 12 | cwd = os.getcwd() 13 | os.chdir(os.path.dirname(__file__)) 14 | yield 15 | os.chdir(cwd) 16 | 17 | 18 | @pytest.fixture(scope='module') 19 | def pygen_output_dir(cwd_module_dir): 20 | """Return an empty output directory, part of syspath to allow importing generated code.""" 21 | path = 'output' 22 | shutil.rmtree(path, ignore_errors=True) 23 | original_sys_path = sys.path 24 | sys.path.append(path) 25 | yield path 26 | sys.path.remove(path) 27 | shutil.rmtree(path, ignore_errors=False) 28 | -------------------------------------------------------------------------------- /tests/input/test.py.tpl: -------------------------------------------------------------------------------- 1 | print('This is a test template for element {{ element.name }}.') -------------------------------------------------------------------------------- /tests/test_formatter.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from multigen.formatter import format_raw, format_autopep8 4 | 5 | 6 | @pytest.fixture 7 | def ugly_code(): 8 | return "import a , b" 9 | 10 | 11 | def test__format_raw(ugly_code): 12 | assert format_raw(ugly_code) is ugly_code 13 | 14 | 15 | def test__format_pep8(ugly_code): 16 | assert format_autopep8(ugly_code) == "import a\nimport b\n" 17 | -------------------------------------------------------------------------------- /tests/test_generator.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from multigen.generator import Generator, Task, TemplateFileTask, TemplateGenerator 7 | 8 | 9 | def test__generator__generate__no_tasks(): 10 | # calling empty generator not raising anything: 11 | Generator().generate(mock.sentinel.MODEL, mock.sentinel.OUTFOLDER) 12 | 13 | 14 | def test__generator__generate__tasks(): 15 | mock_task1 = mock.MagicMock() 16 | mock_task2 = mock.MagicMock() 17 | 18 | elements = mock.sentinel.ELEM1, mock.sentinel.ELEM2 19 | mock_task1.filtered_elements = mock.Mock(return_value=iter(elements)) 20 | 21 | # no matching elements for this task: 22 | mock_task2.filtered_elements = mock.Mock(return_value=iter(tuple())) 23 | 24 | mock_manager = mock.MagicMock() 25 | mock_manager.attach_mock(mock_task1, 'task1') 26 | mock_manager.attach_mock(mock_task2, 'task2') 27 | 28 | generator = Generator() 29 | generator.tasks = (mock_task1, mock_task2) 30 | generator.generate(mock.sentinel.MODEL, mock.sentinel.FOLDERPATH) 31 | 32 | assert mock_manager.mock_calls == [ 33 | mock.call.task1.filtered_elements(mock.sentinel.MODEL), 34 | mock.call.task1.run(mock.sentinel.ELEM1, mock.sentinel.FOLDERPATH), 35 | mock.call.task1.run(mock.sentinel.ELEM2, mock.sentinel.FOLDERPATH), 36 | mock.call.task2.filtered_elements(mock.sentinel.MODEL), 37 | ] 38 | 39 | 40 | class MyTemplateGenerator(TemplateGenerator): 41 | tasks = [ 42 | mock.MagicMock() 43 | ] 44 | 45 | 46 | def test__template_generator__global_context_passed_to_tasks(): 47 | generator = MyTemplateGenerator(global_context=mock.sentinel.GLOBAL_CONTEXT) 48 | assert generator.tasks[0].global_context is mock.sentinel.GLOBAL_CONTEXT 49 | 50 | 51 | @mock.patch.object(TemplateGenerator, 'create_global_context', 52 | side_effect=lambda **kwargs: kwargs) 53 | def test__template_generator__global_context_constructed(mock_create_global_context): 54 | MyTemplateGenerator() 55 | assert mock_create_global_context.call_count == 1 56 | 57 | 58 | @mock.patch.object(Task, 'ensure_folder') 59 | @mock.patch.object(Task, 'relative_path_for_element', return_value='file.ext') 60 | @mock.patch.object(Task, 'generate_file') 61 | def test__task__run(mock_generate_file, mock_relative_path_for_element, mock_ensure_folder): 62 | task = Task() 63 | task.run(mock.sentinel.ELEMENT, 'somefolder') 64 | 65 | outfile = os.path.join('somefolder', 'file.ext') 66 | mock_relative_path_for_element.assert_called_once_with(mock.sentinel.ELEMENT) 67 | mock_ensure_folder.assert_called_once_with(outfile) 68 | mock_generate_file.assert_called_once_with(mock.sentinel.ELEMENT, outfile) 69 | 70 | 71 | @mock.patch.object(Task, 'ensure_folder') 72 | @mock.patch.object(Task, 'relative_path_for_element', return_value=os.path.abspath('file.ext')) 73 | @mock.patch.object(Task, 'generate_file') 74 | def test__task__run_abspath(mock_generate_file, mock_relative_path_for_element, mock_ensure_folder): 75 | task = Task() 76 | task.run(mock.sentinel.ELEMENT, 'somefolder') 77 | 78 | # no concat with with outfolder: 79 | outfile = os.path.abspath('file.ext') 80 | mock_ensure_folder.assert_called_once_with(outfile) 81 | 82 | 83 | def test__template_task__create_context(): 84 | task = TemplateFileTask() 85 | task.global_context = dict(global_key=mock.sentinel.GLOBAL_VALUE) 86 | context = task.create_template_context( 87 | element=mock.sentinel.ELEMENT, 88 | test_key=mock.sentinel.TEST_VALUE 89 | ) 90 | 91 | assert context['element'] is mock.sentinel.ELEMENT 92 | assert context['test_key'] is mock.sentinel.TEST_VALUE 93 | assert context['global_key'] is mock.sentinel.GLOBAL_VALUE 94 | 95 | 96 | @pytest.mark.parametrize("factory", [Generator, Task]) 97 | def test__unexpected_argument(factory): 98 | with pytest.raises(AttributeError) as ex: 99 | factory(unexpected=42) 100 | assert 'unexpected' in ex.message 101 | -------------------------------------------------------------------------------- /tests/test_jinja.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import mock 3 | 4 | from multigen.jinja import JinjaTask, JinjaGenerator 5 | 6 | 7 | class MyTemplateTask(JinjaTask): 8 | template_name = 'test.py.tpl' 9 | 10 | def filtered_elements(self, model): 11 | # apply to all elements in "model": 12 | return model.elements 13 | 14 | def relative_path_for_element(self, element): 15 | return '{}.py'.format(element.name) 16 | 17 | 18 | class MyGenerator(JinjaGenerator): 19 | templates_path = 'input' 20 | 21 | tasks = [ 22 | MyTemplateTask() 23 | ] 24 | 25 | 26 | class MyModel: 27 | def __init__(self, elements): 28 | self.elements = elements 29 | 30 | 31 | class MyElement: 32 | def __init__(self, name): 33 | self.name = name 34 | 35 | 36 | def test__jinja_generator__integration(pygen_output_dir): 37 | model = MyModel([ 38 | MyElement('A'), 39 | MyElement('B'), 40 | ]) 41 | 42 | generator = MyGenerator() 43 | generator.generate(model, pygen_output_dir) 44 | 45 | with open(os.path.join(pygen_output_dir, 'A.py')) as file: 46 | generated_text = file.read() 47 | assert generated_text == 'print(\'This is a test template for element A.\')' 48 | 49 | with open(os.path.join(pygen_output_dir, 'B.py')) as file: 50 | generated_text = file.read() 51 | assert generated_text == 'print(\'This is a test template for element B.\')' 52 | 53 | 54 | @mock.patch.object(JinjaTask, 'create_template_context', side_effect=lambda **kwargs: kwargs) 55 | def test__jinja_task__generate_file(mock_create_template_context): 56 | mock_template = mock.MagicMock() 57 | mock_template.render = mock.MagicMock(return_value='rendered text') 58 | mock_environment = mock.MagicMock() 59 | mock_environment.get_template = mock.MagicMock(return_value=mock_template) 60 | 61 | task = JinjaTask() 62 | task.environment = mock_environment 63 | task.template_name = mock.sentinel.TEMPLATE_NAME 64 | 65 | mock_open = mock.mock_open() 66 | with mock.patch('multigen.jinja.open', mock_open, create=True): 67 | task.generate_file(mock.sentinel.ELEMENT, 'filepath.ext') 68 | 69 | mock_template.render.assert_called_once_with(element=mock.sentinel.ELEMENT) 70 | mock_open.assert_called_once_with('filepath.ext', 'wt') 71 | mock_open().write.assert_called_once_with('rendered text') 72 | --------------------------------------------------------------------------------