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
1 change: 1 addition & 0 deletions binoc-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ impl DatasetConfig {
"binoc.folder_move_detector".into(),
"binoc.table_splitter".into(),
"binoc.tabular_analyzer".into(),
"binoc.tabular_stats_annotator".into(),
"binoc.column_reorder_detector".into(),
"binoc.table_collection_analyzer".into(),
],
Expand Down
10 changes: 5 additions & 5 deletions binoc-core/src/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -573,8 +573,8 @@ impl Controller {
/// re-dispatch the pair through the comparator pipeline, then merge the
/// resulting `item_type`, `comparator`, `source_items`, `artifacts`,
/// `details`, and `children` into the host node. The comparator's own
/// summary (e.g. "2 lines added") is stashed in
/// `annotations.content_summary` so renderers can surface it without
/// summary (e.g. "2 lines added") is stashed in the `content_summary`
/// annotation so renderers can surface it without
/// overwriting the host's move headline.
///
/// `pending_recompare` is `take()`n (cleared) on every visited node,
Expand Down Expand Up @@ -642,9 +642,9 @@ impl Controller {
if node.summary.is_none() {
node.summary = Some(summary.clone());
}
node.annotations
.entry("content_summary".into())
.or_insert_with(|| serde_json::json!(summary));
if node.binoc_annotation("content_summary").is_none() {
node.annotate_from("binoc", "content_summary", serde_json::json!(summary));
}
}
}
// Splice point: future non-Root transformers that need same-pass
Expand Down
8 changes: 6 additions & 2 deletions binoc-python/python/binoc/_binoc.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ from __future__ import annotations

from typing import Any, Iterator

AnnotationRecord = dict[str, Any]

class DiffNode:
def __init__(
self,
Expand All @@ -13,7 +15,7 @@ class DiffNode:
summary: str | None = None,
tags: list[str] | set[str] | None = None,
details: dict[str, Any] | None = None,
annotations: dict[str, Any] | None = None,
annotations: list[AnnotationRecord] | None = None,
children: list[DiffNode] | None = None,
) -> None: ...
@property
Expand All @@ -33,7 +35,7 @@ class DiffNode:
@property
def details(self) -> dict[str, Any]: ...
@property
def annotations(self) -> dict[str, Any]: ...
def annotations(self) -> list[AnnotationRecord]: ...
def node_count(self) -> int: ...
def all_tags(self) -> list[str]: ...
def to_dict(self) -> dict[str, Any]: ...
Expand All @@ -43,6 +45,8 @@ class DiffNode:
def with_source_path(self, source: str) -> DiffNode: ...
def with_children(self, children: list[DiffNode]) -> DiffNode: ...
def with_detail(self, key: str, value: Any) -> DiffNode: ...
def with_annotation_from(self, package: str, key: str, value: Any) -> DiffNode: ...
def annotate_from(self, package: str, key: str, value: Any) -> DiffNode: ...
def find_node(self, selector: str) -> DiffNode | None: ...
def __repr__(self) -> str: ...
def __str__(self) -> str: ...
Expand Down
96 changes: 89 additions & 7 deletions binoc-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,58 @@ fn py_dict_to_json_map(dict: &Bound<'_, PyDict>) -> PyResult<BTreeMap<String, se
Ok(map)
}

fn annotation_to_py<'py>(py: Python<'py>, annotation: &Annotation) -> PyResult<Bound<'py, PyDict>> {
let dict = PyDict::new(py);
dict.set_item("package", &annotation.package)?;
dict.set_item("key", &annotation.key)?;
dict.set_item("value", json_to_py(py, &annotation.value)?)?;
Ok(dict)
}

fn annotations_to_py<'py>(
py: Python<'py>,
annotations: &[Annotation],
) -> PyResult<Bound<'py, PyList>> {
let items: PyResult<Vec<Bound<'py, PyDict>>> = annotations
.iter()
.map(|annotation| annotation_to_py(py, annotation))
.collect();
PyList::new(py, items?)
}

fn py_annotation_record_to_ir(dict: &Bound<'_, PyDict>) -> PyResult<Annotation> {
let package = dict
.get_item("package")?
.map(|value| value.extract::<String>())
.transpose()?
.unwrap_or_else(|| "binoc".to_string());
let key = dict
.get_item("key")?
.ok_or_else(|| PyTypeError::new_err("annotation record missing 'key'"))?
.extract::<String>()?;
let value = dict
.get_item("value")?
.ok_or_else(|| PyTypeError::new_err("annotation record missing 'value'"))
.and_then(|value| py_to_json(&value))?;
Ok(Annotation::new(package, key, value))
}

fn py_annotations_to_ir(obj: &Bound<'_, PyAny>) -> PyResult<Vec<Annotation>> {
if let Ok(list) = obj.cast::<PyList>() {
let mut annotations = Vec::new();
for item in list.iter() {
let dict = item
.cast::<PyDict>()
.map_err(|_| PyTypeError::new_err("annotation list items must be dicts"))?;
annotations.push(py_annotation_record_to_ir(dict)?);
}
return Ok(annotations);
}
Err(PyTypeError::new_err(
"annotations must be a list of annotation dicts",
))
}

