├── changes ├── .directory └── 103.bugfix ├── tests ├── __init__.py ├── sample │ ├── test_project │ │ ├── __init__.py │ │ ├── wsgi.py │ │ ├── urls.py │ │ └── settings.py │ ├── config │ │ ├── 2.json │ │ └── 1.json │ └── manage.py ├── utils.py ├── test_django.py ├── test_install.py ├── conftest.py ├── test_patcher.py ├── test_enable.py └── test_cli.py ├── requirements.txt ├── docs ├── history.rst ├── readme.rst ├── todo.rst ├── api │ └── index.rst ├── index.rst ├── limitations.rst ├── usage.rst ├── addon_configuration.rst ├── Makefile └── conf.py ├── setup.py ├── app_enabler ├── __main__.py ├── __init__.py ├── errors.py ├── django.py ├── install.py ├── cli.py ├── enable.py └── patcher.py ├── requirements-test.txt ├── .pyup.yml ├── .codeclimate.yml ├── AUTHORS.rst ├── MANIFEST.in ├── .github ├── workflows │ ├── logger.yml │ ├── publish.yml │ ├── lint.yml │ ├── test.yml │ └── codeql-analysis.yml ├── pull_request_template.md └── ISSUE_TEMPLATE │ ├── ---feature-request.md │ └── ---bug-report.md ├── CONTRIBUTING.rst ├── .readthedocs.yml ├── .editorconfig ├── .coveragerc ├── setup.cfg ├── LICENSE ├── .pre-commit-config.yaml ├── HISTORY.rst ├── pyproject.toml ├── tox.ini ├── README.rst ├── tasks.py └── .gitignore /changes/.directory: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | . 2 | -------------------------------------------------------------------------------- /tests/sample/test_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup() 4 | -------------------------------------------------------------------------------- /changes/103.bugfix: -------------------------------------------------------------------------------- 1 | Add missing dependencies for CI tests 2 | -------------------------------------------------------------------------------- /app_enabler/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli 2 | 3 | cli() # pragma: no cover 4 | -------------------------------------------------------------------------------- /app_enabler/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.5.0" 2 | __author__ = "Iacopo Spalletti " 3 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | coverage 3 | coveralls>=2.0 4 | django-app-helper>=2.0.0 5 | pytest 6 | pytest-cov 7 | -------------------------------------------------------------------------------- /tests/sample/config/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "MY_SETTING_2": "some_value" 4 | }, 5 | "message": "json2" 6 | } 7 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | update: all 2 | pin: False 3 | branch: 4 | schedule: "every day" 5 | search: True 6 | branch_prefix: pyup/ 7 | close_prs: True 8 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | languages: 2 | Ruby: false 3 | JavaScript: false 4 | PHP: false 5 | Python: true 6 | exclude_paths: 7 | - 'tests/*' 8 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Iacopo Spalletti 9 | 10 | Contributors 11 | ------------ 12 | 13 | * Leonardo Cavallucci 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include README.rst 5 | include LICENSE 6 | recursive-include app_enabler *.html *.png *.gif *js *jpg *jpeg *svg *py *css *po *mo *html *eot *svg *ttf *woff *swf 7 | -------------------------------------------------------------------------------- /.github/workflows/logger.yml: -------------------------------------------------------------------------------- 1 | name: Event Logger 2 | on: push 3 | 4 | jobs: 5 | log-github-event-goodies: 6 | name: "LOG Everything on GitHub Event" 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Logging 10 | run: | 11 | echo '${{toJSON(github.event)}}' 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ############ 2 | Contributing 3 | ############ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | Please read the instructions `here `_ to start contributing to `django-app-enabler`. 9 | -------------------------------------------------------------------------------- /app_enabler/errors.py: -------------------------------------------------------------------------------- 1 | messages = { 2 | "no_managepy": "app-enabler must be executed in the same directory as the project manage.py file", 3 | "install_error": "Package {package} not installable in the current virtualenv", 4 | "enable_error": "Package {package} not installed in the current virtualenv", 5 | "verify_error": "Error verifying {package} configuration", 6 | } 7 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.10" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | fail_on_warning: false 11 | 12 | formats: 13 | - epub 14 | - pdf 15 | 16 | python: 17 | install: 18 | - requirements: requirements-test.txt 19 | - method: pip 20 | path: . 21 | extra_requirements: 22 | - docs 23 | -------------------------------------------------------------------------------- /tests/sample/config/1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "installed-apps": [ 4 | "taggit" 5 | ], 6 | "settings": { 7 | "MY_SETTING_A": "some_value" 8 | }, 9 | "urls": [ 10 | [ 11 | "", 12 | "djangocms_blog.taggit_urls" 13 | ] 14 | ], 15 | "message": "json1-a" 16 | }, 17 | { 18 | "installed-apps": [ 19 | ], 20 | "settings": { 21 | "MY_SETTING_B": "some_value" 22 | }, 23 | "message": "json1-b" 24 | } 25 | ] 26 | -------------------------------------------------------------------------------- /tests/sample/test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /docs/todo.rst: -------------------------------------------------------------------------------- 1 | .. _todo: 2 | 3 | ################ 4 | Planned features 5 | ################ 6 | 7 | * Support extra-requirements `issue-6`_ 8 | * Support Django settings split in multiple files `issue-7`_ 9 | * Support Django urlconf split in multiple files `issue-8`_ 10 | 11 | 12 | 13 | 14 | .. _issue-6: https://github.com/nephila/django-app-enabler/issues/6 15 | .. _issue-7: https://github.com/nephila/django-app-enabler/issues/7 16 | .. _issue-8: https://github.com/nephila/django-app-enabler/issues/8 17 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Describe: 4 | 5 | * Content of the pull request 6 | * Feature added / Problem fixed 7 | 8 | ## References 9 | 10 | Provide any github issue fixed (as in ``Fix #XYZ``) 11 | 12 | # Checklist 13 | 14 | * [ ] I have read the [contribution guide](https://django-app-enabler.readthedocs.io/en/latest/contributing.html) 15 | * [ ] Code lint checked via `inv lint` 16 | * [ ] ``changes`` file included (see [docs](https://django-app-enabler.readthedocs.io/en/latest/contributing.html#pull-request-guidelines)) 17 | * [ ] Usage documentation added in case of new features 18 | * [ ] Tests added 19 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | ### 4 | API 5 | ### 6 | 7 | ******** 8 | Commands 9 | ******** 10 | 11 | .. click:: app_enabler.cli:cli 12 | :prog: django-enabler 13 | :show-nested: 14 | 15 | ******* 16 | CLI 17 | ******* 18 | 19 | .. automodule:: app_enabler.cli 20 | :members: 21 | 22 | .. automodule:: app_enabler.enable 23 | :members: 24 | 25 | .. automodule:: app_enabler.install 26 | :members: 27 | 28 | ******* 29 | Loaders 30 | ******* 31 | 32 | .. automodule:: app_enabler.django 33 | :members: 34 | 35 | ******** 36 | Patchers 37 | ******** 38 | 39 | .. automodule:: app_enabler.patcher 40 | :members: 41 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | max_line_length = 120 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | 16 | [*.rst] 17 | max_line_length = 120 18 | 19 | [*.py] 20 | max_line_length = 120 21 | 22 | [*.{scss,html}] 23 | indent_size = 2 24 | indent_style = space 25 | max_line_length = 120 26 | 27 | [*.{js,vue,json}] 28 | indent_size = 2 29 | max_line_length = 120 30 | 31 | [*.{yml,yaml}] 32 | indent_size = 2 33 | 34 | [Makefile] 35 | indent_style = tab 36 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. App Enabler documentation master file. 2 | You can adapt this file completely to your liking, but it should at least 3 | contain the root `toctree` directive. 4 | 5 | ####################################################### 6 | Welcome to App Enabler's documentation! 7 | ####################################################### 8 | 9 | .. toctree:: 10 | :maxdepth: 3 11 | 12 | readme 13 | usage 14 | limitations 15 | addon_configuration 16 | todo 17 | api/index 18 | history 19 | 20 | 21 | ================== 22 | Indices and tables 23 | ================== 24 | 25 | * :ref:`genindex` 26 | * :ref:`modindex` 27 | * :ref:`search` 28 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = app_enabler 4 | 5 | [report] 6 | omit = *migrations*,*tests*,*test_utils* 7 | # Regexes for lines to exclude from consideration 8 | exclude_lines = 9 | # Have to re-enable the standard pragma 10 | pragma: no cover 11 | 12 | # Don't complain about missing debug-only code: 13 | def __repr__ 14 | if self\.debug 15 | 16 | # Don't complain if tests don't hit defensive assertion code: 17 | raise AssertionError 18 | raise NotImplementedError 19 | 20 | # Don't complain if non-runnable code isn't run: 21 | if 0: 22 | if __name__ == .__main__.: 23 | 24 | ignore_errors = True 25 | 26 | [html] 27 | directory = coverage_html 28 | -------------------------------------------------------------------------------- /tests/sample/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F389 Feature request" 3 | about: Share your idea, let's discuss it! 4 | title: '' 5 | labels: 'type: feature' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 13 | 14 | ## Description 15 | 16 | 19 | 20 | ## Use cases 21 | 22 | 25 | 26 | ## Proposed solution 27 | 28 | 31 | 32 | ## Alternatives 33 | 34 | 37 | 38 | ## Additional information 39 | 40 | 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Bug report" 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'type: bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 13 | 14 | ## Description 15 | 16 | 19 | 20 | ## Steps to reproduce 21 | 22 | 25 | 26 | ## Versions 27 | 28 | 31 | 32 | ## Expected behaviour 33 | 34 | 37 | 38 | ## Actual behaviour 39 | 40 | 43 | 44 | ## Additional information 45 | 46 | 49 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [published,prereleased] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Set up Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: '3.x' 16 | - name: Cache pip 17 | uses: actions/cache@v3 18 | with: 19 | path: ~/.cache/pip 20 | key: ${{ runner.os }}-pip-${{ matrix.toxenv }} 21 | restore-keys: | 22 | ${{ runner.os }}-pip-${{ matrix.toxenv }} 23 | - name: Cache tox 24 | uses: actions/cache@v3 25 | with: 26 | path: .tox 27 | key: ${{ runner.os }}-tox-release-${{ hashFiles('setup.cfg') }} 28 | restore-keys: | 29 | ${{ runner.os }}-tox-release- 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip setuptools tox>=1.8 33 | - name: Build and publish 34 | env: 35 | TWINE_USERNAME: __token__ 36 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 37 | run: | 38 | tox -erelease 39 | -------------------------------------------------------------------------------- /tests/sample/test_project/urls.py: -------------------------------------------------------------------------------- 1 | """test_project URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | 17 | from django.conf.urls.i18n import i18n_patterns 18 | from django.contrib import admin 19 | from django.contrib.sitemaps.views import sitemap 20 | from django.urls import path 21 | from django.views import View 22 | 23 | urlpatterns = [ 24 | path("sitemap.xml", sitemap, {"sitemaps": {}}), 25 | path("admin/", admin.site.urls), 26 | ] 27 | 28 | urlpatterns += [ 29 | path("something/", View.as_view()), 30 | ] 31 | 32 | urlpatterns += i18n_patterns( 33 | path("something/", View.as_view()), 34 | ) 35 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | import sys 4 | from pathlib import Path 5 | 6 | 7 | @contextlib.contextmanager 8 | def working_directory(path: Path): 9 | """Change working directory and returns to previous on exit.""" 10 | prev_cwd = Path.cwd() 11 | os.chdir(str(path)) 12 | sys.path.insert(0, str(path)) 13 | try: 14 | yield 15 | finally: 16 | os.chdir(prev_cwd) 17 | sys.path.remove(str(path)) 18 | 19 | 20 | def get_project_dir() -> Path: 21 | """ 22 | Sample project directory 23 | 24 | :return str: sample project directory 25 | """ 26 | return Path(__file__).parent / "sample" 27 | 28 | 29 | def unload_django(): 30 | """Tear down django initialization by unloding modules and resetting apps state.""" 31 | project_modules = [ 32 | module_name 33 | for module_name in sys.modules.keys() 34 | if module_name.startswith("django") or module_name.startswith("test_project") 35 | ] 36 | for module in project_modules: 37 | if module in sys.modules: 38 | del sys.modules[module] 39 | 40 | from django.apps import apps 41 | 42 | apps.clear_cache() 43 | apps.ready = False 44 | apps.models_ready = False 45 | apps.apps_ready = False 46 | apps.loading = False 47 | apps.app_configs = {} 48 | -------------------------------------------------------------------------------- /docs/limitations.rst: -------------------------------------------------------------------------------- 1 | .. _limitations: 2 | 3 | 4 | ############### 5 | Limitations 6 | ############### 7 | 8 | Paching features have currently the following limitations: 9 | 10 | ************************* 11 | settings.py 12 | ************************* 13 | 14 | * Only single file ``settings.py`` are currently supported. 15 | In case you are using splitted settings, the only way to use ``django-app-enabler`` is to have at least an empty 16 | ``INSTALLED_APPS`` list in the settings file declared in ``DJANGO_SETTINGS_MODULE``. 17 | * Settings with literal or "simple" lists and dictionaries (like ``CACHE``, ``DATABASES``, ``AUTH_PASSWORD_VALIDATORS``) are supported, the most notable exception is ``TEMPLATES`` in which you cannot add / replace options in a single template engine. Any custom setting is supported. 18 | * While extra requirements will be installed when including them in the package argument (as in ``djangocms-blog[search]``), 19 | they will not be added to ``INSTALLED_APPS`` and they must be added manually after command execution. 20 | 21 | 22 | ************************* 23 | urls.py 24 | ************************* 25 | 26 | * Only single file ``urls.py`` are currently supported. 27 | In case you are using splitted settings, the only way to use ``django-app-enabler`` is to have at least an empty 28 | ``urlpatterns`` list in the ``settings.ROOT_URLCONF`` file. 29 | -------------------------------------------------------------------------------- /tests/test_django.py: -------------------------------------------------------------------------------- 1 | from app_enabler.django import get_settings_path, get_urlconf_path, load_addon 2 | from tests.utils import working_directory 3 | 4 | 5 | def test_load_addon(blog_package): 6 | """addon.json file is loaded from the package name.""" 7 | addon_config = load_addon("djangocms_blog") 8 | assert addon_config["package-name"] == "djangocms-blog" 9 | assert addon_config["installed-apps"] 10 | 11 | 12 | def test_load_addon_no_application(blog_package): 13 | """addon.json file is not loaded from a non existing package.""" 14 | assert load_addon("djangocms_blog2") is None 15 | 16 | 17 | def test_get_settings_path(django_setup, project_dir): 18 | """Settings file path can is retrieved from settings in memory module.""" 19 | from django.conf import settings 20 | 21 | with working_directory(project_dir): 22 | expected = project_dir / "test_project" / "settings.py" 23 | settings_file = get_settings_path(settings) 24 | assert str(settings_file) == str(expected) 25 | 26 | 27 | def test_get_urlconf_path(django_setup, project_dir): 28 | """Project urlconf file path is retrieved from settings in memory module.""" 29 | from django.conf import settings 30 | 31 | with working_directory(project_dir): 32 | expected = project_dir / "test_project" / "urls.py" 33 | urlconf_file = get_urlconf_path(settings) 34 | assert str(urlconf_file) == str(expected) 35 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-app-enabler 3 | version = attr: app_enabler.__version__ 4 | url = https://github.com/nephila/django-app-enabler 5 | project_urls = 6 | Documentation = https://django-app-enabler.readthedocs.io/ 7 | author = Nephila 8 | author_email = info@nephila.it 9 | description = Autoconfigurator for django applications 10 | long_description = file: README.rst, HISTORY.rst 11 | long_description_content_type = text/x-rst 12 | license = BSD 13 | license_files = LICENSE 14 | classifiers = 15 | Development Status :: 4 - Beta 16 | Framework :: Django 17 | Intended Audience :: Developers 18 | Natural Language :: English 19 | Framework :: Django 20 | Framework :: Django :: 4.2 21 | Programming Language :: Python :: 3 22 | Programming Language :: Python :: 3.10 23 | Programming Language :: Python :: 3.11 24 | 25 | [options] 26 | include_package_data = True 27 | install_requires = 28 | astor 29 | click<8.2.0 30 | setup_requires = 31 | setuptools 32 | packages = app_enabler 33 | python_requires = >=3.10 34 | test_suite = pytest 35 | zip_safe = False 36 | 37 | [options.package_data] 38 | * = *.txt, *.rst 39 | app_enabler = *.html *.png *.gif *js *jpg *jpeg *svg *py *mo *po 40 | 41 | [options.extras_require] 42 | docs = 43 | django<5.0 44 | sphinx-click 45 | sphinx-rtd-theme 46 | 47 | [options.entry_points] 48 | console_scripts = 49 | django-enabler = app_enabler.__main__:execute 50 | 51 | [sdist] 52 | formats = zip 53 | 54 | [bdist_wheel] 55 | universal = 1 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Iacopo Spalletti 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | * 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. 9 | 10 | * Neither the name of djangocms-blog nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | 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. 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | exclude: "(.idea|node_modules|.tox)" 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v6.0.0 7 | hooks: 8 | - id: trailing-whitespace 9 | exclude: "setup.cfg" 10 | - id: end-of-file-fixer 11 | - id: check-yaml 12 | - id: check-added-large-files 13 | - id: check-builtin-literals 14 | - id: check-executables-have-shebangs 15 | - id: check-merge-conflict 16 | - id: check-toml 17 | - repo: https://github.com/PyCQA/isort 18 | rev: "7.0.0" 19 | hooks: 20 | - id: isort 21 | - repo: https://github.com/psf/black-pre-commit-mirror 22 | rev: 25.12.0 23 | hooks: 24 | - id: black 25 | - repo: https://github.com/astral-sh/ruff-pre-commit 26 | rev: 'v0.14.9' 27 | hooks: 28 | - id: ruff 29 | - repo: https://github.com/asottile/pyupgrade 30 | rev: v3.21.2 31 | hooks: 32 | - id: pyupgrade 33 | args: 34 | - --py3-plus 35 | - repo: https://github.com/adamchainz/django-upgrade 36 | rev: "1.29.1" 37 | hooks: 38 | - id: django-upgrade 39 | args: [--target-version, "3.2"] 40 | - repo: local 41 | hooks: 42 | - id: towncrier 43 | name: towncrier 44 | entry: inv towncrier-check 45 | language: system 46 | pass_filenames: false 47 | always_run: true 48 | ci: 49 | skip: 50 | - towncrier 51 | -------------------------------------------------------------------------------- /app_enabler/django.py: -------------------------------------------------------------------------------- 1 | import json 2 | from importlib import import_module 3 | from typing import Any, Dict, Optional 4 | 5 | import django.conf 6 | from pkg_resources import resource_stream 7 | 8 | 9 | def load_addon(module_name: str) -> Optional[Dict[str, Any]]: 10 | """ 11 | Load addon configuration from json file stored in package resources. 12 | 13 | If addon has no configuration, return ``None``. 14 | 15 | :param str module_name: name of the python module to load as application 16 | :return: addon configuration 17 | """ 18 | try: 19 | with resource_stream(module_name, "addon.json") as fp: 20 | data = json.load(fp) 21 | return data 22 | except Exception: 23 | pass 24 | 25 | 26 | def get_settings_path(setting: "django.conf.LazySettings") -> str: 27 | """ 28 | Get the path of the django settings file path from the django settings object. 29 | 30 | :param django.conf.LazySettings setting: Django settings object 31 | :return: path to the settings file 32 | """ 33 | settings_module = import_module(setting.SETTINGS_MODULE) 34 | return settings_module.__file__ 35 | 36 | 37 | def get_urlconf_path(setting: "django.conf.LazySettings") -> str: 38 | """ 39 | Get the path of the django urlconf file path from the django settings object. 40 | 41 | :param django.conf.LazySettings setting: Django settings object 42 | :return: path to the settings file 43 | """ 44 | urlconf_module = import_module(setting.ROOT_URLCONF) 45 | return urlconf_module.__file__ 46 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Code quality 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | if: "!contains(github.event.head_commit.message, '[skip ci]')" 9 | strategy: 10 | matrix: 11 | python-version: ["3.11.x"] 12 | toxenv: [ruff, isort, black, pypi-description, docs, towncrier] 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | repository: ${{ github.event.pull_request.head.repo.full_name }} 17 | ref: ${{ github.event.pull_request.head.ref }} 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Cache pip 23 | uses: actions/cache@v3 24 | with: 25 | path: ~/.cache/pip 26 | key: ${{ runner.os }}-pip-${{ matrix.toxenv }} 27 | restore-keys: | 28 | ${{ runner.os }}-pip-${{ matrix.toxenv }} 29 | - name: Cache tox 30 | uses: actions/cache@v3 31 | with: 32 | path: .tox 33 | key: ${{ runner.os }}-lint-${{ matrix.toxenv }}-${{ hashFiles('setup.cfg') }} 34 | restore-keys: | 35 | ${{ runner.os }}-lint-${{ matrix.toxenv }}- 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip setuptools tox>4 39 | - name: Test with tox 40 | if: ${{ matrix.toxenv != 'towncrier' || (!contains(github.event.head_commit.message, '[pre-commit.ci]') && !contains(github.event.pull_request.body, 'pre-commit.ci start')) }} 41 | run: | 42 | tox -e${{ matrix.toxenv }} 43 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | ******* 4 | History 5 | ******* 6 | 7 | .. towncrier release notes start 8 | 9 | 0.5.0 (2025-06-03) 10 | ================== 11 | 12 | Bugfixes 13 | -------- 14 | 15 | - Pin click version to < 8.2.0 (#84) 16 | 17 | 18 | 0.4.0 (2025-01-17) 19 | ================== 20 | 21 | Bugfixes 22 | -------- 23 | 24 | - Fix docs build, drop support for Django < 4.2 and python < 3.10 (#64) 25 | 26 | 27 | 0.3.0 (2023-11-09) 28 | ================== 29 | 30 | Features 31 | -------- 32 | 33 | - Improve merge strategy to support all the basic standard Django settings (#5) 34 | - Add support for external configuration json (#9) 35 | - Upgrade to Django 3.2/4.2 (#32) 36 | - Switch to Coveralls Github action (#56) 37 | - Migrate to bump-my-version (#58) 38 | 39 | 40 | 0.2.0 (2020-12-27) 41 | ================== 42 | 43 | Features 44 | -------- 45 | 46 | - Add CLI utility (#20) 47 | 48 | 49 | Bugfixes 50 | -------- 51 | 52 | - Close resource_stream file pointer (#19) 53 | - Fix importing include multiple times in urlconf (#21) 54 | - Add test to verify no multiple urlconf are added (#25) 55 | 56 | 57 | 0.1.1 (2020-12-21) 58 | ================== 59 | 60 | Features 61 | -------- 62 | 63 | - Add codeql action (#15) 64 | 65 | 66 | Bugfixes 67 | -------- 68 | 69 | - Fix errors with urlconf patching (#17) 70 | 71 | 72 | 0.1.0 (2020-12-20) 73 | ================== 74 | 75 | Initial release 76 | 77 | Features 78 | -------- 79 | 80 | - Add install command (#1) 81 | - Add tests (#2) 82 | - Add support for message addon config parameter (#11) 83 | 84 | 85 | Improved Documentation 86 | ---------------------- 87 | 88 | - Improve documentation (#1) 89 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=40.6.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | line-length = 119 7 | target-version = ["py310"] 8 | include = 'app_enabler/*py' 9 | 10 | [tool.towncrier] 11 | package = "app_enabler" 12 | directory = "changes" 13 | filename = "HISTORY.rst" 14 | title_format = "{version} ({project_date})" 15 | 16 | [tool.interrogate] 17 | ignore-init-method = true 18 | ignore-init-module = true 19 | ignore-magic = false 20 | ignore-semiprivate = false 21 | ignore-private = false 22 | ignore-module = true 23 | ignore-nested-functions = true 24 | fail-under = 0 25 | exclude = ["docs", ".tox"] 26 | ignore-regex = ["^get$", "^mock_.*", ".*BaseClass.*"] 27 | verbose = 0 28 | quiet = false 29 | whitelist-regex = [] 30 | color = true 31 | 32 | [tool.isort] 33 | profile = "black" 34 | combine_as_imports = true 35 | default_section = "THIRDPARTY" 36 | force_grid_wrap = 0 37 | include_trailing_comma = true 38 | known_first_party = "app_enabler" 39 | line_length = 119 40 | multi_line_output = 3 41 | use_parentheses = true 42 | 43 | [tool.ruff] 44 | ignore = [] 45 | line-length = 119 46 | target-version = "py310" 47 | 48 | [tool.ruff.mccabe] 49 | max-complexity = 10 50 | 51 | [tool.bumpversion] 52 | allow_dirty = false 53 | commit = true 54 | message = "Release {new_version}" 55 | commit_args = "--no-verify" 56 | tag = false 57 | current_version = "0.5.0" 58 | parse = """(?x) 59 | (?P[0-9]+) 60 | \\.(?P[0-9]+) 61 | \\.(?P[0-9]+) 62 | (?: 63 | .(?Pdev) 64 | (?:(?P[0-9]+))? 65 | )? 66 | """ 67 | serialize = [ 68 | "{major}.{minor}.{patch}.{release}{relver}", 69 | "{major}.{minor}.{patch}" 70 | ] 71 | 72 | [tool.bumpversion.parts.release] 73 | values = [ 74 | "dev", 75 | "" 76 | ] 77 | optional_value = "dev" 78 | 79 | [[tool.bumpversion.files]] 80 | filename = "app_enabler/__init__.py" 81 | search = "{current_version}" 82 | -------------------------------------------------------------------------------- /app_enabler/install.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | import sys 4 | from typing import Optional 5 | 6 | import pkg_resources 7 | from pkg_resources import Requirement 8 | 9 | logger = logging.getLogger("") 10 | 11 | 12 | def install(package: str, verbose: bool = False, pip_options: str = ""): 13 | """ 14 | Install the package. 15 | 16 | Installation is done via pip executed as a subprocess to ensure maximum compatibility. 17 | 18 | :param str package: Package name 19 | :param bool verbose: Verbose output 20 | :param str pip_options: Additional options passed to pip 21 | """ 22 | args = ["install", "--disable-pip-version-check"] 23 | if not verbose: 24 | args.append("-q") 25 | if pip_options: 26 | args.extend([opt for opt in pip_options.split(" ") if opt]) 27 | args.append(package) 28 | cmd = [sys.executable, "-mpip"] + args 29 | if verbose: 30 | sys.stdout.write("python path: {}\n".format(sys.executable)) 31 | sys.stdout.write("packages install command: {}\n".format(" ".join(cmd))) 32 | try: 33 | output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) 34 | sys.stdout.write(output.decode("utf-8")) 35 | return True 36 | except subprocess.CalledProcessError as e: # pragma: no cover 37 | logger.error("cmd : {} :{}".format(e.cmd, e.output)) 38 | raise 39 | 40 | 41 | def get_application_from_package(package: str) -> Optional[str]: 42 | """ 43 | Detect the first in alphabetical order module provided by a package. 44 | 45 | This approach is a bit simplistic, but as we only need this to get the ``addon.json`` file specified by this 46 | package, we can easily enforce this restriction. 47 | 48 | :param str package: package name (or rather its requirement string). It can be anything complying with PEP508 49 | :return: main (first) module name; if ``None``, package is not available in the current virtualenv 50 | """ 51 | try: 52 | distribution = pkg_resources.get_distribution(Requirement.parse(package)) 53 | except pkg_resources.DistributionNotFound: 54 | return 55 | try: 56 | return distribution.get_metadata("top_level.txt").split()[0] 57 | except (FileNotFoundError, IndexError): # pragma: no cover 58 | return 59 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tox tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | if: "!contains(github.event.head_commit.message, '[skip ci]')" 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.11", "3.10"] 12 | django: [42] 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Cache pip 20 | uses: actions/cache@v3 21 | with: 22 | path: ~/.cache/pip 23 | key: ${{ runner.os }}-pip-${{ matrix.toxenv }} 24 | restore-keys: | 25 | ${{ runner.os }}-pip-${{ matrix.toxenv }} 26 | - name: Cache tox 27 | uses: actions/cache@v3 28 | with: 29 | path: .tox 30 | key: ${{ runner.os }}-tox-${{ format('{{py{0}-django{1}}}', matrix.python-version, matrix.django) }}-${{ hashFiles('setup.cfg') }} 31 | restore-keys: | 32 | ${{ runner.os }}-tox-${{ format('{{py{0}-django{1}}}', matrix.python-version, matrix.django) }}- 33 | - name: Install dependencies 34 | run: | 35 | sudo apt-get install gettext libcairo2-dev pkg-config python3-dev 36 | python -m pip install --upgrade pip tox>=3.5 37 | - name: Test with tox 38 | env: 39 | TOX_ENV: ${{ format('py-django{1}', matrix.python-version, matrix.django) }} 40 | PYTEST_ARGS: --cov=app_enabler 41 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | COVERALLS_SERVICE_NAME: github 43 | run: | 44 | tox -e$TOX_ENV 45 | .tox/$TOX_ENV/bin/coverage xml 46 | - name: Coveralls Parallel 47 | uses: coverallsapp/github-action@v2 48 | with: 49 | github-token: ${{ secrets.GITHUB_TOKEN }} 50 | parallel: true 51 | - uses: codecov/codecov-action@v1 52 | with: 53 | token: ${{ secrets.CODECOV_TOKEN }} 54 | flags: unittests 55 | file: ./coverage.xml 56 | fail_ci_if_error: false 57 | services: 58 | redis: 59 | image: redis 60 | ports: 61 | - 6379:6379 62 | finish: 63 | needs: test 64 | if: ${{ always() }} 65 | runs-on: ubuntu-latest 66 | steps: 67 | - name: Coveralls Finished 68 | uses: coverallsapp/github-action@v2 69 | with: 70 | parallel-finished: true 71 | -------------------------------------------------------------------------------- /tests/test_install.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from subprocess import CalledProcessError 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | 7 | from app_enabler.install import get_application_from_package, install 8 | 9 | 10 | def test_get_application_not_existing(): 11 | """Retrieving the main module from a non existing package returns None.""" 12 | assert get_application_from_package("bla_bla") is None 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "package,expected", 17 | ( 18 | ("pytest", "_pytest"), 19 | ("django", "django"), 20 | ("six", "six"), 21 | ), 22 | ) 23 | def test_get_application(package, expected): 24 | """First module of the given package is retrieved.""" 25 | assert get_application_from_package(package) == expected 26 | 27 | 28 | def test_install_real(): 29 | """Package is installed via app_enabler.install.install function.""" 30 | assert install("djangocms_blog") 31 | import djangocms_blog 32 | 33 | # quick and dirty way to test that package is available 34 | assert djangocms_blog 35 | 36 | 37 | def test_install_args(capsys): 38 | """Package is installed via app_enabler.install.install function.""" 39 | with patch("subprocess.check_output") as check_output: 40 | check_output.return_value = b"Installed" 41 | args = [sys.executable, "-mpip", "install", "--disable-pip-version-check", "-v", "djangocms_blog"] 42 | installed = install("djangocms_blog", pip_options="-v", verbose=True) 43 | 44 | captured = capsys.readouterr() 45 | assert installed 46 | assert check_output.call_args[0][0] == args 47 | assert f"python path: {sys.executable}" in captured.out 48 | assert f"packages install command: {sys.executable}" in captured.out 49 | 50 | 51 | def test_install_error(capsys): 52 | """Package installation error report detailed message.""" 53 | with patch("subprocess.check_output") as check_output: 54 | check_output.side_effect = CalledProcessError(cmd="cmd", returncode=1) 55 | 56 | with pytest.raises(CalledProcessError) as exception: 57 | installed = install("djangocms_blog") 58 | captured = capsys.readouterr() 59 | 60 | assert installed is None 61 | assert exception.cmd 62 | assert exception.return_code == 1 63 | assert exception.output 64 | 65 | assert f"python path: {sys.executable}" in captured.out 66 | assert f"packages install command: {sys.executable}" in captured.out 67 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | black 4 | blacken 5 | docs 6 | isort 7 | isort_format 8 | ruff 9 | pypi-description 10 | towncrier 11 | py{311,310}-django{42} 12 | 13 | [testenv] 14 | alwayscopy = True 15 | commands = 16 | {env:COMMAND:python} -mpytest {posargs} {env:PYTEST_ARGS:""} 17 | deps = 18 | django42: Django~=4.2.0 19 | -r{toxinidir}/requirements-test.txt 20 | passenv = 21 | TERM* 22 | PIP_* 23 | PYTEST_* 24 | COMMAND* 25 | BUILD_DIR 26 | DAM_* 27 | DEA_* 28 | DATABASE_* 29 | setenv = 30 | PYTHONDONTWRITEBYTECODE = 1 31 | 32 | [testenv:ruff] 33 | commands = 34 | {envpython} -m ruff check app_enabler tests {posargs} 35 | {envpython} -minterrogate -c pyproject.toml app_enabler tests 36 | deps = 37 | interrogate 38 | ruff 39 | skip_install = true 40 | 41 | [testenv:isort] 42 | commands = 43 | {envpython} -m isort -c --df app_enabler tests 44 | deps = isort>=5.12.0,<5.13.0 45 | skip_install = true 46 | 47 | [testenv:isort_format] 48 | commands = 49 | {envpython} -m isort app_enabler tests 50 | deps = {[testenv:isort]deps} 51 | skip_install = true 52 | 53 | [testenv:black] 54 | commands = 55 | {envpython} -m black --check --diff . 56 | deps = black 57 | skip_install = true 58 | 59 | [testenv:blacken] 60 | commands = 61 | {envpython} -m black . 62 | deps = {[testenv:black]deps} 63 | skip_install = true 64 | 65 | [testenv:docs] 66 | commands = 67 | {envpython} -m invoke docbuild 68 | deps = 69 | invoke 70 | sphinx 71 | sphinx-rtd-theme 72 | sphinx-autobuild 73 | sphinx-click 74 | livereload~=2.6 75 | -rrequirements-test.txt 76 | skip_install = true 77 | 78 | [testenv:towncrier] 79 | commands = 80 | {envpython} -m invoke towncrier-check 81 | deps = 82 | invoke 83 | skip_install = true 84 | 85 | [testenv:pypi-description] 86 | commands = 87 | {envpython} -m invoke clean 88 | {envpython} -m check_manifest 89 | {envpython} -m build . 90 | {envpython} -m twine check dist/* 91 | deps = 92 | invoke 93 | check-manifest 94 | build 95 | twine 96 | skip_install = true 97 | 98 | [testenv:release] 99 | commands = 100 | {envpython} -m invoke clean 101 | {envpython} -m check_manifest 102 | {envpython} -m build . 103 | {envpython} -m twine upload {posargs} dist/* 104 | deps = {[testenv:pypi-description]deps} 105 | passenv = 106 | TWINE_* 107 | skip_install = true 108 | 109 | [check-manifest] 110 | ignore = 111 | .* 112 | *.ini 113 | *.toml 114 | *.json 115 | *.txt 116 | *.yml 117 | *.yaml 118 | .tx/** 119 | changes/** 120 | docs/** 121 | helper.py 122 | tasks.py 123 | tests/** 124 | *.mo 125 | ignore-bad-ideas = 126 | *.mo 127 | 128 | [pytest] 129 | python_files = test_*.py 130 | traceback = short 131 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '37 3 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: 'ubuntu-latest' 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v3 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v2 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v2 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 65 | 66 | # If the Autobuild fails above, remove it and uncomment the following three lines. 67 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 68 | 69 | # - run: | 70 | # echo "Run, Build Application using script" 71 | # ./location_of_script_within_repo/buildscript.sh 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v2 75 | with: 76 | category: "/language:${{matrix.language}}" 77 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ########################## 2 | App Enabler 3 | ########################## 4 | 5 | 6 | |Gitter| |PyPiVersion| |PyVersion| |GAStatus| |TestCoverage| |CodeClimate| |License| 7 | 8 | 9 | ************ 10 | Description 11 | ************ 12 | 13 | PoC autoconfigurator for django applications 14 | 15 | ``django-app-enabler`` goal is to reduce the configuration of a django application to a 16 | one command operation to ease using django applications, both for newcomers and expert developers. 17 | 18 | As configuring a django application can be both boring (as 90% are the usual steps editing ``settings.py`` and ``urls.py``) 19 | and complex (as it's easy to overlook one vital configuration parameter), replacing this with a single command sounds like 20 | a real benefit. 21 | 22 | Key points 23 | ================== 24 | 25 | * zero-knowledge tool to enable and configure django applications in a django project 26 | * rely on specification file shipped by the target application to patch django project configuration 27 | * not a replacement for existing package or dependencies managers (pip / poetry / pipenv / ...) 28 | 29 | Caveats 30 | ================== 31 | 32 | * Project is currently just a proof of concept 33 | * No formal specification or documentation exist (yet) for addon configuration file 34 | * A lot of restrictions regarding the ``settings.py`` and ``urls.py`` files are currently in place 35 | * Not all standard django settings options are currently supported 36 | 37 | See `usage`_ for more details. 38 | 39 | Compatible packages 40 | =================== 41 | 42 | `Up-to-date list of compatible packages`_ 43 | 44 | .. |Gitter| image:: https://img.shields.io/badge/GITTER-join%20chat-brightgreen.svg?style=flat-square 45 | :target: https://gitter.im/nephila/applications 46 | :alt: Join the Gitter chat 47 | 48 | .. |PyPiVersion| image:: https://img.shields.io/pypi/v/django-app-enabler.svg?style=flat-square 49 | :target: https://pypi.python.org/pypi/django-app-enabler 50 | :alt: Latest PyPI version 51 | 52 | .. |PyVersion| image:: https://img.shields.io/pypi/pyversions/django-app-enabler.svg?style=flat-square 53 | :target: https://pypi.python.org/pypi/django-app-enabler 54 | :alt: Python versions 55 | 56 | .. |GAStatus| image:: https://github.com/nephila/django-app-enabler/workflows/Tox%20tests/badge.svg 57 | :target: https://github.com/nephila/django-app-enabler 58 | :alt: Latest CI build status 59 | 60 | .. |TestCoverage| image:: https://img.shields.io/coveralls/nephila/django-app-enabler/master.svg?style=flat-square 61 | :target: https://coveralls.io/r/nephila/django-app-enabler?branch=master 62 | :alt: Test coverage 63 | 64 | .. |License| image:: https://img.shields.io/github/license/nephila/django-app-enabler.svg?style=flat-square 65 | :target: https://pypi.python.org/pypi/django-app-enabler/ 66 | :alt: License 67 | 68 | .. |CodeClimate| image:: https://codeclimate.com/github/nephila/django-app-enabler/badges/gpa.svg?style=flat-square 69 | :target: https://codeclimate.com/github/nephila/django-app-enabler 70 | :alt: Code Climate 71 | 72 | 73 | .. _usage: https://django-app-enabler.readthedocs.io/en/latest/usage.html 74 | .. _Up-to-date list of compatible packages: https://pypi.org/search/?q="django-app-enabler+addon" 75 | -------------------------------------------------------------------------------- /app_enabler/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | from subprocess import CalledProcessError 5 | from typing import List 6 | 7 | import click 8 | 9 | from .enable import apply_configuration_set, enable_application as enable_fun 10 | from .errors import messages 11 | from .install import get_application_from_package, install as install_fun 12 | 13 | 14 | @click.group() 15 | @click.option("--verbose", is_flag=True) 16 | @click.pass_context 17 | def cli(context, verbose): 18 | """Click entrypoint.""" 19 | # this is needed when calling as CLI utility to put the current directory 20 | # in the python path as it's not done automatically 21 | if os.getcwd() not in sys.path: 22 | sys.path.insert(0, os.getcwd()) 23 | context.ensure_object(dict) 24 | context.obj["verbose"] = verbose 25 | 26 | 27 | @cli.command() 28 | @click.argument("application") 29 | @click.pass_context 30 | def enable(context: click.core.Context, application: str): 31 | """ 32 | Enable the application in the current django project. 33 | 34 | APPLICATION: Application module name (example: 'djangocms_blog') 35 | \f 36 | 37 | :param click.core.Context context: Click context 38 | :param str application: python module name to enable. It must be the name of a Django application. 39 | """ 40 | enable_fun(application, verbose=context.obj["verbose"]) 41 | 42 | 43 | @cli.command() 44 | @click.argument("config_set", nargs=-1) 45 | @click.pass_context 46 | def apply(context: click.core.Context, config_set: List[str]): 47 | """ 48 | Apply configuration stored in one or more json files. 49 | 50 | CONFIG_SET: Path to configuration files 51 | \f 52 | 53 | :param click.core.Context context: Click context 54 | :param list config_set: list of paths to addon configuration to load and apply 55 | """ 56 | apply_configuration_set([Path(config) for config in config_set], verbose=context.obj["verbose"]) 57 | 58 | 59 | @cli.command() 60 | @click.argument("package") 61 | @click.option("--pip-options", default="", help="Additional options passed as is to pip") 62 | @click.pass_context 63 | def install(context: click.core.Context, package: str, pip_options: str): 64 | """ 65 | Install the package in the current virtualenv and enable the corresponding application in the current project. 66 | 67 | \b 68 | PACKAGE: Package name as available on PyPi, or rather its requirement string. 69 | Accepts any PEP-508 compliant requirement. 70 | Example: "djangocms-blog~=1.2.0" 71 | \f 72 | 73 | :param click.core.Context context: Click context 74 | :param str package: Name of the package to install 75 | :param str pip_options: Additional options passed to pip 76 | """ 77 | verbose = context.obj["verbose"] 78 | try: 79 | install_fun(package, verbose=verbose, pip_options=pip_options) 80 | except CalledProcessError: 81 | msg = messages["install_error"].format(package=package) 82 | if verbose: 83 | raise RuntimeError(msg) 84 | else: 85 | sys.stderr.write(msg) 86 | return 87 | application = get_application_from_package(package) 88 | if application: 89 | enable_fun(application, verbose=verbose) 90 | else: 91 | msg = messages["enable_error"].format(package=package) 92 | if verbose: 93 | raise RuntimeError(msg) 94 | else: 95 | sys.stderr.write(msg) 96 | return 97 | -------------------------------------------------------------------------------- /tests/sample/test_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_project project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.17. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "super-secret-string" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | ] 41 | 42 | MIDDLEWARE = [ 43 | "django.middleware.security.SecurityMiddleware", 44 | "django.contrib.sessions.middleware.SessionMiddleware", 45 | "django.middleware.common.CommonMiddleware", 46 | "django.middleware.csrf.CsrfViewMiddleware", 47 | "django.contrib.auth.middleware.AuthenticationMiddleware", 48 | "django.contrib.messages.middleware.MessageMiddleware", 49 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 50 | ] 51 | 52 | ROOT_URLCONF = "test_project.urls" 53 | 54 | TEMPLATES = [ 55 | { 56 | "BACKEND": "django.template.backends.django.DjangoTemplates", 57 | "DIRS": [], 58 | "APP_DIRS": True, 59 | "OPTIONS": { 60 | "context_processors": [ 61 | "django.template.context_processors.debug", 62 | "django.template.context_processors.request", 63 | "django.contrib.auth.context_processors.auth", 64 | "django.contrib.messages.context_processors.messages", 65 | ], 66 | }, 67 | }, 68 | ] 69 | 70 | WSGI_APPLICATION = "test_project.wsgi.application" 71 | 72 | 73 | # Database 74 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 75 | 76 | DATABASES = { 77 | "default": { 78 | "ENGINE": "django.db.backends.sqlite3", 79 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 80 | } 81 | } 82 | 83 | 84 | # Password validation 85 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 86 | 87 | AUTH_PASSWORD_VALIDATORS = [ 88 | { 89 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 90 | }, 91 | { 92 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 93 | }, 94 | { 95 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 96 | }, 97 | ] 98 | 99 | 100 | # Internationalization 101 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 102 | 103 | LANGUAGE_CODE = "en-us" 104 | 105 | TIME_ZONE = "UTC" 106 | 107 | USE_I18N = True 108 | 109 | USE_L10N = True 110 | 111 | USE_TZ = True 112 | 113 | 114 | CACHES = { 115 | "default": { 116 | "BACKEND": "django.core.cache.backends.dummy.DummyCache", 117 | } 118 | } 119 | 120 | # Static files (CSS, JavaScript, Images) 121 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 122 | 123 | STATIC_URL = "/static/" 124 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | .. _usage: 2 | 3 | ##### 4 | Usage 5 | ##### 6 | 7 | ``django-app-enabler`` allow application supporting :ref:`addon_configuration` to be installed and configured automatically in the current django project. 8 | 9 | .. _installation: 10 | 11 | ************************* 12 | Installation 13 | ************************* 14 | 15 | ``pip install django-app-enabler`` 16 | 17 | ************************* 18 | Commands 19 | ************************* 20 | 21 | * :ref:`apply \ \ `: Apply configuration from json files 22 | * :ref:`enable \ `: Configure an application 23 | * :ref:`install \ `: Install and configure an application 24 | 25 | 26 | ********************** 27 | Sample execution flow 28 | ********************** 29 | 30 | .. code-block:: bash 31 | 32 | django-enabler install djangocms-blog~=1.2.1 33 | python manage.py migrate 34 | 35 | After this the django application is configured and functional. 36 | 37 | Additional configuration steps might be required according to the application 38 | features and support level and must be documented by the application itself. 39 | 40 | Alternatively you can execute the module itself: 41 | 42 | .. code-block:: bash 43 | 44 | python -mapp_enabler install djangocms-blog~=1.2.1 45 | 46 | 47 | .. _enable_cmd: 48 | 49 | ************************* 50 | Application configuration 51 | ************************* 52 | 53 | The core of ``django-app-enabler`` is its Django configuration patching engine. 54 | 55 | The general concept is that once a django package is installed, ``app-enabler`` can be run from the project root and 56 | the project is automatically updated with the minimal configuration required by the application to run (or any superset 57 | of this definition). 58 | 59 | Applied configurations are declared by the target application in a :ref:`addon_json` file included in the python package. 60 | 61 | Example: 62 | 63 | .. code-block:: bash 64 | 65 | django-enabler enable djangocms_blog 66 | 67 | 68 | See :ref:`limitations` for limitations and caveats. 69 | 70 | 71 | .. _apply_cmd: 72 | 73 | ************************* 74 | Apply configurations 75 | ************************* 76 | 77 | ``django-app-enabler`` can also apply configuration from arbitrary json files not included in any Django application. 78 | 79 | Each configuration file must comply with :ref:`extra_json`. 80 | 81 | .. note:: Django ``settings`` and ``urlconf`` are patched unconditionally. 82 | No attempt to verify that applications declared in ``installed_apps`` 83 | or added to the ``urlconf`` are available in the virtualenv is made. 84 | 85 | Example: 86 | 87 | .. code-block:: bash 88 | 89 | django-enabler apply /path/to/config1.json /path/to/config2.json 90 | 91 | 92 | See :ref:`limitations` for limitations and caveats. 93 | 94 | .. _install_cmd: 95 | 96 | ************************* 97 | Application Installation 98 | ************************* 99 | 100 | As a convenience ``django-app-enabler`` can execute ``pip install`` on your behalf, though step this is not required. 101 | 102 | The ``install`` command will both install the package and enable it. 103 | 104 | Installation is executed via the ``install`` command which a 105 | 106 | .. code-block:: bash 107 | 108 | django-enabler install djangocms-blog~=1.2.0 109 | 110 | .. note:: ``django-app-enabler`` is not intended as a replacement (or sidekick) of existing package / dependencies manager. 111 | The installation step is only intended as a convenience command for those not sticking to any specific workflow. 112 | If you are using anything than manual ``pip`` to install packages, please stick to it and just use :ref:`enable_cmd`. 113 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import re 4 | import sys 5 | from glob import glob 6 | 7 | from invoke import task 8 | 9 | DOCS_PORT = os.environ.get("DOCS_PORT", 8000) 10 | #: branch prefixes for which some checks are skipped 11 | SPECIAL_BRANCHES = ("master", "develop", "release") 12 | 13 | 14 | @task 15 | def clean(c): 16 | """Remove artifacts and binary files.""" 17 | c.run("python setup.py clean --all") 18 | patterns = ["build", "dist"] 19 | patterns.extend(glob("*.egg*")) 20 | patterns.append("docs/_build") 21 | patterns.append("**/*.pyc") 22 | for pattern in patterns: 23 | c.run("rm -rf {}".format(pattern)) 24 | 25 | 26 | @task 27 | def lint(c): 28 | """Run linting tox environments.""" 29 | c.run("tox -eruff,isort,black,pypi-description") 30 | 31 | 32 | @task # NOQA 33 | def format(c): # NOQA 34 | """Run code formatting tasks.""" 35 | c.run("tox -eblacken,isort_format") 36 | 37 | 38 | @task 39 | def towncrier_check(c): # NOQA 40 | """Check towncrier files.""" 41 | output = io.StringIO() 42 | c.run("git branch -a --contains HEAD", out_stream=output) 43 | skipped_branch_prefix = ["pull/", "release/", "develop", "master", "HEAD"] 44 | # cleanup branch names by removing PR-only names in local, remote and disconnected branches to ensure the current 45 | # (i.e. user defined) branch name is used 46 | branches = list( 47 | filter( 48 | lambda x: x and all(not x.startswith(part) for part in skipped_branch_prefix), 49 | ( 50 | branch.replace("origin/", "").replace("remotes/", "").strip("* (") 51 | for branch in output.getvalue().split("\n") 52 | ), 53 | ) 54 | ) 55 | print("Candidate branches", ", ".join(output.getvalue().split("\n"))) 56 | if not branches: 57 | # if no branch name matches, we are in one of the excluded branches above, so we just exit 58 | print("Skip check, branch excluded by configuration") 59 | return 60 | branch = branches[0] 61 | towncrier_file = None 62 | for branch in branches: 63 | if any(branch.startswith(prefix) for prefix in SPECIAL_BRANCHES): 64 | sys.exit(0) 65 | try: 66 | parts = re.search(r"(?P\w+)/\D*(?P\d+)\D*", branch).groups() 67 | towncrier_file = os.path.join("changes", "{1}.{0}".format(*parts)) 68 | if not os.path.exists(towncrier_file) or os.path.getsize(towncrier_file) == 0: 69 | print( 70 | "=========================\n" 71 | "Current tree does not contain the towncrier file {} or file is empty\n" 72 | "please check CONTRIBUTING documentation.\n" 73 | "=========================" 74 | "".format(towncrier_file) 75 | ) 76 | sys.exit(2) 77 | else: 78 | break 79 | except AttributeError: 80 | pass 81 | if not towncrier_file: 82 | print( 83 | "=========================\n" 84 | "Branch {} does not respect the '/(-)-description' format\n" 85 | "=========================\n" 86 | "".format(branch) 87 | ) 88 | sys.exit(1) 89 | 90 | 91 | @task 92 | def test(c): 93 | """Run test in local environment.""" 94 | c.run("python setup.py test") 95 | 96 | 97 | @task 98 | def test_all(c): 99 | """Run all tox environments.""" 100 | c.run("tox") 101 | 102 | 103 | @task 104 | def coverage(c): 105 | """Run test with coverage in local environment.""" 106 | c.run("coverage erase") 107 | c.run("run setup.py test") 108 | c.run("report -m") 109 | 110 | 111 | @task 112 | def tag_release(c, level, new_version=""): 113 | """Tag release version.""" 114 | c.run(f"bump-my-version bump {level}{new_version}") 115 | 116 | 117 | @task 118 | def tag_dev(c, level, new_version=""): 119 | """Tag development version.""" 120 | if new_version: 121 | new_version = f" --new-version {new_version}" 122 | elif level == "release": 123 | c.run("bump-my-version bump patch --no-commit") 124 | level = "relver" 125 | c.run(f"bump-my-version bump {level} --message='Bump develop version [ci skip]' {new_version} --allow-dirty") 126 | 127 | 128 | @task(pre=[clean]) 129 | def docbuild(c): 130 | """Build documentation.""" 131 | os.chdir("docs") 132 | build_dir = os.environ.get("BUILD_DIR", "_build/html") 133 | c.run("python -msphinx -W -b html -d _build/doctrees . %s" % build_dir) 134 | 135 | 136 | @task(docbuild) 137 | def docserve(c): 138 | """Serve docs at http://localhost:$DOCS_PORT/ (default port is 8000).""" 139 | from livereload import Server 140 | 141 | server = Server() 142 | server.watch("docs/conf.py", lambda: docbuild(c)) 143 | server.watch("CONTRIBUTING.rst", lambda: docbuild(c)) 144 | server.watch("docs/*.rst", lambda: docbuild(c)) 145 | server.serve(port=DOCS_PORT, root="_build/html") 146 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from pathlib import Path 4 | from typing import Any, Dict 5 | 6 | import pytest 7 | 8 | from app_enabler.install import install 9 | from app_enabler.patcher import setup_django 10 | from tests.utils import get_project_dir, unload_django, working_directory 11 | 12 | pytest_plugins = "pytester" 13 | 14 | 15 | @pytest.fixture 16 | def blog_package(): 17 | """Ensure djangocms-blog is installed.""" 18 | install("djangocms-blog") 19 | 20 | 21 | @pytest.fixture 22 | def django_setup(project_dir: str): 23 | """Setup django environment.""" 24 | with working_directory(project_dir): 25 | os.environ["DJANGO_SETTINGS_MODULE"] = "test_project.settings" 26 | setup_django() 27 | yield 28 | unload_django() 29 | 30 | 31 | @pytest.fixture 32 | def teardown_django(): 33 | """ 34 | Reset django imports and configuration, undoing django.setup call. 35 | 36 | Use this fixture whenever django.setup is called during test execution (either explicitly or implicitly). 37 | 38 | Already called by :py:func:`django_setup`. 39 | """ 40 | yield 41 | unload_django() 42 | 43 | 44 | @pytest.fixture 45 | def project_dir(pytester) -> Path: 46 | """Create a temporary django project structure.""" 47 | original_project = get_project_dir() 48 | tmp_project = pytester.path / "tmp_project" 49 | shutil.rmtree(tmp_project, ignore_errors=True) 50 | shutil.copytree(original_project, tmp_project) 51 | yield tmp_project 52 | shutil.rmtree(tmp_project, ignore_errors=True) 53 | 54 | 55 | @pytest.fixture 56 | def addon_config() -> Dict[str, Any]: 57 | """Sample addon config.""" 58 | return { 59 | "package-name": "djangocms-blog", 60 | "installed-apps": [ 61 | "filer", 62 | "easy_thumbnails", 63 | {"value": "aldryn_apphooks_config", "next": "cms"}, 64 | "parler", 65 | "taggit", 66 | { 67 | "value": "taggit_autosuggest", 68 | "next": "taggit", 69 | }, 70 | "meta", 71 | "djangocms_blog", 72 | "taggit_autosuggest", 73 | "sortedm2m", 74 | ], 75 | "settings": { 76 | "META_SITE_PROTOCOL": "https", 77 | "META_USE_SITES": True, 78 | "USE_I18N": False, 79 | "LANGUAGE_CODE": "it-IT", 80 | "CACHES": { 81 | "default": { 82 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 83 | }, 84 | "dummy": { 85 | "BACKEND": "django.core.cache.backends.dummy.DummyCache", 86 | }, 87 | }, 88 | "MIDDLEWARE": [ 89 | "django.middleware.gzip.GZipMiddleware", 90 | {"value": "django.middleware.http.ConditionalGetMiddleware", "position": 2}, 91 | { 92 | "value": "django.middleware.locale.LocaleMiddleware", 93 | "next": "django.middleware.common.CommonMiddleware", 94 | }, 95 | ], 96 | "AUTH_PASSWORD_VALIDATORS": [ 97 | { 98 | "value": { 99 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 100 | }, 101 | "next": "django.contrib.auth.password_validation.MinimumLengthValidator", 102 | "key": "NAME", 103 | }, 104 | { 105 | "value": { 106 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 107 | }, 108 | "next": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 109 | "key": "NAME", 110 | }, 111 | { 112 | "value": { 113 | "NAME": "django.contrib.auth.password_validation.SuperCommonPasswordValidator", 114 | }, 115 | "next": "django.contrib.auth.password_validation.LessCommonPasswordValidator", 116 | "key": "NAME", 117 | }, 118 | { 119 | "value": { 120 | "NAME": "django.contrib.auth.password_validation.BotchedCommonPasswordValidator", 121 | }, 122 | "next": "django.contrib.auth.password_validation.LessCommonPasswordValidator", 123 | "key": "NOPE", 124 | }, 125 | ], 126 | }, 127 | "urls": [["", "djangocms_blog.taggit_urls"]], 128 | "message": "Please check documentation to complete the setup", 129 | } 130 | 131 | 132 | @pytest.fixture 133 | def addon_config_minimal() -> Dict[str, Any]: 134 | """Minimal addon config.""" 135 | return { 136 | "package-name": "djangocms-blog", 137 | "installed-apps": [ 138 | "filer", 139 | "easy_thumbnails", 140 | "aldryn_apphooks_config", 141 | "parler", 142 | "taggit", 143 | "taggit_autosuggest", 144 | "meta", 145 | "djangocms_blog", 146 | "sortedm2m", 147 | ], 148 | } 149 | -------------------------------------------------------------------------------- /tests/test_patcher.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os 3 | import sys 4 | import warnings 5 | from importlib import import_module 6 | 7 | import astor 8 | import pytest 9 | from django.urls import LocalePrefixPattern, URLPattern, URLResolver 10 | 11 | from app_enabler.enable import _verify_settings, _verify_urlconf 12 | from app_enabler.errors import messages 13 | from app_enabler.patcher import setup_django, update_setting, update_urlconf 14 | from tests.utils import working_directory 15 | 16 | 17 | def test_setup_django_no_manage(capsys, project_dir, teardown_django): 18 | """Executing setup_django outside a project root raise a specific exception.""" 19 | from django.apps import apps 20 | 21 | apps.ready = False 22 | apps.loading = False 23 | apps.app_configs = {} 24 | 25 | with working_directory(project_dir / "test_project"): 26 | os.environ["DJANGO_SETTINGS_MODULE"] = "test_project.settings" 27 | with pytest.raises(SystemExit): 28 | setup_django() 29 | 30 | assert not apps.ready 31 | captured = capsys.readouterr() 32 | assert captured.err.strip() == messages["no_managepy"].strip() 33 | 34 | 35 | def test_setup_django(project_dir, teardown_django): 36 | """Executing setup_django will setup the corresponding django project.""" 37 | from django.apps import apps 38 | 39 | with working_directory(project_dir): 40 | os.environ["DJANGO_SETTINGS_MODULE"] = "test_project.settings" 41 | setup_django() 42 | assert apps.ready 43 | assert len(apps.get_app_configs()) == 6 44 | 45 | 46 | def test_update_setting(pytester, project_dir, addon_config): 47 | """Project settings is patched with data from addon configuration.""" 48 | settings_file = project_dir / "test_project" / "settings.py" 49 | 50 | update_setting(settings_file, addon_config) 51 | sys.path.insert(0, str(settings_file.parent)) 52 | imported = import_module("settings") 53 | with warnings.catch_warnings(record=True) as w: 54 | # settings is not verified as BotchedCommonPasswordValidator is not added, this is expected and tested 55 | assert _verify_settings(imported, addon_config) is False 56 | assert len(w) == 1 57 | assert issubclass(w[-1].category, RuntimeWarning) 58 | assert "Configuration error for AUTH_PASSWORD_VALIDATORS" in str(w[-1].message) 59 | assert imported.MIDDLEWARE.index("django.middleware.common.CommonMiddleware") > imported.MIDDLEWARE.index( 60 | "django.middleware.locale.LocaleMiddleware" 61 | ) 62 | assert imported.MIDDLEWARE.index("django.middleware.http.ConditionalGetMiddleware") == 2 63 | assert ( 64 | imported.AUTH_PASSWORD_VALIDATORS[0]["NAME"] 65 | == "django.contrib.auth.password_validation.SuperCommonPasswordValidator" 66 | ) 67 | assert ( 68 | imported.AUTH_PASSWORD_VALIDATORS[2]["NAME"] 69 | == "django.contrib.auth.password_validation.NumericPasswordValidator" 70 | ) 71 | assert "django.contrib.auth.password_validation.BotchedCommonPasswordValidator" not in [ 72 | item["NAME"] for item in imported.AUTH_PASSWORD_VALIDATORS 73 | ] 74 | assert imported.INSTALLED_APPS.index("taggit") == imported.INSTALLED_APPS.index("taggit_autosuggest") + 1 75 | assert imported.INSTALLED_APPS.index("aldryn_apphooks_config") == 0 76 | 77 | 78 | def test_update_urlconf(pytester, django_setup, project_dir, addon_config): 79 | """Project urlconf is patched with data from addon configuration.""" 80 | urlconf_file = project_dir / "test_project" / "urls.py" 81 | 82 | update_urlconf(urlconf_file, addon_config) 83 | sys.path.insert(0, str(urlconf_file.parent)) 84 | imported = import_module("urls") 85 | assert _verify_urlconf(imported, addon_config) 86 | 87 | 88 | def test_update_urlconf_multiple_include(pytester, django_setup, project_dir, addon_config): 89 | """Repeated calls to update_urlconf only add a single include.""" 90 | urlconf_file = project_dir / "test_project" / "urls.py" 91 | 92 | update_urlconf(urlconf_file, addon_config) 93 | update_urlconf(urlconf_file, addon_config) 94 | update_urlconf(urlconf_file, addon_config) 95 | 96 | parsed = astor.parse_file(urlconf_file) 97 | for node in parsed.body: 98 | if isinstance(node, ast.ImportFrom) and node.module == "django.urls": 99 | assert len(node.names) == 2 100 | assert "path" in (alias.name for alias in node.names) 101 | assert "include" in (alias.name for alias in node.names) 102 | 103 | 104 | def test_update_urlconf_multiple_urlconf(pytester, django_setup, project_dir, addon_config): 105 | """Repeated calls to update_urlconf only add a single application urlconf instance.""" 106 | urlconf_file = project_dir / "test_project" / "urls.py" 107 | 108 | update_urlconf(urlconf_file, addon_config) 109 | update_urlconf(urlconf_file, addon_config) 110 | update_urlconf(urlconf_file, addon_config) 111 | 112 | imported_urlconf = import_module("test_project.urls") 113 | instances_admin = 0 114 | instances_blog = 0 115 | instances_i18n = 0 116 | instances_view = 0 117 | instances_sitemap = 0 118 | for pattern in imported_urlconf.urlpatterns: 119 | if isinstance(pattern, URLResolver): 120 | if isinstance(pattern.urlconf_module, list): 121 | if pattern.app_name == "admin": 122 | instances_admin += 1 123 | elif isinstance(pattern.pattern, LocalePrefixPattern): 124 | instances_i18n += 1 125 | elif pattern.urlconf_module.__name__ == "djangocms_blog.taggit_urls": 126 | instances_blog += 1 127 | elif isinstance(pattern, URLPattern): 128 | if pattern.lookup_str == "django.contrib.sitemaps.views.sitemap": 129 | instances_sitemap += 1 130 | elif pattern.lookup_str == "django.views.generic.base.View": 131 | instances_view += 1 132 | assert instances_admin == 1 133 | assert instances_blog == 1 134 | assert instances_i18n == 1 135 | assert instances_view == 1 136 | assert instances_sitemap == 1 137 | -------------------------------------------------------------------------------- /docs/addon_configuration.rst: -------------------------------------------------------------------------------- 1 | .. _addon_configuration: 2 | 3 | ################################# 4 | Addon configuration specification 5 | ################################# 6 | 7 | ``django-app-enabler`` support can be enabled by adding a :ref:`addon_json` to any django application 8 | (see below for the structure). 9 | 10 | 11 | .. note: To make easier to find compatible packages, add ``django-app-enabler`` to the package keywords. 12 | 13 | See :ref:`limitations` for limitations and caveats. 14 | 15 | .. _addon_json: 16 | 17 | *********** 18 | addon.json 19 | *********** 20 | 21 | ``addon.json`` is the only configuration file needed to support ``django-app-enabler`` and it **must** provide at least 22 | the minimal setup to make the application up an running on a clean django project. 23 | 24 | .. warning:: The file must be included in root of the first (alphabetically) module of your application package. 25 | See :ref:`packaging` for details. 26 | 27 | .. _extra_json: 28 | 29 | **************************************** 30 | Extra configuration files specifications 31 | **************************************** 32 | 33 | Extra configuration files (applied via :ref:`apply_cmd`) must conform to the same specifications below with two exceptions: 34 | 35 | - all attributes are optional (i.e.: they can be completely omitted) 36 | - the json file can contain a single object like for the ``addon.json`` case, or a list of objects conforming to the specifications. 37 | 38 | 39 | Attributes 40 | =========== 41 | 42 | The following attributes are currently supported: 43 | 44 | * ``package-name`` [**required**]: package name as available on PyPi; 45 | * ``installed-apps`` [**required**]: list of django applications to be appended in the project ``INSTALLED_APPS`` 46 | setting. Application must be already installed when the configuration is processed, thus they must declared as 47 | package dependencies (or dependencies of direct dependencies, even if this is a bit risky); 48 | * ``urls`` [optional]: list of urlconfs to be added to the project ``ROOT_URLCONF``. List can be empty if no url 49 | configuration is needed or it can be omitted. 50 | 51 | Each entry in the list must be in the ``[,]`` format: 52 | 53 | * ```` must be a :py:func:`Django path() ` pattern string, it can be empty 54 | (to add the urlconf to the root) 55 | * ```` must be a valid input for :py:func:`Django include() function `; 56 | * ``settings`` [optional]: A dictionary of custom settings that will be added to project settings verbatim; 57 | * ``message`` [optional]: A text message output after successful completion of the configuration; 58 | 59 | Attribute format 60 | ---------------- 61 | 62 | ``installed-apps`` and ``settings`` values can have the following formats: 63 | 64 | - literal (``string``, ``int``, ``boolean``): value is applied as is 65 | - ``dict`` with the following structure: 66 | 67 | - ``value: Any`` (required), the setting value 68 | - ``position: int``, if set and the target setting is a list, ``value`` is inserted at position 69 | - ``next: str``, name of an existing item before which the ``value`` is going to be inserted 70 | - ``key: str``, in case ``value`` is a dictionary, the dictionary key to be used to match existing settings value for duplicates and to match the ``next`` value 71 | 72 | 73 | Merge strategy 74 | ============== 75 | 76 | ``settings`` items not existing in the target project settings are applied without further changes, so you can use whatever structure is needed. 77 | 78 | ``settings`` which already exists in the project and ``installed-apps`` configuration are merged with the ones already existing according to this strategy: 79 | 80 | - setting does not exist -> custom setting is added verbatim 81 | - setting exists and its value is a literal -> target project setting is overridden 82 | - setting exists and its value is a list -> custom setting is merged: 83 | 84 | - if the custom setting is a literal -> its value is appended to the setting list 85 | - if it's a dictionary (see format above) -> 86 | 87 | - if ``next`` is defined, a value matching the ``next`` value is searched in the project setting and the custom setting ``value`` is inserted before the ``next`` element or at the top of the list if the value is not found; in case ``value`` (and items in the project settings) are dictionaries (like for example ``AUTH_PASSWORD_VALIDATORS``), a ``key`` attribute must be provided as a lookup key; 88 | - if ``position`` is defined, the custom setting value is inserted at that position; 89 | 90 | In any case, if a value is already present, is not duplicated and is simply ignored. 91 | 92 | Sample file 93 | =========== 94 | 95 | .. code-block:: json 96 | 97 | { 98 | "package-name": "djangocms-blog", 99 | "installed-apps": [ 100 | "filer", 101 | "easy_thumbnails", 102 | "aldryn_apphooks_config", 103 | "parler", 104 | "taggit", 105 | "taggit_autosuggest", 106 | "meta", 107 | "djangocms_blog", 108 | "sortedm2m" 109 | ], 110 | "settings": { 111 | "META_SITE_PROTOCOL": "https", 112 | "META_USE_SITES": true, 113 | "MIDDLEWARE": [ 114 | "django.middleware.gzip.GZipMiddleware", 115 | {"value": "django.middleware.http.ConditionalGetMiddleware", "position": 2}, 116 | { 117 | "value": "django.middleware.locale.LocaleMiddleware", 118 | "next": "django.middleware.common.CommonMiddleware", 119 | }, 120 | ], 121 | "AUTH_PASSWORD_VALIDATORS": [ 122 | { 123 | "value": { 124 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 125 | }, 126 | "next": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 127 | "key": "NAME", 128 | }, 129 | ], 130 | }, 131 | "urls": [ 132 | ["", "djangocms_blog.taggit_urls"] 133 | ], 134 | "message": "Please check documentation to complete the setup" 135 | } 136 | 137 | 138 | .. _packaging: 139 | 140 | ********** 141 | Packaging 142 | ********** 143 | 144 | TBA 145 | -------------------------------------------------------------------------------- /tests/test_enable.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | from importlib import import_module 5 | from types import ModuleType 6 | from unittest.mock import patch 7 | 8 | from app_enabler.enable import _verify_settings, _verify_urlconf, apply_configuration_set, enable_application 9 | from app_enabler.errors import messages 10 | from tests.utils import working_directory 11 | 12 | 13 | def test_enable(capsys, pytester, project_dir, addon_config, teardown_django): 14 | """Enabling application load the addon configuration in settings and urlconf.""" 15 | 16 | with working_directory(project_dir), patch("app_enabler.enable.load_addon") as load_addon: 17 | del addon_config["settings"]["AUTH_PASSWORD_VALIDATORS"][-1] 18 | load_addon.return_value = addon_config 19 | os.environ["DJANGO_SETTINGS_MODULE"] = "test_project.settings" 20 | 21 | enable_application("djangocms_blog") 22 | 23 | captured = capsys.readouterr() 24 | assert addon_config["message"] in captured.out 25 | if os.environ["DJANGO_SETTINGS_MODULE"] in sys.modules: 26 | del sys.modules[os.environ["DJANGO_SETTINGS_MODULE"]] 27 | if "test_project.urls" in sys.modules: 28 | del sys.modules["test_project.urls"] 29 | imported = import_module(os.environ["DJANGO_SETTINGS_MODULE"]) 30 | assert _verify_settings(imported, addon_config) 31 | 32 | imported = import_module("test_project.urls") 33 | assert _verify_urlconf(imported, addon_config) 34 | 35 | 36 | def test_enable_minimal(capsys, pytester, project_dir, addon_config_minimal, teardown_django): 37 | """Enabling application load the addon configuration in settings and urlconf - minimal addon config.""" 38 | 39 | with working_directory(project_dir), patch("app_enabler.enable.load_addon") as load_addon: 40 | load_addon.return_value = addon_config_minimal 41 | os.environ["DJANGO_SETTINGS_MODULE"] = "test_project.settings" 42 | 43 | enable_application("djangocms_blog") 44 | 45 | captured = capsys.readouterr() 46 | assert not captured.out 47 | if os.environ["DJANGO_SETTINGS_MODULE"] in sys.modules: 48 | del sys.modules[os.environ["DJANGO_SETTINGS_MODULE"]] 49 | if "test_project.urls" in sys.modules: 50 | del sys.modules["test_project.urls"] 51 | imported = import_module(os.environ["DJANGO_SETTINGS_MODULE"]) 52 | assert _verify_settings(imported, addon_config_minimal) 53 | 54 | imported = import_module("test_project.urls") 55 | assert _verify_urlconf(imported, addon_config_minimal) 56 | 57 | 58 | def test_verify_fail(capsys, pytester, project_dir, addon_config_minimal, blog_package, teardown_django): 59 | """Enabling application load the addon configuration in settings and urlconf - minimal addon config.""" 60 | 61 | with ( 62 | working_directory(project_dir), 63 | patch("app_enabler.enable.load_addon") as load_addon, 64 | patch("app_enabler.enable.verify_installation") as verify_installation, 65 | ): 66 | load_addon.return_value = addon_config_minimal 67 | verify_installation.return_value = False 68 | os.environ["DJANGO_SETTINGS_MODULE"] = "test_project.settings" 69 | 70 | enable_application("djangocms_blog") 71 | 72 | captured = capsys.readouterr() 73 | assert captured.out == messages["verify_error"].format(package="djangocms-blog") 74 | 75 | 76 | def test_enable_no_application(pytester, project_dir, addon_config, teardown_django): 77 | """Enabling application with empty addon configuration does not alter the configuration.""" 78 | 79 | with working_directory(project_dir), patch("app_enabler.enable.load_addon") as load_addon: 80 | load_addon.return_value = None 81 | os.environ["DJANGO_SETTINGS_MODULE"] = "test_project.settings" 82 | 83 | enable_application("djangocms_blog") 84 | if os.environ["DJANGO_SETTINGS_MODULE"] in sys.modules: 85 | del sys.modules[os.environ["DJANGO_SETTINGS_MODULE"]] 86 | if "test_project.urls" in sys.modules: 87 | del sys.modules["test_project.urls"] 88 | imported = import_module(os.environ["DJANGO_SETTINGS_MODULE"]) 89 | assert "djangocms_blog" not in imported.INSTALLED_APPS 90 | assert "django.middleware.gzip.GZipMiddleware" not in imported.MIDDLEWARE 91 | 92 | imported = import_module("test_project.urls") 93 | urlpattern_patched = False 94 | for urlpattern in imported.urlpatterns: 95 | if ( 96 | getattr(urlpattern, "urlconf_name", None) 97 | and isinstance(urlpattern.urlconf_name, ModuleType) 98 | and urlpattern.urlconf_name.__name__ == "djangocms_blog.taggit_urls" 99 | ): 100 | urlpattern_patched = True 101 | assert not urlpattern_patched 102 | 103 | 104 | def test_apply_configuration_set(capsys, pytester, project_dir, teardown_django): 105 | """Applying configurations from a list of json files update the project settings and urlconf.""" 106 | 107 | with working_directory(project_dir): 108 | sample_config_set = [ 109 | project_dir / "config" / "1.json", 110 | project_dir / "config" / "2.json", 111 | project_dir / "config" / "no_file.json", 112 | ] 113 | os.environ["DJANGO_SETTINGS_MODULE"] = "test_project.settings" 114 | 115 | json_configs = [json.loads(path.read_text()) for path in sample_config_set if path.exists()] 116 | 117 | apply_configuration_set(sample_config_set) 118 | 119 | captured = capsys.readouterr() 120 | assert "json1-a" in captured.out 121 | assert "json1-b" in captured.out 122 | assert "json2" in captured.out 123 | if os.environ["DJANGO_SETTINGS_MODULE"] in sys.modules: 124 | del sys.modules[os.environ["DJANGO_SETTINGS_MODULE"]] 125 | if "test_project.urls" in sys.modules: 126 | del sys.modules["test_project.urls"] 127 | imported_settings = import_module(os.environ["DJANGO_SETTINGS_MODULE"]) 128 | imported_urls = import_module("test_project.urls") 129 | for config in json_configs: 130 | if not isinstance(config, list): 131 | config = [config] 132 | for item in config: 133 | assert _verify_settings(imported_settings, item) 134 | assert _verify_urlconf(imported_urls, item) 135 | -------------------------------------------------------------------------------- /app_enabler/enable.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | import warnings 4 | from importlib import import_module 5 | from pathlib import Path 6 | from types import ModuleType 7 | from typing import Any, Dict, List 8 | 9 | import django.conf 10 | 11 | from .django import get_settings_path, get_urlconf_path, load_addon 12 | from .errors import messages 13 | from .patcher import setup_django, update_setting, update_urlconf 14 | 15 | 16 | def _verify_settings(imported: ModuleType, application_config: Dict[str, Any]) -> bool: 17 | """ 18 | Check that addon config has been properly set in patched settings. 19 | 20 | :param ModuleType imported: Update settings module 21 | :param dict application_config: addon configuration 22 | """ 23 | 24 | def _validate_setting(key: str, value: Any): 25 | """ 26 | Validate the given value for a single setting. 27 | 28 | It's aware of the possible structures of the application config setting (either a literal or a dict with the 29 | precedence information). 30 | """ 31 | passed = True 32 | if isinstance(value, list): 33 | for item in value: 34 | if isinstance(item, dict): 35 | real_item = item["value"] 36 | passed = passed and (real_item in getattr(imported, key)) 37 | if not passed: # pragma: no cover 38 | warnings.warn(f"Configuration error for {key}", RuntimeWarning) 39 | else: 40 | passed = passed and (item in getattr(imported, key)) 41 | if not passed: # pragma: no cover 42 | warnings.warn(f"Configuration error for {key}", RuntimeWarning) 43 | else: 44 | passed = passed and getattr(imported, key) == value 45 | if not passed: # pragma: no cover 46 | warnings.warn(f"Configuration error for {key}", RuntimeWarning) 47 | return passed 48 | 49 | test_passed = _validate_setting("INSTALLED_APPS", application_config.get("installed-apps", [])) 50 | for setting_name, setting_value in application_config.get("settings", {}).items(): 51 | test_passed = test_passed and _validate_setting(setting_name, setting_value) 52 | return test_passed 53 | 54 | 55 | def _verify_urlconf(imported: ModuleType, application_config: Dict[str, Any]) -> bool: 56 | """ 57 | Check that addon urlconf has been properly added in patched urlconf. 58 | 59 | 60 | :param ModuleType imported: Update ``ROOT_URLCONF`` module 61 | :param dict application_config: addon configuration 62 | """ 63 | # include function is added by our patcher, soo we must ensure it is available 64 | test_passed = bool(imported.include) 65 | included_urls = [url[1] for url in application_config.get("urls", [])] 66 | # as we want to make sure urlpatterns is really tested, we check both that an existing module of the correct type 67 | # is the module from addon config, and that the assert is reached for real 68 | urlpatterns_checked = not included_urls 69 | if included_urls: 70 | for urlpattern in imported.urlpatterns: 71 | try: 72 | if isinstance(urlpattern.urlconf_name, ModuleType): 73 | urlpatterns_checked = True 74 | test_passed = test_passed and urlpattern.urlconf_name.__name__ in included_urls 75 | except AttributeError: 76 | pass 77 | return test_passed and urlpatterns_checked 78 | 79 | 80 | def verify_installation(settings: django.conf.LazySettings, application_config: Dict[str, Any]) -> bool: 81 | """ 82 | Verify that package installation has been successful. 83 | 84 | :param django.conf.LazySettings settings: Path to settings file 85 | :param dict application_config: addon configuration 86 | """ 87 | try: 88 | del sys.modules[settings.SETTINGS_MODULE] 89 | except KeyError: # pragma: no cover 90 | pass 91 | try: 92 | del sys.modules[settings.ROOT_URLCONF] 93 | except KeyError: # pragma: no cover 94 | pass 95 | imported_settings = import_module(settings.SETTINGS_MODULE) 96 | imported_urlconf = import_module(settings.ROOT_URLCONF) 97 | test_passed = _verify_settings(imported_settings, application_config) 98 | test_passed = test_passed and _verify_urlconf(imported_urlconf, application_config) 99 | return test_passed 100 | 101 | 102 | def output_message(message: str): 103 | """ 104 | Print the given message to stdout. 105 | 106 | :param str message: Success message to display 107 | """ 108 | if message: 109 | sys.stdout.write(message) 110 | 111 | 112 | def apply_configuration(application_config: Dict[str, Any]): 113 | """ 114 | Enable django application in the current project 115 | 116 | :param dict application_config: addon configuration 117 | """ 118 | 119 | setting_file = get_settings_path(django.conf.settings) 120 | urlconf_file = get_urlconf_path(django.conf.settings) 121 | update_setting(setting_file, application_config) 122 | update_urlconf(urlconf_file, application_config) 123 | if verify_installation(django.conf.settings, application_config): 124 | output_message(application_config.get("message", "")) 125 | else: 126 | output_message(messages["verify_error"].format(package=application_config.get("package-name"))) 127 | 128 | 129 | def enable_application(application: str, verbose: bool = False): 130 | """ 131 | Enable django application in the current project 132 | 133 | :param str application: python module name to enable. It must be the name of a Django application. 134 | :param bool verbose: Verbose output (currently unused) 135 | """ 136 | setup_django() 137 | 138 | application_config = load_addon(application) 139 | if application_config: 140 | apply_configuration(application_config) 141 | 142 | 143 | def apply_configuration_set(config_set: List[Path], verbose: bool = False): 144 | """ 145 | Apply settings from the list of input files. 146 | 147 | :param list config_set: list of paths to addon configuration to load and apply 148 | :param bool verbose: Verbose output (currently unused) 149 | """ 150 | setup_django() 151 | 152 | for config_path in config_set: 153 | try: 154 | config_data = json.loads(config_path.read_text()) 155 | except OSError: 156 | config_data = [] 157 | if config_data: 158 | if not isinstance(config_data, list): 159 | config_data = [config_data] 160 | for item in config_data: 161 | apply_configuration(item) 162 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | from subprocess import CalledProcessError 5 | from unittest.mock import call, patch 6 | 7 | import pytest 8 | from click.testing import CliRunner 9 | 10 | from app_enabler.cli import cli 11 | from app_enabler.errors import messages 12 | from tests.utils import working_directory 13 | 14 | 15 | def test_cli_install_wrong_dir(blog_package): 16 | """Running install command from the wrong directory raise an error.""" 17 | with patch("app_enabler.cli.install_fun") as install_fun: 18 | runner = CliRunner() 19 | result = runner.invoke(cli, ["--verbose", "install", "djangocms-blog"]) 20 | assert result.exit_code == 1 21 | assert result.output.strip() == messages["no_managepy"].strip() 22 | install_fun.assert_called_once() 23 | assert install_fun.call_args_list == [call("djangocms-blog", verbose=True, pip_options="")] 24 | 25 | 26 | def test_cli_sys_path(project_dir, blog_package): 27 | """Running install command from the wrong directory raise an error.""" 28 | with patch("app_enabler.cli.enable_fun"): 29 | # not using working_directory context manager to skip setting the sys.path (which is what we want to test) 30 | os.chdir(str(project_dir)) 31 | runner = CliRunner() 32 | runner.invoke(cli, ["enable", "djangocms-blog"]) 33 | assert str(project_dir) == sys.path[0] 34 | 35 | 36 | def test_cli_install(project_dir, blog_package): 37 | """Running install command calls the business functions with the correct arguments.""" 38 | with ( 39 | patch("app_enabler.cli.enable_fun") as enable_fun, 40 | patch("app_enabler.cli.install_fun") as install_fun, 41 | working_directory(project_dir), 42 | ): 43 | runner = CliRunner() 44 | result = runner.invoke(cli, ["--verbose", "install", "djangocms-blog"]) 45 | assert result.exit_code == 0 46 | install_fun.assert_called_once() 47 | assert install_fun.call_args_list == [call("djangocms-blog", verbose=True, pip_options="")] 48 | 49 | enable_fun.assert_called_once() 50 | assert enable_fun.call_args_list == [call("djangocms_blog", verbose=True)] 51 | 52 | 53 | @pytest.mark.parametrize("verbose", (True, False)) 54 | def test_cli_install_error_verbose(verbose: bool): 55 | """Error raised during package install is reported to the user.""" 56 | with patch("app_enabler.cli.enable_fun") as enable_fun, patch("app_enabler.cli.install_fun") as install_fun: 57 | install_fun.side_effect = CalledProcessError(cmd="cmd", returncode=1) 58 | 59 | runner = CliRunner() 60 | if verbose: 61 | args = ["--verbose"] 62 | else: 63 | args = [] 64 | args.extend(("install", "djangocms-blog")) 65 | result = runner.invoke(cli, args) 66 | 67 | if verbose: 68 | assert result.exit_code == 1 69 | assert not result.output 70 | assert str(result.exception) == messages["install_error"].format(package="djangocms-blog") 71 | assert isinstance(result.exception, RuntimeError) 72 | else: 73 | assert result.exit_code == 0 74 | assert result.output == messages["install_error"].format(package="djangocms-blog") 75 | 76 | install_fun.assert_called_once() 77 | assert install_fun.call_args_list == [call("djangocms-blog", verbose=verbose, pip_options="")] 78 | 79 | enable_fun.assert_not_called() 80 | 81 | 82 | @pytest.mark.parametrize("verbose", (True, False)) 83 | def test_cli_install_bad_application_verbose(verbose: bool): 84 | """Error due to bad application name is reported to the user.""" 85 | with ( 86 | patch("app_enabler.cli.enable_fun") as enable_fun, 87 | patch("app_enabler.cli.install_fun"), 88 | patch("app_enabler.cli.get_application_from_package") as get_application_from_package, 89 | ): 90 | get_application_from_package.return_value = None 91 | 92 | runner = CliRunner() 93 | if verbose: 94 | args = ["--verbose"] 95 | else: 96 | args = [] 97 | args.extend(("install", "djangocms-blog")) 98 | result = runner.invoke(cli, args) 99 | 100 | if verbose: 101 | assert result.exit_code == 1 102 | assert not result.output 103 | assert str(result.exception) == messages["enable_error"].format(package="djangocms-blog") 104 | assert isinstance(result.exception, RuntimeError) 105 | else: 106 | assert result.exit_code == 0 107 | assert result.output == messages["enable_error"].format(package="djangocms-blog") 108 | 109 | enable_fun.assert_not_called() 110 | 111 | 112 | @pytest.mark.parametrize("verbose", (True, False)) 113 | def test_cli_enable(verbose: bool): 114 | """Running enable command calls the business functions with the correct arguments.""" 115 | with patch("app_enabler.cli.enable_fun") as enable_fun: 116 | runner = CliRunner() 117 | if verbose: 118 | args = ["--verbose"] 119 | else: 120 | args = [] 121 | args.extend(("enable", "djangocms_blog")) 122 | result = runner.invoke(cli, args) 123 | assert result.exit_code == 0 124 | 125 | enable_fun.assert_called_once() 126 | assert enable_fun.call_args_list == [call("djangocms_blog", verbose=verbose)] 127 | 128 | 129 | @pytest.mark.parametrize("verbose", (True, False)) 130 | def test_cli_apply(verbose: bool): 131 | """Running apply command calls the business functions with the correct arguments.""" 132 | with patch("app_enabler.cli.apply_configuration_set") as apply_configuration_set: 133 | runner = CliRunner() 134 | if verbose: 135 | args = ["--verbose"] 136 | else: 137 | args = [] 138 | 139 | configs = ("/path/config1.json", "/path/config2.json") 140 | args.extend(("apply", *configs)) 141 | result = runner.invoke(cli, args) 142 | assert result.exit_code == 0 143 | 144 | apply_configuration_set.assert_called_once() 145 | assert apply_configuration_set.call_args_list == [call([Path(config) for config in configs], verbose=verbose)] 146 | 147 | 148 | @pytest.mark.parametrize("verbose", (True, False)) 149 | def test_cli_function(verbose: bool): 150 | """Running cli without commands return info message.""" 151 | with patch("app_enabler.cli.enable_fun") as enable_fun, patch("app_enabler.cli.install_fun") as install_fun: 152 | runner = CliRunner() 153 | if verbose: 154 | args = ["--verbose"] 155 | else: 156 | args = [] 157 | result = runner.invoke(cli, args) 158 | install_fun.assert_not_called() 159 | enable_fun.assert_not_called() 160 | 161 | if verbose: 162 | assert result.exit_code == 2 163 | assert "Error: Missing command." in result.output 164 | else: 165 | assert result.exit_code == 0 166 | assert "Commands:" in result.output 167 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/NephilaWidgets.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/NephilaWidgets.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/NephilaWidgets" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/NephilaWidgets" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Nephila Widgets documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Apr 3 07:09:59 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import os 16 | import sys 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | sys.path.insert(0, os.path.abspath("..")) 22 | import app_enabler # isort:skip # noqa: E402 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | needs_sphinx = "1.8" 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | "sphinx.ext.autodoc", 35 | "sphinx.ext.intersphinx", 36 | "sphinx.ext.todo", 37 | "sphinx.ext.coverage", 38 | "sphinx.ext.ifconfig", 39 | "sphinx.ext.viewcode", 40 | "sphinx_click.ext", 41 | ] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ["_templates"] 45 | 46 | # The suffix of source filenames. 47 | source_suffix = ".rst" 48 | 49 | # The encoding of source files. 50 | # source_encoding = 'utf-8-sig' 51 | 52 | # The master toctree document. 53 | master_doc = "index" 54 | 55 | # General information about the project. 56 | project = "App Enabler" 57 | copyright = "2015, Nephila" # noqa: A001 58 | 59 | # The version info for the project you're documenting, acts as replacement for 60 | # |version| and |release|, also used in various other places throughout the 61 | # built documents. 62 | # 63 | # The short X.Y version. 64 | version = app_enabler.__version__ 65 | # The full version, including alpha/beta/rc tags. 66 | release = app_enabler.__version__ 67 | 68 | # The language for content autogenerated by Sphinx. Refer to documentation 69 | # for a list of supported languages. 70 | # language = None 71 | 72 | # There are two options for replacing |today|: either, you set today to some 73 | # non-false value, then it is used: 74 | # today = '' 75 | # Else, today_fmt is used as the format for a strftime call. 76 | # today_fmt = '%B %d, %Y' 77 | 78 | # List of patterns, relative to source directory, that match files and 79 | # directories to ignore when looking for source files. 80 | exclude_patterns = ["_build"] 81 | 82 | # The reST default role (used for this markup: `text`) to use for all 83 | # documents. 84 | # default_role = None 85 | 86 | # If true, '()' will be appended to :func: etc. cross-reference text. 87 | # add_function_parentheses = True 88 | 89 | # If true, the current module name will be prepended to all description 90 | # unit titles (such as .. function::). 91 | # add_module_names = True 92 | 93 | # If true, sectionauthor and moduleauthor directives will be shown in the 94 | # output. They are ignored by default. 95 | # show_authors = False 96 | 97 | # The name of the Pygments (syntax highlighting) style to use. 98 | pygments_style = "sphinx" 99 | 100 | # A list of ignored prefixes for module index sorting. 101 | # modindex_common_prefix = [] 102 | 103 | # If true, keep warnings as "system message" paragraphs in the built documents. 104 | # keep_warnings = False 105 | 106 | 107 | # -- Options for HTML output ---------------------------------------------- 108 | 109 | 110 | html_theme = "sphinx_rtd_theme" 111 | 112 | # Theme options are theme-specific and customize the look and feel of a theme 113 | # further. For a list of options available for each theme, see the 114 | # documentation. 115 | # html_theme_options = {} 116 | 117 | # Add any paths that contain custom themes here, relative to this directory. 118 | # html_theme_path = [] 119 | 120 | # The name for this set of Sphinx documents. If None, it defaults to 121 | # " v documentation". 122 | # html_title = None 123 | 124 | # A shorter title for the navigation bar. Default is the same as html_title. 125 | # html_short_title = None 126 | 127 | # The name of an image file (relative to this directory) to place at the top 128 | # of the sidebar. 129 | # html_logo = None 130 | 131 | # The name of an image file (within the static path) to use as favicon of the 132 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 133 | # pixels large. 134 | # html_favicon = None 135 | 136 | # Add any paths that contain custom static files (such as style sheets) here, 137 | # relative to this directory. They are copied after the builtin static files, 138 | # so a file named "default.css" will overwrite the builtin "default.css". 139 | # html_static_path = ['_static'] 140 | 141 | # Add any extra paths that contain custom files (such as robots.txt or 142 | # .htaccess) here, relative to this directory. These files are copied 143 | # directly to the root of the documentation. 144 | # html_extra_path = [] 145 | 146 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 147 | # using the given strftime format. 148 | # html_last_updated_fmt = '%b %d, %Y' 149 | 150 | # If true, SmartyPants will be used to convert quotes and dashes to 151 | # typographically correct entities. 152 | # html_use_smartypants = True 153 | 154 | # Custom sidebar templates, maps document names to template names. 155 | # html_sidebars = {} 156 | 157 | # Additional templates that should be rendered to pages, maps page names to 158 | # template names. 159 | # html_additional_pages = {} 160 | 161 | # If false, no module index is generated. 162 | # html_domain_indices = True 163 | 164 | # If false, no index is generated. 165 | # html_use_index = True 166 | 167 | # If true, the index is split into individual pages for each letter. 168 | # html_split_index = False 169 | 170 | # If true, links to the reST sources are added to the pages. 171 | # html_show_sourcelink = True 172 | 173 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 174 | # html_show_sphinx = True 175 | 176 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 177 | # html_show_copyright = True 178 | 179 | # If true, an OpenSearch description file will be output, and all pages will 180 | # contain a tag referring to it. The value of this option must be the 181 | # base URL from which the finished HTML is served. 182 | # html_use_opensearch = '' 183 | 184 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 185 | # html_file_suffix = None 186 | 187 | # Output file base name for HTML help builder. 188 | htmlhelp_basename = "app_enabler" 189 | 190 | 191 | # -- Options for LaTeX output --------------------------------------------- 192 | 193 | latex_elements = { 194 | # The paper size ('letterpaper' or 'a4paper'). 195 | # 'papersize': 'letterpaper', 196 | # The font size ('10pt', '11pt' or '12pt'). 197 | # 'pointsize': '10pt', 198 | # Additional stuff for the LaTeX preamble. 199 | # 'preamble': '', 200 | } 201 | 202 | # Grouping the document tree into LaTeX files. List of tuples 203 | # (source start file, target name, title, 204 | # author, documentclass [howto, manual, or own class]). 205 | latex_documents = [ 206 | ("index", "app_enabler.tex", "App Enabler Documentation", "Nephila", "manual"), 207 | ] 208 | 209 | # The name of an image file (relative to this directory) to place at the top of 210 | # the title page. 211 | # latex_logo = None 212 | 213 | # For "manual" documents, if this is true, then toplevel headings are parts, 214 | # not chapters. 215 | # latex_use_parts = False 216 | 217 | # If true, show page references after internal links. 218 | # latex_show_pagerefs = False 219 | 220 | # If true, show URL addresses after external links. 221 | # latex_show_urls = False 222 | 223 | # Documents to append as an appendix to all manuals. 224 | # latex_appendices = [] 225 | 226 | # If false, no module index is generated. 227 | # latex_domain_indices = True 228 | 229 | 230 | # -- Options for manual page output --------------------------------------- 231 | 232 | # One entry per manual page. List of tuples 233 | # (source start file, name, description, authors, manual section). 234 | man_pages = [("index", "app_enabler", "App Enabler Documentation", ["Nephila"], 1)] 235 | 236 | # If true, show URL addresses after external links. 237 | # man_show_urls = False 238 | 239 | 240 | # -- Options for Texinfo output ------------------------------------------- 241 | 242 | # Grouping the document tree into Texinfo files. List of tuples 243 | # (source start file, target name, title, author, 244 | # dir menu entry, description, category) 245 | texinfo_documents = [ 246 | ( 247 | "index", 248 | "NephilaWidgets", 249 | "Nephila Widgets Documentation", 250 | "Iacopo Spalletti", 251 | "NephilaWidgets", 252 | "One line description of project.", 253 | "Miscellaneous", 254 | ), 255 | ] 256 | 257 | # Documents to append as an appendix to all manuals. 258 | # texinfo_appendices = [] 259 | 260 | # If false, no module index is generated. 261 | # texinfo_domain_indices = True 262 | 263 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 264 | # texinfo_show_urls = 'footnote' 265 | 266 | # If true, do not generate a @detailmenu in the "Top" node's menu. 267 | # texinfo_no_detailmenu = False 268 | 269 | 270 | # -- Extension configuration ------------------------------------------------- 271 | 272 | # -- Options for intersphinx extension --------------------------------------- 273 | 274 | # Example configuration for intersphinx: refer to the Python standard library. 275 | intersphinx_mapping = { 276 | "python": ("https://docs.python.org/", None), 277 | "django": ("https://django.readthedocs.io/en/latest/", None), 278 | "djangocms": ("https://django-cms.readthedocs.io/en/latest/", None), 279 | } 280 | 281 | # -- Options for todo extension ---------------------------------------------- 282 | 283 | # If true, `todo` and `todoList` produce output, else they produce nothing. 284 | todo_include_todos = True 285 | 286 | autodoc_mock_imports = [ 287 | "django.conf", 288 | "django.utils.translation", 289 | ] 290 | autodoc_default_flags = [ 291 | "members", 292 | "private-members", 293 | ] 294 | -------------------------------------------------------------------------------- /app_enabler/patcher.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os # noqa - used when eval'ing the management command 3 | import sys 4 | from types import CodeType 5 | from typing import Any, Dict, Iterable, List, Optional, Union 6 | 7 | import astor 8 | 9 | from .errors import messages 10 | 11 | 12 | def setup_django(): 13 | """ 14 | Initialize the django environment by leveraging ``manage.py``. 15 | 16 | This works by using ``manage.py`` to set the ``DJANGO_SETTINGS_MODULE`` environment variable for 17 | :py:func:`django.setup() ` to work as it's unknown at runtime. 18 | 19 | This should be safer than reading the ``manage.py`` looking for the written variable as it rely on 20 | Django runtime behavior. 21 | 22 | Manage.py is monkeypatched in memory to remove the call "execute_from_command_line" and executed from memory. 23 | """ 24 | import django 25 | 26 | try: 27 | managed_command = monkeypatch_manage("manage.py") 28 | eval(managed_command) 29 | django.setup() 30 | except FileNotFoundError: 31 | sys.stderr.write(messages["no_managepy"]) 32 | sys.exit(1) 33 | 34 | 35 | def monkeypatch_manage(manage_file: str) -> CodeType: 36 | """ 37 | Patch ``manage.py`` to be executable without actually running any command. 38 | 39 | By using ast we remove the ``execute_from_command_line`` call and add an unconditional call to the main function. 40 | 41 | :param str manage_file: path to manage.py file 42 | :return: patched manage.py code 43 | """ 44 | parsed = astor.parse_file(manage_file) 45 | # first patch run replace __name__ != '__main__' with a function call 46 | modified = DisableExecute().visit(parsed) 47 | # patching the module with the call to the main function as the standard one is not executed because 48 | # __name__ != '__main__' 49 | modified.body.append(ast.Expr(value=ast.Call(func=ast.Name(id="main", ctx=ast.Load()), args=[], keywords=[]))) 50 | fixed = ast.fix_missing_locations(modified) 51 | return compile(fixed, "", mode="exec") 52 | 53 | 54 | class DisableExecute(ast.NodeTransformer): 55 | """ 56 | Patch the ``manage.py`` module to remove the execute_from_command_line execution. 57 | """ 58 | 59 | def visit_Expr(self, node: ast.AST) -> Any: # noqa 60 | """Visit the ``Expr`` node and remove it if it matches ``'execute_from_command_line'``.""" 61 | if ( 62 | isinstance(node.value, ast.Call) 63 | and isinstance(node.value.func, ast.Name) # noqa 64 | and node.value.func.id == "execute_from_command_line" # noqa 65 | ): 66 | return None 67 | else: 68 | return node 69 | 70 | 71 | def _ast_get_constant_value(ast_obj: Union[ast.Constant, ast.Str, ast.Num]) -> Any: 72 | """ 73 | Extract the value from an ast.Constant / ast.Str / ast.Num obj. 74 | 75 | Required as in python 3.6 / 3.7 ast.Str / ast.Num are not subclasses of ast.Constant 76 | """ 77 | try: 78 | return ast_obj.value 79 | except AttributeError: 80 | return ast_obj.s 81 | 82 | 83 | def _ast_dict_key_index(dict_object: ast.Dict, lookup_key: str) -> Optional[int]: 84 | """Get the index of the lookup key in the ast Dict object.""" 85 | try: 86 | return [_ast_get_constant_value(dict_key) for dict_key in dict_object.keys].index(lookup_key) 87 | except ValueError: 88 | return None 89 | 90 | 91 | def _ast_dict_lookup(dict_object: ast.Dict, lookup_key: str) -> Optional[Any]: 92 | """Get the value of the lookup key in the ast Dict object.""" 93 | key_position = _ast_dict_key_index(dict_object, lookup_key) 94 | if key_position is None: 95 | return None 96 | return _ast_get_constant_value(dict_object.values[key_position]) 97 | 98 | 99 | def _ast_get_object_from_value(val: Any) -> ast.Constant: 100 | """Convert value to AST via :py:func:`ast.parse`.""" 101 | return ast.parse(repr(val)).body[0].value 102 | 103 | 104 | def _update_list_setting(original_setting: List, configuration: Iterable): 105 | for config_value in configuration: 106 | # configuration items can be either strings (which are appended) or dictionaries which contains information 107 | # about the position of the item 108 | if isinstance(config_value, dict): 109 | value = config_value.get("value", None) 110 | position = config_value.get("position", None) 111 | relative_item = config_value.get("next", None) 112 | key = config_value.get("key", None) 113 | if relative_item: 114 | # if the item is already existing, we skip its insertion 115 | position = None 116 | if key: 117 | # if the match is against a key we must both flatted the original setting to a list of literals 118 | # extracting the key value and getting the key value for the setting we want to add 119 | flattened_data = [_ast_dict_lookup(item, key) for item in original_setting] 120 | check_value = value.get(key, None) 121 | else: 122 | flattened_data = [_ast_get_constant_value(item) for item in original_setting] 123 | check_value = value 124 | if any(flattened_data) and check_value not in flattened_data: 125 | try: 126 | position = flattened_data.index(relative_item) 127 | except ValueError: 128 | # in case the relative item is not found we add the value on top 129 | position = 0 130 | if position is not None: 131 | original_setting.insert(position, _ast_get_object_from_value(value)) 132 | else: 133 | if config_value not in [_ast_get_constant_value(item) for item in original_setting]: 134 | original_setting.append(_ast_get_object_from_value(config_value)) 135 | 136 | 137 | def update_setting(project_setting: str, config: Dict[str, Any]): 138 | """ 139 | Patch the settings module to include addon settings. 140 | 141 | Original file is overwritten. As file is patched using AST, original comments and file structure is lost. 142 | 143 | :param str project_setting: project settings file path 144 | :param dict config: addon setting parameters 145 | """ 146 | parsed = astor.parse_file(project_setting) 147 | existing_setting = [] 148 | addon_settings = config.get("settings", {}) 149 | addon_installed_apps = config.get("installed-apps", []) 150 | constant_subclasses = (ast.Constant, ast.Num, ast.Str, ast.Bytes, ast.NameConstant, ast.Ellipsis) 151 | 152 | for node in parsed.body: 153 | if isinstance(node, ast.Assign) and node.targets[0].id == "INSTALLED_APPS": 154 | _update_list_setting(node.value.elts, addon_installed_apps) 155 | elif isinstance(node, ast.Assign) and node.targets[0].id in addon_settings.keys(): # noqa 156 | config_param = addon_settings[node.targets[0].id] 157 | if isinstance(node.value, ast.List) and ( 158 | isinstance(config_param, list) or isinstance(config_param, tuple) 159 | ): 160 | _update_list_setting(node.value.elts, config_param) 161 | elif isinstance(node.value, ast.Dict): 162 | for dict_key, dict_value in config_param.items(): 163 | ast_position = _ast_dict_key_index(node.value, dict_key) 164 | if ast_position is None: 165 | node.value.keys.append(_ast_get_object_from_value(dict_key)) 166 | node.value.values.append(_ast_get_object_from_value(dict_value)) 167 | else: 168 | node.value.values[ast_position] = _ast_get_object_from_value(dict_value) 169 | pass 170 | elif type(node.value) in constant_subclasses: 171 | # check required as in python 3.6 / 3.7 ast.Str / ast.Num are not subclasses of ast.Constant 172 | node.value = _ast_get_object_from_value(config_param) 173 | existing_setting.append(node.targets[0].id) 174 | for name, value in addon_settings.items(): 175 | if name not in existing_setting: 176 | parsed.body.append(ast.Assign(targets=[ast.Name(id=name)], value=_ast_get_object_from_value(value))) 177 | 178 | src = astor.to_source(parsed) 179 | 180 | with open(project_setting, "w") as fp: 181 | fp.write(src) 182 | 183 | 184 | def update_urlconf(project_urls: str, config: Dict[str, Any]): 185 | """ 186 | Patch the ``ROOT_URLCONF`` module to include addon url patterns. 187 | 188 | Original file is overwritten. As file is patched using AST, original comments and file structure is lost. 189 | 190 | :param str project_urls: project urls.py file path 191 | :param dict config: addon urlconf configuration 192 | """ 193 | parsed = astor.parse_file(project_urls) 194 | 195 | addon_urls = config.get("urls", []) 196 | for node in parsed.body: 197 | if isinstance(node, ast.ImportFrom) and node.module == "django.urls": 198 | existing_names = [alias.name for alias in node.names] 199 | if "include" not in existing_names: 200 | node.names.append(ast.alias(name="include", asname=None)) 201 | elif isinstance(node, ast.Assign) and node.targets[0].id == "urlpatterns": 202 | existing_urlconf = [] 203 | for url_line in node.value.elts: 204 | # the following list comprehension matches path() / url() instances in urlpatterns 205 | # using the `include()` statement as argument. ie. 206 | # - matched: path('', include('cms.urls') 207 | # - not matched: path('sitemap.xml', sitemap, {}) 208 | # we look for ast.Call (outer loop) wrapping ast.Str (inner loop), 209 | # and we assume all is wrapped in ast.Call (as we cycle on url_line.args) 210 | urlconf_path = [ 211 | subarg.s 212 | for stmt in url_line.args 213 | if isinstance(stmt, ast.Call) 214 | for subarg in stmt.args 215 | if isinstance(subarg, ast.Str) 216 | ] 217 | if urlconf_path: 218 | existing_urlconf.extend(urlconf_path) 219 | for pattern, urlconf in addon_urls: 220 | if urlconf not in existing_urlconf: 221 | part = ast.parse(f"path('{pattern}', include('{urlconf}'))") 222 | node.value.elts.append(part.body[0].value) 223 | 224 | src = astor.to_source(parsed) 225 | 226 | with open(project_urls, "w") as fp: 227 | fp.write(src) 228 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/vim,node,sass,vuejs,linux,macos,django,python,pycharm,windows,virtualenv,sublimetext,visualstudiocode 3 | # Edit at https://www.gitignore.io/?templates=vim,node,sass,vuejs,linux,macos,django,python,pycharm,windows,virtualenv,sublimetext,visualstudiocode 4 | 5 | ### Django ### 6 | *.log 7 | *.pot 8 | *.pyc 9 | __pycache__/ 10 | local_settings.py 11 | db.sqlite3 12 | db.sqlite3-journal 13 | media 14 | 15 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 16 | # in your Git repository. Update and uncomment the following line accordingly. 17 | # /staticfiles/ 18 | 19 | ### Django.Python Stack ### 20 | # Byte-compiled / optimized / DLL files 21 | *.py[cod] 22 | *$py.class 23 | 24 | # C extensions 25 | *.so 26 | 27 | # Distribution / packaging 28 | .Python 29 | build/ 30 | develop-eggs/ 31 | dist/ 32 | downloads/ 33 | eggs/ 34 | .eggs/ 35 | lib/ 36 | lib64/ 37 | parts/ 38 | sdist/ 39 | var/ 40 | wheels/ 41 | pip-wheel-metadata/ 42 | share/python-wheels/ 43 | *.egg-info/ 44 | .installed.cfg 45 | *.egg 46 | MANIFEST 47 | 48 | # PyInstaller 49 | # Usually these files are written by a python script from a template 50 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 51 | *.manifest 52 | *.spec 53 | 54 | # Installer logs 55 | pip-log.txt 56 | pip-delete-this-directory.txt 57 | 58 | # Unit test / coverage reports 59 | htmlcov/ 60 | .tox/ 61 | .nox/ 62 | .coverage 63 | .coverage.* 64 | .cache 65 | nosetests.xml 66 | coverage.xml 67 | *.cover 68 | .hypothesis/ 69 | .pytest_cache/ 70 | 71 | # Translations 72 | *.mo 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # Mr Developer 107 | .mr.developer.cfg 108 | .project 109 | .pydevproject 110 | 111 | # mkdocs documentation 112 | /site 113 | 114 | # mypy 115 | .mypy_cache/ 116 | .dmypy.json 117 | dmypy.json 118 | 119 | # Pyre type checker 120 | .pyre/ 121 | 122 | ### Linux ### 123 | *~ 124 | 125 | # temporary files which can be created if a process still has a handle open of a deleted file 126 | .fuse_hidden* 127 | 128 | # KDE directory preferences 129 | .directory 130 | 131 | # Linux trash folder which might appear on any partition or disk 132 | .Trash-* 133 | 134 | # .nfs files are created when an open file is removed but is still being accessed 135 | .nfs* 136 | 137 | ### macOS ### 138 | # General 139 | .DS_Store 140 | .AppleDouble 141 | .LSOverride 142 | 143 | # Icon must end with two \r 144 | Icon 145 | 146 | # Thumbnails 147 | ._* 148 | 149 | # Files that might appear in the root of a volume 150 | .DocumentRevisions-V100 151 | .fseventsd 152 | .Spotlight-V100 153 | .TemporaryItems 154 | .Trashes 155 | .VolumeIcon.icns 156 | .com.apple.timemachine.donotpresent 157 | 158 | # Directories potentially created on remote AFP share 159 | .AppleDB 160 | .AppleDesktop 161 | Network Trash Folder 162 | Temporary Items 163 | .apdisk 164 | 165 | ### Node ### 166 | # Logs 167 | logs 168 | npm-debug.log* 169 | yarn-debug.log* 170 | yarn-error.log* 171 | lerna-debug.log* 172 | 173 | # Diagnostic reports (https://nodejs.org/api/report.html) 174 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 175 | 176 | # Runtime data 177 | pids 178 | *.pid 179 | *.seed 180 | *.pid.lock 181 | 182 | # Directory for instrumented libs generated by jscoverage/JSCover 183 | lib-cov 184 | 185 | # Coverage directory used by tools like istanbul 186 | coverage 187 | *.lcov 188 | 189 | # nyc test coverage 190 | .nyc_output 191 | 192 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 193 | .grunt 194 | 195 | # Bower dependency directory (https://bower.io/) 196 | bower_components 197 | 198 | # node-waf configuration 199 | .lock-wscript 200 | 201 | # Compiled binary addons (https://nodejs.org/api/addons.html) 202 | build/Release 203 | 204 | # Dependency directories 205 | node_modules/ 206 | jspm_packages/ 207 | 208 | # TypeScript v1 declaration files 209 | typings/ 210 | 211 | # TypeScript cache 212 | *.tsbuildinfo 213 | 214 | # Optional npm cache directory 215 | .npm 216 | 217 | # Optional eslint cache 218 | .eslintcache 219 | 220 | # Optional REPL history 221 | .node_repl_history 222 | 223 | # Output of 'npm pack' 224 | *.tgz 225 | 226 | # Yarn Integrity file 227 | .yarn-integrity 228 | 229 | # dotenv environment variables file 230 | .env 231 | .env.test 232 | 233 | # parcel-bundler cache (https://parceljs.org/) 234 | 235 | # next.js build output 236 | .next 237 | 238 | # nuxt.js build output 239 | .nuxt 240 | 241 | # rollup.js default build output 242 | 243 | # Uncomment the public line if your project uses Gatsby 244 | # https://nextjs.org/blog/next-9-1#public-directory-support 245 | # https://create-react-app.dev/docs/using-the-public-folder/#docsNav 246 | # public 247 | 248 | # Storybook build outputs 249 | .out 250 | .storybook-out 251 | 252 | # vuepress build output 253 | .vuepress/dist 254 | 255 | # Serverless directories 256 | .serverless/ 257 | 258 | # FuseBox cache 259 | .fusebox/ 260 | 261 | # DynamoDB Local files 262 | .dynamodb/ 263 | 264 | # Temporary folders 265 | tmp/ 266 | temp/ 267 | 268 | ### PyCharm ### 269 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 270 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 271 | 272 | # User-specific stuff 273 | .idea/**/workspace.xml 274 | .idea/**/tasks.xml 275 | .idea/**/usage.statistics.xml 276 | .idea/**/dictionaries 277 | .idea/**/shelf 278 | 279 | # Generated files 280 | .idea/**/contentModel.xml 281 | 282 | # Sensitive or high-churn files 283 | .idea/**/dataSources/ 284 | .idea/**/dataSources.ids 285 | .idea/**/dataSources.local.xml 286 | .idea/**/sqlDataSources.xml 287 | .idea/**/dynamic.xml 288 | .idea/**/uiDesigner.xml 289 | .idea/**/dbnavigator.xml 290 | 291 | # Gradle 292 | .idea/**/gradle.xml 293 | .idea/**/libraries 294 | 295 | # Gradle and Maven with auto-import 296 | # When using Gradle or Maven with auto-import, you should exclude module files, 297 | # since they will be recreated, and may cause churn. Uncomment if using 298 | # auto-import. 299 | # .idea/modules.xml 300 | # .idea/*.iml 301 | # .idea/modules 302 | # *.iml 303 | # *.ipr 304 | 305 | # CMake 306 | cmake-build-*/ 307 | 308 | # Mongo Explorer plugin 309 | .idea/**/mongoSettings.xml 310 | 311 | # File-based project format 312 | *.iws 313 | 314 | # IntelliJ 315 | out/ 316 | 317 | # mpeltonen/sbt-idea plugin 318 | .idea_modules/ 319 | 320 | # JIRA plugin 321 | atlassian-ide-plugin.xml 322 | 323 | # Cursive Clojure plugin 324 | .idea/replstate.xml 325 | 326 | # Crashlytics plugin (for Android Studio and IntelliJ) 327 | com_crashlytics_export_strings.xml 328 | crashlytics.properties 329 | crashlytics-build.properties 330 | fabric.properties 331 | 332 | # Editor-based Rest Client 333 | .idea/httpRequests 334 | 335 | # Android studio 3.1+ serialized cache file 336 | .idea/caches/build_file_checksums.ser 337 | 338 | ### PyCharm Patch ### 339 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 340 | 341 | # *.iml 342 | # modules.xml 343 | # .idea/misc.xml 344 | # *.ipr 345 | 346 | # Sonarlint plugin 347 | .idea/**/sonarlint/ 348 | 349 | # SonarQube Plugin 350 | .idea/**/sonarIssues.xml 351 | 352 | # Markdown Navigator plugin 353 | .idea/**/markdown-navigator.xml 354 | .idea/**/markdown-navigator/ 355 | 356 | ### Python ### 357 | # Byte-compiled / optimized / DLL files 358 | 359 | # C extensions 360 | 361 | # Distribution / packaging 362 | 363 | # PyInstaller 364 | # Usually these files are written by a python script from a template 365 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 366 | 367 | # Installer logs 368 | 369 | # Unit test / coverage reports 370 | 371 | # Translations 372 | 373 | # Scrapy stuff: 374 | 375 | # Sphinx documentation 376 | 377 | # PyBuilder 378 | 379 | # pyenv 380 | 381 | # pipenv 382 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 383 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 384 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 385 | # install all needed dependencies. 386 | 387 | # celery beat schedule file 388 | 389 | # SageMath parsed files 390 | 391 | # Spyder project settings 392 | 393 | # Rope project settings 394 | 395 | # Mr Developer 396 | 397 | # mkdocs documentation 398 | 399 | # mypy 400 | 401 | # Pyre type checker 402 | 403 | ### Sass ### 404 | .sass-cache/ 405 | *.css.map 406 | *.sass.map 407 | *.scss.map 408 | 409 | ### SublimeText ### 410 | # Cache files for Sublime Text 411 | *.tmlanguage.cache 412 | *.tmPreferences.cache 413 | *.stTheme.cache 414 | 415 | # Workspace files are user-specific 416 | *.sublime-workspace 417 | 418 | # Project files should be checked into the repository, unless a significant 419 | # proportion of contributors will probably not be using Sublime Text 420 | # *.sublime-project 421 | 422 | # SFTP configuration file 423 | sftp-config.json 424 | 425 | # Package control specific files 426 | Package Control.last-run 427 | Package Control.ca-list 428 | Package Control.ca-bundle 429 | Package Control.system-ca-bundle 430 | Package Control.cache/ 431 | Package Control.ca-certs/ 432 | Package Control.merged-ca-bundle 433 | Package Control.user-ca-bundle 434 | oscrypto-ca-bundle.crt 435 | bh_unicode_properties.cache 436 | 437 | # Sublime-github package stores a github token in this file 438 | # https://packagecontrol.io/packages/sublime-github 439 | GitHub.sublime-settings 440 | 441 | ### Vim ### 442 | # Swap 443 | [._]*.s[a-v][a-z] 444 | [._]*.sw[a-p] 445 | [._]s[a-rt-v][a-z] 446 | [._]ss[a-gi-z] 447 | [._]sw[a-p] 448 | 449 | # Session 450 | Session.vim 451 | Sessionx.vim 452 | 453 | # Temporary 454 | .netrwhist 455 | 456 | # Auto-generated tag files 457 | tags 458 | 459 | # Persistent undo 460 | [._]*.un~ 461 | 462 | # Coc configuration directory 463 | .vim 464 | 465 | ### VirtualEnv ### 466 | # Virtualenv 467 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 468 | pyvenv.cfg 469 | .venv 470 | env/ 471 | venv/ 472 | ENV/ 473 | env.bak/ 474 | venv.bak/ 475 | pip-selfcheck.json 476 | 477 | ### VisualStudioCode ### 478 | .vscode/* 479 | !.vscode/settings.json 480 | !.vscode/tasks.json 481 | !.vscode/launch.json 482 | !.vscode/extensions.json 483 | 484 | ### VisualStudioCode Patch ### 485 | # Ignore all local history of files 486 | .history 487 | 488 | ### Vuejs ### 489 | # Recommended template: Node.gitignore 490 | 491 | npm-debug.log 492 | yarn-error.log 493 | 494 | ### Windows ### 495 | # Windows thumbnail cache files 496 | Thumbs.db 497 | Thumbs.db:encryptable 498 | ehthumbs.db 499 | ehthumbs_vista.db 500 | 501 | # Dump file 502 | *.stackdump 503 | 504 | # Folder config file 505 | [Dd]esktop.ini 506 | 507 | # Recycle Bin used on file shares 508 | $RECYCLE.BIN/ 509 | 510 | # Windows Installer files 511 | *.cab 512 | *.msi 513 | *.msix 514 | *.msm 515 | *.msp 516 | 517 | # Windows shortcuts 518 | *.lnk 519 | 520 | # End of https://www.gitignore.io/api/vim,node,sass,vuejs,linux,macos,django,python,pycharm,windows,virtualenv,sublimetext,visualstudiocode 521 | 522 | 523 | *.sqlite 524 | data 525 | --------------------------------------------------------------------------------