• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

KenKundert / nestedtext / 27439362261

12 Jun 2026 07:52PM UTC coverage: 94.233% (-5.8%) from 100.0%
27439362261

push

github

Ken Kundert
add comment and spacing support to keymaps, remove deprecated functions

493 of 538 branches covered (91.64%)

Branch coverage included in aggregate %.

350 of 405 new or added lines in 1 file covered. (86.42%)

1141 of 1196 relevant lines covered (95.4%)

1.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.22
/nestedtext/nestedtext.py
1
# encoding: utf8
2
"""
3
NestedText: A Human Readable and Writable Data Format
4

5
NestedText is a file format for holding structured data that is intended to be
6
entered, edited, or viewed by people.  It allows data to be organized into a
7
nested collection of itemized lists (dictionaries), ordered lists (lists), and
8
scalar text (strings).
9

10
It is easily created, modified, or viewed with a text editor and easily
11
understood and used by both programmers and non-programmers.
12
"""
13

14
# MIT License {{{1
15
# Copyright (c) 2020-2026 Ken and Kale Kundert
16
#
17
# Permission is hereby granted, free of charge, to any person obtaining a copy
18
# of this software and associated documentation files (the "Software"), to deal
19
# in the Software without restriction, including without limitation the rights
20
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
21
# copies of the Software, and to permit persons to whom the Software is
22
# furnished to do so, subject to the following conditions:
23
#
24
# The above copyright notice and this permission notice shall be included in all
25
# copies or substantial portions of the Software.
26
#
27
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
28
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
29
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
30
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
31
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
32
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
33
# SOFTWARE.
34

35
# Imports {{{1
36
from inform import (
2✔
37
    cull,
38
    full_stop,
39
    set_culprit,
40
    get_culprit,
41
    is_str,
42
    is_collection,
43
    is_mapping,
44
    join,
45
    plural,
46
    Error,
47
    Info,
48
)
49
import collections.abc
2✔
50
import io
2✔
51
import re
2✔
52
import unicodedata
2✔
53

54

55
# Utility functions {{{1
56
# convert_line_terminators {{{2
57
def convert_line_terminators(text):
2✔
58
    return text.replace("\r\n", "\n").replace("\r", "\n")
2✔
59

60

61
# Unspecified {{{2
62
# class that is used as a default in functions to signal nothing was given
63
class _Unspecified:
2✔
64
    def __bool__(self):  # pragma: no cover
65
        return False
66

67

68
# OnDupCallback {{{2
69
class _OnDupCallback(_Unspecified):
2✔
70
    pass
2✔
71

72

73
# Exceptions {{{1
74
# NestedTextError {{{2
75
class NestedTextError(Error, ValueError):
2✔
76
    r'''
77
    The *load* and *dump* functions all raise *NestedTextError* when they
78
    discover an error. *NestedTextError* subclasses both the Python *ValueError*
79
    and the *Error* exception from *Inform*.  You can find more documentation on
80
    what you can do with this exception in the `Inform documentation
81
    <https://inform.readthedocs.io/en/stable/api.html#exceptions>`_.
82

83
    All exceptions provide the following attributes:
84

85
    Attributes:
86
        args:
87
            The exception arguments.  A tuple that usually contains the
88
            problematic value.
89

90
        template:
91
            The possibly parameterized text used for the error message.
92

93
    Exceptions raised by the :func:`loads()` or :func:`load()` functions provide
94
    the following additional attributes:
95

96
    Attributes:
97
        source:
98
            The source of the *NestedText* content, if given. This is often a
99
            filename.
100

101
        line:
102
            The text of the line of *NestedText* content where the problem was found.
103

104
        prev_line:
105
            The text of the meaningful line immediately before where the problem was
106
            found.  This will not be a comment or blank line.
107

108
        lineno:
109
            The number of the line where the problem was found.  Line numbers are
110
            zero based except when included in messages to the end user.
111

112
        colno:
113
            The number of the character where the problem was found on *line*.
114
            Column numbers are zero based.
115

116
        codicil:
117
            The line that contains the error decorated with the location of the
118
            error.
119

120
    The exception culprit is the tuple that indicates where the error was found.
121
    With exceptions from :func:`loads()` or :func:`load()`, the culprit consists
122
    of the source name, if available, and the line number.  With exceptions from
123
    :func:`dumps()` or :func:`dump()`, the culprit consists of the keys that
124
    lead to the problematic value.
125

126
    As with most exceptions, you can simply cast it to a string to get a
127
    reasonable error message.
128

129
    .. code-block:: python
130

131
        >>> from textwrap import dedent
132
        >>> import nestedtext as nt
133

134
        >>> content = dedent("""
135
        ...     name1: value1
136
        ...     name1: value2
137
        ...     name3: value3
138
        ... """).strip()
139

140
        >>> try:
141
        ...     print(nt.loads(content))
142
        ... except nt.NestedTextError as e:
143
        ...     print(str(e))
144
        2: duplicate key: name1.
145
               1 ❬name1: value1❭
146
               2 ❬name1: value2❭
147
                  ▲
148

149
    You can also use the *report* method to print the message directly. This is
150
    appropriate if you are using *inform* for your messaging as it follows
151
    *inform*’s conventions::
152

153
        >> try:
154
        ..     print(nt.loads(content))
155
        .. except nt.NestedTextError as e:
156
        ..     e.report()
157
        error: 2: duplicate key: name1.
158
            ❬name1: value2❭
159
             ▲
160

161
    The *terminate* method prints the message directly and exits::
162

163
        >> try:
164
        ..     print(nt.loads(content))
165
        .. except nt.NestedTextError as e:
166
        ..     e.terminate()
167
        error: 2: duplicate key: name1.
168
            ❬name1: value2❭
169
             ▲
170

171
    With exceptions generated from :func:`load` or :func:`loads` you may see
172
    extra lines at the end of the message that show the problematic lines if
173
    you have the exception report itself as above.  Those extra lines are
174
    referred to as the codicil and they can be very helpful in illustrating the
175
    actual problem. You do not get them if you simply cast the exception to a
176
    string, but you can access them using :meth:`NestedTextError.get_codicil`.
177
    The codicil or codicils are returned as a tuple.  You should join them with
178
    newlines before printing them.
179

180
    .. code-block:: python
181

182
        >>> try:
183
        ...     print(nt.loads(content))
184
        ... except nt.NestedTextError as e:
185
        ...     print(e.get_message())
186
        ...     print(*e.get_codicil(), sep="\n")
187
        duplicate key: name1.
188
           1 ❬name1: value1❭
189
           2 ❬name1: value2❭
190
              ▲
191

192
    Note the ❬ and ❭ characters in the codicil. They delimit the extent of the
193
    text on each line and help you see troublesome leading or trailing white
194
    space.
195

196
    Exceptions produced by *NestedText* contain a *template* attribute that
197
    contains the basic text of the message. You can change this message by
198
    overriding the attribute using the *template* argument when using *report*,
199
    *terminate*, or *render*.  *render* is like casting the exception to a
200
    string except that allows for the passing of arguments.  For example, to
201
    convert a particular message to Spanish, you could use something like the
202
    following.
203

204
    .. code-block:: python
205

206
        >>> try:
207
        ...     print(nt.loads(content))
208
        ... except nt.NestedTextError as e:
209
        ...     template = None
210
        ...     if e.template == "duplicate key: {}.":
211
        ...         template = "llave duplicada: {}."
212
        ...     print(e.render(template=template))
213
        2: llave duplicada: name1.
214
               1 ❬name1: value1❭
215
               2 ❬name1: value2❭
216
                  ▲
217

218
    '''
219

220

221
# NotSuitableForInline {{{2
222
# this is only intended for internal use
223
class NotSuitableForInline(Exception):
2✔
224
    pass
2✔
225

226

227
# NestedText Reader {{{1
228
# Converts NestedText into Python data hierarchies.
229

230
# constants {{{2
231
# regular expressions used to recognize dict items
232
dict_item_regex = r"""
2✔
233
    (?P<key>[^\s].*?)      # key (must start with non-space character)
234
    \s*                    # optional white space
235
    :                      # separator
236
    (?:\ (?P<value>.*))?   # value
237
"""
238
dict_item_recognizer = re.compile(dict_item_regex, re.VERBOSE)
2✔
239

240

241
# report {{{2
242
def report(message, line, *args, colno=None, **kwargs):
2✔
243
    message = full_stop(message)
2✔
244
    culprits = get_culprit()
2✔
245
    codicil = [kwargs.get("codicil", "")]
2✔
246
    if culprits:
2✔
247
        kwargs["source"] = culprits[0]
2✔
248
    if line:
2✔
249
        # line numbers are always 0 based unless included in a message to user
250
        include_prev_line = not (
2✔
251
            line.prev_line is None or kwargs.pop("suppress_prev_line", False)
252
        )
253
        if colno is not None:
2✔
254
            # build codicil that shows both the line and the preceding line
255
            if include_prev_line:
2✔
256
                codicil += [f"{line.prev_line.lineno+1:>4} ❬{line.prev_line.text}❭"]
2✔
257
            else:
258
                codicil += []
2✔
259
            # replace tabs with → so that arrow points to right location.
260
            text = line.text.replace("\t", "→")
2✔
261
            codicil += [
2✔
262
                f"{line.lineno+1:>4} ❬{text}❭",
263
                "      " + (colno*" ") + "▲",
264
            ]
265
            kwargs["codicil"] = "\n".join(cull(codicil))
2✔
266
            kwargs["colno"] = colno
2✔
267
        else:
268
            kwargs["codicil"] = f"{line.lineno+1:>4} ❬{line.text}❭"
2✔
269
        kwargs["culprit"] = get_culprit(line.lineno + 1)
2✔
270
        kwargs["line"] = line.text
2✔
271
        kwargs["lineno"] = line.lineno
2✔
272
        if include_prev_line:
2✔
273
            kwargs["prev_line"] = line.prev_line.text
2✔
274
    else:
275
        kwargs["culprit"] = culprits  # pragma: no cover
276
    raise NestedTextError(template=message, *args, **kwargs)
2✔
277

278

279
# unrecognized_line {{{2
280
def unrecognized_line(line):
2✔
281
    # line will not be recognized if there is invalid white space in indentation
282
    first_non_space = line.text.lstrip(" ")[0]
2✔
283
    index_of_first_non_space = line.text.index(first_non_space)
2✔
284
    if first_non_space.strip() == "":
2✔
285
        # first non-space is a white space character
286
        # treat it as invalid indentation
287
        desc = unicodedata.name(first_non_space, "")
2✔
288
        if desc:
2✔
289
            desc = f" ({desc})"
2✔
290
        report(
2✔
291
            f"invalid character in indentation: {first_non_space!r}{desc}.",
292
            line,
293
            colno = index_of_first_non_space,
294
            codicil = "Only simple spaces are allowed in indentation."
295
        )
296
    else:
297
        report("unrecognized line.", line, colno=index_of_first_non_space)
2✔
298

299

300
# Lines class {{{2
301
class Lines:
2✔
302
    # constructor {{{3
303
    def __init__(self, lines, support_inlines):
2✔
304
        self.lines = lines
2✔
305
        self.support_inlines = support_inlines
2✔
306
        self.generator = self.read_lines()
2✔
307
        self.first_value_line = None
2✔
308
        self.last_comment_line = None
2✔
309
            # a location is needed for the top of the data, keys = ()
310
            # use the first value given, if the data is not empty
311
            # use last comment given if data is empty
312
        # comment-capture state
313
        self.prev_data_line = None
2✔
314
        self.header_comments = []   # comments before the first data item
2✔
315
        self.eof_comments = []      # footer comments (after the last data item)
2✔
316
        self._comment_buffer = []   # raw comment/blank Lines awaiting attachment
2✔
317
        self.next_line = None
2✔
318
        self._advance_to_data_line()
2✔
319

320
    # Line class {{{3
321
    class Line(Info):
2✔
322
        def render(self, col=None):
2✔
323
            result = [f"{self.lineno+1:>4} ❬{self.text}❭"]
2✔
324
            if col is not None:
2✔
325
                l = len(self.text)
2✔
326
                if l < col:
2✔
327
                    col = l
2✔
328
                result += ["      " + (col*" ") + "▲"]
2✔
329
            return "\n".join(result)
2✔
330

331
        def __str__(self):
2✔
332
            return self.text
2✔
333

334
        def __repr__(self):
2✔
335
            return self.__class__.__name__ + f"({self.lineno+1}: ❬{self.text}❭)"
2✔
336

337
    # read_lines() {{{3
338
    def read_lines(self):
2✔
339
        prev_line = None
2✔
340
        last_line = None
2✔
341
        for lineno, line in enumerate(self.lines):
2✔
342
            key = None
2✔
343
            value = None
2✔
344
            try:
2✔
345
                # decode to utf8 if a byte string or binary file is given
346
                line = line.decode('utf8')
2✔
347
            except AttributeError:
2✔
348
                pass
2✔
349
            line = line.rstrip("\n")
2✔
350

351
            # compute indentation
352
            stripped = line.lstrip(" ")
2✔
353
            depth = len(line) - len(stripped)
2✔
354

355
            # determine line type and extract values
356
            if stripped == "":
2✔
357
                kind = "blank"
2✔
358
                value = None
2✔
359
                depth = None
2✔
360
            elif stripped[:1] == "#":
2✔
361
                kind = "comment"
2✔
362
                # remove the '#' and exactly one optional space (the canonical
363
                # form is '# text'); rstrip whitespace.  This preserves
364
                # additional leading spaces inside the comment text so that
365
                # the dumper can round-trip them faithfully.
366
                value = stripped[1:]
2✔
367
                if value.startswith(" "):
2✔
368
                    value = value[1:]
2✔
369
                value = value.rstrip()
2✔
370
                # depth stays at the computed indent; needed for comment partitioning
371
            elif stripped == "-" or stripped.startswith("- "):
2✔
372
                kind = "list item"
2✔
373
                value = stripped[2:]
2✔
374
            elif stripped == ">" or stripped.startswith("> "):
2✔
375
                kind = "string item"
2✔
376
                value = line[depth+2:]
2✔
377
            elif stripped == ":" or stripped.startswith(": "):
2✔
378
                kind = "key item"
2✔
379
                value = line[depth+2:]
2✔
380
            elif stripped[0:1] in ["[", "{"] and self.support_inlines:
2✔
381
                tag = stripped[0:1]
2✔
382
                kind = "inline dict" if tag == "{" else "inline list"
2✔
383
                value = line[depth:]
2✔
384
            else:
385
                matches = dict_item_recognizer.fullmatch(stripped)
2✔
386
                if matches:
2✔
387
                    kind = "dict item"
2✔
388
                    key = matches.group("key")
2✔
389
                    value = matches.group("value")
2✔
390
                    if value is None:
2✔
391
                        value = ""
2✔
392
                else:
393
                    kind = "unrecognized"
2✔
394
                    value = line
2✔
395

396
            # bundle information about line
397
            this_line = self.Line(
2✔
398
                text = line,
399
                lineno = lineno,
400
                kind = kind,
401
                depth = depth,
402
                key = key,
403
                value = value,
404
                prev_line = prev_line,
405
            )
406
            if kind.endswith(" item") or kind.startswith("inline "):
2✔
407
                # Create prev_line, which differs from last_line in that it
408
                # is a copy of the line without a prev_line attribute of its
409
                # own. This avoids keeping a chain of all previous lines.
410
                #
411
                # In contrast, last_line is the actual this_line from the previous
412
                # non-blank/comment iteration
413
                prev_line = self.Line(
2✔
414
                    text = this_line.text,
415
                    value = this_line.value,
416
                    kind = this_line.kind,
417
                    depth = this_line.depth,
418
                    lineno = this_line.lineno,
419
                )
420

421
                # add this line as next_line in prev_line if this is a continued
422
                # multiline key or multiline string.
423
                if (
2✔
424
                    last_line                 and
425
                    depth == last_line.depth  and
426
                    kind == last_line.kind    and
427
                    kind in ["key item", "string item"]
428
                ):
429
                    last_line.next_line = this_line
2✔
430

431
            if kind in ['blank', 'comment']:
2✔
432
                self.last_comment_line = this_line
2✔
433
            else:
434
                last_line = this_line
2✔
435
                if not self.first_value_line:
2✔
436
                    self.first_value_line = this_line
2✔
437
            yield this_line
2✔
438

439
    # type_of_next() {{{3e
440
    def type_of_next(self):
2✔
441
        if self.next_line:
2✔
442
            return self.next_line.kind
2✔
443

444
    # still_within_level() {{{3
445
    def still_within_level(self, depth):
2✔
446
        if self.next_line:
2✔
447
            return self.next_line.depth >= depth
2✔
448

449
    # still_within_string() {{{3
450
    def still_within_string(self, depth):
2✔
451
        if self.next_line:
2✔
452
            return (
2✔
453
                self.next_line.kind == "string item" and
454
                self.next_line.depth >= depth
455
            )
456

457
    # still_within_key() {{{3
458
    def still_within_key(self, line, depth):
2✔
459
        if not self.next_line:
2✔
460
            report("indented value must follow multiline key", line)
2✔
461
        return (
2✔
462
            self.next_line.kind == "key item" and
463
            self.next_line.depth == depth
464
        )
465

466
    # depth_of_next() {{{3
