<filename>src/advertising/googleads/src/automation/AutomationAdGroup.py
from typing import Union
from googleads import AdWordsClient
from time import sleep
from pymongo.collection import Collection as MongoCollection
from src.Config import Config
from src.Globals import ADWORDS_MICRO_AMOUNT_VALUE
from src.advertising.googleads.AdGroup import AdGroup as AdGroupHelper
from lxml import etree
import requests


class AutomationAdGroup:
    """
    Defines a Google Campaign Ad Group.
    """

    def __init__(self, config: Config, client: AdWordsClient, googleAdsCollection: MongoCollection):
        if not config or type(config) is not Config:
            raise Exception("AdGroup requires a valid config object")
        if not config.getSetting("id", default=None):
            raise Exception("AdGroup requires an id")

        self.config = config
        self.client = client
        self.googleAdsCollection = googleAdsCollection
        self._adGroupID = config.getSetting("id")
        self._adGroupName = config.getSetting("name")
        self._subdivisions = []
        self._adGroupRows = []
        self._feedStructure = []
        self._addedExclusions = []
        self._feedStructureItems = {}
        self._operations = []
        self._partitionTree = []
        self._subdivisionMap = {}
        # We load the AdGroup class as AdGroupHelper so we can access
        # existing partitions for this ad group during processing and
        # to reload if necessary.
        self.adGroupHelper = AdGroupHelper(client, googleAdsCollection)
        # A temporary id for use with the creation of new node creation.
        self._next_id = -1

    def id(self) -> int:
        """
        Get the ad group id.

        :return int: The ad group ID
        """
        return self._adGroupID

    def name(self) -> str:
        """
        Get the ad group name.

        :return str: The ad group name
        """
        return self._adGroupName

    def getAdGroupRows(self) -> list:
        """
        Returns the ad group rows list.

        :return list: Ad groups in list form
        """
        return self._adGroupRows if type(self._adGroupRows) is list else []

    def build(self):
        """
        Build the ad group, handles all of the loading of ad group data and
        rows.
        """

        # Reset the next id, operations list and subdivision map.
        self._next_id = -1
        self._operations = []
        self._subdivisionMap = {}

        # Download and cache the product partition tree.
        self.adGroupHelper.cacheProductPartitions(self._adGroupID)
        self._partitionTree = self.adGroupHelper.getProductPartitionTree(self._adGroupID)

        # Delete any existing top level nodes so we can start from scratch.
        if self._partitionTree:
            for node in self._partitionTree:
                # Skip nodes without a criterion or an unknown partition type.
                if "criterion" not in node or node["criterion"]["partitionType"] == "UNKNOWN":
                    continue
                self.createRemoveOperation(
                    self.createSubdivisionCriterion(
                        subdivisionId=node["criterion"]["id"]
                    )
                )
        if self.getOperations():
            try:
                service = self.client.GetService('AdGroupCriterionService', version='v201809')
                # Wait 5 seconds before making the call, to give Google a break.
                sleep(5)
                service.mutate(self.getOperations())
            except Exception as e:
                pass
            finally:
                pass

        # Reset the next id, operations list, subdivision map and partition tree.
        self._next_id = -1
        self._operations = []
        self._subdivisionMap = {}
        self._partitionTree = []

        # If there is a root partition or anything located at the root, add delete operations
        # for the nodes.

        # Get the subdivisions.
        self.loadSubdivisions()
        # Discern the hierarchy from the subdivisions.
        self.loadFeedStructure()

        # Load any exclusions into the ad group rows and store
        # any added exclusion strings in the instance so we don't
        # end up adding rows that should be excluded during bid
        # determination.
        # self.addExclusions()
        # Run the determiner to calculate bids.
        # Todo: Allow multiple determiners.
        determiner = self.config.getSetting("bid determiner.method", default=None)
        if determiner == "price/margin":
            self.determinerPriceMargin()

        if self.getOperations():
            service = self.client.GetService('AdGroupCriterionService', version='v201809')
            # Wait 5 seconds before making the call, to give Google a break.
            sleep(5)
            service.mutate(self.getOperations())

    def loadSubdivisions(self):
        """
        Load the ad group structure subdivisions.
        """
        self._subdivisions = self.config.getSetting("subdivisions", default=[])

    def loadFeedStructure(self):
        """
        Downloads and parses the XML feed for important data, using
        a prescribed hierarchy.
            The final list contains the unique data in the order
        of the provided subdivision labels. For instance if the subdivision
        structure is:
            - BRAND
            - PRODUCT_TYPE_L1
            - CUSTOM_ATTRIBUTE_1
            - CUSTOM_ATTRIBUTE_2
        The structure will be a list of lists as followed:
            [
                [BRAND, PRODUCT_TYPE_l1, CUSTOM_ATTRIBUTE_1, CUSTOM_ATTRIBUTE_2]
            ]

        Todo: currently only works for xml.
        """

        # Get feed settings.
        feedUrl = self.config.getSetting("feed.url", default="")
        feedType = self.config.getSetting("feed.type", default="xml")

        if feedType != "xml":
            raise TypeError("Feed located at {} should be of type xml only".format(feedUrl))

        self.loadSubdivisions()

        # Store the structure that will be used for ad group subdivisions, parsed
        # from all of the data and adhering to the provided subdivision list.
        self._feedStructure = []
        self._feedStructureItems = {}

        # Load the feed from the url.
        response = requests.get(feedUrl)
        if not response.status_code == 200:
            raise requests.RequestException("Unable to get the feed located at {}".format(feedUrl))

        # Parse the xml structure and take brand, product type and labels only.
        parsedFeedData = []
        gNamespace = "{http://base.google.com/ns/1.0}"
        atomNamespace = "{http://www.w3.org/2005/Atom}"
        root = etree.fromstring(response.content)
        for entry in root:
            # Store simplified and parsed data from each element.
            data = {}
            for element in entry:
                if element.tag == "{}brand".format(gNamespace):
                    data["BRAND"] = element.text.lower()
                elif element.tag == "{}product_type".format(gNamespace):
                    data["PRODUCT_TYPE_L1"] = element.text.lower()
                elif element.tag == "{}custom_label_0".format(gNamespace):
                    data["CUSTOM_ATTRIBUTE_0"] = element.text
                elif element.tag == "{}custom_label_1".format(gNamespace):
                    data["CUSTOM_ATTRIBUTE_1"] = element.text
                elif element.tag == "{}custom_label_2".format(gNamespace):
                    data["CUSTOM_ATTRIBUTE_2"] = element.text
                elif element.tag == "{}custom_label_3".format(gNamespace):
                    data["CUSTOM_ATTRIBUTE_3"] = element.text
                elif element.tag == "{}custom_label_4".format(gNamespace):
                    data["CUSTOM_ATTRIBUTE_4"] = element.text
                elif element.tag == "{}custom_label_5".format(gNamespace):
                    data["CUSTOM_ATTRIBUTE_5"] = element.text

            parsedFeedData.append(data)

        for row in parsedFeedData:
            rowStructure = []
            for i, subdivision in enumerate(self._subdivisions):
                label = subdivision if type(subdivision) is not dict else next(iter(subdivision))
                if label not in self._feedStructureItems:
                    self._feedStructureItems[label] = []
                if label in row:
                    # Make sure we don't have any None values.
                    rowStructure.append("" if not row[label] else row[label])
                    # Add a reference to the value so we have access to it during exclusions.
                    if not row[label] in self._feedStructureItems[label]:
                        self._feedStructureItems[label].append("" if not row[label] else row[label])
                else:
                    rowStructure.append("")
            if rowStructure not in self._feedStructure:
                self._feedStructure.append(rowStructure)

        # Sort the structure for readability.
        self._feedStructure.sort()

    def determinerPriceMargin(self):
        """
        Determines the bid using the price/margin set up, where the bid is decided
        based upon the margin within a given price range, to try to accurately
        land the cpc within a comfortable range where overbidding is least likely.
        """
        minCPC = self.config.getSetting("min cpc", default="0.01")
        maxCPC = self.config.getSetting("max cpc", default="0.01")
        creep = self.config.getSetting("bid determiner.creep", default=0)
        minProfit = self.config.getSetting("bid determiner.min profit", default=.50)
        conversionRate = self.config.getSetting("bid determiner.conversion rate", default=.50)

        subdivisions = self.config.getSetting("subdivisions", default=[])
        parameters = self.config.getSetting("bid determiner.parameters", default=[])

        if type(parameters) is not list or len(parameters) != 2:
            raise Exception("price/margin bid determiner requires 2 parameters in the config in list form.")

        for row in self._feedStructure:
            priceRange = ""
            margin = ""
            cpc = minCPC

            # Get the unit parents by creating subdivision criterion, if we
            # are unable to get a unit parent, skip.
            unitParent = self.addSubdivisions(row)
            if not unitParent:
                continue

            for i, subdivision in enumerate(subdivisions):
                xsiType = subdivision if type(subdivision) is not dict else next(iter(subdivision))
                if xsiType == parameters[0]:
                    priceRange = row[i]
                if xsiType == parameters[1]:
                    margin = row[i]

            # Get an average between the low and high ends of the range/margin.
            average = 0
            if priceRange and margin:
                priceRange = priceRange.split("-")
                margin = margin.split("-")
                if priceRange and len(priceRange) == 2 and margin and len(margin) == 2:
                    # (price range * margin) / 100, then we deduct our desired
                    # profit from the remainder, then calculate our budget based
                    # off of the current profit minus the minimum profit deducted from
                    # the profit, to determine how much we're willing to give Google
                    # we determine this from our conversion rate, if we're consistently
                    # at 2% conversion rate, we consider a higher budget.

                    # Calculate low range working budget.
                    profit = (int(priceRange[0]) * (int(margin[0]) / 100))
                    budget = profit - (profit * minProfit)
                    average += (budget / 100) * conversionRate

                    # Calculate high range working budget.
                    profit = (int(priceRange[1]) * (int(margin[1]) / 100)) * minProfit
                    budget = profit - (profit * minProfit)
                    average += (budget / 100) * conversionRate

            if average:
                cpc = round(average / 2, 2)
                if creep:
                    cpc = round(cpc + (cpc * creep), 2)

            if cpc < minCPC:
                cpc = minCPC
            elif average > maxCPC:
                cpc = maxCPC

            cpc = int(float(cpc) * ADWORDS_MICRO_AMOUNT_VALUE)

            # Add the formatted row if it's not excluded.
            # Todo: Figure out how to handle exclusions here.
            # Get the xsi type for the unit.
            xsiType = subdivisions[-1] if type(subdivisions[-1]) is not dict else next(iter(subdivisions[-1]))
            self.createAddOperation(
                self.createUnitCriterion(
                    unitParent,
                    self.getCaseValue(xsiType, row[-1]),
                    cpc
                )
            )

    def addSubdivisions(self, row: [str]) -> Union[dict, None]:
        """
        Helper function to create subdivisions during a bid determiner.
        Takes a subdivision row i.e. ["casio", "watches", "30-40", "20-40"]
        and creates subdivisions for each element in the path except the final
        position, which will be a unit. They will be stored as slash delimited
        path, for instance:
            - "/" (Root is always added if not present, then attached to the first subdivision)
            - "casio": {...}
            - "casio / watches": {...}
            - "casio / watches / 30-40": {...}
        This will allow easy reference to the parent subdivision.

        Once the subdivisions have either been created or matched, a matching
        subdivision extracted from the criterion object, will be returned.

        :param row: A list of strings denoting the subdivision mapping.
        :return Union[dict, None]: The final subdivision before the unit portion of the path, or None.
        """

        # Todo: Figure out how we're going to handle exceptions for this.
        subdivisions = self.config.getSetting("subdivisions", default=[])
        xsiTypes = []
        # Get the xsi type mapping from the subdivisions in the config file.
        for i, subdivision in enumerate(subdivisions):
            xsiType = subdivision if type(subdivision) is not dict else next(iter(subdivision))
            xsiTypes.append(xsiType)

        if "/" not in self._subdivisionMap:
            # If we have an existing root element in the partition tree, use it.
            if len(self._partitionTree) and "criterion" in self._partitionTree[0]:
                rootElement = self._partitionTree[0]
                # If the root element is a unit, because nothing has been done with it yet,
                # delete it and create a new root subdivision.
                if rootElement["criterion"]["partitionType"] == "UNIT":
                    self.createRemoveOperation(
                        self.createSubdivisionCriterion(
                            subdivisionId=rootElement["criterion"]["id"],
                        )
                    )
                    # Create the new root subdivision.
                    root = self.createSubdivisionCriterion()
                    self.createAddOperation(root)
                    # Create an empty unit inside the subdivision.
                    self.createAddOperation(
                        self.createUnitCriterion(
                            root["criterion"],
                            self.getCaseValue(xsiTypes[0], None)
                        )
                    )

                # If the root is a subdivision, then we use that instead.
                else:
                    root = self.createSubdivisionCriterion(subdivisionId=rootElement["criterion"]["id"])
            else:
                root = self.createSubdivisionCriterion()
                self.createAddOperation(root)
                # Create an empty unit inside the subdivision.
                self.createAddOperation(
                    self.createUnitCriterion(
                        root["criterion"],
                        self.getCaseValue(xsiTypes[0], None)
                    )
                )
            self._subdivisionMap["/"] = root

        # Set up a current path to find the slash delimited position so far of the row,
        # we'll use this to determine subdivision position. i.e. ["Casio", "Casio / Watches"]
        path = []
        parent = self._subdivisionMap["/"]
        for i, value in enumerate(row[:-1]):
            path.append(value)
            currentPath = " / ".join(path)
            if currentPath not in self._subdivisionMap:
                # Create the subdivision if it doesn't exist and add it to the add operations.
                parent = self.createSubdivisionCriterion(
                    parent["criterion"],
                    self.getCaseValue(xsiTypes[i], value),
                )
                self._subdivisionMap[currentPath] = parent
                self.createAddOperation(parent)
                # Todo: Handle exclusions here by looping our exclusion list in the config
                #       for now we'll just add an explict exclude everything else unit.
                # Add a negative bid criterion for the subdivision.
                if i + 1 <= len(xsiTypes):
                    self.createAddOperation(
                        self.createUnitCriterion(
                            parent["criterion"],
                            self.getCaseValue(xsiTypes[i + 1], None)
                        )
                    )
            else:
                # Get the existing subdivision if it does exist.
                parent = self._subdivisionMap[currentPath]

        # Return the criterion for the subdivision if set.
        if " / ".join(row[:-1]) in self._subdivisionMap:
            parent = self._subdivisionMap[" / ".join(row[:-1])]

        # Default to the root parent if something unexpected happened.
        return None if not parent or "criterion" not in parent else parent["criterion"]

    @staticmethod
    def getProductDimensionType(xsiType: str) -> str:
        """
        Determines the product dimension type from the ad criterion xsi type.
        i.e. CUSTOM_ATTRIBUTE_1 will return ProductCustomAttribute.

        :param xsiType: The product dimension criterion type i.e. CUSTOM_ATTRIBUTE_1
        :return str: A mapped product dimension type.
        """
        productDimensionTypes = {
            "BIDDING_CATEGORY_L1": "ProductBiddingCategory",
            "BIDDING_CATEGORY_L2": "ProductBiddingCategory",
            "BIDDING_CATEGORY_L3": "ProductBiddingCategory",
            "BIDDING_CATEGORY_L4": "ProductBiddingCategory",
            "BIDDING_CATEGORY_L5": "ProductBiddingCategory",
            "BRAND": "ProductBrand",
            "CANONICAL_CONDITION": "ProductCanonicalCondition",
            "CUSTOM_ATTRIBUTE_0": "ProductCustomAttribute",
            "CUSTOM_ATTRIBUTE_1": "ProductCustomAttribute",
            "CUSTOM_ATTRIBUTE_2": "ProductCustomAttribute",
            "CUSTOM_ATTRIBUTE_3": "ProductCustomAttribute",
            "CUSTOM_ATTRIBUTE_4": "ProductCustomAttribute",
            "OFFER_ID": "ProductOfferId",
            "PRODUCT_TYPE_L1": "ProductType",
            "PRODUCT_TYPE_L2": "ProductType",
            "PRODUCT_TYPE_L3": "ProductType",
            "PRODUCT_TYPE_L4": "ProductType",
            "PRODUCT_TYPE_L5": "ProductType",
            "CHANNEL": "ProductChannel",
            "CHANNEL_EXCLUSIVITY": "ProductChannelExclusivity"
        }

        if xsiType in productDimensionTypes:
            return productDimensionTypes[xsiType]
        return "UnknownProductDimension"

    def getCaseValue(self, xsiType: str, value: str = None) -> dict:
        """
        Constructs and returns a case value for use in subdivision or
        unit creation.

        :param xsiType: An xsi type string i.e BRAND, CUSTOM_ATTRIBUTE_1
        :param value: The value to set for the case value i.e. "sunglasses" or "100" etc
        :return dict: A case value.
        """
        caseValue = {
            "xsi_type": self.getProductDimensionType(xsiType),
            "value": value
        }

        if caseValue["xsi_type"] == "ProductCanonicalCondition":
            caseValue["condition"] = caseValue.pop("value")
        if caseValue["xsi_type"] == "ProductChannel":
            caseValue["channel"] = caseValue.pop("value")
        if caseValue["xsi_type"] == "ProductChannelExclusivity":
            caseValue["channelExclusivity"] = caseValue.pop("value")
        if caseValue["xsi_type"] in ["ProductBiddingCategory", "ProductType", "ProductCustomAttribute"]:
            caseValue["type"] = xsiType
        if caseValue["xsi_type"] == "UnknownProductDimension":
            caseValue.pop("value")

        return caseValue

    def addExclusions(self):
        """
        Add any pending exclusions, these are normally done
        """
        self._addedExclusions = []
        exclusionGroups = self.config.getSetting("exclusions", default=[])
        subdivisions = self.config.getSetting("subdivisions", default=[])

        if not exclusionGroups or not subdivisions:
            return

        # Loop each exclusion group
        # If the * character is present in the final position of an exclusion,
        # use it literally, otherwise recursively create exclusions for
        # everything succeeding it using pre-generated feed structure data.

        # Loop each exclusion group definition and apply them to the ad group rows.
        for exclusion in exclusionGroups:
            self.addExclusion(exclusion)

    def addExclusion(self, exclusion: list):
        """
        Recursive function to add exclusions to the instance ad groups.

        :param exclusion: A list containing the exclusion definition.
        """

        subdivisions = self.config.getSetting("subdivisions", default=[])
        if not len(subdivisions):
            raise Exception("No Subdivisions have been defined for ad group {}.".format(self.id()))

        # Loop each exclusion, if the exclusion has multiple list items in each
        # position, then we clone the exclusion and reduce that position to contain
        # only one of each list item, which is then passed recursively back into
        # the function. If we encounter a * at any position before the last, we
        # will loop all items in the feed structure recursively back into this
        # function, if we encounter a * at the last position, then we treat it
        # literally.
        build = ["*"]
        for i, position in enumerate(exclusion):
            # Multiple items in an exclusion position run recursively
            # by cloning out the exclusion and using only one item each
            # for the current position.
            if len(exclusion[position]) > 1:
                for item in exclusion[position]:
                    exclusionCopy = exclusion.copy()
                    exclusionCopy[position] = [item]
                    self.addExclusion(exclusionCopy)
            # If we encounter a * but we're not in the final position,
            # then we need to explode the contents of the feed structure
            # and clone out the exclusion for each item in the structure.
            elif exclusion[position][0] == "*" and i < len(exclusion) - 1:
                if position not in self._feedStructureItems:
                    raise Exception("{} doesn't exist in the feed structure, unable to parse exclusions.".format(position))
                for item in self._feedStructureItems[position]:
                    exclusionCopy = exclusion.copy()
                    exclusionCopy[position] = [item]
                    self.addExclusion(exclusionCopy)
            # Finally, add the item to the build.
            else:
                build.append("{} = \"{}\"".format(position, exclusion[position][0]))

        # Add the formatted row.
        if len(build) > 1:
            formattedExclusion = "{} /".format(" / ".join(build))
            self._addedExclusions.append(formattedExclusion)
            self._adGroupRows.append({
                "Product group": formattedExclusion,
                "Partition Type": "UNIT",
                "Action": "set",
                "MaxCPC": "0",
                "Ad Group ID": self.id(),
                "Ad Group": self.name()
            })

    def createSubdivisionCriterion(self, parent: dict = None, value: dict = None, subdivisionId: int = None) -> dict:
        """
        Creates a new subdivision criterion.

        :param parent: An optional parent node.
        :param value: The partition value.
        :param subdivisionId: An optional id for an existing partition.
        :return dict: A new subdivision.
        """
        subdivision = {
            "xsi_type": "ProductPartition",
            "partitionType": "SUBDIVISION",
            "id": str(self._next_id) if not subdivisionId else str(subdivisionId)
        }

        if parent and "id" in parent:
            subdivision['parentCriterionId'] = parent['id']
            subdivision['caseValue'] = value

        adGroupCriterion = {
            'xsi_type': 'BiddableAdGroupCriterion',
            'adGroupId': self._adGroupID,
            'criterion': subdivision
        }

        if not subdivisionId:
            self._next_id -= 1
        return adGroupCriterion

    def createUnitCriterion(self, parent: dict = None, value: dict = None, amount: int = None) -> dict:
        """
        Create a subdivision unit ad group criterion.

        :param parent: An optional parent to attach this node.
        :param value: The partition value.
        :param amount: The bid as a micro amount i.e. 100000.
        :return dict: Returns a new biddable or negative ad group criterion.
        """
        unit = {
            "xsi_type": "ProductPartition",
            "partitionType": "UNIT"
        }

        # Attach the parent if present.
        if parent and "id" in parent:
            unit["parentCriterionId"] = parent["id"]
            unit["caseValue"] = value

        # If we have an amount, configure the bid.
        if amount and amount > 0:
            adGroupCriterion = {
                "xsi_type": "BiddableAdGroupCriterion",
                "biddingStrategyConfiguration": AutomationAdGroup.createBidStrategyConfiguration(amount)
            }
        # Default to negative bid ad group criterion.
        else:
            adGroupCriterion = {
                "xsi_type": "NegativeAdGroupCriterion"
            }

        adGroupCriterion["adGroupId"] = self._adGroupID
        adGroupCriterion["criterion"] = unit

        return adGroupCriterion

    def createAddOperation(self, criterion: dict):
        """
        Adds an Add operation to the instance operations list.

        :param criterion: A fully formed criterion to add.
        """

        if type(self._operations) is not list:
            self._operations = []

        self._operations.append(
            {
                "operator": "ADD",
                "operand": criterion
            }
        )

    def createSetOperation(self, criterion: dict):
        """
        Adds a Set operation to the instance operations list.

        :param criterion: A fully formed criterion to Set.
        """

        if type(self._operations) is not list:
            self._operations = []

        self._operations.append(
            {
                "operator": "SET",
                "operand": criterion
            }
        )

    def createRemoveOperation(self, criterion: dict):
        """
        Adds a Remove operation to the instance operations list.

        :param criterion: A fully formed criterion to Set.
        """

        if type(self._operations) is not list:
            self._operations = []

        self._operations.append(
            {
                "operator": "REMOVE",
                "operand": criterion
            }
        )

    def getOperations(self) -> list:
        """
        Get the operations list.

        :return list: The operations list.
        """
        if type(self._operations) is not list:
            return []

        return self._operations

    @staticmethod
    def createBidStrategyConfiguration(amount: int) -> dict:
        """
        Returns the correct bid strategy configuration structure, using CPC, for
        product subdivisions.

        The amount should be a micro amount, pre-formatted i.e. 0.20 should be multiplied
        by 1 million: 0.20 * 1,000,000 = 200000

        :param amount: A micro amount with 1 million amounting to 1 unit i.e. $1 USD.
        :return dict: A dict containing bid strategy configuration.
        """
        return {
            "bids": [{
                "xsi_type": "CpcBid",
                "bid": {
                    "xsi_type": "Money",
                    "microAmount": str(amount)
                }
            }]
        }

