Repository: afonsoc12/firefly-cli (language: python)

Issue:
Support setting date for transaction
It would be extremely useful if we could set an arbitrary date when creating transactions.
Support setting date for transaction
It would be extremely useful if we could set an arbitrary date when creating transactions.


Discussion / hints:

Fix patch:
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..a67b53b
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,174 @@
+# Project files and folders
+.git
+.github
+test
+
+# firefly-cli config
+firefly-cli.ini
+
+# Flake 8
+.flake8
+
+# Ignore TravisCI after change to Github actions
+.travis.yml
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# IntelliJ
+.idea
+
+# === MacOS gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
diff --git a/.github/workflows/build_dev.yml b/.github/workflows/build_dev.yml
deleted file mode 100644
index 51d54c1..0000000
--- a/.github/workflows/build_dev.yml
+++ /dev/null
@@ -1,85 +0,0 @@
-name: CI development
-
-on:
-  push:
-    paths:
-      - '*/**.py'      # Run on non-root changes on python files
-
-jobs:
-
-  pypi:
-    name: Builds python distribution, publishes to TestPyPI
-    if: ${{ github.ref == 'refs/heads/main' && !startsWith( github.ref, 'refs/tags/v' ) }}
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v2
-        with:
-          fetch-depth: '0'
-      - name: Get version from versioneer
-        run: |
-          VER=$(echo $(python -c "import versioneer; print(versioneer.get_versions()['version'].replace('+', '-'))"))
-          echo "VERSION=$VER" >> $GITHUB_ENV
-      - name: Install pypa/build and build a binary wheel and a source tarball
-        run: |
-          python -m pip install build --user
-          python -m build --sdist --wheel --outdir dist/ .
-      - name: Publish distribution 📦 to TestPyPI
-        uses: pypa/gh-action-pypi-publish@master
-        with:
-          password: ${{ secrets.TEST_PYPI_API_TOKEN }}
-          repository_url: https://test.pypi.org/legacy/
-      - name: Create Github release (pre-release)
-        uses: marvinpinto/action-automatic-releases@latest
-        with:
-          repo_token: "${{ secrets.GITHUB_TOKEN }}"
-          automatic_release_tag: "dev-latest"
-          prerelease: true
-          title: "dev-v${{ env.VERSION }}"
-          files: |
-            LICENSE
-            dist/firefly*
-
-#  docker:
-#    name: Builds docker image and pushes to Docker Hub
-#    if: ${{ github.ref == 'refs/heads/main' && !startsWith( github.ref, 'refs/tags/v' ) }}
-#    runs-on: ubuntu-latest
-#    steps:
-#      - name: Checkout
-#        uses: actions/checkout@v2
-#        with:
-#          fetch-depth: '0'
-#      - name: Get version from versioneer
-#        run: |
-#          echo "${{github.ref}}"
-#          VER=$(echo $(python -c "import versioneer; print(versioneer.get_versions()['version'].replace('+', '-'))"))
-#          echo "VERSION=$VER" >> $GITHUB_ENV
-#      - name: Set up QEMU
-#        uses: docker/setup-qemu-action@v1
-#      - name: Set up Docker Buildx
-#        uses: docker/setup-buildx-action@v1
-#      - name: Login to DockerHub
-#        uses: docker/login-action@v1
-#        with:
-#          username: ${{ secrets.DOCKER_USERNAME }}
-#          password: ${{ secrets.DOCKER_TOKEN }}
-#      - name: Login to GitHub Container Registry
-#        uses: docker/login-action@v1
-#        with:
-#          registry: ghcr.io
-#          username: ${{ github.repository_owner }}
-#          password: ${{ secrets.GITHUB_TOKEN }}
-#      - name: Build and push
-#        uses: docker/build-push-action@v2
-#        with:
-#          context: .
-#          file: Dockerfile
-#          build-args: |
-#            VERSION=dev-v${{ env.VERSION }}
-#          platforms: linux/amd64,linux/arm64
-#          push: true
-#          tags: |
-#            afonsoc12/firefly-cli:dev-latest
-#            afonsoc12/firefly-cli:dev-v${{ env.VERSION }}
-#            ghcr.io/afonsoc12/firefly-cli:dev-latest
-#            ghcr.io/afonsoc12/firefly-cli:dev-v${{ env.VERSION }}
diff --git a/.github/workflows/build_releases.yml b/.github/workflows/build_releases.yml
deleted file mode 100644
index a14f7fa..0000000
--- a/.github/workflows/build_releases.yml
+++ /dev/null
@@ -1,78 +0,0 @@
-name: CI releases
-
-on:
-  push:
-    tags:
-      - 'v*'
-
-jobs:
-  pypi:
-    name: Builds python distribution, publishes to PyPI
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v2
-        with:
-          fetch-depth: '0'
-      - name: Get version from versioneer
-        run: |
-          VER=$(echo $(python -c "import versioneer; print(versioneer.get_versions()['version'].replace('+', '-'))"))
-          echo "VERSION=$VER" >> $GITHUB_ENV
-      - name: Install pypa/build and build a binary wheel and a source tarball
-        run: |
-          python -m pip install build --user
-          python -m build --sdist --wheel --outdir dist/ .
-      - name: Publish distribution 📦 to PyPI
-        uses: pypa/gh-action-pypi-publish@master
-        with:
-          password: ${{ secrets.PYPI_API_TOKEN }}
-      - name: Create Github release
-        uses: marvinpinto/action-automatic-releases@latest
-        with:
-          repo_token: "${{ secrets.GITHUB_TOKEN }}"
-          prerelease: false
-          title: "v${{ env.VERSION }}"
-          files: |
-            LICENSE
-            dist/firefly*
-
-#  docker:
-#    name: Builds docker image and pushes to Docker Hub
-#    runs-on: ubuntu-latest
-#    steps:
-#      - name: Checkout
-#        uses: actions/checkout@v2
-#      - name: Get version from versioneer
-#        run: |
-#          VER=$(echo $(python -c "import versioneer; print(versioneer.get_versions()['version'].replace('+', '-'))"))
-#          echo "VERSION=$VER" >> $GITHUB_ENV
-#      - name: Set up QEMU
-#        uses: docker/setup-qemu-action@v1
-#      - name: Set up Docker Buildx
-#        uses: docker/setup-buildx-action@v1
-#      - name: Login to DockerHub
-#        uses: docker/login-action@v1
-#        with:
-#          username: ${{ secrets.DOCKER_USERNAME }}
-#          password: ${{ secrets.DOCKER_TOKEN }}
-#      - name: Login to GitHub Container Registry
-#        uses: docker/login-action@v1
-#        with:
-#          registry: ghcr.io
-#          username: ${{ github.repository_owner }}
-#          password: ${{ secrets.GITHUB_TOKEN }}
-#      - name: Build and push
-#        uses: docker/build-push-action@v2
-#        with:
-#          context: .
-#          file: Dockerfile
-#          build-args: |
-#            VERSION=v${{ env.VERSION }}
-#          no-cache: true
-#          platforms: linux/amd64,linux/arm64
-#          push: true
-#          tags: |
-#            afonsoc12/firefly-cli:latest
-#            afonsoc12/firefly-cli:v${{ env.VERSION }}
-#            ghcr.io/afonsoc12/firefly-cli:latest
-#            ghcr.io/afonsoc12/firefly-cli:v${{ env.VERSION }}
diff --git a/.github/workflows/deploy_master.yml b/.github/workflows/deploy_master.yml
new file mode 100644
index 0000000..a02ca8e
--- /dev/null
+++ b/.github/workflows/deploy_master.yml
@@ -0,0 +1,110 @@
+name: Test and deploys master
+
+on:
+  push:
+    branches:
+      - master
+    paths:
+      - '*/**.py'      # Run on non-root changes on python files
+
+jobs:
+  pytest:
+    uses: ./.github/workflows/unit_testing.yml
+
+  pre-release:
+    name: "Pre Release"
+    needs:
+      - pytest
+    if: ${{ github.ref == 'refs/heads/master' && !startsWith( github.ref, 'refs/tags/v' ) }}
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          fetch-depth: '0'
+      - name: Get version from versioneer
+        run: |
+          VER=$(echo $(python -c "import versioneer; print(versioneer.get_versions()['version'].replace('+', '-'))"))
+          echo "VERSION=$VER" >> $GITHUB_ENV
+      - name: Create Github release (pre-release)
+        uses: marvinpinto/action-automatic-releases@latest
+        with:
+          repo_token: "${{ secrets.GITHUB_TOKEN }}"
+          automatic_release_tag: "dev-latest"
+          prerelease: true
+          title: "dev-v${{ env.VERSION }}"
+          files: |
+            LICENSE
+            dist/firefly*
+
+  pypi:
+    name: Deploy to TestPyPI
+    needs:
+      - pytest
+    if: ${{ github.ref == 'refs/heads/master' && !startsWith( github.ref, 'refs/tags/v' ) }}
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          fetch-depth: '0'
+      - name: Get version from versioneer
+        run: |
+          VER=$(echo $(python -c "import versioneer; print(versioneer.get_versions()['version'].replace('+', '-'))"))
+          echo "VERSION=$VER" >> $GITHUB_ENV
+      - name: Install pypa/build and build a binary wheel and a source tarball
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install build --user
+          python -m build --sdist --wheel --outdir dist/ .
+      - name: Publish distribution 📦 to TestPyPI
+        uses: pypa/gh-action-pypi-publish@master
+        with:
+          password: ${{ secrets.TEST_PYPI_API_TOKEN }}
+          repository_url: https://test.pypi.org/legacy/
+
+  docker:
+    name: Deploy to Docker Hub (dev-latest)
+    needs:
+      - pytest
+    if: ${{ github.ref == 'refs/heads/master' && !startsWith( github.ref, 'refs/tags/v' ) }}
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          fetch-depth: '0'
+      - name: Get version from versioneer
+        run: |
+          echo "${{github.ref}}"
+          VER=$(echo $(python -c "import versioneer; print(versioneer.get_versions()['version'].replace('+', '-'))"))
+          echo "VERSION=$VER" >> $GITHUB_ENV
+      - name: Set up QEMU
+        uses: docker/setup-qemu-action@v1
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v1
+      - name: Login to DockerHub
+        uses: docker/login-action@v1
+        with:
+          username: ${{ secrets.DOCKER_USERNAME }}
+          password: ${{ secrets.DOCKER_TOKEN }}
+      - name: Login to GitHub Container Registry
+        uses: docker/login-action@v1
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+      - name: Build and push
+        uses: docker/build-push-action@v2
+        with:
+          context: .
+          file: Dockerfile
+          build-args: |
+            VERSION=dev-v${{ env.VERSION }}
+          platforms: linux/amd64,linux/arm64
+          push: true
+          tags: |
+            afonsoc12/firefly-cli:dev-latest
+            afonsoc12/firefly-cli:dev-v${{ env.VERSION }}
+            ghcr.io/afonsoc12/firefly-cli:dev-latest
+            ghcr.io/afonsoc12/firefly-cli:dev-v${{ env.VERSION }}
diff --git a/.github/workflows/deploy_releases.yml b/.github/workflows/deploy_releases.yml
new file mode 100644
index 0000000..ebf39c4
--- /dev/null
+++ b/.github/workflows/deploy_releases.yml
@@ -0,0 +1,102 @@
+name: Test and deploys releases
+
+on:
+  push:
+    tags: 'v*'
+
+jobs:
+  pytest:
+    uses: ./.github/workflows/unit_testing.yml
+
+  release:
+    name: "Tagged Release"
+    needs:
+      - pytest
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          fetch-depth: '0'
+      - name: Get version from versioneer
+        run: |
+          VER=$(echo $(python -c "import versioneer; print(versioneer.get_versions()['version'].replace('+', '-'))"))
+          echo "VERSION=$VER" >> $GITHUB_ENV
+      - name: Create Github release
+        uses: marvinpinto/action-automatic-releases@latest
+        with:
+          repo_token: "${{ secrets.GITHUB_TOKEN }}"
+          prerelease: false
+          title: "v${{ env.VERSION }}"
+          files: |
+            LICENSE
+            dist/firefly*
+
+  pypi:
+    name: Deploy to PyPI
+    needs:
+      - pytest
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          fetch-depth: '0'
+      - name: Get version from versioneer
+        run: |
+          VER=$(echo $(python -c "import versioneer; print(versioneer.get_versions()['version'].replace('+', '-'))"))
+          echo "VERSION=$VER" >> $GITHUB_ENV
+      - name: Install pypa/build and build a binary wheel and a source tarball
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install build --user
+          python -m build --sdist --wheel --outdir dist/ .
+      - name: Publish distribution 📦 to PyPI
+        uses: pypa/gh-action-pypi-publish@master
+        with:
+          password: ${{ secrets.PYPI_API_TOKEN }}
+
+  docker:
+    name: Deploy to Docker Hub (latest)
+    needs:
+      - pytest
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          fetch-depth: '0'
+      - name: Get version from versioneer
+        run: |
+          VER=$(echo $(python -c "import versioneer; print(versioneer.get_versions()['version'].replace('+', '-'))"))
+          echo "VERSION=$VER" >> $GITHUB_ENV
+      - name: Set up QEMU
+        uses: docker/setup-qemu-action@v1
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v1
+      - name: Login to DockerHub
+        uses: docker/login-action@v1
+        with:
+          username: ${{ secrets.DOCKER_USERNAME }}
+          password: ${{ secrets.DOCKER_TOKEN }}
+      - name: Login to GitHub Container Registry
+        uses: docker/login-action@v1
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+      - name: Build and push
+        uses: docker/build-push-action@v2
+        with:
+          context: .
+          file: Dockerfile
+          build-args: |
+            VERSION=${{ env.VERSION }}
+          no-cache: true
+          platforms: linux/amd64,linux/arm64
+          push: true
+          tags: |
+            afonsoc12/firefly-cli:latest
+            afonsoc12/firefly-cli:v${{ env.VERSION }}
+            ghcr.io/afonsoc12/firefly-cli:latest
+            ghcr.io/afonsoc12/firefly-cli:v${{ env.VERSION }}
diff --git a/.github/workflows/update_docker_hub_description.yml b/.github/workflows/update_docker_hub_description.yml
new file mode 100644
index 0000000..39f53e7
--- /dev/null
+++ b/.github/workflows/update_docker_hub_description.yml
@@ -0,0 +1,23 @@
+name: Update Docker Hub description and README
+
+on:
+  push:
+    paths:
+      - README.md   # Changes on README.md updates Docker Hub description and README
+
+jobs:
+
+  docker-description:
+    name: Update Docker Hub description and README
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+      - name: Update Docker Hub description and README
+        uses: peter-evans/dockerhub-description@v2
+        with:
+          username: ${{ secrets.DOCKER_USERNAME }}
+          password: ${{ secrets.DOCKER_PASSWORD }}
+          short-description: ${{ github.event.repository.description }}
+          readme-filepath: ./README.md
+          repository: afonsoc12/firefly-cli
diff --git a/.gitignore b/.gitignore
index e84e9b1..d908236 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
+# firefly-cli config
+firefly-cli.ini
+
 # Flake 8
 .flake8
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 088395e..848e5b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,27 @@
 
 All notable changes to this project will be documented in this file.
 
