Skip to content

fix: replace the layer at an index instead of inserting before it - #811

Merged
kyamagu merged 2 commits into
psd-tools:mainfrom
MaxFreedomPollard:fix/replace-a-layer-on-item-assignment
Sep 15, 2026
Merged

kyamagu merged 2 commits into
psd-tools:mainfrom
MaxFreedomPollard:fix/replace-a-layer-on-item-assignment

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown
Contributor

psd[0] = layer does not replace the layer at index 0. It inserts the new layer in front of it and keeps the old one, so a document with 2 layers ends up with 3. Opening tests/psd_files/group.psd, whose layers are ['Background', 'Group 1'], and assigning a layer taken from tests/psd_files/1layer.psd to psd[0] gives ['Фон', 'Background', 'Group 1'] and len(psd) == 3.

GroupMixin.__setitem__ at src/psd_tools/api/layers.py:1069 was two lines whose whole body was self.insert(key, value). insert() at src/psd_tools/api/layers.py:1109 is the separate insert operation and does exactly what its name says, so item assignment was insertion under a second name. Every neighbour in the same class is real list emulation: __getitem__ at line 1066 is self._layers.__getitem__(key) and __delitem__ at line 1072 is self.remove(self._layers[key]).

docs/usage.rst:153 introduces the section as "methods emulating a python list" and then lists group[0] = layer at line 162 and group.insert(3, layer) at line 171 as two different operations, so the documented contract was replacement. Replacement itself was reachable, but only as two calls: psd.pop(0) followed by psd.insert(0, layer) replaces correctly on main. There was no single-expression form.

The method now reads the layer at that index, inserts the new one at its resolved position and removes it. The same _check_insertion() and the same reparenting in _update_children() still run. Two things differ from writing pop() then insert() by hand. insert() receives the resolved non-negative index rather than key. And the two steps run in the opposite order: this method inserts first and removes second, where pop() plus insert() removes first and inserts second. The number of _psd._update_record() calls is the same either way, 2. I monkeypatched PSDImage._update_record and counted 2 for psd[0] = layer and 2 for psd.pop(0) followed by psd.insert(0, layer), since insert() and remove() call it once each.

Nothing in the tree calls this method by name. git grep -n "__setitem__" -- src returns 7 hits in tracked files: the definition itself in api/layers.py, four in psd/base.py and two in psd/engine_data.py, both unrelated containers. Call sites would not appear in that grep anyway, because item assignment is written x[i] = y, so the tests below are the evidence that the new behaviour holds.

Four cases worth stating, all measured on this branch. An out-of-range index now raises IndexError('list index out of range') and leaves the group untouched, where on main psd[5] = layer on a 2 layer group appended silently and gave ['Background', 'Group 1', 'Фон']. Assigning the layer that is already at that index is a no-op. Assigning a layer that is elsewhere in the same group moves it rather than duplicating it, so the group shrinks by one: psd[-1] = psd[0] on group.psd leaves ['Background']. That move lands the layer next to where the replaced layer was, not necessarily at the index assigned to: on a 3 layer group ['A', 'B', 'C'], group[2] = group[0] gives ['B', 'A'] with A at index 1, because taking A out from before the target shifts the rest down. That no-duplicates behaviour follows the rule append() and insert() already document, not list semantics, and it is stated in the new docstring. Slice assignment was never supported: on main psd[0:1] = [layer] raised TypeError("Expected Layer instance, got list") from inside the insert path, and it now raises TypeError("Slice assignment is not supported") up front.

Two tests in tests/psd_tools/api/test_layers.py, next to test_group_insert. test_group_setitem covers replacement at a positive and a negative index, _parent on both layers and _psd on the incoming one, the no-op case, the move case, the 3 layer case that pins the moved layer at index 1, the out-of-range case and the two TypeError cases. It does not assert _psd on the removed layer, because remove() clears _parent and leaves _psd as it was, which this change does not touch. test_psd_image_setitem_round_trip builds a 2 layer document, replaces the bottom layer, saves and reopens, and asserts the file holds ['Blue', 'Green']. Neither needs aggdraw, scipy or scikit-image, so both run in the no-composite job. Reverting only src/psd_tools/api/layers.py and rerunning gives 2 failed, with AssertionError: assert 3 == 2 in the first and AssertionError: assert ['Blue', 'Red', 'Green'] == ['Blue', 'Green'] in the second.

Ran on macOS with Python 3.11. pytest --no-cov -q gives 2946 passed, 3 skipped, 19 xfailed, 2 xpassed against 2944 passed, 3 skipped, 19 xfailed, 2 xpassed on main at 5d2c957, so the only change is the 2 new tests. The no-composite job, pytest --ignore=tests/psd_tools/composite/ -m "not composite" --no-cov -q, gives 1785 passed, 3 skipped, 2 xfailed against 1783 passed, 3 skipped, 2 xfailed on main. ruff check gives "All checks passed!", ruff format --check gives "110 files already formatted", mypy gives "Success: no issues found in 109 source files".

