devrev-Python-SDK 3.1.1__py3-none-any.whl → 3.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
devrev/client.py CHANGED
@@ -160,7 +160,7 @@ class DevRevClient:
160
160
  self._dev_users = DevUsersService(self._http)
161
161
  self._groups = GroupsService(self._http)
162
162
  self._links = LinksService(self._http)
163
- self._parts = PartsService(self._http)
163
+ self._parts = PartsService(self._http, parent_client=self)
164
164
  self._rev_orgs = RevOrgsService(self._http)
165
165
  self._rev_users = RevUsersService(self._http)
166
166
  self._slas = SlasService(self._http)
@@ -488,7 +488,7 @@ class AsyncDevRevClient:
488
488
  self._dev_users = AsyncDevUsersService(self._http)
489
489
  self._groups = AsyncGroupsService(self._http)
490
490
  self._links = AsyncLinksService(self._http)
491
- self._parts = AsyncPartsService(self._http)
491
+ self._parts = AsyncPartsService(self._http, parent_client=self)
492
492
  self._rev_orgs = AsyncRevOrgsService(self._http)
493
493
  self._rev_users = AsyncRevUsersService(self._http)
494
494
  self._slas = AsyncSlasService(self._http)
devrev/models/__init__.py CHANGED
@@ -217,6 +217,7 @@ from devrev.models.notifications import (
217
217
  NotificationsSendResponse,
218
218
  )
