engineering/arch/schedule.py turns the plan records into schedule rows: doors by tag with width, height, swing, hand and wall; windows by tag with width, height, sill and wall; rooms by number with name and the measured area the label carries, plus a total. Tags compare as numbers (D2 before D10). An opening without a tag gets the next free D1 / W1 - K1 / P1 in Turkish - in wall order; an explicit tag is never renumbered and a generated one never repeats it. A value the model does not carry is an empty cell, never a default, and Turkish output writes decimal commas. draw_schedule reads the ACADMCP_ARCH records off the drawing through the one reader rooms.py already has and draws a real TABLE the way the ISO 7573 parts list does - top-left insertion, the table growing down, the parts list's 7 mm rows and 2.5 mm text at the plot scale, the engine's representation reported. Nothing to schedule, an unknown kind or language and more rows than one TABLE takes are refused before anything is drawn. arch_schedule joins arch_room and arch_rooms_detect under SECTION 25's rooms marker (banner 3 tools), shares the TABLE command with entity_create_table and bom_table, and the counts follow: 232 tools in 25 groups. Live fix in com_backend.entity_create_table. MEASURED on AutoCAD 2026: IAcadTable has no TextHeight property. pywin32 raised AttributeError on `table.TextHeight = h`, the old code swallowed it, and every live table kept the Standard style's heights (title 6.0, header and data 4.5), which is 0.1 mm on paper at 1:50. The member that works is SetCellTextHeight(row, col, h), probed on a scratch document: every cell read back 125.0 via GetCellTextHeight. The row heights stayed 350.0 and GetBoundingBox matched the reported bbox (10000,3250)-(15750,5000). Then draw_schedule ran end to end through ComBackend on a scratch document: a native TABLE on A-SYMB-T-N for doors (5 rows) and rooms/tr (6 rows, total 45,00 m²), and the document was closed afterwards. The new fake in tests/test_table_leader.py has only the measured members. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
621 lines
26 KiB
Python
621 lines
26 KiB
Python
"""server.py must never report something it does not have.
|
|
|
|
Three families of that rule live here.
|
|
|
|
**The registry must be read untransformed.** ``_registered_tools()`` backs three
|
|
things that all need the *real* inventory:
|
|
|
|
* ``system_status`` / ``system_about`` tool counts,
|
|
* ``_tool_groups()`` (the system_about per-group breakdown),
|
|
* ``_apply_tool_profile()`` (which tools get ``mcp.disable()``d).
|
|
|
|
FastMCP applies :class:`~fastmcp.server.transforms.Transform` chains at two
|
|
levels, and only one of them reaches ``mcp._list_tools()``. Shipped search mode
|
|
(``_apply_discovery_mode("search")``) adds a *server-level* transform: it swaps
|
|
the catalog for ``search_tools``/``call_tool`` at the wire, while
|
|
``mcp._list_tools()`` upstream of it still lists everything — so search mode on
|
|
its own moves none of the three. A *provider-level* transform is the one that
|
|
rewrites ``mcp._list_tools()`` itself, and that is what ``_TruncatingTransform``
|
|
below installs. So the property pinned here is the stronger, layer-agnostic one:
|
|
registry-truth is read below every transform, and no catalog rewrite — today's,
|
|
or a provider-level one someone mounts tomorrow — can make the server report a
|
|
surface it does not have or silently turn TOOL_PROFILE into a no-op.
|
|
|
|
**A tool must not report a document it did not open** (T0.2) — the read-side
|
|
twin of the DWG write refusal, and the reason a ``.dwg`` path is refused on a
|
|
backend whose ``dwg`` capability is false.
|
|
|
|
**Discovery metadata must not cost anything.** ``@cad_tool`` writes summary /
|
|
cost / alias metadata into the MCP ``meta`` channel; ``tags`` are load-bearing
|
|
(``_tool_groups()`` buckets every tool by them for system_about), so the
|
|
grouping is pinned to the tags literally written in server.py's source. The
|
|
channel is also gated for *coverage*: every registered tool must carry a card,
|
|
and no card may contradict the tool's own annotations.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastmcp import Client
|
|
from fastmcp.exceptions import ToolError
|
|
from fastmcp.server.transforms import Transform
|
|
|
|
import server
|
|
from backends.base import CapabilityMap, FeatureCapability
|
|
from discovery.aliases import aliases_for
|
|
from discovery.serialize import MAX_SUMMARY_CHARS
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
class _TruncatingTransform(Transform):
|
|
"""Stand-in for a catalog-replacing transform: collapses the list to two tools.
|
|
|
|
Mirrors the shape of the problem rather than any shipped transform — a
|
|
transform that replaces the tool list wholesale instead of decorating it,
|
|
mounted (see the fixture) at the level that reaches ``mcp._list_tools()``.
|
|
"""
|
|
|
|
def __init__(self, keep: int = 2):
|
|
self.keep = keep
|
|
|
|
async def list_tools(self, tools):
|
|
return list(tools)[: self.keep]
|
|
|
|
|
|
@pytest.fixture
|
|
def truncating_transform():
|
|
"""Install the transform where FastMCP actually applies it, then remove it.
|
|
|
|
``mcp._list_tools()`` aggregates ``provider.list_tools()``, and
|
|
``Provider.list_tools`` is what runs the provider's transforms — so a
|
|
provider-level transform is exactly what rewrites that call's output.
|
|
"""
|
|
transform = _TruncatingTransform()
|
|
server.mcp.local_provider.add_transform(transform)
|
|
try:
|
|
yield transform
|
|
finally:
|
|
server.mcp.local_provider._transforms.remove(transform)
|
|
|
|
|
|
@pytest_asyncio.fixture(autouse=True)
|
|
async def _restore_full_profile():
|
|
"""These tests call _apply_tool_profile, which mutates global enablement."""
|
|
yield
|
|
await server._apply_tool_profile("full")
|
|
|
|
|
|
async def test_transform_really_rewrites_the_public_listing(truncating_transform):
|
|
"""Guard the guard: prove the fixture actually breaks the public accessor.
|
|
|
|
Without this the other tests would pass even if the transform silently did
|
|
nothing.
|
|
"""
|
|
assert len(await server.mcp._list_tools()) == 2
|
|
|
|
|
|
async def test_registered_tools_unaffected_by_a_transform(truncating_transform):
|
|
names = {t.name for t in await server._registered_tools()}
|
|
assert len(names) > 100
|
|
assert "system_about" in names
|
|
assert "gear_draw_spur_front_view" in names
|
|
|
|
|
|
async def test_registered_tool_count_unaffected_by_a_transform(truncating_transform):
|
|
count = await server._registered_tool_count()
|
|
assert count is not None
|
|
assert count > 100
|
|
|
|
|
|
async def test_tool_groups_unaffected_by_a_transform(truncating_transform):
|
|
groups = await server._tool_groups()
|
|
# The transform collapses the catalog to two drawing tools, which would
|
|
# leave a single "drawing" bucket.
|
|
assert len(groups) > 5
|
|
assert groups["transactions"] == [
|
|
"transaction_begin",
|
|
"transaction_commit",
|
|
"transaction_rollback",
|
|
]
|
|
assert "gear_draw_spur_front_view" in groups["engineering"]
|
|
|
|
|
|
async def test_tool_profile_still_disables_under_a_transform(truncating_transform):
|
|
"""TOOL_PROFILE must keep working when a catalog-rewriting transform is mounted.
|
|
|
|
Reading the transformed catalog would make ``registered`` tiny, so
|
|
``registered - enabled`` collapses and ``mcp.disable()`` never fires.
|
|
"""
|
|
info = await server._apply_tool_profile("lean")
|
|
assert info["registered_count"] > 100
|
|
assert info["enabled_count"] == len(server.LEAN_TOOL_NAMES)
|
|
assert info["disabled_count"] > 50
|
|
# The escape hatches really are hidden, not merely counted as hidden.
|
|
assert "system_run_command" in info["disabled_tools"]
|
|
|
|
|
|
async def test_registered_tools_returns_empty_when_registry_unknown(monkeypatch):
|
|
"""Contract: never raises, and reports [] / None rather than a bogus count."""
|
|
|
|
class _Broken:
|
|
@property
|
|
def _components(self):
|
|
raise RuntimeError("registry layout changed")
|
|
|
|
async def _list_tools(self):
|
|
raise RuntimeError("registry layout changed")
|
|
|
|
async def _boom():
|
|
raise RuntimeError("public accessor gone")
|
|
|
|
monkeypatch.setattr(server.mcp, "_local_provider", _Broken(), raising=False)
|
|
monkeypatch.setattr(server.mcp, "_list_tools", _boom, raising=False)
|
|
|
|
assert await server._registered_tools() == []
|
|
assert await server._registered_tool_count() is None
|
|
|
|
|
|
# ── T0.2 — drawing_open must not report a document it did not open ──────────
|
|
|
|
|
|
class _Ctx:
|
|
"""Minimal async context for calling a tool function directly."""
|
|
|
|
def __init__(self, backend):
|
|
self.lifespan_context = {"backend": backend}
|
|
|
|
async def info(self, message):
|
|
pass
|
|
|
|
async def warning(self, message):
|
|
pass
|
|
|
|
async def report_progress(self, progress, total):
|
|
pass
|
|
|
|
|
|
async def _saved_dxf(backend, tmp_path):
|
|
dxf = tmp_path / "part.dxf"
|
|
await backend.drawing_save_as(str(dxf), "dxf")
|
|
return dxf
|
|
|
|
|
|
async def test_drawing_open_opens_a_dxf(backend, tmp_path):
|
|
"""Baseline: the honest path keeps working."""
|
|
dxf = await _saved_dxf(backend, tmp_path)
|
|
result = await server.drawing_open(str(dxf), ctx=_Ctx(backend))
|
|
assert result["ok"] is True
|
|
assert result["name"] == "part.dxf"
|
|
|
|
|
|
async def test_drawing_open_refuses_a_dwg_the_backend_cannot_read(backend, tmp_path):
|
|
"""DXF bytes wearing a .dwg name must not be reported as an opened DWG.
|
|
|
|
ezdxf sniffs content, not extensions, so it happily parses a mislabelled
|
|
file (exactly what pre-1.5.0 drawing_save produced) and the tool then
|
|
answers ``{"ok": True, "name": "part.dwg"}`` — a document that does not
|
|
exist in that format.
|
|
"""
|
|
dxf = await _saved_dxf(backend, tmp_path)
|
|
disguised = tmp_path / "part.dwg"
|
|
shutil.copy(dxf, disguised)
|
|
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await server.drawing_open(str(disguised), ctx=_Ctx(backend))
|
|
|
|
message = str(excinfo.value).lower()
|
|
assert "dwg" in message
|
|
# Blames the backend's missing capability and names the escape hatch,
|
|
# rather than ezdxf's bare "is not a DXF file".
|
|
assert "ezdxf" in message
|
|
assert "com" in message
|
|
|
|
|
|
async def test_drawing_open_refuses_a_real_dwg_without_leaking_a_parser_error(backend, tmp_path):
|
|
real_dwg = tmp_path / "real.dwg"
|
|
real_dwg.write_bytes(b"AC1032" + b"\x00" * 256)
|
|
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await server.drawing_open(str(real_dwg), ctx=_Ctx(backend))
|
|
|
|
assert "is not a DXF file" not in str(excinfo.value)
|
|
|
|
|
|
async def test_drawing_open_refusal_leaves_the_current_document_untouched(backend, tmp_path):
|
|
dxf = await _saved_dxf(backend, tmp_path)
|
|
ctx = _Ctx(backend)
|
|
await server.drawing_open(str(dxf), ctx=ctx)
|
|
|
|
disguised = tmp_path / "other.dwg"
|
|
shutil.copy(dxf, disguised)
|
|
with pytest.raises(ToolError):
|
|
await server.drawing_open(str(disguised), ctx=ctx)
|
|
|
|
info = await backend.drawing_info()
|
|
assert info.name == "part.dxf"
|
|
|
|
|
|
class _DwgCapableBackend:
|
|
"""Stand-in for the live COM backend, which reads DWG natively."""
|
|
|
|
name = "com"
|
|
|
|
def __init__(self):
|
|
self.opened: list[str] = []
|
|
|
|
def capabilities(self) -> CapabilityMap:
|
|
return CapabilityMap(
|
|
backend="com",
|
|
features={"dwg": FeatureCapability(True, "native")},
|
|
)
|
|
|
|
async def drawing_open(self, path: str) -> dict:
|
|
self.opened.append(path)
|
|
return {"ok": True, "name": "part.dwg", "path": path}
|
|
|
|
|
|
async def test_drawing_open_allows_dwg_when_the_backend_supports_it(tmp_path):
|
|
"""The refusal is capability-driven, not an ezdxf-shaped hardcode."""
|
|
fake = _DwgCapableBackend()
|
|
result = await server.drawing_open(str(tmp_path / "part.dwg"), ctx=_Ctx(fake))
|
|
assert result["ok"] is True
|
|
assert len(fake.opened) == 1
|
|
|
|
|
|
async def test_drawing_open_does_not_advertise_unconditional_dwg():
|
|
"""The lie was also in the docs the model reads before calling."""
|
|
tool = next(t for t in await server._registered_tools() if t.name == "drawing_open")
|
|
text = (tool.description or "").lower()
|
|
for param in (tool.parameters or {}).get("properties", {}).values():
|
|
text += " " + str(param.get("description", "")).lower()
|
|
assert "dwg" in text, "DWG support should still be discoverable"
|
|
assert "com" in text, "but qualified by the backend that actually has it"
|
|
|
|
|
|
# ── @cad_tool — the discovery metadata channel ──────────────────────────────
|
|
|
|
CAD_TOOL_SECTION = ("transaction_begin", "transaction_commit", "transaction_rollback")
|
|
|
|
|
|
def _component(name: str):
|
|
for tool in server._local_tool_components():
|
|
if tool.name == name:
|
|
return tool
|
|
raise AssertionError(f"{name} is not registered")
|
|
|
|
|
|
@pytest.mark.parametrize("name", CAD_TOOL_SECTION)
|
|
async def test_cad_tool_publishes_discovery_meta(name):
|
|
cad = (_component(name).meta or {})["cad"]
|
|
assert cad["summary"] and "\n" not in cad["summary"]
|
|
assert cad["cost"] in server.CAD_TOOL_COSTS
|
|
|
|
|
|
@pytest.mark.parametrize("name", CAD_TOOL_SECTION)
|
|
async def test_cad_tool_pulls_aliases_from_the_discovery_corpus(name):
|
|
"""The corpus is the single source of truth; server.py must not restate it."""
|
|
record = aliases_for(name)
|
|
cad = (_component(name).meta or {})["cad"]
|
|
assert cad["acad"] == list(record.acad)
|
|
assert cad["synonyms"] == list(record.synonyms)
|
|
assert cad["synonyms"], "the transaction tools do carry synonyms"
|
|
|
|
|
|
async def test_cad_tool_meta_reaches_the_wire(monkeypatch):
|
|
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
|
async with Client(server.mcp) as client:
|
|
tools = {tool.name: tool for tool in await client.list_tools()}
|
|
cad = tools["transaction_rollback"].meta["cad"]
|
|
assert cad["cost"] == "destructive"
|
|
assert "rollback" in cad["synonyms"]
|
|
|
|
|
|
async def test_cad_tool_rejects_an_unknown_cost():
|
|
with pytest.raises(ValueError, match="cost"):
|
|
server.cad_tool(summary="x", cost="somewhat-dangerous")
|
|
|
|
|
|
async def test_cad_tool_requires_the_registration_below_it():
|
|
"""A misordered decorator must fail loudly at import, not silently no-op."""
|
|
decorator = server.cad_tool(summary="x", cost="read")
|
|
|
|
async def never_registered(ctx=None) -> dict:
|
|
return {}
|
|
|
|
with pytest.raises(RuntimeError, match="not registered"):
|
|
decorator(never_registered)
|
|
|
|
|
|
# ── @cad_tool coverage: the whole surface, not a sample ─────────────────────
|
|
#
|
|
# The twin of the alias-corpus gate in tests/test_discovery_aliases.py. That one
|
|
# proves every registered tool has an alias record; this one proves every
|
|
# registered tool has a discovery card, and that the card agrees with the
|
|
# annotations the same tool already publishes. Both derive their subject from
|
|
# the live registry, so a tool added without the metadata fails here instead of
|
|
# shipping with a summary silently scraped off its docstring.
|
|
|
|
|
|
def _cad(tool) -> dict | None:
|
|
cad = (tool.meta or {}).get("cad")
|
|
return cad if isinstance(cad, dict) else None
|
|
|
|
|
|
async def test_every_registered_tool_carries_cad_meta():
|
|
"""Registering a tool without @cad_tool must fail this gate."""
|
|
missing = sorted(t.name for t in await server._registered_tools() if _cad(t) is None)
|
|
assert not missing, f"tools with no @cad_tool metadata: {missing}"
|
|
|
|
|
|
async def test_every_cad_summary_is_a_single_non_empty_line():
|
|
"""A card is one line; a summary with a newline in it would break the format."""
|
|
bad: list[str] = []
|
|
for tool in await server._registered_tools():
|
|
summary = (_cad(tool) or {}).get("summary")
|
|
if not isinstance(summary, str) or not summary.strip() or "\n" in summary:
|
|
bad.append(f"{tool.name}:{summary!r}")
|
|
assert not bad, f"summaries that are not one usable line: {bad}"
|
|
|
|
|
|
async def test_no_authored_summary_is_long_enough_to_be_truncated():
|
|
"""A hand-written summary that overruns the budget would ship rendered as "...".
|
|
|
|
The serializer's truncation exists for the docstring fallback, not as a
|
|
licence to write a paragraph into @cad_tool.
|
|
"""
|
|
overlong = sorted(
|
|
(t.name, len((_cad(t) or {}).get("summary") or ""))
|
|
for t in await server._registered_tools()
|
|
if len((_cad(t) or {}).get("summary") or "") > MAX_SUMMARY_CHARS
|
|
)
|
|
assert not overlong, f"summaries the serializer would truncate: {overlong}"
|
|
|
|
|
|
async def test_every_cad_cost_is_a_known_value():
|
|
bad = sorted(
|
|
f"{t.name}:{(_cad(t) or {}).get('cost')!r}"
|
|
for t in await server._registered_tools()
|
|
if (_cad(t) or {}).get("cost") not in server.CAD_TOOL_COSTS
|
|
)
|
|
assert not bad, f"costs outside CAD_TOOL_COSTS: {bad}"
|
|
|
|
|
|
async def test_a_read_only_tool_is_costed_read():
|
|
"""``readOnlyHint`` is what a client gates on; the cost must not contradict it."""
|
|
bad: list[str] = []
|
|
for tool in await server._registered_tools():
|
|
annotations = tool.annotations
|
|
if annotations is not None and annotations.readOnlyHint:
|
|
cost = (_cad(tool) or {}).get("cost")
|
|
if cost != "read":
|
|
bad.append(f"{tool.name}:{cost!r}")
|
|
assert not bad, f"readOnlyHint tools not costed 'read': {bad}"
|
|
|
|
|
|
async def test_a_destructive_tool_is_never_costed_read_or_safe():
|
|
"""The other half of the agreement: an erase must not look harmless."""
|
|
bad: list[str] = []
|
|
for tool in await server._registered_tools():
|
|
annotations = tool.annotations
|
|
if annotations is not None and annotations.destructiveHint:
|
|
cost = (_cad(tool) or {}).get("cost")
|
|
if cost in ("read", "safe"):
|
|
bad.append(f"{tool.name}:{cost!r}")
|
|
assert not bad, f"destructiveHint tools costed as harmless: {bad}"
|
|
|
|
|
|
async def test_only_the_raw_passthroughs_are_costed_escape():
|
|
"""'escape' means 'this hands your string to AutoCAD', and nothing else."""
|
|
escapes = sorted(
|
|
t.name for t in await server._registered_tools() if (_cad(t) or {}).get("cost") == "escape"
|
|
)
|
|
assert escapes == ["system_run_command", "system_run_lisp"]
|
|
|
|
|
|
async def test_no_tool_restates_its_aliases_in_server_py():
|
|
"""@cad_tool copies the corpus; server.py must never hand-write acad/synonyms."""
|
|
bad: list[str] = []
|
|
for tool in await server._registered_tools():
|
|
cad = _cad(tool) or {}
|
|
record = aliases_for(tool.name)
|
|
expected_acad = list(record.acad) if record else []
|
|
expected_synonyms = list(record.synonyms) if record else []
|
|
if cad.get("acad") != expected_acad or cad.get("synonyms") != expected_synonyms:
|
|
bad.append(tool.name)
|
|
assert not bad, f"cad metadata out of step with discovery.aliases: {bad}"
|
|
|
|
|
|
# ── tags are load-bearing: _tool_groups() feeds system_about ────────────────
|
|
|
|
|
|
def _tags_declared_in_source() -> dict[str, set[str]]:
|
|
"""Every tool's tags as literally written in the @mcp.tool(...) call.
|
|
|
|
An independent derivation of the input to _tool_groups(): if @cad_tool (or
|
|
anything else) rewrote a registered tool's tags, the live grouping would
|
|
stop matching what server.py declares.
|
|
"""
|
|
tree = ast.parse(Path(server.__file__).read_text(encoding="utf-8"))
|
|
declared: dict[str, set[str]] = {}
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)):
|
|
continue
|
|
for decorator in node.decorator_list:
|
|
if not isinstance(decorator, ast.Call):
|
|
continue
|
|
func = decorator.func
|
|
if not (
|
|
isinstance(func, ast.Attribute)
|
|
and func.attr == "tool"
|
|
and isinstance(func.value, ast.Name)
|
|
and func.value.id == "mcp"
|
|
):
|
|
continue
|
|
name = node.name
|
|
tags: set[str] = set()
|
|
for keyword in decorator.keywords:
|
|
if keyword.arg == "name" and isinstance(keyword.value, ast.Constant):
|
|
name = keyword.value.value
|
|
if keyword.arg == "tags" and isinstance(keyword.value, ast.Set):
|
|
tags = {
|
|
element.value
|
|
for element in keyword.value.elts
|
|
if isinstance(element, ast.Constant)
|
|
}
|
|
declared[name] = tags
|
|
return declared
|
|
|
|
|
|
async def test_registered_tags_are_exactly_the_ones_written_in_source():
|
|
declared = _tags_declared_in_source()
|
|
live = {tool.name: set(tool.tags) for tool in await server._registered_tools()}
|
|
assert live == declared
|
|
|
|
|
|
async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
|
|
"""_tool_groups() must be reproducible from server.py's own `tags={...}`."""
|
|
expected: dict[str, list[str]] = {}
|
|
for name, tags in _tags_declared_in_source().items():
|
|
label = "other"
|
|
for tag in server._GROUP_TAG_PRIORITY:
|
|
if tag in tags:
|
|
label = server._GROUP_TAG_LABELS[tag]
|
|
break
|
|
expected.setdefault(label, []).append(name)
|
|
for names in expected.values():
|
|
names.sort()
|
|
|
|
assert await server._tool_groups() == dict(sorted(expected.items()))
|
|
|
|
|
|
async def test_tool_group_sizes_are_unchanged():
|
|
"""Frozen snapshot of the surface (232 tools, 25 groups).
|
|
|
|
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
|
|
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
|
|
`layouts` moved 4 -> 12 with M6's layout/viewport lifecycle. That group
|
|
gained `entity_change_space` too: it is tagged `layout` *and* `modify`, and
|
|
`_GROUP_TAG_PRIORITY` ranks `layout` higher, so CHSPACE files under layouts
|
|
rather than splitting a ninth tool off into entity_modification. `blocks`
|
|
moved 8 -> 9 when v1.6's `block_define` (typed primitives + ATTDEFs on
|
|
both engines) joined SECTION 7. `entity_query` moved 8 -> 9 and
|
|
`entity_modification` 15 -> 16 when v1.6's `entity_get_xdata` /
|
|
`entity_set_xdata` joined SECTION 5 (the getter is tagged `query`, the
|
|
setter `modify`, so the pair splits across two groups). `pid` appeared
|
|
(0 -> 2) when v1.6's SECTION 17 opened with `pid_symbol_list` /
|
|
`pid_symbol_insert`, then 2 -> 3 when `pid_line_draw` joined, 3 -> 4
|
|
when the reader `pid_graph` joined and 4 -> 7 when the three graph
|
|
deliverables (`pid_instrument_index` / `pid_line_list` /
|
|
`pid_equipment_list`) joined, 7 -> 8 when `pid_from_spec` (a whole
|
|
sheet from one declarative spec, in one transaction) joined and 8 -> 9
|
|
when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool)
|
|
joined; the `pid`
|
|
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
|
|
`pid` rather than under their secondary `query` / `create` tags.
|
|
Three groups appeared with v1.6's track E — `styles` (10, SECTION 18),
|
|
`page_setup` (6, SECTION 19) and `environment` (22, SECTION 20) — each
|
|
tagged with its section name, ranked directly after `pid` in
|
|
`_GROUP_TAG_PRIORITY` so a style tool that also carries `dimension` or an
|
|
environment tool that also carries `layer` files under its section; the
|
|
`environment` group is exactly `PACK_TOOL_NAMES["settings"]`. (Until the
|
|
merge task tagged them, the four groups had filed those 28 tools under
|
|
`layouts` / `templates` / `drawing` / `layers` / `view` / `system` by
|
|
their secondary tags; those counts returned to their pre-track-E values
|
|
when the section tags landed.)
|
|
`engineering` moved 10 -> 12 when v1.6's SECTION 24 opened with
|
|
`sheet_frame` and `titleblock_apply` (the ISO 5457 frame and the ISO 7200
|
|
title block for every size A4-A0). Both are tagged `engineering` and
|
|
`sheet`, and `sheet` is not in `_GROUP_TAG_PRIORITY`, so they file under
|
|
`engineering` beside `titleblock_apply_iso_a3` rather than opening a
|
|
group of their own.
|
|
It moved 12 -> 16 when `revision_add`, `bom_extract`, `bom_table` and
|
|
`balloon_add` joined the same section; `bom_extract` also carries `query`,
|
|
but `engineering` outranks it in `_GROUP_TAG_PRIORITY`.
|
|
The section's last five tools file elsewhere on purpose: they are
|
|
whole-drawing and entity operations, not sheet furniture, and
|
|
`_GROUP_TAG_PRIORITY` files them where a caller would look.
|
|
`xref_attach` and `xref_manage` carry `drawing` *and* `block`, and `block`
|
|
outranks `drawing`, so they file under `blocks` (9 -> 11) beside
|
|
`block_insert` -- which is where an xref belongs: it is a block definition
|
|
that lives in another file. `image_attach` carries `create`
|
|
(entity_creation 18 -> 19), `data_extract` carries `engineering`
|
|
(16 -> 17) and `drawing_export_dwg` carries `drawing` alone (11 -> 12).
|
|
Every other number here has been unchanged
|
|
since the snapshot was taken.
|
|
|
|
`mech` appeared when v1.6's tracks B+G landed. SECTION 21 opened it with
|
|
the part model and its view engine (`mech_part_draw` / `mech_view_add` /
|
|
`mech_dimension_part` / `mech_hole_pattern` / `mech_part_from_spec` /
|
|
`mech_part_inspect`); the `mech` tag is ranked directly after `pid` in
|
|
`_GROUP_TAG_PRIORITY`, so those tools file under `mech` rather than under
|
|
their secondary `create` / `dimension` / `query` tags.
|
|
|
|
SECTION 22 added the standard-parts catalogue (`std_part_list` /
|
|
`std_part_insert` / `std_feature_draw`) to the same group, filed under
|
|
`mech` rather than under their secondary `query` / `create` tags.
|
|
|
|
SECTION 23 added the mechanical annotation symbols (`surface_texture` /
|
|
`weld_symbol` / `centre_marks` / `section_line` / `hatch_material`). They
|
|
are tagged `engineering` and `mech`; with `mech` ranked ahead of
|
|
`engineering` in `_GROUP_TAG_PRIORITY` they file under `mech` (14 in all,
|
|
exactly `PACK_TOOL_NAMES["mech"]`) and leave `engineering` untouched.
|
|
|
|
Two groups appeared with v1.6's tracks B and G — `mechanical` (14,
|
|
SECTIONS 21-23: the part drawer, the standard-parts catalogue and the ISO
|
|
annotation symbols) and `sheet` (11, SECTION 24: frames, title blocks,
|
|
revisions, the parts list, balloons, xrefs, images and DWG). Both tags are
|
|
ranked directly after `pid` in `_GROUP_TAG_PRIORITY`, so a part tool that
|
|
also carries `create` and an annotation tool that also carries `dimension`
|
|
file under their own section. `mechanical` is exactly
|
|
`PACK_TOOL_NAMES["mech"]` (the `mech` label above was renamed when the
|
|
integration task gave the section its full name); `sheet` is core-pack,
|
|
because a frame, a title block, a parts list and an xref are universal
|
|
drafting. The integration also tagged SECTION 24's last four tools
|
|
(`xref_attach`, `xref_manage`, `image_attach`, `drawing_export_dwg`) with
|
|
`sheet`, so pack, group and SECTION are one list again: `engineering`
|
|
returned 17 -> 10, `blocks` 11 -> 9, `entity_creation` 19 -> 18 and
|
|
`drawing` 12 -> 11 — every one of them its pre-SECTION-24 value.
|
|
|
|
Track F group R opened SECTION 25 with the rooms: `arch_room` and
|
|
`arch_schedule` are tagged `arch` and `create`, `arch_rooms_detect` `arch`
|
|
and `query`. `arch` is not in `_GROUP_TAG_PRIORITY` on this branch, so they
|
|
file under their secondary tags: `entity_creation` 18 -> 20,
|
|
`entity_query` 9 -> 10. This snapshot is branch-local: the track F merge
|
|
task recomputes it once the walls, rooms and catalogue branches have landed.
|
|
"""
|
|
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
|
assert sizes == {
|
|
"analysis": 12,
|
|
"batch": 3,
|
|
"blocks": 9,
|
|
"corner_ops": 4,
|
|
"dimensions": 5,
|
|
"drawing": 11,
|
|
"engineering": 10,
|
|
"entity_creation": 20,
|
|
"entity_modification": 16,
|
|
"entity_query": 10,
|
|
"environment": 22,
|
|
"layers": 14,
|
|
"layouts": 12,
|
|
"mechanical": 14,
|
|
"page_setup": 6,
|
|
"pid": 9,
|
|
"premium": 12,
|
|
"sheet": 11,
|
|
"solids": 5,
|
|
"styles": 10,
|
|
"system": 7,
|
|
"templates": 2,
|
|
"transactions": 3,
|
|
"validation": 1,
|
|
"view": 4,
|
|
}
|
|
assert sum(sizes.values()) == 232
|