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

andreoliwa / nitpick / 16541860928

26 Jul 2025 04:45PM UTC coverage: 96.745% (-0.001%) from 96.746%
16541860928

push

github

web-flow
fix(deps): update dependency requests to v2.32.4 [security]

788 of 827 branches covered (95.28%)

Branch coverage included in aggregate %.

2095 of 2153 relevant lines covered (97.31%)

4.86 hits per line

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

96.76
/src/nitpick/blender.py
1
"""Dictionary blender and configuration file formats.
2

3
.. testsetup::
4

5
    from nitpick.generic import *
6
"""
7

8
from __future__ import annotations
5✔
9

10
import abc
5✔
11
import json
5✔
12
import re
5✔
13
import shlex
5✔
14
from functools import partial
5✔
15
from pathlib import Path
5✔
16
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
5✔
17

18
import dictdiffer
5✔
19
import jmespath
5✔
20
import toml
5✔
21
import tomlkit
5✔
22
from attr import define  # type: ignore[attr-defined]
5✔
23
from autorepr import autorepr
5✔
24
from flatten_dict import flatten, unflatten
5✔
25
from ruamel.yaml import YAML, RoundTripRepresenter, StringIO
5✔
26
from sortedcontainers import SortedDict
5✔
27
from tomlkit import items
5✔
28

29
if TYPE_CHECKING:
30
    from jmespath.parser import ParsedResult
31

32
    from nitpick.config import SpecialConfig
33
    from nitpick.typedefs import ElementData, JsonDict, ListOrCommentedSeq, PathOrStr, YamlObject, YamlValue
34

35
# Generic type for classes that inherit from BaseDoc
36
TBaseDoc = TypeVar("TBaseDoc", bound="BaseDoc")
5✔
37

38
SINGLE_QUOTE = "'"
5✔
39
DOUBLE_QUOTE = '"'
5✔
40

41
SEPARATOR_DOT = "."
5✔
42
SEPARATOR_COMMA = ","
5✔
43
SEPARATOR_COLON = ":"
5✔
44
SEPARATOR_SPACE = " "
5✔
45

46
#: Special unique separator for :py:meth:`flatten()` and :py:meth:`unflatten()`,
47
# to avoid collision with existing key values (e.g. the default SEPARATOR_DOT separator "." can be part of a TOML key).
48
SEPARATOR_FLATTEN = "$#@"
5✔
49

50
#: Special unique separator for :py:meth:`nitpick.blender.quoted_split()`.
51
SEPARATOR_QUOTED_SPLIT = "#$@"
5✔
52

53

54
def compare_lists_with_dictdiffer(
5✔
55
    actual: list | dict, expected: list | dict, *, return_list: bool = True
56
) -> list | dict:
57
    """Compare two lists using dictdiffer."""
58
    additions_and_changes = [change for change in dictdiffer.diff(actual, expected) if change[0] != "remove"]
5✔
59
    if not additions_and_changes:
5✔
60
        return []
5✔
61

62
    try:
5✔
63
        changed_dict = dictdiffer.patch(additions_and_changes, {})
5✔
64
    except KeyError:
5✔
65
        return expected
5✔
66

67
    if return_list:
5!
68
        return list(changed_dict.values())
×
69
    return changed_dict
5✔
70

71

72
def search_json(json_data: ElementData, jmespath_expression: ParsedResult | str, default: Any | None = None) -> Any:
5✔
73
    """Search a dictionary or list using a JMESPath expression.
74

75
    Return a default value if not found.
76

77
    >>> data = {"root": {"app": [1, 2], "test": "something"}}
78
    >>> search_json(data, "root.app", None)
79
    [1, 2]
80
    >>> search_json(data, "root.test", None)
81
    'something'
82
    >>> search_json(data, "root.unknown", "")
83
    ''
84
    >>> search_json(data, "root.unknown", None)
85

86
    >>> search_json(data, "root.unknown")
87

88
    >>> search_json(data, jmespath.compile("root.app"), [])
89
    [1, 2]
90
    >>> search_json(data, jmespath.compile("root.whatever"), "xxx")
91
    'xxx'
92
    >>> search_json(data, "")
93

94
    >>> search_json(data, None)
95

96
    :param jmespath_expression: A compiled JMESPath expression or a string with an expression.
97
    :param json_data: The dictionary to be searched.
98
    :param default: Default value in case nothing is found.
99
    :return: The object that was found or the default value.
100
    """
