diff --git a/backends/block_specs.py b/backends/block_specs.py index 3fdc85c..5471331 100644 --- a/backends/block_specs.py +++ b/backends/block_specs.py @@ -204,6 +204,38 @@ def validate_attdef_specs(attdefs) -> list[dict]: return out +def validate_attribute_values(attributes, where: str = "attributes") -> dict[str, str]: + """``{TAG: value}`` for ``block_insert`` / ``block_set_attributes`` as the + strings that will be written, refused before any write. + + ``str(value)`` used to be applied to whatever arrived, so a JSON ``null`` + became the text ``None``, ``true`` became ``True`` and an object became its + Python repr — silently, on both engines. A string is taken as is (one line, + the DXF rule ``_one_line`` enforces); an int or a finite float is written + with ``str`` (``101`` → ``"101"``, ``2.5`` → ``"2.5"``); ``bool``, ``None``, + lists and objects are refused by tag. ``None`` for the whole mapping means + "no attributes". + """ + if attributes is None: + return {} + if not isinstance(attributes, dict): + raise TypeError(f"{where} must be an object of {{TAG: value}} pairs") + out: dict[str, str] = {} + for tag, value in attributes.items(): + if not isinstance(tag, str) or not tag.strip(): + raise TypeError(f"{where}: every tag must be a non-empty string") + label = f"{where}[{tag!r}]" + if isinstance(value, str): + out[tag] = _one_line(value, label) + elif isinstance(value, bool) or value is None or not isinstance(value, (int, float)): + raise TypeError(f"{label} must be a string or a number, got {type(value).__name__}") + else: + if isinstance(value, float) and not math.isfinite(value): + raise TypeError(f"{label} must be finite") + out[tag] = str(value) + return out + + def solid_vertices(points: list[tuple[float, float]]) -> list[tuple[float, float]]: """DXF SOLID draws vtx0→vtx1→vtx3→vtx2, so a quad given in polygon order (a, b, c, d) is stored as (a, b, d, c). Triangles are stored as given.""" diff --git a/backends/com_backend.py b/backends/com_backend.py index 6b39885..1b3d8ca 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -3315,6 +3315,10 @@ class ComBackend(AutoCADBackend): attributes=None, layer=None, ) -> EntityInfo: + from backends.block_specs import validate_attribute_values + + values = validate_attribute_values(attributes) + def _sync(): _require_block_defined(_acad_doc(), name) mspace = _msp() @@ -3328,13 +3332,13 @@ class ComBackend(AutoCADBackend): ) if layer: ref.Layer = layer - if attributes: + if values: try: attrs = ref.GetAttributes() for attr in attrs: tag = attr.TagString - if tag in attributes: - attr.TextString = str(attributes[tag]) + if tag in values: + attr.TextString = values[tag] except Exception as exc: log.debug("Block insert: GetAttributes or attribute setting failed: %s", exc) return _entity_info(ref) @@ -3730,6 +3734,10 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) async def block_set_attributes(self, handle, attributes) -> dict: + from backends.block_specs import validate_attribute_values + + values = validate_attribute_values(attributes) + def _sync(): doc = _acad_doc() ref = doc.HandleToObject(handle) @@ -3737,8 +3745,8 @@ class ComBackend(AutoCADBackend): updated = [] for attr in attrs: tag = attr.TagString - if tag in attributes: - attr.TextString = str(attributes[tag]) + if tag in values: + attr.TextString = values[tag] updated.append(tag) return {"ok": True, "updated_tags": updated} diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 37ebb87..8097923 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -4520,6 +4520,10 @@ class EzdxfBackend(AutoCADBackend): attributes=None, layer=None, ) -> EntityInfo: + from backends.block_specs import validate_attribute_values + + values = validate_attribute_values(attributes) + def _sync(): msp = self._msp() attribs: dict = { @@ -4531,12 +4535,12 @@ class EzdxfBackend(AutoCADBackend): attribs["layer"] = layer self._require_block(name) ref = msp.add_blockref(name, (float(x), float(y)), dxfattribs=attribs) - if attributes: + if values: # `add_auto_blockref` wraps the INSERT in an anonymous *U block, # so the returned handle names `*U1` and carries no ATTRIBs. # `add_auto_attribs` attaches ATTRIBs to this INSERT for every # ATTDEF the definition has; values for tags it lacks are ignored. - ref.add_auto_attribs({str(k): str(v) for k, v in attributes.items()}) + ref.add_auto_attribs(values) self._mark_dirty() return _entity_info_dxf(ref) @@ -4828,6 +4832,10 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) async def block_set_attributes(self, handle, attributes) -> dict: + from backends.block_specs import validate_attribute_values + + values = validate_attribute_values(attributes) + def _sync(): ent = self._get_entity(handle) if ent.dxftype() != "INSERT": @@ -4835,8 +4843,8 @@ class EzdxfBackend(AutoCADBackend): updated = [] for attrib in ent.attribs: tag = attrib.dxf.tag - if tag in attributes: - self._write_attrib_value(attrib, str(attributes[tag])) + if tag in values: + self._write_attrib_value(attrib, values[tag]) updated.append(tag) self._mark_dirty() return {"ok": True, "updated_tags": updated} diff --git a/server.py b/server.py index 70baafd..032f2e3 100644 --- a/server.py +++ b/server.py @@ -3011,7 +3011,15 @@ async def block_insert( layer: Annotated[str | None, "Layer name"] = None, ctx: Context = None, ) -> dict: - """Insert a block and optionally set attribute values.""" + """Insert a block and optionally set attribute values. + + Refused before any write, on both engines: an undefined or layout block + name, and an attribute value that is not text — a string is written as + is (one line), an int or finite float as its plain digits (`101` → + `"101"`), while `null`, booleans, lists and objects are refused naming + the tag (they used to be written as their Python repr). Values for tags + the block does not define are ignored. + """ await ctx.info(f"Inserting block '{name}' at ({x},{y})") result = await _backend(ctx).block_insert( name, x, y, scale_x, scale_y, rotation, attributes, layer @@ -3085,6 +3093,11 @@ async def block_set_attributes( ) -> dict: """Update attribute values in a block reference. + Same value rule as `block_insert`: strings and numbers are written, + `null` / booleans / lists / objects are refused naming the tag before + anything changes. `updated_tags` lists the tags that exist on the + reference and were written; unknown tags are ignored. + A multi-line attribute takes `Line1\\PLine2` and keeps every line; the value written is the one `block_get_attributes` reads back and `block_explode` bursts, on both engines. diff --git a/tests/test_track_a_hardening.py b/tests/test_track_a_hardening.py index 3f01430..bfbfd76 100644 --- a/tests/test_track_a_hardening.py +++ b/tests/test_track_a_hardening.py @@ -1504,3 +1504,88 @@ async def test_com_explode_undoes_a_burst_that_fails_after_explode(com): assert placeholder.deleted is True assert [t.deleted for t in sheet.texts] == [True], "the TEXT written before the failure" assert space.calls == [] + + +# ── Task 3: attribute values are validated before any write ────────────────── + + +@pytest.mark.parametrize( + "value, fragment", + [ + (None, "NoneType"), + (True, "bool"), + ({"a": 1}, "dict"), + ([1, 2], "list"), + (float("nan"), "finite"), + ("two\nlines", "single line"), + ], +) +async def test_insert_refuses_a_non_text_attribute_value_by_tag(backend, value, fragment): + _define_tagged_block(backend) + before = len(list(backend._msp())) + with pytest.raises(TypeError, match=r"attributes\['TAG'\]") as exc: + await backend.block_insert("TB", 0, 0, attributes={"TAG": value}) + assert fragment in str(exc.value) + assert len(list(backend._msp())) == before, "a refused insert must not write" + + +async def test_insert_refuses_a_non_mapping_before_writing(backend): + _define_tagged_block(backend) + before = len(list(backend._msp())) + with pytest.raises(TypeError, match="attributes must be an object"): + await backend.block_insert("TB", 0, 0, attributes=[("TAG", "x")]) + assert len(list(backend._msp())) == before + + +async def test_insert_writes_numbers_as_plain_text(backend): + _define_tagged_block(backend) + ref = await backend.block_insert("TB", 0, 0, attributes={"TAG": 101}) + assert await backend.block_get_attributes(ref.handle) == {"TAG": "101"} + ref = await backend.block_insert("TB", 0, 0, attributes={"TAG": 2.5}) + assert await backend.block_get_attributes(ref.handle) == {"TAG": "2.5"} + + +async def test_set_attributes_refuses_by_tag_and_leaves_the_value_alone(backend): + _define_tagged_block(backend) + ref = await backend.block_insert("TB", 0, 0, attributes={"TAG": "P-101"}) + with pytest.raises(TypeError, match=r"attributes\['TAG'\] must be a string or a number"): + await backend.block_set_attributes(ref.handle, {"TAG": None}) + assert await backend.block_get_attributes(ref.handle) == {"TAG": "P-101"} + result = await backend.block_set_attributes(ref.handle, {"TAG": 7}) + assert result == {"ok": True, "updated_tags": ["TAG"]} + assert await backend.block_get_attributes(ref.handle) == {"TAG": "7"} + + +async def test_com_insert_refuses_before_insert_block_and_writes_numbers_as_text(com): + backend, document, space = com + space.attributes = {"TAG": ""} + with pytest.raises(TypeError, match=r"attributes\['TAG'\]"): + await backend.block_insert("TB", 0, 0, attributes={"TAG": None}) + assert space.calls == [], "the refusal fires before InsertBlock" + + await backend.block_insert("TB", 0, 0, attributes={"TAG": 101}) + assert [name for name, _ in space.calls] == ["InsertBlock"] + attr = space.inserted[0].GetAttributes()[0] + assert attr.TextString == "101" and ("TextString", "101") in attr.writes + + +async def test_com_set_attributes_refuses_before_touching_the_reference(com): + backend, document, space = com + attr = _FakeObject("AcDbAttribute", "A1", TagString="TAG", TextString="P-101") + calls = [] + + def get_attributes(): + calls.append("GetAttributes") + return (attr,) + + document.objects["2F"] = _FakeObject( + "AcDbBlockReference", "2F", Name="TB", GetAttributes=get_attributes + ) + with pytest.raises(TypeError, match=r"attributes\['TAG'\]"): + await backend.block_set_attributes("2F", {"TAG": True}) + assert calls == [] and attr.TextString == "P-101" + assert await backend.block_set_attributes("2F", {"TAG": 2.5}) == { + "ok": True, + "updated_tags": ["TAG"], + } + assert attr.TextString == "2.5"