refactoring II (watcher config + dependency mgmt)
This commit is contained in:
@@ -1,18 +1,20 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from lektor.build_programs import BuildProgram
|
from lektor.build_programs import BuildProgram
|
||||||
from lektor.builder import Artifact, Builder # typing
|
from lektor.builder import Artifact, Builder, PathCache # typing
|
||||||
from lektor.constants import PRIMARY_ALT
|
from lektor.context import get_ctx
|
||||||
from lektor.db import Database, Record # typing
|
from lektor.db import Database, Record # typing
|
||||||
from lektor.pluginsystem import Plugin
|
from lektor.environment import Expression
|
||||||
from lektor.reporter import reporter
|
from lektor.pluginsystem import Plugin, IniFile
|
||||||
|
from lektor.reporter import reporter, style
|
||||||
from lektor.sourceobj import SourceObject, VirtualSourceObject
|
from lektor.sourceobj import SourceObject, VirtualSourceObject
|
||||||
from lektor.types.flow import Flow, FlowType
|
from lektor.types.flow import Flow, FlowType
|
||||||
from lektor.utils import bool_from_string, build_url, prune_file_and_folder
|
from lektor.utils import bool_from_string, build_url, prune_file_and_folder
|
||||||
# for quick config
|
# for quick config
|
||||||
from lektor.utils import slugify
|
from lektor.utils import slugify
|
||||||
|
|
||||||
from typing import Tuple, Dict, Set, List, NamedTuple
|
from typing import Tuple, Dict, Set, List, Union, Any, NamedTuple
|
||||||
from typing import NewType, Optional, Iterator, Callable, Iterable
|
from typing import NewType, Optional, Iterable, Callable, Iterator, Generator
|
||||||
|
from weakref import WeakSet
|
||||||
|
|
||||||
VPATH = '@groupby' # potentially unsafe. All matching entries are pruned.
|
VPATH = '@groupby' # potentially unsafe. All matching entries are pruned.
|
||||||
|
|
||||||
@@ -20,17 +22,10 @@ VPATH = '@groupby' # potentially unsafe. All matching entries are pruned.
|
|||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
# Typing
|
# Typing
|
||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
AttributeKey = NewType('AttributeKey', str) # attribute of lektor model
|
SelectionKey = NewType('SelectionKey', str) # attribute of lektor model
|
||||||
GroupKey = NewType('GroupKey', str) # key of group-by
|
GroupKey = NewType('GroupKey', str) # key of group-by
|
||||||
|
|
||||||
|
|
||||||
class ResolverConf(NamedTuple):
|
|
||||||
path: str
|
|
||||||
attrib: AttributeKey
|
|
||||||
group: GroupKey
|
|
||||||
slug: str
|
|
||||||
|
|
||||||
|
|
||||||
class FieldKeyPath(NamedTuple):
|
class FieldKeyPath(NamedTuple):
|
||||||
fieldKey: str
|
fieldKey: str
|
||||||
flowIndex: Optional[int] = None
|
flowIndex: Optional[int] = None
|
||||||
@@ -43,8 +38,88 @@ class GroupByCallbackArgs(NamedTuple):
|
|||||||
field: object # lektor model data-field value
|
field: object # lektor model data-field value
|
||||||
|
|
||||||
|
|
||||||
GroupingCallback = Callable[[GroupByCallbackArgs],
|
GroupByCallbackYield = Union[GroupKey, Tuple[GroupKey, object]]
|
||||||
Iterator[Tuple[GroupKey, object]]]
|
|
||||||
|
GroupingCallback = Callable[[GroupByCallbackArgs], Union[
|
||||||
|
Iterator[GroupByCallbackYield],
|
||||||
|
Generator[GroupByCallbackYield, Optional[str], None],
|
||||||
|
]]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------
|
||||||
|
# Config
|
||||||
|
# -----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class GroupByConfig:
|
||||||
|
'''
|
||||||
|
Holds information for GroupByWatcher and GroupBySource.
|
||||||
|
This object is accessible in your template file ({{this.config}}).
|
||||||
|
|
||||||
|
Available attributes:
|
||||||
|
key, root, slug, template, enabled, dependencies, fields, key_map
|
||||||
|
'''
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
key: SelectionKey, *,
|
||||||
|
root: Optional[str] = None, # default: "/"
|
||||||
|
slug: Optional[str] = None, # default: "{attr}/{group}/index.html"
|
||||||
|
template: Optional[str] = None, # default: "groupby-{attr}.html"
|
||||||
|
) -> None:
|
||||||
|
self.key = key
|
||||||
|
self.root = (root or '/').rstrip('/') + '/'
|
||||||
|
self.slug = slug or f'"{key}/" ~ this.key ~ "/"' # this: GroupBySource
|
||||||
|
self.template = template or f'groupby-{self.key}.html'
|
||||||
|
# editable after init
|
||||||
|
self.enabled = True
|
||||||
|
self.dependencies = set() # type: Set[str]
|
||||||
|
self.fields = {} # type: Dict[str, str]
|
||||||
|
self.key_map = {} # type: Dict[str, str]
|
||||||
|
|
||||||
|
def slugify(self, k: str) -> str:
|
||||||
|
''' key_map replace and slugify. '''
|
||||||
|
return slugify(self.key_map.get(k, k)) # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
def set_fields(self, fields: Optional[Dict[str, str]]) -> None:
|
||||||
|
'''
|
||||||
|
The fields dict is a mapping of attrib = Expression values.
|
||||||
|
Each dict key will be added to the GroupBySource virtual object.
|
||||||
|
Each dict value is passed through jinja context first.
|
||||||
|
'''
|
||||||
|
self.fields = fields or {}
|
||||||
|
|
||||||
|
def set_key_map(self, key_map: Optional[Dict[str, str]]) -> None:
|
||||||
|
''' This mapping replaces group keys before slugify. '''
|
||||||
|
self.key_map = key_map or {}
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
txt = '<GroupByConfig'
|
||||||
|
for x in ['key', 'root', 'slug', 'template', 'dependencies']:
|
||||||
|
txt += ' {}="{}"'.format(x, getattr(self, x))
|
||||||
|
txt += f' fields="{", ".join(self.fields)}"'
|
||||||
|
return txt + '>'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(key: SelectionKey, cfg: Dict[str, str]) -> 'GroupByConfig':
|
||||||
|
''' Set config fields manually. Only: key, root, slug, template. '''
|
||||||
|
return GroupByConfig(
|
||||||
|
key=key,
|
||||||
|
root=cfg.get('root'),
|
||||||
|
slug=cfg.get('slug'),
|
||||||
|
template=cfg.get('template'),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_ini(key: SelectionKey, ini: IniFile) -> 'GroupByConfig':
|
||||||
|
''' Read and parse ini file. Also adds dependency tracking. '''
|
||||||
|
cfg = ini.section_as_dict(key) # type: Dict[str, str]
|
||||||
|
conf = GroupByConfig.from_dict(key, cfg)
|
||||||
|
conf.enabled = ini.get_bool(key + '.enabled', True)
|
||||||
|
conf.dependencies.add(ini.filename)
|
||||||
|
conf.set_fields(ini.section_as_dict(key + '.fields'))
|
||||||
|
conf.set_key_map(ini.section_as_dict(key + '.key_map'))
|
||||||
|
return conf
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
@@ -56,65 +131,140 @@ class GroupBySource(VirtualSourceObject):
|
|||||||
'''
|
'''
|
||||||
Holds information for a single group/cluster.
|
Holds information for a single group/cluster.
|
||||||
This object is accessible in your template file.
|
This object is accessible in your template file.
|
||||||
Attributes: record, attrib, group, slug, template, children
|
Attributes: record, key, group, slug, children, config
|
||||||
|
|
||||||
:DEFAULTS:
|
|
||||||
slug: "{attrib}/{group}/index.html"
|
|
||||||
template: "groupby-attribute.html"
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
record: Record,
|
record: Record,
|
||||||
attrib: AttributeKey,
|
group: GroupKey,
|
||||||
group: GroupKey, *,
|
config: GroupByConfig,
|
||||||
slug: Optional[str] = None, # default: "{attrib}/{group}/index.html"
|
children: Optional[Dict[Record, List[object]]] = None,
|
||||||
template: Optional[str] = None # default: "groupby-attrib.html"
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(record)
|
super().__init__(record)
|
||||||
self.attrib = attrib
|
self.key = config.slugify(group)
|
||||||
self.group = group
|
self.group = group
|
||||||
self.template = template or 'groupby-{}.html'.format(self.attrib)
|
self.config = config
|
||||||
# custom user path
|
# make sure children are on the same pad
|
||||||
slug = slug or '{attrib}/{group}/index.html'
|
self._children = {} # type: Dict[Record, List[object]]
|
||||||
slug = slug.replace('{attrib}', self.attrib)
|
for child, extras in (children or {}).items():
|
||||||
slug = slug.replace('{group}', self.group)
|
if child.pad != record.pad:
|
||||||
if slug.endswith('/index.html'):
|
child = record.pad.get(child.path)
|
||||||
slug = slug[:-10]
|
self._children[child] = extras
|
||||||
self.slug = slug
|
self._reverse_reference_records()
|
||||||
# user adjustable after init
|
# evaluate slug Expression
|
||||||
self.children = {} # type: Dict[Record, List[object]]
|
self.slug = self._eval(config.slug, field='slug') # type: str
|
||||||
self.dependencies = set() # type: Set[str]
|
assert self.slug != Ellipsis, 'invalid config: ' + config.slug
|
||||||
|
if self.slug and self.slug.endswith('/index.html'):
|
||||||
|
self.slug = self.slug[:-10]
|
||||||
|
# extra fields
|
||||||
|
for attr, expr in config.fields.items():
|
||||||
|
setattr(self, attr, self._eval(expr, field='fields.' + attr))
|
||||||
|
|
||||||
|
def _eval(self, value: str, *, field: str) -> Any:
|
||||||
|
''' Internal only: evaluates Lektor config file field expression. '''
|
||||||
|
pad = self.record.pad
|
||||||
|
alt = self.record.alt
|
||||||
|
try:
|
||||||
|
return Expression(pad.env, value).evaluate(pad, this=self, alt=alt)
|
||||||
|
except Exception as e:
|
||||||
|
report_config_error(self.config.key, field, value, e)
|
||||||
|
return Ellipsis
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Lektor properties
|
||||||
|
# ---------------------
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path(self) -> str:
|
def path(self) -> str:
|
||||||
# Used in VirtualSourceInfo, used to prune VirtualObjects
|
# Used in VirtualSourceInfo, used to prune VirtualObjects
|
||||||
return f'{self.record.path}{VPATH}/{self.attrib}/{self.group}'
|
return f'{self.record.path}{VPATH}/{self.config.key}/{self.key}'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def url_path(self) -> str:
|
def url_path(self) -> str:
|
||||||
# Actual path to resource as seen by the browser
|
# Actual path to resource as seen by the browser
|
||||||
return build_url([self.record.path, self.slug])
|
return build_url([self.record.path, self.slug]) # slug can be None!
|
||||||
|
|
||||||
def __getitem__(self, name: str) -> object:
|
def __getitem__(self, name: str) -> object:
|
||||||
|
# needed for preview in admin UI
|
||||||
if name == '_path':
|
if name == '_path':
|
||||||
return self.path
|
return self.path
|
||||||
elif name == '_alt':
|
elif name == '_alt':
|
||||||
return PRIMARY_ALT
|
return self.record.alt
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def iter_source_filenames(self) -> Iterator[str]:
|
def iter_source_filenames(self) -> Iterator[str]:
|
||||||
''' Enumerate all dependencies '''
|
''' Enumerate all dependencies '''
|
||||||
if self.dependencies:
|
if self.config.dependencies:
|
||||||
yield from self.dependencies
|
yield from self.config.dependencies
|
||||||
for record in self.children:
|
for record in self._children:
|
||||||
yield from record.iter_source_filenames()
|
yield from record.iter_source_filenames()
|
||||||
|
|
||||||
def __str__(self) -> str:
|
# -----------------------
|
||||||
txt = '<GroupBySource'
|
# Properties & Helper
|
||||||
for x in ['attrib', 'group', 'slug', 'template']:
|
# -----------------------
|
||||||
txt += ' {}="{}"'.format(x, getattr(self, x))
|
|
||||||
return txt + ' children={}>'.format(len(self.children))
|
@property
|
||||||
|
def children(self):
|
||||||
|
return self._children
|
||||||
|
|
||||||
|
@property
|
||||||
|
def first_child(self) -> Optional[Record]:
|
||||||
|
''' Returns first referencing page record. '''
|
||||||
|
if self._children:
|
||||||
|
return iter(self._children).__next__()
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def first_extra(self) -> Optional[object]:
|
||||||
|
''' Returns first additional / extra info object of first page. '''
|
||||||
|
if not self._children:
|
||||||
|
return None
|
||||||
|
val = iter(self._children.values()).__next__()
|
||||||
|
return val[0] if val else None
|
||||||
|
|
||||||
|
def __lt__(self, other: 'GroupBySource') -> bool:
|
||||||
|
''' The "group" attribute is used for sorting. '''
|
||||||
|
return self.group < other.group
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return '<GroupBySource path="{}" children={}>'.format(
|
||||||
|
self.path, len(self._children))
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Reverse Reference
|
||||||
|
# ---------------------
|
||||||
|
|
||||||
|
def _reverse_reference_records(self) -> None:
|
||||||
|
''' Attach self to page records. '''
|
||||||
|
for child in self._children:
|
||||||
|
if not hasattr(child, '_groupby'):
|
||||||
|
child._groupby = WeakSet() # type: ignore[attr-defined]
|
||||||
|
child._groupby.add(self) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def of_record(
|
||||||
|
record: Record,
|
||||||
|
*keys: str,
|
||||||
|
recursive: bool = False
|
||||||
|
) -> Iterator['GroupBySource']:
|
||||||
|
''' Extract all referencing groupby virtual objects from a page. '''
|
||||||
|
ctx = get_ctx()
|
||||||
|
# manage dependencies
|
||||||
|
if ctx:
|
||||||
|
for dep in ctx.env.plugins['groupby'].config_dependencies:
|
||||||
|
ctx.record_dependency(dep)
|
||||||
|
# find groups
|
||||||
|
proc_list = [record]
|
||||||
|
while proc_list:
|
||||||
|
page = proc_list.pop(0)
|
||||||
|
if recursive and hasattr(page, 'children'):
|
||||||
|
proc_list.extend(page.children) # type: ignore[attr-defined]
|
||||||
|
if not hasattr(page, '_groupby'):
|
||||||
|
continue
|
||||||
|
for vobj in page._groupby: # type: ignore[attr-defined]
|
||||||
|
if not keys or vobj.config.key in keys:
|
||||||
|
yield vobj
|
||||||
|
|
||||||
|
|
||||||
class GroupByBuildProgram(BuildProgram):
|
class GroupByBuildProgram(BuildProgram):
|
||||||
@@ -129,14 +279,24 @@ class GroupByBuildProgram(BuildProgram):
|
|||||||
GroupByPruner.track(url)
|
GroupByPruner.track(url)
|
||||||
|
|
||||||
def build_artifact(self, artifact: Artifact) -> None:
|
def build_artifact(self, artifact: Artifact) -> None:
|
||||||
self.source.pad.db.track_record_dependency(self.source)
|
get_ctx().record_virtual_dependency(self.source)
|
||||||
artifact.render_template_into(self.source.template, this=self.source)
|
artifact.render_template_into(
|
||||||
|
self.source.config.template, this=self.source)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
# Helper
|
# Helper
|
||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
|
|
||||||
|
def report_config_error(key: str, field: str, val: str, e: Exception) -> None:
|
||||||
|
''' Send error message to Lektor reporter. Indicate which field is bad. '''
|
||||||
|
msg = '[ERROR] invalid config for [{}.{}] = "{}", Error: {}'.format(
|
||||||
|
key, field, val, repr(e))
|
||||||
|
try:
|
||||||
|
reporter._write_line(style(msg, fg='red'))
|
||||||
|
except Exception:
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
|
||||||
class GroupByPruner:
|
class GroupByPruner:
|
||||||
'''
|
'''
|
||||||
@@ -176,18 +336,18 @@ class GroupByPruner:
|
|||||||
|
|
||||||
|
|
||||||
class GroupByModelReader:
|
class GroupByModelReader:
|
||||||
''' Find models and flow-models which contain attrib '''
|
''' Find models and flow-models which contain attribute '''
|
||||||
|
|
||||||
def __init__(self, db: Database, attrib: AttributeKey) -> None:
|
def __init__(self, db: Database, attrib: SelectionKey) -> None:
|
||||||
self._flows = {} # type: Dict[str, Set[str]]
|
self._flows = {} # type: Dict[str, Set[str]]
|
||||||
self._models = {} # type: Dict[str, Dict[str, str]]
|
self._models = {} # type: Dict[str, Dict[str, str]]
|
||||||
# find flow blocks with attrib
|
# find flow blocks containing attribute
|
||||||
for key, flow in db.flowblocks.items():
|
for key, flow in db.flowblocks.items():
|
||||||
tmp1 = set(f.name for f in flow.fields
|
tmp1 = set(f.name for f in flow.fields
|
||||||
if bool_from_string(f.options.get(attrib, False)))
|
if bool_from_string(f.options.get(attrib, False)))
|
||||||
if tmp1:
|
if tmp1:
|
||||||
self._flows[key] = tmp1
|
self._flows[key] = tmp1
|
||||||
# find models with attrib or flow-blocks containing attrib
|
# find models and flow-blocks containing attribute
|
||||||
for key, model in db.datamodels.items():
|
for key, model in db.datamodels.items():
|
||||||
tmp2 = {} # Dict[str, str]
|
tmp2 = {} # Dict[str, str]
|
||||||
for field in model.fields:
|
for field in model.fields:
|
||||||
@@ -236,7 +396,7 @@ class GroupByState:
|
|||||||
''' Holds and updates a groupby build state. '''
|
''' Holds and updates a groupby build state. '''
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.state = {} # type: Dict[GroupKey, Dict[Record, List]]
|
self.state = {} # type: Dict[GroupKey, Dict[Record, List[object]]]
|
||||||
self._processed = set() # type: Set[Record]
|
self._processed = set() # type: Set[Record]
|
||||||
|
|
||||||
def __contains__(self, record: Record) -> bool:
|
def __contains__(self, record: Record) -> bool:
|
||||||
@@ -244,10 +404,10 @@ class GroupByState:
|
|||||||
return record.path in self._processed
|
return record.path in self._processed
|
||||||
|
|
||||||
def items(self) -> Iterable[Tuple[GroupKey, Dict]]:
|
def items(self) -> Iterable[Tuple[GroupKey, Dict]]:
|
||||||
''' Iterable with (GroupKey, {record: extras}) tuples. '''
|
''' Iterable with (GroupKey, {record: [extras]}) tuples. '''
|
||||||
return self.state.items()
|
return self.state.items()
|
||||||
|
|
||||||
def add(self, record: Record, group: Dict[GroupKey, List]) -> None:
|
def add(self, record: Record, group: Dict[GroupKey, List[object]]) -> None:
|
||||||
''' Append groups if not processed already. '''
|
''' Append groups if not processed already. '''
|
||||||
if record.path not in self._processed:
|
if record.path not in self._processed:
|
||||||
self._processed.add(record.path)
|
self._processed.add(record.path)
|
||||||
@@ -264,34 +424,35 @@ class GroupByWatcher:
|
|||||||
Callback may yield one or more (group-key, extra-info) tuples.
|
Callback may yield one or more (group-key, extra-info) tuples.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, config: GroupByConfig) -> None:
|
||||||
self,
|
self.config = config
|
||||||
root: str,
|
self.flatten = True
|
||||||
attrib: AttributeKey,
|
self.callback = None # type: GroupingCallback #type:ignore[assignment]
|
||||||
callback: GroupingCallback, *,
|
|
||||||
slug: Optional[str] = None, # default: "{attrib}/{group}/index.html"
|
def grouping(self, flatten: bool = True) \
|
||||||
template: Optional[str] = None # default: "groupby-attrib.html"
|
-> Callable[[GroupingCallback], None]:
|
||||||
) -> None:
|
'''
|
||||||
self.root = root
|
Decorator to subscribe to attrib-elements.
|
||||||
self.attrib = attrib
|
If flatten = False, dont explode FlowType.
|
||||||
self.callback = callback
|
|
||||||
self.slug = slug
|
(record, field-key, field) -> (group-key, extra-info)
|
||||||
self.template = template
|
'''
|
||||||
# user editable attributes
|
def _decorator(fn: GroupingCallback) -> None:
|
||||||
self.flatten = True # if False, dont explode FlowType
|
self.flatten = flatten
|
||||||
self.dependencies = set() # type: Set[str]
|
self.callback = fn
|
||||||
|
return _decorator
|
||||||
|
|
||||||
def initialize(self, db: Database) -> None:
|
def initialize(self, db: Database) -> None:
|
||||||
''' Reset internal state. You must initialize before each build! '''
|
''' Reset internal state. You must initialize before each build! '''
|
||||||
|
assert callable(self.callback), 'No grouping callback provided.'
|
||||||
|
self._root = self.config.root
|
||||||
self._state = GroupByState()
|
self._state = GroupByState()
|
||||||
self._model_reader = GroupByModelReader(db, self.attrib)
|
self._model_reader = GroupByModelReader(db, attrib=self.config.key)
|
||||||
|
|
||||||
def should_process(self, node: SourceObject) -> bool:
|
def should_process(self, node: Record) -> bool:
|
||||||
''' Check if record path is being watched. '''
|
''' Check if record path is being watched. '''
|
||||||
if isinstance(node, Record):
|
|
||||||
p = node['_path'] # type: str
|
p = node['_path'] # type: str
|
||||||
return p.startswith(self.root) or p + '/' == self.root
|
return p.startswith(self._root) or p + '/' == self._root
|
||||||
return False
|
|
||||||
|
|
||||||
def process(self, record: Record) -> None:
|
def process(self, record: Record) -> None:
|
||||||
'''
|
'''
|
||||||
@@ -300,34 +461,36 @@ class GroupByWatcher:
|
|||||||
'''
|
'''
|
||||||
if record in self._state:
|
if record in self._state:
|
||||||
return
|
return
|
||||||
tmp = {}
|
tmp = {} # type: Dict[GroupKey, List[object]]
|
||||||
for key, field in self._model_reader.read(record, self.flatten):
|
for key, field in self._model_reader.read(record, self.flatten):
|
||||||
for ret in self.callback(GroupByCallbackArgs(record, key, field)):
|
_gen = self.callback(GroupByCallbackArgs(record, key, field))
|
||||||
assert isinstance(ret, (tuple, list)), \
|
try:
|
||||||
'Must return tuple (group-key, extra-info)'
|
obj = next(_gen)
|
||||||
group_key, extra = ret
|
while True:
|
||||||
if group_key not in tmp:
|
if not isinstance(obj, (str, tuple)):
|
||||||
tmp[group_key] = [extra]
|
raise TypeError(f'Unsupported groupby yield: {obj}')
|
||||||
|
group = obj if isinstance(obj, str) else obj[0]
|
||||||
|
if group not in tmp:
|
||||||
|
tmp[group] = []
|
||||||
|
if isinstance(obj, tuple):
|
||||||
|
tmp[group].append(obj[1])
|
||||||
|
# return slugified group key and continue iteration
|
||||||
|
if isinstance(_gen, Generator) and not _gen.gi_yieldfrom:
|
||||||
|
obj = _gen.send(self.config.slugify(group))
|
||||||
else:
|
else:
|
||||||
tmp[group_key].append(extra)
|
obj = next(_gen)
|
||||||
|
except StopIteration:
|
||||||
|
del _gen
|
||||||
self._state.add(record, tmp)
|
self._state.add(record, tmp)
|
||||||
|
|
||||||
def iter_sources(self, root: Record) -> Iterator[GroupBySource]:
|
def iter_sources(self, root: Record) -> Iterator[GroupBySource]:
|
||||||
''' Prepare and yield GroupBySource elements. '''
|
''' Prepare and yield GroupBySource elements. '''
|
||||||
for group_key, children in self._state.items():
|
for group, children in self._state.items():
|
||||||
src = GroupBySource(root, self.attrib, group_key,
|
yield GroupBySource(root, group, self.config, children=children)
|
||||||
slug=self.slug, template=self.template)
|
|
||||||
src.dependencies = self.dependencies
|
|
||||||
src.children = children
|
|
||||||
yield src
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
txt = '<GroupByWatcher'
|
return '<GroupByWatcher key="{}" enabled={} callback={}>'.format(
|
||||||
for x in [
|
self.config.key, self.config.enabled, self.callback)
|
||||||
'root', 'attrib', 'slug', 'template', 'flatten', 'dependencies'
|
|
||||||
]:
|
|
||||||
txt += ' {}="{}"'.format(x, getattr(self, x))
|
|
||||||
return txt + '>'
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
@@ -345,46 +508,31 @@ class GroupByCreator:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._watcher = [] # type: List[GroupByWatcher]
|
self._watcher = [] # type: List[GroupByWatcher]
|
||||||
self._results = {} # type: Dict[str, GroupBySource]
|
self._results = {} # type: Dict[str, GroupBySource]
|
||||||
self._resolve_map = {} # type: Dict[str, ResolverConf]
|
self._resolver = {} # type: Dict[str, Tuple[GroupKey, GroupByConfig]]
|
||||||
|
self._weak_ref_keep_alive = [] # type: List[GroupBySource]
|
||||||
|
|
||||||
# ----------------
|
# ----------------
|
||||||
# Add Observer
|
# Add Observer
|
||||||
# ----------------
|
# ----------------
|
||||||
|
|
||||||
def depends_on(self, *args: str) \
|
def add_watcher(
|
||||||
-> Callable[[GroupByWatcher], GroupByWatcher]:
|
|
||||||
''' Set GroupBySource dependency, e.g., a plugin config file. '''
|
|
||||||
def _decorator(r: GroupByWatcher) -> GroupByWatcher:
|
|
||||||
r.dependencies.update(list(args))
|
|
||||||
return r
|
|
||||||
return _decorator
|
|
||||||
|
|
||||||
def watch(
|
|
||||||
self,
|
self,
|
||||||
root: str,
|
key: SelectionKey,
|
||||||
attrib: AttributeKey, *,
|
config: Union[GroupByConfig, IniFile, Dict]
|
||||||
slug: Optional[str] = None, # default: "{attrib}/{group}/index.html"
|
) -> GroupByWatcher:
|
||||||
template: Optional[str] = None, # default: "groupby-attrib.html"
|
''' Init GroupByConfig and add to watch list. '''
|
||||||
flatten: bool = True, # if False, dont explode FlowType
|
assert isinstance(config, (GroupByConfig, IniFile, Dict))
|
||||||
) -> Callable[[GroupingCallback], GroupByWatcher]:
|
if isinstance(config, GroupByConfig):
|
||||||
'''
|
cfg = config
|
||||||
Decorator to subscribe to attrib-elements.
|
elif isinstance(config, IniFile):
|
||||||
(record, field-key, field) -> (group-key, extra-info)
|
cfg = GroupByConfig.from_ini(key, config)
|
||||||
|
elif isinstance(config, Dict):
|
||||||
|
cfg = GroupByConfig.from_dict(key, config)
|
||||||
|
|
||||||
:DEFAULTS:
|
w = GroupByWatcher(cfg)
|
||||||
slug: "{attrib}/{group}/index.html"
|
|
||||||
template: "groupby-attrib.html"
|
|
||||||
'''
|
|
||||||
root = root.rstrip('/') + '/'
|
|
||||||
|
|
||||||
def _decorator(fn: GroupingCallback) -> GroupByWatcher:
|
|
||||||
w = GroupByWatcher(root, attrib, fn, slug=slug, template=template)
|
|
||||||
w.flatten = flatten
|
|
||||||
self._watcher.append(w)
|
self._watcher.append(w)
|
||||||
return w
|
return w
|
||||||
|
|
||||||
return _decorator
|
|
||||||
|
|
||||||
# -----------
|
# -----------
|
||||||
# Builder
|
# Builder
|
||||||
# -----------
|
# -----------
|
||||||
@@ -393,15 +541,25 @@ class GroupByCreator:
|
|||||||
''' Reset prvious results. Must be called before each build. '''
|
''' Reset prvious results. Must be called before each build. '''
|
||||||
self._watcher.clear()
|
self._watcher.clear()
|
||||||
self._results.clear()
|
self._results.clear()
|
||||||
self._resolve_map.clear()
|
self._resolver.clear()
|
||||||
|
self._weak_ref_keep_alive.clear()
|
||||||
|
|
||||||
|
def get_dependencies(self) -> Set[str]:
|
||||||
|
deps = set() # type: Set[str]
|
||||||
|
for w in self._watcher:
|
||||||
|
deps.update(w.config.dependencies)
|
||||||
|
return deps
|
||||||
|
|
||||||
def make_cluster(self, builder: Builder) -> None:
|
def make_cluster(self, builder: Builder) -> None:
|
||||||
''' Perform groupby, iterate over all children. '''
|
''' Iterate over all children and perform groupby. '''
|
||||||
|
# remove disabled watchers
|
||||||
|
self._watcher = [w for w in self._watcher if w.config.enabled]
|
||||||
if not self._watcher:
|
if not self._watcher:
|
||||||
return
|
return
|
||||||
|
# initialize remaining (enabled) watchers
|
||||||
for w in self._watcher:
|
for w in self._watcher:
|
||||||
w.initialize(builder.pad.db)
|
w.initialize(builder.pad.db)
|
||||||
|
# iterate over whole build tree
|
||||||
queue = builder.pad.get_all_roots() # type: List[SourceObject]
|
queue = builder.pad.get_all_roots() # type: List[SourceObject]
|
||||||
while queue:
|
while queue:
|
||||||
record = queue.pop()
|
record = queue.pop()
|
||||||
@@ -412,24 +570,31 @@ class GroupByCreator:
|
|||||||
queue.extend(record.children) # type: ignore[attr-defined]
|
queue.extend(record.children) # type: ignore[attr-defined]
|
||||||
# build artifacts
|
# build artifacts
|
||||||
for w in self._watcher:
|
for w in self._watcher:
|
||||||
root = builder.pad.get(w.root)
|
root = builder.pad.get(w.config.root)
|
||||||
for vobj in w.iter_sources(root):
|
for vobj in w.iter_sources(root):
|
||||||
self._results[vobj.url_path] = vobj
|
if vobj.slug:
|
||||||
|
url = vobj.url_path
|
||||||
|
self._results[url] = vobj
|
||||||
|
self._resolver[url] = (vobj.group, w.config)
|
||||||
|
else:
|
||||||
|
self._weak_ref_keep_alive.append(vobj) # for weak ref
|
||||||
self._watcher.clear()
|
self._watcher.clear()
|
||||||
|
|
||||||
def queue_now(self, node: SourceObject) -> None:
|
def queue_now(self, node: SourceObject) -> None:
|
||||||
''' Process record immediatelly (No-Op if already processed). '''
|
''' Process record immediatelly (No-Op if already processed). '''
|
||||||
|
if isinstance(node, Record):
|
||||||
for w in self._watcher:
|
for w in self._watcher:
|
||||||
if w.should_process(node): # ensures type Record
|
if w.should_process(node):
|
||||||
w.process(node) # type: ignore[arg-type]
|
w.process(node)
|
||||||
|
|
||||||
def build_all(self, builder: Builder) -> None:
|
def build_all(self, builder: Builder) -> None:
|
||||||
''' Create virtual objects and build sources. '''
|
''' Create virtual objects and build sources. '''
|
||||||
for url, x in sorted(self._results.items()):
|
path_cache = PathCache(builder.env)
|
||||||
builder.build(x)
|
for _, vobj in sorted(self._results.items()):
|
||||||
self._resolve_map[url] = ResolverConf(
|
builder.build(vobj, path_cache)
|
||||||
x.record['_path'], x.attrib, x.group, x.slug)
|
del path_cache
|
||||||
self._results.clear()
|
self._results.clear()
|
||||||
|
self._weak_ref_keep_alive.clear() # garbage collect weak refs
|
||||||
|
|
||||||
# -----------------
|
# -----------------
|
||||||
# Path resolver
|
# Path resolver
|
||||||
@@ -441,20 +606,22 @@ class GroupByCreator:
|
|||||||
''' Dev server only: Resolves path/ -> path/index.html '''
|
''' Dev server only: Resolves path/ -> path/index.html '''
|
||||||
if not isinstance(node, Record):
|
if not isinstance(node, Record):
|
||||||
return None
|
return None
|
||||||
conf = self._resolve_map.get(build_url([node.url_path] + pieces))
|
rv = self._resolver.get(build_url([node.url_path] + pieces))
|
||||||
if not conf:
|
if not rv:
|
||||||
return None
|
return None
|
||||||
return GroupBySource(node, conf.attrib, conf.group, slug=conf.slug)
|
group, conf = rv
|
||||||
|
return GroupBySource(node, group, conf)
|
||||||
|
|
||||||
def resolve_virtual_path(
|
def resolve_virtual_path(
|
||||||
self, node: SourceObject, pieces: List[str]
|
self, node: SourceObject, pieces: List[str]
|
||||||
) -> Optional[GroupBySource]:
|
) -> Optional[GroupBySource]:
|
||||||
if isinstance(node, Record) and len(pieces) >= 2:
|
if isinstance(node, Record) and len(pieces) >= 2:
|
||||||
test_node = (node['_path'], pieces[0], pieces[1])
|
path = node['_path'] # type: str
|
||||||
for url, conf in self._resolve_map.items():
|
key, grp, *_ = pieces
|
||||||
if test_node == conf[:3]:
|
for group, conf in self._resolver.values():
|
||||||
_, attr, group, slug = conf
|
if key == conf.key and path == conf.root:
|
||||||
return GroupBySource(node, attr, group, slug=slug)
|
if conf.slugify(group) == grp:
|
||||||
|
return GroupBySource(node, group, conf)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -470,12 +637,14 @@ class GroupByPlugin(Plugin):
|
|||||||
def on_setup_env(self, **extra: object) -> None:
|
def on_setup_env(self, **extra: object) -> None:
|
||||||
self.creator = GroupByCreator()
|
self.creator = GroupByCreator()
|
||||||
self.env.add_build_program(GroupBySource, GroupByBuildProgram)
|
self.env.add_build_program(GroupBySource, GroupByBuildProgram)
|
||||||
|
self.env.jinja_env.filters.update(groupby=GroupBySource.of_record)
|
||||||
|
|
||||||
# resolve /tag/rss/ -> /tag/rss/index.html (local server only)
|
# resolve /tag/rss/ -> /tag/rss/index.html (local server only)
|
||||||
@self.env.urlresolver
|
@self.env.urlresolver
|
||||||
def a(node: SourceObject, parts: List[str]) -> Optional[GroupBySource]:
|
def a(node: SourceObject, parts: List[str]) -> Optional[GroupBySource]:
|
||||||
return self.creator.resolve_dev_server_path(node, parts)
|
return self.creator.resolve_dev_server_path(node, parts)
|
||||||
|
|
||||||
|
# resolve virtual objects in admin UI
|
||||||
@self.env.virtualpathresolver(VPATH.lstrip('@'))
|
@self.env.virtualpathresolver(VPATH.lstrip('@'))
|
||||||
def b(node: SourceObject, parts: List[str]) -> Optional[GroupBySource]:
|
def b(node: SourceObject, parts: List[str]) -> Optional[GroupBySource]:
|
||||||
return self.creator.resolve_virtual_path(node, parts)
|
return self.creator.resolve_virtual_path(node, parts)
|
||||||
@@ -483,28 +652,27 @@ class GroupByPlugin(Plugin):
|
|||||||
def _load_quick_config(self) -> None:
|
def _load_quick_config(self) -> None:
|
||||||
''' Load config file quick listeners. '''
|
''' Load config file quick listeners. '''
|
||||||
config = self.get_config()
|
config = self.get_config()
|
||||||
for attrib in config.sections():
|
for key in config.sections():
|
||||||
sect = config.section_as_dict(attrib)
|
if '.' in key: # e.g., key.fields and key.key_map
|
||||||
root = sect.get('root', '/')
|
continue
|
||||||
slug = sect.get('slug')
|
|
||||||
temp = sect.get('template')
|
|
||||||
split = sect.get('split')
|
|
||||||
|
|
||||||
@self.creator.depends_on(self.config_filename)
|
watcher = self.creator.add_watcher(key, config)
|
||||||
@self.creator.watch(root, attrib, slug=slug, template=temp)
|
split = config.get(key + '.split') # type: str
|
||||||
def _fn(args: GroupByCallbackArgs) \
|
|
||||||
-> Iterator[Tuple[GroupKey, object]]:
|
@watcher.grouping()
|
||||||
|
def _fn(args: GroupByCallbackArgs) -> Iterator[GroupKey]:
|
||||||
val = args.field
|
val = args.field
|
||||||
if isinstance(val, str):
|
if isinstance(val, str):
|
||||||
val = val.split(split) if split else [val] # make list
|
val = val.split(split) if split else [val] # make list
|
||||||
if isinstance(val, list):
|
if isinstance(val, list):
|
||||||
for tag in val:
|
yield from val
|
||||||
yield slugify(tag), tag
|
|
||||||
|
|
||||||
def on_before_build_all(self, builder: Builder, **extra: object) -> None:
|
def on_before_build_all(self, builder: Builder, **extra: object) -> None:
|
||||||
self.creator.clear_previous_results()
|
self.creator.clear_previous_results()
|
||||||
|
self._load_quick_config()
|
||||||
# let other plugins register their @groupby.watch functions
|
# let other plugins register their @groupby.watch functions
|
||||||
self.emit('before-build-all', groupby=self.creator, builder=builder)
|
self.emit('before-build-all', groupby=self.creator, builder=builder)
|
||||||
|
self.config_dependencies = self.creator.get_dependencies()
|
||||||
self.creator.make_cluster(builder)
|
self.creator.make_cluster(builder)
|
||||||
|
|
||||||
def on_before_build(self, source: SourceObject, **extra: object) -> None:
|
def on_before_build(self, source: SourceObject, **extra: object) -> None:
|
||||||
@@ -513,9 +681,6 @@ class GroupByPlugin(Plugin):
|
|||||||
self.creator.queue_now(source)
|
self.creator.queue_now(source)
|
||||||
|
|
||||||
def on_after_build_all(self, builder: Builder, **extra: object) -> None:
|
def on_after_build_all(self, builder: Builder, **extra: object) -> None:
|
||||||
self.emit('after-build-all', groupby=self.creator, builder=builder)
|
|
||||||
self._load_quick_config()
|
|
||||||
self.creator.make_cluster(builder)
|
|
||||||
self.creator.build_all(builder)
|
self.creator.build_all(builder)
|
||||||
|
|
||||||
def on_after_prune(self, builder: Builder, **extra: object) -> None:
|
def on_after_prune(self, builder: Builder, **extra: object) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user