diff --git a/lektor_groupby/config.py b/lektor_groupby/config.py index 85a831d..d507247 100644 --- a/lektor_groupby/config.py +++ b/lektor_groupby/config.py @@ -2,8 +2,9 @@ from inifile import IniFile from lektor.environment import Expression from lektor.context import Context from lektor.utils import slugify as _slugify -from typing import TYPE_CHECKING -from typing import Set, Dict, Optional, Union, Any, List, Generator +from typing import ( + TYPE_CHECKING, Set, Dict, Optional, Union, Any, List, Generator +) from .util import split_strip if TYPE_CHECKING: from lektor.sourceobj import SourceObject @@ -44,14 +45,14 @@ class Config: slug: Optional[str] = None, # default: "{attr}/{group}/index.html" template: Optional[str] = None, # default: "groupby-{attr}.html" replace_none_key: Optional[str] = None, # default: None - key_map_fn: Optional[str] = None, # default: None + key_obj_fn: Optional[str] = None, # default: None ) -> None: self.key = key self.root = (root or '/').rstrip('/') or '/' self.slug = slug or (key + '/{key}/') # key = GroupBySource.key self.template = template or f'groupby-{self.key}.html' self.replace_none_key = replace_none_key - self.key_map_fn = key_map_fn + self.key_obj_fn = key_obj_fn # editable after init self.enabled = True self.dependencies = set() # type: Set[str] @@ -98,7 +99,7 @@ class Config: def __repr__(self) -> str: txt = ' Any: + def eval_key_obj_fn(self, *, on: 'SourceObject', context: Dict) -> Any: ''' - If `key_map_fn` is set, evaluate field expression. - Note: The function does not check whether `key_map_fn` is set. + If `key_obj_fn` is set, evaluate field expression. + Note: The function does not check whether `key_obj_fn` is set. Return: A Generator result is automatically unpacked into a list. ''' - exp = self._make_expression(self.key_map_fn, on=on, field='key_map_fn') + exp = self._make_expression(self.key_obj_fn, on=on, field='key_obj_fn') with Context(pad=on.pad) as ctx: with ctx.gather_dependencies(self.dependencies.add): res = exp.evaluate(on.pad, this=on, alt=on.alt, values=context) diff --git a/lektor_groupby/resolver.py b/lektor_groupby/resolver.py index e8bde5f..43e3800 100644 --- a/lektor_groupby/resolver.py +++ b/lektor_groupby/resolver.py @@ -1,5 +1,7 @@ from lektor.db import Page # isinstance -from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Iterable +from typing import ( + TYPE_CHECKING, NamedTuple, Dict, List, Any, Optional, Iterable +) from .util import build_url from .vobj import VPATH, GroupBySource if TYPE_CHECKING: @@ -9,16 +11,16 @@ if TYPE_CHECKING: class ResolverEntry(NamedTuple): - slug: str - group: str + key: str + key_obj: Any config: 'Config' page: Optional[int] def equals( - self, path: str, attribute: str, group: str, page: Optional[int] + self, path: str, conf_key: str, vobj_key: str, page: Optional[int] ) -> bool: - return self.slug == group \ - and self.config.key == attribute \ + return self.key == vobj_key \ + and self.config.key == conf_key \ and self.config.root == path \ and self.page == page @@ -53,9 +55,9 @@ class Resolver: def add(self, vobj: GroupBySource) -> None: ''' Track new virtual object (only if slug is set). ''' if vobj.slug: - # page_num = 1 overwrites page_num = None -> same url_path() + # `page_num = 1` overwrites `page_num = None` -> same url_path() self._data[vobj.url_path] = ResolverEntry( - vobj.key, vobj.group, vobj.config, vobj.page_num) + vobj.key, vobj.key_obj, vobj.config, vobj.page_num) # ------------ # Resolver @@ -68,15 +70,16 @@ class Resolver: rv = self._data.get(build_url([node.url_path] + pieces)) if rv: return GroupBySource( - node, rv.slug, rv.page).finalize(rv.config, rv.group) + node, rv.key, rv.page).finalize(rv.config, rv.key_obj) return None def resolve_virtual_path(self, node: 'SourceObject', pieces: List[str]) \ -> Optional[GroupBySource]: ''' Admin UI only: Prevent server error and null-redirect. ''' + # format: /path/to/page@groupby/{config-key}/{vobj-key}/{page-num} if isinstance(node, Page) and len(pieces) >= 2: path = node['_path'] # type: str - attr, grp, *optional_page = pieces + conf_key, vobj_key, *optional_page = pieces page = None if optional_page: try: @@ -84,7 +87,7 @@ class Resolver: except ValueError: pass for rv in self._data.values(): - if rv.equals(path, attr, grp, page): + if rv.equals(path, conf_key, vobj_key, page): return GroupBySource( - node, rv.slug, rv.page).finalize(rv.config, rv.group) + node, rv.key, rv.page).finalize(rv.config, rv.key_obj) return None diff --git a/lektor_groupby/vobj.py b/lektor_groupby/vobj.py index 16ae905..8756fa8 100644 --- a/lektor_groupby/vobj.py +++ b/lektor_groupby/vobj.py @@ -3,8 +3,9 @@ from lektor.context import get_ctx from lektor.db import _CmpHelper from lektor.environment import Expression from lektor.sourceobj import VirtualSourceObject # subclass -from typing import TYPE_CHECKING -from typing import List, Any, Dict, Optional, Generator, Iterator, Iterable +from typing import ( + TYPE_CHECKING, List, Any, Dict, Optional, Generator, Iterator, Iterable +) from .pagination import PaginationConfig from .query import FixedRecordsQuery from .util import most_used_key, insert_before_ext, build_url, cached_property @@ -25,31 +26,32 @@ class GroupBySource(VirtualSourceObject): ''' Holds information for a single group/cluster. This object is accessible in your template file. - Attributes: record, key, group, slug, children, config + Attributes: record, key, key_obj, slug, children, config ''' def __init__( self, record: 'Record', - slug: str, + key: str, page_num: Optional[int] = None ) -> None: super().__init__(record) - self.key = slug - self.page_num = page_num - self._expr_fields = {} # type: Dict[str, Expression] self.__children = [] # type: List[str] - self.__group_map = [] # type: List[Any] + self.__key_obj_map = [] # type: List[Any] + self._expr_fields = {} # type: Dict[str, Expression] + self.key = key + self.page_num = page_num - def append_child(self, child: 'Record', group: Any) -> None: + def append_child(self, child: 'Record', key_obj: Any) -> None: if child not in self.__children: self.__children.append(child.path) - # TODO: rename group to value - # __group_map is later used to find most used group - self.__group_map.append(group) + # __key_obj_map is later used to find most used key_obj + self.__key_obj_map.append(key_obj) def _update_attr(self, key: str, value: Any) -> None: ''' Set or remove Jinja evaluated Expression field. ''' + # TODO: instead we could evaluate the fields only once. + # But then we need to record_dependency() every successive access if isinstance(value, Expression): self._expr_fields[key] = value try: @@ -65,22 +67,22 @@ class GroupBySource(VirtualSourceObject): # Evaluate Extra Fields # ------------------------- - def finalize(self, config: 'Config', group: Optional[Any] = None) \ + def finalize(self, config: 'Config', key_obj: Optional[Any] = None) \ -> 'GroupBySource': self.config = config # make a sorted children query self._query = FixedRecordsQuery(self.pad, self.__children, self.alt) self._query._order_by = config.order_by del self.__children - # set group name - self.group = group or most_used_key(self.__group_map) - del self.__group_map + # set indexed original value (can be: str, int, float, bool, obj) + self.key_obj = key_obj or most_used_key(self.__key_obj_map) + del self.__key_obj_map # evaluate slug Expression self.slug = config.eval_slug(self.key, on=self) if self.slug and self.slug.endswith('/index.html'): self.slug = self.slug[:-10] - if group: # exit early if initialized through resolver + if key_obj: # exit early if initialized through resolver return self # extra fields for attr in config.fields: @@ -202,15 +204,15 @@ class GroupBySource(VirtualSourceObject): raise AttributeError def __lt__(self, other: 'GroupBySource') -> bool: - # Used for |sort filter ("group" is the provided original string) - if isinstance(self.group, (bool, int, float)) and \ - isinstance(other.group, (bool, int, float)): - return self.group < other.group - if self.group is None: - return False - if other.group is None: + # Used for |sort filter (`key_obj` is the indexed original value) + if isinstance(self.key_obj, (bool, int, float)) and \ + isinstance(other.key_obj, (bool, int, float)): + return self.key_obj < other.key_obj + if self.key_obj is None: + return False # this will sort None at the end + if other.key_obj is None: return True - return str(self.group).lower() < str(other.group).lower() + return str(self.key_obj).lower() < str(other.key_obj).lower() def __eq__(self, other: object) -> bool: # Used for |unique filter diff --git a/lektor_groupby/watcher.py b/lektor_groupby/watcher.py index ec3cca1..5982a23 100644 --- a/lektor_groupby/watcher.py +++ b/lektor_groupby/watcher.py @@ -1,5 +1,7 @@ -from typing import TYPE_CHECKING, Dict, List, Any, Union, NamedTuple -from typing import Optional, Callable, Iterator, Generator +from typing import ( + TYPE_CHECKING, Dict, List, Any, Union, NamedTuple, + Optional, Callable, Iterator, Generator +) from .backref import VGroups from .model import ModelReader from .vobj import GroupBySource @@ -24,7 +26,7 @@ GroupingCallback = Callable[[GroupByCallbackArgs], Union[ class Watcher: ''' Callback is called with (Record, FieldKeyPath, field-value). - Callback may yield one or more (group, extra-info) tuples. + Callback may yield 0-n objects. ''' def __init__(self, config: 'Config') -> None: @@ -37,7 +39,7 @@ class Watcher: Decorator to subscribe to attrib-elements. If flatten = False, dont explode FlowType. - (record, field-key, field) -> (group, extra-info) + (record, field-key, field) -> value ''' def _decorator(fn: GroupingCallback) -> None: self.flatten = flatten @@ -68,25 +70,25 @@ class Watcher: args = GroupByCallbackArgs(record, key, field) _gen = self.callback(args) try: - group = next(_gen) + key_obj = next(_gen) while True: - if self.config.key_map_fn: - slug = self._persist_multiple(args, group) + if self.config.key_obj_fn: + slug = self._persist_multiple(args, key_obj) else: - slug = self._persist(args, group) - # return slugified group key and continue iteration + slug = self._persist(args, key_obj) + # return slugified key and continue iteration if isinstance(_gen, Generator) and not _gen.gi_yieldfrom: - group = _gen.send(slug) + key_obj = _gen.send(slug) else: - group = next(_gen) + key_obj = next(_gen) except StopIteration: del _gen def _persist_multiple(self, args: 'GroupByCallbackArgs', obj: Any) \ -> Optional[str]: # if custom key mapping function defined, use that first - res = self.config.eval_key_map_fn(on=args.record, - context={'X': obj, 'SRC': args}) + res = self.config.eval_key_obj_fn(on=args.record, + context={'X': obj, 'ARGS': args}) if isinstance(res, (list, tuple)): for k in res: self._persist(args, k) # 1-to-n replacement