mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:43:17 +08:00
Topology MCP adds pull/compact/bundle/suggest-hubs with a no-template skill path; API gains fabric level and NE collection policy; web list pages get paging and denser collect/network workflows. Co-authored-by: Cursor <cursoragent@cursor.com>
138 lines
3.6 KiB
Python
138 lines
3.6 KiB
Python
"""Fabric topology level (layout rank): major.minor, smaller = closer to external/WAN."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import Any
|
|
|
|
# Preset alias → default level (major.0). Sub-tiers use 1.1, 2.1, …
|
|
LEVEL_PRESETS: dict[str, float] = {
|
|
"external": 0.0,
|
|
"core": 1.0,
|
|
"aggregation": 2.0,
|
|
"aggregate": 2.0,
|
|
"agg": 2.0,
|
|
"access": 3.0,
|
|
"edge": 4.0,
|
|
"cpe": 4.0,
|
|
}
|
|
|
|
# floor(level) → synced role alias (filters / UI chips)
|
|
_MAJOR_TO_ROLE: dict[int, str] = {
|
|
0: "external",
|
|
1: "core",
|
|
2: "aggregation",
|
|
3: "access",
|
|
}
|
|
|
|
_ROLE_VALUES = frozenset(LEVEL_PRESETS) | {"unknown", ""}
|
|
|
|
|
|
def normalize_level(value: Any) -> float | None:
|
|
"""Parse level; empty/None → None. Snap to one decimal in [0, 99.9]."""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, str):
|
|
s = value.strip().lower()
|
|
if not s or s in {"unknown", "null", "none"}:
|
|
return None
|
|
if s in LEVEL_PRESETS:
|
|
return LEVEL_PRESETS[s]
|
|
try:
|
|
value = float(s)
|
|
except ValueError as exc:
|
|
raise ValueError("level_invalid") from exc
|
|
try:
|
|
lv = float(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError("level_invalid") from exc
|
|
if not math.isfinite(lv):
|
|
raise ValueError("level_invalid")
|
|
if lv < 0 or lv > 99.9:
|
|
raise ValueError("level_out_of_range")
|
|
return round(lv + 1e-9, 1)
|
|
|
|
|
|
def level_major(level: float | None) -> int | None:
|
|
if level is None:
|
|
return None
|
|
return int(math.floor(float(level)))
|
|
|
|
|
|
def level_to_role(level: float | None) -> str:
|
|
"""Denormalized role alias for filters; empty when unclassified."""
|
|
maj = level_major(level)
|
|
if maj is None:
|
|
return ""
|
|
if maj in _MAJOR_TO_ROLE:
|
|
return _MAJOR_TO_ROLE[maj]
|
|
if maj >= 4:
|
|
return "edge"
|
|
return ""
|
|
|
|
|
|
def role_to_level(role: str | None) -> float | None:
|
|
r = str(role or "").strip().lower()
|
|
if not r or r == "unknown":
|
|
return None
|
|
if r in LEVEL_PRESETS:
|
|
return LEVEL_PRESETS[r]
|
|
raise ValueError("role_invalid")
|
|
|
|
|
|
def coerce_level_input(
|
|
*,
|
|
level: Any = None,
|
|
role: Any = None,
|
|
level_provided: bool = False,
|
|
role_provided: bool = False,
|
|
) -> float | None | object:
|
|
"""Resolve patch/bulk input.
|
|
|
|
Returns:
|
|
- float | None: concrete level (None clears)
|
|
- Ellipsis: neither field provided
|
|
"""
|
|
if level_provided:
|
|
return normalize_level(level)
|
|
if role_provided:
|
|
return role_to_level(None if role is None else str(role))
|
|
return Ellipsis
|
|
|
|
|
|
def format_level(level: float | None) -> str:
|
|
if level is None:
|
|
return ""
|
|
lv = float(level)
|
|
if abs(lv - round(lv)) < 1e-9:
|
|
return str(int(round(lv)))
|
|
return f"{lv:.1f}".rstrip("0").rstrip(".") if "." in f"{lv:.1f}" else f"{lv:.1f}"
|
|
|
|
|
|
def infer_layer_from_level(
|
|
level: float | None,
|
|
*,
|
|
name: str = "",
|
|
role: str | None = None,
|
|
) -> str:
|
|
"""Map to layout layer key: external|core|agg|access|other."""
|
|
maj = level_major(level)
|
|
if maj is not None:
|
|
if maj <= 0:
|
|
return "external"
|
|
if maj == 1:
|
|
return "core"
|
|
if maj == 2:
|
|
return "agg"
|
|
if maj >= 3:
|
|
return "access"
|
|
# Fallbacks when unclassified
|
|
r = str(role or "").strip().lower()
|
|
if r in LEVEL_PRESETS:
|
|
return infer_layer_from_level(LEVEL_PRESETS[r])
|
|
import re
|
|
|
|
m = re.search(r"-(CN|AN|EN)(\d*)-", name or "", re.I)
|
|
if not m:
|
|
return "other"
|
|
return {"CN": "core", "AN": "agg", "EN": "access"}[m.group(1).upper()]
|