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.
Files changed (55) hide show
  1. labharness/__init__.py +19 -0
  2. labharness/cli/__init__.py +8 -0
  3. labharness/cli/app.py +207 -0
  4. labharness/core/__init__.py +21 -0
  5. labharness/core/atomic.py +28 -0
  6. labharness/core/errors.py +17 -0
  7. labharness/core/extras.py +18 -0
  8. labharness/core/latex.py +107 -0
  9. labharness/core/manifest.py +119 -0
  10. labharness/core/templates.py +33 -0
  11. labharness/core/workspace.py +30 -0
  12. labharness/doctor.py +145 -0
  13. labharness/modules/__init__.py +13 -0
  14. labharness/modules/chem/__init__.py +13 -0
  15. labharness/modules/chem/resolve.py +53 -0
  16. labharness/modules/chem/structures.py +103 -0
  17. labharness/modules/diagrams/__init__.py +15 -0
  18. labharness/modules/diagrams/tikz.py +69 -0
  19. labharness/modules/plots/__init__.py +32 -0
  20. labharness/modules/plots/data.py +201 -0
  21. labharness/modules/plots/figure.py +179 -0
  22. labharness/modules/plots/fits.py +244 -0
  23. labharness/py.typed +0 -0
  24. labharness/style/__init__.py +33 -0
  25. labharness/style/fonts.py +33 -0
  26. labharness/style/journals/acs.toml +50 -0
  27. labharness/style/latex.py +93 -0
  28. labharness/style/mpl.py +66 -0
  29. labharness/style/rdkit.py +27 -0
  30. labharness/style/tokens.py +94 -0
  31. labharness/templates/figures/flow.tex +11 -0
  32. labharness/templates/figures/mechanism.tex +17 -0
  33. labharness/templates/figures/network.tex +13 -0
  34. labharness/templates/workspace/.gitignore +11 -0
  35. labharness/templates/workspace/AGENTS.md +30 -0
  36. labharness/templates/workspace/CLAUDE.md +1 -0
  37. labharness/templates/workspace/compile.ps1 +3 -0
  38. labharness/templates/workspace/compile.sh +4 -0
  39. labharness/templates/workspace/data/.gitkeep +0 -0
  40. labharness/templates/workspace/figures/.gitkeep +0 -0
  41. labharness/templates/workspace/journals/acs/labharness-style.tex +34 -0
  42. labharness/templates/workspace/journals/acs/paper.tex +54 -0
  43. labharness/templates/workspace/journals/acs/references.bib +1 -0
  44. labharness/templates/workspace/labharness.toml +8 -0
  45. labharness/templates/workspace/scripts/.gitkeep +0 -0
  46. labharness/watch/__init__.py +26 -0
  47. labharness/watch/latex.py +16 -0
  48. labharness/watch/runner.py +139 -0
  49. labharness/watch/session.py +88 -0
  50. labharness/watch/viewer.py +55 -0
  51. labharness-0.1.0.dist-info/METADATA +269 -0
  52. labharness-0.1.0.dist-info/RECORD +55 -0
  53. labharness-0.1.0.dist-info/WHEEL +4 -0
  54. labharness-0.1.0.dist-info/entry_points.txt +7 -0
  55. 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__"]
@@ -0,0 +1,8 @@
1
+ """Command-line interface: a thin layer over the core, the watcher and the modules.
2
+
3
+ No figure logic lives here, so the same functions can be driven from anywhere else.
4
+ """
5
+
6
+ from labharness.cli.app import app, main
7
+
8
+ __all__ = ["app", "main"]
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
@@ -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