Repository: wolcomm/eos-prefix-list-agent (language: python)

Issue:
port to py3
- [x] EOS SDK agent
- [ ] CLI extension
- [ ] URL plugin

Show operational config in agent status



Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index 015d9d9..0000000
--- a/.coveragerc
+++ /dev/null
@@ -1,8 +0,0 @@
-[run]
-source = prefix_list_agent
-
-[report]
-exclude_lines =
-    pragma: no cover
-    def __repr__
-    if __name__ == .__main__.:
diff --git a/.dockerignore b/.earthignore
similarity index 100%
rename from .dockerignore
rename to .earthignore
diff --git a/.github/actions/earthly/action.yml b/.github/actions/earthly/action.yml
new file mode 100644
index 0000000..88d5650
--- /dev/null
+++ b/.github/actions/earthly/action.yml
@@ -0,0 +1,87 @@
+name: earthly
+description: "Run an earthly target"
+inputs:
+  target:
+    description: "earthly target to run"
+    required: true
+  build_args:
+    description: "build-args for earthly target"
+    required: false
+    default: "DUMMY=true"
+  secrets:
+    description: "secrets for earthly target"
+    required: false
+    default: "DUMMY=true"
+  push:
+    description: "allow earthly push actions"
+    required: false
+    default: "false"
+  allow_privileged:
+    description: "allow earthly to run privileged containers"
+    required: false
+    default: "false"
+  earthly_version:
+    description: "earthly release version"
+    required: false
+    default: "0.6.2"
+  ghcr_token:
+    description: "ghcr authentication token"
+    required: true
+  acr_uri:
+    description: "acr uri"
+    required: false
+    default: "workonline.azurecr.io"
+  acr_token_name:
+    description: "acr authentication token name"
+    required: false
+    default: "gh-actions-eos-prefix-list-agent"
+  acr_token:
+    description: "acr authentication token"
+    required: true
+  artifact_path:
+    description: "artifact path to upload"
+    required: false
+  artifact_name:
+    description: "artifact name"
+    required: false
+runs:
+  using: composite
+  steps:
+    - name: install earthly
+      shell: bash
+      run: |
+        sudo wget "https://github.com/earthly/earthly/releases/download/v${{ inputs.earthly_version }}/earthly-linux-amd64" \
+          -nv -O /usr/local/bin/earthly && \
+        sudo chmod +x /usr/local/bin/earthly
+    - name: bootstrap earthly
+      shell: bash
+      run: /usr/local/bin/earthly bootstrap
+    - name: login to azure acr
+      uses: docker/login-action@v1
+      with:
+        registry: ${{ inputs.acr_uri }}
+        username: ${{ inputs.acr_token_name }}
+        password: ${{ inputs.acr_token }}
+    - name: login to ghcr
+      uses: docker/login-action@v1
+      with:
+        registry: ghcr.io
+        username: ${{ github.actor }}
+        password: ${{ inputs.ghcr_token }}
+    - name: run ${{ inputs.target }} target
+      shell: bash
+      run: earthly +${{ inputs.target }}
+      env:
+        EARTHLY_CI: "true"
+        EARTHLY_BUILD_ARGS: ${{ inputs.build_args }}
+        EARTHLY_SECRETS: ${{ inputs.secrets }}
+        EARTHLY_PUSH: ${{ inputs.push }}
+        EARTHLY_ALLOW_PRIVILEGED: ${{ inputs.allow_privileged }}
+        EARTHLY_OUTPUT: ${{ inputs.artifact_path != '' }}
+        FORCE_COLOR: "1"
+    - name: upload artifact
+      if: inputs.artifact_path != ''
+      uses: actions/upload-artifact@v2
+      with:
+        name: ${{ inputs.artifact_name || inputs.target }}
+        path: ${{ inputs.artifact_path }}
diff --git a/.github/codecov.yml b/.github/codecov.yml
new file mode 100644
index 0000000..f462dcc
--- /dev/null
+++ b/.github/codecov.yml
@@ -0,0 +1,8 @@
+coverage:
+  status:
+    project:
+      default:
+        target: auto
+        threshold: 0%
+comment:
+  behavior: new
diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml
new file mode 100644
index 0000000..a02a3e9
--- /dev/null
+++ b/.github/workflows/cicd.yml
@@ -0,0 +1,161 @@
+name: "CI/CD"
+on:
+  - push
+  - pull_request
+jobs:
+  safety:
+    runs-on: ubuntu-latest
+    steps:
+      - name: checkout
+        uses: actions/checkout@v2
+      - name: earthly
+        uses: ./.github/actions/earthly
+        with:
+          target: safety
+          secrets: "SAFETY_API_KEY=${{ secrets.SAFETY_API_KEY }}"
+          push: ${{ github.ref == 'refs/heads/master' }}
+          ghcr_token: ${{ secrets.GITHUB_TOKEN }}
+          acr_token: ${{ secrets.AZURE_ACR_TOKEN }}
+  lint:
+    needs: [safety]
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        pkg:
+          - "prefix_list_agent"
+          - "prefix_list_agent_cli"
+    steps:
+      - name: checkout
+        uses: actions/checkout@v2
+      - name: earthly
+        uses: ./.github/actions/earthly
+        with:
+          target: lint
+          build_args: "PKG=${{ matrix.pkg }}"
+          ghcr_token: ${{ secrets.GITHUB_TOKEN }}
+          acr_token: ${{ secrets.AZURE_ACR_TOKEN }}
+  typecheck:
+    needs: [safety]
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        pkg:
+          - "prefix_list_agent"
+          - "prefix_list_agent_cli"
+    steps:
+      - name: checkout
+        uses: actions/checkout@v2
+      - name: earthly
+        uses: ./.github/actions/earthly
+        with:
+          target: typecheck
+          build_args: "PKG=${{ matrix.pkg }}"
+          ghcr_token: ${{ secrets.GITHUB_TOKEN }}
+          acr_token: ${{ secrets.AZURE_ACR_TOKEN }}
+  build:
+    needs: [safety]
+    runs-on: ubuntu-latest
+    steps:
+      - name: checkout
+        uses: actions/checkout@v2
+        with:
+          fetch-depth: 0
+      - name: earthly
+        uses: ./.github/actions/earthly
+        with:
+          target: build-swix
+          artifact_path: dist/swix/*.swix
+          push: ${{ github.ref == 'refs/heads/master' }}
+          ghcr_token: ${{ secrets.GITHUB_TOKEN }}
+          acr_token: ${{ secrets.AZURE_ACR_TOKEN }}
+  test:
+    needs: [build]
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        ceos_version:
+          - "4.26.3M"
+          - "4.27.0F"
+    steps:
+      - name: checkout
+        uses: actions/checkout@v2
+      - name: download built package
+        uses: actions/download-artifact@v2
+        with:
+          name: build-swix
+      - name: earthly
+        uses: ./.github/actions/earthly
+        with:
+          target: test
+          build_args: "CEOS_VERSION=${{ matrix.ceos_version }},LOCAL_PACKAGE=./*.swix"
+          artifact_name: coverage-ceos-${{ matrix.ceos_version }}
+          artifact_path: coverage.xml
+          allow_privileged: true
+          push: ${{ github.ref == 'refs/heads/master' }}
+          ghcr_token: ${{ secrets.GITHUB_TOKEN }}
+          acr_token: ${{ secrets.AZURE_ACR_TOKEN }}
+      - name: upload coverage report
+        uses: codecov/codecov-action@v2
+        env:
+          CEOS_VERSION: ${{ matrix.ceos_version }}
+        with:
+          files: coverage.xml
+          env_vars: CEOS_VERSION
+  build-docs:
+    needs: [safety]
+    runs-on: ubuntu-latest
+    steps:
+      - name: checkout
+        uses: actions/checkout@v2
+      - name: earthly
+        uses: ./.github/actions/earthly
+        with:
+          target: docs
+          artifact_path: dist/book
+          ghcr_token: ${{ secrets.GITHUB_TOKEN }}
+          acr_token: ${{ secrets.AZURE_ACR_TOKEN }}
+  publish-docs:
+    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tag')
+    needs:
+      - lint
+      - typecheck
+      - test
+      - build-docs
+    runs-on: ubuntu-latest
+    steps:
+      - name: checkout
+        uses: actions/checkout@v2
+      - name: download built docs artifact
+        uses: actions/download-artifact@v2
+        with:
+          name: docs
+          path: dist/book
+      - name: deploy to github-pages
+        uses: JamesIves/github-pages-deploy-action@4.2.2
+        with:
+          branch: gh-pages
+          folder: dist/book
+  publish-release:
+    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tag')
+    needs:
+      - lint
+      - typecheck
+      - test
+      - build-docs
+    runs-on: ubuntu-latest
+    steps:
+      - name: checkout
+        uses: actions/checkout@v2
+      - name: download built package
+        uses: actions/download-artifact@v2
+        with:
+          name: build-swix
+      - name: publish release
+        uses: softprops/action-gh-release@v1
+        with:
+          files: "./*.swix"
+          draft: false
+          fail_on_unmatched_files: true
diff --git a/.gitignore b/.gitignore
index 898257a..c70eac6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,14 +11,15 @@ __pycache__/
 
 # Distribution / packaging
 .Python
-build/
+/build/
 develop-eggs/
 dist/
 bdist_rpm/
+bdist/
 downloads/
 eggs/
 .eggs/
-lib/
+# lib/
 lib64/
 parts/
 sdist/
@@ -33,7 +34,6 @@ MANIFEST
 #  Usually these files are written by a python script from a template
 #  before PyInstaller builds the exe, so as to inject date/other infos into it.
 *.manifest
-*.spec
 
 # Installer logs
 pip-log.txt
diff --git a/.markdownlint.yml b/.markdownlint.yml
new file mode 100644
index 0000000..6f73e36
--- /dev/null
+++ b/.markdownlint.yml
@@ -0,0 +1,28 @@
+# Markdown linting rules
+
+# Use defaults
+default: true
+
+# Heading style
+MD003:
+  style: "atx"
+
+# Un-ordered list style
+MD004:
+  style: "dash"
+
+# Number of spaces to indent nested lists
+MD007:
+  indent: 4
+
+# Number of spaces following marker in lists
+MD030:
+  ul_multi: 3
+  ol_multi: 2
+
+# Disallow in-line html
+MD033: true
+
+# Horizontal rule style
+MD035:
+  style: "---"
diff --git a/.pyup.yml b/.pyup.yml
new file mode 100644
index 0000000..ee34bba
--- /dev/null
+++ b/.pyup.yml
@@ -0,0 +1,8 @@
+update: all
+pin: True
+assignees:
+  - benmaddison
+label_prs: dependencies
+requirements:
+  - Pipfile
+  - Pipfile.lock
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 9858ce4..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,68 +0,0 @@
-dist: xenial
-language: python
-python:
-- '2.7'
-services:
-- docker
-env:
-  global:
-  - AZURE_ACR_URI="workonline.azurecr.io"
-  - AZURE_ACR_TOKEN_NAME="travis-ci-eos-prefix-list-agent"
-  # AZURE_ACR_TOKEN
-  - secure: "IsXsMf9pRjW9abbFy4Hdjy6tvMexVmZGe+5kXblJ2Rfepnkv9PdEw+OyjUe8wz2Wf3hZqINECVjfzqHOI3Rae63S+aZqGxRrns+WJafIJK1MpwAI+h4SXcFgAL9zFGmFnO3grYkG8R06jLsVx2hmGqzivAZDYwxmZ9xZJYP4eFTV8HKUlpcHxLQzRCIg3gDABB/GCFERayNusFTfMFBxs2ZQ++A3DMgek9muQhhAPPr2i0/wEG0WGtrVRSP0eTQOdL7de1oPjSOL+kukMlJxlsbI3vyC70D0AL/EXLrkzXcngjPMzqJ6Yy9qo/Z44T8WxELLVT/4Q5PmA2uwfrH7JMqOeUlDeq5NgmrfkFd8nBQ5RCjGHBvrU9A3mqGGth946UunEJjR2k6tCDhMOXyFdFLKM6IeIhWR5oHpheOfI2HlTxMjUSHP0efblMkMQ3rQM26uMwjQ2hWvz38oTTuszc2bT69YiznY+fxmQE0yNO/oSEqD8c+NTWBorWKSJtVQWCThz6jpYWoD8tlwyvghLKVDg9f9X1wytILBOrN7cFo/mJP36Lm4uFOICY9EDgzXZlGZcTc83Dgnm1ibT5R8mS9vPhMo0lvY8/A5e8soam1dJkA7ya3MJjHNvuSTMFLiU4qaf88y+fjX2LSw78csxNFQbynKMOr4HbTifO373+4="
-  - CEOS_VERSION_LATEST="4.27.0F"
-  matrix:
-  - CEOS_VERSION="4.22.4M"
-  - CEOS_VERSION="4.23.10M"
-  - CEOS_VERSION="4.24.4M"
-  - CEOS_VERSION="4.25.5M"
-  - CEOS_VERSION="4.26.3M"
-  - CEOS_VERSION="4.27.0F"
-matrix:
-  fast_finish: true
-before_install:
-- export PIP_PREFIX="${VIRTUAL_ENV}"
-- pip install --upgrade pip setuptools codecov
-- echo -n "${AZURE_ACR_TOKEN}" | docker login --username "${AZURE_ACR_TOKEN_NAME}" --password-stdin "${AZURE_ACR_URI}"
-install:
-- IMAGE_ID="$(docker build --build-arg REGISTRY="${AZURE_ACR_URI}" --build-arg VERSION="${CEOS_VERSION}" --quiet .)"
-- CONTAINER_ID="$(docker run --detach --privileged --rm "${IMAGE_ID}")"
-script:
-- docker exec --interactive --tty "${CONTAINER_ID}" pytest
-after_success:
-- docker cp "${CONTAINER_ID}:/root/coverage.xml" "./"
-- codecov -e CEOS_VERSION
-before_deploy:
-- sudo apt-get update -q
-- sudo apt-get install -y rpm
-- mkdir -p bdist_rpm
-deploy:
-- provider: pypi
-  distributions: sdist bdist_wheel bdist_rpm
-  user: __token__
-  password:
-    secure: h97BXyndv9HiCs4L/qRyx2JIIV+peQdZiOW5Zc4aMbQWrTnlgV163h2LZ9V7SzS9Kwk7EnWsAgyLzVp9mJI1Rnc+r8RQQ56D7bcrDogSZnW/qxgGEv+XbEg3aC4IbdnOKMFQWFU4i4rt0Bu+MAAJUwnIOiJ4gbyVKqmYzFoPmPBVAJMC1BOZd3Gr5BblxQzjibi8mVIGNVx3iB7n/mlBqmn1wPii8nccYoNTUtqU6+k0lxCH/5HdRvqXgrZMRM0Jh3n4Y0rGuwxl+EwnqB58jXGc3cFMYrB6ZEWiOC6ZfdWegOdSD5hMuffZ50zZ3yYd5Ii282Yj74eT8r7W90fxLhpsEg3Z4RwZPPtgLUEiHolCJ/G+66utGlHbVEg/JhUjR+equNejH7Tf9igoLYc1G1aaiGUI83CDjFSJH0XakyczUhHKr7PZ8hLPvzTg7DNGyovy7sEuP2PWHKKYlw+zncGBN78ytnX0mJCwgnx4UdZwnbqC8jUZnDbXJKuK8Em2VMFOIF3FjgdDoKDu1J/UOZhuT6neIUwidJeCS2o5LohQktDro6lfwCyu1n5M1ZuSZXU3XHo53Tjm+1qIl6RLmKRnqfYJoNBHFfY0OqGG4Ft0b3JxyULleZA6XtCNNdU5aNJ1yb08Mkvx5PP91R57bmlN8hk27c9ybIwXMe6enSQ=
-  skip_cleanup: true
-  skip_existing: true
-  on:
-    tags: true
-    python: '2.7'
-    condition: "$CEOS_VERSION = $CEOS_VERSION_LATEST"
-- provider: releases
-  api_key:
-    secure: ehKR+Ze6T9ns7e+n5vfRNFFsl62Endugyda0eue9nWuW1LElTLO1JPEQySTXHuo5PkwG0aw+qbJMT6EN9lIJUPL0R9e3hw0CH0GhgR7wBM9d3itpF5Qcnd/wO0XX+kyh8pklu5J/qZ1fSfQGVPs6Qpn5Mx0bHcCHsl52i7xqM+3kyN7rzoTZkZC22obv5NO2SVlMcNquAcES38mFaz/eeV03XGRSL7w2X7mpwIilt3MEOYbDOLMv9Rk0cg/jSZZ6Dnc9vAaMaYtcFKuDqG7PmjDVgKiCd03Wz9aLg5aS37NFOJ/vA0L/v6cxMhkzOg1733wRZaSGQ7SG58z8Yrmk4+yD3dibKWzsshHEMHYQiIWSOBWG3sgTi8olzrnvLSEow69tzOXTs01bs0cjZuyX6ydT3x1NG20v1kBoHaxEWtGKDb5MNkg/69sb5Iz3huYeYcHfY+Tq5i6O+73k9H9NJjllwJ4IkOeBAeFgJHdfjWqtely2OcnRiKpnqUtUliCEkuzEln8SK71o1lKOgTy2r6804eWV9NZwLSyWm3E+SfEfpVnKCfLOe7rS7PtS0yAgicUcmGnYqeXoSlyj5o0i+KGZWTjJF/Jks5uimt+WmU2OLboi6eTvx0ramfq6gozSF+6r16zV2VVjUAE6YPGEkfI1+AUnlIrQUo1NtlsqddQ=
-  file_glob: true
-  file:
-  - dist/*
-  - bdist_rpm/*
-  skip_cleanup: true
-  skip_existing: true
-  on:
-    tags: true
-    python: '2.7'
-    condition: "$CEOS_VERSION = $CEOS_VERSION_LATEST"
-after_script:
-- docker kill "${CONTAINER_ID}"
-- docker image rm "${IMAGE_ID}"
-notifications:
-  email: false
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index ee12571..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,13 +0,0 @@
-ARG  REGISTRY
-ARG  VERSION
-FROM ${REGISTRY}/ceos-lab:${VERSION}
-
-WORKDIR /root
-
-COPY . .
-
-RUN echo "nameserver 9.9.9.9" > /etc/resolv.conf
-RUN pip install --upgrade pip
-RUN pip install --requirement packaging/requirements-test.txt
-RUN pip install --editable .
-RUN cp tests/data/startup-config /mnt/flash/
diff --git a/Earthfile b/Earthfile
new file mode 100644
index 0000000..df1c68f
--- /dev/null
+++ b/Earthfile
@@ -0,0 +1,212 @@
+VERSION 0.6
+
+FROM python:3.7-slim
+WORKDIR "/root/"
+
+all:
+    BUILD +safety
+    FOR PKG IN "prefix_list_agent" "prefix_list_agent_cli"
+        BUILD --build-arg PKG=$PKG +lint
+        BUILD --build-arg PKG=$PKG +typecheck
+        BUILD --build-arg PKG=$PKG +build-rpm
+    END
+    BUILD +build-swix
+    FOR VERSION IN "4.26.3M" "4.27.0F"
+        BUILD --build-arg CEOS_VERSION=$VERSION +test
+    END
+    BUILD +docs
+
+deps:
+    RUN apt update -qq && \
+        apt upgrade -qqy && \
+        apt install -qqy git g++ swig libssl-dev zip
+    RUN python -m pip install \
+        --progress-bar off \
+        --upgrade pip
+    RUN python -m pip install \
+        --progress-bar off \
+        --no-warn-script-location \
+        --user \
+        pipenv
+
+    RUN mkdir -p src
+    WORKDIR "src/"
+    COPY Pipfile Pipfile.lock .
+    RUN python -m pipenv sync --dev
+
+    LABEL org.opencontainers.image.source=https://github.com/wolcomm/eos-prefix-list-agent
+    SAVE IMAGE --push ghcr.io/wolcomm/eos-prefix-list-agent/ci-deps:latest
+
+safety:
+    BUILD +deps
+    FROM +deps
+    RUN --secret SAFETY_API_KEY python -m pipenv run safety check --full-report
+
+src:
+    ARG --required PKG
+    FROM +deps
+    COPY --dir $PKG/ .
+    COPY LICENSE README.md .
+    WORKDIR $PKG
+
+lint:
+    ARG --required PKG
+    FROM --build-arg PKG=$PKG +src
+    RUN python -m pipenv run flake8 .
+
+typecheck:
+    ARG --required PKG
+    FROM --build-arg PKG=$PKG +src
+    RUN python -m pipenv run mypy --package $PKG
+
+sdist:
+    ARG --required PKG
+    FROM --build-arg PKG=$PKG +src
+    COPY --dir .git/ ..
+    RUN python -m pipenv run python -m build --sdist --outdir dist/
+
+    SAVE ARTIFACT dist/*.tar.gz AS LOCAL dist/sdist/
+
+build-rpm:
+    FROM fedora:31
+
+    WORKDIR "/root"
+
+    RUN dnf update -y
+    RUN dnf install -y \
+            # python build deps
+            python3 python3-devel python3-pip \
+            python2 python2-devel python2-pip python2-wheel \
+            pyproject-rpm-macros \
+            # rpm build tools
+            rpm-build rpm-devel rpmlint rpmdevtools \
+            # for switools
+            gcc-c++ swig openssl-devel zip
+
+    RUN python3 -m pip install \
+            --disable-pip-version-check \
+            --progress-bar off \
+            --no-warn-script-location \
+            --user \
+            pipenv
+    COPY Pipfile Pipfile.lock .
+    RUN python3 -m pipenv lock --dev-only -r > requirements.txt
+    RUN python3 -m pip install \
+            --disable-pip-version-check \
+            --progress-bar off \
+            --prefix /usr \
+            --ignore-installed \
+            -r "requirements.txt"
+    
+    ARG --required PKG
+    RUN rpmdev-setuptree
+    COPY packaging/rpm/$PKG.spec ./rpmbuild/SPECS/
+    COPY --build-arg PKG=$PKG +sdist/*.tar.gz rpmbuild/SOURCES/
+
+    RUN SDIST="$(find rpmbuild/SOURCES/ -name '*.tar.gz' | head -n1)" && \
+        VERSION="$(tar zxfO "$SDIST" --no-anchored PKG-INFO | grep '^Version:' | head -n1 | cut -d' ' -f2)" && \
+        echo -n "$VERSION" > VERSION && \
+        rpmbuild -ba rpmbuild/SPECS/$PKG.spec -D "_version $VERSION"
+
+    SAVE ARTIFACT VERSION
+    SAVE ARTIFACT rpmbuild/RPMS/**/*.rpm AS LOCAL dist/rpm/
+
+    LABEL org.opencontainers.image.source=https://github.com/wolcomm/eos-prefix-list-agent
+    SAVE IMAGE --push ghcr.io/wolcomm/eos-prefix-list-agent/ci-build-rpm:$PKG
+
+build-swix:
+    FROM +deps
+
+    RUN mkdir -p dist rpms
+
+    FOR PKG IN "prefix_list_agent" "prefix_list_agent_cli"
+        BUILD --build-arg PKG=$PKG +build-rpm
+        COPY --build-arg PKG=$PKG +build-rpm/*.rpm rpms/
+    END
+
+    COPY --build-arg PKG="prefix_list_agent" +build-rpm/VERSION ./
+
+    COPY packaging/swix/manifest.yaml .
+    RUN SWIX="dist/eos-prefix-list-agent-$(cat VERSION).swix" && \
+        python -m pipenv run swix-create -i manifest.yaml "$SWIX" rpms/*.rpm
+
+    SAVE ARTIFACT dist/*.swix AS LOCAL dist/swix/
+
+test-image:
+    ARG --required CEOS_VERSION
+    FROM workonline.azurecr.io/ceos-lab:$CEOS_VERSION
+    WORKDIR "/root"
+
+    RUN python3 -m ensurepip --default-pip
+    RUN python3 -m pip install --upgrade pip
+
+    COPY Pipfile Pipfile.lock .
+
+    RUN python3 -m pip install \
+        --user \
+        --no-warn-script-location \
+        --progress-bar off \
+        pipenv
+    RUN python3 -m pipenv sync --system
+
+    COPY prefix_list_agent/pyproject.toml .
+    COPY tests/data/startup-config /mnt/flash/
+    COPY --dir tests .
+
+    SAVE IMAGE --cache-from ghcr.io/wolcomm/eos-prefix-list-agent/ci-test:$CEOS_VERSION
+
+    ARG LOCAL_PACKAGE
+    IF [ -n "$LOCAL_PACKAGE" ]
+        COPY $LOCAL_PACKAGE /mnt/flash/
+    ELSE
+        COPY +build-swix/*.swix /mnt/flash/
+    END
+
+    RUN SWIX="$(basename $(find /mnt/flash -name '*.swix'))" && \
+        echo "copy flash:/${SWIX} extension:" > install-extension-script && \
+        echo "extension ${SWIX}" >> install-extension-script
+
+    SAVE ARTIFACT install-extension-script
+
+    LABEL org.opencontainers.image.source=https://github.com/wolcomm/eos-prefix-list-agent
+    SAVE IMAGE --push ghcr.io/wolcomm/eos-prefix-list-agent/ci-test:$CEOS_VERSION
+
+test:
+    FROM earthly/dind:alpine
+
+    ARG --required CEOS_VERSION
+    BUILD --build-arg CEOS_VERSION=$CEOS_VERSION +test-image
+
+    COPY +test-image/install-extension-script .
+
+    WITH DOCKER --load test-image:latest=+test-image --build-arg CEOS_VERSION=$CEOS_VERSION
+        RUN docker run --detach --privileged --rm --name test test-image:latest && \
+            t=30; while [[ $t -gt 0 ]]; do \
+                echo -n "."; \
+                if docker exec --tty test /usr/bin/Cli -c 'show version' >/dev/null; then \
+                    started=1 && break; \
+                fi; \
+                sleep 1; \
+                let t--; \
+            done; \
+            [[ $started ]] && \
+            docker exec --tty test Cli -p 15 -c "$(cat install-extension-script)" && \
+            docker exec --tty test python3 -m pytest && \
+            docker cp test:/root/coverage.xml ./
+    END
+
+    SAVE ARTIFACT coverage.xml AS LOCAL coverage.xml
+
+docs:
+    FROM peaceiris/mdbook:latest
+    WORKDIR "/root"
+
+    COPY --dir docs .
+
+    RUN mdbook build docs/
+
+    SAVE ARTIFACT docs/book/ AS LOCAL dist/
+
+clean:
+    LOCALLY
+    RUN rm -rf dist/ coverage.xml
diff --git a/LICENSE b/LICENSE
index 6bea50a..3bcc2e9 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2019 Workonline Communications (Pty) Ltd
+Copyright (c) 2019-2022 Workonline Communications (Pty) Ltd
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index 2c4f273..0000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,3 +0,0 @@
-include README.md
-include LICENSE
-include packaging/requirements.txt
diff --git a/Pipfile b/Pipfile
new file mode 100644
index 0000000..40df19a
--- /dev/null
+++ b/Pipfile
@@ -0,0 +1,31 @@
+[[source]]
+url = "https://pypi.org/simple"
+verify_ssl = true
+name = "pypi"
+
+[packages]
+build = "*"
+flake8 = "*"
+flake8-bandit = "*"
+flake8-bugbear = "*"
+flake8-commas = "*"
+flake8-docstrings = "*"
+flake8-eradicate = "*"
+flake8-import-order = "*"
+pep8-naming = "*"
+radon = "*"
+safety = "*"
+mypy = "*"
+pytest = "*"
+pytest-cov = "*"
+pytest-mock = "*"
+gunicorn = "*"
+pyeapi = "*"
+Flask = "*"
+
+[dev-packages]
+build = "*"
+switools = "*"
+
+[requires]
+python_version = "3.7"
diff --git a/Pipfile.lock b/Pipfile.lock
new file mode 100644
index 0000000..9b151b6
--- /dev/null
+++ b/Pipfile.lock
@@ -0,0 +1,873 @@
+{
+    "_meta": {
+        "hash": {
+            "sha256": "7b642cbc6796fa317122ed927bf8ec5bdda3939fceeb02596aad1636fa21be99"
+        },
+        "pipfile-spec": 6,
+        "requires": {
+            "python_version": "3.7"
+        },
+        "sources": [
+            {
+                "name": "pypi",
+                "url": "https://pypi.org/simple",
+                "verify_ssl": true
+            }
+        ]
+    },
+    "default": {
+        "attrs": {
+            "hashes": [
+                "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1",
+                "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==21.2.0"
+        },
+        "bandit": {
+            "hashes": [
+                "sha256:a81b00b5436e6880fa8ad6799bc830e02032047713cbb143a12939ac67eb756c",
+                "sha256:f5acd838e59c038a159b5c621cf0f8270b279e884eadd7b782d7491c02add0d4"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==1.7.1"
+        },
+        "build": {
+            "hashes": [
+                "sha256:1aaadcd69338252ade4f7ec1265e1a19184bf916d84c9b7df095f423948cb89f",
+                "sha256:21b7ebbd1b22499c4dac536abc7606696ea4d909fd755e00f09f3c0f2c05e3c8"
+            ],
+            "index": "pypi",
+            "version": "==0.7.0"
+        },
+        "certifi": {
+            "hashes": [
+                "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872",
+                "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
+            ],
+            "version": "==2021.10.8"
+        },
+        "charset-normalizer": {
+            "hashes": [
+                "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721",
+                "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c"
+            ],
+            "markers": "python_version >= '3'",
+            "version": "==2.0.9"
+        },
+        "click": {
+            "hashes": [
+                "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3",
+                "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==8.0.3"
+        },
+        "colorama": {
+            "hashes": [
+                "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",
+                "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==0.4.4"
+        },
+        "coverage": {
+            "extras": [
+                "toml"
+            ],
+            "hashes": [
+                "sha256:01774a2c2c729619760320270e42cd9e797427ecfddd32c2a7b639cdc481f3c0",
+                "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd",
+                "sha256:0a7726f74ff63f41e95ed3a89fef002916c828bb5fcae83b505b49d81a066884",
+                "sha256:1219d760ccfafc03c0822ae2e06e3b1248a8e6d1a70928966bafc6838d3c9e48",
+                "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76",
+                "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0",
+                "sha256:17e6c11038d4ed6e8af1407d9e89a2904d573be29d51515f14262d7f10ef0a64",
+                "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685",
+                "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47",
+                "sha256:2641f803ee9f95b1f387f3e8f3bf28d83d9b69a39e9911e5bfee832bea75240d",
+                "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840",
+                "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f",
+                "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971",
+                "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c",
+                "sha256:4e547122ca2d244f7c090fe3f4b5a5861255ff66b7ab6d98f44a0222aaf8671a",
+                "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de",
+                "sha256:5d6b09c972ce9200264c35a1d53d43ca55ef61836d9ec60f0d44273a31aa9f17",
+                "sha256:600617008aa82032ddeace2535626d1bc212dfff32b43989539deda63b3f36e4",
+                "sha256:619346d57c7126ae49ac95b11b0dc8e36c1dd49d148477461bb66c8cf13bb521",
+                "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57",
+                "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b",
+                "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282",
+                "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644",
+                "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475",
+                "sha256:8e2c35a4c1f269704e90888e56f794e2d9c0262fb0c1b1c8c4ee44d9b9e77b5d",
+                "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da",
+                "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953",
+                "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2",
+                "sha256:9a2b5b52be0a8626fcbffd7e689781bf8c2ac01613e77feda93d96184949a98e",
+                "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c",
+                "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc",
+                "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64",
+                "sha256:bf154ba7ee2fd613eb541c2bc03d3d9ac667080a737449d1a3fb342740eb1a74",
+                "sha256:c254b03032d5a06de049ce8bca8338a5185f07fb76600afff3c161e053d88617",
+                "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3",
+                "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d",
+                "sha256:cfd9386c1d6f13b37e05a91a8583e802f8059bebfccde61a418c5808dea6bbfa",
+                "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739",
+                "sha256:dca38a21e4423f3edb821292e97cec7ad38086f84313462098568baedf4331f8",
+                "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8",
+                "sha256:e3db840a4dee542e37e09f30859f1612da90e1c5239a6a2498c473183a50e781",
+                "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58",
+                "sha256:f467bbb837691ab5a8ca359199d3429a11a01e6dfb3d9dcc676dc035ca93c0a9",
+                "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c",
+                "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd",
+                "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e",
+                "sha256:fb8b8ee99b3fffe4fd86f4c81b35a6bf7e4462cba019997af2fe679365db0c49"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==6.2"
+        },
+        "dparse": {
+            "hashes": [
+                "sha256:a1b5f169102e1c894f9a7d5ccf6f9402a836a5d24be80a986c7ce9eaed78f367",
+                "sha256:e953a25e44ebb60a5c6efc2add4420c177f1d8404509da88da9729202f306994"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==0.5.1"
+        },
+        "eradicate": {
+            "hashes": [
+                "sha256:27434596f2c5314cc9b31410c93d8f7e8885747399773cd088d3adea647a60c8"
+            ],
+            "version": "==2.0.0"
+        },
+        "flake8": {
+            "hashes": [
+                "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d",
+                "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"
+            ],
+            "index": "pypi",
+            "version": "==4.0.1"
+        },
+        "flake8-bandit": {
+            "hashes": [
+                "sha256:687fc8da2e4a239b206af2e54a90093572a60d0954f3054e23690739b0b0de3b"
+            ],
+            "index": "pypi",
+            "version": "==2.1.2"
+        },
+        "flake8-bugbear": {
+            "hashes": [
+                "sha256:179e41ddae5de5e3c20d1f61736feeb234e70958fbb56ab3c28a67739c8e9a82",
+                "sha256:8b04cb2fafc6a78e1a9d873bd3988e4282f7959bb6b0d7c1ae648ec09b937a7b"
+            ],
+            "index": "pypi",
+            "version": "==21.11.29"
+        },
+        "flake8-commas": {
+            "hashes": [
+                "sha256:940441ab8ee544df564ae3b3f49f20462d75d5c7cac2463e0b27436e2050f263",
+                "sha256:ebb96c31e01d0ef1d0685a21f3f0e2f8153a0381430e748bf0bbbb5d5b453d54"
+            ],
+            "index": "pypi",
+            "version": "==2.1.0"
+        },
+        "flake8-docstrings": {
+            "hashes": [
+                "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde",
+                "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"
+            ],
+            "index": "pypi",
+            "version": "==1.6.0"
+        },
+        "flake8-eradicate": {
+            "hashes": [
+                "sha256:51dc660d0c1c1ed93af0f813540bbbf72ab2d3466c14e3f3bac371c618b6042f",
+                "sha256:acaa1b6839ff00d284b805c432fdfa6047262bd15a5504ec945797e87b4de1fa"
+            ],
+            "index": "pypi",
+            "version": "==1.2.0"
+        },
+        "flake8-import-order": {
+            "hashes": [
+                "sha256:90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543",
+                "sha256:a28dc39545ea4606c1ac3c24e9d05c849c6e5444a50fb7e9cdd430fc94de6e92"
+            ],
+            "index": "pypi",
+            "version": "==0.18.1"
+        },
+        "flake8-polyfill": {
+            "hashes": [
+                "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9",
+                "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"
+            ],
+            "version": "==1.0.2"
+        },
+        "flask": {
+            "hashes": [
+                "sha256:7b2fb8e934ddd50731893bdcdb00fc8c0315916f9fcd50d22c7cc1a95ab634e2",
+                "sha256:cb90f62f1d8e4dc4621f52106613488b5ba826b2e1e10a33eac92f723093ab6a"
+            ],
+            "index": "pypi",
+            "version": "==2.0.2"
+        },
+        "future": {
+            "hashes": [
+                "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
+            ],
+            "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==0.18.2"
+        },
+        "gitdb": {
+            "hashes": [
+                "sha256:8033ad4e853066ba6ca92050b9df2f89301b8fc8bf7e9324d412a63f8bf1a8fd",
+                "sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==4.0.9"
+        },
+        "gitpython": {
+            "hashes": [
+                "sha256:dc0a7f2f697657acc8d7f89033e8b1ea94dd90356b2983bca89dc8d2ab3cc647",
+                "sha256:df83fdf5e684fef7c6ee2c02fc68a5ceb7e7e759d08b694088d0cacb4eba59e5"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==3.1.24"
+        },
+        "gunicorn": {
+            "hashes": [
+                "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e",
+                "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"
+            ],
+            "index": "pypi",
+            "version": "==20.1.0"
+        },
+        "idna": {
+            "hashes": [
+                "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
+                "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
+            ],
+            "markers": "python_version >= '3'",
+            "version": "==3.3"
+        },
+        "importlib-metadata": {
+            "hashes": [
+                "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b",
+                "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"
+            ],
+            "markers": "python_version < '3.8'",
+            "version": "==4.2.0"
+        },
+        "iniconfig": {
+            "hashes": [
+                "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3",
+                "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"
+            ],
+            "version": "==1.1.1"
+        },
+        "itsdangerous": {
+            "hashes": [
+                "sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c",
+                "sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==2.0.1"
+        },
+        "jinja2": {
+            "hashes": [
+                "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8",
+                "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==3.0.3"
+        },
+        "mando": {
+            "hashes": [
+                "sha256:4ce09faec7e5192ffc3c57830e26acba0fd6cd11e1ee81af0d4df0657463bd1c",
+                "sha256:79feb19dc0f097daa64a1243db578e7674909b75f88ac2220f1c065c10a0d960"
+            ],
+            "version": "==0.6.4"
+        },
+        "markupsafe": {
+            "hashes": [
+                "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298",
+                "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64",
+                "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b",
+                "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194",
+                "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567",
+                "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff",
+                "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724",
+                "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74",
+                "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646",
+                "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35",
+                "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6",
+                "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a",
+                "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6",
+                "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad",
+                "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26",
+                "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38",
+                "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac",
+                "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7",
+                "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6",
+                "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047",
+                "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75",
+                "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f",
+                "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b",
+                "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135",
+                "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8",
+                "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a",
+                "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a",
+                "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1",
+                "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9",
+                "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864",
+                "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914",
+                "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee",
+                "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f",
+                "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18",
+                "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8",
+                "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2",
+                "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d",
+                "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b",
+                "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b",
+                "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86",
+                "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6",
+                "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f",
+                "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb",
+                "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833",
+                "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28",
+                "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e",
+                "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415",
+                "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902",
+                "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f",
+                "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d",
+                "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9",
+                "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d",
+                "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145",
+                "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066",
+                "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c",
+                "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1",
+                "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a",
+                "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207",
+                "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f",
+                "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53",
+                "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd",
+                "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134",
+                "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85",
+                "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9",
+                "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5",
+                "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94",
+                "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509",
+                "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51",
+                "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==2.0.1"
+        },
+        "mccabe": {
+            "hashes": [
+                "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
+                "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
+            ],
+            "version": "==0.6.1"
+        },
+        "mypy": {
+            "hashes": [
+                "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9",
+                "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a",
+                "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9",
+                "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e",
+                "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2",
+                "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212",
+                "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b",
+                "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885",
+                "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150",
+                "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703",
+                "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072",
+                "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457",
+                "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e",
+                "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0",
+                "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb",
+                "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97",
+                "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8",
+                "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811",
+                "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6",
+                "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de",
+                "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504",
+                "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921",
+                "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d"
+            ],
+            "index": "pypi",
+            "version": "==0.910"
+        },
+        "mypy-extensions": {
+            "hashes": [
+                "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
+                "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
+            ],
+            "version": "==0.4.3"
+        },
+        "netaddr": {
+            "hashes": [
+                "sha256:9666d0232c32d2656e5e5f8d735f58fd6c7457ce52fc21c98d45f2af78f990ac",
+                "sha256:d6cc57c7a07b1d9d2e917aa8b36ae8ce61c35ba3fcd1b83ca31c5a0ee2b5a243"
+            ],
+            "version": "==0.8.0"
+        },
+        "packaging": {
+            "hashes": [
+                "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb",
+                "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==21.3"
+        },
+        "pbr": {
+            "hashes": [
+                "sha256:176e8560eaf61e127817ef93d8a844803abb27a4d4637f0ff3bb783129be2e0a",
+                "sha256:672d8ebee84921862110f23fcec2acea191ef58543d34dfe9ef3d9f13c31cddf"
+            ],
+            "markers": "python_version >= '2.6'",
+            "version": "==5.8.0"
+        },
+        "pep517": {
+            "hashes": [
+                "sha256:931378d93d11b298cf511dd634cf5ea4cb249a28ef84160b3247ee9afb4e8ab0",
+                "sha256:dd884c326898e2c6e11f9e0b64940606a93eb10ea022a2e067959f3a110cf161"
+            ],
+            "version": "==0.12.0"
+        },
+        "pep8-naming": {
+            "hashes": [
+                "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37",
+                "sha256:bb2455947757d162aa4cad55dba4ce029005cd1692f2899a21d51d8630ca7841"
+            ],
+            "index": "pypi",
+            "version": "==0.12.1"
+        },
+        "pluggy": {
+            "hashes": [
+                "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159",
+                "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==1.0.0"
+        },
+        "py": {
+            "hashes": [
+                "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719",
+                "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==1.11.0"
+        },
+        "pycodestyle": {
+            "hashes": [
+                "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20",
+                "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==2.8.0"
+        },
+        "pydocstyle": {
+            "hashes": [
+                "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc",
+                "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==6.1.1"
+        },
+        "pyeapi": {
+            "hashes": [
+                "sha256:c33ad1eadd8ebac75f63488df9412081ce0b024c9e1da12a37196a5c60427c54"
+            ],
+            "index": "pypi",
+            "version": "==0.8.4"
+        },
+        "pyflakes": {
+            "hashes": [
+                "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c",
+                "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==2.4.0"
+        },
+        "pyparsing": {
+            "hashes": [
+                "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4",
+                "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==3.0.6"
+        },
+        "pytest": {
+            "hashes": [
+                "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89",
+                "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"
+            ],
+            "index": "pypi",
+            "version": "==6.2.5"
+        },
+        "pytest-cov": {
+            "hashes": [
+                "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6",
+                "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"
+            ],
+            "index": "pypi",
+            "version": "==3.0.0"
+        },
+        "pytest-mock": {
+            "hashes": [
+                "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3",
+                "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"
+            ],
+            "index": "pypi",
+            "version": "==3.6.1"
+        },
+        "pyyaml": {
+            "hashes": [
+                "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293",
+                "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b",
+                "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57",
+                "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b",
+                "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4",
+                "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07",
+                "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba",
+                "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9",
+                "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287",
+                "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513",
+                "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0",
+                "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0",
+                "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92",
+                "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f",
+                "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2",
+                "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc",
+                "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c",
+                "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86",
+                "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4",
+                "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c",
+                "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34",
+                "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b",
+                "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c",
+                "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb",
+                "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737",
+                "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3",
+                "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d",
+                "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53",
+                "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78",
+                "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803",
+                "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a",
+                "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174",
+                "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==6.0"
+        },
+        "radon": {
+            "hashes": [
+                "sha256:cb1d8752e5f862fb9e20d82b5f758cbc4fb1237c92c9a66450ea0ea7bf29aeee",
+                "sha256:fa74e018197f1fcb54578af0f675d8b8e2342bd8e0b72bef8197bc4c9e645f36"
+            ],
+            "index": "pypi",
+            "version": "==5.1.0"
+        },
+        "requests": {
+            "hashes": [
+                "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24",
+                "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
+            "version": "==2.26.0"
+        },
+        "safety": {
+            "hashes": [
+                "sha256:30e394d02a20ac49b7f65292d19d38fa927a8f9582cdfd3ad1adbbc66c641ad5",
+                "sha256:5f802ad5df5614f9622d8d71fedec2757099705c2356f862847c58c6dfe13e84"
+            ],
+            "index": "pypi",
+            "version": "==1.10.3"
+        },
+        "six": {
+            "hashes": [
+                "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
+                "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==1.16.0"
+        },
+        "smmap": {
+            "hashes": [
+                "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94",
+                "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==5.0.0"
+        },
+        "snowballstemmer": {
+            "hashes": [
+                "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1",
+                "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"
+            ],
+            "version": "==2.2.0"
+        },
+        "stevedore": {
+            "hashes": [
+                "sha256:a547de73308fd7e90075bb4d301405bebf705292fa90a90fc3bcf9133f58616c",
+                "sha256:f40253887d8712eaa2bb0ea3830374416736dc8ec0e22f5a65092c1174c44335"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==3.5.0"
+        },
+        "toml": {
+            "hashes": [
+                "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
+                "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
+            ],
+            "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==0.10.2"
+        },
+        "tomli": {
+            "hashes": [
+                "sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee",
+                "sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==1.2.2"
+        },
+        "typed-ast": {
+            "hashes": [
+                "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace",
+                "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff",
+                "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266",
+                "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528",
+                "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6",
+                "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808",
+                "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4",
+                "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363",
+                "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341",
+                "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04",
+                "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41",
+                "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e",
+                "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3",
+                "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899",
+                "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805",
+                "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c",
+                "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c",
+                "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39",
+                "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a",
+                "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3",
+                "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7",
+                "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f",
+                "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075",
+                "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0",
+                "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40",
+                "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428",
+                "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927",
+                "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3",
+                "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f",
+                "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"
+            ],
+            "markers": "python_version < '3.8'",
+            "version": "==1.4.3"
+        },
+        "typing-extensions": {
+            "hashes": [
+                "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e",
+                "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"
+            ],
+            "markers": "python_version < '3.10'",
+            "version": "==4.0.1"
+        },
+        "urllib3": {
+            "hashes": [
+                "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece",
+                "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
+            "version": "==1.26.7"
+        },
+        "werkzeug": {
+            "hashes": [
+                "sha256:63d3dc1cf60e7b7e35e97fa9861f7397283b75d765afcaefd993d6046899de8f",
+                "sha256:aa2bb6fc8dee8d6c504c0ac1e7f5f7dc5810a9903e793b6f715a9f015bdadb9a"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==2.0.2"
+        },
+        "zipp": {
+            "hashes": [
+                "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832",
+                "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"
+            ],
+            "markers": "python_version < '3.8'",
+            "version": "==3.6.0"
+        }
+    },
+    "develop": {
+        "attrs": {
+            "hashes": [
+                "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1",
+                "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==21.2.0"
+        },
+        "build": {
+            "hashes": [
+                "sha256:1aaadcd69338252ade4f7ec1265e1a19184bf916d84c9b7df095f423948cb89f",
+                "sha256:21b7ebbd1b22499c4dac536abc7606696ea4d909fd755e00f09f3c0f2c05e3c8"
+            ],
+            "index": "pypi",
+            "version": "==0.7.0"
+        },
+        "importlib-metadata": {
+            "hashes": [
+                "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b",
+                "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"
+            ],
+            "markers": "python_version < '3.8'",
+            "version": "==4.2.0"
+        },
+        "importlib-resources": {
+            "hashes": [
+                "sha256:33a95faed5fc19b4bc16b29a6eeae248a3fe69dd55d4d229d2b480e23eeaad45",
+                "sha256:d756e2f85dd4de2ba89be0b21dba2a3bbec2e871a42a3a16719258a11f87506b"
+            ],
+            "markers": "python_version < '3.9'",
+            "version": "==5.4.0"
+        },
+        "jsonschema": {
+            "hashes": [
+                "sha256:2a0f162822a64d95287990481b45d82f096e99721c86534f48201b64ebca6e8c",
+                "sha256:390713469ae64b8a58698bb3cbc3859abe6925b565a973f87323ef21b09a27a8"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==4.2.1"
+        },
+        "m2crypto": {
+            "hashes": [
+                "sha256:99f2260a30901c949a8dc6d5f82cd5312ffb8abc92e76633baf231bbbcb2decb"
+            ],
+            "version": "==0.38.0"
+        },
+        "packaging": {
+            "hashes": [
+                "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb",
+                "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==21.3"
+        },
+        "pep517": {
+            "hashes": [
+                "sha256:931378d93d11b298cf511dd634cf5ea4cb249a28ef84160b3247ee9afb4e8ab0",
+                "sha256:dd884c326898e2c6e11f9e0b64940606a93eb10ea022a2e067959f3a110cf161"
+            ],
+            "version": "==0.12.0"
+        },
+        "pyparsing": {
+            "hashes": [
+                "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4",
+                "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==3.0.6"
+        },
+        "pyrsistent": {
+            "hashes": [
+                "sha256:097b96f129dd36a8c9e33594e7ebb151b1515eb52cceb08474c10a5479e799f2",
+                "sha256:2aaf19dc8ce517a8653746d98e962ef480ff34b6bc563fc067be6401ffb457c7",
+                "sha256:404e1f1d254d314d55adb8d87f4f465c8693d6f902f67eb6ef5b4526dc58e6ea",
+                "sha256:48578680353f41dca1ca3dc48629fb77dfc745128b56fc01096b2530c13fd426",
+                "sha256:4916c10896721e472ee12c95cdc2891ce5890898d2f9907b1b4ae0f53588b710",
+                "sha256:527be2bfa8dc80f6f8ddd65242ba476a6c4fb4e3aedbf281dfbac1b1ed4165b1",
+                "sha256:58a70d93fb79dc585b21f9d72487b929a6fe58da0754fa4cb9f279bb92369396",
+                "sha256:5e4395bbf841693eaebaa5bb5c8f5cdbb1d139e07c975c682ec4e4f8126e03d2",
+                "sha256:6b5eed00e597b5b5773b4ca30bd48a5774ef1e96f2a45d105db5b4ebb4bca680",
+                "sha256:73ff61b1411e3fb0ba144b8f08d6749749775fe89688093e1efef9839d2dcc35",
+                "sha256:772e94c2c6864f2cd2ffbe58bb3bdefbe2a32afa0acb1a77e472aac831f83427",
+                "sha256:773c781216f8c2900b42a7b638d5b517bb134ae1acbebe4d1e8f1f41ea60eb4b",
+                "sha256:a0c772d791c38bbc77be659af29bb14c38ced151433592e326361610250c605b",
+                "sha256:b29b869cf58412ca5738d23691e96d8aff535e17390128a1a52717c9a109da4f",
+                "sha256:c1a9ff320fa699337e05edcaae79ef8c2880b52720bc031b219e5b5008ebbdef",
+                "sha256:cd3caef37a415fd0dae6148a1b6957a8c5f275a62cca02e18474608cb263640c",
+                "sha256:d5ec194c9c573aafaceebf05fc400656722793dac57f254cd4741f3c27ae57b4",
+                "sha256:da6e5e818d18459fa46fac0a4a4e543507fe1110e808101277c5a2b5bab0cd2d",
+                "sha256:e79d94ca58fcafef6395f6352383fa1a76922268fa02caa2272fff501c2fdc78",
+                "sha256:f3ef98d7b76da5eb19c37fda834d50262ff9167c65658d1d8f974d2e4d90676b",
+                "sha256:f4c8cabb46ff8e5d61f56a037974228e978f26bfefce4f61a4b1ac0ba7a2ab72"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==0.18.0"
+        },
+        "pyyaml": {
+            "hashes": [
+                "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293",
+                "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b",
+                "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57",
+                "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b",
+                "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4",
+                "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07",
+                "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba",
+                "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9",
+                "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287",
+                "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513",
+                "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0",
+                "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0",
+                "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92",
+                "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f",
+                "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2",
+                "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc",
+                "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c",
+                "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86",
+                "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4",
+                "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c",
+                "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34",
+                "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b",
+                "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c",
+                "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb",
+                "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737",
+                "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3",
+                "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d",
+                "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53",
+                "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78",
+                "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803",
+                "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a",
+                "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174",
+                "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==6.0"
+        },
+        "switools": {
+            "hashes": [
+                "sha256:36c8b832d650a71954434dba3b305d2e50de0af2f18c42f2b1d10e34be86c871"
+            ],
+            "index": "pypi",
+            "version": "==1.1"
+        },
+        "tomli": {
+            "hashes": [
+                "sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee",
+                "sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==1.2.2"
+        },
+        "typing-extensions": {
+            "hashes": [
+                "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e",
+                "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"
+            ],
+            "markers": "python_version < '3.10'",
+            "version": "==4.0.1"
+        },
+        "zipp": {
+            "hashes": [
+                "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832",
+                "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"
+            ],
+            "markers": "python_version < '3.8'",
+            "version": "==3.6.0"
+        }
+    }
+}
diff --git a/README.md b/README.md
index 2fe2030..862e866 100644
--- a/README.md
+++ b/README.md
@@ -1,132 +1,62 @@
-<!--description: An EOS agent to dynamically update IRR based prefix-lists -->
-[![PyPI](https://img.shields.io/pypi/v/prefix-list-agent.svg)](https://pypi.python.org/pypi/prefix-list-agent)
-[![Build Status](https://travis-ci.com/wolcomm/eos-prefix-list-agent.svg?branch=master)](https://travis-ci.com/wolcomm/eos-prefix-list-agent)
-[![codecov](https://codecov.io/gh/wolcomm/eos-prefix-list-agent/branch/master/graph/badge.svg)](https://codecov.io/gh/wolcomm/eos-prefix-list-agent)
+# EOS Prefix List Agent
 
-# Prefix-list Agent for Arista EOS
+*Automated IRR-based prefix-lists for Arista EOS.*
 
-Periodically retrieve data from the IRR and build prefix-list objects on EOS.
+[![CI/CD](https://github.com/wolcomm/eos-prefix-list-agent/actions/workflows/cicd.yml/badge.svg?event=push)](https://github.com/wolcomm/eos-prefix-list-agent/actions/workflows/cicd.yml)
+[![codecov](https://codecov.io/gh/wolcomm/eos-prefix-list-agent/branch/master/graph/badge.svg)](https://codecov.io/gh/wolcomm/eos-prefix-list-agent)
+[![Updates](https://pyup.io/repos/github/wolcomm/eos-prefix-list-agent/shield.svg)](https://pyup.io/repos/github/wolcomm/eos-prefix-list-agent/)
 
 ## Overview
 
-The `PrefixListAgent` is an EOS SDK based agent that runs under `ProcMrg` on an
-Arista EOS based device or VM.
+The EOS Prefix List Agent is an EOS SDK based agent that runs under `ProcMrg`
+on an Arista EOS based device, VM or container.
 
 The agent maintains up-to-date prefix-list policy objects for use in EOS
 routing policy configurations, based on data in the IRR.
 
 The agent will periodically check the running configuration of the device to
 gather a list of prefix-lists that it is responsible for maintaining. It will
-then retrieve the required data from the IRR via an RPTK web service, and
+then retrieve the required data from the IRR via an [RPTK] web service, and
 update the prefix-lists without calling the EOS config parser.
 
-## Installation
-
-### From RPM (recommended)
-
-Install the agent as an EOS extension:
-```
-ar#copy https://github.com/wolcomm/eos-prefix-list-agent/releases/download/0.1.0a8/prefix_list_agent-0.1.0a8-1.noarch.rpm extension:
-ar#extension prefix_list_agent-0.1.0a8-1.noarch.rpm
-ar#sh extensions
-Name                                      Version/Release    Status    Extension
------------------------------------------ ------------------ --------- ---------
-prefix_list_agent-0.1.0a8-1.noarch.rpm    0.1.0a8/1          A, I      1
-
-
-A: available | NA: not available | I: installed | NI: not installed | F: forced
-```
-
-Persist the extension to be installed on boot:
-```
-ar#copy installed-extensions boot-extensions
-Copy completed successfully.
-ar#sh boot-extensions
-prefix_list_agent-0.1.0a8-1.noarch.rpm
-```
-
-### Using `pip`
-
-This method is not recommended unless you know what you are doing.
-Without additional configuration, the installation will not persist across
-reloads.
-
-Drop to the shell:
-```
-ar#bash
-```
-
-Install from PyPI:
-```bash
-$ sudo pip install prefix-list-agent
-```
-
-## Agent Configuration
-
-### Daemon
-
-Configure the agent as an EOS "daemon":
-```
-ar#conf t
-ar(config)#daemon PrefixListAgent
-ar(config-daemon-PrefixListAgent)#exec /usr/bin/PrefixListAgent
-```
-
-### Tracing
-
-Enable tracing using the EOS agent tracing infrastructure, e.g.:
-```
-ar(config)#trace PrefixListAgent-PrefixListAgent setting PrefixList*/0-6
-```
+## Documentation
 
-### Set Configuration Options
+Installation, configuration and operational documentation is available
+[here][docs].
 
-Set configuration options using the command:
-```
-ar(config-daemon-PrefixListAgent)#option <option-name> value <option-value>
-```
+## Contributing
 
-Available configuration options are:
-* `rptk_endpoint` (default: `None`, *required*):
+External contributions of all forms are welcomed and appreciated.
 
-  The RPTK web service endpoint to connect to when retrieving prefix-list data.
+Potential contributors are requested to open an issue for discussion prior to
+spending time on an implementation.
 
-* `source_dir` (default: `/tmp/prefix-lists`):
+## Developing
 
-  The local directory to write prefix-list files to.
+Due to the on-going transition from Python `2.7` to `3` within EOS, this
+project contains different Python packages, targeting different interpreter
+versions. We hope that this unfortunate complexity will go away in time.
 
-* `refresh_interval` (default: `3600`):
+Additionally, the build process and test suite depend on the availability of
+an Arista cEOS-lab container image. We cannot currently provide this within the
+repository for licensing reasons.
 
-  The interval between prefix-list updates.
+As a result, external contributors are advised to reach out to
+[oss@workonline.africa] or via an issue for assistance setting up a working local
+development environment.
 
-### Start the Agent
+We apologise for the additional work!
 
-```
-ar(config-daemon-PrefixListAgent)#no shutdown
-```
+## PyPI Package
 
-## Prefix-List Configuration
+Previous versions were provided as Python packages via [PyPI].
 
-The PrefixListAgent will check the running configuration for source-based
-prefix-lists matching the form:
-```
-ip[v6] prefix-list ${irr_object} source file:${source_dir}/${policy}/${file_name}
-```
-Where:
+Due to changes in the design of the project this installation method is no
+longer viable, and new versions are no longer published.
 
-* `${irr_object}`: The IRR object (`aut-num` or `as-set`) to build the
-  prefix-list from.
-* `${source_dir}`: The configured `source_dir`, as above.
-* `${policy}`: The resolution policy to use when resolving the IRR object to a
-  prefix-list. Must be a valid policy available on the RPTK web service. You can
-  check the available policies with:
-```bash
-$ curl -q ${rptk_endpoint}/policies | jq
-```
-* `${file_name}`: The file to write the resulting prefix-list data to. This
-  name should be unique per resolution policy.
+**Users should install via the published SWIX packages, not via `pip`.**
 
-After the next update, you should be able to see the entries with:
-```
-ar#show ip[v6] prefix-list ${irr_object} detail
-```
+[RPTK]: https://github.com/wolcomm/rptk
+[docs]: https://wolcomm.github.io/eos-prefix-list-agent
+[PyPI]: https://pypi.python.org/pypi/prefix-list-agent
+[oss@workonline.africa]: mailto:oss@workonline.africa
diff --git a/do-release.sh b/do-release.sh
deleted file mode 100755
index 7f81f52..0000000
--- a/do-release.sh
+++ /dev/null
@@ -1,98 +0,0 @@
-#!/usr/bin/env bash
-#
-# Copyright (c) 2019 Workonline Communications (Pty) Ltd. All rights reserved.
-#
-# The contents of this file are licensed under the MIT License
-# (the "License"); you may not use this file except in compliance with the
-# License.
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-# License for the specific language governing permissions and limitations under
-# the License.
-
-usage() {
-    cat <<EOF
-Usage: ${0##*/} [VERSION]
-
-    Create a new tagged release for the current repo at VERSION.
-    If no arguments are provided, VERSION will be read from STDIN.
-
-    Example:
-        $ package/__meta__.py | ./${0##*/}
-
-EOF
-}
-
-error() { echo "$@" 1>&2; exit 1; }
-
-have() { command -v "$1" >/dev/null 2>&1; }
-
-# Check that we have hub installed
-have "hub" || error "'hub' is required for creating github releases."
-
-# Set VERSION
-if [[ $# -gt 1 ]]; then
-    echo "Too many arguments provided"
-    usage; exit 1
-fi
-if [[ -z "$1" ]]; then
-    echo "Reading from STDIN. End with Ctrl+D..."
-    VERSION=$(cat)
-else
-    VERSION=$1
-fi
-if [[ "$VERSION" == "--help" || "$VERSION" == "-h" ]]; then
-    usage; exit 0
-fi
-
-# Check whether this is a pre-release
-if [[ ! "$VERSION" =~ ^[0-9]+(\.[0-9]+)+$ ]]; then
-    PRE="pre-"
-fi
-echo -e "Creating ${PRE}release\n"
-
-# Find the last release
-SELECT="$([[ -z "$PRE" ]] && echo -n "--exclude-prereleases")"
-RELEASES="$(hub release ${SELECT} | sort -rV)" ||
-    error "Getting previous release failed"
-while read -r R; do
-    if [[ "$VERSION" == "$R" ]]; then
-        error "Release ${VERSION} already exists"
-    fi
-done <<< "${RELEASES}"
-PREVIOUS=$(echo -e "${RELEASES}" | head -n 1)
-
-# Get the change log since the last release
-if [[ -n "$PREVIOUS" ]]; then
-    SINCE=" since [${PREVIOUS}]($(hub browse --url)/releases/tag/${PREVIOUS})" ||
-        error "Getting repository URL failed"
-    LOG_RANGE="${PREVIOUS}..HEAD"
-else
-    LOG_RANGE="HEAD"
-fi
-CHANGE_LOG=$(git log ${LOG_RANGE} --oneline --no-color) ||
-    error "Getting git log failed"
-
-# Create the release message
-RELEASE_MSG=$(cat <<EOF
-${VERSION}
-
-## Change Log
-Changes${SINCE}:
-${CHANGE_LOG}
-EOF
-)
-echo -e "${RELEASE_MSG}\n"
-
-# Create the release
-CMD="hub release create ${PRE:+-p} -F - ${VERSION}"
-echo "Running: ${CMD}" && echo -e "${RELEASE_MSG}" | ${CMD} && echo "Done" ||
-    error "Failed to create release"
-
-# Pull the newly created tag
-echo "Pulling new tags" && git pull --tags ||
-    error "Failed to pull new tags"
-
-exit 0
diff --git a/docs/.gitignore b/docs/.gitignore
new file mode 100644
index 0000000..3006b27
--- /dev/null
+++ b/docs/.gitignore
@@ -0,0 +1,1 @@
+book/
diff --git a/docs/book.toml b/docs/book.toml
new file mode 100644
index 0000000..62ea257
--- /dev/null
+++ b/docs/book.toml
@@ -0,0 +1,17 @@
+[book]
+title = "EOS Prefix List Agent"
+authors = ["Ben Maddison"]
+language = "en"
+multilingual = false
+src = "src"
+
+[build]
+create-missing = false
+
+[output.html]
+git-repository-url = "https://github.com/wolcomm/eos-prefix-list-agent"
+edit-url-template = "https://github.com/wolcomm/eos-prefix-list-agent/edit/master/{path}"
+
+[output.html.print]
+enable = false
+
diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md
new file mode 100644
index 0000000..219b746
--- /dev/null
+++ b/docs/src/SUMMARY.md
@@ -0,0 +1,11 @@
+# Summary
+
+-   [About](about/README.md)
+    - [Background](about/background.md)
+    - [Implementation](about/implementation.md)
+-   [Installation](install/README.md)
+-   [Configuration](config/README.md)
+    - [Agent](config/agent.md)
+    - [Prefix lists](config/prefix-lists.md)
+    - [Tracing](config/tracing.md)
+-   [Operations](ops/README.md)
diff --git a/docs/src/about/README.md b/docs/src/about/README.md
new file mode 100644
index 0000000..b90b727
--- /dev/null
+++ b/docs/src/about/README.md
@@ -0,0 +1,15 @@
+# About
+
+**EOS Prefix List Agent** is an extension to Arista EOS that automatically
+maintains BGP prefix-list filters based on IRR data.
+
+The on-device agent based design integrates tightly with native EOS interfaces,
+and keeps the contents of `prefix-list`s out of the `running-configuration`
+which solves several operational challenges.
+
+This has been used in production at [Workonline Communications (AS37271)][Workonline]
+since 2019.
+
+A brief [background](background.md) to the problems it addresses is provided.
+
+[Workonline]: https://workonline.africa
diff --git a/docs/src/about/background.md b/docs/src/about/background.md
new file mode 100644
index 0000000..e8ea40d
--- /dev/null
+++ b/docs/src/about/background.md
@@ -0,0 +1,90 @@
+# Background
+
+Internet operators maintain BGP filters at the borders of their networks to
+control the propagation of routing information between autonomous systems (ASs).
+
+Such filters are essential to prevent policy violations and outages that occur
+when configuration mistakes or intentional hijacks happen.
+
+Unfortunately, construction of correct filters requires special knowledge of
+the routing intentions of other operators, with whom there may exist no
+operational relationship. Furthermore, the burden of providing and consuming
+the necessary data, at Internet scale, without automated processes is
+impractical.
+
+The [Internet Routing Registry (IRR)][IRR] system is a distributed and loosely
+co-ordinated set of databases in which operators publish data about their
+routing intentions, which can then be used to construct filter configurations
+by others.
+
+The most common way in which IRR data is used for constructing filters is to
+make a "prefix set" representing the prefixes originated in BGP by a
+neighboring AS and their customer ASs.
+
+## Off-line filter generation
+
+In [RPSL], the language of the IRR, a set of ASs is represented by an `as-set`
+object, and a BGP route by `route` (IPv4) or `route6` (IPv6) objects.
+
+To construct a set of prefixes from an `as-set`, one first queries an IRR
+database mirror for the ASNs that are members of the set, and then, for each
+member, performs further queries for the `route` and `route6` objects that have
+an `origin:` attribute equal to that ASN.
+
+Several open source tools are available to perform this procedure, some of
+which are capable of outputting the resulting set directly in the configuration
+language of a specific router vendor.
+
+Most commonly this process is performed periodically, and the results are
+either deployed directly to the target device, or integrated along with other
+configuration snippets into a configuration generation and deployment pipeline.
+
+This methodology has several drawbacks, particularly in an environment where
+whole configurations are generated and deployed as a single unit:
+
+-   **Configuration size**: Prefix filters can easily grow to hundreds of
+    thousands of entries. Generating and deploying filters as part of a device
+    configuration slows down the whole deployment pipeline, and makes unrelated
+    configuration changes unnecessarily burdensome.
+-   **Timing dependence**: Prefix filters and other configuration elements must
+    be generated and deployed together. Most device configuration ought to
+    change on an irregular schedule, according to the changing intentions of
+    the operator. Prefix filters, conversely, are generally updated on a fixed
+    regular schedule. Coupling these schedules together increases configuration
+    churn and scope for failures.
+-   **Rollback dependence**: If an operator wishes to roll back to a previous
+    configuration, this will also roll back the *contents* of any prefix
+    filters. This is unlikely to be desirable in general.
+
+## Policy-plane separation
+
+Network devices are typically conceived of has having three inter-dependent
+"planes", each concerned with a function of the device:
+
+-   The *management plane* provides the means for device management functions
+    such login and authentication, configuration database management, and
+    statistics collection.
+-   The *control plane* provides a host networking stack, on which routing
+    protocols and other network services run.
+-   The *data plane* provides the packet forwarding functions for network
+    traffic traversing the device.
+
+Addressing the above shortcomings of prefix filter maintenance requires a
+decoupling of the *presence* of a filter from its *contents*.
+
+Whether a filter for a particular neighbor AS should be present, how and when
+it should be updated, and how it should be referenced in other policy
+constructs are all concerns of the local device operator, and belong in the
+device configuration, i.e. the *management plane*.
+
+The *contents* of a particular IRR-based prefix filter, however, is not
+(primarily) determined by the intentions of the local operator, but by the data
+that has been published in a remote database by some third party.
+
+This data is a natural part of neither the management nor control planes. Its
+existence is determined by the former, and its contents impacts the operation
+of the later. However it is best managed independently of either, on a separate
+logical *"policy plane"*.
+
+[IRR]: http://www.irr.net/
+[RPSL]: http://www.irr.net/docs/rpsl.html
diff --git a/docs/src/about/implementation.md b/docs/src/about/implementation.md
new file mode 100644
index 0000000..fc45960
--- /dev/null
+++ b/docs/src/about/implementation.md
@@ -0,0 +1,27 @@
+# Implementation
+
+Arista EOS provides the ability to have the contents of `prefix-list`s and
+`as-path access-list`s retrieved dynamically from an arbitrary URL.
+
+This feature allows the presence of the policy object and the source of its
+contents defined in the `running-configuration`, whilst omitting the contents.
+
+The [EOS SDK] provides the ability to run third party extension code under
+the EOS process manager, in order that the extension can be configured via the
+EOS `running-configuration`, and can register callbacks against events
+occurring in the main event loop.
+
+The agent is configured to periodically inspect the current
+`running-configuration` for `prefix-list` objects with a `source` that matches
+a well defined pattern.
+
+For each such object, the agent will execute the necessary queries to write the
+entries in the `prefix-list` to the file system, and instruct EOS to reload
+their contents.
+
+The agent does not query an IRR database server directly, but relies on the
+availability of an instance of [RPTK] to provide an easy-to-consume JSON based
+API.
+
+[EOS SDK]: https://github.com/aristanetworks/EosSdk
+[RPTK]: https://github.com/wolcomm/rptk
diff --git a/docs/src/config/README.md b/docs/src/config/README.md
new file mode 100644
index 0000000..10f1566
--- /dev/null
+++ b/docs/src/config/README.md
@@ -0,0 +1,8 @@
+# Configuration
+
+To use the EOS prefix list agent to create and update prefix-lists based on
+IRR data, the operator must:
+
+1.  [Configure the agent](agent.md) itself to run under the EOS `ProcMgr`, and
+2.  [Configure source based prefix-lists](prefix-lists.md) using a specially
+    constructed source URLs.
diff --git a/docs/src/config/agent.md b/docs/src/config/agent.md
new file mode 100644
index 0000000..de8ed6b
--- /dev/null
+++ b/docs/src/config/agent.md
@@ -0,0 +1,81 @@
+# Agent Configuration
+
+The EOS Prefix List Agent is configured via a custom CLI extension providing
+the `prefix-list-agent` configuration sub-mode.
+
+The legacy `daemon` CLI syntax should still work, but is unsupported and
+untested as of `0.2.0`.
+
+## Synopsis
+
+``` eos
+prefix-list-agent
+   [no] disabled                #  Enable/disable the agent (default: disabled)
+   rptk-endpoint <URL>          #  RPTK Web API endpoint URL (required)
+   source-directory <PATH>      #  Filesystem path to write to (default: /tmp/prefix-lists)
+   refresh-interval <10-86400>  #  Seconds between update runs (default: 3600)
+   update-delay <1-120>         #  Optional delay between prefix-list refreshes (default: none)
+```
+
+## Command Reference
+
+### `[no] disabled`
+
+Control whether the agent is enabled or disabled, i.e. should be started by
+EOS `ProcMgr`.
+
+The agent is disabled by default. Configure `no disabled` to start it.
+
+Default: `disabled`
+
+### `rptk-endpoint <URL>`
+
+The URL of an instance of the [RPTK] Web API service.
+
+The agent does not communicate directly with an IRR mirror using the `tcp/43`
+protocol(s). Instead an instance of [RPTK] is used to resolve IRR object names
+to prefix sets.
+
+The option is required in order for the agent to function. If it is not
+provided then the agent when an update run begins then an error is logged, and
+the agent restarts its `refresh-interval` timer and waits for the next run.
+
+Default: none (required)
+
+### `source-directory <PATH>`
+
+The path on the local filesystem to the directory where the agent should write
+the contents of prefix-lists.
+
+The default path is on a `tmpfs` filesystem, meaning that the contents of the
+prefix-lists will not persist across reloads. To change this behaviour,
+configure a directory path on a filesystem that is backed by a real block
+device (e.g. `/mnt/flash` or `/mnt/disk`).
+
+Default: `/tmp/prefix-lists`
+
+### `refresh-interval <10-86400>`
+
+The interval (in seconds) between prefix list contents update runs.
+
+The timer is reset at the *end* of each run, so the interval excludes however
+much time is taken to perform the update itself. Similarly, and change in the
+configuration will take effect at the end of the next (currently scheduled)
+run.
+
+Default: `3600`
+
+### `update-delay <1-120>`
+
+Tell EOS to re-read the prefix-list content sources one-at-a-time, and
+introduce a delay (in seconds) between each.
+
+When not configured, all prefix-lists are re-read simultaneously. On affected
+EOS versions, bug [578037] can cause the `IsIs` and/or `Ospf` agents to crash
+if the prefix-lists being managed are large enough. Using `update-delay` can
+dampen the effect and avoid this issue.
+
+Default: `none`
+
+[RPTK]: https://github.com/wolcomm/rptk
+[578037]: https://www.arista.com/en/support/software-bug-portal/bugdetail?bug_id=578037
diff --git a/docs/src/config/prefix-lists.md b/docs/src/config/prefix-lists.md
new file mode 100644
index 0000000..d4897d7
--- /dev/null
+++ b/docs/src/config/prefix-lists.md
@@ -0,0 +1,53 @@
+# Prefix List Configuration
+
+The EOS Prefix List Agent leverages the standard EOS source based prefix-list
+feature, which allows the configuration of `prefix-list` objects whose contents
+are to be retrieved from a URL, rather than enumerated in the
+`running-configuration`.
+
+To determine which prefix-lists it should attempt to construct using IRR data,
+the agent attempts to match the `source` URL of all configured source-based
+`prefix-list` objects against a well defined pattern.
+
+To configure a `prefix-list` to be managed by the agent:
+
+``` eos
+ip[v6] prefix-list <IRR_OBJECT> source file:<SOURCE_DIR>/<POLICY>/<FILE_NAME>
+```
+
+Where:
+
+-   `IRR_OBJECT`
+
+    The IRR object (`aut-num` or `as-set`) to build the prefix-list from.
+
+-   `SOURCE_DIR`
+
+    The configured agent [`source-directory`](agent.md#source-directory-path).
+
+-   `POLICY`
+
+    The resolution policy to use when resolving the IRR object to a
+    prefix-list.
+
+    This must be a valid resolution policy available on the [RPTK] web service.
+
+    The available policies can be checked with:
+
+    ``` bash
+    $ curl -q ${rptk_endpoint}/policies | jq
+    {
+      "strict": "Permit only prefixes with explicitly registered 'route' or 'route6' objects",
+      "loose": "Permit prefixes shorter than /24 (ipv4) or /48 (ipv6) with a registered covering 'route' or 'route6' object"
+    }
+    ```
+
+-   `FILE_NAME`
+
+    The file to write the resulting prefix-list data to.
+
+    The actual name is unimportant, but must be unique per resolution policy.
+
+    The recommended convention is `<IRR_OBJECT>-<ipv4|ipv6>.txt`.
+
+[RPTK]: https://github.com/wolcomm/rptk
diff --git a/docs/src/config/tracing.md b/docs/src/config/tracing.md
new file mode 100644
index 0000000..83c8ec4
--- /dev/null
+++ b/docs/src/config/tracing.md
@@ -0,0 +1,33 @@
+# Tracing Configuration
+
+Agent execution tracing is provided by EOS's tracing infrastructure.
+
+To enable tracing:
+
+``` eos
+arista-cli(conf)# trace PrefixListAgent setting <TRACE_NAME>/<TRACE_LEVEL>
+```
+
+Where:
+
+-   `TRACE_NAME`
+
+    The name of the component emitting the trace (usually the module name).
+
+    Wildcard matching is supported using the `*` character.
+
+    To enable tracing for all extension components (but not the EOS SDK
+    infrastructure) use `PrefixList*`.
+
+-   `TRACE_LEVEL`
+
+    Each trace is emitted at a "level" indicated by an integer in the range
+    `0-9`, with lower levels corresponding to higher importance.
+
+    This setting accepts one of:
+
+    - A single level `N`
+    - A range of levels `N-M`
+    - A wildcard `*`, matching all levels
+
+The trace can be viewed as described [here](../ops#inspecting-trace-logs).
diff --git a/docs/src/install/README.md b/docs/src/install/README.md
new file mode 100644
index 0000000..05c3cef
--- /dev/null
+++ b/docs/src/install/README.md
@@ -0,0 +1,52 @@
+# Installation
+
+## EOS Compatibility
+
+Versions from `0.2.0` are compatible with EOS versions `4.26.0F` and above.
+
+To run on an older EOS version (`4.22.0F` and above), select a release from
+`0.1.x`.
+
+> Note: the remainder of this documentation is written for the current version.
+> Usage details for older versions can be found in the [README] of the GitHub
+> repository (at the appropriate tagged commit).
+
+## Download
+
+Packages are available for download from [GitHub Releases].
+
+Versions prior to `0.2.0` were provided as RPM packages. From `0.2.0` onwards
+Arista's SWIX packaging format is used.
+
+Ensure that you change the below URLs and file names accordingly.
+
+The installation procedure is the same in either case.
+
+## Extension installation
+
+1.  Copy the SWIX package onto the device.
+
+    ``` eos
+    arista-cli# copy https://github.com/wolcomm/eos-prefix-list-agent/releases/download/0.2.0/eos-prefix-list-agent-0.2.0.swix extensions:
+    ```
+
+2.  Install the package.
+
+    > Note that the EOS `ConfigAgent` will restart, terminating any existing CLI
+    > sessions. You will have to reconnect to the device after this step.
+
+    ``` eos
+    arista-cli# extension eos-prefix-list-agent-0.2.0.swix
+    ```
+
+3.  (Optional) Persist installation across reloads.
+
+    > If you skip this step, the agent will not be installed after a device
+    > reload.
+
+    ``` eos
+    arista-cli# copy installed-extensions boot-extensions
+    ```
+
+[README]: https://github.com/wolcomm/eos-prefix-list-agent/blob/release/0.1/README.md
+[GitHub Releases]: https://github.com/wolcomm/eos-prefix-list-agent/releases
diff --git a/docs/src/ops/README.md b/docs/src/ops/README.md
new file mode 100644
index 0000000..dbb216e
--- /dev/null
+++ b/docs/src/ops/README.md
@@ -0,0 +1,47 @@
+# Operations
+
+## Inspecting Agent Status
+
+### `show prefix-list-agent`
+
+Display the operational state and configuration of the agent.
+
+### `show daemon PrefixListAgent`
+
+> This command is not provided by the extension, and may change or be removed
+> without notice in future versions.
+
+Display the internal operational state of the EOS SDK daemon process.
+
+This command may be used to view details that are not visible via the `show
+prefix-list-agent` command, such as uptime and PID.
+
+## Inspecting `prefix-list` contents
+
+### `show ip[v6] prefix-list <IRR_OBJECT> <summary|detail>`
+
+The contents of source-based prefix-lists are not shown in the output of the
+unqualified `show ip[v6] prefix-list` command.
+
+Instead, use either `summary` or `detail` as follows:
+
+-   `summary`
+
+    Show the number of entries and the last update time of the `prefix-list`
+
+-   `detail`
+
+    Show the full contents of the `prefix-list`
+
+An error of the form `Failed to import from source: file:<PATH>` indicates that
+the source file could not be read, possibly because the agent has not yet
+processed a newly added `prefix-list`, or due to some other problem.
+
+## Inspecting trace logs
+
+### `show agent PrefixListAgent logs`
+
+Inspect the contents of the agent trace log file if [agent tracing has been
+configured](../config/tracing.md).
+
+Alternatively, the trace files can be found in `/var/log/agent/`.
diff --git a/packaging/requirements.txt b/packaging/requirements.txt
deleted file mode 100644
index e69de29..0000000
diff --git a/packaging/rpm/prefix_list_agent.spec b/packaging/rpm/prefix_list_agent.spec
new file mode 100644
index 0000000..88ce2ec
--- /dev/null
+++ b/packaging/rpm/prefix_list_agent.spec
@@ -0,0 +1,58 @@
+# set binary rpm compression to xz-level2, for compatibility with
+# the `librpm` version shipped with eos 4.26
+%global _binary_payload w2.xzdio
+
+Name:           eos-prefix-list-agent
+Version:        %{_version}
+Release:        0%{?dist}
+Summary:        EOS Prefix List Agent base package
+
+License:        MIT
+URL:            https://github.com/wolcomm/eos-prefix-list-agent
+Source0:        %{url}/archive/v%{version}/%{name}-%{version}.tar.gz
+
+BuildArch:      noarch
+BuildRequires:  python3-devel
+BuildRequires:  pyproject-rpm-macros
+
+%global _description %{expand:
+An EOS SDK based agent that maintains up-to-date prefix-list policy objects for
+use in EOS routing policy configurations, based on data in the IRR.
+
+This package provides the base agent implementation.
+}
+
+%description %_description
+
+
+%prep
+%autosetup -p1 -n eos-prefix-list-agent-%{version}
+
+
+%build
+# override the provided build macro to use `build` instead of `pip`
+%global pyproject_wheel_isolated() %{expand:\\\
+    mkdir -p "%{_pyproject_builddir}"
+    CFLAGS="${CFLAGS:-${RPM_OPT_FLAGS}}" LDFLAGS="${LDFLAGS:-${RPM_LD_FLAGS}}" TMPDIR="%{_pyproject_builddir}" \\\
+    %{__python3} -m build --wheel --outdir %{_pyproject_wheeldir}
+}
+%pyproject_wheel_isolated
+
+
+%install
+%pyproject_install
+%pyproject_save_files 'prefix_list_agent'
+
+
+%check
+
+
+%files -n eos-prefix-list-agent -f %{pyproject_files}
+%doc README.md
+%license LICENSE
+%{_bindir}/PrefixListAgent
+
+
+%changelog
+* Tue May 9 2021 Ben Maddison <benm@workonline.africa>
+-
diff --git a/packaging/rpm/prefix_list_agent_cli.spec b/packaging/rpm/prefix_list_agent_cli.spec
new file mode 100644
index 0000000..d49a634
--- /dev/null
+++ b/packaging/rpm/prefix_list_agent_cli.spec
@@ -0,0 +1,64 @@
+# set binary rpm compression to xz-level2, for compatibility with
+# the `librpm` version shipped with eos 4.26
+%global _binary_payload w2.xzdio
+
+Name:           eos-prefix-list-agent-cli
+Version:        %{_version}
+Release:        0%{?dist}
+Summary:        CLI extension package for EOS Prefix List Agent
+
+License:        MIT
+URL:            https://github.com/wolcomm/eos-prefix-list-agent
+Source0:        %{url}/archive/v%{version}/%{name}-%{version}.tar.gz
+
+BuildArch:      noarch
+BuildRequires:  python2-devel
+
+%global _description %{expand:
+An EOS SDK based agent that maintains up-to-date prefix-list policy objects for
+use in EOS routing policy configurations, based on data in the IRR.
+
+This package provides the CLI extension implementation.
+}
+
+%description %_description
+
+
+%prep
+%autosetup -p1 -n eos-prefix-list-agent-cli-%{version}
+
+
+%build
+# override the provided build macro to use `pip` instead of `setup.py`
+%global py2_build_wheel() %{expand:\\\
+  sleep 1
+  CFLAGS="${CFLAGS:-${RPM_OPT_FLAGS}}" LDFLAGS="${LDFLAGS:-${RPM_LD_FLAGS}}"\\\
+  %{__python2} -m pip wheel -w dist/ .
+  sleep 1
+}
+%py2_build_wheel
+
+
+%install
+%py2_install_wheel eos_prefix_list_agent_cli-%{version}-py2-none-any.whl
+mkdir -p %{buildroot}/%{_datadir}/CliExtension
+mkdir -p %{buildroot}/%{python2_sitelib}/CliPlugin
+ln -s %{python2_sitelib}/prefix_list_agent_cli/extension.yaml %{buildroot}%{_datadir}/CliExtension/PrefixListAgent.yaml
+ln -s %{python2_sitelib}/prefix_list_agent_cli/extension.py %{buildroot}%{python2_sitelib}/CliPlugin/PrefixListAgent.py
+
+
+%check
+
+
+%files -n eos-prefix-list-agent-cli
+%doc README.md
+%license LICENSE
+%{python2_sitelib}/eos_prefix_list_agent_cli-%{version}.dist-info/
+%{python2_sitelib}/prefix_list_agent_cli/
+%{python2_sitelib}/CliPlugin/PrefixListAgent.py
+%{_datadir}/CliExtension/PrefixListAgent.yaml
+
+
+%changelog
+* Tue May 9 2021 Ben Maddison <benm@workonline.africa>
+-
diff --git a/packaging/swix/manifest.yaml b/packaging/swix/manifest.yaml
new file mode 100644
index 0000000..760f978
--- /dev/null
+++ b/packaging/swix/manifest.yaml
@@ -0,0 +1,4 @@
+metadataVersion: 1.0
+agentsToRestart:
+  - ConfigAgent
+  - PrefixListAgent
diff --git a/prefix_list_agent/LICENSE b/prefix_list_agent/LICENSE
new file mode 120000
index 0000000..ea5b606
--- /dev/null
+++ b/prefix_list_agent/LICENSE
@@ -0,0 +1,1 @@
+../LICENSE
\ No newline at end of file
diff --git a/prefix_list_agent/README.md b/prefix_list_agent/README.md
new file mode 120000
index 0000000..32d46ee
--- /dev/null
+++ b/prefix_list_agent/README.md
@@ -0,0 +1,1 @@
+../README.md
\ No newline at end of file
diff --git a/prefix_list_agent/__meta__.py b/prefix_list_agent/__meta__.py
deleted file mode 100755
index c6adcdc..0000000
--- a/prefix_list_agent/__meta__.py
+++ /dev/null
@@ -1,41 +0,0 @@
-#!/usr/bin/env python
-# Copyright (c) 2019 Workonline Communications (Pty) Ltd. All rights reserved.
-#
-# The contents of this file are licensed under the MIT License
-# (the "License"); you may not use this file except in compliance with the
-# License.
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-# License for the specific language governing permissions and limitations under
-# the License.
-"""prefix_list_agent package metadata."""
-
-from __future__ import print_function
-from __future__ import unicode_literals
-
-__version__ = "0.1.2.rc1"
-__author__ = "Ben Maddison"
-__author_email__ = "benm@workonline.africa"
-__licence__ = "MIT"
-__copyright__ = "Copyright (c) 2019 Workonline Communications (Pty) Ltd"
-__url__ = "https://github.com/wolcomm/eos-prefix-list-agent"
-__classifiers__ = [
-    "Development Status :: 4 - Beta",
-    "Environment :: Other Environment",
-    "Intended Audience :: Telecommunications Industry",
-    "License :: OSI Approved :: MIT License",
-    "Operating System :: POSIX :: Other",
-    "Programming Language :: Python :: 2.7",
-    "Topic :: Internet",
-    "Topic :: System :: Networking"
-]
-__entry_points__ = None
-__scripts__ = [
-    "bin/PrefixListAgent"
-]
-
-
-if __name__ == "__main__":
-    print(__version__)
diff --git a/prefix_list_agent/agent.py b/prefix_list_agent/agent.py
deleted file mode 100644
index 463dc4f..0000000
--- a/prefix_list_agent/agent.py
+++ /dev/null
@@ -1,389 +0,0 @@
-# Copyright (c) 2019 Workonline Communications (Pty) Ltd. All rights reserved.
-#
-# The contents of this file are licensed under the MIT License
-# (the "License"); you may not use this file except in compliance with the
-# License.
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-# License for the specific language governing permissions and limitations under
-# the License.
-"""prefix_list_agent agent implementation."""
-
-from __future__ import print_function
-
-import collections
-import datetime
-import filecmp
-import os
-import platform
-import shutil
-import signal
-import tempfile
-
-import eossdk
-
-from prefix_list_agent.base import PrefixListBase
-from prefix_list_agent.worker import PrefixListWorker
-
-
-class PrefixListAgent(PrefixListBase, eossdk.AgentHandler,
-                      eossdk.TimeoutHandler, eossdk.FdHandler):
-    """An EOS SDK based agent that creates prefix-list policy objects."""
-
-    sysdb_mounts = ("agent",)
-    agent_options = ("rptk_endpoint", "source_dir", "refresh_interval",
-                     "update_delay")
-
-    @classmethod
-    def set_sysdb_mp(cls, name):
-        """Create the SysdbMountProfiles file for the agent."""
-        # set the path
-        arch = platform.architecture()[0]
-        if arch == "32bit":     # pragma: no cover
-            lib_dir = "/usr/lib"
-        elif arch == "64bit":
-            lib_dir = "/usr/lib64"
-        else:  # pragma: no cover
-            raise RuntimeError("Unknown architecture '{}'".format(arch))
-        profile_path = os.path.join(lib_dir, "SysdbMountProfiles", name)
-        # get a tempfile for writing the profile to
-        with tempfile.NamedTemporaryFile() as tmp:
-            # write the profile file
-            tmp.write("agentName:{}-%sliceId\n\n".format(name))
-            for profile in cls.sysdb_mounts:
-                tmp.write("Include: EosSdk_{}.include\n".format(profile))
-            tmp.flush()
-            # check whether an existing file matches and bail out
-            if os.path.isfile(profile_path):
-                if filecmp.cmp(profile_path, tmp.name, shallow=False):
-                    return False
-            # copy the tempfile into place
-            shutil.copy(tmp.name, profile_path)
-        return True
-
-    def __init__(self, sdk):
-        """Initialise the agent instance."""
-        # Set up tracing
-        PrefixListBase.__init__(self)
-        # get sdk managers
-        self.agent_mgr = sdk.get_agent_mgr()
-        self.timeout_mgr = sdk.get_timeout_mgr()
-        self.eapi_mgr = sdk.get_eapi_mgr()
-        # init sdk handlers
-        eossdk.AgentHandler.__init__(self, self.agent_mgr)
-        eossdk.TimeoutHandler.__init__(self, self.timeout_mgr)
-        eossdk.FdHandler.__init__(self)
-        # set worker process to None
-        self.worker = None
-        self.watching = set()
-        # set default confg options
-        self._rptk_endpoint = None
-        self._source_dir = "/tmp/prefix-lists"
-        self._refresh_interval = 3600
-        self._update_delay = None
-        # create state containers
-        self._status = None
-        self._last_start = None
-        self._last_end = None
-        self._result = None
-
-    @property
-    def rptk_endpoint(self):
-        """Get 'rptk_endpoint' property."""
-        return self._rptk_endpoint
-
-    @rptk_endpoint.setter
-    def rptk_endpoint(self, url):
-        """Set 'rptk_endpoint' property."""
-        self._rptk_endpoint = url
-
-    @property
-    def source_dir(self):
-        """Get 'source_dir' property."""
-        return self._source_dir
-
-    @source_dir.setter
-    def source_dir(self, path):
-        """Set 'source_dir' property."""
-        self._source_dir = path
-
-    @property
-    def refresh_interval(self):
-        """Get 'refresh_interval' property."""
-        return self._refresh_interval
-
-    @refresh_interval.setter
-    def refresh_interval(self, i):
-        """Set 'refresh_interval' property."""
-        i = int(i)
-        if i in range(10, 86400):
-            self._refresh_interval = i
-        else:
-            raise ValueError("refresh_interval must be in range 1 - 86399")
-
-    @property
-    def update_delay(self):
-        """Get 'update_delay' property."""
-        return self._update_delay
-
-    @update_delay.setter
-    def update_delay(self, i):
-        """Set 'update_delay' property."""
-        if i is not None:
-            i = int(i)
-            if i not in range(1, 121):
-                raise ValueError("update_delay must be in range 1 - 120")
-        self._update_delay = i
-
-    @property
-    def status(self):
-        """Get 'status' property."""
-        return self._status
-
-    @status.setter
-    def status(self, s):
-        """Set 'status' property."""
-        self._status = s
-        self.agent_mgr.status_set("status", self.status)
-        self.info("Status: {}".format(self.status))
-
-    @property
-    def result(self):
-        """Get 'result' property."""
-        return self._result
-
-    @result.setter
-    def result(self, r):
-        """Set 'result' property."""
-        self._result = r
-        self.agent_mgr.status_set("result", self.result)
-        self.notice("Result: {}".format(self.result))
-
-    @property
-    def last_start(self):
-        """Set the 'last_start' timestamp."""
-        return self._last_start
-
-    @last_start.setter
-    def last_start(self, ts):
-        """Set the 'last_start' timestamp."""
-        if not isinstance(ts, datetime.datetime):
-            raise TypeError("Expected datetime.datetime, got {}".format(ts))
-        self._last_start = ts
-        self.agent_mgr.status_set("last_start", str(self.last_start))
-        self.info("Last start: {}".format(ts))
-
-    @property
-    def last_end(self):
-        """Set the 'last_end' timestamp."""
-        return self._last_end
-
-    @last_end.setter
-    def last_end(self, ts):
-        """Set the 'last_end' timestamp."""
-        if not isinstance(ts, datetime.datetime):
-            raise TypeError("Expected datetime.datetime, got {}".format(ts))
-        self._last_end = ts
-        self.agent_mgr.status_set("last_end", str(self.last_end))
-        self.info("Last end: {}".format(ts))
-
-    def configure(self):
-        """Read and set all configuration options."""
-        self.info("Reading configuration options")
-        for key in self.agent_mgr.agent_option_iter():
-            value = self.agent_mgr.agent_option(key)
-            self.set(key, value)
-
-    def set(self, key, value):
-        """Set a configuration option."""
-        if not value:
-            value = None
-        self.info("Setting configuration '{}'='{}'".format(key, value))
-        if key in self.agent_options:
-            setattr(self, key, value)
-        else:
-            self.warning("Ignoring unknown option '{}'".format(key))
-
-    def start(self):
-        """Start up the agent."""
-        self.status = "init"
-        self.configure()
-        self.init()
-        self.run()
-
-    def init(self):  # pragma: no cover
-        """Perform one-time start actions."""
-        pass
-
-    def init_worker(self):
-        """Create a worker instance."""
-        self.info("Initialising worker")
-        self.worker = PrefixListWorker(endpoint=self.rptk_endpoint,
-                                       path=self.source_dir,
-                                       eapi=self.eapi_mgr,
-                                       update_delay=self.update_delay)
-
-    def run(self):
-        """Spawn worker process."""
-        self.status = "running"
-        if self.rptk_endpoint is not None:
-            self.last_start = datetime.datetime.now()
-            try:
-                self.init_worker()
-                self.watch(self.worker.p_data, "result")
-                self.watch(self.worker.p_err, "error")
-                self.info("Starting worker")
-                self.worker.start()
-                self.info("Worker started: pid {}".format(self.worker.pid))
-            except Exception as e:
-                self.err("Starting worker failed: {}".format(e))
-                self.failure(err=e)
-        else:
-            self.warning("'rptk_endpoint' is not set")
-            self.sleep()
-
-    def watch(self, conn, type):
-        """Watch a Connection for new data."""
-        self.info("Trying to watch for {} data on {}".format(type, conn))
-        fileno = conn.fileno()
-        self.watch_readable(fileno, True)
-        self.watching.add(conn)
-        self.info("Watching {} for {} data".format(conn, type))
-
-    def unwatch(self, conn, close=False):
-        """Stop watching a Connection for new data."""
-        self.info("Trying to remove watch on {}".format(conn))
-        fileno = conn.fileno()
-        self.watch_readable(fileno, False)
-        if conn in self.watching:
-            self.watching.remove(conn)
-        self.info("Stopped watching {}".format(conn))
-        if close:
-            self.info("Closing connection {}".format(conn))
-            conn.close()
-
-    def success(self):
-        """Report statistics and restart refresh_interval timer."""
-        self.status = "finalising"
-        self.info("Receiving results from worker")
-        stats = self.worker.data
-        self.report(**stats)
-        self.result = "ok"
-        self.last_end = datetime.datetime.now()
-        self.cleanup(process=self.worker)
-        self.sleep()
-
-    def failure(self, err=None, process=None, restart=False):
-        """Handle worker exception."""
-        self.status = "error"
-        if err is None:
-            try:
-                err = process.error
-            except Exception as e:
-                self.err("Retreiving exception from {} failed"
-                         .format(process.__class__.__name__))
-                err = e
-        self.err(err)
-        self.result = "failed"
-        self.last_end = datetime.datetime.now()
-        if restart:
-            self.restart()
-        else:
-            self.cleanup(process=process)
-            self.sleep()
-
-    def report(self, **stats):
-        """Report statistics to the agent manager."""
-        for name, value in stats.items():
-            self.info("{}: {}".format(name, value))
-            self.agent_mgr.status_set(name, str(value))
-
-    def cleanup(self, process):
-        """Kill the process if it is still running."""
-        self.status = "cleanup"
-        process_name = process.__class__.__name__
-        self.info("Cleaning up {} process".format(process_name))
-        if process is not None:
-            self.info("Closing connections from {}".format(process_name))
-            for conn in [c for c in
-                         [getattr(process, k) for k in dir(process)
-                          if not k.startswith("_")]
-                         if isinstance(c, collections.Hashable)
-                         and c in self.watching]:
-                try:
-                    self.unwatch(conn, close=True)
-                except Exception as e:
-                    self.err(e)
-            for retry in range(3):
-                if process.is_alive():
-                    if retry:
-                        self.info("Sending {} SIGTERM".format(process_name))
-                        process.terminate()
-                    timeout = retry * 5 + 1
-                    self.info("Waiting {} seconds for {} (pid: {}) to join"
-                              .format(timeout, process_name, process.pid))
-                    process.join(timeout)
-            if process.is_alive():
-                self.notice("Timeout waiting for {}. Sending SIGKILL"
-                            .format(process_name))
-                os.kill(process.pid, signal.SIGKILL)
-        self.info("Cleanup complete")
-
-    def sleep(self):
-        """Go to sleep for 'refresh_interval' seconds."""
-        self.status = "sleeping"
-        self.timeout_time_is(eossdk.now() + self.refresh_interval)
-
-    def shutdown(self):
-        """Shutdown the agent gracefully."""
-        self.notice("Shutting down")
-        try:
-            self.cleanup(process=self.worker)
-        except Exception as e:
-            self.err(e)
-        self.status = "shutdown"
-        self.agent_mgr.agent_shutdown_complete_is(True)
-
-    def restart(self):
-        """Restart the agent."""
-        self.notice("Restarting")
-        self.status = "restarting"
-        try:
-            self.cleanup(process=self.worker)
-        except Exception as e:
-            self.err(e)
-        self.start()
-
-    def on_initialized(self):
-        """Start the agent after initialisation."""
-        self.start()
-
-    def on_agent_option(self, key, value):
-        """Handle a change to a configuration option."""
-        self.set(key, value)
-
-    def on_agent_enabled(self, enabled):
-        """Handle a change in the admin state of the agent."""
-        if enabled:
-            self.notice("Agent enabled")
-        else:
-            self.notice("Agent disabled")
-            self.shutdown()
-
-    def on_timeout(self):
-        """Handle a 'refresh_interval' timeout."""
-        self.run()
-
-    def on_readable(self, fd):
-        """Handle a watched file descriptor becoming readable."""
-        self.info("Watched file descriptor {} is readable".format(fd))
-        if fd == self.worker.p_data.fileno():
-            self.info("Data channel is ready")
-            return self.success()
-        elif fd == self.worker.p_err.fileno():
-            self.info("Exception received from worker")
-            return self.failure(process=self.worker)
-        else:
-            self.warning("Unknown file descriptor: ignoring")
diff --git a/bin/PrefixListAgent b/prefix_list_agent/bin/PrefixListAgent
similarity index 94%
rename from bin/PrefixListAgent
rename to prefix_list_agent/bin/PrefixListAgent
index 3667075..46ae4a8 100755
--- a/bin/PrefixListAgent
+++ b/prefix_list_agent/bin/PrefixListAgent
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # Copyright (c) 2019 Workonline Communications (Pty) Ltd. All rights reserved.
 #
 # The contents of this file are licensed under the MIT License
@@ -12,8 +12,6 @@
 # the License.
 """PrefixListAgent EOS SDK Agent."""
 
-from __future__ import print_function
-
 import sys
 
 import eossdk
diff --git a/prefix_list_agent/cli.py b/prefix_list_agent/prefix_list_agent/__init__.py
similarity index 64%
rename from prefix_list_agent/cli.py
rename to prefix_list_agent/prefix_list_agent/__init__.py
index 6cd8de3..40f70fd 100644
--- a/prefix_list_agent/cli.py
+++ b/prefix_list_agent/prefix_list_agent/__init__.py
@@ -9,29 +9,27 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
-"""prefix_list_agent cli entry point."""
-
-from __future__ import print_function
+"""prefix_list_agent Package."""
 
+import logging
 import sys
 
-from prefix_list_agent import PrefixListAgent
+import eossdk
+
+from .agent import PrefixListAgent
+
+logging.getLogger(__name__).addHandler(logging.NullHandler())
 
 
-def start(sdk):
+def start(sdk: eossdk.Sdk) -> int:
     """Start the agent."""
     try:
-        # create a Sysdb mount profile, restarting if necessary
-        if PrefixListAgent.set_sysdb_mp(sdk.name()):
-            # return a user defined status to indicate
-            # that a restart is desired
-            return 64
         # create an instance of the agent
-        agent = PrefixListAgent(sdk)  # noqa: W0612
+        _ = PrefixListAgent(sdk)
         # enter the sdk event-loop
         sdk.main_loop(sys.argv)
     except KeyboardInterrupt:
         return 130
     except Exception:
         raise
-    return
+    return 0
diff --git a/prefix_list_agent/prefix_list_agent/agent.py b/prefix_list_agent/prefix_list_agent/agent.py
new file mode 100644
index 0000000..68852f2
--- /dev/null
+++ b/prefix_list_agent/prefix_list_agent/agent.py
@@ -0,0 +1,351 @@
+# Copyright (c) 2019 Workonline Communications (Pty) Ltd. All rights reserved.
+#
+# The contents of this file are licensed under the MIT License
+# (the "License"); you may not use this file except in compliance with the
+# License.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""prefix_list_agent agent implementation."""
+
+import datetime
+import multiprocessing.connection
+import os
+import signal
+import typing
+
+import eossdk
+
+from .base import PrefixListBase
+from .exceptions import ConfigValueError
+from .types import ConfigVal, StatusVal
+from .worker import PrefixListWorker
+
+
+class PrefixListAgent(PrefixListBase,
+                      eossdk.AgentHandler,  # type: ignore[misc]
+                      eossdk.TimeoutHandler,  # type: ignore[misc]
+                      eossdk.FdHandler):  # type: ignore[misc]
+    """An EOS SDK based agent that creates prefix-list policy objects."""
+
+    def __init__(self, sdk: eossdk.Sdk):
+        """Initialise the agent instance."""
+        # Set up tracing
+        PrefixListBase.__init__(self)
+        # get sdk managers
+        self.agent_mgr = sdk.get_agent_mgr()
+        self.timeout_mgr = sdk.get_timeout_mgr()
+        self.eapi_mgr = sdk.get_eapi_mgr()
+        # init sdk handlers
+        eossdk.AgentHandler.__init__(self, self.agent_mgr)
+        eossdk.TimeoutHandler.__init__(self, self.timeout_mgr)
+        eossdk.FdHandler.__init__(self)
+        # set worker process to None
+        self._worker: typing.Optional[PrefixListWorker] = None
+        self.watching: typing.Set[multiprocessing.connection.Connection] = set()  # noqa: E501
+
+    def option(self,
+               typ: typing.Callable[[str], ConfigVal],
+               key: str,
+               default: ConfigVal) -> ConfigVal:
+        """Get configuration option value."""
+        if not self.agent_mgr.agent_option_exists(key):
+            return default
+        return typ(self.agent_mgr.agent_option(key))
+
+    # TODO: 'rptk-endpoint' should not be optional
+    @property
+    def rptk_endpoint(self) -> typing.Optional[str]:
+        """Get 'rptk-endpoint' option."""
+        return self.option(str, "rptk-endpoint", None)
+
+    @property
+    def source_dir(self) -> str:
+        """Get 'source-directory' option."""
+        return self.option(str, "source-directory", "/tmp/prefix-lists")  # noqa: S108, E501
+
+    @property
+    def refresh_interval(self) -> int:
+        """Get 'refresh-interval' option."""
+        def validate(s: str) -> int:
+            i = int(s)
+            if i not in range(10, 86401):
+                raise ConfigValueError("refresh-interval must be in range 10 - 86400")  # noqa: E501
+            return i
+        return self.option(validate, "refresh-interval", 3600)
+
+    @property
+    def update_delay(self) -> typing.Optional[int]:
+        """Get 'update-delay' option."""
+        def validate(s: str) -> int:
+            i = int(s)
+            if i not in range(1, 121):
+                raise ConfigValueError("update-delay must be in range 1 - 120")
+            return i
+        return self.option(validate, "update-delay", None)
+
+    def status_get(self,
+                   typ: typing.Callable[[str], StatusVal],
+                   key: str) -> StatusVal:
+        """Get agent state key-value-pair."""
+        return typ(self.agent_mgr.status(key))
+
+    # TODO: check max key/val lengths (512 char/10KB)
+    def status_set(self, key: str, val: typing.Optional[StatusVal]) -> None:
+        """Set agent state key-value-pair."""
+        if val is None:
+            self.debug(f"deleting state key {key}")
+            self.agent_mgr.status_del(key)
+        else:
+            self.debug(f"setting state '{key}' = '{val}'")
+            self.agent_mgr.status_set(key, val)
+
+    @property
+    def status(self) -> typing.Optional[str]:
+        """Get 'status' property."""
+        return self.status_get(lambda s: s or None, "status")
+
+    @status.setter
+    def status(self, s: str) -> None:
+        """Set 'status' property."""
+        self.status_set("status", s)
+        self.info(f"Status: {s}")
+
+    @property
+    def result(self) -> typing.Optional[str]:
+        """Get 'result' property."""
+        return self.status_get(lambda s: s or None, "result")
+
+    @result.setter
+    def result(self, r: str) -> None:
+        """Set 'result' property."""
+        self.status_set("result", r)
+        self.notice(f"Result: {r}")
+
+    @staticmethod
+    def timestamp(ts: str) -> typing.Optional[datetime.datetime]:
+        """Parse an ISO timestamp."""
+        if not ts:
+            return None
+        return datetime.datetime.fromisoformat(ts)
+
+    @property
+    def last_start(self) -> typing.Optional[datetime.datetime]:
+        """Set the 'last-start' timestamp."""
+        return self.status_get(self.timestamp, "last-start")
+
+    @last_start.setter
+    def last_start(self, ts: datetime.datetime) -> None:
+        """Set the 'last-start' timestamp."""
+        if not isinstance(ts, datetime.datetime):
+            raise TypeError(f"expected 'datetime' object, got {ts}")
+        self.status_set("last-start", ts.isoformat())
+        self.info(f"Last start: {ts}")
+
+    @property
+    def last_end(self) -> typing.Optional[datetime.datetime]:
+        """Set the 'last-end' timestamp."""
+        return self.status_get(self.timestamp, "last-end")
+
+    @last_end.setter
+    def last_end(self, ts: datetime.datetime) -> None:
+        """Set the 'last-end' timestamp."""
+        if not isinstance(ts, datetime.datetime):
+            raise TypeError(f"expected 'datetime' object, got {ts}")
+        self.status_set("last-end", ts.isoformat())
+        self.info(f"Last end: {ts}")
+
+    @property
+    def worker(self) -> PrefixListWorker:
+        """Get active worker process."""
+        if self._worker is None:
+            raise RuntimeError("attempted to access uninitialized worker.")
+        return self._worker
+
+    def start(self) -> None:
+        """Start up the agent."""
+        self.status = "init"
+        self.init()
+        self.run()
+
+    def init(self) -> None:  # pragma: no cover
+        """Perform one-time start actions."""
+        pass
+
+    def init_worker(self) -> None:
+        """Create a worker instance."""
+        self.info("Initialising worker")
+        assert self.rptk_endpoint is not None  # noqa: S101
+        self._worker = PrefixListWorker(rptk_endpoint=self.rptk_endpoint,
+                                        source_dir=self.source_dir,
+                                        update_delay=self.update_delay,
+                                        eapi=self.eapi_mgr)
+
+    def run(self) -> None:
+        """Spawn worker process."""
+        self.status = "running"
+        if self.rptk_endpoint is not None:
+            try:
+                self.last_start = datetime.datetime.now()
+                self.init_worker()
+                self.watch(self.worker.p_data, "result")
+                self.watch(self.worker.p_err, "error")
+                self.info("Starting worker")
+                self.worker.start()
+                self.info(f"Worker started: pid {self.worker.pid}")
+            except Exception as e:
+                self.err(f"Starting worker failed: {e}")
+                self.failure(err=e)
+        else:
+            self.warning("'rptk-endpoint' is not set")
+            self.sleep()
+
+    def watch(self,
+              conn: multiprocessing.connection.Connection,
+              typ: str) -> None:
+        """Watch a Connection for new data."""
+        self.info(f"Trying to watch for {typ} data on {conn}")
+        fileno = conn.fileno()
+        self.watch_readable(fileno, True)
+        self.watching.add(conn)
+        self.info(f"Watching {conn} for {typ} data")
+
+    def unwatch(self,
+                conn: multiprocessing.connection.Connection,
+                close: bool = False) -> None:
+        """Stop watching a Connection for new data."""
+        self.info(f"Trying to remove watch on {conn}")
+        fileno = conn.fileno()
+        self.watch_readable(fileno, False)
+        if conn in self.watching:
+            self.watching.remove(conn)
+            self.info(f"Stopped watching {conn}")
+        else:
+            self.warning(f"Connection {conn} not watched")
+        if close:
+            self.info(f"Closing connection {conn}")
+            conn.close()
+
+    def success(self) -> None:
+        """Report statistics and restart refresh_interval timer."""
+        self.status = "finalising"
+        self.info("Receiving results from worker")
+        stats = self.worker.data
+        if stats is not None:
+            self.report(**stats)
+        self.result = "ok"
+        self.last_end = datetime.datetime.now()
+        self.cleanup(process=self.worker)
+        self.sleep()
+
+    def failure(self,
+                err: typing.Optional[Exception] = None,
+                process: typing.Optional[PrefixListWorker] = None,
+                restart: bool = False) -> None:
+        """Handle worker exception."""
+        self.status = "error"
+        if err is None:
+            assert process is not None  # noqa: S101
+            err = process.error
+        self.err(err)
+        self.result = "failed"
+        self.last_end = datetime.datetime.now()
+        if restart:
+            self.restart()
+        else:
+            self.cleanup(process=process)
+            self.sleep()
+
+    def report(self, **stats: int) -> None:
+        """Report statistics to the agent manager."""
+        for name, value in stats.items():
+            self.info(f"{name}: {value}")
+            self.agent_mgr.status_set(name, str(value))
+
+    def cleanup(self,  # noqa: R701
+                process: typing.Optional[PrefixListWorker]) -> None:
+        """Kill the process if it is still running."""
+        self.status = "cleanup"
+        process_name = process.__class__.__name__
+        self.info(f"Cleaning up {process_name} process")
+        if process is not None:
+            self.info(f"Closing connections from {process_name}")
+            for conn in [c for c in
+                         [getattr(process, k) for k in dir(process)
+                          if not k.startswith("_")]
+                         if isinstance(c, multiprocessing.connection.Connection)  # noqa: E501
+                         and c in self.watching]:
+                try:
+                    self.unwatch(conn, close=True)
+                except Exception as e:
+                    self.err(e)
+            for retry in range(3):
+                if process.is_alive():
+                    if retry:
+                        self.info(f"Sending {process_name} SIGTERM")
+                        process.terminate()
+                    timeout = retry * 5 + 1
+                    self.info(f"Waiting {timeout} seconds for {process_name} "
+                              f"(pid: {process.pid}) to join")
+                    process.join(timeout)
+            if process.is_alive() and process.pid is not None:
+                self.notice(f"Timeout waiting for {process_name}. "
+                            "Sending SIGKILL")
+                os.kill(process.pid, signal.SIGKILL)
+        self.info("Cleanup complete")
+
+    def sleep(self) -> None:
+        """Go to sleep for 'refresh_interval' seconds."""
+        self.status = "sleeping"
+        self.timeout_time_is(eossdk.now() + self.refresh_interval)
+
+    def shutdown(self) -> None:
+        """Shutdown the agent gracefully."""
+        self.notice("Shutting down")
+        try:
+            self.cleanup(process=self.worker)
+        except Exception as e:
+            self.err(e)
+        self.status = "shutdown"
+        self.agent_mgr.agent_shutdown_complete_is(True)
+
+    def restart(self) -> None:
+        """Restart the agent."""
+        self.notice("Restarting")
+        self.status = "restarting"
+        try:
+            self.cleanup(process=self.worker)
+        except Exception as e:
+            self.err(e)
+        self.start()
+
+    def on_initialized(self) -> None:
+        """Start the agent after initialisation."""
+        self.start()
+
+    def on_agent_enabled(self, enabled: bool) -> None:
+        """Handle a change in the admin state of the agent."""
+        if enabled:
+            self.notice("Agent enabled")
+        else:
+            self.notice("Agent disabled")
+            self.shutdown()
+
+    def on_timeout(self) -> None:
+        """Handle a 'refresh_interval' timeout."""
+        self.run()
+
+    def on_readable(self, fd: int) -> None:
+        """Handle a watched file descriptor becoming readable."""
+        self.info(f"Watched file descriptor {fd} is readable")
+        if fd == self.worker.p_data.fileno():
+            self.info("Data channel is ready")
+            return self.success()
+        elif fd == self.worker.p_err.fileno():
+            self.info("Exception received from worker")
+            return self.failure(process=self.worker)
+        else:
+            self.warning("Unknown file descriptor: ignoring")
diff --git a/prefix_list_agent/base.py b/prefix_list_agent/prefix_list_agent/base.py
similarity index 79%
rename from prefix_list_agent/base.py
rename to prefix_list_agent/prefix_list_agent/base.py
index 70e9989..98d2334 100644
--- a/prefix_list_agent/base.py
+++ b/prefix_list_agent/prefix_list_agent/base.py
@@ -11,50 +11,48 @@
 # the License.
 """prefix_list_agent base class."""
 
-from __future__ import print_function
-
 import eossdk
 
 
 class PrefixListBase(object):
     """Base class that implements tracing."""
 
-    def __init__(self):
+    def __init__(self) -> None:
         """Initialise a PrefixListBase instance."""
         self.tracer = eossdk.Tracer(self.__class__.__name__)
 
-    def _trace(self, msg, level=0):
+    def _trace(self, msg: object, level: int = 0) -> None:
         """Write tracing output."""
         self.tracer.trace(level, str(msg))
 
-    def emerg(self, msg):
+    def emerg(self, msg: object) -> None:
         """Write trace output at 'emergency' (0) level."""
         self._trace(msg, level=0)
 
-    def alert(self, msg):
+    def alert(self, msg: object) -> None:
         """Write trace output at 'alert' (1) level."""
         self._trace(msg, level=1)
 
-    def crit(self, msg):
+    def crit(self, msg: object) -> None:
         """Write trace output at 'critical' (2) level."""
         self._trace(msg, level=2)
 
-    def err(self, msg):
+    def err(self, msg: object) -> None:
         """Write trace output at 'error' (3) level."""
         self._trace(msg, level=3)
 
-    def warning(self, msg):
+    def warning(self, msg: object) -> None:
         """Write trace output at 'warning' (4) level."""
         self._trace(msg, level=4)
 
-    def notice(self, msg):
+    def notice(self, msg: object) -> None:
         """Write trace output at 'notice' (5) level."""
         self._trace(msg, level=5)
 
-    def info(self, msg):
+    def info(self, msg: object) -> None:
         """Write trace output at 'informational' (6) level."""
         self._trace(msg, level=6)
 
-    def debug(self, msg):
+    def debug(self, msg: object) -> None:
         """Write trace output at 'debug' (7) level."""
         self._trace(msg, level=7)
diff --git a/prefix_list_agent/exceptions.py b/prefix_list_agent/prefix_list_agent/exceptions.py
similarity index 77%
rename from prefix_list_agent/exceptions.py
rename to prefix_list_agent/prefix_list_agent/exceptions.py
index 8cede1f..a2d5319 100644
--- a/prefix_list_agent/exceptions.py
+++ b/prefix_list_agent/prefix_list_agent/exceptions.py
@@ -11,7 +11,15 @@
 # the License.
 """prefix_list_agent custom execptions."""
 
-from __future__ import print_function
+import signal
+import types
+import typing
+
+
+class ConfigValueError(Exception):
+    """Invalid config value."""
+
+    pass
 
 
 class TermException(BaseException):
@@ -20,6 +28,7 @@ class TermException(BaseException):
     pass
 
 
-def handle_sigterm(signum, frame):
+def handle_sigterm(signum: signal.Signals,
+                   frame: typing.Optional[types.FrameType]) -> None:
     """Handle a SIGTERM signal by raising custom exception."""
     raise TermException
diff --git a/prefix_list_agent/prefix_list_agent/types.py b/prefix_list_agent/prefix_list_agent/types.py
new file mode 100644
index 0000000..2ce7bc7
--- /dev/null
+++ b/prefix_list_agent/prefix_list_agent/types.py
@@ -0,0 +1,56 @@
+# Copyright (c) 2019 Ben Maddison. All rights reserved.
+#
+# The contents of this file are licensed under the MIT License
+# (the "License"); you may not use this file except in compliance with the
+# License.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""prefix_list_agent typing helpers."""
+
+import typing
+
+ConfigVal = typing.TypeVar("ConfigVal")
+
+StatusVal = typing.TypeVar("StatusVal")
+
+Policies = typing.Dict[str, str]
+
+Configured = typing.Dict[
+    str,
+    typing.DefaultDict[
+        str,
+        typing.Dict[str, str],
+    ],
+]
+
+Objects = typing.Set[str]
+
+Stats = typing.Dict[str, int]
+
+RptkPrefixEntry = typing.Dict[  # prefix
+    str,
+    typing.Union[str, bool, int],
+]
+
+RptkPrefixEntries = typing.List[RptkPrefixEntry]
+
+RptkPrefixes = typing.Dict[
+    str,  # object
+    typing.Dict[
+        str,  # afi
+        RptkPrefixEntries,
+    ],
+]
+
+RptkResult = typing.Union[Policies, RptkPrefixes]
+
+Data = typing.Dict[
+    str,  # policy
+    RptkPrefixes,
+]
+
+EapiResponse = typing.Any
diff --git a/prefix_list_agent/worker.py b/prefix_list_agent/prefix_list_agent/worker.py
similarity index 60%
rename from prefix_list_agent/worker.py
rename to prefix_list_agent/prefix_list_agent/worker.py
index 54e0d4c..67e94c5 100644
--- a/prefix_list_agent/worker.py
+++ b/prefix_list_agent/prefix_list_agent/worker.py
@@ -11,59 +11,72 @@
 # the License.
 """prefix_list_agent worker functions."""
 
-from __future__ import print_function
-
 import collections
 import json
 import multiprocessing
+import multiprocessing.connection
 import os
 import re
 import signal
 import sys
 import time
-import urllib2
+import typing
+import urllib.error
+import urllib.request
+
+import eossdk
+
+from .base import PrefixListBase
+from .exceptions import TermException, handle_sigterm
+from .types import (Configured, Data, EapiResponse, Objects, Policies,
+                    RptkPrefixEntries, RptkPrefixEntry, RptkPrefixes,
+                    RptkResult, Stats)
 
-from prefix_list_agent.base import PrefixListBase
-from prefix_list_agent.exceptions import handle_sigterm, TermException
+PATH_RE = r"^file:{}/(?P<policy>\w+)/(?P<file>[-.:\w]+)$"
 
 
 class PrefixListWorker(multiprocessing.Process, PrefixListBase):
     """Worker to fetch and process IRR data."""
 
-    def __init__(self, endpoint, path, eapi, update_delay, *args, **kwargs):
+    def __init__(self,
+                 rptk_endpoint: str,
+                 source_dir: str,
+                 update_delay: typing.Optional[int],
+                 eapi: eossdk.EapiMgr,
+                 *args: typing.Any,
+                 **kwargs: typing.Any) -> None:
         """Initialise an PrefixListWorker instance."""
         super(PrefixListWorker, self).__init__(*args, **kwargs)
         PrefixListBase.__init__(self)
-        self.endpoint = endpoint
-        self.path = path
-        self.eapi = eapi
+        self.rptk_endpoint = rptk_endpoint
+        self.source_dir = source_dir
         self.update_delay = update_delay
-        self.path_re = re.compile(r"^file:{}/(?P<policy>\w+)/(?P<file>[-.:\w]+)$"  # noqa: E501
-                                  .format(self.path.rstrip("/")))
+        self.eapi = eapi
+        self.path_re = re.compile(PATH_RE.format(self.source_dir.rstrip("/")))
         self._p_err, self._c_err = multiprocessing.Pipe(duplex=False)
         self._p_data, self._c_data = multiprocessing.Pipe(duplex=False)
 
     @property
-    def p_err(self):
-        """Get 'p_err' property."""
+    def p_err(self) -> multiprocessing.connection.Connection:
+        """Get 'p_err' connection."""
         return self._p_err
 
     @property
-    def c_err(self):
-        """Get 'c_err' property."""
+    def c_err(self) -> multiprocessing.connection.Connection:
+        """Get 'c_err' connection."""
         return self._c_err
 
     @property
-    def p_data(self):
-        """Get 'p_data' property."""
+    def p_data(self) -> multiprocessing.connection.Connection:
+        """Get 'p_data' connection."""
         return self._p_data
 
     @property
-    def c_data(self):
-        """Get 'c_data' property."""
+    def c_data(self) -> multiprocessing.connection.Connection:
+        """Get 'c_data' connection."""
         return self._c_data
 
-    def run(self):
+    def run(self) -> None:
         """Run the worker process."""
         self.info("Worker started")
         signal.signal(signal.SIGTERM, handle_sigterm)
@@ -88,21 +101,22 @@ def run(self):
             self.c_err.close()
             self.c_data.close()
 
-    def get_configured(self, policies):
+    def get_configured(self, policies: Policies) -> Configured:
         """Get the prefix-lists in running-config."""
-        configured = {p: collections.defaultdict(dict) for p in policies}
-        self.info("Searching for prefix-lists with source matching {}"
-                  .format(self.path_re.pattern))
+        configured: Configured = {p: collections.defaultdict(dict)
+                                  for p in policies}
+        self.info("Searching for prefix-lists with "
+                  f"source matching {self.path_re.pattern}")
         for afi, cmd in (("ipv4", "show ip prefix-list"),
                          ("ipv6", "show ipv6 prefix-list")):
             data = self.eapi_request(cmd, result_node="ipPrefixLists")
-            self.debug("Got response: {}".format(data))
+            self.debug(f"Got response: {data}")
             for name, config in data.items():
                 try:
                     source = config["ipPrefixListSource"]
                 except KeyError:
                     continue
-                self.debug("Testing {}, source {}".format(name, source))
+                self.debug(f"Testing {name}, source {source}")
                 m = self.path_re.match(source)
                 if m:
                     self.debug("Source matched")
@@ -110,24 +124,25 @@ def get_configured(self, policies):
                     if policy in policies:
                         configured[policy][name][afi] = file
                     else:
-                        self.warning("Ignoring unknown policy {}"
-                                     .format(policy))
+                        self.warning(f"Ignoring unknown policy {policy}")
                 else:
                     self.debug("No match")
         return configured
 
-    def refresh_prefix_list(self, afi, prefix_list=None):
+    def refresh_prefix_list(self,
+                            afi: str,
+                            prefix_list: typing.Optional[str] = None) -> None:
         """Refresh all prefix-lists or a single named prefix-list."""
-        cmd = "refresh {} prefix-list".format(afi)
+        cmd = f"refresh {afi} prefix-list"
         if prefix_list is not None:
-            cmd += " {}".format(prefix_list)
+            cmd += f" {prefix_list}"
         messages = self.eapi_request(cmd, result_node="messages",
                                      allow_empty=True)
         for msg in messages:
             for submsg in msg.replace("\nNum", " -").rstrip().split("\n"):
                 self.info(submsg)
 
-    def refresh_all(self, written_objs):
+    def refresh_all(self, written_objs: typing.Iterable[str]) -> None:
         """Refresh prefix-lists."""
         self.info("Refreshing source-based prefix-lists")
         for afi in ("ip", "ipv6"):
@@ -139,15 +154,15 @@ def refresh_all(self, written_objs):
                     time.sleep(self.update_delay)
         self.notice("Prefix-lists refreshed successfully")
 
-    def get_policies(self):
+    def get_policies(self) -> Policies:
         """Get the list of valid policy names from RPTK."""
         url_path = "/policies"
-        self.info("Trying to get policy data from {}".format(url_path))
+        self.info(f"Trying to get policy data from {url_path}")
         policies = self.rptk_request(url_path)
-        self.debug("Got policies: {}".format(policies.keys()))
-        return policies
+        self.debug(f"Got policies: {policies.keys()}")
+        return typing.cast(Policies, policies)
 
-    def get_data(self, configured):
+    def get_data(self, configured: Configured) -> Data:
         """Get IRR data for the configured prefix-list objects."""
         data = dict()
         self.info("Querying for IRR data")
@@ -172,40 +187,42 @@ def get_data(self, configured):
                 data[policy].update(result)
         return data
 
-    def get_data_bulk(self, policy, objs):
+    def get_data_bulk(self,
+                      policy: str,
+                      objs: typing.Iterable[str]) -> RptkPrefixes:
         """Get IRR data in bulk."""
-        url_path = "/json/query?policy={}&".format(policy) + \
-                   "&".join(["objects={}".format(obj) for obj in objs])
-        self.info("Trying to get prefix data from {}".format(url_path))
+        url_path = f"/json/query?policy={policy}&" + \
+                   "&".join([f"objects={obj}" for obj in objs])
+        self.info(f"Trying to get prefix data from {url_path}")
         result = self.rptk_request(url_path)
         self.debug("Got prefix data")
-        return result
+        return typing.cast(RptkPrefixes, result)
 
-    def get_data_obj(self, policy, obj):
+    def get_data_obj(self, policy: str, obj: str) -> RptkPrefixes:
         """Get IRR data for a single object."""
-        url_path = "/json/{}/{}".format(obj, policy)
-        self.info("Trying to get prefix data from {}".format(url_path))
+        url_path = f"/json/{obj}/{policy}"
+        self.info(f"Trying to get prefix data from {url_path}")
         result = self.rptk_request(url_path)
         self.debug("Got prefix data")
-        return result
+        return typing.cast(RptkPrefixes, result)
 
-    # TODO: include list of written prefix-lists, to give to 'refresh_all()'
-    def write_results(self, configured, data):
+    def write_results(self,
+                      configured: Configured,
+                      data: Data) -> typing.Tuple[Stats, Objects]:
         """Write prefix-list data to files."""
         stats = {"succeeded": 0, "failed": 0}
         written_objs = set()
         for policy, objs in configured.items():
-            self.info("Writing files for policy {}".format(policy))
+            self.info(f"Writing files for policy {policy}")
             if not objs:
-                self.info("No objects with policy: {}".format(policy))
+                self.info(f"No objects with policy: {policy}")
                 continue
-            policy_dir = os.path.join(self.path, policy)
+            policy_dir = os.path.join(self.source_dir, policy)
             if not os.path.isdir(policy_dir):
-                self.info("Creating directory {}".format(policy_dir))
+                self.info(f"Creating directory {policy_dir}")
                 os.makedirs(policy_dir)
             for obj, config in objs.items():
-                self.info("Trying to write files for {}/{}"
-                          .format(obj, policy))
+                self.info(f"Trying to write files for {obj}/{policy}")
                 if obj in data[policy]:
                     for afi, file in config.items():
                         path = os.path.join(policy_dir, file)
@@ -218,23 +235,25 @@ def write_results(self, configured, data):
                         stats["succeeded"] += 1
                         written_objs.add(obj)
                 else:
-                    self.warning("No prefix data for {}/{}"
-                                 .format(obj, policy))
+                    self.warning(f"No prefix data for {obj}/{policy}")
                     stats["failed"] += len(config)
         return stats, written_objs
 
-    def write_prefix_list(self, path, entries, afi):
+    def write_prefix_list(self,
+                          path: str,
+                          entries: RptkPrefixEntries,
+                          afi: str) -> None:
         """Write prefix-list to file."""
-        self.info("Trying to write {}".format(path))
+        self.info(f"Trying to write {path}")
         try:
             with open(path, "w") as f:
                 for i, p in enumerate(entries):
                     f.write(self.prefix_list_line(i, p))
         except Exception as e:
-            self.err("Failed to write {}: {}".format(path, e))
+            self.err(f"Failed to write {path}: {e}")
             raise e
 
-    def prefix_list_line(self, index, entry):
+    def prefix_list_line(self, index: int, entry: RptkPrefixEntry) -> str:
         """Generate a line in a prefix-list."""
         line = "seq {} permit {}".format(index + 1, entry["prefix"])
         if not entry["exact"]:
@@ -245,65 +264,69 @@ def prefix_list_line(self, index, entry):
         line += "\n"
         return line
 
-    def eapi_request(self, cmd, result_node, allow_empty=False):
+    def eapi_request(self,
+                     cmd: str,
+                     result_node: str,
+                     allow_empty: bool = False) -> EapiResponse:
         """Get call an enable-mode eAPI command."""
-        self.debug("Calling eAPI command {}".format(cmd))
+        self.debug(f"Calling eAPI command {cmd}")
         try:
             resp = self.eapi.run_show_cmd(cmd)
         except Exception as e:
-            self.err("eAPI request failed: {}".format(e))
+            self.err(f"eAPI request failed: {e}")
             raise e
         if resp.success():
             data = self.json_load(resp.responses()[0])
         else:
-            e = RuntimeError("eAPI request failed: {} ({})"
-                             .format(resp.error_message(),
-                                     resp.error_code()))
-            self.err(e)
-            raise e
+            err = RuntimeError(f"eAPI request failed: {resp.error_message()} "
+                               f"({resp.error_code()})")
+            self.err(err)
+            raise err
         try:
             result = data[result_node]
         except KeyError as e:
             if allow_empty:
                 result = {}
             else:
-                self.err("Failed to get result data: {}".format(e))
+                self.err(f"Failed to get result data: {e}")
                 raise e
         self.debug("eAPI request successful")
         return result
 
-    def rptk_request(self, url_path):
+    def rptk_request(self, url_path: str) -> RptkResult:
         """Perform a query against the RPTK endpoint."""
-        url = "{}/{}".format(self.endpoint.rstrip("/"),
+        url = "{}/{}".format(self.rptk_endpoint.rstrip("/"),
                              url_path.lstrip("/"))
-        self.debug("Querying RPTK endpoint at {}".format(url))
+        self.debug(f"Querying RPTK endpoint at {url}")
         try:
-            resp = urllib2.urlopen(url)
-        except urllib2.HTTPError as e:
-            self.err("Request failed: {} {}".format(e.code, e.reason))
+            # TODO: construct url properly
+            resp = urllib.request.urlopen(url)  # noqa: S310
+        except urllib.error.HTTPError as e:
+            self.err(f"Request failed: {e.code} {e.reason}")
             raise e
-        except urllib2.URLError as e:
-            self.err("Request failed: {}".format(e))
+        except urllib.error.URLError as e:
+            self.err(f"Request failed: {e}")
             raise e
-        self.debug("Request successful: {}".format(resp.getcode()))
+        self.debug(f"Request successful: {resp.getcode()}")
         result = self.json_load(resp)
-        return result
+        return typing.cast(RptkResult, result)
 
-    def json_load(self, obj):
+    def json_load(self,
+                  obj: typing.Union[str, typing.TextIO]) -> typing.Any:
         """Deserialise JSON from a string or file-like object."""
-        def fail(e):
-            self.err("Failed to deserialise response: {}".format(e))
+        def fail(e: Exception) -> None:
+            self.err(f"Failed to deserialise response: {e}")
             raise e
 
         self.debug("Deserialising JSON response")
         try:
             self.debug("Trying 'json.load' method")
-            result = json.load(obj)
+            result = json.load(typing.cast(typing.TextIO, obj))
         except AttributeError:
             self.debug("Object has no 'read' method")
             self.debug("Trying 'json.loads' method")
             try:
-                result = json.loads(obj)
+                result = json.loads(typing.cast(str, obj))
             except Exception as e:
                 fail(e)
         except Exception as e:
@@ -312,13 +335,15 @@ def fail(e):
         return result
 
     @property
-    def data(self):
+    def data(self) -> typing.Optional[Stats]:
         """Get data from the worker."""
         if self.p_data.poll():
-            return self.p_data.recv()
+            return typing.cast(Stats, self.p_data.recv())
+        return None
 
     @property
-    def error(self):
+    def error(self) -> typing.Optional[Exception]:
         """Get exception raised by worker."""
         if self.p_err.poll():
-            return self.p_err.recv()
+            return typing.cast(Exception, self.p_err.recv())
+        return None
diff --git a/prefix_list_agent/pyproject.toml b/prefix_list_agent/pyproject.toml
new file mode 100644
index 0000000..ff2f8c1
--- /dev/null
+++ b/prefix_list_agent/pyproject.toml
@@ -0,0 +1,38 @@
+[build-system]
+requires = [
+    "setuptools>=45",
+    "wheel",
+    "setuptools_scm[toml]>=6.0",
+]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools_scm]
+root = ".."
+local_scheme = "no-local-version"
+
+[tool.pytest.ini_options]
+addopts = "-vs --strict-markers --cov --cov-report=term-missing --cov-report=xml --cov-branch"
+xfail_strict = true
+
+[tool.coverage.run]
+include = [
+    "*/prefix_list_agent/*",
+]
+
+[tool.coverage.report]
+exclude_lines = [
+    "pragma: no cover",
+    ".def __repr__.",
+    "if __name__ == .__main__.:",
+    "if typing.TYPE_CHECKING:",
+    "raise NotImplementedError",
+]
+
+[tool.mypy]
+pretty = true
+strict = true
+show_error_codes = true
+
+[[tool.mypy.overrides]]
+module = "eossdk.*"
+ignore_missing_imports = true
diff --git a/prefix_list_agent/setup.cfg b/prefix_list_agent/setup.cfg
new file mode 100644
index 0000000..96d0458
--- /dev/null
+++ b/prefix_list_agent/setup.cfg
@@ -0,0 +1,48 @@
+[metadata]
+name = eos-prefix-list-agent
+description = An EOS agent to dynamically update IRR based prefix-lists
+long_description = file: README.md, LICENSE
+long_description_content_type = text/markdown
+author = Ben Maddison
+author_email = benm@workonline.africa
+url = https://github.com/wolcomm/eos-prefix-list-agent
+project_urls =
+    Documentation=https://wolcomm.github.io/eos-prefix-list-agent
+    Bug Tracker=https://github.com/wolcomm/eos-prefix-list-agent/issues
+keywords = bgp, irr
+license = MIT
+classifiers =
+    Development Status :: 4 - Beta
+    Environment :: Other Environment
+    Intended Audience :: Telecommunications Industry
+    License :: OSI Approved :: MIT License
+    Programming Language :: Python :: 3
+    Topic :: Internet
+    Topic :: System :: Networking
+    Typing :: Typed
+
+[options]
+zip_safe = False
+include_package_data = True
+packages = find:
+python_requires = ~=3.7
+scripts =
+    bin/PrefixListAgent
+
+[flake8]
+max-line-length = 79
+doctests = True
+docstring-convention = pep257
+radon-max-cc = 10
+statistics = True
+count = True
+exclude = 
+    .git,
+    __pycache__,
+    build,
+    dist,
+    .venv,
+    .tox,
+per-file-ignores =
+    prefix_list_agent/**/__init__.py:F401
+    tests/*.py:S101,S404,S603
diff --git a/prefix_list_agent_cli/LICENSE b/prefix_list_agent_cli/LICENSE
new file mode 120000
index 0000000..ea5b606
--- /dev/null
+++ b/prefix_list_agent_cli/LICENSE
@@ -0,0 +1,1 @@
+../LICENSE
\ No newline at end of file
diff --git a/prefix_list_agent_cli/README.md b/prefix_list_agent_cli/README.md
new file mode 120000
index 0000000..32d46ee
--- /dev/null
+++ b/prefix_list_agent_cli/README.md
@@ -0,0 +1,1 @@
+../README.md
\ No newline at end of file
diff --git a/prefix_list_agent/__init__.py b/prefix_list_agent_cli/prefix_list_agent_cli/__init__.py
similarity index 60%
rename from prefix_list_agent/__init__.py
rename to prefix_list_agent_cli/prefix_list_agent_cli/__init__.py
index b82fdc1..914f54d 100644
--- a/prefix_list_agent/__init__.py
+++ b/prefix_list_agent_cli/prefix_list_agent_cli/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2019 Workonline Communications (Pty) Ltd. All rights reserved.
+# Copyright (c) 2022 Workonline Communications (Pty) Ltd. All rights reserved.
 #
 # The contents of this file are licensed under the MIT License
 # (the "License"); you may not use this file except in compliance with the
@@ -9,18 +9,8 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
-"""prefix_list_agent Package."""
-
-from __future__ import print_function
-from __future__ import unicode_literals
+"""prefix_list_agent_cli Package."""
 
 import logging
 
-import prefix_list_agent.__meta__  # noqa
-
-from prefix_list_agent.agent import PrefixListAgent
-from prefix_list_agent.cli import start
-
 logging.getLogger(__name__).addHandler(logging.NullHandler())
-
-__all__ = [PrefixListAgent.__name__, start.__name__]
diff --git a/prefix_list_agent_cli/prefix_list_agent_cli/extension.py b/prefix_list_agent_cli/prefix_list_agent_cli/extension.py
new file mode 100644
index 0000000..d0c167c
--- /dev/null
+++ b/prefix_list_agent_cli/prefix_list_agent_cli/extension.py
@@ -0,0 +1,132 @@
+# Copyright (c) 2022 Workonline Communications (Pty) Ltd. All rights reserved.
+#
+# The contents of this file are licensed under the MIT License
+# (the "License"); you may not use this file except in compliance with the
+# License.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""PrefixListAgent CLI plugin handlers."""
+
+import sys
+from typing import Any, Dict, Optional, Text
+
+import CliExtension
+
+
+class ShowPrefixListAgent(CliExtension.ShowCommandClass):  # type: ignore[misc]
+    """Handlers for `show prefix-list-agent` command."""
+
+    def handler(self, ctx):
+        # type: (Any) -> Optional[Dict[Text, Any]]
+        """Handle `show prefix-list-agent` command."""
+        daemon = ctx.getDaemon("PrefixListAgent")
+        if daemon is None:
+            ctx.addError("Unable to get daemon info")
+            return None
+        result = {"enabled": daemon.config.isEnabled(),
+                  "config": {key: value for key, value
+                             in daemon.config.configIter()},
+                  "status": {key: value for key, value
+                             in daemon.status.statusIter()}}
+        return result
+
+    def render(self, data):
+        # type: (Dict[Text, Any]) -> None
+        """Render `show prefix-list-agent` command output."""
+        sys.stdout.write("Prefix list agent: ")
+        if data["enabled"]:
+            sys.stdout.write("Running\n")
+            sys.stdout.write("\nConfiguration\n")
+            sys.stdout.write("----\n")
+            for key, value in data["config"].items():
+                sys.stdout.write("{:10}: {}\n".format(key, value))
+            sys.stdout.write("\nStatus\n")
+            sys.stdout.write("----\n")
+            for key, value in data["status"].items():
+                sys.stdout.write("{:10}: {}\n".format(key, value))
+        else:
+            sys.stdout.write("Not running\n")
+
+
+class PrefixListAgentCfgDisabled(CliExtension.CliCommandClass):  # type: ignore[misc]  # noqa: E501
+    """Handlers for `[no] disabled` commands."""
+
+    def handler(self, ctx):
+        # type: (Any) -> None
+        """Handle `disabled` command."""
+        ctx.daemon.config.disable()
+
+    def noHandler(self, ctx):  # noqa: N802
+        # type: (Any) -> None
+        """Handle `no disabled` command."""
+        ctx.daemon.config.enable()
+
+
+class PrefixListAgentCfg(CliExtension.CliCommandClass):  # type: ignore[misc]
+    """Base configuration command handler."""
+
+    option_key = None  # type: Text
+    arg_key = None  # type: Text
+
+    def handler(self, ctx):
+        # type: (Any) -> None
+        """Handle configuration command."""
+        value = ctx.args[self.arg_key]
+        ctx.daemon.config.configSet(self.option_key, value)
+
+
+class PrefixListAgentCfgNullable(PrefixListAgentCfg):
+    """Base nullable configuration command handler."""
+
+    def noHandler(self, ctx):  # noqa: N802
+        # type: (Any) -> None
+        """Handle `no ...` configuration command."""
+        ctx.daemon.config.configDel(self.option_key)
+
+
+class PrefixListAgentCfgEndpoint(PrefixListAgentCfgNullable):
+    """Handlers for `[no] rptk-endpoint <url>` command."""
+
+    option_key = "rptk-endpoint"
+    arg_key = "<url>"
+
+
+class PrefixListAgentCfgSrcDir(PrefixListAgentCfg):
+    """Handlers for `source-directory <path>` command."""
+
+    option_key = "source-directory"
+    arg_key = "<path>"
+
+
+class PrefixListAgentCfgInterval(PrefixListAgentCfg):
+    """Handlers for `refresh-interval <int>` command."""
+
+    option_key = "refresh-interval"
+    arg_key = "<int>"
+
+
+class PrefixListAgentCfgDelay(PrefixListAgentCfgNullable):
+    """Handlers for `[no] update-delay <int>` command."""
+
+    option_key = "update-delay"
+    arg_key = "<int>"
+
+
+def Plugin(ctx):  # noqa: N802
+    # type: (Any) -> None
+    """Initialise CLI plugin."""
+    CliExtension.registerCommand("show_prefix_list_agent", ShowPrefixListAgent)
+    CliExtension.registerCommand("cfg_prefix_list_agent_disabled",
+                                 PrefixListAgentCfgDisabled)
+    CliExtension.registerCommand("cfg_prefix_list_agent_endpoint",
+                                 PrefixListAgentCfgEndpoint)
+    CliExtension.registerCommand("cfg_prefix_list_agent_source_dir",
+                                 PrefixListAgentCfgSrcDir)
+    CliExtension.registerCommand("cfg_prefix_list_agent_interval",
+                                 PrefixListAgentCfgInterval)
+    CliExtension.registerCommand("cfg_prefix_list_agent_delay",
+                                 PrefixListAgentCfgDelay)
diff --git a/prefix_list_agent_cli/prefix_list_agent_cli/extension.yaml b/prefix_list_agent_cli/prefix_list_agent_cli/extension.yaml
new file mode 100644
index 0000000..446bfbb
--- /dev/null
+++ b/prefix_list_agent_cli/prefix_list_agent_cli/extension.yaml
@@ -0,0 +1,106 @@
+# Copyright (c) 2022 Workonline Communications (Pty) Ltd. All rights reserved.
+#
+# The contents of this file are licensed under the MIT License
+# (the "License"); you may not use this file except in compliance with the
+# License.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+---
+vendor:
+  name: Workonline Communications
+  email: oss@workonline.africa
+daemons:
+  PrefixListAgent:
+    exe: /usr/bin/PrefixListAgent
+schemas:
+  - &show_prefix_list_agent_schema
+    $schema: http://json-schema.org/draft-07/schema#
+    type: object
+    properties:
+      enabled:
+        type: boolean
+      config:
+        type: object
+      status:
+        type: object
+modes:
+  prefix_list_agent_mode:
+    command:
+      syntax: prefix-list-agent
+      noSyntax: prefix-list-agent
+      data:
+        prefix-list-agent:
+          keyword:
+            help: "EOS prefix-list agent"
+    modeKey: pfx-lst-agent
+    daemon: PrefixListAgent
+commands:
+  show_prefix_list_agent:
+    syntax: show prefix-list-agent
+    outputSchema: *show_prefix_list_agent_schema
+    mode: Unprivileged
+    data:
+      prefix-list-agent:
+        keyword:
+          help: "show prefix-list-agent state"
+  cfg_prefix_list_agent_disabled:
+    syntax: disabled
+    noSyntax: disabled
+    mode: prefix_list_agent_mode
+    data:
+      disabled:
+        keyword:
+          help: "Disable agent daemon"
+  cfg_prefix_list_agent_endpoint:
+    syntax: rptk-endpoint <url>
+    noSyntax: rptk-endpoint [<url>]
+    mode: prefix_list_agent_mode
+    data:
+      rptk-endpoint:
+        keyword:
+          help: "RPTK API endpoint"
+      <url>:
+        regex:
+          regex: "^https?://[\\w-]+(\\.[\\w-]+)*(:\\d+)?/?$"
+          help: "RPTK API endpoint URL"
+  cfg_prefix_list_agent_source_dir:
+    syntax: source-directory <path>
+    mode: prefix_list_agent_mode
+    data:
+      source-directory:
+        keyword:
+          help: "data source directory"
+      <path>:
+        regex:
+          regex: "^/\\w+(/\\w+)*/?$"
+          help: "data source directory path"
+  cfg_prefix_list_agent_interval:
+    syntax: refresh-interval <int>
+    mode: prefix_list_agent_mode
+    data:
+      refresh-interval:
+        keyword:
+          help: "Refresh interval"
+      <int>:
+        integer:
+          min: 10
+          max: 86400
+          help: "interval (seconds)"
+  cfg_prefix_list_agent_delay:
+    syntax: update-delay <int>
+    noSyntax: update-delay [<int>]
+    mode: prefix_list_agent_mode
+    data:
+      update-delay:
+        keyword:
+          help: "Update delay"
+      <int>:
+        integer:
+          min: 1
+          max: 120
+          help: "delay (seconds)"
+...
diff --git a/prefix_list_agent_cli/pyproject.toml b/prefix_list_agent_cli/pyproject.toml
new file mode 100644
index 0000000..9f81031
--- /dev/null
+++ b/prefix_list_agent_cli/pyproject.toml
@@ -0,0 +1,20 @@
+[build-system]
+requires = [
+    "setuptools==44.1.1",
+    "wheel==0.37.0",
+    "setuptools_scm[toml]==5.0.2",
+]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools_scm]
+root = ".."
+local_scheme = "no-local-version"
+
+[tool.mypy]
+pretty = true
+strict = true
+show_error_codes = true
+
+[[tool.mypy.overrides]]
+module = "CliExtension"
+ignore_missing_imports = true
diff --git a/prefix_list_agent_cli/setup.cfg b/prefix_list_agent_cli/setup.cfg
new file mode 100644
index 0000000..4cbdf26
--- /dev/null
+++ b/prefix_list_agent_cli/setup.cfg
@@ -0,0 +1,45 @@
+[metadata]
+name = eos-prefix-list-agent-cli
+description = EOS CLI extensions for eos-prefix-list-agent
+long_description = file: README.md, LICENSE
+long_description_content_type = text/markdown
+author = Ben Maddison
+author_email = benm@workonline.africa
+url = https://github.com/wolcomm/eos-prefix-list-agent
+project_urls =
+    Documentation=https://wolcomm.github.io/eos-prefix-list-agent
+    Bug Tracker=https://github.com/wolcomm/eos-prefix-list-agent/issues
+keywords = bgp, irr
+license = MIT
+license_files = LICENSE
+classifiers =
+    Development Status :: 4 - Beta
+    Environment :: Other Environment
+    Intended Audience :: Telecommunications Industry
+    License :: OSI Approved :: MIT License
+    Programming Language :: Python :: 2
+    Topic :: Internet
+    Topic :: System :: Networking
+
+[options]
+zip_safe = False
+include_package_data = True
+packages = find:
+python_requires = ~=2.7
+
+[flake8]
+max-line-length = 79
+doctests = True
+docstring-convention = pep257
+radon-max-cc = 10
+statistics = True
+count = True
+exclude = 
+    .git,
+    __pycache__,
+    build,
+    dist,
+    .venv,
+    .tox,
+per-file-ignores =
+    tests/*.py:S101,S404,S603
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 94fb802..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,17 +0,0 @@
-[install]
-prefix=/usr
-
-[bdist_wheel]
-universal = 1
-
-[bdist_rpm]
-python = /usr/bin/python
-dist-dir = bdist_rpm
-
-[tool:pytest]
-addopts = -vs --cov --cov-report=term-missing --cov-report=xml --pylama --durations=10
-xfail_strict = true
-
-[pylama]
-linters = pycodestyle,pyflakes,mccabe,pydocstyle,import_order
-ignore = D203,D212,D213,D404
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 62de5a7..0000000
--- a/setup.py
+++ /dev/null
@@ -1,60 +0,0 @@
-#!/usr/bin/env python
-# Copyright (c) 2019 Workonline Communications (Pty) Ltd. All rights reserved.
-#
-# The contents of this file are licensed under the MIT License
-# (the "License"); you may not use this file except in compliance with the
-# License.
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-# License for the specific language governing permissions and limitations under
-# the License.
-"""Setup configuration script for package."""
-
-from __future__ import print_function
-from __future__ import unicode_literals
-
-import os
-import re
-
-from setuptools import find_packages, setup
-
-package = {"__name__": find_packages()[0]}
-here = os.path.abspath(os.path.dirname(__file__))
-
-with open(os.path.join(here, package["__name__"], "__meta__.py")) as f:
-    exec(f.read(), package)
-
-with open(os.path.join(here, "packaging", "requirements.txt")) as f:
-    package["__requirements__"] = f.readlines()
-
-description_regexp = re.compile(r'<!--description: (.+) -->')
-with open(os.path.join(here, "README.md")) as f:
-    package["__readme__"] = ""
-    for line in f:
-        match = description_regexp.match(line)
-        if match:
-            package["__description__"] = match.group(1)
-        else:
-            package["__readme__"] += line
-
-
-setup(
-    name=package["__name__"],
-    version=package["__version__"],
-    author=package["__author__"],
-    author_email=package["__author_email__"],
-    description=package["__description__"],
-    long_description=package["__readme__"],
-    long_description_content_type="text/markdown",
-    license=package["__licence__"],
-    classifiers=package["__classifiers__"],
-    packages=find_packages(),
-    include_package_data=True,
-    url=package["__url__"],
-    download_url="{}/{}".format(package["__url__"], package["__version__"]),
-    install_requires=package["__requirements__"],
-    entry_points=package["__entry_points__"],
-    scripts=package["__scripts__"]
-)


Test patch:
diff --git a/packaging/requirements-test.txt b/packaging/requirements-test.txt
deleted file mode 100644
index b0a2437..0000000
--- a/packaging/requirements-test.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-flake8-import-order>=0.18.1,<0.19
-Flask>=1.0.2,<2.0
-gunicorn>=19.9.0,<20.0
-pyeapi>=0.8.2,<0.9
-pytest>=4.4.0,<5.0
-pytest-cov>=2.6.1,<3.0
-pytest-mock>=1.10.3,<2.0
-pylama>=7.6.6,<8.0
diff --git a/test.sh b/test.sh
deleted file mode 100755
index 39c77bb..0000000
--- a/test.sh
+++ /dev/null
@@ -1,80 +0,0 @@
-#!/usr/bin/env bash
-
-DEFAULT_VERSION="4.26.1F"
-REGISTRY="workonline.azurecr.io"
-USAGE="$(cat <<EOF
-usage: $0 ([-d] [-v <version>] | -h)
-    -d              Drop into a bash shell on the cEOS container after testing.
-    -v <version>    Test on cEOS <version>. default: $DEFAULT_VERSION.
-    -h              Display this help message.
-EOF
-)"
-
-ok() { echo "  [OK]"; }
-fail() {
-  if [ -z "$1" ]; then clean; fi
-  echo "  [FAIL]"; exit 1;
-}
-
-clean() {
-  if [[ "${CONTAINER_ID}" ]]; then
-    echo -n "Shutting down container"
-    docker kill "${CONTAINER_ID}" >/dev/null &&
-      ok || fail 1
-  fi
-  if [[ "${IMAGE_ID}" ]]; then
-    echo -n "Cleaning up image"
-    docker image rm "${IMAGE_ID}" >/dev/null &&
-      ok || fail 1
-  fi
-}
-
-while getopts "dv:h" OPT; do
-    case $OPT in
-        d)
-            DEBUG=1
-            ;;
-        v)
-            VERSION="$OPTARG"
-            ;;
-        h)
-            echo "$USAGE"
-            exit 0
-            ;;
-        *)
-            echo "$USAGE"
-            exit 1
-            ;;
-    esac
-done
-
-if [[ -z "$VERSION" ]]; then
-    echo "Using default cEOS version: $DEFAULT_VERSION"
-    VERSION="$DEFAULT_VERSION"
-fi
-
-# Prepare test container
-echo -n "Building test image"
-IMAGE_ID="$(docker build --build-arg REGISTRY="${REGISTRY}" --build-arg VERSION="${VERSION}" --quiet .)" &&
-  ok || fail
-echo -n "Starting container"
-CONTAINER_ID="$(docker run --detach --privileged --rm "${IMAGE_ID}")" &&
-  ok || fail
-
-# Run test suite
-echo "Running test suite"
-docker exec --interactive --tty "${CONTAINER_ID}" pytest
-echo -n "Retrieving coverage report"
-docker cp ${CONTAINER_ID}:/root/coverage.xml ./ >/dev/null &&
-  ok || fail
-
-# Drop into a shell if requested
-if [[ -n "$DEBUG" ]]; then
-  docker exec --interactive --tty "${CONTAINER_ID}" /bin/bash ||
-    fail
-fi
-
-# Clean up
-clean
-
-exit 0
diff --git a/tests/00_unit/conftest.py b/tests/00_unit/conftest.py
index 134092c..57b029b 100644
--- a/tests/00_unit/conftest.py
+++ b/tests/00_unit/conftest.py
@@ -11,23 +11,25 @@
 # the License.
 """Fixtures for prefix_list_agent test cases."""
 
-from __future__ import print_function
-
 import json
-
-import mock
-import pytest
+import multiprocessing.connection
+import unittest.mock
 
 import eossdk
 
-from _multiprocessing import Connection
-
 from prefix_list_agent.agent import PrefixListAgent
 
+import pytest
+
 
 class DummySdk(object):
     """Dummy class to stub-out eossdk interactions in test cases."""
 
+    def __init__(self, **options):
+        """Initialise dummy sdk stub."""
+        self.options = options
+        self.state = dict()
+
     def name(self):
         """Stub for 'name' method."""
         return "PrefixListAgent"
@@ -40,36 +42,45 @@ def main_loop(self, arg=None):
 
     def get_agent_mgr(self):
         """Stub for 'get_agent_mgr' method."""
-        mgr = mock.create_autospec(eossdk.AgentMgr)
-
-        test_options = {"rptk_endpoint": "https://example.com",
-                        "source_dir": "/foo/bar",
-                        "refresh_interval": 60,
-                        "update_delay": 1,
-                        "bad_option": None}
+        mgr = unittest.mock.create_autospec(eossdk.AgentMgr)
 
-        def agent_option_iter():
-            return test_options.keys()
-        mgr.agent_option_iter.side_effect = agent_option_iter
+        def agent_option_exists(key):
+            return key in self.options and self.options[key] is not None
+        mgr.agent_option_exists.side_effect = agent_option_exists
 
         def agent_option(key):
-            return test_options[key]
+            return self.options[key]
         mgr.agent_option.side_effect = agent_option
 
+        def status(key):
+            return self.state.get(key)
+        mgr.status.side_effect = status
+
+        def status_del(key):
+            try:
+                del self.state[key]
+            except KeyError:
+                pass
+        mgr.status_del.side_effect = status_del
+
+        def status_set(key, val):
+            self.state[key] = val
+        mgr.status_set.side_effect = status_set
+
         return mgr
 
     def get_timeout_mgr(self):
         """Stub for 'get_timeout_mgr' method."""
-        mgr = mock.create_autospec(eossdk.TimeoutMgr)
+        mgr = unittest.mock.create_autospec(eossdk.TimeoutMgr)
         return mgr
 
     def get_eapi_mgr(self):
         """Stub for 'get_eapi_mgr' method."""
-        mgr = mock.create_autospec(eossdk.EapiMgr)
+        mgr = unittest.mock.create_autospec(eossdk.EapiMgr)
 
         def run_show_cmd(cmd):
             if cmd == "error":
-                raise StandardError
+                raise RuntimeError
             elif cmd == "fail":
                 return eossdk.EapiResponse(False, 255, "synthetic_failure", [])
             elif cmd == "empty":
@@ -81,7 +92,7 @@ def run_show_cmd(cmd):
                     "AS-FOO": {"ipPrefixListSource": "file:/tmp/prefix-lists/strict/as-foo"},  # noqa: E501
                     "AS-BAR": {},
                     "AS-BAZ": {"ipPrefixListSource": "file:/baz/as-baz"},
-                    "AS-QUX": {"ipPrefixListSource": "file:/tmp/prefix-lists/qux/as-qux"}  # noqa: E501
+                    "AS-QUX": {"ipPrefixListSource": "file:/tmp/prefix-lists/qux/as-qux"},  # noqa: E501
                 }})
             else:
                 result = json.dumps({"{}_resp".format(cmd): {"foo": "bar"}})
@@ -97,14 +108,16 @@ def sdk():
     return DummySdk()
 
 
-@pytest.fixture()
-def agent(sdk, mocker):
+@pytest.fixture(params=({"rptk-endpoint": "https://example.com"},))
+def agent(request, mocker):
     """Provide a PrefixListAgent instance with mocked Mgrs."""
     for cls in PrefixListAgent.mro():
         if cls.__module__ == "eossdk":
             mocker.patch("eossdk.{}".format(cls.__name__), autospec=True)
+    # opts = getattr(request, "param", {})
+    opts = request.param
+    sdk = DummySdk(**opts)
     agent = PrefixListAgent(sdk)
-    agent.rptk_endpoint = "https://example.com"
     return agent
 
 
@@ -118,5 +131,5 @@ def worker(agent):
 @pytest.fixture()
 def connection(mocker):
     """Provide a mocked Connection instance."""
-    conn = mock.create_autospec(Connection)
+    conn = unittest.mock.create_autospec(multiprocessing.connection.Connection)  # noqa: E501
     return conn
diff --git a/tests/00_unit/test_agent.py b/tests/00_unit/test_agent.py
index 567faba..f54f955 100644
--- a/tests/00_unit/test_agent.py
+++ b/tests/00_unit/test_agent.py
@@ -11,68 +11,57 @@
 # the License.
 """Tests for prefix_list_agent.agent module."""
 
-from __future__ import print_function
-
 import datetime
 import signal
 import time
 
-import pytest
-
 from prefix_list_agent.agent import PrefixListAgent
-from prefix_list_agent.exceptions import handle_sigterm, TermException
+from prefix_list_agent.exceptions import (ConfigValueError, TermException,
+                                          handle_sigterm)
+
+import pytest
 
 
 class TestPrefixListAgent(object):
     """Test cases for PrefixListAgent object."""
 
-    def test_set_sysdb_mp(self, sdk):
-        """Test case for 'set_sysdb_mp' classmethod."""
-        # Should return True on the first call
-        assert PrefixListAgent.set_sysdb_mp(sdk.name())
-        # Should return False on subsequent calls
-        for i in range(3):
-            assert not PrefixListAgent.set_sysdb_mp(sdk.name())
-
     def test_init(self, agent):
         """Test case for PrefixListAgent initialisation."""
         assert isinstance(agent, PrefixListAgent)
 
-    def test_property_rptk_endpoint(self, agent):
-        """Test 'rptk_endpoint' getter and setter."""
-        assert agent.rptk_endpoint == "https://example.com"
-        test_value = "https://example.net"
-        agent.rptk_endpoint = test_value
-        assert agent.rptk_endpoint == test_value
-
-    def test_property_source_dir(self, agent):
-        """Test 'source_dir' getter and setter."""
-        assert agent.source_dir == "/tmp/prefix-lists"
-        test_value = "/foo/bar"
-        agent.source_dir = test_value
-        assert agent.source_dir == test_value
-
-    def test_property_refresh_interval(self, agent):
-        """Test 'refresh_interval' getter and setter."""
-        assert agent.refresh_interval == 3600
-        test_value = 60
-        agent.refresh_interval = test_value
-        assert agent.refresh_interval == test_value
-        for fail_value in ("x", 5, 100000):
-            with pytest.raises(ValueError):
-                agent.refresh_interval = fail_value
-        assert agent.refresh_interval == test_value
-
-    def test_property_update_delay(self, agent):
-        """Test 'update_delay' getter and setter."""
-        assert agent.update_delay is None
-        test_value = 5
-        agent.update_delay = test_value
-        assert agent.update_delay == test_value
-        for fail_value in ("x", 0, 100000):
-            with pytest.raises(ValueError):
-                agent.update_delay = fail_value
-        assert agent.update_delay == test_value
+    # def test_property_rptk_endpoint(self, agent):
+    #     """Test 'rptk_endpoint' getter and setter."""
+    #     assert agent.rptk_endpoint == "https://example.com"
+    #     test_value = "https://example.net"
+    #     agent.rptk_endpoint = test_value
+    #     assert agent.rptk_endpoint == test_value
+
+    # def test_property_source_dir(self, agent):
+    #     """Test 'source_dir' getter and setter."""
+    #     assert agent.source_dir == "/tmp/prefix-lists"  # noqa: S108
+    #     test_value = "/foo/bar"
+    #     agent.source_dir = test_value
+    #     assert agent.source_dir == test_value
+
+    @pytest.mark.parametrize(("agent", "value"),
+                             (({}, 3600),
+                              ({"refresh-interval": 60}, 60),
+                              pytest.param({"refresh-interval": 86500}, None,
+                                  marks=pytest.mark.xfail(raises=ConfigValueError))),  # noqa: E501
+                             indirect=("agent",))
+    def test_property_refresh_interval(self, agent, value):
+        """Test 'refresh_interval' getter."""
+        assert agent.refresh_interval == value
+
+    @pytest.mark.parametrize(("agent", "value"),
+                             (({}, None),
+                              ({"update-delay": 10}, 10),
+                              pytest.param({"update-delay": 0}, None,
+                                  marks=pytest.mark.xfail(raises=ConfigValueError))),  # noqa: E501
+                             indirect=("agent",))
+    def test_property_update_delay(self, agent, value):
+        """Test 'update_delay' getter."""
+        assert agent.update_delay == value
 
     def test_property_status(self, agent):
         """Test 'status' getter and setter."""
@@ -80,6 +69,8 @@ def test_property_status(self, agent):
         test_value = "test"
         agent.status = test_value
         assert agent.status == test_value
+        agent.status = None
+        assert agent.status is None
 
     def test_property_result(self, agent):
         """Test 'result' getter and setter."""
@@ -87,72 +78,47 @@ def test_property_result(self, agent):
         test_value = "test"
         agent.result = test_value
         assert agent.result == test_value
+        agent.result = None
+        assert agent.result is None
 
-    def test_property_timestamps(self, agent):
+    @pytest.mark.parametrize("prop", ("last_start", "last_end"))
+    def test_property_timestamps(self, agent, prop):
         """Test 'last_start' and 'last_end' getters and setters."""
-        for prop in ("last_start", "last_end"):
-            assert getattr(agent, prop) is None
-            test_value = datetime.datetime.now()
-            setattr(agent, prop, test_value)
-            assert getattr(agent, prop) == test_value
-            with pytest.raises(TypeError):
-                setattr(agent, prop, "foo")
-            assert getattr(agent, prop) == test_value
-
-    def test_configure(self, agent):
-        """Test 'configure' method."""
-        agent.configure()
-        agent.agent_mgr.agent_option_iter.assert_called_once()
-        assert agent.agent_mgr.agent_option.call_count == 5
-
-    @pytest.mark.parametrize(("key", "value"), (
-        ("rptk_endpoint", "https://x.com"),
-        ("source_dir", "/foo/bar"),
-        ("refresh_interval", 60),
-        ("update_delay", 1),
-        ("bad_option", None)
-    ))
-    def test_set(self, agent, mocker, key, value):
-        """Test case for 'set' method."""
-        mocker.patch.object(agent, "warning", autospec=True)
-        agent.set(key, value)
-        if key in agent.agent_options:
-            assert getattr(agent, key) == value
-        else:
-            agent.warning.assert_called_once_with("Ignoring unknown option '{}'"  # noqa: E501
-                                                  .format(key))
-
-    @pytest.mark.parametrize(("agent_key", "worker_key", "value"), (
-        ("rptk_endpoint", "endpoint", "https://foo.bar"),
-        ("source_dir", "path", "/quux/baz"),
-        ("update_delay", "update_delay", None),
-        ("update_delay", "update_delay", 1)
-    ))
-    def test_init_worker(self, agent, agent_key, worker_key, value):
+        assert getattr(agent, prop) is None
+        test_value = datetime.datetime.now()
+        setattr(agent, prop, test_value)
+        assert getattr(agent, prop) == test_value
+        with pytest.raises(TypeError):
+            setattr(agent, prop, "foo")
+        assert getattr(agent, prop) == test_value
+
+    def test_init_worker(self, agent):
         """Test case for `init_worker` method."""
-        agent.set(agent_key, value)
         agent.init_worker()
-        assert getattr(agent.worker, worker_key) == value
+        assert agent.rptk_endpoint == agent.worker.rptk_endpoint
+        assert agent.source_dir == agent.worker.source_dir
+        assert agent.update_delay == agent.worker.update_delay
 
     def test_start(self, agent, mocker):
         """Test case for 'start' method."""
-        methods = ("configure", "init", "run")
+        methods = ("init", "run")
         for method in methods:
             mocker.patch.object(agent, method, autospec=True)
         agent.start()
         for method in methods:
             getattr(agent, method).assert_called_once_with()
 
-    @pytest.mark.parametrize("side_effect", ((None,), StandardError()))
-    @pytest.mark.parametrize("rptk_endpoint", ("https://example.com", None))
-    def test_run(self, agent, worker, mocker, side_effect, rptk_endpoint):
+    @pytest.mark.parametrize("side_effect", ((None,), RuntimeError()))
+    @pytest.mark.parametrize("agent",
+                             ({}, {"rptk-endpoint": "https://example.com"}),
+                             indirect=True)
+    def test_run(self, agent, mocker, side_effect):
         """Test case for 'run' method."""
         mock_worker = mocker.patch("prefix_list_agent.agent.PrefixListWorker",
                                    autospec=True)
         mock_worker.return_value.start.side_effect = side_effect
         for method in ("watch", "failure", "sleep"):
             mocker.patch.object(agent, method, autospec=True)
-        agent.rptk_endpoint = rptk_endpoint
         agent.run()
         if agent.rptk_endpoint is None:
             agent.sleep.assert_called_once_with()
@@ -169,59 +135,65 @@ def test_watch(self, agent, mocker, connection):
         agent.watch_readable.assert_called_once_with(connection.fileno(), True)
         assert connection in agent.watching
 
+    @pytest.mark.parametrize("watching", (True, False))
     @pytest.mark.parametrize("close", (True, False))
-    def test_unwatch(self, agent, mocker, connection, close):
+    def test_unwatch(self, agent, mocker, connection, watching, close):
         """Test case for 'unwatch' method."""
         mocker.patch.object(agent, "watch_readable")
-        agent.watching.add(connection)
+        mocker.patch.object(agent, "warning")
+        if watching:
+            agent.watching.add(connection)
         agent.unwatch(connection, close=close)
         agent.watch_readable.assert_called_once_with(connection.fileno(),
                                                      False)
+        if not watching:
+            agent.warning.assert_called_once()
         if close:
             connection.close.assert_called_once_with()
 
-    def test_success(self, agent, mocker):
+    @pytest.mark.parametrize("stats", ({"foo": "bar"}, None))
+    def test_success(self, agent, mocker, stats):
         """Test case for 'success' method."""
         for method in ("report", "cleanup", "sleep"):
             mocker.patch.object(agent, method, autospec=True)
-        stats = {"foo": "bar"}
         mock_worker = mocker.patch("prefix_list_agent.agent.PrefixListWorker",
                                    autospec=True)
         mock_worker.return_value.data = stats
-        agent.worker = mock_worker(endpoint=agent.rptk_endpoint,
-                                   path=agent.source_dir,
-                                   eapi=agent.eapi_mgr,
-                                   update_delay=agent.update_delay)
+        agent._worker = mock_worker(rptk_endpoint=agent.rptk_endpoint,
+                                    source_dir=agent.source_dir,
+                                    update_delay=agent.update_delay,
+                                    eapi=agent.eapi_mgr)
         agent.success()
-        agent.report.assert_called_once_with(**stats)
+        if stats is not None:
+            agent.report.assert_called_once_with(**stats)
         agent.cleanup.assert_called_once_with(process=agent.worker)
         agent.sleep.assert_called_once_with()
 
-    @pytest.mark.parametrize("local_err", (None, StandardError("test_error")))
+    @pytest.mark.parametrize("local_err", (None, RuntimeError("test_error")))
+    @pytest.mark.parametrize("worker_err", (None, RuntimeError("worker_err")))
     @pytest.mark.parametrize("worker_process", (True, False))
     @pytest.mark.parametrize("restart", (True, False))
-    def test_failure(self, agent, mocker, local_err, worker_process, restart):
+    def test_failure(self, agent, mocker, local_err, worker_err,
+                     worker_process, restart):
         """Test case for 'failure' method."""
         for method in ("err", "restart", "cleanup", "sleep"):
             mocker.patch.object(agent, method, autospec=True)
         mock_worker = mocker.patch("prefix_list_agent.agent.PrefixListWorker",
                                    autospec=True)
-        worker_err = StandardError("worker_err")
         mock_worker.return_value.error = worker_err
-        agent.worker = mock_worker(endpoint=agent.rptk_endpoint,
-                                   path=agent.source_dir,
-                                   eapi=agent.eapi_mgr,
-                                   update_delay=agent.update_delay)
+        agent._worker = mock_worker(rptk_endpoint=agent.rptk_endpoint,
+                                    source_dir=agent.source_dir,
+                                    update_delay=agent.update_delay,
+                                    eapi=agent.eapi_mgr)
         if worker_process:
             process = agent.worker
         else:
             process = None
+            if local_err is None:
+                pytest.xfail()
         agent.failure(err=local_err, process=process, restart=restart)
         if local_err is None:
-            if process is None:
-                assert agent.err.call_count == 2
-            else:
-                agent.err.assert_called_once_with(worker_err)
+            agent.err.assert_called_once_with(worker_err)
         else:
             agent.err.assert_called_once_with(local_err)
         if restart:
@@ -236,7 +208,7 @@ def test_report(self, agent):
         agent.report(**stats)
         assert agent.agent_mgr.status_set.call_count == len(stats)
 
-    @pytest.mark.parametrize("unwatch_err", ((None,), StandardError()))
+    @pytest.mark.parametrize("unwatch_err", ((None,), RuntimeError()))
     @pytest.mark.parametrize("catch_sigterm", (True, False))
     def test_cleanup(self, agent, worker, mocker, unwatch_err, catch_sigterm):
         """Test case for 'cleanup' method."""
@@ -271,6 +243,13 @@ def run():
                                                  .format(worker.__class__.__name__))  # noqa: E501
         assert 5 <= agent.info.call_count <= 9
 
+    def test_cleanup_noop(self, agent, mocker):
+        """Test case for noop-'cleanup'."""
+        for method in ("err", "notice", "info"):
+            mocker.patch.object(agent, method, autospec=True)
+        agent.cleanup(None)
+        assert agent.info.call_count == 3
+
     def test_sleep(self, agent, mocker):
         """Test case for 'sleep' method."""
         mocker.patch("eossdk.now", autospec=True, return_value=0)
@@ -278,23 +257,31 @@ def test_sleep(self, agent, mocker):
         agent.sleep()
         agent.timeout_time_is.assert_called_once_with(agent.refresh_interval)
 
-    @pytest.mark.parametrize("side_effect", ((None,), StandardError))
-    def test_shutdown(self, agent, mocker, side_effect):
+    @pytest.mark.parametrize("side_effect", ((None,), RuntimeError))
+    @pytest.mark.parametrize("init_worker", (True, False))
+    def test_shutdown(self, agent, mocker, side_effect, init_worker):
         """Test case for 'shutdown' method."""
         mocker.patch.object(agent, "cleanup", autospec=True,
                             side_effect=side_effect)
+        if init_worker:
+            agent.init_worker()
         agent.shutdown()
-        agent.cleanup.assert_called_once_with(process=agent.worker)
+        if init_worker:
+            agent.cleanup.assert_called_once()
         agent.agent_mgr.agent_shutdown_complete_is.assert_called_once_with(True)  # noqa: E501
 
-    @pytest.mark.parametrize("side_effect", ((None,), StandardError))
-    def test_restart(self, agent, mocker, side_effect):
+    @pytest.mark.parametrize("side_effect", ((None,), RuntimeError))
+    @pytest.mark.parametrize("init_worker", (True, False))
+    def test_restart(self, agent, mocker, side_effect, init_worker):
         """Test case for 'restart' method."""
         mocker.patch.object(agent, "cleanup", autospec=True,
                             side_effect=side_effect)
         mocker.patch.object(agent, "start", autospec=True)
+        if init_worker:
+            agent.init_worker()
         agent.restart()
-        agent.cleanup.assert_called_once_with(process=agent.worker)
+        if init_worker:
+            agent.cleanup.assert_called_once()
         agent.start.assert_called_once_with()
 
     def test_on_initialized(self, agent, mocker):
@@ -303,12 +290,6 @@ def test_on_initialized(self, agent, mocker):
         agent.on_initialized()
         agent.start.assert_called_once_with()
 
-    def test_on_agent_option(self, agent, mocker):
-        """Test case for 'on_agent_option' method."""
-        mocker.patch.object(agent, "set", autospec=True)
-        agent.on_agent_option("foo", "bar")
-        agent.set.assert_called_once_with("foo", "bar")
-
     @pytest.mark.parametrize("enabled", (True, False))
     def test_on_agent_enabled(self, agent, mocker, enabled):
         """Test case for 'on_agent_enabled' method."""
@@ -334,10 +315,10 @@ def test_on_readable(self, agent, mocker, fd):
         mock_worker.return_value.p_err.fileno.return_value = 2
         for method in ("success", "failure", "warning"):
             mocker.patch.object(agent, method, autospec=True)
-        agent.worker = mock_worker(endpoint=agent.rptk_endpoint,
-                                   path=agent.source_dir,
-                                   eapi=agent.eapi_mgr,
-                                   update_delay=agent.update_delay)
+        agent._worker = mock_worker(rptk_endpoint=agent.rptk_endpoint,
+                                    source_dir=agent.source_dir,
+                                    update_delay=agent.update_delay,
+                                    eapi=agent.eapi_mgr)
         agent.on_readable(fd)
         if fd == 1:
             agent.success.assert_called_once_with()
diff --git a/tests/00_unit/test_base.py b/tests/00_unit/test_base.py
index f715418..25ce4c9 100644
--- a/tests/00_unit/test_base.py
+++ b/tests/00_unit/test_base.py
@@ -11,17 +11,15 @@
 # the License.
 """Tests for prefix_list_agent.agent module."""
 
-from __future__ import print_function
+from prefix_list_agent.base import PrefixListBase
 
 import pytest
 
-from prefix_list_agent.base import PrefixListBase
-
 
-class TestPrefixListAgent(object):
+class TestPrefixListBase(object):
     """Test cases for PrefixListBase object."""
 
-    def test_init(self, sdk, mocker):
+    def test_init(self, mocker):
         """Test case for PrefixListAgent initialisation."""
         mocker.patch("eossdk.Tracer", autospec=True)
         base = PrefixListBase()
diff --git a/tests/00_unit/test_exceptions.py b/tests/00_unit/test_exceptions.py
index 7d0d8c8..2c31656 100644
--- a/tests/00_unit/test_exceptions.py
+++ b/tests/00_unit/test_exceptions.py
@@ -11,14 +11,12 @@
 # the License.
 """Tests for prefix_list_agent.exceptions module."""
 
-from __future__ import print_function
-
 import signal
 
-import pytest
-
 from prefix_list_agent.exceptions import TermException, handle_sigterm
 
+import pytest
+
 
 class TestExceptions(object):
     """Test cases for exceptions module."""
diff --git a/tests/00_unit/test_cli.py b/tests/00_unit/test_init.py
similarity index 61%
rename from tests/00_unit/test_cli.py
rename to tests/00_unit/test_init.py
index f4a4fd5..901b9a7 100644
--- a/tests/00_unit/test_cli.py
+++ b/tests/00_unit/test_init.py
@@ -9,16 +9,14 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
-"""Tests for prefix_list_agent.cli module."""
-
-from __future__ import print_function
+"""Tests for prefix_list_agent module."""
 
 import sys
 
-import pytest
-
+from prefix_list_agent import start
 from prefix_list_agent.agent import PrefixListAgent
-from prefix_list_agent.cli import start
+
+import pytest
 
 
 class TestCli(object):
@@ -33,20 +31,14 @@ def module_patch(mocker, mod, cls):
                              autospec=True)
 
     @pytest.mark.parametrize(("arg", "ret"),
-                             ((None, None), (KeyboardInterrupt, 130),
-                              (StandardError, StandardError)))
-    @pytest.mark.parametrize("sysdb_mp_written", (True, False))
-    def test_start(self, sdk, mocker, arg, ret, sysdb_mp_written):
+                             ((None, 0), (KeyboardInterrupt, 130),
+                              (RuntimeError, RuntimeError)))
+    def test_start(self, sdk, mocker, arg, ret):
         """Test case for cli entrypoint."""
         self.module_patch(mocker, "eossdk", PrefixListAgent)
-        mocker.patch.object(PrefixListAgent, "set_sysdb_mp",
-                            return_value=sysdb_mp_written)
         sys.argv = arg
-        if sysdb_mp_written:
-            assert start(sdk) == 64
+        if isinstance(ret, int):
+            assert start(sdk) == ret
         else:
-            if not isinstance(ret, type):
-                assert start(sdk) == ret
-            else:
-                with pytest.raises(ret):
-                    start(sdk)
+            with pytest.raises(ret):
+                start(sdk)
diff --git a/tests/00_unit/test_worker.py b/tests/00_unit/test_worker.py
index 4866016..0d879c5 100644
--- a/tests/00_unit/test_worker.py
+++ b/tests/00_unit/test_worker.py
@@ -13,19 +13,37 @@
 
 from __future__ import print_function
 
+import datetime
+import io
 import json
 import signal
-import StringIO
 import time
-import urllib2
-import datetime
-
-import pytest
+import unittest.mock
+import urllib.error
+import urllib.request
 
-from mock import MagicMock
 from prefix_list_agent.exceptions import TermException
 from prefix_list_agent.worker import PrefixListWorker
 
+import pytest
+
+
+@pytest.fixture(scope="module", params=("success", "sigterm", "error"))
+def write_results_side_effect(request):
+    """Provide a callable for use as a mock side effect for 'write_results'."""
+    class SideEffect:
+        case = request.param
+
+        def __call__(self, *args, **kwargs):
+            if self.case == "success":
+                return ({"foo": "bar"}, set())
+            elif self.case == "sigterm":
+                raise TermException
+            else:
+                raise RuntimeError
+
+    return SideEffect()
+
 
 class TestPrefixListWorker(object):
     """Test cases for the PrefixListWorker object."""
@@ -34,50 +52,40 @@ def test_init(self, worker):
         """Test case for PrefixListWorker initialisation."""
         assert isinstance(worker, PrefixListWorker)
 
-    @pytest.mark.parametrize(("case", "side_effect"), (
-        ("success", (({"foo": "bar"}, set()),)),
-        ("sigterm", TermException),
-        ("error", StandardError),
-    ))
-    def test_run(self, worker, mocker, case, side_effect):
+    def test_run(self, worker, mocker, write_results_side_effect):
         """Test case for 'run' method."""
         for method in ("get_policies", "get_configured", "get_data",
                        "refresh_all", "refresh_prefix_list", "notice"):
             mocker.patch.object(worker, method, autospec=True)
         mocker.patch.object(worker, "write_results", autospec=True,
-                            side_effect=side_effect)
+                            side_effect=write_results_side_effect)
         worker.run()
-        if case == "success":
+        if write_results_side_effect.case == "success":
             assert worker.data == {"foo": "bar"}
-        elif case == "sigterm":
+        elif write_results_side_effect.case == "sigterm":
             worker.notice.assert_called_once_with("Got SIGTERM signal: exiting.")  # noqa: E501
-        elif case == "error":
-            assert type(worker.error) is StandardError
+        elif write_results_side_effect.case == "error":
+            assert type(worker.error) is RuntimeError
         else:
-            raise ValueError(case)
+            raise ValueError(write_results_side_effect.case)
 
-    @pytest.mark.parametrize(("case", "side_effect"), (
-        ("success", (({"foo": "bar"}, set()),)),
-        ("sigterm", TermException),
-        ("error", StandardError),
-    ))
-    def test_start(self, worker, mocker, case, side_effect):
+    def test_start(self, worker, mocker, write_results_side_effect):
         """Test case for 'start' method."""
         for method in ("get_policies", "get_configured", "get_data",
                        "refresh_all", "refresh_prefix_list", "notice"):
             mocker.patch.object(worker, method, autospec=True)
         mocker.patch.object(worker, "write_results", autospec=True,
-                            side_effect=side_effect)
+                            side_effect=write_results_side_effect)
         worker.start()
         time.sleep(1)
-        if case == "success":
+        if write_results_side_effect.case == "success":
             assert worker.data == {"foo": "bar"}
-        elif case == "sigterm":
+        elif write_results_side_effect.case == "sigterm":
             assert worker.exitcode == 127 + signal.SIGTERM
-        elif case == "error":
-            assert type(worker.error) is StandardError
+        elif write_results_side_effect.case == "error":
+            assert type(worker.error) is RuntimeError
         else:
-            raise ValueError(case)
+            raise ValueError(write_results_side_effect.case)
         if worker.is_alive():
             worker.terminate()
             worker.join()
@@ -96,7 +104,7 @@ def test_refresh_prefix_list(self, worker, prefix_list):
         test_afi = "ip"
         worker.refresh_prefix_list(test_afi, prefix_list)
         assert worker.eapi.run_show_cmd.call_count == 1
-        cmd = worker.eapi.run_show_cmd.call_args.args[0]
+        cmd = worker.eapi.run_show_cmd.call_args[0][0]
         assert test_afi in cmd
         if prefix_list is not None:
             assert cmd.endswith(prefix_list)
@@ -118,7 +126,7 @@ def wrapped(*args, **kwargs):
                 delta = t1 - t0
                 m.deltas.append(delta)
             return wrapped
-        m = MagicMock()
+        m = unittest.mock.MagicMock()
         m.side_effect = func_wrapper(time.sleep, m)
         mocker.patch.object(time, "sleep", m)
         worker.update_delay = update_delay
@@ -141,10 +149,10 @@ def wrapped(*args, **kwargs):
     def test_get_policies(self, worker, mocker):
         """Test case for 'get_policies' method."""
         resp_data = {"strict": "strict descr", "loose": "loose descr"}
-        resp_fp = StringIO.StringIO(json.dumps(resp_data))
-        return_value = urllib2.addinfourl(url="/testing", code=200,
-                                          headers=None, fp=resp_fp)
-        mocker.patch.object(urllib2, "urlopen", autospec=True,
+        resp_fp = io.StringIO(json.dumps(resp_data))
+        return_value = urllib.request.addinfourl(url="/testing", code=200,
+                                                 headers=None, fp=resp_fp)
+        mocker.patch.object(urllib.request, "urlopen", autospec=True,
                             return_value=return_value)
         policies = worker.get_policies()
         assert policies == resp_data
@@ -164,9 +172,9 @@ def test_get_data(self, worker, mocker):
                            "AS-BAR": {"ipv4": [], "ipv6": []}},
                 "loose":  {"AS-BAZ": {"ipv4": [], "ipv6": []}}}
         mocker.patch.object(worker, "get_data_bulk", autospec=True,
-                            side_effect=(data["strict"], StandardError))
+                            side_effect=(data["strict"], RuntimeError))
         mocker.patch.object(worker, "get_data_obj", autospec=True,
-                            side_effect=(data["loose"], StandardError))
+                            side_effect=(data["loose"], RuntimeError))
         result = worker.get_data(configured)
         assert result == data
         assert worker.get_data_bulk.call_count == 2
@@ -177,10 +185,10 @@ def test_get_data_bulk(self, worker, mocker):
         policy = "strict"
         objs = ["AS-FOO", "AS-BAR"]
         resp_data = {obj: {"ipv4": [], "ipv6": []} for obj in objs}
-        resp_fp = StringIO.StringIO(json.dumps(resp_data))
-        return_value = urllib2.addinfourl(url="/testing", code=200,
-                                          headers=None, fp=resp_fp)
-        mocker.patch.object(urllib2, "urlopen", autospec=True,
+        resp_fp = io.StringIO(json.dumps(resp_data))
+        return_value = urllib.request.addinfourl(url="/testing", code=200,
+                                                 headers=None, fp=resp_fp)
+        mocker.patch.object(urllib.request, "urlopen", autospec=True,
                             return_value=return_value)
         result = worker.get_data_bulk(policy, objs)
         assert result == resp_data
@@ -190,10 +198,10 @@ def test_get_data_obj(self, worker, mocker):
         policy = "strict"
         obj = "AS-FOO"
         resp_data = {obj: {"ipv4": [], "ipv6": []}}
-        resp_fp = StringIO.StringIO(json.dumps(resp_data))
-        return_value = urllib2.addinfourl(url="/testing", code=200,
-                                          headers=None, fp=resp_fp)
-        mocker.patch.object(urllib2, "urlopen", autospec=True,
+        resp_fp = io.StringIO(json.dumps(resp_data))
+        return_value = urllib.request.addinfourl(url="/testing", code=200,
+                                                 headers=None, fp=resp_fp)
+        mocker.patch.object(urllib.request, "urlopen", autospec=True,
                             return_value=return_value)
         result = worker.get_data_obj(policy, obj)
         assert result == resp_data
@@ -209,7 +217,7 @@ def test_get_data_obj(self, worker, mocker):
     ))
     def test_write_results(self, worker, mocker, configured, data):
         """Test case for 'write_results' method."""
-        mocker.patch("__builtin__.open", mocker.mock_open())
+        mocker.patch("builtins.open", mocker.mock_open())
         stats, written_objs = worker.write_results(configured, data)
         assert stats["succeeded"] == 2
         assert stats["failed"] == 2
@@ -219,13 +227,13 @@ def test_write_results(self, worker, mocker, configured, data):
         ([], None),
         ([{"prefix": "2001:db8:b00::/48", "exact": True},
          {"prefix": "2001:db8:f00::/48", "exact": True}], None),
-        pytest.param([], IOError, marks=pytest.mark.xfail(raises=IOError))
+        pytest.param([], IOError, marks=pytest.mark.xfail(raises=IOError)),
     ))
     def test_write_prefix_list(self, worker, mocker, entries, side_effect):
         """Test case for 'write_prefix_list' method."""
-        m = mocker.patch("__builtin__.open", mocker.mock_open())
+        m = mocker.patch("builtins.open", mocker.mock_open())
         m.side_effect = side_effect
-        path = "/tmp/foo"
+        path = "/tmp/foo"  # noqa: S108
         worker.write_prefix_list(path, entries, "ipv6")
         m.assert_called_once_with(path, "w")
         assert m().write.call_count == len(entries)
@@ -234,7 +242,11 @@ def test_write_prefix_list(self, worker, mocker, entries, side_effect):
         ({"prefix": "10.0.0.0/8", "exact": True}, "seq 1 permit 10.0.0.0/8\n"),
         ({"prefix": "2001:db8::/32", "exact": False,
           "greater-equal": 48, "less-equal": 64},
-         "seq 1 permit 2001:db8::/32 ge 48 le 64\n")
+         "seq 1 permit 2001:db8::/32 ge 48 le 64\n"),
+        ({"prefix": "192.168.0.0/16", "exact": False, "greater-equal": 20},
+         "seq 1 permit 192.168.0.0/16 ge 20\n"),
+        ({"prefix": "2001:db8:f00::/48", "exact": False, "less-equal": 64},
+         "seq 1 permit 2001:db8:f00::/48 le 64\n"),
     ))
     def test_prefix_list_line(self, worker, entry, expect):
         """Test case for 'prefix_list_line' method."""
@@ -249,7 +261,7 @@ def test_prefix_list_line(self, worker, entry, expect):
         pytest.param("fail", False,
                      marks=pytest.mark.xfail(raises=RuntimeError)),
         pytest.param("error", False,
-                     marks=pytest.mark.xfail(raises=StandardError))
+                     marks=pytest.mark.xfail(raises=RuntimeError)),
     ))
     def test_eapi_request(self, worker, cmd, allow_empty):
         """Test case for 'eapi_request' method."""
@@ -260,28 +272,30 @@ def test_eapi_request(self, worker, cmd, allow_empty):
             assert result["foo"] == "bar"
 
     @pytest.mark.parametrize("side_effect", (
-        (urllib2.addinfourl(url="/testing", code=200, headers=None,
-                            fp=StringIO.StringIO('{"foo":"bar"}')),),
-        pytest.param(urllib2.URLError(reason="Testing"),
-                     marks=pytest.mark.xfail(raises=urllib2.URLError)),
-        pytest.param(urllib2.HTTPError(url="/testing", code=500,
-                                       msg="Testing", hdrs=None, fp=None),
-                     marks=pytest.mark.xfail(raises=urllib2.HTTPError))
+        (urllib.request.addinfourl(url="/testing", code=200, headers=None,
+                                   fp=io.StringIO('{"foo":"bar"}')),),
+        pytest.param(urllib.error.URLError(reason="Testing"),
+                     marks=pytest.mark.xfail(raises=urllib.error.URLError),
+                     id="URLError"),
+        pytest.param(urllib.error.HTTPError(url="/testing", code=500,
+                                            msg="Testing", hdrs=None, fp=None),
+                     marks=pytest.mark.xfail(raises=urllib.error.HTTPError),
+                     id="HTTPError"),
     ))
     def test_rptk_request(self, mocker, worker, side_effect):
         """Test case for 'rptk_request' method."""
-        mocker.patch.object(urllib2, "urlopen", autospec=True,
+        mocker.patch.object(urllib.request, "urlopen", autospec=True,
                             side_effect=side_effect)
         result = worker.rptk_request("/testing")
         assert result["foo"] == "bar"
 
     @pytest.mark.parametrize("obj", (
         '{"foo":"bar"}',
-        StringIO.StringIO('{"foo":"bar"}'),
+        io.StringIO('{"foo":"bar"}'),
         pytest.param("foo",
                      marks=pytest.mark.xfail(raises=ValueError, strict=True)),
-        pytest.param(StringIO.StringIO("foo"),
-                     marks=pytest.mark.xfail(raises=ValueError, strict=True))
+        pytest.param(io.StringIO("foo"),
+                     marks=pytest.mark.xfail(raises=ValueError, strict=True)),
     ))
     def test_json_load(self, worker, obj):
         """Test case for 'json_load' method."""
diff --git a/tests/01_integration/conftest.py b/tests/01_integration/conftest.py
index 687d9bd..81dd005 100644
--- a/tests/01_integration/conftest.py
+++ b/tests/01_integration/conftest.py
@@ -11,8 +11,6 @@
 # the License.
 """Fixtures for PrefixListAgent integration tests."""
 
-from __future__ import print_function
-
 import time
 
 import pytest
@@ -23,7 +21,8 @@
 @pytest.fixture(scope="module")
 def node():
     """Provide a pyeapi node connected to the local unix socket."""
-    for retry in range(60):
+    err = None
+    for _ in range(60):
         try:
             import pyeapi
             conn = pyeapi.connect(transport="socket")
@@ -31,10 +30,11 @@ def node():
             assert node.version
             break
         except Exception as e:
+            err = e
             time.sleep(3)
             continue
     else:
-        raise e
+        raise err
     return node
 
 
@@ -42,13 +42,12 @@ def node():
 def configure_daemon(node):
     """Configure the agent as an EOS ProcMgr daemon."""
     agent_config = [
-        "trace PrefixListAgent-PrefixListAgent setting PrefixList*/*",
-        "daemon PrefixListAgent",
-        "exec /root/bin/PrefixListAgent",
-        "option rptk_endpoint value http://127.0.0.1:8000/",
-        "option refresh_interval value 10",
-        "option update_delay value 1",
-        "no shutdown"
+        "trace PrefixListAgent setting PrefixList*/*",
+        "prefix-list-agent",
+        "rptk-endpoint http://127.0.0.1:8000/",
+        "refresh-interval 10",
+        "update-delay 1",
+        "no disabled",
     ]
     node.config(agent_config)
     time.sleep(3)
diff --git a/tests/01_integration/rptk_stub.py b/tests/01_integration/rptk_stub.py
index 99fb2e4..e5282fb 100644
--- a/tests/01_integration/rptk_stub.py
+++ b/tests/01_integration/rptk_stub.py
@@ -11,8 +11,6 @@
 # the License.
 """Fixtures for PrefixListAgent integration tests."""
 
-from __future__ import print_function
-
 import json
 import multiprocessing
 import sys
@@ -31,20 +29,20 @@ class RptkStub(gunicorn.app.base.BaseApplication):
     objects = {
         "AS-FOO": {
             "ipv4": [
-                {"prefix": "192.0.2.0/24", "exact": True}
+                {"prefix": "192.0.2.0/24", "exact": True},
             ],
             "ipv6": [
                 {"prefix": "2001:db8::/32", "exact": False,
-                 "greater-equal": 40, "less-equal": 48}
-            ]
+                 "greater-equal": 40, "less-equal": 48},
+            ],
         },
         "AS-BAR": {
             "ipv4": [
                 {"prefix": "198.51.100.0/24", "exact": True},
-                {"prefix": "203.0.113.0/24", "exact": True}
+                {"prefix": "203.0.113.0/24", "exact": True},
             ],
-            "ipv6": []
-        }
+            "ipv6": [],
+        },
     }
 
     def __init__(self, **kwargs):
diff --git a/tests/01_integration/test_agent_daemon.py b/tests/01_integration/test_agent_daemon.py
index d65f97c..326759b 100644
--- a/tests/01_integration/test_agent_daemon.py
+++ b/tests/01_integration/test_agent_daemon.py
@@ -11,8 +11,6 @@
 # the License.
 """Integration tests for PrefixListAgent."""
 
-from __future__ import print_function
-
 import re
 import time
 
@@ -22,41 +20,41 @@
 NAME = "PrefixListAgent"
 
 
-@pytest.mark.usefixtures("rptk_stub", "configure_daemon")
+@pytest.mark.usefixtures("rptk_stub", "configure_daemon")  # noqa: R701
 class TestPrefixListAgentDaemon(object):
     """Integration test cases for PrefixListAgent."""
 
     def test_running(self, node):
         """Test that the agent is running."""
-        resp = node.enable("show daemon {}".format(NAME))
+        resp = node.enable(f"show daemon {NAME}")
         status = resp[0]["result"]["daemons"]
         assert NAME in status
         assert status[NAME]["isSdkAgent"]
         assert status[NAME]["enabled"]
         assert status[NAME]["running"]
 
-    def test_prefix_lists(self, node):
+    def test_prefix_lists(self, node):  # noqa: R701
         """Test prefix-list creation."""
         objects = {
             "AS-FOO": {
                 "ipv4": [
-                    {"prefix": "192.0.2.0/24", "exact": True}
+                    {"prefix": "192.0.2.0/24", "exact": True},
                 ],
                 "ipv6": [
                     {"prefix": "2001:db8::/32", "exact": False,
-                     "greater-equal": 40, "less-equal": 48}
-                ]
+                     "greater-equal": 40, "less-equal": 48},
+                ],
             },
             "AS-BAR": {
                 "ipv4": [
                     {"prefix": "198.51.100.0/24", "exact": True},
-                    {"prefix": "203.0.113.0/24", "exact": True}
+                    {"prefix": "203.0.113.0/24", "exact": True},
                 ],
-                "ipv6": []
-            }
+                "ipv6": [],
+            },
         }
         time.sleep(15)
-        responses = node.enable(["show {} prefix-list".format(config_af)
+        responses = node.enable([f"show {config_af} prefix-list"
                                  for config_af in ("ip", "ipv6")])
         entry_pattern = r"^\s+seq \d+ permit (?P<p>[\w.:/]+)( ge (?P<ge>\d+))?( le (?P<le>\d+))?$"  # noqa: E501
         entry_regexp = re.compile(entry_pattern)
@@ -64,12 +62,12 @@ def test_prefix_lists(self, node):
             for resp in responses:
                 assert obj in resp["result"]["ipPrefixLists"]
             for config_af, afi in (("ip", "ipv4"), ("ipv6", "ipv6")):
-                resp = node.enable("show {} prefix-list {} detail"
-                                   .format(config_af, obj),
+                resp = node.enable(f"show {config_af} prefix-list {obj} detail",  # noqa: E501
                                    encoding="text")
                 output = resp[0]["result"]["output"]
                 entries = [m.groupdict() for m in
-                           [entry_regexp.match(l) for l in output.splitlines()] #noqa E741
+                           [entry_regexp.match(line)
+                            for line in output.splitlines()]
                            if m is not None]
                 assert len(entries) == len(data[afi])
                 for item in data[afi]:
@@ -79,9 +77,18 @@ def test_prefix_lists(self, node):
                                                       for e in entries]
                         assert item["greater-equal"] in [int(e["ge"])
                                                          for e in entries]
-        status_resp = node.enable("show daemon {}".format(NAME))
+        status_resp = node.enable(f"show daemon {NAME}")
         status = status_resp[0]["result"]["daemons"]
         assert NAME in status
         assert status[NAME]["data"]["result"] == "ok"
         assert int(status[NAME]["data"]["failed"]) == 0
         assert int(status[NAME]["data"]["succeeded"]) == 4
+
+    def test_show_cli(self, node):
+        """Test 'show prefix-list-agent' command."""
+        response = node.enable("show prefix-list-agent")
+        data = response[0]["result"]
+        assert data["enabled"] is True
+        assert data["status"]["result"] == "ok"
+        assert int(data["status"]["succeeded"]) == 4
+        assert int(data["status"]["failed"]) == 0