django-htmx-plus 0.1.0__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.
File without changes
@@ -0,0 +1,47 @@
1
+ from typing import List
2
+
3
+ from django.http import HttpResponse
4
+
5
+
6
+ class HtmxResponse(HttpResponse):
7
+ """An HTTP 204 response that carries HTMX trigger events.
8
+
9
+ Returns a ``204 No Content`` status and populates the ``HX-Trigger``
10
+ response header with one or more comma-separated event names, causing
11
+ HTMX to fire those events on the client and trigger any listening elements
12
+ to refresh without a full page reload.
13
+ """
14
+
15
+ def __init__(self, triggers: List[str], *args, **kwargs):
16
+ """Initialise the response with the given trigger event names.
17
+
18
+ Args:
19
+ triggers (List[str]): Event names to include in the ``HX-Trigger``
20
+ response header.
21
+ *args: Additional positional arguments forwarded to ``HttpResponse``.
22
+ **kwargs: Additional keyword arguments forwarded to ``HttpResponse``.
23
+ """
24
+ super(HtmxResponse, self).__init__(*args, **kwargs)
25
+ self.status_code = 204
26
+ self.headers["HX-Trigger"] = ",".join(triggers)
27
+
28
+
29
+ class HtmxRedirectResponse(HttpResponse):
30
+ """An HTTP response that instructs HTMX to perform a client-side redirect.
31
+
32
+ Populates the ``HX-Redirect`` response header with the supplied destination
33
+ URL, causing HTMX to navigate the browser to that URL without a traditional
34
+ HTTP redirect status code.
35
+ """
36
+
37
+ def __init__(self, destination: str, *args, **kwargs):
38
+ """Initialise the response with the redirect destination URL.
39
+
40
+ Args:
41
+ destination (str): The URL to redirect the client to via the
42
+ ``HX-Redirect`` response header.
43
+ *args: Additional positional arguments forwarded to ``HttpResponse``.
44
+ **kwargs: Additional keyword arguments forwarded to ``HttpResponse``.
45
+ """
46
+ super(HtmxRedirectResponse, self).__init__(*args, **kwargs)
47
+ self.headers["HX-Redirect"] = destination
@@ -0,0 +1,70 @@
1
+ import json
2
+ from django.contrib.messages import get_messages
3
+
4
+
5
+ class HtmxMessagesMiddleware:
6
+ """Middleware that forwards Django messages to HTMX via the HX-Trigger header.
7
+
8
+ Intercepts HTMX requests and appends any pending Django messages to the
9
+ ``HX-Trigger`` response header as a ``messages`` key, allowing the
10
+ client-side HTMX event system to display them without a full page reload.
11
+ """
12
+
13
+ def __init__(self, get_response):
14
+ """Initialize the middleware.
15
+
16
+ Args:
17
+ get_response: The next middleware or view in the chain.
18
+ """
19
+ self.get_response = get_response
20
+
21
+ def __call__(self, request):
22
+ """Process the request and attach messages to the HX-Trigger header.
23
+
24
+ Args:
25
+ request: The incoming HTTP request.
26
+
27
+ Returns:
28
+ HttpResponse: The response with the ``HX-Trigger`` header updated
29
+ to include any pending Django messages when the request was made
30
+ by HTMX and the response is not a redirect.
31
+ """
32
+ response = self.get_response(request)
33
+
34
+ # The HX-Request header indicates that the request was made with HTMX
35
+ if "HX-Request" not in request.headers:
36
+ return response
37
+
38
+ # Ignore redirections because HTMX cannot read the headers
39
+ if 300 <= response.status_code < 400:
40
+ return response
41
+
42
+ # Extract the messages
43
+ messages = [{"message": message.message, "tags": message.tags} for message in get_messages(request)]
44
+ if not messages:
45
+ return response
46
+
47
+ # Get the existing HX-Trigger that could have been defined by the view
48
+ hx_trigger = response.headers.get("HX-Trigger")
49
+
50
+ if hx_trigger is None:
51
+ # If the HX-Trigger is not set, start with an empty object
52
+ hx_trigger = {}
53
+ elif hx_trigger.startswith("{"):
54
+ # If the HX-Trigger uses the object syntax, parse the object
55
+ hx_trigger = json.loads(hx_trigger)
56
+ else:
57
+ # If the HX-Trigger uses the string syntax, convert to the object syntax
58
+ triggers = hx_trigger.split(",")
59
+ hx_trigger = {}
60
+ for trigger in triggers:
61
+ hx_trigger[trigger] = True
62
+
63
+ # Add the messages array in the HX-Trigger object
64
+ hx_trigger["messages"] = messages
65
+
66
+ # Add or update the HX-Trigger
67
+ response.headers["HX-Trigger"] = json.dumps(hx_trigger)
68
+
69
+ return response
70
+
@@ -0,0 +1,41 @@
1
+ from django_htmx_plus.http import HtmxResponse
2
+ from django.contrib import messages
3
+ from typing import List
4
+
5
+
6
+ class HtmxFormResponseMixin:
7
+ """A mixin that provides a trigger-aware HTMX response for successful form submissions.
8
+
9
+ Mix this class into any ``FormView`` or ``CreateView`` subclass to replace the
10
+ default redirect response with an :class:`~django_htmx_plus.http.HtmxResponse`
11
+ that carries one or more ``HX-Trigger`` events, allowing the page to react
12
+ without a full navigation.
13
+
14
+ Attributes:
15
+ valid_triggers (List[str]): Event names included in the ``HX-Trigger`` header
16
+ of the response returned after a successful form submission.
17
+ """
18
+
19
+ valid_triggers: List[str] = []
20
+ success_message: str = ''
21
+
22
+ def form_valid(self, form):
23
+ """Save the form and return a trigger-aware HTMX response.
24
+
25
+ Calls ``form.save()`` and then returns an :class:`~django_htmx_plus.http.HtmxResponse`
26
+ that sends all events listed in :attr:`valid_triggers` via the ``HX-Trigger``
27
+ response header, prompting any listening HTMX elements to refresh.
28
+
29
+ Args:
30
+ form (forms.ModelForm): A bound and validated Django form instance.
31
+
32
+ Returns:
33
+ HtmxResponse: A 204 response containing the ``HX-Trigger`` header populated
34
+ with the events defined in :attr:`valid_triggers`.
35
+ """
36
+ form.save()
37
+
38
+ if self.success_message:
39
+ messages.success(self.request, self.success_message)
40
+
41
+ return HtmxResponse(self.valid_triggers)
@@ -0,0 +1,39 @@
1
+ import {Modal, Offcanvas} from 'bootstrap';
2
+
3
+ const dialogs = {}
4
+
5
+ for (const element of document.querySelectorAll('[data-htmx-plus-modal]')) {
6
+ dialogs[element.dataset.htmxPlusModal] = new Modal(element);
7
+ }
8
+
9
+ for (const element of document.querySelectorAll('[data-htmx-plus-offcanvas]')) {
10
+ dialogs[element.dataset.htmxPlusOffcanvas] = new Offcanvas(element);
11
+ }
12
+
13
+ htmx.on("htmx:afterSwap", (e) => {
14
+ // Response targeting #dialog => show the modal
15
+ const element_id = e.detail.target.id;
16
+
17
+ if(element_id in dialogs) {
18
+ dialogs[element_id].show();
19
+ }
20
+ });
21
+
22
+ htmx.on("htmx:beforeSwap", (e) => {
23
+ // Empty response targeting #dialog => hide the modal
24
+ const element_id = e.detail.target.id;
25
+
26
+ if(element_id in dialogs && !e.detail.xhr.response) {
27
+ dialogs[element_id].hide();
28
+ e.detail.shouldSwap = false;
29
+ }
30
+ });
31
+
32
+ htmx.on("hidden.bs.modal", (e) => {
33
+ // This resets modal body to ""
34
+ document.getElementById(e.target.id).firstElementChild.innerHTML = "";
35
+ })
36
+
37
+ htmx.on("hidden.bs.offcanvas", (e) => {
38
+ console.log("offcanvas", e);
39
+ })
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
2
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
3
+ class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-down {{ add_class }}" {{ attrs }}>
4
+ <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
5
+ <path d="M6 9l6 6l6 -6"/>
6
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
2
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
3
+ class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-left {{ add_class }}" {{ attrs }}>
4
+ <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
5
+ <path d="M15 6l-6 6l6 6"/>
6
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
2
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
3
+ class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-right {{ add_class }}" {{ attrs }}>
4
+ <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
5
+ <path d="M9 6l6 6l-6 6"/>
6
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
2
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
3
+ class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-up {{ add_class }}" {{ attrs }}>
4
+ <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
5
+ <path d="M6 15l6 -6l6 6"/>
6
+ </svg>
@@ -0,0 +1,5 @@
1
+ <thead>
2
+ <tr>
3
+ {{ slot }}
4
+ </tr>
5
+ </thead>
@@ -0,0 +1,15 @@
1
+ <th class="cursor-pointer {{ add_class }}" hx-get="{{ path }}{% if page_obj %}?page={{ page_obj.number }}&{% else %}?{% endif %}{% if filter_query %}{{ filter_query }}&{% endif %}order_by={% if name == order_by %}-{{ name }}{% else %}{{ name }}{% endif %}"
2
+ hx-target="{{ target_id }}">
3
+ <div class="d-flex">
4
+ <div class="flex-grow-1 align-bottom mt-1">
5
+ {{ slot }}
6
+ </div>
7
+ {% if order_by %}
8
+ {% if name == order_by %}
9
+ <c-icons.chevron_up add_class="ms-1"/>
10
+ {% elif "-"|add:name == order_by %}
11
+ <c-icons.chevron_down add_class="ms-1"/>
12
+ {% endif %}
13
+ {% endif %}
14
+ </div>
15
+ </th>
@@ -0,0 +1,22 @@
1
+ {% load cotton_extras %}
2
+ <div class="htmx-table-wrap">
3
+ <table class="{{class}}">
4
+ <thead>
5
+ {% for field in fields.keys %}
6
+ <c-tables.header_cell name="{{ field }}">{% if field in fields.labels %}{{ fields.labels|get_key_value:field }}{% else %}{{field|capfirst}}{% endif %}</c-tables.header_cell>
7
+ {% endfor %}
8
+ </thead>
9
+ <tbody>
10
+ {% for item in objects %}
11
+ <tr>
12
+ {% for field in fields.keys %}
13
+ <td>{{ item|get_key_value:field }}</td>
14
+ {% endfor %}
15
+ </tr>
16
+ {% endfor %}
17
+ </tbody>
18
+ </table>
19
+ </div>
20
+ {% if paginator %}
21
+ <c-tables.pager />
22
+ {% endif %}
@@ -0,0 +1,35 @@
1
+
2
+ <div class="d-flex justify-content-center justify-content-sm-between">
3
+ <div class="align-items-center">
4
+ <p class="m-0 text-secondary">Showing <strong>{{ page_obj.start_index }} to {{ page_obj.end_index }}</strong> of
5
+ <strong>{{ page_obj.paginator.count }} entries</strong></p>
6
+ </div>
7
+ <div class="justify-content-end">
8
+ <ul class="pagination" hx-target="{{ target_id }}">
9
+ <li class="page-item {% if not page_obj.has_previous %}disabled{% endif %}">
10
+ <button type="button" class="page-link"
11
+ {% if page_obj.has_previous %}hx-get="{{ request.path }}?page={{ page_obj.previous_page_number }}&{{ query }}"{% endif %}
12
+ tabindex="-1" aria-disabled="true">
13
+ <c-icons.chevron_left/>
14
+ </button>
15
+ </li>
16
+ {% for page in page_range %}
17
+ <li class="page-item {% if page_obj.number == page %}active{% endif %}">
18
+ {% if page == page_obj.paginator.ELLIPSIS %}
19
+ <span class="page-link h-100">{{ page }}</span>
20
+ {% else %}
21
+ <button type="button" class="h-100 page-link {% if page_obj.number == page %}active{% endif %}"
22
+ hx-get="{{ request.path }}?page={{ page }}&{{ query }}">{{ page }}</button>
23
+ {% endif %}
24
+ </li>
25
+ {% endfor %}
26
+ <li class="page-item {% if not page_obj.has_next %}disabled{% endif %}">
27
+ <button type="button" class="page-link"
28
+ {% if page_obj.has_next %}hx-get="{{ request.path }}?page={{ page_obj.next_page_number }}&{{ query }}"{% endif %}
29
+ tabindex="-1" aria-disabled="true">
30
+ <c-icons.chevron_right/>
31
+ </button>
32
+ </li>
33
+ </ul>
34
+ </div>
35
+ </div>
File without changes
@@ -0,0 +1,17 @@
1
+ from django import template
2
+ from typing import Dict, Any
3
+
4
+
5
+ register = template.Library()
6
+
7
+ @register.filter(name="get_attr")
8
+ def get_attr(obj, attr_name):
9
+ if not hasattr(obj, attr_name):
10
+ return ''
11
+
12
+ return getattr(obj, attr_name)
13
+
14
+
15
+ @register.filter(name="get_key_value")
16
+ def get_key_value(obj: Dict[str, Any], key: str):
17
+ return obj.get(key, None)
@@ -0,0 +1,16 @@
1
+ from django.utils.safestring import SafeString
2
+ from django.utils.html import format_html
3
+ from django.templatetags.static import static
4
+ from django.template import Library, Context
5
+
6
+ register = Library()
7
+
8
+
9
+ @register.simple_tag(takes_context=True)
10
+ def htmx_plus_script(context: Context) -> str:
11
+ parts = [
12
+ static("django_htmx_plus/django-htmx-plus.js"),
13
+ (context.get("nonce", None) or "")
14
+ ]
15
+
16
+ return format_html('<script src="{}" type="module"{}></script>', *parts)
@@ -0,0 +1,21 @@
1
+ from enum import StrEnum
2
+
3
+ class Filter(StrEnum):
4
+ EQ = 'exact'
5
+ IEQ = 'iexact'
6
+ GT = 'gt'
7
+ GTE = 'gte'
8
+ LT = 'lt'
9
+ LTE = 'lte'
10
+ LIKE = 'like'
11
+ ILIKE = 'ilike'
12
+ CS = 'cs'
13
+ ICS = 'ics'
14
+ IN = 'in'
15
+ NL = 'isnull'
16
+ RNG = 'range'
17
+ EW = 'endswith'
18
+ IEW = 'iendswith'
19
+ SW = 'startswith'
20
+ ISW = 'istartswith'
21
+ SCH = 'search'
@@ -0,0 +1,111 @@
1
+ import ast
2
+ from typing import List, Dict, Any, Tuple
3
+
4
+ from django_htmx_plus.types import Filter
5
+
6
+
7
+ def build_filter_dict(query_params: Dict[str, Any], allowed_fields: Tuple[str, ...] | None) -> dict:
8
+ """Build a filter dictionary from query parameters, restricted to allowed fields.
9
+
10
+ Parses query parameters of the form ``field_name.filter_type=value`` and
11
+ converts them into a Django ORM-compatible filter dict. Only fields present
12
+ in ``allowed_fields`` are processed; unknown filter suffixes are silently
13
+ dropped rather than forwarded to the ORM.
14
+
15
+ The ``field_name`` portion is the *root* field name only — traversal via
16
+ ``__`` within the field name segment is not permitted. This prevents callers
17
+ from bypassing the allowlist via related-model lookups (e.g.
18
+ ``related__secret.eq=value``).
19
+
20
+ Args:
21
+ query_params (dict): A dictionary of raw query parameters, typically
22
+ ``request.GET``.
23
+ allowed_fields (Tuple[str, ...] | None): A tuple of permitted field names.
24
+ Pass ``("__all__",)`` or an empty tuple/``None`` to allow all fields.
25
+
26
+ Returns:
27
+ dict: A dictionary suitable for passing directly to ``QuerySet.filter()``.
28
+ """
29
+ filter_dict = {}
30
+ unrestricted = not allowed_fields or "__all__" in allowed_fields
31
+ for key, value in query_params.items():
32
+ if "." in key:
33
+ field_name, filter_type = key.split(".", 1)
34
+ # Block traversal: field_name must be a plain field, not a __ path
35
+ if "__" in field_name:
36
+ continue
37
+ if unrestricted or field_name in allowed_fields:
38
+ if filter_type.upper() in Filter.__members__:
39
+ filter_value = Filter.__members__[filter_type.upper()].value
40
+ if isinstance(value, str):
41
+ try:
42
+ filter_dict[f"{field_name}__{filter_value}"] = ast.literal_eval(value)
43
+ except ValueError:
44
+ filter_dict[f"{field_name}__{filter_value}"] = value
45
+ else:
46
+ filter_dict[f"{field_name}__{filter_value}"] = value
47
+ # Unknown filter suffixes are silently ignored instead of being
48
+ # forwarded to the ORM with an invalid key.
49
+ return filter_dict
50
+
51
+
52
+ def build_query_str(query_params: dict) -> str:
53
+ """Build a URL query string from filter-style query parameters.
54
+
55
+ Iterates over ``query_params`` and includes only entries whose key contains
56
+ a ``.`` (i.e. ``field_name.filter_type`` pairs). The ``order_by`` and other
57
+ plain parameters are intentionally excluded.
58
+
59
+ Args:
60
+ query_params (dict): A dictionary of raw query parameters, typically
61
+ ``request.GET``.
62
+
63
+ Returns:
64
+ str: An ampersand-joined query string of the form
65
+ ``field.filter=value&field2.filter=value2``, or an empty string if
66
+ no matching parameters are present.
67
+ """
68
+ items = []
69
+ for key, value in query_params.items():
70
+ if "." in key:
71
+ items.append(f"{key}={value}")
72
+ return "&".join(items)
73
+
74
+ def build_filters_template_dict(filter: dict) -> dict:
75
+ """Convert an ORM filter dict into a template-friendly mapping.
76
+
77
+ Strips the Django ORM lookup suffix (everything from the first ``__``
78
+ onward) from each key so that templates receive plain field names as keys.
79
+ For example, ``{"name__icontains": "Alice"}`` becomes ``{"name": "Alice"}``.
80
+
81
+ Args:
82
+ filter (dict): A dictionary of ORM filter expressions as returned by
83
+ :func:`build_filter_dict`.
84
+
85
+ Returns:
86
+ dict: A dictionary mapping plain field names to their filter values.
87
+ """
88
+ results = {}
89
+ for key, value in filter.items():
90
+ results[key.split("__")[0]] = value
91
+ return results
92
+
93
+ def split_and_strip(content: str) -> List[str]:
94
+ """Split and normalise a multi-line string into a list of non-empty lines.
95
+
96
+ Strips leading/trailing spaces from each line, normalises Windows-style
97
+ line endings (``\\r\\n``) to Unix-style (``\\n``), removes non-breaking
98
+ space characters (``\\xa0`` and ``\\xc2``), and discards any lines that are
99
+ empty after stripping.
100
+
101
+ Args:
102
+ content (str): The raw multi-line string to process.
103
+
104
+ Returns:
105
+ List[str]: A list of cleaned, non-empty lines from ``content``.
106
+ """
107
+ lines = [
108
+ line.strip(" ").replace("\xa0", "").replace("\xc2", "")
109
+ for line in content.strip(" ").replace("\r\n", "\n").split("\n")
110
+ ]
111
+ return [line for line in lines if line]
@@ -0,0 +1,165 @@
1
+ from django.views.generic import ListView
2
+ from typing import Tuple
3
+ from urllib.parse import urlencode
4
+
5
+ from django_htmx_plus.utils import build_filter_dict, build_query_str, build_filters_template_dict
6
+
7
+
8
+ class HtmxListView(ListView):
9
+ """A Django ListView with HTMX support for filtering, sorting, and pagination.
10
+
11
+ Extends Django's ListView to provide seamless HTMX integration, including
12
+ dynamic filtering, column-based ordering, and elided pagination with query
13
+ string preservation.
14
+
15
+ Attributes:
16
+ target_id (str): The HTML element ID that HTMX will target for partial updates.
17
+ fields (Tuple[str, ...]): A tuple of model field names to include in the queryset
18
+ values. Use ``("__all__",)`` or leave empty to return full model instances.
19
+ """
20
+
21
+ target_id = ""
22
+ fields: Tuple[str, ...] = ()
23
+ labels: {}
24
+
25
+ def __init__(self):
26
+ """Initialise instance-level defaults for filter, query, and ordering state.
27
+
28
+ Sets safe default values so that attributes are always defined, even if
29
+ :meth:`setup` has not yet been called (e.g. during class introspection or
30
+ testing). All values are overwritten by :meth:`setup` on every real request.
31
+ """
32
+ super().__init__()
33
+ self.filter = {}
34
+ self.query = ""
35
+ self.filter_query = ""
36
+ self.order_by = "pk"
37
+
38
+ def setup(self, request, *args, **kwargs):
39
+ """Initialize view attributes from the incoming request.
40
+
41
+ Parses query parameters to build filter dictionaries, query strings, and
42
+ the active ordering field. Falls back to ``"pk"`` if no ordering is specified.
43
+
44
+ Args:
45
+ request: The incoming HTTP request object.
46
+ *args: Additional positional arguments passed to the parent ``setup``.
47
+ **kwargs: Additional keyword arguments passed to the parent ``setup``.
48
+ """
49
+ super(HtmxListView, self).setup(request, *args, **kwargs)
50
+ self.filter = build_filter_dict(request.GET, self.fields)
51
+ self.query = build_query_str(request.GET)
52
+ self.filter_query = self.query
53
+ self.order_by = getattr(self, "order_by", None)
54
+
55
+ if "order_by" in request.GET:
56
+ candidate = request.GET.get('order_by')
57
+ if self._is_order_by_allowed(candidate):
58
+ self.order_by = candidate
59
+
60
+ if not self.order_by:
61
+ self.order_by = "pk"
62
+
63
+ self.query += f"&order_by={self.order_by}"
64
+
65
+ def get_queryset(self):
66
+ """Return the filtered and ordered queryset for the view.
67
+
68
+ Applies any active filters from ``self.filter`` to the base queryset and
69
+ orders the result by ``self.order_by``. When ``fields`` is non-empty and
70
+ does not contain ``"__all__"``, the queryset is returned as a
71
+ ``ValuesQuerySet`` restricted to those fields, ensuring that columns not
72
+ listed in ``fields`` are never fetched from the database.
73
+
74
+ Returns:
75
+ QuerySet: A filtered and ordered queryset, or a ``ValuesQuerySet``
76
+ limited to ``self.fields`` when an explicit field list is provided.
77
+ """
78
+ qs = super(HtmxListView, self).get_queryset()
79
+
80
+ if hasattr(self, 'filter') and self.filter:
81
+ qs = qs.filter(**self.filter)
82
+ if hasattr(self, 'order_by') and self.order_by:
83
+ qs = qs.order_by(self.order_by)
84
+
85
+ if not self.fields or "__all__" in self.fields:
86
+ return qs
87
+
88
+ return qs.values(*self.fields)
89
+
90
+ def _is_order_by_allowed(self, order_by: str) -> bool:
91
+ """Check whether a user-supplied ordering field is permitted.
92
+
93
+ The field name is extracted by stripping a leading ``-`` (descending
94
+ indicator). Only the *root* segment (the part before the first ``__``)
95
+ is checked against ``self.fields``, so callers cannot bypass the
96
+ allowlist via related-model traversal (e.g. ``allowed_field__secret``).
97
+
98
+ Args:
99
+ order_by (str): The raw ``order_by`` value from the query string,
100
+ optionally prefixed with ``-`` for descending order.
101
+
102
+ Returns:
103
+ bool: ``True`` if the field is permitted or no allowlist is active,
104
+ ``False`` otherwise.
105
+ """
106
+ if not self.fields or "__all__" in self.fields:
107
+ return True
108
+ field = order_by.lstrip('-')
109
+ root_field = field.split('__')[0]
110
+ return root_field in self.fields
111
+
112
+ def get_context_data(self, **kwargs):
113
+ """Build and return the template context dictionary.
114
+
115
+ Adds HTMX- and pagination-related context variables, including the current
116
+ query string (without the ``page`` parameter), elided page range, ordering
117
+ state, request path, and active filters.
118
+
119
+ Args:
120
+ **kwargs: Additional keyword arguments forwarded to the parent
121
+ ``get_context_data``.
122
+
123
+ Returns:
124
+ dict: The context dictionary containing the following extra keys:
125
+
126
+ - ``query_params`` (str): URL-encoded query string excluding ``page``.
127
+ - ``page_range`` (iterator): Elided page range (only present when
128
+ ``paginate_by`` is set).
129
+ - ``order_by`` (str): The currently active ordering field.
130
+ - ``path`` (str): The current request path.
131
+ - ``target_id`` (str): The HTMX target element ID.
132
+ - ``query`` (str): Full query string including the ``order_by`` parameter.
133
+ - ``filter_query`` (str): Query string containing only filter parameters.
134
+ - ``filters`` (dict): Template-ready representation of active filters.
135
+ """
136
+ context = super().get_context_data(**kwargs)
137
+ query_params = {p: v for p, v in self.request.GET.items() if p != 'page'}
138
+ context["query_params"] = urlencode(query_params, doseq=True)
139
+
140
+ if hasattr(self, 'paginate_by') and self.paginate_by:
141
+ if not hasattr(self, 'elided_each_side'):
142
+ self.elided_each_side = 1
143
+
144
+ if not hasattr(self, 'elided_ends'):
145
+ self.elided_ends = 1
146
+
147
+ context["page_range"] = context["paginator"].get_elided_page_range(
148
+ number=context["page_obj"].number, on_each_side=self.elided_each_side, on_ends=self.elided_ends
149
+ )
150
+ context["paginator"].allow_empty_first_page = True
151
+
152
+ context["order_by"] = self.order_by
153
+ context["path"] = self.request.path
154
+ context["target_id"] = self.target_id
155
+ context["query"] = self.query
156
+ context["filter_query"] = self.filter_query
157
+ context["filters"] = build_filters_template_dict(self.filter)
158
+
159
+ fields = {
160
+ "keys": self.fields,
161
+ "labels": self.labels,
162
+ }
163
+ context["fields"] = fields
164
+
165
+ return context
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tim Davis
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,361 @@
1
+ Metadata-Version: 2.3
2
+ Name: django-htmx-plus
3
+ Version: 0.1.0
4
+ Summary: django-htmx plus some extras
5
+ License: MIT
6
+ Author: Tim Davis
7
+ Author-email: binary.god@gmail.com
8
+ Requires-Python: >=3.11,<4
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: django-cotton (>=2.6.2,<3.0.0)
15
+ Requires-Dist: django-htmx (>=1.27.0,<2.0.0)
16
+ Description-Content-Type: text/markdown
17
+
18
+ # django-htmx-plus
19
+
20
+ A Django utility library that extends [django-htmx](https://github.com/adamchainz/django-htmx) and [django-cotton](https://github.com/wrabit/django-cotton) with ready-made views, mixins, middleware, response helpers, and Cotton components for building HTMX-powered list views with filtering, sorting, and pagination.
21
+
22
+ This package was created for my own projects but it was handy enough to share, I learned how to use HTMX with Django and Boostrap Modals from the following two posts from [Josh Karamuth](https://www.linkedin.com/in/josh-karamuth/):
23
+ * [How to show a modal in Django + HTMX](https://joshkaramuth.com/blog/django-htmx-modal/)
24
+ * [Show Django forms inside a modal using HTMX](https://joshkaramuth.com/blog/django-htmx-modal-forms/)
25
+
26
+ ---
27
+
28
+ ## Features
29
+
30
+ - **`HtmxResponse`** – a 204 No Content response that fires `HX-Trigger` events on the client.
31
+ - **`HtmxRedirectResponse`** – a response that sends an `HX-Redirect` header to navigate the browser.
32
+ - **`HtmxMessagesMiddleware`** – automatically forwards Django messages to the client via the `HX-Trigger` header on every HTMX request.
33
+ - **`HtmxFormResponseMixin`** – a `FormView`/`CreateView` mixin that replaces the success redirect with an HTMX trigger response.
34
+ - **`HtmxListView`** – a `ListView` subclass with built-in URL-based filtering, column sorting, and elided pagination.
35
+ - **Filter helpers** – parse `field.filter_type=value` query parameters safely into Django ORM filter dicts.
36
+ - **Cotton components** – drop-in table, header cell, and pager components styled for Bootstrap 5.
37
+ - **Template filters** – `get_attr` and `get_key_value` for accessing object attributes and dict values in templates.
38
+ - **Built-in JavaScript helper** – a `django-htmx-plus.js` ES module that wires Bootstrap 5 Modals and Offcanvases to HTMX swap events, with a `{% htmx_plus_script %}` template tag to include it.
39
+
40
+ ---
41
+
42
+ ## Requirements
43
+
44
+ - Python 3.11+
45
+ - Django (any version compatible with the above)
46
+ - [django-htmx](https://github.com/adamchainz/django-htmx) ≥ 1.27
47
+ - [django-cotton](https://github.com/wrabit/django-cotton) ≥ 2.6
48
+
49
+ ---
50
+
51
+ ## Installation
52
+
53
+ ```bash
54
+ pip install django-htmx-plus
55
+ ```
56
+
57
+ Add to `INSTALLED_APPS` and configure middleware in `settings.py`:
58
+
59
+ ```python
60
+ INSTALLED_APPS = [
61
+ # ...
62
+ "django_htmx",
63
+ "django_cotton",
64
+ "django_htmx_plus",
65
+ ]
66
+
67
+ MIDDLEWARE = [
68
+ # ...
69
+ "django_htmx.middleware.HtmxMiddleware", # required by django-htmx
70
+ "django_htmx_plus.middleware.HtmxMessagesMiddleware",
71
+ ]
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Usage
77
+
78
+ ### HtmxResponse
79
+
80
+ Return a `204 No Content` response that triggers one or more HTMX events on the client:
81
+
82
+ ```python
83
+ from django_htmx_plus.http import HtmxResponse
84
+
85
+ def my_view(request):
86
+ # ... do some work ...
87
+ return HtmxResponse(triggers=["itemUpdated", "refreshStats"])
88
+ ```
89
+
90
+ The response sets `HX-Trigger: itemUpdated,refreshStats`, which HTMX picks up to re-fetch any elements listening for those events.
91
+
92
+ ---
93
+
94
+ ### HtmxRedirectResponse
95
+
96
+ Instruct HTMX to navigate the browser to a new URL without a traditional HTTP redirect:
97
+
98
+ ```python
99
+ from django_htmx_plus.http import HtmxRedirectResponse
100
+
101
+ def my_view(request):
102
+ return HtmxRedirectResponse(destination="/dashboard/")
103
+ ```
104
+
105
+ ---
106
+
107
+ ### HtmxMessagesMiddleware
108
+
109
+ Once the middleware is installed, any Django message added during an HTMX request is automatically serialised into the `HX-Trigger` header as a `messages` key:
110
+
111
+ ```json
112
+ {
113
+ "messages": [
114
+ {"message": "Record saved.", "tags": "success"}
115
+ ]
116
+ }
117
+ ```
118
+
119
+ If the view already sets its own `HX-Trigger` header (either string or JSON object syntax), the middleware merges the messages in without overwriting existing triggers.
120
+
121
+ Listen for the `messages` event in your HTMX setup to display the messages, for example:
122
+
123
+ ```javascript
124
+ document.body.addEventListener("messages", (event) => {
125
+ event.detail.value.forEach(({message, tags}) => {
126
+ showToast(message, tags); // your toast implementation
127
+ });
128
+ });
129
+ ```
130
+
131
+ ---
132
+
133
+ ### HtmxFormResponseMixin
134
+
135
+ Mix into any `FormView` or `CreateView` to replace the default success redirect with an HTMX trigger response:
136
+
137
+ ```python
138
+ from django.views.generic.edit import CreateView
139
+ from django_htmx_plus.mixins import HtmxFormResponseMixin
140
+ from myapp.models import Article
141
+ from myapp.forms import ArticleForm
142
+
143
+ class ArticleCreateView(HtmxFormResponseMixin, CreateView):
144
+ model = Article
145
+ form_class = ArticleForm
146
+ template_name = "articles/form.html"
147
+
148
+ valid_triggers = ["articleCreated"]
149
+ success_message = "Article created successfully."
150
+ ```
151
+
152
+ After a valid submission, `form.save()` is called, an optional Django success message is queued, and an `HtmxResponse` carrying `articleCreated` in `HX-Trigger` is returned.
153
+
154
+ | Attribute | Type | Description |
155
+ |---|---|---|
156
+ | `valid_triggers` | `List[str]` | Events to include in `HX-Trigger` on success. |
157
+ | `success_message` | `str` | Optional Django success message to queue. |
158
+
159
+ ---
160
+
161
+ ### HtmxListView
162
+
163
+ A drop-in replacement for Django's `ListView` that adds URL-driven filtering, sorting, and elided pagination:
164
+
165
+ ```python
166
+ from django.views.generic import ListView
167
+ from django_htmx_plus.views import HtmxListView
168
+ from myapp.models import Article
169
+
170
+ class ArticleListView(HtmxListView):
171
+ model = Article
172
+ template_name = "articles/list.html"
173
+ paginate_by = 20
174
+ target_id = "#article-table"
175
+
176
+ # Restrict filtering and sorting to these fields only
177
+ fields = ("id", "title", "status", "created_at")
178
+
179
+ # Optional custom column labels
180
+ labels = {
181
+ "created_at": "Date Created",
182
+ }
183
+ ```
184
+
185
+ #### URL-based filtering
186
+
187
+ Filters are expressed as `field_name.filter_type=value` query parameters:
188
+
189
+ | Filter key | Django lookup | Example |
190
+ |---|---|---|
191
+ | `eq` | `exact` | `status.eq=published` |
192
+ | `ieq` | `iexact` | `title.ieq=hello` |
193
+ | `gt` / `gte` / `lt` / `lte` | `gt` / `gte` / `lt` / `lte` | `views.gte=100` |
194
+ | `like` / `ilike` | `like` / `ilike` | `title.ilike=django` |
195
+ | `sw` / `isw` | `startswith` / `istartswith` | `title.sw=Django` |
196
+ | `ew` / `iew` | `endswith` / `iendswith` | `title.ew=plus` |
197
+ | `in` | `in` | `status.in=['draft','published']` |
198
+ | `nl` | `isnull` | `deleted_at.nl=True` |
199
+ | `rng` | `range` | `created_at.rng=['2024-01-01','2024-12-31']` |
200
+ | `sch` | `search` | `body.sch=htmx` |
201
+
202
+ Only fields listed in `fields` are accepted. Setting `fields = ("__all__",)` lifts the restriction.
203
+
204
+ #### Sorting
205
+
206
+ Add `order_by=field_name` (or `order_by=-field_name` for descending) to the query string. Only fields in `fields` are permitted.
207
+
208
+ #### Context variables
209
+
210
+ | Variable | Description |
211
+ |---|---|
212
+ | `query_params` | URL-encoded query string (without `page`). |
213
+ | `page_range` | Elided page range for pagination controls. |
214
+ | `order_by` | The currently active ordering field. |
215
+ | `path` | The current request path. |
216
+ | `target_id` | The HTMX target element ID. |
217
+ | `query` | Full query string including `order_by`. |
218
+ | `filter_query` | Query string containing only filter parameters. |
219
+ | `filters` | Template-ready dict mapping plain field names to their filter values. |
220
+ | `fields` | Dict with `keys` (field names) and `labels` (display names). |
221
+
222
+ ---
223
+
224
+ ### Cotton Table Components
225
+
226
+ `django-htmx-plus` ships a set of [django-cotton](https://github.com/wrabit/django-cotton) components for rendering sortable, paginated HTMX tables with Bootstrap 5.
227
+
228
+ #### `<c-tables.htmx_table />`
229
+
230
+ Renders a full table with auto-generated sortable headers, rows, and an optional pager:
231
+
232
+ ```html
233
+ {% load cotton_extras %}
234
+
235
+ <c-tables.htmx_table class="table table-striped" />
236
+ ```
237
+
238
+ The component uses the `fields`, `objects`, `order_by`, `path`, `filter_query`, `target_id`, `page_obj`, and `paginator` context variables provided automatically by `HtmxListView`.
239
+
240
+ #### `<c-tables.header_cell name="field_name" />`
241
+
242
+ Renders a single `<th>` with an `hx-get` attribute that toggles ascending/descending order and a chevron icon indicating the current sort direction:
243
+
244
+ ```html
245
+ <c-tables.head>
246
+ <c-tables.header_cell name="title">Title</c-tables.header_cell>
247
+ <c-tables.header_cell name="created_at">Date</c-tables.header_cell>
248
+ </c-tables.head>
249
+ ```
250
+
251
+ #### `<c-tables.pager />`
252
+
253
+ Renders a Bootstrap 5 pagination control with previous/next buttons and elided page numbers, all wired to `hx-get`.
254
+
255
+ #### Icon components
256
+
257
+ | Component | Description |
258
+ |---|---|
259
+ | `<c-icons.chevron_up />` | Up chevron (ascending sort indicator). |
260
+ | `<c-icons.chevron_down />` | Down chevron (descending sort indicator). |
261
+ | `<c-icons.chevron_left />` | Left chevron (previous page). |
262
+ | `<c-icons.chevron_right />` | Right chevron (next page). |
263
+
264
+ All icon components accept an optional `add_class` attribute to append extra CSS classes.
265
+
266
+ ---
267
+
268
+ ### Template filters
269
+
270
+ Load the `cotton_extras` tag library in any template:
271
+
272
+ ```html
273
+ {% load cotton_extras %}
274
+ ```
275
+
276
+ | Filter | Description | Example |
277
+ |---|---|---|
278
+ | `get_attr` | Get an attribute from an object by name. | `{{ item\|get_attr:"title" }}` |
279
+ | `get_key_value` | Get a value from a dict by key. | `{{ my_dict\|get_key_value:"name" }}` |
280
+
281
+ ---
282
+
283
+ ### Built-in JavaScript Helper
284
+
285
+ `django-htmx-plus` ships a small ES module (`django-htmx-plus.js`) that integrates Bootstrap 5 **Modals** and **Offcanvases** with HTMX swap events, so they open and close automatically based on HTMX responses.
286
+
287
+ #### How it works
288
+
289
+ | Behaviour | Trigger |
290
+ |---|---|
291
+ | Show a Modal or Offcanvas | HTMX swaps content into the element → `htmx:afterSwap` fires and calls `.show()`. |
292
+ | Hide a Modal or Offcanvas | HTMX receives an **empty** response targeting the element → `htmx:beforeSwap` fires, calls `.hide()`, and cancels the swap. |
293
+ | Reset Modal body | Bootstrap's `hidden.bs.modal` event fires → the modal body is cleared to `""`. |
294
+
295
+ #### Setup
296
+
297
+ Mark your Bootstrap Modal root elements with `data-htmx-plus-modal="<id>"` and your Offcanvas root elements with `data-htmx-plus-offcanvas="<id>"`, where id is the id of the element that will be swapped,:
298
+
299
+ ```html
300
+ <!-- Bootstrap Modal managed by django-htmx-plus -->
301
+ <div class="modal fade" data-htmx-plus-modal="dialog">
302
+ <div id="dialog" class="modal-dialog">
303
+ <!-- Modal content will be swapped here by HTMX and shown/hidden by django-htmx-plus.js -->
304
+ </div>
305
+ </div>
306
+
307
+ <!-- Bootstrap Offcanvas managed by django-htmx-plus -->
308
+ <div id="flyout" class="offcanvas offcanvas-end" data-htmx-plus-offcanvas="flyout">
309
+ <!-- Offcanvas content will be swapped here by HTMX and shown/hidden by django-htmx-plus.js -->
310
+ </div>
311
+ ```
312
+
313
+ Then point an HTMX element at the matching target ID:
314
+
315
+ ```html
316
+ <button hx-get="/person/add/" hx-target="#dialog">
317
+ Add Person
318
+ </button>
319
+ ```
320
+
321
+ - When the response has content the modal/offcanvas is shown automatically.
322
+ - When the server returns an empty `200` (or you use `HtmxResponse`) the modal/offcanvas is hidden automatically.
323
+
324
+ #### Including the script
325
+
326
+ Use the `{% htmx_plus_script %}` template tag to render the `<script>` tag. The script is loaded as an ES module and optionally forwards a CSP nonce if one is present in the template context:
327
+
328
+ ```html
329
+ {% load django_htmx_plus %}
330
+
331
+ <!-- Place near the bottom of your base template, after Bootstrap JS -->
332
+ {% htmx_plus_script %}
333
+ ```
334
+
335
+ This renders:
336
+
337
+ ```html
338
+ <script src="/static/django_htmx_plus/django-htmx-plus.js" type="module"></script>
339
+ ```
340
+
341
+ If a `nonce` variable is present in the template context it is automatically added as a `nonce="..."` attribute.
342
+
343
+ > **Note:** The script imports `Modal` and `Offcanvas` from `bootstrap`, so Bootstrap 5 must be available as an ES module (e.g. via an import map or a bundler). If you load Bootstrap as a plain global script instead, adjust your bundler or import map accordingly.
344
+
345
+ For example
346
+ ```html
347
+ <script type="importmap">
348
+ {
349
+ "imports": {
350
+ "@popperjs/core": "{% static '@popperjs/core/dist/esm/index.js' %}",
351
+ "bootstrap": "{% static 'bootstrap/js/index.esm.js' %}",
352
+ }
353
+ }
354
+ </script>
355
+ ```
356
+ ---
357
+
358
+ ## License
359
+
360
+ MIT — see [LICENSE](LICENSE) for details.
361
+
@@ -0,0 +1,24 @@
1
+ django_htmx_plus/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ django_htmx_plus/http.py,sha256=lzZ2PVE9k2qZtbuDZcPyeG8aLCqYmcb5QgNS6QhrHZo,1906
3
+ django_htmx_plus/middleware.py,sha256=m-pq12yzcWQFwLdPMk-MTxBGXIBx53CitMmZVhEbYno,2557
4
+ django_htmx_plus/mixins.py,sha256=ZT4C8CSj7uo91JXkYeyTriK_sV_bZrUrPFV1IZucgNU,1558
5
+ django_htmx_plus/static/django_htmx_plus/django-htmx-plus.js,sha256=ZOJzI2dhY9O-2QQ4PC6_MAB1KHg7poBOuTsPBXSA3cI,1084
6
+ django_htmx_plus/templates/cotton/icons/chevron_down.html,sha256=4NLlMPH7H4YG-8ZxQQ5Az7Q1YFL8BQJ7mOTMsqukVLo,376
7
+ django_htmx_plus/templates/cotton/icons/chevron_left.html,sha256=QvCAjjrYuq0e_6uxXMEUxZebimCqGrsKZddPCMGI9bk,377
8
+ django_htmx_plus/templates/cotton/icons/chevron_right.html,sha256=OFCzCoi_gG_Mr05EFxFg7IqMQMK0MP0RwjSsaViZlzA,377
9
+ django_htmx_plus/templates/cotton/icons/chevron_up.html,sha256=yiCe7xeunEPmWFyDv54A9NA4fA6U-EZFAXlQfSf57uE,375
10
+ django_htmx_plus/templates/cotton/tables/head.html,sha256=OtiKc5vgLegpe2oMiCsf0i6LyENd5mOrp2gRkFMQ0nM,46
11
+ django_htmx_plus/templates/cotton/tables/header_cell.html,sha256=XTAFnUCBzbxvk-qjC0foezyJE8bkW12xC5xkXVeo6ww,610
12
+ django_htmx_plus/templates/cotton/tables/htmx_table.html,sha256=j_ihhZtv9iS9Kj0NIifVsCZ2x1EkcxQlpd73QgBDcX4,559
13
+ django_htmx_plus/templates/cotton/tables/pager.html,sha256=yrlYxi0WBzwB6LzSceUzX7Wrm4TfeohrghWhn8x1l30,1576
14
+ django_htmx_plus/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ django_htmx_plus/templatetags/cotton_extras.py,sha256=J9WN6DYU6OUDFUixkcPsbS7FjR1F4BXpjO6jKFkJ0mU,364
16
+ django_htmx_plus/templatetags/django_htmx_plus.py,sha256=Jiif_JRJ0uUm6en04CVAWRWKY568L2WzeprH7jGRMGo,488
17
+ django_htmx_plus/types.py,sha256=H62ZyhUPHXB4bvHTOPYxtH9woiPgrL5pYzJqwCpAKXs,369
18
+ django_htmx_plus/utils.py,sha256=5lTLKdzyHAI4IBMz9SnKbWIhC1kvS72Ty0haI9KpQ5M,4597
19
+ django_htmx_plus/views.py,sha256=3d888TxRnlJpBzAUTD2otIpBv_yjV1ayX72Qgf7GpRg,6831
20
+ django_htmx_plus-0.1.0.dist-info/entry_points.txt,sha256=6wk1JJO2vDPZazpIf-Kx5Y1Mvh3VAYHGeAGJdz2JyIs,40
21
+ django_htmx_plus-0.1.0.dist-info/LICENSE.md,sha256=katsVWhESJx9CgbnTX1Cw3ZNNxgIWM58vSrx6rUqluE,1065
22
+ django_htmx_plus-0.1.0.dist-info/METADATA,sha256=0HHxqU0E0W9eZxMMdQXpgbjIYFZM-IK6QWg6SMMOp48,12519
23
+ django_htmx_plus-0.1.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
24
+ django_htmx_plus-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ tests=run_tests:main
3
+