Skip to content

Commit a3d984e

Browse files
committed
implementing the automatic generation of pydra-packages with conversion specs configured
1 parent e9f7467 commit a3d984e

File tree

9 files changed

+420
-2
lines changed

9 files changed

+420
-2
lines changed

.flake8

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
2+
[flake8]
3+
doctests = True
4+
exclude =
5+
**/__init__.py
6+
*build/
7+
docs/sphinxext/
8+
docs/tools/
9+
docs/conf.py
10+
arcana/_version.py
11+
versioneer.py
12+
docs/source/conf.py
13+
max-line-length = 88
14+
select = C,E,F,W,B,B950
15+
extend-ignore = E203,E501,E129,W503
16+
per-file-ignores =
17+
setup.py:F401

nipype2pydra/task.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class TaskConverter:
2020
nipype_module: ModuleType = attrs.field(converter=import_module_from_path)
2121
output_requirements: dict = attrs.field(factory=dict)
2222
inputs_metadata: dict = attrs.field(factory=dict)
23-
inputs_drop: dict = attrs.field(factory=dict)
23+
inputs_drop: dict = attrs.field(factory=list)
2424
output_templates: dict = attrs.field(factory=dict)
2525
output_callables: dict = attrs.field(factory=dict)
2626
doctest: dict = attrs.field(factory=dict)

nipype2pydra/utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import traceback
2+
import typing as ty
23
from types import ModuleType
34
import sys
45
import os
@@ -18,7 +19,7 @@ def show_cli_trace(result):
1819
return "".join(traceback.format_exception(*result.exc_info))
1920

2021

21-
def import_module_from_path(module_path: Path) -> ModuleType:
22+
def import_module_from_path(module_path: ty.Union[ModuleType, Path, str]) -> ModuleType:
2223
if isinstance(module_path, ModuleType) or module_path is None:
2324
return module_path
2425
module_path = Path(module_path).resolve()

