diff --git a/pretext/project/__init__.py b/pretext/project/__init__.py index 815bc868..cf7a1e61 100644 --- a/pretext/project/__init__.py +++ b/pretext/project/__init__.py @@ -8,7 +8,7 @@ import pickle from pathlib import Path from lxml import etree as ET -from pydantic import validator, PrivateAttr +from pydantic import validator, HttpUrl, PrivateAttr import pydantic_xml as pxml from .xml import Executables, LegacyProject, LatexEngine from .. import constants @@ -56,7 +56,21 @@ class Compression(str, Enum): ZIP = "zip" -class Target(pxml.BaseXmlModel, tag="target"): +# See `Target.server`. +class ServerName(str, Enum): + SAGE = "sage" + # Short for Asymptote. + ASY = "asy" + # Possible servers to add: Jing, WebWork. + + +# Allow the author to specify a server instead of a local executable for asset generation. See `Target.server`. +class Server(pxml.BaseXmlModel, tag="server"): + name: ServerName = pxml.attr() + url: HttpUrl = pxml.attr() + + +class Target(pxml.BaseXmlModel, tag="target", search_mode="unordered"): """ Representation of a target for a PreTeXt project: a specific build targeting a format such as HTML, LaTeX, etc. @@ -71,22 +85,34 @@ class Target(pxml.BaseXmlModel, tag="target"): # A path to the publication file for this target, relative to the project's `publication` path. publication: Path = pxml.attr(default=None) - # If no publication file is specified, assume either `publication.ptx` (if it exists) or the CLI's template `publication.ptx` (which always exists). If a publication file is specified, ensure that it exists. + # Perform validation which requires the parent `Project` object: + # - If no publication file is specified, assume either `publication.ptx` (if it exists) or the CLI's template `publication.ptx` (which always exists). If a publication file is specified, ensure that it exists. + # - Merge the `Project.server` with `self.server`. # # This can't be placed in a Pydantic validator, since `self._project` isn't set until after validation finishes. So, this must be manually called after that's done. - def post_validate_publication(self) -> None: + def post_validate(self) -> None: + # Select a default publication file if it's not provided. if self.publication is None: self.publication = Path("publication.ptx") - if self.publication_abspath().exists(): - return - # TODO: this is wrong, since the returned path is only valid inside the context manager. Instead, need to enter the context here, then exit it when this class is deleted (also problematic). - with templates.resource_path("publication.ptx") as self.publication: - return - p_full = self.publication_abspath() - if not p_full.exists(): - raise FileNotFoundError( - f"Provided publication file {p_full} does not exist." - ) + # If this publication file doesn't exist, ... + if not self.publication_abspath().exists(): + # ... then use the CLI's built-in template file. + # TODO: this is wrong, since the returned path is only valid inside the context manager. Instead, need to enter the context here, then exit it when this class is deleted (also problematic). + with templates.resource_path("publication.ptx") as self.publication: + pass + # Otherwise, verify that the provided publication file exists. TODO: perhaps skip this, instead reporting a file open error when this is read? + else: + p_full = self.publication_abspath() + if not p_full.exists(): + raise FileNotFoundError( + f"Provided publication file {p_full} does not exist." + ) + + # Merge in servers from the parent Project that aren't specified in this Target. + self_server_names = [server.name for server in self.server] + for server in self._project.server: + if server.name not in self_server_names: + self.server.append(server) # A path to the output directory for this target, relative to the project's `output` path. output_dir: Path = pxml.attr(name="output-dir", default=None) @@ -133,13 +159,23 @@ def xsl_validator(cls, v: t.Optional[Path], values: t.Any) -> t.Optional[Path]: compression: t.Optional[Compression] = pxml.attr() stringparams: t.Dict[str, str] = pxml.element(default={}) + # See `Server`. Each server name (`sage`, `asy`) may be specified only once. If specified, the CLI will use the server for asset generation instead of a local executable. Settings for a given server name here override settings at the project level. + server: t.List[Server] = pxml.element(default=[]) + + @validator("server") + def server_validator(cls, v: t.List[Server]) -> t.List[Server]: + # Ensure the names are unique. + if len(set([server.name for server in v])) != len(v): + raise ValueError("Server names must not be repeated.") + return v + # Allow specifying `_project` in the constructor. (Since it's private, pydantic ignores it by default). def __init__(self, **kwargs: t.Any): super().__init__(**kwargs) if "_project" in kwargs: self._project = kwargs["_project"] # Since we now have the project, perform validation. - self.post_validate_publication() + self.post_validate() def source_abspath(self) -> Path: return self._project.source_abspath() / self.source @@ -537,7 +573,7 @@ def generate_assets( core.release_temporary_directories() -class Project(pxml.BaseXmlModel, tag="project"): +class Project(pxml.BaseXmlModel, tag="project", search_mode="unordered"): """ Representation of a PreTeXt project: a Path for the project on the disk, and Paths for where to build output and maintain a site. @@ -562,7 +598,7 @@ def validate_path(cls, path: t.Union[Path, str]) -> Path: # A path, relative to the project directory, prepended to any target's `publication`. publication: Path = pxml.attr(default=Path("publication")) # A path, relative to the project directory, prepended to any target's `output_dir`. - output_dir: Path = pxml.attr(default=Path("output")) + output_dir: Path = pxml.attr(name="output-dir", default=Path("output")) # A path, relative to the project directory, prepended to any target's `site`. site: Path = pxml.attr(default=Path("site")) # A path, relative to the project directory, prepended to any target's `xsl`. @@ -571,6 +607,16 @@ def validate_path(cls, path: t.Union[Path, str]) -> Path: "targets", pxml.element(tag="target", default=[]) ) + # See the docs on `Target.server`; they apply here as well. + server: t.List[Server] = pxml.element(default=[]) + + @validator("server") + def server_validator(cls, v: t.List[Server]) -> t.List[Server]: + # Ensure the names are unique. + if len(set([server.name for server in v])) != len(v): + raise ValueError("Server names must not be repeated.") + return v + # Allow specifying `_path` or `_executables` in the constructor. (Since they're private, pydantic ignores them by default). def __init__(self, **kwargs: t.Any): super().__init__(**kwargs) @@ -668,7 +714,7 @@ class ProjectVersionOnly(pxml.BaseXmlModel, tag="project"): # Set the `_project` for each target, which isn't handled in the XML. for _tgt in p.targets: _tgt._project = p - _tgt.post_validate_publication() + _tgt.post_validate() return p def new_target(self, name: str, format: str, **kwargs: t.Any) -> None: diff --git a/pretext/project/xml.py b/pretext/project/xml.py index b38858b1..80490e4e 100644 --- a/pretext/project/xml.py +++ b/pretext/project/xml.py @@ -1,16 +1,20 @@ from pathlib import Path +import shutil import typing as t from enum import Enum import pydantic_xml as pxml +# To prevent circular imports, put this here instead of in `__init__`; however, it's not used in the file. class Executables(pxml.BaseXmlModel, tag="executables"): latex: str = pxml.attr(default="latex") pdflatex: str = pxml.attr(default="pdflatex") xelatex: str = pxml.attr(default="xelatex") pdfsvg: str = pxml.attr(default="pdf2svg") - asy: str = pxml.attr(default="asy") - sage: str = pxml.attr(default="sage") + # 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")) + # The same applies to Sage. + sage: t.Optional[str] = pxml.attr(default=shutil.which("sage")) pdfpng: str = pxml.attr(default="convert") pdfeps: str = pxml.attr(default="pdftops") node: str = pxml.attr(default="node") diff --git a/tests/examples/projects/project_refactor/elaborate/project.ptx b/tests/examples/projects/project_refactor/elaborate/project.ptx index 9524a272..569aeffa 100644 --- a/tests/examples/projects/project_refactor/elaborate/project.ptx +++ b/tests/examples/projects/project_refactor/elaborate/project.ptx @@ -3,22 +3,26 @@ ptx-version="2" source="my_ptx_source" publication="dont-touch" - output="build/here" + output-dir="build/here" site="build/staging" xsl="customizations"> + + + output-dir="my-pdf" + output-filename="out.pdf"> + diff --git a/tests/test_project.py b/tests/test_project.py index 4e13bb68..785a70c0 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -6,6 +6,7 @@ import subprocess from typing import List +import pydantic import pytest from pretext import project as pr @@ -153,21 +154,36 @@ def test_manifest_elaborate(tmp_path: Path) -> None: assert t_web.source == Path("book.ptx") assert t_web.publication == Path("publication.ptx") assert t_web.output_dir == Path("web") + assert t_web.output_dir_abspath().relative_to(project.abspath()) == Path( + "build/here/web" + ) assert t_web.site == Path("") assert t_web.xsl == Path("silly.xsl") assert t_web.stringparams == {} + assert sorted(t_web.server, key=lambda k: k.name) == [ + pr.Server(name="asy", url="http://example1.com"), + pr.Server(name="sage", url="http://example2.com"), + ] t_print = project.get_target("print") assert t_print.format == "pdf" assert t_print.source == Path("main.ptx") assert t_print.publication == Path("extras", "print.xml") assert t_print.output_dir == Path("my-pdf") + assert t_print.output_dir_abspath().relative_to(project.abspath()) == Path( + "build/here/my-pdf" + ) + assert t_print.output_filename == "out.pdf" assert t_print.site == Path("site") assert t_print.xsl is None assert t_print.stringparams == { "foo": "bar", "baz": "goo", } + assert sorted(t_print.server, key=lambda k: k.name) == [ + pr.Server(name="asy", url="http://example3.com"), + pr.Server(name="sage", url="http://example2.com"), + ] def test_manifest_elaborate_build(tmp_path: Path) -> None: @@ -302,3 +318,17 @@ def test_asset_table(tmp_path: Path) -> None: different_than_web = project.get_target("different-than-web") assert web.generate_asset_table() == same_as_web.generate_asset_table() assert web.generate_asset_table() != different_than_web.generate_asset_table() + + +def test_server() -> None: + project = pr.Project(ptx_version="2") + # Verify that repeated server names cause a validation error. + with pytest.raises(pydantic.ValidationError): + project.new_target( + name="test", + format="html", + server=[ + pr.Server(name="sage", url="http://test1.com"), + pr.Server(name="sage", url="http://test2.com"), + ], + )