├── tests └── __init__.py ├── django_docutils ├── __init__.py ├── lib │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_writers.py │ │ └── test_utils.py │ ├── metadata │ │ ├── __init__.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── test_extract.py │ │ │ └── test_process.py │ │ ├── process.py │ │ ├── processors.py │ │ └── extract.py │ ├── transforms │ │ ├── __init__.py │ │ ├── font_awesome.py │ │ ├── toc.py │ │ ├── ads.py │ │ └── code.py │ ├── fixtures │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── test_fixtures.py │ │ │ ├── test_utils.py │ │ │ ├── conftest.py │ │ │ └── test_load.py │ │ ├── directory │ │ │ ├── tests │ │ │ │ ├── __init__.py │ │ │ │ ├── test_utils.py │ │ │ │ ├── test_extract.py │ │ │ │ ├── test_multifile.py │ │ │ │ └── conftest.py │ │ │ ├── __init__.py │ │ │ ├── extract.py │ │ │ └── utils.py │ │ ├── __init__.py │ │ └── utils.py │ ├── templatetags │ │ ├── __init__.py │ │ └── rst.py │ ├── templates │ │ └── rst │ │ │ ├── base.html │ │ │ └── raw.html │ ├── settings.py │ ├── roles │ │ ├── kbd.py │ │ ├── email.py │ │ ├── pypi.py │ │ ├── url.py │ │ ├── twitter.py │ │ ├── hackernews.py │ │ ├── wikipedia.py │ │ ├── leanpub.py │ │ ├── readthedocs.py │ │ ├── github.py │ │ ├── develtech.py │ │ ├── amazon.py │ │ ├── file.py │ │ ├── common.py │ │ └── __init__.py │ ├── directives │ │ ├── __init__.py │ │ └── code.py │ ├── text.py │ ├── views.py │ └── utils.py ├── pygments │ └── __init__.py ├── references │ ├── __init__.py │ ├── rst │ │ ├── __init__.py │ │ ├── transforms │ │ │ ├── __init__.py │ │ │ └── xref.py │ │ ├── utils.py │ │ └── nodes.py │ ├── intersphinx │ │ └── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── loaddata_intersphinx.py │ └── models.py ├── favicon │ ├── rst │ │ ├── __init__.py │ │ ├── transforms │ │ │ ├── __init__.py │ │ │ └── favicon.py │ │ └── nodes.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_app │ │ │ ├── __init__.py │ │ │ ├── apps.py │ │ │ └── models.py │ │ ├── test_store.py │ │ ├── conftest.py │ │ ├── test_scrape.py │ │ └── test_prefetch.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── prefetch_favicons.py │ ├── __init__.py │ ├── models.py │ ├── scrape.py │ └── prefetch.py ├── templatetags │ ├── __init__.py │ └── django_docutils.py ├── rst_post │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── loaddata_grow.py │ └── models │ │ ├── tests │ │ ├── conftest.py │ │ └── test_models.py │ │ ├── __init__.py │ │ ├── post_page.py │ │ ├── post.py │ │ ├── utils.py │ │ └── checks.py ├── models.py ├── exc.py ├── __about__.py ├── engines.py ├── views.py └── directives.py ├── .python-version ├── docs ├── redirects.txt ├── contributing.rst ├── history.md ├── _static │ ├── img │ │ └── icons │ │ │ ├── logo.png │ │ │ ├── favicon.ico │ │ │ ├── apple-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon-96x96.png │ │ │ ├── ms-icon-70x70.png │ │ │ ├── apple-icon-57x57.png │ │ │ ├── apple-icon-60x60.png │ │ │ ├── apple-icon-72x72.png │ │ │ ├── apple-icon-76x76.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── ms-icon-144x144.png │ │ │ ├── ms-icon-150x150.png │ │ │ ├── ms-icon-310x310.png │ │ │ ├── mstile-150x150.png │ │ │ ├── android-icon-36x36.png │ │ │ ├── android-icon-48x48.png │ │ │ ├── android-icon-72x72.png │ │ │ ├── android-icon-96x96.png │ │ │ ├── apple-icon-114x114.png │ │ │ ├── apple-icon-120x120.png │ │ │ ├── apple-icon-144x144.png │ │ │ ├── apple-icon-152x152.png │ │ │ ├── apple-icon-180x180.png │ │ │ ├── android-chrome-36x36.png │ │ │ ├── android-chrome-48x48.png │ │ │ ├── android-chrome-72x72.png │ │ │ ├── android-chrome-96x96.png │ │ │ ├── android-icon-144x144.png │ │ │ ├── android-icon-192x192.png │ │ │ ├── android-chrome-144x144.png │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-256x256.png │ │ │ ├── android-chrome-384x384.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-icon-precomposed.png │ │ │ └── safari-pinned-tab.svg │ ├── css │ │ └── custom.css │ └── django-docutils.css ├── _templates │ ├── star.html │ ├── more.html │ ├── sidebar │ │ └── projects.html │ └── layout.html ├── index.md ├── api.md ├── quickstart.md ├── manifest.json └── conf.py ├── poetry.toml ├── .tool-versions ├── .tmuxp-before-script.sh ├── .github ├── dependabot.yml └── workflows │ ├── tests.yml │ └── docs.yml ├── MANIFEST.in ├── .readthedocs.yml ├── .codecov.yml ├── .pre-commit-config.yaml ├── .gitignore ├── .tmuxp.yaml ├── setup.cfg ├── LICENSE ├── Makefile ├── CHANGES ├── pyproject.toml └── CONTRIBUTING.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/lib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/lib/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/pygments/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/references/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/favicon/rst/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/favicon/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/lib/metadata/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/lib/transforms/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/references/rst/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.10.4 3.9.9 3.8.11 3.7.12 2 | -------------------------------------------------------------------------------- /django_docutils/favicon/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/lib/metadata/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/lib/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/rst_post/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/favicon/rst/transforms/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /django_docutils/references/intersphinx/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/references/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/references/rst/transforms/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/redirects.txt: -------------------------------------------------------------------------------- 1 | "usage.rst" "quickstart.md" 2 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /django_docutils/favicon/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/directory/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/references/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_docutils/rst_post/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | poetry 1.1.12 2 | python 3.10.1 3.9.9 3.8.11 3.7.12 3 | -------------------------------------------------------------------------------- /django_docutils/exc.py: -------------------------------------------------------------------------------- 1 | class BasedException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /docs/history.md: -------------------------------------------------------------------------------- 1 | (history)= 2 | 3 | ```{include} ../CHANGES 4 | 5 | ``` 6 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | """reStructuredText data fixtures for django apps.""" 2 | -------------------------------------------------------------------------------- /django_docutils/rst_post/models/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from django_docutils.favicon.tests.conftest import * # NOQA: F4 2 | -------------------------------------------------------------------------------- /docs/_static/img/icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/logo.png -------------------------------------------------------------------------------- /docs/_static/img/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/favicon.ico -------------------------------------------------------------------------------- /django_docutils/favicon/tests/test_app/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "django_docutils.favicon.tests.test_app.apps.TestAppConfig" 2 | -------------------------------------------------------------------------------- /docs/_static/img/icons/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/apple-icon.png -------------------------------------------------------------------------------- /docs/_static/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /docs/_static/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /docs/_static/img/icons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/favicon-96x96.png -------------------------------------------------------------------------------- /docs/_static/img/icons/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/ms-icon-70x70.png -------------------------------------------------------------------------------- /docs/_static/img/icons/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/apple-icon-57x57.png -------------------------------------------------------------------------------- /docs/_static/img/icons/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/apple-icon-60x60.png -------------------------------------------------------------------------------- /docs/_static/img/icons/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/apple-icon-72x72.png -------------------------------------------------------------------------------- /docs/_static/img/icons/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/apple-icon-76x76.png -------------------------------------------------------------------------------- /docs/_static/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/_static/img/icons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/ms-icon-144x144.png -------------------------------------------------------------------------------- /docs/_static/img/icons/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/ms-icon-150x150.png -------------------------------------------------------------------------------- /docs/_static/img/icons/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/ms-icon-310x310.png -------------------------------------------------------------------------------- /docs/_static/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /docs/_static/img/icons/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-icon-36x36.png -------------------------------------------------------------------------------- /docs/_static/img/icons/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-icon-48x48.png -------------------------------------------------------------------------------- /docs/_static/img/icons/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-icon-72x72.png -------------------------------------------------------------------------------- /docs/_static/img/icons/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-icon-96x96.png -------------------------------------------------------------------------------- /docs/_static/img/icons/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/apple-icon-114x114.png -------------------------------------------------------------------------------- /docs/_static/img/icons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/apple-icon-120x120.png -------------------------------------------------------------------------------- /docs/_static/img/icons/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/apple-icon-144x144.png -------------------------------------------------------------------------------- /docs/_static/img/icons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/apple-icon-152x152.png -------------------------------------------------------------------------------- /docs/_static/img/icons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/apple-icon-180x180.png -------------------------------------------------------------------------------- /.tmuxp-before-script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | poetry shell --no-ansi --no-interaction &2> /dev/null 3 | poetry install --no-ansi --no-interaction &2> /dev/null 4 | -------------------------------------------------------------------------------- /docs/_static/img/icons/android-chrome-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-chrome-36x36.png -------------------------------------------------------------------------------- /docs/_static/img/icons/android-chrome-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-chrome-48x48.png -------------------------------------------------------------------------------- /docs/_static/img/icons/android-chrome-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-chrome-72x72.png -------------------------------------------------------------------------------- /docs/_static/img/icons/android-chrome-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-chrome-96x96.png -------------------------------------------------------------------------------- /docs/_static/img/icons/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-icon-144x144.png -------------------------------------------------------------------------------- /docs/_static/img/icons/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-icon-192x192.png -------------------------------------------------------------------------------- /docs/_static/img/icons/android-chrome-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-chrome-144x144.png -------------------------------------------------------------------------------- /docs/_static/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/_static/img/icons/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-chrome-256x256.png -------------------------------------------------------------------------------- /docs/_static/img/icons/android-chrome-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-chrome-384x384.png -------------------------------------------------------------------------------- /docs/_static/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/_static/img/icons/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dragon1207/django-docutils/HEAD/docs/_static/img/icons/apple-icon-precomposed.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /django_docutils/favicon/rst/nodes.py: -------------------------------------------------------------------------------- 1 | from docutils import nodes 2 | 3 | 4 | class icon(nodes.Inline, nodes.TextElement): 5 | pass 6 | 7 | 8 | nodes._add_node_class_names("icon") 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CONTRIBUTING.rst 2 | include CHANGES 3 | include LICENSE 4 | include README.md 5 | include .tmuxp.yaml .tmuxp-before-script.sh 6 | recursive-include docs *.rst 7 | recursive-include django_docutils *.py 8 | -------------------------------------------------------------------------------- /django_docutils/lib/templates/rst/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content_inner %} 4 |
5 | {{content}} 6 |
7 | {% endblock content_inner %} 8 | 9 | -------------------------------------------------------------------------------- /django_docutils/favicon/tests/test_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestAppConfig(AppConfig): 5 | name = "django_docutils.favicon.tests.test_app" 6 | label = "test_app" 7 | verbose_name = "For pytest" 8 | -------------------------------------------------------------------------------- /django_docutils/lib/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | BASED_LIB_RST = getattr(settings, "BASED_LIB_RST", {}) 4 | 5 | INJECT_FONT_AWESOME = ( 6 | BASED_LIB_RST.get("font_awesome", {}).get("url_patterns") is not None 7 | ) 8 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | image: latest 4 | python: 5 | version: 3.6 6 | install: 7 | - method: pip 8 | path: . 9 | extra_requirements: 10 | - docs 11 | - requirements: doc/requirements.txt 12 | -------------------------------------------------------------------------------- /docs/_templates/star.html: -------------------------------------------------------------------------------- 1 |

2 | 4 |

5 | -------------------------------------------------------------------------------- /django_docutils/rst_post/models/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: NOQA F4 2 | from .checks import _check_postpage_post_back_relation, _check_root_page 3 | from .post import RSTPostBase, get_post_model, get_post_models 4 | from .post_page import RSTPostPageBase, get_postpage_models 5 | -------------------------------------------------------------------------------- /django_docutils/references/rst/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from docutils import nodes 4 | 5 | 6 | def set_role_source_info(inliner: Any, lineno: int, node: nodes.Node): 7 | """From sphinx, for intersphinx""" 8 | node.source, node.line = inliner.reporter.get_source_and_line(lineno) 9 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | require_ci_to_pass: no 4 | 5 | coverage: 6 | precision: 2 7 | round: down 8 | range: "70...100" 9 | status: 10 | project: 11 | default: 12 | target: auto 13 | threshold: 1% 14 | base: auto 15 | patch: off 16 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | (index)= 2 | 3 | ```{include} ../README.md 4 | 5 | ``` 6 | 7 | ```{toctree} 8 | :maxdepth: 2 9 | :hidden: 10 | 11 | quickstart 12 | api 13 | ``` 14 | 15 | ```{toctree} 16 | :caption: Project 17 | :hidden: 18 | 19 | history 20 | contributing 21 | GitHub 22 | 23 | ``` 24 | -------------------------------------------------------------------------------- /django_docutils/references/rst/nodes.py: -------------------------------------------------------------------------------- 1 | from docutils import nodes 2 | 3 | 4 | class pending_xref(nodes.Inline, nodes.Element): 5 | 6 | """Node for cross-references that cannot be resolved without complete 7 | information about all documents. 8 | 9 | These nodes are resolved before writing output, in 10 | BuildEnvironment.resolve_references. 11 | """ 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 21.3.0 4 | hooks: 5 | - id: black 6 | language_version: python3.10 7 | - repo: https://github.com/pycqa/isort 8 | rev: 5.10.1 9 | hooks: 10 | - id: isort 11 | name: isort (python) 12 | - repo: https://gitlab.com/pycqa/flake8 13 | rev: 4.0.1 14 | hooks: 15 | - id: flake8 16 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/kbd.py: -------------------------------------------------------------------------------- 1 | from docutils import nodes 2 | 3 | 4 | def kbd_role(name, rawtext, text, lineno, inliner, options=None, content=None): 5 | html = "" 6 | keys = text.split(",") 7 | 8 | if isinstance(keys, str): 9 | keys = [keys] 10 | 11 | for key in keys: 12 | html += f"{key}" 13 | 14 | return [nodes.raw("", html, format="html")], [] 15 | -------------------------------------------------------------------------------- /django_docutils/lib/templates/rst/raw.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load rst %} 3 | {% load render_entrypoint from webpack_loader %} 4 | 5 | {% block content_inner %} 6 |
7 | {% restructuredtext content inject_ads=inject_ads %} 8 |
9 | {% endblock content %} 10 | 11 | {% block extra_js %} 12 | {% render_entrypoint 'toc_scroller' 'js' %} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/directory/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Test reST fixture files in directory format, "dir".""" 2 | 3 | from django_docutils.lib.fixtures.directory.utils import find_rst_dir_projects 4 | 5 | 6 | def test_find_rst_dir_projects(tmpdir): 7 | tmpdir.mkdir("wat") 8 | tmpdir.join("wat/README.rst").write("") 9 | tmpdir.join("wat/manifest.json").write("") 10 | 11 | assert [str(tmpdir.join("wat"))] == find_rst_dir_projects(str(tmpdir)) 12 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/directory/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This format is different: 3 | 4 | 1. It resides in its own directory 5 | 2. Includes a JSON file 6 | 3. Does not store configuration in the RST file itself 7 | 8 | This is intended to ensure these projects can be open source repositories 9 | with collaborators on GitHub. 10 | 11 | Here is what a layout of "dir"-type RST fixtures look like: 12 | 13 | fixtures/ 14 | - myproject/ 15 | - manifest.json: post content configuration information (e.g. title, taxonomy) 16 | - README.rst: reStructuredText content 17 | """ 18 | -------------------------------------------------------------------------------- /django_docutils/__about__.py: -------------------------------------------------------------------------------- 1 | __title__ = "django-docutils" 2 | __package_name__ = "django_docutils" 3 | __description__ = "Documentation Utilities (Docutils, reStructuredText) for django." 4 | __version__ = "0.6.0" 5 | __author__ = "Tony Narlock" 6 | __github__ = "https://github.com/tony/django-docutils" 7 | __pypi__ = "https://pypi.org/project/django-docutils/" 8 | __docs__ = "https://django-docutils.git-pull.com" 9 | __tracker__ = "https://github.com/tony/django-docutils/issues" 10 | __email__ = "tony@git-pull.com" 11 | __license__ = "MIT" 12 | __copyright__ = "Copyright 2013- Tony Narlock" 13 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/email.py: -------------------------------------------------------------------------------- 1 | from .common import generic_url_role 2 | 3 | 4 | def email_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 5 | """Role for linking to email articles. 6 | 7 | :email:`me@localhost` -> 8 | link: mailto:me@localhost 9 | text: me@localhost 10 | 11 | :email:`E-mail me ` -> 12 | link: mailto:me@localhost 13 | text: E-mail me 14 | 15 | """ 16 | 17 | def url_handler(target): 18 | return f"mailto:{target}" 19 | 20 | return generic_url_role(name, text, url_handler) 21 | -------------------------------------------------------------------------------- /django_docutils/favicon/tests/test_store.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django.core.files.uploadedfile import SimpleUploadedFile 4 | 5 | 6 | @pytest.mark.django_db(transaction=True) 7 | def test_stored_favicon(Favicon): 8 | favicon = Favicon() 9 | 10 | favicon_content = b"imgcontent" 11 | 12 | favicon.domain = "test.com" 13 | favicon.favicon = SimpleUploadedFile( 14 | name="test_image.jpg", content=favicon_content, content_type="image/ico" 15 | ) 16 | 17 | favicon.save() 18 | 19 | f = Favicon.objects.first() 20 | assert f.favicon.read() == favicon_content 21 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/directory/extract.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | 5 | def extract_dir_config(path): 6 | """Return config metadata for dir RST project. 7 | 8 | Automatically lowercases keys for a parity with docutils' docinfo behavior. 9 | 10 | :param path: path to project (directory) 11 | :type path: string 12 | :returns: metadata from json file 13 | :rtype: dict 14 | """ 15 | config_file = os.path.join(path, "manifest.json") 16 | config_dict = json.loads(open(config_file).read()) 17 | return {k.lower(): v for k, v in config_dict.items()} 18 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/pypi.py: -------------------------------------------------------------------------------- 1 | from .common import generic_url_role 2 | 3 | 4 | def pypi_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 5 | """Role for linking to pypi page. 6 | 7 | :pypi:`libsass` -> 8 | link: https://pypi.python.org/pypi/libsass 9 | text: libsass 10 | 11 | 12 | :pypi:`a pypi package ` -> 13 | link: https://pypi.python.org/pypi/libsass 14 | text: a pypi package 15 | """ 16 | 17 | def url_handler(target): 18 | return f"https://pypi.python.org/pypi/{target}" 19 | 20 | return generic_url_role(name, text, url_handler) 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | pip-wheel-metadata 18 | .installed.cfg 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | 37 | # Complexity 38 | output/*.html 39 | output/*/index.html 40 | 41 | # Sphinx 42 | docs/_build 43 | 44 | .eggs/ 45 | .cache/ 46 | .mypy_cache/ 47 | .pytest_cache/ 48 | .*env*/ 49 | -------------------------------------------------------------------------------- /.tmuxp.yaml: -------------------------------------------------------------------------------- 1 | session_name: django-docutils 2 | start_directory: ./ # load session relative to config location (project root). 3 | before_script: ./.tmuxp-before-script.sh 4 | shell_command_before: 5 | - '[ -f .venv/bin/activate ] && source .venv/bin/activate && reset' 6 | windows: 7 | - window_name: django-docutils 8 | focus: True 9 | layout: main-horizontal 10 | options: 11 | main-pane-height: 35 12 | panes: 13 | - focus: true 14 | - pane 15 | - make watch_test 16 | - window_name: docs 17 | layout: main-horizontal 18 | options: 19 | main-pane-height: 35 20 | start_directory: docs/ 21 | panes: 22 | - focus: true 23 | - pane 24 | - pane 25 | - make start 26 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/url.py: -------------------------------------------------------------------------------- 1 | from .common import generic_url_role 2 | 3 | 4 | def url_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 5 | """Role for linking to url articles. 6 | 7 | :url:`https://google.com` -> 8 | link: https://google.com 9 | text: https://google.com 10 | 11 | :url:`Google ` -> 12 | link: https://google.com 13 | text: Google 14 | 15 | :url:`*Google* ` -> 16 | link: https://google.com 17 | text (html): Google 18 | 19 | """ 20 | 21 | def url_handler(target): 22 | return target 23 | 24 | return generic_url_role(name, text, url_handler) 25 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/twitter.py: -------------------------------------------------------------------------------- 1 | from .common import generic_url_role 2 | 3 | 4 | def twitter_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 5 | """Role for linking to twitter articles. 6 | 7 | :twitter:`@username` -> 8 | link: https://twitter.com/username 9 | text: @username 10 | 11 | :twitter:`follow me on twitter <@username>` -> 12 | link: https://twitter.com/username 13 | text: follow on me on twitter 14 | """ 15 | 16 | def url_handler(target): 17 | if "@" in target: 18 | target = target.replace("@", "") 19 | 20 | return f"https://twitter.com/{target}" 21 | 22 | return generic_url_role(name, text, url_handler) 23 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | exclude = .*/,.tox, 6 | *.egg, 7 | django_docutils/_compat.py, 8 | django_docutils/__*__.py, 9 | */migrations 10 | select = E,W,F,N 11 | max-line-length = 88 12 | # Stuff we ignore thanks to black: https://github.com/ambv/black/issues/429 13 | ignore = E203,W503 14 | 15 | [isort] 16 | profile = black 17 | combine_as_imports= true 18 | default_section = THIRDPARTY 19 | include_trailing_comma = true 20 | multi_line_output = 3 21 | known_pytest = pytest,py 22 | known_first_party = django_docutils 23 | sections = FUTURE,STDLIB,PYTEST,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 24 | line_length = 88 25 | 26 | [tool:pytest] 27 | filterwarnings = 28 | ignore::PendingDeprecationWarning 29 | -------------------------------------------------------------------------------- /django_docutils/favicon/__init__.py: -------------------------------------------------------------------------------- 1 | """Favicons 2 | 3 | Short term: 4 | 5 | In the short term, we can use something similar the XRef transform, ran after 6 | that, which downloads and caches favicons. 7 | 8 | 9 | Long term: 10 | 11 | In the future, for this to work at scale, favicons will need to be traversed 12 | ahead of time. 13 | 14 | First, there will to be a way to iterate the content of all Node model 15 | object's content. This can be done via:: 16 | 17 | for content in PostPage.objects.all().values_list('body'): 18 | # get the doctree of content 19 | # traverse content nodes.reference 20 | # collect all domains/subdomains 21 | # pull favicons for them and store in Favicon model 22 | 23 | """ 24 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | (api)= 2 | 3 | # API Reference 4 | 5 | :::{seealso} 6 | 7 | {ref}`Quickstart `. 8 | 9 | ::: 10 | 11 | ## Internals 12 | 13 | ## Directives 14 | 15 | ```{eval-rst} 16 | .. autofunction:: django_docutils.directives.register_pygments_directive 17 | ``` 18 | 19 | ```{eval-rst} 20 | .. autodata:: django_docutils.directives.DEFAULT 21 | ``` 22 | 23 | ```{eval-rst} 24 | .. autodata:: django_docutils.directives.VARIANTS 25 | ``` 26 | 27 | ## Code block 28 | 29 | ```{eval-rst} 30 | .. autoclass:: django_docutils.directives.CodeBlock :members: :inherited-members: :private-members: 31 | :show-inheritance: :member-order: bysource 32 | ``` 33 | 34 | ## Exceptions 35 | 36 | ```{eval-rst} 37 | .. autoexception:: django_docutils.exc.BasedException 38 | ``` 39 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/hackernews.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import quote 2 | 3 | from .common import generic_url_role 4 | 5 | 6 | def hackernews_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 7 | """Role for linking to hackernews articles. 8 | 9 | :hn:`15610489` -> 10 | link: https://news.ycombinator.com/item?id=15610489 11 | text: 15610489 12 | 13 | 14 | :hn:`this hackernews article <15610489>` -> 15 | link: https://news.ycombinator.com/item?id=15610489 16 | text: this hackernews article 17 | """ 18 | 19 | def url_handler(target): 20 | target = quote(target.replace(" ", "_")) 21 | return f"https://news.ycombinator.com/item?id={target}" 22 | 23 | return generic_url_role(name, text, url_handler) 24 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/wikipedia.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import quote 2 | 3 | from .common import generic_url_role 4 | 5 | 6 | def wikipedia_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 7 | """Role for linking to Wikipedia articles. 8 | 9 | :wikipedia:`Don't repeat yourself` -> 10 | link: https://en.wikipedia.org/wiki/Don%27t_repeat_yourself 11 | text: Don't repeat yourself 12 | 13 | 14 | :wikipedia:`this wikipedia article ` -> 15 | link: https://github.com/vim-airline 16 | text: this wikipedia article 17 | """ 18 | 19 | def url_handler(target): 20 | target = quote(target.replace(" ", "_")) 21 | return f"https://en.wikipedia.org/wiki/{target}" 22 | 23 | return generic_url_role(name, text, url_handler) 24 | -------------------------------------------------------------------------------- /docs/_templates/more.html: -------------------------------------------------------------------------------- 1 |