101
    if not jmespath_expression:
5✔
102
        return default
5✔
103
    if isinstance(jmespath_expression, str):
5✔
104
        rv = jmespath.search(jmespath_expression, json_data)
5✔
105
    else:
106
        rv = jmespath_expression.search(json_data)
5✔
107
    return rv or default
5✔
108

109

110
@define
5✔
111
class ElementDetail:  # pylint: disable=too-few-public-methods
5✔
112
    """Detailed information about an element of a list."""
113

114
    data: ElementData
5✔
115
    key: str | list[str]
5✔
116
    index: int
5✔
117
    scalar: bool
5✔
118
    compact: str
5✔
119

120
    @property
5✔
121
    def cast_to_dict(self) -> JsonDict:
5✔
122
        """Data cast to dict, for mypy."""
123
        return cast("JsonDict", self.data)
5✔
124

125
    @classmethod
5✔
126
    def from_data(cls, index: int, data: ElementData, jmes_key: str) -> ElementDetail:
5✔
127
        """Create an element detail from dict data."""
128
        if isinstance(data, (list, dict)):
5✔
129
            scalar = False
5✔
130
            compact = json.dumps(data, sort_keys=True, separators=(SEPARATOR_COMMA, SEPARATOR_COLON))
5✔
131
            key = search_json(data, jmes_key)
5✔
132
            if not key:
5✔
133
                key = compact
5✔
134
        else:
135
            scalar = True
5✔
136
            key = compact = str(data)
5✔
137
        return ElementDetail(data=data, key=key, index=index, scalar=scalar, compact=compact)
5✔
138

139

140
@define
5✔
141
class ListDetail:  # pylint: disable=too-few-public-methods
5✔
142
    """Detailed info about a list."""
143

144
    data: ListOrCommentedSeq
5✔
145
    elements: list[ElementDetail]
5✔
146

147
    @classmethod
5✔
148
    def from_data(cls, data: ListOrCommentedSeq, jmes_key: str) -> ListDetail:
5✔
149
        """Create a list detail from list data."""
150
        return ListDetail(
5✔
151
            data=data, elements=[ElementDetail.from_data(index, data, jmes_key) for index, data in enumerate(data)]
152
        )
153

154
    def find_by_key(self, desired: ElementDetail) -> ElementDetail | None:
5✔
155
        """Find an element by key."""
156
        for actual in self.elements:
5✔
157
            if isinstance(desired.key, list):
5✔
158
                if set(desired.key).issubset(set(actual.key)):
5✔
159
                    return actual
5✔
160
            elif desired.key == actual.key:
5✔
161
                return actual
5✔
162
        return None
5✔
163

164

165
def set_key_if_not_empty(dict_: JsonDict, key: str, value: Any) -> None:
5✔
166
    """Update the dict if the value is valid."""
167
    if not value:
5!
168
        return
×
169
    dict_[key] = value
5✔
170

171

172
def quoted_split(string_: str, separator=SEPARATOR_DOT) -> list[str]:
5✔
173
    """Split a string by a separator, but considering quoted parts (single or double quotes).
174

175
    >>> quoted_split("my.key.without.quotes")
176
    ['my', 'key', 'without', 'quotes']
177
    >>> quoted_split('"double.quoted.string"')
178
    ['double.quoted.string']
179
    >>> quoted_split('"double.quoted.string".and.after')
180
    ['double.quoted.string', 'and', 'after']
181
    >>> quoted_split('something.before."double.quoted.string"')
182
    ['something', 'before', 'double.quoted.string']
183
    >>> quoted_split("'single.quoted.string'")
184
    ['single.quoted.string']
185
    >>> quoted_split("'single.quoted.string'.and.after")
186
    ['single.quoted.string', 'and', 'after']
187
    >>> quoted_split("something.before.'single.quoted.string'")
188
    ['something', 'before', 'single.quoted.string']
189
    """