<filename>src/advertising/googleads/src/automation/AutomationAdGroup.py
from typing import Union
from googleads import AdWordsClient
from time import sleep
from pymongo.collection import Collection as MongoCollection
from src.Config import Config
from src.Globals import ADWORDS_MICRO_AMOUNT_VALUE
from src.advertising.googleads.AdGroup import AdGroup as AdGroupHelper
from lxml import etree
import requests


class AutomationAdGroup:
    """
    Defines a Google Campaign Ad Group.
    """

    def __init__(self, config: Config, client: AdWordsClient, googleAdsCollection: MongoCollection):
        if not config or type(config) is not Config:
            raise Exception("AdGroup requires a valid config object")
        if not config.getSetting("id", default=None):
            raise Exception("AdGroup requires an id")

        self.config = config
        self.client = client
        self.googleAdsCollection = googleAdsCollection
        self._adGroupID = config.getSetting("id")
        self._adGroupName = config.getSetting("name")
        self._subdivisions = []
        self._adGroupRows = []
        self._feedStructure = []
        self._addedExclusions = []
        self._feedStructureItems = {}
        self._operations = []
        self._partitionTree = []
        self._subdivisionMap = {}
        # We load the AdGroup class as AdGroupHelper so we can access
        # existing partitions for this ad group during processing and
        # to reload if necessary.
        self.adGroupHelper = AdGroupHelper(client, googleAdsCollection)
        # A temporary id for use with the creation of new node creation.
        self._next_id = -1

    def id(self) -> int:
        """
        Get the ad group id.

        :return int: The ad group ID
        """
        return self._adGroupID

    def name(self) -> str:
        """
        Get the ad group name.

        :return str: The ad group name
        """
        return self._adGroupName

    def getAdGroupRows(self) -> list:
        """
        Returns the ad group rows list.

        :return list: Ad groups in list form
        """
        return self._adGroupRows if type(self._adGroupRows) is list else []

    def build(self):
        """
        Build the ad group, handles all of the loading of ad group data and
        rows.
        """

        # Reset the next id, operations list and subdivision map.
        self._next_id = -1
        self._operations = []
        self._subdivisionMap = {}

        # Download and cache the product partition tree.
        self.adGroupHelper.cacheProductPartitions(self._adGroupID)
        self._partitionTree = self.adGroupHelper.getProductPartitionTree(self._adGroupID)

        # Delete any existing top level nodes so we can start from scratch.
        if self._partitionTree:
            for node in self._partitionTree:
                # Skip nodes without a criterion or an unknown partition type.
                if "criterion" not in node or node["criterion"]["partitionType"] == "UNKNOWN":
                    continue
                self.createRemoveOperation(
                    self.createSubdivisionCriterion(
                        subdivisionId=node["criterion"]["id"]
                    )
                )
        if self.getOperations():
            try:
                service = self.client.GetService('AdGroupCriterionService', version='v201809')
                # Wait 5 seconds before making the call, to give Google a break.
                sleep(5)
                service.mutate(self.getOperations())
            except Exception as e:
                pass
            finally:
                pass

        # Reset the next id, operations list, subdivision map and partition tree.
        self._next_id = -1
        self._operations = []
        self._subdivisionMap = {}
        self._partitionTree = []

        # If there is a root partition or anything located at the root, add delete operations
        # for the nodes.

        # Get the subdivisions.
        self.loadSubdivisions()
        # Discern the hierarchy from the subdivisions.
        self.loadFeedStructure()

        # Load any exclusions into the ad group rows and store
        # any added exclusion strings in the instance so we don't
        # end up adding rows that should be excluded during bid
        # determination.
        # self.addExclusions()
        # Run the determiner to calculate bids.
        # Todo: Allow multiple determiners.
        determiner = self.config.getSetting("bid determiner.method", default=None)
        if determiner == "price/margin":
            self.determinerPriceMargin()

        if self.getOperations():
            service = self.client.GetService('AdGroupCriterionService', version='v201809')
            # Wait 5 seconds before making the call, to give Google a break.
            sleep(5)
            service.mutate(self.getOperations())

    def loadSubdivisions(self):
        """
        Load the ad group structure subdivisions.
        """
        self._subdivisions = self.config.getSetting("subdivisions", default=[])

    def loadFeedStructure(self):
        """
        Downloads and parses the XML feed for important data, using
        a prescribed hierarchy.
            The final list contains the unique data in the order
        of the provided subdivision labels. For instance if the subdivision
        structure is:
            - BRAND
            - PRODUCT_TYPE_L1
            - CUSTOM_ATTRIBUTE_1
            - CUSTOM_ATTRIBUTE_2
        The structure will be a list of lists as followed:
            [
                [BRAND, PRODUCT_TYPE_l1, CUSTOM_ATTRIBUTE_1, CUSTOM_ATTRIBUTE_2]
            ]

        Todo: currently only works for xml.
        """

        # Get feed settings.
        feedUrl = self.config.getSetting("feed.url", default="")
        feedType = self.config.getSetting("feed.type", default="xml")

        if feedType != "xml":
            raise TypeError("Feed located at {} should be of type xml only".format(feedUrl))

        self.loadSubdivisions()

        # Store the structure that will be used for ad group subdivisions, parsed
        # from all of the data and adhering to the provided subdivision list.
        self._feedStructure = []
        self._feedStructureItems = {}

        # Load the feed from the url.
        response = requests.get(feedUrl)
        if not response.status_code == 200:
            raise requests.RequestException("Unable to get the feed located at {}".format(feedUrl))

        # Parse the xml structure and take brand, product type and labels only.
        parsedFeedData = []
        gNamespace = "{http://base.google.com/ns/1.0}"
        atomNamespace = "{http://www.w3.org/2005/Atom}"
        root = etree.fromstring(response.content)
        for entry in root:
            # Store simplified and parsed data from each element.
            data = {}
            for element in entry:
                if element.tag == "{}brand".format(gNamespace):
                    data["BRAND"] = element.text.lower()
                elif element.tag == "{}product_type".format(gNamespace):
                    data["PRODUCT_TYPE_L1"] = element.text.lower()
                elif element.tag == "{}custom_label_0".format(gNamespace):
                    data["CUSTOM_ATTRIBUTE_0"] = element.text
                elif element.tag == "{}custom_label_1".format(gNamespace):
                    data["CUSTOM_ATTRIBUTE_1"] = element.text
                elif element.tag == "{}custom_label_2".format(gNamespace):
                    data["CUSTOM_ATTRIBUTE_2"] = element.text
                elif element.tag == "{}custom_label_3".format(gNamespace):
                    data["CUSTOM_ATTRIBUTE_3"] = element.text
                elif element.tag == "{}custom_label_4".format(gNamespace):
                    data["CUSTOM_ATTRIBUTE_4"] = element.text
                elif element.tag == "{}custom_label_5".format(gNamespace):
                    data["CUSTOM_ATTRIBUTE_5"] = element.text

            parsedFeedData.append(data)

        for row in parsedFeedData:
            rowStructure = []
            for i, subdivision in enumerate(self._subdivisions):
                label = subdivision if type(subdivision) is not dict else next(iter(subdivision))
                if label not in self._feedStructureItems:
                    self._feedStructureItems[label] = []
                if label in row:
                    # Make sure we don't have any None values.
                    rowStructure.append("" if not row[label] else row[label])
                    # Add a reference to the value so we have access to it during exclusions.
                    if not row[label] in self._feedStructureItems[label]:
                        self._feedStructureItems[label].append("" if not row[label] else row[label])
                else:
                    rowStructure.append("")
            if rowStructure not in self._feedStructure:
                self._feedStructure.append(rowStructure)

        # Sort the structure for readability.
        self._feedStructure.sort()

    def determinerPriceMargin(self):
        """
        Determines the bid using the price/margin set up, where the bid is decided
        based upon the margin within a given price range, to try to accurately
        land the cpc within a comfortable range where overbidding is least likely.
        """
        minCPC = self.config.getSetting("min cpc", default="0.01")
        maxCPC = self.config.getSetting("max cpc", default="0.01")
        creep = self.config.getSetting("bid determiner.creep", default=0)
        minProfit = self.config.getSetting("bid determiner.min profit", default=.50)
        conversionRate = self.config.getSetting("bid determiner.conversion rate", default=.50)

        subdivisions = self.config.getSetting("subdivisions", default=[])
        parameters = self.config.getSetting("bid determiner.parameters", default=[])

        if type(parameters) is not list or len(parameters) != 2:
            raise Exception("price/margin bid determiner requires 2 parameters in the config in list form.")

        for row in self._feedStructure:
            priceRange = ""
            margin = ""
            cpc = minCPC

            # Get the unit parents by creating subdivision criterion, if we
            # are unable to get a unit parent, skip.
            unitParent = self.addSubdivisions(row)
            if not unitParent:
                continue

            for i, subdivision in enumerate(subdivisions):
                xsiType = subdivision if type(subdivision) is not dict else next(iter(subdivision))
                if xsiType == parameters[0]:
                    priceRange = row[i]
                if xsiType == parameters[1]:
                    margin = row[i]

            # Get an average between the low and high ends of the range/margin.
            average = 0
            if priceRange and margin:
                priceRange = priceRange.split("-")
                margin = margin.split("-")
                if priceRange and len(priceRange) == 2 and margin and len(margin) == 2:
                    # (price range * margin) / 100, then we deduct our desired
                    # profit from the remainder, then calculate our budget based
                    # off of the current profit minus the minimum profit deducted from
                    # the profit, to determine how much we're willing to give Google
                    # we determine this from our conversion rate, if we're consistently
                    # at 2% conversion rate, we consider a higher budget.

                    # Calculate low range working budget.
                    profit = (int(priceRange[0]) * (int(margin[0]) / 100))
                    budget = profit - (profit * minProfit)
                    average += (budget / 100) * conversionRate

                    # Calculate high range working budget.
                    profit = (int(priceRange[1]) * (int(margin[1]) / 100)) * minProfit
                    budget = profit - (profit * minProfit)
                    average += (budget / 100) * conversionRate

            if average:
                cpc = round(average / 2, 2)
                if creep:
                    cpc = round(cpc + (cpc * creep), 2)

            if cpc < minCPC:
                cpc = minCPC
            elif average > maxCPC:
                cpc = maxCPC

            cpc = int(float(cpc) * ADWORDS_MICRO_AMOUNT_VALUE)

            # Add the formatted row if it's not excluded.
            # Todo: Figure out how to handle exclusions here.
            # Get the xsi type for the unit.
            xsiType = subdivisions[-1] if type(subdivisions[-1]) is not dict else next(iter(subdivisions[-1]))
            self.createAddOperation(
                self.createUnitCriterion(
                    unitParent,
                    self.getCaseValue(xsiType, row[-1]),
                    cpc
                )
            )

    def addSubdivisions(self, row: [str]) -> Union[dict, None]:
        """
        Helper function to create subdivisions during a bid determiner.
        Takes a subdivision row i.e. ["casio", "watches", "30-40", "20-40"]
        and creates subdivisions for each element in the path except the final
        position, which will be a unit. They will be stored as slash delimited
        path, for instance:
            - "/" (Root is always added if not present, then attached to the first subdivision)
            - "casio": {...}
            - "casio / watches": {...}
            - "casio / watches / 30-40": {...}
        This will allow easy reference to the parent subdivision.

        Once the subdivisions have either been created or matched, a matching
        subdivision extracted from the criterion object, will be returned.

        :param row: A list of strings denoting the subdivision mapping.
        :return Union[dict, None]: The final subdivision before the unit portion of the path, or None.
        """

        # Todo: Figure out how we're going to handle exceptions for this.
        subdivisions = self.config.getSetting("subdivisions", default=[])
        xsiTypes = []
        # Get the xsi type mapping from the subdivisions in the config file.
        for i, subdivision in enumerate(subdivisions):
            xsiType = subdivision if type(subdivision) is not dict else next(iter(subdivision))
            xsiTypes.append(xsiType)

        if "/" not in self._subdivisionMap:
            # If we have an existing root element in the partition tree, use it.
            if len(self._partitionTree) and "criterion" in self._partitionTree[0]:
                rootElement = self._partitionTree[0]
                # If the root element is a unit, because nothing has been done with it yet,
                # delete it and create a new root subdivision.
                if rootElement["criterion"]["partitionType"] == "UNIT":
                    self.createRemoveOperation(
                        self.createSubdivisionCriterion(
                            subdivisionId=rootElement["criterion"]["id"],
                        )
                    )
                    # Create the new root subdivision.
                    root = self.createSubdivisionCriterion()
                    self.createAddOperation(root)
                    # Create an empty unit inside the subdivision.
                    self.createAddOperation(
                        self.createUnitCriterion(
                            root["criterion"],
                            self.getCaseValue(xsiTypes[0], None)
                        )
                    )

                # If the root is a subdivision, then we use that instead.
                else:
                    root = self.createSubdivisionCriterion(subdivisionId=rootElement["criterion"]["id"])
            else:
                root = self.createSubdivisionCriterion()
                self.createAddOperation(root)
                # Create an empty unit inside the subdivision.
                self.createAddOperation(
                    self.createUnitCriterion(
                        root["criterion"],
                        self.getCaseValue(xsiTypes[0], None)
                    )
                )
            self._subdivisionMap["/"] = root

        # Set up a current path to find the slash delimited position so far of the row,
        # we'll use this to determine subdivision position. i.e. ["Casio", "Casio / Watches"]
        path = []
        parent = self._subdivisionMap["/"]
        for i, value in enumerate(row[:-1]):
            path.append(value)
            currentPath = " / ".join(path)
            if currentPath not in self._subdivisionMap:
                # Create the subdivision if it doesn't exist and add it to the add operations.
                parent = self.createSubdivisionCriterion(
                    parent["criterion"],
                    self.getCaseValue(xsiTypes[i], value),
                )
                self._subdivisionMap[currentPath] = parent
                self.createAddOperation(parent)
                # Todo: Handle exclusions here by looping our exclusion list in the config
                #       for now we'll just add an explict exclude everything else unit.
                # Add a negative bid criterion for the subdivision.
                if i + 1 <= len(xsiTypes):
                    self.createAddOperation(
                        self.createUnitCriterion(
                            parent["criterion"],
                            self.getCaseValue(xsiTypes[i + 1], None)
                        )
                    )
            else:
                # Get the existing subdivision if it does exist.
                parent = self._subdivisionMap[currentPath]

        # Return the criterion for the subdivision if set.
        if " / ".join(row[:-1]) in self._subdivisionMap:
            parent = self._subdivisionMap[" / ".join(row[:-1])]

        # Default to the root parent if something unexpected happened.
        return None if not parent or "criterion" not in parent else parent["criterion"]

    @staticmethod
    def getProductDimensionType(xsiType: str) -> str:
        """
        Determines the product dimension type from the ad criterion xsi type.
        i.e. CUSTOM_ATTRIBUTE_1 will return ProductCustomAttribute.

        :param xsiType: The product dimension criterion type i.e. CUSTOM_ATTRIBUTE_1
        :return str: A mapped product dimension type.
        """
        productDimensionTypes = {
            "BIDDING_CATEGORY_L1": "ProductBiddingCategory",
            "BIDDING_CATEGORY_L2": "ProductBiddingCategory",
            "BIDDING_CATEGORY_L3": "ProductBiddingCategory",
            "BIDDING_CATEGORY_L4": "ProductBiddingCategory",
            "BIDDING_CATEGORY_L5": "ProductBiddingCategory",
            "BRAND": "ProductBrand",
            "CANONICAL_CONDITION": "ProductCanonicalCondition",
            "CUSTOM_ATTRIBUTE_0": "ProductCustomAttribute",
            "CUSTOM_ATTRIBUTE_1": "ProductCustomAttribute",
            "CUSTOM_ATTRIBUTE_2": "ProductCustomAttribute",
            "CUSTOM_ATTRIBUTE_3": "ProductCustomAttribute",
            "CUSTOM_ATTRIBUTE_4": "ProductCustomAttribute",
            "OFFER_ID": "ProductOfferId",
            "PRODUCT_TYPE_L1": "ProductType",
            "PRODUCT_TYPE_L2": "ProductType",
            "PRODUCT_TYPE_L3": "ProductType",
            "PRODUCT_TYPE_L4": "ProductType",
            "PRODUCT_TYPE_L5": "ProductType",
            "CHANNEL": "ProductChannel",
            "CHANNEL_EXCLUSIVITY": "ProductChannelExclusivity"
        }

        if xsiType in productDimensionTypes:
            return productDimensionTypes[xsiType]
        return "UnknownProductDimension"

    def getCaseValue(self, xsiType: str, value: str = None) -> dict:
        """
        Constructs and returns a case value for use in subdivision or
        unit creation.

        :param xsiType: An xsi type string i.e BRAND, CUSTOM_ATTRIBUTE_1
        :param value: The value to set for the case value i.e. "sunglasses" or "100" etc
        :return dict: A case value.
        """
        caseValue = {
            "xsi_type": self.getProductDimensionType(xsiType),
            "value": value
        }

        if caseValue["xsi_type"] == "ProductCanonicalCondition":
            caseValue["condition"] = caseValue.pop("value")
        if caseValue["xsi_type"] == "ProductChannel":
            caseValue["channel"] = caseValue.pop("value")
        if caseValue["xsi_type"] == "ProductChannelExclusivity":
            caseValue["channelExclusivity"] = caseValue.pop("value")
        if caseValue["xsi_type"] in ["ProductBiddingCategory", "ProductType", "ProductCustomAttribute"]:
            caseValue["type"] = xsiType
        if caseValue["xsi_type"] == "UnknownProductDimension":
            caseValue.pop("value")

        return caseValue

    def addExclusions(self):
        """
        Add any pending exclusions, these are normally done
        """
        self._addedExclusions = []
        exclusionGroups = self.config.getSetting("exclusions", default=[])
        subdivisions = self.config.getSetting("subdivisions", default=[])

        if not exclusionGroups or not subdivisions:
            return

        # Loop each exclusion group
        # If the * character is present in the final position of an exclusion,
        # use it literally, otherwise recursively create exclusions for
        # everything succeeding it using pre-generated feed structure data.

        # Loop each exclusion group definition and apply them to the ad group rows.
        for exclusion in exclusionGroups:
            self.addExclusion(exclusion)

    def addExclusion(self, exclusion: list):
        """
        Recursive function to add exclusions to the instance ad groups.

        :param exclusion: A list containing the exclusion definition.
        """

        subdivisions = self.config.getSetting("subdivisions", default=[])
        if not len(subdivisions):
            raise Exception("No Subdivisions have been defined for ad group {}.".format(self.id()))

        # Loop each exclusion, if the exclusion has multiple list items in each
        # position, then we clone the exclusion and reduce that position to contain
        # only one of each list item, which is then passed recursively back into
        # the function. If we encounter a * at any position before the last, we
        # will loop all items in the feed structure recursively back into this
        # function, if we encounter a * at the last position, then we treat it
        # literally.
        build = ["*"]
        for i, position in enumerate(exclusion):
            # Multiple items in an exclusion position run recursively
            # by cloning out the exclusion and using only one item each
            # for the current position.
            if len(exclusion[position]) > 1:
                for item in exclusion[position]:
                    exclusionCopy = exclusion.copy()
                    exclusionCopy[position] = [item]
                    self.addExclusion(exclusionCopy)
            # If we encounter a * but we're not in the final position,
            # then we need to explode the contents of the feed structure
            # and clone out the exclusion for each item in the structure.
            elif exclusion[position][0] == "*" and i < len(exclusion) - 1:
                if position not in self._feedStructureItems:
                    raise Exception("{} doesn't exist in the feed structure, unable to parse exclusions.".format(position))
                for item in self._feedStructureItems[position]:
                    exclusionCopy = exclusion.copy()
                    exclusionCopy[position] = [item]
                    self.addExclusion(exclusionCopy)
            # Finally, add the item to the build.
            else:
                build.append("{} = \"{}\"".format(position, exclusion[position][0]))

        # Add the formatted row.
        if len(build) > 1:
            formattedExclusion = "{} /".format(" / ".join(build))
            self._addedExclusions.append(formattedExclusion)
            self._adGroupRows.append({
                "Product group": formattedExclusion,
                "Partition Type": "UNIT",
                "Action": "set",
                "MaxCPC": "0",
                "Ad Group ID": self.id(),
                "Ad Group": self.name()
            })

    def createSubdivisionCriterion(self, parent: dict = None, value: dict = None, subdivisionId: int = None) -> dict:
        """
        Creates a new subdivision criterion.

        :param parent: An optional parent node.
        :param value: The partition value.
        :param subdivisionId: An optional id for an existing partition.
        :return dict: A new subdivision.
        """
        subdivision = {
            "xsi_type": "ProductPartition",
            "partitionType": "SUBDIVISION",
            "id": str(self._next_id) if not subdivisionId else str(subdivisionId)
        }

        if parent and "id" in parent:
            subdivision['parentCriterionId'] = parent['id']
            subdivision['caseValue'] = value

        adGroupCriterion = {
            'xsi_type': 'BiddableAdGroupCriterion',
            'adGroupId': self._adGroupID,
            'criterion': subdivision
        }

        if not subdivisionId:
            self._next_id -= 1
        return adGroupCriterion

    def createUnitCriterion(self, parent: dict = None, value: dict = None, amount: int = None) -> dict:
        """
        Create a subdivision unit ad group criterion.

        :param parent: An optional parent to attach this node.
        :param value: The partition value.
        :param amount: The bid as a micro amount i.e. 100000.
        :return dict: Returns a new biddable or negative ad group criterion.
        """
        unit = {
            "xsi_type": "ProductPartition",
            "partitionType": "UNIT"
        }

        # Attach the parent if present.
        if parent and "id" in parent:
            unit["parentCriterionId"] = parent["id"]
            unit["caseValue"] = value

        # If we have an amount, configure the bid.
        if amount and amount > 0:
            adGroupCriterion = {
                "xsi_type": "BiddableAdGroupCriterion",
                "biddingStrategyConfiguration": AutomationAdGroup.createBidStrategyConfiguration(amount)
            }
        # Default to negative bid ad group criterion.
        else:
            adGroupCriterion = {
                "xsi_type": "NegativeAdGroupCriterion"
            }

        adGroupCriterion["adGroupId"] = self._adGroupID
        adGroupCriterion["criterion"] = unit

        return adGroupCriterion

    def createAddOperation(self, criterion: dict):
        """
        Adds an Add operation to the instance operations list.

        :param criterion: A fully formed criterion to add.
        """

        if type(self._operations) is not list:
            self._operations = []

        self._operations.append(
            {
                "operator": "ADD",
                "operand": criterion
            }
        )

    def createSetOperation(self, criterion: dict):
        """
        Adds a Set operation to the instance operations list.

        :param criterion: A fully formed criterion to Set.
        """

        if type(self._operations) is not list:
            self._operations = []

        self._operations.append(
            {
                "operator": "SET",
                "operand": criterion
            }
        )

    def createRemoveOperation(self, criterion: dict):
        """
        Adds a Remove operation to the instance operations list.

        :param criterion: A fully formed criterion to Set.
        """

        if type(self._operations) is not list:
            self._operations = []

        self._operations.append(
            {
                "operator": "REMOVE",
                "operand": criterion
            }
        )

    def getOperations(self) -> list:
        """
        Get the operations list.

        :return list: The operations list.
        """
        if type(self._operations) is not list:
            return []

        return self._operations

    @staticmethod
    def createBidStrategyConfiguration(amount: int) -> dict:
        """
        Returns the correct bid strategy configuration structure, using CPC, for
        product subdivisions.

        The amount should be a micro amount, pre-formatted i.e. 0.20 should be multiplied
        by 1 million: 0.20 * 1,000,000 = 200000

        :param amount: A micro amount with 1 million amounting to 1 unit i.e. $1 USD.
        :return dict: A dict containing bid strategy configuration.
        """
        return {
            "bids": [{
                "xsi_type": "CpcBid",
                "bid": {
                    "xsi_type": "Money",
                    "microAmount": str(amount)
                }
            }]
        }

