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/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)