190
    if DOUBLE_QUOTE not in string_ and SINGLE_QUOTE not in string_:
5✔
191
        return string_.split(separator)
5✔
192

193
    quoted_regex = re.compile(
5✔
194
        f"([{SINGLE_QUOTE}{DOUBLE_QUOTE}][^{SINGLE_QUOTE}{DOUBLE_QUOTE}]+[{SINGLE_QUOTE}{DOUBLE_QUOTE}])"
195
    )
196

197
    def remove_quotes(match):
5✔
198
        return match.group(0).strip(f"{SINGLE_QUOTE}{DOUBLE_QUOTE}").replace(separator, SEPARATOR_QUOTED_SPLIT)
5✔
199

200
    return [
5✔
201
        part.replace(SEPARATOR_QUOTED_SPLIT, separator)
202
        for part in quoted_regex.sub(remove_quotes, string_).split(separator)
203
    ]
204

205

206
def quote_if_dotted(key: str) -> str:
5✔
207
    """Quote the key if it has a dot."""
208
    if not isinstance(key, str):
5✔
209
        return key
5✔
210
    if SEPARATOR_DOT in key and DOUBLE_QUOTE not in key:
5✔
211
        return f"{DOUBLE_QUOTE}{key}{DOUBLE_QUOTE}"
5✔
212
    return key
5✔
213

214

215
def quote_reducer(separator: str) -> Callable:
5✔
216
    """Reducer used to unflatten dicts.
217

218
    Quote keys when they have dots.
219
    """
220

221
    def _inner_quote_reducer(key1: str | None, key2: str) -> str:
5✔
222
        if key1 is None:
5✔
223
            return quote_if_dotted(key2)
5✔
224
        return f"{key1}{separator}{quote_if_dotted(key2)}"
5✔
225

226
    return _inner_quote_reducer
5✔
227

228

229
def quotes_splitter(flat_key: str) -> tuple[str, ...]:
5✔
230
    """Split keys keeping quoted strings together."""
231
    return tuple(
5✔
232
        piece.replace(SEPARATOR_SPACE, SEPARATOR_DOT) if SEPARATOR_SPACE in piece else piece
233
        for piece in shlex.split(flat_key.replace(SEPARATOR_DOT, SEPARATOR_SPACE))
234
    )
235

236

237
def custom_reducer(separator: str) -> Callable:
5✔
238
    """Custom reducer for :py:meth:`flatten_dict.flatten_dict.flatten()` accepting a separator."""
239

240
    def _inner_custom_reducer(key1, key2):
5✔
241
        if key1 is None:
5✔
242
            return key2
5✔
243
        return f"{key1}{separator}{key2}"
5✔
244

245
    return _inner_custom_reducer
5✔
246

247

248
def custom_splitter(separator: str) -> Callable:
5✔
249
    """Custom splitter for :py:meth:`flatten_dict.flatten_dict.unflatten()` accepting a separator."""
250

251
    def _inner_custom_splitter(flat_key) -> tuple[str, ...]:
5✔
252
        """Return a tuple of keys split by the separator."""
253
        return tuple(flat_key.split(separator))
5✔
254

255
    return _inner_custom_splitter
5✔
256

257

