Skip to content

Commit d9dc0ad

Browse files
simplify flatten_namespace, add docstring for argparsing
1 parent b73b5fe commit d9dc0ad

File tree

1 file changed

+117
-28
lines changed

1 file changed

+117
-28
lines changed

src/borg/helpers/argparsing.py

Lines changed: 117 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,99 @@
1+
"""
2+
Borg argument-parsing layer
3+
===========================
4+
5+
All imports of ``ArgumentParser``, ``Namespace``, ``SUPPRESS``, etc. come
6+
from this module. It is the single seam between borg and the underlying
7+
parser library (jsonargparse).
8+
9+
Library choice
10+
--------------
11+
Borg uses **jsonargparse** instead of plain argparse. jsonargparse is a
12+
superset of argparse that additionally supports:
13+
14+
* reading arguments from YAML/JSON config files (``--config``)
15+
* reading arguments from environment variables
16+
* nested namespaces for subcommands (each subcommand's arguments live in
17+
their own ``Namespace`` object rather than the flat top-level namespace)
18+
19+
Parser hierarchy
20+
----------------
21+
Borg's command line has up to three levels::
22+
23+
borg [common-opts] <command> [common-opts] [<subcommand> [common-opts] [args]]
24+
25+
e.g. borg --info create ...
26+
borg create --info ...
27+
borg debug info --debug ...
28+
29+
Three ``ArgumentParser`` instances are constructed in ``build_parser()``:
30+
31+
``parser`` (top-level)
32+
The root parser. Common options are registered here **with real
33+
defaults** (``provide_defaults=True``).
34+
35+
``common_parser``
36+
A helper parser (``add_help=False``) passed as ``parents=[common_parser]``
37+
to every *leaf* subcommand parser (e.g. ``create``, ``repo-create``, …).
38+
Common options are registered here **with** ``default=SUPPRESS`` so that
39+
an option not given on the command line leaves no attribute at all in the
40+
subcommand namespace.
41+
42+
``mid_common_parser``
43+
Same as ``common_parser`` but used as the parent for *group* subcommand
44+
parsers that introduce a second level (e.g. ``debug``, ``key``,
45+
``benchmark``). Their *leaf* subcommand parsers also use
46+
``mid_common_parser`` as a parent.
47+
48+
Common options (``--info``, ``--debug``, ``--repo``, ``--lock-wait``, …)
49+
are managed by ``Archiver.CommonOptions``, which calls
50+
``define_common_options()`` once per parser so the same options appear at
51+
every level with identical ``dest`` names.
52+
53+
Namespace flattening and precedence
54+
-------------------------------------
55+
jsonargparse stores each subcommand's parsed values in a nested
56+
``Namespace`` object::
57+
58+
# borg --info create --debug ...
59+
Namespace(
60+
log_level = "info", # top-level
61+
subcommand = "create",
62+
create = Namespace(
63+
log_level = "debug", # subcommand level
64+
...
65+
)
66+
)
67+
68+
After ``parser.parse_args()`` returns, ``flatten_namespace()`` collapses
69+
this tree into a single ``Namespace`` that borg's dispatch and command
70+
implementations expect.
71+
72+
Precedence rule: the **most-specific** (innermost) value wins.
73+
``flatten_namespace`` uses ``Namespace.as_flat()`` (provided by jsonargparse)
74+
to linearise the nested tree into a flat dict with dotted keys encoding
75+
depth, for example::
76+
77+
log_level = "info" # top-level (0 dots)
78+
create.log_level = "debug" # one level deep (1 dot)
79+
debug.info.log_level = "critical" # two levels deep (2 dots)
80+
81+
The entries are then sorted deepest-first so the most-specific value is
82+
encountered first and wins. Shallower values only fill in if the key
83+
has not been set yet.
84+
85+
Special case — append-action options (e.g. ``--debug-topic``):
86+
If a key already holds a list and the outer level also supplies a list,
87+
the two lists are **merged** (outer values first, inner values last) so
88+
that ``borg --debug-topic foo create --debug-topic bar`` accumulates
89+
``["foo", "bar"]`` rather than losing one of the values.
90+
91+
The ``SUPPRESS`` default on sub-parsers is essential: if a common option
92+
is not given at the subcommand level, it simply produces no attribute in
93+
the subcommand namespace and the outer (top-level) default flows through
94+
unchanged.
95+
"""
96+
197
from typing import Any
298

