django-markdown-view 0.0.6b2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. django_markdown_view-0.0.6b2.dist-info/METADATA +312 -0
  2. django_markdown_view-0.0.6b2.dist-info/RECORD +37 -0
  3. django_markdown_view-0.0.6b2.dist-info/WHEEL +5 -0
  4. django_markdown_view-0.0.6b2.dist-info/licenses/LICENSE +27 -0
  5. django_markdown_view-0.0.6b2.dist-info/top_level.txt +1 -0
  6. markdown_view/__init__.py +1 -0
  7. markdown_view/apps.py +12 -0
  8. markdown_view/checks.py +42 -0
  9. markdown_view/constants.py +11 -0
  10. markdown_view/loaders.py +61 -0
  11. markdown_view/markdown_extensions.py +19 -0
  12. markdown_view/models.py +0 -0
  13. markdown_view/registry.py +343 -0
  14. markdown_view/templates/markdown_view/markdown.html +28 -0
  15. markdown_view/tests/__init__.py +0 -0
  16. markdown_view/tests/fixtures/loaders_a/readme.md +4 -0
  17. markdown_view/tests/fixtures/loaders_b/readme.md +4 -0
  18. markdown_view/tests/settings/__init__.py +0 -0
  19. markdown_view/tests/settings/coveralls_settings.py +74 -0
  20. markdown_view/tests/test_checks.py +38 -0
  21. markdown_view/tests/test_loaders.py +68 -0
  22. markdown_view/tests/test_markdown_extensions.py +36 -0
  23. markdown_view/tests/test_registry.py +337 -0
  24. markdown_view/tests/test_settings_validation.py +55 -0
  25. markdown_view/tests/test_views.py +169 -0
  26. markdown_view/tests/testapp/CONTEXT_TEST.md +3 -0
  27. markdown_view/tests/testapp/LOGGED_IN.md +3 -0
  28. markdown_view/tests/testapp/NESTED.md +5 -0
  29. markdown_view/tests/testapp/NO_HEADERS.md +4 -0
  30. markdown_view/tests/testapp/README.md +35 -0
  31. markdown_view/tests/testapp/STAFF.md +3 -0
  32. markdown_view/tests/testapp/UNROUTED.md +4 -0
  33. markdown_view/tests/testapp/__init__.py +0 -0
  34. markdown_view/tests/testapp/apps.py +6 -0
  35. markdown_view/tests/testapp/docs/OTHER.md +8 -0
  36. markdown_view/tests/urls.py +109 -0
  37. markdown_view/views.py +109 -0
