feat(schematic): hierarchical sheet insertion and subsheet scaffolding
Adds add_hierarchical_sheet (insert a sheet symbol referencing a child .kicad_sch, with sheet_instances + fixed component instance paths) and create_hierarchical_subsheet (create the child file + wire it into the parent in one call). Upstream has add_sheet_pin / add_schematic_hierarchical_label but no way to create a sheet or stand up a child sheet, so hierarchical designs can't be built through the MCP server today. - python/commands/schematic_hierarchy.py: SchematicHierarchyCommands - src/tools/schematic-hierarchy.ts + registry 'schematic_hierarchy' category - python/kicad_interface.py: import + instantiate + dispatch routes - tests/test_schematic_hierarchy.py: 5 unit/integration tests on real .kicad_sch Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
272
python/commands/schematic_hierarchy.py
Normal file
272
python/commands/schematic_hierarchy.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Schematic hierarchical-sheet commands.
|
||||
|
||||
Tools:
|
||||
- add_hierarchical_sheet: insert a hierarchical-sheet reference into a parent schematic
|
||||
- create_hierarchical_subsheet: create a sub-sheet file and link it in one call
|
||||
|
||||
The command class holds a back-reference to KiCADInterface so it can reuse the existing
|
||||
create_schematic handler, and exposes fix_subsheet_instances for the batch module to call.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import sexpdata
|
||||
from sexpdata import Symbol
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class SchematicHierarchyCommands:
|
||||
"""Handlers for hierarchical sheet insertion and subsheet scaffolding."""
|
||||
|
||||
def __init__(self, iface):
|
||||
self.iface = iface
|
||||
|
||||
def add_hierarchical_sheet(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Insert a hierarchical sheet reference block into a parent schematic."""
|
||||
logger.info("Adding hierarchical sheet")
|
||||
try:
|
||||
schematic_path = params.get("schematicPath")
|
||||
subsheet_path = params.get("subsheetPath")
|
||||
sheet_name = params.get("sheetName", "Sheet")
|
||||
position = params.get("position", {})
|
||||
size = params.get("size", {})
|
||||
|
||||
if not schematic_path or not subsheet_path:
|
||||
return {"success": False, "message": "schematicPath and subsheetPath are required"}
|
||||
|
||||
x = float(position.get("x", 50))
|
||||
y = float(position.get("y", 50))
|
||||
w = float(size.get("width", 80))
|
||||
h = float(size.get("height", 50))
|
||||
|
||||
parent_file = Path(schematic_path)
|
||||
try:
|
||||
rel_str = str(
|
||||
Path(subsheet_path).resolve().relative_to(parent_file.parent.resolve())
|
||||
).replace("\\", "/")
|
||||
except ValueError:
|
||||
rel_str = str(subsheet_path).replace("\\", "/")
|
||||
|
||||
sheet_block_uuid = str(uuid.uuid4())
|
||||
name_x, name_y = round(x + 2.54, 4), round(y - 1.27, 4)
|
||||
file_x, file_y = round(x + 2.54, 4), round(y + h + 1.27, 4)
|
||||
|
||||
sheet_block = (
|
||||
f" (sheet (at {x} {y}) (size {w} {h}) (fields_autoplaced yes)\n"
|
||||
f" (stroke (width 0.0006) (type default))\n"
|
||||
f" (fill (color 0 0 0 0.0000))\n"
|
||||
f' (uuid "{sheet_block_uuid}")\n'
|
||||
f' (property "Sheet name" "{sheet_name}" (at {name_x} {name_y} 0)\n'
|
||||
f" (effects (font (size 1.27 1.27)) (justify left bottom))\n"
|
||||
f" )\n"
|
||||
f' (property "Sheet file" "{rel_str}" (at {file_x} {file_y} 0)\n'
|
||||
f" (effects (font (size 1.27 1.27)) (justify left bottom))\n"
|
||||
f" )\n"
|
||||
f" )\n"
|
||||
)
|
||||
|
||||
content = parent_file.read_text(encoding="utf-8")
|
||||
parent_uuid_match = re.search(r"\(uuid\s+([0-9a-fA-F-]+)\)", content)
|
||||
parent_uuid = parent_uuid_match.group(1) if parent_uuid_match else ""
|
||||
existing_pages = re.findall(r'\(page\s+"(\d+)"\)', content)
|
||||
next_page = max((int(p) for p in existing_pages), default=0) + 1
|
||||
|
||||
instance_path = (
|
||||
f"/{parent_uuid}/{sheet_block_uuid}" if parent_uuid else f"/{sheet_block_uuid}"
|
||||
)
|
||||
path_entry = f' (path "{instance_path}" (page "{next_page}"))\n'
|
||||
|
||||
insert_at = content.rfind("(sheet_instances")
|
||||
if insert_at == -1:
|
||||
return {"success": False, "message": "Could not find (sheet_instances in schematic"}
|
||||
content = content[:insert_at] + sheet_block + " " + content[insert_at:]
|
||||
|
||||
si_start = content.rfind("(sheet_instances")
|
||||
depth = 0
|
||||
si_close = len(content) - 1
|
||||
for i in range(si_start, len(content)):
|
||||
if content[i] == "(":
|
||||
depth += 1
|
||||
elif content[i] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
si_close = i
|
||||
break
|
||||
content = content[:si_close] + path_entry + " " + content[si_close:]
|
||||
|
||||
parent_file.write_text(content, encoding="utf-8")
|
||||
|
||||
# Ensure each sub-sheet component has the hierarchical instance entry.
|
||||
self.fix_subsheet_instances(str(parent_file), content)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"sheet_uuid": sheet_block_uuid,
|
||||
"sheet_name": sheet_name,
|
||||
"subsheet_path": rel_str,
|
||||
"page": next_page,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding hierarchical sheet: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def create_hierarchical_subsheet(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new sub-sheet file and link it into a parent schematic in one call."""
|
||||
logger.info("Creating hierarchical subsheet")
|
||||
try:
|
||||
parent_path = params.get("parentSchematicPath")
|
||||
subsheet_path = params.get("subsheetPath")
|
||||
sheet_name = params.get("sheetName", "Sheet")
|
||||
position = params.get("position", {})
|
||||
size = params.get("size", {})
|
||||
metadata = params.get("metadata", {})
|
||||
|
||||
if not parent_path or not subsheet_path:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "parentSchematicPath and subsheetPath are required",
|
||||
}
|
||||
|
||||
create_result = self.iface._handle_create_schematic(
|
||||
{"filename": subsheet_path, "metadata": metadata}
|
||||
)
|
||||
if not create_result.get("success"):
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Failed to create sub-sheet: {create_result.get('message')}",
|
||||
}
|
||||
|
||||
link_result = self.add_hierarchical_sheet(
|
||||
{
|
||||
"schematicPath": parent_path,
|
||||
"subsheetPath": subsheet_path,
|
||||
"sheetName": sheet_name,
|
||||
"position": position,
|
||||
"size": size,
|
||||
}
|
||||
)
|
||||
if not link_result.get("success"):
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Created sub-sheet but failed to link: {link_result.get('message')}",
|
||||
"subsheet_created": create_result.get("file_path", subsheet_path),
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"subsheet_path": create_result.get("file_path", subsheet_path),
|
||||
"subsheet_uuid": create_result.get("schematic_uuid"),
|
||||
"sheet_block_uuid": link_result.get("sheet_uuid"),
|
||||
"sheet_name": sheet_name,
|
||||
"page": link_result.get("page"),
|
||||
"message": (
|
||||
f"Created sub-sheet '{sheet_name}' at {subsheet_path} "
|
||||
f"and linked it into {parent_path} (page {link_result.get('page')})"
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error in create_hierarchical_subsheet: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def fix_subsheet_instances(self, parent_path: str, parent_content: str) -> List[str]:
|
||||
"""Ensure every component in each referenced sub-sheet has an instances entry for the
|
||||
sheet-block UUID, so ERC resolves references correctly. Returns modified sub-sheet paths.
|
||||
"""
|
||||
modified_sheets: List[str] = []
|
||||
try:
|
||||
parent_file = Path(parent_path)
|
||||
parent_data = sexpdata.loads(parent_content)
|
||||
|
||||
for item in parent_data:
|
||||
if not (isinstance(item, list) and len(item) > 0 and item[0] == Symbol("sheet")):
|
||||
continue
|
||||
|
||||
sheet_block_uuid = None
|
||||
sheet_file_rel = None
|
||||
for sub in item:
|
||||
if isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("uuid"):
|
||||
sheet_block_uuid = str(sub[1])
|
||||
elif (
|
||||
isinstance(sub, list)
|
||||
and len(sub) >= 3
|
||||
and sub[0] == Symbol("property")
|
||||
and sub[1] == "Sheet file"
|
||||
):
|
||||
sheet_file_rel = str(sub[2])
|
||||
if not sheet_block_uuid or not sheet_file_rel:
|
||||
continue
|
||||
|
||||
sub_sheet_path = parent_file.parent / sheet_file_rel
|
||||
if not sub_sheet_path.exists():
|
||||
logger.warning(f"Sub-sheet not found: {sub_sheet_path}")
|
||||
continue
|
||||
|
||||
parent_uuid_match = re.search(r"\(uuid\s+([0-9a-fA-F-]+)\)", parent_content)
|
||||
parent_uuid = parent_uuid_match.group(1) if parent_uuid_match else ""
|
||||
target_path = (
|
||||
f"/{parent_uuid}/{sheet_block_uuid}" if parent_uuid else f"/{sheet_block_uuid}"
|
||||
)
|
||||
|
||||
sub_content = sub_sheet_path.read_text(encoding="utf-8")
|
||||
|
||||
def _balanced_end(s: str, start: int) -> int:
|
||||
depth = 0
|
||||
for j in range(start, len(s)):
|
||||
if s[j] == "(":
|
||||
depth += 1
|
||||
elif s[j] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return j
|
||||
return len(s) - 1
|
||||
|
||||
result_parts: List[str] = []
|
||||
pos = 0
|
||||
changed = False
|
||||
while True:
|
||||
idx = sub_content.find("(instances", pos)
|
||||
if idx == -1:
|
||||
result_parts.append(sub_content[pos:])
|
||||
break
|
||||
result_parts.append(sub_content[pos:idx])
|
||||
end = _balanced_end(sub_content, idx)
|
||||
block = sub_content[idx : end + 1]
|
||||
|
||||
if target_path not in block:
|
||||
existing = re.search(r'\(reference\s+"([^"]+)"\)\s*\(unit\s+(\d+)\)', block)
|
||||
if existing:
|
||||
new_entry = (
|
||||
f'(path "{target_path}" (reference "{existing.group(1)}") '
|
||||
f"(unit {existing.group(2)}))"
|
||||
)
|
||||
proj_start = block.find("(project ")
|
||||
if proj_start != -1:
|
||||
proj_end = _balanced_end(block, proj_start)
|
||||
block = block[:proj_end] + " " + new_entry + block[proj_end:]
|
||||
changed = True
|
||||
result_parts.append(block)
|
||||
pos = end + 1
|
||||
|
||||
if changed:
|
||||
sub_sheet_path.write_text("".join(result_parts), encoding="utf-8")
|
||||
modified_sheets.append(str(sub_sheet_path))
|
||||
logger.info(
|
||||
f"Fixed instances in {sub_sheet_path} for sheet-block {sheet_block_uuid}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fixing sub-sheet instances: {e}")
|
||||
return modified_sheets
|
||||
@@ -325,6 +325,7 @@ try:
|
||||
from commands.project import ProjectCommands
|
||||
from commands.routing import RoutingCommands
|
||||
from commands.schematic import SchematicManager
|
||||
from commands.schematic_hierarchy import SchematicHierarchyCommands
|
||||
from commands.symbol_creator import SymbolCreator
|
||||
from commands.symbol_pins import SymbolPinCommands
|
||||
|
||||
@@ -447,6 +448,8 @@ class KiCADInterface:
|
||||
|
||||
# Symbol pin discovery commands (read-only pin lookup from symbol libraries)
|
||||
self.symbol_pin_commands = SymbolPinCommands()
|
||||
# Schematic hierarchy commands (insert sheets, scaffold sub-sheets)
|
||||
self.hierarchy_commands = SchematicHierarchyCommands(self)
|
||||
|
||||
# Initialize JLCPCB API integration
|
||||
self.jlcpcb_client = JLCPCBClient() # Official API (requires auth)
|
||||
@@ -534,6 +537,9 @@ class KiCADInterface:
|
||||
# Symbol pin discovery commands (read pins straight from symbol libraries)
|
||||
"list_symbol_pins": self.symbol_pin_commands.list_symbol_pins,
|
||||
"batch_list_symbol_pins": self.symbol_pin_commands.batch_list_symbol_pins,
|
||||
# Schematic hierarchy commands (sheet insertion + subsheet scaffolding)
|
||||
"add_hierarchical_sheet": self.hierarchy_commands.add_hierarchical_sheet,
|
||||
"create_hierarchical_subsheet": self.hierarchy_commands.create_hierarchical_subsheet,
|
||||
# JLCPCB API commands (complete parts catalog via API)
|
||||
"download_jlcpcb_database": self._handle_download_jlcpcb_database,
|
||||
"search_jlcpcb_parts": self._handle_search_jlcpcb_parts,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { registerExportTools } from "./tools/export.js";
|
||||
import { registerSchematicTools } from "./tools/schematic.js";
|
||||
import { registerLibraryTools } from "./tools/library.js";
|
||||
import { registerSymbolLibraryTools } from "./tools/library-symbol.js";
|
||||
import { registerSchematicHierarchyTools } from "./tools/schematic-hierarchy.js";
|
||||
import { registerJLCPCBApiTools } from "./tools/jlcpcb-api.js";
|
||||
import { registerDatasheetTools } from "./tools/datasheet.js";
|
||||
import { registerFootprintTools } from "./tools/footprint.js";
|
||||
@@ -296,6 +297,7 @@ export class KiCADMcpServer {
|
||||
registerSchematicTools(this.server, this.callKicadScript.bind(this));
|
||||
registerLibraryTools(this.server, this.callKicadScript.bind(this));
|
||||
registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this));
|
||||
registerSchematicHierarchyTools(this.server, this.callKicadScript.bind(this));
|
||||
registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this));
|
||||
registerDatasheetTools(this.server, this.callKicadScript.bind(this));
|
||||
registerFootprintTools(this.server, this.callKicadScript.bind(this));
|
||||
|
||||
@@ -123,6 +123,11 @@ export const toolCategories: ToolCategory[] = [
|
||||
description: "Read a symbol's pins straight from the library (no schematic needed)",
|
||||
tools: ["list_symbol_pins", "batch_list_symbol_pins"],
|
||||
},
|
||||
{
|
||||
name: "schematic_hierarchy",
|
||||
description: "Hierarchical schematic sheets: insert a sheet, scaffold a sub-sheet",
|
||||
tools: ["add_hierarchical_sheet", "create_hierarchical_subsheet"],
|
||||
},
|
||||
{
|
||||
name: "routing",
|
||||
description: "Advanced routing operations: vias, copper pours",
|
||||
|
||||
64
src/tools/schematic-hierarchy.ts
Normal file
64
src/tools/schematic-hierarchy.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Schematic hierarchy tools: insert a hierarchical sheet, scaffold a sub-sheet.
|
||||
*/
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
|
||||
export function registerSchematicHierarchyTools(server: McpServer, callKicadScript: Function) {
|
||||
// Link an existing sub-sheet into a parent
|
||||
server.tool(
|
||||
"add_hierarchical_sheet",
|
||||
"Insert a hierarchical-sheet reference block into a parent schematic, pointing at an existing sub-sheet file. Adds the sheet box, name/file fields, a sheet_instances path entry on the next page number, and fixes sub-sheet component instance paths so ERC resolves references.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the parent .kicad_sch"),
|
||||
subsheetPath: z.string().describe("Path to the existing sub-sheet .kicad_sch to reference"),
|
||||
sheetName: z.string().optional().default("Sheet").describe("Display name for the sheet"),
|
||||
position: z
|
||||
.object({ x: z.number(), y: z.number() })
|
||||
.optional()
|
||||
.describe("Top-left of the sheet box in mm (default 50,50)"),
|
||||
size: z
|
||||
.object({ width: z.number(), height: z.number() })
|
||||
.optional()
|
||||
.describe("Sheet box size in mm (default 80x50)"),
|
||||
},
|
||||
async (args: any) => {
|
||||
const r = await callKicadScript("add_hierarchical_sheet", args);
|
||||
if (!r.success)
|
||||
return { content: [{ type: "text", text: `Failed: ${r.message || "Unknown error"}` }] };
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Added sheet '${r.sheet_name}' -> ${r.subsheet_path} (page ${r.page})`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Create a sub-sheet file AND link it in one call
|
||||
server.tool(
|
||||
"create_hierarchical_subsheet",
|
||||
"Create a new sub-sheet .kicad_sch file and link it into a parent schematic in a single call (create_schematic + add_hierarchical_sheet). The fastest way to grow a hierarchical design.",
|
||||
{
|
||||
parentSchematicPath: z.string().describe("Path to the parent .kicad_sch"),
|
||||
subsheetPath: z.string().describe("Path for the new sub-sheet .kicad_sch to create"),
|
||||
sheetName: z.string().optional().default("Sheet").describe("Display name for the sheet"),
|
||||
position: z.object({ x: z.number(), y: z.number() }).optional(),
|
||||
size: z.object({ width: z.number(), height: z.number() }).optional(),
|
||||
metadata: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.describe("Optional metadata for the new sub-sheet (title, etc.)"),
|
||||
},
|
||||
async (args: any) => {
|
||||
const r = await callKicadScript("create_hierarchical_subsheet", args);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: r.success ? r.message : `Failed: ${r.message || "Unknown error"}` },
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
96
tests/test_schematic_hierarchy.py
Normal file
96
tests/test_schematic_hierarchy.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Unit/integration tests for the hierarchy commands (commands/schematic_hierarchy.py).
|
||||
|
||||
The hierarchical-sheet text manipulation is exercised against real files in tmp,
|
||||
and create_hierarchical_subsheet's orchestration is checked with a stub interface.
|
||||
No KiCad needed.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||
|
||||
from commands.schematic_hierarchy import SchematicHierarchyCommands # noqa: E402
|
||||
|
||||
|
||||
def _cmds(iface=None):
|
||||
return SchematicHierarchyCommands(iface or types.SimpleNamespace())
|
||||
|
||||
|
||||
class TestAddHierarchicalSheet:
|
||||
def test_requires_params(self):
|
||||
assert _cmds().add_hierarchical_sheet({"schematicPath": "/x"})["success"] is False
|
||||
|
||||
def test_inserts_sheet_block(self, tmp_path):
|
||||
parent = tmp_path / "top.kicad_sch"
|
||||
parent.write_text(
|
||||
'(kicad_sch (uuid abcd-1234)\n (sheet_instances (path "/" (page "1")))\n)'
|
||||
)
|
||||
r = _cmds().add_hierarchical_sheet(
|
||||
{
|
||||
"schematicPath": str(parent),
|
||||
"subsheetPath": str(tmp_path / "sub.kicad_sch"),
|
||||
"sheetName": "Power",
|
||||
}
|
||||
)
|
||||
assert r["success"] is True
|
||||
assert r["page"] == 2 # next page after existing "1"
|
||||
content = parent.read_text()
|
||||
assert "(sheet " in content
|
||||
assert '"Sheet name" "Power"' in content
|
||||
assert '"Sheet file" "sub.kicad_sch"' in content
|
||||
# a sheet_instances path entry for the new sheet block was added
|
||||
assert f'/{ "abcd-1234" }/{ r["sheet_uuid"] }' in content
|
||||
|
||||
|
||||
class TestCreateHierarchicalSubsheet:
|
||||
def test_orchestrates(self):
|
||||
iface = types.SimpleNamespace(
|
||||
_handle_create_schematic=lambda p: {
|
||||
"success": True,
|
||||
"file_path": p["filename"],
|
||||
"schematic_uuid": "sub-uuid",
|
||||
}
|
||||
)
|
||||
c = SchematicHierarchyCommands(iface)
|
||||
c.add_hierarchical_sheet = lambda p: {"success": True, "sheet_uuid": "blk", "page": 2}
|
||||
r = c.create_hierarchical_subsheet(
|
||||
{
|
||||
"parentSchematicPath": "/top.kicad_sch",
|
||||
"subsheetPath": "/sub.kicad_sch",
|
||||
"sheetName": "IO",
|
||||
}
|
||||
)
|
||||
assert r["success"] is True
|
||||
assert r["sheet_block_uuid"] == "blk" and r["page"] == 2
|
||||
|
||||
def test_requires_params(self):
|
||||
assert (
|
||||
_cmds().create_hierarchical_subsheet({"parentSchematicPath": "/x"})["success"] is False
|
||||
)
|
||||
|
||||
|
||||
class TestFixSubsheetInstances:
|
||||
def test_adds_path_entry(self, tmp_path):
|
||||
sub = tmp_path / "sub.kicad_sch"
|
||||
sub.write_text(
|
||||
'(kicad_sch (symbol (lib_id "Device:R")'
|
||||
' (instances (project "proj" (path "/old" (reference "R1") (unit 1))))))'
|
||||
)
|
||||
parent = tmp_path / "top.kicad_sch"
|
||||
parent_content = (
|
||||
"(kicad_sch (uuid abcd-1234)"
|
||||
' (sheet (at 50 50) (uuid "sheet-blk-1")'
|
||||
' (property "Sheet name" "Sub" (at 1 1 0))'
|
||||
' (property "Sheet file" "sub.kicad_sch" (at 1 2 0)))'
|
||||
' (sheet_instances (path "/" (page "1"))))'
|
||||
)
|
||||
parent.write_text(parent_content)
|
||||
|
||||
modified = _cmds().fix_subsheet_instances(str(parent), parent_content)
|
||||
assert str(sub) in modified
|
||||
sub_after = sub.read_text()
|
||||
assert "/abcd-1234/sheet-blk-1" in sub_after
|
||||
assert '(reference "R1")' in sub_after
|
||||
Reference in New Issue
Block a user