Skip to content

Commit d94ad4b

Browse files
authored
Merge pull request #160 from forcedotcom/jo_lineage_gen
generate lineage
2 parents f1dc099 + 99c909f commit d94ad4b

14 files changed

Lines changed: 2158 additions & 8 deletions

File tree

‎README.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,29 @@ If none of these exist, `find_file_path` raises `FileNotFoundError` with the lis
245245

246246
`$LIBRARY_PATH` is set automatically to the root of the package at runtime inside Data Cloud.
247247

248+
### Generating lineage
249+
250+
`generate_lineage` runs a script entry point locally and derives the field-level `dataLineage` section of `config.json` from the Spark plans of the DataFrames it writes:
251+
252+
```python
253+
from datacustomcode.lineage import generate_lineage
254+
255+
result = generate_lineage("payload/entrypoint.py", sf_cli_org="my-org")
256+
print(result.to_dict()["dataLineage"])
257+
# [{"targetField": "Output__dll.name__c",
258+
# "sourceFields": ["Input__dll.first__c", "Input__dll.last__c"]}, ...]
259+
```
260+
261+
The entry point runs exactly as it would locally (same config, reader, and credentials), except that writes are traced and validated against the target DLO instead of being performed. It runs in a separate Python process, which inherits your working directory, environment, and `sys.path`; errors raised by the script are re-raised from `generate_lineage`. Reads use the reader's `default_row_limit` (1000 by default; set it in your `config.yaml`).
262+
263+
> **Review the output before you use it.** The generated lineage is a starting point for review, not a guarantee. It reflects only the code paths and data your local run exercised, and it can't see inside UDFs. Accurate and complete `dataLineage` in your package is your responsibility as the package author.
264+
265+
- A target field's sources are the fields its *value* is computed from. Fields that only decide which rows, groups or branch are used aren't sources, however the logic is written: filter conditions, join keys, `groupBy` keys, sort keys, window `partitionBy`/`orderBy` keys, `when()`/`if()` conditions, and aggregate `FILTER (WHERE …)` clauses. For example, `groupBy("region").agg(sum("amount"))` and `sum("amount").over(Window.partitionBy("region"))` both trace to `amount` only.
266+
- Fields computed only from literals are listed in `noSources`. So are fields whose value doesn't come from any input field's value, such as `row_number()`, `rank()`, `count("*")`, or `sum(when(cond, 1))`.
267+
- Fields that can't be traced are listed in `unresolved` with a reason and get no lineage. This covers data that didn't come from `read_dlo`/`read_dmo` (for example a `toPandas()` → `createDataFrame` round trip), `mapInPandas`, and subqueries.
268+
- `pairCount` is the total number of (target, source) field pairs.
269+
- Only batch script packages are supported.
270+
248271

249272
## CLI
250273