467
    def depth_of_next(self):
2✔
468
        if self.next_line:
2✔
469
            return self.next_line.depth
2✔
470
        return 0
2✔
471

472
    # get_next() {{{3
473
    def get_next(self):
2✔
474
        line = self.next_line
2✔
475
        if line.kind == "unrecognized":
2✔
476
            unrecognized_line(line)
2✔
477

478
        # ensure the staging lists exist (Info returns None for unset attrs,
479
        # a fresh list is needed before the upcoming advance can append).
480
        line.trailing_comments = line.trailing_comments or []
2✔
481
        line.leading_comments = line.leading_comments or []
2✔
482

483
        self.prev_data_line = line
2✔
484

485
        # queue up the next useful line, capturing comments along the way.
486
        self._advance_to_data_line()
2✔
487

488
        return line
2✔
489

490
    # _advance_to_data_line() {{{3
491
    def _advance_to_data_line(self):
2✔
492
        """Pull from the generator until the next data line (or EOF).
493

494
        Buffered comment/blank lines are grouped into Comment objects and
495
        partitioned per the rules onto the surrounding data lines.
496
        """
497
        self.next_line = next(self.generator, None)
2✔
498
        while self.next_line and self.next_line.kind in ["blank", "comment"]:
2✔
499
            self._comment_buffer.append(self.next_line)
2✔
500
            self.next_line = next(self.generator, None)
2✔
501

502
        buffer_lines = self._comment_buffer
2✔
503
        self._comment_buffer = []
2✔
504

505
        if self.next_line is not None:
2✔
506
            self.next_line.leading_comments = self.next_line.leading_comments or []
2✔
507
            self.next_line.trailing_comments = self.next_line.trailing_comments or []
2✔
508

509
        if not buffer_lines:
2✔
510
            return
2✔
511

512
        if self.prev_data_line is None and self.next_line is not None:
2✔
513
            # before the first data line: rule 1 (Header / leading)
514
            # Partition on the raw buffer at the last blank line, then group
515
            # each half.  A comment block is leading on the first key only
516
            # when there is *no* blank between it and that key.
517
            header_lines, leading_lines = _partition_header_leading(buffer_lines)
2✔
518
            self.header_comments.extend(_group_comments(header_lines))
2✔
519
            self.next_line.leading_comments.extend(_group_comments(leading_lines))
2✔
520
        elif self.prev_data_line is None and self.next_line is None:
2✔
521
            # rule "No data" -- entire content is header
522
            self.header_comments.extend(_group_comments(buffer_lines))
2✔
523
        elif self.next_line is None:
2✔
524
            # after the last data line: rule 4 (Trailing / footer)
525
            last_indent = self.prev_data_line.depth or 0
2✔
526
            for c in _group_comments(buffer_lines):
2✔
527
                if c.indent > last_indent:
2✔
528
                    self.prev_data_line.trailing_comments.append(c)
2✔
529
                else:
530
                    self.eof_comments.append(c)
2✔
531
        else:
532
            # between two data lines: rule 2 (Leading / trailing)
533
            next_indent = self.next_line.depth or 0
2✔
534
            for c in _group_comments(buffer_lines):
2✔
535
                if c.indent <= next_indent:
2✔
536
                    self.next_line.leading_comments.append(c)
2✔
537
                else:
538
                    self.prev_data_line.trailing_comments.append(c)
2✔
539

540
    # indentation_error {{{3
541
    def indentation_error(self, line, depth):
2✔
542
        assert line.depth != depth
2✔
543
        prev_line = line.prev_line
2✔
544
        codicil = None
2✔
545
        if not line.prev_line and depth == 0:
2✔
546
            msg = "top-level content must start in column 1."
2✔
547
        elif (
2✔
548
            prev_line                     and
549
            prev_line.value               and
550
            prev_line.depth < line.depth  and
551
            prev_line.kind in ["list item", "dict item"]
552
        ):
553
            if prev_line.value.strip() == "":
2✔
554
                obs = ", which in this case consists only of whitespace"
2✔
555
            else:
556
                obs = ""
2✔
557
            msg = "invalid indentation."
2✔
558
            codicil = join(
2✔
559
                "An indent may only follow a dictionary or list item that does",
560
                f"not already have a value{obs}.",
561
                wrap = True
562
            )
563
        elif prev_line and prev_line.depth > line.depth:
2✔
564
            msg = "invalid indentation, partial dedent."
2✔
565
        else:
566
            msg = "invalid indentation."
2✔
567
        report(join(msg, wrap=True), line, colno=depth, codicil=codicil)
2✔
568

569

570
# KeyPolicy class {{{2
571
# Used to hold and implement the on_dup policy for dictionaries.
572
class KeyPolicy:
2✔
573
    @classmethod
2✔
574
    def set_policy(cls, on_dup):
2✔
575
        if callable(on_dup):
2✔
576
            # if on_dup is a function, convert it to a data structure that will
577
            # hold state during the load
578
            on_dup = {_OnDupCallback: on_dup}
2✔
579
        cls.on_dup = on_dup
2✔
580

581
    @classmethod
2✔
582
    def process_duplicate(cls, dictionary, key, keys, line=None, colno=None):
2✔
583
        if cls.on_dup is None or cls.on_dup == "error":
2✔
584
            report("duplicate key: {}.", line, key, colno=colno)
2✔
585
        if cls.on_dup == "ignore":
2✔
586
            return None
2✔
587
        if isinstance(cls.on_dup, dict):
2✔
588
            dup_handler = cls.on_dup.pop(_OnDupCallback)
2✔
589
            cls.on_dup.update(
2✔
590
                dict(dictionary=dictionary, keys=keys)
591
            )
592
            try:
2✔
593
                key = dup_handler(key=key, state=cls.on_dup)
2✔
594
                if key is None:
2✔
595
                    return None
2✔
596
            except KeyError:
2✔
597
                report("duplicate key: {}.", line, key, colno=colno)
2✔
598
            cls.on_dup[_OnDupCallback] = dup_handler  # restore dup_handler
2✔
599
        elif cls.on_dup != "replace":  # pragma: no cover
600
            raise AssertionError(
601
                f"{cls.on_dup}: unexpected value for on_dup."
602
            ) from None
603
        return key
2✔
604

605

606
# Comment class {{{2
607
class Comment:
2✔
608
    """A single comment captured during load or built by the user.
609

610
    Holds the comment text (joined by ``\\n`` for multi-line comments) plus
611
    indent and per-comment blank-line metadata.
612

613
    *indent* is the absolute indentation (in spaces) at which the comment's
614
    ``#`` will be placed.  It is set by the loader and is used by the
615
    dumper when ``tab`` is None.
616

617
    *tab* is the alternative way to express indent: a tabstop offset
618
    relative to the slot's natural indent.  When *tab* is not None, the
619
    dumper computes the absolute indent at emit time as
620
    ``natural_for_slot + tab * dumps.indent``; the ``indent`` field is
621
    ignored.  The loader leaves *tab* as None; :func:`annotate` and
622
    user-built Comments may set it.
623

624
    *before* / *after* are the number of blank lines emitted before and
625
    after this comment.  The loader does not set these; they exist for
626
    user-built comments only.
627
    """
628

629
    def __init__(self, text="", indent=0, *, tab=None, before=0, after=0):
2✔
630
        self.text = text
2✔
631
        self.indent = indent
2✔
632
        self.tab = tab
2✔
633
        self.before = before
2✔
634
        self.after = after
2✔
635

636
    def __repr__(self):
2✔
NEW
637
        extras = []
×
NEW
638
        if self.tab is not None:
×
NEW
639
            extras.append(f", tab={self.tab}")
×
NEW
640
        if self.before:
×
NEW
641
            extras.append(f", before={self.before}")
×
NEW
642
        if self.after:
×
NEW
643
            extras.append(f", after={self.after}")
×
NEW
644
        return f"Comment({self.text!r}, indent={self.indent}{''.join(extras)})"
×
645

646
    def __eq__(self, other):
2✔
NEW
647
        if not isinstance(other, Comment):
×
NEW
648
            return NotImplemented
×
NEW
649
        return (
×
650
            self.text == other.text
651
            and self.indent == other.indent
652
            and self.tab == other.tab
653
            and self.before == other.before
654
            and self.after == other.after
655
        )
656

657
    # Comments are mutable, so they must not be hashable.
658
    __hash__ = None
2✔
659

660

661
# _group_comments {{{2
662
def _group_comments(comment_lines):
2✔
663
    """Convert a list of blank/comment Line objects into a list of Comments.
664

665
    Rules:
666
    - Adjacent comment lines at the same indent (no blank line between)
667
      merge into one Comment whose text is the source lines joined by '\\n'.
668
    - A blank line, or an indent change, closes the current Comment and
669
      starts a new one.
670
    - Pure blank lines are otherwise discarded; their spacing is the
671
      dumper's concern.
672

673
    Same-indent comment blocks separated by blanks therefore remain as
674
    distinct Comment objects.
675
    """
676
    comments = []
2✔
677
    cur_text_parts = []
2✔
678
    cur_indent = 0
2✔
679

680
    def flush():
2✔
681
        nonlocal cur_text_parts, cur_indent
682
        if cur_text_parts:
2✔
683
            comments.append(
2✔
684
                Comment(text="\n".join(cur_text_parts), indent=cur_indent)
685
            )
686
            cur_text_parts = []
2✔
687
            cur_indent = 0
2✔
688

689
    saw_blank = False
2✔
690
    for line in comment_lines:
2✔
691
        if line.kind == "blank":
2✔
692
            saw_blank = True
2✔
693
            continue
2✔
694
        indent = line.depth
2✔
695
        if cur_text_parts and not saw_blank and indent == cur_indent:
2✔
696
            cur_text_parts.append(line.value)
2✔
697
        else:
698
            flush()
2✔
699
            cur_text_parts.append(line.value)
2✔
700
            cur_indent = indent
2✔
701
        saw_blank = False
2✔
702
    flush()
2✔
703
    return comments
2✔
704

705

706
# _partition_header_leading {{{2
707
def _partition_header_leading(buffer_lines):
2✔
708
    """Split a pre-data-line buffer into (header_lines, leading_lines).
709

710
    The partition is at the LAST blank line in the buffer.  Everything before
711
    that blank becomes the header; everything after becomes the leading
712
    comment block on the first data item.  If no blank line is present, the
713
    entire buffer is leading.
714
    """
715
    last_blank = -1
2✔
716
    for i, line in enumerate(buffer_lines):
2✔
717
        if line.kind == "blank":
2✔
718
            last_blank = i
2✔
719
    if last_blank == -1:
2✔
720
        return [], list(buffer_lines)
2✔
721
    return list(buffer_lines[:last_blank]), list(buffer_lines[last_blank + 1:])
2✔
722

723

724
# Location class {{{2
725
class Location:
2✔
726
    """Holds information about the location of a token.
727

728
    Returned from :func:`load` and :func:`loads` as the values in a *keymap*.
729
    Objects of this class holds the line and column numbers of the key and value
730
    tokens.
731
    """
732

733
    def __init__(self, line=None, col=None, key_line=None, key_col=None):
2✔
734
        self.line = line
2✔
735
        self.key_line = key_line
2✔
736
        self.col = col
2✔
737
        self.key_col = key_col
2✔
738
        # Set by _add_keymap to the last data line consumed within this value's
739
        # scope (the source of value-trailing comments).  For leaf entries
740
        # this is ``line``; for nested values, the deepest descendant line.
741
        self.value_end_line = None
2✔
742
        # Comments are stored on the Location itself (not on the underlying
743
        # Line) so that two Locations that share a Line (parent and its
744
        # first child) do not inadvertently share comment lists.
745
        self.key_leading_comments = []
2✔
746
        self.key_trailing_comments = []
2✔
747
        self.value_leading_comments = []
2✔
748
        self.value_trailing_comments = []
2✔
749
        # Document-level comments only live on the keymap[()] Location.
750
        self.header_comments = []
2✔
751
        self.footer_comments = []
2✔
752
        # Per-Location dump spacing: when non-empty, replaces the dumps()
753
        # *spacing* argument for this Location's entire subtree.  Integer
754
        # keys are relative to this Location (0 == blanks between this
755
        # Location's direct children).  See get_spacing / set_spacing.
756
        self.spacing = {}
2✔
757
        # Per-parent section markers: a list of (predicate, [Comment]) pairs.
758
        # When this Location's value is rendered, the dumper emits each
759
        # section's comments before the first child whose key matches the
760
        # section's predicate (first-match-wins among predicates).
761
        self.sections = []
2✔
762

763
    def __repr__(self):
2✔
764
        components = []
2✔
765
        if self.line:
2!
766
            components.append(f"lineno={self.line.lineno}")
2✔
767
            components.append(f"colno={self.col}")
2✔
768
            key_line = self.key_line
2✔
769
            if key_line is None:
2✔
770
                key_line = self.line
2✔
771
            components.append(f"key_lineno={key_line.lineno}")
2✔
772
            key_col = self.key_col
2✔
773
            if key_col is None:
2✔
774
                key_col = self.col
2✔
775
            components.append(f"key_colno={key_col}")
2✔
776
        return f"{self.__class__.__name__}({', '.join(components)})"
2✔
777

778
    # as_tuple() {{{3
779
    def as_tuple(self, kind="value"):
2✔
780
        """
781
        Returns the location of either the value or the key token as a tuple
782
        that contains the line number and the column number.  The line and
783
        column numbers are 0 based.
784

785
        Args:
786
            kind:
787
                Specify either “key” or “value” depending on which token is
788
                desired.
789
        """
790
        if kind == "key":
2✔
791
            line = self.key_line
2✔
792
            col = self.key_col
2✔
793
            if line is None:
2✔
794
                line = self.line
2✔
795
            if col is None:
2✔
796
                col = self.col
2✔
797
        else:
798
            assert kind == "value"
2✔
799
            line = self.line
2✔
800
            col = self.col
2✔
801
        return line.lineno, col
2✔
802

803
    # as_line() {{{3
804
    def as_line(self, kind="value", offset=0):
2✔
805
        """
806
        Returns a string containing two lines that identify the token in
807
        context.  The first line contains the line number and text of the line
808
        that contains the token.  The second line contains a pointer to the
809
        token.
810

811
        Args:
812
            kind:
813
                Specify either “key” or “value” depending on which token is
814
                desired.
815
            offset:
816
                If *offset* is None, the error pointer is not added to the line.
817
                If *offset* is an integer, the pointer is moved to the right by
818
                this many characters.  The default is 0.
819
                If *offset* is a tuple, it must have two values.  The first is
820
                the row offset and the second is the column offset.  This is
821
                useful for annotating errors in multiline strings.
822

823
        Raises:
824
            *IndexError* if row offset is out of range.
825
        """
826
        # get the line and the column number of the key or value
827
        if kind == "key":
2✔
828
            line = self.key_line
2✔
829
            col = self.key_col
2✔
830
            if line is None:
2✔
831
                line = self.line
2✔
832
            if col is None:
2✔
833
                col = self.col
2✔
834
        else:
835
            assert kind == "value"
2✔
836
            line = self.line
2✔
837
            col = self.col
2✔
838

839
        if not line:  # this occurs if input is completely empty
2✔
840
            return ""
2✔
841

842
        # process the offset
843
        if offset is None:
2✔
844
            return line.render()
2✔
845
        col_offset = offset
2✔
846
        try:
2✔
847
            row_offset, col_offset = offset
2✔
848
            while row_offset > 0:
2✔
849
                line = line.next_line
2✔
850
                row_offset -= 1
2✔
851
                if line is None:
2✔
852
                    raise IndexError(offset[0])
2✔
853
        except TypeError:
2✔
854
            pass
2✔
855

856
        return line.render(col + col_offset)
2✔
857

858
    # get_line_numbers() {{{3
859
    def get_line_numbers(self, kind="value", sep=None):
2✔
860
        """
861
        Returns the line numbers of a token either as a pair of integers or as a
862
        string.
863

864
        Args:
865
            kind:
866
                Specify either “key” or “value” depending on which token is
867
                desired.
868
            sep:
869
                The separator string.
870

871
                If given a string is returned and *sep* is inserted between two
872
                line numbers.  In this case the line numbers start at 1.
873

874
                If *sep* is not given, a tuple of integers is returned.  In this
875
                case the line numbers start at 0, but the second number returned
876
                is the last line number plus 1.  This form is suitable to use
877
                with the Python slice function to extract the lines from the
878
                *NestedText* source.
879
        """
880
        if kind == "key":
2✔
881
            line = self.key_line
2✔
882
            if line is None:
2✔
883
                line = self.line
2✔
884
        else:
885
            assert kind == "value"
2✔
886
            line = self.line
2✔
887

888
        # find line numbers
889
        first_lineno = line.lineno
2✔
890
        while line:
2✔
891
            last_lineno = line.lineno
2✔
892
            line = line.next_line
2✔
893

894
        if sep is None:
2✔
895
            return (first_lineno, last_lineno + 1)
2✔
896
        if first_lineno != last_lineno:
2✔
897
            return join(first_lineno+1, last_lineno+1, sep=sep)
2✔
898
        return str(first_lineno+1)
2✔
899

900
    # _get_original_key() {{{3
901
    def _get_original_key(self, key, strict):
2✔
902
        try:
2✔
903
            line = self.key_line