+## v0.1.0 (2022-02-27)
+
+### New
+- Docker Image
+- Full compatibility with endpoint [`POST /api/v1/transactions`](https://api-docs.firefly-iii.org/#/transactions/storeTransaction) using positional arguments (e.g. `--foreign-currency-code GBP` or `--foreign-amount 10.23`)
+- Transactions are now processed with argparse for broader compatibility
+- Support for "withdraw", "deposit" and "transfer" transaction types
+- Allows specifying arbitrary date or datetime for a transaction, respectively using the arguments `--date` or `--datetime` (#6)
+- Support for specifying config file LOCATION using the environment variable `FIREFLY_CLI_CONFIG`
+- Added colour to certain CLI prints
+- Unit testing
+- Codebase is now [black](https://black.readthedocs.io/en/stable/) and [isort](https://pypi.org/project/isort/) compliant
+
+### Changed
+- CLI engine migrated to [cmd2](https://cmd2.readthedocs.io/en/stable/)
+- Command line and entrypoint refactored
+- Bumped versioneer
+
+### Removed
+- pandas library dependency
+
 ## v0.0.10 (2021-04-05)
 
 ### Changed
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1b5411b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,24 @@
+FROM python:3.7-alpine
+
+ARG VERSION="n/a"
+
+LABEL VERSION=${VERSION}
+
+MAINTAINER Afonso Costa
+
+ENV XDG_CONFIG_HOME=/config
+
+WORKDIR /src
+
+COPY . .
+
+RUN ls -alh
+
+RUN echo -e "def get_versions():\n    return {'version': '${VERSION}', 'full-revisionid': 'n/a', 'date': 'n/a', 'dirty': 'n/a', 'error': 'n/a'}" \
+    > firefly_cli/_version.py
+
+RUN pip install --upgrade pip && \
+    #pip install -r requirements.txt && \
+    pip install .
+
+ENTRYPOINT [ "firefly-cli" ]
\ No newline at end of file
diff --git a/README.md b/README.md
index dc97628..2c3da64 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,23 @@
-![](https://www.firefly-iii.org/static/img/logo-small-new.png)
+<img src="https://www.firefly-iii.org/assets/logo/color.png" width="150">
 
 # Firefly III Command Line Interface
 [![PyPi Version](https://img.shields.io/pypi/v/firefly-cli.svg)](https://pypi.org/project/firefly-cli/)
 [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
-[![Build Status](https://travis-ci.com/afonsoc12/firefly-cli.svg?branch=main)](https://travis-ci.com/afonsoc12/firefly-cli)
+<!-- [![Docker Pulls](https://img.shields.io/docker/pulls/afonsoc12/firefly-cli?logo=docker)](https://hub.docker.com/repository/docker/afonsoc12/firefly-cli) -->
 
-A python-based command line interface for practically entering expenses in [Firefly III](https://www.firefly-iii.org).
+[![Github Starts](https://img.shields.io/github/stars/afonsoc12/firefly-cli?logo=github)](https://github.com/afonsoc12/firefly-cli)
+[![Github Fork](https://img.shields.io/github/forks/afonsoc12/firefly-cli?logo=github)](https://github.com/afonsoc12/firefly-cli)
+[![Github Release](https://img.shields.io/github/v/release/afonsoc12/firefly-cli?logo=github)](https://github.com/afonsoc12/firefly-cli/releases)
 
-*This project was inspired on [firefly-bot](https://github.com/vjFaLk/firefly-bot), a Telegram bot to create transactions in Firefly III. Some of the API code and examples were forked from this project.*
+[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
+
+A python-based command line interface for conveniently entering expenses in [Firefly III](https://www.firefly-iii.org).
 
 # Instalation
-This CLI tool is available on PyPI. So the easiest way to install is:
+
+This CLI tool is available on PyPI and as a docker image.
+
+## 1. Install from PyPI
 ```shell
 # Install firefly-cli from PyPI
 pip install firefly-cli
@@ -19,7 +26,60 @@ pip install firefly-cli
 firefly-cli
 ```
 
-Alternatively, you can clone the repository and run the library module as a script:
+## 2. Docker Image
+Currently, there are images for both `x86-64` and `arm64` architectures. 
+
+They are built from python's Linux Alpine base image (`python:3.7-alpine`) which makes the application very slim (less than 20MB). 
+
+| Architecture<br>[![Docker Image Size](https://img.shields.io/docker/image-size/afonsoc12/firefly-cli/latest?logo=docker)](https://hub.docker.com/repository/docker/afonsoc12/intrusion-monitor/tags?page=1&ordering=last_updated&name=latest) | Tag<br>[![Docker Dev Version](https://img.shields.io/docker/v/afonsoc12/firefly-cli/latest?logo=docker)](https://hub.docker.com/repository/docker/afonsoc12/firefly-cli/tags?page=1&ordering=last_updated&name=latest) |
+|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|
+|                                                                                                                    x86-64                                                                                                                     |                                                                                                         latest                                                                                                         |
+|                                                                                                                     arm64                                                                                                                     |                                                                                                         latest                                                                                                         |
+
+There are also development images with the latest on master branch:
+
+| Architecture<br>[![Docker Image Size](https://img.shields.io/docker/image-size/afonsoc12/firefly-cli/latest?logo=docker)](https://hub.docker.com/repository/docker/afonsoc12/firefly-cli/tags?page=1&ordering=last_updated&name=dev-latest) | Tag<br>[![Docker Dev Version](https://img.shields.io/docker/v/afonsoc12/firefly-cli/dev-latest?logo=docker)](https://hub.docker.com/repository/docker/afonsoc12/firefly-cli-monitor/tags?page=1&ordering=last_updated&name=dev-latest) |
+|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|
+|                                                                                                                   x86-64                                                                                                                    |                                                                                                               dev-latest                                                                                                               |
+|                                                                                                                    arm64                                                                                                                    |                                                                                                               dev-latest                                                                                                               |
+
+Getting started with firefly-cli Docker:
+```shell
+# Pull latest image
+docker pull afonsoc12/firefly-cli:latest
+
+# Test if it is working, mount path to your firefly-cli config folder
+docker run --rm -it \
+           -v ~/.config/firefly-cli:/config/firefly-cli \
+           afonsoc12/firefly-cli:latest
+
+# Set an alias on your .bashrc or .zshrc, so that it is more convenient to run
+alias firefly-cli="docker run --rm -it -v ~/.config/firefly-cli:/config/firefly-cli afonsoc12/firefly-cli:latest"
+
+# Add some transactions!
+firefly-cli add 5, Large Mocha, Cash, Starbucks
+```
+## 3. Bare Python
+Alternatively, you can clone the repository and install from `setup.py` or run as a python module.
+
+From setup.py:
+
+```shell
+# Clone the repository
+git clone https://github.com/afonsoc12/firefly-cli.git
+
+# Go to root directory
+cd firefly-cli
+
+# Install firefly-cli
+pip install .
+
+# Run firefly-cli
+firefly-cli
+```
+
+Python module:
+
 ```shell
 # Clone the repository
 git clone https://github.com/afonsoc12/firefly-cli.git
@@ -36,18 +96,19 @@ python -m firefly_cli
 
 # Usage
 The CLI has two modes of operation:
-  - In one-line command style:
+
+1. In one-line command style:
     
 ```shell
 $ firefly-cli add 5.2, Large Mocha, Cash, Starbucks
 ```
   
-  - Command Line Interface:
+2. Command Line Interface:
   
 ```bash
 $ firefly-cli
-    
-Copyright 2020 Afonso Costa
+
+Copyright 2022 Afonso Costa
 
 Licensed under the Apache License, Version 2.0 (the "License");
 Type "license" for more information.
@@ -55,10 +116,12 @@ Type "license" for more information.
 Welcome to FireflyIII Command Line Interface!
 Created by Afonso Costa (@afonsoc12)
 
-===== Status =====
+=============== Status ===============
   - URL: https://firefly.mydomain.com
-  - API Token: *****xYcV
+  - API Token: *****iUcHo
   - Connection: OK!
+  - Version: 0.1.0
+======================================
 
 Type "help" to list commands.
 
@@ -66,24 +129,26 @@ Type "help" to list commands.
 ```
 
 # Setup
-If you run firefly-cli, you will see that you do not have connection.
-In order to configure your Firefly III `URL` and `API_TOKEN` you have to run these two commands (you can find [here](https://docs.firefly-iii.org/firefly-iii/api/#personal-access-token) how to find your API Personal Access Token):
+If you run firefly-cli straight away, a warning will pop up since you haven't configured it with your FireflyIII instance.
+
+In order to configure your Firefly III `URL` and `API_TOKEN` you have to run these two commands (you can find [here](https://docs.firefly-iii.org/firefly-iii/api/#personal-access-token) how to get your API **Personal Access Token**:
 
 ```shell
 # Start CLI, well this one does not count as a command 🙃
 firefly-cli
 
 # Set your Firefly URL, such as https://firefly.mydomain.com
-edit URL <YOUR URL>
+🐷 ➜ edit URL <YOUR URL>
 
 # Set your Firefly API_TOKEN
-edit API_TOKEN <YOUR API TOKEN>
+🐷 ➜ edit API_TOKEN <YOUR API TOKEN>
 ```
 
-After entering these values, firefly-cli will automatically refresh API connection. At any point you can refresh API connection by yourself:
+After entering these values, firefly-cli will automatically refresh API connection. At any point, you can trigger a connection refresh:
+
 ```shell
 # Refreshes API connection
-refresh
+🐷 ➜ refresh
 ```
 
 Alternatively, you can create a `firefly-cli.ini` file and place it in `$XDG_CONFIG_HOME/firefly-cli/firefly-cli.ini` with the following content:
@@ -94,46 +159,67 @@ api_token = eyXXX
 ```
 **Note:** If `$XDG_CONFIG_HOME` is not set, it defaults to `$HOME/.config/firefly-cli/firefly-cli.ini`
 
+firefly-cli can override this behaviour and read/write from the file specified by the environment variable `FIREFLY_CLI_CONFIG`.
+
 # Commands
-The scope of this CLI is to enter expenses in a comma-separated style. Therefore, some commands do not have a "very polished" UI for the moment. I am looking forward to improve this soon!
+The scope of this CLI is to enter expenses in a comma-separated style. 
 
-If you find any bugs (which you WILL!), please submit a new issue or PR! I am more than happy to accept new suggestions, improvements and corrections.
+Starting in **[v0.1.0](https://github.com/afonsoc12/firefly-cli/releases/tag/v0.1.0)**, it now supports adding all possible transaction fields using optional arguments (e.g. `--source-name "Bank HSBC"`). 
+The comma-separated arguments (aka positional arguments) are maintained for backwards-compatibility, but optional arguments will **always** override the comma-separated ones
 
 Summary of the available commands:
 
-| Command  | Description                                                                                                                |
-|----------|----------------------------------------------------------------------------------------------------------------------------|
-| `help`     | Shows the available commands.                                                                                              |
-| `accounts` | Shows budgets information (UI unpolished).                                                                                  |
-| `add`      | Adds a transaction to firefly (See `add` section).                                                              |
-| `budgets`  | Shows budgets information (UI unpolished).                                                                                  |
-| `edit`     | Edits URL and API_TOKEN parameters. Type edit [URL/API_TOKEN] <VALUE>` to configure firefly-cli with your Firefly instance. |
-| `exit`     | Exits the CLI tool.                                                                                                        |
-| `help`     | Shows available commands. Type `help [command]` to display information about that command.                                   |
-| `license`  | Shows License information.                                                                                                 |
-| `refresh`  | Refreshes API connection.                                                                                                  |
-## `add`
-This command is responsible for entering a new expense in your Firefly instance.
-The fields available are: 
-
-| Amount | Description | Source account | Destination account | Category | Budget |
-|:------:|:-----------:|:--------------:|:-------------------:|:--------:|:------:|
-
-The first three fields can't be omitted.
-
-**Examples:**
-- A simple one:
-  ```shell
-  🐷 ➜ add 5, Large Mocha, Cash
-  ```
-
-- One with all the fields being used:
-  ```shell
-  🐷 ➜ add 5, Large Mocha, Cash, Starbucks, Coffee Category, Food Budget
-  ```
-
-- You can skip specfic fields by leaving them empty (except the first two):
-  ```shell
-  🐷 ➜ add 5, Large Mocha, Cash, , , UCO Bank
-  ``` 
+| Command         | Description                                                                                                                 |
+|-----------------|-----------------------------------------------------------------------------------------------------------------------------|
+| `help`          | Shows the available commands.                                                                                               |
+| `accounts`      | Shows budgets information (UI unpolished).                                                                                  |
+| `add`           | Adds a transaction to FireflyIII (See `add` section).                                                                       |
+| `budgets`       | Shows budgets information (UI unpolished).                                                                                  |
+| `edit`          | Edits URL and API_TOKEN parameters. Type edit [URL/API_TOKEN] <VALUE>` to configure firefly-cli with your Firefly instance. |
+| `exit`          | Exits the CLI tool.                                                                                                         |
+| `help`          | Shows available commands. Type `help [command]` to display information about that command.                                  |
+| `license`       | Shows License information.                                                                                                  |
+| `refresh`       | Refreshes API connection.                                                                                                   |
+| `version` | Shows firefly-cli version.                                                                                                  |
+
+## Adding a transaction
+The command `add` is responsible for entering a new transaction in your Firefly instance. Further help can be shown by typing `add --help` or `help add`.
+
+By default, every transaction is a **withdrawal** and is placed with the current date and time. 
+You may change transaction type by including the optional argument `--type`, change the transaction date with `--date yyyy-mm-dd` or if you would like to be more precise `--datetime yyyy-mm-ddTHH:MM:SS`.
+
+The comma-separated fields available are the following:
+
+`Amount, Description , Source account, Destination account, Category, Budget `
+
+**The first four fields can NEVER be omitted!**
+
+### Examples
+
+```shell
+# These four fields are mandatory
+# Mandatory fields: amount, description, source_account, destination_account
+🐷 ➜ add 5, Large Mocha, Cash, Starbucks
+
+# Don't need to be exclusively comma-separated fields, as long as they are specified
+🐷 ➜ add --amount 5 --description "Large Mocha" --source-name Cash --destination-name Starbucks
+
+# Or a mixture of comma-separated and optional arguments
+🐷 ➜ add 5, Large Mocha --source-name Cash --destination-name Starbucks
+
+# Remember: optional arguments ALWAYS override comma-separated ones
+🐷 ➜ add 5, Large Mocha, Cash, Starbucks --source-name "Bank HSBC"
+# will create a transaction whose source is "Bank HSBC" and NOT "Cash"
+
+# You can skip specfic fields by leaving them empty
+🐷 ➜ add 5, Large Mocha, Cash, Starbucks, , Morning Coffees
+# sets the budget to "Morning Coffees" and skips the category
+```
+
+## Credits
+
+Copyright 2022 Afonso Costa
+
+Licensed under the [Apache License, Version 2.0](https://github.com/afonsoc12/intrusion-monitor/blob/master/LICENSE) (the "License")
 
+FireflyIII logo extracted from the official [FireflyIII website](https://www.firefly-iii.org/)
\ No newline at end of file
diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
new file mode 100644
index 0000000..4fa8560
--- /dev/null
+++ b/docker-compose-dev.yml
@@ -0,0 +1,27 @@
+version: '3.3'
+
+services:
+  firefly:
+    image: fireflyiii/core:latest
+    container_name: firefly-dev
+    environment:
+      - APP_KEY=3tPZNJV1gI90B5a5S3k58o7RV16ZlHRX
+      - DB_HOST=mariadb-dev
+      - DB_PORT=3306
+      - DB_CONNECTION=mysql
+      - DB_DATABASE=firefly
+      - DB_USERNAME=firefly
+      - DB_PASSWORD=firefly
+    ports:
+      - 8080:8080
+    depends_on:
+      - mariadb
+
+  mariadb:
+    image: mariadb
+    container_name: mariadb-dev
+    environment:
+      - MYSQL_RANDOM_ROOT_PASSWORD=yes
+      - MYSQL_USER=firefly
+      - MYSQL_PASSWORD=firefly
+      - MYSQL_DATABASE=firefly
diff --git a/firefly_cli/__init__.py b/firefly_cli/__init__.py
index 74f4e66..0d123bb 100644
--- a/firefly_cli/__init__.py
+++ b/firefly_cli/__init__.py
@@ -1,4 +1,28 @@
+import sys
 
 from ._version import get_versions
-__version__ = get_versions()['version']
-del get_versions
+from .cli import FireflyPrompt
+
+__version__ = get_versions()["version"]
+
+
+def _real_main():
+    if len(sys.argv) > 1:
+        FireflyPrompt().onecmd(" ".join(sys.argv[1:]))
+    else:
+        FireflyPrompt().cmdloop()
+
+
+def main():
+    try:
+        _real_main()
+    except KeyboardInterrupt:
+        print("\n[ERROR] Interrupted by user")
+    except:
+        print("\n[ERROR] Fatal error occurred and firefly-cli cannot continue...")
+        raise
+
+
+from . import _version
+
+__version__ = _version.get_versions()["version"]
diff --git a/firefly_cli/__main__.py b/firefly_cli/__main__.py
index 22420ec..eb6b938 100644
--- a/firefly_cli/__main__.py
+++ b/firefly_cli/__main__.py
@@ -1,14 +1,19 @@
-import sys
-from .cli import FireflyPrompt
+#!/usr/bin/env python3
+
+# Execute with
+# $ python firefly_cli/__main__.py
+# $ python -m firefly_cli
 
+import sys
 
-def main():
+if __package__ is None and not hasattr(sys, "frozen"):
+    # Direct call of __main__.py
+    import os.path
 
-    if len(sys.argv) > 1:
-        FireflyPrompt().onecmd(' '.join(sys.argv[1:]))
-    else:
-        FireflyPrompt().cmdloop()
+    path = os.path.realpath(os.path.abspath(__file__))
+    sys.path.insert(0, os.path.dirname(os.path.dirname(path)))
 
+import firefly_cli
 
 if __name__ == "__main__":
-    sys.exit(main())
+    firefly_cli.main()
diff --git a/firefly_cli/_version.py b/firefly_cli/_version.py
index f1dc724..176ba28 100644
--- a/firefly_cli/_version.py
+++ b/firefly_cli/_version.py
@@ -1,4 +1,3 @@
-
 # 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
@@ -6,7 +5,7 @@
 # that just contains the computed version number.
 
 # This file is released into the public domain. Generated by
-# versioneer-0.19 (https://github.com/python-versioneer/python-versioneer)
+# versioneer-0.21 (https://github.com/python-versioneer/python-versioneer)
 
 """Git implementation of _version.py."""
 
@@ -15,6 +14,7 @@
 import re
 import subprocess
 import sys
+from typing import Callable, Dict
 
 
 def get_keywords():
@@ -52,36 +52,40 @@ class NotThisMethod(Exception):
     """Exception raised if a method is not valid for the current scenario."""
 
 
-LONG_VERSION_PY = {}
-HANDLERS = {}
+LONG_VERSION_PY: Dict[str, str] = {}
+HANDLERS: Dict[str, Dict[str, Callable]] = {}
 
 
 def register_vcs_handler(vcs, method):  # decorator
     """Create decorator to mark a method as the handler of a 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):
+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:
+    process = None
+    for command in commands:
         try:
-            dispcmd = str([c] + args)
+            dispcmd = str([command] + 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))
+            process = subprocess.Popen(
+                [command] + args,
+                cwd=cwd,
+                env=env,
+                stdout=subprocess.PIPE,
+                stderr=(subprocess.PIPE if hide_stderr else None),
+            )
             break
-        except EnvironmentError:
+        except OSError:
             e = sys.exc_info()[1]
             if e.errno == errno.ENOENT:
                 continue
@@ -93,13 +97,13 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
         if verbose:
             print("unable to find command, tried %s" % (commands,))
         return None, None
-    stdout = p.communicate()[0].strip().decode()
-    if p.returncode != 0:
+    stdout = process.communicate()[0].strip().decode()
+    if process.returncode != 0:
         if verbose:
             print("unable to run %s (error)" % dispcmd)
             print("stdout was %s" % stdout)
-        return None, p.returncode
-    return stdout, p.returncode
+        return None, process.returncode
+    return stdout, process.returncode
 
 
 def versions_from_parentdir(parentdir_prefix, root, verbose):
@@ -111,19 +115,24 @@ def versions_from_parentdir(parentdir_prefix, root, verbose):
     """
     rootdirs = []
 
-    for i in range(3):
+    for _ 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
+            return {
+                "version": dirname[len(parentdir_prefix) :],
+                "full-revisionid": None,
+                "dirty": False,
+                "error": None,
+                "date": None,
+            }
+        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))
+        print(
+            "Tried directories %s but none started with prefix %s"
+            % (str(rootdirs), parentdir_prefix)
+        )
     raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
 
 
@@ -136,22 +145,21 @@ def git_get_keywords(versionfile_abs):
     # _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:
+        with open(versionfile_abs, "r") as fobj:
+            for line in fobj:
+                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)
+    except OSError:
         pass
     return keywords
 
@@ -159,8 +167,8 @@ def git_get_keywords(versionfile_abs):
 @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")
+    if "refnames" not in keywords:
+        raise NotThisMethod("Short version file found")
     date = keywords.get("date")
     if date is not None:
         # Use only the last line.  Previous lines may contain GPG signature
@@ -179,11 +187,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
         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(",")])
+    refs = {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)])
+    tags = {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
@@ -192,7 +200,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
         # 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)])
+        tags = {r for r in refs if re.search(r"\d", r)}
         if verbose:
             print("discarding '%s', no digits" % ",".join(refs - tags))
     if verbose:
@@ -200,23 +208,35 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
     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):]
+            r = ref[len(tag_prefix) :]
+            # Filter out refs that exactly match prefix or that don't start
+            # with a number once the prefix is stripped (mostly a concern
+            # when prefix is '')
+            if not re.match(r"\d", r):
+                continue
             if verbose:
                 print("picking %s" % r)
-            return {"version": r,
-                    "full-revisionid": keywords["full"].strip(),
-                    "dirty": False, "error": None,
-                    "date": date}
+            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}
+    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):
+def git_pieces_from_vcs(tag_prefix, root, verbose, runner=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*
@@ -224,11 +244,12 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
     version string, meaning we're inside a checked out source tree.
     """
     GITS = ["git"]
+    TAG_PREFIX_REGEX = "*"
     if sys.platform == "win32":
         GITS = ["git.cmd", "git.exe"]
+        TAG_PREFIX_REGEX = r"\*"
 
-    out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root,
-                          hide_stderr=True)
+    _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True)
     if rc != 0:
         if verbose:
             print("Directory %s not under git control" % root)
@@ -236,15 +257,24 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
 
     # 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, ["describe", "--tags", "--dirty",
-                                          "--always", "--long",
-                                          "--match", "%s*" % tag_prefix],
-                                   cwd=root)
+    describe_out, rc = runner(
+        GITS,
+        [
+            "describe",
+            "--tags",
+            "--dirty",
+            "--always",
+            "--long",
+            "--match",
+            "%s%s" % (tag_prefix, TAG_PREFIX_REGEX),
+        ],
+        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, ["rev-parse", "HEAD"], cwd=root)
+    full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root)
     if full_out is None:
         raise NotThisMethod("'git rev-parse' failed")
     full_out = full_out.strip()
@@ -254,6 +284,38 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
     pieces["short"] = full_out[:7]  # maybe improved later
     pieces["error"] = None
 
+    branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], cwd=root)
+    # --abbrev-ref was added in git-1.6.3
+    if rc != 0 or branch_name is None:
+        raise NotThisMethod("'git rev-parse --abbrev-ref' returned error")
+    branch_name = branch_name.strip()
+
+    if branch_name == "HEAD":
+        # If we aren't exactly on a branch, pick a branch which represents
+        # the current commit. If all else fails, we are on a branchless
+        # commit.
+        branches, rc = runner(GITS, ["branch", "--contains"], cwd=root)
+        # --contains was added in git-1.5.4
+        if rc != 0 or branches is None:
+            raise NotThisMethod("'git branch --contains' returned error")
+        branches = branches.split("\n")
+
+        # Remove the first line if we're running detached
+        if "(" in branches[0]:
+            branches.pop(0)
+
+        # Strip off the leading "* " from the list of branches.
+        branches = [branch[2:] for branch in branches]
+        if "master" in branches:
+            branch_name = "master"
+        elif not branches:
+            branch_name = None
+        else:
+            # Pick the first branch that is returned. Good or bad.
+            branch_name = branches[0]
+
+    pieces["branch"] = branch_name
+
     # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
     # TAG might have hyphens.
     git_describe = describe_out
@@ -262,17 +324,16 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
     dirty = git_describe.endswith("-dirty")
     pieces["dirty"] = dirty
     if dirty:
-        git_describe = git_describe[:git_describe.rindex("-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)
+        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)
+            # unparsable. Maybe git-describe is misbehaving?
+            pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out
             return pieces
 
         # tag
@@ -281,10 +342,12 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
             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))
+            pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (
+                full_tag,
+                tag_prefix,
+            )
             return pieces
-        pieces["closest-tag"] = full_tag[len(tag_prefix):]
+        pieces["closest-tag"] = full_tag[len(tag_prefix) :]
 
         # distance: number of commits since tag
         pieces["distance"] = int(mo.group(2))
@@ -295,13 +358,11 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
     else:
         # HEX: no tags
         pieces["closest-tag"] = None
-        count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"],
-                                    cwd=root)
+        count_out, rc = runner(GITS, ["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, ["show", "-s", "--format=%ci", "HEAD"],
-                       cwd=root)[0].strip()
+    date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip()
     # Use only the last line.  Previous lines may contain GPG signature
     # information.
     date = date.splitlines()[-1]
@@ -335,23 +396,69 @@ def render_pep440(pieces):
                 rendered += ".dirty"
     else:
         # exception #1
-        rendered = "0+untagged.%d.g%s" % (pieces["distance"],
-                                          pieces["short"])
+        rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"])
+        if pieces["dirty"]:
+            rendered += ".dirty"
+    return rendered
+
+
+def render_pep440_branch(pieces):
+    """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] .
+
+    The ".dev0" means not master branch. Note that .dev0 sorts backwards
+    (a feature branch will appear "older" than the master branch).
+
+    Exceptions:
+    1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty]
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"] or pieces["dirty"]:
+            if pieces["branch"] != "master":
+                rendered += ".dev0"
+            rendered += plus_or_dot(pieces)
+            rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
+            if pieces["dirty"]:
+                rendered += ".dirty"
+    else:
+        # exception #1
+        rendered = "0"
+        if pieces["branch"] != "master":
+            rendered += ".dev0"
+        rendered += "+untagged.%d.g%s" % (pieces["distance"], pieces["short"])
         if pieces["dirty"]:
             rendered += ".dirty"
     return rendered
 
 
+def pep440_split_post(ver):
+    """Split pep440 version string at the post-release segment.
+
+    Returns the release segments before the post-release and the
+    post-release version number (or -1 if no post-release segment is present).
+    """
+    vc = str.split(ver, ".post")
+    return vc[0], int(vc[1] or 0) if len(vc) == 2 else None
+
+
 def render_pep440_pre(pieces):
-    """TAG[.post0.devDISTANCE] -- No -dirty.
+    """TAG[.postN.devDISTANCE] -- No -dirty.
 
     Exceptions:
     1: no tags. 0.post0.devDISTANCE
     """
     if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
         if pieces["distance"]:
-            rendered += ".post0.dev%d" % pieces["distance"]
+            # update the post release segment
+            tag_version, post_version = pep440_split_post(pieces["closest-tag"])
+            rendered = tag_version
+            if post_version is not None:
+                rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"])
+            else:
+                rendered += ".post0.dev%d" % (pieces["distance"])
+        else:
+            # no commits, use the tag as the version
+            rendered = pieces["closest-tag"]
     else:
         # exception #1
         rendered = "0.post0.dev%d" % pieces["distance"]
@@ -385,6 +492,35 @@ def render_pep440_post(pieces):
     return rendered
 
 
+def render_pep440_post_branch(pieces):
+    """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] .
+
+    The ".dev0" means not master branch.
+
+    Exceptions:
+    1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty]
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"] or pieces["dirty"]:
+            rendered += ".post%d" % pieces["distance"]
+            if pieces["branch"] != "master":
+                rendered += ".dev0"
+            rendered += plus_or_dot(pieces)
+            rendered += "g%s" % pieces["short"]
+            if pieces["dirty"]:
+                rendered += ".dirty"
+    else:
+        # exception #1
+        rendered = "0.post%d" % pieces["distance"]
+        if pieces["branch"] != "master":
+            rendered += ".dev0"
+        rendered += "+g%s" % pieces["short"]
+        if pieces["dirty"]:
+            rendered += ".dirty"
+    return rendered
+
+
 def render_pep440_old(pieces):
     """TAG[.postDISTANCE[.dev0]] .
 
@@ -450,21 +586,27 @@ def render_git_describe_long(pieces):
 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}
+        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-branch":
+        rendered = render_pep440_branch(pieces)
     elif style == "pep440-pre":
         rendered = render_pep440_pre(pieces)
     elif style == "pep440-post":
         rendered = render_pep440_post(pieces)
+    elif style == "pep440-post-branch":
+        rendered = render_pep440_post_branch(pieces)
     elif style == "pep440-old":
         rendered = render_pep440_old(pieces)
     elif style == "git-describe":
@@ -474,9 +616,13 @@ def render(pieces, style):
     else:
         raise ValueError("unknown style '%s'" % style)
 
-    return {"version": rendered, "full-revisionid": pieces["long"],
-            "dirty": pieces["dirty"], "error": None,
-            "date": pieces.get("date")}
+    return {
+        "version": rendered,
+        "full-revisionid": pieces["long"],
+        "dirty": pieces["dirty"],
+        "error": None,
+        "date": pieces.get("date"),
+    }
 
 
 def get_versions():
@@ -490,8 +636,7 @@ def get_versions():
     verbose = cfg.verbose
 
     try:
-        return git_versions_from_keywords(get_keywords(), cfg.tag_prefix,
-                                          verbose)
+        return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, verbose)
     except NotThisMethod:
         pass
 
@@ -500,13 +645,16 @@ def get_versions():
         # 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('/'):
+        for _ 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}
+        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)
@@ -520,6 +668,10 @@ def get_versions():
     except NotThisMethod:
         pass
 
-    return {"version": "0+unknown", "full-revisionid": None,
-            "dirty": None,
-            "error": "unable to compute version", "date": None}
+    return {
+        "version": "0+unknown",
+        "full-revisionid": None,
+        "dirty": None,
+        "error": "unable to compute version",
+        "date": None,
+    }
diff --git a/firefly_cli/api.py b/firefly_cli/api.py
index ebea398..df9f6bb 100644
--- a/firefly_cli/api.py
+++ b/firefly_cli/api.py
@@ -1,9 +1,8 @@
 import requests
-import datetime
+
+from .transaction import Transaction
 
 """FireflyIII API Driver.
-Created by: @vjFaLk
-Enhanced by: @afonsoc12
 
 API documentation: https://api-docs.firefly-iii.org
 """
@@ -13,9 +12,17 @@ class FireflyAPI:
     """Firefly API driver Class."""
 
     def __init__(self, hostname, auth_token):
-        self.headers = {'Authorization': "Bearer " + auth_token if auth_token is not None else ''}
-        self.hostname = hostname if hostname is None or not hostname.endswith('/') else hostname[:-1]  # Remove trailing backslash
-        self.hostname = self.hostname + '/api/v1/' if hostname is not None else self.hostname
+        self.headers = {
+            "Authorization": "Bearer " + auth_token if auth_token is not None else ""
+        }
+        self.hostname = (
+            hostname
+            if hostname is None or not hostname.endswith("/")
+            else hostname[:-1]
+        )  # Remove trailing backslash
+        self.hostname = (
+            self.hostname + "/api/v1/" if hostname is not None else self.hostname
+        )
         self.api_test = self._test_api()
 
     def _test_api(self):
@@ -29,23 +36,24 @@ def _test_api(self):
     def _post(self, endpoint, payload):
         """Handles general POST requests."""
 
-        response = requests.post("{}{}".format(self.hostname, endpoint),
-                             json=payload,
-                             # Pass extra headers, or it redirects to login
-                             headers={**self.headers,
-                                      **{'Content-Type': 'application/json',
-                                         'accept': 'application/json'}
-                                      }
-                             )
+        response = requests.post(
+            "{}{}".format(self.hostname, endpoint),
+            json=payload,
+            # Pass extra headers, or it redirects to login
+            headers={
+                **self.headers,
+                **{"Content-Type": "application/json", "accept": "application/json"},
+            },
+        )
 
         return response
 
     def _get(self, endpoint, params=None):
         """Handles general GET requests."""
 
-        response = requests.get("{}{}".format(self.hostname, endpoint),
-                                params=params,
-                                headers=self.headers)
+        response = requests.get(
+            "{}{}".format(self.hostname, endpoint), params=params, headers=self.headers
+        )
 
         return response.json()
 
@@ -64,7 +72,7 @@ def get_about_user(self):
 
         return self._get("about/user")
 
-    def create_transaction(self, data):
+    def create_transaction(self, transaction: Transaction):
         """Creates a new transaction.
         data:
             pd.DataFrame
@@ -79,30 +87,15 @@ def create_transaction(self, data):
                 -> `5, Large Mocha, Cash, , , UCO Bank`
         """
 
-        # TODO : Allow DataFrames bigger than 1
-        i = 0
-
-        payload = {
-            "transactions": [{
-                "type": "withdrawal",
-                "description": data['description'].iloc[0],
-                "date": data.index[i].strftime("%Y-%m-%d"),
-                "amount": float(data['amount'].iloc[0]),
-                "budget_name": data['budget'].iloc[0],
-                "category_name": data['category'].iloc[0],
-            }]
+        trans_data = transaction.to_dict(remove_none=True, api_safe=True)
+
+        header = {k: v for k, v in trans_data.items() if k.startswith("header__")}
+        body = {
+            "transactions": [
+                {k: v for k, v in trans_data.items() if not k.startswith("header__")}
+            ]
         }
-        if data['source_name'].iloc[0].isnumeric():
-            payload["transactions"][0]["source_id"] = data['source_name'].iloc[0]
-        else:
-            payload["transactions"][0]["source_name"] = data['source_name'].iloc[0]
-
-        if data['destination_name'].iloc[0] is not None:
-            if data['destination_name'].iloc[0].isnumeric():
-                payload["transactions"][0]["destination_id"] = data['destination_name'].iloc[0]
-            else:
-                payload["transactions"][0]["destination_name"] = data['destination_name'].iloc[0]
-        else:
-            payload["transactions"][0]["destination_name"] = data['description'].iloc[0]
+
+        payload = {**header, **body}
 
         return self._post(endpoint="transactions", payload=payload)
diff --git a/firefly_cli/bcolors.py b/firefly_cli/bcolors.py
new file mode 100644
index 0000000..a8b081b
--- /dev/null
+++ b/firefly_cli/bcolors.py
@@ -0,0 +1,16 @@
+# https://svn.blender.org/svnroot/bf-blender/trunk/blender/build_files/scons/tools/bcolors.py
+class bcolors:
+    HEADER = "\033[95m"
+    OKBLUE = "\033[94m"
+    OKGREEN = "\033[92m"
+    WARNING = "\033[93m"
+    FAIL = "\033[91m"
+    ENDC = "\033[0m"
+
+    def disable(self):
+        self.HEADER = ""
+        self.OKBLUE = ""
+        self.OKGREEN = ""
+        self.WARNING = ""
+        self.FAIL = ""
+        self.ENDC = ""
diff --git a/firefly_cli/cli.py b/firefly_cli/cli.py
index fc5a606..aa1f745 100755
--- a/firefly_cli/cli.py
+++ b/firefly_cli/cli.py
@@ -1,79 +1,75 @@
 import json
-import traceback
 from datetime import date
-from cmd import Cmd
 
-from tabulate import tabulate
-from .utils import parse_transaction_to_df
+import cmd2
+
 from ._version import get_versions
-from .configs import *
 from .api import FireflyAPI
+from .bcolors import bcolors
+from .configs import *
+from .parsers import get_add_parser
+from .transaction import Transaction
+from .utils import prompt_continue
+
 
+def help_exit():
+    print("exit the application. Shorthand: x q Ctrl-D.")
 
 
-class FireflyPrompt(Cmd):
-    prompt = '🐷 ➜ '
+class FireflyPrompt(cmd2.Cmd):
+    prompt = "🐷 ➜ "
+    cmd2.Cmd.set_window_title("🐷 firefly-cli")
     configs = load_configs()
-    api = FireflyAPI(configs['API'].get('URL'),
-                     configs['API'].get('API_TOKEN'))
+    api = FireflyAPI(configs["API"].get("URL"), configs["API"].get("API_TOKEN"))
 
-    is_url_set = True if configs['API'].get('URL') is not None else False
-    is_api_token_set = True if configs['API'].get('API_TOKEN') is not None else False
+    is_url_set = True if configs["API"].get("URL") is not None else False
+    is_api_token_set = True if configs["API"].get("API_TOKEN") is not None else False
 
     # Display information to update URL or API token, if not already set
-    opt_text = ''
+    opt_text = ""
     if not (is_url_set and is_api_token_set):
         if is_url_set and not is_api_token_set:
-            opt_text += 'It appears that you have not set your API token yet.\nType \"edit API_TOKEN <TOKEN>\" to do so.'
+            opt_text += f'\n{bcolors.WARNING}[WARNING] It appears that you have not set your API token yet.{bcolors.ENDC}\nType "edit API_TOKEN <TOKEN>" to do so.\n'
         elif not is_url_set and is_api_token_set:
-            opt_text += 'It appears that you have not set your URL yet.\nType \"edit URL <URL>\" to do so.'
+            opt_text += f'\n{bcolors.WARNING}[WARNING] It appears that you have not set your URL yet.{bcolors.ENDC}\nType "edit URL <URL>" to do so.\n'
         elif not (is_url_set and is_api_token_set):
-            opt_text += 'It appears that you have not set neither of your URL or API token yet.\nType \"edit URL <URL>\" or \"edit API_TOKEN <TOKEN>\" to do so.'
-    #else:
-    #    opt_text += '\n'
+            opt_text += f'\n{bcolors.WARNING}[WARNING] It appears that you have not set neither your URL nor API token yet.{bcolors.ENDC}\nType "edit URL <URL>" or "edit API_TOKEN <TOKEN>" to do so.\n'
 
-
-    intro = '''
-Copyright {} Afonso Costa
+    intro = f"""
+Copyright {date.today().year} Afonso Costa
 
 Licensed under the Apache License, Version 2.0 (the "License");
 Type \"license\" for more information.
 
-
 Welcome to FireflyIII Command Line Interface!
 Created by Afonso Costa (@afonsoc12)
 
-===== Status =====
-  - URL: {}
-  - API Token: {}
-  - Connection: {}
-  - Version: {}
-{}
+=============== Status ===============
+  - URL: {configs["API"]["URL"] if is_url_set else "(not set)"}
+  - API Token: {"*****" + configs["API"]["API_TOKEN"][-5:] if is_api_token_set else "(not set)"}
+  - Version: v{get_versions()["version"]}
+  - Connection: {f"{bcolors.OKGREEN}OK!{bcolors.ENDC}" if api.api_test else f"{bcolors.FAIL}No connection!{bcolors.ENDC}"}
+======================================
+{opt_text}
 Type \"help\" to list commands.
-    '''.format(date.today().year,
-               configs['API']['URL'] if is_url_set else '(not set)',
-               '*****' + configs['API']['API_TOKEN'][-5:] if is_api_token_set else '(not set)',
-               'OK!' if api.api_test else 'No connection!',
-               get_versions()['version'],
-               opt_text)
-
+"""
 
     @classmethod
     def refresh_api(cls):
         cls.configs = load_configs()
-        cls.api = FireflyAPI(cls.configs['API']['URL'], cls.configs['API']['API_TOKEN'])
-        print('API refreshed. Current Status: {}'.format('OK!' if cls.api.api_test else 'No connection!'))
+        cls.api = FireflyAPI(cls.configs["API"]["URL"], cls.configs["API"]["API_TOKEN"])
+        print(
+            f"API refreshed. Current Status: {f'{bcolors.OKGREEN}OK!{bcolors.ENDC}' if cls.api.api_test else f'{bcolors.FAIL}No connection!{bcolors.ENDC}'}"
+        )
 
-    def do_exit(self, input):
-        print('Bye! Come store new transactions soon!')
+    def do_exit(self, _):
+        print("Bye! Come store new transactions soon!")
         return True
 
-    def help_exit(self):
-        print('exit the application. Shorthand: x q Ctrl-D.')
-
-    def do_license(self, input):
-        print('''
-Copyright 2020 Afonso Costa
+    def do_license(self, _):
+        print(
+            f"""
+Copyright {date.today().year} Afonso Costa
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -86,115 +82,94 @@ def do_license(self, input):
 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.
-''')
+"""
+        )
+
+    def do_version(self, _):
+        print(f"firefly-cli: v{get_versions()['version']}")
+
+    def help_version(self):
+        print("Displays version information.")
 
     def help_license(self):
-        print('Displays License information')
+        print("Displays License information.")
 
-    def do_refresh(self, input):
+    def do_refresh(self, _):
         FireflyPrompt.refresh_api()
 
     def help_refresh(self):
-        print('Refreshes API connection')
+        print("Refreshes API connection.")
 
-    def do_edit(self, input):
-        input_split = input.split(' ')
+    def do_edit(self, args):
+        input_split = args.split(" ")
         if len(input_split) != 2:
-            print(f'The command \"edit\" takes exactly two arguments. Provided: {input}')
+            print(f'The command "edit" takes exactly two arguments. Provided: {args}')
         else:
-            if input_split[0] == 'URL' or input_split[0] == 'API_TOKEN':
-                self.configs['API'][input_split[0]] = input_split[1]
+            if input_split[0] == "URL" or input_split[0] == "API_TOKEN":
+                self.configs["API"][input_split[0]] = input_split[1]
                 save_configs_to_file(self.configs)
                 FireflyPrompt.refresh_api()
             else:
-                print(f'The argument \"{input_split[0]}\" is not recognised.')
-
-    def complete_edit(self, text, line, begidx, endidx):
-        if not text:
-            completions = self.configs['API'].keys()
-        else:
-            completions = [f
-                           for f in self.configs['API'].keys()
-                           if f.startswith(text)
-                           ]
-        return completions
+                print(f'The argument "{input_split[0]}" is not recognised.')
 
     def help_edit(self):
-        print('Shows your available budgets.')
+        print(
+            "Edits connection credentials:\n\t> edit URL http://<FireflyIII URL>:<Port>\n\t> edit API <API key>"
+        )
 
-    def do_accounts(self, input):
+    def do_accounts(self, args):
         # TODO: Implement with pagination based
         accounts = self.api.get_accounts()
         print(json.dumps(accounts, sort_keys=True, indent=4))
 
     def help_accounts(self):
-        print('Shows your accounts.')
+        print("Shows your accounts.")
 
-    def do_budgets(self, input):
+    def do_budgets(self, args):
         # TODO: Implement with pagination based
         budgets = self.api.get_budgets()
         print(json.dumps(budgets, sort_keys=True, indent=4))
 
     def help_budgets(self):
-        print('Shows your budgets.')
-
-    def do_add(self, input):
-        try:
-            data = parse_transaction_to_df(input)
-            response = self.api.create_transaction(data)
-            if response.status_code == 200:
-                print(f'Transaction {input} successfully added!')
-                print('Interpreted as:')
-                print(tabulate(data, headers='keys', tablefmt='psql'))
-            elif response.status_code == 422:
-                print(f'The data provided is not valid! Transaction: {input}')
-                print('Interpreted as:')
-                print(tabulate(data, headers='keys', tablefmt='psql'))
-                msg = response.json()['message']
-                errors = response.json()['errors']
-                print(f'\nMessage: {msg}')
-                print('Errors:')
-                for e in errors:
-                    e_line = '; '.join(errors[e])
-                    print(f'\t- {e}: {e_line}')
-
-
-        except Exception:
-            print(f'An error has occurred.')
-            traceback.print_exc()
-
-    def help_add(self):
-        print('''
-Adds a transaction to Firefly in a comma-separated fashion.
-
-Fields are: 
-{}
-The three first fields can't be omitted.
-
-Examples:
-    - A simple one:
-        -> `5, Large Mocha, Cash`
-    - One with all the fields being used:
-        -> `5, Large Mocha, Cash, Starbucks, Coffee Category, Food Budget`
-    - You can skip specfic fields by leaving them empty (except the first two):
-        -> `5, Large Mocha, Cash, , , UCO Bank`
-'''.format(tabulate([['Amount', 'Description', 'Source account', 'Destination account', 'Category', 'Budget']], tablefmt='psql')))
-
-
-    def default(self, input):
-        if input == 'x' or input == 'q':
-            return self.do_exit(input)
-
-        print('{} not recognized. Please type \"help\" to list the available commands'.format(input))
+        print("Shows your budgets.")
+
+    @cmd2.with_argparser(get_add_parser())
+    def do_add(self, parser):
+        trans = Transaction.from_argparse(parser)
+        trans.parse_inline_transaction_to_attributes()
+
+        missing_fields = trans.mandatory_fields_missing()
+        if missing_fields:
+            raise ValueError(
+                f"Your transaction does not have these mandatory fields: {', '.join(missing_fields)}"
+            )
+
+        tab_header, tab_body = trans.get_tabulates()
+        print(f"Transaction header:\n{tab_header}\n")
+        print(f"Transaction Body:\n{tab_body}\n")
+
+        if prompt_continue(extra_line=False, extra_msg=" adding the transaction"):
+            try:
+                response = self.api.create_transaction(trans)
+
+                if response.status_code == 200:
+                    print(f"Transaction successfully added!")
+                elif response.status_code == 422:
+                    print(f"The data provided is not valid!")
+                    msg = response.json()["message"]
+                    errors = response.json()["errors"]
+                    print(f"\nMessage: {msg}")
+                    print("Errors:")
+                    for e in errors:
+                        e_line = "; ".join(errors[e])
+                        print(f"\t- {e}: {e_line}")
+
+            except Exception:
+                print(f"An error has occurred.")
+                raise
+
+    def default(self):
+        print('Input not recognised. Please type "help" to list the available commands')
 
     do_EOF = do_exit
     help_EOF = help_exit
-
-
-#if __name__ == '__main__':
-#    import sys
-#
-#    if len(sys.argv) > 1:
-#        FireflyPrompt().onecmd(' '.join(sys.argv[1:]))
-#    else:
-#        FireflyPrompt().cmdloop()
diff --git a/firefly_cli/configs.py b/firefly_cli/configs.py
index 28184d8..d44a60e 100644
--- a/firefly_cli/configs.py
+++ b/firefly_cli/configs.py
@@ -1,21 +1,27 @@
-from pathlib import Path
 import configparser
+import os
+from pathlib import Path
+
 from xdg.BaseDirectory import xdg_config_home
 
-config_file_path = Path(xdg_config_home).joinpath('firefly-cli', 'firefly-cli.ini')
+if os.getenv("FIREFLY_CLI_CONFIG"):
+    config_file_path = Path(os.environ["FIREFLY_CLI_CONFIG"])
+else:
+    config_file_path = Path(xdg_config_home).joinpath("firefly-cli", "firefly-cli.ini")
+
+    # Create dir if not exists
+    config_file_path.parent.mkdir(parents=True, exist_ok=True)
 
-# Create dir if not exists
-config_file_path.parents[0].mkdir(parents=True, exist_ok=True)
 
 def load_configs():
     configs = configparser.ConfigParser()
 
     # No config file loaded because it was not available/not existent
     if len(configs.read(config_file_path)) < 1:
-        print('File not found, creating the file..')
+        print("File not found, creating the file..")
 
-        with open(config_file_path, 'w') as f:
-            configs['API'] = {}
+        with open(config_file_path, "w") as f:
+            configs["API"] = {}
             save_configs_to_file(configs)
 
     return configs
@@ -23,9 +29,12 @@ def load_configs():
 
 def save_configs_to_file(configs):
     try:
-        with open(config_file_path, 'w') as f:
+        with open(config_file_path, "w") as f:
             configs.write(f)
-            print('Config file saved at {}'.format(str(config_file_path)))
+            print("Config file saved at {}".format(str(config_file_path)))
     except:
-        print('An error has occurred while saving file to {}'.format(str(config_file_path)))
-
+        print(
+            "An error has occurred while saving file to {}".format(
+                str(config_file_path)
+            )
+        )
diff --git a/firefly_cli/parsers.py b/firefly_cli/parsers.py
new file mode 100644
index 0000000..30bf51c
--- /dev/null
+++ b/firefly_cli/parsers.py
@@ -0,0 +1,94 @@
+from datetime import datetime
+
+from cmd2 import Cmd2ArgumentParser
+
+
+def get_add_parser():
+    l_date_to_datetime = lambda s: datetime.strptime(s, "%Y-%m-%d").astimezone()
+    l_datetime_to_datetime = lambda s: datetime.strptime(
+        s, "%Y-%m-%dT%H:%M:%S"
+    ).astimezone()
+
+    add_parser = Cmd2ArgumentParser(description='Adds a new transaction to FireflyIII.', usage='add [comma-separated arguments] [-h] [--optional-arguments]')
+
+    # Positional arguments
+    add_parser.add_argument("transaction", nargs="*", help="Transaction data.")
+
+    # Optional arguments (json header)
+    add_parser.add_argument(
+        "--apply-rules", default=True, dest="header__apply_rules", action="store_true"
+    )
+    add_parser.add_argument(
+        "--fire-webhooks",
+        default=False,
+        dest="header__fire_webhooks",
+        action="store_true",
+    )
+
+    # Optional arguments (json body)
+    # Group mutually exclusive (date or datetime) stored as date.
+    # Allows to specify transaction date or transaction datetime
+    # Both stored as date
+    group_date = add_parser.add_mutually_exclusive_group()
+    group_date.add_argument(
+        "--date",
+        help="Transaction date (defaults to current date).",
+        metavar="yyyy-mm-dd",
+        default=datetime.now().astimezone(),
+        type=l_date_to_datetime,
+    )
+    group_date.add_argument(
+        "--datetime",
+        metavar="yyyy-mm-ssTHH:MM:SS",
+        dest="date",
+        type=l_datetime_to_datetime,
+    )
+
+    add_parser.add_argument(
+        "--type",
+        default="withdrawal",
+        choices=(
+            "withdrawal",
+            "deposit",
+            "transfer",
+        ),
+    )
+    add_parser.add_argument("--amount", type=float)
+    add_parser.add_argument("--description")
+    add_parser.add_argument("--currency-id")
+    add_parser.add_argument("--currency-code")
+    add_parser.add_argument("--foreign-amount")
+    add_parser.add_argument("--foreign-currency-id")
+    add_parser.add_argument("--foreign-currency-code")
+    add_parser.add_argument("--budget-id")
+    add_parser.add_argument("--category-id")
+    add_parser.add_argument("--category-name")
+    add_parser.add_argument("--source-id")
+    add_parser.add_argument("--source-name")
+    add_parser.add_argument("--destination-id")
+    add_parser.add_argument("--destination-name")
+    add_parser.add_argument("--piggy-bank-id")
+    add_parser.add_argument("--piggy-bank-name")
+    add_parser.add_argument("--bill-id")
+    add_parser.add_argument("--bill-name")
+    add_parser.add_argument("--tags")
+    add_parser.add_argument("--notes")
+    add_parser.add_argument("--internal-reference")
+    add_parser.add_argument("--external-id")
+    add_parser.add_argument("--external-url")
+    add_parser.add_argument("--interest-date")
+    add_parser.add_argument(
+        "--book-date", metavar="YYYY-MM-DD", type=l_date_to_datetime
+    )
+    add_parser.add_argument(
+        "--process-date", metavar="YYYY-MM-DD", type=l_date_to_datetime
+    )
+    add_parser.add_argument("--due-date", metavar="YYYY-MM-DD", type=l_date_to_datetime)
+    add_parser.add_argument(
+        "--payment-date", metavar="YYYY-MM-DD", type=l_date_to_datetime
+    )
+    add_parser.add_argument(
+        "--invoice-date", metavar="YYYY-MM-DD", type=l_date_to_datetime
+    )
+
+    return add_parser
diff --git a/firefly_cli/transaction.py b/firefly_cli/transaction.py
new file mode 100644
index 0000000..ebc93fb
--- /dev/null
+++ b/firefly_cli/transaction.py
@@ -0,0 +1,128 @@
+from datetime import datetime
+
+from tabulate import tabulate
+
+from .utils import json_serial
+
+
+class Transaction:
+
+    inline_transaction_fields = {
+        "amount": True,
+        "description": True,
+        "source_name": True,
+        "destination_name": True,
+        "category": False,
+        "budget": False,
+    }
+
+    def __init__(self, **kwargs):
+        # Allow arguments to be comma-separated, but argparse splits by space instead
+        kwargs["transaction"] = list(
+            map(str.strip, " ".join(kwargs["transaction"]).split(","))
+        )
+
+        self.__dict__.update(kwargs)
+
+    def __eq__(self, other):
+        return self.to_dict(remove_none=True, include_header=False) == other.to_dict(
+            remove_none=True, include_header=False
+        )
+
+    @classmethod
+    def from_argparse(cls, args):
+        args = vars(args)
+
+        # Delete cmd2 arguments
+        for k in list(args):
+            if k.startswith("cmd2_"):
+                args.pop(k)
+
+        return cls(**args)
+
+    def mandatory_fields_missing(self):
+
+        mandatory_fields = [
+            field
+            for field, mandatory in Transaction.inline_transaction_fields.items()
+            if mandatory
+        ]
+
+        not_set = []
+        for field in mandatory_fields:
+            if not getattr(self, field, None):
+                not_set.append(field)
+
+        return not_set
+
+    def parse_inline_transaction_to_attributes(self):
+        """Sets the object attributes if not already set by the optional arguments.
+        Optional arguments always take precedence over positional, comma-separated arguments
+        """
+
+        for transaction_field, field in zip(
+            self.transaction, self.inline_transaction_fields
+        ):
+            if not getattr(self, field, None):
+                self.__setattr__(field, transaction_field)
+
+    def to_dict(
+        self,
+        remove_none=False,
+        include_header=True,
+        api_safe=False,
+        tabulate_safe=False,
+    ):
+        if tabulate_safe:
+            remove_none = True
+            api_safe = True
+
+        d = self.__dict__
+
+        if remove_none:
+            d = {k: v for k, v in d.items() if v is not None}
+
+        if not include_header:
+            d = {k: v for k, v in d.items() if not k.startswith("header__")}
+
+        # Do several casts to be json compatible
+        if api_safe:
+            for k, v in d.items():
+                if isinstance(v, datetime):
+                    # If tabulate_safe, then iso_format=False, so that date is in a readable format
+                    d[k] = json_serial(v, iso_format=not tabulate_safe)
+
+        # If for tabulate, each value must be a list
+        if tabulate_safe:
+            d = {k: [v] for k, v in d.items()}
+
+        return d
+
+    def get_tabulates(self):
+        d = self.to_dict(tabulate_safe=True)
+        d_header = {k: v for k, v in d.items() if k.startswith("header__")}
+        d_body = {
+            k: v
+            for k, v in d.items()
+            if not k.startswith("header__") and k != "transaction"
+        }
+
+        # Order d_body first with the date, then inline transaction fields and lastly all the remaining, alphabetically
+        d_body = {
+            **{"date": d_body["date"]},
+            **{
+                k: d_body[k]
+                for k in Transaction.inline_transaction_fields
+                if k in d_body
+            },
+            **{
+                k: d_body[k]
+                for k in sorted(d_body)
+                if k not in Transaction.inline_transaction_fields
+            },
+        }
+
+        tab_header = tabulate(d_header, headers=d_header.keys(), tablefmt="psql")
+        tab_body = tabulate(d_body, headers=d_body.keys(), tablefmt="psql")
+
+        return tab_header, tab_body
diff --git a/firefly_cli/utils.py b/firefly_cli/utils.py
index b9b1f6c..8774264 100644
--- a/firefly_cli/utils.py
+++ b/firefly_cli/utils.py
@@ -1,47 +1,20 @@
 from datetime import datetime
-from io import StringIO
-from pathlib import Path
 
-import pandas as pd
 
+def prompt_continue(extra_line=True, extra_msg=""):
+    extra_line = "\n" if extra_line else ""
+    valid_ans = ("y", "yes")
+    ans = input(f"{extra_line}Would you like to proceed{extra_msg}? (y/n): ")
 
-def parse_transaction_to_df(input):
-    if isinstance(input, str):
-        cols = ['amount', 'description', 'source_name', 'destination_name', 'category', 'budget']
-        data = pd.read_csv(StringIO(input), header=None)
+    return ans.lower() in valid_ans
 
-        # Add remaining columns None
-        data.columns = cols[:data.shape[1]]
-        data = pd.concat([data, pd.DataFrame([[None for _ in cols[data.shape[1]:]]], columns=cols[data.shape[1]:])],
-                         axis=1)
-        data.index = [datetime.now()]
 
-        # Convert all to string and strip edges
-        data = data.apply(lambda x: x.str.strip() if x.dtype == "object" else x)
-        return data
+def json_serial(obj, iso_format=True):
+    """JSON serializer for objects not serializable by default json code"""
 
-
-def parse_csv_to_df(file):
-    """Parses a .csv file to dataframe.
-
-    Assumes that the file exists
-    """
-    cols = ['date', 'amount', 'description', 'source_name', 'destination_name', 'category', 'budget', 'attachment', 'processed']
-
-    data = pd.read_csv(file, header=True)
-
-    if data.columns.tolist()[:4] not in cols:
-        print('[ERROR] Columns of the file don\'t match!')
-        print('\t> Found: {}'.format(' | ' .join(cols)))
-        print('\t> Expected: {}'.format(' | ' .join(cols)))
-        return None
-
-    # Add remaining columns None
-    data.columns = cols[:data.shape[1]]
-    data = pd.concat([data, pd.DataFrame([[None for _ in cols[data.shape[1]:]]], columns=cols[data.shape[1]:])],
-                         axis=1)
-    data.index = [datetime.now()]
-
-    # Convert all to string and strip edges
-    data = data.apply(lambda x: x.str.strip() if x.dtype == "object" else x)
-    return data
+    if isinstance(obj, datetime):
+        if iso_format:
+            return obj.isoformat()
+        else:
+            return obj.strftime("%a, %Y-%m-%d %H:%M:%S")
+    raise TypeError("Type %s not serializable" % type(obj))
diff --git a/requirements.txt b/requirements.txt
index 23dff62..e76d8a9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,12 +1,11 @@
-certifi==2020.12.5
-chardet==4.0.0
-idna==2.10
-numpy==1.19.5
-pandas==1.2.0
-python-dateutil==2.8.1
-pytz==2020.5
-requests==2.25.1
-six==1.15.0
-tabulate==0.8.7
-urllib3==1.26.4
-pyxdg==0.26
+attrs==21.4.0
+certifi==2021.10.8
+charset-normalizer==2.0.12
+cmd2==2.4.0
+idna==3.3
+pyperclip==1.8.2
+pyxdg==0.27
+requests==2.27.1
+tabulate==0.8.9
+urllib3==1.26.8
+wcwidth==0.2.5
diff --git a/setup.cfg b/setup.cfg
index 6c36d6b..b04648b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -6,6 +6,9 @@ versionfile_build = firefly_cli/_version.py
 tag_prefix = v
 parentdir_prefix = firefly-cli-v
 
+[isort]
+profile=black
+
 #[flake8]
 #exclude = <comma separated files>
 #ignore = E402,E501,E731,E741,W503
\ No newline at end of file
diff --git a/setup.py b/setup.py
index e858f36..1bca3f5 100644
--- a/setup.py
+++ b/setup.py
@@ -1,4 +1,5 @@
 import setuptools
+
 import versioneer
 
 with open("README.md", "r") as fh:
@@ -15,18 +16,14 @@
     url="https://github.com/afonsoc12/firefly-cli",
     packages=setuptools.find_packages(),
     install_requires=[
-        'pandas==1.1.4',
-        'requests==2.25.1',
-        'tabulate==0.8.7',
-        'pyxdg==0.26'
+        "cmd2>=2.4,<3",
+        "requests>=2.27,<3",
+        "tabulate>=0.8,<1",
+        "pyxdg>=0.27,<1",
     ],
-    entry_points={
-        'console_scripts': [
-            'firefly-cli = firefly_cli.__main__:main'
-        ]
-    },
+    entry_points={"console_scripts": ["firefly-cli = firefly_cli:main"]},
     classifiers=[
-        "Development Status :: 5 - Production/Stable",
+        "Development Status :: 3 - Alpha",
         "Programming Language :: Python :: 3",
         "License :: OSI Approved :: Apache Software License",
         "Operating System :: OS Independent",
diff --git a/versioneer.py b/versioneer.py
index 1040c21..d70f31b 100644
--- a/versioneer.py
+++ b/versioneer.py
@@ -1,5 +1,4 @@
-
-# Version: 0.19
+# Version: 0.21
 
 """The Versioneer - like a rocketeer, but for versions.
 
@@ -256,6 +255,8 @@
   dependency
 * [minver](https://github.com/jbweston/miniver) - a lightweight reimplementation of
   versioneer
+* [versioningit](https://github.com/jwodder/versioningit) - a PEP 518-based setuptools
+  plugin
 
 ## License
 
@@ -272,6 +273,11 @@
 [travis-url]: https://travis-ci.com/github/python-versioneer/python-versioneer
 
 """
+# pylint:disable=invalid-name,import-outside-toplevel,missing-function-docstring
+# pylint:disable=missing-class-docstring,too-many-branches,too-many-statements
+# pylint:disable=raise-missing-from,too-many-lines,too-many-locals,import-error
+# pylint:disable=too-few-public-methods,redefined-outer-name,consider-using-with
+# pylint:disable=attribute-defined-outside-init,too-many-arguments
 
 import configparser
 import errno
@@ -280,6 +286,7 @@
 import re
 import subprocess
 import sys
+from typing import Callable, Dict
 
 
 class VersioneerConfig:
@@ -301,11 +308,13 @@ def get_root():
         setup_py = os.path.join(root, "setup.py")
         versioneer_py = os.path.join(root, "versioneer.py")
     if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)):
-        err = ("Versioneer was unable to run the project root directory. "
-               "Versioneer requires setup.py to be executed from "
-               "its immediate directory (like 'python setup.py COMMAND'), "
-               "or in a way that lets it use sys.argv[0] to find the root "
-               "(like 'python path/to/setup.py COMMAND').")
+        err = (
+            "Versioneer was unable to run the project root directory. "
+            "Versioneer requires setup.py to be executed from "
+            "its immediate directory (like 'python setup.py COMMAND'), "
+            "or in a way that lets it use sys.argv[0] to find the root "
+            "(like 'python path/to/setup.py COMMAND')."
+        )
         raise VersioneerBadRootError(err)
     try:
         # Certain runtime workflows (setup.py install/develop in a setuptools
@@ -314,12 +323,14 @@ def get_root():
         # module-import table will cache the first one. So we can't use
         # os.path.dirname(__file__), as that will find whichever
         # versioneer.py was first imported, even in later projects.
-        me = os.path.realpath(os.path.abspath(__file__))
-        me_dir = os.path.normcase(os.path.splitext(me)[0])
+        my_path = os.path.realpath(os.path.abspath(__file__))
+        me_dir = os.path.normcase(os.path.splitext(my_path)[0])
         vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0])
         if me_dir != vsr_dir:
-            print("Warning: build in %s is using versioneer.py from %s"
-                  % (os.path.dirname(me), versioneer_py))
+            print(
+                "Warning: build in %s is using versioneer.py from %s"
+                % (os.path.dirname(my_path), versioneer_py)
+            )
     except NameError:
         pass
     return root
@@ -327,30 +338,29 @@ def get_root():
 
 def get_config_from_root(root):
     """Read the project setup.cfg file to determine Versioneer config."""
-    # This might raise EnvironmentError (if setup.cfg is missing), or
+    # This might raise OSError (if setup.cfg is missing), or
     # configparser.NoSectionError (if it lacks a [versioneer] section), or
     # configparser.NoOptionError (if it lacks "VCS="). See the docstring at
     # the top of versioneer.py for instructions on writing your setup.cfg .
     setup_cfg = os.path.join(root, "setup.cfg")
     parser = configparser.ConfigParser()
-    with open(setup_cfg, "r") as f:
-        parser.read_file(f)
+    with open(setup_cfg, "r") as cfg_file:
+        parser.read_file(cfg_file)
     VCS = parser.get("versioneer", "VCS")  # mandatory
 
-    def get(parser, name):
-        if parser.has_option("versioneer", name):
-            return parser.get("versioneer", name)
-        return None
+    # Dict-like interface for non-mandatory entries
+    section = parser["versioneer"]
+
     cfg = VersioneerConfig()
     cfg.VCS = VCS
-    cfg.style = get(parser, "style") or ""
-    cfg.versionfile_source = get(parser, "versionfile_source")
-    cfg.versionfile_build = get(parser, "versionfile_build")
-    cfg.tag_prefix = get(parser, "tag_prefix")
+    cfg.style = section.get("style", "")
+    cfg.versionfile_source = section.get("versionfile_source")
+    cfg.versionfile_build = section.get("versionfile_build")
+    cfg.tag_prefix = section.get("tag_prefix")
     if cfg.tag_prefix in ("''", '""'):
         cfg.tag_prefix = ""
-    cfg.parentdir_prefix = get(parser, "parentdir_prefix")
-    cfg.verbose = get(parser, "verbose")
+    cfg.parentdir_prefix = section.get("parentdir_prefix")
+    cfg.verbose = section.get("verbose")
     return cfg
 
 
@@ -359,36 +369,38 @@ class NotThisMethod(Exception):
 
 
 # these dictionaries contain VCS-specific tools
-LONG_VERSION_PY = {}
-HANDLERS = {}
+LONG_VERSION_PY: Dict[str, str] = {}
+HANDLERS: Dict[str, Dict[str, Callable]] = {}
 
 
 def register_vcs_handler(vcs, method):  # decorator
     """Create decorator to mark a method as the handler of a VCS."""
+
     def decorate(f):
         """Store f in HANDLERS[vcs][method]."""
-        if vcs not in HANDLERS:
-            HANDLERS[vcs] = {}
-        HANDLERS[vcs][method] = f
+        HANDLERS.setdefault(vcs, {})[method] = f
         return f
+
     return decorate
 
 
-def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
-                env=None):
+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:
+    process = None
+    for command in commands:
         try:
-            dispcmd = str([c] + args)
+            dispcmd = str([command] + 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))
+            process = subprocess.Popen(
+                [command] + args,
+                cwd=cwd,
+                env=env,
+                stdout=subprocess.PIPE,
+                stderr=(subprocess.PIPE if hide_stderr else None),
+            )
             break
-        except EnvironmentError:
+        except OSError:
             e = sys.exc_info()[1]
             if e.errno == errno.ENOENT:
                 continue
@@ -400,16 +412,18 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
         if verbose:
             print("unable to find command, tried %s" % (commands,))
         return None, None
-    stdout = p.communicate()[0].strip().decode()
-    if p.returncode != 0:
+    stdout = process.communicate()[0].strip().decode()
+    if process.returncode != 0:
         if verbose:
             print("unable to run %s (error)" % dispcmd)
             print("stdout was %s" % stdout)
-        return None, p.returncode
-    return stdout, p.returncode
+        return None, process.returncode
+    return stdout, process.returncode
 
 
-LONG_VERSION_PY['git'] = r'''
+LONG_VERSION_PY[
+    "git"
+] = r'''
 # 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
@@ -417,7 +431,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
 # that just contains the computed version number.
 
 # This file is released into the public domain. Generated by
-# versioneer-0.19 (https://github.com/python-versioneer/python-versioneer)
+# versioneer-0.21 (https://github.com/python-versioneer/python-versioneer)
 
 """Git implementation of _version.py."""
 
@@ -426,6 +440,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
 import re
 import subprocess
 import sys
+from typing import Callable, Dict
 
 
 def get_keywords():
@@ -463,8 +478,8 @@ class NotThisMethod(Exception):
     """Exception raised if a method is not valid for the current scenario."""
 
 
-LONG_VERSION_PY = {}
-HANDLERS = {}
+LONG_VERSION_PY: Dict[str, str] = {}
+HANDLERS: Dict[str, Dict[str, Callable]] = {}
 
 
 def register_vcs_handler(vcs, method):  # decorator
@@ -482,17 +497,17 @@ 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:
+    process = None
+    for command in commands:
         try:
-            dispcmd = str([c] + args)
+            dispcmd = str([command] + 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))
+            process = subprocess.Popen([command] + args, cwd=cwd, env=env,
+                                       stdout=subprocess.PIPE,
+                                       stderr=(subprocess.PIPE if hide_stderr
+                                               else None))
             break
-        except EnvironmentError:
+        except OSError:
             e = sys.exc_info()[1]
             if e.errno == errno.ENOENT:
                 continue
@@ -504,13 +519,13 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
         if verbose:
             print("unable to find command, tried %%s" %% (commands,))
         return None, None
-    stdout = p.communicate()[0].strip().decode()
-    if p.returncode != 0:
+    stdout = process.communicate()[0].strip().decode()
+    if process.returncode != 0:
         if verbose:
             print("unable to run %%s (error)" %% dispcmd)
             print("stdout was %%s" %% stdout)
-        return None, p.returncode
-    return stdout, p.returncode
+        return None, process.returncode
+    return stdout, process.returncode
 
 
 def versions_from_parentdir(parentdir_prefix, root, verbose):
@@ -522,15 +537,14 @@ def versions_from_parentdir(parentdir_prefix, root, verbose):
     """
     rootdirs = []
 
-    for i in range(3):
+    for _ 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
+        rootdirs.append(root)
+        root = os.path.dirname(root)  # up a level
 
     if verbose:
         print("Tried directories %%s but none started with prefix %%s" %%
@@ -547,22 +561,21 @@ def git_get_keywords(versionfile_abs):
     # _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:
+        with open(versionfile_abs, "r") as fobj:
+            for line in fobj:
+                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)
+    except OSError:
         pass
     return keywords
 
@@ -570,8 +583,8 @@ def git_get_keywords(versionfile_abs):
 @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")
+    if "refnames" not in keywords:
+        raise NotThisMethod("Short version file found")
     date = keywords.get("date")
     if date is not None:
         # Use only the last line.  Previous lines may contain GPG signature
@@ -590,11 +603,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
         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(",")])
+    refs = {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)])
+    tags = {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
@@ -603,7 +616,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
         # 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)])
+        tags = {r for r in refs if re.search(r'\d', r)}
         if verbose:
             print("discarding '%%s', no digits" %% ",".join(refs - tags))
     if verbose:
@@ -612,6 +625,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
         # sorting will prefer e.g. "2.0" over "2.0rc1"
         if ref.startswith(tag_prefix):
             r = ref[len(tag_prefix):]
+            # Filter out refs that exactly match prefix or that don't start
+            # with a number once the prefix is stripped (mostly a concern
+            # when prefix is '')
+            if not re.match(r'\d', r):
+                continue
             if verbose:
                 print("picking %%s" %% r)
             return {"version": r,
@@ -627,7 +645,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
 
 
 @register_vcs_handler("git", "pieces_from_vcs")
-def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
+def git_pieces_from_vcs(tag_prefix, root, verbose, runner=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*
@@ -635,11 +653,13 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
     version string, meaning we're inside a checked out source tree.
     """
     GITS = ["git"]
+    TAG_PREFIX_REGEX = "*"
     if sys.platform == "win32":
         GITS = ["git.cmd", "git.exe"]
+        TAG_PREFIX_REGEX = r"\*"
 
-    out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root,
-                          hide_stderr=True)
+    _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root,
+                   hide_stderr=True)
     if rc != 0:
         if verbose:
             print("Directory %%s not under git control" %% root)
@@ -647,15 +667,16 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
 
     # 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, ["describe", "--tags", "--dirty",
-                                          "--always", "--long",
-                                          "--match", "%%s*" %% tag_prefix],
-                                   cwd=root)
+    describe_out, rc = runner(GITS, ["describe", "--tags", "--dirty",
+                                     "--always", "--long",
+                                     "--match",
+                                     "%%s%%s" %% (tag_prefix, TAG_PREFIX_REGEX)],
+                              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, ["rev-parse", "HEAD"], cwd=root)
+    full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root)
     if full_out is None:
         raise NotThisMethod("'git rev-parse' failed")
     full_out = full_out.strip()
@@ -665,6 +686,39 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
     pieces["short"] = full_out[:7]  # maybe improved later
     pieces["error"] = None
 
+    branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"],
+                             cwd=root)
+    # --abbrev-ref was added in git-1.6.3
+    if rc != 0 or branch_name is None:
+        raise NotThisMethod("'git rev-parse --abbrev-ref' returned error")
+    branch_name = branch_name.strip()
+
+    if branch_name == "HEAD":
+        # If we aren't exactly on a branch, pick a branch which represents
+        # the current commit. If all else fails, we are on a branchless
+        # commit.
+        branches, rc = runner(GITS, ["branch", "--contains"], cwd=root)
+        # --contains was added in git-1.5.4
+        if rc != 0 or branches is None:
+            raise NotThisMethod("'git branch --contains' returned error")
+        branches = branches.split("\n")
+
+        # Remove the first line if we're running detached
+        if "(" in branches[0]:
+            branches.pop(0)
+
+        # Strip off the leading "* " from the list of branches.
+        branches = [branch[2:] for branch in branches]
+        if "master" in branches:
+            branch_name = "master"
+        elif not branches:
+            branch_name = None
+        else:
+            # Pick the first branch that is returned. Good or bad.
+            branch_name = branches[0]
+
+    pieces["branch"] = branch_name
+
     # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
     # TAG might have hyphens.
     git_describe = describe_out
@@ -681,7 +735,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
         # TAG-NUM-gHEX
         mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
         if not mo:
-            # unparseable. Maybe git-describe is misbehaving?
+            # unparsable. Maybe git-describe is misbehaving?
             pieces["error"] = ("unable to parse git-describe output: '%%s'"
                                %% describe_out)
             return pieces
@@ -706,13 +760,11 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
     else:
         # HEX: no tags
         pieces["closest-tag"] = None
-        count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"],
-                                    cwd=root)
+        count_out, rc = runner(GITS, ["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, ["show", "-s", "--format=%%ci", "HEAD"],
-                       cwd=root)[0].strip()
+    date = runner(GITS, ["show", "-s", "--format=%%ci", "HEAD"], cwd=root)[0].strip()
     # Use only the last line.  Previous lines may contain GPG signature
     # information.
     date = date.splitlines()[-1]
@@ -753,16 +805,64 @@ def render_pep440(pieces):
     return rendered
 
 
+def render_pep440_branch(pieces):
+    """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] .
+
+    The ".dev0" means not master branch. Note that .dev0 sorts backwards
+    (a feature branch will appear "older" than the master branch).
+
+    Exceptions:
+    1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty]
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"] or pieces["dirty"]:
+            if pieces["branch"] != "master":
+                rendered += ".dev0"
+            rendered += plus_or_dot(pieces)
+            rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"])
+            if pieces["dirty"]:
+                rendered += ".dirty"
+    else:
+        # exception #1
+        rendered = "0"
+        if pieces["branch"] != "master":
+            rendered += ".dev0"
+        rendered += "+untagged.%%d.g%%s" %% (pieces["distance"],
+                                          pieces["short"])
+        if pieces["dirty"]:
+            rendered += ".dirty"
+    return rendered
+
+
+def pep440_split_post(ver):
+    """Split pep440 version string at the post-release segment.
+
+    Returns the release segments before the post-release and the
+    post-release version number (or -1 if no post-release segment is present).
+    """
+    vc = str.split(ver, ".post")
+    return vc[0], int(vc[1] or 0) if len(vc) == 2 else None
+
+
 def render_pep440_pre(pieces):
-    """TAG[.post0.devDISTANCE] -- No -dirty.
+    """TAG[.postN.devDISTANCE] -- No -dirty.
 
     Exceptions:
     1: no tags. 0.post0.devDISTANCE
     """
     if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
         if pieces["distance"]:
-            rendered += ".post0.dev%%d" %% pieces["distance"]
+            # update the post release segment
+            tag_version, post_version = pep440_split_post(pieces["closest-tag"])
+            rendered = tag_version
+            if post_version is not None:
+                rendered += ".post%%d.dev%%d" %% (post_version+1, pieces["distance"])
+            else:
+                rendered += ".post0.dev%%d" %% (pieces["distance"])
+        else:
+            # no commits, use the tag as the version
+            rendered = pieces["closest-tag"]
     else:
         # exception #1
         rendered = "0.post0.dev%%d" %% pieces["distance"]
@@ -796,6 +896,35 @@ def render_pep440_post(pieces):
     return rendered
 
 
+def render_pep440_post_branch(pieces):
+    """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] .
+
+    The ".dev0" means not master branch.
+
+    Exceptions:
+    1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty]
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"] or pieces["dirty"]:
+            rendered += ".post%%d" %% pieces["distance"]
+            if pieces["branch"] != "master":
+                rendered += ".dev0"
+            rendered += plus_or_dot(pieces)
+            rendered += "g%%s" %% pieces["short"]
+            if pieces["dirty"]:
+                rendered += ".dirty"
+    else:
+        # exception #1
+        rendered = "0.post%%d" %% pieces["distance"]
+        if pieces["branch"] != "master":
+            rendered += ".dev0"
+        rendered += "+g%%s" %% pieces["short"]
+        if pieces["dirty"]:
+            rendered += ".dirty"
+    return rendered
+
+
 def render_pep440_old(pieces):
     """TAG[.postDISTANCE[.dev0]] .
 
@@ -872,10 +1001,14 @@ def render(pieces, style):
 
     if style == "pep440":
         rendered = render_pep440(pieces)
+    elif style == "pep440-branch":
+        rendered = render_pep440_branch(pieces)
     elif style == "pep440-pre":
         rendered = render_pep440_pre(pieces)
     elif style == "pep440-post":
         rendered = render_pep440_post(pieces)
+    elif style == "pep440-post-branch":
+        rendered = render_pep440_post_branch(pieces)
     elif style == "pep440-old":
         rendered = render_pep440_old(pieces)
     elif style == "git-describe":
@@ -911,7 +1044,7 @@ def get_versions():
         # 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('/'):
+        for _ in cfg.versionfile_source.split('/'):
             root = os.path.dirname(root)
     except NameError:
         return {"version": "0+unknown", "full-revisionid": None,
@@ -946,22 +1079,21 @@ def git_get_keywords(versionfile_abs):
     # _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:
+        with open(versionfile_abs, "r") as fobj:
+            for line in fobj:
+                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)
+    except OSError:
         pass
     return keywords
 
@@ -969,8 +1101,8 @@ def git_get_keywords(versionfile_abs):
 @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")
+    if "refnames" not in keywords:
+        raise NotThisMethod("Short version file found")
     date = keywords.get("date")
     if date is not None:
         # Use only the last line.  Previous lines may contain GPG signature
@@ -989,11 +1121,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
         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(",")])
+    refs = {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)])
+    tags = {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
@@ -1002,7 +1134,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
         # 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)])
+        tags = {r for r in refs if re.search(r"\d", r)}
         if verbose:
             print("discarding '%s', no digits" % ",".join(refs - tags))
     if verbose:
@@ -1010,23 +1142,35 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
     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):]
