Skip to content

Commit 1768bbb

Browse files
Merge pull request #44 from openforcefield/skip-failing-notebooks
Skip failing notebooks
2 parents bece688 + 3af7121 commit 1768bbb

File tree

5 files changed

+200
-26
lines changed

5 files changed

+200
-26
lines changed

.github/workflows/cookbook_preproc.yaml

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,13 @@ jobs:
9393
- name: Pre-process and execute notebooks
9494
shell: bash -l {0}
9595
run: |
96-
python source/_ext/proc_examples.py --prefix=deploy/ --cache-branch=${DEPLOY_BRANCH}
96+
set -e
97+
python source/_ext/proc_examples.py --prefix=deploy/ --cache-branch=${DEPLOY_BRANCH} --log-failures=notebooks_log.json
98+
99+
- name: Read notebooks log
100+
if: always()
101+
shell: bash -l {0}
102+
run: echo "NOTEBOOKS_LOG=$(cat 'notebooks_log.json')" >> "$GITHUB_ENV"
97103

98104
- name: Deploy cache
99105
shell: bash -l {0}
@@ -121,6 +127,7 @@ jobs:
121127
curl -X POST -d "branches=$GITHUB_REF_NAME" -d "token=${{ secrets.RTD_WEBHOOK_TOKEN }}" https://readthedocs.org/api/v2/webhook/openff-docs/243876/
122128
123129
- name: Report status to PR
130+
id: reportStatusToPr
124131
if: always() && github.event_name == 'workflow_dispatch' && inputs.pr_number != ''
125132
uses: thollander/actions-comment-pull-request@v2
126133
with:
@@ -137,10 +144,49 @@ jobs:
137144
138145
- Deployment branch: ${{ env.DEPLOY_BRANCH }}
139146
140-
- Status: **${{ job.status }}**
147+
- Job status: **${{ job.status }}**
148+
149+
- Notebooks status: ${{fromJSON(env.NOTEBOOKS_LOG).n_successful}} / ${{fromJSON(env.NOTEBOOKS_LOG).n_total}} notebooks successfully executed (${{fromJSON(env.NOTEBOOKS_LOG).n_ignored}} failures ignored)
150+
151+
${{(fromJSON(env.NOTEBOOKS_LOG).failed || fromJSON(env.NOTEBOOKS_LOG).ignored) && '- Failing notebooks:
152+
- ' || ''}}${{join(fromJSON(env.NOTEBOOKS_LOG).failed, '
153+
- ')}}${{fromJSON(env.NOTEBOOKS_LOG).ignored && '
154+
- [ignored] ' || ''}}${{join(fromJSON(env.NOTEBOOKS_LOG).ignored, '
155+
- [ignored] ')}}
156+
157+
158+
Changes will only be visible in the ReadTheDocs
159+
preview after it has been [rebuilt].
160+
161+
162+
[${{ github.run_id }}]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
163+
164+
[rebuilt]: https://readthedocs.org/projects/openff-docs/builds/
165+
166+
167+
- name: Report status to PR on templating failure
168+
if: always() && steps.reportStatusToPr.outcome == 'failure'
169+
uses: thollander/actions-comment-pull-request@v2
170+
with:
171+
pr_number: ${{ inputs.pr_number }}
172+
message: >
173+
A workflow dispatched to regenerate the cookbook cache for this PR has just finished.
174+
175+
176+
- Run ID: [${{ github.run_id }}]
177+
178+
- Triggering actor: ${{ github.triggering_actor }}
179+
180+
- Target branch: ${{ github.ref_name }}
181+
182+
- Deployment branch: ${{ env.DEPLOY_BRANCH }}
183+
184+
- Job status: **${{ job.status }}**
185+
186+
- Notebooks status: N/A
141187
142188
143-
If the workflow was successful, changes will only be visible in the ReadTheDocs
189+
Changes will only be visible in the ReadTheDocs
144190
preview after it has been [rebuilt].
145191
146192

devtools/conda-envs/examples_env.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ dependencies:
1313
- requests
1414
- packaging
1515
# Examples
16-
- openff-toolkit-examples>=0.15.2
17-
- openff-interchange >=0.3.25
16+
- openff-toolkit-examples>=0.16.0
17+
- openff-interchange>=0.3.26
1818
- openff-nagl
1919
# - openff-fragmenter
2020
# - openff-qcsubmit

source/_ext/cookbook/globals_.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,17 +81,30 @@
8181
DEFAULT_CACHE_BRANCH = "_cookbook_data_main"
8282
"""Branch of the openff-docs repository where cached notebooks are stored."""
8383

84-
SKIP_NOTEBOOKS: set[str] = {
85-
"openforcefield/openff-interchange/experimental/openmmforcefields/gaff.ipynb",
86-
}
84+
SKIP_NOTEBOOKS: set[str] = {}
8785
"""
8886
Notebooks that will not be processed.
8987
90-
This is intended to be used as a way of temporarily disabling broken notebooks
91-
without taking down an entire source repository or the examples page itself.
92-
9388
Specified as a path relative to a notebook search path, eg ``SRC_IPYNB_ROOT``.
9489
This is something like ``{repo_owner}/{repo_name}/{path_from_examples_dir}``.
9590
These notebooks are skipped at the proc_examples stage, but they will still
9691
be rendered if they're in a cache.
9792
"""
93+
94+
OPTIONAL_NOTEBOOKS: list[str] = [
95+
# ".*/experimental/.*",
96+
"openforcefield/openff-interchange/experimental/openmmforcefields/gaff.ipynb",
97+
]
98+
"""
99+
Notebooks whose execution failure will not cause notebook processing to fail.
100+
101+
This is intended to be used as a way of temporarily disabling broken notebooks
102+
without taking down an entire source repository or the examples page itself.
103+
They will be executed and included in the examples page if successful, but if
104+
they fail they will be removed.
105+
106+
Specified as a regex matching a path relative to a notebook search path, eg
107+
``SRC_IPYNB_ROOT``. This is something like ``{repo_owner}/{repo_name}/
108+
{path_from_examples_dir}``. These notebooks are skipped at the proc_examples
109+
stage, but they will still be rendered if they're in a cache.
110+
"""

source/_ext/cookbook/utils.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
1+
from functools import partial
12
from types import FunctionType
2-
from typing import Callable, Iterable, Iterator, Optional, TypeVar, Generator
3+
from typing import (
4+
Callable,
5+
Generic,
6+
Iterable,
7+
Iterator,
8+
Optional,
9+
TypeVar,
10+
Generator,
11+
ParamSpec,
12+
Union,
13+
)
314
import contextlib
415
import os
16+
import re
517

618
T = TypeVar("T")
19+
E = TypeVar("E")
20+
P = ParamSpec("P")
721

822

923
def flatten(iterable: Iterable[Iterable[T]]) -> Generator[T, None, None]:
@@ -20,14 +34,20 @@ def next_or_none(iterator: Iterator[T]) -> Optional[T]:
2034
return ret
2135

2236

23-
def result(fn, exception=Exception):
24-
def ret(*args, **kwargs):
25-
try:
26-
return fn(*args, **kwargs)
27-
except exception as e:
28-
return e
37+
def result(
38+
fn: Callable[P, T], exception: type[E], *args: P.args, **kwargs: P.kwargs
39+
) -> Union[T, E]:
40+
try:
41+
return fn(*args, **kwargs)
42+
except exception as e:
43+
return e
2944

30-
return ret
45+
46+
def to_result(
47+
fn: Callable[P, T], exception: type[E] = Exception
48+
) -> Callable[P, Union[T, E]]:
49+
# partial's type stub is not precise enough to work here
50+
return partial(result, fn, exception) # type: ignore [reportReturnType]
3151

3252

3353
@contextlib.contextmanager
@@ -54,3 +74,10 @@ def set_env(**environ):
5474
finally:
5575
os.environ.clear()
5676
os.environ.update(old_environ)
77+
78+
79+
def in_regexes(s: str, regexes: Iterable[str]) -> bool:
80+
for regex in regexes:
81+
if re.match(regex, s):
82+
return True
83+
return False

source/_ext/proc_examples.py

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Script to execute and pre-process example notebooks"""
22

3+
import re
34
from typing import Tuple, List, Final
45
from zipfile import ZIP_DEFLATED, ZipFile
56
from pathlib import Path
@@ -10,6 +11,7 @@
1011
import sys
1112
import tarfile
1213
from functools import partial
14+
import traceback
1315

1416
import nbformat
1517
from nbconvert.preprocessors.execute import ExecutePreprocessor
@@ -30,7 +32,14 @@
3032
)
3133
from cookbook.github import download_dir, get_tag_matching_installed_version
3234
from cookbook.globals_ import *
33-
from cookbook.utils import set_env
35+
from cookbook.utils import set_env, to_result, in_regexes
36+
37+
38+
class NotebookExceptionError(ValueError):
39+
def __init__(self, src: str, exc: Exception):
40+
self.src: str = str(src)
41+
self.exc: Exception = exc
42+
self.tb: str = "".join(traceback.format_exception(exc, chain=False))
3443

3544

3645
def needed_files(notebook_path: Path) -> List[Tuple[Path, Path]]:
@@ -208,7 +217,8 @@ def execute_notebook(
208217
try:
209218
executor.preprocess(nb, {"metadata": {"path": src.parent}})
210219
except Exception as e:
211-
raise ValueError(f"Exception encountered while executing {src_rel}")
220+
print("Failed to execute", src.relative_to(SRC_IPYNB_ROOT))
221+
raise NotebookExceptionError(str(src_rel), e)
212222

213223
# Store the tag used to execute the notebook in metadata
214224
set_metadata(nb, "src_repo_tag", tag)
@@ -230,7 +240,7 @@ def execute_notebook(
230240
EXEC_IPYNB_ROOT / thumbnail_path.relative_to(SRC_IPYNB_ROOT),
231241
)
232242

233-
print("Done executing", src.relative_to(SRC_IPYNB_ROOT))
243+
print("Successfully executed", src.relative_to(SRC_IPYNB_ROOT))
234244

235245

236246
def delay_iterator(iterator, seconds=1.0):
@@ -260,6 +270,8 @@ def main(
260270
do_exec=True,
261271
prefix: Path | None = None,
262272
processes: int | None = None,
273+
failed_notebooks_log: Path | None = None,
274+
allow_failures: bool = False,
263275
):
264276
print("Working in", Path().resolve())
265277

@@ -287,7 +299,6 @@ def main(
287299
for notebook in find_notebooks(dst_path)
288300
if str(notebook.relative_to(SRC_IPYNB_ROOT)) not in SKIP_NOTEBOOKS
289301
)
290-
print(notebooks, SKIP_NOTEBOOKS)
291302

292303
# Create Colab and downloadable versions of the notebooks
293304
if do_proc:
@@ -299,20 +310,63 @@ def main(
299310
create_download(notebook)
300311

301312
# Execute notebooks in parallel for rendering as HTML
313+
execution_failed = False
302314
if do_exec:
303315
shutil.rmtree(EXEC_IPYNB_ROOT, ignore_errors=True)
304316
# Context manager ensures the pool is correctly terminated if there's
305317
# an exception
306318
with Pool(processes=processes) as pool:
307319
# Wait a second between launching subprocesses
308320
# Workaround https://github.com/jupyter/nbconvert/issues/1066
309-
_ = [
310-
*pool.imap_unordered(
311-
partial(execute_notebook, cache_branch=cache_branch),
321+
exec_results = [
322+
*pool.imap(
323+
to_result(
324+
partial(execute_notebook, cache_branch=cache_branch),
325+
NotebookExceptionError,
326+
),
312327
delay_iterator(notebooks),
313328
)
314329
]
315330

331+
exceptions: list[NotebookExceptionError] = [
332+
result for result in exec_results if isinstance(result, Exception)
333+
]
334+
ignored_exceptions = [
335+
exc for exc in exceptions if in_regexes(exc.src, OPTIONAL_NOTEBOOKS)
336+
]
337+
338+
if exceptions:
339+
for exception in exceptions:
340+
print(
341+
"-" * 80
342+
+ "\n"
343+
+ f"{exception.src} failed. Traceback:\n\n{exception.tb}"
344+
)
345+
if not in_regexes(exception.src, OPTIONAL_NOTEBOOKS):
346+
execution_failed = True
347+
print(f"The following {len(exceptions)}/{len(notebooks)} notebooks failed:")
348+
for exception in exceptions:
349+
print(" ", exception.src)
350+
print("For tracebacks, see above.")
351+
352+
if failed_notebooks_log is not None:
353+
print(f"Writing log to {failed_notebooks_log.absolute()}")
354+
failed_notebooks_log.write_text(
355+
json.dumps(
356+
{
357+
"n_successful": len(notebooks) - len(exceptions),
358+
"n_total": len(notebooks),
359+
"n_ignored": len(ignored_exceptions),
360+
"failed": [
361+
exc.src
362+
for exc in exceptions
363+
if exc not in ignored_exceptions
364+
],
365+
"ignored": [exc.src for exc in ignored_exceptions],
366+
}
367+
)
368+
)
369+
316370
if isinstance(prefix, Path):
317371
prefix.mkdir(parents=True, exist_ok=True)
318372

@@ -327,6 +381,9 @@ def main(
327381
prefix / directory.relative_to(OPENFF_DOCS_ROOT),
328382
)
329383

384+
if execution_failed:
385+
exit(1)
386+
330387

331388
if __name__ == "__main__":
332389
import sys, os
@@ -365,10 +422,41 @@ def main(
365422
"Specify cache branch in a single argument: `--cache-branch=<branch>`"
366423
)
367424

425+
# --log-failures is the path to store a list of failing notebooks in
426+
failed_notebooks_log = None
427+
for arg in sys.argv:
428+
if arg.startswith("--log-failures="):
429+
failed_notebooks_log = Path(arg[15:])
430+
if "--log-failures" in sys.argv:
431+
raise ValueError(
432+
"Specify path to log file in a single argument: `--log-failures=<path>`"
433+
)
434+
435+
# if --allow-failures is True, do not exit with error code 1 if a
436+
# notebook fails
437+
allow_failures = "false"
438+
for arg in sys.argv:
439+
if arg.startswith("--allow-failures="):
440+
allow_failures = arg[17:].lower()
441+
if allow_failures in ["true", "1", "y", "yes", "t"]:
442+
allow_failures = True
443+
elif allow_failures in ["false", "0", "n", "no", "false"]:
444+
allow_failures = False
445+
else:
446+
raise ValueError(
447+
f"Didn't understand value of --allow-failures {allow_failures}; try `true` or `false`"
448+
)
449+
if "--log-failures" in sys.argv:
450+
raise ValueError(
451+
"Specify value in a single argument: `--allow-failures=true` or `--allow-failures=false`"
452+
)
453+
368454
main(
369455
cache_branch=cache_branch,
370456
do_proc=not "--skip-proc" in sys.argv,
371457
do_exec=not "--skip-exec" in sys.argv,
372458
prefix=prefix,
373459
processes=processes,
460+
failed_notebooks_log=failed_notebooks_log,
461+
allow_failures=allow_failures,
374462
)

0 commit comments

Comments
 (0)