docs/usage.rst needed no correction, only a sharper heading for the example: "Set an item" is now "Replace the layer at a given index". Changelog entry added under the existing 1.19.1 (unreleased) heading.

`GroupMixin.__setitem__` in `src/psd_tools/api/layers.py` forwarded
straight to `self.insert(key, value)`, so `psd[0] = layer` put the new
layer in front of the old one and left the old one in place. The group
grew by one layer, and so did the saved file. Every neighbour in the
class is real list emulation, `docs/usage.rst` lists item assignment and
`insert()` as separate operations, and replacing a layer took two calls,
`pop(i)` then `insert(i, layer)`, with no single-expression form.

The method now reads the layer at that index, inserts the new one at its
resolved position and removes it, so the checks in `_check_insertion()`
and the reparenting done by `insert()` and `remove()` still run. Reading
`self._layers[key]` first makes an out-of-range index raise `IndexError`
rather than append silently. Assigning the layer already at the index is
a no-op, and assigning a layer that is elsewhere in the same group moves
it, which shrinks the group by one because `insert()` has never allowed
duplicates. Slice assignment was never supported and now raises a plain
`TypeError` instead of failing inside the insert path.

Opening `tests/psd_files/group.psd`, whose layers are `['Background',
'Group 1']`, and assigning a layer taken from
`tests/psd_files/1layer.psd` to `psd[0]` gave `['Фон', 'Background',
'Group 1']` and `len(psd) == 3`. It now gives `['Фон', 'Group 1']` and
`len(psd) == 2`. On the same file `psd[5] = layer` appended and gave
`['Background', 'Group 1', 'Фон']`; it now leaves the group untouched
and raises `IndexError('list index out of range')`. The suite goes from
`2944 passed, 3 skipped, 19 xfailed, 2 xpassed` to `2946 passed, 3
skipped, 19 xfailed, 2 xpassed`, the two new tests in
`tests/psd_tools/api/test_layers.py`.
@kyamagu

kyamagu commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Agreed on the behaviour change — psd[0] = layer should replace, not insert. docs/usage.rst:153 introduces the section as "methods emulating a python list" and then lists group[0] = layer and group.insert(3, layer) as separate operations, so replacement is the contract we already documented, and item assignment being insertion under a second name contradicts it. Let's take the fix as described, including the explicit IndexError for an out-of-range index and the up-front TypeError for slice assignment.

Two notes on the write-up.

Changelog category is [api], not [fix]. The public surface changes meaning: anyone relying on psd[i] = layer to insert breaks, and out-of-range assignment goes from a silent append to IndexError. Per CLAUDE.md the entry should also flag the backwards incompatibility explicitly.

The move case should cite a rule, not enumerate a surprise. group[2] = group[0] on ['A','B','C'] giving ['B','A'] with A at index 1 is not a quirk of this implementation — it follows from the invariant that a layer has exactly one parent, so inserting it anywhere removes it from where it was. append and insert already behave this way; docs/migration.rst:104 states it in prose. I'd rather the docstring name that invariant and let the result follow from it than list the outcome as a special case. Item 1 of #812 adds the same sentence to docs/usage.rst right above the list-methods section, so the two can point at one statement.

Separately, I've opened #812 for the longer-term question this raises — whether list emulation is the right shape for a container whose elements are document-bound entities rather than free values. Nothing there blocks this fix; #811 is a strict improvement on what's on main today. But two of its work items touch code this PR will edit, so worth reading before implementing:

  • Item 2 proposes rejecting slices on __delitem__ too, which currently fails with a confusing ValueError: Layer [...] not found in group rather than the TypeError this issue adds to __setitem__. Cheap to do in the same PR if you want the two dunders consistent.
  • Item 3 is the detached-layer contract: remove() clears _parent but leaves _psd pointing at the document the layer just left, so a popped layer still marks its ex-document dirty on mutation. That's why the tests here can't assert _psd on the removed layer. It may change what this method's remove step should do, but it's a bigger decision with migration weight, so it shouldn't hold up the replacement fix.

@kyamagu

kyamagu commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Correcting two things in my comment above, now that I've read the diff rather than the description.

Withdraw the [api] note — [fix] is right. CLAUDE.md scopes [api] to a change in the public surface: a new or renamed symbol, a new parameter, a corrected annotation. Nothing here changes a signature; __setitem__ is fixed to do what it already documented. Backwards incompatibility is handled by the separate rule to flag it in the entry text, which the entry does with Behaviour change and the pointer to insert(). Leave the changelog as written.

The docstring note was half-answered before I made it. It already cites the no-duplicates rule of append() rather than presenting the move as a bare special case, which was the substance of what I asked for. The remaining suggestion is smaller than I made it sound: the rule it cites is the consequence, and the cause is that a layer has exactly one parent. If item 1 of #812 lands that sentence in docs/usage.rst, this docstring can point there and drop a line. Not worth blocking on, and fine to leave for that issue.