2✔
904
            if line.kind == "key item":
2✔
905
                # is multiline key (key fragment is actually held in line.text)
906
                key_frags = [line.text[line.depth+2:]]
2✔
907
                while line.next_line:
2✔
908
                    line = line.next_line
2✔
909
                    key_frags.append(line.text[line.depth+2:])
2✔
910
                key = "\n".join(key_frags)
2✔
911
            else:
912
                if line.kind != "list item":
2✔
913
                    key = line.key
2✔
914
            return key
2✔
915
        except AttributeError:
2✔
916
            # this occurs for list indexes
917
            return key
2✔
918

919
    # comment accessors {{{3
920
    # get_key_leading_comments() {{{4
921
    def get_key_leading_comments(self):
2✔
922
        """Return the leading Comments associated with this key."""
923
        return self.key_leading_comments
2✔
924

925
    # set_key_leading_comments {{{4
926
    def set_key_leading_comments(self, comments):
2✔
927
        """Replace the leading Comments for this key."""
928
        self.key_leading_comments = list(comments)
2✔
929

930
    # add_key_leading_comment {{{4
931
    def add_key_leading_comment(self, comment):
2✔
932
        """Append a Comment to the leading list for this key."""
NEW
933
        self.key_leading_comments.append(comment)
×
934

935
    # get_key_trailing_comments {{{4
936
    def get_key_trailing_comments(self):
2✔
937
        """Return Comments between the key line and the value line (multiline case)."""
938
        return self.key_trailing_comments
2✔
939

940
    # set_key_trailing_comments {{{4
941
    def set_key_trailing_comments(self, comments):
2✔
NEW
942
        self.key_trailing_comments = list(comments)
×
943

944
    # add_key_trailing_comment {{{4
945
    def add_key_trailing_comment(self, comment):
2✔
NEW
946
        self.key_trailing_comments.append(comment)
×
947

948
    # get_value_leading_comments {{{4
949
    def get_value_leading_comments(self):
2✔
950
        """Return Comments leading the value (multiline case)."""
951
        return self.value_leading_comments
2✔
952

953
    # set_value_leading_comments {{{4
954
    def set_value_leading_comments(self, comments):
2✔
NEW
955
        self.value_leading_comments = list(comments)
×
956

957
    # add_value_leading_comment {{{4
958
    def add_value_leading_comment(self, comment):
2✔
NEW
959
        self.value_leading_comments.append(comment)
×
960

961
    # get_value_trailing_comments {{{4
962
    def get_value_trailing_comments(self):
2✔
963
        """Return Comments trailing the value (after its last line)."""
964
        return self.value_trailing_comments
2✔
965

966
    # set_value_trailing_comments {{{4
967
    def set_value_trailing_comments(self, comments):
2✔
NEW
968
        self.value_trailing_comments = list(comments)
×
969

970
    # add_value_trailing_comment {{{4
971
    def add_value_trailing_comment(self, comment):
2✔
NEW
972
        self.value_trailing_comments.append(comment)
×
973

974
    # get_header_comments {{{4
975
    def get_header_comments(self):
2✔
976
        """Return the document's header Comments.
977

978
        Header comments only ever live on the document-root Location, i.e.,
979
        ``keymap[()]``.  On any other Location this list is empty.
980
        """
981
        return self.header_comments
2✔
982

983
    # set_header_comments {{{4
984
    def set_header_comments(self, comments):
2✔
985
        """Replace the document's header Comments."""
986
        self.header_comments = list(comments)
2✔
987

988
    # add_header_comment {{{4
989
    def add_header_comment(self, comment):
2✔
990
        """Append a Comment to the document's header."""
NEW
991
        self.header_comments.append(comment)
×
992

993
    # get_footer_comments {{{4
994
    def get_footer_comments(self):
2✔
995
        """Return the document's footer Comments.
996

997
        Footer comments only ever live on the document-root Location, i.e.,
998
        ``keymap[()]``.  On any other Location this list is empty.
999
        """
1000
        return self.footer_comments
2✔
1001

1002
    # set_footer_comments {{{4
1003
    def set_footer_comments(self, comments):
2✔
1004
        """Replace the document's footer Comments."""
NEW
1005
        self.footer_comments = list(comments)
×
1006

1007
    # add_footer_comment {{{4
1008
    def add_footer_comment(self, comment):
2✔
1009
        """Append a Comment to the document's footer."""
NEW
1010
        self.footer_comments.append(comment)
×
1011

1012
    # get_spacing {{{4
1013
    def get_spacing(self):
2✔
1014
        """Return the per-Location spacing dict (empty if none was set).
1015

1016
        When non-empty, this dict replaces the :func:`dumps` *spacing*
1017
        argument for this Location's entire subtree.  Integer keys count
1018
        relative depth below this Location: ``0`` is the number of blank
1019
        lines between this Location's direct children, ``1`` between its
1020
        grandchildren, and so on.  Absent depth keys default to ``0`` --
1021
        the global spacing is *not* consulted as a fallback.
1022

1023
        The ``"edges"`` key is only consulted on the document-root
1024
        Location (``keymap[()]``); it is ignored elsewhere.
1025
        """
1026
        return self.spacing
2✔
1027

1028
    # set_spacing {{{4
1029
    def set_spacing(self, spacing):
2✔
1030
        """Replace the per-Location spacing dict."""
NEW
1031
        self.spacing = dict(spacing)
×
1032

1033
    # get_sections {{{4
1034
    def get_sections(self):
2✔
1035
        """Return the per-parent section markers as a list of
1036
        ``(predicate, [Comment, ...])`` tuples.
1037

1038
        When this Location's value is rendered, the dumper walks the
1039
        children in iteration order; for each child it finds the first
1040
        section whose predicate returns true, and emits that section's
1041
        comments before the child the first time the section is
1042
        encountered.  Section predicates are callables and are dropped
1043
        on :func:`keymap_to_jsonable` round-trips.
1044
        """
NEW
1045
        return self.sections
×
1046

1047
    # set_sections {{{4
1048
    def set_sections(self, sections):
2✔
1049
        """Replace the per-parent section markers list."""
1050
        self.sections = [(pred, list(comments)) for pred, comments in sections]
2✔
1051

1052

1053
# Inline class {{{2
1054
class Inline:
2✔
1055
    # a recursive descent parser to interpret inline lists and dictionaries
1056

1057
    # constructor() {{{3
1058
    def __init__(self, line, keys, loader):
2✔
1059
        self.line = line
2✔
1060
        self.loader = loader
2✔
1061
        self.text = line.value
2✔
1062
        self.max_index = len(self.text)
2✔
1063
        self.starting_col = line.depth
2✔
1064
        try:
2✔
1065
            self.values, self.keymap, index = self.parse_inline_value(keys, 0)
2✔
1066
        except IndexError:
2✔
1067
            self.inline_error("line ended without closing delimiter", self.max_index)
2✔
1068
        if index < self.max_index:
2✔
1069
            extra = self.text[index:]
2✔
1070
            self.inline_error(
2✔
1071
                f"extra {plural(extra):character} after closing delimiter: ‘{{}}’.",
1072
                index, extra
1073
            )
1074
        assert index == self.max_index
2✔
1075

1076
    # parse_inline_value() {{{3
1077
    def parse_inline_value(self, keys, index, forbidden_chars=None):
2✔
1078
        if self.text[index] == "{":
2✔
1079
            return self.parse_inline_dict(keys, index)
2✔
1080
        elif self.text[index] == "[":
2✔
1081
            return self.parse_inline_list(keys, index)
2✔
1082
        else:
1083
            return self.parse_inline_str(keys, index, forbidden_chars)
2✔
1084

1085
    # parse_inline_dict() {{{3
1086
    def parse_inline_dict(self, keys, index):
2✔
1087
        starting_index = index
2✔
1088
        assert self.text[index] == "{"
2✔
1089
        index += 1
2✔
1090
        values = {}
2✔
1091
        need_another = False
2✔
1092

1093
        while self.text[index] != "}":
2✔
1094
            prev_index = index
2✔
1095
            orig_key, value, location, index = self.parse_inline_dict_item(keys, index)
2✔
1096
            key = self.loader.normalize_key(orig_key, keys)
2✔
1097
            if key in values:
2✔
1098
                key = KeyPolicy.process_duplicate(values, key, keys, self.line, prev_index)
2✔
1099
            if key is not None:
2✔
1100
                values[key] = value
2✔
1101
                self.loader._add_keymap(keys + (key,), location)
2✔
1102
            need_another = False
2✔
1103
            if self.text[index] not in ",}":
2✔
1104
                self.inline_error(
2✔
1105
                    "expected ‘,’ or ‘}}’, found ‘{}’", index, self.text[index]
1106
                )
1107
            if self.text[index] == ",":
2✔
1108
                index += 1
2✔
1109
                need_another = True
2✔
1110
        if need_another:
2✔
1111
            self.inline_error("expected value", index)
2✔
1112
        return (
2✔
1113
            values,
1114
            self.location(starting_index),
1115
            self.adjust_index(index+1)
1116
        )
1117

1118
    # parse_inline_dict_item() {{{3
1119
    def parse_inline_dict_item(self, keys, index):
2✔
1120
        forbidden_chars = "{}[],:"
2✔
1121
        key_index = self.adjust_index(index)
2✔
1122
        if self.text[index] in forbidden_chars:
2✔
1123
            key = ""
2✔
1124
        else:
1125
            key, _, index = self.parse_inline_value(keys, index, forbidden_chars)
2✔
1126
        if self.text[index] != ":":
2✔
1127
            self.inline_error(
2✔
1128
                "expected ‘:’, found ‘{}’", index, self.text[index], culprit=key
1129
            )
1130
        index = self.adjust_index(index+1)
2✔
1131
        if self.text[index] in ",}":
2✔
1132
            value = ""
2✔
1133
            loc = self.location(index)
2✔
1134
        else:
1135
            value, loc, index = self.parse_inline_value(keys, index, forbidden_chars)
2✔
1136
        self.add_key_location(loc, key_index)
2✔
1137
        return key, value, loc, index
2✔
1138

1139
    # parse_inline_list() {{{3
1140
    def parse_inline_list(self, keys, index):
2✔
1141
        forbidden_chars = "{}[],"
2✔
1142
        starting_index = index
2✔
1143
        assert self.text[index] == "["
2✔
1144
        index += 1
2✔
1145

1146
        # handle empty list
1147
        if self.text[index] == "]":
2✔
1148
            return [], self.location(starting_index), self.adjust_index(index+1)
2✔
1149

1150
        key = 0
2✔
1151
        values = []
2✔
1152
        value = ""
2✔
1153
        loc = self.location(index)
2✔
1154
        while True:
2✔
1155
            new_keys = keys + (key,)
2✔
1156
            c = self.text[index]
2✔
1157
            if c in ",]":
2✔
1158
                values.append(value)
2✔
1159
                self.loader._add_keymap(new_keys, loc)
2✔
1160
                key += 1
2✔
1161
                if c == "]":
2✔
1162
                    return (
2✔
1163
                        values,
1164
                        self.location(starting_index),
1165
                        self.adjust_index(index+1)
1166
                    )
1167
                index += 1
2✔
1168
                loc = self.location(index)
2✔
1169
                index = self.adjust_index(index)
2✔
1170
                value = ""
2✔
1171
            elif value:
2✔
1172
                self.inline_error(
2✔
1173
                    "expected ‘,’ or ‘]’, found ‘{}’", index, self.text[index]
1174
                )
1175
            elif c in "}],":
2✔
1176
                self.inline_error("expected value", index)
2✔
1177
            else:
1178
                value, loc, index = self.parse_inline_value(
2✔
1179
                    new_keys, index, forbidden_chars
1180
                )
1181

1182
    # parse_inline_str() {{{3
1183
    def parse_inline_str(self, keys, index, forbidden_chars):
2✔
1184
        starting_index = index
2✔
1185
        while self.text[index] not in forbidden_chars:
2✔
1186
            index = self.adjust_index(index+1)
2✔
1187
        value = self.text[starting_index:index].strip()
2✔
1188
        return value, self.location(starting_index), index
2✔
1189

1190
    # adjust_index() {{{3
1191
    def adjust_index(self, index):
2✔
1192
        # if desired index points to white space, shift right until it doesn’t
1193
        while index < self.max_index and self.text[index] in " \t":
2✔
1194
            index += 1
2✔
1195
        return index
2✔
1196

1197
    # location() {{{3
1198
    def location(self, index, **kwargs):
2✔
1199
        kwargs["line"] = self.line
2✔
1200
        return Location(col=index + self.starting_col, **kwargs)
2✔
1201

1202
    # add_key_location() {{{3
1203
    def add_key_location(self, loc, key_index):
2✔
1204
        loc.key_col = key_index + self.starting_col
2✔
1205

1206
    # inline_error {{{3
1207
    def inline_error(self, message, index, *args, culprit=None, **kwargs):
2✔
1208
        report(
2✔
1209
            full_stop(message),
1210
            self.line,
1211
            *args,
1212
            colno = index + self.starting_col,
1213
            culprit = culprit,
1214
            suppress_prev_line = True,
1215
            **kwargs,
1216
        )
1217

1218
    # get_values() {{{3
1219
    def get_values(self):
2✔
1220
        return self.values, self.keymap
2✔
1221

1222
    # render {{{3
1223
    def render(self, index):  # pragma: no cover
1224
        return f"❬{self.text}❭\n {index*' '}▲"
1225

1226
    # __repr__ {{{3
1227
    def __repr__(self):  # pragma: no cover
1228
        name = self.__class__.__name__
1229
        return f"{name}({self.text!r})"
1230

1231

1232
# NestedTextLoader class {{{2
1233
class NestedTextLoader:
2✔
1234
    # __init__() {{{3
1235
    def __init__(self, lines, top, source, on_dup, keymap, normalize_key, dialect):
2✔
1236
        KeyPolicy.set_policy(on_dup)
2✔
1237
        self.source = source
2✔
1238
        self.keymap = keymap
2✔
1239
        assert self.keymap is None or is_mapping(self.keymap)
2✔
1240
        self.normalize_key = normalize_key if normalize_key else lambda k, ks: k
2✔
1241
        if dialect and "i" in dialect:
2✔
1242
            support_inlines = False
2✔
1243
        else:
1244
            support_inlines = True
2✔
1245

1246
        with set_culprit(source):
2✔
1247
            lines = self.lines = Lines(lines, support_inlines)
2✔
1248
            if keymap is not None:
2✔
1249
                # add a location for the top-level of the data set
1250
                if lines.first_value_line:
2✔
1251
                    keymap[()] = Location(line=lines.first_value_line, col=0)
2✔
1252
                else:
1253
                    keymap[()] = Location(line=lines.last_comment_line, col=0)
2✔
1254
            next_is = lines.type_of_next()
2✔
1255

1256
            if top in ["any", any]:
2✔
1257
                if next_is is None:
2✔
1258
                    self.values, self.keymap = None, None
2✔
1259
                else:
1260
                    self.values, self.keymap = self._read_value(0, ())
2✔
1261

1262
            elif top in ["dict", dict]:
2✔
1263
                if next_is in ["dict item", "key item", "inline dict"]:
2✔
1264
                    self.values, self.keymap = self._read_value(0, ())
2✔
1265
                elif next_is is None:
2✔
1266
                    self.values, self.keymap = {}, None
2✔
1267
                else:
1268
                    report(
2✔
1269
                        "content must start with key or brace ({{).",
1270
                        lines.get_next()
1271
                    )
1272

1273
            elif top in ["list", list]:
2✔
1274
                if next_is in ["list item", "inline list"]:
2✔
1275
                    self.values, self.keymap = self._read_value(0, ())
2✔
1276
                elif next_is is None:
2✔
1277
                    self.values, self.keymap = [], None
2✔
1278
                else:
1279
                    report(
2✔
1280
                        "content must start with dash (-) or bracket ([).",
1281
                        lines.get_next(),
1282
                    )
1283

1284
            elif top in ["str", str]:
2✔
1285
                if next_is == "string item":
2✔
1286
                    self.values, self.keymap = self._read_value(0, ())
2✔
1287
                elif next_is is None:
2✔
1288
                    self.values, self.keymap = "", None
2✔
1289
                else:
1290
                    report(
2✔
1291
                        "content must start with greater-than sign (>).",
1292
                        lines.get_next(),
1293
                    )
1294

1295
            else:
1296
                raise NotImplementedError(top)  # pragma: no cover
1297

1298
            if lines.type_of_next():
2✔
1299
                report('extra content', lines.get_next())
2✔
1300

1301
            # attach header / footer comments to the document-root Location
1302
            # (keymap[()]) so that every keymap key remains a tuple, keeping
1303
            # depth-based iteration (``len(keys)``) safe.
1304
            if keymap is not None and () in keymap:
2✔
1305
                root = keymap[()]
2✔
1306
                if lines.header_comments:
2✔
1307
                    root.header_comments = list(lines.header_comments)
2✔
1308
                if lines.eof_comments:
2!
NEW
1309
                    root.footer_comments = list(lines.eof_comments)
×
1310

1311
    # get_decoded() {{{3
1312
    def get_decoded(self):
2✔
1313
        return self.values
2✔
1314

1315
    # # get_keymap() {{{3
1316
    # this method becomes useful when an interface that returns the loader develops
