From eecc610ecdf8d3c1a30d682cb8bc5f23aa8e438f Mon Sep 17 00:00:00 2001 From: coffeepy Date: Fri, 30 May 2025 13:28:41 -0500 Subject: [PATCH 1/4] feat: implement metadata field management with get_field and get_fields methods --- pythonik/models/metadata/fields.py | 19 ++ pythonik/specs/metadata.py | 70 +++++- pythonik/tests/test_metadata.py | 339 ++++++++++++++++++++++++++++- 3 files changed, 423 insertions(+), 5 deletions(-) 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..f1c9293 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 get_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..f1be38d 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().get_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().get_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().get_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().get_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().get_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): From 762d2d988fbbf04b328c8cdf7a8a5b1da7fbb76b Mon Sep 17 00:00:00 2001 From: coffeepy Date: Fri, 30 May 2025 14:56:23 -0500 Subject: [PATCH 2/4] chore: update changelog --- docs/CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5e9c84b..914d439 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,21 @@ # 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 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 From be4cbb579abae2bfc1b7c6e4d5d53d3bce621cfc Mon Sep 17 00:00:00 2001 From: coffeepy Date: Fri, 30 May 2025 15:07:38 -0500 Subject: [PATCH 3/4] updating to list_fields --- pythonik/specs/metadata.py | 2 +- pythonik/tests/test_metadata.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pythonik/specs/metadata.py b/pythonik/specs/metadata.py index f1c9293..5409527 100644 --- a/pythonik/specs/metadata.py +++ b/pythonik/specs/metadata.py @@ -710,7 +710,7 @@ def delete_field( resp = self._delete(endpoint, **kwargs) return self.parse_response(resp) - def get_fields( + def list_fields( self, per_page: Optional[int] = None, last_field_name: Optional[str] = None, diff --git a/pythonik/tests/test_metadata.py b/pythonik/tests/test_metadata.py index f1be38d..b8b55df 100644 --- a/pythonik/tests/test_metadata.py +++ b/pythonik/tests/test_metadata.py @@ -1896,7 +1896,7 @@ def test_get_fields(requests_mock): client = PythonikClient( app_id=str(uuid.uuid4()), auth_token=str(uuid.uuid4()), timeout=3 ) - response = client.metadata().get_fields() + response = client.metadata().list_fields() # Verify the response assert response.response.status_code == 200 @@ -1970,7 +1970,7 @@ def test_get_fields_with_pagination(requests_mock): client = PythonikClient( app_id=str(uuid.uuid4()), auth_token=str(uuid.uuid4()), timeout=3 ) - response = client.metadata().get_fields( + response = client.metadata().list_fields( per_page=current_per_page, last_field_name=last_field_name_from_prev_page ) @@ -2042,7 +2042,7 @@ def test_get_fields_with_filter_param(requests_mock): client = PythonikClient( app_id=str(uuid.uuid4()), auth_token=str(uuid.uuid4()), timeout=3 ) - response = client.metadata().get_fields(filter=filter_names) + response = client.metadata().list_fields(filter=filter_names) # Verify the response assert response.response.status_code == 200 @@ -2061,7 +2061,7 @@ def test_get_fields_unauthorized(requests_mock): client = PythonikClient( app_id=str(uuid.uuid4()), auth_token="invalid-token", timeout=3 ) - response = client.metadata().get_fields() + response = client.metadata().list_fields() # Verify the response assert response.response.status_code == 401 @@ -2080,7 +2080,7 @@ def test_get_fields_empty(requests_mock): client = PythonikClient( app_id=str(uuid.uuid4()), auth_token=str(uuid.uuid4()), timeout=3 ) - response = client.metadata().get_fields() + response = client.metadata().list_fields() # Verify the response assert response.response.status_code == 200 From c8efe8d51cf15858eba7a56d5e83ed1bdf64c3e6 Mon Sep 17 00:00:00 2001 From: coffeepy Date: Fri, 30 May 2025 15:25:05 -0500 Subject: [PATCH 4/4] change log update --- docs/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 914d439..8c355c9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,8 @@ ### 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