diff --git a/CHANGELOG.md b/CHANGELOG.md index e71b78a1..a77113d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ Instructions: Add a subsection under `[Unreleased]` for additions, fixes, change ## [Unreleased] +### Added + +- The `jing` executable can now be set in `executables.ptx`, so validation can use a jing that isn't on your search path (including a command with options, such as `java -jar /usr/share/java/jing.jar`). +- `pretext validate --method` chooses how to validate: `local` (an installed jing, the default), `local-dev` (the development schema, same as `--dev`), `server` (jing as a remote service, needing no local install), or `terse` (machine-readable, one tab-separated message per line). +- `pretext validate --engine salve` (EXPERIMENTAL) runs the RelaxNG check with the salve-annos validator from the pretext-tools VS Code extension instead of jing, needing no Java. It installs itself with npm on first use, and needs node. It finds the same problems as jing, but words them differently and does not repeat follow-on messages, so a report from it is not comparable to jing's line for line. Please try it and report differences. + +### Changed + +- `pretext validate` now produces core's consolidated validation report, written to `logs/`. Alongside the schema messages from jing it includes the "validation-plus" checks that no schema can express, and every message names the source file, path, line, and an excerpt of the offending text -- rather than line numbers of an assembled file you never see. +- Schema validation during a build or generate now warns with a count and writes the messages to `logs/schema-errors.log` (with the assembled source they refer to alongside), instead of putting them on the terminal. It is skipped when jing isn't installed, and never blocks a build. +- Validation now uses jing exclusively; lxml is no longer tried, since it cannot compile the PreTeXt schema (which incorporates PreFigure's). +- External directory specification should now go in the `` element rather than the publication file. +- The `liblouis`, `pdfsvg`, and `pdfpng` executables are no longer used (braille goes through the `louis` Python bindings, and PDF conversion through pyMuPDF). They are still accepted in `executables.ptx`, with a warning, so existing projects keep working. + +### Fixed + +- Legacy (v1) `project.ptx` manifests now supply the full set of executables to core, instead of omitting `mermaid`, `perl`, `fop`, and `jing`. + ## [2.45.0] - 2026-07-26 Includes updates to core through commit: [eec7477](https://github.com/PreTeXtBook/pretext/commit/eec74773ac46acb74bb423b7b216d64b2416ee27) diff --git a/pretext/__init__.py b/pretext/__init__.py index 05c4944b..ba2affea 100644 --- a/pretext/__init__.py +++ b/pretext/__init__.py @@ -18,7 +18,7 @@ VERSION = get_version("pretext", Path(__file__).parent.parent) -CORE_COMMIT = "1939b8c6c1cef11fb1c582ff69f49ce2ea8cfd27" +CORE_COMMIT = "ea9c025222639b69dd7bf87dd3b9a48b507b8418" def activate() -> None: diff --git a/pretext/cli.py b/pretext/cli.py index 66b06096..6866d39c 100644 --- a/pretext/cli.py +++ b/pretext/cli.py @@ -1,4 +1,3 @@ -from datetime import datetime import importlib import importlib.util import logging @@ -712,12 +711,12 @@ def build( # Otherwise, we can report success. log.info("\nSuccess! Built requested target(s) without errors.\n") except ValidationError as e: - # A validation error at this point must be because the publication file is invalid, which only happens if the /source/directories/@generated|@external attributes are missing. + # A validation error at this point must be because the project or publication file failed to validate against the model the CLI builds from. log.critical( - "It appears there is an error with your publication file. Are you missing the required source/directories/@external and @generated attributes?" + "It appears there is an error with your project.ptx or publication file. See the details below." ) + log.critical(e) log.critical("Failed to build without errors. Exiting...") - log.debug(e) log.debug( "\n------------------------\nException info:\n------------------------\n", exc_info=True, @@ -743,48 +742,84 @@ def build( help="Validate against the development schema (pretext-dev.rng) instead of the " "stable schema, allowing experimental elements.", ) +@click.option( + "--method", + type=click.Choice(["local", "local-dev", "server", "terse"]), + default=None, + help='How to validate: "local" (an installed jing, the default), "local-dev" ' + '(as "local", against the development schema), "server" (jing as a remote ' + 'service, needing no local install), or "terse" (machine-readable, one ' + "tab-separated message per line).", +) +@click.option( + "--engine", + type=click.Choice(["jing", "salve"]), + default="jing", + show_default=True, + help='Which RelaxNG engine performs the check. EXPERIMENTAL: "salve" uses ' + "the validator behind the pretext-tools VS Code extension instead of jing, " + "and needs no Java (it installs itself with npm on first use). It finds the " + "same problems but words them differently, so reports are not comparable " + "line for line. Ignored by `--method server`.", +) @click.pass_context @nice_errors -def validate(ctx: click.Context, target_name: Optional[str], dev: bool) -> None: +def validate( + ctx: click.Context, + target_name: Optional[str], + dev: bool, + method: Optional[str], + engine: str, +) -> None: """ Validate the source of TARGET against the PreTeXt RelaxNG schema. - Reports schema errors and exits with a non-zero status when the document is - invalid, so it can gate CI or pre-commit checks. Without TARGET, the first - target in project.ptx is used. Exit codes: 0 = valid, 1 = invalid, 2 = - validation could not be performed (no validator available). + Writes the consolidated validation report: the schema messages from jing + together with those of the "validation-plus" stylesheet, each naming the + source file, path, and line it came from. Without TARGET, the first target + in project.ptx is used. Exit codes: 0 = valid, 1 = invalid, 2 = validation + could not be performed (no validator available). """ project = ctx.obj["project"] target = project.get_target(target_name) # Assemble the source (resolves xinclude); surfaces syntax/xinclude errors. try: - etree = target.source_element() + target.source_element() except Exception as e: log.error(f"Could not assemble source for validation: {e}") raise SystemExit(1) - schema_file = utils.schema_path(dev) - log.info(f"Validating source against schema {schema_file.name}.") - is_valid, error_text = utils.run_schema_validation( - etree, schema_file, order=("jing", "lxml") + if method is None: + method = "local-dev" if dev else "local" + log.info( + f"Validating source of target {target.name} " + f"(method: {method}, engine: {engine})." ) + if engine == "salve": + log.warning( + "The salve engine is experimental. Its messages are worded " + "differently from jing's, so a report from it will not match a " + "report from jing line for line." + ) + result = target.validate_source(method=method, engine=engine) - if is_valid: - log.info(f"PreTeXt source passed schema validation ({schema_file.name}).") - return - if is_valid is None: - log.error(error_text) + if result is None: + log.error("Validation could not be performed.") + log.error( + "Install jing and make sure it is on your PATH (or name it in " + "executables.ptx), or use `pretext validate --method server` or " + "`pretext validate --engine salve`." + ) raise SystemExit(2) - error_log_path = ( - Path("logs") / f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-schema-errors.log" - ) - with open(error_log_path, "w") as error_log_file: - error_log_file.write(error_text) - log.error("PreTeXt source did NOT pass schema validation:") - log.error(error_text) - log.error(f"See {error_log_path} for the full report.") + message_count, report = result + if message_count == 0: + log.info("PreTeXt source passed validation with no messages.") + log.info(f"Report: {report}") + return + log.error(f"PreTeXt source did NOT pass validation ({message_count} messages).") + log.error(f"See {report} for the full report.") raise SystemExit(1) @@ -900,12 +935,12 @@ def generate( # Otherwise, we can report success. log.info("Finished generating assets successfully.\n") except ValidationError as e: - # A validation error at this point must be because the publication file is invalid, which only happens if the /source/directories/@generated|@external attributes are missing. + # A validation error at this point must be because the project or publication file failed to validate against the model the CLI builds from. log.critical( - "It appears there is an error with your publication file. Are you missing the required source/directories/@external and @generated attributes?" + "It appears there is an error with your project.ptx or publication file. See the details below." ) + log.critical(e) log.critical("Failed to build. Exiting...") - log.debug(e) log.debug( "\n------------------------\nException info:\n------------------------\n", exc_info=True, diff --git a/pretext/logger.py b/pretext/logger.py index e95d81ef..4ed95ce0 100644 --- a/pretext/logger.py +++ b/pretext/logger.py @@ -8,18 +8,33 @@ log = logging.getLogger("ptxlogger") +class ColorFormatter(click_log.ColorFormatter): + """click_log prefixes a message with its level name, but only for the levels + in its own `colors` table; an unrecognized level gets no label at all. Core + PreTeXt renames level 50 to FATAL and adds BUG (45) and FALLBACK (25), so + those messages arrived unlabeled. Extend the table to cover them. + """ + + colors = { + **click_log.ColorFormatter.colors, + "fatal": dict(fg="red", bold=True), + "bug": dict(fg="magenta"), + "fallback": dict(fg="cyan"), + } + + def add_log_stream_handler() -> None: # Set up logging: # click_handler logs all messages to stdout as the CLI runs click_handler = logging.StreamHandler(sys.stdout) - click_handler.setFormatter(click_log.ColorFormatter()) + click_handler.setFormatter(ColorFormatter()) log.addHandler(click_handler) def get_log_error_flush_handler() -> logging.handlers.MemoryHandler: # error_flush_handler captures error/critical logs for flushing to stderr at the end of a CLI run sh = logging.StreamHandler(sys.stderr) - sh.setFormatter(click_log.ColorFormatter()) + sh.setFormatter(ColorFormatter()) sh.setLevel(logging.ERROR) error_flush_handler = logging.handlers.MemoryHandler( capacity=1024 * 100, diff --git a/pretext/project/__init__.py b/pretext/project/__init__.py index 7ee295bf..e354c5f9 100644 --- a/pretext/project/__init__.py +++ b/pretext/project/__init__.py @@ -72,13 +72,15 @@ class Format(str, Enum): } -# The CLI only needs two values from the publication file. Therefore, this class ignores the vast majority of a publication file's contents, loading and validating only a (small) relevant subset. -# Since we will want to hash the baseurl for generating qr codes, we also load it here. +# The CLI only needs one value from the publication file: the baseurl, which is +# hashed when generating qr codes. Therefore, this class ignores the vast +# majority of a publication file's contents, loading and validating only that. +# The managed directories are read by core (see `Target._managed_directories`): +# as of 2026-07-30 the external directory is declared in `docinfo`, and the +# publication file's `source/directories/@generated` is optional. class PublicationSubset( pxml.BaseXmlModel, tag="publication", search_mode=SearchMode.UNORDERED ): - external: Path = pxml.wrapped("source/directories", pxml.attr()) - generated: Path = pxml.wrapped("source/directories", pxml.attr()) baseurl: t.Optional[str] = pxml.wrapped( "html/baseurl", pxml.attr(name="href", default=None) ) @@ -141,6 +143,13 @@ class Target(pxml.BaseXmlModel, tag="target", search_mode=SearchMode.UNORDERED): source: Path = pxml.attr(default=Path("main.ptx")) # Cache of assembled "version only" source element. _source_element: t.Optional[ET._Element] = None + # Cache of the (generated, external) managed directories. Core reads the + # external directory from the source, which means an xinclude-processing + # parse, so it is worth doing only once per target. + _managed_dirs: t.Optional[t.Tuple[Path, t.Optional[Path]]] = None + # Whether the warn-only schema check has already run for this target, so a + # build doesn't repeat it when it generates assets. + _schema_checked: bool = False # Cache of assembled with assembly-ids. _source_element_with_ids: 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`. @@ -439,6 +448,17 @@ def source_element(self) -> ET._Element: log.debug(f"Using cached source_element for target {self.name}") return self._source_element + def check_schema(self) -> None: + """ + Warn (once per target) if the assembled source has schema errors, pointing + at the log file rather than filling the terminal. Never raises: a missing + jing, or an invalid document, must not stop a build. + """ + if self._schema_checked: + return + self._schema_checked = True + utils.warn_on_schema_errors(self.source_element(), self._project.logs_abspath()) + def source_element_with_ids(self) -> ET._Element: """ Returns the root element for the assembled source, after processing with assembly-id method. Caches the result for future calls. @@ -518,20 +538,50 @@ def _read_publication_file_subset(self) -> PublicationSubset: p_bytes = ET.tostring(p_et) return PublicationSubset.from_xml(p_bytes) - def external_dir(self) -> Path: - return self._read_publication_file_subset().external - - def external_dir_abspath(self) -> Path: - return (self.source_abspath().parent / self.external_dir()).resolve() + def _managed_directories(self) -> t.Tuple[Path, t.Optional[Path]]: + """ + The (generated, external) managed directories, in absolute form, as core + reads them. `external` is None when the project declares no external + directory at all. + + Core is the single authority on where these directories are: the + external one is a property of the source (`directories/@external` within + `docinfo`, with the deprecated publication file entry still honored, and + warned about, while the docinfo is silent), while the generated one + comes from the publication file and falls back to `generated` beside the + source. Core also enforces the ownership rule that matters to us here: + the author-owned external directory must exist when declared, whereas + the machine-owned generated directory need not, since creating it is the + CLI's job. A declared external directory that is missing therefore + raises, and since both paths come from the one call, that raise reaches + `generated_dir_abspath` too: a mistyped directory stops the CLI at the + first command that needs either one, which is where the author can still + act on it. + """ + if self._managed_dirs is None: + generated, external = core.get_managed_directories( + self.source_abspath(), self.publication_abspath().as_posix() + ) + # Core normalizes but does not resolve; `Project.abspath()` is fully + # resolved, and `clean_assets` compares the two. + self._managed_dirs = ( + Path(generated).resolve(), + None if external is None else Path(external).resolve(), + ) + return self._managed_dirs - def generated_dir(self) -> Path: - return self._read_publication_file_subset().generated + def external_dir_abspath(self) -> t.Optional[Path]: + return self._managed_directories()[1] def generated_dir_abspath(self) -> Path: - return (self.source_abspath().parent / self.generated_dir()).resolve() + return self._managed_directories()[0] def ensure_asset_directories(self, asset: t.Optional[str] = None) -> None: - self.external_dir_abspath().mkdir(parents=True, exist_ok=True) + # Only the generated directory is ours to create. The external + # directory holds files the author supplies, so core is left to object + # when a declared one is missing: that way a mistyped path is reported + # as the error it is, rather than silently becoming an empty directory. + # An author with no external assets should drop the attribute entirely. self.generated_dir_abspath().mkdir(parents=True, exist_ok=True) if asset is not None: # make directories for each asset type that would be generated from "asset": @@ -738,6 +788,87 @@ def build_theme(self) -> None: ) log.info(f"Theme built for target '{self.name}'") + # Not named `validate`: that would shadow pydantic's own (deprecated) + # `BaseModel.validate` classmethod. + def validate_source( + self, + method: str = "local", + dest_dir: t.Optional[Path] = None, + engine: str = "jing", + ) -> t.Optional[t.Tuple[int, Path]]: + """ + Validate the source with core's `validate`, which consolidates the RELAX-NG + messages from jing with those of the "validation-plus" stylesheet into one + report, and deposits the assembled source its line numbers refer to. + + `method` is core's: "local" (the installed jing), "local-dev" (as "local", + against the development schema), "server" (jing as a remote service), or + "terse" (one tab-separated message per line, for a program). + + `engine` selects what performs the RELAX-NG check: "jing", or the + experimental "salve" (the validator behind the pretext-tools VS Code + extension, run through a jing-compatible shim). The engine is irrelevant + to `method="server"`, which validates remotely. + + Returns the number of messages in the report along with its path, or `None` + if validation could not be performed at all. + """ + if dest_dir is None: + dest_dir = self._project.logs_abspath() + dest_dir.mkdir(parents=True, exist_ok=True) + + overrides = None + if engine == "salve": + salve_command = utils.salve_shim_command() + if salve_command is None: + return None + # Core reaches its RELAX-NG engine through the `jing` executable, so + # standing in for jing is all it takes to swap the engine. + overrides = {"jing": " ".join(salve_command)} + self._project.init_core(overrides) + try: + core.validate( + xml_source=self.source_abspath(), + pub_file=self.publication_abspath().as_posix(), + stringparams=self.stringparams.copy(), + out_file=None, + dest_dir=dest_dir.as_posix(), + method=method, + ) + except OSError as e: + # Raised when the configured `jing` can't be found; core has already + # logged which command it looked for. + log.debug(f"Validation could not run: {e}") + return None + finally: + if overrides is not None: + # Put the project's own executables back for anything that follows. + self._project.init_core() + report = dest_dir / (self.source_abspath().stem + "-validation.txt") + if not report.exists(): + # core returns without a report when it cannot reach a validation server. + return None + lines = report.read_text(encoding="utf-8").splitlines() + if method == "terse": + # One message per line, and nothing else in the file. + count = len([line for line in lines if line.strip()]) + else: + # Each message in the consolidated report ends with the check that + # raised it, so those lines count the messages of both sections. The + # report opens with a preamble that describes a message's fields + # (including "check:"), so counting starts at the first section + # banner to avoid mistaking that description for a message. + banner = "=" * 70 + first_section = lines.index(banner) if banner in lines else 0 + count = len( + [ + line + for line in lines[first_section:] + if line.startswith(" check: ") + ] + ) + return count, report + def build( self, clean: bool = False, @@ -760,7 +891,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_validates_against_schema(self.source_element()) + self.check_schema() # Clean output upon request if clean: @@ -1014,6 +1145,10 @@ def generate_assets( """ log.info("Generating any needed assets.") + # Warn about schema errors here too, since assets are often generated + # without a build. A no-op when a build already ran the check. + self.check_schema() + # To help with debugging, we are temporarily adding a reference generation step here. The only way this will be called is if `pretext generate references` is called explicitly. if requested_asset_types == ("references",): try: @@ -1578,7 +1713,10 @@ class ProjectVersionOnly(pxml.BaseXmlModel, tag="project"): d = legacy_project.model_dump() d["targets"] = new_targets # Rename from `executables` to `_executables` when moving from the old to new project format. - d["_executables"] = legacy_project.executables + # The conversion matters: the legacy element names only a subset of the + # executables core needs, so the rest (`mermaid`, `perl`, `fop`, `jing`) + # must come from the modern model's defaults. + d["_executables"] = Executables.from_legacy(legacy_project.executables) d.pop("executables") p = Project( ptx_version="2", @@ -1689,6 +1827,10 @@ def stage_abspath(self) -> Path: def site_abspath(self) -> Path: return self.abspath() / self.site + def logs_abspath(self) -> Path: + # Where the CLI already writes its own logs; `.gitignore`d in a project. + return self.abspath() / "logs" + def deploy_strategy( self, ) -> t.Literal["default_target", "pelican_default", "pelican_custom", "static"]: @@ -1723,10 +1865,18 @@ def server_process( def get_executables(self) -> Executables: return self._executables - def init_core(self) -> None: + def init_core( + self, executable_overrides: t.Optional[t.Dict[str, str]] = None + ) -> None: + # `executable_overrides` swaps in a different command for an executable + # for the duration of one operation (calling `init_core()` again with no + # overrides restores the project's own). Used to run validation through + # an engine other than the configured `jing`. + exec_dict = self._executables.model_dump() + if executable_overrides is not None: + exec_dict.update(executable_overrides) # core does not support None as an executable value, so we must # adjust accordingly - exec_dict = self._executables.model_dump() for k in exec_dict: if exec_dict[k] is None: exec_dict[k] = "None" diff --git a/pretext/project/xml.py b/pretext/project/xml.py index 59ff55ac..d179a9c1 100644 --- a/pretext/project/xml.py +++ b/pretext/project/xml.py @@ -1,30 +1,74 @@ +import logging from pathlib import Path import shutil import typing as t from enum import Enum -from pydantic import ConfigDict +from pydantic import ConfigDict, model_validator import pydantic_xml as pxml from pydantic_xml.element.element import SearchMode +log = logging.getLogger("ptxlogger") + +# Executables that the core script once used but no longer does. They are still +# accepted (silently deleting them would make every older `executables.ptx` a +# hard parse error, since the model forbids extra attributes), but they are +# excluded from `model_dump()` and so never reach core. +DEPRECATED_EXECUTABLES = { + "liblouis": "braille is now translated with the `louis` Python bindings", + "pdfsvg": "PDFs are now converted with the pyMuPDF library", + "pdfpng": "PDFs are now converted with the pyMuPDF library", +} + # To prevent circular imports, put this here instead of in `__init__`; however, it's not used in this file. class Executables(pxml.BaseXmlModel, tag="executables"): + """The executables the core script invokes; keep in sync with the + `[executables]` section of the core script's `pretext.cfg`.""" + model_config = ConfigDict(extra="forbid") latex: str = pxml.attr(default="latex") pdflatex: str = pxml.attr(default="pdflatex") xelatex: str = pxml.attr(default="xelatex") - pdfsvg: t.Optional[str] = pxml.attr(default="pdf2svg") # If not specified, use a local executable if it exists; if it doesn't exist, choose `None`, which allows the generation logic to use the server instead. asy: t.Optional[str] = pxml.attr(default=shutil.which("asy")) # No sage server, so we don't do the same for sage. sage: t.Optional[str] = pxml.attr(default="sage") mermaid: str = pxml.attr(default="mmdc") - pdfpng: t.Optional[str] = pxml.attr(default="convert") pdfeps: str = pxml.attr(default="pdftops") node: str = pxml.attr(default="node") - liblouis: str = pxml.attr(default="file2brl") perl: str = pxml.attr(default="perl") fop: str = pxml.attr(default="fop") + # `jing` is a Java program, so this can be the name of an executable (from a + # system package) or a command with options, e.g. `java -jar /usr/share/java/jing.jar`. + jing: str = pxml.attr(default="jing") + + # Deprecated: see `DEPRECATED_EXECUTABLES`. `exclude=True` keeps these out of + # `model_dump()`, which is what gets handed to core. + liblouis: t.Optional[str] = pxml.attr(default=None, exclude=True) + pdfsvg: t.Optional[str] = pxml.attr(default=None, exclude=True) + pdfpng: t.Optional[str] = pxml.attr(default=None, exclude=True) + + @model_validator(mode="after") + def warn_about_deprecated(self) -> "Executables": + for name, reason in DEPRECATED_EXECUTABLES.items(): + if getattr(self, name) is not None: + log.warning( + f'The "{name}" executable is no longer used by PreTeXt ({reason}); ' + "you can remove it from your executables.ptx file." + ) + return self + + @classmethod + def from_legacy(cls, legacy: "LegacyExecutables") -> "Executables": + """Build from a v1 manifest's ``, which names only a subset + of the executables core needs; the rest keep their defaults.""" + return cls( + **{ + key: value + for key, value in legacy.model_dump().items() + if value is not None and key in cls.model_fields + } + ) class LegacyFormat(str, Enum): @@ -86,13 +130,15 @@ class LegacyExecutables( latex: str = pxml.element() pdflatex: str = pxml.element() xelatex: str = pxml.element() - pdfsvg: t.Optional[str] = pxml.element(default=None) asy: str = pxml.element() sage: str = pxml.element() - pdfpng: t.Optional[str] = pxml.element(default=None) pdfeps: str = pxml.element() node: str = pxml.element() - liblouis: str = pxml.element() + # Deprecated (see `DEPRECATED_EXECUTABLES`), but optional rather than absent, + # since legacy manifests in the wild still carry these elements. + liblouis: t.Optional[str] = pxml.element(default=None) + pdfsvg: t.Optional[str] = pxml.element(default=None) + pdfpng: t.Optional[str] = pxml.element(default=None) class LegacyProject(pxml.BaseXmlModel, tag="project", search_mode=SearchMode.UNORDERED): diff --git a/pretext/resources/salve/__init__.py b/pretext/resources/salve/__init__.py new file mode 100644 index 00000000..6e28970f --- /dev/null +++ b/pretext/resources/salve/__init__.py @@ -0,0 +1,2 @@ +# Marks the shim directory as a package so its files can be located with +# `importlib.resources`. diff --git a/pretext/resources/salve/package.json b/pretext/resources/salve/package.json new file mode 100644 index 00000000..d81dbb45 --- /dev/null +++ b/pretext/resources/salve/package.json @@ -0,0 +1,10 @@ +{ + "name": "ptx-salve-validator", + "version": "1.0.0", + "description": "Runs @pretextbook/schema behind a jing-compatible command-line interface, so core's validate() can use it in place of jing.", + "private": true, + "type": "module", + "dependencies": { + "@pretextbook/schema": "^0.2.0" + } +} diff --git a/pretext/resources/salve/ptx-jing-shim.mjs b/pretext/resources/salve/ptx-jing-shim.mjs new file mode 100644 index 00000000..77b66463 --- /dev/null +++ b/pretext/resources/salve/ptx-jing-shim.mjs @@ -0,0 +1,121 @@ +/** + * A jing-compatible front end for the salve-annos validator in + * `@pretextbook/schema` (the engine behind the pretext-tools VS Code extension). + * + * Core's `validate()` invokes its RELAX-NG engine as + * + * + * + * and reads stdout as lines matching ^.*?:(\d+):(\d+): (.*)$ , using the line + * number and the message body. Exit 0 means valid and 1 means the document has + * messages. Satisfying that contract is all it takes to stand in for jing, and + * core then produces its usual consolidated report. + * + * Runs under Node and under Deno: nothing Node-only is imported at module + * scope, and the package's `require("fs")` lives in a default file reader that + * the `readFile` override below keeps out of the picture. (The grammar + * compiler *is* Node-locked, so it is imported dynamically, only on a cache + * miss.) + */ +import { readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs"; +import { basename } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Kinds that RELAX-NG itself can express. The others the package can report +// (duplicate-id, duplicate-label, dangling-reference) are the ground core +// covers with its "validation-plus" stylesheet, so including them here would +// double-report them in the consolidated report. +const SCHEMA_KINDS = new Set([ + "element-not-allowed", + "attribute-not-allowed", + "attribute-value-invalid", + "choice-not-satisfied", + "text-not-allowed", + "unexpected-end", + "well-formedness", + "other", +]); + +const [schemaPath, sourcePath] = process.argv.slice(2); +if (!schemaPath || !sourcePath) { + console.error("usage: ptx-jing-shim "); + process.exit(2); +} + +/** + * Report a failure of the validator itself as a message *in the report*. + * + * Exiting with a code above 1 would leave core logging a warning and then + * treating the empty output as "no schema errors" -- a silent pass for a + * document nothing actually checked. A message on stdout is counted and + * printed like any other, so the failure cannot be mistaken for success. + */ +function fail(message) { + console.log(`${sourcePath}:1:1: error: the salve validator failed: ${message}`); + process.exit(1); +} + +/** + * A salve grammar for the schema core asked us to use, compiled on first use + * and cached beside this script. + * + * The grammar must come from the schema the CLI actually ships: the copy + * bundled with `@pretextbook/schema` tracks the VS Code extension's schema, + * which drifts from core's and then reports problems with valid documents. + */ +async function loadGrammarJson() { + const cacheDir = new URL("grammars/", import.meta.url); + const cacheFile = new URL(`${basename(schemaPath)}.json`, cacheDir); + try { + if (statSync(cacheFile).mtimeMs >= statSync(schemaPath).mtimeMs) { + return readFileSync(cacheFile, "utf8"); + } + } catch { + // No cache yet (or it is older than the schema): compile below. + } + const { compileRngToJSON } = await import("@pretextbook/schema/compile"); + const { json } = await compileRngToJSON(schemaPath); + try { + mkdirSync(fileURLToPath(cacheDir), { recursive: true }); + writeFileSync(cacheFile, json); + } catch { + // A read-only install just means recompiling (~0.6s) on every run. + } + return json; +} + +let validateDocument, loadGrammarFromJSON, grammarJson, source; +try { + ({ validateDocument, loadGrammarFromJSON } = await import("@pretextbook/schema")); +} catch (e) { + fail(`could not load @pretextbook/schema (${e}). Try \`pretext validate --engine salve\` again to reinstall it.`); +} +try { + grammarJson = await loadGrammarJson(); +} catch (e) { + fail(`could not compile a grammar from ${schemaPath} (${e})`); +} +try { + source = readFileSync(sourcePath, "utf8"); +} catch (e) { + fail(`could not read ${sourcePath} (${e})`); +} + +const { diagnostics } = validateDocument(source, loadGrammarFromJSON(grammarJson), { + uri: sourcePath, + // Core hands over a single, already-assembled file and wants line numbers *of + // that file*; both of these also keep the package from going out to disk. + resolveXIncludes: false, + readFile: () => undefined, +}); + +let count = 0; +for (const d of diagnostics) { + if (d.code !== undefined && !SCHEMA_KINDS.has(String(d.code))) continue; + // LSP positions are 0-based; jing's are 1-based. + const line = d.range.start.line + 1; + const column = d.range.start.character + 1; + console.log(`${sourcePath}:${line}:${column}: error: ${d.message}`); + count += 1; +} +process.exit(count > 0 ? 1 : 0); diff --git a/pretext/utils.py b/pretext/utils.py index 43a963d4..c34f8f11 100644 --- a/pretext/utils.py +++ b/pretext/utils.py @@ -1,6 +1,7 @@ import datetime from hashlib import sha256 import hashlib +import importlib.resources import os from collections.abc import Generator from contextlib import contextmanager @@ -12,7 +13,6 @@ import socketserver import socket import subprocess -import tempfile import time as time import logging import logging.handlers @@ -207,103 +207,157 @@ def xml_syntax_is_valid(xmlfile: Path, root_tag: str = "pretext") -> bool: return True -def xml_validates_against_schema(etree: _Element) -> bool: - schemarngfile = schema_path() - log.debug(f"Validating PreTeXt source against schema {schemarngfile}") - # Build-time validation stays lxml-first (fast) and warn-only. - is_valid, error_text = run_schema_validation( - etree, schemarngfile, order=("lxml", "jing") +def warn_on_schema_errors(etree: _Element, log_dir: Path) -> Optional[bool]: + """A quick, warn-only schema check to accompany a build or generate. + + Runs jing over the assembled source when jing is available, and otherwise + checks nothing (returning `None`) -- a build should not depend on having a + validator installed. Messages are written to a log file rather than the + terminal: they are numerous, they refer to lines of the *assembled* source + (deposited alongside the log so those numbers mean something), and the + consolidated report from `pretext validate` is the better place to read + them. Returns whether the source validated. + """ + schema_file = schema_path() + log.debug(f"Validating PreTeXt source against schema {schema_file}") + log_dir.mkdir(parents=True, exist_ok=True) + assembled_source = log_dir / "schema-assembled-source.xml" + error_log = log_dir / "schema-errors.log" + etree.getroottree().write( + str(assembled_source), encoding="utf-8", xml_declaration=True ) - if is_valid: - log.info("PreTeXt source passed schema validation.") - return True - if is_valid is None: - log.warning(error_text + " Continuing with build.") - else: + + def _discard() -> None: + # Leave nothing behind to be mistaken for the result of this run. + assembled_source.unlink(missing_ok=True) + error_log.unlink(missing_ok=True) + + result = validate_with_jing(assembled_source, schema_file) + if result is None: log.debug( - "PreTeXt document did not pass schema validation; unexpected output " - "may result. See .error_schema.log for hints. Continuing with build." + "No jing executable available, so the source was not checked against " + "the schema. Install jing (or set it in executables.ptx) to have " + "builds report schema errors." ) - log.debug( - "---- Schema validation error details: ----\n" - + error_text - + "\n---- End schema validation error details. ----" + _discard() + return None + + is_valid, output = result + if is_valid: + log.debug("PreTeXt source passed schema validation.") + _discard() + return True + + error_log.write_text(output, encoding="utf-8") + message_count = len([line for line in output.splitlines() if line.strip()]) + log.warning( + f"PreTeXt source did not pass schema validation ({message_count} messages); " + "unexpected output may result. Continuing anyway." ) + log.warning(f" Messages: {error_log}") + log.warning(f" Line numbers refer to the assembled source: {assembled_source}") + log.warning(" Run `pretext validate` for a report that names the source files.") return False -def _validate_with_jing( - etree: _Element, schema_file: Path -) -> Optional[tuple[bool, str]]: +def _jing_command() -> Optional[List[str]]: + """The `jing` invocation to use, or `None` when jing is unavailable. + + Prefers the project's configured `jing` executable (from `executables.ptx`), + which lets a user point at a jing outside the search path; falls back to a + `jing` on the PATH when no project has been loaded. The configured value may + carry options (e.g. `java -jar /usr/share/java/jing.jar`), so the whole + command line is returned rather than just the executable. + """ + try: + return core.get_executable_cmd("jing") + except (KeyError, TypeError, OSError) as e: + # In turn: core's executables have no `jing` key (a manifest read by an + # older CLI), no project has been loaded so core has no executables at + # all, or the configured command isn't on the PATH. + log.debug(f"Could not use a configured jing command: {e}") jing_executable = shutil.which("jing") - if jing_executable is None: + return [jing_executable] if jing_executable is not None else None + + +def salve_shim_command() -> Optional[List[str]]: + """The command that runs the salve-annos validator as a stand-in for jing. + + An experimental alternative engine, so that it can be compared with jing + without anyone having to install jing at all. The shim presents a + jing-compatible interface, so it drops into core's `validate()` as the + `jing` executable. Returns `None` (with a warning) if it can't be set up. + + Its npm dependencies are installed on first use into the CLI's resource + directory -- the same pattern as the other node packages the CLI needs. + """ + node_cmd = shutil.which("node") + if node_cmd is None: + log.warning("The salve validator needs node, which could not be found.") return None - tmp_path: Optional[Path] = None - try: - xml_payload = ET.tostring( - etree.getroottree(), encoding="utf-8", xml_declaration=True - ) - with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as tmp: - tmp.write(xml_payload) - tmp_path = Path(tmp.name) + shim_dir = resources.resource_base_path() / "salve" + shim = shim_dir / "ptx-jing-shim.mjs" + + if not (shim_dir / "node_modules").exists(): + npm_cmd = shutil.which("npm") + if npm_cmd is None: + log.warning("The salve validator needs npm to install itself on first use.") + return None + log.info(f"Installing the salve validator into {shim_dir} (first use only).") + shim_dir.mkdir(parents=True, exist_ok=True) + for name in ("ptx-jing-shim.mjs", "package.json"): + with importlib.resources.path("pretext.resources.salve", name) as src: + shutil.copy2(src, shim_dir / name) + try: + result = subprocess.run( + [npm_cmd, "install"], cwd=shim_dir, capture_output=True, text=True + ) + except OSError as e: + log.warning(f"Could not install the salve validator: {e}") + return None + if result.returncode != 0: + log.warning("Could not install the salve validator with npm:") + log.warning(result.stderr.strip()) + return None + + if not shim.exists(): + log.warning(f"The salve validator is missing from {shim_dir}.") + return None + return [node_cmd, str(shim)] + + +def validate_with_jing( + source_file: Path, schema_file: Path +) -> Optional[tuple[bool, str]]: + """Check an XML file against a RELAX-NG schema with jing. + + Returns `(is_valid, messages)`, or `None` when jing isn't available. jing is + the only engine: lxml cannot compile the PreTeXt schema, which incorporates + PreFigure's schema in a way libxml2 won't accept. + """ + jing_command = _jing_command() + if jing_command is None: + return None + try: result = subprocess.run( - [jing_executable, str(schema_file), str(tmp_path)], + jing_command + [str(schema_file), str(source_file)], check=False, capture_output=True, text=True, ) - output = "\n".join( - chunk for chunk in [result.stdout.strip(), result.stderr.strip()] if chunk - ) - return result.returncode == 0, output except OSError: return None - finally: - if tmp_path is not None: - tmp_path.unlink(missing_ok=True) - - -def _validate_with_lxml( - etree: _Element, schema_file: Path -) -> Optional[tuple[bool, str]]: - # Returns None when lxml cannot compile the schema (the known - # "no define for ref" bug on some libxml2 builds), so the caller can - # fall back to another engine. - try: - relaxng = ET.RelaxNG(file=str(schema_file)) - except ET.RelaxNGParseError: - log.debug( - "lxml could not compile the RelaxNG schema; trying the next validator." - ) - return None - try: - relaxng.assertValid(etree) - return True, "" - except ET.DocumentInvalid as err: - return False, str(err.error_log) - - -def run_schema_validation( - etree: _Element, - schema_file: Path, - order: t.Sequence[str] = ("lxml", "jing"), -) -> tuple[Optional[bool], str]: - # Engines are looked up by name here (not captured at import time) so tests - # can monkeypatch `utils._validate_with_jing` / `utils._validate_with_lxml`. - engines = { - "lxml": _validate_with_lxml, - "jing": _validate_with_jing, - } - for engine_name in order: - result = engines[engine_name](etree, schema_file) - if result is not None: - return result - return None, ( - "Schema validation could not be completed: no validator was available " - "(jing is not installed and lxml could not compile the schema)." + output = "\n".join( + chunk for chunk in [result.stdout.strip(), result.stderr.strip()] if chunk ) + # jing exits 0 when the document is valid and 1 when it has messages; + # anything more means jing itself failed, so nothing was checked. + if result.returncode > 1: + log.debug(f"The jing program failed (code {result.returncode}): {output}") + return None + return result.returncode == 0, output def schema_path(dev: bool = False) -> Path: @@ -442,11 +496,9 @@ def check_asset_execs(element: str, outformats: Optional[List[str]] = None) -> N # Create list of executables needed based on output format required_execs = [] if element == "latex-image": + # svg and png conversions go through the pyMuPDF library, so they need no + # executable beyond the LaTeX engine itself. required_execs = ["xelatex"] - if "svg" in outformats or "all" in outformats: - required_execs.append("pdfsvg") - if "png" in outformats or "all" in outformats: - required_execs.append("pdfpng") if "eps" in outformats or "all" in outformats: required_execs.append("pdfeps") if element == "sageplot": @@ -457,16 +509,6 @@ def check_asset_execs(element: str, outformats: Optional[List[str]] = None) -> N "Darwin": "", "Linux": "", }, - "pdfsvg": { - "Windows": "Follow the instructions at https://pretextbook.org/doc/guide/html/section-installing-pdf2svg.html to install pdf2svg", - "Darwin": "", - "Linux": "You should be able to install pdf2svg with your package manager (e.g., `sudo apt install pdf2svg`. See https://github.com/dawbarton/pdf2svg#pdf2svg.", - }, - "pdfpng": { - "Windows": "See https://pretextbook.org/doc/guide/html/windows-cli-software.html", - "Darwin": "", - "Linux": "", - }, "pdfeps": { "Windows": "See https://pretextbook.org/doc/guide/html/windows-cli-software.html", "Darwin": "", diff --git a/templates/article/assets/.gitkeep b/templates/article/assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/templates/article/publication/publication.ptx b/templates/article/publication/publication.ptx index f36399d0..2be00d28 100644 --- a/templates/article/publication/publication.ptx +++ b/templates/article/publication/publication.ptx @@ -1,8 +1,25 @@ + + + + + + + + + + - + + + - + + + + + + @@ -12,5 +29,58 @@ https://pretextbook.org/doc/guide/html/appendix-journals.html#appendix-journals --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/article/source/main.ptx b/templates/article/source/main.ptx index a63fa4eb..44bf7983 100644 --- a/templates/article/source/main.ptx +++ b/templates/article/source/main.ptx @@ -2,7 +2,7 @@ - + authorslastnamelowercase-ShortTitleCamelCase \newcommand{\foo}{b^{ar}} diff --git a/templates/book/assets/.gitkeep b/templates/book/assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/templates/book/publication/publication.ptx b/templates/book/publication/publication.ptx index 92d6e51f..ad58e77e 100644 --- a/templates/book/publication/publication.ptx +++ b/templates/book/publication/publication.ptx @@ -7,80 +7,156 @@ - + + + - + + + - + + + - + + + - + + - + + + + + + - + + - + + + + + + + + + + + + + + + + + + + + - - - + + + + - - - + + + - + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + - + + + + + + + + - - + + + - + + - + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + - - + + + + + - - + + - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + - + + - - + + + - + + + - - - + + + + - + + + - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/book/source/docinfo.ptx b/templates/book/source/docinfo.ptx index f5a76985..827e78e8 100644 --- a/templates/book/source/docinfo.ptx +++ b/templates/book/source/docinfo.ptx @@ -5,25 +5,31 @@ - changeme + - A simple string (no markup is allowed) to describe your book. + + - - - - - - \newcommand{\N}{\mathbb N} \newcommand{\Z}{\mathbb Z} \newcommand{\Q}{\mathbb Q} \newcommand{\R}{\mathbb R} + + \newcommand{\N}{\mathbb N} + \newcommand{\Z}{\mathbb Z} + \newcommand{\Q}{\mathbb Q} + \newcommand{\R}{\mathbb R} + - \usepackage{tikz, pgfplots} \usetikzlibrary{positioning,matrix,arrows} \usetikzlibrary{shapes,decorations,shadows,fadings,patterns} \usetikzlibrary{decorations.markings} + + \usepackage{tikz, pgfplots} + \usetikzlibrary{positioning,matrix,arrows} + \usetikzlibrary{shapes,decorations,shadows,fadings,patterns} + \usetikzlibrary{decorations.markings} + diff --git a/templates/course/assets/.gitkeep b/templates/course/assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/templates/course/publication/publication-slides-annotated.ptx b/templates/course/publication/publication-slides-annotated.ptx index 788808d4..4a3ac0df 100644 --- a/templates/course/publication/publication-slides-annotated.ptx +++ b/templates/course/publication/publication-slides-annotated.ptx @@ -4,21 +4,32 @@ - + + + + + + - - + + + + + + + + - + diff --git a/templates/course/publication/publication-slides.ptx b/templates/course/publication/publication-slides.ptx index 935aadc1..d2110489 100644 --- a/templates/course/publication/publication-slides.ptx +++ b/templates/course/publication/publication-slides.ptx @@ -2,7 +2,7 @@ - + @@ -10,15 +10,26 @@ + + + + + - - + + + + + + + + - + diff --git a/templates/course/publication/publication.ptx b/templates/course/publication/publication.ptx index a296d5b6..20d77558 100644 --- a/templates/course/publication/publication.ptx +++ b/templates/course/publication/publication.ptx @@ -7,119 +7,247 @@ - + + + - + + + - + + + - + + + - + + - + + + + + + - + + - + + + + + + + + + + + + + + + + + + + + - - - + + + + - - - + + + - + + + + + + + + - - - - - - - - - + + + + + + + + + - - - + + + - + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - + > + --> + + + + + + + + + + + + + + - + + - - - - - - - - + + + - - + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + - + + - - + + + - + + + - - - + + + + - + + + - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/course/publication/publication_standalone.ptx b/templates/course/publication/publication_standalone.ptx index 48973a46..abb1849e 100644 --- a/templates/course/publication/publication_standalone.ptx +++ b/templates/course/publication/publication_standalone.ptx @@ -7,116 +7,244 @@ - + + + - + + + - + + + - + + + - + + - + + + + + + - + + - + + + + + + + + + + + + + + + + + + + + - - - + + + + - - - + + + - + + + + + + + + - - - - - - - - - + + + + + + + + + - - - + + + - + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - + > + --> + + + + + + + + + + + + + + - + + - + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + - + + - - + + + - + + + - - - + + + + - + + + - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/course/source/docinfo.ptx b/templates/course/source/docinfo.ptx index fb0b305b..ab35e2d7 100644 --- a/templates/course/source/docinfo.ptx +++ b/templates/course/source/docinfo.ptx @@ -5,6 +5,8 @@ + + diff --git a/templates/demo/assets/.gitkeep b/templates/demo/assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/templates/demo/publication/publication.ptx b/templates/demo/publication/publication.ptx index b07867ba..bfaabf97 100644 --- a/templates/demo/publication/publication.ptx +++ b/templates/demo/publication/publication.ptx @@ -7,116 +7,245 @@ - + + + - + + + - + + + - + + + - + + - + + + + + + - + + - + + + + + + + + + + + + + + + + + + + + - + + + + - + - + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - + > + --> + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + - + + - - + + + - + + + - - - + + + + - + + + - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/demo/source/docinfo.ptx b/templates/demo/source/docinfo.ptx index e4ea3b0e..b93fe93b 100644 --- a/templates/demo/source/docinfo.ptx +++ b/templates/demo/source/docinfo.ptx @@ -12,17 +12,17 @@ https://pretextbook.org/doc/guide/html/sec-publishing-to-runestone-academy.html for more information. --> - - changeme + + - + diff --git a/templates/hello/publication/publication.ptx b/templates/hello/publication/publication.ptx index 35f12844..083b57a5 100644 --- a/templates/hello/publication/publication.ptx +++ b/templates/hello/publication/publication.ptx @@ -2,6 +2,6 @@ - + diff --git a/templates/publication.ptx b/templates/publication.ptx index 35f12844..083b57a5 100644 --- a/templates/publication.ptx +++ b/templates/publication.ptx @@ -2,6 +2,6 @@ - + diff --git a/templates/slideshow/assets/.gitkeep b/templates/slideshow/assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/templates/slideshow/publication/publication.ptx b/templates/slideshow/publication/publication.ptx index 0b7a3b5b..2db8270b 100644 --- a/templates/slideshow/publication/publication.ptx +++ b/templates/slideshow/publication/publication.ptx @@ -19,19 +19,37 @@ along with PreTeXt. If not, see . - + + + + + + + + + - - + + + + + + + + + + + + - + @@ -39,4 +57,12 @@ along with PreTeXt. If not, see . - \ No newline at end of file + + + + + diff --git a/templates/slideshow/source/main.ptx b/templates/slideshow/source/main.ptx index a551d691..e041eace 100644 --- a/templates/slideshow/source/main.ptx +++ b/templates/slideshow/source/main.ptx @@ -21,6 +21,7 @@ along with PreTeXt. If not, see . + \newcommand{\definiteintegral}[4]{\int_{#1}^{#2}\,#3\,d#4} diff --git a/templates/standalone-publication.ptx b/templates/standalone-publication.ptx index b471b7e5..f396543b 100644 --- a/templates/standalone-publication.ptx +++ b/templates/standalone-publication.ptx @@ -5,7 +5,7 @@ This is a publication file for standalone documents. It should only exist in `~ - + diff --git a/tests/examples/projects/custom-wwserver/project.ptx b/tests/examples/projects/custom-wwserver/project.ptx index ea237f6a..b2cc0cda 100644 --- a/tests/examples/projects/custom-wwserver/project.ptx +++ b/tests/examples/projects/custom-wwserver/project.ptx @@ -4,37 +4,8 @@ project. To edit the content of your document, open `source/main.ptx` (default location). --> - + - - html - source/main.ptx - publication/publication.ptx - output/html - - - latex - source/main.ptx - publication/publication.ptx - output/latex - - - pdf - source/main.ptx - publication/publication.ptx - output/pdf - + - - latex - pdflatex - xelatex - pdf2svg - asy - sage - convert - pdftops - node - file2brl - diff --git a/tests/examples/projects/custom-wwserver/publication/publication.ptx b/tests/examples/projects/custom-wwserver/publication/publication.ptx index 13fc0bf1..48df6f00 100644 --- a/tests/examples/projects/custom-wwserver/publication/publication.ptx +++ b/tests/examples/projects/custom-wwserver/publication/publication.ptx @@ -2,7 +2,7 @@ - + diff --git a/tests/examples/projects/custom-xsl/publication/publication.ptx b/tests/examples/projects/custom-xsl/publication/publication.ptx index 35f12844..083b57a5 100644 --- a/tests/examples/projects/custom-xsl/publication/publication.ptx +++ b/tests/examples/projects/custom-xsl/publication/publication.ptx @@ -2,6 +2,6 @@ - + diff --git a/tests/examples/projects/datafile/publication/publication.ptx b/tests/examples/projects/datafile/publication/publication.ptx index 35f12844..083b57a5 100644 --- a/tests/examples/projects/datafile/publication/publication.ptx +++ b/tests/examples/projects/datafile/publication/publication.ptx @@ -2,6 +2,6 @@ - + diff --git a/tests/examples/projects/datafile/source/main.ptx b/tests/examples/projects/datafile/source/main.ptx index a68f9255..fdaa1a90 100644 --- a/tests/examples/projects/datafile/source/main.ptx +++ b/tests/examples/projects/datafile/source/main.ptx @@ -1,6 +1,9 @@ + + + Datafile Test diff --git a/tests/examples/projects/graphics/publication/publication.ptx b/tests/examples/projects/graphics/publication/publication.ptx index 35f12844..083b57a5 100644 --- a/tests/examples/projects/graphics/publication/publication.ptx +++ b/tests/examples/projects/graphics/publication/publication.ptx @@ -2,6 +2,6 @@ - + diff --git a/tests/examples/projects/graphics/source/main.ptx b/tests/examples/projects/graphics/source/main.ptx index 32120c1d..7bff6562 100644 --- a/tests/examples/projects/graphics/source/main.ptx +++ b/tests/examples/projects/graphics/source/main.ptx @@ -56,4 +56,4 @@ - \ No newline at end of file + diff --git a/tests/examples/projects/interactive/publication/publication.ptx b/tests/examples/projects/interactive/publication/publication.ptx index 35f12844..083b57a5 100644 --- a/tests/examples/projects/interactive/publication/publication.ptx +++ b/tests/examples/projects/interactive/publication/publication.ptx @@ -2,6 +2,6 @@ - + diff --git a/tests/examples/projects/latex-image/publication/publication.ptx b/tests/examples/projects/latex-image/publication/publication.ptx index 828f15c4..a8b1b6f0 100644 --- a/tests/examples/projects/latex-image/publication/publication.ptx +++ b/tests/examples/projects/latex-image/publication/publication.ptx @@ -1,7 +1,6 @@ diff --git a/tests/examples/projects/prefigure/publication/publication.ptx b/tests/examples/projects/prefigure/publication/publication.ptx new file mode 100644 index 00000000..083b57a5 --- /dev/null +++ b/tests/examples/projects/prefigure/publication/publication.ptx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/examples/projects/project_refactor/assets/publication/publication.ptx b/tests/examples/projects/project_refactor/assets/publication/publication.ptx index 434166a6..df12a4d4 100644 --- a/tests/examples/projects/project_refactor/assets/publication/publication.ptx +++ b/tests/examples/projects/project_refactor/assets/publication/publication.ptx @@ -1,7 +1,6 @@ - \ No newline at end of file + diff --git a/tests/examples/projects/project_refactor/elaborate/executables.ptx b/tests/examples/projects/project_refactor/elaborate/executables.ptx index 3b16edde..d6eb99a6 100644 --- a/tests/examples/projects/project_refactor/elaborate/executables.ptx +++ b/tests/examples/projects/project_refactor/elaborate/executables.ptx @@ -1,4 +1,6 @@ + diff --git a/tests/examples/projects/project_refactor/legacy/publication/publication.ptx b/tests/examples/projects/project_refactor/legacy/publication/publication.ptx index 35f12844..083b57a5 100644 --- a/tests/examples/projects/project_refactor/legacy/publication/publication.ptx +++ b/tests/examples/projects/project_refactor/legacy/publication/publication.ptx @@ -2,6 +2,6 @@ - + diff --git a/tests/examples/projects/project_refactor/legacy_extra/publication/publication.ptx b/tests/examples/projects/project_refactor/legacy_extra/publication/publication.ptx index 35f12844..083b57a5 100644 --- a/tests/examples/projects/project_refactor/legacy_extra/publication/publication.ptx +++ b/tests/examples/projects/project_refactor/legacy_extra/publication/publication.ptx @@ -2,6 +2,6 @@ - + diff --git a/tests/examples/projects/xi_pub/publication/source.ptx b/tests/examples/projects/xi_pub/publication/source.ptx index 4cb43edd..52739862 100644 --- a/tests/examples/projects/xi_pub/publication/source.ptx +++ b/tests/examples/projects/xi_pub/publication/source.ptx @@ -1,4 +1,4 @@ - + diff --git a/tests/examples/projects/xi_pub/source/main.ptx b/tests/examples/projects/xi_pub/source/main.ptx index a63fa4eb..44bf7983 100644 --- a/tests/examples/projects/xi_pub/source/main.ptx +++ b/tests/examples/projects/xi_pub/source/main.ptx @@ -2,7 +2,7 @@ - + authorslastnamelowercase-ShortTitleCamelCase \newcommand{\foo}{b^{ar}} diff --git a/tests/examples/projects/xref/publication/publication.ptx b/tests/examples/projects/xref/publication/publication.ptx index 35f12844..083b57a5 100644 --- a/tests/examples/projects/xref/publication/publication.ptx +++ b/tests/examples/projects/xref/publication/publication.ptx @@ -2,6 +2,6 @@ - + diff --git a/tests/test_cli.py b/tests/test_cli.py index 033f32e0..98e57526 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -34,9 +34,8 @@ from contextlib import contextmanager import requests import pretext -from lxml import etree as ET # noqa: N812 -from pretext import constants, server, utils -from typing import cast, Generator +from pretext import constants, resources, server, utils +from typing import cast, Generator, List import pytest from pytest_console_scripts import ScriptRunner @@ -734,17 +733,17 @@ def test_custom_webwork_server(tmp_path: Path, script_runner: ScriptRunner) -> N # --------------------------------------------------------------------------- # 5. Validate # +# `pretext validate` runs core's `validate`, which writes a consolidated report +# (jing's schema messages plus the "validation-plus" stylesheet's) into `logs/`. # Exit code contract (see the validate command's docstring in pretext/cli.py): -# 0 = valid, 1 = invalid or malformed, 2 = no validator available. +# 0 = valid, 1 = invalid or malformed, 2 = validation could not be performed. # --------------------------------------------------------------------------- def _validator_available() -> bool: - """True if either lxml or jing can run against the bundled schema.""" - result = utils.run_schema_validation( - ET.fromstring(""), utils.schema_path(), order=("lxml", "jing") - ) - return result[0] is not None + """True if jing can run: it is the only engine, since lxml cannot compile + the PreTeXt schema (it incorporates PreFigure's).""" + return utils._jing_command() is not None def _make_project(tmp_path: Path, script_runner: ScriptRunner) -> Path: @@ -753,9 +752,7 @@ def _make_project(tmp_path: Path, script_runner: ScriptRunner) -> Path: return tmp_path / "new-pretext-project" -@pytest.mark.skipif( - not _validator_available(), reason="no RelaxNG validator (lxml/jing) available" -) +@pytest.mark.skipif(not _validator_available(), reason="jing is not available") def test_validate_invalid_source_is_nonzero( tmp_path: Path, script_runner: ScriptRunner ) -> None: @@ -781,9 +778,7 @@ def test_validate_malformed_xml_is_nonzero( assert ret.returncode == 1 -@pytest.mark.skipif( - not _validator_available(), reason="no RelaxNG validator (lxml/jing) available" -) +@pytest.mark.skipif(not _validator_available(), reason="jing is not available") def test_validate_dev_schema_runs(tmp_path: Path, script_runner: ScriptRunner) -> None: """`pretext validate --dev` validates against the dev schema; a fresh template project passes.""" @@ -795,25 +790,94 @@ def test_validate_dev_schema_runs(tmp_path: Path, script_runner: ScriptRunner) - def test_validate_could_not_validate_exits_2( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, script_runner: ScriptRunner ) -> None: - """When no validation engine is available at all, `pretext validate` - exits 2 to distinguish "could not check" from "invalid".""" + """With no jing to run, `pretext validate` exits 2 to distinguish + "could not check" from "invalid".""" from click.testing import CliRunner from pretext import cli from pretext import utils as putils + from pretext.core import common as core_common project = _make_project(tmp_path, script_runner) monkeypatch.chdir(project) - # Avoid the network update check and force the "no validator available" result. + # Avoid the network update check, and make jing (and only jing) unavailable, + # as core sees it. monkeypatch.setattr(putils, "check_for_updates", lambda *a, **k: None) - monkeypatch.setattr( - putils, - "run_schema_validation", - lambda *a, **k: (None, "no validator available"), - ) + real_get_executable_cmd = core_common.get_executable_cmd + + def _no_jing(exec_name: str) -> List[str]: + if exec_name == "jing": + raise OSError("cannot locate executable with configuration name `jing`") + return real_get_executable_cmd(exec_name) + + monkeypatch.setattr(core_common, "get_executable_cmd", _no_jing) result = CliRunner().invoke(cli.main, ["validate"]) assert result.exit_code == 2 +def test_validate_engine_salve_unavailable_exits_2( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, script_runner: ScriptRunner +) -> None: + """Asking for the salve engine when it can't be set up exits 2, rather than + silently validating with jing and reporting on the wrong engine.""" + from click.testing import CliRunner + from pretext import cli + from pretext import utils as putils + + project = _make_project(tmp_path, script_runner) + monkeypatch.chdir(project) + monkeypatch.setattr(putils, "check_for_updates", lambda *a, **k: None) + monkeypatch.setattr(putils, "salve_shim_command", lambda: None) + result = CliRunner().invoke(cli.main, ["validate", "--engine", "salve"]) + assert result.exit_code == 2 + + +@pytest.mark.skipif( + not (resources.resource_base_path() / "salve" / "node_modules").exists(), + reason="the salve engine has not been installed (run `pretext validate --engine salve` once)", +) +def test_validate_engine_salve_end_to_end( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, script_runner: ScriptRunner +) -> None: + """A template project validates cleanly through the salve engine too. Skipped + until a developer has installed it, since first use needs npm and a network.""" + from click.testing import CliRunner + from pretext import cli + from pretext import utils as putils + + project = _make_project(tmp_path, script_runner) + monkeypatch.chdir(project) + monkeypatch.setattr(putils, "check_for_updates", lambda *a, **k: None) + # `cli.error_flush_handler` is a module global, so errors logged by an + # earlier in-process invocation would make this run exit 1 on their account. + # A real run is a fresh process; here the buffer has to be emptied by hand. + cli.error_flush_handler.buffer.clear() + result = CliRunner().invoke(cli.main, ["validate", "--engine", "salve"]) + assert result.exit_code == 0 + assert (project / "logs" / "main-validation.txt").exists() + + +@pytest.mark.skipif(not _validator_available(), reason="jing is not available") +def test_validate_terse_method_is_machine_readable( + tmp_path: Path, script_runner: ScriptRunner +) -> None: + """`--method terse` writes core's tab-separated report: one message per + line, each naming the source file it came from.""" + project = _make_project(tmp_path, script_runner) + main_src = project / "source" / "main.ptx" + main_src.write_text( + '\n
Text outside of element.
\n' + ) + ret = script_runner.run([PTX_CMD, "validate", "--method", "terse"], cwd=project) + assert ret.returncode == 1 + report = (project / "logs" / "main-validation.txt").read_text() + lines = [line for line in report.splitlines() if line.strip()] + assert len(lines) > 0 + for line in lines: + fields = line.split("\t") + assert len(fields) == 5 + assert fields[0] == "main.ptx" + + # --------------------------------------------------------------------------- # 6. View (preview server) # --------------------------------------------------------------------------- diff --git a/tests/test_project.py b/tests/test_project.py index 08961ca0..e35ff23a 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -188,7 +188,9 @@ def test_manifest_elaborate(tmp_path: Path) -> None: assert project.site == Path("my-great-site") assert project.xsl == Path("customizations") assert project._executables.xelatex == "xelatex" + # Deprecated executables still parse, but are never handed to core. assert project._executables.liblouis == "foobar" + assert "liblouis" not in project._executables.model_dump() assert project.asy_method == "local" t_web = project.get_target("web") @@ -255,7 +257,13 @@ def test_manifest_legacy() -> None: assert len(project.targets) == 3 assert project._executables.xelatex == "xelatex" + # Deprecated executables still parse, but are never handed to core. assert project._executables.liblouis == "foobar" + assert "liblouis" not in project._executables.model_dump() + # Executables the legacy format has no element for fall back to their + # modern defaults, so core is never handed a short dict. + assert project._executables.model_dump()["jing"] == "jing" + assert project._executables.model_dump()["mermaid"] == "mmdc" t_html = project.get_target("html") assert t_html is not None @@ -297,7 +305,13 @@ def test_manifest_legacy_wrong() -> None: assert len(project.targets) == 3 assert project._executables.xelatex == "xelatex" + # Deprecated executables still parse, but are never handed to core. assert project._executables.liblouis == "foobar" + assert "liblouis" not in project._executables.model_dump() + # Executables the legacy format has no element for fall back to their + # modern defaults, so core is never handed a short dict. + assert project._executables.model_dump()["jing"] == "jing" + assert project._executables.model_dump()["mermaid"] == "mmdc" t_html = project.get_target("html") assert t_html is not None @@ -329,6 +343,99 @@ def test_manifest_legacy_wrong() -> None: assert project._executables.latex == "latex1" +def test_executables_match_core() -> None: + """The executables handed to core are exactly the keys the core script's + `pretext.cfg` declares -- no missing key (core looks these up in a plain + dict) and no vestigial extras.""" + assert set(pr.Executables().model_dump()) == { + "latex", + "pdflatex", + "xelatex", + "asy", + "mermaid", + "sage", + "pdfeps", + "node", + "perl", + "fop", + "jing", + } + + +def test_executables_jing_from_manifest(tmp_path: Path) -> None: + """A `jing` in executables.ptx reaches core, so a user whose jing is off the + search path (or is a `java -jar ...` command) can point the CLI at it.""" + prj_path = tmp_path / "simple" + shutil.copytree(EXAMPLES_DIR / "projects" / "project_refactor" / "simple", prj_path) + (prj_path / "executables.ptx").write_text( + '' + ) + with utils.working_directory(prj_path): + project = pr.Project.parse() + assert project._executables.jing == "java -jar /opt/jing.jar" + # `model_dump()` is what `init_core()` hands to core, options and all. + assert project._executables.model_dump()["jing"] == "java -jar /opt/jing.jar" + + +def test_validate_source_salve_engine_swaps_and_restores_jing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The salve engine reaches core by standing in for the `jing` executable, + and the project's own executables are restored once validation is done (so + a later build isn't left pointing at the shim).""" + prj_path = tmp_path / "simple" + shutil.copytree(EXAMPLES_DIR / "projects" / "project_refactor" / "simple", prj_path) + with utils.working_directory(prj_path): + project = pr.Project.parse() + target = project.get_target() + + handed_to_core = [] + monkeypatch.setattr( + pr.utils, "salve_shim_command", lambda: ["node", "/x/shim.mjs"] + ) + monkeypatch.setattr( + pr.core, "set_executables", lambda d: handed_to_core.append(dict(d)) + ) + monkeypatch.setattr(pr.core, "validate", lambda **kwargs: None) + + target.validate_source(engine="salve") + + assert handed_to_core[0]["jing"] == "node /x/shim.mjs" + assert handed_to_core[-1]["jing"] == "jing" + + +def test_validate_source_salve_unavailable_returns_none( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """With no usable shim, validation reports that it could not run (exit 2 at + the CLI) instead of falling back to jing and quietly testing the wrong thing.""" + prj_path = tmp_path / "simple" + shutil.copytree(EXAMPLES_DIR / "projects" / "project_refactor" / "simple", prj_path) + with utils.working_directory(prj_path): + project = pr.Project.parse() + + monkeypatch.setattr(pr.utils, "salve_shim_command", lambda: None) + + def _unreachable(**kwargs: object) -> None: + raise AssertionError("core.validate should not be called") + + monkeypatch.setattr(pr.core, "validate", _unreachable) + assert project.get_target().validate_source(engine="salve") is None + + +def test_executables_deprecated_and_unknown() -> None: + """Executables core no longer uses are accepted (older `executables.ptx` + files must keep parsing) but withheld from core; genuinely unknown ones + are still rejected, so typos don't pass silently.""" + execs = pr.Executables(liblouis="file2brl", pdfsvg="pdf2svg", pdfpng="convert") + assert "liblouis" not in execs.model_dump() + assert "pdfsvg" not in execs.model_dump() + assert "pdfpng" not in execs.model_dump() + + with pytest.raises(pydantic.ValidationError): + pr.Executables(jjing="typo") # type: ignore[call-arg] + + def test_html_build_permissions(tmp_path: Path) -> None: """HTML output is world-readable (0o755+), so it can be served directly from a web root.""" @@ -400,7 +507,7 @@ def test_xinclude_publication_build(tmp_path: Path) -> None: with utils.working_directory(prj_path): project = pr.Project.parse() target = project.get_target("web") - assert target.external_dir() == Path("../assets") + assert target.external_dir_abspath() == (prj_path / "assets").resolve() target.build() assert (target.output_dir_abspath() / "index.html").exists() diff --git a/tests/test_utils.py b/tests/test_utils.py index 1662d7b7..7c10bf21 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -10,6 +10,7 @@ import fnmatch import os import sys +import typing as t import pytest from pathlib import Path from lxml import etree as ET # noqa: N812 @@ -233,73 +234,172 @@ def test_xml_syntax_is_valid(tmp_path: Path) -> None: utils.xml_syntax_is_valid(tmp_path / "nonexistent.ptx") -def test_validate_with_lxml_valid_and_invalid(tmp_path: Path) -> None: - schema_file = tmp_path / "mini.rng" - schema_file.write_text( - '' - '' - ) +def test_jing_command_prefers_configured_executable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A `jing` configured in executables.ptx wins over one on the PATH, and + keeps its options (jing is a Java program, so it may be a whole command).""" + configured = ["/opt/java", "-jar", "/opt/jing.jar"] + monkeypatch.setattr(utils.core, "get_executable_cmd", lambda name: configured) + monkeypatch.setattr(utils.shutil, "which", lambda name: "/usr/bin/jing") + assert utils._jing_command() == configured + + +@pytest.mark.parametrize("error", [KeyError("jing"), TypeError(), OSError("missing")]) +def test_jing_command_falls_back_to_path( + monkeypatch: pytest.MonkeyPatch, error: Exception +) -> None: + """With no usable configured jing -- no `jing` key, no project loaded, or a + command that isn't installed -- fall back to a jing on the PATH.""" + + def _raise(name: str) -> object: + raise error + + monkeypatch.setattr(utils.core, "get_executable_cmd", _raise) + monkeypatch.setattr(utils.shutil, "which", lambda name: "/usr/bin/jing") + assert utils._jing_command() == ["/usr/bin/jing"] + - ok = utils._validate_with_lxml(ET.fromstring(""), schema_file) - assert ok == (True, "") +def test_jing_command_none_when_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: + def _raise(name: str) -> object: + raise OSError("missing") - bad = utils._validate_with_lxml(ET.fromstring(""), schema_file) - assert bad is not None - assert bad[0] is False - assert bad[1] != "" + monkeypatch.setattr(utils.core, "get_executable_cmd", _raise) + monkeypatch.setattr(utils.shutil, "which", lambda name: None) + assert utils._jing_command() is None + # And the engine reports itself unavailable, rather than "invalid". + assert utils.validate_with_jing(Path("source.xml"), Path("s.rng")) is None -def test_validate_with_lxml_uncompilable_schema_returns_none( +def test_validate_with_jing_invokes_configured_command( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - def _raise(*args: object, **kwargs: object) -> object: - raise ET.RelaxNGParseError("boom") + """The configured command line, options and all, is what gets run.""" + recorded: list[list[str]] = [] - monkeypatch.setattr(utils.ET, "RelaxNG", _raise) - assert ( - utils._validate_with_lxml(ET.fromstring(""), tmp_path / "x.rng") - is None + class _Result: + returncode = 0 + stdout = "" + stderr = "" + + def _run(cmd: list[str], **kwargs: object) -> _Result: + recorded.append(cmd) + return _Result() + + monkeypatch.setattr( + utils, "_jing_command", lambda: ["java", "-jar", "/opt/jing.jar"] ) + monkeypatch.setattr(utils.subprocess, "run", _run) + + schema_file = tmp_path / "mini.rng" + source_file = tmp_path / "source.xml" + assert utils.validate_with_jing(source_file, schema_file) == (True, "") + assert recorded[0] == [ + "java", + "-jar", + "/opt/jing.jar", + str(schema_file), + str(source_file), + ] + + +@pytest.mark.parametrize( + "returncode, expected", + # 0 = valid, 1 = the document has messages, anything more = jing itself + # failed, which is "nothing was checked" rather than "invalid". + [(0, (True, "boom")), (1, (False, "boom")), (2, None), (127, None)], +) +def test_validate_with_jing_maps_exit_codes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + returncode: int, + expected: t.Optional[tuple[bool, str]], +) -> None: + class _Result: + stdout = "boom" + stderr = "" + _Result.returncode = returncode # type: ignore[attr-defined] -def test_run_schema_validation_uses_first_available_engine( + monkeypatch.setattr(utils, "_jing_command", lambda: ["jing"]) + monkeypatch.setattr(utils.subprocess, "run", lambda *a, **k: _Result()) + assert utils.validate_with_jing(tmp_path / "s.xml", tmp_path / "s.rng") == expected + + +def test_salve_shim_command_needs_node(monkeypatch: pytest.MonkeyPatch) -> None: + """Without node there is no salve engine -- and asking for it must not have + the side effect of installing the CLI's resources.""" + monkeypatch.setattr(utils.shutil, "which", lambda name: None) + + def _no_resources() -> Path: + raise AssertionError("resource_base_path() should not be reached") + + monkeypatch.setattr(utils.resources, "resource_base_path", _no_resources) + assert utils.salve_shim_command() is None + + +def test_salve_shim_command_needs_npm_on_first_use( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - monkeypatch.setattr(utils, "_validate_with_jing", lambda *a: (True, "")) + """The shim installs itself with npm the first time; without npm, the engine + is simply unavailable rather than half installed.""" monkeypatch.setattr( - utils, "_validate_with_lxml", lambda *a: (False, "lxml says no") + utils.shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None ) - - # jing first wins - assert utils.run_schema_validation( - ET.fromstring(""), tmp_path / "s.rng", order=("jing", "lxml") - ) == (True, "") - # lxml first wins - assert utils.run_schema_validation( - ET.fromstring(""), tmp_path / "s.rng", order=("lxml", "jing") - ) == (False, "lxml says no") + monkeypatch.setattr(utils.resources, "resource_base_path", lambda: tmp_path) + assert utils.salve_shim_command() is None + assert not (tmp_path / "salve" / "node_modules").exists() -def test_run_schema_validation_skips_unavailable_engine( +def test_salve_shim_command_when_installed( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - monkeypatch.setattr(utils, "_validate_with_jing", lambda *a: None) - monkeypatch.setattr(utils, "_validate_with_lxml", lambda *a: (True, "")) - assert utils.run_schema_validation( - ET.fromstring(""), tmp_path / "s.rng", order=("jing", "lxml") - ) == (True, "") + """Once installed, the command is `node ` -- the shape core needs for + an executable it will call with a schema and a source file.""" + shim_dir = tmp_path / "salve" + (shim_dir / "node_modules").mkdir(parents=True) + (shim_dir / "ptx-jing-shim.mjs").write_text("// shim") + monkeypatch.setattr(utils.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(utils.resources, "resource_base_path", lambda: tmp_path) + assert utils.salve_shim_command() == [ + "/usr/bin/node", + str(shim_dir / "ptx-jing-shim.mjs"), + ] -def test_run_schema_validation_all_unavailable_returns_none( +def test_warn_on_schema_errors_writes_log_not_terminal( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - monkeypatch.setattr(utils, "_validate_with_jing", lambda *a: None) - monkeypatch.setattr(utils, "_validate_with_lxml", lambda *a: None) - result = utils.run_schema_validation( - ET.fromstring(""), tmp_path / "s.rng", order=("jing", "lxml") + """A build's schema check leaves the messages in a log file, alongside the + assembled source their line numbers refer to.""" + monkeypatch.setattr( + utils, "validate_with_jing", lambda source, schema: (False, "line 4: bad") ) - assert result[0] is None - assert "could not be completed" in result[1] + assert utils.warn_on_schema_errors(ET.fromstring(""), tmp_path) is False + assert (tmp_path / "schema-errors.log").read_text() == "line 4: bad" + assert (tmp_path / "schema-assembled-source.xml").exists() + + +def test_warn_on_schema_errors_cleans_up_when_valid( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A clean run clears the artifacts of an earlier failed one, so no stale + log is left to be read as the result of this build.""" + (tmp_path / "schema-errors.log").write_text("errors from a previous build") + monkeypatch.setattr(utils, "validate_with_jing", lambda source, schema: (True, "")) + assert utils.warn_on_schema_errors(ET.fromstring(""), tmp_path) is True + assert not (tmp_path / "schema-errors.log").exists() + assert not (tmp_path / "schema-assembled-source.xml").exists() + + +def test_warn_on_schema_errors_without_jing( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """No jing means nothing was checked -- which must not look like success.""" + monkeypatch.setattr(utils, "validate_with_jing", lambda source, schema: None) + assert utils.warn_on_schema_errors(ET.fromstring(""), tmp_path) is None + assert not (tmp_path / "schema-errors.log").exists() + assert not (tmp_path / "schema-assembled-source.xml").exists() def test_schema_path_selects_stable_or_dev() -> None: