feat(pagesetup): SECTION 19 — page_setup_list/apply, plot_style_list, batch_plot with /MediaBox read-back
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
54bbf72312
commit
f25421709f
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **v1.5 release snapshot:** 166 tools · 8 resources · 5 prompt templates · 2423 collected tests.
|
> **v1.5 release snapshot:** 170 tools · 8 resources · 5 prompt templates · 2423 collected tests.
|
||||||
> 166 is the **registered** count; a default install advertises 161 over `tools/list`,
|
> 166 is the **registered** count; a default install advertises 161 over `tools/list`,
|
||||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
|
|||||||
| P&ID | catalogue blocks with ports and tags (ISO 10628-2 / ISA-5.1), port-to-port lines with ISA-5.1 classes and line numbers, `pid_graph` reads any P&ID back with confidence, instrument index / line list / equipment list, `pid_from_spec` one-call sheets |
|
| P&ID | catalogue blocks with ports and tags (ISO 10628-2 / ISA-5.1), port-to-port lines with ISA-5.1 classes and line numbers, `pid_graph` reads any P&ID back with confidence, instrument index / line list / equipment list, `pid_from_spec` one-call sheets |
|
||||||
| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 161-tool advertised catalog |
|
| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 161-tool advertised catalog |
|
||||||
| Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools |
|
| Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools |
|
||||||
| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=…)` |
|
| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `page_setup_apply` (ISO 216 / ANSI Y14.1 paper, ctb, scale, device — on both engines), `batch_plot` with every sheet size read back from its PDF's `/MediaBox`, `drawing_export_pdf(layout=…)` |
|
||||||
| Selection | window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box |
|
| Selection | window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box |
|
||||||
| Boundaries | `boundary_trace` (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges *(headless)* |
|
| Boundaries | `boundary_trace` (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges *(headless)* |
|
||||||
| Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle |
|
| Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle |
|
||||||
@@ -113,7 +113,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
|
|||||||
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
|
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
|
||||||
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
|
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
|
||||||
|
|
||||||
<sub>166 tools in 20 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools from a client that never draws a P&ID. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
<sub>170 tools in 20 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools from a client that never draws a P&ID. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||||
|
|
||||||
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
|
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
|
||||||
|
|
||||||
|
|||||||
+35
-2
@@ -2265,10 +2265,43 @@ class ComBackend(AutoCADBackend):
|
|||||||
return failed
|
return failed
|
||||||
|
|
||||||
async def plot_style_list(self) -> list[dict]:
|
async def plot_style_list(self) -> list[dict]:
|
||||||
# Completed in Task 18 (installed ctb scan); the catalogue rows already answer.
|
"""The ctb catalogue, marked against the files in AutoCAD's plot style path.
|
||||||
|
|
||||||
|
``Preferences.Files.PrintStyleSheetPath`` is a ``;``-separated list of
|
||||||
|
folders. Every read is guarded: a folder that cannot be listed is
|
||||||
|
skipped, and when the Preferences object is unreachable the catalogue
|
||||||
|
rows come back with ``installed: None`` rather than a fabricated False.
|
||||||
|
"""
|
||||||
from engineering.standards.papers import CTB_CATALOG
|
from engineering.standards.papers import CTB_CATALOG
|
||||||
|
|
||||||
return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]
|
def _sync():
|
||||||
|
rows = [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]
|
||||||
|
try:
|
||||||
|
raw = str(_acad_app().Preferences.Files.PrintStyleSheetPath or "")
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("Preferences.Files.PrintStyleSheetPath unreadable: %s", exc)
|
||||||
|
return rows
|
||||||
|
installed: dict[str, str] = {}
|
||||||
|
for folder in (part.strip() for part in raw.split(";")):
|
||||||
|
if not folder:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entries = sorted(Path(folder).iterdir())
|
||||||
|
except OSError as exc:
|
||||||
|
log.debug("plot style folder %r not listable: %s", folder, exc)
|
||||||
|
continue
|
||||||
|
for entry in entries:
|
||||||
|
if entry.suffix.lower() in (".ctb", ".stb"):
|
||||||
|
installed.setdefault(entry.name.lower(), entry.name)
|
||||||
|
catalog_lower = {row["name"].lower() for row in rows}
|
||||||
|
for row in rows:
|
||||||
|
row["installed"] = row["name"].lower() in installed
|
||||||
|
for key, name in installed.items():
|
||||||
|
if key not in catalog_lower:
|
||||||
|
rows.append({"name": name, "source": "installed", "installed": True})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
return await self._run(_sync)
|
||||||
|
|
||||||
async def drawing_template_save(self, path, name=None, description=None) -> dict:
|
async def drawing_template_save(self, path, name=None, description=None) -> dict:
|
||||||
# Completed in Task 20; declared here so the ABC instantiates.
|
# Completed in Task 20; declared here so the ABC instantiates.
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ class ToolAliases:
|
|||||||
# PLINE -- a raw polyline (entity_create_polyline) or a P&ID line run
|
# PLINE -- a raw polyline (entity_create_polyline) or a P&ID line run
|
||||||
# between two ports (pid_line_draw), which is an LWPOLYLINE with a
|
# between two ports (pid_line_draw), which is an LWPOLYLINE with a
|
||||||
# class, a number and markers; a drafter typing it could mean either.
|
# class, a number and markers; a drafter typing it could mean either.
|
||||||
|
# PAGESETUP -- the Page Setup Manager both shows and edits a sheet's setup
|
||||||
|
# (page_setup_list vs page_setup_apply).
|
||||||
|
# PLOT -- one sheet to PDF (drawing_export_pdf) or every sheet in one go
|
||||||
|
# (batch_plot); a drafter typing it could mean either.
|
||||||
#
|
#
|
||||||
# Every other AutoCAD command must map to exactly one tool.
|
# Every other AutoCAD command must map to exactly one tool.
|
||||||
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
||||||
@@ -83,7 +87,9 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
|||||||
"LAYER",
|
"LAYER",
|
||||||
"LAYOUT",
|
"LAYOUT",
|
||||||
"MEASUREGEOM",
|
"MEASUREGEOM",
|
||||||
|
"PAGESETUP",
|
||||||
"PLINE",
|
"PLINE",
|
||||||
|
"PLOT",
|
||||||
"QSELECT",
|
"QSELECT",
|
||||||
"SETVAR",
|
"SETVAR",
|
||||||
"ZOOM",
|
"ZOOM",
|
||||||
@@ -1427,6 +1433,50 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"loop number",
|
"loop number",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
# ── Page setup & templates (track E) ────────────────────────────────────
|
||||||
|
"batch_plot": ToolAliases(
|
||||||
|
acad=("PUBLISH", "PLOT"),
|
||||||
|
synonyms=(
|
||||||
|
"print all sheets to pdf",
|
||||||
|
"plot every layout",
|
||||||
|
"publish the sheet set",
|
||||||
|
"batch print",
|
||||||
|
"one pdf per sheet",
|
||||||
|
"plot the whole drawing set",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"page_setup_apply": ToolAliases(
|
||||||
|
acad=("PAGESETUP",),
|
||||||
|
synonyms=(
|
||||||
|
"page setup",
|
||||||
|
"set the paper size",
|
||||||
|
"a3 landscape",
|
||||||
|
"plot scale",
|
||||||
|
"monochrome ctb",
|
||||||
|
"which printer",
|
||||||
|
"set up the sheet for printing",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"page_setup_list": ToolAliases(
|
||||||
|
acad=("PAGESETUP",),
|
||||||
|
synonyms=(
|
||||||
|
"what paper is this sheet",
|
||||||
|
"page setup of each layout",
|
||||||
|
"plot settings",
|
||||||
|
"sheet size and orientation",
|
||||||
|
"which ctb is set",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"plot_style_list": ToolAliases(
|
||||||
|
acad=("STYLESMANAGER",),
|
||||||
|
synonyms=(
|
||||||
|
"ctb files",
|
||||||
|
"plot style table",
|
||||||
|
"pen assignments",
|
||||||
|
"monochrome or grayscale",
|
||||||
|
"which plot styles are installed",
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"package_version": "1.5.1",
|
"package_version": "1.5.1",
|
||||||
"totals": {
|
"totals": {
|
||||||
"tools": 166,
|
"tools": 170,
|
||||||
"resources": 8,
|
"resources": 8,
|
||||||
"prompts": 5,
|
"prompts": 5,
|
||||||
"groups": 20
|
"groups": 20
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"entity_modification": 16,
|
"entity_modification": 16,
|
||||||
"entity_query": 9,
|
"entity_query": 9,
|
||||||
"layers": 14,
|
"layers": 14,
|
||||||
"layouts": 12,
|
"layouts": 16,
|
||||||
"pid": 9,
|
"pid": 9,
|
||||||
"premium": 12,
|
"premium": 12,
|
||||||
"solids": 5,
|
"solids": 5,
|
||||||
@@ -175,6 +175,21 @@
|
|||||||
"readOnlyHint": true
|
"readOnlyHint": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "batch_plot",
|
||||||
|
"group": "layouts",
|
||||||
|
"cost": "mutate",
|
||||||
|
"summary": "Plot every sheet (or the named ones) to PDF; each sheet size is read back from its PDF.",
|
||||||
|
"tags": [
|
||||||
|
"export",
|
||||||
|
"layout",
|
||||||
|
"plot"
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"title": "Batch Plot",
|
||||||
|
"destructiveHint": false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "block_create_from_entities",
|
"name": "block_create_from_entities",
|
||||||
"group": "blocks",
|
"group": "blocks",
|
||||||
@@ -1717,6 +1732,35 @@
|
|||||||
"readOnlyHint": false
|
"readOnlyHint": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "page_setup_apply",
|
||||||
|
"group": "layouts",
|
||||||
|
"cost": "safe",
|
||||||
|
"summary": "Set a sheet's paper, orientation, ctb, plot scale and device (PAGESETUP).",
|
||||||
|
"tags": [
|
||||||
|
"layout",
|
||||||
|
"plot"
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"title": "Apply Page Setup",
|
||||||
|
"destructiveHint": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page_setup_list",
|
||||||
|
"group": "layouts",
|
||||||
|
"cost": "read",
|
||||||
|
"summary": "Read each sheet's page setup: paper, orientation, ctb, scale, device.",
|
||||||
|
"tags": [
|
||||||
|
"layout",
|
||||||
|
"plot",
|
||||||
|
"query"
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"title": "List Page Setups",
|
||||||
|
"readOnlyHint": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "pid_equipment_list",
|
"name": "pid_equipment_list",
|
||||||
"group": "pid",
|
"group": "pid",
|
||||||
@@ -1844,6 +1888,21 @@
|
|||||||
"readOnlyHint": true
|
"readOnlyHint": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "plot_style_list",
|
||||||
|
"group": "layouts",
|
||||||
|
"cost": "read",
|
||||||
|
"summary": "List the plot style tables (ctb): the AutoCAD catalogue, plus what is installed.",
|
||||||
|
"tags": [
|
||||||
|
"layout",
|
||||||
|
"plot",
|
||||||
|
"query"
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"title": "List Plot Styles",
|
||||||
|
"readOnlyHint": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "point_from_snap",
|
"name": "point_from_snap",
|
||||||
"group": "premium",
|
"group": "premium",
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""batch_plot: every sheet through drawing_export_pdf, each PDF measured back.
|
||||||
|
|
||||||
|
Composes the existing per-layout export; the only thing it adds is the
|
||||||
|
evidence — ``mediabox_mm`` is parsed from the file each sheet produced, so a
|
||||||
|
caller sees the size that was plotted, not the size that was asked for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from engineering.standards.papers import paper_from_size
|
||||||
|
from engineering.standards.pdfinfo import read_mediabox
|
||||||
|
|
||||||
|
__all__ = ["DEFAULT_FILE_PATTERN", "batch_plot", "safe_filename"]
|
||||||
|
|
||||||
|
DEFAULT_FILE_PATTERN = "{drawing}-{layout}.pdf"
|
||||||
|
|
||||||
|
_UNSAFE = re.compile(r"[<>:\"/\\|?*\s]+")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_filename(text: str) -> str:
|
||||||
|
"""A layout or drawing name as a filename fragment: no separators, no
|
||||||
|
reserved characters, runs of them collapsed to one underscore."""
|
||||||
|
cleaned = _UNSAFE.sub("_", str(text or "").strip()).strip("_")
|
||||||
|
return cleaned or "untitled"
|
||||||
|
|
||||||
|
|
||||||
|
async def batch_plot(
|
||||||
|
backend,
|
||||||
|
layouts: list[str] | None,
|
||||||
|
output_dir: str,
|
||||||
|
file_pattern: str = DEFAULT_FILE_PATTERN,
|
||||||
|
) -> dict:
|
||||||
|
"""Plot layouts to PDF and read each sheet size back from the file.
|
||||||
|
|
||||||
|
``layouts=None`` plots every paper-space layout; ``Model`` is plotted only
|
||||||
|
when named. Refuses (``ValueError``, before any PDF is written): an empty
|
||||||
|
list, a layout that does not exist, a pattern with a path separator, and
|
||||||
|
a pattern without ``{layout}`` when more than one sheet would be written.
|
||||||
|
The output folder is created, then every output path goes through
|
||||||
|
``security.validate_path(allow_write=True)`` before the first export.
|
||||||
|
"""
|
||||||
|
from security import validate_path
|
||||||
|
|
||||||
|
listing = await backend.layout_list()
|
||||||
|
available = [str(name) for name in (listing.get("layouts") or [])]
|
||||||
|
lowered = {name.lower(): name for name in available}
|
||||||
|
if layouts is None:
|
||||||
|
targets = [name for name in available if name.lower() != "model"]
|
||||||
|
if not targets:
|
||||||
|
raise ValueError("batch_plot: the drawing has no paper-space layout to plot")
|
||||||
|
else:
|
||||||
|
requested = [str(name).strip() for name in layouts]
|
||||||
|
if not requested:
|
||||||
|
raise ValueError("batch_plot: layouts is empty — pass null for every sheet")
|
||||||
|
unknown = [name for name in requested if name.lower() not in lowered]
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"batch_plot: layout(s) not found: {', '.join(unknown)} "
|
||||||
|
f"(available: {', '.join(available)})"
|
||||||
|
)
|
||||||
|
targets = [lowered[name.lower()] for name in requested]
|
||||||
|
|
||||||
|
pattern = str(file_pattern or DEFAULT_FILE_PATTERN)
|
||||||
|
if "/" in pattern or "\\" in pattern:
|
||||||
|
raise ValueError("batch_plot: file_pattern must be a file name, not a path")
|
||||||
|
if "{layout}" not in pattern and len(targets) > 1:
|
||||||
|
raise ValueError(
|
||||||
|
"batch_plot: file_pattern needs {layout} when more than one sheet is plotted, "
|
||||||
|
"or every sheet would overwrite the last"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
pattern.format(drawing="x", layout="y")
|
||||||
|
except (KeyError, IndexError, ValueError) as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"batch_plot: file_pattern {pattern!r} may only use {{drawing}} and {{layout}}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
info = await backend.drawing_info()
|
||||||
|
drawing = safe_filename(Path(info.name).stem)
|
||||||
|
destination = Path(validate_path(output_dir, allow_write=True))
|
||||||
|
planned = [
|
||||||
|
(name, destination / pattern.format(drawing=drawing, layout=safe_filename(name)))
|
||||||
|
for name in targets
|
||||||
|
]
|
||||||
|
# validate_path(allow_write=True) requires the parent folder to exist, so
|
||||||
|
# the output folder is created before the per-file fence check; an empty
|
||||||
|
# folder is the most a refused call leaves behind.
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
for _name, path in planned:
|
||||||
|
validate_path(str(path), allow_write=True)
|
||||||
|
|
||||||
|
sheets: list[dict] = []
|
||||||
|
for name, path in planned:
|
||||||
|
is_model = name.lower() == "model"
|
||||||
|
result = await backend.drawing_export_pdf(str(path), layout=None if is_model else name)
|
||||||
|
if not result.get("ok", False):
|
||||||
|
sheets.append(
|
||||||
|
{"layout": name, "ok": False, "path": str(path), "error": result.get("error")}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
row = {"layout": name, "ok": True, "path": str(path), "bytes": path.stat().st_size}
|
||||||
|
try:
|
||||||
|
width, height = read_mediabox(str(path))
|
||||||
|
row["mediabox_mm"] = [width, height]
|
||||||
|
row["paper"] = None if is_model else paper_from_size(width, height)
|
||||||
|
except ValueError as exc:
|
||||||
|
row["mediabox_mm"] = None
|
||||||
|
row["paper"] = None
|
||||||
|
row["error"] = str(exc)
|
||||||
|
sheets.append(row)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": all(row["ok"] for row in sheets),
|
||||||
|
"drawing": drawing,
|
||||||
|
"output_dir": str(destination),
|
||||||
|
"format": "pdf",
|
||||||
|
"sheets": sheets,
|
||||||
|
"count": len(sheets),
|
||||||
|
}
|
||||||
@@ -6815,6 +6815,165 @@ async def pid_tag_parse(
|
|||||||
return parse_tag(tag, kind, equipment_prefixes)
|
return parse_tag(tag, kind, equipment_prefixes)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ── SECTION 19: Page Setup & Templates (4 tools) ────────────────────────────
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@cad_tool(
|
||||||
|
summary="Read each sheet's page setup: paper, orientation, ctb, scale, device.",
|
||||||
|
cost="read",
|
||||||
|
)
|
||||||
|
@mcp.tool(
|
||||||
|
annotations={"title": "List Page Setups", "readOnlyHint": True},
|
||||||
|
tags={"layout", "plot", "query"},
|
||||||
|
)
|
||||||
|
async def page_setup_list(
|
||||||
|
layout: Annotated[
|
||||||
|
str | None, "One paper-space layout, or omit for every sheet (Model is never listed)."
|
||||||
|
] = None,
|
||||||
|
ctx: Context = None,
|
||||||
|
) -> dict:
|
||||||
|
"""The page setup stored on each paper-space layout.
|
||||||
|
|
||||||
|
`paper` is the catalogue name (ISO_A3, ANSI_B, …) when the stored size
|
||||||
|
matches one within 0.5 mm, otherwise the media name as stored; `size_mm`,
|
||||||
|
`orientation`, `plot_style` (ctb), `scale` ("fit", "1:50", or a custom
|
||||||
|
ratio), `plot_area`, `device`, `margins_mm` [top, bottom, left, right] and
|
||||||
|
`center` are read back from the LAYOUT object (headless) or the AutoCAD
|
||||||
|
Layout (live). Refuses an unknown layout name and `Model` (model space has
|
||||||
|
no sheet; plot it with `drawing_export_pdf(layout=None)`).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rows = await _backend(ctx).page_setup_list(layout)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ToolError(str(exc)) from exc
|
||||||
|
return {"ok": True, "layouts": rows, "count": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
|
@cad_tool(
|
||||||
|
summary="Set a sheet's paper, orientation, ctb, plot scale and device (PAGESETUP).",
|
||||||
|
cost="safe",
|
||||||
|
)
|
||||||
|
@mcp.tool(
|
||||||
|
annotations={"title": "Apply Page Setup", "destructiveHint": False},
|
||||||
|
tags={"layout", "plot"},
|
||||||
|
)
|
||||||
|
async def page_setup_apply(
|
||||||
|
layout: Annotated[str, "Paper-space layout to set up (never 'Model')."],
|
||||||
|
paper: Annotated[str, "ISO_A0…ISO_A4 or ANSI_A…ANSI_E (short forms A3, ansi_b accepted)."],
|
||||||
|
orientation: Annotated[str, "landscape | portrait"] = "landscape",
|
||||||
|
plot_style: Annotated[
|
||||||
|
str, "Plot style table, e.g. monochrome.ctb, acad.ctb, Grayscale.ctb (plot_style_list)."
|
||||||
|
] = "monochrome.ctb",
|
||||||
|
scale: Annotated[
|
||||||
|
str, "fit | 1:1 | 1:2 | 1:5 | 1:10 | 1:20 | 1:50 | 1:100 | 2:1 | 5:1 | 10:1"
|
||||||
|
] = "fit",
|
||||||
|
plot_area: Annotated[str, "layout | extents"] = "layout",
|
||||||
|
device: Annotated[str, "Plotter configuration (.pc3) or printer name."] = "DWG To PDF.pc3",
|
||||||
|
margins_mm: Annotated[
|
||||||
|
list[float] | None,
|
||||||
|
"[top, bottom, left, right] in mm. Headless only: AutoCAD takes margins from the .pc3.",
|
||||||
|
] = None,
|
||||||
|
center: Annotated[bool, "Centre the plot on the paper."] = True,
|
||||||
|
ctx: Context = None,
|
||||||
|
) -> dict:
|
||||||
|
"""AutoCAD's PAGESETUP as one call, on both engines.
|
||||||
|
|
||||||
|
Paper sizes are ISO 216 / ANSI Y14.1; the media is written in AutoCAD's own
|
||||||
|
spelling (`ISO_A3_(420.00_x_297.00_MM)`). `changed` lists only the values
|
||||||
|
that moved — re-applying the same setup reports `{}`. An unknown ctb is
|
||||||
|
written and reported `plot_style_known: false` (a missing ctb only matters
|
||||||
|
at plot time). Viewports on the sheet are never touched.
|
||||||
|
|
||||||
|
Refused before anything is written: an unknown paper, orientation, scale
|
||||||
|
or plot_area, an empty device or plot_style, margins that leave no
|
||||||
|
printable area, `Model`, an unknown layout. On the live engine `margins_mm`
|
||||||
|
is refused (the .pc3 owns them) and a media the device does not offer is
|
||||||
|
refused with the device's names for the same paper.
|
||||||
|
|
||||||
|
The proof is the PDF: `batch_plot` reads each sheet's `/MediaBox` back.
|
||||||
|
"""
|
||||||
|
from engineering.standards.papers import resolve_page_setup
|
||||||
|
|
||||||
|
try:
|
||||||
|
setup = resolve_page_setup(
|
||||||
|
paper, orientation, plot_style, scale, plot_area, device, margins_mm, center
|
||||||
|
)
|
||||||
|
await ctx.info(f"Applying page setup {setup['paper']} {orientation} to {layout}")
|
||||||
|
return await _backend(ctx).page_setup_apply(layout, setup)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ToolError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@cad_tool(
|
||||||
|
summary="List the plot style tables (ctb): the AutoCAD catalogue, plus what is installed.",
|
||||||
|
cost="read",
|
||||||
|
)
|
||||||
|
@mcp.tool(
|
||||||
|
annotations={"title": "List Plot Styles", "readOnlyHint": True},
|
||||||
|
tags={"layout", "plot", "query"},
|
||||||
|
)
|
||||||
|
async def plot_style_list(ctx: Context = None) -> dict:
|
||||||
|
"""The ctb files AutoCAD ships (`monochrome.ctb`, `acad.ctb`, `Grayscale.ctb`,
|
||||||
|
the Screening set, …) and, on the live engine, the files actually present
|
||||||
|
in `Preferences.Files.PrintStyleSheetPath` (`source: "installed"`,
|
||||||
|
`installed: true/false` per row). Headlessly `installed` is `null`: there
|
||||||
|
is no installation to scan, and the catalogue is not evidence of one.
|
||||||
|
Never refuses; an unreadable Preferences object degrades to the catalogue.
|
||||||
|
"""
|
||||||
|
backend = _backend(ctx)
|
||||||
|
rows = await backend.plot_style_list()
|
||||||
|
return {"ok": True, "styles": rows, "count": len(rows), "backend": backend.name}
|
||||||
|
|
||||||
|
|
||||||
|
@cad_tool(
|
||||||
|
summary="Plot every sheet (or the named ones) to PDF; each sheet size is read back from its PDF.",
|
||||||
|
cost="mutate",
|
||||||
|
)
|
||||||
|
@mcp.tool(
|
||||||
|
annotations={"title": "Batch Plot", "destructiveHint": False},
|
||||||
|
tags={"layout", "plot", "export"},
|
||||||
|
)
|
||||||
|
async def batch_plot(
|
||||||
|
output_dir: Annotated[str, "Folder for the PDFs (created if missing)."],
|
||||||
|
layouts: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
"Layouts to plot; omit for every paper-space layout. 'Model' only when named.",
|
||||||
|
] = None,
|
||||||
|
format: Annotated[str, "pdf (the only format this release plots)."] = "pdf",
|
||||||
|
file_pattern: Annotated[
|
||||||
|
str, "File name per sheet; {drawing} and {layout} are substituted."
|
||||||
|
] = "{drawing}-{layout}.pdf",
|
||||||
|
ctx: Context = None,
|
||||||
|
) -> dict:
|
||||||
|
"""AutoCAD's PUBLISH for a PDF set, through `drawing_export_pdf(layout=…)`.
|
||||||
|
|
||||||
|
One row per sheet: `path`, `bytes`, and `mediabox_mm` **parsed from the
|
||||||
|
PDF that was written** (`/MediaBox`, points → mm), with `paper` naming the
|
||||||
|
catalogue size it matches within 0.5 mm — so the sheet size is verified,
|
||||||
|
never assumed. Model space is included only when named.
|
||||||
|
|
||||||
|
Refused before any file is written: a format other than pdf, an empty
|
||||||
|
`layouts` list, a layout that does not exist, a `file_pattern` containing a
|
||||||
|
path separator or lacking `{layout}` when more than one sheet is plotted,
|
||||||
|
and any output path outside the allowed directories. A sheet whose PDF has
|
||||||
|
no readable MediaBox is reported with `mediabox_mm: null` and an `error`.
|
||||||
|
"""
|
||||||
|
from engineering.standards.plot import batch_plot as _batch_plot
|
||||||
|
|
||||||
|
if str(format).lower() != "pdf":
|
||||||
|
raise ToolError(
|
||||||
|
f"batch_plot: format must be 'pdf' (got {format!r}); PDF is the only plot format"
|
||||||
|
)
|
||||||
|
destination = validate_path(output_dir, allow_write=True)
|
||||||
|
await ctx.info(f"Batch plotting {layouts or 'every sheet'} to {destination}")
|
||||||
|
try:
|
||||||
|
return await _batch_plot(_backend(ctx), layouts, str(destination), file_pattern)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ToolError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""batch_plot: every sheet to PDF, each sheet size read back from the PDF.
|
||||||
|
|
||||||
|
The composition is exercised on the headless engine end to end; the COM-only
|
||||||
|
piece of this task (the installed ctb scan) is fake-tested; the SECTION 19
|
||||||
|
tools are called directly with a minimal context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastmcp.exceptions import ToolError
|
||||||
|
|
||||||
|
import server
|
||||||
|
from engineering.standards.papers import CTB_CATALOG, resolve_page_setup
|
||||||
|
from engineering.standards.plot import batch_plot, safe_filename
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
|
||||||
|
class _Ctx:
|
||||||
|
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 _two_sheets(backend):
|
||||||
|
"""Exactly two paper layouts: the default tab renamed, plus one more."""
|
||||||
|
await backend.layout_rename("Layout1", "A3")
|
||||||
|
await backend.layout_create("B")
|
||||||
|
await backend.page_setup_apply("A3", resolve_page_setup("ISO_A3", "landscape"))
|
||||||
|
await backend.page_setup_apply("B", resolve_page_setup("ANSI_B", "landscape"))
|
||||||
|
await backend.entity_create_line(0, 0, 100, 50)
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_filename_strips_what_a_filesystem_refuses():
|
||||||
|
assert safe_filename("Sheet 1: A3/rev*B") == "Sheet_1_A3_rev_B"
|
||||||
|
assert safe_filename("Layout1") == "Layout1"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_every_paper_layout_is_plotted_and_measured(backend, tmp_path):
|
||||||
|
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
|
||||||
|
await _two_sheets(backend)
|
||||||
|
result = await batch_plot(backend, None, str(tmp_path / "out"))
|
||||||
|
assert result["ok"] is True and result["format"] == "pdf"
|
||||||
|
assert result["count"] == 2
|
||||||
|
by_layout = {row["layout"]: row for row in result["sheets"]}
|
||||||
|
assert set(by_layout) == {"A3", "B"}, "Model is not plotted unless named"
|
||||||
|
a3, b = by_layout["A3"], by_layout["B"]
|
||||||
|
assert a3["ok"] and a3["bytes"] > 0 and a3["path"].endswith("untitled-A3.pdf")
|
||||||
|
assert abs(a3["mediabox_mm"][0] - 420) <= 0.5 and abs(a3["mediabox_mm"][1] - 297) <= 0.5
|
||||||
|
assert a3["paper"] == "ISO_A3"
|
||||||
|
assert abs(b["mediabox_mm"][0] - 432) <= 0.5 and abs(b["mediabox_mm"][1] - 279) <= 0.5
|
||||||
|
assert b["paper"] == "ANSI_B"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_model_is_included_only_when_named(backend, tmp_path):
|
||||||
|
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
|
||||||
|
await _two_sheets(backend)
|
||||||
|
result = await batch_plot(backend, ["Model", "A3"], str(tmp_path))
|
||||||
|
names = [row["layout"] for row in result["sheets"]]
|
||||||
|
assert names == ["Model", "A3"]
|
||||||
|
model = result["sheets"][0]
|
||||||
|
assert model["ok"] and model["mediabox_mm"] is not None
|
||||||
|
assert model["paper"] is None, "model space has no sheet; the size is reported, not named"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refusals_write_nothing(backend, tmp_path):
|
||||||
|
await _two_sheets(backend)
|
||||||
|
out = tmp_path / "nothing"
|
||||||
|
with pytest.raises(ValueError, match="Nope"):
|
||||||
|
await batch_plot(backend, ["Nope"], str(out))
|
||||||
|
with pytest.raises(ValueError, match="layout"):
|
||||||
|
await batch_plot(backend, None, str(out), file_pattern="all.pdf")
|
||||||
|
with pytest.raises(ValueError, match="file_pattern"):
|
||||||
|
await batch_plot(backend, None, str(out), file_pattern="sub/{layout}.pdf")
|
||||||
|
with pytest.raises(ValueError, match="layouts"):
|
||||||
|
await batch_plot(backend, [], str(out))
|
||||||
|
assert not out.exists() or not any(out.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_fresh_drawing_plots_its_one_default_sheet_at_the_dxf_default_size(
|
||||||
|
backend, tmp_path
|
||||||
|
):
|
||||||
|
"""No page setup ever applied: the sheet still plots, at ezdxf's stored
|
||||||
|
default (A3, 420 x 297), and the row says so rather than guessing."""
|
||||||
|
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
|
||||||
|
result = await batch_plot(backend, None, str(tmp_path), file_pattern="{layout}.pdf")
|
||||||
|
assert [row["layout"] for row in result["sheets"]] == ["Layout1"]
|
||||||
|
row = result["sheets"][0]
|
||||||
|
assert row["path"].endswith("Layout1.pdf") and row["paper"] == "ISO_A3"
|
||||||
|
|
||||||
|
|
||||||
|
# ── the tools ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_page_setup_tools_round_trip(backend):
|
||||||
|
ctx = _Ctx(backend)
|
||||||
|
# A fresh drawing already carries Layout1; renaming it keeps exactly one
|
||||||
|
# sheet, so the unfiltered listing has one row and it is the A3.
|
||||||
|
await backend.layout_rename("Layout1", "A3")
|
||||||
|
applied = await server.page_setup_apply("A3", "ISO_A3", ctx=ctx)
|
||||||
|
assert applied["ok"] and applied["applied"]["paper"] == "ISO_A3"
|
||||||
|
listed = await server.page_setup_list(ctx=ctx)
|
||||||
|
assert listed["count"] == 1 and listed["layouts"][0]["paper"] == "ISO_A3"
|
||||||
|
only = await server.page_setup_list("A3", ctx=ctx)
|
||||||
|
assert only["layouts"][0]["layout"] == "A3"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_page_setup_apply_tool_refuses_before_writing(backend):
|
||||||
|
ctx = _Ctx(backend)
|
||||||
|
await backend.layout_create("A3")
|
||||||
|
with pytest.raises(ToolError, match="scale"):
|
||||||
|
await server.page_setup_apply("A3", "ISO_A3", scale="1:3", ctx=ctx)
|
||||||
|
with pytest.raises(ToolError, match="Model"):
|
||||||
|
await server.page_setup_apply("Model", "ISO_A3", ctx=ctx)
|
||||||
|
row = (await backend.page_setup_list("A3"))[0]
|
||||||
|
assert row["device"] == "", "nothing was written"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_plot_style_list_tool(backend):
|
||||||
|
result = await server.plot_style_list(ctx=_Ctx(backend))
|
||||||
|
assert result["ok"] and result["count"] == len(CTB_CATALOG)
|
||||||
|
assert result["backend"] == "ezdxf"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_batch_plot_tool_validates_the_output_directory(backend, tmp_path):
|
||||||
|
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
|
||||||
|
await _two_sheets(backend)
|
||||||
|
with pytest.raises(ToolError):
|
||||||
|
await server.batch_plot("../../outside", ctx=_Ctx(backend))
|
||||||
|
with pytest.raises(ToolError, match="format"):
|
||||||
|
await server.batch_plot(str(tmp_path), format="png", ctx=_Ctx(backend))
|
||||||
|
result = await server.batch_plot(str(tmp_path), layouts=["A3"], ctx=_Ctx(backend))
|
||||||
|
assert result["count"] == 1 and result["sheets"][0]["paper"] == "ISO_A3"
|
||||||
|
|
||||||
|
|
||||||
|
# ── COM: the installed ctb scan ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def com_backend(monkeypatch, tmp_path):
|
||||||
|
pytest.importorskip("win32com.client", reason="pywin32 not installed")
|
||||||
|
from backends import com_backend as module
|
||||||
|
|
||||||
|
styles = tmp_path / "Plot Styles"
|
||||||
|
styles.mkdir()
|
||||||
|
(styles / "monochrome.ctb").write_bytes(b"")
|
||||||
|
(styles / "Anka.ctb").write_bytes(b"")
|
||||||
|
(styles / "readme.txt").write_bytes(b"")
|
||||||
|
app = types.SimpleNamespace(
|
||||||
|
Preferences=types.SimpleNamespace(
|
||||||
|
Files=types.SimpleNamespace(PrintStyleSheetPath=f"{styles};C:\\does\\not\\exist")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(module, "_acad_app", lambda: app)
|
||||||
|
backend = module.ComBackend()
|
||||||
|
|
||||||
|
async def _run_inline(func, *args, **kwargs):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(backend, "_run", _run_inline)
|
||||||
|
return backend, app
|
||||||
|
|
||||||
|
|
||||||
|
async def test_com_plot_style_list_marks_installed_and_adds_local_files(com_backend):
|
||||||
|
backend, _ = com_backend
|
||||||
|
rows = await backend.plot_style_list()
|
||||||
|
by_name = {row["name"]: row for row in rows}
|
||||||
|
assert by_name["monochrome.ctb"] == {
|
||||||
|
"name": "monochrome.ctb",
|
||||||
|
"source": "catalog",
|
||||||
|
"installed": True,
|
||||||
|
}
|
||||||
|
assert by_name["acad.ctb"]["installed"] is False
|
||||||
|
assert by_name["Anka.ctb"] == {"name": "Anka.ctb", "source": "installed", "installed": True}
|
||||||
|
assert "readme.txt" not in by_name
|
||||||
|
|
||||||
|
|
||||||
|
async def test_com_plot_style_list_survives_an_unreachable_preferences_object(
|
||||||
|
com_backend, monkeypatch
|
||||||
|
):
|
||||||
|
backend, _ = com_backend
|
||||||
|
from backends import com_backend as module
|
||||||
|
|
||||||
|
def _boom():
|
||||||
|
raise RuntimeError("no application")
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "_acad_app", _boom)
|
||||||
|
rows = await backend.plot_style_list()
|
||||||
|
assert [row["name"] for row in rows] == list(CTB_CATALOG)
|
||||||
|
assert all(row["installed"] is None for row in rows)
|
||||||
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
|
|||||||
|
|
||||||
|
|
||||||
async def test_tool_group_sizes_are_unchanged():
|
async def test_tool_group_sizes_are_unchanged():
|
||||||
"""Frozen snapshot of the surface (161 tools, 20 groups).
|
"""Frozen snapshot of the surface (170 tools, 20 groups).
|
||||||
|
|
||||||
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
|
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
|
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
|
||||||
@@ -517,7 +517,11 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool)
|
when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool)
|
||||||
joined; the `pid`
|
joined; the `pid`
|
||||||
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
|
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
|
||||||
`pid` rather than under their secondary `query` / `create` tags. Every
|
`pid` rather than under their secondary `query` / `create` tags.
|
||||||
|
`layouts` moved 12 -> 16 when v1.6's SECTION 19 opened with
|
||||||
|
`page_setup_list` / `page_setup_apply` / `plot_style_list` / `batch_plot`
|
||||||
|
— all tagged `layout`, which `_GROUP_TAG_PRIORITY` ranks above
|
||||||
|
`query`/`export`, so they file with the sheets they set up. Every
|
||||||
other number here has been unchanged since the snapshot was taken.
|
other number here has been unchanged since the snapshot was taken.
|
||||||
"""
|
"""
|
||||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||||
@@ -533,7 +537,7 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"entity_modification": 16,
|
"entity_modification": 16,
|
||||||
"entity_query": 9,
|
"entity_query": 9,
|
||||||
"layers": 14,
|
"layers": 14,
|
||||||
"layouts": 12,
|
"layouts": 16,
|
||||||
"pid": 9,
|
"pid": 9,
|
||||||
"premium": 12,
|
"premium": 12,
|
||||||
"solids": 5,
|
"solids": 5,
|
||||||
@@ -543,4 +547,4 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"validation": 1,
|
"validation": 1,
|
||||||
"view": 4,
|
"view": 4,
|
||||||
}
|
}
|
||||||
assert sum(sizes.values()) == 166
|
assert sum(sizes.values()) == 170
|
||||||
|
|||||||
Reference in New Issue
Block a user