Compare commits

...
18 Commits
Author SHA1 Message Date
Michael Matloka d66f944571 Bump version to 1.2.1 2021-02-05 16:27:51 +01:00
Michael MatlokaandGitHub 9d620967f8 Merge pull request #22 from PostHog/fix-possibly-null-percentage
Add condition for rollout-percentage
2021-01-28 15:01:30 +01:00
Eric 563404f914 add condition 2021-01-25 19:57:21 -05:00
Yakko MajuriandGitHub d15aac41a9 Update README.md 2021-01-11 17:19:21 -03:00
Michael MatlokaandGitHub 8a3e28b949 Merge pull request #21 from PostHog/code-style
Black and isort all the things
2021-01-05 12:25:57 +01:00
Michael Matloka 1aa0d6335c Add CI job for this 2021-01-05 12:21:39 +01:00
Michael Matloka 3b46c60cf1 Normalize strings to double quotes 2021-01-05 12:09:44 +01:00
Michael Matloka 4ad8cbfa58 Fix weird concat 2021-01-05 12:07:05 +01:00
Michael Matloka 984a679b19 Black and isort all the things! 2021-01-03 05:37:51 +01:00
Michael Matloka dd1bad6175 Setup black, isort and pre-commit 2021-01-03 05:36:26 +01:00
Michael MatlokaandGitHub e6f71e4cc3 Merge pull request #20 from Hungsiro506/patch-1
Fix post() returning None on non-200 response
2021-01-03 05:07:51 +01:00
Michael Matloka 17874cb131 Update .gitignore 2021-01-03 05:00:41 +01:00
Michael Matloka 8366e09df9 Add typing to request.py 2021-01-03 04:58:55 +01:00
Hưng VũandGitHub e28b237ff1 Update request.py
-- Fix post method return None
2020-12-30 17:43:08 +07:00
Michael MatlokaandGitHub 05fde2a51e Merge pull request #18 from PostHog/better-errors
Add project_api_key, improve errors
2020-12-15 12:01:28 +01:00
Michael MatlokaandGitHub 2be04f3b8b Merge pull request #14 from PostHog/uuid-property
Allow passing in UUID as property
2020-12-15 11:21:31 +01:00
Michael Matloka d52b605742 Rerun CI 2020-12-15 11:14:18 +01:00
Michael Matloka a5f2e030b5 Allow passing in UUID as property 2020-11-03 17:14:00 +01:00
23 changed files with 684 additions and 624 deletions
+61
View File
@@ -0,0 +1,61 @@
name: CI
on:
- pull_request
jobs:
code-quality:
name: Code quality checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 1
- name: Set up Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.8
- uses: actions/cache@v1
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dev dependencies
run: |
python -m pip install -e .[dev]
if: steps.cache.outputs.cache-hit != 'true'
- name: Check formatting with black
run: |
black --check .
- name: Check import order with isort
run: |
isort --check-only .
tests:
name: Python tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
with:
fetch-depth: 1
- name: Set up Python 3.7
uses: actions/setup-python@v1
with:
python-version: 3.7
- name: Install requirements.txt dependencies with pip
run: |
python -m pip install -e .
- name: Run posthog tests
run: |
python setup.py test
-27
View File
@@ -1,27 +0,0 @@
name: Backend CI
on:
- pull_request
jobs:
tests:
name: Python tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
with:
fetch-depth: 1
- name: Set up Python 3.7
uses: actions/setup-python@v1
with:
python-version: 3.7
- name: Install requirements.txt dependencies with pip
run: |
python -m pip install -e .
- name: Run posthog tests
run: |
python setup.py test
+6 -7
View File
@@ -1,16 +1,15 @@
**sublime**
*.pyc
dist
dist/
*.egg-info
dist
MANIFEST
build
.eggs
.coverage
build/
.eggs/
.coverage/
.vscode/
env
env/
venv/
flake8.out
pylint.out
posthog-analytics
.idea
+9
View File
@@ -0,0 +1,9 @@
repos:
- repo: https://github.com/psf/black
rev: stable
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.7.0
hooks:
- id: isort
+4
View File
@@ -3,3 +3,7 @@
Please see the main [PostHog docs](https://posthog.com/docs).
Specifically, the [Python integration](https://posthog.com/docs/integrations/python-integration) details.
## Questions?
### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ)
+11 -10
View File
@@ -1,29 +1,30 @@
# PostHog Python library example
# Import the library
import posthog
import time
import posthog
# You can find this key on the /setup page in PostHog
posthog.api_key = ''
posthog.personal_api_key = ''
posthog.api_key = ""
posthog.personal_api_key = ""
# Where you host PostHog, with no trailing /.
# You can remove this line if you're using posthog.com
posthog.host = 'http://127.0.0.1:8000'
posthog.host = "http://127.0.0.1:8000"
# Capture an event
posthog.capture('distinct_id', 'event', {'property1': 'value', 'property2': 'value'})
posthog.capture("distinct_id", "event", {"property1": "value", "property2": "value"})
print(posthog.feature_enabled('beta-feature', 'distinct_id'))
print(posthog.feature_enabled("beta-feature", "distinct_id"))
print('sleeping')
print("sleeping")
time.sleep(45)
print(posthog.feature_enabled('beta-feature', 'distinct_id'))
print(posthog.feature_enabled("beta-feature", "distinct_id"))
# # Alias a previous distinct id with a new one
posthog.alias('distinct_id', 'new_distinct_id')
posthog.alias("distinct_id", "new_distinct_id")
# # Add properties to the person
posthog.identify('distinct_id', {'email': 'something@something.com'})
posthog.identify("distinct_id", {"email": "something@something.com"})
+80 -49
View File
@@ -1,38 +1,38 @@
from typing import Callable, Dict, Optional
from posthog.version import VERSION
from posthog.client import Client
from typing import Optional, Dict, Callable
from posthog.version import VERSION
__version__ = VERSION
"""Settings."""
api_key = None # type: str
host = None # type: str
on_error = None # type: Callable
debug = False # type: bool
send = True # type: bool
sync_mode = False # type: bool
disabled = False # type: bool
personal_api_key = None # type: str
api_key = None # type: str
host = None # type: str
on_error = None # type: Callable
debug = False # type: bool
send = True # type: bool
sync_mode = False # type: bool
disabled = False # type: bool
personal_api_key = None # type: str
default_client = None
def capture(
distinct_id, # type: str,
event, # type: str,
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
message_id=None, # type: Optional[str]
):
distinct_id, # type: str,
event, # type: str,
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
message_id=None, # type: Optional[str]
):
# type: (...) -> None
"""
Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up.
A `capture` call requires
- `distinct id` which uniquely identifies your user
- `event name` to make sure
- `event name` to make sure
- We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on.
Optionally you can submit
@@ -43,22 +43,31 @@ def capture(
posthog.capture('distinct id', 'movie played', {'movie_id': '123', 'category': 'romcom'})
```
"""
_proxy('capture', distinct_id=distinct_id, event=event, properties=properties, context=context, timestamp=timestamp, message_id=message_id)
_proxy(
"capture",
distinct_id=distinct_id,
event=event,
properties=properties,
context=context,
timestamp=timestamp,
message_id=message_id,
)
def identify(
distinct_id, # type: str,
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
message_id=None, # type: Optional[str]
):
distinct_id, # type: str,
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
message_id=None, # type: Optional[str]
):
# type: (...) -> None
"""
Identify lets you add metadata on your users so you can more easily identify who they are in PostHog, and even do things like segment users by these properties.
An `identify` call requires
- `distinct id` which uniquely identifies your user
- `properties` with a dict with any key: value pairs
- `properties` with a dict with any key: value pairs
For example:
```python
@@ -68,20 +77,28 @@ def identify(
})
```
"""
_proxy('identify', distinct_id=distinct_id, properties=properties, context=context, timestamp=timestamp, message_id=message_id)
_proxy(
"identify",
distinct_id=distinct_id,
properties=properties,
context=context,
timestamp=timestamp,
message_id=message_id,
)
def group(*args, **kwargs):
"""Send a group call."""
_proxy('group', *args, **kwargs)
_proxy("group", *args, **kwargs)
def alias(
previous_id, # type: str,
distinct_id, # type: str,
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
message_id=None, # type: Optional[str]
):
previous_id, # type: str,
distinct_id, # type: str,
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
message_id=None, # type: Optional[str]
):
# type: (...) -> None
"""
To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?"
@@ -99,13 +116,21 @@ def alias(
posthog.alias('anonymous session id', 'distinct id')
```
"""
_proxy('alias', previous_id=previous_id, distinct_id=distinct_id, context=context, timestamp=timestamp, message_id=message_id)
_proxy(
"alias",
previous_id=previous_id,
distinct_id=distinct_id,
context=context,
timestamp=timestamp,
message_id=message_id,
)
def feature_enabled(
key, # type: str,
distinct_id, # type: str,
default=False, # type: bool
):
key, # type: str,
distinct_id, # type: str,
default=False, # type: bool
):
# type: (...) -> bool
"""
Use feature flags to enable or disable features for users.
@@ -118,33 +143,33 @@ def feature_enabled(
You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.
"""
return _proxy('feature_enabled', key=key, distinct_id=distinct_id, default=default)
return _proxy("feature_enabled", key=key, distinct_id=distinct_id, default=default)
def page(*args, **kwargs):
"""Send a page call."""
_proxy('page', *args, **kwargs)
_proxy("page", *args, **kwargs)
def screen(*args, **kwargs):
"""Send a screen call."""
_proxy('screen', *args, **kwargs)
_proxy("screen", *args, **kwargs)
def flush():
"""Tell the client to flush."""
_proxy('flush')
_proxy("flush")
def join():
"""Block program until the client clears the queue"""
_proxy('join')
_proxy("join")
def shutdown():
"""Flush all messages and cleanly shutdown the client"""
_proxy('flush')
_proxy('join')
_proxy("flush")
_proxy("join")
def _proxy(method, *args, **kwargs):
@@ -153,9 +178,15 @@ def _proxy(method, *args, **kwargs):
if disabled:
return None
if not default_client:
default_client = Client(api_key, host=host, debug=debug,
on_error=on_error, send=send,
sync_mode=sync_mode, personal_api_key=personal_api_key)
default_client = Client(
api_key,
host=host,
debug=debug,
on_error=on_error,
send=send,
sync_mode=sync_mode,
personal_api_key=personal_api_key,
)
fn = getattr(default_client, method)
return fn(*args, **kwargs)
+121 -100
View File
@@ -1,19 +1,19 @@
from datetime import datetime, timedelta
from uuid import uuid4
import logging
import numbers
import atexit
import hashlib
import logging
import numbers
from datetime import datetime, timedelta
from uuid import uuid4
import requests
from dateutil.tz import tzutc
from six import string_types
from posthog.utils import guess_timezone, clean
from posthog.consumer import Consumer
from posthog.request import batch_post, decide, get, APIError
from posthog.version import VERSION
from posthog.poller import Poller
from posthog.request import APIError, batch_post, decide, get
from posthog.utils import clean, guess_timezone
from posthog.version import VERSION
try:
import queue
@@ -27,19 +27,35 @@ __LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
class Client(object):
"""Create a new PostHog client."""
log = logging.getLogger('posthog')
def __init__(self, api_key=None, host=None, debug=False,
max_queue_size=10000, send=True, on_error=None, flush_at=100,
flush_interval=0.5, gzip=False, max_retries=3,
sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, project_api_key=None):
log = logging.getLogger("posthog")
def __init__(
self,
api_key=None,
host=None,
debug=False,
max_queue_size=10000,
send=True,
on_error=None,
flush_at=100,
flush_interval=0.5,
gzip=False,
max_retries=3,
sync_mode=False,
timeout=15,
thread=1,
poll_interval=30,
personal_api_key=None,
project_api_key=None,
):
self.queue = queue.Queue(max_queue_size)
# api_key: This should be the Team API Key (token), public
self.api_key = api_key or project_api_key
require('api_key', self.api_key, string_types)
require("api_key", self.api_key, string_types)
self.on_error = on_error
self.debug = debug
@@ -50,7 +66,7 @@ class Client(object):
self.timeout = timeout
self.feature_flags = None
self.poll_interval = poll_interval
# personal_api_key: This should be a generated Personal API Key, private
self.personal_api_key = personal_api_key
@@ -71,9 +87,15 @@ class Client(object):
for n in range(thread):
self.consumers = []
consumer = Consumer(
self.queue, api_key, host=host, on_error=on_error,
flush_at=flush_at, flush_interval=flush_interval,
gzip=gzip, retries=max_retries, timeout=timeout,
self.queue,
api_key,
host=host,
on_error=on_error,
flush_at=flush_at,
flush_interval=flush_interval,
gzip=gzip,
retries=max_retries,
timeout=timeout,
)
self.consumers.append(consumer)
@@ -81,127 +103,122 @@ class Client(object):
if send:
consumer.start()
def identify(self, distinct_id=None, properties=None, context=None, timestamp=None,
message_id=None):
def identify(self, distinct_id=None, properties=None, context=None, timestamp=None, message_id=None):
properties = properties or {}
context = context or {}
require('distinct_id', distinct_id, ID_TYPES)
require('properties', properties, dict)
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
msg = {
'timestamp': timestamp,
'context': context,
'distinct_id': distinct_id,
'$set': properties,
'event': '$identify',
'messageId': message_id,
"timestamp": timestamp,
"context": context,
"distinct_id": distinct_id,
"$set": properties,
"event": "$identify",
"messageId": message_id,
}
return self._enqueue(msg)
def capture(self, distinct_id=None, event=None, properties=None, context=None,
timestamp=None, message_id=None):
def capture(self, distinct_id=None, event=None, properties=None, context=None, timestamp=None, message_id=None):
properties = properties or {}
context = context or {}
require('distinct_id', distinct_id, ID_TYPES)
require('properties', properties, dict)
require('event', event, string_types)
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
require("event", event, string_types)
msg = {
'properties': properties,
'timestamp': timestamp,
'context': context,
'distinct_id': distinct_id,
'event': event,
'messageId': message_id,
"properties": properties,
"timestamp": timestamp,
"context": context,
"distinct_id": distinct_id,
"event": event,
"messageId": message_id,
}
return self._enqueue(msg)
def alias(self, previous_id=None, distinct_id=None, context=None,
timestamp=None, message_id=None):
def alias(self, previous_id=None, distinct_id=None, context=None, timestamp=None, message_id=None):
context = context or {}
require('previous_id', previous_id, ID_TYPES)
require('distinct_id', distinct_id, ID_TYPES)
require("previous_id", previous_id, ID_TYPES)
require("distinct_id", distinct_id, ID_TYPES)
msg = {
'properties': {
'distinct_id': previous_id,
'alias': distinct_id,
"properties": {
"distinct_id": previous_id,
"alias": distinct_id,
},
'timestamp': timestamp,
'context': context,
'event': '$create_alias'
"timestamp": timestamp,
"context": context,
"event": "$create_alias",
}
return self._enqueue(msg)
def page(self, distinct_id=None, url=None, properties=None,
context=None, timestamp=None, message_id=None):
def page(self, distinct_id=None, url=None, properties=None, context=None, timestamp=None, message_id=None):
properties = properties or {}
context = context or {}
require('distinct_id', distinct_id, ID_TYPES)
require('properties', properties, dict)
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
require('url', url, string_types)
properties['$current_url'] = url
require("url", url, string_types)
properties["$current_url"] = url
msg = {
'event': '$pageview',
'properties': properties,
'timestamp': timestamp,
'context': context,
'distinct_id': distinct_id,
'messageId': message_id,
"event": "$pageview",
"properties": properties,
"timestamp": timestamp,
"context": context,
"distinct_id": distinct_id,
"messageId": message_id,
}
return self._enqueue(msg)
def _enqueue(self, msg):
"""Push a new `msg` onto the queue, return `(success, msg)`"""
timestamp = msg['timestamp']
timestamp = msg["timestamp"]
if timestamp is None:
timestamp = datetime.utcnow().replace(tzinfo=tzutc())
message_id = msg.get('messageId')
message_id = msg.get("messageId")
if message_id is None:
message_id = uuid4()
require('timestamp', timestamp, datetime)
require('context', msg['context'], dict)
require("timestamp", timestamp, datetime)
require("context", msg["context"], dict)
# add common
timestamp = guess_timezone(timestamp)
msg['timestamp'] = timestamp.isoformat()
msg['messageId'] = stringify_id(message_id)
if not msg.get('properties'):
msg['properties'] = {}
msg['properties']['$lib'] = 'posthog-python'
msg['properties']['$lib_version'] = VERSION
msg["timestamp"] = timestamp.isoformat()
msg["messageId"] = stringify_id(message_id)
if not msg.get("properties"):
msg["properties"] = {}
msg["properties"]["$lib"] = "posthog-python"
msg["properties"]["$lib_version"] = VERSION
msg['distinct_id'] = stringify_id(msg.get('distinct_id', None))
msg["distinct_id"] = stringify_id(msg.get("distinct_id", None))
msg = clean(msg)
self.log.debug('queueing: %s', msg)
self.log.debug("queueing: %s", msg)
# if send is False, return msg as if it was successfully queued
if not self.send:
return True, msg
if self.sync_mode:
self.log.debug('enqueued with blocking %s.', msg['event'])
batch_post(self.api_key, self.host, gzip=self.gzip,
timeout=self.timeout, batch=[msg])
self.log.debug("enqueued with blocking %s.", msg["event"])
batch_post(self.api_key, self.host, gzip=self.gzip, timeout=self.timeout, batch=[msg])
return True, msg
try:
self.queue.put(msg, block=False)
self.log.debug('enqueued %s.', msg['event'])
self.log.debug("enqueued %s.", msg["event"])
return True, msg
except queue.Full:
self.log.warning('analytics-python queue is full')
self.log.warning("analytics-python queue is full")
return False, msg
def flush(self):
@@ -210,7 +227,7 @@ class Client(object):
size = queue.qsize()
queue.join()
# Note that this message may not be precise, because of threading.
self.log.debug('successfully flushed about %s items.', size)
self.log.debug("successfully flushed about %s items.", size)
def join(self):
"""Ends the consumer thread once the queue is empty.
@@ -231,29 +248,29 @@ class Client(object):
def _load_feature_flags(self):
try:
self.feature_flags = get(self.personal_api_key, '/api/feature_flag/', self.host)['results']
self.feature_flags = get(self.personal_api_key, "/api/feature_flag/", self.host)["results"]
except APIError as e:
if e.status == 401:
raise APIError(
status=401,
message='You are using a write-only key with feature flags. ' \
'To use feature flags, please set a personal_api_key ' \
'More information: https://posthog.com/docs/api/overview'
status=401,
message="You are using a write-only key with feature flags. "
"To use feature flags, please set a personal_api_key "
"More information: https://posthog.com/docs/api/overview",
)
else:
raise APIError(
status=e.status,
message=e.message
)
raise APIError(status=e.status, message=e.message)
except Exception as e:
self.log.warning('[FEATURE FLAGS] Fetching feature flags failed with following error. We will retry in %s seconds.' % self.poll_interval)
self.log.warning(
"[FEATURE FLAGS] Fetching feature flags failed with following error. We will retry in %s seconds."
% self.poll_interval
)
self.log.warning(e)
self._last_feature_flag_poll = datetime.utcnow().replace(tzinfo=tzutc())
def load_feature_flags(self):
if not self.personal_api_key:
self.log.warning('[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags.')
self.log.warning("[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags.")
self.feature_flags = []
return
@@ -262,11 +279,11 @@ class Client(object):
poller.start()
def feature_enabled(self, key, distinct_id, default=False):
require('key', key, string_types)
require('distinct_id', distinct_id, ID_TYPES)
require("key", key, string_types)
require("distinct_id", distinct_id, ID_TYPES)
if not self.personal_api_key:
self.log.warning('[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags.')
self.log.warning("[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags.")
if not self.feature_flags:
self.load_feature_flags()
@@ -275,12 +292,12 @@ class Client(object):
response = default
else:
try:
feature_flag = [flag for flag in self.feature_flags if flag['key'] == key][0]
feature_flag = [flag for flag in self.feature_flags if flag["key"] == key][0]
except IndexError:
return default
if feature_flag.get('is_simple_flag'):
response = _hash(key, distinct_id) <= (feature_flag['rollout_percentage'] / 100)
if feature_flag.get("is_simple_flag") and feature_flag.get("rollout_percentage"):
response = _hash(key, distinct_id) <= (feature_flag["rollout_percentage"] / 100)
else:
try:
request_data = {
@@ -288,15 +305,18 @@ class Client(object):
"personal_api_key": self.personal_api_key,
}
resp_data = decide(self.api_key, self.host, timeout=10, **request_data)
response = key in resp_data['featureFlags']
response = key in resp_data["featureFlags"]
except Exception as e:
response = default
self.log.warning('[FEATURE FLAGS] Unable to get data for flag %s, because of the following error:' % key)
self.log.warning(
"[FEATURE FLAGS] Unable to get data for flag %s, because of the following error:" % key
)
self.log.warning(e)
self.capture(distinct_id, '$feature_flag_called', {'$feature_flag': key, '$feature_flag_response': response})
self.capture(distinct_id, "$feature_flag_called", {"$feature_flag": key, "$feature_flag_response": response})
return response
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
# Given the same distinct_id and key, it'll always return the same float. These floats are
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
@@ -306,10 +326,11 @@ def _hash(key, distinct_id):
hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16)
return hash_val / __LONG_SCALE__
def require(name, field, data_type):
"""Require that the named `field` has the right `data_type`"""
if not isinstance(field, data_type):
msg = '{0} must have {1}, got: {2}'.format(name, data_type, field)
msg = "{0} must have {1}, got: {2}".format(name, data_type, field)
raise AssertionError(msg)
+28 -26
View File
@@ -1,10 +1,11 @@
import json
import logging
from threading import Thread
import monotonic
import backoff
import json
from posthog.request import batch_post, APIError, DatetimeSerializer
import backoff
import monotonic
from posthog.request import APIError, DatetimeSerializer, batch_post
try:
from queue import Empty
@@ -20,11 +21,21 @@ BATCH_SIZE_LIMIT = 475000
class Consumer(Thread):
"""Consumes the messages from the client's queue."""
log = logging.getLogger('posthog')
def __init__(self, queue, api_key, flush_at=100, host=None,
on_error=None, flush_interval=0.5, gzip=False, retries=10,
timeout=15):
log = logging.getLogger("posthog")
def __init__(
self,
queue,
api_key,
flush_at=100,
host=None,
on_error=None,
flush_interval=0.5,
gzip=False,
retries=10,
timeout=15,
):
"""Create a consumer thread."""
Thread.__init__(self)
# Make consumer a daemon thread so that it doesn't block program exit
@@ -46,11 +57,11 @@ class Consumer(Thread):
def run(self):
"""Runs the consumer."""
self.log.debug('consumer is running...')
self.log.debug("consumer is running...")
while self.running:
self.upload()
self.log.debug('consumer exited.')
self.log.debug("consumer exited.")
def pause(self):
"""Pause the consumer."""
@@ -67,7 +78,7 @@ class Consumer(Thread):
self.request(batch)
success = True
except Exception as e:
self.log.error('error uploading: %s', e)
self.log.error("error uploading: %s", e)
success = False
if self.on_error:
self.on_error(e, batch)
@@ -90,19 +101,15 @@ class Consumer(Thread):
if elapsed >= self.flush_interval:
break
try:
item = queue.get(
block=True, timeout=self.flush_interval - elapsed)
item_size = len(json.dumps(
item, cls=DatetimeSerializer).encode())
item = queue.get(block=True, timeout=self.flush_interval - elapsed)
item_size = len(json.dumps(item, cls=DatetimeSerializer).encode())
if item_size > MAX_MSG_SIZE:
self.log.error(
'Item exceeds 32kb limit, dropping. (%s)', str(item))
self.log.error("Item exceeds 32kb limit, dropping. (%s)", str(item))
continue
items.append(item)
total_size += item_size
if total_size >= BATCH_SIZE_LIMIT:
self.log.debug(
'hit batch size limit (size: %d)', total_size)
self.log.debug("hit batch size limit (size: %d)", total_size)
break
except Empty:
break
@@ -122,13 +129,8 @@ class Consumer(Thread):
# retry on all other errors (eg. network)
return False
@backoff.on_exception(
backoff.expo,
Exception,
max_tries=self.retries + 1,
giveup=fatal_exception)
@backoff.on_exception(backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception)
def send_request():
batch_post(self.api_key, self.host, gzip=self.gzip,
timeout=self.timeout, batch=batch)
batch_post(self.api_key, self.host, gzip=self.gzip, timeout=self.timeout, batch=batch)
send_request()
+3 -2
View File
@@ -1,5 +1,6 @@
import threading
class Poller(threading.Thread):
def __init__(self, interval, execute, *args, **kwargs):
threading.Thread.__init__(self)
@@ -9,11 +10,11 @@ class Poller(threading.Thread):
self.execute = execute
self.args = args
self.kwargs = kwargs
def stop(self):
self.stopped.set()
self.join()
def run(self):
while not self.stopped.wait(self.interval.total_seconds()):
self.execute(*self.args, **self.kwargs)
self.execute(*self.args, **self.kwargs)
+48 -47
View File
@@ -1,92 +1,93 @@
from datetime import date, datetime
from dateutil.tz import tzutc
import logging
import json
import logging
from datetime import date, datetime
from gzip import GzipFile
from requests.auth import HTTPBasicAuth
import requests
from io import BytesIO
from typing import Any, Optional, Union
import requests
from dateutil.tz import tzutc
from posthog.version import VERSION
from posthog.utils import remove_trailing_slash
from posthog.version import VERSION
_session = requests.sessions.Session()
DEFAULT_HOST = 'https://app.posthog.com'
USER_AGENT = 'posthog-python/' + VERSION
DEFAULT_HOST = "https://app.posthog.com"
USER_AGENT = "posthog-python/" + VERSION
def post(api_key, host=None, path=None, gzip=False, timeout=15, **kwargs):
def post(
api_key: str, host: Optional[str] = None, path=None, gzip: bool = False, timeout: int = 15, **kwargs
) -> requests.Response:
"""Post the `kwargs` to the API"""
log = logging.getLogger('posthog')
log = logging.getLogger("posthog")
body = kwargs
body["sentAt"] = datetime.utcnow().replace(tzinfo=tzutc()).isoformat()
url = remove_trailing_slash(host or DEFAULT_HOST) + path
body['api_key'] = api_key
body["api_key"] = api_key
data = json.dumps(body, cls=DatetimeSerializer)
log.debug('making request: %s', data)
headers = {
'Content-Type': 'application/json',
'User-Agent': USER_AGENT
}
log.debug("making request: %s", data)
headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
if gzip:
headers['Content-Encoding'] = 'gzip'
headers["Content-Encoding"] = "gzip"
buf = BytesIO()
with GzipFile(fileobj=buf, mode='w') as gz:
with GzipFile(fileobj=buf, mode="w") as gz:
# 'data' was produced by json.dumps(),
# whose default encoding is utf-8.
gz.write(data.encode('utf-8'))
gz.write(data.encode("utf-8"))
data = buf.getvalue()
res = _session.post(url, data=data,
headers=headers, timeout=timeout)
res = _session.post(url, data=data, headers=headers, timeout=timeout)
if res.status_code == 200:
log.debug('data uploaded successfully')
return res
log.debug("data uploaded successfully")
return res
def _process_response(res, success_message, return_json=True):
log = logging.getLogger('posthog')
def _process_response(
res: requests.Response, success_message: str, *, return_json: bool = True
) -> Union[requests.Response, Any]:
log = logging.getLogger("posthog")
if not res:
raise APIError('N/A', 'Error when fetching PostHog API, please make sure you are using your public project token/key and not a private API key.')
raise APIError(
"N/A",
"Error when fetching PostHog API, please make sure you are using your public project token/key and not a private API key.",
)
if res.status_code == 200:
log.debug(success_message)
return res.json() if return_json else res
try:
payload = res.json()
log.debug('received response: %s', payload)
raise APIError(res.status_code, payload['detail'])
log.debug("received response: %s", payload)
raise APIError(res.status_code, payload["detail"])
except ValueError:
raise APIError(res.status_code, res.text)
def decide(api_key, host=None, gzip=False, timeout=15, **kwargs):
def decide(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any:
"""Post the `kwargs to the decide API endpoint"""
res = post(api_key, host, '/decide/', gzip, timeout, **kwargs)
return _process_response(res, success_message='Feature flags decided successfully')
res = post(api_key, host, "/decide/", gzip, timeout, **kwargs)
return _process_response(res, success_message="Feature flags decided successfully")
def batch_post(api_key, host=None, gzip=False, timeout=15, **kwargs):
def batch_post(
api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs
) -> requests.Response:
"""Post the `kwargs` to the batch API endpoint for events"""
res = post(api_key, host, '/batch/', gzip, timeout, **kwargs)
return _process_response(res, success_message='data uploaded successfully', return_json=False)
res = post(api_key, host, "/batch/", gzip, timeout, **kwargs)
return _process_response(res, success_message="data uploaded successfully", return_json=False)
def get(api_key, url, host=None, timeout=None):
def get(api_key: str, url: str, host: Optional[str] = None, timeout: Optional[int] = None) -> requests.Response:
url = remove_trailing_slash(host or DEFAULT_HOST) + url
res = requests.get(
url,
headers={
'Authorization': 'Bearer %s' % api_key,
'User-Agent': USER_AGENT
},
timeout=timeout
)
return _process_response(res, success_message=f'GET {url} completed successfully')
res = requests.get(url, headers={"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}, timeout=timeout)
return _process_response(res, success_message=f"GET {url} completed successfully")
class APIError(Exception):
def __init__(self, status, message):
def __init__(self, status: Union[int, str], message: str):
self.message = message
self.status = status
@@ -96,7 +97,7 @@ class APIError(Exception):
class DatetimeSerializer(json.JSONEncoder):
def default(self, obj):
def default(self, obj: Any):
if isinstance(obj, (date, datetime)):
return obj.isoformat()
+3 -3
View File
@@ -1,12 +1,12 @@
import unittest
import pkgutil
import logging
import pkgutil
import sys
import unittest
def all_names():
for _, modname, _ in pkgutil.iter_modules(__path__):
yield 'posthog.test.' + modname
yield "posthog.test." + modname
def all():
+125 -133
View File
@@ -1,21 +1,21 @@
from datetime import date, datetime
import unittest
import six
import mock
import time
import unittest
from datetime import date, datetime
import mock
import six
from freezegun import freeze_time
from posthog.version import VERSION
from posthog.client import Client
from posthog.test.utils import TEST_API_KEY
from posthog.request import APIError
from posthog.test.utils import TEST_API_KEY
from posthog.version import VERSION
class TestClient(unittest.TestCase):
def set_fail(self, e, batch):
"""Mark the failure handler"""
print('FAIL', e, batch)
print("FAIL", e, batch)
self.failed = True
def setUp(self):
@@ -30,118 +30,126 @@ class TestClient(unittest.TestCase):
def test_basic_capture(self):
client = self.client
success, msg = client.capture('distinct_id', 'python test event')
success, msg = client.capture("distinct_id", "python test event")
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
self.assertEqual(msg['event'], 'python test event')
self.assertTrue(isinstance(msg['timestamp'], str))
self.assertTrue(isinstance(msg['messageId'], str))
self.assertEqual(msg['distinct_id'], 'distinct_id')
self.assertEqual(msg['properties']['$lib'], 'posthog-python')
self.assertEqual(msg['properties']['$lib_version'], VERSION)
self.assertEqual(msg["event"], "python test event")
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertTrue(isinstance(msg["messageId"], str))
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
def test_stringifies_distinct_id(self):
# A large number that loses precision in node:
# node -e "console.log(157963456373623802 + 1)" > 157963456373623800
client = self.client
success, msg = client.capture(
distinct_id=157963456373623802, event='python test event')
success, msg = client.capture(distinct_id=157963456373623802, event="python test event")
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
self.assertEqual(msg['distinct_id'], '157963456373623802')
self.assertEqual(msg["distinct_id"], "157963456373623802")
def test_advanced_capture(self):
client = self.client
success, msg = client.capture(
'distinct_id', 'python test event', {'property': 'value'},
{'ip': '192.168.0.1'}, datetime(2014, 9, 3),
'messageId')
"distinct_id",
"python test event",
{"property": "value"},
{"ip": "192.168.0.1"},
datetime(2014, 9, 3),
"messageId",
)
self.assertTrue(success)
self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00')
self.assertEqual(msg['properties']['property'], 'value')
self.assertEqual(msg['context']['ip'], '192.168.0.1')
self.assertEqual(msg['event'], 'python test event')
self.assertEqual(msg['properties']['$lib'], 'posthog-python')
self.assertEqual(msg['properties']['$lib_version'], VERSION)
self.assertEqual(msg['messageId'], 'messageId')
self.assertEqual(msg['distinct_id'], 'distinct_id')
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
self.assertEqual(msg["properties"]["property"], "value")
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
self.assertEqual(msg["event"], "python test event")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(msg["messageId"], "messageId")
self.assertEqual(msg["distinct_id"], "distinct_id")
def test_basic_identify(self):
client = self.client
success, msg = client.identify('distinct_id', {'trait': 'value'})
success, msg = client.identify("distinct_id", {"trait": "value"})
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
self.assertEqual(msg['$set']['trait'], 'value')
self.assertTrue(isinstance(msg['timestamp'], str))
self.assertTrue(isinstance(msg['messageId'], str))
self.assertEqual(msg['distinct_id'], 'distinct_id')
self.assertEqual(msg["$set"]["trait"], "value")
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertTrue(isinstance(msg["messageId"], str))
self.assertEqual(msg["distinct_id"], "distinct_id")
def test_advanced_identify(self):
client = self.client
success, msg = client.identify(
'distinct_id', {'trait': 'value'}, {'ip': '192.168.0.1'},
datetime(2014, 9, 3), 'messageId')
"distinct_id", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "messageId"
)
self.assertTrue(success)
self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00')
self.assertEqual(msg['context']['ip'], '192.168.0.1')
self.assertEqual(msg['$set']['trait'], 'value')
self.assertEqual(msg['properties']['$lib'], 'posthog-python')
self.assertEqual(msg['properties']['$lib_version'], VERSION)
self.assertTrue(isinstance(msg['timestamp'], str))
self.assertEqual(msg['messageId'], 'messageId')
self.assertEqual(msg['distinct_id'], 'distinct_id')
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
self.assertEqual(msg["$set"]["trait"], "value")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertEqual(msg["messageId"], "messageId")
self.assertEqual(msg["distinct_id"], "distinct_id")
def test_basic_alias(self):
client = self.client
success, msg = client.alias('previousId', 'distinct_id')
success, msg = client.alias("previousId", "distinct_id")
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
self.assertEqual(msg['properties']['distinct_id'], 'previousId')
self.assertEqual(msg['properties']['alias'], 'distinct_id')
self.assertEqual(msg["properties"]["distinct_id"], "previousId")
self.assertEqual(msg["properties"]["alias"], "distinct_id")
def test_basic_page(self):
client = self.client
success, msg = client.page('distinct_id', url='https://posthog.com/contact')
success, msg = client.page("distinct_id", url="https://posthog.com/contact")
self.assertFalse(self.failed)
client.flush()
self.assertTrue(success)
self.assertEqual(msg['distinct_id'], 'distinct_id')
self.assertEqual(msg['properties']['$current_url'], 'https://posthog.com/contact')
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$current_url"], "https://posthog.com/contact")
def test_advanced_page(self):
client = self.client
success, msg = client.page(
'distinct_id', 'https://posthog.com/contact', {'property': 'value'},
{'ip': '192.168.0.1'}, datetime(2014, 9, 3), 'messageId')
"distinct_id",
"https://posthog.com/contact",
{"property": "value"},
{"ip": "192.168.0.1"},
datetime(2014, 9, 3),
"messageId",
)
self.assertTrue(success)
self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00')
self.assertEqual(msg['context']['ip'], '192.168.0.1')
self.assertEqual(msg['properties']['$current_url'], 'https://posthog.com/contact')
self.assertEqual(msg['properties']['property'], 'value')
self.assertEqual(msg['properties']['$lib'], 'posthog-python')
self.assertEqual(msg['properties']['$lib_version'], VERSION)
self.assertTrue(isinstance(msg['timestamp'], str))
self.assertEqual(msg['messageId'], 'messageId')
self.assertEqual(msg['distinct_id'], 'distinct_id')
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
self.assertEqual(msg["properties"]["$current_url"], "https://posthog.com/contact")
self.assertEqual(msg["properties"]["property"], "value")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertEqual(msg["messageId"], "messageId")
self.assertEqual(msg["distinct_id"], "distinct_id")
def test_flush(self):
client = self.client
# set up the consumer with more requests than a single batch will allow
for i in range(1000):
success, msg = client.identify('distinct_id', {'trait': 'value'})
success, msg = client.identify("distinct_id", {"trait": "value"})
# We can't reliably assert that the queue is non-empty here; that's
# a race condition. We do our best to load it up though.
client.flush()
@@ -152,7 +160,7 @@ class TestClient(unittest.TestCase):
client = self.client
# set up the consumer with more requests than a single batch will allow
for i in range(1000):
success, msg = client.identify('distinct_id', {'trait': 'value'})
success, msg = client.identify("distinct_id", {"trait": "value"})
client.shutdown()
# we expect two things after shutdown:
# 1. client queue is empty
@@ -164,7 +172,7 @@ class TestClient(unittest.TestCase):
def test_synchronous(self):
client = Client(TEST_API_KEY, sync_mode=True)
success, message = client.identify('distinct_id')
success, message = client.identify("distinct_id")
self.assertFalse(client.consumers)
self.assertTrue(client.queue.empty())
self.assertTrue(success)
@@ -175,43 +183,41 @@ class TestClient(unittest.TestCase):
client.join()
for i in range(10):
client.identify('distinct_id')
client.identify("distinct_id")
success, msg = client.identify('distinct_id')
success, msg = client.identify("distinct_id")
# Make sure we are informed that the queue is at capacity
self.assertFalse(success)
def test_unicode(self):
Client(six.u('unicode_key'))
Client(six.u("unicode_key"))
def test_numeric_distinct_id(self):
self.client.capture(1234, 'python event')
self.client.capture(1234, "python event")
self.client.flush()
self.assertFalse(self.failed)
def test_debug(self):
Client('bad_key', debug=True)
Client("bad_key", debug=True)
def test_gzip(self):
client = Client(TEST_API_KEY, on_error=self.fail, gzip=True)
for _ in range(10):
client.identify('distinct_id', {'trait': 'value'})
client.identify("distinct_id", {"trait": "value"})
client.flush()
self.assertFalse(self.failed)
def test_user_defined_flush_at(self):
client = Client(TEST_API_KEY, on_error=self.fail,
flush_at=10, flush_interval=3)
client = Client(TEST_API_KEY, on_error=self.fail, flush_at=10, flush_interval=3)
def mock_post_fn(*args, **kwargs):
self.assertEquals(len(kwargs['batch']), 10)
self.assertEquals(len(kwargs["batch"]), 10)
# the post function should be called 2 times, with a batch size of 10
# each time.
with mock.patch('posthog.consumer.batch_post', side_effect=mock_post_fn) \
as mock_post:
with mock.patch("posthog.consumer.batch_post", side_effect=mock_post_fn) as mock_post:
for _ in range(20):
client.identify('distinct_id', {'trait': 'value'})
client.identify("distinct_id", {"trait": "value"})
time.sleep(1)
self.assertEquals(mock_post.call_count, 2)
@@ -225,91 +231,77 @@ class TestClient(unittest.TestCase):
for consumer in client.consumers:
self.assertEquals(consumer.timeout, 15)
@mock.patch('posthog.client.Poller')
@mock.patch('posthog.client.get')
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags(self, patch_get, patch_poll):
patch_get.return_value = {
'results': [{
'id': 1,
'name': 'Beta Feature',
'key': 'beta-feature'
}]
}
client = Client(TEST_API_KEY, personal_api_key='test')
with freeze_time('2020-01-01T12:01:00.0000Z'):
patch_get.return_value = {"results": [{"id": 1, "name": "Beta Feature", "key": "beta-feature"}]}
client = Client(TEST_API_KEY, personal_api_key="test")
with freeze_time("2020-01-01T12:01:00.0000Z"):
client.load_feature_flags()
self.assertEqual(client.feature_flags[0]['key'], 'beta-feature')
self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
self.assertEqual(client._last_feature_flag_poll.isoformat(), "2020-01-01T12:01:00+00:00")
self.assertEqual(patch_poll.call_count, 1)
def test_load_feature_flags_wrong_key(self):
client = Client(TEST_API_KEY, personal_api_key=TEST_API_KEY)
with freeze_time('2020-01-01T12:01:00.0000Z'):
with freeze_time("2020-01-01T12:01:00.0000Z"):
self.assertRaises(APIError, client.load_feature_flags)
@mock.patch('posthog.client.get')
@mock.patch("posthog.client.get")
def test_feature_enabled_simple(self, patch_get):
client = Client(TEST_API_KEY)
client.feature_flags = [{
'id': 1,
'name': 'Beta Feature',
'key': 'beta-feature',
'is_simple_flag': True,
'rollout_percentage': 100
}]
self.assertTrue(client.feature_enabled('beta-feature', 'distinct_id'))
client.feature_flags = [
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "is_simple_flag": True, "rollout_percentage": 100}
]
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
@mock.patch('posthog.client.decide')
@mock.patch("posthog.client.decide")
def test_feature_enabled_request(self, patch_get):
patch_get.return_value = {
'featureFlags': ['beta-feature']
}
patch_get.return_value = {"featureFlags": ["beta-feature"]}
client = Client(TEST_API_KEY)
client.feature_flags = [{
'id': 1,
'name': 'Beta Feature',
'key': 'beta-feature',
'is_simple_flag': False,
'rollout_percentage': 100
}]
self.assertTrue(client.feature_enabled('beta-feature', 'distinct_id'))
client.feature_flags = [
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "is_simple_flag": False, "rollout_percentage": 100}
]
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
@mock.patch('posthog.client.Poller')
@mock.patch('posthog.client.get')
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_feature_enabled_doesnt_exist(self, patch_get, patch_poll):
client = Client(TEST_API_KEY, personal_api_key='test')
client = Client(TEST_API_KEY, personal_api_key="test")
client.feature_flags = []
self.assertFalse(client.feature_enabled('doesnt-exist', 'distinct_id'))
self.assertTrue(client.feature_enabled('doesnt-exist', 'distinct_id', True))
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
self.assertTrue(client.feature_enabled("doesnt-exist", "distinct_id", True))
@mock.patch('posthog.client.Poller')
@mock.patch('posthog.client.get')
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_personal_api_key_doesnt_exist(self, patch_get, patch_poll):
client = Client(TEST_API_KEY)
client.feature_flags = []
self.assertFalse(client.feature_enabled('doesnt-exist', 'distinct_id'))
self.assertTrue(client.feature_enabled('doesnt-exist', 'distinct_id', True))
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
self.assertTrue(client.feature_enabled("doesnt-exist", "distinct_id", True))
@mock.patch('posthog.client.Poller')
@mock.patch('posthog.client.get')
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags_error(self, patch_get, patch_poll):
def raise_effect():
raise Exception('http exception')
raise Exception("http exception")
patch_get.return_value.raiseError.side_effect = raise_effect
client = Client(TEST_API_KEY, personal_api_key='test')
client = Client(TEST_API_KEY, personal_api_key="test")
client.feature_flags = []
self.assertFalse(client.feature_enabled('doesnt-exist', 'distinct_id'))
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
@mock.patch('posthog.client.Poller')
@mock.patch('posthog.client.get')
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_call_identify_fails(self, patch_get, patch_poll):
def raise_effect():
raise Exception('http exception')
patch_get.return_value.raiseError.side_effect = raise_effect
client = Client(TEST_API_KEY, personal_api_key='test')
client.feature_flags = [{'key': 'example', 'is_simple_flag': False}]
raise Exception("http exception")
self.assertFalse(client.feature_enabled('example', 'distinct_id'))
patch_get.return_value.raiseError.side_effect = raise_effect
client = Client(TEST_API_KEY, personal_api_key="test")
client.feature_flags = [{"key": "example", "is_simple_flag": False}]
self.assertFalse(client.feature_enabled("example", "distinct_id"))
+33 -68
View File
@@ -1,23 +1,23 @@
import unittest
import mock
import time
import json
import time
import unittest
import mock
try:
from queue import Queue
except ImportError:
from Queue import Queue
from posthog.consumer import Consumer, MAX_MSG_SIZE
from posthog.consumer import MAX_MSG_SIZE, Consumer
from posthog.request import APIError
from posthog.test.utils import TEST_API_KEY
class TestConsumer(unittest.TestCase):
def test_next(self):
q = Queue()
consumer = Consumer(q, '')
consumer = Consumer(q, "")
q.put(1)
next = consumer.next()
self.assertEqual(next, [1])
@@ -25,7 +25,7 @@ class TestConsumer(unittest.TestCase):
def test_next_limit(self):
q = Queue()
flush_at = 50
consumer = Consumer(q, '', flush_at)
consumer = Consumer(q, "", flush_at)
for i in range(10000):
q.put(i)
next = consumer.next()
@@ -33,8 +33,8 @@ class TestConsumer(unittest.TestCase):
def test_dropping_oversize_msg(self):
q = Queue()
consumer = Consumer(q, '')
oversize_msg = {'m': 'x' * MAX_MSG_SIZE}
consumer = Consumer(q, "")
oversize_msg = {"m": "x" * MAX_MSG_SIZE}
q.put(oversize_msg)
next = consumer.next()
self.assertEqual(next, [])
@@ -43,11 +43,7 @@ class TestConsumer(unittest.TestCase):
def test_upload(self):
q = Queue()
consumer = Consumer(q, TEST_API_KEY)
track = {
'type': 'track',
'event': 'python event',
'distinct_id': 'distinct_id'
}
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
q.put(track)
success = consumer.upload()
self.assertTrue(success)
@@ -58,16 +54,11 @@ class TestConsumer(unittest.TestCase):
# The consumer should upload _n_ times.
q = Queue()
flush_interval = 0.3
consumer = Consumer(q, TEST_API_KEY, flush_at=10,
flush_interval=flush_interval)
with mock.patch('posthog.consumer.batch_post') as mock_post:
consumer = Consumer(q, TEST_API_KEY, flush_at=10, flush_interval=flush_interval)
with mock.patch("posthog.consumer.batch_post") as mock_post:
consumer.start()
for i in range(0, 3):
track = {
'type': 'track',
'event': 'python event %d' % i,
'distinct_id': 'distinct_id'
}
track = {"type": "track", "event": "python event %d" % i, "distinct_id": "distinct_id"}
q.put(track)
time.sleep(flush_interval * 1.1)
self.assertEqual(mock_post.call_count, 3)
@@ -78,45 +69,30 @@ class TestConsumer(unittest.TestCase):
q = Queue()
flush_interval = 0.5
flush_at = 10
consumer = Consumer(q, TEST_API_KEY, flush_at=flush_at,
flush_interval=flush_interval)
with mock.patch('posthog.consumer.batch_post') as mock_post:
consumer = Consumer(q, TEST_API_KEY, flush_at=flush_at, flush_interval=flush_interval)
with mock.patch("posthog.consumer.batch_post") as mock_post:
consumer.start()
for i in range(0, flush_at * 2):
track = {
'type': 'track',
'event': 'python event %d' % i,
'distinct_id': 'distinct_id'
}
track = {"type": "track", "event": "python event %d" % i, "distinct_id": "distinct_id"}
q.put(track)
time.sleep(flush_interval * 1.1)
self.assertEqual(mock_post.call_count, 2)
def test_request(self):
consumer = Consumer(None, TEST_API_KEY)
track = {
'type': 'track',
'event': 'python event',
'distinct_id': 'distinct_id'
}
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
consumer.request([track])
def _test_request_retry(self, consumer,
expected_exception, exception_count):
def _test_request_retry(self, consumer, expected_exception, exception_count):
def mock_post(*args, **kwargs):
mock_post.call_count += 1
if mock_post.call_count <= exception_count:
raise expected_exception
mock_post.call_count = 0
with mock.patch('posthog.consumer.batch_post',
mock.Mock(side_effect=mock_post)):
track = {
'type': 'track',
'event': 'python event',
'distinct_id': 'distinct_id'
}
with mock.patch("posthog.consumer.batch_post", mock.Mock(side_effect=mock_post)):
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
# request() should succeed if the number of exceptions raised is
# less than the retries paramater.
if exception_count <= consumer.retries:
@@ -131,38 +107,35 @@ class TestConsumer(unittest.TestCase):
self.assertEqual(exc, expected_exception)
else:
self.fail(
"request() should raise an exception if still failing "
"after %d retries" % consumer.retries)
"request() should raise an exception if still failing after %d retries" % consumer.retries
)
def test_request_retry(self):
# we should retry on general errors
consumer = Consumer(None, TEST_API_KEY)
self._test_request_retry(consumer, Exception('generic exception'), 2)
self._test_request_retry(consumer, Exception("generic exception"), 2)
# we should retry on server errors
consumer = Consumer(None, TEST_API_KEY)
self._test_request_retry(consumer, APIError(
500, 'Internal Server Error'), 2)
self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 2)
# we should retry on HTTP 429 errors
consumer = Consumer(None, TEST_API_KEY)
self._test_request_retry(consumer, APIError(
429, 'Too Many Requests'), 2)
self._test_request_retry(consumer, APIError(429, "Too Many Requests"), 2)
# we should NOT retry on other client errors
consumer = Consumer(None, TEST_API_KEY)
api_error = APIError(400, 'Client Errors')
api_error = APIError(400, "Client Errors")
try:
self._test_request_retry(consumer, api_error, 1)
except APIError:
pass
else:
self.fail('request() should not retry on client errors')
self.fail("request() should not retry on client errors")
# test for number of exceptions raise > retries value
consumer = Consumer(None, TEST_API_KEY, retries=3)
self._test_request_retry(consumer, APIError(
500, 'Internal Server Error'), 3)
self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 3)
def test_pause(self):
consumer = Consumer(None, TEST_API_KEY)
@@ -171,13 +144,8 @@ class TestConsumer(unittest.TestCase):
def test_max_batch_size(self):
q = Queue()
consumer = Consumer(
q, TEST_API_KEY, flush_at=100000, flush_interval=3)
track = {
'type': 'track',
'event': 'python event',
'distinct_id': 'distinct_id'
}
consumer = Consumer(q, TEST_API_KEY, flush_at=100000, flush_interval=3)
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
msg_size = len(json.dumps(track).encode())
# number of messages in a maximum-size batch
n_msgs = int(475000 / msg_size)
@@ -185,13 +153,10 @@ class TestConsumer(unittest.TestCase):
def mock_post_fn(_, data, **kwargs):
res = mock.Mock()
res.status_code = 200
self.assertTrue(len(data.encode()) < 500000,
'batch size (%d) exceeds 500KB limit'
% len(data.encode()))
self.assertTrue(len(data.encode()) < 500000, "batch size (%d) exceeds 500KB limit" % len(data.encode()))
return res
with mock.patch('posthog.request._session.post',
side_effect=mock_post_fn) as mock_post:
with mock.patch("posthog.request._session.post", side_effect=mock_post_fn) as mock_post:
consumer.start()
for _ in range(0, n_msgs + 2):
q.put(track)
+5 -6
View File
@@ -4,13 +4,12 @@ import posthog
class TestModule(unittest.TestCase):
def failed(self):
self.failed = True
def setUp(self):
self.failed = False
posthog.api_key = 'testsecret'
posthog.api_key = "testsecret"
posthog.on_error = self.failed
def test_no_api_key(self):
@@ -22,19 +21,19 @@ class TestModule(unittest.TestCase):
self.assertRaises(Exception, posthog.capture)
def test_track(self):
posthog.capture('distinct_id', 'python module event')
posthog.capture("distinct_id", "python module event")
posthog.flush()
def test_identify(self):
posthog.identify('distinct_id', {'email': 'user@email.com'})
posthog.identify("distinct_id", {"email": "user@email.com"})
posthog.flush()
def test_alias(self):
posthog.alias('previousId', 'distinct_id')
posthog.alias("previousId", "distinct_id")
posthog.flush()
def test_page(self):
posthog.page('distinct_id', 'https://posthog.com/contact')
posthog.page("distinct_id", "https://posthog.com/contact")
posthog.flush()
def test_flush(self):
+15 -25
View File
@@ -1,54 +1,44 @@
from datetime import datetime, date
import unittest
import json
import unittest
from datetime import date, datetime
import requests
from posthog.request import batch_post, DatetimeSerializer
from posthog.request import DatetimeSerializer, batch_post
from posthog.test.utils import TEST_API_KEY
class TestRequests(unittest.TestCase):
def test_valid_request(self):
res = batch_post(TEST_API_KEY, batch=[{
'distinct_id': 'distinct_id',
'event': 'python event',
'type': 'track'
}])
res = batch_post(TEST_API_KEY, batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}])
self.assertEqual(res.status_code, 200)
def test_invalid_request_error(self):
self.assertRaises(Exception, batch_post, 'testsecret',
'https://t.posthog.com', False, '[{]')
self.assertRaises(Exception, batch_post, "testsecret", "https://t.posthog.com", False, "[{]")
def test_invalid_host(self):
self.assertRaises(Exception, batch_post, 'testsecret',
't.posthog.com/', batch=[])
self.assertRaises(Exception, batch_post, "testsecret", "t.posthog.com/", batch=[])
def test_datetime_serialization(self):
data = {'created': datetime(2012, 3, 4, 5, 6, 7, 891011)}
data = {"created": datetime(2012, 3, 4, 5, 6, 7, 891011)}
result = json.dumps(data, cls=DatetimeSerializer)
self.assertEqual(result, '{"created": "2012-03-04T05:06:07.891011"}')
def test_date_serialization(self):
today = date.today()
data = {'created': today}
data = {"created": today}
result = json.dumps(data, cls=DatetimeSerializer)
expected = '{"created": "%s"}' % today.isoformat()
self.assertEqual(result, expected)
def test_should_not_timeout(self):
res = batch_post(TEST_API_KEY, batch=[{
'distinct_id': 'distinct_id',
'event': 'python event',
'type': 'track'
}], timeout=15)
res = batch_post(
TEST_API_KEY, batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=15
)
self.assertEqual(res.status_code, 200)
def test_should_timeout(self):
with self.assertRaises(requests.ReadTimeout):
batch_post('key', batch=[{
'distinct_id': 'distinct_id',
'event': 'python event',
'type': 'track'
}], timeout=0.0001)
batch_post(
"key", batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=0.0001
)
+28 -26
View File
@@ -1,16 +1,17 @@
import unittest
from datetime import date, datetime, timedelta
from decimal import Decimal
import unittest
from uuid import UUID
from dateutil.tz import tzutc
import six
from dateutil.tz import tzutc
from posthog import utils
TEST_API_KEY = 'kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4'
TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4"
class TestUtils(unittest.TestCase):
def test_timezone_utils(self):
now = datetime.now()
utcnow = datetime.now(tz=tzutc())
@@ -25,21 +26,21 @@ class TestUtils(unittest.TestCase):
def test_clean(self):
simple = {
'decimal': Decimal('0.142857'),
'unicode': six.u('woo'),
'date': datetime.now(),
'long': 200000000,
'integer': 1,
'float': 2.0,
'bool': True,
'str': 'woo',
'none': None
"decimal": Decimal("0.142857"),
"unicode": six.u("woo"),
"date": datetime.now(),
"long": 200000000,
"integer": 1,
"float": 2.0,
"bool": True,
"str": "woo",
"none": None,
}
complicated = {
'exception': Exception('This should show up'),
'timedelta': timedelta(microseconds=20),
'list': [1, 2, 3]
"exception": Exception("This should show up"),
"timedelta": timedelta(microseconds=20),
"list": [1, 2, 3],
}
combined = dict(simple.items())
@@ -50,10 +51,13 @@ class TestUtils(unittest.TestCase):
utils.clean(combined)
self.assertEqual(combined.keys(), pre_clean_keys)
# test UUID separately, as the UUID object doesn't equal its string representation according to Python
self.assertEqual(utils.clean(UUID("12345678123456781234567812345678")), "12345678-1234-5678-1234-567812345678")
def test_clean_with_dates(self):
dict_with_dates = {
'birthdate': date(1980, 1, 1),
'registration': datetime.utcnow(),
"birthdate": date(1980, 1, 1),
"registration": datetime.utcnow(),
}
self.assertEqual(dict_with_dates, utils.clean(dict_with_dates))
@@ -66,14 +70,12 @@ class TestUtils(unittest.TestCase):
utils.clean(item)
def test_clean_fn(self):
cleaned = utils.clean({'fn': lambda x: x, 'number': 4})
self.assertEqual(cleaned['number'], 4)
cleaned = utils.clean({"fn": lambda x: x, "number": 4})
self.assertEqual(cleaned["number"], 4)
# TODO: fixme, different behavior on python 2 and 3
if 'fn' in cleaned:
self.assertEqual(cleaned['fn'], None)
if "fn" in cleaned:
self.assertEqual(cleaned["fn"], None)
def test_remove_slash(self):
self.assertEqual('http://posthog.io',
utils.remove_trailing_slash('http://posthog.io/'))
self.assertEqual('http://posthog.io',
utils.remove_trailing_slash('http://posthog.io'))
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io/"))
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io"))
+15 -13
View File
@@ -1,12 +1,13 @@
from dateutil.tz import tzlocal, tzutc
from datetime import date, datetime
from decimal import Decimal
import logging
import numbers
from datetime import date, datetime
from decimal import Decimal
from uuid import UUID
import six
from dateutil.tz import tzlocal, tzutc
log = logging.getLogger('posthog')
log = logging.getLogger("posthog")
def is_naive(dt):
@@ -17,8 +18,7 @@ def is_naive(dt):
def total_seconds(delta):
"""Determines total seconds with python < 2.7 compat."""
# http://stackoverflow.com/questions/3694835/python-2-6-5-divide-timedelta-with-timedelta
return (delta.microseconds
+ (delta.seconds + delta.days * 24 * 3600) * 1e6) / 1e6
return (delta.microseconds + (delta.seconds + delta.days * 24 * 3600) * 1e6) / 1e6
def guess_timezone(dt):
@@ -39,7 +39,7 @@ def guess_timezone(dt):
def remove_trailing_slash(host):
if host.endswith('/'):
if host.endswith("/"):
return host[:-1]
return host
@@ -47,8 +47,9 @@ def remove_trailing_slash(host):
def clean(item):
if isinstance(item, Decimal):
return float(item)
elif isinstance(item, (six.string_types, bool, numbers.Number, datetime,
date, type(None))):
if isinstance(item, UUID):
return str(item)
elif isinstance(item, (six.string_types, bool, numbers.Number, datetime, date, type(None))):
return item
elif isinstance(item, (set, list, tuple)):
return _clean_list(item)
@@ -69,9 +70,10 @@ def _clean_dict(dict_):
data[k] = clean(v)
except TypeError:
log.warning(
'Dictionary values must be serializeable to '
'JSON "%s" value %s of type %s is unsupported.',
k, v, type(v),
'Dictionary values must be serializeable to JSON "%s" value %s of type %s is unsupported.',
k,
v,
type(v),
)
return data
@@ -82,6 +84,6 @@ def _coerce_unicode(cmplx):
except AttributeError as exception:
item = ":".join(exception)
item.decode("utf-8", "strict")
log.warning('Error decoding: %s', item)
log.warning("Error decoding: %s", item)
return None
return item
+1 -1
View File
@@ -1 +1 @@
VERSION = '1.1.3'
VERSION = "1.2.1"
+10
View File
@@ -0,0 +1,10 @@
[tool.black]
line-length = 120
[tool.isort]
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 8
ensure_newline_before_comments = true
line_length = 120
virtual_env = "env"
+24 -25
View File
@@ -1,4 +1,3 @@
import os
import sys
@@ -8,40 +7,40 @@ except ImportError:
from distutils.core import setup
# Don't import analytics-python module here, since deps may not be installed
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'posthog'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthog"))
from version import VERSION
long_description = '''
long_description = """
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
'''
"""
install_requires = [
"requests>=2.7,<3.0",
"six>=1.5",
"monotonic>=1.5",
"backoff==1.6.0",
"python-dateutil>2.1"
]
install_requires = ["requests>=2.7,<3.0", "six>=1.5", "monotonic>=1.5", "backoff==1.6.0", "python-dateutil>2.1"]
tests_require = [
"mock>=2.0.0",
"freezegun==0.3.15"
]
extras_require = {
"dev": [
"black",
"isort",
"pre-commit",
]
}
tests_require = ["mock>=2.0.0", "freezegun==0.3.15"]
setup(
name='posthog',
name="posthog",
version=VERSION,
url='https://github.com/posthog/posthog-python',
author='Posthog',
author_email='hey@posthog.com',
maintainer='PostHog',
maintainer_email='hey@posthog.com',
test_suite='posthog.test.all',
packages=['posthog', 'posthog.test'],
license='MIT License',
url="https://github.com/posthog/posthog-python",
author="Posthog",
author_email="hey@posthog.com",
maintainer="PostHog",
maintainer_email="hey@posthog.com",
test_suite="posthog.test.all",
packages=["posthog", "posthog.test"],
license="MIT License",
install_requires=install_requires,
extras_require=extras_require,
tests_require=tests_require,
description='Integrate PostHog into any python application.',
description="Integrate PostHog into any python application.",
long_description=long_description,
classifiers=[
"Development Status :: 5 - Production/Stable",
+15 -24
View File
@@ -1,4 +1,3 @@
import os
import sys
@@ -8,39 +7,31 @@ except ImportError:
from distutils.core import setup
# Don't import module here, since deps may not be installed
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'posthoganalytics'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthoganalytics"))
from version import VERSION
long_description = '''
long_description = """
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
'''
"""
install_requires = [
"requests>=2.7,<3.0",
"six>=1.5",
"monotonic>=1.5",
"backoff==1.6.0",
"python-dateutil>2.1"
]
install_requires = ["requests>=2.7,<3.0", "six>=1.5", "monotonic>=1.5", "backoff==1.6.0", "python-dateutil>2.1"]
tests_require = [
"mock>=2.0.0"
]
tests_require = ["mock>=2.0.0"]
setup(
name='posthoganalytics',
name="posthoganalytics",
version=VERSION,
url='https://github.com/posthog/posthog-python',
author='Posthog',
author_email='hey@posthog.com',
maintainer='PostHog',
maintainer_email='hey@posthog.com',
test_suite='posthoganalytics.test.all',
packages=['posthoganalytics', 'posthoganalytics.test'],
license='MIT License',
url="https://github.com/posthog/posthog-python",
author="Posthog",
author_email="hey@posthog.com",
maintainer="PostHog",
maintainer_email="hey@posthog.com",
test_suite="posthoganalytics.test.all",
packages=["posthoganalytics", "posthoganalytics.test"],
license="MIT License",
install_requires=install_requires,
tests_require=tests_require,
description='Integrate PostHog into any python application.',
description="Integrate PostHog into any python application.",
long_description=long_description,
classifiers=[
"Development Status :: 5 - Production/Stable",
+39 -32
View File
@@ -1,42 +1,39 @@
import posthog
import argparse
import json
import logging
__name__ = 'simulator.py'
__version__ = '0.0.1'
__description__ = 'scripting simulator'
import posthog
__name__ = "simulator.py"
__version__ = "0.0.1"
__description__ = "scripting simulator"
def json_hash(str):
if str:
return json.loads(str)
# posthog -method=<method> -posthog-write-key=<posthogWriteKey> [options]
parser = argparse.ArgumentParser(description='send a posthog message')
parser = argparse.ArgumentParser(description="send a posthog message")
parser.add_argument('--writeKey', help='the posthog writeKey')
parser.add_argument('--type', help='The posthog message type')
parser.add_argument("--writeKey", help="the posthog writeKey")
parser.add_argument("--type", help="The posthog message type")
parser.add_argument('--distinct_id', help='the user id to send the event as')
parser.add_argument(
'--anonymousId', help='the anonymous user id to send the event as')
parser.add_argument(
'--context', help='additional context for the event (JSON-encoded)')
parser.add_argument("--distinct_id", help="the user id to send the event as")
parser.add_argument("--anonymousId", help="the anonymous user id to send the event as")
parser.add_argument("--context", help="additional context for the event (JSON-encoded)")
parser.add_argument('--event', help='the event name to send with the event')
parser.add_argument(
'--properties', help='the event properties to send (JSON-encoded)')
parser.add_argument("--event", help="the event name to send with the event")
parser.add_argument("--properties", help="the event properties to send (JSON-encoded)")
parser.add_argument(
'--name', help='name of the screen or page to send with the message')
parser.add_argument("--name", help="name of the screen or page to send with the message")
parser.add_argument(
'--traits', help='the identify/group traits to send (JSON-encoded)')
parser.add_argument("--traits", help="the identify/group traits to send (JSON-encoded)")
parser.add_argument('--groupId', help='the group id')
parser.add_argument("--groupId", help="the group id")
options = parser.parse_args()
@@ -46,18 +43,32 @@ def failed(status, msg):
def capture():
posthog.capture(options.distinct_id, options.event, anonymous_id=options.anonymousId,
properties=json_hash(options.properties), context=json_hash(options.context))
posthog.capture(
options.distinct_id,
options.event,
anonymous_id=options.anonymousId,
properties=json_hash(options.properties),
context=json_hash(options.context),
)
def page():
posthog.page(options.distinct_id, name=options.name, anonymous_id=options.anonymousId,
properties=json_hash(options.properties), context=json_hash(options.context))
posthog.page(
options.distinct_id,
name=options.name,
anonymous_id=options.anonymousId,
properties=json_hash(options.properties),
context=json_hash(options.context),
)
def identify():
posthog.identify(options.distinct_id, anonymous_id=options.anonymousId,
traits=json_hash(options.traits), context=json_hash(options.context))
posthog.identify(
options.distinct_id,
anonymous_id=options.anonymousId,
traits=json_hash(options.traits),
context=json_hash(options.context),
)
def unknown():
@@ -68,16 +79,12 @@ posthog.api_key = options.writeKey
posthog.on_error = failed
posthog.debug = True
log = logging.getLogger('posthog')
log = logging.getLogger("posthog")
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
log.addHandler(ch)
switcher = {
"capture": capture,
"page": page,
"identify": identify
}
switcher = {"capture": capture, "page": page, "identify": identify}
func = switcher.get(options.type)
if func: