Skip to content

Commit 08ffd86

Browse files
committed
Merge remote-tracking branch 'upstream/main'
2 parents 4bdceb0 + ab86527 commit 08ffd86

13 files changed

Lines changed: 7043 additions & 593 deletions

.github/workflows/ci.yaml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: CI
2+
3+
concurrency:
4+
group: ${{ github.ref }}
5+
cancel-in-progress: true
6+
7+
on:
8+
push: { branches: [ "main" ] }
9+
pull_request: { branches: [ "main" ] }
10+
11+
jobs:
12+
test:
13+
14+
runs-on: ubuntu-latest
15+
container: condaforge/mambaforge:latest
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Run CI
21+
# Ensure the step runs under bash so `pipefail` is supported in the shell flags
22+
# The `{0}` placeholder is replaced by the step commands by GitHub Actions.
23+
shell: bash -eo pipefail {0}
24+
run: |
25+
# Fail fast: any command that exits non-zero will stop the job
26+
set -euo pipefail
27+
IFS=$'\n\t'
28+
29+
apt update && apt install -y git make
30+
31+
make env-dev
32+
make create-student-nb
33+
make run-nb-and-convert-to-md
34+
35+
- name: Commit and push notebook.md files
36+
run: |
37+
git config --global --add safe.directory "$GITHUB_WORKSPACE"
38+
git config --global user.name "github-actions[bot]"
39+
git config --global user.email "github-actions[bot]@users.noreply.github.com"
40+
git add notebooks-rendered/
41+
git add notebooks/
42+
git commit -m "Add generated notebook.md and answerless notebook files [skip ci]" || echo "No changes to commit"
43+
git push
44+
env:
45+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Makefile

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ ENV_NAME := openff-env
22

33
CONDA_ENV_RUN = conda run --no-capture-output --name $(ENV_NAME)
44

5-
.PHONY: env env-dev clean-nb format-nb run-nb
5+
.PHONY: env env-dev clean-nb format-nb run-nb create-student-nb
66

77
env:
88
mamba create --name $(ENV_NAME)
@@ -21,8 +21,14 @@ clean-nb:
2121
format-nb:
2222
$(CONDA_ENV_RUN) find . -name "*.ipynb" -exec nbqa ruff --fix {} --ignore E402 \;
2323

24+
create-student-nb:
25+
$(CONDA_ENV_RUN) python devtools/scripts/remove_notebook_solutions.py notebooks_with_solutions/small_molecule_parameterisation.ipynb notebooks/small_molecule_parameterisation.ipynb
26+
$(CONDA_ENV_RUN) python devtools/scripts/remove_notebook_solutions.py notebooks_with_solutions/protein_ligand_complex_parameterisation_and_md.ipynb notebooks/protein_ligand_complex_parameterisation_and_md.ipynb
27+
2428
run-nb:
25-
$(CONDA_ENV_RUN) find . -name "*.ipynb" -exec jupyter nbconvert --to notebook --execute --inplace {} \;
29+
$(CONDA_ENV_RUN) find notebooks_with_solutions -name "*.ipynb" -exec jupyter nbconvert --to notebook --execute --inplace {} \;
2630

2731
run-nb-and-convert-to-md:
28-
$(CONDA_ENV_RUN) find . -name "*.ipynb" -exec jupyter nbconvert --to markdown --execute --output-dir notebooks-rendered {} \;
32+
$(CONDA_ENV_RUN) python devtools/scripts/execute_and_convert_notebooks.py \
33+
--input-dir notebooks_with_solutions --output-dir notebooks-rendered \
34+
--skip-tag ci_skip

README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,26 @@
22

