Browse Source

Initial commit

Michael Hanke 1 year ago
commit
db7eef06d7

+ 224 - 0
.appveyor.yml

@@ -0,0 +1,224 @@
+# This CI setup provides a largely homogeneous configuration across all
+# major platforms (Windows, MacOS, and Linux). The aim of this test setup is
+# to create a "native" platform experience, using as few cross-platform
+# helper tools as possible.
+#
+# On Linux/Mac a virtualenv is used for testing. The effective virtual env
+# is available under ~/VENV.
+#
+# All workers support remote login. Login details are shown at the top of each
+# CI run log.
+#
+# - Linux/Mac workers (via SSH):
+#
+#   - A permitted SSH key must be defined in an APPVEYOR_SSH_KEY environment
+#     variable (via the appveyor project settings)
+#
+#   - SSH login info is given in the form of: 'appveyor@67.225.164.xx -p 22xxx'
+#
+#   - Login with:
+#
+#     ssh -o StrictHostKeyChecking=no <LOGIN>
+#
+#   - to prevent the CI run from exiting, `touch` a file named `BLOCK` in the
+#     user HOME directory (current directory directly after login). The session
+#     will run until the file is removed (or 60 min have passed)
+#
+# - Windows workers (via RDP):
+#
+#   - An RDP password should be defined in an APPVEYOR_RDP_PASSWORD environment
+#     variable (via the appveyor project settings), or a random password is used
+#     every time
+#
+#   - RDP login info is given in the form of IP:PORT
+#
+#   - Login with:
+#
+#     xfreerdp /cert:ignore /dynamic-resolution /u:appveyor /p:<PASSWORD> /v:<LOGIN>
+#
+#   - to prevent the CI run from exiting, create a textfile named `BLOCK` on the
+#     Desktop (a required .txt extension will be added automatically). The session
+#     will run until the file is removed (or 60 min have passed)
+#
+#   - in a terminal execute, for example, `C:\datalad_debug.bat 39` to set up the
+#     environment to debug in a Python 3.8 session (should generally match the
+#     respective CI run configuration).
+
+
+# do not make repository clone cheap: interfers with versioneer
+shallow_clone: false
+
+
+environment:
+  DATALAD_TESTS_SSH: 1
+
+  # Do not use `image` as a matrix dimension, to have fine-grained control over
+  # what tests run on which platform
+  # The ID variable had no impact, but sorts first in the CI run overview
+  # an intelligible name can help to locate a specific test run
+  matrix:
+    # List a CI run for each platform first, to have immediate access when there
+    # is a need for debugging
+
+    # Ubuntu core tests
+    - ID: Ubu20
+      DTS: datalad_helloworld
+      APPVEYOR_BUILD_WORKER_IMAGE: Ubuntu2004
+      INSTALL_SYSPKGS: python3-virtualenv
+      # system git-annex is way too old, use better one
+      INSTALL_GITANNEX: git-annex -m deb-url --url http://snapshot.debian.org/archive/debian/20210906T204127Z/pool/main/g/git-annex/git-annex_8.20210903-1_amd64.deb
+      CODECOV_BINARY: https://uploader.codecov.io/latest/linux/codecov
+    # Windows core tests
+    - ID: WinP39core
+      # ~35 min
+      DTS: datalad_helloworld
+      APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019
+      # Python version specification is non-standard on windows
+      PY: 39-x64
+      INSTALL_GITANNEX: git-annex -m datalad/packages
+    # MacOS core tests
+    - ID: MacP38core
+      DTS: datalad_helloworld
+      APPVEYOR_BUILD_WORKER_IMAGE: macOS
+      PY: 3.8
+      INSTALL_GITANNEX: git-annex
+      DATALAD_LOCATIONS_SOCKETS: /Users/appveyor/DLTMP/sockets
+      CODECOV_BINARY: https://uploader.codecov.io/latest/macos/codecov
+
+matrix:
+  allow_failures:
+    - KNOWN2FAIL: 1
+
+
+# do not run the CI if only documentation changes were made
+# documentation builds are tested elsewhere and cheaper
+skip_commits:
+  files:
+    - docs/
+
+
+# it is OK to specify paths that may not exist for a particular test run
+cache:
+  # pip cache
+  - C:\Users\appveyor\AppData\Local\pip\Cache -> .appveyor.yml
+  - /home/appveyor/.cache/pip -> .appveyor.yml
+  # TODO: where is the cache on macOS?
+  #- /Users/appveyor/.cache/pip -> .appveyor.yml
+  # TODO: Can we cache `brew`?
+  #- /usr/local/Cellar
+  #- /usr/local/bin
+
+
+# turn of support for MS project build support (not needed)
+build: off
+
+
+# init cannot use any components from the repo, because it runs prior to
+# cloning it
+init:
+  # remove windows 260-char limit on path names
+  - cmd: powershell Set-Itemproperty -path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name LongPathsEnabled -value 1
+  # enable developer mode on windows
+  # this should enable mklink without admin privileges, but it doesn't seem to work
+  #- cmd: powershell tools\ci\appveyor_enable_windevmode.ps1
+  # enable RDP access on windows (RDP password is in appveyor project config)
+  # this is relatively expensive (1-2min), but very convenient to jump into any build at any time
+  - cmd: powershell.exe iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1'))
+  # enable external SSH access to CI worker on all other systems
+  # needs APPVEYOR_SSH_KEY defined in project settings (or environment)
+  - sh: curl -sflL 'https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-ssh.sh' | bash -e -
+  # Identity setup
+  - git config --global user.email "test@appveyor.land"
+  - git config --global user.name "Appveyor Almighty"
+  # Scratch space
+  - cmd: md C:\DLTMP
+  # we place the "unix" one into the user's HOME to avoid git-annex issues on MacOSX
+  # gh-5291
+  - sh: mkdir ~/DLTMP
+  # and use that scratch space to get short paths in test repos
+  # (avoiding length-limits as much as possible)
+  - cmd: "set TMP=C:\\DLTMP"
+  - cmd: "set TEMP=C:\\DLTMP"
+  - sh: export TMPDIR=~/DLTMP
+
+
+install:
+  # place a debug setup helper at a convenient location
+  - cmd: copy tools\ci\appveyor_env_setup.bat C:\\datalad_debug.bat
+  # If a particular Python version is requested, use env setup (using the
+  # appveyor provided environments/installation). Note, these are broken
+  # on the ubuntu images
+  # https://help.appveyor.com/discussions/problems/28217-appveyor-ubunu-image-with-python3-lzma-module
+  # Otherwise create a virtualenv using the default Python 3, to enable uniform
+  # use of python/pip executables below
+  - sh: "[ \"x$PY\" != x ] && . ${HOME}/venv${PY}/bin/activate || virtualenv -p 3 ${HOME}/dlvenv && . ${HOME}/dlvenv/bin/activate; ln -s \"$VIRTUAL_ENV\" \"${HOME}/VENV\""
+  - cmd: "set PATH=C:\\Python%PY%;C:\\Python%PY%\\Scripts;%PATH%"
+  # deploy the datalad installer, override version via DATALAD_INSTALLER_VERSION
+  - cmd:
+      IF DEFINED DATALAD_INSTALLER_VERSION (
+      python -m pip install "datalad-installer%DATALAD_INSTALLER_VERSION%"
+      ) ELSE (
+      python -m pip install datalad-installer
+      )
+  - sh: python -m pip install datalad-installer${DATALAD_INSTALLER_VERSION:-}
+  # Missing system software
+  - sh: "[ -n \"$INSTALL_SYSPKGS\" ] && ( [ \"x${APPVEYOR_BUILD_WORKER_IMAGE}\" = \"xmacOS\" ] && brew install -q ${INSTALL_SYSPKGS} || { sudo apt-get update -y && sudo apt-get install --no-install-recommends -y ${INSTALL_SYSPKGS}; } ) || true"
+  # Install git-annex on windows, otherwise INSTALL_SYSPKGS can be used
+  # deploy git-annex, if desired
+  - cmd: IF DEFINED INSTALL_GITANNEX datalad-installer --sudo ok %INSTALL_GITANNEX%
+  - sh: "[ -n \"${INSTALL_GITANNEX}\" ] && datalad-installer --sudo ok ${INSTALL_GITANNEX}"
+  # in case of a snapshot installation, use the following approach to adjust
+  # the PATH as necessary
+  #- sh: "[ -n \"${INSTALL_GITANNEX}\" ] && datalad-installer -E ${HOME}/dlinstaller_env.sh --sudo ok ${INSTALL_GITANNEX}"
+  # add location of datalad installer results to PATH
+  #- sh: "[ -f ${HOME}/dlinstaller_env.sh ] && . ${HOME}/dlinstaller_env.sh || true"
+
+
+#before_build:
+#
+
+
+build_script:
+  - python -m pip install -r requirements-devel.txt
+  - python -m pip install .
+
+
+#after_build:
+#
+
+
+before_test:
+  # simple call to see if datalad and git-annex are installed properly
+  - datalad wtf
+
+
+test_script:
+  # run tests on installed module, not source tree files
+  - cmd: md __testhome__
+  - sh: mkdir __testhome__
+  - cd __testhome__
+  - cmd: python -m pytest -s -v -m "not (turtle)" --doctest-modules --cov=datalad_helloworld --pyargs %DTS%
+  - sh:  python -m pytest -s -v -m "not (turtle)" --doctest-modules --cov=datalad_helloworld --pyargs ${DTS}
+
+
+after_test:
+  - python -m coverage xml
+  - cmd: curl -fsSL -o codecov.exe "https://uploader.codecov.io/latest/windows/codecov.exe"
+  - cmd: .\codecov.exe -f "coverage.xml"
+  - sh: "curl -Os $CODECOV_BINARY"
+  - sh: chmod +x codecov
+  - sh: ./codecov
+
+
+#on_success:
+#
+
+
+#on_failure:
+#
+
+
+on_finish:
+  # conditionally block the exit of a CI run for direct debugging
+  - sh: while [ -f ~/BLOCK ]; do sleep 5; done
+  - cmd: powershell.exe while ((Test-Path "C:\Users\\appveyor\\Desktop\\BLOCK.txt")) { Start-Sleep 5 }