What stands from the earlier comment: agreement on the behaviour change, and the __delitem__ suggestion — del group[0:2] currently raises ValueError: Layer [...] not found in group from inside remove(), where __setitem__ now raises a clear TypeError for the same misuse. Making the two dunders agree is a two-line addition here if you want it; otherwise it belongs to #812 item 2.

@kyamagu kyamagu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against the branch at bfd0bcd, Python 3.10.21. mypy clean, pytest -k setitem 2 passed.

I probed the mutating paths for correctness and found nothing to fix. Recording what I checked so it doesn't get re-derived:

  • group[2] = group[0] and group[-1] = group[0] on ['A','B','C'] both give ['B','A'], so the negative-index resolution agrees with the positive one.
  • Pulling a layer out of a nested subgroup by assignment reparents it correctly (doc ['A','B','G'], G ['X'] → p[0] = X → doc ['X','B','G'], G []).
  • Assigning a group over a layer works, and a group's own descendants are still loop-checked.
  • Every raising path leaves the container untouched: self-assignment of a group, assigning an ancestor group, a non-layer, a slice, and an out-of-range index all raise with the group's contents unchanged. That matters here because this is a two-step mutation (insert then remove), so a raise between the steps would have left the group one layer too long. _check_insertion() running first inside insert() is what prevents it.

Two non-blocking nits below.

Comment thread src/psd_tools/api/layers.py Resolved
@@ -1067,7 +1067,32 @@ def __getitem__(self, key: int) -> Layer:
return self._layers.__getitem__(key)

def __setitem__(self, key: int, value: Layer) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, pre-existing and only adjacent to this change.

GroupMixinProtocol (src/psd_tools/api/protocols.py:294) declares __len__, __iter__, __getitem__, __delitem__ and index, but not __setitem__. So the protocol that is meant to describe the container surface has a hole exactly where this PR gives item assignment real semantics — a structural type can satisfy GroupMixinProtocol today while having no item assignment at all, or while having the old insert behaviour.

Worth adding alongside the existing __delitem__ declaration:

def __setitem__(self, index: int, value: "Layer") -> None:
    """Replace the child layer at the given index."""
    ...

Not introduced by this PR, so ignore it if you'd rather keep the diff tight.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

An unresolved moderate comment requests avoiding the redundant full-tree record rebuild during replacement.

Pull request overview

This PR makes indexed layer assignment replace the existing layer instead of inserting a duplicate.

Changes:

  • Implements replacement semantics, validation, and move handling.
  • Adds behavioral and round-trip tests.
  • Updates usage documentation and the changelog.
File summaries
File Summary
tests/psd_tools/api/test_layers.py Adds replacement, edge-case, and persistence tests.
src/psd_tools/api/layers.py Corrects assignment behavior; an unresolved comment requests avoiding a redundant full-tree rebuild.
docs/usage.rst Clarifies replacement semantics.
docs/changelog.rst Documents the behavior change.
Review details

Suppressed comments (1)

src/psd_tools/api/layers.py:1095

  • This replacement now invokes _psd._update_record() twice: once through insert() and again through remove(). _update_record() rebuilds the complete layer-record and channel-data lists by recursively walking the document (psd_image.py:1000-1022), so a single logical assignment now performs two full-tree rebuilds (and the old implementation did one). Please add a replacement path that performs the parent/reference mutations and calls _update_record() only once, or otherwise avoid the redundant rebuild.
        self.insert(index, value)
        self.remove(target)
  • Files reviewed: 4/4 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

`GroupMixin.__delitem__` handed a slice straight to `self._layers[key]`,
got a list back and failed inside `remove()` with `ValueError: Layer
[TypeLayer('A' size=22x23), SmartObjectLayer('Smart object' size=22x23)]
not found in group Group('Group 1' size=22x23)`. It now raises
`TypeError("Slice deletion is not supported")` before touching the
group, matching the check `__setitem__` gained earlier in this PR. One
test in `tests/psd_tools/api/test_layers.py` covers deletion by index
and the rejected slice, taking the suite from 2946 passed to 2947.
@kyamagu
kyamagu merged commit 96e15c3 into psd-tools:main Sep 15, 2026
20 checks passed
kyamagu added a commit that referenced this pull request Sep 15, 2026
…tion

docs: record the slice TypeError in the #811 changelog entry
omar16100 pushed a commit to omar16100/psd-tools that referenced this pull request Sep 27, 2026
psd-tools#811 landed two behaviour changes but the changelog entry only covered
one. Both `__setitem__` and `__delitem__` now reject a slice with an
explicit `TypeError`, where `del group[0:2]` previously raised
`ValueError: Layer [...] not found in group` from inside `remove()`.
That is user-visible, so the entry should say so.

Rewords the existing entry rather than adding a second one, keeping it
at five lines. 1.19.1 is still unreleased, so the record is corrected
before it ships.

Co-Authored-By: Claude Opus 5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants