Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<docinfo>` 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)
Expand Down
2 changes: 1 addition & 1 deletion pretext/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

VERSION = get_version("pretext", Path(__file__).parent.parent)

CORE_COMMIT = "1939b8c6c1cef11fb1c582ff69f49ce2ea8cfd27"
CORE_COMMIT = "ea9c025222639b69dd7bf87dd3b9a48b507b8418"


def activate() -> None:
Expand Down
95 changes: 65 additions & 30 deletions pretext/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from datetime import datetime
import importlib
import importlib.util
import logging
Expand Down Expand Up @@ -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,
Expand All @@ -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)


Expand Down Expand Up @@ -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,
Expand Down
19 changes: 17 additions & 2 deletions pretext/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading