diff --git a/changelog.rst b/changelog.rst index 369e258..401c3b5 100644 --- a/changelog.rst +++ b/changelog.rst @@ -2,6 +2,14 @@ **CHANGELOG** ================ +*v1.4.0* +============ + +NEW FEATURE: Variants utility (CDA entry variant aliases). + +- Added ``Utils.get_variant_aliases`` to read variant alias strings from ``publish_details.variants`` on a CDA entry (single dict or list of entries). Supports optional ``content_type_uid`` when ``_content_type_uid`` is absent on the entry. +- Added ``Utils.get_variant_metadata_tags`` to build a ``data-csvariants`` HTML data-attribute value (JSON string of the multi-entry alias results). + *v1.3.3* ============ diff --git a/contentstack_utils/__init__.py b/contentstack_utils/__init__.py index 917ef05..c404814 100644 --- a/contentstack_utils/__init__.py +++ b/contentstack_utils/__init__.py @@ -31,6 +31,6 @@ __title__ = 'contentstack_utils' __author__ = 'contentstack' __status__ = 'debug' -__version__ = '1.3.2' +__version__ = '1.4.0' __endpoint__ = 'cdn.contentstack.io' __contact__ = 'support@contentstack.com' diff --git a/contentstack_utils/utils.py b/contentstack_utils/utils.py index 5710381..134c9a4 100644 --- a/contentstack_utils/utils.py +++ b/contentstack_utils/utils.py @@ -1,5 +1,8 @@ # pylint: disable=missing-function-docstring +import json +from typing import Any, Dict, List, Union + from lxml import etree from contentstack_utils.automate import Automate @@ -10,6 +13,95 @@ class Utils(Automate): + @staticmethod + def _variants_map_from_entry(entry: dict) -> dict: + publish_details = entry.get("publish_details") + if not isinstance(publish_details, dict): + return {} + raw = publish_details.get("variants") + return raw if isinstance(raw, dict) else {} + + @staticmethod + def _aliases_from_variants_map(variants_map: dict) -> List[str]: + aliases: List[str] = [] + for _variant_uid, value in variants_map.items(): + if not isinstance(value, dict): + continue + alias = value.get("alias") + if alias is None: + continue + alias_str = str(alias).strip() + if alias_str: + aliases.append(alias_str) + return aliases + + @staticmethod + def _variant_aliases_for_entry(entry: dict, content_type_uid: str = "") -> Dict[str, Any]: + if entry is None: + raise ValueError("entry cannot be None") + if not isinstance(entry, dict): + raise TypeError("entry must be a dict") + uid = entry.get("uid") + if uid is None or (isinstance(uid, str) and uid.strip() == ""): + raise ValueError("entry must contain a non-empty uid") + entry_uid = str(uid) + ct = entry.get("_content_type_uid") + if ct is None or ct == "": + ct = content_type_uid or "" + contenttype_uid = "" if ct is None else str(ct) + variants_map = Utils._variants_map_from_entry(entry) + aliases = Utils._aliases_from_variants_map(variants_map) + return { + "entry_uid": entry_uid, + "contenttype_uid": contenttype_uid, + "variants": aliases, + } + + @staticmethod + def get_variant_aliases( + entry_or_entries: Union[dict, List[dict]], + content_type_uid: str = "", + ) -> Union[Dict[str, Any], List[Dict[str, Any]]]: + """ + Extract variant aliases from a CDA entry (or list of entries). + + The entry must have been fetched with ``x-cs-variant-uid`` set to variant + aliases (not UIDs) for ``publish_details.variants`` to be present. + + :param entry_or_entries: A single entry dict, or a list of entry dicts. + :param content_type_uid: Used when ``entry._content_type_uid`` is absent; + ignored when ``entry_or_entries`` is a list (each entry supplies its own). + :raises ValueError: if ``entry_or_entries`` is None, an entry is None, or an + entry has no non-empty ``uid``. + :raises TypeError: if a single entry is not a dict, or a list is expected but + another type was passed for the multi-entry overload. + """ + if entry_or_entries is None: + raise ValueError("entry is required and cannot be None") + if isinstance(entry_or_entries, list): + return [Utils._variant_aliases_for_entry(e, "") for e in entry_or_entries] + if isinstance(entry_or_entries, dict): + return Utils._variant_aliases_for_entry(entry_or_entries, content_type_uid or "") + raise TypeError("entry must be a dict or a list of dicts") + + @staticmethod + def get_variant_metadata_tags(entries: List[dict]) -> Dict[str, str]: + """ + Build a ``data-csvariants`` HTML data-attribute payload from entry objects. + + :param entries: List of CDA entry dicts (same shape as for multi-entry + :meth:`get_variant_aliases`). + :raises ValueError: if ``entries`` is None. + :raises TypeError: if ``entries`` is not a list. + """ + if entries is None: + raise ValueError("entries is required and cannot be None") + if not isinstance(entries, list): + raise TypeError("entries must be a list") + results = Utils.get_variant_aliases(entries) + payload = json.dumps(results, separators=(",", ":")) + return {"data-csvariants": payload} + @staticmethod def render(entry_obj, key_path: list, option: Options): valid = Automate.is_json(entry_obj) diff --git a/tests/test_variant_aliases.py b/tests/test_variant_aliases.py new file mode 100644 index 0000000..d65d035 --- /dev/null +++ b/tests/test_variant_aliases.py @@ -0,0 +1,164 @@ +import json +import unittest + +from contentstack_utils.utils import Utils + + +class TestVariantAliases(unittest.TestCase): + + def _sample_entry(self): + return { + "uid": "blt3e91e3812a44ba90", + "_content_type_uid": "landing_page", + "publish_details": { + "variants": { + "cs669f1759b774fe1d": { + "alias": "cs_personalize_0_2", + "environment": "bltb5963e2163c24eb6", + "locale": "en", + }, + "csbf165536748bdee2": { + "alias": "cs_personalize_0_1", + "environment": "bltb5963e2163c24eb6", + "locale": "en", + }, + } + }, + } + + def test_single_entry_extracts_aliases(self): + result = Utils.get_variant_aliases(self._sample_entry()) + self.assertEqual(result["entry_uid"], "blt3e91e3812a44ba90") + self.assertEqual(result["contenttype_uid"], "landing_page") + self.assertEqual( + result["variants"], + ["cs_personalize_0_2", "cs_personalize_0_1"], + ) + + def test_content_type_from_parameter_when_missing_on_entry(self): + entry = { + "uid": "blt1", + "publish_details": {"variants": {}}, + } + result = Utils.get_variant_aliases(entry, "landing_page") + self.assertEqual(result["contenttype_uid"], "landing_page") + + def test_empty_contenttype_when_missing(self): + entry = {"uid": "blt1", "publish_details": {"variants": {}}} + result = Utils.get_variant_aliases(entry) + self.assertEqual(result["contenttype_uid"], "") + + def test_missing_publish_details(self): + entry = {"uid": "blt1", "_content_type_uid": "page"} + result = Utils.get_variant_aliases(entry) + self.assertEqual(result["variants"], []) + + def test_missing_variants_key(self): + entry = {"uid": "blt1", "publish_details": {}} + result = Utils.get_variant_aliases(entry) + self.assertEqual(result["variants"], []) + + def test_empty_variants_object(self): + entry = {"uid": "blt1", "publish_details": {"variants": {}}} + result = Utils.get_variant_aliases(entry) + self.assertEqual(result["variants"], []) + + def test_skips_variant_without_alias(self): + entry = { + "uid": "blt1", + "publish_details": { + "variants": { + "a": {"alias": "ok"}, + "b": {}, + "c": {"alias": ""}, + "d": {"alias": " "}, + } + }, + } + result = Utils.get_variant_aliases(entry) + self.assertEqual(result["variants"], ["ok"]) + + def test_non_dict_variant_value_skipped(self): + entry = { + "uid": "blt1", + "publish_details": {"variants": {"x": "not-a-dict"}}, + } + result = Utils.get_variant_aliases(entry) + self.assertEqual(result["variants"], []) + + def test_none_entry_raises(self): + with self.assertRaises(ValueError): + Utils.get_variant_aliases(None) + + def test_missing_uid_raises(self): + with self.assertRaises(ValueError): + Utils.get_variant_aliases({"publish_details": {}}) + + def test_empty_uid_raises(self): + with self.assertRaises(ValueError): + Utils.get_variant_aliases({"uid": ""}) + + def test_whitespace_uid_raises(self): + with self.assertRaises(ValueError): + Utils.get_variant_aliases({"uid": " "}) + + def test_non_dict_entry_raises(self): + with self.assertRaises(TypeError): + Utils.get_variant_aliases("not-a-dict") + + def test_multiple_entries(self): + results = Utils.get_variant_aliases( + [ + { + "uid": "blt123", + "_content_type_uid": "page", + "publish_details": { + "variants": { + "v1": {"alias": "cs_personalize_3_1"}, + "v2": {"alias": "cs_personalize_4_0"}, + } + }, + }, + {"uid": "blt456", "_content_type_uid": "page"}, + ] + ) + self.assertEqual(len(results), 2) + self.assertEqual(results[0]["entry_uid"], "blt123") + self.assertEqual( + results[0]["variants"], + ["cs_personalize_3_1", "cs_personalize_4_0"], + ) + self.assertEqual(results[1]["variants"], []) + + def test_list_entry_none_raises(self): + with self.assertRaises(ValueError): + Utils.get_variant_aliases([None]) + + def test_get_variant_metadata_tags(self): + entries = [ + { + "uid": "blt123", + "_content_type_uid": "page", + "publish_details": { + "variants": {"v1": {"alias": "cs_personalize_3_1"}} + }, + } + ] + tag = Utils.get_variant_metadata_tags(entries) + self.assertIn("data-csvariants", tag) + parsed = json.loads(tag["data-csvariants"]) + self.assertEqual(len(parsed), 1) + self.assertEqual(parsed[0]["entry_uid"], "blt123") + self.assertEqual(parsed[0]["variants"], ["cs_personalize_3_1"]) + + def test_get_variant_metadata_tags_none_raises(self): + with self.assertRaises(ValueError): + Utils.get_variant_metadata_tags(None) + + def test_get_variant_metadata_tags_not_list_raises(self): + with self.assertRaises(TypeError): + Utils.get_variant_metadata_tags({}) + + +if __name__ == "__main__": + unittest.main()