diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..312f9ef --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,9 @@ +Dropbox_Upload 1.1.0 (2018-11-14) +================================= + +Features +-------- + +- Add a new "filename" setting to customise the filenames saved in dropbox (#15) +- The dropbox_dir is now validated to ensure it isn't an empty string. (#16) +- Add a warning if snapshots are not "protected", adding a password is always a good idea (#31) diff --git a/README.md b/README.md index 1575789..6deb97f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Add this repository URL in Hass.io: ## Configuration +### Dropbox + You will need to create a [Dropbox app](https://www.dropbox.com/developers/apps) 1. Choose `Dropbox API` @@ -18,15 +20,30 @@ You will need to create a [Dropbox app](https://www.dropbox.com/developers/apps) 3. Give it a unique name, this can be anything 4. Click `Generate` under "Generated access token" and copy the token. -After that, the config is simple. You just need to specify the access token and -a directory name. + +### All Configuration Options + + +| Options | Default | Description | +|---------------------- |--------------- |---------------------------------------------------------------------------------------------------------------------------------- | +| access_token | | Dropbox API Access Token. Required. | +| dropbox_dir | "/snapshots" | The directory name in dropbox to upload snapshots. Must start with a forward slash. | +| keep | 10 | The number of snapshots to keep. Once the limit is reached, older snapshots will be removed from Hass.io and Dropbox. | +| mins_between_backups | 60 | How often, in minutes, should the addon check for new backups. | +| filename | snapshot_slug | What filename strategy should be used in Dropbox? Can be either "snapshot_name" or "snapshot_slug". | +| debug | false | A flag to enable/disable verbose logging. If you are having issues, change this to True and include the output in bug reports. | + + +### Full Configuration Example ``` { - "access_token": "ACCESS TOKEN", - "dropbox_dir": "/hass-snapshots/" + "access_token": "", + "dropbox_dir": "/snapshots", "keep": 10, - "mins_between_backups": 30 + "mins_between_backups": 60, + "filename": "snapshot_name", + "debug": false } ``` diff --git a/dropbox-upload/config.json b/dropbox-upload/config.json index be29821..27a8afb 100644 --- a/dropbox-upload/config.json +++ b/dropbox-upload/config.json @@ -1,6 +1,6 @@ { "name": "Dropbox Upload", - "version": "1.0.13", + "version": "1.1.0", "slug": "dropbox_upload", "description": "Upload snapshots to Dropbox!", "startup": "application", @@ -17,7 +17,8 @@ "access_token": "", "dropbox_dir": "/snapshots", "keep": 10, - "mins_between_backups": 10, + "mins_between_backups": 60, + "filename": "snapshot_name", "debug": false }, "schema": { @@ -25,6 +26,7 @@ "dropbox_dir": "str", "keep": "int?", "mins_between_backups": "int?", + "filename": "str", "debug": "bool?" } } diff --git a/dropbox-upload/dropbox_upload/__main__.py b/dropbox-upload/dropbox_upload/__main__.py index 79f95aa..b0d2125 100644 --- a/dropbox-upload/dropbox_upload/__main__.py +++ b/dropbox-upload/dropbox_upload/__main__.py @@ -18,6 +18,8 @@ def main(config_file, sleeper=time.sleep, DropboxAPI=dropbox.Dropbox): LOG.debug(copy) config.setup_logging(cfg) + config.validate(cfg) + try: dbx = DropboxAPI(cfg["access_token"]) dbx.users_get_current_account() diff --git a/dropbox-upload/dropbox_upload/backup.py b/dropbox-upload/dropbox_upload/backup.py index 51ab1a4..520ab3e 100644 --- a/dropbox-upload/dropbox_upload/backup.py +++ b/dropbox-upload/dropbox_upload/backup.py @@ -16,7 +16,16 @@ def local_path(snapshot): def dropbox_path(config, snapshot): dropbox_dir = pathlib.Path(config["dropbox_dir"]) - name = snapshot["slug"] + + if "filename" in config and config["filename"] == "snapshot_slug": + name = snapshot["slug"] + elif "filename" in config and config["filename"] == "snapshot_name": + name = snapshot["name"] + else: + raise ValueError( + "Unknown value for the filename config: {config.get('filename')}" + ) + return dropbox_dir / f"{name}.tar" @@ -57,6 +66,12 @@ def process_snapshot(config, dbx, snapshot): if not os.path.isfile(path): LOG.warning("The snapshot no longer exists") return + if not snapshot.get("protected"): + LOG.warning( + f"Snapshot '{snapshot['name']}' is not password protected. Always " + "try to use passwords, particulary when uploading all your data " + "to a snapshot to a third party." + ) bytes_ = os.path.getsize(path) size = util.bytes_to_human(bytes_) target = str(dropbox_path(config, snapshot)) diff --git a/dropbox-upload/dropbox_upload/config.py b/dropbox-upload/dropbox_upload/config.py index f6c19e5..45e1e59 100644 --- a/dropbox-upload/dropbox_upload/config.py +++ b/dropbox-upload/dropbox_upload/config.py @@ -2,7 +2,10 @@ import logging import sys +from dropbox_upload import exceptions + DEFAULT_CONFIG = "/data/options.json" +LOG = logging.getLogger(__name__) def load_config(path=DEFAULT_CONFIG): @@ -10,6 +13,32 @@ def load_config(path=DEFAULT_CONFIG): return json.load(f) +def validate(cfg): + global errored + errored = False + + def _e(message): + global errored + LOG.error(message) + errored = True + + if not cfg["dropbox_dir"]: + _e("The dropbox_dir can't be an empty string, it must be at least '/'") + + if "filename" not in cfg: + cfg["filename"] = "snapshot_slug" + + if not cfg["filename"] in ["snapshot_name", "snapshot_slug"]: + _e( + "The `filename` config setting must equal either 'snapshot_name' " + "or 'snapshot_slug'. This is what it will use for the filename in " + "dropbox." + ) + + if errored: + raise exceptions.InvalidConfig() + + def setup_logging(config): log = logging.getLogger("dropbox_upload") log.setLevel(logging.DEBUG if config.get("debug") else logging.INFO) diff --git a/dropbox-upload/dropbox_upload/exceptions.py b/dropbox-upload/dropbox_upload/exceptions.py new file mode 100644 index 0000000..8080cfa --- /dev/null +++ b/dropbox-upload/dropbox_upload/exceptions.py @@ -0,0 +1,2 @@ +class InvalidConfig(Exception): + pass diff --git a/dropbox-upload/dropbox_upload/newsfragments/.gitignore b/dropbox-upload/dropbox_upload/newsfragments/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..56a6468 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[tool.towncrier] + package = "dropbox_upload" + package_dir = "dropbox-upload" + filename = "CHANGELOG.rst" diff --git a/tests/conftest.py b/tests/conftest.py index 9bacf5b..23ef3d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,19 +13,27 @@ def cfg(): "access_token": "token", "debug": True, "keep": 100, + "filename": "snapshot_slug", } @pytest.fixture -def snapshot(requests_mock): +def snapshot(): return { "slug": "dbaa2add", "name": "Automated Backup 2018-09-14", "date": "2018-09-14T01:00:00.873481+00:00", "type": "full", + "protected": True, } +@pytest.fixture +def snapshot_unprotected(snapshot): + snapshot["protected"] = False + return snapshot + + @pytest.fixture def snapshots(requests_mock): @@ -34,10 +42,10 @@ def snapshots(requests_mock): snapshots = [ # Intentionally out of order. - {"date": days_ago(3), "slug": "slug3", "name": "name3"}, - {"date": days_ago(1), "slug": "slug1", "name": "name1"}, - {"date": days_ago(2), "slug": "slug2", "name": "name2"}, - {"date": days_ago(0), "slug": "slug0", "name": "name0"}, + {"date": days_ago(3), "slug": "slug3", "name": "name3", "protected": True}, + {"date": days_ago(1), "slug": "slug1", "name": "name1", "protected": True}, + {"date": days_ago(2), "slug": "slug2", "name": "name2", "protected": False}, + {"date": days_ago(0), "slug": "slug0", "name": "name0", "protected": True}, ] data = {"data": {"snapshots": snapshots}} requests_mock.get("http://hassio/snapshots", text=json.dumps(data)) diff --git a/tests/test_backup.py b/tests/test_backup.py index d45f5d6..25c2029 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -2,6 +2,8 @@ import pathlib from unittest import mock +import pytest + from dropbox_upload import backup @@ -10,12 +12,27 @@ def test_local_path(): assert backup.local_path({"slug": "SLUG"}) == expected -def test_dropbox_path(cfg): +def test_dropbox_path_invalid_config(): + cfg = {"dropbox_dir": "/"} + with pytest.raises(ValueError): + backup.dropbox_path(cfg, {"slug": "SLUG"}) + + +def test_dropbox_path_slug(cfg): cfg["dropbox_dir"] = "/dropbox_dir" + cfg["filename"] = "snapshot_slug" expected = pathlib.Path("/dropbox_dir/SLUG.tar") assert backup.dropbox_path(cfg, {"slug": "SLUG"}) == expected +def test_dropbox_path_name(cfg): + cfg["dropbox_dir"] = "/dropbox_dir" + cfg["filename"] = "snapshot_name" + snapshot = {"name": "Automated Backup 2018-11-14"} + expected = pathlib.Path("/dropbox_dir/") / f"{snapshot['name']}.tar" + assert backup.dropbox_path(cfg, snapshot) == expected + + def test_backup_no_snapshots(cfg, caplog): backup.backup(None, cfg, []) @@ -35,7 +52,7 @@ def test_snapshot_deleted(cfg, snapshot, caplog): ) in caplog.record_tuples -def test_snapshot_stats(cfg, snapshot, caplog, tmpdir, dropbox_fake): +def test_snapshot_stats(cfg, snapshot, tmpdir, dropbox_fake): file_ = tmpdir.mkdir("sub").join("hello.txt") file_.write("testing content 24 bytes" * 1000) with mock.patch("dropbox_upload.backup.local_path") as local_path: @@ -74,3 +91,20 @@ def test_backup_file_exists(cfg, dropbox_fake, snapshot, caplog): logging.INFO, "Already found in Dropbox with the same hash", ) in caplog.record_tuples + + +def test_backup_password_warning(cfg, dropbox_fake, snapshot_unprotected, caplog): + caplog.set_level(logging.WARNING) + with mock.patch("dropbox_upload.dropbox.file_exists") as file_exists: + with mock.patch("dropbox_upload.backup.local_path"): + file_exists.return_value = True + backup.process_snapshot(cfg, None, snapshot_unprotected) + assert ( + "dropbox_upload.backup", + logging.WARNING, + ( + f"Snapshot '{snapshot_unprotected['name']}' is not password " + "protected. Always try to use passwords, particulary when " + "uploading all your data to a snapshot to a third party." + ), + ) in caplog.record_tuples diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..979ebef --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,25 @@ +import json + +import pytest + +from dropbox_upload import config, exceptions + + +def test_config_dropbox_dir(tmpdir): + + p = tmpdir.join("config.json") + p.write(json.dumps({"dropbox_dir": "/"})) + + cfg = config.load_config(p.strpath) + assert cfg["dropbox_dir"] == "/" + + +def test_config_dropbox_dir_invalid(tmpdir): + + p = tmpdir.join("config.json") + p.write(json.dumps({"dropbox_dir": ""})) + + cfg = config.load_config(p.strpath) + + with pytest.raises(exceptions.InvalidConfig): + config.validate(cfg)