diff --git a/README.md b/README.md index 583d37a..ebf380b 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ Easily parse arbitrary arguments from the command line without dependencies: ![example code](assets/code.png) ![example code](assets/run.png) -![](https://img.shields.io/badge/coverage-97%25-success) -![](https://img.shields.io/badge/version-0.1.4-informational) +![](https://img.shields.io/badge/coverage-99%25-success) +![](https://img.shields.io/badge/version-0.1.5-informational) ![](https://img.shields.io/badge/python-3.7%2B%20-orange) ```bash @@ -25,6 +25,7 @@ pip install minydra MinyDict  •  Save config  •  Prevent typos  •  + Use default configs  •  Examples

@@ -336,6 +337,68 @@ KeyError: 'Cannot create a non-existing key in strict mode ({"log_leveel":INFO})
+### Using default configurations + +The `minydra.Parser` class takes a `defaults=` keyword argument. This can be: + +* a `str` or a `pathlib.Path` to a `json` `yaml` or `pickle` file that `minydra.MinyDict` can load (`from_X`) +* a `dict` or a `minydra.MinyDict` + +When `defaults` is provided, the resulting `minydra.MinyDict` serves as a reference for the arguments parsed from the command-line: + +* arguments from the command-line have a higher priority but **must** be present in the `defaults` (`defaults.update(args, strict=True)` is used, see [strict mode](#strict-mode)) +* arguments not present in the command-line with fallback to values in `defaults` + +`defaults` can actually be a `list` and the update order is the same as the list's. For instance: + +```python +In [1]: from minydra import Parser + +In [2]: Parser(defaults=["./examples/demo.json", "./examples/demo2.json"]).args.pretty_print(); +╭─────────────────────────────────╮ +│ log │ +│ │logger │ +│ │ │log_level : INFO │ +│ │ │logger_name : minydra │ +│ │outdir : /some/other/path │ +│ │project : demo │ +│ new_key : 3 │ +│ verbose : False │ +╰─────────────────────────────────╯ +``` + +If you need to set defaults from the command-line, there's a special `@defaults` keyword you can use: + + +```text +$ python examples/decorator.py @defaults=./examples/demo.json +╭──────────────────────────────────────╮ +│ @defaults : ./examples/demo.json │ +│ log │ +│ │logger │ +│ │ │log_level : DEBUG │ +│ │ │logger_name : minydra │ +│ │outdir : /some/path │ +│ │project : demo │ +│ verbose : False │ +╰──────────────────────────────────────╯ + +$ python examples/decorator.py @defaults="['./examples/demo.json', './examples/demo2.json']" +╭───────────────────────────────────────────────────────────────────╮ +│ @defaults : ['./examples/demo.json', './examples/demo2.json'] │ +│ log │ +│ │logger │ +│ │ │log_level : INFO │ +│ │ │logger_name : minydra │ +│ │outdir : /some/other/path │ +│ │project : demo │ +│ new_key : 3 │ +│ verbose : False │ +╰───────────────────────────────────────────────────────────────────╯ +``` + +
+ ### `pretty_print` Prints the `MinyDict` in a box, with dicts properly indented. A few arguments: diff --git a/assets/carbon-config.json b/assets/carbon-config.json index 1aa56be..ebd1bae 100644 --- a/assets/carbon-config.json +++ b/assets/carbon-config.json @@ -1 +1 @@ -{"paddingVertical":"57px","paddingHorizontal":"55px","backgroundImage":null,"backgroundImageSelection":null,"backgroundMode":"color","backgroundColor":"rgba(255,255,255,1)","dropShadow":true,"dropShadowOffsetY":"21px","dropShadowBlurRadius":"45px","theme":"material","windowTheme":"none","language":"text","fontFamily":"MonoLisa","fontSize":"13.5px","lineHeight":"150%","windowControls":true,"widthAdjustment":false,"lineNumbers":false,"firstLineNumber":1,"exportSize":"4x","watermark":false,"squaredImage":false,"hiddenCharacters":false,"name":"","width":912} \ No newline at end of file +{"paddingVertical":"57px","paddingHorizontal":"55px","backgroundImage":null,"backgroundImageSelection":null,"backgroundMode":"color","backgroundColor":"rgba(255,255,255,1)","dropShadow":true,"dropShadowOffsetY":"21px","dropShadowBlurRadius":"45px","theme":"material","windowTheme":"none","language":"text","fontFamily":"MonoLisa","fontSize":"13.5px","lineHeight":"150%","windowControls":true,"widthAdjustment":false,"lineNumbers":false,"firstLineNumber":1,"exportSize":"4x","watermark":false,"squaredImage":false,"hiddenCharacters":false,"name":"","width":912} diff --git a/examples/Readme.md b/examples/Readme.md index 8acddae..457cdec 100644 --- a/examples/Readme.md +++ b/examples/Readme.md @@ -297,6 +297,72 @@ In [6]: d.update({"b": {"e": 2}}).pretty_print() # default is strict=False, allo Out[6]: {'a': 10, 'b': {'c': 0, 'e': 2}} ``` +## Using default configurations + +Code: [**`defaults.py`**](defaults.py) + +```python +from minydra import resolved_args + +if __name__ == "__main__": + + args = resolved_args(defaults="demo.yaml") + args.pretty_print() +``` + +```text +$ python examples/defaults.py +╭──────────────────────────────╮ +│ log │ +│ │logger │ +│ │ │log_level : DEBUG │ +│ │ │logger_name : minydra │ +│ │outdir : /some/path │ +│ │project : demo │ +│ verbose : False │ +╰──────────────────────────────╯ + +$ python examples/defaults.py @defaults=examples/demo2.json +╭─────────────────────────────────────╮ +│ @defaults : examples/demo2.json │ +│ log │ +│ │logger │ +│ │ │log_level : INFO │ +│ │ │logger_name : minydra │ +│ │outdir : /some/other/path │ +│ new_key : 3 │ +│ verbose : False │ +╰─────────────────────────────────────╯ + +$ python examples/defaults.py @defaults="['examples/demo.json', 'examples/demo2.json']" +╭───────────────────────────────────────────────────────────────╮ +│ @defaults : ['examples/demo.json', 'examples/demo2.json'] │ +│ log │ +│ │logger │ +│ │ │log_level : INFO │ +│ │ │logger_name : minydra │ +│ │outdir : /some/other/path │ +│ │project : demo │ +│ new_key : 3 │ +│ verbose : False │ +╰───────────────────────────────────────────────────────────────╯ +``` + +```python +In [1]: from minydra import Parser + +In [2]: Parser(defaults=["./examples/demo.json", "./examples/demo2.json"]).args.pretty_print(); +╭─────────────────────────────────╮ +│ log │ +│ │logger │ +│ │ │log_level : INFO │ +│ │ │logger_name : minydra │ +│ │outdir : /some/other/path │ +│ │project : demo │ +│ new_key : 3 │ +│ verbose : False │ +╰─────────────────────────────────╯ +``` ## Protected attributes `MinyDict`'s methods (including the `dict` class's) are protected, they are read-only and you cannot therefore set _attributes_ with there names, like `args.get = 2`. If you do need to have a `get` argument, you can access it through _items_: `args["get"] = 2`. diff --git a/examples/defaults.py b/examples/defaults.py new file mode 100644 index 0000000..b69466f --- /dev/null +++ b/examples/defaults.py @@ -0,0 +1,8 @@ +from pathlib import Path + +from minydra import resolved_args + +if __name__ == "__main__": + + args = resolved_args(defaults=Path(__file__).parent / "demo.yaml") + args.pretty_print() diff --git a/examples/demo.json b/examples/demo.json index 1408972..a397eed 100644 --- a/examples/demo.json +++ b/examples/demo.json @@ -8,4 +8,4 @@ } }, "verbose": false -} \ No newline at end of file +} diff --git a/examples/demo2.json b/examples/demo2.json new file mode 100644 index 0000000..aba4456 --- /dev/null +++ b/examples/demo2.json @@ -0,0 +1,11 @@ +{ + "log": { + "outdir": "/some/other/path", + "logger": { + "log_level": "INFO", + "logger_name": "minydra" + } + }, + "verbose": false, + "new_key": 3 +} diff --git a/minydra/__init__.py b/minydra/__init__.py index 9461ca2..375c53a 100644 --- a/minydra/__init__.py +++ b/minydra/__init__.py @@ -1,7 +1,7 @@ from .dict import MinyDict # noqa: F401 from .parser import Parser -__version__ = "0.1.4" +__version__ = "0.1.5" def parse_args( @@ -10,6 +10,7 @@ def parse_args( warn_overwrites=True, parse_env=True, warn_env=True, + defaults=None, ): def decorator(function): def wrapper(*args, **kwargs): @@ -19,6 +20,7 @@ def wrapper(*args, **kwargs): warn_overwrites=warn_overwrites, parse_env=parse_env, warn_env=warn_env, + defaults=defaults, ) result = function(parser.args) return result @@ -34,6 +36,7 @@ def resolved_args( warn_overwrites=True, parse_env=True, warn_env=True, + defaults=None, ): return Parser( verbose=verbose, @@ -41,4 +44,5 @@ def resolved_args( warn_overwrites=warn_overwrites, parse_env=parse_env, warn_env=warn_env, + defaults=defaults, ).args.resolve() diff --git a/minydra/parser.py b/minydra/parser.py index 40689ed..2b6166a 100644 --- a/minydra/parser.py +++ b/minydra/parser.py @@ -1,11 +1,13 @@ import ast import os +import pathlib import re import sys -from typing import Any, List, Optional +from typing import Any, List, Optional, Union from minydra.dict import MinyDict from minydra.exceptions import MinydraWrongArgumentException +from minydra.utils import resolve_path class Parser: @@ -32,6 +34,7 @@ def __init__( warn_overwrites=True, parse_env=True, warn_env=True, + defaults=None, ) -> None: """ Create a Minydra Parser to parse arbitrary commandline argument as: @@ -51,6 +54,9 @@ def __init__( as key or value in the command line. Defaults to True. warn_env (bool, optional): Wether to print a warning in case an environment variable is parsed but no value is found. Defaults to True. + defaults (Union[str, dict, MinyDict], optional): The set of allowed keys as + a (Miny)dict or a path to a file that `minydra.MinyDict` will be able to + load (as `json`, `pickle` or `yaml`) """ super().__init__() @@ -64,6 +70,48 @@ def __init__( self._print("sys.argv:", self._argv) self._parse_args() + if defaults is not None or self.args["@defaults"]: + default = self.load_defaults(self.args["@defaults"] or defaults) + args = self.args.deepcopy().resolve() + args_defaults = args["@defaults"] + if args["@defaults"]: + del args["@defaults"] + self.args = default.update(args, strict=True) + if args_defaults: + self.args["@defaults"] = args_defaults + + @staticmethod + def load_defaults(default: Union[str, dict, MinyDict]): + """ + Set the default keys. + + Args: + allow (Union[str, dict, MinyDict]): The set of allowed keys as a + (Miny)dict or a path to a file that `minydra.MinyDict` will be able to + load (as `json`, `pickle` or `yaml`) + """ + if isinstance(default, (str, pathlib.Path)): + default = resolve_path(default) + assert default.exists() + assert default.is_file() + if default.suffix not in {".json", ".yaml", ".yml", ".pickle", ".pkl"}: + raise ValueError(f"{str(default)} is not a valid file extension.") + if default.suffix in {".yaml", ".yml"}: + default = MinyDict.from_yaml(default) + elif default.suffix in {".pickle", ".pkl"}: + default = MinyDict.from_pickle(default) + else: + default = MinyDict.from_json(default) + elif isinstance(default, dict): + default = MinyDict(default).resolve() + elif isinstance(default, list): + defaults = [Parser.load_defaults(d) for d in default] + default = MinyDict() + for d in defaults: + default.update(d, strict=False) + + assert isinstance(default, MinyDict) + return default def _print(self, *args, **kwargs): if self.verbose > 0: @@ -177,7 +225,6 @@ def _force_type(value: str, type_str: str) -> Any: return float(value) if type_str == "str": return str(value) - return value @staticmethod def _infer_arg_type(arg: Any, type_str: Optional[str] = None) -> Any: diff --git a/requirements-test.txt b/requirements-test.txt index 4eb528b..89f8c7f 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -4,4 +4,4 @@ isort pre-commit pytest pytest-cov -tox \ No newline at end of file +tox diff --git a/tests/test_minydra.py b/tests/test_minydra.py index 4142b5e..c637d15 100644 --- a/tests/test_minydra.py +++ b/tests/test_minydra.py @@ -1,7 +1,12 @@ +import json import sys +from pathlib import Path from unittest.mock import patch +import pytest + import minydra +from minydra.dict import MinyDict def test_resolved_args(): @@ -21,3 +26,59 @@ def main(args): assert args.foo == "bar" main() + + +def test_defaults(): + + examples = Path(__file__).resolve().parent.parent / "examples" + d1 = examples / "demo.json" + d2 = examples / "demo2.json" + y1 = examples / "demo.yaml" + + p = MinyDict({"a": "2", "c": 3, "d": {"e": {"f": 4, "g": 5}}}) + pkl = p.to_pickle(Path(__file__).resolve().parent / "test.pkl") + + with patch.object(sys, "argv", [""]): + args = minydra.resolved_args(defaults=p) + assert args == p + + with patch.object(sys, "argv", ["", "d.e.f=2"]): + args = minydra.resolved_args(defaults=p) + assert args.d.e.f == 2 + + with patch.object(sys, "argv", ["", f"@defaults={str(d1)}"]): + args = minydra.resolved_args() + del args["@defaults"] + assert args.to_dict() == json.loads(d1.read_text()) + + with patch.object(sys, "argv", ["", f"@defaults={str(y1)}"]): + args = minydra.resolved_args() + del args["@defaults"] + assert args.to_dict() == MinyDict.from_yaml(y1) + + with patch.object(sys, "argv", ["", f"@defaults={str(pkl)}"]): + args = minydra.resolved_args() + del args["@defaults"] + assert args.to_dict() == MinyDict.from_pickle(pkl) + Path(pkl).unlink() + + with pytest.raises(ValueError): + with patch.object( + sys, "argv", ["", f"@defaults={str(d1).replace('.json', '.py')}"] + ): + args = minydra.resolved_args() + + with pytest.raises(KeyError): + with patch.object(sys, "argv", ["", f"@defaults={str(d1)}", "new_key=3"]): + args = minydra.resolved_args() + del args["@defaults"] + assert args.to_dict() == json.loads(d1.read_text()) + + double_defaults = f"['{str(d1)}', '{str(d2)}']" + with patch.object(sys, "argv", ["", f"@defaults={double_defaults}", "new_key=3"]): + args = minydra.resolved_args() + d1d = MinyDict.from_json(d1) + d2d = MinyDict.from_json(d2) + d1d = d1d.update(d2d) + del args["@defaults"] + assert args == d1d diff --git a/tests/test_parser_static.py b/tests/test_parser_static.py index 8a1c6d2..0564a14 100644 --- a/tests/test_parser_static.py +++ b/tests/test_parser_static.py @@ -91,42 +91,42 @@ def test_set_env(monkeypatch, capfd): assert "Detected variable $MINY_VAR_DOES_NOT_EXIST" in out -def test_sanitize_args_int(): - assert Parser.sanitize_args({"a": "3"}) == {"a": 3} +def test_parse_arg_types_int(): + assert Parser.parse_arg_types({"a": "3"}) == {"a": 3} -def test_sanitize_args_float(): - assert Parser.sanitize_args({"a": "3.1"}) == {"a": 3.1} - assert Parser.sanitize_args({"a": "1e-4"}) == {"a": 0.0001} +def test_parse_arg_types_float(): + assert Parser.parse_arg_types({"a": "3.1"}) == {"a": 3.1} + assert Parser.parse_arg_types({"a": "1e-4"}) == {"a": 0.0001} -def test_sanitize_args_bool(): - assert Parser.sanitize_args({"a": "true"}) == {"a": True} - assert Parser.sanitize_args({"a": "True"}) == {"a": True} - assert Parser.sanitize_args({"a": "false"}) == {"a": False} - assert Parser.sanitize_args({"a": "False"}) == {"a": False} +def test_parse_arg_types_bool(): + assert Parser.parse_arg_types({"a": "true"}) == {"a": True} + assert Parser.parse_arg_types({"a": "True"}) == {"a": True} + assert Parser.parse_arg_types({"a": "false"}) == {"a": False} + assert Parser.parse_arg_types({"a": "False"}) == {"a": False} -def test_sanitize_args_list(): - assert Parser.sanitize_args({"a": "[]"}) == {"a": []} - assert Parser.sanitize_args({"a": "[1, 2]"}) == {"a": [1, 2]} - assert Parser.sanitize_args({"a": "[1, 2, 'b']"}) == {"a": [1, 2, "b"]} - assert Parser.sanitize_args({"a": "[1, 2, 'b']", "c": 4}) == { +def test_parse_arg_types_list(): + assert Parser.parse_arg_types({"a": "[]"}) == {"a": []} + assert Parser.parse_arg_types({"a": "[1, 2]"}) == {"a": [1, 2]} + assert Parser.parse_arg_types({"a": "[1, 2, 'b']"}) == {"a": [1, 2, "b"]} + assert Parser.parse_arg_types({"a": "[1, 2, 'b']", "c": 4}) == { "a": [1, 2, "b"], "c": 4, } - assert Parser.sanitize_args({"a": "[1, [2, 3]]"}) == {"a": [1, [2, 3]]} - assert Parser.sanitize_args({"a": "[1, [2, 1e-3]]"}) == {"a": [1, [2, 0.001]]} - assert Parser.sanitize_args({"a": "['false']"}) == {"a": [False]} - assert Parser.sanitize_args({"a": "[False]"}) == {"a": [False]} + assert Parser.parse_arg_types({"a": "[1, [2, 3]]"}) == {"a": [1, [2, 3]]} + assert Parser.parse_arg_types({"a": "[1, [2, 1e-3]]"}) == {"a": [1, [2, 0.001]]} + assert Parser.parse_arg_types({"a": "['false']"}) == {"a": [False]} + assert Parser.parse_arg_types({"a": "[False]"}) == {"a": [False]} -def test_sanitize_args_dict(monkeypatch): - assert Parser.sanitize_args({"a": "{}"}) == {"a": {}} - assert Parser.sanitize_args({"a": "{1: 2}"}) == {"a": {1: 2}} - assert Parser.sanitize_args({"a": "{1: 2e-3}"}) == {"a": {1: 0.002}} - assert Parser.sanitize_args({"a": "{1: False}"}) == {"a": {1: False}} - assert Parser.sanitize_args({"a": "{1: [1, 3]}"}) == {"a": {1: [1, 3]}} +def test_parse_arg_types_dict(monkeypatch): + assert Parser.parse_arg_types({"a": "{}"}) == {"a": {}} + assert Parser.parse_arg_types({"a": "{1: 2}"}) == {"a": {1: 2}} + assert Parser.parse_arg_types({"a": "{1: 2e-3}"}) == {"a": {1: 0.002}} + assert Parser.parse_arg_types({"a": "{1: False}"}) == {"a": {1: False}} + assert Parser.parse_arg_types({"a": "{1: [1, 3]}"}) == {"a": {1: [1, 3]}} monkeypatch.setenv("USER", "TestingUser") - assert Parser.sanitize_args({"a": "{1: '$USER'}"}) == {"a": {1: "TestingUser"}} + assert Parser.parse_arg_types({"a": "{1: '$USER'}"}) == {"a": {1: "TestingUser"}}