Repository: AMYPAD/AmyPET (language: python)

Issue:
GUI launch error
```python
Traceback (most recent call last):
  ...
  File "\AppData\Local\Continuum\anaconda3\lib\site-packages\amypad\gui.py", line 278, in main
    import miutil.cuinfo
  File "\AppData\Local\Continuum\anaconda3\lib\site-packages\miutil\__init__.py", line 1, in <module>
    from .fdio import *  # NOQA
  File "\AppData\Local\Continuum\anaconda3\lib\site-packages\miutil\fdio.py", line 22, in <module>
    from tqdm.utils import CallbackIOWrapper
ImportError: cannot import name 'CallbackIOWrapper' from 'tqdm.utils' (\AppData\Local\Continuum\anaconda3\lib\site-packages\tqdm\utils.py)
```

- [x] https://github.com/AMYPAD/miutil needs to specify [tqdm>=4.40.0](https://tqdm.github.io/releases/#v4400-2019-12-01)
- [ ] bump `miutil>=0.8.0`


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/comment-bot.yml b/.github/workflows/comment-bot.yml
new file mode 100644
index 00000000..b44ee7ba
--- /dev/null
+++ b/.github/workflows/comment-bot.yml
@@ -0,0 +1,52 @@
+name: Comment Bot
+on:
+  issue_comment:
+    types: [created]
+  pull_request_review_comment:
+    types: [created]
+jobs:
+  tag:  # /tag <tagname> <commit>
+    if: startsWith(github.event.comment.body, '/tag ')
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v2
+    - name: React Seen
+      uses: actions/github-script@v2
+      with:
+        script: |
+          const perm = await github.repos.getCollaboratorPermissionLevel({
+            owner: context.repo.owner, repo: context.repo.repo,
+            username: context.payload.comment.user.login})
+          post = (context.eventName == "issue_comment"
+            ? github.reactions.createForIssueComment
+            : github.reactions.createForPullRequestReviewComment)
+          if (!["admin", "write"].includes(perm.data.permission)){
+            post({
+              owner: context.repo.owner, repo: context.repo.repo,
+              comment_id: context.payload.comment.id, content: "laugh"})
+            throw "Permission denied for user " + context.payload.comment.user.login
+          }
+          post({
+            owner: context.repo.owner, repo: context.repo.repo,
+            comment_id: context.payload.comment.id, content: "eyes"})
+        github-token: ${{ secrets.GH_TOKEN }}
+    - name: Tag Commit
+      run: |
+        git clone https://${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY} repo
+        git -C repo tag $(echo "$BODY" | awk '{print $2" "$3}')
+        git -C repo push --tags
+        rm -rf repo
+      env:
+        BODY: ${{ github.event.comment.body }}
+        GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
+    - name: React Success
+      uses: actions/github-script@v2
+      with:
+        script: |
+          post = (context.eventName == "issue_comment"
+            ? github.reactions.createForIssueComment
+            : github.reactions.createForPullRequestReviewComment)
+          post({
+            owner: context.repo.owner, repo: context.repo.repo,
+            comment_id: context.payload.comment.id, content: "rocket"})
+        github-token: ${{ secrets.GH_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 753afde1..3eacabe1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,11 @@
-*.py[cod]
+*.py[co]
 __pycache__/
+
+# build
 /amypad/_dist_ver.py
-/.eggs/
-/*.egg-info/
 /build/
 /dist/
+/*.egg*/
+
 /.coverage*
 /coverage.xml
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 00000000..afba6eee
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,46 @@
+default_language_version:
+  python: python3
+repos:
+- repo: https://github.com/pre-commit/pre-commit-hooks
+  rev: v4.0.1
+  hooks:
+  - id: check-added-large-files
+  - id: check-case-conflict
+  - id: check-docstring-first
+  - id: check-executables-have-shebangs
+  - id: check-toml
+  - id: check-merge-conflict
+  - id: check-yaml
+  - id: debug-statements
+  - id: end-of-file-fixer
+  - id: mixed-line-ending
+  - id: sort-simple-yaml
+  - id: trailing-whitespace
+- repo: local
+  hooks:
+  - id: todo
+    name: Check TODO
+    language: pygrep
+    args: [-i]
+    entry: TODO
+    types: [text]
+    exclude: ^(.pre-commit-config.yaml|.github/workflows/test.yml)$
+- repo: https://gitlab.com/pycqa/flake8
+  rev: 3.9.2
+  hooks:
+  - id: flake8
+    args: [-j8]
+    additional_dependencies:
+    - flake8-bugbear
+    - flake8-comprehensions
+    - flake8-debugger
+    - flake8-string-format
+- repo: https://github.com/google/yapf
+  rev: v0.31.0
+  hooks:
+  - id: yapf
+    args: [-i]
+- repo: https://github.com/PyCQA/isort
+  rev: 5.9.3
+  hooks:
+  - id: isort
diff --git a/LICENCE.md b/LICENCE.md
index 957e9cbb..53871088 100644
--- a/LICENCE.md
+++ b/LICENCE.md
@@ -1,13 +1,7 @@
-Copyright 2020 AMYPAD
+Copyright (c) 2021 AMYPAD
 
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
+Mozilla Public License Version 2.0 (MPL-2.0)
 
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
+This Source Code Form is subject to the terms of the Mozilla Public
+License, v. 2.0. If a copy of the MPL was not distributed with this
+file, You can obtain one at http://mozilla.org/MPL/2.0/.
diff --git a/README.rst b/README.rst
index b6b87b51..29fdb60a 100644
--- a/README.rst
+++ b/README.rst
@@ -3,7 +3,28 @@
 AMYPAD
 ======
 
-Amyloid imaging to prevent Alzheimer's Disease
+Amyloid imaging to prevent Alzheimer's Disease.
+
+
+Install
+-------
+
+Choose one of the following:
+
+.. code:: sh
+
+    pip install amypad  # command line interface (CLI) version
+    pip install amypad[gui]  # CLI and graphical user interface (GUI) version
+
+
+Usage
+-----
+
+.. code:: sh
+
+    amypad --help  # CLI version
+    amypad.gui     # GUI version
+
 
 .. |Logo| image:: https://amypad.eu/wp-content/themes/AMYPAD/images/AMYPAD_Logo.jpg
    :target: https://amypad.eu
diff --git a/amypad/__init__.py b/amypad/__init__.py
index 812265af..7b1b7acb 100644
--- a/amypad/__init__.py
+++ b/amypad/__init__.py
@@ -1,3 +1,7 @@
+from pathlib import Path
+
+from pkg_resources import DistributionNotFound, get_distribution
+
 # version detector. Precedence: installed dist, git, 'UNKNOWN'
 try:
     from ._dist_ver import __version__
@@ -8,3 +12,11 @@
         __version__ = get_version(root="..", relative_to=__file__)
     except (ImportError, LookupError):
         __version__ = "UNKNOWN"
+
+try:
+    __licence__ = get_distribution("amypad").get_metadata("LICENCE.md")
+except (DistributionNotFound, FileNotFoundError):
+    try:
+        __licence__ = (Path(__file__).parent.parent / "LICENCE.md").read_text()
+    except FileNotFoundError:
+        __licence__ = "MPL-2.0"
diff --git a/amypad/__main__.py b/amypad/__main__.py
new file mode 100755
index 00000000..3bb6d4b2
--- /dev/null
+++ b/amypad/__main__.py
@@ -0,0 +1,4 @@
+#!/usr/bin/env python3
+from .cli import main
+
+main()
diff --git a/amypad/cli.py b/amypad/cli.py
new file mode 100755
index 00000000..7a3c47b1
--- /dev/null
+++ b/amypad/cli.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python3
+import sys
+from functools import partial
+
+if "--ignore-gooey" not in sys.argv:
+    sys.argv.insert(1, "--ignore-gooey") # must be before gooey import
+from .gui import main as gui_main
+
+main = partial(gui_main, gui_mode=False)
+
+if __name__ == "__main__": # pragma: no cover
+    main()
diff --git a/amypad/config_icon.png b/amypad/config_icon.png
new file mode 100644
index 00000000..5e8a23a5
Binary files /dev/null and b/amypad/config_icon.png differ
diff --git a/amypad/gui.py b/amypad/gui.py
new file mode 100755
index 00000000..d819d8c1
--- /dev/null
+++ b/amypad/gui.py
@@ -0,0 +1,352 @@
+#!/usr/bin/env python3
+import logging
+import re
+import sys
+from argparse import SUPPRESS, ArgumentParser, RawDescriptionHelpFormatter
+from functools import partial
+from pathlib import Path
+from subprocess import PIPE, Popen
+from textwrap import dedent
+from weakref import WeakSet
+
+import shtab
+from argopt import argopt
+from pkg_resources import resource_filename
+
+try:
+    from . import __licence__, __version__
+except ImportError:
+    __version__, __licence__ = "", "MPL-2.0"
+
+log = logging.getLogger(__name__)
+WIDGETS = (
+    "FileChooser",
+    "MultiFileChooser",
+    "DirChooser",
+    "FileSaver",
+    "MultiFileSaver",
+    "Slider",
+)
+RE_DEFAULT = re.compile(f"\\[default: (None:.*?|{'|'.join(WIDGETS)})\\]", flags=re.M)
+RE_PRECOLON = re.compile(r"^\s*:\s*", flags=re.M)
+ENCODING = sys.getfilesystemencoding()
+
+
+def patch_argument_kwargs(kwargs, gooey=True):
+    kwargs = kwargs.copy()
+    if "help" in kwargs:
+        kwargs["help"] = RE_PRECOLON.sub("", RE_DEFAULT.sub("", kwargs["help"]))
+
+    default = kwargs.get("default", None)
+    if default in WIDGETS:
+        if gooey:
+            kwargs["widget"] = default
+        kwargs["default"] = None
+    elif gooey:
+        typ = kwargs.get("type", None)
+        if typ == open:
+            nargs = kwargs.get("nargs", 1)
+            if nargs and (nargs > 1 if isinstance(nargs, int) else nargs in "+*"):
+                kwargs['widget'] = "MultiFileChooser"
+            else:
+                kwargs['widget'] = "FileChooser"
+        elif typ == int:
+            kwargs['widget'] = "IntegerField"
+            kwargs['gooey_options'] = {'min': 0, 'max': 1_000}
+        elif typ == float:
+            kwargs['widget'] = "DecimalField"
+            kwargs['gooey_options'] = {'min': -1, 'max': 1e3, 'increment': 1e-6}
+
+    return kwargs
+
+
+try:
+    from gooey import Gooey
+    from gooey import GooeyParser as BaseParser
+
+    patch_argument_kwargs = partial(patch_argument_kwargs, gooey=True)
+except ImportError:
+    BaseParser = ArgumentParser
+    patch_argument_kwargs = partial(patch_argument_kwargs, gooey=False)
+
+    def Gooey(**_):
+        def wrapper(func):
+            return func
+
+        return wrapper
+
+
+class MyParser(BaseParser):
+    def add_argument(self, *args, **kwargs):
+        kwargs = patch_argument_kwargs(kwargs)
+        log.debug("%r, %r", args, kwargs)
+        return super(MyParser, self).add_argument(*args, **kwargs)
+
+
+class CmdException(Exception):
+    def __init__(self, code, cmd, stdout, stderr):
+        super(CmdException, self).__init__(
+            dedent("""\
+                Code {:d}:
+                === command ===
+                {}
+                === stderr ===
+                {}=== stdout ===
+                {}===""").format(code, cmd, stderr, stdout))
+
+
+class Base(object):
+    _instances = WeakSet()
+
+    def __init__(self, python_deps=None, matlab_deps=None, version=__version__):
+        self.python_deps = python_deps or []
+        self.matlab_deps = matlab_deps or []
+        self.version = version
+
+    def __new__(cls, *_, **__):
+        self = object.__new__(cls)
+        cls._instances.add(self)
+        return self
+
+    def __str__(self):
+        pydeps = ""
+        if self.python_deps:
+            pydeps = "\n  - " + "\n  - ".join(self.python_deps)
+        mdeps = ""
+        if self.matlab_deps:
+            mdeps = "\n  - " + "\n  - ".join(self.matlab_deps)
+
+        return dedent("""\
+            .
+              version: {}
+              python_deps:{}
+              matlab_deps:{}""")[2:].format(self.version, pydeps, mdeps)
+
+
+class Cmd(Base):
+    def __init__(
+        self,
+        cmd,
+        doc,
+        version=None,
+        argparser=MyParser,
+        formatter_class=RawDescriptionHelpFormatter,
+        **kwargs,
+    ):
+        """
+        Args:
+          cmd (list):  e.g. `[sys.executable, "-m", "miutil.cuinfo"]`
+          doc (str): an `argopt`-compatible docstring for `cmd`
+          version (str): optional
+        """
+        super(Cmd, self).__init__(**kwargs)
+        self.parser = argopt(
+            dedent(doc),
+            argparser=argparser,
+            formatter_class=formatter_class,
+            version=version,
+        )
+        self.parser.set_defaults(main__=self.main)
+        self.cmd = cmd
+
+    def __str__(self):
+        return dedent("""\
+            {}
+            {}""").format(self.parser.prog,
+                          super(Cmd, self).__str__())
+
+    def main(self, args, verify_args=True):
+        """
+        Args:
+            args (list): list of arguments (e.g. `sys.argv[1:]`)
+            verify_args (bool): whether to parse args to ensure no input errors
+        """
+        try:
+            if verify_args:
+                self.parser.parse_args(args=args)
+        except SystemExit as exc:
+            if exc.code:
+                raise
+        else:
+            # return check_output(self.cmd + args, stderr=STDOUT).decode("U8")
+            out = Popen(self.cmd + args, stdout=PIPE, stderr=PIPE)
+            stdout, stderr = out.communicate()
+            if out.returncode != 0:
+                raise CmdException(
+                    out.returncode,
+                    str(self),
+                    stdout.decode(ENCODING),
+                    stderr.decode(ENCODING),
+                )
+            return stdout.decode(ENCODING)
+
+
+class Func(Base):
+    def __init__(
+        self,
+        func,
+        doc,
+        version=None,
+        argparser=MyParser,
+        formatter_class=RawDescriptionHelpFormatter,
+        **kwargs,
+    ):
+        """
+        Args:
+          func (callable):  e.g. `miutil.hasext`
+          doc (str): an `argopt`-compatible docstring for `func`
+          version (str): optional
+        """
+        super(Func, self).__init__(**kwargs)
+        self.parser = argopt(
+            dedent(doc),
+            argparser=argparser,
+            formatter_class=formatter_class,
+            version=version,
+        )
+        self.parser.set_defaults(run__=func)
+        # self.func = func
+
+    def __str__(self):
+        return dedent("""\
+            {}
+            {}""").format(self.parser.prog,
+                          super(Func, self).__str__())
+
+
+def fix_subparser(subparser, gui_mode=True):
+    subparser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="don't run command (implies print_command)" if gui_mode else SUPPRESS,
+    )
+    return subparser
+
+
+def print_not_none(value, **kwargs):
+    if value is not None:
+        print(value, **kwargs)
+
+
+# progress_regex="^\s*(?P<percent>\d[.\d]*)%|",
+# progress_expr="float(percent or 0)",
+# hide_progress_msg=True,
+# richtext_controls=True,
+@Gooey(default_size=(768, 768), program_name="amypad", sidebar_title="pipeline",
+       image_dir=resource_filename(__name__, ""), show_restart_button=False,
+       header_bg_color="#ffffff", sidebar_bg_color="#a3b5cd", body_bg_color="#a3b5cd",
+       footer_bg_color="#2a569f", terminal_font_family="monospace", menu=[{
+           "name": "Help", "items": [{
+               "type": "Link", "menuTitle": "🌐 View source (online)",
+               "url": "https://github.com/AMYPAD/amypad"}, {
+                   "type": "AboutDialog", "menuTitle": "🔍 About", "name": "AMYPAD Pipeline",
+                   "description": "GUI to run AMYPAD tools", "version": __version__,
+                   "copyright": "2021", "website": "https://amypad.eu",
+                   "developer": "https://github.com/AMYPAD", "license": __licence__}]}])
+def main(args=None, gui_mode=True):
+    logging.basicConfig(level=logging.INFO)
+    import miutil.cuinfo
+    import niftypad.api
+    import niftypad.models
+
+    parser = fix_subparser(MyParser(prog=None if gui_mode else "amypad"), gui_mode=gui_mode)
+    sub_kwargs = {}
+    if sys.version_info[:2] >= (3, 7):
+        sub_kwargs["required"] = True
+    subparsers = parser.add_subparsers(help="pipeline to run", **sub_kwargs)
+    if not gui_mode:
+        subparser = subparsers.add_parser("completion", help="Print tab completion scripts")
+        shtab.add_argument_to(subparser, "shell", parent=parser)
+
+    def argparser(prog, description=None, epilog=None, formatter_class=None):
+        """handle (prog, description, epilog) => (title, help)"""
+        return fix_subparser(
+            subparsers.add_parser(
+                {"miutil.cuinfo": "cuinfo"}.get(prog, prog),               # override
+                help="\n".join([description or "", epilog or ""]).strip(),
+            ),
+            gui_mode=gui_mode,
+        )
+
+    kinetic_model = Func(
+        niftypad.api.kinetic_model,
+        """\
+        Kinetic modelling
+
+        Usage:
+          kinetic_model [options] <src>
+
+        Arguments:
+          <src>  : Input file/folder [default: FileChooser]
+
+        Options:
+          -d PATH, --dst PATH      : Output file/folder (default: input folder)
+                                     [default: FileChooser]
+          -m MODEL, --model MODEL  : model [default: srtmb_basis]
+          -p FILE, --params FILE   : config file hint (relative to `--input`)
+                                     [default: FileChooser]
+          --w W                : (default: None)
+          --r1 R1              : [default: 0.905:float]
+          --k2p K2P            : [default: 0.000250:float]
+          --beta_lim BETA_LIM  : (default: None)
+          --n_beta N_BETA      : [default: 40:int]
+          --linear_phase_start LINEAR_PHASE_START  : [default: 500:int]
+          --linear_phase_end LINEAR_PHASE_END      : (default: None)
+          --km_outputs KM_OUTPUTS                  : (default: None)
+          --thr THR                                : [default: 0.1:float]
+        """,
+        version=niftypad.__version__,
+        python_deps=["niftypad>=1.1.0"],
+        argparser=argparser,
+    )
+    opts = kinetic_model.parser._get_optional_actions()
+    model = next(i for i in opts if i.dest == "model")
+    model.choices = niftypad.models.NAMES
+
+    # example of how to wrap any CLI command using an `argopt`-style docstring
+    Cmd(
+        [sys.executable, "-m", "miutil.cuinfo"],
+        miutil.cuinfo.__doc__,
+        version=miutil.__version__,
+        python_deps=["miutil[cuda]>=0.8.0"],
+        argparser=argparser,
+    )
+
+    # example of how to wrap any callable using an `argopt`-style docstring
+    Func(
+        miutil.hasext,
+        """\
+        Check if a given filename has a given extension
+
+        Usage:
+          hasext <fname> <ext>
+
+        Arguments:
+          <fname>  : path to file [default: FileChooser]
+          <ext>    : extension (with or without `.` prefix)
+        """,
+        version=miutil.__version__,
+        python_deps=["miutil>=0.8.0"],
+        argparser=argparser,
+    )
+
+    if args is None:
+        args = sys.argv[1:]
+    args = [i for i in args if i not in ("--ignore-gooey",)]
+    opts = parser.parse_args(args=args)
+    args = [i for i in args if i not in ("--dry-run",)] # strip args
+
+    if gui_mode:
+        print(" ".join([Path(sys.executable).name, "-m amypad"] + args))
+    if opts.dry_run:
+        pass
+    elif hasattr(opts, "main__"):                                                    # Cmd
+        print_not_none(opts.main__(args[1:], verify_args=False), end="")
+    elif hasattr(opts, "run__"):                                                     # Func
+        kwargs = {k: v
+                  for (k, v) in opts._get_kwargs() if k not in ("dry_run", "run__")} # strip opts
+        print_not_none(opts.run__(*opts._get_args(), **kwargs))
+
+
+if __name__ == "__main__": # pragma: no cover
+    main()
diff --git a/amypad/program_icon.png b/amypad/program_icon.png
new file mode 100644
index 00000000..43d28359
Binary files /dev/null and b/amypad/program_icon.png differ
diff --git a/setup.cfg b/setup.cfg
index 770da816..e7035614 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,61 +1,93 @@
 [metadata]
-name = amypad
-description = Amyloid imaging to prevent Alzheimer's Disease
-long_description = file: README.rst
-long_description_content_type = text/x-rst
-license = Apache 2.0
-license_file = LICENCE.md
-url = https://amypad.eu
-project_urls =
-    Repository = https://github.com/AMYPAD/amypad
-    Changelog = https://github.com/AMYPAD/amypad/releases
-    Documentation = https://github.com/AMYPAD/amypad/#amypad
-maintainer = Casper da Costa-Luis
-maintainer_email = casper.dcl@physics.org
-keywords = pet, alzheimers
-platforms = any
-provides = amypad
-classifiers =
+name=amypad
+description=Amyloid imaging to prevent Alzheimer's Disease
+long_description=file: README.rst
+long_description_content_type=text/x-rst
+license=MPL 2.0
+license_file=LICENCE.md
+url=https://amypad.eu
+project_urls=
+    Repository=https://github.com/AMYPAD/amypad
+    Changelog=https://github.com/AMYPAD/amypad/releases
+    Documentation=https://github.com/AMYPAD/amypad/#amypad
+maintainer=Casper da Costa-Luis
+maintainer_email=casper.dcl@physics.org
+keywords=pet, alzheimers
+platforms=any
+provides=amypad
+classifiers=
     Development Status :: 3 - Alpha
+    Intended Audience :: Developers
     Intended Audience :: Education
     Intended Audience :: Healthcare Industry
     Intended Audience :: Science/Research
-    License :: OSI Approved :: Apache Software License
+    License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
     Operating System :: Microsoft :: Windows
     Operating System :: POSIX :: Linux
     Programming Language :: Python :: 3
-    Programming Language :: Python :: 3.5
     Programming Language :: Python :: 3.6
     Programming Language :: Python :: 3.7
     Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
+    Programming Language :: Python :: 3 :: Only
     Topic :: Scientific/Engineering :: Medical Science Apps.
+    Topic :: Software Development :: Libraries
+    Topic :: Software Development :: Libraries :: Python Modules
+    Topic :: Software Development :: User Interfaces
+    Topic :: System :: Installation/Setup
+    Topic :: Utilities
 [options]
-zip_safe = False
-setup_requires = setuptools>=42; setuptools_scm[toml]>=3.4
-install_requires =
-    argparse; python_version == "2.6"
-include_package_data = True
-packages = find:
+setup_requires=setuptools>=42; setuptools_scm[toml]>=3.4
+install_requires=
+    argopt
+    miutil[cuda]>=0.8.0
+    niftypad>=1.1.0
+    setuptools
+    shtab>1.3.2
+packages=find:
+python_requires=>=3.6
 [options.extras_require]
-dev =
-    black; python_version >= "3.0"
-    flake8
-    twine
-    wheel
+dev=
+    pre-commit
     pytest
     pytest-cov
     pytest-timeout
     pytest-xdist
-    codecov
-niftypet = niftypet>=0.0.1
+gui=Gooey>=1.0.8
+niftypet=niftypet>=0.0.1
+niftypad=
+[options.entry_points]
+console_scripts=
+    amypad=amypad.cli:main
+    amypad.gui=amypad.gui:main
+[options.packages.find]
+exclude=tests
 [options.package_data]
-* = *.md, *.rst, *.m
+*=*.md, *.rst, *.m, *.png
 
 [flake8]
-max_line_length = 88
-extend-ignore = E203
-exclude = .git,__pycache__,build,dist,.eggs
+max_line_length=99
+extend-ignore=E261,P1
+exclude=.git,__pycache__,build,dist,.eggs
 
-[bdist_wheel]
-universal = 1
+[yapf]
+spaces_before_comment=15, 20
+arithmetic_precedence_indication=true
+allow_split_before_dict_value=false
+coalesce_brackets=True
+column_limit=99
+each_dict_entry_on_separate_line=False
+space_between_ending_comma_and_closing_bracket=False
+split_before_named_assigns=False
+split_before_closing_bracket=False
+
+[isort]
+profile=black
+line_length=99
+known_first_party=amypad,tests
+
+[tool:pytest]
+timeout=10
+log_level=INFO
+python_files=tests/test_*.py
+addopts=-v --tb=short -rxs -W=error -n=auto --durations=0 --durations-min=1 --cov=amypad --cov-report=term-missing --cov-report=xml
diff --git a/setup.py b/setup.py
index 60684932..d5d43d7c 100644
--- a/setup.py
+++ b/setup.py
@@ -1,3 +1,3 @@
 from setuptools import setup
 
-setup()
+setup(use_scm_version=True)


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 00000000..27597e11
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,82 @@
+name: Test
+on: [push, pull_request]
+jobs:
+  check:
+    if: github.event_name != 'pull_request' || github.repository_owner != 'AMYPAD'
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v2
+    - uses: actions/setup-python@v2
+    - name: set PYSHA
+      run: echo "PYSHA=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV
+    - uses: actions/cache@v1
+      with:
+        path: ~/.cache/pre-commit
+        key: pre-commit|${{ env.PYSHA }}|${{ hashFiles('.pre-commit-config.yaml') }}
+    - name: dependencies
+      run: pip install -U pre-commit
+    - uses: reviewdog/action-setup@v1
+    - if: github.event_name == 'push' || github.event_name == 'pull_request'
+      name: comment
+      run: |
+        if [[ $EVENT == pull_request ]]; then
+          REPORTER=github-pr-review
+        else
+          REPORTER=github-check
+        fi
+        pre-commit run -a todo | reviewdog -efm="%f:%l: %m" -name=TODO -tee -reporter=$REPORTER -filter-mode nofilter
+        pre-commit run -a flake8 | reviewdog -f=pep8 -name=flake8 -tee -reporter=$REPORTER -filter-mode nofilter
+      env:
+        REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        EVENT: ${{ github.event_name }}
+    - run: pre-commit run -a --show-diff-on-failure
+  test:
+    if: github.event_name != 'pull_request' || github.repository_owner != 'AMYPAD'
+    name: Test py${{ matrix.python }}
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python: [3.6, 3.9]
+    steps:
+    - uses: actions/checkout@v2
+      with:
+        fetch-depth: 0
+    - uses: actions/setup-python@v2
+      with:
+        python-version: ${{ matrix.python }}
+    - run: pip install -U -e .[dev,niftypad]
+    - run: pytest
+    - uses: codecov/codecov-action@v1
+  deploy:
+    needs: [check, test]
+    name: PyPI Deploy
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v2
+      with:
+        fetch-depth: 0
+    - uses: actions/setup-python@v2
+    - id: dist
+      uses: casperdcl/deploy-pypi@v2
+      with:
+        requirements: twine setuptools wheel setuptools_scm[toml]
+        build: true
+        gpg_key: ${{ secrets.GPG_KEY }}
+        password: ${{ secrets.PYPI_TOKEN }}
+        upload: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags') }}
+    - id: meta
+      name: Changelog
+      run: |
+        echo ::set-output name=tag::${GITHUB_REF#refs/tags/}
+        git log --pretty='format:%d%n- %s%n%b---' $(git tag --sort=v:refname | tail -n2 | head -n1)..HEAD > _CHANGES.md
+    - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
+      uses: softprops/action-gh-release@v1
+      env:
+        GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
+      with:
+        name: amypad ${{ steps.meta.outputs.tag }} alpha
+        body_path: _CHANGES.md
+        draft: true
+        files: |
+          dist/${{ steps.dist.outputs.whl }}
+          dist/${{ steps.dist.outputs.whl_asc }}
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..f488f336
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,12 @@
+import pytest
+
+
+@pytest.fixture(scope="session")
+def nvml():
+    import pynvml
+
+    try:
+        pynvml.nvmlInit()
+    except pynvml.NVMLError as exc:
+        pytest.skip(str(exc))
+    return pynvml
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 00000000..00dcdbbe
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,9 @@
+from amypad.cli import main
+
+
+def test_run():
+    main(["hasext", "foo.bar", "bar"])
+
+
+def test_cmd(nvml):
+    main(["cuinfo", "-n"])