mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
266 lines
9.5 KiB
Python
266 lines
9.5 KiB
Python
"""Metadata API listMetadata wrappers — for sObjects that are NOT
|
|
exposed via SOQL on Tooling or Data API (e.g. GenAiPromptTemplate).
|
|
|
|
Wraps the sf CLI `sf org list metadata` recipe. Not a REST client call —
|
|
uses subprocess via sf_cli.run_sf. Failure semantics mirror the rest:
|
|
returns [] on known-missing + surfaces unresolved reasons upstream;
|
|
raises SfCliError / AuthRequired on unknown failures.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import xml.etree.ElementTree as ET
|
|
import zipfile
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List
|
|
|
|
from rest_client import redact_error
|
|
from sf_cli import (
|
|
AuthRequired,
|
|
_redact_subprocess_stderr,
|
|
_stderr_matches_auth,
|
|
run_sf,
|
|
)
|
|
|
|
|
|
# Metadata XML default namespace — same as parse_bundle.NS. Duplicated
|
|
# (rather than imported) to keep the "no intra-skill imports" convention
|
|
# documented throughout the pipeline.
|
|
_NS = {"sf": "http://soap.sforce.com/2006/04/metadata"}
|
|
|
|
|
|
def list_prompt_template_metadata(org_alias: str) -> List[Dict[str, str]]:
|
|
"""Return every GenAiPromptTemplate metadata component in the org.
|
|
|
|
Each row has the shape produced by `sf org list metadata --json`:
|
|
{"id": "0hfUv...", "fullName": "AGNT_...", "type": "GenAiPromptTemplate",
|
|
"namespacePrefix": "...", ...}
|
|
|
|
Empty list on success with no templates. Raises SfCliError on CLI
|
|
failure (caller decides whether to treat as fatal).
|
|
"""
|
|
data = run_sf("list_metadata_genaiprompttemplate", ORG_ALIAS=org_alias)
|
|
result = data.get("result")
|
|
if not isinstance(result, list):
|
|
return []
|
|
return [r for r in result if isinstance(r, dict)]
|
|
|
|
|
|
def _find_text(el: ET.Element | None, path: str) -> str | None:
|
|
if el is None:
|
|
return None
|
|
found = el.find(path, _NS)
|
|
if found is None:
|
|
return None
|
|
return found.text
|
|
|
|
|
|
def _parse_prompt_template_xml(xml_bytes: bytes) -> Dict[str, Any] | None:
|
|
"""Parse a single .genAiPromptTemplate XML file into a body dict.
|
|
|
|
Returns None on parse failure so the caller can log + skip. The
|
|
returned dict carries developerName/masterLabel/activeVersionIdentifier/
|
|
content/inputs — absent optional fields become None or empty lists.
|
|
|
|
Version selection: if multiple <templateVersions> elements exist,
|
|
prefer the one whose `versionIdentifier` matches the template-level
|
|
`activeVersionIdentifier`; otherwise fall back to the first version.
|
|
"""
|
|
try:
|
|
root = ET.fromstring(xml_bytes)
|
|
except ET.ParseError:
|
|
return None
|
|
|
|
developer_name = _find_text(root, "sf:developerName")
|
|
master_label = _find_text(root, "sf:masterLabel")
|
|
active_version = _find_text(root, "sf:activeVersionIdentifier")
|
|
|
|
versions = root.findall("sf:templateVersions", _NS)
|
|
picked: ET.Element | None = None
|
|
if versions:
|
|
if active_version:
|
|
for v in versions:
|
|
vid = _find_text(v, "sf:versionIdentifier")
|
|
if vid and vid == active_version:
|
|
picked = v
|
|
break
|
|
if picked is None:
|
|
picked = versions[0]
|
|
|
|
content = _find_text(picked, "sf:content") if picked is not None else None
|
|
|
|
inputs: List[Dict[str, str]] = []
|
|
if picked is not None:
|
|
for inp in picked.findall("sf:inputs", _NS):
|
|
name = _find_text(inp, "sf:apiName") or _find_text(inp, "sf:name")
|
|
data_type = _find_text(inp, "sf:dataType")
|
|
if name is None and data_type is None:
|
|
continue
|
|
entry: Dict[str, str] = {}
|
|
if name is not None:
|
|
entry["name"] = name
|
|
if data_type is not None:
|
|
entry["dataType"] = data_type
|
|
inputs.append(entry)
|
|
|
|
return {
|
|
"developerName": developer_name,
|
|
"masterLabel": master_label,
|
|
"activeVersionIdentifier": active_version,
|
|
"content": content,
|
|
"inputs": inputs,
|
|
}
|
|
|
|
|
|
# Timeout for `sf project retrieve start` — matches the prior YAML recipe's
|
|
# `timeout_seconds: 300`. Kept as a module constant (rather than a literal
|
|
# at the call site) so future adjustments have a single home.
|
|
_RETRIEVE_TIMEOUT_SECONDS = 300
|
|
|
|
# stderr patterns that mean "the CLI ran but there is no authenticated org
|
|
# for this alias" — reraised as AuthRequired. Mirrors the prior recipe's
|
|
# `auth_required_stderr_patterns` field.
|
|
_RETRIEVE_AUTH_PATTERNS = ("NoOrgAuthenticationError", "AuthInfoError")
|
|
|
|
|
|
def _run_retrieve(argv: List[str], timeout: int) -> "subprocess.CompletedProcess[str]":
|
|
"""Thin wrapper over `subprocess.run` for the retrieve-start call.
|
|
|
|
Exists so tests can mock the subprocess boundary without also mocking
|
|
the argv-construction logic — see `RetrievePromptTemplatesTests`.
|
|
"""
|
|
return subprocess.run(
|
|
argv,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def retrieve_prompt_templates(
|
|
org_alias: str,
|
|
template_names: List[str],
|
|
work_dir: Path,
|
|
) -> Dict[str, Dict[str, Any]]:
|
|
"""Retrieve GenAiPromptTemplate bodies via `sf project retrieve start`.
|
|
|
|
The sf plugin's `--metadata` flag is REPEATED once per requested
|
|
template (`--metadata GenAiPromptTemplate:A --metadata
|
|
GenAiPromptTemplate:B ...`). A comma-joined value is NOT a valid list;
|
|
the CLI treats the whole comma-joined string as a single malformed
|
|
member name, silently skips it, and the resulting zip contains only
|
|
`package.xml` (observed 2026-05-05 against my-org-alias). Hence we
|
|
bypass the YAML recipe loader here — `run_sf` substitutes
|
|
`{{METADATA_SPEC}}` single-shot and has no list-expansion primitive.
|
|
|
|
The resulting `unpackaged.zip` carries one `.genAiPromptTemplate` XML
|
|
file per template under `unpackaged/genAiPromptTemplates/`.
|
|
|
|
Returns a dict keyed by `developerName`. Templates the retrieve
|
|
didn't produce a file for are OMITTED (caller handles missing by
|
|
leaving them in `_pending_fetches`).
|
|
|
|
Failure modes:
|
|
- Empty `template_names` → short-circuits with `{}` and no sf call.
|
|
- `AuthRequired` (stderr matches auth patterns) → re-raised.
|
|
- `SfCliError` (any other non-zero exit, timeout, etc) → swallowed;
|
|
returns `{}`. Caller logs the retrieval-failure reason at the
|
|
call site.
|
|
- Zip missing or malformed → returns `{}`.
|
|
- XML parse failure on a specific file → that file is skipped; the
|
|
other templates in the same zip still parse and return.
|
|
"""
|
|
if not template_names:
|
|
return {}
|
|
|
|
retrieve_dir = work_dir / "prompt_template_retrieve"
|
|
retrieve_dir.mkdir(parents=True, exist_ok=True)
|
|
# Nuke stale files from a prior invocation so we don't trust an old
|
|
# unpackaged.zip if this run's sf call fails before writing one.
|
|
for path in retrieve_dir.iterdir():
|
|
if path.is_file():
|
|
path.unlink()
|
|
elif path.is_dir():
|
|
_rm_tree(path)
|
|
|
|
argv: List[str] = [
|
|
"sf",
|
|
"project",
|
|
"retrieve",
|
|
"start",
|
|
"--target-org",
|
|
org_alias,
|
|
"--target-metadata-dir",
|
|
str(retrieve_dir),
|
|
"--json",
|
|
]
|
|
for name in template_names:
|
|
argv.extend(["--metadata", f"GenAiPromptTemplate:{name}"])
|
|
|
|
try:
|
|
cp = _run_retrieve(argv, _RETRIEVE_TIMEOUT_SECONDS)
|
|
except subprocess.TimeoutExpired as e:
|
|
# timeout exceptions may carry stderr; route through
|
|
# redact_error before surfacing — then swallow as non-fatal, same
|
|
# contract the old run_sf-based path had for SfCliError.
|
|
_ = redact_error(e)
|
|
return {}
|
|
except (OSError, subprocess.SubprocessError) as e:
|
|
_ = redact_error(e)
|
|
return {}
|
|
|
|
if cp.returncode != 0:
|
|
# Classify: auth failure re-raises, everything else is non-fatal.
|
|
if _stderr_matches_auth(cp.stderr, _RETRIEVE_AUTH_PATTERNS):
|
|
safe_stderr = _redact_subprocess_stderr(cp.stderr)
|
|
raise AuthRequired(safe_stderr or "auth required")
|
|
return {}
|
|
|
|
zip_path = retrieve_dir / "unpackaged.zip"
|
|
if not zip_path.exists():
|
|
return {}
|
|
|
|
results: Dict[str, Dict[str, Any]] = {}
|
|
try:
|
|
with zipfile.ZipFile(zip_path) as zf:
|
|
for info in zf.infolist():
|
|
name = info.filename
|
|
if not name.endswith(".genAiPromptTemplate"):
|
|
continue
|
|
if "genAiPromptTemplates/" not in name:
|
|
continue
|
|
try:
|
|
xml_bytes = zf.read(info)
|
|
except (KeyError, zipfile.BadZipFile):
|
|
continue
|
|
body = _parse_prompt_template_xml(xml_bytes)
|
|
if not body:
|
|
continue
|
|
dev_name = body.get("developerName")
|
|
if not dev_name:
|
|
# Fall back to the file stem if the XML somehow lacks
|
|
# a <developerName> element — keeps the caller's
|
|
# lookup working.
|
|
stem = Path(name).stem
|
|
if stem:
|
|
body["developerName"] = stem
|
|
dev_name = stem
|
|
if dev_name:
|
|
results[dev_name] = body
|
|
except zipfile.BadZipFile:
|
|
return {}
|
|
|
|
return results
|
|
|
|
|
|
def _rm_tree(path: Path) -> None:
|
|
"""Recursively delete a directory tree. stdlib-only, no shutil import
|
|
at module top to keep the import surface minimal."""
|
|
for child in path.iterdir():
|
|
if child.is_dir():
|
|
_rm_tree(child)
|
|
else:
|
|
child.unlink()
|
|
path.rmdir()
|