forked from diegoecab/oci-deal-accelerator
Accept flat-spec input aliases across generators
Harden bizcase, bom, deck, and diagram generators to tolerate payload shape variants (metadata/cover/summary/line_item aliases, current_state as string, alternate pillar keys) so MCP and CLI flat specs render consistently. Add input-alias tests per generator. Also loosen KB governance tests to handle multi-document service YAMLs with optional changelogs, untrack the customer demo output under examples/output-demo-pharma-mx/ (matches .gitignore), and ship an ADB-S vs Aurora 500GB sample deck. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,37 @@ from pptx.enum.shapes import MSO_SHAPE
|
||||
from oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Defensive input helpers
|
||||
# ============================================================
|
||||
|
||||
def pick(mapping: dict, *keys, default=""):
|
||||
"""Return the first non-empty value for any of the provided keys."""
|
||||
if not isinstance(mapping, dict):
|
||||
return default
|
||||
for key in keys:
|
||||
if key in mapping:
|
||||
value = mapping.get(key)
|
||||
if value is not None:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def pick_list(mapping: dict, *keys):
|
||||
"""Return the first list-like field normalized to a list."""
|
||||
if not isinstance(mapping, dict):
|
||||
return []
|
||||
for key in keys:
|
||||
if key in mapping:
|
||||
value = mapping.get(key)
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
return []
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Business Case Deck Generator
|
||||
# ============================================================
|
||||
@@ -461,10 +492,10 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
for i, phase in enumerate(phases[:4]):
|
||||
color = phase_colors[i % len(phase_colors)]
|
||||
y = content_start + i * row_h
|
||||
name = phase.get("name", f"Phase {i+1}")
|
||||
duration = phase.get("duration", "")
|
||||
deliverables = phase.get("deliverables", [])
|
||||
quick_wins = phase.get("quick_wins", [])
|
||||
name = pick(phase, "name", "title", default=f"Phase {i+1}")
|
||||
duration = pick(phase, "duration", "weeks")
|
||||
deliverables = pick_list(phase, "deliverables", "outputs")
|
||||
quick_wins = pick_list(phase, "quick_wins", "milestones")
|
||||
|
||||
# Alternating row background
|
||||
if i % 2 == 0:
|
||||
@@ -593,10 +624,10 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
|
||||
# Slide 1: Cover
|
||||
gen.add_cover_slide(
|
||||
customer=bc.get("customer_name", ""),
|
||||
customer=pick(bc, "customer_name", "customer"),
|
||||
subtitle="Business Case for Oracle Cloud Infrastructure",
|
||||
prepared_by=bc.get("prepared_by", ""),
|
||||
date=bc.get("date", ""),
|
||||
prepared_by=pick(bc, "prepared_by", "author"),
|
||||
date=pick(bc, "date", "generated_on"),
|
||||
)
|
||||
|
||||
# Slide 2: Executive Summary
|
||||
@@ -608,18 +639,21 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
drivers_data = bc.get("drivers", {})
|
||||
if drivers_data:
|
||||
driver_statements = []
|
||||
primary = drivers_data.get("primary", "")
|
||||
urgency = drivers_data.get("urgency", "")
|
||||
primary = pick(drivers_data, "primary")
|
||||
urgency = pick(drivers_data, "urgency")
|
||||
coi = drivers_data.get("cost_of_inaction", {})
|
||||
|
||||
if primary:
|
||||
driver_statements.append(f"Primary Driver: {primary.replace('_', ' ').title()}\n{urgency}" if urgency else primary.replace('_', ' ').title())
|
||||
if coi.get("financial"):
|
||||
driver_statements.append(f"Financial Impact of Inaction\n{coi['financial']}")
|
||||
if coi.get("operational"):
|
||||
driver_statements.append(f"Operational Impact\n{coi['operational']}")
|
||||
elif coi.get("strategic"):
|
||||
driver_statements.append(f"Strategic Risk\n{coi['strategic']}")
|
||||
financial = pick(coi, "financial")
|
||||
operational = pick(coi, "operational")
|
||||
strategic = pick(coi, "strategic")
|
||||
if financial:
|
||||
driver_statements.append(f"Financial Impact of Inaction\n{financial}")
|
||||
if operational:
|
||||
driver_statements.append(f"Operational Impact\n{operational}")
|
||||
elif strategic:
|
||||
driver_statements.append(f"Strategic Risk\n{strategic}")
|
||||
|
||||
if driver_statements:
|
||||
gen.add_business_drivers_slide(driver_statements)
|
||||
@@ -641,8 +675,8 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
|
||||
# Slide 7: Risk Assessment
|
||||
risks = bc.get("risks", {})
|
||||
migration_risks = risks.get("migration_risks", [])
|
||||
do_nothing_risks = risks.get("do_nothing_risks", [])
|
||||
migration_risks = pick_list(risks, "migration_risks")
|
||||
do_nothing_risks = pick_list(risks, "do_nothing_risks")
|
||||
if migration_risks or do_nothing_risks:
|
||||
gen.add_risk_slide(migration_risks, do_nothing_risks)
|
||||
|
||||
@@ -650,24 +684,24 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
roadmap = bc.get("roadmap", {})
|
||||
if roadmap.get("phases"):
|
||||
gen.add_roadmap_slide(
|
||||
phases=roadmap["phases"],
|
||||
total_duration=roadmap.get("total_duration", ""),
|
||||
phases=pick_list(roadmap, "phases"),
|
||||
total_duration=pick(roadmap, "total_duration"),
|
||||
)
|
||||
|
||||
# Slide 9: Recommendation
|
||||
rec = bc.get("recommendation", {})
|
||||
if rec:
|
||||
summary = rec.get("summary", "")
|
||||
summary = pick(rec, "summary")
|
||||
if not summary and rec.get("commitment_amount"):
|
||||
summary = f"Recommended: {rec['commitment_amount']} {rec.get('commitment_type', 'UCM')} commitment"
|
||||
if summary:
|
||||
gen.add_recommendation_slide(
|
||||
summary=summary,
|
||||
next_steps=rec.get("next_steps", []),
|
||||
next_steps=pick_list(rec, "next_steps"),
|
||||
)
|
||||
|
||||
# Closing slides
|
||||
prepared_by = bc.get("prepared_by", "")
|
||||
prepared_by = pick(bc, "prepared_by", "author")
|
||||
gen.add_closing_slide(name=prepared_by)
|
||||
|
||||
return gen
|
||||
|
||||
@@ -73,6 +73,18 @@ TAIL_COLS = [
|
||||
]
|
||||
|
||||
|
||||
def pick(mapping: dict, *keys, default=""):
|
||||
"""Return the first non-empty value for any of the provided keys."""
|
||||
if not isinstance(mapping, dict):
|
||||
return default
|
||||
for key in keys:
|
||||
if key in mapping:
|
||||
value = mapping.get(key)
|
||||
if value is not None:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
class OCIBomGenerator:
|
||||
"""Generate OCI Bill of Materials (.xlsx) with Oracle Redwood styling."""
|
||||
|
||||
@@ -667,17 +679,22 @@ class OCIBomGenerator:
|
||||
def from_spec(cls, spec: dict, catalog_path: Optional[str] = None) -> "OCIBomGenerator":
|
||||
"""Build BOM from a YAML specification dict."""
|
||||
bom = spec.get("bom", spec)
|
||||
conversion = bom.get("conversion")
|
||||
if isinstance(conversion, dict):
|
||||
conversion = dict(conversion)
|
||||
if "target_currency" not in conversion and "currency" in conversion:
|
||||
conversion["target_currency"] = conversion.get("currency")
|
||||
|
||||
gen = cls(
|
||||
customer=bom.get("customer_name", ""),
|
||||
project=bom.get("project_name", ""),
|
||||
prepared_by=bom.get("prepared_by", ""),
|
||||
customer=pick(bom, "customer_name", "customer"),
|
||||
project=pick(bom, "project_name", "project"),
|
||||
prepared_by=pick(bom, "prepared_by", "author"),
|
||||
bom_date=bom.get("date", ""),
|
||||
reference_label=bom.get("reference_label", ""),
|
||||
reference_label=pick(bom, "reference_label", "reference"),
|
||||
currency=bom.get("currency", "USD"),
|
||||
realm=bom.get("realm", ""),
|
||||
service_type=bom.get("service_type", ""),
|
||||
conversion=bom.get("conversion"),
|
||||
conversion=conversion,
|
||||
notes=bom.get("notes"),
|
||||
)
|
||||
|
||||
@@ -688,14 +705,17 @@ class OCIBomGenerator:
|
||||
global_discount = bom_meta.get("discount_pct", bom.get("discount_pct", 0.0))
|
||||
|
||||
for item in bom.get("line_items", []):
|
||||
sku = pick(item, "sku", "part_number")
|
||||
if sku in ("", None):
|
||||
raise ValueError("BOM line item requires 'sku' or 'part_number'")
|
||||
gen.add_line_item(
|
||||
sku=str(item["sku"]),
|
||||
qty=item.get("qty", 0),
|
||||
hours_units=item.get("hours_units"),
|
||||
sku=str(sku),
|
||||
qty=pick(item, "qty", "quantity", default=0),
|
||||
hours_units=pick(item, "hours_units", "units", "hours", default=None),
|
||||
months=item.get("months", 12),
|
||||
discount=item.get("discount", global_discount),
|
||||
custom_label=item.get("custom_label", ""),
|
||||
custom_note=item.get("custom_note", ""),
|
||||
discount=pick(item, "discount", "discount_pct", default=global_discount),
|
||||
custom_label=pick(item, "custom_label", "label"),
|
||||
custom_note=pick(item, "custom_note", "note", "notes"),
|
||||
)
|
||||
|
||||
return gen
|
||||
|
||||
@@ -29,6 +29,45 @@ from pptx.enum.shapes import MSO_SHAPE
|
||||
from oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Defensive input helpers
|
||||
# ============================================================
|
||||
|
||||
def pick(mapping: dict, *keys, default=""):
|
||||
"""Return the first non-empty value for any of the provided keys."""
|
||||
if not isinstance(mapping, dict):
|
||||
return default
|
||||
for key in keys:
|
||||
if key in mapping:
|
||||
value = mapping.get(key)
|
||||
if value is not None:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def coerce_list(value):
|
||||
"""Accept list, string, tuple, or None and normalize to a list."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, tuple):
|
||||
return list(value)
|
||||
if isinstance(value, str):
|
||||
return [value] if value else []
|
||||
return [value]
|
||||
|
||||
|
||||
def pick_list(mapping: dict, *keys):
|
||||
"""Return the first list-like field normalized to a list."""
|
||||
if not isinstance(mapping, dict):
|
||||
return []
|
||||
for key in keys:
|
||||
if key in mapping:
|
||||
return coerce_list(mapping.get(key))
|
||||
return []
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MCP flat-spec adapter
|
||||
# ============================================================
|
||||
@@ -525,8 +564,9 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
for i, wl in enumerate(workloads):
|
||||
row_idx = i + 1
|
||||
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
|
||||
self._style_table_cell(table.cell(row_idx, 0), wl["name"], font_size=10, bold=True, bg_color=bg)
|
||||
tier_label = wl.get("tier", "Silver")
|
||||
workload_name = pick(wl, "name", "workload", "title")
|
||||
self._style_table_cell(table.cell(row_idx, 0), workload_name, font_size=10, bold=True, bg_color=bg)
|
||||
tier_label = pick(wl, "tier", "name", default="Silver")
|
||||
tier_color = tier_colors.get(tier_label.lower(), Colors.SECONDARY_TEXT)
|
||||
self._style_table_cell(
|
||||
table.cell(row_idx, 1), tier_label.title(),
|
||||
@@ -613,11 +653,11 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
row_idx = i + 1
|
||||
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
|
||||
self._style_table_cell(
|
||||
table.cell(row_idx, 0), dec["decision"],
|
||||
table.cell(row_idx, 0), pick(dec, "decision", "title"),
|
||||
font_size=11, bold=True, bg_color=bg,
|
||||
)
|
||||
self._style_table_cell(
|
||||
table.cell(row_idx, 1), dec["rationale"],
|
||||
table.cell(row_idx, 1), pick(dec, "rationale", "reason", "notes"),
|
||||
font_size=10, bg_color=bg,
|
||||
)
|
||||
|
||||
@@ -659,10 +699,10 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
for i, tier in enumerate(tiers):
|
||||
row_idx = i + 1
|
||||
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
|
||||
self._style_table_cell(table.cell(row_idx, 0), tier["tier"], font_size=10, bold=True, bg_color=bg)
|
||||
self._style_table_cell(table.cell(row_idx, 1), tier["technology"], font_size=10, bg_color=bg)
|
||||
self._style_table_cell(table.cell(row_idx, 2), tier["rto"], font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
||||
self._style_table_cell(table.cell(row_idx, 3), tier["rpo"], font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
||||
self._style_table_cell(table.cell(row_idx, 0), pick(tier, "tier", "name"), font_size=10, bold=True, bg_color=bg)
|
||||
self._style_table_cell(table.cell(row_idx, 1), pick(tier, "technology", "architecture", "pattern"), font_size=10, bg_color=bg)
|
||||
self._style_table_cell(table.cell(row_idx, 2), pick(tier, "rto"), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
||||
self._style_table_cell(table.cell(row_idx, 3), pick(tier, "rpo"), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
||||
|
||||
def add_security_slide(self, controls: dict, compliance: list = None):
|
||||
"""Slide 6: Security & Compliance.
|
||||
@@ -850,19 +890,22 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
|
||||
for i, item in enumerate(line_items):
|
||||
row_idx = i + 1
|
||||
is_total = "total" in item.get("component", "").lower()
|
||||
component = pick(item, "component", "name", "label")
|
||||
notes = pick(item, "notes", "note")
|
||||
monthly_payg = pick(item, "monthly_payg", "monthly")
|
||||
is_total = "total" in str(component).lower()
|
||||
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 and not is_total else None
|
||||
if is_total:
|
||||
bg = Colors.TEAL
|
||||
|
||||
self._style_table_cell(
|
||||
table.cell(row_idx, 0), item["component"],
|
||||
table.cell(row_idx, 0), component,
|
||||
font_size=10, bold=is_total,
|
||||
color=Colors.WHITE if is_total else None,
|
||||
bg_color=bg,
|
||||
)
|
||||
self._style_table_cell(
|
||||
table.cell(row_idx, 1), item.get("monthly_payg", ""),
|
||||
table.cell(row_idx, 1), monthly_payg,
|
||||
font_size=10, bold=is_total,
|
||||
color=Colors.WHITE if is_total else None,
|
||||
bg_color=bg, alignment=PP_ALIGN.RIGHT,
|
||||
@@ -875,13 +918,13 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
bg_color=bg, alignment=PP_ALIGN.RIGHT,
|
||||
)
|
||||
self._style_table_cell(
|
||||
table.cell(row_idx, 3), item.get("notes", ""),
|
||||
table.cell(row_idx, 3), notes,
|
||||
font_size=9, bg_color=bg,
|
||||
color=Colors.WHITE if is_total else Colors.SECONDARY_TEXT,
|
||||
)
|
||||
else:
|
||||
self._style_table_cell(
|
||||
table.cell(row_idx, 2), item.get("notes", ""),
|
||||
table.cell(row_idx, 2), notes,
|
||||
font_size=9, bg_color=bg,
|
||||
color=Colors.WHITE if is_total else Colors.SECONDARY_TEXT,
|
||||
)
|
||||
@@ -983,14 +1026,14 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
|
||||
for i, phase in enumerate(phases):
|
||||
color = phase_colors[i % len(phase_colors)]
|
||||
duration = phase.get("duration", phase.get("weeks", ""))
|
||||
milestones = phase.get("milestones", phase.get("tasks", []))
|
||||
duration = pick(phase, "duration", "weeks")
|
||||
milestones = pick_list(phase, "milestones", "tasks")
|
||||
|
||||
# Phase label (left of bar)
|
||||
self._add_textbox(
|
||||
slide, self.MARGIN, y,
|
||||
Inches(2.3), Inches(0.4),
|
||||
text=phase["name"], font_size=11, bold=True,
|
||||
text=pick(phase, "name", "title"), font_size=11, bold=True,
|
||||
)
|
||||
|
||||
# Colored phase bar with duration
|
||||
@@ -1122,7 +1165,7 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
row_idx = i + 1
|
||||
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
|
||||
self._style_table_cell(
|
||||
table.cell(row_idx, 0), risk["risk"],
|
||||
table.cell(row_idx, 0), pick(risk, "risk", "title"),
|
||||
font_size=10, bg_color=bg,
|
||||
)
|
||||
sev = risk.get("severity", "MEDIUM").upper()
|
||||
@@ -1133,7 +1176,7 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
bg_color=bg, alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
self._style_table_cell(
|
||||
table.cell(row_idx, 2), risk["mitigation"],
|
||||
table.cell(row_idx, 2), pick(risk, "mitigation", "action", "response"),
|
||||
font_size=10, bg_color=bg,
|
||||
)
|
||||
|
||||
@@ -1155,7 +1198,8 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
|
||||
y = Inches(1.4)
|
||||
for pillar in pillars:
|
||||
style = status_styles.get(pillar["status"], status_styles["PASS"])
|
||||
status = pick(pillar, "status", default="PASS")
|
||||
style = status_styles.get(status, status_styles["PASS"])
|
||||
|
||||
# Status indicator pill
|
||||
pill = self._add_rect(
|
||||
@@ -1173,11 +1217,13 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
self._add_textbox(
|
||||
slide, Inches(1.2), y,
|
||||
Inches(4), Inches(0.45),
|
||||
text=pillar["name"], font_size=13, bold=True,
|
||||
text=pick(pillar, "name", "pillar"), font_size=13, bold=True,
|
||||
)
|
||||
|
||||
# Score
|
||||
score_text = f"{pillar['passed']}/{pillar['total']}" if pillar['total'] > 0 else "—"
|
||||
passed = pick(pillar, "passed", default=0)
|
||||
total = pick(pillar, "total", default=0)
|
||||
score_text = f"{passed}/{total}" if total > 0 else "—"
|
||||
self._add_textbox(
|
||||
slide, Inches(5.5), y,
|
||||
Inches(1.5), Inches(0.45),
|
||||
@@ -1274,24 +1320,24 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
|
||||
meta = spec.get("metadata", {})
|
||||
gen = cls(
|
||||
customer=meta.get("customer", ""),
|
||||
project=meta.get("project", ""),
|
||||
architect=meta.get("architect", ""),
|
||||
customer=pick(meta, "customer", "customer_name"),
|
||||
project=pick(meta, "project", "project_name"),
|
||||
architect=pick(meta, "architect", "prepared_by"),
|
||||
firm=meta.get("firm", ""),
|
||||
template=template,
|
||||
)
|
||||
|
||||
# Slide 1: Title
|
||||
gen.add_title_slide(subtitle=meta.get("subtitle", ""))
|
||||
gen.add_title_slide(subtitle=pick(meta, "subtitle", "title"))
|
||||
|
||||
# Slide 2: Summary
|
||||
if "summary" in spec:
|
||||
s = spec["summary"]
|
||||
gen.add_summary_slide(
|
||||
why=s.get("why", ""),
|
||||
current_state=s.get("current_state", []),
|
||||
target_state=s.get("target_state", ""),
|
||||
timeline=s.get("timeline", ""),
|
||||
why=pick(s, "why"),
|
||||
current_state=coerce_list(s.get("current_state")),
|
||||
target_state=pick(s, "target_state"),
|
||||
timeline=pick(s, "timeline"),
|
||||
)
|
||||
|
||||
# Slide 3: Service Tiering (ECAL)
|
||||
@@ -1343,8 +1389,8 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
if "cost" in spec:
|
||||
c = spec["cost"]
|
||||
gen.add_cost_slide(
|
||||
line_items=c.get("line_items", []),
|
||||
assumptions=c.get("assumptions", []),
|
||||
line_items=pick_list(c, "line_items"),
|
||||
assumptions=pick_list(c, "assumptions"),
|
||||
show_byol=c.get("show_byol", True),
|
||||
)
|
||||
|
||||
@@ -1361,9 +1407,9 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
if "migration" in spec:
|
||||
m = spec["migration"]
|
||||
gen.add_migration_slide(
|
||||
phases=m.get("phases", []),
|
||||
tools=m.get("tools", []),
|
||||
downtime=m.get("downtime", ""),
|
||||
phases=pick_list(m, "phases"),
|
||||
tools=pick_list(m, "tools"),
|
||||
downtime=pick(m, "downtime"),
|
||||
)
|
||||
|
||||
# Slide 13: Operational RACI (ECAL)
|
||||
@@ -1382,16 +1428,16 @@ class OCIDeckGenerator(OraclePresBase):
|
||||
if "scorecard" in spec:
|
||||
sc = spec["scorecard"]
|
||||
gen.add_scorecard_slide(
|
||||
pillars=sc.get("pillars", []),
|
||||
recommendations=sc.get("recommendations", []),
|
||||
pillars=pick_list(sc, "pillars"),
|
||||
recommendations=pick_list(sc, "recommendations"),
|
||||
)
|
||||
|
||||
# Slide 16: Next Steps
|
||||
if "next_steps" in spec:
|
||||
ns = spec["next_steps"]
|
||||
gen.add_next_steps_slide(
|
||||
steps=ns.get("steps", []),
|
||||
contact_info=ns.get("contact_info", ""),
|
||||
steps=pick_list(ns, "steps", "next_steps"),
|
||||
contact_info=pick(ns, "contact_info"),
|
||||
)
|
||||
|
||||
# Closing slides
|
||||
|
||||
@@ -408,6 +408,220 @@ OBS_TYPE_MAP = {
|
||||
"auditing": "monitoring", # uses monitoring icon as closest match
|
||||
}
|
||||
|
||||
|
||||
def pick(mapping: dict, *keys, default=""):
|
||||
"""Return the first non-empty value for any of the provided keys."""
|
||||
if not isinstance(mapping, dict):
|
||||
return default
|
||||
for key in keys:
|
||||
if key in mapping:
|
||||
value = mapping.get(key)
|
||||
if value is not None:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def pick_list(mapping: dict, *keys):
|
||||
"""Return the first list-like field normalized to a list."""
|
||||
if not isinstance(mapping, dict):
|
||||
return []
|
||||
for key in keys:
|
||||
if key in mapping:
|
||||
value = mapping.get(key)
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
return []
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
text = re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-")
|
||||
return text or "item"
|
||||
|
||||
|
||||
class _SpecIdGenerator:
|
||||
"""Generate stable unique ids within one normalized spec."""
|
||||
|
||||
def __init__(self):
|
||||
self.used = set()
|
||||
self.counters = {}
|
||||
|
||||
def make(self, prefix: str, seed: str = "") -> str:
|
||||
base = f"{prefix}-{_slugify(seed)}" if seed else prefix
|
||||
candidate = base
|
||||
idx = 2
|
||||
while candidate in self.used:
|
||||
candidate = f"{base}-{idx}"
|
||||
idx += 1
|
||||
self.used.add(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def _normalize_diagram_spec(spec: dict) -> dict:
|
||||
"""Normalize aliases and synthesize ids before rendering."""
|
||||
spec = spec or {}
|
||||
id_gen = _SpecIdGenerator()
|
||||
aliases = {}
|
||||
|
||||
def remember(node: dict, node_id: str):
|
||||
for key in ("id", "name", "label", "title"):
|
||||
value = node.get(key)
|
||||
if value:
|
||||
aliases[str(value)] = node_id
|
||||
|
||||
def resolve_ref(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return aliases.get(str(value), str(value))
|
||||
|
||||
def normalize_service(node: dict, prefix: str, default_type: str = "compute") -> dict:
|
||||
raw = dict(node or {})
|
||||
label = pick(raw, "label", "name", "title")
|
||||
node_id = pick(raw, "id", "name") or id_gen.make(prefix, label)
|
||||
normalized = dict(raw)
|
||||
normalized["id"] = str(node_id)
|
||||
normalized["label"] = label or str(node_id)
|
||||
normalized["type"] = pick(raw, "type", "service_type", default=default_type)
|
||||
remember(raw, normalized["id"])
|
||||
return normalized
|
||||
|
||||
normalized = {
|
||||
"title": spec.get("title", ""),
|
||||
"external": [],
|
||||
"clouds": [],
|
||||
"tenancy": {},
|
||||
"connections": [],
|
||||
}
|
||||
|
||||
for ext in pick_list(spec, "external"):
|
||||
normalized["external"].append(normalize_service(ext, "external"))
|
||||
|
||||
for cloud in pick_list(spec, "clouds"):
|
||||
raw_cloud = dict(cloud or {})
|
||||
label = pick(raw_cloud, "label", "name", "title")
|
||||
cloud_id = pick(raw_cloud, "id", "name") or id_gen.make("cloud", label)
|
||||
normalized_cloud = dict(raw_cloud)
|
||||
normalized_cloud["id"] = str(cloud_id)
|
||||
normalized_cloud["label"] = label or str(cloud_id)
|
||||
normalized_cloud["services"] = [
|
||||
normalize_service(svc, "cloud-svc", default_type="compute")
|
||||
for svc in pick_list(raw_cloud, "services")
|
||||
]
|
||||
remember(raw_cloud, normalized_cloud["id"])
|
||||
normalized["clouds"].append(normalized_cloud)
|
||||
|
||||
tenancy_spec = dict(spec.get("tenancy", {}) or {})
|
||||
normalized_tenancy = dict(tenancy_spec)
|
||||
normalized_tenancy["label"] = pick(tenancy_spec, "label", "name", "title", default="Oracle Cloud Infrastructure")
|
||||
normalized_tenancy["regions"] = []
|
||||
|
||||
for region in pick_list(tenancy_spec, "regions"):
|
||||
raw_region = dict(region or {})
|
||||
region_label = pick(raw_region, "label", "name", "title")
|
||||
region_id = pick(raw_region, "id", "name") or id_gen.make("region", region_label)
|
||||
normalized_region = dict(raw_region)
|
||||
normalized_region["id"] = str(region_id)
|
||||
normalized_region["label"] = region_label or str(region_id)
|
||||
normalized_region["availability_domains"] = []
|
||||
normalized_region["vcns"] = []
|
||||
normalized_region["compartments"] = []
|
||||
normalized_region["observability"] = pick_list(raw_region, "observability")
|
||||
remember(raw_region, normalized_region["id"])
|
||||
|
||||
for ad in pick_list(raw_region, "availability_domains"):
|
||||
raw_ad = dict(ad or {})
|
||||
ad_label = pick(raw_ad, "label", "name", "title")
|
||||
ad_id = pick(raw_ad, "id", "name") or id_gen.make("ad", ad_label)
|
||||
normalized_ad = dict(raw_ad)
|
||||
normalized_ad["id"] = str(ad_id)
|
||||
normalized_ad["label"] = ad_label or str(ad_id)
|
||||
remember(raw_ad, normalized_ad["id"])
|
||||
normalized_region["availability_domains"].append(normalized_ad)
|
||||
|
||||
def normalize_vcn(raw_vcn: dict) -> dict:
|
||||
vcn_label = pick(raw_vcn, "label", "name", "title")
|
||||
vcn_id = pick(raw_vcn, "id", "name") or id_gen.make("vcn", vcn_label)
|
||||
normalized_vcn = dict(raw_vcn)
|
||||
normalized_vcn["id"] = str(vcn_id)
|
||||
normalized_vcn["label"] = vcn_label or str(vcn_id)
|
||||
parent_ref = pick(raw_vcn, "parent", default="")
|
||||
if parent_ref:
|
||||
normalized_vcn["parent"] = resolve_ref(parent_ref) or str(parent_ref)
|
||||
normalized_vcn["gateways"] = [
|
||||
normalize_service(gw, "gw", default_type="drg" if pick(gw, "type", "service_type") in ("drg", "dynamic_routing_gateway") else "compute")
|
||||
for gw in pick_list(raw_vcn, "gateways")
|
||||
]
|
||||
normalized_vcn["subnets"] = []
|
||||
remember(raw_vcn, normalized_vcn["id"])
|
||||
|
||||
for subnet in pick_list(raw_vcn, "subnets"):
|
||||
raw_subnet = dict(subnet or {})
|
||||
subnet_label = pick(raw_subnet, "label", "name", "title")
|
||||
subnet_id = pick(raw_subnet, "id", "name") or id_gen.make("subnet", subnet_label)
|
||||
normalized_subnet = dict(raw_subnet)
|
||||
normalized_subnet["id"] = str(subnet_id)
|
||||
normalized_subnet["label"] = subnet_label or str(subnet_id)
|
||||
normalized_subnet["services"] = [
|
||||
normalize_service(svc, "svc", default_type="compute")
|
||||
for svc in pick_list(raw_subnet, "services")
|
||||
]
|
||||
remember(raw_subnet, normalized_subnet["id"])
|
||||
normalized_vcn["subnets"].append(normalized_subnet)
|
||||
return normalized_vcn
|
||||
|
||||
for vcn in pick_list(raw_region, "vcns"):
|
||||
normalized_region["vcns"].append(normalize_vcn(dict(vcn or {})))
|
||||
|
||||
for comp in pick_list(raw_region, "compartments"):
|
||||
raw_comp = dict(comp or {})
|
||||
comp_label = pick(raw_comp, "label", "name", "title")
|
||||
comp_id = pick(raw_comp, "id", "name") or id_gen.make("compartment", comp_label)
|
||||
normalized_comp = dict(raw_comp)
|
||||
normalized_comp["id"] = str(comp_id)
|
||||
normalized_comp["label"] = comp_label or str(comp_id)
|
||||
normalized_comp["vcns"] = [
|
||||
normalize_vcn(dict(vcn or {}))
|
||||
for vcn in pick_list(raw_comp, "vcns")
|
||||
]
|
||||
remember(raw_comp, normalized_comp["id"])
|
||||
normalized_region["compartments"].append(normalized_comp)
|
||||
|
||||
normalized_tenancy["regions"].append(normalized_region)
|
||||
|
||||
normalized["tenancy"] = normalized_tenancy
|
||||
|
||||
onprem_spec = spec.get("onprem")
|
||||
if not onprem_spec:
|
||||
onprem_spec = spec.get("on_prem")
|
||||
if onprem_spec:
|
||||
raw_onprem = dict(onprem_spec or {})
|
||||
onprem_label = pick(raw_onprem, "label", "name", "title", default="On-Premises Data Center")
|
||||
normalized_onprem = dict(raw_onprem)
|
||||
normalized_onprem["id"] = pick(raw_onprem, "id", "name", default="onprem") or "onprem"
|
||||
normalized_onprem["label"] = onprem_label
|
||||
normalized_onprem["services"] = [
|
||||
normalize_service(svc, "onprem-svc", default_type="compute")
|
||||
for svc in pick_list(raw_onprem, "services")
|
||||
]
|
||||
remember(raw_onprem, normalized_onprem["id"])
|
||||
normalized["onprem"] = normalized_onprem
|
||||
|
||||
for conn in pick_list(spec, "connections"):
|
||||
raw_conn = dict(conn or {})
|
||||
source = resolve_ref(pick(raw_conn, "from", "source", default=None))
|
||||
target = resolve_ref(pick(raw_conn, "to", "target", default=None))
|
||||
if not source or not target:
|
||||
continue
|
||||
normalized_conn = dict(raw_conn)
|
||||
normalized_conn["from"] = source
|
||||
normalized_conn["to"] = target
|
||||
normalized_conn["type"] = pick(raw_conn, "type", "service_type", default="standard")
|
||||
normalized["connections"].append(normalized_conn)
|
||||
|
||||
return normalized
|
||||
|
||||
# Unicode circled numbers for flow badges ① ② ③ ... ⑳
|
||||
_CIRCLED_NUMS = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
|
||||
|
||||
@@ -1314,6 +1528,7 @@ class OCIDiagramGenerator:
|
||||
Plus optional on-premises, external actors, and connections.
|
||||
See examples/diagram-spec.yaml for the full format.
|
||||
"""
|
||||
spec = _normalize_diagram_spec(spec)
|
||||
gen = cls()
|
||||
|
||||
# External actors (users, internet, third-party)
|
||||
|
||||
Reference in New Issue
Block a user