diff --git a/aw_core/__about__.py b/aw_core/__about__.py index c79f0dc..dce3913 100644 --- a/aw_core/__about__.py +++ b/aw_core/__about__.py @@ -16,7 +16,7 @@ __summary__ = "Core library for ActivityWatch" __uri__ = "https://github.com/ActivityWatch/aw-core" -__version__ = "0.4.1" +__version__ = "0.4.2" __author__ = "Erik Bjäreholt, Johan Bjäreholt" __email__ = "erik@bjareho.lt, johan@bjareho.lt" diff --git a/aw_core/config.py b/aw_core/config.py index 94d2d40..214ce22 100644 --- a/aw_core/config.py +++ b/aw_core/config.py @@ -1,12 +1,83 @@ import os import logging +from typing import Any, Dict, Union from configparser import ConfigParser +from deprecation import deprecated +import tomlkit + from aw_core import dirs +from aw_core.__about__ import __version__ logger = logging.getLogger(__name__) +def _merge(a: dict, b: dict, path=None): + """ + Recursively merges b into a, with b taking precedence. + + From: https://stackoverflow.com/a/7205107/965332 + """ + if path is None: + path = [] + for key in b: + if key in a: + if isinstance(a[key], dict) and isinstance(b[key], dict): + _merge(a[key], b[key], path + [str(key)]) + elif a[key] == b[key]: + pass # same leaf value + else: + a[key] = b[key] + else: + a[key] = b[key] + return a + + +def _comment_out_toml(s: str): + return "\n".join(["#" + line for line in s.split("\n")]) + + +def load_config_toml( + appname: str, default_config: str +) -> Union[dict, tomlkit.container.Container]: + config_dir = dirs.get_config_dir(appname) + config_file_path = os.path.join(config_dir, "{}.toml".format(appname)) + + # Run early to ensure input is valid toml before writing + default_config_toml = tomlkit.parse(default_config) + + # Override defaults from existing config file + if os.path.isfile(config_file_path): + with open(config_file_path, "r") as f: + config = f.read() + config_toml = tomlkit.parse(config) + else: + # If file doesn't exist, write with commented-out default config + with open(config_file_path, "w") as f: + f.write(_comment_out_toml(default_config)) + config_toml = dict() + + config = _merge(default_config_toml, config_toml) + + return config + + +def save_config_toml(appname: str, config: str) -> None: + # Check that passed config string is valid toml + assert tomlkit.parse(config) + + config_dir = dirs.get_config_dir(appname) + config_file_path = os.path.join(config_dir, "{}.toml".format(appname)) + + with open(config_file_path, "w") as f: + f.write(config) + + +@deprecated( + details="Use the load_config_toml function instead", + deprecated_in="0.4.2", + current_version=__version__, +) def load_config(appname, default_config): """ Take the defaults, and if a config file exists, use the settings specified @@ -15,7 +86,7 @@ def load_config(appname, default_config): config = default_config config_dir = dirs.get_config_dir(appname) - config_file_path = os.path.join(config_dir, "{}.ini".format(appname)) + config_file_path = os.path.join(config_dir, "{}.toml".format(appname)) # Override defaults from existing config file if os.path.isfile(config_file_path): @@ -28,6 +99,11 @@ def load_config(appname, default_config): return config +@deprecated( + details="Use the save_config_toml function instead", + deprecated_in="0.4.2", + current_version=__version__, +) def save_config(appname, config): config_dir = dirs.get_config_dir(appname) config_file_path = os.path.join(config_dir, "{}.ini".format(appname)) diff --git a/poetry.lock b/poetry.lock index c5c4ceb..4d0af8a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -60,6 +60,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] toml = ["toml"] +[[package]] +name = "deprecation" +version = "2.1.0" +description = "A library to handle automated deprecations" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +packaging = "*" + [[package]] name = "importlib-metadata" version = "4.5.0" @@ -169,7 +180,7 @@ python-versions = "*" name = "packaging" version = "20.9" description = "Core utilities for Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -243,7 +254,7 @@ zstd = ["zstandard"] name = "pyparsing" version = "2.4.7" description = "Python parsing module" -category = "dev" +category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" @@ -341,6 +352,14 @@ category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "tomlkit" +version = "0.6.0" +description = "Style preserving TOML library" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "typed-ast" version = "1.4.3" @@ -383,7 +402,7 @@ mongo = ["pymongo"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "f5888f45fba59c3cf9548ac229cbaa9ffe8f6df0f3cb3bb09847f77f31a4befd" +content-hash = "f2250f81e037411bf822f67d43ec0a9ddfa7840b5f7f0e5e944f6432111a173b" [metadata.files] appdirs = [ @@ -460,6 +479,10 @@ coverage = [ {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] +deprecation = [ + {file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"}, + {file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"}, +] importlib-metadata = [ {file = "importlib_metadata-4.5.0-py3-none-any.whl", hash = "sha256:833b26fb89d5de469b24a390e9df088d4e52e4ba33b01dc5e0e4f41b81a16c00"}, {file = "importlib_metadata-4.5.0.tar.gz", hash = "sha256:b142cc1dd1342f31ff04bb7d022492b09920cb64fed867cd3ea6f80fe3ebd139"}, @@ -658,6 +681,10 @@ toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] +tomlkit = [ + {file = "tomlkit-0.6.0-py2.py3-none-any.whl", hash = "sha256:e5d5f20809c2b09276a6c5d98fb0202325aee441a651db84ac12e0812ab7e569"}, + {file = "tomlkit-0.6.0.tar.gz", hash = "sha256:74f976908030ff164c0aa1edabe3bf83ea004b3daa5b0940b9c86a060c004e9a"}, +] typed-ast = [ {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, diff --git a/pyproject.toml b/pyproject.toml index 45c560e..5d00287 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ python-json-logger = "^0.1.11" TakeTheTime = "^0.3.1" pymongo = {version = "^3.10.0", optional = true} strict-rfc3339 = "^0.7" +tomlkit = "^0.6.0" +deprecation = "^2.0.7" timeslot = "*" [tool.poetry.dev-dependencies] diff --git a/tests/test_config.py b/tests/test_config.py index d0efdc1..b038c9e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,9 +1,35 @@ -import unittest import shutil from configparser import ConfigParser +import pytest +import deprecation + from aw_core import dirs -from aw_core.config import load_config, save_config +from aw_core.config import load_config, save_config, load_config_toml, save_config_toml + +appname = "aw-core-test" +section = "section" +config_dir = dirs.get_config_dir(appname) + +default_config_str = f"""# A default config file, with comments! +[{section}] +somestring = "Hello World!" # A comment +somevalue = 12.3 # Another comment +somearray = ["asd", 123]""" + + +@pytest.fixture(autouse=True) +def clean_config(): + # Remove test config file if it already exists + shutil.rmtree(config_dir, ignore_errors=True) + + # Rerun get_config dir to create config directory + dirs.get_config_dir(appname) + + yield + + # Remove test config file if it already exists + shutil.rmtree(config_dir) def test_create(): @@ -11,9 +37,41 @@ def test_create(): section = "section" config_dir = dirs.get_config_dir(appname) - # Remove test config file if it already exists - shutil.rmtree(config_dir) +def test_config_defaults(): + # Load non-existing config (will create a out-commented default config file) + config = load_config_toml(appname, default_config_str) + + # Check that load_config used defaults + assert config[section]["somestring"] == "Hello World!" + assert config[section]["somevalue"] == 12.3 + assert config[section]["somearray"] == ["asd", 123] + + +def test_config_no_defaults(): + # Write defaults to file + save_config_toml(appname, default_config_str) + + # Load written defaults without defaults + config = load_config_toml(appname, "") + assert config[section]["somestring"] == "Hello World!" + assert config[section]["somevalue"] == 12.3 + assert config[section]["somearray"] == ["asd", 123] + + +def test_config_override(): + # Create a minimal config file with one overridden value + config = """[section] +somevalue = 1000.1""" + save_config_toml(appname, config) + + # Open non-default config file and verify that values are correct + config = load_config_toml(appname, default_config_str) + assert config[section]["somevalue"] == 1000.1 + + +@deprecation.fail_if_not_removed +def test_config_ini(): # Create default config default_config = ConfigParser() default_config[section] = {"somestring": "Hello World!", "somevalue": 12.3} @@ -37,6 +95,3 @@ def test_create(): assert new_config[section].getfloat("somevalue") == config[section].getfloat( "somevalue" ) - - # Remove test config file - shutil.rmtree(config_dir)