diff --git a/.drone.jsonnet b/.drone.jsonnet
new file mode 100644
index 0000000..b174d0c
--- /dev/null
+++ b/.drone.jsonnet
@@ -0,0 +1,343 @@
+local PythonVersion(pyversion='3.5') = {
+ name: 'python' + std.strReplace(pyversion, '.', '') + '-pytest',
+ image: 'python:' + pyversion,
+ environment: {
+ PY_COLORS: 1,
+ },
+ commands: [
+ 'pip install -r test-requirements.txt -qq',
+ 'pip install -qq .',
+ 'docker-tidy --help',
+ ],
+ depends_on: [
+ 'clone',
+ ],
+};
+
+local PipelineLint = {
+ kind: 'pipeline',
+ name: 'lint',
+ platform: {
+ os: 'linux',
+ arch: 'amd64',
+ },
+ steps: [
+ {
+ name: 'flake8',
+ image: 'python:3.7',
+ environment: {
+ PY_COLORS: 1,
+ },
+ commands: [
+ 'pip install -r test-requirements.txt -qq',
+ 'pip install -qq .',
+ 'flake8 ./dockertidy',
+ ],
+ },
+ ],
+ trigger: {
+ ref: ['refs/heads/master', 'refs/tags/**', 'refs/pull/**'],
+ },
+};
+
+local PipelineTest = {
+ kind: 'pipeline',
+ name: 'test',
+ platform: {
+ os: 'linux',
+ arch: 'amd64',
+ },
+ steps: [
+ PythonVersion(pyversion='3.5'),
+ PythonVersion(pyversion='3.6'),
+ PythonVersion(pyversion='3.7'),
+ PythonVersion(pyversion='3.8-rc'),
+ ],
+ depends_on: [
+ 'lint',
+ ],
+ trigger: {
+ ref: ['refs/heads/master', 'refs/tags/**', 'refs/pull/**'],
+ },
+};
+
+local PipelineSecurity = {
+ kind: 'pipeline',
+ name: 'security',
+ platform: {
+ os: 'linux',
+ arch: 'amd64',
+ },
+ steps: [
+ {
+ name: 'bandit',
+ image: 'python:3.7',
+ environment: {
+ PY_COLORS: 1,
+ },
+ commands: [
+ 'pip install -r test-requirements.txt -qq',
+ 'pip install -qq .',
+ 'bandit -r ./dockertidy -x ./dockertidy/tests',
+ ],
+ },
+ ],
+ depends_on: [
+ 'test',
+ ],
+ trigger: {
+ ref: ['refs/heads/master', 'refs/tags/**', 'refs/pull/**'],
+ },
+};
+
+local PipelineBuildPackage = {
+ kind: 'pipeline',
+ name: 'build-package',
+ platform: {
+ os: 'linux',
+ arch: 'amd64',
+ },
+ steps: [
+ {
+ name: 'build',
+ image: 'python:3.7',
+ commands: [
+ 'python setup.py sdist bdist_wheel',
+ ],
+ },
+ {
+ name: 'checksum',
+ image: 'alpine',
+ commands: [
+ 'cd dist/ && sha256sum * > ../sha256sum.txt',
+ ],
+ },
+ {
+ name: 'publish-github',
+ image: 'plugins/github-release',
+ settings: {
+ overwrite: true,
+ api_key: { from_secret: 'github_token' },
+ files: ['dist/*', 'sha256sum.txt'],
+ title: '${DRONE_TAG}',
+ note: 'CHANGELOG.md',
+ },
+ when: {
+ ref: ['refs/tags/**'],
+ },
+ },
+ {
+ name: 'publish-pypi',
+ image: 'plugins/pypi',
+ settings: {
+ username: { from_secret: 'pypi_username' },
+ password: { from_secret: 'pypi_password' },
+ repository: 'https://upload.pypi.org/legacy/',
+ skip_build: true,
+ },
+ when: {
+ ref: ['refs/tags/**'],
+ },
+ },
+ ],
+ depends_on: [
+ 'security',
+ ],
+ trigger: {
+ ref: ['refs/heads/master', 'refs/tags/**', 'refs/pull/**'],
+ },
+};
+
+local PipelineBuildContainer(arch='amd64') = {
+ kind: 'pipeline',
+ name: 'build-container-' + arch,
+ platform: {
+ os: 'linux',
+ arch: arch,
+ },
+ steps: [
+ {
+ name: 'build',
+ image: 'python:3.7',
+ commands: [
+ 'python setup.py bdist_wheel',
+ ],
+ },
+ {
+ name: 'dryrun',
+ image: 'plugins/docker:18-linux-' + arch,
+ settings: {
+ dry_run: true,
+ dockerfile: 'Dockerfile',
+ repo: 'xoxys/${DRONE_REPO_NAME}',
+ username: { from_secret: 'docker_username' },
+ password: { from_secret: 'docker_password' },
+ },
+ when: {
+ ref: ['refs/pull/**'],
+ },
+ },
+ {
+ name: 'publish',
+ image: 'plugins/docker:18-linux-' + arch,
+ settings: {
+ auto_tag: true,
+ auto_tag_suffix: arch,
+ dockerfile: 'Dockerfile',
+ repo: 'xoxys/${DRONE_REPO_NAME}',
+ username: { from_secret: 'docker_username' },
+ password: { from_secret: 'docker_password' },
+ },
+ when: {
+ ref: ['refs/heads/master', 'refs/tags/**'],
+ },
+ },
+ ],
+ depends_on: [
+ 'security',
+ ],
+ trigger: {
+ ref: ['refs/heads/master', 'refs/tags/**', 'refs/pull/**'],
+ },
+};
+
+local PipelineDocs = {
+ kind: 'pipeline',
+ name: 'docs',
+ platform: {
+ os: 'linux',
+ arch: 'amd64',
+ },
+ concurrency: {
+ limit: 1,
+ },
+ steps: [
+ {
+ name: 'assets',
+ image: 'byrnedo/alpine-curl',
+ commands: [
+ 'mkdir -p docs/themes/hugo-geekdoc/',
+ 'curl -L https://github.com/xoxys/hugo-geekdoc/releases/latest/download/hugo-geekdoc.tar.gz | tar -xz -C docs/themes/hugo-geekdoc/ --strip-components=1',
+ ],
+ },
+ {
+ name: 'test',
+ image: 'klakegg/hugo:0.59.1-ext-alpine',
+ commands: [
+ 'cd docs/ && hugo-official',
+ ],
+ },
+ {
+ name: 'freeze',
+ image: 'appleboy/drone-ssh:1.5.5',
+ settings: {
+ host: { from_secret: 'ssh_host' },
+ key: { from_secret: 'ssh_key' },
+ script: [
+ 'cp -R /var/www/virtual/geeklab/html/docker-tidy.geekdocs.de/ /var/www/virtual/geeklab/html/dockertidy_freeze/',
+ 'ln -sfn /var/www/virtual/geeklab/html/dockertidy_freeze /var/www/virtual/geeklab/docker-tidy.geekdocs.de',
+ ],
+ username: { from_secret: 'ssh_username' },
+ },
+ },
+ {
+ name: 'publish',
+ image: 'appleboy/drone-scp',
+ settings: {
+ host: { from_secret: 'ssh_host' },
+ key: { from_secret: 'ssh_key' },
+ rm: true,
+ source: 'docs/public/*',
+ strip_components: 2,
+ target: '/var/www/virtual/geeklab/html/docker-tidy.geekdocs.de/',
+ username: { from_secret: 'ssh_username' },
+ },
+ },
+ {
+ name: 'cleanup',
+ image: 'appleboy/drone-ssh:1.5.5',
+ settings: {
+ host: { from_secret: 'ssh_host' },
+ key: { from_secret: 'ssh_key' },
+ script: [
+ 'ln -sfn /var/www/virtual/geeklab/html/docker-tidy.geekdocs.de /var/www/virtual/geeklab/docker-tidy.geekdocs.de',
+ 'rm -rf /var/www/virtual/geeklab/html/dockertidy_freeze/',
+ ],
+ username: { from_secret: 'ssh_username' },
+ },
+ },
+ ],
+ depends_on: [
+ 'build-package',
+ 'build-container-amd64',
+ 'build-container-arm64',
+ 'build-container-arm',
+ ],
+ trigger: {
+ ref: ['refs/heads/master', 'refs/tags/**'],
+ },
+};
+
+local PipelineNotifications = {
+ kind: 'pipeline',
+ name: 'notifications',
+ platform: {
+ os: 'linux',
+ arch: 'amd64',
+ },
+ steps: [
+ {
+ image: 'plugins/manifest',
+ name: 'manifest',
+ settings: {
+ ignore_missing: true,
+ auto_tag: true,
+ username: { from_secret: 'docker_username' },
+ password: { from_secret: 'docker_password' },
+ spec: 'manifest.tmpl',
+ },
+ },
+ {
+ name: 'readme',
+ image: 'sheogorath/readme-to-dockerhub',
+ environment: {
+ DOCKERHUB_USERNAME: { from_secret: 'docker_username' },
+ DOCKERHUB_PASSWORD: { from_secret: 'docker_password' },
+ DOCKERHUB_REPO_PREFIX: 'xoxys',
+ DOCKERHUB_REPO_NAME: '${DRONE_REPO_NAME}',
+ README_PATH: 'README.md',
+ SHORT_DESCRIPTION: 'docker-tidy - Simple annotation based documentation for your roles',
+ },
+ },
+ {
+ name: 'matrix',
+ image: 'plugins/matrix',
+ settings: {
+ homeserver: { from_secret: 'matrix_homeserver' },
+ roomid: { from_secret: 'matrix_roomid' },
+ template: 'Status: **{{ build.status }}**
Build: [{{ repo.Owner }}/{{ repo.Name }}]({{ build.link }}) ({{ build.branch }}) by {{ build.author }}
Message: {{ build.message }}',
+ username: { from_secret: 'matrix_username' },
+ password: { from_secret: 'matrix_password' },
+ },
+ },
+ ],
+ depends_on: [
+ 'docs',
+ ],
+ trigger: {
+ ref: ['refs/heads/master', 'refs/tags/**'],
+ status: ['success', 'failure'],
+ },
+};
+
+[
+ PipelineLint,
+ PipelineTest,
+ PipelineSecurity,
+ PipelineBuildPackage,
+ PipelineBuildContainer(arch='amd64'),
+ PipelineBuildContainer(arch='arm64'),
+ PipelineBuildContainer(arch='arm'),
+ PipelineDocs,
+ PipelineNotifications,
+]
diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..c29b240
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,18 @@
+[flake8]
+ignore = D103
+max-line-length = 110
+inline-quotes = double
+exclude =
+ .git
+ .tox
+ __pycache__
+ build
+ dist
+ tests
+ *.pyc
+ *.egg-info
+ .cache
+ .eggs
+ env*
+application-import-names = dockertidy
+format = ${cyan}%(path)s:%(row)d:%(col)d${reset}: ${red_bold}%(code)s${reset} %(text)s
diff --git a/.gitignore b/.gitignore
index 28a584d..6f577d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,108 @@
-*.py?
-.*.swp
-.tox
-dist
-build/
-*.pyc
-__pycache__
+# ---> Python
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+parts/
+sdist/
+var/
+wheels/
*.egg-info/
+.installed.cfg
+*.egg
+
+# 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/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+.hypothesis/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# dotenv
+.env
+
+# virtualenv
+.venv
+venv/
+ENV/
+env/
+env*/
+
+# Spyder project settings
+.spyderproject
+
+# Rope project settings
+.ropeproject
+
+# Ignore ide addons
+.server-script
+.on-save.json
+.vscode
+.pytest_cache
+
+pip-wheel-metadata
+
+# Hugo documentation
+docs/themes/
+docs/public/
+resources/_gen/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
deleted file mode 100644
index 8e3af55..0000000
--- a/.pre-commit-config.yaml
+++ /dev/null
@@ -1,21 +0,0 @@
-repos:
-- repo: git://github.com/pre-commit/pre-commit-hooks
- rev: v2.2.1
- hooks:
- - id: check-added-large-files
- - id: check-docstring-first
- - id: check-merge-conflict
- - id: check-yaml
- - id: debug-statements
- - id: end-of-file-fixer
- exclude: CHANGELOG.md
- - id: flake8
- - id: name-tests-test
- - id: requirements-txt-fixer
- - id: trailing-whitespace
-- repo: git://github.com/Yelp/detect-secrets
- rev: v0.12.2
- hooks:
- - id: detect-secrets
- args: ['--baseline', '.secrets.baseline']
- exclude: tests/.*
diff --git a/.secrets.baseline b/.secrets.baseline
deleted file mode 100644
index 4902d77..0000000
--- a/.secrets.baseline
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "exclude": {
- "files": "tests/.*",
- "lines": null
- },
- "generated_at": "2019-04-24T14:36:38Z",
- "plugins_used": [
- {
- "base64_limit": 4.5,
- "name": "Base64HighEntropyString"
- },
- {
- "hex_limit": 3,
- "name": "HexHighEntropyString"
- },
- {
- "name": "PrivateKeyDetector"
- }
- ],
- "results": {},
- "version": "0.12.2"
-}
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 11164d7..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-language: python
-matrix:
- include:
- - python: 2.7
- env: TOXENV=py27
- - python: 3.5
- env: TOXENV=py35
- - python: 3.6
- env: TOXENV=py36
-install:
-- pip install tox
-script:
-- tox
-
-deploy:
- - provider: pypi
- user: yelplabs
- password:
- secure: R3TcB5uz56Pu3qRAb0X1/uLQ/hCzNEi1MEAxDxNbwCfKbDZ1s/IxcjeQa4m5W8JgxBNEfa/ygbcV7UKe4wKHnuuXIve4XSd0Slde+cEY1awOFzgbeaxhnu8a1Z6lFXfOriq1fDKX3fGLvxKhoR0uonvRQO0Hx3oZWPMeT8XnoqkPTiAXkGdDhbnQbgFj5fxOq6Fd/InIkWnTSScCjHxaf4FJZISumGCAFF7PBWHOJhkYCVx/CoMK2h2Ch8rqbiVQUIYDiDrDeXkoXYXjCnoGZ1xjLqBS7TLkZgC0Ic8XZwcGdmQvUroOXnGRvs+P7J8clhO4hhEawoSAzcWcSeHBlm+45iW+s1wXD+yJ5BzZpXZHG5BlU8tPnpbY/MV8+2aYq/EPzcGbc6FR9dYyBw2Elja9pFBYzh6ZZqMuH47g12PHs95GakZ31SvqOmWG91KMKBOFDEnwrMd4Vwfn94wuMACf8y8oinO+Irvu2/FYyJ5+KIEjJwkDAEcSE4SJWCoFqcQaiSJizgJh85TIytJq39PJtHc3eax7+/uTcAqBnS+g9iGcsWSelzMJhPfUPch37jWPurDibwR6ui4S8+zpwB7LIGrzarcuqUXZmAaWrxNhCIasmcsmBbfq2YYHuV0DMRKRhhN+urRxkk8luMOQmUR7isb3YZ2b18HZGkNEEec=
- on:
- tags: true
- condition: "$TOXENV == py27"
- repo: Yelp/docker-custodian
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index a3ca519..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,9 +0,0 @@
-FROM alpine:3.2
-MAINTAINER Kyle Anderson
-
-RUN apk add -U python py-pip
-ADD requirements.txt /code/requirements.txt
-RUN pip install -r /code/requirements.txt
-ADD docker_custodian/ /code/docker_custodian/
-ADD setup.py /code/
-RUN pip install --no-deps -e /code
diff --git a/LICENSE b/LICENSE
index d645695..fb2b81f 100644
--- a/LICENSE
+++ b/LICENSE
@@ -187,7 +187,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
- Copyright [yyyy] [name of copyright owner]
+ Copyright 2020 Robert Kaussow
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 210d541..0000000
--- a/Makefile
+++ /dev/null
@@ -1,26 +0,0 @@
-.PHONY: all clean tag test
-
-PACKAGE_VERSION = $(shell python setup.py --version)
-
-DOCKER_REPO ?= ${USER}
-BUILD_TAG ?= ${PACKAGE_VERSION}
-
-all: test
-
-clean:
- git clean -fdx -- debian
- rm -f ./dist
- find . -iname '*.pyc' -delete
-
-tag:
- git tag v${PACKAGE_VERSION}
-
-test:
- tox
-
-tests: test
-
-
-.PHONY: build
-build:
- docker build -t ${DOCKER_REPO}/docker-custodian:${BUILD_TAG} .
diff --git a/Pipfile b/Pipfile
new file mode 100644
index 0000000..b206a8d
--- /dev/null
+++ b/Pipfile
@@ -0,0 +1,48 @@
+[[source]]
+name = "pypi"
+url = "https://pypi.org/simple"
+verify_ssl = true
+
+[dev-packages]
+pipenv-setup = "*"
+pydocstyle = "<4.0.0"
+flake8 = "*"
+flake8-colors = "*"
+flake8-blind-except = "*"
+flake8-builtins = "*"
+flake8-docstrings = "<=3.0.0"
+flake8-isort = "*"
+flake8-logging-format = "*"
+flake8-polyfill = "*"
+flake8-quotes = "*"
+pep8-naming = "*"
+pytest = "*"
+pytest-mock = "*"
+pytest-cov = "*"
+bandit = "*"
+docker-tidy = {editable = true,path = "."}
+
+[packages]
+importlib-metadata = {version = "*",markers = "python_version<'3.8'"}
+certifi = "*"
+chardet = "*"
+docker = "*"
+docker-pycreds = "*"
+idna = "*"
+ipaddress = "*"
+python-dateutil = "*"
+pytimeparse = "*"
+requests = "*"
+appdirs = "*"
+colorama = "*"
+anyconfig = "*"
+pathspec = "*"
+python-json-logger = "*"
+jsonschema = "*"
+environs = "*"
+nested-lookup = "*"
+"ruamel.yaml" = "*"
+websocket-client = "*"
+
+[requires]
+python_version = "3.7"
diff --git a/Pipfile.lock b/Pipfile.lock
new file mode 100644
index 0000000..c5bbfa6
--- /dev/null
+++ b/Pipfile.lock
@@ -0,0 +1,967 @@
+{
+ "_meta": {
+ "hash": {
+ "sha256": "5435cb449b46e93e063eb55b6d7bd5d990e1c552d7648a35b4a5eef846914075"
+ },
+ "pipfile-spec": 6,
+ "requires": {
+ "python_version": "3.7"
+ },
+ "sources": [
+ {
+ "name": "pypi",
+ "url": "https://pypi.org/simple",
+ "verify_ssl": true
+ }
+ ]
+ },
+ "default": {
+ "anyconfig": {
+ "hashes": [
+ "sha256:4e1674d184e5d9e56aad5321ee65612abaa7a05a03081ccf2ee452b2d557aeed"
+ ],
+ "index": "pypi",
+ "version": "==0.9.10"
+ },
+ "appdirs": {
+ "hashes": [
+ "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
+ "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
+ ],
+ "index": "pypi",
+ "version": "==1.4.3"
+ },
+ "attrs": {
+ "hashes": [
+ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
+ "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
+ ],
+ "version": "==19.3.0"
+ },
+ "certifi": {
+ "hashes": [
+ "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
+ "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
+ ],
+ "index": "pypi",
+ "version": "==2019.11.28"
+ },
+ "chardet": {
+ "hashes": [
+ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
+ "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+ ],
+ "index": "pypi",
+ "version": "==3.0.4"
+ },
+ "colorama": {
+ "hashes": [
+ "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff",
+ "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"
+ ],
+ "index": "pypi",
+ "version": "==0.4.3"
+ },
+ "docker": {
+ "hashes": [
+ "sha256:1c2ddb7a047b2599d1faec00889561316c674f7099427b9c51e8cb804114b553",
+ "sha256:ddae66620ab5f4bce769f64bcd7934f880c8abe6aa50986298db56735d0f722e"
+ ],
+ "index": "pypi",
+ "version": "==4.2.0"
+ },
+ "docker-pycreds": {
+ "hashes": [
+ "sha256:6ce3270bcaf404cc4c3e27e4b6c70d3521deae82fb508767870fdbf772d584d4",
+ "sha256:7266112468627868005106ec19cd0d722702d2b7d5912a28e19b826c3d37af49"
+ ],
+ "index": "pypi",
+ "version": "==0.4.0"
+ },
+ "environs": {
+ "hashes": [
+ "sha256:2291ce502c9e61b8e208c8c9be4ac474e0f523c4dc23e0beb23118086e43b324",
+ "sha256:44700c562fb6f783640f90c2225d9a80d85d24833b4dd02d20b8ff1c83901e47"
+ ],
+ "index": "pypi",
+ "version": "==7.2.0"
+ },
+ "idna": {
+ "hashes": [
+ "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",
+ "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"
+ ],
+ "index": "pypi",
+ "version": "==2.9"
+ },
+ "importlib-metadata": {
+ "hashes": [
+ "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302",
+ "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b"
+ ],
+ "index": "pypi",
+ "markers": "python_version < '3.8'",
+ "version": "==1.5.0"
+ },
+ "ipaddress": {
+ "hashes": [
+ "sha256:6e0f4a39e66cb5bb9a137b00276a2eff74f93b71dcbdad6f10ff7df9d3557fcc",
+ "sha256:b7f8e0369580bb4a24d5ba1d7cc29660a4a6987763faf1d8a8046830e020e7e2"
+ ],
+ "index": "pypi",
+ "version": "==1.0.23"
+ },
+ "jsonschema": {
+ "hashes": [
+ "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163",
+ "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a"
+ ],
+ "index": "pypi",
+ "version": "==3.2.0"
+ },
+ "marshmallow": {
+ "hashes": [
+ "sha256:3a94945a7461f2ab4df9576e51c97d66bee2c86155d3d3933fab752b31effab8",
+ "sha256:4b95c7735f93eb781dfdc4dded028108998cad759dda8dd9d4b5b4ac574cbf13"
+ ],
+ "version": "==3.5.0"
+ },
+ "nested-lookup": {
+ "hashes": [
+ "sha256:23789e328bd1d0b3f9db93cf51b7103a978dd0d8a834770d2c19b365e934ab96"
+ ],
+ "index": "pypi",
+ "version": "==0.2.21"
+ },
+ "pathspec": {
+ "hashes": [
+ "sha256:163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424",
+ "sha256:562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96"
+ ],
+ "index": "pypi",
+ "version": "==0.7.0"
+ },
+ "pyrsistent": {
+ "hashes": [
+ "sha256:cdc7b5e3ed77bed61270a47d35434a30617b9becdf2478af76ad2c6ade307280"
+ ],
+ "version": "==0.15.7"
+ },
+ "python-dateutil": {
+ "hashes": [
+ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
+ "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
+ ],
+ "index": "pypi",
+ "version": "==2.8.1"
+ },
+ "python-dotenv": {
+ "hashes": [
+ "sha256:81822227f771e0cab235a2939f0f265954ac4763cafd806d845801c863bf372f",
+ "sha256:92b3123fb2d58a284f76cc92bfe4ee6c502c32ded73e8b051c4f6afc8b6751ed"
+ ],
+ "version": "==0.12.0"
+ },
+ "python-json-logger": {
+ "hashes": [
+ "sha256:b7a31162f2a01965a5efb94453ce69230ed208468b0bbc7fdfc56e6d8df2e281"
+ ],
+ "index": "pypi",
+ "version": "==0.1.11"
+ },
+ "pytimeparse": {
+ "hashes": [
+ "sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd",
+ "sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a"
+ ],
+ "index": "pypi",
+ "version": "==1.1.8"
+ },
+ "requests": {
+ "hashes": [
+ "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
+ "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
+ ],
+ "index": "pypi",
+ "version": "==2.23.0"
+ },
+ "ruamel.yaml": {
+ "hashes": [
+ "sha256:0962fd7999e064c4865f96fb1e23079075f4a2a14849bcdc5cdba53a24f9759b",
+ "sha256:099c644a778bf72ffa00524f78dd0b6476bca94a1da344130f4bf3381ce5b954"
+ ],
+ "index": "pypi",
+ "version": "==0.16.10"
+ },
+ "ruamel.yaml.clib": {
+ "hashes": [
+ "sha256:1e77424825caba5553bbade750cec2277ef130647d685c2b38f68bc03453bac6",
+ "sha256:392b7c371312abf27fb549ec2d5e0092f7ef6e6c9f767bfb13e83cb903aca0fd",
+ "sha256:4d55386129291b96483edcb93b381470f7cd69f97585829b048a3d758d31210a",
+ "sha256:550168c02d8de52ee58c3d8a8193d5a8a9491a5e7b2462d27ac5bf63717574c9",
+ "sha256:57933a6986a3036257ad7bf283529e7c19c2810ff24c86f4a0cfeb49d2099919",
+ "sha256:615b0396a7fad02d1f9a0dcf9f01202bf9caefee6265198f252c865f4227fcc6",
+ "sha256:77556a7aa190be9a2bd83b7ee075d3df5f3c5016d395613671487e79b082d784",
+ "sha256:7aee724e1ff424757b5bd8f6c5bbdb033a570b2b4683b17ace4dbe61a99a657b",
+ "sha256:8073c8b92b06b572e4057b583c3d01674ceaf32167801fe545a087d7a1e8bf52",
+ "sha256:9c6d040d0396c28d3eaaa6cb20152cb3b2f15adf35a0304f4f40a3cf9f1d2448",
+ "sha256:a0ff786d2a7dbe55f9544b3f6ebbcc495d7e730df92a08434604f6f470b899c5",
+ "sha256:b1b7fcee6aedcdc7e62c3a73f238b3d080c7ba6650cd808bce8d7761ec484070",
+ "sha256:b66832ea8077d9b3f6e311c4a53d06273db5dc2db6e8a908550f3c14d67e718c",
+ "sha256:be018933c2f4ee7de55e7bd7d0d801b3dfb09d21dad0cce8a97995fd3e44be30",
+ "sha256:d0d3ac228c9bbab08134b4004d748cf9f8743504875b3603b3afbb97e3472947",
+ "sha256:d10e9dd744cf85c219bf747c75194b624cc7a94f0c80ead624b06bfa9f61d3bc",
+ "sha256:ea4362548ee0cbc266949d8a441238d9ad3600ca9910c3fe4e82ee3a50706973",
+ "sha256:ed5b3698a2bb241b7f5cbbe277eaa7fe48b07a58784fba4f75224fd066d253ad",
+ "sha256:f9dcc1ae73f36e8059589b601e8e4776b9976effd76c21ad6a855a74318efd6e"
+ ],
+ "markers": "platform_python_implementation == 'CPython' and python_version < '3.9'",
+ "version": "==0.2.0"
+ },
+ "six": {
+ "hashes": [
+ "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
+ "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
+ ],
+ "version": "==1.14.0"
+ },
+ "urllib3": {
+ "hashes": [
+ "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
+ "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
+ ],
+ "version": "==1.25.8"
+ },
+ "websocket-client": {
+ "hashes": [
+ "sha256:0fc45c961324d79c781bab301359d5a1b00b13ad1b10415a4780229ef71a5549",
+ "sha256:d735b91d6d1692a6a181f2a8c9e0238e5f6373356f561bb9dc4c7af36f452010"
+ ],
+ "index": "pypi",
+ "version": "==0.57.0"
+ },
+ "zipp": {
+ "hashes": [
+ "sha256:12248a63bbdf7548f89cb4c7cda4681e537031eda29c02ea29674bc6854460c2",
+ "sha256:7c0f8e91abc0dc07a5068f315c52cb30c66bfbc581e5b50704c8a2f6ebae794a"
+ ],
+ "version": "==3.0.0"
+ }
+ },
+ "develop": {
+ "anyconfig": {
+ "hashes": [
+ "sha256:4e1674d184e5d9e56aad5321ee65612abaa7a05a03081ccf2ee452b2d557aeed"
+ ],
+ "index": "pypi",
+ "version": "==0.9.10"
+ },
+ "appdirs": {
+ "hashes": [
+ "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
+ "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
+ ],
+ "index": "pypi",
+ "version": "==1.4.3"
+ },
+ "attrs": {
+ "hashes": [
+ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
+ "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
+ ],
+ "version": "==19.3.0"
+ },
+ "bandit": {
+ "hashes": [
+ "sha256:336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952",
+ "sha256:41e75315853507aa145d62a78a2a6c5e3240fe14ee7c601459d0df9418196065"
+ ],
+ "index": "pypi",
+ "version": "==1.6.2"
+ },
+ "black": {
+ "hashes": [
+ "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b",
+ "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
+ ],
+ "markers": "python_version >= '3.6'",
+ "version": "==19.10b0"
+ },
+ "cached-property": {
+ "hashes": [
+ "sha256:3a026f1a54135677e7da5ce819b0c690f156f37976f3e30c5430740725203d7f",
+ "sha256:9217a59f14a5682da7c4b8829deadbfc194ac22e9908ccf7c8820234e80a1504"
+ ],
+ "version": "==1.5.1"
+ },
+ "cerberus": {
+ "hashes": [
+ "sha256:302e6694f206dd85cb63f13fd5025b31ab6d38c99c50c6d769f8fa0b0f299589"
+ ],
+ "version": "==1.3.2"
+ },
+ "certifi": {
+ "hashes": [
+ "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
+ "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
+ ],
+ "index": "pypi",
+ "version": "==2019.11.28"
+ },
+ "chardet": {
+ "hashes": [
+ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
+ "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+ ],
+ "index": "pypi",
+ "version": "==3.0.4"
+ },
+ "click": {
+ "hashes": [
+ "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
+ "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
+ ],
+ "version": "==7.0"
+ },
+ "colorama": {
+ "hashes": [
+ "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff",
+ "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"
+ ],
+ "index": "pypi",
+ "version": "==0.4.3"
+ },
+ "coverage": {
+ "hashes": [
+ "sha256:15cf13a6896048d6d947bf7d222f36e4809ab926894beb748fc9caa14605d9c3",
+ "sha256:1daa3eceed220f9fdb80d5ff950dd95112cd27f70d004c7918ca6dfc6c47054c",
+ "sha256:1e44a022500d944d42f94df76727ba3fc0a5c0b672c358b61067abb88caee7a0",
+ "sha256:25dbf1110d70bab68a74b4b9d74f30e99b177cde3388e07cc7272f2168bd1477",
+ "sha256:3230d1003eec018ad4a472d254991e34241e0bbd513e97a29727c7c2f637bd2a",
+ "sha256:3dbb72eaeea5763676a1a1efd9b427a048c97c39ed92e13336e726117d0b72bf",
+ "sha256:5012d3b8d5a500834783689a5d2292fe06ec75dc86ee1ccdad04b6f5bf231691",
+ "sha256:51bc7710b13a2ae0c726f69756cf7ffd4362f4ac36546e243136187cfcc8aa73",
+ "sha256:527b4f316e6bf7755082a783726da20671a0cc388b786a64417780b90565b987",
+ "sha256:722e4557c8039aad9592c6a4213db75da08c2cd9945320220634f637251c3894",
+ "sha256:76e2057e8ffba5472fd28a3a010431fd9e928885ff480cb278877c6e9943cc2e",
+ "sha256:77afca04240c40450c331fa796b3eab6f1e15c5ecf8bf2b8bee9706cd5452fef",
+ "sha256:7afad9835e7a651d3551eab18cbc0fdb888f0a6136169fbef0662d9cdc9987cf",
+ "sha256:9bea19ac2f08672636350f203db89382121c9c2ade85d945953ef3c8cf9d2a68",
+ "sha256:a8b8ac7876bc3598e43e2603f772d2353d9931709345ad6c1149009fd1bc81b8",
+ "sha256:b0840b45187699affd4c6588286d429cd79a99d509fe3de0f209594669bb0954",
+ "sha256:b26aaf69713e5674efbde4d728fb7124e429c9466aeaf5f4a7e9e699b12c9fe2",
+ "sha256:b63dd43f455ba878e5e9f80ba4f748c0a2156dde6e0e6e690310e24d6e8caf40",
+ "sha256:be18f4ae5a9e46edae3f329de2191747966a34a3d93046dbdf897319923923bc",
+ "sha256:c312e57847db2526bc92b9bfa78266bfbaabac3fdcd751df4d062cd4c23e46dc",
+ "sha256:c60097190fe9dc2b329a0eb03393e2e0829156a589bd732e70794c0dd804258e",
+ "sha256:c62a2143e1313944bf4a5ab34fd3b4be15367a02e9478b0ce800cb510e3bbb9d",
+ "sha256:cc1109f54a14d940b8512ee9f1c3975c181bbb200306c6d8b87d93376538782f",
+ "sha256:cd60f507c125ac0ad83f05803063bed27e50fa903b9c2cfee3f8a6867ca600fc",
+ "sha256:d513cc3db248e566e07a0da99c230aca3556d9b09ed02f420664e2da97eac301",
+ "sha256:d649dc0bcace6fcdb446ae02b98798a856593b19b637c1b9af8edadf2b150bea",
+ "sha256:d7008a6796095a79544f4da1ee49418901961c97ca9e9d44904205ff7d6aa8cb",
+ "sha256:da93027835164b8223e8e5af2cf902a4c80ed93cb0909417234f4a9df3bcd9af",
+ "sha256:e69215621707119c6baf99bda014a45b999d37602cb7043d943c76a59b05bf52",
+ "sha256:ea9525e0fef2de9208250d6c5aeeee0138921057cd67fcef90fbed49c4d62d37",
+ "sha256:fca1669d464f0c9831fd10be2eef6b86f5ebd76c724d1e0706ebdff86bb4adf0"
+ ],
+ "version": "==5.0.3"
+ },
+ "distlib": {
+ "hashes": [
+ "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21"
+ ],
+ "version": "==0.3.0"
+ },
+ "docker": {
+ "hashes": [
+ "sha256:1c2ddb7a047b2599d1faec00889561316c674f7099427b9c51e8cb804114b553",
+ "sha256:ddae66620ab5f4bce769f64bcd7934f880c8abe6aa50986298db56735d0f722e"
+ ],
+ "index": "pypi",
+ "version": "==4.2.0"
+ },
+ "docker-pycreds": {
+ "hashes": [
+ "sha256:6ce3270bcaf404cc4c3e27e4b6c70d3521deae82fb508767870fdbf772d584d4",
+ "sha256:7266112468627868005106ec19cd0d722702d2b7d5912a28e19b826c3d37af49"
+ ],
+ "index": "pypi",
+ "version": "==0.4.0"
+ },
+ "docker-tidy": {
+ "editable": true,
+ "path": "."
+ },
+ "entrypoints": {
+ "hashes": [
+ "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19",
+ "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"
+ ],
+ "version": "==0.3"
+ },
+ "environs": {
+ "hashes": [
+ "sha256:2291ce502c9e61b8e208c8c9be4ac474e0f523c4dc23e0beb23118086e43b324",
+ "sha256:44700c562fb6f783640f90c2225d9a80d85d24833b4dd02d20b8ff1c83901e47"
+ ],
+ "index": "pypi",
+ "version": "==7.2.0"
+ },
+ "first": {
+ "hashes": [
+ "sha256:8d8e46e115ea8ac652c76123c0865e3ff18372aef6f03c22809ceefcea9dec86",
+ "sha256:ff285b08c55f8c97ce4ea7012743af2495c9f1291785f163722bd36f6af6d3bf"
+ ],
+ "version": "==2.0.2"
+ },
+ "flake8": {
+ "hashes": [
+ "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb",
+ "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"
+ ],
+ "index": "pypi",
+ "version": "==3.7.9"
+ },
+ "flake8-blind-except": {
+ "hashes": [
+ "sha256:aca3356633825544cec51997260fe31a8f24a1a2795ce8e81696b9916745e599"
+ ],
+ "index": "pypi",
+ "version": "==0.1.1"
+ },
+ "flake8-builtins": {
+ "hashes": [
+ "sha256:29bc0f7e68af481d088f5c96f8aeb02520abdfc900500484e3af969f42a38a5f",
+ "sha256:c44415fb19162ef3737056e700d5b99d48c3612a533943b4e16419a5d3de3a64"
+ ],
+ "index": "pypi",
+ "version": "==1.4.2"
+ },
+ "flake8-colors": {
+ "hashes": [
+ "sha256:508fcf6efc15826f2146b42172ab41999555e07af43fcfb3e6a28ad596189560"
+ ],
+ "index": "pypi",
+ "version": "==0.1.6"
+ },
+ "flake8-docstrings": {
+ "hashes": [
+ "sha256:3d5a31c7ec6b7367ea6506a87ec293b94a0a46c0bce2bb4975b7f1d09b6f3717",
+ "sha256:a256ba91bc52307bef1de59e2a009c3cf61c3d0952dbe035d6ff7208940c2edc"
+ ],
+ "index": "pypi",
+ "version": "==1.5.0"
+ },
+ "flake8-isort": {
+ "hashes": [
+ "sha256:64454d1f154a303cfe23ee715aca37271d4f1d299b2f2663f45b73bff14e36a9",
+ "sha256:aa0c4d004e6be47e74f122f5b7f36554d0d78ad8bf99b497a460dedccaa7cce9"
+ ],
+ "index": "pypi",
+ "version": "==2.8.0"
+ },
+ "flake8-logging-format": {
+ "hashes": [
+ "sha256:ca5f2b7fc31c3474a0aa77d227e022890f641a025f0ba664418797d979a779f8"
+ ],
+ "index": "pypi",
+ "version": "==0.6.0"
+ },
+ "flake8-polyfill": {
+ "hashes": [
+ "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9",
+ "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"
+ ],
+ "index": "pypi",
+ "version": "==1.0.2"
+ },
+ "flake8-quotes": {
+ "hashes": [
+ "sha256:11a15d30c92ca5f04c2791bd7019cf62b6f9d3053eb050d02a135557eb118bfc"
+ ],
+ "index": "pypi",
+ "version": "==2.1.1"
+ },
+ "gitdb": {
+ "hashes": [
+ "sha256:284a6a4554f954d6e737cddcff946404393e030b76a282c6640df8efd6b3da5e",
+ "sha256:598e0096bb3175a0aab3a0b5aedaa18a9a25c6707e0eca0695ba1a0baf1b2150"
+ ],
+ "version": "==4.0.2"
+ },
+ "gitpython": {
+ "hashes": [
+ "sha256:43da89427bdf18bf07f1164c6d415750693b4d50e28fc9b68de706245147b9dd",
+ "sha256:e426c3b587bd58c482f0b7fe6145ff4ac7ae6c82673fc656f489719abca6f4cb"
+ ],
+ "version": "==3.1.0"
+ },
+ "idna": {
+ "hashes": [
+ "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",
+ "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"
+ ],
+ "index": "pypi",
+ "version": "==2.9"
+ },
+ "importlib-metadata": {
+ "hashes": [
+ "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302",
+ "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b"
+ ],
+ "index": "pypi",
+ "markers": "python_version < '3.8'",
+ "version": "==1.5.0"
+ },
+ "ipaddress": {
+ "hashes": [
+ "sha256:6e0f4a39e66cb5bb9a137b00276a2eff74f93b71dcbdad6f10ff7df9d3557fcc",
+ "sha256:b7f8e0369580bb4a24d5ba1d7cc29660a4a6987763faf1d8a8046830e020e7e2"
+ ],
+ "index": "pypi",
+ "version": "==1.0.23"
+ },
+ "isort": {
+ "extras": [
+ "pyproject"
+ ],
+ "hashes": [
+ "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1",
+ "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"
+ ],
+ "version": "==4.3.21"
+ },
+ "jsonschema": {
+ "hashes": [
+ "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163",
+ "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a"
+ ],
+ "index": "pypi",
+ "version": "==3.2.0"
+ },
+ "marshmallow": {
+ "hashes": [
+ "sha256:3a94945a7461f2ab4df9576e51c97d66bee2c86155d3d3933fab752b31effab8",
+ "sha256:4b95c7735f93eb781dfdc4dded028108998cad759dda8dd9d4b5b4ac574cbf13"
+ ],
+ "version": "==3.5.0"
+ },
+ "mccabe": {
+ "hashes": [
+ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
+ "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
+ ],
+ "version": "==0.6.1"
+ },
+ "more-itertools": {
+ "hashes": [
+ "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c",
+ "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"
+ ],
+ "version": "==8.2.0"
+ },
+ "nested-lookup": {
+ "hashes": [
+ "sha256:23789e328bd1d0b3f9db93cf51b7103a978dd0d8a834770d2c19b365e934ab96"
+ ],
+ "index": "pypi",
+ "version": "==0.2.21"
+ },
+ "orderedmultidict": {
+ "hashes": [
+ "sha256:04070bbb5e87291cc9bfa51df413677faf2141c73c61d2a5f7b26bea3cd882ad",
+ "sha256:43c839a17ee3cdd62234c47deca1a8508a3f2ca1d0678a3bf791c87cf84adbf3"
+ ],
+ "version": "==1.0.1"
+ },
+ "packaging": {
+ "hashes": [
+ "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47",
+ "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"
+ ],
+ "version": "==19.2"
+ },
+ "pathspec": {
+ "hashes": [
+ "sha256:163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424",
+ "sha256:562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96"
+ ],
+ "index": "pypi",
+ "version": "==0.7.0"
+ },
+ "pbr": {
+ "hashes": [
+ "sha256:139d2625547dbfa5fb0b81daebb39601c478c21956dc57e2e07b74450a8c506b",
+ "sha256:61aa52a0f18b71c5cc58232d2cf8f8d09cd67fcad60b742a60124cb8d6951488"
+ ],
+ "version": "==5.4.4"
+ },
+ "pep517": {
+ "hashes": [
+ "sha256:5ce351f3be71d01bb094d63253854b6139931fcaba8e2f380c02102136c51e40",
+ "sha256:882e2eeeffe39ccd6be6122d98300df18d80950cb5f449766d64149c94c5614a"
+ ],
+ "version": "==0.8.1"
+ },
+ "pep8-naming": {
+ "hashes": [
+ "sha256:45f330db8fcfb0fba57458c77385e288e7a3be1d01e8ea4268263ef677ceea5f",
+ "sha256:a33d38177056321a167decd6ba70b890856ba5025f0a8eca6a3eda607da93caf"
+ ],
+ "index": "pypi",
+ "version": "==0.9.1"
+ },
+ "pip-shims": {
+ "hashes": [
+ "sha256:1cc3e2e4e5d5863edd4760d2032b180a6ef81719277fe95404df1bb0e58b7261",
+ "sha256:b5bb01c4394a2e0260bddb4cfdc7e6fdd9d6e61c8febd18c3594e2ea2596c190"
+ ],
+ "version": "==0.5.0"
+ },
+ "pipenv-setup": {
+ "hashes": [
+ "sha256:18ce5474261bab22b9a3cd919d70909b578b57438d452ebb88dbe22ca70f2ef2",
+ "sha256:5b69f8a91dd922806577d4e0c84acda1ce274657aab800749f088b46fcfe76cb"
+ ],
+ "index": "pypi",
+ "version": "==3.0.1"
+ },
+ "pipfile": {
+ "hashes": [
+ "sha256:f7d9f15de8b660986557eb3cc5391aa1a16207ac41bc378d03f414762d36c984"
+ ],
+ "version": "==0.0.2"
+ },
+ "plette": {
+ "extras": [
+ "validation"
+ ],
+ "hashes": [
+ "sha256:46402c03e36d6eadddad2a5125990e322dd74f98160c8f2dcd832b2291858a26",
+ "sha256:d6c9b96981b347bddd333910b753b6091a2c1eb2ef85bb373b4a67c9d91dca16"
+ ],
+ "version": "==0.2.3"
+ },
+ "pluggy": {
+ "hashes": [
+ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
+ "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
+ ],
+ "version": "==0.13.1"
+ },
+ "py": {
+ "hashes": [
+ "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa",
+ "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"
+ ],
+ "version": "==1.8.1"
+ },
+ "pycodestyle": {
+ "hashes": [
+ "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
+ "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
+ ],
+ "version": "==2.5.0"
+ },
+ "pydocstyle": {
+ "hashes": [
+ "sha256:2258f9b0df68b97bf3a6c29003edc5238ff8879f1efb6f1999988d934e432bd8",
+ "sha256:5741c85e408f9e0ddf873611085e819b809fca90b619f5fd7f34bd4959da3dd4",
+ "sha256:ed79d4ec5e92655eccc21eb0c6cf512e69512b4a97d215ace46d17e4990f2039"
+ ],
+ "index": "pypi",
+ "version": "==3.0.0"
+ },
+ "pyflakes": {
+ "hashes": [
+ "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
+ "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"
+ ],
+ "version": "==2.1.1"
+ },
+ "pyparsing": {
+ "hashes": [
+ "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f",
+ "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"
+ ],
+ "version": "==2.4.6"
+ },
+ "pyrsistent": {
+ "hashes": [
+ "sha256:cdc7b5e3ed77bed61270a47d35434a30617b9becdf2478af76ad2c6ade307280"
+ ],
+ "version": "==0.15.7"
+ },
+ "pytest": {
+ "hashes": [
+ "sha256:0d5fe9189a148acc3c3eb2ac8e1ac0742cb7618c084f3d228baaec0c254b318d",
+ "sha256:ff615c761e25eb25df19edddc0b970302d2a9091fbce0e7213298d85fb61fef6"
+ ],
+ "index": "pypi",
+ "version": "==5.3.5"
+ },
+ "pytest-cov": {
+ "hashes": [
+ "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b",
+ "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"
+ ],
+ "index": "pypi",
+ "version": "==2.8.1"
+ },
+ "pytest-mock": {
+ "hashes": [
+ "sha256:b35eb281e93aafed138db25c8772b95d3756108b601947f89af503f8c629413f",
+ "sha256:cb67402d87d5f53c579263d37971a164743dc33c159dfb4fb4a86f37c5552307"
+ ],
+ "index": "pypi",
+ "version": "==2.0.0"
+ },
+ "python-dateutil": {
+ "hashes": [
+ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
+ "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
+ ],
+ "index": "pypi",
+ "version": "==2.8.1"
+ },
+ "python-dotenv": {
+ "hashes": [
+ "sha256:81822227f771e0cab235a2939f0f265954ac4763cafd806d845801c863bf372f",
+ "sha256:92b3123fb2d58a284f76cc92bfe4ee6c502c32ded73e8b051c4f6afc8b6751ed"
+ ],
+ "version": "==0.12.0"
+ },
+ "python-json-logger": {
+ "hashes": [
+ "sha256:b7a31162f2a01965a5efb94453ce69230ed208468b0bbc7fdfc56e6d8df2e281"
+ ],
+ "index": "pypi",
+ "version": "==0.1.11"
+ },
+ "pytimeparse": {
+ "hashes": [
+ "sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd",
+ "sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a"
+ ],
+ "index": "pypi",
+ "version": "==1.1.8"
+ },
+ "pyyaml": {
+ "hashes": [
+ "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6",
+ "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf",
+ "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5",
+ "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e",
+ "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811",
+ "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e",
+ "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d",
+ "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20",
+ "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689",
+ "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994",
+ "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615"
+ ],
+ "version": "==5.3"
+ },
+ "regex": {
+ "hashes": [
+ "sha256:01b2d70cbaed11f72e57c1cfbaca71b02e3b98f739ce33f5f26f71859ad90431",
+ "sha256:046e83a8b160aff37e7034139a336b660b01dbfe58706f9d73f5cdc6b3460242",
+ "sha256:113309e819634f499d0006f6200700c8209a2a8bf6bd1bdc863a4d9d6776a5d1",
+ "sha256:200539b5124bc4721247a823a47d116a7a23e62cc6695744e3eb5454a8888e6d",
+ "sha256:25f4ce26b68425b80a233ce7b6218743c71cf7297dbe02feab1d711a2bf90045",
+ "sha256:269f0c5ff23639316b29f31df199f401e4cb87529eafff0c76828071635d417b",
+ "sha256:5de40649d4f88a15c9489ed37f88f053c15400257eeb18425ac7ed0a4e119400",
+ "sha256:7f78f963e62a61e294adb6ff5db901b629ef78cb2a1cfce3cf4eeba80c1c67aa",
+ "sha256:82469a0c1330a4beb3d42568f82dffa32226ced006e0b063719468dcd40ffdf0",
+ "sha256:8c2b7fa4d72781577ac45ab658da44c7518e6d96e2a50d04ecb0fd8f28b21d69",
+ "sha256:974535648f31c2b712a6b2595969f8ab370834080e00ab24e5dbb9d19b8bfb74",
+ "sha256:99272d6b6a68c7ae4391908fc15f6b8c9a6c345a46b632d7fdb7ef6c883a2bbb",
+ "sha256:9b64a4cc825ec4df262050c17e18f60252cdd94742b4ba1286bcfe481f1c0f26",
+ "sha256:9e9624440d754733eddbcd4614378c18713d2d9d0dc647cf9c72f64e39671be5",
+ "sha256:9ff16d994309b26a1cdf666a6309c1ef51ad4f72f99d3392bcd7b7139577a1f2",
+ "sha256:b33ebcd0222c1d77e61dbcd04a9fd139359bded86803063d3d2d197b796c63ce",
+ "sha256:bba52d72e16a554d1894a0cc74041da50eea99a8483e591a9edf1025a66843ab",
+ "sha256:bed7986547ce54d230fd8721aba6fd19459cdc6d315497b98686d0416efaff4e",
+ "sha256:c7f58a0e0e13fb44623b65b01052dae8e820ed9b8b654bb6296bc9c41f571b70",
+ "sha256:d58a4fa7910102500722defbde6e2816b0372a4fcc85c7e239323767c74f5cbc",
+ "sha256:f1ac2dc65105a53c1c2d72b1d3e98c2464a133b4067a51a3d2477b28449709a0"
+ ],
+ "version": "==2020.2.20"
+ },
+ "requests": {
+ "hashes": [
+ "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
+ "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
+ ],
+ "index": "pypi",
+ "version": "==2.23.0"
+ },
+ "requirementslib": {
+ "hashes": [
+ "sha256:50731ac1052473e4c7df59a44a1f3aa20f32e687110bc05d73c3b4109eebc23d",
+ "sha256:8b594ab8b6280ee97cffd68fc766333345de150124d5b76061dd575c3a21fe5a"
+ ],
+ "version": "==1.5.3"
+ },
+ "ruamel.yaml": {
+ "hashes": [
+ "sha256:0962fd7999e064c4865f96fb1e23079075f4a2a14849bcdc5cdba53a24f9759b",
+ "sha256:099c644a778bf72ffa00524f78dd0b6476bca94a1da344130f4bf3381ce5b954"
+ ],
+ "index": "pypi",
+ "version": "==0.16.10"
+ },
+ "ruamel.yaml.clib": {
+ "hashes": [
+ "sha256:1e77424825caba5553bbade750cec2277ef130647d685c2b38f68bc03453bac6",
+ "sha256:392b7c371312abf27fb549ec2d5e0092f7ef6e6c9f767bfb13e83cb903aca0fd",
+ "sha256:4d55386129291b96483edcb93b381470f7cd69f97585829b048a3d758d31210a",
+ "sha256:550168c02d8de52ee58c3d8a8193d5a8a9491a5e7b2462d27ac5bf63717574c9",
+ "sha256:57933a6986a3036257ad7bf283529e7c19c2810ff24c86f4a0cfeb49d2099919",
+ "sha256:615b0396a7fad02d1f9a0dcf9f01202bf9caefee6265198f252c865f4227fcc6",
+ "sha256:77556a7aa190be9a2bd83b7ee075d3df5f3c5016d395613671487e79b082d784",
+ "sha256:7aee724e1ff424757b5bd8f6c5bbdb033a570b2b4683b17ace4dbe61a99a657b",
+ "sha256:8073c8b92b06b572e4057b583c3d01674ceaf32167801fe545a087d7a1e8bf52",
+ "sha256:9c6d040d0396c28d3eaaa6cb20152cb3b2f15adf35a0304f4f40a3cf9f1d2448",
+ "sha256:a0ff786d2a7dbe55f9544b3f6ebbcc495d7e730df92a08434604f6f470b899c5",
+ "sha256:b1b7fcee6aedcdc7e62c3a73f238b3d080c7ba6650cd808bce8d7761ec484070",
+ "sha256:b66832ea8077d9b3f6e311c4a53d06273db5dc2db6e8a908550f3c14d67e718c",
+ "sha256:be018933c2f4ee7de55e7bd7d0d801b3dfb09d21dad0cce8a97995fd3e44be30",
+ "sha256:d0d3ac228c9bbab08134b4004d748cf9f8743504875b3603b3afbb97e3472947",
+ "sha256:d10e9dd744cf85c219bf747c75194b624cc7a94f0c80ead624b06bfa9f61d3bc",
+ "sha256:ea4362548ee0cbc266949d8a441238d9ad3600ca9910c3fe4e82ee3a50706973",
+ "sha256:ed5b3698a2bb241b7f5cbbe277eaa7fe48b07a58784fba4f75224fd066d253ad",
+ "sha256:f9dcc1ae73f36e8059589b601e8e4776b9976effd76c21ad6a855a74318efd6e"
+ ],
+ "markers": "platform_python_implementation == 'CPython' and python_version < '3.9'",
+ "version": "==0.2.0"
+ },
+ "six": {
+ "hashes": [
+ "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
+ "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
+ ],
+ "version": "==1.14.0"
+ },
+ "smmap": {
+ "hashes": [
+ "sha256:171484fe62793e3626c8b05dd752eb2ca01854b0c55a1efc0dc4210fccb65446",
+ "sha256:5fead614cf2de17ee0707a8c6a5f2aa5a2fc6c698c70993ba42f515485ffda78"
+ ],
+ "version": "==3.0.1"
+ },
+ "snowballstemmer": {
+ "hashes": [
+ "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0",
+ "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"
+ ],
+ "version": "==2.0.0"
+ },
+ "stevedore": {
+ "hashes": [
+ "sha256:18afaf1d623af5950cc0f7e75e70f917784c73b652a34a12d90b309451b5500b",
+ "sha256:a4e7dc759fb0f2e3e2f7d8ffe2358c19d45b9b8297f393ef1256858d82f69c9b"
+ ],
+ "version": "==1.32.0"
+ },
+ "testfixtures": {
+ "hashes": [
+ "sha256:799144b3cbef7b072452d9c36cbd024fef415ab42924b96aad49dfd9c763de66",
+ "sha256:cdfc3d73cb6d3d4dc3c67af84d912e86bf117d30ae25f02fe823382ef99383d2"
+ ],
+ "version": "==6.14.0"
+ },
+ "toml": {
+ "hashes": [
+ "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
+ "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
+ ],
+ "version": "==0.10.0"
+ },
+ "tomlkit": {
+ "hashes": [
+ "sha256:4e1bd6c9197d984528f9ff0cc9db667c317d8881288db50db20eeeb0f6b0380b",
+ "sha256:f044eda25647882e5ef22b43a1688fb6ab12af2fc50e8456cdfc751c873101cf"
+ ],
+ "version": "==0.5.11"
+ },
+ "typed-ast": {
+ "hashes": [
+ "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
+ "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
+ "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
+ "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
+ "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
+ "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
+ "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
+ "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
+ "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
+ "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
+ "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
+ "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
+ "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
+ "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
+ "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
+ "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
+ "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
+ "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
+ "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
+ "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
+ "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
+ ],
+ "version": "==1.4.1"
+ },
+ "typing": {
+ "hashes": [
+ "sha256:91dfe6f3f706ee8cc32d38edbbf304e9b7583fb37108fef38229617f8b3eba23",
+ "sha256:c8cabb5ab8945cd2f54917be357d134db9cc1eb039e59d1606dc1e60cb1d9d36",
+ "sha256:f38d83c5a7a7086543a0f649564d661859c5146a85775ab90c0d2f93ffaa9714"
+ ],
+ "version": "==3.7.4.1"
+ },
+ "urllib3": {
+ "hashes": [
+ "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
+ "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
+ ],
+ "version": "==1.25.8"
+ },
+ "vistir": {
+ "hashes": [
+ "sha256:33f8e905d40a77276b3d5310c8b57c1479a4e46930042b4894fcf7ed60ad76c4",
+ "sha256:e47afdec8baf35032a8d17116765f751ecd2f2146d47e5af457c5de1fe5a334e"
+ ],
+ "version": "==0.5.0"
+ },
+ "wcwidth": {
+ "hashes": [
+ "sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603",
+ "sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8"
+ ],
+ "version": "==0.1.8"
+ },
+ "websocket-client": {
+ "hashes": [
+ "sha256:0fc45c961324d79c781bab301359d5a1b00b13ad1b10415a4780229ef71a5549",
+ "sha256:d735b91d6d1692a6a181f2a8c9e0238e5f6373356f561bb9dc4c7af36f452010"
+ ],
+ "index": "pypi",
+ "version": "==0.57.0"
+ },
+ "wheel": {
+ "hashes": [
+ "sha256:8788e9155fe14f54164c1b9eb0a319d98ef02c160725587ad60f14ddc57b6f96",
+ "sha256:df277cb51e61359aba502208d680f90c0493adec6f0e848af94948778aed386e"
+ ],
+ "version": "==0.34.2"
+ },
+ "zipp": {
+ "hashes": [
+ "sha256:12248a63bbdf7548f89cb4c7cda4681e537031eda29c02ea29674bc6854460c2",
+ "sha256:7c0f8e91abc0dc07a5068f315c52cb30c66bfbc581e5b50704c8a2f6ebae794a"
+ ],
+ "version": "==3.0.0"
+ }
+ }
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a88c1a9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,21 @@
+# docker-tidy
+
+[![Build Status](https://img.shields.io/drone/build/xoxys/docker-tidy?logo=drone)](https://cloud.drone.io/xoxys/docker-tidy)
+[![Docker Hub](https://img.shields.io/badge/docker-latest-blue.svg?logo=docker&logoColor=white)](https://hub.docker.com/r/xoxys/docker-tidy)
+[![Python Version](https://img.shields.io/pypi/pyversions/docker-tidy.svg)](https://pypi.org/project/docker-tidy/)
+[![PyPi Status](https://img.shields.io/pypi/status/docker-tidy.svg)](https://pypi.org/project/docker-tidy/)
+[![PyPi Release](https://img.shields.io/pypi/v/docker-tidy.svg)](https://pypi.org/project/docker-tidy/)
+[![Codecov](https://img.shields.io/codecov/c/github/xoxys/docker-tidy)](https://codecov.io/gh/xoxys/docker-tidy)
+[![License: MIT](https://img.shields.io/github/license/xoxys/docker-tidy)](LICENSE)
+
+This is a fork of [Yelp/docker-custodian)](https://github.com/Yelp/docker-custodian). Keep docker hosts tidy.
+
+You can find the full documentation at [https://docker-tidy.geekdocs.de](https://docker-tidy.geekdocs.de/).
+
+## License
+
+This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
+
+## Maintainers and Contributors
+
+[Robert Kaussow](https://github.com/xoxys)
diff --git a/README.rst b/README.rst
deleted file mode 100644
index 21e14e6..0000000
--- a/README.rst
+++ /dev/null
@@ -1,132 +0,0 @@
-Docker Custodian
-================
-
-.. image:: https://travis-ci.org/Yelp/docker-custodian.svg
- :target: https://travis-ci.org/Yelp/docker-custodian
-
-Keep docker hosts tidy.
-
-
-.. contents::
- :backlinks: none
-
-Install
--------
-
-There are three installation options
-
-Container
-~~~~~~~~~
-
-.. code::
-
- docker pull yelp/docker-custodian
- docker run -ti \
- -v /var/run/docker.sock:/var/run/docker.sock \
- yelp/docker-custodian dcgc --help
-
-Debian/Ubuntu package
-~~~~~~~~~~~~~~~~~~~~~
-
-First build the package (requires `dh-virtualenv`)
-
-.. code:: sh
-
- dpkg-buildpackage -us -uc
-
-Then install it
-
-.. code:: sh
-
- dpkg -i ../docker-custodian_*.deb
-
-
-Source
-~~~~~~
-
-.. code:: sh
-
- pip install git+https://github.com/Yelp/docker-custodian.git#egg=docker_custodian
-
-
-dcgc
-----
-
-Remove old docker containers and docker images.
-
-``dcgc`` will remove stopped containers and unused images that are older than
-"max age". Running containers, and images which are used by a container are
-never removed.
-
-Maximum age can be specificied with any format supported by
-`pytimeparse `_.
-
-Example:
-
-.. code:: sh
-
- dcgc --max-container-age 3days --max-image-age 30days
-
-
-Prevent images from being removed
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-``dcgc`` supports an image exclude list. If you have images that you'd like
-to keep around forever you can use the exclude list to prevent them from
-being removed.
-
-::
-
- --exclude-image
- Never remove images with this tag. May be specified more than once.
-
- --exclude-image-file
- Path to a file which contains a list of images to exclude, one
- image tag per line.
-
-You also can use basic pattern matching to exclude images with generic tags.
-
-.. code::
-
- user/repositoryA:*
- user/repositoryB:?.?
- user/repositoryC-*:tag
-
-
-Prevent containers and associated images from being removed
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-``dcgc`` also supports a container exclude list based on labels. If there are
-stopped containers that you'd like to keep, then you can check the labels to
-prevent them from being removed.
-
-::
-
- --exclude-container-label
- Never remove containers that have the label key=value. =value can be
- omitted and in that case only the key is checked. May be specified
- more than once.
-
-You also can use basic pattern matching to exclude generic labels.
-
-.. code::
-
- foo*
- com.docker.compose.project=test*
- com.docker*=*bar*
-
-
-dcstop
-------
-
-Stop containers that have been running for too long.
-
-``dcstop`` will ``docker stop`` containers where the container name starts
-with `--prefix` and it has been running for longer than `--max-run-time`.
-
-
-Example:
-
-.. code:: sh
-
- dcstop --max-run-time 2days --prefix "projectprefix_"
diff --git a/bin/docker-tidy b/bin/docker-tidy
new file mode 100755
index 0000000..10e597a
--- /dev/null
+++ b/bin/docker-tidy
@@ -0,0 +1,7 @@
+#!/usr/bin/env python
+
+import sys
+
+import dockertidy.__main__
+
+sys.exit(dockertidy.__main__.main())
diff --git a/debian/.gitignore b/debian/.gitignore
deleted file mode 100644
index 643487d..0000000
--- a/debian/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-*
-!.gitignore
-!changelog
-!compat
-!control
-!copyright
-!rules
-!docker-custodian.links
diff --git a/debian/changelog b/debian/changelog
deleted file mode 100644
index b1d43e3..0000000
--- a/debian/changelog
+++ /dev/null
@@ -1,99 +0,0 @@
-docker-custodian (0.7.3) lucid; urgency=medium
-
- * Fix handling containers with null labels
-
- -- Matthew Mead-Briggs Thu, 25 Apr 2019 03:43:55 -0700
-
-docker-custodian (0.7.2) lucid; urgency=medium
-
- * Fix debian links and release 0.7.2
-
- -- Kyle Anderson Wed, 21 Mar 2018 15:48:42 -0700
-
-docker-custodian (0.7.1) lucid; urgency=medium
-
- * Release 0.7.1
-
- -- Kyle Anderson Wed, 21 Mar 2018 15:26:16 -0700
-
-docker-custodian (0.7.0) lucid; urgency=low
-
- * Delete volumes along with containers
-
- -- Paul O'Connor Wed, 05 Oct 2016 00:58:10 -0700
-
-docker-custodian (0.6.1) lucid; urgency=low
-
- * New release for pypi
-
- -- kwa Wed, 31 Aug 2016 09:49:37 -0700
-
-docker-custodian (0.6.0) lucid; urgency=low
-
- * Remove python 2.6 support
- * Remove argparse
-
- -- Daniel Hoherd Fri, 24 Jun 2016 13:55:49 -0700
-
-docker-custodian (0.5.3) lucid; urgency=low
-
- * Update docker-py
-
- -- Alex Dudko Mon, 4 Apr 2016 09:44:26 -0800
-
-docker-custodian (0.5.2) lucid; urgency=low
-
- * Fixed bug where never started containers that are not old were getting removed
-
- -- Semir Patel Tue, 15 Dec 2015 09:44:26 -0800
-
-docker-custodian (0.5.0) lucid; urgency=low
-
- * Add option to exclude images from removal by dcgc
-
- -- Daniel Nephin Tue, 21 Jul 2015 11:14:38 -0700
-
-docker-custodian (0.4.0) lucid; urgency=low
-
- * Renamed to docker-custodian
- * Changed defaults of dcgc to not remove anything
-
- -- Daniel Nephin Mon, 29 Jun 2015 18:48:22 -0700
-
-docker-custodian (0.3.3) lucid; urgency=low
-
- * Bug fixes for removing images by Id and with multiple tags
-
- -- Daniel Nephin Thu, 04 Jun 2015 13:24:14 -0700
-
-docker-custodian (0.3.2) lucid; urgency=low
-
- * docker-custodian should now remove image names before trying to remove
- by id, so that images tagged with more than one name are removed
- correctly
-
- -- Daniel Nephin Tue, 02 Jun 2015 13:26:56 -0700
-
-docker-custodian (0.3.1) lucid; urgency=low
-
- * Fix broken commands
-
- -- Daniel Nephin Mon, 09 Mar 2015 17:58:03 -0700
-
-docker-custodian (0.3.0) lucid; urgency=low
-
- * Change age and time options to support pytimeparse formats
-
- -- Daniel Nephin Fri, 06 Mar 2015 13:30:36 -0800
-
-docker-custodian (0.2.0) lucid; urgency=low
-
- * Add docker-autostop
-
- -- Daniel Nephin Wed, 28 Jan 2015 15:37:40 -0800
-
-docker-custodian (0.1.0) lucid; urgency=low
-
- * Initial release
-
- -- Daniel Nephin Thu, 02 Oct 2014 11:13:43 -0700
diff --git a/debian/compat b/debian/compat
deleted file mode 100644
index 7f8f011..0000000
--- a/debian/compat
+++ /dev/null
@@ -1 +0,0 @@
-7
diff --git a/debian/control b/debian/control
deleted file mode 100644
index e59c022..0000000
--- a/debian/control
+++ /dev/null
@@ -1,9 +0,0 @@
-Source: docker-custodian
-Maintainer: Daniel Nephin
-Build-Depends:
- dh-virtualenv,
-
-Depends: python2.7
-Package: docker-custodian
-Architecture: any
-Description: Remove old Docker containers and images that are no longer in use
diff --git a/debian/docker-custodian.links b/debian/docker-custodian.links
deleted file mode 100644
index b488c46..0000000
--- a/debian/docker-custodian.links
+++ /dev/null
@@ -1,2 +0,0 @@
-opt/venvs/docker-custodian/bin/dcgc usr/bin/dcgc
-opt/venvs/docker-custodian/bin/dcstop usr/bin/dcstop
diff --git a/debian/rules b/debian/rules
deleted file mode 100755
index c78d1de..0000000
--- a/debian/rules
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/make -f
-# -*- makefile -*-
-
-export DH_OPTIONS
-
-%:
- dh $@ --with python-virtualenv
-
-override_dh_virtualenv:
- dh_virtualenv --python python2.7
-
-# do not call `make clean` as part of packaging
-override_dh_auto_clean:
- true
-
-# do not call `make` as part of packaging
-override_dh_auto_build:
- true
-
-override_dh_auto_test:
- true
diff --git a/docker_custodian/__about__.py b/docker_custodian/__about__.py
deleted file mode 100644
index 342443d..0000000
--- a/docker_custodian/__about__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-# -*- coding: utf8 -*-
-
-__version_info__ = (0, 7, 3)
-__version__ = '%d.%d.%d' % __version_info__
diff --git a/docker_custodian/__init__.py b/docker_custodian/__init__.py
deleted file mode 100644
index d2aca59..0000000
--- a/docker_custodian/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-# -*- coding: utf8 -*-
diff --git a/dockertidy/Cli.py b/dockertidy/Cli.py
new file mode 100644
index 0000000..9f87312
--- /dev/null
+++ b/dockertidy/Cli.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Entrypoint and CLI handler."""
+
+import argparse
+import logging
+import os
+import sys
+
+import dockertidy.Exception
+from dockertidy.Config import SingleConfig
+from dockertidy.Utils import SingleLog
+from dockertidy.Utils import timedelta_type
+from importlib_metadata import version, PackageNotFoundError
+
+
+class DockerTidy:
+
+ def __init__(self):
+ self.log = SingleLog()
+ self.logger = self.log.logger
+ self.args = self._cli_args()
+ self.config = self._get_config()
+
+ def _cli_args(self):
+ """
+ Use argparse for parsing CLI arguments.
+
+ :return: args objec
+ """
+ parser = argparse.ArgumentParser(
+ description="Generate documentation from annotated Ansible roles using templates")
+ parser.add_argument("-v", dest="logging.level", action="append_const", const=-1,
+ help="increase log level")
+ parser.add_argument("-q", dest="logging.level", action="append_const",
+ const=1, help="decrease log level")
+ parser.add_argument("--version", action="version",
+ version=version(__name__))
+
+ subparsers = parser.add_subparsers(help="sub-command help")
+
+ parser_gc = subparsers.add_parser(
+ "gc", help="Run docker garbage collector.")
+ parser_gc.add_argument(
+ "--max-container-age",
+ type=timedelta_type,
+ help="Maximum age for a container. Containers older than this age "
+ "will be removed. Age can be specified in any pytimeparse "
+ "supported format.")
+ parser_gc.add_argument(
+ "--max-image-age",
+ type=timedelta_type,
+ help="Maxium age for an image. Images older than this age will be "
+ "removed. Age can be specified in any pytimeparse supported "
+ "format.")
+ parser_gc.add_argument(
+ "--dangling-volumes",
+ action="store_true",
+ help="Dangling volumes will be removed.")
+ parser_gc.add_argument(
+ "--dry-run", action="store_true",
+ help="Only log actions, don't remove anything.")
+ parser_gc.add_argument(
+ "-t", "--timeout", type=int, default=60,
+ help="HTTP timeout in seconds for making docker API calls.")
+ parser_gc.add_argument(
+ "--exclude-image",
+ action="append",
+ help="Never remove images with this tag.")
+ parser_gc.add_argument(
+ "--exclude-image-file",
+ type=argparse.FileType("r"),
+ help="Path to a file which contains a list of images to exclude, one "
+ "image tag per line.")
+ parser_gc.add_argument(
+ "--exclude-container-label",
+ action="append", type=str, default=[],
+ help="Never remove containers with this label key or label key=value")
+
+ return parser.parse_args().__dict__
+
+ def _get_config(self):
+ try:
+ config = SingleConfig(args=self.args)
+ except dockertidy.Exception.ConfigError as e:
+ self.log.sysexit_with_message(e)
+
+ try:
+ self.log.set_level(config.config["logging"]["level"])
+ except ValueError as e:
+ self.log.sysexit_with_message(
+ "Can not set log level.\n{}".format(str(e)))
+
+ self.logger.info("Using config file {}".format(config.config_file))
+
+ return config
diff --git a/dockertidy/Config.py b/dockertidy/Config.py
new file mode 100644
index 0000000..770b450
--- /dev/null
+++ b/dockertidy/Config.py
@@ -0,0 +1,316 @@
+#!/usr/bin/env python3
+"""Global settings definition."""
+
+import logging
+import os
+import sys
+
+import anyconfig
+import environs
+import jsonschema.exceptions
+import ruamel.yaml
+from appdirs import AppDirs
+from jsonschema._utils import format_as_index
+from pkg_resources import resource_filename
+
+import dockertidy.Exception
+from dockertidy.Utils import Singleton
+
+config_dir = AppDirs("docker-tidy").user_config_dir
+default_config_file = os.path.join(config_dir, "config.yml")
+
+
+class Config():
+ """
+ Create an object with all necessary settings.
+
+ Settings are loade from multiple locations in defined order (last wins):
+ - default settings defined by `self._get_defaults()`
+ - yaml config file, defaults to OS specific user config dir (https://pypi.org/project/appdirs/)
+ - provides cli parameters
+ """
+
+ SETTINGS = {
+ "config_file": {
+ "default": "",
+ "env": "CONFIG_FILE",
+ "type": environs.Env().str
+ },
+ "role_dir": {
+ "default": "",
+ "env": "ROLE_DIR",
+ "type": environs.Env().str
+ },
+ "role_name": {
+ "default": "",
+ "env": "ROLE_NAME",
+ "type": environs.Env().str
+ },
+ "dry_run": {
+ "default": False,
+ "env": "DRY_RUN",
+ "file": True,
+ "type": environs.Env().bool
+ },
+ "logging.level": {
+ "default": "WARNING",
+ "env": "LOG_LEVEL",
+ "file": True,
+ "type": environs.Env().str
+ },
+ "logging.json": {
+ "default": False,
+ "env": "LOG_JSON",
+ "file": True,
+ "type": environs.Env().bool
+ },
+ "output_dir": {
+ "default": os.getcwd(),
+ "env": "OUTPUT_DIR",
+ "file": True,
+ "type": environs.Env().str
+ },
+ "template_dir": {
+ "default": os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates"),
+ "env": "TEMPLATE_DIR",
+ "file": True,
+ "type": environs.Env().str
+ },
+ "template": {
+ "default": "readme",
+ "env": "TEMPLATE",
+ "file": True,
+ "type": environs.Env().str
+ },
+ "force_overwrite": {
+ "default": False,
+ "env": "FORCE_OVERWRITE",
+ "file": True,
+ "type": environs.Env().bool
+ },
+ "custom_header": {
+ "default": "",
+ "env": "CUSTOM_HEADER",
+ "file": True,
+ "type": environs.Env().str
+ },
+ "exclude_files": {
+ "default": [],
+ "env": "EXCLUDE_FILES",
+ "file": True,
+ "type": environs.Env().list
+ },
+ }
+
+ ANNOTATIONS = {
+ "meta": {
+ "name": "meta",
+ "automatic": True,
+ "subtypes": []
+ },
+ "todo": {
+ "name": "todo",
+ "automatic": True,
+ "subtypes": []
+ },
+ "var": {
+ "name": "var",
+ "automatic": True,
+ "subtypes": [
+ "value",
+ "example",
+ "description"
+ ]
+ },
+ "example": {
+ "name": "example",
+ "automatic": True,
+ "subtypes": []
+ },
+ "tag": {
+ "name": "tag",
+ "automatic": True,
+ "subtypes": []
+ },
+ }
+
+ def __init__(self, args={}):
+ """
+ Initialize a new settings class.
+
+ :param args: An optional dict of options, arguments and commands from the CLI.
+ :param config_file: An optional path to a yaml config file.
+ :returns: None
+
+ """
+ self._args = args
+ self._schema = None
+ self.config_file = default_config_file
+ self.role_dir = os.getcwd()
+ self.config = None
+ self._set_config()
+ self.is_role = self._set_is_role() or False
+
+ def _get_args(self, args):
+ cleaned = dict(filter(lambda item: item[1] is not None, args.items()))
+
+ normalized = {}
+ for key, value in cleaned.items():
+ normalized = self._add_dict_branch(normalized, key.split("."), value)
+
+ # Override correct log level from argparse
+ levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
+ log_level = levels.index(self.SETTINGS["logging.level"]["default"])
+ if normalized.get("logging"):
+ for adjustment in normalized["logging"]["level"]:
+ log_level = min(len(levels) - 1, max(log_level + adjustment, 0))
+ normalized["logging"]["level"] = levels[log_level]
+
+ return normalized
+
+ def _get_defaults(self):
+ normalized = {}
+ for key, item in self.SETTINGS.items():
+ normalized = self._add_dict_branch(normalized, key.split("."), item["default"])
+
+ # compute role_name default
+ normalized["role_name"] = os.path.basename(self.role_dir)
+
+ self.schema = anyconfig.gen_schema(normalized)
+ return normalized
+
+ def _get_envs(self):
+ normalized = {}
+ for key, item in self.SETTINGS.items():
+ if item.get("env"):
+ prefix = "ANSIBLE_DOCTOR_"
+ envname = prefix + item["env"]
+ try:
+ value = item["type"](envname)
+ normalized = self._add_dict_branch(normalized, key.split("."), value)
+ except environs.EnvError as e:
+ if '"{}" not set'.format(envname) in str(e):
+ pass
+ else:
+ raise dockertidy.Exception.ConfigError("Unable to read environment variable", str(e))
+
+ return normalized
+
+ def _set_config(self):
+ args = self._get_args(self._args)
+ envs = self._get_envs()
+ defaults = self._get_defaults()
+
+ # preset config file path
+ if envs.get("config_file"):
+ self.config_file = self._normalize_path(envs.get("config_file"))
+ if envs.get("role_dir"):
+ self.role_dir = self._normalize_path(envs.get("role_dir"))
+
+ if args.get("config_file"):
+ self.config_file = self._normalize_path(args.get("config_file"))
+ if args.get("role_dir"):
+ self.role_dir = self._normalize_path(args.get("role_dir"))
+
+ source_files = []
+ source_files.append(self.config_file)
+ source_files.append(os.path.join(os.getcwd(), ".dockertidy"))
+ source_files.append(os.path.join(os.getcwd(), ".dockertidy.yml"))
+ source_files.append(os.path.join(os.getcwd(), ".dockertidy.yaml"))
+
+ for config in source_files:
+ if config and os.path.exists(config):
+ with open(config, "r", encoding="utf8") as stream:
+ s = stream.read()
+ try:
+ file_dict = ruamel.yaml.safe_load(s)
+ except (ruamel.yaml.composer.ComposerError, ruamel.yaml.scanner.ScannerError) as e:
+ message = "{} {}".format(e.context, e.problem)
+ raise dockertidy.Exception.ConfigError(
+ "Unable to read config file {}".format(config), message
+ )
+
+ if self._validate(file_dict):
+ anyconfig.merge(defaults, file_dict, ac_merge=anyconfig.MS_DICTS)
+ defaults["logging"]["level"] = defaults["logging"]["level"].upper()
+
+ if self._validate(envs):
+ anyconfig.merge(defaults, envs, ac_merge=anyconfig.MS_DICTS)
+
+ if self._validate(args):
+ anyconfig.merge(defaults, args, ac_merge=anyconfig.MS_DICTS)
+
+ fix_files = ["output_dir", "template_dir", "custom_header"]
+ for file in fix_files:
+ if defaults[file] and defaults[file] != "":
+ defaults[file] = self._normalize_path(defaults[file])
+
+ if "config_file" in defaults:
+ defaults.pop("config_file")
+ if "role_dir" in defaults:
+ defaults.pop("role_dir")
+
+ defaults["logging"]["level"] = defaults["logging"]["level"].upper()
+
+ self.config = defaults
+
+ def _normalize_path(self, path):
+ if not os.path.isabs(path):
+ base = os.path.join(os.getcwd(), path)
+ return os.path.abspath(os.path.expanduser(os.path.expandvars(base)))
+ else:
+ return path
+
+ def _set_is_role(self):
+ if os.path.isdir(os.path.join(self.role_dir, "tasks")):
+ return True
+
+ def _validate(self, config):
+ try:
+ anyconfig.validate(config, self.schema, ac_schema_safe=False)
+ except jsonschema.exceptions.ValidationError as e:
+ schema_error = "Failed validating '{validator}' in schema{schema}\n{message}".format(
+ validator=e.validator,
+ schema=format_as_index(list(e.relative_schema_path)[:-1]),
+ message=e.message
+ )
+ raise dockertidy.Exception.ConfigError("Configuration error", schema_error)
+
+ return True
+
+ def _add_dict_branch(self, tree, vector, value):
+ key = vector[0]
+ tree[key] = value \
+ if len(vector) == 1 \
+ else self._add_dict_branch(tree[key] if key in tree else {}, vector[1:], value)
+ return tree
+
+ def get_annotations_definition(self, automatic=True):
+ annotations = {}
+ if automatic:
+ for k, item in self.ANNOTATIONS.items():
+ if "automatic" in item.keys() and item["automatic"]:
+ annotations[k] = item
+ return annotations
+
+ def get_annotations_names(self, automatic=True):
+ annotations = []
+ if automatic:
+ for k, item in self.ANNOTATIONS.items():
+ if "automatic" in item.keys() and item["automatic"]:
+ annotations.append(k)
+ return annotations
+
+ def get_template(self):
+ """
+ Get the base dir for the template to use.
+
+ :return: str abs path
+ """
+ template_dir = self.config.get("template_dir")
+ template = self.config.get("template")
+ return os.path.realpath(os.path.join(template_dir, template))
+
+
+class SingleConfig(Config, metaclass=Singleton):
+ pass
diff --git a/dockertidy/Exception.py b/dockertidy/Exception.py
new file mode 100644
index 0000000..01f28b5
--- /dev/null
+++ b/dockertidy/Exception.py
@@ -0,0 +1,16 @@
+#!/usr/bin/env python3
+"""Custom exceptions."""
+
+
+class TidyError(Exception):
+ """Generic exception class for docker-tidy."""
+
+ def __init__(self, msg, original_exception=""):
+ super(TidyError, self).__init__(msg + ("\n%s" % original_exception))
+ self.original_exception = original_exception
+
+
+class ConfigError(TidyError):
+ """Errors related to config file handling."""
+
+ pass
diff --git a/dockertidy/Utils.py b/dockertidy/Utils.py
new file mode 100644
index 0000000..19165d2
--- /dev/null
+++ b/dockertidy/Utils.py
@@ -0,0 +1,239 @@
+#!/usr/bin/env python3
+"""Global utility methods and classes."""
+
+import datetime
+import logging
+import os
+import pprint
+import sys
+from distutils.util import strtobool
+
+import colorama
+from dateutil import tz
+from pythonjsonlogger import jsonlogger
+from pytimeparse import timeparse
+
+import dockertidy.Exception
+
+CONSOLE_FORMAT = "{}[%(levelname)s]{} %(message)s"
+JSON_FORMAT = "(asctime) (levelname) (message)"
+
+
+def to_bool(string):
+ return bool(strtobool(str(string)))
+
+
+def timedelta_type(value):
+ """Return the :class:`datetime.datetime.DateTime` for a time in the past.
+
+ :param value: a string containing a time format supported by
+ mod:`pytimeparse`
+ """
+ if value is None:
+ return None
+ return _datetime_seconds_ago(timeparse.timeparse(value))
+
+
+def _datetime_seconds_ago(seconds):
+ now = datetime.datetime.now(tz.tzutc())
+ return now - datetime.timedelta(seconds=seconds)
+
+
+def _should_do_markup():
+ py_colors = os.environ.get("PY_COLORS", None)
+ if py_colors is not None:
+ return to_bool(py_colors)
+
+ return sys.stdout.isatty() and os.environ.get("TERM") != "dumb"
+
+
+colorama.init(autoreset=True, strip=not _should_do_markup())
+
+
+class Singleton(type):
+ _instances = {}
+
+ def __call__(cls, *args, **kwargs):
+ if cls not in cls._instances:
+ cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
+ return cls._instances[cls]
+
+
+class LogFilter(object):
+ """A custom log filter which excludes log messages above the logged level."""
+
+ def __init__(self, level):
+ """
+ Initialize a new custom log filter.
+
+ :param level: Log level limit
+ :returns: None
+
+ """
+ self.__level = level
+
+ def filter(self, logRecord): # noqa
+ # https://docs.python.org/3/library/logging.html#logrecord-attributes
+ return logRecord.levelno <= self.__level
+
+
+class MultilineFormatter(logging.Formatter):
+ """Logging Formatter to reset color after newline characters."""
+
+ def format(self, record): # noqa
+ record.msg = record.msg.replace("\n", "\n{}... ".format(colorama.Style.RESET_ALL))
+ return logging.Formatter.format(self, record)
+
+
+class MultilineJsonFormatter(jsonlogger.JsonFormatter):
+ """Logging Formatter to remove newline characters."""
+
+ def format(self, record): # noqa
+ record.msg = record.msg.replace("\n", " ")
+ return jsonlogger.JsonFormatter.format(self, record)
+
+
+class Log:
+ def __init__(self, level=logging.WARN, name="dockertidy", json=False):
+ self.logger = logging.getLogger(name)
+ self.logger.setLevel(level)
+ self.logger.addHandler(self._get_error_handler(json=json))
+ self.logger.addHandler(self._get_warn_handler(json=json))
+ self.logger.addHandler(self._get_info_handler(json=json))
+ self.logger.addHandler(self._get_critical_handler(json=json))
+ self.logger.addHandler(self._get_debug_handler(json=json))
+ self.logger.propagate = False
+
+ def _get_error_handler(self, json=False):
+ handler = logging.StreamHandler(sys.stderr)
+ handler.setLevel(logging.ERROR)
+ handler.addFilter(LogFilter(logging.ERROR))
+ handler.setFormatter(MultilineFormatter(
+ self.error(CONSOLE_FORMAT.format(colorama.Fore.RED, colorama.Style.RESET_ALL))))
+
+ if json:
+ handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
+
+ return handler
+
+ def _get_warn_handler(self, json=False):
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setLevel(logging.WARN)
+ handler.addFilter(LogFilter(logging.WARN))
+ handler.setFormatter(MultilineFormatter(
+ self.warn(CONSOLE_FORMAT.format(colorama.Fore.YELLOW, colorama.Style.RESET_ALL))))
+
+ if json:
+ handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
+
+ return handler
+
+ def _get_info_handler(self, json=False):
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setLevel(logging.INFO)
+ handler.addFilter(LogFilter(logging.INFO))
+ handler.setFormatter(MultilineFormatter(
+ self.info(CONSOLE_FORMAT.format(colorama.Fore.CYAN, colorama.Style.RESET_ALL))))
+
+ if json:
+ handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
+
+ return handler
+
+ def _get_critical_handler(self, json=False):
+ handler = logging.StreamHandler(sys.stderr)
+ handler.setLevel(logging.CRITICAL)
+ handler.addFilter(LogFilter(logging.CRITICAL))
+ handler.setFormatter(MultilineFormatter(
+ self.critical(CONSOLE_FORMAT.format(colorama.Fore.RED, colorama.Style.RESET_ALL))))
+
+ if json:
+ handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
+
+ return handler
+
+ def _get_debug_handler(self, json=False):
+ handler = logging.StreamHandler(sys.stderr)
+ handler.setLevel(logging.DEBUG)
+ handler.addFilter(LogFilter(logging.DEBUG))
+ handler.setFormatter(MultilineFormatter(
+ self.critical(CONSOLE_FORMAT.format(colorama.Fore.BLUE, colorama.Style.RESET_ALL))))
+
+ if json:
+ handler.setFormatter(MultilineJsonFormatter(JSON_FORMAT))
+
+ return handler
+
+ def set_level(self, s):
+ self.logger.setLevel(s)
+
+ def debug(self, msg):
+ """Format info messages and return string."""
+ return msg
+
+ def critical(self, msg):
+ """Format critical messages and return string."""
+ return msg
+
+ def error(self, msg):
+ """Format error messages and return string."""
+ return msg
+
+ def warn(self, msg):
+ """Format warn messages and return string."""
+ return msg
+
+ def info(self, msg):
+ """Format info messages and return string."""
+ return msg
+
+ def _color_text(self, color, msg):
+ """
+ Colorize strings.
+
+ :param color: colorama color settings
+ :param msg: string to colorize
+ :returns: string
+
+ """
+ return "{}{}{}".format(color, msg, colorama.Style.RESET_ALL)
+
+ def sysexit(self, code=1):
+ sys.exit(code)
+
+ def sysexit_with_message(self, msg, code=1):
+ self.logger.critical(str(msg))
+ self.sysexit(code)
+
+
+class SingleLog(Log, metaclass=Singleton):
+ pass
+
+
+class FileUtils:
+ @staticmethod
+ def create_path(path):
+ os.makedirs(path, exist_ok=True)
+
+ @staticmethod
+ def query_yes_no(question, default=True):
+ """Ask a yes/no question via input() and return their answer.
+
+ "question" is a string that is presented to the user.
+ "default" is the presumed answer if the user just hits .
+ It must be "yes" (the default), "no" or None (meaning
+ an answer is required of the user).
+
+ The "answer" return value is one of "yes" or "no".
+ """
+ if default:
+ prompt = "[Y/n]"
+ else:
+ prompt = "[N/y]"
+
+ try:
+ # input() is safe in python3
+ choice = input("{} {} ".format(question, prompt)) or default # nosec
+ return to_bool(choice)
+ except (KeyboardInterrupt, ValueError) as e:
+ raise dockertidy.Exception.InputError("Error while reading input", e)
diff --git a/dockertidy/__init__.py b/dockertidy/__init__.py
new file mode 100644
index 0000000..5082da0
--- /dev/null
+++ b/dockertidy/__init__.py
@@ -0,0 +1,8 @@
+"""Default package."""
+
+__author__ = "Robert Kaussow"
+__project__ = "docker-tidy"
+__license__ = "Apache-2.0"
+__maintainer__ = "Robert Kaussow"
+__email__ = "mail@geeklabor.de"
+__url__ = "https://github.com/xoxys/docker-tidy"
diff --git a/dockertidy/__main__.py b/dockertidy/__main__.py
new file mode 100644
index 0000000..870a746
--- /dev/null
+++ b/dockertidy/__main__.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python3
+"""Main program."""
+
+from dockertidy.Cli import DockerTidy
+
+
+def main():
+ DockerTidy()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docker_custodian/args.py b/dockertidy/args.py
similarity index 100%
rename from docker_custodian/args.py
rename to dockertidy/args.py
diff --git a/docker_custodian/docker_autostop.py b/dockertidy/docker_autostop.py
similarity index 68%
rename from docker_custodian/docker_autostop.py
rename to dockertidy/docker_autostop.py
index 98d0df8..61d3eb0 100644
--- a/docker_custodian/docker_autostop.py
+++ b/dockertidy/docker_autostop.py
@@ -1,8 +1,6 @@
-#!/usr/bin/env python
-"""
-Stop docker container that have been running longer than the max_run_time and
-match some prefix.
-"""
+#!/usr/bin/env python3
+"""Stop long running docker iamges."""
+
import argparse
import logging
import sys
@@ -11,39 +9,36 @@ import dateutil.parser
import docker
import docker.errors
import requests.exceptions
-
-from docker_custodian.args import timedelta_type
from docker.utils import kwargs_from_env
-
+from docker_custodian.args import timedelta_type
log = logging.getLogger(__name__)
def stop_containers(client, max_run_time, matcher, dry_run):
for container_summary in client.containers():
- container = client.inspect_container(container_summary['Id'])
- name = container['Name'].lstrip('/')
+ container = client.inspect_container(container_summary["Id"])
+ name = container["Name"].lstrip("/")
if (
- matcher(name) and
- has_been_running_since(container, max_run_time)
+ matcher(name) and has_been_running_since(container, max_run_time)
):
log.info("Stopping container %s %s: running since %s" % (
- container['Id'][:16],
+ container["Id"][:16],
name,
- container['State']['StartedAt']))
+ container["State"]["StartedAt"]))
if not dry_run:
- stop_container(client, container['Id'])
+ stop_container(client, container["Id"])
-def stop_container(client, id):
+def stop_container(client, cid):
try:
- client.stop(id)
+ client.stop(cid)
except requests.exceptions.Timeout as e:
- log.warn("Failed to stop container %s: %s" % (id, e))
+ log.warn("Failed to stop container %s: %s" % (cid, e))
except docker.errors.APIError as ae:
- log.warn("Error stopping %s: %s" % (id, ae))
+ log.warn("Error stopping %s: %s" % (cid, ae))
def build_container_matcher(prefixes):
@@ -53,7 +48,7 @@ def build_container_matcher(prefixes):
def has_been_running_since(container, min_time):
- started_at = container.get('State', {}).get('StartedAt')
+ started_at = container.get("State", {}).get("StartedAt")
if not started_at:
return False
@@ -67,7 +62,7 @@ def main():
stream=sys.stdout)
opts = get_opts()
- client = docker.APIClient(version='auto',
+ client = docker.APIClient(version="auto",
timeout=opts.timeout,
**kwargs_from_env())
@@ -78,22 +73,22 @@ def main():
def get_opts(args=None):
parser = argparse.ArgumentParser()
parser.add_argument(
- '--max-run-time',
+ "--max-run-time",
type=timedelta_type,
help="Maximum time a container is allows to run. Time may "
"be specified in any pytimeparse supported format."
)
parser.add_argument(
- '--prefix', action="append", default=[],
+ "--prefix", action="append", default=[],
help="Only stop containers which match one of the "
"prefix."
)
parser.add_argument(
- '--dry-run', action="store_true",
+ "--dry-run", action="store_true",
help="Only log actions, don't stop anything."
)
parser.add_argument(
- '-t', '--timeout', type=int, default=60,
+ "-t", "--timeout", type=int, default=60,
help="HTTP timeout in seconds for making docker API calls."
)
opts = parser.parse_args(args=args)
diff --git a/docker_custodian/docker_gc.py b/dockertidy/docker_gc.py
similarity index 74%
rename from docker_custodian/docker_gc.py
rename to dockertidy/docker_gc.py
index dffcd03..79612e7 100644
--- a/docker_custodian/docker_gc.py
+++ b/dockertidy/docker_gc.py
@@ -1,21 +1,18 @@
-#!/usr/bin/env python
-"""
-Remove old docker containers and images that are no longer in use.
+#!/usr/bin/env python3
+"""Remove unused docker containers and images."""
-"""
import argparse
import fnmatch
import logging
import sys
+from collections import namedtuple
import dateutil.parser
import docker
import docker.errors
import requests.exceptions
-
-from collections import namedtuple
-from docker_custodian.args import timedelta_type
from docker.utils import kwargs_from_env
+from docker_custodian.args import timedelta_type
log = logging.getLogger(__name__)
@@ -23,7 +20,7 @@ log = logging.getLogger(__name__)
# This seems to be something docker uses for a null/zero date
YEAR_ZERO = "0001-01-01T00:00:00Z"
-ExcludeLabel = namedtuple('ExcludeLabel', ['key', 'value'])
+ExcludeLabel = namedtuple("ExcludeLabel", ["key", "value"])
def cleanup_containers(
@@ -40,7 +37,7 @@ def cleanup_containers(
for container_summary in reversed(list(filtered_containers)):
container = api_call(
client.inspect_container,
- container=container_summary['Id'],
+ container=container_summary["Id"],
)
if not container or not should_remove_container(
container,
@@ -49,14 +46,14 @@ def cleanup_containers(
continue
log.info("Removing container %s %s %s" % (
- container['Id'][:16],
- container.get('Name', '').lstrip('/'),
- container['State']['FinishedAt']))
+ container["Id"][:16],
+ container.get("Name", "").lstrip("/"),
+ container["State"]["FinishedAt"]))
if not dry_run:
api_call(
client.remove_container,
- container=container['Id'],
+ container=container["Id"],
v=True,
)
@@ -76,22 +73,22 @@ def filter_excluded_containers(containers, exclude_container_labels):
def should_exclude_container_with_labels(container, exclude_container_labels):
- if container['Labels']:
+ if container["Labels"]:
for exclude_label in exclude_container_labels:
if exclude_label.value:
matching_keys = fnmatch.filter(
- container['Labels'].keys(),
+ container["Labels"].keys(),
exclude_label.key,
)
label_values_to_check = [
- container['Labels'][matching_key]
+ container["Labels"][matching_key]
for matching_key in matching_keys
]
if fnmatch.filter(label_values_to_check, exclude_label.value):
return True
else:
if fnmatch.filter(
- container['Labels'].keys(),
+ container["Labels"].keys(),
exclude_label.key
):
return True
@@ -99,20 +96,20 @@ def should_exclude_container_with_labels(container, exclude_container_labels):
def should_remove_container(container, min_date):
- state = container.get('State', {})
+ state = container.get("State", {})
- if state.get('Running'):
+ if state.get("Running"):
return False
- if state.get('Ghost'):
+ if state.get("Ghost"):
return True
# Container was created, but never started
- if state.get('FinishedAt') == YEAR_ZERO:
- created_date = dateutil.parser.parse(container['Created'])
+ if state.get("FinishedAt") == YEAR_ZERO:
+ created_date = dateutil.parser.parse(container["Created"])
return created_date < min_date
- finished_date = dateutil.parser.parse(state['FinishedAt'])
+ finished_date = dateutil.parser.parse(state["FinishedAt"])
return finished_date < min_date
@@ -132,22 +129,22 @@ def get_all_images(client):
def get_dangling_volumes(client):
log.info("Getting dangling volumes")
- volumes = client.volumes({'dangling': True})['Volumes'] or []
+ volumes = client.volumes({"dangling": True})["Volumes"] or []
log.info("Found %s dangling volumes", len(volumes))
return volumes
def cleanup_images(client, max_image_age, dry_run, exclude_set):
- # re-fetch container list so that we don't include removed containers
+ # re-fetch container list so that we don't include removed containers
containers = get_all_containers(client)
images = get_all_images(client)
- if docker.utils.compare_version('1.21', client._version) < 0:
- image_tags_in_use = {container['Image'] for container in containers}
+ if docker.utils.compare_version("1.21", client._version) < 0:
+ image_tags_in_use = {container["Image"] for container in containers}
images = filter_images_in_use(images, image_tags_in_use)
else:
# ImageID field was added in 1.21
- image_ids_in_use = {container['ImageID'] for container in containers}
+ image_ids_in_use = {container["ImageID"] for container in containers}
images = filter_images_in_use_by_id(images, image_ids_in_use)
images = filter_excluded_images(images, exclude_set)
@@ -157,7 +154,7 @@ def cleanup_images(client, max_image_age, dry_run, exclude_set):
def filter_excluded_images(images, exclude_set):
def include_image(image_summary):
- image_tags = image_summary.get('RepoTags')
+ image_tags = image_summary.get("RepoTags")
if no_image_tags(image_tags):
return True
for exclude_pattern in exclude_set:
@@ -170,10 +167,10 @@ def filter_excluded_images(images, exclude_set):
def filter_images_in_use(images, image_tags_in_use):
def get_tag_set(image_summary):
- image_tags = image_summary.get('RepoTags')
+ image_tags = image_summary.get("RepoTags")
if no_image_tags(image_tags):
# The repr of the image Id used by client.containers()
- return set(['%s:latest' % image_summary['Id'][:12]])
+ return set(["%s:latest" % image_summary["Id"][:12]])
return set(image_tags)
def image_not_in_use(image_summary):
@@ -184,21 +181,21 @@ def filter_images_in_use(images, image_tags_in_use):
def filter_images_in_use_by_id(images, image_ids_in_use):
def image_not_in_use(image_summary):
- return image_summary['Id'] not in image_ids_in_use
+ return image_summary["Id"] not in image_ids_in_use
return filter(image_not_in_use, images)
def is_image_old(image, min_date):
- return dateutil.parser.parse(image['Created']) < min_date
+ return dateutil.parser.parse(image["Created"]) < min_date
def no_image_tags(image_tags):
- return not image_tags or image_tags == [':']
+ return not image_tags or image_tags == [":"]
def remove_image(client, image_summary, min_date, dry_run):
- image = api_call(client.inspect_image, image=image_summary['Id'])
+ image = api_call(client.inspect_image, image=image_summary["Id"])
if not image or not is_image_old(image, min_date):
return
@@ -206,13 +203,13 @@ def remove_image(client, image_summary, min_date, dry_run):
if dry_run:
return
- image_tags = image_summary.get('RepoTags')
+ image_tags = image_summary.get("RepoTags")
# If there are no tags, remove the id
if no_image_tags(image_tags):
- api_call(client.remove_image, image=image_summary['Id'])
+ api_call(client.remove_image, image=image_summary["Id"])
return
- # Remove any repository tags so we don't hit 409 Conflict
+ # Remove any repository tags so we don't hit 409 Conflict
for image_tag in image_tags:
api_call(client.remove_image, image=image_tag)
@@ -221,18 +218,18 @@ def remove_volume(client, volume, dry_run):
if not volume:
return
- log.info("Removing volume %s" % volume['Name'])
+ log.info("Removing volume %s" % volume["Name"])
if dry_run:
return
- api_call(client.remove_volume, name=volume['Name'])
+ api_call(client.remove_volume, name=volume["Name"])
def cleanup_volumes(client, dry_run):
dangling_volumes = get_dangling_volumes(client)
for volume in reversed(dangling_volumes):
- log.info("Removing dangling volume %s", volume['Name'])
+ log.info("Removing dangling volume %s", volume["Name"])
remove_volume(client, volume, dry_run)
@@ -240,31 +237,31 @@ def api_call(func, **kwargs):
try:
return func(**kwargs)
except requests.exceptions.Timeout as e:
- params = ','.join('%s=%s' % item for item in kwargs.items())
+ params = ",".join("%s=%s" % item for item in kwargs.items())
log.warn("Failed to call %s %s %s" % (func.__name__, params, e))
except docker.errors.APIError as ae:
- params = ','.join('%s=%s' % item for item in kwargs.items())
+ params = ",".join("%s=%s" % item for item in kwargs.items())
log.warn("Error calling %s %s %s" % (func.__name__, params, ae))
def format_image(image, image_summary):
def get_tags():
- tags = image_summary.get('RepoTags')
- if not tags or tags == [':']:
- return ''
- return ', '.join(tags)
+ tags = image_summary.get("RepoTags")
+ if not tags or tags == [":"]:
+ return ""
+ return ", ".join(tags)
- return "%s %s" % (image['Id'][:16], get_tags())
+ return "%s %s" % (image["Id"][:16], get_tags())
def build_exclude_set(image_tags, exclude_file):
exclude_set = set(image_tags or [])
def is_image_tag(line):
- return line and not line.startswith('#')
+ return line and not line.startswith("#")
if exclude_file:
- lines = [line.strip() for line in exclude_file.read().split('\n')]
+ lines = [line.strip() for line in exclude_file.read().split("\n")]
exclude_set.update(filter(is_image_tag, lines))
return exclude_set
@@ -272,7 +269,7 @@ def build_exclude_set(image_tags, exclude_file):
def format_exclude_labels(exclude_label_args):
exclude_labels = []
for exclude_label_arg in exclude_label_args:
- split_exclude_label = exclude_label_arg.split('=', 1)
+ split_exclude_label = exclude_label_arg.split("=", 1)
exclude_label_key = split_exclude_label[0]
if len(split_exclude_label) == 2:
exclude_label_value = split_exclude_label[1]
@@ -294,7 +291,7 @@ def main():
stream=sys.stdout)
args = get_args()
- client = docker.APIClient(version='auto',
+ client = docker.APIClient(version="auto",
timeout=args.timeout,
**kwargs_from_env())
@@ -323,39 +320,39 @@ def main():
def get_args(args=None):
parser = argparse.ArgumentParser()
parser.add_argument(
- '--max-container-age',
+ "--max-container-age",
type=timedelta_type,
help="Maximum age for a container. Containers older than this age "
"will be removed. Age can be specified in any pytimeparse "
"supported format.")
parser.add_argument(
- '--max-image-age',
+ "--max-image-age",
type=timedelta_type,
help="Maxium age for an image. Images older than this age will be "
"removed. Age can be specified in any pytimeparse supported "
"format.")
parser.add_argument(
- '--dangling-volumes',
+ "--dangling-volumes",
action="store_true",
help="Dangling volumes will be removed.")
parser.add_argument(
- '--dry-run', action="store_true",
+ "--dry-run", action="store_true",
help="Only log actions, don't remove anything.")
parser.add_argument(
- '-t', '--timeout', type=int, default=60,
+ "-t", "--timeout", type=int, default=60,
help="HTTP timeout in seconds for making docker API calls.")
parser.add_argument(
- '--exclude-image',
- action='append',
+ "--exclude-image",
+ action="append",
help="Never remove images with this tag.")
parser.add_argument(
- '--exclude-image-file',
- type=argparse.FileType('r'),
+ "--exclude-image-file",
+ type=argparse.FileType("r"),
help="Path to a file which contains a list of images to exclude, one "
"image tag per line.")
parser.add_argument(
- '--exclude-container-label',
- action='append', type=str, default=[],
+ "--exclude-container-label",
+ action="append", type=str, default=[],
help="Never remove containers with this label key or label key=value")
return parser.parse_args(args=args)
diff --git a/tests/__init__.py b/dockertidy/tests/__init__.py
similarity index 100%
rename from tests/__init__.py
rename to dockertidy/tests/__init__.py
diff --git a/tests/args_test.py b/dockertidy/tests/args_test.py
similarity index 99%
rename from tests/args_test.py
rename to dockertidy/tests/args_test.py
index 378bd81..67f65aa 100644
--- a/tests/args_test.py
+++ b/dockertidy/tests/args_test.py
@@ -1,12 +1,14 @@
import datetime
+
+from dateutil import tz
+from docker_custodian import args
+
try:
from unittest import mock
except ImportError:
import mock
-from dateutil import tz
-from docker_custodian import args
def test_datetime_seconds_ago(now):
diff --git a/tests/conftest.py b/dockertidy/tests/conftest.py
similarity index 99%
rename from tests/conftest.py
rename to dockertidy/tests/conftest.py
index b891cc2..257375c 100644
--- a/tests/conftest.py
+++ b/dockertidy/tests/conftest.py
@@ -1,12 +1,13 @@
import datetime
-from dateutil import tz
import docker
+import pytest
+from dateutil import tz
+
try:
from unittest import mock
except ImportError:
import mock
-import pytest
@pytest.fixture
diff --git a/tests/docker_autostop_test.py b/dockertidy/tests/docker_autostop_test.py
similarity index 87%
rename from tests/docker_autostop_test.py
rename to dockertidy/tests/docker_autostop_test.py
index 8f0a2a4..dd6b22a 100644
--- a/tests/docker_autostop_test.py
+++ b/dockertidy/tests/docker_autostop_test.py
@@ -3,14 +3,12 @@ try:
except ImportError:
import mock
-from docker_custodian.docker_autostop import (
- build_container_matcher,
- get_opts,
- has_been_running_since,
- main,
- stop_container,
- stop_containers,
-)
+from docker_custodian.docker_autostop import build_container_matcher
+from docker_custodian.docker_autostop import get_opts
+from docker_custodian.docker_autostop import has_been_running_since
+from docker_custodian.docker_autostop import main
+from docker_custodian.docker_autostop import stop_container
+from docker_custodian.docker_autostop import stop_containers
def test_stop_containers(mock_client, container, now):
diff --git a/tests/docker_gc_test.py b/dockertidy/tests/docker_gc_test.py
similarity index 99%
rename from tests/docker_gc_test.py
rename to dockertidy/tests/docker_gc_test.py
index 445301f..e23549b 100644
--- a/tests/docker_gc_test.py
+++ b/dockertidy/tests/docker_gc_test.py
@@ -1,14 +1,15 @@
-from six import StringIO
import textwrap
import docker.errors
+import requests.exceptions
+from docker_custodian import docker_gc
+from io import StringIO
+
try:
from unittest import mock
except ImportError:
import mock
-import requests.exceptions
-from docker_custodian import docker_gc
class TestShouldRemoveContainer(object):
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 8050d9c..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,14 +0,0 @@
-backports.ssl-match-hostname==3.5.0.1
-certifi==2018.1.18
-chardet==3.0.4
-docker==3.1.0
-docker-pycreds==0.2.2
-future==0.16.0
-idna==2.6
-ipaddress==1.0.19
-python-dateutil==2.6.1
-pytimeparse==1.1.7
-requests==2.20.0
-six==1.11.0
-urllib3==1.24.2
-websocket-client==0.47.0
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..43f23d7
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,20 @@
+[metadata]
+description-file = README.md
+license_file = LICENSE
+
+[bdist_wheel]
+universal = 1
+
+[isort]
+default_section = THIRDPARTY
+known_first_party = dockertidy
+sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
+force_single_line = true
+line_length = 110
+skip_glob = **/env/*,**/docs/*
+
+[tool:pytest]
+filterwarnings =
+ ignore::FutureWarning
+ ignore:.*collections.*:DeprecationWarning
+ ignore:.*pep8.*:FutureWarning
diff --git a/setup.py b/setup.py
index 7873542..f3a18ec 100644
--- a/setup.py
+++ b/setup.py
@@ -1,27 +1,99 @@
-# -*- coding: utf-8 -*-
-from setuptools import setup, find_packages
-from docker_custodian.__about__ import __version__
+#!/usr/bin/env python
+"""Setup script for the package."""
+
+import io
+import os
+import re
+
+from setuptools import find_packages
+from setuptools import setup
+
+PACKAGE_NAME = "dockertidy"
+
+
+def get_property(prop, project):
+ current_dir = os.path.dirname(os.path.realpath(__file__))
+ result = re.search(
+ r'{}\s*=\s*[\'"]([^\'"]*)[\'"]'.format(prop),
+ open(os.path.join(current_dir, project, "__init__.py")).read(),
+ )
+ return result.group(1)
+
+
+def get_readme(filename="README.md"):
+ this = os.path.abspath(os.path.dirname(__file__))
+ with io.open(os.path.join(this, filename), encoding="utf-8") as f:
+ long_description = f.read()
+ return long_description
setup(
- name='docker_custodian',
- version=__version__,
- provides=['docker_custodian'],
- author='Daniel Nephin',
- author_email='dnephin@yelp.com',
- description='Keep docker hosts tidy.',
- packages=find_packages(exclude=['tests*']),
- include_package_data=True,
- install_requires=[
- 'python-dateutil',
- 'docker',
- 'pytimeparse',
- ],
- license="Apache License 2.0",
- entry_points={
- 'console_scripts': [
- 'dcstop = docker_custodian.docker_autostop:main',
- 'dcgc = docker_custodian.docker_gc:main',
- ],
+ name=get_property("__project__", PACKAGE_NAME),
+ use_scm_version={
+ "version_scheme": "python-simplified-semver",
+ "local_scheme": "no-local-version",
+ "fallback_version": "unknown",
},
+ description="Keep docker hosts tidy",
+ keywords="docker gc prune garbage",
+ author=get_property("__author__", PACKAGE_NAME),
+ author_email=get_property("__email__", PACKAGE_NAME),
+ url="https://github.com/xoxys/docker-tidy",
+ license=get_property("__url__", PACKAGE_NAME),
+ long_description=get_readme(),
+ long_description_content_type="text/markdown",
+ packages=find_packages(exclude=["*.tests", "tests", "tests.*"]),
+ include_package_data=True,
+ zip_safe=False,
+ python_requires=">=3.5",
+ classifiers=[
+ "Development Status :: 5 - Production/Stable",
+ "Environment :: Console",
+ "Intended Audience :: Developers",
+ "Intended Audience :: Information Technology",
+ "Intended Audience :: System Administrators",
+ "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
+ "Natural Language :: English",
+ "Operating System :: POSIX",
+ "Programming Language :: Python :: 3 :: Only",
+ "Topic :: System :: Installation/Setup",
+ "Topic :: System :: Systems Administration",
+ "Topic :: Utilities",
+ "Topic :: Software Development",
+ "Topic :: Software Development :: Documentation",
+ ],
+ install_requires=[
+ "anyconfig==0.9.10",
+ "appdirs==1.4.3",
+ "attrs==19.3.0",
+ "certifi==2019.11.28",
+ "chardet==3.0.4",
+ "colorama==0.4.3",
+ "docker==4.2.0",
+ "docker-pycreds==0.4.0",
+ "environs==7.2.0",
+ "idna==2.9",
+ "importlib-metadata==1.5.0; python_version < '3.8'",
+ "ipaddress==1.0.23",
+ "jsonschema==3.2.0",
+ "marshmallow==3.5.0",
+ "nested-lookup==0.2.21",
+ "pathspec==0.7.0",
+ "pyrsistent==0.15.7",
+ "python-dateutil==2.8.1",
+ "python-dotenv==0.12.0",
+ "python-json-logger==0.1.11",
+ "pytimeparse==1.1.8",
+ "requests==2.23.0",
+ "ruamel.yaml==0.16.10",
+ "ruamel.yaml.clib==0.2.0; platform_python_implementation == 'CPython' and python_version < '3.9'",
+ "six==1.14.0",
+ "urllib3==1.25.8",
+ "websocket-client==0.57.0",
+ "zipp==3.0.0",
+ ],
+ dependency_links=[],
+ setup_requires=["setuptools_scm",],
+ entry_points={"console_scripts": ["docker-tidy = dockertidy.__main__:main"]},
+ test_suite="tests",
)
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index 8713cf8..0000000
--- a/tox.ini
+++ /dev/null
@@ -1,14 +0,0 @@
-[tox]
-envlist = py27,py35,py36
-
-[testenv]
-deps =
- -rrequirements.txt
- mock
- pre-commit
- pytest
-commands =
- py.test {posargs:tests}
- pre-commit autoupdate
- pre-commit install -f --install-hooks
- pre-commit run --all-files