+ 17 - 0
.codeclimate.yml

@@ -0,0 +1,17 @@
+version: "2"
+checks:
+  file-lines:
+    config:
+      threshold: 500
+plugins:
+  bandit:
+    enabled: true
+    checks:
+      assert_used:
+        enabled: false
+exclude_patterns:
+- "_datalad_buildsupport/"
+- "versioneer.py"
+- "*/_version.py"
+- "tools/"
+- "**/tests/"

+ 1 - 0
.gitattributes

@@ -0,0 +1 @@
+datalad_helloworld/_version.py export-subst

+ 27 - 0
.github/workflows/docbuild.yml

@@ -0,0 +1,27 @@
+name: docs
+
+on: [push, pull_request]
+
+jobs:
+  build:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Set up environment
+      run: |
+        git config --global user.email "test@github.land"
+        git config --global user.name "GitHub Almighty"
+    - uses: actions/checkout@v1
+    - name: Set up Python 3.8
+      uses: actions/setup-python@v1
+      with:
+        python-version: 3.8
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install -r requirements-devel.txt
+        pip install .
+    - name: Build docs
+      run: |
+        make -C docs html

+ 55 - 0
.github/workflows/test_crippledfs.yml

@@ -0,0 +1,55 @@
+name: crippled-filesystems
+
+on: [pull_request]
+
+jobs:
+  test:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Set up system
+      shell: bash
+      run: |
+        bash <(wget -q -O- http://neuro.debian.net/_files/neurodebian-travis.sh)
+        # enable repo for devel git-annex, if needed
+        #sudo sed -e 's|\(deb.*data\)|#\1|' -e 's|/debian |/debian-devel |' /etc/apt/sources.list.d/neurodebian.sources.list | sudo tee /etc/apt/sources.list.d/neurodebian-devel.sources.list
+        sudo apt-get update -qq
+        sudo apt-get install eatmydata
+        sudo eatmydata apt-get install git-annex-standalone dosfstools
+        # 500 MB VFAT FS in a box
+        sudo dd if=/dev/zero of=/crippledfs.img count=500 bs=1M
+        sudo mkfs.vfat /crippledfs.img
+        # mount
+        sudo mkdir /crippledfs
+        sudo mount -o "uid=$(id -u),gid=$(id -g)" /crippledfs.img /crippledfs
+    - name: Set up environment
+      run: |
+        git config --global user.email "test@github.land"
+        git config --global user.name "GitHub Almighty"
+    - uses: actions/checkout@v1
+    - name: Set up Python 3.7
+      uses: actions/setup-python@v1
+      with:
+        python-version: 3.7
+    - name: Install dependencies
+      run: |
+        pip install -r requirements-devel.txt
+        python -m pip install --upgrade pip
+    - name: Installation
+      run: |
+        # package install
+        python -m pip install .
+    - name: Run tests
+      env:
+        # forces all test repos/paths into the VFAT FS
+        TMPDIR: /crippledfs
+      run: |
+        mkdir -p __testhome__
+        cd __testhome__
+        # give detailed info on actual test setup
+        datalad wtf
+        echo "== mount >>"
+        mount
+        echo "<< mount =="
+        python -m pytest -s -v --doctest-modules --cov=datalad_helloworld --pyargs datalad_helloworld

+ 12 - 0
.gitignore

@@ -0,0 +1,12 @@
+.pybuild/
+.coverage
+/.tox
+*.egg-info
+*.py[coe]
+.#*
+.*.swp
+pip-wheel-metadata
+docs/build
+docs/source/generated
+build/
+dist/

+ 0 - 0
.noannex


+ 23 - 0
.zenodo.json

@@ -0,0 +1,23 @@
+{
+  "creators": [
+    {
+      "affiliation": "Edit me!",
+      "name": "Edit me!",
+      "orcid": "Edit me!"
+    },
+    {
+      "affiliation": "Edit me!",
+      "name": "Edit me!",
+      "orcid": "Edit me!"
+    }
+  ],
+  "keywords": [
+    "data management",
+    "data distribution",
+    "execution provenance tracking",
+    "version control"
+  ],
+  "access_right": "open",
+  "license": "Edit me!",
+  "upload_type": "software"
+}

+ 3 - 0
CONTRIBUTORS

@@ -0,0 +1,3 @@
+The following people have contributed to this project:
+
+Michael Hanke

+ 28 - 0
LICENSE

@@ -0,0 +1,28 @@
+# Main Copyright/License
+
+DataLad, including all examples, code snippets and attached
+documentation is covered by the MIT license.
+
+  The MIT License
+
+  Copyright (c) 2018-     DataLad Team
+
+  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.
+
+See CONTRIBUTORS file for a full list of contributors.

+ 6 - 0
MANIFEST.in

@@ -0,0 +1,6 @@
+include CONTRIBUTORS LICENSE versioneer.py
+graft _datalad_buildsupport
+graft datalad_helloworld
+graft docs
+prune docs/build
+global-exclude *.py[cod]

+ 21 - 0
Makefile

@@ -0,0 +1,21 @@
+PYTHON ?= python
+
+clean:
+	$(PYTHON) setup.py clean
+	rm -rf dist build bin docs/build docs/source/generated *.egg-info
+	-find . -name '*.pyc' -delete
+	-find . -name '__pycache__' -type d -delete
+
+release-pypi:
+	# avoid upload of stale builds
+	test ! -e dist
+	$(PYTHON) setup.py sdist bdist_wheel
+	twine upload dist/*
+
+update-buildsupport:
+	git subtree pull \
+		-m "Update DataLad build helper" \
+		--squash \
+		--prefix _datalad_buildsupport \
+		https://github.com/datalad/datalad-buildsupport.git \
+		master

+ 48 - 0
README.md

@@ -0,0 +1,48 @@
+# DataLad extension template
+
+[![Build status](https://ci.appveyor.com/api/projects/status/g9von5wtpoidcecy/branch/master?svg=true)](https://ci.appveyor.com/project/mih/datalad-extension-template/branch/master) [![codecov.io](https://codecov.io/github/datalad/datalad-extension-template/coverage.svg?branch=master)](https://codecov.io/github/datalad/datalad-extension-template?branch=master) [![crippled-filesystems](https://github.com/datalad/datalad-extension-template/workflows/crippled-filesystems/badge.svg)](https://github.com/datalad/datalad-extension-template/actions?query=workflow%3Acrippled-filesystems) [![docs](https://github.com/datalad/datalad-extension-template/workflows/docs/badge.svg)](https://github.com/datalad/datalad-extension-template/actions?query=workflow%3Adocs)
+
+
+This repository contains an extension template that can serve as a starting point
+for implementing a [DataLad](http://datalad.org) extension. An extension can
+provide any number of additional DataLad commands that are automatically
+included in DataLad's command line and Python API.
+
+For a demo, clone this repository and install the demo extension via
+
+    pip install -e .
+
+DataLad will now expose a new command suite with a `hello...` command.
+
+    % datalad --help |grep -B2 -A2 hello
+    *Demo DataLad command suite*
+
+      hello-cmd
+          Short description of the command
+
+To start implementing your own extension, [use this
+template](https://github.com/datalad/datalad-extension-template/generate), and
+adjust as necessary. A good approach is to
+
+- Pick a name for the new extension.
+- Look through the sources and replace `datalad_helloworld` with
+  `datalad_<newname>` (hint: `git grep datalad_helloworld` should find all
+  spots).
+- Delete the example command implementation in `datalad_helloworld/__init__.py`
+  by (re)moving the `HelloWorld` class.
+- Implement a new command, and adjust the `command_suite` in
+  `datalad_helloworld/__init__.py` to point to it.
+- Replace `hello_cmd` with the name of the new command in
+  `datalad_helloworld/tests/test_register.py` to automatically test whether the
+  new extension installs correctly.
+- Adjust the documentation in `docs/source/index.rst`. Refer to [`docs/README.md`](docs/README.md) for more information on documentation building, testing and publishing.
+- Replace this README.
+- Update `setup.cfg` with appropriate metadata on the new extension.
+
+You can consider filling in the provided [.zenodo.json](.zenodo.json) file with
+contributor information and [meta data](https://developers.zenodo.org/#representation)
+to acknowledge contributors and describe the publication record that is created when
+[you make your code citeable](https://guides.github.com/activities/citable-code/)
+by archiving it using [zenodo.org](https://zenodo.org/). You may also want to
+consider acknowledging contributors with the
+[allcontributors bot](https://allcontributors.org/docs/en/bot/overview).

+ 13 - 0
_datalad_buildsupport/__init__.py

@@ -0,0 +1,13 @@
+# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
+#
+#   See COPYING file distributed along with the DataLad package for the
+#   copyright and license terms.
+#
+# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
+"""Python package for functionality needed at package 'build' time by DataLad and its extensions
+
+__init__ here should be really minimalistic, not import submodules by default
+and submodules should also not require heavy dependencies.
+"""
+
+__version__ = '0.1'

+ 314 - 0
_datalad_buildsupport/formatters.py

@@ -0,0 +1,314 @@
+# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
+#
+#   See COPYING file distributed along with the DataLad package for the
+#   copyright and license terms.
+#
+# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
+
+import argparse
+import datetime
+import re
+
+
+class ManPageFormatter(argparse.HelpFormatter):
+    # This code was originally distributed
+    # under the same License of Python
+    # Copyright (c) 2014 Oz Nahum Tiram  <nahumoz@gmail.com>
+    def __init__(self,
+                 prog,
+                 indent_increment=2,
+                 max_help_position=4,
+                 width=1000000,
+                 section=1,
+                 ext_sections=None,
+                 authors=None,
+                 version=None
+                 ):
+
+        super(ManPageFormatter, self).__init__(
+            prog,
+            indent_increment=indent_increment,
+            max_help_position=max_help_position,
+            width=width)
+
+        self._prog = prog
+        self._section = 1
+        self._today = datetime.date.today().strftime('%Y\\-%m\\-%d')
+        self._ext_sections = ext_sections
+        self._version = version
+
+    def _get_formatter(self, **kwargs):
+        return self.formatter_class(prog=self.prog, **kwargs)
+
+    def _markup(self, txt):
+        return txt.replace('-', '\\-')
+
+    def _underline(self, string):
+        return "\\fI\\s-1" + string + "\\s0\\fR"
+
+    def _bold(self, string):
+        if not string.strip().startswith('\\fB'):
+            string = '\\fB' + string
+        if not string.strip().endswith('\\fR'):
+            string = string + '\\fR'
+        return string
+
+    def _mk_synopsis(self, parser):
+        self.add_usage(parser.usage, parser._actions,
+                       parser._mutually_exclusive_groups, prefix='')
+        usage = self._format_usage(None, parser._actions,
+                                   parser._mutually_exclusive_groups, '')
+        # replace too long list of commands with a single placeholder
+        usage = re.sub(r'{[^]]*?create,.*?}', ' COMMAND ', usage, flags=re.MULTILINE)
+        # take care of proper wrapping
+        usage = re.sub(r'\[([-a-zA-Z0-9]*)\s([a-zA-Z0-9{}|_]*)\]', r'[\1\~\2]', usage)
+
+        usage = usage.replace('%s ' % self._prog, '')
+        usage = '.SH SYNOPSIS\n.nh\n.HP\n\\fB%s\\fR %s\n.hy\n' % (self._markup(self._prog),
+                                                    usage)
+        return usage
+
+    def _mk_title(self, prog):
+        name_version = "{0} {1}".format(prog, self._version)
+        return '.TH "{0}" "{1}" "{2}" "{3}"\n'.format(
+            prog, self._section, self._today, name_version)
+
+    def _mk_name(self, prog, desc):
+        """
+        this method is in consitent with others ... it relies on
+        distribution
+        """
+        desc = desc.splitlines()[0] if desc else 'it is in the name'
+        # ensure starting lower case
+        desc = desc[0].lower() + desc[1:]
+        return '.SH NAME\n%s \\- %s\n' % (self._bold(prog), desc)
+
+    def _mk_description(self, parser):
+        desc = parser.description
+        desc = '\n'.join(desc.splitlines()[1:])
+        if not desc:
+            return ''
+        desc = desc.replace('\n\n', '\n.PP\n')
+        # sub-section headings
+        desc = re.sub(r'^\*(.*)\*$', r'.SS \1', desc, flags=re.MULTILINE)
+        # italic commands
+        desc = re.sub(r'^  ([-a-z]*)$', r'.TP\n\\fI\1\\fR', desc, flags=re.MULTILINE)
+        # deindent body text, leave to troff viewer
+        desc = re.sub(r'^      (\S.*)\n', '\\1\n', desc, flags=re.MULTILINE)
+        # format NOTEs as indented paragraphs
+        desc = re.sub(r'^NOTE\n', '.TP\nNOTE\n', desc, flags=re.MULTILINE)
+        # deindent indented paragraphs after heading setup
+        desc = re.sub(r'^  (.*)$', '\\1', desc, flags=re.MULTILINE)
+
+        return '.SH DESCRIPTION\n%s\n' % self._markup(desc)
+
+    def _mk_footer(self, sections):
+        if not hasattr(sections, '__iter__'):
+            return ''
+
+        footer = []
+        for section, value in sections.items():
+            part = ".SH {}\n {}".format(section.upper(), value)
+            footer.append(part)
+
+        return '\n'.join(footer)
+
+    def format_man_page(self, parser):
+        page = []
+        page.append(self._mk_title(self._prog))
+        page.append(self._mk_name(self._prog, parser.description))
+        page.append(self._mk_synopsis(parser))
+        page.append(self._mk_description(parser))
+        page.append(self._mk_options(parser))
+        page.append(self._mk_footer(self._ext_sections))
+
+        return ''.join(page)
+
+    def _mk_options(self, parser):
+
+        formatter = parser._get_formatter()
+
+        # positionals, optionals and user-defined groups
+        for action_group in parser._action_groups:
+            formatter.start_section(None)
+            formatter.add_text(None)
+            formatter.add_arguments(action_group._group_actions)
+            formatter.end_section()
+
+        # epilog
+        formatter.add_text(parser.epilog)
+
+        # determine help from format above
+        help = formatter.format_help()
+        # add spaces after comma delimiters for easier reformatting
+        help = re.sub(r'([a-z]),([a-z])', '\\1, \\2', help)
+        # get proper indentation for argument items
+        help = re.sub(r'^  (\S.*)\n', '.TP\n\\1\n', help, flags=re.MULTILINE)
+        # deindent body text, leave to troff viewer
+        help = re.sub(r'^    (\S.*)\n', '\\1\n', help, flags=re.MULTILINE)
+        return '.SH OPTIONS\n' + help
+
+    def _format_action_invocation(self, action, doubledash='--'):
+        if not action.option_strings:
+            metavar, = self._metavar_formatter(action, action.dest)(1)
+            return metavar
+
+        else:
+            parts = []
+
+            # if the Optional doesn't take a value, format is:
+            #    -s, --long
+            if action.nargs == 0:
+                parts.extend([self._bold(action_str) for action_str in
+                              action.option_strings])
+
+            # if the Optional takes a value, format is:
+            #    -s ARGS, --long ARGS
+            else:
+                default = self._underline(action.dest.upper())
+                args_string = self._format_args(action, default)
+                for option_string in action.option_strings:
+                    parts.append('%s %s' % (self._bold(option_string),
+                                            args_string))
+
+            return ', '.join(p.replace('--', doubledash) for p in parts)
+
+
+class RSTManPageFormatter(ManPageFormatter):
+    def _get_formatter(self, **kwargs):
+        return self.formatter_class(prog=self.prog, **kwargs)
+
+    def _markup(self, txt):
+        # put general tune-ups here
+        return txt
+
+    def _underline(self, string):
+        return "*{0}*".format(string)
+
+    def _bold(self, string):
+        return "**{0}**".format(string)
+
+    def _mk_synopsis(self, parser):
+        self.add_usage(parser.usage, parser._actions,
+                       parser._mutually_exclusive_groups, prefix='')
+        usage = self._format_usage(None, parser._actions,
+                                   parser._mutually_exclusive_groups, '')
+
+        usage = usage.replace('%s ' % self._prog, '')
+        usage = 'Synopsis\n--------\n::\n\n  %s %s\n' \
+                % (self._markup(self._prog), usage)
+        return usage
+
+    def _mk_title(self, prog):
+        # and an easy to use reference point
+        title = ".. _man_%s:\n\n" % prog.replace(' ', '-')
+        title += "{0}".format(prog)
+        title += '\n{0}\n\n'.format('=' * len(prog))
+        return title
+
+    def _mk_name(self, prog, desc):
+        return ''
+
+    def _mk_description(self, parser):
+        desc = parser.description
+        if not desc:
+            return ''
+        return 'Description\n-----------\n%s\n' % self._markup(desc)
+
+    def _mk_footer(self, sections):
+        if not hasattr(sections, '__iter__'):
+            return ''
+
+        footer = []
+        for section, value in sections.items():
+            part = "\n{0}\n{1}\n{2}\n".format(
+                section,
+                '-' * len(section),
+                value)
+            footer.append(part)
+
+        return '\n'.join(footer)
+
+    def _mk_options(self, parser):
+
+        # this non-obvious maneuver is really necessary!
+        formatter = self.__class__(self._prog)
+
+        # positionals, optionals and user-defined groups
+        for action_group in parser._action_groups:
+            formatter.start_section(None)
+            formatter.add_text(None)
+            formatter.add_arguments(action_group._group_actions)
+            formatter.end_section()
+
+        # epilog
+        formatter.add_text(parser.epilog)
+
+        # determine help from format above
+        option_sec = formatter.format_help()
+
+        return '\n\nOptions\n-------\n{0}'.format(option_sec)
+
+    def _format_action(self, action):
+        # determine the required width and the entry label
+        action_header = self._format_action_invocation(action)
+
+        if action.help:
+            help_text = self._expand_help(action)
+            help_lines = self._split_lines(help_text, 80)
+            help = ' '.join(help_lines)
+        else:
+            help = ''
+
+        # return a single string
+        return '{0}\n{1}\n{2}\n\n'.format(
+            action_header,
+
+            '~' * len(action_header),
+            help)
+
+
+def cmdline_example_to_rst(src, out=None, ref=None):
+    if out is None:
+        from io import StringIO
+        out = StringIO()
+
+    # place header
+    out.write('.. AUTO-GENERATED FILE -- DO NOT EDIT!\n\n')
+    if ref:
+        # place cross-ref target
+        out.write('.. {0}:\n\n'.format(ref))
+
+    # parser status vars
+    inexample = False
+    incodeblock = False
+
+    for line in src:
+        if line.startswith('#% EXAMPLE START'):
+            inexample = True
+            incodeblock = False
+            continue
+        if not inexample:
+            continue
+        if line.startswith('#% EXAMPLE END'):
+            break
+        if not inexample:
+            continue
+        if line.startswith('#%'):
+            incodeblock = not incodeblock
+            if incodeblock:
+                out.write('\n.. code-block:: sh\n\n')
+            continue
+        if not incodeblock and line.startswith('#'):
+            out.write(line[(min(2, len(line) - 1)):])
+            continue
+        if incodeblock:
+            if not line.rstrip().endswith('#% SKIP'):
+                out.write('  %s' % line)
+            continue
+        if not len(line.strip()):
+            continue
+        else:
+            raise RuntimeError("this should not happen")
+
+    return out

+ 220 - 0
_datalad_buildsupport/setup.py

@@ -0,0 +1,220 @@
+# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
+#
+#   See COPYING file distributed along with the DataLad package for the
+#   copyright and license terms.
+#
+# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
+
+
+import datetime
+import os
+
+from os.path import (
+    dirname,
+    join as opj,
+)
+from setuptools import Command, DistutilsOptionError
+from setuptools.config import read_configuration
+
+import versioneer
+
+from . import formatters as fmt
+
+
+class BuildManPage(Command):
+    # The BuildManPage code was originally distributed
+    # under the same License of Python
+    # Copyright (c) 2014 Oz Nahum Tiram  <nahumoz@gmail.com>
+
+    description = 'Generate man page from an ArgumentParser instance.'
+
+    user_options = [
+        ('manpath=', None,
+         'output path for manpages (relative paths are relative to the '
+         'datalad package)'),
+        ('rstpath=', None,
+         'output path for RST files (relative paths are relative to the '
+         'datalad package)'),
+        ('parser=', None, 'module path to an ArgumentParser instance'
+         '(e.g. mymod:func, where func is a method or function which return'
+         'a dict with one or more arparse.ArgumentParser instances.'),
+        ('cmdsuite=', None, 'module path to an extension command suite '
+         '(e.g. mymod:command_suite) to limit the build to the contained '
+         'commands.'),
+    ]
+
+    def initialize_options(self):
+        self.manpath = opj('build', 'man')
+        self.rstpath = opj('docs', 'source', 'generated', 'man')
+        self.parser = 'datalad.cmdline.main:setup_parser'
+        self.cmdsuite = None
+
+    def finalize_options(self):
+        if self.manpath is None:
+            raise DistutilsOptionError('\'manpath\' option is required')
+        if self.rstpath is None:
+            raise DistutilsOptionError('\'rstpath\' option is required')
+        if self.parser is None:
+            raise DistutilsOptionError('\'parser\' option is required')
+        mod_name, func_name = self.parser.split(':')
+        fromlist = mod_name.split('.')
+        try:
+            mod = __import__(mod_name, fromlist=fromlist)
+            self._parser = getattr(mod, func_name)(
+                ['datalad'],
+                formatter_class=fmt.ManPageFormatter,
+                return_subparsers=True,
+                # ignore extensions only for the main package to avoid pollution
+                # with all extension commands that happen to be installed
+                help_ignore_extensions=self.distribution.get_name() == 'datalad')
+
+        except ImportError as err:
+            raise err
+        if self.cmdsuite:
+            mod_name, suite_name = self.cmdsuite.split(':')
+            mod = __import__(mod_name, fromlist=mod_name.split('.'))
+            suite = getattr(mod, suite_name)
+            self.cmdlist = [c[2] if len(c) > 2 else c[1].replace('_', '-').lower()
+                            for c in suite[1]]
+
+        self.announce('Writing man page(s) to %s' % self.manpath)
+        self._today = datetime.date.today()
+
+    @classmethod
+    def handle_module(cls, mod_name, **kwargs):
+        """Module specific handling.
+
+        This particular one does
+        1. Memorize (at class level) the module name of interest here
+        2. Check if 'datalad.extensions' are specified for the module,
+           and then analyzes them to obtain command names it provides
+
+        If cmdline commands are found, its entries are to be used instead of
+        the ones in datalad's _parser.
+
+        Parameters
+        ----------
+        **kwargs:
+            all the kwargs which might be provided to setuptools.setup
+        """
+        cls.mod_name = mod_name
+
+        exts = kwargs.get('entry_points', {}).get('datalad.extensions', [])
+        for ext in exts:
+            assert '=' in ext      # should be label=module:obj
+            ext_label, mod_obj = ext.split('=', 1)
+            assert ':' in mod_obj  # should be module:obj
+            mod, obj = mod_obj.split(':', 1)
+            assert mod_name == mod  # AFAIK should be identical
+
+            mod = __import__(mod_name)
+            if hasattr(mod, obj):
+                command_suite = getattr(mod, obj)
+                assert len(command_suite) == 2  # as far as I see it
+                if not hasattr(cls, 'cmdline_names'):
+                    cls.cmdline_names = []
+                cls.cmdline_names += [
+                    cmd
+                    for _, _, cmd, _ in command_suite[1]
+                ]
+
+    def run(self):
+
+        dist = self.distribution
+        #homepage = dist.get_url()
+        #appname = self._parser.prog
+        appname = 'datalad'
+
+        cfg = read_configuration(
+            opj(dirname(dirname(__file__)), 'setup.cfg'))['metadata']
+
+        sections = {
+            'Authors': """{0} is developed by {1} <{2}>.""".format(
+                appname, cfg['author'], cfg['author_email']),
+        }
+
+        for cls, opath, ext in ((fmt.ManPageFormatter, self.manpath, '1'),
+                                (fmt.RSTManPageFormatter, self.rstpath, 'rst')):
+            if not os.path.exists(opath):
+                os.makedirs(opath)
+            for cmdname in getattr(self, 'cmdline_names', list(self._parser)):
+                if hasattr(self, 'cmdlist') and cmdname not in self.cmdlist:
+                    continue
+                p = self._parser[cmdname]
+                cmdname = "{0}{1}".format(
+                    'datalad ' if cmdname != 'datalad' else '',
+                    cmdname)
+                format = cls(
+                    cmdname,
+                    ext_sections=sections,
+                    version=versioneer.get_version())
+                formatted = format.format_man_page(p)
+                with open(opj(opath, '{0}.{1}'.format(
+                        cmdname.replace(' ', '-'),
+                        ext)),
+                        'w') as f:
+                    f.write(formatted)
+
+
+class BuildConfigInfo(Command):
+    description = 'Generate RST documentation for all config items.'
+
+    user_options = [
+        ('rstpath=', None, 'output path for RST file'),
+    ]
+
+    def initialize_options(self):
+        self.rstpath = opj('docs', 'source', 'generated', 'cfginfo')
+
+    def finalize_options(self):
+        if self.rstpath is None:
+            raise DistutilsOptionError('\'rstpath\' option is required')
+        self.announce('Generating configuration documentation')
+
+    def run(self):
+        opath = self.rstpath
+        if not os.path.exists(opath):
+            os.makedirs(opath)
+
+        from datalad.interface.common_cfg import definitions as cfgdefs
+        from datalad.dochelpers import _indent
+
+        categories = {
+            'global': {},
+            'local': {},
+            'dataset': {},
+            'misc': {}
+        }
+        for term, v in cfgdefs.items():
+            categories[v.get('destination', 'misc')][term] = v
+
+        for cat in categories:
+            with open(opj(opath, '{}.rst.in'.format(cat)), 'w') as rst:
+                rst.write('.. glossary::\n')
+                for term, v in sorted(categories[cat].items(), key=lambda x: x[0]):
+                    rst.write(_indent(term, '\n  '))
+                    qtype, docs = v.get('ui', (None, {}))
+                    desc_tmpl = '\n'
+                    if 'title' in docs:
+                        desc_tmpl += '{title}:\n'
+                    if 'text' in docs:
+                        desc_tmpl += '{text}\n'
+                    if 'default' in v:
+                        default = v['default']
+                        if hasattr(default, 'replace'):
+                            # protect against leaking specific home dirs
+                            v['default'] = default.replace(os.path.expanduser('~'), '~')
+                        desc_tmpl += 'Default: {default}\n'
+                    if 'type' in v:
+                        type_ = v['type']
+                        if hasattr(type_, 'long_description'):
+                            type_ = type_.long_description()
+                        else:
+                            type_ = type_.__name__
+                        desc_tmpl += '\n[{type}]\n'
+                        v['type'] = type_
+                    if desc_tmpl == '\n':
+                        # we need something to avoid joining terms
+                        desc_tmpl += 'undocumented\n'
+                    v.update(docs)
+                    rst.write(_indent(desc_tmpl.format(**v), '    '))

+ 31 - 0
datalad_helloworld/__init__.py

@@ -0,0 +1,31 @@
+"""DataLad demo extension"""
+
+__docformat__ = 'restructuredtext'
+
+import logging
+lgr = logging.getLogger('datalad.helloworld')
+
+# Defines a datalad command suite.
+# This variable must be bound as a setuptools entrypoint
+# to be found by datalad
+command_suite = (
+    # description of the command suite, displayed in cmdline help
+    "Demo DataLad command suite",
+    [
+        # specification of a command, any number of commands can be defined
+        (
+            # importable module that contains the command implementation
+            'datalad_helloworld.hello_cmd',
+            # name of the command class implementation in above module
+            'HelloWorld',
+            # optional name of the command in the cmdline API
+            'hello-cmd',
+            # optional name of the command in the Python API
+            'hello_cmd'
+        ),
+    ]
+)
+
+from ._version import get_versions
+__version__ = get_versions()['version']
+del get_versions

+ 520 - 0
datalad_helloworld/_version.py

@@ -0,0 +1,520 @@
+
+# This file helps to compute a version number in source trees obtained from
+# git-archive tarball (such as those provided by githubs download-from-tag
+# feature). Distribution tarballs (built by setup.py sdist) and build
+# directories (produced by setup.py build) will contain a much shorter file
+# that just contains the computed version number.
+
+# This file is released into the public domain. Generated by
+# versioneer-0.18 (https://github.com/warner/python-versioneer)
+
+"""Git implementation of _version.py."""
+
+import errno
+import os
+import re
+import subprocess
+import sys
+
+
+def get_keywords():
+    """Get the keywords needed to look up the version information."""
+    # these strings will be replaced by git during git-archive.
+    # setup.py/versioneer.py will grep for the variable names, so they must
+    # each be defined on a line of their own. _version.py will just call
+    # get_keywords().
+    git_refnames = "$Format:%d$"
+    git_full = "$Format:%H$"
+    git_date = "$Format:%ci$"
+    keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
+    return keywords
+
+
+class VersioneerConfig:
+    """Container for Versioneer configuration parameters."""
+
+
+def get_config():
+    """Create, populate and return the VersioneerConfig() object."""
+    # these strings are filled in when 'setup.py versioneer' creates
+    # _version.py
+    cfg = VersioneerConfig()
+    cfg.VCS = "git"
+    cfg.style = "pep440"
+    cfg.tag_prefix = ""
+    cfg.parentdir_prefix = ""
+    cfg.versionfile_source = "datalad_helloworld/_version.py"
+    cfg.verbose = False
+    return cfg
+
+
+class NotThisMethod(Exception):
+    """Exception raised if a method is not valid for the current scenario."""
+
+
+LONG_VERSION_PY = {}
+HANDLERS = {}
+
+
+def register_vcs_handler(vcs, method):  # decorator
+    """Decorator to mark a method as the handler for a particular VCS."""
+    def decorate(f):
+        """Store f in HANDLERS[vcs][method]."""
+        if vcs not in HANDLERS:
+            HANDLERS[vcs] = {}
+        HANDLERS[vcs][method] = f
+        return f
+    return decorate
+
+
+def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
+                env=None):
+    """Call the given command(s)."""
+    assert isinstance(commands, list)
+    p = None
+    for c in commands:
+        try:
+            dispcmd = str([c] + args)
+            # remember shell=False, so use git.cmd on windows, not just git
+            p = subprocess.Popen([c] + args, cwd=cwd, env=env,
+                                 stdout=subprocess.PIPE,
+                                 stderr=(subprocess.PIPE if hide_stderr
+                                         else None))
+            break
+        except EnvironmentError:
+            e = sys.exc_info()[1]
+            if e.errno == errno.ENOENT:
+                continue
+            if verbose:
+                print("unable to run %s" % dispcmd)
+                print(e)
+            return None, None
+    else:
+        if verbose:
+            print("unable to find command, tried %s" % (commands,))
+        return None, None
+    stdout = p.communicate()[0].strip()
+    if sys.version_info[0] >= 3:
+        stdout = stdout.decode()
+    if p.returncode != 0:
+        if verbose:
+            print("unable to run %s (error)" % dispcmd)
+            print("stdout was %s" % stdout)
+        return None, p.returncode
+    return stdout, p.returncode
+
+
+def versions_from_parentdir(parentdir_prefix, root, verbose):
+    """Try to determine the version from the parent directory name.
+
+    Source tarballs conventionally unpack into a directory that includes both
+    the project name and a version string. We will also support searching up
+    two directory levels for an appropriately named parent directory
+    """
+    rootdirs = []
+
+    for i in range(3):
+        dirname = os.path.basename(root)
+        if dirname.startswith(parentdir_prefix):
+            return {"version": dirname[len(parentdir_prefix):],
+                    "full-revisionid": None,
+                    "dirty": False, "error": None, "date": None}
+        else:
+            rootdirs.append(root)
+            root = os.path.dirname(root)  # up a level
+
+    if verbose:
+        print("Tried directories %s but none started with prefix %s" %
+              (str(rootdirs), parentdir_prefix))
+    raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
+
+
+@register_vcs_handler("git", "get_keywords")
+def git_get_keywords(versionfile_abs):
+    """Extract version information from the given file."""
+    # the code embedded in _version.py can just fetch the value of these
+    # keywords. When used from setup.py, we don't want to import _version.py,
+    # so we do it with a regexp instead. This function is not used from
+    # _version.py.
+    keywords = {}
+    try:
+        f = open(versionfile_abs, "r")
+        for line in f.readlines():
+            if line.strip().startswith("git_refnames ="):
+                mo = re.search(r'=\s*"(.*)"', line)
+                if mo:
+                    keywords["refnames"] = mo.group(1)
+            if line.strip().startswith("git_full ="):
+                mo = re.search(r'=\s*"(.*)"', line)
+                if mo:
+                    keywords["full"] = mo.group(1)
+            if line.strip().startswith("git_date ="):
+                mo = re.search(r'=\s*"(.*)"', line)
+                if mo:
+                    keywords["date"] = mo.group(1)
+        f.close()
+    except EnvironmentError:
+        pass
+    return keywords
+
+
+@register_vcs_handler("git", "keywords")
+def git_versions_from_keywords(keywords, tag_prefix, verbose):
+    """Get version information from git keywords."""
+    if not keywords:
+        raise NotThisMethod("no keywords at all, weird")
+    date = keywords.get("date")
+    if date is not None:
+        # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant
+        # datestamp. However we prefer "%ci" (which expands to an "ISO-8601
+        # -like" string, which we must then edit to make compliant), because
+        # it's been around since git-1.5.3, and it's too difficult to
+        # discover which version we're using, or to work around using an
+        # older one.
+        date = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
+    refnames = keywords["refnames"].strip()
+    if refnames.startswith("$Format"):
+        if verbose:
+            print("keywords are unexpanded, not using")
+        raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
+    refs = set([r.strip() for r in refnames.strip("()").split(",")])
+    # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
+    # just "foo-1.0". If we see a "tag: " prefix, prefer those.
+    TAG = "tag: "
+    tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)])
+    if not tags:
+        # Either we're using git < 1.8.3, or there really are no tags. We use
+        # a heuristic: assume all version tags have a digit. The old git %d
+        # expansion behaves like git log --decorate=short and strips out the
+        # refs/heads/ and refs/tags/ prefixes that would let us distinguish
+        # between branches and tags. By ignoring refnames without digits, we
+        # filter out many common branch names like "release" and
+        # "stabilization", as well as "HEAD" and "master".
+        tags = set([r for r in refs if re.search(r'\d', r)])
+        if verbose:
+            print("discarding '%s', no digits" % ",".join(refs - tags))
+    if verbose:
+        print("likely tags: %s" % ",".join(sorted(tags)))
+    for ref in sorted(tags):
+        # sorting will prefer e.g. "2.0" over "2.0rc1"
+        if ref.startswith(tag_prefix):
+            r = ref[len(tag_prefix):]
+            if verbose:
+                print("picking %s" % r)
+            return {"version": r,
+                    "full-revisionid": keywords["full"].strip(),
+                    "dirty": False, "error": None,
+                    "date": date}
+    # no suitable tags, so version is "0+unknown", but full hex is still there
+    if verbose:
+        print("no suitable tags, using unknown + full revision id")
+    return {"version": "0+unknown",
+            "full-revisionid": keywords["full"].strip(),
+            "dirty": False, "error": "no suitable tags", "date": None}
+
+
+@register_vcs_handler("git", "pieces_from_vcs")
+def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
+    """Get version from 'git describe' in the root of the source tree.
+
+    This only gets called if the git-archive 'subst' keywords were *not*
+    expanded, and _version.py hasn't already been rewritten with a short
+    version string, meaning we're inside a checked out source tree.
+    """
+    GITS = ["git"]
+    if sys.platform == "win32":
+        GITS = ["git.cmd", "git.exe"]
+
+    out, rc = run_command(GITS, ["--git-dir=.git", "rev-parse", "--git-dir"], cwd=root,
+                          hide_stderr=True)
+    if rc != 0:
+        if verbose:
+            print("Directory %s not under git control" % root)
+        raise NotThisMethod("'git rev-parse --git-dir' returned error")
+
+    # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
+    # if there isn't one, this yields HEX[-dirty] (no NUM)
+    describe_out, rc = run_command(GITS, ["--git-dir=.git", "describe", "--tags", "--dirty",
+                                          "--always", "--long",
+                                          "--match", "%s*" % tag_prefix],
+                                   cwd=root)
+    # --long was added in git-1.5.5
+    if describe_out is None:
+        raise NotThisMethod("'git describe' failed")
+    describe_out = describe_out.strip()
+    full_out, rc = run_command(GITS, ["--git-dir=.git", "rev-parse", "HEAD"], cwd=root)
+    if full_out is None:
+        raise NotThisMethod("'git rev-parse' failed")
+    full_out = full_out.strip()
+
+    pieces = {}
+    pieces["long"] = full_out
+    pieces["short"] = full_out[:7]  # maybe improved later
+    pieces["error"] = None
+
+    # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
+    # TAG might have hyphens.
+    git_describe = describe_out
+
+    # look for -dirty suffix
+    dirty = git_describe.endswith("-dirty")
+    pieces["dirty"] = dirty
+    if dirty:
+        git_describe = git_describe[:git_describe.rindex("-dirty")]
+
+    # now we have TAG-NUM-gHEX or HEX
+
+    if "-" in git_describe:
+        # TAG-NUM-gHEX
+        mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
+        if not mo:
+            # unparseable. Maybe git-describe is misbehaving?
+            pieces["error"] = ("unable to parse git-describe output: '%s'"
+                               % describe_out)
+            return pieces
+
+        # tag
+        full_tag = mo.group(1)
+        if not full_tag.startswith(tag_prefix):
+            if verbose:
+                fmt = "tag '%s' doesn't start with prefix '%s'"
+                print(fmt % (full_tag, tag_prefix))
+            pieces["error"] = ("tag '%s' doesn't start with prefix '%s'"
+                               % (full_tag, tag_prefix))
+            return pieces
+        pieces["closest-tag"] = full_tag[len(tag_prefix):]
+
+        # distance: number of commits since tag
+        pieces["distance"] = int(mo.group(2))
+
+        # commit: short hex revision ID
+        pieces["short"] = mo.group(3)
+
+    else:
+        # HEX: no tags
+        pieces["closest-tag"] = None
+        count_out, rc = run_command(GITS, ["--git-dir=.git", "rev-list", "HEAD", "--count"],
+                                    cwd=root)
+        pieces["distance"] = int(count_out)  # total number of commits
+
+    # commit date: see ISO-8601 comment in git_versions_from_keywords()
+    date = run_command(GITS, ["--git-dir=.git", "show", "-s", "--format=%ci", "HEAD"],
+                       cwd=root)[0].strip()
+    pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
+
+    return pieces
+
+
+def plus_or_dot(pieces):
+    """Return a + if we don't already have one, else return a ."""
+    if "+" in pieces.get("closest-tag", ""):
+        return "."
+    return "+"
+
+
+def render_pep440(pieces):
+    """Build up version string, with post-release "local version identifier".
+
+    Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
+    get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty
+
+    Exceptions:
+    1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty]
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"] or pieces["dirty"]:
+            rendered += plus_or_dot(pieces)
+            rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
+            if pieces["dirty"]:
+                rendered += ".dirty"
+    else:
+        # exception #1
+        rendered = "0+untagged.%d.g%s" % (pieces["distance"],
+                                          pieces["short"])
+        if pieces["dirty"]:
+            rendered += ".dirty"
+    return rendered
+
+
+def render_pep440_pre(pieces):
+    """TAG[.post.devDISTANCE] -- No -dirty.
+
+    Exceptions:
+    1: no tags. 0.post.devDISTANCE
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"]:
+            rendered += ".post.dev%d" % pieces["distance"]
+    else:
+        # exception #1
+        rendered = "0.post.dev%d" % pieces["distance"]
+    return rendered
+
+
+def render_pep440_post(pieces):
+    """TAG[.postDISTANCE[.dev0]+gHEX] .
+
+    The ".dev0" means dirty. Note that .dev0 sorts backwards
+    (a dirty tree will appear "older" than the corresponding clean one),
+    but you shouldn't be releasing software with -dirty anyways.
+
+    Exceptions:
+    1: no tags. 0.postDISTANCE[.dev0]
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"] or pieces["dirty"]:
+            rendered += ".post%d" % pieces["distance"]
+            if pieces["dirty"]:
+                rendered += ".dev0"
+            rendered += plus_or_dot(pieces)
+            rendered += "g%s" % pieces["short"]
+    else:
+        # exception #1
+        rendered = "0.post%d" % pieces["distance"]
+        if pieces["dirty"]:
+            rendered += ".dev0"
+        rendered += "+g%s" % pieces["short"]
+    return rendered
+
+
+def render_pep440_old(pieces):
+    """TAG[.postDISTANCE[.dev0]] .
+
+    The ".dev0" means dirty.
+
+    Eexceptions:
+    1: no tags. 0.postDISTANCE[.dev0]
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"] or pieces["dirty"]:
+            rendered += ".post%d" % pieces["distance"]
+            if pieces["dirty"]:
+                rendered += ".dev0"
+    else:
+        # exception #1
+        rendered = "0.post%d" % pieces["distance"]
+        if pieces["dirty"]:
+            rendered += ".dev0"
+    return rendered
+
+
+def render_git_describe(pieces):
+    """TAG[-DISTANCE-gHEX][-dirty].
+
+    Like 'git describe --tags --dirty --always'.
+
+    Exceptions:
+    1: no tags. HEX[-dirty]  (note: no 'g' prefix)
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"]:
+            rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
+    else:
+        # exception #1
+        rendered = pieces["short"]
+    if pieces["dirty"]:
+        rendered += "-dirty"
+    return rendered
+
+
+def render_git_describe_long(pieces):
+    """TAG-DISTANCE-gHEX[-dirty].
+
+    Like 'git describe --tags --dirty --always -long'.
+    The distance/hash is unconditional.
+
+    Exceptions:
+    1: no tags. HEX[-dirty]  (note: no 'g' prefix)
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
+    else:
+        # exception #1
+        rendered = pieces["short"]
+    if pieces["dirty"]:
+        rendered += "-dirty"
+    return rendered
+
+
+def render(pieces, style):
+    """Render the given version pieces into the requested style."""
+    if pieces["error"]:
+        return {"version": "unknown",
+                "full-revisionid": pieces.get("long"),
+                "dirty": None,
+                "error": pieces["error"],
+                "date": None}
+
+    if not style or style == "default":
+        style = "pep440"  # the default
+
+    if style == "pep440":
+        rendered = render_pep440(pieces)
+    elif style == "pep440-pre":
+        rendered = render_pep440_pre(pieces)
+    elif style == "pep440-post":
+        rendered = render_pep440_post(pieces)
+    elif style == "pep440-old":
+        rendered = render_pep440_old(pieces)
+    elif style == "git-describe":
+        rendered = render_git_describe(pieces)
+    elif style == "git-describe-long":
+        rendered = render_git_describe_long(pieces)
+    else:
+        raise ValueError("unknown style '%s'" % style)
+
+    return {"version": rendered, "full-revisionid": pieces["long"],
+            "dirty": pieces["dirty"], "error": None,
+            "date": pieces.get("date")}
+
+
+def get_versions():
+    """Get version information or return default if unable to do so."""
+    # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
+    # __file__, we can work backwards from there to the root. Some
+    # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which
+    # case we can only use expanded keywords.
+
+    cfg = get_config()
+    verbose = cfg.verbose
+
+    try:
+        return git_versions_from_keywords(get_keywords(), cfg.tag_prefix,
+                                          verbose)
+    except NotThisMethod:
+        pass
+
+    try:
+        root = os.path.realpath(__file__)
+        # versionfile_source is the relative path from the top of the source
+        # tree (where the .git directory might live) to this file. Invert
+        # this to find the root from __file__.
+        for i in cfg.versionfile_source.split('/'):
+            root = os.path.dirname(root)
+    except NameError:
+        return {"version": "0+unknown", "full-revisionid": None,
+                "dirty": None,
+                "error": "unable to find root of source tree",
+                "date": None}
+
+    try:
+        pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose)
+        return render(pieces, cfg.style)
+    except NotThisMethod:
+        pass
+
+    try:
+        if cfg.parentdir_prefix:
+            return versions_from_parentdir(cfg.parentdir_prefix, root, verbose)
+    except NotThisMethod:
+        pass
+
+    return {"version": "0+unknown", "full-revisionid": None,
+            "dirty": None,
+            "error": "unable to compute version", "date": None}

+ 1 - 0
datalad_helloworld/conftest.py

@@ -0,0 +1 @@
+from datalad.conftest import setup_package

+ 76 - 0
datalad_helloworld/hello_cmd.py

@@ -0,0 +1,76 @@
+"""DataLad demo command"""
+
+__docformat__ = 'restructuredtext'
+
+from os.path import curdir
+from os.path import abspath
+
+from datalad.interface.base import Interface
+from datalad.interface.base import build_doc
+from datalad.support.param import Parameter
+from datalad.distribution.dataset import datasetmethod
+from datalad.interface.utils import eval_results
+from datalad.support.constraints import EnsureChoice
+
+from datalad.interface.results import get_status_dict
+
+import logging
+lgr = logging.getLogger('datalad.helloworld.hello_cmd')
+
+
+# decoration auto-generates standard help
+@build_doc
+# all commands must be derived from Interface
+class HelloWorld(Interface):
+    # first docstring line is used a short description in the cmdline help
+    # the rest is put in the verbose help and manpage
+    """Short description of the command
+
+    Long description of arbitrary volume.
+    """
+
+    # parameters of the command, must be exhaustive
+    _params_ = dict(
+        # name of the parameter, must match argument name
+        language=Parameter(
+            # cmdline argument definitions, incl aliases
+            args=("-l", "--language"),
+            # documentation
+            doc="""language to say "hello" in""",
+            # type checkers, constraint definition is automatically
+            # added to the docstring
+            constraints=EnsureChoice('en', 'de')),
+    )
+
+    @staticmethod
+    # decorator binds the command to the Dataset class as a method
+    @datasetmethod(name='hello_cmd')
+    # generic handling of command results (logging, rendering, filtering, ...)
+    @eval_results
+    # signature must match parameter list above
+    # additional generic arguments are added by decorators
+    def __call__(language='en'):
+        if language == 'en':
+            msg = 'Hello!'
+        elif language == 'de':
+            msg = 'Tachchen!'
+        else:
+            msg = ("unknown language: '%s'", language)
+
+        # commands should be implemented as generators and should
+        # report any results by yielding status dictionaries
+        yield get_status_dict(
+            # an action label must be defined, the command name make a good
+            # default
+            action='demo',
+            # most results will be about something associated with a dataset
+            # (component), reported paths MUST be absolute
+            path=abspath(curdir),
+            # status labels are used to identify how a result will be reported
+            # and can be used for filtering
+            status='ok' if language in ('en', 'de') else 'error',
+            # arbitrary result message, can be a str or tuple. in the latter
+            # case string expansion with arguments is delayed until the
+            # message actually needs to be rendered (analog to exception
+            # messages)
+            message=msg)

+ 0 - 0
datalad_helloworld/tests/__init__.py


+ 11 - 0
datalad_helloworld/tests/test_register.py

@@ -0,0 +1,11 @@
+from datalad.tests.utils_pytest import assert_result_count
+
+
+def test_register():
+    import datalad.api as da
+    assert hasattr(da, 'hello_cmd')
+    assert_result_count(
+        da.hello_cmd(),
+        1,
+        action='demo')
+

+ 192 - 0
docs/Makefile

@@ -0,0 +1,192 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    = -W
+SPHINXBUILD   = python -m sphinx
+PAPER         =
+BUILDDIR      = build
+
+# User-friendly check for sphinx-build
+#ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
+#$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
+#endif
+
+# Internal variables.
+PAPEROPT_a4     = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
+# the i18n builder cannot share the environment and doctrees with the others
+I18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext
+
+help:
+	@echo "Please use \`make <target>' where <target> is one of"
+	@echo "  html       to make standalone HTML files"
+	@echo "  dirhtml    to make HTML files named index.html in directories"
+	@echo "  singlehtml to make a single large HTML file"
+	@echo "  pickle     to make pickle files"
+	@echo "  json       to make JSON files"
+	@echo "  htmlhelp   to make HTML files and a HTML help project"
+	@echo "  qthelp     to make HTML files and a qthelp project"
+	@echo "  applehelp  to make an Apple Help Book"
+	@echo "  devhelp    to make HTML files and a Devhelp project"
+	@echo "  epub       to make an epub"
+	@echo "  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+	@echo "  latexpdf   to make LaTeX files and run them through pdflatex"
+	@echo "  latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
+	@echo "  text       to make text files"
+	@echo "  man        to make manual pages"
+	@echo "  texinfo    to make Texinfo files"
+	@echo "  info       to make Texinfo files and run them through makeinfo"
+	@echo "  gettext    to make PO message catalogs"
+	@echo "  changes    to make an overview of all changed/added/deprecated items"
+	@echo "  xml        to make Docutils-native XML files"
+	@echo "  pseudoxml  to make pseudoxml-XML files for display purposes"
+	@echo "  linkcheck  to check all external links for integrity"
+	@echo "  doctest    to run all doctests embedded in the documentation (if enabled)"
+	@echo "  coverage   to run coverage check of the documentation (if enabled)"
+
+clean:
+	rm -rf $(BUILDDIR)/* source/generated source/_extras/schema.json
+
+html:
+	$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+	@echo
+	@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+	$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+	@echo
+	@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+	$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+	@echo
+	@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+	$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+	@echo
+	@echo "Build finished; now you can process the pickle files."
+
+json:
+	$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+	@echo
+	@echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+	$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+	@echo
+	@echo "Build finished; now you can run HTML Help Workshop with the" \
+	      ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+	$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+	@echo
+	@echo "Build finished; now you can run "qcollectiongenerator" with the" \
+	      ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+	@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/datalad_helloworld.qhcp"
+	@echo "To view the help file:"
+	@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/datalad_helloworld.qhc"
+
+applehelp:
+	$(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
+	@echo
+	@echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
+	@echo "N.B. You won't be able to view it unless you put it in" \
+	      "~/Library/Documentation/Help or install it in your application" \
+	      "bundle."
+
+devhelp:
+	$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+	@echo
+	@echo "Build finished."
+	@echo "To view the help file:"
+	@echo "# mkdir -p $$HOME/.local/share/devhelp/datalad_helloworld"
+	@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/datalad_helloworld"
+	@echo "# devhelp"
+
+epub:
+	$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+	@echo
+	@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo
+	@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+	@echo "Run \`make' in that directory to run these through (pdf)latex" \
+	      "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo "Running LaTeX files through pdflatex..."
+	$(MAKE) -C $(BUILDDIR)/latex all-pdf
+	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+latexpdfja:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo "Running LaTeX files through platex and dvipdfmx..."
+	$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
+	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+	$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+	@echo
+	@echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+	$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+	@echo
+	@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+texinfo:
+	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+	@echo
+	@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
+	@echo "Run \`make' in that directory to run these through makeinfo" \
+	      "(use \`make info' here to do that automatically)."
+
+info:
+	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+	@echo "Running Texinfo files through makeinfo..."
+	make -C $(BUILDDIR)/texinfo info
+	@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
+
+gettext:
+	$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
+	@echo
+	@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
+
+changes:
+	$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+	@echo
+	@echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+	$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+	@echo
+	@echo "Link check complete; look for any errors in the above output " \
+	      "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+	$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+	@echo "Testing of doctests in the sources finished, look at the " \
+	      "results in $(BUILDDIR)/doctest/output.txt."
+
+coverage:
+	$(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
+	@echo "Testing of coverage in the sources finished, look at the " \
+	      "results in $(BUILDDIR)/coverage/python.txt."
+
+xml:
+	$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
+	@echo
+	@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
+
+pseudoxml:
+	$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
+	@echo
+	@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."

+ 35 - 0
docs/README.md

@@ -0,0 +1,35 @@
+## Editing, building, and publishing extension documentation
+
+
+The `datalad-extension-template` uses [Sphinx](https://www.sphinx-doc.org/en/master/index.html#) for document generation
+and suggests using [Read the Docs](https://docs.readthedocs.io/en/stable/) for automatic documentation building, versioning, and hosting.
+
+Once you are ready to document your extension software, take note of the following:
+
+### Document editing
+
+Edit your `docs/source/index.rst` file using [reStructuredText](https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html),
+which is the default plaintext markup language used by Sphinx. Add further documentation as needed.
+
+### Local testing
+
+For testing locally whether your documentation builds and renders correctly, first install the developer requirements from the repository's root directory:
+```
+pip install -r requirements-devel.txt
+```
+
+Then build the documentation locally:
+```
+make -C docs html
+```
+
+Navigate to `docs/build/` and open `index.html` in your browser to view your documentation.
+
+### Remote building and testing
+
+The GitHub Action workflow located at `.github/workflows/docbuild.yml` will run on a push or pull request to your GitHub repository's master/main branch. This builds the documentation remotely and serves as an automated documentation test.
+
+### Publishing your documentation
+
+- If you maintain your extension yourself *outside of the scope of the DataLad GitHub organization*, you can follow [these instructions](https://docs.readthedocs.io/en/stable/integrations.html) for integrating your version control system (such as GitHub) with Read the Docs.
+- If your extension is *maintained by the DataLad developer team*, please create an issue asking for help with the setup.

BIN
docs/source/_static/datalad_logo.png


+ 23 - 0
docs/source/_templates/autosummary/module.rst

@@ -0,0 +1,23 @@
+{% if fullname == 'datalad.api' -%}
+`{{ name }}`
+=={%- for c in name %}={%- endfor %}
+.. automodule:: datalad.api
+
+.. currentmodule:: datalad.api
+
+{% for item in members if not item.startswith('_') %}
+`{{ item }}`
+--{%- for c in item %}-{%- endfor %}
+
+.. autofunction:: {{ item }}
+{% endfor %}
+
+{% else -%}
+{{ fullname }}
+{{ underline }}
+
+.. automodule:: {{ fullname }}
+   :members:
+   :undoc-members:
+   :show-inheritance:
+{% endif %}

+ 145 - 0
docs/source/conf.py

@@ -0,0 +1,145 @@
+# -*- coding: utf-8 -*-
+#
+# datalad_helloworld documentation build configuration file, created by
+# sphinx-quickstart on Tue Oct 13 08:41:19 2015.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+import sys
+import subprocess
+
+import datetime
+from os.path import (
+    abspath,
+    dirname,
+    exists,
+    join as opj,
+)
+from os import pardir
+
+import datalad_helloworld
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#sys.path.insert(0, os.path.abspath('.'))
+
+# generate missing pieces
+for setup_py_path in (opj(pardir, 'setup.py'),  # travis
+                      opj(pardir, pardir, 'setup.py')):  # RTD
+    if exists(setup_py_path):
+        sys.path.insert(0, abspath(dirname(setup_py_path)))
+        # Build manpage
+        try:
+            subprocess.run(
+                args=[setup_py_path, 'build_manpage',
+                     '--cmdsuite', 'datalad_helloworld:command_suite',
+                     '--manpath', abspath(opj(
+                         dirname(setup_py_path), 'build', 'man')),
+                     '--rstpath', opj(dirname(__file__), 'generated', 'man'),
+                     ],
+                check=True,
+            )
+        except (FileNotFoundError, subprocess.CalledProcessError):
+            # shut up and do your best
+            pass
+
+# -- General configuration ------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+    'sphinx.ext.autodoc',
+    'sphinx.ext.autosummary',
+    'sphinx.ext.doctest',
+    'sphinx.ext.intersphinx',
+    'sphinx.ext.todo',
+    'sphinx.ext.coverage',
+    'sphinx.ext.mathjax',
+    'sphinx.ext.ifconfig',
+    'sphinx.ext.inheritance_diagram',
+    'sphinx.ext.viewcode',
+    'sphinx.ext.napoleon',
+]
+
+# for the module reference
+autosummary_generate = True
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+# source_suffix = ['.rst', '.md']
+source_suffix = '.rst'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'Datalad Extension Template'
+copyright = u'2018-{}, DataLad team'.format(datetime.datetime.now().year)
+author = u'DataLad team'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+version = datalad_helloworld.__version__
+release = version
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = 'en'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+exclude_patterns = []
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# If true, `todo` and `todoList` produce output, else they produce nothing.
+todo_include_todos = True
+
+# Example configuration for intersphinx: refer to the Python standard library.
+intersphinx_mapping = {'https://docs.python.org/': None}
+
+# -- Options for HTML output ----------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+html_theme = 'sphinx_rtd_theme'
+
+# The name of an image file (relative to this directory) to place at the top
+# of the sidebar.
+html_logo = '_static/datalad_logo.png'
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+# If true, the index is split into individual pages for each letter.
+html_split_index = True
+
+# If true, links to the reST sources are added to the pages.
+html_show_sourcelink = False
+
+# smart quotes are incompatible with the RST flavor of the generated manpages
+# but see `smartquotes_action` for more fine-grained control, in case
+# some of this functionality is needed
+smartquotes = False

+ 37 - 0
docs/source/index.rst

@@ -0,0 +1,37 @@
+DataLad extension template
+**************************
+
+This is a template for creating a `DataLad <http://datalad.org>`__ extension
+that equips DataLad with additional functionality.
+
+
+API
+===
+
+High-level API commands
+-----------------------
+
+.. currentmodule:: datalad.api
+.. autosummary::
+   :toctree: generated
+
+   hello_cmd
+
+
+Command line reference
+----------------------
+
+.. toctree::
+   :maxdepth: 1
+
+   generated/man/datalad-hello-cmd
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
+
+.. |---| unicode:: U+02014 .. em dash

+ 2 - 0
pyproject.toml

@@ -0,0 +1,2 @@
+[build-system]
+requires = ["setuptools >= 43.0.0", "wheel"]

+ 6 - 0
requirements-devel.txt

@@ -0,0 +1,6 @@
+# requirements for a development environment
+pytest
+pytest-cov
+coverage
+sphinx
+sphinx_rtd_theme

+ 53 - 0
setup.cfg

@@ -0,0 +1,53 @@
+[metadata]
+url = https://github.com/datalad/datalad-extension-template
+author = The DataLad Team and Contributors
+author_email = team@datalad.org
+description = demo DataLad extension package
+long_description = file:README.md
+long_description_content_type = text/markdown; charset=UTF-8
+license = MIT
+classifiers =
+    Programming Language :: Python
+    License :: OSI Approved :: BSD License
+    Programming Language :: Python :: 3
+
+[options]
+python_requires = >= 3.7
+install_requires =
+    datalad >= 0.17.0
+packages = find_namespace:
+include_package_data = True
+
+[options.packages.find]
+include = datalad_helloworld*
+
+[options.extras_require]
+# this matches the name used by -core and what is expected by some CI setups
+devel =
+    pytest
+    coverage
+
+[options.entry_points]
+# 'datalad.extensions' is THE entrypoint inspected by the datalad API builders
+datalad.extensions =
+    # the label in front of '=' is the command suite label
+    # the entrypoint can point to any symbol of any name, as long it is
+    # valid datalad interface specification (see demo in this extensions)
+    helloworld = datalad_helloworld:command_suite
+
+[versioneer]
+# See the docstring in versioneer.py for instructions. Note that you must
+# re-run 'versioneer.py setup' after changing this section, and commit the
+# resulting files.
+VCS = git
+style = pep440
+versionfile_source = datalad_helloworld/_version.py
+versionfile_build = datalad_helloworld/_version.py
+tag_prefix =
+parentdir_prefix =
+
+[coverage:report]
+show_missing = True
+omit =
+    # versioneer code
+    datalad_helloworld/_version.py

+ 18 - 0
setup.py

@@ -0,0 +1,18 @@
+#!/usr/bin/env python
+
+import sys
+from setuptools import setup
+import versioneer
+
+from _datalad_buildsupport.setup import (
+    BuildManPage,
+)
+
+cmdclass = versioneer.get_cmdclass()
+cmdclass.update(build_manpage=BuildManPage)
+
+if __name__ == '__main__':
+    setup(name='datalad_helloworld',
+          version=versioneer.get_version(),
+          cmdclass=cmdclass,
+    )

+ 4 - 0
tools/ci/appveyor_env_setup.bat

@@ -0,0 +1,4 @@
+set PY=%1-x64
+set TMP=C:\DLTMP
+set TEMP=C:\DLTMP
+set PATH=C:\Python%PY%;C:\Python%PY%\Scripts;%PATH%

File diff suppressed because it is too large
+ 1822 - 0
versioneer.py