diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ebd3ecb..01f3e03 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2025-05-02 "Segment Deletion" - version 1.10.0 + +### Added +- Add segment deletion endpoints to `AssetSpec`: + - `bulk_delete_segments()`: Delete multiple segments by ID or type using `DELETE /v1/assets/{asset_id}/segments/bulk/`. + - `delete_segment()`: Delete a single segment by ID using `DELETE /v1/assets/{asset_id}/segments/{segment_id}/`. +- Add `BulkDeleteSegmentsBody` model for bulk deletion requests. +- Add test coverage for segment deletion operations. + +### Technical Details +This update adds methods for deleting asset segments, both individually and in bulk. The implementation includes necessary Pydantic models for request bodies and corresponding unit tests to ensure functionality. + ## 2025-04-11 "FileSet Archive Field & SDK Fixes" - version 1.9.5 ### Added diff --git a/pythonik/models/assets/segments.py b/pythonik/models/assets/segments.py index 0537cd4..4632048 100644 --- a/pythonik/models/assets/segments.py +++ b/pythonik/models/assets/segments.py @@ -61,3 +61,10 @@ class SegmentBody(BaseModel): class SegmentResponse(SegmentBody): id: Optional[str] = "" + + +class BulkDeleteSegmentsBody(BaseModel): + """Request body for bulk deleting segments.""" + segment_ids: Optional[List[str]] = None + segment_type: Optional[str] = None + version_id: Optional[str] = None diff --git a/pythonik/specs/assets.py b/pythonik/specs/assets.py index 0b9fe73..a048c82 100644 --- a/pythonik/specs/assets.py +++ b/pythonik/specs/assets.py @@ -1,12 +1,16 @@ from typing import Union, Dict, Any from pythonik.models.assets.assets import Asset, AssetCreate, BulkDelete -from pythonik.models.assets.segments import SegmentBody, SegmentResponse +from pythonik.models.assets.segments import ( + SegmentBody, + SegmentResponse, + BulkDeleteSegmentsBody, +) from pythonik.models.assets.versions import ( AssetVersionCreate, AssetVersionResponse, AssetVersionFromAssetCreate, - AssetVersion + AssetVersion, ) from pythonik.models.base import Response from pythonik.specs.base import Spec @@ -24,12 +28,13 @@ VERSIONS_FROM_ASSET_URL = BASE + "/{}/versions/from/assets/{}/" BULK_DELETE_URL = DELETE_QUEUE + "/bulk/" PURGE_ALL_URL = DELETE_QUEUE + "/purge/all/" +BULK_DELETE_SEGMENTS_URL = SEGMENT_URL + "bulk/" class AssetSpec(Spec): server = "API/assets/" - def __init__(self, session, timeout=3, base_url: str ="https://app.iconik.io"): + def __init__(self, session, timeout=3, base_url: str = "https://app.iconik.io"): self._collection_spec = CollectionSpec(session=session, timeout=timeout) return super().__init__(session, timeout, base_url) @@ -69,7 +74,7 @@ def bulk_delete( body: Union[BulkDelete, Dict[str, Any]], permanently_delete=False, exclude_defaults: bool = True, - **kwargs + **kwargs, ) -> Response: """ Bulk delete objects. If `permanently_delete` is True, the objects are @@ -99,11 +104,7 @@ def bulk_delete( 403 User does not have permission """ json_data = self._prepare_model_data(body, exclude_defaults=exclude_defaults) - response = self._post( - BULK_DELETE_URL, - json=json_data, - **kwargs - ) + response = self._post(BULK_DELETE_URL, json=json_data, **kwargs) if permanently_delete: response = self.permanently_delete().response return self.parse_response(response, model=None) @@ -113,10 +114,10 @@ def partial_update_asset( asset_id: str, body: Union[Asset, Dict[str, Any]], exclude_defaults: bool = True, - **kwargs + **kwargs, ) -> Response: """Partially update an asset using PATCH - + Args: asset_id: The asset ID to update body: Asset data to update, either as Asset model or dict @@ -124,21 +125,17 @@ def partial_update_asset( **kwargs: Additional kwargs to pass to the request """ json_data = self._prepare_model_data(body, exclude_defaults=exclude_defaults) - response = self._patch( - GET_URL.format(asset_id), - json=json_data, - **kwargs - ) + response = self._patch(GET_URL.format(asset_id), json=json_data, **kwargs) return self.parse_response(response, Asset) def get(self, asset_id: str, **kwargs) -> Response: """ Get an iconik asset by id - + Args: asset_id: The asset ID to get **kwargs: Additional kwargs to pass to the request - + Returns: Response(model=Asset) """ resp = self._get(GET_URL.format(asset_id), **kwargs) @@ -148,24 +145,20 @@ def create( self, body: Union[AssetCreate, Dict[str, Any]], exclude_defaults: bool = True, - **kwargs + **kwargs, ) -> Response: """ Create a new asset - + Args: body: Asset creation parameters, either as AssetCreate model or dict exclude_defaults: Whether to exclude default values when dumping Pydantic models **kwargs: Additional kwargs to pass to the request - + Returns: Response(model=Asset) """ json_data = self._prepare_model_data(body, exclude_defaults=exclude_defaults) - response = self._post( - BASE, - json=json_data, - **kwargs - ) + response = self._post(BASE, json=json_data, **kwargs) return self.parse_response(response, Asset) def create_segment( @@ -173,11 +166,11 @@ def create_segment( asset_id: str, body: Union[SegmentBody, Dict[str, Any]], exclude_defaults: bool = True, - **kwargs + **kwargs, ) -> Response: """ Create a segment on an asset, such as a comment - + Args: asset_id: The asset ID to create segment for body: Segment data, either as SegmentBody model or dict @@ -185,11 +178,7 @@ def create_segment( **kwargs: Additional kwargs to pass to the request """ json_data = self._prepare_model_data(body, exclude_defaults=exclude_defaults) - resp = self._post( - SEGMENT_URL.format(asset_id), - json=json_data, - **kwargs - ) + resp = self._post(SEGMENT_URL.format(asset_id), json=json_data, **kwargs) return self.parse_response(resp, SegmentResponse) @@ -199,11 +188,11 @@ def update_segment( segment_id: str, body: Union[SegmentBody, Dict[str, Any]], exclude_defaults: bool = True, - **kwargs + **kwargs, ) -> Response: """ Update a segment on an asset - + Args: asset_id: The asset ID to update segment for segment_id: The segment ID to update @@ -217,9 +206,7 @@ def update_segment( """ json_data = self._prepare_model_data(body, exclude_defaults=exclude_defaults) resp = self._put( - SEGMENT_URL_UPDATE.format(asset_id, segment_id), - json=json_data, - **kwargs + SEGMENT_URL_UPDATE.format(asset_id, segment_id), json=json_data, **kwargs ) return self.parse_response(resp, SegmentResponse) @@ -230,11 +217,11 @@ def partial_update_segment( segment_id: str, body: Union[SegmentBody, Dict[str, Any]], exclude_defaults: bool = True, - **kwargs + **kwargs, ) -> Response: """ Partially update a segment on an asset - + Args: asset_id: The asset ID to update segment for segment_id: The segment ID to update @@ -247,23 +234,95 @@ def partial_update_segment( """ json_data = self._prepare_model_data(body, exclude_defaults=exclude_defaults) resp = self._patch( - SEGMENT_URL_UPDATE.format(asset_id, segment_id), - json=json_data, - **kwargs + SEGMENT_URL_UPDATE.format(asset_id, segment_id), json=json_data, **kwargs ) return self.parse_response(resp, SegmentResponse) + def bulk_delete_segments( + self, + asset_id: str, + body: Union[BulkDeleteSegmentsBody, Dict[str, Any]], + immediately: bool = True, + ignore_reindexing: bool = False, + exclude_defaults: bool = True, + **kwargs, + ) -> Response: + """ + Delete segments with either ids or by type. + + Args: + asset_id: The ID of the asset containing the segments. + body: Request body containing segment_ids or segment_type, and optionally version_id. + Can be BulkDeleteSegmentsBody model or dict. + immediately: If True, delete segments synchronously. If False, delete asynchronously. + ignore_reindexing: If True, skip reindexing after deletion. + exclude_defaults: Whether to exclude default values when dumping Pydantic models. + **kwargs: Additional kwargs to pass to the request. + + Returns: + Response with no data model (204 status code). + + Required roles: + - can_delete_segments + + Raises: + 400 Segment ids or segment type not provided correctly + 401 Token is invalid + 403 User does not have permission (Implicit from required roles) + 404 No segments found + """ + json_data = self._prepare_model_data(body, exclude_defaults=exclude_defaults) + params = {"immediately": immediately, "ignore_reindexing": ignore_reindexing} + response = self._delete( + BULK_DELETE_SEGMENTS_URL.format(asset_id), + json=json_data, + params=params, + **kwargs, + ) + # Expects 204 No Content on success + return self.parse_response(response, model=None) + + def delete_segment( + self, asset_id: str, segment_id: str, soft_delete: bool = True, **kwargs + ) -> Response: + """ + Delete a particular segment from an asset by id. + + Args: + asset_id: The ID of the asset containing the segment. + segment_id: The ID of the segment to delete. + soft_delete: Query parameter to control soft/hard delete. + **kwargs: Additional kwargs to pass to the request. + + Returns: + Response with no data model (204 status code). + + Required roles: + - can_delete_segments + + Raises: + 401 Token is invalid + 403 User does not have permission (Implicit from required roles) + 404 Segment not found + """ + params = {"soft_delete": soft_delete} + response = self._delete( + SEGMENT_URL_UPDATE.format(asset_id, segment_id), params=params, **kwargs + ) + # Expects 204 No Content on success + return self.parse_response(response, model=None) + def create_version( self, asset_id: str, body: Union[AssetVersionCreate, Dict[str, Any]], exclude_defaults: bool = True, - **kwargs + **kwargs, ) -> Response: """ Create a new version of an asset - + Args: asset_id: The ID of the asset to create a version for body: Version creation parameters, either as AssetVersionCreate model or dict @@ -277,11 +336,7 @@ def create_version( - can_write_versions """ json_data = self._prepare_model_data(body, exclude_defaults=exclude_defaults) - response = self._post( - VERSIONS_URL.format(asset_id), - json=json_data, - **kwargs - ) + response = self._post(VERSIONS_URL.format(asset_id), json=json_data, **kwargs) return self.parse_response(response, AssetVersionResponse) def create_version_from_asset( @@ -290,11 +345,11 @@ def create_version_from_asset( source_asset_id: str, body: Union[AssetVersionFromAssetCreate, Dict[str, Any]], exclude_defaults: bool = True, - **kwargs + **kwargs, ) -> Response: """ Create a new version of an asset from another asset - + Args: asset_id: The ID of the asset to create a version for source_asset_id: The ID of the source asset to create version from @@ -319,7 +374,7 @@ def create_version_from_asset( response = self._post( VERSIONS_FROM_ASSET_URL.format(asset_id, source_asset_id), json=json_data, - **kwargs + **kwargs, ) # Since this returns 202 with no content, we don't need a response model return self.parse_response(response, None) @@ -348,11 +403,7 @@ def delete(self, asset_id: str, **kwargs) -> Response: return self.parse_response(response, model=None) def partial_update_version( - self, - asset_id: str, - version_id: str, - body: AssetVersion, - **kwargs + self, asset_id: str, version_id: str, body: AssetVersion, **kwargs ) -> Response: """ Partially update an asset version. @@ -364,16 +415,12 @@ def partial_update_version( """ response = self._patch( VERSION_URL.format(asset_id, version_id), - json=self._prepare_model_data(body) + json=self._prepare_model_data(body), ) return self.parse_response(response, AssetVersionResponse) def update_version( - self, - asset_id: str, - version_id: str, - body: AssetVersion, - **kwargs + self, asset_id: str, version_id: str, body: AssetVersion, **kwargs ) -> Response: """ Update an asset version. @@ -385,19 +432,14 @@ def update_version( """ response = self._put( VERSION_URL.format(asset_id, version_id), - json=self._prepare_model_data(body) + json=self._prepare_model_data(body), ) return self.parse_response(response, AssetVersionResponse) - def promote_version( - self, - asset_id: str, - version_id: str, - **kwargs - ) -> Response: + def promote_version(self, asset_id: str, version_id: str, **kwargs) -> Response: """ Promote a particular asset version to latest version - + Args: asset_id: The asset ID to promote version for version_id: The version ID to promote @@ -414,20 +456,13 @@ def promote_version( 401 Token is invalid 404 Asset does not exist """ - response = self._put( - VERSION_PROMOTE_URL.format(asset_id, version_id), - **kwargs - ) + response = self._put(VERSION_PROMOTE_URL.format(asset_id, version_id), **kwargs) return self.parse_response(response, None) - def delete_old_versions( - self, - asset_id: str, - **kwargs - ) -> Response: + def delete_old_versions(self, asset_id: str, **kwargs) -> Response: """ Delete all asset versions except the latest one - + Args: asset_id: The asset ID to delete old versions for **kwargs: Additional kwargs to pass to the request @@ -444,22 +479,15 @@ def delete_old_versions( 403 Forbidden 404 Asset does not exist """ - response = self._delete( - VERSION_OLD_URL.format(asset_id), - **kwargs - ) + response = self._delete(VERSION_OLD_URL.format(asset_id), **kwargs) return self.parse_response(response, None) def delete_version( - self, - asset_id: str, - version_id: str, - hard_delete: bool = False, - **kwargs + self, asset_id: str, version_id: str, hard_delete: bool = False, **kwargs ) -> Response: """ Delete a particular asset version by id - + Args: asset_id: The asset ID to delete version from version_id: The version ID to delete @@ -480,8 +508,6 @@ def delete_version( """ params = {"hard_delete": hard_delete} if hard_delete else None response = self._delete( - VERSION_URL.format(asset_id, version_id), - params=params, - **kwargs + VERSION_URL.format(asset_id, version_id), params=params, **kwargs ) return self.parse_response(response, None) diff --git a/pythonik/tests/test_assets.py b/pythonik/tests/test_assets.py index 7fe4cce..6175709 100644 --- a/pythonik/tests/test_assets.py +++ b/pythonik/tests/test_assets.py @@ -15,7 +15,7 @@ AssetVersionFromAssetCreate, AssetVersion ) -from pythonik.models.assets.segments import SegmentBody, SegmentResponse +from pythonik.models.assets.segments import SegmentBody, SegmentResponse, BulkDeleteSegmentsBody from pythonik.specs.assets import ( BASE, GET_URL, @@ -29,6 +29,7 @@ BULK_DELETE_URL, SEGMENT_URL_UPDATE, VERSIONS_FROM_ASSET_URL, + BULK_DELETE_SEGMENTS_URL, ) @@ -377,3 +378,75 @@ def test_delete_version_hard(): version_id=version_id, hard_delete=True ) + + +def test_bulk_delete_segments(): + with requests_mock.Mocker() as m: + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + asset_id = str(uuid.uuid4()) + segment_ids = [str(uuid.uuid4()), str(uuid.uuid4())] + + model = BulkDeleteSegmentsBody(segment_ids=segment_ids) + data = model.model_dump(exclude_defaults=True) # Match method behaviour + mock_address = AssetSpec.gen_url(BULK_DELETE_SEGMENTS_URL.format(asset_id)) + expected_params = { + "immediately": ["true"], + "ignore_reindexing": ["false"] + } + + m.delete(mock_address, status_code=204) + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + + # Call the method with default parameters + client.assets().bulk_delete_segments( + asset_id=asset_id, + body=model, + immediately=True, # Default, testing explicit pass + ignore_reindexing=False # Default, testing explicit pass + ) + + # Verify request details + assert m.called + last_request = m.last_request + assert last_request.method == 'DELETE' + # Compare URLs ignoring query params first + assert last_request.url.split('?')[0] == mock_address + # Compare query params + assert last_request.qs == expected_params + # Compare request body + assert last_request.json() == data + + +def test_delete_segment(): + with requests_mock.Mocker() as m: + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + asset_id = str(uuid.uuid4()) + segment_id = str(uuid.uuid4()) + + mock_address = AssetSpec.gen_url(SEGMENT_URL_UPDATE.format(asset_id, segment_id)) + expected_params_soft = {"soft_delete": ["true"]} + expected_params_hard = {"soft_delete": ["false"]} + + # Mock both scenarios + m.delete(mock_address, status_code=204) + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + + # Test soft delete (default) + client.assets().delete_segment(asset_id=asset_id, segment_id=segment_id) + last_request_soft = m.last_request + assert last_request_soft.method == 'DELETE' + assert last_request_soft.url.split('?')[0] == mock_address + assert last_request_soft.qs == expected_params_soft + + # Reset history for the next call verification if needed or use call_count + call_count_before_hard = m.call_count + + # Test hard delete + client.assets().delete_segment(asset_id=asset_id, segment_id=segment_id, soft_delete=False) + assert m.call_count == call_count_before_hard + 1 # Ensure a new call was made + last_request_hard = m.last_request + assert last_request_hard.method == 'DELETE' + assert last_request_hard.url.split('?')[0] == mock_address + assert last_request_hard.qs == expected_params_hard