1317
    # def get_keymap(self):
1318
    #     return self.keymap
1319

1320
    # # get_source() {{{3
1321
    # this method becomes useful when an interface that returns the loader develops
1322
    # def get_source(self):
1323
    #     return self.source
1324

1325
    # # get_value() {{{3
1326
    # this method becomes useful when an interface that returns the loader develops
1327
    # def get_value(self, keys):
1328
    #     """
1329
    #     Return the value associated with a set of keys.
1330
    #     """
1331
    #     value = self.values
1332
    #     key = None
1333
    #     keys_used = ()
1334
    #     try:
1335
    #         for key in keys:
1336
    #             keys_used += (key,)
1337
    #             value = value[key]
1338
    #     except (KeyError, IndexError) as e:
1339
    #         raise NestedTextError(
1340
    #             key, template=f"key not found ({}).", culprit=keys_used
1341
    #         )
1342
    #     return value
1343

1344
    # _add_keymap() {{{3
1345
    def _add_keymap(self, keys, location):
2✔
1346
        if self.keymap is not None:
2✔
1347
            # The last data line consumed is the last line of this value's
1348
            # scope, where trailing-after-value comments are staged.
1349
            location.value_end_line = self.lines.prev_data_line
2✔
1350
            # Claim any comments staged on the relevant Lines.  This makes
1351
            # each Location own its comments outright -- two Locations that
1352
            # share a Line (parent and its first child) cannot then
1353
            # double-emit the same comment block.
1354
            kl = location.key_line if location.key_line is not None else location.line
2✔
1355
            vl = location.line
2✔
1356
            ve = location.value_end_line
2✔
1357
            if kl is not None and kl.leading_comments:
2✔
1358
                location.key_leading_comments = list(kl.leading_comments)
2✔
1359
                kl.leading_comments = []
2✔
1360
            if vl is not None and vl is not kl and vl.leading_comments:
2!
NEW
1361
                location.value_leading_comments = list(vl.leading_comments)
×
NEW
1362
                vl.leading_comments = []
×
1363
            if kl is not None and kl is not ve and kl.trailing_comments:
2!
NEW
1364
                location.key_trailing_comments = list(kl.trailing_comments)
×
NEW
1365
                kl.trailing_comments = []
×
1366
            if ve is not None and ve.trailing_comments:
2✔
1367
                location.value_trailing_comments = list(ve.trailing_comments)
2✔
1368
                ve.trailing_comments = []
2✔
1369
            self.keymap[keys] = location
2✔
1370

1371
    # _read_value() {{{3
1372
    def _read_value(self, depth, keys):
2✔
1373
        lines = self.lines
2✔
1374
        if lines.type_of_next() == "list item":
2✔
1375
            return self._read_list(depth, keys)
2✔
1376
        if lines.type_of_next() in ["dict item", "key item"]:
2✔
1377
            return self._read_dict(depth, keys)
2✔
1378
        if lines.type_of_next() == "string item":
2✔
1379
            return self._read_string(depth, keys)
2✔
1380
        if lines.type_of_next() in ["inline dict", "inline list"]:
2✔
1381
            return self._read_inline(keys)
2✔
1382
        unrecognized_line(lines.get_next())
2✔
1383

1384
    # _read_list() {{{3
1385
    def _read_list(self, depth, keys):
2✔
1386
        lines = self.lines
2✔
1387
        values = []
2✔
1388
        index = 0
2✔
1389
        first_line = lines.next_line
2✔
1390
        while lines.still_within_level(depth):
2✔
1391
            line = lines.get_next()
2✔
1392
            if line.depth != depth:
2✔
1393
                lines.indentation_error(line, depth)
2✔
1394
            if line.kind != "list item":
2✔
1395
                report("expected list item.", line, colno=depth)
2✔
1396
            new_keys = keys + (index,)
2✔
1397
            if line.value:
2✔
1398
                values.append(line.value)
2✔
1399
                self._add_keymap(
2✔
1400
                    new_keys, Location(line=line, key_col=depth, col=depth + 2)
1401
                )
1402
            else:
1403
                # value may simply be empty, or it may be on next line, in which
1404
                # case it must be indented.
1405
                depth_of_next = lines.depth_of_next()
2✔
1406
                if depth_of_next > depth:
2✔
1407
                    value, loc = self._read_value(depth_of_next, new_keys)
2✔
1408
                    loc.key_line = line
2✔
1409
                    loc.key_col = depth
2✔
1410
                else:
1411
                    value = ""
2✔
1412
                    loc = Location(line=line, key_col=depth, col=depth + 1)
2✔
1413
                values.append(value)
2✔
1414
                self._add_keymap(new_keys, loc)
2✔
1415
            index += 1
2✔
1416

1417
        return values, Location(line=first_line, col=first_line.depth)
2✔
1418

1419
    # _read_dict() {{{3
1420
    def _read_dict(self, depth, keys):
2✔
1421
        lines = self.lines
2✔
1422
        values = {}
2✔
1423
        first_line = lines.next_line
2✔
1424

1425
        # process all items in dictionary
1426
        while lines.still_within_level(depth):
2✔
1427
            line = lines.get_next()
2✔
1428
            key_line = line
2✔
1429
            key_col = depth
2✔
1430

1431
            # error checking
1432
            if line.depth != depth:
2✔
1433
                lines.indentation_error(line, depth)
2✔
1434
            if line.kind not in ["dict item", "key item"]:
2✔
1435
                report("expected dictionary item.", line, colno=depth)
2✔
1436

1437
            # process key
1438
            if line.kind == "key item":
2✔
1439
                # multiline key
1440
                original_key = self._read_key(line, depth)
2✔
1441
                value = None
2✔
1442
            else:
1443
                # key and value on a single line
1444
                original_key = line.key
2✔
1445
                value = line.value
2✔
1446
            key = self.normalize_key(original_key, keys)
2✔
1447
            if key in values:
2✔
1448
                # found duplicate key
1449
                key = KeyPolicy.process_duplicate(values, key, keys, line, depth)
2✔
1450
                if key is None:
2✔
1451
                    continue
2✔
1452
            new_keys = keys + (key,)
2✔
1453

1454
            # process value
1455
            if value:
2✔
1456
                # this is a single-line item, value was found above
1457
                loc = Location(line=line, col=depth + len(key) + 2)
2✔
1458
            else:
1459
                # value is on subsequent lines
1460
                depth_of_next = lines.depth_of_next()
2✔
1461
                if depth_of_next > depth:
2✔
1462
                    # read indented values
1463
                    value, loc = self._read_value(depth_of_next, new_keys)
2✔
1464
                elif line.kind == "dict item":
2✔
1465
                    # found the next key in this dictionary, so value is empty
1466
                    value = ""
2✔
1467
                    loc = Location(line=line, col=depth + len(key) + 1)
2✔
1468
                else:
1469
                    report("multiline key requires a value.", line, None, colno=depth)
2✔
1470

1471
            values[key] = value
2✔
1472
            loc.key_line = key_line
2✔
1473
            loc.key_col = key_col
2✔
1474
            self._add_keymap(new_keys, loc)
2✔
1475
        return values, Location(line=first_line, col=first_line.depth)
2✔
1476

1477
    # _read_key() {{{3
1478
    def _read_key(self, line, depth):
2✔
1479
        lines = self.lines
2✔
1480
        data = [line.value]
2✔
1481
        while lines.still_within_key(line, depth):
2✔
1482
            line = lines.get_next()
2✔
1483
            data.append(line.value)
2✔
1484
        return "\n".join(data)
2✔
1485

1486
    # _read_string() {{{3
1487
    def _read_string(self, depth, keys):
2✔
1488
        lines = self.lines
2✔
1489
        data = []
2✔
1490
        first_line = lines.next_line
2✔
1491
        loc = Location(line=first_line, key_col=depth)
2✔
1492
        last_line = first_line
2✔
1493
        while lines.still_within_string(depth):
2✔
1494
            line = lines.get_next()
2✔
1495
            last_line = line
2✔
1496
            data.append(line.value)
2✔
1497
            if line.depth != depth:
2✔
1498
                lines.indentation_error(line, depth)
2✔
1499
        # Per the rules, inline comments (those that appear on continuation
1500
        # lines of a multi-line string) are converted to trailing on the
1501
        # value immediately on load.  After the loop, gather any leading
1502
        # comments from continuation lines and move them onto the last
1503
        # line's trailing slot, which is where the value's trailing
1504
        # comments live (see Location.value_end_line).
1505
        cur = first_line.next_line
2✔
1506
        while cur is not None:
2✔
1507
            inline = cur.leading_comments
2✔
1508
            if inline:
2✔
1509
                last_line.trailing_comments = last_line.trailing_comments or []
2✔
1510
                if cur is last_line:
2✔
1511
                    # avoid clobbering when cur and last_line are the same
1512
                    last_line.trailing_comments = list(inline) + last_line.trailing_comments
2✔
1513
                else:
1514
                    last_line.trailing_comments.extend(inline)
2✔
1515
                cur.leading_comments = []
2✔
1516
            cur = cur.next_line
2✔
1517
        value = "\n".join(data)
2✔
1518
        loc.col = depth + (2 if value else 1)
2✔
1519
        return value, loc
2✔
1520

1521
    # _read_inline() {{{3
1522
    def _read_inline(self, keys):
2✔
1523
        lines = self.lines
2✔
1524
        line = lines.get_next()
2✔
1525
        return Inline(line, keys, self).get_values()
2✔
1526

1527

1528
# loads {{{2
1529
def loads(
2✔
1530
    content,
1531
    top = "dict",
1532
    *,
1533
    source = None,
1534
    on_dup = None,
1535
    keymap = None,
1536
    normalize_key = None,
1537
    dialect = None
1538
):
1539
    # description {{{3
1540
    r'''
1541
    Loads *NestedText* from string.
1542

1543
    Args:
1544
        content (str):
1545
            String that contains encoded data.
1546

1547
        top (str):
1548
            Top-level data type. The NestedText format allows for a dictionary,
1549
            a list, or a string as the top-level data container.  By specifying
1550
            top as “dict”, “list”, or “str” you constrain both the type of
1551
            top-level container and the return value of this function. By
1552
            specifying “any” you enable support for all three data types, with
1553
            the type of the returned value matching that of top-level container
1554
            in content. As a short-hand, you may specify the *dict*, *list*,
1555
            *str*, and *any* built-ins rather than specifying *top* with a
1556
            string.
1557

1558
        source (str or Path):
1559
            If given, this string is attached to any error messages as the
1560
            culprit. It is otherwise unused. Is often the name of the file that
1561
            originally contained the NestedText content.
1562

1563
        on_dup (str or func):
1564
            Indicates how duplicate keys in dictionaries should be handled.
1565
            Specifying "error" causes them to raise exceptions (the default
1566
            behavior). Specifying "ignore" causes them to be ignored (first
1567
            wins). Specifying "replace" results in them replacing earlier items
1568
            (last wins). By specifying a function, the keys can be
1569
            de-duplicated.  This call-back function returns a new key and takes
1570
            two arguments:
1571

1572
            key:
1573
                The new key (duplicates an existing key).
1574

1575
            state:
1576
                A dictionary containing other possibly helpful information:
1577

1578
                dictionary:
1579
                    The entire dictionary as it is at the moment the duplicate
1580
                    key is found.  You should not change it.
1581
                keys:
1582
                    The keys that identify the dictionary.
1583

1584
                This dictionary is created as *loads* is called and deleted as
1585
                it returns. Any values placed in it are retained and available
1586
                on subsequent calls to this function during the load operation.
1587

1588
            This function should return a new key.  If the key duplicates an
1589
            existing key, the value associated with that key is replaced.  If
1590
            *None* is returned, this key is ignored.  If a *KeyError* is
1591
            raised, the duplicate key is reported as an error.
1592

1593
            Be aware that key de-duplication occurs after key normalization.  As
1594
            such you should generate keys during de-duplication that are
1595
            consistent with your normalization scheme.
1596

1597
        keymap (dict):
1598
            Specify an empty dictionary or nothing at all for the value of
1599
            this argument.  If you give an empty dictionary it will be filled
1600
            with location information for the values that are returned.  Upon
1601
            return the dictionary maps a tuple containing the keys for the value
1602
            of interest to the location of that value in the *NestedText* source
1603
            document. The location is contained in a :class:`Location` object.
1604
            You can access the line and column number using the
1605
            :meth:`Location.as_tuple` method, and the line that contains the
1606
            value annotated with its location using the :meth:`Location.as_line`
1607
            method.
1608

1609
        normalize_key (func):
1610
            A function that takes two arguments; the original key for a value
1611
            and the tuple of normalized keys for its parent values.  It then
1612
            transforms the given key into the desired normalized form.  Only
1613
            called on dictionary keys, so the key will always be a string.
1614

1615
        dialect (str):
1616
            Specifies support for particular variations in *NestedText*.
1617

1618
            In general you are discouraged from using a dialect as it can result
1619
            in *NestedText* documents that are not compliant with the standard.
1620

1621
            The following deviant dialects are supported.
1622

1623
            *support inlines*:
1624
                If "i" is included in *dialect*, support for inline lists and
1625
                dictionaries is dropped.  The default is "I", which enables
1626
                support for inlines.  The main effect of disabling inlines in
1627
                the load functions is that keys may begin with ``[`` or ``{``.
1628

1629
    Returns:
1630
        The extracted data.  The type of the return value is specified by the
1631
        top argument.  If top is “any”, then the return value will match that of
1632
        top-level data container in the input content. If content is empty, an
1633
        empty data value of the type specified by top is returned. If top is
1634
        “any” None is returned.
1635

1636
    Raises:
1637
        NestedTextError: if there is a problem in the *NextedText* document.
1638

1639
    Examples:
1640

1641
        A *NestedText* document is specified to *loads* in the form of a string:
1642

1643
        .. code-block:: python
1644

1645
            >>> import nestedtext as nt
1646

1647
            >>> contents = """
1648
            ... name: Kristel Templeton
1649
            ... gender: female
1650
            ... age: 74
1651
            ... """
1652

1653
            >>> try:
1654
            ...     data = nt.loads(contents, "dict")
1655
            ... except nt.NestedTextError as e:
1656
            ...     e.terminate()
1657

1658
            >>> print(data)
1659
            {'name': 'Kristel Templeton', 'gender': 'female', 'age': '74'}
1660

1661
        *loads()* takes an optional argument, *source*. If specified, it is
1662
        added to any error messages. It is often used to designate the source
1663
        of *NestedText* document. For example, if *contents* were read from a
1664
        file, *source* would be the file name.  Here is a typical example of
1665
        reading *NestedText* from a file:
1666

1667
        .. code-block:: python
1668

1669
            >>> filename = "examples/duplicate-keys.nt"
1670
            >>> try:
1671
            ...     with open(filename, encoding="utf-8") as f:
1672
            ...         addresses = nt.loads(f.read(), source=filename)
1673
            ... except nt.NestedTextError as e:
1674
            ...     print(e.render())
1675
            examples/duplicate-keys.nt, 5: duplicate key: name.
1676
                   4 ❬name:❭
1677
                   5 ❬name:❭
1678
                      ▲
1679

1680
        Notice in the above example the encoding is explicitly specified as
1681
        "utf-8".  *NestedText* files should always be read and written using
1682
        *utf-8* encoding.
1683

1684
        The following examples demonstrate the various ways of handling
1685
        duplicate keys:
1686

1687
        .. code-block:: python
1688

1689
            >>> content = """
1690
            ... key: value 1
1691
            ... key: value 2
1692
            ... key: value 3
1693
            ... name: value 4
1694
            ... name: value 5
1695
            ... """
1696

1697
            >>> print(nt.loads(content))
1698
            Traceback (most recent call last):
1699
            ...
1700
            nestedtext.nestedtext.NestedTextError: 3: duplicate key: key.
1701
                   2 ❬key: value 1❭
1702
                   3 ❬key: value 2❭
1703
                      ▲
1704

1705
            >>> print(nt.loads(content, on_dup="ignore"))
1706
            {'key': 'value 1', 'name': 'value 4'}
1707

1708
            >>> print(nt.loads(content, on_dup="replace"))
1709
            {'key': 'value 3', 'name': 'value 5'}
1710

1711
            >>> def de_dup(key, state):
1712
            ...     if key not in state:
1713
            ...         state[key] = 1
1714
            ...     state[key] += 1
1715
            ...     return f"{key} — #{state[key]}"
1716

1717
            >>> print(nt.loads(content, on_dup=de_dup))
1718
            {'key': 'value 1', 'key — #2': 'value 2', 'key — #3': 'value 3', 'name': 'value 4', 'name — #2': 'value 5'}
1719

1720
    '''
1721

1722
    # code {{{3
1723
    if isinstance(content, bytes):
2✔
1724
        content = content.decode('utf-8-sig', errors='strict')
2✔
1725
    f = io.StringIO(content, newline=None)
2✔
1726
    loader = NestedTextLoader(
2✔
1727
        f, top, source, on_dup, keymap, normalize_key, dialect
1728
    )
1729
    return loader.get_decoded()
2✔
1730

1731

1732
# load {{{2
1733
def load(
2✔
1734
    f,
1735
    top = "dict",
1736
    *,
1737
    source = None,
1738
    on_dup = None,
1739
    keymap = None,
1740
    normalize_key = None,
1741
    dialect = None
1742
):
1743
    # description {{{3