258
# TODO: refactor: use only tomlkit and remove uiri/toml
259
#  - tomlkit preserves comments
260
#  - uiri/toml looks abandoned https://github.com/uiri/toml/issues/361
261
#  Code to be used with tomlkit when merging styles
262
# merged_dict = unflatten(self._merged_styles, toml_style_splitter)
263
# def toml_style_splitter(flat_key: str) -> Tuple[str, ...]:
264
#     """Splitter for TOML style files, in an attempt to remove empty TOML tables."""
265
#     original = flat_key.split(SEPARATOR_FLATTEN)
266
#     quoted = [quote_if_dotted(k) for k in original]
267
#
268
#     first = quoted.pop(0)
269
#     last = quoted.pop() if quoted else None
270
#
271
#     grouped = [first]
272
#     if quoted:
273
#         grouped.append(SEPARATOR_DOT.join(quoted))
274
#     if last:
275
#         grouped.append(last)
276
#     return tuple(grouped)
277

278

279
def flatten_quotes(dict_: JsonDict, separator=SEPARATOR_DOT) -> JsonDict:
5✔
280
    """Flatten a dict keeping quotes in keys."""
281
    dict_with_quoted_keys = flatten(dict_, reducer=quote_reducer(separator))
5✔
282
    clean_dict = {}
5✔
283
    for key, value in dict_with_quoted_keys.items():  # type: str, Any
5✔
284
        key_with_stripped_ends = key.strip(DOUBLE_QUOTE)
5✔
285
        if key_with_stripped_ends.count(DOUBLE_QUOTE):
5✔
286
            # Key has quotes in the middle; keep all quotes
287
            clean_dict[key] = value
5✔
288
        else:
289
            # Key only has quotes in the beginning and end; remove quotes
290
            clean_dict[key_with_stripped_ends] = value
5✔
291
    return clean_dict
5✔
292

293

294
unflatten_quotes = partial(unflatten, splitter=quotes_splitter)
5✔
295

296

297
class Comparison:
5✔
298
    """A comparison between two dictionaries, computing missing items and differences."""
299

300
    def __init__(self, actual: TBaseDoc, expected: JsonDict, special_config: SpecialConfig) -> None:
5✔
301
        self.flat_actual = flatten_quotes(actual.as_object)
5✔
302
        self.flat_expected = flatten_quotes(expected)
5✔
303

304
        self.doc_class = actual.__class__
5✔
305

306
        self.missing_dict: JsonDict = {}
5✔
307
        self.diff_dict: JsonDict = {}
5✔
308
        self.replace_dict: JsonDict = {}
5✔
309

310
        self.special_config = special_config
5✔
311

312
    @property
5✔
313
    def missing(self) -> TBaseDoc | None:
5✔
314
        """Missing data."""
315
        if not self.missing_dict:
5✔
316
            return None
5✔
317
        return self.doc_class(obj=(unflatten_quotes(self.missing_dict)))  # type: ignore[return-value]
5✔
318

319
    @property
5✔
320
    def diff(self) -> TBaseDoc | None:
5✔
321
        """Different data."""
322
        if not self.diff_dict:
5✔
323
            return None
5✔
324
        return self.doc_class(obj=(unflatten_quotes(self.diff_dict)))  # type: ignore[return-value]
5✔
325

326
    @property
5✔
327
    def replace(self) -> TBaseDoc | None:
5✔
328
        """Data to be replaced."""
329
        if not self.replace_dict:
5✔
330
            return None
5✔
331
        return self.doc_class(obj=unflatten_quotes(self.replace_dict))  # type: ignore[return-value]
5✔
332

333
    @property
5✔
334
    def has_changes(self) -> bool:
5✔
335
        """Return True is there is a difference or something missing."""
336
        return bool(self.missing or self.diff or self.replace)
5✔
337

338
    def __call__(self) -> Comparison:
5✔
339
        """Compare two flattened dictionaries and compute missing and different items."""
340
        if self.flat_expected.items() <= self.flat_actual.items():
5✔
341
            return self
5✔
342

343
        for key, expected_value in self.flat_expected.items():
5✔
344
            if key not in self.flat_actual:
5✔
345
                self.missing_dict[key] = expected_value
