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..966e0c5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,183 @@ +# Continuous integration testing for ChRIS Plugin. +# https://github.com/FNNDSC/python-chrisapp-template/wiki/Continuous-Integration +# +# - on push and PR: run pytest +# - on push to main: build and push container images as ":latest" +# - on push to semver tag: build and push container image with tag and +# upload plugin description to https://chrisstore.co + +name: build + +on: + push: + branches: [ main ] + tags: + - "v?[0-9]+.[0-9]+.[0-9]+*" + pull_request: + branches: [ main ] + +jobs: + test: + name: Unit tests + if: false # delete this line to enable automatic testing + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build + uses: docker/build-push-action@v5 + with: + build-args: extras_require=dev + context: . + load: true + push: false + tags: "localhost/local/app:dev" + cache-from: type=gha + cache-to: type=gha,mode=max + - 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 + if: false # delete this line and uncomment the line below to enable automatic builds + # if: github.event_name == 'push' || github.event_name == 'release' + # needs: [ test ] # uncomment to require passing tests + runs-on: ubuntu-22.04 + + steps: + - name: Decide image tags + id: info + shell: python + run: | + import os + import itertools + + def join_tag(t): + registry, repo, tag = t + return f'{registry}/{repo}:{tag}'.lower() + + registries = ['docker.io', 'ghcr.io'] + repos = ['${{ github.repository }}'] + if '${{ github.ref_type }}' == 'branch': + tags = ['latest'] + elif '${{ github.ref_type }}' == 'tag': + tag = '${{ github.ref_name }}' + version = tag[1:] if tag.startswith('v') else tag + tags = ['latest', version] + else: + tags = [] + + if '${{ github.ref_type }}' == 'tag': + local_tag = join_tag(('ghcr.io', '${{ github.repository }}', version)) + else: + local_tag = join_tag(('localhost', '${{ github.repository }}', 'latest')) + + product = itertools.product(registries, repos, tags) + tags_csv = ','.join(map(join_tag, product)) + outputs = { + 'tags_csv' : tags_csv, + 'push' : 'true' if tags_csv else 'false', + 'local_tag': local_tag + } + with open(os.environ['GITHUB_OUTPUT'], 'a') as out: + for k, v in outputs.items(): + out.write(f'{k}={v}\n') + + - uses: actions/checkout@v4 + # QEMU is used for non-x86_64 builds + - uses: docker/setup-qemu-action@v3 + # buildx adds additional features to docker build + - uses: docker/setup-buildx-action@v3 + with: + driver-opts: network=host + + # Here, we want to do the docker build twice: + # The first build pushes to our local registry for testing. + # The second build pushes to Docker Hub and ghcr.io + - name: Build (local only) + uses: docker/build-push-action@v3 + id: docker_build + with: + context: . + file: ./Dockerfile + tags: ${{ steps.info.outputs.local_tag }} + load: true + cache-from: type=gha + # If you have a directory called examples/incoming/ and examples/outgoing/, then + # run your ChRIS plugin with no parameters, and assert that it creates all the files + # which are expected. File contents are not compared. + - name: Run examples + id: run_examples + run: | + if ! [ -d 'examples/incoming/' ] || ! [ -d 'examples/outgoing/' ]; then + echo "No examples." + exit 0 + fi + + dock_image=${{ steps.info.outputs.local_tag }} + output_dir=$(mktemp -d) + cmd=$(docker image inspect -f '{{ (index .Config.Cmd 0) }}' $dock_image) + docker run --rm -u "$(id -u):$(id -g)" \ + -v "$PWD/examples/incoming:/incoming:ro" \ + -v "$output_dir:/outgoing:rw" \ + $dock_image $cmd /incoming /outgoing + + for expected_file in $(find examples/outgoing -type f); do + fname="${expected_file##*/}" + out_path="$output_dir/$fname" + printf "Checking output %s exists..." "$out_path" + if [ -f "$out_path" ]; then + echo "ok" + else + echo "not found" + exit 1 + fi + done + + - name: Login to DockerHub + if: (github.event_name == 'push' || github.event_name == 'release') && contains(steps.info.outputs.tags_csv, 'docker.io') + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + - name: Login to GitHub Container Registry + if: (github.event_name == 'push' || github.event_name == 'release') && contains(steps.info.outputs.tags_csv, 'ghcr.io') + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v5 + if: (github.event_name == 'push' || github.event_name == 'release') + with: + context: . + file: ./Dockerfile + tags: ${{ steps.info.outputs.tags_csv }} + # if non-x86_84 architectures are supported, add them here + platforms: linux/amd64 #,linux/arm64,linux/ppc64le + push: ${{ steps.info.outputs.push }} + cache-to: type=gha,mode=max + + - name: Upload ChRIS Plugin + id: upload + if: github.ref_type == 'tag' + uses: FNNDSC/upload-chris-plugin@v1 + with: + dock_image: ${{ steps.info.outputs.local_tag }} + username: ${{ secrets.CHRISPROJECT_USERNAME }} + password: ${{ secrets.CHRISPROJECT_PASSWORD }} + chris_url: https://cube.chrisproject.org/api/v1/ + compute_names: NERC + + - name: Update DockerHub description + if: steps.upload.outcome == 'success' + uses: peter-evans/dockerhub-description@v3 + continue-on-error: true # it is not crucial that this works + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + short-description: ${{ steps.upload.outputs.title }} + readme-filepath: ./README.md 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..b3fd92b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# Python version can be changed, e.g. +# FROM python:3.8 +# FROM ghcr.io/mamba-org/micromamba:1.5.1-focal-cuda-11.3.1 +FROM docker.io/python:3.12.1-slim-bookworm + +LABEL org.opencontainers.image.authors="FNNDSC " \ + org.opencontainers.image.title="ChRIS Plugin Title" \ + org.opencontainers.image.description="A ChRIS plugin that..." + +ARG SRCDIR=/usr/local/src/app +WORKDIR ${SRCDIR} + +COPY requirements.txt . +RUN --mount=type=cache,sharing=private,target=/root/.cache/pip pip install -r requirements.txt + +COPY . . +ARG extras_require=none +RUN pip install ".[${extras_require}]" \ + && cd / && rm -rf ${SRCDIR} +WORKDIR / + +CMD ["commandname"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d3ffa30 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 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..611ea6c --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# _ChRIS_ Plugin Template + +[![test status](https://github.com/FNNDSC/python-chrisapp-template/actions/workflows/src.yml/badge.svg)](https://github.com/FNNDSC/python-chrisapp-template/actions/workflows/src.yml) +[![MIT License](https://img.shields.io/github/license/FNNDSC/python-chrisapp-template)](LICENSE) + +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 wrapper example) +- (parallelizes a command) +- https://github.com/FNNDSC/pl-mri-preview (uses [NiBabel](https://nipy.org/nibabel/)) +- https://github.com/FNNDSC/pl-pyvista-volume (example using Python package project structure and pytest) +- https://github.com/FNNDSC/pl-fetal-cp-surface-extract (has a good README.md) + +## 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 | + +## Contributing + +The source code for the `main` branch of this repository is on the +[src](https://github.com/fnndsc/python-chrisapp-template/tree/src) +branch, which has an additional file +[`.github/workflows/src.yml`](https://github.com/FNNDSC/python-chrisapp-template/blob/src/.github/workflows/src.yml) +When tests pass, changes are automatically merged into `main`. +Developers should commit to or make pull requests targeting `src`. +Do not push directly to `main`. + +This is a workaround in order to do automatic testing of this template +without including the `.github/workflows/src.yml` file in the template itself. + + diff --git a/app.py b/app.py new file mode 100755 index 0000000..68e8e68 --- /dev/null +++ b/app.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python + +from pathlib import Path +from argparse import ArgumentParser, Namespace, ArgumentDefaultsHelpFormatter + +from chris_plugin import chris_plugin, PathMapper + +__version__ = '1.0.0' + +DISPLAY_TITLE = r""" +ChRIS Plugin Template Title +""" + + +parser = ArgumentParser(description='!!!CHANGE ME!!! An example ChRIS plugin which ' + 'counts the number of occurrences of a given ' + 'word in text files.', + formatter_class=ArgumentDefaultsHelpFormatter) +parser.add_argument('-w', '--word', required=True, type=str, + help='word to count') +parser.add_argument('-p', '--pattern', default='**/*.txt', type=str, + help='input file filter glob') +parser.add_argument('-V', '--version', action='version', + version=f'%(prog)s {__version__}') + + +# The main function of this *ChRIS* plugin is denoted by this ``@chris_plugin`` "decorator." +# Some metadata about the plugin is specified here. There is more metadata specified in setup.py. +# +# 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): + """ + *ChRIS* plugins usually have two positional arguments: an **input directory** containing + input files and an **output directory** where to write output files. Command-line arguments + are passed to this main method implicitly when ``main()`` is called below without parameters. + + :param options: non-positional arguments parsed by the parser given to @chris_plugin + :param inputdir: directory containing (read-only) input files + :param outputdir: directory where to write output files + """ + + print(DISPLAY_TITLE) + + # Typically it's easier to think of programs as operating on individual files + # rather than directories. The helper functions provided by a ``PathMapper`` + # object make it easy to discover input files and write to output files inside + # the given paths. + # + # Refer to the documentation for more options, examples, and advanced uses e.g. + # adding a progress bar and parallelism. + mapper = PathMapper.file_mapper(inputdir, outputdir, glob=options.pattern, suffix='.count.txt') + for input_file, output_file in mapper: + # The code block below is a small and easy example of how to use a ``PathMapper``. + # It is recommended that you put your functionality in a helper function, so that + # it is more legible and can be unit tested. + data = input_file.read_text() + frequency = data.count(options.word) + output_file.write_text(str(frequency)) + + +if __name__ == '__main__': + main() diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..6350aec --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env bash +# bootstrap.sh: customize python-chrisapp-template with project details +# +# WARNING: This script is for advanced users only! Do not proceed +# unless you understand what this does. New developers would find +# it easier to use python-chrisapp-template as is. Simply ignore +# and optionally delete this file. + +# ======================================== +# 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' + +# Github Actions: automatically test and build your code. +# https://github.com/FNNDSC/python-chrisapp-template/wiki/Continuous-Integration +# +# These options will fail unless your Github settings are preconfigured. +# Repositories under github.com/FNNDSC are preconfigured, so these defaults might work. +# Please review the file .github/workflows/ci.yml before you push it. + +# Automatically test on Github Actions each time you run `git push` +# If the value is "no" then tests are not performed. There are no side effects. +ENABLE_ACTIONS_TEST=yes +# Automatically build images on Github Actions each time you run `git push`, +# and also publish to https://chrisstore.co each time you run `git push --tags` +# If the value is "no" then builds will not be automated. +ENABLE_ACTIONS_BUILD=yes + +# WARNING: the default configuration in .github/workflows/ci.yml is to allow for +# the build to proceed regardless of whether tests pass. To modify this behavior +# and other advanced features (such as multi-architecture builds such as arm64, ppc64le) +# you must edit .github/workflows/ci.yml by hand. + + +# ---------------------------------------- +# STEP 2. Uncomment the line where it says READY=yes +# ---------------------------------------- + +#READY=yes + +# ---------------------------------------- +# STEP 3. Run: ./bootstrap.sh +# ---------------------------------------- + + +if [ "$(uname -o 2> /dev/null)" != 'GNU/Linux' ]; then + >&2 echo "error: this script only works on GNU/Linux." +fi + +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/ +if [ "${ENABLE_ACTIONS_TEST,,}" = 'yes' ]; then + sed -i -e '/delete this line to enable automatic testing/d' .github/workflows/ci.yml +fi + +if [ "${ENABLE_ACTIONS_BUILD,,}" = 'yes' ]; then + sed -i -e '/delete this line and uncomment the line below to enable automatic builds/d' .github/workflows/ci.yml + sed -i -e 's/# *if: github\.event_name/if: github\.event_name/' .github/workflows/ci.yml +fi + +# 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#ARG SRCDIR=/usr/local/src/app#ARG SRCDIR=/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)'#" \ + | sed "s/app\.py/$SCRIPT_NAME.py/" \ + > $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]' + + +if [ -z "$TERM" ]; then + tput=tput +else + tput=true +fi + +$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..645d77e --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +chris_plugin==0.4.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..240b0dd --- /dev/null +++ b/setup.py @@ -0,0 +1,48 @@ +from setuptools import setup +import re + +_version_re = re.compile(r"(?<=^__version__ = (\"|'))(.+)(?=\"|')") + +def get_version(rel_path: str) -> str: + """ + Searches for the ``__version__ = `` line in a source code file. + + https://packaging.python.org/en/latest/guides/single-sourcing-package-version/ + """ + with open(rel_path, 'r') as f: + matches = map(_version_re.search, f) + filtered = filter(lambda m: m is not None, matches) + version = next(filtered, None) + if version is None: + raise RuntimeError(f'Could not find __version__ in {rel_path}') + return version.group(0) + + +setup( + name='chris-plugin-template', + version=get_version('app.py'), + 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' + ] + } +) diff --git a/tests/test_example.py b/tests/test_example.py new file mode 100644 index 0000000..83d3846 --- /dev/null +++ b/tests/test_example.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from app import parser, main + + +def test_main(tmp_path: Path): + # setup example data + inputdir = tmp_path / 'incoming' + outputdir = tmp_path / 'outgoing' + inputdir.mkdir() + outputdir.mkdir() + (inputdir / 'plaintext.txt').write_text('hello ChRIS, I am a ChRIS plugin') + + # simulate run of main function + options = parser.parse_args(['--word', 'ChRIS', '--pattern', '*.txt']) + main(options, inputdir, outputdir) + + # assert behavior is expected + expected_output_file = outputdir / 'plaintext.count.txt' + assert expected_output_file.exists() + assert expected_output_file.read_text() == '2'