Skip to content

Commit fe9b8b5

Browse files
fix(dump,rust): type-faithful preserve edge cases + rust rpath, from adversarial review
Follow-up to abbe811, addressing every finding of a three-lens adversarial review plus the broken rust cross-language backend. Rust backend (never actually ran) - examples/tryparse links libl4yaml.so and libleanshared.so but cargo baked no rpath, so every suiterunner invocation died with exit 127 (loader error) and scored as a parse rejection — masked by tee until the pipefail fix. l4yaml-sys/build.rs now exports both library dirs over its links="l4yaml" metadata channel and a new l4yaml/build.rs turns them into -Wl,-rpath args (the cargo mirror of tryparse_c's CMake BUILD_RPATH). Verified: binary runs with no LD_LIBRARY_PATH; all four backends now agree exactly on all five presets (unlimited 783/86/151; others 774/95/151). ScalarPref.preserve edge cases (review blockers/minors) - Plain negative numbers re-quoted: isPlainSafe rejects every leading indicator, so preserve turned plain -1 into "-1" — which the new style-aware safe_load reads back as a STRING. New isPlainSafePreserve admits leading '-' followed by a non-space (ns-plain-first [126]), used only in the preserve arm; existing prefs untouched. - Literal/folded content without a honored newline fell into the plain path (`>- 42` re-emitted as plain 42, string→int). Block styles now fall back to double quotes: block scalars always resolve to strings. - Single-quoted content with CR/C0/C1 controls was emitted raw (CR reparses as folding — silent corruption). New singleQuotedRepresentable (printable+tab per c-printable) gates the style; else escaped double quotes. - 17 new #guard tests pin the preserve semantics (Tests/Guards/Dump.lean). Python compat (review blockers/minors) - Mapping KEYS lost their types: _dump_mapping quoted every key via _quote_scalar(str(key)), so {42: ...} came back {'42': ...}. Keys now serialize through _python_to_yaml (non-scalar keys raise TypeError, as pyyaml does). - _fidelity_config now validates known DumpConfig fields (types, enums, Nat-ness of indent/lineWidth) and wraps malformed config YAML in ConfigError: the Lean config reader silently falls back to ALL defaults on any bad field, which would quietly discard the fidelity settings over a cosmetic typo. - safe_dump_all newline-terminates chunks so '---' no longer glues onto the previous document (stream now reparses to the same documents). - _find_library: directory-valued L4YAML_LIB now tries platform library names (darwin .dylib included); stale pre-0.5.0 libraries produce an OSError with a rebuild hint instead of an opaque AttributeError. - 10 new pytest regressions (negative ints/floats, non-string keys, config validation, dump_all reparse). Tests and CI - Dropped test_tab_as_separation_accepted: it codified acceptance of "key:\n\t value", a leniency no yaml-test-suite case adjudicates (Y79Y pins tab-before-indicator as fail, tab-before-scalar same-line as valid; the next-line form is spec-questionable and PyYAML rejects it). The rejection test keeps its probed input; the comment now states the leniency without asserting it. - Workflow venv step: PYTHONPATH/PYTHONNOUSERSITE now exported locally before the in-step pip/pytest checks (GITHUB_ENV only affects later steps), and venv creation failure gets an actionable error. - TryDump.lean doc lists the "preserve" scalarStyle. Verified: full lake build green (all guards), dumproundtrip 117/117 (default config unchanged), 206/206 across both pytest suites under the hermetic CI environment, and a follow-up confirmation review reported 10/10 fix areas confirmed; its one new finding (negative indent bypassing validation into the same silent-fallback) is fixed and tested here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent abbe811 commit fe9b8b5

10 files changed

Lines changed: 279 additions & 40 deletions

File tree

.github/workflows/test-coverage.yml

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,10 @@ jobs:
4848
# ambient PATH says.
4949
PYBASE=/usr/bin/python3.12
5050
if [ ! -x "$PYBASE" ]; then PYBASE=/usr/bin/python3; fi
51-
"$PYBASE" -m venv "$RUNNER_TEMP/ci-venv"
52-
VPY="$RUNNER_TEMP/ci-venv/bin/python3"
53-
"$VPY" -m pip install --quiet --disable-pip-version-check --upgrade pip
54-
"$VPY" -m pip install --quiet --disable-pip-version-check pytest
55-
"$VPY" --version
56-
"$VPY" -m pytest --version
57-
echo "$RUNNER_TEMP/ci-venv/bin" >> "$GITHUB_PATH"
51+
"$PYBASE" -m venv "$RUNNER_TEMP/ci-venv" || {
52+
echo "::error::venv creation with $PYBASE failed — the runner needs a python with the venv module (Debian/Ubuntu: apt install python3-venv)"
53+
exit 1
54+
}
5855
# A venv does NOT isolate against PYTHONPATH: its entries come
5956
# before both the stdlib and the venv's site-packages on
6057
# sys.path. On this box, ROS setup scripts export
@@ -63,10 +60,20 @@ jobs:
6360
# of the old `-p no:launch_ros` flags). GITHUB_ENV cannot
6461
# unset, and PYTHONPATH="" would put the cwd on sys.path, so
6562
# point it at an empty directory; PYTHONNOUSERSITE blocks
66-
# ~/.local site-packages the same way.
63+
# ~/.local site-packages the same way. Export locally too:
64+
# the GITHUB_ENV write only affects LATER steps, and the
65+
# pip/pytest checks below must not load foreign site-packages.
6766
mkdir -p "$RUNNER_TEMP/ci-venv/empty-pythonpath"
68-
echo "PYTHONPATH=$RUNNER_TEMP/ci-venv/empty-pythonpath" >> "$GITHUB_ENV"
67+
export PYTHONPATH="$RUNNER_TEMP/ci-venv/empty-pythonpath"
68+
export PYTHONNOUSERSITE=1
69+
echo "PYTHONPATH=$PYTHONPATH" >> "$GITHUB_ENV"
6970
echo "PYTHONNOUSERSITE=1" >> "$GITHUB_ENV"
71+
VPY="$RUNNER_TEMP/ci-venv/bin/python3"
72+
"$VPY" -m pip install --quiet --disable-pip-version-check --upgrade pip
73+
"$VPY" -m pip install --quiet --disable-pip-version-check pytest
74+
"$VPY" --version
75+
"$VPY" -m pytest --version
76+
echo "$RUNNER_TEMP/ci-venv/bin" >> "$GITHUB_PATH"
7077
7178
- name: Install weasyprint for PDF generation
7279
id: weasyprint

L4YAML/Output/Dump.lean

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,30 @@ def isPlainSafe (s : String) (allowReserved : Bool := false) : Bool :=
251251
def hasNewlines (s : String) : Bool :=
252252
s.any (· == '\n')
253253

254+
/-- `ns-plain-first` refinement used only by `ScalarPref.preserve`:
255+
`isPlainSafe` rejects every leading indicator, but YAML [126]
256+
permits a leading `-` when followed by a non-space character
257+
(`-1`, `-.inf`). Without this, a parsed plain `-1` would be
258+
re-emitted quoted — changing its core-schema type to string.
259+
Kept separate from `isPlainSafe` so the existing prefs (and the
260+
output calibrated to them) are untouched. -/
261+
def isPlainSafePreserve (s : String) (allowReserved : Bool := false) : Bool :=
262+
isPlainSafe s allowReserved ||
263+
(match s.toList with
264+
| '-' :: c :: rest =>
265+
c != ' ' && c != '\t' && isPlainSafe (String.ofList (c :: rest)) allowReserved
266+
| _ => false)
267+
268+
/-- Whether content can be carried VERBATIM inside single quotes:
269+
printable characters only (plus tab; C0, DEL, and C1 excluded per
270+
c-printable §5.1). A raw CR reparses as line folding (silent
271+
corruption) and other controls are invalid YAML there;
272+
double-quoting escapes them instead. -/
273+
def singleQuotedRepresentable (s : String) : Bool :=
274+
s.all (fun c => c == '\t' ||
275+
(0x20 ≤ c.toNat && c.toNat != 0x7F &&
276+
!(0x80 ≤ c.toNat && c.toNat ≤ 0x9F)))
277+
254278
/-- Check if string content is unsafe as a plain scalar in flow context.
255279
Flow context forbids additional characters beyond what `isPlainSafe` checks:
256280
- Any `:` (not just `: `), since `:` followed by `,`, `}`, `]` is a mapping indicator
@@ -298,16 +322,21 @@ def chooseScalarStyle (s : Scalar) (cfg : DumpConfig)
298322
match s.style with
299323
| .doubleQuoted => .doubleQuoted
300324
| .singleQuoted =>
301-
-- Single-quoted cannot represent newlines
302-
if hasNewlines s.content then .doubleQuoted else .singleQuoted
303-
| _ =>
304-
-- Plain (or a block style not already honored by the outer
305-
-- literal/folded case): plain when safe, else double-quoted.
306-
if !s.content.isEmpty && !hasNewlines s.content &&
307-
isPlainSafe s.content cfg.allowReservedPlain &&
325+
-- Newlines/controls cannot be carried verbatim in single quotes
326+
if singleQuotedRepresentable s.content then .singleQuoted
327+
else .doubleQuoted
328+
| .plain =>
329+
if !hasNewlines s.content &&
330+
isPlainSafePreserve s.content cfg.allowReservedPlain &&
308331
(ctx == .block || !isFlowUnsafe s.content) then
309332
.plain
310333
else .doubleQuoted
334+
| _ =>
335+
-- A literal/folded style NOT honored by the outer newline+block
336+
-- case (no newline in content, or flow context): block scalars
337+
-- always resolve to strings (§10.3.2), so quote — re-emitting
338+
-- plain could flip the type (`>- 42` must not become `42`).
339+
.doubleQuoted
311340

312341
/-- Resolve collection style from node annotation, config, and dump context.
313342
When context is flow, block collections are forced to flow (YAML §8.1

Tests/Guards/Dump.lean

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,4 +186,52 @@ private def docADir : YamlDocument :=
186186
#guard dump (.sequence .block #[.plainScalar "a", .plainScalar "b"])
187187
{ compactSequenceMap := true } == "- a\n- b"
188188

189+
/-! ### scalarStyle preserve: honor node style, keep core-schema type
190+
191+
The type-fidelity config used by the Python binding's `safe_dump`
192+
(`{ scalarStyle := .preserve, allowReservedPlain := true }`): each
193+
scalar re-emits in its own style, so quoting — and therefore the type
194+
a core-schema consumer resolves — survives a load→dump round trip. -/
195+
196+
-- Plain stays plain, including leading '-' (ns-plain-first
197+
-- refinement `isPlainSafePreserve`; plain `-1` must not re-quote
198+
-- into a string) and reserved words under allowReservedPlain.
199+
#guard dump (.plainScalar "-1")
200+
{ scalarStyle := .preserve, allowReservedPlain := true } == "-1"
201+
#guard dump (.plainScalar "-3.14")
202+
{ scalarStyle := .preserve, allowReservedPlain := true } == "-3.14"
203+
#guard dump (.plainScalar "-.inf")
204+
{ scalarStyle := .preserve, allowReservedPlain := true } == "-.inf"
205+
#guard dump (.plainScalar "true")
206+
{ scalarStyle := .preserve, allowReservedPlain := true } == "true"
207+
#guard dump (.plainScalar "42") { scalarStyle := .preserve } == "42"
208+
-- Without allowReservedPlain, reserved words still quote.
209+
#guard dump (.plainScalar "true") { scalarStyle := .preserve } == "\"true\""
210+
-- Plain content that is not plain-safe still quotes ('- x' is a
211+
-- sequence-entry lookalike; empty needs quotes).
212+
#guard dump (.plainScalar "- x") { scalarStyle := .preserve } == "\"- x\""
213+
#guard dump (.plainScalar "key: value")
214+
{ scalarStyle := .preserve } == "\"key: value\""
215+
#guard dump (.plainScalar "") { scalarStyle := .preserve } == "\"\""
216+
-- Quoted stays quoted: '42' must not re-emit plain (type would flip
217+
-- string→int under core-schema resolution).
218+
#guard dump (.quotedScalar "42" .singleQuoted)
219+
{ scalarStyle := .preserve } == "'42'"
220+
#guard dump (.quotedScalar "true" .singleQuoted)
221+
{ scalarStyle := .preserve, allowReservedPlain := true } == "'true'"
222+
#guard dump (.quotedScalar "42" .doubleQuoted)
223+
{ scalarStyle := .preserve } == "\"42\""
224+
-- Block styles not honored by the newline case quote to stay strings
225+
-- (`>- 42` must not become plain 42).
226+
#guard dump (.scalar ⟨"42", .folded, none, none, none⟩)
227+
{ scalarStyle := .preserve, allowReservedPlain := true } == "\"42\""
228+
#guard dump (.scalar ⟨"true", .literal, none, none, none⟩)
229+
{ scalarStyle := .preserve, allowReservedPlain := true } == "\"true\""
230+
-- Single-quoted content that single quotes cannot carry verbatim
231+
-- (CR/C0 controls, newlines) falls back to escaped double quotes.
232+
#guard dump (.scalar ⟨"a\rb", .singleQuoted, none, none, none⟩)
233+
{ scalarStyle := .preserve } == "\"a\\rb\""
234+
#guard dump (.scalar ⟨"line1\nline2", .singleQuoted, none, none, none⟩)
235+
{ scalarStyle := .preserve } == "\"line1\\nline2\""
236+
189237
end L4YAML.Dump

Tests/TryDump.lean

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ All fields are optional (defaults match `DumpConfig {}`):
2828
```
2929
3030
`defaultStyle`: `"block"` | `"flow"` | `"auto"`
31-
`scalarStyle`: `"plain"` | `"doubleQuoted"` | `"singleQuoted"` | `"auto"`
31+
`scalarStyle`: `"plain"` | `"doubleQuoted"` | `"singleQuoted"` | `"auto"` | `"preserve"`
3232
3333
## Exit codes
3434

Tests/test_python_ffi.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -421,19 +421,15 @@ def test_unclosed_flow_mapping(self) -> None:
421421
l4yaml.load("{unclosed")
422422

423423
def test_tab_in_indentation(self) -> None:
424-
# "key:\n\t value" is NOT an error: the line's indentation is
425-
# zero spaces, and the tab is separation space before the
426-
# plain scalar (yaml-test-suite tab semantics). A tab used as
427-
# block indentation before an indicator IS an error:
424+
# Tab used as block indentation before an indicator is an
425+
# error (cf. yaml-test-suite Y79Y: `-\t-` is fail:true).
426+
# NB the parser currently ACCEPTS a tab before a plain scalar
427+
# on a value line ("key:\n\t value") — a leniency no suite
428+
# case adjudicates and PyYAML rejects — so that input is
429+
# deliberately not asserted either way here.
428430
with pytest.raises(l4yaml.ParseError):
429431
l4yaml.load("a:\n\t- x")
430432

431-
def test_tab_as_separation_accepted(self) -> None:
432-
# Companion to test_tab_in_indentation: tab as separation
433-
# space before a plain scalar is legal.
434-
v = l4yaml.load("key:\n\t value")
435-
assert v.as_dict()["key"].as_str() == "value"
436-
437433
def test_error_message_nonempty(self) -> None:
438434
with pytest.raises(l4yaml.ParseError) as exc_info:
439435
l4yaml.load("[unclosed")

python/l4yaml/_ffi.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,19 @@
2929

3030
def _find_library() -> Path:
3131
"""Locate libl4yaml.so by searching several candidate paths."""
32-
# 1. Explicit environment variable: the .so itself, or a directory
33-
# containing it.
32+
# 1. Explicit environment variable: the library itself, or a
33+
# directory containing it (platform-appropriate name).
34+
lib_names: list[str] = ["libl4yaml.so"]
35+
if sys.platform == "darwin":
36+
lib_names.append("libl4yaml.dylib")
3437
env_path: str | None = os.environ.get("L4YAML_LIB")
3538
if env_path:
3639
p = Path(env_path)
3740
if p.is_dir():
38-
p = p / "libl4yaml.so"
39-
if p.is_file():
41+
for name in lib_names:
42+
if (p / name).is_file():
43+
return p / name
44+
elif p.is_file():
4045
return p
4146

4247
# 2. Relative to this package: pkg_dir is <repo>/python/l4yaml, so
@@ -61,9 +66,9 @@ def _find_library() -> Path:
6166
return candidate
6267

6368
raise OSError(
64-
"Cannot find libl4yaml.so. Set L4YAML_LIB environment "
65-
"variable to the full path, or place the library next to this "
66-
"package."
69+
"Cannot find libl4yaml.so. Set the L4YAML_LIB environment "
70+
"variable to the library file or its directory, or place the "
71+
"library next to this package."
6772
)
6873

6974

@@ -148,8 +153,16 @@ def _load_lib() -> ctypes.CDLL:
148153
lib.l4yaml_value_kind.argtypes = [c_void_p]
149154
lib.l4yaml_value_kind.restype = c_uint8
150155

151-
lib.l4yaml_value_scalar_style.argtypes = [c_void_p]
152-
lib.l4yaml_value_scalar_style.restype = c_uint8
156+
try:
157+
lib.l4yaml_value_scalar_style.argtypes = [c_void_p]
158+
lib.l4yaml_value_scalar_style.restype = c_uint8
159+
except AttributeError as exc:
160+
# OSError (not AttributeError) so callers' library-missing
161+
# handling — e.g. the test suites' needs_lib skip — applies.
162+
raise OSError(
163+
"libl4yaml.so predates the scalar-style API (v0.5.0) — "
164+
"rebuild it: cmake -B ffi/out -S ffi && cmake --build ffi/out"
165+
) from exc
153166

154167
lib.l4yaml_value_string.argtypes = [c_void_p]
155168
lib.l4yaml_value_string.restype = c_char_p

python/l4yaml/compat.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,10 @@ def safe_dump_all(
352352
for doc in documents:
353353
chunk: str | None = safe_dump(doc, config=config)
354354
if chunk is not None:
355+
# The dumper emits no trailing newline; without one the
356+
# '---' separator would glue onto the previous document.
357+
if not chunk.endswith("\n"):
358+
chunk += "\n"
355359
parts.append(chunk)
356360
result: str = "---\n".join(parts)
357361
if stream is not None:
@@ -363,6 +367,22 @@ def safe_dump_all(
363367
# ── Dump config with type fidelity ───────────────────────────────────
364368

365369

370+
# DumpConfig fields (L4YAML/Output/Dump.lean) and their value shapes,
371+
# used to validate user configs eagerly: the Lean config reader falls
372+
# back to ALL defaults when any field fails to parse, which would
373+
# silently drop the fidelity settings below over a cosmetic typo.
374+
_DUMP_CONFIG_SPEC: dict[str, Any] = {
375+
"indent": int,
376+
"lineWidth": int,
377+
"sortKeys": bool,
378+
"allowReservedPlain": bool,
379+
"omitEmpty": bool,
380+
"compactSequenceMap": bool,
381+
"defaultStyle": ("block", "flow", "auto"),
382+
"scalarStyle": ("plain", "doubleQuoted", "singleQuoted", "auto", "preserve"),
383+
}
384+
385+
366386
def _fidelity_config(user_cfg: str | None) -> str:
367387
"""Merge type-fidelity defaults into a user dump config.
368388
@@ -373,12 +393,42 @@ def _fidelity_config(user_cfg: str | None) -> str:
373393
``true``/``null`` (so they stay a bool/null). Explicit user
374394
settings for either key win. The merged config is emitted as
375395
JSON, which the config parser reads as flow-style YAML.
396+
397+
Raises:
398+
ConfigError: If the config is not valid YAML, not a mapping,
399+
or a known field has a value the dump config would reject.
376400
"""
377-
merged: Any = safe_load(user_cfg) if user_cfg is not None else {}
378-
if merged is None:
379-
merged = {}
401+
if user_cfg is None:
402+
merged: Any = {}
403+
else:
404+
try:
405+
merged = safe_load(user_cfg)
406+
except L4YAMLError as exc:
407+
raise ConfigError(f"invalid dump config YAML: {exc}") from exc
408+
if merged is None:
409+
merged = {}
380410
if not isinstance(merged, dict):
381411
raise ConfigError(f"dump config must be a mapping, got: {merged!r}")
412+
for key, spec in _DUMP_CONFIG_SPEC.items():
413+
if key not in merged:
414+
continue
415+
val = merged[key]
416+
if isinstance(spec, tuple):
417+
ok = val in spec
418+
expected = " | ".join(spec)
419+
elif spec is int:
420+
# The Lean fields are Nat: bools and negatives would fail
421+
# its reader and silently drop the whole config.
422+
ok = isinstance(val, int) and not isinstance(val, bool) and val >= 0
423+
expected = "non-negative int"
424+
else:
425+
ok = isinstance(val, spec)
426+
expected = spec.__name__
427+
if not ok:
428+
raise ConfigError(
429+
f"dump config field {key!r}: invalid value {val!r} "
430+
f"(expected {expected})"
431+
)
382432
merged.setdefault("scalarStyle", "preserve")
383433
merged.setdefault("allowReservedPlain", True)
384434
return json.dumps(merged)
@@ -470,7 +520,11 @@ def _dump_mapping(mapping: dict[Any, Any], indent: int) -> str:
470520
prefix: str = " " * indent
471521
lines: list[str] = []
472522
for key, val in mapping.items():
473-
key_str: str = _quote_scalar(str(key))
523+
# Serialize keys with type fidelity: _quote_scalar(str(key))
524+
# would quote non-string keys (42 -> '42'), pinning them to str
525+
# under style-aware loading. Non-scalar keys raise TypeError
526+
# (as pyyaml's safe_dump rejects them).
527+
key_str: str = _python_to_yaml(key)
474528
if isinstance(val, (dict, list)) and val:
475529
child: str = _python_to_yaml(val, indent + 1)
476530
lines.append(f"{prefix}{key_str}:\n{child}")

0 commit comments

Comments
 (0)