diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 7237cc50..10b24303 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -36,7 +36,7 @@ jobs: strategy: matrix: - python-version: [2.7, 3.8] + python-version: [3.8] steps: - name: Checkout code @@ -47,13 +47,9 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Ensure pip version for python 2 + - name: Pin pip version run: | - if [ "${{ matrix.python-version}}" == "2.7" ] ; then - echo "pip_v=pip < 21.0" >> $GITHUB_ENV - else - echo "pip_v=pip" >> $GITHUB_ENV - fi + echo "pip_v=pip" >> $GITHUB_ENV - name: Build run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66f03048..ac8398ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - python-version: [2.7, 3.6, 3.7, 3.8, 3.9] + python-version: [3.6, 3.7, 3.8, 3.9, "3.10"] extras-required: [".", ".[redis]"] services: @@ -35,13 +35,9 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Pin pip version for python 2 + - name: Pin pip version run: | - if [ "${{ matrix.python-version}}" == "2.7" ] ; then - echo "pip_v=pip < 21.0" >> $GITHUB_ENV - else - echo "pip_v=pip" >> $GITHUB_ENV - fi + echo "pip_v=pip" >> $GITHUB_ENV - name: Install dependencies run: | diff --git a/CHANGES.txt b/CHANGES.txt index 7b35654f..ffd71a36 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,8 @@ +Version 0.10.0 (2021-12-16) +-------------------------- +Add Python 3.10 support (#254) +Add configurable timeout for HTTP requests (#258) + Version 0.9.1 (2021-10-26) -------------------------- Update python versions in run-tests script (#256) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..e6c5e819 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM centos:8 + +RUN yum -y install wget +RUN yum install -y epel-release +RUN yum -y install git tar gcc make bzip2 openssl openssl-devel patch gcc-c++ libffi-devel sqlite-devel +RUN git clone git://github.com/yyuu/pyenv.git ~/.pyenv +ENV HOME /root +ENV PYENV_ROOT $HOME/.pyenv +ENV PATH $PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH + +RUN pyenv install 3.5.10 && pyenv install 3.6.14 && pyenv install 3.7.11 && pyenv install 3.8.11 && pyenv install 3.9.6 && pyenv install 3.10.1 +RUN git clone https://github.com/pyenv/pyenv-virtualenv.git ~/.pyenv/plugins/pyenv-virtualenv + +WORKDIR /app +COPY . . +RUN [ "./run-tests.sh", "deploy"] +CMD [ "./run-tests.sh", "test"] diff --git a/README.rst b/README.rst index 26616577..dd2d05fd 100644 --- a/README.rst +++ b/README.rst @@ -58,6 +58,17 @@ Find out more .. _`Setup Guide`: https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/python-tracker/setup/ .. _`Contributing`: https://github.com/snowplow/snowplow-python-tracker/blob/master/CONTRIBUTING.md +Python Support +############## + ++----------------+--------------------------+ +| Python version | snowplow-tracker version | ++================+==========================+ +| >=3.5 | 0.10.0 | ++----------------+--------------------------+ +| 2.7 | 0.9.1 | ++----------------+--------------------------+ + Maintainer Quickstart ####################### diff --git a/requirements-test.txt b/requirements-test.txt index 88623635..668dae79 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,7 +1,8 @@ -pytest==4.6.11 -attrs==20.3.0 +pytest==4.6.11; python_version < '3.10.0' +pytest==6.2.5; python_version >= '3.10.0' +attrs==21.2.0 httmock==1.4.0 -freezegun==0.3.15 -pytest-cov==2.11.1 -coveralls==1.11.1 -mock==3.0.5; python_version < '3.0' +freezegun==1.1.0 +pytest-cov +coveralls==3.3.1 +fakeredis==1.7.0 diff --git a/run-tests.sh b/run-tests.sh index 5218ed48..477f3f7e 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -15,23 +15,6 @@ eval "$(pyenv init -)" eval "$(pyenv virtualenv-init -)" function deploy { - # pyenv install 2.7.18 - if [ ! -e ~/.pyenv/versions/tracker27 ]; then - pyenv virtualenv 2.7.18 tracker27 - pyenv activate tracker27 - pip install . - pip install -r requirements-test.txt - source deactivate - fi - - if [ ! -e ~/.pyenv/versions/tracker27redis ]; then - pyenv virtualenv 2.7.18 tracker27redis - pyenv activate tracker27redis - pip install .[redis] - pip install -r requirements-test.txt - source deactivate - fi - # pyenv install 3.5.10 if [ ! -e ~/.pyenv/versions/tracker35 ]; then pyenv virtualenv 3.5.10 tracker35 @@ -116,18 +99,27 @@ function deploy { pip install -r requirements-test.txt source deactivate fi -} + # pyenv install 3.10.1 + if [ ! -e ~/.pyenv/versions/tracker310 ]; then + pyenv virtualenv 3.10.1 tracker310 + pyenv activate tracker310 + pip install . + pip install -r requirements-test.txt + source deactivate + fi -function run_tests { - pyenv activate tracker27 - pytest -s - source deactivate + if [ ! -e ~/.pyenv/versions/tracker310redis ]; then + pyenv virtualenv 3.10.1 tracker310redis + pyenv activate tracker310redis + pip install .[redis] + pip install -r requirements-test.txt + source deactivate + fi +} - pyenv activate tracker27redis - pytest -s - source deactivate +function run_tests { pyenv activate tracker35 pytest source deactivate @@ -167,11 +159,17 @@ function run_tests { pyenv activate tracker39redis pytest source deactivate + + pyenv activate tracker310 + pytest + source deactivate + + pyenv activate tracker310redis + pytest + source deactivate } function refresh_deploy { - pyenv uninstall -f tracker27 - pyenv uninstall -f tracker27redis pyenv uninstall -f tracker35 pyenv uninstall -f tracker35redis pyenv uninstall -f tracker36 @@ -182,6 +180,8 @@ function refresh_deploy { pyenv uninstall -f tracker38redis pyenv uninstall -f tracker39 pyenv uninstall -f tracker39redis + pyenv uninstall -f tracker310 + pyenv uninstall -f tracker310redis } diff --git a/setup.py b/setup.py index d038064c..c8ca1b0c 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,6 @@ License: Apache License Version 2.0 """ - #!/usr/bin/env python # -*- coding: utf-8 -*- @@ -28,24 +27,22 @@ except ImportError: from distutils.core import setup -import os - authors_list = [ 'Anuj More', 'Alexander Dean', 'Fred Blundun', 'Paul Boocock' - ] +] authors_str = ', '.join(authors_list) authors_email_list = [ 'support@snowplowanalytics.com', - ] +] authors_email_str = ', '.join(authors_email_list) setup( name='snowplow-tracker', - version='0.9.1', + version='0.10.0', author=authors_str, author_email=authors_email_str, packages=['snowplow_tracker', 'snowplow_tracker.test', 'snowplow_tracker.redis', 'snowplow_tracker.celery'], @@ -60,22 +57,19 @@ "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Operating System :: OS Independent", ], install_requires=[ "requests>=2.25.1,<3.0", - "pycontracts>=1.8.12;python_version<'3.0'", - "pycontracts3>=3.0.2;python_version>='3.0'", - "six>=1.9.0,<2.0" + "typing_extensions>=3.7.4" ], extras_require={ diff --git a/snowplow_tracker/__init__.py b/snowplow_tracker/__init__.py index a2ef8d47..3d618f9f 100644 --- a/snowplow_tracker/__init__.py +++ b/snowplow_tracker/__init__.py @@ -3,7 +3,7 @@ from snowplow_tracker.emitters import logger, Emitter, AsyncEmitter from snowplow_tracker.self_describing_json import SelfDescribingJson from snowplow_tracker.tracker import Tracker -from contracts import disable_all as disable_contracts, enable_all as enable_contracts +from snowplow_tracker.contracts import disable_contracts, enable_contracts # celery extra from .celery import CeleryEmitter diff --git a/snowplow_tracker/_version.py b/snowplow_tracker/_version.py index b506807b..0e493b27 100644 --- a/snowplow_tracker/_version.py +++ b/snowplow_tracker/_version.py @@ -19,7 +19,6 @@ License: Apache License Version 2.0 """ - -__version_info__ = (0, 9, 1) +__version_info__ = (0, 10, 0) __version__ = ".".join(str(x) for x in __version_info__) __build_version__ = __version__ + '' diff --git a/snowplow_tracker/celery/__init__.py b/snowplow_tracker/celery/__init__.py index df8cc426..2a4d905a 100644 --- a/snowplow_tracker/celery/__init__.py +++ b/snowplow_tracker/celery/__init__.py @@ -1,2 +1 @@ from .celery_emitter import CeleryEmitter - diff --git a/snowplow_tracker/celery/celery_emitter.py b/snowplow_tracker/celery/celery_emitter.py index faa3a9dc..5b4af764 100644 --- a/snowplow_tracker/celery/celery_emitter.py +++ b/snowplow_tracker/celery/celery_emitter.py @@ -20,7 +20,10 @@ """ import logging +from typing import Any, Optional + from snowplow_tracker.emitters import Emitter +from snowplow_tracker.typing import HttpProtocol, Method _CELERY_OPT = True try: @@ -33,6 +36,7 @@ logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) + class CeleryEmitter(Emitter): """ Uses a Celery worker to send HTTP requests asynchronously. @@ -43,7 +47,14 @@ class CeleryEmitter(Emitter): celery_app = None - def __init__(self, endpoint, protocol="http", port=None, method="get", buffer_size=None, byte_limit=None): + def __init__( + self, + endpoint: str, + protocol: HttpProtocol = "http", + port: Optional[int] = None, + method: Method = "get", + buffer_size: Optional[int] = None, + byte_limit: Optional[int] = None) -> None: super(CeleryEmitter, self).__init__(endpoint, protocol, port, method, buffer_size, None, None, byte_limit) try: @@ -57,18 +68,18 @@ def __init__(self, endpoint, protocol="http", port=None, method="get", buffer_si self.async_flush = self.celery_app.task(self.async_flush) - def flush(self): + def flush(self) -> None: """ Schedules a flush task """ self.async_flush.delay() logger.info("Scheduled a Celery task to flush the event queue") - def async_flush(self): + def async_flush(self) -> None: super(CeleryEmitter, self).flush() else: - def __new__(cls, *args, **kwargs): + def __new__(cls, *args: Any, **kwargs: Any) -> 'CeleryEmitter': logger.error("CeleryEmitter is not available. Please install snowplow-tracker with celery extra dependency.") raise RuntimeError('CeleryEmitter is not available. To use: `pip install snowplow-tracker[celery]`') diff --git a/snowplow_tracker/contracts.py b/snowplow_tracker/contracts.py new file mode 100644 index 00000000..f98654ca --- /dev/null +++ b/snowplow_tracker/contracts.py @@ -0,0 +1,98 @@ +""" + contracts.py + + Copyright (c) 2013-2021 Snowplow Analytics Ltd. All rights reserved. + + This program is licensed to you under the Apache License Version 2.0, + and you may not use this file except in compliance with the Apache License + Version 2.0. You may obtain a copy of the Apache License Version 2.0 at + http://www.apache.org/licenses/LICENSE-2.0. + + Unless required by applicable law or agreed to in writing, + software distributed under the Apache License Version 2.0 is distributed on + an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the Apache License Version 2.0 for the specific + language governing permissions and limitations there under. + + Authors: Anuj More, Alex Dean, Fred Blundun, Paul Boocock, Matus Tomlein + Copyright: Copyright (c) 2013-2021 Snowplow Analytics Ltd + License: Apache License Version 2.0 +""" + +import traceback +import re +from typing import Any, Dict, Iterable, Callable, Sized +from snowplow_tracker.typing import FORM_TYPES, FORM_NODE_NAMES + +_CONTRACTS_ENABLED = True +_MATCH_FIRST_PARAMETER_REGEX = re.compile(r"\(([\w.]+)[,)]") + + +def disable_contracts() -> None: + global _CONTRACTS_ENABLED + _CONTRACTS_ENABLED = False + + +def enable_contracts() -> None: + global _CONTRACTS_ENABLED + _CONTRACTS_ENABLED = True + + +def contracts_enabled() -> bool: + global _CONTRACTS_ENABLED + return _CONTRACTS_ENABLED + + +def greater_than(value: float, compared_to: float) -> None: + if contracts_enabled() and value <= compared_to: + raise ValueError("{0} must be greater than {1}.".format(_get_parameter_name(), compared_to)) + + +def non_empty(seq: Sized) -> None: + if contracts_enabled() and len(seq) == 0: + raise ValueError("{0} is empty.".format(_get_parameter_name())) + + +def non_empty_string(s: str) -> None: + if contracts_enabled() and type(s) is not str or not s: + raise ValueError("{0} is empty.".format(_get_parameter_name())) + + +def one_of(value: Any, supported: Iterable) -> None: + if contracts_enabled() and value not in supported: + raise ValueError("{0} is not supported.".format(_get_parameter_name())) + + +def satisfies(value: Any, check: Callable[[Any], bool]) -> None: + if contracts_enabled() and not check(value): + raise ValueError("{0} is not allowed.".format(_get_parameter_name())) + + +def form_element(element: Dict[str, Any]) -> None: + satisfies(element, lambda x: _check_form_element(x)) + + +def _get_parameter_name() -> str: + stack = traceback.extract_stack() + _, _, _, code = stack[-3] + + match = _MATCH_FIRST_PARAMETER_REGEX.search(code) + if not match: + return 'Unnamed parameter' + return match.groups(0)[0] + + +def _check_form_element(element: Dict[str, Any]) -> bool: + """ + Helper method to check that dictionary conforms element + in sumbit_form and change_form schemas + """ + all_present = isinstance(element, dict) and 'name' in element and 'value' in element and 'nodeName' in element + try: + if element['type'] in FORM_TYPES: + type_valid = True + else: + type_valid = False + except KeyError: + type_valid = True + return all_present and element['nodeName'] in FORM_NODE_NAMES and type_valid diff --git a/snowplow_tracker/emitters.py b/snowplow_tracker/emitters.py index 8afae6ae..2b2718d5 100644 --- a/snowplow_tracker/emitters.py +++ b/snowplow_tracker/emitters.py @@ -20,21 +20,16 @@ """ -import sys -import json import logging import time import threading import requests -from contracts import contract, new_contract -from snowplow_tracker.self_describing_json import SelfDescribingJson +from typing import Optional, Union, Tuple +from queue import Queue -try: - # Python 2 - from Queue import Queue -except ImportError: - # Python 3 - from queue import Queue +from snowplow_tracker.self_describing_json import SelfDescribingJson +from snowplow_tracker.typing import PayloadDict, PayloadDictList, HttpProtocol, Method, SuccessCallback, FailureCallback +from snowplow_tracker.contracts import one_of # logging logging.basicConfig() @@ -43,13 +38,8 @@ DEFAULT_MAX_LENGTH = 10 PAYLOAD_DATA_SCHEMA = "iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-4" - -# contracts -new_contract("protocol", lambda x: x == "http" or x == "https") - -new_contract("method", lambda x: x == "get" or x == "post") - -new_contract("function", lambda x: hasattr(x, "__call__")) +PROTOCOLS = {"http", "https"} +METHODS = {"get", "post"} class Emitter(object): @@ -58,8 +48,17 @@ class Emitter(object): Supports both GET and POST requests """ - @contract - def __init__(self, endpoint, protocol="http", port=None, method="get", buffer_size=None, on_success=None, on_failure=None, byte_limit=None): + def __init__( + self, + endpoint: str, + protocol: HttpProtocol = "http", + port: Optional[int] = None, + method: Method = "get", + buffer_size: Optional[int] = None, + on_success: Optional[SuccessCallback] = None, + on_failure: Optional[FailureCallback] = None, + byte_limit: Optional[int] = None, + request_timeout: Optional[Union[float, Tuple[float, float]]] = None) -> None: """ :param endpoint: The collector URL. Don't include "http://" - this is done automatically. :type endpoint: string @@ -82,7 +81,14 @@ def __init__(self, endpoint, protocol="http", port=None, method="get", buffer_si :type on_failure: function | None :param byte_limit: The size event list after reaching which queued events will be flushed :type byte_limit: int | None + :param request_timeout: Timeout for the HTTP requests. Can be set either as single float value which + applies to both "connect" AND "read" timeout, or as tuple with two float values + which specify the "connect" and "read" timeouts separately + :type request_timeout: float | tuple | None """ + one_of(protocol, PROTOCOLS) + one_of(method, METHODS) + self.endpoint = Emitter.as_collector_uri(endpoint, protocol, port, method) self.method = method @@ -96,6 +102,7 @@ def __init__(self, endpoint, protocol="http", port=None, method="get", buffer_si self.buffer = [] self.byte_limit = byte_limit self.bytes_queued = None if byte_limit is None else 0 + self.request_timeout = request_timeout self.on_success = on_success self.on_failure = on_failure @@ -107,8 +114,11 @@ def __init__(self, endpoint, protocol="http", port=None, method="get", buffer_si logger.info("Emitter initialized with endpoint " + self.endpoint) @staticmethod - @contract - def as_collector_uri(endpoint, protocol="http", port=None, method="get"): + def as_collector_uri( + endpoint: str, + protocol: HttpProtocol = "http", + port: Optional[int] = None, + method: Method = "get") -> str: """ :param endpoint: The raw endpoint provided by the user :type endpoint: string @@ -132,8 +142,7 @@ def as_collector_uri(endpoint, protocol="http", port=None, method="get"): else: return protocol + "://" + endpoint + ":" + str(port) + path - @contract - def input(self, payload): + def input(self, payload: PayloadDict) -> None: """ Adds an event to the buffer. If the maximum size has been reached, flushes the buffer. @@ -146,23 +155,14 @@ def input(self, payload): self.bytes_queued += len(str(payload)) if self.method == "post": - self.buffer.append({key: Emitter.to_str(payload[key]) for key in payload}) + self.buffer.append({key: str(payload[key]) for key in payload}) else: self.buffer.append(payload) if self.reached_limit(): self.flush() - @staticmethod - def to_str(x): - pyVersion = sys.version_info[0] - if pyVersion < 3: - if isinstance(x, basestring): - return x - return str(x) - return str(x) - - def reached_limit(self): + def reached_limit(self) -> bool: """ Checks if event-size or bytes limit are reached @@ -171,9 +171,9 @@ def reached_limit(self): if self.byte_limit is None: return len(self.buffer) >= self.buffer_size else: - return self.bytes_queued >= self.byte_limit or len(self.buffer) >= self.buffer_size + return (self.bytes_queued or 0) >= self.byte_limit or len(self.buffer) >= self.buffer_size - def flush(self): + def flush(self) -> None: """ Sends all events in the buffer to the collector. """ @@ -183,8 +183,7 @@ def flush(self): if self.bytes_queued is not None: self.bytes_queued = 0 - @contract - def http_post(self, data): + def http_post(self, data: str) -> bool: """ :param data: The array of JSONs to be sent :type data: string @@ -193,16 +192,19 @@ def http_post(self, data): logger.debug("Payload: %s" % data) post_succeeded = False try: - r = requests.post(self.endpoint, data=data, headers={'Content-Type': 'application/json; charset=utf-8'}) - post_succeeded= Emitter.is_good_status_code(r.status_code) + r = requests.post( + self.endpoint, + data=data, + headers={'Content-Type': 'application/json; charset=utf-8'}, + timeout=self.request_timeout) + post_succeeded = Emitter.is_good_status_code(r.status_code) getattr(logger, "info" if post_succeeded else "warning")("POST request finished with status code: " + str(r.status_code)) except requests.RequestException as e: logger.warning(e) return post_succeeded - @contract - def http_get(self, payload): + def http_get(self, payload: PayloadDict) -> bool: """ :param payload: The event properties :type payload: dict(string:*) @@ -211,7 +213,7 @@ def http_get(self, payload): logger.debug("Payload: %s" % payload) get_succeeded = False try: - r = requests.get(self.endpoint, params=payload) + r = requests.get(self.endpoint, params=payload, timeout=self.request_timeout) get_succeeded = Emitter.is_good_status_code(r.status_code) getattr(logger, "info" if get_succeeded else "warning")("GET request finished with status code: " + str(r.status_code)) except requests.RequestException as e: @@ -219,18 +221,17 @@ def http_get(self, payload): return get_succeeded - def sync_flush(self): + def sync_flush(self) -> None: """ Calls the flush method of the base Emitter class. This is guaranteed to be blocking, not asynchronous. """ logger.debug("Starting synchronous flush...") Emitter.flush(self) - logger.info("Finished synchrous flush") + logger.info("Finished synchronous flush") @staticmethod - @contract - def is_good_status_code(status_code): + def is_good_status_code(status_code: int) -> bool: """ :param status_code: HTTP status code :type status_code: int @@ -238,8 +239,7 @@ def is_good_status_code(status_code): """ return 200 <= status_code < 400 - @contract - def send_events(self, evts): + def send_events(self, evts: PayloadDictList) -> None: """ :param evts: Array of events to be sent :type evts: list(dict(string:*)) @@ -275,8 +275,7 @@ def send_events(self, evts): else: logger.info("Skipping flush since buffer is empty") - @contract - def set_flush_timer(self, timeout, flush_now=False): + def set_flush_timer(self, timeout: float, flush_now: bool = False) -> None: """ Set an interval at which the buffer will be flushed @@ -293,7 +292,7 @@ def set_flush_timer(self, timeout, flush_now=False): self.timer.daemon = True self.timer.start() - def cancel_flush_timer(self): + def cancel_flush_timer(self) -> None: """ Abort automatic async flushing """ @@ -302,7 +301,7 @@ def cancel_flush_timer(self): self.timer.cancel() @staticmethod - def attach_sent_timestamp(events): + def attach_sent_timestamp(events: PayloadDictList) -> None: """ Attach (by mutating in-place) current timestamp in milliseconds as `stm` param @@ -311,10 +310,11 @@ def attach_sent_timestamp(events): :type events: list(dict(string:*)) :rtype: None """ - def update(e): + def update(e: PayloadDict) -> None: e.update({'stm': str(int(time.time()) * 1000)}) - [update(event) for event in events] + for event in events: + update(event) class AsyncEmitter(Emitter): @@ -322,18 +322,17 @@ class AsyncEmitter(Emitter): Uses threads to send HTTP requests asynchronously """ - @contract def __init__( - self, - endpoint, - protocol="http", - port=None, - method="get", - buffer_size=None, - on_success=None, - on_failure=None, - thread_count=1, - byte_limit=None): + self, + endpoint: str, + protocol: HttpProtocol = "http", + port: Optional[int] = None, + method: Method = "get", + buffer_size: Optional[int] = None, + on_success: Optional[SuccessCallback] = None, + on_failure: Optional[FailureCallback] = None, + thread_count: int = 1, + byte_limit: Optional[int] = None) -> None: """ :param endpoint: The collector URL. Don't include "http://" - this is done automatically. :type endpoint: string @@ -366,14 +365,14 @@ def __init__( t.daemon = True t.start() - def sync_flush(self): + def sync_flush(self) -> None: while True: self.flush() self.queue.join() if len(self.buffer) < 1: break - def flush(self): + def flush(self) -> None: """ Removes all dead threads, then creates a new thread which executes the flush method of the base Emitter class @@ -384,7 +383,7 @@ def flush(self): if self.bytes_queued is not None: self.bytes_queued = 0 - def consume(self): + def consume(self) -> None: while True: evts = self.queue.get() self.send_events(evts) diff --git a/snowplow_tracker/payload.py b/snowplow_tracker/payload.py index c2d10df9..fc48a13e 100644 --- a/snowplow_tracker/payload.py +++ b/snowplow_tracker/payload.py @@ -19,16 +19,15 @@ License: Apache License Version 2.0 """ -import random -import time import json import base64 -from contracts import contract +from typing import Any, Optional +from snowplow_tracker.typing import PayloadDict, JsonEncoderFunction class Payload: - def __init__(self, dict_=None): + def __init__(self, dict_: Optional[PayloadDict] = None) -> None: """ Constructor """ @@ -39,20 +38,18 @@ def __init__(self, dict_=None): for f in dict_: self.nv_pairs[f] = dict_[f] - """ Methods to add to the payload """ - def add(self, name, value): + def add(self, name: str, value: Any) -> None: """ Add a name value pair to the Payload object """ if not (value == "" or value is None): self.nv_pairs[name] = value - @contract - def add_dict(self, dict_, base64=False): + def add_dict(self, dict_: PayloadDict, base64: bool = False) -> None: """ Add a dict of name value pairs to the Payload object @@ -62,8 +59,13 @@ def add_dict(self, dict_, base64=False): for f in dict_: self.add(f, dict_[f]) - @contract - def add_json(self, dict_, encode_base64, type_when_encoded, type_when_not_encoded, json_encoder=None): + def add_json( + self, + dict_: Optional[PayloadDict], + encode_base64: bool, + type_when_encoded: str, + type_when_not_encoded: str, + json_encoder: Optional[JsonEncoderFunction] = None) -> None: """ Add an encoded or unencoded JSON to the payload @@ -92,7 +94,7 @@ def add_json(self, dict_, encode_base64, type_when_encoded, type_when_not_encode else: self.add(type_when_not_encoded, json_dict) - def get(self): + def get(self) -> PayloadDict: """ Returns the context dictionary from the Payload object """ diff --git a/snowplow_tracker/redis/redis_emitter.py b/snowplow_tracker/redis/redis_emitter.py index a4d31d3b..e79512d0 100644 --- a/snowplow_tracker/redis/redis_emitter.py +++ b/snowplow_tracker/redis/redis_emitter.py @@ -21,12 +21,12 @@ import json import logging -from contracts import contract, new_contract +from typing import Any, Optional +from snowplow_tracker.typing import PayloadDict, RedisProtocol _REDIS_OPT = True try: import redis - new_contract("redis", lambda x: isinstance(x, (redis.Redis, redis.StrictRedis))) except ImportError: _REDIS_OPT = False @@ -42,8 +42,7 @@ class RedisEmitter(object): """ if _REDIS_OPT: - @contract - def __init__(self, rdb=None, key="snowplow"): + def __init__(self, rdb: Optional[RedisProtocol] = None, key: str = "snowplow") -> None: """ :param rdb: Optional custom Redis database :type rdb: redis | None @@ -56,8 +55,7 @@ def __init__(self, rdb=None, key="snowplow"): self.rdb = rdb self.key = key - @contract - def input(self, payload): + def input(self, payload: PayloadDict) -> None: """ :param payload: The event properties :type payload: dict(string:*) @@ -66,14 +64,14 @@ def input(self, payload): self.rdb.rpush(self.key, json.dumps(payload)) logger.info("Finished sending event to Redis.") - def flush(self): + def flush(self) -> None: logger.warning("The RedisEmitter class does not need to be flushed") - def sync_flush(self): + def sync_flush(self) -> None: self.flush() else: - def __new__(cls, *args, **kwargs): + def __new__(cls, *args: Any, **kwargs: Any) -> 'RedisEmitter': logger.error("RedisEmitter is not available. Please install snowplow-tracker with redis extra dependency.") raise RuntimeError('RedisEmitter is not available. To use: `pip install snowplow-tracker[redis]`') diff --git a/snowplow_tracker/redis/redis_worker.py b/snowplow_tracker/redis/redis_worker.py index 4fc37dac..ad57ae5f 100644 --- a/snowplow_tracker/redis/redis_worker.py +++ b/snowplow_tracker/redis/redis_worker.py @@ -22,6 +22,9 @@ import json import signal +from typing import Any, Optional + +from snowplow_tracker.typing import EmitterProtocol, PayloadDict, RedisProtocol _REDIS_OPT = True try: @@ -31,16 +34,16 @@ except ImportError: _REDIS_OPT = False - DEFAULT_KEY = "snowplow" + class RedisWorker(object): """ Asynchronously take events from redis and send them to an emitter """ if _REDIS_OPT: - def __init__(self, emitter, rdb=None, key=DEFAULT_KEY): + def __init__(self, emitter: EmitterProtocol, rdb: Optional[RedisProtocol] = None, key: str = DEFAULT_KEY) -> None: self.emitter = emitter self.key = key if rdb is None: @@ -52,13 +55,13 @@ def __init__(self, emitter, rdb=None, key=DEFAULT_KEY): signal.signal(signal.SIGINT, self.request_shutdown) signal.signal(signal.SIGQUIT, self.request_shutdown) - def send(self, payload): + def send(self, payload: PayloadDict) -> None: """ Send an event to an emitter """ self.emitter.input(payload) - def pop_payload(self): + def pop_payload(self) -> None: """ Get a single event from Redis and send it If the Redis queue is empty, sleep to avoid making continual requests @@ -69,7 +72,7 @@ def pop_payload(self): else: gevent.sleep(5) - def run(self): + def run(self) -> None: """ Run indefinitely """ @@ -79,7 +82,7 @@ def run(self): self.pop_payload() self.pool.join(timeout=20) - def request_shutdown(self, *args): + def request_shutdown(self, *args: Any) -> None: """ Halt the worker """ @@ -87,5 +90,5 @@ def request_shutdown(self, *args): else: - def __new__(cls, *args, **kwargs): + def __new__(cls, *args: Any, **kwargs: Any) -> 'RedisWorker': raise RuntimeError('RedisWorker is not available. To use: `pip install snowplow-tracker[redis]`') diff --git a/snowplow_tracker/self_describing_json.py b/snowplow_tracker/self_describing_json.py index 3b07ccd8..1aba4317 100644 --- a/snowplow_tracker/self_describing_json.py +++ b/snowplow_tracker/self_describing_json.py @@ -20,19 +20,22 @@ """ import json +from typing import Union + +from snowplow_tracker.typing import PayloadDict, PayloadDictList class SelfDescribingJson(object): - def __init__(self, schema, data): + def __init__(self, schema: str, data: Union[PayloadDict, PayloadDictList]) -> None: self.schema = schema self.data = data - def to_json(self): + def to_json(self) -> PayloadDict: return { "schema": self.schema, "data": self.data } - def to_string(self): + def to_string(self) -> str: return json.dumps(self.to_json()) diff --git a/snowplow_tracker/subject.py b/snowplow_tracker/subject.py index d3da41c3..c82ec0d2 100644 --- a/snowplow_tracker/subject.py +++ b/snowplow_tracker/subject.py @@ -19,15 +19,11 @@ License: Apache License Version 2.0 """ -from contracts import contract, new_contract +from snowplow_tracker.contracts import one_of, greater_than +from snowplow_tracker.typing import SupportedPlatform, SUPPORTED_PLATFORMS -SUPPORTED_PLATFORMS = set(["pc", "tv", "mob", "cnsl", "iot", "web", "srv", "app"]) DEFAULT_PLATFORM = "pc" -new_contract("subject", lambda x: isinstance(x, Subject)) - -new_contract("supported_platform", lambda x: x in SUPPORTED_PLATFORMS) - class Subject(object): """ @@ -35,22 +31,22 @@ class Subject(object): (Subject) -> (Verb) -> (Object) """ - def __init__(self): + def __init__(self) -> None: self.standard_nv_pairs = {"p": DEFAULT_PLATFORM} - @contract - def set_platform(self, value): + def set_platform(self, value: SupportedPlatform) -> 'Subject': """ :param value: One of ["pc", "tv", "mob", "cnsl", "iot", "web", "srv", "app"] :type value: supported_platform :rtype: subject """ + one_of(value, SUPPORTED_PLATFORMS) + self.standard_nv_pairs["p"] = value return self - @contract - def set_user_id(self, user_id): + def set_user_id(self, user_id: str) -> 'Subject': """ :param user_id: User ID :type user_id: string @@ -59,8 +55,7 @@ def set_user_id(self, user_id): self.standard_nv_pairs["uid"] = user_id return self - @contract - def set_screen_resolution(self, width, height): + def set_screen_resolution(self, width: int, height: int) -> 'Subject': """ :param width: Width of the screen :param height: Height of the screen @@ -68,11 +63,13 @@ def set_screen_resolution(self, width, height): :type height: int,>0 :rtype: subject """ + greater_than(width, 0) + greater_than(height, 0) + self.standard_nv_pairs["res"] = "".join([str(width), "x", str(height)]) return self - @contract - def set_viewport(self, width, height): + def set_viewport(self, width: int, height: int) -> 'Subject': """ :param width: Width of the viewport :param height: Height of the viewport @@ -80,11 +77,13 @@ def set_viewport(self, width, height): :type height: int,>0 :rtype: subject """ + greater_than(width, 0) + greater_than(height, 0) + self.standard_nv_pairs["vp"] = "".join([str(width), "x", str(height)]) return self - @contract - def set_color_depth(self, depth): + def set_color_depth(self, depth: int) -> 'Subject': """ :param depth: Depth of the color on the screen :type depth: int @@ -93,8 +92,7 @@ def set_color_depth(self, depth): self.standard_nv_pairs["cd"] = depth return self - @contract - def set_timezone(self, timezone): + def set_timezone(self, timezone: str) -> 'Subject': """ :param timezone: Timezone as a string :type timezone: string @@ -103,8 +101,7 @@ def set_timezone(self, timezone): self.standard_nv_pairs["tz"] = timezone return self - @contract - def set_lang(self, lang): + def set_lang(self, lang: str) -> 'Subject': """ Set language. @@ -115,8 +112,7 @@ def set_lang(self, lang): self.standard_nv_pairs["lang"] = lang return self - @contract - def set_domain_user_id(self, duid): + def set_domain_user_id(self, duid: str) -> 'Subject': """ Set the domain user ID @@ -127,8 +123,7 @@ def set_domain_user_id(self, duid): self.standard_nv_pairs["duid"] = duid return self - @contract - def set_ip_address(self, ip): + def set_ip_address(self, ip: str) -> 'Subject': """ Set the domain user ID @@ -139,8 +134,7 @@ def set_ip_address(self, ip): self.standard_nv_pairs["ip"] = ip return self - @contract - def set_useragent(self, ua): + def set_useragent(self, ua: str) -> 'Subject': """ Set the user agent @@ -151,8 +145,7 @@ def set_useragent(self, ua): self.standard_nv_pairs["ua"] = ua return self - @contract - def set_network_user_id(self, nuid): + def set_network_user_id(self, nuid: str) -> 'Subject': """ Set the network user ID field This overwrites the nuid field set by the collector diff --git a/snowplow_tracker/test/integration/test_integration.py b/snowplow_tracker/test/integration/test_integration.py index f210a835..4cbe131c 100644 --- a/snowplow_tracker/test/integration/test_integration.py +++ b/snowplow_tracker/test/integration/test_integration.py @@ -23,18 +23,15 @@ import re import json import base64 -try: - from urllib.parse import unquote_plus # Python 3 -except ImportError: - from urllib import unquote_plus # Python 2 - +from urllib.parse import unquote_plus import pytest from httmock import all_requests, HTTMock from freezegun import freeze_time +from typing import Any, Dict, Optional from snowplow_tracker import tracker, _version, emitters, subject from snowplow_tracker.self_describing_json import SelfDescribingJson -from snowplow_tracker.redis import redis_emitter, redis_worker +from snowplow_tracker.redis import redis_emitter querystrings = [""] @@ -45,30 +42,34 @@ default_subject = subject.Subject() -def from_querystring(field, url): + +def from_querystring(field: str, url: str) -> Optional[str]: pattern = re.compile("^[^#]*[?&]" + field + "=([^&#]*)") match = pattern.match(url) if match: return match.groups()[0] + @all_requests -def pass_response_content(url, request): +def pass_response_content(url: str, request: Any) -> Dict[str, Any]: querystrings.append(request.url) return { "url": request.url, "status_code": 200 } + @all_requests -def pass_post_response_content(url, request): +def pass_post_response_content(url: str, request: Any) -> Dict[str, Any]: querystrings.append(json.loads(request.body)) return { "url": request.url, "status_code": 200 } + @all_requests -def fail_response_content(url, request): +def fail_response_content(url: str, request: Any) -> Dict[str, Any]: return { "url": request.url, "status_code": 501 @@ -77,7 +78,7 @@ def fail_response_content(url, request): class IntegrationTest(unittest.TestCase): - def test_integration_page_view(self): + def test_integration_page_view(self) -> None: t = tracker.Tracker([default_emitter], default_subject) with HTTMock(pass_response_content): t.track_page_view("http://savethearctic.org", "Save The Arctic", "http://referrer.com") @@ -85,7 +86,7 @@ def test_integration_page_view(self): for key in expected_fields: self.assertEqual(from_querystring(key, querystrings[-1]), expected_fields[key]) - def test_integration_ecommerce_transaction_item(self): + def test_integration_ecommerce_transaction_item(self) -> None: t = tracker.Tracker([default_emitter], default_subject) with HTTMock(pass_response_content): t.track_ecommerce_transaction_item("12345", "pbz0025", 7.99, 2, "black-tarot", "tarot", currency="GBP") @@ -93,36 +94,38 @@ def test_integration_ecommerce_transaction_item(self): for key in expected_fields: self.assertEqual(from_querystring(key, querystrings[-1]), expected_fields[key]) - def test_integration_ecommerce_transaction(self): + def test_integration_ecommerce_transaction(self) -> None: t = tracker.Tracker([default_emitter], default_subject) with HTTMock(pass_response_content): - t.track_ecommerce_transaction("6a8078be", 35, city="London", currency="GBP", items= - [{ - "sku": "pbz0026", - "price": 20, - "quantity": 1 - }, - { - "sku": "pbz0038", - "price": 15, - "quantity": 1 - }]) + t.track_ecommerce_transaction( + "6a8078be", 35, city="London", currency="GBP", + items=[ + { + "sku": "pbz0026", + "price": 20, + "quantity": 1 + }, + { + "sku": "pbz0038", + "price": 15, + "quantity": 1 + }]) expected_fields = {"e": "tr", "tr_id": "6a8078be", "tr_tt": "35", "tr_ci": "London", "tr_cu": "GBP"} for key in expected_fields: self.assertEqual(from_querystring(key, querystrings[-3]), expected_fields[key]) - expected_fields = {"e": "ti", "ti_id": "6a8078be", "ti_sk": "pbz0026", "ti_pr": "20", "ti_cu": "GBP"} + expected_fields = {"e": "ti", "ti_id": "6a8078be", "ti_sk": "pbz0026", "ti_pr": "20", "ti_cu": "GBP"} for key in expected_fields: self.assertEqual(from_querystring(key, querystrings[-2]), expected_fields[key]) - expected_fields = {"e": "ti", "ti_id": "6a8078be", "ti_sk": "pbz0038", "ti_pr": "15", "ti_cu": "GBP"} + expected_fields = {"e": "ti", "ti_id": "6a8078be", "ti_sk": "pbz0038", "ti_pr": "15", "ti_cu": "GBP"} for key in expected_fields: self.assertEqual(from_querystring(key, querystrings[-1]), expected_fields[key]) self.assertEqual(from_querystring("ttm", querystrings[-3]), from_querystring("ttm", querystrings[-2])) - def test_integration_screen_view(self): + def test_integration_screen_view(self) -> None: t = tracker.Tracker([default_emitter], default_subject, encode_base64=False) with HTTMock(pass_response_content): t.track_screen_view("Game HUD 2", id_="534") @@ -133,7 +136,8 @@ def test_integration_screen_view(self): envelope = json.loads(unquote_plus(envelope_string)) self.assertEqual(envelope, { "schema": "iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0", - "data": {"schema": "iglu:com.snowplowanalytics.snowplow/screen_view/jsonschema/1-0-0", + "data": { + "schema": "iglu:com.snowplowanalytics.snowplow/screen_view/jsonschema/1-0-0", "data": { "name": "Game HUD 2", "id": "534" @@ -141,7 +145,7 @@ def test_integration_screen_view(self): } }) - def test_integration_struct_event(self): + def test_integration_struct_event(self) -> None: t = tracker.Tracker([default_emitter], default_subject) with HTTMock(pass_response_content): t.track_struct_event("Ecomm", "add-to-basket", "dog-skateboarding-video", "hd", 13.99) @@ -149,7 +153,7 @@ def test_integration_struct_event(self): for key in expected_fields: self.assertEqual(from_querystring(key, querystrings[-1]), expected_fields[key]) - def test_integration_unstruct_event_non_base64(self): + def test_integration_unstruct_event_non_base64(self) -> None: t = tracker.Tracker([default_emitter], default_subject, encode_base64=False) with HTTMock(pass_response_content): t.track_unstruct_event(SelfDescribingJson("iglu:com.acme/viewed_product/jsonschema/2-0-2", {"product_id": "ASO01043", "price$flt": 49.95, "walrus$tms": 1000})) @@ -163,7 +167,7 @@ def test_integration_unstruct_event_non_base64(self): "data": {"schema": "iglu:com.acme/viewed_product/jsonschema/2-0-2", "data": {"product_id": "ASO01043", "price$flt": 49.95, "walrus$tms": 1000}} }) - def test_integration_unstruct_event_base64(self): + def test_integration_unstruct_event_base64(self) -> None: t = tracker.Tracker([default_emitter], default_subject, encode_base64=True) with HTTMock(pass_response_content): t.track_unstruct_event(SelfDescribingJson("iglu:com.acme/viewed_product/jsonschema/2-0-2", {"product_id": "ASO01043", "price$flt": 49.95, "walrus$tms": 1000})) @@ -177,7 +181,7 @@ def test_integration_unstruct_event_base64(self): "data": {"schema": "iglu:com.acme/viewed_product/jsonschema/2-0-2", "data": {"product_id": "ASO01043", "price$flt": 49.95, "walrus$tms": 1000}} }) - def test_integration_context_non_base64(self): + def test_integration_context_non_base64(self) -> None: t = tracker.Tracker([default_emitter], default_subject, encode_base64=False) with HTTMock(pass_response_content): t.track_page_view("localhost", "local host", None, [SelfDescribingJson("iglu:com.example/user/jsonschema/2-0-3", {"user_type": "tester"})]) @@ -185,10 +189,10 @@ def test_integration_context_non_base64(self): envelope = json.loads(unquote_plus(envelope_string)) self.assertEqual(envelope, { "schema": "iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-1", - "data":[{"schema": "iglu:com.example/user/jsonschema/2-0-3", "data": {"user_type": "tester"}}] + "data": [{"schema": "iglu:com.example/user/jsonschema/2-0-3", "data": {"user_type": "tester"}}] }) - def test_integration_context_base64(self): + def test_integration_context_base64(self) -> None: t = tracker.Tracker([default_emitter], default_subject, encode_base64=True) with HTTMock(pass_response_content): t.track_page_view("localhost", "local host", None, [SelfDescribingJson("iglu:com.example/user/jsonschema/2-0-3", {"user_type": "tester"})]) @@ -196,10 +200,10 @@ def test_integration_context_base64(self): envelope = json.loads((base64.urlsafe_b64decode(bytearray(envelope_string, "utf-8"))).decode("utf-8")) self.assertEqual(envelope, { "schema": "iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-1", - "data":[{"schema": "iglu:com.example/user/jsonschema/2-0-3", "data": {"user_type": "tester"}}] + "data": [{"schema": "iglu:com.example/user/jsonschema/2-0-3", "data": {"user_type": "tester"}}] }) - def test_integration_standard_nv_pairs(self): + def test_integration_standard_nv_pairs(self) -> None: s = subject.Subject() s.set_platform("mob") s.set_user_id("user12345") @@ -219,7 +223,7 @@ def test_integration_standard_nv_pairs(self): self.assertIsNotNone(from_querystring("eid", querystrings[-1])) self.assertIsNotNone(from_querystring("dtm", querystrings[-1])) - def test_integration_identification_methods(self): + def test_integration_identification_methods(self) -> None: s = subject.Subject() s.set_domain_user_id("4616bfb38f872d16") s.set_ip_address("255.255.255.255") @@ -238,7 +242,7 @@ def test_integration_identification_methods(self): for key in expected_fields: self.assertEqual(from_querystring(key, querystrings[-1]), expected_fields[key]) - def test_integration_event_subject(self): + def test_integration_event_subject(self) -> None: s = subject.Subject() s.set_domain_user_id("4616bfb38f872d16") s.set_ip_address("255.255.255.255") @@ -254,23 +258,23 @@ def test_integration_event_subject(self): for key in expected_fields: self.assertEqual(from_querystring(key, querystrings[-1]), expected_fields[key]) - def test_integration_redis_default(self): + def test_integration_redis_default(self) -> None: try: - import redis - r = redis.StrictRedis() - t = tracker.Tracker([redis_emitter.RedisEmitter()], default_subject) + import fakeredis + r = fakeredis.FakeStrictRedis() + t = tracker.Tracker([redis_emitter.RedisEmitter(rdb=r)], default_subject) t.track_page_view("http://www.example.com") event_string = r.rpop("snowplow") event_dict = json.loads(event_string.decode("utf-8")) self.assertEqual(event_dict["e"], "pv") except ImportError: with pytest.raises(RuntimeError): - re = redis_emitter.RedisEmitter() + redis_emitter.RedisEmitter() - def test_integration_redis_custom(self): + def test_integration_redis_custom(self) -> None: try: - import redis - r = redis.StrictRedis(db=1) + import fakeredis + r = fakeredis.FakeStrictRedis() t = tracker.Tracker([redis_emitter.RedisEmitter(rdb=r, key="custom_key")], default_subject) t.track_page_view("http://www.example.com") event_string = r.rpop("custom_key") @@ -278,13 +282,15 @@ def test_integration_redis_custom(self): self.assertEqual(event_dict["e"], "pv") except ImportError: with pytest.raises(RuntimeError): - re = redis_emitter.RedisEmitter("arg", key="kwarg") + redis_emitter.RedisEmitter("arg", key="kwarg") - def test_integration_success_callback(self): + def test_integration_success_callback(self) -> None: callback_success_queue = [] callback_failure_queue = [] - callback_emitter = emitters.Emitter("localhost", on_success=lambda x: callback_success_queue.append(x), - on_failure=lambda x, y:callback_failure_queue.append(x)) + callback_emitter = emitters.Emitter( + "localhost", + on_success=lambda x: callback_success_queue.append(x), + on_failure=lambda x, y: callback_failure_queue.append(x)) t = tracker.Tracker([callback_emitter], default_subject) with HTTMock(pass_response_content): t.track_page_view("http://www.example.com") @@ -297,18 +303,20 @@ def test_integration_success_callback(self): self.assertEqual(callback_success_queue[0][0][k], expected[k]) self.assertEqual(callback_failure_queue, []) - def test_integration_failure_callback(self): + def test_integration_failure_callback(self) -> None: callback_success_queue = [] callback_failure_queue = [] - callback_emitter = emitters.Emitter("localhost", on_success=lambda x: callback_success_queue.append(x), - on_failure=lambda x, y:callback_failure_queue.append(x)) + callback_emitter = emitters.Emitter( + "localhost", + on_success=lambda x: callback_success_queue.append(x), + on_failure=lambda x, y: callback_failure_queue.append(x)) t = tracker.Tracker([callback_emitter], default_subject) with HTTMock(fail_response_content): t.track_page_view("http://www.example.com") self.assertEqual(callback_success_queue, []) self.assertEqual(callback_failure_queue[0], 0) - def test_post_page_view(self): + def test_post_page_view(self) -> None: t = tracker.Tracker([post_emitter], default_subject) with HTTMock(pass_post_response_content): t.track_page_view("localhost", "local host", None) @@ -318,7 +326,7 @@ def test_post_page_view(self): for key in expected_fields: self.assertEqual(request["data"][0][key], expected_fields[key]) - def test_post_batched(self): + def test_post_batched(self) -> None: post_emitter = emitters.Emitter("localhost", protocol="http", port=80, method='post', buffer_size=2) t = tracker.Tracker(post_emitter, default_subject) with HTTMock(pass_post_response_content): @@ -328,7 +336,7 @@ def test_post_batched(self): self.assertEqual(querystrings[-1]["data"][1]["se_ac"], "B") @freeze_time("2021-04-19 00:00:01") # unix: 1618790401000 - def test_timestamps(self): + def test_timestamps(self) -> None: emitter = emitters.Emitter("localhost", protocol="http", port=80, method='post', buffer_size=3) t = tracker.Tracker([emitter], default_subject) with HTTMock(pass_post_response_content): @@ -348,7 +356,7 @@ def test_timestamps(self): self.assertEqual(request["data"][i].get("ttm"), expected_timestamps[i]["ttm"]) self.assertEqual(request["data"][i].get("stm"), expected_timestamps[i]["stm"]) - def test_bytelimit(self): + def test_bytelimit(self) -> None: post_emitter = emitters.Emitter("localhost", protocol="http", port=80, method='post', buffer_size=5, byte_limit=420) t = tracker.Tracker(post_emitter, default_subject) with HTTMock(pass_post_response_content): @@ -359,7 +367,7 @@ def test_bytelimit(self): self.assertEqual(len(querystrings[-1]["data"]), 3) self.assertEqual(post_emitter.bytes_queued, 136 + len(_version.__version__)) - def test_unicode_get(self): + def test_unicode_get(self) -> None: t = tracker.Tracker([default_emitter], default_subject, encode_base64=False) unicode_a = u'\u0107' unicode_b = u'test.\u0107om' @@ -383,7 +391,7 @@ def test_unicode_get(self): actual_b = json.loads(uepr_string)['data']['data']['name'] self.assertEqual(actual_b, unicode_b) - def test_unicode_post(self): + def test_unicode_post(self) -> None: t = tracker.Tracker([post_emitter], default_subject, encode_base64=False) unicode_a = u'\u0107' unicode_b = u'test.\u0107om' diff --git a/snowplow_tracker/test/unit/__init__.py b/snowplow_tracker/test/unit/__init__.py index 8b137891..e69de29b 100644 --- a/snowplow_tracker/test/unit/__init__.py +++ b/snowplow_tracker/test/unit/__init__.py @@ -1 +0,0 @@ - diff --git a/snowplow_tracker/test/unit/test_contracts.py b/snowplow_tracker/test/unit/test_contracts.py new file mode 100644 index 00000000..b3200b54 --- /dev/null +++ b/snowplow_tracker/test/unit/test_contracts.py @@ -0,0 +1,125 @@ +""" + test_tracker.py + + Copyright (c) 2013-2021 Snowplow Analytics Ltd. All rights reserved. + + This program is licensed to you under the Apache License Version 2.0, + and you may not use this file except in compliance with the Apache License + Version 2.0. You may obtain a copy of the Apache License Version 2.0 at + http://www.apache.org/licenses/LICENSE-2.0. + + Unless required by applicable law or agreed to in writing, + software distributed under the Apache License Version 2.0 is distributed on + an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the Apache License Version 2.0 for the specific + language governing permissions and limitations there under. + + Authors: Anuj More, Alex Dean, Fred Blundun, Paul Boocock + Copyright: Copyright (c) 2013-2021 Snowplow Analytics Ltd + License: Apache License Version 2.0 +""" + +import unittest + +from snowplow_tracker.contracts import form_element, greater_than, non_empty, non_empty_string, one_of, satisfies + + +class TestContracts(unittest.TestCase): + + def setUp(self) -> None: + pass + + def test_greater_than_succeeds(self) -> None: + greater_than(10, 0) + + def test_greater_than_fails(self) -> None: + with self.assertRaises(ValueError): + greater_than(0, 10) + + def test_non_empty_succeeds(self) -> None: + non_empty(['something']) + + def test_non_empty_fails(self) -> None: + with self.assertRaises(ValueError): + non_empty([]) + + def test_non_empty_string_succeeds(self) -> None: + non_empty_string('something') + + def test_non_empty_string_fails(self) -> None: + with self.assertRaises(ValueError): + non_empty_string('') + + def test_one_of_succeeds(self) -> None: + one_of('something', ['something', 'something else']) + + def test_one_of_fails(self) -> None: + with self.assertRaises(ValueError): + one_of('something', ['something else']) + + def test_satisfies_succeeds(self) -> None: + satisfies(10, lambda v: v == 10) + + def test_satisfies_fails(self) -> None: + with self.assertRaises(ValueError): + satisfies(0, lambda v: v == 10) + + def test_form_element_no_type(self) -> None: + elem = { + "name": "elemName", + "value": "elemValue", + "nodeName": "INPUT" + } + form_element(elem) + + def test_form_element_type_valid(self) -> None: + elem = { + "name": "elemName", + "value": "elemValue", + "nodeName": "TEXTAREA", + "type": "button" + } + form_element(elem) + + def test_form_element_type_invalid(self) -> None: + elem = { + "name": "elemName", + "value": "elemValue", + "nodeName": "SELECT", + "type": "invalid" + } + with self.assertRaises(ValueError): + form_element(elem) + + def test_form_element_nodename_invalid(self) -> None: + elem = { + "name": "elemName", + "value": "elemValue", + "nodeName": "invalid" + } + with self.assertRaises(ValueError): + form_element(elem) + + def test_form_element_no_nodename(self) -> None: + elem = { + "name": "elemName", + "value": "elemValue" + } + with self.assertRaises(ValueError): + form_element(elem) + + def test_form_element_no_value(self) -> None: + elem = { + "name": "elemName", + "nodeName": "INPUT" + } + with self.assertRaises(ValueError): + form_element(elem) + + def test_form_element_no_name(self) -> None: + elem = { + "value": "elemValue", + "nodeName": "INPUT" + } + with self.assertRaises(ValueError): + form_element(elem) diff --git a/snowplow_tracker/test/unit/test_emitters.py b/snowplow_tracker/test/unit/test_emitters.py index 421fcf05..77c708ed 100644 --- a/snowplow_tracker/test/unit/test_emitters.py +++ b/snowplow_tracker/test/unit/test_emitters.py @@ -22,36 +22,37 @@ import time import unittest -try: - import unittest.mock as mock # py3 -except ImportError: - import mock # py2 - +import unittest.mock as mock from freezegun import freeze_time +from typing import Any +from requests import ConnectTimeout from snowplow_tracker.emitters import Emitter, AsyncEmitter, DEFAULT_MAX_LENGTH -from snowplow_tracker.payload import Payload + # helpers -def mocked_flush(*args): +def mocked_flush(*args: Any) -> None: pass -def mocked_send_events(*args): + +def mocked_send_events(*args: Any) -> None: pass -def mocked_http_success(*args): + +def mocked_http_success(*args: Any) -> bool: return True -def mocked_http_failure(*args): + +def mocked_http_failure(*args: Any) -> bool: return False class TestEmitters(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: pass - def test_init(self): + def test_init(self) -> None: e = Emitter('0.0.0.0') self.assertEqual(e.endpoint, 'http://0.0.0.0/i') self.assertEqual(e.method, 'get') @@ -62,41 +63,46 @@ def test_init(self): self.assertIsNone(e.on_success) self.assertIsNone(e.on_failure) self.assertIsNone(e.timer) + self.assertIsNone(e.request_timeout) - def test_init_buffer_size(self): + def test_init_buffer_size(self) -> None: e = Emitter('0.0.0.0', buffer_size=10) self.assertEqual(e.buffer_size, 10) - def test_init_post(self): + def test_init_post(self) -> None: e = Emitter('0.0.0.0', method="post") self.assertEqual(e.buffer_size, DEFAULT_MAX_LENGTH) - def test_init_byte_limit(self): + def test_init_byte_limit(self) -> None: e = Emitter('0.0.0.0', byte_limit=512) self.assertEqual(e.bytes_queued, 0) - def test_as_collector_uri(self): + def test_init_requests_timeout(self) -> None: + e = Emitter('0.0.0.0', request_timeout=(2.5, 5)) + self.assertEqual(e.request_timeout, (2.5, 5)) + + def test_as_collector_uri(self) -> None: uri = Emitter.as_collector_uri('0.0.0.0') self.assertEqual(uri, 'http://0.0.0.0/i') - def test_as_collector_uri_post(self): + def test_as_collector_uri_post(self) -> None: uri = Emitter.as_collector_uri('0.0.0.0', method="post") self.assertEqual(uri, 'http://0.0.0.0/com.snowplowanalytics.snowplow/tp2') - def test_as_collector_uri_port(self): + def test_as_collector_uri_port(self) -> None: uri = Emitter.as_collector_uri('0.0.0.0', port=9090, method="post") self.assertEqual(uri, 'http://0.0.0.0:9090/com.snowplowanalytics.snowplow/tp2') - def test_as_collector_uri_https(self): + def test_as_collector_uri_https(self) -> None: uri = Emitter.as_collector_uri('0.0.0.0', protocol="https") self.assertEqual(uri, 'https://0.0.0.0/i') - def test_as_collector_uri_empty_string(self): + def test_as_collector_uri_empty_string(self) -> None: with self.assertRaises(ValueError): - uri = Emitter.as_collector_uri('') + Emitter.as_collector_uri('') @mock.patch('snowplow_tracker.Emitter.flush') - def test_input_no_flush(self, mok_flush): + def test_input_no_flush(self, mok_flush: Any) -> None: mok_flush.side_effect = mocked_flush e = Emitter('0.0.0.0', method="get", buffer_size=2) @@ -110,7 +116,7 @@ def test_input_no_flush(self, mok_flush): mok_flush.assert_not_called() @mock.patch('snowplow_tracker.Emitter.flush') - def test_input_flush_byte_limit(self, mok_flush): + def test_input_flush_byte_limit(self, mok_flush: Any) -> None: mok_flush.side_effect = mocked_flush e = Emitter('0.0.0.0', method="get", buffer_size=2, byte_limit=16) @@ -123,7 +129,7 @@ def test_input_flush_byte_limit(self, mok_flush): self.assertEqual(mok_flush.call_count, 1) @mock.patch('snowplow_tracker.Emitter.flush') - def test_input_flush_buffer(self, mok_flush): + def test_input_flush_buffer(self, mok_flush: Any) -> None: mok_flush.side_effect = mocked_flush e = Emitter('0.0.0.0', method="get", buffer_size=2, byte_limit=1024) @@ -142,7 +148,7 @@ def test_input_flush_buffer(self, mok_flush): self.assertEqual(mok_flush.call_count, 1) @mock.patch('snowplow_tracker.Emitter.flush') - def test_input_bytes_queued(self, mok_flush): + def test_input_bytes_queued(self, mok_flush: Any) -> None: mok_flush.side_effect = mocked_flush e = Emitter('0.0.0.0', method="get", buffer_size=2, byte_limit=1024) @@ -156,7 +162,7 @@ def test_input_bytes_queued(self, mok_flush): self.assertEqual(e.bytes_queued, 48) @mock.patch('snowplow_tracker.Emitter.flush') - def test_input_bytes_post(self, mok_flush): + def test_input_bytes_post(self, mok_flush: Any) -> None: mok_flush.side_effect = mocked_flush e = Emitter('0.0.0.0', method="post") @@ -166,11 +172,11 @@ def test_input_bytes_post(self, mok_flush): self.assertEqual(e.buffer, [{"testString": "test", "testNum": "2.72"}]) @mock.patch('snowplow_tracker.Emitter.send_events') - def test_flush(self, mok_send_events): + def test_flush(self, mok_send_events: Any) -> None: mok_send_events.side_effect = mocked_send_events e = Emitter('0.0.0.0', buffer_size=2, byte_limit=None) - nvPairs = {"n":"v"} + nvPairs = {"n": "v"} e.input(nvPairs) e.input(nvPairs) @@ -178,11 +184,11 @@ def test_flush(self, mok_send_events): self.assertEqual(len(e.buffer), 0) @mock.patch('snowplow_tracker.Emitter.send_events') - def test_flush_bytes_queued(self, mok_send_events): + def test_flush_bytes_queued(self, mok_send_events: Any) -> None: mok_send_events.side_effect = mocked_send_events e = Emitter('0.0.0.0', buffer_size=2, byte_limit=256) - nvPairs = {"n":"v"} + nvPairs = {"n": "v"} e.input(nvPairs) e.input(nvPairs) @@ -191,22 +197,22 @@ def test_flush_bytes_queued(self, mok_send_events): self.assertEqual(e.bytes_queued, 0) @freeze_time("2021-04-14 00:00:02") # unix: 1618358402000 - def test_attach_sent_tstamp(self): + def test_attach_sent_tstamp(self) -> None: e = Emitter('0.0.0.0') - ev_list = [{"a": "aa"},{"b": "bb"},{"c": "cc"}] + ev_list = [{"a": "aa"}, {"b": "bb"}, {"c": "cc"}] e.attach_sent_timestamp(ev_list) reduced = True - for e in ev_list: - reduced = reduced and "stm" in e.keys() and e["stm"] == "1618358402000" + for ev in ev_list: + reduced = reduced and "stm" in ev.keys() and ev["stm"] == "1618358402000" self.assertTrue(reduced) @mock.patch('snowplow_tracker.Emitter.flush') - def test_flush_timer(self, mok_flush): + def test_flush_timer(self, mok_flush: Any) -> None: mok_flush.side_effect = mocked_flush e = Emitter('0.0.0.0', method="post", buffer_size=10) - ev_list = [{"a": "aa"},{"b": "bb"},{"c": "cc"}] + ev_list = [{"a": "aa"}, {"b": "bb"}, {"c": "cc"}] for i in ev_list: e.input(i) @@ -216,85 +222,101 @@ def test_flush_timer(self, mok_flush): self.assertEqual(mok_flush.call_count, 1) @mock.patch('snowplow_tracker.Emitter.http_get') - def test_send_events_get_success(self, mok_http_get): + def test_send_events_get_success(self, mok_http_get: Any) -> None: mok_http_get.side_effect = mocked_http_success mok_success = mock.Mock(return_value="success mocked") mok_failure = mock.Mock(return_value="failure mocked") e = Emitter('0.0.0.0', method="get", buffer_size=10, on_success=mok_success, on_failure=mok_failure) - evBuffer = [{"a":"aa"}, {"b": "bb"}, {"c": "cc"}] + evBuffer = [{"a": "aa"}, {"b": "bb"}, {"c": "cc"}] e.send_events(evBuffer) mok_success.assert_called_once_with(evBuffer) mok_failure.assert_not_called() @mock.patch('snowplow_tracker.Emitter.http_get') - def test_send_events_get_failure(self, mok_http_get): + def test_send_events_get_failure(self, mok_http_get: Any) -> None: mok_http_get.side_effect = mocked_http_failure mok_success = mock.Mock(return_value="success mocked") mok_failure = mock.Mock(return_value="failure mocked") e = Emitter('0.0.0.0', method="get", buffer_size=10, on_success=mok_success, on_failure=mok_failure) - evBuffer = [{"a":"aa"}, {"b": "bb"}, {"c": "cc"}] + evBuffer = [{"a": "aa"}, {"b": "bb"}, {"c": "cc"}] e.send_events(evBuffer) mok_success.assert_not_called() mok_failure.assert_called_once_with(0, evBuffer) @mock.patch('snowplow_tracker.Emitter.http_post') - def test_send_events_post_success(self, mok_http_post): + def test_send_events_post_success(self, mok_http_post: Any) -> None: mok_http_post.side_effect = mocked_http_success mok_success = mock.Mock(return_value="success mocked") mok_failure = mock.Mock(return_value="failure mocked") e = Emitter('0.0.0.0', method="post", buffer_size=10, on_success=mok_success, on_failure=mok_failure) - evBuffer = [{"a":"aa"}, {"b": "bb"}, {"c": "cc"}] + evBuffer = [{"a": "aa"}, {"b": "bb"}, {"c": "cc"}] e.send_events(evBuffer) mok_success.assert_called_once_with(evBuffer) mok_failure.assert_not_called() @mock.patch('snowplow_tracker.Emitter.http_post') - def test_send_events_post_failure(self, mok_http_post): + def test_send_events_post_failure(self, mok_http_post: Any) -> None: mok_http_post.side_effect = mocked_http_failure mok_success = mock.Mock(return_value="success mocked") mok_failure = mock.Mock(return_value="failure mocked") e = Emitter('0.0.0.0', method="post", buffer_size=10, on_success=mok_success, on_failure=mok_failure) - evBuffer = [{"a":"aa"}, {"b": "bb"}, {"c": "cc"}] + evBuffer = [{"a": "aa"}, {"b": "bb"}, {"c": "cc"}] e.send_events(evBuffer) mok_success.assert_not_called() mok_failure.assert_called_with(0, evBuffer) + @mock.patch('snowplow_tracker.emitters.requests.post') + def test_http_post_connect_timeout_error(self, mok_post_request: Any) -> None: + mok_post_request.side_effect = ConnectTimeout + e = Emitter('0.0.0.0') + post_succeeded = e.http_post("dummy_string") + + self.assertFalse(post_succeeded) + + @mock.patch('snowplow_tracker.emitters.requests.post') + def test_http_get_connect_timeout_error(self, mok_post_request: Any) -> None: + mok_post_request.side_effect = ConnectTimeout + e = Emitter('0.0.0.0') + get_succeeded = e.http_get({"a": "b"}) + + self.assertFalse(get_succeeded) + ### # AsyncEmitter ### @mock.patch('snowplow_tracker.AsyncEmitter.flush') - def test_async_emitter_input(self, mok_flush): + def test_async_emitter_input(self, mok_flush: Any) -> None: mok_flush.side_effect = mocked_flush ae = AsyncEmitter('0.0.0.0', port=9090, method="get", buffer_size=3, thread_count=5) self.assertTrue(ae.queue.empty()) - ae.input({"a":"aa"}) - ae.input({"b":"bb"}) + ae.input({"a": "aa"}) + ae.input({"b": "bb"}) self.assertEqual(len(ae.buffer), 2) self.assertTrue(ae.queue.empty()) mok_flush.assert_not_called() - ae.input({"c":"cc"}) # meet buffer size + ae.input({"c": "cc"}) # meet buffer size self.assertEqual(mok_flush.call_count, 1) @mock.patch('snowplow_tracker.AsyncEmitter.send_events') - def test_async_emitter_sync_flash(self, mok_send_events): + def test_async_emitter_sync_flash(self, mok_send_events: Any) -> None: mok_send_events.side_effect = mocked_send_events ae = AsyncEmitter('0.0.0.0', port=9090, method="get", buffer_size=3, thread_count=5, byte_limit=1024) self.assertTrue(ae.queue.empty()) - ae.input({"a":"aa"}) - ae.input({"b":"bb"}) + ae.input({"a": "aa"}) + ae.input({"b": "bb"}) self.assertEqual(len(ae.buffer), 2) self.assertTrue(ae.queue.empty()) mok_send_events.assert_not_called() @@ -305,60 +327,60 @@ def test_async_emitter_sync_flash(self, mok_send_events): self.assertEqual(mok_send_events.call_count, 1) @mock.patch('snowplow_tracker.Emitter.http_get') - def test_async_send_events_get_success(self, mok_http_get): + def test_async_send_events_get_success(self, mok_http_get: Any) -> None: mok_http_get.side_effect = mocked_http_success mok_success = mock.Mock(return_value="success mocked") mok_failure = mock.Mock(return_value="failure mocked") ae = AsyncEmitter('0.0.0.0', method="get", buffer_size=10, on_success=mok_success, on_failure=mok_failure) - evBuffer = [{"a":"aa"}, {"b": "bb"}, {"c": "cc"}] + evBuffer = [{"a": "aa"}, {"b": "bb"}, {"c": "cc"}] ae.send_events(evBuffer) mok_success.assert_called_once_with(evBuffer) mok_failure.assert_not_called() @mock.patch('snowplow_tracker.Emitter.http_get') - def test_async_send_events_get_failure(self, mok_http_get): + def test_async_send_events_get_failure(self, mok_http_get: Any) -> None: mok_http_get.side_effect = mocked_http_failure mok_success = mock.Mock(return_value="success mocked") mok_failure = mock.Mock(return_value="failure mocked") ae = AsyncEmitter('0.0.0.0', method="get", buffer_size=10, on_success=mok_success, on_failure=mok_failure) - evBuffer = [{"a":"aa"}, {"b": "bb"}, {"c": "cc"}] + evBuffer = [{"a": "aa"}, {"b": "bb"}, {"c": "cc"}] ae.send_events(evBuffer) mok_success.assert_not_called() mok_failure.assert_called_once_with(0, evBuffer) @mock.patch('snowplow_tracker.Emitter.http_post') - def test_async_send_events_post_success(self, mok_http_post): + def test_async_send_events_post_success(self, mok_http_post: Any) -> None: mok_http_post.side_effect = mocked_http_success mok_success = mock.Mock(return_value="success mocked") mok_failure = mock.Mock(return_value="failure mocked") ae = Emitter('0.0.0.0', method="post", buffer_size=10, on_success=mok_success, on_failure=mok_failure) - evBuffer = [{"a":"aa"}, {"b": "bb"}, {"c": "cc"}] + evBuffer = [{"a": "aa"}, {"b": "bb"}, {"c": "cc"}] ae.send_events(evBuffer) mok_success.assert_called_once_with(evBuffer) mok_failure.assert_not_called() @mock.patch('snowplow_tracker.Emitter.http_post') - def test_async_send_events_post_failure(self, mok_http_post): + def test_async_send_events_post_failure(self, mok_http_post: Any) -> None: mok_http_post.side_effect = mocked_http_failure mok_success = mock.Mock(return_value="success mocked") mok_failure = mock.Mock(return_value="failure mocked") ae = Emitter('0.0.0.0', method="post", buffer_size=10, on_success=mok_success, on_failure=mok_failure) - evBuffer = [{"a":"aa"}, {"b": "bb"}, {"c": "cc"}] + evBuffer = [{"a": "aa"}, {"b": "bb"}, {"c": "cc"}] ae.send_events(evBuffer) mok_success.assert_not_called() mok_failure.assert_called_with(0, evBuffer) - ## Unicode + # Unicode @mock.patch('snowplow_tracker.AsyncEmitter.flush') - def test_input_unicode_get(self, mok_flush): + def test_input_unicode_get(self, mok_flush: Any) -> None: mok_flush.side_effect = mocked_flush payload = {"unicode": u'\u0107', "alsoAscii": "abc"} @@ -369,7 +391,7 @@ def test_input_unicode_get(self, mok_flush): self.assertDictEqual(payload, ae.buffer[0]) @mock.patch('snowplow_tracker.AsyncEmitter.flush') - def test_input_unicode_post(self, mok_flush): + def test_input_unicode_post(self, mok_flush: Any) -> None: mok_flush.side_effect = mocked_flush payload = {"unicode": u'\u0107', "alsoAscii": "abc"} diff --git a/snowplow_tracker/test/unit/test_payload.py b/snowplow_tracker/test/unit/test_payload.py index ac816e05..d3707e78 100644 --- a/snowplow_tracker/test/unit/test_payload.py +++ b/snowplow_tracker/test/unit/test_payload.py @@ -19,14 +19,15 @@ License: Apache License Version 2.0 """ - import json import base64 import unittest +from typing import Dict, Any + from snowplow_tracker import payload -def is_subset(dict1, dict2): +def is_subset(dict1: Dict[Any, Any], dict2: Dict[Any, Any]) -> bool: """ * is_subset(smaller_dict, larger_dict) Checks if dict1 has name, value pairs that also exist in dict2. @@ -42,12 +43,12 @@ def is_subset(dict1, dict2): return False -def date_encoder(o): +def date_encoder(o: Any) -> str: """Sample custom JSON encoder which converts dates into their ISO format""" from datetime import date from json.encoder import JSONEncoder - if isinstance(o,date): + if isinstance(o, date): return o.isoformat() return JSONEncoder.default(o) @@ -55,97 +56,97 @@ def date_encoder(o): class TestPayload(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: pass - def test_object_generation(self): + def test_object_generation(self) -> None: p = payload.Payload() self.assertDictEqual({}, p.nv_pairs) - def test_object_generation_2(self): + def test_object_generation_2(self) -> None: p = payload.Payload({"test1": "result1", "test2": "result2", }) output = {"test1": "result1", "test2": "result2"} self.assertDictEqual(output, p.nv_pairs) - def test_add(self): + def test_add(self) -> None: p = payload.Payload() p.add("name1", "value1") p.add("name2", "value2") output = {"name1": "value1", "name2": "value2", } self.assertDictEqual(output, p.nv_pairs) - def test_add_empty_val(self): + def test_add_empty_val(self) -> None: p = payload.Payload() p.add("name", "") output = {} self.assertDictEqual(output, p.nv_pairs) - def test_add_none(self): + def test_add_none(self) -> None: p = payload.Payload() p.add("name", None) output = {} self.assertDictEqual(output, p.nv_pairs) - def test_add_dict(self): + def test_add_dict(self) -> None: p = payload.Payload({"n1": "v1", "n2": "v2", }) p.add_dict({"name4": 4, "name3": 3}) # Order doesn't matter output = {"n1": "v1", "n2": "v2", "name3": 3, "name4": 4} self.assertDictEqual(output, p.nv_pairs) - def test_add_json_empty(self): + def test_add_json_empty(self) -> None: p = payload.Payload({'name': 'value'}) input = {} p.add_json(input, False, 'ue_px', 'ue_pr') output = {'name': 'value'} self.assertDictEqual(output, p.nv_pairs) - def test_add_json_none(self): + def test_add_json_none(self) -> None: p = payload.Payload({'name': 'value'}) input = None p.add_json(input, False, 'ue_px', 'ue_pr') output = {'name': 'value'} self.assertDictEqual(output, p.nv_pairs) - def test_add_json_encode_false(self): + def test_add_json_encode_false(self) -> None: p = payload.Payload() input = {'a': 1} p.add_json(input, False, 'ue_px', 'ue_pr') self.assertTrue('ue_pr' in p.nv_pairs.keys()) self.assertFalse('ue_px' in p.nv_pairs.keys()) - def test_add_json_encode_true(self): + def test_add_json_encode_true(self) -> None: p = payload.Payload() input = {'a': 1} p.add_json(input, True, 'ue_px', 'ue_pr') self.assertFalse('ue_pr' in p.nv_pairs.keys()) self.assertTrue('ue_px' in p.nv_pairs.keys()) - def test_add_json_unicode_encode_false(self): + def test_add_json_unicode_encode_false(self) -> None: p = payload.Payload() input = {'a': u'\u0107', u'\u0107': 'b'} p.add_json(input, False, 'ue_px', 'ue_pr') ue_pr = json.loads(p.nv_pairs["ue_pr"]) self.assertDictEqual(input, ue_pr) - def test_add_json_unicode_encode_true(self): + def test_add_json_unicode_encode_true(self) -> None: p = payload.Payload() input = {'a': '\u0107', '\u0107': 'b'} p.add_json(input, True, 'ue_px', 'ue_pr') ue_px = json.loads(base64.urlsafe_b64decode(p.nv_pairs["ue_px"]).decode('utf-8')) self.assertDictEqual(input, ue_px) - def test_add_json_with_custom_enc(self): + def test_add_json_with_custom_enc(self) -> None: from datetime import date p = payload.Payload() - input = {"key1": date(2020,2,1)} + input = {"key1": date(2020, 2, 1)} p.add_json(input, False, "name1", "name1", date_encoder) results = json.loads(p.nv_pairs["name1"]) self.assertTrue(is_subset({"key1": "2020-02-01"}, results)) - def test_subject_get(self): + def test_subject_get(self) -> None: p = payload.Payload({'name1': 'val1'}) self.assertDictEqual(p.get(), p.nv_pairs) diff --git a/snowplow_tracker/test/unit/test_subject.py b/snowplow_tracker/test/unit/test_subject.py index 8d3c8da0..82d83128 100644 --- a/snowplow_tracker/test/unit/test_subject.py +++ b/snowplow_tracker/test/unit/test_subject.py @@ -19,20 +19,18 @@ License: Apache License Version 2.0 """ - import unittest import pytest -from contracts.interface import ContractNotRespected - from snowplow_tracker import subject as _subject + class TestSubject(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: pass - def test_subject_0(self): + def test_subject_0(self) -> None: s = _subject.Subject() self.assertDictEqual(s.standard_nv_pairs, {"p": _subject.DEFAULT_PLATFORM}) @@ -63,7 +61,7 @@ def test_subject_0(self): } self.assertDictEqual(s.standard_nv_pairs, exp) - def test_subject_1(self): + def test_subject_1(self) -> None: s = _subject.Subject().set_platform("srv").set_user_id("1234").set_lang("EN") exp = { diff --git a/snowplow_tracker/test/unit/test_tracker.py b/snowplow_tracker/test/unit/test_tracker.py index b36d3ea1..c89586f4 100644 --- a/snowplow_tracker/test/unit/test_tracker.py +++ b/snowplow_tracker/test/unit/test_tracker.py @@ -19,23 +19,17 @@ License: Apache License Version 2.0 """ - import re -import time import json import unittest -try: - import unittest.mock as mock # py3 -except ImportError: - import mock # py2 +import unittest.mock as mock -from contracts.interface import ContractNotRespected -from contracts import disable_all, enable_all from freezegun import freeze_time +from typing import Any +from snowplow_tracker.contracts import disable_contracts, enable_contracts from snowplow_tracker.tracker import Tracker from snowplow_tracker.tracker import VERSION as TRACKER_VERSION -from snowplow_tracker.emitters import Emitter from snowplow_tracker.subject import Subject from snowplow_tracker.payload import Payload from snowplow_tracker.self_describing_json import SelfDescribingJson @@ -53,54 +47,61 @@ # helpers _TEST_UUID = '5628c4c6-3f8a-43f8-a09f-6ff68f68dfb6' geoSchema = "iglu:com.snowplowanalytics.snowplow/geolocation_context/jsonschema/1-0-0" -geoData = {"latitude": -23.2,"longitude": 43.0} +geoData = {"latitude": -23.2, "longitude": 43.0} movSchema = "iglu:com.acme_company/movie_poster/jsonschema/2-1-1" movData = {"movie": "TestMovie", "year": 2021} -def mocked_uuid(): + +def mocked_uuid() -> str: return _TEST_UUID -def mocked_track(pb): + +def mocked_track(pb: Any) -> None: pass -def mocked_complete_payload(*args, **kwargs): + +def mocked_complete_payload(*args: Any, **kwargs: Any) -> None: pass -def mocked_track_trans_item(*args, **kwargs): + +def mocked_track_trans_item(*args: Any, **kwargs: Any) -> None: pass -def mocked_track_unstruct(*args, **kwargs): + +def mocked_track_unstruct(*args: Any, **kwargs: Any) -> None: pass + class ContractsDisabled(object): - def __enter__(self): - disable_all() - def __exit__(self, type, value, traceback): - enable_all() + def __enter__(self) -> None: + disable_contracts() + + def __exit__(self, type: Any, value: Any, traceback: Any) -> None: + enable_contracts() class TestTracker(unittest.TestCase): - def create_patch(self, name): + def create_patch(self, name: str) -> Any: patcher = mock.patch(name) thing = patcher.start() thing.side_effect = mock.MagicMock self.addCleanup(patcher.stop) return thing - def setUp(self): + def setUp(self) -> None: pass - def test_initialisation(self): + def test_initialisation(self) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - t = Tracker([e], namespace="cloudfront", encode_base64= False, app_id="AF003") + t = Tracker([e], namespace="cloudfront", encode_base64=False, app_id="AF003") self.assertEqual(t.standard_nv_pairs["tna"], "cloudfront") self.assertEqual(t.standard_nv_pairs["aid"], "AF003") self.assertEqual(t.encode_base64, False) - def test_initialisation_default_optional(self): + def test_initialisation_default_optional(self) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() @@ -110,19 +111,19 @@ def test_initialisation_default_optional(self): self.assertTrue(t.standard_nv_pairs["aid"] is None) self.assertEqual(t.encode_base64, True) - def test_initialisation_emitter_list(self): + def test_initialisation_emitter_list(self) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e1 = mokEmitter() e2 = mokEmitter() - t = Tracker([e1,e2]) - self.assertEqual(t.emitters, [e1,e2]) + t = Tracker([e1, e2]) + self.assertEqual(t.emitters, [e1, e2]) - def test_initialisation_error(self): - with self.assertRaises(ContractNotRespected): - t = Tracker([]) + def test_initialisation_error(self) -> None: + with self.assertRaises(ValueError): + Tracker([]) - def test_initialization_with_subject(self): + def test_initialization_with_subject(self) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() @@ -130,43 +131,41 @@ def test_initialization_with_subject(self): t = Tracker(e, subject=s) self.assertIs(t.subject, s) - def test_get_uuid(self): + def test_get_uuid(self) -> None: eid = Tracker.get_uuid() self.assertIsNotNone(re.match(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z', eid)) @freeze_time("1970-01-01 00:00:01") - def test_get_timestamp(self): + def test_get_timestamp(self) -> None: tstamp = Tracker.get_timestamp() self.assertEqual(tstamp, 1000) # 1970-01-01 00:00:01 in ms - def test_get_timestamp_1(self): + def test_get_timestamp_1(self) -> None: tstamp = Tracker.get_timestamp(1399021242030) self.assertEqual(tstamp, 1399021242030) - def test_get_timestamp_2(self): + def test_get_timestamp_2(self) -> None: tstamp = Tracker.get_timestamp(1399021242240.0303) self.assertEqual(tstamp, 1399021242240) @freeze_time("1970-01-01 00:00:01") - def test_get_timestamp_3(self): - with ContractsDisabled(): - tstamp = Tracker.get_timestamp("1399021242030") # test wrong arg type - self.assertEqual(tstamp, 1000) # 1970-01-01 00:00:01 in ms + def test_get_timestamp_3(self) -> None: + tstamp = Tracker.get_timestamp("1399021242030") # test wrong arg type + self.assertEqual(tstamp, 1000) # 1970-01-01 00:00:01 in ms @mock.patch('snowplow_tracker.Tracker.track') - def test_alias_of_track_unstruct_event(self, mok_track): + def test_alias_of_track_unstruct_event(self, mok_track: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_track.side_effect = mocked_track - t = Tracker(e) - evJson = SelfDescribingJson("test.schema", {"n":"v"}) - # call the alias - t.track_self_describing_event(evJson) - self.assertEqual(mok_track.call_count, 1) - - def test_flush(self): + mok_track.side_effect = mocked_track + t = Tracker(e) + evJson = SelfDescribingJson("test.schema", {"n": "v"}) + # call the alias + t.track_self_describing_event(evJson) + self.assertEqual(mok_track.call_count, 1) + + def test_flush(self) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e1 = mokEmitter() e2 = mokEmitter() @@ -178,7 +177,7 @@ def test_flush(self): e2.flush.assert_not_called() self.assertEqual(e2.sync_flush.call_count, 1) - def test_flush_async(self): + def test_flush_async(self) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e1 = mokEmitter() e2 = mokEmitter() @@ -190,7 +189,7 @@ def test_flush_async(self): self.assertEqual(e2.flush.call_count, 1) e2.sync_flush.assert_not_called() - def test_set_subject(self): + def test_set_subject(self) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() @@ -200,7 +199,7 @@ def test_set_subject(self): t.set_subject(new_subject) self.assertIs(t.subject, new_subject) - def test_add_emitter(self): + def test_add_emitter(self) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e1 = mokEmitter() e2 = mokEmitter() @@ -209,72 +208,17 @@ def test_add_emitter(self): t.add_emitter(e2) self.assertEqual(t.emitters, [e1, e2]) - def test_check_form_element_no_type(self): - elem = { - "name": "elemName", - "value": "elemValue", - "nodeName": "INPUT" - } - self.assertTrue(Tracker.check_form_element(elem)) - - def test_check_form_element_type_valid(self): - elem = { - "name": "elemName", - "value": "elemValue", - "nodeName": "TEXTAREA", - "type": "button" - } - self.assertTrue(Tracker.check_form_element(elem)) - - def test_check_form_element_type_invalid(self): - elem = { - "name": "elemName", - "value": "elemValue", - "nodeName": "SELECT", - "type": "invalid" - } - self.assertFalse(Tracker.check_form_element(elem)) - - def test_check_form_element_nodename_invalid(self): - elem = { - "name": "elemName", - "value": "elemValue", - "nodeName": "invalid" - } - self.assertFalse(Tracker.check_form_element(elem)) - - def test_check_form_element_no_nodename(self): - elem = { - "name": "elemName", - "value": "elemValue" - } - self.assertFalse(Tracker.check_form_element(elem)) - - def test_check_form_element_no_value(self): - elem = { - "name": "elemName", - "nodeName": "INPUT" - } - self.assertFalse(Tracker.check_form_element(elem)) - - def test_check_form_element_no_name(self): - elem = { - "value": "elemValue", - "nodeName": "INPUT" - } - self.assertFalse(Tracker.check_form_element(elem)) - ### # test track and complete payload methods ### - def test_track(self): + def test_track(self) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e1 = mokEmitter() e2 = mokEmitter() e3 = mokEmitter() - t = Tracker([e1,e2,e3]) + t = Tracker([e1, e2, e3]) p = Payload({"test": "track"}) t.track(p) @@ -286,961 +230,988 @@ def test_track(self): @freeze_time("2021-04-19 00:00:01") # unix: 1618790401000 @mock.patch('snowplow_tracker.Tracker.track') @mock.patch('snowplow_tracker.Tracker.get_uuid') - def test_complete_payload(self, mok_uuid, mok_track): + def test_complete_payload(self, mok_uuid: Any, mok_track: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_uuid.side_effect = mocked_uuid - mok_track.side_effect = mocked_track - - t = Tracker(e) - p = Payload() - t.complete_payload(p, None, None, None) - - self.assertEqual(mok_track.call_count, 1) - trackArgsTuple = mok_track.call_args_list[0][0] - self.assertEqual(len(trackArgsTuple), 1) - passed_nv_pairs = trackArgsTuple[0].nv_pairs - - expected = { - "eid": _TEST_UUID, - "dtm": 1618790401000, - "tv": TRACKER_VERSION, - "p": "pc" - } - self.assertDictEqual(passed_nv_pairs, expected) + mok_uuid.side_effect = mocked_uuid + mok_track.side_effect = mocked_track + t = Tracker(e) + p = Payload() + t.complete_payload(p, None, None, None) + + self.assertEqual(mok_track.call_count, 1) + trackArgsTuple = mok_track.call_args_list[0][0] + self.assertEqual(len(trackArgsTuple), 1) + passed_nv_pairs = trackArgsTuple[0].nv_pairs + + expected = { + "eid": _TEST_UUID, + "dtm": 1618790401000, + "tv": TRACKER_VERSION, + "p": "pc" + } + self.assertDictEqual(passed_nv_pairs, expected) @freeze_time("2021-04-19 00:00:01") # unix: 1618790401000 @mock.patch('snowplow_tracker.Tracker.track') @mock.patch('snowplow_tracker.Tracker.get_uuid') - def test_complete_payload_tstamp_int(self, mok_uuid, mok_track): + def test_complete_payload_tstamp_int(self, mok_uuid: Any, mok_track: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_uuid.side_effect = mocked_uuid - mok_track.side_effect = mocked_track - - t = Tracker(e) - p = Payload() - time_in_millis = 100010001000 - t.complete_payload(p, None, time_in_millis, None) - - self.assertEqual(mok_track.call_count, 1) - trackArgsTuple = mok_track.call_args_list[0][0] - self.assertEqual(len(trackArgsTuple), 1) - passed_nv_pairs = trackArgsTuple[0].nv_pairs - - expected = { - "eid": _TEST_UUID, - "dtm": 1618790401000, - "ttm": time_in_millis, - "tv": TRACKER_VERSION, - "p": "pc" - } - self.assertDictEqual(passed_nv_pairs, expected) + mok_uuid.side_effect = mocked_uuid + mok_track.side_effect = mocked_track + + t = Tracker(e) + p = Payload() + time_in_millis = 100010001000 + t.complete_payload(p, None, time_in_millis, None) + + self.assertEqual(mok_track.call_count, 1) + trackArgsTuple = mok_track.call_args_list[0][0] + self.assertEqual(len(trackArgsTuple), 1) + passed_nv_pairs = trackArgsTuple[0].nv_pairs + + expected = { + "eid": _TEST_UUID, + "dtm": 1618790401000, + "ttm": time_in_millis, + "tv": TRACKER_VERSION, + "p": "pc" + } + self.assertDictEqual(passed_nv_pairs, expected) @freeze_time("2021-04-19 00:00:01") # unix: 1618790401000 @mock.patch('snowplow_tracker.Tracker.track') @mock.patch('snowplow_tracker.Tracker.get_uuid') - def test_complete_payload_tstamp_dtm(self, mok_uuid, mok_track): + def test_complete_payload_tstamp_dtm(self, mok_uuid: Any, mok_track: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_uuid.side_effect = mocked_uuid - mok_track.side_effect = mocked_track - - t = Tracker(e) - p = Payload() - time_in_millis = 100010001000 - t.complete_payload(p, None, time_in_millis, None) - - self.assertEqual(mok_track.call_count, 1) - trackArgsTuple = mok_track.call_args_list[0][0] - self.assertEqual(len(trackArgsTuple), 1) - passed_nv_pairs = trackArgsTuple[0].nv_pairs - - expected = { - "eid": _TEST_UUID, - "dtm": 1618790401000, - "ttm": time_in_millis, - "tv": TRACKER_VERSION, - "p": "pc" - } - self.assertDictEqual(passed_nv_pairs, expected) + mok_uuid.side_effect = mocked_uuid + mok_track.side_effect = mocked_track + + t = Tracker(e) + p = Payload() + time_in_millis = 100010001000 + t.complete_payload(p, None, time_in_millis, None) + + self.assertEqual(mok_track.call_count, 1) + trackArgsTuple = mok_track.call_args_list[0][0] + self.assertEqual(len(trackArgsTuple), 1) + passed_nv_pairs = trackArgsTuple[0].nv_pairs + + expected = { + "eid": _TEST_UUID, + "dtm": 1618790401000, + "ttm": time_in_millis, + "tv": TRACKER_VERSION, + "p": "pc" + } + self.assertDictEqual(passed_nv_pairs, expected) @freeze_time("2021-04-19 00:00:01") # unix: 1618790401000 @mock.patch('snowplow_tracker.Tracker.track') @mock.patch('snowplow_tracker.Tracker.get_uuid') - def test_complete_payload_tstamp_ttm(self, mok_uuid, mok_track): + def test_complete_payload_tstamp_ttm(self, mok_uuid: Any, mok_track: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_uuid.side_effect = mocked_uuid - mok_track.side_effect = mocked_track - - t = Tracker(e) - p = Payload() - time_in_millis = 100010001000 - t.complete_payload(p, None, time_in_millis, None) - - self.assertEqual(mok_track.call_count, 1) - trackArgsTuple = mok_track.call_args_list[0][0] - self.assertEqual(len(trackArgsTuple), 1) - passed_nv_pairs = trackArgsTuple[0].nv_pairs - - expected = { - "eid": _TEST_UUID, - "dtm": 1618790401000, - "ttm": time_in_millis, - "tv": TRACKER_VERSION, - "p": "pc" - } - self.assertDictEqual(passed_nv_pairs, expected) + mok_uuid.side_effect = mocked_uuid + mok_track.side_effect = mocked_track + + t = Tracker(e) + p = Payload() + time_in_millis = 100010001000 + t.complete_payload(p, None, time_in_millis, None) + + self.assertEqual(mok_track.call_count, 1) + trackArgsTuple = mok_track.call_args_list[0][0] + self.assertEqual(len(trackArgsTuple), 1) + passed_nv_pairs = trackArgsTuple[0].nv_pairs + + expected = { + "eid": _TEST_UUID, + "dtm": 1618790401000, + "ttm": time_in_millis, + "tv": TRACKER_VERSION, + "p": "pc" + } + self.assertDictEqual(passed_nv_pairs, expected) @freeze_time("2021-04-19 00:00:01") # unix: 1618790401000 @mock.patch('snowplow_tracker.Tracker.track') @mock.patch('snowplow_tracker.Tracker.get_uuid') - def test_complete_payload_co(self, mok_uuid, mok_track): + def test_complete_payload_co(self, mok_uuid: Any, mok_track: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_uuid.side_effect = mocked_uuid - mok_track.side_effect = mocked_track - - t = Tracker(e, encode_base64=False) - p = Payload() - - geo_ctx = SelfDescribingJson(geoSchema, geoData) - mov_ctx = SelfDescribingJson(movSchema, movData) - ctx_array = [geo_ctx, mov_ctx] - t.complete_payload(p, ctx_array, None, None) - - self.assertEqual(mok_track.call_count, 1) - trackArgsTuple = mok_track.call_args_list[0][0] - self.assertEqual(len(trackArgsTuple), 1) - passed_nv_pairs = trackArgsTuple[0].nv_pairs - - expected_co = { - "schema": CONTEXT_SCHEMA, - "data": [ - { - "schema": geoSchema, - "data": geoData - }, - { - "schema": movSchema, - "data": movData - } - ] - } - self.assertIn("co", passed_nv_pairs) - self.assertDictEqual(json.loads(passed_nv_pairs["co"]), expected_co) + mok_uuid.side_effect = mocked_uuid + mok_track.side_effect = mocked_track + + t = Tracker(e, encode_base64=False) + p = Payload() + + geo_ctx = SelfDescribingJson(geoSchema, geoData) + mov_ctx = SelfDescribingJson(movSchema, movData) + ctx_array = [geo_ctx, mov_ctx] + t.complete_payload(p, ctx_array, None, None) + + self.assertEqual(mok_track.call_count, 1) + trackArgsTuple = mok_track.call_args_list[0][0] + self.assertEqual(len(trackArgsTuple), 1) + passed_nv_pairs = trackArgsTuple[0].nv_pairs + + expected_co = { + "schema": CONTEXT_SCHEMA, + "data": [ + { + "schema": geoSchema, + "data": geoData + }, + { + "schema": movSchema, + "data": movData + } + ] + } + self.assertIn("co", passed_nv_pairs) + self.assertDictEqual(json.loads(passed_nv_pairs["co"]), expected_co) @freeze_time("2021-04-19 00:00:01") # unix: 1618790401000 @mock.patch('snowplow_tracker.Tracker.track') @mock.patch('snowplow_tracker.Tracker.get_uuid') - def test_complete_payload_cx(self, mok_uuid, mok_track): + def test_complete_payload_cx(self, mok_uuid: Any, mok_track: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_uuid.side_effect = mocked_uuid - mok_track.side_effect = mocked_track + mok_uuid.side_effect = mocked_uuid + mok_track.side_effect = mocked_track - t = Tracker(e, encode_base64=True) - p = Payload() + t = Tracker(e, encode_base64=True) + p = Payload() - geo_ctx = SelfDescribingJson(geoSchema, geoData) - mov_ctx = SelfDescribingJson(movSchema, movData) - ctx_array = [geo_ctx, mov_ctx] - t.complete_payload(p, ctx_array, None, None) + geo_ctx = SelfDescribingJson(geoSchema, geoData) + mov_ctx = SelfDescribingJson(movSchema, movData) + ctx_array = [geo_ctx, mov_ctx] + t.complete_payload(p, ctx_array, None, None) - self.assertEqual(mok_track.call_count, 1) - trackArgsTuple = mok_track.call_args_list[0][0] - self.assertEqual(len(trackArgsTuple), 1) - passed_nv_pairs = trackArgsTuple[0].nv_pairs + self.assertEqual(mok_track.call_count, 1) + trackArgsTuple = mok_track.call_args_list[0][0] + self.assertEqual(len(trackArgsTuple), 1) + passed_nv_pairs = trackArgsTuple[0].nv_pairs - self.assertIn("cx", passed_nv_pairs) + self.assertIn("cx", passed_nv_pairs) @freeze_time("2021-04-19 00:00:01") # unix: 1618790401000 @mock.patch('snowplow_tracker.Tracker.track') @mock.patch('snowplow_tracker.Tracker.get_uuid') - def test_complete_payload_event_subject(self, mok_uuid, mok_track): + def test_complete_payload_event_subject(self, mok_uuid: Any, mok_track: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_uuid.side_effect = mocked_uuid - mok_track.side_effect = mocked_track - - t = Tracker(e) - p = Payload() - evSubject = Subject().set_lang('EN').set_user_id("tester") - t.complete_payload(p, None, None, evSubject) - - self.assertEqual(mok_track.call_count, 1) - trackArgsTuple = mok_track.call_args_list[0][0] - self.assertEqual(len(trackArgsTuple), 1) - passed_nv_pairs = trackArgsTuple[0].nv_pairs - - expected = { - "eid": _TEST_UUID, - "dtm": 1618790401000, - "tv": TRACKER_VERSION, - "p": "pc", - "lang": "EN", - "uid": "tester" - } - self.assertDictEqual(passed_nv_pairs, expected) + mok_uuid.side_effect = mocked_uuid + mok_track.side_effect = mocked_track + + t = Tracker(e) + p = Payload() + evSubject = Subject().set_lang('EN').set_user_id("tester") + t.complete_payload(p, None, None, evSubject) + + self.assertEqual(mok_track.call_count, 1) + trackArgsTuple = mok_track.call_args_list[0][0] + self.assertEqual(len(trackArgsTuple), 1) + passed_nv_pairs = trackArgsTuple[0].nv_pairs + + expected = { + "eid": _TEST_UUID, + "dtm": 1618790401000, + "tv": TRACKER_VERSION, + "p": "pc", + "lang": "EN", + "uid": "tester" + } + self.assertDictEqual(passed_nv_pairs, expected) ### # test track_x methods ### @mock.patch('snowplow_tracker.Tracker.complete_payload') - def test_track_unstruct_event(self, mok_complete_payload): + def test_track_unstruct_event(self, mok_complete_payload: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_complete_payload.side_effect = mocked_complete_payload - - t = Tracker(e, encode_base64=False) - evJson = SelfDescribingJson("test.sde.schema", {"n":"v"}) - t.track_unstruct_event(evJson) - self.assertEqual(mok_complete_payload.call_count, 1) - completeArgsList = mok_complete_payload.call_args_list[0][0] - self.assertEqual(len(completeArgsList), 4) - - # payload - actualPayloadArg = completeArgsList[0] - actualPairs = actualPayloadArg.nv_pairs - actualUePr = json.loads(actualPairs["ue_pr"]) - # context - actualContextArg = completeArgsList[1] - # tstamp - actualTstampArg = completeArgsList[2] - - expectedUePr = { - "data": { - "data": {"n": "v"}, - "schema": "test.sde.schema" - }, - "schema": UNSTRUCT_SCHEMA - } + mok_complete_payload.side_effect = mocked_complete_payload + + t = Tracker(e, encode_base64=False) + evJson = SelfDescribingJson("test.sde.schema", {"n": "v"}) + t.track_unstruct_event(evJson) + self.assertEqual(mok_complete_payload.call_count, 1) + completeArgsList = mok_complete_payload.call_args_list[0][0] + self.assertEqual(len(completeArgsList), 4) + + # payload + actualPayloadArg = completeArgsList[0] + actualPairs = actualPayloadArg.nv_pairs + actualUePr = json.loads(actualPairs["ue_pr"]) + # context + actualContextArg = completeArgsList[1] + # tstamp + actualTstampArg = completeArgsList[2] + + expectedUePr = { + "data": { + "data": {"n": "v"}, + "schema": "test.sde.schema" + }, + "schema": UNSTRUCT_SCHEMA + } - self.assertDictEqual(actualUePr, expectedUePr) - self.assertEqual(actualPairs["e"], "ue") - self.assertTrue(actualContextArg is None) - self.assertTrue(actualTstampArg is None) + self.assertDictEqual(actualUePr, expectedUePr) + self.assertEqual(actualPairs["e"], "ue") + self.assertTrue(actualContextArg is None) + self.assertTrue(actualTstampArg is None) @mock.patch('snowplow_tracker.Tracker.complete_payload') - def test_track_unstruct_event_all_args(self, mok_complete_payload): + def test_track_unstruct_event_all_args(self, mok_complete_payload: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_complete_payload.side_effect = mocked_complete_payload - - t = Tracker(e, encode_base64=False) - evJson = SelfDescribingJson("test.schema", {"n":"v"}) - ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) - evContext = [ctx] - evTstamp = 1399021242030 - t.track_unstruct_event(evJson, evContext, evTstamp) - self.assertEqual(mok_complete_payload.call_count, 1) - completeArgsList = mok_complete_payload.call_args_list[0][0] - self.assertEqual(len(completeArgsList), 4) - - # payload - actualPayloadArg = completeArgsList[0] - actualPairs = actualPayloadArg.nv_pairs - actualUePr = json.loads(actualPairs["ue_pr"]) - # context - actualContextArg = completeArgsList[1] - # tstamp - actualTstampArg = completeArgsList[2] - - expectedUePr = { - "data": { - "data": {"n": "v"}, - "schema": "test.schema" - }, - "schema": UNSTRUCT_SCHEMA - } + mok_complete_payload.side_effect = mocked_complete_payload + + t = Tracker(e, encode_base64=False) + evJson = SelfDescribingJson("test.schema", {"n": "v"}) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evContext = [ctx] + evTstamp = 1399021242030 + t.track_unstruct_event(evJson, evContext, evTstamp) + self.assertEqual(mok_complete_payload.call_count, 1) + completeArgsList = mok_complete_payload.call_args_list[0][0] + self.assertEqual(len(completeArgsList), 4) + + # payload + actualPayloadArg = completeArgsList[0] + actualPairs = actualPayloadArg.nv_pairs + actualUePr = json.loads(actualPairs["ue_pr"]) + # context + actualContextArg = completeArgsList[1] + # tstamp + actualTstampArg = completeArgsList[2] + + expectedUePr = { + "data": { + "data": {"n": "v"}, + "schema": "test.schema" + }, + "schema": UNSTRUCT_SCHEMA + } - self.assertDictEqual(actualUePr, expectedUePr) - self.assertEqual(actualPairs["e"], "ue") - self.assertIs(actualContextArg[0], ctx) - self.assertEqual(actualTstampArg, evTstamp) + self.assertDictEqual(actualUePr, expectedUePr) + self.assertEqual(actualPairs["e"], "ue") + self.assertIs(actualContextArg[0], ctx) + self.assertEqual(actualTstampArg, evTstamp) @mock.patch('snowplow_tracker.Tracker.complete_payload') - def test_track_unstruct_event_encode(self, mok_complete_payload): + def test_track_unstruct_event_encode(self, mok_complete_payload: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_complete_payload.side_effect = mocked_complete_payload + mok_complete_payload.side_effect = mocked_complete_payload - t = Tracker(e, encode_base64=True) - evJson = SelfDescribingJson("test.sde.schema", {"n":"v"}) - t.track_unstruct_event(evJson) - self.assertEqual(mok_complete_payload.call_count, 1) - completeArgsList = mok_complete_payload.call_args_list[0][0] - self.assertEqual(len(completeArgsList), 4) + t = Tracker(e, encode_base64=True) + evJson = SelfDescribingJson("test.sde.schema", {"n": "v"}) + t.track_unstruct_event(evJson) + self.assertEqual(mok_complete_payload.call_count, 1) + completeArgsList = mok_complete_payload.call_args_list[0][0] + self.assertEqual(len(completeArgsList), 4) - actualPayloadArg = completeArgsList[0] - actualPairs = actualPayloadArg.nv_pairs - self.assertTrue("ue_px" in actualPairs.keys()) + actualPayloadArg = completeArgsList[0] + actualPairs = actualPayloadArg.nv_pairs + self.assertTrue("ue_px" in actualPairs.keys()) @mock.patch('snowplow_tracker.Tracker.complete_payload') - def test_track_struct_event(self, mok_complete_payload): + def test_track_struct_event(self, mok_complete_payload: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_complete_payload.side_effect = mocked_complete_payload - - t = Tracker(e) - ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) - evTstamp = 1399021242030 - t.track_struct_event("Mixes","Play","Test","TestProp",value=3.14,context=[ctx],tstamp=evTstamp) - self.assertEqual(mok_complete_payload.call_count, 1) - completeArgsList = mok_complete_payload.call_args_list[0][0] - self.assertEqual(len(completeArgsList), 4) - - actualPayloadArg = completeArgsList[0] - actualContextArg = completeArgsList[1] - actualTstampArg = completeArgsList[2] - actualPairs = actualPayloadArg.nv_pairs - - expectedPairs = { - "e": "se", - "se_ca": "Mixes", - "se_ac": "Play", - "se_la": "Test", - "se_pr": "TestProp", - "se_va": 3.14 - } - self.assertDictEqual(actualPairs, expectedPairs) - self.assertIs(actualContextArg[0], ctx) - self.assertEqual(actualTstampArg, evTstamp) + mok_complete_payload.side_effect = mocked_complete_payload + + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 + t.track_struct_event("Mixes", "Play", "Test", "TestProp", value=3.14, context=[ctx], tstamp=evTstamp) + self.assertEqual(mok_complete_payload.call_count, 1) + completeArgsList = mok_complete_payload.call_args_list[0][0] + self.assertEqual(len(completeArgsList), 4) + + actualPayloadArg = completeArgsList[0] + actualContextArg = completeArgsList[1] + actualTstampArg = completeArgsList[2] + actualPairs = actualPayloadArg.nv_pairs + + expectedPairs = { + "e": "se", + "se_ca": "Mixes", + "se_ac": "Play", + "se_la": "Test", + "se_pr": "TestProp", + "se_va": 3.14 + } + self.assertDictEqual(actualPairs, expectedPairs) + self.assertIs(actualContextArg[0], ctx) + self.assertEqual(actualTstampArg, evTstamp) @mock.patch('snowplow_tracker.Tracker.complete_payload') - def test_track_page_view(self, mok_complete_payload): + def test_track_page_view(self, mok_complete_payload: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_complete_payload.side_effect = mocked_complete_payload - - t = Tracker(e) - ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) - evTstamp = 1399021242030 - t.track_page_view("example.com", "Example", "docs.snowplowanalytics.com", context=[ctx], tstamp=evTstamp) - self.assertEqual(mok_complete_payload.call_count, 1) - completeArgsList = mok_complete_payload.call_args_list[0][0] - self.assertEqual(len(completeArgsList), 4) - - actualPayloadArg = completeArgsList[0] - actualContextArg = completeArgsList[1] - actualTstampArg = completeArgsList[2] - actualPairs = actualPayloadArg.nv_pairs - - expectedPairs = { - "e": "pv", - "url": "example.com", - "page": "Example", - "refr": "docs.snowplowanalytics.com" - } - self.assertDictEqual(actualPairs, expectedPairs) - self.assertIs(actualContextArg[0], ctx) - self.assertEqual(actualTstampArg, evTstamp) + mok_complete_payload.side_effect = mocked_complete_payload + + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 + t.track_page_view("example.com", "Example", "docs.snowplowanalytics.com", context=[ctx], tstamp=evTstamp) + self.assertEqual(mok_complete_payload.call_count, 1) + completeArgsList = mok_complete_payload.call_args_list[0][0] + self.assertEqual(len(completeArgsList), 4) + + actualPayloadArg = completeArgsList[0] + actualContextArg = completeArgsList[1] + actualTstampArg = completeArgsList[2] + actualPairs = actualPayloadArg.nv_pairs + + expectedPairs = { + "e": "pv", + "url": "example.com", + "page": "Example", + "refr": "docs.snowplowanalytics.com" + } + self.assertDictEqual(actualPairs, expectedPairs) + self.assertIs(actualContextArg[0], ctx) + self.assertEqual(actualTstampArg, evTstamp) @mock.patch('snowplow_tracker.Tracker.complete_payload') - def test_track_page_ping(self, mok_complete_payload): + def test_track_page_ping(self, mok_complete_payload: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_complete_payload.side_effect = mocked_complete_payload - - t = Tracker(e) - ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) - evTstamp = 1399021242030 - t.track_page_ping("example.com", "Example", "docs.snowplowanalytics.com", 0, 1, 2, 3, context=[ctx], tstamp=evTstamp) - self.assertEqual(mok_complete_payload.call_count, 1) - completeArgsList = mok_complete_payload.call_args_list[0][0] - self.assertEqual(len(completeArgsList), 4) - - actualPayloadArg = completeArgsList[0] - actualContextArg = completeArgsList[1] - actualTstampArg = completeArgsList[2] - actualPairs = actualPayloadArg.nv_pairs - - expectedPairs = { - "e": "pp", - "url": "example.com", - "page": "Example", - "refr": "docs.snowplowanalytics.com", - "pp_mix": 0, - "pp_max": 1, - "pp_miy": 2, - "pp_may": 3 - } - self.assertDictEqual(actualPairs, expectedPairs) - self.assertIs(actualContextArg[0], ctx) - self.assertEqual(actualTstampArg, evTstamp) + mok_complete_payload.side_effect = mocked_complete_payload + + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 + t.track_page_ping("example.com", "Example", "docs.snowplowanalytics.com", 0, 1, 2, 3, context=[ctx], tstamp=evTstamp) + self.assertEqual(mok_complete_payload.call_count, 1) + completeArgsList = mok_complete_payload.call_args_list[0][0] + self.assertEqual(len(completeArgsList), 4) + + actualPayloadArg = completeArgsList[0] + actualContextArg = completeArgsList[1] + actualTstampArg = completeArgsList[2] + actualPairs = actualPayloadArg.nv_pairs + + expectedPairs = { + "e": "pp", + "url": "example.com", + "page": "Example", + "refr": "docs.snowplowanalytics.com", + "pp_mix": 0, + "pp_max": 1, + "pp_miy": 2, + "pp_may": 3 + } + self.assertDictEqual(actualPairs, expectedPairs) + self.assertIs(actualContextArg[0], ctx) + self.assertEqual(actualTstampArg, evTstamp) @mock.patch('snowplow_tracker.Tracker.complete_payload') - def test_track_ecommerce_transaction_item(self, mok_complete_payload): + def test_track_ecommerce_transaction_item(self, mok_complete_payload: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_complete_payload.side_effect = mocked_complete_payload - - t = Tracker(e) - ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) - evTstamp = 1399021242030 - t.track_ecommerce_transaction_item("1234", "sku1234", 3.14, 1, "itemName", "itemCategory", "itemCurrency", context=[ctx], tstamp=evTstamp) - self.assertEqual(mok_complete_payload.call_count, 1) - completeArgsList = mok_complete_payload.call_args_list[0][0] - self.assertEqual(len(completeArgsList), 4) - - actualPayloadArg = completeArgsList[0] - actualContextArg = completeArgsList[1] - actualTstampArg = completeArgsList[2] - actualPairs = actualPayloadArg.nv_pairs - - expectedPairs = { - "e": "ti", - "ti_id": "1234", - "ti_sk": "sku1234", - "ti_nm": "itemName", - "ti_ca": "itemCategory", - "ti_pr": 3.14, - "ti_qu": 1, - "ti_cu": "itemCurrency" - } - self.assertDictEqual(actualPairs, expectedPairs) - self.assertIs(actualContextArg[0], ctx) - self.assertEqual(actualTstampArg, evTstamp) + mok_complete_payload.side_effect = mocked_complete_payload + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 + t.track_ecommerce_transaction_item("1234", "sku1234", 3.14, 1, "itemName", "itemCategory", "itemCurrency", context=[ctx], tstamp=evTstamp) + self.assertEqual(mok_complete_payload.call_count, 1) + completeArgsList = mok_complete_payload.call_args_list[0][0] + self.assertEqual(len(completeArgsList), 4) + + actualPayloadArg = completeArgsList[0] + actualContextArg = completeArgsList[1] + actualTstampArg = completeArgsList[2] + actualPairs = actualPayloadArg.nv_pairs + + expectedPairs = { + "e": "ti", + "ti_id": "1234", + "ti_sk": "sku1234", + "ti_nm": "itemName", + "ti_ca": "itemCategory", + "ti_pr": 3.14, + "ti_qu": 1, + "ti_cu": "itemCurrency" + } + self.assertDictEqual(actualPairs, expectedPairs) + self.assertIs(actualContextArg[0], ctx) + self.assertEqual(actualTstampArg, evTstamp) @mock.patch('snowplow_tracker.Tracker.complete_payload') - def test_track_ecommerce_transaction_no_items(self, mok_complete_payload): + def test_track_ecommerce_transaction_no_items(self, mok_complete_payload: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_complete_payload.side_effect = mocked_complete_payload - - t = Tracker(e) - ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) - evTstamp = 1399021242030 - t.track_ecommerce_transaction("1234", 10, "transAffiliation", 2.5, 1.5, "transCity", "transState", "transCountry", "transCurrency", context=[ctx], tstamp=evTstamp) - self.assertEqual(mok_complete_payload.call_count, 1) - completeArgsList = mok_complete_payload.call_args_list[0][0] - self.assertEqual(len(completeArgsList), 4) - actualPayloadArg = completeArgsList[0] - actualContextArg = completeArgsList[1] - actualTstampArg = completeArgsList[2] - actualPairs = actualPayloadArg.nv_pairs - - expectedPairs = { - "e": "tr", - "tr_id": "1234", - "tr_tt": 10, - "tr_af": "transAffiliation", - "tr_tx": 2.5, - "tr_sh": 1.5, - "tr_ci": "transCity", - "tr_st": "transState", - "tr_co": "transCountry", - "tr_cu": "transCurrency" - } - self.assertDictEqual(actualPairs, expectedPairs) - self.assertIs(actualContextArg[0], ctx) - self.assertEqual(actualTstampArg, evTstamp) + mok_complete_payload.side_effect = mocked_complete_payload + + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 + t.track_ecommerce_transaction("1234", 10, "transAffiliation", 2.5, 1.5, "transCity", "transState", "transCountry", "transCurrency", context=[ctx], tstamp=evTstamp) + self.assertEqual(mok_complete_payload.call_count, 1) + completeArgsList = mok_complete_payload.call_args_list[0][0] + self.assertEqual(len(completeArgsList), 4) + actualPayloadArg = completeArgsList[0] + actualContextArg = completeArgsList[1] + actualTstampArg = completeArgsList[2] + actualPairs = actualPayloadArg.nv_pairs + + expectedPairs = { + "e": "tr", + "tr_id": "1234", + "tr_tt": 10, + "tr_af": "transAffiliation", + "tr_tx": 2.5, + "tr_sh": 1.5, + "tr_ci": "transCity", + "tr_st": "transState", + "tr_co": "transCountry", + "tr_cu": "transCurrency" + } + self.assertDictEqual(actualPairs, expectedPairs) + self.assertIs(actualContextArg[0], ctx) + self.assertEqual(actualTstampArg, evTstamp) @mock.patch('snowplow_tracker.Tracker.track_ecommerce_transaction_item') @mock.patch('snowplow_tracker.Tracker.complete_payload') - def test_track_ecommerce_transaction_with_items(self, mok_complete_payload, mok_track_trans_item): + def test_track_ecommerce_transaction_with_items(self, mok_complete_payload: Any, mok_track_trans_item: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_complete_payload.side_effect = mocked_complete_payload - mok_track_trans_item.side_effect = mocked_track_trans_item - - t = Tracker(e) - ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) - evTstamp = 1399021242030 - transItems = [ - {"sku":"sku1234", "quantity": 3, "price": 3.14}, - {"sku":"sku5678", "quantity": 1, "price": 2.72} - ] - t.track_ecommerce_transaction("1234", 10, "transAffiliation", 2.5, 1.5, "transCity", "transState", "transCountry", "transCurrency", items=transItems, context=[ctx], tstamp=evTstamp) - - # Transaction - callCompleteArgsList = mok_complete_payload.call_args_list - firstCallArgsList = callCompleteArgsList[0][0] - self.assertEqual(len(firstCallArgsList), 4) - actualPayloadArg =firstCallArgsList[0] - actualContextArg = firstCallArgsList[1] - actualTstampArg = firstCallArgsList[2] - actualPairs = actualPayloadArg.nv_pairs - - expectedTransPairs = { - "e": "tr", - "tr_id": "1234", - "tr_tt": 10, - "tr_af": "transAffiliation", - "tr_tx": 2.5, - "tr_sh": 1.5, - "tr_ci": "transCity", - "tr_st": "transState", - "tr_co": "transCountry", - "tr_cu": "transCurrency" - } - self.assertDictEqual(actualPairs, expectedTransPairs) - self.assertIs(actualContextArg[0], ctx) - self.assertEqual(actualTstampArg, evTstamp) - - # Items - calls_to_track_trans_item = mok_track_trans_item.call_count - self.assertEqual(calls_to_track_trans_item, 2) - callTrackItemsArgsList = mok_track_trans_item.call_args_list - # 1st item - firstItemCallArgs = callTrackItemsArgsList[0][0] - self.assertEqual((), firstItemCallArgs) - firstItemCallKwargs = callTrackItemsArgsList[0][1] - - expectedFirstItemPairs = { - 'tstamp': evTstamp, - 'order_id': '1234', - 'currency': 'transCurrency', - 'sku': 'sku1234', - 'quantity': 3, - "price": 3.14, - 'event_subject': None - } - self.assertDictEqual(firstItemCallKwargs, expectedFirstItemPairs) - # 2nd item - secItemCallArgs = callTrackItemsArgsList[1][0] - self.assertEqual((), secItemCallArgs) - secItemCallKwargs = callTrackItemsArgsList[1][1] - - expectedSecItemPairs = { - 'tstamp': evTstamp, - 'order_id': '1234', - 'currency': 'transCurrency', - 'sku': 'sku5678', - 'quantity': 1, - "price": 2.72, - 'event_subject': None - } - self.assertDictEqual(secItemCallKwargs, expectedSecItemPairs) + mok_complete_payload.side_effect = mocked_complete_payload + mok_track_trans_item.side_effect = mocked_track_trans_item + + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 + transItems = [ + {"sku": "sku1234", "quantity": 3, "price": 3.14}, + {"sku": "sku5678", "quantity": 1, "price": 2.72} + ] + t.track_ecommerce_transaction("1234", 10, "transAffiliation", 2.5, 1.5, "transCity", "transState", "transCountry", "transCurrency", items=transItems, context=[ctx], tstamp=evTstamp) + + # Transaction + callCompleteArgsList = mok_complete_payload.call_args_list + firstCallArgsList = callCompleteArgsList[0][0] + self.assertEqual(len(firstCallArgsList), 4) + actualPayloadArg = firstCallArgsList[0] + actualContextArg = firstCallArgsList[1] + actualTstampArg = firstCallArgsList[2] + actualPairs = actualPayloadArg.nv_pairs + + expectedTransPairs = { + "e": "tr", + "tr_id": "1234", + "tr_tt": 10, + "tr_af": "transAffiliation", + "tr_tx": 2.5, + "tr_sh": 1.5, + "tr_ci": "transCity", + "tr_st": "transState", + "tr_co": "transCountry", + "tr_cu": "transCurrency" + } + self.assertDictEqual(actualPairs, expectedTransPairs) + self.assertIs(actualContextArg[0], ctx) + self.assertEqual(actualTstampArg, evTstamp) + + # Items + calls_to_track_trans_item = mok_track_trans_item.call_count + self.assertEqual(calls_to_track_trans_item, 2) + callTrackItemsArgsList = mok_track_trans_item.call_args_list + # 1st item + firstItemCallArgs = callTrackItemsArgsList[0][0] + self.assertEqual((), firstItemCallArgs) + firstItemCallKwargs = callTrackItemsArgsList[0][1] + + expectedFirstItemPairs = { + 'tstamp': evTstamp, + 'order_id': '1234', + 'currency': 'transCurrency', + 'sku': 'sku1234', + 'quantity': 3, + "price": 3.14, + 'event_subject': None + } + self.assertDictEqual(firstItemCallKwargs, expectedFirstItemPairs) + # 2nd item + secItemCallArgs = callTrackItemsArgsList[1][0] + self.assertEqual((), secItemCallArgs) + secItemCallKwargs = callTrackItemsArgsList[1][1] + + expectedSecItemPairs = { + 'tstamp': evTstamp, + 'order_id': '1234', + 'currency': 'transCurrency', + 'sku': 'sku5678', + 'quantity': 1, + "price": 2.72, + 'event_subject': None + } + self.assertDictEqual(secItemCallKwargs, expectedSecItemPairs) @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') - def test_track_link_click(self, mok_track_unstruct): + def test_track_link_click(self, mok_track_unstruct: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_track_unstruct.side_effect = mocked_track_unstruct - - t = Tracker(e) - ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) - evTstamp = 1399021242030 - - t.track_link_click("example.com", "elemId", ["elemClass1", "elemClass2"], "_blank", "elemContent", context=[ctx], tstamp=evTstamp) - - expected = { - "schema": LINK_CLICK_SCHEMA, - "data": { - "targetUrl": "example.com", - "elementId": "elemId", - "elementClasses": ["elemClass1", "elemClass2"], - "elementTarget": "_blank", - "elementContent": "elemContent" - } + mok_track_unstruct.side_effect = mocked_track_unstruct + + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 + + t.track_link_click("example.com", "elemId", ["elemClass1", "elemClass2"], "_blank", "elemContent", context=[ctx], tstamp=evTstamp) + + expected = { + "schema": LINK_CLICK_SCHEMA, + "data": { + "targetUrl": "example.com", + "elementId": "elemId", + "elementClasses": ["elemClass1", "elemClass2"], + "elementTarget": "_blank", + "elementContent": "elemContent" } + } - callArgs = mok_track_unstruct.call_args_list[0][0] - self.assertEqual(len(callArgs), 4) - self.assertDictEqual(callArgs[0].to_json(), expected) - self.assertIs(callArgs[1][0], ctx) - self.assertEqual(callArgs[2], evTstamp) + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) + self.assertIs(callArgs[1][0], ctx) + self.assertEqual(callArgs[2], evTstamp) @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') - def test_track_link_click_optional_none(self, mok_track_unstruct): + def test_track_link_click_optional_none(self, mok_track_unstruct: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_track_unstruct.side_effect = mocked_track_unstruct + mok_track_unstruct.side_effect = mocked_track_unstruct - t = Tracker(e) + t = Tracker(e) - t.track_link_click("example.com") + t.track_link_click("example.com") - expected = { - "schema": LINK_CLICK_SCHEMA, - "data": { - "targetUrl": "example.com", - } + expected = { + "schema": LINK_CLICK_SCHEMA, + "data": { + "targetUrl": "example.com", } + } - callArgs = mok_track_unstruct.call_args_list[0][0] - self.assertEqual(len(callArgs), 4) - self.assertDictEqual(callArgs[0].to_json(), expected) - self.assertTrue(callArgs[1] is None) - self.assertTrue(callArgs[2] is None) + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) + self.assertTrue(callArgs[1] is None) + self.assertTrue(callArgs[2] is None) @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') - def test_track_add_to_cart(self, mok_track_unstruct): + def test_track_add_to_cart(self, mok_track_unstruct: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_track_unstruct.side_effect = mocked_track_unstruct - - t = Tracker(e) - ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) - evTstamp = 1399021242030 - - t.track_add_to_cart("sku1234", 3, "testName", "testCategory", 3.14, "testCurrency", context=[ctx], tstamp=evTstamp) - - expected = { - "schema": ADD_TO_CART_SCHEMA, - "data": { - "sku": "sku1234", - "quantity": 3, - "name": "testName", - "category": "testCategory", - "unitPrice": 3.14, - "currency": "testCurrency" - } + mok_track_unstruct.side_effect = mocked_track_unstruct + + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 + + t.track_add_to_cart("sku1234", 3, "testName", "testCategory", 3.14, "testCurrency", context=[ctx], tstamp=evTstamp) + + expected = { + "schema": ADD_TO_CART_SCHEMA, + "data": { + "sku": "sku1234", + "quantity": 3, + "name": "testName", + "category": "testCategory", + "unitPrice": 3.14, + "currency": "testCurrency" } + } - callArgs = mok_track_unstruct.call_args_list[0][0] - self.assertEqual(len(callArgs), 4) - self.assertDictEqual(callArgs[0].to_json(), expected) - self.assertIs(callArgs[1][0], ctx) - self.assertEqual(callArgs[2], evTstamp) + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) + self.assertIs(callArgs[1][0], ctx) + self.assertEqual(callArgs[2], evTstamp) @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') - def test_track_add_to_cart_optional_none(self, mok_track_unstruct): + def test_track_add_to_cart_optional_none(self, mok_track_unstruct: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_track_unstruct.side_effect = mocked_track_unstruct + mok_track_unstruct.side_effect = mocked_track_unstruct - t = Tracker(e) + t = Tracker(e) - t.track_add_to_cart("sku1234", 1) + t.track_add_to_cart("sku1234", 1) - expected = { - "schema": ADD_TO_CART_SCHEMA, - "data": { - "sku": "sku1234", - "quantity": 1 - } + expected = { + "schema": ADD_TO_CART_SCHEMA, + "data": { + "sku": "sku1234", + "quantity": 1 } + } - callArgs = mok_track_unstruct.call_args_list[0][0] - self.assertEqual(len(callArgs), 4) - self.assertDictEqual(callArgs[0].to_json(), expected) - self.assertTrue(callArgs[1] is None) - self.assertTrue(callArgs[2] is None) + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) + self.assertTrue(callArgs[1] is None) + self.assertTrue(callArgs[2] is None) @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') - def test_track_remove_from_cart(self, mok_track_unstruct): + def test_track_remove_from_cart(self, mok_track_unstruct: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_track_unstruct.side_effect = mocked_track_unstruct - - t = Tracker(e) - ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) - evTstamp = 1399021242030 - - t.track_remove_from_cart("sku1234", 3, "testName", "testCategory", 3.14, "testCurrency", context=[ctx], tstamp=evTstamp) - - expected = { - "schema": REMOVE_FROM_CART_SCHEMA, - "data": { - "sku": "sku1234", - "quantity": 3, - "name": "testName", - "category": "testCategory", - "unitPrice": 3.14, - "currency": "testCurrency" - } + mok_track_unstruct.side_effect = mocked_track_unstruct + + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 + + t.track_remove_from_cart("sku1234", 3, "testName", "testCategory", 3.14, "testCurrency", context=[ctx], tstamp=evTstamp) + + expected = { + "schema": REMOVE_FROM_CART_SCHEMA, + "data": { + "sku": "sku1234", + "quantity": 3, + "name": "testName", + "category": "testCategory", + "unitPrice": 3.14, + "currency": "testCurrency" } + } - callArgs = mok_track_unstruct.call_args_list[0][0] - self.assertEqual(len(callArgs), 4) - self.assertDictEqual(callArgs[0].to_json(), expected) - self.assertIs(callArgs[1][0], ctx) - self.assertEqual(callArgs[2], evTstamp) + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) + self.assertIs(callArgs[1][0], ctx) + self.assertEqual(callArgs[2], evTstamp) @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') - def test_track_remove_from_cart_optional_none(self, mok_track_unstruct): + def test_track_remove_from_cart_optional_none(self, mok_track_unstruct: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_track_unstruct.side_effect = mocked_track_unstruct + mok_track_unstruct.side_effect = mocked_track_unstruct - t = Tracker(e) + t = Tracker(e) - t.track_remove_from_cart("sku1234", 1) + t.track_remove_from_cart("sku1234", 1) - expected = { - "schema": REMOVE_FROM_CART_SCHEMA, - "data": { - "sku": "sku1234", - "quantity": 1 - } + expected = { + "schema": REMOVE_FROM_CART_SCHEMA, + "data": { + "sku": "sku1234", + "quantity": 1 } + } - callArgs = mok_track_unstruct.call_args_list[0][0] - self.assertEqual(len(callArgs), 4) - self.assertDictEqual(callArgs[0].to_json(), expected) - self.assertTrue(callArgs[1] is None) - self.assertTrue(callArgs[2] is None) + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) + self.assertTrue(callArgs[1] is None) + self.assertTrue(callArgs[2] is None) @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') - def test_track_form_change(self, mok_track_unstruct): + def test_track_form_change(self, mok_track_unstruct: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_track_unstruct.side_effect = mocked_track_unstruct - - t = Tracker(e) - ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) - evTstamp = 1399021242030 - - t.track_form_change("testFormId", "testElemId", "INPUT", "testValue", "text", ["testClass1", "testClass2"], context=[ctx], tstamp=evTstamp) - - expected = { - "schema": FORM_CHANGE_SCHEMA, - "data": { - "formId": "testFormId", - "elementId": "testElemId", - "nodeName": "INPUT", - "value": "testValue", - "type": "text", - "elementClasses": ["testClass1", "testClass2"] - } + mok_track_unstruct.side_effect = mocked_track_unstruct + + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 + + t.track_form_change("testFormId", "testElemId", "INPUT", "testValue", "text", ["testClass1", "testClass2"], context=[ctx], tstamp=evTstamp) + + expected = { + "schema": FORM_CHANGE_SCHEMA, + "data": { + "formId": "testFormId", + "elementId": "testElemId", + "nodeName": "INPUT", + "value": "testValue", + "type": "text", + "elementClasses": ["testClass1", "testClass2"] } + } - callArgs = mok_track_unstruct.call_args_list[0][0] - self.assertEqual(len(callArgs), 4) - self.assertDictEqual(callArgs[0].to_json(), expected) - self.assertIs(callArgs[1][0], ctx) - self.assertEqual(callArgs[2], evTstamp) + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) + self.assertIs(callArgs[1][0], ctx) + self.assertEqual(callArgs[2], evTstamp) @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') - def test_track_form_change_optional_none(self, mok_track_unstruct): + def test_track_form_change_optional_none(self, mok_track_unstruct: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_track_unstruct.side_effect = mocked_track_unstruct - - t = Tracker(e) - t.track_form_change("testFormId", "testElemId", "INPUT", "testValue") - - expected = { - "schema": FORM_CHANGE_SCHEMA, - "data": { - "formId": "testFormId", - "elementId": "testElemId", - "nodeName": "INPUT", - "value": "testValue", - } + mok_track_unstruct.side_effect = mocked_track_unstruct + + t = Tracker(e) + t.track_form_change("testFormId", "testElemId", "INPUT", "testValue") + + expected = { + "schema": FORM_CHANGE_SCHEMA, + "data": { + "formId": "testFormId", + "elementId": "testElemId", + "nodeName": "INPUT", + "value": "testValue", } + } - callArgs = mok_track_unstruct.call_args_list[0][0] - self.assertEqual(len(callArgs), 4) - self.assertDictEqual(callArgs[0].to_json(), expected) - self.assertTrue(callArgs[1] is None) - self.assertTrue(callArgs[2] is None) + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) + self.assertTrue(callArgs[1] is None) + self.assertTrue(callArgs[2] is None) @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') - def test_track_form_submit(self, mok_track_unstruct): + def test_track_form_submit(self, mok_track_unstruct: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_track_unstruct.side_effect = mocked_track_unstruct + mok_track_unstruct.side_effect = mocked_track_unstruct - t = Tracker(e) - ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) - evTstamp = 1399021242030 - elems = [ - { - "name": "user_email", - "value": "fake@email.fake", - "nodeName": "INPUT", - "type": "email" - } - ] + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 + elems = [ + { + "name": "user_email", + "value": "fake@email.fake", + "nodeName": "INPUT", + "type": "email" + } + ] - t.track_form_submit("testFormId", ["testClass1", "testClass2"], elems, context=[ctx], tstamp=evTstamp) + t.track_form_submit("testFormId", ["testClass1", "testClass2"], elems, context=[ctx], tstamp=evTstamp) - expected = { - "schema": FORM_SUBMIT_SCHEMA, - "data": { - "formId": "testFormId", - "formClasses": ["testClass1", "testClass2"], - "elements": elems - } + expected = { + "schema": FORM_SUBMIT_SCHEMA, + "data": { + "formId": "testFormId", + "formClasses": ["testClass1", "testClass2"], + "elements": elems } + } - callArgs = mok_track_unstruct.call_args_list[0][0] - self.assertEqual(len(callArgs), 4) - self.assertDictEqual(callArgs[0].to_json(), expected) - self.assertIs(callArgs[1][0], ctx) - self.assertEqual(callArgs[2], evTstamp) + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) + self.assertIs(callArgs[1][0], ctx) + self.assertEqual(callArgs[2], evTstamp) @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') - def test_track_form_submit_optional_none(self, mok_track_unstruct): + def test_track_form_submit_invalid_element_type(self, mok_track_unstruct: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_track_unstruct.side_effect = mocked_track_unstruct - - t = Tracker(e) - t.track_form_submit("testFormId") + mok_track_unstruct.side_effect = mocked_track_unstruct - expected = { - "schema": FORM_SUBMIT_SCHEMA, - "data": { - "formId": "testFormId" - } + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 + elems = [ + { + "name": "user_email", + "value": "fake@email.fake", + "nodeName": "INPUT", + "type": "invalid" } + ] - callArgs = mok_track_unstruct.call_args_list[0][0] - self.assertEqual(len(callArgs), 4) - self.assertDictEqual(callArgs[0].to_json(), expected) - self.assertTrue(callArgs[1] is None) - self.assertTrue(callArgs[2] is None) + with self.assertRaises(ValueError): + t.track_form_submit("testFormId", ["testClass1", "testClass2"], elems, context=[ctx], tstamp=evTstamp) @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') - def test_track_form_submit_empty_elems(self, mok_track_unstruct): + def test_track_form_submit_invalid_element_type_disabled_contracts(self, mok_track_unstruct: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() + mok_track_unstruct.side_effect = mocked_track_unstruct + + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 + elems = [ + { + "name": "user_email", + "value": "fake@email.fake", + "nodeName": "INPUT", + "type": "invalid" + } + ] + with ContractsDisabled(): - mok_track_unstruct.side_effect = mocked_track_unstruct + t.track_form_submit("testFormId", ["testClass1", "testClass2"], elems, context=[ctx], tstamp=evTstamp) - t = Tracker(e) - t.track_form_submit("testFormId", elements=[]) + expected = { + "schema": FORM_SUBMIT_SCHEMA, + "data": { + "formId": "testFormId", + "formClasses": ["testClass1", "testClass2"], + "elements": elems + } + } - expected = { - "schema": FORM_SUBMIT_SCHEMA, - "data": { - "formId": "testFormId" - } + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) + self.assertIs(callArgs[1][0], ctx) + self.assertEqual(callArgs[2], evTstamp) + + @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') + def test_track_form_submit_optional_none(self, mok_track_unstruct: Any) -> None: + mokEmitter = self.create_patch('snowplow_tracker.Emitter') + e = mokEmitter() + + mok_track_unstruct.side_effect = mocked_track_unstruct + + t = Tracker(e) + t.track_form_submit("testFormId") + + expected = { + "schema": FORM_SUBMIT_SCHEMA, + "data": { + "formId": "testFormId" } + } - callArgs = mok_track_unstruct.call_args_list[0][0] - self.assertEqual(len(callArgs), 4) - self.assertDictEqual(callArgs[0].to_json(), expected) + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) + self.assertTrue(callArgs[1] is None) + self.assertTrue(callArgs[2] is None) @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') - def test_track_site_search(self, mok_track_unstruct): + def test_track_form_submit_empty_elems(self, mok_track_unstruct: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_track_unstruct.side_effect = mocked_track_unstruct + mok_track_unstruct.side_effect = mocked_track_unstruct + + t = Tracker(e) + t.track_form_submit("testFormId", elements=[]) - t = Tracker(e) - ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) - evTstamp = 1399021242030 + expected = { + "schema": FORM_SUBMIT_SCHEMA, + "data": { + "formId": "testFormId" + } + } - t.track_site_search(["track", "search"], {"new":True}, 100, 10, context=[ctx], tstamp=evTstamp) + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) - expected = { - "schema": SITE_SEARCH_SCHEMA, - "data": { - "terms": ["track", "search"], - "filters": {"new": True}, - "totalResults": 100, - "pageResults": 10 - } + @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') + def test_track_site_search(self, mok_track_unstruct: Any) -> None: + mokEmitter = self.create_patch('snowplow_tracker.Emitter') + e = mokEmitter() + + mok_track_unstruct.side_effect = mocked_track_unstruct + + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 + + t.track_site_search(["track", "search"], {"new": True}, 100, 10, context=[ctx], tstamp=evTstamp) + + expected = { + "schema": SITE_SEARCH_SCHEMA, + "data": { + "terms": ["track", "search"], + "filters": {"new": True}, + "totalResults": 100, + "pageResults": 10 } + } - callArgs = mok_track_unstruct.call_args_list[0][0] - self.assertEqual(len(callArgs), 4) - self.assertDictEqual(callArgs[0].to_json(), expected) - self.assertIs(callArgs[1][0], ctx) - self.assertEqual(callArgs[2], evTstamp) + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) + self.assertIs(callArgs[1][0], ctx) + self.assertEqual(callArgs[2], evTstamp) @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') - def test_track_site_search_optional_none(self, mok_track_unstruct): + def test_track_site_search_optional_none(self, mok_track_unstruct: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_track_unstruct.side_effect = mocked_track_unstruct + mok_track_unstruct.side_effect = mocked_track_unstruct - t = Tracker(e) - t.track_site_search(["track", "search"]) + t = Tracker(e) + t.track_site_search(["track", "search"]) - expected = { - "schema": SITE_SEARCH_SCHEMA, - "data": { - "terms": ["track", "search"] - } + expected = { + "schema": SITE_SEARCH_SCHEMA, + "data": { + "terms": ["track", "search"] } + } - callArgs = mok_track_unstruct.call_args_list[0][0] - self.assertEqual(len(callArgs), 4) - self.assertDictEqual(callArgs[0].to_json(), expected) - self.assertTrue(callArgs[1] is None) - self.assertTrue(callArgs[2] is None) + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) + self.assertTrue(callArgs[1] is None) + self.assertTrue(callArgs[2] is None) @mock.patch('snowplow_tracker.Tracker.track_unstruct_event') - def test_track_screen_view(self, mok_track_unstruct): + def test_track_screen_view(self, mok_track_unstruct: Any) -> None: mokEmitter = self.create_patch('snowplow_tracker.Emitter') e = mokEmitter() - with ContractsDisabled(): - mok_track_unstruct.side_effect = mocked_track_unstruct + mok_track_unstruct.side_effect = mocked_track_unstruct - t = Tracker(e) - ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) - evTstamp = 1399021242030 + t = Tracker(e) + ctx = SelfDescribingJson("test.context.schema", {"user": "tester"}) + evTstamp = 1399021242030 - t.track_screen_view("screenName", "screenId", context=[ctx], tstamp=evTstamp) + t.track_screen_view("screenName", "screenId", context=[ctx], tstamp=evTstamp) - expected = { - "schema": SCREEN_VIEW_SCHEMA, - "data": { - "name": "screenName", - "id": "screenId" - } + expected = { + "schema": SCREEN_VIEW_SCHEMA, + "data": { + "name": "screenName", + "id": "screenId" } + } - callArgs = mok_track_unstruct.call_args_list[0][0] - self.assertEqual(len(callArgs), 4) - self.assertDictEqual(callArgs[0].to_json(), expected) - self.assertIs(callArgs[1][0], ctx) - self.assertEqual(callArgs[2], evTstamp) + callArgs = mok_track_unstruct.call_args_list[0][0] + self.assertEqual(len(callArgs), 4) + self.assertDictEqual(callArgs[0].to_json(), expected) + self.assertIs(callArgs[1][0], ctx) + self.assertEqual(callArgs[2], evTstamp) diff --git a/snowplow_tracker/tracker.py b/snowplow_tracker/tracker.py index 20d73c02..92154a9c 100644 --- a/snowplow_tracker/tracker.py +++ b/snowplow_tracker/tracker.py @@ -21,13 +21,13 @@ import time import uuid -import six - -from contracts import contract, new_contract +from typing import Any, Optional, Union, List, Dict, Sequence from snowplow_tracker import payload, _version, SelfDescribingJson from snowplow_tracker import subject as _subject - +from snowplow_tracker.contracts import non_empty_string, one_of, non_empty, form_element +from snowplow_tracker.typing import JsonEncoderFunction, EmitterProtocol,\ + FORM_NODE_NAMES, FORM_TYPES, FormNodeName, ElementClasses, FormClasses """ Constants & config @@ -39,13 +39,7 @@ SCHEMA_TAG = "jsonschema" CONTEXT_SCHEMA = "%s/contexts/%s/1-0-1" % (BASE_SCHEMA_PATH, SCHEMA_TAG) UNSTRUCT_EVENT_SCHEMA = "%s/unstruct_event/%s/1-0-0" % (BASE_SCHEMA_PATH, SCHEMA_TAG) -FORM_NODE_NAMES = ("INPUT", "TEXTAREA", "SELECT") -FORM_TYPES = ( - "button", "checkbox", "color", "date", "datetime", - "datetime-local", "email", "file", "hidden", "image", "month", - "number", "password", "radio", "range", "reset", "search", - "submit", "tel", "text", "time", "url", "week" -) +ContextArray = List[SelfDescribingJson] """ Tracker class @@ -54,32 +48,14 @@ class Tracker: - new_contract("not_none", lambda s: s is not None) - - new_contract("non_empty_string", lambda s: isinstance(s, six.string_types) - and len(s) > 0) - new_contract("string_or_none", lambda s: isinstance(s, six.string_types) - or s is None) - new_contract("payload", lambda s: isinstance(s, payload.Payload)) - - new_contract("tracker", lambda s: isinstance(s, Tracker)) - - new_contract("emitter", lambda s: hasattr(s, "input")) - - new_contract("self_describing_json", lambda s: isinstance(s, SelfDescribingJson)) - - new_contract("context_array", "list(self_describing_json)") - - new_contract("form_node_name", lambda s: s in FORM_NODE_NAMES) - - new_contract("form_type", lambda s: s.lower() in FORM_TYPES) - - new_contract("form_element", lambda x: Tracker.check_form_element(x)) - - @contract - def __init__(self, emitters, subject=None, - namespace=None, app_id=None, - encode_base64=DEFAULT_ENCODE_BASE64, json_encoder=None): + def __init__( + self, + emitters: Union[List[EmitterProtocol], EmitterProtocol], + subject: Optional[_subject.Subject] = None, + namespace: Optional[str] = None, + app_id: Optional[str] = None, + encode_base64: bool = DEFAULT_ENCODE_BASE64, + json_encoder: Optional[JsonEncoderFunction] = None) -> None: """ :param emitters: Emitters to which events will be sent :type emitters: list[>0](emitter) | emitter @@ -98,6 +74,7 @@ def __init__(self, emitters, subject=None, subject = _subject.Subject() if type(emitters) is list: + non_empty(emitters) self.emitters = emitters else: self.emitters = [emitters] @@ -114,8 +91,7 @@ def __init__(self, emitters, subject=None, self.timer = None @staticmethod - @contract - def get_uuid(): + def get_uuid() -> str: """ Set transaction ID for the payload once during the lifetime of the event. @@ -125,8 +101,7 @@ def get_uuid(): return str(uuid.uuid4()) @staticmethod - @contract - def get_timestamp(tstamp=None): + def get_timestamp(tstamp: Optional[float] = None) -> int: """ :param tstamp: User-input timestamp or None :type tstamp: int | float | None @@ -136,13 +111,11 @@ def get_timestamp(tstamp=None): return int(tstamp) return int(time.time() * 1000) - """ Tracking methods """ - @contract - def track(self, pb): + def track(self, pb: payload.Payload) -> 'Tracker': """ Send the payload to a emitter @@ -154,8 +127,12 @@ def track(self, pb): emitter.input(pb.nv_pairs) return self - @contract - def complete_payload(self, pb, context, tstamp, event_subject): + def complete_payload( + self, + pb: payload.Payload, + context: Optional[List[SelfDescribingJson]], + tstamp: Optional[float], + event_subject: Optional[_subject.Subject]) -> 'Tracker': """ Called by all tracking events to add the standard name-value pairs to the Payload object irrespective of the tracked event. @@ -188,8 +165,14 @@ def complete_payload(self, pb, context, tstamp, event_subject): return self.track(pb) - @contract - def track_page_view(self, page_url, page_title=None, referrer=None, context=None, tstamp=None, event_subject=None): + def track_page_view( + self, + page_url: str, + page_title: Optional[str] = None, + referrer: Optional[str] = None, + context: Optional[List[SelfDescribingJson]] = None, + tstamp: Optional[float] = None, + event_subject: Optional[_subject.Subject] = None) -> 'Tracker': """ :param page_url: URL of the viewed page :type page_url: non_empty_string @@ -205,6 +188,8 @@ def track_page_view(self, page_url, page_title=None, referrer=None, context=None :type event_subject: subject | None :rtype: tracker """ + non_empty_string(page_url) + pb = payload.Payload() pb.add("e", "pv") # pv: page view pb.add("url", page_url) @@ -213,8 +198,18 @@ def track_page_view(self, page_url, page_title=None, referrer=None, context=None return self.complete_payload(pb, context, tstamp, event_subject) - @contract - def track_page_ping(self, page_url, page_title=None, referrer=None, min_x=None, max_x=None, min_y=None, max_y=None, context=None, tstamp=None, event_subject=None): + def track_page_ping( + self, + page_url: str, + page_title: Optional[str] = None, + referrer: Optional[str] = None, + min_x: Optional[int] = None, + max_x: Optional[int] = None, + min_y: Optional[int] = None, + max_y: Optional[int] = None, + context: Optional[List[SelfDescribingJson]] = None, + tstamp: Optional[float] = None, + event_subject: Optional[_subject.Subject] = None) -> 'Tracker': """ :param page_url: URL of the viewed page :type page_url: non_empty_string @@ -238,6 +233,8 @@ def track_page_ping(self, page_url, page_title=None, referrer=None, min_x=None, :type event_subject: subject | None :rtype: tracker """ + non_empty_string(page_url) + pb = payload.Payload() pb.add("e", "pp") # pp: page ping pb.add("url", page_url) @@ -250,11 +247,16 @@ def track_page_ping(self, page_url, page_title=None, referrer=None, min_x=None, return self.complete_payload(pb, context, tstamp, event_subject) - @contract - def track_link_click(self, target_url, element_id=None, - element_classes=None, element_target=None, - element_content=None, context=None, tstamp=None, - event_subject=None): + def track_link_click( + self, + target_url: str, + element_id: Optional[str] = None, + element_classes: Optional[ElementClasses] = None, + element_target: Optional[str] = None, + element_content: Optional[str] = None, + context: Optional[List[SelfDescribingJson]] = None, + tstamp: Optional[float] = None, + event_subject: Optional[_subject.Subject] = None) -> 'Tracker': """ :param target_url: Target URL of the link :type target_url: non_empty_string @@ -274,6 +276,8 @@ def track_link_click(self, target_url, element_id=None, :type event_subject: subject | None :rtype: tracker """ + non_empty_string(target_url) + properties = {} properties["targetUrl"] = target_url if element_id is not None: @@ -289,10 +293,17 @@ def track_link_click(self, target_url, element_id=None, return self.track_unstruct_event(event_json, context, tstamp, event_subject) - @contract - def track_add_to_cart(self, sku, quantity, name=None, category=None, - unit_price=None, currency=None, context=None, - tstamp=None, event_subject=None): + def track_add_to_cart( + self, + sku: str, + quantity: int, + name: Optional[str] = None, + category: Optional[str] = None, + unit_price: Optional[float] = None, + currency: Optional[str] = None, + context: Optional[List[SelfDescribingJson]] = None, + tstamp: Optional[float] = None, + event_subject: Optional[_subject.Subject] = None) -> 'Tracker': """ :param sku: Item SKU or ID :type sku: non_empty_string @@ -314,6 +325,8 @@ def track_add_to_cart(self, sku, quantity, name=None, category=None, :type event_subject: subject | None :rtype: tracker """ + non_empty_string(sku) + properties = {} properties["sku"] = sku properties["quantity"] = quantity @@ -330,10 +343,17 @@ def track_add_to_cart(self, sku, quantity, name=None, category=None, return self.track_unstruct_event(event_json, context, tstamp, event_subject) - @contract - def track_remove_from_cart(self, sku, quantity, name=None, category=None, - unit_price=None, currency=None, context=None, - tstamp=None, event_subject=None): + def track_remove_from_cart( + self, + sku: str, + quantity: int, + name: Optional[str] = None, + category: Optional[str] = None, + unit_price: Optional[float] = None, + currency: Optional[str] = None, + context: Optional[List[SelfDescribingJson]] = None, + tstamp: Optional[float] = None, + event_subject: Optional[_subject.Subject] = None) -> 'Tracker': """ :param sku: Item SKU or ID :type sku: non_empty_string @@ -355,6 +375,8 @@ def track_remove_from_cart(self, sku, quantity, name=None, category=None, :type event_subject: subject | None :rtype: tracker """ + non_empty_string(sku) + properties = {} properties["sku"] = sku properties["quantity"] = quantity @@ -371,10 +393,17 @@ def track_remove_from_cart(self, sku, quantity, name=None, category=None, return self.track_unstruct_event(event_json, context, tstamp, event_subject) - @contract - def track_form_change(self, form_id, element_id, node_name, value, type_=None, - element_classes=None, context=None, tstamp=None, - event_subject=None): + def track_form_change( + self, + form_id: str, + element_id: Optional[str], + node_name: FormNodeName, + value: Optional[str], + type_: Optional[str] = None, + element_classes: Optional[ElementClasses] = None, + context: Optional[List[SelfDescribingJson]] = None, + tstamp: Optional[float] = None, + event_subject: Optional[_subject.Subject] = None) -> 'Tracker': """ :param form_id: ID attribute of the HTML form :type form_id: non_empty_string @@ -396,6 +425,11 @@ def track_form_change(self, form_id, element_id, node_name, value, type_=None, :type event_subject: subject | None :rtype: tracker """ + non_empty_string(form_id) + one_of(node_name, FORM_NODE_NAMES) + if type_ is not None: + one_of(type_.lower(), FORM_TYPES) + properties = dict() properties["formId"] = form_id properties["elementId"] = element_id @@ -410,9 +444,14 @@ def track_form_change(self, form_id, element_id, node_name, value, type_=None, return self.track_unstruct_event(event_json, context, tstamp, event_subject) - @contract - def track_form_submit(self, form_id, form_classes=None, elements=None, - context=None, tstamp=None, event_subject=None): + def track_form_submit( + self, + form_id: str, + form_classes: Optional[FormClasses] = None, + elements: Optional[List[Dict[str, Any]]] = None, + context: Optional[List[SelfDescribingJson]] = None, + tstamp: Optional[float] = None, + event_subject: Optional[_subject.Subject] = None) -> 'Tracker': """ :param form_id: ID attribute of the HTML form :type form_id: non_empty_string @@ -428,6 +467,9 @@ def track_form_submit(self, form_id, form_classes=None, elements=None, :type event_subject: subject | None :rtype: tracker """ + non_empty_string(form_id) + for element in elements or []: + form_element(element) properties = dict() properties['formId'] = form_id @@ -440,9 +482,15 @@ def track_form_submit(self, form_id, form_classes=None, elements=None, return self.track_unstruct_event(event_json, context, tstamp, event_subject) - @contract - def track_site_search(self, terms, filters=None, total_results=None, - page_results=None, context=None, tstamp=None, event_subject=None): + def track_site_search( + self, + terms: Sequence[str], + filters: Optional[Dict[str, Union[str, bool]]] = None, + total_results: Optional[int] = None, + page_results: Optional[int] = None, + context: Optional[List[SelfDescribingJson]] = None, + tstamp: Optional[float] = None, + event_subject: Optional[_subject.Subject] = None) -> 'Tracker': """ :param terms: Search terms :type terms: seq[>=1](str) @@ -460,6 +508,8 @@ def track_site_search(self, terms, filters=None, total_results=None, :type event_subject: subject | None :rtype: tracker """ + non_empty(terms) + properties = {} properties["terms"] = terms if filters is not None: @@ -473,10 +523,18 @@ def track_site_search(self, terms, filters=None, total_results=None, return self.track_unstruct_event(event_json, context, tstamp, event_subject) - @contract - def track_ecommerce_transaction_item(self, order_id, sku, price, quantity, - name=None, category=None, currency=None, - context=None, tstamp=None, event_subject=None): + def track_ecommerce_transaction_item( + self, + order_id: str, + sku: str, + price: float, + quantity: int, + name: Optional[str] = None, + category: Optional[str] = None, + currency: Optional[str] = None, + context: Optional[List[SelfDescribingJson]] = None, + tstamp: Optional[float] = None, + event_subject: Optional[_subject.Subject] = None) -> 'Tracker': """ This is an internal method called by track_ecommerce_transaction. It is not for public use. @@ -503,6 +561,9 @@ def track_ecommerce_transaction_item(self, order_id, sku, price, quantity, :type event_subject: subject | None :rtype: tracker """ + non_empty_string(order_id) + non_empty_string(sku) + pb = payload.Payload() pb.add("e", "ti") pb.add("ti_id", order_id) @@ -515,11 +576,21 @@ def track_ecommerce_transaction_item(self, order_id, sku, price, quantity, return self.complete_payload(pb, context, tstamp, event_subject) - @contract - def track_ecommerce_transaction(self, order_id, total_value, affiliation=None, - tax_value=None, shipping=None, city=None, state=None, - country=None, currency=None, items=None, - context=None, tstamp=None, event_subject=None): + def track_ecommerce_transaction( + self, + order_id: str, + total_value: float, + affiliation: Optional[str] = None, + tax_value: Optional[float] = None, + shipping: Optional[float] = None, + city: Optional[str] = None, + state: Optional[str] = None, + country: Optional[str] = None, + currency: Optional[str] = None, + items: Optional[List[Dict[str, Any]]] = None, + context: Optional[List[SelfDescribingJson]] = None, + tstamp: Optional[float] = None, + event_subject: Optional[_subject.Subject] = None) -> 'Tracker': """ :param order_id: ID of the eCommerce transaction :type order_id: non_empty_string @@ -549,6 +620,8 @@ def track_ecommerce_transaction(self, order_id, total_value, affiliation=None, :type event_subject: subject | None :rtype: tracker """ + non_empty_string(order_id) + pb = payload.Payload() pb.add("e", "tr") pb.add("tr_id", order_id) @@ -576,8 +649,13 @@ def track_ecommerce_transaction(self, order_id, total_value, affiliation=None, return self - @contract - def track_screen_view(self, name=None, id_=None, context=None, tstamp=None, event_subject=None): + def track_screen_view( + self, + name: Optional[str] = None, + id_: Optional[str] = None, + context: Optional[List[SelfDescribingJson]] = None, + tstamp: Optional[float] = None, + event_subject: Optional[_subject.Subject] = None) -> 'Tracker': """ :param name: The name of the screen view event :type name: string_or_none @@ -601,9 +679,16 @@ def track_screen_view(self, name=None, id_=None, context=None, tstamp=None, even return self.track_unstruct_event(event_json, context, tstamp, event_subject) - @contract - def track_struct_event(self, category, action, label=None, property_=None, value=None, - context=None, tstamp=None, event_subject=None): + def track_struct_event( + self, + category: str, + action: str, + label: Optional[str] = None, + property_: Optional[str] = None, + value: Optional[float] = None, + context: Optional[List[SelfDescribingJson]] = None, + tstamp: Optional[float] = None, + event_subject: Optional[_subject.Subject] = None) -> 'Tracker': """ :param category: Category of the event :type category: non_empty_string @@ -625,6 +710,9 @@ def track_struct_event(self, category, action, label=None, property_=None, value :type event_subject: subject | None :rtype: tracker """ + non_empty_string(category) + non_empty_string(action) + pb = payload.Payload() pb.add("e", "se") pb.add("se_ca", category) @@ -635,8 +723,12 @@ def track_struct_event(self, category, action, label=None, property_=None, value return self.complete_payload(pb, context, tstamp, event_subject) - @contract - def track_unstruct_event(self, event_json, context=None, tstamp=None, event_subject=None): + def track_unstruct_event( + self, + event_json: SelfDescribingJson, + context: Optional[List[SelfDescribingJson]] = None, + tstamp: Optional[float] = None, + event_subject: Optional[_subject.Subject] = None) -> 'Tracker': """ :param event_json: The properties of the event. Has two field: A "data" field containing the event properties and @@ -663,8 +755,7 @@ def track_unstruct_event(self, event_json, context=None, tstamp=None, event_subj # Alias track_self_describing_event = track_unstruct_event - @contract - def flush(self, is_async=False): + def flush(self, is_async: bool = False) -> 'Tracker': """ Flush the emitter @@ -674,13 +765,14 @@ def flush(self, is_async=False): """ for emitter in self.emitters: if is_async: - emitter.flush() + if hasattr(emitter, 'flush'): + emitter.flush() else: - emitter.sync_flush() + if hasattr(emitter, 'sync_flush'): + emitter.sync_flush() return self - @contract - def set_subject(self, subject): + def set_subject(self, subject: Optional[_subject.Subject]) -> 'Tracker': """ Set the subject of the events fired by the tracker @@ -691,8 +783,7 @@ def set_subject(self, subject): self.subject = subject return self - @contract - def add_emitter(self, emitter): + def add_emitter(self, emitter: EmitterProtocol) -> 'Tracker': """ Add a new emitter to which events should be passed @@ -702,19 +793,3 @@ def add_emitter(self, emitter): """ self.emitters.append(emitter) return self - - @staticmethod - def check_form_element(element): - """ - PyContracts helper method to check that dictionary conforms element - in sumbit_form and change_form schemas - """ - all_present = isinstance(element, dict) and 'name' in element and 'value' in element and 'nodeName' in element - try: - if element['type'] in FORM_TYPES: - type_valid = True - else: - type_valid = False - except KeyError: - type_valid = True - return all_present and element['nodeName'] in FORM_NODE_NAMES and type_valid diff --git a/snowplow_tracker/typing.py b/snowplow_tracker/typing.py new file mode 100644 index 00000000..24c23d91 --- /dev/null +++ b/snowplow_tracker/typing.py @@ -0,0 +1,62 @@ +""" + typing.py + + Copyright (c) 2013-2021 Snowplow Analytics Ltd. All rights reserved. + + This program is licensed to you under the Apache License Version 2.0, + and you may not use this file except in compliance with the Apache License + Version 2.0. You may obtain a copy of the Apache License Version 2.0 at + http://www.apache.org/licenses/LICENSE-2.0. + + Unless required by applicable law or agreed to in writing, + software distributed under the Apache License Version 2.0 is distributed on + an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied. See the Apache License Version 2.0 for the specific + language governing permissions and limitations there under. + + Authors: Anuj More, Alex Dean, Fred Blundun, Paul Boocock, Matus Tomlein + Copyright: Copyright (c) 2013-2021 Snowplow Analytics Ltd + License: Apache License Version 2.0 +""" + +from typing import Dict, List, Callable, Any, Optional, Union, Tuple +from typing_extensions import Protocol, Literal + +PayloadDict = Dict[str, Any] +PayloadDictList = List[PayloadDict] +JsonEncoderFunction = Callable[[Any], Any] + +# tracker +FORM_NODE_NAMES = {"INPUT", "TEXTAREA", "SELECT"} +FORM_TYPES = { + "button", "checkbox", "color", "date", "datetime", + "datetime-local", "email", "file", "hidden", "image", "month", + "number", "password", "radio", "range", "reset", "search", + "submit", "tel", "text", "time", "url", "week" +} +FormNodeName = Literal["INPUT", "TEXTAREA", "SELECT"] +ElementClasses = Union[List[str], Tuple[str, Any]] +FormClasses = Union[List[str], Tuple[str, Any]] + +# emitters +HttpProtocol = Literal["http", "https"] +Method = Literal["get", "post"] +SuccessCallback = Callable[[PayloadDictList], None] +FailureCallback = Callable[[int, PayloadDictList], None] + +# subject +SUPPORTED_PLATFORMS = {"pc", "tv", "mob", "cnsl", "iot", "web", "srv", "app"} +SupportedPlatform = Literal["pc", "tv", "mob", "cnsl", "iot", "web", "srv", "app"] + + +class EmitterProtocol(Protocol): + def input(self, payload: PayloadDict) -> None: + ... + + +class RedisProtocol(Protocol): + def rpush(self, name: Any, *values: Any) -> int: + ... + + def lpop(self, name: Any, count: Optional[int] = ...) -> Any: + ...