5✔
346
                self.replace_dict[key] = expected_value
5✔
347
                continue
5✔
348

349
            actual = self.flat_actual[key]
5✔
350
            if isinstance(expected_value, list):
5✔
351
                list_keys = self.special_config.list_keys.value.get(key, "")
5✔
352
                if SEPARATOR_DOT in list_keys:
5✔
353
                    parent_key, child_key = list_keys.rsplit(SEPARATOR_DOT, 1)
5✔
354
                    jmes_key = f"{parent_key}[].{child_key}"
5✔
355
                else:
356
                    parent_key = ""
5✔
357
                    child_key = list_keys
5✔
358
                    jmes_key = child_key
5✔
359

360
                self._compare_list_elements(
5✔
361
                    key,
362
                    parent_key,
363
                    child_key,
364
                    ListDetail.from_data(actual, jmes_key),
365
                    ListDetail.from_data(expected_value, jmes_key),
366
                )
367
            elif expected_value != actual:
5✔
368
                set_key_if_not_empty(self.diff_dict, key, expected_value)
5✔
369

370
        return self
5✔
371

372
    def _compare_list_elements(  # pylint: disable=too-many-arguments
5✔
373
        self, key: str, parent_key: str, child_key: str, actual_detail: ListDetail, expected_detail: ListDetail
374
    ) -> None:
375
        """Compare list elements by their keys or hashes."""
376
        display = []
5✔
377
        replace = actual_detail.data.copy()
5✔
378
        for expected_element in expected_detail.elements:
5✔
379
            actual_element = actual_detail.find_by_key(expected_element)
5✔
380
            if not actual_element:
5✔
381
                display.append(expected_element.data)
5✔
382
                replace.append(expected_element.data)
5✔
383
                continue
5✔
384

385
            if parent_key:
5✔
386
                new_block: JsonDict = self._compare_children(parent_key, child_key, actual_element, expected_element)
5✔
387
                if new_block:
5✔
388
                    display.append(expected_element.data)
5✔
389
                    replace[actual_element.index] = new_block
5✔
390
                continue
5✔
391

392
            diff = compare_lists_with_dictdiffer(
5✔
393
                actual_element.cast_to_dict, expected_element.cast_to_dict, return_list=False
394
            )
395
            if diff:
5✔
396
                new_block = cast("JsonDict", actual_element.data).copy()
5✔
397
                new_block.update(diff)
5✔
398
                display.append(new_block)
5✔
399
                replace[actual_element.index] = new_block
5✔
400

401
        if display:
5✔
402
            set_key_if_not_empty(self.missing_dict, key, display)
5✔
403
            set_key_if_not_empty(self.replace_dict, key, replace)
5✔
404

405
    @staticmethod
5✔
406
    def _compare_children(
5✔
407
        parent_key: str, child_key: str, actual_element: ElementDetail, expected_element: ElementDetail
408
    ) -> JsonDict:
409
        """Compare children of a JSON dict, return only the inner difference.
410

411
        E.g.: a pre-commit hook ID with different args will return a JSON only with the specific hook,
412
        not with all the hooks of the parent repo.
413
        """
414
        new_nested_block: JsonDict = {}
5✔
415
        jmes_nested = f"{parent_key}[?{child_key}=='{expected_element.key[0]}']"
5✔
416
        actual_nested = search_json(actual_element.data, jmes_nested, [])
5✔
417
        expected_nested = search_json(expected_element.data, jmes_nested, [{}])
5✔
418
        diff_nested = compare_lists_with_dictdiffer(actual_nested, expected_nested, return_list=True)
5✔
419
        if diff_nested:
5✔
420
            actual_data = cast("JsonDict", actual_element.data)
5✔
421
            expected_data = cast("JsonDict", expected_element.data)
5✔
422
            # TODO: fix: set value deep down the tree (try dpath-python). parent_key = 'regions[].cities[].people'
