argmerge 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- argmerge/__init__.py +6 -0
- argmerge/base.py +38 -0
- argmerge/cli.py +93 -0
- argmerge/decorator.py +134 -0
- argmerge/env.py +103 -0
- argmerge/func.py +126 -0
- argmerge/json.py +85 -0
- argmerge/py.typed +0 -0
- argmerge/trace.py +138 -0
- argmerge/utils.py +13 -0
- argmerge/yaml.py +87 -0
- argmerge-0.0.0.dist-info/METADATA +101 -0
- argmerge-0.0.0.dist-info/RECORD +15 -0
- argmerge-0.0.0.dist-info/WHEEL +4 -0
- argmerge-0.0.0.dist-info/licenses/LICENSE +21 -0
argmerge/__init__.py
ADDED
argmerge/base.py
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
"""Module that holds the Abstract Base Class for all external parsers."""
|
2
|
+
|
3
|
+
from abc import ABC, abstractmethod
|
4
|
+
|
5
|
+
__all__ = ["SourceParser"]
|
6
|
+
|
7
|
+
|
8
|
+
class SourceParser(ABC):
|
9
|
+
"""The base parser from which all other parsers are built.
|
10
|
+
|
11
|
+
To build a new parser, you will need to subclass this one. Set 'rank' and 'label'
|
12
|
+
as class variables and overwrite `__call__` with your parser. You MUST include
|
13
|
+
three parameters: `threshold_kwargs`, `ledger`, and `debug`. This will allow you
|
14
|
+
to add your own with other keyword arguments. You can look at any of the existing
|
15
|
+
subclasses for how this works. After you build your subclass, you will instantiate
|
16
|
+
it, setting it equal to a variable, ideally beginning with `'parse_'`. This will
|
17
|
+
allow you to treat it like a function by invoking the `__call__` method, which calls
|
18
|
+
your parser.
|
19
|
+
|
20
|
+
Args:
|
21
|
+
rank (int): The priority of the parser. Generally, we aim between [0,100] for
|
22
|
+
human-readabilty.
|
23
|
+
label (str): The debugging label to indicate an argument was set at the
|
24
|
+
<source level>.
|
25
|
+
"""
|
26
|
+
|
27
|
+
rank: int = -100
|
28
|
+
label: str = ""
|
29
|
+
|
30
|
+
def __init__(cls):
|
31
|
+
cls.label
|
32
|
+
cls.rank
|
33
|
+
|
34
|
+
@abstractmethod
|
35
|
+
def __call__(
|
36
|
+
cls, threshold_kwargs: dict, ledger: dict, debug: bool = False, **kwargs
|
37
|
+
) -> tuple[dict, dict]:
|
38
|
+
"""This is too abstract to be covered"""
|
argmerge/cli.py
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
"""Module that provides a flexible CLI parser component in the decorator.
|
2
|
+
|
3
|
+
|
4
|
+
```py
|
5
|
+
CLI_PATTERN: re.Pattern = re.compile(r"--([A-Za-z_-]+)=([0-9A-Za-z_-\.]+)")
|
6
|
+
```
|
7
|
+
|
8
|
+
- matches `'--arg=value'`
|
9
|
+
- does not match `'--arg value'`
|
10
|
+
"""
|
11
|
+
|
12
|
+
import re
|
13
|
+
import sys
|
14
|
+
from typing import Any
|
15
|
+
|
16
|
+
from loguru import logger as LOGGER
|
17
|
+
|
18
|
+
from argmerge.base import SourceParser
|
19
|
+
|
20
|
+
__all__ = ["CLI_PATTERN", "parse_cli"]
|
21
|
+
|
22
|
+
# matches '--arg=value'
|
23
|
+
# does not match '--arg value'
|
24
|
+
CLI_PATTERN: re.Pattern = re.compile(r"--([A-Za-z_-]+)=([0-9A-Za-z_-\.]+)")
|
25
|
+
|
26
|
+
|
27
|
+
class CLIParser(SourceParser):
|
28
|
+
"""The parser the extracts relevant CLI arguments.
|
29
|
+
|
30
|
+
params:
|
31
|
+
label (str): The debugging label to indicate an argument was set at the CLI.
|
32
|
+
rank (int): The priority of the parser. Generally, we aim between [0,100] for
|
33
|
+
human-readabilty.
|
34
|
+
|
35
|
+
"""
|
36
|
+
|
37
|
+
label: str = "CLI"
|
38
|
+
rank: int = 40
|
39
|
+
|
40
|
+
def __call__(
|
41
|
+
cls,
|
42
|
+
threshold_kwargs: dict[str, Any],
|
43
|
+
change_ledger: dict[str, dict[str, str | int]],
|
44
|
+
cli_pattern: re.Pattern[str] = CLI_PATTERN,
|
45
|
+
debug: bool = False,
|
46
|
+
**kwargs,
|
47
|
+
) -> tuple[dict, dict]:
|
48
|
+
"""Parse the CLI commands using the cli_pattern regular expression.
|
49
|
+
|
50
|
+
Args:
|
51
|
+
threshold_kwargs (dict[str, Any]): kwargs passed around the
|
52
|
+
@threshold decorator.
|
53
|
+
change_ledger (dict[str, dict[str, str | int]]): Tracks when kwargs are
|
54
|
+
updated inside the @threshold decorator.
|
55
|
+
cli_pattern (re.Pattern[str], optional): The regular expression pattern
|
56
|
+
used to extract arguments from the CLI. Defaults to CLI_PATTERN.
|
57
|
+
debug (bool, optional): Flag to turn on more logging. Defaults to False.
|
58
|
+
|
59
|
+
Returns:
|
60
|
+
tuple[dict, dict]: an updated `threshold_kwargs` and `change_ledger`.
|
61
|
+
"""
|
62
|
+
_cli_kwargs: dict
|
63
|
+
_cli_input: str
|
64
|
+
|
65
|
+
if debug:
|
66
|
+
LOGGER.remove()
|
67
|
+
LOGGER.add(sys.stderr, level="DEBUG")
|
68
|
+
|
69
|
+
if isinstance(cli_pattern, re.Pattern):
|
70
|
+
_cli_pattern = cli_pattern
|
71
|
+
|
72
|
+
else:
|
73
|
+
_cli_pattern = re.compile(rf"{cli_pattern}")
|
74
|
+
|
75
|
+
LOGGER.debug(f"{cli_pattern=}")
|
76
|
+
LOGGER.debug(f"{_cli_pattern=}")
|
77
|
+
LOGGER.debug(f"{sys.argv=}")
|
78
|
+
_cli_input = " ".join(sys.argv[1:])
|
79
|
+
LOGGER.debug(f"{_cli_input}")
|
80
|
+
|
81
|
+
_cli_kwargs = dict(_cli_pattern.findall(_cli_input))
|
82
|
+
LOGGER.debug(f"{_cli_kwargs=}")
|
83
|
+
|
84
|
+
threshold_kwargs.update(_cli_kwargs)
|
85
|
+
LOGGER.debug(f"Updated {threshold_kwargs=}")
|
86
|
+
|
87
|
+
for key in _cli_kwargs:
|
88
|
+
change_ledger[key] = {"label": cls.label, "rank": cls.rank}
|
89
|
+
|
90
|
+
return threshold_kwargs, change_ledger
|
91
|
+
|
92
|
+
|
93
|
+
parse_cli = CLIParser()
|
argmerge/decorator.py
ADDED
@@ -0,0 +1,134 @@
|
|
1
|
+
"""Module that contains the main decorator, `@threshold`.
|
2
|
+
|
3
|
+
Usage:
|
4
|
+
```py
|
5
|
+
# main.py
|
6
|
+
from argmerge import threshold
|
7
|
+
|
8
|
+
@threshold
|
9
|
+
def main(first: int, second: str, third: float = 3.0):
|
10
|
+
...
|
11
|
+
|
12
|
+
if __name__ == '__main__':
|
13
|
+
main()
|
14
|
+
```
|
15
|
+
Many more examples of how to use this can be found in [the Examples section](/examples/)
|
16
|
+
"""
|
17
|
+
|
18
|
+
import functools
|
19
|
+
import re
|
20
|
+
from pathlib import Path
|
21
|
+
|
22
|
+
from argmerge.cli import CLI_PATTERN, parse_cli
|
23
|
+
from argmerge.env import ENV_PREFIX, parse_env
|
24
|
+
from argmerge.func import parse_func, parse_func_runtime
|
25
|
+
from argmerge.json import parse_json
|
26
|
+
from argmerge.trace import LOG_LEVELS, trace_arg_lineage
|
27
|
+
from argmerge.yaml import parse_yaml
|
28
|
+
|
29
|
+
|
30
|
+
def threshold(
|
31
|
+
*args,
|
32
|
+
fpath_json: str | Path = "",
|
33
|
+
fpath_yaml: str | Path = "",
|
34
|
+
env_prefix: str | re.Pattern[str] = ENV_PREFIX, # 'THRESH', also set at PYTHRESH
|
35
|
+
cli_pattern: str | re.Pattern[str] = CLI_PATTERN,
|
36
|
+
trace_level: str = "",
|
37
|
+
debug: bool = False,
|
38
|
+
**kwargs,
|
39
|
+
):
|
40
|
+
"""Merge arguments from external environment sources into the program.
|
41
|
+
|
42
|
+
We allow syntax of both @threshold and @threshold(), depending whether you want to
|
43
|
+
allow for defaults or override them.
|
44
|
+
|
45
|
+
Args:
|
46
|
+
fpath_json (str | Path, optional): The path to find a JSON configuration file.
|
47
|
+
Defaults to "".
|
48
|
+
fpath_yaml (str | Path, optional): The path to find a YAML configuration file.
|
49
|
+
Defaults to "".
|
50
|
+
env_prefix (str | re.Pattern[str], optional): The string or Regex to match
|
51
|
+
environment variables against. Defaults to ENV_PREFIX, which is 'THRESH'.
|
52
|
+
cli_pattern (str | re.Pattern[str], optional): The string or Regex to match
|
53
|
+
CLI arguments against. Defaults to CLI_PATTERN.
|
54
|
+
trace_level (str, optional): Trace the source of each kwarg and display at the
|
55
|
+
specified trace log level. Defaults to "", which skips the trace entirely.
|
56
|
+
debug (bool, optional): Turns on debugging for all the parsers.
|
57
|
+
Defaults to False.
|
58
|
+
|
59
|
+
Raises:
|
60
|
+
ValueError: `level` must be in one of the default loguru logger levels:
|
61
|
+
`("critical", "warning", "success", "info", "debug")`
|
62
|
+
|
63
|
+
Returns:
|
64
|
+
callable: A wrapped, yet-to-be-called function with resolved arguments.
|
65
|
+
"""
|
66
|
+
if len(args) == 1:
|
67
|
+
# allow syntax of @threshold and @threshold()
|
68
|
+
return threshold()(args[0])
|
69
|
+
|
70
|
+
else:
|
71
|
+
|
72
|
+
def wrapped(f):
|
73
|
+
@functools.wraps(f)
|
74
|
+
def wrapped_f(*_args, **_kwargs):
|
75
|
+
_threshold_kwargs, _change_ledger = dict(), dict()
|
76
|
+
_threshold_kwargs, _change_ledger = parse_func(
|
77
|
+
_threshold_kwargs, _change_ledger, f, debug=debug
|
78
|
+
)
|
79
|
+
|
80
|
+
_threshold_kwargs, _change_ledger = parse_json(
|
81
|
+
_threshold_kwargs,
|
82
|
+
_change_ledger,
|
83
|
+
fpath_json=fpath_json,
|
84
|
+
debug=debug,
|
85
|
+
)
|
86
|
+
|
87
|
+
_threshold_kwargs, _change_ledger = parse_yaml(
|
88
|
+
_threshold_kwargs,
|
89
|
+
_change_ledger,
|
90
|
+
fpath_yaml=fpath_yaml,
|
91
|
+
debug=debug,
|
92
|
+
)
|
93
|
+
|
94
|
+
_threshold_kwargs, _change_ledger = parse_env(
|
95
|
+
_threshold_kwargs,
|
96
|
+
_change_ledger,
|
97
|
+
env_prefix=env_prefix,
|
98
|
+
debug=debug,
|
99
|
+
)
|
100
|
+
|
101
|
+
_threshold_kwargs, _change_ledger = parse_cli(
|
102
|
+
_threshold_kwargs,
|
103
|
+
_change_ledger,
|
104
|
+
cli_pattern=cli_pattern,
|
105
|
+
debug=debug,
|
106
|
+
)
|
107
|
+
|
108
|
+
_threshold_kwargs, _change_ledger = parse_func_runtime(
|
109
|
+
_threshold_kwargs, _change_ledger, func_kwargs=_kwargs, debug=debug
|
110
|
+
)
|
111
|
+
|
112
|
+
if trace_level.lower() in LOG_LEVELS:
|
113
|
+
trace_arg_lineage(
|
114
|
+
f,
|
115
|
+
_change_ledger,
|
116
|
+
level=trace_level,
|
117
|
+
)
|
118
|
+
|
119
|
+
elif trace_level == "":
|
120
|
+
# default behavior
|
121
|
+
pass
|
122
|
+
|
123
|
+
else:
|
124
|
+
raise ValueError(
|
125
|
+
f"'trace_level' has been set to '{trace_level}', which is not "
|
126
|
+
"supported. Please set 'trace_level' to an empty string or one"
|
127
|
+
f" of: {LOG_LEVELS}."
|
128
|
+
)
|
129
|
+
|
130
|
+
return f(*_args, **_threshold_kwargs)
|
131
|
+
|
132
|
+
return wrapped_f
|
133
|
+
|
134
|
+
return wrapped
|
argmerge/env.py
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
# cython: linetrace=True
|
2
|
+
# distutils: define_macros=CYTHON_TRACE=1
|
3
|
+
import os
|
4
|
+
import re
|
5
|
+
import sys
|
6
|
+
from typing import Any
|
7
|
+
|
8
|
+
from loguru import logger as LOGGER
|
9
|
+
|
10
|
+
from argmerge.base import SourceParser
|
11
|
+
from argmerge.utils import extract_literals
|
12
|
+
|
13
|
+
__all__ = ["ENV_PREFIX", "parse_env"]
|
14
|
+
|
15
|
+
ENV_PREFIX = os.environ.get("PYTHRESH", "THRESH")
|
16
|
+
|
17
|
+
|
18
|
+
class EnvParser(SourceParser):
|
19
|
+
"""The parser the extracts relevant environment variables.
|
20
|
+
|
21
|
+
Args:
|
22
|
+
label (str): The debugging label to indicate an argument was set by environment
|
23
|
+
variables.
|
24
|
+
rank (int): The priority of the parser. Generally, we aim between [0,100] for
|
25
|
+
human-readabilty.
|
26
|
+
"""
|
27
|
+
|
28
|
+
rank: int = 30
|
29
|
+
label: str = "Environment Variable"
|
30
|
+
|
31
|
+
def __call__(
|
32
|
+
cls,
|
33
|
+
threshold_kwargs: dict[str, Any],
|
34
|
+
change_ledger: dict[str, dict[str, str | int]],
|
35
|
+
env_prefix: str | re.Pattern[str] = ENV_PREFIX,
|
36
|
+
debug=False,
|
37
|
+
**kwargs,
|
38
|
+
):
|
39
|
+
"""Parse the environment variables using the `env_prefix` and update inputs.
|
40
|
+
|
41
|
+
Args:
|
42
|
+
threshold_kwargs (dict[str, Any]): kwargs passed around the
|
43
|
+
@threshold decorator.
|
44
|
+
change_ledger (dict[str, dict[str, str | int]]): Tracks when kwargs are
|
45
|
+
updated inside the @threshold decorator.
|
46
|
+
env_prefix (str | re.Pattern[str], optional): The prefix used to search for
|
47
|
+
set environment variables. Defaults to ENV_PREFIX, which is 'THRESH_'.
|
48
|
+
debug (bool, optional): Flag to turn on more logging. Defaults to False.
|
49
|
+
|
50
|
+
Raises:
|
51
|
+
ValueError: `env_prefix` must either be a string or Regex string pattern.
|
52
|
+
|
53
|
+
Returns:
|
54
|
+
tuple[dict, dict]: an updated `threshold_kwargs` and `change_ledger`.
|
55
|
+
"""
|
56
|
+
if debug:
|
57
|
+
LOGGER.remove()
|
58
|
+
LOGGER.add(sys.stderr, level="DEBUG")
|
59
|
+
|
60
|
+
if isinstance(env_prefix, re.Pattern):
|
61
|
+
pattern = env_prefix
|
62
|
+
|
63
|
+
elif isinstance(env_prefix, str):
|
64
|
+
pattern = re.compile(rf"(?:{env_prefix.upper()}.)([A-Za-z0\-\_]+)")
|
65
|
+
|
66
|
+
else:
|
67
|
+
raise ValueError(
|
68
|
+
f"'env_prefix' must be either a string or Regex string pattern. Received: {env_prefix} ({type(env_prefix)})."
|
69
|
+
)
|
70
|
+
|
71
|
+
LOGGER.debug(f"{env_prefix=}")
|
72
|
+
LOGGER.debug(f"{pattern=}")
|
73
|
+
|
74
|
+
_env_kwargs = {}
|
75
|
+
|
76
|
+
for k, v in os.environ.items():
|
77
|
+
_search = pattern.search(k)
|
78
|
+
|
79
|
+
if _search is not None:
|
80
|
+
try:
|
81
|
+
key = _search.group(1).lower()
|
82
|
+
LOGGER.debug(f"{key=} {v=}")
|
83
|
+
_env_kwargs[key] = extract_literals(v)
|
84
|
+
|
85
|
+
except IndexError:
|
86
|
+
LOGGER.debug(f"Regex search failed on environment variable {k}.")
|
87
|
+
|
88
|
+
else:
|
89
|
+
LOGGER.debug(f"Miss: {k=}")
|
90
|
+
|
91
|
+
LOGGER.debug(f"{_env_kwargs=}")
|
92
|
+
threshold_kwargs.update(_env_kwargs)
|
93
|
+
|
94
|
+
LOGGER.debug(f"Updated {threshold_kwargs=}")
|
95
|
+
|
96
|
+
for key in _env_kwargs:
|
97
|
+
change_ledger[key] = {"label": cls.label, "rank": cls.rank}
|
98
|
+
|
99
|
+
return threshold_kwargs, change_ledger
|
100
|
+
|
101
|
+
|
102
|
+
# Make EnvParser appear as a function when it uses __call__.
|
103
|
+
parse_env = EnvParser()
|
argmerge/func.py
ADDED
@@ -0,0 +1,126 @@
|
|
1
|
+
"""Module to work with retrieving arguments functions"""
|
2
|
+
|
3
|
+
import sys
|
4
|
+
from inspect import Parameter, signature
|
5
|
+
from typing import Any, Callable
|
6
|
+
|
7
|
+
from loguru import logger as LOGGER
|
8
|
+
|
9
|
+
from argmerge.base import SourceParser
|
10
|
+
|
11
|
+
__all__ = ["parse_func", "parse_func_runtime"]
|
12
|
+
|
13
|
+
|
14
|
+
class FuncDefaultParser(SourceParser):
|
15
|
+
"""Builds the parser to extract default arguments from a function signature
|
16
|
+
|
17
|
+
This is the lowest-ranked parser.
|
18
|
+
|
19
|
+
params:
|
20
|
+
label (str): The debugging label to indicate an argument was set at the CLI.
|
21
|
+
rank (int): The priority of the parser. Generally, we aim between [0,100] for
|
22
|
+
human-readabilty.
|
23
|
+
|
24
|
+
"""
|
25
|
+
|
26
|
+
label: str = "Python Function Default"
|
27
|
+
rank: int = 0
|
28
|
+
|
29
|
+
def __call__(
|
30
|
+
cls,
|
31
|
+
threshold_kwargs: dict,
|
32
|
+
change_ledger: dict,
|
33
|
+
f: Callable,
|
34
|
+
debug: bool = False,
|
35
|
+
) -> tuple[dict, dict]:
|
36
|
+
"""Lowest level parser - retrieve function defaults as fallback arguments.
|
37
|
+
|
38
|
+
Args:
|
39
|
+
threshold_kwargs (dict[str, Any]): kwargs passed around the
|
40
|
+
@threshold decorator.
|
41
|
+
change_ledger (dict[str, dict[str, str | int]]): Tracks when kwargs are
|
42
|
+
updated inside the @threshold decorator.
|
43
|
+
f (Callable): The function we wrap.
|
44
|
+
debug (bool, optional): Flag to turn on more logging. Defaults to False.
|
45
|
+
|
46
|
+
|
47
|
+
Returns:
|
48
|
+
tuple[dict, dict]: an updated `threshold_kwargs` and `change_ledger`.
|
49
|
+
"""
|
50
|
+
|
51
|
+
if debug:
|
52
|
+
LOGGER.remove()
|
53
|
+
LOGGER.add(sys.stderr, level="DEBUG")
|
54
|
+
|
55
|
+
_sig = signature(f)
|
56
|
+
LOGGER.debug(f"Function {signature=}")
|
57
|
+
_default: dict = {}
|
58
|
+
|
59
|
+
for k, v in _sig.parameters.items():
|
60
|
+
if v.default is not Parameter.empty:
|
61
|
+
_default[k] = v.default
|
62
|
+
|
63
|
+
LOGGER.debug(f"{_default=}")
|
64
|
+
for k in _default:
|
65
|
+
change_ledger[k] = {"label": cls.label, "rank": cls.rank}
|
66
|
+
|
67
|
+
threshold_kwargs.update(**_default)
|
68
|
+
|
69
|
+
return threshold_kwargs, change_ledger
|
70
|
+
|
71
|
+
|
72
|
+
parse_func = FuncDefaultParser()
|
73
|
+
|
74
|
+
|
75
|
+
class FuncUpdater(SourceParser):
|
76
|
+
"""Builds the parser to extract default arguments from a function signature
|
77
|
+
|
78
|
+
This is the highest-ranked parser.
|
79
|
+
|
80
|
+
params:
|
81
|
+
label (str): The debugging label to indicate an argument was set at Runtime by
|
82
|
+
the developer.
|
83
|
+
rank (int): The priority of the parser. Generally, we aim between [0,100] for
|
84
|
+
human-readabilty.
|
85
|
+
"""
|
86
|
+
|
87
|
+
label: str = "Developer-provided"
|
88
|
+
rank: int = 100
|
89
|
+
|
90
|
+
def __call__(
|
91
|
+
cls,
|
92
|
+
threshold_kwargs: dict[str, Any],
|
93
|
+
change_ledger: dict[str, dict[str, str | int]],
|
94
|
+
func_kwargs: dict[str, Any],
|
95
|
+
debug: bool = False,
|
96
|
+
) -> tuple[dict, dict]:
|
97
|
+
"""Update the external values with the function's runtime arguments.
|
98
|
+
|
99
|
+
Args:
|
100
|
+
threshold_kwargs (dict[str, Any]): kwargs passed around the
|
101
|
+
@threshold decorator.
|
102
|
+
change_ledger (dict[str, dict[str, str | int]]): Tracks when kwargs are
|
103
|
+
updated inside the @threshold decorator.
|
104
|
+
func_kwargs (dict[str, Any]): The Runtime kwargs of the function.
|
105
|
+
debug (bool, optional): Flag to turn on more logging. Defaults to False.
|
106
|
+
|
107
|
+
Returns:
|
108
|
+
Returns:
|
109
|
+
tuple[dict, dict]: an updated `threshold_kwargs` and `change_ledger`.
|
110
|
+
"""
|
111
|
+
if debug:
|
112
|
+
LOGGER.remove()
|
113
|
+
LOGGER.add(sys.stderr, level="DEBUG")
|
114
|
+
|
115
|
+
LOGGER.debug(f"{threshold_kwargs=}")
|
116
|
+
LOGGER.debug(f"{func_kwargs=}")
|
117
|
+
|
118
|
+
threshold_kwargs.update(**func_kwargs)
|
119
|
+
|
120
|
+
for key in func_kwargs:
|
121
|
+
change_ledger[key] = {"label": cls.label, "rank": cls.rank}
|
122
|
+
|
123
|
+
return threshold_kwargs, change_ledger
|
124
|
+
|
125
|
+
|
126
|
+
parse_func_runtime = FuncUpdater()
|
argmerge/json.py
ADDED
@@ -0,0 +1,85 @@
|
|
1
|
+
import json
|
2
|
+
import sys
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import Any
|
5
|
+
|
6
|
+
from loguru import logger as LOGGER
|
7
|
+
|
8
|
+
from argmerge.base import SourceParser
|
9
|
+
|
10
|
+
__all__ = ["parse_json"]
|
11
|
+
|
12
|
+
|
13
|
+
class JSONParser(SourceParser):
|
14
|
+
"""The parser the extracts relevant arguments from a JSON file.
|
15
|
+
|
16
|
+
params:
|
17
|
+
label (str): The debugging label to indicate an argument was set in a JSON
|
18
|
+
config file.
|
19
|
+
rank (int): The priority of the parser. Generally, we aim between [0,100] for
|
20
|
+
human-readabilty.
|
21
|
+
|
22
|
+
"""
|
23
|
+
|
24
|
+
label: str = "JSON"
|
25
|
+
rank: int = 10
|
26
|
+
|
27
|
+
def __call__(
|
28
|
+
cls,
|
29
|
+
threshold_kwargs: dict[str, Any],
|
30
|
+
change_ledger: dict[str, dict[str, str | int]],
|
31
|
+
fpath_json: str | Path,
|
32
|
+
debug: bool = False,
|
33
|
+
) -> tuple[dict, dict]:
|
34
|
+
"""Parse a JSON configuration file for arguments
|
35
|
+
|
36
|
+
Args:
|
37
|
+
threshold_kwargs (dict[str, Any]): kwargs passed around the
|
38
|
+
@threshold decorator.
|
39
|
+
change_ledger (dict[str, dict[str, str | int]]): Tracks when kwargs are
|
40
|
+
updated inside the @threshold decorator.
|
41
|
+
fpath_json (str | Path): The filepath to the JSON configuration file.
|
42
|
+
debug (bool, optional): Flag to turn on more logging. Defaults to False.
|
43
|
+
|
44
|
+
Raises:
|
45
|
+
ValueError: If filepath extension is not `json`.
|
46
|
+
|
47
|
+
Returns:
|
48
|
+
tuple[dict, dict]: an updated `threshold_kwargs` and `change_ledger`.
|
49
|
+
"""
|
50
|
+
_json_kwargs: dict
|
51
|
+
|
52
|
+
if debug:
|
53
|
+
LOGGER.remove()
|
54
|
+
LOGGER.add(sys.stderr, level="DEBUG")
|
55
|
+
|
56
|
+
LOGGER.debug(f"{threshold_kwargs=}")
|
57
|
+
LOGGER.debug(f"{fpath_json=}")
|
58
|
+
|
59
|
+
_fpath_json = Path(fpath_json)
|
60
|
+
if _fpath_json == Path(""):
|
61
|
+
LOGGER.debug("fpath_json not provided, skipping.")
|
62
|
+
|
63
|
+
else:
|
64
|
+
if _fpath_json.suffix != ".json":
|
65
|
+
raise ValueError(
|
66
|
+
f"The JSON suffix of '{_fpath_json.as_posix()}' is not correct."
|
67
|
+
" Please use '.json'."
|
68
|
+
)
|
69
|
+
|
70
|
+
cls.label = f"JSON ({_fpath_json})"
|
71
|
+
|
72
|
+
with open(fpath_json, "rb") as fy:
|
73
|
+
_json_kwargs = json.load(fy)
|
74
|
+
|
75
|
+
LOGGER.debug(f"{_json_kwargs=}")
|
76
|
+
threshold_kwargs.update(_json_kwargs)
|
77
|
+
LOGGER.debug(f"Updated {threshold_kwargs=}")
|
78
|
+
|
79
|
+
for key in _json_kwargs:
|
80
|
+
change_ledger[key] = {"label": cls.label, "rank": cls.rank}
|
81
|
+
|
82
|
+
return threshold_kwargs, change_ledger
|
83
|
+
|
84
|
+
|
85
|
+
parse_json = JSONParser()
|
argmerge/py.typed
ADDED
File without changes
|
argmerge/trace.py
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
"""Module to write the source of each function keyword argument for the developer
|
2
|
+
|
3
|
+
Example:
|
4
|
+
|
5
|
+
$ uv run main.py # trace_level=DEBUG set in the program
|
6
|
+
2025-10-14 16:15:33.466 | DEBUG | argmerge.trace:_write_trace:50 -
|
7
|
+
Parameter Name | Source
|
8
|
+
=======================================
|
9
|
+
third | Python Function default
|
10
|
+
fourth | Python Function default
|
11
|
+
fifth | Python Function default
|
12
|
+
first | developer-provided
|
13
|
+
second | developer-provided
|
14
|
+
=======================================
|
15
|
+
"""
|
16
|
+
|
17
|
+
import sys
|
18
|
+
from inspect import signature
|
19
|
+
from typing import Callable
|
20
|
+
|
21
|
+
from loguru import logger as LOGGER
|
22
|
+
|
23
|
+
LOG_LEVELS = ("critical", "warning", "success", "info", "debug")
|
24
|
+
|
25
|
+
|
26
|
+
def _write_trace(message: str, level: str):
|
27
|
+
"""Write the trace message out to the specified log level.
|
28
|
+
|
29
|
+
args:
|
30
|
+
message (str): The debugging message for the developer.
|
31
|
+
level (str): The `loguru` log level to set.
|
32
|
+
|
33
|
+
raises:
|
34
|
+
TypeError: `level` must be a string.
|
35
|
+
ValueError: `level` must be in one of the default loguru logger levels:
|
36
|
+
`("critical", "warning", "success", "info", "debug")`
|
37
|
+
"""
|
38
|
+
if isinstance(level, str):
|
39
|
+
_level = level.lower()
|
40
|
+
|
41
|
+
else:
|
42
|
+
raise TypeError(f"Log level '{level}' ({type(level)}) is not a string.")
|
43
|
+
|
44
|
+
if _level not in LOG_LEVELS:
|
45
|
+
raise ValueError(
|
46
|
+
f"Log level '{_level}' not in basic loguru log levels: '{LOG_LEVELS}''."
|
47
|
+
)
|
48
|
+
|
49
|
+
_trace_logger: int = LOGGER.add(sys.stderr, level=level.upper())
|
50
|
+
|
51
|
+
getattr(LOGGER, _level.lower())(message)
|
52
|
+
|
53
|
+
LOGGER.remove(_trace_logger)
|
54
|
+
|
55
|
+
|
56
|
+
def _log_trace(ledger: dict[str, dict[str, str | int]], level: str = ""):
|
57
|
+
"""Compose a developer-friendly report of each kwarg's source form the ledger.
|
58
|
+
|
59
|
+
First, sort the keys by their ranks. Ranks are sorted in ascending order such that
|
60
|
+
the highest ranked sources (Developer-Provided, etc) will be seen first. Then sort
|
61
|
+
the labels by that same order. Next, we will dynamically produce the report. We
|
62
|
+
will find the kwarg and source each with the longest length. Then, left-justify
|
63
|
+
(ljust) each kwarg and source to fill the space. Repeat for each kwarg-source pair.
|
64
|
+
We then set the heading such that the '=' characters "hang" over the report,
|
65
|
+
visually grouping them.
|
66
|
+
|
67
|
+
Args:
|
68
|
+
ledger (dict[str, dict[str, str | int]]): The change ledger that describes
|
69
|
+
each kwarg's highest-ranked source.
|
70
|
+
level (str, optional): A `loguru` logging level. Defaults to "", which means
|
71
|
+
'DEBUG' will be set.
|
72
|
+
"""
|
73
|
+
|
74
|
+
if len(ledger) == 0:
|
75
|
+
LOGGER.warning("Change ledger is empty, will not write out!")
|
76
|
+
|
77
|
+
else:
|
78
|
+
# split the ranks from the labels
|
79
|
+
ranks: dict[str, int] = {k: int(v["rank"]) for k, v in ledger.items()}
|
80
|
+
labels: dict[str, str] = {k: str(v["label"]) for k, v in ledger.items()}
|
81
|
+
|
82
|
+
# First, sort the keys by their ranks.
|
83
|
+
sorted_keys = [x for x, _ in sorted(ranks.items(), key=lambda x: x[1])]
|
84
|
+
sorted_labels = {k: labels[k] for k in sorted_keys}
|
85
|
+
|
86
|
+
# calculate the longest kwarg name and source name
|
87
|
+
_key_spacing: int = max(list(map(len, sorted_labels.keys())))
|
88
|
+
_value_spacing: int = max(list(map(len, sorted_labels.values())))
|
89
|
+
|
90
|
+
# left-justify
|
91
|
+
_pre_join_spacing = {
|
92
|
+
k.ljust(_key_spacing, " "): v.ljust(_value_spacing, " ")
|
93
|
+
for k, v in sorted_labels.items()
|
94
|
+
}
|
95
|
+
|
96
|
+
# stringified, left-justified kwargs and sources
|
97
|
+
_params = [f"{k}\t| {v}" for k, v in _pre_join_spacing.items()]
|
98
|
+
|
99
|
+
# set up the heading
|
100
|
+
# heading will extend over the params
|
101
|
+
_heading_spacing: int = max(map(len, _params)) + 7
|
102
|
+
|
103
|
+
_heading_param = "Parameter Name".ljust(_key_spacing)
|
104
|
+
_heading_loc = "Source".ljust(_value_spacing)
|
105
|
+
|
106
|
+
# construct the full heading
|
107
|
+
_heading = f"{_heading_param}\t| {_heading_loc}"
|
108
|
+
|
109
|
+
_body = "\n".join(_params)
|
110
|
+
|
111
|
+
# combine the heading with the body
|
112
|
+
msg = (
|
113
|
+
f"\n{_heading}\n{'=' * _heading_spacing}\n{_body}\n{'=' * _heading_spacing}"
|
114
|
+
)
|
115
|
+
|
116
|
+
# log it to the appropriate level
|
117
|
+
_write_trace(message=msg, level=level)
|
118
|
+
|
119
|
+
|
120
|
+
def trace_arg_lineage(
|
121
|
+
f: Callable,
|
122
|
+
change_ledger: dict[str, dict[str, str | int]],
|
123
|
+
level: str = "",
|
124
|
+
):
|
125
|
+
"""Determine where each argument in the function came from.
|
126
|
+
|
127
|
+
Only include arguments that exist in the function header. If a function accepts
|
128
|
+
**kwargs and an irrelevant keyword is provided - discard it.
|
129
|
+
|
130
|
+
args:
|
131
|
+
f (callable):
|
132
|
+
change_ledger (dict): The final dictionary detailing where every argument
|
133
|
+
is set - defaults, files, environment variables, CLI arguments, etc.
|
134
|
+
"""
|
135
|
+
sig = signature(f)
|
136
|
+
_changed = {k: v for k, v in change_ledger.items() if k in sig.parameters}
|
137
|
+
|
138
|
+
_log_trace(ledger=_changed, level=level)
|
argmerge/utils.py
ADDED
argmerge/yaml.py
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
# cython: linetrace=True
|
2
|
+
# distutils: define_macros=CYTHON_TRACE=1
|
3
|
+
|
4
|
+
import sys
|
5
|
+
from pathlib import Path
|
6
|
+
|
7
|
+
import yaml
|
8
|
+
from loguru import logger as LOGGER
|
9
|
+
|
10
|
+
from argmerge.base import SourceParser
|
11
|
+
|
12
|
+
__all__ = ["parse_yaml"]
|
13
|
+
|
14
|
+
|
15
|
+
class YAMLParser(SourceParser):
|
16
|
+
"""The parser the extracts relevant arguments from a YAML file.
|
17
|
+
|
18
|
+
params:
|
19
|
+
label (str): The debugging label to indicate an argument was set in a YAML
|
20
|
+
config file.
|
21
|
+
rank (int): The priority of the parser. Generally, we aim between [0,100] for
|
22
|
+
human-readabilty.
|
23
|
+
|
24
|
+
"""
|
25
|
+
|
26
|
+
label: str = "YAML"
|
27
|
+
rank: int = 20
|
28
|
+
|
29
|
+
def __call__(
|
30
|
+
cls,
|
31
|
+
threshold_kwargs: dict[str, str],
|
32
|
+
change_ledger: dict[str, dict[str, str | int]],
|
33
|
+
fpath_yaml: str | Path,
|
34
|
+
debug: bool = False,
|
35
|
+
) -> tuple[dict, dict]:
|
36
|
+
"""Parse a YAML configuration file for arguments
|
37
|
+
|
38
|
+
Args:
|
39
|
+
threshold_kwargs (dict[str, Any]): kwargs passed around the
|
40
|
+
@threshold decorator.
|
41
|
+
change_ledger (dict[str, dict[str, str | int]]): Tracks when kwargs are
|
42
|
+
updated inside the @threshold decorator.
|
43
|
+
fpath_yaml (str | Path): The filepath to the YAML configuration file.
|
44
|
+
debug (bool, optional): Flag to turn on more logging. Defaults to False.
|
45
|
+
|
46
|
+
Raises:
|
47
|
+
ValueError: If filepath extension is not `yml` or `yaml`.
|
48
|
+
|
49
|
+
Returns:
|
50
|
+
tuple[dict, dict]: an updated `threshold_kwargs` and `change_ledger`.
|
51
|
+
"""
|
52
|
+
_yaml_kwargs: dict
|
53
|
+
|
54
|
+
if debug:
|
55
|
+
LOGGER.remove()
|
56
|
+
LOGGER.add(sys.stderr, level="DEBUG")
|
57
|
+
|
58
|
+
LOGGER.debug(f"{threshold_kwargs=}")
|
59
|
+
LOGGER.debug(f"{fpath_yaml=}")
|
60
|
+
|
61
|
+
_fpath_yaml = Path(fpath_yaml)
|
62
|
+
if _fpath_yaml == Path(""):
|
63
|
+
LOGGER.debug("fpath_yaml not provided, skipping.")
|
64
|
+
|
65
|
+
else:
|
66
|
+
if _fpath_yaml.suffix not in (".yml", ".yaml"):
|
67
|
+
raise ValueError(
|
68
|
+
f"The YAML suffix of '{_fpath_yaml.suffix}' is not correct."
|
69
|
+
" Please use '.yml' or '.yaml'."
|
70
|
+
)
|
71
|
+
|
72
|
+
cls.label = f"YAML ({_fpath_yaml})"
|
73
|
+
|
74
|
+
with open(fpath_yaml, "rb") as fy:
|
75
|
+
_yaml_kwargs = yaml.safe_load(fy)
|
76
|
+
|
77
|
+
LOGGER.debug(f"{_yaml_kwargs=}")
|
78
|
+
threshold_kwargs.update(_yaml_kwargs)
|
79
|
+
LOGGER.debug(f"Updated {threshold_kwargs=}")
|
80
|
+
|
81
|
+
for key in _yaml_kwargs:
|
82
|
+
change_ledger[key] = {"label": cls.label, "rank": cls.rank}
|
83
|
+
|
84
|
+
return threshold_kwargs, change_ledger
|
85
|
+
|
86
|
+
|
87
|
+
parse_yaml = YAMLParser()
|
@@ -0,0 +1,101 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: argmerge
|
3
|
+
Version: 0.0.0
|
4
|
+
Summary: Add your description here
|
5
|
+
Author-email: duck-bongos <billmannd@gmail.com>
|
6
|
+
License-File: LICENSE
|
7
|
+
Requires-Python: >=3.10
|
8
|
+
Requires-Dist: loguru>=0.7.3
|
9
|
+
Requires-Dist: mkdocs-material>=9.6.21
|
10
|
+
Requires-Dist: pydantic>=2.12.2
|
11
|
+
Requires-Dist: pyyaml>=6.0.3
|
12
|
+
Provides-Extra: documentation
|
13
|
+
Requires-Dist: mkdocs-autoapi>=0.4.1; extra == 'documentation'
|
14
|
+
Requires-Dist: mkdocs-landing>=0.0.8; extra == 'documentation'
|
15
|
+
Requires-Dist: mkdocs-terminal>=4.7.0; extra == 'documentation'
|
16
|
+
Requires-Dist: mkdocs>=1.6.1; extra == 'documentation'
|
17
|
+
Requires-Dist: mkdocstrings[python]>=0.30.1; extra == 'documentation'
|
18
|
+
Description-Content-Type: text/markdown
|
19
|
+
|
20
|
+
# `argmerge`
|
21
|
+
## Description
|
22
|
+
_Customize how program defaults and overrides from config files, environment variables, and CLI arguments "cross the threshold" into your program._
|
23
|
+
|
24
|
+
| | |
|
25
|
+
|---|---|
|
26
|
+
CI/CD |
|
27
|
+
Package | [](https://pypi.org/project/hatch-vcs/) |
|
28
|
+
Meta|[]
|
29
|
+
|
30
|
+
|
31
|
+

|
32
|
+

|
33
|
+
|
34
|
+
We retrieve each possible source of program arguments as Python dictionaries and then perform dictionary updates between each source before passing the final dictionary to the wrapped function. Effectively:
|
35
|
+
```py
|
36
|
+
source_1: dict
|
37
|
+
source_2: dict
|
38
|
+
|
39
|
+
source_1.update(source_2)
|
40
|
+
|
41
|
+
function(**source_1)
|
42
|
+
```
|
43
|
+
|
44
|
+
## Installation
|
45
|
+
We recommend using [`uv` for package management](http://docs.astral.sh/uv/).
|
46
|
+
```sh
|
47
|
+
uv add argmerge
|
48
|
+
```
|
49
|
+
|
50
|
+
If you're using pip you can run
|
51
|
+
```sh
|
52
|
+
pip install argmerge
|
53
|
+
```
|
54
|
+
|
55
|
+
## Usage
|
56
|
+
### Code Example
|
57
|
+
While designed for `main` functions, you can ddd the `@threshold` decorator to any program that interfaces with external variables or files.
|
58
|
+
```py
|
59
|
+
from argmerge import threshold
|
60
|
+
|
61
|
+
|
62
|
+
@threshold
|
63
|
+
def main(first: int, second: str, third: float = 0.0):
|
64
|
+
...
|
65
|
+
```
|
66
|
+
|
67
|
+
## Hierarchy
|
68
|
+
We determined the hierarchy based on (our perception of) developer experience. The intent is for higher priority sources to correspond to the quickest sources to change. For example, we perceive changing a CLI argument quicker than changing an environment variable - etc.
|
69
|
+
|
70
|
+
|
71
|
+
| Level | Rank |
|
72
|
+
| --- | --- |
|
73
|
+
| Developer-provided | 100 |
|
74
|
+
| CLI | 40 |
|
75
|
+
| Environment Variable | 30 |
|
76
|
+
| YAML File | 20 |
|
77
|
+
| JSON File | 10 |
|
78
|
+
| Python Function Default | 0 |
|
79
|
+
|
80
|
+
## FAQ
|
81
|
+
#### Why YAML over JSON?
|
82
|
+
We prioritized YAML above JSON is because we find it significantly easier to read because it has less "line noise". JSON contains copious amounts of brackets (`{`,`}`) and commas. These are the only two sources that can pass dictionaries in by default. Of course, passing in different Regular expressions for environment variables and CLI arguments could also capture dictionaries, if you want to figure that out.
|
83
|
+
|
84
|
+
## Planned Future Work
|
85
|
+
For specific bite-sized work, you can take a look at [our project's GitHub issues](https://github.com/duck-bongos/py-argmerge/issues). ⚠️ Caution - this project is new and issues may be under-specified ⚠️
|
86
|
+
|
87
|
+
#### Validation
|
88
|
+
We want users to be able to validate the input arguments to their program using a `PyDantic BaseModel`. This would slot in after we collect the arguments from all the different sources.
|
89
|
+
|
90
|
+
### Customizing `@threshold`
|
91
|
+
We provide ways to customize reading environment variables and CLI arguments with the `env_prefix` and `cli_pattern` parameters. If developers want to go a step further and pull values from external sources we haven't considered (like `.cfg` files), we would like to allow them to do so.
|
92
|
+
|
93
|
+
#### Databricks
|
94
|
+
Databricks offers a way to collect 'widgets' or 'secrets' in their notebooks at runtime and pass them into your program. [This is cool](https://docs.databricks.com/aws/en/notebooks/widgets). It also is higher-risk for not being able to exactly reproduce your workloads. This makes `@threshold` a very good candidate to merge arguments between the Databricks platform and other external sources. For example, though not recommended, it's possible to create widgets in a notebook that gets run in an asset bundle. Asset bundles can be run like CLI applications, which means you can pass arguments in from the CLI or the widgets.
|
95
|
+
|
96
|
+
#### Authentication
|
97
|
+
This is a tricky one, and we may not do it. Since we truly want to provide _everything_ a program needs to run, this would include any authentication keys - think Database access keys or application API keys. The way we'd retrieve keys varies between cloud platforms, as well as non-cloud platforms. What may make the most sense is for us to build some sort of plugin system that allows users to bring their own authentication
|
98
|
+
|
99
|
+
|
100
|
+
|
101
|
+
|
@@ -0,0 +1,15 @@
|
|
1
|
+
argmerge/__init__.py,sha256=Qwzs4V0UrF0t5xKzlkyETZ-2wFK5q4NRd9QzCzseDa4,101
|
2
|
+
argmerge/base.py,sha256=sH40cnHH1hL2A3hTkT8KclQe5UsqNSEkXwR4ZF3avXo,1389
|
3
|
+
argmerge/cli.py,sha256=L75YUHjQjYgRjGH9Px3T4L_d2prUemfWWZjAAk7kVfw,2725
|
4
|
+
argmerge/decorator.py,sha256=ygETPoncQ8To-9-KJSl-T6thczRLBnrJ8k8ANnjhLA4,4606
|
5
|
+
argmerge/env.py,sha256=4pujGpQQpaeXKFoEAs0gAYsp7xt0Dzwafxn16-ZJBVc,3244
|
6
|
+
argmerge/func.py,sha256=wEdtnGy032qD8Bs5mPcB2JHp9icqgTFNYkJ0YZF6L44,3830
|
7
|
+
argmerge/json.py,sha256=-MQHFvMqou1gTk3IQzjXEhzMbDeM1dV00Xixn3qdlyA,2542
|
8
|
+
argmerge/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
9
|
+
argmerge/trace.py,sha256=TEL15zq4kB167g5_kol8gn_bcbu_g6Xfoi56F_QZcAY,4905
|
10
|
+
argmerge/utils.py,sha256=vm831wU84oLYMECd9fNNgAoG_h4NRc4V09xLCuCSOt4,229
|
11
|
+
argmerge/yaml.py,sha256=F67BBo3qdF8i_KSAbhHWu1-rAinvqE4OM4rHsVt5bvw,2621
|
12
|
+
argmerge-0.0.0.dist-info/METADATA,sha256=3bhWAgxnw-9n3dKVl_BzoC6E0BnDarIlaZOqaym2LcY,4820
|
13
|
+
argmerge-0.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
14
|
+
argmerge-0.0.0.dist-info/licenses/LICENSE,sha256=VCHxOgrvZCaQVMK2Si3ZzeN_EqtZGohk7zFmT6MSNeg,1069
|
15
|
+
argmerge-0.0.0.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Dan Billmann
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|