+            r = ref[len(tag_prefix) :]
+            # Filter out refs that exactly match prefix or that don't start
+            # with a number once the prefix is stripped (mostly a concern
+            # when prefix is '')
+            if not re.match(r"\d", r):
+                continue
             if verbose:
                 print("picking %s" % r)
-            return {"version": r,
-                    "full-revisionid": keywords["full"].strip(),
-                    "dirty": False, "error": None,
-                    "date": date}
+            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}
+    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):
+def git_pieces_from_vcs(tag_prefix, root, verbose, runner=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*
@@ -1034,11 +1178,12 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
     version string, meaning we're inside a checked out source tree.
     """
     GITS = ["git"]
+    TAG_PREFIX_REGEX = "*"
     if sys.platform == "win32":
         GITS = ["git.cmd", "git.exe"]
+        TAG_PREFIX_REGEX = r"\*"
 
-    out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root,
-                          hide_stderr=True)
+    _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True)
     if rc != 0:
         if verbose:
             print("Directory %s not under git control" % root)
@@ -1046,15 +1191,24 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
 
     # 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, ["describe", "--tags", "--dirty",
-                                          "--always", "--long",
-                                          "--match", "%s*" % tag_prefix],
-                                   cwd=root)
+    describe_out, rc = runner(
+        GITS,
+        [
+            "describe",
+            "--tags",
+            "--dirty",
+            "--always",
+            "--long",
+            "--match",
+            "%s%s" % (tag_prefix, TAG_PREFIX_REGEX),
+        ],
+        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, ["rev-parse", "HEAD"], cwd=root)
+    full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root)
     if full_out is None:
         raise NotThisMethod("'git rev-parse' failed")
     full_out = full_out.strip()
@@ -1064,6 +1218,38 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
     pieces["short"] = full_out[:7]  # maybe improved later
     pieces["error"] = None
 
+    branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], cwd=root)
+    # --abbrev-ref was added in git-1.6.3
+    if rc != 0 or branch_name is None:
+        raise NotThisMethod("'git rev-parse --abbrev-ref' returned error")
+    branch_name = branch_name.strip()
+
+    if branch_name == "HEAD":
+        # If we aren't exactly on a branch, pick a branch which represents
+        # the current commit. If all else fails, we are on a branchless
+        # commit.
+        branches, rc = runner(GITS, ["branch", "--contains"], cwd=root)
+        # --contains was added in git-1.5.4
+        if rc != 0 or branches is None:
+            raise NotThisMethod("'git branch --contains' returned error")
+        branches = branches.split("\n")
+
+        # Remove the first line if we're running detached
+        if "(" in branches[0]:
+            branches.pop(0)
+
+        # Strip off the leading "* " from the list of branches.
+        branches = [branch[2:] for branch in branches]
+        if "master" in branches:
+            branch_name = "master"
+        elif not branches:
+            branch_name = None
+        else:
+            # Pick the first branch that is returned. Good or bad.
+            branch_name = branches[0]
+
+    pieces["branch"] = branch_name
+
     # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
     # TAG might have hyphens.
     git_describe = describe_out
@@ -1072,17 +1258,16 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
     dirty = git_describe.endswith("-dirty")
     pieces["dirty"] = dirty
     if dirty:
-        git_describe = git_describe[:git_describe.rindex("-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)
+        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)
+            # unparsable. Maybe git-describe is misbehaving?
+            pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out
             return pieces
 
         # tag
@@ -1091,10 +1276,12 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
             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))
+            pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (
+                full_tag,
+                tag_prefix,
+            )
             return pieces
-        pieces["closest-tag"] = full_tag[len(tag_prefix):]
+        pieces["closest-tag"] = full_tag[len(tag_prefix) :]
 
         # distance: number of commits since tag
         pieces["distance"] = int(mo.group(2))
@@ -1105,13 +1292,11 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
     else:
         # HEX: no tags
         pieces["closest-tag"] = None
-        count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"],
-                                    cwd=root)
+        count_out, rc = runner(GITS, ["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, ["show", "-s", "--format=%ci", "HEAD"],
-                       cwd=root)[0].strip()
+    date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip()
     # Use only the last line.  Previous lines may contain GPG signature
     # information.
     date = date.splitlines()[-1]
@@ -1133,27 +1318,26 @@ def do_vcs_install(manifest_in, versionfile_source, ipy):
     if ipy:
         files.append(ipy)
     try:
-        me = __file__
-        if me.endswith(".pyc") or me.endswith(".pyo"):
-            me = os.path.splitext(me)[0] + ".py"
-        versioneer_file = os.path.relpath(me)
+        my_path = __file__
+        if my_path.endswith(".pyc") or my_path.endswith(".pyo"):
+            my_path = os.path.splitext(my_path)[0] + ".py"
+        versioneer_file = os.path.relpath(my_path)
     except NameError:
         versioneer_file = "versioneer.py"
     files.append(versioneer_file)
     present = False
     try:
-        f = open(".gitattributes", "r")
-        for line in f.readlines():
-            if line.strip().startswith(versionfile_source):
-                if "export-subst" in line.strip().split()[1:]:
-                    present = True
-        f.close()
-    except EnvironmentError:
+        with open(".gitattributes", "r") as fobj:
+            for line in fobj:
+                if line.strip().startswith(versionfile_source):
+                    if "export-subst" in line.strip().split()[1:]:
+                        present = True
+                        break
+    except OSError:
         pass
     if not present:
-        f = open(".gitattributes", "a+")
-        f.write("%s export-subst\n" % versionfile_source)
-        f.close()
+        with open(".gitattributes", "a+") as fobj:
+            fobj.write(f"{versionfile_source} export-subst\n")
         files.append(".gitattributes")
     run_command(GITS, ["add", "--"] + files)
 
@@ -1167,24 +1351,29 @@ def versions_from_parentdir(parentdir_prefix, root, verbose):
     """
     rootdirs = []
 
-    for i in range(3):
+    for _ 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
+            return {
+                "version": dirname[len(parentdir_prefix) :],
+                "full-revisionid": None,
+                "dirty": False,
+                "error": None,
+                "date": None,
+            }
+        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))
+        print(
+            "Tried directories %s but none started with prefix %s"
+            % (str(rootdirs), parentdir_prefix)
+        )
     raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
 
 
 SHORT_VERSION_PY = """
-# This file was generated by 'versioneer.py' (0.19) from
+# This file was generated by 'versioneer.py' (0.21) from
 # revision-control system data, or from the parent directory name of an
 # unpacked source archive. Distribution tarballs contain a pre-generated copy
 # of this file.
@@ -1206,13 +1395,15 @@ def versions_from_file(filename):
     try:
         with open(filename) as f:
             contents = f.read()
-    except EnvironmentError:
+    except OSError:
         raise NotThisMethod("unable to read _version.py")
-    mo = re.search(r"version_json = '''\n(.*)'''  # END VERSION_JSON",
-                   contents, re.M | re.S)
+    mo = re.search(
+        r"version_json = '''\n(.*)'''  # END VERSION_JSON", contents, re.M | re.S
+    )
     if not mo:
-        mo = re.search(r"version_json = '''\r\n(.*)'''  # END VERSION_JSON",
-                       contents, re.M | re.S)
+        mo = re.search(
+            r"version_json = '''\r\n(.*)'''  # END VERSION_JSON", contents, re.M | re.S
+        )
     if not mo:
         raise NotThisMethod("no version_json in _version.py")
     return json.loads(mo.group(1))
@@ -1221,8 +1412,7 @@ def versions_from_file(filename):
 def write_to_version_file(filename, versions):
     """Write the given version number to the given _version.py file."""
     os.unlink(filename)
-    contents = json.dumps(versions, sort_keys=True,
-                          indent=1, separators=(",", ": "))
+    contents = json.dumps(versions, sort_keys=True, indent=1, separators=(",", ": "))
     with open(filename, "w") as f:
         f.write(SHORT_VERSION_PY % contents)
 
@@ -1254,23 +1444,69 @@ def render_pep440(pieces):
                 rendered += ".dirty"
     else:
         # exception #1
-        rendered = "0+untagged.%d.g%s" % (pieces["distance"],
-                                          pieces["short"])
+        rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"])
+        if pieces["dirty"]:
+            rendered += ".dirty"
+    return rendered
+
+
+def render_pep440_branch(pieces):
+    """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] .
+
+    The ".dev0" means not master branch. Note that .dev0 sorts backwards
+    (a feature branch will appear "older" than the master branch).
+
+    Exceptions:
+    1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty]
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"] or pieces["dirty"]:
+            if pieces["branch"] != "master":
+                rendered += ".dev0"
+            rendered += plus_or_dot(pieces)
+            rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
+            if pieces["dirty"]:
+                rendered += ".dirty"
+    else:
+        # exception #1
+        rendered = "0"
+        if pieces["branch"] != "master":
+            rendered += ".dev0"
+        rendered += "+untagged.%d.g%s" % (pieces["distance"], pieces["short"])
         if pieces["dirty"]:
             rendered += ".dirty"
     return rendered
 
 