423
            expected_data[parent_key] = diff_nested
5✔
424

425
            new_nested_block = actual_data.copy()
5✔
426
            for nested_index, obj in enumerate(actual_data[parent_key]):
5!
427
                if obj == actual_nested[0]:
5✔
428
                    new_nested_block[parent_key][nested_index] = diff_nested[0]
5✔
429
                    break
5✔
430
        return new_nested_block
5✔
431

432

433
class BaseDoc(metaclass=abc.ABCMeta):
5✔
434
    """Base class for configuration file formats.
435

436
    :param path: Path of the config file to be loaded.
437
    :param string: Config in string format.
438
    :param obj: Config object (Python dict, YamlDoc, TomlDoc instances).
439
    """
440

441
    __repr__ = autorepr(["path"])
5✔
442

443
    def __init__(
5✔
444
        self, *, path: PathOrStr | None = None, string: str | None = None, obj: JsonDict | None = None
445
    ) -> None:
446
        self.path = path
5✔
447
        self._string = string
5✔
448
        self._object = obj
5✔
449

450
        self._reformatted: str | None = None
5✔
451

452
    @abc.abstractmethod
5✔
453
    def load(self) -> bool:
5✔
454
        """Load the configuration from a file, a string or a dict."""
455

456
    @property
5✔
457
    def as_string(self) -> str:
5✔
458
        """Contents of the file or the original string provided when the instance was created."""
459
        return self._string or ""
5✔
460

461
    @property
5✔
462
    def as_object(self) -> dict:
5✔
463
        """String content converted to a Python object (dict, YAML object instance, etc.)."""
464
        if self._object is None:
5✔
465
            self.load()
5✔
466
        return self._object or {}
5✔
467

468
    @property
5✔
469
    def reformatted(self) -> str:
5✔
470
        """Reformat the configuration dict as a new string (it might not match the original string/file contents)."""
471
        if self._reformatted is None:
5!
472
            self.load()
5✔
473
        return self._reformatted or ""
5✔
474

475

476
class InlineTableTomlDecoder(toml.TomlDecoder):  # type: ignore[name-defined]
5✔
477
    """A hacky decoder to work around some bug (or unfinished work) in the Python TOML package.
478

479
    https://github.com/uiri/toml/issues/362.
480
    """
481

482
    def get_empty_inline_table(self):
5✔
483
        """Hackity hack for a crappy unmaintained package.
484

485
        Total lack of respect, the guy doesn't even reply: https://github.com/uiri/toml/issues/361
486
        """
487
        return self.get_empty_table()
5✔
488

489

490
class TomlDoc(BaseDoc):
5✔
491
    """TOML configuration format."""
492

493
    # TODO: refactor: use only tomlkit and remove uiri/toml
494
    #   remove __init__() completely
495
    def __init__(
5✔
496
        self,
497
        *,
498
        path: PathOrStr | None = None,
499
        string: str | None = None,
500
        obj: JsonDict | None = None,
501
        use_tomlkit=False,
502
    ) -> None:
503
        super().__init__(path=path, string=string, obj=obj)
5✔
504
        self.use_tomlkit = use_tomlkit
5✔
505

506
    def load(self) -> bool:
5✔
507
        """Load a TOML file by its path, a string or a dict."""
508
        if self.path is not None:
5✔
509
            self._string = Path(self.path).read_text(encoding="UTF-8")
5✔
510
        if self._string is not None:
5✔
511
            # TODO: refactor: use only tomlkit and remove uiri/toml
512
            #  I tried to replace toml by tomlkit, but lots of tests break.
513
            if self.use_tomlkit:
5!
514
                # TODO: refactor: use only tomlkit and remove uiri/toml
515
                #  Removing empty tables on loads() didn't work.
516
                #  The empty tables are gone, but:
517
                #  1. the output has 2 blank lines at the top
518
                #  2. the underlying dict is different than expected, and tests fail:
519
                #     'NIP001  has an incorrect style. Invalid config:',
