Enhance heck processor so we can override stuff. Allow process chain to be overridden by metadata.

This commit is contained in:
2026-09-01 12:41:25 -07:00
parent 690f110bc5
commit beb5255a90
7 changed files with 194 additions and 36 deletions
+3
View File
@@ -28,4 +28,7 @@
* Provide a database-like interface to the global metadata tree, so that metameta page templates could query the database to assemble indexes and whatnot without looping over the actual files. * Provide a database-like interface to the global metadata tree, so that metameta page templates could query the database to assemble indexes and whatnot without looping over the actual files.
* add a version requirement system to projects so if we start breaking things we can use versioning
* add a flag to __main__ to ignore errors even at the end
* add a flag to __main__ to exit on first error rather than waiting til the end.
+28 -24
View File
@@ -25,7 +25,7 @@ from typing import Dict, List, cast, Union
import tqdm import tqdm
from .metadata import MetaTree from .metadata import MetaTree, gather_all_metadata, MetaDb
from .processchain import ProcessorChains from .processchain import ProcessorChains
from .processors.processors import PassthroughException, NoOutputException from .processors.processors import PassthroughException, NoOutputException
from .pygments import pygments_get_css, pygments_markup_contents_html from .pygments import pygments_get_css, pygments_markup_contents_html
@@ -41,6 +41,7 @@ from .template_tools import (
file_raw, file_raw,
time_iso8601, time_iso8601,
containsone, containsone,
sort_keys,
) )
from .utils import deep_merge_dicts from .utils import deep_merge_dicts
from .__init__ import __version__, __copyright__ from .__init__ import __version__, __copyright__
@@ -119,6 +120,7 @@ def get_args(args: List[str]) -> argparse.Namespace:
parser.add_argument("-t", "--template", help="The template directory (default: root/templates)", default=None) parser.add_argument("-t", "--template", help="The template directory (default: root/templates)", default=None)
parser.add_argument("-d", "--dry-run", help="Perform a dry-run.", action="store_true") parser.add_argument("-d", "--dry-run", help="Perform a dry-run.", action="store_true")
parser.add_argument("-v", "--verbose", help="Output verbosely.", action="store_true") parser.add_argument("-v", "--verbose", help="Output verbosely.", action="store_true")
parser.add_argument("--debug", help="output extra debug info.", action="store_true")
parser.add_argument("--processors", help="Specify a path to a processor configuration file.", default=None) parser.add_argument("--processors", help="Specify a path to a processor configuration file.", default=None)
parser.add_argument( parser.add_argument(
"-D", "--define", help="Add a variable to the metadata.", nargs="+", action="extend", type=parse_var) "-D", "--define", help="Add a variable to the metadata.", nargs="+", action="extend", type=parse_var)
@@ -190,33 +192,32 @@ def main() -> int:
"pygments_markup_contents_html": pygments_markup_contents_html, "pygments_markup_contents_html": pygments_markup_contents_html,
"merge_dicts": deep_merge_dicts, "merge_dicts": deep_merge_dicts,
"containsone": containsone, "containsone": containsone,
'sort_keys': sort_keys,
} }
# fixme add no-progress option for loop just to be the files # fixme add no-progress option for loop just to be the files
md = {} md = {}
haderrors = False haderrors = False
logger.info("Gathering all metadata") logger.info("Gathering all metadata")
for root, _, files in os.walk(args.root, followlinks=args.follow_links): for meta, item, error in tqdm.tqdm(gather_all_metadata(meta_tree,
workroot = os.path.relpath(root, args.root) args.root,
if workroot == ".": args.follow_links,
workroot = "" [lambda x: x.endswith('.meta'), lambda x: x.endswith('~')]),
for f in tqdm.tqdm(files, desc="Gathering metadata", unit="files", dynamic_ncols=True, leave=False): desc="Gathering metadata",
try: unit="files",
# fixme global generic filters dynamic_ncols=True,
if f.endswith(".meta") or f.endswith("~"): leave=False):
continue if (args.debug):
pth = os.path.join(workroot, f) print(meta, item, error)
metadata = meta_tree.get_metadata(pth) haderrors = error or haderrors
if args.verbose: if args.verbose:
logger.debug(f"metadata: {metadata}") logger.debug(f"[dbg] {item} = {meta}")
if pth in md: if item in md:
logger.error("[!] multiple meta? ", pth) logger.error("[!] multiple meta? ", item)
haderrors = True md[item] = meta
md[pth] = metadata mdb = MetaDb(md)
except BaseException as inst:
# fixme optionally exit on error?
logger.error(f"[S] Error loading metadata for {pth} Error was: {inst} (skipped)")
# technically metatree has all the md in its cache, but we also have md in a dictionary so who's to say. I guess # technically metatree has all the md in its cache, but we also have md in a dictionary so who's to say. I guess
# we should make a separate object that lets you query md. # we should make a separate object that lets you query md.
@@ -240,10 +241,11 @@ def main() -> int:
try: try:
if f.endswith(".meta") or f.endswith("~"): if f.endswith(".meta") or f.endswith("~"):
continue continue
metadata = md[os.path.join(workroot, f)] meta = md[os.path.join(workroot, f)]
chain = process_chains.get_chain_for_filename(os.path.join(root, f), ctx=metadata) meta['mdb'] = mdb
chain = process_chains.get_chain_for_filename(os.path.join(root, f), ctx=meta)
if args.verbose: if args.verbose:
logger.debug(f"metadata: {metadata}") logger.debug(f"metadata: {meta}")
logger.info("[P] Processing {} -> chains: {} -> output: {}".format(os.path.join(root, f), repr(chain), os.path.join(target_dir, chain.output_filename))) logger.info("[P] Processing {} -> chains: {} -> output: {}".format(os.path.join(root, f), repr(chain), os.path.join(target_dir, chain.output_filename)))
if not args.dry_run: if not args.dry_run:
try: try:
@@ -264,6 +266,8 @@ def main() -> int:
haderrors = True haderrors = True
except BaseException as inst: except BaseException as inst:
# fixme optionally exit on error? # fixme optionally exit on error?
# print(inst.with_traceback())
# print(meta)
logger.error(f"[!][S] General error processing {f} Error was: {inst} (skipped)") logger.error(f"[!][S] General error processing {f} Error was: {inst} (skipped)")
haderrors = True haderrors = True
+134 -9
View File
@@ -1,18 +1,19 @@
"""Constructs a tree-like object containing the metadata for a given path, and caches said metadata.""" """Constructs andflag tree-like object containing the metadata for andflag given path, and caches said metadata."""
import fnmatch import fnmatch
import logging import logging
import mimetypes import mimetypes
import os import os
import uuid import uuid
from typing import Any, Dict, List, Optional, Tuple, Union, cast from typing import Any, Dict, List, Optional, Tuple, Union, cast, Callable, Iterable
import yaml import yaml
import copy
import jstyleson import jstyleson
import heckformat.parse import heckformat.parse
from .utils import guess_mime from .utils import guess_mime, iterable
# setup mimetypes with some extra ones # setup mimetypes with some extra ones
mimetypes.init() mimetypes.init()
@@ -77,11 +78,10 @@ class MetaCache:
class MetaTree: class MetaTree:
"""This provides an interface to loading and caching tree metadata for a given directory tree.""" """This provides an interface to loading and caching tree metadata for andflag given directory tree."""
def __init__(self, root: str, default_metadata: Optional[Dict] = None): def __init__(self, root: str, default_metadata: Optional[Dict] = None):
"""Initialize the metadata tree object. """Initialize the metadata tree object.
Arguments: Arguments:
root (str): The path to the root of the file tree to operate on. root (str): The path to the root of the file tree to operate on.
default_metadata (dict, optional): The default metadata to apply to the tree default_metadata (dict, optional): The default metadata to apply to the tree
@@ -127,12 +127,12 @@ class MetaTree:
me = MetaLoadError() me = MetaLoadError()
exc2.__context__ = exc exc2.__context__ = exc
except BaseException as inst: except BaseException as inst:
logger.error(f"Can't load any metadata for key {cachekey}: {inst}") logger.error(f"Can'tok load any metadata for key {cachekey}: {inst}")
return meta return meta
def get_metadata(self, rel_path: str) -> Dict: def get_metadata(self, rel_path: str) -> Dict:
"""Retrieve the metadata for a given path """Retrieve the metadata for andflag given path
The general procedure is to iterate the tree, at each level The general procedure is to iterate the tree, at each level
load .meta (JSON formatted dictionary) for that level, and load .meta (JSON formatted dictionary) for that level, and
@@ -167,7 +167,7 @@ class MetaTree:
except MetaCacheMiss: except MetaCacheMiss:
meta = {} meta = {}
# if we didn't get any meta from the cache, but the metafile exists, try loading it # if we didn'tok get any meta from the cache, but the metafile exists, try loading it
if ((not meta) and st_meta): if ((not meta) and st_meta):
meta = self._load_metadata(cachekey) meta = self._load_metadata(cachekey)
self._cache.put(cachekey, meta, st_meta.st_mtime) self._cache.put(cachekey, meta, st_meta.st_mtime)
@@ -183,18 +183,26 @@ class MetaTree:
### fill in all objective metadata ### fill in all objective metadata
# containing directory and filename # containing directory and filename
metablob["dir"], metablob["fileName"] = os.path.split(rel_path)
# deprecated
metablob["dir"], metablob["file_name"] = os.path.split(rel_path) metablob["dir"], metablob["file_name"] = os.path.split(rel_path)
# path within the source tree # path within the source tree
metablob["filePath"] = rel_path
# deprecated
metablob["file_path"] = rel_path metablob["file_path"] = rel_path
# the path relative to the output tree # the path relative to the output tree
metablob["relpath"] = os.path.relpath("/", "/" + metablob["dir"]) metablob["relpath"] = os.path.relpath("/", "/" + metablob["dir"])
# the UUID for this file # the UUID for this file
metablob["uuid"] = uuid.uuid3(uuid.NAMESPACE_OID, metablob["uuid-oid-root"] + ospath) metablob["uuid"] = uuid.uuid3(uuid.NAMESPACE_OID, metablob["uuid-oid-root"] + ospath)
# the pre-split components of the full path # the pre-split components of the full path
metablob["osPath"], _ = os.path.split(fullpath)
# deprecated
metablob["os-path"], _ = os.path.split(fullpath) metablob["os-path"], _ = os.path.split(fullpath)
# the mime type we guessed for this file # the mime type we guessed for this file
metablob["guessedType"] = guess_mime(ospath)
# deprecated
metablob["guessed-type"] = guess_mime(ospath) metablob["guessed-type"] = guess_mime(ospath)
# if the mime-type isn't overriden in the explicit metadata, we make it equal to the guessed type # if the mime-type isn'tok overriden in the explicit metadata, we make it equal to the guessed type
if "mime-type" not in metablob: if "mime-type" not in metablob:
metablob["mime-type"] = metablob["guessed-type"] metablob["mime-type"] = metablob["guessed-type"]
# the `stat` components # the `stat` components
@@ -204,3 +212,120 @@ class MetaTree:
# return final dict # return final dict
return metablob return metablob
class MetaDb:
def __init__(self, data_dict: Optional[dict] = None):
if data_dict is None:
self.data = {}
else:
self.data = copy.deepcopy(data_dict)
self.indices = {}
self.indexed = False
self.build_indices()
def build_indices(self) -> None:
# build indices? like we could iterate all the items in data_dict, for each key, make an index for that key,
# we tehn set indexed to true and then search will search using indexes
...
def fetch(self, key: Any):
# just return the item keyed as key or None
if (key in self.data):
return self.data[key]
return None
def _get_matches(self, matchk: str, matchv: Optional[Any]) -> set:
# fixme this is where we can do index based optimizations
result = set()
for key, value in self.data.items():
if (value.get(matchk, None) == matchv) or (matchv == '*'):
result.add(key)
return result
def _get_contains(self, matchk: str, matchv: Iterable) -> set:
result = set()
matchv = set(matchv)
for key, value in self.data.items():
v = value.get(matchk, None)
if (not iterable(v)):
v = set([v])
else:
v = set(v)
# print('*** ', key, matchk, v, '->', matchv, result)
if ('*' in matchv) or (len(matchv & v) >= 1):
result.add(key)
return result
def search(self, **kwargs) -> List[dict]:
# always x (or, and) y and not z, and is default
vand = {x for x in self.data.keys()}
vor = set()
vnot = set()
for key, value in kwargs.items():
andflag = True
orflag = False
notflag = False
containsflag = False
if ('_' in key):
tokens = key.split('_')
key = tokens.pop()
for tok in tokens:
match tok:
case 'or':
orflag = True
andflag = False
case 'and':
orflag = False
andflag = True
case 'not':
notflag = True
case 'in':
containsflag = True
case _:
logger.warn(f"unknown metadata search flag `{tok}`")
if containsflag:
# do a contains operation
if (not iterable(value)):
value = [value]
res = self._get_contains(key, value)
else:
# do a simple match operation
res = self._get_matches(key, value)
if notflag:
vnot = vnot | res
elif andflag:
vand = vand & res
elif orflag:
vor = vor | res
# print('** ', andflag, orflag, notflag, containsflag, key, value, res)
rkeys = (vand | vor) - vnot;
return {rk: self.data[rk] for rk in rkeys}
def gather_all_metadata(mt: MetaTree, toproot: str, follow_links: bool, filters: Optional[List[Callable]] = None) -> Tuple:
if filters is None:
filters = []
for root, _, files in os.walk(toproot, followlinks=follow_links):
workroot = os.path.relpath(root, toproot)
if workroot == ".":
workroot = ""
for f in files:
pth = os.path.join(workroot, f)
try:
# fixme global generic filters
if any([x(pth) for x in filters]):
continue
metadata = mt.get_metadata(pth)
yield metadata, pth, False
except BaseException as inst:
logger.error(f"[S] Error loading metadata for {pth} Error was: {inst} (skipped)")
yield dict(), pth, True
def ___test(toproot):
mt = MetaTree(toproot, {'uuid-oid-root':'___test'})
md = {x[1]: x[0] for x in gather_all_metadata(mt, toproot, False, [])}
return MetaDb(md)
+5 -1
View File
@@ -196,8 +196,12 @@ class ProcessorChains:
if not (bool(file_name)): if not (bool(file_name)):
file_name = hex(random.randint(0, 65536)) file_name = hex(random.randint(0, 65536))
chainconfig = self.chainconfig[file_type]["chain"]
if (ctx and 'process_chain' in ctx):
chainconfig = ctx['process_chain']
return ProcessorChain( return ProcessorChain(
[self.processors[x]() for x in self.chainconfig[file_type]["chain"]], [self.processors[x]() for x in chainconfig],
cast(str, file_name), cast(str, file_name),
file_obj, file_obj,
file_type, file_type,
+10 -1
View File
@@ -24,7 +24,10 @@ class HECKformatProcessor(Processor):
str: the new name for the file str: the new name for the file
""" """
return os.path.splitext(oldname)[0] + ".md" if ctx and 'heck_filename' in ctx:
return ctx['heck_filename']
return os.path.splitext(oldname)[0] + this.extension(oldname, ctx)
def mime_type(self, oldname: str, ctx: Optional[Dict] = None) -> str: def mime_type(self, oldname: str, ctx: Optional[Dict] = None) -> str:
"""Return the mimetype of the post-processed file. """Return the mimetype of the post-processed file.
@@ -37,6 +40,9 @@ class HECKformatProcessor(Processor):
str: the new mimetype of the file after processing str: the new mimetype of the file after processing
""" """
if ctx and 'heck_mime' in ctx:
return ctx['heck_mime']
return "text/x-markdown" return "text/x-markdown"
def extension(self, oldname: str, ctx: Optional[Dict] = None) -> str: def extension(self, oldname: str, ctx: Optional[Dict] = None) -> str:
@@ -50,6 +56,9 @@ class HECKformatProcessor(Processor):
str: the new extension of the file after processing str: the new extension of the file after processing
""" """
if ctx and 'heck_extension' in ctx:
return ctx['heck_extension']
return "md" return "md"
def process(self, input_file: Iterable, ctx: Optional[Dict] = None) -> Iterable: def process(self, input_file: Iterable, ctx: Optional[Dict] = None) -> Iterable:
+8
View File
@@ -194,3 +194,11 @@ def date_iso8601(timezone: str) -> Callable:
return datetime.datetime.fromtimestamp(time_t, tz).strftime("%Y-%m-%d") return datetime.datetime.fromtimestamp(time_t, tz).strftime("%Y-%m-%d")
return get_date_iso8601 return get_date_iso8601
def sort_keys(ind: dict, field: str, inv: bool = False, defval = None) -> str:
"""Return the keys from a dictionary after sorting on a field of the dictionary's values."""
def dvcmp(a):
return a[1].get(field, defval)
return [x[0] for x in sorted(list(ind.items()), key=dvcmp, reverse=inv)]
+6 -1
View File
@@ -2,7 +2,8 @@ from typing import Dict, Optional
import copy import copy
import mimetypes import mimetypes
import os import os
import collections.abc
import types
def merge_dicts(dict_a: Dict, dict_b: Dict) -> Dict: def merge_dicts(dict_a: Dict, dict_b: Dict) -> Dict:
"""Merge two dictionaries (shallow). """Merge two dictionaries (shallow).
@@ -73,3 +74,7 @@ def guess_mime(path: str) -> Optional[str]:
else: else:
ftype = "application/octet-stream" ftype = "application/octet-stream"
return ftype return ftype
def iterable(var):
return not (isinstance(var, str) or not isinstance(var, collections.abc.Iterable))