Repository navigation
generate lineage #160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
generate lineage #160
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
f9d157b
adding some lineage generating features
joroscoSF ded2ea2
remediate LLM findings
joroscoSF 27dbe95
more bugfixes
joroscoSF eadd5db
addressing PR comments
joroscoSF 856d863
more UDF tracer things
joroscoSF 99c909f
few more PR comments, and updated docs
joroscoSF File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| # Copyright (c) 2025, Salesforce, Inc. | ||
| # SPDX-License-Identifier: Apache-2 | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
| """Generate config.json ``dataLineage`` by tracing a script's Spark plans.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import builtins | ||
| import json | ||
| import os | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
| from typing import ( | ||
| Any, | ||
| Dict, | ||
| NoReturn, | ||
| Optional, | ||
| Sequence, | ||
| ) | ||
|
|
||
| from datacustomcode.lineage.capture import ( | ||
| LineageCaptureReader, | ||
| LineageCaptureWriter, | ||
| LineageCollector, | ||
| LineageResult, | ||
| install_capture, | ||
| ) | ||
| from datacustomcode.lineage.tracer import LineageTraceError | ||
|
|
||
| __all__ = [ | ||
| "LineageCaptureReader", | ||
| "LineageCaptureWriter", | ||
| "LineageCollector", | ||
| "LineageResult", | ||
| "LineageTraceError", | ||
| "generate_lineage", | ||
| "install_capture", | ||
| ] | ||
|
|
||
|
|
||
| def generate_lineage( | ||
| entrypoint: str, | ||
| config_file: Optional[str] = None, | ||
| dependencies: Sequence[str] = (), | ||
| profile: str = "default", | ||
| sf_cli_org: Optional[str] = None, | ||
| ) -> LineageResult: | ||
| """Run a script entrypoint and return the field-level lineage it produces. | ||
|
|
||
| The entrypoint runs exactly as with ``run`` (same config resolution, reader | ||
| and credentials), except that writes are traced instead of performed. It runs | ||
| in a fresh Python process, so no client or config from the caller's process | ||
| leaks into the capture. The process inherits this one's working directory, | ||
| environment, ``sys.path`` and stdout/stderr. | ||
|
|
||
| Args: | ||
| entrypoint: The script entrypoint to run. | ||
| config_file: Optional SDK config file, as for ``run``. | ||
| dependencies: Modules to import before running, as for ``run``. | ||
| profile: The credentials profile to use. | ||
| sf_cli_org: Optional SF CLI org alias or username. | ||
|
|
||
| Returns: | ||
| The captured :class:`LineageResult`. | ||
|
|
||
| Raises: | ||
| ValueError: If the package is not a batch script. | ||
| Exception: Whatever the run raised, re-raised as the same built-in | ||
| exception type or :class:`LineageTraceError`, else ``RuntimeError``. | ||
| """ | ||
| with tempfile.TemporaryDirectory(prefix="datacustomcode-lineage-") as tmp: | ||
| request_file = os.path.join(tmp, "request.json") | ||
| response_file = os.path.join(tmp, "response.json") | ||
| with open(request_file, "w", encoding="utf-8") as f: | ||
| json.dump( | ||
| { | ||
| "args": { | ||
| "entrypoint": entrypoint, | ||
| "config_file": config_file, | ||
| "dependencies": list(dependencies), | ||
| "profile": profile, | ||
| "sf_cli_org": sf_cli_org, | ||
| }, | ||
| "response_file": response_file, | ||
| }, | ||
| f, | ||
| ) | ||
| # The worker sees the same modules this process does | ||
| env = dict(os.environ) | ||
| env["PYTHONPATH"] = os.pathsep.join(path for path in sys.path if path) | ||
| completed = subprocess.run( | ||
| [sys.executable, "-m", "datacustomcode.lineage._worker", request_file], | ||
| env=env, | ||
| check=False, | ||
| ) | ||
| if not os.path.exists(response_file): | ||
| raise RuntimeError( | ||
| f"Lineage generation exited with code {completed.returncode} " | ||
| "without a result" | ||
| ) | ||
| with open(response_file, encoding="utf-8") as f: | ||
| response = json.load(f) | ||
| if "error" in response: | ||
| _raise(response["error"]) | ||
| return LineageResult.from_dict(response["result"]) | ||
|
|
||
|
|
||
| def _raise(error: Dict[str, Any]) -> NoReturn: | ||
| """Re-raise an error reported by the worker as the closest local type.""" | ||
| message = error["message"] | ||
| if error["type"] == LineageTraceError.__name__: | ||
| raise LineageTraceError(message) | ||
| exc_type = getattr(builtins, error["type"], None) | ||
| if ( | ||
| error["module"] == "builtins" | ||
| and isinstance(exc_type, type) | ||
| and issubclass(exc_type, Exception) | ||
| ): | ||
| try: | ||
| exc = exc_type(message) | ||
| except TypeError: # e.g. UnicodeDecodeError takes more arguments | ||
| pass | ||
| else: | ||
| raise exc | ||
| raise RuntimeError(f"{error['module']}.{error['type']}: {message}") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| # Copyright (c) 2025, Salesforce, Inc. | ||
| # SPDX-License-Identifier: Apache-2 | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
| """Runs one lineage capture in a fresh process, for ``generate_lineage``. | ||
|
|
||
| Usage: ``python -m datacustomcode.lineage._worker <request.json>``. The request | ||
| holds the ``run_capture`` arguments and the path of a response file, which gets | ||
| either ``{"result": LineageResult.to_dict()}`` or | ||
| ``{"error": {"module", "type", "message"}}``. The response goes to a file rather | ||
| than stdout because the entrypoint prints freely to stdout. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import sys | ||
| import traceback | ||
| from typing import ( | ||
| List, | ||
| Optional, | ||
| Sequence, | ||
| ) | ||
|
|
||
| from datacustomcode.lineage.capture import ( | ||
| LineageCollector, | ||
| LineageResult, | ||
| install_capture, | ||
| ) | ||
|
|
||
|
|
||
| def run_capture( | ||
| entrypoint: str, | ||
| config_file: Optional[str], | ||
| dependencies: Sequence[str], | ||
| profile: str, | ||
| sf_cli_org: Optional[str], | ||
| ) -> LineageResult: | ||
| """Run the entrypoint with capture installed. Call only in a fresh process.""" | ||
| from datacustomcode.run import ( | ||
| execute_entrypoint, | ||
| import_dependencies, | ||
| prepare_run, | ||
| ) | ||
|
|
||
| # Dependencies are imported after capture is installed, so a Client they | ||
| # create at import time is captured too | ||
| context = prepare_run(entrypoint, config_file, [], profile, sf_cli_org) | ||
| if context.package_type != "script": | ||
| raise ValueError( | ||
| f"Lineage can only be generated for script packages, not " | ||
| f"{context.package_type} packages" | ||
| ) | ||
| if context.config_json.get("streamingSource"): | ||
| raise ValueError("Lineage generation does not support streaming scripts") | ||
|
|
||
| collector = LineageCollector() | ||
| install_capture(collector) | ||
| import_dependencies(list(dependencies)) | ||
| execute_entrypoint(entrypoint, context) | ||
| return collector.result() | ||
|
|
||
|
|
||
| def main(argv: List[str]) -> int: | ||
| with open(argv[0], encoding="utf-8") as f: | ||
| request = json.load(f) | ||
| try: | ||
| result = run_capture(**request["args"]) | ||
| response: dict = {"result": result.to_dict()} | ||
| except Exception as exc: | ||
| # The caller re-raises from the response; the traceback is for the | ||
| # customer, pointing into their entrypoint | ||
| traceback.print_exc() | ||
| response = { | ||
| "error": { | ||
| "module": type(exc).__module__, | ||
| "type": type(exc).__name__, | ||
| "message": str(exc), | ||
| } | ||
| } | ||
| with open(request["response_file"], "w", encoding="utf-8") as f: | ||
| json.dump(response, f) | ||
| return 1 if "error" in response else 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main(sys.argv[1:])) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we speak more to this tool being a good "starting point to review" or however we want to phrase it - and how accurate, full data lineage is the author's responsibility?