Repository: adswerve/GA4-Measurement-Protocol-Python (language: python)

Issue:
Feature/set properties
Set user properties to send with hits

Delete user properties

Test user properties present

Test user properties deleted


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index bec2198..bd174de 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,7 @@
 .DS_Store
 # Python pycache:
 __pycache__/
-#Python virtual environment
-venv/
+
 #python compiled files
 *.pyc
 #sandbox files
@@ -34,14 +33,146 @@ Thumbs.db
 *.sbr
 *.un~
 *.gz
+
 #Pycharm
 *.ndb
 .idea
+
 # node
 node_modules/
+
 # credential files
 credentials/
-# test
-test/
+
 #Spyder
-.spyproject
\ No newline at end of file
+.spyproject
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# PEP 582
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# Visual Studio Code
+.vscode/
\ No newline at end of file
diff --git a/README.md b/README.md
index 954ea4b..1c35f5f 100644
--- a/README.md
+++ b/README.md
@@ -21,11 +21,11 @@ The easiest way to install GA4 Measurement Protocol Support for Python is direct
 
 The required credentials for sending events to GA4 are made up by the following:
 
-|Credential|Description|
-|---|---|
-|measurement_id|The identifier for a Data Stream. Found in the Google Analytics UI under:  **Admin** > **Data Streams** > **choose your stream** > **Measurement ID**|
-|api_secret|Generated throught the Google Analytics UI. To create a new secret, navigate in the Google Analytics UI to: **Admin** > **Data Streams** > **choose your stream** > **Measurement Protocol** > **Create**|
-|client_id|[Get your Google API client ID](https://developers.google.com/identity/one-tap/web/guides/get-google-api-clientid)|
+| Credential     | Description                                                                                                                                                                                               |
+| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| measurement_id | The identifier for a Data Stream. Found in the Google Analytics UI under:  **Admin** > **Data Streams** > **choose your stream** > **Measurement ID**                                                     |
+| api_secret     | Generated throught the Google Analytics UI. To create a new secret, navigate in the Google Analytics UI to: **Admin** > **Data Streams** > **choose your stream** > **Measurement Protocol API secrets** > **Create** |
+| client_id      | [Get your Google API client ID](https://developers.google.com/identity/one-tap/web/guides/get-google-api-clientid)                                                                                        |
 
 
 Create your *credentials.json* file and put in your "./credentials" subdirectory.
@@ -37,7 +37,7 @@ Create your *credentials.json* file and put in your "./credentials" subdirectory
 ```
 The following represents a simple example of a custom event sent to GA4:
 ``` python
-from ga4measurementprotocol import Ga4mp
+from ga4mp import Ga4mp
 
 # Create an instance of GA4 object
 ga = Ga4mp(measurement_id = <MEASUREMENT_ID>, api_secret = <API_SECRET>, client_id=<CLIENT_ID>)
@@ -60,6 +60,14 @@ Events need to be passed as a list of dictionaries, fitting the format:
  }]
 """
 
+# Set persistent user properties
+#   Includes user_id, non_personalized_ads, and all else set as custom user_properties
+ga.set_user_property('user_id', 'Thales2000')
+ga.set_user_property('customer_tier','enterprise')
+
+# Remove a user property
+ga.delete_user_property('user_id')
+
 # Send a custom event to GA4 immediately
 ga.send(events)
 
@@ -68,6 +76,12 @@ ga.send(events, postpone=True)
 ga.postponed_send()
 ```
 
-## Liscense
+## How to construct Events
+For more information on constructing events, please review the [GA4 Measurement Protocol reference](https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference).
+
+## User properties
+For more information on what user properties are in GA4 and what you can do with them, [please review here](https://developers.google.com/analytics/devguides/collection/protocol/ga4/user-properties?client_type=gtag)
+
+## License
 
 GA4 Measurement Protocol Support for Python is licensed under the [BSD License](./LICENSE).
diff --git a/dist/ga4mp-1.0.0-py3-none-any.whl b/dist/ga4mp-1.0.0-py3-none-any.whl
deleted file mode 100644
index 0b851d9..0000000
Binary files a/dist/ga4mp-1.0.0-py3-none-any.whl and /dev/null differ
diff --git a/ga4mp.egg-info/PKG-INFO b/ga4mp.egg-info/PKG-INFO
deleted file mode 100644
index 8d585a4..0000000
--- a/ga4mp.egg-info/PKG-INFO
+++ /dev/null
@@ -1,18 +0,0 @@
-Metadata-Version: 1.1
-Name: ga4mp
-Version: 1.0.0
-Summary: Google Analytics 4 Measurement Protocol Python Module
-Home-page: https://github.com/adswerve/GA4-Measurement-Protocol-Python
-Author: Nate Bukowski
-Author-email: nate.bukowski@adswerve.com
-License: BSD
-Download-URL: https://github.com/adswerve/GA4-Measurement-Protocol-Python1.0.0
-Description: Google Analytics 4 Measurement Protocol for Python
-        ============================================================
-        An interface for the Google Analytics 4 Measurement Protocol.
-        
-        This project is in *beta*. Please feel free to file issues for feature
-        requests or contact us directly at analytics-help@adswerve.com
-        __
-        
-Platform: UNKNOWN
diff --git a/ga4mp.egg-info/SOURCES.txt b/ga4mp.egg-info/SOURCES.txt
deleted file mode 100644
index d58a3f3..0000000
--- a/ga4mp.egg-info/SOURCES.txt
+++ /dev/null
@@ -1,12 +0,0 @@
-DESCRIPTION.rst
-MANIFEST.in
-README.md
-setup.py
-ga4mp/__init__.py
-ga4mp/ga4mp.py
-ga4mp.egg-info/PKG-INFO
-ga4mp.egg-info/SOURCES.txt
-ga4mp.egg-info/dependency_links.txt
-ga4mp.egg-info/top_level.txt
-ga4mp.egg-info/zip-safe
-test/test_warning_and_status_code.py
\ No newline at end of file
diff --git a/ga4mp.egg-info/dependency_links.txt b/ga4mp.egg-info/dependency_links.txt
deleted file mode 100644
index 8b13789..0000000
--- a/ga4mp.egg-info/dependency_links.txt
+++ /dev/null
@@ -1,1 +0,0 @@
-
diff --git a/ga4mp.egg-info/top_level.txt b/ga4mp.egg-info/top_level.txt
deleted file mode 100644
index 58d0477..0000000
--- a/ga4mp.egg-info/top_level.txt
+++ /dev/null
@@ -1,1 +0,0 @@
-ga4mp
diff --git a/ga4mp.egg-info/zip-safe b/ga4mp.egg-info/zip-safe
deleted file mode 100644
index 8b13789..0000000
--- a/ga4mp.egg-info/zip-safe
+++ /dev/null
@@ -1,1 +0,0 @@
-
diff --git a/ga4mp/__init__.py b/ga4mp/__init__.py
index c83de08..032361d 100644
--- a/ga4mp/__init__.py
+++ b/ga4mp/__init__.py
@@ -1,1 +1,3 @@
-import Ga4mp
+from ga4mp.ga4mp import Ga4mp
+
+__all__ = ['Ga4mp']
\ No newline at end of file
diff --git a/ga4mp/ga4mp.py b/ga4mp/ga4mp.py
index fad999e..dde217b 100644
--- a/ga4mp/ga4mp.py
+++ b/ga4mp/ga4mp.py
@@ -7,149 +7,339 @@
 # assistance in strategy, implementation, or auditing existing work.
 ###############################################################################
 
-import requests
 import json
+import logging
+import urllib.request
+import time
+import datetime
+from ga4mp.utils import params_dict
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
 
 class Ga4mp(object):
+    """
+    Class that provides an interface for sending data to Google Analytics, supporting the GA4 Measurement Protocol.
+
+    Parameters
+    ----------
+    measurement_id : string
+        The identifier for a Data Stream. Found in the Google Analytics UI under: Admin > Data Streams > choose your stream > Measurement ID
+    api_secret : string
+        Generated throught the Google Analytics UI. To create a new secret, navigate in the Google Analytics UI to: Admin > Data Streams >
+        choose your stream > Measurement Protocol > Create
+    client_id : string
+        Getting your Google API client ID: https://developers.google.com/identity/one-tap/web/guides/get-google-api-clientid
+
+
+    See Also
+    --------
+
+    * Measurement Protocol (Google Analytics 4): https://developers.google.com/analytics/devguides/collection/protocol/ga4
+
+    Examples
+    --------
+
+    >>> ga = Ga4mp(measurement_id = "MEASUREMENT_ID", api_secret = "API_SECRET", client_id="CLIENT_ID")
+    >>> event_type = 'new_custom_event'
+    >>> event_parameters = {'paramater_key_1': 'parameter_1', 'paramater_key_2': 'parameter_2'}
+    >>> event = {'name': event_type, 'params': event_parameters }
+    >>> events = [event]
+
+    # Send a custom event to GA4 immediately
+    >>> ga.send(events)
+
+    # Postponed send of a custom event to GA4
+    >>> ga.send(events, postpone=True)
+    >>> ga.postponed_send()
+    """
 
     def __init__(self, measurement_id, api_secret, client_id):
         self.measurement_id = measurement_id
         self.api_secret = api_secret
         self.client_id = client_id
-        self.event_list = []
-        self.base_domain = 'https://www.google-analytics.com/mp/collect'
-        self.validation_domain = 'https://www.google-analytics.com/debug/mp/collect'
+        self._event_list = []
+        self._user_properties = {}
+        self._base_domain = "https://www.google-analytics.com/mp/collect"
+        self._validation_domain = "https://www.google-analytics.com/debug/mp/collect"
 
+    def send(self, events, validation_hit=False, postpone=False, date=None):
+        """
+        Method to send an http post request to google analytics with the specified events.
 