scripts/pkg_gen/create_packages.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import os
2+
import typing as ty
3+
import tempfile
4+
import re
5+
import subprocess as sp
6+
import shutil
7+
import tarfile
8+
from pathlib import Path
9+
import requests
10+
import click
11+
import yaml
12+
13+
RESOURCES_DIR = Path(__file__).parent / "resources"
14+
15+
16+
def download_tasks_template(output_path: Path):
17+
"""Downloads the latest pydra-tasks-template to the output path"""
18+
19+
release_url = (
20+
"https://api.github.com/repos/nipype/pydra-tasks-template/releases/latest"
21+
)
22+
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": "nipype2pydra"}
23+
24+
response = requests.get(release_url, headers=headers)
25+
if response.status_code != 200:
26+
raise RuntimeError(
27+
f"Did not find release at '{release_url}'"
28+
)
29+
data = response.json()
30+
tarball_url = data["tarball_url"]
31+
32+
response = requests.get(tarball_url)
33+
34+
if response.status_code == 200:
35+
# Save the response content to a file
36+
with open(output_path, "wb") as f:
37+
f.write(response.content)
38+
else:
39+
raise RuntimeError(
40+
f"Could not download the pydra-tasks template at {release_url}"
41+
)
42+
43+
44+
@click.command(help="Generates stub pydra packages for all nipype interfaces to import")
45+
@click.argument("output_dir", type=click.Path(path_type=Path))
46+
@click.option("--work-dir", type=click.Path(path_type=Path), default=None)
47+
@click.option("--task-template", type=click.Path(path_type=Path), default=None)
48+
def generate_packages(
49+
output_dir: Path, work_dir: ty.Optional[Path], task_template: ty.Optional[Path]
50+
):
51+
52+
if work_dir is None:
53+
work_dir = Path(tempfile.mkdtemp())
54+
55+
if task_template is None:
56+
task_template_tar = work_dir / "task-template.tar.gz"
57+
download_tasks_template(task_template_tar)
58+
extract_dir = work_dir / "task_template"
59+
with tarfile.open(task_template_tar, 'r:gz') as tar:
60+
tar.extractall(path=extract_dir)
61+
task_template = extract_dir / next(extract_dir.iterdir())
62+
63+
with open(Path(__file__).parent.parent.parent / "nipype-interfaces-to-import.yaml") as f:
64+
to_import = yaml.load(f, Loader=yaml.SafeLoader)
65+
66+
# Wipe output dir
67+
if output_dir.exists():
68+
shutil.rmtree(output_dir)
69+
output_dir.mkdir()
70+
71+
for pkg in to_import["packages"]:
72+
73+
pkg_dir = output_dir / f"pydra-{pkg}"
74+
pkg_dir.mkdir()
75+
76+
def copy_ignore(_, names):
77+
return [n for n in names if n in (".git", "__pycache__", ".pytest_cache")]
78+
79+
shutil.copytree(task_template, pkg_dir, ignore=copy_ignore)
80+
81+
auto_conv_dir = pkg_dir / "nipype-auto-conv"
82+
specs_dir = auto_conv_dir / "specs"
83+
specs_dir.mkdir(parents=True)
84+
shutil.copy(RESOURCES_DIR / "nipype-auto-convert.py", auto_conv_dir / "generate")
85+
os.chmod(auto_conv_dir / "generate", 0o755) # make executable
86+
87+
gh_workflows_dir = pkg_dir / ".github" / "workflows"
88+
gh_workflows_dir.mkdir(parents=True)
89+
shutil.copy(RESOURCES_DIR / "pythonpackage.yaml", gh_workflows_dir / "pythonpackage.yaml")
90+
91+
# Add "pydra.tasks.<pkg>.auto to gitignore"
92+
with open(pkg_dir / ".gitignore", "a") as f:
93+
f.write("\npydra/tasks/{pkg}/auto")
94+
95+
# rename tasks directory
96+
(pkg_dir / "pydra" / "tasks" / "CHANGEME").rename(pkg_dir / "pydra" / "tasks" / pkg)
97+
98+
# Replace "CHANGEME" string with pkg name
99+
for fspath in pkg_dir.glob("**/*"):
100+
if fspath.is_dir():
101+
continue
102+
with open(fspath) as f:
103+
contents = f.read()
104+
contents = re.sub(r"(?<![0-9a-zA-Z])CHANGEME(?![0-9a-zA-Z])", pkg, contents)
105+
with open(fspath, "w") as f:
106+
f.write(contents)
107+
108+
for module, interfaces in to_import["interfaces"].items():
109+
if module.split("/")[0] != pkg:
110+
continue
111+
module_spec_dir = specs_dir.joinpath(*module.split("/"))
112+
module_spec_dir.mkdir(parents=True)
113+
for interface in interfaces:
114+
callables_fspath = module_spec_dir / f"{interface}_callables.py"
115+
spec_stub = {
116+
"task_name": interface,
117+
"nipype_module": "nipype.interfaces." + ".".join(module.split("/")),
118+
"output_requirements": "# dict[output-field, list[input-field]] : the required input fields for output-field",
119+
"inputs_metadata": "# dict[input-field, dict[str, Any]] : additional metadata to be inserted into input field",
120+
"inputs_drop": "# list[input-field] : input fields to drop from the spec",
121+
"output_templates": "# dict[input-field, str] : \"output_file_template\" to provide to input field",
122+
"output_callables": f"# dict[output-field, str] : name of function defined in {callables_fspath.name} that retrieves value for output",
123+
"doctest": "# dict[str, Any]: key-value pairs to provide as inputs to the doctest + the expected value of \"cmdline\" as special key-value pair",
124+
"tests_inputs": "# List of inputs to pass to tests",
125+
"tests_outputs": "# list of outputs expected from tests",
126+
}
127+
yaml_str = yaml.dump(spec_stub, indent=2, sort_keys=False)
128+
# strip inserted line-breaks in long strings (so they can be converted to in-line comments)
129+
yaml_str = re.sub(r"\n ", " ", yaml_str)
130+
# extract comments after they have been dumped as strings
131+
yaml_str = re.sub(r"'#(.*)'", r" # \1", yaml_str)
132+
with open(module_spec_dir / (interface + ".yaml"), "w") as f:
133+
f.write(yaml_str)
134+
with open(callables_fspath, "w") as f:
135+
f.write(
136+
f'"""Module to put any functions that are referred to in {interface}.yaml"""\n'
137+
)
138+
139+
sp.check_call("git init", shell=True, cwd=pkg_dir)
140+
sp.check_call("git add --all", shell=True, cwd=pkg_dir)
141+
sp.check_call('git commit -m"initial commit of generated stubs"', shell=True, cwd=pkg_dir)
142+
sp.check_call("git tag 0.1.0", shell=True, cwd=pkg_dir)
143+
144+
145+
if __name__ == "__main__":
146+
import sys
147+
148+
generate_packages(sys.argv[1:])

