diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 70ab4fa9..659f58ad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,3 +32,8 @@ repos: - id: pyupgrade args: [--py39-plus] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.14.0 + hooks: + - id: mypy + args: ["django_tables2/__init__.py"] diff --git a/django_tables2/__init__.py b/django_tables2/__init__.py index e747f92e..e039d921 100644 --- a/django_tables2/__init__.py +++ b/django_tables2/__init__.py @@ -9,7 +9,6 @@ JSONColumn, LinkColumn, ManyToManyColumn, - RelatedLinkColumn, TemplateColumn, TimeColumn, URLColumn, @@ -35,7 +34,6 @@ "JSONColumn", "LinkColumn", "ManyToManyColumn", - "RelatedLinkColumn", "TemplateColumn", "TimeColumn", "URLColumn", diff --git a/django_tables2/columns/__init__.py b/django_tables2/columns/__init__.py index 649d4478..306b432d 100644 --- a/django_tables2/columns/__init__.py +++ b/django_tables2/columns/__init__.py @@ -6,7 +6,7 @@ from .emailcolumn import EmailColumn from .filecolumn import FileColumn from .jsoncolumn import JSONColumn -from .linkcolumn import LinkColumn, RelatedLinkColumn +from .linkcolumn import LinkColumn from .manytomanycolumn import ManyToManyColumn from .templatecolumn import TemplateColumn from .timecolumn import TimeColumn @@ -26,7 +26,6 @@ "JSONColumn", "LinkColumn", "ManyToManyColumn", - "RelatedLinkColumn", "TemplateColumn", "URLColumn", "TimeColumn", diff --git a/django_tables2/columns/base.py b/django_tables2/columns/base.py index d8298fb3..805f618d 100644 --- a/django_tables2/columns/base.py +++ b/django_tables2/columns/base.py @@ -1,5 +1,6 @@ from collections import OrderedDict -from itertools import islice +from collections.abc import Callable, Iterator +from typing import TYPE_CHECKING, Any, Optional, TypedDict, Union from django.core.exceptions import ImproperlyConfigured from django.urls import reverse @@ -16,6 +17,23 @@ computed_values, ) +if TYPE_CHECKING: + from django.db.models import Field, QuerySet + from django.utils.safestring import SafeString + from typing_extensions import Unpack + + from ..rows import BoundRow + from ..tables import Table + + +class CellArguments(TypedDict): + value: "Union[SafeString | QuerySet]" + record: Any + column: "Column" + bound_column: "BoundColumn" + bound_row: "BoundRow" + table: "Table" + class Library: """A collection of columns.""" @@ -23,19 +41,14 @@ class Library: def __init__(self): self.columns = [] - def register(self, column): + def register(self, column: "type[Column]"): if not hasattr(column, "from_field"): raise ImproperlyConfigured(f"{column.__class__.__name__} is not a subclass of Column") self.columns.append(column) return column - def column_for_field(self, field, **kwargs): - """ - Return a column object suitable for model field. - - Returns: - `.Column` object or `None` - """ + def column_for_field(self, field: "Field", **kwargs) -> "Optional[Column]": + """Return a column object suitable for the supplied model field.""" if field is None: return self.columns[0](**kwargs) @@ -53,6 +66,7 @@ def column_for_field(self, field, **kwargs): if column is None: continue return column + return None # The library is a mechanism for announcing what columns are available. Its @@ -64,11 +78,17 @@ def column_for_field(self, field, **kwargs): class LinkTransform: """Object used to generate attributes for the ``-tag to wrap the cell content in.""" - viewname = None - accessor = None - attrs = None + viewname: Optional[str] = None + accessor: Optional[Accessor] = None + attrs: Optional[dict] = None - def __init__(self, url=None, accessor=None, attrs=None, reverse_args=None): + def __init__( + self, + url: Optional[Callable] = None, + accessor: Optional[Accessor] = None, + attrs: Optional[dict] = None, + reverse_args: Union[list, tuple, None] = None, + ): """ arguments: url (callable): If supplied, the result of this callable will be used as ``href`` attribute. @@ -90,12 +110,15 @@ def __init__(self, url=None, accessor=None, attrs=None, reverse_args=None): if isinstance(reverse_args, (list, tuple)): viewname, args = reverse_args - reverse_args = {"viewname": viewname} - reverse_args["kwargs" if isinstance(args, dict) else "args"] = args + self.reverse_args = { + "viewname": viewname, + "kwargs" if isinstance(args, dict) else "args": args, + } + else: - self.reverse_args = reverse_args or {} + self.reverse_args = reverse_args or {} - def compose_url(self, **kwargs): + def compose_url(self, **kwargs) -> str: if self.url and callable(self.url): return call_with_appropriate(self.url, kwargs) @@ -119,16 +142,13 @@ def compose_url(self, **kwargs): ) return context.get_absolute_url() - def call_reverse(self, record): - """ - Prepares the arguments to reverse() for this record and calls reverse() - """ + def call_reverse(self, record) -> str: + """Prepares the arguments to reverse() for this record and calls reverse().""" def resolve_if_accessor(val): return val.resolve(record) if isinstance(val, Accessor) else val params = self.reverse_args.copy() - params["viewname"] = resolve_if_accessor(params["viewname"]) if params.get("urlconf", None): params["urlconf"] = resolve_if_accessor(params["urlconf"]) @@ -250,12 +270,12 @@ class Blog(models.Model): .. [1] The provided callable object must not expect to receive any arguments. """ - # Tracks each time a Column instance is created. Used to retain order. + # Tracks each time a Column instance is created. Used to retain column order. creation_counter = 0 - empty_values = (None, "") + empty_values: tuple[Any, ...] = (None, "") # by default, contents are not wrapped in an -tag. - link = None + link: Optional[LinkTransform] = None # Explicit is set to True if the column is defined as an attribute of a # class, used to give explicit columns precedence. @@ -263,19 +283,19 @@ class Blog(models.Model): def __init__( self, - verbose_name=None, - accessor=None, - default=None, + verbose_name: Optional[str] = None, + accessor: Union[str, Accessor, Callable[..., str], None] = None, + default: Optional[str] = None, visible=True, orderable=None, attrs=None, order_by=None, empty_values=None, localize=None, - footer=None, - exclude_from_export=False, - linkify=False, - initial_sort_descending=False, + footer: Union[str, Callable[..., str], None] = None, + exclude_from_export: bool = False, + linkify: Union[bool, tuple[str, Union[list, tuple]], Callable[..., str]] = False, + initial_sort_descending: bool = False, ): if not (accessor is None or isinstance(accessor, str) or callable(accessor)): raise TypeError(f"accessor must be a string or callable, not {type(accessor).__name__}") @@ -299,8 +319,10 @@ def __init__( self.exclude_from_export = exclude_from_export link_kwargs = None - if callable(linkify) or hasattr(self, "get_url"): - link_kwargs = dict(url=linkify if callable(linkify) else self.get_url) + if callable(linkify): + link_kwargs = dict(url=linkify) + elif get_url := getattr(self, "get_url", None): + link_kwargs = dict(url=get_url) elif isinstance(linkify, (dict, tuple)): link_kwargs = dict(reverse_args=linkify) elif linkify is True: @@ -319,14 +341,12 @@ def default(self): return self._default() if callable(self._default) else self._default @property - def header(self): + def header(self) -> Optional[str]: """ The value used for the column heading (e.g. inside the ```` tag). By default this returns `~.Column.verbose_name`. - :returns: `unicode` or `None` - .. note:: This property typically is not accessed directly when a table is @@ -338,7 +358,7 @@ def header(self): """ return self.verbose_name - def footer(self, bound_column, table): + def footer(self, bound_column: "BoundColumn", table: "Table") -> Optional[str]: """Return the content of the footer, if specified.""" footer_kwargs = {"column": self, "bound_column": bound_column, "table": table} @@ -353,7 +373,7 @@ def footer(self, bound_column, table): return "" - def render(self, value): + def render(self, **kwargs: "Unpack[CellArguments]") -> "SafeString": """ Return the content for a specific cell. @@ -365,41 +385,36 @@ def render(self, value): Subclasses should set `.empty_values` to ``()`` if they want to handle all values in `.render`. """ - return value + return kwargs["value"] - def value(self, **kwargs): + def value(self, **kwargs: "Unpack[CellArguments]") -> Any: """ Return the content for a specific cell for exports. - Similar to `.render` but without any html content. + Similar to `.render` but without any HTML content. This can be used to get the data in the formatted as it is presented but in a - form that could be added to a csv file. + form that could be added to a CSV file. The default implementation just calls the `render` function but any - subclasses where `render` returns html content should override this + subclasses where `render` returns HTML content should override this method. See `LinkColumn` for an example. """ - value = call_with_appropriate(self.render, kwargs) + return call_with_appropriate(self.render, kwargs) - return value - - def order(self, queryset, is_descending): + def order(self, queryset: "QuerySet", is_descending: bool) -> "tuple[QuerySet, bool]": """ Order the QuerySet of the table. This method can be overridden by :ref:`table.order_FOO` methods on the table or by subclassing `.Column`; but only overrides if second element in return tuple is True. - - returns: - Tuple (QuerySet, boolean) """ return (queryset, False) @classmethod - def from_field(cls, field, **kwargs): + def from_field(cls, field: "Field", **kwargs) -> "Optional[Column]": """ Return a specialized column for the model field or `None`. @@ -418,6 +433,7 @@ def from_field(cls, field, **kwargs): # column if this class was asked directly. if cls is Column: return cls(**kwargs) + return None class BoundColumn: @@ -440,7 +456,14 @@ class SimpleTable(tables.Table): """ - def __init__(self, table, column, name): + _table: "Table" + column: Column + render: Callable + order: Callable + value: Callable + current_record: Any + + def __init__(self, table: "Table", column: Column, name: str): self._table = table self.column = column self.name = name @@ -452,11 +475,11 @@ def __init__(self, table, column, name): self.current_value = None - def __str__(self): + def __str__(self) -> str: return str(self.header) @property - def attrs(self): + def attrs(self) -> dict: """ Proxy to `.Column.attrs` but injects some values of our own. @@ -470,10 +493,9 @@ def attrs(self): kwargs = {"table": self._table, "bound_column": self} # BoundRow.items() sets current_record and current_value when iterating over # the records in a table. - if ( - getattr(self, "current_record", None) is not None - and getattr(self, "current_value", None) is not None - ): + current_record = getattr(self, "current_record", None) + current_value = getattr(self, "current_value", None) + if current_record is not None and current_value is not None: kwargs.update({"record": self.current_record, "value": self.current_value}) # Start with table's attrs; Only 'th' and 'td' attributes will be used @@ -636,18 +658,18 @@ def order_by_alias(self): return order_by @property - def is_ordered(self): + def is_ordered(self) -> bool: return self.name in (self._table.order_by or ()) @property - def orderable(self): + def orderable(self) -> bool: """Return whether this column supports ordering.""" if self.column.orderable is not None: return self.column.orderable return self._table.orderable @property - def verbose_name(self): + def verbose_name(self) -> "Union[str, SafeString]": """ Return the verbose name for this column. @@ -692,12 +714,12 @@ def verbose_name(self): return capfirst(name) @property - def visible(self): + def visible(self) -> bool: """Return whether this column is visible.""" return self.column.visible @property - def localize(self): + def localize(self) -> Optional[bool]: """Return `True`, `False` or `None` as described in ``Column.localize``""" return self.column.localize @@ -723,7 +745,7 @@ class BoundColumns: table (`.Table`): the table containing the columns """ - def __init__(self, table, base_columns): + def __init__(self, table: "Table", base_columns): self._table = table self.columns = OrderedDict() for name, column in base_columns.items(): @@ -735,23 +757,7 @@ def __init__(self, table, base_columns): ) bound_column.order = getattr(table, "order_" + name, column.order) - def iternames(self): - return (name for name, column in self.iteritems()) - - def names(self): - return list(self.iternames()) - - def iterall(self): - """ - Return an iterator that exposes all `.BoundColumn` objects, - regardless of visibility or sortability. - """ - return (column for name, column in self.iteritems()) - - def all(self): - return list(self.iterall()) - - def iteritems(self): + def items(self) -> Iterator[tuple[str, BoundColumn]]: """ Return an iterator of ``(name, column)`` pairs (where ``column`` is a `BoundColumn`). @@ -764,71 +770,52 @@ def iteritems(self): if name not in self._table.exclude: yield (name, self.columns[name]) - def items(self): - return list(self.iteritems()) + def names(self) -> list[str]: + return [name for name, column in self.items()] - def iterorderable(self): - """ - Same as `BoundColumns.all` but only returns orderable columns. + def all(self) -> list[BoundColumn]: + return [column for name, column in self.items()] - This is useful in templates, where iterating over the full - set and checking ``{% if column.ordarable %}`` can be problematic in - conjunction with e.g. ``{{ forloop.last }}`` (the last column might not - be the actual last that is rendered). + def orderable(self) -> list[BoundColumn]: """ - return (x for x in self.iterall() if x.orderable) + Return a list of orderable `.BoundColumn` objects. - def itervisible(self): + This is useful in templates, where iterating over the full set and checking ``{% if column.orderable %}`` can + be problematic in conjunction with e.g. ``{{ forloop.last }}`` (the last column might not be the actual last + that is rendered). """ - Same as `.iterorderable` but only returns visible `.BoundColumn` objects. + return [column for column in self.all() if column.orderable] - This is geared towards table rendering. - """ - return (x for x in self.iterall() if x.visible) + def visible(self) -> list[BoundColumn]: + """Return a list of visible `.BoundColumn` objects.""" + return [column for column in self.all() if column.visible] - def hide(self, name): - """ - Hide a column. + def __iter__(self) -> Iterator[BoundColumn]: + return iter(self.visible()) - Arguments: - name(str): name of the column - """ + def hide(self, name: str) -> None: + """Hide a column by name.""" self.columns[name].column.visible = False - def show(self, name): - """ - Show a column otherwise hidden. - - Arguments: - name(str): name of the column - """ + def show(self, name: str) -> None: + """Show a column otherwise hidden by name.""" self.columns[name].column.visible = True - def __iter__(self): - """Convenience API, alias of `.itervisible`.""" - return self.itervisible() - - def __contains__(self, item): - """ - Check if a column is contained within a `BoundColumns` object. - - *item* can either be a `~.BoundColumn` object, or the name of a column. - """ + def __contains__(self, item: Union[str, BoundColumn]) -> bool: + """Check if a column is contained within a `BoundColumns` object.""" if isinstance(item, str): - return item in self.iternames() - else: - # let's assume we were given a column - return item in self.iterall() + return item in self.names() + if isinstance(item, BoundColumn): + return item in self.all() + return TypeError("Argument type must be a string or a BoundColumn.") - def __len__(self): + def __len__(self) -> int: """Return how many `~.BoundColumn` objects are contained (and visible).""" - return len(list(self.itervisible())) + return len(self.visible()) - def __getitem__(self, index): + def __getitem__(self, index: Union[int, str]) -> BoundColumn: """ - Retrieve a specific `~.BoundColumn` object. - - *index* can either be 0-indexed or the name of a column + Retrieve a specific `~.BoundColumn` object by index or name. .. code-block:: python @@ -836,13 +823,10 @@ def __getitem__(self, index): columns[0] # returns the first column """ if isinstance(index, int): - try: - return next(islice(self.iterall(), index, index + 1)) - except StopIteration: - raise IndexError + return self.all()[index] elif isinstance(index, str): - for column in self.iterall(): - if column.name == index: + for name, column in self.items(): + if name == index: return column raise KeyError( f"Column with name '{index}' does not exist; choices are: {self.names()}" diff --git a/django_tables2/columns/booleancolumn.py b/django_tables2/columns/booleancolumn.py index d3bcbb28..0b311ed1 100644 --- a/django_tables2/columns/booleancolumn.py +++ b/django_tables2/columns/booleancolumn.py @@ -1,8 +1,15 @@ +from typing import TYPE_CHECKING, Any, Optional + from django.db import models from django.utils.html import escape, format_html from ..utils import AttributeDict -from .base import Column, library +from .base import BoundColumn, CellArguments, Column, library + +if TYPE_CHECKING: + from django.db.models import Field + from django.utils.safestring import SafeString + from typing_extensions import Unpack @library.register @@ -30,38 +37,40 @@ def __init__(self, null=False, yesno="✔,✘", **kwargs): kwargs["empty_values"] = () super().__init__(**kwargs) - def _get_bool_value(self, record, value, bound_column): + def _get_bool_value(self, record: Any, value: Any, bound_column: BoundColumn) -> bool: # If record is a model, we need to check if it has choices defined. if hasattr(record, "_meta"): field = bound_column.accessor.get_field(record) # If that's the case, we need to inverse lookup the value to convert # to a boolean we can use. - if hasattr(field, "choices") and field.choices is not None and len(field.choices) > 0: - value = next(val for val, name in field.choices if name == value) + choices = getattr(field, "choices", None) + if choices is not None and len(choices) > 0: + value = next(val for val, name in choices if name == value) - value = bool(value) - return value + return bool(value) - def render(self, value, record, bound_column): - value = self._get_bool_value(record, value, bound_column) + def render(self, **kwargs: "Unpack[CellArguments]") -> "SafeString": + value = self._get_bool_value(kwargs["record"], kwargs["value"], kwargs["bound_column"]) text = self.yesno[int(not value)] attrs = {"class": str(value).lower()} attrs.update(self.attrs.get("span", {})) return format_html("{}", AttributeDict(attrs).as_html(), escape(text)) - def value(self, record, value, bound_column): + def value(self, **kwargs: "Unpack[CellArguments]") -> Any: """ Returns the content for a specific cell similarly to `.render` however without any html content. """ - value = self._get_bool_value(record, value, bound_column) - return str(value) + return str(self._get_bool_value(kwargs["record"], kwargs["value"], kwargs["bound_column"])) @classmethod - def from_field(cls, field, **kwargs): - if isinstance(field, models.NullBooleanField): - return cls(null=True, **kwargs) + def from_field(cls, field: "Field", **kwargs) -> "Optional[BooleanColumn]": + if NullBooleanField := getattr(models, "NullBooleanField", None): + if isinstance(field, NullBooleanField): + return cls(null=True, **kwargs) if isinstance(field, models.BooleanField): return cls(null=getattr(field, "null", False), **kwargs) + + return None diff --git a/django_tables2/columns/checkboxcolumn.py b/django_tables2/columns/checkboxcolumn.py index caecb7f9..e353c6ac 100644 --- a/django_tables2/columns/checkboxcolumn.py +++ b/django_tables2/columns/checkboxcolumn.py @@ -1,4 +1,6 @@ -from django.utils.safestring import mark_safe +from typing import Any + +from django.utils.safestring import SafeString, mark_safe from django_tables2.utils import Accessor, AttributeDict, computed_values @@ -58,8 +60,9 @@ def header(self): attrs = AttributeDict(default, **(specific or general or {})) return mark_safe(f"") - def render(self, value, bound_column, record): - default = {"type": "checkbox", "name": bound_column.name, "value": value} + def render(self, value: Any, **kwargs) -> "SafeString": + record = kwargs["record"] + default = {"type": "checkbox", "name": kwargs["bound_column"].name, "value": value} if self.is_checked(value, record): default.update({"checked": "checked"}) diff --git a/django_tables2/columns/datecolumn.py b/django_tables2/columns/datecolumn.py index ef500115..d419d6cd 100644 --- a/django_tables2/columns/datecolumn.py +++ b/django_tables2/columns/datecolumn.py @@ -1,8 +1,13 @@ +from typing import TYPE_CHECKING, Optional + from django.db import models from .base import library from .templatecolumn import TemplateColumn +if TYPE_CHECKING: + from django.db.models import Field + @library.register class DateColumn(TemplateColumn): @@ -16,13 +21,14 @@ class DateColumn(TemplateColumn): ``SHORT_DATE_FORMAT`` setting, otherwise use ``DATE_FORMAT`` """ - def __init__(self, format=None, short=True, *args, **kwargs): + def __init__(self, format: Optional[str] = None, short: bool = True, *args, **kwargs): if format is None: format = "SHORT_DATE_FORMAT" if short else "DATE_FORMAT" - template = '{{ value|date:"%s"|default:default }}' % format - super().__init__(template_code=template, *args, **kwargs) + kwargs["template_code"] = '{{ value|date:"%s"|default:default }}' % format + super().__init__(*args, **kwargs) @classmethod - def from_field(cls, field, **kwargs): + def from_field(cls, field: "Field", **kwargs) -> "Optional[DateColumn]": if isinstance(field, models.DateField): return cls(**kwargs) + return None diff --git a/django_tables2/columns/datetimecolumn.py b/django_tables2/columns/datetimecolumn.py index 097735e5..ded35bb2 100644 --- a/django_tables2/columns/datetimecolumn.py +++ b/django_tables2/columns/datetimecolumn.py @@ -1,8 +1,13 @@ +from typing import TYPE_CHECKING, Optional + from django.db import models from .base import library from .templatecolumn import TemplateColumn +if TYPE_CHECKING: + from django.db.models.fields import Field + @library.register class DateTimeColumn(TemplateColumn): @@ -16,13 +21,14 @@ class DateTimeColumn(TemplateColumn): ``SHORT_DATETIME_FORMAT``, else ``DATETIME_FORMAT`` """ - def __init__(self, format=None, short=True, *args, **kwargs): + def __init__(self, format: Optional[str] = None, short: bool = True, *args, **kwargs): if format is None: format = "SHORT_DATETIME_FORMAT" if short else "DATETIME_FORMAT" - template = '{{ value|date:"%s"|default:default }}' % format - super().__init__(template_code=template, *args, **kwargs) + kwargs["template_code"] = '{{ value|date:"%s"|default:default }}' % format + super().__init__(*args, **kwargs) @classmethod - def from_field(cls, field, **kwargs): + def from_field(cls, field: "Field", **kwargs) -> "Optional[DateTimeColumn]": if isinstance(field, models.DateTimeField): return cls(**kwargs) + return None diff --git a/django_tables2/columns/emailcolumn.py b/django_tables2/columns/emailcolumn.py index 16561517..5d96e645 100644 --- a/django_tables2/columns/emailcolumn.py +++ b/django_tables2/columns/emailcolumn.py @@ -1,8 +1,13 @@ +from typing import TYPE_CHECKING, Optional + from django.db import models from .base import library from .linkcolumn import BaseLinkColumn +if TYPE_CHECKING: + from django.db.models import Field + @library.register class EmailColumn(BaseLinkColumn): @@ -31,10 +36,11 @@ class PeopleTable(tables.Table): # [...]email@example.com """ - def get_url(self, value): + def get_url(self, value) -> str: return f"mailto:{value}" @classmethod - def from_field(cls, field, **kwargs): + def from_field(cls, field: "Field", **kwargs) -> "Optional[EmailColumn]": if isinstance(field, models.EmailField): return cls(**kwargs) + return None diff --git a/django_tables2/columns/filecolumn.py b/django_tables2/columns/filecolumn.py index 930f6ad3..47a0787e 100644 --- a/django_tables2/columns/filecolumn.py +++ b/django_tables2/columns/filecolumn.py @@ -1,12 +1,17 @@ import os +from typing import TYPE_CHECKING, Any, Optional from django.db import models from django.utils.html import format_html +from django.utils.safestring import SafeString from ..utils import AttributeDict from .base import library from .linkcolumn import BaseLinkColumn +if TYPE_CHECKING: + from django.db.models import Field + @library.register class FileColumn(BaseLinkColumn): @@ -34,7 +39,7 @@ class FileColumn(BaseLinkColumn): the file's ``basename`` (default) """ - def __init__(self, verify_exists=True, **kwargs): + def __init__(self, verify_exists: bool = True, **kwargs): self.verify_exists = verify_exists super().__init__(**kwargs) @@ -50,13 +55,13 @@ def text_value(self, record, value): return os.path.basename(value.name) return super().text_value(record, value) - def render(self, record, value): + def render(self, value: Any, **kwargs) -> "SafeString": + record = kwargs["record"] attrs = AttributeDict(self.attrs.get("span", {})) classes = [c for c in attrs.get("class", "").split(" ") if c] exists = None - storage = getattr(value, "storage", None) - if storage: + if storage := getattr(value, "storage", None): # we'll assume value is a `django.db.models.fields.files.FieldFile` if self.verify_exists: exists = storage.exists(value.name) @@ -80,6 +85,7 @@ def render(self, record, value): ) @classmethod - def from_field(cls, field, **kwargs): + def from_field(cls, field: "Field", **kwargs) -> "Optional[FileColumn]": if isinstance(field, models.FileField): return cls(**kwargs) + return None diff --git a/django_tables2/columns/jsoncolumn.py b/django_tables2/columns/jsoncolumn.py index e2ae2bc5..68fd60ef 100644 --- a/django_tables2/columns/jsoncolumn.py +++ b/django_tables2/columns/jsoncolumn.py @@ -1,36 +1,32 @@ import json +from typing import TYPE_CHECKING, Optional from django.db.models import JSONField from django.utils.html import format_html +from django.utils.safestring import SafeString from ..utils import AttributeDict -from .base import library -from .linkcolumn import BaseLinkColumn +from .base import CellArguments, Column, library + +if TYPE_CHECKING: + from django.db.models import Field + from typing_extensions import Unpack try: from django.contrib.postgres.fields import HStoreField - - POSTGRES_AVAILABLE = True except ImportError: - # psycopg2 is not available, cannot import from django.contrib.postgres. - # JSONColumn might still be useful to add manually. - POSTGRES_AVAILABLE = False + # psycopg is not available, cannot import from django.contrib.postgres. + HStoreField = None # type: ignore @library.register -class JSONColumn(BaseLinkColumn): +class JSONColumn(Column): """ Render the contents of `~django.contrib.postgres.fields.JSONField` or `~django.contrib.postgres.fields.HStoreField` as an indented string. .. versionadded :: 1.5.0 - .. note:: - - Automatic rendering of data to this column requires PostgreSQL support - (psycopg2 installed) to import the fields, but this column can also be - used manually without it. - Arguments: json_dumps_kwargs: kwargs passed to `json.dumps`, defaults to `{'indent': 2}` attrs (dict): In addition to *attrs* keys supported by `~.Column`, the @@ -47,15 +43,15 @@ def __init__(self, json_dumps_kwargs=None, **kwargs): super().__init__(**kwargs) - def render(self, record, value): - return format_html( - "
{}
", - AttributeDict(self.attrs.get("pre", {})).as_html(), - json.dumps(value, **self.json_dumps_kwargs), - ) + def render(self, **kwargs: "Unpack[CellArguments]") -> "SafeString": + attributes = AttributeDict(self.attrs.get("pre", {})).as_html() + content = json.dumps(kwargs["value"], **self.json_dumps_kwargs) + return format_html("
{}
", attributes, content) @classmethod - def from_field(cls, field, **kwargs): - if POSTGRES_AVAILABLE: - if isinstance(field, (JSONField, HStoreField)): - return cls(**kwargs) + def from_field(cls, field: "Field", **kwargs) -> "Optional[JSONColumn]": + if isinstance(field, JSONField) or ( + HStoreField is not None and isinstance(field, HStoreField) + ): + return cls(**kwargs) + return None diff --git a/django_tables2/columns/linkcolumn.py b/django_tables2/columns/linkcolumn.py index adf58762..e4b16f19 100644 --- a/django_tables2/columns/linkcolumn.py +++ b/django_tables2/columns/linkcolumn.py @@ -1,4 +1,11 @@ -from .base import Column, library +from typing import TYPE_CHECKING, Any + +from django.utils.safestring import SafeString + +from .base import CellArguments, Column, library + +if TYPE_CHECKING: + from typing_extensions import Unpack class BaseLinkColumn(Column): @@ -19,20 +26,20 @@ def __init__(self, text=None, *args, **kwargs): super().__init__(*args, **kwargs) self.text = text - def text_value(self, record, value): + def text_value(self, record, value) -> SafeString: if self.text is None: return value return self.text(record) if callable(self.text) else self.text - def value(self, record, value): + def render(self, **kwargs: "Unpack[CellArguments]") -> SafeString: + return self.text_value(kwargs["record"], kwargs["value"]) + + def value(self, **kwargs: "Unpack[CellArguments]") -> Any: """ Returns the content for a specific cell similarly to `.render` however without any html content. """ - return self.text_value(record, value) - - def render(self, record, value): - return self.text_value(record, value) + return self.text_value(kwargs["record"], kwargs["value"]) @library.register @@ -143,37 +150,3 @@ def __init__( ), **extra ) - - -@library.register -class RelatedLinkColumn(LinkColumn): - """ - Render a link to a related object using related object's ``get_absolute_url``, - same parameters as ``~.LinkColumn``. - - .. note :: - - This column should not be used anymore, the `linkify` keyword argument to - regular columns can be used achieve the same results. - - If the related object does not have a method called ``get_absolute_url``, - or if it is not callable, the link will be rendered as '#'. - - Traversing relations is also supported, suppose a Person has a foreign key to - Country which in turn has a foreign key to Continent:: - - class PersonTable(tables.Table): - name = tables.Column() - country = tables.RelatedLinkColumn() - continent = tables.RelatedLinkColumn(accessor="country.continent") - - will render: - - - in column 'country', link to ``person.country.get_absolute_url()`` with the output of - ``str(person.country)`` as ```` contents. - - in column 'continent', a link to ``person.country.continent.get_absolute_url()`` with - the output of ``str(person.country.continent)`` as ```` contents. - - Alternative contents of ```` can be supplied using the ``text`` keyword argument as - documented for `~.columns.LinkColumn`. - """ diff --git a/django_tables2/columns/manytomanycolumn.py b/django_tables2/columns/manytomanycolumn.py index ba9c0240..1d1cfb88 100644 --- a/django_tables2/columns/manytomanycolumn.py +++ b/django_tables2/columns/manytomanycolumn.py @@ -1,9 +1,14 @@ +from typing import TYPE_CHECKING, Optional + from django.db import models from django.utils.encoding import force_str from django.utils.html import conditional_escape -from django.utils.safestring import mark_safe +from django.utils.safestring import SafeString, mark_safe + +from .base import CellArguments, Column, LinkTransform, library -from .base import Column, LinkTransform, library +if TYPE_CHECKING: + from typing_extensions import Unpack @library.register @@ -71,22 +76,22 @@ def __init__( if link_kwargs is not None: self.linkify_item = LinkTransform(attrs=self.attrs.get("a", {}), **link_kwargs) - def transform(self, obj): + def transform(self, obj: models.Model): """ Transform is applied to each item of the list of objects from the ManyToMany relation. """ return force_str(obj) - def filter(self, qs): + def filter(self, qs: models.QuerySet): """ Filter is called on the ManyRelatedManager to allow ordering, filtering or limiting on the set of related objects. """ return qs.all() - def render(self, value): + def render(self, **kwargs: "Unpack[CellArguments]") -> "SafeString": items = [] - for item in self.filter(value): + for item in self.filter(kwargs["value"]): content = conditional_escape(self.transform(item)) if hasattr(self, "linkify_item"): content = self.linkify_item(content=content, record=item) @@ -96,6 +101,7 @@ def render(self, value): return mark_safe(conditional_escape(self.separator).join(items)) @classmethod - def from_field(cls, field, **kwargs): + def from_field(cls, field, **kwargs) -> "Optional[ManyToManyColumn]": if isinstance(field, models.ManyToManyField): return cls(**kwargs) + return None diff --git a/django_tables2/columns/templatecolumn.py b/django_tables2/columns/templatecolumn.py index ddddc24a..efd31dc5 100644 --- a/django_tables2/columns/templatecolumn.py +++ b/django_tables2/columns/templatecolumn.py @@ -1,8 +1,16 @@ +from typing import TYPE_CHECKING, Any, Optional + from django.template import Context, Template from django.template.loader import get_template from django.utils.html import strip_tags +from django.utils.safestring import SafeString + +from django_tables2.rows import BoundRow + +from .base import BoundColumn, CellArguments, Column, library -from .base import Column, library +if TYPE_CHECKING: + from typing_extensions import Unpack @library.register @@ -40,7 +48,13 @@ class ExampleTable(tables.Table): empty_values = () - def __init__(self, template_code=None, template_name=None, extra_context=None, **extra): + def __init__( + self, + template_code: Optional[str] = None, + template_name: Optional[str] = None, + extra_context: Optional[dict] = None, + **extra + ): super().__init__(**extra) self.template_code = template_code self.template_name = template_name @@ -49,25 +63,30 @@ def __init__(self, template_code=None, template_name=None, extra_context=None, * if not self.template_code and not self.template_name: raise ValueError("A template must be provided") - def render(self, record, table, value, bound_column, **kwargs): + def render(self, **kwargs: "Unpack[CellArguments]") -> "SafeString": # If the table is being rendered using `render_table`, it hackily # attaches the context to the table as a gift to `TemplateColumn`. + table = kwargs["table"] context = getattr(table, "context", Context()) + bound_column: BoundColumn = kwargs["bound_column"] + bound_row: BoundRow = kwargs["bound_row"] additional_context = { "default": bound_column.default, "column": bound_column, - "record": record, - "value": value, - "row_counter": kwargs["bound_row"].row_counter, + "record": kwargs["record"], + "value": kwargs["value"], + "row_counter": bound_row.row_counter, } additional_context.update(self.extra_context) with context.update(additional_context): if self.template_code: return Template(self.template_code).render(context) - else: - return get_template(self.template_name).render(context.flatten()) + elif self.template_name: + dict_context: dict[Any, Any] = context.flatten() + return SafeString(get_template(self.template_name).render(dict_context)) + return SafeString("") - def value(self, **kwargs): + def value(self, **kwargs) -> Any: """ The value returned from a call to `value()` on a `TemplateColumn` is the rendered template with `django.utils.html.strip_tags` applied. diff --git a/django_tables2/columns/timecolumn.py b/django_tables2/columns/timecolumn.py index b4564fc2..76b9f290 100644 --- a/django_tables2/columns/timecolumn.py +++ b/django_tables2/columns/timecolumn.py @@ -1,8 +1,13 @@ +from typing import TYPE_CHECKING, Optional + from django.db import models from .base import library from .templatecolumn import TemplateColumn +if TYPE_CHECKING: + from django.db.models import Field + @library.register class TimeColumn(TemplateColumn): @@ -14,13 +19,14 @@ class TimeColumn(TemplateColumn): short (bool): if *format* is not specified, use Django's ``TIME_FORMAT`` setting. """ - def __init__(self, format=None, *args, **kwargs): + def __init__(self, format: Optional[str] = None, *args, **kwargs): if format is None: format = "TIME_FORMAT" - template = '{{ value|date:"%s"|default:default }}' % format - super().__init__(template_code=template, *args, **kwargs) + kwargs["template_code"] = '{{ value|date:"%s"|default:default }}' % format + super().__init__(*args, **kwargs) @classmethod - def from_field(cls, field, **kwargs): + def from_field(cls, field: "Field", **kwargs) -> "Optional[TimeColumn]": if isinstance(field, models.TimeField): return cls(**kwargs) + return None diff --git a/django_tables2/columns/urlcolumn.py b/django_tables2/columns/urlcolumn.py index 69a49777..2aab0d92 100644 --- a/django_tables2/columns/urlcolumn.py +++ b/django_tables2/columns/urlcolumn.py @@ -1,8 +1,13 @@ +from typing import TYPE_CHECKING, Optional + from django.db import models from .base import library from .linkcolumn import BaseLinkColumn +if TYPE_CHECKING: + from django.db.models.fields import Field + @library.register class URLColumn(BaseLinkColumn): @@ -24,10 +29,11 @@ class URLColumn(BaseLinkColumn): 'http://google.com' """ - def get_url(self, value): + def get_url(self, value: str) -> str: return value @classmethod - def from_field(cls, field, **kwargs): + def from_field(cls, field: "Field", **kwargs) -> "Optional[URLColumn]": if isinstance(field, models.URLField): return cls(**kwargs) + return None diff --git a/django_tables2/config.py b/django_tables2/config.py index 097149fc..1dbcb781 100644 --- a/django_tables2/config.py +++ b/django_tables2/config.py @@ -1,5 +1,12 @@ +from typing import TYPE_CHECKING, Union + from django.core.paginator import EmptyPage, PageNotAnInteger +if TYPE_CHECKING: + from django.http import HttpRequest + + from .tables import Table + class RequestConfig: """ @@ -26,11 +33,11 @@ class RequestConfig: """ - def __init__(self, request, paginate=True): + def __init__(self, request: "HttpRequest", paginate: Union[bool, dict, tuple, list] = True): self.request = request self.paginate = paginate - def configure(self, table): + def configure(self, table: "Table"): """ Configure a table using information from the request. @@ -43,7 +50,7 @@ def configure(self, table): if order_by: table.order_by = order_by if self.paginate: - if hasattr(self.paginate, "items"): + if isinstance(self.paginate, (dict, tuple, list)): kwargs = dict(self.paginate) else: kwargs = {} diff --git a/django_tables2/data.py b/django_tables2/data.py index c4900c63..d49c9382 100644 --- a/django_tables2/data.py +++ b/django_tables2/data.py @@ -167,8 +167,7 @@ def set_table(self, table): @property def ordering(self): """ - Returns the list of order by aliases that are enforcing ordering on the - data. + Returns the list of order by aliases that are enforcing ordering on the data. If the data is unordered, an empty sequence is returned. If the ordering can not be determined, `None` is returned. @@ -185,7 +184,7 @@ def ordering(self): except StopIteration: pass - def order_by(self, aliases): + def order_by(self, aliases: OrderByTuple): """ Order the data based on order by aliases (prefixed column names) in the table. diff --git a/django_tables2/export/export.py b/django_tables2/export/export.py index 49bda338..191f93fb 100644 --- a/django_tables2/export/export.py +++ b/django_tables2/export/export.py @@ -1,6 +1,11 @@ +from typing import TYPE_CHECKING, Optional, Union + from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse +if TYPE_CHECKING: + from ..tables import Table + try: from tablib import Dataset except ImportError: # pragma: no cover @@ -51,10 +56,12 @@ def __init__(self, export_format, table, exclude_columns=None, dataset_kwargs=No self.format = export_format self.dataset = self.table_to_dataset(table, exclude_columns, dataset_kwargs) - def table_to_dataset(self, table, exclude_columns, dataset_kwargs=None): + def table_to_dataset( + self, table: "Table", exclude_columns: list[str], dataset_kwargs: "Optional[dict]" = None + ) -> Dataset: """Transform a table to a tablib dataset.""" - def default_dataset_title(): + def default_dataset_title() -> str: try: return table.Meta.model._meta.verbose_name_plural.title() except AttributeError: @@ -71,25 +78,21 @@ def default_dataset_title(): return dataset @classmethod - def is_valid_format(self, export_format): + def is_valid_format(self, export_format: str) -> bool: """ Returns true if `export_format` is one of the supported export formats """ return export_format is not None and export_format in TableExport.FORMATS.keys() - def content_type(self): - """ - Returns the content type for the current export format - """ + def content_type(self) -> str: + """Return the content type for the current export format.""" return self.FORMATS[self.format] - def export(self): - """ - Returns the string/bytes for the current export format - """ + def export(self) -> Union[str, bytes]: + """Return the string/bytes for the current export format.""" return self.dataset.export(self.format) - def response(self, filename=None): + def response(self, filename: Optional[str] = None) -> HttpResponse: """ Builds and returns a `HttpResponse` containing the exported data diff --git a/django_tables2/export/views.py b/django_tables2/export/views.py index bb434f2e..808403be 100644 --- a/django_tables2/export/views.py +++ b/django_tables2/export/views.py @@ -1,7 +1,12 @@ +from typing import Any, Optional + +from django.http import HttpResponse +from django.views.generic.base import TemplateResponseMixin + from .export import TableExport -class ExportMixin: +class ExportMixin(TemplateResponseMixin): """ Support various export formats for the table data. @@ -24,7 +29,7 @@ class Table(tables.Table): export_formats (iterable): export formats to render a set of buttons in the template. dataset_kwargs (dictionary): passed as `**kwargs` to `tablib.Dataset` constructor:: - dataset_kwargs = {"tite": "My custom tab title"} + dataset_kwargs = {"title": "My custom tab title"} """ export_class = TableExport @@ -35,13 +40,13 @@ class Table(tables.Table): export_formats = (TableExport.CSV,) - def get_export_filename(self, export_format): + def get_export_filename(self, export_format: str) -> str: return f"{self.export_name}.{export_format}" - def get_dataset_kwargs(self): + def get_dataset_kwargs(self) -> Optional[dict[str, Any]]: return self.dataset_kwargs - def create_export(self, export_format): + def create_export(self, export_format: str) -> HttpResponse: exporter = self.export_class( export_format=export_format, table=self.get_table(**self.get_table_kwargs()), @@ -51,9 +56,9 @@ def create_export(self, export_format): return exporter.response(filename=self.get_export_filename(export_format)) - def render_to_response(self, context, **kwargs): + def render_to_response(self, context: dict, **kwargs) -> HttpResponse: export_format = self.request.GET.get(self.export_trigger_param, None) - if self.export_class.is_valid_format(export_format): + if export_format and self.export_class.is_valid_format(export_format): return self.create_export(export_format) return super().render_to_response(context, **kwargs) diff --git a/django_tables2/paginators.py b/django_tables2/paginators.py index 46ad7146..9610dd3d 100644 --- a/django_tables2/paginators.py +++ b/django_tables2/paginators.py @@ -1,3 +1,5 @@ +from typing import Union + from django.core.paginator import EmptyPage, Page, PageNotAnInteger, Paginator from django.utils.translation import gettext as _ @@ -62,7 +64,7 @@ def __init__(self, object_list, per_page, look_ahead=None, **kwargs): super().__init__(object_list, per_page, **kwargs) - def validate_number(self, number): + def validate_number(self, number: Union[int, float, str]) -> int: """Validate the given 1-based page number.""" try: if isinstance(number, float) and not number.is_integer(): @@ -74,7 +76,7 @@ def validate_number(self, number): raise EmptyPage(_("That page number is less than 1")) return number - def page(self, number): + def page(self, number: Union[int, str]) -> Page: # Number might be None, because the total number of pages is not known in this paginator. # If an unknown page is requested, serve the first page. number = self.validate_number(number or 1) @@ -98,20 +100,17 @@ def page(self, number): self._final_num_pages = number return Page(objects, number, self) - def is_last_page(self, number): + def is_last_page(self, number: Union[float, int]) -> bool: return number == self._final_num_pages - def _get_count(self): + @property + def count(self) -> int: raise NotImplementedError - count = property(_get_count) - - def _get_num_pages(self): + @property + def num_pages(self) -> int: return self._num_pages - num_pages = property(_get_num_pages) - - def _get_page_range(self): + @property + def page_range(self) -> range: raise NotImplementedError - - page_range = property(_get_page_range) diff --git a/django_tables2/rows.py b/django_tables2/rows.py index de5d0c1c..f3ffb2bc 100644 --- a/django_tables2/rows.py +++ b/django_tables2/rows.py @@ -1,15 +1,19 @@ +from typing import TYPE_CHECKING, Any + from django.core.exceptions import FieldDoesNotExist from django.db import models +from .columns.base import BoundColumn, CellArguments from .columns.linkcolumn import BaseLinkColumn from .columns.manytomanycolumn import ManyToManyColumn from .utils import A, AttributeDict, call_with_appropriate, computed_values +if TYPE_CHECKING: + from .tables import Table + class CellAccessor: - """ - Allows accessing cell contents on a row object (see `BoundRow`) - """ + """Allows accessing cell contents on a row object (see `BoundRow`).""" def __init__(self, row): self.row = row @@ -80,31 +84,26 @@ class BoundRow: """ - def __init__(self, record, table): + def __init__(self, record: Any, table: "Table"): self._record = record self._table = table self.row_counter = next(table._counter) - # support accessing cells from a template: {{ row.cells.column_name }} + # Support accessing cells from a template: {{ row.cells.column_name }} self.cells = CellAccessor(self) @property - def table(self): + def table(self) -> "Table": """The `.Table` this row is part of.""" return self._table - def get_even_odd_css_class(self): - """ - Return css class, alternating for odd and even records. - - Return: - string: `even` for even records, `odd` otherwise. - """ + def get_even_odd_css_class(self) -> str: + """Return "odd" and "even" depending on the row counter.""" return "odd" if self.row_counter % 2 else "even" @property - def attrs(self): + def attrs(self) -> AttributeDict: """Return the attributes for a certain row.""" cssClass = self.get_even_odd_css_class() @@ -120,7 +119,7 @@ def attrs(self): return AttributeDict(row_attrs) @property - def record(self): + def record(self) -> Any: """The data record from the data source which is used to populate this row with data.""" return self._record @@ -136,7 +135,7 @@ def __iter__(self): # is correct – it's what __getitem__ expects. yield value - def _get_and_render_with(self, bound_column, render_func, default): + def _get_and_render_with(self, bound_column: BoundColumn, render_func, default): value = None accessor = A(bound_column.accessor) column = bound_column.column @@ -172,20 +171,20 @@ def _get_and_render_with(self, bound_column, render_func, default): return render_func(bound_column, value) - def _optional_cell_arguments(self, bound_column, value): + def _optional_cell_arguments(self, bound_column: "BoundRow", value: Any) -> CellArguments: """ Defines the arguments that will optionally be passed while calling the cell's rendering or value getter if that function has one of these as a keyword argument. """ - return { - "value": value, - "record": self.record, - "column": bound_column.column, - "bound_column": bound_column, - "bound_row": self, - "table": self._table, - } + return CellArguments( + value=value, + record=self.record, + column=bound_column.column, + bound_column=bound_column, + bound_row=self, + table=self._table, + ) def get_cell(self, name): """ diff --git a/django_tables2/tables.py b/django_tables2/tables.py index 10dea6e6..d40fbd14 100644 --- a/django_tables2/tables.py +++ b/django_tables2/tables.py @@ -1,6 +1,7 @@ import copy from collections import OrderedDict from itertools import count +from typing import TYPE_CHECKING, Optional from django.conf import settings from django.core.paginator import Paginator @@ -14,6 +15,11 @@ from .rows import BoundRows from .utils import Accessor, AttributeDict, OrderBy, OrderByTuple, Sequence +if TYPE_CHECKING: + from django.http import HttpRequest + + from .columns.base import BoundColumn + class DeclarativeColumnsMetaclass(type): """ @@ -252,6 +258,10 @@ class Table(metaclass=DeclarativeColumnsMetaclass): with `name` will be removed from the table. """ + request: "Optional[HttpRequest]" + Meta: TableOptions + _meta: TableOptions + def __init__( self, data=None, @@ -489,7 +499,7 @@ def value_name(self, value): columns = [ column - for column in self.columns.iterall() + for column in self.columns.all() if not (column.column.exclude_from_export or column.name in exclude_columns) ] @@ -500,7 +510,7 @@ def value_name(self, value): force_str(row.get_cell_value(column.name), strings_only=True) for column in columns ] - def has_footer(self): + def has_footer(self) -> bool: """ Returns True if any of the columns define a ``_footer`` attribute or a ``render_footer()`` method @@ -508,11 +518,11 @@ def has_footer(self): return self.show_footer and any(column.has_footer() for column in self.columns) @property - def show_header(self): + def show_header(self) -> bool: return self._show_header if self._show_header is not None else self._meta.show_header @show_header.setter - def show_header(self, value): + def show_header(self, value: bool) -> None: self._show_header = value @property @@ -548,18 +558,25 @@ def order_by_field(self): ) @order_by_field.setter - def order_by_field(self, value): + def order_by_field(self, value: str) -> None: self._order_by_field = value @property - def page_field(self): + def page_field(self) -> str: return self._page_field if self._page_field is not None else self._meta.page_field @page_field.setter - def page_field(self, value): + def page_field(self, value: str) -> None: self._page_field = value - def paginate(self, paginator_class=Paginator, per_page=None, page=1, *args, **kwargs): + def paginate( + self, + paginator_class: type[Paginator] = Paginator, + per_page: Optional[int] = None, + page: int = 1, + *args, + **kwargs, + ): """ Paginates the table using a paginator and creates a ``page`` property containing information for the current page. @@ -595,23 +612,23 @@ def per_page_field(self, value): self._per_page_field = value @property - def prefix(self): + def prefix(self) -> str: return self._prefix if self._prefix is not None else self._meta.prefix @prefix.setter - def prefix(self, value): + def prefix(self, value: str) -> None: self._prefix = value @property - def prefixed_order_by_field(self): + def prefixed_order_by_field(self) -> str: return f"{self.prefix}{self.order_by_field}" @property - def prefixed_page_field(self): + def prefixed_page_field(self) -> str: return f"{self.prefix}{self.page_field}" @property - def prefixed_per_page_field(self): + def prefixed_per_page_field(self) -> str: return f"{self.prefix}{self.per_page_field}" @property @@ -626,25 +643,25 @@ def sequence(self, value): self._sequence = value @property - def orderable(self): + def orderable(self) -> bool: if self._orderable is not None: return self._orderable else: return self._meta.orderable @orderable.setter - def orderable(self, value): + def orderable(self, value: Optional[bool]): self._orderable = value @property - def template_name(self): + def template_name(self) -> str: if self._template is not None: return self._template else: return self._meta.template_name @template_name.setter - def template_name(self, value): + def template_name(self, value: str): self._template = value @property @@ -656,7 +673,7 @@ def paginated_rows(self): return self.page.object_list return self.rows - def get_column_class_names(self, classes_set, bound_column): + def get_column_class_names(self, classes_set, bound_column: "BoundColumn"): """ Returns a set of HTML class names for cells (both ``td`` and ``th``) of a **bound column** in this table. diff --git a/django_tables2/utils.py b/django_tables2/utils.py index 33efabc9..82d27b0b 100644 --- a/django_tables2/utils.py +++ b/django_tables2/utils.py @@ -1,13 +1,18 @@ import inspect import warnings from collections import OrderedDict +from collections.abc import Callable from functools import total_ordering from itertools import chain +from typing import TYPE_CHECKING, Any, Optional, Union from django.core.exceptions import FieldDoesNotExist from django.db import models from django.utils.html import format_html_join +if TYPE_CHECKING: + from django.contrib.contenttypes.fields import GenericForeignKey + class Sequence(list): """ @@ -286,8 +291,9 @@ def opposite(self): class Accessor(str): """ - A string describing a path from one object to another via attribute/index - accesses. For convenience, the class has an alias `.A` to allow for more concise code. + A string describing a path from one object to another via attribute/key/index accesses. + + For convenience, the class is aliased as `.A` to allow for more concise code. Relations are separated by a ``__`` character. @@ -303,12 +309,22 @@ class Accessor(str): "Failed lookup for key [{key}] in {context}, when resolving the accessor {accessor}" ) - def __init__(self, value, callable_args=None, callable_kwargs=None): + def __init__( + self, + value: "Union[str, Callable, Accessor]", + callable_args: Optional[list[Callable]] = None, + callable_kwargs: Optional[dict[str, Callable]] = None, + ): self.callable_args = callable_args or getattr(value, "callable_args", None) or [] self.callable_kwargs = callable_kwargs or getattr(value, "callable_kwargs", None) or {} super().__init__() - def __new__(cls, value, callable_args=None, callable_kwargs=None): + def __new__( + cls, + value, + callable_args: Optional[list[Callable]] = None, + callable_kwargs: Optional[dict[str, Callable]] = None, + ): instance = super().__new__(cls, value) if cls.LEGACY_SEPARATOR in value: instance.SEPARATOR = cls.LEGACY_SEPARATOR @@ -322,7 +338,7 @@ def __new__(cls, value, callable_args=None, callable_kwargs=None): return instance - def resolve(self, context, safe=True, quiet=False): + def resolve(self, context: Any, safe: bool = True, quiet: bool = False): """ Return an object described by the accessor by traversing the attributes of *context*. @@ -416,12 +432,14 @@ def bits(self): return () return self.split(self.SEPARATOR) - def get_field(self, model): + def get_field( + self, model: models.Model + ) -> "Union[models.Field[Any, Any], models.ForeignObjectRel, GenericForeignKey, None]": """ Return the django model field for model in context, following relations. """ if not hasattr(model, "_meta"): - return + return None field = None for bit in self.bits: @@ -433,10 +451,9 @@ def get_field(self, model): if hasattr(field, "remote_field"): rel = getattr(field, "remote_field", None) model = getattr(rel, "model", model) - return field - def penultimate(self, context, quiet=True): + def penultimate(self, context, quiet: bool = True) -> tuple[Any, str]: """ Split the accessor on the right-most separator ('__'), return a tuple with: - the resolved left part. @@ -530,8 +547,10 @@ def segment(sequence, aliases): yield tuple([valias]) -def signature(fn): +def signature(fn: Callable) -> tuple[tuple[Any, ...], Optional[str]]: """ + Return argument names and the name of the kwargs catch all. + Returns: tuple: Returns a (arguments, kwarg_name)-tuple: - the arguments (positional or keyword) @@ -555,9 +574,9 @@ def signature(fn): return tuple(args), keywords -def call_with_appropriate(fn, kwargs): +def call_with_appropriate(fn: Callable, kwargs: dict[str, Any]): """ - Calls the function ``fn`` with the keyword arguments from ``kwargs`` it expects + Calls the function ``fn`` with the keyword arguments from ``kwargs`` it expects. If the kwargs argument is defined, pass all arguments, else provide exactly the arguments wanted. @@ -577,9 +596,9 @@ def call_with_appropriate(fn, kwargs): return fn(**kwargs) -def computed_values(d, kwargs=None): +def computed_values(d: dict[str, Any], kwargs=None) -> dict[str, Any]: """ - Returns a new `dict` that has callable values replaced with the return values. + Returns a new `dict` that has callable values replaced with their return values. Example:: diff --git a/django_tables2/views.py b/django_tables2/views.py index b972c0a0..4f457fbd 100644 --- a/django_tables2/views.py +++ b/django_tables2/views.py @@ -1,14 +1,19 @@ from itertools import count -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Optional, Union from django.core.exceptions import ImproperlyConfigured +from django.views.generic.base import ContextMixin from django.views.generic.list import ListView from . import tables from .config import RequestConfig +if TYPE_CHECKING: + from .data import TableData + from .tables import Table -class TableMixinBase: + +class TableMixinBase(ContextMixin): """ Base mixin for the Single- and MultiTable class based views. """ @@ -16,13 +21,13 @@ class TableMixinBase: context_table_name = "table" table_pagination = None - def get_context_table_name(self, table): + def get_context_table_name(self, table: "Table") -> str: """ Get the name to use for the table's template variable. """ return self.context_table_name - def get_table_pagination(self, table): + def get_table_pagination(self, table: "Table") -> Union[dict[str, Any], bool]: """ Return pagination options passed to `.RequestConfig`: - True for standard pagination (default), @@ -45,11 +50,12 @@ def get_table_pagination(self, table): if paginate_by is not None: paginate["per_page"] = paginate_by - if hasattr(self, "paginator_class"): - paginate["paginator_class"] = self.paginator_class + if paginator_class := getattr(self, "paginator_class", None): + paginate["paginator_class"] = paginator_class - if getattr(self, "paginate_orphans", 0) != 0: - paginate["orphans"] = self.paginate_orphans + paginate_orphans = getattr(self, "paginate_orphans", 0) + if paginate_orphans != 0: + paginate["orphans"] = paginate_orphans # table_pagination overrides any MultipleObjectMixin attributes if self.table_pagination: @@ -61,7 +67,7 @@ def get_table_pagination(self, table): return paginate - def get_paginate_by(self, table_data) -> Optional[int]: + def get_paginate_by(self, table_data: "TableData") -> Optional[int]: """ Determines the number of items per page, or ``None`` for no pagination. @@ -130,8 +136,8 @@ def get_table_data(self): """ if self.table_data is not None: return self.table_data - elif hasattr(self, "object_list"): - return self.object_list + elif object_list := getattr(self, "object_list", None): + return object_list elif hasattr(self, "get_queryset"): return self.get_queryset() diff --git a/docs/pages/api-reference.rst b/docs/pages/api-reference.rst index d87ade8c..e17a6139 100644 --- a/docs/pages/api-reference.rst +++ b/docs/pages/api-reference.rst @@ -323,12 +323,6 @@ Columns .. autoclass:: django_tables2.columns.ManyToManyColumn :members: -`.RelatedLinkColumn` -~~~~~~~~~~~~~~~~~~~~ - -.. autoclass:: django_tables2.columns.RelatedLinkColumn - :members: - `.TemplateColumn` ~~~~~~~~~~~~~~~~~ diff --git a/docs/pages/builtin-columns.rst b/docs/pages/builtin-columns.rst index fabde589..1c74025d 100644 --- a/docs/pages/builtin-columns.rst +++ b/docs/pages/builtin-columns.rst @@ -15,7 +15,6 @@ For common use-cases the following columns are included: - `.JSONColumn` -- renders JSON as an indented string in ``
``
 - `.LinkColumn` -- renders ```` tags (compose a Django URL)
 - `.ManyToManyColumn` -- renders a list objects from a `ManyToManyField`
