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/app.py
ADDED
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
"""The convenience layer: give :class:`App` your application's name
|
|
2
|
+
and its defaults dict, and it infers everything the rest of conclude
|
|
3
|
+
would otherwise need spelled out by hand -- env var names, casters,
|
|
4
|
+
and (via :meth:`App.add_arguments`/:meth:`App.build_arg_parser`) CLI
|
|
5
|
+
flag names -- so the common case really is "set up the defaults, get
|
|
6
|
+
a working CLI/env/config-file app back".
|
|
7
|
+
|
|
8
|
+
Minimal usage::
|
|
9
|
+
|
|
10
|
+
import conclude
|
|
11
|
+
|
|
12
|
+
DEFAULTS = {
|
|
13
|
+
"filename": conclude.opt(str),
|
|
14
|
+
"shuffle": False,
|
|
15
|
+
"size": 1,
|
|
16
|
+
}
|
|
17
|
+
app = conclude.App("myapp", DEFAULTS)
|
|
18
|
+
|
|
19
|
+
parser = app.build_arg_parser(prog="myapp")
|
|
20
|
+
namespace = parser.parse_args()
|
|
21
|
+
cli = {k: v for k, v in vars(namespace).items() if v is not None}
|
|
22
|
+
settings = app.resolve(cli)
|
|
23
|
+
|
|
24
|
+
Every piece of that is overridable when inference genuinely isn't
|
|
25
|
+
enough for one particular setting (custom --help text, a caster with
|
|
26
|
+
its own error-message wording, an env var name that doesn't follow
|
|
27
|
+
the ``APPNAME_SETTING`` convention) -- see each method's docstring --
|
|
28
|
+
without giving up inference for every *other* setting.
|
|
29
|
+
|
|
30
|
+
Three template generators turn the same ``defaults`` into documentation
|
|
31
|
+
instead of behavior -- :meth:`App.format_env`, :meth:`App.format_toml`,
|
|
32
|
+
and :meth:`App.format_cli` render every setting's name in that layer
|
|
33
|
+
next to its default value, ready to paste into a ``.env`` file, a
|
|
34
|
+
config file, or a README, with no hand-written example to keep in sync.
|
|
35
|
+
|
|
36
|
+
Two more pieces of common boilerplate ``App`` handles:
|
|
37
|
+
:meth:`App.add_print_invocation_argument` adds the conventional
|
|
38
|
+
``--print-invocation`` flag for :meth:`App.format_invocation` to pair
|
|
39
|
+
with, and a ``.env`` file can supply the environment-variable layer's
|
|
40
|
+
fallback (see :meth:`App.load_env`) -- opt-in only, via
|
|
41
|
+
``dotenv_path=AUTO`` or a specific path, since reading an arbitrary
|
|
42
|
+
local file looking for secrets is not something that should ever
|
|
43
|
+
happen by accident.
|
|
44
|
+
|
|
45
|
+
The full precedence chain, lowest to highest::
|
|
46
|
+
|
|
47
|
+
defaults < system < user < project < env < developer < CLI
|
|
48
|
+
|
|
49
|
+
where "system" is an optional, opt-in system-wide config file (see
|
|
50
|
+
``config_system_path`` on :class:`App`), "user" and "project" are the
|
|
51
|
+
user-global and project-local config files, "env" includes the opt-in
|
|
52
|
+
``.env`` fallback, and "developer" is an optional, opt-in, private
|
|
53
|
+
project-local file that beats the environment (see
|
|
54
|
+
``pyproject_path`` on :class:`App` and :mod:`conclude.developer`).
|
|
55
|
+
|
|
56
|
+
Any path -- ``dotenv_path``, ``config_system_path``, ``pyproject_path``,
|
|
57
|
+
``config_home_path``, ``config_cwd_path`` -- can be set to ``None`` to
|
|
58
|
+
disable that source outright (handy for
|
|
59
|
+
debugging, or for an app that deliberately shouldn't read a given
|
|
60
|
+
layer at all); :meth:`App.describe_sources` renders a short summary of
|
|
61
|
+
which sources are active, meant for your program's own ``--help``
|
|
62
|
+
text.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
import argparse
|
|
66
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
67
|
+
from dataclasses import dataclass, field
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
from typing import Any, cast
|
|
70
|
+
|
|
71
|
+
from conclude.developer import DeveloperStatus
|
|
72
|
+
from conclude.developer import developer_status as _developer_status
|
|
73
|
+
from conclude.developer import load_developer_config as _load_developer_config
|
|
74
|
+
from conclude.env import DotenvStatus
|
|
75
|
+
from conclude.env import dotenv_status as _dotenv_status
|
|
76
|
+
from conclude.env import load_env as _load_env
|
|
77
|
+
from conclude.files import load_config_files as _load_config_files
|
|
78
|
+
from conclude.files import resolve_config_table as _resolve_config_table
|
|
79
|
+
from conclude.formatters import Formatter, infer_formatters
|
|
80
|
+
from conclude.infer import Caster, Opt, effective_defaults, infer_casters
|
|
81
|
+
from conclude.merge import resolve as _resolve
|
|
82
|
+
from conclude.naming import cli_flag_name, config_key_name, env_var_name
|
|
83
|
+
from conclude.paths import default_config_home_path, default_config_system_path
|
|
84
|
+
from conclude.templates import env_value, plain_text, toml_value
|
|
85
|
+
from conclude.tomlwrite import toml_key, toml_string
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _AutoType:
|
|
89
|
+
"""The type of :data:`conclude.AUTO` -- see that constant. A named
|
|
90
|
+
class (rather than a bare ``object()``) purely so it reprs as
|
|
91
|
+
``AUTO`` instead of a memory address, which matters here since
|
|
92
|
+
``AUTO`` is public and shows up in ``repr(App(...))``.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __repr__(self) -> str:
|
|
96
|
+
return "AUTO"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
AUTO = _AutoType()
|
|
100
|
+
"""Sentinel meaning "compute the conventional default for this path",
|
|
101
|
+
distinct from both an unset field and an explicit ``None``. Used for
|
|
102
|
+
:class:`App`'s file-based sources:
|
|
103
|
+
|
|
104
|
+
- ``config_home_path`` defaults to ``AUTO`` -- i.e. config files are
|
|
105
|
+
read from ``~/.config/<name>/config.toml`` unless you say otherwise.
|
|
106
|
+
Pass ``config_home_path=None`` to disable that source instead, or a
|
|
107
|
+
``Path`` for a specific location.
|
|
108
|
+
- ``dotenv_path`` defaults to plain ``None`` (no ``.env`` file read at
|
|
109
|
+
all) -- unlike the user/project config files, reading arbitrary
|
|
110
|
+
files for environment-variable-shaped secrets is exactly the kind of
|
|
111
|
+
thing that should never happen just because nobody thought about
|
|
112
|
+
it, so conclude requires you to opt in: pass ``dotenv_path=AUTO``
|
|
113
|
+
for the conventional ``./.env``, or a ``Path`` for a specific file.
|
|
114
|
+
- ``config_system_path`` defaults to plain ``None`` too (no system-wide
|
|
115
|
+
config file read at all) -- machine-wide configuration changing an
|
|
116
|
+
app's behavior for every user on the box is a decision about the
|
|
117
|
+
program, not something to pick up by accident. Pass
|
|
118
|
+
``config_system_path=AUTO`` for the conventional
|
|
119
|
+
``/etc/<name>/config.toml``, or a ``Path`` for a specific file.
|
|
120
|
+
- ``pyproject_path`` defaults to plain ``None`` as well (no developer
|
|
121
|
+
config layer at all): a file that beats the environment is something
|
|
122
|
+
an app should turn on deliberately. Pass ``pyproject_path=AUTO`` to
|
|
123
|
+
read ``tool.conclude.developer.config`` from ``./pyproject.toml``, or
|
|
124
|
+
a ``Path`` for a specific ``pyproject.toml``.
|
|
125
|
+
- ``config_cwd_path`` doesn't use ``AUTO`` -- its conventional default
|
|
126
|
+
(``./.config.toml``) doesn't depend on anything else about the
|
|
127
|
+
``App``, so the field's own default already *is* that path; pass
|
|
128
|
+
``None`` to disable it the same way as the other two.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class App:
|
|
134
|
+
"""Everything conclude can infer from an app name + a defaults
|
|
135
|
+
dict, bundled up so you don't have to keep passing both to every
|
|
136
|
+
function separately.
|
|
137
|
+
|
|
138
|
+
``casters``/``formatters``/``env_vars``, if given, override
|
|
139
|
+
specific keys' inferred value outright -- every key not mentioned
|
|
140
|
+
there still comes from inference on its own, so you only override
|
|
141
|
+
what actually needs it.
|
|
142
|
+
|
|
143
|
+
Any of ``dotenv_path``/``config_system_path``/``config_home_path``/
|
|
144
|
+
``config_cwd_path`` can be set to ``None`` to disable that source
|
|
145
|
+
entirely -- handy for
|
|
146
|
+
debugging (ruling a source out while narrowing down where a value
|
|
147
|
+
is coming from), or for an app that deliberately doesn't want a
|
|
148
|
+
given layer at all (e.g. one that should never read a project-local
|
|
149
|
+
``.config.toml`` regardless of cwd). See :data:`AUTO` for how the
|
|
150
|
+
conventional default is opted into for each, and
|
|
151
|
+
:meth:`describe_sources` for surfacing which sources are active to
|
|
152
|
+
the person running your program.
|
|
153
|
+
|
|
154
|
+
Config-file precedence, lowest to highest, is
|
|
155
|
+
``system < user < project`` (``config_system_path``,
|
|
156
|
+
``config_home_path``, ``config_cwd_path`` plus its sibling aux
|
|
157
|
+
files), all of which sit above the hardcoded defaults and below the
|
|
158
|
+
environment and CLI: ``defaults < system < user < project < env <
|
|
159
|
+
developer < CLI``. ``config_system_path`` is the only one of the
|
|
160
|
+
three that is off by default. ``developer`` is a fourth, separate
|
|
161
|
+
kind of file: private, project-local, and *above* the environment
|
|
162
|
+
(``pyproject_path``, off by default -- see :mod:`conclude.developer`
|
|
163
|
+
for exactly when it applies, and :meth:`developer_status`).
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
name: str
|
|
167
|
+
defaults: Mapping[str, Any]
|
|
168
|
+
casters: Mapping[str, Caster] | None = None
|
|
169
|
+
formatters: Mapping[str, Formatter] | None = None
|
|
170
|
+
env_vars: Mapping[str, str] | None = None
|
|
171
|
+
# Unlike the other two sources, .env reads whatever's sitting in
|
|
172
|
+
# the current directory looking for environment-variable-shaped
|
|
173
|
+
# secrets -- so, unlike them, it defaults to off. Pass
|
|
174
|
+
# dotenv_path=AUTO for the conventional ./.env, opting in on
|
|
175
|
+
# purpose, or a specific Path.
|
|
176
|
+
dotenv_path: Path | _AutoType | None = None
|
|
177
|
+
config_home_path: Path | _AutoType | None = AUTO
|
|
178
|
+
config_cwd_path: Path | None = field(default_factory=lambda: Path(".config.toml"))
|
|
179
|
+
default_table: str | None = None
|
|
180
|
+
# Machine-wide config (/etc/<name>/config.toml), the lowest-priority
|
|
181
|
+
# config file. Off by default, like .env: pass config_system_path=AUTO
|
|
182
|
+
# to opt in on purpose, or a specific Path. (Declared last so
|
|
183
|
+
# existing positional construction keeps working.)
|
|
184
|
+
config_system_path: Path | _AutoType | None = None
|
|
185
|
+
# Opt-in developer config layer: the pyproject.toml whose
|
|
186
|
+
# tool.conclude.developer.config names a private, gitignored file
|
|
187
|
+
# that beats the environment. Off by default; AUTO -> ./pyproject.toml.
|
|
188
|
+
pyproject_path: Path | _AutoType | None = None
|
|
189
|
+
# Which sibling files next to the project-local config file are
|
|
190
|
+
# merged in too (sorted by name, each beating the plain file).
|
|
191
|
+
# ``None`` keeps the project file but drops the sibling search --
|
|
192
|
+
# otherwise any stray ``.config.<anything>.toml`` joins the merge.
|
|
193
|
+
config_cwd_aux_pattern: str | None = ".config.*.toml"
|
|
194
|
+
# Hold the .env fallback to the same standard as the developer file:
|
|
195
|
+
# read it only if it exists, sits in a git working tree, and is
|
|
196
|
+
# gitignored. Off by default (a committed .env of non-secret defaults
|
|
197
|
+
# is common); needs the optional ``pathspec`` (conclude[gitignore]).
|
|
198
|
+
dotenv_require_gitignored: bool = False
|
|
199
|
+
|
|
200
|
+
def __post_init__(self) -> None:
|
|
201
|
+
if self.config_home_path is AUTO:
|
|
202
|
+
# frozen dataclass -- object.__setattr__ is the documented
|
|
203
|
+
# way to fill in a computed default in __post_init__.
|
|
204
|
+
object.__setattr__(self, "config_home_path", default_config_home_path(self.name))
|
|
205
|
+
if self.dotenv_path is AUTO:
|
|
206
|
+
object.__setattr__(self, "dotenv_path", Path(".env"))
|
|
207
|
+
if self.config_system_path is AUTO:
|
|
208
|
+
object.__setattr__(self, "config_system_path", default_config_system_path(self.name))
|
|
209
|
+
if self.pyproject_path is AUTO:
|
|
210
|
+
object.__setattr__(self, "pyproject_path", Path("pyproject.toml"))
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def resolved_defaults(self) -> dict[str, Any]:
|
|
214
|
+
"""``defaults`` with every :func:`conclude.opt` placeholder
|
|
215
|
+
unwrapped to the ``None`` it stands for."""
|
|
216
|
+
return effective_defaults(self.defaults)
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def resolved_casters(self) -> dict[str, Caster]:
|
|
220
|
+
"""A caster per setting: inferred from each default's type,
|
|
221
|
+
with ``self.casters`` overriding specific keys."""
|
|
222
|
+
return infer_casters(self.defaults, self.casters)
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def resolved_formatters(self) -> dict[str, Formatter]:
|
|
226
|
+
"""A formatter per setting -- the inverse of a caster, used by
|
|
227
|
+
:meth:`format_invocation` -- inferred from each default's
|
|
228
|
+
type, with ``self.formatters`` overriding specific keys."""
|
|
229
|
+
return infer_formatters(self.defaults, self.formatters)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def resolved_env_vars(self) -> dict[str, str]:
|
|
233
|
+
"""An ``APPNAME_SETTING``-style env var name per setting, with
|
|
234
|
+
``self.env_vars`` overriding specific keys."""
|
|
235
|
+
inferred = {key: env_var_name(self.name, key) for key in self.defaults}
|
|
236
|
+
inferred.update(self.env_vars or {})
|
|
237
|
+
return inferred
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def resolved_config_system_path(self) -> Path | None:
|
|
241
|
+
"""The system-wide config file path in effect -- ``None`` by
|
|
242
|
+
default (source disabled; see :data:`AUTO` for opting into the
|
|
243
|
+
conventional ``/etc/<name>/config.toml``), or whatever ``Path``
|
|
244
|
+
``config_system_path`` was set to. Lowest priority of the
|
|
245
|
+
config files: user-global, project-local, and everything above
|
|
246
|
+
those override it."""
|
|
247
|
+
return cast("Path | None", self.config_system_path) # AUTO resolved in __post_init__
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def resolved_pyproject_path(self) -> Path | None:
|
|
251
|
+
"""The ``pyproject.toml`` the developer config layer reads its
|
|
252
|
+
file location from -- ``None`` by default (layer not opted
|
|
253
|
+
into; see :data:`AUTO` for ``./pyproject.toml``), or whatever
|
|
254
|
+
``Path`` ``pyproject_path`` was set to."""
|
|
255
|
+
return cast("Path | None", self.pyproject_path) # AUTO resolved in __post_init__
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def resolved_config_home_path(self) -> Path | None:
|
|
259
|
+
"""The user-global config file path in effect -- the
|
|
260
|
+
conventional ``~/.config/<name>/config.toml`` (the default,
|
|
261
|
+
``config_home_path=AUTO``) unless set to a specific ``Path``,
|
|
262
|
+
or ``None`` if set to ``None`` (source disabled)."""
|
|
263
|
+
return cast("Path | None", self.config_home_path) # AUTO resolved in __post_init__
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def resolved_config_cwd_path(self) -> Path | None:
|
|
267
|
+
"""The project-local config file path in effect (default:
|
|
268
|
+
``./.config.toml``), or ``None`` if ``config_cwd_path`` was set
|
|
269
|
+
to ``None`` (source disabled, including the aux-file search
|
|
270
|
+
alongside it; see ``config_cwd_aux_pattern`` to drop only the
|
|
271
|
+
sibling search)."""
|
|
272
|
+
return self.config_cwd_path
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def resolved_dotenv_path(self) -> Path | None:
|
|
276
|
+
"""The ``.env`` file path in effect -- ``None`` by default
|
|
277
|
+
(source disabled; see :data:`AUTO` for opting into the
|
|
278
|
+
conventional ``./.env``), or whatever ``Path`` ``dotenv_path``
|
|
279
|
+
was set to."""
|
|
280
|
+
return cast("Path | None", self.dotenv_path) # AUTO resolved in __post_init__
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def resolved_default_table(self) -> str:
|
|
284
|
+
return self.default_table or self.name
|
|
285
|
+
|
|
286
|
+
def describe_sources(self, environ: Mapping[str, str] | None = None) -> str:
|
|
287
|
+
"""A short, human-readable summary of which config-file/``.env``
|
|
288
|
+
sources this app checks, lowest priority first -- or
|
|
289
|
+
``"disabled"`` for any turned off (or, for the system config
|
|
290
|
+
and ``.env`` file, never opted into; a ``.env`` file held to
|
|
291
|
+
``dotenv_require_gitignored`` also says whether it is active,
|
|
292
|
+
or why not; the project row also lists
|
|
293
|
+
the sibling-file pattern unless ``config_cwd_aux_pattern`` is
|
|
294
|
+
``None``) via
|
|
295
|
+
``config_system_path``/``config_home_path``/``config_cwd_path``/
|
|
296
|
+
``dotenv_path`` being ``None``.
|
|
297
|
+
|
|
298
|
+
The last row is the developer config layer, which is never
|
|
299
|
+
merely on or off: it says which of ``not opted in``,
|
|
300
|
+
``not configured``, ``configured, inactive (<why>)``, or
|
|
301
|
+
``configured, active`` it is (see :meth:`developer_status`;
|
|
302
|
+
``environ`` is only consulted for its kill switch).
|
|
303
|
+
|
|
304
|
+
Meant to be dropped into ``--help`` output (e.g. as part of an
|
|
305
|
+
``ArgumentParser``'s ``epilog``) -- which sources exist at all
|
|
306
|
+
is a fact about how this ``App`` was built, true for every run
|
|
307
|
+
of the program, so it belongs where someone goes to understand
|
|
308
|
+
the program's shape before running it. This is deliberately
|
|
309
|
+
*not* something :meth:`format_invocation` reports: that method
|
|
310
|
+
describes one run's already-resolved *values*, not which
|
|
311
|
+
layers were even in play to produce them.
|
|
312
|
+
|
|
313
|
+
This renders as aligned, multi-line text -- pass
|
|
314
|
+
``formatter_class=argparse.RawDescriptionHelpFormatter`` (or
|
|
315
|
+
``RawTextHelpFormatter``) to your ``ArgumentParser``, or
|
|
316
|
+
argparse's default formatter will re-wrap it into one run-on
|
|
317
|
+
paragraph and lose the alignment entirely.
|
|
318
|
+
"""
|
|
319
|
+
project = self.resolved_config_cwd_path
|
|
320
|
+
project_text = "disabled"
|
|
321
|
+
if project is not None:
|
|
322
|
+
project_text = str(project)
|
|
323
|
+
if self.config_cwd_aux_pattern is not None:
|
|
324
|
+
project_text += f", {project.parent / self.config_cwd_aux_pattern}"
|
|
325
|
+
|
|
326
|
+
def path_text(path: Path | None) -> str:
|
|
327
|
+
return str(path) if path is not None else "disabled"
|
|
328
|
+
|
|
329
|
+
texts = [
|
|
330
|
+
("system config", path_text(self.resolved_config_system_path)),
|
|
331
|
+
("user config", path_text(self.resolved_config_home_path)),
|
|
332
|
+
("project config", project_text),
|
|
333
|
+
(".env file", str(self.dotenv_status())),
|
|
334
|
+
("developer config", str(self.developer_status(environ))),
|
|
335
|
+
]
|
|
336
|
+
label_width = max(len(label) for label, _ in texts)
|
|
337
|
+
lines = ["config sources:"]
|
|
338
|
+
for label, value in texts:
|
|
339
|
+
lines.append(f" {label.ljust(label_width)} {value}")
|
|
340
|
+
return "\n".join(lines)
|
|
341
|
+
|
|
342
|
+
def add_arguments(
|
|
343
|
+
self,
|
|
344
|
+
parser: argparse.ArgumentParser,
|
|
345
|
+
*,
|
|
346
|
+
overrides: Mapping[str, Mapping[str, Any]] | None = None,
|
|
347
|
+
skip: Iterable[str] = (),
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Add one CLI flag per setting in ``defaults`` to ``parser``.
|
|
350
|
+
|
|
351
|
+
The flag name is inferred (``filter_col`` -> ``--filter-col``,
|
|
352
|
+
via :func:`conclude.naming.cli_flag_name`), ``dest`` is always
|
|
353
|
+
the setting's own key (so the parsed namespace's attributes
|
|
354
|
+
line up 1:1 with ``defaults``, whatever the flag looks like),
|
|
355
|
+
and the action is inferred from the setting's type: a
|
|
356
|
+
bool-typed setting (a bare ``False``/``True`` default, or
|
|
357
|
+
``opt(bool)``) becomes a ``store_true`` flag; anything else
|
|
358
|
+
becomes a plain value flag.
|
|
359
|
+
|
|
360
|
+
``overrides[key]``, if given, is a dict of kwargs merged into
|
|
361
|
+
that one flag's ``add_argument()`` call -- typically a
|
|
362
|
+
hand-written ``help`` string and/or ``metavar``, which
|
|
363
|
+
inference has no way to guess well. ``skip`` leaves specific
|
|
364
|
+
keys out of this loop entirely, for a setting you're adding a
|
|
365
|
+
hand-built flag for yourself instead (e.g. a positional
|
|
366
|
+
argument, or one flag standing in for several settings).
|
|
367
|
+
"""
|
|
368
|
+
overrides = overrides or {}
|
|
369
|
+
skip = set(skip)
|
|
370
|
+
for key, default in self.defaults.items():
|
|
371
|
+
if key in skip:
|
|
372
|
+
continue
|
|
373
|
+
type_ = default.type if isinstance(default, Opt) else type(default)
|
|
374
|
+
kwargs: dict[str, Any] = {"dest": key, "default": None}
|
|
375
|
+
if type_ is bool:
|
|
376
|
+
kwargs["action"] = "store_true"
|
|
377
|
+
else:
|
|
378
|
+
kwargs["metavar"] = key.upper()
|
|
379
|
+
kwargs.update(overrides.get(key, {}))
|
|
380
|
+
parser.add_argument(cli_flag_name(key), **kwargs)
|
|
381
|
+
|
|
382
|
+
def build_arg_parser(self, **parser_kwargs: Any) -> argparse.ArgumentParser:
|
|
383
|
+
"""A fresh ``argparse.ArgumentParser`` with one inferred flag
|
|
384
|
+
per setting already added via :meth:`add_arguments` -- for an
|
|
385
|
+
app that doesn't need anything beyond that. ``parser_kwargs``
|
|
386
|
+
pass straight through to ``ArgumentParser()`` (``prog``,
|
|
387
|
+
``description``, ...). For a parser that also needs a
|
|
388
|
+
positional argument, extra non-setting flags, or per-flag help
|
|
389
|
+
text, build the parser yourself and call :meth:`add_arguments`
|
|
390
|
+
(with ``overrides``/``skip`` as needed) instead of this.
|
|
391
|
+
"""
|
|
392
|
+
parser = argparse.ArgumentParser(**parser_kwargs)
|
|
393
|
+
self.add_arguments(parser)
|
|
394
|
+
return parser
|
|
395
|
+
|
|
396
|
+
def add_print_invocation_argument(
|
|
397
|
+
self,
|
|
398
|
+
parser: argparse.ArgumentParser,
|
|
399
|
+
*,
|
|
400
|
+
flag: str = "--print-invocation",
|
|
401
|
+
dest: str = "print_invocation",
|
|
402
|
+
help: str | None = None,
|
|
403
|
+
) -> None:
|
|
404
|
+
"""Add a conventional ``--print-invocation`` flag to ``parser``.
|
|
405
|
+
|
|
406
|
+
This only adds the flag -- it's the same shape for every app
|
|
407
|
+
(a bare, ``store_true`` flag defaulting to ``False``), so
|
|
408
|
+
there's nothing to infer per setting the way
|
|
409
|
+
:meth:`add_arguments` infers one flag per entry in
|
|
410
|
+
``defaults``. Your app checks ``namespace.<dest>`` itself, same
|
|
411
|
+
as any other non-setting flag (``--diagnose``, say), and if
|
|
412
|
+
it's true, prints ``app.format_invocation(resolved, ...)`` and
|
|
413
|
+
exits however it normally would -- conclude doesn't force an
|
|
414
|
+
exit here, since that's a decision about your app's own
|
|
415
|
+
control flow, not something a config library should make for
|
|
416
|
+
you.
|
|
417
|
+
"""
|
|
418
|
+
parser.add_argument(
|
|
419
|
+
flag,
|
|
420
|
+
dest=dest,
|
|
421
|
+
action="store_true",
|
|
422
|
+
default=False,
|
|
423
|
+
help=help
|
|
424
|
+
or (
|
|
425
|
+
"Print the equivalent standalone command line, with every "
|
|
426
|
+
"setting fully resolved through the CLI/env/config-file/"
|
|
427
|
+
"default chain, then exit without doing anything else."
|
|
428
|
+
),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
def load_env(self, environ: Mapping[str, str] | None = None) -> dict[str, Any]:
|
|
432
|
+
"""The environment-variable layer, using ``resolved_env_vars``.
|
|
433
|
+
|
|
434
|
+
Falls back to a ``.env`` file at ``self.resolved_dotenv_path``
|
|
435
|
+
for any variable not actually set in the real environment --
|
|
436
|
+
see :func:`conclude.load_dotenv` for the file format, and
|
|
437
|
+
:func:`conclude.load_env` for why a real environment variable
|
|
438
|
+
always wins over it. Off by default (``dotenv_path=None``);
|
|
439
|
+
pass ``dotenv_path=AUTO`` (for the conventional ``./.env``) or
|
|
440
|
+
a specific ``Path`` to this ``App`` to enable it. With
|
|
441
|
+
``dotenv_require_gitignored=True`` the file is read only if it
|
|
442
|
+
is gitignored (see :meth:`dotenv_status`); a missing ``pathspec``
|
|
443
|
+
then raises ``ImportError``.
|
|
444
|
+
"""
|
|
445
|
+
return _load_env(
|
|
446
|
+
self.resolved_env_vars,
|
|
447
|
+
environ,
|
|
448
|
+
dotenv_path=self.resolved_dotenv_path,
|
|
449
|
+
require_gitignored=self.dotenv_require_gitignored,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
def dotenv_status(self) -> DotenvStatus:
|
|
453
|
+
"""Where the ``.env`` fallback stands -- see
|
|
454
|
+
:class:`conclude.DotenvStatus`. Never raises. Without
|
|
455
|
+
``dotenv_require_gitignored`` it is ``DISABLED`` or plain
|
|
456
|
+
``ACTIVE``; with it, ``ACTIVE`` only if the file passes the
|
|
457
|
+
gitignore guard (:mod:`conclude.guard`), else ``INACTIVE`` with
|
|
458
|
+
the reason.
|
|
459
|
+
"""
|
|
460
|
+
return _dotenv_status(
|
|
461
|
+
self.resolved_dotenv_path, require_gitignored=self.dotenv_require_gitignored
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def load_config_files(
|
|
465
|
+
self,
|
|
466
|
+
table_path: list[str] | None = None,
|
|
467
|
+
*,
|
|
468
|
+
cwd_path: Path | None = None,
|
|
469
|
+
) -> dict[str, Any]:
|
|
470
|
+
"""The config-file layer, merged across the usual locations --
|
|
471
|
+
see :func:`conclude.load_config_files`. Defaults to this app's
|
|
472
|
+
own ``[name]`` table if ``table_path`` isn't given, and this
|
|
473
|
+
app's own ``resolved_config_system_path``/
|
|
474
|
+
``resolved_config_home_path``/``resolved_config_cwd_path`` (any
|
|
475
|
+
of which may be ``None``, disabling that source) if
|
|
476
|
+
``cwd_path`` isn't given here.
|
|
477
|
+
"""
|
|
478
|
+
return _load_config_files(
|
|
479
|
+
self.resolved_config_home_path,
|
|
480
|
+
cwd_path if cwd_path is not None else self.resolved_config_cwd_path,
|
|
481
|
+
self.resolved_defaults,
|
|
482
|
+
table_path or [self.resolved_default_table],
|
|
483
|
+
aux_pattern=self.config_cwd_aux_pattern,
|
|
484
|
+
system_path=self.resolved_config_system_path,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def developer_status(self, environ: Mapping[str, str] | None = None) -> DeveloperStatus:
|
|
488
|
+
"""Where the developer config layer stands -- see
|
|
489
|
+
:class:`conclude.DeveloperStatus`. Never raises, so it's safe to
|
|
490
|
+
call from ``--help`` or a diagnostics flag. The kill switch is
|
|
491
|
+
the ``<NAME>_DEVELOPER_CONFIG`` environment variable (from
|
|
492
|
+
``environ``, default ``os.environ``): set it to ``off`` to
|
|
493
|
+
deactivate a configured file, e.g. in a deployment.
|
|
494
|
+
"""
|
|
495
|
+
return _developer_status(
|
|
496
|
+
self.resolved_pyproject_path,
|
|
497
|
+
kill_switch_var=env_var_name(self.name, "developer_config"),
|
|
498
|
+
environ=environ,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
def load_developer_config(
|
|
502
|
+
self,
|
|
503
|
+
table_path: list[str] | None = None,
|
|
504
|
+
*,
|
|
505
|
+
environ: Mapping[str, str] | None = None,
|
|
506
|
+
) -> dict[str, Any]:
|
|
507
|
+
"""The developer config layer -- ``{}`` unless
|
|
508
|
+
:meth:`developer_status` is active. A ``.toml`` file is read
|
|
509
|
+
like a config file: the same table(s) (this app's own ``[name]``
|
|
510
|
+
table if ``table_path`` isn't given; pass the same
|
|
511
|
+
``table_path`` you gave :meth:`load_config_files` when you chose
|
|
512
|
+
one yourself, see :meth:`resolve_config_table`, so both layers
|
|
513
|
+
read the same table). Any other file name is dotenv -- say
|
|
514
|
+
``.env.local`` -- and is keyed by ``resolved_env_vars``, with no
|
|
515
|
+
tables, so ``table_path`` doesn't apply to it.
|
|
516
|
+
"""
|
|
517
|
+
return _load_developer_config(
|
|
518
|
+
self.developer_status(environ),
|
|
519
|
+
self.resolved_defaults,
|
|
520
|
+
table_path or [self.resolved_default_table],
|
|
521
|
+
env_vars=self.resolved_env_vars,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
def resolve_config_table(
|
|
525
|
+
self,
|
|
526
|
+
*,
|
|
527
|
+
config_value: str | None = None,
|
|
528
|
+
shorthand_value: str | None = None,
|
|
529
|
+
cwd_path: Path | None = None,
|
|
530
|
+
) -> list[str]:
|
|
531
|
+
"""Which config-file table(s) to read -- see
|
|
532
|
+
:func:`conclude.resolve_config_table`, using this app's own
|
|
533
|
+
default table name and config-file paths (either of which may
|
|
534
|
+
be ``None``, disabling that source from the shorthand lookup)."""
|
|
535
|
+
return _resolve_config_table(
|
|
536
|
+
config_value=config_value,
|
|
537
|
+
shorthand_value=shorthand_value,
|
|
538
|
+
default_table=self.resolved_default_table,
|
|
539
|
+
home_path=self.resolved_config_home_path,
|
|
540
|
+
cwd_path=cwd_path if cwd_path is not None else self.resolved_config_cwd_path,
|
|
541
|
+
aux_pattern=self.config_cwd_aux_pattern,
|
|
542
|
+
system_path=self.resolved_config_system_path,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
def resolve(
|
|
546
|
+
self,
|
|
547
|
+
cli: Mapping[str, Any],
|
|
548
|
+
env: Mapping[str, Any] | None = None,
|
|
549
|
+
config_file: Mapping[str, Any] | None = None,
|
|
550
|
+
developer: Mapping[str, Any] | None = None,
|
|
551
|
+
) -> dict[str, Any]:
|
|
552
|
+
"""Merge ``cli`` (already parsed, e.g. from
|
|
553
|
+
``vars(namespace)``, with unset/``None`` keys left in or
|
|
554
|
+
dropped -- either way, only non-``None`` values win) with the
|
|
555
|
+
env-var, config-file, and developer-config layers -- loading
|
|
556
|
+
whichever of those three isn't passed in already -- against
|
|
557
|
+
this app's inferred defaults and casters, lowest-priority-first
|
|
558
|
+
(``defaults < config files < env < developer < cli``). Pass
|
|
559
|
+
``env``/``config_file``/``developer`` yourself when you need to
|
|
560
|
+
choose a specific config table first (see
|
|
561
|
+
:meth:`resolve_config_table`) rather than this app's plain
|
|
562
|
+
default table -- ``developer=app.load_developer_config(table_path)``
|
|
563
|
+
alongside ``config_file=app.load_config_files(table_path)``.
|
|
564
|
+
"""
|
|
565
|
+
if env is None:
|
|
566
|
+
env = self.load_env()
|
|
567
|
+
if config_file is None:
|
|
568
|
+
config_file = self.load_config_files()
|
|
569
|
+
if developer is None:
|
|
570
|
+
developer = self.load_developer_config()
|
|
571
|
+
return _resolve(
|
|
572
|
+
cli,
|
|
573
|
+
env,
|
|
574
|
+
config_file,
|
|
575
|
+
self.resolved_defaults,
|
|
576
|
+
self.resolved_casters,
|
|
577
|
+
developer=developer,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
def format_invocation(
|
|
581
|
+
self,
|
|
582
|
+
resolved: Mapping[str, Any],
|
|
583
|
+
*,
|
|
584
|
+
prog: str | None = None,
|
|
585
|
+
formatters: Mapping[str, Formatter] | None = None,
|
|
586
|
+
compare_defaults: Mapping[str, Any] | None = None,
|
|
587
|
+
always_include: Iterable[str] = (),
|
|
588
|
+
skip: Iterable[str] = (),
|
|
589
|
+
) -> str:
|
|
590
|
+
"""Render a standalone command line that reproduces ``resolved``
|
|
591
|
+
(a dict from :meth:`resolve`, or an equivalent one you built
|
|
592
|
+
yourself) with no env vars, config files, or CLI shorthand
|
|
593
|
+
needed to get back to the same result -- handy for debugging a
|
|
594
|
+
confusing resolved setup, or turning one into a documented,
|
|
595
|
+
copy-pasteable command.
|
|
596
|
+
|
|
597
|
+
A setting whose resolved value equals its default (see
|
|
598
|
+
``compare_defaults``) is left out entirely: omitting a flag
|
|
599
|
+
already reproduces that default on its own, so there's nothing
|
|
600
|
+
to gain by spelling it out, and every setting an app defines
|
|
601
|
+
is covered by this rule automatically -- there's no separate
|
|
602
|
+
list of "settings worth mentioning" to maintain by hand. A
|
|
603
|
+
setting genuinely required in practice (no real default,
|
|
604
|
+
conventionally written ``opt(str)`` or similar) falls out of
|
|
605
|
+
this the same way, with no special-casing needed: its
|
|
606
|
+
"default" is ``None``, a resolved run essentially never
|
|
607
|
+
matches that, so it's always included.
|
|
608
|
+
|
|
609
|
+
``prog``, if given, is prepended as the first token (with
|
|
610
|
+
nothing else quoted around it); leave it out to get back just
|
|
611
|
+
the space-joined flags, e.g. to assemble your own command line
|
|
612
|
+
around them (see ``skip`` below).
|
|
613
|
+
|
|
614
|
+
``formatters`` overrides specific keys' inferred formatter --
|
|
615
|
+
pair it with a matching ``casters`` override on this ``App``
|
|
616
|
+
for a setting whose caster does more than a plain type
|
|
617
|
+
conversion (e.g. decodes backslash escapes), since the
|
|
618
|
+
inferred formatter for that setting's bare type would produce
|
|
619
|
+
text that doesn't cast back to the same value.
|
|
620
|
+
|
|
621
|
+
``compare_defaults`` overrides what counts as "the default" for
|
|
622
|
+
this call only, instead of this app's own ``resolved_defaults``
|
|
623
|
+
-- for a setting whose *effective* default is something other
|
|
624
|
+
than what's in ``defaults`` because your app substitutes a
|
|
625
|
+
fallback value after ``resolve()`` runs (e.g. an ``opt(str)``
|
|
626
|
+
setting that becomes a hardcoded constant when still ``None``
|
|
627
|
+
after merging). Pass ``{**app.resolved_defaults, "key": value}``
|
|
628
|
+
to override just that one key.
|
|
629
|
+
|
|
630
|
+
``always_include`` forces specific keys to be shown even when
|
|
631
|
+
they equal their default. ``skip`` leaves specific keys out of
|
|
632
|
+
this call entirely -- typically because your app renders them
|
|
633
|
+
itself, with its own conditional logic ``format_invocation``
|
|
634
|
+
has no way to know about (e.g. a flag only meaningful given
|
|
635
|
+
some other setting's value, or one setting whose value gets
|
|
636
|
+
folded into another's before display).
|
|
637
|
+
"""
|
|
638
|
+
formatters = infer_formatters(self.defaults, formatters or self.formatters)
|
|
639
|
+
defaults = compare_defaults if compare_defaults is not None else self.resolved_defaults
|
|
640
|
+
always_include = set(always_include)
|
|
641
|
+
skip = set(skip)
|
|
642
|
+
|
|
643
|
+
parts = [prog] if prog else []
|
|
644
|
+
for key in self.defaults:
|
|
645
|
+
if key in skip:
|
|
646
|
+
continue
|
|
647
|
+
value = resolved.get(key)
|
|
648
|
+
if value == defaults.get(key) and key not in always_include:
|
|
649
|
+
continue
|
|
650
|
+
rendered = formatters[key](value)
|
|
651
|
+
if rendered is None:
|
|
652
|
+
continue
|
|
653
|
+
flag = cli_flag_name(key)
|
|
654
|
+
parts.append(flag if rendered == "" else f"{flag}={rendered}")
|
|
655
|
+
return " ".join(parts)
|
|
656
|
+
|
|
657
|
+
def _template_rows(
|
|
658
|
+
self,
|
|
659
|
+
skip: Iterable[str],
|
|
660
|
+
formatters: Mapping[str, Formatter] | None,
|
|
661
|
+
defaults: Mapping[str, Any] | None,
|
|
662
|
+
) -> list[tuple[str, Any, Formatter | None]]:
|
|
663
|
+
"""``(key, default value, explicit formatter or None)`` per
|
|
664
|
+
setting, in ``defaults`` order, for the ``format_env``/
|
|
665
|
+
``format_toml``/``format_cli`` template generators. Only an
|
|
666
|
+
*explicitly* overridden formatter is ever returned: a setting
|
|
667
|
+
of a plain built-in type renders directly from its value, so
|
|
668
|
+
these never need (and never raise for lack of) an inferred
|
|
669
|
+
formatter, unlike :meth:`format_invocation`.
|
|
670
|
+
"""
|
|
671
|
+
overrides = formatters or self.formatters or {}
|
|
672
|
+
values = {**self.resolved_defaults, **effective_defaults(defaults or {})}
|
|
673
|
+
skip = set(skip)
|
|
674
|
+
return [(key, values[key], overrides.get(key)) for key in self.defaults if key not in skip]
|
|
675
|
+
|
|
676
|
+
def format_env(
|
|
677
|
+
self,
|
|
678
|
+
*,
|
|
679
|
+
skip: Iterable[str] = (),
|
|
680
|
+
formatters: Mapping[str, Formatter] | None = None,
|
|
681
|
+
defaults: Mapping[str, Any] | None = None,
|
|
682
|
+
) -> str:
|
|
683
|
+
"""An environment-variable template: one ``NAME=value`` line
|
|
684
|
+
per setting, with the env var name from ``resolved_env_vars``
|
|
685
|
+
and the setting's default as the value --
|
|
686
|
+
|
|
687
|
+
.. code-block:: text
|
|
688
|
+
|
|
689
|
+
MYAPP_HOST=localhost
|
|
690
|
+
MYAPP_PORT=8080
|
|
691
|
+
MYAPP_DEBUG=false
|
|
692
|
+
|
|
693
|
+
The output is valid ``.env`` file syntax (see
|
|
694
|
+
:func:`conclude.load_dotenv`): a value is quoted only when it
|
|
695
|
+
has to be, and always so that it reads back as exactly the
|
|
696
|
+
default. A setting with no default (``None``, i.e. ``opt(str)``
|
|
697
|
+
and friends) is a commented-out ``# NAME=`` placeholder,
|
|
698
|
+
since an unset variable and an empty one are different things
|
|
699
|
+
to a shell.
|
|
700
|
+
|
|
701
|
+
``skip`` leaves specific settings out. ``defaults`` overrides
|
|
702
|
+
what's shown as the default for this call only (merged over
|
|
703
|
+
this app's own ``resolved_defaults``) -- for a setting whose
|
|
704
|
+
*effective* default isn't what's in ``defaults`` because your
|
|
705
|
+
app substitutes a fallback after :meth:`resolve` runs (e.g. an
|
|
706
|
+
``opt(str)`` that becomes a hardcoded constant when still
|
|
707
|
+
``None``). ``formatters`` overrides the explicit formatters for
|
|
708
|
+
this call (default: this app's own ``formatters``); only a
|
|
709
|
+
setting with such an explicit formatter uses one here -- pair
|
|
710
|
+
it with its custom caster so the text casts back to the same
|
|
711
|
+
value.
|
|
712
|
+
"""
|
|
713
|
+
env_vars = self.resolved_env_vars
|
|
714
|
+
lines = []
|
|
715
|
+
for key, value, formatter in self._template_rows(skip, formatters, defaults):
|
|
716
|
+
text = plain_text(value, formatter)
|
|
717
|
+
name = env_vars[key]
|
|
718
|
+
lines.append(f"# {name}=" if text is None else f"{name}={env_value(text)}")
|
|
719
|
+
return "\n".join(lines)
|
|
720
|
+
|
|
721
|
+
def format_toml(
|
|
722
|
+
self,
|
|
723
|
+
*,
|
|
724
|
+
table: str | Sequence[str] | None = None,
|
|
725
|
+
header: bool = True,
|
|
726
|
+
skip: Iterable[str] = (),
|
|
727
|
+
formatters: Mapping[str, Formatter] | None = None,
|
|
728
|
+
defaults: Mapping[str, Any] | None = None,
|
|
729
|
+
) -> str:
|
|
730
|
+
"""A config-file template: a ``[table]`` header and one
|
|
731
|
+
``key = value`` line per setting, with the setting's default as
|
|
732
|
+
the value --
|
|
733
|
+
|
|
734
|
+
.. code-block:: text
|
|
735
|
+
|
|
736
|
+
[myapp]
|
|
737
|
+
host = "localhost"
|
|
738
|
+
port = 8080
|
|
739
|
+
debug = false
|
|
740
|
+
|
|
741
|
+
Values are native TOML (``8080``, ``false``, ``["a", "b"]``,
|
|
742
|
+
not strings) so the template reads like a config file a person
|
|
743
|
+
would write; a setting with an explicit formatter (see
|
|
744
|
+
below) is written as the string that formatter produces,
|
|
745
|
+
since that's the text its custom caster expects. A setting with
|
|
746
|
+
no default (``None``) is a commented-out ``# key =``
|
|
747
|
+
placeholder.
|
|
748
|
+
|
|
749
|
+
``table`` is the header's name -- a string, or a sequence for a
|
|
750
|
+
nested table (``["myapp", "deck"]`` -> ``[myapp.deck]``) --
|
|
751
|
+
defaulting to this app's ``resolved_default_table``. Pass
|
|
752
|
+
``header=False`` for just the key lines, to paste under a
|
|
753
|
+
header that's already in the file. ``skip``, ``formatters``,
|
|
754
|
+
and ``defaults`` work as in :meth:`format_env`.
|
|
755
|
+
"""
|
|
756
|
+
lines = []
|
|
757
|
+
if header:
|
|
758
|
+
if table is None:
|
|
759
|
+
path = [self.resolved_default_table]
|
|
760
|
+
elif isinstance(table, str):
|
|
761
|
+
path = [table]
|
|
762
|
+
else:
|
|
763
|
+
path = list(table)
|
|
764
|
+
if path:
|
|
765
|
+
lines.append("[" + ".".join(toml_key(part) for part in path) + "]")
|
|
766
|
+
for key, value, formatter in self._template_rows(skip, formatters, defaults):
|
|
767
|
+
name = toml_key(config_key_name(key))
|
|
768
|
+
if formatter is not None and not isinstance(value, bool):
|
|
769
|
+
text = plain_text(value, formatter)
|
|
770
|
+
rendered = None if text is None else toml_string(text)
|
|
771
|
+
else:
|
|
772
|
+
rendered = None if value is None else toml_value(value)
|
|
773
|
+
lines.append(f"# {name} =" if rendered is None else f"{name} = {rendered}")
|
|
774
|
+
return "\n".join(lines)
|
|
775
|
+
|
|
776
|
+
def format_cli(
|
|
777
|
+
self,
|
|
778
|
+
*,
|
|
779
|
+
overrides: Mapping[str, Mapping[str, Any]] | None = None,
|
|
780
|
+
skip: Iterable[str] = (),
|
|
781
|
+
formatters: Mapping[str, Formatter] | None = None,
|
|
782
|
+
defaults: Mapping[str, Any] | None = None,
|
|
783
|
+
) -> str:
|
|
784
|
+
"""A CLI reference: one line per flag :meth:`add_arguments`
|
|
785
|
+
adds, with a ``(default: ...)`` column, aligned --
|
|
786
|
+
|
|
787
|
+
.. code-block:: text
|
|
788
|
+
|
|
789
|
+
--host <HOST> (default: localhost)
|
|
790
|
+
--port <PORT> (default: 8080)
|
|
791
|
+
--debug (default: false)
|
|
792
|
+
--timeout <TIMEOUT> (default: 30)
|
|
793
|
+
|
|
794
|
+
A bool setting is a bare flag; every other setting shows its
|
|
795
|
+
metavar (``key.upper()``, or ``overrides[key]["metavar"]`` --
|
|
796
|
+
pass the same ``overrides`` you gave :meth:`add_arguments` so
|
|
797
|
+
the two agree; nothing else in it, ``help`` included, is
|
|
798
|
+
rendered). A setting with no default (``None``) shows
|
|
799
|
+
``(default: none)``, and an empty string shows ``""``.
|
|
800
|
+
|
|
801
|
+
``skip``, ``formatters``, and ``defaults`` work as in
|
|
802
|
+
:meth:`format_env`; ``skip`` should match the ``skip`` you gave
|
|
803
|
+
:meth:`add_arguments`, so a flag you build by hand isn't
|
|
804
|
+
listed.
|
|
805
|
+
"""
|
|
806
|
+
overrides = overrides or {}
|
|
807
|
+
rows: list[tuple[str, str]] = []
|
|
808
|
+
for key, value, formatter in self._template_rows(skip, formatters, defaults):
|
|
809
|
+
declared = self.defaults[key]
|
|
810
|
+
type_ = declared.type if isinstance(declared, Opt) else type(declared)
|
|
811
|
+
flag = cli_flag_name(key)
|
|
812
|
+
if type_ is not bool:
|
|
813
|
+
metavar = overrides.get(key, {}).get("metavar", key.upper())
|
|
814
|
+
metavars = [metavar] if isinstance(metavar, str) else list(metavar)
|
|
815
|
+
flag += " " + " ".join(f"<{name}>" for name in metavars)
|
|
816
|
+
text = plain_text(value, formatter)
|
|
817
|
+
rows.append((flag, "none" if text is None else text or '""'))
|
|
818
|
+
width = max((len(flag) for flag, _ in rows), default=0)
|
|
819
|
+
return "\n".join(f"{flag.ljust(width)} (default: {text})" for flag, text in rows)
|