33
[![CI](https://github.com/openforcefield/ccpbiosim-2025/actions/workflows/ci.yaml/badge.svg)](https://github.com/openforcefield/ccpbiosim-2025/actions/workflows/ci.yaml)
44

5+
These tutorials were delivered at the 2025 CCPBioSim training week, but are suitable for self-guided learning.
6+
57
Presenters:
68

79
* Danny Cole
810
* Finlay Clark
911

10-
## Agenda
12+
## Materials
1113

12-
### Half session - Wednesday Morning (11.30 AM - 1 PM)
14+
We recommend you view the materials in the following order:
1315

14-
* Talk - Intro to OpenFF
16+
* Talk: [Intro to OpenFF](talk-cole-openFFintro.pdf)
1517
* Notebook: [Parameterising small molecules with OpenFF](notebooks/small_molecule_parameterisation.ipynb)
16-
17-
### Half session - Wednesday Afternoon (2.00 PM - 3.00 PM)
18-
1918
* Notebook: [Parameterisation, molecular dynamics, and basic trajectory analysis for a protein-ligand complex](notebooks/protein_ligand_complex_parameterisation_and_md.ipynb)
2019

20+
Answers to most exercises are given in the [notebooks_with_solutions directory](notebooks_with_solutions).
21+
2122
## Local installation
2223

23-
If there are any issues with the provided cloud-hosted JupyterHub instance, or to use these notebooks outside of the workshop hours, use a Python distribution (we recommend [Mambaforge](https://docs.openforcefield.org/en/latest/install.html#quick-install-guide)) and create an environment from the provided YAML file:
24+
To use these notebooks on your local machine, we recommend using [mamba](https://docs.openforcefield.org/en/latest/install.html#quick-install-guide) to create an environment from the provided YAML file:
2425

2526
```shell
2627
$ mamba env create --file environment.yaml
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env python3
2+
"""Execute and convert notebooks while skipping cells that have a given tag.
3+
4+
This script:
5+
- finds .ipynb files under --input-dir
6+
- loads each notebook, removes cells that have the skip tag
7+
- executes the notebook with nbconvert ExecutePreprocessor
8+
- exports the executed notebook to a markdown file under --output-dir
9+
10+
Use this in CI to skip interactive or long-running cells by adding a tag
11+
to those cells, e.g. tags: ["ci_skip"]
12+
13+
Example:
14+
python devtools/scripts/execute_and_convert_notebooks.py \
15+
--input-dir notebooks_with_solutions --output-dir notebooks-rendered \
16+
--skip-tag ci_skip --timeout 600
17+
"""
18+
import argparse
19+
import nbformat
20+
from nbformat import NotebookNode
21+
import sys
22+
from pathlib import Path
23+
from typing import List, Optional
24+
from nbconvert.preprocessors import ExecutePreprocessor
25+
from nbconvert.exporters import MarkdownExporter
26+
from copy import deepcopy
27+
28+
29+
def notebook_files(input_dir: str) -> List[Path]:
30+
p = Path(input_dir)
31+
return sorted(p.rglob("*.ipynb"))
32+
33+
34+
def remove_tagged_cells(nb: NotebookNode, tag: Optional[str]) -> List[NotebookNode]:
35+
if not tag:
36+
return list(nb.cells)
37+
return [
38+
cell for cell in nb.cells if tag not in cell.get("metadata", {}).get("tags", [])
39+
]
40+
41+
42+
def has_skip_tag(cell: NotebookNode, tag: Optional[str]) -> bool:
43+
if not tag:
44+
return False
45+
return tag in cell.get("metadata", {}).get("tags", [])
46+
47+
48+
def execute_notebook(
49+
nb: NotebookNode,
50+
timeout: int,
51+
kernel_name: str,
52+
cwd: Optional[str] = None,
53+
skip_tag: Optional[str] = None,
54+
) -> NotebookNode:
55+
"""Execute the notebook but skip execution of cells that have skip_tag.
56+
57+
Implementation: run a deep copy of the notebook where skipped code cells
58+
are replaced with a noop (`pass`) so the ExecutePreprocessor executes but
59+
does nothing for those cells. After execution, copy outputs and
60+
execution_count back to the original notebook so the original cell
61+
sources are preserved for conversion.
62+
"""
63+
exec_nb = deepcopy(nb)
64+
# replace skipped code cells with a harmless noop so they won't run
65+
for cell in exec_nb.cells:
66+
if cell.get("cell_type") == "code" and has_skip_tag(cell, skip_tag):
67+
cell.source = "pass\n"
68+
# clear any existing outputs
69+
cell.outputs = []
70+
cell.execution_count = None
71+
72+
ep = ExecutePreprocessor(timeout=timeout, kernel_name=kernel_name)
73+
ep.preprocess(exec_nb, {"metadata": {"path": cwd or "."}})
74+
75+
# copy outputs back to original notebook cells
76+
for orig_cell, run_cell in zip(nb.cells, exec_nb.cells):
77+
if orig_cell.get("cell_type") == "code":
78+
orig_cell["outputs"] = run_cell.get("outputs", [])
79+
orig_cell["execution_count"] = run_cell.get("execution_count", None)
80+
81+
return nb
82+
83+
84+
def convert_to_markdown(nb, out_path: Path):
85+
exporter = MarkdownExporter()
86+
body, resources = exporter.from_notebook_node(nb)
87+
out_path.parent.mkdir(parents=True, exist_ok=True)
88+
out_path.write_text(body, encoding="utf8")
89+
90+
91+
def main(argv=None):
92+
p = argparse.ArgumentParser()
93+
p.add_argument("--input-dir", required=True)
94+
p.add_argument("--output-dir", required=True)
95+
p.add_argument(
96+
"--skip-tag", default="ci_skip", help="Cell tag to remove before execution"
97+
)
98+
p.add_argument(
99+
"--timeout",
100+
type=int,
101+
default=600,
102+
help="ExecutePreprocessor timeout in seconds",
103+
)
104+
p.add_argument(
105+
"--kernel", default="python3", help="Kernel name to use for execution"
106+
)
107+
args = p.parse_args(argv)
108+
109+
input_dir = Path(args.input_dir)
110+
output_dir = Path(args.output_dir)
111+
if not input_dir.exists():
112+
print(f"Input directory not found: {input_dir}", file=sys.stderr)
113+
return 2
114+
115+
files = notebook_files(input_dir)
116+
if not files:
117+
print(f"No notebooks found under {input_dir}")
118+
return 0
119+
120+
exit_code = 0
121+
for nb_path in files:
122+
rel = nb_path.relative_to(input_dir)
123+
out_md = output_dir / rel.with_suffix(".md")
124+
print(f"Processing {nb_path} -> {out_md}")
125+
try:
126+
nb = nbformat.read(str(nb_path), as_version=4)
127+
# execute in the notebook's parent directory to keep relative paths working
128+
cwd = str(nb_path.parent)
129+
nb = execute_notebook(
130+
nb,
131+
timeout=args.timeout,
132+
kernel_name=args.kernel,
133+
cwd=cwd,
134+
skip_tag=args.skip_tag,
135+
)
136+
convert_to_markdown(nb, out_md)
137+
except Exception as e:
138+
print(f"ERROR processing {nb_path}: {e}", file=sys.stderr)
139+
exit_code = 1
140+
141+
return exit_code
142+
143+
144+
if __name__ == "__main__":
145+
raise SystemExit(main())
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""
2+
Generate a student version of Jupyter notebooks by replacing
3+
cells tagged as 'solution' with empty code cells containing a placeholder.
4+
5+
Usage:
6+
python devtools/make_student.py input_notebook.ipynb output_notebook.ipynb
7+
"""
8+
9+
from __future__ import annotations
10+
import sys
11+
from pathlib import Path
12+
import nbformat
13+
from nbformat.notebooknode import NotebookNode
14+
15+
16+
def make_student_version(
17+
input_path: str | Path,
18+
output_path: str | Path,
19+
tag_to_replace: str = "solution",
20+
) -> None:
21+
"""
22+
Create a student version of a Jupyter notebook by replacing all cells
23+
tagged with `tag_to_replace` by placeholder code cells.
24+
25+
Parameters
26+
----------
27+
input_path : str | Path
28+
Path to the input notebook (typically the solutions version).
29+
output_path : str | Path
30+
Path where the cleaned student notebook should be written.
31+
tag_to_replace : str, optional
32+
Tag identifying cells that should be replaced. Default is 'solution'.
33+
"""
34+
input_path = Path(input_path)
35+
output_path = Path(output_path)
36+
37+
nb: NotebookNode = nbformat.read(input_path, as_version=4)
38+
new_cells: list[NotebookNode] = []
39+
40+
for cell in nb.cells:
41+
tags: list[str] = cell.get("metadata", {}).get("tags", [])
42+
43+
if tag_to_replace in tags:
44+
# Replace tagged cell with placeholder
45+
placeholder = nbformat.v4.new_code_cell(
46+
source="# your solution here",
47+
metadata={"tags": ["placeholder"]},
48+
)
49+
new_cells.append(placeholder)
50+
else:
51+
# Clean up execution metadata
52+
if cell.cell_type == "code":
53+
cell.outputs = []
54+
cell.execution_count = None
55+
new_cells.append(cell)
56+
57+
nb.cells = new_cells
58+
nbformat.write(nb, output_path)
59+
print(f"✅ Wrote student notebook: {output_path}")
60+
61+
62+
def main() -> None:
63+
"""CLI entry point."""
64+
if len(sys.argv) != 3:
65+
print("Usage: python devtools/make_student.py input.ipynb output.ipynb")
66+
sys.exit(1)
67+
68+
input_path, output_path = sys.argv[1], sys.argv[2]
69+
make_student_version(input_path, output_path)
70+
71+
72+
if __name__ == "__main__":
73+
main()

0 commit comments

Comments
 (0)