-- `.RelatedLinkColumn` -- renders ```` tags linking related objects
 - `.TemplateColumn` -- renders template code
 - `.TimeColumn` -- time formatting
 - `.URLColumn` -- renders ```` tags (absolute URL)
diff --git a/example/app/tables.py b/example/app/tables.py
index 01f58686..d138ef22 100644
--- a/example/app/tables.py
+++ b/example/app/tables.py
@@ -73,7 +73,7 @@ class Meta:
 
 
 class SemanticTable(tables.Table):
-    country = tables.RelatedLinkColumn()
+    country = tables.Column(linkify=True)
 
     class Meta:
         model = Person
diff --git a/pyproject.toml b/pyproject.toml
index aa4949aa..3b925b95 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,2 +1,8 @@
 [tool.black]
 line-length = 100
+
+[tool.mypy]
+python_version = "3.9"
+strict = false
+ignore_missing_imports = true
+
diff --git a/tests/columns/test_jsoncolumn.py b/tests/columns/test_jsoncolumn.py
index 6a063562..38a4d43f 100644
--- a/tests/columns/test_jsoncolumn.py
+++ b/tests/columns/test_jsoncolumn.py
@@ -26,26 +26,26 @@ def test_jsoncolumn_attrs(self):
         column = tables.JSONColumn(attrs={"pre": {"class": "json"}})
 
         record = {"json": "foo"}