+def pep440_split_post(ver):
+    """Split pep440 version string at the post-release segment.
+
+    Returns the release segments before the post-release and the
+    post-release version number (or -1 if no post-release segment is present).
+    """
+    vc = str.split(ver, ".post")
+    return vc[0], int(vc[1] or 0) if len(vc) == 2 else None
+
+
 def render_pep440_pre(pieces):
-    """TAG[.post0.devDISTANCE] -- No -dirty.
+    """TAG[.postN.devDISTANCE] -- No -dirty.
 
     Exceptions:
     1: no tags. 0.post0.devDISTANCE
     """
     if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
         if pieces["distance"]:
-            rendered += ".post0.dev%d" % pieces["distance"]
+            # update the post release segment
+            tag_version, post_version = pep440_split_post(pieces["closest-tag"])
+            rendered = tag_version
+            if post_version is not None:
+                rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"])
+            else:
+                rendered += ".post0.dev%d" % (pieces["distance"])
+        else:
+            # no commits, use the tag as the version
+            rendered = pieces["closest-tag"]
     else:
         # exception #1
         rendered = "0.post0.dev%d" % pieces["distance"]
@@ -1304,6 +1540,35 @@ def render_pep440_post(pieces):
     return rendered
 
 
+def render_pep440_post_branch(pieces):
+    """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] .
+
+    The ".dev0" means not master branch.
+
+    Exceptions:
+    1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty]
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"] or pieces["dirty"]:
+            rendered += ".post%d" % pieces["distance"]
+            if pieces["branch"] != "master":
+                rendered += ".dev0"
+            rendered += plus_or_dot(pieces)
+            rendered += "g%s" % pieces["short"]
+            if pieces["dirty"]:
+                rendered += ".dirty"
+    else:
+        # exception #1
+        rendered = "0.post%d" % pieces["distance"]
+        if pieces["branch"] != "master":
+            rendered += ".dev0"
+        rendered += "+g%s" % pieces["short"]
+        if pieces["dirty"]:
+            rendered += ".dirty"
+    return rendered
+
+
 def render_pep440_old(pieces):
     """TAG[.postDISTANCE[.dev0]] .
 
@@ -1369,21 +1634,27 @@ def render_git_describe_long(pieces):
 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}
+        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-branch":
+        rendered = render_pep440_branch(pieces)
     elif style == "pep440-pre":
         rendered = render_pep440_pre(pieces)
     elif style == "pep440-post":
         rendered = render_pep440_post(pieces)
+    elif style == "pep440-post-branch":
+        rendered = render_pep440_post_branch(pieces)
     elif style == "pep440-old":
         rendered = render_pep440_old(pieces)
     elif style == "git-describe":
@@ -1393,9 +1664,13 @@ def render(pieces, style):
     else:
         raise ValueError("unknown style '%s'" % style)
 
-    return {"version": rendered, "full-revisionid": pieces["long"],
-            "dirty": pieces["dirty"], "error": None,
-            "date": pieces.get("date")}
+    return {
+        "version": rendered,
+        "full-revisionid": pieces["long"],
+        "dirty": pieces["dirty"],
+        "error": None,
+        "date": pieces.get("date"),
+    }
 
 
 class VersioneerBadRootError(Exception):
@@ -1418,8 +1693,9 @@ def get_versions(verbose=False):
     handlers = HANDLERS.get(cfg.VCS)
     assert handlers, "unrecognized VCS '%s'" % cfg.VCS
     verbose = verbose or cfg.verbose
-    assert cfg.versionfile_source is not None, \
-        "please set versioneer.versionfile_source"
+    assert (
+        cfg.versionfile_source is not None
+    ), "please set versioneer.versionfile_source"
     assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix"
 
     versionfile_abs = os.path.join(root, cfg.versionfile_source)
@@ -1473,9 +1749,13 @@ def get_versions(verbose=False):
     if verbose:
         print("unable to compute version")
 
-    return {"version": "0+unknown", "full-revisionid": None,
-            "dirty": None, "error": "unable to compute version",
-            "date": None}
+    return {
+        "version": "0+unknown",
+        "full-revisionid": None,
+        "dirty": None,
+        "error": "unable to compute version",
+        "date": None,
+    }
 
 
 def get_version():
@@ -1528,6 +1808,7 @@ def run(self):
             print(" date: %s" % vers.get("date"))
             if vers["error"]:
                 print(" error: %s" % vers["error"])
+
     cmds["version"] = cmd_version
 
     # we override "build_py" in both distutils and setuptools
@@ -1546,8 +1827,8 @@ def run(self):
     #  setup.py egg_info -> ?
 
     # we override different "build_py" commands for both environments
-    if 'build_py' in cmds:
-        _build_py = cmds['build_py']
+    if "build_py" in cmds:
+        _build_py = cmds["build_py"]
     elif "setuptools" in sys.modules:
         from setuptools.command.build_py import build_py as _build_py
     else:
@@ -1562,13 +1843,15 @@ def run(self):
             # now locate _version.py in the new build/ directory and replace
             # it with an updated value
             if cfg.versionfile_build:
-                target_versionfile = os.path.join(self.build_lib,
-                                                  cfg.versionfile_build)
+                target_versionfile = os.path.join(self.build_lib, cfg.versionfile_build)
                 print("UPDATING %s" % target_versionfile)
                 write_to_version_file(target_versionfile, versions)
+
     cmds["build_py"] = cmd_build_py
 
-    if "setuptools" in sys.modules:
+    if "build_ext" in cmds:
+        _build_ext = cmds["build_ext"]
+    elif "setuptools" in sys.modules:
         from setuptools.command.build_ext import build_ext as _build_ext
     else:
         from distutils.command.build_ext import build_ext as _build_ext
@@ -1587,14 +1870,15 @@ def run(self):
                 return
             # now locate _version.py in the new build/ directory and replace
             # it with an updated value
-            target_versionfile = os.path.join(self.build_lib,
-                                              cfg.versionfile_source)
+            target_versionfile = os.path.join(self.build_lib, cfg.versionfile_build)
             print("UPDATING %s" % target_versionfile)
             write_to_version_file(target_versionfile, versions)
+
     cmds["build_ext"] = cmd_build_ext
 
     if "cx_Freeze" in sys.modules:  # cx_freeze enabled?
         from cx_Freeze.dist import build_exe as _build_exe
+
         # nczeczulin reports that py2exe won't like the pep440-style string
         # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g.
         # setup(console=[{
@@ -1615,17 +1899,21 @@ def run(self):
                 os.unlink(target_versionfile)
                 with open(cfg.versionfile_source, "w") as f:
                     LONG = LONG_VERSION_PY[cfg.VCS]
-                    f.write(LONG %
-                            {"DOLLAR": "$",
-                             "STYLE": cfg.style,
-                             "TAG_PREFIX": cfg.tag_prefix,
-                             "PARENTDIR_PREFIX": cfg.parentdir_prefix,
-                             "VERSIONFILE_SOURCE": cfg.versionfile_source,
-                             })
+                    f.write(
+                        LONG
+                        % {
+                            "DOLLAR": "$",
+                            "STYLE": cfg.style,
+                            "TAG_PREFIX": cfg.tag_prefix,
+                            "PARENTDIR_PREFIX": cfg.parentdir_prefix,
+                            "VERSIONFILE_SOURCE": cfg.versionfile_source,
+                        }
+                    )
+
         cmds["build_exe"] = cmd_build_exe
         del cmds["build_py"]
 
-    if 'py2exe' in sys.modules:  # py2exe enabled?
+    if "py2exe" in sys.modules:  # py2exe enabled?
         from py2exe.distutils_buildexe import py2exe as _py2exe
 
         class cmd_py2exe(_py2exe):
@@ -1641,18 +1929,22 @@ def run(self):
                 os.unlink(target_versionfile)
                 with open(cfg.versionfile_source, "w") as f:
                     LONG = LONG_VERSION_PY[cfg.VCS]
-                    f.write(LONG %
-                            {"DOLLAR": "$",
-                             "STYLE": cfg.style,
-                             "TAG_PREFIX": cfg.tag_prefix,
-                             "PARENTDIR_PREFIX": cfg.parentdir_prefix,
-                             "VERSIONFILE_SOURCE": cfg.versionfile_source,
-                             })
+                    f.write(
+                        LONG
+                        % {
+                            "DOLLAR": "$",
+                            "STYLE": cfg.style,
+                            "TAG_PREFIX": cfg.tag_prefix,
+                            "PARENTDIR_PREFIX": cfg.parentdir_prefix,
+                            "VERSIONFILE_SOURCE": cfg.versionfile_source,
+                        }
+                    )
+
         cmds["py2exe"] = cmd_py2exe
 
     # we override different "sdist" commands for both environments
-    if 'sdist' in cmds:
-        _sdist = cmds['sdist']
+    if "sdist" in cmds:
+        _sdist = cmds["sdist"]
     elif "setuptools" in sys.modules:
         from setuptools.command.sdist import sdist as _sdist
     else:
@@ -1676,8 +1968,10 @@ def make_release_tree(self, base_dir, files):
             # updated value
             target_versionfile = os.path.join(base_dir, cfg.versionfile_source)
             print("UPDATING %s" % target_versionfile)
-            write_to_version_file(target_versionfile,
-                                  self._versioneer_generated_versions)
+            write_to_version_file(
+                target_versionfile, self._versioneer_generated_versions
+            )
+
     cmds["sdist"] = cmd_sdist
 
     return cmds
@@ -1720,23 +2014,26 @@ def make_release_tree(self, base_dir, files):
 
 """
 
