Source code for app_enabler.patcher

import ast
import os  # noqa - used when eval'ing the management command
import sys
from types import CodeType
from typing import Any, Dict, Iterable, List, Optional, Union

import astor

from .errors import messages


[docs] def setup_django(): """ Initialize the django environment by leveraging ``manage.py``. This works by using ``manage.py`` to set the ``DJANGO_SETTINGS_MODULE`` environment variable for :py:func:`django.setup() <django:django.setup>` to work as it's unknown at runtime. This should be safer than reading the ``manage.py`` looking for the written variable as it rely on Django runtime behavior. Manage.py is monkeypatched in memory to remove the call "execute_from_command_line" and executed from memory. """ import django try: managed_command = monkeypatch_manage("manage.py") eval(managed_command) django.setup() except FileNotFoundError: sys.stderr.write(messages["no_managepy"]) sys.exit(1)
[docs] def monkeypatch_manage(manage_file: str) -> CodeType: """ Patch ``manage.py`` to be executable without actually running any command. By using ast we remove the ``execute_from_command_line`` call and add an unconditional call to the main function. :param str manage_file: path to manage.py file :return: patched manage.py code """ parsed = astor.parse_file(manage_file) # first patch run replace __name__ != '__main__' with a function call modified = DisableExecute().visit(parsed) # patching the module with the call to the main function as the standard one is not executed because # __name__ != '__main__' modified.body.append(ast.Expr(value=ast.Call(func=ast.Name(id="main", ctx=ast.Load()), args=[], keywords=[]))) fixed = ast.fix_missing_locations(modified) return compile(fixed, "<string>", mode="exec")
[docs] class DisableExecute(ast.NodeTransformer): """ Patch the ``manage.py`` module to remove the execute_from_command_line execution. """
[docs] def visit_Expr(self, node: ast.AST) -> Any: # noqa """Visit the ``Expr`` node and remove it if it matches ``'execute_from_command_line'``.""" if ( isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Name) # noqa and node.value.func.id == "execute_from_command_line" # noqa ): return None else: return node
def _ast_get_constant_value(ast_obj: Union[ast.Constant, ast.Str, ast.Num]) -> Any: """ Extract the value from an ast.Constant / ast.Str / ast.Num obj. Required as in python 3.6 / 3.7 ast.Str / ast.Num are not subclasses of ast.Constant """ try: return ast_obj.value except AttributeError: return ast_obj.s def _ast_dict_key_index(dict_object: ast.Dict, lookup_key: str) -> Optional[int]: """Get the index of the lookup key in the ast Dict object.""" try: return [_ast_get_constant_value(dict_key) for dict_key in dict_object.keys].index(lookup_key) except ValueError: return None def _ast_dict_lookup(dict_object: ast.Dict, lookup_key: str) -> Optional[Any]: """Get the value of the lookup key in the ast Dict object.""" key_position = _ast_dict_key_index(dict_object, lookup_key) if key_position is None: return None return _ast_get_constant_value(dict_object.values[key_position]) def _ast_get_object_from_value(val: Any) -> ast.Constant: """Convert value to AST via :py:func:`ast.parse`.""" return ast.parse(repr(val)).body[0].value def _update_list_setting(original_setting: List, configuration: Iterable): for config_value in configuration: # configuration items can be either strings (which are appended) or dictionaries which contains information # about the position of the item if isinstance(config_value, dict): value = config_value.get("value", None) position = config_value.get("position", None) relative_item = config_value.get("next", None) key = config_value.get("key", None) if relative_item: # if the item is already existing, we skip its insertion position = None if key: # if the match is against a key we must both flatted the original setting to a list of literals # extracting the key value and getting the key value for the setting we want to add flattened_data = [_ast_dict_lookup(item, key) for item in original_setting] check_value = value.get(key, None) else: flattened_data = [_ast_get_constant_value(item) for item in original_setting] check_value = value if any(flattened_data) and check_value not in flattened_data: try: position = flattened_data.index(relative_item) except ValueError: # in case the relative item is not found we add the value on top position = 0 if position is not None: original_setting.insert(position, _ast_get_object_from_value(value)) else: if config_value not in [_ast_get_constant_value(item) for item in original_setting]: original_setting.append(_ast_get_object_from_value(config_value))
[docs] def update_setting(project_setting: str, config: Dict[str, Any]): """ Patch the settings module to include addon settings. Original file is overwritten. As file is patched using AST, original comments and file structure is lost. :param str project_setting: project settings file path :param dict config: addon setting parameters """ parsed = astor.parse_file(project_setting) existing_setting = [] addon_settings = config.get("settings", {}) addon_installed_apps = config.get("installed-apps", []) constant_subclasses = (ast.Constant, ast.Num, ast.Str, ast.Bytes, ast.NameConstant, ast.Ellipsis) for node in parsed.body: if isinstance(node, ast.Assign) and node.targets[0].id == "INSTALLED_APPS": _update_list_setting(node.value.elts, addon_installed_apps) elif isinstance(node, ast.Assign) and node.targets[0].id in addon_settings.keys(): # noqa config_param = addon_settings[node.targets[0].id] if isinstance(node.value, ast.List) and ( isinstance(config_param, list) or isinstance(config_param, tuple) ): _update_list_setting(node.value.elts, config_param) elif isinstance(node.value, ast.Dict): for dict_key, dict_value in config_param.items(): ast_position = _ast_dict_key_index(node.value, dict_key) if ast_position is None: node.value.keys.append(_ast_get_object_from_value(dict_key)) node.value.values.append(_ast_get_object_from_value(dict_value)) else: node.value.values[ast_position] = _ast_get_object_from_value(dict_value) pass elif type(node.value) in constant_subclasses: # check required as in python 3.6 / 3.7 ast.Str / ast.Num are not subclasses of ast.Constant node.value = _ast_get_object_from_value(config_param) existing_setting.append(node.targets[0].id) for name, value in addon_settings.items(): if name not in existing_setting: parsed.body.append(ast.Assign(targets=[ast.Name(id=name)], value=_ast_get_object_from_value(value))) src = astor.to_source(parsed) with open(project_setting, "w") as fp: fp.write(src)
[docs] def update_urlconf(project_urls: str, config: Dict[str, Any]): """ Patch the ``ROOT_URLCONF`` module to include addon url patterns. Original file is overwritten. As file is patched using AST, original comments and file structure is lost. :param str project_urls: project urls.py file path :param dict config: addon urlconf configuration """ parsed = astor.parse_file(project_urls) addon_urls = config.get("urls", []) for node in parsed.body: if isinstance(node, ast.ImportFrom) and node.module == "django.urls": existing_names = [alias.name for alias in node.names] if "include" not in existing_names: node.names.append(ast.alias(name="include", asname=None)) elif isinstance(node, ast.Assign) and node.targets[0].id == "urlpatterns": existing_urlconf = [] for url_line in node.value.elts: # the following list comprehension matches path() / url() instances in urlpatterns # using the `include()` statement as argument. ie. # - matched: path('', include('cms.urls') # - not matched: path('sitemap.xml', sitemap, {}) # we look for ast.Call (outer loop) wrapping ast.Str (inner loop), # and we assume all is wrapped in ast.Call (as we cycle on url_line.args) urlconf_path = [ subarg.s for stmt in url_line.args if isinstance(stmt, ast.Call) for subarg in stmt.args if isinstance(subarg, ast.Str) ] if urlconf_path: existing_urlconf.extend(urlconf_path) for pattern, urlconf in addon_urls: if urlconf not in existing_urlconf: part = ast.parse(f"path('{pattern}', include('{urlconf}'))") node.value.elts.append(part.body[0].value) src = astor.to_source(parsed) with open(project_urls, "w") as fp: fp.write(src)