// ═══════════════════════════════════════════════════════════════════════════
// PyDiffNode
// ═══════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -540,7 +592,8 @@ impl PyDiffNode {
/// :param tags: Optional list or set of open-string tags (used for
/// renderer significance classification and transformer dispatch).
/// :param details: Optional dict of structured JSON-serializable data.
/// :param annotations: Optional dict of transient/presentation data.
/// :param annotations: Optional list of annotation dicts with explicit
/// ``package``, ``key``, and ``value`` fields.
/// :param children: Optional list of child ``DiffNode`` s.
#[new]
#[pyo3(signature = (action, item_type, path, *, source_path=None, summary=None, tags=None, details=None, annotations=None, children=None))]
Expand All @@ -553,7 +606,7 @@ impl PyDiffNode {
summary: Option<String>,
tags: Option<Bound<'_, PyAny>>,
details: Option<Bound<'_, PyDict>>,
annotations: Option<Bound<'_, PyDict>>,
annotations: Option<Bound<'_, PyAny>>,
children: Option<Vec<PyDiffNode>>,
) -> PyResult<Self> {
let mut node = DiffNode::new(action, item_type, path);
Expand All @@ -576,7 +629,7 @@ impl PyDiffNode {
node.details = py_dict_to_json_map(&d)?;
}
if let Some(a) = annotations {
node.annotations = py_dict_to_json_map(&a)?;
node.annotations = py_annotations_to_ir(&a)?;
}
if let Some(c) = children {
node.children = c.into_iter().map(|n| n.inner).collect();
Expand Down Expand Up @@ -629,10 +682,11 @@ impl PyDiffNode {
fn details<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
json_map_to_py(py, &self.inner.details)
}
/// Transient/presentation annotations not part of the persisted IR.
/// Renderer-visible annotations as ``{"package", "key", "value"}``
/// records.
#[getter]
fn annotations<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
json_map_to_py(py, &self.inner.annotations)
fn annotations<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyList>> {
annotations_to_py(py, &self.inner.annotations)
}

/// Total number of nodes in the subtree rooted at this node.
Expand Down Expand Up @@ -661,7 +715,10 @@ impl PyDiffNode {
.collect();
dict.set_item("children", PyList::new(py, children?)?)?;
dict.set_item("details", json_map_to_py(py, &self.inner.details)?)?;
dict.set_item("annotations", json_map_to_py(py, &self.inner.annotations)?)?;
dict.set_item(
"annotations",
annotations_to_py(py, &self.inner.annotations)?,
)?;
Ok(dict)
}

Expand Down Expand Up @@ -704,6 +761,31 @@ impl PyDiffNode {
inner: self.inner.clone().with_detail(key, json_val),
})
}
/// Return a clone of this node with a namespaced annotation set.
/// ``value`` must be JSON-serializable.
fn with_annotation_from(
&self,
package: String,
key: String,
value: Bound<'_, PyAny>,
) -> PyResult<Self> {
let json_val = py_to_json(&value)?;
Ok(Self {
inner: self
.inner
.clone()
.with_annotation_from(package, key, json_val),
})
}
/// Alias for ``with_annotation_from``.
fn annotate_from(
&self,
package: String,
key: String,
value: Bound<'_, PyAny>,
) -> PyResult<Self> {
self.with_annotation_from(package, key, value)
}
/// Recursively search this subtree for a node whose ``path`` matches
/// ``selector``. Returns ``None`` if no match is found.
fn find_node(&self, selector: &str) -> Option<PyDiffNode> {
Expand Down
37 changes: 37 additions & 0 deletions binoc-python/tests/test_ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,43 @@ def test_with_detail(self):
updated = node.with_detail('key', 'value')
assert updated.details['key'] == 'value'

def test_annotations_are_namespaced_records(self):
node = binoc.DiffNode(
'modify',
'file',
'f',
annotations=[{'package': 'binoc', 'key': 'note', 'value': 'check me'}],
)
assert node.annotations == [{'package': 'binoc', 'key': 'note', 'value': 'check me'}]

updated = node.annotate_from('binoc', 'distribution_shifts', ['score changed'])
updated = updated.annotate_from('example.plugin', 'warning', {'level': 2})
assert updated.annotations == [
{'package': 'binoc', 'key': 'note', 'value': 'check me'},
{
'package': 'binoc',
'key': 'distribution_shifts',
'value': ['score changed'],
},
{
'package': 'example.plugin',
'key': 'warning',
'value': {'level': 2},
},
]
assert node.annotations == [{'package': 'binoc', 'key': 'note', 'value': 'check me'}]

def test_annotation_records_can_be_constructed_explicitly(self):
node = binoc.DiffNode(
'modify',
'file',
'f',
annotations=[{'package': 'example.plugin', 'key': 'note', 'value': ['one', 'two']}],
)
assert node.to_dict()['annotations'] == [
{'package': 'example.plugin', 'key': 'note', 'value': ['one', 'two']}
]

def test_with_source_path(self):
node = binoc.DiffNode('move', 'file', 'new.txt')
moved = node.with_source_path('old.txt')
Expand Down
Loading