Defines a Google Campaign Ad Group. # We load the AdGroup class as AdGroupHelper so we can access # existing partitions for this ad group during processing and # to reload if necessary. # A temporary id for use with the creation of new node creation. Get the ad group id.          :return int: The ad group ID Get the ad group name.          :return str: The ad group name Returns the ad group rows list.          :return list: Ad groups in list form Build the ad group, handles all of the loading of ad group data and         rows. # Reset the next id, operations list and subdivision map. # Download and cache the product partition tree. # Delete any existing top level nodes so we can start from scratch. # Skip nodes without a criterion or an unknown partition type. # Wait 5 seconds before making the call, to give Google a break. # Reset the next id, operations list, subdivision map and partition tree. # If there is a root partition or anything located at the root, add delete operations # for the nodes. # Get the subdivisions. # Discern the hierarchy from the subdivisions. # Load any exclusions into the ad group rows and store # any added exclusion strings in the instance so we don't # end up adding rows that should be excluded during bid # determination. # self.addExclusions() # Run the determiner to calculate bids. # Todo: Allow multiple determiners. # Wait 5 seconds before making the call, to give Google a break. Load the ad group structure subdivisions. Downloads and parses the XML feed for important data, using         a prescribed hierarchy.             The final list contains the unique data in the order         of the provided subdivision labels. For instance if the subdivision         structure is:             - BRAND             - PRODUCT_TYPE_L1             - CUSTOM_ATTRIBUTE_1             - CUSTOM_ATTRIBUTE_2         The structure will be a list of lists as followed:             [                 [BRAND, PRODUCT_TYPE_l1, CUSTOM_ATTRIBUTE_1, CUSTOM_ATTRIBUTE_2]             ]          Todo: currently only works for xml. # Get feed settings. # Store the structure that will be used for ad group subdivisions, parsed # from all of the data and adhering to the provided subdivision list. # Load the feed from the url. # Parse the xml structure and take brand, product type and labels only. # Store simplified and parsed data from each element. # Make sure we don't have any None values. # Add a reference to the value so we have access to it during exclusions. # Sort the structure for readability. Determines the bid using the price/margin set up, where the bid is decided         based upon the margin within a given price range, to try to accurately         land the cpc within a comfortable range where overbidding is least likely. # Get the unit parents by creating subdivision criterion, if we # are unable to get a unit parent, skip. # Get an average between the low and high ends of the range/margin. # (price range * margin) / 100, then we deduct our desired # profit from the remainder, then calculate our budget based # off of the current profit minus the minimum profit deducted from # the profit, to determine how much we're willing to give Google # we determine this from our conversion rate, if we're consistently # at 2% conversion rate, we consider a higher budget. # Calculate low range working budget. # Calculate high range working budget. # Add the formatted row if it's not excluded. # Todo: Figure out how to handle exclusions here. # Get the xsi type for the unit. Helper function to create subdivisions during a bid determiner.         Takes a subdivision row i.e. ["casio", "watches", "30-40", "20-40"]         and creates subdivisions for each element in the path except the final         position, which will be a unit. They will be stored as slash delimited         path, for instance:             - "/" (Root is always added if not present, then attached to the first subdivision)             - "casio": {...}             - "casio / watches": {...}             - "casio / watches / 30-40": {...}         This will allow easy reference to the parent subdivision.          Once the subdivisions have either been created or matched, a matching         subdivision extracted from the criterion object, will be returned.          :param row: A list of strings denoting the subdivision mapping.         :return Union[dict, None]: The final subdivision before the unit portion of the path, or None. # Todo: Figure out how we're going to handle exceptions for this. # Get the xsi type mapping from the subdivisions in the config file. # If we have an existing root element in the partition tree, use it. # If the root element is a unit, because nothing has been done with it yet, # delete it and create a new root subdivision. # Create the new root subdivision. # Create an empty unit inside the subdivision. # If the root is a subdivision, then we use that instead. # Create an empty unit inside the subdivision. # Set up a current path to find the slash delimited position so far of the row, # we'll use this to determine subdivision position. i.e. ["Casio", "Casio / Watches"] # Create the subdivision if it doesn't exist and add it to the add operations. # Todo: Handle exclusions here by looping our exclusion list in the config #       for now we'll just add an explict exclude everything else unit. # Add a negative bid criterion for the subdivision. # Get the existing subdivision if it does exist. # Return the criterion for the subdivision if set. # Default to the root parent if something unexpected happened. Determines the product dimension type from the ad criterion xsi type.         i.e. CUSTOM_ATTRIBUTE_1 will return ProductCustomAttribute.          :param xsiType: The product dimension criterion type i.e. CUSTOM_ATTRIBUTE_1         :return str: A mapped product dimension type. Constructs and returns a case value for use in subdivision or         unit creation.          :param xsiType: An xsi type string i.e BRAND, CUSTOM_ATTRIBUTE_1         :param value: The value to set for the case value i.e. "sunglasses" or "100" etc         :return dict: A case value. Add any pending exclusions, these are normally done # Loop each exclusion group # If the * character is present in the final position of an exclusion, # use it literally, otherwise recursively create exclusions for # everything succeeding it using pre-generated feed structure data. # Loop each exclusion group definition and apply them to the ad group rows. Recursive function to add exclusions to the instance ad groups.          :param exclusion: A list containing the exclusion definition. # Loop each exclusion, if the exclusion has multiple list items in each # position, then we clone the exclusion and reduce that position to contain # only one of each list item, which is then passed recursively back into # the function. If we encounter a * at any position before the last, we # will loop all items in the feed structure recursively back into this # function, if we encounter a * at the last position, then we treat it # literally. # Multiple items in an exclusion position run recursively # by cloning out the exclusion and using only one item each # for the current position. # If we encounter a * but we're not in the final position, # then we need to explode the contents of the feed structure # and clone out the exclusion for each item in the structure. # Finally, add the item to the build. # Add the formatted row. Creates a new subdivision criterion.          :param parent: An optional parent node.         :param value: The partition value.         :param subdivisionId: An optional id for an existing partition.         :return dict: A new subdivision. Create a subdivision unit ad group criterion.          :param parent: An optional parent to attach this node.         :param value: The partition value.         :param amount: The bid as a micro amount i.e. 100000.         :return dict: Returns a new biddable or negative ad group criterion. # Attach the parent if present. # If we have an amount, configure the bid. # Default to negative bid ad group criterion. Adds an Add operation to the instance operations list.          :param criterion: A fully formed criterion to add. Adds a Set operation to the instance operations list.          :param criterion: A fully formed criterion to Set. Adds a Remove operation to the instance operations list.          :param criterion: A fully formed criterion to Set. Get the operations list.          :return list: The operations list. Returns the correct bid strategy configuration structure, using CPC, for         product subdivisions.          The amount should be a micro amount, pre-formatted i.e. 0.20 should be multiplied         by 1 million: 0.20 * 1,000,000 = 200000          :param amount: A micro amount with 1 million amounting to 1 unit i.e. $1 USD.         :return dict: A dict containing bid strategy configuration.