-        html = column.render(value=record["json"], record=record)
+        html = column.render(value=record["json"])
         self.assertEqual(html, '
"foo"
') def test_jsoncolumn_dict(self): column = tables.JSONColumn() record = {"json": {"species": "Falcon"}} - html = column.render(value=record["json"], record=record) + html = column.render(value=record["json"]) self.assertEqual(html, "
{\n  "species": "Falcon"\n}
") def test_jsoncolumn_string(self): column = tables.JSONColumn() record = {"json": "really?"} - html = column.render(value=record["json"], record=record) + html = column.render(value=record["json"]) self.assertEqual(html, "
"really?"
") def test_jsoncolumn_number(self): column = tables.JSONColumn() record = {"json": 3.14} - html = column.render(value=record["json"], record=record) + html = column.render(value=record["json"]) self.assertEqual(html, "
3.14
") diff --git a/tests/columns/test_linkcolumn.py b/tests/columns/test_linkcolumn.py index 01d3da83..a4acf543 100644 --- a/tests/columns/test_linkcolumn.py +++ b/tests/columns/test_linkcolumn.py @@ -6,7 +6,7 @@ import django_tables2 as tables from django_tables2 import A -from ..app.models import Occupation, Person +from ..app.models import Person from ..utils import attrs, build_request @@ -200,29 +200,6 @@ class Table(tables.Table): with self.assertRaisesMessage(TypeError, message): table.as_html(build_request()) - def test_RelatedLinkColumn(self): - carpenter = Occupation.objects.create(name="Carpenter") - Person.objects.create(first_name="Bob", last_name="Builder", occupation=carpenter) - - class Table(tables.Table): - occupation = tables.RelatedLinkColumn() - occupation_linkify = tables.Column(accessor="occupation", linkify=True) - - table = Table(Person.objects.all()) - - url = reverse("occupation", args=[carpenter.pk]) - self.assertEqual(table.rows[0].cells["occupation"], f'
Carpenter') - - def test_RelatedLinkColumn_without_model(self): - class Table(tables.Table): - occupation = tables.RelatedLinkColumn() - - table = Table([{"occupation": "Fabricator"}]) - - message = "for linkify=True, 'Fabricator' must have a method get_absolute_url" - with self.assertRaisesMessage(TypeError, message): - table.rows[0].cells["occupation"] - def test_value_returns_a_raw_value_without_html(self): class Table(tables.Table): col = tables.LinkColumn("occupation", args=(A("id"),)) diff --git a/tests/test_extra_columns.py b/tests/test_extra_columns.py index 015c285d..5257b0c9 100644 --- a/tests/test_extra_columns.py +++ b/tests/test_extra_columns.py @@ -181,7 +181,7 @@ def __init__(self, data, *args, **kwargs): if add_occupation_column: kwargs["extra_columns"].append( - ("occupation", tables.RelatedLinkColumn(orderable=False)) + ("occupation", tables.Column(linkify=True, orderable=False)) ) super().__init__(data, *args, **kwargs)