Skip to content

Commit cebe962

Browse files
authored
Merge pull request #180 from neuromatch/staging
Update gh actions and notebook processing step
2 parents 5155b87 + 3a85c3b commit cebe962

16 files changed

Lines changed: 861 additions & 741 deletions

File tree

.github/actions/check-notebooks/action.yaml

Lines changed: 0 additions & 117 deletions
This file was deleted.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
name: 'Setup nmaci CI Tools'
2+
description: 'Download and install Neuromatch CI tools'
3+
4+
inputs:
5+
branch:
6+
description: 'nmaci branch to use'
7+
required: false
8+
default: 'main'
9+
commit-message:
10+
description: 'Commit message to parse for branch override'
11+
required: false
12+
default: ''
13+
stub-widgets:
14+
description: 'Install ipywidgets stub for headless execution (disable for book builds)'
15+
required: false
16+
default: 'true'
17+
18+
outputs:
19+
nmaci-branch:
20+
description: 'nmaci branch used'
21+
value: ${{ steps.detect-branch.outputs.branch }}
22+
23+
runs:
24+
using: 'composite'
25+
steps:
26+
- name: Detect nmaci branch
27+
id: detect-branch
28+
shell: bash
29+
env:
30+
COMMIT_MESSAGE: ${{ inputs.commit-message }}
31+
run: |
32+
BRANCH="${{ inputs.branch }}"
33+
if [ -n "$COMMIT_MESSAGE" ]; then
34+
OVERRIDE=$(python3 -c "import os, re; m = re.search(r'nmaci:([\w-]+)', os.environ.get('COMMIT_MESSAGE', '')); print(m.group(1) if m else '')")
35+
if [ -n "$OVERRIDE" ]; then
36+
BRANCH="$OVERRIDE"
37+
fi
38+
fi
39+
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
40+
echo "Using nmaci branch: $BRANCH"
41+
42+
- name: Get nmaci latest commit
43+
id: nmaci-sha
44+
shell: bash
45+
run: |
46+
BRANCH="${{ steps.detect-branch.outputs.branch }}"
47+
SHA=$(git ls-remote https://github.com/neuromatch/nmaci.git refs/heads/$BRANCH | cut -c1-8)
48+
[ -n "$SHA" ] || { echo "::error::Could not resolve branch $BRANCH in neuromatch/nmaci"; exit 1; }
49+
echo "sha=$SHA" >> $GITHUB_OUTPUT
50+
echo "nmaci $BRANCH is at commit $SHA"
51+
52+
- name: Cache nmaci tools
53+
id: cache-nmaci
54+
uses: actions/cache@v4
55+
with:
56+
path: ci/
57+
key: nmaci-v2-${{ runner.os }}-${{ steps.detect-branch.outputs.branch }}-${{ steps.nmaci-sha.outputs.sha }}
58+
restore-keys: nmaci-v2-${{ runner.os }}-${{ steps.detect-branch.outputs.branch }}-
59+
60+
- name: Download nmaci tools
61+
if: steps.cache-nmaci.outputs.cache-hit != 'true'
62+
shell: bash
63+
run: |
64+
BRANCH="${{ steps.detect-branch.outputs.branch }}"
65+
wget -q "https://github.com/neuromatch/nmaci/archive/refs/heads/${BRANCH}.tar.gz"
66+
tar -xzf "${BRANCH}.tar.gz"
67+
rm -rf ci/scripts
68+
mv "nmaci-${BRANCH}/scripts/" ci/
69+
mv "nmaci-${BRANCH}/requirements.txt" ci/requirements.txt
70+
rm -r "nmaci-${BRANCH}" "${BRANCH}.tar.gz"
71+
72+
- name: Install nmaci dependencies
73+
shell: bash -el {0}
74+
run: |
75+
set -e
76+
pip install --upgrade pip
77+
78+
if [ ! -f ci/requirements.txt ]; then
79+
echo "ci/requirements.txt not found, downloading..."
80+
BRANCH="${{ steps.detect-branch.outputs.branch }}"
81+
wget -O ci/requirements.txt "https://raw.githubusercontent.com/neuromatch/nmaci/${BRANCH}/requirements.txt"
82+
fi
83+
84+
echo "Installing from ci/requirements.txt:"
85+
cat ci/requirements.txt
86+
pip install -r ci/requirements.txt
87+
88+
BRANCH="${{ steps.detect-branch.outputs.branch }}"
89+
echo "Installing nmaci package from branch: $BRANCH"
90+
pip install "git+https://github.com/neuromatch/nmaci.git@${BRANCH}"
91+
92+
python -c "import nmaci; import nbformat; print(f'nmaci installed, nbformat version: {nbformat.__version__}')"
93+
94+
- name: Ignore ci directory
95+
shell: bash -el {0}
96+
run: grep -qxF 'ci/' .gitignore || echo 'ci/' >> .gitignore
97+
98+
- name: Stub ipywidgets for headless kernel execution
99+
if: inputs.stub-widgets == 'true'
100+
shell: bash -el {0}
101+
run: |
102+
mkdir -p ~/.ipython/profile_default/startup
103+
cp ${{ github.action_path }}/stub_widgets.py ~/.ipython/profile_default/startup/00-stub-widgets.py
104+
echo "Installed ipywidgets stub to IPython startup"
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Stub ipywidgets for headless/CI execution.
2+
# Replaces blocking widget calls with no-ops so notebooks execute without hanging.
3+
# A guard prevents this stub from replacing real ipywidgets if it is already loaded
4+
# (e.g. when running inside a Colab/Jupyter environment with a real frontend).
5+
#
6+
# Installed into ~/.ipython/profile_default/startup/ by the setup-ci-tools action
7+
# so it runs automatically before any notebook cell when nbconvert spawns a kernel.
8+
import sys
9+
import types
10+
import inspect
11+
12+
13+
class _NoOpWidget:
14+
"""A no-op stand-in for any ipywidgets widget class."""
15+
16+
def __init__(self, *args, **kwargs):
17+
# Preserve value/options so _Interact can extract call defaults
18+
object.__setattr__(self, "children", kwargs.get("children", []))
19+
object.__setattr__(self, "value", kwargs.get("value", None))
20+
object.__setattr__(self, "options", kwargs.get("options", []))
21+
22+
def __enter__(self):
23+
return self
24+
25+
def __exit__(self, *args):
26+
pass
27+
28+
def __setattr__(self, name, value):
29+
object.__setattr__(self, name, value)
30+
31+
def __getattr__(self, name):
32+
# Return a no-op callable for any unknown method/attribute
33+
return lambda *args, **kwargs: None
34+
35+
36+
class _Interact:
37+
"""Stub for widgets.interact / widgets.interactive.
38+
39+
Calls the wrapped function once with default values extracted from
40+
widget stubs so that matplotlib outputs are captured by nbconvert.
41+
"""
42+
43+
def __call__(self, *args, **kwargs):
44+
if args and callable(args[0]):
45+
# interact(f) or interact(f, param=value, ...)
46+
return self._call_with_defaults(args[0], kwargs if kwargs else None)
47+
# interact(param=value) used as decorator factory
48+
widget_kwargs = kwargs
49+
50+
def decorator(f):
51+
return self._call_with_defaults(f, widget_kwargs)
52+
53+
return decorator
54+
55+
def _call_with_defaults(self, f, widget_kwargs=None):
56+
sig = inspect.signature(f)
57+
call_kwargs = {}
58+
for name, param in sig.parameters.items():
59+
widget = (widget_kwargs or {}).get(name)
60+
if widget is None and param.default is not inspect.Parameter.empty:
61+
widget = param.default
62+
if isinstance(widget, _NoOpWidget) and widget.value is not None:
63+
call_kwargs[name] = widget.value
64+
elif widget is not None and not isinstance(widget, _NoOpWidget):
65+
call_kwargs[name] = widget
66+
try:
67+
f(**call_kwargs)
68+
except Exception as e:
69+
print(f"[stub] interact call skipped: {e}")
70+
return f
71+
72+
73+
class _StubModule(types.ModuleType):
74+
"""ipywidgets stub module.
75+
76+
Any attribute access returns _NoOpWidget so that
77+
'from ipywidgets import AnythingAtAll' always succeeds.
78+
"""
79+
80+
interact = _Interact()
81+
interactive = _Interact()
82+
83+
def __getattr__(self, name):
84+
if name.startswith("__"):
85+
raise AttributeError(name)
86+
return _NoOpWidget
87+
88+
89+
# Only install stub if ipywidgets not already in sys.modules
90+
if "ipywidgets" not in sys.modules:
91+
stub = _StubModule("ipywidgets")
92+
stub.widgets = stub # support: from ipywidgets import widgets
93+
sys.modules["ipywidgets"] = stub
94+
sys.modules["ipywidgets.widgets"] = stub
95+
print("ipywidgets stubbed for headless CI execution")
96+
else:
97+
print("ipywidgets already loaded, skipping stub")
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: 'Setup Python Environment'
2+
description: 'Set up Python environment via micromamba using environment.yml'
3+
4+
inputs:
5+
python-version:
6+
description: 'Python version (informational only, environment.yml specifies the version)'
7+
required: false
8+
default: '3.10'
9+
10+
outputs:
11+
cache-hit:
12+
description: 'Whether the conda environment cache was hit'
13+
value: ${{ steps.setup-micromamba.outputs.cache-hit }}
14+
15+
runs:
16+
using: 'composite'
17+
steps:
18+
- name: Setup micromamba and install environment
19+
id: setup-micromamba
20+
uses: mamba-org/setup-micromamba@v3
21+
with:
22+
micromamba-version: 'latest'
23+
environment-file: environment.yml
24+
init-shell: bash
25+
cache-downloads: true
26+
cache-environment: true
27+
post-cleanup: 'all'
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: 'Setup Rendering Dependencies'
2+
description: 'Install fonts and graphviz for notebook rendering'
3+
4+
inputs:
5+
skip-fonts:
6+
description: 'Skip XKCD font installation'
7+
required: false
8+
default: 'false'
9+
skip-graphviz:
10+
description: 'Skip graphviz installation'
11+
required: false
12+
default: 'false'
13+
14+
runs:
15+
using: 'composite'
16+
steps:
17+
- name: Cache fonts
18+
id: cache-fonts
19+
uses: actions/cache@v4
20+
with:
21+
path: /usr/share/fonts/truetype/humor-sans
22+
key: fonts-${{ runner.os }}-humor-sans-v1
23+
24+
- name: Install XKCD fonts
25+
if: ${{ inputs.skip-fonts != 'true' && steps.cache-fonts.outputs.cache-hit != 'true' }}
26+
shell: bash
27+
run: |
28+
sudo apt-get update -yq
29+
sudo apt-get install -y fonts-humor-sans
30+
# Clear matplotlib font cache so it picks up the new font
31+
rm -f "$HOME/.matplotlib/fontList.cache"
32+
33+
- name: Install Graphviz
34+
if: ${{ inputs.skip-graphviz != 'true' }}
35+
uses: tlylt/install-graphviz@v1

0 commit comments

Comments
 (0)