From 0981a81173bd34ce1ddcea0c4db07c14397a817d Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Wed, 10 Dec 2025 08:57:36 -0800 Subject: [PATCH 1/6] Add stopwatch class to utils --- pretext/utils.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pretext/utils.py b/pretext/utils.py index 602b5faf..eca2211a 100644 --- a/pretext/utils.py +++ b/pretext/utils.py @@ -12,6 +12,7 @@ import socketserver import socket import subprocess +import time as time import logging import logging.handlers import psutil @@ -1104,3 +1105,30 @@ def rs_methods( f"PTX-BUG: Format {format} not recognized for running ext_rs_methods. Something is wrong with the pretext script." ) return None + + +class Stopwatch: + """A simple stopwatch class for measuring elapsed time.""" + + """print_log set to false disables logging of elapsed time """ + + def __init__(self, name: str = "", print_log: bool = True): + self.name = name + self.print_log = print_log + self.start_time = time.time() + self.last_log_time = self.start_time + + def reset(self): + """Reset the log timer to the current time.""" + self.last_log_time = time.time() + + def log(self, timepoint_description: str = ""): + """Print a log message with the elapsed time since the last log event.""" + if self.print_log: + cur_time = time.time() + elapsed_time = cur_time - self.start_time + since_last_log_time = cur_time - self.last_log_time + self.reset() + log.debug( + f"** Timing report from {self.name}: {timepoint_description}, {since_last_log_time:.2f}s since last watch reset. {elapsed_time:.2f}s total elapsed time." + ) From 9440e66c7043b76d21ca1b5f2cb3f611e32718ee Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Wed, 10 Dec 2025 08:35:16 -0800 Subject: [PATCH 2/6] Don't always inspect source for Runestone target --- pretext/project/__init__.py | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/pretext/project/__init__.py b/pretext/project/__init__.py index d9bad097..931b60f0 100644 --- a/pretext/project/__init__.py +++ b/pretext/project/__init__.py @@ -368,29 +368,9 @@ def post_validate(self) -> None: if server.name not in self_server_names: self.server.append(server) - # For the Runestone format, determine the ``, which specifies the `output_dir`. - if self.format == Format.HTML and self.platform == Platform.RUNESTONE: - # We expect `d_list == ["document-id contents here"]`. - d_list = self.source_element().xpath("/pretext/docinfo/document-id/text()") - if isinstance(d_list, list): - if len(d_list) != 1: - raise ValueError( - "Only one `document-id` tag is allowed in a PreTeXt document." - ) - # NB as of 2025-04-08, we are no longer setting the output directory automatically for - # Runestone targets. This must be managed by the project.ptx file or by a client script. - # The commented code below is how we used to do this. - - # d = d_list[0] - # assert isinstance(d, str) - # # Use the correct number of `../` to undo the project's `output-dir`, so the output from the build is located in the correct directory of `published/document-id`. - # self.output_dir = Path( - # f"{'../'*len(self._project.output_dir.parents)}published/{d}" - # ) - else: - raise ValueError( - "The `document-id` tag must be defined for the Runestone format." - ) + # NB as of 2025-04-08, we are no longer setting the output directory automatically for + # Runestone targets. This must be managed by the project.ptx file or by a client script. + # Check history here for how that was done. def source_abspath(self) -> Path: return self._project.source_abspath() / self.source From 00c13b1e56b9e427e15281d2eb56d56e094490c8 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Wed, 10 Dec 2025 09:33:02 -0800 Subject: [PATCH 3/6] Cache results of Target.source_element() --- pretext/project/__init__.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/pretext/project/__init__.py b/pretext/project/__init__.py index 931b60f0..066722ad 100644 --- a/pretext/project/__init__.py +++ b/pretext/project/__init__.py @@ -128,6 +128,8 @@ class Target(pxml.BaseXmlModel, tag="target", search_mode=SearchMode.UNORDERED): # # A path to the root source for this target, relative to the project's `source` path. source: Path = pxml.attr(default=Path("main.ptx")) + # Cache of assembled source element. + _source_element: t.Optional[ET._Element] = None # A path to the publication file for this target, relative to the project's `publication` path. This is mostly validated by `post_validate`. publication: Path = pxml.attr(default=None) latex_engine: LatexEngine = pxml.attr( @@ -388,14 +390,21 @@ def original_source_element(self) -> ET._Element: def source_element(self) -> ET._Element: """ Returns the root element for the assembled source, after processing with the "version-only" assembly. + Caches the result for future calls. """ - assembled = core.assembly_internal( - xml=self.source_abspath(), - pub_file=self.publication_abspath().as_posix(), - stringparams=self.stringparams.copy(), - method="version", - ) - return assembled.getroot() + if self._source_element is None: + log.debug( + f"Parsing source element for target {self.name}", + ) + self._source_element = core.assembly_internal( + xml=self.source_abspath(), + pub_file=self.publication_abspath().as_posix(), + stringparams=self.stringparams.copy(), + method="version", + ) + else: + log.debug(f"Using cached source_element for target {self.name}") + return self._source_element.getroot() def publication_abspath(self) -> Path: return self._project.publication_abspath() / self.publication From 47ea2081a712f3b4d1c9b0677596cde9c497c2c5 Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Wed, 10 Dec 2025 09:38:30 -0800 Subject: [PATCH 4/6] Rely on Target.source_element for XML syntax validation --- pretext/project/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pretext/project/__init__.py b/pretext/project/__init__.py index 066722ad..2bcae429 100644 --- a/pretext/project/__init__.py +++ b/pretext/project/__init__.py @@ -671,7 +671,11 @@ def build( self.stringparams["cli.version"] = VERSION[: VERSION.rfind(".")] # Check for xml syntax errors and quit if xml invalid: - if not utils.xml_syntax_is_valid(self.source_abspath()): + try: + # Access the source_element to trigger assembly if it hasn't been done yet. + self.source_element() + except Exception as e: + log.error(f"Error assembling source file: {e}") raise RuntimeError("XML syntax for source file is invalid") if not utils.xml_syntax_is_valid(self.publication_abspath(), "publication"): raise RuntimeError("XML syntax for publication file is invalid") From ce11b6cadb52f8a76d57c317218645fb5048c9ba Mon Sep 17 00:00:00 2001 From: Andrew Scholer Date: Wed, 10 Dec 2025 08:44:39 -0800 Subject: [PATCH 5/6] Schema validation reuses existing ETree --- pretext/utils.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pretext/utils.py b/pretext/utils.py index eca2211a..d55a6b3b 100644 --- a/pretext/utils.py +++ b/pretext/utils.py @@ -204,17 +204,13 @@ def xml_syntax_is_valid(xmlfile: Path, root_tag: str = "pretext") -> bool: return True -def xml_source_validates_against_schema(xmlfile: Path) -> bool: +def xml_validates_against_schema(etree: ET) -> bool: # get path to RelaxNG schema file: schemarngfile = resources.resource_base_path() / "core" / "schema" / "pretext.rng" # Open schemafile for validation: relaxng = ET.RelaxNG(file=schemarngfile) - # Parse xml file: - source_xml = ET.parse(xmlfile) - source_xml.xinclude() - # just for testing # ---------------- # relaxng.validate(source_xml) @@ -223,7 +219,7 @@ def xml_source_validates_against_schema(xmlfile: Path) -> bool: # validate against schema try: - relaxng.assertValid(source_xml) + relaxng.assertValid(etree) log.info("PreTeXt source passed schema validation.") except ET.DocumentInvalid as err: log.debug( From a0d62f01f8149fed466331f1b6d9a42569bdc6cf Mon Sep 17 00:00:00 2001 From: Oscar Levin Date: Sat, 13 Dec 2025 13:32:14 -0700 Subject: [PATCH 6/6] fix types --- pretext/project/__init__.py | 6 +++--- pretext/utils.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pretext/project/__init__.py b/pretext/project/__init__.py index 2bcae429..73838c40 100644 --- a/pretext/project/__init__.py +++ b/pretext/project/__init__.py @@ -401,10 +401,10 @@ def source_element(self) -> ET._Element: pub_file=self.publication_abspath().as_posix(), stringparams=self.stringparams.copy(), method="version", - ) + ).getroot() else: log.debug(f"Using cached source_element for target {self.name}") - return self._source_element.getroot() + return self._source_element def publication_abspath(self) -> Path: return self._project.publication_abspath() / self.publication @@ -680,7 +680,7 @@ def build( if not utils.xml_syntax_is_valid(self.publication_abspath(), "publication"): raise RuntimeError("XML syntax for publication file is invalid") # Validate xml against schema; continue with warning if invalid: - utils.xml_source_validates_against_schema(self.source_abspath()) + utils.xml_validates_against_schema(self.source_element()) # Clean output upon request if clean: diff --git a/pretext/utils.py b/pretext/utils.py index d55a6b3b..86b2b6e0 100644 --- a/pretext/utils.py +++ b/pretext/utils.py @@ -204,7 +204,7 @@ def xml_syntax_is_valid(xmlfile: Path, root_tag: str = "pretext") -> bool: return True -def xml_validates_against_schema(etree: ET) -> bool: +def xml_validates_against_schema(etree: _Element) -> bool: # get path to RelaxNG schema file: schemarngfile = resources.resource_base_path() / "core" / "schema" / "pretext.rng" @@ -1114,11 +1114,11 @@ def __init__(self, name: str = "", print_log: bool = True): self.start_time = time.time() self.last_log_time = self.start_time - def reset(self): + def reset(self) -> None: """Reset the log timer to the current time.""" self.last_log_time = time.time() - def log(self, timepoint_description: str = ""): + def log(self, timepoint_description: str = "") -> None: """Print a log message with the elapsed time since the last log event.""" if self.print_log: cur_time = time.time()