diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000000000000000000000000000000000000..ed02b7691ddfbf487adb28a9ed875cbe45dd301c
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,7 @@
+[flake8]
+max-line-length = 120
+exclude=build,.cache,.eggs,.git,src/zeep,front
+# Flake8 ignores multiple errors by default;
+# the only interesting ignore is W503, which goes against PEP8.
+# See https://lintlyci.github.io/Flake8Rules/rules/W503.html
+ignore = E203,E501,W503
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..e4f1a63408f2c8d3c386535babe1797c7bb76eb8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+**.pyc
+.tox/
+*.egg-info
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b5620d1ff7b780a8580daa79198cb2f6834cb8d8
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,93 @@
+stages:
+  - test
+  - build
+  - release
+
+variables:
+  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
+cache:
+  paths:
+    - .cache/pip
+
+linter:
+  stage: test
+  image: python:3
+
+  cache:
+    paths:
+      - .cache/pip
+      - .cache/pre-commit
+
+  except:
+    - schedules
+
+  variables:
+    PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
+    PRE_COMMIT_HOME: "$CI_PROJECT_DIR/.cache/pre-commit"
+
+  before_script:
+    - pip install pre-commit
+
+  script:
+    - pre-commit run -a
+
+tests:
+  stage: test
+  image: python:3.7
+
+  cache:
+    paths:
+      - .cache/pip
+
+  except:
+    - schedules
+
+  variables:
+    PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
+
+  before_script:
+    - pip install tox
+
+  script:
+    - tox -e py37-unit
+
+release-pypi:
+  stage: release
+  only:
+    - tags
+  environment:
+    name: pypi
+    url: https://pypi.org/project/teklia-toolbox
+
+  before_script:
+    - pip install twine setuptools wheel
+    - echo "[distutils]" > ~/.pypirc
+    - echo "index-servers =" >> ~/.pypirc
+    - echo "  pypi" >> ~/.pypirc
+    - echo "[pypi]" >> ~/.pypirc
+    - echo "repository=https://upload.pypi.org/legacy/" >> ~/.pypirc
+    - echo "username=$PYPI_release_USERNAME" >> ~/.pypirc
+    - echo "password=$PYPI_release_PASSWORD" >> ~/.pypirc
+  script:
+    - python setup.py sdist bdist_wheel
+    - twine upload dist/* -r pypi
+
+release-notes:
+  stage: release
+  image: registry.gitlab.com/teklia/devops:latest
+
+  only:
+    - tags
+
+  script:
+    - devops release-notes
+
+bump-python-deps:
+  stage: build
+  image: registry.gitlab.com/teklia/devops:latest
+
+  only:
+    - schedules
+
+  script:
+    - devops python-deps requirements.txt base/requirements.txt tests-requirements.txt
diff --git a/.isort.cfg b/.isort.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..386a610ebc5076d7980bdbaaaef3b4a8d07e4bf5
--- /dev/null
+++ b/.isort.cfg
@@ -0,0 +1,11 @@
+[settings]
+# Compatible with black
+multi_line_output = 3
+include_trailing_comma = True
+force_grid_wrap = 0
+use_parentheses = True
+line_length = 120
+
+default_section = FIRSTPARTY
+known_first_party = arkindex_toolbox
+known_third_party = setuptools,yaml
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..f7b5071ddad617da28eb695ecb5618a6fc525459
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,49 @@
+repos:
+  - repo: https://github.com/asottile/seed-isort-config
+    rev: v2.2.0
+    hooks:
+      - id: seed-isort-config
+  - repo: https://github.com/pre-commit/mirrors-isort
+    rev: v4.3.21
+    hooks:
+      - id: isort
+  - repo: https://github.com/ambv/black
+    rev: 20.8b1
+    hooks:
+    - id: black
+  - repo: https://gitlab.com/pycqa/flake8
+    rev: 3.8.3
+    hooks:
+      - id: flake8
+        additional_dependencies:
+          - 'flake8-coding==1.3.1'
+          - 'flake8-copyright==0.2.2'
+          - 'flake8-debugger==3.1.0'
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v3.1.0
+    hooks:
+      - id: check-ast
+      - id: check-docstring-first
+      - id: check-executables-have-shebangs
+      - id: check-merge-conflict
+      - id: check-symlinks
+      - id: debug-statements
+      - id: trailing-whitespace
+      - id: check-yaml
+        args: [--allow-multiple-documents]
+      - id: mixed-line-ending
+      - id: name-tests-test
+        args: ['--django']
+      - id: check-json
+      - id: requirements-txt-fixer
+  - repo: https://github.com/codespell-project/codespell
+    rev: v1.17.1
+    hooks:
+      - id: codespell
+        args: ['--write-changes']
+  - repo: meta
+    hooks:
+      - id: check-useless-excludes
+
+default_language_version:
+  python: python3.7
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000000000000000000000000000000000000..510b4afff24a85a88d63efe462586a38175a0901
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,2 @@
+include VERSION
+include requirements.txt
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000000000000000000000000000000000000..6e8bf73aa550d4c57f6f35830f1bcdc7a4a62f38
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.1.0
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..7a997b5e44bdb0814e90ee7698c15ca92bc8d3e1
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+PyYAML==5.3.1
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000000000000000000000000000000000000..a9a0dfc700bad16280ef7497653bbb9870da37aa
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+import os.path
+
+from setuptools import find_packages, setup
+
+
+def requirements(path):
+    assert os.path.exists(path), "Missing requirements {}".format(path)
+    with open(path) as f:
+        return list(map(str.strip, f.read().splitlines()))
+
+
+with open("VERSION") as f:
+    VERSION = f.read()
+
+install_requires = requirements("requirements.txt")
+
+setup(
+    name="teklia-toolbox",
+    version=VERSION,
+    author="Teklia",
+    author_email="contact@teklia.com",
+    python_requires=">=3.7",
+    install_requires=install_requires,
+    packages=find_packages(),
+    include_package_data=True,
+)
diff --git a/teklia_toolbox/__init__.py b/teklia_toolbox/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/teklia_toolbox/config.py b/teklia_toolbox/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..f46babbc69d9f2aea443ef1286f34705e3ef028d
--- /dev/null
+++ b/teklia_toolbox/config.py
@@ -0,0 +1,130 @@
+# -*- coding: utf-8 -*-
+import json
+import os
+import sys
+from collections import namedtuple
+from collections.abc import Mapping
+from pathlib import Path
+
+import yaml
+
+Option = namedtuple("Option", ["type", "default"])
+
+
+# Used as a default value in `ConfigParser.add_option(default=UNSET)`
+# because default=None implies that the option is optional
+UNSET = object()
+
+
+def _all_checks():
+    """
+    Prevents checking for path existence when running unit tests
+    or other dev-related operations.
+    This is the same as settings.ALL_CHECKS, but since the configuration
+    is accessed before settings are initialized, it has to be copied here.
+    This is made as a method to make mocking in unit tests much simpler
+    than with a module-level constant.
+    """
+    os.environ.get("ALL_CHECKS") == "true" or "runserver" in sys.argv
+
+
+def file_path(data):
+    path = Path(data).resolve()
+    if _all_checks():
+        assert path.exists(), f"{path} does not exist"
+        assert path.is_file(), f"{path} is not a file"
+    return path
+
+
+def dir_path(data):
+    path = Path(data).resolve()
+    if _all_checks():
+        assert path.exists(), f"{path} does not exist"
+        assert path.is_dir(), f"{path} is not a directory"
+    return path
+
+
+class ConfigurationError(ValueError):
+    def __init__(self, errors, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.errors = errors
+
+    def __str__(self):
+        return json.dumps(self.errors)
+
+    def __repr__(self):
+        return "{}({!s})".format(self.__class__.__name__, self)
+
+
+class ConfigParser(object):
+    def __init__(self, allow_extra_keys=True):
+        """
+        :param allow_extra_keys bool: Ignore extra unspecified keys instead
+        of causing errors.
+        """
+        self.options = {}
+        self.allow_extra_keys = allow_extra_keys
+
+    def add_option(self, name, *, type=str, many=False, default=UNSET):
+        assert name not in self.options, f"{name} is an already defined option"
+        assert callable(type), "Option type must be callable"
+        if many:
+            self.options[name] = Option(lambda data: list(map(type, data)), default)
+        else:
+            self.options[name] = Option(type, default)
+
+    def add_subparser(self, *args, allow_extra_keys=True, **kwargs):
+        """
+        Add a parser as a new option to this parser,
+        to allow finer control over nested configuration options.
+        """
+        parser = ConfigParser(allow_extra_keys=allow_extra_keys)
+        self.add_option(*args, **kwargs, type=parser.parse_data)
+        return parser
+
+    def parse_data(self, data):
+        """
+        Parse configuration data from a dict.
+        Will raise ConfigurationError if any error is detected.
+        """
+        if not isinstance(data, Mapping):
+            raise ConfigurationError("Parser data must be a mapping")
+
+        parsed, errors = {}, {}
+
+        if not self.allow_extra_keys:
+            for name in data:
+                if name not in self.options:
+                    errors[name] = "This option does not exist"
+
+        for name, option in self.options.items():
+            if name in data:
+                value = data[name]
+            elif option.default is UNSET:
+                errors[name] = "This option is required"
+                continue
+            elif option.default is None:
+                parsed[name] = None
+                continue
+            else:
+                value = option.default
+
+            try:
+                parsed[name] = option.type(value)
+            except ConfigurationError as e:
+                # Allow nested error dicts for nicer error messages
+                # with add_subparser
+                errors[name] = e.errors
+            except Exception as e:
+                errors[name] = str(e)
+
+        if errors:
+            raise ConfigurationError(errors)
+        return parsed
+
+    def parse(self, path, exist_ok=False):
+        if not path.is_file() and exist_ok:
+            # Act like the file is empty
+            return self.parse_data({})
+        with open(path) as f:
+            return self.parse_data(yaml.safe_load(f))
diff --git a/teklia_toolbox/time.py b/teklia_toolbox/time.py
new file mode 100644
index 0000000000000000000000000000000000000000..3bc906706da7cbad442e96528a143311dfd82251
--- /dev/null
+++ b/teklia_toolbox/time.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+import datetime
+from timeit import default_timer
+
+
+class Timer(object):
+    """
+    A context manager to help measure execution times
+    """
+
+    def __init__(self):
+        self.timer = default_timer
+
+    def __enter__(self):
+        self.start = self.timer()
+        return self
+
+    def __exit__(self, *args):
+        end = self.timer()
+        self.elapsed = end - self.start
+        self.delta = datetime.timedelta(seconds=self.elapsed)
diff --git a/tests/test_time.py b/tests/test_time.py
new file mode 100644
index 0000000000000000000000000000000000000000..bb2c0bde43c28412b5f1c12fb3f7635863d57978
--- /dev/null
+++ b/tests/test_time.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+
+import time
+
+from teklia_toolbox.time import Timer
+
+
+def test_timer():
+
+    with Timer() as t:
+        time.sleep(0.05)
+
+    assert t.delta.total_seconds() >= 0.05
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000000000000000000000000000000000000..c959e1efa7f8f3c29b82691a48c01c507a5ab914
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,10 @@
+[tox]
+envlist = py37-unit
+
+[testenv:py37-unit]
+commands =
+  pytest
+
+deps =
+  pytest
+  -rrequirements.txt