Source code for ferrosoft.request

"""
Query string parsing with PHP/Rails/jQuery bracket-notation support.

.. deprecated:: 2.0.0
    **Use of this module is strongly discouraged.**  New code should use
    Django's built-in form machinery (:class:`~django.forms.Form`,
    :class:`~django.forms.ModelForm`) together with standard
    :class:`~django.http.QueryDict` access patterns.  Django forms provide
    validation, error reporting, rendering, and CSRF protection that this
    module does not.

This module was written to handle query strings that use the bracket-notation
convention popularised by PHP, Ruby on Rails, and jQuery's ``$.param()``:

* ``foo=bar`` → ``{"foo": "bar"}`` (scalar)
* ``items[]=1&items[]=2`` → ``{"items": ["1", "2"]}`` (list)
* ``addr[city]=Berlin&addr[zip]=10115`` → ``{"addr": {"city": "Berlin", "zip": "10115"}}`` (dict)

It also provides :func:`is_htmx_request` for detecting
`HTMX <https://htmx.org>`_ driven requests, and :func:`sanitize_uuid` /
:func:`sanitize_decimal` converters used by the :class:`Form` class.

The :class:`Form` class mimics the ``django.forms.Form`` API at a surface
level (``is_valid()`` / ``cleaned_data``) but lacks Django's validation
pipeline, localisation support, and widget rendering.

Migration guide
---------------
Replace :class:`Form` subclasses with :class:`~django.forms.Form` subclasses
and :func:`restructure_query` calls with direct ``request.GET``/``request.POST``
access or a DRF serializer.  Replace :func:`is_htmx_request` with
``django_htmx`` or an inline header check.
"""

from decimal import Decimal
from enum import Enum
import re
from typing import Any, Callable, List, Mapping
import uuid

from django.http import HttpRequest, QueryDict


SanitizerMap = Mapping[str, Callable[[str], Any]]


