diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index 36cb51b..2bdabf4 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -27,6 +27,12 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install tox tox-gh-actions + pip install tox tox-gh-actions build - name: Test with tox run: tox + - name: Build + run: python -m build + - uses: actions/upload-artifact@v3 + with: + name: Artifacts + path: dist/* diff --git a/.gitignore b/.gitignore index 8643cbc..d0c7329 100644 --- a/.gitignore +++ b/.gitignore @@ -165,4 +165,6 @@ venv/ venv_linux/ venv_windows/ -.vscode/ \ No newline at end of file +.vscode/ + +tests/.tmp/ \ No newline at end of file diff --git a/requirements/dev.txt b/requirements/dev.txt index c2ea1dc..f80992d 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -2,10 +2,10 @@ black==24.4.2 build==1.2.1 isort==5.13.2 mypy==1.10.0 -pylint==3.2.1 +pylint==3.2.2 pytest-asyncio==0.23.7 pytest-cov==5.0.0 -pytest==8.2.0 +pytest==8.2.1 tox==4.15.0 types-aiofiles==23.2.0.20240403 types-ujson==5.10.0.20240515 \ No newline at end of file diff --git a/src/libbot/__init__.py b/src/libbot/__init__.py index ae01807..15853ce 100644 --- a/src/libbot/__init__.py +++ b/src/libbot/__init__.py @@ -1,6 +1,6 @@ -__version__ = "3.1.0" +__version__ = "3.2.0" __license__ = "GPL3" __author__ = "Profitroll" from . import errors, i18n, pycord, pyrogram, sync -from .__main__ import * +from .__main__ import config_delete, config_get, config_set, json_read, json_write diff --git a/src/libbot/__main__.py b/src/libbot/__main__.py index cf8adb4..ccac675 100644 --- a/src/libbot/__main__.py +++ b/src/libbot/__main__.py @@ -8,7 +8,7 @@ try: except ImportError: from json import dumps, loads -from libbot.sync import nested_set +from .sync._nested import nested_delete, nested_set async def json_read(path: Union[str, Path]) -> Any: @@ -22,6 +22,7 @@ async def json_read(path: Union[str, Path]) -> Any: """ async with aiofiles.open(str(path), mode="r", encoding="utf-8") as f: data = await f.read() + return loads(data) @@ -73,8 +74,10 @@ async def config_get( ``` """ this_key = await json_read(config_file) + for dict_key in path: this_key = this_key[dict_key] + return this_key[key] @@ -88,8 +91,31 @@ async def config_set( * value (`Any`): Any JSON serializable data * *path (`str`): Path to the key of the target * config_file (`Union[str, Path]`, *optional*): Path-like object or path as a string of a location of the config file. Defaults to `"config.json"` + + ### Raises: + * `KeyError`: Key is not found under path provided """ await json_write( nested_set(await json_read(config_file), value, *(*path, key)), config_file ) return + + +async def config_delete( + key: str, *path: str, config_file: Union[str, Path] = "config.json" +) -> None: + """Set config's key by its path + + ### Args: + * key (`str`): Key to delete + * *path (`str`): Path to the key of the target + * config_file (`Union[str, Path]`, *optional*): Path-like object or path as a string of a location of the config file. Defaults to `"config.json"` + + ### Raises: + * `KeyError`: Key is not found under path provided + """ + config_data = await json_read(config_file) + + nested_delete(config_data, *(*path, key)) + + await json_write(config_data, config_file) diff --git a/src/libbot/sync/__init__.py b/src/libbot/sync/__init__.py index 180ba98..b4a3fa9 100644 --- a/src/libbot/sync/__init__.py +++ b/src/libbot/sync/__init__.py @@ -1,116 +1 @@ -from pathlib import Path -from typing import Any, Union - -try: - from ujson import dumps, loads -except ImportError: - from json import dumps, loads - - -def json_read(path: Union[str, Path]) -> Any: - """Read contents of a JSON file - - ### Args: - * path (`Union[str, Path]`): Path-like object or path as a string - - ### Returns: - * `Any`: File contents - """ - with open(str(path), mode="r", encoding="utf-8") as f: - data = f.read() - return loads(data) - - -def json_write(data: Any, path: Union[str, Path]) -> None: - """Write contents to a JSON file - - ### Args: - * data (`Any`): Contents to write. Must be a JSON serializable - * path (`Union[str, Path]`): Path-like object or path as a string of a destination - """ - with open(str(path), mode="w", encoding="utf-8") as f: - f.write( - dumps(data, ensure_ascii=False, escape_forward_slashes=False, indent=4) - if hasattr(dumps, "escape_forward_slashes") - else dumps(data, ensure_ascii=False, indent=4) - ) - - -def nested_set(target: dict, value: Any, *path: str, create_missing=True) -> dict: - """Set the key by its path to the value - - ### Args: - * target (`dict`): Dictionary to perform modifications on - * value (`Any`): Any data - * *path (`str`): Path to the key of the target - * create_missing (`bool`, *optional*): Create keys on the way if they're missing. Defaults to `True` - - ### Returns: - * `dict`: Changed dictionary - """ - d = target - for key in path[:-1]: - if key in d: - d = d[key] - elif create_missing: - d = d.setdefault(key, {}) - else: - raise KeyError( - f"Key '{key}' is not found under path provided ({path}) and create_missing is False" - ) - if path[-1] in d or create_missing: - d[path[-1]] = value - return target - - -def config_get( - key: str, *path: str, config_file: Union[str, Path] = "config.json" -) -> Any: - """Get a value of the config key by its path provided - For example, `foo.bar.key` has a path of `"foo", "bar"` and the key `"key"` - - ### Args: - * key (`str`): Key that contains the value - * *path (`str`): Path to the key that contains the value - * config_file (`Union[str, Path]`, *optional*): Path-like object or path as a string of a location of the config file. Defaults to `"config.json"` - - ### Returns: - * `Any`: Key's value - - ### Example: - Get the "salary" of "Pete" from this JSON structure: - ```json - { - "users": { - "Pete": { - "salary": 10.0 - } - } - } - ``` - - This can be easily done with the following code: - ```python - import libbot - salary = libbot.sync.config_get("salary", "users", "Pete") - ``` - """ - this_key = json_read(config_file) - for dict_key in path: - this_key = this_key[dict_key] - return this_key[key] - - -def config_set( - key: str, value: Any, *path: str, config_file: Union[str, Path] = "config.json" -) -> None: - """Set config's key by its path to the value - - ### Args: - * key (`str`): Key that leads to the value - * value (`Any`): Any JSON serializable data - * *path (`str`): Path to the key of the target - * config_file (`Union[str, Path]`, *optional*): Path-like object or path as a string of a location of the config file. Defaults to `"config.json"` - """ - json_write(nested_set(json_read(config_file), value, *(*path, key)), config_file) - return +from .__main__ import config_delete, config_get, config_set, json_read, json_write diff --git a/src/libbot/sync/__main__.py b/src/libbot/sync/__main__.py new file mode 100644 index 0000000..1abf1d4 --- /dev/null +++ b/src/libbot/sync/__main__.py @@ -0,0 +1,117 @@ +from pathlib import Path +from typing import Any, Union + +from ._nested import nested_delete, nested_set + +try: + from ujson import dumps, loads +except ImportError: + from json import dumps, loads + + +def json_read(path: Union[str, Path]) -> Any: + """Read contents of a JSON file + + ### Args: + * path (`Union[str, Path]`): Path-like object or path as a string + + ### Returns: + * `Any`: File contents + """ + with open(str(path), mode="r", encoding="utf-8") as f: + data = f.read() + + return loads(data) + + +def json_write(data: Any, path: Union[str, Path]) -> None: + """Write contents to a JSON file + + ### Args: + * data (`Any`): Contents to write. Must be a JSON serializable + * path (`Union[str, Path]`): Path-like object or path as a string of a destination + """ + with open(str(path), mode="w", encoding="utf-8") as f: + f.write( + dumps(data, ensure_ascii=False, escape_forward_slashes=False, indent=4) + if hasattr(dumps, "escape_forward_slashes") + else dumps(data, ensure_ascii=False, indent=4) + ) + + +def config_get( + key: str, *path: str, config_file: Union[str, Path] = "config.json" +) -> Any: + """Get a value of the config key by its path provided + For example, `foo.bar.key` has a path of `"foo", "bar"` and the key `"key"` + + ### Args: + * key (`str`): Key that contains the value + * *path (`str`): Path to the key that contains the value + * config_file (`Union[str, Path]`, *optional*): Path-like object or path as a string of a location of the config file. Defaults to `"config.json"` + + ### Returns: + * `Any`: Key's value + + ### Example: + Get the "salary" of "Pete" from this JSON structure: + ```json + { + "users": { + "Pete": { + "salary": 10.0 + } + } + } + ``` + + This can be easily done with the following code: + ```python + import libbot + salary = libbot.sync.config_get("salary", "users", "Pete") + ``` + """ + this_key = json_read(config_file) + + for dict_key in path: + this_key = this_key[dict_key] + + return this_key[key] + + +def config_set( + key: str, value: Any, *path: str, config_file: Union[str, Path] = "config.json" +) -> None: + """Set config's key by its path to the value + + ### Args: + * key (`str`): Key that leads to the value + * value (`Any`): Any JSON serializable data + * *path (`str`): Path to the key of the target + * config_file (`Union[str, Path]`, *optional*): Path-like object or path as a string of a location of the config file. Defaults to `"config.json"` + + ### Raises: + * `KeyError`: Key is not found under path provided + """ + json_write(nested_set(json_read(config_file), value, *(*path, key)), config_file) + return + + +def config_delete( + key: str, *path: str, config_file: Union[str, Path] = "config.json" +) -> None: + """Set config's key by its path + + ### Args: + * key (`str`): Key to delete + * *path (`str`): Path to the key of the target + * config_file (`Union[str, Path]`, *optional*): Path-like object or path as a string of a location of the config file. Defaults to `"config.json"` + + ### Raises: + * `KeyError`: Key is not found under path provided + """ + config_data = json_read(config_file) + + nested_delete(config_data, *(*path, key)) + + json_write(config_data, config_file) diff --git a/src/libbot/sync/_nested.py b/src/libbot/sync/_nested.py new file mode 100644 index 0000000..310bc1c --- /dev/null +++ b/src/libbot/sync/_nested.py @@ -0,0 +1,64 @@ +from typing import Any, Dict + + +def nested_set( + target: dict, value: Any, *path: str, create_missing=True +) -> Dict[str, Any]: + """Set the key by its path to the value + + ### Args: + * target (`dict`): Dictionary to perform modifications on + * value (`Any`): Any data + * *path (`str`): Path to the key of the target + * create_missing (`bool`, *optional*): Create keys on the way if they're missing. Defaults to `True` + + ### Raises: + * `KeyError`: Key is not found under path provided + + ### Returns: + * `Dict[str, Any]`: Changed dictionary + """ + d = target + + for key in path[:-1]: + if key in d: + d = d[key] + elif create_missing: + d = d.setdefault(key, {}) + else: + raise KeyError( + f"Key '{key}' is not found under path provided ({path}) and create_missing is False" + ) + + if path[-1] in d or create_missing: + d[path[-1]] = value + + return target + + +def nested_delete(target: dict, *path: str) -> Dict[str, Any]: + """Delete the key by its path + + ### Args: + * target (`dict`): Dictionary to perform modifications on + + ### Raises: + * `KeyError`: Key is not found under path provided + + ### Returns: + `Dict[str, Any]`: Changed dictionary + """ + d = target + + for key in path[:-1]: + if key in d: + d = d[key] + else: + raise KeyError(f"Key '{key}' is not found under path provided ({path})") + + if path[-1] in d: + del d[path[-1]] + else: + raise KeyError(f"Key '{path[-1]}' is not found under path provided ({path})") + + return target diff --git a/tests/conftest.py b/tests/conftest.py index e7e66cf..7b5fe60 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,7 @@ +from json import dumps, loads +from os import makedirs from pathlib import Path +from uuid import uuid4 import pytest @@ -7,7 +10,22 @@ from libbot.i18n import BotLocale @pytest.fixture() def location_config() -> Path: - return Path("tests/config.json") + makedirs(Path("tests/.tmp"), exist_ok=True) + + filename = str(uuid4()) + + with open(Path("tests/config.json"), "r", encoding="utf-8") as file: + config = loads(file.read()) + + with open(Path(f"tests/.tmp/{filename}.json"), "w", encoding="utf-8") as file: + file.write( + dumps( + config, + ensure_ascii=False, + indent=4, + ) + ) + return Path(f"tests/.tmp/{filename}.json") @pytest.fixture() diff --git a/tests/test_config_async.py b/tests/test_config_async.py index 0453c7c..c23223c 100644 --- a/tests/test_config_async.py +++ b/tests/test_config_async.py @@ -3,7 +3,7 @@ from typing import Any, List import pytest -from libbot import config_get, config_set +from libbot import config_delete, config_get, config_set, sync @pytest.mark.asyncio @@ -46,3 +46,15 @@ async def test_config_get_invalid( async def test_config_set(key: str, path: List[str], value: Any, location_config: Path): await config_set(key, value, *path, config_file=location_config) assert await config_get(key, *path, config_file=location_config) == value + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "key, path", + [ + ("bot_token", ["bot"]), + ], +) +async def test_config_delete(key: str, path: List[str], location_config: Path): + await config_delete(key, *path, config_file=location_config) + assert key not in (await config_get(*path, config_file=location_config)) diff --git a/tests/test_config_sync.py b/tests/test_config_sync.py index 1ed9e19..6ffe4af 100644 --- a/tests/test_config_sync.py +++ b/tests/test_config_sync.py @@ -40,3 +40,14 @@ def test_config_get_invalid(args: List[str], expected: Any, location_config: Pat def test_config_set(key: str, path: List[str], value: Any, location_config: Path): sync.config_set(key, value, *path, config_file=location_config) assert sync.config_get(key, *path, config_file=location_config) == value + + +@pytest.mark.parametrize( + "key, path", + [ + ("bot_token", ["bot"]), + ], +) +def test_config_delete(key: str, path: List[str], location_config: Path): + sync.config_delete(key, *path, config_file=location_config) + assert key not in sync.config_get(*path, config_file=location_config) diff --git a/tests/test_nested_set.py b/tests/test_nested_set.py index 5dc4fee..be9789c 100644 --- a/tests/test_nested_set.py +++ b/tests/test_nested_set.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List import pytest -from libbot import sync +from libbot.sync._nested import nested_delete, nested_set @pytest.mark.parametrize( @@ -33,9 +33,7 @@ def test_nested_set_valid( create_missing: bool, expected: Any, ): - assert ( - sync.nested_set(target, value, *path, create_missing=create_missing) - ) == expected + assert (nested_set(target, value, *path, create_missing=create_missing)) == expected @pytest.mark.parametrize( @@ -59,5 +57,25 @@ def test_nested_set_invalid( ): with pytest.raises(expected): assert ( - sync.nested_set(target, value, *path, create_missing=create_missing) + nested_set(target, value, *path, create_missing=create_missing) ) == expected + + +@pytest.mark.parametrize( + "target, path, expected", + [ + ({"foo": "bar"}, ["foo"], {}), + ({"foo": "bar", "bar": "foo"}, ["bar"], {"foo": "bar"}), + ( + {"foo": {"bar": {}}}, + ["foo", "bar"], + {"foo": {}}, + ), + ], +) +def test_nested_delete( + target: Dict[str, Any], + path: List[str], + expected: Any, +): + assert (nested_delete(target, *path)) == expected