Other Projects

2 | 3 |

4 | 5 |

More open source projects from Tony Narlock:

6 | 7 | 13 | 14 | 15 | Fork me on GitHub 18 | 19 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import py 2 | import pytest 3 | 4 | from django.apps import AppConfig 5 | 6 | from django_docutils.lib.fixtures.load import load_app_rst_fixtures 7 | 8 | 9 | @pytest.mark.django_db(transaction=True) 10 | def test_load_app_rst_fixtures_bare(bare_app_config, RSTPost): 11 | """Verifies the fixture acts as expected.""" 12 | assert isinstance(bare_app_config, AppConfig) 13 | 14 | bare_app = py.path.local(bare_app_config.path) 15 | fixtures_dir = bare_app.ensure("fixtures", dir=True) 16 | test_file = fixtures_dir.join("test.rst") 17 | test_file.write( 18 | """ 19 | === 20 | moo 21 | === 22 | 23 | :Author: anonymous 24 | :Slug_Id: tEst 25 | 26 | foo 27 | """.strip() 28 | ) 29 | 30 | posts = load_app_rst_fixtures(bare_app_config, model=RSTPost) 31 | assert len(posts) == 1 32 | -------------------------------------------------------------------------------- /django_docutils/favicon/tests/test_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_docutils.favicon.models import FaviconBase 4 | from django_docutils.references.models import ReferenceBase 5 | from django_docutils.rst_post.models import RSTPostBase, RSTPostPageBase 6 | 7 | 8 | class EmptyModel(models.Model): 9 | pass 10 | 11 | 12 | class Reference(ReferenceBase): 13 | pass 14 | 15 | 16 | class Favicon(FaviconBase): 17 | pass 18 | 19 | 20 | class RSTPost(RSTPostBase): 21 | root_page = models.ForeignKey( 22 | "RSTPostPage", null=True, on_delete=models.SET_NULL, related_name="+" 23 | ) 24 | 25 | 26 | class RSTPostSubclass(RSTPost): 27 | pass 28 | 29 | 30 | class RSTPostPage(RSTPostPageBase): 31 | 32 | post = models.ForeignKey( 33 | RSTPost, on_delete=models.CASCADE, related_name="pages", null=True 34 | ) 35 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/directory/tests/test_extract.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import py 4 | 5 | from django_docutils.lib.fixtures.directory.extract import extract_dir_config 6 | from django_docutils.lib.metadata.process import process_metadata 7 | 8 | 9 | def test_extract_dir_config(sample_dir_app_config): 10 | sample_dir_app_dir = py.path.local(sample_dir_app_config.path) 11 | project_dir = sample_dir_app_dir.join("fixtures").join("sample_project") 12 | 13 | raw_metadata = extract_dir_config(str(project_dir)) 14 | analyzed_metadata = process_metadata(raw_metadata.copy()) 15 | 16 | assert isinstance(analyzed_metadata["created"], datetime.date) 17 | 18 | assert process_metadata(raw_metadata) != { 19 | "programming_languages": ["python"], 20 | "topics": ["Web Frameworks"], 21 | "created": "2017-09-12", 22 | "author": "tony", 23 | } 24 | -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | h2 { 2 | margin-bottom: 1.25rem; 3 | margin-top: 1.25rem; 4 | scroll-margin-top: 0.5rem; 5 | } 6 | 7 | h3 { 8 | margin-bottom: 1.25rem; 9 | margin-top: 1.25rem; 10 | scroll-margin-top: 0.5rem; 11 | } 12 | 13 | h4 { 14 | margin-bottom: 1.25rem; 15 | scroll-margin-top: 0.5rem; 16 | } 17 | 18 | .sidebar-tree p.indented-block { 19 | padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0 20 | var(--sidebar-item-spacing-horizontal); 21 | margin-bottom: 0; 22 | } 23 | 24 | .sidebar-tree p.indented-block span.indent { 25 | margin-left: var(--sidebar-item-spacing-horizontal); 26 | display: block; 27 | } 28 | 29 | .sidebar-tree p.indented-block .project-name { 30 | font-size: var(--sidebar-item-font-size); 31 | font-weight: bold; 32 | margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5); 33 | } 34 | 35 | .sidebar-tree .active { 36 | font-weight: bold; 37 | } 38 | -------------------------------------------------------------------------------- /docs/_static/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 14 | 15 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/leanpub.py: -------------------------------------------------------------------------------- 1 | from .common import generic_url_role 2 | 3 | 4 | def leanpub_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 5 | """Role for linking to leanpub page. 6 | 7 | :leanpub:`the-tao-of-tmux` -> 8 | link: https://leanpub.com/the-tao-of-tmux 9 | text: the-tao-of-tmux 10 | 11 | :leanpub:`my book ` -> 12 | link: https://leanpub.com/the-tao-of-tmux 13 | text: my book 14 | 15 | :leanpub:`The Tao of tmux ` -> 16 | link: https://leanpub.com/the-tao-of-tmux/read 17 | text: The Tao of tmux 18 | """ 19 | 20 | def url_handler(target): 21 | if ":" in target: 22 | project, path = target.split(":") 23 | return "https://leanpub.com/{project}/{path}".format( 24 | project=project, path=path 25 | ) 26 | return f"https://leanpub.com/{target}" 27 | 28 | return generic_url_role(name, text, url_handler) 29 | -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | (quickstart)= 2 | 3 | # Quickstart 4 | 5 | ## Installation 6 | 7 | For latest official version: 8 | 9 | ```console 10 | $ pip install django-docutils 11 | ``` 12 | 13 | Upgrading: 14 | 15 | ```console 16 | $ pip install --upgrade django-docutils 17 | ``` 18 | 19 | (developmental-releases)= 20 | 21 | ### Developmental releases 22 | 23 | New versions of django-docutils are published to PyPI as alpha, beta, or release candidates. In 24 | their versions you will see notfication like `a1`, `b1`, and `rc1`, respectively. `1.10.0b4` would 25 | mean the 4th beta release of `1.10.0` before general availability. 26 | 27 | - [pip]\: 28 | 29 | ```console 30 | $ pip install --upgrade --pre django-docutils 31 | ``` 32 | 33 | via trunk (can break easily): 34 | 35 | - [pip]\: 36 | 37 | ```console 38 | $ pip install -e git+https://github.com/tony/django-docutils.git#egg=django-docutils 39 | ``` 40 | 41 | [pip]: https://pip.pypa.io/en/stable/ 42 | 43 | ## Usage 44 | 45 | ```python 46 | import django_docutils 47 | ``` 48 | 49 | See {ref}`Home page `. 50 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/directory/tests/test_multifile.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_docutils.exc import BasedException 4 | from django_docutils.lib.fixtures.directory.extract import extract_dir_config 5 | from django_docutils.lib.fixtures.directory.utils import ( 6 | find_rst_dirs_in_app, 7 | find_series_files, 8 | ) 9 | 10 | 11 | def test_dir_with_series(sample_dir_series_app_config): 12 | project_paths = find_rst_dirs_in_app(sample_dir_series_app_config) 13 | 14 | assert len(project_paths) == 1 15 | 16 | project_path = project_paths[0] # pick out the project 17 | 18 | config = extract_dir_config(project_path) 19 | 20 | # this function automatically checks that series files exist 21 | find_series_files(config, project_path) 22 | 23 | # raises exception if files not found 24 | with pytest.raises(BasedException, match=r"Files in .*"): 25 | # test it with missing files 26 | missing_file_config = config.copy() 27 | missing_file_config["series"].append("non-existant-file.rst") 28 | find_series_files(missing_file_config, project_path) 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015- tmuxp contributors 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 | -------------------------------------------------------------------------------- /django_docutils/templatetags/django_docutils.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.conf import settings 3 | from django.utils.encoding import force_bytes, force_str 4 | from django.utils.safestring import mark_safe 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.filter(is_safe=True) 10 | def restructuredtext(value): 11 | import warnings 12 | 13 | warnings.warn( 14 | "The restructuredtext filter has been deprecated", category=DeprecationWarning 15 | ) 16 | try: 17 | from docutils.core import publish_parts 18 | except ImportError: 19 | if settings.DEBUG: 20 | raise template.TemplateSyntaxError( 21 | "Error in 'restructuredtext' filter: The Python docutils " 22 | "library isn't installed." 23 | ) 24 | return force_str(value) 25 | else: 26 | docutils_settings = getattr(settings, "RESTRUCTUREDTEXT_FILTER_SETTINGS", {}) 27 | parts = publish_parts( 28 | source=force_bytes(value), 29 | writer_name="html5_polyglot", 30 | settings_overrides=docutils_settings, 31 | ) 32 | return mark_safe(force_str(parts["fragment"])) 33 | -------------------------------------------------------------------------------- /django_docutils/favicon/models.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps as django_apps 2 | from django.conf import settings 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.db import models 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | 8 | def get_favicon_model(): 9 | """ 10 | Return the Favicon model that is active in this project. 11 | """ 12 | try: 13 | return django_apps.get_model(settings.BASED_FAVICON_MODEL, require_ready=False) 14 | except ValueError: 15 | raise ImproperlyConfigured( 16 | "BASED_FAVICON_MODEL must be of the form 'app_label.model_name'" 17 | ) 18 | except LookupError: 19 | raise ImproperlyConfigured( 20 | "BASED_FAVICON_MODEL refers to model '%s' that has not been installed" 21 | % settings.BASED_FAVICON_MODEL 22 | ) 23 | 24 | 25 | class FaviconBase(models.Model): 26 | domain = models.URLField(verbose_name=_("Domain or subdomain"), unique=True) 27 | favicon = models.ImageField( 28 | verbose_name=("Path to icon in static files"), 29 | upload_to="favicons", 30 | blank=True, 31 | null=True, 32 | ) 33 | 34 | class Meta: 35 | abstract = True 36 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/readthedocs.py: -------------------------------------------------------------------------------- 1 | from .common import generic_url_role 2 | 3 | 4 | def readthedocs_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 5 | """Role for linking to readthedocs.org page. 6 | 7 | :rtd:`django-pipeline` -> 8 | link: https://django-pipeline.readthedocs.io/ 9 | text: django-pipeline 10 | 11 | :rtd:`a rtd site ` -> 12 | link: https://django-pipeline.readthedocs.io/ 13 | text: a rtd site 14 | 15 | :rtd:`python-guide:dev/virtualenvs` -> 16 | link: https://python-guide.readthedocs.io/en/latest/dev/virtualenvs/ 17 | text: python-guide:dev/virtualenvs 18 | 19 | :rtd:`about virtualenvs ` -> 20 | link: https://python-guide.readthedocs.io/en/latest/dev/virtualenvs/ 21 | text: about virtalenvs 22 | """ 23 | 24 | def url_handler(target): 25 | if ":" in target: 26 | project, path = target.split(":") 27 | return "https://{project}.readthedocs.io/en/latest/{path}".format( 28 | project=project, path=path 29 | ) 30 | return f"https://{target}.readthedocs.io" 31 | 32 | return generic_url_role(name, text, url_handler) 33 | -------------------------------------------------------------------------------- /django_docutils/favicon/tests/conftest.py: -------------------------------------------------------------------------------- 1 | # flake8: NOQA: F401 2 | 3 | import pytest 4 | 5 | from django.apps import apps 6 | 7 | 8 | @pytest.fixture 9 | def EmptyModel(favicon_app): 10 | return favicon_app.get_model("EmptyModel") 11 | 12 | 13 | @pytest.fixture 14 | def Favicon(favicon_app): 15 | return favicon_app.get_model("Favicon") 16 | 17 | 18 | @pytest.fixture 19 | def RSTPost(favicon_app): 20 | return favicon_app.get_model("RSTPost") 21 | 22 | 23 | @pytest.fixture 24 | def RSTPostPage(favicon_app): 25 | return favicon_app.get_model("RSTPostPage") 26 | 27 | 28 | @pytest.fixture 29 | def favicon_app(settings, request): 30 | app_name = "test_app" 31 | app_import_string = f"django_docutils.favicon.tests.{app_name}" 32 | 33 | if app_import_string not in settings.INSTALLED_APPS: 34 | settings.INSTALLED_APPS = settings.INSTALLED_APPS + (app_import_string,) 35 | 36 | def resource_a_teardown(): 37 | print("\nresources_a_teardown()") 38 | settings.INSTALLED_APPS = ( 39 | s for s in settings.INSTALLED_APPS if s != app_import_string 40 | ) 41 | 42 | assert app_import_string not in settings.INSTALLED_APPS 43 | 44 | request.addfinalizer(resource_a_teardown) 45 | 46 | return apps.get_app_config(app_name) 47 | -------------------------------------------------------------------------------- /django_docutils/lib/directives/__init__.py: -------------------------------------------------------------------------------- 1 | from django.utils.module_loading import import_string 2 | from docutils.parsers.rst import directives 3 | 4 | from ..settings import BASED_LIB_RST 5 | 6 | 7 | def register_based_directives(): 8 | """Register all directives, exists to avoid race conditions. 9 | 10 | Sometimes stuff like publish_parts can be ran from command line functions 11 | tests. There's also ways we could avoid this by placing it in __init__ 12 | of django_docutils.lib, but that's a bit implicit. Investigate that later. 13 | 14 | In order to make this work across django projects, let's use django 15 | settings to register to them. 16 | 17 | Why? Not all django projects want code highlighting (which requires 18 | pygments). Let's use a TEMPLATES-style django config:: 19 | 20 | BASED_LIB_RST = { 21 | 'directives': { #: directive-name: Directive class (import string) 22 | 'code-block': 'django_docutils.lib.directives.pygments.CodeBlock' 23 | } 24 | } 25 | """ 26 | if not BASED_LIB_RST: 27 | return 28 | 29 | if "directives" in BASED_LIB_RST: 30 | for dir_name, dir_cls_str in BASED_LIB_RST["directives"].items(): 31 | class_ = import_string(dir_cls_str) 32 | directives.register_directive(dir_name, class_) 33 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/github.py: -------------------------------------------------------------------------------- 1 | from .common import generic_url_role 2 | 3 | 4 | def github_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 5 | """Role for linking to GitHub repos and issues. 6 | 7 | :gh:`vim-airline` -> 8 | link: https://github.com/vim-airline 9 | text: vim-airline 10 | 11 | 12 | :gh:`vim-airline's org ` -> 13 | link: https://github.com/vim-airline 14 | text: vim-airline's org 15 | 16 | :gh:`vim-airline/vim-airline` -> 17 | link: https://github.com/vim-airline/vim-airline 18 | text: vim-airline/vim-airline 19 | 20 | :gh:`vim-airline/vim-airline#134` -> 21 | link: https://github.com/vim-airline/vim-airline/issues/134 22 | text: vim-airline/vim-airline#134 23 | 24 | :gh:`this example issue ` -> 25 | link: https://github.com/vim-airline/vim-airline/issues/134 26 | text: this example issue 27 | """ 28 | 29 | def url_handler(target): 30 | if "#" in target: 31 | user_n_repo, issue = target.split("#") 32 | if issue.isnumeric(): 33 | return f"https://github.com/{user_n_repo}/issues/{issue}" 34 | 35 | return f"https://github.com/{target}" 36 | 37 | return generic_url_role(name, text, url_handler) 38 | -------------------------------------------------------------------------------- /django_docutils/lib/tests/test_writers.py: -------------------------------------------------------------------------------- 1 | from django.utils.encoding import force_bytes 2 | from docutils.core import publish_doctree 3 | from docutils.writers.html5_polyglot import Writer 4 | 5 | from django_docutils.lib.publisher import publish_parts_from_doctree 6 | from django_docutils.lib.settings import BASED_LIB_RST 7 | from django_docutils.lib.writers import BasedWriter 8 | 9 | 10 | def test_HTMLWriter_hides_docinfo(): 11 | docutils_settings = BASED_LIB_RST.get("docutils", {}) 12 | 13 | content = """ 14 | =========== 15 | Hello world 16 | =========== 17 | 18 | :key1: value 19 | :key2: value 20 | 21 | more text 22 | 23 | first section 24 | ------------- 25 | 26 | some content 27 | """.strip() 28 | 29 | doctree = publish_doctree( 30 | source=force_bytes(content), settings_overrides=docutils_settings 31 | ) 32 | 33 | # Test that normal writer will show docinfo in HTML 34 | parts = publish_parts_from_doctree( 35 | doctree, writer=Writer(), settings_overrides=docutils_settings 36 | ) 37 | assert "key1" in parts["html_body"] 38 | 39 | # Our writer should *not* output docinto 40 | parts = publish_parts_from_doctree( 41 | doctree, writer=BasedWriter(), settings_overrides=docutils_settings 42 | ) 43 | 44 | assert "key1" not in parts["html_body"] 45 | assert "first section" in parts["html_body"] 46 | -------------------------------------------------------------------------------- /django_docutils/references/management/commands/loaddata_intersphinx.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.db import DEFAULT_DB_ALIAS, connections, transaction 3 | 4 | from django_docutils.references.intersphinx.load import load_mappings 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Installs / Updates growth rst file(s) in the database." 9 | 10 | def handle(self, **options): 11 | self.using = options["database"] 12 | with transaction.atomic(using=self.using): 13 | self.loaddata() 14 | 15 | # Close the DB connection -- unless we're still in a transaction. This 16 | # is required as a workaround for an edge case in MySQL: if the same 17 | # connection is used to create tables, load data, and query, the query 18 | # can return incorrect results. See Django #7572, MySQL #37735. 19 | if transaction.get_autocommit(self.using): 20 | connections[self.using].close() 21 | 22 | def add_arguments(self, parser): 23 | parser.add_argument( 24 | "--database", 25 | action="store", 26 | dest="database", 27 | default=DEFAULT_DB_ALIAS, 28 | help="Nominates a specific database to load fixtures into. " 29 | 'Defaults to the "default" database.', 30 | ) 31 | 32 | def loaddata(self): 33 | load_mappings() 34 | -------------------------------------------------------------------------------- /django_docutils/lib/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django_docutils.lib.utils import chop_after_docinfo, chop_after_title 2 | 3 | 4 | def test_chop_after_title(): 5 | content = """============================================= 6 | Learn JavaScript for free: The best resources 7 | ============================================= 8 | 9 | first section 10 | ------------- 11 | 12 | some content 13 | """.strip() 14 | 15 | result = chop_after_title(content) 16 | 17 | expected = """ 18 | first section 19 | ------------- 20 | 21 | some content""".strip() 22 | 23 | assert result == expected 24 | 25 | 26 | def test_chop_after_docinfo(): 27 | before = """ 28 | =========== 29 | Content ok! 30 | =========== 31 | 32 | :programming_languages: javascript 33 | :topics: webpack 34 | :Created: 2017-07-30 35 | :Author: tony 36 | 37 | more text 38 | 39 | first section 40 | ------------- 41 | 42 | some content 43 | """.strip() 44 | 45 | after = """ 46 | more text 47 | 48 | first section 49 | ------------- 50 | 51 | some content 52 | """.strip() 53 | 54 | assert chop_after_docinfo(before) == after 55 | 56 | # test docinfo handles spaces in values 57 | assert ( 58 | chop_after_docinfo( 59 | source=""" 60 | ============== 61 | Document title 62 | ============== 63 | ----------------- 64 | Document subtitle 65 | ----------------- 66 | 67 | :Title: Overridden Title 68 | :Subtitle: Overridden Subtitle 69 | 70 | Content 71 | ------- 72 | 73 | hi 74 | """.strip() 75 | ) 76 | == """ 77 | Content 78 | ------- 79 | 80 | hi""".strip() 81 | ) 82 | -------------------------------------------------------------------------------- /django_docutils/lib/transforms/font_awesome.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from docutils import nodes, utils 4 | from docutils.transforms import Transform 5 | 6 | from django_docutils.lib.settings import BASED_LIB_RST 7 | 8 | url_patterns = BASED_LIB_RST.get("font_awesome", {}).get("url_patterns", {}) 9 | permissible_nodes = [nodes.Text] 10 | 11 | 12 | def fa_classes_from_url(url: str) -> str: 13 | for url_pattern, classes in url_patterns.items(): 14 | if re.match(url_pattern, url): 15 | 16 | return classes 17 | 18 | return "" 19 | 20 | 21 | def inject_font_awesome_to_ref_node(target: nodes.Node, url: str): 22 | fa_classes = fa_classes_from_url(url=url) 23 | if fa_classes != "": 24 | fa_tag = f'' 25 | title = utils.unescape(target[0]) 26 | rn = nodes.reference("", "", internal=True, refuri=url) 27 | rn += nodes.raw("", fa_tag, format="html") 28 | rn += target[0].__class__(title, title) 29 | return rn 30 | return None 31 | 32 | 33 | class InjectFontAwesome(Transform): 34 | 35 | default_priority = 680 36 | 37 | def apply(self): 38 | for target in self.document.traverse(nodes.reference): 39 | if target.hasattr("refuri") and any( 40 | isinstance(target[0], node_type) for node_type in permissible_nodes 41 | ): 42 | rn = inject_font_awesome_to_ref_node( 43 | target=target, url=target["refuri"] 44 | ) 45 | if rn is not None: 46 | target.replace_self(rn) 47 | -------------------------------------------------------------------------------- /docs/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-docutils", 3 | "short_name": "django-docutils", 4 | "description": "Helpers for interfacing between django and docutils", 5 | "theme_color": "#2196f3", 6 | "background_color": "#fff", 7 | "display": "browser", 8 | "Scope": "https://django-docutils.git-pull.com/", 9 | "start_url": "https://django-docutils.git-pull.com/", 10 | "icons": [ 11 | { 12 | "src": "_static/img/icons/android-chrome-72x72.png", 13 | "sizes": "72x72", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "_static/img/icons/android-chrome-96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "_static/img/icons/android-chrome-128x128.png", 23 | "sizes": "128x128", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "_static/img/icons/android-chrome-144x144.png", 28 | "sizes": "144x144", 29 | "type": "image/png" 30 | }, 31 | { 32 | "src": "_static/img/icons/android-chrome-152x152.png", 33 | "sizes": "152x152", 34 | "type": "image/png" 35 | }, 36 | { 37 | "src": "_static/img/icons/android-chrome-192x192.png", 38 | "sizes": "192x192", 39 | "type": "image/png" 40 | }, 41 | { 42 | "src": "_static/img/icons/android-chrome-384x384.png", 43 | "sizes": "384x384", 44 | "type": "image/png" 45 | }, 46 | { 47 | "src": "_static/img/icons/android-chrome-512x512.png", 48 | "sizes": "512x512", 49 | "type": "image/png" 50 | } 51 | ], 52 | "splash_pages": null 53 | } 54 | -------------------------------------------------------------------------------- /docs/_static/django-docutils.css: -------------------------------------------------------------------------------- 1 | h1.logo { 2 | font-size: 20px; 3 | } 4 | 5 | div.sidebar { 6 | margin: 0px; 7 | border: 0px; 8 | padding: 0px; 9 | background-color: inherit; 10 | } 11 | 12 | div.sidebar .sidebar-title { 13 | display: none; 14 | } 15 | 16 | form.navbar-form { 17 | padding: 0px 10px; 18 | } 19 | 20 | div#changelog > div.section > ul > li > p:only-child { 21 | margin-bottom: 0; 22 | } 23 | 24 | div#text-based-window-manager { 25 | clear: both; 26 | } 27 | 28 | @media screen and (max-width: 768px) { 29 | #fork-gh img { 30 | display: none; 31 | } 32 | } 33 | 34 | table.docutils { 35 | background-color: #fafbfc; 36 | border: 0; 37 | } 38 | 39 | table.docutils td, table.docutils th { 40 | border: 0; 41 | } 42 | 43 | table.docutils pre { 44 | background-color: rgba(239, 242, 244, .75); 45 | } 46 | 47 | pre { 48 | background-color: #fafbfc; 49 | border-left: 5px solid #558abb; 50 | font-size: 0.75em; 51 | } 52 | 53 | div.seealso, div.note { 54 | background-color: #fafbfc; 55 | border-right: 0; 56 | border-top: 0; 57 | border-bottom: 0; 58 | } 59 | 60 | div.seealso { 61 | border-left: 5px solid #8abb55; 62 | } 63 | 64 | div.note { 65 | border-left: 5px solid #bb5557; 66 | } 67 | 68 | code.literal { 69 | font-size: 85%; 70 | color: #24292e; 71 | box-sizing: border-box; 72 | display: inline-block; 73 | padding: 0; 74 | background: #fafcfc; 75 | border: 1px solid #f0f4f7; 76 | line-height: 20px; 77 | } 78 | 79 | code::before, code::after { 80 | letter-spacing: -0.2em; 81 | content: "\00a0"; 82 | } 83 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PY_FILES= find . -type f -not -path '*/\.*' | grep -i '.*[.]py$$' 2> /dev/null 2 | DOC_FILES= find . -type f -not -path '*/\.*' | grep -i '.*[.]rst\$\|.*[.]md\$\|.*[.]css\$\|.*[.]py\$\|mkdocs\.yml\|CHANGES\|TODO\|.*conf\.py' 2> /dev/null 3 | SHELL := /bin/bash 4 | 5 | 6 | entr_warn: 7 | @echo "----------------------------------------------------------" 8 | @echo " ! File watching functionality non-operational ! " 9 | @echo " " 10 | @echo "Install entr(1) to automatically run tasks on file change." 11 | @echo "See http://entrproject.org/ " 12 | @echo "----------------------------------------------------------" 13 | 14 | isort: 15 | poetry run isort `${PY_FILES}` 16 | 17 | black: 18 | poetry run black `${PY_FILES}` 19 | 20 | test: 21 | poetry run py.test $(test) 22 | 23 | start: 24 | $(MAKE) test; poetry run ptw . 25 | 26 | watch_test: 27 | if command -v entr > /dev/null; then ${PY_FILES} | entr -c $(MAKE) test; else $(MAKE) test entr_warn; fi 28 | 29 | build_docs: 30 | $(MAKE) -C docs html 31 | 32 | start_docs: 33 | $(MAKE) -C docs start 34 | 35 | design_docs: 36 | $(MAKE) -C docs design 37 | 38 | flake8: 39 | flake8 django_docutils tests 40 | 41 | watch_flake8: 42 | if command -v entr > /dev/null; then ${PY_FILES} | entr -c $(MAKE) flake8; else $(MAKE) flake8 entr_warn; fi 43 | 44 | mypy: 45 | poetry run mypy `${PY_FILES}` 46 | 47 | watch_mypy: 48 | if command -v entr > /dev/null; then ${PY_FILES} | entr -c $(MAKE) mypy; else $(MAKE) mypy entr_warn; fi 49 | 50 | format_markdown: 51 | prettier --parser=markdown -w *.md docs/*.md docs/**/*.md CHANGES 52 | -------------------------------------------------------------------------------- /django_docutils/lib/transforms/toc.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from docutils import nodes 4 | from docutils.transforms import parts 5 | 6 | 7 | class Contents(parts.Contents): 8 | 9 | """ 10 | Changes: 11 | - remove unused autonum 12 | - PEP8 13 | - Removed extra nodes.paragraph wrapping of list_item's 14 | """ 15 | 16 | def build_contents(self, node, level=0): 17 | level += 1 18 | sections = [sect for sect in node if isinstance(sect, nodes.section)] 19 | entries = [] 20 | depth = self.startnode.details.get("depth", sys.maxsize) 21 | 22 | for section in sections: 23 | title = section[0] 24 | auto = title.get("auto") # May be set by SectNum. 25 | entrytext = self.copy_and_filter(title) 26 | reference = nodes.reference("", "", refid=section["ids"][0], *entrytext) 27 | ref_id = self.document.set_id(reference) 28 | item = nodes.list_item("", reference) 29 | if ( 30 | self.backlinks in ("entry", "top") 31 | and title.next_node(nodes.reference) is None 32 | ): 33 | if self.backlinks == "entry": 34 | title["refid"] = ref_id 35 | elif self.backlinks == "top": 36 | title["refid"] = self.toc_id 37 | if level < depth: 38 | subsects = self.build_contents(section, level) 39 | item += subsects 40 | entries.append(item) 41 | if entries: 42 | contents = nodes.bullet_list("", *entries, classes=["menu-list"]) 43 | 44 | if auto: 45 | contents["classes"].append("auto-toc") 46 | return contents 47 | else: 48 | return [] 49 | -------------------------------------------------------------------------------- /django_docutils/lib/metadata/process.py: -------------------------------------------------------------------------------- 1 | """Metadata is a catch-all term for information for an RST document. 2 | 3 | It can be pulled from the RST file itself, such as: 4 | 5 | - The Title/Subtitle of the document 6 | - The docinfo attributes 7 | 8 | In the case of directory-style projects, the manifest.json. 9 | 10 | These optional pipeline functions can be configured to to create, read, 11 | update, and delete metadata from RST projects. 12 | 13 | To set metadata processors, use BASED_LIB_RST['metadata_processors']:: 14 | 15 | BASED_LIB_RST = { 16 | 'metadata_processors': [ 17 | 'django_docutils.lib.metadata.processors.process_datetime' 18 | ], 19 | } 20 | 21 | The order of the processors will be respected. 22 | 23 | The function accepts one argument, the metadata dictionary, and returns the 24 | same dictionary:: 25 | 26 | def process_datetime(metadata): 27 | # create, read, update, delete metadata from the RST document 28 | return metadata 29 | 30 | See *processors.py* for more examples. 31 | """ 32 | from django.utils.module_loading import import_string 33 | 34 | from ..settings import BASED_LIB_RST 35 | 36 | 37 | def process_metadata(metadata): 38 | """Return objects from rst metadata pulled from document source. 39 | 40 | This will turn things like string dates into time-zone'd dateutil objects. 41 | 42 | :param metadata: data returned from processing an RST file 43 | :type metadata: dict 44 | :rtype: dict 45 | """ 46 | 47 | if not BASED_LIB_RST: 48 | return metadata 49 | 50 | if "metadata_processors" in BASED_LIB_RST: 51 | for processor_str in BASED_LIB_RST["metadata_processors"]: 52 | processor_fn = import_string(processor_str) 53 | metadata = processor_fn(metadata) 54 | 55 | return metadata 56 | -------------------------------------------------------------------------------- /django_docutils/lib/metadata/processors.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytz 4 | from django.conf import settings 5 | 6 | from django_docutils.lib.fixtures.publisher import M2M_FIELDS 7 | 8 | 9 | def process_datetime(metadata): 10 | timezone_formats = [ # timezone formats to try, most detailed to least 11 | "%Y-%m-%d %I:%M%p", 12 | "%Y-%m-%d", 13 | ] 14 | 15 | for time_key in ["created", "modified"]: 16 | if time_key in metadata: 17 | for _format in timezone_formats: 18 | try: 19 | metadata[time_key] = datetime.datetime.strptime( 20 | metadata[time_key], _format 21 | ) 22 | break 23 | except ValueError: 24 | continue 25 | metadata[time_key] = pytz.timezone(settings.TIME_ZONE).localize( 26 | metadata[time_key], is_dst=None 27 | ) 28 | return metadata 29 | 30 | 31 | def process_anonymous_user(metadata): 32 | """Corrects name of author "anonymous" to django's anonymous username""" 33 | 34 | if metadata.get("author", None) == "anonymous": 35 | metadata["author"] = settings.ANONYMOUS_USER_NAME 36 | 37 | return metadata 38 | 39 | 40 | def process_m2m_fields(metadata): 41 | """Expand m2m values. When parsed from RST docinfo attributes, they're 42 | separated by commas: e.g. :Topic: web frameworks, django 43 | 44 | Directory-style imports skip this, json imports into list 45 | """ 46 | for m2m_field in M2M_FIELDS: 47 | if m2m_field in metadata and isinstance(metadata[m2m_field], str): 48 | metadata[m2m_field] = metadata[m2m_field].split(",") 49 | if isinstance(metadata[m2m_field], str): 50 | metadata[m2m_field] = [metadata[m2m_field]] 51 | return metadata 52 | -------------------------------------------------------------------------------- /django_docutils/lib/metadata/tests/test_extract.py: -------------------------------------------------------------------------------- 1 | from django.utils.encoding import force_bytes 2 | from docutils.core import publish_doctree 3 | 4 | from django_docutils.lib.metadata.extract import ( 5 | extract_metadata, 6 | extract_subtitle, 7 | extract_title, 8 | ) 9 | from django_docutils.lib.settings import BASED_LIB_RST 10 | 11 | 12 | def test_extract_title(): 13 | content = """ 14 | =========== 15 | Hello world 16 | =========== 17 | 18 | :key1: value 19 | :key2: value 20 | 21 | more text 22 | 23 | first section 24 | ------------- 25 | 26 | some content 27 | """.strip() 28 | 29 | doctree = publish_doctree(source=force_bytes(content)) 30 | 31 | assert extract_title(doctree) == "Hello world" 32 | 33 | 34 | def test_extract_subtitle(): 35 | content = """ 36 | =========== 37 | Hello world 38 | =========== 39 | moo 40 | === 41 | 42 | :key1: value 43 | :key2: value 44 | 45 | more text 46 | 47 | first section 48 | ------------- 49 | 50 | some content 51 | """.strip() 52 | 53 | doctree = publish_doctree(source=force_bytes(content)) 54 | 55 | assert extract_subtitle(doctree) == "moo" 56 | 57 | 58 | def test_extract_metadata(tmpdir): 59 | docutils_settings = BASED_LIB_RST.get("docutils", {}) 60 | content = """ 61 | =========== 62 | Content ok! 63 | =========== 64 | 65 | :programming_languages: javascript 66 | :topics: webpack 67 | :Created: 2017-07-30 68 | :Author: tony 69 | 70 | more text 71 | 72 | first section 73 | ------------- 74 | 75 | some content 76 | """.strip() 77 | 78 | doctree = publish_doctree( 79 | source=force_bytes(content), settings_overrides=docutils_settings 80 | ) 81 | 82 | assert extract_metadata(doctree) == { 83 | "programming_languages": "javascript", 84 | "topics": "webpack", 85 | "created": "2017-07-30", 86 | "author": "tony", 87 | } 88 | -------------------------------------------------------------------------------- /docs/_templates/sidebar/projects.html: -------------------------------------------------------------------------------- 1 | 32 | 46 | -------------------------------------------------------------------------------- /django_docutils/rst_post/management/commands/loaddata_grow.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.core.management.base import BaseCommand 3 | from django.db import DEFAULT_DB_ALIAS, connections, transaction 4 | 5 | from django_docutils.lib.directives import register_based_directives 6 | from django_docutils.lib.fixtures.load import load_app_rst_fixtures 7 | from django_docutils.lib.fixtures.utils import find_app_configs_with_fixtures 8 | from django_docutils.lib.roles import register_based_roles 9 | 10 | User = get_user_model() 11 | 12 | 13 | class Command(BaseCommand): 14 | help = "Installs / Updates growth rst file(s) in the database." 15 | 16 | def handle(self, **options): 17 | self.using = options["database"] 18 | with transaction.atomic(using=self.using): 19 | self.loaddata() 20 | 21 | # Close the DB connection -- unless we're still in a transaction. This 22 | # is required as a workaround for an edge case in MySQL: if the same 23 | # connection is used to create tables, load data, and query, the query 24 | # can return incorrect results. See Django #7572, MySQL #37735. 25 | if transaction.get_autocommit(self.using): 26 | connections[self.using].close() 27 | 28 | def add_arguments(self, parser): 29 | parser.add_argument( 30 | "--database", 31 | action="store", 32 | dest="database", 33 | default=DEFAULT_DB_ALIAS, 34 | help="Nominates a specific database to load fixtures into. " 35 | 'Defaults to the "default" database.', 36 | ) 37 | 38 | def loaddata(self): 39 | # prepare directives / roles 40 | register_based_directives() 41 | register_based_roles() 42 | 43 | # apps that have growth fixtures 44 | app_configs = find_app_configs_with_fixtures(has_rst_files=True) 45 | 46 | for app_config in app_configs: 47 | load_app_rst_fixtures(app_config=app_config) 48 | -------------------------------------------------------------------------------- /django_docutils/lib/text.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.conf import settings 4 | from django.utils.module_loading import import_string 5 | 6 | _word_re = re.compile(r"\w+", re.UNICODE) 7 | _word_beginning_split_re = re.compile(r"([\s\(\{\[\<]+)", re.UNICODE) 8 | 9 | 10 | def is_uncapitalized_word(value): 11 | """Return True if term/word segment is special uncap term (e.g. "django-") 12 | 13 | :param value: string value from template 14 | :type value: string 15 | 16 | Functions can be declared via BASED_TEXT in django settings via string 17 | imports. The filters accept one argument (the word). If you don't want the 18 | word/pattern capitalized, return True. Anything else capitalizes as normal. 19 | 20 | How to create filters:; 21 | 22 | def handle_uncapped_word(value): 23 | if value.startswith('django-'): 24 | return True 25 | if 'vs' in value: 26 | return True 27 | return False 28 | 29 | In your settings:: 30 | 31 | BASED_LIB_TEXT = { 32 | 'uncapitalized_word_filters': [ 33 | 'develtech.path.to.handle_uncapped_word' 34 | ] 35 | } 36 | """ 37 | try: 38 | config = settings.BASED_LIB_TEXT 39 | except AttributeError: 40 | return 41 | 42 | if "uncapitalized_word_filters" in config: 43 | for filter_fn_str in config["uncapitalized_word_filters"]: 44 | filter_ = import_string(filter_fn_str) 45 | if filter_(value): 46 | return True 47 | return False 48 | 49 | 50 | def smart_capfirst(value): 51 | """Capitalize the first character of the value.""" 52 | 53 | if is_uncapitalized_word(value): 54 | return value 55 | 56 | return value[0].upper() + value[1:] 57 | 58 | 59 | def smart_title(value): 60 | """Convert a string into titlecase, except for special cases. 61 | 62 | Django can still be capitalized, but it must already be like that. 63 | """ 64 | 65 | return "".join( 66 | [smart_capfirst(item) for item in _word_beginning_split_re.split(value) if item] 67 | ) 68 | -------------------------------------------------------------------------------- /django_docutils/favicon/management/commands/prefetch_favicons.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.db import DEFAULT_DB_ALIAS, connections, transaction 3 | 4 | from django_docutils.lib.favicon.prefetch import prefetch_favicons 5 | 6 | from ...models import get_favicon_model 7 | 8 | 9 | class Command(BaseCommand): 10 | help = "Parse all page/node content for favicons." 11 | 12 | def handle(self, **options): 13 | if options["clear"]: 14 | self.stdout.write("Wiping favicons") 15 | Favicon = get_favicon_model() 16 | for f in Favicon.objects.all(): 17 | f.delete() 18 | 19 | self.using = options["database"] 20 | self.url_pattern = options["pattern"] 21 | 22 | with transaction.atomic(using=self.using): 23 | prefetch_favicons(self.url_pattern) 24 | 25 | # Close the DB connection -- unless we're still in a transaction. This 26 | # is required as a workaround for an edge case in MySQL: if the same 27 | # connection is used to create tables, load data, and query, the query 28 | # can return incorrect results. See Django #7572, MySQL #37735. 29 | if transaction.get_autocommit(self.using): 30 | connections[self.using].close() 31 | 32 | def add_arguments(self, parser): 33 | parser.add_argument( 34 | "-c", 35 | "--clear", 36 | action="store_true", 37 | dest="clear", 38 | help="Clear the existing favicons from the database" 39 | "before trying to fetch favicons.", 40 | ) 41 | 42 | parser.add_argument( 43 | "--database", 44 | action="store", 45 | dest="database", 46 | default=DEFAULT_DB_ALIAS, 47 | help="Nominates a specific database to load fixtures into. " 48 | 'Defaults to the "default" database.', 49 | ) 50 | 51 | parser.add_argument( 52 | "--pattern", 53 | action="store", 54 | dest="pattern", 55 | default=None, 56 | help="Only parse URL's with pattern", 57 | ) 58 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/develtech.py: -------------------------------------------------------------------------------- 1 | from django.urls import NoReverseMatch, reverse 2 | from docutils import nodes, utils 3 | 4 | from ..utils import split_explicit_title 5 | 6 | 7 | def site_url_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 8 | name = name.lower() 9 | 10 | has_explicit_title, title, target = split_explicit_title(text) 11 | title = utils.unescape(title) 12 | target = utils.unescape(target) 13 | 14 | if not has_explicit_title: 15 | title = "Page " + utils.unescape(title) 16 | try: 17 | url = reverse(target) 18 | except NoReverseMatch: 19 | msg = inliner.reporter.error("no matching url %s" % target, line=lineno) 20 | prb = inliner.problematic(rawtext, rawtext, msg) 21 | return [prb], [msg] 22 | sn = nodes.Text(title, title) 23 | rn = nodes.reference("", "", internal=True, refuri=url, classes=[name]) 24 | rn += sn 25 | return [rn], [] 26 | 27 | 28 | def post_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 29 | from django.apps import apps 30 | 31 | from django_docutils.lib.fixtures.utils import get_model_from_post_app 32 | 33 | # split :post:appname:`post_id` into :post:appname: 34 | raw_role = rawtext.split("`")[0] 35 | 36 | app_name_singular = raw_role.split(":")[2] 37 | app_config = apps.get_app_config(f"{app_name_singular}s") 38 | Model = get_model_from_post_app(app_config) 39 | 40 | name = name.lower() 41 | has_explicit_title, title, target = split_explicit_title(text) 42 | title = utils.unescape(title) 43 | target = utils.unescape(target) 44 | 45 | try: 46 | obj = Model.objects.get(slug_id=target) 47 | url = obj.get_absolute_url() 48 | if not has_explicit_title: 49 | title = obj.title 50 | except (Model.DoesNotExist, NoReverseMatch) as e: 51 | msg = inliner.reporter.error(f"{e} ({target})", line=lineno) 52 | prb = inliner.problematic(rawtext, rawtext, msg) 53 | return [prb], [msg] 54 | sn = nodes.Text(title, title) 55 | rn = nodes.reference("", "", internal=True, refuri=url, classes=[name]) 56 | rn += sn 57 | return [rn], [] 58 | -------------------------------------------------------------------------------- /django_docutils/lib/metadata/tests/test_process.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.utils.encoding import force_bytes 4 | from docutils.core import publish_doctree 5 | 6 | from django_docutils.lib.metadata.extract import extract_metadata 7 | from django_docutils.lib.metadata.process import process_metadata 8 | 9 | 10 | def test_process_metadata_file(): 11 | source = """ 12 | =========== 13 | Content ok! 14 | =========== 15 | 16 | :programming_language: javascript 17 | :topic: webpack 18 | :Created: 2017-07-30 19 | :Author: tony 20 | 21 | more text 22 | 23 | first section 24 | ------------- 25 | 26 | some content 27 | """.strip() 28 | 29 | doctree = publish_doctree(source=force_bytes(source)) 30 | 31 | raw_metadata = extract_metadata(doctree) 32 | 33 | analyzed_metadata = process_metadata(raw_metadata.copy()) 34 | assert set(raw_metadata.keys()) == set(analyzed_metadata.keys()) 35 | 36 | assert isinstance(analyzed_metadata["created"], datetime.date) 37 | 38 | assert process_metadata(raw_metadata) != { 39 | "programming_languages": "javascript", 40 | "topics": "webpack", 41 | "created": "2017-07-30", 42 | "author": "tony", 43 | } 44 | 45 | 46 | def test_process_metadata_daytime_timezone(): 47 | """Verify time of day and timezone (optional) work with dates.""" 48 | 49 | source = """ 50 | =========== 51 | Content ok! 52 | =========== 53 | 54 | :programming_language: javascript 55 | :topic: webpack 56 | :Created: 2017-07-30 2:30PM 57 | :Author: tony 58 | 59 | more text 60 | 61 | first section 62 | ------------- 63 | 64 | some content 65 | """.strip() 66 | 67 | doctree = publish_doctree(source=force_bytes(source)) 68 | 69 | raw_metadata = extract_metadata(doctree) 70 | 71 | analyzed_metadata = process_metadata(raw_metadata.copy()) 72 | assert set(raw_metadata.keys()) == set(analyzed_metadata.keys()) 73 | 74 | created = analyzed_metadata["created"] 75 | 76 | assert isinstance(created, datetime.date) 77 | assert created.year == 2017 78 | assert created.month == 7 79 | assert created.day == 30 80 | assert created.strftime("%I") == "02" 81 | assert created.strftime("%p") == "PM" 82 | assert created.minute == 30 83 | -------------------------------------------------------------------------------- /django_docutils/engines.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import io 4 | 5 | from django.conf import settings 6 | from django.template.backends.base import BaseEngine 7 | from django.template.backends.utils import csrf_input_lazy, csrf_token_lazy 8 | from django.template.engine import Engine 9 | from docutils import core 10 | 11 | from .directives import register_pygments_directive 12 | 13 | try: 14 | from django.template.base import TemplateDoesNotExist 15 | except ImportError: # >= 1.9 16 | from django.template.exceptions import TemplateDoesNotExist 17 | 18 | 19 | class Docutils(BaseEngine): 20 | app_dirname = "templates" 21 | 22 | def __init__(self, params): 23 | params = params.copy() 24 | self.options = params.pop("OPTIONS").copy() 25 | self.options.setdefault("debug", settings.DEBUG) 26 | self.options.setdefault("file_charset", settings.FILE_CHARSET) 27 | super(Docutils, self).__init__(params) 28 | self.engine = Engine(self.dirs, self.app_dirs, **self.options) 29 | 30 | def from_string(self, template_code): 31 | return DocutilsTemplate(template_code, self.options) 32 | 33 | def get_template(self, template_name): 34 | for template_file in self.iter_template_filenames(template_name): 35 | try: 36 | with io.open(template_file, encoding=settings.FILE_CHARSET) as fp: 37 | template_code = fp.read() 38 | except IOError: 39 | continue 40 | 41 | return DocutilsTemplate(template_code, self.options) 42 | else: 43 | raise TemplateDoesNotExist(template_name) 44 | 45 | 46 | class DocutilsTemplate(object): 47 | def __init__(self, source, options): 48 | self.source = source 49 | self.options = options 50 | 51 | def render(self, context=None, request=None): 52 | context = self.options 53 | if request is not None: 54 | context["request"] = request 55 | context["csrf_input"] = csrf_input_lazy(request) 56 | context["csrf_token"] = csrf_token_lazy(request) 57 | context = {"source": self.source, "writer_name": "html"} 58 | 59 | return core.publish_parts(**context)["html_body"] 60 | 61 | 62 | register_pygments_directive() 63 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import py 2 | import pytest 3 | 4 | from django.apps import apps 5 | 6 | from django_docutils.lib.fixtures.tests.conftest import create_bare_app 7 | from django_docutils.lib.fixtures.utils import ( 8 | find_app_configs_with_fixtures, 9 | find_rst_files, 10 | find_rst_files_in_app, 11 | get_model_from_post_app, 12 | ) 13 | 14 | 15 | @pytest.fixture(scope="function") 16 | def bare_app_config_with_empty_fixture_dir(tmpdir_factory, request, settings): 17 | tmpdir = tmpdir_factory.mktemp("bare_project") 18 | 19 | app_config = create_bare_app(tmpdir, request, settings, "app_with_empty_fixtures") 20 | sample_app_dir = py.path.local(app_config.path) 21 | sample_app_dir.ensure("fixtures", dir=True) 22 | yield app_config 23 | 24 | 25 | def test_find_rst_files(tmpdir): 26 | tmpdir.join("hi.rst").write("") 27 | tmpdir.join("not_at_rst_file.html").write("") 28 | tmpdir.mkdir("wat") 29 | tmpdir.join("wat/moo.rst").write("") 30 | tmpdir.join("wat/another.rst").write("") 31 | 32 | assert ["./hi.rst"] == find_rst_files(str(tmpdir)) 33 | 34 | 35 | def test_find_app_configs_with_fixtures(sample_app_config): 36 | assert sample_app_config in find_app_configs_with_fixtures(has_rst_files=False) 37 | 38 | 39 | def test_find_app_configs_with_fixtures_with_rst_files(bare_app_config): 40 | assert bare_app_config not in find_app_configs_with_fixtures() 41 | assert bare_app_config not in find_app_configs_with_fixtures(has_rst_files=False) 42 | assert bare_app_config not in find_app_configs_with_fixtures(has_rst_files=True) 43 | 44 | 45 | def test_find_app_configs_with_fixtures_with_empty_fixtures_dir( 46 | bare_app_config_with_empty_fixture_dir, 47 | ): 48 | assert bare_app_config_with_empty_fixture_dir in find_app_configs_with_fixtures( 49 | has_rst_files=False 50 | ) 51 | assert bare_app_config_with_empty_fixture_dir not in find_app_configs_with_fixtures( 52 | has_rst_files=True 53 | ) 54 | 55 | 56 | def test_find_rst_files_in_app(sample_app_config): 57 | assert "./hi.rst" in find_rst_files_in_app(sample_app_config) 58 | assert "2017/06/04/moo.rst" not in find_rst_files_in_app(sample_app_config) 59 | 60 | 61 | def test_get_model_from_post_app(): 62 | app = apps.get_app_config("test_app") 63 | assert get_model_from_post_app(app) == app.get_model("RSTPost") 64 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/amazon.py: -------------------------------------------------------------------------------- 1 | from urllib.error import HTTPError 2 | 3 | from bitly_api import bitly_api 4 | from django.conf import settings 5 | 6 | from django_docutils.references.models import get_reference_model 7 | 8 | from .common import generic_remote_url_role 9 | 10 | Reference = get_reference_model() 11 | 12 | 13 | def get_client(): 14 | from amazon.api import AmazonAPI 15 | 16 | return AmazonAPI( 17 | settings.AMAZON_PRODUCT_API_ACCESS_KEY, 18 | settings.AMAZON_PRODUCT_API_SECRET_KEY, 19 | settings.AMAZON_ASSOCIATES_TRACKING_ID, 20 | ) 21 | 22 | 23 | def amazon_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 24 | """Role for linking to amazon product. 25 | 26 | First, try to resolve via memoization (redis kv store). 27 | Second, try to resolve via database back-end. 28 | Finally, try to resolve via remote API call, which saves to 29 | the database of references. 30 | 31 | In the future, create a wrapper / function / decorator to automate this 32 | for remote API roles. 33 | 34 | :amzn:`B01MG342KU` -> 35 | link: amazn shortline 36 | text: The Tao of tmux: and Terminal Tricks 37 | 38 | :amzn:`my book ` -> 39 | link: amzn shortlink 40 | text: my book 41 | """ 42 | amzn = get_client() 43 | 44 | def url_handler(target): 45 | try: 46 | r = Reference.objects.get(project="amazon", target=target) 47 | except Reference.DoesNotExist: 48 | query = amzn.lookup(ItemId=target) 49 | url = query.offer_url 50 | 51 | access_token = settings.BITLY_ACCESS_TOKEN 52 | bitly = bitly_api.Connection(access_token=access_token) 53 | url = bitly.shorten(url)["url"] 54 | 55 | r = Reference( 56 | project="amazon", 57 | type=name, 58 | target=target, 59 | display_name=query.title, 60 | uri=url, 61 | ) 62 | r.save() 63 | return r.display_name, r.uri 64 | 65 | try: 66 | return generic_remote_url_role(name, text, url_handler) 67 | except HTTPError: 68 | msg = inliner.reporter.error( 69 | 'Error connecting to amazon API for "%s"' % text, line=lineno 70 | ) 71 | prb = inliner.problematic(rawtext, rawtext, msg) 72 | return [prb], [msg] 73 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## django-docutils 0.6.0 (2022-03-27) 4 | 5 | ### Compatibility 6 | 7 | - Drop python 3.5 to 3.7 (minimum version python 3.8) 8 | - Minimum django version 3.0 9 | 10 | ### Development 11 | 12 | - CI: 13 | - Fix CI variables 14 | - Rename Publish Docs -> docs 15 | - Fix poetry installation and caching 16 | - Update development packages (black, isort) 17 | - Add .tool-versions, .python-version 18 | - Run code through black w/o `--skip-string-normalization` 19 | 20 | ### Documentation 21 | 22 | - Move theme to furo 23 | - Move to markdown 24 | 25 | ## django-docutils 0.5.1 (2020-08-09) 26 | 27 | - [#270](https://github.com/tony/django-docutils/pull/270) Fix packaging / optional dependencies 28 | - Remove twine extra dependency 29 | - Remove setup.py (in favor of poetry, which we used to build and publish as of django-docutils 30 | 0.5.0) 31 | 32 | ## django-docutils 0.5.0 (2020-08-08) 33 | 34 | - Use our new bitly api fork at / 35 | 36 | - pipenv -> poetry 37 | - readthedocs -> self-hosted docs 38 | - travis -> github actions 39 | - Add black + isort and format code with it 40 | - Remove vulture 41 | - Remove python 2.x support, python 3.3 and 3.4 (reached end of life) 42 | - Update to new django versions (2.2 and 2.3) 43 | - Remove unsupported django versions (1.8, 1.9, 1.10, 2.0, 2.1) 44 | - Cleanup CI, add caching 45 | - Add `InjectFontAwesome` transformer to inject icon `` tags for font awesome based on regex 46 | patterns 47 | - Additional support for detecting font-awesome patterns and injecting the icon in other 48 | transformers (e.g. `XRefTransform`) 49 | 50 | ## django-docutils 0.4.0 (2017-02-21) 51 | 52 | - Django template tag 53 | - Some README documentation 54 | 55 | ## django-docutils 0.3.4 (2017-02-12) 56 | 57 | - Add requirements/test.txt to manifest 58 | 59 | ## django-docutils 0.3.3 (2017-02-12) 60 | 61 | - Add requirements/base.txt to manifest 62 | 63 | ## django-docutils 0.3.2 (2017-02-12) 64 | 65 | - Another tweak to get pypi readme up 66 | 67 | ## django-docutils 0.3.1 (2017-02-12) 68 | 69 | - Some changes to attempt to fix pypi README 70 | 71 | ## django-docutils 0.3.0 (2017-02-12) 72 | 73 | - Package updates and fixes 74 | 75 | ## django-docutils 0.2.0 (2017-01-01) 76 | 77 | - Support for Django 1.10.0 78 | 79 | ## django-docutils 0.1.0 (2015-06-20) 80 | 81 | - First release on PyPI. 82 | 83 | 86 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/file.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from docutils import nodes, utils 4 | 5 | 6 | def file_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 7 | """Role for files. 8 | 9 | :file:`./path/to/moo` -> 10 | text: ./path/to/moo (italicized + file icon) 11 | 12 | :file:`./path/to/moo/` -> 13 | text: ./path/to/moo/ (italicized + directory icon) 14 | 15 | """ 16 | name = name.lower() 17 | title = utils.unescape(text) 18 | 19 | # 'file' would collide with bulma, so we use 'filepath' 20 | # https://github.com/jgthms/bulma/blob/c2fae71/sass/elements/form.sass#L218 21 | # https://github.com/jgthms/bulma/issues/1442 22 | classes = [] 23 | 24 | # add .fa class since this isn't a link 25 | classes.append("far") 26 | 27 | if title.endswith("/"): 28 | classes.append("fa-folder") 29 | else: 30 | classes.append("fa-file-alt") 31 | extension = os.path.splitext(title)[1] 32 | if extension: 33 | classes.append(extension.lstrip(".")) 34 | 35 | sn = nodes.emphasis(title, title) 36 | 37 | # insert inside the 38 | sn.insert(0, nodes.inline("", "", classes=classes)) 39 | return [sn], [] 40 | 41 | 42 | # TODO: Let font-awesome classes be configured via settings 43 | def manifest_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 44 | """Role for manifests (package.json, file outputs) 45 | 46 | :manifest:`package.json` -> 47 | text: package.json (italicized + file icon) 48 | 49 | """ 50 | name = name.lower() 51 | title = utils.unescape(text) 52 | 53 | classes = ["manifest"] 54 | 55 | # add .fa class since this isn't a link 56 | classes.append("fa-file-alt far") 57 | 58 | sn = nodes.emphasis(title, title) 59 | 60 | # insert inside the 61 | sn.insert(0, nodes.inline("", "", classes=classes)) 62 | return [sn], [] 63 | 64 | 65 | def exe_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 66 | """Role for executables. 67 | 68 | :exe:`./path/to/webpack` -> 69 | text: ./path/to/webpack (italicized + file icon) 70 | 71 | """ 72 | name = name.lower() 73 | title = utils.unescape(text) 74 | 75 | classes = ["exe", "fa"] 76 | 77 | sn = nodes.emphasis(title, title) 78 | 79 | # insert inside the 80 | sn.insert(0, nodes.inline("", "", classes=classes)) 81 | return [sn], [] 82 | -------------------------------------------------------------------------------- /django_docutils/lib/metadata/extract.py: -------------------------------------------------------------------------------- 1 | from django.template.defaultfilters import truncatewords 2 | from django.utils.html import strip_tags 3 | from docutils import nodes 4 | 5 | 6 | def extract_title(document): 7 | """Return the title of the document. 8 | 9 | :param document: 10 | :type document: :class:`docutils.nodes.document` 11 | """ 12 | for node in document.traverse(nodes.PreBibliographic): 13 | if isinstance(node, nodes.title): 14 | return node.astext() 15 | 16 | 17 | def extract_metadata(document): 18 | """Return the dict containing document metadata. 19 | 20 | :param document: 21 | :type document: :class:`docutils.nodes.document` 22 | :returns: docinfo data from document 23 | :rtype: dict 24 | 25 | From: https://github.com/adieu/mezzanine-cli @ mezzanine_cli/parser.py 26 | License: BSD (https://github.com/adieu/mezzanine-cli/blob/master/setup.py) 27 | """ 28 | output = {} 29 | for docinfo in document.traverse(nodes.docinfo): 30 | for element in docinfo.children: 31 | if element.tagname == "field": # custom fields (e.g. summary) 32 | name_elem, body_elem = element.children 33 | name = name_elem.astext() 34 | value = body_elem.astext() 35 | else: # standard fields (e.g. address) 36 | name = element.tagname 37 | value = element.astext() 38 | name = name.lower() 39 | 40 | output[name] = value 41 | return output 42 | 43 | 44 | def extract_subtitle(document): 45 | """Return the subtitle of the document.""" 46 | for node in document.traverse(nodes.PreBibliographic): 47 | if isinstance(node, nodes.subtitle): 48 | return node.astext() 49 | 50 | 51 | def extract_abstract(doctree, length=100): 52 | """Pull first n words from a docutils document. 53 | 54 | We use this to create snippets for Twitter Cards, FB, etc. 55 | 56 | :param doctree: docutils document to extract from 57 | :type doctree: :class:`docutils.nodes.document` 58 | :param length: word count to cut content off at 59 | :type length: int 60 | :rtype: string 61 | :returns: truncated content, html tags removed 62 | 63 | """ 64 | paragraph_nodes = doctree.traverse(nodes.paragraph) 65 | text = "" 66 | for node in paragraph_nodes: 67 | text += node.astext() 68 | if len(text.split(" ")) > 100: 69 | break 70 | return truncatewords(strip_tags(text), 100) 71 | -------------------------------------------------------------------------------- /django_docutils/favicon/scrape.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import sys 4 | from urllib.parse import urljoin 5 | 6 | import requests 7 | from lxml import html 8 | from six.moves.urllib.parse import urlparse 9 | 10 | from django_docutils.exc import BasedException 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def _request_favicon(url): 16 | """Tries to download favicon from URL and checks if it's valid.""" 17 | r = requests.get(url) 18 | r.raise_for_status() 19 | if "image" not in r.headers["Content-Type"]: 20 | raise BasedException("Not an image") 21 | return r.content 22 | 23 | 24 | def get_favicon(url): 25 | try: 26 | r = requests.get(url) 27 | r.raise_for_status() 28 | # update url if redirected 29 | if r.url != url: 30 | url = r.url 31 | doc = html.fromstring(r.content) 32 | except requests.exceptions.ConnectionError as e: 33 | raise BasedException(f"The website {url} isn't connecting:", e) 34 | 35 | paths = ['//link[@rel="shortcut icon"]/@href', '//link[@rel="icon"]/@href'] 36 | for path in paths: 37 | # Method 1: to find favicon via "shortcut icon" 38 | favicons = doc.xpath(path) 39 | 40 | if len(favicons): # Is pattern found? 41 | try: 42 | favicon_url = favicons[0] 43 | favicon_url = urljoin(url, favicon_url) 44 | return _request_favicon(favicon_url) 45 | except Exception as e: 46 | logger.debug( 47 | "Could not retrieve {favicon_url}: \n{e}".format( 48 | favicon_url=favicon_url, e=e 49 | ) 50 | ) 51 | 52 | # Method 2: site root/favicon.ico 53 | try: 54 | parsed = urlparse(url) 55 | parsed = parsed._replace(path="/favicon.ico") 56 | favicon_url = parsed.geturl() 57 | return _request_favicon(favicon_url) 58 | except Exception as e: 59 | logger.debug( 60 | "Could not retrieve {favicon_url}.\n{e}".format( 61 | favicon_url=favicon_url, e=e 62 | ) 63 | ) 64 | 65 | raise BasedException( 66 | """ 67 | Could not retrieve favicon for {url}. Both strategies failed 68 | """.format( 69 | url=url 70 | ) 71 | ) 72 | 73 | 74 | if __name__ == "__main__": 75 | favicon = get_favicon(sys.argv[1]) 76 | file_ = open("/Users/me/favicon.ico", "wb") 77 | file_.write(favicon) 78 | file_.close() 79 | -------------------------------------------------------------------------------- /django_docutils/lib/templatetags/rst.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.template.base import kwarg_re 3 | from django.template.defaulttags import Node 4 | from django.template.exceptions import TemplateSyntaxError 5 | 6 | from ..publisher import publish_html_from_source 7 | 8 | register = template.Library() 9 | 10 | 11 | class ReStructuredTextNode(Node): 12 | 13 | """Implement the actions of the rst tag.""" 14 | 15 | def __init__(self, content, args, kwargs, asvar): 16 | self.content = content 17 | self.args = args 18 | self.kwargs = kwargs 19 | self.asvar = asvar 20 | 21 | def render(self, context): 22 | args = [arg.resolve(context) for arg in self.args] 23 | kwargs = {k: v.resolve(context) for k, v in self.kwargs.items()} 24 | 25 | content = self.content.resolve(context) 26 | 27 | return publish_html_from_source(content, *args, **kwargs) 28 | 29 | 30 | @register.tag 31 | def restructuredtext(parser, token): 32 | """Parse raw reStructuredText into HTML. Supports keyword arguments! 33 | 34 | Usage:: 35 | 36 | {% restructuredtext content %} 37 | 38 | {% restructuredtext content inject_ads=False %} 39 | 40 | {% restructuredtext content toc_only=True %} 41 | 42 | {% restructuredtext content show_title=False %} 43 | 44 | {% restructuredtext content inject_ads=True ad_keywords=ad_keywords %} 45 | 46 | Why does toc_only=true needed (why do you need to call twice just to get 47 | a ToC)? Because of how docutils parses. 48 | 49 | Passing content/params right into publish_html_from_source. 50 | """ 51 | bits = token.split_contents() 52 | if len(bits) < 2: 53 | raise TemplateSyntaxError( 54 | "'%s' takes at least one argument, a URL pattern name." % bits[0] 55 | ) 56 | content = parser.compile_filter(bits[1]) 57 | args = [] 58 | kwargs = {} 59 | asvar = None 60 | bits = bits[2:] 61 | if len(bits) >= 2 and bits[-2] == "as": 62 | asvar = bits[-1] 63 | bits = bits[:-2] 64 | 65 | if len(bits): 66 | for bit in bits: 67 | match = kwarg_re.match(bit) 68 | if not match: 69 | raise TemplateSyntaxError("Malformed arguments to url tag") 70 | name, value = match.groups() 71 | if name: 72 | kwargs[name] = parser.compile_filter(value) 73 | else: 74 | args.append(parser.compile_filter(value)) 75 | return ReStructuredTextNode(content, args, kwargs, asvar) 76 | -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {# Import the theme's layout. #} 2 | {% extends "!layout.html" %} 3 | 4 | {# Include our new CSS file into existing ones. #} 5 | {% set css_files = css_files + ['_static/django-docutils.css']%} 6 | 7 | 8 | {%- block extrahead %} 9 | {{ super() }} 10 | {%- if theme_show_meta_manifest_tag == true %} 11 | 12 | {% endif -%} 13 | {%- if theme_show_meta_og_tags == true %} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {% endif -%} 30 | {%- if theme_show_meta_app_icon_tags == true %} 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | {% endif -%} 51 | {% endblock %} 52 | -------------------------------------------------------------------------------- /django_docutils/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import django 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.template.loader import select_template 6 | from django.template.response import TemplateResponse 7 | from django.views.generic.base import TemplateView 8 | 9 | 10 | class DocutilsResponse(TemplateResponse): 11 | 12 | template_name = "base.html" 13 | 14 | def __init__( 15 | self, 16 | request, 17 | template, 18 | rst, 19 | context=None, 20 | content_type=None, 21 | status=None, 22 | charset=None, 23 | using=None, 24 | ): 25 | self.rst_name = rst 26 | super(DocutilsResponse, self).__init__( 27 | request, template, context, content_type, status, charset, using 28 | ) 29 | 30 | @property 31 | def rendered_content(self): 32 | """Return the freshly rendered content for the template and context 33 | described by the TemplateResponse. 34 | 35 | This *does not* set the final content of the response. To set the 36 | response content, you must either call render(), or set the 37 | content explicitly using the value of this property. 38 | """ 39 | 40 | if django.VERSION < (1, 10): 41 | context = self._resolve_context(self.context_data) 42 | else: 43 | context = self.resolve_context(self.context_data) 44 | 45 | # we should be able to use the engine to .Render this 46 | from django.utils.safestring import mark_safe 47 | 48 | context["content"] = mark_safe( 49 | select_template(self.rst_name, using="docutils").render() 50 | ) 51 | 52 | if django.VERSION < (1, 10): 53 | template = self._resolve_template(self.template_name) 54 | else: 55 | template = self.resolve_template(self.template_name) 56 | content = template.render(context, self._request) 57 | return content 58 | 59 | 60 | class DocutilsView(TemplateView): 61 | response_class = DocutilsResponse 62 | rst_name = None 63 | 64 | def render_to_response(self, context, **response_kwargs): 65 | """Override to pay in rst content.""" 66 | return self.response_class( 67 | request=self.request, 68 | template=self.get_template_names(), 69 | rst=self.get_rst_names(), 70 | context=context, 71 | using=self.template_engine, 72 | **response_kwargs 73 | ) 74 | 75 | def get_rst_names(self): 76 | """ 77 | Follows after get_template_names, but for scanning for rst content. 78 | """ 79 | if self.rst_name is None: 80 | raise ImproperlyConfigured( 81 | "DocutilsView requires either a definition of " 82 | "'rst_name' or an implementation of 'get_rst_names()'" 83 | ) 84 | else: 85 | return [self.rst_name] 86 | -------------------------------------------------------------------------------- /django_docutils/favicon/rst/transforms/favicon.py: -------------------------------------------------------------------------------- 1 | import tldextract 2 | from django.db.models import Q 3 | from docutils import nodes 4 | from docutils.transforms import Transform 5 | 6 | from django_docutils.favicon.models import get_favicon_model 7 | 8 | from ..nodes import icon 9 | 10 | Favicon = get_favicon_model() 11 | 12 | 13 | def resolve_favicon(url): 14 | """Given a URL to a website, see if a Favicon exists in db. 15 | 16 | URL will be resolved to a fqdn for a key lookup. 17 | 18 | :param url: URL to any page on a website 19 | :type url: str 20 | :returns: Full Storage based favicon url path, or None 21 | :rtype: str|None 22 | """ 23 | # e.g. forums.bbc.co.uk 24 | fqdn = tldextract.extract(url).fqdn 25 | 26 | try: 27 | return Favicon.objects.get(domain=fqdn).favicon.url 28 | except (ValueError, Favicon.DoesNotExist): 29 | return None 30 | 31 | 32 | class FaviconTransform(Transform): 33 | #: run after based.app.references.rst.transforms.xref 34 | default_priority = 20 35 | 36 | def apply(self): 37 | q = Q() 38 | 39 | # first run, iterate through references, extract FQDN's, add to query 40 | for node in self.document.traverse(plain_references): 41 | q.add(Q(domain__exact=tldextract.extract(node["refuri"]).fqdn), Q.OR) 42 | 43 | # pull all fqdn's with a favicon 44 | favicons = Favicon.objects.filter(q) 45 | 46 | for node in self.document.traverse(plain_references): 47 | fqdn = tldextract.extract(node["refuri"]).fqdn 48 | try: 49 | favicon_url = next( # Find favicon matching fqdn 50 | (f.favicon.url for f in favicons if f.domain == fqdn), None 51 | ) 52 | except ValueError: # no favicon exists for fqdn 53 | favicon_url = None 54 | 55 | if favicon_url: 56 | nodecopy = node.deepcopy() 57 | ico = icon( 58 | "", 59 | "", 60 | style=f"background-image: url({favicon_url})", 61 | classes=["ico"], 62 | ) 63 | nodecopy.insert(0, ico) 64 | node.replace_self(nodecopy) 65 | 66 | 67 | def plain_references(node): 68 | """Docutils traversal: Only return references with URI's, skip xref's 69 | 70 | If a nodes.reference already has classes, it's an icon class from xref, 71 | so skip that. 72 | 73 | If a nodes.reference has no 'refuri', it's junk, skip. 74 | 75 | Docutils node.traverse condition callback 76 | 77 | :returns: True if it's a URL we want to lookup favicons for 78 | :rtype: bool 79 | """ 80 | if isinstance(node, nodes.reference): 81 | # skip nodes already with xref icon classes or no refuri 82 | no_classes = "classes" not in node or not node["classes"] 83 | has_refuri = "refuri" in node 84 | if no_classes and has_refuri and node["refuri"].startswith("http"): 85 | return True 86 | return False 87 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: [ '3.x' ] 11 | django-version: [ '3.0', '3.1', '3.2', '4.0' ] 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v3 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | 19 | - name: Get full Python version 20 | id: full-python-version 21 | shell: bash 22 | run: echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") 23 | 24 | - name: Install poetry 25 | run: | 26 | curl -O -sSL https://install.python-poetry.org/install-poetry.py 27 | python install-poetry.py -y --version 1.1.12 28 | echo "PATH=${HOME}/.poetry/bin:${PATH}" >> $GITHUB_ENV 29 | rm install-poetry.py 30 | 31 | - name: Add ~/.local/bin to PATH 32 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 33 | 34 | - name: Get poetry cache paths from config 35 | run: | 36 | echo "poetry_virtualenvs_path=$(poetry config --list | sed -n 's/.*virtualenvs.path = .* # //p' | sed -e 's/^\"//' -e 's/\"$//')" >> $GITHUB_ENV 37 | echo "poetry_virtualenvs_path=$(poetry config --list | sed -n 's/.*virtualenvs.path = .* # //p' | sed -e 's/^\"//' -e 's/\"$//')" >> $GITHUB_ENV 38 | 39 | - name: Configure poetry 40 | shell: bash 41 | run: poetry config virtualenvs.in-project true 42 | 43 | - name: Set up cache 44 | uses: actions/cache@v3 45 | id: cache 46 | with: 47 | path: | 48 | .venv 49 | ${{ env.poetry_cache_dir }} 50 | ${{ env.poetry_virtualenvs_path }} 51 | key: venv-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }} 52 | 53 | - name: Ensure cache is healthy 54 | if: steps.cache.outputs.cache-hit == 'true' 55 | shell: bash 56 | run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv 57 | 58 | - name: Install dependencies 59 | run: | 60 | poetry install -E "docs test coverage lint format favicon" 61 | poetry run pip install DJANGO~=${{ matrix.django-version }} 62 | 63 | - name: Lint with flake8 64 | run: poetry run flake8 65 | 66 | - name: Test with pytest 67 | run: poetry run py.test --cov=./ --cov-report=xml 68 | 69 | - uses: codecov/codecov-action@v2 70 | with: 71 | token: ${{ secrets.CODECOV_TOKEN }} 72 | 73 | - name: Build package 74 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 75 | run: poetry build 76 | 77 | - name: Publish package 78 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 79 | uses: pypa/gh-action-pypi-publish@release/v1 80 | with: 81 | user: __token__ 82 | password: ${{ secrets.PYPI_API_TOKEN }} 83 | skip_existing: true 84 | -------------------------------------------------------------------------------- /django_docutils/rst_post/models/post_page.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.core.exceptions import ObjectDoesNotExist 3 | from django.db import models 4 | from django.urls import reverse 5 | from django.urls.exceptions import NoReverseMatch 6 | from django.utils.functional import cached_property 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | from django_docutils.lib.publisher import publish_doctree 10 | 11 | 12 | def get_postpage_models(): 13 | """Return high-level PageBase models. Highest level PostPage.""" 14 | models = [] 15 | for model in apps.get_models(): 16 | if issubclass(model, RSTPostPageBase) and not model.__subclasses__(): 17 | models.append(model) 18 | return models 19 | 20 | 21 | class RSTPostPageBase(models.Model): 22 | 23 | """Where the content of a post resides. Posts can have multiple pages.""" 24 | 25 | body = models.TextField() 26 | subtitle = models.CharField(_("sub title"), max_length=255, null=True, blank=True) 27 | page_number = models.PositiveSmallIntegerField(null=True) 28 | 29 | class Meta: 30 | abstract = True 31 | 32 | def __str__(self): 33 | if self.subtitle: 34 | return "{title}: {subtitle}".format( 35 | title=self.post.title, subtitle=self.subtitle 36 | ) 37 | return self.post.title 38 | 39 | @cached_property 40 | def title(self): 41 | return self.post.title 42 | 43 | @cached_property 44 | def previous_page(self): 45 | try: 46 | return self.post.pages.get(page_number=self.page_number - 1) 47 | except ObjectDoesNotExist: 48 | return None 49 | 50 | @cached_property 51 | def next_page(self): 52 | try: 53 | return self.post.pages.get(page_number=self.page_number + 1) 54 | except ObjectDoesNotExist: 55 | return None 56 | 57 | def get_absolute_url(self): 58 | try: 59 | return reverse( 60 | f"{self.post_url_key}:detail-view", 61 | kwargs={ 62 | "slug_id": self.post.slug_id, 63 | "slug_title": self.post.slug_title, 64 | "page": self.page_number, 65 | }, 66 | ) 67 | except NoReverseMatch: 68 | return reverse( 69 | f"{self.post_url_key}:detail-view", 70 | kwargs={"slug_title": self.post.slug_title, "page": self.page_number}, 71 | ) 72 | 73 | @cached_property 74 | def post_url_key(self): 75 | return self.post.__class__.__name__.lower() + "s" 76 | 77 | @cached_property 78 | def document(self): 79 | """Return page content in a docutils' document 80 | 81 | :rtype: :class:`docutils.nodes.document` 82 | """ 83 | return publish_doctree(self.body) 84 | 85 | def get_subclass(self): 86 | return self.post._meta.concrete_model.objects.get_subclass(pk=self.pk) 87 | 88 | @classmethod 89 | def check(cls, **kwargs): 90 | from .checks import _check_postpage_post_back_relation 91 | 92 | errors = super().check(**kwargs) 93 | errors.extend(_check_postpage_post_back_relation(cls)) 94 | return errors 95 | -------------------------------------------------------------------------------- /django_docutils/lib/transforms/ads.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from django.conf import settings 4 | from docutils import nodes 5 | from docutils.transforms import Transform 6 | 7 | from django_docutils.lib.utils import append_html_to_node, find_root_sections 8 | 9 | """ 10 | Ideas: 11 | 12 | Create a generic Ad class that can counter the number of times it's 13 | posted, and render itself. It can be used to wrap Google and Amazon ads. 14 | 15 | If content is < 1000 TOTAL across all sections, show NO ads whatsoever 16 | 17 | """ 18 | 19 | 20 | class InjectAds(Transform): 21 | 22 | """Add AdSense Javascript after the second nodes.section in Writer. 23 | 24 | Finding the node: 25 | 26 | 1. First preference, use the second node (usually it picks this) 27 | 2. But if the node's text ends with a :, continue to traverse nodes 28 | """ 29 | 30 | default_priority = 100 31 | 32 | #: list of keywords for the ad system, default 'linux' 33 | ad_keywords = ["linux"] 34 | 35 | #: minimum amount of chars in a section to show and add 36 | #: https://support.google.com/adsense/answer/1346295?hl=en#Ad_limit_per_page # NOQA 37 | ad_section_length_min = 1000 38 | 39 | #: minimum content on page to show an ad 40 | #: showing ads where there isn't enough content sucks 41 | ad_page_length_min = ad_section_length_min 42 | 43 | #: max ads per page 44 | ads_max = 3 45 | 46 | @classmethod 47 | def keywords(cls, ad_keywords): 48 | if ad_keywords: 49 | cls.ad_keywords = ad_keywords 50 | return cls 51 | 52 | def apply(self): 53 | section_nodes = list(find_root_sections(self.document)) 54 | 55 | ads_posted = 0 56 | 57 | page_length = 0 58 | 59 | for node in section_nodes: 60 | # Don't show Google ads on sections that are too small 61 | section_length = 0 62 | for p in node.traverse(nodes.paragraph): 63 | section_length += len(p.astext()) 64 | 65 | # add this section size to the current page length 66 | page_length += section_length 67 | 68 | if section_length < self.ad_section_length_min: 69 | continue 70 | 71 | ad_code = random.choice( 72 | [ 73 | settings.BASED_ADS["GOOGLE_AD_CODE"], 74 | settings.BASED_ADS["AMAZON_AD_STRIP"], 75 | ] 76 | ) 77 | # ad_code = settings.BASED_ADS['AMZN_AD_CODE'].format( 78 | # keyword=self.ad_keywords[0] 79 | # ) 80 | 81 | # append node to end of section 82 | append_html_to_node(node, ad_code) 83 | ads_posted += 1 84 | 85 | if ads_posted >= self.ads_max: 86 | break 87 | 88 | # if no ads posted, inject in last section 89 | if not ads_posted and page_length > self.ad_page_length_min: 90 | try: 91 | last_section = section_nodes[-1] 92 | except IndexError: # there's no sections after main title 93 | last_section = self.document 94 | 95 | node = last_section[-1] # end of section 96 | 97 | append_html_to_node(node, settings.BASED_ADS["GOOGLE_AD_CODE"]) 98 | -------------------------------------------------------------------------------- /django_docutils/lib/directives/code.py: -------------------------------------------------------------------------------- 1 | """ 2 | The Pygments reStructuredText directive 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | This fragment is a Docutils_ 0.5 directive that renders source code 6 | (to HTML only, currently) via Pygments. 7 | 8 | To use it, adjust the options below and copy the code into a module 9 | that you import on initialization. The code then automatically 10 | registers a ``sourcecode`` directive that you can use instead of 11 | normal code blocks like this:: 12 | 13 | .. sourcecode:: python 14 | 15 | My code goes here. 16 | 17 | If you want to have different code styles, e.g. one with line numbers 18 | and one without, add formatters with their names in the VARIANTS dict 19 | below. You can invoke them instead of the DEFAULT one by using a 20 | directive option:: 21 | 22 | .. sourcecode:: python 23 | :linenos: 24 | 25 | My code goes here. 26 | 27 | Look at the `directive documentation`_ to get all the gory details. 28 | 29 | .. _Docutils: http://docutils.sf.net/ 30 | .. _directive documentation: 31 | http://docutils.sourceforge.net/docs/howto/rst-directives.html 32 | 33 | :copyright: Copyright 2006-2015 by the Pygments team, see AUTHORS. 34 | :license: BSD, see LICENSE for details. 35 | """ 36 | 37 | import re 38 | 39 | from docutils import nodes 40 | from docutils.parsers.rst import Directive, directives 41 | from pygments import highlight 42 | from pygments.formatters.html import HtmlFormatter 43 | from pygments.lexers import get_lexer_by_name 44 | from pygments.lexers.shell import BashSessionLexer 45 | from pygments.lexers.special import TextLexer 46 | 47 | #: Monkey patch Bash Session lexer to gobble up initial space after prompt 48 | BashSessionLexer._ps1rgx = re.compile( 49 | r"^((?:(?:\[.*?\])|(?:\(\S+\))?(?:| |sh\S*?|\w+\S+[@:]\S+(?:\s+\S+)" 50 | r"?|\[\S+[@:][^\n]+\].+))\s*[$#%] )(.*\n?)" 51 | ) 52 | 53 | # Options 54 | # ~~~~~~~ 55 | 56 | #: Set to True if you want inline CSS styles instead of classes 57 | INLINESTYLES = False 58 | 59 | #: The default formatter 60 | DEFAULT = HtmlFormatter(cssclass="highlight code-block", noclasses=INLINESTYLES) 61 | 62 | #: Add name -> formatter pairs for every variant you want to use 63 | VARIANTS = { 64 | # 'linenos': HtmlFormatter(noclasses=INLINESTYLES, linenos=True), 65 | } 66 | 67 | 68 | class CodeBlock(Directive): 69 | 70 | """Source code syntax hightlighting.""" 71 | 72 | required_arguments = 1 73 | optional_arguments = 0 74 | final_argument_whitespace = True 75 | option_spec = {key: directives.flag for key in VARIANTS} 76 | has_content = True 77 | 78 | def run(self): 79 | self.assert_has_content() 80 | try: 81 | lexer_name = self.arguments[0] 82 | 83 | lexer = get_lexer_by_name(lexer_name) 84 | except ValueError: 85 | # no lexer found - use the text one instead of an exception 86 | lexer = TextLexer() 87 | # take an arbitrary option if more than one is given 88 | formatter = self.options and VARIANTS[list(self.options)[0]] or DEFAULT 89 | parsed = highlight("\n".join(self.content), lexer, formatter) 90 | return [nodes.raw("", parsed, format="html")] 91 | -------------------------------------------------------------------------------- /django_docutils/references/rst/transforms/xref.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | from django.utils.translation import gettext_lazy as _ 4 | from docutils import nodes 5 | from docutils.transforms import Transform 6 | from docutils.utils import relative_path 7 | 8 | from django_docutils.lib.transforms.font_awesome import fa_classes_from_url 9 | from django_docutils.references.models import get_reference_model 10 | 11 | from ..nodes import pending_xref 12 | 13 | Reference = get_reference_model() 14 | 15 | 16 | class XRefTransform(Transform): 17 | default_priority = 5 18 | 19 | def apply(self): 20 | references = Reference.objects.all().values() 21 | 22 | for node in self.document.traverse(pending_xref): 23 | contnode = node[0].deepcopy() 24 | domain = "std" 25 | project, target = node["reftarget"].split(":", 1) 26 | 27 | ref = next( 28 | ( 29 | r 30 | for r in references 31 | if r["target"] == target and r["project"] == project 32 | ), 33 | None, 34 | ) 35 | 36 | if not ref: 37 | ref = next((r for r in references if r["target"] == target), None) 38 | 39 | proj, version, uri, dispname = ( 40 | ref["project"], 41 | ref["project_version"], 42 | ref["uri"], 43 | ref["display_name"], 44 | ) 45 | 46 | if not dispname: 47 | dispname = "-" 48 | if "://" not in uri and node.get("refdoc"): 49 | # get correct path in case of subdirectories 50 | uri = path.join(relative_path(node["refdoc"], "."), uri) 51 | newnode = nodes.reference( 52 | "", 53 | "", 54 | internal=False, 55 | refuri=uri, 56 | reftitle=_("(in %s v%s)") % (proj, version), 57 | ) 58 | 59 | if node.get("refexplicit"): 60 | # use whatever title was given 61 | newnode.append(contnode) 62 | elif dispname == "-" or (domain == "std" and node["reftype"] == "keyword"): 63 | # use whatever title was given, but strip prefix 64 | title = contnode.astext() 65 | if project and title.startswith(project + ":"): 66 | newnode.append( 67 | contnode.__class__( 68 | title[len(project) + 1 :], title[len(project) + 1 :] 69 | ) 70 | ) 71 | else: 72 | newnode.append(contnode) 73 | else: 74 | # else use the given display name (used for :ref:) 75 | newnode.append(contnode.__class__(dispname, dispname)) 76 | 77 | fa_classes = fa_classes_from_url(url=uri) 78 | if fa_classes != "": 79 | fa_tag = f'' 80 | newnode.insert(0, nodes.raw("", fa_tag, format="html")) 81 | 82 | node.replace_self(newnode) 83 | 84 | def visit_pending_xref(self, node): 85 | pass 86 | 87 | def depart_pending_xref(self, node): 88 | pass 89 | -------------------------------------------------------------------------------- /django_docutils/rst_post/models/post.py: -------------------------------------------------------------------------------- 1 | import dirtyfields 2 | from django.apps import apps as django_apps 3 | from django.conf import settings 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.db import models 7 | from django.utils.functional import cached_property 8 | from django.utils.translation import gettext_lazy as _ 9 | from django_extensions.db.fields import ( 10 | AutoSlugField, 11 | CreationDateTimeField, 12 | ModificationDateTimeField, 13 | ) 14 | from django_slugify_processor.text import slugify 15 | from randomslugfield import RandomSlugField 16 | 17 | 18 | def get_post_model(): 19 | try: 20 | return django_apps.get_model(settings.BASED_POST_MODEL, require_ready=False) 21 | except ValueError: 22 | raise ImproperlyConfigured( 23 | "BASED_POST_MODEL must be of the form 'app_label.model_name'" 24 | ) 25 | except LookupError: 26 | raise ImproperlyConfigured( 27 | "BASED_POST_MODEL refers to model '%s' that has not been installed" 28 | % settings.BASED_POST_MODEL 29 | ) 30 | 31 | 32 | def get_post_models(): 33 | """Return high-level PageBase models. Skips subclasses of PostBase.""" 34 | models = [] 35 | for model in django_apps.get_models(): 36 | if issubclass(model, RSTPostBase) and model.__subclasses__(): 37 | models.append(model) 38 | return models 39 | 40 | 41 | def get_anonymous_user_instance(UserModel=None): 42 | if UserModel is None: 43 | from django.contrib.auth import get_user_model 44 | 45 | UserModel = get_user_model() 46 | user, _ = UserModel.objects.get_or_create( 47 | username=settings.ANONYMOUS_USER_NAME, email="noone@localhost" 48 | ) 49 | return user 50 | 51 | 52 | class RSTPostBase(dirtyfields.DirtyFieldsMixin, models.Model): 53 | title = models.CharField(_("title"), max_length=255) 54 | 55 | slug_title = AutoSlugField( 56 | _("slug"), populate_from="title", slugify_function=slugify 57 | ) 58 | 59 | slug_id = RandomSlugField(length=8, unique=True, editable=False) 60 | author_name = models.CharField(_("Author name"), max_length=255) 61 | is_draft = models.BooleanField(default=False, editable=False, db_index=True) 62 | created = CreationDateTimeField(_("created")) 63 | modified = ModificationDateTimeField(_("modified")) 64 | 65 | class Meta: 66 | ordering = ["-created"] 67 | abstract = True 68 | 69 | def save(self, **kwargs): 70 | self.update_modified = kwargs.pop( 71 | "update_modified", getattr(self, "update_modified", True) 72 | ) 73 | super().save(**kwargs) 74 | 75 | @cached_property 76 | def content_type(self): 77 | return ContentType.objects.get_for_model(self) 78 | 79 | @cached_property 80 | def subtitle(self): 81 | return self.root_page.subtitle 82 | 83 | def __str__(self): 84 | title = self.title 85 | if self.subtitle: 86 | title += ": " + self.subtitle 87 | return title 88 | 89 | @classmethod 90 | def check(cls, **kwargs): 91 | from .checks import _check_root_page 92 | 93 | errors = super().check(**kwargs) 94 | errors.extend(_check_root_page(cls)) 95 | return errors 96 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-docutils" 3 | version = "0.6.0" 4 | description = "Documentation Utilities (Docutils, reStructuredText) for django.)" 5 | 6 | license = "MIT" 7 | 8 | authors = [ 9 | "Tony Narlock ", 10 | ] 11 | 12 | readme = "README.md" 13 | packages = [ 14 | { include = "django_docutils" }, 15 | ] 16 | classifiers = [ 17 | 'Development Status :: 2 - Pre-Alpha', 18 | 'Framework :: Django', 19 | 'Framework :: Django :: 3.1', 20 | 'Framework :: Django :: 3.2', 21 | 'Framework :: Django :: 4.0', 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: MIT License', 24 | 'Natural Language :: English', 25 | 'Programming Language :: Python :: 3', 26 | 'Programming Language :: Python :: 3.8', 27 | 'Programming Language :: Python :: 3.9', 28 | 'Programming Language :: Python :: 3.10', 29 | ] 30 | keywords = ["django", "docutils", "reStructuredText", "rst", "reST"] 31 | 32 | homepage = "https://django-docutils.git-pull.com" 33 | 34 | [tool.poetry.urls] 35 | "Bug Tracker" = "https://github.com/tony/django-docutils/issues" 36 | Documentation = "https://django-docutils.git-pull.com" 37 | Repository = "https://github.com/tony/django-docutils" 38 | Changes = "https://github.com/tony/django-docutils/blob/master/CHANGES" 39 | "Q & A" = "https://github.com/tony/django-docutils/discussions" 40 | 41 | [tool.poetry.dependencies] 42 | python = "^3.8" 43 | Django = ">=3.2" 44 | docutils = "*" 45 | tldextract = { version = "*", optional = true } 46 | tqdm = { version = "*", optional = true } 47 | pygments = "<3" 48 | django-extensions = "*" 49 | django-randomslugfield = "*" 50 | django-slugify-processor = "*" 51 | django-dirtyfields = ">1.3.0" 52 | lxml = "*" 53 | bitly-api-py3 = "*" 54 | 55 | [tool.poetry.dev-dependencies] 56 | ### Docs ### 57 | sphinx = "*" 58 | furo = "*" 59 | sphinx-autobuild = "*" 60 | sphinx-autodoc-typehints = "*" 61 | sphinx-click = "*" 62 | sphinx-issues = "*" 63 | sphinx-inline-tabs = "*" 64 | sphinxext-opengraph = "*" 65 | sphinx-copybutton = "*" 66 | sphinxext-rediraffe = "*" 67 | myst_parser = "*" 68 | 69 | ### Testing ### 70 | pytest = "*" 71 | pytest-rerunfailures = "*" 72 | pytest-mock = "*" 73 | pytest-watcher = "^0.2.3" 74 | factory-boy = "*" 75 | pytest-factoryboy = "*" 76 | pytest-django = "*" 77 | dj-inmemorystorage = "*" 78 | responses = "*" 79 | 80 | ### Coverage ### 81 | codecov = "*" 82 | coverage = "*" 83 | pytest-cov = "*" 84 | 85 | ### Format ### 86 | black = "*" 87 | isort = "*" 88 | 89 | ### Lint ### 90 | flake8 = "*" 91 | mypy = "*" 92 | 93 | [tool.poetry.extras] 94 | favicon = ["tldextract", "tqdm"] 95 | intersphinx = ["tqdm"] 96 | 97 | # Development stuff 98 | docs = [ 99 | "sphinx", 100 | "sphinx-issues", 101 | "sphinx-click", 102 | "sphinx-autodoc-typehints", 103 | "sphinx-autobuild", 104 | "sphinxext-rediraffe", 105 | "sphinx-copybutton", 106 | "sphinxext-opengraph", 107 | "sphinx-inline-tabs", 108 | "myst_parser", 109 | "furo", 110 | ] 111 | test = [ 112 | "pytest", 113 | "pytest-rerunfailures", 114 | "pytest-mock", 115 | "pytest-watcher", 116 | "factory-boy", 117 | "pytest-factoryboy", 118 | "pytest-django", 119 | "dj-inmemorystorage", 120 | "responses", 121 | ] 122 | coverage = ["codecov", "coverage", "pytest-cov"] 123 | format = ["black", "isort"] 124 | lint = ["flake8", "mypy"] 125 | -------------------------------------------------------------------------------- /django_docutils/lib/views.py: -------------------------------------------------------------------------------- 1 | from django.utils.functional import cached_property 2 | from django.views.generic.base import ContextMixin, TemplateView 3 | 4 | from django_docutils.lib.publisher import ( 5 | publish_doctree, 6 | publish_html_from_doctree, 7 | publish_toc_from_doctree, 8 | ) 9 | 10 | from .text import smart_title 11 | 12 | 13 | class TitleMixin(ContextMixin): 14 | title = None 15 | subtitle = None 16 | 17 | def get_context_data(self, **kwargs): 18 | context = super().get_context_data(**kwargs) 19 | if self.title: 20 | context["title"] = smart_title(self.title) 21 | if self.subtitle: 22 | context["subtitle"] = smart_title(self.subtitle) 23 | return context 24 | 25 | 26 | class TemplateTitleView(TemplateView, TitleMixin): 27 | title = None 28 | subtitle = None 29 | 30 | def get_context_data(self, **kwargs): 31 | context = super().get_context_data(**kwargs) 32 | return context 33 | 34 | 35 | class RSTMixin: 36 | @cached_property 37 | def raw_content(self): 38 | raise NotImplementedError 39 | 40 | @cached_property 41 | def doctree(self): 42 | return publish_doctree(self.raw_content) 43 | 44 | @cached_property 45 | def sidebar(self, **kwargs): 46 | return publish_toc_from_doctree(self.doctree) 47 | 48 | @cached_property 49 | def content(self): 50 | return publish_html_from_doctree( 51 | self.doctree, **getattr(self, "rst_settings", {}) 52 | ) 53 | 54 | def get_base_template(self): 55 | """TODO: move this out of RSTMixin, it is AMP related, not RST""" 56 | if self.request.GET.get("is_amp", False): 57 | return "based/base-amp.html" 58 | else: 59 | return "base.html" 60 | 61 | 62 | class RSTRawView(TemplateTitleView): 63 | 64 | """Send pure reStructuredText to template. 65 | 66 | Requires template tags to process it. 67 | 68 | .. code-block:: html 69 | 70 | {% block content %} 71 |
72 | {% restructuredtext content show_title=False inject_ads=False %} 73 |
74 | {% endblock content %} 75 | 76 | {% block sidebar %} 77 | {% restructuredtext content toc_only=True %} 78 | {% endblock sidebar %} 79 | 80 | """ 81 | 82 | template_name = "rst/raw.html" 83 | file_path = None 84 | title = None 85 | rst_settings = {"inject_ads": True} 86 | 87 | def get_context_data(self, **kwargs): 88 | context = super().get_context_data(**kwargs) 89 | context["content"] = open(self.file_path, "r").read() 90 | context["inject_ads"] = self.rst_settings["inject_ads"] 91 | return context 92 | 93 | 94 | class RSTView(RSTRawView, RSTMixin): 95 | template_name = "rst/base.html" 96 | file_path = None 97 | title = None 98 | rst_settings = {"inject_ads": True} 99 | 100 | @cached_property 101 | def raw_content(self): 102 | return open(self.file_path, "r").read() 103 | 104 | def get_context_data(self, **kwargs): 105 | context = super().get_context_data(**kwargs) 106 | context["content"] = self.content 107 | context["sidebar"] = self.sidebar 108 | 109 | return context 110 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/directory/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | This format is different: 3 | 4 | 1. It resides in its own directory 5 | 2. Includes a JSON file 6 | 3. Does not store configuration in the RST file itself 7 | 8 | This is intended to ensure these projects can be open source repositories 9 | with collaborators on GitHub. 10 | 11 | Here is what a layout of "dir"-type RST fixtures look like: 12 | 13 | fixtures/ 14 | - myproject/ 15 | - manifest.json: post content configuration information (e.g. title) 16 | - README.rst: reStructuredText content 17 | """ 18 | import os 19 | 20 | from django_docutils.exc import BasedException 21 | from django_docutils.lib.fixtures.utils import find_rst_files 22 | 23 | 24 | def find_series_files(config, path): 25 | """Find series files from config + path. 26 | 27 | Configs don't hold the path of the files right now, so we pass it in. 28 | 29 | :param config: config.json in dict format 30 | :type config: :class:`python:dict` 31 | :param path: path to project (directory) 32 | :type path: string 33 | :returns: *ordered* list of files, relative to path 34 | :rtype: list 35 | """ 36 | 37 | files_in_path = find_rst_files(path, absolute=True) 38 | files_in_series = [os.path.join(path, f) for f in config["series"]] 39 | 40 | # assert series in config matches files present in directory, if not 41 | # throw exception 42 | has_files = set(files_in_path) == set(files_in_series) 43 | 44 | if not has_files: 45 | raise BasedException( 46 | "Files in {} ({}) do not match the ones listed in" 47 | 'the "series" metadata ({}).'.format( 48 | path, ", ".join(files_in_path), ", ".join(files_in_series) 49 | ) 50 | ) 51 | 52 | return files_in_series 53 | 54 | 55 | def is_dir_project(path): 56 | """Return True if directory is a directory-based project. 57 | 58 | (As opposed to a single file.) 59 | 60 | :param path: Directory of project 61 | :type path: string 62 | :returns; If directory has the proper files to be a "dir"-type project 63 | :rtype: boolean 64 | """ 65 | required_files = [ 66 | os.path.join(path, "README.rst"), 67 | os.path.join(path, "manifest.json"), 68 | ] 69 | 70 | return all(os.path.exists(f) for f in required_files) 71 | 72 | 73 | def find_rst_dirs_in_app(app_config): 74 | """Return reStructuredText fixtures from fixtures dir for app. 75 | 76 | :param app_config: Configuration for django app 77 | :type app_config: :class:`django.apps.AppConfig` 78 | :returns: list of files relative to app's fixture dir path 79 | """ 80 | fixtures_dir = os.path.join(app_config.path, "fixtures") 81 | return find_rst_dir_projects(fixtures_dir) 82 | 83 | 84 | def find_rst_dir_projects(path): 85 | """Find and return projects in a directory with: 86 | 87 | - manifest.json 88 | - README.rst 89 | 90 | :param path: Path to search for projects 91 | :type path: string 92 | :returns: List of directories containing fixtures projects in dir format 93 | :rtype: list 94 | """ 95 | paths = [] 96 | for _root, dirname, filenames in os.walk(path): 97 | for dir_ in dirname: 98 | if is_dir_project(os.path.join(_root, dir_)): 99 | paths.append(os.path.join(_root, dir_)) 100 | return paths 101 | -------------------------------------------------------------------------------- /django_docutils/directives.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from docutils import nodes 5 | from docutils.parsers.rst import Directive, directives 6 | from pygments import highlight 7 | from pygments.formatters import HtmlFormatter 8 | from pygments.lexers import TextLexer, get_lexer_by_name 9 | 10 | # -*- coding: utf-8 -*- 11 | """ 12 | The Pygments reStructuredText directive 13 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 14 | 15 | This fragment is a Docutils_ 0.5 directive that renders source code 16 | (to HTML only, currently) via Pygments. 17 | 18 | To use it, adjust the options below and copy the code into a module 19 | that you import on initialization. The code then automatically 20 | registers a ``sourcecode`` directive that you can use instead of 21 | normal code blocks like this:: 22 | 23 | .. sourcecode:: python 24 | 25 | My code goes here. 26 | 27 | If you want to have different code styles, e.g. one with line numbers 28 | and one without, add formatters with their names in the VARIANTS dict 29 | below. You can invoke them instead of the DEFAULT one by using a 30 | directive option:: 31 | 32 | .. sourcecode:: python 33 | :linenos: 34 | 35 | My code goes here. 36 | 37 | Look at the `directive documentation`_ to get all the gory details. 38 | 39 | .. _Docutils: http://docutils.sf.net/ 40 | .. _directive documentation: 41 | http://docutils.sourceforge.net/docs/howto/rst-directives.html 42 | 43 | :copyright: Copyright 2006-2015 by the Pygments team, see AUTHORS. 44 | :license: BSD, see LICENSE for details. 45 | """ 46 | 47 | # Options 48 | # ~~~~~~~ 49 | 50 | # Set to True if you want inline CSS styles instead of classes 51 | INLINESTYLES = False 52 | 53 | 54 | #: The default formatter 55 | DEFAULT = HtmlFormatter(noclasses=INLINESTYLES) 56 | 57 | #: Add name -> formatter pairs for every variant you want to use 58 | VARIANTS = { 59 | # 'linenos': HtmlFormatter(noclasses=INLINESTYLES, linenos=True), 60 | } 61 | 62 | 63 | class CodeBlock(Directive): 64 | """Source code syntax hightlighting.""" 65 | 66 | required_arguments = 1 67 | optional_arguments = 0 68 | final_argument_whitespace = True 69 | option_spec = dict([(key, directives.flag) for key in VARIANTS]) 70 | has_content = True 71 | 72 | def run(self): 73 | self.assert_has_content() 74 | try: 75 | lexer = get_lexer_by_name(self.arguments[0]) 76 | except ValueError: 77 | # no lexer found - use the text one instead of an exception 78 | lexer = TextLexer() 79 | # take an arbitrary option if more than one is given 80 | formatter = self.options and VARIANTS[list(self.options)[0]] or DEFAULT 81 | parsed = highlight("\n".join(self.content), lexer, formatter) 82 | return [nodes.raw("", parsed, format="html")] 83 | 84 | 85 | def register_pygments_directive(directive="code-block"): 86 | """Register pygments directive. 87 | 88 | Parameters 89 | ---------- 90 | directive : str 91 | directive name to register pygments to. 92 | 93 | Examples 94 | -------- 95 | If you wish to use (override) code-block (default), that means:: 96 | 97 | .. code-block:: 98 | 99 | // will be highlighted by pygments 100 | """ 101 | directives.register_directive(directive, CodeBlock) 102 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/utils.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | import os 3 | 4 | from django.apps import apps 5 | 6 | 7 | def get_model_from_post_app(app_config): 8 | """Return post model for app. 9 | 10 | :param app_config: Configuration for django app 11 | :type app_config: :class:`django.apps.AppConfig` 12 | :returns: Post sub-model for app 13 | :rtype: Model 14 | """ 15 | from django_docutils.rst_post.models import RSTPostBase 16 | 17 | for model in app_config.get_models(): 18 | if issubclass(model, RSTPostBase): 19 | return model 20 | 21 | 22 | def find_rst_files(path, absolute=False, recursive=False): 23 | """Find .rst files in directory. 24 | 25 | This is reused in dir-style projects. 26 | 27 | :param path: path to project (directory) 28 | :type path: string 29 | :param absolute: return absolute paths 30 | :type absolute: bool 31 | :param recursive: recursively check for rst files 32 | :type recursive: bool 33 | :returns: return list of .rst files from path 34 | :rtype: list 35 | """ 36 | files = [] 37 | for _root, dirname, filenames in os.walk(path): 38 | for filename in fnmatch.filter(filenames, "*.rst"): 39 | p = os.path.relpath(_root, path) 40 | if absolute: 41 | files.append(os.path.normpath(os.path.join(path, p, filename))) 42 | else: 43 | files.append(os.path.join(p, filename)) 44 | if not recursive: 45 | break 46 | return files 47 | 48 | 49 | def find_app_configs_with_fixtures(has_rst_files=True): 50 | """Return a list of apps with fixtures dir. 51 | 52 | In Django 1.11 fixture_dirs grabs a directory of all fixtures, in our 53 | situation, we want to grab the correct model. 54 | 55 | :param has_rst_files: Only return apps with .rst files in fixtures 56 | :type has_rst_files: bool 57 | :returns: list of app configs with fixtures directory 58 | :rtype: list of :django:class`django.apps.AppConfig` 59 | """ 60 | app_configs = [] 61 | for app_config in apps.get_app_configs(): 62 | app_dir = os.path.join(app_config.path, "fixtures") 63 | if os.path.isdir(app_dir): 64 | if has_rst_files: 65 | if len(find_rst_files(app_dir, recursive=True)) < 1: 66 | continue 67 | app_configs.append(app_config) 68 | 69 | return app_configs 70 | 71 | 72 | def find_rst_files_in_app(app_config): 73 | """Return fixtures reStructuredText files from fixtures dir for app. 74 | 75 | :param app_config: Configuration for django app 76 | :type app_config: :class:`django.apps.AppConfig` 77 | :returns: list of files relative to app's fixtures dir path 78 | """ 79 | fixtures_dir = os.path.join(app_config.path, "fixtures") 80 | return find_rst_files(fixtures_dir) 81 | 82 | 83 | def split_page_data(post_data): 84 | """Pluck the page data from the post data and return both. 85 | 86 | publish_post is pure and doesn't know what a post/page is. 87 | 88 | Posts contain pages. devel.tech's architecture needs to split page data 89 | from the post. The page will store the "body" content and also 90 | a subtitle. 91 | """ 92 | page_data = {} 93 | for field in ["body", "subtitle", "draft"]: 94 | try: 95 | page_data[field] = post_data.pop(field) 96 | except KeyError: # handle corner case, no subtitle/body 97 | pass 98 | return post_data, page_data 99 | -------------------------------------------------------------------------------- /django_docutils/rst_post/models/utils.py: -------------------------------------------------------------------------------- 1 | from django.core import checks 2 | from django.core.exceptions import FieldDoesNotExist 3 | from django.db import models 4 | from django.db.models.fields.related import resolve_relation 5 | 6 | from .post import RSTPostBase 7 | from .post_page import RSTPostPageBase 8 | 9 | 10 | def _check_root_page(cls): 11 | """System check for root_page field on PostBase models.""" 12 | try: 13 | root_page = cls._meta.get_field("root_page") 14 | 15 | # must be correct field type 16 | if root_page.__class__ != models.ForeignKey: 17 | return [ 18 | checks.Error( 19 | "Wrong field type for root_page field.", 20 | hint="Use a models.ForeignKey field.", 21 | obj=cls, 22 | id="rst_post.E002", 23 | ) 24 | ] 25 | else: # check for the correct relation inside root_page 26 | # incase of model class import strings, e.g. 'MyPostPage' 27 | # instead of MyPostPage 28 | related_model = resolve_relation(cls, root_page.related_model) 29 | if not issubclass(related_model, RSTPostPageBase): 30 | return [ 31 | checks.Error( 32 | "Wrong related model for root_page relationship.", 33 | hint="Use a model subclassing RSTPostPageBase", 34 | obj=cls, 35 | id="rst_post.E003", 36 | ) 37 | ] 38 | except FieldDoesNotExist: # no root_page field 39 | return [ 40 | checks.Error( 41 | "Missing root_page field.", 42 | hint="Add a root_page ForeignKey a subclass of RSTPostPageBase", 43 | obj=cls, 44 | id="rst_post.E001", 45 | ) 46 | ] 47 | return [] 48 | 49 | 50 | def _check_postpage_post_back_relation(cls): 51 | """System check for post field on PostPageBase models.""" 52 | try: 53 | page_field = cls._meta.get_field("post") 54 | 55 | # must be correct field type 56 | if page_field.__class__ != models.ForeignKey: 57 | return [ 58 | checks.Error( 59 | "Wrong field type for post field.", 60 | hint="Use a models.ForeignKey field.", 61 | obj=cls, 62 | id="rst_post.E005", 63 | ) 64 | ] 65 | else: # check for the correct relation inside page_field 66 | # incase of model class import strings, e.g. 'MyPostPage' 67 | # instead of MyPostPage 68 | related_model = resolve_relation(cls, page_field.related_model) 69 | if not issubclass(related_model, RSTPostBase): 70 | return [ 71 | checks.Error( 72 | "Wrong related model for post relationship.", 73 | hint="Use a model subclassing RSTPostBase", 74 | obj=cls, 75 | id="rst_post.E006", 76 | ) 77 | ] 78 | except FieldDoesNotExist: # no page_field field 79 | return [ 80 | checks.Error( 81 | "Missing post field.", 82 | hint="Add a post ForeignKey that subclasses PostBase", 83 | obj=cls, 84 | id="rst_post.E004", 85 | ) 86 | ] 87 | return [] 88 | -------------------------------------------------------------------------------- /django_docutils/rst_post/models/checks.py: -------------------------------------------------------------------------------- 1 | from django.core import checks 2 | from django.core.exceptions import FieldDoesNotExist 3 | from django.db import models 4 | from django.db.models.fields.related import resolve_relation 5 | 6 | from .post import RSTPostBase 7 | from .post_page import RSTPostPageBase 8 | 9 | 10 | def _check_root_page(cls): 11 | """System check for root_page field on PostBase models.""" 12 | try: 13 | root_page = cls._meta.get_field("root_page") 14 | 15 | # must be correct field type 16 | if root_page.__class__ != models.ForeignKey: 17 | return [ 18 | checks.Error( 19 | "Wrong field type for root_page field.", 20 | hint="Use a models.ForeignKey field.", 21 | obj=cls, 22 | id="rst_post.E002", 23 | ) 24 | ] 25 | else: # check for the correct relation inside root_page 26 | # incase of model class import strings, e.g. 'MyPostPage' 27 | # instead of MyPostPage 28 | related_model = resolve_relation(cls, root_page.related_model) 29 | if not issubclass(related_model, RSTPostPageBase): 30 | return [ 31 | checks.Error( 32 | "Wrong related model for root_page relationship.", 33 | hint="Use a model subclassing RSTPostPageBase", 34 | obj=cls, 35 | id="rst_post.E003", 36 | ) 37 | ] 38 | except FieldDoesNotExist: # no root_page field 39 | return [ 40 | checks.Error( 41 | "Missing root_page field.", 42 | hint="Add a root_page ForeignKey a subclass of RSTPostPageBase", 43 | obj=cls, 44 | id="rst_post.E001", 45 | ) 46 | ] 47 | return [] 48 | 49 | 50 | def _check_postpage_post_back_relation(cls): 51 | """System check for post field on PostPageBase models.""" 52 | try: 53 | page_field = cls._meta.get_field("post") 54 | 55 | # must be correct field type 56 | if page_field.__class__ != models.ForeignKey: 57 | return [ 58 | checks.Error( 59 | "Wrong field type for post field.", 60 | hint="Use a models.ForeignKey field.", 61 | obj=cls, 62 | id="rst_post.E005", 63 | ) 64 | ] 65 | else: # check for the correct relation inside page_field 66 | # incase of model class import strings, e.g. 'MyPostPage' 67 | # instead of MyPostPage 68 | related_model = resolve_relation(cls, page_field.related_model) 69 | if not issubclass(related_model, RSTPostBase): 70 | return [ 71 | checks.Error( 72 | "Wrong related model for post relationship.", 73 | hint="Use a model subclassing RSTPostBase", 74 | obj=cls, 75 | id="rst_post.E006", 76 | ) 77 | ] 78 | except FieldDoesNotExist: # no page_field field 79 | return [ 80 | checks.Error( 81 | "Missing post field.", 82 | hint="Add a post ForeignKey that subclasses RSTPostBase", 83 | obj=cls, 84 | id="rst_post.E004", 85 | ) 86 | ] 87 | return [] 88 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | You can contribute in many ways: 9 | 10 | Types of Contributions 11 | ---------------------- 12 | 13 | Report Bugs 14 | ~~~~~~~~~~~ 15 | 16 | Report bugs at https://github.com/tony/django-docutils/issues. 17 | 18 | If you are reporting a bug, please include: 19 | 20 | * Your operating system name and version. 21 | * Any details about your local setup that might be helpful in troubleshooting. 22 | * Detailed steps to reproduce the bug. 23 | 24 | Fix Bugs 25 | ~~~~~~~~ 26 | 27 | Look through the GitHub issues for bugs. Anything tagged with "bug" 28 | is open to whoever wants to implement it. 29 | 30 | Implement Features 31 | ~~~~~~~~~~~~~~~~~~ 32 | 33 | Look through the GitHub issues for features. Anything tagged with "feature" 34 | is open to whoever wants to implement it. 35 | 36 | Write Documentation 37 | ~~~~~~~~~~~~~~~~~~~ 38 | 39 | django-docutils could always use more documentation, whether as part of the 40 | official django-docutils docs, in docstrings, or even on the web in blog posts, 41 | articles, and such. 42 | 43 | Submit Feedback 44 | ~~~~~~~~~~~~~~~ 45 | 46 | The best way to send feedback is to file an issue at https://github.com/tony/django-docutils/issues. 47 | 48 | If you are proposing a feature: 49 | 50 | * Explain in detail how it would work. 51 | * Keep the scope as narrow as possible, to make it easier to implement. 52 | * Remember that this is a volunteer-driven project, and that contributions 53 | are welcome :) 54 | 55 | Get Started! 56 | ------------ 57 | 58 | Ready to contribute? Here's how to set up `django-docutils` for local development. 59 | 60 | 1. Fork the `django-docutils` repo on GitHub. 61 | 2. Clone your fork locally:: 62 | 63 | $ git clone git@github.com:your_name_here/django-docutils.git 64 | 65 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 66 | 67 | $ mkvirtualenv django-docutils 68 | $ cd django-docutils/ 69 | $ python setup.py develop 70 | 71 | 4. Create a branch for local development:: 72 | 73 | $ git checkout -b name-of-your-bugfix-or-feature 74 | 75 | Now you can make your changes locally. 76 | 77 | 5. When you're done making changes, check that your changes pass flake8 and the 78 | tests, including testing other Python versions with tox:: 79 | 80 | $ flake8 django_docutils tests 81 | $ python setup.py test 82 | $ tox 83 | 84 | To get flake8 and tox, just pip install them into your virtualenv. 85 | 86 | 6. Commit your changes and push your branch to GitHub:: 87 | 88 | $ git add . 89 | $ git commit -m "Your detailed description of your changes." 90 | $ git push origin name-of-your-bugfix-or-feature 91 | 92 | 7. Submit a pull request through the GitHub website. 93 | 94 | Pull Request Guidelines 95 | ----------------------- 96 | 97 | Before you submit a pull request, check that it meets these guidelines: 98 | 99 | 1. The pull request should include tests. 100 | 2. If the pull request adds functionality, the docs should be updated. Put 101 | your new functionality into a function with a docstring, and add the 102 | feature to the list in README.rst. 103 | 3. The pull request should work for Python 2.6, 2.7, and 3.3, and for PyPy. Check 104 | the GitHub actions and make sure that the tests pass for all supported Python 105 | versions. 106 | 107 | Tips 108 | ---- 109 | 110 | To run a subset of tests:: 111 | 112 | $ python -m unittest tests.test_django_docutils 113 | -------------------------------------------------------------------------------- /django_docutils/rst_post/models/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django.core import checks 4 | from django.db import models 5 | 6 | from django_docutils.favicon.tests.test_app.models import RSTPost, RSTPostSubclass 7 | from django_docutils.rst_post.models import ( 8 | RSTPostBase, 9 | RSTPostPageBase, 10 | get_post_models, 11 | ) 12 | 13 | 14 | def test_get_post_models(): 15 | models = get_post_models() 16 | assert RSTPost in models 17 | assert RSTPostSubclass not in models 18 | 19 | 20 | def test_no_root_page_field(): 21 | class NoRootPage(RSTPostBase): 22 | class Meta: 23 | app_label = "test" 24 | 25 | assert NoRootPage.check() == [ 26 | checks.Error( 27 | "Missing root_page field.", 28 | hint="Add a root_page ForeignKey a subclass of RSTPostPageBase", 29 | obj=NoRootPage, 30 | id="rst_post.E001", 31 | ) 32 | ] 33 | 34 | 35 | def test_root_page_wrong_field_type(): 36 | class WrongRootPageFieldType(RSTPostBase): 37 | root_page = models.CharField(max_length=255) 38 | 39 | class Meta: 40 | app_label = "test" 41 | 42 | assert WrongRootPageFieldType.check() == [ 43 | checks.Error( 44 | "Wrong field type for root_page field.", 45 | hint="Use a models.ForeignKey field.", 46 | obj=WrongRootPageFieldType, 47 | id="rst_post.E002", 48 | ) 49 | ] 50 | 51 | 52 | @pytest.mark.django_db 53 | def test_root_page_wrong_relation(EmptyModel): 54 | class WrongRootPageRelation(RSTPostBase): 55 | root_page = models.ForeignKey(EmptyModel, on_delete=models.CASCADE) 56 | 57 | class Meta: 58 | app_label = "test" 59 | 60 | assert WrongRootPageRelation.check() == [ 61 | checks.Error( 62 | "Wrong related model for root_page relationship.", 63 | hint="Use a model subclassing RSTPostPageBase", 64 | obj=WrongRootPageRelation, 65 | id="rst_post.E003", 66 | ) 67 | ] 68 | 69 | 70 | @pytest.mark.django_db 71 | def test_postpage_back_relation_no_field(transactional_db): 72 | class NoPageRelation(RSTPostPageBase): 73 | class Meta: 74 | app_label = "test" 75 | 76 | assert NoPageRelation.check() == [ 77 | checks.Error( 78 | "Missing post field.", 79 | hint="Add a post ForeignKey that subclasses RSTPostBase", 80 | obj=NoPageRelation, 81 | id="rst_post.E004", 82 | ) 83 | ] 84 | 85 | 86 | @pytest.mark.django_db 87 | def test_postpage_back_relation_field_type(): 88 | class WrongPageFieldType(RSTPostPageBase): 89 | post = models.CharField(max_length=255) 90 | 91 | class Meta: 92 | app_label = "test" 93 | 94 | assert WrongPageFieldType.check() == [ 95 | checks.Error( 96 | "Wrong field type for post field.", 97 | hint="Use a models.ForeignKey field.", 98 | obj=WrongPageFieldType, 99 | id="rst_post.E005", 100 | ) 101 | ] 102 | 103 | 104 | @pytest.mark.django_db 105 | def test_postpage_back_relation_relation_type(EmptyModel): 106 | class WrongPostBackRelation(RSTPostPageBase): 107 | post = models.ForeignKey(EmptyModel, on_delete=models.CASCADE) 108 | 109 | class Meta: 110 | app_label = "test" 111 | 112 | assert WrongPostBackRelation.check() == [ 113 | checks.Error( 114 | "Wrong related model for post relationship.", 115 | hint="Use a model subclassing RSTPostBase", 116 | obj=WrongPostBackRelation, 117 | id="rst_post.E006", 118 | ) 119 | ] 120 | -------------------------------------------------------------------------------- /django_docutils/lib/utils.py: -------------------------------------------------------------------------------- 1 | """Docutils util functions and regexes. 2 | 3 | Some stuff is ported from sphinx: 4 | 5 | - explicit_title_re, ws_re, set_role_source_info, split_explicit_title 6 | """ 7 | import re 8 | 9 | from docutils import nodes 10 | 11 | if False: 12 | from typing import Any, Tuple, Type, unicode # NOQA 13 | 14 | from docutils import nodes # NOQA 15 | from sphinx import Pattern 16 | 17 | # \x00 means the "<" was backslash-escaped (from sphinx) 18 | explicit_title_re = re.compile(r"^(.+?)\s*(?$", re.DOTALL) 19 | 20 | ws_re = re.compile(r"\s+") # type: Pattern 21 | 22 | 23 | def split_explicit_title(text): 24 | # type: (unicode) -> Tuple[bool, unicode, unicode] 25 | """Split role content into title and target, if given (from sphinx).""" 26 | match = explicit_title_re.match(text) # type: ignore 27 | if match: 28 | return True, match.group(1), match.group(2) 29 | return False, text, text 30 | 31 | 32 | def chop_after_docinfo(source): 33 | """Return the source of a document after DocInfo metadata. 34 | 35 | :param source: Source of RST document 36 | :type source: string 37 | :returns: All source content after docinfo 38 | :rtype: string 39 | """ 40 | # find the last docinfo element 41 | index = re.findall(r":[\w_]+: [\w \-_\,]+\n", source)[-1] 42 | 43 | # find the character position of last docinfo element + len of it 44 | rest = source[source.rindex(index) + len(index) :] 45 | return rest.strip() 46 | 47 | 48 | def chop_after_title(source): 49 | """Return the source of a document after DocInfo metadata. 50 | 51 | :param source: Source of RST document 52 | :type source: string 53 | :returns: All source content after docinfo 54 | :rtype: string 55 | """ 56 | # find the last docinfo element 57 | index = re.findall(r"[=-]{3,}\n.*\n[-=]{3,}", source, re.MULTILINE)[-1] 58 | 59 | # find the character position of last docinfo element + len of it 60 | rest = source[source.rindex(index) + len(index) :] 61 | return rest.strip() 62 | 63 | 64 | def chop_after_heading_smartly(source): 65 | """Return the content after subtitle, or, if exists, docinfo. 66 | 67 | This is a universal chop that can be used whether a document has docinfo, 68 | a title, subtitle, or not. Traditionally, directory-style RST fixtures keep 69 | metadata inside a JSON file instead of docinfo, so 70 | :func:`chop_after_docinfo` wouldn't work. 71 | 72 | :param source: Source of RST document 73 | :type source: string 74 | :returns: All source content after docinfo or title 75 | :rtype: string 76 | """ 77 | try: 78 | return chop_after_docinfo(source) 79 | except IndexError: 80 | return chop_after_title(source) 81 | 82 | 83 | def find_root_sections(document): 84 | """Yield top level section nodes 85 | 86 | :param document: docutils document 87 | :type document: :class:`docutils.nodes.document` 88 | :yields: upper level titles of document 89 | :rtype: :class:`docutils.nodes.Node` 90 | """ 91 | for node in document: 92 | if isinstance(node, nodes.section): # traverse root-level sections 93 | yield node 94 | 95 | 96 | def append_html_to_node(node, ad_code): 97 | """Inject HTML in this node 98 | 99 | :param node: node of the section to find last paragraph of 100 | :type node: :class:`docutils.nodes.node` 101 | :param ad_code: html to inject inside ad 102 | :type ad_code: string 103 | """ 104 | html = '
' 105 | html += ad_code 106 | html += "
" 107 | 108 | html_node = nodes.raw("", html, format="html") 109 | 110 | node.append(html_node) 111 | node.replace_self(node) 112 | -------------------------------------------------------------------------------- /django_docutils/favicon/tests/test_scrape.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import responses 4 | 5 | from django_docutils.exc import BasedException 6 | from django_docutils.favicon.scrape import _request_favicon, get_favicon 7 | 8 | 9 | @responses.activate 10 | def test__request_favicon_rejects_wrong_type(): 11 | url = "https://aklsdfjaweof.com" 12 | 13 | # page response 14 | responses.add(responses.GET, url, body="blah", status=200, content_type="text/html") 15 | 16 | with pytest.raises(BasedException, match=r"Not an image"): 17 | _request_favicon(url) 18 | 19 | 20 | @responses.activate 21 | def test_get_favicon_url_connection(): 22 | url = "https://aklsdfjaweof.com" 23 | 24 | with pytest.raises(BasedException, match=r"The website .* isn\'t connecting."): 25 | get_favicon(url) 26 | 27 | 28 | @responses.activate 29 | def test_get_favicon_url_catches_shortcut_icon(): 30 | url = "https://aklsdfjaweof.com" 31 | favicon_url = f"{url}/images/favicon.ico" 32 | favicon_content = b"lol" 33 | 34 | responses.add( 35 | responses.GET, 36 | url, 37 | body=''.format( 38 | favicon_url=favicon_url 39 | ), 40 | status=200, 41 | content_type="text/html", 42 | ) 43 | 44 | responses.add( 45 | responses.GET, 46 | favicon_url, 47 | body=favicon_content, 48 | status=200, 49 | content_type="image/ico", 50 | ) 51 | 52 | assert get_favicon(url) == favicon_content 53 | 54 | 55 | @responses.activate 56 | def test_get_favicon_url_falls_back_to_root_favicon_error_retrieve(): 57 | """In this case, pattern exists, but favicon URL in pattern bad. 58 | 59 | The get_faviconr should then attempt to connect to site_root/favicon.ico""" 60 | url = "https://aklsdfjaweof.com" 61 | favicon_url = f"{url}/images/favicon.ico" 62 | root_favicon_url = f"{url}/favicon.ico" 63 | favicon_content = b"lol" 64 | 65 | responses.add( 66 | responses.GET, 67 | url, 68 | body=''.format( 69 | favicon_url=favicon_url 70 | ), 71 | status=200, 72 | content_type="text/html", 73 | ) 74 | 75 | responses.add( 76 | responses.GET, 77 | root_favicon_url, 78 | body=favicon_content, 79 | status=200, 80 | content_type="image/ico", 81 | ) 82 | 83 | assert get_favicon(url) == favicon_content 84 | 85 | 86 | @responses.activate 87 | def test_get_favicon_url_falls_back_to_root_favicon_no_pattern(): 88 | """In this case, page loads, but 'shortcut icon' pattern not found. 89 | 90 | The get_faviconr should then attempt to connect to site_root/favicon.ico""" 91 | url = "https://aklsdfjaweof.com" 92 | favicon_url = f"{url}/images/favicon.ico" 93 | root_favicon_url = f"{url}/favicon.ico" 94 | favicon_content = b"lol" 95 | 96 | responses.add( 97 | responses.GET, 98 | url, 99 | body=f'', 100 | status=200, 101 | content_type="text/html", 102 | ) 103 | 104 | responses.add( 105 | responses.GET, 106 | root_favicon_url, 107 | body=favicon_content, 108 | status=200, 109 | content_type="image/ico", 110 | ) 111 | 112 | assert get_favicon(url) == favicon_content 113 | 114 | 115 | @responses.activate 116 | def test_get_favicon_raises_exception_all_strategies_fail(): 117 | """Raise BasedException if none of the favicon download methods work.""" 118 | url = "https://aklsdfjaweof.com" 119 | 120 | # page response 121 | responses.add(responses.GET, url, body="blah", status=200, content_type="text/html") 122 | 123 | with pytest.raises(BasedException, match=r"Could not retrieve favicon for .*"): 124 | assert get_favicon(url) 125 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.10"] 14 | steps: 15 | - uses: actions/checkout@v1 16 | 17 | - name: Filter changed file paths to outputs 18 | uses: dorny/paths-filter@v2.7.0 19 | id: changes 20 | with: 21 | filters: | 22 | root_docs: 23 | - CHANGES 24 | - README.* 25 | docs: 26 | - 'docs/**' 27 | - 'examples/**' 28 | python_files: 29 | - 'django_docutils/**' 30 | - pyproject.toml 31 | - poetry.lock 32 | 33 | - name: Should publish 34 | if: steps.changes.outputs.docs == 'true' || steps.changes.outputs.root_docs == 'true' || steps.changes.outputs.python_files == 'true' 35 | run: echo "PUBLISH=$(echo true)" >> $GITHUB_ENV 36 | 37 | - name: Set up Python ${{ matrix.python-version }} 38 | uses: actions/setup-python@v3 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | 42 | - name: Get full Python version 43 | id: full-python-version 44 | shell: bash 45 | run: echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") 46 | 47 | - name: Install poetry 48 | run: | 49 | curl -O -sSL https://install.python-poetry.org/install-poetry.py 50 | python install-poetry.py -y --version 1.1.12 51 | echo "PATH=${HOME}/.poetry/bin:${PATH}" >> $GITHUB_ENV 52 | rm install-poetry.py 53 | 54 | - name: Add ~/.local/bin to PATH 55 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 56 | 57 | - name: Get poetry cache paths from config 58 | run: | 59 | echo "poetry_virtualenvs_path=$(poetry config --list | sed -n 's/.*virtualenvs.path = .* # //p' | sed -e 's/^\"//' -e 's/\"$//')" >> $GITHUB_ENV 60 | echo "poetry_virtualenvs_path=$(poetry config --list | sed -n 's/.*virtualenvs.path = .* # //p' | sed -e 's/^\"//' -e 's/\"$//')" >> $GITHUB_ENV 61 | 62 | - name: Configure poetry 63 | shell: bash 64 | run: poetry config virtualenvs.in-project true 65 | 66 | - name: Set up cache 67 | uses: actions/cache@v3 68 | id: cache 69 | with: 70 | path: | 71 | .venv 72 | {{ env.poetry_cache_dir }} 73 | {{ env.poetry_virtualenvs_path }} 74 | key: venv-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }} 75 | 76 | - name: Ensure cache is healthy 77 | if: steps.cache.outputs.cache-hit == 'true' 78 | shell: bash 79 | run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv 80 | 81 | - name: Install dependencies [w/ docs] 82 | run: poetry install --extras "docs lint" 83 | 84 | - name: Build documentation 85 | run: | 86 | pushd docs; make SPHINXBUILD='poetry run sphinx-build' html; popd 87 | 88 | - name: Push documentation to S3 89 | uses: jakejarvis/s3-sync-action@v0.5.1 90 | with: 91 | args: --acl public-read --follow-symlinks --delete 92 | env: 93 | AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} 94 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 95 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 96 | AWS_REGION: "us-west-1" # optional: defaults to us-east-1 97 | SOURCE_DIR: "docs/_build/html" # optional: defaults to entire repository 98 | 99 | - name: Purge cache on Cloudflare 100 | uses: jakejarvis/cloudflare-purge-action@v0.3.0 101 | env: 102 | CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }} 103 | CLOUDFLARE_ZONE: ${{ secrets.CLOUDFLARE_ZONE }} 104 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import sys 3 | 4 | import py 5 | import pytest 6 | 7 | import factory 8 | from django.apps import apps 9 | from django.contrib.auth import get_user_model 10 | from pytest_factoryboy import register 11 | 12 | from django_docutils.favicon.tests.conftest import RSTPost, favicon_app # NOQA 13 | 14 | 15 | class UserFactory(factory.django.DjangoModelFactory): 16 | class Meta: 17 | model = get_user_model() 18 | 19 | username = factory.Sequence(lambda n: "user%03d" % n) 20 | password = factory.Sequence(lambda n: "pass%03d" % n) 21 | 22 | 23 | register(UserFactory) 24 | 25 | 26 | def create_bare_app(project_tmpdir, request, settings, app_name): 27 | """Create a blank django project for PyTest that cleans out of scope. 28 | 29 | Intended for use inside of PyTest Fixtures. 30 | 31 | :param project_tmpdir: root of the django project 32 | 33 | This cleans up automatically after the fixture falls out of scope. 34 | :type project_tmpdir: :class:`py._path.local.LocalPath` 35 | :param request: PyTest request object (dependency injected) 36 | :type request: :class:`pytest.fixtures.FixtureRequest` 37 | :param settings: django settings fixture (from pytest-django) 38 | 39 | From pytest-django docs: 40 | 41 | "This fixture will provide a handle on the Django settings module, and 42 | automatically revert any changes made to the settings (modifications, 43 | additions and deletions). 44 | :type settings: :class:`pytest_django.fixtures.settings` 45 | :param app_name: application name 46 | :type app_name: str 47 | :rtype: :class:`django.apps.apps.AppConfig` 48 | :returns: a bare app config with temporary file structure created 49 | """ 50 | 51 | bare_app = project_tmpdir.mkdir(app_name) 52 | 53 | bare_app.join("__init__.py").write("") 54 | 55 | if project_tmpdir.strpath not in sys.path: 56 | sys.path.append(project_tmpdir.strpath) 57 | settings.INSTALLED_APPS = settings.INSTALLED_APPS + (app_name,) 58 | 59 | def resource_a_teardown(): 60 | print("\nresources_a_teardown()") 61 | # todo replace 'bare_app' with app_name 62 | settings.INSTALLED_APPS = ( 63 | s for s in settings.INSTALLED_APPS if s != "bare_app" 64 | ) 65 | 66 | assert app_name not in settings.INSTALLED_APPS 67 | shutil.rmtree(str(bare_app)) 68 | 69 | request.addfinalizer(resource_a_teardown) 70 | 71 | return apps.get_app_config(app_name) 72 | 73 | 74 | @pytest.fixture(scope="function") 75 | def bare_app_config(tmpdir_factory, request, settings): 76 | """Return a Django AppConfig for a blank project. 77 | 78 | It will automatically remove from INSTALLED_APPS and clean created 79 | files on teardown. See :func:`create_bare_app`. 80 | 81 | :rtype: :class:`django.apps.apps.AppConfig` 82 | :returns: a bare app config with temporary file structure created 83 | """ 84 | 85 | tmpdir = tmpdir_factory.mktemp("bare_project") 86 | 87 | return create_bare_app(tmpdir, request, settings, "bare_app") 88 | 89 | 90 | @pytest.fixture(scope="function") 91 | def sample_app_config(tmpdir_factory, request, settings): 92 | tmpdir = tmpdir_factory.mktemp("sample_project") 93 | 94 | sample_app = create_bare_app(tmpdir, request, settings, "sample_app") 95 | 96 | sample_app_dir = py.path.local(sample_app.path) 97 | 98 | # give it a fixtures dir 99 | fixtures_dir = sample_app_dir.ensure("fixtures", dir=True) 100 | 101 | fixtures_dir.join("hi.rst").write( 102 | """ 103 | === 104 | moo 105 | === 106 | 107 | :Author: anonymous 108 | :Slug_Id: tEst 109 | 110 | foo 111 | """.strip() 112 | ) 113 | fixtures_dir.ensure("2017", dir=True) 114 | fixtures_dir.join("2017").ensure("06", dir=True) 115 | fixtures_dir.join("2017").join("06").ensure("04", dir=True) 116 | fixtures_dir.join("2017").join("06").join("04").join("moo.rst").write("h") 117 | 118 | return sample_app 119 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/directory/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import py 2 | import pytest 3 | 4 | from django_docutils.lib.fixtures.tests.conftest import create_bare_app 5 | 6 | 7 | @pytest.fixture() 8 | def sample_dir_app_config(tmpdir_factory, request, settings): 9 | """Return a Django AppConfig for a project with a directory-style 10 | fixture inside of it. 11 | 12 | It will automatically remove from INSTALLED_APPS and clean created 13 | files on teardown. See :func:`create_bare_app`. 14 | 15 | :rtype: :class:`django.apps.apps.AppConfig` 16 | :returns: a bare app config with temporary file structure created 17 | """ 18 | conf = """{ 19 | "Programming_languages": ["python"], 20 | "Topics": ["Web Frameworks"], 21 | "Slug_Id": "Te5t17fr", 22 | "Created": "2017-09-12", 23 | "Author": "tony" 24 | } 25 | """.strip() 26 | 27 | content = """============================================= 28 | Learn JavaScript for free: The best resources 29 | ============================================= 30 | 31 | first section 32 | ------------- 33 | 34 | some content 35 | """.strip() 36 | 37 | tmpdir = tmpdir_factory.mktemp("sample_dir_project") 38 | 39 | sample_app = create_bare_app( 40 | project_tmpdir=tmpdir, 41 | request=request, 42 | settings=settings, 43 | app_name="sample_dir_app", 44 | ) 45 | 46 | sample_app_dir = py.path.local(sample_app.path) 47 | 48 | sample_app_dir.join("__init__.py").write("") 49 | 50 | # give it a fixtures dir 51 | fixtures_dir = sample_app_dir.ensure("fixtures", dir=True) 52 | fixtures_dir.join("hi.rst").write("") 53 | fixtures_dir.ensure("sample_project", dir=True) 54 | fixtures_dir.join("sample_project").join("manifest.json").write(conf) 55 | fixtures_dir.join("sample_project").join("README.rst").write(content) 56 | 57 | return sample_app 58 | 59 | 60 | @pytest.fixture() 61 | def sample_dir_series_app_config(tmpdir_factory, request, settings): 62 | """Returns a "multi-file" directory-style fixture. 63 | 64 | :rtype: :class:`django.apps.apps.AppConfig` 65 | :returns: a bare app config with temporary file structure created 66 | """ 67 | 68 | conf = """{ 69 | "Programming_languages": ["python"], 70 | "Topics": ["Web Frameworks"], 71 | "Slug_Id": "Te3y28vm", 72 | "Created": "2017-10-20", 73 | "Author": "tony", 74 | "Series": [ 75 | "README.rst", 76 | "page2.rst", 77 | "page3.rst" 78 | ] 79 | } 80 | """.strip() 81 | 82 | content = """============= 83 | A test series 84 | ============= 85 | hi there 86 | -------- 87 | 88 | first section 89 | ------------- 90 | 91 | some content 92 | """.strip() 93 | 94 | content2 = """============= 95 | A test series 96 | ============= 97 | page 2 98 | ------ 99 | 100 | first section of page 2 101 | ----------------------- 102 | 103 | some content for page 2 104 | """.strip() 105 | 106 | content3 = """============= 107 | A test series 108 | ============= 109 | page 3 110 | ------ 111 | 112 | first section of page 3 113 | ----------------------- 114 | 115 | some content for page 3 116 | """.strip() 117 | 118 | BASE_FOLDER = "sample_dir_series_project1" 119 | 120 | tmpdir = tmpdir_factory.mktemp(BASE_FOLDER) 121 | 122 | sample_app = create_bare_app(tmpdir, request, settings, "sample_dir_series_app") 123 | 124 | sample_app_dir = py.path.local(sample_app.path) 125 | 126 | sample_app_dir.join("__init__.py").write("") 127 | 128 | # give it a fixtures dir 129 | fixtures_dir = sample_app_dir.ensure("fixtures", dir=True) 130 | 131 | # the app's fixtures/ dir 132 | fixtures_dir.join("hi.rst").write("") 133 | fixtures_dir.mkdir(BASE_FOLDER) 134 | 135 | # The RST project inside the app's fixtures dir 136 | project_dir = fixtures_dir.join(BASE_FOLDER) 137 | project_dir.join("manifest.json").write(conf) 138 | project_dir.join("README.rst").write(content) 139 | project_dir.join("page2.rst").write(content2) 140 | project_dir.join("page3.rst").write(content3) 141 | 142 | return sample_app 143 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/common.py: -------------------------------------------------------------------------------- 1 | from docutils import nodes, utils 2 | 3 | from ..utils import split_explicit_title 4 | 5 | 6 | def generic_url_role(name, text, url_handler_fn, innernodeclass=nodes.Text): 7 | """This cleans up a lot of code we had to repeat over and over. 8 | 9 | This generic role also handles explicit titles (:role:`yata yata `) 10 | 11 | This breaks convention a feels a bit jenky at first. It uses a callback 12 | because figuring out the url is the only magic that happens, but its 13 | sandwiched in the middle. 14 | 15 | :param name: name of the role, e.g. 'github' 16 | :type name: string 17 | :param text: text inside of the role, e.g: 18 | - 'airline-mode/airline-mode' 19 | - 'this repo ' 20 | :type text: string 21 | :param url_handler_fn: a function that accepts the target param, example: 22 | :returntype url_handler_fn: string 23 | :returns: tuple ([node], []) 24 | :returntype: tuple 25 | 26 | Simple example, let's create a role:: 27 | 28 | .. code-block:: python 29 | 30 | def github_role( 31 | name, rawtext, text, lineno, inliner, options={}, content=[] 32 | ): 33 | def url_handler(target): 34 | return 'https://github.com/{}'.format(target) 35 | 36 | return generic_url_role(name, text, url_handler) 37 | 38 | roles.register_local_role('gh', github_role) 39 | """ 40 | name = name.lower() 41 | has_explicit_title, title, target = split_explicit_title(text) 42 | title = utils.unescape(title) 43 | target = utils.unescape(target) 44 | 45 | if not has_explicit_title: 46 | title = utils.unescape(title) 47 | else: 48 | if "**" == title[:2] and "**" == title[-2:]: 49 | innernodeclass = nodes.strong 50 | title = title.strip("**") 51 | elif "*" == title[0] and "*" == title[-1]: 52 | innernodeclass = nodes.emphasis 53 | title = title.strip("*") 54 | 55 | url = url_handler_fn(target) 56 | 57 | sn = innernodeclass(title, title) 58 | rn = nodes.reference("", "", internal=True, refuri=url, classes=[name]) 59 | rn += sn 60 | return [rn], [] 61 | 62 | 63 | def generic_remote_url_role(name, text, url_handler_fn, innernodeclass=nodes.Text): 64 | """This is a generic_url_role that can return a url AND a title remotely 65 | 66 | The url_handler_fn returns a title and a url 67 | 68 | In cases like Amazon API, database lookups, and other stuff, information 69 | may be looked up by key, and we may get a fresh title to fill in if nothing 70 | else explicit is mentioned. 71 | 72 | :param name: name of the role, e.g. 'github' 73 | :type name: string 74 | :param text: text inside of the role, e.g: 75 | - 'airline-mode/airline-mode' 76 | - 'this repo ' 77 | :type text: string 78 | :param url_handler_fn: a function that accepts the target param, example: 79 | :returntype url_handler_fn: (string, string) 80 | :returns: tuple ([node], []) 81 | :returntype: tuple 82 | 83 | Simple example, let's create a role:: 84 | 85 | .. code-block:: python 86 | 87 | def amzn_role( 88 | name, rawtext, text, lineno, inliner, options={}, content=[] 89 | ): 90 | def url_handler(target): 91 | query = amzn.lookup(ItemId=target) 92 | return query.title, query.offer_url 93 | 94 | return generic_remote_url_role(name, text, url_handler) 95 | 96 | roles.register_local_role('amzn', amzn_role) 97 | """ 98 | name = name.lower() 99 | has_explicit_title, title, target = split_explicit_title(text) 100 | title = utils.unescape(title) 101 | target = utils.unescape(target) 102 | 103 | remote_title, url = url_handler_fn(target) 104 | if not has_explicit_title: 105 | title = utils.unescape(remote_title) 106 | 107 | sn = innernodeclass(title, title) 108 | rn = nodes.reference("", "", internal=True, refuri=url, classes=[name]) 109 | rn += sn 110 | return [rn], [] 111 | -------------------------------------------------------------------------------- /django_docutils/references/models.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps as django_apps 2 | from django.conf import settings 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.db import models 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | 8 | def get_reference_model(): 9 | try: 10 | return django_apps.get_model( 11 | settings.BASED_REFERENCE_MODEL, require_ready=False 12 | ) 13 | except ValueError: 14 | raise ImproperlyConfigured( 15 | "BASED_REFERENCE_MODEL must be of the form 'app_label.model_name'" 16 | ) 17 | except LookupError: 18 | raise ImproperlyConfigured( 19 | "BASED_REFERENCE_MODEL refers to model '%s' that has not been installed" 20 | % settings.BASED_REFERENCE_MODEL 21 | ) 22 | 23 | 24 | class ReferenceBase(models.Model): 25 | 26 | r"""Reference for targets, including the intersphinx network. 27 | 28 | :py:mod:`py3k:module2` 29 | | | | 30 | | | ---- target 31 | | -- type | 32 | |-- domain py3k:module2 33 | | | 34 | setname --| |-- settarget 35 | 36 | An intersphinx collection can link to a "set", or a remote manifest of 37 | many references, such as in Python, Sphinx, or SQLAlchemy's documentation. 38 | 39 | While vanilla docutils only has targets for its local documents, sphinx 40 | creates a manifest of targets project-wide. It collects these in an 41 | "inventory", which we namespace through : in the "target", e.g. 42 | "py3k:collections", where py3k is the namespace INTERSPHINX_MAPPING pointed 43 | to python 3's documentation on the internet. e.g.: 44 | 45 | intersphinx_mapping = { 46 | 'https://docs.python.org/': inv_file, 47 | 'py3k': ('https://docs.python.org/py3k/', inv_file), 48 | } 49 | 50 | See how there's also an item with setname (docs.python.org, the first 51 | entry). Sphinx allows this, so items which don't show a setname for the 52 | target (e.g. "collections", without the "py3k:" namespace) will refer to 53 | targets in https://docs.python.org instead of https://docs.python.org/py3k/ 54 | 55 | Note: intersphinx represents the nameless inventory to be the "main 56 | inventory". And the others as "name inventories". 57 | 58 | Also of note is the domain and type, e.g. (:py:mod:). By default, sphinx 59 | uses the python domain, so you could actually do :mod: without the :py:. 60 | e.g. \:mod\:`collections` instead of \:py\:mod\:`collections`. 61 | 62 | However, it's still important to store and collect the domain and type, 63 | since we'll want to reference multiple programming languages. In addition, 64 | despite the practice of intersphinx_mapping to have a default set with no 65 | set name, and a default fallback domain, it may be more correct in the long 66 | term to enforce explicit domains and sets. 67 | """ 68 | 69 | domain = models.CharField( 70 | _('Sphinx domain of the reference. e.g. "py" or "std"'), max_length=255 71 | ) 72 | type = models.CharField( 73 | _('Type of object being linked to, e.g. "mod", "func", "option"'), 74 | max_length=255, 75 | ) 76 | project = models.CharField( 77 | _("Project, e.g. py3k, python, sqlalchemy"), max_length=255 78 | ) 79 | project_version = models.CharField( 80 | _("Version of project documentation"), max_length=255 81 | ) 82 | target = models.CharField(_("Docutils/Sphinx target"), max_length=255) 83 | uri = models.URLField(_("Link to reference"), max_length=255) 84 | display_name = models.CharField( 85 | _("Optional name for the reference item"), max_length=255, null=True 86 | ) 87 | 88 | @property 89 | def full_target(self): 90 | return f"{self.project}:{self.target}" 91 | 92 | @property 93 | def full_reference(self): 94 | if self.domain: 95 | return ":{}:{}:`{}:{}`".format( 96 | self.domain, self.type, self.project, self.full_target 97 | ) 98 | else: 99 | return f":{self.type}:`{self.target}`" 100 | 101 | class Meta: 102 | unique_together = ("project", "target", "type") 103 | abstract = True 104 | 105 | def __str__(self): 106 | return self.full_reference 107 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa E501 3 | import os 4 | import sys 5 | from pathlib import Path 6 | 7 | # Get the project root dir, which is the parent dir of this 8 | cwd = Path.cwd() 9 | project_root = cwd.parent 10 | 11 | sys.path.insert(0, str(project_root)) 12 | sys.path.insert(0, str(cwd / "_ext")) 13 | 14 | 15 | # package data 16 | about = {} 17 | with open("../django_docutils/__about__.py") as fp: 18 | exec(fp.read(), about) 19 | 20 | extensions = [ 21 | "sphinx.ext.autodoc", 22 | "sphinx.ext.intersphinx", 23 | "sphinx.ext.napoleon", 24 | "sphinx_autodoc_typehints", 25 | "sphinx_issues", 26 | "sphinx_click.ext", # sphinx-click 27 | "sphinx_inline_tabs", 28 | "sphinx_copybutton", 29 | "sphinxext.opengraph", 30 | "sphinxext.rediraffe", 31 | "myst_parser", 32 | ] 33 | myst_enable_extensions = ["colon_fence", "substitution", "replacements"] 34 | 35 | issues_github_path = about["__github__"].replace("https://github.com/", "") 36 | 37 | templates_path = ["_templates"] 38 | 39 | source_suffix = {".rst": "restructuredtext", ".md": "markdown"} 40 | 41 | master_doc = "index" 42 | 43 | project = about["__title__"] 44 | copyright = about["__copyright__"] 45 | 46 | version = "%s" % (".".join(about["__version__"].split("."))[:2]) 47 | release = "%s" % (about["__version__"]) 48 | 49 | exclude_patterns = ["_build"] 50 | 51 | pygments_style = "monokai" 52 | pygments_dark_style = "monokai" 53 | 54 | html_favicon = "_static/favicon.ico" 55 | html_static_path = ["_static"] 56 | html_css_files = ["css/custom.css"] 57 | html_extra_path = ["manifest.json"] 58 | html_theme = "furo" 59 | html_theme_options = { 60 | "light_logo": "img/icons/logo.svg", 61 | "dark_logo": "img/icons/logo.svg", 62 | "footer_icons": [ 63 | { 64 | "name": "GitHub", 65 | "url": about["__github__"], 66 | "html": """ 67 | 68 | 69 | 70 | """, 71 | "class": "", 72 | }, 73 | ], 74 | } 75 | 76 | html_sidebars = { 77 | "**": [ 78 | "sidebar/scroll-start.html", 79 | "sidebar/brand.html", 80 | "sidebar/search.html", 81 | "sidebar/navigation.html", 82 | "sidebar/projects.html", 83 | "sidebar/scroll-end.html", 84 | ] 85 | } 86 | 87 | # sphinxext.opengraph 88 | ogp_site_url = about["__docs__"] 89 | ogp_image = "_static/img/icons/icon-192x192.png" 90 | ogp_desscription_length = about["__description__"] 91 | ogp_site_name = about["__title__"] 92 | 93 | # sphinx-copybutton 94 | copybutton_prompt_text = ( 95 | r">>> |\.\.\. |> |\$ |\# | In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " 96 | ) 97 | copybutton_prompt_is_regexp = True 98 | copybutton_remove_prompts = True 99 | 100 | # sphinxext-rediraffe 101 | rediraffe_redirects = "redirects.txt" 102 | rediraffe_branch = "master~1" 103 | 104 | 105 | htmlhelp_basename = "%sdoc" % about["__title__"] 106 | 107 | latex_documents = [ 108 | ( 109 | "index", 110 | "{0}.tex".format(about["__package_name__"]), 111 | "{0} Documentation".format(about["__title__"]), 112 | about["__author__"], 113 | "manual", 114 | ), 115 | ] 116 | 117 | man_pages = [ 118 | ( 119 | "index", 120 | about["__package_name__"], 121 | "{0} Documentation".format(about["__title__"]), 122 | about["__author__"], 123 | 1, 124 | ), 125 | ] 126 | 127 | texinfo_documents = [ 128 | ( 129 | "index", 130 | "{0}".format(about["__package_name__"]), 131 | "{0} Documentation".format(about["__title__"]), 132 | about["__author__"], 133 | about["__package_name__"], 134 | about["__description__"], 135 | "Miscellaneous", 136 | ), 137 | ] 138 | 139 | intersphinx_mapping = { 140 | "python": ("http://docs.python.org/", None), 141 | } 142 | -------------------------------------------------------------------------------- /django_docutils/favicon/prefetch.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import tldextract 4 | from django.core.files.uploadedfile import SimpleUploadedFile 5 | from tqdm import tqdm, trange 6 | 7 | from django_docutils.favicon.models import get_favicon_model 8 | from django_docutils.favicon.rst.transforms.favicon import plain_references 9 | from django_docutils.favicon.scrape import get_favicon 10 | 11 | logger = logging.getLogger(__name__) 12 | Favicon = get_favicon_model() 13 | 14 | 15 | def yield_references(document, url_pattern=None): 16 | """Yield site pages in a docutils document format 17 | 18 | :param document: 19 | :type document: :class:`docutils.nodes.document` 20 | :rtype: string 21 | :yields: Document of pages in side 22 | """ 23 | nodes = document.traverse(plain_references) 24 | for node in nodes: 25 | if url_pattern: # if --pattern entered 26 | if url_pattern not in node["refuri"]: 27 | continue 28 | 29 | yield node["refuri"] 30 | 31 | 32 | def prefetch_favicons(url_pattern=None, PostPage=None): 33 | # PostPage=get_postpage_models()[0] 34 | urls = [] 35 | t = trange(PostPage.objects.count()) 36 | 37 | # iterate through all page documents on the sites 38 | for page_document in yield_page_doctrees(PostPage): 39 | # iterate through all references inside the document 40 | urls.extend(yield_references(page_document, url_pattern)) 41 | t.update(1) 42 | 43 | t = tqdm(urls) 44 | for url in t: 45 | prefetch_favicon(url, progress=t) 46 | 47 | 48 | def is_favicon_stored(fqdn): 49 | """Assure the favicon *and its file* exist in storage 50 | 51 | This is split up for testing critical logic. There is a race condition 52 | where the object exists in the database, but the file in not in storage. 53 | 54 | prefetch_favicon must be resilient enough to download the image if its 55 | not existing in storage. 56 | """ 57 | # don't redown if fqdn favicon already exists 58 | try: 59 | favicon = Favicon.objects.get(domain=fqdn) 60 | 61 | # check for the file itself 62 | try: 63 | if favicon.favicon.file: 64 | logger.debug( 65 | "{} for {} already exists, skipping".format( 66 | favicon.favicon.file, fqdn 67 | ) 68 | ) 69 | return True # Favicon for fqdn + file already exists 70 | except FileNotFoundError: 71 | pass # that's fine, we'll download it again! 72 | except Favicon.DoesNotExist: 73 | pass # that's fine! we'll add it 74 | 75 | return False 76 | 77 | 78 | def prefetch_favicon(url, progress=None): 79 | """Download a store a favicon for a site, if it exists. 80 | 81 | Intended for use when running in situ transforms on :class:` 82 | docutils.nodes.reference` traversals of URL's. This will look up the 83 | fqdn (fully qualified domain name) for a URL, check against the Favicon 84 | models for any results, if they don't exist, it'll try to scrape the 85 | favicon from the fqdn, store it, and store the path in a Favicon entry for 86 | that fqdn. 87 | 88 | :param url: URL to any page on a website 89 | :type url: str 90 | :rtype: (model, created date)|None 91 | """ 92 | fqdn = tldextract.extract(url).fqdn 93 | 94 | # optional tqdm progress bar pass-in 95 | if progress: 96 | progress.set_description(f"Downloading favicon {fqdn}") 97 | 98 | if is_favicon_stored(fqdn): # don't redownload 99 | return 100 | 101 | try: 102 | favicon_content = get_favicon(url) 103 | except Exception as e: 104 | logger.debug(f"Error occurred fetch icon for {fqdn}: {e}") 105 | return 106 | 107 | return Favicon.objects.update_or_create( 108 | domain=fqdn, 109 | defaults={ 110 | "domain": fqdn, 111 | "favicon": SimpleUploadedFile( 112 | name=f"{fqdn}.ico", 113 | content=favicon_content, 114 | content_type="image/ico", 115 | ), 116 | }, 117 | ) 118 | 119 | 120 | def yield_page_doctrees(PostPage): 121 | """Yield site pages in a docutils document format 122 | 123 | :param PostPage: Any model implementing PostPageBase 124 | :type: :class:`django_docutils.rst_post.models.RSTPostPageBase` 125 | :yields: Document of pages in side 126 | :rtype: :class:`docutils.nodes.document` 127 | """ 128 | for page in PostPage.objects.all(): 129 | yield page.document 130 | -------------------------------------------------------------------------------- /django_docutils/lib/fixtures/tests/test_load.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | import pytz 6 | from django.conf import settings 7 | 8 | from django_docutils.lib.fixtures.load import load_app_rst_fixtures, load_post_data 9 | from django_docutils.lib.fixtures.publisher import publish_post 10 | 11 | 12 | @pytest.mark.django_db(transaction=True) 13 | def test_load_post_explicitness_persists(bare_app_config, RSTPost): 14 | sample_page_body = """ 15 | ============== 16 | Document title 17 | ============== 18 | ----------------- 19 | Document subtitle 20 | ----------------- 21 | 22 | :Slug_Id: tEst 23 | :Author: anonymous 24 | 25 | Content 26 | ------- 27 | 28 | hi 29 | """.strip() 30 | 31 | post_data = publish_post(sample_page_body) 32 | 33 | # assert default behavior 34 | post = load_post_data(RSTPost, post_data) 35 | 36 | assert post.title == "Document title" 37 | assert post.pages.first().subtitle == "Document subtitle" 38 | assert post.slug_id == "tEst" 39 | 40 | 41 | @pytest.mark.django_db(transaction=True) 42 | def test_correct_date_on_initial_and_reimport(RSTPost): 43 | # check tests 44 | sample_page_body = """ 45 | ============== 46 | Document title 47 | ============== 48 | ----------------- 49 | Document subtitle 50 | ----------------- 51 | 52 | :Slug_Id: tEst2 53 | :Author: anonymous 54 | :created: 2017-01-25 55 | 56 | Content 57 | ------- 58 | 59 | hi 60 | """.strip() 61 | 62 | post_data = publish_post(sample_page_body) 63 | 64 | assert post_data["created"].year == 2017 65 | assert post_data["created"].month == 1 66 | assert post_data["created"].day == 25 67 | 68 | # assert default behavior 69 | post = load_post_data(RSTPost, post_data) 70 | 71 | assert post.created.year == 2017 72 | assert post.created.month == 1 73 | assert post.created.day == 25 74 | 75 | # simulate re-import 76 | post = load_post_data(RSTPost, post_data) 77 | 78 | assert post.created.year == 2017 79 | assert post.created.month == 1 80 | assert post.created.day == 25 81 | 82 | now = datetime.datetime.now(pytz.timezone("UTC")) 83 | 84 | assert post.modified.year == now.year 85 | assert post.modified.month == now.month 86 | assert post.modified.day == now.day 87 | 88 | 89 | @pytest.mark.django_db(transaction=True) 90 | def test_correct_slug_title_on_initial_and_reimport(RSTPost): 91 | slug_title = "my_brief_url" 92 | sample_page_body = """ 93 | ============== 94 | Document title 95 | ============== 96 | ----------------- 97 | Document subtitle 98 | ----------------- 99 | 100 | :Slug_Id: tEst2 101 | :Slug_title: {slug_title} 102 | :Author: anonymous 103 | :created: 2017-01-25 104 | 105 | Content 106 | ------- 107 | 108 | hi 109 | """.format( 110 | slug_title=slug_title 111 | ).strip() 112 | 113 | post_data = publish_post(sample_page_body) 114 | 115 | assert post_data["slug_title"] == slug_title 116 | 117 | # assert default behavior 118 | post = load_post_data(RSTPost, post_data) 119 | 120 | assert post.slug_title == slug_title 121 | 122 | # simulate re-import 123 | post = load_post_data(RSTPost, post_data) 124 | 125 | assert post.slug_title == slug_title 126 | 127 | 128 | @pytest.mark.django_db(transaction=True) 129 | def test_slug_title_updates_on_reimport(RSTPost): 130 | sample_page_body = """ 131 | ============== 132 | Document title 133 | ============== 134 | ----------------- 135 | Document subtitle 136 | ----------------- 137 | 138 | :Slug_Id: tEst2 139 | :Author: anonymous 140 | :created: 2017-01-25 141 | 142 | Content 143 | ------- 144 | 145 | hi 146 | """.strip() 147 | from django_slugify_processor.text import slugify 148 | 149 | post_data = publish_post(sample_page_body) 150 | 151 | # assert default behavior 152 | post = load_post_data(RSTPost, post_data) 153 | assert post.slug_title == slugify(post_data["title"]) 154 | 155 | # simulate re-import with updated title 156 | new_title = "a new title yay" 157 | new_slug_title = slugify(new_title) 158 | post_data["title"] = new_title 159 | post = load_post_data(RSTPost, post_data) 160 | 161 | assert post.slug_title == new_slug_title 162 | 163 | 164 | @pytest.mark.django_db(transaction=True) 165 | def test_load_app_rst_fixtures_minimal(sample_app_config, RSTPost): 166 | load_app_rst_fixtures(sample_app_config, model=RSTPost) 167 | 168 | 169 | @pytest.mark.django_db(transaction=True) 170 | def test_load_post_data_minimal(RSTPost): 171 | pages = [{"body": "moo", "subtitle": "my subtitle"}] 172 | 173 | post = load_post_data( 174 | RSTPost, 175 | {"author": settings.ANONYMOUS_USER_NAME, "slug_id": "aFeuM8e", "pages": pages}, 176 | ) 177 | 178 | assert isinstance(post, RSTPost) 179 | -------------------------------------------------------------------------------- /django_docutils/lib/transforms/code.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from docutils import nodes 4 | from docutils.transforms import Transform 5 | from pygments import highlight 6 | from pygments.formatters.html import HtmlFormatter 7 | from pygments.token import Token 8 | 9 | 10 | class InlineHtmlFormatter(HtmlFormatter): 11 | def format_unencoded(self, tokensource, outfile): 12 | """ 13 | 14 | First problem (filter trailing newline): 15 | 16 | There is an issue where the final token generated returns a 17 | (Token.Other, '\n') which results in a blank space 18 | 19 | 20 | This would otherwise be unnoticeable if it was a code block, 21 | but since we're inline, it looks strange. Let's filter out 22 | any trailing newlines from token source, then fallback to 23 | the normal process by passing it back into parent class method. 24 | 25 | Now, AFTER this method, you're not out of the woods yet: 26 | _format_lines will still add a \n (which renders as a space again 27 | in the browser). For that, pass lineseparator='' into the 28 | InlineHtmlFormatter class to supress that. 29 | 30 | """ 31 | 32 | def filter_trailing_newline(source): 33 | tokens = list(source) 34 | 35 | # filter out the trailing newline token 36 | if tokens[-1] == (Token.Text, "\n"): 37 | del tokens[-1] 38 | 39 | return ((t, v) for t, v in tokens) 40 | 41 | source = filter_trailing_newline(tokensource) 42 | 43 | return super().format_unencoded(source, outfile) 44 | 45 | def _wrap_div(self, inner): 46 | style = [] 47 | if ( 48 | self.noclasses 49 | and not self.nobackground 50 | and self.style.background_color is not None 51 | ): 52 | style.append(f"background: {self.style.background_color}") 53 | if self.cssstyles: 54 | style.append(self.cssstyles) 55 | style = "; ".join(style) 56 | 57 | yield 0, ( 58 | "" 62 | ) 63 | yield from inner 64 | yield 0, "
\n" 65 | 66 | def _wrap_pre(self, inner): 67 | yield from inner 68 | 69 | 70 | formatter = InlineHtmlFormatter( 71 | cssclass="highlight docutils literal inline-code", 72 | noclasses=False, 73 | lineseparator="", # removes \n from end of inline snippet 74 | ) 75 | 76 | 77 | class CodeTransform(Transform): 78 | 79 | """Run over unparsed literals and try to guess language + highlight.""" 80 | 81 | default_priority = 120 82 | 83 | def apply(self): 84 | paragraph_nodes = self.document.traverse(nodes.literal) 85 | 86 | for node in paragraph_nodes: 87 | text = node.astext() 88 | 89 | newnode = None 90 | newtext = None 91 | newlexer = None 92 | 93 | if text.startswith("$ "): 94 | from django_docutils.lib.directives.code import BashSessionLexer 95 | 96 | newlexer = BashSessionLexer() 97 | elif text.startswith("{%") or text.startswith("{{"): 98 | from pygments.lexers.templates import DjangoLexer 99 | 100 | newlexer = DjangoLexer() 101 | elif re.match(r"^:\w+:", text): # match :rolename: beginning 102 | from pygments.lexers.markup import RstLexer 103 | 104 | newlexer = RstLexer() 105 | else: 106 | from pygments.lexers import guess_lexer 107 | from pygments.lexers.mime import MIMELexer 108 | from pygments.lexers.special import TextLexer 109 | 110 | guess = guess_lexer(text) 111 | if not any(guess.__class__ != lex for lex in [MIMELexer, TextLexer]): 112 | newlexer = guess 113 | 114 | if newlexer: 115 | """Inline code can't have newlines, but we still get them: 116 | 117 | Take this reStructuredText code: 118 | 119 | .. code-block:: reStructuredText 120 | 121 | You can set options with ``$ tmux set-option`` and ``$ tmux 122 | set-window-option``. 123 | 124 | Docutils detects the separation between "tmux" and 125 | "set-window-option" in ``$ tmux set-window-options``, now as a 126 | space, but a *new line*. 127 | 128 | Let's replace the newline escape (``\n``) with a space. 129 | """ 130 | text = text.strip() # trim any whitespace around text 131 | text = text.replace("\n", " ") # switch out newlines w/ space 132 | 133 | newtext = highlight(text, newlexer, formatter) 134 | 135 | if newtext: 136 | newnode = nodes.raw("", newtext, format="html") 137 | 138 | if newnode and node.parent: 139 | node.replace_self(newnode) 140 | -------------------------------------------------------------------------------- /django_docutils/favicon/tests/test_prefetch.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import responses 4 | from django.core.files.uploadedfile import SimpleUploadedFile 5 | from docutils.nodes import document 6 | from inmemorystorage.storage import InMemoryFile 7 | 8 | from django_docutils.favicon.prefetch import ( 9 | is_favicon_stored, 10 | prefetch_favicon, 11 | yield_page_doctrees, 12 | yield_references, 13 | ) 14 | from django_docutils.lib.publisher import publish_doctree 15 | 16 | TEST_RST_DOCUMENT = """ 17 | Developing 18 | ========== 19 | 20 | - Make a hobby website in django or flask. 21 | 22 | Services like `Heroku`_ are free to try, and simple to deploy Django 23 | websites to. 24 | 25 | - For free editors, check out good old `vim`_, `Visual Studio Code`_, 26 | `Atom`_, or `PyCharm`_ 27 | 28 | .. _Visual Studio Code: https://code.visualstudio.com/ 29 | .. _Atom: https://atom.io/ 30 | .. _vim: http://vim.org 31 | .. _PyCharm: https://www.jetbrains.com/pycharm/ 32 | """ 33 | 34 | 35 | def test_yield_references(): 36 | document = publish_doctree(TEST_RST_DOCUMENT) 37 | assert set(yield_references(document)) == { 38 | "https://code.visualstudio.com/", 39 | "https://atom.io/", 40 | "http://vim.org", 41 | "https://www.jetbrains.com/pycharm/", 42 | } 43 | 44 | 45 | def test_yield_references_patterns(): 46 | document = publish_doctree(TEST_RST_DOCUMENT) 47 | assert set(yield_references(document, url_pattern="atom")) == {"https://atom.io/"} 48 | 49 | 50 | @pytest.mark.django_db(transaction=True) 51 | def test_yield_page_doctrees(RSTPostPage): 52 | RSTPostPage.objects.create(subtitle="lol", body=TEST_RST_DOCUMENT) 53 | assert RSTPostPage.objects.filter(subtitle="lol").count() 54 | 55 | page_doctrees = list(yield_page_doctrees(RSTPostPage)) 56 | assert len(page_doctrees) 57 | 58 | for page in page_doctrees: 59 | assert isinstance(page, document) 60 | 61 | 62 | @pytest.mark.django_db(transaction=True) 63 | @responses.activate 64 | def test_prefetch_favicon_working(): 65 | url = "http://vim.org" 66 | favicon_url = f"{url}/images/favicon.ico" 67 | favicon_content = b"lol" 68 | 69 | responses.add( 70 | responses.GET, 71 | url, 72 | body=''.format( 73 | favicon_url=favicon_url 74 | ), 75 | status=200, 76 | content_type="text/html", 77 | ) 78 | 79 | responses.add( 80 | responses.GET, 81 | favicon_url, 82 | body=favicon_content, 83 | status=200, 84 | content_type="image/ico", 85 | ) 86 | 87 | favicon, created = prefetch_favicon(url) 88 | 89 | assert favicon.favicon.read() == favicon_content 90 | 91 | 92 | @pytest.mark.django_db(transaction=True) 93 | @responses.activate 94 | def test_prefetch_favicon_file_missing(monkeypatch): 95 | # case where the favicon is in ORM, but file not in storage 96 | url = "http://vim.org" 97 | favicon_url = f"{url}/images/favicon.ico" 98 | favicon_content = b"lol" 99 | 100 | responses.add( 101 | responses.GET, 102 | url, 103 | body=''.format( 104 | favicon_url=favicon_url 105 | ), 106 | status=200, 107 | content_type="text/html", 108 | ) 109 | 110 | responses.add( 111 | responses.GET, 112 | favicon_url, 113 | body=favicon_content, 114 | status=200, 115 | content_type="image/ico", 116 | ) 117 | 118 | def mock_file(): 119 | raise FileNotFoundError 120 | 121 | favicon, created = prefetch_favicon(url) 122 | 123 | import django_docutils.favicon.prefetch 124 | 125 | assert not prefetch_favicon(url) 126 | monkeypatch.setattr( 127 | django_docutils.favicon.prefetch, "is_favicon_stored", lambda fqdn: False 128 | ) 129 | 130 | favicon, created = prefetch_favicon(url) 131 | assert not created 132 | 133 | 134 | @pytest.mark.django_db(transaction=True) 135 | @responses.activate 136 | def test_is_favicon_stored_file_missing(monkeypatch, Favicon): 137 | # case where the favicon is in ORM, but file not in storage 138 | url = "http://vim.org" 139 | fqdn = "vim.org" 140 | 141 | def mock_open(path, mode="r"): 142 | raise FileNotFoundError 143 | 144 | favicon = Favicon.objects.create( 145 | domain=fqdn, 146 | favicon=SimpleUploadedFile( 147 | name=f"{fqdn}.ico", 148 | content=b"lol", 149 | content_type="image/ico", 150 | ), 151 | ) 152 | 153 | assert not prefetch_favicon(url), "File should not redownload" 154 | 155 | monkeypatch.setattr(InMemoryFile, "open", mock_open) 156 | with pytest.raises(FileNotFoundError): # Assure monkeypatch 157 | favicon.favicon.file 158 | 159 | assert not is_favicon_stored( 160 | favicon.domain 161 | ), "favicon missing from storage should return False" 162 | 163 | 164 | @pytest.mark.django_db(transaction=True) 165 | @responses.activate 166 | def test_is_favicon_stored_favicon_not_in_db(monkeypatch): 167 | assert not is_favicon_stored( 168 | "nonexistant_fqdn.com" 169 | ), "favicon missing from database should return False" 170 | -------------------------------------------------------------------------------- /django_docutils/lib/roles/__init__.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from django.utils.module_loading import import_string 4 | from docutils.parsers.rst import roles 5 | 6 | from django_docutils.references.rst.roles import XRefRole 7 | 8 | from ..settings import BASED_LIB_RST 9 | 10 | 11 | def register_based_roles(): 12 | """Register all roles, exists to avoid race conditions / pulling in deps. 13 | 14 | This makes based a lot leaner by making roles explicit and "opt-in". 15 | 16 | Why? Not all django projects want need intersphinx cross-referencing 17 | or amazon links (which requires bitly and an amazon product api package). 18 | Let's use a TEMPLATES-style django config:: 19 | 20 | BASED_LIB_RST = { 21 | 'roles': { #: directive-name: Directive class (import string) 22 | 'local': { #: roles.register_local_role 23 | # below: same as 24 | # roles.register_local_role('gh', github_role) 25 | 'gh': 'django_docutils.lib.roles.github.git_role', 26 | 'pypi': 'django_docutils.lib.roles.pypi.pypi_role', 27 | }, 28 | 'canonical': { #: roles.register_canonical_role 29 | # below: same as: 30 | # roles.register_canonical_role('class', PyXRefRole()) 31 | 'class': 'django_docutils.lib.roles.xref.PyXRefRole', 32 | 33 | # below: same as 34 | # roles.register_canonical_role( 35 | # 'ref', 36 | # XRefRole( 37 | # lowercase=True, innernodeclass=nodes.inline, 38 | # warn_dangling=True 39 | # ) 40 | # ) 41 | # See nodes.inline will be resolved 42 | 'ref': ( 43 | 'django_docutils.lib.roles.xref.XRefRole', 44 | { 45 | 'lowercase': True, 46 | 'innernodeclass': 'docutils.nodes.inline', 47 | 'warn_dangling': True 48 | } 49 | ), 50 | 'meth': ( 51 | 'django_docutils.lib.roles.xref.PyXRefRole', 52 | { 53 | 'fix_parens': True 54 | } 55 | ), 56 | } 57 | } 58 | } 59 | """ 60 | if not BASED_LIB_RST: 61 | return 62 | 63 | if "roles" not in BASED_LIB_RST: 64 | return 65 | 66 | based_roles = BASED_LIB_RST["roles"] 67 | 68 | local_roles = based_roles.get("local", None) 69 | 70 | if local_roles: 71 | register_role_mapping(local_roles) 72 | 73 | 74 | def register_role_mapping(role_mapping): 75 | """Register a dict mapping of roles 76 | 77 | An item consists of a role name, import string to a callable, and an 78 | optional mapping of keyword args for special roles that are classes 79 | that can accept arguments. 80 | 81 | The term inside 'cb' is short for callback/callable. Since the string can 82 | be any callable object: a function or class. 83 | 84 | :param role_mapping: 85 | :type role_mapping: dict 86 | :rtype: void 87 | :returns: Nothing 88 | """ 89 | 90 | for role_name, role_cb_str in role_mapping.items(): 91 | role_cb_kwargs = {} 92 | 93 | if isinstance(role_cb_str, tuple): 94 | # ( 95 | # 'django_docutils.lib.roles.xref.PyXRefRole', # role_cb_str 96 | # { # role_cb_kwargs 97 | # 'fix_parens': True 98 | # } 99 | # ), 100 | 101 | # pop off dict of kwargs 102 | role_cb_kwargs = role_cb_str[1] 103 | 104 | # move class string item to a pure string 105 | role_cb_str = role_cb_str[0] 106 | 107 | # One more check, we may have an innernodeclass that needs 108 | # to be resolved, e.g. 109 | # ( 110 | # 'django_docutils.lib.roles.xref.XRefRole', 111 | # { 112 | # 'lowercase': True, 113 | # 'innernodeclass': 'docutils.nodes.inline', 114 | # 'warn_dangling': True 115 | # } 116 | # ), 117 | if "innernodeclass" in role_cb_kwargs and isinstance( 118 | role_cb_kwargs["innernodeclass"], str 119 | ): 120 | role_cb_kwargs["innernodeclass"] = import_string( 121 | role_cb_kwargs["innernodeclass"] 122 | ) 123 | 124 | # Docutils roles accept a function or callable class as a callback 125 | role_ = import_string(role_cb_str) 126 | 127 | # Stuff like cross-reference roles, which are derived from sphinx work 128 | # differently. Unlike normal function roles, these roles are classes 129 | # passed in instantiated. 130 | # 131 | # If they include kwargs, they are entered as a tuple with a second 132 | # element that's a dict of the kwargs passed into the role. 133 | if inspect.isclass(role_) and issubclass(role_, XRefRole): 134 | if role_cb_kwargs: 135 | roles.register_local_role(role_name, role_(**role_cb_kwargs)) 136 | else: 137 | roles.register_local_role(role_name, role_()) 138 | else: 139 | roles.register_local_role(role_name, role_) 140 | --------------------------------------------------------------------------------