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
82 changes: 64 additions & 18 deletions pretext/project/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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`.
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions pretext/project/xml.py
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is good. It makes me wonder if we should really be doing checks ourselves for all executables rather than letting core handle that functionality.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. That's easy to add if we want it.

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")
Expand Down
10 changes: 7 additions & 3 deletions tests/examples/projects/project_refactor/elaborate/project.ptx
Original file line number Diff line number Diff line change
Expand Up @@ -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">
<server name="asy" url="http://example1.com"/>
<server name="sage" url="http://example2.com"/>
<targets>
<target
name="web"
format="html"
source="book.ptx"
output = "web"
output-dir="web"
site=""
xsl="silly.xsl" />
<target
name="print"
format="pdf"
publication="extras/print.xml"
output="my-pdf">
output-dir="my-pdf"
output-filename="out.pdf">
<server name="asy" url="http://example3.com"/>
<stringparams foo="bar" baz="goo" />
</target>
</targets>
Expand Down
30 changes: 30 additions & 0 deletions tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import subprocess
from typing import List

import pydantic
import pytest

from pretext import project as pr
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"),
],
)