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 ADDED
@@ -0,0 +1,6 @@
1
+ from loguru import logger
2
+
3
+ from .decorator import threshold
4
+
5
+ __all__ = ["threshold"]
6
+ logger.remove()
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
@@ -0,0 +1,13 @@
1
+ """Module for utility functions."""
2
+
3
+ import ast
4
+
5
+ from loguru import logger as LOGGER
6
+
7
+
8
+ def extract_literals(s: str):
9
+ try:
10
+ return ast.literal_eval(s)
11
+ except Exception as e:
12
+ LOGGER.debug(e)
13
+ return s
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 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/argmerge.svg?logo=python&label=Python&logoColor=gold)](https://pypi.org/project/hatch-vcs/) |
28
+ Meta|[![types - Mypy](https://img.shields.io/badge/types-Mypy-blue.svg)]
29
+
30
+
31
+ ![](https://raw.githubusercontent.com/duck-bongos/py-argmerge/ee26d70afb01489a43741ff9f88347b3ada3c25a/docs/img/argmerge.svg)
32
+ ![](/img/argmerge.svg)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.