diff --git a/.flake8 b/.flake8 index bb31e22..1d1c7f5 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,8 @@ # vim: ft=cfg [flake8] -inline-quotes = double max-complexity = 7 +docstring-convention=google + +# Required to be compatible with black +extend-ignore = E203,W503 +inline-quotes = double diff --git a/app/__init__.py b/app/__init__.py index e69de29..5927fab 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Module containing all app code.""" diff --git a/app/__main__.py b/app/__main__.py index fec66e1..aaf2942 100644 --- a/app/__main__.py +++ b/app/__main__.py @@ -1,13 +1,13 @@ """The main entrypoint of the program.""" + + import argparse import sys 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): - """ - Make errors not show the stracktrace to stdout. + """Make errors not show the stracktrace to stdout. Todo: * Replace this with proper error handling @@ -59,9 +59,9 @@ if __name__ == "__main__": print(json.dumps([spec.to_dict() for spec in specs], indent=4)) elif not specs: - # TODO replace this with error handling system - print("No specs, exiting.") - sys.exit(0) + # TODO replace this with error handling system + print("No specs, exiting.") + sys.exit(0) for spec in specs: spec.backup() diff --git a/app/notifier.py b/app/notifier.py index 1848775..99b3ffa 100644 --- a/app/notifier.py +++ b/app/notifier.py @@ -1,9 +1,14 @@ +"""Module handling IFTTT notifications.""" + + from typing import List import os import requests class Notifier: + """A notifier object that can send IFTTT notifications.""" + # (positive, negative) _EVENTS = { "backup": ( @@ -15,24 +20,40 @@ class Notifier: "Couldn't restore {name}.", ), } + """The message content for a given event.""" # Placeholder def __init__( 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.events = events self.endpoint = endpoint self.api_key = api_key def notify(self, category: str, name: str, status_code: int): - """ + """Send an IFTTT notification. + 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 status_code: exit code of the command """ - event = "{}_{}".format( category, "success" if status_code == 0 else "failure" ) diff --git a/app/parser.py b/app/parser.py index dcd9fb1..ce5fdf5 100644 --- a/app/parser.py +++ b/app/parser.py @@ -1,4 +1,6 @@ """Handles parsing a config file from disk.""" + + import yaml from pathlib import Path from typing import List, Union @@ -7,8 +9,7 @@ import skeleton 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: path: path to the yaml config file diff --git a/app/skeleton.py b/app/skeleton.py index 5391e1b..b1aabd3 100644 --- a/app/skeleton.py +++ b/app/skeleton.py @@ -6,8 +6,7 @@ class InvalidKeyError(Exception): """Thrown when a config file contains an invalid key.""" def __init__(self, key: str): - """ - Create a new InvalidKeyError object with the given key. + """Create a new InvalidKeyError object with the given key. Args: key: the invalid key @@ -21,8 +20,7 @@ class MissingKeyError(Exception): """Thrown when a required key is missing from a config.""" def __init__(self, key: str): - """ - Create a new MissingKeyError object with the given key. + """Create a new MissingKeyError object with the given key. Args: key: the invalid key @@ -33,8 +31,7 @@ class MissingKeyError(Exception): 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" 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: - """ - 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 enforces a certain shape. This allows us to define a config file using a diff --git a/app/specs/container.py b/app/specs/container.py index 76579f0..7bc2a95 100644 --- a/app/specs/container.py +++ b/app/specs/container.py @@ -1,4 +1,4 @@ -"""Module defining a Container-based spec.""" +"""Module defining a container-based spec.""" from .spec import Spec from typing import Union from pathlib import Path @@ -46,6 +46,7 @@ class ContainerSpec(Spec): self.command = command def backup(self): + """Create a new backup.""" # Remove excess backups self.remove_backups() diff --git a/app/specs/directory.py b/app/specs/directory.py index 4fa0ffe..17048a7 100644 --- a/app/specs/directory.py +++ b/app/specs/directory.py @@ -1,3 +1,6 @@ +"""Module defining a directory-based spec.""" + + from .spec import Spec from pathlib import Path from typing import Union @@ -23,6 +26,18 @@ class DirectorySpec(Spec): extension: str, 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) self.source = source if type(source) == Path else Path(source) @@ -36,6 +51,7 @@ class DirectorySpec(Spec): self.command = command def backup(self): + """Create a new backup.""" # Remove excess backups self.remove_backups() diff --git a/app/specs/spec.py b/app/specs/spec.py index 2669ccb..d5e0b26 100644 --- a/app/specs/spec.py +++ b/app/specs/spec.py @@ -1,3 +1,6 @@ +"""This module contains the base Spec class.""" + + from pathlib import Path from typing import Union, Dict import skeleton @@ -7,9 +10,7 @@ import inspect class Spec: - """ - Base class for all other spec types. - """ + """Base class for all other spec types.""" _SKEL = { "destination": None, @@ -31,23 +32,25 @@ class Spec: extension: str, notify=None, ): - """ + """Initialize a new Spec object. + + This initializer usually gets called by a subclass's init instead of + directly. + Args: name: name of the spec destination: directory where the backups shall reside limit: max amount of backups notifier: notifier object """ - self.name = name - self.destination = ( - destination if type(destination) == Path else Path(destination) - ) + self.destination = Path(destination) # Create destination if non-existent try: self.destination.mkdir(parents=True, exist_ok=True) + # TODO just make this some checks in advance except FileExistsError: raise NotADirectoryError( "{} already exists, but isn't a directory.".format( @@ -61,8 +64,7 @@ class Spec: @classmethod 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 for each of the parents. @@ -78,10 +80,7 @@ class Spec: ) def remove_backups(self): - """ - Remove all backups exceeding the limit - """ - + """Remove all backups exceeding the limit.""" files = sorted( self.destination.glob("*." + self.extension), key=os.path.getmtime, @@ -93,13 +92,22 @@ class Spec: path.unlink() def backup(self): + """Create a new backup. + + This function should be implemented by the subclasses. + """ raise NotImplementedError() def restore(self): + """Restore a given backup (NOT IMPLEMENTED). + + This function should be implemented by the subclasses. + """ raise NotImplementedError() @classmethod 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 skel = skeleton.merge(cls.skeleton(), defaults) @@ -109,4 +117,7 @@ class Spec: return cls(name, **obj) def to_dict(self): + """Export the class as a dictionary. + + This function should be imnplemented by the subclasses.""" raise NotImplementedError() diff --git a/app/specs/volume.py b/app/specs/volume.py index cd9d937..f6f0980 100644 --- a/app/specs/volume.py +++ b/app/specs/volume.py @@ -6,9 +6,7 @@ import subprocess class VolumeSpec(Spec): - """ - A spec for backing up a Docker volume. - """ + """A spec for backing up a Docker volume.""" _SKEL = { "volume": None,