From 4c91e60ca0f0db18fcb5b7b2cd1a7c9a59dbc3ea Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Thu, 6 Aug 2026 12:22:36 +0200 Subject: [PATCH 01/37] Added verify_lineage function that parses a Python script with ast and checks: - lineage tracking calls (ln.track(...) / ln.finish(...) or imported equivalents) - LaminDB input retrieval patterns (e.g. ln.Artifact.get(...), ln.Collection.filter(...), etc.) - LaminDB output persistence patterns (.save() / ln.save(...)) - suspicious non-LaminDB input reads (e.g. pd.read_*, open, np.load, etc.) --- lamindb/core/_verify_lineage.py | 225 ++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 lamindb/core/_verify_lineage.py diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py new file mode 100644 index 000000000..89114efd1 --- /dev/null +++ b/lamindb/core/_verify_lineage.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import ast +from dataclasses import dataclass +from pathlib import Path + + +LAMINDB_MODEL_NAMES = { + "Artifact", + "Collection", + "Run", + "Transform", +} + +LAMINDB_INPUT_METHODS = { + "get", + "filter", + "search", + "one", + "one_or_none", + "first", + "last", + "load", + "open", + "cache", + "mapped", +} + +LAMINDB_OUTPUT_METHODS = { + "save", +} + +EXTERNAL_INPUT_CALL_NAMES = { + "open", + "numpy.load", + "np.load", + "anndata.read", + "ad.read", + "scanpy.read", + "sc.read", + "sqlite3.connect", + "duckdb.connect", + "fsspec.open", +} + +EXTERNAL_INPUT_PREFIXES = ( + "pandas.read_", + "pd.read_", + "polars.read_", + "pl.read_", +) + + +@dataclass(frozen=True) +class ScriptLineageVerification: + """Result of static lineage checks for a Python script.""" + + has_lineage_tracking: bool + has_lamindb_inputs: bool + has_lamindb_outputs: bool + has_external_inputs: bool + is_fully_tracked: bool + missing: tuple[str, ...] + lineage_calls: tuple[str, ...] + lamindb_input_calls: tuple[str, ...] + lamindb_output_calls: tuple[str, ...] + external_input_calls: tuple[str, ...] + + +def _dotted_name(node: ast.AST) -> str | None: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + parent = _dotted_name(node.value) + if parent is None: + return None + return f"{parent}.{node.attr}" + if isinstance(node, ast.Call): + return _dotted_name(node.func) + return None + + +def _root_name(node: ast.AST) -> str | None: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return _root_name(node.value) + if isinstance(node, ast.Call): + return _root_name(node.func) + return None + + +def _format_call(call_name: str, lineno: int) -> str: + return f"{call_name} (line {lineno})" + + +def verify_lineage(path: str | Path) -> ScriptLineageVerification: + """Statically verify lineage tracking conventions in a Python script. + + Checks for: + - run lineage calls (`ln.track(...)` and `ln.finish(...)`) + - laminDB input retrieval calls (for example `ln.Artifact.get(...)`) + - laminDB output persistence calls (for example `.save()` on laminDB records) + - common non-laminDB input reads (for example `pd.read_csv(...)`) + + Notes: + - This is a static AST check and cannot prove runtime behavior. + - "All inputs come from LaminDB" is interpreted conservatively: + if known external read patterns are present, the check fails. + """ + script_path = Path(path) + source = script_path.read_text(encoding="utf-8") + tree = ast.parse(source) + + lamindb_module_aliases: set[str] = set() + lamindb_model_aliases: set[str] = set() + imported_track_aliases: set[str] = set() + imported_finish_aliases: set[str] = set() + imported_save_aliases: set[str] = set() + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "lamindb": + lamindb_module_aliases.add(alias.asname or alias.name) + elif isinstance(node, ast.ImportFrom): + if node.module == "lamindb": + for alias in node.names: + imported_name = alias.asname or alias.name + if alias.name == "track": + imported_track_aliases.add(imported_name) + elif alias.name == "finish": + imported_finish_aliases.add(imported_name) + elif alias.name == "save": + imported_save_aliases.add(imported_name) + elif alias.name in LAMINDB_MODEL_NAMES: + lamindb_model_aliases.add(imported_name) + elif node.module == "lamindb.models": + for alias in node.names: + if alias.name in LAMINDB_MODEL_NAMES: + lamindb_model_aliases.add(alias.asname or alias.name) + + lineage_calls: list[str] = [] + lamindb_input_calls: list[str] = [] + lamindb_output_calls: list[str] = [] + external_input_calls: list[str] = [] + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + + call_name = _dotted_name(node.func) + if call_name is None: + continue + + # Lineage tracking calls + if ( + call_name in imported_track_aliases + or any(call_name == f"{alias}.track" for alias in lamindb_module_aliases) + or call_name in imported_finish_aliases + or any(call_name == f"{alias}.finish" for alias in lamindb_module_aliases) + ): + lineage_calls.append(_format_call(call_name, node.lineno)) + + # LaminDB inputs + method_name = call_name.split(".")[-1] + if method_name in LAMINDB_INPUT_METHODS: + root = _root_name(node.func) + if root in lamindb_model_aliases: + lamindb_input_calls.append(_format_call(call_name, node.lineno)) + elif root in lamindb_module_aliases: + # Handles `ln.Artifact.get(...)`, `ln.Collection.filter(...)`, etc. + if "." in call_name: + model_name = call_name.split(".")[1] + if model_name in LAMINDB_MODEL_NAMES: + lamindb_input_calls.append(_format_call(call_name, node.lineno)) + + # LaminDB outputs + if call_name in imported_save_aliases: + lamindb_output_calls.append(_format_call(call_name, node.lineno)) + elif method_name in LAMINDB_OUTPUT_METHODS: + root = _root_name(node.func) + if root in lamindb_model_aliases: + lamindb_output_calls.append(_format_call(call_name, node.lineno)) + elif root in lamindb_module_aliases: + # Handles `ln.Artifact(...).save()` and related calls. + lamindb_output_calls.append(_format_call(call_name, node.lineno)) + + # External inputs + if call_name in EXTERNAL_INPUT_CALL_NAMES: + external_input_calls.append(_format_call(call_name, node.lineno)) + continue + if call_name.endswith(".read_text") or call_name.endswith(".read_bytes"): + external_input_calls.append(_format_call(call_name, node.lineno)) + continue + if call_name.startswith(EXTERNAL_INPUT_PREFIXES): + external_input_calls.append(_format_call(call_name, node.lineno)) + + has_lineage_tracking = len(lineage_calls) > 0 + has_lamindb_inputs = len(lamindb_input_calls) > 0 + has_lamindb_outputs = len(lamindb_output_calls) > 0 + has_external_inputs = len(external_input_calls) > 0 + + missing: list[str] = [] + if not has_lineage_tracking: + missing.append("lineage tracking call (`ln.track()` or `ln.finish()`)") + if not has_lamindb_inputs: + missing.append("LaminDB input retrieval") + if not has_lamindb_outputs: + missing.append("LaminDB output persistence") + if has_external_inputs: + missing.append("unexpected non-LaminDB input reads") + + return ScriptLineageVerification( + has_lineage_tracking=has_lineage_tracking, + has_lamindb_inputs=has_lamindb_inputs, + has_lamindb_outputs=has_lamindb_outputs, + has_external_inputs=has_external_inputs, + is_fully_tracked=len(missing) == 0, + missing=tuple(missing), + lineage_calls=tuple(lineage_calls), + lamindb_input_calls=tuple(lamindb_input_calls), + lamindb_output_calls=tuple(lamindb_output_calls), + external_input_calls=tuple(external_input_calls), + ) From 949db166cf8d0c3fb9031d76896b780da9a0067b Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Thu, 6 Aug 2026 14:07:04 +0200 Subject: [PATCH 02/37] Add tests for verify_lineage --- lamindb/core/__init__.py | 2 +- tests/no_instance/test_verify_lineage.py | 111 +++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/no_instance/test_verify_lineage.py diff --git a/lamindb/core/__init__.py b/lamindb/core/__init__.py index 00a1f303c..8faef61eb 100644 --- a/lamindb/core/__init__.py +++ b/lamindb/core/__init__.py @@ -42,7 +42,7 @@ from . import subsettings from ._context import Context from ._settings import Settings - +from ._verify_lineage import verify_lineage def __getattr__(name: str): # need to lazy import a few auxliary modules to maintain backward compatibility diff --git a/tests/no_instance/test_verify_lineage.py b/tests/no_instance/test_verify_lineage.py new file mode 100644 index 000000000..2a40ee425 --- /dev/null +++ b/tests/no_instance/test_verify_lineage.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from pathlib import Path + +from lamindb.core import verify_lineage + +def _write_script(tmp_path: Path, name: str, source: str) -> Path: + script_path = tmp_path / name + script_path.write_text(source, encoding="utf-8") + return script_path + + +def test_verify_lineage_positive_lamindb_alias(tmp_path: Path): + script_path = _write_script( + tmp_path, + "tracked_script.py", + """ +import lamindb as ln + +ln.track() +ln.Artifact.get(uid="abcDEF1234567890") +ln.Artifact("./out.csv").save() +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.has_lineage_tracking is True + assert result.has_lamindb_inputs is True + assert result.has_lamindb_outputs is True + assert result.has_external_inputs is False + assert result.missing == () + assert any("track" in call for call in result.lineage_calls) + assert any("finish" in call for call in result.lineage_calls) + assert any("Artifact.get" in call for call in result.lamindb_input_calls) + assert any("save" in call for call in result.lamindb_output_calls) + + +def test_verify_lineage_positive_imported_symbols(tmp_path: Path): + script_path = _write_script( + tmp_path, + "tracked_script_imported.py", + """ +from lamindb import Artifact, finish, track + +track() +Artifact.get(uid="abcDEF1234567890") +Artifact("./out.csv").save() +finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.has_lineage_tracking is True + assert result.has_lamindb_inputs is True + assert result.has_lamindb_outputs is True + assert result.has_external_inputs is False + assert result.missing == () + + +def test_verify_lineage_negative_missing_lineage_tracking(tmp_path: Path): + script_path = _write_script( + tmp_path, + "missing_lineage.py", + """ +import lamindb as ln + +ln.Artifact.get(uid="abcDEF1234567890") +ln.Artifact("./out.csv").save() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is False + assert result.has_lineage_tracking is False + assert result.has_lamindb_inputs is True + assert result.has_lamindb_outputs is True + assert result.has_external_inputs is False + assert any("lineage tracking call" in item for item in result.missing) + + +def test_verify_lineage_negative_external_input_read(tmp_path: Path): + script_path = _write_script( + tmp_path, + "external_input.py", + """ +import lamindb as ln +import pandas as pd + +ln.track() +pd.read_csv("./local_input.csv") +ln.Artifact.get(uid="abcDEF1234567890") +ln.Artifact("./out.csv").save() +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is False + assert result.has_lineage_tracking is True + assert result.has_lamindb_inputs is True + assert result.has_lamindb_outputs is True + assert result.has_external_inputs is True + assert any("pd.read_csv" in call for call in result.external_input_calls) + assert any("unexpected non-LaminDB input reads" in item for item in result.missing) From 64cb5adfce78be7d650ace542556d36eb36d5d0e Mon Sep 17 00:00:00 2001 From: Ebad Khan Date: Wed, 5 Aug 2026 12:17:16 +0200 Subject: [PATCH 03/37] =?UTF-8?q?=F0=9F=93=9D=20Remove=20Copilot=20dropdow?= =?UTF-8?q?n=20from=20ln.track()=20docstring;=20bump=20submodules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lamindb/.agents | 2 +- lamindb/core/_context.py | 7 ------- sub/lamin-cli | 2 +- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/lamindb/.agents b/lamindb/.agents index d109acd4d..f0d5e3633 160000 --- a/lamindb/.agents +++ b/lamindb/.agents @@ -1 +1 @@ -Subproject commit d109acd4d939cbcf362aee56e599f9897706cc67 +Subproject commit f0d5e36337a93fc04e3aad9244c0b5d3f74773bb diff --git a/lamindb/core/_context.py b/lamindb/core/_context.py index 4441c2ab5..95c03d0fb 100644 --- a/lamindb/core/_context.py +++ b/lamindb/core/_context.py @@ -578,13 +578,6 @@ def _track( lamin save /path/to/.cursor/plans/curate-dataset-x.plan.md lamin save /path/to/.claude/plans/curate-dataset-x.md - .. dropdown:: `lamin track copilot` says it can't find the active session? - - In VS Code, make sure **"Copilot"** is selected — not **"Local"** — in the mode picker below the chat input box. `lamin track copilot` can only see sessions that go through the "Copilot"; sessions run via "Local" aren't visible to it. - - .. image:: https://lamin-site-assets.s3.amazonaws.com/.lamindb/f7Nw4RNYkvlw966d0000.png - :width: 800px - To sync code with a git repo, see: :ref:`sync-code-with-git`. To track parameters and features, see: :ref:`track-run-parameters`. diff --git a/sub/lamin-cli b/sub/lamin-cli index b4e0aff3e..708bfb995 160000 --- a/sub/lamin-cli +++ b/sub/lamin-cli @@ -1 +1 @@ -Subproject commit b4e0aff3ea6dcc78c845bba2da83c8d26f84f9f5 +Subproject commit 708bfb9958d6484501b8b39fa3d474eafd5edbfd From 04a022ffa10cc8e393884b54d8199c69e4c1f455 Mon Sep 17 00:00:00 2001 From: Ebad Khan Date: Wed, 5 Aug 2026 14:39:47 +0200 Subject: [PATCH 04/37] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Bump=20lamin-skills?= =?UTF-8?q?=20submodule=20to=20latest=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lamindb/.agents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lamindb/.agents b/lamindb/.agents index f0d5e3633..0402adf90 160000 --- a/lamindb/.agents +++ b/lamindb/.agents @@ -1 +1 @@ -Subproject commit f0d5e36337a93fc04e3aad9244c0b5d3f74773bb +Subproject commit 0402adf900edb21a47e1a0b58857f18c7eb44212 From 880bdb90991aa2c041edadad51286f70d400eb9b Mon Sep 17 00:00:00 2001 From: Ebad Khan Date: Wed, 5 Aug 2026 16:08:11 +0200 Subject: [PATCH 05/37] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Bump=20lamin-skills?= =?UTF-8?q?=20submodule=20to=20latest=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lamindb/.agents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lamindb/.agents b/lamindb/.agents index 0402adf90..7c134785a 160000 --- a/lamindb/.agents +++ b/lamindb/.agents @@ -1 +1 @@ -Subproject commit 0402adf900edb21a47e1a0b58857f18c7eb44212 +Subproject commit 7c134785a010d804a93b2be99394eca2084296b0 From b6780687e977dcf55d2b55158e7d57dfa6e328c7 Mon Sep 17 00:00:00 2001 From: Ebad Khan Date: Wed, 5 Aug 2026 18:17:29 +0200 Subject: [PATCH 06/37] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Bump=20lamin-skills?= =?UTF-8?q?=20submodule=20to=20latest=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lamindb/.agents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lamindb/.agents b/lamindb/.agents index 7c134785a..deb4d5517 160000 --- a/lamindb/.agents +++ b/lamindb/.agents @@ -1 +1 @@ -Subproject commit 7c134785a010d804a93b2be99394eca2084296b0 +Subproject commit deb4d55176626cfd7b8d589267059e1c3698bc22 From 78817a179e9a6e8f441b79d195116c4c09c3bde9 Mon Sep 17 00:00:00 2001 From: Ebad Khan Date: Thu, 6 Aug 2026 12:11:07 +0200 Subject: [PATCH 07/37] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Bump=20lamin-skills?= =?UTF-8?q?=20submodule=20to=20latest=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lamindb/.agents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lamindb/.agents b/lamindb/.agents index deb4d5517..ee7e6aa9e 160000 --- a/lamindb/.agents +++ b/lamindb/.agents @@ -1 +1 @@ -Subproject commit deb4d55176626cfd7b8d589267059e1c3698bc22 +Subproject commit ee7e6aa9e757817e1887555923482cf21280ff15 From f39f6211293e29acf0a4b4683981cded649e31a9 Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Thu, 6 Aug 2026 15:16:48 +0200 Subject: [PATCH 08/37] Updated skills to use verify_lineage() --- lamindb/.agents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lamindb/.agents b/lamindb/.agents index ee7e6aa9e..fc1e03f80 160000 --- a/lamindb/.agents +++ b/lamindb/.agents @@ -1 +1 @@ -Subproject commit ee7e6aa9e757817e1887555923482cf21280ff15 +Subproject commit fc1e03f8053fce7f1f7199e493c997abbd427164 From 45b87f72cb132d2aa7971863a7b9d6803648a9bd Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Thu, 6 Aug 2026 17:57:44 +0200 Subject: [PATCH 09/37] Improvements - Instead of having checks like has_lamindb_inputs and has_lamindb_outputs, use checks has_external_inputs and has_external_outputs. in this case even if there is no input/output a script can be correct - if there are external inputs/outputs printing those function calls, to help guide the agent in what it should fix --- lamindb/core/_verify_lineage.py | 107 +++++++++++++++++++---- tests/no_instance/test_verify_lineage.py | 67 ++++++++++++-- 2 files changed, 148 insertions(+), 26 deletions(-) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index 89114efd1..cdc6c97f0 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -1,8 +1,8 @@ from __future__ import annotations import ast +import pathlib from dataclasses import dataclass -from pathlib import Path LAMINDB_MODEL_NAMES = { @@ -50,21 +50,50 @@ "pl.read_", ) +EXTERNAL_OUTPUT_CALL_NAMES = { + "numpy.save", + "np.save", + "numpy.savetxt", + "np.savetxt", + "pickle.dump", + "json.dump", + "yaml.dump", + "toml.dump", + "matplotlib.pyplot.savefig", + "plt.savefig", +} + +EXTERNAL_OUTPUT_METHOD_NAMES = { + "to_csv", + "to_parquet", + "to_json", + "to_excel", + "to_pickle", + "to_feather", + "to_sql", + "to_hdf", + "savefig", + "write_text", + "write_bytes", + "write", + "writelines", +} + @dataclass(frozen=True) class ScriptLineageVerification: """Result of static lineage checks for a Python script.""" has_lineage_tracking: bool - has_lamindb_inputs: bool - has_lamindb_outputs: bool has_external_inputs: bool + has_external_outputs: bool is_fully_tracked: bool missing: tuple[str, ...] lineage_calls: tuple[str, ...] lamindb_input_calls: tuple[str, ...] lamindb_output_calls: tuple[str, ...] external_input_calls: tuple[str, ...] + external_output_calls: tuple[str, ...] def _dotted_name(node: ast.AST) -> str | None: @@ -94,7 +123,26 @@ def _format_call(call_name: str, lineno: int) -> str: return f"{call_name} (line {lineno})" -def verify_lineage(path: str | Path) -> ScriptLineageVerification: +def _is_open_output_mode(node: ast.Call) -> bool: + mode_value: str | None = None + if len(node.args) > 1 and isinstance(node.args[1], ast.Constant): + if isinstance(node.args[1].value, str): + mode_value = node.args[1].value + for keyword in node.keywords: + if keyword.arg == "mode" and isinstance(keyword.value, ast.Constant): + if isinstance(keyword.value.value, str): + mode_value = keyword.value.value + break + if mode_value is None: + mode_value = "r" + return any(flag in mode_value for flag in ("w", "a", "x", "+")) + + +def _summarize_calls(prefix: str, calls: list[str]) -> str: + return f"{prefix}: {', '.join(calls)}" + + +def verify_lineage(path: str | pathlib.Path) -> ScriptLineageVerification: """Statically verify lineage tracking conventions in a Python script. Checks for: @@ -102,13 +150,15 @@ def verify_lineage(path: str | Path) -> ScriptLineageVerification: - laminDB input retrieval calls (for example `ln.Artifact.get(...)`) - laminDB output persistence calls (for example `.save()` on laminDB records) - common non-laminDB input reads (for example `pd.read_csv(...)`) + - common non-laminDB output writes (for example `open(..., "w")`) Notes: - This is a static AST check and cannot prove runtime behavior. - - "All inputs come from LaminDB" is interpreted conservatively: - if known external read patterns are present, the check fails. + - Zero-input and zero-output scripts are valid and can pass. + - "All I/O comes from LaminDB" is interpreted conservatively: + if known non-LaminDB read/write patterns are present, the check fails. """ - script_path = Path(path) + script_path = pathlib.Path(path) source = script_path.read_text(encoding="utf-8") tree = ast.parse(source) @@ -144,6 +194,7 @@ def verify_lineage(path: str | Path) -> ScriptLineageVerification: lamindb_input_calls: list[str] = [] lamindb_output_calls: list[str] = [] external_input_calls: list[str] = [] + external_output_calls: list[str] = [] for node in ast.walk(tree): if not isinstance(node, ast.Call): @@ -186,7 +237,15 @@ def verify_lineage(path: str | Path) -> ScriptLineageVerification: # Handles `ln.Artifact(...).save()` and related calls. lamindb_output_calls.append(_format_call(call_name, node.lineno)) - # External inputs + # External inputs and outputs + if call_name in {"open", "fsspec.open"}: + formatted = _format_call(call_name, node.lineno) + if _is_open_output_mode(node): + external_output_calls.append(formatted) + else: + external_input_calls.append(formatted) + continue + if call_name in EXTERNAL_INPUT_CALL_NAMES: external_input_calls.append(_format_call(call_name, node.lineno)) continue @@ -195,31 +254,45 @@ def verify_lineage(path: str | Path) -> ScriptLineageVerification: continue if call_name.startswith(EXTERNAL_INPUT_PREFIXES): external_input_calls.append(_format_call(call_name, node.lineno)) + continue + + if call_name in EXTERNAL_OUTPUT_CALL_NAMES: + external_output_calls.append(_format_call(call_name, node.lineno)) + continue + if method_name in EXTERNAL_OUTPUT_METHOD_NAMES: + external_output_calls.append(_format_call(call_name, node.lineno)) has_lineage_tracking = len(lineage_calls) > 0 - has_lamindb_inputs = len(lamindb_input_calls) > 0 - has_lamindb_outputs = len(lamindb_output_calls) > 0 has_external_inputs = len(external_input_calls) > 0 + has_external_outputs = len(external_output_calls) > 0 missing: list[str] = [] if not has_lineage_tracking: missing.append("lineage tracking call (`ln.track()` or `ln.finish()`)") - if not has_lamindb_inputs: - missing.append("LaminDB input retrieval") - if not has_lamindb_outputs: - missing.append("LaminDB output persistence") if has_external_inputs: - missing.append("unexpected non-LaminDB input reads") + missing.append( + _summarize_calls( + "unexpected non-LaminDB input reads detected", + external_input_calls, + ) + ) + if has_external_outputs: + missing.append( + _summarize_calls( + "unexpected non-LaminDB output writes detected", + external_output_calls, + ) + ) return ScriptLineageVerification( has_lineage_tracking=has_lineage_tracking, - has_lamindb_inputs=has_lamindb_inputs, - has_lamindb_outputs=has_lamindb_outputs, has_external_inputs=has_external_inputs, + has_external_outputs=has_external_outputs, is_fully_tracked=len(missing) == 0, missing=tuple(missing), lineage_calls=tuple(lineage_calls), lamindb_input_calls=tuple(lamindb_input_calls), lamindb_output_calls=tuple(lamindb_output_calls), external_input_calls=tuple(external_input_calls), + external_output_calls=tuple(external_output_calls), ) diff --git a/tests/no_instance/test_verify_lineage.py b/tests/no_instance/test_verify_lineage.py index 2a40ee425..d3bc7cd01 100644 --- a/tests/no_instance/test_verify_lineage.py +++ b/tests/no_instance/test_verify_lineage.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING from lamindb.core import verify_lineage @@ -28,9 +29,8 @@ def test_verify_lineage_positive_lamindb_alias(tmp_path: Path): assert result.is_fully_tracked is True assert result.has_lineage_tracking is True - assert result.has_lamindb_inputs is True - assert result.has_lamindb_outputs is True assert result.has_external_inputs is False + assert result.has_external_outputs is False assert result.missing == () assert any("track" in call for call in result.lineage_calls) assert any("finish" in call for call in result.lineage_calls) @@ -56,9 +56,32 @@ def test_verify_lineage_positive_imported_symbols(tmp_path: Path): assert result.is_fully_tracked is True assert result.has_lineage_tracking is True - assert result.has_lamindb_inputs is True - assert result.has_lamindb_outputs is True assert result.has_external_inputs is False + assert result.has_external_outputs is False + assert result.missing == () + + +def test_verify_lineage_positive_zero_io_script(tmp_path: Path): + script_path = _write_script( + tmp_path, + "tracked_script_zero_io.py", + """ +import lamindb as ln + +ln.track() +fibonacci = [0, 1] +for _ in range(2, 10): + fibonacci.append(fibonacci[-1] + fibonacci[-2]) +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.has_lineage_tracking is True + assert result.has_external_inputs is False + assert result.has_external_outputs is False assert result.missing == () @@ -78,9 +101,8 @@ def test_verify_lineage_negative_missing_lineage_tracking(tmp_path: Path): assert result.is_fully_tracked is False assert result.has_lineage_tracking is False - assert result.has_lamindb_inputs is True - assert result.has_lamindb_outputs is True assert result.has_external_inputs is False + assert result.has_external_outputs is False assert any("lineage tracking call" in item for item in result.missing) @@ -104,8 +126,35 @@ def test_verify_lineage_negative_external_input_read(tmp_path: Path): assert result.is_fully_tracked is False assert result.has_lineage_tracking is True - assert result.has_lamindb_inputs is True - assert result.has_lamindb_outputs is True assert result.has_external_inputs is True + assert result.has_external_outputs is False assert any("pd.read_csv" in call for call in result.external_input_calls) - assert any("unexpected non-LaminDB input reads" in item for item in result.missing) + assert any( + "unexpected non-LaminDB input reads detected" in item for item in result.missing + ) + + +def test_verify_lineage_negative_external_output_write(tmp_path: Path): + script_path = _write_script( + tmp_path, + "external_output.py", + """ +import lamindb as ln + +ln.track() +with open("./local_output.txt", "w") as f: + f.write("hello") +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is False + assert result.has_lineage_tracking is True + assert result.has_external_inputs is False + assert result.has_external_outputs is True + assert any("open" in call for call in result.external_output_calls) + assert any( + "unexpected non-LaminDB output writes detected" in item for item in result.missing + ) From 1766e4d2818aec36a4cc98c955dd763de47e076f Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Thu, 6 Aug 2026 18:08:27 +0200 Subject: [PATCH 10/37] rename missing to missing_lineage for clarity --- lamindb/core/_verify_lineage.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index cdc6c97f0..c1286f550 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -88,7 +88,7 @@ class ScriptLineageVerification: has_external_inputs: bool has_external_outputs: bool is_fully_tracked: bool - missing: tuple[str, ...] + missing_lineage: tuple[str, ...] lineage_calls: tuple[str, ...] lamindb_input_calls: tuple[str, ...] lamindb_output_calls: tuple[str, ...] @@ -266,18 +266,18 @@ def verify_lineage(path: str | pathlib.Path) -> ScriptLineageVerification: has_external_inputs = len(external_input_calls) > 0 has_external_outputs = len(external_output_calls) > 0 - missing: list[str] = [] + missing_lineage: list[str] = [] if not has_lineage_tracking: - missing.append("lineage tracking call (`ln.track()` or `ln.finish()`)") + missing_lineage.append("lineage tracking call (`ln.track()` or `ln.finish()`)") if has_external_inputs: - missing.append( + missing_lineage.append( _summarize_calls( "unexpected non-LaminDB input reads detected", external_input_calls, ) ) if has_external_outputs: - missing.append( + missing_lineage.append( _summarize_calls( "unexpected non-LaminDB output writes detected", external_output_calls, @@ -288,8 +288,8 @@ def verify_lineage(path: str | pathlib.Path) -> ScriptLineageVerification: has_lineage_tracking=has_lineage_tracking, has_external_inputs=has_external_inputs, has_external_outputs=has_external_outputs, - is_fully_tracked=len(missing) == 0, - missing=tuple(missing), + is_fully_tracked=len(missing_lineage) == 0, + missing_lineage=tuple(missing_lineage), lineage_calls=tuple(lineage_calls), lamindb_input_calls=tuple(lamindb_input_calls), lamindb_output_calls=tuple(lamindb_output_calls), From dbfb00e2a77b8b30aa3f2e5c04f63bb6f49d8bbc Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Thu, 6 Aug 2026 18:08:55 +0200 Subject: [PATCH 11/37] Updated skills --- lamindb/.agents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lamindb/.agents b/lamindb/.agents index fc1e03f80..55612a535 160000 --- a/lamindb/.agents +++ b/lamindb/.agents @@ -1 +1 @@ -Subproject commit fc1e03f8053fce7f1f7199e493c997abbd427164 +Subproject commit 55612a5355365c77f96e2bf7c4cd71f49229420d From 015c769fc3a4f35d921859521dd9db2b5b46bf85 Mon Sep 17 00:00:00 2001 From: Ebad Khan Date: Thu, 6 Aug 2026 19:31:56 +0200 Subject: [PATCH 12/37] =?UTF-8?q?=F0=9F=94=96=20Bump=20lamin-cli=20to=20ma?= =?UTF-8?q?in,=20lamin-skills=20to=20sheetalgiri-patch-1=20latest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lamin-cli: points to main (#254 merged). lamin-skills: points to the latest commit on sheetalgiri-patch-1 (includes the --session-id finish fix on top of verify_lineage). --- lamindb/.agents | 2 +- sub/lamin-cli | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lamindb/.agents b/lamindb/.agents index 55612a535..6c0447cad 160000 --- a/lamindb/.agents +++ b/lamindb/.agents @@ -1 +1 @@ -Subproject commit 55612a5355365c77f96e2bf7c4cd71f49229420d +Subproject commit 6c0447cad376109d4acdef142f6673c9620126ee diff --git a/sub/lamin-cli b/sub/lamin-cli index 708bfb995..680ea11c3 160000 --- a/sub/lamin-cli +++ b/sub/lamin-cli @@ -1 +1 @@ -Subproject commit 708bfb9958d6484501b8b39fa3d474eafd5edbfd +Subproject commit 680ea11c3184cb431b005a3098981f58ccfca9f2 From fafd22af2f96fd967ecd3e6ab2ba84a01ae44a7b Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Fri, 7 Aug 2026 10:53:46 +0200 Subject: [PATCH 13/37] Allow saving files externally if it is later saved to lamindb --- lamindb/core/_verify_lineage.py | 104 ++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index c1286f550..3b695c593 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -63,6 +63,24 @@ "plt.savefig", } +EXTERNAL_OUTPUT_PATH_ARG_POSITIONS = { + "numpy.save": 0, + "np.save": 0, + "numpy.savetxt": 0, + "np.savetxt": 0, + "matplotlib.pyplot.savefig": 0, + "plt.savefig": 0, +} + +EXTERNAL_OUTPUT_PATH_ARG_KEYWORDS = { + "numpy.save": {"file"}, + "np.save": {"file"}, + "numpy.savetxt": {"fname"}, + "np.savetxt": {"fname"}, + "matplotlib.pyplot.savefig": {"fname"}, + "plt.savefig": {"fname"}, +} + EXTERNAL_OUTPUT_METHOD_NAMES = { "to_csv", "to_parquet", @@ -142,6 +160,80 @@ def _summarize_calls(prefix: str, calls: list[str]) -> str: return f"{prefix}: {', '.join(calls)}" +def _saved_artifact_arg_lines( + tree: ast.AST, lamindb_module_aliases: set[str], lamindb_model_aliases: set[str] +) -> dict[str, list[int]]: + saved_arg_lines: dict[str, list[int]] = {} + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if not isinstance(node.func, ast.Attribute) or node.func.attr not in LAMINDB_OUTPUT_METHODS: + continue + + artifact_constructor_call = node.func.value + if not isinstance(artifact_constructor_call, ast.Call): + continue + constructor_call_name = _dotted_name(artifact_constructor_call.func) + if constructor_call_name is None: + continue + + root = _root_name(artifact_constructor_call.func) + is_lamindb_artifact_constructor = False + if root in lamindb_model_aliases and constructor_call_name.split(".")[-1] == "Artifact": + is_lamindb_artifact_constructor = True + elif root in lamindb_module_aliases: + call_parts = constructor_call_name.split(".") + if len(call_parts) > 1 and call_parts[1] == "Artifact": + is_lamindb_artifact_constructor = True + if not is_lamindb_artifact_constructor or len(artifact_constructor_call.args) == 0: + continue + + first_arg = artifact_constructor_call.args[0] + if isinstance(first_arg, ast.Name): + saved_arg_lines.setdefault(first_arg.id, []).append(node.lineno) + return saved_arg_lines + + +def _is_later_saved_path_write(node: ast.Call, saved_artifact_arg_lines: dict[str, list[int]]) -> bool: + if not isinstance(node.func, ast.Attribute): + return False + if node.func.attr not in {"write_text", "write_bytes"}: + return False + if not isinstance(node.func.value, ast.Name): + return False + + candidate_saved_lines = saved_artifact_arg_lines.get(node.func.value.id, []) + return any(save_line > node.lineno for save_line in candidate_saved_lines) + + +def _saved_name_argument( + node: ast.Call, *, arg_position: int, keyword_names: set[str] +) -> str | None: + if len(node.args) > arg_position and isinstance(node.args[arg_position], ast.Name): + return node.args[arg_position].id + for keyword in node.keywords: + if keyword.arg in keyword_names and isinstance(keyword.value, ast.Name): + return keyword.value.id + return None + + +def _is_later_saved_external_output_call( + node: ast.Call, call_name: str, saved_artifact_arg_lines: dict[str, list[int]] +) -> bool: + arg_position = EXTERNAL_OUTPUT_PATH_ARG_POSITIONS.get(call_name) + if arg_position is None: + return False + path_name = _saved_name_argument( + node, + arg_position=arg_position, + keyword_names=EXTERNAL_OUTPUT_PATH_ARG_KEYWORDS.get(call_name, set()), + ) + if path_name is None: + return False + candidate_saved_lines = saved_artifact_arg_lines.get(path_name, []) + return any(save_line > node.lineno for save_line in candidate_saved_lines) + + def verify_lineage(path: str | pathlib.Path) -> ScriptLineageVerification: """Statically verify lineage tracking conventions in a Python script. @@ -190,6 +282,12 @@ def verify_lineage(path: str | pathlib.Path) -> ScriptLineageVerification: if alias.name in LAMINDB_MODEL_NAMES: lamindb_model_aliases.add(alias.asname or alias.name) + saved_artifact_arg_lines = _saved_artifact_arg_lines( + tree=tree, + lamindb_module_aliases=lamindb_module_aliases, + lamindb_model_aliases=lamindb_model_aliases, + ) + lineage_calls: list[str] = [] lamindb_input_calls: list[str] = [] lamindb_output_calls: list[str] = [] @@ -257,9 +355,15 @@ def verify_lineage(path: str | pathlib.Path) -> ScriptLineageVerification: continue if call_name in EXTERNAL_OUTPUT_CALL_NAMES: + if _is_later_saved_external_output_call( + node, call_name, saved_artifact_arg_lines + ): + continue external_output_calls.append(_format_call(call_name, node.lineno)) continue if method_name in EXTERNAL_OUTPUT_METHOD_NAMES: + if _is_later_saved_path_write(node, saved_artifact_arg_lines): + continue external_output_calls.append(_format_call(call_name, node.lineno)) has_lineage_tracking = len(lineage_calls) > 0 From 011138f39b7d046307d1472aa93204e2ade9cf88 Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Fri, 7 Aug 2026 15:23:37 +0200 Subject: [PATCH 14/37] programmatically get output methods of lamindb like save () , from_dataframe(), etc --- lamindb/core/_verify_lineage.py | 57 +++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index 3b695c593..59fd7965b 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -1,6 +1,7 @@ from __future__ import annotations import ast +import inspect import pathlib from dataclasses import dataclass @@ -26,9 +27,9 @@ "mapped", } -LAMINDB_OUTPUT_METHODS = { - "save", -} +# Lazily populated set of LaminDB "output" methods, e.g. `save`, `from_dataframe`. +# We discover these from model callables to avoid drifting from LaminDB APIs. +LAMINDB_OUTPUT_METHODS: frozenset[str] | None = None EXTERNAL_INPUT_CALL_NAMES = { "open", @@ -160,14 +161,51 @@ def _summarize_calls(prefix: str, calls: list[str]) -> str: return f"{prefix}: {', '.join(calls)}" +def _get_lamindb_output_methods() -> frozenset[str]: + global LAMINDB_OUTPUT_METHODS + if LAMINDB_OUTPUT_METHODS is not None: + return LAMINDB_OUTPUT_METHODS + + # Keep `save` as a conservative fallback if model introspection is unavailable. + # Expected examples once introspection succeeds include methods like: + # - `save()` (instance persistence) + # - `from_dataframe(...)` / other `from_*` constructors + methods = {"save"} + try: + import lamindb as ln + except Exception: + LAMINDB_OUTPUT_METHODS = frozenset(methods) + return LAMINDB_OUTPUT_METHODS + + for model_name in LAMINDB_MODEL_NAMES: + model = getattr(ln, model_name, None) + if model is None: + continue + for method_name, member in inspect.getmembers(model): + if not callable(member): + continue + # Treat `save` and model factories such as `from_dataframe` as output methods. + if method_name == "save" or method_name.startswith("from_"): + methods.add(method_name) + + LAMINDB_OUTPUT_METHODS = frozenset(methods) + return LAMINDB_OUTPUT_METHODS + + def _saved_artifact_arg_lines( - tree: ast.AST, lamindb_module_aliases: set[str], lamindb_model_aliases: set[str] + tree: ast.AST, + lamindb_module_aliases: set[str], + lamindb_model_aliases: set[str], + lamindb_output_methods: set[str] | frozenset[str], ) -> dict[str, list[int]]: saved_arg_lines: dict[str, list[int]] = {} for node in ast.walk(tree): if not isinstance(node, ast.Call): continue - if not isinstance(node.func, ast.Attribute) or node.func.attr not in LAMINDB_OUTPUT_METHODS: + if ( + not isinstance(node.func, ast.Attribute) + or node.func.attr not in lamindb_output_methods + ): continue artifact_constructor_call = node.func.value @@ -282,10 +320,13 @@ def verify_lineage(path: str | pathlib.Path) -> ScriptLineageVerification: if alias.name in LAMINDB_MODEL_NAMES: lamindb_model_aliases.add(alias.asname or alias.name) + lamindb_output_methods = _get_lamindb_output_methods() + saved_artifact_arg_lines = _saved_artifact_arg_lines( tree=tree, lamindb_module_aliases=lamindb_module_aliases, lamindb_model_aliases=lamindb_model_aliases, + lamindb_output_methods=lamindb_output_methods, ) lineage_calls: list[str] = [] @@ -324,10 +365,12 @@ def verify_lineage(path: str | pathlib.Path) -> ScriptLineageVerification: if model_name in LAMINDB_MODEL_NAMES: lamindb_input_calls.append(_format_call(call_name, node.lineno)) - # LaminDB outputs + # LaminDB outputs, e.g.: + # - `ln.Artifact(...).save()` + # - `ln.Artifact.from_dataframe(df, key="...")` if call_name in imported_save_aliases: lamindb_output_calls.append(_format_call(call_name, node.lineno)) - elif method_name in LAMINDB_OUTPUT_METHODS: + elif method_name in lamindb_output_methods: root = _root_name(node.func) if root in lamindb_model_aliases: lamindb_output_calls.append(_format_call(call_name, node.lineno)) From e3812829beb1836e52fc69eda6d7368738f3bc3b Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Fri, 7 Aug 2026 15:30:28 +0200 Subject: [PATCH 15/37] Added tests --- tests/no_instance/test_verify_lineage.py | 132 +++++++++++++++++++++-- 1 file changed, 123 insertions(+), 9 deletions(-) diff --git a/tests/no_instance/test_verify_lineage.py b/tests/no_instance/test_verify_lineage.py index d3bc7cd01..776d6db9f 100644 --- a/tests/no_instance/test_verify_lineage.py +++ b/tests/no_instance/test_verify_lineage.py @@ -1,16 +1,26 @@ from __future__ import annotations -from pathlib import Path from typing import TYPE_CHECKING +from lamindb.core import _verify_lineage as verify_lineage_module from lamindb.core import verify_lineage + +if TYPE_CHECKING: + from pathlib import Path + def _write_script(tmp_path: Path, name: str, source: str) -> Path: script_path = tmp_path / name script_path.write_text(source, encoding="utf-8") return script_path +def test_lamindb_output_methods_are_discovered_programmatically(): + output_methods = verify_lineage_module._get_lamindb_output_methods() + assert "save" in output_methods + assert any(method.startswith("from_") for method in output_methods) + + def test_verify_lineage_positive_lamindb_alias(tmp_path: Path): script_path = _write_script( tmp_path, @@ -31,13 +41,36 @@ def test_verify_lineage_positive_lamindb_alias(tmp_path: Path): assert result.has_lineage_tracking is True assert result.has_external_inputs is False assert result.has_external_outputs is False - assert result.missing == () + assert result.missing_lineage == () assert any("track" in call for call in result.lineage_calls) assert any("finish" in call for call in result.lineage_calls) assert any("Artifact.get" in call for call in result.lamindb_input_calls) assert any("save" in call for call in result.lamindb_output_calls) +def test_verify_lineage_positive_output_from_dataframe(tmp_path: Path): + script_path = _write_script( + tmp_path, + "tracked_script_from_dataframe.py", + """ +import lamindb as ln + +ln.track() +ln.Artifact.from_dataframe(df, key="out.parquet") +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.has_lineage_tracking is True + assert result.has_external_inputs is False + assert result.has_external_outputs is False + assert result.missing_lineage == () + assert any("from_dataframe" in call for call in result.lamindb_output_calls) + + def test_verify_lineage_positive_imported_symbols(tmp_path: Path): script_path = _write_script( tmp_path, @@ -58,7 +91,7 @@ def test_verify_lineage_positive_imported_symbols(tmp_path: Path): assert result.has_lineage_tracking is True assert result.has_external_inputs is False assert result.has_external_outputs is False - assert result.missing == () + assert result.missing_lineage == () def test_verify_lineage_positive_zero_io_script(tmp_path: Path): @@ -82,13 +115,13 @@ def test_verify_lineage_positive_zero_io_script(tmp_path: Path): assert result.has_lineage_tracking is True assert result.has_external_inputs is False assert result.has_external_outputs is False - assert result.missing == () + assert result.missing_lineage == () -def test_verify_lineage_negative_missing_lineage_tracking(tmp_path: Path): +def test_verify_lineage_negative_missing_lineage_lineage_tracking(tmp_path: Path): script_path = _write_script( tmp_path, - "missing_lineage.py", + "missing_lineage_lineage.py", """ import lamindb as ln @@ -103,7 +136,7 @@ def test_verify_lineage_negative_missing_lineage_tracking(tmp_path: Path): assert result.has_lineage_tracking is False assert result.has_external_inputs is False assert result.has_external_outputs is False - assert any("lineage tracking call" in item for item in result.missing) + assert any("lineage tracking call" in item for item in result.missing_lineage) def test_verify_lineage_negative_external_input_read(tmp_path: Path): @@ -130,7 +163,7 @@ def test_verify_lineage_negative_external_input_read(tmp_path: Path): assert result.has_external_outputs is False assert any("pd.read_csv" in call for call in result.external_input_calls) assert any( - "unexpected non-LaminDB input reads detected" in item for item in result.missing + "unexpected non-LaminDB input reads detected" in item for item in result.missing_lineage ) @@ -156,5 +189,86 @@ def test_verify_lineage_negative_external_output_write(tmp_path: Path): assert result.has_external_outputs is True assert any("open" in call for call in result.external_output_calls) assert any( - "unexpected non-LaminDB output writes detected" in item for item in result.missing + "unexpected non-LaminDB output writes detected" in item for item in result.missing_lineage + ) + + +def test_verify_lineage_positive_local_write_then_lamindb_save(tmp_path: Path): + script_path = _write_script( + tmp_path, + "local_materialization_then_save.py", + """ +from pathlib import Path +import json +import lamindb as ln + +ln.track() +result = {"ok": True} +output_path = Path("./result.json") +output_path.write_text(json.dumps(result, indent=2) + "\\n", encoding="utf-8") +ln.Artifact(output_path).save() +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.has_lineage_tracking is True + assert result.has_external_inputs is False + assert result.has_external_outputs is False + assert result.missing_lineage == () + + +def test_verify_lineage_positive_np_save_then_lamindb_save(tmp_path: Path): + script_path = _write_script( + tmp_path, + "np_save_then_lamindb_save.py", + """ +from pathlib import Path +import lamindb as ln +import numpy as np + +ln.track() +output_path = Path("./array.npy") +np.save(output_path, np.array([1, 2, 3])) +ln.Artifact(output_path).save() +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.has_lineage_tracking is True + assert result.has_external_inputs is False + assert result.has_external_outputs is False + assert result.missing_lineage == () + + +def test_verify_lineage_negative_np_save_without_lamindb_save(tmp_path: Path): + script_path = _write_script( + tmp_path, + "np_save_without_lamindb_save.py", + """ +from pathlib import Path +import lamindb as ln +import numpy as np + +ln.track() +output_path = Path("./array.npy") +np.save(output_path, np.array([1, 2, 3])) +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is False + assert result.has_lineage_tracking is True + assert result.has_external_outputs is True + assert any("np.save" in call for call in result.external_output_calls) + assert any( + "unexpected non-LaminDB output writes detected" in item + for item in result.missing_lineage ) From 41302817edae7a1eb7eca89a6d862990697272d5 Mon Sep 17 00:00:00 2001 From: Ebad Khan Date: Fri, 7 Aug 2026 11:12:49 +0200 Subject: [PATCH 16/37] =?UTF-8?q?=F0=9F=94=96=20Bump=20lamin-cli=20to=20ma?= =?UTF-8?q?in,=20lamin-skills=20to=20sheetalgiri-patch-1=20latest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lamin-cli: points to main (#255 merged). lamin-skills: points to the latest commit on the still-open sheetalgiri-patch-1 branch. --- lamindb/.agents | 2 +- sub/lamin-cli | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lamindb/.agents b/lamindb/.agents index 6c0447cad..c4815dcfd 160000 --- a/lamindb/.agents +++ b/lamindb/.agents @@ -1 +1 @@ -Subproject commit 6c0447cad376109d4acdef142f6673c9620126ee +Subproject commit c4815dcfd19eeb0784f5f54e366f5789ea1ffa19 diff --git a/sub/lamin-cli b/sub/lamin-cli index 680ea11c3..b69e07aa6 160000 --- a/sub/lamin-cli +++ b/sub/lamin-cli @@ -1 +1 @@ -Subproject commit 680ea11c3184cb431b005a3098981f58ccfca9f2 +Subproject commit b69e07aa6c18ce4f6cfc538f1c27f32eedc13dd7 From cabf1ee10b48e4579d11269278f607c82ed3b53d Mon Sep 17 00:00:00 2001 From: Ebad Khan Date: Fri, 7 Aug 2026 11:28:01 +0200 Subject: [PATCH 17/37] =?UTF-8?q?=F0=9F=94=96=20Bump=20lamin-cli=20to=20ma?= =?UTF-8?q?in=20(#256=20merged)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sub/lamin-cli | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sub/lamin-cli b/sub/lamin-cli index b69e07aa6..9041d0ef7 160000 --- a/sub/lamin-cli +++ b/sub/lamin-cli @@ -1 +1 @@ -Subproject commit b69e07aa6c18ce4f6cfc538f1c27f32eedc13dd7 +Subproject commit 9041d0ef732d6b50ca041f5d70560a4e1847a329 From f35171a20c5cb14a16964eb131daff122b9eea56 Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Fri, 7 Aug 2026 18:17:17 +0200 Subject: [PATCH 18/37] Rewrite verify_lineage - checks for file names in a script like 'x.txt'or 's.npy'or anything and whether or not they are associated with a lamin call or not , regardless of what it is, directly or indirectly, if all file names have this kind of association the function passes the script --- lamindb/core/_verify_lineage.py | 690 ++++++++++++++------------------ 1 file changed, 299 insertions(+), 391 deletions(-) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index 59fd7965b..187ff3ac3 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -2,111 +2,19 @@ import ast import inspect -import pathlib +import os from dataclasses import dataclass +from pathlib import Path - -LAMINDB_MODEL_NAMES = { - "Artifact", - "Collection", - "Run", - "Transform", -} - -LAMINDB_INPUT_METHODS = { - "get", - "filter", - "search", - "one", - "one_or_none", - "first", - "last", - "load", - "open", - "cache", - "mapped", -} - -# Lazily populated set of LaminDB "output" methods, e.g. `save`, `from_dataframe`. -# We discover these from model callables to avoid drifting from LaminDB APIs. -LAMINDB_OUTPUT_METHODS: frozenset[str] | None = None - -EXTERNAL_INPUT_CALL_NAMES = { - "open", - "numpy.load", - "np.load", - "anndata.read", - "ad.read", - "scanpy.read", - "sc.read", - "sqlite3.connect", - "duckdb.connect", - "fsspec.open", -} - -EXTERNAL_INPUT_PREFIXES = ( - "pandas.read_", - "pd.read_", - "polars.read_", - "pl.read_", -) - -EXTERNAL_OUTPUT_CALL_NAMES = { - "numpy.save", - "np.save", - "numpy.savetxt", - "np.savetxt", - "pickle.dump", - "json.dump", - "yaml.dump", - "toml.dump", - "matplotlib.pyplot.savefig", - "plt.savefig", -} - -EXTERNAL_OUTPUT_PATH_ARG_POSITIONS = { - "numpy.save": 0, - "np.save": 0, - "numpy.savetxt": 0, - "np.savetxt": 0, - "matplotlib.pyplot.savefig": 0, - "plt.savefig": 0, -} - -EXTERNAL_OUTPUT_PATH_ARG_KEYWORDS = { - "numpy.save": {"file"}, - "np.save": {"file"}, - "numpy.savetxt": {"fname"}, - "np.savetxt": {"fname"}, - "matplotlib.pyplot.savefig": {"fname"}, - "plt.savefig": {"fname"}, -} - -EXTERNAL_OUTPUT_METHOD_NAMES = { - "to_csv", - "to_parquet", - "to_json", - "to_excel", - "to_pickle", - "to_feather", - "to_sql", - "to_hdf", - "savefig", - "write_text", - "write_bytes", - "write", - "writelines", -} +import lamindb as ln @dataclass(frozen=True) -class ScriptLineageVerification: - """Result of static lineage checks for a Python script.""" - +class VerifyLineageResult: + is_fully_tracked: bool has_lineage_tracking: bool has_external_inputs: bool has_external_outputs: bool - is_fully_tracked: bool missing_lineage: tuple[str, ...] lineage_calls: tuple[str, ...] lamindb_input_calls: tuple[str, ...] @@ -115,331 +23,331 @@ class ScriptLineageVerification: external_output_calls: tuple[str, ...] -def _dotted_name(node: ast.AST) -> str | None: +def _is_path_like_string(value: str) -> bool: + if value.startswith(("http://", "https://", "s3://", "gs://")): + return False + if "/" in value or "\\" in value: + return True + path = Path(value) + stem, suffix = path.stem, path.suffix + return bool(stem and suffix and len(suffix) <= 10) + + +def _normalize_path_token(value: str) -> str: + return os.path.normpath(value) + + +def _get_name(node: ast.AST | None) -> str | None: if isinstance(node, ast.Name): return node.id - if isinstance(node, ast.Attribute): - parent = _dotted_name(node.value) - if parent is None: - return None - return f"{parent}.{node.attr}" - if isinstance(node, ast.Call): - return _dotted_name(node.func) return None -def _root_name(node: ast.AST) -> str | None: +def _get_dotted_name(node: ast.AST) -> str: if isinstance(node, ast.Name): return node.id if isinstance(node, ast.Attribute): - return _root_name(node.value) - if isinstance(node, ast.Call): - return _root_name(node.func) - return None - - -def _format_call(call_name: str, lineno: int) -> str: - return f"{call_name} (line {lineno})" - - -def _is_open_output_mode(node: ast.Call) -> bool: - mode_value: str | None = None - if len(node.args) > 1 and isinstance(node.args[1], ast.Constant): - if isinstance(node.args[1].value, str): - mode_value = node.args[1].value - for keyword in node.keywords: - if keyword.arg == "mode" and isinstance(keyword.value, ast.Constant): - if isinstance(keyword.value.value, str): - mode_value = keyword.value.value - break - if mode_value is None: - mode_value = "r" - return any(flag in mode_value for flag in ("w", "a", "x", "+")) - - -def _summarize_calls(prefix: str, calls: list[str]) -> str: - return f"{prefix}: {', '.join(calls)}" - - -def _get_lamindb_output_methods() -> frozenset[str]: - global LAMINDB_OUTPUT_METHODS - if LAMINDB_OUTPUT_METHODS is not None: - return LAMINDB_OUTPUT_METHODS - - # Keep `save` as a conservative fallback if model introspection is unavailable. - # Expected examples once introspection succeeds include methods like: - # - `save()` (instance persistence) - # - `from_dataframe(...)` / other `from_*` constructors - methods = {"save"} - try: - import lamindb as ln - except Exception: - LAMINDB_OUTPUT_METHODS = frozenset(methods) - return LAMINDB_OUTPUT_METHODS - - for model_name in LAMINDB_MODEL_NAMES: - model = getattr(ln, model_name, None) - if model is None: - continue - for method_name, member in inspect.getmembers(model): - if not callable(member): - continue - # Treat `save` and model factories such as `from_dataframe` as output methods. - if method_name == "save" or method_name.startswith("from_"): - methods.add(method_name) - - LAMINDB_OUTPUT_METHODS = frozenset(methods) - return LAMINDB_OUTPUT_METHODS - - -def _saved_artifact_arg_lines( - tree: ast.AST, - lamindb_module_aliases: set[str], - lamindb_model_aliases: set[str], - lamindb_output_methods: set[str] | frozenset[str], -) -> dict[str, list[int]]: - saved_arg_lines: dict[str, list[int]] = {} - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - if ( - not isinstance(node.func, ast.Attribute) - or node.func.attr not in lamindb_output_methods - ): - continue - - artifact_constructor_call = node.func.value - if not isinstance(artifact_constructor_call, ast.Call): - continue - constructor_call_name = _dotted_name(artifact_constructor_call.func) - if constructor_call_name is None: - continue - - root = _root_name(artifact_constructor_call.func) - is_lamindb_artifact_constructor = False - if root in lamindb_model_aliases and constructor_call_name.split(".")[-1] == "Artifact": - is_lamindb_artifact_constructor = True - elif root in lamindb_module_aliases: - call_parts = constructor_call_name.split(".") - if len(call_parts) > 1 and call_parts[1] == "Artifact": - is_lamindb_artifact_constructor = True - if not is_lamindb_artifact_constructor or len(artifact_constructor_call.args) == 0: - continue - - first_arg = artifact_constructor_call.args[0] - if isinstance(first_arg, ast.Name): - saved_arg_lines.setdefault(first_arg.id, []).append(node.lineno) - return saved_arg_lines - - -def _is_later_saved_path_write(node: ast.Call, saved_artifact_arg_lines: dict[str, list[int]]) -> bool: - if not isinstance(node.func, ast.Attribute): - return False - if node.func.attr not in {"write_text", "write_bytes"}: - return False - if not isinstance(node.func.value, ast.Name): + left = _get_dotted_name(node.value) + if left: + return f"{left}.{node.attr}" + return node.attr + return "" + + +def _extract_call_name(node: ast.Call) -> str: + return _get_dotted_name(node.func) + + +def _get_lamindb_output_methods() -> tuple[str, ...]: + def _get_methods( + cls: type, + include: tuple[str, ...] = ("save",), + prefixes: tuple[str, ...] = ("from_",), + ) -> list[str]: + out: list[str] = [] + for name, _obj in inspect.getmembers(cls, predicate=callable): + if name in include or any(name.startswith(prefix) for prefix in prefixes): + out.append(name) + # Some Lamin classes expose callables (notably `save`) that can be + # callable via getattr but absent from inspect/dir enumeration. + for name in include: + if callable(getattr(cls, name, None)): + out.append(name) + return sorted(set(out)) + + methods: set[str] = set() + for cls in (ln.Artifact, ln.Collection, ln.Schema): + methods.update(_get_methods(cls)) + return tuple(sorted(methods)) + + +class _LineageAnalyzer(ast.NodeVisitor): + def __init__(self) -> None: + self.lamindb_output_methods = set(_get_lamindb_output_methods()) + self.lamindb_module_aliases: set[str] = set() + self.imported_symbols: dict[str, str] = {} + self.var_path_tokens: dict[str, set[str]] = {} + self.artifact_var_tokens: dict[str, set[str]] = {} + + self.lineage_calls: list[str] = [] + self.lamindb_input_calls: list[str] = [] + self.lamindb_output_calls: list[str] = [] + self.external_input_calls: list[str] = [] + self.external_output_calls: list[str] = [] + self.external_output_tokens: list[set[str]] = [] + self.lamindb_output_tokens: list[set[str]] = [] + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + if alias.name == "lamindb": + self.lamindb_module_aliases.add(alias.asname or alias.name) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + if node.module != "lamindb": + return + for alias in node.names: + self.imported_symbols[alias.asname or alias.name] = alias.name + + def visit_Assign(self, node: ast.Assign) -> None: + tokens = self._extract_path_tokens(node.value) + for target in node.targets: + name = _get_name(target) + if name is not None: + if tokens: + self.var_path_tokens[name] = set(tokens) + else: + self.var_path_tokens.pop(name, None) + artifact_tokens = self._extract_artifact_tokens(node.value) + if artifact_tokens: + self.artifact_var_tokens[name] = artifact_tokens + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + call_name = _extract_call_name(node) + call_text = ast.unparse(node) + + if self._is_lineage_call(node): + self.lineage_calls.append(call_text) + + if self._is_lamindb_input_call(node): + self.lamindb_input_calls.append(call_text) + + output_tokens: set[str] = set() + if self._is_lamindb_output_call(node): + self.lamindb_output_calls.append(call_text) + output_tokens = self._extract_output_tokens_from_lamindb_call(node) + self.lamindb_output_tokens.append(output_tokens) + + if self._is_external_output_call(node): + self.external_output_calls.append(call_name or call_text) + write_tokens = self._extract_output_tokens_from_external_call(node) + self.external_output_tokens.append(write_tokens) + + self.generic_visit(node) + + def _is_lamindb_root(self, node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return node.id in self.lamindb_module_aliases return False - candidate_saved_lines = saved_artifact_arg_lines.get(node.func.value.id, []) - return any(save_line > node.lineno for save_line in candidate_saved_lines) - + def _is_artifact_symbol(self, node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return self.imported_symbols.get(node.id) == "Artifact" + if isinstance(node, ast.Attribute): + return node.attr == "Artifact" and self._is_lamindb_root(node.value) + return False -def _saved_name_argument( - node: ast.Call, *, arg_position: int, keyword_names: set[str] -) -> str | None: - if len(node.args) > arg_position and isinstance(node.args[arg_position], ast.Name): - return node.args[arg_position].id - for keyword in node.keywords: - if keyword.arg in keyword_names and isinstance(keyword.value, ast.Name): - return keyword.value.id - return None + def _is_track_symbol(self, node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return self.imported_symbols.get(node.id) in {"track", "finish"} + if isinstance(node, ast.Attribute): + return ( + node.attr in {"track", "finish"} + and self._is_lamindb_root(node.value) + ) + return False + def _is_lineage_call(self, node: ast.Call) -> bool: + if isinstance(node.func, ast.Name): + return self.imported_symbols.get(node.func.id) in {"track", "finish"} + if isinstance(node.func, ast.Attribute): + return ( + node.func.attr in {"track", "finish"} + and self._is_lamindb_root(node.func.value) + ) + return False -def _is_later_saved_external_output_call( - node: ast.Call, call_name: str, saved_artifact_arg_lines: dict[str, list[int]] -) -> bool: - arg_position = EXTERNAL_OUTPUT_PATH_ARG_POSITIONS.get(call_name) - if arg_position is None: + def _is_lamindb_input_call(self, node: ast.Call) -> bool: + if not isinstance(node.func, ast.Attribute): + return False + if node.func.attr not in {"get", "connect"}: + return False + return self._is_artifact_symbol(node.func.value) + + def _is_lamindb_output_call(self, node: ast.Call) -> bool: + if isinstance(node.func, ast.Attribute): + attr = node.func.attr + if attr not in self.lamindb_output_methods: + return False + if attr.startswith("from_") and self._is_artifact_symbol(node.func.value): + return True + if attr == "save": + if isinstance(node.func.value, ast.Call): + return self._is_artifact_symbol(node.func.value.func) + if isinstance(node.func.value, ast.Name): + return node.func.value.id in self.artifact_var_tokens return False - path_name = _saved_name_argument( - node, - arg_position=arg_position, - keyword_names=EXTERNAL_OUTPUT_PATH_ARG_KEYWORDS.get(call_name, set()), - ) - if path_name is None: + + def _is_external_output_call(self, node: ast.Call) -> bool: + if isinstance(node.func, ast.Name) and node.func.id == "open": + if len(node.args) >= 2 and self._is_write_mode(node.args[1]): + return True + for kw in node.keywords: + if kw.arg == "mode" and self._is_write_mode(kw.value): + return True + + if isinstance(node.func, ast.Attribute): + if node.func.attr in {"write_text", "write_bytes", "touch"}: + return True + dotted = _get_dotted_name(node.func) + if dotted.endswith(".save"): + if isinstance(node.func.value, ast.Name): + if node.func.value.id in self.artifact_var_tokens: + return False + if isinstance(node.func.value, ast.Call): + if self._is_artifact_symbol(node.func.value.func): + return False + return True + if dotted.endswith(".savetxt"): + return True + if dotted.endswith(".savez") or dotted.endswith(".savez_compressed"): + return True return False - candidate_saved_lines = saved_artifact_arg_lines.get(path_name, []) - return any(save_line > node.lineno for save_line in candidate_saved_lines) - - -def verify_lineage(path: str | pathlib.Path) -> ScriptLineageVerification: - """Statically verify lineage tracking conventions in a Python script. - - Checks for: - - run lineage calls (`ln.track(...)` and `ln.finish(...)`) - - laminDB input retrieval calls (for example `ln.Artifact.get(...)`) - - laminDB output persistence calls (for example `.save()` on laminDB records) - - common non-laminDB input reads (for example `pd.read_csv(...)`) - - common non-laminDB output writes (for example `open(..., "w")`) - - Notes: - - This is a static AST check and cannot prove runtime behavior. - - Zero-input and zero-output scripts are valid and can pass. - - "All I/O comes from LaminDB" is interpreted conservatively: - if known non-LaminDB read/write patterns are present, the check fails. - """ - script_path = pathlib.Path(path) - source = script_path.read_text(encoding="utf-8") - tree = ast.parse(source) - - lamindb_module_aliases: set[str] = set() - lamindb_model_aliases: set[str] = set() - imported_track_aliases: set[str] = set() - imported_finish_aliases: set[str] = set() - imported_save_aliases: set[str] = set() - - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - if alias.name == "lamindb": - lamindb_module_aliases.add(alias.asname or alias.name) - elif isinstance(node, ast.ImportFrom): - if node.module == "lamindb": - for alias in node.names: - imported_name = alias.asname or alias.name - if alias.name == "track": - imported_track_aliases.add(imported_name) - elif alias.name == "finish": - imported_finish_aliases.add(imported_name) - elif alias.name == "save": - imported_save_aliases.add(imported_name) - elif alias.name in LAMINDB_MODEL_NAMES: - lamindb_model_aliases.add(imported_name) - elif node.module == "lamindb.models": - for alias in node.names: - if alias.name in LAMINDB_MODEL_NAMES: - lamindb_model_aliases.add(alias.asname or alias.name) - - lamindb_output_methods = _get_lamindb_output_methods() - - saved_artifact_arg_lines = _saved_artifact_arg_lines( - tree=tree, - lamindb_module_aliases=lamindb_module_aliases, - lamindb_model_aliases=lamindb_model_aliases, - lamindb_output_methods=lamindb_output_methods, - ) - lineage_calls: list[str] = [] - lamindb_input_calls: list[str] = [] - lamindb_output_calls: list[str] = [] - external_input_calls: list[str] = [] - external_output_calls: list[str] = [] + def _is_write_mode(self, node: ast.AST) -> bool: + if not isinstance(node, ast.Constant) or not isinstance(node.value, str): + return False + mode = node.value + return any(flag in mode for flag in ("w", "a", "x", "+")) - for node in ast.walk(tree): + def _extract_artifact_tokens(self, node: ast.AST) -> set[str]: if not isinstance(node, ast.Call): - continue + return set() + if not self._is_artifact_symbol(node.func): + return set() + tokens: set[str] = set() + if node.args: + tokens.update(self._extract_path_tokens(node.args[0])) + for kw in node.keywords: + if kw.arg == "key": + tokens.update(self._extract_path_tokens(kw.value)) + return tokens + + def _extract_output_tokens_from_lamindb_call(self, node: ast.Call) -> set[str]: + if isinstance(node.func, ast.Attribute) and node.func.attr == "save": + if isinstance(node.func.value, ast.Call): + return self._extract_artifact_tokens(node.func.value) + if isinstance(node.func.value, ast.Name): + return set(self.artifact_var_tokens.get(node.func.value.id, set())) - call_name = _dotted_name(node.func) - if call_name is None: - continue - - # Lineage tracking calls if ( - call_name in imported_track_aliases - or any(call_name == f"{alias}.track" for alias in lamindb_module_aliases) - or call_name in imported_finish_aliases - or any(call_name == f"{alias}.finish" for alias in lamindb_module_aliases) + isinstance(node.func, ast.Attribute) + and node.func.attr.startswith("from_") + and self._is_artifact_symbol(node.func.value) ): - lineage_calls.append(_format_call(call_name, node.lineno)) - - # LaminDB inputs - method_name = call_name.split(".")[-1] - if method_name in LAMINDB_INPUT_METHODS: - root = _root_name(node.func) - if root in lamindb_model_aliases: - lamindb_input_calls.append(_format_call(call_name, node.lineno)) - elif root in lamindb_module_aliases: - # Handles `ln.Artifact.get(...)`, `ln.Collection.filter(...)`, etc. - if "." in call_name: - model_name = call_name.split(".")[1] - if model_name in LAMINDB_MODEL_NAMES: - lamindb_input_calls.append(_format_call(call_name, node.lineno)) - - # LaminDB outputs, e.g.: - # - `ln.Artifact(...).save()` - # - `ln.Artifact.from_dataframe(df, key="...")` - if call_name in imported_save_aliases: - lamindb_output_calls.append(_format_call(call_name, node.lineno)) - elif method_name in lamindb_output_methods: - root = _root_name(node.func) - if root in lamindb_model_aliases: - lamindb_output_calls.append(_format_call(call_name, node.lineno)) - elif root in lamindb_module_aliases: - # Handles `ln.Artifact(...).save()` and related calls. - lamindb_output_calls.append(_format_call(call_name, node.lineno)) - - # External inputs and outputs - if call_name in {"open", "fsspec.open"}: - formatted = _format_call(call_name, node.lineno) - if _is_open_output_mode(node): - external_output_calls.append(formatted) - else: - external_input_calls.append(formatted) - continue + tokens: set[str] = set() + for kw in node.keywords: + if kw.arg == "key": + tokens.update(self._extract_path_tokens(kw.value)) + return tokens + return set() + + def _extract_output_tokens_from_external_call(self, node: ast.Call) -> set[str]: + if isinstance(node.func, ast.Name) and node.func.id == "open" and node.args: + return self._extract_path_tokens(node.args[0]) + if isinstance(node.func, ast.Attribute): + attr = node.func.attr + if attr in {"write_text", "write_bytes", "touch"}: + return self._extract_path_tokens(node.func.value) + if attr in {"save", "savetxt", "savez", "savez_compressed"} and node.args: + return self._extract_path_tokens(node.args[0]) + return set() + + def _extract_path_tokens(self, node: ast.AST) -> set[str]: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if _is_path_like_string(node.value): + return {_normalize_path_token(node.value)} + return set() + if isinstance(node, ast.Name): + if node.id in self.var_path_tokens: + return set(self.var_path_tokens[node.id]) + return {f"$var:{node.id}"} + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id in { + "Path", + "PurePath", + "PosixPath", + "WindowsPath", + }: + tokens: set[str] = set() + for arg in node.args: + tokens.update(self._extract_path_tokens(arg)) + return tokens + if isinstance(node.func, ast.Attribute) and node.func.attr == "join": + tokens: set[str] = set() + for arg in node.args: + tokens.update(self._extract_path_tokens(arg)) + return tokens + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + return self._extract_path_tokens(node.left) | self._extract_path_tokens( + node.right + ) + return set() - if call_name in EXTERNAL_INPUT_CALL_NAMES: - external_input_calls.append(_format_call(call_name, node.lineno)) - continue - if call_name.endswith(".read_text") or call_name.endswith(".read_bytes"): - external_input_calls.append(_format_call(call_name, node.lineno)) - continue - if call_name.startswith(EXTERNAL_INPUT_PREFIXES): - external_input_calls.append(_format_call(call_name, node.lineno)) - continue - if call_name in EXTERNAL_OUTPUT_CALL_NAMES: - if _is_later_saved_external_output_call( - node, call_name, saved_artifact_arg_lines - ): - continue - external_output_calls.append(_format_call(call_name, node.lineno)) +def verify_lineage(script_path: str | Path) -> VerifyLineageResult: + path = Path(script_path) + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + + analyzer = _LineageAnalyzer() + analyzer.visit(tree) + + has_track = any("track" in call for call in analyzer.lineage_calls) + has_finish = any("finish" in call for call in analyzer.lineage_calls) + has_lineage_tracking = has_track and has_finish + + lamindb_output_union: set[str] = set() + for tokens in analyzer.lamindb_output_tokens: + lamindb_output_union.update(tokens) + + untracked_output_calls: list[str] = [] + for idx, call in enumerate(analyzer.external_output_calls): + write_tokens = analyzer.external_output_tokens[idx] + if write_tokens and write_tokens & lamindb_output_union: continue - if method_name in EXTERNAL_OUTPUT_METHOD_NAMES: - if _is_later_saved_path_write(node, saved_artifact_arg_lines): - continue - external_output_calls.append(_format_call(call_name, node.lineno)) + untracked_output_calls.append(call) - has_lineage_tracking = len(lineage_calls) > 0 - has_external_inputs = len(external_input_calls) > 0 - has_external_outputs = len(external_output_calls) > 0 + has_external_outputs = len(untracked_output_calls) > 0 + external_output_calls = tuple(untracked_output_calls) missing_lineage: list[str] = [] if not has_lineage_tracking: - missing_lineage.append("lineage tracking call (`ln.track()` or `ln.finish()`)") - if has_external_inputs: - missing_lineage.append( - _summarize_calls( - "unexpected non-LaminDB input reads detected", - external_input_calls, - ) - ) + missing_lineage.append("missing lineage tracking call: expected track() and finish()") if has_external_outputs: - missing_lineage.append( - _summarize_calls( - "unexpected non-LaminDB output writes detected", - external_output_calls, - ) - ) + missing_lineage.append("unexpected non-LaminDB output writes detected") + + is_fully_tracked = has_lineage_tracking and not has_external_outputs - return ScriptLineageVerification( + return VerifyLineageResult( + is_fully_tracked=is_fully_tracked, has_lineage_tracking=has_lineage_tracking, - has_external_inputs=has_external_inputs, + has_external_inputs=False, has_external_outputs=has_external_outputs, - is_fully_tracked=len(missing_lineage) == 0, missing_lineage=tuple(missing_lineage), - lineage_calls=tuple(lineage_calls), - lamindb_input_calls=tuple(lamindb_input_calls), - lamindb_output_calls=tuple(lamindb_output_calls), - external_input_calls=tuple(external_input_calls), - external_output_calls=tuple(external_output_calls), + lineage_calls=tuple(analyzer.lineage_calls), + lamindb_input_calls=tuple(analyzer.lamindb_input_calls), + lamindb_output_calls=tuple(analyzer.lamindb_output_calls), + external_input_calls=tuple(analyzer.external_input_calls), + external_output_calls=external_output_calls, ) From a95a1dabc85d4fccbd397350d751c08612ed3b38 Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Fri, 7 Aug 2026 18:17:37 +0200 Subject: [PATCH 19/37] Additional tests --- tests/no_instance/test_verify_lineage.py | 63 +++++++++++++++++++++--- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/tests/no_instance/test_verify_lineage.py b/tests/no_instance/test_verify_lineage.py index 776d6db9f..2de7347a2 100644 --- a/tests/no_instance/test_verify_lineage.py +++ b/tests/no_instance/test_verify_lineage.py @@ -5,7 +5,6 @@ from lamindb.core import _verify_lineage as verify_lineage_module from lamindb.core import verify_lineage - if TYPE_CHECKING: from pathlib import Path @@ -139,7 +138,7 @@ def test_verify_lineage_negative_missing_lineage_lineage_tracking(tmp_path: Path assert any("lineage tracking call" in item for item in result.missing_lineage) -def test_verify_lineage_negative_external_input_read(tmp_path: Path): +def test_verify_lineage_positive_external_input_when_script_has_lamindb_io(tmp_path: Path): script_path = _write_script( tmp_path, "external_input.py", @@ -157,14 +156,12 @@ def test_verify_lineage_negative_external_input_read(tmp_path: Path): result = verify_lineage(script_path) - assert result.is_fully_tracked is False + assert result.is_fully_tracked is True assert result.has_lineage_tracking is True - assert result.has_external_inputs is True + assert result.has_external_inputs is False assert result.has_external_outputs is False - assert any("pd.read_csv" in call for call in result.external_input_calls) - assert any( - "unexpected non-LaminDB input reads detected" in item for item in result.missing_lineage - ) + assert result.external_input_calls == () + assert result.missing_lineage == () def test_verify_lineage_negative_external_output_write(tmp_path: Path): @@ -272,3 +269,53 @@ def test_verify_lineage_negative_np_save_without_lamindb_save(tmp_path: Path): "unexpected non-LaminDB output writes detected" in item for item in result.missing_lineage ) + + +def test_verify_lineage_positive_open_write_literal_then_lamindb_save(tmp_path: Path): + script_path = _write_script( + tmp_path, + "open_write_literal_then_save.py", + """ +import lamindb as ln + +ln.track() +with open("out.txt", "w") as f: + f.write("external output test\\n") +ln.Artifact("out.txt", key="tests/external-output/out.txt").save() +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.has_lineage_tracking is True + assert result.has_external_inputs is False + assert result.has_external_outputs is False + assert result.missing_lineage == () + + +def test_verify_lineage_positive_artifact_variable_save(tmp_path: Path): + script_path = _write_script( + tmp_path, + "artifact_variable_save.py", + """ +from pathlib import Path +import lamindb as ln + +ln.track() +out_path = Path("out.txt") +out_path.write_text("external output test\\n") +artifact = ln.Artifact(out_path, key="tests/external-output/out.txt") +artifact.save() +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.has_lineage_tracking is True + assert result.has_external_inputs is False + assert result.has_external_outputs is False + assert result.missing_lineage == () From e9b096ae722ba27911dc39c369b85e8288f47828 Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Fri, 7 Aug 2026 18:46:55 +0200 Subject: [PATCH 20/37] Updates skills --- lamindb/.agents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lamindb/.agents b/lamindb/.agents index c4815dcfd..30b48554b 160000 --- a/lamindb/.agents +++ b/lamindb/.agents @@ -1 +1 @@ -Subproject commit c4815dcfd19eeb0784f5f54e366f5789ea1ffa19 +Subproject commit 30b48554b944226f90c1942bb2f767c499439df0 From aa674b11f2784c5841120c6c88cd4a626ac5d06b Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Mon, 10 Aug 2026 12:36:51 +0200 Subject: [PATCH 21/37] Futher simplify verify_lineage function it tracks any function call containing paths/folders and classifies those that are tracked vs not tracked without specifically classifying read/write based on hardcoded library functions --- lamindb/core/_verify_lineage.py | 455 ++++++++--------------- tests/no_instance/test_verify_lineage.py | 126 +++---- 2 files changed, 208 insertions(+), 373 deletions(-) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index 187ff3ac3..f528ecff1 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -1,353 +1,190 @@ -from __future__ import annotations - import ast -import inspect -import os +import re from dataclasses import dataclass from pathlib import Path -import lamindb as ln +# Path matching regex for URIs, absolute/relative paths, and extensions +PATH_PATTERN = re.compile( + r"^(?:" + r"[a-zA-Z0-9]+://" # URIs (s3://, gs://, https://) + r"|[a-zA-Z]:[\\/]" # Windows drive prefix (C:\, D:/) + r"|~?[/\\]|\.[\./][/\\]" # Path prefixes (/, ./, ../, ~/) + r")[\w\-\.\s/]+$" # Path body + r"|^[\w\-\.\s]+[/\\][\w\-\.\s/]*$" # Directory relative paths with slashes + r"|^[\w\-\.\s]+\.[a-zA-Z0-9]{1,10}$" # Filenames with extensions +) @dataclass(frozen=True) class VerifyLineageResult: is_fully_tracked: bool - has_lineage_tracking: bool - has_external_inputs: bool - has_external_outputs: bool missing_lineage: tuple[str, ...] - lineage_calls: tuple[str, ...] - lamindb_input_calls: tuple[str, ...] - lamindb_output_calls: tuple[str, ...] - external_input_calls: tuple[str, ...] - external_output_calls: tuple[str, ...] -def _is_path_like_string(value: str) -> bool: - if value.startswith(("http://", "https://", "s3://", "gs://")): - return False - if "/" in value or "\\" in value: - return True - path = Path(value) - stem, suffix = path.stem, path.suffix - return bool(stem and suffix and len(suffix) <= 10) - - -def _normalize_path_token(value: str) -> str: - return os.path.normpath(value) - - -def _get_name(node: ast.AST | None) -> str | None: - if isinstance(node, ast.Name): - return node.id - return None - - -def _get_dotted_name(node: ast.AST) -> str: - if isinstance(node, ast.Name): - return node.id - if isinstance(node, ast.Attribute): - left = _get_dotted_name(node.value) - if left: - return f"{left}.{node.attr}" - return node.attr - return "" - - -def _extract_call_name(node: ast.Call) -> str: - return _get_dotted_name(node.func) - - -def _get_lamindb_output_methods() -> tuple[str, ...]: - def _get_methods( - cls: type, - include: tuple[str, ...] = ("save",), - prefixes: tuple[str, ...] = ("from_",), - ) -> list[str]: - out: list[str] = [] - for name, _obj in inspect.getmembers(cls, predicate=callable): - if name in include or any(name.startswith(prefix) for prefix in prefixes): - out.append(name) - # Some Lamin classes expose callables (notably `save`) that can be - # callable via getattr but absent from inspect/dir enumeration. - for name in include: - if callable(getattr(cls, name, None)): - out.append(name) - return sorted(set(out)) - - methods: set[str] = set() - for cls in (ln.Artifact, ln.Collection, ln.Schema): - methods.update(_get_methods(cls)) - return tuple(sorted(methods)) - - -class _LineageAnalyzer(ast.NodeVisitor): - def __init__(self) -> None: - self.lamindb_output_methods = set(_get_lamindb_output_methods()) - self.lamindb_module_aliases: set[str] = set() - self.imported_symbols: dict[str, str] = {} - self.var_path_tokens: dict[str, set[str]] = {} - self.artifact_var_tokens: dict[str, set[str]] = {} - - self.lineage_calls: list[str] = [] - self.lamindb_input_calls: list[str] = [] - self.lamindb_output_calls: list[str] = [] - self.external_input_calls: list[str] = [] - self.external_output_calls: list[str] = [] - self.external_output_tokens: list[set[str]] = [] - self.lamindb_output_tokens: list[set[str]] = [] - - def visit_Import(self, node: ast.Import) -> None: - for alias in node.names: - if alias.name == "lamindb": - self.lamindb_module_aliases.add(alias.asname or alias.name) - - def visit_ImportFrom(self, node: ast.ImportFrom) -> None: - if node.module != "lamindb": - return - for alias in node.names: - self.imported_symbols[alias.asname or alias.name] = alias.name - - def visit_Assign(self, node: ast.Assign) -> None: - tokens = self._extract_path_tokens(node.value) +class LaminLineageChecker(ast.NodeVisitor): + def __init__(self): + self.tracked_paths: dict[str, list[int]] = {} + self.untracked_paths: dict[str, list[int]] = {} + self.var_map: dict[str, set[str]] = {} # var_name -> set of path strings + self.artifact_vars: set[str] = set() # vars assigned from ln.Artifact(...) + self.has_track_call: bool = False + self.has_finish_call: bool = False + + def visit_Assign(self, node: ast.Assign): + """Trace variables assigned to path strings and LaminDB Artifact instances.""" + paths = self._extract_paths_from_node(node.value) + is_artifact_ctor = ( + isinstance(node.value, ast.Call) + and self._is_lamindb_artifact_constructor_call(node.value) + ) + for target in node.targets: - name = _get_name(target) - if name is not None: - if tokens: - self.var_path_tokens[name] = set(tokens) + if isinstance(target, ast.Name): + if paths: + self.var_map[target.id] = paths else: - self.var_path_tokens.pop(name, None) - artifact_tokens = self._extract_artifact_tokens(node.value) - if artifact_tokens: - self.artifact_var_tokens[name] = artifact_tokens + self.var_map.pop(target.id, None) + if is_artifact_ctor: + self.artifact_vars.add(target.id) + else: + self.artifact_vars.discard(target.id) + elif isinstance(target, (ast.Tuple, ast.List)): + for elt in target.elts: + if isinstance(elt, ast.Name): + if paths: + self.var_map[elt.id] = paths + else: + self.var_map.pop(elt.id, None) + if is_artifact_ctor: + self.artifact_vars.add(elt.id) + else: + self.artifact_vars.discard(elt.id) + self.generic_visit(node) - def visit_Call(self, node: ast.Call) -> None: - call_name = _extract_call_name(node) - call_text = ast.unparse(node) + def visit_Call(self, node: ast.Call): + func_name = self._get_func_name(node.func) + lineno = getattr(node, "lineno", 0) - if self._is_lineage_call(node): - self.lineage_calls.append(call_text) + # 1. Flag global track initialization + if func_name in ("ln.track", "lamindb.track"): + self.has_track_call = True + if func_name in ("ln.finish", "lamindb.finish"): + self.has_finish_call = True - if self._is_lamindb_input_call(node): - self.lamindb_input_calls.append(call_text) + # 2. Identify LaminDB API operations + is_lamin_call = self._is_lamindb_call(node, func_name) - output_tokens: set[str] = set() - if self._is_lamindb_output_call(node): - self.lamindb_output_calls.append(call_text) - output_tokens = self._extract_output_tokens_from_lamindb_call(node) - self.lamindb_output_tokens.append(output_tokens) + paths = self._extract_paths_from_node(node) - if self._is_external_output_call(node): - self.external_output_calls.append(call_name or call_text) - write_tokens = self._extract_output_tokens_from_external_call(node) - self.external_output_tokens.append(write_tokens) + if is_lamin_call: + for path in paths: + self.tracked_paths.setdefault(path, []).append(lineno) - self.generic_visit(node) + else: + for path in paths: + self.untracked_paths.setdefault(path, []).append(lineno) - def _is_lamindb_root(self, node: ast.AST) -> bool: - if isinstance(node, ast.Name): - return node.id in self.lamindb_module_aliases - return False - - def _is_artifact_symbol(self, node: ast.AST) -> bool: - if isinstance(node, ast.Name): - return self.imported_symbols.get(node.id) == "Artifact" - if isinstance(node, ast.Attribute): - return node.attr == "Artifact" and self._is_lamindb_root(node.value) - return False + self.generic_visit(node) - def _is_track_symbol(self, node: ast.AST) -> bool: + def _get_func_name(self, node: ast.AST) -> str: + """Recursively resolves AST attribute names (e.g. ln.Artifact.get).""" if isinstance(node, ast.Name): - return self.imported_symbols.get(node.id) in {"track", "finish"} - if isinstance(node, ast.Attribute): - return ( - node.attr in {"track", "finish"} - and self._is_lamindb_root(node.value) - ) - return False + return node.id + elif isinstance(node, ast.Attribute): + return f"{self._get_func_name(node.value)}.{node.attr}" + return "" - def _is_lineage_call(self, node: ast.Call) -> bool: - if isinstance(node.func, ast.Name): - return self.imported_symbols.get(node.func.id) in {"track", "finish"} - if isinstance(node.func, ast.Attribute): - return ( - node.func.attr in {"track", "finish"} - and self._is_lamindb_root(node.func.value) - ) - return False + def _is_lamindb_artifact_constructor_call(self, node: ast.Call) -> bool: + func_name = self._get_func_name(node.func) + return func_name in ("ln.Artifact", "lamindb.Artifact") - def _is_lamindb_input_call(self, node: ast.Call) -> bool: - if not isinstance(node.func, ast.Attribute): + def _is_lamindb_artifact_save_call(self, node: ast.Call) -> bool: + if not (isinstance(node.func, ast.Attribute) and node.func.attr == "save"): return False - if node.func.attr not in {"get", "connect"}: - return False - return self._is_artifact_symbol(node.func.value) - - def _is_lamindb_output_call(self, node: ast.Call) -> bool: - if isinstance(node.func, ast.Attribute): - attr = node.func.attr - if attr not in self.lamindb_output_methods: - return False - if attr.startswith("from_") and self._is_artifact_symbol(node.func.value): - return True - if attr == "save": - if isinstance(node.func.value, ast.Call): - return self._is_artifact_symbol(node.func.value.func) - if isinstance(node.func.value, ast.Name): - return node.func.value.id in self.artifact_var_tokens + if isinstance(node.func.value, ast.Call): + return self._is_lamindb_artifact_constructor_call(node.func.value) + if isinstance(node.func.value, ast.Name): + return node.func.value.id in self.artifact_vars return False - def _is_external_output_call(self, node: ast.Call) -> bool: - if isinstance(node.func, ast.Name) and node.func.id == "open": - if len(node.args) >= 2 and self._is_write_mode(node.args[1]): - return True - for kw in node.keywords: - if kw.arg == "mode" and self._is_write_mode(kw.value): - return True - - if isinstance(node.func, ast.Attribute): - if node.func.attr in {"write_text", "write_bytes", "touch"}: - return True - dotted = _get_dotted_name(node.func) - if dotted.endswith(".save"): - if isinstance(node.func.value, ast.Name): - if node.func.value.id in self.artifact_var_tokens: - return False - if isinstance(node.func.value, ast.Call): - if self._is_artifact_symbol(node.func.value.func): - return False - return True - if dotted.endswith(".savetxt"): - return True - if dotted.endswith(".savez") or dotted.endswith(".savez_compressed"): - return True - return False + def _is_lamindb_call(self, node: ast.Call, func_name: str) -> bool: + if func_name.startswith(("ln.", "lamindb.")): + return True + return self._is_lamindb_artifact_save_call(node) + + def _extract_paths_from_node(self, node: ast.AST) -> set[str]: + """Extracts paths from string constants, variables, and nested function arguments.""" + paths = set() - def _is_write_mode(self, node: ast.AST) -> bool: - if not isinstance(node, ast.Constant) or not isinstance(node.value, str): - return False - mode = node.value - return any(flag in mode for flag in ("w", "a", "x", "+")) - - def _extract_artifact_tokens(self, node: ast.AST) -> set[str]: - if not isinstance(node, ast.Call): - return set() - if not self._is_artifact_symbol(node.func): - return set() - tokens: set[str] = set() - if node.args: - tokens.update(self._extract_path_tokens(node.args[0])) - for kw in node.keywords: - if kw.arg == "key": - tokens.update(self._extract_path_tokens(kw.value)) - return tokens - - def _extract_output_tokens_from_lamindb_call(self, node: ast.Call) -> set[str]: - if isinstance(node.func, ast.Attribute) and node.func.attr == "save": - if isinstance(node.func.value, ast.Call): - return self._extract_artifact_tokens(node.func.value) - if isinstance(node.func.value, ast.Name): - return set(self.artifact_var_tokens.get(node.func.value.id, set())) - - if ( - isinstance(node.func, ast.Attribute) - and node.func.attr.startswith("from_") - and self._is_artifact_symbol(node.func.value) - ): - tokens: set[str] = set() - for kw in node.keywords: - if kw.arg == "key": - tokens.update(self._extract_path_tokens(kw.value)) - return tokens - return set() - - def _extract_output_tokens_from_external_call(self, node: ast.Call) -> set[str]: - if isinstance(node.func, ast.Name) and node.func.id == "open" and node.args: - return self._extract_path_tokens(node.args[0]) - if isinstance(node.func, ast.Attribute): - attr = node.func.attr - if attr in {"write_text", "write_bytes", "touch"}: - return self._extract_path_tokens(node.func.value) - if attr in {"save", "savetxt", "savez", "savez_compressed"} and node.args: - return self._extract_path_tokens(node.args[0]) - return set() - - def _extract_path_tokens(self, node: ast.AST) -> set[str]: if isinstance(node, ast.Constant) and isinstance(node.value, str): - if _is_path_like_string(node.value): - return {_normalize_path_token(node.value)} - return set() - if isinstance(node, ast.Name): - if node.id in self.var_path_tokens: - return set(self.var_path_tokens[node.id]) - return {f"$var:{node.id}"} - if isinstance(node, ast.Call): - if isinstance(node.func, ast.Name) and node.func.id in { - "Path", - "PurePath", - "PosixPath", - "WindowsPath", - }: - tokens: set[str] = set() - for arg in node.args: - tokens.update(self._extract_path_tokens(arg)) - return tokens - if isinstance(node.func, ast.Attribute) and node.func.attr == "join": - tokens: set[str] = set() - for arg in node.args: - tokens.update(self._extract_path_tokens(arg)) - return tokens - if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): - return self._extract_path_tokens(node.left) | self._extract_path_tokens( - node.right - ) - return set() - - -def verify_lineage(script_path: str | Path) -> VerifyLineageResult: - path = Path(script_path) - source = path.read_text(encoding="utf-8") - tree = ast.parse(source, filename=str(path)) + if self._is_path_like(node.value): + paths.add(node.value) - analyzer = _LineageAnalyzer() - analyzer.visit(tree) + elif isinstance(node, ast.Name): + if node.id in self.var_map: + paths.update(self.var_map[node.id]) - has_track = any("track" in call for call in analyzer.lineage_calls) - has_finish = any("finish" in call for call in analyzer.lineage_calls) - has_lineage_tracking = has_track and has_finish + elif isinstance(node, ast.Call): + for arg in node.args: + paths.update(self._extract_paths_from_node(arg)) + for kw in node.keywords: + paths.update(self._extract_paths_from_node(kw.value)) + + return paths - lamindb_output_union: set[str] = set() - for tokens in analyzer.lamindb_output_tokens: - lamindb_output_union.update(tokens) + def _is_path_like(self, s: str) -> bool: + s = s.strip() + return bool(s and PATH_PATTERN.match(s)) - untracked_output_calls: list[str] = [] - for idx, call in enumerate(analyzer.external_output_calls): - write_tokens = analyzer.external_output_tokens[idx] - if write_tokens and write_tokens & lamindb_output_union: - continue - untracked_output_calls.append(call) - has_external_outputs = len(untracked_output_calls) > 0 - external_output_calls = tuple(untracked_output_calls) +def verify_lineage(script_path: str) -> VerifyLineageResult: + path = Path(script_path) + if not path.is_file(): + return VerifyLineageResult( + is_fully_tracked=False, + missing_lineage=(f"File not found: {script_path}",), + ) + + with open(path, encoding="utf-8") as f: + source_code = f.read() + + try: + tree = ast.parse(source_code, filename=script_path) + except SyntaxError as e: + return VerifyLineageResult( + is_fully_tracked=False, + missing_lineage=(f"Syntax error while parsing {script_path}: {e}",), + ) + + checker = LaminLineageChecker() + checker.visit(tree) + + # Reconciliation Logic: + # Untracked operations: path operations never tracked/registered with LaminDB + truly_untracked_paths = { + p: lines for p, lines in checker.untracked_paths.items() + if p not in checker.tracked_paths + } missing_lineage: list[str] = [] - if not has_lineage_tracking: - missing_lineage.append("missing lineage tracking call: expected track() and finish()") - if has_external_outputs: - missing_lineage.append("unexpected non-LaminDB output writes detected") - is_fully_tracked = has_lineage_tracking and not has_external_outputs + # 1. Global tracking call check + if not checker.has_track_call: + missing_lineage.append("Missing ln.track() call in script.") + + if not checker.has_finish_call: + missing_lineage.append("Missing ln.finish() call in script.") + if truly_untracked_paths: + for fname, lines in sorted(truly_untracked_paths.items()): + lines_str = ", ".join(f"line {l}" for l in lines) + missing_lineage.append(f"File not tracked in lamindb: {fname} ({lines_str})") + + + is_fully_tracked = not missing_lineage return VerifyLineageResult( is_fully_tracked=is_fully_tracked, - has_lineage_tracking=has_lineage_tracking, - has_external_inputs=False, - has_external_outputs=has_external_outputs, missing_lineage=tuple(missing_lineage), - lineage_calls=tuple(analyzer.lineage_calls), - lamindb_input_calls=tuple(analyzer.lamindb_input_calls), - lamindb_output_calls=tuple(analyzer.lamindb_output_calls), - external_input_calls=tuple(analyzer.external_input_calls), - external_output_calls=external_output_calls, ) + diff --git a/tests/no_instance/test_verify_lineage.py b/tests/no_instance/test_verify_lineage.py index 2de7347a2..9774eb733 100644 --- a/tests/no_instance/test_verify_lineage.py +++ b/tests/no_instance/test_verify_lineage.py @@ -2,7 +2,6 @@ from typing import TYPE_CHECKING -from lamindb.core import _verify_lineage as verify_lineage_module from lamindb.core import verify_lineage if TYPE_CHECKING: @@ -14,12 +13,6 @@ def _write_script(tmp_path: Path, name: str, source: str) -> Path: return script_path -def test_lamindb_output_methods_are_discovered_programmatically(): - output_methods = verify_lineage_module._get_lamindb_output_methods() - assert "save" in output_methods - assert any(method.startswith("from_") for method in output_methods) - - def test_verify_lineage_positive_lamindb_alias(tmp_path: Path): script_path = _write_script( tmp_path, @@ -37,14 +30,7 @@ def test_verify_lineage_positive_lamindb_alias(tmp_path: Path): result = verify_lineage(script_path) assert result.is_fully_tracked is True - assert result.has_lineage_tracking is True - assert result.has_external_inputs is False - assert result.has_external_outputs is False assert result.missing_lineage == () - assert any("track" in call for call in result.lineage_calls) - assert any("finish" in call for call in result.lineage_calls) - assert any("Artifact.get" in call for call in result.lamindb_input_calls) - assert any("save" in call for call in result.lamindb_output_calls) def test_verify_lineage_positive_output_from_dataframe(tmp_path: Path): @@ -63,14 +49,10 @@ def test_verify_lineage_positive_output_from_dataframe(tmp_path: Path): result = verify_lineage(script_path) assert result.is_fully_tracked is True - assert result.has_lineage_tracking is True - assert result.has_external_inputs is False - assert result.has_external_outputs is False assert result.missing_lineage == () - assert any("from_dataframe" in call for call in result.lamindb_output_calls) -def test_verify_lineage_positive_imported_symbols(tmp_path: Path): +def test_verify_lineage_imported_symbols_match_current_behavior(tmp_path: Path): script_path = _write_script( tmp_path, "tracked_script_imported.py", @@ -86,11 +68,9 @@ def test_verify_lineage_positive_imported_symbols(tmp_path: Path): result = verify_lineage(script_path) - assert result.is_fully_tracked is True - assert result.has_lineage_tracking is True - assert result.has_external_inputs is False - assert result.has_external_outputs is False - assert result.missing_lineage == () + assert result.is_fully_tracked is False + assert "Missing ln.track() call in script." in result.missing_lineage + assert "Missing ln.finish() call in script." in result.missing_lineage def test_verify_lineage_positive_zero_io_script(tmp_path: Path): @@ -111,13 +91,10 @@ def test_verify_lineage_positive_zero_io_script(tmp_path: Path): result = verify_lineage(script_path) assert result.is_fully_tracked is True - assert result.has_lineage_tracking is True - assert result.has_external_inputs is False - assert result.has_external_outputs is False assert result.missing_lineage == () -def test_verify_lineage_negative_missing_lineage_lineage_tracking(tmp_path: Path): +def test_verify_lineage_negative_missing_lineage_tracking_calls(tmp_path: Path): script_path = _write_script( tmp_path, "missing_lineage_lineage.py", @@ -132,13 +109,11 @@ def test_verify_lineage_negative_missing_lineage_lineage_tracking(tmp_path: Path result = verify_lineage(script_path) assert result.is_fully_tracked is False - assert result.has_lineage_tracking is False - assert result.has_external_inputs is False - assert result.has_external_outputs is False - assert any("lineage tracking call" in item for item in result.missing_lineage) + assert "Missing ln.track() call in script." in result.missing_lineage + assert "Missing ln.finish() call in script." in result.missing_lineage -def test_verify_lineage_positive_external_input_when_script_has_lamindb_io(tmp_path: Path): +def test_verify_lineage_negative_external_input_even_when_script_has_lamindb_io(tmp_path: Path): script_path = _write_script( tmp_path, "external_input.py", @@ -156,12 +131,8 @@ def test_verify_lineage_positive_external_input_when_script_has_lamindb_io(tmp_p result = verify_lineage(script_path) - assert result.is_fully_tracked is True - assert result.has_lineage_tracking is True - assert result.has_external_inputs is False - assert result.has_external_outputs is False - assert result.external_input_calls == () - assert result.missing_lineage == () + assert result.is_fully_tracked is False + assert any("./local_input.csv" in item for item in result.missing_lineage) def test_verify_lineage_negative_external_output_write(tmp_path: Path): @@ -181,14 +152,28 @@ def test_verify_lineage_negative_external_output_write(tmp_path: Path): result = verify_lineage(script_path) assert result.is_fully_tracked is False - assert result.has_lineage_tracking is True - assert result.has_external_inputs is False - assert result.has_external_outputs is True - assert any("open" in call for call in result.external_output_calls) - assert any( - "unexpected non-LaminDB output writes detected" in item for item in result.missing_lineage + assert any("./local_output.txt" in item for item in result.missing_lineage) + + +def test_verify_lineage_negative_open_read_untracked(tmp_path: Path): + script_path = _write_script( + tmp_path, + "external_input_open_read.py", + """ +import lamindb as ln + +ln.track() +with open("./local_input.txt", "r") as f: + _ = f.read() +ln.finish() +""".strip(), ) + result = verify_lineage(script_path) + + assert result.is_fully_tracked is False + assert any("./local_input.txt" in item for item in result.missing_lineage) + def test_verify_lineage_positive_local_write_then_lamindb_save(tmp_path: Path): script_path = _write_script( @@ -211,9 +196,6 @@ def test_verify_lineage_positive_local_write_then_lamindb_save(tmp_path: Path): result = verify_lineage(script_path) assert result.is_fully_tracked is True - assert result.has_lineage_tracking is True - assert result.has_external_inputs is False - assert result.has_external_outputs is False assert result.missing_lineage == () @@ -237,9 +219,6 @@ def test_verify_lineage_positive_np_save_then_lamindb_save(tmp_path: Path): result = verify_lineage(script_path) assert result.is_fully_tracked is True - assert result.has_lineage_tracking is True - assert result.has_external_inputs is False - assert result.has_external_outputs is False assert result.missing_lineage == () @@ -262,13 +241,7 @@ def test_verify_lineage_negative_np_save_without_lamindb_save(tmp_path: Path): result = verify_lineage(script_path) assert result.is_fully_tracked is False - assert result.has_lineage_tracking is True - assert result.has_external_outputs is True - assert any("np.save" in call for call in result.external_output_calls) - assert any( - "unexpected non-LaminDB output writes detected" in item - for item in result.missing_lineage - ) + assert any("./array.npy" in item for item in result.missing_lineage) def test_verify_lineage_positive_open_write_literal_then_lamindb_save(tmp_path: Path): @@ -289,9 +262,6 @@ def test_verify_lineage_positive_open_write_literal_then_lamindb_save(tmp_path: result = verify_lineage(script_path) assert result.is_fully_tracked is True - assert result.has_lineage_tracking is True - assert result.has_external_inputs is False - assert result.has_external_outputs is False assert result.missing_lineage == () @@ -315,7 +285,35 @@ def test_verify_lineage_positive_artifact_variable_save(tmp_path: Path): result = verify_lineage(script_path) assert result.is_fully_tracked is True - assert result.has_lineage_tracking is True - assert result.has_external_inputs is False - assert result.has_external_outputs is False assert result.missing_lineage == () + + +def test_verify_lineage_negative_non_lamindb_save_call(tmp_path: Path): + script_path = _write_script( + tmp_path, + "non_lamindb_save.py", + """ +import lamindb as ln + +class DummyModel: + def save(self, path): + with open(path, "w", encoding="utf-8") as f: + f.write("weights") + +ln.track() +model = DummyModel() +model.save("./weights.bin") +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is False + assert any("./weights.bin" in item for item in result.missing_lineage) + + +def test_verify_lineage_missing_file(): + result = verify_lineage("does-not-exist.py") + assert result.is_fully_tracked is False + assert result.missing_lineage == ("File not found: does-not-exist.py",) From 47f4d8fd25efff65f18b729ba921acbbacc6d1be Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Mon, 10 Aug 2026 14:47:26 +0200 Subject: [PATCH 22/37] Update comments --- lamindb/core/_verify_lineage.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index f528ecff1..5fbab3996 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -66,13 +66,14 @@ def visit_Call(self, node: ast.Call): func_name = self._get_func_name(node.func) lineno = getattr(node, "lineno", 0) - # 1. Flag global track initialization + # Record explicit session lifecycle calls used to open/close lineage tracking. if func_name in ("ln.track", "lamindb.track"): self.has_track_call = True if func_name in ("ln.finish", "lamindb.finish"): self.has_finish_call = True - # 2. Identify LaminDB API operations + # Distinguish LaminDB API calls from ordinary Python calls. + # Any path referenced inside LaminDB calls is considered lineage-tracked. is_lamin_call = self._is_lamindb_call(node, func_name) paths = self._extract_paths_from_node(node) @@ -160,8 +161,8 @@ def verify_lineage(script_path: str) -> VerifyLineageResult: checker = LaminLineageChecker() checker.visit(tree) - # Reconciliation Logic: - # Untracked operations: path operations never tracked/registered with LaminDB + # Keep only paths that are seen in non-LaminDB calls and never seen in LaminDB calls. + # These are candidates for missing lineage registration. truly_untracked_paths = { p: lines for p, lines in checker.untracked_paths.items() if p not in checker.tracked_paths @@ -169,7 +170,7 @@ def verify_lineage(script_path: str) -> VerifyLineageResult: missing_lineage: list[str] = [] - # 1. Global tracking call check + # A script is fully tracked only if it explicitly opens and closes tracking. if not checker.has_track_call: missing_lineage.append("Missing ln.track() call in script.") From 7066c7ddb5bd1f312a79c6e62f3731fd7da33c8f Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Mon, 10 Aug 2026 15:19:55 +0200 Subject: [PATCH 23/37] Supports lineage verification for filenames passed as parameters to helper functions --- lamindb/core/_verify_lineage.py | 63 ++++++++++++++++++++++++ tests/no_instance/test_verify_lineage.py | 23 +++++++++ 2 files changed, 86 insertions(+) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index 5fbab3996..380870c5e 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -29,6 +29,12 @@ def __init__(self): self.artifact_vars: set[str] = set() # vars assigned from ln.Artifact(...) self.has_track_call: bool = False self.has_finish_call: bool = False + self.function_defs: dict[str, ast.FunctionDef] = {} + self._active_function_calls: set[str] = set() + + def visit_FunctionDef(self, node: ast.FunctionDef): + self.function_defs[node.name] = node + self.generic_visit(node) def visit_Assign(self, node: ast.Assign): """Trace variables assigned to path strings and LaminDB Artifact instances.""" @@ -86,6 +92,7 @@ def visit_Call(self, node: ast.Call): for path in paths: self.untracked_paths.setdefault(path, []).append(lineno) + self._trace_user_function_call(node) self.generic_visit(node) def _get_func_name(self, node: ast.AST) -> str: @@ -138,6 +145,62 @@ def _is_path_like(self, s: str) -> bool: s = s.strip() return bool(s and PATH_PATTERN.match(s)) + def _trace_user_function_call(self, node: ast.Call): + if not isinstance(node.func, ast.Name): + return + + function_name = node.func.id + function_def = self.function_defs.get(function_name) + if function_def is None or function_name in self._active_function_calls: + return + + bindings = self._get_param_path_bindings(node, function_def) + if not bindings: + return + + self._active_function_calls.add(function_name) + original_var_map = self.var_map.copy() + original_artifact_vars = self.artifact_vars.copy() + try: + self.var_map.update(bindings) + for stmt in function_def.body: + self.visit(stmt) + finally: + self.var_map = original_var_map + self.artifact_vars = original_artifact_vars + self._active_function_calls.remove(function_name) + + def _get_param_path_bindings( + self, call: ast.Call, function_def: ast.FunctionDef + ) -> dict[str, set[str]]: + bindings: dict[str, set[str]] = {} + + positional_params = function_def.args.posonlyargs + function_def.args.args + for param, arg in zip(positional_params, call.args): + paths = self._extract_paths_from_node(arg) + if paths: + bindings[param.arg] = paths + + keyword_args = { + kw.arg: self._extract_paths_from_node(kw.value) + for kw in call.keywords + if kw.arg is not None + } + + for param in positional_params: + if param.arg in bindings: + continue + paths = keyword_args.get(param.arg, set()) + if paths: + bindings[param.arg] = paths + + for param in function_def.args.kwonlyargs: + paths = keyword_args.get(param.arg, set()) + if paths: + bindings[param.arg] = paths + + return bindings + def verify_lineage(script_path: str) -> VerifyLineageResult: path = Path(script_path) diff --git a/tests/no_instance/test_verify_lineage.py b/tests/no_instance/test_verify_lineage.py index 9774eb733..856611704 100644 --- a/tests/no_instance/test_verify_lineage.py +++ b/tests/no_instance/test_verify_lineage.py @@ -313,6 +313,29 @@ def save(self, path): assert any("./weights.bin" in item for item in result.missing_lineage) +def test_verify_lineage_positive_for_file_passed_as_param_to_helper_function(tmp_path: Path): + script_path = _write_script( + tmp_path, + "helper_function_any_param.py", + """ +import lamindb as ln + +def load_dataset(dataset_ref: str): + return ln.Artifact.get(key=dataset_ref) + +def main() -> None: + ln.track() + _ = load_dataset("datasets/rnaseq/synthetic_rnaseq_from_age_disease.csv") + ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.missing_lineage == () + + def test_verify_lineage_missing_file(): result = verify_lineage("does-not-exist.py") assert result.is_fully_tracked is False From b5ec99f66f7f03b443be0bb615a27552b2c0d60b Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Mon, 10 Aug 2026 16:43:02 +0200 Subject: [PATCH 24/37] If a path is used in mkdir/Path() calls, dont add it to the untracked path list --- lamindb/core/_verify_lineage.py | 39 +++++++++++++++-- tests/no_instance/test_verify_lineage.py | 53 ++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index 380870c5e..9dfa96963 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -81,14 +81,16 @@ def visit_Call(self, node: ast.Call): # Distinguish LaminDB API calls from ordinary Python calls. # Any path referenced inside LaminDB calls is considered lineage-tracked. is_lamin_call = self._is_lamindb_call(node, func_name) + is_path_constructor_call = self._is_path_constructor_call(node, func_name) paths = self._extract_paths_from_node(node) + is_directory_setup_call = self._is_directory_setup_call(node, func_name) if is_lamin_call: for path in paths: self.tracked_paths.setdefault(path, []).append(lineno) - else: + elif not (is_path_constructor_call or is_directory_setup_call): for path in paths: self.untracked_paths.setdefault(path, []).append(lineno) @@ -121,6 +123,19 @@ def _is_lamindb_call(self, node: ast.Call, func_name: str) -> bool: return True return self._is_lamindb_artifact_save_call(node) + def _is_directory_setup_call(self, node: ast.Call, func_name: str) -> bool: + if func_name in {"os.makedirs", "makedirs"}: + return True + return isinstance(node.func, ast.Attribute) and node.func.attr == "mkdir" + + def _is_path_constructor_call(self, node: ast.Call, func_name: str) -> bool: + if func_name in {"Path", "PurePath", "PosixPath", "WindowsPath"}: + return True + return ( + isinstance(node.func, ast.Attribute) + and node.func.attr in {"Path", "PurePath", "PosixPath", "WindowsPath"} + ) + def _extract_paths_from_node(self, node: ast.AST) -> set[str]: """Extracts paths from string constants, variables, and nested function arguments.""" paths = set() @@ -139,12 +154,28 @@ def _extract_paths_from_node(self, node: ast.AST) -> set[str]: for kw in node.keywords: paths.update(self._extract_paths_from_node(kw.value)) + elif isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Div)): + left_paths = self._extract_paths_from_node(node.left) + right_paths = self._extract_paths_from_node(node.right) + for left_path in left_paths: + for right_path in right_paths: + combined = self._join_paths(left_path, right_path) + if self._is_path_like(combined): + paths.add(combined) + return paths def _is_path_like(self, s: str) -> bool: s = s.strip() return bool(s and PATH_PATTERN.match(s)) + def _join_paths(self, left: str, right: str) -> str: + left = left.strip() + right = right.strip() + if left.endswith(("/", "\\")) or right.startswith(("/", "\\")): + return f"{left}{right}" + return f"{left}/{right}" + def _trace_user_function_call(self, node: ast.Call): if not isinstance(node.func, ast.Name): return @@ -226,9 +257,11 @@ def verify_lineage(script_path: str) -> VerifyLineageResult: # Keep only paths that are seen in non-LaminDB calls and never seen in LaminDB calls. # These are candidates for missing lineage registration. + tracked_path_keys = set(checker.tracked_paths) truly_untracked_paths = { - p: lines for p, lines in checker.untracked_paths.items() - if p not in checker.tracked_paths + path: lines + for path, lines in checker.untracked_paths.items() + if path not in tracked_path_keys } missing_lineage: list[str] = [] diff --git a/tests/no_instance/test_verify_lineage.py b/tests/no_instance/test_verify_lineage.py index 856611704..ea16daec5 100644 --- a/tests/no_instance/test_verify_lineage.py +++ b/tests/no_instance/test_verify_lineage.py @@ -336,6 +336,59 @@ def main() -> None: assert result.missing_lineage == () +def test_verify_lineage_positive_concatenated_output_path_then_artifact_save(tmp_path: Path): + script_path = _write_script( + tmp_path, + "concatenated_output_path.py", + """ +from pathlib import Path +import lamindb as ln + +ln.track() +output_dir = Path("outputs/proteomics") +output_dir.mkdir(parents=True, exist_ok=True) +output_path = output_dir / "uniprot_human_reviewed.tsv" +ln.Artifact( + output_path, + key="datasets/proteomics/uniprot_human_reviewed.tsv", + description="UniProt reviewed human proteomics dataset with disulfide annotations", +).save() +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.missing_lineage == () + + +def test_verify_lineage_negative_non_mkdir_directory_usage_is_still_untracked(tmp_path: Path): + script_path = _write_script( + tmp_path, + "non_mkdir_directory_use.py", + """ +from pathlib import Path +import lamindb as ln + +def get_path(path: str): + print(path) + +ln.track() +output_dir = Path("outputs/proteomics") +get_path("outputs/proteomics") +output_path = output_dir / "uniprot_human_reviewed.tsv" +ln.Artifact(output_path, key="datasets/proteomics/uniprot_human_reviewed.tsv").save() +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is False + assert any("outputs/proteomics" in item for item in result.missing_lineage) + + def test_verify_lineage_missing_file(): result = verify_lineage("does-not-exist.py") assert result.is_fully_tracked is False From 67c4a864512b1cbd6d25ceaa6cb4ed70f02e0931 Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Tue, 11 Aug 2026 10:40:39 +0200 Subject: [PATCH 25/37] Update skills --- lamindb/.agents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lamindb/.agents b/lamindb/.agents index 30b48554b..de69b03fe 160000 --- a/lamindb/.agents +++ b/lamindb/.agents @@ -1 +1 @@ -Subproject commit 30b48554b944226f90c1942bb2f767c499439df0 +Subproject commit de69b03fe031e3cedc9c998f318cfc8f5b25aabc From cae866b7176a34e6c393cfedffc0a4ab71196eab Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Tue, 11 Aug 2026 11:12:45 +0200 Subject: [PATCH 26/37] Update with additional comments --- lamindb/core/_verify_lineage.py | 43 +++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index 9dfa96963..1211aae78 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -17,22 +17,31 @@ @dataclass(frozen=True) class VerifyLineageResult: + """Public result object returned by `verify_lineage()`.""" is_fully_tracked: bool missing_lineage: tuple[str, ...] class LaminLineageChecker(ast.NodeVisitor): def __init__(self): + # Paths observed in LaminDB API calls, keyed by path string -> line numbers. self.tracked_paths: dict[str, list[int]] = {} + # Paths observed in non-LaminDB calls, keyed by path string -> line numbers. self.untracked_paths: dict[str, list[int]] = {} + # Best-effort binding of variable names to path-like strings as code is traversed. self.var_map: dict[str, set[str]] = {} # var_name -> set of path strings + # Variable names that currently refer to an ln.Artifact(...) instance. self.artifact_vars: set[str] = set() # vars assigned from ln.Artifact(...) + # Session-level lifecycle flags required for a "fully tracked" script. self.has_track_call: bool = False self.has_finish_call: bool = False + # Function definitions are cached so later calls can be re-visited with bound args. self.function_defs: dict[str, ast.FunctionDef] = {} + # Prevent recursive re-entry while tracing user-defined function calls. self._active_function_calls: set[str] = set() def visit_FunctionDef(self, node: ast.FunctionDef): + # Save function bodies for later path-flow tracing at call sites. self.function_defs[node.name] = node self.generic_visit(node) @@ -110,6 +119,8 @@ def _is_lamindb_artifact_constructor_call(self, node: ast.Call) -> bool: return func_name in ("ln.Artifact", "lamindb.Artifact") def _is_lamindb_artifact_save_call(self, node: ast.Call) -> bool: + # Treat both `ln.Artifact(...).save()` and `artifact.save()` (where artifact + # was created from ln.Artifact(...)) as LaminDB lineage-tracked operations. if not (isinstance(node.func, ast.Attribute) and node.func.attr == "save"): return False if isinstance(node.func.value, ast.Call): @@ -119,6 +130,8 @@ def _is_lamindb_artifact_save_call(self, node: ast.Call) -> bool: return False def _is_lamindb_call(self, node: ast.Call, func_name: str) -> bool: + # All ln./lamindb.* calls are considered lineage-aware. + # We also include artifact save calls recognized above. if func_name.startswith(("ln.", "lamindb.")): return True return self._is_lamindb_artifact_save_call(node) @@ -155,6 +168,7 @@ def _extract_paths_from_node(self, node: ast.AST) -> set[str]: paths.update(self._extract_paths_from_node(kw.value)) elif isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Div)): + # Handle both string concatenation and pathlib-style joins. left_paths = self._extract_paths_from_node(node.left) right_paths = self._extract_paths_from_node(node.right) for left_path in left_paths: @@ -177,6 +191,11 @@ def _join_paths(self, left: str, right: str) -> str: return f"{left}/{right}" def _trace_user_function_call(self, node: ast.Call): + """Propagate path bindings into user-defined functions at call sites. + + This gives the checker a lightweight inter-procedural view, so path usage + hidden behind helper functions can still be classified as tracked/untracked. + """ if not isinstance(node.func, ast.Name): return @@ -190,6 +209,8 @@ def _trace_user_function_call(self, node: ast.Call): return self._active_function_calls.add(function_name) + # Temporarily augment variable scope with call argument bindings, then + # restore state after visiting the function body. original_var_map = self.var_map.copy() original_artifact_vars = self.artifact_vars.copy() try: @@ -234,6 +255,27 @@ def _get_param_path_bindings( def verify_lineage(script_path: str) -> VerifyLineageResult: + """Statically analyze one script and report missing LaminDB lineage hooks. + + Coverage (static AST checks): + - Confirms the script contains explicit session lifecycle calls: + `ln.track()` and `ln.finish()`. + - Extracts path-like strings from literals, variables, call arguments, + and simple path joins (string `+` or path `/` operations). + - Classifies path usage as tracked when it appears in LaminDB calls + (`ln.*`, `lamindb.*`, including `ln.Artifact(...).save()` patterns), + and flags paths that only appear in non-LaminDB calls. + - Propagates path-like argument bindings into user-defined helper + functions to catch indirect path usage. + + Important exclusions: + - Path constructors such as `Path(...)`, `PurePath(...)`, etc. are + treated as path construction only and are not themselves counted as + lineage-tracking operations. + - Directory setup calls such as `os.makedirs(...)`, `makedirs(...)`, + and `.mkdir(...)` are treated as filesystem preparation and are not + considered lineage tracking. + """ path = Path(script_path) if not path.is_file(): return VerifyLineageResult( @@ -274,6 +316,7 @@ def verify_lineage(script_path: str) -> VerifyLineageResult: missing_lineage.append("Missing ln.finish() call in script.") if truly_untracked_paths: + # Point to concrete source lines where paths are used. for fname, lines in sorted(truly_untracked_paths.items()): lines_str = ", ".join(f"line {l}" for l in lines) missing_lineage.append(f"File not tracked in lamindb: {fname} ({lines_str})") From eeed050ed83068df84260c03f7c6a5593f860cdd Mon Sep 17 00:00:00 2001 From: Ebad Khan Date: Tue, 11 Aug 2026 12:40:36 +0200 Subject: [PATCH 27/37] =?UTF-8?q?=F0=9F=94=96=20Bump=20lamin-skills=20to?= =?UTF-8?q?=20sheetalgiri-patch-1=20latest=20(#15=20merged)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lamindb/.agents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lamindb/.agents b/lamindb/.agents index de69b03fe..099c880f6 160000 --- a/lamindb/.agents +++ b/lamindb/.agents @@ -1 +1 @@ -Subproject commit de69b03fe031e3cedc9c998f318cfc8f5b25aabc +Subproject commit 099c880f67e817b9a8f66f88087498a76f349b76 From 1dee8cfa5b55bbdd3461b02444371ca662295939 Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Tue, 11 Aug 2026 16:38:10 +0200 Subject: [PATCH 28/37] Additional tests and add further options for non-lineage related calls with paths in verify_lineage --- lamindb/core/_verify_lineage.py | 87 +++++++++++++++++----- tests/no_instance/test_verify_lineage.py | 91 ++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 19 deletions(-) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index 1211aae78..6d80b8587 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -14,6 +14,40 @@ r"|^[\w\-\.\s]+\.[a-zA-Z0-9]{1,10}$" # Filenames with extensions ) +# Calls in these categories can carry path strings but are considered +# environment/path setup (not data lineage I/O), so they are ignored. +NON_LINEAGE_EXACT_CALLS = frozenset( + { + "Path", + "PurePath", + "PosixPath", + "WindowsPath", + "makedirs", + "os.makedirs", + } +) +NON_LINEAGE_CALL_PREFIXES = ( + "sys.path.", + "os.path.", + "importlib.", +) +NON_LINEAGE_ATTR_CALLS = frozenset( + { + "Path", + "PurePath", + "PosixPath", + "WindowsPath", + "mkdir", + "exists", + "is_file", + "is_dir", + "resolve", + "expanduser", + "glob", + "rglob", + } +) + @dataclass(frozen=True) class VerifyLineageResult: @@ -47,6 +81,11 @@ def visit_FunctionDef(self, node: ast.FunctionDef): def visit_Assign(self, node: ast.Assign): """Trace variables assigned to path strings and LaminDB Artifact instances.""" + if self._is_env_var_setup_assignment(node): + # Environment variable assignment is runtime setup and not lineage I/O. + # Skip traversing nested helper calls (e.g. str(path)) in this subtree. + return + paths = self._extract_paths_from_node(node.value) is_artifact_ctor = ( isinstance(node.value, ast.Call) @@ -77,6 +116,14 @@ def visit_Assign(self, node: ast.Assign): self.generic_visit(node) + def _is_env_var_setup_assignment(self, node: ast.Assign) -> bool: + for target in node.targets: + if not isinstance(target, ast.Subscript): + continue + if self._get_func_name(target.value) == "os.environ": + return True + return False + def visit_Call(self, node: ast.Call): func_name = self._get_func_name(node.func) lineno = getattr(node, "lineno", 0) @@ -90,16 +137,19 @@ def visit_Call(self, node: ast.Call): # Distinguish LaminDB API calls from ordinary Python calls. # Any path referenced inside LaminDB calls is considered lineage-tracked. is_lamin_call = self._is_lamindb_call(node, func_name) - is_path_constructor_call = self._is_path_constructor_call(node, func_name) + is_non_lineage_setup_call = self._is_non_lineage_setup_call(node, func_name) + if is_non_lineage_setup_call: + # Ignore full setup-call subtrees (e.g. `sys.path.insert(..., str(path))`) + # so nested helper calls are not misclassified as lineage-relevant I/O. + return paths = self._extract_paths_from_node(node) - is_directory_setup_call = self._is_directory_setup_call(node, func_name) if is_lamin_call: for path in paths: self.tracked_paths.setdefault(path, []).append(lineno) - elif not (is_path_constructor_call or is_directory_setup_call): + elif not is_non_lineage_setup_call: for path in paths: self.untracked_paths.setdefault(path, []).append(lineno) @@ -136,18 +186,14 @@ def _is_lamindb_call(self, node: ast.Call, func_name: str) -> bool: return True return self._is_lamindb_artifact_save_call(node) - def _is_directory_setup_call(self, node: ast.Call, func_name: str) -> bool: - if func_name in {"os.makedirs", "makedirs"}: + def _is_non_lineage_setup_call(self, node: ast.Call, func_name: str) -> bool: + # Centralized allowlist for path/setup helpers that should not be + # interpreted as lineage-relevant file I/O. + if func_name in NON_LINEAGE_EXACT_CALLS: return True - return isinstance(node.func, ast.Attribute) and node.func.attr == "mkdir" - - def _is_path_constructor_call(self, node: ast.Call, func_name: str) -> bool: - if func_name in {"Path", "PurePath", "PosixPath", "WindowsPath"}: + if func_name.startswith(NON_LINEAGE_CALL_PREFIXES): return True - return ( - isinstance(node.func, ast.Attribute) - and node.func.attr in {"Path", "PurePath", "PosixPath", "WindowsPath"} - ) + return isinstance(node.func, ast.Attribute) and node.func.attr in NON_LINEAGE_ATTR_CALLS def _extract_paths_from_node(self, node: ast.AST) -> set[str]: """Extracts paths from string constants, variables, and nested function arguments.""" @@ -269,12 +315,15 @@ def verify_lineage(script_path: str) -> VerifyLineageResult: functions to catch indirect path usage. Important exclusions: - - Path constructors such as `Path(...)`, `PurePath(...)`, etc. are - treated as path construction only and are not themselves counted as - lineage-tracking operations. - - Directory setup calls such as `os.makedirs(...)`, `makedirs(...)`, - and `.mkdir(...)` are treated as filesystem preparation and are not - considered lineage tracking. + - Path construction/inspection/setup helpers are allowlisted and ignored + for lineage (for example: `Path(...)`, `os.path.*`, `.mkdir()`, + `.exists()`, `.is_file()`, `.resolve()`, and glob helpers). + - `importlib.*` calls are treated as import/module setup and ignored for + lineage purposes. + - `os.environ[...] = ...` assignments are treated as environment setup and + ignored for lineage purposes. + - `sys.path.*` calls (for example `sys.path.insert(...)`) are treated + as import-path setup and ignored for lineage purposes. """ path = Path(script_path) if not path.is_file(): diff --git a/tests/no_instance/test_verify_lineage.py b/tests/no_instance/test_verify_lineage.py index ea16daec5..6a9ffd2ae 100644 --- a/tests/no_instance/test_verify_lineage.py +++ b/tests/no_instance/test_verify_lineage.py @@ -389,6 +389,97 @@ def get_path(path: str): assert any("outputs/proteomics" in item for item in result.missing_lineage) +def test_verify_lineage_positive_sys_path_insert_nested_calls_ignored(tmp_path: Path): + script_path = _write_script( + tmp_path, + "sys_path_insert_nested_calls.py", + """ +import sys +from pathlib import Path +import lamindb as ln + +ln.track() +plugins = Path("./plugins") +scanpy_plugin = plugins / "scanpy" +sys.path.insert(0, str(scanpy_plugin)) +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.missing_lineage == () + + +def test_verify_lineage_positive_os_path_helpers_ignored(tmp_path: Path): + script_path = _write_script( + tmp_path, + "os_path_helpers.py", + """ +import os +import lamindb as ln + +ln.track() +base = os.path.abspath("./data") +out = os.path.join(base, "output.txt") +parent = os.path.dirname(out) +name = os.path.basename(out) +_ = os.path.splitext(name) +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.missing_lineage == () + + +def test_verify_lineage_positive_env_var_path_setup_ignored(tmp_path: Path): + script_path = _write_script( + tmp_path, + "env_var_path_setup.py", + """ +import os +from pathlib import Path +import lamindb as ln + +ln.track() +plugins = Path("./plugins") +os.environ["PYTHONPATH"] = str(plugins) +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.missing_lineage == () + + +def test_verify_lineage_positive_importlib_setup_ignored(tmp_path: Path): + script_path = _write_script( + tmp_path, + "importlib_setup.py", + """ +import importlib.util +from pathlib import Path +import lamindb as ln + +ln.track() +plugin_path = Path("./plugins") / "scanpy_plugin.py" +_ = importlib.util.spec_from_file_location("scanpy_plugin", str(plugin_path)) +ln.finish() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.missing_lineage == () + + def test_verify_lineage_missing_file(): result = verify_lineage("does-not-exist.py") assert result.is_fully_tracked is False From 6f1671614fa1a31214903616a72809cc2d9713a1 Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Tue, 11 Aug 2026 17:13:39 +0200 Subject: [PATCH 29/37] Updated skills --- lamindb/.agents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lamindb/.agents b/lamindb/.agents index 099c880f6..2c252f7fe 160000 --- a/lamindb/.agents +++ b/lamindb/.agents @@ -1 +1 @@ -Subproject commit 099c880f67e817b9a8f66f88087498a76f349b76 +Subproject commit 2c252f7fee6daa1fe22e2df4c64fe8897ca872e3 From 0e873d8498ccd5444eb6cd469af3ea2f046981fe Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Fri, 14 Aug 2026 12:54:11 +0200 Subject: [PATCH 30/37] Support lineage tracking using imported lamindb symbols --- lamindb/core/_verify_lineage.py | 29 +++++++++++++++++++++--- tests/no_instance/test_verify_lineage.py | 7 +++--- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index 6d80b8587..cc376c288 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -69,11 +69,21 @@ def __init__(self): # Session-level lifecycle flags required for a "fully tracked" script. self.has_track_call: bool = False self.has_finish_call: bool = False + # Local symbols imported from `lamindb`, keyed by local alias -> imported name. + self.imported_lamindb_symbols: dict[str, str] = {} # Function definitions are cached so later calls can be re-visited with bound args. self.function_defs: dict[str, ast.FunctionDef] = {} # Prevent recursive re-entry while tracing user-defined function calls. self._active_function_calls: set[str] = set() + def visit_ImportFrom(self, node: ast.ImportFrom): + if node.module != "lamindb": + return + + for imported in node.names: + local_name = imported.asname or imported.name + self.imported_lamindb_symbols[local_name] = imported.name + def visit_FunctionDef(self, node: ast.FunctionDef): # Save function bodies for later path-flow tracing at call sites. self.function_defs[node.name] = node @@ -129,9 +139,13 @@ def visit_Call(self, node: ast.Call): lineno = getattr(node, "lineno", 0) # Record explicit session lifecycle calls used to open/close lineage tracking. - if func_name in ("ln.track", "lamindb.track"): + if func_name in ("ln.track", "lamindb.track") or self._is_imported_lamindb_symbol( + func_name, "track" + ): self.has_track_call = True - if func_name in ("ln.finish", "lamindb.finish"): + if func_name in ("ln.finish", "lamindb.finish") or self._is_imported_lamindb_symbol( + func_name, "finish" + ): self.has_finish_call = True # Distinguish LaminDB API calls from ordinary Python calls. @@ -166,7 +180,13 @@ def _get_func_name(self, node: ast.AST) -> str: def _is_lamindb_artifact_constructor_call(self, node: ast.Call) -> bool: func_name = self._get_func_name(node.func) - return func_name in ("ln.Artifact", "lamindb.Artifact") + return func_name in ("ln.Artifact", "lamindb.Artifact") or self._is_imported_lamindb_symbol( + func_name, "Artifact" + ) + + def _is_imported_lamindb_symbol(self, func_name: str, symbol_name: str) -> bool: + root_name = func_name.split(".", 1)[0] + return self.imported_lamindb_symbols.get(root_name) == symbol_name def _is_lamindb_artifact_save_call(self, node: ast.Call) -> bool: # Treat both `ln.Artifact(...).save()` and `artifact.save()` (where artifact @@ -184,6 +204,9 @@ def _is_lamindb_call(self, node: ast.Call, func_name: str) -> bool: # We also include artifact save calls recognized above. if func_name.startswith(("ln.", "lamindb.")): return True + root_name = func_name.split(".", 1)[0] + if root_name in self.imported_lamindb_symbols: + return True return self._is_lamindb_artifact_save_call(node) def _is_non_lineage_setup_call(self, node: ast.Call, func_name: str) -> bool: diff --git a/tests/no_instance/test_verify_lineage.py b/tests/no_instance/test_verify_lineage.py index 6a9ffd2ae..e8bbf8c30 100644 --- a/tests/no_instance/test_verify_lineage.py +++ b/tests/no_instance/test_verify_lineage.py @@ -52,7 +52,7 @@ def test_verify_lineage_positive_output_from_dataframe(tmp_path: Path): assert result.missing_lineage == () -def test_verify_lineage_imported_symbols_match_current_behavior(tmp_path: Path): +def test_verify_lineage_positive_imported_symbols(tmp_path: Path): script_path = _write_script( tmp_path, "tracked_script_imported.py", @@ -68,9 +68,8 @@ def test_verify_lineage_imported_symbols_match_current_behavior(tmp_path: Path): result = verify_lineage(script_path) - assert result.is_fully_tracked is False - assert "Missing ln.track() call in script." in result.missing_lineage - assert "Missing ln.finish() call in script." in result.missing_lineage + assert result.is_fully_tracked is True + assert result.missing_lineage == () def test_verify_lineage_positive_zero_io_script(tmp_path: Path): From b7656088627c4af3dd0ec6b4fa8ca062f40954f7 Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Fri, 14 Aug 2026 13:32:41 +0200 Subject: [PATCH 31/37] Add ln.flow support --- lamindb/core/_verify_lineage.py | 18 ++++++ tests/no_instance/test_verify_lineage.py | 70 ++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index cc376c288..530380a60 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -85,6 +85,16 @@ def visit_ImportFrom(self, node: ast.ImportFrom): self.imported_lamindb_symbols[local_name] = imported.name def visit_FunctionDef(self, node: ast.FunctionDef): + for decorator in node.decorator_list: + decorator_name = ( + self._get_func_name(decorator.func) + if isinstance(decorator, ast.Call) + else self._get_func_name(decorator) + ) + if self._is_lamindb_flow_name(decorator_name): + # `@ln.flow` provides run lifecycle management. + self.has_track_call = True + self.has_finish_call = True # Save function bodies for later path-flow tracing at call sites. self.function_defs[node.name] = node self.generic_visit(node) @@ -147,6 +157,9 @@ def visit_Call(self, node: ast.Call): func_name, "finish" ): self.has_finish_call = True + if self._is_lamindb_flow_name(func_name): + self.has_track_call = True + self.has_finish_call = True # Distinguish LaminDB API calls from ordinary Python calls. # Any path referenced inside LaminDB calls is considered lineage-tracked. @@ -188,6 +201,11 @@ def _is_imported_lamindb_symbol(self, func_name: str, symbol_name: str) -> bool: root_name = func_name.split(".", 1)[0] return self.imported_lamindb_symbols.get(root_name) == symbol_name + def _is_lamindb_flow_name(self, func_name: str) -> bool: + return func_name in ("ln.flow", "lamindb.flow") or self._is_imported_lamindb_symbol( + func_name, "flow" + ) + def _is_lamindb_artifact_save_call(self, node: ast.Call) -> bool: # Treat both `ln.Artifact(...).save()` and `artifact.save()` (where artifact # was created from ln.Artifact(...)) as LaminDB lineage-tracked operations. diff --git a/tests/no_instance/test_verify_lineage.py b/tests/no_instance/test_verify_lineage.py index e8bbf8c30..1fe400499 100644 --- a/tests/no_instance/test_verify_lineage.py +++ b/tests/no_instance/test_verify_lineage.py @@ -72,6 +72,76 @@ def test_verify_lineage_positive_imported_symbols(tmp_path: Path): assert result.missing_lineage == () +def test_verify_lineage_positive_flow_decorator(tmp_path: Path): + script_path = _write_script( + tmp_path, + "tracked_script_flow.py", + """ +import lamindb as ln + +@ln.flow() +def main(): + ln.Artifact.get(uid="abcDEF1234567890") + ln.Artifact("./out.csv").save() + +main() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.missing_lineage == () + + +def test_verify_lineage_positive_imported_flow_step_decorators(tmp_path: Path): + script_path = _write_script( + tmp_path, + "tracked_script_imported_flow_step.py", + """ +from lamindb import Artifact, flow, step + +@step() +def build_output(): + Artifact("./out.csv").save() + +@flow() +def main(): + Artifact.get(uid="abcDEF1234567890") + build_output() + +main() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is True + assert result.missing_lineage == () + + +def test_verify_lineage_negative_step_decorator_without_flow_or_track(tmp_path: Path): + script_path = _write_script( + tmp_path, + "tracked_script_step_only.py", + """ +import lamindb as ln + +@ln.step() +def build_output(): + ln.Artifact("./out.csv").save() + +build_output() +""".strip(), + ) + + result = verify_lineage(script_path) + + assert result.is_fully_tracked is False + assert "Missing ln.track() call in script." in result.missing_lineage + assert "Missing ln.finish() call in script." in result.missing_lineage + + def test_verify_lineage_positive_zero_io_script(tmp_path: Path): script_path = _write_script( tmp_path, From b9f97085d4e9b886ddca49db42b17a9dcb5d3f63 Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Fri, 14 Aug 2026 15:21:34 +0200 Subject: [PATCH 32/37] Parameterize tests --- tests/no_instance/test_verify_lineage.py | 857 +++++++++-------------- 1 file changed, 334 insertions(+), 523 deletions(-) diff --git a/tests/no_instance/test_verify_lineage.py b/tests/no_instance/test_verify_lineage.py index 1fe400499..3c0ea9a49 100644 --- a/tests/no_instance/test_verify_lineage.py +++ b/tests/no_instance/test_verify_lineage.py @@ -1,552 +1,363 @@ from __future__ import annotations +import textwrap from typing import TYPE_CHECKING +import pytest + from lamindb.core import verify_lineage if TYPE_CHECKING: from pathlib import Path -def _write_script(tmp_path: Path, name: str, source: str) -> Path: + +def _write_script(tmp_path: Path, name: str, source_code: str) -> Path: script_path = tmp_path / name - script_path.write_text(source, encoding="utf-8") + script_path.write_text(source_code, encoding="utf-8") return script_path -def test_verify_lineage_positive_lamindb_alias(tmp_path: Path): - script_path = _write_script( - tmp_path, - "tracked_script.py", - """ -import lamindb as ln - -ln.track() -ln.Artifact.get(uid="abcDEF1234567890") -ln.Artifact("./out.csv").save() -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () - - -def test_verify_lineage_positive_output_from_dataframe(tmp_path: Path): - script_path = _write_script( - tmp_path, - "tracked_script_from_dataframe.py", - """ -import lamindb as ln - -ln.track() -ln.Artifact.from_dataframe(df, key="out.parquet") -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () - - -def test_verify_lineage_positive_imported_symbols(tmp_path: Path): - script_path = _write_script( - tmp_path, - "tracked_script_imported.py", - """ -from lamindb import Artifact, finish, track - -track() -Artifact.get(uid="abcDEF1234567890") -Artifact("./out.csv").save() -finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () - - -def test_verify_lineage_positive_flow_decorator(tmp_path: Path): - script_path = _write_script( - tmp_path, - "tracked_script_flow.py", - """ -import lamindb as ln - -@ln.flow() -def main(): - ln.Artifact.get(uid="abcDEF1234567890") - ln.Artifact("./out.csv").save() - -main() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () - - -def test_verify_lineage_positive_imported_flow_step_decorators(tmp_path: Path): - script_path = _write_script( - tmp_path, - "tracked_script_imported_flow_step.py", - """ -from lamindb import Artifact, flow, step - -@step() -def build_output(): - Artifact("./out.csv").save() - -@flow() -def main(): - Artifact.get(uid="abcDEF1234567890") - build_output() - -main() -""".strip(), - ) - +def extract_cases(cls): + return [ + pytest.param(textwrap.dedent(v).strip(), id=k) + for k, v in vars(cls).items() + if not k.startswith("_") + ] + + +def extract_untracked_cases(cls): + cases = [] + for k, v in vars(cls).items(): + if not k.startswith("_"): + source_code = v["source_code"] + untracked_path = v["untracked_path"] + cases.append( + pytest.param(textwrap.dedent(source_code).strip(), untracked_path, id=k) + ) + return cases + + +class PositiveCases: + lamindb_alias = """ + import lamindb as ln + + ln.track() + ln.Artifact.get(uid="abcDEF1234567890") + ln.Artifact("./out.csv").save() + ln.finish() + """ + + artifact_from_dataframe = """ + import lamindb as ln + + ln.track() + ln.Artifact.from_dataframe(df, key="out.parquet") + ln.finish() + """ + + imported_symbols = """ + from lamindb import Artifact, finish, track + + track() + Artifact.get(uid="abcDEF1234567890") + Artifact("./out.csv").save() + finish() + """ + + flow_decorator = """ + import lamindb as ln + + @ln.flow() + def main(): + ln.Artifact.get(uid="abcDEF1234567890") + ln.Artifact("./out.csv").save() + + main() + """ + + imported_flow_step_decorators = """ + from lamindb import Artifact, flow, step + + @step() + def build_output(): + Artifact("./out.csv").save() + + @flow() + def main(): + Artifact.get(uid="abcDEF1234567890") + build_output() + + main() + """ + + zero_io = """ + import lamindb as ln + + ln.track() + fibonacci = [0, 1] + for _ in range(2, 10): + fibonacci.append(fibonacci[-1] + fibonacci[-2]) + ln.finish() + """ + + local_write_then_save = """ + from pathlib import Path + import json + import lamindb as ln + + ln.track() + result = {"ok": True} + output_path = Path("./result.json") + output_path.write_text(json.dumps(result, indent=2) + "\\n", encoding="utf-8") + ln.Artifact(output_path).save() + ln.finish() + """ + + np_save_then_save = """ + from pathlib import Path + import lamindb as ln + import numpy as np + + ln.track() + output_path = Path("./array.npy") + np.save(output_path, np.array([1, 2, 3])) + ln.Artifact(output_path).save() + ln.finish() + """ + + open_write_then_save = """ + import lamindb as ln + + ln.track() + with open("out.txt", "w") as f: + f.write("external output test\\n") + ln.Artifact("out.txt", key="tests/external-output/out.txt").save() + ln.finish() + """ + + artifact_variable_save = """ + from pathlib import Path + import lamindb as ln + + ln.track() + out_path = Path("out.txt") + out_path.write_text("external output test\\n") + artifact = ln.Artifact(out_path, key="tests/external-output/out.txt") + artifact.save() + ln.finish() + """ + + helper_function_param = """ + import lamindb as ln + + def load_dataset(dataset_ref: str): + return ln.Artifact.get(key=dataset_ref) + + def main() -> None: + ln.track() + _ = load_dataset("datasets/rnaseq/synthetic_rnaseq_from_age_disease.csv") + ln.finish() + """ + + concatenated_output_path = """ + from pathlib import Path + import lamindb as ln + + ln.track() + output_dir = Path("outputs/proteomics") + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "uniprot_human_reviewed.tsv" + ln.Artifact( + output_path, + key="datasets/proteomics/uniprot_human_reviewed.tsv", + description="UniProt reviewed human proteomics dataset with disulfide annotations", + ).save() + ln.finish() + """ + + sys_path_insert_ignored = """ + import sys + from pathlib import Path + import lamindb as ln + + ln.track() + plugins = Path("./plugins") + scanpy_plugin = plugins / "scanpy" + sys.path.insert(0, str(scanpy_plugin)) + ln.finish() + """ + + os_path_helpers_ignored = """ + import os + import lamindb as ln + + ln.track() + base = os.path.abspath("./data") + out = os.path.join(base, "output.txt") + parent = os.path.dirname(out) + name = os.path.basename(out) + _ = os.path.splitext(name) + ln.finish() + """ + + env_var_setup_ignored = """ + import os + from pathlib import Path + import lamindb as ln + + ln.track() + plugins = Path("./plugins") + os.environ["PYTHONPATH"] = str(plugins) + ln.finish() + """ + + importlib_setup_ignored = """ + import importlib.util + from pathlib import Path + import lamindb as ln + + ln.track() + plugin_path = Path("./plugins") / "scanpy_plugin.py" + _ = importlib.util.spec_from_file_location("scanpy_plugin", str(plugin_path)) + ln.finish() + """ + + +class MissingLifecycleCases: + step_without_flow_or_track = """ + import lamindb as ln + + @ln.step() + def build_output(): + ln.Artifact("./out.csv").save() + + build_output() + """ + + no_track_finish = """ + import lamindb as ln + + ln.Artifact.get(uid="abcDEF1234567890") + ln.Artifact("./out.csv").save() + """ + + +class UntrackedPathCases: + external_input_read_csv = { + "untracked_path": "./local_input.csv", + "source_code": """ + import lamindb as ln + import pandas as pd + + ln.track() + pd.read_csv("./local_input.csv") + ln.Artifact.get(uid="abcDEF1234567890") + ln.Artifact("./out.csv").save() + ln.finish() + """, + } + + external_output_open_write = { + "untracked_path": "./local_output.txt", + "source_code": """ + import lamindb as ln + + ln.track() + with open("./local_output.txt", "w") as f: + f.write("hello") + ln.finish() + """, + } + + external_input_open_read = { + "untracked_path": "./local_input.txt", + "source_code": """ + import lamindb as ln + + ln.track() + with open("./local_input.txt", "r") as f: + _ = f.read() + ln.finish() + """, + } + + np_save_without_artifact_save = { + "untracked_path": "./array.npy", + "source_code": """ + from pathlib import Path + import lamindb as ln + import numpy as np + + ln.track() + output_path = Path("./array.npy") + np.save(output_path, np.array([1, 2, 3])) + ln.finish() + """, + } + + non_lamindb_save_call = { + "untracked_path": "./weights.bin", + "source_code": """ + import lamindb as ln + + class DummyModel: + def save(self, path): + with open(path, "w", encoding="utf-8") as f: + f.write("weights") + + ln.track() + model = DummyModel() + model.save("./weights.bin") + ln.finish() + """, + } + + non_mkdir_directory_use = { + "untracked_path": "outputs/proteomics", + "source_code": """ + from pathlib import Path + import lamindb as ln + + def get_path(path: str): + print(path) + + ln.track() + output_dir = Path("outputs/proteomics") + get_path("outputs/proteomics") + output_path = output_dir / "uniprot_human_reviewed.tsv" + ln.Artifact(output_path, key="datasets/proteomics/uniprot_human_reviewed.tsv").save() + ln.finish() + """, + } + + +@pytest.mark.parametrize("source_code", extract_cases(PositiveCases)) +def test_verify_lineage_positive_cases(tmp_path: Path, source_code: str): + script_path = _write_script(tmp_path, "script.py", source_code) result = verify_lineage(script_path) - assert result.is_fully_tracked is True assert result.missing_lineage == () -def test_verify_lineage_negative_step_decorator_without_flow_or_track(tmp_path: Path): - script_path = _write_script( - tmp_path, - "tracked_script_step_only.py", - """ -import lamindb as ln - -@ln.step() -def build_output(): - ln.Artifact("./out.csv").save() - -build_output() -""".strip(), - ) - +@pytest.mark.parametrize("source_code", extract_cases(MissingLifecycleCases)) +def test_verify_lineage_negative_missing_lifecycle(tmp_path: Path, source_code: str): + script_path = _write_script(tmp_path, "script.py", source_code) result = verify_lineage(script_path) - assert result.is_fully_tracked is False assert "Missing ln.track() call in script." in result.missing_lineage assert "Missing ln.finish() call in script." in result.missing_lineage -def test_verify_lineage_positive_zero_io_script(tmp_path: Path): - script_path = _write_script( - tmp_path, - "tracked_script_zero_io.py", - """ -import lamindb as ln - -ln.track() -fibonacci = [0, 1] -for _ in range(2, 10): - fibonacci.append(fibonacci[-1] + fibonacci[-2]) -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () - - -def test_verify_lineage_negative_missing_lineage_tracking_calls(tmp_path: Path): - script_path = _write_script( - tmp_path, - "missing_lineage_lineage.py", - """ -import lamindb as ln - -ln.Artifact.get(uid="abcDEF1234567890") -ln.Artifact("./out.csv").save() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is False - assert "Missing ln.track() call in script." in result.missing_lineage - assert "Missing ln.finish() call in script." in result.missing_lineage - - -def test_verify_lineage_negative_external_input_even_when_script_has_lamindb_io(tmp_path: Path): - script_path = _write_script( - tmp_path, - "external_input.py", - """ -import lamindb as ln -import pandas as pd - -ln.track() -pd.read_csv("./local_input.csv") -ln.Artifact.get(uid="abcDEF1234567890") -ln.Artifact("./out.csv").save() -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is False - assert any("./local_input.csv" in item for item in result.missing_lineage) - - -def test_verify_lineage_negative_external_output_write(tmp_path: Path): - script_path = _write_script( - tmp_path, - "external_output.py", - """ -import lamindb as ln - -ln.track() -with open("./local_output.txt", "w") as f: - f.write("hello") -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is False - assert any("./local_output.txt" in item for item in result.missing_lineage) - - -def test_verify_lineage_negative_open_read_untracked(tmp_path: Path): - script_path = _write_script( - tmp_path, - "external_input_open_read.py", - """ -import lamindb as ln - -ln.track() -with open("./local_input.txt", "r") as f: - _ = f.read() -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is False - assert any("./local_input.txt" in item for item in result.missing_lineage) - - -def test_verify_lineage_positive_local_write_then_lamindb_save(tmp_path: Path): - script_path = _write_script( - tmp_path, - "local_materialization_then_save.py", - """ -from pathlib import Path -import json -import lamindb as ln - -ln.track() -result = {"ok": True} -output_path = Path("./result.json") -output_path.write_text(json.dumps(result, indent=2) + "\\n", encoding="utf-8") -ln.Artifact(output_path).save() -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () - - -def test_verify_lineage_positive_np_save_then_lamindb_save(tmp_path: Path): - script_path = _write_script( - tmp_path, - "np_save_then_lamindb_save.py", - """ -from pathlib import Path -import lamindb as ln -import numpy as np - -ln.track() -output_path = Path("./array.npy") -np.save(output_path, np.array([1, 2, 3])) -ln.Artifact(output_path).save() -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () - - -def test_verify_lineage_negative_np_save_without_lamindb_save(tmp_path: Path): - script_path = _write_script( - tmp_path, - "np_save_without_lamindb_save.py", - """ -from pathlib import Path -import lamindb as ln -import numpy as np - -ln.track() -output_path = Path("./array.npy") -np.save(output_path, np.array([1, 2, 3])) -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is False - assert any("./array.npy" in item for item in result.missing_lineage) - - -def test_verify_lineage_positive_open_write_literal_then_lamindb_save(tmp_path: Path): - script_path = _write_script( - tmp_path, - "open_write_literal_then_save.py", - """ -import lamindb as ln - -ln.track() -with open("out.txt", "w") as f: - f.write("external output test\\n") -ln.Artifact("out.txt", key="tests/external-output/out.txt").save() -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () - - -def test_verify_lineage_positive_artifact_variable_save(tmp_path: Path): - script_path = _write_script( - tmp_path, - "artifact_variable_save.py", - """ -from pathlib import Path -import lamindb as ln - -ln.track() -out_path = Path("out.txt") -out_path.write_text("external output test\\n") -artifact = ln.Artifact(out_path, key="tests/external-output/out.txt") -artifact.save() -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () - - -def test_verify_lineage_negative_non_lamindb_save_call(tmp_path: Path): - script_path = _write_script( - tmp_path, - "non_lamindb_save.py", - """ -import lamindb as ln - -class DummyModel: - def save(self, path): - with open(path, "w", encoding="utf-8") as f: - f.write("weights") - -ln.track() -model = DummyModel() -model.save("./weights.bin") -ln.finish() -""".strip(), - ) - +@pytest.mark.parametrize( + ("source_code", "untracked_path"), extract_untracked_cases(UntrackedPathCases) +) +def test_verify_lineage_negative_untracked_paths( + tmp_path: Path, source_code: str, untracked_path: str +): + script_path = _write_script(tmp_path, "script.py", source_code) result = verify_lineage(script_path) - assert result.is_fully_tracked is False - assert any("./weights.bin" in item for item in result.missing_lineage) - - -def test_verify_lineage_positive_for_file_passed_as_param_to_helper_function(tmp_path: Path): - script_path = _write_script( - tmp_path, - "helper_function_any_param.py", - """ -import lamindb as ln - -def load_dataset(dataset_ref: str): - return ln.Artifact.get(key=dataset_ref) - -def main() -> None: - ln.track() - _ = load_dataset("datasets/rnaseq/synthetic_rnaseq_from_age_disease.csv") - ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () - - -def test_verify_lineage_positive_concatenated_output_path_then_artifact_save(tmp_path: Path): - script_path = _write_script( - tmp_path, - "concatenated_output_path.py", - """ -from pathlib import Path -import lamindb as ln - -ln.track() -output_dir = Path("outputs/proteomics") -output_dir.mkdir(parents=True, exist_ok=True) -output_path = output_dir / "uniprot_human_reviewed.tsv" -ln.Artifact( - output_path, - key="datasets/proteomics/uniprot_human_reviewed.tsv", - description="UniProt reviewed human proteomics dataset with disulfide annotations", -).save() -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () - - -def test_verify_lineage_negative_non_mkdir_directory_usage_is_still_untracked(tmp_path: Path): - script_path = _write_script( - tmp_path, - "non_mkdir_directory_use.py", - """ -from pathlib import Path -import lamindb as ln - -def get_path(path: str): - print(path) - -ln.track() -output_dir = Path("outputs/proteomics") -get_path("outputs/proteomics") -output_path = output_dir / "uniprot_human_reviewed.tsv" -ln.Artifact(output_path, key="datasets/proteomics/uniprot_human_reviewed.tsv").save() -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is False - assert any("outputs/proteomics" in item for item in result.missing_lineage) - - -def test_verify_lineage_positive_sys_path_insert_nested_calls_ignored(tmp_path: Path): - script_path = _write_script( - tmp_path, - "sys_path_insert_nested_calls.py", - """ -import sys -from pathlib import Path -import lamindb as ln - -ln.track() -plugins = Path("./plugins") -scanpy_plugin = plugins / "scanpy" -sys.path.insert(0, str(scanpy_plugin)) -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () - - -def test_verify_lineage_positive_os_path_helpers_ignored(tmp_path: Path): - script_path = _write_script( - tmp_path, - "os_path_helpers.py", - """ -import os -import lamindb as ln - -ln.track() -base = os.path.abspath("./data") -out = os.path.join(base, "output.txt") -parent = os.path.dirname(out) -name = os.path.basename(out) -_ = os.path.splitext(name) -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () - - -def test_verify_lineage_positive_env_var_path_setup_ignored(tmp_path: Path): - script_path = _write_script( - tmp_path, - "env_var_path_setup.py", - """ -import os -from pathlib import Path -import lamindb as ln - -ln.track() -plugins = Path("./plugins") -os.environ["PYTHONPATH"] = str(plugins) -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () - - -def test_verify_lineage_positive_importlib_setup_ignored(tmp_path: Path): - script_path = _write_script( - tmp_path, - "importlib_setup.py", - """ -import importlib.util -from pathlib import Path -import lamindb as ln - -ln.track() -plugin_path = Path("./plugins") / "scanpy_plugin.py" -_ = importlib.util.spec_from_file_location("scanpy_plugin", str(plugin_path)) -ln.finish() -""".strip(), - ) - - result = verify_lineage(script_path) - - assert result.is_fully_tracked is True - assert result.missing_lineage == () + assert any(untracked_path in item for item in result.missing_lineage) def test_verify_lineage_missing_file(): From 7f85167f19ac4c778edd53755e28a595dcb30c04 Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Fri, 14 Aug 2026 15:26:21 +0200 Subject: [PATCH 33/37] ruff --- tests/no_instance/test_verify_lineage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/no_instance/test_verify_lineage.py b/tests/no_instance/test_verify_lineage.py index 3c0ea9a49..85f6d1b96 100644 --- a/tests/no_instance/test_verify_lineage.py +++ b/tests/no_instance/test_verify_lineage.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING import pytest - from lamindb.core import verify_lineage if TYPE_CHECKING: From 0a73699d34f839df3225ae83d138bb49950a91d2 Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Fri, 14 Aug 2026 15:51:34 +0200 Subject: [PATCH 34/37] Don't hardcode 'ln' alias, support any other alias for lamindb --- lamindb/core/_verify_lineage.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index 530380a60..448da0f51 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -69,6 +69,8 @@ def __init__(self): # Session-level lifecycle flags required for a "fully tracked" script. self.has_track_call: bool = False self.has_finish_call: bool = False + # Module aliases bound to `lamindb` via `import lamindb as `. + self.lamindb_module_aliases: set[str] = {"lamindb"} # Local symbols imported from `lamindb`, keyed by local alias -> imported name. self.imported_lamindb_symbols: dict[str, str] = {} # Function definitions are cached so later calls can be re-visited with bound args. @@ -84,6 +86,12 @@ def visit_ImportFrom(self, node: ast.ImportFrom): local_name = imported.asname or imported.name self.imported_lamindb_symbols[local_name] = imported.name + def visit_Import(self, node: ast.Import): + for imported in node.names: + if imported.name == "lamindb": + local_name = imported.asname or imported.name + self.lamindb_module_aliases.add(local_name) + def visit_FunctionDef(self, node: ast.FunctionDef): for decorator in node.decorator_list: decorator_name = ( @@ -149,11 +157,11 @@ def visit_Call(self, node: ast.Call): lineno = getattr(node, "lineno", 0) # Record explicit session lifecycle calls used to open/close lineage tracking. - if func_name in ("ln.track", "lamindb.track") or self._is_imported_lamindb_symbol( + if self._is_lamindb_module_call(func_name, "track") or self._is_imported_lamindb_symbol( func_name, "track" ): self.has_track_call = True - if func_name in ("ln.finish", "lamindb.finish") or self._is_imported_lamindb_symbol( + if self._is_lamindb_module_call(func_name, "finish") or self._is_imported_lamindb_symbol( func_name, "finish" ): self.has_finish_call = True @@ -193,16 +201,20 @@ def _get_func_name(self, node: ast.AST) -> str: def _is_lamindb_artifact_constructor_call(self, node: ast.Call) -> bool: func_name = self._get_func_name(node.func) - return func_name in ("ln.Artifact", "lamindb.Artifact") or self._is_imported_lamindb_symbol( + return self._is_lamindb_module_call(func_name, "Artifact") or self._is_imported_lamindb_symbol( func_name, "Artifact" ) + def _is_lamindb_module_call(self, func_name: str, call_name: str) -> bool: + root_name, _, remainder = func_name.partition(".") + return root_name in self.lamindb_module_aliases and remainder == call_name + def _is_imported_lamindb_symbol(self, func_name: str, symbol_name: str) -> bool: root_name = func_name.split(".", 1)[0] return self.imported_lamindb_symbols.get(root_name) == symbol_name def _is_lamindb_flow_name(self, func_name: str) -> bool: - return func_name in ("ln.flow", "lamindb.flow") or self._is_imported_lamindb_symbol( + return self._is_lamindb_module_call(func_name, "flow") or self._is_imported_lamindb_symbol( func_name, "flow" ) @@ -220,9 +232,9 @@ def _is_lamindb_artifact_save_call(self, node: ast.Call) -> bool: def _is_lamindb_call(self, node: ast.Call, func_name: str) -> bool: # All ln./lamindb.* calls are considered lineage-aware. # We also include artifact save calls recognized above. - if func_name.startswith(("ln.", "lamindb.")): - return True root_name = func_name.split(".", 1)[0] + if root_name in self.lamindb_module_aliases: + return True if root_name in self.imported_lamindb_symbols: return True return self._is_lamindb_artifact_save_call(node) From 3e0bb7352d2ecb25645f82a58fbf3bc115945afd Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Fri, 14 Aug 2026 15:52:01 +0200 Subject: [PATCH 35/37] Additional tests --- tests/no_instance/test_verify_lineage.py | 79 ++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/tests/no_instance/test_verify_lineage.py b/tests/no_instance/test_verify_lineage.py index 85f6d1b96..d3ad92651 100644 --- a/tests/no_instance/test_verify_lineage.py +++ b/tests/no_instance/test_verify_lineage.py @@ -46,6 +46,24 @@ class PositiveCases: ln.finish() """ + lamindb_custom_alias = """ + import lamindb as ldb + + ldb.track() + ldb.Artifact.get(uid="abcDEF1234567890") + ldb.Artifact("./out.csv").save() + ldb.finish() + """ + + lamindb_without_alias = """ + import lamindb + + lamindb.track() + lamindb.Artifact.get(uid="abcDEF1234567890") + lamindb.Artifact("./out.csv").save() + lamindb.finish() + """ + artifact_from_dataframe = """ import lamindb as ln @@ -63,7 +81,7 @@ class PositiveCases: finish() """ - flow_decorator = """ + flow_decorator_with_alias = """ import lamindb as ln @ln.flow() @@ -74,6 +92,17 @@ def main(): main() """ + flow_decorator_no_alias = """ + import lamindb as ln + + @lamindb.flow() + def main(): + ln.Artifact.get(uid="abcDEF1234567890") + ln.Artifact("./out.csv").save() + + main() + """ + imported_flow_step_decorators = """ from lamindb import Artifact, flow, step @@ -240,6 +269,20 @@ def build_output(): ln.Artifact("./out.csv").save() """ + missing_track_only = """ + import lamindb as ln + + ln.Artifact("./out.csv").save() + ln.finish() + """ + + missing_finish_only = """ + import lamindb as ln + + ln.track() + ln.Artifact("./out.csv").save() + """ + class UntrackedPathCases: external_input_read_csv = { @@ -311,7 +354,7 @@ def save(self, path): """, } - non_mkdir_directory_use = { + untracked_directory = { "untracked_path": "outputs/proteomics", "source_code": """ from pathlib import Path @@ -329,6 +372,32 @@ def get_path(path: str): """, } + untracked_matplotlib_savefig = { + "untracked_path": "./my_plot.png", + "source_code": """ + import lamindb as ln + import matplotlib.pyplot as plt + + ln.track() + plt.plot([1, 2]) + plt.savefig("./my_plot.png") + ln.finish() + """, + } + + untracked_pandas_to_parquet = { + "untracked_path": "./data.parquet", + "source_code": """ + import lamindb as ln + import pandas as pd + + ln.track() + df = pd.DataFrame({"a": [1, 2]}) + df.to_parquet("./data.parquet") + ln.finish() + """, + } + @pytest.mark.parametrize("source_code", extract_cases(PositiveCases)) def test_verify_lineage_positive_cases(tmp_path: Path, source_code: str): @@ -343,8 +412,10 @@ def test_verify_lineage_negative_missing_lifecycle(tmp_path: Path, source_code: script_path = _write_script(tmp_path, "script.py", source_code) result = verify_lineage(script_path) assert result.is_fully_tracked is False - assert "Missing ln.track() call in script." in result.missing_lineage - assert "Missing ln.finish() call in script." in result.missing_lineage + assert ( + "Missing ln.track() call in script." in result.missing_lineage + or "Missing ln.finish() call in script." in result.missing_lineage + ) @pytest.mark.parametrize( From be32063e940e47177cf2fb2cb00040229ad22f48 Mon Sep 17 00:00:00 2001 From: Sheetal Giri Date: Fri, 14 Aug 2026 16:11:17 +0200 Subject: [PATCH 36/37] Updated comments --- lamindb/core/_verify_lineage.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/lamindb/core/_verify_lineage.py b/lamindb/core/_verify_lineage.py index 448da0f51..66592cdb7 100644 --- a/lamindb/core/_verify_lineage.py +++ b/lamindb/core/_verify_lineage.py @@ -64,8 +64,8 @@ def __init__(self): self.untracked_paths: dict[str, list[int]] = {} # Best-effort binding of variable names to path-like strings as code is traversed. self.var_map: dict[str, set[str]] = {} # var_name -> set of path strings - # Variable names that currently refer to an ln.Artifact(...) instance. - self.artifact_vars: set[str] = set() # vars assigned from ln.Artifact(...) + # Variable names that currently refer to a lamindb Artifact(...) instance. + self.artifact_vars: set[str] = set() # vars assigned from lamindb.Artifact(...) # Session-level lifecycle flags required for a "fully tracked" script. self.has_track_call: bool = False self.has_finish_call: bool = False @@ -100,7 +100,7 @@ def visit_FunctionDef(self, node: ast.FunctionDef): else self._get_func_name(decorator) ) if self._is_lamindb_flow_name(decorator_name): - # `@ln.flow` provides run lifecycle management. + # `@lamindb.flow` provides run lifecycle management. self.has_track_call = True self.has_finish_call = True # Save function bodies for later path-flow tracing at call sites. @@ -192,7 +192,7 @@ def visit_Call(self, node: ast.Call): self.generic_visit(node) def _get_func_name(self, node: ast.AST) -> str: - """Recursively resolves AST attribute names (e.g. ln.Artifact.get).""" + """Recursively resolves AST attribute names (e.g. lamindb.Artifact.get).""" if isinstance(node, ast.Name): return node.id elif isinstance(node, ast.Attribute): @@ -219,8 +219,9 @@ def _is_lamindb_flow_name(self, func_name: str) -> bool: ) def _is_lamindb_artifact_save_call(self, node: ast.Call) -> bool: - # Treat both `ln.Artifact(...).save()` and `artifact.save()` (where artifact - # was created from ln.Artifact(...)) as LaminDB lineage-tracked operations. + # Treat both `lamindb.Artifact(...).save()` and `artifact.save()` + # (where artifact was created from `lamindb.Artifact(...)`) + # as LaminDB lineage-tracked operations. if not (isinstance(node.func, ast.Attribute) and node.func.attr == "save"): return False if isinstance(node.func.value, ast.Call): @@ -230,7 +231,7 @@ def _is_lamindb_artifact_save_call(self, node: ast.Call) -> bool: return False def _is_lamindb_call(self, node: ast.Call, func_name: str) -> bool: - # All ln./lamindb.* calls are considered lineage-aware. + # All calls rooted at `lamindb` (or any imported alias like `ln`) are lineage-aware. # We also include artifact save calls recognized above. root_name = func_name.split(".", 1)[0] if root_name in self.lamindb_module_aliases: @@ -357,12 +358,14 @@ def verify_lineage(script_path: str) -> VerifyLineageResult: """Statically analyze one script and report missing LaminDB lineage hooks. Coverage (static AST checks): - - Confirms the script contains explicit session lifecycle calls: - `ln.track()` and `ln.finish()`. + - Confirms the script contains explicit session lifecycle calls from LaminDB: + `track()` and `finish()` (or `flow()`). - Extracts path-like strings from literals, variables, call arguments, and simple path joins (string `+` or path `/` operations). - Classifies path usage as tracked when it appears in LaminDB calls - (`ln.*`, `lamindb.*`, including `ln.Artifact(...).save()` patterns), + (`ln.*`, `lamindb.*`, or any alias like `import lamindb as alias`, + including `x=lamindb.Artifact(...)->x.save()` patterns and + `from lamindb import Artifact`), and flags paths that only appear in non-LaminDB calls. - Propagates path-like argument bindings into user-defined helper functions to catch indirect path usage. @@ -410,7 +413,8 @@ def verify_lineage(script_path: str) -> VerifyLineageResult: missing_lineage: list[str] = [] - # A script is fully tracked only if it explicitly opens and closes tracking. + # A script is fully tracked only if it opens and closes tracking + # (or uses `flow()` lifecycle management). if not checker.has_track_call: missing_lineage.append("Missing ln.track() call in script.") From 138908a90616994663b825709f7fcee1de771103 Mon Sep 17 00:00:00 2001 From: Ebad Khan Date: Mon, 17 Aug 2026 13:42:34 +0200 Subject: [PATCH 37/37] =?UTF-8?q?=F0=9F=94=96=20Bump=20lamin-cli=20to=20ma?= =?UTF-8?q?in=20(#259,=20#260=20merged)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sub/lamin-cli | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sub/lamin-cli b/sub/lamin-cli index 9041d0ef7..6cf93c893 160000 --- a/sub/lamin-cli +++ b/sub/lamin-cli @@ -1 +1 @@ -Subproject commit 9041d0ef732d6b50ca041f5d70560a4e1847a329 +Subproject commit 6cf93c893d11e886cde77a6a9b68207076757242