1744
    r"""
1745
    Loads *NestedText* from file or stream.
1746

1747
    Is the same as :func:`loads` except the *NextedText* is accessed by reading
1748
    a file rather than directly from a string. It does not keep the full
1749
    contents of the file in memory and so is more memory efficient with large
1750
    files.
1751

1752
    Args:
1753
        f (str, os.PathLike, io.TextIOBase, collections.abc.Iterator):
1754
            The file to read the *NestedText* content from.  This can be
1755
            specified either as a path (e.g. a string or a `pathlib.Path`),
1756
            as a text IO object (e.g. an open file, or 0 for stdin), or as an
1757
            iterator.  If a path is given, the file will be opened, read, and
1758
            closed.  If an IO object is given, it will be read and not closed;
1759
            utf-8 encoding should be used..  If an iterator is given, it should
1760
            generate full lines in the same manner that iterating on a file
1761
            descriptor would.
1762
        kwargs:
1763
            See :func:`loads` for optional arguments.
1764

1765
    Returns:
1766
        The extracted data.
1767
        See :func:`loads` description of the return value.
1768

1769
    Raises:
1770
        NestedTextError: if there is a problem in the *NextedText* document.
1771
        OSError: if there is a problem opening the file.
1772

1773
    Examples:
1774

1775
        Load from a path specified as a string:
1776

1777
        .. code-block:: python
1778

1779
            >>> import nestedtext as nt
1780
            >>> print(open("examples/groceries.nt").read())
1781
            groceries:
1782
              - Bread
1783
              - Peanut butter
1784
              - Jam
1785
            <BLANKLINE>
1786

1787
            >>> nt.load("examples/groceries.nt")
1788
            {'groceries': ['Bread', 'Peanut butter', 'Jam']}
1789

1790
        Load from a `pathlib.Path`:
1791

1792
        .. code-block:: python
1793

1794
            >>> from pathlib import Path
1795
            >>> nt.load(Path("examples/groceries.nt"))
1796
            {'groceries': ['Bread', 'Peanut butter', 'Jam']}
1797

1798
        Load from an open file object:
1799

1800
        .. code-block:: python
1801

1802
            >>> with open("examples/groceries.nt") as f:
1803
            ...     nt.load(f)
1804
            ...
1805
            {'groceries': ['Bread', 'Peanut butter', 'Jam']}
1806

1807
    """
1808

1809
    # code {{{3
1810
    # Do not invoke the read method as that would read in the entire contents of
1811
    # the file, possibly consuming a lot of memory. Instead pass the file
1812
    # pointer into loader, it will iterate through the lines, discarding
1813
    # them once they are no longer needed, which reduces the memory usage.
1814

1815
    if isinstance(f, collections.abc.Iterator):
2✔
1816
        if not source:
2✔
1817
            source = getattr(f, "name", None)
2✔
1818
        loader = NestedTextLoader(
2✔
1819
            f, top, source, on_dup, keymap, normalize_key, dialect
1820
        )
1821
        return loader.get_decoded()
2✔
1822
    else:
1823
        if not source:
2✔
1824
            if f == 0:
2✔
1825
                source = '<stdin>'
2✔
1826
            else:
1827
                source = str(f)
2✔
1828
        with open(f, encoding="utf-8-sig") as fp:
2✔
1829
            loader = NestedTextLoader(
2✔
1830
                fp, top, source, on_dup, keymap, normalize_key, dialect
1831
            )
1832
            return loader.get_decoded()
2✔
1833

1834

1835
# NestedText Writer {{{1
1836
# Converts Python data hierarchies to NestedText.
1837

1838
# add_leader {{{2
1839
def add_leader(s, leader):
2✔
1840
    # split into separate lines
1841
    # add leader to each non-blank line
1842
    # add right-stripped leader to each blank line
1843
    #
1844
    # When the leader is pure indentation (only spaces), comment lines (those
1845
    # whose first non-space character is '#') are passed through unchanged --
1846
    # they already carry their own absolute indentation from
1847
    # _comments_to_lines, and re-indenting them at deeper levels would put
1848
    # them out of position.  When the leader contains content like "> "
1849
    # (multi-line string syntax) the leader is always applied -- the input
1850
    # is user data being converted into string items, not pre-rendered
1851
    # comments.
1852
    indent_only = (leader.lstrip(" ") == "")
2✔
1853
    rejoined = []
2✔
1854
    for line in s.split("\n"):
2✔
1855
        if indent_only and line and line.lstrip(" ")[:1] == "#":
2✔
1856
            rejoined.append(line)
2✔
1857
        elif line:
2✔
1858
            rejoined.append(leader + line)
2✔
1859
        else:
1860
            rejoined.append(leader.rstrip())
2✔
1861
    return "\n".join(rejoined)
2✔
1862

1863

1864
# add_prefix {{{2
1865
def add_prefix(prefix, suffix):
2✔
1866
    # A simple formatting of dict and list items will result in a space
1867
    # after the colon or dash if the value is placed on next line.
1868
    # This, function simply eliminates that space.
1869
    if not suffix or suffix.startswith("\n"):
2✔
1870
        return prefix + suffix
2✔
1871
    return prefix + " " + suffix
2✔
1872

1873

1874
# grow {{{2
1875
# add object to end of a tuple
1876
def grow(base, ext):
2✔
1877
    return base + (ext,)
2✔
1878

1879

1880
# NestedTextDumper class {{{2
1881
class NestedTextDumper:
2✔
1882
    # constructor {{{3
1883
    def __init__(
2✔
1884
        self,
1885
        width,
1886
        inline_level,
1887
        sort_keys,
1888
        indent,
1889
        converters,
1890
        default,
1891
        map_keys,
1892
        dialect,
1893
        spacing,
1894
    ):
1895
        assert indent > 0
2✔
1896
        self.width = width
2✔
1897
        self.inline_level = inline_level
2✔
1898
        self.indent = indent
2✔
1899
        self.converters = converters
2✔
1900
        self.map_keys = map_keys
2✔
1901
        self.default = default
2✔
1902
        self.spacing = spacing or {}
2✔
1903
        self.support_inlines = True
2✔
1904
        if dialect and "i" in dialect:
2✔
1905
            self.support_inlines = False
2✔
1906

1907
        # define key sorting function {{{4
1908
        if sort_keys:
2✔
1909
            if callable(sort_keys):
2✔
1910
                def sort(items, keys):
2✔
1911
                    return sorted(items, key=lambda k: sort_keys(k, keys))
2✔
1912
            else:
1913
                def sort(items, keys):
2✔
1914
                    return sorted(items)
2✔
1915
        else:
1916
            def sort(items, keys):
2✔
1917
                return items
2✔
1918
        self.sort = sort
2✔
1919

1920
        # define object type identification functions {{{4
1921
        if default == "strict":
2✔
1922
            self.is_a_dict = lambda obj: isinstance(obj, dict)
2✔
1923
            self.is_a_list = lambda obj: isinstance(obj, list)
2✔
1924
            self.is_a_str = lambda obj: isinstance(obj, str)
2✔
1925
            self.is_a_scalar = lambda obj: False
2✔
1926
        else:
1927
            self.is_a_dict = is_mapping
2✔
1928
            self.is_a_list = is_collection
2✔
1929
            self.is_a_str = is_str
2✔
1930
            self.is_a_scalar = lambda obj: obj is None or isinstance(obj, (bool, int, float))
2✔
1931
            if is_str(default):
2✔
1932
                raise NotImplementedError(default)  # pragma: no cover
1933

1934
    # render_key {{{3
1935
    def render_key(self, key, keys):
2✔
1936
        key = self.convert(key, keys)
2✔
1937
        if self.is_a_scalar(key):
2✔
1938
            if key is None:
2✔
1939
                key = ""
2✔
1940
            else:
1941
                key = str(key)
2✔
1942
        if not self.is_a_str(key) and callable(self.default):
2✔
1943
            key = self.default(key)
2✔
1944
        if not self.is_a_str(key):
2✔
1945
            raise NestedTextError(
2✔
1946
                key, template="keys must be strings.", culprit=keys
1947
            ) from None
1948
        return convert_line_terminators(key)
2✔
1949

1950
    # render_dict_item {{{3
1951
    def render_dict_item(self, key, value, keys, values):
2✔
1952
        multiline_key_required = (
2✔
1953
            not key
1954
            or "\n" in key
1955
            or key.strip() != key
1956
            or key[:1] == "#"
1957
            or (key[:1] in "[{" and self.support_inlines)
1958
            or key[:2] in ["- ", "> ", ": "]
1959
            or ": " in key
1960
        )
1961
        if multiline_key_required:
2✔
1962
            key = "\n".join(": "+l if l else ":" for l in key.split("\n"))
2✔
1963
            if self.is_a_dict(value) or self.is_a_list(value):
2✔
1964
                return key + self.render_value(value, keys, values)
2✔
1965
            if is_str(value):
2✔
1966
                # force use of multiline value with multiline keys
1967
                value = convert_line_terminators(value)
2✔
1968
            else:
1969
                value = self.render_value(value, keys, values)
2✔
1970
            return key + "\n" + add_leader(value, self.indent*" " + "> ")
2✔
1971
        else:
1972
            return add_prefix(key + ":", self.render_value(value, keys, values))
2✔
1973

1974
    # render_inline_value {{{3
1975
    def render_inline_value(self, obj, exclude, keys, values):
2✔
1976
        obj = self.convert(obj, keys)
2✔
1977
        if self.is_a_dict(obj):
2✔
1978
            return self.render_inline_dict(obj, keys, values)
2✔
1979
        if self.is_a_list(obj):
2✔
1980
            return self.render_inline_list(obj, keys, values)
2✔
1981
        return self.render_inline_scalar(obj, exclude, keys, values)
2✔
1982

1983
    # render_inline_dict {{{3
1984
    def render_inline_dict(self, obj, keys, values):
2✔
1985
        exclude = set("\n\r[]{}:,")
2✔
1986
        rendered = []
2✔
1987
        for k, v in obj.items():
2✔
1988
            new_keys = grow(keys, k)
2✔
1989
            new_values = grow(values, id(v))
2✔
1990
            key = self.render_key(k, new_keys)
2✔
1991
            mapped_key = self.map_key(key, new_keys)
2✔
1992
            v = obj[k]
2✔
1993
            rendered_value = self.render_inline_value(
2✔
1994
                v, exclude, new_keys, new_values
1995
            )
1996
            rendered_key = self.render_inline_scalar(
2✔
1997
                mapped_key, exclude, new_keys, new_values
1998
            )
1999
            rendered.append((mapped_key, key, f"{rendered_key}: {rendered_value}",))
2✔
2000
        items = [v for mk, k, v in self.sort(rendered, keys)]
2✔
2001
        return ''.join(["{", ", ".join(items), "}"])
2✔
2002

2003
    # render_inline_list {{{3
2004
    def render_inline_list(self, obj, keys, values):
2✔
2005
        rendered_values = []
2✔
2006
        for i, v in enumerate(obj):
2✔
2007
            rendered_value = self.render_inline_value(
2✔
2008
                v, set("\n\r[]{},"), grow(keys, i), grow(values, id(v))
2009
            )
2010
            rendered_values.append(rendered_value)
2✔
2011
        if len(rendered_values) == 1 and not rendered_values[0]:
2✔
2012
            return "[ ]"
2✔
2013
        content = ", ".join(rendered_values)
2✔
2014
        leading_delimiter = "[ " if content[0:1] == "," else "["
2✔
2015
        return leading_delimiter + content + "]"
2✔
2016

2017
    # render_inline_scalar {{{3
2018
    def render_inline_scalar(self, obj, exclude, keys, values):
2✔
2019
        obj = self.convert(obj, keys)
2✔
2020
        if self.is_a_str(obj):
2✔
2021
            value = obj
2✔
2022
        elif self.is_a_scalar(obj):
2✔
2023
            value = "" if obj is None else str(obj)
2✔
2024
        elif self.default and callable(self.default):
2✔
2025
            try:
2✔
2026
                obj = self.default(obj)
2✔
2027
            except TypeError:
2✔
2028
                raise NestedTextError(
2✔
2029
                    obj,
2030
                    template = f"unsupported type ({type(obj).__name__}).",
2031
                    culprit = keys
2032
                ) from None
2033
            return self.render_inline_value(obj, exclude, keys, values)
2✔
2034
        else:
2035
            raise NotSuitableForInline from None
2✔
2036

2037
        if exclude & set(value):
2✔
2038
            raise NotSuitableForInline from None
2✔
2039
        if value.strip() != value:
2✔
2040
            raise NotSuitableForInline from None
2✔
2041
        return value
2✔
2042

2043
    # _comments_to_lines {{{3
2044
    def _comments_to_lines(self, comments, natural=0):
2✔
2045
        """Render a list of Comment objects to a list of lines (no trailing \\n).
2046

2047
        *natural* is the natural indent (in spaces) for this slot at the
2048
        current depth -- used to resolve any Comment whose ``tab`` field
2049
        is not None to an absolute indent of
2050
        ``natural + tab * self.indent`` (clamped to >= 0).  Comments whose
2051
        ``tab`` is None render at their stored ``indent`` field absolutely.
2052

2053
        Per-comment ``before`` / ``after`` blank-line counts are honored
2054
        in addition to the existing same-indent auto-blank between
2055
        consecutive comments at the same resolved column.
2056
        """
2057
        lines = []
2✔
2058
        prev_indent = None
2✔
2059
        for c in comments:
2✔
2060
            if c.tab is not None:
2✔
2061
                abs_indent = max(0, natural + c.tab * self.indent)
2✔
2062
            else:
2063
                abs_indent = c.indent
2✔
2064
            for _ in range(c.before):
2!
NEW
2065
                lines.append("")
×
2066
            if prev_indent is not None and prev_indent == abs_indent:
2!
NEW
2067
                lines.append("")
×
2068
            ind = " " * abs_indent
2✔
2069
            for line in c.text.split("\n"):
2✔
2070
                if line:
2!
2071
                    lines.append(f"{ind}# {line}")
2✔
2072
                else:
NEW
2073
                    lines.append(f"{ind}#")
×
2074
            for _ in range(c.after):
2!
NEW
2075
                lines.append("")
×
2076
            prev_indent = abs_indent
2✔
2077
        return lines
2✔
2078

2079
    # _resolve_spacing {{{3
2080
    def _resolve_spacing(self, keys):
2✔
2081
        """Determine the blank-line count for joining sibling items whose
2082
        shared parent is at *keys* (absolute depth ``len(keys)``).
2083

2084
        Walks the keymap from the innermost prefix to the root looking for
2085
        a Location with a non-empty ``spacing`` dict.  The first one found
2086
        replaces the global *spacing* argument wholesale for that subtree:
2087
        ``spacing.get(N - len(A), 0)`` where ``A`` is that Location's keys
2088
        and ``N`` is the absolute depth.  Falls back to the global
2089
        ``self.spacing[N]`` only when no Location in the walk has a
2090
        non-empty spacing.
2091
        """
2092
        depth = len(keys)
2✔
2093
        if is_mapping(self.map_keys):
2✔
2094
            for i in range(depth, -1, -1):
2✔
2095
                loc = self.map_keys.get(keys[:i])
2✔
2096
                if loc is not None:
2!
2097
                    sp = getattr(loc, "spacing", None)
2✔
2098
                    if sp:
2!
NEW
2099
                        return sp.get(depth - i, 0)
×
2100
        return self.spacing.get(depth, 0) if self.spacing else 0
2✔
2101

2102
    # _join_items {{{3
2103
    def _join_items(self, items, keys):
2✔
2104
        """Join rendered sibling items with the configured *spacing*.
2105

2106
        ``keys`` is the path of the parent whose children are being joined;
2107
        the per-Location keymap spacing (if any) at any prefix of ``keys``
2108
        takes precedence over the global ``self.spacing`` (the dumps()
2109
        *spacing* argument).
2110
        """
2111
        n = self._resolve_spacing(keys)
2✔
2112
        return ("\n" + "\n" * n).join(items)
2✔
2113

2114
    # _wrap_with_comments {{{3
2115
    def _wrap_with_comments(self, rendered_value, keys):
2✔
2116
        """Inject the four comment slots from the keymap around the rendered item.
2117

2118
        - ``key_leading_comments``  emitted before the rendered item.
2119
        - ``key_trailing_comments`` injected between the key line and the
2120
          value's first line (multi-line value form only).
2121
        - ``value_leading_comments`` injected at the same position, after
2122
          ``key_trailing_comments`` (multi-line value form only).
2123
        - ``value_trailing_comments`` emitted after the rendered item.
2124

2125
        Only applies when ``map_keys`` is a keymap dict (the form returned by
2126
        load).  When ``map_keys`` is a callable (key transformer), there are
2127
        no comments to apply.
2128
        """
2129
        if not is_mapping(self.map_keys):
2✔
2130
            return rendered_value
2✔
2131
        loc = self.map_keys.get(keys)
2✔
2132
        if loc is None:
2✔
2133
            return rendered_value
2✔
2134
        n = len(keys)
2✔
2135
        key_natural = max(0, (n - 1) * self.indent)
2✔
2136
        val_natural = n * self.indent