-INIT_PY_SNIPPET = """
+OLD_SNIPPET = """
 from ._version import get_versions
 __version__ = get_versions()['version']
 del get_versions
 """
 
+INIT_PY_SNIPPET = """
+from . import {0}
+__version__ = {0}.get_versions()['version']
+"""
+
 
 def do_setup():
     """Do main VCS-independent setup function for installing Versioneer."""
     root = get_root()
     try:
         cfg = get_config_from_root(root)
-    except (EnvironmentError, configparser.NoSectionError,
-            configparser.NoOptionError) as e:
-        if isinstance(e, (EnvironmentError, configparser.NoSectionError)):
-            print("Adding sample versioneer config to setup.cfg",
-                  file=sys.stderr)
+    except (OSError, configparser.NoSectionError, configparser.NoOptionError) as e:
+        if isinstance(e, (OSError, configparser.NoSectionError)):
+            print("Adding sample versioneer config to setup.cfg", file=sys.stderr)
             with open(os.path.join(root, "setup.cfg"), "a") as f:
                 f.write(SAMPLE_CONFIG)
         print(CONFIG_ERROR, file=sys.stderr)
@@ -1745,25 +2042,34 @@ def do_setup():
     print(" creating %s" % cfg.versionfile_source)
     with open(cfg.versionfile_source, "w") as f:
         LONG = LONG_VERSION_PY[cfg.VCS]
-        f.write(LONG % {"DOLLAR": "$",
-                        "STYLE": cfg.style,
-                        "TAG_PREFIX": cfg.tag_prefix,
-                        "PARENTDIR_PREFIX": cfg.parentdir_prefix,
-                        "VERSIONFILE_SOURCE": cfg.versionfile_source,
-                        })
-
-    ipy = os.path.join(os.path.dirname(cfg.versionfile_source),
-                       "__init__.py")
+        f.write(
+            LONG
+            % {
+                "DOLLAR": "$",
+                "STYLE": cfg.style,
+                "TAG_PREFIX": cfg.tag_prefix,
+                "PARENTDIR_PREFIX": cfg.parentdir_prefix,
+                "VERSIONFILE_SOURCE": cfg.versionfile_source,
+            }
+        )
+
+    ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py")
     if os.path.exists(ipy):
         try:
             with open(ipy, "r") as f:
                 old = f.read()
-        except EnvironmentError:
+        except OSError:
             old = ""
-        if INIT_PY_SNIPPET not in old:
+        module = os.path.splitext(os.path.basename(cfg.versionfile_source))[0]
+        snippet = INIT_PY_SNIPPET.format(module)
+        if OLD_SNIPPET in old:
+            print(" replacing boilerplate in %s" % ipy)
+            with open(ipy, "w") as f:
+                f.write(old.replace(OLD_SNIPPET, snippet))
+        elif snippet not in old:
             print(" appending to %s" % ipy)
             with open(ipy, "a") as f:
-                f.write(INIT_PY_SNIPPET)
+                f.write(snippet)
         else:
             print(" %s unmodified" % ipy)
     else:
@@ -1782,7 +2088,7 @@ def do_setup():
                 if line.startswith("include "):
                     for include in line.split()[1:]:
                         simple_includes.add(include)
-    except EnvironmentError:
+    except OSError:
         pass
     # That doesn't cover everything MANIFEST.in can do
     # (http://docs.python.org/2/distutils/sourcedist.html#commands), so
@@ -1795,8 +2101,10 @@ def do_setup():
     else:
         print(" 'versioneer.py' already in MANIFEST.in")
     if cfg.versionfile_source not in simple_includes:
-        print(" appending versionfile_source ('%s') to MANIFEST.in" %
-              cfg.versionfile_source)
+        print(
+            " appending versionfile_source ('%s') to MANIFEST.in"
+            % cfg.versionfile_source
+        )
         with open(manifest_in, "a") as f:
             f.write("include %s\n" % cfg.versionfile_source)
     else:


Test patch:
diff --git a/.github/workflows/unit_testing.yml b/.github/workflows/unit_testing.yml
new file mode 100644
index 0000000..21b6ee8
--- /dev/null
+++ b/.github/workflows/unit_testing.yml
@@ -0,0 +1,23 @@
+name: Run unit tests
+
+on:
+  workflow_call:
+  push:
+  pull_request:
+    branches:
+      - master
+
+jobs:
+  pytest:
+    name: Runs pytest unit-tests
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+      - name: Install python dependencies
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install -r requirements.txt
+          python -m pip install pytest requests-mock
+      - name: Run pytest
+        run: pytest test/*
\ No newline at end of file
diff --git a/test/test_dummy.py b/test/test_dummy.py
deleted file mode 100644
index ff53927..0000000
--- a/test/test_dummy.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import unittest
-
-class TestCase(unittest.TestCase):
-
-    def test_dummy(self):
-        self.assertEqual('tests to be added', 'tests to be added')
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/test/test_transaction.py b/test/test_transaction.py
new file mode 100644
index 0000000..347d5a6
--- /dev/null
+++ b/test/test_transaction.py
@@ -0,0 +1,113 @@
+from datetime import datetime
+
+import pytest
+
+from firefly_cli.parsers import get_add_parser
+from firefly_cli.transaction import Transaction
+
+
+class TestTransaction:
+    parser = get_add_parser()
+
+    @pytest.mark.parametrize(
+        "fields, fields_missing",
+        [
+            ({"transaction": ["3,mocha,bank1,expense1"]}, []),
+            ({"transaction": ["3,  mocha , bank1  , expense1  "]}, []),
+            ({"transaction": [", mocha,, expense1"]}, ["amount", "source_name"]),
+            ({"transaction": ["3,mocha, bank1"]}, ["destination_name"]),
+            ({"transaction": ["3,mocha, bank1,"]}, ["destination_name"]),
+            (
+                {"transaction": ["mocha,, expense1"]},
+                ["description", "destination_name"],
+            ),
+        ],
+    )
+    def test_mandatory_fields_missing(self, fields, fields_missing):
+        """Asserts if the missing fields are the ones expected."""
+
+        transaction = Transaction(**fields)
+        transaction.parse_inline_transaction_to_attributes()
+
+        assert transaction.mandatory_fields_missing() == fields_missing
+
+    @pytest.mark.parametrize(
+        "fields, arg_str",
+        [
+            (
+                {
+                    "transaction": [" 3 ,  mocha  , bank1 , expense1 "],
+                    "date": datetime.strptime("1970-01-01", "%Y-%m-%d").astimezone(),
+                    "type": "withdrawal",
+                },
+                " 3 ,  mocha  , bank1 , expense1  --date 1970-01-01",
+            )
+        ],
+    )
+    def test_constructor_from_argparse(self, fields, arg_str):
+        """Asserts if Transaction is the same if constructed from argparse or dict."""
+
+        transaction = Transaction(**fields)
+
+        args = TestTransaction.parser.parse_args([a for a in arg_str.split(" ") if a])
+
+        transaction_argparse = Transaction.from_argparse(args)
+
+        transaction.parse_inline_transaction_to_attributes()
+        transaction_argparse.parse_inline_transaction_to_attributes()
+
+        assert transaction == transaction_argparse
+
+    @pytest.mark.parametrize(
+        "arg_str, fields_expected",
+        [
+            (
+                "3, mocha, bank1, expense1  --date 1970-01-01 --source-name bank2",
+                {
+                    "transaction": [""],
+                    "amount": "3",
+                    "description": "mocha",
+                    "source_name": "bank2",
+                    "destination_name": "expense1",
+                    "date": datetime.strptime("1970-01-01", "%Y-%m-%d").astimezone(),
+                    "type": "withdrawal",
+                },
+            ),
+            (
+                "3, mocha, bank1  --date 1970-01-01 --destination-name expense3  --source-name bank2",
+                {
+                    "transaction": [""],
+                    "amount": "3",
+                    "description": "mocha",
+                    "source_name": "bank2",
+                    "destination_name": "expense3",
+                    "date": datetime.strptime("1970-01-01", "%Y-%m-%d").astimezone(),
+                    "type": "withdrawal",
+                },
+            ),
+            (
+                "3, mocha, bank1  --date 1970-01-01 --source-name bank2 --type deposit",
+                {
+                    "transaction": [""],
+                    "amount": "3",
+                    "description": "mocha",
+                    "source_name": "bank2",
+                    "date": datetime.strptime("1970-01-01", "%Y-%m-%d").astimezone(),
+                    "type": "deposit",
+                },
+            ),
+        ],
+    )
+    def test_parse_inline_transaction_to_attributes(self, arg_str, fields_expected):
+
+        args = TestTransaction.parser.parse_args([a for a in arg_str.split(" ") if a])
+
+        transaction = Transaction.from_argparse(args)
+        transaction.parse_inline_transaction_to_attributes()
+        transaction.transaction = None
+
+        transaction_expected = Transaction(**fields_expected)
+        transaction_expected.parse_inline_transaction_to_attributes()
+        transaction_expected.transaction = None
+
+        assert transaction == transaction_expected