├── tests
├── __init__.py
├── media
│ └── media-test.txt
├── static
│ ├── static-test.txt
│ ├── admin
│ │ └── admin-test.txt
│ └── appdir
│ │ └── appdir-test.txt
├── templates
│ ├── flatpage.html
│ └── humanize.html
├── i18n_urls.py
├── test_commands.py
├── namespaced_sub_urls.py
├── no_namespaced_urls.py
├── test_interface.py
├── namespaced_urls.py
├── settings.py
├── test_static.py
├── test_redirects.py
├── urls.py
└── test_renderer.py
├── MANIFEST.in
├── .github
├── FUNDING.yml
└── workflows
│ └── ci.yaml
├── django_distill
├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ ├── distill-test-publish.py
│ │ ├── distill-local.py
│ │ └── distill-publish.py
├── errors.py
├── __init__.py
├── distill.py
├── publisher.py
├── backends
│ ├── google_storage.py
│ ├── amazon_s3.py
│ ├── __init__.py
│ └── microsoft_azure_storage.py
└── renderer.py
├── requirements.txt
├── setup.cfg
├── Pipfile
├── run-tests.py
├── SECURITY.md
├── CODE_OF_CONDUCT.md
├── .gitignore
├── LICENSE
├── setup.py
├── CONTRIBUTING.md
└── README.md
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include *.txt
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [meeb]
2 |
--------------------------------------------------------------------------------
/django_distill/management/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/media/media-test.txt:
--------------------------------------------------------------------------------
1 | media
2 |
--------------------------------------------------------------------------------
/tests/static/static-test.txt:
--------------------------------------------------------------------------------
1 | test
2 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | django
2 | requests
3 |
--------------------------------------------------------------------------------
/tests/static/admin/admin-test.txt:
--------------------------------------------------------------------------------
1 | test
2 |
--------------------------------------------------------------------------------
/django_distill/management/commands/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/static/appdir/appdir-test.txt:
--------------------------------------------------------------------------------
1 | test
2 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | description-file = README.md
3 |
--------------------------------------------------------------------------------
/tests/templates/flatpage.html:
--------------------------------------------------------------------------------
1 |
{{ flatpage.title }}{{ flatpage.content }}
2 |
--------------------------------------------------------------------------------
/Pipfile:
--------------------------------------------------------------------------------
1 | [[source]]
2 | name = "pypi"
3 | url = "https://pypi.org/simple"
4 | verify_ssl = true
5 |
6 | [dev-packages]
7 |
8 | [packages]
9 | requests = "*"
10 | Django = "*"
11 |
--------------------------------------------------------------------------------
/django_distill/errors.py:
--------------------------------------------------------------------------------
1 | class DistillError(Exception):
2 | pass
3 |
4 |
5 | class DistillWarning(RuntimeWarning):
6 | pass
7 |
8 |
9 | class DistillPublishError(Exception):
10 | pass
11 |
--------------------------------------------------------------------------------
/tests/templates/humanize.html:
--------------------------------------------------------------------------------
1 | {% load humanize %}
2 |
3 | - test
4 | - one hour ago naturaltime: {{ one_hour_ago|naturaltime }}
5 | - nineteen hours ago naturaltime: {{ nineteen_hours_ago|naturaltime }}
6 |
7 |
--------------------------------------------------------------------------------
/run-tests.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 |
4 | import os
5 | import sys
6 | import django
7 | from django.conf import settings
8 | from django.test.utils import get_runner
9 |
10 |
11 | if __name__ == '__main__':
12 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings'
13 | django.setup()
14 | TestRunner = get_runner(settings)
15 | test_runner = TestRunner(verbosity=2)
16 | failures = test_runner.run_tests(['tests'])
17 | sys.exit(bool(failures))
18 |
--------------------------------------------------------------------------------
/tests/i18n_urls.py:
--------------------------------------------------------------------------------
1 | from django.http import HttpResponse
2 | from django_distill import distill_path
3 |
4 |
5 | app_name = 'i18n'
6 |
7 |
8 | def test_url_i18n_view(request):
9 | return HttpResponse(b'test', content_type='application/octet-stream')
10 |
11 |
12 | def test_no_param_func():
13 | return None
14 |
15 |
16 | urlpatterns = [
17 |
18 | distill_path('sub-url-with-i18n-prefix',
19 | test_url_i18n_view,
20 | name='test-url-i18n',
21 | distill_func=test_no_param_func),
22 |
23 | ]
24 |
--------------------------------------------------------------------------------
/tests/test_commands.py:
--------------------------------------------------------------------------------
1 | from importlib import import_module
2 | from django.test import TestCase
3 |
4 |
5 | class DjangoDistillCommandTestSuite(TestCase):
6 |
7 | def test_command_imports_distill_local(self):
8 | import_module('django_distill.management.commands.distill-local')
9 |
10 | def test_command_imports_distill_publish(self):
11 | import_module('django_distill.management.commands.distill-publish')
12 |
13 | def test_command_imports_distill_test_publish(self):
14 | import_module('django_distill.management.commands.distill-test-publish')
15 |
--------------------------------------------------------------------------------
/tests/namespaced_sub_urls.py:
--------------------------------------------------------------------------------
1 | from django.http import HttpResponse
2 | from django_distill import distill_path
3 |
4 |
5 | app_name = 'sub-urls'
6 |
7 |
8 | def test_url_in_deep_namespace_view(request):
9 | return HttpResponse(b'test', content_type='application/octet-stream')
10 |
11 |
12 | def test_no_param_func():
13 | return None
14 |
15 |
16 | urlpatterns = [
17 |
18 | distill_path('sub-url-in-sub-namespace',
19 | test_url_in_deep_namespace_view,
20 | name='test_url_in_namespace',
21 | distill_func=test_no_param_func,
22 | distill_file='test_url_in_sub_namespace'),
23 |
24 | ]
25 |
--------------------------------------------------------------------------------
/tests/no_namespaced_urls.py:
--------------------------------------------------------------------------------
1 | from django.http import HttpResponse
2 | from django_distill import distill_path
3 |
4 |
5 | app_name = 'no-namespaced-urls'
6 |
7 |
8 | def test_url_in_no_namespace_view(request):
9 | return HttpResponse(b'test', content_type='application/octet-stream')
10 |
11 |
12 | def test_no_param_func():
13 | return None
14 |
15 |
16 | urlpatterns = [
17 |
18 | distill_path('sub-url-in-no-namespace',
19 | test_url_in_no_namespace_view,
20 | name='test_url_in_no_namespace',
21 | distill_func=test_no_param_func,
22 | distill_file='test_url_in_no_namespace'),
23 |
24 | ]
25 |
--------------------------------------------------------------------------------
/tests/test_interface.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from django.conf import settings
3 | import django_distill
4 |
5 |
6 | class DjangoDistillInterfaceTestSuite(TestCase):
7 |
8 | def test_import(self):
9 | assert hasattr(django_distill, 'distill_url')
10 | assert callable(django_distill.distill_url)
11 | if settings.HAS_RE_PATH:
12 | assert hasattr(django_distill, 'distill_re_path')
13 | assert callable(django_distill.distill_re_path)
14 | if settings.HAS_PATH:
15 | assert hasattr(django_distill, 'distill_path')
16 | assert callable(django_distill.distill_path)
17 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | Typically, only the latest version of `django-distill` will be supported for
6 | security issues. Please upgrade to the latest release first before reporting
7 | any security issues.
8 |
9 | | Version | Supported |
10 | | ------- | ------------------ |
11 | | 3.2.4 | :white_check_mark: |
12 | | < 3.2.4 | :x: |
13 |
14 | ## Reporting a Vulnerability
15 |
16 | `django-distill` is a static site generator, as such any security issues are
17 | going to be inherently limited in scope. Please feel free to create a public
18 | GitHub issue if you encounter any unexpected or potentially security related
19 | issues.
20 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | name: Run Django tests for django-distill
2 | on:
3 | push:
4 | branches:
5 | - master
6 | pull_request:
7 | branches:
8 | - master
9 |
10 | jobs:
11 | build:
12 | runs-on: ubuntu-latest
13 | strategy:
14 | matrix:
15 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', 'pypy3.9', 'pypy3.10']
16 | steps:
17 | - uses: actions/checkout@v4
18 | - name: Install Python ${{ matrix.python-version }}
19 | uses: actions/setup-python@v5
20 | with:
21 | python-version: ${{ matrix.python-version }}
22 | - name: Install dependencies
23 | run: |
24 | python -m pip install --upgrade pip
25 | pip install -r requirements.txt
26 | - name: Run Django tests
27 | run: ./run-tests.py
28 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Code of Conduct
2 |
3 | The maintainers of this project will treat everyone equally and fairly
4 | and expect anyone interacting with the project to do the same. Use common
5 | sense, that's about it.
6 |
7 | All interactions that are a net positive for the project are encouraged.
8 | Any negative actions or behaviour deemed innapropriate will be ignored
9 | or deleted.
10 |
11 | This code of conduct applies to this repository and related GitHub hosted
12 | services as well as emails addressed to the maintainers concerning this
13 | project.
14 |
15 | We've never had an issue with conduct or contributor behaviour, however
16 | it appears mandatory to now have a code of conduct and various services
17 | and sites won't stop bugging us about it.
18 |
19 | If anything really bad occurs the worst that will happen is we delete
20 | some comments or a few issues.
21 |
--------------------------------------------------------------------------------
/tests/namespaced_urls.py:
--------------------------------------------------------------------------------
1 | from django.http import HttpResponse
2 | from django_distill import distill_path
3 | from django.urls import path, include
4 |
5 |
6 | app_name = 'namespaced-urls'
7 |
8 |
9 | def test_url_in_namespace_view(request):
10 | return HttpResponse(b'test', content_type='application/octet-stream')
11 |
12 |
13 | def test_no_param_func():
14 | return None
15 |
16 |
17 | urlpatterns = [
18 |
19 | distill_path('sub-url-in-namespace',
20 | test_url_in_namespace_view,
21 | name='test_url_in_namespace',
22 | distill_func=test_no_param_func,
23 | distill_file='test_url_in_namespace'),
24 | path('path/sub-namespace/',
25 | include('tests.namespaced_sub_urls', namespace='sub_test_namespace')),
26 | # Uncomment to trigger a DistillError for including the same sub-urls more than
27 | # once in a single project which is unsupported
28 | #path('path/sub-namespace1/',
29 | # include('tests.namespaced_sub_urls', namespace='sub_test_namespace1')),
30 |
31 | ]
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 |
5 | # C extensions
6 | *.so
7 |
8 | # Distribution / packaging
9 | .Python
10 | env/
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | *.egg-info/
23 | .installed.cfg
24 | *.egg
25 |
26 | # PyInstaller
27 | # Usually these files are written by a python script from a template
28 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
29 | *.manifest
30 | *.spec
31 |
32 | # Installer logs
33 | pip-log.txt
34 | pip-delete-this-directory.txt
35 |
36 | # Unit test / coverage reports
37 | htmlcov/
38 | .tox/
39 | .coverage
40 | .coverage.*
41 | .cache
42 | nosetests.xml
43 | coverage.xml
44 | *,cover
45 |
46 | # Translations
47 | *.mo
48 | *.pot
49 |
50 | # Django stuff:
51 | *.log
52 |
53 | # Sphinx documentation
54 | docs/_build/
55 |
56 | # PyBuilder
57 | target/
58 |
59 | # IDE
60 | .vscode
61 | .idea
62 |
63 | # Pipenv lockfile
64 | Pipfile.lock
65 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2020 meeb@meeb.org
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/django_distill/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = '3.2.7'
2 |
3 |
4 | from django import __version__ as django_version
5 | from django_distill.errors import DistillError
6 | from django_distill.distill import urls_to_distill
7 | from django_distill.renderer import generate_urls
8 |
9 |
10 | try:
11 | from django_distill.distill import distill_url
12 | except ImportError:
13 | def distill_url(*args, **kwargs):
14 | err = ('Your installed version of Django ({}) does not supprt '
15 | 'django.urls.url or django.conf.urls.url, use '
16 | 'django_distill.distill_re_path or django_distill.distill_path')
17 | raise DistillError(err.format(django_version))
18 |
19 |
20 | try:
21 | from django_distill.distill import distill_re_path
22 | except ImportError:
23 | def distill_re_path(*args, **kwargs):
24 | err = ('Your installed version of Django ({}) does not supprt '
25 | 'django.urls.re_path, please upgrade')
26 | raise DistillError(err.format(django_version))
27 |
28 |
29 | try:
30 | from django_distill.distill import distill_path
31 | except ImportError:
32 | def distill_path(*args, **kwargs):
33 | err = ('Your installed version of Django ({}) does not supprt '
34 | 'django.urls.path, please upgrade')
35 | raise DistillError(err.format(django_version))
36 |
37 |
38 | def distilled_urls():
39 | return generate_urls(urls_to_distill)
40 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 |
4 | BASE_DIR = Path(__file__).resolve().parent.parent
5 |
6 |
7 | try:
8 | from django.urls import path
9 | HAS_PATH = True
10 | except ImportError:
11 | HAS_PATH = False
12 |
13 |
14 | try:
15 | from django.urls import re_path
16 | HAS_RE_PATH = True
17 | except ImportError:
18 | HAS_RE_PATH = False
19 |
20 |
21 | SECRET_KEY = 'test'
22 |
23 |
24 | ROOT_URLCONF = 'tests.urls'
25 |
26 |
27 | MIDDLEWARE = ['django.contrib.sessions.middleware.SessionMiddleware']
28 |
29 |
30 | DATABASES = {
31 | 'default': {
32 | 'ENGINE': 'django.db.backends.sqlite3',
33 | 'NAME': BASE_DIR / 'test.sqlite3',
34 | }
35 | }
36 |
37 |
38 | INSTALLED_APPS = [
39 | 'django.contrib.sites',
40 | 'django.contrib.flatpages',
41 | 'django.contrib.sessions',
42 | 'django.contrib.sitemaps',
43 | 'django.contrib.humanize',
44 | 'django.contrib.redirects',
45 | 'django_distill',
46 | 'tests',
47 | ]
48 |
49 |
50 | TEMPLATES = [
51 | {
52 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
53 | 'DIRS': [BASE_DIR / 'tests' / 'templates'],
54 | 'APP_DIRS': True,
55 | },
56 | ]
57 |
58 |
59 | SITE_ID = 1
60 |
61 |
62 | STATIC_URL = '/static/'
63 | STATIC_ROOT = BASE_DIR / 'tests' / 'static'
64 | MEDIA_URL = '/media/'
65 | MEDIA_ROOT = BASE_DIR / 'tests' / 'media'
66 |
67 |
68 | LANGUAGE_CODE = 'en'
69 | USE_I18N = True
70 |
--------------------------------------------------------------------------------
/django_distill/distill.py:
--------------------------------------------------------------------------------
1 | from django_distill.errors import DistillError
2 |
3 |
4 | urls_to_distill = []
5 |
6 |
7 | def _distill_url(func, *a, **k):
8 | distill_func = k.get('distill_func')
9 | if distill_func:
10 | del k['distill_func']
11 | else:
12 | distill_func = lambda: None
13 | distill_file = k.get('distill_file')
14 | distill_status_codes = k.get('distill_status_codes')
15 | if distill_file:
16 | del k['distill_file']
17 | if distill_status_codes:
18 | del k['distill_status_codes']
19 | else:
20 | distill_status_codes = (200,)
21 | name = k.get('name')
22 | if not name:
23 | raise DistillError('Distill function provided with no name')
24 | if not callable(distill_func):
25 | err = 'Distill function not callable: {}'
26 | raise DistillError(err.format(distill_func))
27 | url = func(*a, **k)
28 | urls_to_distill.append((url, distill_func, distill_file, distill_status_codes,
29 | name, a, k))
30 | return url
31 |
32 |
33 | try:
34 | from django.conf.urls import url
35 | def distill_url(*a, **k):
36 | return _distill_url(url, *a, **k)
37 | except ImportError:
38 | try:
39 | from django.urls import url
40 | def distill_url(*a, **k):
41 | return _distill_url(url, *a, **k)
42 | except ImportError:
43 | pass
44 |
45 |
46 | try:
47 | from django.urls import path
48 | def distill_path(*a, **k):
49 | return _distill_url(path, *a, **k)
50 | except ImportError:
51 | pass
52 |
53 |
54 | try:
55 | from django.urls import re_path
56 | def distill_re_path(*a, **k):
57 | return _distill_url(re_path, *a, **k)
58 | except ImportError:
59 | pass
60 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | from setuptools import setup, find_packages
4 |
5 |
6 | version = '3.2.7'
7 |
8 |
9 | with open('README.md', 'rt') as f:
10 | long_description = f.read()
11 |
12 |
13 | with open('requirements.txt', 'rt') as f:
14 | requirements = tuple(f.read().split())
15 |
16 |
17 | setup(
18 | name = 'django-distill',
19 | version = version,
20 | url = 'https://github.com/meeb/django-distill',
21 | author = 'https://github.com/meeb',
22 | author_email = 'meeb@meeb.org',
23 | description = 'Static site renderer and publisher for Django.',
24 | long_description = long_description,
25 | long_description_content_type = 'text/markdown',
26 | license = 'MIT',
27 | include_package_data = True,
28 | install_requires = requirements,
29 | extras_require = {
30 | 'amazon': ['boto3'],
31 | 'google': ['google-api-python-client', 'google-cloud-storage'],
32 | 'microsoft': ['azure-storage-blob'],
33 | },
34 | packages = find_packages(),
35 | classifiers = [
36 | 'Development Status :: 5 - Production/Stable',
37 | 'Environment :: Web Environment',
38 | 'Intended Audience :: Developers',
39 | 'License :: OSI Approved :: MIT License',
40 | 'Operating System :: OS Independent',
41 | 'Programming Language :: Python',
42 | 'Programming Language :: Python :: 3',
43 | 'Programming Language :: Python :: 3.5',
44 | 'Programming Language :: Python :: 3.6',
45 | 'Topic :: Internet :: WWW/HTTP',
46 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
47 | 'Topic :: Software Development :: Libraries :: Application Frameworks',
48 | 'Topic :: Software Development :: Libraries :: Python Modules',
49 | ],
50 | keywords = ['django', 'distill', 'static', 'website', 'jamstack', 's3',
51 | 'amazon s3', 'aws', 'amazon', 'google', 'microsoft',
52 | 'google cloud', 'google cloud storage', 'azure',
53 | 'azure storage', 'azure blob storage'],
54 | )
55 |
--------------------------------------------------------------------------------
/tests/test_static.py:
--------------------------------------------------------------------------------
1 | import os
2 | from pathlib import Path
3 | from tempfile import TemporaryDirectory
4 | from django.test import TestCase
5 | from django.conf import settings
6 | from django_distill.renderer import copy_static_and_media_files
7 |
8 |
9 | def null(*args, **kwargs):
10 | pass
11 |
12 |
13 | class DjangoDistillStaticFilesTestSuite(TestCase):
14 |
15 | def test_copying_static_files(self):
16 | # Test default behavior
17 | with TemporaryDirectory() as tempdir:
18 | copy_static_and_media_files(tempdir, null)
19 | test_media_file_path = str(Path(tempdir) / 'media' / 'media-test.txt')
20 | self.assertTrue(os.path.exists(test_media_file_path))
21 | test_static_file_path = str(Path(tempdir) / 'static' / 'static-test.txt')
22 | self.assertTrue(os.path.exists(test_static_file_path))
23 | # Test admin dir is copied with SKIP_ADMIN_DIRS set to False
24 | settings.DISTILL_SKIP_ADMIN_DIRS = False
25 | with TemporaryDirectory() as tempdir:
26 | copy_static_and_media_files(tempdir, null)
27 | test_media_file_path = str(Path(tempdir) / 'media' / 'media-test.txt')
28 | self.assertTrue(os.path.exists(test_media_file_path))
29 | test_static_file_path = str(Path(tempdir) / 'static' / 'static-test.txt')
30 | self.assertTrue(os.path.exists(test_static_file_path))
31 | test_admin_file_path = str(Path(tempdir) / 'static' / 'admin' / 'admin-test.txt')
32 | self.assertTrue(os.path.exists(test_admin_file_path))
33 | test_appdir_file_path = str(Path(tempdir) / 'static' / 'appdir' / 'appdir-test.txt')
34 | self.assertTrue(os.path.exists(test_appdir_file_path))
35 | # Test static app dirs are skipped with DISTILL_SKIP_STATICFILES_DIRS populated
36 | settings.DISTILL_SKIP_STATICFILES_DIRS = ['appdir']
37 | with TemporaryDirectory() as tempdir:
38 | copy_static_and_media_files(tempdir, null)
39 | test_appdir_file_path = str(Path(tempdir) / 'static' / 'appdir' / 'appdir-test.txt')
40 | self.assertFalse(os.path.exists(test_appdir_file_path))
41 |
--------------------------------------------------------------------------------
/django_distill/publisher.py:
--------------------------------------------------------------------------------
1 | from concurrent.futures import ThreadPoolExecutor
2 | from django_distill.errors import DistillPublishError
3 |
4 |
5 | def publish_dir(backend, stdout, verify=True, parallel_publish=1, ignore_remote_content=False):
6 | stdout('Authenticating')
7 | backend.authenticate()
8 | stdout('Getting file indexes')
9 | remote_files = set() if ignore_remote_content else backend.list_remote_files()
10 | local_files = backend.list_local_files()
11 | to_upload = set()
12 | to_delete = set()
13 | local_files_r = set()
14 | # check local files to upload
15 | for f in local_files:
16 | remote_f = backend.remote_path(f)
17 | local_files_r.add(remote_f)
18 | if remote_f not in remote_files:
19 | # local file not present remotely, upload it
20 | to_upload.add(f)
21 | else:
22 | # file is present remotely, check its hash
23 | if not backend.compare_file(f, remote_f):
24 | stdout(f'File stale (hash different): {remote_f}')
25 | to_upload.add(f)
26 | else:
27 | stdout(f'File fresh: {remote_f}')
28 | # check for remote files to delete
29 | for f in remote_files:
30 | if f not in local_files_r:
31 | to_delete.add(f)
32 | with ThreadPoolExecutor(max_workers=parallel_publish) as executor:
33 | # upload any new or changed files
34 | executor.map(lambda f: _publish_file(backend, f, verify, stdout), to_upload)
35 | # Call any final checks that may be needed by the backend
36 | stdout('Final checks')
37 | backend.final_checks()
38 | # delete any orphan files
39 | executor.map(lambda f: _delete_file(backend, f, stdout), to_delete)
40 |
41 |
42 | def _publish_file(backend, f, verify, stdout):
43 | remote_f = backend.remote_path(f)
44 | stdout(f'Publishing: {f} -> {remote_f}')
45 | backend.upload_file(f, remote_f)
46 | if verify:
47 | url = backend.remote_url(f)
48 | stdout(f'Verifying: {url}')
49 | if not backend.check_file(f, url):
50 | err = f'Remote file {url} failed hash check'
51 | raise DistillPublishError(err)
52 |
53 |
54 | def _delete_file(backend, f, stdout):
55 | stdout(f'Deleting remote: {f}')
56 | backend.delete_remote_file(f)
57 |
--------------------------------------------------------------------------------
/django_distill/backends/google_storage.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import warnings
4 | from base64 import b64decode
5 | from binascii import hexlify
6 |
7 |
8 | try:
9 | from googleapiclient import discovery
10 | from google.cloud import storage
11 | except ImportError:
12 | name = 'django_distill.backends.google_storage'
13 | pipm = 'google-api-python-client google-cloud-storage'
14 | sys.stdout.write('{} backend requires {}:\n'.format(name, pipm))
15 | sys.stdout.write('$ pip install django-distill[google]{}\n\n'.format(pipm))
16 | raise
17 |
18 |
19 | from django_distill.errors import DistillPublishError
20 | from django_distill.backends import BackendBase
21 |
22 |
23 | class GoogleCloudStorageBackend(BackendBase):
24 | '''
25 | Publisher for Google Cloud Storage. Implements the BackendBase.
26 | '''
27 |
28 | REQUIRED_OPTIONS = ('ENGINE', 'BUCKET')
29 |
30 | def account_username(self):
31 | return
32 |
33 | def account_container(self):
34 | return self.options.get('BUCKET', '')
35 |
36 | def authenticate(self):
37 | credentials_file = self.options.get('JSON_CREDENTIALS', '')
38 | if credentials_file:
39 | if not os.path.exists(credentials_file):
40 | err = 'Credentials file does not exist: {}'
41 | raise DistillPublishError(err.format(credentials_file))
42 | os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = credentials_file
43 |
44 | bucket = self.account_container()
45 | self.d['connection'] = storage.Client()
46 | self.d['bucket'] = self.d['connection'].get_bucket(bucket)
47 |
48 | def list_remote_files(self):
49 | rtn = set()
50 | for b in self.d['bucket'].list_blobs():
51 | rtn.add(b.name)
52 | return rtn
53 |
54 | def delete_remote_file(self, remote_name):
55 | b = self.d['bucket'].get_blob(remote_name)
56 | return b.delete()
57 |
58 | def compare_file(self, local_name, remote_name):
59 | b = self.d['bucket'].get_blob(remote_name)
60 | local_hash = self._get_local_file_hash(local_name)
61 | remote_hash = str(hexlify(b64decode(b.md5_hash)).decode())
62 | return local_hash == remote_hash
63 |
64 | def upload_file(self, local_name, remote_name):
65 | b = self.d['bucket'].blob(remote_name)
66 | b.upload_from_filename(local_name)
67 | b.make_public()
68 | return True
69 |
70 | def create_remote_dir(self, remote_dir_name):
71 | # not required for Google Storage buckets
72 | return True
73 |
74 | def remote_path(self, local_name):
75 | truncated_path = super().remote_path(local_name)
76 | # Replace \ for /, Google Cloud Files API docs state they handle both \ and /
77 | # as directory separators but really make sure we're only using / in blob names
78 | return truncated_path.replace('\\', '/')
79 |
80 |
81 | backend_class = GoogleCloudStorageBackend
82 |
--------------------------------------------------------------------------------
/django_distill/backends/amazon_s3.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import mimetypes
3 |
4 | try:
5 | import boto3
6 | except ImportError:
7 | name = 'django_distill.backends.amazon_s3'
8 | pipm = 'boto3'
9 | sys.stdout.write('{} backend requires {}:\n'.format(name, pipm))
10 | sys.stdout.write('$ pip install django-distill[amazon]{}\n\n'.format(pipm))
11 | raise
12 |
13 | from django_distill.errors import DistillPublishError
14 | from django_distill.backends import BackendBase
15 |
16 |
17 | class AmazonS3Backend(BackendBase):
18 | '''
19 | Publisher for Amazon S3. Implements the BackendBase.
20 | '''
21 |
22 | REQUIRED_OPTIONS = ('ENGINE', 'PUBLIC_URL', 'BUCKET')
23 |
24 | def _get_object(self, name):
25 | bucket = self.account_container()
26 | return self.d['connection'].head_object(Bucket=bucket, Key=name)
27 |
28 | def account_username(self):
29 | return self.options.get('ACCESS_KEY_ID', '')
30 |
31 | def account_container(self):
32 | return self.options.get('BUCKET', '')
33 |
34 | def authenticate(self, calling_format=None):
35 | access_key_id = self.account_username()
36 | secret_access_key = self.options.get('SECRET_ACCESS_KEY', '')
37 | endpoint_url = self.options.get('ENDPOINT_URL', None)
38 | bucket = self.account_container()
39 | if access_key_id and secret_access_key:
40 | self.d['connection'] = boto3.client('s3', aws_access_key_id=access_key_id,
41 | aws_secret_access_key=secret_access_key,
42 | endpoint_url=endpoint_url)
43 | else:
44 | self.d['connection'] = boto3.client('s3')
45 | self.d['bucket'] = bucket
46 |
47 | def list_remote_files(self):
48 | rtn = set()
49 | response = self.d['connection'].list_objects_v2(Bucket=self.d['bucket'])
50 | if 'Contents' in response:
51 | for obj in response['Contents']:
52 | rtn.add(obj['Key'])
53 | return rtn
54 |
55 | def delete_remote_file(self, remote_name):
56 | self.d['connection'].delete_object(Bucket=self.d['bucket'], Key=remote_name)
57 |
58 | def compare_file(self, local_name, remote_name):
59 | obj = self._get_object(remote_name)
60 | local_hash = self._get_local_file_hash(local_name)
61 | return local_hash == obj['ETag'][1:-1]
62 |
63 | def upload_file(self, local_name, remote_name):
64 | default_content_type = self.options.get('DEFAULT_CONTENT_TYPE', 'application/octet-stream')
65 | content_type, _ = mimetypes.guess_type(local_name)
66 | if content_type is None:
67 | content_type = default_content_type
68 | extra_args = {'ContentType': content_type}
69 | self.d['connection'].upload_file(local_name, self.d['bucket'], remote_name, ExtraArgs=extra_args)
70 |
71 | def create_remote_dir(self, remote_dir_name):
72 | # not required for S3 buckets
73 | return True
74 |
75 | backend_class = AmazonS3Backend
76 |
--------------------------------------------------------------------------------
/tests/test_redirects.py:
--------------------------------------------------------------------------------
1 | import os
2 | from pathlib import Path
3 | from tempfile import TemporaryDirectory
4 | from django.test import TestCase
5 | from django.contrib.redirects.models import Redirect
6 | from django_distill.renderer import render_static_redirect, render_redirects
7 |
8 |
9 | def null(*args, **kwargs):
10 | pass
11 |
12 |
13 | class DjangoDistillRedirectsTestSuite(TestCase):
14 |
15 | def test_template(self):
16 | test_url = 'https://example.com/'
17 | test_template = render_static_redirect(test_url)
18 | expected_template = ('\n'
19 | '\n'
20 | '\n'
21 | '\n'
22 | '\n'
23 | 'Redirecting to https://example.com/\n'
24 | '\n'
25 | '\n'
26 | '\n'
27 | '\n'
28 | 'If you are not automatically redirected please click this link
\n'
29 | '')
30 | self.assertEqual(test_template, expected_template.encode())
31 |
32 | def test_redirects(self):
33 | # Create some test redirects
34 | Redirect.objects.create(site_id=1, old_path='/redirect-from1/', new_path='/redirect-to1/')
35 | Redirect.objects.create(site_id=1, old_path='/redirect-from2/', new_path='/redirect-to2/')
36 | Redirect.objects.create(site_id=1, old_path='/redirect-from3/', new_path='/redirect-to3/')
37 | Redirect.objects.create(site_id=1, old_path='/redirect-from4/test.html', new_path='/redirect-to4/test.html')
38 | Redirect.objects.create(site_id=1, old_path='/redirect-from5/noslash', new_path='/redirect-to5/noslash')
39 | Redirect.objects.create(site_id=1, old_path='/redirect-from6/deep/redirect/path/', new_path='/redirect-to6/')
40 | # Render the redirect templates
41 | with TemporaryDirectory() as tempdir:
42 | render_redirects(tempdir, null)
43 | # Test the redirect templates exist
44 | for redirect in Redirect.objects.all():
45 | redirect_path = redirect.old_path.lstrip('/')
46 | if redirect_path.lower().endswith('.html'):
47 | test_file_path = str(Path(tempdir) / redirect_path)
48 | else:
49 | test_file_path = str(Path(tempdir) / redirect_path / 'index.html')
50 | self.assertTrue(os.path.exists(test_file_path))
51 | with open(test_file_path, 'rb') as f:
52 | test_file_contents = f.read()
53 | expected_file_contents = render_static_redirect(redirect.new_path)
54 | self.assertEqual(test_file_contents, expected_file_contents)
55 |
--------------------------------------------------------------------------------
/django_distill/management/commands/distill-test-publish.py:
--------------------------------------------------------------------------------
1 | import os
2 | from binascii import hexlify
3 | from tempfile import NamedTemporaryFile
4 | from django.conf import settings
5 | from django.core.management.base import (BaseCommand, CommandError)
6 | from django_distill.backends import get_backend
7 |
8 |
9 | class Command(BaseCommand):
10 |
11 | help = 'Tests a distill publishing target'
12 |
13 | def add_arguments(self, parser):
14 | parser.add_argument('publish_target_name', nargs='?', type=str)
15 |
16 | def handle(self, *args, **options):
17 | publish_target_name = options.get('publish_target_name')
18 | if not publish_target_name:
19 | publish_target_name = 'default'
20 | publish_targets = getattr(settings, 'DISTILL_PUBLISH', {})
21 | publish_target = publish_targets.get(publish_target_name)
22 | if type(publish_target) != dict:
23 | e = 'Invalid publish target name: "{}"'.format(publish_target_name)
24 | e += ', check your settings.DISTILL_PUBLISH values'
25 | raise CommandError(e)
26 | publish_engine = publish_target.get('ENGINE')
27 | if not publish_engine:
28 | e = 'Publish target {} has no ENGINE'.format(publish_target_name)
29 | raise CommandError(e)
30 | self.stdout.write('')
31 | self.stdout.write('You have requested to test a publishing target:')
32 | self.stdout.write('')
33 | self.stdout.write(' Name: {}'.format(publish_target_name))
34 | self.stdout.write(' Engine: {}'.format(publish_engine))
35 | self.stdout.write('')
36 | ans = input('Type \'yes\' to continue, or \'no\' to cancel: ')
37 | if ans.lower() == 'yes':
38 | self.stdout.write('')
39 | self.stdout.write('Testing publishing target...')
40 | else:
41 | raise CommandError('Testing publishing target cancelled.')
42 | self.stdout.write('')
43 | self.stdout.write('Connecting to backend engine')
44 | backend_class = get_backend(publish_engine)
45 | random_file = NamedTemporaryFile(delete=False)
46 | random_str = hexlify(os.urandom(16))
47 | random_file.write(random_str)
48 | random_file.close()
49 | backend = backend_class(os.path.dirname(random_file.name),
50 | publish_target)
51 | self.stdout.write('Authenticating')
52 | backend.authenticate()
53 | remote_file_name = os.path.basename(random_file.name)
54 | self.stdout.write('Uploading test file: {}'.format(random_file.name))
55 | backend.upload_file(random_file.name, remote_file_name)
56 | url = backend.remote_url(random_file.name)
57 | self.stdout.write('Verifying remote test file: {}'.format(url))
58 | if backend.check_file(random_file.name, url):
59 | self.stdout.write('File uploaded correctly, file hash is correct')
60 | else:
61 | msg = 'File error, remote file hash differs from local hash'
62 | self.stderr.write(msg)
63 | self.stdout.write('Final checks')
64 | backend.final_checks()
65 | self.stdout.write('Deleting remote test file')
66 | backend.delete_remote_file(remote_file_name)
67 | if os.path.exists(random_file.name):
68 | self.stdout.write('Deleting local test file')
69 | os.unlink(random_file.name)
70 | self.stdout.write('')
71 | self.stdout.write('Backend testing complete.')
72 |
--------------------------------------------------------------------------------
/django_distill/management/commands/distill-local.py:
--------------------------------------------------------------------------------
1 | import os
2 | from shutil import rmtree
3 | from django.core.management.base import (BaseCommand, CommandError)
4 | from django.conf import settings
5 | from django_distill.distill import urls_to_distill
6 | from django_distill.renderer import (run_collectstatic, render_to_dir,
7 | copy_static_and_media_files, render_redirects)
8 | from django_distill.errors import DistillError
9 |
10 |
11 | class Command(BaseCommand):
12 |
13 | help = 'Generates a static local site using distill'
14 |
15 | def add_arguments(self, parser):
16 | parser.add_argument('output_dir', nargs='?', type=str)
17 | parser.add_argument('--collectstatic', dest='collectstatic', action='store_true')
18 | parser.add_argument('--quiet', dest='quiet', action='store_true')
19 | parser.add_argument('--force', dest='force', action='store_true')
20 | parser.add_argument('--exclude-staticfiles', dest='exclude_staticfiles', action='store_true')
21 | parser.add_argument('--generate-redirects', dest='generate_redirects', action='store_true')
22 | parser.add_argument('--parallel-render', dest='parallel_render', type=int, default=1)
23 |
24 | def _quiet(self, *args, **kwargs):
25 | pass
26 |
27 | def handle(self, *args, **options):
28 | output_dir = options.get('output_dir')
29 | collectstatic = options.get('collectstatic')
30 | quiet = options.get('quiet')
31 | force = options.get('force')
32 | exclude_staticfiles = options.get('exclude_staticfiles')
33 | generate_redirects = options.get('generate_redirects')
34 | parallel_render = options.get('parallel_render')
35 | if quiet:
36 | stdout = self._quiet
37 | else:
38 | stdout = self.stdout.write
39 | if not output_dir:
40 | output_dir = getattr(settings, 'DISTILL_DIR', None)
41 | if not output_dir:
42 | e = 'Usage: ./manage.py distill-local [directory]'
43 | raise CommandError(e)
44 | if collectstatic:
45 | run_collectstatic(stdout)
46 | if not exclude_staticfiles and not os.path.isdir(settings.STATIC_ROOT):
47 | e = 'Static source directory does not exist, run collectstatic'
48 | raise CommandError(e)
49 | output_dir = os.path.abspath(os.path.expanduser(output_dir))
50 | stdout('')
51 | stdout('You have requested to create a static version of')
52 | stdout('this site into the output path directory:')
53 | stdout('')
54 | stdout(' Source static path: {}'.format(settings.STATIC_ROOT))
55 | stdout(' Distill output path: {}'.format(output_dir))
56 | stdout('')
57 | if os.path.isdir(output_dir):
58 | stdout('Distill output directory exists, clean up?')
59 | stdout('This will delete and recreate all files in the output dir')
60 | stdout('')
61 | if force:
62 | ans = 'yes'
63 | else:
64 | ans = input('Type \'yes\' to continue, or \'no\' to cancel: ')
65 | if ans.lower() == 'yes':
66 | stdout('Recreating output directory...')
67 | rmtree(output_dir)
68 | os.makedirs(output_dir)
69 | else:
70 | raise CommandError('Distilling site cancelled.')
71 | else:
72 | if force:
73 | ans = 'yes'
74 | else:
75 | ans = input('Does not exist, create it? (YES/no): ')
76 | if ans.lower() == 'yes':
77 | stdout('Creating directory...')
78 | os.makedirs(output_dir)
79 | else:
80 | raise CommandError('Aborting...')
81 | stdout('')
82 | stdout('Generating static site into directory: {}'.format(output_dir))
83 | try:
84 | render_to_dir(output_dir, urls_to_distill, stdout, parallel_render=parallel_render)
85 | if not exclude_staticfiles:
86 | copy_static_and_media_files(output_dir, stdout)
87 | except DistillError as err:
88 | raise CommandError(str(err)) from err
89 | stdout('')
90 | if generate_redirects:
91 | stdout('Generating redirects')
92 | render_redirects(output_dir, stdout)
93 | stdout('')
94 | stdout('Site generation complete.')
95 |
--------------------------------------------------------------------------------
/django_distill/management/commands/distill-publish.py:
--------------------------------------------------------------------------------
1 | import os
2 | import tempfile
3 | from django.conf import settings
4 | from django.core.management.base import (BaseCommand, CommandError)
5 | from django_distill.backends import get_backend
6 | from django_distill.distill import urls_to_distill
7 | from django_distill.errors import DistillError
8 | from django_distill.renderer import (run_collectstatic, render_to_dir,
9 | copy_static_and_media_files, render_redirects)
10 | from django_distill.publisher import publish_dir
11 |
12 |
13 | class Command(BaseCommand):
14 |
15 | help = 'Distills a site into a temporary local directory then publishes it'
16 |
17 | def add_arguments(self, parser):
18 | parser.add_argument('publish_target_name', nargs='?', type=str)
19 | parser.add_argument('--collectstatic', dest='collectstatic',
20 | action='store_true')
21 | parser.add_argument('--quiet', dest='quiet', action='store_true')
22 | parser.add_argument('--force', dest='force', action='store_true')
23 | parser.add_argument('--exclude-staticfiles', dest='exclude_staticfiles',
24 | action='store_true')
25 | parser.add_argument('--skip-verify', dest='skip_verify', action='store_true')
26 | parser.add_argument('--ignore-remote-content', dest='ignore_remote_content', action='store_true')
27 | parser.add_argument('--parallel-publish', dest='parallel_publish', type=int, default=1)
28 | parser.add_argument('--generate-redirects', dest='generate_redirects', action='store_true')
29 | parser.add_argument('--parallel-render', dest='parallel_render', type=int, default=1)
30 |
31 | def _quiet(self, *args, **kwargs):
32 | pass
33 |
34 | def handle(self, *args, **options):
35 | publish_target_name = options.get('publish_target_name')
36 | if not publish_target_name:
37 | publish_target_name = 'default'
38 | publish_targets = getattr(settings, 'DISTILL_PUBLISH', {})
39 | publish_target = publish_targets.get(publish_target_name)
40 | if type(publish_target) != dict:
41 | e = 'Invalid publish target name: "{}"'.format(publish_target_name)
42 | e += ', check your settings.DISTILL_PUBLISH values'
43 | raise CommandError(e)
44 | publish_engine = publish_target.get('ENGINE')
45 | if not publish_engine:
46 | e = 'Publish target {} has no ENGINE'.format(publish_target_name)
47 | raise CommandError(e)
48 | collectstatic = options.get('collectstatic')
49 | exclude_staticfiles = options.get('exclude_staticfiles')
50 | skip_verify = options.get('skip_verify', False)
51 | parallel_publish = options.get('parallel_publish')
52 | ignore_remote_content = options.get('ignore_remote_content', False)
53 | quiet = options.get('quiet')
54 | force = options.get('force')
55 | generate_redirects = options.get('generate_redirects')
56 | parallel_render = options.get('parallel_render')
57 | if quiet:
58 | stdout = self._quiet
59 | else:
60 | stdout = self.stdout.write
61 | with tempfile.TemporaryDirectory() as output_dir:
62 | if not output_dir.endswith(os.sep):
63 | output_dir += os.sep
64 | backend_class = get_backend(publish_engine)
65 | backend = backend_class(output_dir, publish_target)
66 | username = backend.account_username()
67 | container = backend.account_container()
68 | stdout('')
69 | stdout('You have requested to distill and publish this site')
70 | stdout('to the following target:')
71 | stdout('')
72 | stdout(' Settings name: {}'.format(publish_target_name))
73 | stdout(' Engine: {}'.format(publish_engine))
74 | stdout(' Username: {}'.format(username))
75 | stdout(' Container: {}'.format(container))
76 | stdout('')
77 | if collectstatic:
78 | run_collectstatic(stdout)
79 | if not exclude_staticfiles and not os.path.isdir(settings.STATIC_ROOT):
80 | e = 'Static source directory does not exist, run collectstatic'
81 | raise CommandError(e)
82 | if force:
83 | ans = 'yes'
84 | else:
85 | ans = input('Type \'yes\' to continue, or \'no\' to cancel: ')
86 | if ans.lower() == 'yes':
87 | pass
88 | else:
89 | raise CommandError('Publishing site cancelled.')
90 | self.stdout.write('')
91 | msg = 'Generating static site into directory: {}'
92 | stdout(msg.format(output_dir))
93 | try:
94 | render_to_dir(output_dir, urls_to_distill, stdout, parallel_render=parallel_render)
95 | if not exclude_staticfiles:
96 | copy_static_and_media_files(output_dir, stdout)
97 | except DistillError as err:
98 | raise CommandError(str(err)) from err
99 | stdout('')
100 | if generate_redirects:
101 | stdout('Generating redirects')
102 | render_redirects(output_dir, stdout)
103 | stdout('')
104 | stdout('Publishing site')
105 | backend.index_local_files()
106 | publish_dir(backend, stdout, not skip_verify, parallel_publish, ignore_remote_content)
107 | stdout('')
108 | stdout('Site generation and publishing complete.')
109 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to django-distill
2 |
3 | Thank you for showing an interest in contributing to `django-distill`!
4 |
5 |
6 | ## Code of Conduct
7 |
8 | This project and everyone participating in it is governed by the [CONTRIBUTING.md Code of Conduct](blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.
9 |
10 |
11 | ## Questions
12 |
13 | > If you want to ask a question, we assume that you have read the available [Documentation](blob/master/README.md).
14 |
15 | Before you ask a question please look for existing [Issues](/issues) that might help you. In case you have found a suitable issue and still need clarification you can ask your question in the existing issue.
16 |
17 | If you then still feel the need to ask a question and need clarification please:
18 |
19 | - Open an [Issue](/issues/new).
20 | - Provide as much context as you can about what you're running into.
21 | - Provide project and platform versions (Python, Django, etc), depending on what seems relevant.
22 |
23 | We review issues regularly.
24 |
25 |
26 | ## Contributing
27 |
28 | > ### Legal Notice
29 | > When contributing to this project you must agree that you have authored all of the content of your contribution, that you have the necessary rights to the content and that the content you contribute may be provided under the project license (MIT).
30 |
31 |
32 | ### Reporting Bugs
33 |
34 |
35 | #### Before Submitting a Bug Report
36 |
37 | A good bug report shouldn't leave others needing to chase you up for more information. We ask you to investigate carefully, collect information and describe the issue in detail in your report.
38 |
39 | - Make sure that you are using the latest version.
40 | - Determine if your bug is really a bug and not an error on your side (for example using incompatible environment components/versions).
41 | - Make sure that you have read the [documentation](blob/master/README.md).
42 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [issues](issues).
43 | - Also make sure to search the internet to see if users outside of the GitHub community have discussed the issue.
44 | - Collect information about the bug such as stack traces, OS and platform version, versions of Python and Django:
45 | - Can you reliably reproduce the issue? Can you reproduce this issue with the previous version?
46 |
47 |
48 | #### Submitting a Bug Report
49 |
50 | > You must never report security related issues which contain sensitive information, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public.
51 | > In the extremely unlikely event you do find a critical security issue with a static site generator you can send an email to with the details
52 |
53 |
54 | We use GitHub issues to track bugs and features. If you run into an issue with the project:
55 |
56 | - Open an [Issue](/issues/new).
57 | - Explain the behavior you would expect and the actual behavior.
58 | - Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This may include a bare bones example of your code.
59 | - Provide the information you collected in the previous section.
60 |
61 | Once the issue is created:
62 |
63 | - The project team will label the issue accordingly.
64 | - A team member will try to reproduce the issue with your provided steps.
65 | - If the team is able to reproduce the issue the issue will be left to be implemented or resolved
66 |
67 |
68 | ### Suggesting Enhancements
69 |
70 | This section guides you through submitting an enhancement suggestion for CONTRIBUTING.md, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.
71 |
72 |
73 | #### Before Submitting an Enhancement
74 |
75 | - Make sure that you are using the latest version.
76 | - Read the [documentation](blob/master/README.md) carefully and find out if the functionality is already covered.
77 | - Perform a [search](/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
78 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of people using `django-distill` and not just a small subset.
79 |
80 |
81 | #### Submitting an Enhancement Suggestion
82 |
83 | Enhancement suggestions are tracked as [GitHub issues](/issues).
84 |
85 | - Use a **clear and descriptive title** for the issue to identify the suggestion.
86 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
87 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
88 | - **Explain why this enhancement would be useful** to most CONTRIBUTING.md users.
89 |
90 |
91 | ### Code Contribution
92 |
93 | Sensible and well formatted, easy to follow and concise pull requests are welcome. We may not merge your requested feature but it will be reviewed and discussed.
94 |
95 |
96 |
97 | ### Improving The Documentation
98 |
99 | Sensible and well formatted, easy to follow and concise amendments to the documentation submitted as pull requests are welcome.
100 |
101 |
102 |
103 | ## Maintainers
104 |
105 | `django-distill` is not currently looking for additional maintainers or administrators, however contributions are welcome.
106 |
--------------------------------------------------------------------------------
/django_distill/backends/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import warnings
4 | import mimetypes
5 | from hashlib import md5
6 | from binascii import hexlify
7 | import requests
8 | from urllib.parse import urlsplit, urlunsplit
9 | from django_distill.errors import DistillPublishError
10 | from django_distill.renderer import filter_dirs
11 |
12 |
13 | class BackendBase(object):
14 | '''
15 | Generic base class for all backends, mostly an interface / template.
16 | '''
17 |
18 | REQUIRED_OPTIONS = ()
19 |
20 | def __init__(self, source_dir, options):
21 | if not source_dir.endswith(os.sep):
22 | source_dir += os.sep
23 | self.source_dir = source_dir
24 | self.options = options
25 | self.local_files = set()
26 | self.local_dirs = set()
27 | self.remote_files = set()
28 | self.remote_url_parts = urlsplit(options.get('PUBLIC_URL', ''))
29 | self.d = {}
30 | self._validate_options()
31 |
32 | def _validate_options(self):
33 | for o in self.REQUIRED_OPTIONS:
34 | if o not in self.options:
35 | e = 'Missing required settings value for this backend: {}'
36 | raise DistillPublishError(e.format(o))
37 |
38 | def index_local_files(self):
39 | for root, dirs, files in os.walk(self.source_dir):
40 | dirs[:] = filter_dirs(dirs)
41 | for d in dirs:
42 | self.local_dirs.add(os.path.join(root, d))
43 | for f in files:
44 | self.local_files.add(os.path.join(root, f))
45 |
46 | def _get_local_file_hash(self, file_path, digest_func=md5, chunk=1048576):
47 | # md5 is used by Amazon S3, Rackspace Cloud Files and Google Storage
48 | if not self._file_exists(file_path):
49 | return None
50 | digest = digest_func()
51 | with open(file_path, 'rb') as f:
52 | while True:
53 | data = f.read(chunk)
54 | if not data:
55 | break
56 | digest.update(data)
57 | return digest.hexdigest()
58 |
59 | def _get_url_hash(self, url, digest_func=md5, chunk=1024):
60 | # CDN cache buster
61 | url += '?' + hexlify(os.urandom(16)).decode('utf-8')
62 | request = requests.get(url, stream=True)
63 | if request.status_code == 404:
64 | return False
65 | digest = digest_func()
66 | for block in request.iter_content(chunk_size=chunk):
67 | if block:
68 | digest.update(block)
69 | return digest.hexdigest()
70 |
71 | def _file_exists(self, file_path):
72 | return os.path.isfile(file_path)
73 |
74 | def local_file_mimetype(self, local_name):
75 | try:
76 | return mimetypes.guess_type(local_name)[0]
77 | except Exception:
78 | return 'application/octet-stream'
79 |
80 | def remote_url(self, local_name):
81 | if local_name[:len(self.source_dir)] != self.source_dir:
82 | raise DistillPublishError('File {} is not in source dir {}'.format(
83 | local_name, self.source_dir))
84 | truncated = local_name[len(self.source_dir):]
85 | remote_file = '/'.join(truncated.split(os.sep))
86 | remote_uri = self.remote_url_parts.path + remote_file
87 | return urlunsplit((self.remote_url_parts.scheme,
88 | self.remote_url_parts.netloc, remote_uri, '', ''))
89 |
90 | def list_local_dirs(self):
91 | return self.local_dirs
92 |
93 | def list_local_files(self):
94 | return self.local_files
95 |
96 | def check_file(self, local_name, url):
97 | if not self._file_exists(local_name):
98 | raise DistillPublishError('File does not exist: {}'.format(
99 | local_name))
100 | local_hash = self._get_local_file_hash(local_name)
101 | remote_hash = self._get_url_hash(url)
102 | return local_hash == remote_hash
103 |
104 | def final_checks(self):
105 | pass
106 |
107 | def remote_path(self, local_name):
108 | return local_name[len(self.source_dir):]
109 |
110 | def account_username(self):
111 | raise NotImplementedError('account_username must be implemented')
112 |
113 | def account_container(self):
114 | raise NotImplementedError('account_container must be implemented')
115 |
116 | def authenticate(self):
117 | raise NotImplementedError('authenticate must be implemented')
118 |
119 | def list_remote_files(self):
120 | raise NotImplementedError('list_remote_files must be implemented')
121 |
122 | def delete_remote_file(self, remote_name):
123 | raise NotImplementedError('delete_remote_file must be implemented')
124 |
125 | def compare_file(self, local_name, remote_name):
126 | raise NotImplementedError('compare_file must be implemented')
127 |
128 | def upload_file(self, local_name, remote_name):
129 | raise NotImplementedError('upload_file must be implemented')
130 |
131 | def create_remote_dir(self, remote_dir_name):
132 | raise NotImplementedError('create_remote_dir must be implemented')
133 |
134 |
135 | def get_backend(engine):
136 | with warnings.catch_warnings():
137 | warnings.simplefilter('ignore')
138 | try:
139 | backend = __import__(engine, globals(), locals(),
140 | ['backend_class'])
141 | except ImportError as e:
142 | err = 'Failed to import backend engine: {} ({})\n'
143 | sys.stderr.write(err.format(engine, e))
144 | raise
145 | module = getattr(backend, 'backend_class')
146 | if not module:
147 | raise ImportError('Backend engine has no backend_class attribute')
148 | return module
149 |
--------------------------------------------------------------------------------
/django_distill/backends/microsoft_azure_storage.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import warnings
4 | from urllib.parse import urlsplit, urlunsplit, quote_plus
5 | from time import sleep
6 | from base64 import b64decode
7 | from binascii import hexlify
8 |
9 |
10 | try:
11 | from azure.storage.blob import BlobServiceClient, ContentSettings
12 | except ImportError:
13 | name = 'django_distill.backends.azure_storage'
14 | pipm = 'azure-storage-blob'
15 | sys.stdout.write('{} backend requires {}:\n'.format(name, pipm))
16 | sys.stdout.write('$ pip install django-distill[microsoft]{}\n\n'.format(pipm))
17 | raise
18 |
19 |
20 | from django_distill.errors import DistillPublishError
21 | from django_distill.backends import BackendBase
22 |
23 |
24 | class AzureBlobStorateBackend(BackendBase):
25 | '''
26 | Publisher for Azure Blob Storage. Implements the BackendBase. Azure
27 | static websites in containers are relatively slow to make the files
28 | available via the public URL. To work around this uploaded files
29 | are cached and then verified in a loop at the end with up to
30 | RETRY_ATTEMPTS attempts with a delay of SLEEP_BETWEEN_RETRIES seconds
31 | between each attempt.
32 | '''
33 |
34 | REQUIRED_OPTIONS = ('ENGINE', 'CONNECTION_STRING')
35 | RETRY_ATTEMPTS = 30
36 | SLEEP_BETWEEN_RETRIES = 3
37 |
38 | def account_username(self):
39 | return
40 |
41 | def account_container(self):
42 | return '$web'
43 |
44 | def connection_string(self):
45 | return self.options.get('CONNECTION_STRING', '')
46 |
47 | def _get_blob(self, name):
48 | return self.d['connection'].get_blob_client(
49 | container=self.account_container(),
50 | blob=name
51 | )
52 |
53 | def _get_blob_url(self, blob):
54 | blob_parts = urlsplit(blob.url)
55 | prefix = '/{}/'.format(quote_plus(self.account_container()))
56 | path = blob_parts.path
57 | if path.startswith(prefix):
58 | path = path[len(prefix):]
59 | parts = (self.remote_url_parts.scheme, self.remote_url_parts.netloc,
60 | path, None, None)
61 | return urlunsplit(parts)
62 |
63 | def authenticate(self):
64 | self.d['connection'] = BlobServiceClient.from_connection_string(
65 | conn_str=self.connection_string()
66 | )
67 |
68 | def list_remote_files(self):
69 | container = self.d['connection'].get_container_client(
70 | container=self.account_container()
71 | )
72 | rtn = set()
73 | for obj in container.list_blobs():
74 | rtn.add(obj.name)
75 | return rtn
76 |
77 | def delete_remote_file(self, remote_name):
78 | container = self.d['connection'].get_container_client(
79 | container=self.account_container()
80 | )
81 | return container.delete_blob(remote_name)
82 |
83 | def check_file(self, local_name, url):
84 | # Azure uploads are checked in bulk at the end of the uploads, do
85 | # nothing here
86 | return True
87 |
88 | def compare_file(self, local_name, remote_name):
89 | blob = self._get_blob(remote_name)
90 | properties = blob.get_blob_properties()
91 | content_settings = properties.content_settings
92 | content_md5 = properties.get('content_settings', {}).get('content_md5')
93 | if not content_md5:
94 | return False
95 | local_hash = self._get_local_file_hash(local_name)
96 | remote_hash = str(hexlify(bytes(content_md5)).decode())
97 | return local_hash == remote_hash
98 |
99 | def upload_file(self, local_name, remote_name):
100 | blob = self._get_blob(remote_name)
101 | mimetype = self.local_file_mimetype(local_name)
102 | content_settings = ContentSettings(content_type=mimetype)
103 | with open(local_name, 'rb') as data:
104 | result = blob.upload_blob(
105 | data, overwrite=True, content_settings=content_settings)
106 | if result:
107 | actual_url = self._get_blob_url(blob)
108 | self.d.setdefault('azure_uploads_to_check', []).append(
109 | (local_name, remote_name, actual_url)
110 | )
111 | return result
112 |
113 | def _check_file(self, local_name, actual_url):
114 | # Azure specific patched check_file with retries to account for Azure
115 | # being slow
116 | err = ('Failed to upload local file "{}" blob to Azure container at '
117 | 'URL "{}" not available over the public URL after {} attempts')
118 | local_hash = self._get_local_file_hash(local_name)
119 | for i in range(self.RETRY_ATTEMPTS):
120 | remote_hash = self._get_url_hash(actual_url)
121 | if not remote_hash:
122 | sleep(self.SLEEP_BETWEEN_RETRIES)
123 | continue
124 | if local_hash == remote_hash:
125 | return True
126 | DistillPublishError(err.format(local_name, actual_url, i + 1))
127 |
128 | def final_checks(self):
129 | # Iterate over any cached files to check and verify they have been
130 | # uploaded correctly.
131 | to_check = self.d.setdefault('azure_uploads_to_check', [])
132 | for (local_name, remote_name, actual_url) in to_check:
133 | # Verify the upload, this may require retries
134 | self._check_file(local_name, actual_url)
135 | # If we reached here no DistillPublishError was raised
136 | return True
137 |
138 | def create_remote_dir(self, remote_dir_name):
139 | # not required for Azure Blob Storage containers
140 | return True
141 |
142 |
143 | backend_class = AzureBlobStorateBackend
144 |
--------------------------------------------------------------------------------
/tests/urls.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 | from django.conf import settings
3 | from django.http import HttpResponse
4 | from django.urls import include, path, reverse
5 | from django.conf.urls.i18n import i18n_patterns
6 | from django.shortcuts import render
7 | from django.utils import timezone
8 | from django.contrib.flatpages.views import flatpage as flatpage_view
9 | from django.contrib.sitemaps import Sitemap
10 | from django.contrib.sitemaps.views import sitemap
11 | from django.apps import apps as django_apps
12 | from django_distill import distill_path, distill_re_path
13 |
14 |
15 | class TestStaticViewSitemap(Sitemap):
16 |
17 | priority = 0.5
18 | changefreq = 'daily'
19 |
20 | def items(self):
21 | return ['path-sitemap']
22 |
23 | def location(self, item):
24 | return reverse(item)
25 |
26 |
27 | sitemap_dict = {
28 | 'static': TestStaticViewSitemap,
29 | }
30 |
31 |
32 | def test_no_param_view(request):
33 | return HttpResponse(b'test', content_type='application/octet-stream')
34 |
35 |
36 | def test_positional_param_view(request, param):
37 | if not isinstance(param, str):
38 | param = str(param)
39 | return HttpResponse(b'test' + param.encode(),
40 | content_type='application/octet-stream')
41 |
42 |
43 | def test_named_param_view(request, param=None):
44 | if not isinstance(param, str):
45 | param = str(param)
46 | return HttpResponse(b'test' + param.encode(),
47 | content_type='application/octet-stream')
48 |
49 |
50 | def test_session_view(request):
51 | request.session['test'] = 'test'
52 | return HttpResponse(b'test', content_type='application/octet-stream')
53 |
54 |
55 | def test_broken_view(request):
56 | # Trigger a normal Python exception when rendering
57 | a = 1 / 0
58 |
59 |
60 | def test_http404_view(request):
61 | response = HttpResponse(b'404', content_type='application/octet-stream')
62 | response.status_code = 404
63 | return response
64 |
65 |
66 | def test_humanize_view(request):
67 | now = timezone.now()
68 | one_hour_ago = now - timedelta(hours=1)
69 | nineteen_hours_ago = now - timedelta(hours=19)
70 | return render(request, 'humanize.html', {
71 | 'now': now,
72 | 'one_hour_ago': one_hour_ago,
73 | 'nineteen_hours_ago': nineteen_hours_ago
74 | })
75 |
76 |
77 | def test_no_param_func():
78 | return None
79 |
80 |
81 | def test_positional_param_func():
82 | return ('12345', '67890')
83 |
84 |
85 | def test_named_param_func():
86 | return [{'param': 'test'}]
87 |
88 |
89 | def test_flatpages_func():
90 | Site = django_apps.get_model('sites.Site')
91 | current_site = Site.objects.get_current()
92 | flatpages = current_site.flatpage_set.filter(registration_required=False)
93 | for flatpage in flatpages:
94 | yield {'url': flatpage.url}
95 |
96 |
97 | def test_request_has_resolver_match(request):
98 | return HttpResponse(request.resolver_match.func.__name__)
99 |
100 |
101 | urlpatterns = [
102 |
103 | path('path/namespace1/',
104 | include('tests.namespaced_urls', namespace='test_namespace')),
105 | path('path/no-namespace/',
106 | include('tests.no_namespaced_urls')),
107 |
108 | ]
109 |
110 |
111 | urlpatterns += i18n_patterns(
112 | path('path/i18n/', include('tests.i18n_urls')),
113 | )
114 |
115 |
116 | if settings.HAS_RE_PATH:
117 | urlpatterns += [
118 |
119 | distill_re_path(r'^re_path/$',
120 | test_no_param_view,
121 | name='re_path-no-param',
122 | distill_func=test_no_param_func,
123 | distill_file='test'),
124 | distill_re_path(r'^re_path-no-func/$',
125 | test_no_param_view,
126 | name='re_path-no-param-no-func',
127 | distill_file='test'),
128 | distill_re_path(r'^re_path/([\d]+)$',
129 | test_positional_param_view,
130 | name='re_path-positional-param',
131 | distill_func=test_positional_param_func),
132 | distill_re_path(r'^re_path/x/([\d]+)$',
133 | test_positional_param_view,
134 | name='re_path-positional-param-custom',
135 | distill_func=test_positional_param_func,
136 | distill_file="re_path/x/{}.html"),
137 | distill_re_path(r'^re_path/(?P[\w]+)$',
138 | test_named_param_view,
139 | name='re_path-named-param',
140 | distill_func=test_named_param_func),
141 | distill_re_path(r'^re_path/x/(?P[\w]+)$',
142 | test_named_param_view,
143 | name='re_path-named-param-custom',
144 | distill_func=test_named_param_func,
145 | distill_file="re_path/x/{param}.html"),
146 | distill_re_path(r'^re_path/broken$',
147 | test_broken_view,
148 | name='re_path-broken',
149 | distill_func=test_no_param_func),
150 | distill_re_path(r'^re_path/ignore-sessions$',
151 | test_session_view,
152 | name='re_path-ignore-sessions',
153 | distill_func=test_no_param_func),
154 | distill_re_path(r'^re_path/404$',
155 | test_http404_view,
156 | name='re_path-404',
157 | distill_status_codes=(404,),
158 | distill_func=test_no_param_func),
159 | distill_re_path(r'^re_path/flatpage(?P.+)$',
160 | flatpage_view,
161 | name='re_path-flatpage',
162 | distill_func=test_flatpages_func),
163 |
164 | ]
165 |
166 |
167 | if settings.HAS_PATH:
168 | urlpatterns += [
169 |
170 | distill_path('path/',
171 | test_no_param_view,
172 | name='path-no-param',
173 | distill_func=test_no_param_func,
174 | distill_file='test'),
175 | distill_path('path-no-func/',
176 | test_no_param_view,
177 | name='path-no-param-no-func',
178 | distill_file='test'),
179 | distill_path('path/',
180 | test_positional_param_view,
181 | name='path-positional-param',
182 | distill_func=test_positional_param_func),
183 | distill_path('path/x/',
184 | test_positional_param_view,
185 | name='path-positional-param-custom',
186 | distill_func=test_positional_param_func,
187 | distill_file="path/x/{}.html"),
188 | distill_path('path/',
189 | test_named_param_view,
190 | name='path-named-param',
191 | distill_func=test_named_param_func),
192 | distill_path('path/x/',
193 | test_named_param_view,
194 | name='path-named-param-custom',
195 | distill_func=test_named_param_func,
196 | distill_file="path/x/{param}.html"),
197 | distill_path('path/broken',
198 | test_broken_view,
199 | name='path-broken',
200 | distill_func=test_no_param_func),
201 | distill_path('path/ignore-sessions',
202 | test_session_view,
203 | name='path-ignore-sessions',
204 | distill_func=test_no_param_func),
205 | distill_path('path/404',
206 | test_http404_view,
207 | name='path-404',
208 | distill_status_codes=(404,),
209 | distill_func=test_no_param_func),
210 | distill_path('path/flatpage',
211 | flatpage_view,
212 | name='path-flatpage',
213 | distill_func=test_flatpages_func),
214 | distill_path('path/test-sitemap',
215 | sitemap,
216 | {'sitemaps': sitemap_dict},
217 | name='path-sitemap'
218 | ),
219 | distill_path(route='path/kwargs',
220 | view=test_no_param_view,
221 | name='test-kwargs'),
222 | distill_path('path/humanize',
223 | test_humanize_view,
224 | name='test-humanize'),
225 | distill_path(
226 | "path/has-resolver-match",
227 | test_request_has_resolver_match,
228 | name="test-has-resolver-match",
229 | ),
230 |
231 | ]
232 |
--------------------------------------------------------------------------------
/django_distill/renderer.py:
--------------------------------------------------------------------------------
1 | import errno
2 | import inspect
3 | import logging
4 | import os
5 | import types
6 | from shutil import copy2
7 | from concurrent.futures import ThreadPoolExecutor
8 | from django.utils.translation import activate as activate_lang
9 | from django.conf import settings, global_settings
10 | from django.urls import include as include_urls, get_resolver
11 | from django.core.exceptions import ImproperlyConfigured, MiddlewareNotUsed
12 | from django.utils.module_loading import import_string
13 | from django.test import RequestFactory
14 | from django.test.client import ClientHandler
15 | from django.urls import reverse, ResolverMatch
16 | from django.urls.exceptions import NoReverseMatch
17 | from django.core.management import call_command
18 | from django_distill.errors import DistillError
19 |
20 |
21 | logger = logging.getLogger(__name__)
22 | urlconf = get_resolver()
23 |
24 |
25 | def iter_resolved_urls(url_patterns, namespace_path=[]):
26 | url_patterns_resolved = []
27 | for entry in url_patterns:
28 | if hasattr(entry, 'url_patterns'):
29 | if getattr(entry, 'namespace', None) is not None:
30 | url_patterns_resolved += iter_resolved_urls(
31 | entry.url_patterns, namespace_path + [entry.namespace])
32 | else:
33 | url_patterns_resolved += iter_resolved_urls(
34 | entry.url_patterns, namespace_path)
35 | else:
36 | url_patterns_resolved.append((namespace_path, entry))
37 | return url_patterns_resolved
38 |
39 |
40 | def load_namespace_map():
41 | namespace_map = {}
42 | for (namespaces, url) in iter_resolved_urls(urlconf.url_patterns):
43 | if namespaces:
44 | nspath = ':'.join(namespaces)
45 | if url in namespace_map:
46 | raise DistillError(f'Ambiguous namespace for URL "{url}" in namespace '
47 | f'"{nspath}", Distill does not support the same Django '
48 | f'app being include()ed more than once in the same '
49 | f'project')
50 | else:
51 | namespace_map[url] = nspath
52 | return namespace_map
53 |
54 |
55 | class DistillHandler(ClientHandler):
56 | '''
57 | Overload ClientHandler's resolve_request(...) to return the already known
58 | and pre-resolved view method. Also overwrite any session handling middleware
59 | with the dummy session handler.
60 | '''
61 |
62 | def __init__(self, *a, **k):
63 | self.view_func = lambda x: x
64 | self.view_uri_args = ()
65 | self.view_uri_kwargs = {}
66 | self.view_args = ()
67 | k['enforce_csrf_checks'] = False
68 | super().__init__(*a, *k)
69 |
70 | def set_view(self, view_func, view_uri_args, view_uri_kwargs, view_args):
71 | self.view_func = view_func
72 | self.view_uri_args = view_uri_args
73 | self.view_uri_kwargs = view_uri_kwargs
74 | self.view_args = view_args
75 |
76 | def resolve_request(self, request):
77 | for arg in self.view_args:
78 | self.view_uri_kwargs.update(**arg)
79 | request.resolver_match = ResolverMatch(
80 | self.view_func,
81 | self.view_uri_args,
82 | self.view_uri_kwargs,
83 | )
84 | return request.resolver_match
85 |
86 | def load_middleware(self, is_async=False):
87 | '''
88 | Replaces the standard BaseHandler.load_middleware(). This method is
89 | identical apart from not trapping all exceptions. We actually want the
90 | real Python exceptions to be raised here.
91 | '''
92 | self._view_middleware = []
93 | self._template_response_middleware = []
94 | self._exception_middleware = []
95 | get_response = self._get_response_async if is_async else self._get_response
96 | handler = get_response
97 | handler_is_async = is_async
98 | for middleware_path in reversed(settings.MIDDLEWARE):
99 | middleware = import_string(middleware_path)
100 | middleware_can_sync = getattr(middleware, 'sync_capable', True)
101 | middleware_can_async = getattr(middleware, 'async_capable', False)
102 | if not middleware_can_sync and not middleware_can_async:
103 | raise RuntimeError(
104 | 'Middleware %s must have at least one of '
105 | 'sync_capable/async_capable set to True.' % middleware_path
106 | )
107 | elif not handler_is_async and middleware_can_sync:
108 | middleware_is_async = False
109 | else:
110 | middleware_is_async = middleware_can_async
111 | try:
112 | # Adapt handler, if needed.
113 | adapted_handler = self.adapt_method_mode(
114 | middleware_is_async,
115 | handler,
116 | handler_is_async,
117 | debug=settings.DEBUG,
118 | name='middleware %s' % middleware_path,
119 | )
120 | mw_instance = middleware(adapted_handler)
121 | except MiddlewareNotUsed as exc:
122 | if settings.DEBUG:
123 | if str(exc):
124 | logger.debug('MiddlewareNotUsed(%r): %s', middleware_path, exc)
125 | else:
126 | logger.debug('MiddlewareNotUsed: %r', middleware_path)
127 | continue
128 | else:
129 | handler = adapted_handler
130 | if mw_instance is None:
131 | raise ImproperlyConfigured(
132 | 'Middleware factory %s returned None.' % middleware_path
133 | )
134 | if hasattr(mw_instance, 'process_view'):
135 | self._view_middleware.insert(
136 | 0,
137 | self.adapt_method_mode(is_async, mw_instance.process_view),
138 | )
139 | if hasattr(mw_instance, 'process_template_response'):
140 | self._template_response_middleware.append(
141 | self.adapt_method_mode(
142 | is_async, mw_instance.process_template_response
143 | ),
144 | )
145 | if hasattr(mw_instance, 'process_exception'):
146 | self._exception_middleware.append(
147 | self.adapt_method_mode(False, mw_instance.process_exception),
148 | )
149 | handler = mw_instance
150 | handler_is_async = middleware_is_async
151 | handler = self.adapt_method_mode(is_async, handler, handler_is_async)
152 | self._middleware_chain = handler
153 |
154 |
155 | class DistillRender(object):
156 | '''
157 | Renders a complete static site from all urls registered with
158 | distill_url() and then copies over all static media.
159 | '''
160 |
161 | def __init__(self, urls_to_distill, parallel_render=1):
162 | self.urls_to_distill = urls_to_distill
163 | self.parallel_render = parallel_render
164 | self.namespace_map = load_namespace_map()
165 | # set allowed hosts to '*', static rendering shouldn't care about the hostname
166 | settings.ALLOWED_HOSTS = ['*']
167 |
168 | def render_file(self, view_name, status_codes, view_args, view_kwargs):
169 | view_details = []
170 | for params in self.urls_to_distill:
171 | if view_name == params[4]:
172 | view_details = params
173 | break
174 | if not view_details:
175 | raise DistillError(f'No view exists with the name: {view_name}')
176 | url, distill_func, file_name, status_codes, view_name, a, k = view_details
177 | if view_kwargs:
178 | uri = self.generate_uri(url, view_name, view_kwargs)
179 | args = view_kwargs
180 | else:
181 | uri = self.generate_uri(url, view_name, view_args)
182 | args = view_args
183 | render = self.render_view(uri, status_codes, args, a, k)
184 | file_name = self._get_filename(file_name, uri, args)
185 | return uri, file_name, render
186 |
187 | def render_all_urls(self, do_render=True):
188 |
189 | def _render(item):
190 | rtn = []
191 | for lang in self.get_langs():
192 | activate_lang(lang)
193 | url, view_name, param_set, status_codes, file_name_base, a, k, do_render = item
194 | uri = self.generate_uri(url, view_name, param_set)
195 | if do_render:
196 | render = self.render_view(uri, status_codes, param_set, a, k)
197 | else:
198 | render = None
199 | file_name = self._get_filename(file_name_base, uri, param_set)
200 | rtn.append((uri, file_name, render))
201 | return rtn
202 |
203 | to_render = []
204 | for url, distill_func, file_name_base, status_codes, view_name, a, k in self.urls_to_distill:
205 | for param_set in self.get_uri_values(distill_func, view_name):
206 | if not param_set:
207 | param_set = ()
208 | elif self._is_str(param_set):
209 | param_set = (param_set,)
210 | to_render.append((url, view_name, param_set, status_codes, file_name_base, a, k, do_render))
211 | with ThreadPoolExecutor(max_workers=self.parallel_render) as executor:
212 | results = executor.map(_render, to_render)
213 | for i18n_result in results:
214 | for uri, file_name, render in i18n_result:
215 | yield uri, file_name, render
216 |
217 | def render(self, view_name=None, status_codes=None, view_args=None, view_kwargs=None):
218 | if view_name:
219 | if not status_codes:
220 | status_codes = (200,)
221 | if not view_args:
222 | view_args = ()
223 | if not view_kwargs:
224 | view_kwargs = {}
225 | return self.render_file(view_name, status_codes, view_args, view_kwargs)
226 | else:
227 | return self.render_all_urls()
228 |
229 | def urls(self):
230 | for (uri, file_name, _) in self.render_all_urls(do_render=False):
231 | yield uri, file_name
232 |
233 | def get_langs(self):
234 | langs = []
235 | LANGUAGE_CODE = str(getattr(settings, 'LANGUAGE_CODE', 'en'))
236 | GLOBAL_LANGUAGES = list(getattr(global_settings, 'LANGUAGES', []))
237 | try:
238 | LANGUAGES = list(getattr(settings, 'LANGUAGES', []))
239 | except (ValueError, TypeError, AttributeError):
240 | LANGUAGES = []
241 | try:
242 | DISTILL_LANGUAGES = list(getattr(settings, 'DISTILL_LANGUAGES', []))
243 | except (ValueError, TypeError, AttributeError) as e:
244 | DISTILL_LANGUAGES = []
245 | if LANGUAGES != GLOBAL_LANGUAGES:
246 | for lang_code, lang_name in LANGUAGES:
247 | langs.append(lang_code)
248 | if LANGUAGE_CODE not in DISTILL_LANGUAGES and LANGUAGE_CODE not in langs:
249 | langs.append(LANGUAGE_CODE)
250 | for lang in DISTILL_LANGUAGES:
251 | langs.append(lang)
252 | return langs
253 |
254 | def _get_filename(self, file_name, uri, param_set):
255 | if file_name is not None:
256 | if isinstance(param_set, dict):
257 | return file_name.format(**param_set)
258 | else:
259 | return file_name.format(*param_set)
260 | elif uri.endswith('/'):
261 | # rewrite URIs ending with a slash to ../index.html
262 | if uri.startswith('/'):
263 | uri = uri[1:]
264 | return uri + 'index.html'
265 | else:
266 | return None
267 |
268 | def _is_str(self, s):
269 | return isinstance(s, str)
270 |
271 | def get_uri_values(self, func, view_name):
272 | fullargspec = inspect.getfullargspec(func)
273 | try:
274 | if 'view_name' in fullargspec.args:
275 | v = func(view_name)
276 | else:
277 | v = func()
278 | except Exception as e:
279 | raise DistillError('Failed to call distill function: {}'.format(e))
280 | if not v:
281 | return (None,)
282 | elif isinstance(v, (list, tuple)):
283 | return v
284 | elif isinstance(v, types.GeneratorType):
285 | return list(v)
286 | else:
287 | err = 'Distill function returned an invalid type: {}'
288 | raise DistillError(err.format(type(v)))
289 |
290 | def generate_uri(self, url, view_name, param_set):
291 | namespace = self.namespace_map.get(url, '')
292 | view_name_ns = namespace + ':' + view_name if namespace else view_name
293 | if isinstance(param_set, (list, tuple)):
294 | try:
295 | uri = reverse(view_name, args=param_set)
296 | except NoReverseMatch:
297 | uri = reverse(view_name_ns, args=param_set)
298 | elif isinstance(param_set, dict):
299 | try:
300 | uri = reverse(view_name, kwargs=param_set)
301 | except NoReverseMatch:
302 | uri = reverse(view_name_ns, kwargs=param_set)
303 | else:
304 | err = 'Distill function returned an invalid type: {}'
305 | raise DistillError(err.format(type(param_set)))
306 | return uri
307 |
308 | def render_view(self, uri, status_codes, param_set, args, kwargs={}):
309 | view_path, view_func = None, None
310 | try:
311 | view_path, view_func = args[0], args[1]
312 | except IndexError:
313 | try:
314 | view_path, view_func = kwargs['route'], kwargs['view']
315 | except KeyError:
316 | pass
317 | if view_path is None or view_func is None:
318 | raise DistillError(f'Invalid view arguments, args:{args}, kwargs:{kwargs}')
319 | # Default status_codes to (200,) if they are invalid or not set
320 | if not isinstance(status_codes, (tuple, list)):
321 | status_codes = (200,)
322 | for status_code in status_codes:
323 | if not isinstance(status_code, int):
324 | status_codes = (200,)
325 | break
326 | view_args = args[2:] if len(args) > 2 else ()
327 | request_factory = RequestFactory()
328 | request = request_factory.get(uri)
329 | handler = DistillHandler()
330 | handler.load_middleware()
331 | if isinstance(param_set, dict):
332 | a, k = (), param_set
333 | else:
334 | a, k = param_set, {}
335 | try:
336 | handler.set_view(view_func, a, k, view_args)
337 | response = handler.get_response(request)
338 | except Exception as err:
339 | e = 'Failed to render view "{}": {}'.format(uri, err)
340 | raise DistillError(e) from err
341 | if response.status_code not in status_codes:
342 | err = 'View returned an invalid status code: {} (expected one of {})'
343 | raise DistillError(err.format(response.status_code, status_codes))
344 | return response
345 |
346 |
347 | def copy_static(dir_from, dir_to):
348 | # we need to ignore some static dirs such as 'admin' so this is a
349 | # little more complex than a straight shutil.copytree()
350 | if not dir_from.endswith(os.sep):
351 | dir_from = dir_from + os.sep
352 | if not dir_to.endswith(os.sep):
353 | dir_to = dir_to + os.sep
354 | for root, dirs, files in os.walk(dir_from):
355 | dirs[:] = filter_dirs(dirs)
356 | for f in files:
357 | from_path = os.path.join(root, f)
358 | base_path = from_path[len(dir_from):]
359 | to_path = os.path.join(dir_to, base_path)
360 | to_path_dir = os.path.dirname(to_path)
361 | if not os.path.isdir(to_path_dir):
362 | os.makedirs(to_path_dir)
363 | copy2(from_path, to_path)
364 | yield from_path, to_path
365 |
366 |
367 | def copy_static_and_media_files(output_dir, stdout):
368 | static_url = str(settings.STATIC_URL)
369 | static_root = str(settings.STATIC_ROOT)
370 | static_url = static_url[1:] if static_url.startswith('/') else static_url
371 | static_output_dir = os.path.join(output_dir, static_url)
372 | for file_from, file_to in copy_static(static_root, static_output_dir):
373 | stdout('Copying static: {} -> {}'.format(file_from, file_to))
374 | media_url = str(settings.MEDIA_URL)
375 | media_root = str(settings.MEDIA_ROOT)
376 | if media_root:
377 | media_url = media_url[1:] if media_url.startswith('/') else media_url
378 | media_output_dir = os.path.join(output_dir, media_url)
379 | for file_from, file_to in copy_static(media_root, media_output_dir):
380 | stdout('Copying media: {} -> {}'.format(file_from, file_to))
381 | return True
382 |
383 |
384 | def run_collectstatic(stdout):
385 | stdout('Distill is running collectstatic...')
386 | call_command('collectstatic', '--noinput')
387 | stdout('')
388 | stdout('collectstatic complete, continuing...')
389 |
390 |
391 | def filter_dirs(dirs):
392 | DISTILL_SKIP_ADMIN_DIRS = bool(getattr(settings, 'DISTILL_SKIP_ADMIN_DIRS', True))
393 | _ignore_dirs = []
394 | if DISTILL_SKIP_ADMIN_DIRS:
395 | _ignore_dirs = [
396 | 'admin',
397 | 'grappelli',
398 | 'unfold',
399 | ]
400 | try:
401 | DISTILL_SKIP_STATICFILES_DIRS = list(getattr(settings, 'DISTILL_SKIP_STATICFILES_DIRS', []))
402 | except (ValueError, TypeError):
403 | DISTILL_SKIP_STATICFILES_DIRS = []
404 | for d in DISTILL_SKIP_STATICFILES_DIRS:
405 | if isinstance(d, str) and os.sep not in d:
406 | _ignore_dirs.append(d)
407 | return [d for d in dirs if d not in _ignore_dirs]
408 |
409 |
410 | def load_urls(stdout=None):
411 | if stdout:
412 | stdout('Loading site URLs')
413 | for url in urlconf.url_patterns:
414 | include_urls(url)
415 |
416 |
417 | def get_filepath(output_dir, file_name, page_uri):
418 | if file_name:
419 | local_uri = file_name
420 | full_path = os.path.join(output_dir, file_name)
421 | else:
422 | local_uri = page_uri
423 | if page_uri.startswith('/'):
424 | page_uri = page_uri[1:]
425 | page_path = page_uri.replace('/', os.sep)
426 | full_path = os.path.join(output_dir, page_path)
427 | return full_path, local_uri
428 |
429 |
430 | def write_file(full_path, content):
431 | try:
432 | dirname = os.path.dirname(full_path)
433 | if not os.path.isdir(dirname):
434 | os.makedirs(dirname)
435 | with open(full_path, 'wb') as f:
436 | f.write(content)
437 | except IOError as e:
438 | if e.errno == errno.EISDIR:
439 | err = ('Output path: {} is a directory! Try adding a '
440 | '"distill_file" arg to your distill_url()')
441 | raise DistillError(err.format(full_path))
442 | else:
443 | raise
444 |
445 |
446 | def get_renderer(urls_to_distill, parallel_render=1):
447 | import_path = getattr(settings, 'DISTILL_RENDERER', None)
448 | if import_path:
449 | render_cls = import_string(import_path)
450 | else:
451 | render_cls = DistillRender
452 | return render_cls(urls_to_distill, parallel_render)
453 |
454 |
455 | def render_to_dir(output_dir, urls_to_distill, stdout, parallel_render=1):
456 | load_urls(stdout)
457 | renderer = get_renderer(urls_to_distill, parallel_render)
458 | for page_uri, file_name, http_response in renderer.render():
459 | full_path, local_uri = get_filepath(output_dir, file_name, page_uri)
460 | content = http_response.content
461 | mime = http_response.get('Content-Type')
462 | renamed = ' (renamed from "{}")'.format(page_uri) if file_name else ''
463 | msg = 'Rendering page: {} -> {} ["{}", {} bytes] {}'
464 | stdout(msg.format(local_uri, full_path, mime, len(content), renamed))
465 | write_file(full_path, content)
466 | return True
467 |
468 |
469 | def render_single_file(output_dir, view_name, *args, **kwargs):
470 | from django_distill.distill import urls_to_distill
471 | status_codes = None
472 | if 'status_codes' in kwargs:
473 | status_codes = kwargs['status_codes']
474 | del kwargs['status_codes']
475 | view_name = str(view_name)
476 | if not status_codes:
477 | status_codes = (200,)
478 | load_urls()
479 | renderer = get_renderer(urls_to_distill)
480 | page_uri, file_name, http_response = renderer.render(
481 | view_name, status_codes, args, kwargs)
482 | full_path, local_uri = get_filepath(output_dir, file_name, page_uri)
483 | content = http_response.content
484 | write_file(full_path, content)
485 | return True
486 |
487 |
488 | def generate_urls(urls_to_distill):
489 | load_urls()
490 | renderer = get_renderer(urls_to_distill)
491 | return renderer.urls()
492 |
493 |
494 | def render_static_redirect(destination_url):
495 | redir = []
496 | redir.append(f'')
497 | redir.append(f'')
498 | redir.append(f'')
499 | redir.append(f'')
500 | redir.append(f'')
501 | redir.append(f'Redirecting to {destination_url}')
502 | redir.append(f'')
503 | redir.append(f'')
504 | redir.append(f'')
505 | redir.append(f'')
506 | redir.append(f'If you are not automatically redirected please click this link
')
507 | redir.append(f'')
508 | return '\n'.join(redir).encode()
509 |
510 |
511 | def render_redirects(output_dir, stdout):
512 | from django.contrib.redirects.models import Redirect
513 | for redirect in Redirect.objects.all():
514 | redirect_path = redirect.old_path.lstrip('/')
515 | if redirect_path.lower().endswith('.html'):
516 | redirect_file = redirect_path
517 | else:
518 | redirect_file = os.path.join(redirect_path, 'index.html')
519 | full_path, local_uri = get_filepath(output_dir, redirect_file, redirect_file)
520 | content = render_static_redirect(redirect.new_path)
521 | msg = 'Rendering redirect: {} -> {}'
522 | stdout(msg.format(local_uri, redirect.new_path))
523 | write_file(full_path, content)
524 | return True
525 |
--------------------------------------------------------------------------------
/tests/test_renderer.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import tempfile
4 | from datetime import datetime, timedelta
5 | from unittest.mock import patch
6 | from django.test import TestCase, override_settings
7 | from django.conf import settings
8 | from django.contrib.flatpages.models import FlatPage
9 | from django.apps import apps as django_apps
10 | from django.utils import timezone
11 | from django.utils.translation import activate as activate_lang
12 | from django_distill.distill import urls_to_distill
13 | from django_distill.renderer import DistillRender, render_to_dir, render_single_file, get_renderer
14 | from django_distill.errors import DistillError
15 | from django_distill import distilled_urls
16 |
17 |
18 | class CustomRender(DistillRender):
19 | pass
20 |
21 |
22 | class DjangoDistillRendererTestSuite(TestCase):
23 |
24 | def setUp(self):
25 | self.renderer = DistillRender(urls_to_distill)
26 | # Create a few test flatpages
27 | Site = django_apps.get_model('sites.Site')
28 | current_site = Site.objects.get_current()
29 | page1 = FlatPage()
30 | page1.url = '/flat/page1.html'
31 | page1.title = 'flatpage1'
32 | page1.content = 'flatpage1'
33 | page1.template_name = 'flatpage.html'
34 | page1.save()
35 | page1.sites.add(current_site)
36 | page2 = FlatPage()
37 | page2.url = '/flat/page2.html'
38 | page2.title = 'flatpage2'
39 | page2.content = 'flatpage2'
40 | page2.template_name = 'flatpage.html'
41 | page2.save()
42 | page2.sites.add(current_site)
43 |
44 | def _get_view(self, name):
45 | for u in urls_to_distill:
46 | if u[4] == name:
47 | return u
48 | return False
49 |
50 | def _skip(self, what):
51 | sys.stdout.write('Missing {}, skipping test... '.format(what))
52 | sys.stdout.flush()
53 |
54 | def test_is_str(self):
55 | self.assertTrue(self.renderer._is_str('a'))
56 | self.assertFalse(self.renderer._is_str(None))
57 | self.assertFalse(self.renderer._is_str(1))
58 | self.assertFalse(self.renderer._is_str([]))
59 | self.assertFalse(self.renderer._is_str(()))
60 | self.assertFalse(self.renderer._is_str({}))
61 | self.assertFalse(self.renderer._is_str({'a':'a'}))
62 | self.assertFalse(self.renderer._is_str(object()))
63 |
64 | def test_get_uri_values(self):
65 | test = ()
66 | check = self.renderer.get_uri_values(lambda: test, None)
67 | self.assertEqual(check, (None,))
68 | test = ('a',)
69 | check = self.renderer.get_uri_values(lambda: test, None)
70 | self.assertEqual(check, test)
71 | test = (('a',),)
72 | check = self.renderer.get_uri_values(lambda: test, None)
73 | self.assertEqual(check, test)
74 | test = []
75 | check = self.renderer.get_uri_values(lambda: test, None)
76 | self.assertEqual(check, (None,))
77 | test = ['a']
78 | check = self.renderer.get_uri_values(lambda: test, None)
79 | self.assertEqual(check, test)
80 | test = [['a']]
81 | check = self.renderer.get_uri_values(lambda: test, None)
82 | self.assertEqual(check, test)
83 | for invalid in ('a', 1, b'a', {'s'}, {'a':'a'}, object()):
84 | with self.assertRaises(DistillError):
85 | self.renderer.get_uri_values(lambda: invalid, None)
86 |
87 | def test_re_path_no_param(self):
88 | if not settings.HAS_RE_PATH:
89 | self._skip('django.urls.re_path')
90 | return
91 | view = self._get_view('re_path-no-param')
92 | assert view
93 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
94 | param_set = self.renderer.get_uri_values(view_func, view_name)[0]
95 | if not param_set:
96 | param_set = ()
97 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
98 | self.assertEqual(uri, '/re_path/')
99 | render = self.renderer.render_view(uri, status_codes, param_set, args)
100 | self.assertEqual(render.content, b'test')
101 |
102 | def test_re_path_positional_param(self):
103 | if not settings.HAS_RE_PATH:
104 | self._skip('django.urls.re_path')
105 | return
106 | view = self._get_view('re_path-positional-param')
107 | assert view
108 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
109 | param_sets = self.renderer.get_uri_values(view_func, view_name)
110 | for param_set in param_sets:
111 | param_set = (param_set,)
112 | first_value = param_set[0]
113 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
114 | self.assertEqual(uri, f'/re_path/{first_value}')
115 | render = self.renderer.render_view(uri, status_codes, param_set, args)
116 | self.assertEqual(render.content, b'test' + first_value.encode())
117 |
118 | def test_re_path_named_param(self):
119 | if not settings.HAS_RE_PATH:
120 | self._skip('django.urls.re_path')
121 | return
122 | view = self._get_view('re_path-named-param')
123 | assert view
124 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
125 | param_set = self.renderer.get_uri_values(view_func, view_name)[0]
126 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
127 | self.assertEqual(uri, '/re_path/test')
128 | render = self.renderer.render_view(uri, status_codes, param_set, args)
129 | self.assertEqual(render.content, b'testtest')
130 |
131 | def test_re_broken(self):
132 | if not settings.HAS_RE_PATH:
133 | self._skip('django.urls.re_path')
134 | return
135 | view = self._get_view('re_path-broken')
136 | assert view
137 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
138 | param_set = self.renderer.get_uri_values(view_func, view_name)[0]
139 | if not param_set:
140 | param_set = ()
141 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
142 | self.assertEqual(uri, '/re_path/broken')
143 | with self.assertRaises(DistillError):
144 | self.renderer.render_view(uri, status_codes, param_set, args)
145 |
146 | def test_path_no_param(self):
147 | if not settings.HAS_PATH:
148 | self._skip('django.urls.path')
149 | return
150 | view = self._get_view('path-no-param')
151 | assert view
152 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
153 | param_set = self.renderer.get_uri_values(view_func, view_name)[0]
154 | if not param_set:
155 | param_set = ()
156 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
157 | self.assertEqual(uri, '/path/')
158 | render = self.renderer.render_view(uri, status_codes, param_set, args)
159 | self.assertEqual(render.content, b'test')
160 |
161 | def test_path_positional_param(self):
162 | if not settings.HAS_PATH:
163 | self._skip('django.urls.path')
164 | return
165 | view = self._get_view('path-positional-param')
166 | assert view
167 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
168 | param_sets = self.renderer.get_uri_values(view_func, view_name)
169 | for param_set in param_sets:
170 | param_set = (param_set,)
171 | first_value = param_set[0]
172 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
173 | self.assertEqual(uri, f'/path/{first_value}')
174 | render = self.renderer.render_view(uri, status_codes, param_set, args)
175 | self.assertEqual(render.content, b'test' + first_value.encode())
176 |
177 | def test_path_named_param(self):
178 | if not settings.HAS_PATH:
179 | self._skip('django.urls.path')
180 | return
181 | view = self._get_view('path-named-param')
182 | assert view
183 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
184 | param_set = self.renderer.get_uri_values(view_func, view_name)[0]
185 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
186 | self.assertEqual(uri, '/path/test')
187 | render = self.renderer.render_view(uri, status_codes, param_set, args)
188 | self.assertEqual(render.content, b'testtest')
189 |
190 | def test_path_broken(self):
191 | if not settings.HAS_PATH:
192 | self._skip('django.urls.path')
193 | return
194 | view = self._get_view('path-broken')
195 | assert view
196 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
197 | param_set = self.renderer.get_uri_values(view_func, view_name)[0]
198 | if not param_set:
199 | param_set = ()
200 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
201 | self.assertEqual(uri, '/path/broken')
202 | with self.assertRaises(DistillError):
203 | self.renderer.render_view(uri, status_codes, param_set, args)
204 |
205 | def test_render_paths(self):
206 | def _blackhole(_):
207 | pass
208 | expected_files = (
209 | ('test',),
210 | ('re_path', '12345'),
211 | ('re_path', 'test'),
212 | ('re_path', 'x', '12345.html'),
213 | ('re_path', 'x', 'test.html'),
214 | )
215 | with tempfile.TemporaryDirectory() as tmpdirname:
216 | with self.assertRaises(DistillError):
217 | render_to_dir(tmpdirname, urls_to_distill, _blackhole)
218 | written_files = []
219 | for (root, dirs, files) in os.walk(tmpdirname):
220 | for f in files:
221 | filepath = os.path.join(root, f)
222 | written_files.append(filepath)
223 | for expected_file in expected_files:
224 | filepath = os.path.join(tmpdirname, *expected_file)
225 | self.assertIn(filepath, written_files)
226 |
227 | @patch.object(CustomRender, "render_view", side_effect=CustomRender.render_view, autospec=True)
228 | @override_settings(DISTILL_RENDERER="tests.test_renderer.CustomRender")
229 | def test_render_paths_custom_renderer(self, render_view_spy):
230 | def _blackhole(_):
231 | pass
232 | expected_files = (
233 | ('test',),
234 | ('re_path', '12345'),
235 | ('re_path', 'test'),
236 | )
237 | with tempfile.TemporaryDirectory() as tmpdirname:
238 | with self.assertRaises(DistillError):
239 | render_to_dir(tmpdirname, urls_to_distill, _blackhole)
240 | written_files = []
241 | for (root, dirs, files) in os.walk(tmpdirname):
242 | for f in files:
243 | filepath = os.path.join(root, f)
244 | written_files.append(filepath)
245 | for expected_file in expected_files:
246 | filepath = os.path.join(tmpdirname, *expected_file)
247 | self.assertIn(filepath, written_files)
248 | #self.assertEqual(render_view_spy.call_count, 34)
249 |
250 | def test_sessions_are_ignored(self):
251 | if settings.HAS_PATH:
252 | view = self._get_view('path-ignore-sessions')
253 | assert view
254 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
255 | param_set = self.renderer.get_uri_values(view_func, view_name)[0]
256 | if not param_set:
257 | param_set = ()
258 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
259 | self.assertEqual(uri, '/path/ignore-sessions')
260 | render = self.renderer.render_view(uri, status_codes, param_set, args)
261 | self.assertEqual(render.content, b'test')
262 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
263 | if settings.HAS_RE_PATH:
264 | view = self._get_view('re_path-ignore-sessions')
265 | assert view
266 | view_url, iew_func, file_name, status_codes, view_name, args, kwargs = view
267 | param_set = self.renderer.get_uri_values(view_func, view_name)[0]
268 | if not param_set:
269 | param_set = ()
270 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
271 | self.assertEqual(uri, '/re_path/ignore-sessions')
272 | render = self.renderer.render_view(uri, status_codes, param_set, args)
273 | self.assertEqual(render.content, b'test')
274 |
275 | def test_custom_status_codes(self):
276 | if settings.HAS_PATH:
277 | view = self._get_view('path-404')
278 | assert view
279 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
280 | param_set = self.renderer.get_uri_values(view_func, view_name)[0]
281 | if not param_set:
282 | param_set = ()
283 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
284 | self.assertEqual(uri, '/path/404')
285 | render = self.renderer.render_view(uri, status_codes, param_set, args)
286 | self.assertEqual(render.content, b'404')
287 | self.assertEqual(render.status_code, 404)
288 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
289 | if settings.HAS_RE_PATH:
290 | view = self._get_view('re_path-404')
291 | assert view
292 | view_url, iew_func, file_name, status_codes, view_name, args, kwargs = view
293 | param_set = self.renderer.get_uri_values(view_func, view_name)[0]
294 | if not param_set:
295 | param_set = ()
296 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
297 | self.assertEqual(uri, '/re_path/404')
298 | render = self.renderer.render_view(uri, status_codes, param_set, args)
299 | self.assertEqual(render.content, b'404')
300 | self.assertEqual(render.status_code, 404)
301 |
302 | def test_contrib_flatpages(self):
303 | if settings.HAS_PATH:
304 | view = self._get_view('path-flatpage')
305 | assert view
306 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
307 | param_set = self.renderer.get_uri_values(view_func, view_name)
308 | for param in param_set:
309 | page_url = param['url']
310 | uri = self.renderer.generate_uri(view_url, view_name, param)
311 | self.assertEqual(uri, f'/path/flatpage{page_url}')
312 | render = self.renderer.render_view(uri, status_codes, param, args)
313 | flatpage = FlatPage.objects.get(url=page_url)
314 | expected = f'{flatpage.title}{flatpage.content}\n'
315 | self.assertEqual(render.content, expected.encode())
316 | self.assertEqual(render.status_code, 200)
317 | if settings.HAS_RE_PATH:
318 | view = self._get_view('re_path-flatpage')
319 | assert view
320 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
321 | param_set = self.renderer.get_uri_values(view_func, view_name)
322 | for param in param_set:
323 | page_url = param['url']
324 | uri = self.renderer.generate_uri(view_url, view_name, param)
325 | self.assertEqual(uri, f'/re_path/flatpage{page_url}')
326 | render = self.renderer.render_view(uri, status_codes, param, args)
327 | flatpage = FlatPage.objects.get(url=page_url)
328 | expected = f'{flatpage.title}{flatpage.content}\n'
329 | self.assertEqual(render.content, expected.encode())
330 | self.assertEqual(render.status_code, 200)
331 |
332 | def test_contrib_sitemaps(self):
333 | view = self._get_view('path-sitemap')
334 | assert view
335 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
336 | param_set = ()
337 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
338 | self.assertEqual(uri, '/path/test-sitemap')
339 | render = self.renderer.render_view(uri, status_codes, param_set, args)
340 | expected_content = (
341 | b'\n'
342 | b'\n'
344 | b'http://example.com/path/test-sitemap'
345 | b'daily'
346 | b'0.5\n\n'
347 | )
348 | self.assertEqual(render.content, expected_content)
349 | self.assertEqual(render.status_code, 200)
350 |
351 | def test_render_single_file(self):
352 | expected_files = (
353 | ('path', '12345'),
354 | ('path', 'test'),
355 | )
356 | with tempfile.TemporaryDirectory() as tmpdirname:
357 | render_single_file(tmpdirname, 'path-positional-param', 12345)
358 | render_single_file(tmpdirname, 'path-named-param', param='test')
359 | written_files = []
360 | for (root, dirs, files) in os.walk(tmpdirname):
361 | for f in files:
362 | filepath = os.path.join(root, f)
363 | written_files.append(filepath)
364 | for expected_file in expected_files:
365 | filepath = os.path.join(tmpdirname, *expected_file)
366 | self.assertIn(filepath, written_files)
367 |
368 | def test_i18n(self):
369 | if not settings.USE_I18N:
370 | self._skip('settings.USE_I18N')
371 | return
372 | settings.DISTILL_LANGUAGES = [
373 | 'en',
374 | 'fr',
375 | 'de',
376 | ]
377 | expected = {}
378 | for lang_code, lang_name in settings.DISTILL_LANGUAGES:
379 | expected[lang_code] = f'/{lang_code}/path/i18n/sub-url-with-i18n-prefix'
380 | view = self._get_view('test-url-i18n')
381 | assert view
382 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
383 | param_set = self.renderer.get_uri_values(view_func, view_name)[0]
384 | if not param_set:
385 | param_set = ()
386 | for lang_code, path in expected.items():
387 | activate_lang(lang_code)
388 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
389 | self.assertEqual(uri, path)
390 | render = self.renderer.render_view(uri, status_codes, param_set, args)
391 | self.assertEqual(render.content, b'test')
392 | # Render the test URLs and confirm the expected language URI prefixes are present
393 | def _blackhole(_):
394 | pass
395 | expected_files = (
396 | ('test',),
397 | ('re_path', '12345'),
398 | ('re_path', 'test'),
399 | ('re_path', 'x', '12345.html'),
400 | ('re_path', 'x', 'test.html'),
401 | ('en', 'path', 'i18n', 'sub-url-with-i18n-prefix'),
402 | ('fr', 'path', 'i18n', 'sub-url-with-i18n-prefix'),
403 | ('de', 'path', 'i18n', 'sub-url-with-i18n-prefix'),
404 | )
405 | with tempfile.TemporaryDirectory() as tmpdirname:
406 | with self.assertRaises(DistillError):
407 | render_to_dir(tmpdirname, urls_to_distill, _blackhole, parallel_render=8)
408 | written_files = []
409 | for (root, dirs, files) in os.walk(tmpdirname):
410 | for f in files:
411 | filepath = os.path.join(root, f)
412 | written_files.append(filepath)
413 | for expected_file in expected_files:
414 | filepath = os.path.join(tmpdirname, *expected_file)
415 | self.assertIn(filepath, written_files)
416 | settings.DISTILL_LANGUAGES = []
417 |
418 | def test_kwargs(self):
419 | if not settings.HAS_PATH:
420 | self._skip('django.urls.path')
421 | return
422 | view = self._get_view('test-kwargs')
423 | assert view
424 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
425 | param_set = self.renderer.get_uri_values(view_func, view_name)[0]
426 | if not param_set:
427 | param_set = ()
428 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
429 | self.assertEqual(uri, '/path/kwargs')
430 | render = self.renderer.render_view(uri, status_codes, param_set, args, kwargs)
431 | self.assertEqual(render.content, b'test')
432 |
433 | def test_humanize(self):
434 | if not settings.HAS_PATH:
435 | self._skip('django.urls.path')
436 | return
437 | view = self._get_view('test-humanize')
438 | assert view
439 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
440 | param_set = self.renderer.get_uri_values(view_func, view_name)[0]
441 | if not param_set:
442 | param_set = ()
443 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
444 | self.assertEqual(uri, '/path/humanize')
445 | now = timezone.now()
446 | one_hour_ago = now - timedelta(hours=1)
447 | nineteen_hours_ago = now - timedelta(hours=19)
448 | render = self.renderer.render_view(uri, status_codes, param_set, args, kwargs)
449 | content = render.content
450 | expected = b'\n'.join([
451 | b'',
452 | b'',
453 | b'- test
',
454 | b'- one hour ago naturaltime: an hour ago
',
455 | b'- nineteen hours ago naturaltime: 19\xc2\xa0hours ago
',
456 | b'
',
457 | b'',
458 | ])
459 | self.assertEqual(render.content, expected)
460 |
461 | def test_request_has_resolver_match(self):
462 | view = self._get_view('test-has-resolver-match')
463 | assert view
464 | view_url, view_func, file_name, status_codes, view_name, args, kwargs = view
465 | param_set = self.renderer.get_uri_values(view_func, view_name)[0]
466 | if not param_set:
467 | param_set = ()
468 | uri = self.renderer.generate_uri(view_url, view_name, param_set)
469 | render = self.renderer.render_view(uri, status_codes, param_set, args, kwargs)
470 | self.assertEqual(render.content, b"test_request_has_resolver_match")
471 |
472 | def test_parallel_rendering(self):
473 | def _blackhole(_):
474 | pass
475 | expected_files = (
476 | ('test',),
477 | ('re_path', '12345'),
478 | ('re_path', 'test'),
479 | ('re_path', 'x', '12345.html'),
480 | ('re_path', 'x', 'test.html'),
481 | )
482 | with tempfile.TemporaryDirectory() as tmpdirname:
483 | with self.assertRaises(DistillError):
484 | render_to_dir(tmpdirname, urls_to_distill, _blackhole, parallel_render=8)
485 | written_files = []
486 | for (root, dirs, files) in os.walk(tmpdirname):
487 | for f in files:
488 | filepath = os.path.join(root, f)
489 | written_files.append(filepath)
490 | for expected_file in expected_files:
491 | filepath = os.path.join(tmpdirname, *expected_file)
492 | self.assertIn(filepath, written_files)
493 |
494 | def test_generate_urls(self):
495 | urls = distilled_urls()
496 | generated_urls = []
497 | for url, file_name in urls:
498 | generated_urls.append(url)
499 | expected_urls = (
500 | '/path/namespace1/sub-url-in-namespace',
501 | '/path/namespace1/path/sub-namespace/sub-url-in-sub-namespace',
502 | '/path/no-namespace/sub-url-in-no-namespace',
503 | '/en/path/i18n/sub-url-with-i18n-prefix',
504 | '/re_path/',
505 | '/re_path-no-func/',
506 | '/re_path/12345',
507 | '/re_path/67890',
508 | '/re_path/x/12345',
509 | '/re_path/x/67890',
510 | '/re_path/test',
511 | '/re_path/x/test',
512 | '/re_path/broken',
513 | '/re_path/ignore-sessions',
514 | '/re_path/404',
515 | '/re_path/flatpage/flat/page1.html',
516 | '/re_path/flatpage/flat/page2.html',
517 | '/path/',
518 | '/path-no-func/',
519 | '/path/12345',
520 | '/path/67890',
521 | '/path/x/12345',
522 | '/path/x/67890',
523 | '/path/test',
524 | '/path/x/test',
525 | '/path/broken',
526 | '/path/ignore-sessions',
527 | '/path/404',
528 | '/path/flatpage/flat/page1.html',
529 | '/path/flatpage/flat/page2.html',
530 | '/path/test-sitemap',
531 | '/path/kwargs',
532 | '/path/humanize',
533 | '/path/has-resolver-match'
534 | )
535 | self.assertEqual(sorted(generated_urls), sorted(expected_urls))
536 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # django-distill
2 |
3 | `django-distill` now has a website. Read more at:
4 |
5 | ## :link: https://django-distill.com/
6 |
7 | `django-distill` is a minimal configuration static site generator and publisher
8 | for Django. Most Django versions are supported, however up to date versions are
9 | advised including the Django 3.x releases. `django-distill` as of the 1.7 release
10 | only supports Python 3. Python 2 support has been dropped. If you require Python 2
11 | support please pin `django-distill` to version 1.6 in your requirements.txt or
12 | Pipfile. Python 3.6 or above is advised.
13 |
14 | `django-distill` extends existing Django sites with the ability to export
15 | fully functional static sites. It is suitable for sites such as blogs that have
16 | a mostly static front end but you still want to use a CMS to manage the
17 | content.
18 |
19 | `django-distill` iterates over URLs in your Django project using easy to write
20 | iterable functions to yield the parameters for whatever pages you want to save
21 | as static HTML. These static files can be automatically uploaded to a bucket-style
22 | remote container such as Amazon S3, Googe Cloud Files, Microsoft Azure Storage,
23 | or, written to a local directory as a fully working local static version of
24 | your project. The site generation, or distillation process, can be easily
25 | integrated into CI/CD workflows to auto-deploy static sites on commit.
26 | `django-distill` can be defined as an extension to Django to make Django
27 | projects compatible with "Jamstack"-style site architecture.
28 |
29 | `django-distill` plugs directly into the existing Django framework without the
30 | need to write custom renderers or other more verbose code. You can also integrate
31 | `django-distill` with existing dynamic sites and just generate static pages for
32 | a small subsection of pages rather than the entire site.
33 |
34 | For static files on CDNs you can use the following 'cache buster' library to
35 | allow for fast static media updates when pushing changes:
36 |
37 | [:link: meeb/django-cachekiller](https://github.com/meeb/django-cachekiller)
38 |
39 | There is a complete example site that creates a static blog and uses
40 | `django-distill` with `django-cachekiller` via continuous deployment on Netlify
41 | available here:
42 |
43 | [:link: meeb/django-distill-example](https://github.com/meeb/django-distill-example)
44 |
45 |
46 | # Installation
47 |
48 | Install from pip:
49 |
50 | ```bash
51 | $ pip install django-distill
52 | ```
53 |
54 | Add `django_distill` to your `INSTALLED_APPS` in your `settings.py`:
55 |
56 | ```python
57 | INSTALLED_APPS = [
58 | # ... other apps here ...
59 | 'django_distill',
60 | ]
61 | ```
62 |
63 | That's it.
64 |
65 |
66 | # Limitations
67 |
68 | `django-distill` generates static pages and therefore only views which allow
69 | `GET` requests that return an `HTTP 200` status code are supported.
70 |
71 | It is assumed you are using URI parameters such as `/blog/123-abc` and not
72 | querystring parameters such as `/blog?post_id=123&title=abc`. Querystring
73 | parameters do not make sense for static page generation for obvious reasons.
74 |
75 | Static media files such as images and style sheets are copied from your static
76 | media directory defined in `STATIC_ROOT`. This means that you will want to run
77 | `./manage.py collectstatic` **before** you run `./manage.py distill-local`
78 | if you have made changes to static media. `django-distill` doesn't chain this
79 | request by design, however you can enable it with the `--collectstatic`
80 | argument.
81 |
82 |
83 | # Usage
84 |
85 | Assuming you have an existing Django project, edit a `urls.py` to include the
86 | `distill_path` function which replaces Django's standard `path` function and
87 | supports the new keyword arguments `distill_func` and `distill_file`.
88 |
89 | The `distill_func` argument should be provided with a function or callable
90 | class that returns an iterable or `None`.
91 |
92 | The `distill_file` argument is entirely optional and allows you to override the
93 | URL that would otherwise be generated from the reverse of the URL regex. This
94 | allows you to rename URLs like `/example` to any other name like
95 | `example.html`. As of v0.8 any URIs ending in a slash `/` are automatically
96 | modified to end in `/index.html`. You can use format string parameters in the
97 | `distill_file` to customise the file name, arg values from the URL will be
98 | substituted in, for example `{}` for positional args or `{param_name}` for
99 | named args.
100 |
101 | An example distill setup for a theoretical blogging app would be:
102 |
103 | ```python
104 | # Replaces the standard django.conf.path, identical syntax
105 | from django_distill import distill_path
106 |
107 | # Views and models from a theoretical blogging app
108 | from blog.views import PostIndex, PostView, PostYear
109 | from blog.models import Post
110 |
111 | def get_index():
112 | # The index URI path, '', contains no parameters, named or otherwise.
113 | # You can simply just return nothing here.
114 | return None
115 |
116 | def get_all_blogposts():
117 | # This function needs to return an iterable of dictionaries. Dictionaries
118 | # are required as the URL this distill function is for has named parameters.
119 | # You can just export a small subset of values here if you wish to
120 | # limit what pages will be generated.
121 | for post in Post.objects.all():
122 | yield {'blog_id': post.id, 'blog_title': post.title}
123 |
124 | def get_years():
125 | # You can also just return an iterable containing static strings if the
126 | # URL only has one argument and you are using positional URL parameters:
127 | return (2014, 2015)
128 | # This is really just shorthand for ((2014,), (2015,))
129 |
130 | urlpatterns = (
131 | # e.g. / the blog index
132 | distill_path('',
133 | PostIndex.as_view(),
134 | name='blog-index',
135 | # Note that for paths which have no paramters
136 | # distill_func is optional
137 | distill_func=get_index,
138 | # '' is not a valid file name! override it to index.html
139 | distill_file='index.html'),
140 | # e.g. /post/123-some-post-title using named parameters
141 | distill_path('post/-.html',
142 | PostView.as_view(),
143 | name='blog-post',
144 | distill_func=get_all_blogposts),
145 | # e.g. /posts-by-year/2015 using positional parameters
146 | # url ends in / so file path will have /index.html appended
147 | distill_path('posts-by-year//',
148 | PostYear.as_view(),
149 | name='blog-year',
150 | distill_func=get_years),
151 | )
152 | ```
153 |
154 | Your site will still function identically with the above changes. Internally
155 | the `distill_func` and `distill_file` parameters are removed and the URL is
156 | passed back to Django for normal processing. This has no runtime performance
157 | impact as this happens only once upon starting the application.
158 |
159 | If your path has no URI paramters, such as `/` or `/some-static-url` you do
160 | not have to specify the `distill_func` parameter if you don't want to. As for
161 | paths with no parameters the `distill_func` always returns `None`, this is set
162 | as the default behaviour for `distill_func`s.
163 |
164 | You can use the `distill_re_path` function as well, which replaces the default
165 | `django.urls.re_path` function. Its usage is identical to the above:
166 |
167 | ```python
168 | from django_distill import distill_re_path
169 |
170 | urlpatterns = (
171 | distill_re_path(r'some/regex'
172 | SomeOtherView.as_view(),
173 | name='url-other-view',
174 | distill_func=some_other_func),
175 | )
176 |
177 | ```
178 |
179 | If you are using an older version of Django in the 1.x series you can use the
180 | `distill_url` function instead which replaces the `django.conf.urls.url` or
181 | `django.urls.url` functions. Its usage is identical to the above:
182 |
183 | ```python
184 | from django_distill import distill_url
185 |
186 | urlpatterns = (
187 | distill_url(r'some/regex'
188 | SomeView.as_view(),
189 | name='url-view',
190 | distill_func=some_func),
191 | )
192 | ```
193 |
194 | ### Parameters in file names
195 |
196 | You can use standard Python string formatting in `distill_file` as well to enable
197 | you to change the output file path for a file if you wish. Note this does not
198 | update the URL used by Django so if you use this make sure your `path` pattern
199 | matches the `distill_file` pattern or your links might not work in Django. An
200 | example:
201 |
202 | ```python
203 | # Override file path with parameters. Values are taken from the URL pattern
204 | urlpatterns = (
205 | distill_path('post/-.html',
206 | PostView.as_view(),
207 | name='blog-post',
208 | distill_func=get_all_blogposts,
209 | distill_file="post/{blog_id}-{blog_title}.html"
210 | )
211 | ```
212 |
213 | ### Non-standard status codes
214 |
215 | All views rendered by `django-distill` into static pages must return an HTTP 200 status
216 | code. If for any reason you need to render a view which does not return an HTTP 200
217 | status code, for example you also want to statically generate a 404 page which has a
218 | view which (correctly) returns an HTTP 404 status code you can use the
219 | `distill_status_codes` optional argument to a view. For example:
220 |
221 | ```python
222 | from django_distill import distill_url
223 |
224 | urlpatterns = (
225 | distill_url(r'some/regex'
226 | SomeView.as_view(),
227 | name='url-view',
228 | distill_status_codes=(200, 404),
229 | distill_func=some_func),
230 | )
231 | ```
232 |
233 | The optional `distill_status_codes` argument accepts a tuple of status codes as integers
234 | which are permitted for the view to return without raising an error. By default this is
235 | set to `(200,)` but you can override it if you need to for your site.
236 |
237 | ### Tracking Django's URL function support
238 |
239 | `django-distill` will mirror whatever your installed version of Django supports,
240 | therefore at some point the `distill_url` function will cease working in the future
241 | when Django 2.x itself depreciates the `django.conf.urls.url` and `django.urls.url`
242 | functions. You can use `distill_re_path` as a drop-in replacement. It is advisable to
243 | use `distill_path` or `distill_re_path` if you're building a new site now.
244 |
245 |
246 | ### Internationalization
247 |
248 | Internationalization is only supported for URLs, page content is unable to be
249 | dynamically translated. By default your site will be generated using the
250 | `LANGUAGE_CODE` value in your `settings.py`. If you also set `settings.USE_I18N` to
251 | `True` then set other language codes in your `settings.DISTILL_LANGUAGES` value and register
252 | URLs with `i18n_patterns(...)` then your site will be generated in multiple languges.
253 | This assumes your multi-language site works as expected before adding `django-distill`.
254 |
255 | For example if you set `settings.LANGUAGE_CODE = 'en'` your site will be
256 | generated in one language.
257 |
258 | If you have something like this in your `settings.py` instead:
259 |
260 | ```python
261 | USE_I18N = True
262 |
263 | DISTILL_LANGUAGES = [
264 | 'en',
265 | 'fr',
266 | 'de',
267 | ]
268 | ```
269 |
270 | While also using `i18n_patterns`in your `urls.py` like so:
271 |
272 | ```python
273 | from django.conf.urls.i18n import i18n_patterns
274 | from django_distill import distill_path
275 |
276 | urlpatterns = i18n_patterns(
277 | distill_path('some-file.html',
278 | SomeView.as_view(),
279 | name='i18n-view',
280 | distill_func=some_func
281 | )
282 | )
283 | ```
284 |
285 | Then your views will be generaged as `/en/some-file.html`, `/fr/some-file.html`
286 | and `/de/some-file.html`. These URLs should work (and be translated) by your
287 | site already. `django-distill` doesn't do any translation magic, it just
288 | calls the URLs with the language code prefix.
289 |
290 | **Note** While the default suggested method is to use `settings.DISTILL_LANGUAGES`
291 | to keep things seperate `django-distill` will also check `settings.LANGUAGES` for
292 | language codes.
293 |
294 |
295 | ### Sitemaps
296 |
297 | You may need to generate a list of all the URLs registered with `django-distill`.
298 | For example, you have a statically generated blog with a few hundred pages and
299 | you want to list all of the URLs easily in a `sitemap.xml` or other similar list
300 | of all URLs. You could wrap your sitemap view in `distill_path` then replicate
301 | all of your URL generation logic by importing your views `distill_func`s from
302 | your `urls.py` and generating these all manually, but given this is quite a hassle
303 | there's a built-in helper to generate all your URLs that will be distilled for you.
304 |
305 | ```python
306 | from django_distill import distilled_urls
307 |
308 | for uri, file_name in distilled_urls():
309 | # URI is the generated, complete URI for the page
310 | print(uri) # for example: /blog/my-post-123/
311 | # file_name is the actual file name on disk, this may be None or a string
312 | print(file_name) # for example: /blog/my-post-123/index.html
313 | ```
314 |
315 | **Note** that `distilled_urls()` will only return URLs after all of your URLs
316 | in `urls.py` have been loaded with `distill_path(...)`.
317 |
318 |
319 | # The `distill-local` command
320 |
321 | Once you have wrapped the URLs you want to generate statically you can now
322 | generate a complete functioning static site with:
323 |
324 | ```bash
325 | $ ./manage.py distill-local [optional /path/to/export/directory]
326 | ```
327 |
328 | Under the hood this simply iterates all URLs registered with `distill_url` and
329 | generates the pages for them using parts of the Django testing framework to
330 | spoof requests. Once the site pages have been rendered then files from the
331 | `STATIC_ROOT` are copied over. Existing files with the same name are replaced in
332 | the target directory and orphan files are deleted.
333 |
334 | `distill-local` supports the following optional arguments:
335 |
336 | `--collectstatic`: Automatically run `collectstatic` on your site before
337 | rendering, this is just a shortcut to save you typing an extra command.
338 |
339 | `--quiet`: Disable all output other than asking confirmation questions.
340 |
341 | `--force`: Assume 'yes' to all confirmation questions.
342 |
343 | `--exclude-staticfiles`: Do not copy any static files at all, only render output from
344 | Django views.
345 |
346 | `--parallel-render [number of threads]`: Render files in parallel on multiple
347 | threads, this can speed up rendering. Defaults to `1` thread.
348 |
349 | `--generate-redirects`: Attempt to generate static redirects stored in the
350 | `django.contrib.redirects` app. If you have a redirect from `/old/` to `/new/` using
351 | this flag will create a static HTML ``
352 | style redirect at `/old/index.html` to `/new/`.
353 |
354 | **Note** If any of your views contain a Python error then rendering will fail
355 | then the stack trace will be printed to the terminal and the rendering command
356 | will exit with a status code of 1.
357 |
358 |
359 | # The `distill-publish` command
360 |
361 | ```bash
362 | $ ./manage.py distill-publish [optional destination here]
363 | ```
364 |
365 | If you have configured at least one publishing destination (see below) you can
366 | use the `distill-publish` command to publish the site to a remote location.
367 |
368 | This will perform a full synchronisation, removing any remote files that are no
369 | longer present in the generated static site and uploading any new or changed
370 | files. The site will be built into a temporary directory locally first when
371 | publishing which is deleted once the site has been published. Each file will be
372 | checked that it has been published correctly by requesting it via the
373 | `PUBLIC_URL`.
374 |
375 | `distill-publish` supports the following optional arguments:
376 |
377 | `--collectstatic`: Automatically run `collectstatic` on your site before
378 | rendering, this is just a shortcut to save you typing an extra command.
379 |
380 | `--quiet`: Disable all output other than asking confirmation questions.
381 |
382 | `--force`: Assume 'yes' to all confirmation questions.
383 |
384 | `--exclude-staticfiles`: Do not copy any static files at all, only render output from
385 | Django views.
386 |
387 | `--skip-verify`: Do not test if files are correctly uploaded on the server.
388 |
389 | `--ignore-remote-content`: Do not fetch the list of remote files. It means that all
390 | files will be uploaded, and no existing remote file will be deleted. This can be
391 | useful if you have a lot of files on the remote server, and you know that you want
392 | to update most of them, and you don't care if old files remain on the server.
393 |
394 | `--parallel-publish [number of threads]`: Publish files in parallel on multiple
395 | threads, this can speed up publishing. Defaults to `1` thread.
396 |
397 | `--parallel-render [number of threads]`: Render files in parallel on multiple
398 | threads, this can speed up rendering. Defaults to `1` thread.
399 |
400 | `--generate-redirects`: Attempt to generate static redirects stored in the
401 | `django.contrib.redirects` app. If you have a redirect from `/old/` to `/new/` using
402 | this flag will create a static HTML ``
403 | style redirect at `/old/index.html` to `/new/`.
404 |
405 | **Note** that this means if you use `--force` and `--quiet` that the output
406 | directory will have all files not part of the site export deleted without any
407 | confirmation.
408 |
409 | **Note** If any of your views contain a Python error then rendering will fail
410 | then the stack trace will be printed to the terminal and the rendering command
411 | will exit with a status code of 1.
412 |
413 |
414 | # The `distill-test-publish` command
415 |
416 | ```bash
417 | $ ./manage.py distill-test-publish [optional destination here]
418 | ```
419 |
420 | This will connect to your publishing target, authenticate to it, upload a
421 | randomly named file, verify it exists on the `PUBLIC_URL` and then delete it
422 | again. Use this to check your publishing settings are correct.
423 |
424 | `distill-test-publish` has no arguments.
425 |
426 |
427 | # Optional configuration settings
428 |
429 | You can set the following optional `settings.py` variables:
430 |
431 | **DISTILL_DIR**: string, default directory to export to:
432 |
433 | ```python
434 | DISTILL_DIR = '/path/to/export/directory'
435 | ```
436 |
437 | **DISTILL_PUBLISH**: dictionary, like Django's `settings.DATABASES`, supports
438 | `default`:
439 |
440 | ```python
441 | DISTILL_PUBLISH = {
442 | 'default': {
443 | ... options ...
444 | },
445 | 'some-other-target': {
446 | ... options ...
447 | },
448 | }
449 | ```
450 |
451 | **DISTILL_SKIP_ADMIN_DIRS**: bool, defaults to `True`
452 |
453 | ```python
454 | DISTILL_SKIP_ADMIN_DIRS = True
455 | ```
456 |
457 | Set `DISTILL_SKIP_ADMIN_DIRS` to `False` if you want `django-distill` to also copy over
458 | static files in the `static/admin` directory. Usually, these are not required or
459 | desired for statically generated sites. The default behaviour is to skip static admin
460 | files.
461 |
462 |
463 | **DISTILL_SKIP_STATICFILES_DIRS**: list, defaults to `[]`
464 |
465 | ```python
466 | DISTILL_SKIP_STATICFILES_DIRS = ['some_dir']
467 | ```
468 |
469 | Set `DISTILL_SKIP_STATICFILES_DIRS` to a list of directory names you want `django-distill`
470 | to ignore directories in your defined `static/` directory. You can use this to ignore
471 | copying directories containing files from apps you're not using that get bundled into your
472 | `static/` directory by `collect-static`. For example if you set `DISTILL_SKIP_STATICFILES_DIRS`
473 | to `['some_dir']` the static files directory `static/some_dir` would be skipped.
474 |
475 |
476 | **DISTILL_LANGUAGES**: list, defaults to `[]`
477 |
478 | ```python
479 | DISTILL_LANGUAGES = [
480 | 'en',
481 | 'fr',
482 | 'de',
483 | ]
484 | ```
485 |
486 | Set `DISTILL_LANGUAGES` to a list of language codes to attempt to render URLs with.
487 | See the "Internationalization" section for more details.
488 |
489 |
490 | # Developing locally with HTTPS
491 |
492 | If you are using a local development environment which has HTTPS support you may need
493 | to add `SECURE_SSL_REDIRECT = False` to your `settings.py` to prevent a `CommandError`
494 | being raised when a request returns a 301 redirect instead of the expected HTTP/200
495 | response code.
496 |
497 |
498 | # Writing single files
499 |
500 | As of `django-distill` version `3.0.0` you can use the
501 | `django_distill.renderer.render_single_file` method to write out a single file
502 | to disk using `django_distill`. This is useful for writing out single files to disk,
503 | for example, you have a Django site which has some static files in a directory
504 | written by `django_distill` but the rest of the site is a normal dynamic Django site.
505 | You can update a static HTML file every time a model instance is saved. You can
506 | use single file writing with signals to achieve this. For example:
507 |
508 | ```python
509 | # in models.py
510 | from django.db.models.signals import post_save
511 | from django.dispatch import receiver
512 | from django_distill.renderer import render_single_file
513 |
514 | @receiver(post_save, sender=SomeBlogPostModel)
515 | def write_blog_post_static_file_post_save(sender, **kwargs):
516 | render_single_file(
517 | '/path/to/output/directory',
518 | 'blog-post-view-name',
519 | blog_id=sender.pk,
520 | blog_slug=sender.slug
521 | )
522 | ```
523 |
524 | The syntax for `render_single_file` is similar to Django's `url.reverse`. The full
525 | usage interface is:
526 |
527 | ```python
528 | render_single_file(
529 | '/path/to/output/directory',
530 | 'view-name-set-in-urls-py',
531 | *view_args,
532 | **view_kwargs
533 | )
534 | ```
535 |
536 | For example, if you had a blog post URL defined as:
537 |
538 | ```python
539 | # in urls.py
540 | distill_path('post/_.html',
541 | PostView.as_view(),
542 | name='blog-post',
543 | distill_func=get_all_blogposts),
544 | ```
545 |
546 | Your usage would be:
547 |
548 | ```python
549 | render_single_file(
550 | '/path/to/output/directory',
551 | 'blog-post',
552 | blog_id=123,
553 | blog_slug='blog-title-slug',
554 | )
555 | ```
556 |
557 | which would write out the contents of `/post/123_blog-title-slug.html` into
558 | `/path/to/output/directory` as the file
559 | `/path/to/output/directory/post/123_blog-title-slug.html`. Note any required
560 | sub-directories (`/path/to/output/directory/post` in this example) will be
561 | automatically created if they don't already exist. All `django-distill` rules
562 | apply, such as URLs ending in `/` will be saved as `/index.html` to make sense
563 | for a physical file on disk.
564 |
565 | Also note that `render_single_file` can only be imported and used into an
566 | initialised Django project.
567 |
568 |
569 | # Publishing targets
570 |
571 | You can automatically publish sites to various supported remote targets through
572 | backends just like how you can use MySQL, SQLite, PostgreSQL etc. with
573 | Django by changing the backend database engine. Currently the engines supported
574 | by `django-distill` are:
575 |
576 | **django_distill.backends.amazon_s3**: Publish to an Amazon S3 bucket. Requires
577 | the Python library `boto3` (`$ pip install django-distill[amazon]`). The bucket
578 | must already exist (use the AWS control panel). Options:
579 |
580 | ```python
581 | 'some-s3-container': {
582 | 'ENGINE': 'django_distill.backends.amazon_s3',
583 | 'PUBLIC_URL': 'http://.../',
584 | 'ACCESS_KEY_ID': '...',
585 | 'SECRET_ACCESS_KEY': '...',
586 | 'BUCKET': '...',
587 | 'ENDPOINT_URL': 'https://.../', # Optional, set to use a different S3 endpoint
588 | 'DEFAULT_CONTENT_TYPE': 'application/octet-stream', # Optional
589 | },
590 | ```
591 |
592 | **django_distill.backends.google_storage**: Publish to a Google Cloud Storage
593 | bucket. Requires the Python libraries `google-api-python-client` and
594 | `google-cloud-storage`
595 | (`$ pip install django-distill[google]`). The bucket
596 | must already exist and be set up to host a public static website (use the
597 | Google Cloud control panel). Options:
598 |
599 | ```python
600 | 'some-google-storage-bucket': {
601 | 'ENGINE': 'django_distill.backends.google_storage',
602 | 'PUBLIC_URL': 'https://storage.googleapis.com/[bucket.name.here]/',
603 | 'BUCKET': '[bucket.name.here]',
604 | 'JSON_CREDENTIALS': '/path/to/some/credentials.json',
605 | },
606 | ```
607 |
608 | Note that `JSON_CREDENTIALS` is optional; if it is not specified, the google libraries
609 | will try other authentication methods, in the search order described here:
610 | https://cloud.google.com/docs/authentication/application-default-credentials (e.g. the
611 | `GOOGLE_APPLICATION_CREDENTIALS` environment variable, attached service account, etc).
612 |
613 |
614 | **django_distill.backends.microsoft_azure_storage**: Publish to a Microsoft
615 | Azure Blob Storage container. Requires the Python library
616 | `azure-storage-blob` (`$ pip install django-distill[microsoft]`). The storage
617 | account must already exist and be set up to host a public static website
618 | (use the Microsoft Azure control panel). Options:
619 |
620 | ```python
621 | 'some-microsoft-storage-account': {
622 | 'ENGINE': 'django_distill.backends.microsoft_azure_storage',
623 | 'PUBLIC_URL': 'https://[storage-account-name]...windows.net/',
624 | 'CONNECTION_STRING': '...',
625 | },
626 | ```
627 |
628 | Note that each Azure storage account supports one static website using the
629 | magic container `$web` which is where `django-distill` will attempt to
630 | publish your site.
631 |
632 |
633 | # Tests
634 |
635 | There is a minimal test suite, you can run it by cloing this repository,
636 | installing the required dependancies in `requirements.txt` then execuiting:
637 |
638 | ```bash
639 | # ./run-tests.py
640 | ```
641 |
642 |
643 | # Contributing
644 |
645 | All properly formatted and sensible pull requests, issues and comments are
646 | welcome.
647 |
--------------------------------------------------------------------------------