Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d48834b
feat(runtime): preserve explicit null values for collections and scal…
kervel Sep 10, 2025
eaf609d
chore: rustfmt and clippy fixes; avoid get(0) in tests
kervel Sep 10, 2025
d51ee4b
feat(diff): null/missing semantics; add treat_missing_as_null flag; t…
kervel Sep 10, 2025
a99c1e8
feat(diff): single flag treat_missing_as_null; explicit nulls as upda…
kervel Sep 10, 2025
7381afd
docs: testing guideline to extend existing tests instead of adding re…
kervel Sep 10, 2025
d317729
feat(runtime): add NodeIds to LinkMLValue and return PatchTrace from …
kervel Sep 10, 2025
3744161
refactor(runtime): implement LinkMLValue-native patch with incrementa…
kervel Sep 10, 2025
2c99faf
Refactor LinkMLValue builders and reuse in patch; fix fmt/clippy; res…
kervel Sep 10, 2025
6d9f753
fmt: apply rustfmt after refactor; clippy clean excluding linkml_meta
kervel Sep 10, 2025
be5dacc
diff: inline LinkMLValue builder calls; remove thin wrappers and unus…
kervel Sep 10, 2025
8746af6
diff: make patch panic-safe; return LResult and propagate errors inst…
kervel Sep 10, 2025
8af38c6
Merge pull request #4 from Kapernikov/feat/null-collections
kervel Sep 10, 2025
4e2b77e
Merge origin/main into feat/node-ids-patch-trace-clean: integrate Lin…
kervel Sep 10, 2025
3d99bd4
tests: update for Null variant; use JSON roundtrip instead of constru…
kervel Sep 10, 2025
91ec1de
diff: treat identifier/key changes as object replacement; add tests f…
kervel Sep 11, 2025
e078ce9
docs(runtime): clarify semantics of internal NodeId and PatchTrace fi…
kervel Sep 11, 2025
5c31a7b
runtime: add LinkMLValue::equals per Instances spec; tests: add equal…
Sep 11, 2025
661e9ad
python: expose LinkMLValue.equals(); tests: add python_equals coverage
Sep 11, 2025
7c58d56
runtime: equals(treat_missing_as_null) and patch no-op skipping; patc…
Sep 11, 2025
4b34691
runtime: introduce PatchOptions {ignore_no_ops, treat_missing_as_null…
Sep 11, 2025
d5337fc
dummy commit to reopen PR
kervel Sep 12, 2025
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@
- Python (bindings helpers): follow PEP 8; prefer type hints where feasible.

## Testing Guidelines
- When testing locally, always provide network access. never try to run the tests offline
- Add integration tests under `src/runtime/tests/` when changing CLI/runtime behavior.
- Prefer `assert_cmd` for CLI and `predicates` for output checks. Keep fixtures in `src/runtime/tests/data/`.
- Run `cargo test --workspace` locally; ensure tests don’t rely on network input.
- Prefer modifying existing tests over adding new ones for new code paths. Extend current scenarios with extra assertions/fixtures to avoid redundant tests proliferating. For example, if adding null-handling in diff/patch, enhance the existing diff tests rather than introducing separate "basic diff works" tests that become redundant.

## Commit & Pull Request Guidelines
- Commits: short, imperative summary (e.g., “Add __repr__ for LinkMLValue”); group related changes.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# linkml-core

core linkml schema operations written in rust :)
core linkml schema operations written in rust

## Crates