@@ -0,0 +1,343 @@
1
+ """
2
+ Registry mapping ``MarkdownView`` (and subclass) ``file_name`` values to the
3
+ concrete URLs that serve them.
4
+
5
+ Markdown source files commonly link to other markdown files using plain,
6
+ filesystem-relative links (e.g. ``[See also](../README-cron.md)``), exactly
7
+ as they would on GitHub or in a local editor. When such a file is rendered
8
+ as an HTML page instead of viewed as a raw file, that relative link no
9
+ longer resolves to anything meaningful in the browser -- the rendered page
10
+ lives at whatever URL its own view was routed to, not at the file's
11
+ location on disk.
12
+
13
+ This module lets ``MarkdownView`` automatically discover, for any given
14
+ target ``.md`` file, whether some URL pattern in the project serves that
15
+ file via a ``MarkdownView`` (or subclass), and if so, resolve to that URL
16
+ instead of leaving the raw relative path in place.
17
+
18
+ This is intentionally best-effort: routes behind a required URL argument,
19
+ or wrapped by a decorator that doesn't preserve the underlying view's
20
+ ``view_class``/``view_initkwargs`` attributes, are silently skipped. A link
21
+ that can't be resolved to a known route is left exactly as markdown would
22
+ otherwise have rendered it, so nothing breaks if a target file simply isn't
23
+ routed anywhere.
24
+ """
25
+ import logging
26
+ import os
27
+ import re
28
+ from urllib.parse import urlsplit, urlunsplit
29
+
30
+ from django.conf import settings
31
+ from django.core.signals import setting_changed
32
+ from django.template import Engine, TemplateDoesNotExist
33
+ from django.urls import NoReverseMatch, URLResolver, get_resolver, reverse
34
+
35
+ from markdown_view.constants import DEFAULT_MARKDOWN_VIEW_LOADERS
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # Cached {id(resolver): {absolute file path: full reverse name}} registries.
40
+ # Keyed by the resolver's identity (Django itself caches `get_resolver()` per
41
+ # urlconf, so this naturally rebuilds if `ROOT_URLCONF`/the active urlconf
42
+ # ever changes, e.g. under `override_settings` in tests). Note that the
43
+ # *reverse name* is cached here, not the result of calling `reverse()` on
44
+ # it -- `reverse()` can depend on request/runtime context (e.g. the active
45
+ # language under `i18n_patterns()`, or the active script prefix), so it must
46
+ # be called fresh every time a link is actually rewritten.
47
+ #
48
+ # This cache key does *not* otherwise account for `MARKDOWN_VIEW_LOADERS`,
49
+ # since a route's file resolution normally never changes while its
50
+ # resolver/urlconf stays the same -- so instead we listen for
51
+ # `MARKDOWN_VIEW_LOADERS` changing (e.g. via `override_settings` in tests)
52
+ # below and explicitly clear the cache when it does.
53
+ _registry_cache = {}
54
+
55
+
56
+ def _on_setting_changed(*, setting, **kwargs):
57
+ """
58
+ Invalidate the cached URL registry whenever `MARKDOWN_VIEW_LOADERS`
59
+ changes (e.g. via `override_settings` in tests), since the registry's
60
+ `source_path` keys depend on how that setting resolves `file_name`
61
+ values to files on disk, and merely rebuilding on urlconf/resolver
62
+ identity change (as `get_markdown_view_url_registry()` does) wouldn't
63
+ otherwise catch a loader-only change.
64
+ """
65
+ if setting == "MARKDOWN_VIEW_LOADERS":
66
+ clear_markdown_view_url_registry_cache()
67
+
68
+
69
+ setting_changed.connect(_on_setting_changed)
70
+
71
+
72
+ def _build_engine():
73
+ """
74
+ Return a new `Engine` configured with the same template loaders
75
+ `MarkdownView` itself uses, so relative `file_name` values resolve
76
+ identically here and at render time. Built fresh (not cached at module
77
+ scope) so it always reflects the current `MARKDOWN_VIEW_LOADERS` setting,
78
+ e.g. under `override_settings` in tests.
79
+ """
80
+ return Engine(
81
+ loaders=getattr(
82
+ settings, "MARKDOWN_VIEW_LOADERS", DEFAULT_MARKDOWN_VIEW_LOADERS
83
+ )
84
+ )
85
+
86
+
87
+ def resolve_markdown_source_path(file_name, engine=None):
88
+ """
89
+ Resolve a markdown_view `file_name` to the absolute filesystem path of
90
+ its underlying source file, using the same template-loader resolution
91
+ `MarkdownView` uses to actually load and render it.
92
+
93
+ :param file_name: A `file_name` value, as passed to
94
+ `MarkdownView.as_view(file_name=...)`.
95
+ :param engine: An optional `django.template.Engine` to resolve
96
+ `file_name` with. Built via `_build_engine()` if not provided.
97
+ :return: An absolute, normalized filesystem path, or `None` if it
98
+ cannot be resolved (e.g. no matching file exists).
99
+ """
100
+ if engine is None:
101
+ engine = _build_engine()
102
+ try:
103
+ template = engine.get_template(file_name)
104
+ except TemplateDoesNotExist:
105
+ return None
106
+ return os.path.normpath(template.origin.name)
107
+
108
+
109
+ def _iter_markdown_view_routes(resolver, namespace_parts=()):
110
+ """
111
+ Recursively walk a URLResolver's patterns, yielding
112
+ `(file_name, full_reverse_name)` for every leaf pattern whose view is a
113
+ `MarkdownView` (or subclass) constructed with a `file_name` kwarg.
114
+
115
+ :param resolver: A `django.urls.URLResolver` (typically the project's
116
+ root resolver, from `django.urls.get_resolver()`).
117
+ :param namespace_parts: Namespace segments accumulated from ancestor
118
+ `include()` calls, used to build the fully-qualified reverse name.
119
+ """
120
+ # Local import to avoid a hard import-time dependency between this
121
+ # module and `views.py` (which doesn't need to import this module at
122
+ # module load time).
123
+ from markdown_view.views import MarkdownView
124
+
125
+ for pattern in resolver.url_patterns:
126
+ if isinstance(pattern, URLResolver):
127
+ # A nested `include()` -- recurse into it, extending the
128
+ # namespace path if this include() was itself namespaced.
129
+ child_namespace = namespace_parts
130
+ if pattern.namespace:
131
+ child_namespace = namespace_parts + (pattern.namespace,)
132
+ yield from _iter_markdown_view_routes(pattern, child_namespace)
133
+ continue
134
+
135
+ # Leaf pattern (an actual URL, not a nested urlconf). Only
136
+ # `as_view()`-based class-based views expose `view_class`/
137
+ # `view_initkwargs` on their callback -- function views and
138
+ # views wrapped by decorators that don't forward these
139
+ # attributes will simply have `view_class=None` and get skipped
140
+ # below.
141
+ callback = getattr(pattern, "callback", None)
142
+ view_class = getattr(callback, "view_class", None)
143
+ if not (isinstance(view_class, type) and issubclass(view_class, MarkdownView)):
144
+ continue
145
+
146
+ file_name = getattr(callback, "view_initkwargs", {}).get("file_name")
147
+ if not file_name or not pattern.name:
148
+ # No `file_name`, or an unnamed route we can't `reverse()`.
149
+ continue
150
+
151
+ # Build the fully-qualified name (e.g. "users:support:readme")
152
+ # needed to `reverse()` this route later, since `pattern.name`
153
+ # alone is only unique within its own urlconf.
154
+ full_name = ":".join((*namespace_parts, pattern.name))
155
+ yield file_name, full_name
156
+
157
+
158
+ def build_markdown_view_url_registry(resolver=None):
159
+ """
160
+ Build a `{absolute source file path: full reverse name}` mapping for
161
+ every registered `MarkdownView` (or subclass) route in the project, by
162
+ walking the resolved root URLconf.
163
+
164
+ Note that this stores the *reverse name* for each route, not the result
165
+ of calling `reverse()` on it -- `reverse()`'s result can depend on
166
+ request/runtime context (e.g. the active language under
167
+ `i18n_patterns()`, or the active script prefix), so it must be called
168
+ fresh at the point a link is actually rewritten, not cached here.
169
+
170
+ :param resolver: A `django.urls.URLResolver` to walk. Defaults to
171
+ `django.urls.get_resolver()` (the project's root resolver).
172
+ :return: dict mapping each route's absolute source file path to the
173
+ full reverse name (e.g. `"users:support:readme"`) that serves it.
174
+ """
175
+ if resolver is None:
176
+ resolver = get_resolver()
177
+
178
+ engine = _build_engine()
179
+ registry = {}
180
+ for file_name, full_name in _iter_markdown_view_routes(resolver):
181
+ # Map the route to the file it serves by resolving `file_name`
182
+ # (e.g. "users/README.md") through the same loader MarkdownView
183
+ # itself uses, so the key here matches the `source_path` that
184
+ # `rewrite_markdown_links()` will later look up against it.
185
+ source_path = resolve_markdown_source_path(file_name, engine=engine)
186
+ if source_path is None:
187
+ logger.debug(
188
+ "markdown_view: route %r references file_name %r, which "
189
+ "could not be resolved to a source file; skipping.",
190
+ full_name, file_name,
191
+ )
192
+ continue
193
+ try:
194
+ # Routes behind a required URL argument (e.g.
195
+ # "<int:pk>/readme/") can't be reversed without knowing that
196
+ # argument's value, so they're simply not linkable targets.
197
+ # The result is only used here to confirm reversibility --
198
+ # it's discarded rather than cached, since `reverse()` must be
199
+ # called fresh (with the active request's language/script
200
+ # prefix, etc.) whenever a link is actually rewritten.
201
+ reverse(full_name)
202
+ except NoReverseMatch:
203
+ logger.debug(
204
+ "markdown_view: could not reverse %r for file_name %r; "
205
+ "skipping (likely behind a required URL argument).",
206
+ full_name, file_name,
207
+ )
208
+ continue
209
+ if source_path in registry:
210
+ # The same source file is served by more than one route. There's
211
+ # no inherently correct answer here, so we make the choice
212
+ # explicit: the first route encountered wins, and later aliases
213
+ # are logged rather than silently overriding it -- adding a
214
+ # later alias shouldn't unexpectedly change all of a file's
215
+ # internal links to the new alias.
216
+ logger.debug(
217
+ "markdown_view: file_name %r is already routed to %r; "
218
+ "ignoring additional route %r for the same file.",
219
+ file_name, registry[source_path], full_name,
220
+ )
221
+ continue
222
+ registry[source_path] = full_name
223
+ return registry
224
+
225
+
226
+ def get_markdown_view_url_registry(force_refresh=False):
227
+ """
228
+ Return the (cached) `{absolute source file path: full reverse name}`
229
+ registry for the currently active URLconf, building it on first access.
230
+
231
+ :param force_refresh: Rebuild the registry even if a cached copy exists.
232
+ :return: dict mapping each route's absolute source file path to the
233
+ full reverse name that serves it.
234
+ """
235
+ resolver = get_resolver()
236
+ # `get_resolver()` is itself cached per-urlconf by Django, so keying
237
+ # on its identity means this registry naturally rebuilds if the
238
+ # active urlconf ever changes (e.g. `ROOT_URLCONF` swapped via
239
+ # `override_settings` in tests) without needing to track that
240
+ # explicitly.
241
+ cache_key = id(resolver)
242
+ if force_refresh or cache_key not in _registry_cache:
243
+ _registry_cache[cache_key] = build_markdown_view_url_registry(resolver)
244
+ return _registry_cache[cache_key]
245
+
246
+
247
+ def clear_markdown_view_url_registry_cache():
248
+ """Clear all cached URL registries. Primarily useful in tests."""
249
+ _registry_cache.clear()
250
+
251
+
252
+ # Matches an `<a href="...">` opening tag, capturing the text before the
253
+ # href value (group 1), the href value itself (group 2), and the closing
254
+ # quote (group 3), so `_replace()` below can substitute just the middle
255
+ # group back into the original tag without disturbing anything else on
256
+ # the `<a>` tag (other attributes, casing, etc.).
257
+ _HREF_RE = re.compile(r'(<a\b[^>]*\bhref=")([^"]*)(")', re.IGNORECASE)
258
+
259
+
260
+ def rewrite_markdown_links(html, source_path, registry=None):
261
+ """
262
+ Rewrite `<a href="...">` targets in rendered markdown HTML that point
263
+ at other markdown source files -- via a plain, filesystem-relative
264
+ link resolved relative to `source_path`'s directory -- to the actual
265
+ routed URL of the `MarkdownView` serving that file, if one is
266
+ registered.
267
+
268
+ Links that are absolute (have a scheme or network location, or start
269
+ with `/`) or don't target a `.md` file are left untouched, as are
270
+ relative `.md` links with no matching registered route (they render
271
+ exactly as markdown would otherwise produce). Any query string or
272
+ `#fragment` on the original link is preserved on the rewritten URL.
273
+
274
+ :param html: Rendered HTML markup, already converted from Markdown.
275
+ :param source_path: Absolute filesystem path of the `.md` file `html`
276
+ was rendered from; used to resolve relative link targets.
277
+ :param registry: An optional pre-built `{absolute path: full reverse
278
+ name}` registry. Built via `get_markdown_view_url_registry()` if
279
+ not provided.
280
+ :return: The HTML with resolvable internal links rewritten.
281
+ """
282
+ if registry is None:
283
+ registry = get_markdown_view_url_registry()
284
+ # Relative links in the source `.md` file are resolved the same way a
285
+ # browser or file manager would: relative to the directory the *source
286
+ # file* lives in, not relative to the URL the rendered page happens to
287
+ # be served at.
288
+ source_dir = os.path.dirname(source_path)
289
+
290
+ def _replace(match):
291
+ prefix, href, suffix = match.groups()
292
+ # Use real URL parsing rather than string tests, so query strings
293
+ # and fragments on otherwise-relative `.md` links (e.g.
294
+ # `guide.md?format=print#section`) are recognized and preserved,
295
+ # while links with a scheme (`mailto:`, `tel:`, `custom-scheme:`,
296
+ # `https://...`) or netloc, or that are site-root-relative, are
297
+ # correctly left alone.
298
+ parts = urlsplit(href)
299
+ if parts.scheme or parts.netloc or parts.path.startswith("/"):
300
+ # Already absolute -- nothing for us to resolve, leave as-is.
301
+ return match.group(0)
302
+ if not parts.path.lower().endswith(".md"):
303
+ # Not a link to another markdown source file (e.g. an image,
304
+ # a `.txt` file, an in-page anchor) -- leave as-is.
305
+ return match.group(0)
306
+
307
+ # Resolve the relative path exactly as the browser would if this
308
+ # were a raw file link, then see if some route serves that exact
309
+ # file.
310
+ resolved_path = os.path.normpath(os.path.join(source_dir, parts.path))
311
+ full_name = registry.get(resolved_path)
312
+ if full_name is None:
313
+ # Points at a real .md file, but nothing routes it -- leave
314
+ # the link exactly as Markdown would otherwise have rendered
315
+ # it, rather than producing a broken URL of our own.
316
+ logger.debug(
317
+ "markdown_view: no registered route for linked file %r "
318
+ "(resolved from %r while rendering %r).",
319
+ resolved_path, href, source_path,
320
+ )
321
+ return match.group(0)
322
+
323
+ try:
324
+ # Resolved fresh on every call (rather than cached), since
325
+ # `reverse()`'s result can depend on request/runtime context --
326
+ # e.g. the currently active language under `i18n_patterns()`,
327
+ # or the active script prefix -- not just the URLconf.
328
+ url = reverse(full_name)
329
+ except NoReverseMatch:
330
+ # The registry is stale relative to the active URLconf (e.g. it
331
+ # changed since the registry was built) -- leave as-is rather
332
+ # than producing a broken link.
333
+ logger.debug(
334
+ "markdown_view: could not reverse %r for linked file %r "
335
+ "while rendering %r; leaving link unrewritten.",
336
+ full_name, resolved_path, source_path,
337
+ )
338
+ return match.group(0)
339
+
340
+ new_href = urlunsplit(("", "", url, parts.query, parts.fragment))
341
+ return f"{prefix}{new_href}{suffix}"
342
+
343
+ return _HREF_RE.sub(_replace, html)
@@ -0,0 +1,28 @@
1
+ {% if use_highlight_js %}
2
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/styles/a11y-light.min.css" integrity="sha512-PW96n2amVglidqEDLPUdjJ0zByhT20poSqWJYZRutR6CP2QH58k96WmorqNnC4QXnosNeqMJM8FR/93isIifDQ==" crossorigin="anonymous" />
3
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/highlight.min.js" integrity="sha512-lnOllyZZlRk/gQIyjS3+h+LUy54uyM4aGq2zbGc82KTvBlp/fodSpdh/pywPztpU9zUqHcJr+jP+a1zLa9oCJw==" crossorigin="anonymous"></script>
4
+ {% endif %}
5
+
6
+ {% comment %}{{ page_title|safe }}{% endcomment %}
7
+
8
+ {% if use_toc %}
9
+ <section id="markdown_view_toc">
10
+ <h3>Table of Contents</h3>
11
+ {{ markdown_toc }}
12
+ </section>
13
+ {% endif %}
14
+
15
+ <section id="markdown_view_content">
16
+ {{ markdown_content }}
17
+ </section>
18
+
19
+ {% if use_highlight_js %}
20
+ <script>
21
+ hljs.initHighlightingOnLoad();
22
+ $(document).ready(function(){
23
+ $('.md-content div.toc ul').attr("class", "cta-links");
24
+ $('.md-content div.toc ul li a').attr("class", "default-fa");
25
+ $('.md-content table').attr("class", "table table-bordered table-hover table-striped table-mobile tablesorter tablesorter-default");
26
+ });
27
+ </script>
28
+ {% endif %}
File without changes
@@ -0,0 +1,4 @@
1
+ # Fixture used by markdown_view.tests.test_registry to prove that the
2
+ # markdown_view URL registry rebuilds when MARKDOWN_VIEW_LOADERS changes,
3
+ # even without ROOT_URLCONF/the resolver identity changing.
4
+ # "loaders_a" version.
@@ -0,0 +1,4 @@
1
+ # Fixture used by markdown_view.tests.test_registry to prove that the
2
+ # markdown_view URL registry rebuilds when MARKDOWN_VIEW_LOADERS changes,
3
+ # even without ROOT_URLCONF/the resolver identity changing.
4
+ # "loaders_b" version.
File without changes
@@ -0,0 +1,74 @@
1
+ """
2
+ Django settings used to run this project's test suite (see ``manage.py`` and
3
+ ``tox.ini``, which invoke ``manage.py test`` / ``coverage run manage.py
4
+ test`` against
5
+ ``DJANGO_SETTINGS_MODULE=markdown_view.tests.settings.coveralls_settings``).
6
+ """
7
+ import os
8
+
9
+ # The `markdown_view/tests/` directory, used below to locate fixtures.
10
+ TESTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
11
+
12
+ # The repository root. `markdown_view.loaders.MarkdownLoader` falls back to
13
+ # this (via `MarkdownLoader.get_dirs()`) when `MARKDOWN_VIEW_BASE_DIR` isn't
14
+ # set, so this is also exercised directly by `test_loaders.py`.
15
+ BASE_DIR = os.path.dirname(os.path.dirname(TESTS_DIR))
16
+
17
+ SECRET_KEY = "not-a-secret-only-used-to-run-tests"
18
+ DEBUG = True
19
+ ALLOWED_HOSTS = ["*"]
20
+
21
+ INSTALLED_APPS = [
22
+ "django.contrib.auth",
23
+ "django.contrib.contenttypes",
24
+ "django.contrib.sessions",
25
+ "django.contrib.staticfiles",
26
+ "markdown_view",
27
+ "markdown_view.tests.testapp",
28
+ ]
29
+
30
+ MIDDLEWARE = [
31
+ "django.contrib.sessions.middleware.SessionMiddleware",
32
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
33
+ "django.middleware.common.CommonMiddleware",
34
+ ]
35
+
36
+ DATABASES = {
37
+ "default": {
38
+ "ENGINE": "django.db.backends.sqlite3",
39
+ "NAME": ":memory:",
40
+ }
41
+ }
42
+
43
+ ROOT_URLCONF = "markdown_view.tests.urls"
44
+
45
+ TEMPLATES = [
46
+ {
47
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
48
+ "DIRS": [],
49
+ "APP_DIRS": True,
50
+ "OPTIONS": {
51
+ "context_processors": [
52
+ "django.template.context_processors.request",
53
+ "django.contrib.auth.context_processors.auth",
54
+ ],
55
+ },
56
+ },
57
+ ]
58
+
59
+ # The default `MARKDOWN_VIEW_LOADERS` (`markdown_view.loaders.MarkdownLoader`)
60
+ # is used for the vast majority of the suite -- it discovers fixtures under
61
+ # `markdown_view/tests/testapp/` via `INSTALLED_APPS`, exactly as it would in
62
+ # a real project. `markdown_view.tests.test_registry`'s loader-cache
63
+ # invalidation tests override this per-test (via `override_settings`) to
64
+ # point at `fixtures/loaders_a`/`fixtures/loaders_b` instead.
65
+
66
+ AUTH_PASSWORD_VALIDATORS = []
67
+
68
+ LOGIN_URL = "/accounts/login/"
69
+
70
+ STATIC_URL = "/static/"
71
+
72
+ USE_TZ = True
73
+
74
+ DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
@@ -0,0 +1,38 @@
1
+ from django.core.checks import Tags, run_checks
2
+ from django.test import SimpleTestCase, override_settings
3
+
4
+ from markdown_view.checks import markdown_view_check
5
+
6
+
7
+ class MarkdownViewCheckTests(SimpleTestCase):
8
+ def test_returns_empty_list_for_valid_settings(self):
9
+ # markdown_view/tests/settings/coveralls_settings.py doesn't define
10
+ # any of the optional MARKDOWN_VIEW_* settings, so there's nothing
11
+ # to flag.
12
+ self.assertEqual(markdown_view_check(app_configs=[]), [])
13
+
14
+
15
+ class MarkdownViewCheckRegistrationTests(SimpleTestCase):
16
+ """
17
+ Confirms `markdown_view_check` is actually *wired up* through Django's
18
+ system-check framework (registered against `Tags.security` in
19
+ `MarkdownViewConfig.ready()`, see `markdown_view/apps.py`), not just
20
+ that the function itself behaves correctly in isolation.
21
+ """
22
+
23
+ def test_run_checks_surfaces_invalid_setting_via_registered_check(self):
24
+ with override_settings(MARKDOWN_VIEW_TEMPLATE_USE_TOC="not-a-bool"):
25
+ errors = run_checks(tags=[Tags.security])
26
+
27
+ self.assertTrue(
28
+ any(error.id == "markdown_view.E001" for error in errors),
29
+ "Expected django.core.checks.run_checks() to surface "
30
+ "markdown_view.E001 for an invalid MARKDOWN_VIEW_* setting; "
31
+ "got: %r" % (errors,),
32
+ )
33
+
34
+ def test_run_checks_reports_no_markdown_view_errors_for_valid_settings(self):
35
+ errors = run_checks(tags=[Tags.security])
36
+ self.assertFalse(
37
+ any(error.id == "markdown_view.E001" for error in errors)
38
+ )
@@ -0,0 +1,68 @@
1
+ import os
2
+
3
+ from django.template import Engine
4
+ from django.test import SimpleTestCase, override_settings
5
+
6
+ from markdown_view.loaders import MarkdownLoader
7
+
8
+ TESTAPP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testapp")
9
+
10
+
11
+ def _get_loader():
12
+ engine = Engine(loaders=["markdown_view.loaders.MarkdownLoader"])
13
+ return engine.template_loaders[0]
14
+
15
+
16
+ class MarkdownLoaderGetDirsTests(SimpleTestCase):
17
+ def test_includes_app_template_dirs(self):
18
+ loader = _get_loader()
19
+ dirs = [str(d) for d in loader.get_dirs()]
20
+ self.assertIn(TESTAPP_DIR, dirs)
21
+
22
+ @override_settings(MARKDOWN_VIEW_BASE_DIR="/tmp/some-markdown-base-dir")
23
+ def test_includes_markdown_view_base_dir_when_set(self):
24
+ loader = _get_loader()
25
+ dirs = [str(d) for d in loader.get_dirs()]
26
+ self.assertIn("/tmp/some-markdown-base-dir", dirs)
27
+
28
+ def test_includes_base_dir_when_markdown_view_base_dir_not_set(self):
29
+ # markdown_view/tests/settings.py sets BASE_DIR but not
30
+ # MARKDOWN_VIEW_BASE_DIR, so BASE_DIR should be used as a fallback.
31
+ from django.conf import settings
32
+
33
+ loader = _get_loader()
34
+ dirs = [str(d) for d in loader.get_dirs()]
35
+ self.assertIn(settings.BASE_DIR, dirs)
36
+
37
+
38
+ class MarkdownLoaderGetTemplateSourcesTests(SimpleTestCase):
39
+ def test_yields_one_origin_for_valid_name(self):
40
+ loader = _get_loader()
41
+ origins = list(loader.get_template_sources("testapp/README.md"))
42
+ self.assertEqual(len(origins), 1)
43
+ self.assertEqual(
44
+ os.path.normpath(origins[0].name),
45
+ os.path.join(TESTAPP_DIR, "README.md"),
46
+ )
47
+
48
+ def test_yields_one_origin_for_nested_valid_name(self):
49
+ loader = _get_loader()
50
+ origins = list(loader.get_template_sources("testapp/docs/OTHER.md"))
51
+ self.assertEqual(len(origins), 1)
52
+ self.assertEqual(
53
+ os.path.normpath(origins[0].name),
54
+ os.path.join(TESTAPP_DIR, "docs", "OTHER.md"),
55
+ )
56
+
57
+ def test_yields_nothing_for_non_md_name(self):
58
+ loader = _get_loader()
59
+ origins = list(loader.get_template_sources("testapp/notes.txt"))
60
+ self.assertEqual(origins, [])
61
+
62
+ def test_does_not_raise_for_path_escaping_template_dir(self):
63
+ loader = _get_loader()
64
+ # Attempts to escape testapp/'s directory via `..`; the
65
+ # SuspiciousFileOperation guard should be swallowed, yielding
66
+ # nothing rather than raising.
67
+ origins = list(loader.get_template_sources("testapp/../secret.md"))
68
+ self.assertEqual(origins, [])
@@ -0,0 +1,36 @@
1
+ from xml.etree import ElementTree
2
+
3
+ import markdown
4
+ from django.test import SimpleTestCase
5
+
6
+ from markdown_view.markdown_extensions import ImageExtension, InlineImageProcessor
7
+
8
+
9
+ class InlineImageProcessorTests(SimpleTestCase):
10
+ def _run(self, src):
11
+ root = ElementTree.Element("div")
12
+ img = ElementTree.SubElement(root, "img")
13
+ img.set("src", src)
14
+ InlineImageProcessor(md=None).run(root)
15
+ return root.find("img")
16
+
17
+ def test_rewrites_src_to_static_tag(self):
18
+ img = self._run("foo.png")
19
+ self.assertEqual(img.attrib["src"], "{% static 'foo.png' %}")
20
+
21
+ def test_strips_leading_static_segment(self):
22
+ img = self._run("/static/images/foo.png")
23
+ self.assertEqual(img.attrib["src"], "{% static 'images/foo.png' %}")
24
+
25
+ def test_sets_img_fluid_class(self):
26
+ img = self._run("foo.png")
27
+ self.assertEqual(img.attrib["class"], "img-fluid")
28
+
29
+
30
+ class ImageExtensionTests(SimpleTestCase):
31
+ def test_extends_markdown_conversion(self):
32
+ md = markdown.Markdown(extensions=[ImageExtension()])
33
+ html = md.convert("![alt text](foo.png)")
34
+ self.assertIn("{% static 'foo.png' %}", html)
35
+ self.assertIn('class="img-fluid"', html)
36
+ self.assertIn('alt="alt text"', html)