Added even more docstrings everywhere
continuous-integration/drone the build failed
Details
continuous-integration/drone the build failed
Details
parent
ecfa6fe7b7
commit
83ea06f016
6
.flake8
6
.flake8
|
@ -1,4 +1,8 @@
|
||||||
# vim: ft=cfg
|
# vim: ft=cfg
|
||||||
[flake8]
|
[flake8]
|
||||||
inline-quotes = double
|
|
||||||
max-complexity = 7
|
max-complexity = 7
|
||||||
|
docstring-convention=google
|
||||||
|
|
||||||
|
# Required to be compatible with black
|
||||||
|
extend-ignore = E203,W503
|
||||||
|
inline-quotes = double
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
"""Module containing all app code."""
|
|
@ -1,13 +1,13 @@
|
||||||
"""The main entrypoint of the program."""
|
"""The main entrypoint of the program."""
|
||||||
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
from parser import read_specs_file
|
from parser import read_specs_file
|
||||||
|
|
||||||
|
|
||||||
# This just displays the error type and message, not the stack trace
|
|
||||||
def except_hook(ext_type, value, traceback):
|
def except_hook(ext_type, value, traceback):
|
||||||
"""
|
"""Make errors not show the stracktrace to stdout.
|
||||||
Make errors not show the stracktrace to stdout.
|
|
||||||
|
|
||||||
Todo:
|
Todo:
|
||||||
* Replace this with proper error handling
|
* Replace this with proper error handling
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
|
"""Module handling IFTTT notifications."""
|
||||||
|
|
||||||
|
|
||||||
from typing import List
|
from typing import List
|
||||||
import os
|
import os
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
class Notifier:
|
class Notifier:
|
||||||
|
"""A notifier object that can send IFTTT notifications."""
|
||||||
|
|
||||||
# (positive, negative)
|
# (positive, negative)
|
||||||
_EVENTS = {
|
_EVENTS = {
|
||||||
"backup": (
|
"backup": (
|
||||||
|
@ -15,24 +20,40 @@ class Notifier:
|
||||||
"Couldn't restore {name}.",
|
"Couldn't restore {name}.",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
"""The message content for a given event."""
|
||||||
|
|
||||||
# Placeholder
|
# Placeholder
|
||||||
def __init__(
|
def __init__(
|
||||||
self, title: str, events: List[str], endpoint: str, api_key: str = None
|
self, title: str, events: List[str], endpoint: str, api_key: str = None
|
||||||
):
|
):
|
||||||
|
"""Initialize a new Notifier object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: the notification title to use
|
||||||
|
events: the event types that should trigger a notification (should
|
||||||
|
be one of the keys in _EVENTS).
|
||||||
|
endpoint: IFTTT endpoint name
|
||||||
|
api_key: your IFTTT API key. If not provided, it will be read from
|
||||||
|
the IFTTT_API_KEY environment variable.
|
||||||
|
|
||||||
|
Todo:
|
||||||
|
* Read the API key on init
|
||||||
|
"""
|
||||||
self.title = title
|
self.title = title
|
||||||
self.events = events
|
self.events = events
|
||||||
self.endpoint = endpoint
|
self.endpoint = endpoint
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
|
|
||||||
def notify(self, category: str, name: str, status_code: int):
|
def notify(self, category: str, name: str, status_code: int):
|
||||||
"""
|
"""Send an IFTTT notification.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
category: type of notify (e.g. backup or restore)
|
category: type of notify (should be one of the keys in _EVENTS).
|
||||||
|
Only if the category was passed during initialization will the
|
||||||
|
notification be sent.
|
||||||
name: name of the spec
|
name: name of the spec
|
||||||
status_code: exit code of the command
|
status_code: exit code of the command
|
||||||
"""
|
"""
|
||||||
|
|
||||||
event = "{}_{}".format(
|
event = "{}_{}".format(
|
||||||
category, "success" if status_code == 0 else "failure"
|
category, "success" if status_code == 0 else "failure"
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
"""Handles parsing a config file from disk."""
|
"""Handles parsing a config file from disk."""
|
||||||
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Union
|
from typing import List, Union
|
||||||
|
@ -7,8 +9,7 @@ import skeleton
|
||||||
|
|
||||||
|
|
||||||
def read_specs_file(path: Union[str, Path]) -> List[Spec]:
|
def read_specs_file(path: Union[str, Path]) -> List[Spec]:
|
||||||
"""
|
"""Read a config file and merge it with the skeleton.
|
||||||
Read a config file and merge it with the skeleton.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: path to the yaml config file
|
path: path to the yaml config file
|
||||||
|
|
|
@ -6,8 +6,7 @@ class InvalidKeyError(Exception):
|
||||||
"""Thrown when a config file contains an invalid key."""
|
"""Thrown when a config file contains an invalid key."""
|
||||||
|
|
||||||
def __init__(self, key: str):
|
def __init__(self, key: str):
|
||||||
"""
|
"""Create a new InvalidKeyError object with the given key.
|
||||||
Create a new InvalidKeyError object with the given key.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: the invalid key
|
key: the invalid key
|
||||||
|
@ -21,8 +20,7 @@ class MissingKeyError(Exception):
|
||||||
"""Thrown when a required key is missing from a config."""
|
"""Thrown when a required key is missing from a config."""
|
||||||
|
|
||||||
def __init__(self, key: str):
|
def __init__(self, key: str):
|
||||||
"""
|
"""Create a new MissingKeyError object with the given key.
|
||||||
Create a new MissingKeyError object with the given key.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: the invalid key
|
key: the invalid key
|
||||||
|
@ -33,8 +31,7 @@ class MissingKeyError(Exception):
|
||||||
|
|
||||||
|
|
||||||
def merge(*dicts: [Dict]) -> Dict:
|
def merge(*dicts: [Dict]) -> Dict:
|
||||||
"""
|
"""Merge multiple dicts into one.
|
||||||
Merge multiple dicts into one.
|
|
||||||
|
|
||||||
It reads the dicts from left to right, always preferring the "right"
|
It reads the dicts from left to right, always preferring the "right"
|
||||||
dictionary's values. Therefore, the dictionaries should be sorted from
|
dictionary's values. Therefore, the dictionaries should be sorted from
|
||||||
|
@ -78,8 +75,7 @@ def merge(*dicts: [Dict]) -> Dict:
|
||||||
|
|
||||||
|
|
||||||
def merge_with_skeleton(data: Dict, skel: Dict) -> Dict:
|
def merge_with_skeleton(data: Dict, skel: Dict) -> Dict:
|
||||||
"""
|
"""Merge a dictionary with a skeleton containing default values.
|
||||||
Merge a dictionary with a skeleton containing default values.
|
|
||||||
|
|
||||||
The skeleton not only defines what the default values are, but also
|
The skeleton not only defines what the default values are, but also
|
||||||
enforces a certain shape. This allows us to define a config file using a
|
enforces a certain shape. This allows us to define a config file using a
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
"""Module defining a Container-based spec."""
|
"""Module defining a container-based spec."""
|
||||||
from .spec import Spec
|
from .spec import Spec
|
||||||
from typing import Union
|
from typing import Union
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
@ -46,6 +46,7 @@ class ContainerSpec(Spec):
|
||||||
self.command = command
|
self.command = command
|
||||||
|
|
||||||
def backup(self):
|
def backup(self):
|
||||||
|
"""Create a new backup."""
|
||||||
# Remove excess backups
|
# Remove excess backups
|
||||||
self.remove_backups()
|
self.remove_backups()
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
"""Module defining a directory-based spec."""
|
||||||
|
|
||||||
|
|
||||||
from .spec import Spec
|
from .spec import Spec
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
@ -23,6 +26,18 @@ class DirectorySpec(Spec):
|
||||||
extension: str,
|
extension: str,
|
||||||
notify=None,
|
notify=None,
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Initialize a new DirectorySpec object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: name of the spec
|
||||||
|
source: what directory to back up
|
||||||
|
destination: where to store the backup
|
||||||
|
limit: how many backups to keep
|
||||||
|
command: what command to use to create the backup
|
||||||
|
extension: extension of the backup files
|
||||||
|
notify: a Notifier object that handles sending notifications
|
||||||
|
"""
|
||||||
super().__init__(name, destination, limit, extension, notify)
|
super().__init__(name, destination, limit, extension, notify)
|
||||||
|
|
||||||
self.source = source if type(source) == Path else Path(source)
|
self.source = source if type(source) == Path else Path(source)
|
||||||
|
@ -36,6 +51,7 @@ class DirectorySpec(Spec):
|
||||||
self.command = command
|
self.command = command
|
||||||
|
|
||||||
def backup(self):
|
def backup(self):
|
||||||
|
"""Create a new backup."""
|
||||||
# Remove excess backups
|
# Remove excess backups
|
||||||
self.remove_backups()
|
self.remove_backups()
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
"""This module contains the base Spec class."""
|
||||||
|
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union, Dict
|
from typing import Union, Dict
|
||||||
import skeleton
|
import skeleton
|
||||||
|
@ -7,9 +10,7 @@ import inspect
|
||||||
|
|
||||||
|
|
||||||
class Spec:
|
class Spec:
|
||||||
"""
|
"""Base class for all other spec types."""
|
||||||
Base class for all other spec types.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SKEL = {
|
_SKEL = {
|
||||||
"destination": None,
|
"destination": None,
|
||||||
|
@ -31,23 +32,25 @@ class Spec:
|
||||||
extension: str,
|
extension: str,
|
||||||
notify=None,
|
notify=None,
|
||||||
):
|
):
|
||||||
"""
|
"""Initialize a new Spec object.
|
||||||
|
|
||||||
|
This initializer usually gets called by a subclass's init instead of
|
||||||
|
directly.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: name of the spec
|
name: name of the spec
|
||||||
destination: directory where the backups shall reside
|
destination: directory where the backups shall reside
|
||||||
limit: max amount of backups
|
limit: max amount of backups
|
||||||
notifier: notifier object
|
notifier: notifier object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.name = name
|
self.name = name
|
||||||
self.destination = (
|
self.destination = Path(destination)
|
||||||
destination if type(destination) == Path else Path(destination)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create destination if non-existent
|
# Create destination if non-existent
|
||||||
try:
|
try:
|
||||||
self.destination.mkdir(parents=True, exist_ok=True)
|
self.destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# TODO just make this some checks in advance
|
||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
raise NotADirectoryError(
|
raise NotADirectoryError(
|
||||||
"{} already exists, but isn't a directory.".format(
|
"{} already exists, but isn't a directory.".format(
|
||||||
|
@ -61,8 +64,7 @@ class Spec:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def skeleton(cls: "Spec") -> Dict:
|
def skeleton(cls: "Spec") -> Dict:
|
||||||
"""
|
"""Return the skeleton for the given class.
|
||||||
Return the skeleton for the given class.
|
|
||||||
|
|
||||||
It works by inspecting the inheritance tree and merging the skeleton
|
It works by inspecting the inheritance tree and merging the skeleton
|
||||||
for each of the parents.
|
for each of the parents.
|
||||||
|
@ -78,10 +80,7 @@ class Spec:
|
||||||
)
|
)
|
||||||
|
|
||||||
def remove_backups(self):
|
def remove_backups(self):
|
||||||
"""
|
"""Remove all backups exceeding the limit."""
|
||||||
Remove all backups exceeding the limit
|
|
||||||
"""
|
|
||||||
|
|
||||||
files = sorted(
|
files = sorted(
|
||||||
self.destination.glob("*." + self.extension),
|
self.destination.glob("*." + self.extension),
|
||||||
key=os.path.getmtime,
|
key=os.path.getmtime,
|
||||||
|
@ -93,13 +92,22 @@ class Spec:
|
||||||
path.unlink()
|
path.unlink()
|
||||||
|
|
||||||
def backup(self):
|
def backup(self):
|
||||||
|
"""Create a new backup.
|
||||||
|
|
||||||
|
This function should be implemented by the subclasses.
|
||||||
|
"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def restore(self):
|
def restore(self):
|
||||||
|
"""Restore a given backup (NOT IMPLEMENTED).
|
||||||
|
|
||||||
|
This function should be implemented by the subclasses.
|
||||||
|
"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, name, obj: Dict, defaults: Dict) -> "Spec":
|
def from_dict(cls, name, obj: Dict, defaults: Dict) -> "Spec":
|
||||||
|
"""Create the class given a dictionary (e.g. from a config)."""
|
||||||
# Combine defaults with skeleton, creating new skeleton
|
# Combine defaults with skeleton, creating new skeleton
|
||||||
skel = skeleton.merge(cls.skeleton(), defaults)
|
skel = skeleton.merge(cls.skeleton(), defaults)
|
||||||
|
|
||||||
|
@ -109,4 +117,7 @@ class Spec:
|
||||||
return cls(name, **obj)
|
return cls(name, **obj)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
|
"""Export the class as a dictionary.
|
||||||
|
|
||||||
|
This function should be imnplemented by the subclasses."""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
|
@ -6,9 +6,7 @@ import subprocess
|
||||||
|
|
||||||
|
|
||||||
class VolumeSpec(Spec):
|
class VolumeSpec(Spec):
|
||||||
"""
|
"""A spec for backing up a Docker volume."""
|
||||||
A spec for backing up a Docker volume.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SKEL = {
|
_SKEL = {
|
||||||
"volume": None,
|
"volume": None,
|
||||||
|
|
Loading…
Reference in New Issue