Skip to content

Commit adb73c4

Browse files
committed
Introduce NestedReferenceNullAsNone compatibility flag
This compat flag changes reclass-rs's serialization of YAML `null` values in nested references to emit string "None" instead of the new default of string "null". Unless your inventory depends on this behavior we strongly recommend not using the compatibility mode.
1 parent ddab620 commit adb73c4

11 files changed

Lines changed: 102 additions & 25 deletions

File tree

README-extensions.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,17 @@ For example, given the following inventory, the node's internal path is parsed a
2121
```
2222

2323
However, optionally, reclass-rs can be configured to handle `compose_node_name` the same way that kapicorp-reclass does, by naively splitting node names on each dot.
24-
To enable this compatibility mode, set `compat_flags: ['ComposeNodeNameLiteralDots']` in your inventory's `reclass-config.yml`.
24+
To enable this compatibility mode, set `reclass_rs_compat_flags: ['ComposeNodeNameLiteralDots']` in your inventory's `reclass-config.yml`.
2525
In compatibility mode, the node's internal path for the previous inventory is `['path', 'to', 'the', 'node']`.
2626

27+
## Handling of YAML `null` values in nested references
28+
29+
By default, reclass-rs resolves YAML `null` values in nested references to the string `null`.
30+
This behavior ensures that references which resolve to YAML `null` are correctly preserved when using them as reference default values (see below).
31+
32+
Optionally, reclass-rs can be configured to preserve the kapicorp-reclass behavior of resolving YAML `null` values in nested references to string "None".
33+
To enable this compatibility mode, set `reclass_rs_compat_flags: ['NestedReferenceNullAsNone']` in your inventory's `reclass-config.yml`.
34+
2735
## Verbose warnings
2836

2937
Reclass-rs supports boolean config option `verbose_warnings`.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ The implementation currently supports the following features of Kapicorp Reclass
1919
* Merging referenced lists and dictionaries
2020
* Constant parameters
2121
* Nested references
22+
* By default reclass-rs uses a non-compatible mode which resolves YAML `null` values in nested references to string "null".
2223
* References in class names
2324
* Loading classes with relative names
2425
* Loading Reclass configuration options from `reclass-config.yaml`

python/reclass_rs/reclass_rs.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ __all__: list[str] = [
1515
@final
1616
class CompatFlag(Enum):
1717
ComposeNodeNameLiteralDots = "ComposeNodeNameLiteralDots"
18+
NestedReferenceNullAsNone = "NestedReferenceNullAsNone"
1819

1920
@final
2021
class Config:

src/config.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ pub enum CompatFlag {
2525
/// file path when rendering fields `path` and `parts` in `NodeInfoMeta` when
2626
/// `compose-node-name` is enabled.
2727
ComposeNodeNameLiteralDots,
28+
/// This flag enables Python Reclass-compatible rendering of nested references that resolve to
29+
/// a YAML `null` value. When this flag is set, YAML `null` values encountered during nested
30+
/// reference resolution will be serialized as `"None"`.
31+
///
32+
/// By default, if this flag isn't enabled, reclass-rs will serialize YAML `null` values as
33+
/// string `"null"` during nested reference resolution.
34+
NestedReferenceNullAsNone,
2835
}
2936

3037
#[pymethods]
@@ -43,6 +50,9 @@ impl TryFrom<&str> for CompatFlag {
4350
"compose-node-name-literal-dots"
4451
| "compose_node_name_literal_dots"
4552
| "ComposeNodeNameLiteralDots" => Ok(Self::ComposeNodeNameLiteralDots),
53+
"nested-reference-null-as-none"
54+
| "nested_reference_null_as_none"
55+
| "NestedReferenceNullAsNone" => Ok(Self::NestedReferenceNullAsNone),
4656
_ => Err(anyhow!("Unknown compatibility flag '{value}'")),
4757
}
4858
}
@@ -171,6 +181,7 @@ pub struct RenderOpts {
171181
pub ignore_overwritten_missing_references: bool,
172182
pub verbose_warnings: bool,
173183
pub(crate) preserve_resolve_error_in_flattened: bool,
184+
pub(crate) nested_reference_null_as_none: bool,
174185
}
175186

176187
#[pyclass(from_py_object)]
@@ -490,6 +501,9 @@ impl From<&Config> for RenderOpts {
490501
Self {
491502
ignore_overwritten_missing_references: value.ignore_overwritten_missing_references,
492503
verbose_warnings: value.verbose_warnings,
504+
nested_reference_null_as_none: value
505+
.compatflags
506+
.contains(&CompatFlag::NestedReferenceNullAsNone),
493507
..Default::default()
494508
}
495509
}

src/node/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ impl Node {
234234
let mut state = ResolveState::default();
235235
clstoken
236236
.render(&root.parameters, &mut state, &r.config.get_render_opts())?
237-
.raw_string()?
237+
.raw_string(&r.config.get_render_opts())?
238238
} else {
239239
// If Token::parse() returns None, the class name can't contain any references,
240240
// just convert cls into an owned String.

src/refs/mod.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ impl ResolveState {
4646
/// Pushes mapping key into the `current_keys` list. If possible, the provided value is
4747
/// formatted with `raw_string()`. Additionally, unprocessed `String` values are pushed as-is.
4848
/// This function will return an error when it's called with a `Value::ValueList`.
49-
pub(crate) fn push_mapping_key(&mut self, key: &Value) -> Result<()> {
50-
let kstr = match key.raw_string() {
49+
pub(crate) fn push_mapping_key(&mut self, key: &Value, opts: &RenderOpts) -> Result<()> {
50+
let kstr = match key.raw_string(opts) {
5151
Ok(s) => s,
5252
Err(_) => match key {
5353
Value::String(s) => Ok(s.clone()),
@@ -173,7 +173,7 @@ impl Token {
173173
.interpolate(params, state, opts)
174174
} else {
175175
Ok(Value::Literal(
176-
self.resolve(params, state, opts)?.raw_string()?,
176+
self.resolve(params, state, opts)?.raw_string(opts)?,
177177
))
178178
}
179179
}
@@ -374,7 +374,7 @@ fn interpolate_token_slice(
374374
while v.is_string() {
375375
v = v.interpolate(params, &mut st, opts)?;
376376
}
377-
res.push_str(&v.raw_string()?);
377+
res.push_str(&v.raw_string(opts)?);
378378
}
379379
Ok(res)
380380
}

src/types/mapping.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ impl Mapping {
401401
&& let Some(p) = p
402402
{
403403
let mut st = state.clone();
404-
st.push_mapping_key(k)?;
404+
st.push_mapping_key(k, opts)?;
405405
if let Some(errmsg) = p.as_resolve_error() {
406406
if opts.ignore_overwritten_missing_references {
407407
#[cfg(not(feature = "bench"))]
@@ -476,7 +476,7 @@ impl Mapping {
476476
// either manage to interpolate a value (in which case it doesn't contain a loop) or we
477477
// don't and the whole interpolation is aborted.
478478
let mut st = state.clone();
479-
st.push_mapping_key(k)?;
479+
st.push_mapping_key(k, opts)?;
480480
let iv = v.interpolate(root, &mut st, opts);
481481
let v = if let Err(e) = iv {
482482
// convert interpolation errors into `Value::ResolveError` when interpolating

src/types/value.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -481,13 +481,20 @@ impl Value {
481481
/// strings which match Python's `str()` for other types.
482482
///
483483
#[inline]
484-
pub(crate) fn raw_string(&self) -> Result<String> {
484+
pub(crate) fn raw_string(&self, opts: &RenderOpts) -> Result<String> {
485485
match self {
486486
Value::Literal(s) => Ok(s.clone()),
487-
// We serialize Null as `null`. NOTE(sg): This isn't compatible with Python's str(),
488-
// but ensures that a reclass-rs reference default value which is a reference that
489-
// resolves to a Null value is preserved correctly.
490-
Value::Null => Ok("null".to_string()),
487+
// We serialize Null as `null` unless the `NestedReferenceNullAsNone` compat flag is
488+
// set, in which case we serialize Null as `None`. The default serialization isn't
489+
// compatible with Python's str(), but ensures that a reclass-rs reference default
490+
// value which is a reference that resolves to a Null value is preserved correctly.
491+
Value::Null => {
492+
if opts.nested_reference_null_as_none {
493+
Ok("None".to_string())
494+
} else {
495+
Ok("null".to_string())
496+
}
497+
}
491498
// We need custom formatting for bool instead of `format!("{b}")`, so that this
492499
// function returns strings which match Python's `str()` implementation.
493500
Value::Bool(b) => match b {

src/types/value/value_flattened_tests.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ test_flattened_simple! {
4242
fn test_flattened_string() {
4343
let v = Value::String("foo".into());
4444
let mut st = ResolveState::default();
45-
st.push_mapping_key(&"test".into()).unwrap();
45+
st.push_mapping_key(&"test".into(), &RenderOpts::default())
46+
.unwrap();
4647
v.flattened(&mut st, &RenderOpts::default()).unwrap();
4748
}
4849

src/types/value/value_tests.rs

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -294,45 +294,65 @@ fn test_strip_prefix() {
294294
#[test]
295295
fn test_raw_string_literal() {
296296
assert_eq!(
297-
Value::Literal("foo".into()).raw_string().unwrap(),
297+
Value::Literal("foo".into())
298+
.raw_string(&RenderOpts::default())
299+
.unwrap(),
298300
"foo".to_string()
299301
);
300302
}
301303

302304
#[test]
303305
fn test_raw_string_null() {
304-
assert_eq!(Value::Null.raw_string().unwrap(), "null".to_string());
306+
assert_eq!(
307+
Value::Null.raw_string(&RenderOpts::default()).unwrap(),
308+
"null".to_string()
309+
);
310+
}
311+
312+
#[test]
313+
fn test_raw_string_null_as_none() {
314+
let opts = RenderOpts {
315+
nested_reference_null_as_none: true,
316+
..RenderOpts::default()
317+
};
318+
assert_eq!(Value::Null.raw_string(&opts).unwrap(), "None".to_string());
305319
}
306320

307321
#[test]
308322
fn test_raw_string_number() {
309323
assert_eq!(
310-
Value::Number(5.into()).raw_string().unwrap(),
324+
Value::Number(5.into())
325+
.raw_string(&RenderOpts::default())
326+
.unwrap(),
311327
"5".to_string()
312328
);
313329
assert_eq!(
314-
Value::Number((-1).into()).raw_string().unwrap(),
330+
Value::Number((-1).into())
331+
.raw_string(&RenderOpts::default())
332+
.unwrap(),
315333
"-1".to_string()
316334
);
317335
assert_eq!(
318-
Value::Number(3.14.into()).raw_string().unwrap(),
336+
Value::Number(3.14.into())
337+
.raw_string(&RenderOpts::default())
338+
.unwrap(),
319339
"3.14".to_string()
320340
);
321341
assert_eq!(
322342
Value::Number(serde_yaml::Number::from(f64::INFINITY))
323-
.raw_string()
343+
.raw_string(&RenderOpts::default())
324344
.unwrap(),
325345
".inf".to_string()
326346
);
327347
assert_eq!(
328348
Value::Number(serde_yaml::Number::from(f64::NEG_INFINITY))
329-
.raw_string()
349+
.raw_string(&RenderOpts::default())
330350
.unwrap(),
331351
"-.inf".to_string()
332352
);
333353
assert_eq!(
334354
Value::Number(serde_yaml::Number::from(f64::NAN))
335-
.raw_string()
355+
.raw_string(&RenderOpts::default())
336356
.unwrap(),
337357
".nan".to_string()
338358
);
@@ -343,15 +363,15 @@ fn test_raw_string_mapping() {
343363
let mut m = Value::Mapping(Mapping::from_str("{foo: foo, bar: true, baz: 1.23}").unwrap());
344364
// turn string values into literals by calling flatten
345365
m.render(&Mapping::new(), &RenderOpts::default()).unwrap();
346-
let mstr = m.raw_string().unwrap();
366+
let mstr = m.raw_string(&RenderOpts::default()).unwrap();
347367
// NOTE(sg): serde_json output is sorted by keys
348368
assert_eq!(mstr, r#"{"bar":true,"baz":1.23,"foo":"foo"}"#);
349369
}
350370

351371
#[test]
352372
fn test_raw_string_sequence() {
353373
let v = Value::Sequence(vec!["foo".into(), 3.14.into(), Value::Bool(true)]);
354-
let vstr = v.raw_string().unwrap();
374+
let vstr = v.raw_string(&RenderOpts::default()).unwrap();
355375
assert_eq!(vstr, r#"["foo",3.14,true]"#);
356376
}
357377

@@ -364,7 +384,7 @@ fn test_raw_string_mapping_nonstring_keys() {
364384
let m = Value::Mapping(m)
365385
.rendered(&Mapping::new(), &RenderOpts::default())
366386
.unwrap();
367-
let mstr = m.raw_string().unwrap();
387+
let mstr = m.raw_string(&RenderOpts::default()).unwrap();
368388
// NOTE(sg): serde_json output is sorted by keys
369389
assert_eq!(mstr, r#"{"3.14":true,"null":1.23,"true":"foo"}"#);
370390
}

0 commit comments

Comments
 (0)