-    def send(self, events, validation_hit=False, postpone=False):
+        Parameters
+        ----------
+        events : List[Dict]
+            A list of dictionaries  of the events to be sent to Google Analytics. The list of dictionaries should adhere
+            to the following format:
+
+            [{'name': 'level_end',
+            'params' : {'level_name': 'First',
+                        'success': 'True'}
+            },
+            {'name': 'level_up',
+            'params': {'character': 'John Madden',
+                        'level': 'First'}
+            }]
+
+        validation_hit : bool, optional
+            Boolean to depict if events should be tested against the Measurement Protocol Validation Server, by default False
+        postpone : bool, optional
+            Boolean to depict if provided event list should be postponed, by default False
+        date : datetime
+            Python datetime object for sending a historical event at the given date. Date cannot be in the future.
+        """
 
         # check for any missing or invalid parameters among automatically collected and recommended event types
-        self.check_params(events)
+        self._check_params(events)
+        self._check_date_not_in_future(date)
 
-        if postpone == True:
+        if postpone is True:
             # build event list to send later
             for event in events:
-                self.event_list.append(event)
+                event["_timestamp_micros"] = self._get_timestamp(time.time())
+                self._event_list.append(event)
         else:
-            # batch events into sets of 25 events
-            batched_event_list = [events[event:event + 25] for event in range(0, len(events), 25)]
+            # batch events into sets of 25 events, the maximum allowed.
+            batched_event_list = [
+                events[event : event + 25] for event in range(0, len(events), 25)
+            ]
             # send http post request
-            self.http_post(batched_event_list, validation_hit=validation_hit)
-
+            self._http_post(
+                batched_event_list, validation_hit=validation_hit, date=date
+            )
 
     def postponed_send(self):
+        """
+        Method to send the events provided to Ga4mp.send(events,postpone=True)
+        """
 
-        # batch events into sets of 25 events
-        batched_event_list = [self.event_list[event:event + 25] for event in range(0, len(self.event_list), 25)]
-        self.http_post(batched_event_list)
+        for event in self._event_list:
+            self._http_post([event], postpone=True)
 
         # clear event_list for future use
-        self.event_list = []
+        self._event_list = []
 
+    def append_event_to_params_dict(self, new_name_and_parameters):
 
-    def http_post(self, batched_event_list, validation_hit=False):
+        """
+        Method to append event name and parameters key-value pair to parameters dictionary.
+
+        Parameters
+        ----------
+        new_name_and_parameters : Dict
+            A dictionary with one key-value pair representing a new type of event to be sent to Google Analytics.
+            The dictionary should adhere to the following format:
+
+            {'new_name': ['new_param_1', 'new_param_2', 'new_param_3']}
+        """
+
+        params_dict.update(new_name_and_parameters)
+
+    def _http_post(
+        self, batched_event_list, validation_hit=False, postpone=False, date=None
+    ):
+        """
+        Method to send http POST request to google-analytics.
+
+        Parameters
+        ----------
+        batched_event_list : List[List[Dict]]
+            List of List of events. Places initial event payload into a list to send http POST in batches.
+        validation_hit : bool, optional
+            Boolean to depict if events should be tested against the Measurement Protocol Validation Server, by default False
+        postpone : bool, optional
+            Boolean to depict if provided event list should be postponed, by default False
+        date : datetime
+            Python datetime object for sending a historical event at the given date. Date cannot be in the future.
+            Timestamp micros supports up to 48 hours of backdating.
+            If date is specified, postpone must be False or an assertion will be thrown.
+        """
+        self._check_date_not_in_future(date)
+        status_code = None  # Default set to know if batch loop does not work and to bound status_code
 
         # set domain
-        domain = self.base_domain
-        if validation_hit == True:
-            domain = self.validation_domain
+        domain = self._base_domain
+        if validation_hit is True:
+            domain = self._validation_domain
+        logger.info(f"Sending POST to: {domain}")
 
         # loop through events in batches of 25
         batch_number = 1
         for batch in batched_event_list:
-            url = f'{domain}?measurement_id={self.measurement_id}&api_secret={self.api_secret}'
-            request = {'client_id': self.client_id,
-                       'events': batch}
-            body = json.dumps(request)
-
-            # Send http post request
-            result = requests.post(url=url, data=body)
-            status_code = result.status_code
-            print(f'Batch Number: {batch_number}\nStatus code: {status_code}')
+            url = f"{domain}?measurement_id={self.measurement_id}&api_secret={self.api_secret}"
+            request = {"client_id": self.client_id, "events": batch}
+            self._add_user_props_to_hit(request)
+
+            # make adjustments for postponed hit
+            request["events"] = (
+                {"name": batch["name"], "params": batch["params"]}
+                if (postpone)
+                else batch
+            )
+
+            if date is not None:
+                logger.info(f"Setting event timestamp to: {date}")
+                assert (
+                    postpone is False
+                ), "Cannot send postponed historical hit, ensure postpone=False"
+
+                ts = self._datetime_to_timestamp(date)
+                ts_micro = self._get_timestamp(ts)
+                request["timestamp_micros"] = int(ts_micro)
+                logger.info(f"Timestamp of request is: {request['timestamp_micros']}")
+
+            if postpone:
+                # add timestamp to hit
+                request["timestamp_micros"] = batch["_timestamp_micros"]
+
+            req = urllib.request.Request(url)
+            req.add_header("Content-Type", "application/json; charset=utf-8")
+            jsondata = json.dumps(request)
+            json_data_as_bytes = jsondata.encode("utf-8")  # needs to be bytes
+            req.add_header("Content-Length", len(json_data_as_bytes))
+            result = urllib.request.urlopen(req, json_data_as_bytes)
+
+            status_code = result.status
+            logger.info(f"Batch Number: {batch_number}")
+            logger.info(f"Status code: {status_code}")
             batch_number += 1
 
+        return status_code
+
+    def _check_params(self, events):
+
+        """
+        Method to check whether the event payload parameters provided meets supported parameters.
 
