conclude 1.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.
- conclude/__init__.py +189 -0
- conclude/app.py +819 -0
- conclude/casters.py +262 -0
- conclude/developer.py +215 -0
- conclude/env.py +176 -0
- conclude/files.py +247 -0
- conclude/formatters.py +100 -0
- conclude/guard.py +172 -0
- conclude/infer.py +116 -0
- conclude/merge.py +58 -0
- conclude/naming.py +58 -0
- conclude/paths.py +26 -0
- conclude/py.typed +0 -0
- conclude/templates.py +83 -0
- conclude/tomlwrite.py +29 -0
- conclude-1.0.0.dist-info/METADATA +260 -0
- conclude-1.0.0.dist-info/RECORD +19 -0
- conclude-1.0.0.dist-info/WHEEL +4 -0
- conclude-1.0.0.dist-info/licenses/LICENSE +7 -0
conclude/merge.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The merge itself: layers, lowest priority first.
|
|
2
|
+
|
|
3
|
+
Design: every layer (defaults, a config file, environment variables,
|
|
4
|
+
CLI flags) produces a dict of the *same* keys, using ``None`` (or
|
|
5
|
+
"not present" -- simply omitting the key) to mean "this layer
|
|
6
|
+
expresses no opinion". :func:`resolve` walks the layers from lowest
|
|
7
|
+
to highest priority and lets later, non-``None`` values overwrite
|
|
8
|
+
earlier ones. This avoids nested if/elif/else chains entirely --
|
|
9
|
+
adding a new setting to an application built on conclude means adding
|
|
10
|
+
one entry to its own ``DEFAULTS`` dict, one caster, and (if it should
|
|
11
|
+
be settable that way) one CLI flag and one env var mapping, nothing
|
|
12
|
+
else.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from conclude.infer import Caster
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve(
|
|
22
|
+
cli: Mapping[str, Any],
|
|
23
|
+
env: Mapping[str, Any],
|
|
24
|
+
config_file: Mapping[str, Any],
|
|
25
|
+
defaults: Mapping[str, Any],
|
|
26
|
+
casters: Mapping[str, Caster],
|
|
27
|
+
*,
|
|
28
|
+
developer: Mapping[str, Any] | None = None,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
"""Merge ``defaults < config_file < env < developer < cli``,
|
|
31
|
+
casting each value as it lands. ``developer`` (the private,
|
|
32
|
+
project-local developer config layer -- see
|
|
33
|
+
:mod:`conclude.developer`) is optional and, when given, sits
|
|
34
|
+
between the environment and the CLI.
|
|
35
|
+
|
|
36
|
+
Returns a plain dict of fully-resolved, correctly-typed settings --
|
|
37
|
+
every key in ``defaults`` is present, plus any key from a layer
|
|
38
|
+
that ``casters`` knows how to cast (a layer key with no matching
|
|
39
|
+
caster is silently ignored, so a config file or environment can
|
|
40
|
+
carry unrelated keys without upsetting the merge).
|
|
41
|
+
|
|
42
|
+
A required-ness check (e.g. "this particular key must not still be
|
|
43
|
+
None once everything is merged") is a caller-level concern, not a
|
|
44
|
+
merging one -- ``resolve`` never raises for a still-``None`` result,
|
|
45
|
+
only for a caster itself raising on a value it was actually given
|
|
46
|
+
(e.g. an unparseable number).
|
|
47
|
+
"""
|
|
48
|
+
merged: dict[str, Any] = {}
|
|
49
|
+
for layer in (defaults, config_file, env, developer or {}, cli):
|
|
50
|
+
for key, value in layer.items():
|
|
51
|
+
if key not in casters:
|
|
52
|
+
continue
|
|
53
|
+
if value is None:
|
|
54
|
+
continue
|
|
55
|
+
merged[key] = casters[key](value)
|
|
56
|
+
for key, value in defaults.items():
|
|
57
|
+
merged.setdefault(key, value)
|
|
58
|
+
return merged
|
conclude/naming.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Deriving the conventional name for a setting in each layer -- its
|
|
2
|
+
CLI flag, its environment variable, its config-file key -- from its
|
|
3
|
+
Python name alone, so an application writes that name exactly once
|
|
4
|
+
(as a key in its defaults dict) instead of once per layer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
_NON_IDENTIFIER_CHARS = re.compile(r"[^0-9a-zA-Z_]")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cli_flag_name(key: str) -> str:
|
|
13
|
+
"""``"filter_col"`` -> ``"--filter-col"``."""
|
|
14
|
+
return "--" + key.replace("_", "-")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def env_var_name(app_name: str, key: str) -> str:
|
|
18
|
+
"""``("remind", "retries")`` -> ``"REMIND_RETRIES"``.
|
|
19
|
+
|
|
20
|
+
A hyphen (or any other character that can't appear in a shell
|
|
21
|
+
environment variable name) in either part is replaced with an
|
|
22
|
+
underscore first -- e.g. ``("my-app", "foo_bar")`` ->
|
|
23
|
+
``"MY_APP_FOO_BAR"``, not the shell-illegal ``"MY-APP_FOO_BAR"``
|
|
24
|
+
a bare ``.upper()`` would otherwise produce. Common for the app
|
|
25
|
+
name in particular, since "my-app"-style hyphenated names are a
|
|
26
|
+
normal CLI-tool naming convention that an app's own ``name`` is
|
|
27
|
+
often taken straight from (e.g. its package name).
|
|
28
|
+
|
|
29
|
+
A leading underscore is added if the result would otherwise start
|
|
30
|
+
with a digit (``("123app", "foo")`` -> ``"_123APP_FOO"``, not the
|
|
31
|
+
equally shell-illegal ``"123APP_FOO"`` -- a shell identifier can't
|
|
32
|
+
start with a digit even though it can contain one).
|
|
33
|
+
|
|
34
|
+
One thing this can't fix: two app names that only differ by which
|
|
35
|
+
non-identifier character they use in the same position --
|
|
36
|
+
``"my-app"`` and ``"my_app"``, say -- sanitize to the *same* env
|
|
37
|
+
var prefix. Not a concern for a single app calling this on its own
|
|
38
|
+
name, but worth knowing if you're generating names for several
|
|
39
|
+
apps at once.
|
|
40
|
+
"""
|
|
41
|
+
name = f"{_sanitize(app_name)}_{_sanitize(key)}".upper()
|
|
42
|
+
if name[:1].isdigit():
|
|
43
|
+
name = f"_{name}"
|
|
44
|
+
return name
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def config_key_name(key: str) -> str:
|
|
48
|
+
"""The config-file key for ``key`` -- always just ``key`` itself,
|
|
49
|
+
since a config file's keys already *are* your defaults dict's
|
|
50
|
+
keys. Provided for symmetry with :func:`cli_flag_name`/
|
|
51
|
+
:func:`env_var_name`, and so calling code never has to special-case
|
|
52
|
+
"no translation needed" for this one layer.
|
|
53
|
+
"""
|
|
54
|
+
return key
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _sanitize(text: str) -> str:
|
|
58
|
+
return _NON_IDENTIFIER_CHARS.sub("_", text)
|
conclude/paths.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The conventional per-app config file locations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def default_config_home_path(app_name: str) -> Path:
|
|
7
|
+
"""``~/.config/<app_name>/config.toml`` -- the usual user-global
|
|
8
|
+
config file location (XDG-style, minus actually honoring
|
|
9
|
+
``$XDG_CONFIG_HOME``; pass your own path to the loading functions
|
|
10
|
+
instead if your app needs that).
|
|
11
|
+
"""
|
|
12
|
+
return Path.home() / ".config" / app_name / "config.toml"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_config_system_path(app_name: str) -> Path:
|
|
16
|
+
"""``/etc/<app_name>/config.toml`` -- the conventional system-wide
|
|
17
|
+
config file location, shared by every user on the machine (the
|
|
18
|
+
lowest-priority config file: any user-global, project-local, env,
|
|
19
|
+
or CLI value overrides it).
|
|
20
|
+
|
|
21
|
+
Unlike the user-global path this is never read unless the app opts
|
|
22
|
+
in (``config_system_path=AUTO`` on :class:`conclude.App`), since
|
|
23
|
+
silently picking up machine-wide configuration is a decision about
|
|
24
|
+
the program, not something that should happen by default.
|
|
25
|
+
"""
|
|
26
|
+
return Path("/etc") / app_name / "config.toml"
|
conclude/py.typed
ADDED
|
File without changes
|
conclude/templates.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Rendering a setting's default value as plain text, an ``.env``
|
|
2
|
+
value, or a TOML value -- the building blocks behind
|
|
3
|
+
:meth:`conclude.App.format_env`, :meth:`conclude.App.format_toml`, and
|
|
4
|
+
:meth:`conclude.App.format_cli`, which generate a ready-to-fill-in
|
|
5
|
+
configuration template (or a bit of reference documentation) straight
|
|
6
|
+
from an app's ``defaults``, so there's no hand-written example config
|
|
7
|
+
to keep in sync with them.
|
|
8
|
+
|
|
9
|
+
Unlike :mod:`conclude.formatters`, whose output is a shell-quoted CLI
|
|
10
|
+
token, everything here renders for a *reader* or a *file*: unquoted
|
|
11
|
+
``localhost``, not ``'localhost'``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import shlex
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from conclude.tomlwrite import toml_string
|
|
20
|
+
|
|
21
|
+
# Characters that never need quoting in an .env value: word characters
|
|
22
|
+
# (letters, digits, "_" -- Unicode-aware, so "café" stays bare) plus a
|
|
23
|
+
# few punctuation marks that are unremarkable in every .env dialect.
|
|
24
|
+
_ENV_BARE_RE = re.compile(r"[\w./:@%+,-]*")
|
|
25
|
+
|
|
26
|
+
_ENV_DOUBLE_QUOTE_ESCAPES = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\t": "\\t", "\r": "\\r"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def plain_text(value: Any, formatter: Callable[[Any], str | None] | None = None) -> str | None:
|
|
30
|
+
"""``value`` as unquoted, human-readable text, or ``None`` for
|
|
31
|
+
"no value" (an unset ``None`` setting).
|
|
32
|
+
|
|
33
|
+
A bool is ``true``/``false`` (never the bare-flag-or-nothing shape a
|
|
34
|
+
CLI formatter gives it); a list is comma-joined, matching what
|
|
35
|
+
:func:`conclude.cast_comma_list` casts back out of. ``formatter``,
|
|
36
|
+
if given, is an explicit per-setting :data:`conclude.Formatter`
|
|
37
|
+
override -- its (shell-quoted) output is unquoted again, so a
|
|
38
|
+
setting with a custom caster/formatter pair renders as text that
|
|
39
|
+
casts back to the same value.
|
|
40
|
+
"""
|
|
41
|
+
if value is None:
|
|
42
|
+
return None
|
|
43
|
+
if isinstance(value, bool):
|
|
44
|
+
return "true" if value else "false"
|
|
45
|
+
if formatter is not None:
|
|
46
|
+
rendered = formatter(value)
|
|
47
|
+
if rendered is None:
|
|
48
|
+
return None
|
|
49
|
+
return " ".join(shlex.split(rendered))
|
|
50
|
+
if isinstance(value, (list, tuple)):
|
|
51
|
+
return ",".join(str(item) for item in value)
|
|
52
|
+
return str(value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def env_value(text: str) -> str:
|
|
56
|
+
"""``text`` as the right-hand side of a ``NAME=value`` line that
|
|
57
|
+
:func:`conclude.load_dotenv` reads back as exactly ``text``: bare
|
|
58
|
+
when it's made only of unremarkable characters, single-quoted
|
|
59
|
+
(taken literally) when it has anything else, and double-quoted with
|
|
60
|
+
backslash escapes only when it contains a single quote or a
|
|
61
|
+
newline/tab/carriage return, which single quotes can't carry.
|
|
62
|
+
"""
|
|
63
|
+
if _ENV_BARE_RE.fullmatch(text):
|
|
64
|
+
return text
|
|
65
|
+
if "'" not in text and not any(ch in text for ch in "\n\t\r"):
|
|
66
|
+
return f"'{text}'"
|
|
67
|
+
return '"' + "".join(_ENV_DOUBLE_QUOTE_ESCAPES.get(ch, ch) for ch in text) + '"'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def toml_value(value: Any) -> str:
|
|
71
|
+
"""``value`` as a native TOML value: ``true``/``false``, an integer
|
|
72
|
+
or float as-is, a list as an array, and anything else as a quoted
|
|
73
|
+
string.
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(value, bool):
|
|
76
|
+
return "true" if value else "false"
|
|
77
|
+
if isinstance(value, int):
|
|
78
|
+
return str(value)
|
|
79
|
+
if isinstance(value, float):
|
|
80
|
+
return repr(value)
|
|
81
|
+
if isinstance(value, (list, tuple)):
|
|
82
|
+
return "[" + ", ".join(toml_value(item) for item in value) + "]"
|
|
83
|
+
return toml_string(str(value))
|
conclude/tomlwrite.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Writing (not reading) small bits of TOML -- for an app feature like
|
|
2
|
+
"print me a ready-to-paste config entry for what I just set up".
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_TOML_BARE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
8
|
+
_TOML_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def toml_string(s: str) -> str:
|
|
12
|
+
"""``s`` as a double-quoted TOML basic string, with the characters
|
|
13
|
+
that need escaping there escaped.
|
|
14
|
+
"""
|
|
15
|
+
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
|
|
16
|
+
escaped = escaped.replace("\n", "\\n").replace("\t", "\\t").replace("\r", "\\r")
|
|
17
|
+
# Every other control character (and DEL) is illegal unescaped in a
|
|
18
|
+
# TOML basic string.
|
|
19
|
+
escaped = _TOML_CONTROL_RE.sub(lambda m: f"\\u{ord(m.group()):04X}", escaped)
|
|
20
|
+
return f'"{escaped}"'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def toml_key(s: str) -> str:
|
|
24
|
+
"""A TOML table/key name for ``s`` -- bare if that's valid syntax
|
|
25
|
+
(letters, digits, ``_``/``-``, non-empty), a quoted string key
|
|
26
|
+
otherwise (TOML allows arbitrary quoted keys, e.g. one containing a
|
|
27
|
+
space).
|
|
28
|
+
"""
|
|
29
|
+
return s if _TOML_BARE_KEY_RE.match(s) else toml_string(s)
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: conclude
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Set up your defaults; conclude infers the env vars, config-file keys, and CLI flags, and resolves them all into one settings object.
|
|
5
|
+
Project-URL: Homepage, https://github.com/tanakapayam/conclude
|
|
6
|
+
Project-URL: Documentation, https://github.com/tanakapayam/conclude/blob/main/docs/guide.md
|
|
7
|
+
Project-URL: Repository, https://github.com/tanakapayam/conclude
|
|
8
|
+
Project-URL: Issues, https://github.com/tanakapayam/conclude/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/tanakapayam/conclude/blob/main/CHANGELOG.md
|
|
10
|
+
Author: Payam Tanaka
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: cli,config,configuration,dotenv,environment-variables,settings,toml
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.11
|
|
28
|
+
Provides-Extra: gitignore
|
|
29
|
+
Requires-Dist: pathspec>=0.12; extra == 'gitignore'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# conclude
|
|
33
|
+
|
|
34
|
+
A setting that can come from a flag, an environment variable, or a
|
|
35
|
+
config file usually gets written down three times: an argparse flag, an
|
|
36
|
+
environment variable, and a config-file key -- each with its own name,
|
|
37
|
+
its own type conversion, and its own copy of the default. Add a setting
|
|
38
|
+
and you touch three places; rename one and the other two quietly drift.
|
|
39
|
+
|
|
40
|
+
conclude has you write it once. Set up your defaults; conclude works
|
|
41
|
+
out the rest. You write down a setting's name, its type, and its
|
|
42
|
+
starting value -- as an entry in a plain dict -- and conclude infers
|
|
43
|
+
the environment variable name, the config-file key, the CLI flag, and
|
|
44
|
+
how to cast a raw value from any of those into the right type.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import conclude
|
|
48
|
+
|
|
49
|
+
DEFAULTS = {
|
|
50
|
+
"host": "localhost",
|
|
51
|
+
"port": 8080,
|
|
52
|
+
"debug": False,
|
|
53
|
+
"tags": conclude.opt(list), # unset by default, but still a list when set
|
|
54
|
+
}
|
|
55
|
+
app = conclude.App("myapp", DEFAULTS)
|
|
56
|
+
|
|
57
|
+
parser = app.build_arg_parser(prog="myapp")
|
|
58
|
+
namespace = parser.parse_args()
|
|
59
|
+
cli = {k: v for k, v in vars(namespace).items() if v is not None}
|
|
60
|
+
print(app.resolve(cli))
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
$ MYAPP_PORT=9000 myapp --debug --tags a,b
|
|
65
|
+
{'host': 'localhost', 'port': 9000, 'debug': True, 'tags': ['a', 'b']}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
That one dict gave you the CLI flags (`--host`, `--port`, `--debug`,
|
|
69
|
+
`--tags`), the environment variables (`MYAPP_HOST`, `MYAPP_PORT`, ...),
|
|
70
|
+
the config-file keys (a `[myapp]` table in `~/.config/myapp/config.toml`
|
|
71
|
+
or `./.config.toml`), and a caster for each -- with no second list of
|
|
72
|
+
names to keep in sync.
|
|
73
|
+
|
|
74
|
+
## Where it fits
|
|
75
|
+
|
|
76
|
+
Configuration libraries tend to start from different things. conclude
|
|
77
|
+
starts from a dict of defaults and derives the rest:
|
|
78
|
+
|
|
79
|
+
| Package | Primary abstraction | Reach for it when... |
|
|
80
|
+
| ----------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
|
|
81
|
+
| `argparse` | CLI parser | you only need flags |
|
|
82
|
+
| ConfigArgParse | argparse plus config files and environment variables | you have an argparse CLI and want files and env vars added |
|
|
83
|
+
| jsonargparse | CLI and config built from type hints | your program is functions, classes or dataclasses with type hints |
|
|
84
|
+
| python-dotenv | `.env` loader | you just need a `.env` in `os.environ` |
|
|
85
|
+
| python-decouple | individual value reader (env, then file, then default) | you read a handful of values, Django-style |
|
|
86
|
+
| pydantic-settings | typed, validated settings model | you want validation, nested models or secret-manager sources |
|
|
87
|
+
| Dynaconf | general-purpose layered configuration system | you want named environments, many file formats, or Vault |
|
|
88
|
+
| Hydra / OmegaConf | hierarchical, composable configuration | you compose config groups, run sweeps or manage experiments |
|
|
89
|
+
| **conclude** | **defaults → inferred env / TOML / CLI interfaces → layered resolution** | **you want all three interfaces from one dict, and safe private local overrides** |
|
|
90
|
+
|
|
91
|
+
conclude deliberately does not do schema validation (values are cast,
|
|
92
|
+
not validated), secret-manager backends, YAML or JSON files, or named
|
|
93
|
+
environments; the table says where to look for those. For a dated,
|
|
94
|
+
feature-by-feature comparison, see
|
|
95
|
+
[How conclude compares](https://github.com/tanakapayam/conclude/blob/main/docs/comparison.md).
|
|
96
|
+
|
|
97
|
+
## Design principles
|
|
98
|
+
|
|
99
|
+
- **One source of truth.** The defaults dict. Flags, environment
|
|
100
|
+
variable names, config keys, casters and the generated templates are
|
|
101
|
+
all derived from it, so they can't drift apart.
|
|
102
|
+
- **Opt in to anything unusual.** The system file, `.env` and the
|
|
103
|
+
developer file are off until you ask; the user and project files are
|
|
104
|
+
one `None` from off.
|
|
105
|
+
- **Private files have to prove they're private.** The developer file
|
|
106
|
+
(and `.env`, if you ask) is read only when git is ignoring it.
|
|
107
|
+
- **Quiet for ordinary situations, loud for broken setups.** A missing
|
|
108
|
+
local file is normal and silent, and `describe_sources()` explains
|
|
109
|
+
it; a malformed developer file or a missing `pathspec` raises.
|
|
110
|
+
- **Standard library only.** `pathspec`, for the gitignore check, is an
|
|
111
|
+
optional extra.
|
|
112
|
+
|
|
113
|
+
## The layers
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
1. CLI flags
|
|
117
|
+
2. Developer config file (opt-in; private, gitignored, project-local,
|
|
118
|
+
TOML or dotenv -- beats the environment)
|
|
119
|
+
3. Environment variables (with an opt-in `.env` file as a fallback)
|
|
120
|
+
4. Config file(s), in this order (each overrides the previous):
|
|
121
|
+
a. /etc/<app>/config.toml (system-wide; opt-in, off by default)
|
|
122
|
+
b. ~/.config/<app>/config.toml (user-global, overrides system)
|
|
123
|
+
c. ./.config.toml (project-local, overrides user)
|
|
124
|
+
d. ./.config.*.toml (sibling files, sorted by name)
|
|
125
|
+
5. Hardcoded defaults
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Or, lowest priority to highest:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
defaults < system < user < project < env < developer < CLI
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Everything hangs off that one dict: the layers it feeds, and what
|
|
135
|
+
conclude infers from it so you never write any of it twice:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
┌── CLI
|
|
139
|
+
defaults ───────┼── developer config (explicit opt-in, must be gitignored)
|
|
140
|
+
│ ├── environment
|
|
141
|
+
│ ├── .env (explicit opt-in)
|
|
142
|
+
│ └── config files
|
|
143
|
+
│
|
|
144
|
+
└── inference
|
|
145
|
+
├── caster
|
|
146
|
+
├── formatter
|
|
147
|
+
├── CLI name
|
|
148
|
+
└── environment name
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
(The layers are listed highest priority first; `defaults` sits beneath
|
|
152
|
+
all of them.) The user and project config files are on by default, and
|
|
153
|
+
each is one `None` away from off. The system file, `.env`, and the
|
|
154
|
+
developer file are off until you ask for them.
|
|
155
|
+
|
|
156
|
+
## Local files: which one?
|
|
157
|
+
|
|
158
|
+
Two of the layers are about your own machine, and each comes in two
|
|
159
|
+
formats:
|
|
160
|
+
|
|
161
|
+
| Format | Below the environment | Above the environment |
|
|
162
|
+
| ------ | ----------------------------------------- | ------------------------------------------ |
|
|
163
|
+
| TOML | the system, user and project config files | the developer file, e.g. `.developer.toml` |
|
|
164
|
+
| dotenv | `.env` | the developer file, e.g. `.env.local` |
|
|
165
|
+
|
|
166
|
+
- **Non-secret local defaults you're happy to commit** -- use `.env`.
|
|
167
|
+
A real `export` in the shell still beats it, so it can't override a
|
|
168
|
+
deployment.
|
|
169
|
+
- **Private values that must win over stray shell exports** (a local
|
|
170
|
+
`DATABASE_URL`, say) -- use the developer file. Make it TOML if only
|
|
171
|
+
your app reads it (it gets `[table]`s, including per-recipient ones);
|
|
172
|
+
make it dotenv, like `.env.local`, if docker compose, direnv or your
|
|
173
|
+
IDE need the same values.
|
|
174
|
+
- **A private `.env` that stays below the environment** -- use `.env`
|
|
175
|
+
with `dotenv_require_gitignored=True`.
|
|
176
|
+
|
|
177
|
+
The developer file's location lives in your committed `pyproject.toml`
|
|
178
|
+
(any name works; the name decides the format, and only `.toml` means
|
|
179
|
+
TOML):
|
|
180
|
+
|
|
181
|
+
```toml
|
|
182
|
+
[tool.conclude.developer]
|
|
183
|
+
config = ".developer.toml" # or ".env.local"
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
app = conclude.App(
|
|
188
|
+
"myapp",
|
|
189
|
+
DEFAULTS,
|
|
190
|
+
pyproject_path=conclude.AUTO, # the developer file
|
|
191
|
+
dotenv_path=conclude.AUTO, # .env
|
|
192
|
+
)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The developer file is always guarded, and `.env` is when you ask: the
|
|
196
|
+
file is used only if it exists, sits in a git working tree, and is
|
|
197
|
+
gitignored. Otherwise it is skipped quietly, and `describe_sources()`
|
|
198
|
+
says why. Details: [the `.env` file](https://github.com/tanakapayam/conclude/blob/main/docs/guide.md#6-local-development-a-env-file), [the developer file](https://github.com/tanakapayam/conclude/blob/main/docs/guide.md#8-a-private-developer-config-file).
|
|
199
|
+
|
|
200
|
+
## What's in it
|
|
201
|
+
|
|
202
|
+
- **Inference** of casters, formatters, CLI flags, env var names and
|
|
203
|
+
config keys from the defaults alone; `opt(type)` for a setting that
|
|
204
|
+
starts out unset ([guide, section 1](https://github.com/tanakapayam/conclude/blob/main/docs/guide.md#1-the-bare-minimum), [section 3](https://github.com/tanakapayam/conclude/blob/main/docs/guide.md#3-when-inference-isnt-quite-enough-casters-and-formatters)).
|
|
205
|
+
- **Config-file tables**, including a positional shorthand that picks a
|
|
206
|
+
table per recipient/deck/profile ([section 4](https://github.com/tanakapayam/conclude/blob/main/docs/guide.md#4-a-positional-shorthand--per-recipient-config-tables)).
|
|
207
|
+
- **`--print-invocation`** and `App.format_invocation()`: print the
|
|
208
|
+
command line that reproduces a resolved configuration
|
|
209
|
+
([section 5](https://github.com/tanakapayam/conclude/blob/main/docs/guide.md#5-debugging-and-documentation---print-invocation)).
|
|
210
|
+
- **A `.env` fallback**, optionally required to be gitignored, sitting
|
|
211
|
+
beneath real environment variables ([section 6](https://github.com/tanakapayam/conclude/blob/main/docs/guide.md#6-local-development-a-env-file)).
|
|
212
|
+
- **A system-wide config file**, the lowest-priority file ([section 7](https://github.com/tanakapayam/conclude/blob/main/docs/guide.md#7-machine-wide-defaults-a-system-config-file)).
|
|
213
|
+
- **A private developer file**, TOML or dotenv, that beats ambient
|
|
214
|
+
environment variables ([section 8](https://github.com/tanakapayam/conclude/blob/main/docs/guide.md#8-a-private-developer-config-file)).
|
|
215
|
+
- **`describe_sources()`** for `--help`: which sources are in play, and
|
|
216
|
+
why a given one isn't ([section 9](https://github.com/tanakapayam/conclude/blob/main/docs/guide.md#9-turning-off-a-source-and-telling-the-user)).
|
|
217
|
+
- **Templates and docs generated from your defaults**:
|
|
218
|
+
`format_env()`, `format_toml()`, `format_cli()`
|
|
219
|
+
([section 10](https://github.com/tanakapayam/conclude/blob/main/docs/guide.md#10-generating-docs-and-templates)).
|
|
220
|
+
- Standard library only, fully typed (`py.typed`), Python 3.11+. The
|
|
221
|
+
gitignore check (developer file, strict `.env`) uses the optional
|
|
222
|
+
`pathspec` package.
|
|
223
|
+
|
|
224
|
+
## Install
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
pip install conclude
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The developer config layer (and a `.env` you ask to be held to the same
|
|
231
|
+
standard) needs one small pure-Python dependency, only to check that a
|
|
232
|
+
private file is gitignored:
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
pip install 'conclude[gitignore]'
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Documentation
|
|
239
|
+
|
|
240
|
+
- [Guide](https://github.com/tanakapayam/conclude/blob/main/docs/guide.md) -- builds a small CLI, `remind`, one
|
|
241
|
+
idea at a time.
|
|
242
|
+
- [Reference](https://github.com/tanakapayam/conclude/blob/main/docs/reference.md) -- every class, method, and
|
|
243
|
+
module.
|
|
244
|
+
- [Changelog](https://github.com/tanakapayam/conclude/blob/main/CHANGELOG.md).
|
|
245
|
+
|
|
246
|
+
## Development
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
uv sync
|
|
250
|
+
uv run pytest
|
|
251
|
+
uv run ruff check . && uv run ruff format --check .
|
|
252
|
+
uv run mypy
|
|
253
|
+
uv build && uvx twine check --strict dist/*
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
CI runs these same commands on Python 3.11 through 3.14.
|
|
257
|
+
|
|
258
|
+
## License
|
|
259
|
+
|
|
260
|
+
MIT -- see [LICENSE](https://github.com/tanakapayam/conclude/blob/main/LICENSE).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
conclude/__init__.py,sha256=MKDasCQZ2cQJX4Ts9-8hvflyKhjc8eenAhLgZpfoxWs,8155
|
|
2
|
+
conclude/app.py,sha256=hFGiacq_ID_nOwNf_mGlSMi0yZF2a7vBAiTIyZYwTE8,38572
|
|
3
|
+
conclude/casters.py,sha256=crgvKmugrgG9ElCtRxajbqEj4tUQymPFc3qwcCs0V_8,11044
|
|
4
|
+
conclude/developer.py,sha256=emRRrO0OCVNUoZc7Lr5ettah_bQXd1SdNyhiLvIusAM,9415
|
|
5
|
+
conclude/env.py,sha256=BFMjiHZ2btIphmHZ4qnRDRlaGadUb29-hBfSPTBS4IE,7018
|
|
6
|
+
conclude/files.py,sha256=if_XvwlGKvAob7GTa3LTlVYTaEEyDYcA0mfzCOGipwM,10383
|
|
7
|
+
conclude/formatters.py,sha256=qTJJqVG6XV4k3YQCPNS2bneTfxy1LIya4LHOTaldOas,3724
|
|
8
|
+
conclude/guard.py,sha256=2IhlxeivFaLcJ1mEYmADS2lObrxdZzlLLwAE6j1yn3c,7223
|
|
9
|
+
conclude/infer.py,sha256=zsZ8iMZG5o-7nNwaZGff_AKFKuQAQ04sup_hDL2fZbE,4493
|
|
10
|
+
conclude/merge.py,sha256=tlOLgz0APaVov65K2GamBIkQSz7oRFzMZXZ54F9Kqwk,2355
|
|
11
|
+
conclude/naming.py,sha256=BLZkW9qbq4ReFfjEOFUwwkcouHoI5muP6sNf7aep3P0,2315
|
|
12
|
+
conclude/paths.py,sha256=d4b0t-rYIk5p7mnzNqgaDR-sgA0RLX12HhHjMSDJeD8,1072
|
|
13
|
+
conclude/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
conclude/templates.py,sha256=kMhOgFmXS-PGmJF_jVR6qJ-d-6LB-XOtC4oiaFqSatU,3376
|
|
15
|
+
conclude/tomlwrite.py,sha256=oGTZbaOyyOmv42JJqG1GFLrs-vJNBmiF-jCFL_VxZXk,1076
|
|
16
|
+
conclude-1.0.0.dist-info/METADATA,sha256=IQqX_hoDVcejBJ0qvPfsRXWeyelrS2bF2y-YK66Hlzg,12802
|
|
17
|
+
conclude-1.0.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
18
|
+
conclude-1.0.0.dist-info/licenses/LICENSE,sha256=EywLvILKrIuRUTXOSGJgDxKm1YH0g7zpbRG8vvzwMHA,1060
|
|
19
|
+
conclude-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 Payam Tanaka
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|