diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5e9c84b..8c355c9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 2025-05-30 "Metadata Enhancements & SDK Improvements" - version 1.13.0 + +### Added +- Implemented `get_field(field_name: str)` method in `pythonik.specs.metadata.MetadataSpec` to retrieve a specific metadata field by its name. +- Added corresponding unit tests for `get_field` covering success (200), not found (404), and unauthorized (401) scenarios. +- Implemented `list_fields` method in `pythonik.specs.metadata.MetadataSpec` to retrieve a list of all metadata fields. +- Added corresponding unit tests for `list_fields` +- Implemented methods for retrieving metadata for specific object types (e.g., assets, collections, segments) and generic object metadata retrieval in `MetadataSpec` (PR #78). + +### Changed +- Updated `.gitignore` to include macOS-specific patterns (e.g., `.DS_Store`) for improved repository hygiene (PR #82). + +### Fixed +- N/A + +### Technical Details +This release introduces the ability to fetch individual metadata fields by name, adds comprehensive integration testing capabilities for metadata fields, and enhances object metadata retrieval. It also includes improvements to the development environment with an updated .gitignore. + ## 2025-05-09 "IconikFieldType Update" - version 1.12.2 ### Fixed diff --git a/pythonik/models/metadata/fields.py b/pythonik/models/metadata/fields.py index e32a029..d4b9260 100644 --- a/pythonik/models/metadata/fields.py +++ b/pythonik/models/metadata/fields.py @@ -111,3 +111,22 @@ class FieldResponse(BaseModel): class Config: use_enum_values = True + + +class FieldListResponse(BaseModel): + """Response model for a paginated list of metadata fields. + + This follows the standard pagination format used by the Iconik API. + """ + first_url: Optional[str] = None + last_url: Optional[str] = None + next_url: Optional[str] = None + objects: List[FieldResponse] = [] + page: Optional[int] = None + pages: Optional[int] = None + per_page: Optional[int] = None + prev_url: Optional[str] = None + total: Optional[int] = None + + class Config: + use_enum_values = True diff --git a/pythonik/specs/metadata.py b/pythonik/specs/metadata.py index 107b7cd..5409527 100644 --- a/pythonik/specs/metadata.py +++ b/pythonik/specs/metadata.py @@ -13,9 +13,14 @@ UpdateMetadata, UpdateMetadataResponse, ) -from pythonik.models.metadata.fields import FieldCreate, FieldUpdate, FieldResponse +from pythonik.models.metadata.fields import ( + FieldCreate, + FieldUpdate, + FieldResponse, + FieldListResponse, +) from pythonik.specs.base import Spec -from typing import Literal, Union, Dict, Any, List +from typing import Literal, Union, Dict, Any, List, Optional # Asset metadata paths @@ -634,6 +639,33 @@ def create_field( resp = self._post(FIELDS_BASE_PATH, json=json_data, **kwargs) return self.parse_response(resp, FieldResponse) + def get_field( + self, + field_name: str, + **kwargs, + ) -> Response: + """Retrieve a specific metadata field by its name. + + Args: + field_name: The name of the field to retrieve. + **kwargs: Additional kwargs to pass to the request. + + Required roles: + - can_read_metadata_fields + + Returns: + Response: An object containing the HTTP response and a `data` attribute + with a `FieldResponse` model instance on success, or `None` on error. + + Raises: + - 400 Bad request + - 401 Token is invalid + - 404 Metadata field doesn't exist + """ + endpoint = FIELD_BY_NAME_PATH.format(field_name=field_name) + resp = self._get(endpoint, **kwargs) + return self.parse_response(resp, FieldResponse) + def update_field( self, field_name: str, @@ -678,8 +710,38 @@ def delete_field( resp = self._delete(endpoint, **kwargs) return self.parse_response(resp) - # Backward compatibility aliases - # ------------------------------ + def list_fields( + self, + per_page: Optional[int] = None, + last_field_name: Optional[str] = None, + filter: Optional[str] = None, + **kwargs, + ) -> Response: + """List all metadata fields. + + Args: + per_page: Optional The number of items for each page (Default 500). + last_field_name: Optional If your request returns per_page entries, + send the last value of "name" to fetch next page. + filter: Optional A comma separated list of fieldnames to filter by. + **kwargs: Additional query parameters to pass to the request. + + Returns: + Response: A paginated response containing a list of FieldResponse objects. + """ + params = {} + if per_page is not None: + params["per_page"] = per_page + if last_field_name: + params["last_field_name"] = last_field_name + if filter: + params["filter"] = filter + + # Add any additional params from kwargs + params.update(kwargs) + + resp = self._get(FIELDS_BASE_PATH, params=params) + return self.parse_response(resp, FieldListResponse) def create_metadata_field( self, diff --git a/pythonik/tests/test_metadata.py b/pythonik/tests/test_metadata.py index 706b110..b8b55df 100644 --- a/pythonik/tests/test_metadata.py +++ b/pythonik/tests/test_metadata.py @@ -1607,6 +1607,86 @@ def test_delete_field_not_found_error(requests_mock): assert result.response.status_code == 404 +def test_get_field_success(requests_mock): + """Test successful retrieval of a metadata field by its name.""" + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + field_name_to_get = "my_test_field_get" + + expected_field_response = FieldResponse( + name=field_name_to_get, + label="My Test Field Get Label", + field_type="string", + options=[], + date_created="2025-02-01T00:00:00Z", + date_modified="2025-02-01T00:00:00Z", + required=False, + auto_set=False, + hide_if_not_set=False, + is_block_field=False, + is_warning_field=False, + multi=False, + read_only=False, + representative=False, + sortable=False, + use_as_facet=False, + ) + + mock_address = MetadataSpec.gen_url( + FIELD_BY_NAME_PATH.format(field_name=field_name_to_get) + ) + requests_mock.get( + mock_address, + json=json.loads(expected_field_response.model_dump_json()), + status_code=200, + ) + + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + result = client.metadata().get_field(field_name_to_get) + + assert result.response.ok + assert result.response.status_code == 200 + assert isinstance(result.data, FieldResponse) + assert result.data.name == field_name_to_get + assert result.data.label == "My Test Field Get Label" + + +def test_get_field_not_found(requests_mock): + """Test retrieving a non-existent metadata field (404).""" + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + non_existent_field_name = "i_do_not_exist_field" + + mock_address = MetadataSpec.gen_url( + FIELD_BY_NAME_PATH.format(field_name=non_existent_field_name) + ) + requests_mock.get(mock_address, json={"error": "not found"}, status_code=404) + + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + result = client.metadata().get_field(non_existent_field_name) + + assert not result.response.ok + assert result.response.status_code == 404 + + +def test_get_field_unauthorized(requests_mock): + """Test retrieving a metadata field with an unauthorized token (401).""" + app_id = str(uuid.uuid4()) + auth_token = "invalid_token" + field_name = "any_field_name" + + mock_address = MetadataSpec.gen_url( + FIELD_BY_NAME_PATH.format(field_name=field_name) + ) + requests_mock.get(mock_address, json={"error": "unauthorized"}, status_code=401) + + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + result = client.metadata().get_field(field_name) + + assert not result.response.ok + assert result.response.status_code == 401 + + @pytest.mark.parametrize("field_type_enum", list(IconikFieldType)) def test_create_field_for_all_types(requests_mock, field_type_enum: IconikFieldType): """Test creating a metadata field for each IconikFieldType using MetadataSpec.create_field.""" @@ -1751,8 +1831,265 @@ def test_create_field_with_unknown_type_raises_validation_error(requests_mock): assert f"'{unknown_type_string}'" in error_str or unknown_type_string in error_str +# Field listing tests +# ------------------- + + +def test_get_fields(requests_mock): + """Test listing all metadata fields using MetadataSpec.get_fields.""" + # Setup test data + field1 = { + "name": "test_field_1", + "label": "Test Field 1", + "field_type": "string", + "description": "First test field", + "required": True, + "auto_set": False, + "multi": False, + "read_only": False, + "representative": False, + "sortable": True, + "use_as_facet": False, + "hide_if_not_set": False, + "is_block_field": False, + "is_warning_field": False, + "date_created": "2023-01-01T00:00:00Z", + "date_modified": "2023-01-01T00:00:00Z", + } + + field2 = { + "name": "test_field_2", + "label": "Test Field 2", + "field_type": "integer", + "description": "Second test field", + "required": False, + "auto_set": True, + "multi": False, + "read_only": True, + "representative": True, + "sortable": False, + "use_as_facet": True, + "hide_if_not_set": True, + "is_block_field": False, + "is_warning_field": True, + "date_created": "2023-01-02T00:00:00Z", + "date_modified": "2023-01-02T00:00:00Z", + } + + response_data = { + "objects": [field1, field2], + "total": 2, + "page": 1, + "pages": 1, + "per_page": 50, + "first_url": "https://app.iconik.io/API/metadata/v1/fields/?page=1&per_page=50", + "last_url": "https://app.iconik.io/API/metadata/v1/fields/?page=1&per_page=50", + "next_url": None, + "prev_url": None, + } + + # Mock the API response + mock_url = f"{MetadataSpec.base_url}/API/metadata/v1/fields/" + requests_mock.get(mock_url, json=response_data) + + # Call the method + client = PythonikClient( + app_id=str(uuid.uuid4()), auth_token=str(uuid.uuid4()), timeout=3 + ) + response = client.metadata().list_fields() + + # Verify the response + assert response.response.status_code == 200 + assert len(response.data.objects) == 2 + assert response.data.objects[0].name == "test_field_1" + assert response.data.objects[0].field_type == "string" + assert response.data.objects[1].name == "test_field_2" + assert response.data.objects[1].field_type == "integer" + assert response.data.total == 2 + assert response.data.page == 1 + + +def test_get_fields_with_pagination(requests_mock): + """Test pagination parameters (per_page and last_field_name) for get_fields.""" + # Setup test data for the next page + last_field_name_from_prev_page = "field_on_prev_page" + current_per_page = 2 + + field_data_page_2_item_1 = { + "name": "paged_field_1", + "label": "Paged Field 1", + "field_type": "string", + "date_created": "2023-01-01T00:00:00Z", + "date_modified": "2023-01-01T00:00:00Z", + "auto_set": False, + "hide_if_not_set": False, + "is_block_field": False, + "is_warning_field": False, + "multi": False, + "read_only": False, + "representative": False, + "required": False, + "sortable": True, + "use_as_facet": False, + } + field_data_page_2_item_2 = { + "name": "paged_field_2", + "label": "Paged Field 2", + "field_type": "integer", + "date_created": "2023-01-02T00:00:00Z", + "date_modified": "2023-01-02T00:00:00Z", + "auto_set": True, + "hide_if_not_set": True, + "is_block_field": True, + "is_warning_field": True, + "multi": True, + "read_only": True, + "representative": True, + "required": True, + "sortable": False, + "use_as_facet": True, + } + + response_data = { + "objects": [field_data_page_2_item_1, field_data_page_2_item_2], + "total": 10, # Assuming 10 total fields for this example + "page": 2, # This is illustrative; API might not return page number with last_field_name + "pages": 5, # Illustrative + "per_page": current_per_page, + "first_url": f"{MetadataSpec.base_url}/API/metadata/v1/fields/?per_page={current_per_page}", + "last_url": f"{MetadataSpec.base_url}/API/metadata/v1/fields/?last_field_name=some_last_field&per_page={current_per_page}", # Illustrative + "next_url": f"{MetadataSpec.base_url}/API/metadata/v1/fields/?last_field_name=paged_field_2&per_page={current_per_page}", + "prev_url": f"{MetadataSpec.base_url}/API/metadata/v1/fields/?per_page={current_per_page}", # Actual prev might need different handling + } + + # Mock the API response with pagination parameters + mock_url = f"{MetadataSpec.base_url}/API/metadata/v1/fields/?per_page={current_per_page}&last_field_name={last_field_name_from_prev_page}" + requests_mock.get(mock_url, json=response_data) + + # Call the method with pagination + client = PythonikClient( + app_id=str(uuid.uuid4()), auth_token=str(uuid.uuid4()), timeout=3 + ) + response = client.metadata().list_fields( + per_page=current_per_page, last_field_name=last_field_name_from_prev_page + ) + + # Verify the response + assert response.response.status_code == 200 + assert len(response.data.objects) == 2 + assert response.data.objects[0].name == "paged_field_1" + assert response.data.objects[1].name == "paged_field_2" + assert response.data.per_page == current_per_page + # Note: 'page', 'pages', 'total' might behave differently with cursor pagination + # We're primarily testing that the SDK passes the params correctly and parses the response. + + +def test_get_fields_with_filter_param(requests_mock): + """Test filtering fields by a comma-separated list of field names.""" + # Setup test data + filter_names = "name_to_filter1,name_to_filter2" + + field_data1 = { + "name": "name_to_filter1", + "label": "Filtered Field 1", + "field_type": "string", + "date_created": "2023-01-01T00:00:00Z", + "date_modified": "2023-01-01T00:00:00Z", + "auto_set": False, + "hide_if_not_set": False, + "is_block_field": False, + "is_warning_field": False, + "multi": False, + "read_only": False, + "representative": False, + "required": False, + "sortable": True, + "use_as_facet": False, + } + field_data2 = { + "name": "name_to_filter2", + "label": "Filtered Field 2", + "field_type": "integer", + "date_created": "2023-01-02T00:00:00Z", + "date_modified": "2023-01-02T00:00:00Z", + "auto_set": True, + "hide_if_not_set": True, + "is_block_field": True, + "is_warning_field": True, + "multi": True, + "read_only": True, + "representative": True, + "required": True, + "sortable": False, + "use_as_facet": True, + } + + response_data = { + "objects": [field_data1, field_data2], + "total": 2, + "page": 1, # May not be present or accurate with 'filter' + "pages": 1, # May not be present or accurate with 'filter' + "per_page": 50, # Default, or what was requested + } + + # Mock the API response with filter parameter + expected_url = ( + f"{MetadataSpec.base_url}/API/metadata/v1/fields/?filter={filter_names}" + ) + requests_mock.get(expected_url, json=response_data) + + # Call the method with the filter + client = PythonikClient( + app_id=str(uuid.uuid4()), auth_token=str(uuid.uuid4()), timeout=3 + ) + response = client.metadata().list_fields(filter=filter_names) + + # Verify the response + assert response.response.status_code == 200 + assert len(response.data.objects) == 2 + assert response.data.objects[0].name == "name_to_filter1" + assert response.data.objects[1].name == "name_to_filter2" + + +def test_get_fields_unauthorized(requests_mock): + """Test that get_fields handles unauthorized access.""" + # Mock unauthorized response + mock_url = f"{MetadataSpec.base_url}/API/metadata/v1/fields/" + requests_mock.get(mock_url, status_code=401, json={"message": "Unauthorized"}) + + # Call the method and verify it raises an exception + client = PythonikClient( + app_id=str(uuid.uuid4()), auth_token="invalid-token", timeout=3 + ) + response = client.metadata().list_fields() + + # Verify the response + assert response.response.status_code == 401 + assert response.data is None + + +def test_get_fields_empty(requests_mock): + """Test get_fields with an empty result set.""" + # Mock empty response + response_data = {"objects": [], "total": 0, "page": 1, "pages": 0, "per_page": 50} + + mock_url = f"{MetadataSpec.base_url}/API/metadata/v1/fields/" + requests_mock.get(mock_url, json=response_data) + + # Call the method + client = PythonikClient( + app_id=str(uuid.uuid4()), auth_token=str(uuid.uuid4()), timeout=3 + ) + response = client.metadata().list_fields() + + # Verify the response + assert response.response.status_code == 200 + assert len(response.data.objects) == 0 + assert response.data.total == 0 + + # Backward compatibility alias tests -# --------------------------------- +# -------------------------------- def test_create_metadata_field_alias(requests_mock):