2✔
2137
        leading = self._comments_to_lines(
2✔
2138
            loc.get_key_leading_comments(), natural=key_natural
2139
        )
2140
        key_trailing = self._comments_to_lines(
2✔
2141
            loc.get_key_trailing_comments(), natural=val_natural
2142
        )
2143
        value_leading = self._comments_to_lines(
2✔
2144
            loc.get_value_leading_comments(), natural=val_natural
2145
        )
2146
        trailing = self._comments_to_lines(
2✔
2147
            loc.get_value_trailing_comments(), natural=val_natural
2148
        )
2149
        if not (leading or key_trailing or value_leading or trailing):
2✔
2150
            return rendered_value
2✔
2151
        value_lines = rendered_value.split("\n")
2✔
2152
        # Inject key_trailing and value_leading between the key line (first
2153
        # line) and the value's first line.  Only meaningful when the item
2154
        # spans multiple lines.
2155
        if (key_trailing or value_leading) and len(value_lines) > 1:
2!
NEW
2156
            value_lines = (
×
2157
                value_lines[:1]
2158
                + key_trailing
2159
                + value_leading
2160
                + value_lines[1:]
2161
            )
2162
        return "\n".join(leading + value_lines + trailing)
2✔
2163

2164
    # _section_context {{{3
2165
    def _section_context(self, parent_keys, parent_level):
2✔
2166
        """Resolve the section state for a parent at *parent_keys*.
2167

2168
        Returns ``(sections, emitted, natural)`` where ``sections`` is the
2169
        list of (predicate, [Comment]) pairs declared on the parent's
2170
        Location (empty if none), ``emitted`` is a fresh set used to track
2171
        which sections have already fired during this pass, and ``natural``
2172
        is the absolute indent (in spaces) for section markers (the
2173
        children's column).
2174
        """
2175
        sections = ()
2✔
2176
        if is_mapping(self.map_keys):
2✔
2177
            loc = self.map_keys.get(parent_keys)
2✔
2178
            if loc is not None:
2!
2179
                sections = getattr(loc, "sections", ()) or ()
2✔
2180
        return sections, set(), parent_level * self.indent
2✔
2181

2182
    # _apply_section_marker {{{3
2183
    def _apply_section_marker(
2✔
2184
        self, rendered, child_key, sections, emitted, natural
2185
    ):
2186
        """Prepend a section's comments to *rendered* if *child_key* is the
2187
        first child to belong to a not-yet-emitted section.  First-match
2188
        wins among the parent's sections.
2189
        """
2190
        if not sections:
2✔
2191
            return rendered
2✔
2192
        matched = None
2✔
2193
        for i, (pred, _comments) in enumerate(sections):
2!
2194
            if pred(child_key):
2✔
2195
                matched = i
2✔
2196
                break
2✔
2197
        if matched is None or matched in emitted:
2✔
2198
            return rendered
2✔
2199
        sec_lines = self._comments_to_lines(sections[matched][1], natural=natural)
2✔
2200
        emitted.add(matched)
2✔
2201
        if not sec_lines:
2!
NEW
2202
            return rendered
×
2203
        return "\n".join(sec_lines) + "\n" + rendered
2✔
2204

2205
    # render value {{{3
2206
    def render_value(self, obj, keys, values):
2✔
2207
        level = len(keys)
2✔
2208
        error = None
2✔
2209
        content = ""
2✔
2210
        obj = self.convert(obj, keys)
2✔
2211
        need_indented_block = is_collection(obj)
2✔
2212

2213
        if self.is_a_dict(obj):
2✔
2214
            self.check_for_cyclic_reference(obj, keys, values)
2✔
2215
            try:
2✔
2216
                if not self.support_inlines:
2✔
2217
                    raise NotSuitableForInline from None
2✔
2218
                if level < self.inline_level:
2✔
2219
                    raise NotSuitableForInline from None
2✔
2220
                if obj and (self.width <= 0 or len(obj) > self.width/5):
2✔
2221
                    raise NotSuitableForInline from None
2✔
2222
                content = self.render_inline_dict(obj, keys, values)
2✔
2223
                if obj and (len(content) > self.width):
2✔
2224
                    raise NotSuitableForInline from None
2✔
2225
            except NotSuitableForInline:
2✔
2226
                rendered = []
2✔
2227
                parent_sections, section_emitted, section_natural = (
2✔
2228
                    self._section_context(keys, level)
2229
                )
2230
                for k, v in obj.items():
2✔
2231
                    new_keys = grow(keys, k)
2✔
2232
                    new_values = grow(values, id(v))
2✔
2233
                    key = self.render_key(k, new_keys)
2✔
2234
                    mapped_key = self.map_key(key, new_keys)
2✔
2235
                    rendered_value = self.render_dict_item(
2✔
2236
                        mapped_key, obj[k], new_keys, new_values
2237
                    )
2238
                    rendered_value = self._wrap_with_comments(rendered_value, new_keys)
2✔
2239
                    rendered_value = self._apply_section_marker(
2✔
2240
                        rendered_value, k, parent_sections,
2241
                        section_emitted, section_natural,
2242
                    )
2243
                    rendered.append((mapped_key, key, rendered_value))
2✔
2244
                content = self._join_items(
2✔
2245
                    [v for mk, k, v in self.sort(rendered, keys)], keys
2246
                )
2247
        elif self.is_a_list(obj):
2✔
2248
            self.check_for_cyclic_reference(obj, keys, values)
2✔
2249
            try:
2✔
2250
                if not self.support_inlines:
2✔
2251
                    raise NotSuitableForInline from None
2✔
2252
                if level < self.inline_level:
2✔
2253
                    raise NotSuitableForInline from None
2✔
2254
                if obj and (self.width <= 0 or len(obj) > self.width/3):
2✔
2255
                    raise NotSuitableForInline from None
2✔
2256
                content = self.render_inline_list(obj, keys, values)
2✔
2257
                if obj and (len(content) > self.width):
2✔
2258
                    raise NotSuitableForInline from None
2✔
2259
            except NotSuitableForInline:
2✔
2260
                content = []
2✔
2261
                parent_sections, section_emitted, section_natural = (
2✔
2262
                    self._section_context(keys, level)
2263
                )
2264
                for i, v in enumerate(obj):
2✔
2265
                    new_keys = grow(keys, i)
2✔
2266
                    rendered_v = self.render_value(v, new_keys, grow(values, id(v)))
2✔
2267
                    item = add_prefix("-", rendered_v)
2✔
2268
                    item = self._wrap_with_comments(item, new_keys)
2✔
2269
                    item = self._apply_section_marker(
2✔
2270
                        item, i, parent_sections,
2271
                        section_emitted, section_natural,
2272
                    )
2273
                    content.append(item)
2✔
2274
                content = self._join_items(content, keys)
2✔
2275

2276
        elif self.is_a_str(obj):
2✔
2277
            text = convert_line_terminators(obj)
2✔
2278
            if "\n" in text or level == 0:
2✔
2279
                content = add_leader(text, "> ")
2✔
2280
                need_indented_block = True
2✔
2281
            else:
2282
                content = text
2✔
2283
        elif self.is_a_scalar(obj):
2✔
2284
            if obj is None:
2✔
2285
                content = ""
2✔
2286
            else:
2287
                content = str(obj)
2✔
2288
                if level == 0:
2✔
2289
                    content = add_leader(content, "> ")
2✔
2290
                    need_indented_block = True
2✔
2291
        elif self.default and callable(self.default):
2✔
2292
            try:
2✔
2293
                obj = self.default(obj)
2✔
2294
            except TypeError:
2✔
2295
                error = f"unsupported type ({type(obj).__name__})."
2✔
2296
            else:
2297
                content = self.render_value(obj, keys, values)
2✔
2298
        else:
2299
            error = f"unsupported type ({type(obj).__name__})."
2✔
2300

2301
        if need_indented_block and content and level:
2✔
2302
            content = "\n" + add_leader(content, self.indent*" ")
2✔
2303

2304
        if error:
2✔
2305
            raise NestedTextError(obj, template=error, culprit=keys) from None
2✔
2306

2307
        return content
2✔
2308

2309
    # check_for_cyclic_reference {{{3
2310
    def check_for_cyclic_reference(self, obj, keys, values):
2✔
2311
        if id(obj) in values[:-1]:
2✔
2312
            raise NestedTextError(
2✔
2313
                obj, template="circular reference.", culprit=keys
2314
            )
2315

2316
    # convert {{{3
2317
    # apply externally supplied converter to convert value to string
2318
    def convert(self, obj, keys):
2✔
2319
        converters = self.converters
2✔
2320
        converter = getattr(obj, "__nestedtext_converter__", None)
2✔
2321
        converter = converters.get(type(obj)) if converters else converter
2✔
2322
        if converter:
2✔
2323
            try:
2✔
2324
                return converter(obj)
2✔
2325
            except TypeError:
2✔
2326
                # is bound method
2327
                return converter()
2✔
2328
        elif converter is False:
2✔
2329
            raise NestedTextError(
2✔
2330
                obj,
2331
                template = f"unsupported type ({type(obj).__name__}).",
2332
                culprit = keys,
2333
            ) from None
2334
        return obj
2✔
2335

2336
    # map_key {{{3
2337
    # apply externally supplied mapping to convert key to desired form
2338
    def map_key(self, key, keys):
2✔
2339
        mapper = self.map_keys
2✔
2340
        if not mapper:
2✔
2341
            return key
2✔
2342
        if callable(mapper):
2✔
2343
            new_key = mapper(key, keys[:-1])
2✔
2344
            if new_key is None:
2✔
2345
                return key
2✔
2346
            return new_key
2✔
2347
        elif is_mapping(mapper):
2✔
2348
            try:
2✔
2349
                loc = mapper.get(keys)
2✔
2350
                if loc:
2✔
2351
                    return loc._get_original_key(key, strict=False)
2✔
2352
                else:
2353
                    return key
2✔
2354
            except AttributeError:    # pragma: no cover
2355
                raise AssertionError(
2356
                    "if map_keys is a dictionary, it must be a keymap"
2357
                ) from None
2358
        else:  # pragma: no cover
2359
            raise AssertionError(
2360
                "map_keys must be a callable or a dictionary"
2361
            ) from None
2362

2363

2364
# dumps {{{2
2365
def dumps(
2✔
2366
    obj,
2367
    *,
2368
    width = 0,
2369
    inline_level = 0,
2370
    sort_keys = False,
2371
    indent = 4,
2372
    converters = None,
2373
    map_keys = None,
2374
    default = None,
2375
    dialect = None,
2376
    spacing = None,
2377
):
2378
    # description {{{3
2379
    '''Recursively convert object to *NestedText* string.
2380

2381
    Args:
2382
        obj:
2383
            The object to convert to *NestedText*.
2384

2385
        width (int):
2386
            Enables inline lists and dictionaries if greater than zero and if
2387
            resulting line would be less than or equal to given width.
2388

2389
        inline_level (int):
2390
            Recursion depth must be equal to this value or greater to be
2391
            eligible for inlining.
2392

2393
        sort_keys (bool or func):
2394
            Dictionary items are sorted by their key if *sort_keys* is *True*.
2395
            In this case, keys at all level are sorted alphabetically.  If
2396
            *sort_keys* is *False*, the natural order of dictionaries is
2397
            retained.
2398

2399
            If a function is passed in, it is expected to return the sort key.
2400
            The function is passed two tuples, each consists only of strings.
2401
            The first contains the mapped key, the original key, and the
2402
            rendered item.  So it takes the form::
2403

2404
                ('<mapped_key>', '<orig_key>', '<mapped_key>: <value>')
2405

2406
            The second contains the keys of the parent.
2407

2408
        indent (int):
2409
            The number of spaces to use to represent a single level of
2410
            indentation.  Must be one or greater.
2411

2412
        converters (dict):
2413
            A dictionary where the keys are types and the values are converter
2414
            functions (functions that take an object and return it in a form
2415
            that can be processed by *NestedText*).  If a value is False, an
2416
            unsupported type error is raised.
2417

2418
            An object may provide its own converter by defining the
2419
            ``__nestedtext_converter__`` attribute.  It may be False, or it may
2420
            be a method that converts the object to a supported data type for
2421
            *NestedText*.  A matching converter specified in the *converters*
2422
            argument dominates over this attribute.
2423

2424
        default (func or “strict”):
2425
            The default converter. Use to convert otherwise unrecognized objects
2426
            to a form that can be processed. If not provided an error will be
2427
            raised for unsupported data types. Typical values are *repr* or
2428
            *str*. If “strict” is specified then only dictionaries, lists,
2429
            strings, and those types that have converters are allowed. If
2430
            *default* is not specified then a broader collection of value types
2431
            are supported, including *None*, *bool*, *int*, *float*, and *list*-
2432
            and *dict*-like objects.  In this case Booleans are rendered as
2433
            “True” and “False” and None is rendered as an empty string.  If
2434
            *default* is a function, it acts as the default converter.  If
2435
            it raises a TypeError, the value is reported as an
2436
            unsupported type.
2437

2438
        map_keys (func or keymap):
2439
            This argument is used to modify the way keys are rendered, and,
2440
            when it is a keymap, to preserve comments and blank-line spacing
2441
            on round trip.
2442

2443
            It may be a keymap that was created by :func:`load` or
2444
            :func:`loads`, in which case keys are rendered into their original
2445
            form, before any normalization or de-duplication was performed by
2446
            the load functions.  In addition, any comments captured by the
2447
            loader and stored on the keymap are re-emitted around their
2448
            associated keys.  Document-level header and footer comments are
2449
            stored on the root Location (``keymap[()]``) and emitted at the
2450
            top and bottom of the document.
2451

2452
            It may also be a function that takes two arguments: the key after
2453
            any needed conversion has been performed, and the tuple of parent
2454
            keys.  The value returned is used as the key and so must be a
2455
            string.  If no value is returned, the key is not modified.
2456

2457
        spacing (dict):
2458
            A mapping that controls vertical spacing in the rendered output.
2459

2460
            Integer keys specify the minimum number of blank lines between
2461
            sibling items at that depth.  ``spacing={0: 1}`` requests one blank
2462
            line between top-level items; ``spacing={0: 2, 1: 1}`` requests two
2463
            blank lines between top-level items and one between items at the
2464
            first nested level.  Depths not present in the mapping default to
2465
            zero.
2466

2467
            The special key ``"edges"`` is the number of blank lines between
2468
            the document's header comments and the first data item, and
2469
            between the last data item and the document's footer comments.
2470
            One number applies to both.  Defaults to zero.
2471

2472
        dialect (str):
2473
            Specifies support for particular variations in *NestedText*.
2474

2475
            In general you are discouraged from using a dialect as it can result
2476
            in *NestedText* documents that are not compliant with the standard.
2477

2478
            The following deviant dialects are supported.
2479

2480
            *support inlines*:
2481
                If "i" is included in *dialect*, support for inline lists and
2482
                dictionaries is dropped.  The default is "I", which enables
2483
                support for inlines.  The main effect of disabling inlines in
2484
                the dump functions is that empty lists and dictionaries are
2485
                output using an empty value, which is normally interpreted by
2486
                *NestedText* as an empty string.
2487

2488
    Returns:
2489
        The *NestedText* content without a trailing newline.  *NestedText* files
2490
        should end with a newline, but unlike :func:`dump`, this function does
2491
        not output that newline.  You will need to explicitly add that newline
2492
        when writing the output :func:`dumps` to a file.
2493

2494
    Raises:
2495
        NestedTextError: if there is a problem in the input data.
2496

2497
    Examples:
2498

2499
        .. code-block:: python
2500

2501
            >>> import nestedtext as nt
2502

2503
            >>> data = {
2504
            ...     "name": "Kristel Templeton",
2505
            ...     "gender": "female",
2506
            ...     "age": "74",
2507
            ... }
2508

2509
            >>> try:
2510
            ...     print(nt.dumps(data))
2511
            ... except nt.NestedTextError as e:
2512
            ...     print(str(e))
2513
            name: Kristel Templeton
2514
            gender: female
2515
            age: 74
2516

2517
        The *NestedText* format only supports dictionaries, lists, and strings.
2518
        By default, *dumps* is configured to be rather forgiving, so it will
2519
        render many of the base Python data types, such as *None*, *bool*,
2520
        *int*, *float* and list-like types such as *tuple* and *set* by
2521
        converting them to the types supported by the format.  This implies that
2522
        a round trip through *dumps* and *loads* could result in the types of
2523
        values being transformed. You can restrict *dumps* to only supporting
2524
        the native types of *NestedText* by passing `default="strict"` to
2525
        *dumps*.  Doing so means that values that are not dictionaries, lists,
2526
        or strings generate exceptions.
2527

2528
        .. code-block:: python
2529

2530
            >>> data = {"key": 42, "value": 3.1415926, "valid": True}
2531

2532
            >>> try:
2533
            ...     print(nt.dumps(data))
2534
            ... except nt.NestedTextError as e:
2535
            ...     print(str(e))
2536
            key: 42
2537
            value: 3.1415926
2538
            valid: True
2539

2540
            >>> try:
2541
            ...     print(nt.dumps(data, default="strict"))
2542
            ... except nt.NestedTextError as e:
2543
            ...     print(str(e))
2544
            key: unsupported type (int).
2545

2546
        Alternatively, you can specify a function to *default*, which is used
2547
        to convert values to recognized types.  It is used if no suitable
2548
        converter is available.  Typical values are *str* and *repr*.
2549

2550
        .. code-block:: python
2551

2552
            >>> class Color:
2553
            ...     def __init__(self, color):
2554
            ...         self.color = color
2555
            ...     def __repr__(self):
2556
            ...         return f"Color({self.color!r})"
2557
            ...     def __str__(self):
2558
            ...         return self.color
2559

2560
            >>> data["house"] = Color("red")
2561
            >>> print(nt.dumps(data, default=repr))
2562
            key: 42
2563
            value: 3.1415926
2564
            valid: True
2565
            house: Color('red')
2566

2567
            >>> print(nt.dumps(data, default=str))
2568
            key: 42
2569
            value: 3.1415926
2570
            valid: True
2571
            house: red
2572

2573
        If *Color* is consistently used with *NestedText*, you can include the
2574
        converter in *Color* itself.
2575

2576
        .. code-block:: python
2577

2578
            >>> class Color:
2579
            ...     def __init__(self, color):
2580
            ...         self.color = color
2581
            ...     def __nestedtext_converter__(self):
2582
            ...         return self.color.title()
2583

2584
            >>> data["house"] = Color("red")
2585
            >>> print(nt.dumps(data))
2586
            key: 42
2587
            value: 3.1415926
2588
            valid: True
2589
            house: Red
2590

2591
        You can also specify a dictionary of converters. The dictionary maps the
2592
        object type to a converter function.
2593

2594
        .. code-block:: python
2595

2596
            >>> class Info:
2597
            ...     def __init__(self, **kwargs):
2598
            ...         self.__dict__ = kwargs
2599

2600
            >>> converters = {
2601
            ...     bool: lambda b: "yes" if b else "no",
2602
            ...     int: hex,
2603
            ...     float: lambda f: f"{f:0.3}",
2604
            ...     Color: lambda c: c.color,
2605
            ...     Info: lambda i: i.__dict__,
2606
            ... }
2607

2608
            >>> data["attributes"] = Info(readable=True, writable=False)
2609

2610
            >>> try:
2611
            ...    print(nt.dumps(data, converters=converters))
2612
            ... except nt.NestedTextError as e:
2613
            ...     print(str(e))
2614
            key: 0x2a
2615
            value: 3.14
2616
            valid: yes
2617
            house: red
2618
            attributes:
2619
                readable: yes
2620
                writable: no
2621

2622
        The above example shows that *Color* in the *converters* argument
2623
        dominates over the ``__nestedtest__converter__`` class.
2624

2625
        If the dictionary maps a type to *None*, then the default behavior is
2626
        used for that type. If it maps to *False*, then an exception is raised.
2627

2628
        .. code-block:: python
2629

2630
            >>> converters = {
2631
            ...     bool: lambda b: "yes" if b else "no",
2632
            ...     int: hex,
2633
            ...     float: False,
2634
            ...     Color: lambda c: c.color,
2635
            ...     Info: lambda i: i.__dict__,
2636
            ... }
2637

2638
            >>> try:
2639
            ...    print(nt.dumps(data, converters=converters))
2640
            ... except nt.NestedTextError as e:
2641
            ...     print(str(e))
2642
            value: unsupported type (float).
2643

2644
        *converters* need not actually change the type of a value, it may simply
2645
        transform the value.  In the following example, *converters* is used to
2646
        transform dictionaries by removing empty dictionary fields.  It is also
2647
        converts dates and physical quantities to strings.
2648

2649
        .. code-block:: python
2650

2651
            >>> import arrow
2652
            >>> from inform import cull
2653
            >>> import quantiphy
2654

2655
            >>> class Dollars(quantiphy.Quantity):
2656
            ...     units = "$"
2657
            ...     form = "fixed"
2658
            ...     prec = 2
2659
            ...     strip_zeros = False
2660
            ...     show_commas = True
2661

2662
            >>> converters = {
2663
            ...     dict: cull,
2664
            ...     arrow.Arrow: lambda d: d.format("D MMMM YYYY"),
2665
            ...     quantiphy.Quantity: lambda q: str(q)
2666
            ... }
2667

2668
            >>> transaction = dict(
2669
            ...     date = arrow.get("2013-05-07T22:19:11.363410-07:00"),
2670
            ...     description = "Incoming wire from Publisher’s Clearing House",
2671
            ...     debit = 0,
2672
            ...     credit = Dollars(12345.67)
2673
            ... )
2674

2675
            >>> print(nt.dumps(transaction, converters=converters))
2676
            date: 7 May 2013
2677
            description: Incoming wire from Publisher’s Clearing House
2678
            credit: $12,345.67
2679

2680
        Both *default* and *converters* may be used together. *converters* has
2681
        priority over the built-in types and *default*.  When a function is
2682
        specified as *default*, it is always applied as a last resort.
2683

2684
        Use the *map_keys* argument to format the keys as you wish.  For
2685
        example, you may wish to render the keys at the first level of hierarchy
2686
        in upper case:
2687

2688
        .. code-block:: python
2689

2690
            >>> def map_keys(key, parent_keys):
2691
            ...     if len(parent_keys) == 0:
2692
            ...         return key.upper()
2693

2694
            >>> print(nt.dumps(transaction, converters=converters, map_keys=map_keys))
2695
            DATE: 7 May 2013
2696
            DESCRIPTION: Incoming wire from Publisher’s Clearing House
2697
            CREDIT: $12,345.67
2698

2699
        It can also be used map the keys back to their original form when
2700
        round-tripping a dataset when using key normalization or key
2701
        de-duplication:
2702

2703
        .. code-block:: python
2704

2705
            >>> content = """
2706
            ... Michael Jordan:
2707
            ...     occupation: basketball player
2708
            ... Michael Jordan:
2709
            ...     occupation: actor
2710
            ... Michael Jordan:
2711
            ...     occupation: football player
2712
            ... """
2713

2714
            >>> def de_dup(key, state):
2715
            ...     if key not in state:
2716
            ...         state[key] = 1
2717
            ...     state[key] += 1
2718
            ...     return f"{key}  ⟪#{state[key]}⟫"
2719

2720
            >>> keymap = {}
2721
            >>> people = nt.loads(content, dict, on_dup=de_dup, keymap=keymap)
2722
            >>> print(nt.dumps(people))
2723
            Michael Jordan:
2724
                occupation: basketball player
2725
            Michael Jordan  ⟪#2⟫:
2726
                occupation: actor
2727
            Michael Jordan  ⟪#3⟫:
2728
                occupation: football player
2729

2730
            >>> print(nt.dumps(people, map_keys=keymap))
2731
            Michael Jordan:
2732
                occupation: basketball player
2733
            Michael Jordan:
2734
                occupation: actor
2735
            Michael Jordan:
2736
                occupation: football player
2737

2738
    '''