-    def check_params(self, events):
-
-        # all automatically collected and recommended event types
-        params_dict = {'ad_click': ['ad_event_id'],
-                       'ad_exposure': ['firebase_screen', 'firebase_screen_id', 'firebase_screen_class', 'exposure_time'],
-                       'ad_impression': ['ad_event_id'],
-                       'ad_query': ['ad_event_id'],
-                       'ad_reward': ['ad_unit_id', 'reward_type', 'reward_value'],
-                       'add_payment_info': ['coupon', 'currency', 'items', 'payment_type', 'value'],
-                       'add_shipping_info': ['coupon', 'currency', 'items', 'shipping_tier', 'value'],
-                       'add_to_cart': ['currency', 'items', 'value'],
-                       'add_to_wishlist': ['currency', 'items', 'value'],
-                       'adunit_exposure': ['firebase_screen', 'firebase_screen_id', 'firebase_screen_class', 'exposure_time'],
-                       'app_clear_data': [],
-                       'app_exception': ['fatal', 'timestamp', 'engagement_time_msec'],
-                       'app_remove': [],
-                       'app_store_refund': ['product_id', 'value', 'currency', 'quantity'],
-                       'app_store_subscription_cancel': ['product_id', 'price', 'value', 'currency', 'cancellation_reason'],
-                       'app_store_subscription_convert': ['product_id', 'price', 'value', 'currency', 'quantity'],
-                       'app_store_subscription_renew': ['product_id', 'price', 'value', 'currency', 'quantity', 'renewal_count'],
-                       'app_update': ['previous_app_version'],
-                       'begin_checkout': ['coupon', 'currency', 'items', 'value'],
-                       'click': [],
-                       'dynamic_link_app_open': ['source', 'medium', 'campaign', 'link_id', 'accept_time'],
-                       'dynamic_link_app_update': ['source', 'medium', 'campaign', 'link_id', 'accept_time'],
-                       'dynamic_link_first_open': ['source', 'medium', 'campaign', 'link_id', 'accept_time'],
-                       'earn_virtual_currency': ['virtual_currency_name', 'value'],
-                       'error': ['firebase_error', 'firebase_error_value'],
-                       'file_download': ['file_extension', 'file_name', 'link_classes', 'link_domain', 'link_id', 'link_text', 'link_url'],
-                       'firebase_campaign': ['source', 'medium', 'campaign', 'term', 'content', 'gclid', 'aclid', 'cp1', 'anid', 'click_timestamp', 'campaign_info_source'],
-                       'firebase_in_app_message_action': ['message_name', 'message_device_time', 'message_id'],
-                       'firebase_in_app_message_dismiss': ['message_name', 'message_device_time', 'message_id'],
-                       'firebase_in_app_message_impression': ['message_name', 'message_device_time', 'message_id'],
-                       'first_open': ['previous_gmp_app_id', 'updated_with_analytics', 'previous_first_open_count', 'system_app', 'system_app_update', 'deferred_analytics_collection', 'reset_analytics_cause', 'engagement_time_msec'],
-                       'first_visit': [],
-                       'generate_lead': ['value', 'currency'],
-                       'in_app_purchase': ['product_id', 'price', 'value', 'currency', 'quantity', 'subscription', 'free_trial', 'introductory_price'],
-                       'join_group': ['group_id'],
-                       'level_end': ['level_name', 'success'],
-                       'level_start': ['level_name'],
-                       'level_up': ['character', 'level'],
-                       'login': ['method'],
-                       'notification_dismiss': ['message_name', 'message_time', 'message_device_time', 'message_id', 'topic', 'label', 'message_channel'],
-                       'notification_foreground': ['message_name', 'message_time', 'message_device_time', 'message_id', 'topic', 'label', 'message_channel', 'message_type'],
-                       'notification_open': ['message_name', 'message_time', 'message_device_time', 'message_id', 'topic', 'label', 'message_channel'],
-                       'notification_receive': ['message_name', 'message_time', 'message_device_time', 'message_id', 'topic', 'label', 'message_channel', 'message_type'],
-                       'notification_send': ['message_name', 'message_time', 'message_device_time', 'message_id', 'topic', 'label', 'message_channel'],
-                       'os_update': ['previous_os_version'],
-                       'page_view': ['page_location', 'page_referrer'],
-                       'post_score': ['level', 'character', 'score'],
-                       'purchase': ['affiliation', 'coupon', 'currency', 'items', 'transaction_id', 'shipping', 'tax', 'value'],
-                       'refund': ['transaction_id', 'value', 'currency', 'tax', 'shipping', 'items'],
-                       'remove_from_cart': ['currency', 'items', 'value'],
-                       'screen_view': ['firebase_screen', 'firebase_screen_class', 'firebase_screen_id', 'firebase_previous_screen', 'firebase_previous_class', 'firebase_previous_id', 'engagement_time_msec'],
-                       'scroll': [],
-                       'search': ['search_term'],
-                       'select_content': ['content_type', 'item_id'],
-                       'select_item': ['items', 'item_list_name', 'item_list_id'],
-                       'select_promotion': ['items', 'promotion_id', 'promotion_name', 'creative_name', 'creative_slot', 'location_id'],
-                       'session_start': [],
-                       'share': ['content_type', 'item_id'],
-                       'sign_up': ['method'],
-                       'view_search_results': ['search_term'],
-                       'spend_virtual_currency': ['item_name', 'virtual_currency_name', 'value'],
-                       'tutorial_begin': [],
-                       'tutorial_complete': [],
-                       'unlock_achievement': ['achievement_id'],
-                       'user_engagement': ['engagement_time_msec'],
-                       'video_start': ['video_current_time', 'video_duration', 'video_percent', 'video_provider', 'video_title', 'video_url', 'visible'],
-                       'video_progress': ['video_current_time', 'video_duration', 'video_percent', 'video_provider', 'video_title', 'video_url', 'visible'],
-                       'video_complete': ['video_current_time', 'video_duration', 'video_percent', 'video_provider', 'video_title', 'video_url', 'visible'],
-                       'view_cart': ['currency', 'items', 'value'],
-                       'view_item': ['currency', 'items', 'value'],
-                       'view_item_list': ['items', 'item_list_name', 'item_list_id'],
-                       'view_promotion': ['items', 'promotion_id', 'promotion_name', 'creative_name', 'creative_slot', 'location_id']}
+        Parameters
+        ----------
+        events : List[Dict]
+            A list of dictionaries  of the events to be sent to Google Analytics. The list of dictionaries should adhere
+            to the following format:
+
+            [{'name': 'level_end',
+            'params' : {'level_name': 'First',
+                        'success': 'True'}
+            },
+            {'name': 'level_up',
+            'params': {'character': 'John Madden',
+                        'level': 'First'}
+            }]
+        """
+
+        # check to make sure it's a list of dictionaries with the right keys
+
+        assert type(events) == list, "events should be a list"
+
+        for event in events:
+
+            assert type(event) == dict, "each event should be a dictionary"
+
+            assert "name" in event, 'each event should have a "name" key'
+
+            assert "params" in event, 'each event should have a "params" key'
 
         # check for any missing or invalid parameters
+
         for e in events:
-            event_name = e['name']
-            event_params = e['params']
-            if (event_name in params_dict.keys()):
+            event_name = e["name"]
+            event_params = e["params"]
+            if event_name in params_dict.keys():
                 for parameter in params_dict[event_name]:
                     if parameter not in event_params.keys():
-                        print(f"WARNING: Event parameters do not match event type.\nFor {event_name} event type, the correct parameter(s) are {params_dict[event_name]}.\nFor a breakdown of currently supported event types and their parameters go here: https://support.google.com/analytics/answer/9267735\n")
+                        logger.warning(
+                            f"WARNING: Event parameters do not match event type.\nFor {event_name} event type, the correct parameter(s) are {params_dict[event_name]}.\nFor a breakdown of currently supported event types and their parameters go here: https://support.google.com/analytics/answer/9267735\n"
+                        )
+
+    def set_user_property(self, property, value):
+
+        """
+        Method to set user_id, user_properties, non_personalized_ads
+
+        Parameters
+        ----------
+        property : string
+        value: dependent on property (user_id, user_properties - string, non_personalized_ads - bool)
+        """
+        self._user_properties.update({property: value})
+
+    def delete_user_property(self, property):
+
+        """
+        Method to remove user_id, user_properties, non_personalized_ads
+
+        Parameters
+        ----------
+        property : string
+        """
+        try:
+            if property in self._user_properties.keys():
+                self._user_properties.pop(property)
+        except:
+            logger.info(f"Failed to delete user property: {property}")
+
+    def _add_user_props_to_hit(self, hit):
+
+        """
+        Method is a helper function to add user properties to outgoing hits.
+
+        Parameters
+        ----------
+        hit : dict
+        """
+        for key in self._user_properties:
+            try:
+                if key in ["user_id", "non_personalized_ads"]:
+                    hit.update({key: self._user_properties[key]})
+                else:
+                    if "user_properties" not in hit.keys():
+                        hit.update({"user_properties": {}})
+                    hit["user_properties"].update(
+                        {key: {"value": self._user_properties[key]}}
+                    )
+            except:
+                logger.info(f"Failed to add user property to outgoing hit: {key}")
+
+    def _get_timestamp(self, timestamp):
+        """
+        Method returns UNIX timestamp in microseconds for postponed hits.
+
+        Parameters
+        ----------
+        None
+        """
+        return int(timestamp * 1e6)
+
+    def _datetime_to_timestamp(self, dt):
+        """
+        Private method to convert a datetime object into a timestamp
+
+        Parameters
+        ----------
+        dt : datetime
+            A datetime object in any format
+
+        Returns
+        -------
+        timestamp
+            A UNIX timestamp in milliseconds
+        """
+        return time.mktime(dt.timetuple())
+
+    def _check_date_not_in_future(self, date):
+        """
+        Method to check that provided date is not in the future.
+
+        Parameters
+        ----------
+        date : datetime
+            Python datetime object
+        """
+        if date is None:
+            pass
+        else:
+            assert (
+                date <= datetime.datetime.now()
+            ), "Provided date cannot be in the future"
diff --git a/ga4mp/utils.py b/ga4mp/utils.py
new file mode 100644
index 0000000..16c5a3e
--- /dev/null
+++ b/ga4mp/utils.py
@@ -0,0 +1,92 @@
+
+# all automatically collected and recommended event types
+params_dict = {'ad_click': ['ad_event_id'],
+               'ad_exposure': ['firebase_screen', 'firebase_screen_id', 'firebase_screen_class', 'exposure_time'],
+               'ad_impression': ['ad_event_id'],
+               'ad_query': ['ad_event_id'],
+               'ad_reward': ['ad_unit_id', 'reward_type', 'reward_value'],
+               'add_payment_info': ['coupon', 'currency', 'items', 'payment_type', 'value'],
+               'add_shipping_info': ['coupon', 'currency', 'items', 'shipping_tier', 'value'],
+               'add_to_cart': ['currency', 'items', 'value'],
+               'add_to_wishlist': ['currency', 'items', 'value'],
+               'adunit_exposure': ['firebase_screen', 'firebase_screen_id', 'firebase_screen_class', 'exposure_time'],
+               'app_clear_data': [],
+               'app_exception': ['fatal', 'timestamp', 'engagement_time_msec'],
+               'app_remove': [],
+               'app_store_refund': ['product_id', 'value', 'currency', 'quantity'],
+               'app_store_subscription_cancel': ['product_id', 'price', 'value', 'currency', 'cancellation_reason'],
+               'app_store_subscription_convert': ['product_id', 'price', 'value', 'currency', 'quantity'],
+               'app_store_subscription_renew': ['product_id', 'price', 'value', 'currency', 'quantity',
+                                                'renewal_count'],
+               'app_update': ['previous_app_version'],
+               'begin_checkout': ['coupon', 'currency', 'items', 'value'],
+               'click': [],
+               'dynamic_link_app_open': ['source', 'medium', 'campaign', 'link_id', 'accept_time'],
+               'dynamic_link_app_update': ['source', 'medium', 'campaign', 'link_id', 'accept_time'],
+               'dynamic_link_first_open': ['source', 'medium', 'campaign', 'link_id', 'accept_time'],
+               'earn_virtual_currency': ['virtual_currency_name', 'value'],
+               'error': ['firebase_error', 'firebase_error_value'],
+               'file_download': ['file_extension', 'file_name', 'link_classes', 'link_domain', 'link_id', 'link_text',
+                                 'link_url'],
+               'firebase_campaign': ['source', 'medium', 'campaign', 'term', 'content', 'gclid', 'aclid', 'cp1', 'anid',
+                                     'click_timestamp', 'campaign_info_source'],
+               'firebase_in_app_message_action': ['message_name', 'message_device_time', 'message_id'],
+               'firebase_in_app_message_dismiss': ['message_name', 'message_device_time', 'message_id'],
+               'firebase_in_app_message_impression': ['message_name', 'message_device_time', 'message_id'],
+               'first_open': ['previous_gmp_app_id', 'updated_with_analytics', 'previous_first_open_count',
+                              'system_app', 'system_app_update', 'deferred_analytics_collection',
+                              'reset_analytics_cause', 'engagement_time_msec'],
+               'first_visit': [],
+               'generate_lead': ['value', 'currency'],
+               'in_app_purchase': ['product_id', 'price', 'value', 'currency', 'quantity', 'subscription', 'free_trial',
+                                   'introductory_price'],
+               'join_group': ['group_id'],
+               'level_end': ['level_name', 'success'],
+               'level_start': ['level_name'],
+               'level_up': ['character', 'level'],
+               'login': ['method'],
+               'notification_dismiss': ['message_name', 'message_time', 'message_device_time', 'message_id', 'topic',
+                                        'label', 'message_channel'],
+               'notification_foreground': ['message_name', 'message_time', 'message_device_time', 'message_id', 'topic',
+                                           'label', 'message_channel', 'message_type'],
+               'notification_open': ['message_name', 'message_time', 'message_device_time', 'message_id', 'topic',
+                                     'label', 'message_channel'],
+               'notification_receive': ['message_name', 'message_time', 'message_device_time', 'message_id', 'topic',
+                                        'label', 'message_channel', 'message_type'],
+               'notification_send': ['message_name', 'message_time', 'message_device_time', 'message_id', 'topic',
+                                     'label', 'message_channel'],
+               'os_update': ['previous_os_version'],
+               'page_view': ['page_location', 'page_referrer'],
+               'post_score': ['level', 'character', 'score'],
+               'purchase': ['affiliation', 'coupon', 'currency', 'items', 'transaction_id', 'shipping', 'tax', 'value'],
+               'refund': ['transaction_id', 'value', 'currency', 'tax', 'shipping', 'items'],
+               'remove_from_cart': ['currency', 'items', 'value'],
+               'screen_view': ['firebase_screen', 'firebase_screen_class', 'firebase_screen_id',
+                               'firebase_previous_screen', 'firebase_previous_class', 'firebase_previous_id',
+                               'engagement_time_msec'],
+               'scroll': [],
+               'search': ['search_term'],
+               'select_content': ['content_type', 'item_id'],
+               'select_item': ['items', 'item_list_name', 'item_list_id'],
+               'select_promotion': ['items', 'promotion_id', 'promotion_name', 'creative_name', 'creative_slot',
+                                    'location_id'],
+               'session_start': [],
+               'share': ['content_type', 'item_id'],
+               'sign_up': ['method'],
+               'view_search_results': ['search_term'],
+               'spend_virtual_currency': ['item_name', 'virtual_currency_name', 'value'],
+               'tutorial_begin': [],
+               'tutorial_complete': [],
+               'unlock_achievement': ['achievement_id'],
+               'user_engagement': ['engagement_time_msec'],
+               'video_start': ['video_current_time', 'video_duration', 'video_percent', 'video_provider', 'video_title',
+                               'video_url', 'visible'],
+               'video_progress': ['video_current_time', 'video_duration', 'video_percent', 'video_provider',
+                                  'video_title', 'video_url', 'visible'],
+               'video_complete': ['video_current_time', 'video_duration', 'video_percent', 'video_provider',
+                                  'video_title', 'video_url', 'visible'],
+               'view_cart': ['currency', 'items', 'value'],
+               'view_item': ['currency', 'items', 'value'],
+               'view_item_list': ['items', 'item_list_name', 'item_list_id'],
+               'view_promotion': ['items', 'promotion_id', 'promotion_name', 'creative_name', 'creative_slot',
+                                  'location_id']}
diff --git a/main.py b/main.py
index 2d8206b..429738b 100644
--- a/main.py
+++ b/main.py
@@ -1,13 +1,17 @@
 import json
 
-from ga4measurementprotocol import Ga4mp
+from ga4mp import Ga4mp
 
 
 '''
 event_type and event_parameters description: https://support.google.com/analytics/answer/9267735
 '''
 
-credentials = json.load(open("./credentials/credentials.json"))
+try:
+    credentials = json.load(open("./credentials/credentials.json"))
+
+except:
+    credentials = json.load(open("../credentials/credentials.json"))
 
 MEASUREMENT_ID = credentials['MEASUREMENT_ID']
 API_SECRET = credentials['API_SECRET']
@@ -30,19 +34,4 @@
     ga = Ga4mp(measurement_id = MEASUREMENT_ID, api_secret = API_SECRET, client_id=CLIENT_ID)
 
 
-    ga.send(events)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+    ga.send(events)
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 3b3400e..09b0d84 100644
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@
 except Exception:
     long_description="Google Analytics 4 Measurement Protocol in Python; an implementation of Google's Analytics 4 Measurement Protocol"
 
-VERSION = '1.0.0'
+VERSION = '1.1.1'
 
 setup(
     name = "ga4mp",


Test patch:
diff --git a/test/test_ga4mp.py b/test/test_ga4mp.py
new file mode 100644
index 0000000..89bc164
--- /dev/null
+++ b/test/test_ga4mp.py
@@ -0,0 +1,136 @@
+import requests
+import json
+import copy
+import unittest
+import pytest
+
+from ga4mp import Ga4mp
+from ga4mp.utils import params_dict
+from main import MEASUREMENT_ID, API_SECRET, CLIENT_ID
+import logging
+
+logging.basicConfig(level=logging.DEBUG)
+logger = logging.getLogger(__name__)
+from testfixtures import log_capture
+
+
+class Ga4mpTestMethods(unittest.TestCase):
+
+    def test_check_params_1_events_correct(self):
+
+        events_correct = [{'name': 'level_end',
+                          'params': {'level_name': 'First',
+                                     'success': 'True'}
+                           },
+                           {'name': 'level_up',
+                          'params': {'character': 'John Madden',
+                                     'level': 'First'}
+                            }]
+
+        Ga4mp._check_params(self, events_correct)
+
+        assert True
+
+    def test_check_params_2_events_not_a_list(self):
+
+        events_not_a_list = ({'name': 'level_end',
+                             'params': {'level_name': 'First',
+                              'success': 'True'}
+                              },
+                              {'name': 'level_up',
+                               'params': {'character': 'John Madden',
+                                          'level': 'First'}
+                               })
+        with pytest.raises(AssertionError, match="events should be a list"):
+            Ga4mp._check_params(self, events_not_a_list)
+
+    def test_check_params_3_event_not_a_dict(self):
+
+        events_event_not_a_dict = [{'name': 'level_end',
+                                    'params': {'level_name': 'First',
+                                               'success': 'True'}
+                                 },
+                                    ['name', 'level_up', 'params', {'character': 'John Madden', 'level': 'First'}
+                                     ]]
+
+        with pytest.raises(AssertionError, match="each event should be a dictionary"):
+            Ga4mp._check_params(self, events_event_not_a_dict)
+
+    def test_check_params_4_events_incorrect_key(self):
+
+        events_incorrect_key = [{'incorrect_key_causes_error': 'level_end',
+                                 'params': {'level_name': 'First',
+                                            'success': 'True'}
+                                 },
+                                {'name': 'level_up',
+                                 'params': {'character': 'John Madden',
+                                            'level': 'First'}
+                                 }]
+
+        with pytest.raises(AssertionError, match='each event should have a "name" key'):
+            Ga4mp._check_params(self, events_incorrect_key)
+
+    @log_capture()
+    def test_check_params_5_no_warning(self, capture):
+
+        events_correct_no_warning = [{'name': 'level_end',
+                                      'params': {'level_name': 'First',
+                                                 'success': 'True'}
+                                      }]
+
+        Ga4mp._check_params(self, events_correct_no_warning)
+
+        logger.info('input validated')
+
+        expected_log = ('test_ga4mp', 'INFO', "input validated")
+
+        capture.check(expected_log,)
+
+    @log_capture()
+    def test_check_params_6_warning(self, capture):
+
+        events_should_get_warning = [{'name': 'level_end',
+                                      'params' : {'level_name': 'First',
+                                                  'incorrect_key_causes_warning': 'True'}
+                                     }]
+
+        Ga4mp._check_params(self, events_should_get_warning)
+
+        expected_log = ('ga4mp.ga4mp','WARNING', "WARNING: Event parameters do not match event type.\nFor level_end event type, the correct parameter(s) are ['level_name', 'success'].\nFor a breakdown of currently supported event types and their parameters go here: https://support.google.com/analytics/answer/9267735\n")
+
+        capture.check(expected_log,)
+
+    def test_http_status_code(self):
+
+        # Create an instance of GA4 object
+        ga = Ga4mp(
+            measurement_id=MEASUREMENT_ID, api_secret=API_SECRET, client_id=CLIENT_ID
+        )
+
+        # Specify event type and parameters
+        event_type = "new_custom_event"
+        event_parameters = {
+            "parameter_key_1": "parameter_1",
+            "parameter_key_2": "parameter_2",
+        }
+        event = {"name": event_type, "params": event_parameters}
+        events = [event]
+        batched_event_list = [events[event:event + 25] for event in range(0, len(events), 25)]
+        status_code = ga._http_post(batched_event_list)
+
+        acceptable_http_status_codes = [200, 201, 204]
+
+        assert status_code in acceptable_http_status_codes
+
+    def test_append_event_to_params_dict(self):
+
+        params_dict_test = copy.deepcopy(params_dict)
+        params_dict_test.update(
+            {"new_name": ["new_param_1", "new_param_2", "new_param_3"]}
+        )
+
+        assert params_dict_test["new_name"]
+
+
+if __name__ == "__main__":
+    unittest.main()
\ No newline at end of file
diff --git a/test/test_historical_hit.py b/test/test_historical_hit.py
new file mode 100644
index 0000000..193262a
--- /dev/null
+++ b/test/test_historical_hit.py
@@ -0,0 +1,59 @@
+import unittest
+import logging
+from ga4mp import Ga4mp
+import datetime
+from main import MEASUREMENT_ID, API_SECRET, CLIENT_ID
+
+logging.basicConfig(level=logging.DEBUG)
+logger = logging.getLogger(__name__)
+
+
+class Ga4mpTestHistoricalHit(unittest.TestCase):
+    def test_datetime_arg(self):
+        dt = datetime.datetime.now()
+
+        ga = Ga4mp(
+            measurement_id=MEASUREMENT_ID, api_secret=API_SECRET, client_id=CLIENT_ID
+        )
+
+        event_type = "test_datetime_now"
+        event_parameters = {
+            "paramater_key_1": "parameter_1",
+        }
+        event = {"name": event_type, "params": event_parameters}
+        events = [event]
+        batched_event_list = [
+            events[event : event + 25] for event in range(0, len(events), 25)
+        ]
+        status_code = ga._http_post(batched_event_list, date=dt)
+
+        acceptable_http_status_codes = [200, 201, 204]
+
+        assert status_code in acceptable_http_status_codes
+
+    def test_datetime_delta(self):
+
+        dt = datetime.datetime.now() - datetime.timedelta(hours=1)
+
+        ga = Ga4mp(
+            measurement_id=MEASUREMENT_ID, api_secret=API_SECRET, client_id=CLIENT_ID
+        )
+        logger.info("Running 48hr")
+        event_type = "test_datetime_48hr"
+        event_parameters = {
+            "paramater_key_1": "parameter_1",
+        }
+        event = {"name": event_type, "params": event_parameters}
+        events = [event]
+        batched_event_list = [
+            events[event : event + 25] for event in range(0, len(events), 25)
+        ]
+        status_code = ga._http_post(batched_event_list, date=dt)
+
+        acceptable_http_status_codes = [200, 201, 204]
+
+        assert status_code in acceptable_http_status_codes
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/test_imports.py b/test/test_imports.py
new file mode 100644
index 0000000..c611860
--- /dev/null
+++ b/test/test_imports.py
@@ -0,0 +1,22 @@
+import unittest
+import logging
+
+logging.basicConfig(level=logging.DEBUG)
+logger = logging.getLogger(__name__)
+
+
+class TestImports(unittest.TestCase):
+    def test_import_ga4mp(self):
+        test_pass = None
+        try:
+            from ga4mp import Ga4mp
+            from ga4mp.ga4mp import Ga4mp
+
+            test_pass = True
+        except:
+            test_pass = False
+        self.assertEqual(test_pass, True)
+
+
+if __name__ == "__main__":
+    unittest.main()
\ No newline at end of file
diff --git a/test/test_user_properties.py b/test/test_user_properties.py
new file mode 100644
index 0000000..6ea2f78
--- /dev/null
+++ b/test/test_user_properties.py
@@ -0,0 +1,75 @@
+import unittest
+import logging
+from ga4mp import Ga4mp
+import datetime
+from main import MEASUREMENT_ID, API_SECRET, CLIENT_ID
+
+logging.basicConfig(level=logging.DEBUG)
+logger = logging.getLogger(__name__)
+
+
+class Ga4mpTestUserProperties(unittest.TestCase):
+    def test_add_user_properties(self):
+
+        dt = datetime.datetime.now()
+
+        ga = Ga4mp(
+            measurement_id=MEASUREMENT_ID, api_secret=API_SECRET, client_id=CLIENT_ID
+        )
+
+        ga.set_user_property('user_id','BabyYoda2000')
+        ga.set_user_property('non_personalized_ads', True)
+        ga.set_user_property('enterprise', False)
+
+
+        event_type = "test_add_user_properties"
+        event_parameters = {
+            "paramater_key_1": "testing - " + str(dt)
+        }
+        event = {"name": event_type, "params": event_parameters}
+        events = [event]
+        batched_event_list = [
+            events[event : event + 25] for event in range(0, len(events), 25)
+        ]
+        status_code = ga._http_post(batched_event_list, date=dt)
+
+        acceptable_http_status_codes = [200, 201, 204]
+
+        assert status_code in acceptable_http_status_codes
+
+
+
+    def test_delete_user_properties(self):
+
+        dt = datetime.datetime.now()
+
+        ga = Ga4mp(
+            measurement_id=MEASUREMENT_ID, api_secret=API_SECRET, client_id=CLIENT_ID
+        )
+
+        ga.set_user_property('user_id','BabyYoda2000')
+        ga.set_user_property('non_personalized_ads', True)
+        ga.set_user_property('enterprise', False)
+
+        ga.delete_user_property('user_id')
+        ga.delete_user_property('non_personalized_ads')
+        ga.delete_user_property('enterprise')
+        
+        event_type = "delete_add_user_properties"
+        event_parameters = {
+            "paramater_key_1": "testing - " + str(dt)
+        }
+        event = {"name": event_type, "params": event_parameters}
+        events = [event]
+        batched_event_list = [
+            events[event : event + 25] for event in range(0, len(events), 25)
+        ]
+        status_code = ga._http_post(batched_event_list, date=dt)
+
+        acceptable_http_status_codes = [200, 201, 204]
+
+        assert status_code in acceptable_http_status_codes
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/test_warning_and_status_code.py b/test/test_warning_and_status_code.py
deleted file mode 100644
index 25d70ca..0000000
--- a/test/test_warning_and_status_code.py
+++ /dev/null
@@ -1,99 +0,0 @@
-import requests
-import json
-import random
-
-from main import MEASUREMENT_ID, API_SECRET, CLIENT_ID
-from ga4measurementprotocol import Ga4mp
-
-
-class Ga4mpTest(Ga4mp):
-
-    # # when this function is called it will add a new event to the event_list
-    # def add_event(self, event_type, event_parameters):
-    #
-    #     global give_warning, parameter, parameter_keys
-    #
-    #     # check for any missing or invalid parameters
-    #     parameter_keys = event_parameters.keys()
-    #     params = self.get_params(event_type) if self.get_params(event_type) is not None else []
-    #
-    #     for parameter in params:
-    #         if parameter not in parameter_keys:
-    #             give_warning = True
-    #             print(
-    #                 f"WARNING: Event parameters do not match event type.\nFor {event_type} event type, the correct parameter(s) are {params}.\nFor a breakdown of currently supported event types and their parameters go here: https://support.google.com/analytics/answer/9267735\n")
-    #
-    #     new_event = {'name': event_type,
-    #                  'params': event_parameters}
-    #     self.event_list.append(new_event)
-
-    def send(self, validation_hit=False):
-
-        global status_code
-
-        domain = self.base_domain
-        if validation_hit == True:
-            domain = self.validation_domain
-
-        batched_event_list = [self.event_list[event:event + 25] for event in range(0, len(self.event_list), 25)]
-        batch_number = 1
-        for batch in batched_event_list:
-            url = f'{domain}?measurement_id={self.measurement_id}&api_secret={self.api_secret}'
-            # this will return code 404 and will fail the test_send_hit() test
-            # url = f'{domain}testAddURLPartToBreakProgram?measurement_id={self.measurement_id}&api_secret={self.api_secret}'
-
-            request = {'client_id': self.client_id,
-                       'events': batch
-                       }
-            body = json.dumps(request)
-
-            # Send http post request
-            result = requests.post(url=url, data=body)
-            status_code = result.status_code
-            print(f'Batch Number: {batch_number}\nStatus code: {status_code}')
-            batch_number += 1
-
-        self.event_list = []
-
-
-testing_dict = {'custom_event_unit_test_1':{'cd1': 'a parameter', 'cd2': 'another parameter'},
-                'custom_event_unit_test_2': {'cd2': 'parameter_1', 'cd4': 'parameter_2'},
-                'ad_click' : {'ad_event_id': 'test_value'},
-                'ad_exposure' : {'ad_event_id': 'test_value'}, # this will give a warning, as these are incorrect parameters
-                'video_start' : {'content_type': 'test_value'}
-                }
-
-
-index = random.randint(0, len(testing_dict) - 1)
-
-number_of_events = random.randint(1,10)
-
-event_type = list(testing_dict.keys())[index]
-
-event_parameters = testing_dict.get(event_type)
-
-
-def test_add_event():
-    ga = Ga4mpTest(measurement_id=MEASUREMENT_ID, api_secret=API_SECRET, client_id=CLIENT_ID)
-
-    ga.add_event(event_type, event_parameters)
-
-    if parameter not in parameter_keys:
-        assert give_warning
-
-
-def test_send_hit():
-
-    ga = Ga4mpTest(measurement_id=MEASUREMENT_ID, api_secret=API_SECRET, client_id=CLIENT_ID)
-
-    acceptable_http_status_codes = [200, 201, 204]
-
-    for i in range(number_of_events):
-        # ga.add_event(event_type + str(i+number_of_events), event_parameters) # to give each event a unique name
-        ga.add_event(event_type, event_parameters)
-
-    print(f"Sending {number_of_events} hits of {event_type} event with with parameters {event_parameters}")
-
-    ga.send_hit()
-
-    assert status_code in acceptable_http_status_codes