[docs] class QueryFieldType(Enum): """Classifies a bracket-notation query string key by its expected value shape. Determines how values are aggregated when :func:`restructure_query` processes a :class:`~django.http.QueryDict`. Attributes: SCALAR: A plain key with no brackets. Only a single value is stored. Even if the key contains brackets that make it *look* subscriptable the field type is still ``SCALAR`` when no brackets are present. LIST: A key ending in ``[]``. All values for this key are collected into a list. DICT: A key with a non-empty subscript, e.g. ``addr[city]``. Values are stored in a dict keyed by the subscript string. """ SCALAR = 1 LIST = 2 DICT = 3
[docs] class FormField: """Descriptor that declares a field on a :class:`Form` subclass. Stores arbitrary keyword arguments and exposes them via ``__getitem__``. The special key ``"sanitizer"`` is allowed to be absent (returns ``None``). .. deprecated:: Use :class:`~django.forms.Field` and its subclasses instead. Args: **kwargs: Field configuration, typically ``sanitizer``. """ _allow_none = ["sanitizer"] def __init__(self, **kwargs): self._fields = kwargs def __getitem__(self, key): if key not in self._fields: if key in self._allow_none: return None else: raise ValueError("%s must be set" % key) return self._fields[key] def __str__(self): return str(self._fields)
[docs] class DecimalField(FormField): """A :class:`FormField` that sanitizes values with :func:`sanitize_decimal`. .. deprecated:: Use :class:`~django.forms.DecimalField` instead. """ def __init__(self, **kwargs): super().__init__( sanitizer=sanitize_decimal, **kwargs, )
[docs] class IntegerField(DecimalField): """A :class:`DecimalField` alias for integer inputs. .. deprecated:: Use :class:`~django.forms.IntegerField` instead. """ pass
[docs] class UUIDField(FormField): """A :class:`FormField` that sanitizes values with :func:`sanitize_uuid`. .. deprecated:: Use :class:`~django.forms.UUIDField` instead. Args: **kwargs: Passed through to :class:`FormField`. """ def __init__(self, **kwargs): super().__init__( sanitizer=sanitize_uuid, **kwargs, )
[docs] class Form: """Minimal Django-form-like class for parsing bracket-notation query strings. .. deprecated:: Use :class:`~django.forms.Form` instead. At construction time, all :class:`FormField` class attributes are discovered by reflection. Their ``sanitizer`` callables are collected into a map that is passed to :func:`restructure_query` when :meth:`is_valid` is called. Args: query: The :class:`~django.http.QueryDict` to parse (typically ``request.GET`` or ``request.POST``). """ def __init__(self, query: QueryDict): sanitizers = {} for attr_name in dir(self): if attr_name.startswith("__"): continue attr_value = getattr(self, attr_name) if isinstance(attr_value, FormField): sanitize_fn = attr_value["sanitizer"] if sanitize_fn is not None: sanitizers[attr_name] = sanitize_fn self.query = query self.sanitizers = sanitizers self.cleaned_data = {}
[docs] def is_valid(self) -> bool: """Parse and sanitize the query string. Populates :attr:`cleaned_data` on success. Returns: ``True`` if :func:`restructure_query` succeeds; ``False`` if it raises :exc:`ValueError` (malformed key or type conflict). """ try: self.cleaned_data = restructure_query(self.query, self.sanitizers) except ValueError: return False return True
[docs] def is_htmx_request(request: HttpRequest) -> bool: """Return ``True`` if the request was issued by the HTMX JavaScript library. HTMX sets the ``HX-Request: true`` header on every request it makes. This can be used to return partial HTML fragments instead of full pages. Args: request: The incoming Django HTTP request. Returns: ``True`` if the ``HX-Request`` header is present and equals ``"true"``; ``False`` otherwise. """ return request.headers.get("HX-Request", "") == "true"
[docs] def sanitize_uuid(input: str) -> uuid.UUID: """Parse a UUID from a string, raising on invalid input. Args: input: A string in any UUID format accepted by :class:`uuid.UUID`. Returns: The parsed :class:`uuid.UUID`. Raises: ValueError: If ``input`` is not a valid UUID representation. """ return uuid.UUID(input)
[docs] def sanitize_decimal(input: str) -> Decimal: """Parse a :class:`~decimal.Decimal` from a string, treating empty input as zero. Args: input: A numeric string, or ``""`` which is interpreted as ``"0"``. Returns: The parsed :class:`~decimal.Decimal`. """ return Decimal("0" if input == "" else input)
[docs] def sanitize_list(input: List[str], sanitizer): """Apply a sanitizer function to every element of a list. Args: input: List of raw string values. sanitizer: A callable that converts a single string to the desired type. Returns: A new list with ``sanitizer`` applied to each element. """ result = [] for item in input: result.append(sanitizer(item)) return result
[docs] def restructure_query( query: QueryDict, sanitizers: SanitizerMap = None ) -> Mapping[str, Any]: """Convert a :class:`~django.http.QueryDict` to a nested Python structure. Follows the PHP / Ruby on Rails / jQuery ``$.param()`` bracket-notation convention (lightly): * Plain keys (no brackets) are stored as scalars. * Keys ending with ``[]`` are collected into a list. * Keys with a named subscript (e.g. ``addr[city]``) are stored in a dict. If a sanitizer is provided for a root key, it is applied to every value for that key before storage. Args: query: The :class:`~django.http.QueryDict` to parse (e.g. ``request.GET``). sanitizers: Optional mapping from root key name to a callable that converts a raw string value to the desired type. Keys without a sanitizer entry are stored as raw strings. Returns: A :class:`dict` with scalar, list, or dict values depending on the key notation used. Raises: ValueError: If a key does not match the expected pattern, if a list field carries more than one value while typed as scalar, or if the same root key is used with conflicting types (e.g. first as a scalar then as a dict). Example:: # Query string: foo=bar&items[]=1&items[]=2&addr[city]=Berlin result = restructure_query(request.GET) # {"foo": "bar", "items": ["1", "2"], "addr": {"city": "Berlin"}} """ result = {} for query_key, query_values in query.lists(): root_key, field_type, subscript = _parse_query_key(query_key) if len(query_values) == 0: raise ValueError("query values is empty") elif len(query_values) > 1 and field_type != QueryFieldType.LIST: raise ValueError("only list field can store more than one value") _ensure_uniform_field_type(root_key, field_type, result) sanitizers = sanitizers or {} sanitizer = sanitizers[root_key] if root_key in sanitizers else None sanitized_values = [] for val in query_values: sanitized_values.append(sanitizer(val) if sanitizer is not None else val) match field_type: case QueryFieldType.SCALAR: result[root_key] = sanitized_values[0] case QueryFieldType.LIST: result[root_key] = sanitized_values case QueryFieldType.DICT: if root_key not in result: result[root_key] = {} result[root_key][subscript] = sanitized_values[0] return result
#: Compiled regex that parses a bracket-notation query key into #: (root_key, optional_brackets, optional_subscript). _key_pattern = re.compile("^([a-zA-Z_]+)(\\[(.*)\\])?$") def _parse_query_key(raw_key: str): """Parse a bracket-notation query key into its components. Args: raw_key: A raw query string key such as ``"foo"``, ``"items[]"``, or ``"addr[city]"``. Returns: A 3-tuple of ``(root_key, field_type, subscript)`` where ``subscript`` is the string inside the brackets, or ``None`` for scalar and list fields. Raises: ValueError: If ``raw_key`` does not match :data:`_key_pattern`. RuntimeError: For bracket patterns that are syntactically valid but semantically ambiguous (should not occur in practice). """ match = _key_pattern.match(raw_key) if match is None: raise ValueError("query key does not match {}".format(_key_pattern.pattern)) root_key, brackets, subscript = match.groups() if brackets is None: field_type = QueryFieldType.SCALAR elif brackets == "[]": field_type = QueryFieldType.LIST elif subscript != "": field_type = QueryFieldType.DICT else: raise RuntimeError("should not happen") return root_key, field_type, subscript def _ensure_uniform_field_type(root_key: str, field_type: QueryFieldType, result: dict): """Raise :exc:`ValueError` if a root key is used with a conflicting field type. Prevents a single root key from being treated as both a scalar and a list, or as both a list and a dict, within the same query string. Args: root_key: The root portion of the query key (without brackets). field_type: The :class:`QueryFieldType` inferred from the current key. result: The result dict accumulated so far. Raises: ValueError: If ``root_key`` is already present in ``result`` with an incompatible Python type. """ if root_key in result: is_dict = isinstance(result[root_key], dict) is_list = isinstance(result[root_key], list) if field_type == QueryFieldType.SCALAR and (is_dict or is_list): raise ValueError("refusing to overwrite dict or list with scalar value") elif field_type == QueryFieldType.LIST and not is_list: raise ValueError("refusing to overwrite non-list value with list value") elif field_type == QueryFieldType.DICT and not is_dict: raise ValueError("refusing to overwrite non-dict value with dict value")