2739

2740
    # code {{{3
2741
    dumper = NestedTextDumper(
2✔
2742
        width, inline_level, sort_keys, indent, converters, default,
2743
        map_keys, dialect, spacing
2744
    )
2745
    content = dumper.render_value(obj, (), ())
2✔
2746

2747
    # prepend header / append footer comments when map_keys is a keymap dict
2748
    # carrying a document-root Location.  The blank-line gap between header
2749
    # and body (and between body and footer) is taken from spacing["edges"]
2750
    # if present, else zero.
2751
    if is_mapping(map_keys):
2✔
2752
        root = map_keys.get(())
2✔
2753
        header = root.get_header_comments() if root is not None else None
2✔
2754
        footer = root.get_footer_comments() if root is not None else None
2✔
2755
        root_spacing = root.get_spacing() if root is not None else None
2✔
2756
        if root_spacing:
2!
NEW
2757
            edge_blanks = root_spacing.get("edges", 1)
×
2758
        else:
2759
            edge_blanks = (spacing or {}).get("edges", 1)
2✔
2760
        edge_sep = "\n" + ("\n" * edge_blanks)
2✔
2761
        if header:
2✔
2762
            header_lines = dumper._comments_to_lines(header, natural=0)
2✔
2763
            if header_lines:
2!
2764
                rendered = "\n".join(header_lines)
2✔
2765
                content = rendered + edge_sep + content if content else rendered
2✔
2766
        if footer:
2!
NEW
2767
            footer_lines = dumper._comments_to_lines(footer, natural=0)
×
NEW
2768
            if footer_lines:
×
NEW
2769
                rendered = "\n".join(footer_lines)
×
NEW
2770
                content = content + edge_sep + rendered if content else rendered
×
2771

2772
    return content
2✔
2773

2774

2775
# dump {{{2
2776
def dump(obj, dest, **kwargs):
2✔
2777
    # description {{{3
2778
    """Write the *NestedText* representation of the given object to the given file.
2779

2780
    Args:
2781
        obj:
2782
            The object to convert to *NestedText*.
2783
        dest (str, os.PathLike, io.TextIOBase):
2784
            The file to write the *NestedText* content to.  The file can be
2785
            specified either as a path (e.g. a string or a `pathlib.Path`) or
2786
            as a text IO instance (e.g. an open file, or 1 for stdout).  If a
2787
            path is given, the will be opened, written, and closed.  If an IO
2788
            object is given, it must have been opened in a mode that allows
2789
            writing (e.g.  ``open(path, "w")``), if applicable.  It will be
2790
            written and not closed.
2791

2792
            The name used for the file is arbitrary but it is tradition to use a
2793
            .nt suffix.  If you also wish to further distinguish the file type
2794
            by giving the schema, it is recommended that you use two suffixes,
2795
            with the suffix that specifies the schema given first and .nt given
2796
            last. For example: flicker.sig.nt.
2797
        kwargs:
2798
            See :func:`dumps` for optional arguments.
2799

2800
    Returns:
2801
        The *NestedText* content with a trailing newline.  This differs from
2802
        :func:`dumps`, which does not add the trailing newline.
2803

2804
    Raises:
2805
        NestedTextError: if there is a problem in the input data.
2806
        OSError: if there is a problem opening the file.
2807

2808
    Examples:
2809

2810
        This example writes to a pointer to an open file.
2811

2812
        .. code-block:: python
2813

2814
            >>> import nestedtext as nt
2815
            >>> from inform import fatal, os_error
2816

2817
            >>> data = {
2818
            ...     "name": "Kristel Templeton",
2819
            ...     "gender": "female",
2820
            ...     "age": "74",
2821
            ... }
2822

2823
            >>> try:
2824
            ...     with open("data.nt", "w", encoding="utf-8") as f:
2825
            ...         nt.dump(data, f)
2826
            ... except nt.NestedTextError as e:
2827
            ...     e.terminate()
2828
            ... except OSError as e:
2829
            ...     fatal(os_error(e))
2830

2831
        This example writes to a file specified by file name.  In general, the
2832
        file name and extension are arbitrary. However, by convention a
2833
        ‘.nt’ suffix is generally used for *NestedText* files.
2834

2835
        .. code-block:: python
2836

2837
            >>> try:
2838
            ...     nt.dump(data, "data.nt")
2839
            ... except nt.NestedTextError as e:
2840
            ...     e.terminate()
2841
            ... except OSError as e:
2842
            ...     fatal(os_error(e))
2843

2844
    """
2845

2846
    # code {{{3
2847
    content = dumps(obj, **kwargs)
2✔
2848

2849
    try:
2✔
2850
        dest.write(content + "\n")
2✔
2851
    except (AttributeError, TypeError) as e:
2✔
2852
        # Avoid nested try-except blocks, since they lead to chained exceptions
2853
        # (e.g. if the file isn’t found, etc.) that unnecessarily complicate the
2854
        # stack trace.
2855
        exception = e
2✔
2856
    else:
2857
        return
2✔
2858

2859
    if isinstance(exception, TypeError):
2✔
2860
        # file may be binary, encode in utf8 and try again
2861
        dest.write((content + "\n").encode('utf8'))
2✔
2862
    else:
2863
        # dest is a file name rather than a file pointer
2864
        with open(dest, "w", encoding="utf-8") as f:
2✔
2865
            f.write(content + "\n")
2✔
2866

2867

2868
# NestedText Utilities {{{1
2869
# Extras that are useful when using NestedText.
2870

2871
# get_keys {{{2
2872
def get_keys(keys, keymap, *, original=True, strict=True, sep=None):
2✔
2873
    # description {{{3
2874
    '''
2875
    Returns a key sequence given a normalized key sequence.
2876

2877
    Keys in the dataset output by the load functions are referred to as
2878
    normalized keys, even though no key normalization may have occurred.  This
2879
    distinguishes them from the original keys, which are the keys given in the
2880
    NestedText document read by the load functions.  The original keys are
2881
    mapped to normalized keys by the *normalize_key* argument to the load
2882
    function.  If normalization is not performed, the normalized keys are
2883
    the same as the original keys.
2884

2885
    By default this function returns the original key sequence that corresponds
2886
    to *keys*, a normalized key sequence.
2887

2888
    Args:
2889
        keys:
2890
            The sequence of normalized keys that identify a value in the
2891
            dataset.
2892
        keymap:
2893
            The keymap returned from :meth:`load` or :meth:`loads`.
2894
        original:
2895
            If true, return keys as originally given in the NestedText document
2896
            (pre-normalization). Otherwise return keys as they exist in the
2897
            dataset (post-normalization).  The value of this argument has no
2898
            effect if the keys were not normalized.
2899
        strict:
2900
            *strict* controls what happens if the given keys are not found in
2901
            *keymap*.
2902

2903
            The various options can be helpful when reporting errors on key
2904
            sequences that do not exist in the data set.  Since they are not in
2905
            the dataset, the original keys are not available.
2906

2907
            True or "error":
2908
                A *KeyError* is raised.
2909
            False or "all":
2910
                All keys given in *keys* are returned.
2911
            "found":
2912
                Only the initial available keys are returned.
2913
            "missing":
2914
                Only the missing final keys are returned.
2915

2916
            When returning keys, the initial available keys are converted to
2917
            their original form if *original* is true,  The missing keys are
2918
            always returned as given.
2919

2920
        sep:
2921
            A join string.  If given the keys are interleaved with *sep* and
2922
            joined into a string before being returned.
2923

2924
    Returns:
2925
        A tuple containing the desired keys if *sep* is not given.
2926
        A string formed by joining the keys with *sep* if *sep* is given.
2927

2928
    Examples:
2929

2930
        .. code-block:: python
2931

2932
            >>> import nestedtext as nt
2933

2934
            >>> contents = """
2935
            ... Names:
2936
            ...     Given: Fumiko
2937
            ... """
2938

2939
            >>> def normalize_key(key, keys):
2940
            ...     return key.lower()
2941

2942
            >>> data = nt.loads(contents, "dict", normalize_key=normalize_key, keymap=(keymap:={}))
2943

2944
            >>> print(get_keys(("names", "given"), keymap))
2945
            ('Names', 'Given')
2946

2947
            >>> print(get_keys(("names", "given"), keymap, sep="❭"))
2948
            Names❭Given
2949

2950
            >>> print(get_keys(("names", "given"), keymap, original=False))
2951
            ('names', 'given')
2952

2953
            >>> keys = get_keys(("names", "surname"), keymap, strict=True)
2954
            Traceback (most recent call last):
2955
            ...
2956
            KeyError: ('names', 'surname')
2957

2958
            >>> print(get_keys(("names", "surname"), keymap, strict="found"))
2959
            ('Names',)
2960

2961
            >>> print(get_keys(("names", "surname"), keymap, strict="missing"))
2962
            ('surname',)
2963

2964
            >>> print(get_keys(("names", "surname"), keymap, strict="all"))
2965
            ('Names', 'surname')
2966

2967
    '''
2968

2969
    # code {{{3
2970
    assert strict in [True, False, "missing", "error", "all", "found"], strict
2✔
2971
    if type(keys) is not tuple:
2✔
2972
        keys = tuple(keys)
2✔
2973

2974
    to_return = ()
2✔
2975
    for i in range(len(keys)):
2✔
2976
        try:
2✔
2977
            loc = keymap[tuple(keys[:i+1])]
2✔
2978
            key = loc._get_original_key(keys[i], strict) if original else keys[i]
2✔
2979
            if strict != "missing":
2✔
2980
                to_return += key,
2✔
2981
        except (KeyError, IndexError):
2✔
2982
            if strict in [True, "error"]:
2✔
2983
                raise
2✔
2984
            if strict != "found":
2✔
2985
                to_return += keys[i],
2✔
2986
    if sep:
2✔
2987
        return sep.join(str(k) for k in to_return)
2✔
2988
    return to_return
2✔
2989

2990

2991
# get_value{{{2
2992
def get_value(data, keys):
2✔
2993
    # description {{{3
2994
    '''
2995
    Get value from keys.
2996

2997
    Args:
2998
        data:
2999
            Your data set as returned by :meth:`load` or :meth:`loads`.
3000
        keys:
3001
            The sequence of normalized keys that identify a value in the
3002
            dataset.
3003

3004
    Returns:
3005
        The value that corresponds to a tuple of keys from a keymap.
3006

3007
    Examples:
3008

3009
        .. code-block:: python
3010

3011
            >>> import nestedtext as nt
3012

3013
            >>> contents = """
3014
            ... names:
3015
            ...     given: Fumiko
3016
            ...     surname: Purvis
3017
            ... """
3018

3019
            >>> data = nt.loads(contents, "dict")
3020

3021
            >>> nt.get_value(data, ("names", "given"))
3022
            'Fumiko'
3023

3024
    '''
