Configuration Rules API Sample

Description

Demonstrates reading and modifying configuration rules using the typed ConfigurationRules API.

Before running: Run in the DESIGN workspace from Scripts and Add-Ins (Python, C++, or TypeScript), unless the script states otherwise.

Code Samples

"""
Sample: read and modify Configuration Rules via the typed Fusion API.

Prerequisites:
- The active design must be a configured design (createConfiguredDesign +
  at least one column).
- The Fusion Design Extension must be enabled.

Run from the Fusion Script Editor (Text Commands > Scripts and Add-Ins).

Notes:
- The manager is reached via Design.configurationRulesManager. It returns null
  for designs that are not configured designs.
- Rules are stored remotely, so every read (getRules / itemById / itemByIds) is
  a discrete round-trip that returns a ConfigurationRulesResult; iterate its
  rules array rather than indexing the manager.
- Rule bodies are typed block trees (ConfigurationRuleStatement[]), built through
  the static create factories on each concrete class. There is no JSON body
  channel on the public surface.
- Always inspect the issues array on a returned result, even on success: a
  non-empty array means at least one block silently degraded to a placeholder.
- Issues and statuses are different things. An issue reports what went wrong with
  the call you just made; a status reports what the rules engine found when the
  rules last ran. Statuses are read separately, via getStatuses /
  getStatusesByRuleId / getStatusesByConfigurationId.
"""

import adsk.core
import adsk.fusion
import traceback


def _issue_summary(issues):
    """Render a list of ConfigurationRulesIssue objects for printing."""
    parts = []
    for issue in issues:
        index = issue.ruleIndex if issue.hasRuleIndex else "-"
        parts.append(
            f"  code={issue.code} ruleId={issue.ruleId} "
            f"ruleIndex={index} columnId={issue.columnId} message={issue.message}"
        )
    return "\n".join(parts) if parts else "  (none)"


def _severity_name(severity):
    """Render a ConfigurationRuleStatusSeverities value for printing."""
    severities = adsk.fusion.ConfigurationRuleStatusSeverities
    if severity == severities.ErrorConfigurationRuleStatusSeverity:
        return "ERROR"
    if severity == severities.WarningConfigurationRuleStatusSeverity:
        return "WARNING"
    return "INFO"


def _status_summary(statuses):
    """Render a list of ConfigurationRuleStatus objects for printing."""
    parts = []
    for status in statuses:
        # blockId is empty when the status describes the rule as a whole rather
        # than one block of it.
        block = status.blockId if status.blockId else "-"
        parts.append(
            f"  [{_severity_name(status.severity)}] code={status.code} "
            f"ruleId={status.ruleId} configurationId={status.configurationId} "
            f"blockId={block} message={status.message}"
        )
    return "\n".join(parts) if parts else "  (none)"


def _first_column_id(design):
    """Best-effort: return a column id from the configured design, or '' if none."""
    table = design.configurationTopTable
    if table is None:
        return ""
    columns = table.columns
    if columns is None or columns.count == 0:
        return ""
    return columns.item(0).id


def _report_statuses(cr, design):
    """Print what the rules engine found the last time the rules ran.

    Only problems are reported, so an empty result is the healthy case. These are
    local reads — they never trigger a rules run — and each result is a
    point-in-time snapshot, so re-query rather than holding on to one.
    """
    all_statuses = cr.getStatuses()
    print(
        f"=== Statuses: {len(all_statuses.statuses)} "
        f"({all_statuses.errorCount} error, {all_statuses.warningCount} warning, "
        f"{all_statuses.infoCount} info) ==="
    )
    print(_status_summary(all_statuses.statuses))

    # Per-rule health. A rule the engine is not running — deleted, or inactive and
    # therefore excluded from the rule set — returns None rather than an empty
    # result, so check for None before reading counts. A non-None result with
    # errorCount == 0 means the rule was applied to every configuration.
    print("=== Rule health ===")
    for rule in cr.getRules().rules:
        rule_statuses = cr.getStatusesByRuleId(rule.id)
        if rule_statuses is None:
            print(f"  '{rule.name}': not running (deleted or inactive)")
        elif rule_statuses.errorCount:
            print(f"  '{rule.name}': turned off on {rule_statuses.errorCount} configuration(s)")
        else:
            print(f"  '{rule.name}': applied everywhere")

    # Per-configuration health. The result also carries statuses that name no
    # configuration at all — a rule with an unfilled socket is a problem for every
    # configuration — so entries with an empty configurationId are normal here.
    rows = design.configurationTopTable.rows
    print(f"=== Configuration health: {rows.count} configuration(s) ===")
    for index in range(rows.count):
        row = rows.item(index)
        config_statuses = cr.getStatusesByConfigurationId(row.id)
        if config_statuses is None:
            # Only reachable if the row is no longer on the top table.
            continue
        state = "OK" if config_statuses.errorCount == 0 else f"{config_statuses.errorCount} error(s)"
        print(
            f"  '{row.name}': {state} "
            f"({config_statuses.warningCount} warning, {config_statuses.infoCount} info)"
        )


