airoh 0.1.2__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.
airoh/__init__.py ADDED
File without changes
airoh/containers.py ADDED
@@ -0,0 +1,160 @@
1
+ # src/airoh/containers.py
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+ import gzip
6
+ from pathlib import Path
7
+ from invoke import task
8
+
9
+ def _set_image(c, image=None):
10
+ """
11
+ Resolve the Docker image name from parameter or invoke.yaml.
12
+ """
13
+ image = image or c.config.get("docker_image")
14
+ if not image:
15
+ raise ValueError("No Docker image specified. Please set docker_image in invoke.yaml or pass it explicitly.")
16
+ return image
17
+
18
+ @task
19
+ def docker_build(c, image=None):
20
+ """
21
+ Build the Docker image from the Dockerfile in the project root.
22
+ """
23
+ image = _set_image(c, image)
24
+ print(f"ðŸģ Building Docker image: {image}")
25
+ c.run(f"docker build -t {image} .")
26
+
27
+ @task
28
+ def docker_archive(c, image=None):
29
+ """
30
+ Save the Docker image to a compressed archive for Zenodo or transport.
31
+ """
32
+ image = _set_image(c, image)
33
+ output = f"{image}.tar.gz"
34
+ print(f"ðŸ“Ķ Archiving Docker image '{image}' to {output}...")
35
+ c.run(f"docker save {image} | gzip > {output}")
36
+ print("ðŸŠĶ Archive complete.")
37
+
38
+ @task
39
+ def docker_setup(c, url=None, image=None):
40
+ """
41
+ Download and load the prebuilt Docker image from Zenodo.
42
+ """
43
+ image = _set_image(c, image)
44
+ if not url:
45
+ url = c.config.get("docker_archive")
46
+ if not url:
47
+ raise ValueError("No archive URL provided. Set docker_archive in invoke.yaml or pass --url.")
48
+
49
+ output = f"{image}.tar.gz"
50
+ if not os.path.exists(output):
51
+ print(f"ðŸ“Ĩ Downloading container from {url}...")
52
+ c.run(f"wget -O {output} '{url}'")
53
+ else:
54
+ print(f"ðŸ“Ķ Container archive already exists: {output}")
55
+
56
+ print("ðŸģ Loading Docker image...")
57
+ c.run(f"gunzip -c {output} | docker load")
58
+ print("âœĻ Container setup complete.")
59
+
60
+ def _ensure_docker_image_loaded(c, image, image_tar):
61
+ """
62
+ Ensure the specified Docker image is available. If not, try to load it from a .tar or .tar.gz.
63
+ """
64
+ if not shutil.which("docker"):
65
+ raise RuntimeError("❌ Docker is not installed or not in PATH. Please install Docker.")
66
+
67
+ result = c.run(f"docker images -q {image}", hide=True, warn=True)
68
+ if result.stdout.strip():
69
+ c.run(f"docker tag {image} {image}:latest", warn=True)
70
+ return
71
+
72
+ print(f"ðŸ“Ķ Docker image '{image}' not found. Attempting to load from {image_tar}...")
73
+
74
+ image_tar = Path(image_tar)
75
+ if not image_tar.exists():
76
+ raise FileNotFoundError(f"❌ Docker image file not found: {image_tar}")
77
+
78
+ if image_tar.suffixes[-2:] == ['.tar', '.gz']:
79
+ print(f"🖜ïļ Extracting {image_tar}...")
80
+ with tempfile.NamedTemporaryFile(suffix=".tar", delete=False) as temp_tar:
81
+ with gzip.open(image_tar, "rb") as f_in:
82
+ shutil.copyfileobj(f_in, temp_tar)
83
+ temp_tar_path = temp_tar.name
84
+ c.run(f"docker load -i {temp_tar_path}")
85
+ os.remove(temp_tar_path)
86
+ elif image_tar.suffix == ".tar":
87
+ c.run(f"docker load -i {image_tar}")
88
+ else:
89
+ raise ValueError("❌ Unsupported container format. Use .tar or .tar.gz")
90
+
91
+ c.run(f"docker tag {image} {image}:latest", warn=True)
92
+
93
+ @task
94
+ def docker_run(c, task, args=""):
95
+ """
96
+ Run an invoke task inside the Docker container.
97
+
98
+ Args:
99
+ task (str): the invoke task to run
100
+ args (str): any additional CLI args to pass to the task
101
+ """
102
+ image = c.config.get("docker_image")
103
+ image_tar = f"{image}.tar.gz"
104
+
105
+ _ensure_docker_image_loaded(c, image, image_tar)
106
+
107
+ hostdir = os.getcwd()
108
+ workdir = "/home/jovyan/work"
109
+ cmd = f"invoke {task} {args}"
110
+ docker_cmd = f'docker run --rm -v {hostdir}:{workdir} -w {workdir} {image} {cmd}'
111
+
112
+ print(f"ðŸģ Running inside container: {cmd}")
113
+ c.run(docker_cmd)
114
+
115
+ @task
116
+ def apptainer_archive(c, image=None):
117
+ """
118
+ Archive the Apptainer (Singularity) image from a Docker image using Docker daemon.
119
+ Builds the .sif file if not present.
120
+ """
121
+ image = _set_image(c, image)
122
+ sif_path = Path(f"{image}.sif")
123
+
124
+ if not shutil.which("apptainer"):
125
+ raise RuntimeError("❌ Apptainer is not installed or not in PATH. Please install it.")
126
+
127
+ if sif_path.exists():
128
+ print(f"✅ Apptainer image already exists at {sif_path}. Skipping build.")
129
+ return
130
+
131
+ if not shutil.which("docker"):
132
+ raise RuntimeError("❌ Docker is required to build from Docker image. Please install it.")
133
+
134
+ _ensure_docker_image_loaded(c, image, f"{image}.tar.gz")
135
+ print(f"🧊 Building Apptainer image {sif_path} from Docker image {image}:latest...")
136
+ c.run(f"apptainer build {sif_path} docker-daemon:{image}:latest")
137
+ print("✅ Apptainer image build complete.")
138
+
139
+ @task
140
+ def apptainer_run(c, task, args=""):
141
+ """
142
+ Run an invoke task inside the Apptainer container.
143
+
144
+ Args:
145
+ task (str): the invoke task to run
146
+ args (str): any additional CLI args to pass to the task
147
+ """
148
+ docker_image = c.config.get("docker_image")
149
+ sif_path = Path(f"{docker_image}.sif")
150
+
151
+ if not sif_path.exists():
152
+ raise FileNotFoundError(f"❌ Apptainer image not found: {sif_path}")
153
+
154
+ hostdir = os.getcwd()
155
+ workdir = "/home/jovyan/work"
156
+ cmd = f"invoke {task} {args}"
157
+ apptainer_cmd = f"apptainer exec --cleanenv --bind {hostdir}:{workdir} {sif_path} {cmd}"
158
+
159
+ print(f"🧊 Running inside Apptainer: {cmd}")
160
+ c.run(apptainer_cmd)
airoh/datalad.py ADDED
@@ -0,0 +1,82 @@
1
+ # src/airoh/datalad.py
2
+ from invoke import task
3
+ import os
4
+ import shlex
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ @task
9
+ def get_data(c, name):
10
+ """
11
+ Ensure a Datalad subdataset is installed and retrieved.
12
+
13
+ Parameters:
14
+ name (str): Name of the dataset as defined in invoke.yaml under 'datasets'.
15
+ """
16
+ datasets = c.config.get("datasets", {})
17
+ if name not in datasets:
18
+ raise ValueError(f"❌ Dataset '{name}' not found in invoke.yaml under 'datasets'.")
19
+
20
+ path = datasets[name]
21
+ print(f"ðŸ“Ķ Checking dataset '{name}' at: {path}")
22
+
23
+ if not os.path.exists(path):
24
+ print(f"ðŸ“Ĩ Installing subdataset '{name}'...")
25
+ c.run(f"datalad install --recursive {path}")
26
+
27
+ print(f"ðŸ“Ĩ Retrieving data for '{name}'...")
28
+ c.run(f"datalad get {path}")
29
+ print("✅ Done.")
30
+
31
+ @task
32
+ def import_file(c, name):
33
+ """
34
+ Download a file using its name from invoke.yaml -> files.<name>.
35
+
36
+ Parameters:
37
+ name (str): Key from the 'files' section in invoke.yaml.
38
+ """
39
+ files = c.config.get("files", {})
40
+ if name not in files:
41
+ raise ValueError(f"❌ No file config found for '{name}' in invoke.yaml.")
42
+
43
+ entry = files[name]
44
+ url = entry.get("url")
45
+ output_file = entry.get("output_file")
46
+
47
+ if not url or not output_file:
48
+ raise ValueError(f"❌ Entry for '{name}' must define both 'url' and 'output_file'.")
49
+
50
+ c.run(f"datalad download-url -O {shlex.quote(output_file)} {shlex.quote(url)}")
51
+ print(f"✅ Downloaded {name} to {output_file}")
52
+
53
+ @task
54
+ def import_archive(c, url, archive_name=None, target_dir=".", drop_archive=False):
55
+ """
56
+ Download a remote archive (e.g. from Zenodo or Figshare) and extract its content with Datalad.
57
+
58
+ Parameters:
59
+ url (str): URL to the archive (e.g. .zip, .tar.gz). For Figshare-style links, explicitly provide
60
+ `archive_name` if the URL does not end with the actual filename.
61
+ archive_name (str): Optional filename (default: basename of URL).
62
+ target_dir (str): Directory to extract into (default: current dir).
63
+ drop_archive (bool): Whether to drop the original archive from annex after extraction.
64
+ """
65
+ archive_name = archive_name or os.path.basename(url)
66
+ archive_path = os.path.join(target_dir, archive_name)
67
+
68
+ import_file(c, url, archive_path)
69
+
70
+ archive_exts = ['.zip', '.tar', '.tar.gz', '.tgz', '.tar.bz2', '.7z']
71
+ if not any(archive_path.endswith(ext) for ext in archive_exts):
72
+ print("⚠ïļ Skipping extraction — file does not appear to be a supported archive.")
73
+ return
74
+
75
+ print(f"ðŸ“Ķ Extracting archive content into {target_dir}...")
76
+ c.run(f"datalad add-archive-content --delete --extract {shlex.quote(archive_path)} -d {shlex.quote(target_dir)}")
77
+
78
+ if drop_archive:
79
+ print(f"ðŸ§đ Dropping archive from annex: {archive_path}")
80
+ c.run(f"datalad drop {shlex.quote(archive_path)}")
81
+
82
+ print("✅ Archive import complete.")
airoh/utils.py ADDED
@@ -0,0 +1,95 @@
1
+ # src/airoh/utils.py
2
+ import os
3
+ import shutil
4
+ from pathlib import Path
5
+ from invoke import task
6
+
7
+ @task
8
+ def setup_env_python(c, reqs="requirements.txt"):
9
+ """
10
+ Set up Python environment by installing from a requirements file.
11
+ """
12
+ if not os.path.exists(reqs):
13
+ raise FileNotFoundError(f"⚠ïļ Requirements file not found: {reqs}")
14
+
15
+ print(f"🐍 Installing Python requirements from {reqs}...")
16
+ c.run(f"pip install -r {reqs}")
17
+
18
+ @task
19
+ def ensure_submodule(c, path):
20
+ """
21
+ Ensure a git submodule is present and up to date.
22
+
23
+ Parameters:
24
+ path (str): Path to the submodule directory
25
+ """
26
+ if not os.path.exists(path) or not os.path.exists(os.path.join(path, ".git")):
27
+ print(f"ðŸ“Ķ Initializing submodule at {path}...")
28
+ c.run(f"git submodule update --init --recursive {path}")
29
+ else:
30
+ print(f"🔄 Updating submodule at {path}...")
31
+ c.run(f"git submodule update --remote {path}")
32
+
33
+ @task
34
+ def install_local(c, path):
35
+ """
36
+ Install a local Python package in editable mode using pip.
37
+
38
+ Parameters:
39
+ path (str): Path to the package directory
40
+ """
41
+ if not os.path.exists(path):
42
+ raise FileNotFoundError(f"❌ Package path not found: {path}")
43
+
44
+ print(f"🔧 Installing package from {path} in editable mode...")
45
+ c.run(f"pip install -e {path}")
46
+ print("✅ Editable install complete.")
47
+
48
+ @task
49
+ def clean_folder(c, dir_name, label=None):
50
+ """
51
+ Remove an entire directory recursively. Use with caution!!!
52
+ Parameters:
53
+ dir_name (str): Path to be removed
54
+ label (str, optional): label to use for path in the verbose
55
+ """
56
+ label = label or dir_name
57
+ if os.path.exists(dir_name):
58
+ shutil.rmtree(dir_name)
59
+ print(f"ðŸ’Ĩ Removed {label} at {dir_name}")
60
+ else:
61
+ print(f"ðŸŦ§ Nothing to clean: {label}")
62
+
63
+ @task
64
+ def run_figures(c, notebooks_path=None, figures_base=None):
65
+ """
66
+ Run figure notebooks, skipping any that already have output folders.
67
+ Notebooks directory and output location pulled from invoke.yaml.
68
+ """
69
+ import pathlib as lib
70
+
71
+ notebooks_path = Path(notebooks_path or c.config.get("notebooks_dir", "code/figures"))
72
+ figures_base = Path(figures_base or c.config.get("figures_dir", "output_data/Figures"))
73
+
74
+ if not notebooks_path.exists():
75
+ print(f"⚠ïļ Notebooks directory not found: {notebooks_path}")
76
+ return
77
+
78
+ notebooks = sorted(notebooks_path.glob("*.ipynb"))
79
+
80
+ if not notebooks:
81
+ print(f"⚠ïļ No notebooks found in {notebooks_path}/")
82
+ return
83
+
84
+ for nb in notebooks:
85
+ fig_name = nb.stem
86
+ fig_output_dir = figures_base / fig_name
87
+
88
+ if fig_output_dir.exists():
89
+ print(f"✅ Skipping {nb.name} (output exists at {fig_output_dir})")
90
+ continue
91
+
92
+ print(f"📈 Running {nb.name}...")
93
+ c.run(f"jupyter nbconvert --to notebook --execute --inplace {nb}")
94
+
95
+ print("🎉 All figure notebooks processed.")
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: airoh
3
+ Version: 0.1.2
4
+ Summary: A lightweight task library for reproducible workflows
5
+ Project-URL: Homepage, https://github.com/simexp/airoh
6
+ Project-URL: Issues, https://github.com/SIMEXP/airoh/issues
7
+ Author-email: Lune Bellec <lune.bellec@umontreal.ca>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.8
11
+ Requires-Dist: invoke>=2.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # airoh
15
+ _Because reproducible science takes clean tasks. And why don't you have a cup of relaxing jasmin tea?_
16
+
17
+ **airoh** is a lightweight Python task library built with [`invoke`](https://www.pyinvoke.org/), designed for reproducible research workflows. It provides pre-written, modular task definitions that can be easily reused in your own `tasks.py` file — no boilerplate, just useful automation.
18
+
19
+ ## Installation
20
+ Installation through PIP:
21
+ ```bash
22
+ pip install airoh
23
+ ```
24
+
25
+ For local deployment:
26
+
27
+ ```bash
28
+ git clone https://github.com/simexp/airoh.git
29
+ cd airoh
30
+ pip install -e .
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ You can use `airoh` in your project simply by importing tasks in your `tasks.py` file.
36
+
37
+ ### Minimal Example
38
+
39
+ ```python
40
+ # tasks.py
41
+ from airoh.utils import run_figures, setup_env_python
42
+ ```
43
+
44
+ Now you can call:
45
+
46
+ ```bash
47
+ invoke run-figures
48
+ invoke setup-env-python
49
+ ```
50
+
51
+ ## Available Tasks
52
+
53
+ ### From `containers.py`
54
+
55
+ * `docker-build` — Build a Docker image from a Dockerfile
56
+ * `docker-archive` — Archive the Docker image to `.tar.gz`
57
+ * `docker-setup` — Download and load a Docker image from URL
58
+ * `docker-run` — Run an `invoke` task inside Docker
59
+ * `apptainer-archive` — Build an Apptainer image from Docker
60
+ * `apptainer-run` — Run an `invoke` task inside Apptainer
61
+
62
+ ### From `utils.py`
63
+
64
+ * `ensure_submodule` — Ensure a git submodule is present and up to date
65
+ * `install_local` — Install a local Python package in editable mode using pip
66
+ * `setup-env-python` — Install Python dependencies from a file
67
+ * `run-figures` — Run Jupyter notebooks to generate figures
68
+ * `clean_folder` — Remove an entire directory recursively. Use with caution!!!
69
+
70
+ ### From `datalad.py`
71
+ * `get-data` — Install and retrieve a subdataset listed in `invoke.yaml`
72
+ * `import-archive` — Download a remote archive (e.g. from Zenodo), extract its contents with `datalad`, and optionally drop the original file
73
+ * `import-file` — Download a remote file (e.g. from Zenodo), using `datalad`
74
+
75
+ ## Requirements
76
+
77
+ * Python â‰Ĩ 3.8
78
+ * [`invoke`](https://www.pyinvoke.org/) â‰Ĩ 2.0
79
+ * Docker (for container tasks)
80
+ * Apptainer (optional, for `.sif` support)
81
+ * `jupyter` (if using `run-figures`)
82
+
83
+ ## Philosophy
84
+
85
+ Inspired by Uncle Iroh from *Avatar: The Last Airbender*, `airoh` aims to bring simplicity, reusability, and clarity to research infrastructure — one well-structured task at a time. It is meant to support a concrete implementation of the [YODA principles](https://handbook.datalad.org/en/latest/basics/101-127-yoda.html).
86
+
87
+ ## License
88
+
89
+ MIT ÂĐ Lune Bellec
@@ -0,0 +1,8 @@
1
+ airoh/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ airoh/containers.py,sha256=8fy7SbvcgvGB7zUz3XujZt0V1Mw3df5bu9tpPSGz5w8,5370
3
+ airoh/datalad.py,sha256=UchPdADLLUIMWVpAaleFqVNxqzCCyMyCEs18NhYG3ms,2968
4
+ airoh/utils.py,sha256=yjcxMiinRD79NTrGxHouQv0MEU6p0_lz7B7-6QUWDUs,2997
5
+ airoh-0.1.2.dist-info/METADATA,sha256=mQsmrjvEyCI2SrXxS6dxD9CKWaYcTofzwdV5wvAqVZc,2944
6
+ airoh-0.1.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
7
+ airoh-0.1.2.dist-info/licenses/LICENSE,sha256=g9RmS4EgjG249_mj6p16XgUnsqp4cpigQnI_pGyEKnk,1063
8
+ airoh-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 SIMEXP
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.