219
219
  from devrev.models.parts import (
220
+ ParentPartFilter,
220
221
  Part,
221
222
  PartsCreateRequest,
222
223
  PartsCreateResponse,
@@ -226,6 +227,9 @@ from devrev.models.parts import (
226
227
  PartsGetResponse,
227
228
  PartsListRequest,
228
229
  PartsListResponse,
230
+ PartsMovePlan,
231
+ PartsMoveRequest,
232
+ PartsMoveResult,
229
233
  PartSummary,
230
234
  PartsUpdateRequest,
231
235
  PartsUpdateResponse,
@@ -669,6 +673,7 @@ __all__ = [
669
673
  "NotificationsSendRequest",
670
674
  "NotificationsSendResponse",
671
675
  # Parts
676
+ "ParentPartFilter",
672
677
  "Part",
673
678
  "PartSummary",
674
679
  "PartType",
@@ -682,6 +687,9 @@ __all__ = [
682
687
  "PartsUpdateResponse",
683
688
  "PartsDeleteRequest",
684
689
  "PartsDeleteResponse",
690
+ "PartsMoveRequest",
691
+ "PartsMovePlan",
692
+ "PartsMoveResult",
685
693
  # Preferences
686
694
  "Preferences",
687
695
  "PreferencesGetRequest",
devrev/models/parts.py CHANGED
@@ -11,6 +11,7 @@ from devrev.models.base import (
11
11
  DevRevBaseModel,
12
12
  DevRevResponseModel,
13
13
  PaginatedResponse,
14
+ TagWithValue,
14
15
  UserSummary,
15
16
  )
16
17
 
@@ -33,6 +34,7 @@ class Part(DevRevResponseModel):
33
34
  type: PartType | None = Field(default=None, description="Part type")
34
35
  description: str | None = Field(default=None, description="Description")
35
36
  owned_by: list[UserSummary] | None = Field(default=None, description="Owners")
37
+ tags: list[TagWithValue] | None = Field(default=None, description="Tags")
36
38
  created_date: datetime | None = Field(default=None, description="Creation date")
37
39
  modified_date: datetime | None = Field(default=None, description="Last modified")
38
40
 
@@ -87,12 +89,32 @@ class PartsDeleteRequest(DevRevBaseModel):
87
89
  id: str = Field(..., description="Part ID to delete")
88
90
 
89
91
 
92
+ class ParentPartFilter(DevRevBaseModel):
93
+ """Hierarchy filter for ``parts.list`` to fetch parts under given parents.
94
+
95
+ Mirrors the DevRev ``parent_part`` query filter. ``parts`` lists the parent
96
+ part IDs to fetch the hierarchy for (required), and ``level`` optionally
97
+ bounds how many levels of the hierarchy to return (``level=1`` returns the
98
+ direct children of the given parents).
99
+ """
100
+
101
+ parts: list[str] = Field(
102
+ ..., min_length=1, description="Parent part IDs to fetch the hierarchy for"
103
+ )
104
+ level: int | None = Field(
105
+ default=None, ge=1, description="Number of levels of the hierarchy to fetch"
106
+ )
107
+
108
+
90
109
  class PartsListRequest(DevRevBaseModel):
91
110
  """Request to list parts."""
92
111
 
93
112
  cursor: str | None = Field(default=None, description="Pagination cursor")
94
113
  limit: int | None = Field(default=None, ge=1, le=100, description="Max results")
95
114
  type: list[PartType] | None = Field(default=None, description="Filter by type")
115
+ parent_part: ParentPartFilter | None = Field(
116
+ default=None, description="Filter by parent part hierarchy"
117
+ )
96
118
 
97
119
 
98
120
  class PartsUpdateRequest(DevRevBaseModel):
@@ -131,3 +153,75 @@ class PartsDeleteResponse(DevRevResponseModel):
131
153
  """Response from deleting a part."""
132
154
 
133
155
  pass
156
+
157
+
158
+ class PartsMoveRequest(DevRevBaseModel):
159
+ """Request to move (re-parent) a part.
160
+
161
+ The DevRev REST API only accepts ``parent_part`` on ``parts.create``, not
162
+ ``parts.update``. This SDK-level convenience request describes a higher-level
163
+ "move" operation that re-parents a part by recreating it under a new parent
164
+ and relinking its dependents. It is NOT a direct DevRev API request body.
165
+ """
166
+
167
+ id: str = Field(..., description="ID of the part to move (re-parent)")
168
+ new_parent_part: str = Field(
169
+ ..., description="Target parent part ID under which the part should be moved"
170
+ )
171
+ dry_run: bool = Field(
172
+ default=False,
173
+ description="If True, compute and return the move plan without executing it",
174
+ )
175
+
176
+
177
+ class PartsMovePlan(DevRevResponseModel):
178
+ """Plan describing what a part move (re-parent) would do.
179
+
180
+ Computed before (or instead of, for a dry run) executing a move so callers
181
+ can review the dependents that will be relinked or re-parented.
182
+ """
183
+
184
+ source_part_id: str = Field(..., description="ID of the part to be moved")
185
+ source_part_type: str | None = Field(
186
+ default=None, description="Type of the source part (product, capability, etc.)"
187
+ )
188
+ new_parent_part: str = Field(
189
+ ..., description="Target parent part ID the source will be moved under"
190
+ )
191
+ work_items_to_relink: list[str] = Field(
192
+ default_factory=list,
193
+ description="IDs of work items currently applies_to the source part that will be relinked",
194
+ )
195
+ child_parts_to_reparent: list[str] = Field(
196
+ default_factory=list,
197
+ description="IDs of child parts of the source that will be re-parented",
198
+ )
199
+ will_delete_source: bool = Field(
200
+ default=False,
201
+ description="Whether the original source part will be deleted after the move",
202
+ )
203
+
204
+
205
+ class PartsMoveResult(DevRevResponseModel):
206
+ """Outcome of an executed (or dry-run) part move (re-parent) operation."""
207
+
208
+ new_part_id: str = Field(..., description="ID of the newly created part under the new parent")
209
+ source_part_id: str = Field(..., description="ID of the original source part that was moved")
210
+ relinked_work_items: list[str] = Field(
211
+ default_factory=list,
212
+ description="IDs of work items that were relinked from the source to the new part",
213
+ )
214
+ reparented_children: list[str] = Field(
215
+ default_factory=list,
216
+ description="IDs of child parts that were re-parented under the new part",
217
+ )
218
+ source_deleted: bool = Field(
219
+ default=False, description="Whether the original source part was deleted"
220
+ )
221
+ dry_run: bool = Field(
222
+ default=False,
223
+ description="Whether this result describes a dry run (no changes were applied)",
224
+ )
225
+ plan: PartsMovePlan = Field(
226
+ ..., description="The move plan that was computed for this operation"
227
+ )
devrev/services/parts.py CHANGED
@@ -2,7 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from typing import TYPE_CHECKING
6
+
7
+ from devrev.exceptions import DevRevError
8
+ from devrev.models.base import TagWithValue, UserSummary
5
9
  from devrev.models.parts import (
10
+ ParentPartFilter,
6
11
  Part,
7
12
  PartsCreateRequest,
8
13
  PartsCreateResponse,
@@ -12,11 +17,47 @@ from devrev.models.parts import (
12
17
  PartsGetResponse,
13
18
  PartsListRequest,
14
19
  PartsListResponse,
20
+ PartsMovePlan,
21
+ PartsMoveRequest,
22
+ PartsMoveResult,
15
23
  PartsUpdateRequest,
16
24
  PartsUpdateResponse,
17
25
  )
18
26
  from devrev.services.base import AsyncBaseService, BaseService
19
27
 
28
+ if TYPE_CHECKING:
29
+ from devrev.client import AsyncDevRevClient, DevRevClient
30
+
31
+ # Module-level type alias. Declared here so method-scoped annotations can refer
32
+ # to ``list[str]`` without colliding with the ``list`` method defined on
33
+ # :class:`PartsService` / :class:`AsyncPartsService`.
34
+ _StrList = list[str]
35
+
36
+ # Sentinel returned as ``new_part_id`` for a dry-run move: no part is created,
37
+ # so there is no real ID to report.
38
+ _DRY_RUN_NEW_PART_ID = ""
39
+
40
+
41
+ def _owned_by_ids(owned_by: list[UserSummary] | None) -> list[str]:
42
+ """Map a part's ``owned_by`` UserSummary list to a list of owner IDs."""
43
+ if not owned_by:
44
+ return []
45
+ return [owner.id for owner in owned_by]
46
+
47
+
48
+ def _tag_ids(tags: list[TagWithValue] | None) -> list[str]:
49
+ """Map a part's ``tags`` to a list of tag ID strings for recreation.
50
+
51
+ ``TagWithValue.tag`` is a full ``Tag`` object in responses but a tag ID
52
+ string in requests; this normalizes both forms to the ID string.
53
+ """
54
+ if not tags:
55
+ return []
56
+ ids: list[str] = []
57
+ for tag in tags:
58
+ ids.append(tag.tag if isinstance(tag.tag, str) else tag.tag.id)
59
+ return ids
60
+
20
61
 
21
62
  class PartsService(BaseService):
22
63
  """Service for managing DevRev Parts."""
@@ -36,17 +77,21 @@ class PartsService(BaseService):
36
77
  *,
37
78
  limit: int | None = None,
38
79
  cursor: str | None = None,
80
+ parent_part: ParentPartFilter | None = None,
39
81
  ) -> PartsListResponse:
40
82
  """List parts.
41
83
 
42
84
  Args:
43
85
  limit: Maximum number of results to return (1-100).
44
86
  cursor: Pagination cursor from previous response.
87
+ parent_part: Hierarchy filter to return parts under given parents
88
+ (e.g. ``ParentPartFilter(parts=[parent_id], level=1)`` for the
89
+ direct children of ``parent_id``).
45
90
 
46
91
  Returns:
47
92
  PartsListResponse with parts and next_cursor for pagination.
48
93
  """
49
- request = PartsListRequest(limit=limit, cursor=cursor)
94
+ request = PartsListRequest(limit=limit, cursor=cursor, parent_part=parent_part)
50
95
  return self._post("/parts.list", request, PartsListResponse)
51
96
 
52
97
  def update(self, request: PartsUpdateRequest) -> Part:
@@ -58,6 +103,198 @@ class PartsService(BaseService):
58
103
  """Delete a part."""
59
104
  self._post("/parts.delete", request, PartsDeleteResponse)
60
105
 
106
+ def _list_child_part_ids(self, source_id: str) -> _StrList:
107
+ """Return the IDs of the direct child parts of ``source_id``.
108
+
109
+ Pages through ``parts.list`` with a ``parent_part`` hierarchy filter
110
+ (level 1). The source part itself is excluded from the result.
111
+ """
112
+ child_ids: _StrList = []
113
+ cursor: str | None = None
114
+ while True:
115
+ page = self.list(
116
+ cursor=cursor,
117
+ parent_part=ParentPartFilter(parts=[source_id], level=1),
118
+ )
119
+ for part in page.parts:
120
+ if part.id != source_id:
121
+ child_ids.append(part.id)
122
+ if not page.next_cursor:
123
+ break
124
+ cursor = page.next_cursor
125
+ return child_ids
126
+
127
+ def _collect_descendant_ids(self, source_id: str) -> set[str]:
128
+ """Return the IDs of every descendant of ``source_id`` (whole subtree).
129
+
130
+ Performs a bounded breadth-first walk over the part hierarchy using the
131
+ existing ``_list_child_part_ids`` (level-1) lookup at each node. A
132
+ ``visited`` set guards against re-processing a node, so a malformed
133
+ hierarchy that contains a cycle cannot cause an infinite loop. The
134
+ source itself is never included in the returned set.
135
+ """
136
+ descendants: set[str] = set()
137
+ visited: set[str] = {source_id}
138
+ frontier: _StrList = [source_id]
139
+ while frontier:
140
+ current = frontier.pop()
141
+ for child_id in self._list_child_part_ids(current):
142
+ if child_id in visited:
143
+ continue
144
+ visited.add(child_id)
145
+ descendants.add(child_id)
146
+ frontier.append(child_id)
147
+ return descendants
148
+
149
+ def _validate_move_target(self, source: Part, request: PartsMoveRequest) -> None:
150
+ """Reject self-moves and cycles before any mutation occurs.
151
+
152
+ A move whose ``new_parent_part`` is the source itself (self-move) or a
153
+ descendant of the source (cycle) would, under the recreate-and-relink
154
+ workaround, create the replacement part under the soon-to-be-deleted
155
+ source and then delete it -- destroying the new part (data loss). This
156
+ guard runs read-only lookups only and raises before create/relink/delete.
157
+ """
158
+ if request.new_parent_part == source.id:
159
+ raise DevRevError(
160
+ f"Cannot move part {source.id!r} under itself "
161
+ f"(new_parent_part == id): a self-move would orphan and delete "
162
+ "the recreated part."
163
+ )
164
+ if request.new_parent_part in self._collect_descendant_ids(source.id):
165
+ raise DevRevError(
166
+ f"Cannot move part {source.id!r} under its own descendant "
167
+ f"{request.new_parent_part!r}: this would create a cycle and "
168
+ "destroy the recreated part when the source is deleted."
169
+ )
170
+
171
+ def _list_applies_to_work_ids(self, source_id: str) -> _StrList:
172
+ """Return the IDs of all work items that apply to ``source_id``.
173
+
174
+ Uses the works service (via the parent client) and paginates fully.
175
+ """
176
+ works = self._require_parent_client().works
177
+ work_ids: _StrList = []
178
+ cursor: str | None = None
179
+ while True:
180
+ page = works.list(applies_to_part=[source_id], cursor=cursor)
181
+ work_ids.extend(work.id for work in page.works)
182
+ if not page.next_cursor:
183
+ break
184
+ cursor = page.next_cursor
185
+ return work_ids
186
+
187
+ def _require_parent_client(self) -> DevRevClient:
188
+ """Return the parent client, raising if it was not provided."""
189
+ if not self._parent_client:
190
+ raise DevRevError(
191
+ "move requires a parent client reference. "
192
+ "Ensure the parts service is accessed via DevRevClient.parts."
193
+ )
194
+ return self._parent_client
195
+
196
+ def _compute_move_plan(self, source: Part, request: PartsMoveRequest) -> PartsMovePlan:
197
+ """Compute the plan describing what moving ``source`` would do."""
198
+ return PartsMovePlan(
199
+ source_part_id=source.id,
200
+ source_part_type=source.type.value if source.type else None,
201
+ new_parent_part=request.new_parent_part,
202
+ work_items_to_relink=self._list_applies_to_work_ids(source.id),
203
+ child_parts_to_reparent=self._list_child_part_ids(source.id),
204
+ will_delete_source=not request.dry_run,
205
+ )
206
+
207
+ def move(self, request: PartsMoveRequest) -> PartsMoveResult:
208
+ """Move (re-parent) a part under a new parent part.
209
+
210
+ The DevRev REST API does not allow changing ``parent_part`` on
211
+ ``parts.update`` -- only ``parts.create`` accepts it. This method
212
+ therefore implements a recreate-and-relink workaround: it creates a new
213
+ part of the same type under ``new_parent_part`` (preserving name,
214
+ description, owners and tags), relinks every work item that applied to
215
+ the source onto the new part, re-parents the source's direct children,
216
+ and finally deletes the original source part.
217
+
218
+ Order of operations for a real move: create the new part first, relink
219
+ work items and re-parent children to the new part, then delete the
220
+ source last. If any relink or re-parent fails, the source part is left
221
+ intact (not deleted) so nothing is orphaned and the error is surfaced.
222
+
223
+ Before any mutation (and before computing the plan), the request is
224
+ validated against self-moves and cycles: ``new_parent_part`` may not be
225
+ the source itself nor any descendant of the source. Both checks run
226
+ read-only lookups only and apply to dry runs as well, since such a
227
+ request is invalid regardless of ``dry_run``.
228
+
229
+ Args:
230
+ request: The move request (source id, new parent id, dry_run flag).
231
+
232
+ Returns:
233
+ A fully-populated :class:`PartsMoveResult`. For a dry run,
234
+ ``new_part_id`` is an empty-string sentinel, ``source_deleted`` is
235
+ False, and ``plan`` describes what would happen with no mutations.
236
+
237
+ Raises:
238
+ DevRevError: If no parent client is available; if ``new_parent_part``
239
+ is the source itself (self-move) or one of its descendants
240
+ (cycle); or if a relink or re-parent fails (the source is not
241
+ deleted in that case).
242
+ """
243
+ source = self.get(PartsGetRequest(id=request.id))
244
+ self._validate_move_target(source, request)
245
+ plan = self._compute_move_plan(source, request)
246
+
247
+ if request.dry_run:
248
+ return PartsMoveResult(
249
+ new_part_id=_DRY_RUN_NEW_PART_ID,
250
+ source_part_id=source.id,
251
+ relinked_work_items=[],
252
+ reparented_children=[],
253
+ source_deleted=False,
254
+ dry_run=True,
255
+ plan=plan,
256
+ )
257
+
258
+ if source.type is None:
259
+ raise DevRevError(
260
+ f"Cannot move part {source.id!r}: its type is unknown, "
261
+ "so the replacement part cannot be created with the same type."
262
+ )
263
+
264
+ new_part = self.create(
265
+ PartsCreateRequest(
266
+ name=source.name,
267
+ type=source.type,
268
+ description=source.description,
269
+ owned_by=_owned_by_ids(source.owned_by) or None,
270
+ parent_part=[request.new_parent_part],
271
+ tags=_tag_ids(source.tags) or None,
272
+ )
273
+ )
274
+
275
+ works = self._require_parent_client().works
276
+ relinked: _StrList = []
277
+ for work_id in plan.work_items_to_relink:
278
+ works.update(work_id, applies_to_part=new_part.id)
279
+ relinked.append(work_id)
280
+
281
+ reparented: _StrList = []
282
+ for child_id in plan.child_parts_to_reparent:
283
+ self.move(PartsMoveRequest(id=child_id, new_parent_part=new_part.id, dry_run=False))
284
+ reparented.append(child_id)
285
+
286
+ self.delete(PartsDeleteRequest(id=source.id))
287
+
288
+ return PartsMoveResult(
289
+ new_part_id=new_part.id,
290
+ source_part_id=source.id,
291
+ relinked_work_items=relinked,
292
+ reparented_children=reparented,
293
+ source_deleted=True,
294
+ dry_run=False,
295
+ plan=plan,
296
+ )
297
+
61
298
 
62
299
  class AsyncPartsService(AsyncBaseService):
63
300
  """Async service for managing DevRev Parts."""
@@ -77,17 +314,21 @@ class AsyncPartsService(AsyncBaseService):
77
314
  *,
78
315
  limit: int | None = None,
79
316
  cursor: str | None = None,
317
+ parent_part: ParentPartFilter | None = None,
80
318
  ) -> PartsListResponse:
81
319
  """List parts.
82
320
 
83
321
  Args:
84
322
  limit: Maximum number of results to return (1-100).
85
323
  cursor: Pagination cursor from previous response.
324
+ parent_part: Hierarchy filter to return parts under given parents
325
+ (e.g. ``ParentPartFilter(parts=[parent_id], level=1)`` for the
326
+ direct children of ``parent_id``).
86
327
 
87
328
  Returns:
88
329
  PartsListResponse with parts and next_cursor for pagination.
89
330
  """
90
- request = PartsListRequest(limit=limit, cursor=cursor)
331
+ request = PartsListRequest(limit=limit, cursor=cursor, parent_part=parent_part)
91
332
  return await self._post("/parts.list", request, PartsListResponse)
92
333
 
93
334
  async def update(self, request: PartsUpdateRequest) -> Part:
@@ -98,3 +339,197 @@ class AsyncPartsService(AsyncBaseService):
98
339
  async def delete(self, request: PartsDeleteRequest) -> None:
99
340
  """Delete a part."""
100
341
  await self._post("/parts.delete", request, PartsDeleteResponse)
342
+
343
+ async def _list_child_part_ids(self, source_id: str) -> _StrList:
344
+ """Return the IDs of the direct child parts of ``source_id``.
345
+
346
+ Pages through ``parts.list`` with a ``parent_part`` hierarchy filter
347
+ (level 1). The source part itself is excluded from the result.
348
+ """
349
+ child_ids: _StrList = []
350
+ cursor: str | None = None
351
+ while True:
352
+ page = await self.list(
353
+ cursor=cursor,
354
+ parent_part=ParentPartFilter(parts=[source_id], level=1),
355
+ )
356
+ for part in page.parts:
357
+ if part.id != source_id:
358
+ child_ids.append(part.id)
359
+ if not page.next_cursor:
360
+ break
361
+ cursor = page.next_cursor
362
+ return child_ids
363
+
364
+ async def _collect_descendant_ids(self, source_id: str) -> set[str]:
365
+ """Return the IDs of every descendant of ``source_id`` (whole subtree).
366
+
367
+ Performs a bounded breadth-first walk over the part hierarchy using the
368
+ existing ``_list_child_part_ids`` (level-1) lookup at each node. A
369
+ ``visited`` set guards against re-processing a node, so a malformed
370
+ hierarchy that contains a cycle cannot cause an infinite loop. The
371
+ source itself is never included in the returned set.
372
+ """
373
+ descendants: set[str] = set()
374
+ visited: set[str] = {source_id}
375
+ frontier: _StrList = [source_id]
376
+ while frontier:
377
+ current = frontier.pop()
378
+ for child_id in await self._list_child_part_ids(current):
379
+ if child_id in visited:
380
+ continue
381
+ visited.add(child_id)
382
+ descendants.add(child_id)
383
+ frontier.append(child_id)
384
+ return descendants
385
+
386
+ async def _validate_move_target(self, source: Part, request: PartsMoveRequest) -> None:
387
+ """Reject self-moves and cycles before any mutation occurs.
388
+
389
+ A move whose ``new_parent_part`` is the source itself (self-move) or a
390
+ descendant of the source (cycle) would, under the recreate-and-relink
391
+ workaround, create the replacement part under the soon-to-be-deleted
392
+ source and then delete it -- destroying the new part (data loss). This
393
+ guard runs read-only lookups only and raises before create/relink/delete.
394
+ """
395
+ if request.new_parent_part == source.id:
396
+ raise DevRevError(
397
+ f"Cannot move part {source.id!r} under itself "
398
+ f"(new_parent_part == id): a self-move would orphan and delete "
399
+ "the recreated part."
400
+ )
401
+ if request.new_parent_part in await self._collect_descendant_ids(source.id):
402
+ raise DevRevError(
403
+ f"Cannot move part {source.id!r} under its own descendant "
404
+ f"{request.new_parent_part!r}: this would create a cycle and "
405
+ "destroy the recreated part when the source is deleted."
406
+ )
407
+
408
+ async def _list_applies_to_work_ids(self, source_id: str) -> _StrList:
409
+ """Return the IDs of all work items that apply to ``source_id``.
410
+
411
+ Uses the works service (via the parent client) and paginates fully.
412
+ """
413
+ works = self._require_parent_client().works
414
+ work_ids: _StrList = []
415
+ cursor: str | None = None
416
+ while True:
417
+ page = await works.list(applies_to_part=[source_id], cursor=cursor)
418
+ work_ids.extend(work.id for work in page.works)
419
+ if not page.next_cursor:
420
+ break
421
+ cursor = page.next_cursor
422
+ return work_ids
423
+
424
+ def _require_parent_client(self) -> AsyncDevRevClient:
425
+ """Return the parent client, raising if it was not provided."""
426
+ if not self._parent_client:
427
+ raise DevRevError(
428
+ "move requires a parent client reference. "
429
+ "Ensure the parts service is accessed via AsyncDevRevClient.parts."
430
+ )
431
+ return self._parent_client
432
+
433
+ async def _compute_move_plan(self, source: Part, request: PartsMoveRequest) -> PartsMovePlan:
434
+ """Compute the plan describing what moving ``source`` would do."""
435
+ return PartsMovePlan(
436
+ source_part_id=source.id,
437
+ source_part_type=source.type.value if source.type else None,
438
+ new_parent_part=request.new_parent_part,
439
+ work_items_to_relink=await self._list_applies_to_work_ids(source.id),
440
+ child_parts_to_reparent=await self._list_child_part_ids(source.id),
441
+ will_delete_source=not request.dry_run,
442
+ )
443
+
444
+ async def move(self, request: PartsMoveRequest) -> PartsMoveResult:
445
+ """Move (re-parent) a part under a new parent part.
446
+
447
+ The DevRev REST API does not allow changing ``parent_part`` on
448
+ ``parts.update`` -- only ``parts.create`` accepts it. This method
449
+ therefore implements a recreate-and-relink workaround: it creates a new
450
+ part of the same type under ``new_parent_part`` (preserving name,
451
+ description, owners and tags), relinks every work item that applied to
452
+ the source onto the new part, re-parents the source's direct children,
453
+ and finally deletes the original source part.
454
+
455
+ Order of operations for a real move: create the new part first, relink
456
+ work items and re-parent children to the new part, then delete the
457
+ source last. If any relink or re-parent fails, the source part is left
458
+ intact (not deleted) so nothing is orphaned and the error is surfaced.
459
+
460
+ Before any mutation (and before computing the plan), the request is
461
+ validated against self-moves and cycles: ``new_parent_part`` may not be
462
+ the source itself nor any descendant of the source. Both checks run
463
+ read-only lookups only and apply to dry runs as well, since such a
464
+ request is invalid regardless of ``dry_run``.
465
+
466
+ Args:
467
+ request: The move request (source id, new parent id, dry_run flag).
468
+
469
+ Returns:
470
+ A fully-populated :class:`PartsMoveResult`. For a dry run,
471
+ ``new_part_id`` is an empty-string sentinel, ``source_deleted`` is
472
+ False, and ``plan`` describes what would happen with no mutations.
473
+
474
+ Raises:
475
+ DevRevError: If no parent client is available; if ``new_parent_part``
476
+ is the source itself (self-move) or one of its descendants
477
+ (cycle); or if a relink or re-parent fails (the source is not
478
+ deleted in that case).
479
+ """
480
+ source = await self.get(PartsGetRequest(id=request.id))
481
+ await self._validate_move_target(source, request)
482
+ plan = await self._compute_move_plan(source, request)
483
+
484
+ if request.dry_run:
485
+ return PartsMoveResult(
486
+ new_part_id=_DRY_RUN_NEW_PART_ID,
487
+ source_part_id=source.id,
488
+ relinked_work_items=[],
489
+ reparented_children=[],
490
+ source_deleted=False,
491
+ dry_run=True,
492
+ plan=plan,
493
+ )
494
+
495
+ if source.type is None:
496
+ raise DevRevError(
497
+ f"Cannot move part {source.id!r}: its type is unknown, "
498
+ "so the replacement part cannot be created with the same type."
499
+ )
500
+
501
+ new_part = await self.create(
502
+ PartsCreateRequest(
503
+ name=source.name,
504
+ type=source.type,
505
+ description=source.description,
506
+ owned_by=_owned_by_ids(source.owned_by) or None,
507
+ parent_part=[request.new_parent_part],
508
+ tags=_tag_ids(source.tags) or None,
509
+ )
510
+ )
511
+
512
+ works = self._require_parent_client().works
513
+ relinked: _StrList = []
514
+ for work_id in plan.work_items_to_relink:
515
+ await works.update(work_id, applies_to_part=new_part.id)
516
+ relinked.append(work_id)
517
+
518
+ reparented: _StrList = []
519
+ for child_id in plan.child_parts_to_reparent:
520
+ await self.move(
521
+ PartsMoveRequest(id=child_id, new_parent_part=new_part.id, dry_run=False)
522
+ )
523
+ reparented.append(child_id)
524
+
525
+ await self.delete(PartsDeleteRequest(id=source.id))
526
+
527
+ return PartsMoveResult(
528
+ new_part_id=new_part.id,
529
+ source_part_id=source.id,
530
+ relinked_work_items=relinked,
531
+ reparented_children=reparented,
532
+ source_deleted=True,
533
+ dry_run=False,
534
+ plan=plan,
535
+ )
devrev/services/works.py CHANGED
@@ -194,6 +194,7 @@ class WorksService(BaseService):
194
194
  *,
195
195
  title: str | None = None,
196
196
  body: str | None = None,
197
+ applies_to_part: str | None = None,
197
198
  owned_by: Sequence[str] | None = None,
198
199
  stage: StageUpdate | None = None,
199
200
  priority: IssuePriority | None = None,
@@ -206,6 +207,7 @@ class WorksService(BaseService):
206
207
  id: Work item ID
207
208
  title: New title
208
209
  body: New body/description
210
+ applies_to_part: New part ID this work applies to
209
211
  owned_by: New owner IDs
210
212
  stage: New stage
211
213
  priority: New priority (for issues)
@@ -220,6 +222,7 @@ class WorksService(BaseService):
220
222
  id=id,
221
223
  title=title,
222
224
  body=body,
225
+ applies_to_part=applies_to_part,
223
226
  owned_by=owned_by_update,
224
227
  stage=stage,
225
228
  priority=priority,
@@ -471,16 +474,31 @@ class AsyncWorksService(AsyncBaseService):
471
474
  *,
472
475
  title: str | None = None,
473
476
  body: str | None = None,
477
+ applies_to_part: str | None = None,
474
478
  owned_by: Sequence[str] | None = None,
475
479
  priority: IssuePriority | None = None,
476
480
  severity: TicketSeverity | None = None,
477
481
  ) -> Work:
478
- """Update a work item."""
482
+ """Update a work item.
483
+
484
+ Args:
485
+ id: Work item ID
486
+ title: New title
487
+ body: New body/description
488
+ applies_to_part: New part ID this work applies to
489
+ owned_by: New owner IDs
490
+ priority: New priority (for issues)
491
+ severity: New severity (for tickets)
492
+
493
+ Returns:
494
+ The updated Work item
495
+ """
479
496
  owned_by_update = WorksUpdateRequestOwnedBy(set=owned_by) if owned_by else None
480
497
  request = WorksUpdateRequest(
481
498
  id=id,
482
499
  title=title,
483
500
  body=body,
501
+ applies_to_part=applies_to_part,
484
502
  owned_by=owned_by_update,
485
503
  priority=priority,
486
504
  severity=severity,
devrev_mcp/tools/parts.py CHANGED
@@ -16,6 +16,7 @@ from devrev.models.parts import (
16
16
  PartsCreateRequest,
17
17
  PartsDeleteRequest,
18
18
  PartsGetRequest,
19
+ PartsMoveRequest,
19
20
  PartsUpdateRequest,
20
21
  PartType,
21
22
  )
@@ -191,3 +192,55 @@ if _config.enable_destructive_tools:
191
192
  return {"success": True, "message": f"Part {id} deleted successfully"}
192
193
  except DevRevError as e:
193
194
  raise RuntimeError(format_devrev_error(e)) from e
195
+
196
+ @mcp.tool()
197
+ async def devrev_parts_move(
198
+ ctx: Context[Any, Any, Any],
199
+ id: str,
200
+ new_parent_part: str,
201
+ dry_run: bool = False,
202
+ ) -> dict[str, Any]:
203
+ """Move (re-parent) a DevRev part under a new parent part.
204
+
205
+ This is NOT an in-place update. The DevRev REST API does not allow
206
+ changing a part's parent, so this operation RECREATES the part under the
207
+ new parent (giving it a NEW id), relinks dependents (work items that
208
+ apply to the source part and child parts of the source), and then, on a
209
+ real run, DELETES the original source part.
210
+
211
+ When ``dry_run`` is True, the move plan is computed and returned WITHOUT
212
+ mutating anything: no part is created, no dependents are relinked, and
213
+ the source part is not deleted. Use a dry run to review the impact
214
+ (``plan.work_items_to_relink``, ``plan.child_parts_to_reparent``,
215
+ ``plan.will_delete_source``) before performing a real move.
216
+
217
+ Args:
218
+ id: The ID of the part to move (re-parent). This part is recreated
219
+ under the new parent and, on a real run, deleted.
220
+ new_parent_part: The ID of the target parent part to move under.
221
+ dry_run: If True, return the computed move plan without applying any
222
+ changes. Defaults to False (perform the move).
223
+
224
+ Returns:
225
+ Dictionary containing the serialized move result, including the
226
+ new part ID, the original source part ID, the relinked work items,
227
+ the re-parented child parts, whether the source was deleted, the
228
+ dry-run flag, and the nested move plan.
229
+
230
+ Raises:
231
+ RuntimeError: If a provided ID is not a valid part DON ID, or if the
232
+ DevRev API call fails.
233
+ """
234
+ validate_don_id(id, "part", "devrev_parts_move")
235
+ validate_don_id(new_parent_part, "part", "devrev_parts_move")
236
+ app = ctx.request_context.lifespan_context
237
+ try:
238
+ request = PartsMoveRequest(
239
+ id=id,
240
+ new_parent_part=new_parent_part,
241
+ dry_run=dry_run,
242
+ )
243
+ result = await app.get_client().parts.move(request)
244
+ return serialize_model(result)
245
+ except DevRevError as e:
246
+ raise RuntimeError(format_devrev_error(e)) from e
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devrev-Python-SDK
3
- Version: 3.1.1
3
+ Version: 3.2.0
4
4
  Summary: A modern, type-safe Python SDK for the DevRev API
5
5
  Project-URL: Homepage, https://github.com/mgmonteleone/py-dev-rev
6
6
  Project-URL: Documentation, https://github.com/mgmonteleone/py-dev-rev
@@ -505,6 +505,57 @@ export_result = client.works.export(
505
505
  )
506
506
  ```
507
507
 
508
+ ### Managing Parts
509
+
510
+ ```python
511
+ from devrev.models.parts import PartsCreateRequest, PartsMoveRequest, PartType
512
+
513
+ # Create a feature under a capability
514
+ feature = client.parts.create(PartsCreateRequest(
515
+ name="Bulk import",
516
+ type=PartType.FEATURE,
517
+ parent_part=["don:core:dvrv-us-1:devo/1:part/1"],
518
+ ))
519
+
520
+ # Move (re-parent) a part under a new parent part
521
+ #
522
+ # The DevRev REST API only accepts `parent_part` on parts.create (not
523
+ # parts.update), so move() implements a recreate-and-relink workaround: it
524
+ # creates a new same-type part under the new parent (preserving name,
525
+ # description, owners and tags), relinks work items (applies_to_part) and child
526
+ # parts onto the new part, then deletes the original source part.
527
+ #
528
+ # CAVEAT: a real move is NOT an in-place update. It produces a NEW part id and
529
+ # DELETES the original part. Update any references you hold to the old id.
530
+
531
+ # Dry run first: returns the plan, mutates nothing.
532
+ plan = client.parts.move(PartsMoveRequest(
533
+ id=feature.id,
534
+ new_parent_part="don:core:dvrv-us-1:devo/1:part/2",
535
+ dry_run=True,
536
+ ))
537
+ print(plan.plan.work_items_to_relink) # work items that would be relinked
538
+ print(plan.plan.child_parts_to_reparent) # child parts that would be re-parented
539
+ print(plan.plan.will_delete_source) # True -> the source part will be deleted
540
+ assert plan.source_deleted is False # nothing was changed by the dry run
541
+
542
+ # Real move: creates the new part, relinks dependents, deletes the source.
543
+ result = client.parts.move(PartsMoveRequest(
544
+ id=feature.id,
545
+ new_parent_part="don:core:dvrv-us-1:devo/1:part/2",
546
+ ))
547
+ print(result.new_part_id) # the NEW part id (the source id is now deleted)
548
+ print(result.relinked_work_items)
549
+ print(result.reparented_children)
550
+ print(result.source_deleted) # True
551
+ ```
552
+
553
+ > **Note**: `move` requires the parts service be accessed via the client
554
+ > (`client.parts`) because it coordinates with the works service to relink work
555
+ > items. Via the MCP server, this is the `devrev_parts_move` tool, which is
556
+ > gated behind `MCP_ENABLE_DESTRUCTIVE_TOOLS=true` (it creates and deletes
557
+ > parts).
558
+
508
559
  ### Articles and Knowledge Base
509
560
 
510
561
  ```python
@@ -995,7 +1046,7 @@ All settings are configurable via environment variables (prefix `MCP_`):
995
1046
  | Users | 5 | dev list/get, rev list/get/create |
996
1047
  | Conversations | 6 | list, get, create, update, delete, export |
997
1048
  | Articles | 6 | list, get, create, update, delete, count |
998
- | Parts | 5 | list, get, create, update, delete |
1049
+ | Parts | 6 | list, get, create, update, delete, move |
999
1050
  | Tags | 5 | list, get, create, update, delete |
1000
1051
  | Groups | 8 | list, get, create, update, delete, add/remove member, count |
1001
1052
  | Timeline | 5 | list, get, create, update, delete |
@@ -1028,13 +1079,13 @@ py-devrev/
1028
1079
  │ ├── __main__.py # CLI entry point
1029
1080
  │ ├── server.py # FastMCP server setup & lifecycle
1030
1081
  │ ├── config.py # MCPServerConfig (pydantic-settings)
1031
- │ ├── tools/ # 78 MCP tools across 15 categories
1082
+ │ ├── tools/ # 79 MCP tools across 15 categories
1032
1083
  │ │ ├── works.py # Works: list, get, create, update, delete, count, search
1033
1084
  │ │ ├── accounts.py # Accounts: list, get, create, update, delete, merge
1034
1085
  │ │ ├── users.py # Users: list/get dev users, list/get rev users, whoami
1035
1086
  │ │ ├── conversations.py # Conversations: list, get, create, update, delete, export
1036
1087
  │ │ ├── articles.py # Articles: list, get, create, update, delete, count
1037
- │ │ ├── parts.py # Parts: list, get, create, update, delete
1088
+ │ │ ├── parts.py # Parts: list, get, create, update, delete, move
1038
1089
  │ │ ├── tags.py # Tags: list, get, create, update, delete
1039
1090
  │ │ ├── groups.py # Groups: CRUD + member management + count
1040
1091
  │ │ ├── timeline.py # Timeline: list, get, create, update, delete
@@ -1,9 +1,9 @@
1
1
  devrev/__init__.py,sha256=iBKIRvS10mFWpBgnMjQQiQR04rgb8ZyXVk1-7cYJikI,1555
2
- devrev/client.py,sha256=FXEadZd0NzBUzXXY60e4qnN2Osv5vHE_9rj8nPpVPxI,26458
2
+ devrev/client.py,sha256=WfbWOWJo34bBzh2FwAsJfrWMwHSPTHINYbDUI4eE-Dk,26498
3
3
  devrev/config.py,sha256=BKFhD8lMJvq-I9kpchiA50fYefVFhnNwcK9ReFogh4k,8122
4
4
  devrev/exceptions.py,sha256=Bs0pYgOgG26cyI785rdJeTR72gM-rXHaCEt24HAJzEk,7540
5
5
  devrev/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
- devrev/models/__init__.py,sha256=D9J3kwZrAUJijh4wuLbfvb7WbokL84_dRg93OxtT_k4,24033
6
+ devrev/models/__init__.py,sha256=0R2_Khol0gx7AYMKLLge5CMGKv2h6wCd31CQqG8-j_g,24209
7
7
  devrev/models/accounts.py,sha256=LrXAit2gIr34XyRO1T0fL6EwdXHsfJOyXwcegPnKnaE,6974
8
8
  devrev/models/articles.py,sha256=DgrZC_wtLsXIfilAvHJBoQkhbGDg3Vx5UX7h9MD63xA,16732
9
9
  devrev/models/artifacts.py,sha256=3iA58RyVlX0S0U5iID3Cnoc096Y70QfGqYfKEhxfE9g,4375
@@ -17,7 +17,7 @@ devrev/models/groups.py,sha256=oB3eCuX14po5E8WhqkuuC-GlC1WXEPVfpqB5tn4NZ-o,4486
17
17
  devrev/models/incidents.py,sha256=e8Wq7MyXeDPiYMCNvxPlZa4D939196fUaND-MYtBbcw,6305
18
18
  devrev/models/links.py,sha256=0jtIXn90WQgGrSrnR72AgM9lMVSK6xv9fXKzq2Eki-A,2963
19
19
  devrev/models/notifications.py,sha256=BjVGx6XR1rQbQPR1hz05j5wU-8VMqH2m02KZmMl3Z98,867
20
- devrev/models/parts.py,sha256=A1EjliQfmGfxGeg2NlrAGOXB5Qk8MCyhus_LmmTdakA,4233
20
+ devrev/models/parts.py,sha256=PcE_N3Gd9YRhBoXwgYVhXYsVtVMAzM_nIcGEQ3Ut2Hc,8003
21
21
  devrev/models/preferences.py,sha256=S98RoSJhSlBavzFotyqsx_r1LGS2WjRtmazN4yIQFwc,1806
22
22
  devrev/models/question_answers.py,sha256=lPgE-OXzxMkrQdQE4NPz8IRV0I5P8iJq7VVdpx4iovg,3606
23
23
  devrev/models/recommendations.py,sha256=4szM-gtm-oVN7lBqi1xtGKABJcdwTeFLF_iR1fUoImY,2727
@@ -50,7 +50,7 @@ devrev/services/groups.py,sha256=0VpOKTwHiRjrKXrO1kdHo8V_z2I02GphYQhyDFnImxg,496
50
50
  devrev/services/incidents.py,sha256=YhrSj89FTVXI7xEw_vm0SMR5iIrnvhwu4MRdQqqhBx8,8076
51
51
  devrev/services/links.py,sha256=e6ppPQzdIp2tkJ_VEpkNhACZUq2scu2EfCr1S7NPh9s,2266
52
52
  devrev/services/notifications.py,sha256=uZZrNfpfEFOMChoAeVZR-Ow2NtPBgAtZ5Qv2mPN6Fvw,2201
53
- devrev/services/parts.py,sha256=Ork8lMFTTIJ-MJhsHoktpzx6lFQlxeZvOroNXVk10c8,3195
53
+ devrev/services/parts.py,sha256=EQbuhDFIPKcCxljTTaLe5fo1gdwNhmmk0ZsBH8x8sj4,22120
54
54
  devrev/services/preferences.py,sha256=MXdBsPcAUcCO14hKrIjhHkFj_XD1OZbtTHnOGZaWTwg,3327
55
55
  devrev/services/question_answers.py,sha256=bDgbtjI3RCHjJLwI7JsSgvxNh7OITNQZrNigq9CqL8E,4332
56
56
  devrev/services/recommendations.py,sha256=_UV9BBlxwfrek69Q5LzSZRE2D43FR3BSWUhRMDHeXWM,1962
@@ -63,7 +63,7 @@ devrev/services/timeline_entries.py,sha256=6nbVPcWlE3-ohLhvkM421eqYS5ztKtHmGsPek
63
63
  devrev/services/track_events.py,sha256=lI4wXkWu3uUuXtuRg1MGNkTZ7B0Lc1PjM8Kw-6sUnWc,1439
64
64
  devrev/services/uoms.py,sha256=AA3ymoHj24FIbsZpYC4tg2elSdQ3iINTVOz7MraZcj8,8163
65
65
  devrev/services/webhooks.py,sha256=-TSkcaya1y48WB24_vHd-bqO5xSqxRsCLiilncNzQZU,3917
66
- devrev/services/works.py,sha256=hAK7ZUqLph5Db15p4RDbeRFLZUALAF5tCFOQSYytj_g,20456
66
+ devrev/services/works.py,sha256=wUVQ8EcBs7Ogn2x219G37HL_WTYI0TCdxn-KPOD3IaQ,21064
67
67
  devrev/utils/__init__.py,sha256=NOrbpkjDVLH8n9xf-xpZJiIIa_GVI_6vqTm3E8L3Udw,857
68
68
  devrev/utils/content_converter.py,sha256=emRBLiVoOfDGpPDzrMRnqQr4-QkqN13OdWlYOyU_LCg,28141
69
69
  devrev/utils/deprecation.py,sha256=7qB2Dx531oP7mNi7q2txOYsOKC9YwdHqlKPMFHOW9Ws,1275
@@ -101,7 +101,7 @@ devrev_mcp/tools/engagements.py,sha256=21zsMLlgY1FwKzSIxPf6XVdMY77p8NXrWZ7l8PRKI
101
101
  devrev_mcp/tools/groups.py,sha256=E29eptwl_1rbmMPPElcSblWs71t1XdvSZIl1UR8TZr0,8456
102
102
  devrev_mcp/tools/incidents.py,sha256=XO9FaZlLHCXULCFpMpCOftYNfvajH5PeXOkW2uUZ_Z4,7984
103
103
  devrev_mcp/tools/links.py,sha256=oma8sLKaz0i3BnQmdoXdneZJx62j2P5YHbLag2aLNkU,4230
104
- devrev_mcp/tools/parts.py,sha256=4Yw4EamM2Q0o7a7ejfwIjTEdvcaV5KUnzcloGMkJCdk,6499
104
+ devrev_mcp/tools/parts.py,sha256=nyEBYznHbhAzR0zPu0-f24zLRKrTKdXpb6luQ4cZ-_I,8906
105
105
  devrev_mcp/tools/question_answers.py,sha256=57p4bZVQ52wGseBAamI-2qJoiZVbMRo46NNuOtGGhOo,5272
106
106
  devrev_mcp/tools/recommendations.py,sha256=Cg1NeDYgIlWLrtFYuxGlvQLpy6uDm2HoG91yGoSRC8A,3014
107
107
  devrev_mcp/tools/rev_orgs.py,sha256=Uge-rORmFTHOD1hhYy7pDLaGSxtisJsnn5ccazldfpU,5074
@@ -118,7 +118,7 @@ devrev_mcp/utils/don_id.py,sha256=jbVKXW_BeKjPHhz1F9p14fNmrXP2R36rgWZqdC4QlOc,63
118
118
  devrev_mcp/utils/errors.py,sha256=5mRAo76rJvvEVi6b1ZokPxDtX5JKkptaqmiYDLCkwBE,2110
119
119
  devrev_mcp/utils/formatting.py,sha256=6JssG5x1BxjdgSiQ8Ou3H-9Wo3wgWTWmejsrGez4wKc,2431
120
120
  devrev_mcp/utils/pagination.py,sha256=EOUgL-ZdSToM1Q-ydXmjhibsef5K1u1g3CaS9K8I2fY,1286
121
- devrev_python_sdk-3.1.1.dist-info/METADATA,sha256=KuDpsWwhLZQ-048S8B7FchJkkjP1ATp3a2upRwVfxJM,40934
122
- devrev_python_sdk-3.1.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
123
- devrev_python_sdk-3.1.1.dist-info/entry_points.txt,sha256=XiV4J_yy0yzVZVxg7T66YERVIlqdPNp3O-NHTHkllqQ,63
124
- devrev_python_sdk-3.1.1.dist-info/RECORD,,
121
+ devrev_python_sdk-3.2.0.dist-info/METADATA,sha256=boi9nuE-abiG-LLrdoLGjLyJLWZ5u9Vb21rG_1jViQQ,43054
122
+ devrev_python_sdk-3.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
123
+ devrev_python_sdk-3.2.0.dist-info/entry_points.txt,sha256=XiV4J_yy0yzVZVxg7T66YERVIlqdPNp3O-NHTHkllqQ,63
124
+ devrev_python_sdk-3.2.0.dist-info/RECORD,,