djxi 0.1.2__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.
djxi/__init__.py ADDED
File without changes
@@ -0,0 +1,4 @@
1
+ from .base import DxActionRouter
2
+ from .router import dx_route
3
+
4
+ __all__ = ["DxActionRouter", "dx_route"]
djxi/actions/base.py ADDED
@@ -0,0 +1,25 @@
1
+ from django.http import HttpResponse
2
+ from django.template import Template, RequestContext
3
+
4
+ from .router import DXRouterMixin
5
+ from .section import DXSectionTemplateMixin
6
+
7
+
8
+ class DxActionRouter(DXSectionTemplateMixin, DXRouterMixin):
9
+ """Unify the Main User Loop into one class with defined routed actions and a section library.
10
+ Main User Loop = Request->Route->Logic->Render->Response ...
11
+
12
+ 1) define the section template:
13
+ a) inline_template = <dx-section name="identifier"> html for this section </dx-section>
14
+ b) template_name = path/to/template.html (app/template.html)
15
+ 2) set up actions as class methods with the route decorator
16
+ 3) hook the router into a url conf
17
+ """
18
+
19
+ def render_section(self, request, section_name, context=None):
20
+ """Render a dx-section as a full HTTP response."""
21
+ if context is None:
22
+ context = {}
23
+ raw_html = self.get_section(section_name)
24
+ template = Template(raw_html)
25
+ return HttpResponse(template.render(RequestContext(request, context)))
djxi/actions/parser.py ADDED
@@ -0,0 +1,75 @@
1
+ import os
2
+ from html.parser import HTMLParser
3
+ from django.template import loader
4
+
5
+ from djxi.conf import package_settings as djxi_settings
6
+
7
+
8
+ class SectionParser(HTMLParser):
9
+ def __init__(self):
10
+ super().__init__()
11
+ self.section_tag = getattr(djxi_settings, "DX_SECTION_TAG", None)
12
+ self.sections = {} # name -> inner HTML
13
+ self._current_name = None
14
+ self._inside = False
15
+ self._chunks = []
16
+
17
+ def handle_starttag(self, tag, attrs):
18
+ if tag == self.section_tag:
19
+ # Extract the 'name' attribute
20
+ attrs_dict = dict(attrs)
21
+ name = attrs_dict.get("name")
22
+ if name:
23
+ self._current_name = name
24
+ self._inside = True
25
+ self._chunks = []
26
+ return
27
+ if self._inside:
28
+ # Re‑emit the opening tag (including its attributes)
29
+ attrs_str = " " + " ".join(f'{k}="{v}"' for k, v in attrs) if attrs else ""
30
+ self._chunks.append(f"<{tag}{attrs_str}>")
31
+
32
+ def handle_endtag(self, tag):
33
+ if tag == self.section_tag and self._inside:
34
+ # End of section – store the collected inner HTML
35
+ self.sections[self._current_name] = "".join(self._chunks)
36
+ self._inside = False
37
+ self._current_name = None
38
+ self._chunks = []
39
+ elif self._inside:
40
+ # Re‑emit the closing tag for nested elements
41
+ self._chunks.append(f"</{tag}>")
42
+
43
+ def handle_data(self, data):
44
+ if self._inside:
45
+ self._chunks.append(data)
46
+
47
+ def handle_startendtag(self, tag, attrs):
48
+ # Self‑closing tags (like <br/> or <img ... />)
49
+ if self._inside:
50
+ attrs_str = " " + " ".join(f'{k}="{v}"' for k, v in attrs) if attrs else ""
51
+ self._chunks.append(f"<{tag}{attrs_str}/>")
52
+
53
+
54
+ def load_django_template(template_name: str) -> str:
55
+ """Loads a Django template from the given name."""
56
+ template_string = ""
57
+ # If template_name references a filesystem path, read it directly.
58
+ # In that case template name has to be an absolute path (this is mainly for the test suite)
59
+ if os.path.exists(template_name):
60
+ with open(template_name, "r", encoding="utf-8") as f:
61
+ template_string = f.read()
62
+ else:
63
+ # Attempt to load via Django's template loader (e.g. app/template.html).
64
+ try:
65
+ tpl = loader.get_template(template_name)
66
+ origin = getattr(tpl, "origin", None)
67
+ if origin and hasattr(origin, "loader"):
68
+ template_string = origin.loader.get_contents(origin)
69
+ else:
70
+ template_string = (
71
+ getattr(getattr(tpl, "template", None), "source", "") or ""
72
+ )
73
+ except Exception:
74
+ template_string = ""
75
+ return template_string
djxi/actions/router.py ADDED
@@ -0,0 +1,103 @@
1
+ """ Djxi Routing
2
+
3
+ TODO: Potential Refactor
4
+ - dx_route -> make_route | dx_mark_view
5
+ - dx_router -> dx_collect_paths
6
+ """
7
+ from functools import wraps
8
+ from django.http import HttpResponseNotAllowed
9
+ from django.urls import path
10
+
11
+
12
+ class DXRouterMixin:
13
+ """Use as a Mixin to call CLS.dx_router() to get a list of djxi routes"""
14
+
15
+ @classmethod
16
+ def dx_router(cls) -> list:
17
+ """
18
+ Generate a list of Django URL patterns from all methods decorated with @route.
19
+ Use the class method to DxActionRouter.dx_router() in the url conf to hook the routes
20
+ """
21
+ patterns = []
22
+
23
+ for attr_name in dir(cls):
24
+ attr = getattr(cls, attr_name)
25
+ if hasattr(attr, "_routes"):
26
+ for url_path, methods, name in attr._routes:
27
+ # Create a view that instantiates the class and calls the method
28
+ def make_view(method_name, allowed_methods, attr_func=attr):
29
+ @wraps(attr_func)
30
+ def view(request, *args, **kwargs):
31
+ if request.method not in allowed_methods:
32
+ return HttpResponseNotAllowed(allowed_methods)
33
+ instance = cls() # instantiate the view class
34
+ handler = getattr(instance, method_name)
35
+ return handler(request, *args, **kwargs)
36
+
37
+ return view
38
+
39
+ patterns.append(
40
+ path(url_path, make_view(attr_name, methods), name=name)
41
+ )
42
+
43
+ return patterns
44
+
45
+
46
+ def dx_route(path: str, methods: [] = None, **kwargs):
47
+ """
48
+ Decorator to mark a class method as a URL endpoint.
49
+ - path: URL path (can include Django path converters, e.g. '/items/<int:id>/')
50
+ - methods: list of allowed HTTP methods (defaults to ["GET"])
51
+ - kwargs:
52
+ - name: qualify the route with a name, default to func.__name__
53
+ """
54
+ if methods is None:
55
+ methods = ["GET"]
56
+
57
+ def decorator(func):
58
+ name = kwargs.pop("name", func.__name__)
59
+ if not hasattr(func, "_routes"):
60
+ func._routes = []
61
+ func._routes.append((path, methods, name))
62
+ return func
63
+
64
+ return decorator
65
+
66
+
67
+ #
68
+ # Alias definitions
69
+ #
70
+ def dx_GET(path: str, **kwargs):
71
+ return dx_route(path, ["GET"], **kwargs)
72
+
73
+
74
+ def dx_HEAD(path: str, **kwargs):
75
+ return dx_route(path, ["HEAD"], **kwargs)
76
+
77
+
78
+ def dx_POST(path: str, **kwargs):
79
+ return dx_route(path, ["POST"], **kwargs)
80
+
81
+
82
+ def dx_PUT(path: str, **kwargs):
83
+ return dx_route(path, ["PUT"], **kwargs)
84
+
85
+
86
+ def dx_PATCH(path: str, **kwargs):
87
+ return dx_route(path, ["PATCH"], **kwargs)
88
+
89
+
90
+ def dx_DELETE(path: str, **kwargs):
91
+ return dx_route(path, ["DELETE"], **kwargs)
92
+
93
+
94
+ def dx_OPTIONS(path: str, **kwargs):
95
+ return dx_route(path, ["OPTIONS"], **kwargs)
96
+
97
+
98
+ def dx_ANY(path: str, **kwargs):
99
+ return dx_route(
100
+ path,
101
+ ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
102
+ **kwargs,
103
+ )
@@ -0,0 +1,49 @@
1
+ from django.core.exceptions import ImproperlyConfigured
2
+
3
+ from .parser import SectionParser, load_django_template
4
+
5
+
6
+ class DXSectionTemplateMixin:
7
+ """"""
8
+
9
+ section_inline = None
10
+ section_template_name = None
11
+
12
+ def __init__(self, **kwargs):
13
+ """Constructor builds the dx section cache."""
14
+ if self.section_inline is None and self.section_template_name is None:
15
+ raise ImproperlyConfigured(
16
+ "DxActionRouter requires a definition of sections via "
17
+ "'section_inline' or a path to a 'section_template_name'"
18
+ )
19
+ self.build_section_cache()
20
+
21
+ def build_section_cache(self) -> None:
22
+ """On init builds the section cache.
23
+ A dictionary with the (k,v) = name-of-section, html_string.
24
+ """
25
+ self._dx_section_cache = self.parse_section_dict()
26
+
27
+ def parse_section_dict(self) -> dict:
28
+ """Parses the string and extract the dx-section parts."""
29
+ parser = SectionParser()
30
+ parser.feed(self.build_section_string())
31
+ return parser.sections
32
+
33
+ def build_section_string(self) -> str:
34
+ """Concatenates the specified templates and returns the unmodified string.
35
+ In most cases you want to set one or the other, yet both is allowed.
36
+ Sections are read in order and later redefinition may override.
37
+ """
38
+ section_string = ""
39
+ if self.section_template_name is not None:
40
+ section_string = load_django_template(self.section_template_name)
41
+ if self.section_inline is not None:
42
+ section_string += self.section_inline
43
+ return section_string
44
+
45
+ def get_section(self, name: str) -> str:
46
+ """Returns the djxi section from the _dx_section_cache.
47
+ If name cannot be found, returns empty string.
48
+ """
49
+ return self._dx_section_cache.get(name, "")
djxi/apps.py ADDED
@@ -0,0 +1,27 @@
1
+ from django.apps import AppConfig
2
+ from django.core.exceptions import ImproperlyConfigured
3
+
4
+
5
+ class DjxiAppConfig(AppConfig):
6
+ name = "djxi"
7
+ verbose_name = "Djxi"
8
+
9
+ def ready(self):
10
+ """
11
+ Django calls this method when the application registry is fully loaded.
12
+ This is the safest place to import signals, validators, or run
13
+ one-time startup code.
14
+ """
15
+ # Validate required settings exist and sane
16
+ from .conf import package_settings
17
+
18
+ htmx_version = package_settings.DX_HTMX_VERSION
19
+ if htmx_version not in ["2", "4"]:
20
+ raise ImproperlyConfigured("DX_HTMX_VERSION must be 2 or 4")
21
+
22
+ section_tag = package_settings.DX_SECTION_TAG
23
+ if section_tag in ["", None]:
24
+ raise ImproperlyConfigured("DX_SECTION_TAG can not be None or empty")
25
+
26
+ # Optional: Import signals if you have a signals.py file
27
+ # import mypackage.signals
djxi/conf.py ADDED
@@ -0,0 +1,27 @@
1
+ from django.conf import settings
2
+
3
+ # Define your package's default settings here.
4
+ # Use ALL CAPS names, just like Django's own settings.
5
+ DEFAULTS = {
6
+ "DX_HTMX_VERSION": "4",
7
+ "DX_HTMX_MINIFIED": True,
8
+ "DX_SECTION_TAG": "dx-section",
9
+ }
10
+
11
+
12
+ class Settings:
13
+ """
14
+ A proxy object that reads from django.conf.settings first,
15
+ and falls back to DEFAULTS if the user didn't define it.
16
+ """
17
+
18
+ def __getattr__(self, name):
19
+ if name not in DEFAULTS:
20
+ raise AttributeError(f"Invalid setting: '{name}'")
21
+
22
+ # Return the user's value from settings.py, or fall back to the default
23
+ return getattr(settings, name, DEFAULTS[name])
24
+
25
+
26
+ # Instantiate a single global object for easy import
27
+ package_settings = Settings()
@@ -0,0 +1 @@
1
+ hx-headers{% if explicit_inheritance %}:inherited{% endif %}='{"X-CSRFTOKEN":"{{ csrf_token }}"}'
@@ -0,0 +1,5 @@
1
+ {% if version is "2" %}
2
+ <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx{{minified}}.js" integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V" crossorigin="anonymous"></script>
3
+ {% else %}
4
+ <script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta5/dist/htmx{{minified}}.js" integrity="sha384-RZoQSZlu2BAuZMuM5lTKAWXXSKC+7X6eVzP1pwkUBcyfPmOswexqVOsUqQMKbAFA" crossorigin="anonymous"></script>
5
+ {% endif %}
File without changes
@@ -0,0 +1,16 @@
1
+ from django.template import Library
2
+ from djxi.conf import package_settings as djxi_settings
3
+
4
+ register = Library()
5
+
6
+
7
+ @register.inclusion_tag("htmx_script.html")
8
+ def htmx_script_inclusion():
9
+ minified = ".min" if djxi_settings.DX_HTMX_MINIFIED is True else ""
10
+ return {"version": djxi_settings.DX_HTMX_VERSION, "minified": minified}
11
+
12
+
13
+ @register.inclusion_tag("htmx_headers.html")
14
+ def htmx_headers():
15
+ explicit_inheritance = djxi_settings.DX_HTMX_VERSION == "4"
16
+ return {"explicit_inheritance": explicit_inheritance}
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: djxi
3
+ Version: 0.1.2
4
+ Summary: Django HTMX Integration
5
+ Author-email: Philipp Rollinger <philipp.rollinger@protonmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/rollinger/djxi
8
+ Project-URL: Repository, https://github.com/rollinger/djxi
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENCE.md
12
+ Requires-Dist: django>=4.2
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
15
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
16
+ Requires-Dist: black>=23.0.0; extra == "dev"
17
+ Requires-Dist: ruff>=0.0.275; extra == "dev"
18
+ Requires-Dist: pre-commit>=3.0; extra == "dev"
19
+ Requires-Dist: build>=1.0; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # Djxi | **HTMX Integration for Django**
23
+ ![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)
24
+ ---
25
+ [![CI](https://github.com/rollinger/djxi/actions/workflows/main.yml/badge.svg)](https://github.com/rollinger/djxi/actions/workflows/main.yml)
26
+ [![codecov](https://codecov.io/gh/rollinger/djxi/branch/master/graph/badge.svg)](https://codecov.io/gh/rollinger/djxi)
27
+ [![PyPI](https://img.shields.io/pypi/v/djxi)](https://pypi.org/project/djxi)
28
+ [![PyPI - Wheel](https://img.shields.io/pypi/wheel/djxi)](https://pypi.org/project/djxi)
29
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/djxi)](https://pypi.org/project/djxi)
30
+ ---
31
+
32
+ ## 📦 What is this?
33
+
34
+ **Stop hunting for HTMX endpoints.**
35
+ Djxi bundles the route, the view logic, and the HTML partial into a single **Endpoint Battery**.
36
+ Drop the `DxActionRouter` into your existing Django views or use it standalone. Keep every tiny `hx-*` swap exactly where it lives—without scattering your code across `urls.py`, `views/`, and `templates/`.
37
+
38
+ - **No more archaeology.** No more digging through three files just to tweak a button label.
39
+ - **LoB, restored.** Request → Logic → Render stays in one atomic, inline hub.
40
+ - **Scales cleanly.** Small partials stay manageable, without turning your project into spaghetti.
41
+
42
+ Just a prenup between Grandpa Django and his sexy new HTMX fling—keeping your repo clean, one battery at a time.
43
+
44
+ ## Pre-Alpha Note
45
+ The package is not yet published and considered in experimental pre-alpha state.
46
+ - Watch out for updates and consider giving it a star.
47
+ - Checkout the djxi showcases in the example django app.
48
+
49
+ ---
50
+
51
+ ## Getting Started
52
+ ### Instalation
53
+ 1) Install with pip:
54
+
55
+ `python -m pip install djxi`
56
+
57
+ 2) Add django-htmx to your INSTALLED_APPS:
58
+
59
+ ```python
60
+ INSTALLED_APPS = [
61
+ ...,
62
+ "djxi",
63
+ ...,
64
+ ]
65
+ ```
66
+
67
+ 3) Optional: Adjust your base template to get you up and running instantly
68
+ ```html
69
+ {% load djxi %}
70
+ <!doctype html>
71
+ <html>
72
+ <head>
73
+ ...
74
+ {% htmx_script_inclusion %}
75
+ </head>
76
+ <body {% htmx_headers %}>
77
+ ...
78
+ </body>
79
+ </html>
80
+ ```
81
+ The htmx_script_inclusion tag will pull the unminified v4 from CDN. Set DX_HTMX_VERSION="2" to pull in v2.
82
+ For prodution you likely want to serve your own minified htmx.js.
83
+
84
+ As there are significant syntax changes between v4 and v2 of htmx, keep DX_HTMX_VERSION in sync with
85
+ what htmx version you are serving.
86
+
87
+ ### Configuration
88
+ In your settings file you can overide the following default values for Djxi:
89
+ - DX_HTMX_VERSION": "4" # Choose between 4 and 2
90
+ - DX_HTMX_MINIFIED": False # Load a minified source, recommended for production
91
+
92
+ ### Quick start
93
+ Create and manage your HTMX Endpoint in a convenient Battery:
94
+
95
+ ```python
96
+ from djxi.actions import DxActionRouter, dx_route
97
+
98
+ INLINE_TEMPLATE = """
99
+ <dx-section name="confirm-button">
100
+ <button hx-post="/showcase/simple/confirm">
101
+ Confirm, {{ name }}
102
+ </button>
103
+ </dx-section>
104
+
105
+ <dx-section name="check-confirmed">
106
+ <button disabled>Confirmed!</button>
107
+ </dx-section>
108
+ """
109
+
110
+ class SimpleInlineActionRouter(DxActionRouter):
111
+ inline_template = INLINE_TEMPLATE
112
+
113
+ @dx_route("get-confirm-button", methods=["GET"])
114
+ def get_confirm_button(self, request):
115
+ context = {"name": "Phil"}
116
+ return self.render_section(
117
+ request, section_name="confirm-button", context=context
118
+ )
119
+ ```
@@ -0,0 +1,24 @@
1
+ djxi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ djxi/apps.py,sha256=VkuR7GCtN4bil6bdwnPQ0fnvKUH55AYDVMIEU7AbxGA,940
3
+ djxi/conf.py,sha256=pnfM8dBbpNUDjaXVy3CTEaY3UXKmun68nr4v7LUnqQc,754
4
+ djxi/actions/__init__.py,sha256=bGieYBqGmiM9TO4osylRgh0sXHHEWFoexwvozERyRJU,104
5
+ djxi/actions/base.py,sha256=FT7w7d3uk7X2zEDa0O5ZPfMEniG0sHueGwA2v6P4JD0,1055
6
+ djxi/actions/parser.py,sha256=sA6OlnE8snJmBw_rsVYpgDHPUMh_iA__r4iOYdhUa_8,2858
7
+ djxi/actions/router.py,sha256=YmWwcdLPChO4CkuUnXjCaVd0dsZdnnCOVCJD71pzUwU,3038
8
+ djxi/actions/section.py,sha256=RxB4-Lw1x3PRLPjGkhUUAtNq1LcASBkCmb_rrRr3aIs,1868
9
+ djxi/templates/htmx_headers.html,sha256=8I9e-IHH3dMyJ3unUxeYLP3lLeeKZDSBDcPX5K1E4Do,97
10
+ djxi/templates/htmx_script.html,sha256=tK9yc1IkV3HJXcYXNT0qq37wqqHAxp5wFUre9zqISgI,463
11
+ djxi/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ djxi/templatetags/djxi.py,sha256=e9zEWu68kQ4kd3ydvArZEYm9baRZuj7jTVeemU65FJk,526
13
+ djxi-0.1.2.dist-info/licenses/LICENCE.md,sha256=No1vF19rSru7Liq0pzUzfWaFLl0R0bd7D3aprQ6T9_M,1073
14
+ tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ tests/conftest.py,sha256=5KrxJ2VyLp8TMcw1pf-QUFnrcmsH2Mkxi1eEKw_zQ2Y,983
16
+ tests/test_actions.py,sha256=ltPcR9A9kOwXQ1IUSQb6IbnKoIlI79jfS1qIYj-0VtQ,1541
17
+ tests/test_parser.py,sha256=zr_6OQU3THLdUyu32kaVrguUrtgs35kny53-NOe3xMI,1237
18
+ tests/test_routing.py,sha256=Q1jD9M859Uz92XKo3hVx82FsWf5cGyuwLcvZ_L3NCXE,3185
19
+ tests/test_settings.py,sha256=nsQSFqVTuy8NJ7yE22tcKKBM46WxeL3wO04T2kLlPm4,678
20
+ tests/test_template_compat.py,sha256=TwFKVqzuc1do21CxsO3X64YhVl2EtdqWhPnsXXbBxnE,4477
21
+ djxi-0.1.2.dist-info/METADATA,sha256=EOBndSD_ItbBC5rt2iZcbgDRL3HU1vyjZzDVKuHFZUU,4083
22
+ djxi-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
23
+ djxi-0.1.2.dist-info/top_level.txt,sha256=iPqRhXpD7kKW5S5T7CoZaYG-WkV68sVourW1aSdd5gk,11
24
+ djxi-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Philipp Rollinger
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.
@@ -0,0 +1,2 @@
1
+ djxi
2
+ tests
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,32 @@
1
+ import django
2
+ from pathlib import Path
3
+ from django.conf import settings
4
+
5
+ TEST_DIR = Path(__file__).parent
6
+ TEMPLATE_DIR = str(TEST_DIR / "data") # absolute string path usable by Django
7
+
8
+ # Configure Django only once
9
+ if not settings.configured:
10
+ settings.configure(
11
+ SECRET_KEY="dummy-secret-for-testing",
12
+ ROOT_URLCONF=__name__, # will be overridden per test if needed
13
+ INSTALLED_APPS=[
14
+ "django.contrib.auth",
15
+ "django.contrib.contenttypes",
16
+ "djxi",
17
+ ],
18
+ MIDDLEWARE=[], # empty for speed
19
+ TEMPLATES=[
20
+ {
21
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
22
+ "DIRS": [TEMPLATE_DIR],
23
+ "APP_DIRS": True,
24
+ "OPTIONS": {
25
+ "context_processors": [
26
+ "django.template.context_processors.request",
27
+ ],
28
+ },
29
+ }
30
+ ],
31
+ )
32
+ django.setup()
tests/test_actions.py ADDED
@@ -0,0 +1,50 @@
1
+ import pytest
2
+ from django.core.exceptions import ImproperlyConfigured
3
+
4
+ from djxi.actions import DxActionRouter
5
+
6
+ INLINE_TEMPLATE = """
7
+ <dx-section name="a-b">AB</dx-section><dx-section name="long spaced name">
8
+ Long spaced Name
9
+ </dx-section>
10
+
11
+ <dx-section name="b-c-d">
12
+ This should be good<br>
13
+ </dx-section>
14
+ """
15
+
16
+
17
+ class EmptyActionRouter(DxActionRouter):
18
+ pass
19
+
20
+
21
+ class InlineActionRouter(DxActionRouter):
22
+ section_inline = INLINE_TEMPLATE
23
+
24
+
25
+ class TemplateActionRouter(DxActionRouter):
26
+ section_template_name = "template.html"
27
+
28
+
29
+ def test_djxi_empty_init():
30
+ # Raise error if inline_template and template_name is not configured
31
+ with pytest.raises(ImproperlyConfigured):
32
+ dx_action = EmptyActionRouter() # noqa: F841
33
+
34
+
35
+ def test_djxi_inline_build_sections():
36
+ dx_action = InlineActionRouter()
37
+ assert len(dx_action._dx_section_cache) == 3
38
+ assert dx_action.get_section("a-b") == "AB"
39
+ assert dx_action.get_section("long spaced name") == "\nLong spaced Name\n"
40
+ assert dx_action.get_section("b-c-d") == "\n This should be good<br>\n"
41
+ assert dx_action.get_section("this-key-does-not_exist") == ""
42
+
43
+
44
+ def test_djxi_template_build_sections():
45
+ dx_action = TemplateActionRouter()
46
+ assert len(dx_action._dx_section_cache) == 3
47
+ assert dx_action.get_section("a-b") == "AB"
48
+ assert dx_action.get_section("long spaced name") == "\nLong spaced Name\n"
49
+ assert dx_action.get_section("b-c-d") == "\n This should be good<br>\n"
50
+ assert dx_action.get_section("this-key-does-not_exist") == ""
tests/test_parser.py ADDED
@@ -0,0 +1,38 @@
1
+ from django.test import override_settings
2
+
3
+ from djxi.actions import DxActionRouter
4
+
5
+ DEFAULT = "dx-section"
6
+ CUSTOM = "my-section-tag"
7
+
8
+ INLINE_TEMPLATE = f"""
9
+ <unrelated-tag>
10
+ <{DEFAULT} name="section_01">Content 1</{DEFAULT}>
11
+ <{DEFAULT} name="section_02">Content 2</{DEFAULT}>
12
+ <unrelated-tag/ >
13
+ <{CUSTOM} name="section_03">Content 3</{CUSTOM}>
14
+ <{DEFAULT} name="section_04">Content 4</{DEFAULT}>
15
+ <unrelated-tag>XYZ</unrelated-tag>
16
+ <{CUSTOM} name="section_05">Content 5</{CUSTOM}>
17
+ """
18
+
19
+
20
+ class InlineActionRouter(DxActionRouter):
21
+ section_inline = INLINE_TEMPLATE
22
+
23
+
24
+ @override_settings(DX_SECTION_TAG=DEFAULT)
25
+ def test_default_section_tag():
26
+ default_router = InlineActionRouter()
27
+ assert len(default_router._dx_section_cache) == 3
28
+ assert default_router.get_section("section_01") == "Content 1"
29
+ assert default_router.get_section("section_02") == "Content 2"
30
+ assert default_router.get_section("section_04") == "Content 4"
31
+
32
+
33
+ @override_settings(DX_SECTION_TAG=CUSTOM)
34
+ def test_custom_section_tag():
35
+ custom_router = InlineActionRouter()
36
+ assert len(custom_router._dx_section_cache) == 2
37
+ assert custom_router.get_section("section_03") == "Content 3"
38
+ assert custom_router.get_section("section_05") == "Content 5"
tests/test_routing.py ADDED
@@ -0,0 +1,83 @@
1
+ # fmt: off
2
+ from django.test import override_settings, RequestFactory
3
+ from django.urls import path, include, resolve, reverse
4
+
5
+ from djxi.actions import DxActionRouter, dx_route
6
+
7
+ INLINE_TEMPLATE = """
8
+ <dx-section name="section_01">Content 1</dx-section>
9
+ <dx-section name="section_02">Content 2</dx-section>
10
+ <dx-section name="section_03">Content 3</dx-section>
11
+ """
12
+
13
+
14
+ class InlineActionRouter(DxActionRouter):
15
+ section_inline = INLINE_TEMPLATE
16
+
17
+ @dx_route("section/1", methods=["GET"], name="nifty_first_section")
18
+ def section_01(self, request):
19
+ return self.render_section(request, "section_01")
20
+
21
+ @dx_route("section/2/", methods=["GET"])
22
+ def section_02(self, request):
23
+ return self.render_section(request, "section_02")
24
+
25
+ @dx_route("section/3", methods=["GET", "POST"])
26
+ def section_03(self, request):
27
+ return self.render_section(request, "section_03")
28
+
29
+ @dx_route("section/<int:id>", methods=["GET"], name="get_the_section_you_want")
30
+ def section_by_id(self, request, id):
31
+ section_name = f"section_{id}"
32
+ return self.render_section(request, section_name)
33
+
34
+
35
+ # URL Patterns from .dx_router
36
+ urlpatterns = [
37
+ path("", include((InlineActionRouter.dx_router(), "djxi"), namespace="djxi"))
38
+ ]
39
+
40
+
41
+ @override_settings(ROOT_URLCONF=__name__)
42
+ def test_path_resolver():
43
+ true_paths = {
44
+ "/section/1": {"view": "section_01", "args": (), "kwargs": {}, "content": "Content 1"},
45
+ "/section/2/": {"view": "section_02", "args": (), "kwargs": {}, "content": "Content 2"},
46
+ "/section/3": {"view": "section_03", "args": (), "kwargs": {}, "content": "Content 3"},
47
+ "/section/4": {"view": "section_by_id", "args": (), "kwargs": {"id": 4}, "content": ""},
48
+ "/section/99": {"view": "section_by_id", "args": (), "kwargs": {"id": 99}, "content": ""},
49
+ }
50
+ rf = RequestFactory()
51
+ for request_path, truth in true_paths.items():
52
+ match = resolve(request_path)
53
+ assert match.func.__name__ == truth["view"]
54
+ assert match.args == truth["args"]
55
+ assert match.kwargs == truth["kwargs"]
56
+ req = rf.get(request_path)
57
+ assert match.func(req, *match.args, **match.kwargs).content.decode() == truth["content"]
58
+
59
+ @override_settings(ROOT_URLCONF=__name__)
60
+ def test_name_resolver():
61
+ request_path = reverse("djxi:nifty_first_section")
62
+ assert request_path == "/section/1"
63
+ match = resolve(request_path)
64
+ assert match.func.__name__ == "section_01"
65
+
66
+
67
+ @override_settings(ROOT_URLCONF=__name__)
68
+ def test_no_name_resolver():
69
+ request_path = reverse("djxi:section_02")
70
+ assert request_path == "/section/2/"
71
+ match = resolve(request_path)
72
+ assert match.func.__name__ == "section_02"
73
+
74
+ @override_settings(ROOT_URLCONF=__name__)
75
+ def test_name_override_resolver():
76
+ request_path = reverse("djxi:get_the_section_you_want", kwargs={"id": 3})
77
+ assert request_path == "/section/3"
78
+ match = resolve(request_path)
79
+ assert match.func.__name__ == "section_03"
80
+ request_path = reverse("djxi:get_the_section_you_want", kwargs={"id": 4})
81
+ assert request_path == "/section/4"
82
+ match = resolve(request_path)
83
+ assert match.func.__name__ == "section_by_id"
tests/test_settings.py ADDED
@@ -0,0 +1,19 @@
1
+ from django.test import override_settings
2
+
3
+ from djxi.conf import package_settings as djxi_settings
4
+
5
+
6
+ def test_conf_DX_HTMX_VERSION():
7
+ assert djxi_settings.DX_HTMX_VERSION == "4"
8
+ with override_settings(DX_HTMX_VERSION="2"):
9
+ assert djxi_settings.DX_HTMX_VERSION == "2"
10
+
11
+
12
+ def test_conf_DX_SECTION_TAG():
13
+ section_name = getattr(djxi_settings, "DX_SECTION_TAG", None)
14
+ assert section_name is not None
15
+ assert section_name == "dx-section"
16
+ with override_settings(DX_SECTION_TAG="my-section-tag"):
17
+ section_name = getattr(djxi_settings, "DX_SECTION_TAG", None)
18
+ assert section_name is not None
19
+ assert section_name == "my-section-tag"
@@ -0,0 +1,119 @@
1
+ """ Test compatibility of inline sections with Django Templating Tags and Filter"""
2
+ from django.test import override_settings, RequestFactory, modify_settings
3
+ from django.urls import path, include, resolve, reverse
4
+
5
+ from djxi.actions import DxActionRouter, dx_route
6
+
7
+ INLINE_TEMPLATE = """
8
+ <dx-section name="hello-world">
9
+ <p>Hello, {{ name }}!</p>
10
+ </dx-section>
11
+ <dx-section name="hello-many">
12
+ <p>Hello {% for name in names %}{{ name }}{% if not forloop.last %}, {% endif %}{% endfor %}!</p>
13
+ </dx-section>
14
+ <dx-section name="hello-filter">
15
+ <p>{{ number|floatformat:3 }}</p>
16
+ </dx-section>
17
+ <dx-section name="hello-humanize">
18
+ {% load humanize %}
19
+ <p>{{ number|intword }}</p>
20
+ </dx-section>
21
+ <dx-section name="hello-include">
22
+ <p>Hello {% for name in names %}{% include "partial.html" %}{% endfor %}!</p>
23
+ </dx-section>
24
+
25
+ """
26
+
27
+
28
+ class InlineActionRouter(DxActionRouter):
29
+ section_inline = INLINE_TEMPLATE
30
+
31
+ @dx_route("hello/<str:name>", methods=["GET"], name="hello_world")
32
+ def hallo_du(self, request, name: str):
33
+ context = {"name": name}
34
+ return self.render_section(request, "hello-world", context)
35
+
36
+ @dx_route("hello-all", methods=["GET"], name="hello_many")
37
+ def hello_many(self, request):
38
+ context = {"names": ["Django", "World", "Python"]}
39
+ return self.render_section(request, "hello-many", context)
40
+
41
+ @dx_route("hello-filter/<str:number>", methods=["GET"])
42
+ def hello_filter(self, request, number: str):
43
+ context = {"number": number}
44
+ return self.render_section(request, "hello-filter", context)
45
+
46
+ @dx_route("hello-humanize/<str:number>", methods=["GET"])
47
+ def hello_humanize(self, request, number: str):
48
+ return self.render_section(request, "hello-humanize", {"number": number})
49
+
50
+ @dx_route("hello_include", methods=["GET"])
51
+ def hello_include(self, request):
52
+ context = {"names": ["Django", "World", "Python"]}
53
+ return self.render_section(request, "hello-include", context)
54
+
55
+
56
+ # URL Patterns from .dx_router
57
+ urlpatterns = [
58
+ path("", include((InlineActionRouter.dx_router(), "djxi"), namespace="djxi"))
59
+ ]
60
+
61
+
62
+ @override_settings(ROOT_URLCONF=__name__)
63
+ def test_value_interpolation():
64
+ rf = RequestFactory()
65
+ resolver_match = resolve(reverse("djxi:hello_world", kwargs={"name": "Django"}))
66
+ req = rf.get(resolver_match.url_name)
67
+ response = resolver_match.func(req, *resolver_match.args, **resolver_match.kwargs)
68
+ assert req.method == "GET"
69
+ assert response.status_code == 200
70
+ assert response.content.decode().strip() == "<p>Hello, Django!</p>"
71
+
72
+
73
+ @override_settings(ROOT_URLCONF=__name__)
74
+ def test_builtin_templatetags():
75
+ rf = RequestFactory()
76
+ resolver_match = resolve(reverse("djxi:hello_many"))
77
+ req = rf.get(resolver_match.url_name)
78
+ response = resolver_match.func(req, *resolver_match.args, **resolver_match.kwargs)
79
+ assert req.method == "GET"
80
+ assert response.status_code == 200
81
+ assert response.content.decode().strip() == "<p>Hello Django, World, Python!</p>"
82
+
83
+
84
+ @override_settings(ROOT_URLCONF=__name__)
85
+ def test_template_include():
86
+ rf = RequestFactory()
87
+ resolver_match = resolve(reverse("djxi:hello_include"))
88
+ req = rf.get(resolver_match.url_name)
89
+ response = resolver_match.func(req, *resolver_match.args, **resolver_match.kwargs)
90
+ assert req.method == "GET"
91
+ assert response.status_code == 200
92
+ assert response.content.decode().strip() == "<p>Hello Django & World & Python!</p>"
93
+
94
+
95
+ @override_settings(ROOT_URLCONF=__name__)
96
+ def test_buildin_filter():
97
+ rf = RequestFactory()
98
+ resolver_match = resolve(
99
+ reverse("djxi:hello_filter", kwargs={"number": "108.999212"})
100
+ )
101
+ req = rf.get(resolver_match.url_name)
102
+ response = resolver_match.func(req, *resolver_match.args, **resolver_match.kwargs)
103
+ assert req.method == "GET"
104
+ assert response.status_code == 200
105
+ assert response.content.decode().strip() == "<p>108.999</p>"
106
+
107
+
108
+ @modify_settings(INSTALLED_APPS={"append": "django.contrib.humanize"})
109
+ @override_settings(ROOT_URLCONF=__name__)
110
+ def test_opt_in_tags():
111
+ rf = RequestFactory()
112
+ resolver_match = resolve(
113
+ reverse("djxi:hello_humanize", kwargs={"number": "1000000"})
114
+ )
115
+ req = rf.get(resolver_match.url_name)
116
+ response = resolver_match.func(req, *resolver_match.args, **resolver_match.kwargs)
117
+ assert req.method == "GET"
118
+ assert response.status_code == 200
119
+ assert response.content.decode().strip() == "<p>1.0 million</p>"