scripts/pkg_gen/requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
PyYaml >= 1.4.0
2+
click >= 8.1.3
3+
requests >= 2.31.0
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Generate Release
2+
3+
on:
4+
repository_dispatch:
5+
types: [create-release]
6+
7+
jobs:
8+
create_release:
9+
runs-on: ubuntu-latest
10+
11+
steps:
12+
- name: Checkout repository
13+
uses: actions/checkout@v2
14+
15+
- name: Generate Release
16+
run: |
17+
# Extract necessary information from the event payload
18+
REPO_OWNER=${{ github.event.client_payload.repo_owner }}
19+
REPO_NAME=${{ github.event.client_payload.repo_name }}
20+
RELEASE_TAG=${{ github.event.client_payload.release_tag }}
21+
RELEASE_NAME=${{ github.event.client_payload.release_name }}
22+
RELEASE_BODY=${{ github.event.client_payload.release_body }}
23+
24+
# Create a new release using the GitHub API
25+
curl -X POST \
26+
-H "Accept: application/vnd.github.v3+json" \
27+
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
28+
-d '{
29+
"tag_name": "$RELEASE_TAG",
30+
"target_commitish": "master",
31+
"name": "$RELEASE_NAME",
32+
"body": "$RELEASE_BODY",
33+
"draft": false,
34+
"prerelease": false
35+
}' \
36+
"https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases"
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/usr/bin/env python3
2+
import sys
3+
import os.path
4+
from warnings import warn
5+
from pathlib import Path
6+
from importlib import import_module
7+
import yaml
8+
import nipype
9+
import nipype2pydra
10+
from nipype2pydra.task import TaskConverter
11+
12+
13+
SPECS_DIR = Path(__file__).parent / "specs"
14+
PKG_ROOT = Path(__file__).parent.parent
15+
PKG_NAME = "CHANGEME"
16+
17+
if ".dev" in nipype.__version__:
18+
raise RuntimeError(
19+
f"Cannot use a development version of Nipype {nipype.__version__}"
20+
)
21+
22+
if ".dev" in nipype2pydra.__version__:
23+
warn(
24+
f"using development version of nipype2pydra ({nipype2pydra.__version__}), "
25+
f"development component will be dropped in {PKG_NAME} package version"
26+
)
27+
n2p_version = nipype2pydra.__version__.split(".dev")[0]
28+
29+
auto_version = f"{nipype.__version__}.{n2p_version}"
30+
31+
32+
# Insert specs dir into path so we can load callables modules
33+
sys.path.insert(0, str(SPECS_DIR))
34+
35+
auto_init = f"# Auto-generated by {__file__}, do not edit as it will be overwritten\n\n"
36+
37+
for fspath in SPECS_DIR.glob("**/*.yaml"):
38+
with open(fspath) as f:
39+
spec = yaml.load(f, Loader=yaml.SafeLoader)
40+
41+
rel_pkg_path = str(fspath.relative_to(SPECS_DIR)).replace(os.path.sep, ".")
42+
callables = import_module(rel_pkg_path + "_callables")
43+
module_name = fspath.name.lower()
44+
45+
converter = TaskConverter(
46+
output_module=f"pydra.tasks.{PKG_NAME}.auto.{module_name}",
47+
callables_module=callables, # type: ignore
48+
**spec
49+
)
50+
converter.generate(PKG_ROOT)
51+
auto_init += f"from .{module_name} import {spec['task_name']}\n"
52+
53+
54+
with open(PKG_ROOT / "pydra" / "tasks" / PKG_NAME / "auto" / "_version.py", "w") as f:
55+
f.write(f"""# Auto-generated by {__file__}, do not edit as it will be overwritten
56+
57+
auto_version = {auto_version}
58+
""")
59+
60+
with open(PKG_ROOT / "pydra" / "tasks" / PKG_NAME / "auto" / "__init__.py", "w") as f:
61+
f.write(auto_init)

scripts/pkg_gen/resources/pkg_init.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""
2+
This is a basic doctest demonstrating that the package and pydra can both be successfully
3+
imported.
4+
5+
>>> import pydra.engine
6+
>>> import pydra.tasks.freesurfer
7+
"""
8+
try:
9+
from ._version import __version__ as main_version
10+
except ImportError:
11+
pass
12+
13+
from .auto._version import auto_version # Get version of
14+
15+
if ".dev" in main_version:
16+
main_version, dev_version = main_version.split(".dev")
17+
else:
18+
dev_version = None
19+
20+
__version__ = main_version + "." + auto_version
21+
if dev_version:
22+
__version__ += ".dev" + dev_version

0 commit comments

Comments
 (0)