afv-library/skills/tableau-next-semantic-model-generate/scripts/create_calc_field.py
Antoine Laviron b26d254871 feat: add tableau-next-semantic-model-generate skill
Adds a skill for authoring Tableau Next semantic models (SDMs) on Data 360: build from scratch, add data objects, define joins, enrich with calculated fields and metrics, and make models AI-ready.

Smoke-tested against a live Data 360 org: SDM discovery, AI-readiness flip, dimension creation, metric creation, and description backfill all exercised end-to-end.
2026-07-26 13:33:29 +02:00

264 lines
9.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Create calculated measurements and dimensions on semantic models.
Usage:
# Free-form measurement (with aggregation in expression → auto UserAgg)
python scripts/create_calc_field.py \\
--sdm Sales_Cloud12_SDM_1772196180 \\
--type measurement \\
--name Avg_Deal_Size_clc \\
--label "Average Deal Size" \\
--expression "AVG([Amount])" \\
--aggregation Avg
# Template-based
python scripts/create_calc_field.py \\
--sdm Sales_Cloud12_SDM_1772196180 \\
--template win_rate \\
--template-args '{"won_field": "Stage_Won_Count", "total_field": "Stage_Total_Count"}' \\
--name Win_Rate_clc \\
--label "Win Rate"
# Boolean dimension
python scripts/create_calc_field.py \\
--sdm Sales_Cloud12_SDM_1772196180 \\
--type dimension \\
--name Is_Large_Deal_clc \\
--label "Is Large Deal?" \\
--expression "[Amount] > 100000" \\
--data-type Boolean
# Dry-run (show payload without POSTing)
python scripts/create_calc_field.py \\
--sdm Sales_Cloud12_SDM_1772196180 \\
--template bucket_amount \\
--template-args '{"field": "Amount", "small_threshold": 10000, "medium_threshold": 50000}' \\
--name Deal_Size_Bucket_clc \\
--label "Deal Size" \\
--dry-run
"""
import argparse
import json
import sys
from typing import Any, Dict, Optional
from _shared.calc_field_templates import (
TEMPLATE_REGISTRY,
build_calculated_dimension,
build_calculated_measurement,
validate_calc_field,
)
from _shared.sf_api import calculated_field_endpoint, get_credentials, sf_post
from _shared.verify import report_verification, verify_calc_field_has_data
def resolve_expression(
template: Optional[str],
template_args: Optional[str],
expression: Optional[str]
) -> str:
"""Resolve expression from template or use provided expression.
Args:
template: Template name (win_rate, days_between, etc.)
template_args: JSON string with template arguments
expression: Direct expression string
Returns:
Resolved expression string
"""
if template:
if template not in TEMPLATE_REGISTRY:
print(f"Error: Unknown template '{template}'. Valid: {', '.join(TEMPLATE_REGISTRY.keys())}", file=sys.stderr)
sys.exit(1)
if not template_args:
print(f"Error: --template-args required when using --template", file=sys.stderr)
sys.exit(1)
try:
args = json.loads(template_args)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in --template-args: {e}", file=sys.stderr)
sys.exit(1)
template_func = TEMPLATE_REGISTRY[template]
try:
return template_func(**args)
except TypeError as e:
print(f"Error: Template '{template}' arguments mismatch: {e}", file=sys.stderr)
print(f"Expected arguments: {template_func.__code__.co_varnames[:template_func.__code__.co_argcount]}", file=sys.stderr)
sys.exit(1)
if expression:
return expression
print("Error: Either --template or --expression must be provided", file=sys.stderr)
sys.exit(1)
def normalize_api_name(name: str) -> str:
"""Ensure API name ends with _clc and doesn't have double underscores.
Salesforce API names cannot contain double underscores (__).
Args:
name: API name
Returns:
Normalized API name (no double underscores, ends with _clc)
"""
name = name.strip()
# Replace double underscores with single underscore
while "__" in name:
name = name.replace("__", "_")
if not name.endswith("_clc"):
return f"{name}_clc"
return name
def main() -> int:
parser = argparse.ArgumentParser(
description="Create calculated measurements and dimensions on semantic models",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--sdm", required=True, help="Semantic model API name")
parser.add_argument("--type", required=True, choices=["measurement", "dimension"], help="Field type")
parser.add_argument("--name", required=True, help="API name (auto-appends _clc if missing)")
parser.add_argument("--label", required=True, help="Display label")
# Expression source (mutually exclusive). Not required at the argparse layer
# so --verify-only can run without it; the create path enforces it below.
expr_group = parser.add_mutually_exclusive_group(required=False)
expr_group.add_argument("--expression", help="Tableau formula expression")
expr_group.add_argument("--template", choices=list(TEMPLATE_REGISTRY.keys()), help="Template name")
parser.add_argument("--template-args", help="JSON dict of template arguments (required with --template)")
# Measurement-specific
parser.add_argument("--aggregation", default="UserAgg",
choices=["Sum", "Avg", "Count", "Min", "Max", "UserAgg", "Median"],
help="Aggregation type (for measurements, default: UserAgg)")
parser.add_argument("--data-type", choices=["Number", "Text", "Boolean", "DateTime"],
help="Data type (default: Number for measurements, inferred for dimensions)")
parser.add_argument("--decimal-places", type=int, default=2, help="Decimal places for Number fields (default: 2)")
parser.add_argument("--description", default="", help="Field description")
parser.add_argument("--dry-run", action="store_true", help="Show payload without POSTing")
parser.add_argument("--skip-verify", action="store_true",
help="Skip the post-create query that confirms the field returns data")
parser.add_argument("--verify-only", action="store_true",
help="Do not create; only query the EXISTING calc field (--name) and report "
"whether it returns data (verified-done / NOT-shippable). Use this to "
"re-check an already-created field — never re-run a create on one.")
parser.add_argument("-o", "--output", help="Write JSON to file (default: stdout)")
args = parser.parse_args()
# Verify-only: skip creation, just query the existing calc field for data.
if args.verify_only:
existing = normalize_api_name(args.name)
has_data, verify_err = verify_calc_field_has_data(args.sdm, existing)
return report_verification("calc field", existing, has_data, verify_err, created=False)
# Resolve expression
expression = resolve_expression(args.template, args.template_args, args.expression)
# Normalize API name
api_name = normalize_api_name(args.name)
# Determine data type
data_type = args.data_type
if not data_type:
if args.type == "measurement":
data_type = "Number"
else:
# Try to infer from expression (basic heuristics)
if "THEN" in expression.upper() or "IF" in expression.upper():
data_type = "Text" # Likely a bucketing or conditional
else:
data_type = "Text" # Default for dimensions
# Validate
is_valid, errors = validate_calc_field(
api_name=api_name,
field_type=args.type,
aggregation_type=args.aggregation if args.type == "measurement" else None,
data_type=data_type,
expression=expression
)
if not is_valid:
print("Validation errors:", file=sys.stderr)
for err in errors:
print(f" - {err}", file=sys.stderr)
sys.exit(1)
# Build payload
if args.type == "measurement":
payload = build_calculated_measurement(
api_name=api_name,
label=args.label,
expression=expression,
aggregation_type=args.aggregation,
data_type=data_type,
decimal_place=args.decimal_places,
description=args.description,
)
else:
payload = build_calculated_dimension(
api_name=api_name,
label=args.label,
expression=expression,
data_type=data_type,
description=args.description,
)
# Output payload
output_json = json.dumps(payload, indent=2)
if args.output:
with open(args.output, "w") as f:
f.write(output_json)
f.write("\n")
print(f"Wrote {args.output}", file=sys.stderr)
else:
print(output_json)
# Dry-run mode - exit before POST
if args.dry_run:
print("\n[Dry-run mode - payload shown above, not POSTed]", file=sys.stderr)
return 0
# POST to API
token, instance = get_credentials()
endpoint = calculated_field_endpoint(args.sdm, f"{args.type}s") # measurement -> measurements
resp, err = sf_post(token, instance, endpoint, payload)
if err:
print(f"Error: {err}", file=sys.stderr)
return 1
actual_name = api_name
if resp:
actual_name = resp.get('apiName', api_name)
print(f"\n✓ Created calculated {args.type}: {api_name}", file=sys.stderr)
if actual_name != api_name:
print(f" → Actual API name: {actual_name}", file=sys.stderr)
if "label" in resp:
print(f" Label: {resp['label']}", file=sys.stderr)
else:
print(f"\n✓ Calculated {args.type} created successfully", file=sys.stderr)
# Verify-by-querying: a created field that returns no data is NOT shippable.
# Creation success is not data success — confirm before declaring done.
if args.skip_verify:
return 0
has_data, verify_err = verify_calc_field_has_data(args.sdm, actual_name)
return report_verification("calc field", actual_name, has_data, verify_err)
if __name__ == "__main__":
sys.exit(main())