520
                #     '"pyproject.toml".tool.black: Unknown file. See '
521
                #     'https://nitpick.rtfd.io/en/latest/plugins.html.']
522

523
                # toml_obj = tomlkit.loads(self._string)
524
                # if "tool.black" in self._string:
525
                #     from tomlkit.items import KeyType, SingleKey
526
                #
527
                #     black_dict = toml_obj["pyproject.toml"]["tool"]["black"]
528
                #     toml_obj["pyproject.toml"].remove("tool")
529
                #     toml_obj.remove("pyproject.toml")
530
                #     toml_obj.add(SingleKey('"pyproject.toml".tool.black', KeyType.Bare), black_dict)
531
                #     result = tomlkit.dumps(toml_obj)
532
                #     print(result)
533
                self._object = tomlkit.loads(self._string)
×
534
            else:
535
                self._object = toml.loads(self._string, decoder=InlineTableTomlDecoder(dict))  # type: ignore[call-arg,assignment]
5✔
536
        if self._object is not None:
5!
537
            # TODO: fix: tomlkit.dumps() renders comments and I didn't find a way to turn this off,
538
            #  but comments are being lost when the TOML plugin does dict comparisons.
539
            if self.use_tomlkit:
5!
540
                # TODO: refactor: use only tomlkit and remove uiri/toml
541
                #  Removing empty tables on dumps() didn't work.
542
                #  Another attempt would be to remove tables when dumping to TOML when setting self._reformatted:
543
                #  1. load a dict normally with loads()
544
                #  2. clean up TomlDocument and its empty tables recursively, reusing the code with SingleKey above
545
                #  3. dump the cleaned TomlDocument
546
                #  It looks like some effort. I'll wait for https://github.com/sdispater/tomlkit/issues/166
547
                # remove_empty_tables = unflatten(
548
                #     flatten(self._object, custom_reducer(SEPARATOR_FLATTEN)), toml_style_splitter
549
                # )
550
                self._reformatted = tomlkit.dumps(self._object, sort_keys=True)
×
551
            else:
552
                self._reformatted = toml.dumps(self._object)
5✔
553
        return True
5✔
554

555

556
def traverse_toml_tree(document: tomlkit.TOMLDocument, dictionary):
5✔
557
    """Traverse a TOML document recursively and change values, keeping its formatting and comments."""
558
    for key, value in dictionary.items():
5✔
559
        if isinstance(value, (dict,)):
5✔
560
            if key in document:
5✔
561
                traverse_toml_tree(document[key], value)
5✔
562
            else:
563
                document[key] = value
5✔
564
        else:
565
            document[key] = value
5✔
566

567

568
class SensibleYAML(YAML):
5✔
569
    """YAML with sensible defaults but an inefficient dump to string.
570

571
    `Output of dump() as a string <https://yaml.readthedocs.io/en/latest/example.html#output-of-dump-as-a-string>`_.
572
    """
573

574
    def __init__(self) -> None:
5✔
575
        super().__init__()
5✔
576
        self.map_indent = 2
5✔
577
        self.sequence_indent = 4
5✔
578
        self.sequence_dash_offset = 2
5✔
579
        self.preserve_quotes = True
5✔
580

581
    def loads(self, string: str):
5✔
582
        """Load YAML from a string... that unusual use case in a world of files only."""
583
        return self.load(StringIO(string))
5✔
584

585
    def dumps(self, data) -> str:
5✔
586
        """Dump to a string... who would want such a thing? One can dump to a file or stdout."""
587
        output = StringIO()
5✔
588
        self.dump(data, output, transform=None)
5✔
589
        return output.getvalue()
5✔
590

591

592
class YamlDoc(BaseDoc):
5✔
593
    """YAML configuration format."""
594

595
    updater: SensibleYAML
5✔
596

597
    def load(self) -> bool:
5✔
598
        """Load a YAML file by its path, a string or a dict."""
