Skip to content

Commit 48a6220

Browse files
committed
Merge branch 'main' into add_doc_nb
2 parents 93208ea + 177b897 commit 48a6220

33 files changed

Lines changed: 2132 additions & 121 deletions

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
fail-fast: false
1818
matrix:
1919
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
20-
python-version: ['3.8', '3.9', '3.10']
20+
python-version: ['3.9', '3.10', '3.11']
2121
steps:
2222
- uses: actions/checkout@v3
2323
- name: Set up Python ${{ matrix.python-version }}

Dockerfile

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
FROM ghcr.io/ecoextreml/stemmus_scope:1.5.0
2+
3+
LABEL maintainer="Bart Schilperoort <b.schilperoort@esciencecenter.nl>"
4+
LABEL org.opencontainers.image.source = "https://github.com/EcoExtreML/STEMMUS_SCOPE_Processing"
5+
6+
# Requirements for building Python 3.10
7+
RUN apt-get update && apt-get -y upgrade
8+
RUN apt-get install -y build-essential zlib1g-dev libncurses5-dev libgdbm-dev \
9+
libnss3-dev libssl-dev libreadline-dev libffi-dev libsqlite3-dev wget libbz2-dev
10+
RUN apt-get install -y libhdf5-serial-dev
11+
12+
# Get Python source and compile
13+
WORKDIR /python
14+
RUN wget https://www.python.org/ftp/python/3.10.12/Python-3.10.12.tgz --no-check-certificate
15+
RUN tar -xf Python-3.10.*.tgz
16+
WORKDIR /python/Python-3.10.12
17+
RUN ./configure --prefix=/usr/local --enable-optimizations --enable-shared LDFLAGS="-Wl,-rpath /usr/local/lib"
18+
RUN make -j $(nproc)
19+
RUN make altinstall
20+
WORKDIR /
21+
22+
# Pip install PyStemmusScope and dependencies
23+
COPY . /opt/PyStemmusScope
24+
RUN pip3.10 install /opt/PyStemmusScope/[docker]
25+
RUN pip3.10 install grpc4bmi==0.5.0
26+
27+
# # Set the STEMMUS_SCOPE environmental variable, so the BMI can find the executable
28+
WORKDIR /
29+
ENV STEMMUS_SCOPE /STEMMUS_SCOPE
30+
31+
EXPOSE 55555
32+
# Start grpc4bmi server
33+
CMD run-bmi-server --name "PyStemmusScope.bmi.implementation.StemmusScopeBmi" --port 55555 --debug
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""The Docker STEMMUS_SCOPE model process wrapper."""
2+
import os
3+
import socket as pysocket
4+
import warnings
5+
from time import sleep
6+
from typing import Any
7+
from PyStemmusScope.bmi.docker_utils import check_tags
8+
from PyStemmusScope.bmi.docker_utils import find_image
9+
from PyStemmusScope.bmi.docker_utils import make_docker_vols_binds
10+
from PyStemmusScope.bmi.utils import MATLAB_ERROR
11+
from PyStemmusScope.bmi.utils import PROCESS_FINALIZED
12+
from PyStemmusScope.bmi.utils import PROCESS_READY
13+
from PyStemmusScope.bmi.utils import MatlabError
14+
from PyStemmusScope.config_io import read_config
15+
16+
17+
try:
18+
import docker
19+
except ImportError:
20+
docker = None
21+
22+
23+
def _model_is_ready(socket: Any, client: Any, container_id: Any) -> None:
24+
return _wait_for_model(PROCESS_READY, socket, client, container_id)
25+
26+
27+
def _model_is_finalized(socket: Any, client: Any, container_id: Any) -> None:
28+
return _wait_for_model(PROCESS_FINALIZED, socket, client, container_id)
29+
30+
31+
def _wait_for_model(phrase: bytes, socket: Any, client: Any, container_id: Any) -> None:
32+
"""Wait for the model to be ready to receive (more) commands, or is finalized."""
33+
output = b""
34+
35+
while phrase not in output:
36+
try:
37+
data = socket.read(1)
38+
except TimeoutError as err:
39+
client.stop(container_id)
40+
logs = client.logs(container_id).decode("utf-8")
41+
msg = (
42+
f"Container connection timed out '{container_id['Id']}'."
43+
f"\nPlease inspect logs:\n{logs}"
44+
)
45+
raise TimeoutError(msg) from err
46+
47+
if data is None:
48+
msg = "Could not read data from socket. Docker container might be dead."
49+
raise ConnectionError(msg)
50+
else:
51+
output += bytes(data)
52+
53+
if MATLAB_ERROR in output:
54+
client.stop(container_id)
55+
logs = client.logs(container_id).decode("utf-8")
56+
msg = (
57+
f"Error in container '{container_id['Id']}'.\n"
58+
f"Please inspect logs:\n{logs}"
59+
)
60+
raise MatlabError(msg)
61+
62+
63+
def _attach_socket(client, container_id) -> Any:
64+
"""Attach a socket to a container and add a timeout to it."""
65+
connection_timeout = 30 # seconds
66+
67+
socket = client.attach_socket(container_id, {"stdin": 1, "stdout": 1, "stream": 1})
68+
if isinstance(socket, pysocket.SocketIO):
69+
socket._sock.settimeout(connection_timeout) # type: ignore
70+
else:
71+
warnings.warn(
72+
message=(
73+
"Unknown socket type found. This might cause issues with the Docker"
74+
" connection. \nPlease report this to the developers in an issue "
75+
"on: https://github.com/EcoExtreML/STEMMUS_SCOPE_Processing/issues"
76+
),
77+
stacklevel=1,
78+
)
79+
return socket
80+
81+
82+
class StemmusScopeDocker:
83+
"""Communicate with a STEMMUS_SCOPE Docker container."""
84+
85+
# Default image, can be overridden with config:
86+
compatible_tags = ("1.5.0",)
87+
88+
_process_ready_phrase = b"Select BMI mode:"
89+
_process_finalized_phrase = b"Finished clean up."
90+
91+
def __init__(self, cfg_file: str):
92+
"""Create the Docker container.."""
93+
self.cfg_file = cfg_file
94+
config = read_config(cfg_file)
95+
96+
self.image = config["DockerImage"]
97+
find_image(self.image)
98+
check_tags(self.image, self.compatible_tags)
99+
100+
self.client = docker.APIClient()
101+
102+
vols, binds = make_docker_vols_binds(cfg_file)
103+
self.container_id = self.client.create_container(
104+
self.image,
105+
stdin_open=True,
106+
tty=True,
107+
detach=True,
108+
user=f"{os.getuid()}:{os.getgid()}", # ensure correct user for writing files.
109+
volumes=vols,
110+
host_config=self.client.create_host_config(binds=binds),
111+
)
112+
113+
self.running = False
114+
115+
def _wait_for_model(self) -> None:
116+
"""Wait for the model to be ready to receive (more) commands."""
117+
_model_is_ready(self.socket, self.client, self.container_id)
118+
119+
def is_alive(self) -> bool:
120+
"""Return if the process is alive."""
121+
return self.running
122+
123+
def initialize(self) -> None:
124+
"""Initialize the model and wait for it to be ready."""
125+
if self.is_alive():
126+
self.client.stop(self.container_id)
127+
128+
self.client.start(self.container_id)
129+
self.socket = _attach_socket(self.client, self.container_id)
130+
131+
self._wait_for_model()
132+
os.write(
133+
self.socket.fileno(),
134+
bytes(f'initialize "{self.cfg_file}"\n', encoding="utf-8"),
135+
)
136+
self._wait_for_model()
137+
138+
self.running = True
139+
140+
def update(self) -> None:
141+
"""Update the model and wait for it to be ready."""
142+
if self.is_alive():
143+
os.write(self.socket.fileno(), b"update\n")
144+
self._wait_for_model()
145+
else:
146+
msg = "Docker container is not alive. Please restart the model."
147+
raise ConnectionError(msg)
148+
149+
def finalize(self) -> None:
150+
"""Finalize the model."""
151+
if self.is_alive():
152+
os.write(self.socket.fileno(), b"finalize\n")
153+
_model_is_finalized(
154+
self.socket,
155+
self.client,
156+
self.container_id,
157+
)
158+
sleep(0.5) # Ensure the container can stop cleanly.
159+
self.client.stop(self.container_id)
160+
self.running = False
161+
self.client.remove_container(self.container_id, v=True)
162+
else:
163+
pass

PyStemmusScope/bmi/docker_utils.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Utility functions for making the docker process work."""
2+
import warnings
3+
from pathlib import Path
4+
from PyStemmusScope.config_io import read_config
5+
6+
7+
try:
8+
import docker
9+
except ImportError:
10+
docker = None
11+
12+
13+
def make_docker_vols_binds(cfg_file: str) -> tuple[list[str], list[str]]:
14+
"""Make docker volume mounting configs.
15+
16+
Args:
17+
cfg_file: Location of the config file
18+
19+
Returns:
20+
volumes, binds
21+
"""
22+
cfg = read_config(cfg_file)
23+
cfg_dir = Path(cfg_file).parent
24+
volumes = []
25+
binds = []
26+
27+
# Make sure no subpaths are mounted:
28+
if not cfg_dir.is_relative_to(cfg["InputPath"]):
29+
volumes.append(str(cfg_dir))
30+
binds.append(f"{str(cfg_dir)}:{str(cfg_dir)}")
31+
if (not Path(cfg["InputPath"]).is_relative_to(cfg_dir)) or (
32+
Path(cfg["InputPath"]) == cfg_dir
33+
):
34+
volumes.append(cfg["InputPath"])
35+
binds.append(f"{cfg['InputPath']}:{cfg['InputPath']}")
36+
if not Path(cfg["OutputPath"]).is_relative_to(cfg_dir):
37+
volumes.append(cfg["OutputPath"])
38+
binds.append(f"{cfg['OutputPath']}:{cfg['OutputPath']}")
39+
40+
return volumes, binds
41+
42+
43+
def check_tags(image: str, compatible_tags: tuple[str, ...]):
44+
"""Check if the tag is compatible with this version of the BMI.
45+
46+
Args:
47+
image: The full image name (including tag)
48+
compatible_tags: Tags which are known to be compatible with this version of the
49+
BMI.
50+
"""
51+
if ":" not in image:
52+
msg = (
53+
"Could not validate the Docker image tag, as no tag was provided.\n"
54+
"Please set the Docker image tag in the configuration file."
55+
)
56+
warnings.warn(UserWarning(msg), stacklevel=1)
57+
58+
tag = image.split(":")[-1]
59+
if tag not in compatible_tags:
60+
msg = (
61+
f"Docker image tag '{tag}' not found in compatible tags "
62+
f"({compatible_tags}).\n"
63+
"You might experience issues or unexpected results."
64+
)
65+
warnings.warn(UserWarning(msg), stacklevel=1)
66+
67+
68+
def find_image(image: str) -> None:
69+
"""See if the desired image is available, and if not, try to pull it."""
70+
client = docker.APIClient()
71+
images = client.images()
72+
tags = []
73+
for img in images:
74+
for tag in img["RepoTags"]:
75+
tags.append(tag)
76+
if image not in set(tags):
77+
pull_image(image)
78+
79+
80+
def pull_image(image: str) -> None:
81+
"""Pull the image from ghcr/dockerhub."""
82+
if ":" in image:
83+
image, tag = image.split(":")
84+
else:
85+
tag = None
86+
client = docker.from_env()
87+
image = client.images.pull(image, tag)

0 commit comments

Comments
 (0)