Expand Down
54 changes: 47 additions & 7 deletions src/python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,11 +478,18 @@ impl Clone for PyLinkMLValue {

#[pymethods]
impl PyLinkMLValue {
/// Semantic equality per LinkML Instances spec.
/// Compares this value with another `LinkMLValue`.
#[pyo3(signature = (other, treat_missing_as_null = false))]
fn equals(&self, other: &PyLinkMLValue, treat_missing_as_null: bool) -> bool {
self.value.equals(&other.value, treat_missing_as_null)
}
#[getter]
fn slot_name(&self) -> Option<String> {
match &self.value {
LinkMLValue::Scalar { slot, .. } => Some(slot.name.clone()),
LinkMLValue::List { slot, .. } => Some(slot.name.clone()),
LinkMLValue::Null { slot, .. } => Some(slot.name.clone()),
_ => None,
}
}
Expand All @@ -491,17 +498,24 @@ impl PyLinkMLValue {
fn kind(&self) -> String {
match &self.value {
LinkMLValue::Scalar { .. } => "scalar".to_string(),
LinkMLValue::Null { .. } => "null".to_string(),
LinkMLValue::List { .. } => "list".to_string(),
LinkMLValue::Mapping { .. } => "mapping".to_string(),
LinkMLValue::Object { .. } => "object".to_string(),
}
}

#[getter]
fn node_id(&self) -> u64 {
self.value.node_id()
}

#[getter]
fn slot_definition(&self) -> Option<SlotDefinition> {
match &self.value {
LinkMLValue::Scalar { slot, .. } => Some(slot.definition().clone()),
LinkMLValue::List { slot, .. } => Some(slot.definition().clone()),
LinkMLValue::Null { slot, .. } => Some(slot.definition().clone()),
_ => None,
}
}
Expand All @@ -512,6 +526,7 @@ impl PyLinkMLValue {
LinkMLValue::Object { class, .. } => Some(class.def().clone()),
LinkMLValue::Scalar { class: Some(c), .. } => Some(c.def().clone()),
LinkMLValue::List { class: Some(c), .. } => Some(c.def().clone()),
LinkMLValue::Null { class: Some(c), .. } => Some(c.def().clone()),
_ => None,
}
}
Expand All @@ -522,13 +537,15 @@ impl PyLinkMLValue {
LinkMLValue::Object { class, .. } => Some(class.def().name.clone()),
LinkMLValue::Scalar { class: Some(c), .. } => Some(c.def().name.clone()),
LinkMLValue::List { class: Some(c), .. } => Some(c.def().name.clone()),
LinkMLValue::Null { class: Some(c), .. } => Some(c.def().name.clone()),
_ => None,
}
}

fn __len__(&self) -> PyResult<usize> {
Ok(match &self.value {
LinkMLValue::Scalar { .. } => 0,
LinkMLValue::Null { .. } => 0,
LinkMLValue::List { values, .. } => values.len(),
LinkMLValue::Mapping { values, .. } => values.len(),
LinkMLValue::Object { values, .. } => values.len(),
Expand Down Expand Up @@ -645,6 +662,9 @@ impl PyLinkMLValue {
LinkMLValue::Scalar { value, slot, .. } => {
format!("LinkMLValue.Scalar(slot='{}', value={})", slot.name, value)
}
LinkMLValue::Null { slot, .. } => {
format!("LinkMLValue.Null(slot='{}')", slot.name)
}
LinkMLValue::List { values, slot, .. } => {
format!(
"LinkMLValue.List(slot='{}', len={})",
Expand Down Expand Up @@ -725,17 +745,17 @@ fn load_json(
Ok(PyLinkMLValue::new(v, sv))
}

#[pyfunction(name = "diff", signature = (source, target, ignore_missing_target=None))]
#[pyfunction(name = "diff", signature = (source, target, treat_missing_as_null=None))]
fn py_diff(
py: Python<'_>,
source: &PyLinkMLValue,
target: &PyLinkMLValue,
ignore_missing_target: Option<bool>,
treat_missing_as_null: Option<bool>,
) -> PyResult<PyObject> {
let deltas = diff_internal(
&source.value,
&target.value,
ignore_missing_target.unwrap_or(false),
treat_missing_as_null.unwrap_or(false),
);
let vals: Vec<JsonValue> = deltas
.iter()
Expand All @@ -744,20 +764,40 @@ fn py_diff(
Ok(json_value_to_py(py, &JsonValue::Array(vals)))
}

#[pyfunction(name = "patch")]
#[pyfunction(name = "patch", signature = (source, deltas, treat_missing_as_null = true, ignore_no_ops = true))]
fn py_patch(
py: Python<'_>,
source: &PyLinkMLValue,
deltas: &Bound<'_, PyAny>,
) -> PyResult<PyLinkMLValue> {
treat_missing_as_null: bool,
ignore_no_ops: bool,
) -> PyResult<PyObject> {
let json_mod = PyModule::import(py, "json")?;
let deltas_str: String = json_mod.call_method1("dumps", (deltas,))?.extract()?;
let deltas_vec: Vec<Delta> =
serde_json::from_str(&deltas_str).map_err(|e| PyException::new_err(e.to_string()))?;
let sv_ref = source.sv.bind(py).borrow();
let rust_sv = sv_ref.as_rust();
let new_value = patch_internal(&source.value, &deltas_vec, rust_sv);
Ok(PyLinkMLValue::new(new_value, source.sv.clone_ref(py)))
let (new_value, trace) = patch_internal(
&source.value,
&deltas_vec,
rust_sv,
linkml_runtime::diff::PatchOptions {
ignore_no_ops,
treat_missing_as_null,
},
)
.map_err(|e| PyException::new_err(e.to_string()))?;
let trace_json = serde_json::json!({
"added": trace.added,
"deleted": trace.deleted,
"updated": trace.updated,
});
let py_val = PyLinkMLValue::new(new_value, source.sv.clone_ref(py));
let dict = pyo3::types::PyDict::new(py);
dict.set_item("value", Py::new(py, py_val)?)?;
dict.set_item("trace", json_value_to_py(py, &trace_json))?;
Ok(dict.into_any().unbind())
}

#[pyfunction(name = "to_turtle", signature = (value, skolem=None))]
Expand Down
75 changes: 75 additions & 0 deletions src/python/tests/python_equals.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
use linkml_runtime_python::runtime_module;
use pyo3::prelude::*;
use pyo3::types::PyDict;
use std::path::PathBuf;

fn data_path(name: &str) -> PathBuf {
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let candidates = [
base.join("../runtime/tests/data").join(name),
base.join("../schemaview/tests/data").join(name),
base.join("tests/data").join(name),
];
for c in candidates {
if c.exists() {
return c;
}
}
panic!("test data not found: {}", name);
}

#[test]
fn python_equals_api() {
pyo3::prepare_freethreaded_python();
Python::with_gil(|py| {
let module = PyModule::new(py, "linkml_runtime").unwrap();
runtime_module(&module).unwrap();
let sys = py.import("sys").unwrap();
let modules = sys.getattr("modules").unwrap();
let sys_modules = modules.downcast::<PyDict>().unwrap();
sys_modules.set_item("linkml_runtime", module).unwrap();

let locals = PyDict::new(py);
locals
.set_item(
"schema_path",
data_path("personinfo.yaml").to_str().unwrap(),
)
.unwrap();

pyo3::py_run!(
py,
*locals,
r#"
import linkml_runtime as lr
import json
sv = lr.make_schema_view(schema_path)
cls = sv.get_class_view('Container')

doc1 = {
'objects': [
{
'objecttype': 'personinfo:Person',
'id': 'P:1',
'name': 'Alice',
'current_address': None
}
]
}
doc2 = {
'objects': [
{
'objecttype': 'personinfo:Person',
'id': 'P:1',
'name': 'Alice'
}
]
}

v1 = lr.load_json(json.dumps(doc1), sv, cls)
v2 = lr.load_json(json.dumps(doc2), sv, cls)
assert v1['objects'][0].equals(v2['objects'][0], True)
"#
);
});
}
Loading
Loading