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