399
# here are the only imports from argparse and jsonargparse,
@@ -13,17 +109,16 @@
13109

14110
def flatten_namespace(ns: Any) -> Namespace:
15111
"""
16-
Recursively flattens a nested namespace into a single-level namespace.
17-
JSONArgparse uses nested namespaces for subcommands, whereas borg's
18-
internal dispatch and logic expect a flat namespace.
112+
Flattens the nested namespace jsonargparse produces for subcommands into a
113+
single-level namespace that borg's dispatch and command implementations expect.
19114
20115
Inner (subcommand) values take precedence over outer (top-level) values.
21116
For list-typed values (append-action options like --debug-topic) that appear
22117
at multiple levels, the lists are merged: outer values first, inner values last.
23118
"""
24119
flat = Namespace()
25120

26-
# Extract the nested path of subcommands
121+
# Extract the joined subcommand path from the nested namespace tree.
27122
subcmds = []
28123
current = ns
29124
while current and hasattr(current, "subcommand") and current.subcommand:
@@ -33,28 +128,22 @@ def flatten_namespace(ns: Any) -> Namespace:
33128
if subcmds:
34129
flat.subcommand = " ".join(subcmds)
35130

36-
def _flatten(source, target):
37-
items = list(
38-
vars(source).items() if hasattr(source, "__dict__") else source.items() if hasattr(source, "items") else []
39-
)
40-
# First pass: recurse into sub-namespaces so inner (subcommand) values are set first.
41-
for k, v in items:
42-
if isinstance(v, Namespace) or type(v).__name__ == "Namespace":
43-
_flatten(v, target)
44-
# Second pass: apply this level's plain values.
45-
# - If not yet set: set it (inner already won via the first pass).
46-
# - If already set and both are lists: merge outer + inner (for append-action options).
47-
for k, v in items:
48-
if isinstance(v, Namespace) or type(v).__name__ == "Namespace":
49-
continue
50-
if k == "subcommand":
51-
continue
52-
existing = getattr(target, k, None)
53-
if existing is None:
54-
setattr(target, k, v)
55-
elif isinstance(existing, list) and isinstance(v, list):
56-
# Append-action options (e.g. --debug-topic): outer values come first.
57-
setattr(target, k, list(v) + list(existing))
58-
59-
_flatten(ns, flat)
131+
# as_flat() linearises the nested tree into dotted-key entries, e.g.:
132+
# log_level='info' (outer, 0 dots)
133+
# create.log_level='debug' (subcommand, 1 dot)
134+
# debug.info.log_level='crit' (two-level subcommand, 2 dots)
135+
# Sorting deepest-first ensures the most-specific value is processed first and therefore wins ("inner wins" rule).
136+
all_items = sorted(vars(ns.as_flat()).items(), key=lambda kv: kv[0].count("."), reverse=True)
137+
138+
for dotted_key, value in all_items:
139+
dest = dotted_key.rsplit(".", 1)[-1] # e.g. "create.log_level" -> "log_level"
140+
if dest == "subcommand":
141+
continue
142+
existing = getattr(flat, dest, None)
143+
if existing is None:
144+
setattr(flat, dest, value)
145+
elif isinstance(existing, list) and isinstance(value, list):
146+
# Append-action options (e.g. --debug-topic): outer values come first.
147+
setattr(flat, dest, list(value) + list(existing))
148+
60149
return flat

0 commit comments

Comments
 (0)