3025

3026
    # code {{{3
3027
    for key in keys:
2✔
3028
        try:
2✔
3029
            data = data[key]
2✔
3030
        except TypeError:
2✔
3031
            raise KeyError(key)
2✔
3032
    return data
2✔
3033

3034

3035
# get_line_numbers {{{2
3036
def get_line_numbers(keys, keymap, kind="value", *, strict=True, sep=None):
2✔
3037
    # description {{{3
3038
    '''
3039
    Get line numbers from normalized key sequence.
3040

3041
    This function returns the line numbers of the key or value selected by
3042
    *keys*.  It is used when reporting an error in a value that is possibly a
3043
    multiline string.  If the location contained in a keymap were used the user
3044
    would only see the line number of the first line, which may confuse some
3045
    users into believing the error is actually contained in the first line.
3046
    Using this function gives both the starting and ending line number so the
3047
    user focuses on the whole string and not just the first line.  This only
3048
    happens for multiline keys and multiline strings.
3049

3050
    If *sep* is given, either one line number or both the beginning and ending line
3051
    numbers are returned, joined with the separator. In this case the line numbers
3052
    start from line 1.
3053

3054
    If *sep* is not given, the line numbers are returned as a tuple containing a pair
3055
    of integers that is tailored to be suitable to be arguments to the Python slice
3056
    function (see example). The beginning line number and 1 plus the ending line
3057
    number is returned as a tuple. In this case the line numbers start at 0.
3058

3059
    If *keys* corresponds to a composite value (a dictionary or list), the
3060
    line on which it ends cannot be easily determined, so the value is treated
3061
    as if it consists of a single line.  This is considered tolerable as it is
3062
    expected that this function is primarily used to return the line number of
3063
    leaf values, which are always strings.
3064

3065
    Args:
3066
        keys:
3067
            The sequence of normalized keys that identify a value in the
3068
            dataset.
3069
        keymap:
3070
            The keymap returned from :meth:`load` or :meth:`loads`.
3071
        kind:
3072
            Specify either “key” or “value” depending on which token is
3073
            desired.
3074
        strict:
3075
            If *strict* is true, a *KeyError* is raised if *keys* is not found.
3076
            Otherwise the line number that corresponds to composite value that
3077
            would contain *keys* if it existed.  This composite value
3078
            corresponds to the largest sequence of keys that does actually exist
3079
            in the dataset.
3080
        sep:
3081
            The separator string. If given a string is returned and *sep* is
3082
            inserted between two line numbers.  Otherwise a tuple is returned.
3083

3084
    Raises:
3085
        KeyError:
3086
            If keys are not in *keymap* and *strict* is true.
3087

3088
    Example:
3089
        >>> import nestedtext as nt
3090

3091
        >>> doc = """
3092
        ... key:
3093
        ...     > this is line 1
3094
        ...     > this is line 2
3095
        ...     > this is line 3
3096
        ... """
3097

3098
        >>> data = nt.loads(doc, keymap=(keymap:={}))
3099
        >>> keys = ("key",)
3100
        >>> lines = nt.get_line_numbers(keys, keymap, sep="-")
3101
        >>> text = doc.splitlines()
3102
        >>> print(
3103
        ...     f"Lines {lines}:",
3104
        ...     *text[slice(*nt.get_line_numbers(keys, keymap))],
3105
        ...     sep="\\n"
3106
        ... )
3107
        Lines 3-5:
3108
            > this is line 1
3109
            > this is line 2
3110
            > this is line 3
3111

3112
    '''
3113

3114
    # code {{{3
3115
    loc = get_location(keys, keymap)
2✔
3116
    if not loc:
2✔
3117
        if strict:
2✔
3118
            raise KeyError(keys)
2✔
3119
        else:
3120
            found = get_keys(keys, keymap, original=False, strict="found")
2✔
3121
            loc = keymap[found]
2✔
3122
    return loc.get_line_numbers(kind, sep)
2✔
3123

3124

3125
# get_location {{{2
3126
def get_location(keys, keymap):
2✔
3127
    # description {{{3
3128
    '''
3129
    Returns :class:`Location` information from the keys.
3130
    None is returned if location is unknown.
3131

3132
    Args:
3133
        keys:
3134
            The sequence of normalized keys that identify a value in the
3135
            dataset.
3136
        keymap:
3137
            The keymap returned from :meth:`load` or :meth:`loads`.
3138
    '''
3139

3140
    # code {{{3
3141
    if type(keys) is not tuple:
2✔
3142
        keys = tuple(keys)
2✔
3143

3144
    try:
2✔
3145
        return keymap[keys]
2✔
3146
    except KeyError:
2✔
3147
        return None
2✔
3148

3149

3150
# annotate {{{2
3151
def annotate(
2✔
3152
    keymap,
3153
    keys,
3154
    *,
3155
    key_leading=(),
3156
    key_trailing=(),
3157
    value_leading=(),
3158
    value_trailing=(),
3159
    header=(),
3160
    footer=(),
3161
    sections=(),
3162
    spacing=None,
3163
):
3164
    '''Create or update ``keymap[tuple(keys)]`` with comments, section
3165
    markers, and per-Location spacing in a single call.
3166

3167
    This is the from-scratch counterpart to the keymap that :func:`load`
3168
    builds.  Each slot kwarg is an iterable of :class:`Comment` objects.
3169
    Within *annotate*, each Comment is interpreted in *tab mode*: its
3170
    ``tab`` field (defaulting to 0 when ``None``) is the tabstop offset
3171
    from the slot's natural indent.  The Comment is stored verbatim; the
3172
    dumper resolves ``tab`` to an absolute column at emit time using the
3173
    ``dumps(indent=...)`` setting -- so the same Comment renders
3174
    correctly at any chosen indent step.
3175

3176
    The natural indent for each slot, given ``N = len(keys)`` and
3177
    ``S = dumps.indent``:
3178

3179
    +-------------------+---------------------+
3180
    | slot              | natural indent      |
3181
    +===================+=====================+
3182
    | ``key_leading``   | ``(N - 1) * S``     |
3183
    +-------------------+---------------------+
3184
    | ``key_trailing``, |                     |
3185
    | ``value_leading``,| ``N * S``           |
3186
    | ``value_trailing``|                     |
3187
    +-------------------+---------------------+
3188
    | ``header``,       | ``0``               |
3189
    | ``footer``        |                     |
3190
    +-------------------+---------------------+
3191

3192
    ``sections`` is an iterable of ``(predicate, [Comment, ...])`` pairs
3193
    declared on a parent Location.  When the dumper renders the parent's
3194
    value, it walks its children in iteration order; for each child it
3195
    finds the first predicate that matches and -- the first time a given
3196
    section is matched -- emits that section's comments before the
3197
    child.  Section predicates are callables; they are not
3198
    JSON-serializable and are dropped on
3199
    :func:`keymap_to_jsonable` round-trips.
3200

3201
    ``spacing``, if given, is applied via :meth:`Location.set_spacing`.
3202

3203
    Args:
3204
        keymap:
3205
            The keymap dict to mutate.
3206
        keys:
3207
            The keys tuple identifying the Location.  Use ``()`` for the
3208
            document-root Location (which accepts ``header`` and
3209
            ``footer`` only).
3210
        key_leading, key_trailing, value_leading, value_trailing:
3211
            Iterables of :class:`Comment` objects for the matching slot.
3212
            Only allowed for non-root keys.
3213
        header, footer:
3214
            Iterables of :class:`Comment` objects for the document
3215
            header and footer.  Only allowed when ``keys == ()``.
3216
        sections:
3217
            Iterable of ``(predicate, [Comment, ...])`` pairs.
3218
        spacing:
3219
            Per-Location spacing dict; see :meth:`Location.set_spacing`.
3220

3221
    Returns:
3222
        The :class:`Location` that was created or updated.
3223

3224
    Raises:
3225
        ValueError: if ``header`` or ``footer`` is supplied for non-root
3226
            keys, or if any of the key/value slots is supplied for the
3227
            root.
3228
    '''
3229
    if not isinstance(keys, tuple):
2!
NEW
3230
        keys = tuple(keys)
×
3231

3232
    is_root = (keys == ())
2✔
3233
    if is_root and (
2!
3234
        key_leading or key_trailing or value_leading or value_trailing
3235
    ):
NEW
3236
        raise ValueError(
×
3237
            "key_leading/key_trailing/value_leading/value_trailing are not "
3238
            "allowed at the document root (keys=()); use header/footer instead."
3239
        )
3240
    if not is_root and (header or footer):
2!
NEW
3241
        raise ValueError(
×
3242
            "header/footer are only allowed at the document root (keys=())."
3243
        )
3244

3245
    loc = keymap.get(keys)
2✔
3246
    if loc is None:
2!
3247
        loc = Location()
2✔
3248
        keymap[keys] = loc
2✔
3249

3250
    def _tab_mode(comments):
2✔
3251
        out = []
2✔
3252
        for c in comments:
2✔
3253
            if c.tab is None:
2!
3254
                c.tab = 0
2✔
3255
            out.append(c)
2✔
3256
        return out
2✔
3257

3258
    if key_leading:
2✔
3259
        loc.set_key_leading_comments(_tab_mode(key_leading))
2✔
3260
    if key_trailing:
2!
NEW
3261
        loc.set_key_trailing_comments(_tab_mode(key_trailing))
×
3262
    if value_leading:
2!
NEW
3263
        loc.set_value_leading_comments(_tab_mode(value_leading))
×
3264
    if value_trailing:
2!
NEW
3265
        loc.set_value_trailing_comments(_tab_mode(value_trailing))
×
3266
    if header:
2✔
3267
        loc.set_header_comments(_tab_mode(header))
2✔
3268
    if footer:
2!
NEW
3269
        loc.set_footer_comments(_tab_mode(footer))
×
3270

3271
    if sections:
2✔
3272
        normalized = []
2✔
3273
        for pred, comments in sections:
2✔
3274
            normalized.append((pred, _tab_mode(comments)))
2✔
3275
        loc.set_sections(normalized)
2✔
3276

3277
    if spacing is not None:
2!
NEW
3278
        loc.set_spacing(spacing)
×
3279

3280
    return loc
2✔
3281

3282

3283
# keymap_to/from_json {{{2
3284
class _RestoredLocation(Location):
2✔
3285
    """A Location reconstructed from JSON.
3286

3287
    Carries only what :func:`dumps` reads from a keymap: the original key
3288
    string (for ``_get_original_key``) and the comment slots.
3289
    """
3290
    def __init__(self, original_key=None):
2✔
3291
        super().__init__()
2✔
3292
        self._original_key = original_key
2✔
3293

3294
    def _get_original_key(self, key, strict):
2✔
3295
        if self._original_key is not None:
2!
3296
            return self._original_key
2✔
NEW
3297
        return key
×
3298

3299

3300
def _comment_to_dict(c):
2✔
3301
    d = {"text": c.text, "indent": c.indent}
2✔
3302
    if c.tab is not None:
2!
NEW
3303
        d["tab"] = c.tab
×
3304
    if c.before:
2!
NEW
3305
        d["before"] = c.before
×
3306
    if c.after:
2!
NEW
3307
        d["after"] = c.after
×
3308
    return d
2✔
3309

3310

3311
def _comment_from_dict(d):
2✔
3312
    return Comment(
2✔
3313
        text=d["text"],
3314
        indent=d["indent"],
3315
        tab=d.get("tab"),
3316
        before=d.get("before", 0),
3317
        after=d.get("after", 0),
3318
    )
3319

3320

3321
def keymap_to_jsonable(keymap, **kwargs):
2✔
3322
    '''Reduce a keymap to a JSON-serializable structure for use with :func:`dumps`.
3323

3324
    Captures only what :func:`dumps` needs from the keymap to reconstruct
3325
    the original file: the original key strings (so ``map_keys`` can
3326
    restore them) and the per-entry comment slots, plus the document
3327
    header / footer on ``keymap[()]``.  Source line/column information is
3328
    discarded.  Per-parent section markers
3329
    (:meth:`Location.set_sections`) are also dropped because their
3330
    predicates are callables and not JSON-serializable; rebuilt keymaps
3331
    therefore omit section behavior.
3332

3333
    The returned object is built from ``dict``, ``list``, ``str``, ``int``,
3334
    and ``None`` — safe to pass through :mod:`json`, :mod:`msgpack`, or any
3335
    similar encoder.
3336

3337
    Args:
3338
        keymap:
3339
            The keymap returned from :func:`load` or :func:`loads`, or any
3340
            equivalent mapping from key-tuples to :class:`Location`
3341
            objects.
3342
        **kwargs:
3343
            Any extra keyword arguments are included in the returned structure
3344
            under a top-level "meta" key.  These values are not used by
3345
            :func:`keymap_from_jsonable` but are included to allow you to
3346
            include any extra metadata you wish in the JSON-serializable
3347
            structure.  No attempt is made to ensure that the values in
3348
            **kwargs** are themselves JSON-serializable, so you should ensure
3349
            that they are if you intend to pass the output through a JSON
3350
            encoder.
3351

3352
    Returns:
3353
        A JSON-serializable ``dict``.  Pass it to :func:`keymap_from_jsonable`
3354
        to rebuild a keymap that can be given to :func:`dumps` as
3355
        ``map_keys=``.
3356
    '''
3357
    entries = []
2✔
3358
    for keys, loc in keymap.items():
2✔
3359
        entry = {"keys": list(keys)}
2✔
3360
        if keys and isinstance(keys[-1], str):
2✔
3361
            entry["original_key"] = loc._get_original_key(keys[-1], strict=False)
2✔
3362
        for attr, label in (
2✔
3363
            ("key_leading_comments",   "key_leading"),
3364
            ("key_trailing_comments",  "key_trailing"),
3365
            ("value_leading_comments", "value_leading"),
3366
            ("value_trailing_comments","value_trailing"),
3367
        ):
3368
            comments = getattr(loc, attr, None)
2✔
3369
            if comments:
2✔
3370
                entry[label] = [_comment_to_dict(c) for c in comments]
2✔
3371
        if not keys:
2✔
3372
            if loc.header_comments:
2!
3373
                entry["header"] = [_comment_to_dict(c) for c in loc.header_comments]
2✔
3374
            if loc.footer_comments:
2!
NEW
3375
                entry["footer"] = [_comment_to_dict(c) for c in loc.footer_comments]
×
3376
        sp = getattr(loc, "spacing", None)
2✔
3377
        if sp:
2!
3378
            # JSON object keys must be strings; integer depth keys are
3379
            # stringified here and parsed back in keymap_from_jsonable.
NEW
3380
            entry["spacing"] = {str(k): v for k, v in sp.items()}
×
3381
        entries.append(entry)
2✔
3382
    return cull(dict(keymap=entries, meta=kwargs))
2✔
3383

3384

3385
def keymap_from_jsonable(data):
2✔
3386
    '''Rebuild a keymap from the output of :func:`keymap_to_jsonable`.
3387

3388
    The returned mapping is suitable for passing to :func:`dumps` (or
3389
    :func:`dump`) as ``map_keys=``; it will restore the original key
3390
    strings and inject the captured comments.  Locations in the rebuilt
3391
    keymap do *not* carry source line/column information.
3392

3393
    Args:
3394
        data:
3395
            The JSON-serializable structure produced by
3396
            :func:`keymap_to_jsonable` (or an equivalent reconstruction of
3397
            it, e.g., from ``json.loads``).
3398
    '''
3399
    keymap = {}
2✔
3400
    for entry in data["keymap"]:
2✔
3401
        keys = tuple(entry["keys"])
2✔
3402
        loc = _RestoredLocation(original_key=entry.get("original_key"))
2✔
3403
        loc.key_leading_comments = [
2✔
3404
            _comment_from_dict(d) for d in entry.get("key_leading", [])
3405
        ]
3406
        loc.key_trailing_comments = [
2✔
3407
            _comment_from_dict(d) for d in entry.get("key_trailing", [])
3408
        ]
3409
        loc.value_leading_comments = [
2✔
3410
            _comment_from_dict(d) for d in entry.get("value_leading", [])
3411
        ]
3412
        loc.value_trailing_comments = [
2✔
3413
            _comment_from_dict(d) for d in entry.get("value_trailing", [])
3414
        ]
3415
        if not keys:
2✔
3416
            loc.header_comments = [
2✔
3417
                _comment_from_dict(d) for d in entry.get("header", [])
3418
            ]
3419
            loc.footer_comments = [
2✔
3420
                _comment_from_dict(d) for d in entry.get("footer", [])
3421
            ]
3422
        raw_spacing = entry.get("spacing")
2✔
3423
        if raw_spacing:
2!
3424
            # Convert numeric string keys back to int (depth keys); leave
3425
            # non-numeric keys -- e.g. "edges" -- as strings.
NEW
3426
            loc.spacing = {
×
3427
                (int(k) if k.lstrip("-").isdigit() else k): v
3428
                for k, v in raw_spacing.items()
3429
            }
3430
        keymap[keys] = loc
2✔
3431
    return keymap
2✔
3432

3433
# vim: set sw=4 sts=4 tw=80 fo=croqj foldmethod=marker et spell:
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc