Skip to content

Commit

Permalink
Base implementation (#1)
Browse files Browse the repository at this point in the history
Base implementation of Async CustomerIO client.
  • Loading branch information
akalex authored Dec 9, 2022
1 parent cdae3fc commit 7ce0ed4
Show file tree
Hide file tree
Showing 24 changed files with 2,222 additions and 3 deletions.
24 changes: 24 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[run]
source =
./async_customerio
omit =
# omit tests
*/tests/*
*/__init__.py

[report]
# Regexes for lines to exclude from consideration
exclude_lines =
# Don't complain if tests don't hit defensive assertion code:
raise AssertionError
raise NotImplementedError

pragma: no cover
Should not reach here
def __repr__
raise NotImplementedError
except ImportError

# Don't complain if non-runnable code isn't run:
if 0:
if __name__ == .__main__.:
3 changes: 3 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[flake8]
max-line-length = 120
max-complexity = 10
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ jobs:
custom_cache_key_element: v1.2

- name: Static type checker
run: mypy --no-error-summary --hide-error-codes --follow-imports=skip async_customerio
run: |
python -m pip install types-setuptools
mypy --install-types --no-error-summary --hide-error-codes --follow-imports=skip async_customerio
test:
needs: [ linters-black, linters-mypy ]
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,5 @@ dmypy.json

# Pyre type checker
.pyre/

.idea
4 changes: 4 additions & 0 deletions .mypy.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[mypy]
python_version = 3.8
follow_imports = skip
ignore_missing_imports = True
38 changes: 38 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
exclude: '^tests'
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0
hooks:
# forgotten debugger imports like pdb
- id: debug-statements
# merge cruft like '<<<<<<< '
- id: check-merge-conflict
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml

- repo: https://github.com/psf/black
rev: 22.3.0
hooks:
- id: black
language_version: python3.8
args: [--line-length=120, --skip-string-normalization]

- repo: https://gitlab.com/pycqa/flake8
rev: 4.0.1
hooks:
- id: flake8

- repo: https://github.com/pycqa/isort
rev: 5.10.1
hooks:
- id: isort
stages: [commit]

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.950
hooks:
- id: mypy
args: [--no-error-summary, --hide-error-codes, --follow-imports=skip]
files: ^async_customerio/
additional_dependencies: [types-setuptools]
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# Changelog

## 0.1.0

* First release on PyPI.
83 changes: 83 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
BOLD := \033[1m
RESET := \033[0m

.DEFAULT: help

.PHONY: help
help:
@echo "$(BOLD)CLI$(RESET)"
@echo ""
@echo "$(BOLD)make install$(RESET)"
@echo " install all requirements"
@echo ""
@echo "$(BOLD)make update$(RESET)"
@echo " update all requirements"
@echo ""
@echo "$(BOLD)make setup_dev$(RESET)"
@echo " install all requirements and setup for development"
@echo ""
@echo "$(BOLD)make test$(RESET)"
@echo " run tests"
@echo ""
@echo "$(BOLD)make mypy$(RESET)"
@echo " run static type checker (mypy)"
@echo ""
@echo "$(BOLD)make clean$(RESET)"
@echo " clean trash like *.pyc files"
@echo ""
@echo "$(BOLD)make install_pre_commit$(RESET)"
@echo " install pre_commit hook for git, "
@echo " so that linters will check up code before every commit"
@echo ""
@echo "$(BOLD)make pre_commit$(RESET)"
@echo " run linters check up"
@echo ""

.PHONY: install
install:
@echo "$(BOLD)Installing package$(RESET)"
@poetry config virtualenvs.create false
@poetry install --only main
@echo "$(BOLD)Done!$(RESET)"

.PHONY: update
update:
@echo "$(BOLD)Updating package and dependencies$(RESET)"
@poetry update
@echo "$(BOLD)Done!$(RESET)"

.PHONY: setup_dev
setup_dev:
@echo "$(BOLD)DEV setup$(RESET)"
@poetry install --no-root
@echo "$(BOLD)Done!$(RESET)"

.PHONY: clean
clean:
@echo "$(BOLD)Cleaning up repository$(RESET)"
@find . -name \*.pyc -delete
@echo "$(BOLD)Done!$(RESET)"

.PHONY: test
test: setup_dev
@echo "$(BOLD)Running tests$(RESET)"
@poetry run pytest --maxfail=2 ${ARGS}
@echo "$(BOLD)Done!$(RESET)"

.PHONY: mypy
mypy: setup_dev
@echo "$(BOLD)Running static type checker (mypy)$(RESET)"
@poetry run mypy --no-error-summary --hide-error-codes --follow-imports=skip async_customerio
@echo "$(BOLD)Done!$(RESET)"

.PHONY: install_pre_commit
install_pre_commit:
@echo "$(BOLD)Add pre-commit hook for git$(RESET)"
@pre-commit install
@echo "$(BOLD)Done!$(RESET)"

.PHONY: pre_commit
pre_commit:
@echo "$(BOLD)Run pre-commit$(RESET)"
@pre-commit run -a
@echo "$(BOLD)Done!$(RESET)"
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,34 @@
# async-customerio
The lightweight client to interaction with CustomerIO in async fashion.
# async-customerio is a lightweight asynchronous client to interact with CustomerIO

[![PyPI download total](https://img.shields.io/pypi/dt/async-customerio.svg)](https://pypi.python.org/pypi/async-customerio/)
[![PyPI download month](https://img.shields.io/pypi/dm/async-customerio.svg)](https://pypi.python.org/pypi/async-customerio/)
[![PyPI version fury.io](https://badge.fury.io/py/async-customerio.svg)](https://pypi.python.org/pypi/async-customerio/)
[![PyPI license](https://img.shields.io/pypi/l/async-customerio.svg)](https://pypi.python.org/pypi/async-customerio/)
[![PyPI pyversions](https://img.shields.io/pypi/pyversions/async-customerio.svg)](https://pypi.python.org/pypi/async-customerio/)
[![GitHub Workflow Status for CI](https://img.shields.io/github/workflow/status/healthjoy/async-customerio/CI?label=CI&logo=github)](https://github.com/healthjoy/async-customerio/actions?query=workflow%3ACI)
[![Codacy coverage](https://img.shields.io/codacy/coverage/b6a59cdf5ca64eab9104928d4f9bbb97?logo=codacy)](https://app.codacy.com/gh/healthjoy/async-customerio/dashboard)


* Free software: MIT license
* Requires: Python 3.7+

## Features

*

## Installation
```shell script
$ pip install async-customerio
```

## Getting started
TBD...

## License

``async-customerio`` is offered under the MIT license.

## Source code

The latest developer version is available in a GitHub repository:
[https://github.com/healthjoy/async-customerio](https://github.com/healthjoy/async-customerio)
11 changes: 11 additions & 0 deletions async_customerio/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import logging

from async_customerio.api import AsyncAPIClient, SendEmailRequest # noqa
from async_customerio.errors import AsyncCustomerIOError # noqa
from async_customerio.regions import Regions # noqa
from async_customerio.track import AsyncCustomerIO # noqa


root_logger = logging.getLogger("async_customerio")
if root_logger.level == logging.NOTSET:
root_logger.setLevel(logging.WARN)
134 changes: 134 additions & 0 deletions async_customerio/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""
Implements the client that interacts with Customer.io"s App API using app keys.
"""
import base64
import typing as t

from async_customerio.client_base import AsyncClientBase
from async_customerio.errors import AsyncCustomerIOError
from async_customerio.regions import Region, Regions
from async_customerio.utils import join_url


class SendEmailRequest:
"""An object with all the options available for triggering a transactional message"""

def __init__(
self,
transactional_message_id: t.Union[str, int] = None,
to: str = None,
identifiers=None,
_from: str = None,
headers=None,
reply_to: str = None,
bcc=None,
subject: str = None,
preheader=None,
body=None,
plaintext_body: str = None,
amp_body=None,
fake_bcc=None,
disable_message_retention: bool = None,
send_to_unsubscribed: bool = None,
tracked: bool = None,
queue_draft=None,
message_data=None,
attachments: t.Dict[str, str] = None,
):

self.transactional_message_id = transactional_message_id
self.to = to
self.identifiers = identifiers
self._from = _from
self.headers = headers
self.reply_to = reply_to
self.bcc = bcc
self.subject = subject
self.preheader = preheader
self.body = body
self.plaintext_body = plaintext_body
self.amp_body = amp_body
self.fake_bcc = fake_bcc
self.disable_message_retention = disable_message_retention
self.send_to_unsubscribed = send_to_unsubscribed
self.tracked = tracked
self.queue_draft = queue_draft
self.message_data = message_data
self.attachments = attachments

def attach(self, name: str, content: str, encode: bool = True) -> None:
"""Helper method to add base64 encode the attachments"""
if not self.attachments:
self.attachments = {}

if self.attachments.get(name, None):
raise AsyncCustomerIOError("attachment {name} already exists".format(name=name))

if encode:
if isinstance(content, str):
content = base64.b64encode(content.encode("utf-8")).decode()
else:
content = base64.b64encode(content).decode()

self.attachments[name] = content

def to_dict(self):
"""Build a request payload from the object"""
field_map = dict(
# `from` is reserved keyword hence the object has the field
# `_from` but in the request payload we map it to `from`
_from="from",
# field name is the same as the payload field name
transactional_message_id="transactional_message_id",
to="to",
identifiers="identifiers",
headers="headers",
reply_to="reply_to",
bcc="bcc",
subject="subject",
preheader="preheader",
body="body",
plaintext_body="plaintext_body",
amp_body="amp_body",
fake_bcc="fake_bcc",
disable_message_retention="disable_message_retention",
send_to_unsubscribed="send_to_unsubscribed",
tracked="tracked",
queue_draft="queue_draft",
message_data="message_data",
attachments="attachments",
)

data = {}
for field, name in field_map.items():
value = getattr(self, field, None)
if value is not None:
data[name] = value

return data


class AsyncAPIClient(AsyncClientBase):
API_PREFIX = "/v1"
SEND_EMAIL_ENDPOINT = "/send/email"

def __init__(
self, key: str, url: t.Optional[str] = None, region: Region = Regions.US, retries: int = 3, timeout: int = 10
):
if not isinstance(region, Region):
raise AsyncCustomerIOError("invalid region provided")

self.key = key
self.base_url = url or "https://{host}".format(host=region.api_host)
super().__init__(retries=retries, timeout=timeout)

async def send_email(self, request: SendEmailRequest) -> dict:
if not isinstance(request, SendEmailRequest):
raise AsyncCustomerIOError("invalid request provided")

return await self.send_request(
"POST",
join_url(self.base_url, self.API_PREFIX, self.SEND_EMAIL_ENDPOINT),
json_payload=request.to_dict(),
headers={"Authorization": "Bearer {key}".format(key=self.key)},
)
Loading

0 comments on commit 7ce0ed4

Please sign in to comment.