Repository navigation
Conversation
`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`.
|
Agreed on the behaviour change — Two notes on the write-up. Changelog category is The move case should cite a rule, not enumerate a surprise. Separately, I've opened #812 for the longer-term question this raises — whether
|
|
Correcting two things in my comment above, now that I've read the diff rather than the description. Withdraw the The docstring note was half-answered before I made it. It already cites the no-duplicates rule of What stands from the earlier comment: agreement on the behaviour change, and the |
kyamagu
left a comment
There was a problem hiding this comment.
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]andgroup[-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 (
insertthenremove), so a raise between the steps would have left the group one layer too long._check_insertion()running first insideinsert()is what prevents it.
Two non-blocking nits below.
| @@ -1067,7 +1067,32 @@ def __getitem__(self, key: int) -> Layer: | |||
| return self._layers.__getitem__(key) | |||
|
|
|||
| def __setitem__(self, key: int, value: Layer) -> None: | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🔵 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 throughinsert()and again throughremove()._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.
…tion docs: record the slice TypeError in the #811 changelog entry
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]>
psd[0] = layerdoes 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. Openingtests/psd_files/group.psd, whose layers are['Background', 'Group 1'], and assigning a layer taken fromtests/psd_files/1layer.psdtopsd[0]gives['Фон', 'Background', 'Group 1']andlen(psd) == 3.GroupMixin.__setitem__atsrc/psd_tools/api/layers.py:1069was two lines whose whole body wasself.insert(key, value).insert()atsrc/psd_tools/api/layers.py:1109is 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 isself._layers.__getitem__(key)and__delitem__at line 1072 isself.remove(self._layers[key]).docs/usage.rst:153introduces the section as "methods emulating a python list" and then listsgroup[0] = layerat line 162 andgroup.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 bypsd.insert(0, layer)replaces correctly onmain. 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 writingpop()theninsert()by hand.insert()receives the resolved non-negative index rather thankey. And the two steps run in the opposite order: this method inserts first and removes second, wherepop()plusinsert()removes first and inserts second. The number of_psd._update_record()calls is the same either way, 2. I monkeypatchedPSDImage._update_recordand counted 2 forpsd[0] = layerand 2 forpsd.pop(0)followed bypsd.insert(0, layer), sinceinsert()andremove()call it once each.Nothing in the tree calls this method by name.
git grep -n "__setitem__" -- srcreturns 7 hits in tracked files: the definition itself inapi/layers.py, four inpsd/base.pyand two inpsd/engine_data.py, both unrelated containers. Call sites would not appear in that grep anyway, because item assignment is writtenx[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 mainpsd[5] = layeron 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]ongroup.psdleaves['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']withAat index 1, because takingAout from before the target shifts the rest down. That no-duplicates behaviour follows the ruleappend()andinsert()already document, not list semantics, and it is stated in the new docstring. Slice assignment was never supported: on mainpsd[0:1] = [layer]raisedTypeError("Expected Layer instance, got list")from inside the insert path, and it now raisesTypeError("Slice assignment is not supported")up front.Two tests in
tests/psd_tools/api/test_layers.py, next totest_group_insert.test_group_setitemcovers replacement at a positive and a negative index,_parenton both layers and_psdon 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 twoTypeErrorcases. It does not assert_psdon the removed layer, becauseremove()clears_parentand leaves_psdas it was, which this change does not touch.test_psd_image_setitem_round_tripbuilds 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 onlysrc/psd_tools/api/layers.pyand rerunning gives2 failed, withAssertionError: assert 3 == 2in the first andAssertionError: assert ['Blue', 'Red', 'Green'] == ['Blue', 'Green']in the second.Ran on macOS with Python 3.11.
pytest --no-cov -qgives2946 passed, 3 skipped, 19 xfailed, 2 xpassedagainst2944 passed, 3 skipped, 19 xfailed, 2 xpassedonmainat 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, gives1785 passed, 3 skipped, 2 xfailedagainst1783 passed, 3 skipped, 2 xfailedonmain.ruff checkgives "All checks passed!",ruff format --checkgives "110 files already formatted",mypygives "Success: no issues found in 109 source files".docs/usage.rstneeded 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 existing1.19.1 (unreleased)heading.