‎src/datacustomcode/client.py‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,13 @@ def __new__(
376376
instance._spark_named_credential = spark_named_credential
377377
# Initialize Readers and Writers from config
378378
# and/or provided reader and writer
379-
if reader is None or writer is None:
379+
reader_forced = (
380+
config.reader_config is not None and config.reader_config.force
381+
)
382+
writer_forced = (
383+
config.writer_config is not None and config.writer_config.force
384+
)
385+
if reader is None or writer is None or reader_forced or writer_forced:
380386
# We need a spark because we will initialize readers and writers.
381387
# Reuse the process-wide session if one client already built it,
382388
# so a Client and a StreamingClient share a single connection.
@@ -404,19 +410,15 @@ def __new__(
404410
raise ValueError(
405411
"Reader config is required when reader is not provided"
406412
)
407-
elif reader is None or (
408-
config.reader_config is not None and config.reader_config.force
409-
):
413+
elif reader is None or reader_forced:
410414
reader_init = config.reader_config.to_object(spark) # type: ignore
411415
else:
412416
reader_init = reader
413417
if config.writer_config is None and writer is None:
414418
raise ValueError(
415419
"Writer config is required when writer is not provided"
416420
)
417-
elif writer is None or (
418-
config.writer_config is not None and config.writer_config.force
419-
):
421+
elif writer is None or writer_forced:
420422
writer_init = config.writer_config.to_object(spark) # type: ignore
421423
else:
422424
writer_init = writer
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Copyright (c) 2025, Salesforce, Inc.
2+
# SPDX-License-Identifier: Apache-2
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Generate config.json ``dataLineage`` by tracing a script's Spark plans."""
16+
17+
from __future__ import annotations
18+
19+
import builtins
20+
import json
21+
import os
22+
import subprocess
23+
import sys
24+
import tempfile
25+
from typing import (
26+
Any,
27+
Dict,
28+
NoReturn,
29+
Optional,
30+
Sequence,
31+
)
32+
33+
from datacustomcode.lineage.capture import (
34+
LineageCaptureReader,
35+
LineageCaptureWriter,
36+
LineageCollector,
37+
LineageResult,
38+
install_capture,
39+
)
40+
from datacustomcode.lineage.tracer import LineageTraceError
41+
42+
__all__ = [
43+
"LineageCaptureReader",
44+
"LineageCaptureWriter",
45+
"LineageCollector",
46+
"LineageResult",
47+
"LineageTraceError",
48+
"generate_lineage",
49+
"install_capture",
50+
]
51+
52+
53+
def generate_lineage(
54+
entrypoint: str,
55+
config_file: Optional[str] = None,
56+
dependencies: Sequence[str] = (),
57+
profile: str = "default",
58+
sf_cli_org: Optional[str] = None,
59+
) -> LineageResult:
60+
"""Run a script entrypoint and return the field-level lineage it produces.
61+
62+
The entrypoint runs exactly as with ``run`` (same config resolution, reader
63+
and credentials), except that writes are traced instead of performed. It runs
64+
in a fresh Python process, so no client or config from the caller's process
65+
leaks into the capture. The process inherits this one's working directory,
66+
environment, ``sys.path`` and stdout/stderr.
67+
68+
Args:
69+
entrypoint: The script entrypoint to run.
70+
config_file: Optional SDK config file, as for ``run``.
71+
dependencies: Modules to import before running, as for ``run``.
72+
profile: The credentials profile to use.
73+
sf_cli_org: Optional SF CLI org alias or username.
74+
75+
Returns:
76+
The captured :class:`LineageResult`.
77+
78+
Raises:
79+
ValueError: If the package is not a batch script.
80+
Exception: Whatever the run raised, re-raised as the same built-in
81+
exception type or :class:`LineageTraceError`, else ``RuntimeError``.
82+
"""
83+
with tempfile.TemporaryDirectory(prefix="datacustomcode-lineage-") as tmp:
84+
request_file = os.path.join(tmp, "request.json")
85+
response_file = os.path.join(tmp, "response.json")
86+
with open(request_file, "w", encoding="utf-8") as f:
87+
json.dump(
88+
{
89+
"args": {
90+
"entrypoint": entrypoint,
91+
"config_file": config_file,
92+
"dependencies": list(dependencies),
93+
"profile": profile,
94+
"sf_cli_org": sf_cli_org,
95+
},
96+
"response_file": response_file,
97+
},
98+
f,
99+
)
100+
# The worker sees the same modules this process does
101+
env = dict(os.environ)
102+
env["PYTHONPATH"] = os.pathsep.join(path for path in sys.path if path)
103+
completed = subprocess.run(
104+
[sys.executable, "-m", "datacustomcode.lineage._worker", request_file],
105+
env=env,
106+
check=False,
107+
)
108+
if not os.path.exists(response_file):
109+
raise RuntimeError(
110+
f"Lineage generation exited with code {completed.returncode} "
111+
"without a result"
112+
)
113+
with open(response_file, encoding="utf-8") as f:
114+
response = json.load(f)
115+
if "error" in response:
116+
_raise(response["error"])
117+
return LineageResult.from_dict(response["result"])
118+
119+
120+
def _raise(error: Dict[str, Any]) -> NoReturn:
121+
"""Re-raise an error reported by the worker as the closest local type."""
122+
message = error["message"]
123+
if error["type"] == LineageTraceError.__name__:
124+
raise LineageTraceError(message)
125+
exc_type = getattr(builtins, error["type"], None)
126+
if (
127+
error["module"] == "builtins"
128+
and isinstance(exc_type, type)
129+
and issubclass(exc_type, Exception)
130+
):
131+
try:
132+
exc = exc_type(message)
133+
except TypeError: # e.g. UnicodeDecodeError takes more arguments
134+
pass
135+
else:
136+
raise exc
137+
raise RuntimeError(f"{error['module']}.{error['type']}: {message}")
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Copyright (c) 2025, Salesforce, Inc.
2+
# SPDX-License-Identifier: Apache-2
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Runs one lineage capture in a fresh process, for ``generate_lineage``.
16+
17+
Usage: ``python -m datacustomcode.lineage._worker <request.json>``. The request
18+
holds the ``run_capture`` arguments and the path of a response file, which gets
19+
either ``{"result": LineageResult.to_dict()}`` or
20+
``{"error": {"module", "type", "message"}}``. The response goes to a file rather
21+
than stdout because the entrypoint prints freely to stdout.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import json
27+
import sys
28+
import traceback
29+
from typing import (
30+
List,
31+
Optional,
32+
Sequence,
33+
)
34+
35+
from datacustomcode.lineage.capture import (
36+
LineageCollector,
37+
LineageResult,
38+
install_capture,
39+
)
40+
41+
42+
def run_capture(
43+
entrypoint: str,
44+
config_file: Optional[str],
45+
dependencies: Sequence[str],
46+
profile: str,
47+
sf_cli_org: Optional[str],
48+
) -> LineageResult:
49+
"""Run the entrypoint with capture installed. Call only in a fresh process."""
50+
from datacustomcode.run import (
51+
execute_entrypoint,
52+
import_dependencies,
53+
prepare_run,
54+
)
55+
56+
# Dependencies are imported after capture is installed, so a Client they
57+
# create at import time is captured too
58+
context = prepare_run(entrypoint, config_file, [], profile, sf_cli_org)
59+
if context.package_type != "script":
60+
raise ValueError(
61+
f"Lineage can only be generated for script packages, not "
62+
f"{context.package_type} packages"
63+
)
64+
if context.config_json.get("streamingSource"):
65+
raise ValueError("Lineage generation does not support streaming scripts")
66+
67+
collector = LineageCollector()
68+
install_capture(collector)
69+
import_dependencies(list(dependencies))
70+
execute_entrypoint(entrypoint, context)
71+
return collector.result()
72+
73+
74+
def main(argv: List[str]) -> int:
75+
with open(argv[0], encoding="utf-8") as f:
76+
request = json.load(f)
77+
try:
78+
result = run_capture(**request["args"])
79+
response: dict = {"result": result.to_dict()}
80+
except Exception as exc:
81+
# The caller re-raises from the response; the traceback is for the
82+
# customer, pointing into their entrypoint
83+
traceback.print_exc()
84+
response = {
85+
"error": {
86+
"module": type(exc).__module__,
87+
"type": type(exc).__name__,
88+
"message": str(exc),
89+
}
90+
}
91+
with open(request["response_file"], "w", encoding="utf-8") as f:
92+
json.dump(response, f)
93+
return 1 if "error" in response else 0
94+
95+
96+
if __name__ == "__main__":
97+
sys.exit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)