Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions nestedtext/nestedtext.py
Original file line number Diff line number Diff line change
Expand Up @@ -2078,6 +2078,7 @@ def __init__(
self.indent = indent
self.converters = converters
self.map_keys = map_keys
self._inline_unsafe_keys = None # cached set, built lazily per dump
self.default = default
self.spacing = spacing or {}
self.width = width
Expand Down Expand Up @@ -2195,6 +2196,70 @@ def _comments_force_multiline(self, keys):
return True
return False

# _inline_would_drop_comments {{{3
def _inline_would_drop_comments(self, keys):
"""Return True if rendering the collection at *keys* inline would
silently drop a comment.

The inline forms (``{...}`` / ``[...]``) emit no comments for the
collection's own value slots nor for any of its descendants, so
inlining is only safe when neither carries any comment. Comments
on the collection's *key* (the ``key_leading``/``key_trailing``
slots at *keys* itself) are still emitted by the parent, so they
do not force multi-line here; only value-side comments at *keys*
and any comment (or comment provider) at a strict descendant do.

The set of keys for which this holds is built once per dump in a
single pass over *map_keys* (see `_compute_inline_unsafe_keys`) and
cached, so each query is an O(1) membership test rather than a
rescan of every entry for every collection.
"""
if not is_mapping(self.map_keys):
return False
if self._inline_unsafe_keys is None:
self._inline_unsafe_keys = self._compute_inline_unsafe_keys()
return keys in self._inline_unsafe_keys

# _compute_inline_unsafe_keys {{{3
def _compute_inline_unsafe_keys(self):
"""Collect, in a single pass over *map_keys*, every *keys* for which
inlining the collection there would drop a comment.

A comment (or comment provider) at some path *p* makes inlining
unsafe at: every strict ancestor of *p* (the inline form has nowhere
to emit a descendant's comment); and at *p* itself when the comment
is value-side (key-side comments at *p* are emitted by *p*'s parent).
Providers at *p* count for both, matching `_inline_would_drop_comments`.
"""
unsafe = set()
for path, loc in self.map_keys.items():
has_provider = (
loc.get_key_leading_provider() is not None
or loc.get_key_trailing_provider() is not None
or loc.get_value_leading_provider() is not None
or loc.get_value_trailing_provider() is not None
)
# value-side comment or any provider at *path* makes inlining the
# collection at *path* itself unsafe
if (
loc.get_value_leading_comments()
or loc.get_value_trailing_comments()
or has_provider
):
unsafe.add(path)
# any comment or provider at *path* makes inlining any strict
# ancestor of *path* unsafe
if (
loc.get_key_leading_comments()
or loc.get_key_trailing_comments()
or loc.get_value_leading_comments()
or loc.get_value_trailing_comments()
or has_provider
):
for depth in range(len(path)):
unsafe.add(path[:depth])
return unsafe

# render_inline_value {{{3
def render_inline_value(self, obj, exclude, keys, values):
obj = self.convert(obj, keys)
Expand Down Expand Up @@ -2447,6 +2512,8 @@ def render_value(self, obj, keys, values):
raise NotSuitableForInline from None
if obj and (self.width <= 0 or level < self.inline_level):
raise NotSuitableForInline from None
if self._inline_would_drop_comments(keys):
raise NotSuitableForInline from None
try:
if 0 < len(obj) < self.inline_count:
raise NotSuitableForInline from None
Expand Down Expand Up @@ -2479,6 +2546,8 @@ def render_value(self, obj, keys, values):
raise NotSuitableForInline from None
if obj and (self.width <= 0 or level < self.inline_level):
raise NotSuitableForInline from None
if self._inline_would_drop_comments(keys):
raise NotSuitableForInline from None
try:
if 0 < len(obj) < self.inline_count:
raise NotSuitableForInline from None
Expand Down
30 changes: 30 additions & 0 deletions tests/test_comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -1668,3 +1668,33 @@ def test_comment_repr_includes_new_fields_when_set():
assert "tab=" not in r2
assert "before=" not in r2
assert "after=" not in r2


# ---------------------------------------------------------------------------
# Inlining must not silently drop comments
# ---------------------------------------------------------------------------

def test_inline_does_not_drop_comment_on_list_item():
"""A collection that carries comments on its items must fall back to the
multi-line form rather than inline, otherwise the comments are silently
dropped (the inline forms have no place to emit them)."""
data = {"servers": ["alpha", "beta"]}
keymap = {}
annotate(("servers", 0), keymap, key_leading=[Comment("the first server")])
# width is large enough that, absent comments, the list would be inlined.
out = nt.dumps(data, map_keys=keymap, width=200, inline_level=0, inline_count=1)
assert "the first server" in out
# and the list was forced to the multi-line form
assert "- alpha" in out
assert "[alpha, beta]" not in out


def test_inline_does_not_drop_value_comment_on_dict():
"""A value-side comment on a key whose dict value would inline must keep
that value multi-line so the comment survives."""
data = {"config": {"a": "1", "b": "2"}}
keymap = {}
annotate(("config",), keymap, value_trailing=[Comment("end of config", tab=1)])
out = nt.dumps(data, map_keys=keymap, width=200, inline_level=0, inline_count=1)
assert "end of config" in out
assert "{a: 1, b: 2}" not in out
Loading