Skip to content
Open
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ Instructions: Add a subsection under `[Unreleased]` for additions, fixes, change

## [Unreleased]

### Changed

- `pretext validate` now follows core, which checks the source against both schemas in one pass and adds a survey of the experimental constructs in use to the report. An experimental construct is not an error, so it no longer makes validation exit non-zero; it is counted and reported separately, since its markup may change without a deprecation cycle.
- `pretext validate --method` now selects only where the checks run: `local` (an installed jing, the default) or `server` (jing as a remote service). The shape of the report has moved to `pretext validate --report-form`: `full` (the default) or `terse` (machine-readable, one tab-separated message per line), so `--method terse` becomes `--report-form terse`.

### Removed

- `pretext validate --dev` (and `--method local-dev`), since validation now always consults the development schema and reports what only the production schema rejects as an experimental construct.

## [2.49.1] - 2026-08-13

Includes updates to core through commit: [5836dfc](https://github.com/PreTeXtBook/pretext/commit/5836dfcbdc342841acdbe266871a204c8a9dc8cc)
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 = "5836dfcbdc342841acdbe266871a204c8a9dc8cc"
CORE_COMMIT = "a5908505a498abfbb8fd9b6d8b26b45cdb8fba82"


def activate() -> None:
Expand Down
55 changes: 33 additions & 22 deletions pretext/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -735,18 +735,20 @@ def build(
)
@click.argument("target_name", required=False, metavar="target")
@click.option(
"--dev",
is_flag=True,
help="Validate against the development schema (pretext-dev.rng) instead of the "
"stable schema, allowing experimental elements.",
"--method",
type=click.Choice(["local", "server"]),
default="local",
show_default=True,
help='Where the schema checks run: "local" (an installed jing) or "server" '
"(jing as a remote service, needing no local install).",
)
@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 '
"--report-form",
type=click.Choice(["full", "terse"]),
default="full",
show_default=True,
help='The shape of the report: "full" (locations, excerpts, and '
'explanations, meant to be read) or "terse" (machine-readable, one '
"tab-separated message per line).",
)
@click.option(
Expand All @@ -765,18 +767,21 @@ def build(
def validate(
ctx: click.Context,
target_name: Optional[str],
dev: bool,
method: Optional[str],
method: str,
report_form: str,
engine: str,
) -> None:
"""
Validate the source of TARGET against the PreTeXt RelaxNG schema.

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).
Writes the consolidated validation report: the schema messages from jing,
a survey of the experimental constructs in use, and the messages of the
"validation-plus" stylesheet, each naming the source file, path, and line
it came from. An experimental construct is not an error, so it does not
make validation fail; it is markup whose form may change without a
deprecation cycle. 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)
Expand All @@ -788,19 +793,19 @@ def validate(
log.error(f"Could not assemble source for validation: {e}")
raise SystemExit(1)

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})."
f"(method: {method}, report form: {report_form}, 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)
result = target.validate_source(
method=method, engine=engine, report_form=report_form
)

if result is None:
log.error("Validation could not be performed.")
Expand All @@ -811,7 +816,13 @@ def validate(
)
raise SystemExit(2)

message_count, report = result
message_count, experimental_count, report = result
if experimental_count:
log.info(
f"Experimental constructs are in use at {experimental_count} "
"locations; the report surveys them. They are not errors, but "
"their markup may change without a deprecation cycle."
)
if message_count == 0:
log.info("PreTeXt source passed validation with no messages.")
log.info(f"Report: {report}")
Expand Down
61 changes: 41 additions & 20 deletions pretext/project/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -798,23 +798,33 @@ def validate_source(
method: str = "local",
dest_dir: t.Optional[Path] = None,
engine: str = "jing",
) -> t.Optional[t.Tuple[int, Path]]:
report_form: str = "full",
) -> t.Optional[t.Tuple[int, 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).
Core checks the source against both schemas. A message from the development
schema is a genuine problem; a construct that only the production schema
rejects is experimental -- not an error, but markup that may change without
a deprecation cycle -- and is surveyed in its own section of the report.

`method` is core's, and says where the jing runs happen: "local" (an
installed jing) or "server" (jing as a remote service).

`report_form` is core's shape for the report: "full" (locations, excerpts,
and explanations, meant for an author) or "terse" (one tab-separated
message per line, meant 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.
Returns the number of problems found, the number of experimental constructs
surveyed, and the path of the report; or `None` if validation could not be
performed at all.
"""
if dest_dir is None:
dest_dir = self._project.logs_abspath()
Expand All @@ -837,6 +847,7 @@ def validate_source(
out_file=None,
dest_dir=dest_dir.as_posix(),
method=method,
report_form=report_form,
)
except OSError as e:
# Raised when the configured `jing` can't be found; core has already
Expand All @@ -852,25 +863,35 @@ def validate_source(
# 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()])
# Every message names the check that raised it, so the checks are the
# messages. The survey of experimental constructs shares the report with
# the problems, under the check "experimental", and is counted apart:
# using an experimental construct is not a validation failure.
checks: t.List[str] = []
if report_form == "terse":
# One message per line, and nothing else in the file: five
# tab-separated fields, of which the fourth is the check. A message
# core could not locate is passed through whole, so a line without
# those fields is still a problem to report.
for line in lines:
if not line.strip():
continue
fields = line.split("\t")
checks.append(fields[3] if len(fields) > 3 else "schema")
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
# 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
prefix = " check: "
checks = [
line[len(prefix) :].strip()
for line in lines[first_section:]
if line.startswith(prefix)
]
experimental = checks.count("experimental")
return len(checks) - experimental, experimental, report

def build(
self,
Expand Down
26 changes: 17 additions & 9 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -772,7 +772,8 @@ def test_build_webwork_and_dynamic_fillin(
# 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/`.
# into `logs/`: jing's schema messages, a survey of the experimental constructs
# in use (not errors), and the "validation-plus" stylesheet's messages.
# Exit code contract (see the validate command's docstring in pretext/cli.py):
# 0 = valid, 1 = invalid or malformed, 2 = validation could not be performed.
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -817,12 +818,17 @@ def test_validate_malformed_xml_is_nonzero(


@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."""
def test_validate_experimental_constructs_do_not_fail(
tmp_path: Path, script_runner: ScriptRunner
) -> None:
"""Core validates against both schemas, and reports a construct that only
the production schema rejects as experimental rather than as an error, so
it does not make `pretext validate` exit non-zero."""
project = _make_project(tmp_path, script_runner)
ret = script_runner.run([PTX_CMD, "validate", "--dev"], cwd=project)
ret = script_runner.run([PTX_CMD, "validate"], cwd=project)
assert ret.returncode == 0
report = (project / "logs" / "main-validation.txt").read_text(encoding="utf-8")
assert "experimental constructs" in report


def test_validate_could_not_validate_exits_2(
Expand Down Expand Up @@ -895,17 +901,19 @@ def test_validate_engine_salve_end_to_end(


@pytest.mark.skipif(not _validator_available(), reason="jing is not available")
def test_validate_terse_method_is_machine_readable(
def test_validate_terse_report_form_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."""
"""`--report-form 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(
'<?xml version="1.0"?>\n<pretext><article><section>Text outside of element.</section></article></pretext>\n'
)
ret = script_runner.run([PTX_CMD, "validate", "--method", "terse"], cwd=project)
ret = script_runner.run(
[PTX_CMD, "validate", "--report-form", "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()]
Expand Down
72 changes: 72 additions & 0 deletions tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import time
import json
from pathlib import Path
from typing import Any
import requests
import shutil

Expand Down Expand Up @@ -423,6 +424,77 @@ def _unreachable(**kwargs: object) -> None:
assert project.get_target().validate_source(engine="salve") is None


def test_validate_source_counts_experimental_apart(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Core reports the experimental constructs in use alongside the problems,
under the check "experimental". Those are not failures, so they are counted
separately from the messages that make `pretext validate` exit non-zero."""
prj_path = tmp_path / "simple"
shutil.copytree(EXAMPLES_DIR / "projects" / "project_refactor" / "simple", prj_path)
banner = "=" * 70
report_lines = [
"Validation Report",
" check: a short name for the check (described in the preamble)",
banner,
'element "p" not allowed here',
" check: schema",
"",
'element "notation" not allowed here',
" check: experimental",
"",
"PTX:WARNING: a check no schema can express",
" check: some-plus-check-id",
"",
]

def _fake_validate(**kwargs: Any) -> None:
report = Path(kwargs["dest_dir"]) / "main-validation.txt"
report.write_text("\n".join(report_lines), encoding="utf-8")

with utils.working_directory(prj_path):
project = pr.Project.parse()
monkeypatch.setattr(pr.core, "validate", _fake_validate)
result = project.get_target().validate_source()
assert result is not None
message_count, experimental_count, _ = result
# The two real messages count; the experimental construct and the
# preamble's description of the "check:" field do not.
assert (message_count, experimental_count) == (2, 1)


def test_validate_source_passes_report_form_to_core(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The report form is core's, and separate from the method: it shapes the
report, while the method says where the jing runs happen. A terse report is
one tab-separated message per line, counted by its fourth field."""
prj_path = tmp_path / "simple"
shutil.copytree(EXAMPLES_DIR / "projects" / "project_refactor" / "simple", prj_path)
handed_to_core = {}

def _fake_validate(**kwargs: Any) -> None:
handed_to_core.update(kwargs)
report = Path(kwargs["dest_dir"]) / "main-validation.txt"
report.write_text(
"main.ptx\t/pretext/article[1]\t12\tschema\tnot allowed here\n"
"main.ptx\t/pretext/article[1]/notation[1]\t20\texperimental\tnew markup\n",
encoding="utf-8",
)

with utils.working_directory(prj_path):
project = pr.Project.parse()
monkeypatch.setattr(pr.core, "validate", _fake_validate)
result = project.get_target().validate_source(
method="server", report_form="terse"
)

assert handed_to_core["method"] == "server"
assert handed_to_core["report_form"] == "terse"
assert result is not None
assert result[:2] == (1, 1)


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
Expand Down
Loading