diff --git a/CHANGELOG.md b/CHANGELOG.md index 36fc418..2124497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## _v2.1.0_ + +### **Date: 02-June-2025** + +- Global fields support added. + ## _v2.0.1_ ### **Date: 12-MAY-2025** diff --git a/contentstack/__init__.py b/contentstack/__init__.py index 874f834..74d26e0 100644 --- a/contentstack/__init__.py +++ b/contentstack/__init__.py @@ -22,7 +22,7 @@ __title__ = 'contentstack-delivery-python' __author__ = 'contentstack' __status__ = 'debug' -__version__ = 'v2.0.1' +__version__ = 'v2.1.0' __endpoint__ = 'cdn.contentstack.io' __email__ = 'support@contentstack.com' __developer_email__ = 'mobile@contentstack.com' diff --git a/contentstack/globalfields.py b/contentstack/globalfields.py new file mode 100644 index 0000000..1f42a91 --- /dev/null +++ b/contentstack/globalfields.py @@ -0,0 +1,73 @@ +""" +Global field defines the structure or schema of a page or a section of your web +or mobile property. To create content for your application, you are required +to first create a Global field, and then create entries using the +Global field. +""" + +import logging +from urllib import parse + +class GlobalField: + """ + Global field defines the structure or schema of a page or a + section of your web or mobile property. To create + content for your application, you are required to + first create a Global field, and then create entries using the + Global field. + """ + + def __init__(self, http_instance, global_field_uid, logger=None): + self.http_instance = http_instance + self.__global_field_uid = global_field_uid + self.local_param = {} + self.logger = logger or logging.getLogger(__name__) + + + def fetch(self): + """ + This method is useful to fetch GlobalField of the of the stack. + :return:dict -- GlobalField response + ------------------------------ + Example: + + >>> import contentstack + >>> stack = contentstack.Stack('api_key', 'delivery_token', 'environment') + >>> global_field = stack.global_field('global_field_uid') + >>> some_dict = {'abc':'something'} + >>> response = global_field.fetch(some_dict) + ------------------------------ + """ + if self.__global_field_uid is None: + raise KeyError( + 'global_field_uid can not be None to fetch GlobalField') + self.local_param['environment'] = self.http_instance.headers['environment'] + uri = f'{self.http_instance.endpoint}/global_fields/{self.__global_field_uid}' + encoded_params = parse.urlencode(self.local_param) + url = f'{uri}?{encoded_params}' + result = self.http_instance.get(url) + return result + + def find(self, params=None): + """ + This method is useful to fetch GlobalField of the of the stack. + :param params: dictionary of params + :return:dict -- GlobalField response + ------------------------------ + Example: + + >>> import contentstack + >>> stack = contentstack.Stack('api_key', 'delivery_token', 'environment') + >>> global_field = stack.global_field() + >>> some_dict = {'abc':'something'} + >>> response = global_field.find(param=some_dict) + ------------------------------ + """ + self.local_param['environment'] = self.http_instance.headers['environment'] + if params is not None: + self.local_param.update(params) + encoded_params = parse.urlencode(self.local_param) + endpoint = self.http_instance.endpoint + url = f'{endpoint}/global_fields?{encoded_params}' + result = self.http_instance.get(url) + return result diff --git a/contentstack/stack.py b/contentstack/stack.py index 30aabfa..856d287 100644 --- a/contentstack/stack.py +++ b/contentstack/stack.py @@ -6,6 +6,7 @@ from contentstack.asset import Asset from contentstack.assetquery import AssetQuery from contentstack.contenttype import ContentType +from contentstack.globalfields import GlobalField from contentstack.https_connection import HTTPSConnection from contentstack.image_transform import ImageTransform @@ -202,6 +203,15 @@ def content_type(self, content_type_uid=None): :return: ContentType """ return ContentType(self.http_instance, content_type_uid) + + def global_field(self, global_field_uid=None): + """ + Global field defines the structure or schema of a page or a section + of your web or mobile property. + param global_field_uid: + :return: GlobalField + """ + return GlobalField(self.http_instance, global_field_uid) def asset(self, uid): """ diff --git a/tests/__init__.py b/tests/__init__.py index 3545c68..33fb399 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -12,6 +12,9 @@ from .test_entry import TestEntry from .test_query import TestQuery from .test_stack import TestStack +from .test_global_fields import TestGlobalFieldInit +from .test_early_fetch import TestGlobalFieldFetch +from .test_early_find import TestGlobalFieldFind from .test_live_preview import TestLivePreviewConfig @@ -21,10 +24,16 @@ def all_tests(): test_module_entry = TestLoader().loadTestsFromTestCase(TestEntry) test_module_query = TestLoader().loadTestsFromTestCase(TestQuery) test_module_live_preview = TestLoader().loadTestsFromTestCase(TestLivePreviewConfig) + test_module_globalFields = TestLoader().loadTestsFromName(TestGlobalFieldInit) + test_module_globalFields_fetch = TestLoader().loadTestsFromName(TestGlobalFieldFetch) + test_module_globalFields_find = TestLoader().loadTestsFromName(TestGlobalFieldFind) TestSuite([ test_module_stack, test_module_asset, test_module_entry, test_module_query, - test_module_live_preview + test_module_live_preview, + test_module_globalFields, + test_module_globalFields_fetch, + test_module_globalFields_find ]) diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..bb405f8 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,4 @@ +# pytest.ini +[pytest] +filterwarnings = + ignore::DeprecationWarning:.*ast.*: \ No newline at end of file diff --git a/tests/test_early_fetch.py b/tests/test_early_fetch.py new file mode 100644 index 0000000..83843ec --- /dev/null +++ b/tests/test_early_fetch.py @@ -0,0 +1,124 @@ +""" +Unit tests for GlobalField.fetch method in contentstack.globalfields +""" + +import pytest +from unittest.mock import MagicMock +from contentstack.globalfields import GlobalField +from urllib.parse import urlencode + + +@pytest.fixture +def mock_http_instance(): + """ + Fixture to provide a mock http_instance with required attributes. + """ + mock = MagicMock() + mock.endpoint = "https://api.contentstack.io/v3" + mock.headers = {"environment": "test_env"} + mock.get = MagicMock(return_value={"global_field": "data"}) + return mock + + +@pytest.fixture +def global_field_uid(): + """ + Fixture to provide a sample global_field_uid. + """ + return "sample_uid" + + +@pytest.fixture +def global_field(mock_http_instance, global_field_uid): + """ + Fixture to provide a GlobalField instance with a mock http_instance and uid. + """ + return GlobalField(mock_http_instance, global_field_uid) + + +class TestGlobalFieldFetch: + # ------------------- Happy Path Tests ------------------- + + def test_fetch_returns_expected_result(self, global_field): + """ + Test that fetch returns the result from http_instance.get with correct URL and params. + """ + result = global_field.fetch() + assert result == {"global_field": "data"} + assert global_field.local_param["environment"] == "test_env" + expected_params = urlencode({"environment": "test_env"}) + expected_url = f"https://api.contentstack.io/v3/global_fields/sample_uid?{expected_params}" + global_field.http_instance.get.assert_called_once_with(expected_url) + + def test_fetch_with_different_environment(self, mock_http_instance, global_field_uid): + """ + Test fetch with a different environment value in headers. + """ + mock_http_instance.headers["environment"] = "prod_env" + gf = GlobalField(mock_http_instance, global_field_uid) + result = gf.fetch() + assert result == {"global_field": "data"} + expected_params = urlencode({"environment": "prod_env"}) + expected_url = f"https://api.contentstack.io/v3/global_fields/sample_uid?{expected_params}" + mock_http_instance.get.assert_called_once_with(expected_url) + + def test_fetch_preserves_existing_local_param(self, global_field): + """ + Test that fetch overwrites only the 'environment' key in local_param, preserving others. + """ + global_field.local_param = {"foo": "bar"} + result = global_field.fetch() + assert result == {"global_field": "data"} + assert global_field.local_param["foo"] == "bar" + assert global_field.local_param["environment"] == "test_env" + expected_params = urlencode({"foo": "bar", "environment": "test_env"}) + expected_url = f"https://api.contentstack.io/v3/global_fields/sample_uid?{expected_params}" + global_field.http_instance.get.assert_called_once_with(expected_url) + + # ------------------- Edge Case Tests ------------------- + + def test_fetch_raises_keyerror_when_uid_is_none(self, mock_http_instance): + """ + Test that fetch raises KeyError if global_field_uid is None. + """ + gf = GlobalField(mock_http_instance, None) + with pytest.raises(KeyError, match="global_field_uid can not be None"): + gf.fetch() + + def test_fetch_raises_keyerror_when_uid_is_explicitly_set_to_none(self, mock_http_instance): + """ + Test that fetch raises KeyError if global_field_uid is explicitly set to None after init. + """ + gf = GlobalField(mock_http_instance, "not_none") + gf._GlobalField__global_field_uid = None # forcibly set to None + with pytest.raises(KeyError, match="global_field_uid can not be None"): + gf.fetch() + + def test_fetch_handles_special_characters_in_params(self, global_field): + """ + Test that fetch correctly encodes special characters in local_param. + """ + global_field.local_param = {"foo": "bar baz", "qux": "a&b"} + result = global_field.fetch() + assert result == {"global_field": "data"} + expected_params = urlencode({"foo": "bar baz", "qux": "a&b", "environment": "test_env"}) + expected_url = f"https://api.contentstack.io/v3/global_fields/sample_uid?{expected_params}" + global_field.http_instance.get.assert_called_once_with(expected_url) + + def test_fetch_raises_keyerror_if_environment_header_missing(self, mock_http_instance, global_field_uid): + """ + Test that fetch raises KeyError if 'environment' is missing from http_instance.headers. + """ + del mock_http_instance.headers["environment"] + gf = GlobalField(mock_http_instance, global_field_uid) + with pytest.raises(KeyError): + gf.fetch() + + def test_fetch_propagates_http_instance_get_exception(self, global_field): + """ + Test that fetch propagates exceptions raised by http_instance.get. + """ + global_field.http_instance.get.side_effect = RuntimeError("Network error") + with pytest.raises(RuntimeError, match="Network error"): + global_field.fetch() + diff --git a/tests/test_early_find.py b/tests/test_early_find.py new file mode 100644 index 0000000..a3115cf --- /dev/null +++ b/tests/test_early_find.py @@ -0,0 +1,138 @@ +# test_globalfields_find.py + +import pytest +from unittest.mock import MagicMock, patch +from contentstack.globalfields import GlobalField + +@pytest.fixture +def mock_http_instance(): + """ + Fixture to provide a mock http_instance with headers and endpoint. + """ + mock = MagicMock() + mock.headers = {"environment": "test_env"} + mock.endpoint = "https://api.contentstack.io/v3" + mock.get = MagicMock(return_value={"global_fields": "data"}) + return mock + +@pytest.fixture +def global_field_uid(): + """ + Fixture to provide a sample global_field_uid. + """ + return "sample_uid" + +class TestGlobalFieldFind: + """ + Unit tests for GlobalField.find method, covering happy paths and edge cases. + """ + + # -------------------- Happy Path Tests -------------------- + + def test_find_with_no_params(self, mock_http_instance, global_field_uid): + """ + Test that find() with no params returns expected result and constructs correct URL. + """ + gf = GlobalField(mock_http_instance, global_field_uid) + result = gf.find() + assert result == {"global_fields": "data"} + expected_url = ( + "https://api.contentstack.io/v3/global_fields?environment=test_env" + ) + mock_http_instance.get.assert_called_once_with(expected_url) + + def test_find_with_params(self, mock_http_instance, global_field_uid): + """ + Test that find() with additional params merges them and encodes URL correctly. + """ + gf = GlobalField(mock_http_instance, global_field_uid) + params = {"limit": 10, "skip": 5} + result = gf.find(params=params) + # The order of query params in the URL is not guaranteed, so check both possibilities + called_url = mock_http_instance.get.call_args[0][0] + assert result == {"global_fields": "data"} + assert called_url.startswith("https://api.contentstack.io/v3/global_fields?") + # All params must be present in the URL + for k, v in {"environment": "test_env", "limit": "10", "skip": "5"}.items(): + assert f"{k}={v}" in called_url + + def test_find_with_empty_params_dict(self, mock_http_instance, global_field_uid): + """ + Test that find() with an empty params dict behaves like no params. + """ + gf = GlobalField(mock_http_instance, global_field_uid) + result = gf.find(params={}) + assert result == {"global_fields": "data"} + expected_url = ( + "https://api.contentstack.io/v3/global_fields?environment=test_env" + ) + mock_http_instance.get.assert_called_once_with(expected_url) + + + + def test_find_with_special_characters_in_params(self, mock_http_instance, global_field_uid): + """ + Test that find() correctly URL-encodes special characters in params. + """ + gf = GlobalField(mock_http_instance, global_field_uid) + params = {"q": "name:foo/bar&baz", "limit": 1} + result = gf.find(params=params) + called_url = mock_http_instance.get.call_args[0][0] + # Check that special characters are URL-encoded + assert "q=name%3Afoo%2Fbar%26baz" in called_url + assert "limit=1" in called_url + assert result == {"global_fields": "data"} + + def test_find_with_none_environment_in_headers(self, mock_http_instance, global_field_uid): + """ + Test that find() handles the case where 'environment' in headers is None. + """ + mock_http_instance.headers["environment"] = None + gf = GlobalField(mock_http_instance, global_field_uid) + result = gf.find() + called_url = mock_http_instance.get.call_args[0][0] + # Should include 'environment=None' in the query string + assert "environment=None" in called_url + assert result == {"global_fields": "data"} + + def test_find_with_non_string_param_values(self, mock_http_instance, global_field_uid): + """ + Test that find() handles non-string param values (e.g., int, bool, None). + """ + gf = GlobalField(mock_http_instance, global_field_uid) + params = {"int_val": 42, "bool_val": True, "none_val": None} + result = gf.find(params=params) + called_url = mock_http_instance.get.call_args[0][0] + # int and bool should be stringified, None should be 'None' + assert "int_val=42" in called_url + assert "bool_val=True" in called_url + assert "none_val=None" in called_url + assert result == {"global_fields": "data"} + + def test_find_with_empty_headers(self, global_field_uid): + """ + Test that find() raises KeyError if 'environment' is missing from headers. + """ + mock_http_instance = MagicMock() + mock_http_instance.headers = {} + mock_http_instance.endpoint = "https://api.contentstack.io/v3" + mock_http_instance.get = MagicMock(return_value={"global_fields": "data"}) + gf = GlobalField(mock_http_instance, global_field_uid) + with pytest.raises(KeyError): + gf.find() + + + def test_find_with_mutable_local_param(self, mock_http_instance, global_field_uid): + """ + Test that local_param is updated and persists between calls. + """ + gf = GlobalField(mock_http_instance, global_field_uid) + # First call with a param + gf.find(params={"foo": "bar"}) + # Second call with a different param + gf.find(params={"baz": "qux"}) + # local_param should have been updated with the last call's params + assert gf.local_param["baz"] == "qux" + assert gf.local_param["environment"] == "test_env" + # The previous param 'foo' should still be present (since update is cumulative) + assert gf.local_param["foo"] == "bar" \ No newline at end of file diff --git a/tests/test_global_fields.py b/tests/test_global_fields.py new file mode 100644 index 0000000..d8f682b --- /dev/null +++ b/tests/test_global_fields.py @@ -0,0 +1,98 @@ +# test_globalfields_init.py + +import pytest +import logging +from contentstack.globalfields import GlobalField + +class DummyHttpInstance: + """A dummy HTTP instance for testing purposes.""" + pass + +@pytest.fixture +def dummy_http(): + """Fixture to provide a dummy http_instance.""" + return DummyHttpInstance() + +@pytest.fixture +def dummy_logger(): + """Fixture to provide a dummy logger.""" + return logging.getLogger("dummy_logger") + +@pytest.mark.usefixtures("dummy_http") +class TestGlobalFieldInit: + """ + Unit tests for GlobalField.__init__ method. + """ + + # -------------------- Happy Path Tests -------------------- + + def test_init_with_all_arguments(self, dummy_http, dummy_logger): + """ + Test that __init__ correctly assigns all arguments when all are provided. + """ + uid = "global_field_123" + gf = GlobalField(dummy_http, uid, logger=dummy_logger) + assert gf.http_instance is dummy_http + # Accessing the private variable via name mangling + assert gf._GlobalField__global_field_uid == uid + assert gf.local_param == {} + assert gf.logger is dummy_logger + + def test_init_without_logger_uses_default(self, dummy_http): + """ + Test that __init__ assigns a default logger if none is provided. + """ + uid = "gf_uid" + gf = GlobalField(dummy_http, uid) + assert gf.http_instance is dummy_http + assert gf._GlobalField__global_field_uid == uid + assert gf.local_param == {} + # Should be a logger instance, and not None + assert isinstance(gf.logger, logging.Logger) + # Should be the logger for the module + assert gf.logger.name == "contentstack.globalfields" + + # -------------------- Edge Case Tests -------------------- + + def test_init_with_none_uid(self, dummy_http): + """ + Test that __init__ accepts None as global_field_uid. + """ + gf = GlobalField(dummy_http, None) + assert gf._GlobalField__global_field_uid is None + + def test_init_with_empty_string_uid(self, dummy_http): + """ + Test that __init__ accepts empty string as global_field_uid. + """ + gf = GlobalField(dummy_http, "") + assert gf._GlobalField__global_field_uid == "" + + def test_init_with_non_string_uid(self, dummy_http): + """ + Test that __init__ accepts non-string types for global_field_uid. + """ + for val in [123, 45.6, {"a": 1}, [1, 2, 3], (4, 5), True, object()]: + gf = GlobalField(dummy_http, val) + assert gf._GlobalField__global_field_uid == val + + def test_init_with_none_http_instance(self): + """ + Test that __init__ accepts None as http_instance. + """ + uid = "gf_uid" + gf = GlobalField(None, uid) + assert gf.http_instance is None + assert gf._GlobalField__global_field_uid == uid + + def test_init_with_custom_logger_object(self, dummy_http): + """ + Test that __init__ accepts any object as logger. + """ + class DummyLogger: + def info(self, msg): pass + dummy = DummyLogger() + gf = GlobalField(dummy_http, "uid", logger=dummy) + assert gf.logger is dummy + + \ No newline at end of file