diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 01f3e03..6d993bc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 2025-05-08 "Metadata Field Management & Type Safety" - version 1.11.0 + +### Added +- **Metadata Field Management (in `pythonik.specs.metadata.MetadataSpec`):** + - `create_metadata_field()`: Added method to create new metadata fields. + - `update_metadata_field()`: Added method to update existing metadata fields by name. + - `delete_metadata_field()`: Added method to delete metadata fields by name. +- **Type Safety & Modeling (in `pythonik.models.metadata.fields`):** + - Defined `IconikFieldType` Enum to represent all known metadata field types from the Iconik API. + - Integrated `IconikFieldType` into `FieldCreate`, `FieldUpdate`, and `Field` Pydantic models for robust type validation and clarity. +- **Testing:** + - Added comprehensive test coverage for metadata field type handling and new CRUD operations: + - Parameterized test (`test_create_metadata_field_for_all_types`) for every defined `IconikFieldType`. + - Test (`test_create_metadata_field_with_unknown_type_raises_validation_error`) for API returning unrecognized `field_type`. + +### Technical Details +This update introduces full programmatic management of metadata fields, including creation, update, and deletion capabilities. A dedicated Enum (`IconikFieldType`) enhances type safety and developer experience when working with these fields. This Enum is integrated throughout the relevant Pydantic models and the new `MetadataSpec` methods. Comprehensive tests ensure correct handling of all defined field types, robust behavior against unknown types from the API, and functionality of the new CRUD operations. + ## 2025-05-02 "Segment Deletion" - version 1.10.0 ### Added diff --git a/pythonik/models/metadata/fields.py b/pythonik/models/metadata/fields.py new file mode 100644 index 0000000..ed65729 --- /dev/null +++ b/pythonik/models/metadata/fields.py @@ -0,0 +1,73 @@ +# pythonik/models/metadata/fields.py +from typing import List, Optional +from datetime import datetime +from enum import Enum +from pydantic import BaseModel, HttpUrl + +class IconikFieldType(str, Enum): + """Known Iconik metadata field types based on documentation. + Actual values sent to/received from API. + """ + STRING = "string" # General short text + TEXT = "text" # Longer text (potentially multi-line in UI but distinct type) + TEXT_AREA = "text_area" # Explicitly for larger amounts of text data + INTEGER = "integer" + FLOAT = "float" + BOOLEAN = "boolean" # For Yes/No fields + DATE = "date" + DATETIME = "datetime" # For Date Time fields + DROPDOWN = "drop_down" # For fields with predefined options + EMAIL = "email" + TAG_CLOUD = "tag_cloud" # For free-form tag collections + URL = "url" + +class FieldOption(BaseModel): + """Represents an option for a metadata field (e.g., for dropdowns).""" + label: Optional[str] = None + value: Optional[str] = None + +class _FieldConfigurable(BaseModel): + """Base model for common configurable attributes of metadata fields.""" + label: Optional[str] = None + field_type: Optional[IconikFieldType] = None + description: Optional[str] = None + options: Optional[List[FieldOption]] = None + required: Optional[bool] = None + auto_set: Optional[bool] = None + hide_if_not_set: Optional[bool] = None + is_block_field: Optional[bool] = None + is_warning_field: Optional[bool] = None + multi: Optional[bool] = None + read_only: Optional[bool] = None + representative: Optional[bool] = None + sortable: Optional[bool] = None + use_as_facet: Optional[bool] = None + min_value: Optional[float] = None + max_value: Optional[float] = None + external_id: Optional[str] = None + source_url: Optional[HttpUrl] = None + +class FieldCreate(_FieldConfigurable): + """Data Transfer Object for creating a new metadata field.""" + name: str + label: str + field_type: IconikFieldType + +class FieldUpdate(_FieldConfigurable): + """ + Data Transfer Object for updating an existing metadata field. + All fields are optional to support partial updates. + 'name' is specified in the URL path for updates, not in the body. + """ + pass + +class Field(_FieldConfigurable): + """Represents a metadata field as returned by the API.""" + id: str + name: str + label: str + field_type: IconikFieldType + + date_created: Optional[datetime] = None + date_modified: Optional[datetime] = None + mapped_field_name: Optional[str] = None diff --git a/pythonik/specs/metadata.py b/pythonik/specs/metadata.py index c30970e..8544e25 100644 --- a/pythonik/specs/metadata.py +++ b/pythonik/specs/metadata.py @@ -10,6 +10,7 @@ UpdateMetadata, UpdateMetadataResponse, ) +from pythonik.models.metadata.fields import Field, FieldCreate, FieldUpdate from pythonik.specs.base import Spec from typing import Literal, Union, Dict, Any, List @@ -27,6 +28,10 @@ UPDATE_VIEW_PATH = GET_VIEW_PATH DELETE_VIEW_PATH = GET_VIEW_PATH +# Field paths +FIELDS_BASE_PATH = "fields/" +FIELD_BY_NAME_PATH = "fields/{field_name}/" + ObjectType = Literal["segments"] @@ -383,7 +388,7 @@ def delete_view(self, view_id: str, **kwargs) -> Response: - can_delete_metadata_views Returns: - Response: Empty response with 204 status code + Response: An empty response, expecting HTTP 204 No Content on success. Raises: - 400 Bad request @@ -392,3 +397,71 @@ def delete_view(self, view_id: str, **kwargs) -> Response: """ resp = self._delete(DELETE_VIEW_PATH.format(view_id=view_id), **kwargs) return self.parse_response(resp, None) + + # Metadata Field Management + # ------------------------- + + def create_metadata_field( + self, + field_data: FieldCreate, + exclude_defaults: bool = True, + **kwargs, + ) -> Response: + """Create a new metadata field. + + Args: + field_data: The data for the new field. + exclude_defaults: Whether to exclude default values when dumping Pydantic models. + **kwargs: Additional kwargs to pass to the request. + + Returns: + Response: The created metadata field. + """ + json_data = self._prepare_model_data( + field_data, exclude_defaults=exclude_defaults + ) + resp = self._post(FIELDS_BASE_PATH, json=json_data, **kwargs) + return self.parse_response(resp, Field) + + def update_metadata_field( + self, + field_name: str, + field_data: FieldUpdate, + exclude_defaults: bool = True, + **kwargs, + ) -> Response: + """Update an existing metadata field by its name. + + Args: + field_name: The name of the field to update. + field_data: The data to update the field with. + exclude_defaults: Whether to exclude default values when dumping Pydantic models. + **kwargs: Additional kwargs to pass to the request. + + Returns: + Response: The updated metadata field. + """ + json_data = self._prepare_model_data( + field_data, exclude_defaults=exclude_defaults + ) + endpoint = FIELD_BY_NAME_PATH.format(field_name=field_name) + resp = self._put(endpoint, json=json_data, **kwargs) + return self.parse_response(resp, Field) + + def delete_metadata_field( + self, + field_name: str, + **kwargs, + ) -> Response: + """Delete a metadata field by its name. + + Args: + field_name: The name of the field to delete. + **kwargs: Additional kwargs to pass to the request. + + Returns: + Response: An empty response, expecting HTTP 204 No Content on success. + """ + endpoint = FIELD_BY_NAME_PATH.format(field_name=field_name) + resp = self._delete(endpoint, **kwargs) + return self.parse_response(resp) diff --git a/pythonik/tests/test_metadata.py b/pythonik/tests/test_metadata.py index 96834fc..9c9f0e3 100644 --- a/pythonik/tests/test_metadata.py +++ b/pythonik/tests/test_metadata.py @@ -4,6 +4,7 @@ from requests import HTTPError from pythonik.models.base import ObjectType from loguru import logger +import json from pythonik.models.metadata.views import ( FieldValue, @@ -31,8 +32,15 @@ UPDATE_VIEW_PATH, DELETE_VIEW_PATH, GET_VIEW_PATH, + FIELDS_BASE_PATH, + FIELD_BY_NAME_PATH, ) +from pythonik.models.metadata.fields import Field, FieldCreate, FieldUpdate, FieldOption +import pytest +from pythonik.models.metadata.fields import IconikFieldType +from pydantic import ValidationError + def test_get_asset_metadata(): with requests_mock.Mocker() as m: @@ -1323,3 +1331,299 @@ def test_get_view_alternate_base_url(): logger.info(m.last_request.url) logger.info(mock_address) assert m.last_request.url == mock_address + + +def test_create_metadata_field(requests_mock): + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + client = PythonikClient( + app_id=app_id, auth_token=auth_token, timeout=3 + ) # Client instance + spec_instance = client.metadata() # MetadataSpec instance + + field_name = "new_test_field_create" + field_create_payload = FieldCreate( + name=field_name, + label="New Test Field Create", + field_type="string", + options=[FieldOption(label="Option 1", value="opt1_val_create")], + ) + expected_field_response = Field( + id="generated_uuid_for_new_field_create", + name=field_name, + label="New Test Field Create", + field_type="string", + options=[FieldOption(label="Option 1", value="opt1_val_create")], + date_created="2025-01-01T00:00:00Z", + date_modified="2025-01-01T00:00:00Z", + ) + + # mock_address = MetadataSpec.gen_url(FIELDS_BASE_PATH) # Old way (classmethod, relative URL) + mock_address = spec_instance.gen_url( + FIELDS_BASE_PATH + ) # New way (instance method, absolute URL) + requests_mock.post( + mock_address, + json=json.loads(expected_field_response.model_dump_json()), + status_code=201, + ) + + # client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) # Already created above + result = spec_instance.create_metadata_field( + field_create_payload + ) # Use the same spec_instance + + assert result.response.ok + assert result.response.status_code == 201 + assert result.data.name == expected_field_response.name + assert result.data.label == expected_field_response.label + assert len(result.data.options) == 1 + assert result.data.options[0].label == "Option 1" + + +def test_update_metadata_field(requests_mock): + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + field_to_update_name = "existing_field_for_update_test" + field_update_payload = FieldUpdate( + label="Updated Label for Field Test", + description="Updated description test.", + ) + expected_field_response = Field( + id="uuid_of_existing_field_update_test", + name=field_to_update_name, + label="Updated Label for Field Test", + description="Updated description test.", + field_type="string", + options=[], + date_created="2024-01-01T00:00:00Z", + date_modified="2025-01-01T00:00:00Z", + ) + + mock_address = MetadataSpec.gen_url( + FIELD_BY_NAME_PATH.format(field_name=field_to_update_name) + ) + requests_mock.put( + 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().update_metadata_field( + field_to_update_name, field_update_payload + ) + + assert result.response.ok + assert result.response.status_code == 200 + assert result.data.name == field_to_update_name + assert result.data.label == "Updated Label for Field Test" + assert result.data.description == "Updated description test." + + +def test_delete_metadata_field(requests_mock): + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + field_to_delete_name = "field_marked_for_deletion_test" + + mock_address = MetadataSpec.gen_url( + FIELD_BY_NAME_PATH.format(field_name=field_to_delete_name) + ) + requests_mock.delete(mock_address, status_code=204) + + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + result = client.metadata().delete_metadata_field(field_to_delete_name) + + assert result.response.ok + assert result.response.status_code == 204 + + +def test_create_metadata_field_conflict_error(requests_mock): + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + field_create_payload = FieldCreate( + name="existing_name", label="Fail Label", field_type="string" + ) + mock_address = MetadataSpec.gen_url(FIELDS_BASE_PATH) + requests_mock.post(mock_address, json={"error": "conflict"}, status_code=409) + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + result = client.metadata().create_metadata_field(field_create_payload) + assert not result.response.ok + assert result.response.status_code == 409 + + +def test_update_metadata_field_not_found_error(requests_mock): + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + field_update_payload = FieldUpdate(label="NonExistent Update") + non_existent_field = "non_existent_field_name" + mock_address = MetadataSpec.gen_url( + FIELD_BY_NAME_PATH.format(field_name=non_existent_field) + ) + requests_mock.put(mock_address, json={"error": "not found"}, status_code=404) + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + result = client.metadata().update_metadata_field( + non_existent_field, field_update_payload + ) + assert not result.response.ok + assert result.response.status_code == 404 + + +def test_delete_metadata_field_not_found_error(requests_mock): + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + non_existent_field = "another_non_existent_field" + mock_address = MetadataSpec.gen_url( + FIELD_BY_NAME_PATH.format(field_name=non_existent_field) + ) + requests_mock.delete(mock_address, json={"error": "not found"}, status_code=404) + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + result = client.metadata().delete_metadata_field(non_existent_field) + assert not result.response.ok + assert result.response.status_code == 404 + + +@pytest.mark.parametrize("field_type_enum", list(IconikFieldType)) +def test_create_metadata_field_for_all_types( + requests_mock, field_type_enum: IconikFieldType +): + """Test creating a metadata field for each IconikFieldType.""" + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + spec_instance = client.metadata() + + field_name = f"test_sdk_{field_type_enum.value.lower()}_{str(uuid.uuid4())[:8]}" + field_label = f"Test SDK {field_type_enum.name.replace('_', ' ').title()} Field" + + field_create_payload = { + "name": field_name, + "label": field_label, + "field_type": field_type_enum, # Use the Enum member directly + } + + if field_type_enum == IconikFieldType.DROPDOWN: + field_create_payload["options"] = [FieldOption(label="Opt1", value="val1")] + + # Prepare Pydantic model for request + field_data_model = FieldCreate(**field_create_payload) + + # Expected response from API (API returns string value for field_type) + expected_response_json = { + "id": str(uuid.uuid4()), + "name": field_name, + "label": field_label, + "field_type": field_type_enum.value, # API returns the string value + "date_created": "2023-01-01T12:00:00Z", + "date_modified": "2023-01-01T12:00:00Z", + # ... other fields with default/null values as per Field model + "description": None, + "options": field_create_payload.get("options", []), + "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, + "min_value": None, + "max_value": None, + "external_id": None, + "source_url": None, + "mapped_field_name": None, + } + if field_type_enum == IconikFieldType.DROPDOWN: + # Pydantic model for options will be list of FieldOption objects + # API mock should return list of dicts + expected_response_json["options"] = [{"label": "Opt1", "value": "val1"}] + else: + expected_response_json["options"] = [] + + create_url = spec_instance.gen_url(FIELDS_BASE_PATH) + requests_mock.post(create_url, json=expected_response_json, status_code=201) + + # Call the method under test + response = spec_instance.create_metadata_field(field_data=field_data_model) + + assert response.response.ok + assert response.response.status_code == 201 + assert response.data is not None + assert response.data.name == field_name + assert response.data.label == field_label + assert ( + response.data.field_type == field_type_enum + ) # Pydantic model converts back to Enum + + # Optional: Mock and test deletion for cleanup (good practice) + delete_url = spec_instance.gen_url(FIELD_BY_NAME_PATH.format(field_name=field_name)) + requests_mock.delete(delete_url, status_code=204) + delete_response = spec_instance.delete_metadata_field(field_name) + assert delete_response.response.ok + assert delete_response.response.status_code == 204 + + +def test_create_metadata_field_with_unknown_type_raises_validation_error(requests_mock): + """ + Test that fetching a field with an unknown field_type string + from the API raises a Pydantic ValidationError during response parsing. + """ + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + spec_instance = client.metadata() + + field_name = f"test_sdk_unknown_type_{str(uuid.uuid4())[:8]}" + field_label = "Test SDK Unknown Type Field" + unknown_type_string = "some_future_unrecognized_type" + + # Data for creating the field - this part uses a valid IconikFieldType for the request + field_data_model = FieldCreate( + name=field_name, + label=field_label, + field_type=IconikFieldType.STRING, # A valid type for the request itself + ) + + # Mocked API response containing the unknown field_type in its body + mock_api_response_json = { + "id": str(uuid.uuid4()), + "name": field_name, + "label": field_label, + "field_type": unknown_type_string, # This is the unexpected value from API + "date_created": "2023-01-01T12:00:00Z", + "date_modified": "2023-01-01T12:00:00Z", + # Fill in other required fields for Field model based on its definition + "description": None, + "options": [], + "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, + "min_value": None, + "max_value": None, + "external_id": None, + "source_url": None, + "mapped_field_name": None, + } + + create_url = spec_instance.gen_url(FIELDS_BASE_PATH) + # We mock a successful creation (201) but with a problematic field_type in the response body + requests_mock.post(create_url, json=mock_api_response_json, status_code=201) + + # The ValidationError should be raised when parse_response (called by create_metadata_field) + # tries to fit unknown_type_string into IconikFieldType. + with pytest.raises(ValidationError) as excinfo: + spec_instance.create_metadata_field(field_data=field_data_model) + + # Check that the error message mentions 'field_type' and the unknown value + error_str = str(excinfo.value).lower() + assert "field_type" in error_str + assert f"'{unknown_type_string}'" in error_str or unknown_type_string in error_str