599
        self.updater = SensibleYAML()
5✔
600

601
        if self.path is not None:
5✔
602
            self._string = Path(self.path).read_text(encoding="UTF-8")
5✔
603
        if self._string is not None:
5✔
604
            self._object = self.updater.loads(self._string)
5✔
605
        if self._object is not None:
5✔
606
            self._reformatted = self.updater.dumps(self._object)
5✔
607
        return True
5✔
608

609

610
# Classes and their representation on ruamel.yaml
611
for dict_class in (SortedDict, items.Table, items.InlineTable):
5✔
612
    RoundTripRepresenter.add_representer(dict_class, RoundTripRepresenter.represent_dict)
5✔
613
RoundTripRepresenter.add_representer(items.String, RoundTripRepresenter.represent_str)
5✔
614
for list_class in (items.Array, items.AoT):
5✔
615
    RoundTripRepresenter.add_representer(list_class, RoundTripRepresenter.represent_list)
5✔
616
RoundTripRepresenter.add_representer(items.Integer, RoundTripRepresenter.represent_int)
5✔
617

618

619
def is_scalar(value: YamlValue) -> bool:
5✔
620
    """Return True if the value is NOT a dict or a list."""
621
    return not isinstance(value, (list, dict))
5✔
622

623

624
def replace_or_add_list_element(yaml_obj: YamlObject, element: Any, key: str, index: int) -> None:
5✔
625
    """Replace or add a new element in a YAML sequence of mappings."""
626
    current = yaml_obj
5✔
627
    if key in yaml_obj:
5!
628
        current = yaml_obj[key]
5✔
629

630
    insert: bool = index >= len(current)
5✔
631
    if insert:
5✔
632
        current.append(element)
5✔
633
        return
5✔
634

635
    if is_scalar(current[index]) or is_scalar(element):
5✔
636
        # If the original object is scalar, replace it with whatever element;
637
        # without traversing, even if it's a dict
638
        current[index] = element
5✔
639
        return
5✔
640
    if isinstance(element, dict):
5✔
641
        traverse_yaml_tree(current[index], element)
5✔
642
        return
5✔
643

644
    # At this point, value is probably a list. Set the whole list in YAML.
645
    current[index] = element
5✔
646
    return
5✔
647

648

649
def traverse_yaml_tree(yaml_obj: YamlObject, change: JsonDict):
5✔
650
    """Traverse a YAML document recursively and change values, keeping its formatting and comments."""
651
    for key, value in change.items():
5✔
652
        if key not in yaml_obj:
5✔
653
            if isinstance(yaml_obj, dict):
5!
654
                yaml_obj[key] = value
5✔
655
            else:
656
                # Key doesn't exist: we can insert the whole nested dict at once, no regrets
657
                last_pos = len(yaml_obj.keys()) + 1
×
658
                yaml_obj.insert(last_pos, key, value)
×
659
            continue
3✔
660

661
        if isinstance(value, dict):
5✔
662
            traverse_yaml_tree(yaml_obj[key], value)
5✔
663
        elif isinstance(value, list):
5✔
664
            for index, element in enumerate(value):
5✔
665
                replace_or_add_list_element(yaml_obj, element, key, index)
5✔
666
        else:
667
            yaml_obj[key] = value
5✔
668

669

670
class JsonDoc(BaseDoc):
5✔
671
    """JSON configuration format."""
672

673
    def load(self) -> bool:
5✔
674
        """Load a JSON file by its path, a string or a dict."""
675
        if self.path is not None:
5✔
676
            self._string = Path(self.path).read_text(encoding="UTF-8")
5✔
677
        if self._string is not None:
5✔
678
            self._object = flatten_quotes(json.loads(self._string))
5✔
679
        if self._object is not None:
5!
680
            # Every file should end with a blank line
681
            self._reformatted = json.dumps(self._object, sort_keys=True, indent=2) + "\n"
5✔
682
        return True
5✔
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