diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..63df785 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git/ +.github/ +.dockerignore +Dockerfile + +*~ +*.DS_Store +*.egg-info/ +__pycache__/ + +.docker + +.idea/ +.vscode/ + +examples/ + +venv/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b600673 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,156 @@ +# Continuous integration testing for ChRIS Plugin. +# https://github.com/FNNDSC/python-chrisapp-template/wiki/Continuous-Integration +# +# - on push, release, and PR: run pytest +# - on push to main: build and push container images as ":latest" +# - on release: build and push container image with tag and +# upload plugin description to https://chrisstore.co + +name: build + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + release: + types: [ published ] + +jobs: + test: + name: Unit tests + if: false # CI disabled by default, delete this line to enable CI + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - uses: docker/setup-buildx-action@v2 + - name: Cache Docker layers + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Build + uses: docker/build-push-action@v3 + with: + build-args: extras_require=dev + context: . + load: true + push: false + tags: "localhost/local/app:dev" + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache + - name: Run pytest + run: | + docker run -v "$GITHUB_WORKSPACE:/app:ro" -w /app localhost/local/app:dev \ + pytest -o cache_dir=/tmp/pytest + + build: + name: Build + needs: [ test ] # tests must pass before build + if: github.event_name == 'push' || github.event_name == 'release' + runs-on: ubuntu-22.04 + + # A local registry helps us reuse the built image between steps + services: + registry: + image: registry:2 + ports: + - 5000:5000 + + steps: + - name: Get git tag + id: git_info + if: startsWith(github.ref, 'refs/tags/') + run: echo "::set-output name=tag::${GITHUB_REF##*/}" + - name: Get project info + id: determine + env: + git_tag: ${{ steps.git_info.outputs.tag }} + run: | + repo="${GITHUB_REPOSITORY,,}" # to lower case + # if build triggered by tag, use tag name + tag="${git_tag:-latest}" + + # if tag is a version number prefixed by 'v', remove the 'v' + if [[ "$tag" =~ ^v[0-9].* ]]; then + tag="${tag:1}" + fi + + dock_image=$repo:$tag + echo $dock_image + echo "::set-output name=dock_image::$dock_image" + echo "::set-output name=repo::$repo" + + - uses: actions/checkout@v3 + # QEMU is used for non-x86_64 builds + - uses: docker/setup-qemu-action@v2 + # buildx adds additional features to docker build + - uses: docker/setup-buildx-action@v2 + with: + driver-opts: network=host + # cache slightly improves rebuild time + - name: Cache Docker layers + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Login to DockerHub + id: dockerhub_login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v3 + id: docker_build + with: + context: . + file: ./Dockerfile + tags: | + localhost:5000/${{ steps.determine.outputs.dock_image }} + docker.io/${{ steps.determine.outputs.dock_image }} + ghcr.io/${{ steps.determine.outputs.dock_image }} + # if non-x86_84 architectures are supported, add them here + platforms: linux/amd64 #,linux/arm64,linux/ppc64le + push: true + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache + + - name: Get plugin meta + id: pluginmeta + run: | + repo=${{ steps.determine.outputs.repo }} + dock_image=${{ steps.determine.outputs.dock_image }} + docker run --rm localhost:5000/$dock_image chris_plugin_info > /tmp/description.json + jq < /tmp/description.json # pretty print in log + echo "::set-output name=title::$(jq -r '.title' < /tmp/description.json)" + + - name: Update DockerHub description + uses: peter-evans/dockerhub-description@v2 + continue-on-error: true # it is not crucial that this works + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + short-description: ${{ steps.pluginmeta.outputs.title }} + readme-filepath: ./README.md + repository: ${{ steps.determine.outputs.repo }} + + - name: Upload to ChRIS Store + if: github.event_name == 'release' + uses: FNNDSC/chrisstore-action@master + with: + descriptor_file: /tmp/description.json + auth: ${{ secrets.CHRIS_STORE_USER }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffd9fdb --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*~ +*.DS_Store +*.egg-info/ +__pycache__/ + +.docker + +.idea/ +.vscode/ + +venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5341d02 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Python version can be changed, e.g. +# FROM python:3.8 +# FROM docker.io/fnndsc/conda:python3.10.2-cuda11.6.0 +FROM docker.io/python:3.10.6-slim-bullseye + +LABEL org.opencontainers.image.authors="FNNDSC " \ + org.opencontainers.image.title="ChRIS Plugin Title" \ + org.opencontainers.image.description="A ChRIS plugin that..." + +WORKDIR /usr/local/src/app + +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . +ARG extras_require=none +RUN pip install ".[${extras_require}]" + +CMD ["commandname", "--help"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..205de68 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 FNNDSC / BCH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf3c2be --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# _ChRIS_ Plugin Template + +This is a minimal template repository for _ChRIS_ plugin applications in Python. + +## About _ChRIS_ Plugins + +A _ChRIS_ plugin is a scientific data-processing software which can run anywhere all-the-same: +in the cloud via a [web app](https://github.com/FNNDSC/ChRIS_ui/), or on your own laptop +from the terminal. They are easy to build and easy to understand: most simply, a +_ChRIS_ plugin is a command-line program which processes data from an input directory +and creates data to an output directory with the usage +`commandname [options...] inputdir/ outputdir/`. + +For more information, visit our website https://chrisproject.org + +## How to Use This Template + +Go to https://github.com/FNNDSC/python-chrisapp-template and click "Use this template". +The newly created repository is ready to use right away. + +A script `bootstrap.sh` is provided to help fill in and rename values for your new project. +It is optional to use. + +1. Edit the variables in `bootstrap.sh` +2. Run `./bootstrap.sh` +3. Follow the instructions it will print out + +## Example Plugins + +Here are some good, complete examples of _ChRIS_ plugins created from this template. + +- https://github.com/FNNDSC/pl-dcm2niix (basic command example) +- https://github.com/FNNDSC/pl-mri-preview (uses [NiBabel](https://nipy.org/nibabel/)) +- https://github.com/FNNDSC/pl-fetal-cp-surface-extract (example using Python package project structure) + +## What's Inside + +| Path | Purpose | +|----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `app.py` | Main script: start editing here! | +| `tests/` | Unit tests | +| `setup.py` | [Python project metadata and installation script](https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/#setup-py) | +| `requirements.txt` | List of Python dependencies | +| `Dockerfile` | [Container image build recipe](https://docs.docker.com/engine/reference/builder/) | +| `.github/workflows/ci.yml` | "continuous integration" using [Github Actions](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions): automatic testing, building, and uploads to https://chrisstore.co | + + + \ No newline at end of file diff --git a/app.py b/app.py new file mode 100755 index 0000000..d8dfc6f --- /dev/null +++ b/app.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python + +from pathlib import Path +from argparse import ArgumentParser, Namespace, ArgumentDefaultsHelpFormatter +from importlib.metadata import Distribution + +from chris_plugin import chris_plugin + +__pkg = Distribution.from_name(__package__) +__version__ = __pkg.version + + +DISPLAY_TITLE = r""" +ChRIS Plugin Template Title +""" + + +parser = ArgumentParser(description='cli description', + formatter_class=ArgumentDefaultsHelpFormatter) +parser.add_argument('-n', '--name', default='foo', + help='argument which sets example output file name') +parser.add_argument('-V', '--version', action='version', + version=f'%(prog)s {__version__}') + + +# documentation: https://fnndsc.github.io/chris_plugin/chris_plugin.html#chris_plugin +@chris_plugin( + parser=parser, + title='My ChRIS plugin', + category='', # ref. https://chrisstore.co/plugins + min_memory_limit='100Mi', # supported units: Mi, Gi + min_cpu_limit='1000m', # millicores, e.g. "1000m" = 1 CPU core + min_gpu_limit=0 # set min_gpu_limit=1 to enable GPU +) +def main(options: Namespace, inputdir: Path, outputdir: Path): + print(DISPLAY_TITLE) + + output_file = outputdir / f'{options.name}.txt' + output_file.write_text('did nothing successfully!') + + +if __name__ == '__main__': + main() diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..277b7b2 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# bootstrap.sh: customize python-chrisapp-template with project details + +# ======================================== +# CONFIGURATION +# ======================================== + +# STEP 1. Change these values to your liking. + +PLUGIN_NAME="$(basename $(dirname $(realpath $0)))" # name of current directory +PLUGIN_TITLE='My ChRIS Plugin' +SCRIPT_NAME='commandname' +DESCRIPTION='A ChRIS plugin to do something awesome' +ORGANIZATION='FNNDSC' +EMAIL='dev@babyMRI.org' + +# Enables automatic testing, building, and release. +# You are advised to review the file .github/workflows/ci.yml +# https://github.com/FNNDSC/python-chrisapp-template/wiki/Continuous-Integration#use-ci +ENABLE_CI=yes + +# STEP 2. Uncomment the line below. + +#READY=yes + +# STEP 3. Run: ./bootstrap.sh + +if ! [ "$READY" = 'yes' ]; then + >&2 echo "error: you are not READY." + exit 1 +fi + +cd $(dirname "$0") + + +# ======================================== +# VALIDATE INPUT +# ======================================== + +function contains_invalid_characters () { + [[ "$1" = *"/"* ]] +} + +# given a variable name, exit if the variable's value contains invalid characters. +function check_variable_value_valid () { + local varname="$1" + local varvalue="${!varname}" + if contains_invalid_characters "$varvalue"; then + >&2 echo "error: invalid characters in $varname=$varvalue" + exit 1 + fi +} + +# may not contain '/' +check_variable_value_valid PLUGIN_NAME +check_variable_value_valid SCRIPT_NAME +check_variable_value_valid ORGANIZATION +check_variable_value_valid EMAIL + + +# ======================================== +# COMMIT THE USER-SET CONFIG +# ======================================== + +# print command to run before running it +function verb () { + set -x + "$@" + { set +x; } 2> /dev/null +} + +# fail on error +set -e +set -o pipefail + +verb git commit -m 'Configure python-chrisapp-template/bootstrap.sh' -- "$0" + + +# ======================================== +# REPLACE VALUES +# ======================================== + +# execute sed on all files in project, excluding hidden paths and venv/ +function replace_in_all () { + if [ -z "$2" ]; then + return + fi + find . -type f \ + -not -path '*/\.*/*' -not -path '*/\venv/*' -not -name 'bootstrap.sh' \ + -exec sed -i -e "s/$1/$2/g" '{}' \; +} + +replace_in_all commandname "$SCRIPT_NAME" +replace_in_all pl-appname "$PLUGIN_NAME" +replace_in_all 'dev@babyMRI.org' "$EMAIL" +replace_in_all FNNDSC "$ORGANIZATION" + +# .github/ +sed -i -e '/CI disabled by default, delete this line/d' .github/workflows/ci.yml + +# replace "/" with "\/" in string +function escape_slashes () { + sed 's/\//\\&/g' <<< "$@" +} + +escaped_description="$(escape_slashes "$DESCRIPTION")" +escaped_title="$(escape_slashes "$PLUGIN_TITLE")" + +# README.md +temp_file=$(mktemp) +sed -e'/^# ChRIS Plugin Title$/'\{ -e:1 -en\;b1 -e\} -ed README.md \ + | sed "s/^# ChRIS Plugin Title\$/# $escaped_title/" \ + | sed '/^END README TEMPLATE -->$/d' \ + | sed "s/fnndsc/${ORGANIZATION,,}/g" \ + | sed "s/app\\.py/$SCRIPT_NAME.py/g" \ + > $temp_file +mv $temp_file README.md + +# Dockerfile +sed "s#WORKDIR /usr/local/src/app#WORKDIR /usr/local/src/$PLUGIN_NAME#" Dockerfile \ + | sed "s/org\.opencontainers\.image\.title=\"ChRIS Plugin Title\"/org.opencontainers.image.title=\"$escaped_title\"/" \ + | sed "s/org\.opencontainers\.image\.description=\"A ChRIS plugin that\.\.\.\"/org.opencontainers.image.description=\"$escaped_description\"/" \ + > $temp_file +mv $temp_file Dockerfile + +# setup.py + +function guess_https_url () { + local origin="$(git remote get-url origin)" + local https_url="$origin" + if [[ "$https_url" = "git@"* ]]; then + # convert SSH url to HTTPS url by + # 1. change last ':' to '/' + # 2. replace leading 'git@' with 'https://' + https_url="$( + echo "$https_url" \ + | sed 's#\(.*\):#\1/#' \ + | sed 's#^git@#https://#' + )" + fi + echo "${https_url:0:-4}" # remove trailing ".git" +} + +appname_without_prefix="$(sed -E 's/(pl|dbg|ep)-//' <<< "$PLUGIN_NAME")" +sed "s/name='.*'/name='$appname_without_prefix'/" setup.py \ + | sed "s/description='.*'/description='$escaped_description'/" \ + | sed "s/py_modules=\['app'\]/py_modules=['$SCRIPT_NAME']/" \ + | sed "s/app:main/$SCRIPT_NAME:main/" \ + | sed "s#url='.*'#url='$(guess_https_url)'#" \ + > $temp_file +mv $temp_file setup.py + +# app.py + +# FIGlet over HTTPS, since it's probably not installed locally +function figlet_wrapper () { + curl -fsSG 'https://figlet.chrisproject.org/' --data-urlencode "message=$*" \ + | grep -v '^[[:space:]]*$' +} + +function inject_figleted_title () { + python << EOF +for line in open('app.py'): + if line == 'ChRIS Plugin Template Title\n': + print(r"""$1""") + else: + print(line, end='') +EOF +} + +figleted_title="$(figlet_wrapper "$PLUGIN_NAME")" +echo "$figleted_title" +inject_figleted_title "$figleted_title" \ + | sed "s/title='My ChRIS plugin'/title='$escaped_title'/" \ + | sed "s/description='cli description'/description='$escaped_description'/" \ + > "$SCRIPT_NAME.py" +rm app.py + +# tests/ +for test_file in tests/*.py; do + sed "s/from app import/from $SCRIPT_NAME import/" $test_file > $temp_file + mv $temp_file $test_file +done + +# ======================================== +# SETUP +# ======================================== + +if ! [ -e venv ]; then + verb python -m venv venv +fi + +>&2 echo + source venv/bin/activate +source venv/bin/activate +verb pip install -r requirements.txt +verb pip install -e '.[dev]' + +tput bold +>&2 printf '\n%s\n\n' '✨Done!✨' +tput sgr0 + +tput setaf 3 +>&2 echo 'To undo these actions and start over, run:' +>&2 printf '\n\t%s\n\t%s\n\t%s\n\t%s\n\n' \ + 'git reset --hard' \ + 'git clean -df' \ + 'rm -rf venv *.egg-info' \ + "git reset 'HEAD^'" +tput setaf 6 +>&2 echo 'Activate the Python virtual environment by running:' +>&2 printf '\n\t%s\n\n' 'source venv/bin/activate' +>&2 echo 'Save these changes by running:' +>&2 printf '\n\t%s\n\n' 'git add -A && git commit -m "Run bootstrap.sh"' +tput setaf 2 +echo 'For more information on how to get started, see README.md' +tput sgr0 + +verb rm -v "$0" + +# Note to self: consider rewriting this in Python? diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8eef26a --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +chris_plugin==0.1.1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e8a9b28 --- /dev/null +++ b/setup.py @@ -0,0 +1,31 @@ +from setuptools import setup + +setup( + name='chris-plugin-template', + version='1.0.0', + description='A ChRIS DS plugin template', + author='FNNDSC', + author_email='dev@babyMRI.org', + url='https://github.com/FNNDSC/python-chrisapp-template', + py_modules=['app'], + install_requires=['chris_plugin'], + license='MIT', + entry_points={ + 'console_scripts': [ + 'commandname = app:main' + ] + }, + classifiers=[ + 'License :: OSI Approved :: MIT License', + 'Topic :: Scientific/Engineering', + 'Topic :: Scientific/Engineering :: Bio-Informatics', + 'Topic :: Scientific/Engineering :: Medical Science Apps.' + ], + extras_require={ + 'none': [], + 'dev': [ + 'pytest~=7.1', + 'pytest-mock~=3.8' + ] + } +) diff --git a/tests/test_example.py b/tests/test_example.py new file mode 100644 index 0000000..f9dbfcb --- /dev/null +++ b/tests/test_example.py @@ -0,0 +1,23 @@ +from pathlib import Path + +from app import parser, main, DISPLAY_TITLE + + +def test_main(mocker, tmp_path: Path): + """ + Simulated test run of the app. + """ + inputdir = tmp_path / 'incoming' + outputdir = tmp_path / 'outgoing' + inputdir.mkdir() + outputdir.mkdir() + + options = parser.parse_args(['--name', 'bar']) + + mock_print = mocker.patch('builtins.print') + main(options, inputdir, outputdir) + mock_print.assert_called_once_with(DISPLAY_TITLE) + + expected_output_file = outputdir / 'bar.txt' + assert expected_output_file.exists() + assert expected_output_file.read_text() == 'did nothing successfully!'