From f4b1bb86446904e2221e64a9c2bff40d2e75f111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Sun, 19 Apr 2020 18:30:05 +0200 Subject: [PATCH 1/2] feat: added support for toml config --- aw_core/__about__.py | 12 +++++-- aw_core/config.py | 82 ++++++++++++++++++++++++++++++++++++++++++-- poetry.lock | 33 ++++++++++++++++-- pyproject.toml | 2 ++ tests/test_config.py | 81 +++++++++++++++++++++++++++++++++++-------- 5 files changed, 186 insertions(+), 24 deletions(-) diff --git a/aw_core/__about__.py b/aw_core/__about__.py index 9f034ab..dce3913 100644 --- a/aw_core/__about__.py +++ b/aw_core/__about__.py @@ -2,15 +2,21 @@ # https://github.com/pypa/pipfile/blob/master/pipfile/__about__.py __all__ = [ - "__title__", "__summary__", "__uri__", "__version__", "__author__", - "__email__", "__license__", "__copyright__", + "__title__", + "__summary__", + "__uri__", + "__version__", + "__author__", + "__email__", + "__license__", + "__copyright__", ] __title__ = "aw-core" __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 d95a378..9e59a26 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: + # TODO: 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,11 +86,11 @@ 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): - with open(config_file_path, 'r') as f: + with open(config_file_path, "r") as f: config.read_file(f) # Overwrite current config file (necessary in case new default would be added) @@ -28,8 +99,13 @@ 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)) - with open(config_file_path, 'w') as f: + with open(config_file_path, "w") as f: config.write(f) diff --git a/poetry.lock b/poetry.lock index 0f7cae4..772098e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -66,6 +66,17 @@ version = "5.0.1" [package.extras] toml = ["toml"] +[[package]] +category = "main" +description = "A library to handle automated deprecations" +name = "deprecation" +optional = false +python-versions = "*" +version = "2.0.7" + +[package.dependencies] +packaging = "*" + [[package]] category = "main" description = "Read metadata from Python packages" @@ -175,7 +186,7 @@ python-versions = "*" version = "0.4.3" [[package]] -category = "dev" +category = "main" description = "Core utilities for Python packages" name = "packaging" optional = false @@ -249,7 +260,7 @@ tls = ["ipaddress"] zstd = ["zstandard"] [[package]] -category = "dev" +category = "main" description = "Python parsing module" name = "pyparsing" optional = false @@ -339,6 +350,14 @@ optional = false python-versions = "*" version = "0.3.1" +[[package]] +category = "main" +description = "Style preserving TOML library" +name = "tomlkit" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.6.0" + [[package]] category = "dev" description = "a fork of Python 2 and 3 ast modules with type comment support" @@ -391,7 +410,7 @@ testing = ["pathlib2", "contextlib2", "unittest2"] mongo = ["pymongo"] [metadata] -content-hash = "e3e177a232ed4236fb5593f477f356603d73f350dbabd5e6e38e873e130c7196" +content-hash = "e8b2b30715d1b081330270e66b84d6b030940c52dabedfdeae64b57011b70234" python-versions = "^3.6" [metadata.files] @@ -448,6 +467,10 @@ coverage = [ {file = "coverage-5.0.1-cp39-cp39m-win_amd64.whl", hash = "sha256:b7dbc5e8c39ea3ad3db22715f1b5401cd698a621218680c6daf42c2f9d36e205"}, {file = "coverage-5.0.1.tar.gz", hash = "sha256:5ac71bba1e07eab403b082c4428f868c1c9e26a21041436b4905c4c3d4e49b08"}, ] +deprecation = [ + {file = "deprecation-2.0.7-py2.py3-none-any.whl", hash = "sha256:dc9b4f252b7aca8165ce2764a71da92a653b5ffbf7a389461d7a640f6536ecb2"}, + {file = "deprecation-2.0.7.tar.gz", hash = "sha256:c0392f676a6146f0238db5744d73e786a43510d54033f80994ef2f4c9df192ed"}, +] importlib-metadata = [ {file = "importlib_metadata-1.3.0-py2.py3-none-any.whl", hash = "sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f"}, {file = "importlib_metadata-1.3.0.tar.gz", hash = "sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45"}, @@ -621,6 +644,10 @@ strict-rfc3339 = [ takethetime = [ {file = "TakeTheTime-0.3.1.tar.gz", hash = "sha256:dbe30453a1b596a38f9e2e3fa8e1adc5af2dbf646ca0837ad5c2059a16fe2ff9"}, ] +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.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e"}, {file = "typed_ast-1.4.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b"}, diff --git a/pyproject.toml b/pyproject.toml index d718330..ab642fc 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" [tool.poetry.dev-dependencies] pytest = "^5.3.2" diff --git a/tests/test_config.py b/tests/test_config.py index b71ee4c..1f68075 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,31 +1,83 @@ -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) -def test_create(): - appname = "aw-core-test" - section = "section" - config_dir = dirs.get_config_dir(appname) + yield # 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 - } + default_config[section] = {"somestring": "Hello World!", "somevalue": 12.3} # Load non-existing config (will create a default config file) config = load_config(appname, default_config) # Check that current config file is same as default config file assert config[section]["somestring"] == default_config[section]["somestring"] - assert config[section].getfloat("somevalue") == default_config[section].getfloat("somevalue") + assert config[section].getfloat("somevalue") == default_config[section].getfloat( + "somevalue" + ) # Modify and save config file config[section]["somevalue"] = "1000.1" @@ -34,7 +86,6 @@ def test_create(): # Open non-default config file and verify that values are correct new_config = load_config(appname, default_config) assert new_config[section]["somestring"] == config[section]["somestring"] - assert new_config[section].getfloat("somevalue") == config[section].getfloat("somevalue") - - # Remove test config file - shutil.rmtree(config_dir) + assert new_config[section].getfloat("somevalue") == config[section].getfloat( + "somevalue" + ) From 067bb696f9c0b2b6facbd6a7ed13b2fe4c44e9c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Tue, 15 Jun 2021 12:44:50 +0200 Subject: [PATCH 2/2] fix: removed resolved TODO --- aw_core/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aw_core/config.py b/aw_core/config.py index ac0daf7..214ce22 100644 --- a/aw_core/config.py +++ b/aw_core/config.py @@ -52,7 +52,7 @@ def load_config_toml( config = f.read() config_toml = tomlkit.parse(config) else: - # TODO: If file doesn't exist, write with commented-out default config + # 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()