labharness 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- labharness/__init__.py +19 -0
- labharness/cli/__init__.py +8 -0
- labharness/cli/app.py +207 -0
- labharness/core/__init__.py +21 -0
- labharness/core/atomic.py +28 -0
- labharness/core/errors.py +17 -0
- labharness/core/extras.py +18 -0
- labharness/core/latex.py +107 -0
- labharness/core/manifest.py +119 -0
- labharness/core/templates.py +33 -0
- labharness/core/workspace.py +30 -0
- labharness/doctor.py +145 -0
- labharness/modules/__init__.py +13 -0
- labharness/modules/chem/__init__.py +13 -0
- labharness/modules/chem/resolve.py +53 -0
- labharness/modules/chem/structures.py +103 -0
- labharness/modules/diagrams/__init__.py +15 -0
- labharness/modules/diagrams/tikz.py +69 -0
- labharness/modules/plots/__init__.py +32 -0
- labharness/modules/plots/data.py +201 -0
- labharness/modules/plots/figure.py +179 -0
- labharness/modules/plots/fits.py +244 -0
- labharness/py.typed +0 -0
- labharness/style/__init__.py +33 -0
- labharness/style/fonts.py +33 -0
- labharness/style/journals/acs.toml +50 -0
- labharness/style/latex.py +93 -0
- labharness/style/mpl.py +66 -0
- labharness/style/rdkit.py +27 -0
- labharness/style/tokens.py +94 -0
- labharness/templates/figures/flow.tex +11 -0
- labharness/templates/figures/mechanism.tex +17 -0
- labharness/templates/figures/network.tex +13 -0
- labharness/templates/workspace/.gitignore +11 -0
- labharness/templates/workspace/AGENTS.md +30 -0
- labharness/templates/workspace/CLAUDE.md +1 -0
- labharness/templates/workspace/compile.ps1 +3 -0
- labharness/templates/workspace/compile.sh +4 -0
- labharness/templates/workspace/data/.gitkeep +0 -0
- labharness/templates/workspace/figures/.gitkeep +0 -0
- labharness/templates/workspace/journals/acs/labharness-style.tex +34 -0
- labharness/templates/workspace/journals/acs/paper.tex +54 -0
- labharness/templates/workspace/journals/acs/references.bib +1 -0
- labharness/templates/workspace/labharness.toml +8 -0
- labharness/templates/workspace/scripts/.gitkeep +0 -0
- labharness/watch/__init__.py +26 -0
- labharness/watch/latex.py +16 -0
- labharness/watch/runner.py +139 -0
- labharness/watch/session.py +88 -0
- labharness/watch/viewer.py +55 -0
- labharness-0.1.0.dist-info/METADATA +269 -0
- labharness-0.1.0.dist-info/RECORD +55 -0
- labharness-0.1.0.dist-info/WHEEL +4 -0
- labharness-0.1.0.dist-info/entry_points.txt +7 -0
- labharness-0.1.0.dist-info/licenses/LICENSE +21 -0
labharness/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""LabHarness: a local-first Data-to-Paper harness.
|
|
2
|
+
|
|
3
|
+
The package is organised as a thin core plus independent modules:
|
|
4
|
+
|
|
5
|
+
- ``labharness.core``: module contract, registry, manifest and errors.
|
|
6
|
+
- ``labharness.style``: the shared style (one typeface for the whole document).
|
|
7
|
+
- ``labharness.watch``: the event-driven orchestrator.
|
|
8
|
+
- ``labharness.cli``: the command-line interface, a thin layer over the above.
|
|
9
|
+
- ``labharness.modules``: one subpackage per domain, each behind an optional extra.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
__version__ = version("labharness")
|
|
16
|
+
except PackageNotFoundError: # running from a source checkout that is not installed
|
|
17
|
+
__version__ = "0.0.0+unknown"
|
|
18
|
+
|
|
19
|
+
__all__ = ["__version__"]
|
labharness/cli/app.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""The LabHarness command line.
|
|
2
|
+
|
|
3
|
+
A thin layer: every command reads its workspace, calls into the core or the watcher, and
|
|
4
|
+
prints the result. No figure logic lives here, so a graphical interface can reuse the same
|
|
5
|
+
functions later.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from labharness import __version__
|
|
15
|
+
from labharness.core.errors import LabHarnessError, MissingExtraError
|
|
16
|
+
from labharness.core.manifest import Figure, Workspace, load_workspace
|
|
17
|
+
from labharness.core.workspace import create_workspace
|
|
18
|
+
from labharness.doctor import everything_required_passes, run_checks
|
|
19
|
+
from labharness.modules.chem import resolve_name
|
|
20
|
+
from labharness.watch.runner import BuildResult
|
|
21
|
+
from labharness.watch.runner import build as run_build
|
|
22
|
+
from labharness.watch.session import DEFAULT_DEBOUNCE_MS, Cycle, watch
|
|
23
|
+
from labharness.watch.viewer import open_pdf
|
|
24
|
+
|
|
25
|
+
EXIT_ERROR = 1
|
|
26
|
+
EXIT_MISSING_DEPENDENCY = 3
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(
|
|
29
|
+
add_completion=False,
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
invoke_without_command=True,
|
|
32
|
+
help="Turn raw lab data into publication-ready LaTeX figures, and keep the PDF in sync.",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.callback()
|
|
37
|
+
def _main(
|
|
38
|
+
ctx: typer.Context,
|
|
39
|
+
version: Annotated[
|
|
40
|
+
bool, typer.Option("--version", help="Print the LabHarness version and exit.")
|
|
41
|
+
] = False,
|
|
42
|
+
) -> None:
|
|
43
|
+
if version:
|
|
44
|
+
typer.echo(__version__)
|
|
45
|
+
raise typer.Exit()
|
|
46
|
+
if ctx.invoked_subcommand is None:
|
|
47
|
+
typer.echo(ctx.get_help())
|
|
48
|
+
raise typer.Exit()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def init(
|
|
53
|
+
path: Annotated[Path, typer.Argument(help="Where to create the workspace.")] = Path("."),
|
|
54
|
+
journal: Annotated[str, typer.Option(help="Journal template to use.")] = "acs",
|
|
55
|
+
force: Annotated[bool, typer.Option(help="Write into a folder that is not empty.")] = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Create a workspace: manuscript, data, scripts, figures and manifest."""
|
|
58
|
+
with _reporting_errors():
|
|
59
|
+
target = create_workspace(path, journal=journal, force=force)
|
|
60
|
+
|
|
61
|
+
typer.secho(f"Workspace created in {target}", fg=typer.colors.GREEN)
|
|
62
|
+
typer.echo("Next: add a figure with 'labharness add', then run 'labharness watch'.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def build(
|
|
67
|
+
only: Annotated[str | None, typer.Option(help="Rebuild a single figure, by name.")] = None,
|
|
68
|
+
no_latex: Annotated[bool, typer.Option(help="Rebuild figures without compiling.")] = False,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Rebuild the figures and compile the document once."""
|
|
71
|
+
with _reporting_errors():
|
|
72
|
+
workspace = load_workspace()
|
|
73
|
+
figures = _selected(workspace, only)
|
|
74
|
+
result = _build(workspace, figures, compile_latex=not no_latex)
|
|
75
|
+
|
|
76
|
+
_print_result(result)
|
|
77
|
+
if not result.ok:
|
|
78
|
+
raise typer.Exit(EXIT_ERROR)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command("watch")
|
|
82
|
+
def watch_command(
|
|
83
|
+
no_open: Annotated[bool, typer.Option("--no-open", help="Do not open the PDF viewer.")] = False,
|
|
84
|
+
debounce: Annotated[
|
|
85
|
+
int, typer.Option(help="Milliseconds used to group rapid saves.")
|
|
86
|
+
] = DEFAULT_DEBOUNCE_MS,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Watch the workspace and rebuild on every save."""
|
|
89
|
+
with _reporting_errors():
|
|
90
|
+
workspace = load_workspace()
|
|
91
|
+
typer.secho(f"Watching {workspace.root}", fg=typer.colors.GREEN)
|
|
92
|
+
|
|
93
|
+
first = _build(workspace, list(workspace.figures), compile_latex=True)
|
|
94
|
+
_print_result(first)
|
|
95
|
+
|
|
96
|
+
pdf = first.compilation.pdf if first.compilation is not None else None
|
|
97
|
+
if not no_open and pdf is not None and not open_pdf(pdf):
|
|
98
|
+
typer.secho(
|
|
99
|
+
"No PDF viewer found. Install SumatraPDF (Windows) or Skim (macOS); "
|
|
100
|
+
"Adobe Acrobat locks the file and stops the rebuild.",
|
|
101
|
+
fg=typer.colors.YELLOW,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
typer.echo("Waiting for changes. Press Ctrl+C to stop.")
|
|
105
|
+
try:
|
|
106
|
+
watch(workspace, on_cycle=_print_cycle, debounce_ms=debounce)
|
|
107
|
+
except KeyboardInterrupt: # pragma: no cover - interactive
|
|
108
|
+
typer.echo("Stopped.")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.command()
|
|
112
|
+
def resolve(
|
|
113
|
+
name: Annotated[str, typer.Argument(help="Systematic IUPAC name, in quotes.")],
|
|
114
|
+
output: Annotated[Path, typer.Option("--output", "-o", help="Where to write the SMILES.")],
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Turn an IUPAC name into a SMILES file, offline, with OPSIN."""
|
|
117
|
+
with _reporting_errors():
|
|
118
|
+
smiles = resolve_name(name, output=output)
|
|
119
|
+
|
|
120
|
+
typer.secho(f"{smiles}", fg=typer.colors.GREEN)
|
|
121
|
+
typer.echo(f"written to {output}. Check it before using it in a figure.")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command()
|
|
125
|
+
def doctor() -> None:
|
|
126
|
+
"""Check that this machine has everything LabHarness needs."""
|
|
127
|
+
checks = run_checks()
|
|
128
|
+
for check in checks:
|
|
129
|
+
if check.ok:
|
|
130
|
+
mark, colour = "OK ", typer.colors.GREEN
|
|
131
|
+
elif check.required:
|
|
132
|
+
mark, colour = "FAIL", typer.colors.RED
|
|
133
|
+
else:
|
|
134
|
+
mark, colour = "WARN", typer.colors.YELLOW
|
|
135
|
+
typer.secho(f"{mark} {check.name}: {check.detail}", fg=colour)
|
|
136
|
+
if check.hint and not check.ok:
|
|
137
|
+
typer.echo(f" {check.hint}")
|
|
138
|
+
|
|
139
|
+
if not everything_required_passes(checks):
|
|
140
|
+
raise typer.Exit(EXIT_MISSING_DEPENDENCY)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _selected(workspace: Workspace, only: str | None) -> list[Figure] | None:
|
|
144
|
+
if only is None:
|
|
145
|
+
return None
|
|
146
|
+
chosen = [figure for figure in workspace.figures if figure.name == only]
|
|
147
|
+
if not chosen:
|
|
148
|
+
known = ", ".join(figure.name for figure in workspace.figures) or "none"
|
|
149
|
+
raise LabHarnessError(f"no figure called '{only}' in the manifest. Known figures: {known}")
|
|
150
|
+
return chosen
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _build(workspace: Workspace, figures: list[Figure] | None, compile_latex: bool) -> BuildResult:
|
|
154
|
+
return run_build(workspace, figures=figures, compile_latex=compile_latex)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _print_result(result: BuildResult) -> None:
|
|
158
|
+
for figure in result.figures:
|
|
159
|
+
if figure.ok:
|
|
160
|
+
typer.echo(f" {figure.figure.output} {_ms(figure.seconds)}")
|
|
161
|
+
else:
|
|
162
|
+
typer.secho(f" {figure.figure.output} failed", fg=typer.colors.RED)
|
|
163
|
+
typer.echo(f" {figure.error}")
|
|
164
|
+
|
|
165
|
+
compilation = result.compilation
|
|
166
|
+
if compilation is not None and not compilation.ok:
|
|
167
|
+
typer.secho(" LaTeX failed", fg=typer.colors.RED)
|
|
168
|
+
for line in compilation.errors[:5]:
|
|
169
|
+
typer.echo(f" {line}")
|
|
170
|
+
|
|
171
|
+
typer.echo(
|
|
172
|
+
f" figures {_ms(result.figures_seconds)} | latex {_ms(result.latex_seconds)} | "
|
|
173
|
+
f"total {_ms(result.total_seconds)}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _print_cycle(cycle: Cycle) -> None:
|
|
178
|
+
changed = ", ".join(path.name for path in cycle.changed)
|
|
179
|
+
typer.echo(f"\n{time.strftime('%H:%M:%S')} {changed}")
|
|
180
|
+
_print_result(cycle.result)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _ms(seconds: float) -> str:
|
|
184
|
+
return f"{seconds * 1000:.0f} ms"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class _reporting_errors:
|
|
188
|
+
"""Turn LabHarness errors into a clear message and the right exit code."""
|
|
189
|
+
|
|
190
|
+
def __enter__(self) -> None:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
def __exit__(self, kind: object, error: BaseException | None, traceback: object) -> None:
|
|
194
|
+
if isinstance(error, MissingExtraError):
|
|
195
|
+
typer.secho(str(error), fg=typer.colors.RED)
|
|
196
|
+
raise typer.Exit(EXIT_MISSING_DEPENDENCY) from None
|
|
197
|
+
if isinstance(error, LabHarnessError):
|
|
198
|
+
typer.secho(str(error), fg=typer.colors.RED)
|
|
199
|
+
raise typer.Exit(EXIT_ERROR) from None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def main() -> None:
|
|
203
|
+
app()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__": # pragma: no cover
|
|
207
|
+
main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Core of LabHarness: module contract, manifest, workspace and errors.
|
|
2
|
+
|
|
3
|
+
Modules may only depend on the public API exported here and in ``labharness.style``.
|
|
4
|
+
That rule is what makes ``labharness eject`` possible later on.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from labharness.core.errors import LabHarnessError, MissingExtraError
|
|
8
|
+
from labharness.core.extras import require
|
|
9
|
+
from labharness.core.manifest import Figure, Workspace, find_workspace, load_workspace
|
|
10
|
+
from labharness.core.workspace import create_workspace
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Figure",
|
|
14
|
+
"LabHarnessError",
|
|
15
|
+
"MissingExtraError",
|
|
16
|
+
"Workspace",
|
|
17
|
+
"create_workspace",
|
|
18
|
+
"find_workspace",
|
|
19
|
+
"load_workspace",
|
|
20
|
+
"require",
|
|
21
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Writing outputs atomically.
|
|
2
|
+
|
|
3
|
+
Every module writes this way: the watcher recompiles as soon as a figure changes, and LaTeX
|
|
4
|
+
must never open a PDF that is still being written.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@contextmanager
|
|
14
|
+
def atomic_output(output: Path) -> Iterator[Path]:
|
|
15
|
+
"""Yield a temporary path to write to, then move it into place.
|
|
16
|
+
|
|
17
|
+
If the writer raises, the temporary file is removed and the previous output is left
|
|
18
|
+
untouched, so a broken script never destroys the figure that was working.
|
|
19
|
+
"""
|
|
20
|
+
output = Path(output)
|
|
21
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
temporary = output.with_name(f".{output.name}.tmp")
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
yield temporary
|
|
26
|
+
os.replace(temporary, output)
|
|
27
|
+
finally:
|
|
28
|
+
temporary.unlink(missing_ok=True)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Exception hierarchy shared by every part of LabHarness."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LabHarnessError(Exception):
|
|
5
|
+
"""Base class for every error raised by LabHarness."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MissingExtraError(LabHarnessError, ImportError):
|
|
9
|
+
"""A feature needs an optional extra that is not installed."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, extra: str, package: str) -> None:
|
|
12
|
+
super().__init__(
|
|
13
|
+
f"This feature needs the '{extra}' extra (missing package: {package}). "
|
|
14
|
+
f'Install it with: uv sync --extra {extra} or pip install "labharness[{extra}]"'
|
|
15
|
+
)
|
|
16
|
+
self.extra = extra
|
|
17
|
+
self.package = package
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Lazy imports of optional dependencies, with an actionable error when they are missing."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from types import ModuleType
|
|
5
|
+
|
|
6
|
+
from labharness.core.errors import MissingExtraError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def require(package: str, extra: str) -> ModuleType:
|
|
10
|
+
"""Import ``package`` or explain which extra installs it.
|
|
11
|
+
|
|
12
|
+
Modules call this inside their functions, never at import time, so that
|
|
13
|
+
``import labharness`` works with no extras installed.
|
|
14
|
+
"""
|
|
15
|
+
try:
|
|
16
|
+
return importlib.import_module(package)
|
|
17
|
+
except ImportError as exc:
|
|
18
|
+
raise MissingExtraError(extra, package) from exc
|
labharness/core/latex.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Running latexmk, and turning its output into something a person can read.
|
|
2
|
+
|
|
3
|
+
Shared by the watcher, which compiles the document, and by the diagrams module, which
|
|
4
|
+
compiles each figure on its own.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from labharness.core.errors import LabHarnessError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class LatexResult:
|
|
17
|
+
ok: bool
|
|
18
|
+
pdf: Path | None
|
|
19
|
+
errors: tuple[str, ...]
|
|
20
|
+
log: str
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def summary(self) -> str:
|
|
24
|
+
return "\n".join(self.errors) if self.errors else "LaTeX failed, see the log"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_latexmk(document: Path) -> LatexResult:
|
|
28
|
+
"""Compile ``document`` once. Incremental: latexmk only redoes what changed.
|
|
29
|
+
|
|
30
|
+
No -halt-on-error: latexmk needs its extra passes for bibliographies and
|
|
31
|
+
cross-references, and nonstopmode already stops it from waiting for input.
|
|
32
|
+
"""
|
|
33
|
+
latexmk = shutil.which("latexmk")
|
|
34
|
+
if latexmk is None:
|
|
35
|
+
raise LabHarnessError(
|
|
36
|
+
"cannot find 'latexmk'. Install a LaTeX distribution, and on Windows with MiKTeX "
|
|
37
|
+
"install Perl as well (for example Strawberry Perl)."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
[latexmk, "-pdf", "-interaction=nonstopmode", document.name],
|
|
42
|
+
cwd=document.parent,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
check=False,
|
|
46
|
+
)
|
|
47
|
+
output = result.stdout + result.stderr
|
|
48
|
+
pdf = document.with_suffix(".pdf")
|
|
49
|
+
ok = result.returncode == 0 and pdf.is_file()
|
|
50
|
+
|
|
51
|
+
errors = summarise_errors(output)
|
|
52
|
+
if not ok and not errors:
|
|
53
|
+
# latexmk summarises instead of repeating what LaTeX said, so the real message is
|
|
54
|
+
# in the log. Without this the terminal says "LaTeX failed" and nothing else.
|
|
55
|
+
errors = summarise_errors(read_log(document))
|
|
56
|
+
|
|
57
|
+
return LatexResult(ok=ok, pdf=pdf if pdf.is_file() else None, errors=errors, log=output)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_pdflatex(document: Path, passes: int = 2) -> LatexResult:
|
|
61
|
+
"""Compile with pdflatex directly, for when only a figure changed.
|
|
62
|
+
|
|
63
|
+
latexmk is the right tool when the bibliography or the cross-references may have moved,
|
|
64
|
+
but it costs about a second of its own before running anything, and then makes two or
|
|
65
|
+
three passes. Redrawing a figure needs one pass, and LaTeX itself says so when it needs
|
|
66
|
+
another, so the quick path listens for that instead of assuming.
|
|
67
|
+
"""
|
|
68
|
+
pdflatex = shutil.which("pdflatex")
|
|
69
|
+
if pdflatex is None:
|
|
70
|
+
raise LabHarnessError(
|
|
71
|
+
"cannot find 'pdflatex'. Install a LaTeX distribution (MiKTeX or TeX Live)."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
output = ""
|
|
75
|
+
for attempt in range(passes):
|
|
76
|
+
result = subprocess.run(
|
|
77
|
+
[pdflatex, "-interaction=nonstopmode", document.name],
|
|
78
|
+
cwd=document.parent,
|
|
79
|
+
capture_output=True,
|
|
80
|
+
text=True,
|
|
81
|
+
check=False,
|
|
82
|
+
)
|
|
83
|
+
output = result.stdout + result.stderr
|
|
84
|
+
if "Rerun" not in output or attempt == passes - 1:
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
pdf = document.with_suffix(".pdf")
|
|
88
|
+
ok = result.returncode == 0 and pdf.is_file()
|
|
89
|
+
errors = summarise_errors(output) or (summarise_errors(read_log(document)) if not ok else ())
|
|
90
|
+
return LatexResult(ok=ok, pdf=pdf if pdf.is_file() else None, errors=errors, log=output)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def read_log(document: Path) -> str:
|
|
94
|
+
log = document.with_suffix(".log")
|
|
95
|
+
if not log.is_file():
|
|
96
|
+
return ""
|
|
97
|
+
return log.read_text(encoding="utf-8", errors="replace")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def summarise_errors(output: str) -> tuple[str, ...]:
|
|
101
|
+
"""The lines a person needs: LaTeX errors and the line they happened on."""
|
|
102
|
+
interesting = [
|
|
103
|
+
line.rstrip()
|
|
104
|
+
for line in output.splitlines()
|
|
105
|
+
if line.startswith("!") or line.startswith("l.") or "not found" in line
|
|
106
|
+
]
|
|
107
|
+
return tuple(dict.fromkeys(interesting))
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The workspace manifest: which script builds which figure, from which data.
|
|
2
|
+
|
|
3
|
+
``labharness.toml`` is plain text the researcher can read and edit. It is also what lets the
|
|
4
|
+
watcher rebuild only what changed, and what a future graphical interface will read.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from labharness.core.errors import LabHarnessError
|
|
12
|
+
|
|
13
|
+
MANIFEST_NAME = "labharness.toml"
|
|
14
|
+
DOCUMENT_NAME = "paper.tex"
|
|
15
|
+
STYLE_NAME = "labharness-style.tex"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Figure:
|
|
20
|
+
"""One generated figure, with the script that builds it and the files it depends on."""
|
|
21
|
+
|
|
22
|
+
output: Path
|
|
23
|
+
script: Path
|
|
24
|
+
inputs: tuple[Path, ...]
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def name(self) -> str:
|
|
28
|
+
return self.output.stem
|
|
29
|
+
|
|
30
|
+
def depends_on(self, path: Path) -> bool:
|
|
31
|
+
return path == self.script or path in self.inputs
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Workspace:
|
|
36
|
+
"""A folder with a manifest in it."""
|
|
37
|
+
|
|
38
|
+
root: Path
|
|
39
|
+
journal: str
|
|
40
|
+
figures: tuple[Figure, ...]
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def document(self) -> Path:
|
|
44
|
+
return self.root / DOCUMENT_NAME
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def watched_paths(self) -> list[Path]:
|
|
48
|
+
return [self.root / name for name in ("data", "scripts", DOCUMENT_NAME, MANIFEST_NAME)]
|
|
49
|
+
|
|
50
|
+
def figures_affected_by(self, path: Path) -> list[Figure]:
|
|
51
|
+
"""Figures to rebuild after ``path`` changed.
|
|
52
|
+
|
|
53
|
+
A change to the manifest or the style rebuilds everything, because either can change
|
|
54
|
+
how every figure looks.
|
|
55
|
+
"""
|
|
56
|
+
relative = _relative_to(path, self.root)
|
|
57
|
+
if relative is None:
|
|
58
|
+
return []
|
|
59
|
+
if relative.name in (MANIFEST_NAME, STYLE_NAME):
|
|
60
|
+
return list(self.figures)
|
|
61
|
+
return [figure for figure in self.figures if figure.depends_on(relative)]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def find_workspace(start: Path | None = None) -> Path:
|
|
65
|
+
"""Walk up from ``start`` looking for a manifest, the way git looks for ``.git``."""
|
|
66
|
+
current = (start or Path.cwd()).resolve()
|
|
67
|
+
for folder in [current, *current.parents]:
|
|
68
|
+
if (folder / MANIFEST_NAME).is_file():
|
|
69
|
+
return folder
|
|
70
|
+
raise LabHarnessError(
|
|
71
|
+
f"no '{MANIFEST_NAME}' found here or in any parent folder. "
|
|
72
|
+
"Run this inside a workspace, or create one with: labharness init"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_workspace(root: Path | None = None) -> Workspace:
|
|
77
|
+
"""Read the manifest of a workspace."""
|
|
78
|
+
root = find_workspace(root) if root is None or not (root / MANIFEST_NAME).is_file() else root
|
|
79
|
+
manifest = root / MANIFEST_NAME
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
data = tomllib.loads(manifest.read_text(encoding="utf-8"))
|
|
83
|
+
except tomllib.TOMLDecodeError as error:
|
|
84
|
+
raise LabHarnessError(f"{manifest} is not valid TOML: {error}") from error
|
|
85
|
+
|
|
86
|
+
figures = tuple(_figure(entry, manifest) for entry in data.get("figure", []))
|
|
87
|
+
_reject_duplicates(figures, manifest)
|
|
88
|
+
return Workspace(root=root, journal=str(data.get("journal", "acs")), figures=figures)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _figure(entry: dict[str, object], manifest: Path) -> Figure:
|
|
92
|
+
for key in ("output", "script"):
|
|
93
|
+
if key not in entry:
|
|
94
|
+
raise LabHarnessError(f"{manifest}: a [[figure]] entry has no '{key}'")
|
|
95
|
+
|
|
96
|
+
inputs = entry.get("inputs", [])
|
|
97
|
+
if not isinstance(inputs, list):
|
|
98
|
+
raise LabHarnessError(f"{manifest}: 'inputs' must be a list of paths")
|
|
99
|
+
|
|
100
|
+
return Figure(
|
|
101
|
+
output=Path(str(entry["output"])),
|
|
102
|
+
script=Path(str(entry["script"])),
|
|
103
|
+
inputs=tuple(Path(str(item)) for item in inputs),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _reject_duplicates(figures: tuple[Figure, ...], manifest: Path) -> None:
|
|
108
|
+
seen: set[Path] = set()
|
|
109
|
+
for figure in figures:
|
|
110
|
+
if figure.output in seen:
|
|
111
|
+
raise LabHarnessError(f"{manifest}: two [[figure]] entries write to '{figure.output}'")
|
|
112
|
+
seen.add(figure.output)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _relative_to(path: Path, root: Path) -> Path | None:
|
|
116
|
+
try:
|
|
117
|
+
return path.resolve().relative_to(root.resolve())
|
|
118
|
+
except ValueError:
|
|
119
|
+
return None
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Locating the workspace template, which ships inside the installed package."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from labharness.core.errors import LabHarnessError
|
|
6
|
+
|
|
7
|
+
WORKSPACE = "workspace"
|
|
8
|
+
JOURNALS = "journals"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def template_root() -> Path:
|
|
12
|
+
"""The folder holding the workspace template.
|
|
13
|
+
|
|
14
|
+
Installed, it lives inside the package. Running from a source checkout it is the
|
|
15
|
+
``templates/`` folder of the repository.
|
|
16
|
+
"""
|
|
17
|
+
packaged = Path(__file__).resolve().parents[1] / "templates" / WORKSPACE
|
|
18
|
+
if packaged.is_dir():
|
|
19
|
+
return packaged
|
|
20
|
+
|
|
21
|
+
from_source = Path(__file__).resolve().parents[3] / "templates" / WORKSPACE
|
|
22
|
+
if from_source.is_dir():
|
|
23
|
+
return from_source
|
|
24
|
+
|
|
25
|
+
raise LabHarnessError("the workspace template is missing from this installation")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def journal_template(journal: str) -> Path:
|
|
29
|
+
folder = template_root() / JOURNALS / journal
|
|
30
|
+
if not folder.is_dir():
|
|
31
|
+
available = sorted(item.name for item in (template_root() / JOURNALS).iterdir())
|
|
32
|
+
raise LabHarnessError(f"unknown journal '{journal}'. Available: {', '.join(available)}")
|
|
33
|
+
return folder
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Creating a workspace from the template."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from labharness.core.errors import LabHarnessError
|
|
7
|
+
from labharness.core.manifest import MANIFEST_NAME
|
|
8
|
+
from labharness.core.templates import JOURNALS, journal_template, template_root
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_workspace(target: Path, journal: str = "acs", force: bool = False) -> Path:
|
|
12
|
+
"""Copy the template into ``target`` and drop in the chosen journal files."""
|
|
13
|
+
target = target.expanduser().resolve()
|
|
14
|
+
if target.exists() and any(target.iterdir()) and not force:
|
|
15
|
+
raise LabHarnessError(f"'{target}' is not empty. Use --force to write into it anyway.")
|
|
16
|
+
|
|
17
|
+
# The journals folder is not part of a workspace: only the chosen journal is copied,
|
|
18
|
+
# flattened into the root.
|
|
19
|
+
shutil.copytree(
|
|
20
|
+
template_root(),
|
|
21
|
+
target,
|
|
22
|
+
dirs_exist_ok=True,
|
|
23
|
+
ignore=shutil.ignore_patterns(JOURNALS),
|
|
24
|
+
)
|
|
25
|
+
for source in journal_template(journal).iterdir():
|
|
26
|
+
shutil.copy(source, target / source.name)
|
|
27
|
+
|
|
28
|
+
if not (target / MANIFEST_NAME).is_file(): # pragma: no cover - template would be broken
|
|
29
|
+
raise LabHarnessError(f"the template produced no {MANIFEST_NAME}")
|
|
30
|
+
return target
|