Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/x2mdx/asyncapi/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,10 @@ def extract_channel_detail(doc: dict[str, Any], channel_name: str, channel_node:
resolved_channel = resolve_local_ref(doc, channel_node)
if not isinstance(resolved_channel, dict):
resolved_channel = {}
raw_state = resolved_channel.get("x-state")
state = str(raw_state).strip().lower() if isinstance(raw_state, str) and raw_state.strip() else None
raw_replaces = resolved_channel.get("x-replaces")
replaces = str(raw_replaces).strip() if isinstance(raw_replaces, str) and raw_replaces.strip() else None

actions: list[dict[str, Any]] = []
for action_name in ("publish", "subscribe"):
Expand All @@ -356,6 +360,8 @@ def extract_channel_detail(doc: dict[str, Any], channel_name: str, channel_node:
"channel": channel_name,
"anchor": channel_anchor(channel_name),
"description": str(resolved_channel.get("description") or ""),
"state": state,
"replaces": replaces,
"actions": actions,
"action_names": [action["action"] for action in actions],
}
Expand All @@ -365,6 +371,13 @@ def render_name_list(names: list[str]) -> str:
return ", ".join(f"`{name}`" for name in names)


def render_optional_token(value: Any) -> str:
if value is None:
return "`-`"
text = str(value).strip()
return f"`{text}`" if text else "`-`"


def describe_action_changes(previous: dict[str, Any] | None, current: dict[str, Any] | None, *, action_name: str) -> list[str]:
if previous is None and current is None:
return []
Expand Down Expand Up @@ -412,6 +425,14 @@ def describe_action_changes(previous: dict[str, Any] | None, current: dict[str,

def describe_channel_changes(previous: dict[str, Any], current: dict[str, Any]) -> list[str]:
changes: list[str] = []
if previous.get("state") != current.get("state"):
changes.append(
f"lifecycle state changed {render_optional_token(previous.get('state'))} -> {render_optional_token(current.get('state'))}"
)
if previous.get("replaces") != current.get("replaces"):
changes.append(
f"replacement target changed {render_optional_token(previous.get('replaces'))} -> {render_optional_token(current.get('replaces'))}"
)
if previous["description"] != current["description"]:
changes.append("channel description updated")

Expand Down Expand Up @@ -581,4 +602,3 @@ def build_asyncapi_report_from_sources(
per_version_deltas=per_version_deltas,
channels=merged_channels,
)

235 changes: 217 additions & 18 deletions src/x2mdx/asyncapi/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,27 @@

from __future__ import annotations

import json
import html
import re
from pathlib import Path
from typing import Any

from x2mdx.asyncapi.models import AsyncApiChannelLifecycle, AsyncApiReport
from x2mdx.output import Page
from x2mdx.presentation import (
CollectionPageModel,
DetailCodeBlock,
DetailTable,
LifecycleStatus,
ProtocolInteraction,
ProtocolSubject,
StatusRow,
VersionDeltaRow,
status_legend_items,
status_row_context,
version_delta_row_cells,
)
from x2mdx.templating import markdown_page


Expand Down Expand Up @@ -63,11 +77,28 @@ def compact_text(text: str, *, limit: int = 120) -> str:
return normalized[: limit - 3].rstrip() + "..."


def _channel_context(channel: AsyncApiChannelLifecycle) -> dict[str, Any]:
lifecycle_bits = [
f"Actions: {action_list(channel)}",
f"Introduced: `{channel.introduced_version}`",
]
def _channel_status_summary(channel: AsyncApiChannelLifecycle) -> str:
action_kind = action_list(channel)
description = escape_md_cell(compact_text(str(channel.latest.get("description") or "")))
if action_kind != "-" and description != "-":
return f"{action_kind}: {description}"
if action_kind != "-":
return action_kind
return description


def _channel_context_legacy(channel: AsyncApiChannelLifecycle) -> dict[str, Any]:
lifecycle_bits = []
if channel.latest.get("state"):
lifecycle_bits.append(f"Lifecycle state: {md_code(channel.latest['state'])}")
if channel.latest.get("replaces"):
lifecycle_bits.append(f"Replaces: {md_code(channel.latest['replaces'])}")
lifecycle_bits.extend(
[
f"Actions: {action_list(channel)}",
f"Introduced: `{channel.introduced_version}`",
]
)
if channel.change_details:
lifecycle_bits.append("Changed in: " + ", ".join(f"`{entry['version']}`" for entry in channel.change_details))
if channel.removed_version:
Expand Down Expand Up @@ -123,13 +154,26 @@ def _channel_context(channel: AsyncApiChannelLifecycle) -> dict[str, Any]:
}


def build_page(
def build_page_legacy(
report: AsyncApiReport,
*,
output_path: str,
page_title: str,
page_description: str,
) -> Page:
status_rows = tuple(
StatusRow(
link=channel_link(channel.channel),
summary=_channel_status_summary(channel),
lifecycle=LifecycleStatus.from_values(
introduced=channel.introduced_version,
changed_versions=[str(entry["version"]) for entry in channel.change_details],
state=str(channel.latest.get("state") or "") or None,
removed=channel.removed_version,
),
)
for channel in report.channels
)
return markdown_page(
path=Path(output_path).as_posix(),
title=page_title,
Expand All @@ -145,17 +189,172 @@ def build_page(
]
for version in report.versions
],
channel_summary_rows=[
[
channel_link(channel.channel),
action_list(channel),
escape_md_cell(compact_text(str(channel.latest.get("description") or ""))),
md_code(channel.introduced_version),
escape_md_cell(render_change_summary(channel.change_details)),
"-",
md_code(channel.removed_version) if channel.removed_version else "-",
channel_summary_rows=[status_row_context(row) for row in status_rows],
channel_summary_legend=status_legend_items(status_rows),
channels=[_channel_context_legacy(channel) for channel in report.channels],
)


def _channel_subject(channel: AsyncApiChannelLifecycle) -> ProtocolSubject:
lifecycle_items: list[str] = []
if channel.latest.get("state"):
lifecycle_items.append(f"Lifecycle state: {md_code(channel.latest['state'])}")
if channel.latest.get("replaces"):
lifecycle_items.append(f"Replaces: {md_code(channel.latest['replaces'])}")
lifecycle_items.extend(
[
f"Actions: {action_list(channel)}",
f"Introduced: `{channel.introduced_version}`",
]
)
if channel.change_details:
lifecycle_items.append("Changed in: " + ", ".join(f"`{entry['version']}`" for entry in channel.change_details))
if channel.removed_version:
lifecycle_items.append(f"Removed in: `{channel.removed_version}`")
lifecycle_items.append("Shown for historical reference.")
interactions: list[ProtocolInteraction] = []
for action in channel.latest.get("actions", []):
detail_items = tuple(
item
for item in [
f"Operation ID: `{action['operation_id']}`" if action["operation_id"] else "",
f"WebSocket method: `{action['ws_method']}`" if action["ws_method"] else "",
(
f"Message: `{action['message']['name']}`"
if action["message"]["name"] and action["message"]["name"] != "-"
else ""
),
(
f"Content type: `{action['message']['content_type']}`"
if action["message"]["content_type"] and action["message"]["content_type"] != "-"
else ""
),
]
for channel in report.channels
],
channels=[_channel_context(channel) for channel in report.channels],
if item
)
detail_blocks: list[DetailTable | DetailCodeBlock] = [
DetailTable(
headers=("Payload Schema", "Required Fields"),
rows=(
(
md_code(action["message"]["payload_schema"]),
", ".join(md_code(field) for field in action["message"]["required_fields"])
if action["message"]["required_fields"]
else "-",
),
),
)
]
if action["message"]["sample"] is not None:
detail_blocks.append(
DetailCodeBlock(
title="Message Example",
language="json",
body=json.dumps(action["message"]["sample"], indent=2),
)
)
interactions.append(
ProtocolInteraction(
label=str(action["action"]).capitalize(),
detail_items=detail_items,
description=str(action["description"] or ""),
detail_blocks=tuple(detail_blocks),
)
)
return ProtocolSubject(
anchor=channel.anchor,
title=channel.channel,
kind=action_list(channel),
summary=escape_md_cell(compact_text(str(channel.latest.get("description") or ""))),
lifecycle=LifecycleStatus.from_values(
introduced=channel.introduced_version,
changed_versions=[str(entry["version"]) for entry in channel.change_details],
state=str(channel.latest.get("state") or "") or None,
removed=channel.removed_version,
),
lifecycle_items=tuple(lifecycle_items),
description=str(channel.latest.get("description") or ""),
version_changes=tuple(
(
md_code(str(entry["version"])),
escape_md_cell("; ".join(str(change) for change in entry["changes"])),
)
for entry in channel.change_details
),
interactions=tuple(interactions),
)


def _channel_context(channel: ProtocolSubject) -> dict[str, Any]:
actions: list[dict[str, Any]] = []
for action in channel.interactions:
payload = next(
block for block in action.detail_blocks if isinstance(block, DetailTable) and block.headers == ("Payload Schema", "Required Fields")
)
sample = next((block for block in action.detail_blocks if isinstance(block, DetailCodeBlock)), None)
actions.append(
{
"heading": action.label,
"detail_items": list(action.detail_items),
"description": action.description,
"payload_row": list(payload.rows[0]),
"sample": json.loads(sample.body) if sample else None,
}
)
return {
"anchor": channel.anchor,
"name": channel.title,
"lifecycle_bits": list(channel.lifecycle_items),
"description": channel.description,
"change_rows": [list(row) for row in channel.version_changes],
"actions": actions,
}


def build_page(
report: AsyncApiReport,
*,
output_path: str,
page_title: str,
page_description: str,
) -> Page:
channels = tuple(_channel_subject(channel) for channel in report.channels)
page_model = CollectionPageModel(
path=Path(output_path).as_posix(),
title=page_title,
description=page_description,
version_rows=tuple(
VersionDeltaRow(
version=version,
added=str(report.per_version_deltas[version]["added_count"]),
changed=str(report.per_version_deltas[version]["changed_count"]),
removed=str(report.per_version_deltas[version]["removed_count"]),
)
for version in report.versions
),
status_rows=tuple(
StatusRow(
link=channel_link(channel.title),
summary=(
f"{channel.kind}: {channel.summary}"
if channel.kind != "-" and channel.summary != "-"
else channel.kind
if channel.kind != "-"
else channel.summary
),
lifecycle=channel.lifecycle,
)
for channel in channels
),
)
return markdown_page(
path=page_model.path,
title=page_model.title,
description=page_model.description,
template_name="asyncapi/page.md.j2",
report=report,
version_timeline_rows=[version_delta_row_cells(row) for row in page_model.version_rows],
channel_summary_rows=[status_row_context(row) for row in page_model.status_rows],
channel_summary_legend=status_legend_items(page_model.status_rows),
channels=[_channel_context(channel) for channel in channels],
)
Loading