← Files SavvyARCHIVED FILE
skills/savvy/scripts/alteryx/parse.py
38.8 KB · Oct 5, 2026 · 18:27 UTC
#!/usr/bin/env python3
"""Parse an Alteryx workflow package (.yxmd / .yxwz / .yxmc / .yxzp) into a migration inventory.
Output: one JSON file with
- workflow metadata and every tool (id, type, category, annotation, config summary, parent container)
- connections (origin tool/anchor -> destination tool/anchor)
- inputs / outputs / macros / external dependencies
- validation checkpoints (tools where row counts change) with inferred grain keys
- behaviour-difference traps found in this workflow
- dead-code / inconsistency heuristics
- a coverage summary (mapped exact / equivalent / approximated / unsupported / dropped)
Usage (through the toolchain router):
savant.py alteryx parse <file.yxmd|.yxzp> [--out inventory.json] [--markdown inventory.md]
With no --out the inventory lands in the session tmp dir (`savant.py session tmp-path alteryx`).
The tool-to-Savant mapping lives in solutions/alteryx/references/tool-mapping.json; edit that
file, not this script, to change how a tool type is classified. `--mapping` overrides the path.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
HERE = Path(__file__).resolve().parent
SCRIPTS_DIR = HERE.parent
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from savant_api.fileio import workspace_tmp # noqa: E402
# scripts/alteryx/parse.py -> skills/savvy/solutions/alteryx/references/tool-mapping.json
MAPPING_PATH = SCRIPTS_DIR.parent / "solutions" / "alteryx" / "references" / "tool-mapping.json"
# ----------------------------------------------------------------------------- loading
def load_package(path: Path) -> dict[str, ET.Element]:
"""Return {relative_name: root_element} for every workflow/macro XML in the input."""
docs: dict[str, ET.Element] = {}
if path.suffix.lower() == ".yxzp":
with zipfile.ZipFile(path) as z:
for name in z.namelist():
if Path(name).suffix.lower() in {".yxmd", ".yxwz", ".yxmc"}:
docs[name] = ET.fromstring(z.read(name))
else:
docs[path.name] = ET.parse(path).getroot()
if not docs:
raise SystemExit(f"No Alteryx workflow XML found in {path}")
return docs
def plugin_of(node: ET.Element) -> str:
gs = node.find("GuiSettings")
return (gs.get("Plugin") or "") if gs is not None else ""
def tool_type(node: ET.Element) -> tuple[str, str | None]:
"""Return (type_name, macro_path). Macros are identified from EngineSettings."""
es = node.find("EngineSettings")
macro = es.get("Macro") if es is not None else None
if macro:
return ("Macro", macro.replace("\\\\", "\\"))
plugin = plugin_of(node)
if plugin.endswith(".0"): # some macro/app plugins register as '<Name>.0'
return ("Macro", plugin)
name = plugin.split(".")[-1] or "Unknown"
return (name, None)
def text(el: ET.Element | None) -> str:
return (el.text or "").strip() if el is not None else ""
# ----------------------------------------------------------------------------- config summaries
def summarize_config(ttype: str, node: ET.Element) -> dict: # noqa: C901
"""Extract the business-relevant settings per tool type (best effort, lossless fallback)."""
cfg = node.find("Properties/Configuration")
out: dict = {}
if cfg is None:
return out
if ttype == "DbFileInput":
f = text(cfg.find("File"))
conn, _, query = f.partition("|||")
out["connection"] = conn
fso = cfg.find("FormatSpecificOptions")
if fso is not None:
for tag in ("HeaderRow", "Delimeter", "CodePage", "FieldLen"):
v = text(fso.find(tag))
if v:
out[{"HeaderRow": "header_row", "Delimeter": "delimiter", "CodePage": "code_page", "FieldLen": "field_len"}[tag]] = v
meta = node.find("Properties/MetaInfo/RecordInfo")
if meta is not None:
out["fields"] = [f.get("name") for f in meta.findall("Field")]
out["field_types"] = {f.get("name"): f.get("type") for f in meta.findall("Field") if f.get("type")}
if query:
out["query"] = query
out["tables"] = sorted(set(re.findall(r'(?:FROM|JOIN)\s+((?:"?[\w]+"?\.){0,2}"?[\w]+"?)', query, re.I)))
elif ttype == "DbFileOutput":
out["file"] = text(cfg.find("File"))
elif ttype == "Filter":
out["mode"] = text(cfg.find("Mode"))
out["expression"] = text(cfg.find("Expression"))
simple = cfg.find("Simple")
if simple is not None:
out["field"] = text(simple.find("Field"))
out["operator"] = text(simple.find("Operator"))
ops = simple.find("Operands")
out["operand"] = text(ops.find("Operand")) if ops is not None else ""
elif ttype in {"Formula", "MultiRowFormula"}:
fields = []
for ff in cfg.iter("FormulaField"):
fields.append({"field": ff.get("field"), "expression": ff.get("expression"), "type": ff.get("type"), "size": ff.get("size")})
if ttype == "MultiRowFormula":
upd = cfg.find("UpdateField")
updating = upd is not None and upd.get("value") == "True"
fields.append({"field": text(cfg.find("UpdateField_Name")) if updating else text(cfg.find("CreateField_Name")),
"expression": text(cfg.find("Expression")),
"type": "(existing field)" if updating else text(cfg.find("CreateField_Type")),
"group_by": [f.get("field") for f in cfg.findall("GroupByFields/Field")],
"num_rows": (cfg.find("NumRows").get("value") if cfg.find("NumRows") is not None else ""),
"other_rows": text(cfg.find("OtherRows"))})
out["fields"] = fields
elif ttype == "AlteryxSelect":
sel = [{"field": s.get("field"), "selected": s.get("selected"), "rename": s.get("rename"),
"type": (f"{s.get('type')}({s.get('size')})" if s.get("type") and s.get("size") else s.get("type"))}
for s in cfg.iter("SelectField")]
out["deselected"] = [s["field"] for s in sel if s["selected"] == "False" and s["field"] != "*Unknown"]
out["renamed"] = {s["field"]: s["rename"] for s in sel if s.get("rename")}
out["retyped"] = {s["field"]: s["type"] for s in sel if s.get("type")}
out["unknown_dropped"] = any(s["field"] == "*Unknown" and s["selected"] == "False" for s in sel)
elif ttype == "Summarize":
out["group_by"] = [s.get("field") for s in cfg.iter("SummarizeField") if s.get("action") == "GroupBy"]
out["aggs"] = [{"field": s.get("field"), "action": s.get("action"), "rename": s.get("rename")}
for s in cfg.iter("SummarizeField") if s.get("action") != "GroupBy"]
elif ttype in {"Join", "JoinMultiple"}:
out["keys"] = [(text(j.find("Field")) if j.find("Field") is not None else j.get("field")) for j in cfg.findall("JoinInfo/Field")]
li = [j for j in cfg.findall("JoinInfo") if j.get("connection") == "Left"]
ri = [j for j in cfg.findall("JoinInfo") if j.get("connection") == "Right"]
out["left_keys"] = [f.get("field") for j in li for f in j.findall("Field")]
out["right_keys"] = [f.get("field") for j in ri for f in j.findall("Field")]
elif ttype == "Unique":
out["fields"] = [f.get("field") for f in cfg.findall("UniqueFields/Field")]
elif ttype == "Sort":
out["fields"] = [{"field": f.get("field"), "order": f.get("order")} for f in cfg.findall("SortInfo/Field")]
elif ttype == "Union":
out["mode"] = text(cfg.find("Mode"))
out["by_name"] = text(cfg.find("ByName_ErrorMode"))
elif ttype == "AppendFields":
out["warn_on_multiple"] = text(cfg.find("Warn"))
elif ttype == "TextBox":
out["text"] = text(cfg.find("Text"))
elif ttype in {"Browse", "BrowseV2"}:
pass # preview only; the config is a local temp path, not business logic
elif ttype == "Macro":
vals = {}
for v in cfg.iter("Value"):
k = v.get("name") or ""
if re.search(r"allDatasources|totalNum|totalDatasources|currentPage|password|token|secret", k, re.I):
continue
vals[k] = (v.text or "").strip()[:200]
out["parameters"] = vals
else:
raw = ET.tostring(cfg, encoding="unicode")
out["raw_config"] = re.sub(r"\s+", " ", raw)[:600]
return out
# ----------------------------------------------------------------------------- inventory
def walk_nodes(parent: ET.Element, container: str | None, acc: list, containers: dict):
for node in parent.findall("Node"):
tid = node.get("ToolID")
ttype, macro = tool_type(node)
ann = text(node.find("Properties/Annotation/AnnotationText"))
default_ann = text(node.find("Properties/Annotation/DefaultAnnotationText"))
pos = node.find("GuiSettings/Position")
entry = {
"tool_id": tid, "type": ttype, "macro": macro, "plugin": plugin_of(node),
"annotation": ann, "default_annotation": default_ann[:300],
"container": container,
"position": {"x": float(pos.get("x", 0)), "y": float(pos.get("y", 0))} if pos is not None else None,
"config": summarize_config(ttype, node),
}
if ttype == "ToolContainer":
cap = text(node.find("Properties/Configuration/Caption"))
containers[tid] = cap
entry["caption"] = cap
entry["disabled"] = text(node.find("Properties/Configuration/Disabled")) == "True"
acc.append(entry)
child = node.find("ChildNodes")
if child is not None:
walk_nodes(child, tid, acc, containers)
def parse_workflow(root: ET.Element, name: str) -> dict:
tools: list = []
containers: dict = {}
walk_nodes(root.find("Nodes"), None, tools, containers)
conns = []
for c in root.findall("Connections/Connection"):
o, d = c.find("Origin"), c.find("Destination")
conns.append({"from": o.get("ToolID"), "from_anchor": o.get("Connection"),
"to": d.get("ToolID"), "to_anchor": d.get("Connection"), "wireless": c.get("Wireless") == "True"})
meta = root.find("Properties/MetaInfo")
notes = [{"tool_id": t["tool_id"], "text": t["config"].get("text", "")} for t in tools if t["type"] == "TextBox" and t["config"].get("text")]
return {
"file": name,
"alteryx_version": root.get("yxmdVer"),
"workflow_name": text(meta.find("Name")) if meta is not None else "",
"workflow_description": text(meta.find("Description")) if meta is not None else "",
"author": text(meta.find("Author")) if meta is not None else "",
"containers": containers,
"notes": notes,
"tools": tools,
"connections": conns,
}
# ----------------------------------------------------------------------------- classification
def load_mapping(path: Path | None = None) -> dict:
mapping_path = Path(path) if path else MAPPING_PATH
if not mapping_path.exists():
raise SystemExit(f"Tool mapping not found at {mapping_path}; the solution package is incomplete.")
return json.loads(mapping_path.read_text())
def classify_tools(inv: dict, mapping: dict) -> None:
"""Attach savant target + mapping quality to each tool."""
for t in inv["tools"]:
if t["type"] == "Macro":
key = Path(t["macro"] or "").name.lower()
m = None
for pattern, spec in mapping.get("macros", {}).items():
if re.search(pattern, key, re.I):
m = spec
break
if m is None:
m = {"savant": "inline macro tools as steps (custom macro)", "mapping": "custom_macro"}
t["savant"] = m["savant"]
t["mapping"] = m["mapping"]
t["macro_kind"] = m.get("kind", "custom")
if m.get("p1"):
t["p1"] = m["p1"]
if t["macro_kind"] == "output":
# Output macros (Tableau, Power BI, Salesforce...) carry server credentials in
# generically named slots ("Text Box (14)") that a key-name filter cannot see.
# Keep only what cannot be a secret: URLs and enumerated controls (drop downs,
# check boxes, list boxes). Free-text slots are dropped; the destination name
# comes from the annotation.
kept, dropped = {}, 0
for k, v in t["config"].get("parameters", {}).items():
if re.match(r"https?://", v or "", re.I) or re.match(r"(Drop Down|Check Box|List Box|Radio)", k or "", re.I):
kept[k] = v
else:
dropped += 1
t["config"]["parameters"] = kept
t["config"]["parameters_redacted"] = f"output macro: {dropped} free-text parameter(s) dropped (may carry credentials); URLs and drop-down choices kept"
else:
m = mapping.get("tools", {}).get(t["type"], mapping.get("default"))
t["savant"] = m["savant"]
t["mapping"] = m["mapping"]
if m.get("p1"):
t["p1"] = m["p1"]
if m.get("trap"):
t["trap_hint"] = m["trap"]
# ----------------------------------------------------------------------------- checkpoints
ROW_CHANGING = {"Filter", "Join", "JoinMultiple", "Union", "Summarize", "Unique", "AppendFields",
"CrossTab", "Transpose", "Sample", "RecordID", "DbFileOutput", "TextToColumns", "RegEx"}
def infer_grain(tool: dict, tools_by_id: dict, upstream_keys: list[str]) -> list[str]:
cfg = tool["config"]
if tool["type"] == "Unique":
return cfg.get("fields", [])
if tool["type"] == "Summarize":
return cfg.get("group_by", [])
if tool["type"] in {"Join", "JoinMultiple"}:
# A join does not change the grain of the driving (left) stream when the right side is
# unique on the keys; the upstream Unique/Summarize grain stays the checkpoint grain.
return upstream_keys or cfg.get("left_keys") or cfg.get("keys") or []
return upstream_keys
def build_checkpoints(inv: dict) -> list[dict]:
by_id = {t["tool_id"]: t for t in inv["tools"]}
incoming: dict[str, list] = {}
for c in inv["connections"]:
incoming.setdefault(c["to"], []).append(c)
checkpoints = []
# propagate grain keys along the graph (simple: last Unique/Summarize/Join keys seen upstream)
memo: dict[str, list[str]] = {}
def grain_for(tid: str, depth=0) -> list[str]:
if tid in memo or depth > 60:
return memo.get(tid, [])
t = by_id.get(tid)
if t is None:
return []
ins = incoming.get(tid, [])
if t["type"] in {"Join", "JoinMultiple"}:
ins = [c for c in ins if c.get("to_anchor") == "Left"] + [c for c in ins if c.get("to_anchor") != "Left"]
ups = [c["from"] for c in ins]
up_keys: list[str] = []
for u in ups:
k = grain_for(u, depth + 1)
if k:
up_keys = k
break
memo[tid] = infer_grain(t, by_id, up_keys)
return memo[tid]
n = 0
for t in inv["tools"]:
if t["type"] in ROW_CHANGING or (t["type"] == "Macro" and t.get("macro_kind") == "output"):
n += 1
outs = sorted({c["from_anchor"] for c in inv["connections"] if c["from"] == t["tool_id"]}) or ["Output"]
rel = {
"Filter": "true + false = input rows",
"Union": "sum of input rows",
"Unique": "<= input rows; delta = duplicates",
"Summarize": "one row per distinct group tuple",
"Join": "J + L + R covers both inputs; J <= min(L,R) unless keys duplicate",
"AppendFields": "left rows x right rows",
}.get(t["type"], "compare row count and measure sums")
checkpoints.append({
"id": f"A{n}", "tool_id": t["tool_id"], "tool_type": t["type"],
"anchors": outs, "grain_keys": grain_for(t["tool_id"]),
"expected_relationship": rel,
"is_output": t["type"] == "DbFileOutput" or t.get("macro_kind") == "output",
"priority": 1 if (t["type"] == "DbFileOutput" or t.get("macro_kind") == "output") else (2 if t["type"] in {"Union", "Unique", "Summarize", "CrossTab", "Transpose"} else 3),
"savant_step": "", # filled by the migration author after the build
})
return checkpoints
# ----------------------------------------------------------------------------- traps and dead code
RUN_DATE_RE = re.compile(r"\b(DateTimeToday|DateTimeNow|DateTimeNowPrecise|Today|Now)\s*\(", re.I)
STRING_TYPES = {"string", "wstring", "v_string", "v_wstring"}
ID_LIKE_RE = re.compile(r"(^|_)(id|ids|code|codes|zip|postcode|postal|account|acct|sku|no|num|number|ref)($|_)", re.I)
def _ancestors(inv: dict) -> dict[str, set[str]]:
incoming: dict[str, list[str]] = {}
for c in inv["connections"]:
incoming.setdefault(c["to"], []).append(c["from"])
memo: dict[str, set[str]] = {}
def anc(tid: str, depth=0) -> set[str]:
if tid in memo or depth > 60:
return memo.get(tid, set())
acc: set[str] = set()
for u in incoming.get(tid, []):
acc.add(u)
acc |= anc(u, depth + 1)
memo[tid] = acc
return acc
for t in inv["tools"]:
anc(t["tool_id"])
return memo
def cleanse_options(params: dict) -> dict:
"""Decode the standard Cleanse macro's control ids (see references/macro-handling.md)."""
on = lambda k: (params.get(k) or "").strip().lower() == "true" # noqa: E731
fields = [f.strip().strip('"') for f in (params.get("List Box (11)") or "").split(",") if f.strip()]
return {"fields": fields, "null_to_blank": on("Check Box (84)"), "null_to_zero": on("Check Box (117)"),
"trim": on("Check Box (15)"), "case_change": on("Check Box (77)"),
"case": (params.get("Drop Down (81)") or "").strip() if on("Check Box (77)") else ""}
def detect_traps(inv: dict) -> list[dict]:
traps = []
by_id = {t["tool_id"]: t for t in inv["tools"]}
ancestors = _ancestors(inv)
for t in inv["tools"]:
cfg = t["config"]
expr_text = " ".join(f.get("expression") or "" for f in cfg.get("fields", []) if isinstance(f, dict)) + " " + (cfg.get("expression") or "")
if t["type"] in {"Formula", "MultiRowFormula", "Filter"} and RUN_DATE_RE.search(expr_text):
traps.append({"trap": "run_date_dependency", "tool_id": t["tool_id"],
"detail": "Expression uses the run date (DateTimeToday/Now): results differ by run day. Pin the as-of date to the Alteryx baseline run date for the comparison, and make it a parameter or a documented constant in Savant"})
if t["type"] == "AlteryxSelect":
forced = {f: ty for f, ty in cfg.get("retyped", {}).items() if (ty or "").split("(")[0].lower() in STRING_TYPES}
if forced:
traps.append({"trap": "text_type_forced", "tool_id": t["tool_id"],
"detail": f"Select forces {sorted(forced)} to text; keep them text in the Savant dataset — type inference would strip leading zeros (00042 → 42) before any step runs"})
if t["type"] == "DbFileInput":
idish = [f for f, ty in cfg.get("field_types", {}).items() if (ty or "").lower() in STRING_TYPES and ID_LIKE_RE.search(f or "")]
if idish:
traps.append({"trap": "id_like_text_fields", "tool_id": t["tool_id"],
"detail": f"Input reads {idish} as text; confirm the Savant dataset keeps them text (leading zeros, mixed codes)"})
if t["type"] in {"Formula", "MultiRowFormula"}:
for f in cfg.get("fields", []):
if (f.get("type") or "").lower() in {"int16", "int32", "int64", "byte"} and f.get("expression"):
if re.search(r"[\w\]]\s*[-+*/]|Row-|Row\+|\.\d", f["expression"]):
traps.append({"trap": "integer_cast_truncation", "tool_id": t["tool_id"],
"detail": f"{f['field']} typed {f['type']} from expression `{f['expression'][:80]}`; decimals truncated in Alteryx"})
literals = re.findall(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"", f.get("expression") or "")
if any("\n" in lit for lit in literals):
traps.append({"trap": "literal_with_line_break", "tool_id": t["tool_id"],
"detail": f"{f['field']}: a string literal contains a line break; likely never matches after trimming"})
if t["type"] == "MultiRowFormula":
mr = cfg["fields"][-1] if cfg.get("fields") else {}
traps.append({"trap": "multi_row_first_row_behaviour", "tool_id": t["tool_id"],
"detail": f"Multi-Row Formula ({mr.get('field')}): first row of each group uses '{mr.get('other_rows') or 'null'}' for missing rows; Savant LAG returns blank"})
if t["type"] == "Filter" and cfg.get("operator") in {"!=", "<>"}:
blanked = [a for a in ancestors.get(t["tool_id"], set())
if by_id.get(a, {}).get("type") == "Macro" and "cleanse" in (by_id[a].get("macro") or "").lower()
and cleanse_options(by_id[a]["config"].get("parameters", {}))["null_to_blank"]
and (cfg.get("field") in cleanse_options(by_id[a]["config"].get("parameters", {}))["fields"]
or not cleanse_options(by_id[a]["config"].get("parameters", {}))["fields"])]
if blanked:
detail = (f"Filter `{cfg.get('field')} {cfg.get('operator')} {cfg.get('operand')}`: NULL {cfg.get('field')} was already turned "
f"into blank by Cleanse {', '.join(sorted(blanked))} upstream, so those rows are kept; keep the same order in Savant")
else:
detail = f"Filter `{cfg.get('field')} {cfg.get('operator')} {cfg.get('operand')}`: Alteryx drops NULL rows; verify Savant treats NULL the same"
traps.append({"trap": "not_equal_with_null", "tool_id": t["tool_id"], "detail": detail})
if t["type"] == "Summarize" and any(a["action"] in {"First", "Last"} for a in cfg.get("aggs", [])):
traps.append({"trap": "first_last_depends_on_sort", "tool_id": t["tool_id"],
"detail": "Summarize First/Last depends on upstream Sort order; use a deterministic rank in Savant"})
if t["type"] == "Union" and (cfg.get("mode") or "").lower().startswith("bypos"):
traps.append({"trap": "union_by_position", "tool_id": t["tool_id"], "detail": "Union by position; Savant Stack should match by name"})
if t["type"] == "Macro" and "cleanse" in (t.get("macro") or "").lower():
o = cleanse_options(cfg.get("parameters", {}))
onoff = lambda b: "on" if b else "off" # noqa: E731
traps.append({"trap": "cleanse_macro_defaults", "tool_id": t["tool_id"],
"detail": (f"Cleanse on {o['fields'] or 'all fields'}: null→blank {onoff(o['null_to_blank'])}, null→0 {onoff(o['null_to_zero'])}"
f" (inert on text), trim {onoff(o['trim'])}, case change {o['case'] or 'off'}; "
"reproduce exactly these on exactly these fields, and check other branches are cleaned the same way")})
cfg["cleanse_options"] = o
if t["type"] in {"Join", "JoinMultiple", "Unique", "Summarize"}:
keys = cfg.get("left_keys") or cfg.get("keys") or cfg.get("fields") or cfg.get("group_by") or []
if keys:
traps.append({"trap": "blank_keys", "tool_id": t["tool_id"],
"detail": f"{t['type']} on {keys}: Alteryx treats blank keys as equal; a Savant Blend/Summarize on a NULL key drops or splits the group — build blank-safe keys (COALESCE(TO_TEXT(x), \"\")) first"})
if t["type"] in {"Join", "JoinMultiple"}:
traps.append({"trap": "join_key_types_and_case", "tool_id": t["tool_id"],
"detail": f"Join on {cfg.get('left_keys') or cfg.get('keys')}: Alteryx string joins are case-sensitive and do not trim; confirm key normalisation"})
if t["type"] == "Unique":
used = {c["from_anchor"] for c in inv["connections"] if c["from"] == t["tool_id"]}
if "Duplicates" in used:
traps.append({"trap": "unique_duplicates_anchor", "tool_id": t["tool_id"],
"detail": f"Unique 'Duplicates' output is used: Alteryx emits only the 2nd+ occurrence of each key {cfg.get('fields')}; Savant's duplicate flag marks every member of the group — decide which the business wants"})
if t["type"] == "DbFileInput" and t["config"].get("code_page") not in (None, "", "65001"):
traps.append({"trap": "file_encoding", "tool_id": t["tool_id"],
"detail": f"Input code page {t['config']['code_page']} (not UTF-8); set the Savant dataset charset to match or accented characters are mangled"})
if t["type"] == "DbFileInput" and t["config"].get("header_row") == "False":
traps.append({"trap": "headerless_input", "tool_id": t["tool_id"],
"detail": "Input has no header row; fields are positional (Field_1…). Savant dataset needs a header or an explicit column map; confirm column meanings with the owner"})
if t["type"] == "AppendFields":
traps.append({"trap": "cartesian_append", "tool_id": t["tool_id"], "detail": "Append Fields is a cartesian product; replicate with a constant-key join or window aggregate"})
if t["type"] == "Formula":
for f in cfg.get("fields", []):
if f.get("type") == "FixedDecimal":
traps.append({"trap": "fixed_decimal_precision", "tool_id": t["tool_id"],
"detail": f"{f['field']} FixedDecimal {f.get('size')}: Alteryx rounds to declared scale"})
break
return traps
def detect_dead_code(inv: dict) -> list[dict]:
findings = []
outgoing: dict[tuple, int] = {}
incoming_count: dict[str, int] = {}
for c in inv["connections"]:
outgoing[(c["from"], c["from_anchor"])] = outgoing.get((c["from"], c["from_anchor"]), 0) + 1
incoming_count[c["to"]] = incoming_count.get(c["to"], 0) + 1
by_id = {t["tool_id"]: t for t in inv["tools"]}
for t in inv["tools"]:
if t["type"] in {"TextBox", "ToolContainer", "BrowseV2", "Browse"} or t.get("mapping") == "not_applicable":
continue
outs = [k for k in outgoing if k[0] == t["tool_id"]]
if not outs and t["type"] not in {"DbFileOutput"} and t.get("mapping") != "output" and t.get("macro_kind") != "output":
findings.append({"finding": "unused_tool", "tool_id": t["tool_id"], "detail": f"{t['type']} has no downstream connection"})
if t["type"] in {"Join", "JoinMultiple"}:
for anchor in ("Left", "Right"):
if (t["tool_id"], anchor) not in outgoing:
findings.append({"finding": "unused_join_anchor", "tool_id": t["tool_id"], "detail": f"Join {anchor} output not used (unmatched rows dropped)"})
if t["type"] == "AlteryxSelect" and not t["config"].get("deselected") and not t["config"].get("renamed") and not t["config"].get("retyped"):
findings.append({"finding": "no_op_select", "tool_id": t["tool_id"], "detail": "Select tool changes nothing"})
if t["type"] == "ToolContainer" and t.get("disabled"):
findings.append({"finding": "disabled_container", "tool_id": t["tool_id"], "detail": f"Container '{t.get('caption')}' is disabled; contents do not run"})
# duplicated filter/formula chains: same config on two tools
seen: dict[str, str] = {}
for t in inv["tools"]:
if t["type"] in {"Filter", "Formula"}:
sig = t["type"] + json.dumps(t["config"], sort_keys=True)
if sig in seen:
findings.append({"finding": "duplicated_logic", "tool_id": t["tool_id"],
"detail": f"Same {t['type']} configuration as tool {seen[sig]} — do once upstream"})
else:
seen[sig] = t["tool_id"]
# multiple inputs hitting the same connection/table
inputs = [t for t in inv["tools"] if t["type"] == "DbFileInput"]
for i, a in enumerate(inputs):
for b in inputs[i + 1:]:
ta, tb = set(a["config"].get("tables", [])), set(b["config"].get("tables", []))
if ta and (ta <= tb or tb <= ta):
findings.append({"finding": "overlapping_inputs", "tool_id": b["tool_id"],
"detail": f"Input {b['tool_id']} reads a subset/superset of input {a['tool_id']} ({', '.join(sorted(ta & tb))}); consider a single input"})
return findings
# ----------------------------------------------------------------------------- dataset configuration
# Alteryx code page -> Savant file-parser charset (the API enum has exactly UTF_8 and WINDOWS_1252).
CODE_PAGE_TO_CHARSET = {
"65001": ("UTF_8", ""),
"1252": ("WINDOWS_1252", ""),
"28591": ("WINDOWS_1252", "Alteryx code page 28591 is ISO-8859-1; Savant has no Latin-1 option. WINDOWS_1252 decodes every printable Latin-1 byte identically (only the unused 0x80–0x9F control range differs)."),
"28605": ("WINDOWS_1252", "Alteryx code page 28605 is ISO-8859-15; WINDOWS_1252 matches except for the euro sign and seven rare glyphs — check accented output."),
"": ("UTF_8", "No code page in the Input tool (Designer default); confirm the file is UTF-8 — if accents break, use WINDOWS_1252."),
}
def dataset_configs(inv: dict) -> list[dict]:
"""One suggested Savant dataset configuration per Input tool: charset, delimiter and the
columns that must stay text (from downstream Select retypes and id-like string fields)."""
ancestors = _ancestors(inv)
out = []
for t in inv["tools"]:
if t["type"] != "DbFileInput":
continue
cfg = t["config"]
code_page = str(cfg.get("code_page") or "")
charset, note = CODE_PAGE_TO_CHARSET.get(code_page, (None, f"Alteryx code page {code_page} has no Savant charset (UTF_8 / WINDOWS_1252); convert the file to UTF-8 before upload."))
text_columns: dict[str, str] = {}
for f, ty in cfg.get("field_types", {}).items():
if (ty or "").lower() in STRING_TYPES and ID_LIKE_RE.search(f or ""):
text_columns[f] = "input reads it as text"
for s in inv["tools"]:
if s["type"] == "AlteryxSelect" and t["tool_id"] in ancestors.get(s["tool_id"], set()):
for f, ty in s["config"].get("retyped", {}).items():
if (ty or "").split("(")[0].lower() in STRING_TYPES:
text_columns[f] = f"Select {s['tool_id']} forces {ty}"
delimiter = cfg.get("delimiter") or ","
stem = Path((cfg.get("connection") or "").replace("\\", "/")).name or f"input_{t['tool_id']}"
entry = {
"tool_id": t["tool_id"], "source": cfg.get("connection", ""), "code_page": code_page,
"charset": charset, "charset_note": note, "delimiter": delimiter,
"text_columns": text_columns, "p1": charset is None,
}
if charset:
cmd = f"savant.py dataset create --file <path to {stem}> --name \"{Path(stem).stem}\" --delimiter '{delimiter}' --charset {charset}"
if text_columns:
cmd += " --column-type " + ",".join(f"{c}=string" for c in text_columns)
entry["suggested_command"] = cmd
else:
t["p1"] = (t.get("p1", "") + "; " if t.get("p1") else "") + f"file code page {code_page} is not supported by the Savant file parser — convert to UTF-8 first"
out.append(entry)
return out
# ----------------------------------------------------------------------------- dependencies and coverage
def dependencies(inv: dict) -> list[dict]:
deps = []
for t in inv["tools"]:
c = t["config"]
if t["type"] == "DbFileInput":
deps.append({"kind": "input", "tool_id": t["tool_id"], "target": c.get("connection", ""), "tables": c.get("tables", [])})
elif t["type"] == "DbFileOutput":
deps.append({"kind": "output", "tool_id": t["tool_id"], "target": c.get("file", "")})
elif t["type"] == "Macro":
deps.append({"kind": "macro", "tool_id": t["tool_id"], "target": t.get("macro"), "macro_kind": t.get("macro_kind"),
"parameters": {k: v for k, v in c.get("parameters", {}).items() if v and not re.search("password|token|secret", k or "", re.I)}})
elif t["type"] in {"RunCommand", "DownloadData", "PythonTool", "RTool", "Email"}:
deps.append({"kind": "external", "tool_id": t["tool_id"], "target": t["type"]})
return deps
def coverage(inv: dict) -> dict:
counts = {"exact": 0, "equivalent": 0, "approximated": 0, "unsupported": 0, "custom_macro": 0, "not_applicable": 0, "review": 0}
unsupported_by: dict[str, list] = {}
approximated_by: dict[str, list] = {}
for t in inv["tools"]:
m = t.get("mapping", "review")
counts[m] = counts.get(m, 0) + 1
label = t['type'] if t['type'] != 'Macro' else Path((t['macro'] or '').replace('\\', '/')).stem
if m == "unsupported":
unsupported_by.setdefault(label, []).append(t['tool_id'])
if m == "approximated":
approximated_by.setdefault(label, []).append(t['tool_id'])
fmt = lambda d: [f"{k} (tool{'s' if len(v) > 1 else ''} {', '.join(v)})" for k, v in d.items()]
unsupported, approximated = fmt(unsupported_by), fmt(approximated_by)
working = len(inv["tools"]) - counts.get("not_applicable", 0)
headline = (f"{counts['exact'] + counts['equivalent']} mapped · {counts['approximated']} approximated · "
f"{counts['unsupported']} unsupported" + (f" — {'; '.join(unsupported)}" if unsupported else "") +
(f" · {counts['custom_macro']} custom macro(s) to inline" if counts['custom_macro'] else ""))
return {"total_tools": len(inv["tools"]), "working_tools": working, "counts": counts,
"unsupported": unsupported, "approximated": approximated, "headline": headline}
# ----------------------------------------------------------------------------- markdown
def to_markdown(result: dict) -> str:
inv = result["workflows"][0]
lines = [f"# Alteryx inventory — {inv['file']}", "",
f"**Coverage:** {result['coverage']['headline']}", ""]
lines += ["## Tools", "", "| ID | Type | Container | Annotation / summary | Savant | Mapping |", "|---|---|---|---|---|---|"]
for t in inv["tools"]:
summ = t["config"].get("text") if t["type"] == "TextBox" else (t["annotation"] or t["default_annotation"] or json.dumps(t["config"])[:80])
lines.append(f"| {t['tool_id']} | {t['type'] if t['type'] != 'Macro' else 'Macro: ' + Path(t['macro'] or '').name} | {inv['containers'].get(t['container'], '') if t['container'] else ''} | {summ.replace('|', '/')[:90]} | {t.get('savant', '')} | {t.get('mapping', '')} |")
if inv.get("notes"):
lines += ["", "## Workflow notes (Text Box tools)", ""] + [f"- (tool {n['tool_id']}) {n['text'].replace(chr(10), ' ')[:300]}" for n in inv["notes"]]
if result.get("dataset_configs"):
lines += ["", "## Dataset configuration (P1 until done)", "", "| Input tool | Source | Charset | Delimiter | Keep as text | Create command |", "|---|---|---|---|---|---|"]
for d in result["dataset_configs"]:
lines.append(f"| {d['tool_id']} | {d['source'].replace('|', '/')[:60]} | {d['charset'] or 'unsupported: code page ' + d['code_page']} | `{d['delimiter']}` | {', '.join(d['text_columns']) or '—'} | `{d.get('suggested_command', 'convert the file to UTF-8 first')}` |")
notes = [d["charset_note"] for d in result["dataset_configs"] if d["charset_note"]]
if notes:
lines += [""] + [f"- {n}" for n in dict.fromkeys(notes)]
lines += ["", "## Validation checkpoints", "", "| # | Tool | Type | Grain keys | Expected |", "|---|---|---|---|---|"]
for c in result["checkpoints"]:
lines.append(f"| {c['id']} | {c['tool_id']} | {c['tool_type']} | {', '.join(c['grain_keys'])} | {c['expected_relationship']} |")
lines += ["", "## Traps found", ""] + [f"- **{x['trap']}** (tool {x['tool_id']}): {x['detail']}" for x in result["traps"]]
lines += ["", "## Dead code / inconsistencies", ""] + [f"- **{x['finding']}** (tool {x['tool_id']}): {x['detail']}" for x in result["dead_code"]]
lines += ["", "## External dependencies", ""] + [f"- {d['kind']} (tool {d['tool_id']}): {d['target']} {d.get('tables', '') or ''}" for d in result["dependencies"]]
return "\n".join(lines) + "\n"
# ----------------------------------------------------------------------------- main
def parse_package(path: Path, mapping: dict) -> dict:
"""Parse one Alteryx package into the inventory result (pure; no file writes)."""
docs = load_package(path)
workflows = []
for name, root in docs.items():
inv = parse_workflow(root, name)
classify_tools(inv, mapping)
workflows.append(inv)
primary = workflows[0]
return {
"source_path": str(path),
"package_members": list(docs.keys()),
"workflows": workflows,
"checkpoints": build_checkpoints(primary),
"dataset_configs": dataset_configs(primary),
"traps": detect_traps(primary),
"dead_code": detect_dead_code(primary),
"dependencies": dependencies(primary),
"coverage": coverage(primary),
}
def main() -> int:
ap = argparse.ArgumentParser(prog="savant.py alteryx parse", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("path", help="Alteryx .yxmd / .yxwz / .yxmc / .yxzp file")
ap.add_argument("--out", default=None, help="inventory JSON path (default: session tmp alteryx/<stem>.inventory.json)")
ap.add_argument("--markdown", default=None, help="also write a human-readable inventory .md")
ap.add_argument("--mapping", default=None, help="override the tool-mapping.json path")
args = ap.parse_args()
path = Path(args.path)
if not path.exists():
raise SystemExit(f"Alteryx file not found: {path}")
mapping = load_mapping(args.mapping)
result = parse_package(path, mapping)
out = Path(args.out) if args.out else workspace_tmp("alteryx", f"{path.stem}.inventory.json")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(result, indent=2))
print(f"Wrote {out}")
if args.markdown:
md = Path(args.markdown)
md.parent.mkdir(parents=True, exist_ok=True)
md.write_text(to_markdown(result))
print(f"Wrote {args.markdown}")
print(result["coverage"]["headline"])
return 0
if __name__ == "__main__":
sys.exit(main())
SHA-256: 647789fe48738341adf157580910b74b16be807dc21dddb9fb3788b61094c7c8