Skip to content

Commit c2ee44b

Browse files
authored
Automatic generation of needed assets (#447)
* add methods for hashing assets, saving and loading * add automatic generation of missing assets * add --no-generate option, improve logs
1 parent 69613ce commit c2ee44b

2 files changed

Lines changed: 109 additions & 11 deletions

File tree

pretext/cli.py

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,13 @@ def init(refresh):
326326
type=click.Choice(ASSETS, case_sensitive=False),
327327
help="Generates assets for target. -g [asset] will generate the specific assets given.",
328328
)
329+
@click.option(
330+
"-q",
331+
"--no-generate",
332+
is_flag=True,
333+
default=False,
334+
help="Do not generate assets for target, even if their source has changed since last build.",
335+
)
329336
@click.option(
330337
"-x", "--xmlid", type=click.STRING, help="xml:id of element to be generated."
331338
)
@@ -340,16 +347,14 @@ def build(
340347
target,
341348
clean,
342349
generate,
350+
no_generate,
343351
xmlid: t.Optional[str],
344352
project_ptx_override: t.Tuple[str, str],
345353
):
346354
"""
347355
Build [TARGET] according to settings specified by project.ptx.
348356
349-
If using certain elements (webwork, latex-image, etc.) then
350-
using `--generate` may be necessary for a successful build. Generated
351-
assets are cached so they need not be regenerated in subsequent builds unless
352-
they are changed.
357+
If using elements that require separate generation of assets (e.g., webwork, latex-image, etc.) then these will be generated automatically if their source has changed since the last build. You can suppress this with the `--no-generate` flag, or force a regeneration with the `--generate` flag.
353358
354359
Certain builds may require installations not included with the CLI, or internet
355360
access to external servers. Command-line paths
@@ -375,23 +380,60 @@ def build(
375380
target = project.target(name=target_name)
376381
if target_name is None:
377382
log.info(
378-
f"Since no build target was supplied, the first target of the project.ptx manifest ({target.name()}) will be built."
383+
f"Since no build target was supplied, the first target of the project.ptx manifest ({target.name()}) will be built.\n"
379384
)
380385
target_name = target.name()
381386
if target is None:
382387
utils.show_target_hints(target_name, project, task="build")
383388
log.critical("Exiting without completing build.")
384389
return
390+
# Automatically generate any assets that have changed.
391+
if not no_generate:
392+
asset_table = target.load_asset_table()
393+
asset_hash_dict = target.asset_hash()
394+
if asset_table == asset_hash_dict:
395+
log.info(
396+
"No change in assets requiring generating detected. To force regeneration of assets, use `-g` flag.\n"
397+
)
398+
else:
399+
for asset in set(asset[0] for asset in asset_hash_dict.keys()):
400+
if asset in ["webwork"]:
401+
if (asset, "") not in asset_table or asset_hash_dict[
402+
(asset, "")
403+
] != asset_table[(asset, "")]:
404+
project.generate(target.name(), asset_list=[asset])
405+
elif (asset, "") not in asset_table or asset_hash_dict[
406+
(asset, "")
407+
] != asset_table[(asset, "")]:
408+
project.generate(target.name(), asset_list=[asset])
409+
else:
410+
for id in set(
411+
key[1] for key in asset_hash_dict.keys() if key[0] == asset
412+
):
413+
if (asset, id) not in asset_table or asset_hash_dict[
414+
(asset, id)
415+
] != asset_table[(asset, id)]:
416+
log.info(
417+
f"\nIt appears the source has changed of an asset that needs to be generated. Now generating asset: {asset} with xmlid: {id}."
418+
)
419+
project.generate(
420+
target.name(), asset_list=[asset], xmlid=id
421+
)
422+
target.save_asset_table(target.asset_hash())
423+
else:
424+
log.info("Skipping asset generation as requested.")
385425
if generate == "ALL":
386-
log.info("Generating all assets in default formats.")
426+
log.info("Generating all assets in default formats as requested.")
427+
log.info(
428+
"Note: PreTeXt will automatically generate assets that have been changed since your last build, so this option is no longer necessary unless something isn't happening as expected."
429+
)
387430
project.generate(target.name())
388431
elif generate is not None:
389-
log.warning(f"Generating only {generate} assets.")
432+
log.info(f"Generating {generate} assets as requested.")
433+
log.info(
434+
"Note: PreTeXt will automatically generate assets that have been changed since your last build, so this option is no longer necessary unless something isn't happening as expected."
435+
)
390436
project.generate(target.name(), asset_list=[generate])
391-
else:
392-
log.warning("Assets like latex-images will not be regenerated for this build")
393-
log.warning("(previously generated assets will be used if they exist).")
394-
log.warning("To generate these assets before building, run `pretext build -g`.")
395437
project.build(target.name(), clean)
396438

397439

pretext/project.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pickle
12
from lxml import etree as ET
23
from lxml.etree import Element
34
import os
@@ -6,10 +7,12 @@
67
import tempfile
78
from . import utils, generate, core
89
from . import build as builder
10+
from . import ASSETS
911
from pathlib import Path
1012
import sys
1113
from .config.xml_overlay import ShadowXmlDocument
1214
import typing as t
15+
import hashlib
1316

1417
log = logging.getLogger("ptxlogger")
1518

@@ -125,6 +128,59 @@ def xmlid_root(self):
125128
else:
126129
return ele.text.strip()
127130

131+
def asset_hash(self):
132+
asset_hash_dict = {}
133+
for asset in ASSETS:
134+
if asset == "webwork":
135+
# WeBWorK must be regenerated every time *any* of the ww exercises change.
136+
if len(self.source_xml().xpath(".//webwork[@*|*]")) == 0:
137+
# Only generate a hash if there are actually ww exercises in the source
138+
continue
139+
h = hashlib.sha256()
140+
for node in self.source_xml().xpath(".//webwork[@*|*]"):
141+
h.update(ET.tostring(node))
142+
asset_hash_dict[(asset, "")] = h.digest()
143+
elif asset != "ALL":
144+
# everything else can be updated individually, if it has an xml:id
145+
if len(self.source_xml().xpath(f".//{asset}")) == 0:
146+
# Only generate a hash if there are actually assets of this type in the source
147+
continue
148+
h_no_id = hashlib.sha256()
149+
for node in self.source_xml().xpath(f".//{asset}"):
150+
# First see if the node has an xml:id, or if it is a child of a node with an xml:id (but we haven't already made this key)
151+
if (
152+
id := node.xpath("@xml:id") or node.xpath("parent::*/@xml:id")
153+
) and (asset, id[0]) not in asset_hash_dict:
154+
asset_hash_dict[(asset, id[0])] = hashlib.sha256(
155+
ET.tostring(node)
156+
).digest()
157+
# otherwise collect all non-id'd nodes into a single hash
158+
else:
159+
h_no_id.update(ET.tostring(node))
160+
asset_hash_dict[(asset, "")] = h_no_id.digest()
161+
return asset_hash_dict
162+
163+
def save_asset_table(self, asset_table: dict):
164+
"""
165+
Saves the asset_table to a pickle file in the generated assets directory based on the target name.
166+
"""
167+
with open(
168+
self.generated_dir().joinpath(f".{self.name()}_assets.pkl"), "wb"
169+
) as f:
170+
pickle.dump(asset_table, f)
171+
172+
def load_asset_table(self) -> dict:
173+
"""
174+
Loads the asset_table from a pickle file in the generated assets directory based on the target name.
175+
"""
176+
try:
177+
with open(
178+
self.generated_dir().joinpath(f".{self.name()}_assets.pkl"), "rb"
179+
) as f:
180+
return pickle.load(f)
181+
except Exception:
182+
return {}
183+
128184

129185
class Project:
130186
def __init__(self, project_path=None):

0 commit comments

Comments
 (0)