def _build_example_body(column_id):
    """A minimal typed body: set the given column to the fixed string 'Default'.

    Build bodies from the create factories on the concrete statement classes
    (ConfigurationRuleSetBlock / ConfigurationRuleIfBlock /
    ConfigurationRuleCommentBlock) and assign the array to ConfigurationRule.body.
    """
    set_block = adsk.fusion.ConfigurationRuleSetBlock.create()
    set_block.columnId = column_id
    set_block.operatorType = adsk.fusion.ConfigurationRuleSetOperators.EqualsConfigurationRuleSetOperator
    set_block.value = adsk.fusion.ConfigurationRuleStringValue.create("Default")
    return [set_block]


def run(context):
    ui = None
    try:
        app = adsk.core.Application.get()
        ui = app.userInterface
        design = adsk.fusion.Design.cast(app.activeProduct)

        if design is None:
            ui.messageBox("No active Fusion design.")
            return

        cr = design.configurationRulesManager
        if cr is None:
            ui.messageBox("This design is not a configured design with rules.")
            return

        # -------------------------------------------------------------------
        # Read: getRules() fetches every rule in one round-trip; iterate the
        # returned ConfigurationRulesResult.
        # -------------------------------------------------------------------
        snapshot = cr.getRules()
        print(f"=== Rules: {len(snapshot.rules)} ===")
        for rule in snapshot.rules:
            print(f"  id={rule.id} name={rule.name} active={rule.isActive}")
        if snapshot.issues:
            print("Issues from read:")
            print(_issue_summary(snapshot.issues))

        # Single id lookup via itemById; the result carries the matched rule.
        if snapshot.rules:
            first_id = snapshot.rules[0].id
            single = cr.itemById(first_id)
            if single.rule:
                print(f"itemById('{first_id}') -> name={single.rule.name}")

        # -------------------------------------------------------------------
        # Single-op conveniences: addRule(name) and deleteRule(id).
        # addRule returns a ConfigurationRuleResult: the new rule plus any
        # non-fatal issues. Inspect issues even on success.
        # -------------------------------------------------------------------
        add_result = cr.addRule("Sample-Convenience")
        new_rule = add_result.rule
        print(f"addRule() -> id={new_rule.id}, issues={len(add_result.issues)}")
        if add_result.issues:
            print(_issue_summary(add_result.issues))

        deleted = cr.deleteRule(new_rule.id)
        print(f"deleteRule('{new_rule.id}') -> {deleted}")

        # -------------------------------------------------------------------
        # Bulk modify: ConfigurationRulesModifyInput.create() + a typed rule.
        # Applied atomically in one transaction (deletes -> reorder -> adds/updates).
        # The returned result carries the post-mutation descriptors for adds and
        # updates, so server-assigned ids come back in the same call.
        # -------------------------------------------------------------------
        column_id = _first_column_id(design)
        if not column_id:
            print("No configuration columns found; skipping the bulk-modify example.")
            _report_statuses(cr, design)
            return

        modify = adsk.fusion.ConfigurationRulesModifyInput.create()

        # Add a brand-new rule with a populated, typed body. A rule with an empty
        # id is treated as an add on submit.
        added = adsk.fusion.ConfigurationRule.create()
        added.name = "Sample-Bulk-Add"
        added.comment = "Created from ConfigurationRulesSample"
        added.isActive = True
        added.body = _build_example_body(column_id)

        rules_to_submit = [added]

        # Update an existing rule by resubmitting it with its id intact. Submitting
        # a rule with a non-empty id replaces that rule's name/comment/active/body.
        if snapshot.rules:
            existing = snapshot.rules[0]
            existing.name = existing.name + " (updated)"
            rules_to_submit.append(existing)

        modify.rules = rules_to_submit

        result = cr.modifyRules(modify)
        print(f"modifyRules -> {len(result.rules)} affected rule(s), {len(result.issues)} issue(s)")
        for rule in result.rules:
            print(f"  affected: id={rule.id} name={rule.name}")
        print(_issue_summary(result.issues))

        _report_statuses(cr, design)

    except Exception:
        if ui:
            ui.messageBox("Configuration rules sample failed:\n" + traceback.format_exc())