Skip to content

Commit cabdb74

Browse files
refactor(delete): replace force_parts/force_masters with part_integrity
Introduced unified `part_integrity` parameter for delete and drop operations with policies for master-part integrity: For delete(): - "enforce" (default): Error if parts would be deleted without masters - "ignore": Allow deleting parts without masters (breaks integrity) - "cascade": Also delete masters when parts are deleted For drop(): - "enforce" (default): Error - drop master instead - "ignore": Allow direct drop This replaces the previous boolean parameters: - force_parts=True → part_integrity="ignore" - force_masters=True → part_integrity="cascade" - Part.drop(force=True) → Part.drop(part_integrity="ignore") The same parameter works consistently on both Table and Part classes, providing a cleaner, more intuitive API. Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent c2a2eae commit cabdb74

File tree

6 files changed

+175
-33
lines changed

6 files changed

+175
-33
lines changed

RELEASE_MEMO.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# DataJoint 2.0 Release Memo
2+
3+
## PyPI Release Process
4+
5+
### Steps
6+
7+
1. **Run "Manual Draft Release" workflow** on GitHub Actions
8+
2. **Edit the draft release**:
9+
- Change release name to `Release 2.0.0`
10+
- Change tag to `v2.0.0`
11+
3. **Publish the release**
12+
4. Automation will:
13+
- Update `version.py` to `2.0.0`
14+
- Build and publish to PyPI
15+
- Create PR to merge version update back to master
16+
17+
### Version Note
18+
19+
The release drafter computes version from the previous tag (`v0.14.6`), so it would generate `0.14.7` or `0.15.0`. You must **manually edit** the release name to include `2.0.0`.
20+
21+
The regex on line 42 of `post_draft_release_published.yaml` extracts version from the release name:
22+
```bash
23+
VERSION=$(echo "${{ github.event.release.name }}" | grep -oP '\d+\.\d+\.\d+')
24+
```
25+
26+
---
27+
28+
## Conda-Forge Release Process
29+
30+
DataJoint has a [conda-forge feedstock](https://github.com/conda-forge/datajoint-feedstock).
31+
32+
### How Conda-Forge Updates Work
33+
34+
Conda-forge has **automated bots** that detect new PyPI releases and create PRs automatically:
35+
36+
1. **You publish to PyPI** (via the GitHub release workflow)
37+
2. **regro-cf-autotick-bot** detects the new version within ~24 hours
38+
3. **Bot creates a PR** to the feedstock with updated version and hash
39+
4. **Maintainers review and merge** (you're listed as a maintainer)
40+
5. **Package builds automatically** for all platforms
41+
42+
### Manual Update (if bot doesn't trigger)
43+
44+
If the bot doesn't create a PR, manually update the feedstock:
45+
46+
1. **Fork** [conda-forge/datajoint-feedstock](https://github.com/conda-forge/datajoint-feedstock)
47+
48+
2. **Edit `recipe/meta.yaml`**:
49+
```yaml
50+
{% set version = "2.0.0" %}
51+
52+
package:
53+
name: datajoint
54+
version: {{ version }}
55+
56+
source:
57+
url: https://pypi.io/packages/source/d/datajoint/datajoint-{{ version }}.tar.gz
58+
sha256: <NEW_SHA256_HASH>
59+
60+
build:
61+
number: 0 # Reset to 0 for new version
62+
```
63+
64+
3. **Get the SHA256 hash**:
65+
```bash
66+
curl -sL https://pypi.org/pypi/datajoint/2.0.0/json | jq -r '.urls[] | select(.packagetype=="sdist") | .digests.sha256'
67+
```
68+
69+
4. **Update license** (important for 2.0!):
70+
```yaml
71+
about:
72+
license: Apache-2.0 # Changed from LGPL-2.1-only
73+
license_file: LICENSE
74+
```
75+
76+
5. **Submit PR** to the feedstock
77+
78+
### Action Items for 2.0 Release
79+
80+
1. **First**: Publish to PyPI via GitHub release (name it "Release 2.0.0")
81+
2. **Wait**: ~24 hours for conda-forge bot to detect
82+
3. **Check**: [datajoint-feedstock PRs](https://github.com/conda-forge/datajoint-feedstock/pulls) for auto-PR
83+
4. **Review**: Ensure license changed from LGPL to Apache-2.0
84+
5. **Merge**: As maintainer, approve and merge the PR
85+
86+
### Timeline
87+
88+
| Step | When |
89+
|------|------|
90+
| PyPI release | Day 0 |
91+
| Bot detects & creates PR | Day 0-1 |
92+
| Review & merge PR | Day 1-2 |
93+
| Conda-forge package available | Day 1-2 |
94+
95+
### Verification
96+
97+
After release:
98+
```bash
99+
conda search datajoint -c conda-forge
100+
# Should show 2.0.0
101+
```
102+
103+
---
104+
105+
## Maintainers
106+
107+
- @datajointbot
108+
- @dimitri-yatsenko
109+
- @drewyangdev
110+
- @guzman-raphael
111+
- @ttngu207
112+
113+
## Links
114+
115+
- [datajoint-feedstock on GitHub](https://github.com/conda-forge/datajoint-feedstock)
116+
- [datajoint on Anaconda.org](https://anaconda.org/conda-forge/datajoint)
117+
- [datajoint on PyPI](https://pypi.org/project/datajoint/)

docs/src/archive/manipulation/delete.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,6 @@ Entities in a [part table](../design/tables/master-part.md) are usually removed
2626
consequence of deleting the master table.
2727

2828
To enforce this workflow, calling `delete` directly on a part table produces an error.
29-
In some cases, it may be necessary to override this behavior.
30-
To remove entities from a part table without calling `delete` master, use the argument `force_parts=True`.
31-
To include the corresponding entries in the master table, use the argument `force_masters=True`.
29+
In some cases, it may be necessary to override this behavior using the `part_integrity` parameter:
30+
- `part_integrity="ignore"`: Remove entities from a part table without deleting from master (breaks integrity).
31+
- `part_integrity="cascade"`: Delete from parts and also cascade up to delete the corresponding master entries.

src/datajoint/table.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -813,8 +813,7 @@ def delete(
813813
self,
814814
transaction: bool = True,
815815
prompt: bool | None = None,
816-
force_parts: bool = False,
817-
force_masters: bool = False,
816+
part_integrity: str = "enforce",
818817
) -> int:
819818
"""
820819
Deletes the contents of the table and its dependent tables, recursively.
@@ -825,18 +824,22 @@ def delete(
825824
nested within another transaction.
826825
prompt: If `True`, show what will be deleted and ask for confirmation.
827826
If `False`, delete without confirmation. Default is `dj.config['safemode']`.
828-
force_parts: Delete from parts even when not deleting from their masters.
829-
force_masters: If `True`, include part/master pairs in the cascade.
830-
Default is `False`.
827+
part_integrity: Policy for master-part integrity. One of:
828+
- ``"enforce"`` (default): Error if parts would be deleted without masters.
829+
- ``"ignore"``: Allow deleting parts without masters (breaks integrity).
830+
- ``"cascade"``: Also delete masters when parts are deleted (maintains integrity).
831831
832832
Returns:
833833
Number of deleted rows (excluding those from dependent tables).
834834
835835
Raises:
836836
DataJointError: Delete exceeds maximum number of delete attempts.
837837
DataJointError: When deleting within an existing transaction.
838-
DataJointError: Deleting a part table before its master.
838+
DataJointError: Deleting a part table before its master (when part_integrity="enforce").
839+
ValueError: Invalid part_integrity value.
839840
"""
841+
if part_integrity not in ("enforce", "ignore", "cascade"):
842+
raise ValueError(f"part_integrity must be 'enforce', 'ignore', or 'cascade', got {part_integrity!r}")
840843
deleted = set()
841844
visited_masters = set()
842845

@@ -892,7 +895,7 @@ def cascade(table):
892895

893896
master_name = get_master(child.full_table_name)
894897
if (
895-
force_masters
898+
part_integrity == "cascade"
896899
and master_name
897900
and master_name != table.full_table_name
898901
and master_name not in visited_masters
@@ -941,15 +944,16 @@ def cascade(table):
941944
self.connection.cancel_transaction()
942945
raise
943946

944-
if not force_parts:
945-
# Avoid deleting from child before master (See issue #151)
947+
if part_integrity == "enforce":
948+
# Avoid deleting from part before master (See issue #151)
946949
for part in deleted:
947950
master = get_master(part)
948951
if master and master not in deleted:
949952
if transaction:
950953
self.connection.cancel_transaction()
951954
raise DataJointError(
952-
"Attempt to delete part table {part} before deleting from its master {master} first.".format(
955+
"Attempt to delete part table {part} before deleting from its master {master} first. "
956+
"Use part_integrity='ignore' to allow, or part_integrity='cascade' to also delete master.".format(
953957
part=part, master=master
954958
)
955959
)

src/datajoint/user_tables.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -213,32 +213,53 @@ class Part(UserTable, metaclass=PartMeta):
213213
+ ")"
214214
)
215215

216-
def delete(self, force=False, **kwargs):
216+
def delete(self, part_integrity: str = "enforce", **kwargs):
217217
"""
218218
Delete from a Part table.
219219
220220
Args:
221-
force: If True, allow direct deletion from Part table.
222-
If False (default), raise an error.
221+
part_integrity: Policy for master-part integrity. One of:
222+
- ``"enforce"`` (default): Error - delete from master instead.
223+
- ``"ignore"``: Allow direct deletion (breaks master-part integrity).
224+
- ``"cascade"``: Delete parts AND cascade up to delete master.
223225
**kwargs: Additional arguments passed to Table.delete()
224-
(transaction, prompt, force_masters)
226+
(transaction, prompt)
225227
226228
Raises:
227-
DataJointError: If force is False (direct Part deletes are prohibited)
229+
DataJointError: If part_integrity="enforce" (direct Part deletes prohibited)
228230
"""
229-
if force:
230-
super().delete(force_parts=True, **kwargs)
231-
else:
232-
raise DataJointError("Cannot delete from a Part directly. Delete from master instead")
233-
234-
def drop(self, force=False):
231+
if part_integrity == "enforce":
232+
raise DataJointError(
233+
"Cannot delete from a Part directly. Delete from master instead, "
234+
"or use part_integrity='ignore' to break integrity, "
235+
"or part_integrity='cascade' to also delete master."
236+
)
237+
super().delete(part_integrity=part_integrity, **kwargs)
238+
239+
def drop(self, part_integrity: str = "enforce"):
235240
"""
236-
unless force is True, prohibits direct deletes from parts.
241+
Drop a Part table.
242+
243+
Args:
244+
part_integrity: Policy for master-part integrity. One of:
245+
- ``"enforce"`` (default): Error - drop master instead.
246+
- ``"ignore"``: Allow direct drop (breaks master-part structure).
247+
Note: ``"cascade"`` is not supported for drop (too destructive).
248+
249+
Raises:
250+
DataJointError: If part_integrity="enforce" (direct Part drops prohibited)
237251
"""
238-
if force:
252+
if part_integrity == "ignore":
239253
super().drop()
254+
elif part_integrity == "enforce":
255+
raise DataJointError(
256+
"Cannot drop a Part directly. Drop master instead, "
257+
"or use part_integrity='ignore' to force."
258+
)
240259
else:
241-
raise DataJointError("Cannot drop a Part directly. Delete from master instead")
260+
raise ValueError(
261+
f"part_integrity for drop must be 'enforce' or 'ignore', got {part_integrity!r}"
262+
)
242263

243264
def alter(self, prompt=True, context=None):
244265
# without context, use declaration context which maps master keyword to master table

tests/integration/test_cascading_delete.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def test_delete_tree(schema_simp_pop):
3838
def test_stepwise_delete(schema_simp_pop):
3939
assert not dj.config["safemode"], "safemode must be off for testing"
4040
assert L() and A() and B() and B.C(), "schema population failed"
41-
B.C().delete(force=True)
41+
B.C().delete(part_integrity="ignore")
4242
assert not B.C(), "failed to delete child tables"
4343
B().delete()
4444
assert not B(), "failed to delete from the parent table following child table deletion"
@@ -113,19 +113,19 @@ def test_delete_parts_error(schema_simp_pop):
113113
"""test issue #151"""
114114
with pytest.raises(dj.DataJointError):
115115
Profile().populate_random()
116-
Website().delete(force_masters=False)
116+
Website().delete(part_integrity="enforce")
117117

118118

119119
def test_delete_parts(schema_simp_pop):
120120
"""test issue #151"""
121121
Profile().populate_random()
122-
Website().delete(force_masters=True)
122+
Website().delete(part_integrity="cascade")
123123

124124

125125
def test_delete_parts_complex(schema_simp_pop):
126126
"""test issue #151 with complex master/part. PR #1158."""
127127
prev_len = len(G())
128-
(A() & "id_a=1").delete(force_masters=True)
128+
(A() & "id_a=1").delete(part_integrity="cascade")
129129
assert prev_len - len(G()) == 16, "Failed to delete parts"
130130

131131

tests/schema_simple.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,9 @@ class H(dj.Part):
141141
"""
142142

143143
class M(dj.Part):
144-
definition = """ # test force_masters revisit
144+
definition = """ # test part_integrity cascade
145145
-> E
146-
id_m :int
146+
id_m : uint16
147147
---
148148
-> E.H
149149
"""

0 commit comments

Comments
 (0)