Due to a concurrency bug (lektor/lektor#1017), a source file is sporadically not updated because `before-build` is evaluated faster than `before-build-all`. Fixed with a redundant build process. Also: - adds before- and after-init hooks - encapsulates logic into separate classes - fix virtual path and remove virtual path resolver - more type hints (incl. bugfixes)
499 lines
18 KiB
Python
499 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
from lektor.db import Database, Record # typing
|
|
from lektor.build_programs import BuildProgram
|
|
from lektor.builder import Artifact, Builder # typing
|
|
from lektor.pluginsystem import Plugin
|
|
from lektor.reporter import reporter
|
|
from lektor.sourceobj import SourceObject, VirtualSourceObject
|
|
from lektor.types.flow import Flow, FlowType
|
|
from lektor.utils import bool_from_string, build_url, prune_file_and_folder
|
|
# for quick config
|
|
from lektor.utils import slugify
|
|
|
|
from typing import Tuple, Dict, Set, List, NamedTuple
|
|
from typing import NewType, Optional, Iterator, Callable, Iterable
|
|
|
|
VPATH = '@groupby' # potentially unsafe. All matching entries are pruned.
|
|
|
|
|
|
# -----------------------------------
|
|
# Typing
|
|
# -----------------------------------
|
|
AttributeKey = NewType('AttributeKey', str) # attribute of lektor model
|
|
GroupKey = NewType('GroupKey', str) # key of group-by
|
|
|
|
|
|
class ResolverConf(NamedTuple):
|
|
attrib: AttributeKey
|
|
group: GroupKey
|
|
slug: str
|
|
|
|
|
|
class FieldKeyPath(NamedTuple):
|
|
fieldKey: str
|
|
flowIndex: Optional[int] = None
|
|
flowKey: Optional[str] = None
|
|
|
|
|
|
class GroupByCallbackArgs(NamedTuple):
|
|
record: Record
|
|
key: FieldKeyPath
|
|
field: object # lektor model data-field value
|
|
|
|
|
|
GroupingCallback = Callable[[GroupByCallbackArgs],
|
|
Iterator[Tuple[GroupKey, object]]]
|
|
|
|
|
|
# -----------------------------------
|
|
# VirtualSource & BuildProgram
|
|
# -----------------------------------
|
|
|
|
|
|
class GroupBySource(VirtualSourceObject):
|
|
'''
|
|
Holds information for a single group/cluster.
|
|
This object is accessible in your template file.
|
|
Attributes: record, attrib, group, slug, template, children
|
|
|
|
:DEFAULTS:
|
|
slug: "{attrib}/{group}/index.html"
|
|
template: "groupby-attribute.html"
|
|
'''
|
|
|
|
def __init__(
|
|
self,
|
|
record: Record,
|
|
attrib: AttributeKey,
|
|
group: GroupKey, *,
|
|
slug: Optional[str] = None, # default: "{attrib}/{group}/index.html"
|
|
template: Optional[str] = None # default: "groupby-attrib.html"
|
|
) -> None:
|
|
super().__init__(record)
|
|
self.attrib = attrib
|
|
self.group = group
|
|
self.template = template or 'groupby-{}.html'.format(self.attrib)
|
|
# custom user path
|
|
slug = slug or '{attrib}/{group}/index.html'
|
|
slug = slug.replace('{attrib}', self.attrib)
|
|
slug = slug.replace('{group}', self.group)
|
|
if slug.endswith('/index.html'):
|
|
slug = slug[:-10]
|
|
self.slug = slug
|
|
# user adjustable after init
|
|
self.children = {} # type: Dict[Record, List[object]]
|
|
self.dependencies = set() # type: Set[str]
|
|
|
|
@property
|
|
def path(self) -> str:
|
|
# Used in VirtualSourceInfo, used to prune VirtualObjects
|
|
return f'{self.record.path}{VPATH}/{self.attrib}/{self.group}'
|
|
|
|
@property
|
|
def url_path(self) -> str:
|
|
# Actual path to resource as seen by the browser
|
|
return build_url([self.record.path, self.slug])
|
|
|
|
def iter_source_filenames(self) -> Iterator[str]:
|
|
''' Enumerate all dependencies '''
|
|
if self.dependencies:
|
|
yield from self.dependencies
|
|
for record in self.children:
|
|
yield from record.iter_source_filenames()
|
|
|
|
def __str__(self) -> str:
|
|
txt = '<GroupBySource'
|
|
for x in ['attrib', 'group', 'slug', 'template']:
|
|
txt += ' {}="{}"'.format(x, getattr(self, x))
|
|
return txt + ' children={}>'.format(len(self.children))
|
|
|
|
|
|
class GroupByBuildProgram(BuildProgram):
|
|
''' Generate Build-Artifacts and write files. '''
|
|
|
|
def produce_artifacts(self) -> None:
|
|
url = self.source.url_path
|
|
if url.endswith('/'):
|
|
url += 'index.html'
|
|
self.declare_artifact(url, sources=list(
|
|
self.source.iter_source_filenames()))
|
|
GroupByPruner.track(url)
|
|
|
|
def build_artifact(self, artifact: Artifact) -> None:
|
|
self.source.pad.db.track_record_dependency(self.source)
|
|
artifact.render_template_into(self.source.template, this=self.source)
|
|
|
|
|
|
# -----------------------------------
|
|
# Helper
|
|
# -----------------------------------
|
|
|
|
|
|
class GroupByPruner:
|
|
'''
|
|
Static collector for build-artifact urls.
|
|
All non-tracked VPATH-urls will be pruned after build.
|
|
'''
|
|
_cache: Set[str] = set()
|
|
# Note: this var is static or otherwise two instances of
|
|
# GroupByCreator would prune each others artifacts.
|
|
|
|
@classmethod
|
|
def track(cls, url: str) -> None:
|
|
''' Add url to build cache to prevent pruning. '''
|
|
cls._cache.add(url.lstrip('/'))
|
|
|
|
@classmethod
|
|
def prune(cls, builder: Builder) -> None:
|
|
''' Remove previously generated, unreferenced Artifacts. '''
|
|
dest_path = builder.destination_path
|
|
con = builder.connect_to_database()
|
|
try:
|
|
with builder.new_build_state() as build_state:
|
|
for url, file in build_state.iter_artifacts():
|
|
if url.lstrip('/') in cls._cache:
|
|
continue # generated in this build-run
|
|
infos = build_state.get_artifact_dependency_infos(url, [])
|
|
for v_path, _ in infos:
|
|
if VPATH not in v_path:
|
|
continue # we only care about groupby Virtuals
|
|
reporter.report_pruned_artifact(url)
|
|
prune_file_and_folder(file.filename, dest_path)
|
|
build_state.remove_artifact(url)
|
|
break # there is only one VPATH-entry per source
|
|
finally:
|
|
con.close()
|
|
cls._cache.clear()
|
|
|
|
|
|
class GroupByModelReader:
|
|
''' Find models and flow-models which contain attrib '''
|
|
|
|
def __init__(self, db: Database, attrib: AttributeKey) -> None:
|
|
self._flows = {} # type: Dict[str, Set[str]]
|
|
self._models = {} # type: Dict[str, Dict[str, str]]
|
|
# find flow blocks with attrib
|
|
for key, flow in db.flowblocks.items():
|
|
tmp1 = set(f.name for f in flow.fields
|
|
if bool_from_string(f.options.get(attrib, False)))
|
|
if tmp1:
|
|
self._flows[key] = tmp1
|
|
# find models with attrib or flow-blocks containing attrib
|
|
for key, model in db.datamodels.items():
|
|
tmp2 = {} # Dict[str, str]
|
|
for field in model.fields:
|
|
if bool_from_string(field.options.get(attrib, False)):
|
|
tmp2[field.name] = '*' # include all children
|
|
elif isinstance(field.type, FlowType) and self._flows:
|
|
# only processed if at least one flow has attrib
|
|
fbs = field.type.flow_blocks
|
|
# if fbs == None, all flow-blocks are allowed
|
|
if fbs is None or any(x in self._flows for x in fbs):
|
|
tmp2[field.name] = '?' # only some flow blocks
|
|
if tmp2:
|
|
self._models[key] = tmp2
|
|
|
|
def read(
|
|
self,
|
|
record: Record,
|
|
flatten: bool = False
|
|
) -> Iterator[Tuple[FieldKeyPath, object]]:
|
|
'''
|
|
Enumerate all fields of a Record with attrib = True.
|
|
Flows are either returned directly (flatten=False) or
|
|
expanded so that each flow-block is yielded (flatten=True)
|
|
'''
|
|
assert isinstance(record, Record)
|
|
for r_key, subs in self._models.get(record.datamodel.id, {}).items():
|
|
if subs == '*': # either normal field or flow type (all blocks)
|
|
field = record[r_key]
|
|
if flatten and isinstance(field, Flow):
|
|
for i, flow in enumerate(field.blocks):
|
|
flowtype = flow['_flowblock']
|
|
for f_key, block in flow._data.items():
|
|
if f_key.startswith('_'): # e.g., _flowblock
|
|
continue
|
|
yield FieldKeyPath(r_key, i, f_key), block
|
|
else:
|
|
yield FieldKeyPath(r_key), field
|
|
else: # always flow type (only some blocks)
|
|
for i, flow in enumerate(record[r_key].blocks):
|
|
flowtype = flow['_flowblock']
|
|
for f_key in self._flows.get(flowtype, []):
|
|
yield FieldKeyPath(r_key, i, f_key), flow[f_key]
|
|
|
|
|
|
class GroupByState:
|
|
''' Holds and updates a groupby build state. '''
|
|
|
|
def __init__(self) -> None:
|
|
self.state = {} # type: Dict[GroupKey, Dict[Record, List]]
|
|
self._processed = set() # type: Set[Record]
|
|
|
|
def __contains__(self, record: Record) -> bool:
|
|
''' Returns True if record was already processed. '''
|
|
return record in self._processed
|
|
|
|
def items(self) -> Iterable[Tuple[GroupKey, Dict]]:
|
|
''' Iterable with (GroupKey, {record: extras}) tuples. '''
|
|
return self.state.items()
|
|
|
|
def add(self, record: Record, group: Dict[GroupKey, List]) -> None:
|
|
''' Append groups if not processed already. '''
|
|
if record not in self._processed:
|
|
self._processed.add(record)
|
|
for group_key, extras in group.items():
|
|
if group_key in self.state:
|
|
self.state[group_key][record] = extras
|
|
else:
|
|
self.state[group_key] = {record: extras}
|
|
|
|
|
|
class GroupByWatcher:
|
|
'''
|
|
Callback is called with (Record, FieldKeyPath, field-value).
|
|
Callback may yield one or more (group-key, extra-info) tuples.
|
|
'''
|
|
|
|
def __init__(
|
|
self,
|
|
root: str,
|
|
attrib: AttributeKey,
|
|
callback: GroupingCallback, *,
|
|
slug: Optional[str] = None, # default: "{attrib}/{group}/index.html"
|
|
template: Optional[str] = None # default: "groupby-attrib.html"
|
|
) -> None:
|
|
self.root = root
|
|
self.attrib = attrib
|
|
self.callback = callback
|
|
self.slug = slug
|
|
self.template = template
|
|
# user editable attributes
|
|
self.flatten = True # if False, dont explode FlowType
|
|
self.dependencies = set() # type: Set[str]
|
|
|
|
def initialize(self, db: Database) -> None:
|
|
''' Reset internal state. You must initialize before each build! '''
|
|
self._state = GroupByState()
|
|
self._model_reader = GroupByModelReader(db, self.attrib)
|
|
|
|
def should_process(self, node: SourceObject) -> bool:
|
|
''' Check if record path is being watched. '''
|
|
if isinstance(node, Record):
|
|
p = node['_path'] # type: str
|
|
return p.startswith(self.root) or p + '/' == self.root
|
|
return False
|
|
|
|
def process(self, record: Record) -> None:
|
|
'''
|
|
Will iterate over all record fields and call the callback method.
|
|
Each record is guaranteed to be processed only once.
|
|
'''
|
|
if record in self._state:
|
|
return
|
|
tmp = {}
|
|
for key, field in self._model_reader.read(record, self.flatten):
|
|
for ret in self.callback(GroupByCallbackArgs(record, key, field)):
|
|
assert isinstance(ret, (tuple, list)), \
|
|
'Must return tuple (group-key, extra-info)'
|
|
group_key, extra = ret
|
|
if group_key not in tmp:
|
|
tmp[group_key] = [extra]
|
|
else:
|
|
tmp[group_key].append(extra)
|
|
self._state.add(record, tmp)
|
|
|
|
def iter_sources(self, root: Record) -> Iterator[GroupBySource]:
|
|
''' Prepare and yield GroupBySource elements. '''
|
|
for group_key, children in self._state.items():
|
|
src = GroupBySource(root, self.attrib, group_key,
|
|
slug=self.slug, template=self.template)
|
|
src.dependencies = self.dependencies
|
|
src.children = children
|
|
yield src
|
|
|
|
def __str__(self) -> str:
|
|
txt = '<GroupByWatcher'
|
|
for x in [
|
|
'root', 'attrib', 'slug', 'template', 'flatten', 'dependencies'
|
|
]:
|
|
txt += ' {}="{}"'.format(x, getattr(self, x))
|
|
return txt + '>'
|
|
|
|
|
|
# -----------------------------------
|
|
# Main Component
|
|
# -----------------------------------
|
|
|
|
|
|
class GroupByCreator:
|
|
'''
|
|
Process all children with matching conditions under specified page.
|
|
Creates a grouping of pages with similar (self-defined) attributes.
|
|
The grouping is performed only once per build.
|
|
'''
|
|
|
|
def __init__(self) -> None:
|
|
self._watcher = [] # type: List[GroupByWatcher]
|
|
self._results = {} # type: Dict[str, GroupBySource]
|
|
self._resolve_map = {} # type: Dict[str, ResolverConf]
|
|
|
|
# ----------------
|
|
# Add Observer
|
|
# ----------------
|
|
|
|
def depends_on(self, *args: str) \
|
|
-> 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,
|
|
root: str,
|
|
attrib: AttributeKey, *,
|
|
slug: Optional[str] = None, # default: "{attrib}/{group}/index.html"
|
|
template: Optional[str] = None, # default: "groupby-attrib.html"
|
|
flatten: bool = True, # if False, dont explode FlowType
|
|
) -> Callable[[GroupingCallback], GroupByWatcher]:
|
|
'''
|
|
Decorator to subscribe to attrib-elements.
|
|
(record, field-key, field) -> (group-key, extra-info)
|
|
|
|
:DEFAULTS:
|
|
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)
|
|
return w
|
|
|
|
return _decorator
|
|
|
|
# -----------
|
|
# Builder
|
|
# -----------
|
|
|
|
def clear_previous_results(self) -> None:
|
|
''' Reset prvious results. Must be called before each build. '''
|
|
self._watcher.clear()
|
|
self._results.clear()
|
|
self._resolve_map.clear()
|
|
|
|
def make_cluster(self, builder: Builder) -> None:
|
|
''' Perform groupby, iterate over all children. '''
|
|
if not self._watcher:
|
|
return
|
|
for w in self._watcher:
|
|
w.initialize(builder.pad.db)
|
|
|
|
queue = builder.pad.get_all_roots() # type: List[SourceObject]
|
|
while queue:
|
|
record = queue.pop()
|
|
self.queue_now(record)
|
|
if hasattr(record, 'attachments'):
|
|
queue.extend(record.attachments) # type: ignore[attr-defined]
|
|
if hasattr(record, 'children'):
|
|
queue.extend(record.children) # type: ignore[attr-defined]
|
|
# build artifacts
|
|
for w in self._watcher:
|
|
root = builder.pad.get(w.root)
|
|
for vobj in w.iter_sources(root):
|
|
self._results[vobj.url_path] = vobj
|
|
self._watcher.clear()
|
|
|
|
def queue_now(self, node: SourceObject) -> None:
|
|
''' Process record immediatelly (No-Op if already processed). '''
|
|
for w in self._watcher:
|
|
if w.should_process(node): # ensures type Record
|
|
w.process(node) # type: ignore[arg-type]
|
|
|
|
def build_all(self, builder: Builder) -> None:
|
|
''' Create virtual objects and build sources. '''
|
|
for url, x in sorted(self._results.items()):
|
|
builder.build(x)
|
|
self._resolve_map[url] = ResolverConf(x.attrib, x.group, x.slug)
|
|
self._results.clear()
|
|
|
|
# -----------------
|
|
# Path resolver
|
|
# -----------------
|
|
|
|
def resolve_dev_server_path(
|
|
self, node: SourceObject, pieces: List[str]
|
|
) -> Optional[GroupBySource]:
|
|
''' Dev server only: Resolves path/ -> path/index.html '''
|
|
if not isinstance(node, Record):
|
|
return None
|
|
conf = self._resolve_map.get(build_url([node.url_path] + pieces))
|
|
if not conf:
|
|
return None
|
|
return GroupBySource(node, conf.attrib, conf.group, slug=conf.slug)
|
|
|
|
|
|
# -----------------------------------
|
|
# Plugin Entry
|
|
# -----------------------------------
|
|
|
|
|
|
class GroupByPlugin(Plugin):
|
|
name = 'GroupBy Plugin'
|
|
description = 'Cluster arbitrary records with field attribute keyword.'
|
|
|
|
def on_setup_env(self, **extra: object) -> None:
|
|
self.creator = GroupByCreator()
|
|
self.env.add_build_program(GroupBySource, GroupByBuildProgram)
|
|
|
|
# resolve /tag/rss/ -> /tag/rss/index.html (local server only)
|
|
@self.env.urlresolver
|
|
def _(node: SourceObject, parts: List[str]) -> Optional[GroupBySource]:
|
|
return self.creator.resolve_dev_server_path(node, parts)
|
|
|
|
def _load_quick_config(self) -> None:
|
|
''' Load config file quick listeners. '''
|
|
config = self.get_config()
|
|
for attrib in config.sections():
|
|
sect = config.section_as_dict(attrib)
|
|
root = sect.get('root', '/')
|
|
slug = sect.get('slug')
|
|
temp = sect.get('template')
|
|
split = sect.get('split')
|
|
|
|
@self.creator.depends_on(self.config_filename)
|
|
@self.creator.watch(root, attrib, slug=slug, template=temp)
|
|
def _fn(args: GroupByCallbackArgs) \
|
|
-> Iterator[Tuple[GroupKey, object]]:
|
|
val = args.field
|
|
if isinstance(val, str):
|
|
val = val.split(split) if split else [val] # make list
|
|
if isinstance(val, list):
|
|
for tag in val:
|
|
yield slugify(tag), tag
|
|
|
|
def on_before_build_all(self, builder: Builder, **extra: object) -> None:
|
|
self.creator.clear_previous_results()
|
|
# let other plugins register their @groupby.watch functions
|
|
self.emit('before-build-all', groupby=self.creator, builder=builder)
|
|
self.creator.make_cluster(builder)
|
|
|
|
def on_before_build(self, source: SourceObject, **extra: object) -> None:
|
|
# before-build may be called before before-build-all (issue #1017)
|
|
# make sure it is evaluated immediatelly
|
|
self.creator.queue_now(source)
|
|
|
|
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)
|
|
|
|
def on_after_prune(self, builder: Builder, **extra: object) -> None:
|
|
# TODO: find a better way to prune unreferenced elements
|
|
GroupByPruner.prune(builder)
|