diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..3930b8b
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,10 @@
+.git/
+.idea/
+
+dist/
+example/
+
+Dockerfile
+.dockerignore
+.gitignore
+.drone.yml
\ No newline at end of file
diff --git a/.drone.yml b/.drone.yml
index ecd6889..d0bae99 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -3,6 +3,14 @@ kind: pipeline
name: default
steps:
+- name: pytest
+ image: python:3.6-alpine3.13
+ commands:
+ - apk add --no-cache g++ libffi-dev
+ - wget -O- https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py | python3 -
+ - /root/.local/bin/poetry install
+ - /root/.local/bin/poetry run pytest
+
- name: docker
image: plugins/docker
settings:
diff --git a/.gitignore b/.gitignore
index c18dd8d..03a44c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
__pycache__/
+htmlcov/
+.coverage
\ No newline at end of file
diff --git a/.idea/kiwi-scp.iml b/.idea/kiwi-scp.iml
index 127e464..2f08fd2 100644
--- a/.idea/kiwi-scp.iml
+++ b/.idea/kiwi-scp.iml
@@ -4,6 +4,8 @@
+
+
diff --git a/.idea/runConfigurations/Test_Coverage.xml b/.idea/runConfigurations/Test_Coverage.xml
new file mode 100644
index 0000000..89e6064
--- /dev/null
+++ b/.idea/runConfigurations/Test_Coverage.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Tests__Debuggable_.xml b/.idea/runConfigurations/Tests__Debuggable_.xml
new file mode 100644
index 0000000..e648d65
--- /dev/null
+++ b/.idea/runConfigurations/Tests__Debuggable_.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/kiwi.xml b/.idea/runConfigurations/kiwi.xml
new file mode 100644
index 0000000..127b64e
--- /dev/null
+++ b/.idea/runConfigurations/kiwi.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index dd4db5d..5a33c84 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,6 +12,6 @@ RUN set -ex; \
COPY . /usr/src/kiwi_scp
RUN set -ex; \
- pip3 --use-feature=in-tree-build install /usr/src/kiwi_scp
+ pip3 --no-cache-dir --use-feature=in-tree-build install /usr/src/kiwi_scp
ENTRYPOINT ["kiwi"]
diff --git a/README.md b/README.md
index 2b4828a..009461c 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ The simple tool for managing container servers
## Quick start
- Learn to use `docker` with `docker-compose`
-- Install kiwi-scp
+- Install `kiwi-scp`
- Look at [the example instance](./example)
- Look at the output of `kiwi --help`
- Start building your own instances
@@ -18,24 +18,24 @@ The simple tool for managing container servers
## Installation
-A convenience installer is available as [install.sh](./install.sh) in this directory.
+A convenience installer is available as [install.sh](./dist/install.sh) in the `dist` directory.
You can `curl | sh` it using the following one-liner.
```shell script
-curl --proto '=https' --tlsv1.2 -sSf 'https://raw.githubusercontent.com/ldericher/kiwi-scp/master/install.sh' | sh
+curl --proto '=https' --tlsv1.2 -sSf 'https://raw.githubusercontent.com/ldericher/kiwi-scp/master/dist/install.sh' | sh
```
The installer downloads the `kiwi` launcher script and installs it to a location of your choice.
Please consider installing into a directory inside your `$PATH`.
-Run in a root shell or use `sudo sh` instead for system-wide installation.
+Run in a root shell or use `sudo sh` at the end instead for system-wide installation.
-You should now be able to run `kiwi init --show` and see the default configuration file.
-This downloads the latest version of the main kiwi-scp executable and sets it up for you.
+You should then be able to run `kiwi list --show` and see the default configuration file.
+This installs the latest version of the kiwi-scp package and sets it up for you.
### Adjusting environment for `kiwi`
-The `kiwi` executable depends on [Python](https://www.python.org/) 3.6 (or later) and
+The `kiwi` executable depends on [Python](https://www.python.org/) 3.6.1 (or later) and
[less](http://www.greenwoodsoftware.com/less/) being in your `$PATH`.
In some cases, notably when using a multi-version system such as
@@ -44,8 +44,7 @@ at login time.
In those cases, you can simply create a `.kiwi_profile` file in your home directory.
It will be sourced every time you use the `kiwi` command.
-For the aforementioned case where you installed `centos-release-scl` and `rh-python36`, your `~/.kiwi_profile` should
-contain:
+For the aforementioned case where you installed `centos-release-scl` and `rh-python36`, your `~/.kiwi_profile` should contain:
```shell script
#!/bin/sh
@@ -59,8 +58,7 @@ contain:
### Create a kiwi-scp instance
Any directory is implicitly a valid kiwi-scp instance using the default configuration.
-To prevent surprises however, you should run `kiwi init` in an empty directory and follow its directions to
-create a `kiwi.yml` before using `kiwi` more.
+To prevent surprises however, you should run `kiwi init` and follow its directions to create a `kiwi.yml` for your instance before using `kiwi` more.
### Concept
@@ -69,11 +67,11 @@ A kiwi-scp instance is a directory containing a bunch of static configuration fi
"Static" there as in "those don't change during normal service operation".
These files could be anything from actual `.conf` files to entire html-web-roots.
-Non-static, but persistent files are to be kept in a "service data directory", by default `/var/kiwi`.
-In your `docker-compose.yml` files, you can refer to that directory as **${TARGETROOT}**.
+Non-static, persistent files are to be kept in a "service data storage", by default the directory `/var/local/kiwi`.
+In your `docker-compose.yml` files, you can refer to that directory as **${KIWI_INSTANCE}**.
-Start the current directory as a kiwi-scp instance using `kiwi up`, or stop it using `kiwi down`.
-This also creates kiwi's internal hub network, which you can use as **kiwi_hub** in your `docker-compose.yml` files.
+Start the current kiwi-scp instance using `kiwi up`, or stop it using `kiwi down`.
+This also manages kiwi's internal hub network, which you can use as **kiwi_hub** in your `docker-compose.yml` files.
### Projects
@@ -88,7 +86,7 @@ Before enabling or starting, consider editing the new project's `docker-compose.
Finally, enable it with `kiwi enable `.
You can also create, enable or (analogously) disable multiple projects in a single command.
-Each project will have its own place in the service data directory, which you can refer to as **${TARGETDIR}**.
+Each project will have its own place in the service data directory, which you can refer to as **${KIWI_PROJECT}**.
Finally, start a project using `kiwi up `.
@@ -100,7 +98,7 @@ kiwi-scp extends the logical bounds of `docker-compose` to handling multiple pro
#### The `kiwi_hub`
-With kiwi-scp, you get the internal kiwi_hub network for free.
+With kiwi-scp, you get the internal `kiwi_hub` network for free.
It allows for network communication between services in different projects.
Be aware, services only connected to the kiwi_hub can't use a port mapping!
In most cases, you will want to use this:
@@ -112,25 +110,23 @@ networks:
```
-#### The `CONFDIR`
+#### The `KIWI_CONFIG`
Sometimes, it's convenient to re-use configuration files across projects.
-For this use case, create a directory named `conf` in a project.
-Those will all be combined into a directory available as **${CONFDIR}** in your `docker-compose.yml` files.
+For this use case, create a directory named `config` in your instance.
+In your `docker-compose.yml` files, you can refer to that directory as **${KIWI_CONFIG}**.
#### `kiwi.yml` options
##### `version`
-Version of kiwi-scp to use for this instance.
-Default: Latest version.
+Version of kiwi-scp to use for this instance.
-##### `runtime:storage`
-Path of the service data directory, available as **${TARGETROOT}** in projects.
-Default: `/var/kiwi`
+Default: Version of [`master` branch](https://github.com/ldericher/kiwi-scp/tree/master).
+
+##### `shells`
+Sequence of additionally preferable shell executables when entering service containers.
-##### `runtime:shells`
-List of additionally preferable shell executables when entering service containers.
Default: `- /bin/bash`
Example:
@@ -141,17 +137,74 @@ runtime:
- /bin/fish
```
-##### `runtime:env`
-Associative array of custom variables available in projects' `docker-compose.yml` files.
-Default: `null`
+##### `projects`
+Sequence of project definitions in this instance.
+
+###### Project definition
+Defining a project in this instance. Any subdirectory with a `docker-compose.yml` should be considered a project. The directory name is equivalent to the project name.
+
+Format[^1]: Mapping using the keys `name` (required), `enabled` and `override_storage`
Example:
```yaml
-runtime:
- env:
- HELLO: "World"
- FOO: "Bar"
+- name: "hello_world"
+ enabled: true
```
-#### For everything else, look at `kiwi --help`
-#### Happy kiwi-ing!
+##### `environment`
+Custom variables available in projects' `docker-compose.yml` files.
+
+Format[^1]: Mapping of `KEY: "value"` pairs
+Example:
+
+```yaml
+environment:
+ HELLO: "World"
+ FOO: "Bar"
+```
+
+##### `storage`
+Configuration for the service data storage.
+
+Format: Mapping using the key `directory`
+Example:
+
+```yaml
+storage:
+ directory: "/var/local/kiwi"
+```
+
+###### `storage:directory`
+Path to the local service data directory, the only currently supported service data storage.
+Available as **${KIWI_INSTANCE}** in projects.
+
+Default: `"/var/local/kiwi"`
+
+
+##### `network`
+Configuration for the internal `kiwi_hub` network.
+
+Format: Mapping using the keys `name` and `cidr`
+Example:
+
+```yaml
+network:
+ name: "kiwi_hub"
+ cidr: "10.22.46.0/24"
+```
+
+###### `network:name`
+Configuration for the internal `kiwi_hub` network.
+
+Default: `"kiwi_hub"`
+
+###### `network:cidr`
+[CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#IPv4_CIDR_blocks) for the subnet of the internal `kiwi_hub` network.
+
+Default: `"10.22.46.0/24"`
+
+**For everything else, look at `kiwi --help`**
+
+**Happy kiwi-ing!**
+
+[^1]: This is the officially correct format. For enabling varying conventions, there are multiple accepted formats. Start trying and check with `kiwi list --show` -- if it makes sense, it will likely just work.
diff --git a/bump-version.sh b/bump-version.sh
deleted file mode 100755
index 7c4b7b3..0000000
--- a/bump-version.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/bin/bash
-
-this="$(readlink -f "${0}")"
-this_dir="$(dirname "${this}")"
-
-git_branch="$(git rev-parse --abbrev-ref HEAD)"
-git_tag="$(git describe --abbrev=0)"
-version_str="${git_branch##*/}"
-
-echo "${version_str}" > "${this_dir}/kiwi_scp/data/etc/version_tag"
-sed -ri "s/(version\s*:).*$/\1 '${version_str}'/" "${this_dir}/example/kiwi.yml"
-sed -ri "s/(version\s*=\s*).*$/\1\"${version_str}\"/" "${this_dir}/pyproject.toml"
diff --git a/dist/bump-version.sh b/dist/bump-version.sh
new file mode 100755
index 0000000..6022e8c
--- /dev/null
+++ b/dist/bump-version.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+this="$(readlink -f "${0}")"
+this_dir="$(dirname "${this}")"
+
+git_branch="$(git rev-parse --abbrev-ref HEAD)"
+# git_tag="$(git describe --abbrev=0)"
+version_str="${git_branch##*/}"
+# version_str="0.2.0"
+
+sed -ri "s/(version\s*:).*$/\1 ${version_str}/" "${this_dir}/../example/kiwi.yml"
+sed -ri "s/(version\s*=\s*).*$/\1\"${version_str}\"/" "${this_dir}/../pyproject.toml"
+sed -ri "s/(version.*=\s*).*$/\1\"${version_str}\"/" "${this_dir}/../kiwi_scp/config.py"
diff --git a/install.sh b/dist/install.sh
similarity index 99%
rename from install.sh
rename to dist/install.sh
index 13ce687..d8a9b31 100755
--- a/install.sh
+++ b/dist/install.sh
@@ -7,7 +7,7 @@
# default installation directory
INSTALL_DIR_DEFAULT="/usr/local/sbin"
# URI of "kiwi" launcher script
-KIWI_URI="https://raw.githubusercontent.com/ldericher/kiwi-scp/master/kiwi"
+KIWI_URI="https://raw.githubusercontent.com/ldericher/kiwi-scp/master/dist/kiwi"
#############
# FUNCTIONS #
diff --git a/kiwi b/dist/kiwi
similarity index 79%
rename from kiwi
rename to dist/kiwi
index 4038eb4..63714eb 100755
--- a/kiwi
+++ b/dist/kiwi
@@ -6,8 +6,6 @@
# base config filename
KIWI_CONF_NAME="kiwi.yml"
-# version tag filename
-KIWI_VERSION_TAG="kiwi_scp/data/etc/version_tag"
# dependencies to run kiwi-scp
KIWI_DEPENDENCIES="python3 less docker docker-compose"
@@ -18,12 +16,14 @@ KIWI_PROFILE="${HOME}/.kiwi_profile"
# repository uri
KIWI_REPO="https://github.com/ldericher/kiwi-scp"
+KIWI_REPO_RAW="https://raw.githubusercontent.com/ldericher/kiwi-scp"
# use latest version by default
KIWI_VERSION="master"
-# URI of "kiwi" launcher script
-KIWI_URI="https://raw.githubusercontent.com/ldericher/kiwi-scp/master/kiwi"
-INSTALLER_URI="https://raw.githubusercontent.com/ldericher/kiwi-scp/master/install.sh"
+# URIs in this directory
+PACKAGE_URI="pyproject.toml"
+KIWI_URI="dist/kiwi"
+INSTALLER_URI="dist/install.sh"
# canary file: limit curl requests
CANARY_FILENAME="/tmp/kiwi-scp-$(id -u).canary"
CANARY_MAX_AGE=600
@@ -115,14 +115,14 @@ fi
if [ "${run_kiwi_check}" = "yes" ]; then
# hash this script and the master version
hash_local="$(md5sum <"$(readlink -f "${0}")")"
- hash_remote="$(curl --proto '=https' --tlsv1.2 -sSfL "${KIWI_URI}" | md5sum)"
+ hash_remote="$(curl --proto '=https' --tlsv1.2 -sSfL "${KIWI_REPO_RAW}/${KIWI_VERSION}/${KIWI_URI}" | md5sum)"
# warn if different
if [ "${hash_local}" != "${hash_remote}" ]; then
if yes_no "Your kiwi launcher is outdated. Update now?" >/dev/stderr; then
# should reinstall, so download installer
- installer="$(curl --proto '=https' --tlsv1.2 -sSfL "${INSTALLER_URI}")"
+ installer="$(curl --proto '=https' --tlsv1.2 -sSfL "${KIWI_REPO_RAW}/${KIWI_VERSION}/${INSTALLER_URI}")"
if yes_no "Use sudo to run as root?"; then
# enable system-wide install
@@ -156,12 +156,20 @@ if [ "${run_kiwi_check}" = "yes" ]; then
chmod 0777 "${CANARY_FILENAME}"
fi
-# check if pwd is a kiwi folder
-if [ -f "./${KIWI_CONF_NAME}" ]; then
- # determine needed kiwi-scp version
- re_version_line='version\s*:\s*'
- eval "$(grep -E "${re_version_line}" "./${KIWI_CONF_NAME}" | sed -r "s/${re_version_line}/KIWI_VERSION=/")"
-fi
+# check if pwd is a kiwi instance
+path="$(pwd)"
+while [ "${path}" != "" ]; do
+ if [ -e "${path}/${KIWI_CONF_NAME}" ]; then
+ # cd into kiwi instance
+ cd "${path}" || yes_no "Could not enter kiwi instance at '${path}'. Continue anyway?" || exit 1
+
+ # determine needed kiwi-scp version
+ re_version_line='version\s*:\s*'
+ eval "$(grep -E "${re_version_line}" "./${KIWI_CONF_NAME}" | sed -r "s/${re_version_line}/KIWI_VERSION=/")"
+ break;
+ fi
+ path="${path%/*}"
+done
# install if kiwi-scp not found
if [ ! -x "$(kiwi_executable)" ]; then
@@ -169,11 +177,9 @@ if [ ! -x "$(kiwi_executable)" ]; then
# read version tag
KIWI_VERSION="$( \
- curl \
- --proto '=https' \
- --tlsv1.2 \
- -sSfL \
- "https://raw.githubusercontent.com/ldericher/kiwi-scp/${KIWI_VERSION}/${KIWI_VERSION_TAG}" \
+ curl --proto '=https' --tlsv1.2 -sSfL "${KIWI_REPO_RAW}/${KIWI_VERSION}/${PACKAGE_URI}" \
+ | grep -e 'version\s*=' \
+ | sed -r "s/version\s*=\s*\"([^\"]*)\"$/\1/" \
)"
if [ -x "$(kiwi_executable)" ]; then
diff --git a/example/hello-world.project/conf/html/index.html b/example/config/html/index.html
similarity index 100%
rename from example/hello-world.project/conf/html/index.html
rename to example/config/html/index.html
diff --git a/example/hello-world.project/docker-compose.yml b/example/hello_world/docker-compose.yml
similarity index 68%
rename from example/hello-world.project/docker-compose.yml
rename to example/hello_world/docker-compose.yml
index 9a7ea1e..33c6f1a 100644
--- a/example/hello-world.project/docker-compose.yml
+++ b/example/hello_world/docker-compose.yml
@@ -10,20 +10,20 @@ networks:
name: ${KIWI_HUB_NAME}
services:
- # simple loop producing (rather boring) logs
greeter:
+ # simple loop producing (rather boring) logs
image: alpine:latest
command: sh -c 'LOOP=1; while :; do echo Hello World "$$LOOP"; LOOP=$$(($$LOOP + 1)); sleep 10; done'
- # basic webserver listening on localhost:8080
web:
+ # basic webserver listening on localhost:8080
build: web
restart: unless-stopped
ports:
- "8080:80"
- # internal mariadb (mysql) instance with persistent storage
db:
+ # internal mariadb (mysql) instance with persistent storage
image: mariadb:10
restart: unless-stopped
networks:
@@ -31,10 +31,10 @@ services:
environment:
MYSQL_ROOT_PASSWORD: changeme
volumes:
- - "${TARGETDIR}/db:/var/lib/mysql"
+ - "${KIWI_PROJECT}/db:/var/lib/mysql"
- # admin interface for databases
adminer:
+ # admin interface for databases
image: adminer:standalone
restart: unless-stopped
networks:
@@ -45,11 +45,11 @@ services:
ports:
- "8081:8080"
- # Another webserver just to show off the ${CONFDIR} variable
another-web:
+ # Another webserver just to show off the ${KIWI_CONFIG} variable
image: nginx:stable-alpine
restart: unless-stopped
ports:
- "8082:80"
volumes:
- - "${CONFDIR}/html/index.html:/usr/share/nginx/html/index.html:ro"
+ - "${KIWI_CONFIG}/html/index.html:/usr/share/nginx/html/index.html:ro"
diff --git a/example/hello-world.project/web/Dockerfile b/example/hello_world/web/Dockerfile
similarity index 100%
rename from example/hello-world.project/web/Dockerfile
rename to example/hello_world/web/Dockerfile
diff --git a/example/hello-world.project/web/index.html b/example/hello_world/web/index.html
similarity index 100%
rename from example/hello-world.project/web/index.html
rename to example/hello_world/web/index.html
diff --git a/example/kiwi.yml b/example/kiwi.yml
index 1eb6293..a9f55fa 100644
--- a/example/kiwi.yml
+++ b/example/kiwi.yml
@@ -2,17 +2,17 @@
# kiwi-scp instance configuration #
###################################
-version: '0.1.7'
+version: 0.2.0
-runtime:
- storage: /tmp/kiwi
- shells:
+shells:
- /bin/bash
- env: null
-markers:
- project: .project
- disabled: .disabled
+projects:
+ - name: hello_world
+ enabled: true
+
+storage:
+ directory: /var/local/kiwi
network:
name: kiwi_hub
diff --git a/kiwi_scp/__init__.py b/kiwi_scp/__init__.py
index ba8912f..e69de29 100644
--- a/kiwi_scp/__init__.py
+++ b/kiwi_scp/__init__.py
@@ -1,20 +0,0 @@
-# local
-from .parser import Parser
-from .runner import Runner
-
-
-def verbosity():
- # ensure singleton is instantiated: runs subcommand setup routines
- _ = Runner()
- return Parser().get_args().verbosity
-
-
-def run():
- # pass down
- return Runner().run()
-
-
-__all__ = [
- 'verbosity',
- 'run'
-]
diff --git a/kiwi_scp/_constants.py b/kiwi_scp/_constants.py
index cd12c09..31de4c9 100644
--- a/kiwi_scp/_constants.py
+++ b/kiwi_scp/_constants.py
@@ -1,13 +1,26 @@
-# system
import os
+#############
+# REGEX PARTS
+
+# regex part for a number with no leading zeroes
+_RE_NUMBER: str = r"(?:0|[1-9][0-9]*)"
+
+# regex for a semantic version string
+RE_SEMVER = rf"^{_RE_NUMBER}(?:\.{_RE_NUMBER}(?:\.{_RE_NUMBER})?)?$"
+
+# regex for a variable name
+RE_VARNAME = r"^[A-Za-z](?:[A-Za-z0-9\._-]*[A-Za-z0-9])$"
+
#############
# ENVIRONMENT
# location of "kiwi_scp" module
KIWI_ROOT = os.path.dirname(__file__)
# default name of kiwi-scp file
-KIWI_CONF_NAME = os.getenv('KIWI_CONF_NAME', "kiwi.yml")
+KIWI_CONF_NAME = os.getenv("KIWI_CONF_NAME", "kiwi.yml")
+# default name of compose files
+COMPOSE_FILE_NAME = "docker-compose.yml"
############
# FILE NAMES
@@ -15,19 +28,22 @@ KIWI_CONF_NAME = os.getenv('KIWI_CONF_NAME', "kiwi.yml")
# text files inside kiwi-scp "src" directory
HEADER_KIWI_CONF_NAME = f"{KIWI_ROOT}/data/etc/kiwi_header.yml"
DEFAULT_KIWI_CONF_NAME = f"{KIWI_ROOT}/data/etc/kiwi_default.yml"
-VERSION_TAG_NAME = f"{KIWI_ROOT}/data/etc/version_tag"
DEFAULT_DOCKER_COMPOSE_NAME = f"{KIWI_ROOT}/data/etc/docker-compose_default.yml"
-KIWI_HELP_TEXT_NAME = f"{KIWI_ROOT}/data/etc/kiwi_help.txt"
-COMMAND_HELP_TEXT_NAME = f"{KIWI_ROOT}/data/etc/command_help.txt"
-# special config directory in projects
-CONF_DIRECTORY_NAME = 'conf'
+# special config directory
+CONFIG_DIRECTORY_NAME = "config"
+
# location for auxiliary Dockerfiles
IMAGES_DIRECTORY_NAME = f"{KIWI_ROOT}/data/images"
+# prohibited project names
+RESERVED_PROJECT_NAMES = [
+ CONFIG_DIRECTORY_NAME,
+]
+
####################
# DOCKER IMAGE NAMES
# name for auxiliary docker images
-LOCAL_IMAGES_NAME = 'localhost/kiwi-scp/auxiliary'
-DEFAULT_IMAGE_NAME = 'alpine:latest'
+LOCAL_IMAGES_NAME = "localhost/kiwi-scp/auxiliary"
+DEFAULT_IMAGE_NAME = "alpine:latest"
diff --git a/kiwi_scp/commands/__init__.py b/kiwi_scp/commands/__init__.py
new file mode 100644
index 0000000..ffeb421
--- /dev/null
+++ b/kiwi_scp/commands/__init__.py
@@ -0,0 +1,3 @@
+from .cli import KiwiCLI
+
+__all__ = ["KiwiCLI"]
diff --git a/kiwi_scp/commands/cli.py b/kiwi_scp/commands/cli.py
new file mode 100644
index 0000000..ed1ceed
--- /dev/null
+++ b/kiwi_scp/commands/cli.py
@@ -0,0 +1,95 @@
+import importlib
+import os
+from gettext import gettext as _
+from typing import List, Optional
+
+import click
+
+
+class MissingCMDObjectError(ValueError):
+ """raised if command object can't be found in its module"""
+ pass
+
+
+class CMDObjectSubclassError(TypeError):
+ """raised if a command object is not inheriting click.Command"""
+ pass
+
+
+class CMDUnregisteredError(ValueError):
+ """raised if commands have not been assigned to a command group"""
+
+ unregistered: List[str]
+
+ def __init__(self, unregistered):
+ self.unregistered = unregistered
+
+ super().__init__(f"Some commands were not registered in a group above: {unregistered!r}")
+
+
+class KiwiCLI(click.MultiCommand):
+ """Command Line Interface spread over multiple files in this directory"""
+
+ def list_commands(self, ctx: click.Context) -> List[str]:
+ """list all the commands defined by cmd_*.py files in this directory"""
+
+ return [
+ filename[4:-3]
+ for filename in os.listdir(os.path.abspath(os.path.dirname(__file__)))
+ if filename.startswith("cmd_") and filename.endswith(".py")
+ ]
+
+ def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]:
+ """import and return a specific command"""
+
+ try:
+ cmd_module = importlib.import_module(f"kiwi_scp.commands.cmd_{cmd_name}")
+
+ except ImportError:
+ return
+
+ cmd_object_name = f"{cmd_name.capitalize()}Command"
+
+ if cmd_object_name in dir(cmd_module):
+ cmd_object = getattr(cmd_module, cmd_object_name)
+
+ if isinstance(cmd_object, click.Command):
+ return cmd_object
+
+ else:
+ raise CMDObjectSubclassError()
+
+ else:
+ raise MissingCMDObjectError()
+
+ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
+ commands = {
+ "Operation": [
+ "up", "down", "restart", "update",
+ ],
+ "Instance Management": [
+ "init", "list",
+ ],
+ "Project and Service Management": [
+ "new", "enable", "disable", "logs", "shell", "cmd",
+ ],
+ "Image Handling": [
+ "build", "pull", "push",
+ ],
+ }
+
+ # allow for 3 times the default spacing
+ cmd_names = set(self.list_commands(ctx))
+ limit = formatter.width - 6 - max(len(cmd_name) for cmd_name in cmd_names)
+
+ for purpose, cmd_list in commands.items():
+ with formatter.section(_(f"Commands for {purpose}")):
+ formatter.write_dl([
+ (cmd_name, self.get_command(ctx, cmd_name).get_short_help_str(limit))
+ for cmd_name in cmd_list
+ ])
+
+ cmd_names -= set(cmd_list)
+
+ if len(cmd_names) > 0:
+ raise CMDUnregisteredError(cmd_names)
diff --git a/kiwi_scp/commands/cmd.py b/kiwi_scp/commands/cmd.py
new file mode 100644
index 0000000..70d20e2
--- /dev/null
+++ b/kiwi_scp/commands/cmd.py
@@ -0,0 +1,184 @@
+import logging
+import sys
+from enum import Enum, auto
+from typing import TypeVar, Iterable, Type, Optional, List
+
+import click
+
+from ..instance import Instance
+from ..project import Project
+from ..services import Services
+from ..wstring import WParagraph, WAlignment
+
+_logger = logging.getLogger(__name__)
+
+
+class KiwiCommandType(Enum):
+ INSTANCE = auto()
+ PROJECT = auto()
+ PROJECTS = auto()
+ SERVICES = auto()
+
+
+T = TypeVar("T")
+
+
+class KiwiCommandNotImplementedError(Exception):
+ pass
+
+
+class KiwiCommand:
+ type: KiwiCommandType = KiwiCommandType.SERVICES
+ enabled_only: bool = False
+
+ @staticmethod
+ def print_header(header: str) -> None:
+ click.secho(header, fg="green", bold=True)
+
+ @staticmethod
+ def print_error(error: str) -> None:
+ click.secho(error, file=sys.stderr, fg="red", bold=True)
+
+ @staticmethod
+ def print_list(content: Iterable[str]) -> None:
+ for item in content:
+ click.echo(click.style(" - ", fg="green") + click.style(item, fg="blue"))
+
+ @staticmethod
+ def user_query(description: str, default: T, cast_to: Type[T] = str) -> T:
+ # prompt user as per argument
+ while True:
+ try:
+ prompt = \
+ click.style(f"Enter {description} [", fg="green") + \
+ click.style(default, fg="blue") + \
+ click.style("] ", fg="green")
+ str_value = input(prompt).strip()
+ if str_value:
+ return cast_to(str_value)
+ else:
+ return default
+
+ except EOFError:
+ click.echo("Input aborted.")
+ return default
+
+ except Exception as e:
+ click.echo(f"Invalid input: {e}")
+
+ @staticmethod
+ def danger_confirm(*prompt_lines: str, default: Optional[bool] = None) -> bool:
+ if default is True:
+ suffix = "[YES|no]"
+ default_answer = "yes"
+
+ elif default is False:
+ suffix = "[yes|NO]"
+ default_answer = "no"
+
+ else:
+ suffix = "[yes|no]"
+ default_answer = None
+
+ dumb = WParagraph.from_strings(
+ click.style("WARNING", bold=True, underline=True, blink=True, fg="red"),
+ click.style("ここにゴミ", fg="cyan"),
+ click.style("を捨てないで下さい", fg="cyan"),
+ click.style("DO NOT DUMB HERE", fg="yellow"),
+ click.style("NO DUMB AREA", fg="yellow"),
+ ).align(WAlignment.CENTER).surround("!")
+
+ prompt = WParagraph.from_strings(*prompt_lines).align(WAlignment.LEFT).emphasize(3)
+
+ answer = input(
+ f"{dumb}\n\n"
+ f"{prompt}\n\n"
+ f"Are you sure you want to proceed? {suffix}: "
+ ).strip().lower()
+
+ if not answer:
+ answer = default_answer
+
+ while answer not in ["yes", "no"]:
+ answer = input("Please type 'yes' or 'no' explicitly: ").strip().lower()
+
+ return answer == "yes"
+
+ @classmethod
+ def run(cls, instance: Instance, project_names: List[str], service_names: List[str], **kwargs) -> None:
+
+ _logger.debug(f"{instance.directory!r}: {project_names!r}, {service_names!r}")
+
+ projects = instance.get_projects(project_names)
+
+ if not projects:
+ # run for whole instance
+ _logger.debug(f"running for instance, kwargs={kwargs}")
+ cls.run_for_instance(instance, **kwargs)
+
+ elif not service_names:
+ # run for entire project(s)
+ for project_name, project in projects.items():
+ if project is None:
+ _logger.debug(f"running for new project {project_name}, kwargs={kwargs}")
+ cls.run_for_new_project(instance, project_name, **kwargs)
+
+ else:
+ if cls.enabled_only and not project.config.enabled:
+ cls.print_error(f"Can't interact with disabled project {project_name}!")
+ return
+
+ _logger.debug(f"running for project {project.name}, kwargs={kwargs}")
+ cls.run_for_project(instance, project, **kwargs)
+
+ else:
+ # run for some services
+ project_name = list(projects)[0]
+ project = projects[project_name]
+
+ if project is None:
+ cls.print_error(f"Project '{project_name}' not in kiwi-scp instance at '{instance.directory}'!")
+
+ else:
+ if cls.enabled_only and not project.config.enabled:
+ cls.print_error(f"Can't interact with disabled project {project_name}!")
+ return
+
+ _logger.debug(f"running for services {service_names} in project {project_name}, kwargs={kwargs}")
+ cls.run_for_services(instance, project, service_names, **kwargs)
+
+ @classmethod
+ def run_for_instance(cls, instance: Instance, **kwargs) -> None:
+ for project in instance.projects:
+ if cls.enabled_only and not project.config.enabled:
+ cls.print_header(f"Skipping disabled project {project.name}")
+ continue
+
+ cls.run_for_project(instance, project, **kwargs)
+
+ @classmethod
+ def run_for_project(cls, instance: Instance, project: Project, **kwargs) -> None:
+ service_names = [service.name for service in project.services.content]
+ cls.run_for_services(instance, project, service_names, **kwargs)
+
+ @classmethod
+ def run_for_new_project(cls, instance: Instance, project_name: str, **kwargs) -> None:
+ cls.print_error(f"Project '{project_name}' not in kiwi-scp instance at '{instance.directory}'!")
+
+ @classmethod
+ def run_for_services(cls, instance: Instance, project: Project, service_names: List[str], **kwargs) -> None:
+ services = project.services.filter_existing(service_names)
+
+ new_service_names = [
+ service_name
+ for service_name
+ in service_names
+ if service_name not in list(services.names)
+ ]
+
+ cls.run_for_filtered_services(instance, project, services, new_service_names, **kwargs)
+
+ @classmethod
+ def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services,
+ new_service_names: List[str], **kwargs) -> None:
+ raise KiwiCommandNotImplementedError()
diff --git a/kiwi_scp/commands/cmd_build.py b/kiwi_scp/commands/cmd_build.py
new file mode 100644
index 0000000..720632c
--- /dev/null
+++ b/kiwi_scp/commands/cmd_build.py
@@ -0,0 +1,21 @@
+from typing import List
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .decorators import kiwi_command
+from ..executable import COMPOSE_EXE
+from ..instance import Instance
+from ..project import Project
+
+
+@kiwi_command(
+ short_help="Build docker images",
+)
+class BuildCommand(KiwiCommand):
+ """Build images for the whole instance, a project or service(s) inside a project"""
+
+ type = KiwiCommandType.SERVICES
+ enabled_only = True
+
+ @classmethod
+ def run_for_services(cls, instance: Instance, project: Project, service_names: List[str], **kwargs) -> None:
+ COMPOSE_EXE.run(["build", "--pull", *service_names], **project.process_kwargs)
diff --git a/kiwi_scp/commands/cmd_cmd.py b/kiwi_scp/commands/cmd_cmd.py
new file mode 100644
index 0000000..8a03692
--- /dev/null
+++ b/kiwi_scp/commands/cmd_cmd.py
@@ -0,0 +1,36 @@
+from typing import Tuple
+
+import click
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .decorators import kiwi_command
+from ..executable import COMPOSE_EXE
+from ..instance import Instance
+from ..project import Project
+
+
+@click.argument(
+ "compose_args",
+ metavar="[ARG]...",
+ nargs=-1,
+)
+@click.argument(
+ "compose_cmd",
+ metavar="COMMAND",
+)
+@kiwi_command(
+ short_help="Run docker-compose command",
+ # ignore arguments looking like options
+ # just pass everything down to docker-compose
+ context_settings={"ignore_unknown_options": True},
+)
+class CmdCommand(KiwiCommand):
+ """Run raw docker-compose command in a project"""
+
+ type = KiwiCommandType.PROJECT
+ enabled_only = True
+
+ @classmethod
+ def run_for_project(cls, instance: Instance, project: Project, compose_cmd: str = None,
+ compose_args: Tuple[str] = None) -> None:
+ COMPOSE_EXE.run([compose_cmd, *compose_args], **project.process_kwargs)
diff --git a/kiwi_scp/commands/cmd_disable.py b/kiwi_scp/commands/cmd_disable.py
new file mode 100644
index 0000000..e7ee373
--- /dev/null
+++ b/kiwi_scp/commands/cmd_disable.py
@@ -0,0 +1,40 @@
+import click
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .decorators import kiwi_command
+from .._constants import KIWI_CONF_NAME
+from ..instance import Instance
+from ..project import Project
+
+
+@click.option(
+ "-f/-F",
+ "--force/--no-force",
+ help=f"skip confirmation",
+)
+@kiwi_command()
+class DisableCommand(KiwiCommand):
+ """Disable project(s)"""
+
+ type = KiwiCommandType.PROJECTS
+
+ @classmethod
+ def run_for_instance(cls, instance: Instance, force: bool = None) -> None:
+ if not force:
+ if not KiwiCommand.danger_confirm("This will disable all projects in this instance."):
+ return
+
+ super().run_for_instance(instance)
+
+ @classmethod
+ def run_for_project(cls, instance: Instance, project: Project, **kwargs) -> None:
+ if not project.config.enabled:
+ KiwiCommand.print_error(f"Project {project.name} is already disabled!")
+ return
+
+ project.config.enabled = False
+ KiwiCommand.print_header(f"Project {project.name} disabled")
+
+ # write out the new kiwi.yml
+ with open(instance.directory.joinpath(KIWI_CONF_NAME), "w") as file:
+ instance.config.dump_kiwi_yml(file)
diff --git a/kiwi_scp/commands/cmd_down.py b/kiwi_scp/commands/cmd_down.py
new file mode 100644
index 0000000..2236e17
--- /dev/null
+++ b/kiwi_scp/commands/cmd_down.py
@@ -0,0 +1,57 @@
+from typing import List
+
+import click
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .decorators import kiwi_command
+from ..executable import COMPOSE_EXE
+from ..instance import Instance
+from ..project import Project
+from ..services import Services
+
+
+@click.option(
+ "-f/-F",
+ "--force/--no-force",
+ help=f"skip confirmation",
+)
+@kiwi_command(
+ short_help="Bring down kiwi services",
+)
+class DownCommand(KiwiCommand):
+ """Bring down the whole instance, a project or service(s) inside a project"""
+
+ type = KiwiCommandType.SERVICES
+ enabled_only = True
+
+ @classmethod
+ def run_for_instance(cls, instance: Instance, force: bool = None) -> None:
+ if not force:
+ if not KiwiCommand.danger_confirm(
+ "This will bring down the entire instance.",
+ "",
+ "This may not be what you intended, because:",
+ " - Bringing down the instance stops ALL services in here",
+ ):
+ return
+
+ super().run_for_instance(instance)
+ instance.remove_net()
+
+ @classmethod
+ def run_for_project(cls, instance: Instance, project: Project, **kwargs) -> None:
+ COMPOSE_EXE.run(["down"], **project.process_kwargs)
+
+ @classmethod
+ def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services,
+ new_service_names: List[str], **kwargs) -> None:
+ if not services:
+ if not click.confirm(
+ "Did not find any of those services. \n"
+ f"Bring down the entire project {project.name} instead?",
+ default=True
+ ):
+ return
+
+ COMPOSE_EXE.run(["stop", *services.names], **project.process_kwargs)
+ COMPOSE_EXE.run(["rm", "-f", *services.names], **project.process_kwargs)
diff --git a/kiwi_scp/commands/cmd_enable.py b/kiwi_scp/commands/cmd_enable.py
new file mode 100644
index 0000000..def3017
--- /dev/null
+++ b/kiwi_scp/commands/cmd_enable.py
@@ -0,0 +1,40 @@
+import click
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .decorators import kiwi_command
+from .._constants import KIWI_CONF_NAME
+from ..instance import Instance
+from ..project import Project
+
+
+@click.option(
+ "-f/-F",
+ "--force/--no-force",
+ help=f"skip confirmation",
+)
+@kiwi_command()
+class EnableCommand(KiwiCommand):
+ """Enable project(s)"""
+
+ type = KiwiCommandType.PROJECTS
+
+ @classmethod
+ def run_for_instance(cls, instance: Instance, force: bool = None) -> None:
+ if not force:
+ if not KiwiCommand.danger_confirm("This will enable all projects in this instance."):
+ return
+
+ super().run_for_instance(instance)
+
+ @classmethod
+ def run_for_project(cls, instance: Instance, project: Project, **kwargs) -> None:
+ if project.config.enabled:
+ KiwiCommand.print_error(f"Project {project.name} is already enabled!")
+ return
+
+ project.config.enabled = True
+ KiwiCommand.print_header(f"Project {project.name} enabled")
+
+ # write out the new kiwi.yml
+ with open(instance.directory.joinpath(KIWI_CONF_NAME), "w") as file:
+ instance.config.dump_kiwi_yml(file)
diff --git a/kiwi_scp/commands/cmd_init.py b/kiwi_scp/commands/cmd_init.py
new file mode 100644
index 0000000..a5ae22d
--- /dev/null
+++ b/kiwi_scp/commands/cmd_init.py
@@ -0,0 +1,71 @@
+import logging
+import os
+from ipaddress import IPv4Network
+from pathlib import Path
+
+import click
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .decorators import kiwi_command
+from .._constants import KIWI_CONF_NAME
+from ..config import KiwiConfig
+from ..instance import Instance
+
+_logger = logging.getLogger(__name__)
+
+
+@click.option(
+ "-d",
+ "--directory",
+ help=f"initialize a kiwi-scp instance in another directory",
+ type=click.Path(
+ path_type=Path,
+ dir_okay=True,
+ writable=True,
+ ),
+)
+@click.option(
+ "-f/-F",
+ "--force/--no-force",
+ help=f"use default values even if {KIWI_CONF_NAME} is present",
+)
+@kiwi_command(
+ short_help="Initializes kiwi-scp",
+)
+class InitCommand(KiwiCommand):
+ """Initialize or reconfigure a kiwi-scp instance"""
+
+ type = KiwiCommandType.INSTANCE
+
+ @classmethod
+ def run_for_instance(cls, instance: Instance, directory: Path = None, force: bool = None) -> None:
+ if directory is not None:
+ instance.directory = directory
+
+ current_config = KiwiConfig() if force else instance.config
+
+ # check force switch
+ if force and os.path.isfile(KIWI_CONF_NAME):
+ _logger.warning(f"About to overwrite an existing '{KIWI_CONF_NAME}'!")
+
+ # build new kiwi dict
+ kiwi_dict = current_config.kiwi_dict
+ kiwi_dict.update({
+ "version": KiwiCommand.user_query("kiwi-scp version to use in this instance", current_config.version),
+ "storage": {
+ "directory": KiwiCommand.user_query("local directory for service data",
+ current_config.storage.directory, Path),
+ },
+ "network": {
+ "name": KiwiCommand.user_query("name for local network hub", current_config.network.name),
+ "cidr": KiwiCommand.user_query("CIDRv4 block for local network hub", current_config.network.cidr,
+ IPv4Network),
+ },
+ })
+
+ # ensure output directory exists
+ if not os.path.isdir(instance.directory):
+ os.mkdir(instance.directory)
+
+ # write out the new kiwi.yml
+ instance.save_config(KiwiConfig.parse_obj(kiwi_dict))
diff --git a/kiwi_scp/commands/cmd_list.py b/kiwi_scp/commands/cmd_list.py
new file mode 100644
index 0000000..8fc82a4
--- /dev/null
+++ b/kiwi_scp/commands/cmd_list.py
@@ -0,0 +1,59 @@
+from typing import List
+
+import click
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .decorators import kiwi_command
+from ..instance import Instance
+from ..project import Project
+
+
+@click.option(
+ "-s/-S",
+ "--show/--no-show",
+ help=f"show actual config contents instead",
+)
+@kiwi_command(
+ short_help="Inspect a kiwi-scp instance",
+)
+class ListCommand(KiwiCommand):
+ """List projects in this instance, services inside a project or service(s) inside a project"""
+
+ type = KiwiCommandType.SERVICES
+
+ @classmethod
+ def run_for_instance(cls, instance: Instance, show: bool = None) -> None:
+ if show:
+ KiwiCommand.print_header(f"Showing config for kiwi-scp instance at '{instance.directory}'.")
+ click.echo_via_pager(instance.config.kiwi_yml)
+
+ else:
+ KiwiCommand.print_header(f"Projects in kiwi-scp instance at '{instance.directory}':")
+ KiwiCommand.print_list(
+ project.name + (click.style(" (disabled)", fg="red") if not project.enabled else "")
+ for project in instance.config.projects
+ )
+
+ @classmethod
+ def run_for_project(cls, instance: Instance, project: Project, show: bool = None) -> None:
+ if show:
+ KiwiCommand.print_header(f"Showing config for all services in project '{project.name}'.")
+ click.echo_via_pager(str(project.services))
+
+ else:
+ KiwiCommand.print_header(f"Services in project '{project.name}':")
+ KiwiCommand.print_list(service.name for service in project.services.content)
+
+ @classmethod
+ def run_for_services(cls, instance: Instance, project: Project, service_names: List[str],
+ show: bool = None) -> None:
+ services = project.services.filter_existing(service_names)
+ if show:
+ service_names = [service.name for service in services.content]
+ KiwiCommand.print_header(
+ f"Showing config for matching services '{', '.join(service_names)}' in project '{project.name}'.")
+ click.echo_via_pager(str(services))
+
+ else:
+ KiwiCommand.print_header(f"Matching services in project '{project.name}':")
+ KiwiCommand.print_list(service.name for service in services.content)
diff --git a/kiwi_scp/commands/cmd_logs.py b/kiwi_scp/commands/cmd_logs.py
new file mode 100644
index 0000000..d7669a1
--- /dev/null
+++ b/kiwi_scp/commands/cmd_logs.py
@@ -0,0 +1,44 @@
+from typing import List
+
+import click
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .decorators import kiwi_command
+from ..executable import COMPOSE_EXE
+from ..instance import Instance
+from ..project import Project
+from ..services import Services
+
+
+@click.option(
+ "-f/-F",
+ "--follow/--no-follow",
+ help="output appended data as log grows",
+)
+@kiwi_command(
+ short_help="Show logs",
+)
+class LogsCommand(KiwiCommand):
+ """Show logs of a project or service(s) inside a project"""
+
+ type = KiwiCommandType.SERVICES
+ enabled_only = True
+
+ @classmethod
+ def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services,
+ new_service_names: List[str], follow: bool = None) -> None:
+ # include timestamps
+ compose_cmd = ["logs", "-t"]
+
+ # handle following the log output
+ if follow:
+ compose_cmd.extend(("-f", "--tail=10"))
+
+ compose_cmd.extend(services.names)
+
+ if follow:
+ COMPOSE_EXE.run(compose_cmd, **project.process_kwargs)
+
+ else:
+ # output is static, use pager
+ COMPOSE_EXE.run_with_pager(compose_cmd, **project.process_kwargs)
diff --git a/kiwi_scp/commands/cmd_new.py b/kiwi_scp/commands/cmd_new.py
new file mode 100644
index 0000000..0ac1394
--- /dev/null
+++ b/kiwi_scp/commands/cmd_new.py
@@ -0,0 +1,41 @@
+import os
+import shutil
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .decorators import kiwi_command
+from .._constants import DEFAULT_DOCKER_COMPOSE_NAME, COMPOSE_FILE_NAME, RESERVED_PROJECT_NAMES
+from ..config import ProjectConfig
+from ..instance import Instance
+from ..project import Project
+
+
+@kiwi_command()
+class NewCommand(KiwiCommand):
+ """Create new empty project(s) in this instance"""
+
+ type = KiwiCommandType.PROJECTS
+
+ @classmethod
+ def run_for_project(cls, instance: Instance, project: Project, **kwargs) -> None:
+ KiwiCommand.print_error(f"Project {project.name} already exists!")
+
+ @classmethod
+ def run_for_new_project(cls, instance: Instance, project_name: str, **kwargs) -> None:
+ if project_name in RESERVED_PROJECT_NAMES:
+ KiwiCommand.print_error(f"Project name '{project_name}' is reserved!")
+ return
+
+ try:
+ os.mkdir(project_name)
+ instance.config.projects.append(ProjectConfig(
+ name=project_name,
+ enabled=False,
+ ))
+ shutil.copy(
+ DEFAULT_DOCKER_COMPOSE_NAME,
+ instance.directory.joinpath(project_name).joinpath(COMPOSE_FILE_NAME)
+ )
+ instance.save_config(instance.config)
+
+ except FileExistsError:
+ KiwiCommand.print_error(f"Project directory {project_name} already exists!")
diff --git a/kiwi_scp/commands/cmd_pull.py b/kiwi_scp/commands/cmd_pull.py
new file mode 100644
index 0000000..b5f0517
--- /dev/null
+++ b/kiwi_scp/commands/cmd_pull.py
@@ -0,0 +1,33 @@
+from typing import List
+
+import click
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .decorators import kiwi_command
+from ..executable import COMPOSE_EXE
+from ..instance import Instance
+from ..project import Project
+from ..services import Services
+
+
+@kiwi_command(
+ short_help="Pull docker images",
+)
+class PullCommand(KiwiCommand):
+ """Pull images for the whole instance, a project or service(s) inside a project"""
+
+ type = KiwiCommandType.SERVICES
+ enabled_only = True
+
+ @classmethod
+ def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services,
+ new_service_names: List[str], **kwargs) -> None:
+ if not services:
+ if not click.confirm(
+ "Did not find any of those services. \n"
+ f"Pull images for the entire project {project.name} instead?",
+ default=True
+ ):
+ return
+
+ COMPOSE_EXE.run(["pull", "--ignore-pull-failures", *services.names], **project.process_kwargs)
diff --git a/kiwi_scp/commands/cmd_push.py b/kiwi_scp/commands/cmd_push.py
new file mode 100644
index 0000000..999677b
--- /dev/null
+++ b/kiwi_scp/commands/cmd_push.py
@@ -0,0 +1,33 @@
+from typing import List
+
+import click
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .decorators import kiwi_command
+from ..executable import COMPOSE_EXE
+from ..instance import Instance
+from ..project import Project
+from ..services import Services
+
+
+@kiwi_command(
+ short_help="Push docker images",
+)
+class PushCommand(KiwiCommand):
+ """Push images for the whole instance, a project or service(s) inside a project"""
+
+ type = KiwiCommandType.SERVICES
+ enabled_only = True
+
+ @classmethod
+ def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services,
+ new_service_names: List[str], **kwargs) -> None:
+ if not services:
+ if not click.confirm(
+ "Did not find any of those services. \n"
+ f"Push images for the entire project {project.name} instead?",
+ default=True
+ ):
+ return
+
+ COMPOSE_EXE.run(["push", *services.names], **project.process_kwargs)
diff --git a/kiwi_scp/commands/cmd_restart.py b/kiwi_scp/commands/cmd_restart.py
new file mode 100644
index 0000000..fbd1cf3
--- /dev/null
+++ b/kiwi_scp/commands/cmd_restart.py
@@ -0,0 +1,48 @@
+from typing import List
+
+import click
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .decorators import kiwi_command
+from ..executable import COMPOSE_EXE
+from ..instance import Instance
+from ..project import Project
+from ..services import Services
+
+
+@click.option(
+ "-f/-F",
+ "--force/--no-force",
+ help=f"skip confirmation",
+)
+@kiwi_command(
+ short_help="Restart kiwi services",
+)
+class RestartCommand(KiwiCommand):
+ """Restart the whole instance, a project or service(s) inside a project"""
+
+ type = KiwiCommandType.SERVICES
+ enabled_only = True
+
+ @classmethod
+ def run_for_instance(cls, instance: Instance, force: bool = None) -> None:
+ if not force:
+ if not KiwiCommand.danger_confirm(
+ "This will restart the entire instance.",
+ ):
+ return
+
+ super().run_for_instance(instance)
+
+ @classmethod
+ def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services,
+ new_service_names: List[str], **kwargs) -> None:
+ if not services:
+ if not click.confirm(
+ "Did not find any of those services. \n"
+ f"Restart the entire project {project.name} instead?",
+ default=True
+ ):
+ return
+
+ COMPOSE_EXE.run(["restart", *services.names], **project.process_kwargs)
diff --git a/kiwi_scp/commands/cmd_shell.py b/kiwi_scp/commands/cmd_shell.py
new file mode 100644
index 0000000..2d1ca23
--- /dev/null
+++ b/kiwi_scp/commands/cmd_shell.py
@@ -0,0 +1,73 @@
+import logging
+from typing import List, Optional
+
+import click
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .decorators import kiwi_command
+from ..executable import COMPOSE_EXE
+from ..instance import Instance
+from ..project import Project
+from ..services import Services
+
+_logger = logging.getLogger(__name__)
+
+
+@click.option(
+ "-s", "--shell",
+ help="shell to spawn",
+ type=str,
+)
+@click.option(
+ "-u", "--user",
+ help="container user to run shell",
+ type=str,
+)
+@kiwi_command(
+ short_help="Spawn shell",
+)
+class ShellCommand(KiwiCommand):
+ """Spawn shell inside a project's service"""
+
+ type = KiwiCommandType.SERVICES
+ enabled_only = True
+
+ @classmethod
+ def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services,
+ new_service_names: List[str], shell: Optional[str] = None,
+ user: Optional[str] = None) -> None:
+ # shells from KiwiConfig
+ shells = [
+ *(str(path) for path in instance.config.shells),
+ # as a last resort, fall back to "/bin/sh" and "sh"
+ "/bin/sh", "sh",
+ ]
+
+ # add shell from argument
+ if shell is not None:
+ shells.insert(0, shell)
+
+ user_args = ["-u", user] if user is not None else []
+
+ for service in services.content:
+ try:
+ use_shell = next(service.existing_executables(shells))
+ _logger.debug(f"Using shell {use_shell!r}")
+
+ except StopIteration:
+ if shell is not None:
+ use_shell = shell
+ _logger.warning(
+ "Could not find a working shell in this container. "
+ f"Launching provided shell {use_shell!r} nevertheless. This might fail!"
+ )
+
+ else:
+ _logger.warning(
+ f"Could not find any working shell among {shells!r} in this container. "
+ "Please suggest a shell using the '-s SHELL' command line option!"
+ )
+ continue
+
+ # spawn shell
+ COMPOSE_EXE.run(['exec', *user_args, service.name, use_shell], **project.process_kwargs)
diff --git a/kiwi_scp/commands/cmd_up.py b/kiwi_scp/commands/cmd_up.py
new file mode 100644
index 0000000..d1004ce
--- /dev/null
+++ b/kiwi_scp/commands/cmd_up.py
@@ -0,0 +1,34 @@
+from typing import List
+
+import click
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .decorators import kiwi_command
+from ..executable import COMPOSE_EXE
+from ..instance import Instance
+from ..project import Project
+from ..services import Services
+
+
+@kiwi_command(short_help="Bring up kiwi services")
+class UpCommand(KiwiCommand):
+ """Bring up the whole instance, a project or service(s) inside a project"""
+
+ type = KiwiCommandType.SERVICES
+ enabled_only = True
+
+ @classmethod
+ def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services,
+ new_service_names: List[str], **kwargs) -> None:
+ if not services:
+ if not click.confirm(
+ "Did not find any of those services. \n"
+ f"Bring up the entire project {project.name} instead?",
+ default=True
+ ):
+ return
+
+ instance.create_net()
+ services.copy_configs()
+
+ COMPOSE_EXE.run(["up", "-d", *services.names], **project.process_kwargs)
diff --git a/kiwi_scp/commands/cmd_update.py b/kiwi_scp/commands/cmd_update.py
new file mode 100644
index 0000000..630e8dc
--- /dev/null
+++ b/kiwi_scp/commands/cmd_update.py
@@ -0,0 +1,65 @@
+from typing import List
+
+import click
+from click import get_current_context
+
+from .cmd import KiwiCommandType, KiwiCommand
+from .cmd_build import BuildCommand
+from .cmd_down import DownCommand
+from .cmd_pull import PullCommand
+from .cmd_up import UpCommand
+from .decorators import kiwi_command
+from ..instance import Instance
+from ..project import Project
+from ..services import Services
+
+
+@click.option(
+ "-f/-F",
+ "--force/--no-force",
+ help=f"skip confirmation",
+)
+@kiwi_command(
+ short_help="Update kiwi services",
+)
+class UpdateCommand(KiwiCommand):
+ """Update the whole instance, a project or service(s) inside a project"""
+
+ type = KiwiCommandType.SERVICES
+ enabled_only = True
+
+ @classmethod
+ def run_for_instance(cls, instance: Instance, force: bool = None) -> None:
+ if not force:
+ if not KiwiCommand.danger_confirm(
+ "This will update the entire instance at once.",
+ "",
+ "This may not be what you intended, because:",
+ " - Updates may take a long time",
+ " - Updates may break beloved functionality",
+ ):
+ return
+
+ super().run_for_instance(instance)
+
+ @classmethod
+ def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services,
+ new_service_names: List[str], **kwargs) -> None:
+ if not services:
+ if not click.confirm(
+ "Did not find any of those services. \n"
+ f"Update the entire project {project.name} instead?",
+ default=True
+ ):
+ return
+
+ ctx = get_current_context()
+ assert isinstance(BuildCommand, click.Command)
+ ctx.forward(BuildCommand)
+ assert isinstance(PullCommand, click.Command)
+ ctx.forward(PullCommand)
+ services.copy_configs()
+ assert isinstance(DownCommand, click.Command)
+ ctx.forward(DownCommand)
+ assert isinstance(UpCommand, click.Command)
+ ctx.forward(UpCommand)
diff --git a/kiwi_scp/commands/decorators.py b/kiwi_scp/commands/decorators.py
new file mode 100644
index 0000000..7b28fc9
--- /dev/null
+++ b/kiwi_scp/commands/decorators.py
@@ -0,0 +1,83 @@
+from typing import Callable, Type, Optional, Tuple
+
+import click
+
+from .cmd import KiwiCommandType, KiwiCommand
+from ..instance import Instance
+
+_pass_instance = click.make_pass_decorator(
+ Instance,
+ ensure=True,
+)
+
+_project_arg = click.argument(
+ "project_name",
+ metavar="PROJECT",
+ type=str,
+)
+
+_projects_arg = click.argument(
+ "project_names",
+ metavar="[PROJECT]...",
+ nargs=-1,
+ type=str,
+)
+
+_services_arg_p = click.argument(
+ "project_name",
+ metavar="[PROJECT]",
+ required=False,
+ type=str,
+)
+
+_services_arg_s = click.argument(
+ "service_names",
+ metavar="[SERVICE]...",
+ nargs=-1,
+ type=str,
+)
+
+
+def kiwi_command(
+ **decorator_kwargs,
+) -> Callable:
+ def decorator(command_cls: Type[KiwiCommand]) -> Callable:
+
+ @click.command(
+ help=command_cls.__doc__,
+ **decorator_kwargs,
+ )
+ @_pass_instance
+ def cmd(ctx: Instance, project_name: Optional[str] = None, project_names: Optional[Tuple[str]] = None,
+ service_names: Optional[Tuple[str]] = None, **kwargs) -> None:
+ if command_cls.type is KiwiCommandType.INSTANCE:
+ project_names = []
+
+ elif command_cls.type is KiwiCommandType.PROJECTS:
+ project_names = list(project_names)
+
+ else:
+ if project_name is None:
+ project_names = []
+
+ else:
+ project_names = [project_name]
+
+ if command_cls.type is KiwiCommandType.SERVICES:
+ service_names = list(service_names)
+
+ command_cls.run(ctx, project_names, service_names, **kwargs)
+
+ if command_cls.type is KiwiCommandType.PROJECT:
+ cmd = _project_arg(cmd)
+
+ elif command_cls.type is KiwiCommandType.PROJECTS:
+ cmd = _projects_arg(cmd)
+
+ elif command_cls.type is KiwiCommandType.SERVICES:
+ cmd = _services_arg_p(cmd)
+ cmd = _services_arg_s(cmd)
+
+ return cmd
+
+ return decorator
diff --git a/kiwi_scp/config.py b/kiwi_scp/config.py
index 00c0dfc..c60f81c 100644
--- a/kiwi_scp/config.py
+++ b/kiwi_scp/config.py
@@ -1,157 +1,402 @@
-# system
-import copy
-import logging
-import os
-import re
-import yaml
+import functools
+from ipaddress import IPv4Network
+from pathlib import Path
+from typing import Optional, Dict, List, Any, TextIO, Tuple
-# local
-from ._constants import KIWI_CONF_NAME, HEADER_KIWI_CONF_NAME, DEFAULT_KIWI_CONF_NAME, VERSION_TAG_NAME
+from pydantic import BaseModel, constr, root_validator, validator
+
+from ._constants import RE_SEMVER, RE_VARNAME, KIWI_CONF_NAME, RESERVED_PROJECT_NAMES
+from .yaml import YAML
-class Config:
+class InvalidFormatError(ValueError):
+ """raised if format recognition unsuccessful"""
+
+ cls: type
+ member: Optional[str]
+ data: str
+
+ def __init__(self, cls, data, member = None):
+ self.cls = cls
+ self.data = data
+
+ if member is not None:
+ self.member = member
+ super().__init__(f"Invalid {self.cls.__name__!r}.{self.member!r} Format: {self.data!r}")
+
+ else:
+ super().__init__(f"Invalid {self.cls.__name__!r} Format: {self.data!r}")
+
+
+class StorageConfig(BaseModel):
+ """a storage subsection"""
+
+ directory: Path
+
+ @property
+ def kiwi_dict(self) -> Dict[str, Any]:
+ """write this object as a dictionary of strings"""
+
+ return {"directory": str(self.directory)}
+
+ @root_validator(pre=True)
+ @classmethod
+ def unify_storage(cls, values) -> Dict[str, Any]:
+ """parse different storage notations"""
+
+ if "directory" in values:
+ # default format
+ return values
+
+ else:
+ # undefined format
+ raise InvalidFormatError(cls, str(values))
+
+
+class ProjectNameReservedError(ValueError):
+ """raised if trying to create a project with a reserved name"""
+
+ name: str
+
+ def __init__(self, name):
+ self.name = name
+ super().__init__(f"Project name {self.name!r} is reserved!")
+
+
+class ProjectConfig(BaseModel):
+ """a project subsection"""
+
+ name: constr(regex=RE_VARNAME)
+ enabled: bool = True
+ override_storage: Optional[StorageConfig]
+
+ @property
+ def kiwi_dict(self) -> Dict[str, Any]:
+ """write this object as a dictionary of strings"""
+
+ result = self.dict(exclude={"override_storage"})
+
+ if self.override_storage is not None:
+ result["override_storage"] = self.override_storage.kiwi_dict
+
+ return result
+
+ @validator("name")
+ @classmethod
+ def check_project(cls, value: str) -> str:
+ """check if project name is allowed"""
+
+ if value in RESERVED_PROJECT_NAMES:
+ raise ProjectNameReservedError(value)
+
+ return value
+
+ @validator("override_storage", pre=True)
+ @classmethod
+ def unify_storage(cls, value) -> Dict[str, Any]:
+ """parse different storage notations"""
+
+ if value is None or isinstance(value, dict):
+ return value
+
+ elif isinstance(value, str):
+ return {"directory": value}
+
+ elif isinstance(value, list) and len(value) == 1:
+ return {"directory": value[0]}
+
+ else:
+ # undefined format
+ return {}
+
+ @root_validator(pre=True)
+ @classmethod
+ def unify_project(cls, values) -> Dict[str, Any]:
+ """parse different project notations"""
+
+ if "name" in values:
+ # default format
+ return values
+
+ elif len(values) == 1:
+ # short format:
+ # - :
+
+ name, enabled = list(values.items())[0]
+ return {
+ "name": name,
+ "enabled": True if enabled is None else enabled,
+ }
+
+ else:
+ # undefined format
+ raise InvalidFormatError(ProjectConfig, values)
+
+
+class NetworkConfig(BaseModel):
+ """a network subsection"""
+
+ name: constr(to_lower=True, regex=RE_VARNAME)
+ cidr: IPv4Network
+
+ @property
+ def kiwi_dict(self) -> Dict[str, Any]:
+ """write this object as a dictionary of strings"""
+
+ return {
+ "name": self.name,
+ "cidr": str(self.cidr),
+ }
+
+
+class MissingMemberError(ValueError):
+ """raised if class member is missing a definition"""
+
+ cls: type
+ member: str
+
+ def __init__(self, cls, member):
+ self.cls = cls
+ self.member = member
+ super().__init__(f"Member {self.cls.__name__!r}.{self.member!r} is required!")
+
+
+class KiwiConfig(BaseModel):
"""represents a kiwi.yml"""
- __yml_content = {}
- __keys = {
- 'version': "kiwi-scp version to use in this instance",
+ version: constr(regex=RE_SEMVER) = "0.2.0"
- 'runtime:storage': "local directory for service data",
- 'runtime:shells': "shell preference for working in service containers",
- 'runtime:env': "common environment for compose yml",
+ shells: List[Path] = [
+ Path("/bin/bash"),
+ ]
- 'markers:project': "marker string for project directories",
- 'markers:disabled': "marker string for disabled projects",
+ projects: List[ProjectConfig] = []
- 'network:name': "name for local network hub",
- 'network:cidr': "CIDR block for local network hub",
- }
+ environment: Dict[str, Optional[str]] = {}
- def __key_resolve(self, key):
- """
- Resolve nested dictionaries
+ storage: StorageConfig = StorageConfig(
+ directory="/var/local/kiwi",
+ )
- If __yml_content is {'a': {'b': {'c': "val"}}} and key is 'a:b:c',
- this returns a single dict {'c': "val"} and the direct key 'c'
- """
+ network: NetworkConfig = NetworkConfig(
+ name="kiwi_hub",
+ cidr="10.22.46.0/24",
+ )
- # "a:b:c" => path = ['a', 'b'], key = 'c'
- path = key.split(':')
- path, key = path[:-1], path[-1]
+ @classmethod
+ @functools.lru_cache(maxsize=5)
+ def from_directory(cls, directory: Path) -> "KiwiConfig":
+ """parses an actual kiwi.yml from disk (cached)"""
- # resolve path
- container = self.__yml_content
- for step in path:
- container = container[step]
-
- return container, key
-
- def __getitem__(self, key):
- """array-like read access to __yml_content"""
-
- container, key = self.__key_resolve(key)
- return container[key]
-
- def __setitem__(self, key, value):
- """array-like write access to __yml_content"""
-
- container, key = self.__key_resolve(key)
- container[key] = value
-
- def __str__(self):
- """dump into textual representation"""
-
- # dump yml content
- yml_string = yaml.dump(
- self.__yml_content,
- default_flow_style=False, sort_keys=False
- ).strip()
-
- # insert newline before every main key
- yml_string = re.sub(r'^(\S)', r'\n\1', yml_string, flags=re.MULTILINE)
-
- # load header comment from file
- with open(HEADER_KIWI_CONF_NAME, 'r') as stream:
- yml_string = stream.read() + yml_string
-
- return yml_string
-
- def _update_from_file(self, filename):
- """return a copy updated using a kiwi.yml file"""
-
- with open(filename, 'r') as stream:
- try:
- # create copy
- result = Config()
- result.__yml_content = copy.deepcopy(self.__yml_content)
-
- # read file
- logging.debug(f"Reading '{filename}' into '{id(result.__yml_content)}'")
- result.__yml_content.update(yaml.safe_load(stream))
-
- return result
- except yaml.YAMLError as exc:
- logging.error(exc)
-
- def user_query(self, key):
- """query user for new config value"""
-
- # prompt user as per argument
try:
- result = input(f"Enter {self.__keys[key]} [{self[key]}] ").strip()
- except EOFError:
- print()
- result = None
+ with open(directory.joinpath(KIWI_CONF_NAME)) as kc:
+ return cls.parse_obj(YAML().load(kc))
- # store result if present
- if result:
- self[key] = result
-
- def save(self):
- """save current yml representation in current directory's kiwi.yml"""
-
- with open(KIWI_CONF_NAME, 'w') as stream:
- stream.write(str(self))
- stream.write('\n')
-
-
-class DefaultConfig(Config):
- """Singleton: The default kiwi.yml file"""
-
- __instance = None
+ except FileNotFoundError:
+ # return the defaults if no kiwi.yml found
+ return cls.from_default()
@classmethod
- def get(cls):
- if cls.__instance is None:
- # create singleton
- cls.__instance = cls()._update_from_file(DEFAULT_KIWI_CONF_NAME)
+ @functools.lru_cache(maxsize=1)
+ def from_default(cls) -> "KiwiConfig":
+ """returns the default config (cached)"""
- # add version data from separate file (keeps default config cleaner)
- with open(VERSION_TAG_NAME, 'r') as stream:
- cls.__instance['version'] = stream.read().strip()
+ return cls()
- # return singleton
- return cls.__instance
+ def get_project_config(self, name: str) -> Optional[ProjectConfig]:
+ """returns the config of a project with a given name"""
+ for project in self.projects:
+ if project.name == name:
+ return project
-class LoadedConfig(Config):
- """Singleton collection: kiwi.yml files by path"""
+ @property
+ def kiwi_dict(self) -> Dict[str, Any]:
+ """write this object as a dictionary of strings"""
- __instances = {}
+ result = {
+ "version": self.version,
+ "shells": [str(shell) for shell in self.shells],
+ }
+ if self.projects:
+ result["projects"] = [
+ project.kiwi_dict
+ for project in self.projects
+ ]
+
+ if self.environment:
+ result["environment"] = self.environment
+
+ result["storage"] = self.storage.kiwi_dict
+
+ result["network"] = self.network.kiwi_dict
+
+ return result
+
+ def dump_kiwi_yml(self, stream: TextIO = None) -> Optional[str]:
+ """dump a kiwi.yml file"""
+
+ return YAML().dump_kiwi_yml(self.kiwi_dict, stream=stream)
+
+ @property
+ def kiwi_yml(self) -> str:
+ """get a kiwi.yml dump as a string"""
+
+ return self.dump_kiwi_yml()
+
+ @validator("shells", pre=True)
@classmethod
- def get(cls, directory='.'):
- if directory not in LoadedConfig.__instances:
- # create singleton for new path
- result = DefaultConfig.get()
+ def unify_shells(cls, value) -> List[str]:
+ """parse different shells notations"""
- # update with that dir's kiwi.yml
+ if value is None:
+ return []
+
+ elif isinstance(value, list):
+ return value
+
+ elif isinstance(value, dict):
+ return list(value)
+
+ else:
+ # any other format (try to coerce to str first)
try:
- result = result._update_from_file(os.path.join(directory, KIWI_CONF_NAME))
- except FileNotFoundError:
- logging.info(f"No '{KIWI_CONF_NAME}' found at '{directory}'. Using defaults.")
+ return [str(value)]
- LoadedConfig.__instances[directory] = result
+ except Exception:
+ # undefined format
+ raise InvalidFormatError(KiwiConfig, value, "shells")
- # return singleton
- return LoadedConfig.__instances[directory]
+ @validator("projects", pre=True)
+ @classmethod
+ def unify_projects(cls, value) -> List[Dict[str, str]]:
+ """parse different projects notations"""
+
+ if value is None:
+ # empty projects list
+ return []
+
+ elif isinstance(value, list):
+ # handle projects list
+
+ result = []
+ for entry in value:
+ # ignore empties
+ if entry is not None:
+ if isinstance(entry, dict):
+ # handle single project dict
+ result.append(entry)
+
+ else:
+ try:
+ # handle single project name
+ result.append({"name": str(entry)})
+
+ except Exception:
+ # undefined format
+ raise InvalidFormatError(KiwiConfig, value, "projects")
+
+ return result
+
+ elif isinstance(value, dict):
+ # handle single project dict
+ return [value]
+
+ else:
+ # any other format (try to coerce to str first)
+ try:
+ # handle as a single project name
+ return [{"name": str(value)}]
+
+ except Exception:
+ # undefined format
+ raise InvalidFormatError(KiwiConfig, value, "projects")
+
+ @validator("environment", pre=True)
+ @classmethod
+ def unify_environment(cls, value) -> Dict[str, Optional[str]]:
+ """parse different environment notations"""
+
+ def parse_str(var_val: Any) -> Tuple[str, Optional[str]]:
+ """parse a "=" string"""
+
+ try:
+ idx = str(var_val).find("=")
+ except Exception:
+ # undefined format
+ raise InvalidFormatError(KiwiConfig, value, "environment")
+
+ if idx == -1:
+ # don't split, just define the variable
+ return var_val, None
+ else:
+ # split string, set variable to value
+ return var_val[:idx], var_val[idx + 1:]
+
+ if value is None:
+ # empty environment
+ return {}
+
+ elif isinstance(value, dict):
+ # native dict format
+ return value
+
+ elif isinstance(value, list):
+ # list format (multiple strings)
+
+ result: Dict[str, Optional[str]] = {}
+ for item in value:
+ key, value = parse_str(item)
+ result[key] = value
+
+ return result
+
+ else:
+ # any other format (try to coerce to str first)
+ # string format (single variable):
+ # "="
+ key, value = parse_str(value)
+ return {key: value}
+
+ @validator("storage", pre=True)
+ @classmethod
+ def unify_storage(cls, value) -> Dict[str, Any]:
+ """parse different storage notations"""
+
+ if value is None:
+ # empty storage
+ raise MissingMemberError(KiwiConfig, "storage")
+
+ elif isinstance(value, dict):
+ # native dict format
+ return value
+
+ elif isinstance(value, str):
+ # just the directory string
+ return {"directory": value}
+
+ elif isinstance(value, list) and len(value) == 1 and isinstance(value[0], str):
+ # directory string as a single-item list
+ return {"directory": value[0]}
+
+ else:
+ # undefined format
+ return {}
+
+ @validator("network", pre=True)
+ @classmethod
+ def unify_network(cls, value) -> Dict[str, Any]:
+ """parse different network notations"""
+
+ if value is None:
+ # empty network
+ raise MissingMemberError(KiwiConfig, "network")
+
+ elif isinstance(value, dict):
+ # native dict format
+ return value
+
+ else:
+ # undefined format
+ raise InvalidFormatError(KiwiConfig, value, "network")
diff --git a/kiwi_scp/data/etc/command_help.txt b/kiwi_scp/data/etc/command_help.txt
deleted file mode 100644
index 33c0376..0000000
--- a/kiwi_scp/data/etc/command_help.txt
+++ /dev/null
@@ -1,22 +0,0 @@
-Commands for Operation:
- up Bring up the whole instance, a project or service(s) inside a project
- down Bring down the whole instance, a project or service(s) inside a project
- update Update the whole instance, a project or service(s) inside a project
- restart Restart the whole instance, a project or service(s) inside a project
-
-Commands for Instance Management:
- init Initialize or reconfigure kiwi-scp instance
- show Show projects in this instance, services inside a project or service(s) inside a project
- cmd Run raw docker-compose command in a project
-
-Commands for Project and Service Management:
- new Create new empty project(s) in this instance
- enable Enable project(s) in this instance
- disable Disable project(s) in this instance
- logs Show logs of a project or service(s) inside a project
- shell Spawn shell inside a service inside a project
-
-Commands for Image Handling:
- build Build images for the whole instance, a project or service(s) inside a project
- pull Pull images for the whole instance, a project or service(s) inside a project
- push Push images for the whole instance, a project or service(s) inside a project
\ No newline at end of file
diff --git a/kiwi_scp/data/etc/docker-compose_default.yml b/kiwi_scp/data/etc/docker-compose_default.yml
index d3cbd6d..cc6476a 100644
--- a/kiwi_scp/data/etc/docker-compose_default.yml
+++ b/kiwi_scp/data/etc/docker-compose_default.yml
@@ -10,6 +10,10 @@ networks:
name: ${KIWI_HUB_NAME}
services:
+ ######################
+ # START EDITING HERE #
+ ######################
+
# an example service
something:
# uses an image
diff --git a/kiwi_scp/data/etc/kiwi_default.yml b/kiwi_scp/data/etc/kiwi_default.yml
deleted file mode 100644
index 1404379..0000000
--- a/kiwi_scp/data/etc/kiwi_default.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-version:
-runtime:
- storage: /var/kiwi
- shells:
- - /bin/bash
- env: null
-markers:
- project: .project
- disabled: .disabled
-network:
- name: kiwi_hub
- cidr: 10.22.46.0/24
diff --git a/kiwi_scp/data/etc/kiwi_help.txt b/kiwi_scp/data/etc/kiwi_help.txt
deleted file mode 100644
index 08d0224..0000000
--- a/kiwi_scp/data/etc/kiwi_help.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-kiwi is the simple tool for managing container servers.
-
-Features:
- - Group services into projects using their own docker-compose.yml
- - Bind to the local file system by using ${TARGETDIR} as volume in docker-compose.yml
- - Add instance-global config files by using ${CONFDIR} as volume in docker-compose.yml
- - Add instance-global custom values inside docker-compose.yml using config:runtime:env
- - Build service-specific, private docker images from Dockerfiles
- - Check full instances into any version control system
diff --git a/kiwi_scp/data/etc/version_tag b/kiwi_scp/data/etc/version_tag
deleted file mode 100644
index 1180819..0000000
--- a/kiwi_scp/data/etc/version_tag
+++ /dev/null
@@ -1 +0,0 @@
-0.1.7
diff --git a/kiwi_scp/executable.py b/kiwi_scp/executable.py
index 1c054f5..184e41e 100644
--- a/kiwi_scp/executable.py
+++ b/kiwi_scp/executable.py
@@ -1,74 +1,64 @@
-# system
+import functools
import logging
import os
import subprocess
+from pathlib import Path
+from typing import Optional, List
+
+import attr
+
+_logger = logging.getLogger(__name__)
-def _is_executable(filename):
- if filename is None:
- return False
-
- return os.path.isfile(filename) and os.access(filename, os.X_OK)
-
-
-def _find_exe_file(exe_name):
- for path in os.environ['PATH'].split(os.pathsep):
- exe_file = os.path.join(path, exe_name)
- if _is_executable(exe_file):
- return exe_file
-
- raise FileNotFoundError(f"Executable '{exe_name}' not found in $PATH!")
-
-
+@attr.s
class Executable:
- class __Executable:
- __exe_path = None
+ exe_name: str = attr.ib()
- def __init__(self, exe_name):
- self.__exe_path = _find_exe_file(exe_name)
+ @staticmethod
+ @functools.lru_cache(maxsize=None)
+ def __find_exe_file(exe_name: str) -> Optional[Path]:
+ for path in os.environ["PATH"].split(os.pathsep):
+ exe_file = Path(path).joinpath(exe_name)
+ if os.path.isfile(exe_file) and os.access(exe_file, os.X_OK):
+ return exe_file
- def __build_cmd(self, args, kwargs):
- cmd = [self.__exe_path, *args]
+ raise FileNotFoundError(f"Executable '{exe_name}' not found in $PATH!")
- logging.debug(f"Executable cmd{cmd}, kwargs{kwargs}")
- return cmd
+ @property
+ def exe_file(self) -> Optional[Path]:
+ return self.__find_exe_file(self.exe_name)
- def run(self, process_args, **kwargs):
- return subprocess.run(
- self.__build_cmd(process_args, kwargs),
- **kwargs
- )
+ def __build_cmd(self, args, kwargs) -> List:
+ cmd = [self.exe_file, *args]
- def Popen(self, process_args, **kwargs):
- return subprocess.Popen(
- self.__build_cmd(process_args, kwargs),
- **kwargs
- )
+ _logger.debug(f"Executable cmd{cmd}, kwargs{kwargs}")
+ return cmd
- def run_less(self, process_args, **kwargs):
- kwargs['stdout'] = subprocess.PIPE
- kwargs['stderr'] = subprocess.DEVNULL
+ def run(self, process_args, **kwargs) -> Optional[subprocess.CompletedProcess]:
+ return subprocess.run(
+ self.__build_cmd(process_args, kwargs),
+ **kwargs
+ )
- process = self.Popen(
- process_args,
- **kwargs
- )
+ def Popen(self, process_args, **kwargs) -> subprocess.Popen:
+ return subprocess.Popen(
+ self.__build_cmd(process_args, kwargs),
+ **kwargs
+ )
- less_process = Executable('less').run([
- '-R', '+G'
+ def run_with_pager(self, process_args, **kwargs) -> Optional[subprocess.CompletedProcess]:
+ kwargs["stdout"] = subprocess.PIPE
+ kwargs["stderr"] = subprocess.DEVNULL
+
+ with self.Popen(process_args, **kwargs) as process:
+ less_process = Executable("less").run([
+ "-R", "+G"
], stdin=process.stdout)
process.communicate()
- return less_process
- __exe_name = None
- __instances = {}
+ return less_process
- def __init__(self, exe_name):
- self.__exe_name = exe_name
- if exe_name not in Executable.__instances:
- Executable.__instances[exe_name] = Executable.__Executable(exe_name)
-
- def __getattr__(self, item):
- return getattr(self.__instances[self.__exe_name], item)
+DOCKER_EXE = Executable("docker")
+COMPOSE_EXE = Executable("docker-compose")
diff --git a/kiwi_scp/instance.py b/kiwi_scp/instance.py
new file mode 100644
index 0000000..c40e1f7
--- /dev/null
+++ b/kiwi_scp/instance.py
@@ -0,0 +1,108 @@
+import logging
+import subprocess
+from pathlib import Path
+from typing import Generator, Dict, Sequence
+
+import attr
+
+from ._constants import KIWI_CONF_NAME, CONFIG_DIRECTORY_NAME
+from .config import KiwiConfig
+from .executable import DOCKER_EXE
+from .project import Project
+
+_logger = logging.getLogger(__name__)
+
+
+@attr.s
+class Instance:
+ directory: Path = attr.ib(default=Path('.'))
+
+ @property
+ def config(self) -> KiwiConfig:
+ """shorthand: get the current configuration"""
+
+ return KiwiConfig.from_directory(self.directory)
+
+ def save_config(self, config: KiwiConfig) -> None:
+ with open(self.directory.joinpath(KIWI_CONF_NAME), "w") as file:
+ config.dump_kiwi_yml(file)
+
+ @property
+ def config_directory(self):
+ return self.directory.joinpath(CONFIG_DIRECTORY_NAME)
+
+ @property
+ def storage_config_directory(self):
+ return self.config.storage.directory.joinpath(CONFIG_DIRECTORY_NAME)
+
+ @staticmethod
+ def __find_net(net_name):
+ ps = DOCKER_EXE.run([
+ "network", "ls", "--filter", f"name={net_name}", "--format", "{{.Name}}"
+ ], stdout=subprocess.PIPE)
+
+ net_found = str(ps.stdout, 'utf-8').strip()
+
+ return net_found == net_name
+
+ def create_net(self):
+ net_name = self.config.network.name
+ net_cidr = str(self.config.network.cidr)
+
+ if self.__find_net(net_name):
+ _logger.info(f"Network '{net_name}' already exists")
+ return
+
+ try:
+ DOCKER_EXE.run([
+ "network", "create",
+ "--driver", "bridge",
+ "--internal",
+ "--subnet", net_cidr,
+ net_name
+ ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ _logger.info(f"Network '{net_name}' created")
+
+ except subprocess.CalledProcessError:
+ _logger.error(f"Error creating network '{net_name}'")
+
+ def remove_net(self):
+ net_name = self.config.network.name
+
+ if not self.__find_net(net_name):
+ _logger.info(f"Network '{net_name}' does not exist")
+ return
+
+ try:
+ DOCKER_EXE.run([
+ "network", "rm",
+ net_name
+ ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ _logger.info(f"Network '{net_name}' removed")
+
+ except subprocess.CalledProcessError:
+ _logger.error(f"Error removing network '{net_name}'")
+
+ @property
+ def projects(self) -> Generator[Project, None, None]:
+ for project in self.config.projects:
+ yield Project(
+ directory=self.directory.joinpath(project.name),
+ parent_instance=self,
+ )
+
+ def get_projects(self, project_names: Sequence[str]) -> Dict[str, Project]:
+ existing_projects = {
+ project.name: project
+ for project in self.projects
+ if project.name in project_names
+ }
+ nonexistent_projects = {
+ name: None
+ for name in project_names
+ if name not in existing_projects
+ }
+ return {
+ **existing_projects,
+ **nonexistent_projects
+ }
diff --git a/kiwi_scp/misc.py b/kiwi_scp/misc.py
deleted file mode 100644
index ed91da9..0000000
--- a/kiwi_scp/misc.py
+++ /dev/null
@@ -1,37 +0,0 @@
-def _surround(string, bang):
- midlane = f"{bang * 3} {string} {bang * 3}"
- sidelane = bang * len(midlane)
-
- return f"{sidelane}\n{midlane}\n{sidelane}"
-
-
-def _emphasize(lines):
- if isinstance(lines, list):
- return '\n'.join([_emphasize(line) for line in lines])
- elif lines:
- return f">>> {lines} <<<"
- else:
- return lines
-
-
-def are_you_sure(prompt, default="no"):
- if default.lower() == 'yes':
- suffix = "[YES|no]"
- else:
- suffix = "[yes|NO]"
-
- answer = input(
- f"{_surround('MUST HAVE CAREFULING IN PROCESS', '!')}\n"
- f"\n"
- f"{_emphasize(prompt)}\n"
- f"\n"
- f"Are you sure you want to proceed? {suffix} "
- ).strip().lower()
-
- if answer == '':
- answer = default
-
- while answer not in ['yes', 'no']:
- answer = input("Please type 'yes' or 'no' explicitly: ").strip().lower()
-
- return answer == 'yes'
diff --git a/kiwi_scp/parser.py b/kiwi_scp/parser.py
deleted file mode 100644
index 5276152..0000000
--- a/kiwi_scp/parser.py
+++ /dev/null
@@ -1,66 +0,0 @@
-# system
-import argparse
-
-# local
-from ._constants import COMMAND_HELP_TEXT_NAME, KIWI_HELP_TEXT_NAME
-
-
-class Parser:
- """Singleton: Main CLI arguments parser"""
-
- class __Parser:
- """Singleton type"""
-
- # argparse objects
- __parser = None
- __subparsers = None
- __args = None
-
- def __init__(self):
- # add version data from separate file (keeps default config cleaner)
- with open(KIWI_HELP_TEXT_NAME, 'r') as stream:
- kiwi_help = stream.read()
-
- with open(COMMAND_HELP_TEXT_NAME, 'r') as stream:
- command_help_text = stream.read()
-
- # create main parser
- self.__parser = argparse.ArgumentParser(
- prog='kiwi',
- description=kiwi_help,
- epilog=command_help_text,
- )
- self.__parser.formatter_class = argparse.RawDescriptionHelpFormatter
-
- # main arguments
- self.__parser.add_argument(
- '-v', '--verbosity',
- action='count', default=0
- )
-
- # attach subparsers
- self.__subparsers = self.__parser.add_subparsers()
- self.__subparsers.required = True
- self.__subparsers.dest = 'command'
-
- def get_subparsers(self):
- return self.__subparsers
-
- def get_args(self):
- if self.__args is None:
- # parse args if needed
- self.__args, unknowns = self.__parser.parse_known_args()
- self.__args.unknowns = unknowns
-
- return self.__args
-
- __instance = None
-
- def __init__(self):
- if Parser.__instance is None:
- # create singleton
- Parser.__instance = Parser.__Parser()
-
- def __getattr__(self, item):
- """Inner singleton direct access"""
- return getattr(self.__instance, item)
diff --git a/kiwi_scp/project.py b/kiwi_scp/project.py
index 60a3fbd..a5b1bf7 100644
--- a/kiwi_scp/project.py
+++ b/kiwi_scp/project.py
@@ -1,133 +1,74 @@
-import logging
-import os
+import functools
+from pathlib import Path
+from typing import TYPE_CHECKING, Optional, Dict, Any
-from ._constants import CONF_DIRECTORY_NAME
-from .config import LoadedConfig
-from .executable import Executable
+import attr
+from ruamel.yaml import CommentedMap
+
+from ._constants import COMPOSE_FILE_NAME, CONFIG_DIRECTORY_NAME
+from .config import ProjectConfig
+from .service import Service
+from .services import Services
+from .yaml import YAML
+
+if TYPE_CHECKING:
+ from .instance import Instance
+@attr.s
class Project:
- __name = None
+ directory: Path = attr.ib()
+ parent_instance: "Instance" = attr.ib()
- def __init__(self, name):
- self.__name = name
+ @staticmethod
+ @functools.lru_cache(maxsize=10)
+ def _parse_compose_file(directory: Path) -> CommentedMap:
+ with open(directory.joinpath(COMPOSE_FILE_NAME), "r") as cf:
+ return YAML().load(cf)
- @classmethod
- def from_file_name(cls, file_name):
- if os.path.isdir(file_name):
- config = LoadedConfig.get()
+ @property
+ def name(self) -> str:
+ return self.directory.name
- if file_name.endswith(config['markers:disabled']):
- file_name = file_name[:-len(config['markers:disabled'])]
+ @property
+ def config(self) -> Optional[ProjectConfig]:
+ return self.parent_instance.config.get_project_config(self.name)
- if file_name.endswith(config['markers:project']):
- file_name = file_name[:-len(config['markers:project'])]
- return cls(file_name)
+ @property
+ def process_kwargs(self) -> Dict[str, Any]:
+ directory: Path = self.directory
+ project_name: str = self.name
+ kiwi_hub_name: str = self.parent_instance.config.network.name
+ kiwi_instance_dir: Path = self.parent_instance.config.storage.directory
+ kiwi_config_dir: Path = kiwi_instance_dir.joinpath(CONFIG_DIRECTORY_NAME)
+ kiwi_project_dir: Path = kiwi_instance_dir.joinpath(project_name)
- return None
+ if self.config.override_storage is not None:
+ kiwi_project_dir = self.config.override_storage.directory
- def get_name(self):
- return self.__name
+ result: Dict[str, Any] = {
+ "cwd": str(directory),
+ "env": {
+ "COMPOSE_PROJECT_NAME": project_name,
+ "KIWI_HUB_NAME": kiwi_hub_name,
+ "KIWI_INSTANCE": str(kiwi_instance_dir),
+ "KIWI_CONFIG": str(kiwi_config_dir),
+ "KIWI_PROJECT": str(kiwi_project_dir),
+ },
+ }
- def dir_name(self):
- if self.is_enabled():
- return self.enabled_dir_name()
- elif self.is_disabled():
- return self.disabled_dir_name()
- else:
- return None
+ result["env"].update(self.parent_instance.config.environment)
- def enabled_dir_name(self):
- return f"{self.__name}{LoadedConfig.get()['markers:project']}"
+ return result
- def disabled_dir_name(self):
- return f"{self.enabled_dir_name()}{LoadedConfig.get()['markers:disabled']}"
+ @property
+ def services(self) -> Services:
+ yml = Project._parse_compose_file(self.directory)
- def conf_dir_name(self):
- return os.path.join(self.dir_name(), CONF_DIRECTORY_NAME)
-
- def compose_file_name(self):
- return os.path.join(self.dir_name(), 'docker-compose.yml')
-
- def target_dir_name(self):
- return os.path.join(LoadedConfig.get()['runtime:storage'], self.enabled_dir_name())
-
- def exists(self):
- return os.path.isdir(self.enabled_dir_name()) or os.path.isdir(self.disabled_dir_name())
-
- def is_enabled(self):
- return os.path.isdir(self.enabled_dir_name())
-
- def is_disabled(self):
- return os.path.isdir(self.disabled_dir_name())
-
- def has_configs(self):
- return os.path.isdir(self.conf_dir_name())
-
- def __update_kwargs(self, kwargs):
- if not self.is_enabled():
- # cannot compose in a disabled project
- logging.warning(f"Project '{self.get_name()}' is not enabled!")
- return False
-
- config = LoadedConfig.get()
-
- # execute command in project directory
- kwargs['cwd'] = self.dir_name()
-
- # ensure there is an environment
- if 'env' not in kwargs:
- kwargs['env'] = {}
-
- # create environment variables for docker commands
- kwargs['env'].update({
- 'COMPOSE_PROJECT_NAME': self.get_name(),
- 'KIWI_HUB_NAME': config['network:name'],
- 'TARGETROOT': config['runtime:storage'],
- 'CONFDIR': os.path.join(config['runtime:storage'], CONF_DIRECTORY_NAME),
- 'TARGETDIR': self.target_dir_name()
- })
-
- # add common environment from config
- if config['runtime:env'] is not None:
- kwargs['env'].update(config['runtime:env'])
-
- logging.debug(f"kwargs updated: {kwargs}")
-
- return True
-
- def compose_run(self, compose_args, **kwargs):
- if self.__update_kwargs(kwargs):
- Executable('docker-compose').run(compose_args, **kwargs)
-
- def compose_run_less(self, compose_args, **kwargs):
- if self.__update_kwargs(kwargs):
- Executable('docker-compose').run_less(compose_args, **kwargs)
-
- def enable(self):
- if self.is_disabled():
- logging.info(f"Enabling project '{self.get_name()}'")
- os.rename(self.dir_name(), self.enabled_dir_name())
-
- elif self.is_enabled():
- logging.warning(f"Project '{self.get_name()}' is enabled!")
-
- else:
- logging.warning(f"Project '{self.get_name()}' not found in instance!")
- return False
-
- return True
-
- def disable(self):
- if self.is_enabled():
- logging.info(f"Disabling project '{self.get_name()}'")
- os.rename(self.dir_name(), self.disabled_dir_name())
-
- elif self.is_disabled():
- logging.warning(f"Project '{self.get_name()}' is disabled!")
-
- else:
- logging.warning(f"Project '{self.get_name()}' not found in instance!")
- return False
-
- return True
+ return Services([
+ Service(
+ name=name,
+ content=content,
+ parent_project=self,
+ ) for name, content in yml["services"].items()
+ ])
diff --git a/kiwi_scp/projects.py b/kiwi_scp/projects.py
deleted file mode 100644
index 1444646..0000000
--- a/kiwi_scp/projects.py
+++ /dev/null
@@ -1,83 +0,0 @@
-import os
-
-from .project import Project
-
-
-class Projects:
- __projects = None
-
- def __getitem__(self, item):
- return self.__projects[item]
-
- def __str__(self):
- return str([
- project.get_name()
- for project
- in self.__projects
- ])
-
- def __bool__(self):
- return bool(self.__projects)
-
- @classmethod
- def from_names(cls, project_names):
- result = cls()
- result.__projects = [
- Project(name)
- for name in project_names if isinstance(name, str)
- ]
- return result
-
- @classmethod
- def from_projects(cls, projects):
- result = cls()
- result.__projects = [
- project
- for project in projects if isinstance(project, Project)
- ]
- return result
-
- @classmethod
- def from_dir(cls, directory='.'):
- return cls.from_projects([
- Project.from_file_name(file_name)
- for file_name in os.listdir(directory)
- ])
-
- @classmethod
- def from_args(cls, args):
- if args is not None and 'projects' in args:
- if isinstance(args.projects, list) and args.projects:
- return cls.from_names(args.projects)
-
- elif isinstance(args.projects, str):
- return cls.from_names([args.projects])
-
- return cls()
-
- def filter_exists(self):
- result = Projects()
- result.__projects = [
- project
- for project in self.__projects
- if project.exists()
- ]
- return result
-
- def filter_enabled(self):
- result = Projects()
- result.__projects = [
- project
- for project in self.__projects
- if project.is_enabled()
- ]
- return result
-
- def filter_disabled(self):
- result = Projects()
- result.__projects = [
- project
- for project in self.__projects
- if project.is_disabled()
- ]
- return result
diff --git a/kiwi_scp/rootkit.py b/kiwi_scp/rootkit.py
index a3c7937..1be6ab6 100644
--- a/kiwi_scp/rootkit.py
+++ b/kiwi_scp/rootkit.py
@@ -1,86 +1,94 @@
-# system
+import functools
import logging
-import os
import subprocess
+from pathlib import Path
+from typing import Optional, TypeVar, Union, Sequence, Any
+
+import attr
-# local
from ._constants import IMAGES_DIRECTORY_NAME, LOCAL_IMAGES_NAME, DEFAULT_IMAGE_NAME
-from .executable import Executable
-
-
-def _prefix_path(prefix, path):
- if isinstance(path, str):
- abs_path = os.path.abspath(path)
- return os.path.realpath(f"{prefix}/{abs_path}")
-
- elif isinstance(path, list):
- return [_prefix_path(prefix, p) for p in path]
-
-
-def prefix_path_mnt(path):
- return _prefix_path('/mnt/', path)
-
-
-def _image_name(image_tag):
- if image_tag is not None:
- return f"{LOCAL_IMAGES_NAME}:{image_tag}"
- else:
- return DEFAULT_IMAGE_NAME
+from .executable import DOCKER_EXE
+
+_logger = logging.getLogger(__name__)
+
+ROOTKIT_PREFIX = Path("/mnt")
+@attr.s
class Rootkit:
- class __Rootkit:
- __image_tag = None
+ image_tag: str = attr.ib()
- def __init__(self, image_tag=None):
- self.__image_tag = image_tag
+ @staticmethod
+ @functools.lru_cache(maxsize=None)
+ def __image_name(image_tag: Optional[str]) -> str:
+ if image_tag is not None:
+ return f"{LOCAL_IMAGES_NAME}:{image_tag}"
+ else:
+ return DEFAULT_IMAGE_NAME
- def __exists(self):
- ps = Executable('docker').run([
- 'images',
- '--filter', f"reference={_image_name(self.__image_tag)}",
- '--format', '{{.Repository}}:{{.Tag}}'
- ], stdout=subprocess.PIPE)
+ @staticmethod
+ @functools.lru_cache(maxsize=None)
+ def __exists(image_tag: str) -> bool:
+ ps = DOCKER_EXE.run([
+ "images",
+ "--filter", f"reference={Rootkit.__image_name(image_tag)}",
+ "--format", "{{.Repository}}:{{.Tag}}"
+ ], stdout=subprocess.PIPE)
- return str(ps.stdout, 'utf-8').strip() == _image_name(self.__image_tag)
+ return str(ps.stdout, "utf-8").strip() == Rootkit.__image_name(image_tag)
+
+ def __build_image(self) -> None:
+ if Rootkit.__exists(self.image_tag):
+ _logger.info(f"Using image {Rootkit.__image_name(self.image_tag)}")
+ else:
+ if self.image_tag is None:
+ _logger.info(f"Pulling image {Rootkit.__image_name(self.image_tag)}")
+ DOCKER_EXE.run([
+ "pull", Rootkit.__image_name(self.image_tag)
+ ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
- def __build_image(self):
- if self.__exists():
- logging.info(f"Using image {_image_name(self.__image_tag)}")
else:
- if self.__image_tag is None:
- logging.info(f"Pulling image {_image_name(self.__image_tag)}")
- Executable('docker').run([
- 'pull', _image_name(self.__image_tag)
- ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ _logger.info(f"Building image {Rootkit.__image_name(self.image_tag)}")
+ DOCKER_EXE.run([
+ "build",
+ "-t", Rootkit.__image_name(self.image_tag),
+ "-f", f"{IMAGES_DIRECTORY_NAME}/{self.image_tag}.Dockerfile",
+ f"{IMAGES_DIRECTORY_NAME}"
+ ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
- else:
- logging.info(f"Building image {_image_name(self.__image_tag)}")
- Executable('docker').run([
- 'build',
- '-t', _image_name(self.__image_tag),
- '-f', f"{IMAGES_DIRECTORY_NAME}/{self.__image_tag}.Dockerfile",
- f"{IMAGES_DIRECTORY_NAME}"
- ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ def run(self, process_args, **kwargs) -> Optional[subprocess.CompletedProcess]:
+ any_sequence = TypeVar("any_sequence", Union[str, Path, Any], Sequence[Union[str, Path, Any]])
- def run(self, process_args, **kwargs):
- self.__build_image()
- Executable('docker').run([
- 'run', '--rm',
- '-v', '/:/mnt',
- '-u', 'root',
- _image_name(self.__image_tag),
- *process_args
- ], **kwargs)
+ def parse_args(argument: any_sequence) -> any_sequence:
+ if isinstance(argument, str):
+ return argument
- __image_tag = None
- __instances = {}
+ elif isinstance(argument, Path):
+ if argument.is_absolute():
+ argument = argument.relative_to("/")
- def __init__(self, image_tag=None):
- self.__image_tag = image_tag
+ return str(ROOTKIT_PREFIX.joinpath(argument))
- if _image_name(self.__image_tag) not in Rootkit.__instances:
- Rootkit.__instances[_image_name(self.__image_tag)] = Rootkit.__Rootkit(image_tag)
+ elif not isinstance(argument, Sequence):
+ return str(argument)
- def __getattr__(self, item):
- return getattr(self.__instances[_image_name(self.__image_tag)], item)
+ else:
+ parsed = [parse_args(path) for path in argument]
+
+ flat = []
+ for item in parsed:
+ if not isinstance(item, list):
+ flat.append(item)
+ else:
+ flat.extend(item)
+
+ return flat
+
+ self.__build_image()
+ return DOCKER_EXE.run([
+ "run", "--rm",
+ "-v", f"/:{ROOTKIT_PREFIX!s}",
+ "-u", "root",
+ Rootkit.__image_name(self.image_tag),
+ *parse_args(process_args)
+ ], **kwargs)
diff --git a/kiwi_scp/runner.py b/kiwi_scp/runner.py
deleted file mode 100644
index a2b33dd..0000000
--- a/kiwi_scp/runner.py
+++ /dev/null
@@ -1,73 +0,0 @@
-# system
-import logging
-import subprocess
-
-# local
-from . import subcommands
-from .executable import Executable
-from .parser import Parser
-
-
-class Runner:
- """Singleton: Subcommands setup and run"""
-
- class __Runner:
- """Singleton type"""
-
- __commands = []
-
- def __init__(self):
- # probe for Docker access
- try:
- Executable('docker').run([
- 'ps'
- ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
-
- except subprocess.CalledProcessError:
- logging.critical("Cannot access docker, please get into the docker group or run as root!")
- quit(1)
-
- # setup all subcommands
- for className in subcommands.__all__:
- cmd = getattr(subcommands, className)
- self.__commands.append(cmd())
-
- def run(self, command=None, args=None):
- """run the desired subcommand"""
-
- if args is None:
- args = Parser().get_args()
-
- if command is None:
- command = args.command
-
- for cmd in self.__commands:
- if str(cmd) == command:
- # command found
- logging.debug(f"Running '{cmd}' with args: {args}")
-
- try:
- result = cmd.run(self, args)
-
- except KeyboardInterrupt:
- print()
- logging.warning(f"'{cmd}' aborted, inputs may have been discarded.")
- result = False
-
- return result
-
- # command not found
- logging.error(f"kiwi command '{command}' unknown")
- return False
-
- __instance = None
-
- def __init__(self):
- if Runner.__instance is None:
- # create singleton
- Runner.__instance = Runner.__Runner()
-
- def __getattr__(self, item):
- """Inner singleton direct access"""
-
- return getattr(self.__instance, item)
diff --git a/kiwi_scp/scripts/kiwi.py b/kiwi_scp/scripts/kiwi.py
old mode 100755
new mode 100644
index 9bcbc20..ac9dc9e
--- a/kiwi_scp/scripts/kiwi.py
+++ b/kiwi_scp/scripts/kiwi.py
@@ -1,38 +1,43 @@
-#!/usr/bin/env python3
-
-# system
import logging
-# local
-import kiwi_scp
+import click
+
+from kiwi_scp.commands import KiwiCLI
-def set_verbosity(logger, handler, verbosity):
- """set logging default verbosity level and format"""
+@click.option(
+ "-v", "--verbose",
+ help="increase output verbosity",
+ count=True,
+)
+@click.command(cls=KiwiCLI)
+def main(verbose: int) -> None:
+ """kiwi is the simple tool for managing container servers.
- if verbosity >= 2:
+ \b
+ - Manage full instances using just your favorite version control system
+ - Group services into projects, each with their own docker-compose.yml
+ - Build service-specific, private docker images from Dockerfiles
+ - Make use of the local file system by referring to ${KIWI_PROJECT}, ${KIWI_INSTANCE} and ${KIWI_CONFIG} in compose files
+ - Create your own instance-global variables for compose files using the kiwi.yml "environment" section
+ """
+
+ if verbose >= 2:
log_level = logging.DEBUG
log_format = "[%(asctime)s] %(levelname)s @ %(filename)s:%(funcName)s:%(lineno)d: %(message)s"
- elif verbosity >= 1:
+ elif verbose >= 1:
log_level = logging.INFO
log_format = "[%(asctime)s] %(levelname)s: %(message)s"
else:
log_level = logging.WARNING
log_format = "%(levelname)s: %(message)s"
- logger.setLevel(log_level)
- handler.setFormatter(logging.Formatter(log_format))
-
-
-def main():
# add a new handler (needed to set the level)
log_handler = logging.StreamHandler()
logging.getLogger().addHandler(log_handler)
- set_verbosity(logging.getLogger(), log_handler, kiwi_scp.verbosity())
- # run the app
- if not kiwi_scp.run():
- quit(1)
+ logging.getLogger().setLevel(log_level)
+ log_handler.setFormatter(logging.Formatter(log_format))
if __name__ == "__main__":
diff --git a/kiwi_scp/service.py b/kiwi_scp/service.py
new file mode 100644
index 0000000..893f387
--- /dev/null
+++ b/kiwi_scp/service.py
@@ -0,0 +1,61 @@
+import logging
+import re
+import subprocess
+from itertools import zip_longest
+from pathlib import Path
+from typing import TYPE_CHECKING, Generator, Sequence
+
+import attr
+from ruamel.yaml import CommentedMap
+
+from .executable import COMPOSE_EXE
+
+if TYPE_CHECKING:
+ from .project import Project
+
+_logger = logging.getLogger(__name__)
+
+
+@attr.s
+class Service:
+ name: str = attr.ib()
+ content: CommentedMap = attr.ib()
+ parent_project: "Project" = attr.ib()
+
+ _RE_KIWI_CONFIG = re.compile(r"^\s*\$(?:KIWI_CONFIG|{KIWI_CONFIG})/+(.*)$", flags=re.UNICODE)
+
+ @property
+ def configs(self) -> Generator[Path, None, None]:
+ if "volumes" not in self.content:
+ return
+
+ for volume in self.content["volumes"]:
+ host_part = volume.split(":")[0]
+ cd_match = Service._RE_KIWI_CONFIG.match(host_part)
+
+ if cd_match:
+ yield Path(cd_match.group(1))
+
+ def has_executable(self, exe_name: str) -> bool:
+ try:
+ # test if desired executable exists
+ COMPOSE_EXE.run(
+ ["exec", "-T", self.name, "/bin/sh", "-c", f"command -v {exe_name}"],
+ check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ **self.parent_project.process_kwargs,
+ )
+ return True
+
+ except subprocess.CalledProcessError:
+ return False
+
+ def existing_executables(self, exe_names: Sequence[str]) -> Generator[str, None, None]:
+ for cur, nxt in zip_longest(exe_names, exe_names[1:]):
+ if self.has_executable(cur):
+ # found working shell
+ _logger.debug(f"Found executable '{cur}'")
+ yield cur
+
+ elif nxt is not None:
+ # try next in list
+ _logger.info(f"Executable '{cur}' not found in container, trying '{nxt}'")
diff --git a/kiwi_scp/services.py b/kiwi_scp/services.py
new file mode 100644
index 0000000..f1b3455
--- /dev/null
+++ b/kiwi_scp/services.py
@@ -0,0 +1,91 @@
+import subprocess
+from pathlib import Path
+from typing import List, Generator, Optional, TYPE_CHECKING, TypeVar, Union
+
+import attr
+
+from .rootkit import Rootkit
+from .yaml import YAML
+
+if TYPE_CHECKING:
+ from .project import Project
+ from .service import Service
+
+
+@attr.s
+class Services:
+ content: List["Service"] = attr.ib()
+
+ def __str__(self) -> str:
+ return YAML().dump({
+ "services": {
+ service.name: service.content
+ for service in self.content
+ },
+ "configs": [
+ str(config)
+ for config in self.configs
+ ],
+ }).strip()
+
+ def __bool__(self) -> bool:
+ return bool(self.content)
+
+ @property
+ def parent_project(self) -> Optional["Project"]:
+ if not self:
+ return
+
+ return self.content[0].parent_project
+
+ @property
+ def configs(self) -> Generator[Path, None, None]:
+ for service in self.content:
+ yield from service.configs
+
+ def copy_configs(self) -> None:
+ path_str_list = TypeVar("path_str_list", Union[Path, str], List[Union[Path, str]])
+
+ def prefix_path(path: path_str_list, prefix: Path) -> path_str_list:
+ if isinstance(path, Path):
+ return prefix.absolute().joinpath(path)
+
+ elif isinstance(path, str):
+ return prefix_path(Path(path), prefix)
+
+ elif isinstance(path, list):
+ return [prefix_path(p, prefix) for p in path]
+
+ project = self.parent_project
+
+ if project is None:
+ return
+
+ instance = project.parent_instance
+ cfgs = list(self.configs)
+
+ local_cfgs = prefix_path(cfgs, instance.config_directory)
+ storage_cfgs = prefix_path(cfgs, instance.storage_config_directory)
+ storage_dirs = [path.parent for path in storage_cfgs]
+
+ Rootkit("rsync").run([
+ "mkdir", "-p", storage_dirs
+ ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+ Rootkit("rsync").run([
+ "rsync", "-rpt", list(zip(local_cfgs, storage_cfgs))
+ ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+ @property
+ def names(self) -> Generator[str, None, None]:
+ return (
+ service.name
+ for service in self.content
+ )
+
+ def filter_existing(self, service_names: List[str]) -> "Services":
+ return Services([
+ service
+ for service in self.content
+ if service.name in service_names
+ ])
diff --git a/kiwi_scp/subcommand.py b/kiwi_scp/subcommand.py
deleted file mode 100644
index b5955d3..0000000
--- a/kiwi_scp/subcommand.py
+++ /dev/null
@@ -1,128 +0,0 @@
-# system
-import logging
-import os
-
-# local
-from .parser import Parser
-from .projects import Projects
-
-
-class SubCommand:
- """represents kiwi [anything] command"""
-
- # actual command string
- __name = None
- # command parser
- _sub_parser = None
-
- _action = None
-
- def __init__(self, name, action, add_parser=True, **kwargs):
- self.__name = name
- self._action = action
-
- if add_parser:
- self._sub_parser = Parser().get_subparsers().add_parser(
- name,
- **kwargs
- )
-
- def __str__(self):
- return self.__name
-
- def _run_instance(self, runner, args):
- pass
-
- def run(self, runner, args):
- """actually run command with parsed CLI args"""
-
- # run for entire instance
- logging.info(f"{self._action} kiwi-scp instance at '{os.getcwd()}'")
- return self._run_instance(runner, args)
-
-
-class ProjectCommand(SubCommand):
- """this command concerns a project in current instance"""
-
- def __init__(self, name, num_projects, action, add_parser=True, **kwargs):
- super().__init__(
- name, action=action, add_parser=add_parser,
- **kwargs
- )
-
- if num_projects == 1:
- projects = "a project"
- else:
- projects = "project(s)"
-
- self._sub_parser.add_argument(
- 'projects', metavar='project', nargs=num_projects, type=str,
- help=f"select {projects} in this instance"
- )
-
- def _run_instance(self, runner, args):
- # default: run for all enabled projects
- return self._run_projects(runner, args, Projects.from_dir().filter_enabled())
-
- def _run_projects(self, runner, args, projects):
- # default: run for all given projects
- return all([
- self._run_project(runner, args, project)
- for project in projects
- ])
-
- def _run_project(self, runner, args, project):
- pass
-
- def run(self, runner, args):
- projects = Projects.from_args(args)
-
- if projects:
- # project(s) given
- logging.info(f"{self._action} projects {projects}")
- return self._run_projects(runner, args, projects)
-
- else:
- return super().run(runner, args)
-
-
-class ServiceCommand(ProjectCommand):
- """this command concerns service(s) in a project"""
-
- def __init__(self, name, num_projects, num_services, action, add_parser=True, **kwargs):
- super().__init__(
- name, num_projects=num_projects, action=action, add_parser=add_parser,
- **kwargs
- )
-
- if (isinstance(num_projects, str) and num_projects == '*') \
- or (isinstance(num_projects, int) and num_projects > 1):
- raise ValueError(f"Invalid choice for project count: {num_projects}")
-
- if num_services == 1:
- services = "a service"
- else:
- services = "service(s)"
-
- self._sub_parser.add_argument(
- 'services', metavar='service', nargs=num_services, type=str,
- help=f"select {services} in a project"
- )
-
- def _run_project(self, runner, args, project):
- # default: run with empty service list
- return self._run_services(runner, args, project, [])
-
- def _run_services(self, runner, args, project, services):
- pass
-
- def run(self, runner, args):
- if 'services' in args and args.services:
- project = Projects.from_args(args)[0]
-
- # run for service(s) inside project
- logging.info(f"{self._action} project '{project.get_name()}', services {args.services}")
- return self._run_services(runner, args, project, args.services)
-
- else:
- return super().run(runner, args)
diff --git a/kiwi_scp/subcommands/__init__.py b/kiwi_scp/subcommands/__init__.py
deleted file mode 100644
index f958982..0000000
--- a/kiwi_scp/subcommands/__init__.py
+++ /dev/null
@@ -1,39 +0,0 @@
-# local
-from ._hidden import ConfCopyCommand, NetUpCommand
-
-from .build import BuildCommand
-from .cmd import CmdCommand
-from .disable import DisableCommand
-from .down import DownCommand
-from .enable import EnableCommand
-from .init import InitCommand
-from .logs import LogsCommand
-from .new import NewCommand
-from .pull import PullCommand
-from .push import PushCommand
-from .restart import RestartCommand
-from .shell import ShellCommand
-from .show import ShowCommand
-from .up import UpCommand
-from .update import UpdateCommand
-
-__all__ = [
- 'ConfCopyCommand',
- 'NetUpCommand',
-
- 'BuildCommand',
- 'CmdCommand',
- 'DisableCommand',
- 'DownCommand',
- 'EnableCommand',
- 'InitCommand',
- 'LogsCommand',
- 'NewCommand',
- 'PullCommand',
- 'PushCommand',
- 'RestartCommand',
- 'ShellCommand',
- 'ShowCommand',
- 'UpCommand',
- 'UpdateCommand',
-]
diff --git a/kiwi_scp/subcommands/_hidden.py b/kiwi_scp/subcommands/_hidden.py
deleted file mode 100644
index 062495b..0000000
--- a/kiwi_scp/subcommands/_hidden.py
+++ /dev/null
@@ -1,85 +0,0 @@
-# system
-import logging
-import subprocess
-
-# local
-from ..config import LoadedConfig
-from ..executable import Executable
-from ..projects import Projects
-from ..rootkit import Rootkit, prefix_path_mnt
-from ..subcommand import SubCommand
-
-
-class ConfCopyCommand(SubCommand):
- """kiwi conf-copy"""
-
- def __init__(self):
- super().__init__(
- 'conf-copy',
- action="Syncing all configs for", add_parser=False,
- description="Synchronize all config files to target directory"
- )
-
- def _run_instance(self, runner, args):
- conf_dirs = [
- project.conf_dir_name()
- for project in Projects.from_dir().filter_enabled()
- if project.has_configs()
- ]
-
- if conf_dirs:
- # add target directory
- conf_dirs.append(LoadedConfig.get()['runtime:storage'])
- logging.info(f"Sync directories: {conf_dirs}")
-
- Rootkit('rsync').run([
- 'rsync', '-rpt', '--delete', *prefix_path_mnt(conf_dirs)
- ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
-
- return True
-
-
-def _find_net(net_name):
- ps = Executable('docker').run([
- 'network', 'ls', '--filter', f"name={net_name}", '--format', '{{.Name}}'
- ], stdout=subprocess.PIPE)
-
- net_found = str(ps.stdout, 'utf-8').strip()
-
- return net_found == net_name
-
-
-class NetUpCommand(SubCommand):
- """kiwi net-up"""
-
- def __init__(self):
- super().__init__(
- 'net-up',
- action="Creating the local network hub for", add_parser=False,
- description="Create the local network hub for this instance"
- )
-
- def _run_instance(self, runner, args):
- config = LoadedConfig.get()
- net_name = config['network:name']
- net_cidr = config['network:cidr']
-
- if _find_net(net_name):
- logging.info(f"Network '{net_name}' already exists")
- return True
-
- try:
- Executable('docker').run([
- 'network', 'create',
- '--driver', 'bridge',
- '--internal',
- '--subnet', net_cidr,
- net_name
- ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
- logging.info(f"Network '{net_name}' created")
-
- except subprocess.CalledProcessError:
- logging.error(f"Error creating network '{net_name}'")
- return False
-
- return True
diff --git a/kiwi_scp/subcommands/build.py b/kiwi_scp/subcommands/build.py
deleted file mode 100644
index 35cb4e5..0000000
--- a/kiwi_scp/subcommands/build.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# local
-from ..subcommand import ServiceCommand
-
-
-class BuildCommand(ServiceCommand):
- """kiwi build"""
-
- def __init__(self):
- super().__init__(
- 'build', num_projects='?', num_services='*',
- action="Building images for",
- description="Build images for the whole instance, a project or service(s) inside a project"
- )
-
- def _run_services(self, runner, args, project, services):
- project.compose_run(['build', '--pull', *services])
-
- return True
diff --git a/kiwi_scp/subcommands/cmd.py b/kiwi_scp/subcommands/cmd.py
deleted file mode 100644
index c890edf..0000000
--- a/kiwi_scp/subcommands/cmd.py
+++ /dev/null
@@ -1,40 +0,0 @@
-# system
-import logging
-
-# local
-from ..subcommand import ProjectCommand
-
-
-class CmdCommand(ProjectCommand):
- """kiwi cmd"""
-
- def __init__(self):
- super().__init__(
- 'cmd', num_projects=1,
- action="Running docker-compose in",
- description="Run raw docker-compose command in a project"
- )
-
- # command for docker-compose
- self._sub_parser.add_argument(
- 'compose_cmd', metavar='cmd', type=str,
- help="command for 'docker-compose'"
- )
-
- # arguments for docker-compose command
- self._sub_parser.add_argument(
- 'compose_args', metavar='arg', nargs='*', type=str,
- help="arguments for 'docker-compose' commands"
- )
-
- def _run_project(self, runner, args, project):
- if args.unknowns:
- args.compose_args = [*args.compose_args, *args.unknowns]
- args.unknowns = []
-
- logging.debug(f"Updated args: {args}")
-
- # run with split compose_cmd argument
- project.compose_run([args.compose_cmd, *args.compose_args])
-
- return True
diff --git a/kiwi_scp/subcommands/disable.py b/kiwi_scp/subcommands/disable.py
deleted file mode 100644
index d4d762d..0000000
--- a/kiwi_scp/subcommands/disable.py
+++ /dev/null
@@ -1,16 +0,0 @@
-# local
-from ..subcommand import ProjectCommand
-
-
-class DisableCommand(ProjectCommand):
- """kiwi disable"""
-
- def __init__(self):
- super().__init__(
- 'disable', num_projects='+',
- action="Disabling",
- description="Disable project(s) in this instance"
- )
-
- def _run_project(self, runner, args, project):
- return project.disable()
diff --git a/kiwi_scp/subcommands/down.py b/kiwi_scp/subcommands/down.py
deleted file mode 100644
index b0a1147..0000000
--- a/kiwi_scp/subcommands/down.py
+++ /dev/null
@@ -1,56 +0,0 @@
-# system
-import logging
-import subprocess
-
-# local
-from ._hidden import _find_net
-from ..config import LoadedConfig
-from ..executable import Executable
-from ..misc import are_you_sure
-from ..subcommand import ServiceCommand
-
-
-class DownCommand(ServiceCommand):
- """kiwi down"""
-
- def __init__(self):
- super().__init__(
- 'down', num_projects='?', num_services='*',
- action="Bringing down",
- description="Bring down the whole instance, a project or service(s) inside a project"
- )
-
- def _run_instance(self, runner, args):
- net_name = LoadedConfig.get()['network:name']
-
- if are_you_sure([
- "This will bring down the entire instance.",
- "",
- "This may not be what you intended, because:",
- " - Bringing down the instance stops ALL services in here",
- ]):
- if super()._run_instance(runner, args):
- # remove the hub network afterwards
- if _find_net(net_name):
- Executable('docker').run([
- 'network', 'rm', net_name
- ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
-
- logging.info(f"Network '{net_name}' removed")
-
- else:
- logging.info(f"Network '{net_name}' does not exist")
-
- return True
-
- return False
-
- def _run_project(self, runner, args, project):
- project.compose_run(['down'])
- return True
-
- def _run_services(self, runner, args, project, services):
- project.compose_run(['stop', *services])
- project.compose_run(['rm', '-f', *services])
-
- return True
diff --git a/kiwi_scp/subcommands/enable.py b/kiwi_scp/subcommands/enable.py
deleted file mode 100644
index 1262a7f..0000000
--- a/kiwi_scp/subcommands/enable.py
+++ /dev/null
@@ -1,16 +0,0 @@
-# local
-from ..subcommand import ProjectCommand
-
-
-class EnableCommand(ProjectCommand):
- """kiwi enable"""
-
- def __init__(self):
- super().__init__(
- 'enable', num_projects='+',
- action="Enabling",
- description="Enable project(s) in this instance"
- )
-
- def _run_project(self, runner, args, project):
- return project.enable()
diff --git a/kiwi_scp/subcommands/init.py b/kiwi_scp/subcommands/init.py
deleted file mode 100644
index 1ca9c6a..0000000
--- a/kiwi_scp/subcommands/init.py
+++ /dev/null
@@ -1,63 +0,0 @@
-# system
-import logging
-import os
-
-# local
-from .._constants import KIWI_CONF_NAME
-from ..config import DefaultConfig, LoadedConfig
-from ..subcommand import SubCommand
-
-
-class InitCommand(SubCommand):
- """kiwi init"""
-
- def __init__(self):
- super().__init__(
- 'init',
- action=f"Initializing '{KIWI_CONF_NAME}' in",
- description="Initialize or reconfigure kiwi-scp instance"
- )
-
- # -f switch: Initialize with default config
- self._sub_parser.add_argument(
- '-f', '--force',
- action='store_true',
- help=f"use default values even if {KIWI_CONF_NAME} is present"
- )
-
- # -s switch: Show current config instead
- self._sub_parser.add_argument(
- '-s', '--show',
- action='store_true',
- help=f"show effective {KIWI_CONF_NAME} contents instead"
- )
-
- def _run_instance(self, runner, args):
- config = LoadedConfig.get()
-
- # check show switch
- if args.show:
- print(config)
- return True
-
- # check force switch
- if args.force and os.path.isfile(KIWI_CONF_NAME):
- logging.warning(f"Overwriting existing '{KIWI_CONF_NAME}'!")
- config = DefaultConfig.get()
-
- # version
- config.user_query('version')
-
- # runtime
- config.user_query('runtime:storage')
-
- # markers
- config.user_query('markers:project')
- config.user_query('markers:disabled')
-
- # network
- config.user_query('network:name')
- config.user_query('network:cidr')
-
- config.save()
- return True
diff --git a/kiwi_scp/subcommands/logs.py b/kiwi_scp/subcommands/logs.py
deleted file mode 100644
index 2407220..0000000
--- a/kiwi_scp/subcommands/logs.py
+++ /dev/null
@@ -1,40 +0,0 @@
-# local
-from ..subcommand import ServiceCommand
-
-
-class LogsCommand(ServiceCommand):
- """kiwi logs"""
-
- def __init__(self):
- super().__init__(
- 'logs', num_projects=1, num_services='*',
- action="Showing logs of",
- description="Show logs of a project or service(s) inside a project"
- )
-
- # -f switch: Follow logs
- self._sub_parser.add_argument(
- '-f', '--follow', action='store_true',
- help="output appended data as log grows"
- )
-
- def _run_services(self, runner, args, project, services):
- # include timestamps
- compose_cmd = ['logs', '-t']
-
- # handle following the log output
- if args.follow:
- compose_cmd = [*compose_cmd, '-f', '--tail=10']
-
- # append if one or more services are given
- if services:
- compose_cmd = [*compose_cmd, *args.services]
-
- if args.follow:
- project.compose_run(compose_cmd)
-
- else:
- # use 'less' viewer if output is static
- project.compose_run_less(compose_cmd)
-
- return True
diff --git a/kiwi_scp/subcommands/new.py b/kiwi_scp/subcommands/new.py
deleted file mode 100644
index 5fc5243..0000000
--- a/kiwi_scp/subcommands/new.py
+++ /dev/null
@@ -1,30 +0,0 @@
-# system
-import logging
-import os
-import shutil
-
-# local
-from .._constants import DEFAULT_DOCKER_COMPOSE_NAME
-from ..subcommand import ProjectCommand
-
-
-class NewCommand(ProjectCommand):
- """kiwi new"""
-
- def __init__(self):
- super().__init__(
- 'new', num_projects='+',
- action="Creating",
- description="Create new empty project(s) in this instance"
- )
-
- def _run_project(self, runner, args, project):
- if project.exists():
- logging.error(f"Project '{project.get_name()}' exists in this instance!")
- return False
-
- else:
- os.mkdir(project.disabled_dir_name())
- shutil.copy(DEFAULT_DOCKER_COMPOSE_NAME, project.compose_file_name())
- logging.debug(f"Project '{project.get_name()}' created")
- return True
diff --git a/kiwi_scp/subcommands/pull.py b/kiwi_scp/subcommands/pull.py
deleted file mode 100644
index a1ed790..0000000
--- a/kiwi_scp/subcommands/pull.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# local
-from ..subcommand import ServiceCommand
-
-
-class PullCommand(ServiceCommand):
- """kiwi pull"""
-
- def __init__(self):
- super().__init__(
- 'pull', num_projects='?', num_services='*',
- action="Pulling images for",
- description="Pull images for the whole instance, a project or service(s) inside a project"
- )
-
- def _run_services(self, runner, args, project, services):
- project.compose_run(['pull', '--ignore-pull-failures', *services])
-
- return True
diff --git a/kiwi_scp/subcommands/purge.py b/kiwi_scp/subcommands/purge.py
deleted file mode 100644
index 86cd372..0000000
--- a/kiwi_scp/subcommands/purge.py
+++ /dev/null
@@ -1,45 +0,0 @@
-# system
-import logging
-import subprocess
-
-# local
-from ._hidden import _find_net
-from ..config import LoadedConfig
-from ..executable import Executable
-from ..misc import are_you_sure
-from ..subcommand import SubCommand
-
-
-class PurgeCommand(SubCommand):
- """kiwi purge"""
-
- def __init__(self):
- super().__init__(
- 'purge',
- action="Tearing down",
- description="Remove all running docker artifacts of this instance"
- )
-
- def _run_instance(self, runner, args):
- net_name = LoadedConfig.get()['network:name']
-
- if not _find_net(net_name):
- logging.info(f"Network '{net_name}' does not exist")
- return True
-
- try:
- if are_you_sure("This will bring down this instance's hub network!"):
- if runner.run('down'):
- Executable('docker').run([
- 'network', 'rm', net_name
- ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
-
- logging.info(f"Network '{net_name}' removed")
- else:
- return False
-
- except subprocess.CalledProcessError:
- logging.error(f"Error removing network '{net_name}'")
- return False
-
- return True
diff --git a/kiwi_scp/subcommands/push.py b/kiwi_scp/subcommands/push.py
deleted file mode 100644
index 0f83c52..0000000
--- a/kiwi_scp/subcommands/push.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# local
-from ..subcommand import ServiceCommand
-
-
-class PushCommand(ServiceCommand):
- """kiwi push"""
-
- def __init__(self):
- super().__init__(
- 'push', num_projects='?', num_services='*',
- action="Pushing images for",
- description="Push images for the whole instance, a project or service(s) inside a project"
- )
-
- def _run_services(self, runner, args, project, services):
- project.compose_run(['push', *services])
-
- return True
diff --git a/kiwi_scp/subcommands/restart.py b/kiwi_scp/subcommands/restart.py
deleted file mode 100644
index 86fce22..0000000
--- a/kiwi_scp/subcommands/restart.py
+++ /dev/null
@@ -1,26 +0,0 @@
-# local
-from ..misc import are_you_sure
-from ..subcommand import ServiceCommand
-
-
-class RestartCommand(ServiceCommand):
- """kiwi restart"""
-
- def __init__(self):
- super().__init__(
- 'restart', num_projects='?', num_services='*',
- action="Restarting",
- description="Restart the whole instance, a project or service(s) inside a project"
- )
-
- def _run_instance(self, runner, args):
- if are_you_sure([
- "This will restart the entire instance."
- ]):
- return super()._run_instance(runner, args)
-
- return False
-
- def _run_services(self, runner, args, project, services):
- project.compose_run(['restart', *services])
- return True
diff --git a/kiwi_scp/subcommands/shell.py b/kiwi_scp/subcommands/shell.py
deleted file mode 100644
index 7547ced..0000000
--- a/kiwi_scp/subcommands/shell.py
+++ /dev/null
@@ -1,104 +0,0 @@
-# system
-import logging
-import subprocess
-
-from ..config import LoadedConfig
-# local
-from ..subcommand import ServiceCommand
-
-
-def _service_has_executable(project, service, exe_name):
- """
- Test if service in project has an executable exe_name in its PATH.
- Requires /bin/sh.
- """
-
- try:
- # test if desired shell exists
- project.compose_run(
- ['exec', service, '/bin/sh', '-c', f"command -v {exe_name}"],
- check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
- )
- return True
-
- except subprocess.CalledProcessError as e:
- # fallback
- return False
-
-
-def _find_shell(args, project, service):
- """find first working shell (provided by config and args) in service in project"""
-
- # builtin shells: as a last resort, fallback to '/bin/sh' and 'sh'
- shells = ['/bin/sh', 'sh']
-
- # load favorite shells from config
- config = LoadedConfig.get()
- if config['runtime:shells']:
- shells = [*config['runtime:shells'], *shells]
-
- # consider shell from args
- if args.shell:
- shells = [args.shell, *shells]
-
- logging.debug(f"Shells priority: {shells}")
-
- # actually try shells
- for i, shell in enumerate(shells):
- if _service_has_executable(project, service, shell):
- # found working shell
- logging.debug(f"Using shell '{shell}'")
- return shell
-
- elif i + 1 < len(shells):
- # try next in list
- logging.info(f"Shell '{shell}' not found in container, trying '{shells[i + 1]}'")
-
- elif args.shell:
- # not found, user suggestion provided
- logging.warning(f"Could not find any working shell in this container. "
- f"Launching provided '{args.shell}' nevertheless. "
- f"Don't get mad if this fails!")
- return args.shell
-
- else:
- # not found, search exhausted
- logging.error(f"Could not find any working shell among '{shells}' in this container. "
- f"Please suggest a shell using the '-s SHELL' command line option!")
- return None
-
-
-class ShellCommand(ServiceCommand):
- """kiwi shell"""
-
- def __init__(self):
- super().__init__(
- 'shell', num_projects=1, num_services=1,
- action="Spawning shell in",
- description="Spawn shell inside a project's service"
- )
-
- # -s argument: Select shell
- self._sub_parser.add_argument(
- '-s', '--shell', type=str,
- help="shell to spawn"
- )
-
- # -u argument: Run as user
- self._sub_parser.add_argument(
- '-u', '--user', type=str,
- help="container user to run shell"
- )
-
- def _run_services(self, runner, args, project, services):
- service = services[0]
- shell = _find_shell(args, project, service)
-
- user_args = ['-u', args.user] if args.user else []
-
- if shell is not None:
- # spawn shell
- project.compose_run(['exec', *user_args, service, shell])
- return True
-
- return False
diff --git a/kiwi_scp/subcommands/show.py b/kiwi_scp/subcommands/show.py
deleted file mode 100644
index d8e7a34..0000000
--- a/kiwi_scp/subcommands/show.py
+++ /dev/null
@@ -1,98 +0,0 @@
-# system
-import logging
-import os
-import yaml
-
-from ..project import Project
-from ..projects import Projects
-# local
-from ..subcommand import ServiceCommand
-
-
-def _print_list(strings):
- if isinstance(strings, str):
- print(f" - {strings}")
-
- elif isinstance(strings, Project):
- _print_list(strings.get_name())
-
- elif isinstance(strings, list):
- for string in strings:
- _print_list(string)
-
- else:
- _print_list(list(strings))
-
-
-class ShowCommand(ServiceCommand):
- """kiwi show"""
-
- def __init__(self):
- super().__init__(
- 'show', num_projects='?', num_services='*',
- action="Showing",
- description="Show projects in this instance, services inside a project or service(s) inside a project"
- )
-
- def _run_instance(self, runner, args):
- print(f"kiwi-scp instance at '{os.getcwd()}'")
- print("#########")
- projects = Projects.from_dir()
-
- enabled_projects = projects.filter_enabled()
- if enabled_projects:
- print(f"Enabled projects:")
- _print_list(enabled_projects)
-
- disabled_projects = projects.filter_disabled()
- if disabled_projects:
- print(f"Disabled projects:")
- _print_list(disabled_projects)
-
- return True
-
- def _run_project(self, runner, args, project):
- if not project.exists():
- logging.warning(f"Project '{project.get_name()}' not found")
- return False
-
- print(f"Services in project '{project.get_name()}':")
- print("#########")
-
- with open(project.compose_file_name(), 'r') as stream:
- try:
- docker_compose_yml = yaml.safe_load(stream)
- _print_list(docker_compose_yml['services'].keys())
-
- except yaml.YAMLError as exc:
- logging.error(exc)
-
- return True
-
- def _run_services(self, runner, args, project, services):
- if not project.exists():
- logging.error(f"Project '{project.get_name()}' not found")
- return False
-
- print(f"Configuration of services {services} in project '{project.get_name()}':")
- print("#########")
-
- with open(project.compose_file_name(), 'r') as stream:
- try:
- docker_compose_yml = yaml.safe_load(stream)
-
- for service_name in services:
- try:
- print(yaml.dump(
- {service_name: docker_compose_yml['services'][service_name]},
- default_flow_style=False, sort_keys=False
- ).strip())
- except KeyError:
- logging.error(f"Service '{service_name}' not found")
-
- return True
-
- except yaml.YAMLError as exc:
- logging.error(exc)
-
- return False
diff --git a/kiwi_scp/subcommands/up.py b/kiwi_scp/subcommands/up.py
deleted file mode 100644
index b369545..0000000
--- a/kiwi_scp/subcommands/up.py
+++ /dev/null
@@ -1,26 +0,0 @@
-# local
-from ..subcommand import ServiceCommand
-
-
-class UpCommand(ServiceCommand):
- """kiwi up"""
-
- def __init__(self):
- super().__init__(
- 'up', num_projects='?', num_services='*',
- action="Bringing up",
- description="Bring up the whole instance, a project or service(s) inside a project"
- )
-
- def _run_instance(self, runner, args):
- if runner.run('conf-copy'):
- return super()._run_instance(runner, args)
-
- return False
-
- def _run_services(self, runner, args, project, services):
- if runner.run('net-up'):
- project.compose_run(['up', '-d', *services])
- return True
-
- return False
diff --git a/kiwi_scp/subcommands/update.py b/kiwi_scp/subcommands/update.py
deleted file mode 100644
index 35bc6dd..0000000
--- a/kiwi_scp/subcommands/update.py
+++ /dev/null
@@ -1,37 +0,0 @@
-# local
-from ..misc import are_you_sure
-from ..subcommand import ServiceCommand
-
-
-class UpdateCommand(ServiceCommand):
- """kiwi update"""
-
- def __init__(self):
- super().__init__(
- 'update', num_projects='?', num_services='*',
- action="Updating",
- description="Update the whole instance, a project or service(s) inside a project"
- )
-
- def _run_instance(self, runner, args):
- if are_you_sure([
- "This will update the entire instance at once.",
- "",
- "This is probably not what you intended, because:",
- " - Updates may take a long time",
- " - Updates may break beloved functionality",
- ]):
- return super()._run_instance(runner, args)
-
- return False
-
- def _run_services(self, runner, args, project, services):
- result = True
-
- result &= runner.run('build')
- result &= runner.run('pull')
- result &= runner.run('conf-copy')
- result &= runner.run('down')
- result &= runner.run('up')
-
- return result
diff --git a/kiwi_scp/wstring.py b/kiwi_scp/wstring.py
new file mode 100644
index 0000000..c23c31a
--- /dev/null
+++ b/kiwi_scp/wstring.py
@@ -0,0 +1,97 @@
+import re
+from enum import Enum, auto
+from typing import List
+
+import attr
+import wcwidth
+
+
+class WAlignment(Enum):
+ LEFT = auto()
+ RIGHT = auto()
+ CENTER = auto()
+
+
+@attr.s
+class WString:
+ s: str = attr.ib()
+
+ # from https://stackoverflow.com/a/38662876
+ ANSI_ESCAPES = re.compile(r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]")
+
+ def __str__(self) -> str:
+ return self.s
+
+ def __len__(self) -> int:
+ return wcwidth.wcswidth(WString.ANSI_ESCAPES.sub("", self.s))
+
+ def pad(self, alignment: WAlignment = WAlignment.CENTER, wlen: int = 0, char: str = " ") -> "WString":
+ char = char[0]
+
+ if alignment is WAlignment.LEFT:
+ return WString(f"{self}{char * wlen}")
+ elif alignment is WAlignment.RIGHT:
+ return WString(f"{char * wlen}{self}")
+ else:
+ pad_l, pad_r = wlen // 2, wlen - (wlen // 2)
+ return WString(f"{char * pad_l}{self}{char * pad_r}")
+
+
+@attr.s
+class WParagraph:
+ lines: List[WString] = attr.ib()
+
+ def __str__(self) -> str:
+ return "\n".join(
+ str(line)
+ for line in self.lines
+ )
+
+ @classmethod
+ def from_strings(cls, *source: str) -> "WParagraph":
+ return cls([
+ WString(line)
+ for line in source
+ ])
+
+ def align(self, alignment: WAlignment = WAlignment.CENTER, padding: int = 0, char: str = " ") -> "WParagraph":
+ total_length = max(
+ len(line)
+ for line in self.lines
+ ) + padding
+ pad_lengths = (
+ total_length - len(line)
+ for line in self.lines
+ )
+
+ return WParagraph([
+ line.pad(alignment, wlen, char)
+ for line, wlen in zip(self.lines, pad_lengths)
+ ])
+
+ def surround(self, char: str, padding: int = 1) -> "WParagraph":
+ char = char[0]
+ padding = " " * padding
+
+ l_border, r_border = char + padding, padding + char
+
+ lines = [
+ WString(f"{l_border}{line}{r_border}")
+ for line in self.lines
+ ]
+ extra_line = char * len(lines[0])
+
+ return WParagraph([
+ extra_line,
+ *lines,
+ extra_line,
+ ])
+
+ def emphasize(self, count: int = 3, padding: int = 1) -> "WParagraph":
+ padding = " " * padding
+ l_border, r_border = (">" * count) + padding, padding + ("<" * count)
+
+ return WParagraph([
+ WString(f"{l_border}{line}{r_border}")
+ for line in self.lines
+ ])
diff --git a/kiwi_scp/yaml.py b/kiwi_scp/yaml.py
new file mode 100644
index 0000000..2d7a606
--- /dev/null
+++ b/kiwi_scp/yaml.py
@@ -0,0 +1,37 @@
+import re
+from typing import Optional
+
+import ruamel.yaml
+import ruamel.yaml.compat
+
+from ._constants import HEADER_KIWI_CONF_NAME
+
+
+class YAML(ruamel.yaml.YAML):
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.indent(sequence=4, offset=2)
+
+ def dump(self, data, stream=None, **kwargs) -> Optional[str]:
+ into_str: bool = False
+ if stream is None:
+ into_str = True
+ stream = ruamel.yaml.compat.StringIO()
+
+ super().dump(data, stream=stream, **kwargs)
+ if into_str:
+ return stream.getvalue()
+
+ @staticmethod
+ def _format_kiwi_yml(yml_string: str) -> str:
+ # insert newline before every main key
+ yml_string = re.sub(r"^(\S)", r"\n\1", yml_string, flags=re.MULTILINE)
+
+ # load header comment from file
+ with open(HEADER_KIWI_CONF_NAME, 'r') as stream:
+ yml_string = stream.read() + yml_string
+
+ return yml_string
+
+ def dump_kiwi_yml(self, data, **kwargs) -> Optional[str]:
+ return self.dump(data, transform=YAML._format_kiwi_yml, **kwargs)
diff --git a/poetry.lock b/poetry.lock
index dd53b7f..eecd76d 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,45 +1,544 @@
[[package]]
-name = "pyyaml"
-version = "5.4.1"
-description = "YAML parser and emitter for Python"
+name = "atomicwrites"
+version = "1.4.0"
+description = "Atomic file writes."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "attrs"
+version = "21.4.0"
+description = "Classes Without Boilerplate"
category = "main"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.extras]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
+docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
+
+[[package]]
+name = "click"
+version = "8.0.4"
+description = "Composable command line interface toolkit"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.4"
+description = "Cross-platform colored terminal text."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "coverage"
+version = "6.2"
+description = "Code coverage measurement for Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+tomli = {version = "*", optional = true, markers = "extra == \"toml\""}
+
+[package.extras]
+toml = ["tomli"]
+
+[[package]]
+name = "dataclasses"
+version = "0.8"
+description = "A backport of the dataclasses module for Python 3.6"
+category = "main"
+optional = false
+python-versions = ">=3.6, <3.7"
+
+[[package]]
+name = "distlib"
+version = "0.3.4"
+description = "Distribution utilities"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "filelock"
+version = "3.4.1"
+description = "A platform independent file lock."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"]
+testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"]
+
+[[package]]
+name = "importlib-metadata"
+version = "4.8.3"
+description = "Read metadata from Python packages"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
+zipp = ">=0.5"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+perf = ["ipython"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
+
+[[package]]
+name = "importlib-resources"
+version = "5.4.0"
+description = "Read resources from Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"]
+
+[[package]]
+name = "iniconfig"
+version = "1.1.1"
+description = "iniconfig: brain-dead simple config-ini parsing"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "packaging"
+version = "21.3"
+description = "Core utilities for Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
+
+[[package]]
+name = "platformdirs"
+version = "2.4.0"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"]
+test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"]
+
+[[package]]
+name = "pluggy"
+version = "1.0.0"
+description = "plugin and hook calling mechanisms for python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "py"
+version = "1.11.0"
+description = "library with cross-python path, ini-parsing, io, code, log facilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "pydantic"
+version = "1.9.0"
+description = "Data validation and settings management using python 3.6 type hinting"
+category = "main"
+optional = false
+python-versions = ">=3.6.1"
+
+[package.dependencies]
+dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""}
+typing-extensions = ">=3.7.4.3"
+
+[package.extras]
+dotenv = ["python-dotenv (>=0.10.4)"]
+email = ["email-validator (>=1.0.3)"]
+
+[[package]]
+name = "pyparsing"
+version = "3.0.7"
+description = "Python parsing module"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+diagrams = ["jinja2", "railroad-diagrams"]
+
+[[package]]
+name = "pytest"
+version = "7.0.1"
+description = "pytest: simple powerful testing with Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
+attrs = ">=19.2.0"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<2.0"
+py = ">=1.8.2"
+tomli = ">=1.0.0"
+
+[package.extras]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
+
+[[package]]
+name = "pytest-cov"
+version = "3.0.0"
+description = "Pytest plugin for measuring coverage."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+coverage = {version = ">=5.2.1", extras = ["toml"]}
+pytest = ">=4.6"
+
+[package.extras]
+testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"]
+
+[[package]]
+name = "ruamel.yaml"
+version = "0.17.21"
+description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order"
+category = "main"
+optional = false
+python-versions = ">=3"
+
+[package.dependencies]
+"ruamel.yaml.clib" = {version = ">=0.2.6", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.11\""}
+
+[package.extras]
+docs = ["ryd"]
+jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"]
+
+[[package]]
+name = "ruamel.yaml.clib"
+version = "0.2.6"
+description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+category = "dev"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "tomli"
+version = "1.2.3"
+description = "A lil' TOML parser"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "typing-extensions"
+version = "4.1.1"
+description = "Backported and Experimental Type Hints for Python 3.6+"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "virtualenv"
+version = "20.13.1"
+description = "Virtual Python Environment builder"
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+
+[package.dependencies]
+distlib = ">=0.3.1,<1"
+filelock = ">=3.2,<4"
+importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
+importlib-resources = {version = ">=1.0", markers = "python_version < \"3.7\""}
+platformdirs = ">=2,<3"
+six = ">=1.9.0,<2"
+
+[package.extras]
+docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"]
+testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"]
+
+[[package]]
+name = "wcwidth"
+version = "0.2.5"
+description = "Measures the displayed width of unicode strings in a terminal"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "zipp"
+version = "3.6.0"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
[metadata]
lock-version = "1.1"
-python-versions = "^3.6"
-content-hash = "36970da0e8c6151dcf68abd9008ecef35673f04db53952bfb3fd7544c0516b7f"
+python-versions = "^3.6.1"
+content-hash = "01f4aaa2c8fbbf76fa7442a0c64bf553fe95fd3503fde730cad71c06da957a58"
[metadata.files]
-pyyaml = [
- {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"},
- {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"},
- {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"},
- {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"},
- {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"},
- {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"},
- {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"},
- {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"},
- {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"},
- {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"},
- {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"},
- {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"},
- {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"},
- {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"},
- {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"},
- {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"},
- {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"},
- {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"},
- {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"},
- {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"},
- {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"},
- {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"},
- {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"},
- {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"},
- {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"},
- {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"},
- {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"},
- {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"},
- {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},
+atomicwrites = [
+ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
+ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
+]
+attrs = [
+ {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
+ {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
+]
+click = [
+ {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"},
+ {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"},
+]
+colorama = [
+ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
+ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
+]
+coverage = [
+ {file = "coverage-6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b"},
+ {file = "coverage-6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0"},
+ {file = "coverage-6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da"},
+ {file = "coverage-6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d"},
+ {file = "coverage-6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739"},
+ {file = "coverage-6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971"},
+ {file = "coverage-6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840"},
+ {file = "coverage-6.2-cp310-cp310-win32.whl", hash = "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c"},
+ {file = "coverage-6.2-cp310-cp310-win_amd64.whl", hash = "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f"},
+ {file = "coverage-6.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76"},
+ {file = "coverage-6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47"},
+ {file = "coverage-6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64"},
+ {file = "coverage-6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f467bbb837691ab5a8ca359199d3429a11a01e6dfb3d9dcc676dc035ca93c0a9"},
+ {file = "coverage-6.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2641f803ee9f95b1f387f3e8f3bf28d83d9b69a39e9911e5bfee832bea75240d"},
+ {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1219d760ccfafc03c0822ae2e06e3b1248a8e6d1a70928966bafc6838d3c9e48"},
+ {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9a2b5b52be0a8626fcbffd7e689781bf8c2ac01613e77feda93d96184949a98e"},
+ {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8e2c35a4c1f269704e90888e56f794e2d9c0262fb0c1b1c8c4ee44d9b9e77b5d"},
+ {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5d6b09c972ce9200264c35a1d53d43ca55ef61836d9ec60f0d44273a31aa9f17"},
+ {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e3db840a4dee542e37e09f30859f1612da90e1c5239a6a2498c473183a50e781"},
+ {file = "coverage-6.2-cp36-cp36m-win32.whl", hash = "sha256:4e547122ca2d244f7c090fe3f4b5a5861255ff66b7ab6d98f44a0222aaf8671a"},
+ {file = "coverage-6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:01774a2c2c729619760320270e42cd9e797427ecfddd32c2a7b639cdc481f3c0"},
+ {file = "coverage-6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb8b8ee99b3fffe4fd86f4c81b35a6bf7e4462cba019997af2fe679365db0c49"},
+ {file = "coverage-6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:619346d57c7126ae49ac95b11b0dc8e36c1dd49d148477461bb66c8cf13bb521"},
+ {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0a7726f74ff63f41e95ed3a89fef002916c828bb5fcae83b505b49d81a066884"},
+ {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cfd9386c1d6f13b37e05a91a8583e802f8059bebfccde61a418c5808dea6bbfa"},
+ {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:17e6c11038d4ed6e8af1407d9e89a2904d573be29d51515f14262d7f10ef0a64"},
+ {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c254b03032d5a06de049ce8bca8338a5185f07fb76600afff3c161e053d88617"},
+ {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dca38a21e4423f3edb821292e97cec7ad38086f84313462098568baedf4331f8"},
+ {file = "coverage-6.2-cp37-cp37m-win32.whl", hash = "sha256:600617008aa82032ddeace2535626d1bc212dfff32b43989539deda63b3f36e4"},
+ {file = "coverage-6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:bf154ba7ee2fd613eb541c2bc03d3d9ac667080a737449d1a3fb342740eb1a74"},
+ {file = "coverage-6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e"},
+ {file = "coverage-6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58"},
+ {file = "coverage-6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc"},
+ {file = "coverage-6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd"},
+ {file = "coverage-6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953"},
+ {file = "coverage-6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475"},
+ {file = "coverage-6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57"},
+ {file = "coverage-6.2-cp38-cp38-win32.whl", hash = "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c"},
+ {file = "coverage-6.2-cp38-cp38-win_amd64.whl", hash = "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2"},
+ {file = "coverage-6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd"},
+ {file = "coverage-6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685"},
+ {file = "coverage-6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c"},
+ {file = "coverage-6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3"},
+ {file = "coverage-6.2-cp39-cp39-win32.whl", hash = "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282"},
+ {file = "coverage-6.2-cp39-cp39-win_amd64.whl", hash = "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644"},
+ {file = "coverage-6.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de"},
+ {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"},
+]
+dataclasses = [
+ {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"},
+ {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"},
+]
+distlib = [
+ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"},
+ {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"},
+]
+filelock = [
+ {file = "filelock-3.4.1-py3-none-any.whl", hash = "sha256:a4bc51381e01502a30e9f06dd4fa19a1712eab852b6fb0f84fd7cce0793d8ca3"},
+ {file = "filelock-3.4.1.tar.gz", hash = "sha256:0f12f552b42b5bf60dba233710bf71337d35494fc8bdd4fd6d9f6d082ad45e06"},
+]
+importlib-metadata = [
+ {file = "importlib_metadata-4.8.3-py3-none-any.whl", hash = "sha256:65a9576a5b2d58ca44d133c42a241905cc45e34d2c06fd5ba2bafa221e5d7b5e"},
+ {file = "importlib_metadata-4.8.3.tar.gz", hash = "sha256:766abffff765960fcc18003801f7044eb6755ffae4521c8e8ce8e83b9c9b0668"},
+]
+importlib-resources = [
+ {file = "importlib_resources-5.4.0-py3-none-any.whl", hash = "sha256:33a95faed5fc19b4bc16b29a6eeae248a3fe69dd55d4d229d2b480e23eeaad45"},
+ {file = "importlib_resources-5.4.0.tar.gz", hash = "sha256:d756e2f85dd4de2ba89be0b21dba2a3bbec2e871a42a3a16719258a11f87506b"},
+]
+iniconfig = [
+ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
+ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
+]
+packaging = [
+ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
+ {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
+]
+platformdirs = [
+ {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"},
+ {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"},
+]
+pluggy = [
+ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
+ {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
+]
+py = [
+ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
+ {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
+]
+pydantic = [
+ {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"},
+ {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"},
+ {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"},
+ {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25"},
+ {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"},
+ {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"},
+ {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"},
+ {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"},
+ {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"},
+ {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c"},
+ {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"},
+ {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"},
+ {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"},
+ {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"},
+ {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"},
+ {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1"},
+ {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"},
+ {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"},
+ {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"},
+ {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"},
+ {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"},
+ {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9"},
+ {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6"},
+ {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"},
+ {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034"},
+ {file = "pydantic-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f"},
+ {file = "pydantic-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b"},
+ {file = "pydantic-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c"},
+ {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce"},
+ {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3"},
+ {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d"},
+ {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721"},
+ {file = "pydantic-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16"},
+ {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"},
+ {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"},
+]
+pyparsing = [
+ {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"},
+ {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"},
+]
+pytest = [
+ {file = "pytest-7.0.1-py3-none-any.whl", hash = "sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db"},
+ {file = "pytest-7.0.1.tar.gz", hash = "sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171"},
+]
+pytest-cov = [
+ {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"},
+ {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"},
+]
+"ruamel.yaml" = [
+ {file = "ruamel.yaml-0.17.21-py3-none-any.whl", hash = "sha256:742b35d3d665023981bd6d16b3d24248ce5df75fdb4e2924e93a05c1f8b61ca7"},
+ {file = "ruamel.yaml-0.17.21.tar.gz", hash = "sha256:8b7ce697a2f212752a35c1ac414471dc16c424c9573be4926b56ff3f5d23b7af"},
+]
+"ruamel.yaml.clib" = [
+ {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:cfdb9389d888c5b74af297e51ce357b800dd844898af9d4a547ffc143fa56751"},
+ {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7b2927e92feb51d830f531de4ccb11b320255ee95e791022555971c466af4527"},
+ {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-win32.whl", hash = "sha256:ada3f400d9923a190ea8b59c8f60680c4ef8a4b0dfae134d2f2ff68429adfab5"},
+ {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-win_amd64.whl", hash = "sha256:de9c6b8a1ba52919ae919f3ae96abb72b994dd0350226e28f3686cb4f142165c"},
+ {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d67f273097c368265a7b81e152e07fb90ed395df6e552b9fa858c6d2c9f42502"},
+ {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:72a2b8b2ff0a627496aad76f37a652bcef400fd861721744201ef1b45199ab78"},
+ {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-win32.whl", hash = "sha256:9efef4aab5353387b07f6b22ace0867032b900d8e91674b5d8ea9150db5cae94"},
+ {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-win_amd64.whl", hash = "sha256:846fc8336443106fe23f9b6d6b8c14a53d38cef9a375149d61f99d78782ea468"},
+ {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0847201b767447fc33b9c235780d3aa90357d20dd6108b92be544427bea197dd"},
+ {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:78988ed190206672da0f5d50c61afef8f67daa718d614377dcd5e3ed85ab4a99"},
+ {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-win32.whl", hash = "sha256:a49e0161897901d1ac9c4a79984b8410f450565bbad64dbfcbf76152743a0cdb"},
+ {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-win_amd64.whl", hash = "sha256:bf75d28fa071645c529b5474a550a44686821decebdd00e21127ef1fd566eabe"},
+ {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a32f8d81ea0c6173ab1b3da956869114cae53ba1e9f72374032e33ba3118c233"},
+ {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7f7ecb53ae6848f959db6ae93bdff1740e651809780822270eab111500842a84"},
+ {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-win32.whl", hash = "sha256:89221ec6d6026f8ae859c09b9718799fea22c0e8da8b766b0b2c9a9ba2db326b"},
+ {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-win_amd64.whl", hash = "sha256:31ea73e564a7b5fbbe8188ab8b334393e06d997914a4e184975348f204790277"},
+ {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dc6a613d6c74eef5a14a214d433d06291526145431c3b964f5e16529b1842bed"},
+ {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:1866cf2c284a03b9524a5cc00daca56d80057c5ce3cdc86a52020f4c720856f0"},
+ {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-win32.whl", hash = "sha256:3fb9575a5acd13031c57a62cc7823e5d2ff8bc3835ba4d94b921b4e6ee664104"},
+ {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-win_amd64.whl", hash = "sha256:825d5fccef6da42f3c8eccd4281af399f21c02b32d98e113dbc631ea6a6ecbc7"},
+ {file = "ruamel.yaml.clib-0.2.6.tar.gz", hash = "sha256:4ff604ce439abb20794f05613c374759ce10e3595d1867764dd1ae675b85acbd"},
+]
+six = [
+ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+toml = [
+ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
+]
+tomli = [
+ {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"},
+ {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"},
+]
+typing-extensions = [
+ {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"},
+ {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"},
+]
+virtualenv = [
+ {file = "virtualenv-20.13.1-py2.py3-none-any.whl", hash = "sha256:45e1d053cad4cd453181ae877c4ffc053546ae99e7dd049b9ff1d9be7491abf7"},
+ {file = "virtualenv-20.13.1.tar.gz", hash = "sha256:e0621bcbf4160e4e1030f05065c8834b4e93f4fcc223255db2a823440aca9c14"},
+]
+wcwidth = [
+ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
+ {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
+]
+zipp = [
+ {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"},
+ {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"},
]
diff --git a/pyproject.toml b/pyproject.toml
index 9e5d4ca..7407cf8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,14 +1,22 @@
[tool.poetry]
name = "kiwi-scp"
-version = "0.1.7"
+version = "0.2.0"
description = "kiwi is the simple tool for managing container servers."
authors = ["ldericher <40151420+ldericher@users.noreply.github.com>"]
[tool.poetry.dependencies]
-python = "^3.6"
-PyYAML = "^5.4.1"
+python = "^3.6.1"
+attrs = "^21.2.0"
+click = "^8.0.3"
+pydantic = "^1.8.2"
+"ruamel.yaml" = "^0.17.16"
+wcwidth = "^0.2.5"
[tool.poetry.dev-dependencies]
+pytest = "^7.0.0"
+pytest-cov = "^3.0.0"
+toml = "^0.10.2"
+virtualenv = "^20.10.0"
[tool.poetry.scripts]
kiwi = "kiwi_scp.scripts.kiwi:main"
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..cb3bd90
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,491 @@
+from ipaddress import IPv4Network
+from pathlib import Path
+
+import pytest
+from pydantic import ValidationError
+
+from kiwi_scp.config import KiwiConfig
+from kiwi_scp.yaml import YAML
+
+
+class UnCoercibleError(ValueError):
+ pass
+
+
+class UnCoercible:
+ """A class that doesn't have a string representation"""
+
+ def __str__(self):
+ raise UnCoercibleError()
+
+ def __repr__(self) -> str:
+ return "UnCoercible()"
+
+
+class TestDefault:
+ def test(self):
+ import toml
+
+ c = KiwiConfig()
+ version = toml.load("./pyproject.toml")["tool"]["poetry"]["version"]
+
+ assert c == KiwiConfig.from_default()
+
+ assert c.version == version
+ assert len(c.shells) == 1
+ assert c.shells[0] == Path("/bin/bash")
+ assert c.projects == []
+ assert c.environment == {}
+ assert c.storage.directory == Path("/var/local/kiwi")
+ assert c.network.name == "kiwi_hub"
+ assert c.network.cidr == IPv4Network("10.22.46.0/24")
+
+ kiwi_dict = {
+ "version": version,
+ "shells": ["/bin/bash"],
+ "storage": {"directory": "/var/local/kiwi"},
+ "network": {
+ "name": "kiwi_hub",
+ "cidr": "10.22.46.0/24",
+ },
+ }
+ assert c.kiwi_dict == kiwi_dict
+
+ assert c.kiwi_yml == YAML().dump_kiwi_yml(kiwi_dict)
+
+
+class TestVersion:
+ def test_valid(self):
+ c = KiwiConfig(version="0.0.0")
+ assert c.version == "0.0.0"
+
+ c = KiwiConfig(version="0.0")
+ assert c.version == "0.0"
+
+ c = KiwiConfig(version="0")
+ assert c.version == "0"
+
+ c = KiwiConfig(version=1.0)
+ assert c.version == "1.0"
+
+ c = KiwiConfig(version=1)
+ assert c.version == "1"
+
+ def test_invalid(self):
+ # definitely not a version
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(version="dnaf")
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"].find("string does not match regex") != -1
+ assert error["type"] == "value_error.str.regex"
+
+ # almost a version
+ with pytest.raises(ValidationError) as exc_info:
+ c = KiwiConfig(version="0.0.0alpha")
+ print(c.version)
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"].find("string does not match regex") != -1
+ assert error["type"] == "value_error.str.regex"
+
+
+class TestShells:
+ def test_empty(self):
+ c = KiwiConfig(shells=None)
+
+ assert c == KiwiConfig(shells=[])
+
+ assert c.shells == []
+
+ def test_list(self):
+ c = KiwiConfig(shells=["/bin/sh", "sh"])
+
+ assert len(c.shells) == 2
+ assert c.shells[0] == Path("/bin/sh")
+ assert c.shells[1] == Path("sh")
+
+ c = KiwiConfig(shells=["/bin/bash"])
+
+ assert len(c.shells) == 1
+ assert c.shells[0] == Path("/bin/bash")
+
+ def test_dict(self):
+ c = KiwiConfig(shells={"/bin/bash": None})
+
+ assert len(c.shells) == 1
+ assert c.shells[0] == Path("/bin/bash")
+
+ def test_coercible(self):
+ c = KiwiConfig(shells="/bin/bash")
+
+ assert c == KiwiConfig(shells=Path("/bin/bash"))
+
+ assert len(c.shells) == 1
+ assert c.shells[0] == Path("/bin/bash")
+
+ c = KiwiConfig(shells=123)
+
+ assert len(c.shells) == 1
+ assert c.shells[0] == Path("123")
+
+ def test_uncoercible(self):
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(shells=UnCoercible())
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "Invalid 'KiwiConfig'.'shells' Format: UnCoercible()"
+ assert error["type"] == "value_error.invalidformat"
+
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(shells=["/bin/bash", UnCoercible()])
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "value is not a valid path"
+ assert error["type"] == "type_error.path"
+
+
+class TestProject:
+ def test_empty(self):
+ c = KiwiConfig(projects=None)
+
+ assert c == KiwiConfig(projects=[])
+ assert c.projects == []
+
+ assert c.get_project_config("invalid") is None
+
+ def test_long(self):
+ kiwi_dict = {
+ "name": "project",
+ "enabled": False,
+ "override_storage": {"directory": "/test/directory"},
+ }
+ c = KiwiConfig(projects=[kiwi_dict])
+
+ assert len(c.projects) == 1
+ p = c.projects[0]
+ assert p.name == "project"
+ assert p == c.get_project_config("project")
+ assert not p.enabled
+ assert p.override_storage is not None
+
+ assert c.kiwi_dict["projects"][0] == kiwi_dict
+
+ def test_storage_str(self):
+ kiwi_dict = {
+ "name": "project",
+ "enabled": False,
+ "override_storage": "/test/directory",
+ }
+ c = KiwiConfig(projects=[kiwi_dict])
+
+ assert len(c.projects) == 1
+ p = c.projects[0]
+ assert p.name == "project"
+ assert not p.enabled
+ assert p.override_storage is not None
+
+ def test_storage_list(self):
+ kiwi_dict = {
+ "name": "project",
+ "enabled": False,
+ "override_storage": ["/test/directory"],
+ }
+ c = KiwiConfig(projects=[kiwi_dict])
+
+ assert len(c.projects) == 1
+ p = c.projects[0]
+ assert p.name == "project"
+ assert not p.enabled
+ assert p.override_storage is not None
+
+ def test_storage_invalid(self):
+ kiwi_dict = {
+ "name": "project",
+ "enabled": False,
+ "override_storage": True,
+ }
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(projects=[kiwi_dict])
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "Invalid 'StorageConfig' Format: '{}'"
+ assert error["type"] == "value_error.invalidformat"
+
+ def test_short(self):
+ kiwi_dict = {
+ "project": False,
+ }
+ c = KiwiConfig(projects=[kiwi_dict])
+
+ assert len(c.projects) == 1
+ p = c.projects[0]
+ assert p.name == "project"
+ assert not p.enabled
+ assert p.override_storage is None
+
+ resulting_kiwi_dict = {
+ "name": "project",
+ "enabled": False,
+ }
+ assert p.kiwi_dict == resulting_kiwi_dict
+
+ def test_dict(self):
+ c = KiwiConfig(projects={"name": "project"})
+
+ assert c == KiwiConfig(projects=[{"name": "project"}])
+
+ assert len(c.projects) == 1
+ p = c.projects[0]
+ assert p.name == "project"
+ assert p.enabled
+ assert p.override_storage is None
+
+ def test_reserved_name(self):
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(projects={"name": "config"})
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "Project name 'config' is reserved!"
+ assert error["type"] == "value_error.projectnamereserved"
+
+ def test_invalid_dict(self):
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(projects={
+ "random key 1": "random value 1",
+ "random key 2": "random value 2",
+ })
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "Invalid 'ProjectConfig' Format: " \
+ "{'random key 1': 'random value 1', 'random key 2': 'random value 2'}"
+ assert error["type"] == "value_error.invalidformat"
+
+ def test_coercible(self):
+ c = KiwiConfig(projects="project")
+
+ assert c == KiwiConfig(projects=["project"])
+
+ assert len(c.projects) == 1
+ p = c.projects[0]
+ assert p.name == "project"
+ assert p.enabled
+ assert p.override_storage is None
+
+ def test_uncoercible(self):
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(projects=["valid", UnCoercible()])
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "Invalid 'KiwiConfig'.'projects' Format: ['valid', UnCoercible()]"
+ assert error["type"] == "value_error.invalidformat"
+
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(projects=UnCoercible())
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "Invalid 'KiwiConfig'.'projects' Format: UnCoercible()"
+ assert error["type"] == "value_error.invalidformat"
+
+
+class TestEnvironment:
+ def test_empty(self):
+ c = KiwiConfig(environment=None)
+
+ assert c.environment == {}
+
+ def test_dict(self):
+ c = KiwiConfig(environment={})
+
+ assert c.environment == {}
+
+ kiwi_dict = {"variable": "value"}
+ c = KiwiConfig(environment=kiwi_dict)
+
+ assert len(c.environment) == 1
+ assert "variable" in c.environment
+ assert c.environment["variable"] == "value"
+
+ assert c.kiwi_dict["environment"] == kiwi_dict
+
+ def test_list(self):
+ c = KiwiConfig(environment=[])
+
+ assert c.environment == {}
+
+ c = KiwiConfig(environment=[
+ "variable=value",
+ ])
+
+ assert len(c.environment) == 1
+ assert "variable" in c.environment
+ assert c.environment["variable"] == "value"
+
+ c = KiwiConfig(environment=[
+ "variable",
+ ])
+
+ assert len(c.environment) == 1
+ assert "variable" in c.environment
+ assert c.environment["variable"] is None
+
+ c = KiwiConfig(environment=[
+ 123,
+ ])
+
+ assert len(c.environment) == 1
+ assert "123" in c.environment
+ assert c.environment["123"] is None
+
+ def test_coercible(self):
+ c = KiwiConfig(environment="variable=value")
+
+ assert len(c.environment) == 1
+ assert "variable" in c.environment
+ assert c.environment["variable"] == "value"
+
+ c = KiwiConfig(environment="variable")
+
+ assert len(c.environment) == 1
+ assert "variable" in c.environment
+ assert c.environment["variable"] is None
+
+ c = KiwiConfig(environment=123)
+
+ assert len(c.environment) == 1
+ assert "123" in c.environment
+ assert c.environment["123"] is None
+
+ c = KiwiConfig(environment=123.4)
+
+ assert len(c.environment) == 1
+ assert "123.4" in c.environment
+ assert c.environment["123.4"] is None
+
+ def test_uncoercible(self):
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(environment=UnCoercible())
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "Invalid 'KiwiConfig'.'environment' Format: UnCoercible()"
+ assert error["type"] == "value_error.invalidformat"
+
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(environment=["valid", UnCoercible()])
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "Invalid 'KiwiConfig'.'environment' Format: None"
+ assert error["type"] == "value_error.invalidformat"
+
+
+class TestStorage:
+ def test_empty(self):
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(storage=None)
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "Member 'KiwiConfig'.'storage' is required!"
+ assert error["type"] == "value_error.missingmember"
+
+ def test_dict(self):
+ kiwi_dict = {"directory": "/test/directory"}
+ c = KiwiConfig(storage=kiwi_dict)
+
+ assert c.storage.directory == Path("/test/directory")
+ assert c.storage.kiwi_dict == kiwi_dict
+
+ def test_invalid_dict(self):
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(storage={"random key": "random value"})
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "Invalid 'StorageConfig' Format: \"{'random key': 'random value'}\""
+ assert error["type"] == "value_error.invalidformat"
+
+ def test_str(self):
+ c = KiwiConfig(storage="/test/directory")
+
+ assert c.storage.directory == Path("/test/directory")
+
+ def test_list(self):
+ c = KiwiConfig(storage=["/test/directory"])
+
+ assert c.storage.directory == Path("/test/directory")
+
+ def test_invalid(self):
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(storage=True)
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "Invalid 'StorageConfig' Format: '{}'"
+ assert error["type"] == "value_error.invalidformat"
+
+
+class TestNetwork:
+ def test_empty(self):
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(network=None)
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "Member 'KiwiConfig'.'network' is required!"
+ assert error["type"] == "value_error.missingmember"
+
+ def test_dict(self):
+ kiwi_dict = {
+ "name": "test_hub",
+ "cidr": "1.2.3.4/32",
+ }
+ c = KiwiConfig(network=kiwi_dict)
+
+ assert c == KiwiConfig(network={
+ "name": "TEST_HUB",
+ "cidr": "1.2.3.4/32",
+ })
+
+ assert c.network.name == "test_hub"
+ assert c.network.cidr == IPv4Network("1.2.3.4/32")
+ assert c.network.kiwi_dict == kiwi_dict
+
+ def test_invalid_dict(self):
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(network={"name": "test_hub"})
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "field required"
+ assert error["type"] == "value_error.missing"
+
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(network={
+ "name": "test_hub",
+ "cidr": "1.2.3.4/123",
+ })
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "value is not a valid IPv4 network"
+ assert error["type"] == "value_error.ipv4network"
+
+ def test_invalid(self):
+ with pytest.raises(ValidationError) as exc_info:
+ KiwiConfig(network=True)
+
+ assert len(exc_info.value.errors()) == 1
+ error = exc_info.value.errors()[0]
+ assert error["msg"] == "Invalid 'KiwiConfig'.'network' Format: True"
+ assert error["type"] == "value_error.invalidformat"
diff --git a/tests/test_instance.py b/tests/test_instance.py
new file mode 100644
index 0000000..4b7f9a5
--- /dev/null
+++ b/tests/test_instance.py
@@ -0,0 +1,29 @@
+from pathlib import Path
+
+from kiwi_scp.instance import Instance
+
+
+class TestDefault:
+ def test_example(self):
+ i = Instance(Path("example"))
+
+ assert i.config is not None
+ assert len(i.config.projects) == 1
+
+ pc = i.config.projects[0]
+
+ assert pc.name == "hello_world"
+
+ def test_empty(self):
+ i = Instance()
+
+ assert i.config is not None
+ assert len(i.config.projects) == 0
+
+ def test_no_such_dir(self):
+ nonexistent_path = Path("nonexistent")
+ i = Instance(nonexistent_path)
+
+ assert i.directory == nonexistent_path
+ assert i.config is not None
+ assert len(i.config.projects) == 0
diff --git a/tests/test_project.py b/tests/test_project.py
new file mode 100644
index 0000000..8234e32
--- /dev/null
+++ b/tests/test_project.py
@@ -0,0 +1,40 @@
+from pathlib import Path
+
+import pytest
+
+from kiwi_scp._constants import COMPOSE_FILE_NAME
+from kiwi_scp.config import KiwiConfig
+from kiwi_scp.project import Project
+
+
+class TestDefault:
+ cfg = KiwiConfig()
+
+ def test_example(self):
+ p = Project(
+ directory=Path("example/hello_world"),
+ parent_instance=None,
+ )
+
+ ss = p.services
+
+ assert len(ss.content) == 5
+
+ s = ss.content[0]
+
+ assert s.name == "greeter"
+
+ ss2 = p.services.filter_existing(["nonexistent"])
+
+ assert len(ss2.content) == 0
+
+ def test_empty(self):
+ p = Project(
+ directory=Path("nonexistent"),
+ parent_instance=None,
+ )
+
+ with pytest.raises(FileNotFoundError) as exc_info:
+ _ = p.services
+
+ assert exc_info.value.filename == f"nonexistent/{COMPOSE_FILE_NAME}"
diff --git a/tests/test_service.py b/tests/test_service.py
new file mode 100644
index 0000000..d172f06
--- /dev/null
+++ b/tests/test_service.py
@@ -0,0 +1,66 @@
+from pathlib import Path
+
+from ruamel.yaml import CommentedMap
+
+from kiwi_scp.service import Service
+
+
+class TestDefault:
+ def test_empty(self):
+ s = Service(
+ name="s",
+ content=CommentedMap(),
+ parent_project=None,
+ )
+
+ assert s.name == "s"
+ assert list(s.configs) == []
+
+ def test_no_configs(self):
+ s = Service(
+ name="s",
+ content=CommentedMap({
+ "image": "repo/image:tag",
+ }),
+ parent_project=None,
+ )
+
+ assert s.name == "s"
+ assert list(s.configs) == []
+
+ def test_no_configs_in_volumes(self):
+ s = Service(
+ name="s",
+ content=CommentedMap({
+ "image": "repo/image:tag",
+ "volumes": [
+ "docker_volume/third/dir:/path/to/third/mountpoint",
+ "${KIWI_PROJECT}/some/dir:/path/to/some/mountpoint",
+ "$KIWI_PROJECT/other/dir:/path/to/other/mountpoint",
+ ]
+ }),
+ parent_project=None,
+ )
+
+ assert s.name == "s"
+ assert list(s.configs) == []
+
+ def test_with_configs(self):
+ s = Service(
+ name="s",
+ content=CommentedMap({
+ "image": "repo/image:tag",
+ "volumes": [
+ "${KIWI_CONFIG}/some/config:/path/to/some/config",
+ "$KIWI_CONFIG/other/config:/path/to/other/config",
+ ]
+ }),
+ parent_project=None,
+ )
+
+ assert s.name == "s"
+ assert len(list(s.configs)) == 2
+ assert list(s.configs) == [
+ Path("some/config"),
+ Path("other/config"),
+ ]
diff --git a/tests/test_services.py b/tests/test_services.py
new file mode 100644
index 0000000..cfb7716
--- /dev/null
+++ b/tests/test_services.py
@@ -0,0 +1,16 @@
+from ruamel.yaml import CommentedMap
+
+from kiwi_scp.service import Service
+from kiwi_scp.services import Services
+
+
+class TestServices:
+ def test_empty(self):
+ s = Service(
+ name="s",
+ content=CommentedMap(),
+ parent_project=None,
+ )
+ ss = Services([s])
+
+ assert str(ss) == "services:\n s: {}\nconfigs: []"