├── tests ├── core │ ├── __init__.py │ └── test_airship.py ├── push │ ├── __init__.py │ └── test_schedule.py ├── automation │ └── __init__.py ├── client │ ├── __init__.py │ ├── test_urls.py │ ├── test_basic_auth_client.py │ ├── test_token_client.py │ ├── test_oauth_client.py │ └── test_response.py ├── devices │ ├── __init__.py │ ├── test_channel_uninstall.py │ ├── test_subscription_list.py │ ├── test_segments.py │ ├── test_tags_lists.py │ ├── test_named_user.py │ ├── test_tags.py │ ├── test_attributes.py │ └── test_open_channel.py ├── reports │ ├── __init__.py │ └── test_experiments_reports.py ├── custom_events │ ├── __init__.py │ └── test_custom_events.py ├── experiments │ ├── __init__.py │ └── test_experiment.py ├── __init__.py └── data │ ├── logo.png │ ├── tag_list.csv │ └── attribute_list.csv ├── urbanairship ├── py.typed ├── __about__.py ├── custom_events │ ├── __init__.py │ └── custom_events.py ├── automation │ ├── __init__.py │ └── core.py ├── experiments │ ├── __init__.py │ ├── variant.py │ ├── core.py │ └── experiment.py ├── enums.py ├── reports │ ├── __init__.py │ ├── experiments.py │ └── reports.py ├── devices │ ├── __init__.py │ ├── channel_uninstall.py │ ├── subscription_lists.py │ ├── segment.py │ ├── tag_lists.py │ ├── open_channel.py │ ├── static_lists.py │ └── tag.py ├── push │ ├── __init__.py │ ├── schedule.py │ └── audience.py ├── urls.py ├── common.py ├── __init__.py └── core.py ├── .codespell-ignore ├── MANIFEST.in ├── docs ├── custom_events.rst ├── devices.rst ├── reports.rst ├── index.rst ├── audience.rst ├── client.rst ├── push.rst └── Makefile ├── AUTHORS ├── requirements.txt ├── .gitignore ├── .github ├── SUPPORT.md ├── CONTRIBUTING.md ├── workflows │ ├── test_runner.yaml │ ├── pypi_test_upload.yaml │ ├── release_build_push.yaml │ └── docs_release.yaml ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE.md ├── requirements-dev.txt ├── LICENSE ├── README.rst ├── .pre-commit-config.yaml ├── setup.py └── pyproject.toml /tests/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/push/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /urbanairship/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/automation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/client/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/devices/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/reports/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/custom_events/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/experiments/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.codespell-ignore: -------------------------------------------------------------------------------- 1 | caf 2 | optins 3 | -------------------------------------------------------------------------------- /urbanairship/__about__.py: -------------------------------------------------------------------------------- 1 | __version__ = "7.3.1" 2 | -------------------------------------------------------------------------------- /urbanairship/custom_events/__init__.py: -------------------------------------------------------------------------------- 1 | from .custom_events import CustomEvent 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | include AUTHORS 4 | include CHANGELOG 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | TEST_KEY = "x" * 22 2 | TEST_SECRET = "y" * 22 3 | TEST_TOKEN = "z" * 92 4 | -------------------------------------------------------------------------------- /tests/data/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urbanairship/python-library/HEAD/tests/data/logo.png -------------------------------------------------------------------------------- /urbanairship/automation/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import Automation 2 | from .pipeline import Pipeline 3 | -------------------------------------------------------------------------------- /urbanairship/experiments/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import ABTest 2 | from .experiment import Experiment 3 | from .variant import Variant 4 | -------------------------------------------------------------------------------- /docs/custom_events.rst: -------------------------------------------------------------------------------- 1 | Custom Events 2 | ************* 3 | 4 | .. autoclass:: urbanairship.custom_events.custom_events.CustomEvent 5 | :members: 6 | -------------------------------------------------------------------------------- /tests/data/tag_list.csv: -------------------------------------------------------------------------------- 1 | channel_id 2 | c543f3a3-bc1d-4830-8dee-7532c6a23b9a 3 | 6ba360a0-1f73-4ee7-861e-95f6c1ed6410 4 | 15410d17-687c-46fa-bbd9-f255741a1523 5 | c2c64ef7-8f5c-470e-915f-f5e3da04e1df 6 | -------------------------------------------------------------------------------- /urbanairship/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class LiveActivityEvent(Enum): 5 | UPDATE = "update" 6 | END = "end" 7 | 8 | 9 | class LiveUpdateEvent(Enum): 10 | START = "start" 11 | UPDATE = "update" 12 | END = "end" 13 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Thanks to the following for patches, issues, assistance, and suggestions: 2 | 3 | Jim Lyndon 4 | Matt Good 5 | Caio Iglesias 6 | Jessamyn Smith 7 | Rodrigo Dias Arruda Senra 8 | -------------------------------------------------------------------------------- /tests/data/attribute_list.csv: -------------------------------------------------------------------------------- 1 | channel_id,MISC_02,storeName 2 | c543f3a3-bc1d-4830-8dee-7532c6a23b9a,100,Basketball 3 | 6ba360a0-1f73-4ee7-861e-95f6c1ed6410,,Basketball 4 | 15410d17-687c-46fa-bbd9-f255741a1523,2,Football 5 | c2c64ef7-8f5c-470e-915f-f5e3da04e1df,22.1,Rugby 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.32.3 2 | backoff==2.2.1 3 | six==1.16.0 4 | pyjwt==2.8.0 5 | cryptography==42.0.5 6 | 7 | # Type stubs 8 | types-requests>=2.31.0 9 | types-six>=1.16.21 10 | types-mock>=5.1.0 11 | 12 | # Development dependencies 13 | mypy>=1.9.0 14 | mypy-extensions>=1.0.0 15 | build>=1.1.1 16 | twine>=5.0.0 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /MANIFEST 3 | *pyc 4 | /docs/_build 5 | .idea/ 6 | *.iml 7 | urbanairship.egg-info/ 8 | .DS_Store 9 | /build 10 | .tox/ 11 | .vscode/ 12 | test_data.csv 13 | .pytest_cache/ 14 | nosetests.xml 15 | .noseids 16 | /mypython 17 | ipynb/ 18 | .coverage 19 | .mypy_cache/ 20 | # Ignoring venv stuff 21 | bin/ 22 | lib/ 23 | man/ 24 | pyvenv.cfg 25 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support Requests 2 | 3 | All requests for support including implementation support and feature requests are made to the Airship support team. 4 | 5 | You can contact them by visiting https://support.airship.com/ 6 | 7 | # Documentation 8 | 9 | Documentation for the library can be found here: 10 | https://docs.airship.com/api/libraries/python/ 11 | -------------------------------------------------------------------------------- /urbanairship/reports/__init__.py: -------------------------------------------------------------------------------- 1 | from .experiments import ExperimentReport 2 | from .reports import ( 3 | AppOpensList, 4 | CustomEventsList, 5 | DevicesReport, 6 | IndividualResponseStats, 7 | OptInList, 8 | OptOutList, 9 | PushList, 10 | ResponseList, 11 | ResponseReportList, 12 | TimeInAppList, 13 | WebResponseReport, 14 | ) 15 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | # Testing 4 | pytest>=7.0.0 5 | pytest-cov>=4.0.0 6 | mock>=5.1.0 7 | tox>=4.14.0 8 | 9 | # Linting and Formatting 10 | black>=24.2.0 11 | isort>=5.13.0 12 | flake8>=7.0.0 13 | flake8-bugbear>=24.2.6 # Additional bug checks for flake8 14 | 15 | # Type checking 16 | mypy>=1.9.0 17 | 18 | # Documentation 19 | sphinx-rtd-theme>=2.0.0 20 | sphinx>=7.2.0 21 | 22 | # Development tools 23 | pre-commit>=3.6.0 24 | build>=1.1.1 # For building packages 25 | twine>=5.0.0 # For uploading to PyPI 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | © Copyright 2009-2022, Airship Group, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /urbanairship/devices/__init__.py: -------------------------------------------------------------------------------- 1 | from .attributes import Attribute, AttributeList, AttributeResponse, ModifyAttributes 2 | from .channel_uninstall import ChannelUninstall 3 | from .devicelist import APIDList, ChannelInfo, ChannelList, DeviceInfo, DeviceTokenList 4 | from .email import Email, EmailAttachment, EmailTags 5 | from .named_users import NamedUser, NamedUserList, NamedUserTags 6 | from .open_channel import OpenChannel 7 | from .segment import Segment, SegmentList 8 | from .sms import KeywordInteraction, Sms, SmsCustomResponse 9 | from .static_lists import StaticList, StaticLists 10 | from .subscription_lists import SubscriptionList 11 | from .tag import ChannelTags, OpenChannelTags 12 | from .tag_lists import TagList 13 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Agreement 2 | 3 | ## What is the Contributor License Agreement, and what does it mean? 4 | 5 | The Contributor License Agreement (CLA) below is to ensure that when someone contributes code to one of our open source libraries that we have a clear record of the license to use it. This is necessary so that we can ensure to all of our customers that they can make use of our libraries and tools without worry. You retain copyright of all of your code. 6 | 7 | Please read through the agreement at the URL below. 8 | 9 | If you have questions about this agreement or why we need it please contact us at https://support.airship.com/. 10 | 11 | [Contribution Agreement](https://docs.google.com/forms/d/e/1FAIpQLScErfiz-fXSPpVZ9r8Di2Tr2xDFxt5MgzUel0__9vqUgvko7Q/viewform) 12 | -------------------------------------------------------------------------------- /docs/devices.rst: -------------------------------------------------------------------------------- 1 | Devices 2 | ********* 3 | 4 | Create, look up, and list several supported device types. Note that some device types must be registered from the mobile or web SDKs. 5 | 6 | Channels 7 | ========== 8 | 9 | .. automodule:: urbanairship.devices.devicelist 10 | :members: 11 | :exclude-members: instance_class, from_payload 12 | 13 | 14 | Channel Uninstall 15 | ================== 16 | 17 | .. automodule:: urbanairship.devices.channel_uninstall 18 | :members: ChannelUninstall 19 | 20 | Open Channels 21 | ============= 22 | 23 | .. automodule:: urbanairship.devices.open_channel 24 | :members: 25 | 26 | SMS 27 | ========= 28 | 29 | .. automodule:: urbanairship.devices.sms 30 | :members: 31 | 32 | Email 33 | ======== 34 | 35 | .. automodule:: urbanairship.devices.email 36 | :members: 37 | -------------------------------------------------------------------------------- /tests/devices/test_channel_uninstall.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | import mock 5 | import requests 6 | 7 | import urbanairship as ua 8 | from tests import TEST_KEY, TEST_SECRET 9 | 10 | 11 | class TestChannelUninstall(unittest.TestCase): 12 | def test_channel_uninstall(self): 13 | with mock.patch.object(ua.Airship, "_request") as mock_request: 14 | response = requests.Response() 15 | response._content = json.dumps({"ok": True}) 16 | mock_request.return_value = response 17 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 18 | 19 | cu = ua.ChannelUninstall(airship) 20 | 21 | chans = [ 22 | { 23 | "channel_id": "01000001-01010000-01010000-01001100", 24 | "device_type": "ios", 25 | } 26 | ] 27 | 28 | cu_res = json.loads(cu.uninstall(chans).content) 29 | 30 | self.assertEqual(cu_res["ok"], True) 31 | -------------------------------------------------------------------------------- /.github/workflows/test_runner.yaml: -------------------------------------------------------------------------------- 1 | name: Python Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.10", "3.11", "3.12", "3.13"] 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | python -m pip install -r requirements-dev.txt 22 | - name: Type check with mypy 23 | run: | 24 | mypy --config-file=pyproject.toml --ignore-missing-imports --disable-error-code=no-any-return urbanairship/ 25 | - name: Test with pytest 26 | run: | 27 | python -m pytest 28 | - name: Build package 29 | run: | 30 | python -m pip install build twine 31 | python -m build 32 | twine check dist/* 33 | -------------------------------------------------------------------------------- /.github/workflows/pypi_test_upload.yaml: -------------------------------------------------------------------------------- 1 | name: PyPI Test Build and Upload 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | id-token: write 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | token: ${{ secrets.GITHUB_TOKEN }} 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.13" 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | python -m pip install build twine 25 | - name: Build binary wheel and source tarball 26 | run: | 27 | python -m build 28 | twine check dist/* 29 | - name: Publish to Test PyPI 30 | if: ${{github.repository == 'urbanairship/python-library-dev'}} 31 | uses: pypa/gh-action-pypi-publish@release/v1 32 | with: 33 | password: ${{ secrets.PYPI_TEST_TOKEN }} 34 | repository-url: https://test.pypi.org/legacy/ 35 | -------------------------------------------------------------------------------- /.github/workflows/release_build_push.yaml: -------------------------------------------------------------------------------- 1 | name: Release Publish and Upload 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | libVersion: 7 | description: Library Version 8 | required: true 9 | default: x.x.x 10 | type: string 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | pages: write 17 | actions: read 18 | id-token: write 19 | contents: read 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: "3.13" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install build twine 30 | - name: Build binary wheel and source tarball 31 | run: | 32 | python -m build 33 | twine check dist/* 34 | - name: Publish to PyPI 35 | if: ${{github.repository == 'urbanairship/python-library-dev'}} 36 | uses: pypa/gh-action-pypi-publish@release/v1 37 | with: 38 | password: ${{ secrets.PYPI_TOKEN_URBANAIRSHIP }} 39 | -------------------------------------------------------------------------------- /docs/reports.rst: -------------------------------------------------------------------------------- 1 | Reports 2 | ******* 3 | 4 | Examples can be found in `the reports documentation here. `_ 5 | 6 | .. note:: 7 | ``precision`` needs to be one of 'HOURLY', 'DAILY', or 'MONTHLY'. 8 | ``start_date`` and ``end_date`` must be ``datetime.datetime`` objects. 9 | 10 | .. autoclass:: urbanairship.reports.IndividualResponseStats 11 | :members: 12 | 13 | .. autoclass:: urbanairship.reports.ResponseList 14 | :members: 15 | 16 | .. autoclass:: urbanairship.reports.DevicesReport 17 | :members: 18 | 19 | .. autoclass:: urbanairship.reports.OptInList 20 | :members: 21 | 22 | .. autoclass:: urbanairship.reports.OptOutList 23 | :members: 24 | 25 | .. autoclass:: urbanairship.reports.PushList 26 | :members: 27 | 28 | .. autoclass:: urbanairship.reports.ResponseReportList 29 | :members: 30 | 31 | .. autoclass:: urbanairship.reports.AppOpensList 32 | :members: 33 | 34 | .. autoclass:: urbanairship.reports.TimeInAppList 35 | :members: 36 | 37 | .. autoclass:: urbanairship.reports.CustomEventsList 38 | :members: 39 | 40 | .. autoclass:: urbanairship.reports.WebResponseReport 41 | :members: 42 | 43 | .. autoclass:: urbanairship.reports.experiments.ExperimentReport 44 | :members: 45 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **If you want your PR addressed quickly, please also reach out to our [support team](https://support.airship.com/) 2 | so we can understand when you need it reviewed and how it is impacting your use of our services.** We also generally 3 | will not release new versions of our library without new feature support, a bug fix, or a clear reason from a customer 4 | why an update is required to minimize how often other customers need to update. 5 | 6 | ### What does this do and why? 7 | Please include link to open issue if applicable. 8 | 9 | ### Additional notes for reviewers 10 | * If applicable, include any information that provides context for these changes. 11 | 12 | ### Testing 13 | - [ ] If these changes added new functionality, I tested them against the live API with real auth 14 | - [ ] I wrote tests covering these changes 15 | 16 | * Tests pass in the GitHub Actions CI for the following Python versions: 17 | 18 | - [ ] 3.10 19 | - [ ] 3.11 20 | - [ ] 3.12 21 | - [ ] 3.13 22 | 23 | ### Airship Contribution Agreement 24 | [Link here](https://docs.google.com/forms/d/e/1FAIpQLScErfiz-fXSPpVZ9r8Di2Tr2xDFxt5MgzUel0__9vqUgvko7Q/viewform) 25 | 26 | - [ ] I've filled out and signed Airship's contribution agreement form 27 | 28 | ### Screenshots 29 | * If applicable 30 | -------------------------------------------------------------------------------- /urbanairship/devices/channel_uninstall.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import List 4 | 5 | from requests import Response 6 | 7 | from urbanairship.client import BaseClient 8 | 9 | logger = logging.getLogger("urbanairship") 10 | 11 | 12 | class ChannelUninstall(object): 13 | """ 14 | Uninstalls a channel id. 15 | 16 | :param airship: Required. An urbanairship.Airship instance 17 | """ 18 | 19 | def __init__(self, airship: BaseClient) -> None: 20 | self._airship = airship 21 | 22 | def uninstall(self, channels: List[str]) -> Response: 23 | """ 24 | Perform the channel uninstall on a list of channel UUIDs. 25 | 26 | :param channels: Required. A list of channel_id UUIDs. 27 | """ 28 | chan_num: int = len(channels) 29 | 30 | if chan_num > 200: 31 | raise ValueError( 32 | ("Maximum of 200 channel uninstalls exceeded. ({0} channels)").format( 33 | chan_num 34 | ) 35 | ) 36 | 37 | body = json.dumps(channels) 38 | url = self._airship.urls.get("channel_url") + "uninstall/" 39 | 40 | response = self._airship._request("POST", body, url, version=3) 41 | logger.info("Successfully uninstalled {0} channels".format(chan_num)) 42 | 43 | return response 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **All bugs, feature requests, implementation concerns or general queries should be sent to our [support team](https://support.airship.com/).** 2 | 3 | You are welcome to submit an issue here for bugs, but please also reach out to our support team as well. 4 | 5 | Before completing the form below, please check the following: 6 | 7 | - [ ] You are using the most recent version of the library. 8 | - [ ] You are using a supported version of Python for that library version. 9 | - [ ] This issue is reproducible. 10 | 11 | ## Expected Behavior 12 | 13 | 14 | ## Current Behavior 15 | 16 | 17 | ## Possible Solution 18 | 19 | 20 | ## Steps to Reproduce 21 | 22 | 23 | 1. 24 | 2. 25 | 3. 26 | 4. 27 | 28 | ## Detailed Description 29 | 30 | 31 | ## Possible Fix 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/client/test_urls.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from urbanairship.urls import Urls 4 | 5 | 6 | class TestAirshipUrls(unittest.TestCase): 7 | def setUp(self): 8 | self.test_url = "https://something.tld" 9 | return super().setUp() 10 | 11 | def test_no_location(self): 12 | urls = Urls() 13 | # use of apid_url is arbitrary 14 | self.assertIn("urbanairship.com", urls.apid_url) 15 | 16 | def test_no_location_oauth(self): 17 | urls = Urls(oauth_base=True) 18 | self.assertIn("asnapius.com", urls.apid_url) 19 | 20 | def test_us_location(self): 21 | urls = Urls(location="us") 22 | self.assertIn("urbanairship.com", urls.apid_url) 23 | 24 | def test_us_location_oauth(self): 25 | urls = Urls(location="us", oauth_base=True) 26 | self.assertIn("asnapius.com", urls.apid_url) 27 | 28 | def test_eu_location(self): 29 | urls = Urls(location="eu") 30 | self.assertIn("airship.eu", urls.apid_url) 31 | 32 | def test_eu_location_oauth(self): 33 | urls = Urls(location="eu", oauth_base=True) 34 | self.assertIn("asnapieu.com", urls.apid_url) 35 | 36 | def test_base_url(self): 37 | urls = Urls(base_url=self.test_url) 38 | self.assertEqual(urls.base_url, self.test_url) 39 | self.assertIn(self.test_url, urls.apid_url) 40 | -------------------------------------------------------------------------------- /tests/client/test_basic_auth_client.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tests import TEST_KEY, TEST_SECRET 4 | from urbanairship.client import BasicAuthClient 5 | 6 | 7 | class TestBasicClient(unittest.TestCase): 8 | def test_basic_client_timeout(self): 9 | timeout_int = 50 10 | 11 | airship_timeout = BasicAuthClient( 12 | key=TEST_KEY, secret=TEST_SECRET, timeout=timeout_int 13 | ) 14 | 15 | self.assertEqual(airship_timeout.timeout, timeout_int) 16 | 17 | def test_basic_client_timeout_exception(self): 18 | timeout_str = "50" 19 | 20 | with self.assertRaises(ValueError): 21 | BasicAuthClient(key=TEST_KEY, secret=TEST_SECRET, timeout=timeout_str) 22 | 23 | def test_basic_client_retry(self): 24 | retry_int = 5 25 | 26 | airship_w_retry = BasicAuthClient(TEST_KEY, TEST_SECRET, retries=retry_int) 27 | 28 | self.assertEqual(retry_int, airship_w_retry.retries) 29 | 30 | def test_basic_client_location(self): 31 | location = "eu" 32 | 33 | airship_eu = BasicAuthClient( 34 | key=TEST_KEY, secret=TEST_SECRET, location=location 35 | ) 36 | 37 | self.assertEqual(airship_eu.location, location) 38 | 39 | def test_basic_client_location_exception(self): 40 | invalid_location = "xx" 41 | 42 | with self.assertRaises(ValueError): 43 | BasicAuthClient(key=TEST_KEY, secret=TEST_SECRET, location=invalid_location) 44 | -------------------------------------------------------------------------------- /tests/devices/test_subscription_list.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | import mock 5 | import requests 6 | 7 | import urbanairship as ua 8 | from tests import TEST_KEY, TEST_SECRET 9 | 10 | 11 | class TestSubscriptionList(unittest.TestCase): 12 | def setUp(self): 13 | self.airship = ua.Airship(TEST_KEY, TEST_SECRET) 14 | self.ios_channel = ua.ios_channel("b8f9b663-0a3b-cf45-587a-be880946e881") 15 | 16 | def test_list_subscribe(self): 17 | with mock.patch.object(ua.Airship, "_request") as mock_request: 18 | response = requests.Response() 19 | response._content = json.dumps({"ok": True}).encode("utf-8") 20 | mock_request.return_value = response 21 | 22 | sub_list = ua.SubscriptionList(airship=self.airship, list_id="test_list") 23 | 24 | results = sub_list.subscribe(audience=self.ios_channel) 25 | 26 | self.assertEqual(results.json(), {"ok": True}) 27 | 28 | def test_list_unsubscribe(self): 29 | with mock.patch.object(ua.Airship, "_request") as mock_request: 30 | response = requests.Response() 31 | response._content = json.dumps({"ok": True}).encode("utf-8") 32 | mock_request.return_value = response 33 | 34 | sub_list = ua.SubscriptionList(airship=self.airship, list_id="test_list") 35 | 36 | results = sub_list.unsubscribe(audience=self.ios_channel) 37 | 38 | self.assertEqual(results.json(), {"ok": True}) 39 | -------------------------------------------------------------------------------- /.github/workflows/docs_release.yaml: -------------------------------------------------------------------------------- 1 | name: Make Sphinx Docs and Upload to GCS 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | libVersion: 7 | description: Library Version 8 | required: true 9 | default: x.x.x 10 | type: string 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: 'read' 17 | id-token: 'write' 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: "3.13" 25 | - name: Setup Google Cloud SDK 26 | uses: google-github-actions/setup-gcloud@v2 27 | with: 28 | project_id: ${{ secrets.GCP_PROJECT_ID }} 29 | service_account_key: ${{ secrets.GCP_SA_KEY }} 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | python -m pip install .[dev] 34 | python -m pip install -r docs/requirements.txt 35 | - name: Build & Zip Sphinx HTML Docs 36 | run: | 37 | cd docs/ 38 | make clean html 39 | cd _build/html 40 | tar -czvf ../../${{ github.event.inputs.libVersion }}.tar.gz . 41 | - name: Upload to Google Cloud Storage 42 | uses: 'google-github-actions/upload-cloud-storage@v2' 43 | with: 44 | path: 'docs/${{ github.event.inputs.libVersion }}.tar.gz' 45 | destination: 'ua-web-ci-prod-docs-transfer/libraries/python/${{ github.event.inputs.libVersion }}.tar.gz' 46 | parent: false 47 | -------------------------------------------------------------------------------- /urbanairship/reports/experiments.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, cast 2 | 3 | from urbanairship.client import BaseClient 4 | 5 | 6 | class ExperimentReport(object): 7 | def __init__(self, airship: BaseClient) -> None: 8 | """Access reporting related to A/B Tests (experiments) 9 | 10 | :param airship: An urbanairship.Airship instance. 11 | """ 12 | self.airship = airship 13 | 14 | def get_overview(self, push_id: str) -> Dict[str, Any]: 15 | """Returns statistics and metadata about an experiment (A/B Test). 16 | 17 | :param push_id: A UUID representing an A/B test of the requested experiment. 18 | 19 | :returns: JSON from the API 20 | """ 21 | url = self.airship.urls.get("reports_url") + "experiment/overview/{0}".format(push_id) 22 | 23 | response = self.airship._request("GET", None, url, version=3) 24 | 25 | return cast(Dict[str, Any], response.json()) 26 | 27 | def get_variant(self, push_id: str, variant_id: str) -> Dict[str, Any]: 28 | """Returns statistics and metadata about a specific variant in an experiment (A/B Test). 29 | 30 | :param push_id: A UUID representing an A/B test of the requested experiment. 31 | :param variant_id: An integer represennting the variant requested. 32 | 33 | :returns: JSON from the API 34 | """ 35 | url = self.airship.urls.get("reports_url") + "experiment/detail/{0}/{1}".format( 36 | push_id, variant_id 37 | ) 38 | 39 | response = self.airship._request("GET", None, url, version=3) 40 | 41 | return cast(Dict[str, Any], response.json()) 42 | -------------------------------------------------------------------------------- /tests/client/test_token_client.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tests import TEST_KEY, TEST_SECRET, TEST_TOKEN 4 | from urbanairship.client import BearerTokenClient 5 | 6 | 7 | class TestTokenClient(unittest.TestCase): 8 | def test_token_client_timeout(self): 9 | timeout_int = 50 10 | 11 | airship_timeout = BearerTokenClient( 12 | key=TEST_KEY, token=TEST_TOKEN, timeout=timeout_int 13 | ) 14 | 15 | self.assertEqual(airship_timeout.timeout, timeout_int) 16 | 17 | def test_token_client_timeout_exception(self): 18 | timeout_str = "50" 19 | 20 | with self.assertRaises(ValueError): 21 | BearerTokenClient(key=TEST_KEY, token=TEST_TOKEN, timeout=timeout_str) 22 | 23 | def test_token_client_retry(self): 24 | retry_int = 5 25 | 26 | airship_w_retry = BearerTokenClient(TEST_KEY, TEST_SECRET, retries=retry_int) 27 | 28 | self.assertEqual(retry_int, airship_w_retry.retries) 29 | 30 | def test_token_client_location(self): 31 | location = "eu" 32 | 33 | airship_eu = BearerTokenClient( 34 | key=TEST_KEY, token=TEST_TOKEN, location=location 35 | ) 36 | 37 | self.assertEqual(airship_eu.location, location) 38 | 39 | def test_token_client_location_exception(self): 40 | invalid_location = "xx" 41 | 42 | with self.assertRaises(ValueError): 43 | BearerTokenClient(key=TEST_KEY, token=TEST_TOKEN, location=invalid_location) 44 | 45 | def test_token_auth(self): 46 | test_token_client = BearerTokenClient(key=TEST_KEY, token=TEST_TOKEN) 47 | 48 | self.assertEqual(TEST_TOKEN, test_token_client.token) 49 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ``urbanairship`` is a Python library for using the Airship REST 2 | API for push notifications, message 3 | center messages, email, and SMS. 4 | 5 | Requirements 6 | ============ 7 | 8 | Python 3.10 or higher is required. Other requirements can be found in requirements.txt. 9 | 10 | Questions 11 | ========= 12 | 13 | The best place to ask questions or report a problem is our support site: 14 | http://support.airship.com/ 15 | 16 | Usage 17 | ===== 18 | 19 | See the `full documentation for this library 20 | `_, as well as the 21 | `Airship API Documentation 22 | `_. 23 | 24 | 25 | History 26 | ======= 27 | 28 | * 7.0 Update to client classes and authentication methods. Removes support for Python version prior to 3.10. 29 | * 6.3 Support for OAuth2 Authentication. Adds new clients module and class. 30 | * 6.0 Support for Bearer Token Authentication. Removes support for Python 2. 31 | * 5.0 Support for SMS and Email messages. See changelog for other updates. 32 | * 4.0 Support for Automation, removed Feedback 33 | * 3.0 Support for Open Channels, several other significant changes 34 | * 2.0 Support for Web Notify and more iOS 10, stopped supporting Python 2.6 35 | * 1.0 Support for In-App and iOS 10 36 | * 0.8 Support for Reports APIs 37 | * 0.7 Support for Python 3, major refactoring 38 | * 0.6 Major refactoring, support for push api v3 39 | * 0.5 Added Android, Rich Push, and scheduled notifications 40 | * 0.4 Added batch push 41 | * 0.3 Added deregister, device token list, other minor improvements 42 | * 0.2 Added tags, broadcast, feedback 43 | * 0.1 Initial release 44 | 45 | See the CHANGELOG file for more details. 46 | -------------------------------------------------------------------------------- /tests/client/test_oauth_client.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tests import TEST_KEY 4 | from urbanairship.client import OAuthClient 5 | 6 | 7 | class TestOAuthClient(unittest.TestCase): 8 | def setUp(self) -> None: 9 | self.scope = ["nu"] 10 | self.ip_addr = ["24.20.40.0/24"] 11 | self.timeout = 50 12 | self.retries = 3 13 | self.private_key = "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" 14 | 15 | self.test_oauth_client = OAuthClient( 16 | client_id=TEST_KEY, 17 | private_key=self.private_key, 18 | key=TEST_KEY, 19 | scope=self.scope, 20 | ip_addr=self.ip_addr, 21 | timeout=self.timeout, 22 | retries=self.retries, 23 | ) 24 | 25 | def test_oauth_client_timeout(self): 26 | self.assertEqual(self.test_oauth_client.timeout, self.timeout) 27 | 28 | def test_oauth_client_id(self): 29 | self.assertEqual(self.test_oauth_client.client_id, TEST_KEY) 30 | 31 | def test_oauth_client_scope(self): 32 | self.assertEqual(self.test_oauth_client.scope, self.scope) 33 | 34 | def test_oauth_client_ip_addr(self): 35 | self.assertEqual(self.test_oauth_client.ip_addr, self.ip_addr) 36 | 37 | def test_oauth_client_retry(self): 38 | self.assertEqual(self.test_oauth_client.retries, self.retries) 39 | 40 | def test_oauth_token_url(self): 41 | self.assertEqual( 42 | self.test_oauth_client.token_url, "https://oauth2.asnapius.com/token" 43 | ) 44 | 45 | def test_oauth_private_key(self): 46 | self.assertIn("-----BEGIN PRIVATE KEY-----", self.test_oauth_client.private_key) 47 | self.assertIn("-----END PRIVATE KEY-----", self.test_oauth_client.private_key) 48 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - id: no-commit-to-branch 9 | args: ["--branch", "master", "--branch", "main"] 10 | - id: check-added-large-files 11 | - id: check-json 12 | - id: check-toml 13 | - repo: https://github.com/psf/black 14 | rev: 24.2.0 15 | hooks: 16 | - id: black 17 | language_version: python3.13 18 | - repo: https://github.com/pycqa/isort 19 | rev: 6.0.1 20 | hooks: 21 | - id: isort 22 | - repo: https://github.com/pycqa/flake8 23 | rev: 7.0.0 24 | hooks: 25 | - id: flake8 26 | additional_dependencies: [flake8-bugbear>=24.2.6] 27 | args: [ 28 | "--max-line-length=99", 29 | "--extend-ignore=E203,W503,E501,C901", # Ignore style errors that conflict with Black, line length, and complexity 30 | "--exclude=.git,__pycache__,build,dist", 31 | "--per-file-ignores=*/__init__.py:F401" 32 | ] 33 | - repo: https://github.com/codespell-project/codespell 34 | rev: v2.2.6 35 | hooks: 36 | - id: codespell 37 | args: [--ignore-words=.codespell-ignore] 38 | exclude: > 39 | (?x)^( 40 | .*\.lock| 41 | \.git/.*| 42 | .*/build/.* 43 | )$ 44 | - repo: https://github.com/pre-commit/mirrors-mypy 45 | rev: v1.9.0 46 | hooks: 47 | - id: mypy 48 | args: [--config-file=pyproject.toml, --ignore-missing-imports, --disable-error-code=no-any-return] 49 | files: ^urbanairship/ 50 | exclude: ^docs/ 51 | additional_dependencies: 52 | - types-requests>=2.31.0.20240311 53 | - types-six>=1.16.21.20240311 54 | - types-mock>=5.1.0.20240311 55 | - types-urllib3 56 | -------------------------------------------------------------------------------- /urbanairship/devices/subscription_lists.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict 3 | 4 | from requests import Response 5 | 6 | from urbanairship.client import BaseClient 7 | 8 | 9 | class SubscriptionList(object): 10 | """ 11 | Subscribe or unsubscribe channels to/from Subscription lists. These lists must 12 | be created in the Airship web dashboard prior to making these calls. The 13 | value for list_id can be found after creating these lists. 14 | 15 | :param airship: Required. An urbanairship.Airship instance. 16 | :param list_id: Required. The list_id from the Airship web dashboard. 17 | """ 18 | 19 | def __init__(self, airship: BaseClient, list_id: str) -> None: 20 | self.airship = airship 21 | self.list_id = list_id 22 | 23 | def unsubscribe(self, audience: Dict) -> Response: 24 | """ 25 | Unsubscribe an audience from a subscription list. 26 | 27 | :param audience: Required. A single audience selector (ex: 28 | urbanairship.ios_channel) to unsubscribe. 29 | """ 30 | payload = { 31 | "subscription_lists": {"action": "unsubscribe", "list_id": self.list_id}, 32 | "audience": audience, 33 | } 34 | 35 | response = self.airship.request( 36 | method="POST", 37 | body=json.dumps(payload), 38 | url=self.airship.urls.get("subscription_lists_url"), 39 | version=3, 40 | ) 41 | 42 | return response 43 | 44 | def subscribe(self, audience: Dict) -> Response: 45 | """ 46 | Subscribe an audience from a subscription list. 47 | 48 | :param list_id: Required. The list_id from the Airship web dashboard. 49 | :param audience: Required. A single audience selector (ex: 50 | urbanairship.ios_channel) to subscribe. 51 | """ 52 | payload = { 53 | "subscription_lists": {"action": "subscribe", "list_id": self.list_id}, 54 | "audience": audience, 55 | } 56 | 57 | response = self.airship.request( 58 | method="POST", 59 | body=json.dumps(payload), 60 | url=self.airship.urls.get("subscription_lists_url"), 61 | version=3, 62 | ) 63 | 64 | return response 65 | -------------------------------------------------------------------------------- /urbanairship/experiments/variant.py: -------------------------------------------------------------------------------- 1 | class Variant(object): 2 | """The variants for the experiment. An experiment must have at least 1 variant 3 | and no more than 26. 4 | """ 5 | 6 | def __init__(self, push, description=None, name=None, schedule=None, weight=None): 7 | """ 8 | :keyword push: [required] A push object without audience and device_types 9 | fields. These two fields are not allowed because they are already defined 10 | in the experiment object 11 | :keyword description: [optional] A description of the variant. 12 | :keyword name: [optional] A name for the variant 13 | unless either message or in_app is present. You can provide an alert and any 14 | platform overrides that apply to the device_type platforms you specify. 15 | :keyword schedule: [optional] The time when the push notification should be sent 16 | :keyword weight: [optional] The proportion of the audience that will receive 17 | this variant. Defaults to 1. 18 | """ 19 | self.push = push 20 | self.description = description 21 | self.name = name 22 | self.schedule = schedule 23 | self.weight = weight 24 | 25 | @property 26 | def description(self): 27 | if not self._description: 28 | return None 29 | return self._description 30 | 31 | @description.setter 32 | def description(self, value): 33 | if not isinstance(value, str): 34 | TypeError("the description must be type string") 35 | 36 | self._description = value 37 | 38 | @property 39 | def name(self): 40 | if not self._name: 41 | return None 42 | return self._name 43 | 44 | @name.setter 45 | def name(self, value): 46 | if not isinstance(value, str): 47 | TypeError("the name must be a string type") 48 | 49 | self._name = value 50 | 51 | @property 52 | def weight(self): 53 | if not self._weight: 54 | return None 55 | return self._weight 56 | 57 | @weight.setter 58 | def weight(self, value): 59 | if not isinstance(value, int): 60 | TypeError("the value must be a integer type") 61 | self._weight = value 62 | -------------------------------------------------------------------------------- /tests/client/test_response.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | import uuid 4 | 5 | import mock 6 | import requests 7 | 8 | import urbanairship as ua 9 | from tests import TEST_KEY, TEST_SECRET 10 | from urbanairship.client import BasicAuthClient 11 | 12 | 13 | class TestAirshipResponse(unittest.TestCase): 14 | test_channel = str(uuid.uuid4()) 15 | airship = BasicAuthClient(TEST_KEY, TEST_SECRET, location="us") 16 | common_push = ua.Push(airship=airship) 17 | common_push.device_types = ua.device_types("ios") 18 | common_push.audience = ua.channel(test_channel) 19 | common_push.notification = ua.notification(alert="testing") 20 | 21 | def test_unauthorized(self): 22 | with mock.patch.object(BasicAuthClient, "_request") as mock_request: 23 | response = requests.Response() 24 | response._content = json.dumps({"ok": False}).encode("utf-8") 25 | response.status_code = 401 26 | mock_request.return_value = response 27 | 28 | try: 29 | self.common_push.send() 30 | except Exception as e: 31 | self.assertIsInstance(ua.Unauthorized, e) 32 | 33 | def test_client_error(self): 34 | with mock.patch.object(BasicAuthClient, "_request") as mock_request: 35 | response = requests.Response() 36 | response._content = json.dumps({"ok": False}).encode("utf-8") 37 | response.status_code = 400 38 | mock_request.return_value = response 39 | 40 | try: 41 | r = self.common_push.send() 42 | except Exception as e: 43 | self.assertIsInstance(ua.AirshipFailure, e) 44 | self.assertEqual(r.status_code, 400) 45 | 46 | def test_server_error(self): 47 | with mock.patch.object(BasicAuthClient, "_request") as mock_request: 48 | response = requests.Response() 49 | response._content = json.dumps({"ok": False}).encode("utf-8") 50 | response.status_code = 500 51 | mock_request.return_value = response 52 | 53 | try: 54 | r = self.common_push.send() 55 | except Exception as e: 56 | self.assertIsInstance(ua.AirshipFailure, e) 57 | self.assertEqual(r.status_code, 500) 58 | -------------------------------------------------------------------------------- /tests/custom_events/test_custom_events.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | import urbanairship as ua 5 | from tests import TEST_KEY, TEST_TOKEN 6 | 7 | 8 | class TestCustomEvent(unittest.TestCase): 9 | def setUp(self): 10 | airship = ua.BearerTokenClient(key=TEST_KEY, token=TEST_TOKEN) 11 | 12 | self.event = ua.CustomEvent( 13 | airship=airship, name="test_event", user=ua.named_user("test_named_user") 14 | ) 15 | 16 | def test_minimum_custom_event(self): 17 | self.assertEqual( 18 | self.event._payload, 19 | { 20 | "body": {"name": "test_event"}, 21 | "user": {"named_user_id": "test_named_user"}, 22 | }, 23 | ) 24 | 25 | def test_minimum_event_channel(self): 26 | self.event.user = ua.channel("0617a35e-b0c2-4b1c-9c41-586ca6b081d6") 27 | 28 | self.assertEqual( 29 | self.event._payload, 30 | { 31 | "body": {"name": "test_event"}, 32 | "user": {"channel": "0617a35e-b0c2-4b1c-9c41-586ca6b081d6"}, 33 | }, 34 | ) 35 | 36 | def test_full_custom_event(self): 37 | properties = {"key": "value", "nested": {"another": "pair"}} 38 | 39 | self.event.occurred = datetime.datetime(2022, 1, 11, 11, 30, 00) 40 | self.event.session_id = "0617a35e-b0c2-4b1c-9c41-586ca6b081d6" 41 | self.event.interaction_id = "test_interaction_id" 42 | self.event.interaction_type = "test_interaction_type" 43 | self.event.value = 1234.56 44 | self.event.transaction = "test_transaction" 45 | self.event.properties = properties 46 | 47 | self.maxDiff = 10000 48 | 49 | self.assertEqual( 50 | self.event._payload, 51 | { 52 | "occurred": "2022-01-11T11:30:00", 53 | "user": {"named_user_id": "test_named_user"}, 54 | "body": { 55 | "name": "test_event", 56 | "session_id": "0617a35e-b0c2-4b1c-9c41-586ca6b081d6", 57 | "interaction_id": "test_interaction_id", 58 | "interaction_type": "test_interaction_type", 59 | "value": 1234.56, 60 | "transaction": "test_transaction", 61 | "properties": properties, 62 | }, 63 | }, 64 | ) 65 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | __about__: dict[str, str] = {} 4 | 5 | with open("urbanairship/__about__.py") as fp: 6 | exec(fp.read(), None, __about__) 7 | 8 | # Read long description from README 9 | with open("README.rst", encoding="utf-8") as f: 10 | long_description = f.read() 11 | 12 | # Define test requirements 13 | test_requirements = [ 14 | "pytest>=7.0.0", 15 | "pytest-cov>=4.0.0", 16 | "mock>=5.0.0", 17 | ] 18 | 19 | setup( 20 | name="urbanairship", 21 | version=__about__["__version__"], 22 | author="Airship Tools", 23 | author_email="tools@airship.com", 24 | url="https://airship.com/", 25 | description="Python package for using the Airship API", 26 | long_description=long_description, 27 | long_description_content_type="text/x-rst", 28 | packages=[ 29 | "urbanairship", 30 | "urbanairship.push", 31 | "urbanairship.devices", 32 | "urbanairship.reports", 33 | "urbanairship.automation", 34 | "urbanairship.experiments", 35 | "urbanairship.custom_events", 36 | ], 37 | license="BSD License", 38 | classifiers=[ 39 | "Development Status :: 5 - Production/Stable", 40 | "Environment :: Web Environment", 41 | "Intended Audience :: Developers", 42 | "License :: OSI Approved :: Apache Software License", 43 | "Operating System :: OS Independent", 44 | "Programming Language :: Python", 45 | "Programming Language :: Python :: 3", 46 | "Programming Language :: Python :: 3.10", 47 | "Programming Language :: Python :: 3.11", 48 | "Programming Language :: Python :: 3.12", 49 | "Programming Language :: Python :: 3.13", 50 | "Topic :: Software Development :: Libraries", 51 | ], 52 | python_requires=">=3.10", 53 | install_requires=["requests>=2.32", "six", "backoff>=2.2.1", "pyjwt>=2.8.0"], 54 | tests_require=test_requirements, 55 | extras_require={ 56 | "test": test_requirements, 57 | "dev": test_requirements + ["black", "isort", "flake8"], 58 | }, 59 | package_data={ 60 | "urbanairship": ["py.typed"], 61 | }, 62 | project_urls={ 63 | "Documentation": "https://docs.airship.com/", 64 | "Source": "https://github.com/urbanairship/python-library", 65 | "Tracker": "https://github.com/urbanairship/python-library/issues", 66 | }, 67 | test_suite="tests", 68 | ) 69 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "urbanairship" 7 | dynamic = [ 8 | "version", 9 | "description", 10 | "readme", 11 | "license", 12 | "authors", 13 | "classifiers", 14 | "urls", 15 | "dependencies", 16 | "optional-dependencies" 17 | ] 18 | requires-python = ">=3.10" 19 | 20 | [tool.setuptools] 21 | packages = [ 22 | "urbanairship", 23 | "urbanairship.push", 24 | "urbanairship.devices", 25 | "urbanairship.reports", 26 | "urbanairship.automation", 27 | "urbanairship.experiments", 28 | "urbanairship.custom_events", 29 | ] 30 | package-data = { "urbanairship" = ["py.typed"] } 31 | 32 | [tool.setuptools.dynamic] 33 | version = {attr = "urbanairship.__about__.__version__"} 34 | description = {file = "README.rst"} 35 | readme = {file = "README.rst", content-type = "text/x-rst"} 36 | 37 | [tool.black] 38 | line-length = 99 39 | target-version = ["py310", "py311", "py312"] 40 | include = '\.pyi?$' 41 | 42 | [tool.isort] 43 | profile = "black" 44 | multi_line_output = 3 45 | include_trailing_comma = true 46 | force_grid_wrap = 0 47 | use_parentheses = true 48 | ensure_newline_before_comments = true 49 | 50 | [tool.flake8] 51 | max-line-length = 99 52 | extend-ignore = "E203" # to work with black 53 | exclude = [ 54 | ".git", 55 | "__pycache__", 56 | "build", 57 | "dist", 58 | ] 59 | 60 | [tool.pytest.ini_options] 61 | minversion = "7.0" 62 | addopts = "-ra -q --cov=urbanairship --cov-report=term-missing" 63 | testpaths = [ 64 | "tests", 65 | ] 66 | 67 | [tool.coverage.run] 68 | branch = true 69 | source = ["urbanairship"] 70 | 71 | [tool.coverage.report] 72 | exclude_lines = [ 73 | "pragma: no cover", 74 | "def __repr__", 75 | "if self.debug:", 76 | "raise NotImplementedError", 77 | "if __name__ == .__main__.:", 78 | "pass", 79 | "raise ImportError", 80 | ] 81 | ignore_errors = true 82 | omit = [ 83 | "tests/*", 84 | "setup.py", 85 | ] 86 | 87 | [tool.mypy] 88 | python_version = "3.10" 89 | warn_return_any = true 90 | warn_unused_configs = true 91 | disallow_untyped_defs = false 92 | disallow_incomplete_defs = false 93 | check_untyped_defs = false 94 | disallow_untyped_decorators = false 95 | no_implicit_optional = true 96 | warn_redundant_casts = true 97 | warn_unused_ignores = true 98 | warn_no_return = true 99 | warn_unreachable = true 100 | strict_equality = true 101 | show_error_codes = true 102 | ignore_missing_imports = true 103 | disable_error_code = "unreachable" 104 | 105 | [[tool.mypy.overrides]] 106 | module = [ 107 | "tests.*", 108 | "docs.*", 109 | ] 110 | ignore_errors = true 111 | -------------------------------------------------------------------------------- /urbanairship/push/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from .audience import ( 4 | alias, 5 | amazon_channel, 6 | and_, 7 | android_channel, 8 | apid, 9 | channel, 10 | date_attribute, 11 | device_token, 12 | ios_channel, 13 | named_user, 14 | not_, 15 | number_attribute, 16 | open_channel, 17 | or_, 18 | segment, 19 | sms_id, 20 | sms_sender, 21 | static_list, 22 | subscription_list, 23 | tag, 24 | tag_group, 25 | text_attribute, 26 | wns, 27 | ) 28 | from .core import CreateAndSendPush, Push, ScheduledPush, TemplatePush 29 | from .payload import ( 30 | actions, 31 | amazon, 32 | android, 33 | campaigns, 34 | device_types, 35 | email, 36 | in_app, 37 | interactive, 38 | ios, 39 | live_activity, 40 | live_update, 41 | localization, 42 | media_attachment, 43 | message, 44 | mms, 45 | notification, 46 | open_platform, 47 | options, 48 | public_notification, 49 | sms, 50 | style, 51 | wearable, 52 | web, 53 | wns_payload, 54 | ) 55 | from .schedule import ( 56 | ScheduledList, 57 | best_time, 58 | local_scheduled_time, 59 | recurring_schedule, 60 | schedule_exclusion, 61 | scheduled_time, 62 | ) 63 | from .template import Template, TemplateList, merge_data 64 | 65 | # Common selector for audience & device_types 66 | 67 | all_: str = "all" 68 | """Select all, to do a broadcast. 69 | 70 | Used in both ``audience`` and ``device_types``. 71 | """ 72 | 73 | 74 | __all__: List[Any] = [ 75 | all_, 76 | Push, 77 | ScheduledPush, 78 | ScheduledList, 79 | TemplatePush, 80 | Template, 81 | TemplateList, 82 | CreateAndSendPush, 83 | ios_channel, 84 | android_channel, 85 | amazon_channel, 86 | channel, 87 | open_channel, 88 | sms_id, 89 | sms_sender, 90 | device_token, 91 | apid, 92 | wns, 93 | tag, 94 | tag_group, 95 | alias, 96 | segment, 97 | and_, 98 | or_, 99 | not_, 100 | notification, 101 | ios, 102 | android, 103 | amazon, 104 | web, 105 | sms, 106 | mms, 107 | wns_payload, 108 | open_platform, 109 | message, 110 | device_types, 111 | options, 112 | actions, 113 | interactive, 114 | scheduled_time, 115 | local_scheduled_time, 116 | in_app, 117 | named_user, 118 | date_attribute, 119 | email, 120 | campaigns, 121 | wearable, 122 | style, 123 | public_notification, 124 | best_time, 125 | merge_data, 126 | text_attribute, 127 | number_attribute, 128 | static_list, 129 | subscription_list, 130 | localization, 131 | recurring_schedule, 132 | schedule_exclusion, 133 | live_activity, 134 | live_update, 135 | media_attachment, 136 | ] 137 | -------------------------------------------------------------------------------- /tests/devices/test_segments.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | import mock 5 | import requests 6 | from mock import Mock 7 | 8 | import urbanairship as ua 9 | from tests import TEST_KEY, TEST_SECRET 10 | 11 | 12 | class TestSegmentList(unittest.TestCase): 13 | def test_segment_list(self): 14 | with mock.patch.object(ua.Airship, "_request") as mock_request: 15 | response = requests.Response() 16 | response._content = json.dumps( 17 | {"segments": [{"display_name": "test1"}, {"display_name": "test2"}]} 18 | ).encode("utf-8") 19 | mock_request.return_value = response 20 | 21 | name_list = ["test2", "test1"] 22 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 23 | seg_list = ua.SegmentList(airship) 24 | 25 | for a in seg_list: 26 | self.assertEqual(a.display_name, name_list.pop()) 27 | 28 | 29 | class TestSegment(unittest.TestCase): 30 | def test_segment_create_update_delete(self): 31 | name = "test_segment" 32 | criteria = json.dumps({"and": [{"tag": "TEST"}, {"not": {"tag": "TEST2"}}]}) 33 | 34 | data = json.dumps({"name": name, "criteria": criteria}).encode("utf-8") 35 | 36 | create_response = requests.Response() 37 | create_response.status_code = 200 38 | create_response._content = json.dumps( 39 | { 40 | "ok": True, 41 | "segment_id": "12345678-1234-1234-1234-1234567890ab", 42 | "operation_id": "12345678-1234-1234-1234-1234567890ab", 43 | } 44 | ).encode("utf-8") 45 | 46 | id_response = requests.Response() 47 | id_response._content = data 48 | id_response.status_code = 200 49 | 50 | update_response = requests.Response() 51 | update_response.status_code = 200 52 | 53 | del_response = requests.Response() 54 | del_response.status_code = 204 55 | 56 | ua.Airship._request = Mock() 57 | ua.Airship._request.side_effect = [ 58 | create_response, 59 | id_response, 60 | id_response, 61 | update_response, 62 | del_response, 63 | ] 64 | 65 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 66 | 67 | seg = ua.Segment() 68 | seg.display_name = name 69 | seg.criteria = criteria 70 | create_res = seg.create(airship) 71 | 72 | self.assertEqual(create_res.status_code, 200) 73 | self.assertEqual(seg.display_name, name) 74 | self.assertEqual(seg.criteria, criteria) 75 | 76 | from_id = seg.from_id(airship, "test_id") 77 | self.assertEqual(from_id.status_code, 200) 78 | self.assertEqual(from_id.content, data) 79 | 80 | seg.display_name = "new_test_segment" 81 | up_res = seg.update(airship) 82 | del_res = seg.delete(airship) 83 | 84 | self.assertEqual(up_res.status_code, 200) 85 | self.assertEqual(del_res.status_code, 204) 86 | -------------------------------------------------------------------------------- /tests/devices/test_tags_lists.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | import mock 5 | import requests 6 | 7 | import urbanairship as ua 8 | from tests import TEST_KEY, TEST_SECRET 9 | 10 | 11 | class TestTagLists(unittest.TestCase): 12 | def setUp(self): 13 | self.airship = ua.Airship(key=TEST_KEY, secret=TEST_SECRET) 14 | self.list_name = "test_tag_list" 15 | self.description = "a list of some tags" 16 | self.extra = {"key": "value"} 17 | self.file_path = "tests/data/tag_list.csv" 18 | self.tag_dict = {"group_name": ["tag1", "tag2"]} 19 | self.tag_list = ua.TagList( 20 | airship=self.airship, 21 | list_name=self.list_name, 22 | description=self.description, 23 | extra=self.extra, 24 | add_tags=self.tag_dict, 25 | remove_tags=self.tag_dict, 26 | set_tags=self.tag_dict, 27 | ) 28 | 29 | def test_create(self): 30 | with mock.patch.object(ua.Airship, "_request") as mock_request: 31 | response = requests.Response() 32 | response._content = json.dumps({"ok": True}).encode("UTF-8") 33 | mock_request.return_value = response 34 | 35 | result = self.tag_list.create() 36 | 37 | self.assertEqual(result.json(), {"ok": True}) 38 | 39 | def test_create_payload_property(self): 40 | self.assertEqual( 41 | self.tag_list._create_payload, 42 | { 43 | "name": self.list_name, 44 | "description": self.description, 45 | "extra": self.extra, 46 | "add": self.tag_dict, 47 | "remove": self.tag_dict, 48 | "set": self.tag_dict, 49 | }, 50 | ) 51 | 52 | def test_upload(self): 53 | with mock.patch.object(ua.Airship, "_request") as mock_request: 54 | response = requests.Response() 55 | response._content = json.dumps({"ok": True}).encode("UTF-8") 56 | mock_request.return_value = response 57 | 58 | result = self.tag_list.upload(file_path=self.file_path) 59 | 60 | self.assertEqual(result.json(), {"ok": True}) 61 | 62 | def test_get_errors(self): 63 | with mock.patch.object(ua.Airship, "_request") as mock_request: 64 | response = requests.Response() 65 | response._content = json.dumps({"ok": True}).encode("UTF-8") 66 | mock_request.return_value = response 67 | 68 | result = self.tag_list.get_errors() 69 | 70 | self.assertEqual(result.json(), {"ok": True}) 71 | 72 | def test_list(self): 73 | with mock.patch.object(ua.Airship, "_request") as mock_request: 74 | response = requests.Response() 75 | response._content = json.dumps({"ok": True}).encode("UTF-8") 76 | mock_request.return_value = response 77 | 78 | result = ua.TagList.list(airship=self.airship) 79 | 80 | self.assertEqual(result.json(), {"ok": True}) 81 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Airship Python Library 2 | *********************** 3 | 4 | ``urbanairship`` is a Python library for using the `Airship 5 | `_ web service in support of our messaging product 6 | lines and related features. 7 | 8 | Installation 9 | ============= 10 | 11 | Using ``pip``:: 12 | 13 | $ pip install urbanairship 14 | 15 | Using the library 16 | ================== 17 | 18 | The library is intended to be used with the small footprint of a single 19 | import. To get started, import the package, and create an appropriate client object 20 | representing a single Airship project. 21 | 22 | .. code-block:: python 23 | 24 | import urbanairship as ua 25 | client = ua.client.BasicAuthClient('', '') 26 | 27 | push = ua.Push(client) 28 | push.audience = ua.all_ 29 | push.notification = ua.notification(alert='Hello, world!') 30 | push.device_types = ua.device_types('ios', 'android') 31 | push.send() 32 | 33 | The library uses `requests`_ for communication with the Airship API, 34 | providing connection pooling and strict SSL checking. All client objects are 35 | threadsafe, and can be instantiated once and reused in multiple threads. 36 | 37 | Authentication 38 | ------------- 39 | 40 | The library supports three authentication methods: 41 | 42 | * Basic Authentication - Using app key and master secret 43 | * Bearer Token Authentication - Using app key and Airship-generated bearer token 44 | * OAuth2 Authentication - Using JWT assertions with automatic token refresh 45 | 46 | For more details on each authentication method, see the :doc:`client` documentation. 47 | 48 | Logging 49 | ------- 50 | 51 | ``urbanairship`` uses the standard logging module for integration into 52 | an application's existing logging. If you do not have logging 53 | configured otherwise, your application can set it up like so: 54 | 55 | .. code-block:: python 56 | 57 | import logging 58 | logging.basicConfig() 59 | 60 | If you're having trouble with the Airship API, you can turn on verbose debug 61 | logging. 62 | 63 | .. code-block:: python 64 | 65 | logging.getLogger('urbanairship').setLevel(logging.DEBUG) 66 | 67 | Exceptions 68 | ========== 69 | 70 | .. autoclass:: urbanairship.AirshipFailure 71 | 72 | .. autoclass:: urbanairship.Unauthorized 73 | 74 | .. autoclass:: urbanairship.ConnectionFailure 75 | 76 | Development 77 | ============ 78 | 79 | The library source code is `available on GitHub `_. 80 | 81 | Tests can be run with nose_: 82 | 83 | .. code-block:: sh 84 | 85 | nosetests --with-doctest 86 | 87 | Contents 88 | ========= 89 | 90 | .. toctree:: 91 | :maxdepth: 3 92 | 93 | client 94 | push.rst 95 | devices.rst 96 | audience.rst 97 | reports.rst 98 | custom_events.rst 99 | 100 | 101 | Indices and tables 102 | ================== 103 | 104 | * :ref:`genindex` 105 | * :ref:`modindex` 106 | * :ref:`search` 107 | 108 | 109 | .. _channels: https://docs.airship.com/api/ua/?python#tag-channels 110 | .. _requests: http://python-requests.org 111 | .. _github: https://github.com/urbanairship/python-library 112 | .. _nose: https://nose.readthedocs.org/en/latest/ 113 | -------------------------------------------------------------------------------- /tests/core/test_airship.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | import uuid 4 | 5 | import mock 6 | import requests 7 | 8 | import urbanairship as ua 9 | from tests import TEST_KEY, TEST_SECRET 10 | 11 | 12 | class TestAirshipCore(unittest.TestCase): 13 | def test_airship_timeout(self): 14 | timeout_int = 50 15 | 16 | airship_timeout = ua.Airship( 17 | key=TEST_KEY, secret=TEST_SECRET, timeout=timeout_int 18 | ) 19 | 20 | self.assertEqual(airship_timeout.timeout, timeout_int) 21 | 22 | def test_airship_timeout_exception(self): 23 | timeout_str = "50" 24 | 25 | with self.assertRaises(ValueError): 26 | ua.Airship(key=TEST_KEY, secret=TEST_SECRET, timeout=timeout_str) 27 | 28 | def test_airship_retry(self): 29 | retry_int = 5 30 | 31 | airship_w_retry = ua.Airship(TEST_KEY, TEST_SECRET, retries=retry_int) 32 | 33 | self.assertEqual(retry_int, airship_w_retry.retries) 34 | 35 | def test_airship_location(self): 36 | location = "eu" 37 | 38 | airship_eu = ua.Airship(key=TEST_KEY, secret=TEST_SECRET, location=location) 39 | 40 | self.assertEqual(airship_eu.location, location) 41 | 42 | def test_airship_location_exception(self): 43 | invalid_location = "xx" 44 | 45 | with self.assertRaises(ValueError): 46 | ua.Airship(key=TEST_KEY, secret=TEST_SECRET, location=invalid_location) 47 | 48 | 49 | class TestAirshipResponse(unittest.TestCase): 50 | test_channel = str(uuid.uuid4()) 51 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 52 | common_push = ua.Push(airship) 53 | common_push.device_types = ua.device_types("ios", "android", "amazon") 54 | common_push.audience = ua.channel(test_channel) 55 | common_push.notification = ua.notification(alert="testing") 56 | 57 | def test_unauthorized(self): 58 | with mock.patch.object(ua.Airship, "_request") as mock_request: 59 | response = requests.Response() 60 | response._content = json.dumps({"ok": False}).encode("utf-8") 61 | response.status_code = 401 62 | mock_request.return_value = response 63 | 64 | try: 65 | self.common_push.send() 66 | except Exception as e: 67 | self.assertIsInstance(ua.Unauthorized, e) 68 | 69 | def test_client_error(self): 70 | with mock.patch.object(ua.Airship, "_request") as mock_request: 71 | response = requests.Response() 72 | response._content = json.dumps({"ok": False}).encode("utf-8") 73 | response.status_code = 400 74 | mock_request.return_value = response 75 | 76 | try: 77 | self.common_push.send() 78 | except ua.AirshipFailure as e: 79 | self.assertIsInstance(ua.AirshipFailure, e) 80 | 81 | def test_server_error(self): 82 | with mock.patch.object(ua.Airship, "_request") as mock_request: 83 | response = requests.Response() 84 | response._content = json.dumps({"ok": False}).encode("utf-8") 85 | response.status_code = 500 86 | mock_request.return_value = response 87 | 88 | try: 89 | self.common_push.send() 90 | except ua.AirshipFailure as e: 91 | self.assertIsInstance(ua.AirshipFailure, e) 92 | -------------------------------------------------------------------------------- /urbanairship/devices/segment.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import Dict, Optional 4 | 5 | from requests import Response 6 | 7 | from urbanairship import common 8 | from urbanairship.client import BaseClient 9 | 10 | logger = logging.getLogger("urbanairship") 11 | 12 | 13 | class Segment(object): 14 | url: Optional[str] = None 15 | id: Optional[str] = None 16 | display_name: Optional[str] = None 17 | creation_date: Optional[str] = None 18 | modification_date: Optional[str] = None 19 | criteria: Optional[str] = None 20 | data: Optional[Dict] = None 21 | 22 | def create(self, airship: BaseClient) -> Response: 23 | """Create a Segment object and return it.""" 24 | 25 | url = airship.urls.get("segments_url") 26 | 27 | body = json.dumps( 28 | {"display_name": self.display_name, "criteria": self.criteria} 29 | ) 30 | response = airship._request(method="POST", body=body, url=url, version=3) 31 | logger.info("Successful segment creation: {0}".format(self.display_name)) 32 | 33 | payload = response.json() 34 | seg_id = payload.get("segment_id") 35 | 36 | self.id = seg_id 37 | self.from_id(airship, seg_id) 38 | 39 | return response 40 | 41 | @classmethod 42 | def from_id(cls, airship: BaseClient, seg_id: str): 43 | """Retrieve a segment based on the provided ID.""" 44 | 45 | url = airship.urls.get("segments_url") + seg_id 46 | response = airship._request(method="GET", body=None, url=url, version=3) 47 | 48 | payload = response.json() 49 | cls.id = seg_id 50 | cls.from_payload(payload) 51 | 52 | return response 53 | 54 | @classmethod 55 | def from_payload(cls, payload: Dict): 56 | """Create segment based on results from a SegmentList iterator.""" 57 | 58 | for key in payload: 59 | setattr(cls, key, payload[key]) 60 | 61 | return cls 62 | 63 | def update(self, airship: BaseClient) -> Response: 64 | """Updates the segment associated with data in the current object.""" 65 | 66 | data = {} 67 | data["display_name"] = self.display_name 68 | data["criteria"] = self.criteria 69 | 70 | url = f'{airship.urls.get("segments_url")}{self.id}' 71 | body = json.dumps(data) 72 | response = airship._request(method="PUT", body=body, url=url, version=3) 73 | logger.info("Successful segment update: '{0}'".format(self.display_name)) 74 | 75 | return response 76 | 77 | def delete(self, airship: BaseClient) -> Response: 78 | url = f'{airship.urls.get("segments_url")}{self.id}' 79 | res = airship._request(method="DELETE", body=None, url=url, version=3) 80 | logger.info("Successful segment deletion: '{0}'".format(self.display_name)) 81 | return res 82 | 83 | 84 | class SegmentList(common.IteratorParent): 85 | """Retrieves a list of segments 86 | 87 | :keyword limit: Number of segments to fetch 88 | 89 | """ 90 | 91 | next_url: Optional[str] = None 92 | data_attribute: str = "segments" 93 | 94 | def __init__(self, airship: BaseClient, limit: Optional[int] = None): 95 | self.next_url = airship.urls.get("segments_url") 96 | params = {"limit": limit} if limit else {} 97 | super(SegmentList, self).__init__(airship, params) 98 | -------------------------------------------------------------------------------- /docs/audience.rst: -------------------------------------------------------------------------------- 1 | Audience and Segmentation 2 | ************************** 3 | 4 | Segments 5 | ======== 6 | Examples can be found in `the segments documentation here. `_ 7 | 8 | .. autoclass:: urbanairship.devices.segment.Segment 9 | :members: 10 | :exclude-members: from_payload 11 | :noindex: 12 | 13 | Segment Listing 14 | --------------- 15 | Segment lists are fetched by instantiating an iterator object 16 | using :py:class:`SegmentList`. 17 | 18 | .. autoclass:: urbanairship.devices.segment.SegmentList 19 | :members: 20 | 21 | 22 | Tags 23 | ===== 24 | Examples can be found in `the tags documentation here. `_ 25 | 26 | Channel Tags 27 | ------------ 28 | 29 | .. automodule:: urbanairship.devices.tag 30 | :members: ChannelTags 31 | 32 | Open Channel Tags 33 | ------------------ 34 | .. autoclass:: urbanairship.devices.tag.OpenChannelTags 35 | :members: 36 | 37 | Email Channel Tags 38 | ------------------- 39 | 40 | .. autoclass:: urbanairship.devices.email.EmailTags 41 | :members: 42 | :noindex: 43 | 44 | Named User Tags 45 | ---------------- 46 | 47 | .. autoclass:: urbanairship.devices.named_users.NamedUserTags 48 | :members: 49 | :inherited-members: 50 | :noindex: 51 | 52 | Named User 53 | =========== 54 | A Named User is a proprietary identifier that maps customer-chosen IDs, e.g., CRM data, to Channels. It is useful to think of a Named User as an individual consumer who might have more than one mobile device registered with your app. 55 | 56 | Examples can be found in `the named users documentation here. `_ 57 | 58 | .. autoclass:: urbanairship.devices.named_users.NamedUser 59 | :members: 60 | 61 | Named User List 62 | --------------- 63 | 64 | .. autoclass:: urbanairship.devices.named_users.NamedUserList 65 | :members: 66 | :inherited-members: 67 | :noindex: 68 | 69 | Attributes 70 | =========== 71 | Define and manage attributes. 72 | 73 | Examples can be found in `the attributes documentation here. `_ 74 | 75 | .. autoclass:: urbanairship.devices.attributes.Attribute 76 | :members: 77 | 78 | .. autoclass:: urbanairship.devices.attributes.ModifyAttributes 79 | :members: 80 | 81 | 82 | Lists 83 | ====== 84 | Create and manage audience lists. 85 | 86 | Attribute Lists 87 | --------------- 88 | Examples can be found in `the attributes documentation here. `_ 89 | 90 | .. autoclass:: urbanairship.devices.attributes.AttributeList 91 | :members: 92 | 93 | Subscription Lists 94 | ------------------- 95 | Examples can be found in `the subscription lists documentation here. `_ 96 | 97 | .. autoclass:: urbanairship.devices.subscription_lists.SubscriptionList 98 | :members: 99 | 100 | Static Lists 101 | ------------ 102 | Examples can be found in `the static lists documentation here. `_ 103 | 104 | .. autoclass:: urbanairship.devices.static_lists.StaticList 105 | :members: 106 | 107 | .. autoclass:: urbanairship.devices.static_lists.StaticLists 108 | :members: 109 | -------------------------------------------------------------------------------- /docs/client.rst: -------------------------------------------------------------------------------- 1 | Client Classes and Authentication 2 | ******************************** 3 | 4 | The Airship Python Library supports multiple authentication methods through different client classes. Each client class inherits from :py:class:`BaseClient` and provides specific authentication functionality. 5 | 6 | Base Client 7 | =========== 8 | 9 | .. autoclass:: urbanairship.client.BaseClient 10 | :members: 11 | :exclude-members: _request, request 12 | 13 | Basic Authentication 14 | =================== 15 | 16 | The :py:class:`BasicAuthClient` is used for traditional key/secret authentication. This is the same as the deprecated `Airship` client class. 17 | 18 | .. autoclass:: urbanairship.client.BasicAuthClient 19 | :members: 20 | :exclude-members: _request, request 21 | 22 | Example usage: 23 | 24 | .. code-block:: python 25 | 26 | import urbanairship as ua 27 | client = ua.client.BasicAuthClient('', '') 28 | 29 | # Create and send a push notification 30 | push = ua.Push(client) 31 | push.audience = ua.all_ 32 | push.notification = ua.notification(alert='Hello, world!') 33 | push.device_types = ua.device_types('ios', 'android') 34 | push.send() 35 | 36 | Bearer Token Authentication 37 | ========================= 38 | 39 | The :py:class:`BearerTokenClient` is used when you have an Airship-generated bearer token. This is useful when you want to manage token refresh yourself or when using tokens from other sources. 40 | 41 | .. autoclass:: urbanairship.client.BearerTokenClient 42 | :members: 43 | :exclude-members: _request, request 44 | 45 | Example usage: 46 | 47 | .. code-block:: python 48 | 49 | import urbanairship as ua 50 | client = ua.client.BearerTokenClient('', '') 51 | 52 | # Create and send a push notification 53 | push = ua.Push(client) 54 | push.audience = ua.all_ 55 | push.notification = ua.notification(alert='Hello, world!') 56 | push.device_types = ua.device_types('ios', 'android') 57 | push.send() 58 | 59 | OAuth2 Authentication 60 | ==================== 61 | 62 | The :py:class:`OAuthClient` handles OAuth2 authentication using JWT assertions. It automatically manages token refresh and is recommended for production use. 63 | 64 | .. autoclass:: urbanairship.client.OAuthClient 65 | :members: 66 | :exclude-members: _request, request, _update_session_oauth_token 67 | 68 | Example usage: 69 | 70 | .. code-block:: python 71 | 72 | import urbanairship as ua 73 | 74 | # Initialize with OAuth credentials 75 | client = ua.client.OAuthClient( 76 | key='', 77 | client_id='', 78 | private_key='', 79 | scope=['push:write', 'channels:read'] # Optional scopes 80 | ) 81 | 82 | # Create and send a push notification 83 | push = ua.Push(client) 84 | push.audience = ua.all_ 85 | push.notification = ua.notification(alert='Hello, world!') 86 | push.device_types = ua.device_types('ios', 'android') 87 | push.send() 88 | 89 | EU Data Center Support 90 | ===================== 91 | 92 | All client classes support the EU data center through the `location` parameter: 93 | 94 | .. code-block:: python 95 | 96 | # For EU data center 97 | eu_airship = ua.client.BasicAuthClient( 98 | key='', 99 | secret='', 100 | location='eu' 101 | ) 102 | -------------------------------------------------------------------------------- /urbanairship/urls.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class Urls: 5 | def __init__( 6 | self, 7 | location: Optional[str] = None, 8 | base_url: Optional[str] = None, 9 | oauth_base: bool = False, 10 | ) -> None: 11 | if base_url: 12 | self.base_url = base_url 13 | elif not location or location.lower() == "us": 14 | if oauth_base: 15 | self.base_url = "https://api.asnapius.com/api/" 16 | else: 17 | self.base_url = "https://go.urbanairship.com/api/" 18 | elif location.lower() == "eu": 19 | if oauth_base: 20 | self.base_url = "https://api.asnapieu.com/api/" 21 | else: 22 | self.base_url = "https://go.airship.eu/api/" 23 | 24 | self.channel_url = self.base_url + "channels/" 25 | self.open_channel_url = self.channel_url + "open/" 26 | self.device_token_url = self.base_url + "device_tokens/" 27 | self.apid_url = self.base_url + "apids/" 28 | self.push_url = self.base_url + "push/" 29 | self.validate_url = self.push_url + "validate/" 30 | self.schedules_url = self.base_url + "schedules/" 31 | self.tags_url = self.base_url + "tags/" 32 | self.segments_url = self.base_url + "segments/" 33 | self.reports_url = self.base_url + "reports/" 34 | self.lists_url = self.base_url + "lists/" 35 | self.attributes_url = self.channel_url + "attributes/" 36 | self.attributes_list_url = self.base_url + "attribute-lists/" 37 | self.message_center_delete_url = self.base_url + "user/messages/" 38 | self.subscription_lists_url = self.channel_url + "subscription_lists/" 39 | self.templates_url = self.base_url + "templates/" 40 | self.schedule_template_url = self.templates_url + "schedules/" 41 | self.pipelines_url = self.base_url + "pipelines/" 42 | self.named_user_url = self.base_url + "named_users/" 43 | self.named_user_tag_url = self.named_user_url + "tags/" 44 | self.named_user_disassociate_url = self.named_user_url + "disassociate/" 45 | self.named_user_associate_url = self.named_user_url + "associate/" 46 | self.named_user_uninstall_url = self.named_user_url + "uninstall/" 47 | self.sms_url = self.channel_url + "sms/" 48 | self.sms_opt_out_url = self.sms_url + "opt-out/" 49 | self.sms_uninstall_url = self.sms_url + "uninstall/" 50 | self.sms_custom_response_url = self.base_url + "sms/custom-response/" 51 | self.email_url = self.channel_url + "email/" 52 | self.email_tags_url = self.email_url + "tags/" 53 | self.email_uninstall_url = self.email_url + "uninstall/" 54 | self.create_and_send_url = self.base_url + "create-and-send/" 55 | self.schedule_create_and_send_url = self.schedules_url + "create-and-send/" 56 | self.experiments_url = self.base_url + "experiments/" 57 | self.experiments_schedule_url = self.experiments_url + "scheduled/" 58 | self.experiments_validate = self.experiments_url + "validate/" 59 | self.attachment_url = self.base_url + "attachments/" 60 | self.custom_events_url = self.base_url + "custom-events/" 61 | self.tag_lists_url = self.base_url + "tag-lists/" 62 | 63 | def get(self, endpoint: str) -> str: 64 | url: str = getattr(self, endpoint) 65 | 66 | if not url: 67 | raise AttributeError("No url for endpoint %s" % endpoint) 68 | 69 | return url 70 | -------------------------------------------------------------------------------- /urbanairship/experiments/core.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Dict, Optional 3 | 4 | from requests import Response 5 | 6 | from urbanairship.client import BaseClient 7 | from urbanairship.experiments.experiment import Experiment 8 | 9 | 10 | class ABTest(object): 11 | def __init__(self, airship: BaseClient): 12 | self.airship = airship 13 | 14 | def _get_listing(self, url: str, limit: Optional[int] = None) -> Response: 15 | """List experiments 16 | 17 | :keyword limit: Optional, maximum number of experiments, 18 | default is 10, max is 100 19 | """ 20 | 21 | params: Dict[str, Any] = {} 22 | if isinstance(limit, int): 23 | params["limit"] = limit 24 | 25 | response = self.airship.request( 26 | method="GET", body=None, params=params, url=url, version=3 27 | ) 28 | return response 29 | 30 | def list_experiments(self) -> Response: 31 | """List experiments, sorted by created_at date/time from newest to oldest 32 | 33 | :keyword limit: Positive maximum number of elements to return per page. 34 | Default limit is 10. Max: 100 and Min: 1. 35 | """ 36 | url = self.airship.urls.get("experiments_url") 37 | return self._get_listing(url) 38 | 39 | def create(self, experiment: Experiment) -> Response: 40 | """Create an experiment""" 41 | 42 | url = self.airship.urls.get("experiments_url") 43 | body = json.dumps(experiment.payload) 44 | response = self.airship.request( 45 | method="POST", 46 | body=body, 47 | url=url, 48 | content_type="application/json", 49 | version=3, 50 | ) 51 | return response 52 | 53 | def list_scheduled_experiment(self) -> Response: 54 | """List scheduled experiments in order, from closest to the current 55 | date-time to farthest""" 56 | 57 | url = self.airship.urls.get("experiments_schedule_url") 58 | return self._get_listing(url) 59 | 60 | def delete(self, experiment_id: str) -> Response: 61 | """Delete a scheduled experiment. You can only delete experiments before they start 62 | 63 | :keyword experiment_id: The unique identifier of the experiment, type string 64 | """ 65 | 66 | url = self.airship.urls.get("experiments_schedule_url") + "/" + experiment_id 67 | response = self.airship.request(method="DELETE", body=None, url=url, version=3) 68 | 69 | return response 70 | 71 | def validate(self, experiment: Experiment) -> Response: 72 | """Accepts the same range of payloads as /api/experiments, 73 | but only parses and validates the payload without creating the experiment. 74 | An experiment may validate and still fail to be delivered. For example, 75 | you may have a valid experiment with no devices in your audience. 76 | 77 | :keyword experiment: Body of the experiment you want to validate 78 | """ 79 | url = self.airship.urls.get("experiments_validate") 80 | body = json.dumps(experiment.payload) 81 | response = self.airship.request( 82 | method="POST", 83 | body=body, 84 | url=url, 85 | content_type="application/json", 86 | version=3, 87 | ) 88 | 89 | return response 90 | 91 | def lookup(self, experiment_id: str) -> Response: 92 | """Look up an experiment (A/B Test) 93 | 94 | :keyword experiment_id: The unique identifier of the experiment, type string 95 | """ 96 | 97 | url = self.airship.urls.get("experiments_url") + "/" + experiment_id 98 | response = self.airship.request(method="GET", body=None, url=url, version=3) 99 | 100 | return response 101 | -------------------------------------------------------------------------------- /tests/reports/test_experiments_reports.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | import requests 5 | from mock import Mock 6 | 7 | import urbanairship as ua 8 | from tests import TEST_KEY, TEST_SECRET 9 | 10 | 11 | class TestExperimentsReports(unittest.TestCase): 12 | def test_experiment_overview(self): 13 | mock_response = requests.Response() 14 | mock_response._content = json.dumps( 15 | { 16 | "app_key": TEST_KEY, 17 | "experiment_id": "24cf2af1-9961-4f3f-b301-75505c240358", 18 | "push_id": "bb74f63c-c1c8-4618-800d-a04478e7d28c", 19 | "created": "2021-11-12 13:44:09", 20 | "sends": 6, 21 | "direct_responses": 0, 22 | "influenced_responses": 0, 23 | "web_clicks": 0, 24 | "web_sessions": 0, 25 | "variants": [ 26 | { 27 | "id": 0, 28 | "name": "Test A", 29 | "audience_pct": 80.0, 30 | "sends": 6, 31 | "direct_responses": 0, 32 | "direct_response_pct": 0.0, 33 | "indirect_responses": 0, 34 | "indirect_response_pct": 0.0, 35 | } 36 | ], 37 | "control": { 38 | "audience_pct": 20.0, 39 | "sends": 1, 40 | "responses": 0, 41 | "response_rate_pct": 0.0, 42 | }, 43 | } 44 | ).encode("UTF-8") 45 | 46 | ua.Airship._request = Mock() 47 | ua.Airship._request.side_effect = [mock_response] 48 | 49 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 50 | overview = ua.ExperimentReport(airship).get_overview( 51 | push_id="bb74f63c-c1c8-4618-800d-a04478e7d28c" 52 | ) 53 | self.assertEqual(overview.get("app_key"), TEST_KEY) 54 | self.assertEqual(overview.get("sends"), 6) 55 | self.assertEqual(type(overview.get("variants")), list) 56 | self.assertEqual(type(overview.get("control")), dict) 57 | 58 | def test_variant_overview(self): 59 | mock_response = requests.Response() 60 | mock_response._content = json.dumps( 61 | { 62 | "app_key": TEST_KEY, 63 | "experiment_id": "24cf2af1-9961-4f3f-b301-75505c240358", 64 | "push_id": "bb74f63c-c1c8-4618-800d-a04478e7d28c", 65 | "created": "2021-11-12 13:44:09", 66 | "variant": 0, 67 | "variant_name": "Test A", 68 | "sends": 6, 69 | "direct_responses": 0, 70 | "influenced_responses": 0, 71 | "platforms": { 72 | "amazon": { 73 | "type": "devicePlatformBreakdown", 74 | "direct_responses": 0, 75 | "influenced_responses": 0, 76 | "sends": 0, 77 | }, 78 | "ios": { 79 | "type": "devicePlatformBreakdown", 80 | "direct_responses": 0, 81 | "influenced_responses": 0, 82 | "sends": 5, 83 | }, 84 | "web": { 85 | "type": "webPlatformBreakdown", 86 | "direct_responses": 0, 87 | "indirect_responses": 0, 88 | "sends": 0, 89 | }, 90 | "android": { 91 | "type": "devicePlatformBreakdown", 92 | "direct_responses": 0, 93 | "influenced_responses": 0, 94 | "sends": 1, 95 | }, 96 | }, 97 | } 98 | ).encode("UTF-8") 99 | 100 | ua.Airship._request = Mock() 101 | ua.Airship._request.side_effect = [mock_response] 102 | 103 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 104 | variant = ua.ExperimentReport(airship).get_variant( 105 | push_id="bb74f63c-c1c8-4618-800d-a04478e7d28c", variant_id=0 106 | ) 107 | 108 | self.assertEqual(variant.get("app_key"), TEST_KEY) 109 | self.assertEqual(variant.get("variant"), 0) 110 | -------------------------------------------------------------------------------- /urbanairship/automation/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from typing import TYPE_CHECKING, List, Optional, Union 5 | 6 | from requests import Response 7 | 8 | from urbanairship.automation.pipeline import Pipeline 9 | 10 | if TYPE_CHECKING: 11 | from urbanairship.client import BaseClient 12 | 13 | 14 | class Automation(object): 15 | """An object for getting and creating automations. 16 | 17 | :keyword airship: An urbanairship.Airship instance, 18 | instantiated with the key corresponding to the airship project you wish 19 | to use. 20 | """ 21 | 22 | def __init__(self, airship: BaseClient) -> None: 23 | self.airship = airship 24 | 25 | def create(self, pipelines: Union[Pipeline, List[Pipeline]]) -> Response: 26 | """Create an automation with one or more Pipeline payloads 27 | 28 | :keyword pipelines: A single Pipeline payload or list of Pipeline payloads 29 | """ 30 | url = self.airship.urls.get("pipelines_url") 31 | body = json.dumps(pipelines) 32 | response = self.airship.request( 33 | method="POST", 34 | body=body, 35 | url=url, 36 | content_type="application/json", 37 | version=3, 38 | ) 39 | 40 | return response 41 | 42 | def validate(self, pipelines: Union[Pipeline, List[Pipeline]]) -> Response: 43 | """Validate a Pipeline payloads 44 | 45 | :keyword pipelines: A single Pipeline payload or list of Pipeline payloads 46 | """ 47 | url = self.airship.urls.get("pipelines_url") + "validate/" 48 | body = json.dumps(pipelines) 49 | response = self.airship.request( 50 | method="POST", 51 | body=body, 52 | url=url, 53 | content_type="application/json", 54 | version=3, 55 | ) 56 | 57 | return response 58 | 59 | def update(self, pipeline_id: str, pipeline: Pipeline) -> Response: 60 | """Update an existing Automation Pipeline 61 | 62 | :keyword pipeline_id: A Pipeline ID 63 | :keyword pipeline: Full Pipeline payload; partial updates are not supported 64 | """ 65 | url = self.airship.urls.get("pipelines_url") + pipeline_id 66 | body = json.dumps(pipeline) 67 | response = self.airship.request( 68 | method="PUT", body=body, url=url, content_type="application/json", version=3 69 | ) 70 | 71 | return response 72 | 73 | def delete(self, pipeline_id: str) -> Response: 74 | """Delete an existing Automation Pipeline 75 | 76 | :keyword pipeline_id: A Pipeline ID 77 | """ 78 | url = self.airship.urls.get("pipelines_url") + pipeline_id 79 | response = self.airship.request(method="DELETE", body=None, url=url, version=3) 80 | 81 | return response 82 | 83 | def lookup(self, pipeline_id: str) -> Response: 84 | """Lookup an Automation Pipeline 85 | 86 | :keyword pipeline_id: A Pipeline ID 87 | """ 88 | url = self.airship.urls.get("pipelines_url") + pipeline_id 89 | response = self.airship.request(method="GET", body=None, url=url, version=3) 90 | 91 | return response 92 | 93 | def list_automations( 94 | self, limit: Optional[int] = None, enabled: bool = True 95 | ) -> Response: 96 | """List active Automations 97 | 98 | :keyword limit: Optional, maximum pipelines to return 99 | :keyword enabled: Optional, boolean limits results to either enabled or not 100 | enabled Pipelines (defaults to True) 101 | """ 102 | params = {} 103 | if isinstance(limit, int): 104 | params["limit"] = limit 105 | if isinstance(enabled, bool): 106 | params["enabled"] = enabled 107 | 108 | url = self.airship.urls.get("pipelines_url") 109 | response = self.airship.request( 110 | method="GET", body=None, params=params, url=url, version=3 111 | ) 112 | 113 | return response 114 | 115 | def list_deleted_automations(self, start: Optional[str] = None) -> Response: 116 | """List deleted Automation Pipelines 117 | 118 | :keyword start: Optional starting timestamp for limiting results in ISO-8601 119 | format 120 | """ 121 | params = {} 122 | if start: 123 | params["start"] = start 124 | url = self.airship.urls.get("pipelines_url") + "deleted/" 125 | response = self.airship.request( 126 | method="GET", body=None, params=params, url=url, version=3 127 | ) 128 | 129 | return response 130 | -------------------------------------------------------------------------------- /urbanairship/devices/tag_lists.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Dict, List, Optional 3 | 4 | from requests import Response 5 | 6 | from urbanairship.client import BaseClient 7 | from urbanairship.devices.static_lists import GzipCompressReadStream 8 | 9 | 10 | class TagList: 11 | """Create, Upload, Delete, and get information for a tag list. 12 | Please see the Airship API documentation for more information 13 | about CSV formatting, limits, and use of this feature. 14 | 15 | ;param airship: Required. An urbanairship.Airship instance. 16 | :param list_name: Required. A name for the list this instance represents. 17 | :param description: Optional. A description of the list. 18 | :param extra: Optional. A a dictionary of string mappings associated with the list. 19 | :param add_tags: Optional. A dictionary consisting of a tag group string and list of tag 20 | string to add to uploaded channels. 21 | :param remove_tags: Optional. A dictionary consisting of a tag group string and list of 22 | tag strings to remove from uploaded channels. 23 | :param set_tags: Optional. A dictionary consisting of a tag group string and list of tag 24 | strings to set on uploaded channels. Warning: This action is destructive and will 25 | remove all existing tags associated with channels. 26 | """ 27 | 28 | def __init__( 29 | self, 30 | airship: BaseClient, 31 | list_name: str, 32 | description: Optional[str] = None, 33 | extra: Optional[Dict[str, str]] = None, 34 | add_tags: Optional[Dict[str, List[str]]] = None, 35 | remove_tags: Optional[Dict[str, List[str]]] = None, 36 | set_tags: Optional[Dict[str, List[str]]] = None, 37 | ) -> None: 38 | self.airship = airship 39 | self.list_name = list_name 40 | self.description = description 41 | self.extra = extra 42 | self.add_tags = add_tags 43 | self.remove_tags = remove_tags 44 | self.set_tags = set_tags 45 | 46 | @property 47 | def _create_payload(self) -> Dict: 48 | payload: Dict[str, Any] = {"name": self.list_name} 49 | 50 | if self.description: 51 | payload["description"] = self.description 52 | if self.extra: 53 | payload["extra"] = self.extra 54 | if self.add_tags: 55 | payload["add"] = self.add_tags 56 | if self.remove_tags: 57 | payload["remove"] = self.remove_tags 58 | if self.set_tags: 59 | payload["set"] = self.set_tags 60 | 61 | return payload 62 | 63 | def create(self) -> Response: 64 | """Create a new tag list. Channels must be uploaded after creation using 65 | the `upload` method. 66 | 67 | :return: Response object 68 | """ 69 | response = self.airship.request( 70 | method="POST", 71 | url=self.airship.urls.get("tag_lists_url"), 72 | body=json.dumps(self._create_payload), 73 | content_type="application/json", 74 | version=3, 75 | ) 76 | 77 | return response 78 | 79 | def upload(self, file_path: str) -> Response: 80 | """Upload a CSV file of channels. See the Airship API documentation 81 | for information about CSV file formatting requirements and limits. 82 | 83 | :param file_path: Path to the CSV file to upload. 84 | 85 | :return: Response object 86 | """ 87 | with open(file_path, "rb") as open_file: 88 | response = self.airship.request( 89 | method="PUT", 90 | body=GzipCompressReadStream(open_file), 91 | url=f"{self.airship.urls.get('tag_lists_url')}/{self.list_name}/csv", 92 | content_type="text/csv", 93 | version=3, 94 | encoding="gzip", 95 | ) 96 | return response 97 | 98 | def get_errors(self) -> Response: 99 | """Returns a csv of tag list processing errors. 100 | 101 | :return: Response object 102 | """ 103 | response = self.airship.request( 104 | method="GET", 105 | body={}, 106 | url=f"{self.airship.urls.get('tag_lists_url')}/{self.list_name}/errors", 107 | version=3, 108 | ) 109 | return response 110 | 111 | @classmethod 112 | def list(cls, airship: BaseClient) -> Response: 113 | """Returns a json string with details on all tag lists associated with 114 | an Airship instance / project. 115 | 116 | :return: Response object 117 | """ 118 | response = airship.request( 119 | method="GET", body={}, url=airship.urls.get("tag_lists_url"), version=3 120 | ) 121 | return response 122 | -------------------------------------------------------------------------------- /urbanairship/common.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | from typing import Any, Dict, Optional, Union 4 | 5 | import six 6 | 7 | logger = logging.getLogger("urbanairship") 8 | 9 | 10 | class Unauthorized(Exception): 11 | """Raised when we get a 401 from the server""" 12 | 13 | 14 | class ConnectionFailure(Exception): 15 | """Raised when there's a connection failure.""" 16 | 17 | 18 | class AirshipFailure(Exception): 19 | """Raised when we get an error response from the server. 20 | 21 | :param args: For backwards compatibility, ``*args`` includes the status and 22 | response body. 23 | 24 | """ 25 | 26 | error: Optional[str] = None 27 | error_code: Union[int, str, None] = None 28 | details: Optional[str] = None 29 | response: Optional[str] = None 30 | 31 | def __init__( 32 | self, 33 | error: Optional[str], 34 | error_code: Union[str, int, None], 35 | details: Optional[str], 36 | response: Optional[str], 37 | *args: Any, 38 | ) -> None: 39 | self.error = error 40 | self.error_code = error_code 41 | self.details = details 42 | self.response = response 43 | super(AirshipFailure, self).__init__(*args) 44 | 45 | @classmethod 46 | def from_response(cls, response): 47 | """ 48 | Instantiate a ValidationFailure from a Response object 49 | :param response: response object used to create failure obj 50 | """ 51 | try: 52 | payload = response.json() 53 | error = payload.get("error") 54 | error_code = payload.get("error_code") 55 | details = payload.get("details") 56 | except (ValueError, TypeError, KeyError): 57 | error = response.reason 58 | error_code = response.status_code 59 | details = response.content 60 | 61 | logger.warning( 62 | "Request failed with status %d: '%s %s': %s", 63 | response.status_code, 64 | error_code, 65 | error, 66 | details, 67 | ) 68 | 69 | return cls(error, error_code, details, response, response.status_code, response.content) 70 | 71 | 72 | class IteratorDataObj(object): 73 | airship = None 74 | payload: Optional[Dict[str, Any]] = None 75 | device_type: Optional[str] = None 76 | id: Optional[str] = None 77 | 78 | @classmethod 79 | def from_payload( 80 | cls, 81 | payload: Dict[str, Any], 82 | device_key: Optional[str] = None, 83 | airship=None, 84 | ) -> "IteratorDataObj": 85 | obj = cls() 86 | if device_key and payload[device_key]: 87 | obj.id = payload[device_key] 88 | if airship: 89 | obj.airship = airship 90 | for key in payload: 91 | try: 92 | val = datetime.datetime.strptime(payload[key], "%Y-%m-%d %H:%M:%S") 93 | except (TypeError, ValueError): 94 | val = payload[key] 95 | setattr(obj, key, val) 96 | return obj 97 | 98 | def __str__(self) -> str: 99 | print_str = "" 100 | for attr in dir(self): 101 | if not attr.startswith("__") and not callable(getattr(self, attr)): 102 | print_str += attr + ": " + str(getattr(self, attr)) + ", " 103 | return print_str[:-2] 104 | 105 | 106 | class IteratorParent(six.Iterator): 107 | next_url: Optional[str] = None 108 | data_attribute: Optional[str] = None 109 | data_list: Optional[Any] = None 110 | params: Optional[Any] = None 111 | id_key: Optional[Any] = None 112 | instance_class: Any = IteratorDataObj 113 | 114 | def __init__(self, airship, params): 115 | self.airship = airship 116 | self.params = params 117 | self._token_iter = iter(()) 118 | 119 | def __iter__(self): 120 | return self 121 | 122 | def __next__(self): 123 | try: 124 | return self.instance_class.from_payload( 125 | next(self._token_iter), self.id_key, self.airship 126 | ) 127 | except StopIteration: 128 | if self._load_page(): 129 | return self.instance_class.from_payload( 130 | next(self._token_iter), self.id_key, self.airship 131 | ) 132 | else: 133 | raise StopIteration 134 | 135 | def _load_page(self) -> bool: 136 | if not self.next_url: 137 | return False 138 | response = self.airship.request( 139 | method="GET", body=None, url=self.next_url, version=3, params=self.params 140 | ) 141 | self.params = None 142 | self._page = response.json() 143 | check_url = self._page.get("next_page") 144 | if check_url == self.next_url: 145 | return False 146 | self.next_url = check_url 147 | self._token_iter = iter(self._page[self.data_attribute]) 148 | return True 149 | -------------------------------------------------------------------------------- /urbanairship/experiments/experiment.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional 2 | 3 | from urbanairship.experiments.variant import Variant 4 | 5 | 6 | class Experiment(object): 7 | """An experiment object describes an A/B test, 8 | including the audience and variant portions. 9 | """ 10 | 11 | def __init__( 12 | self, 13 | audience: Dict[str, Any], 14 | device_types: List[str], 15 | variants: List[Variant], 16 | name: Optional[str] = None, 17 | description: Optional[str] = None, 18 | weight: Optional[int] = None, 19 | campaigns: Optional[Dict[str, Any]] = None, 20 | control: Optional[float] = None, 21 | ) -> None: 22 | """ 23 | :keyword audience: [required] The audience for the experiment 24 | :keyword device_types: A list containing one or more strings identifying 25 | targeted platforms. Accepted platforms are ios, android, amazon, wns, web, 26 | sms, email, and open:: 27 | :keyword variants: [required] The variants for the experiment. An experiment 28 | must have at least 1 variant and no more than 26. 29 | :keyword name: [optional] A name for the experiment 30 | :keyword description: [optional] A description of the experiment 31 | :keyword campaigns: [optional] Campaigns object that will be applied to 32 | resulting pushes 33 | :keyword control: [optional] The proportional subset of the audience that will 34 | not receive a push 35 | 36 | """ 37 | self.audience = audience 38 | self.device_types = device_types 39 | self.variants = variants 40 | self.name = name 41 | self.description = description 42 | self.campaigns = campaigns 43 | self.control = control 44 | self.weight = weight 45 | 46 | @property 47 | def payload(self) -> Dict[str, Any]: 48 | """JSON serialized experiment object""" 49 | 50 | variants_data: List = [] 51 | for variant in self.variants: 52 | variant_data: Dict[str, Any] = {} 53 | push_options: Dict[str, Any] = {} 54 | 55 | if getattr(variant, "description", None): 56 | variant_data["description"] = variant.description 57 | if getattr(variant, "name", None): 58 | variant_data["name"] = variant.name 59 | 60 | if getattr(variant.push, "in_app", None): 61 | push_options["in_app"] = variant.push.in_app 62 | if getattr(variant.push, "notification", None): 63 | push_options["notification"] = variant.push.notification 64 | if getattr(variant.push, "options", None): 65 | push_options["options"] = variant.push.options 66 | if getattr(variant, "schedule", None): 67 | variant_data["schedule"] = variant.schedule 68 | if getattr(variant, "weight", None): 69 | variant_data["weight"] = variant.weight 70 | 71 | variant_data["push"] = push_options 72 | variants_data.append(variant_data) 73 | 74 | data: Dict[str, Any] = { 75 | "audience": self.audience, 76 | "device_types": self.device_types, 77 | "variants": variants_data, 78 | } 79 | 80 | if self.name is not None: 81 | data["name"] = self.name 82 | if self.description is not None: 83 | data["description"] = self.description 84 | if self.campaigns is not None: 85 | data["campaigns"] = self.campaigns 86 | if self.control is not None: 87 | data["control"] = self.control 88 | 89 | if self.weight is not None: 90 | variant_data["weight"] = self.weight 91 | 92 | return data 93 | 94 | @property 95 | def name(self) -> Optional[str]: 96 | if not self._name: 97 | return None 98 | return self._name 99 | 100 | @name.setter 101 | def name(self, value: Optional[str]) -> None: 102 | if not isinstance(value, str): 103 | TypeError("the name must be a string type") 104 | 105 | self._name = value 106 | 107 | @property 108 | def description(self) -> Optional[str]: 109 | if not self._description: 110 | return None 111 | return self._description 112 | 113 | @description.setter 114 | def description(self, value: Optional[str]) -> None: 115 | if not isinstance(value, str): 116 | TypeError("the description must be type string") 117 | 118 | self._description = value 119 | 120 | @property 121 | def control(self) -> Optional[float]: 122 | if not self._control: 123 | return None 124 | return self._control 125 | 126 | @control.setter 127 | def control(self, value: Optional[float]) -> None: 128 | if not isinstance(value, float): 129 | TypeError("the control must be type float") 130 | if value is not None: 131 | if not 0.0 >= value >= 1.0: 132 | ValueError("control must be in a range of 0.0 and 1.0") 133 | 134 | self._control = value 135 | -------------------------------------------------------------------------------- /urbanairship/__init__.py: -------------------------------------------------------------------------------- 1 | """Python package for using the Airship API""" 2 | 3 | import logging 4 | from typing import Any, List 5 | 6 | from .automation.core import Automation 7 | from .automation.pipeline import Pipeline 8 | from .client import BasicAuthClient, BearerTokenClient, OAuthClient 9 | from .common import AirshipFailure, ConnectionFailure, Unauthorized 10 | from .core import Airship as _DeprecatedAirship 11 | from .custom_events import CustomEvent 12 | from .devices import ( 13 | APIDList, 14 | Attribute, 15 | AttributeList, 16 | AttributeResponse, 17 | ChannelInfo, 18 | ChannelList, 19 | ChannelTags, 20 | ChannelUninstall, 21 | DeviceInfo, 22 | DeviceTokenList, 23 | Email, 24 | EmailAttachment, 25 | EmailTags, 26 | KeywordInteraction, 27 | ModifyAttributes, 28 | NamedUser, 29 | NamedUserList, 30 | NamedUserTags, 31 | OpenChannel, 32 | OpenChannelTags, 33 | Segment, 34 | SegmentList, 35 | Sms, 36 | SmsCustomResponse, 37 | StaticList, 38 | StaticLists, 39 | SubscriptionList, 40 | TagList, 41 | ) 42 | from .experiments import ABTest, Experiment, Variant 43 | from .push import ( 44 | CreateAndSendPush, 45 | Push, 46 | ScheduledList, 47 | ScheduledPush, 48 | Template, 49 | TemplateList, 50 | TemplatePush, 51 | actions, 52 | alias, 53 | all_, 54 | amazon, 55 | amazon_channel, 56 | and_, 57 | android, 58 | android_channel, 59 | apid, 60 | best_time, 61 | campaigns, 62 | channel, 63 | date_attribute, 64 | device_token, 65 | device_types, 66 | email, 67 | in_app, 68 | interactive, 69 | ios, 70 | ios_channel, 71 | live_activity, 72 | live_update, 73 | local_scheduled_time, 74 | localization, 75 | media_attachment, 76 | merge_data, 77 | message, 78 | mms, 79 | named_user, 80 | not_, 81 | notification, 82 | number_attribute, 83 | open_channel, 84 | open_platform, 85 | options, 86 | or_, 87 | public_notification, 88 | recurring_schedule, 89 | schedule_exclusion, 90 | scheduled_time, 91 | segment, 92 | sms, 93 | sms_id, 94 | sms_sender, 95 | static_list, 96 | style, 97 | subscription_list, 98 | tag, 99 | tag_group, 100 | text_attribute, 101 | wearable, 102 | web, 103 | wns, 104 | wns_payload, 105 | ) 106 | from .reports import ( 107 | AppOpensList, 108 | CustomEventsList, 109 | DevicesReport, 110 | ExperimentReport, 111 | IndividualResponseStats, 112 | OptInList, 113 | OptOutList, 114 | PushList, 115 | ResponseList, 116 | ResponseReportList, 117 | TimeInAppList, 118 | WebResponseReport, 119 | ) 120 | 121 | Airship = BasicAuthClient 122 | 123 | __all__: List[Any] = [ 124 | BasicAuthClient, 125 | BearerTokenClient, 126 | OAuthClient, 127 | Airship, 128 | AirshipFailure, 129 | ConnectionFailure, 130 | Unauthorized, 131 | all_, 132 | Push, 133 | ScheduledPush, 134 | TemplatePush, 135 | ios_channel, 136 | android_channel, 137 | amazon_channel, 138 | channel, 139 | open_channel, 140 | device_token, 141 | apid, 142 | wns, 143 | tag, 144 | tag_group, 145 | alias, 146 | segment, 147 | sms_id, 148 | sms_sender, 149 | mms, 150 | and_, 151 | or_, 152 | not_, 153 | notification, 154 | ios, 155 | android, 156 | amazon, 157 | web, 158 | wns_payload, 159 | open_platform, 160 | message, 161 | in_app, 162 | options, 163 | campaigns, 164 | actions, 165 | interactive, 166 | device_types, 167 | scheduled_time, 168 | local_scheduled_time, 169 | sms, 170 | email, 171 | wearable, 172 | public_notification, 173 | style, 174 | best_time, 175 | named_user, 176 | merge_data, 177 | recurring_schedule, 178 | schedule_exclusion, 179 | static_list, 180 | subscription_list, 181 | localization, 182 | live_activity, 183 | live_update, 184 | media_attachment, 185 | ChannelList, 186 | ChannelInfo, 187 | OpenChannel, 188 | Sms, 189 | DeviceTokenList, 190 | APIDList, 191 | DeviceInfo, 192 | Segment, 193 | SegmentList, 194 | ChannelUninstall, 195 | NamedUser, 196 | NamedUserList, 197 | NamedUserTags, 198 | IndividualResponseStats, 199 | ResponseList, 200 | DevicesReport, 201 | OptInList, 202 | OptOutList, 203 | PushList, 204 | ResponseReportList, 205 | AppOpensList, 206 | TimeInAppList, 207 | CustomEventsList, 208 | StaticList, 209 | StaticLists, 210 | Template, 211 | TemplateList, 212 | ScheduledList, 213 | Automation, 214 | Pipeline, 215 | Email, 216 | EmailTags, 217 | EmailAttachment, 218 | CreateAndSendPush, 219 | date_attribute, 220 | text_attribute, 221 | number_attribute, 222 | ChannelTags, 223 | OpenChannelTags, 224 | Attribute, 225 | AttributeResponse, 226 | AttributeList, 227 | ModifyAttributes, 228 | WebResponseReport, 229 | ExperimentReport, 230 | KeywordInteraction, 231 | SubscriptionList, 232 | CustomEvent, 233 | SmsCustomResponse, 234 | TagList, 235 | ABTest, 236 | Experiment, 237 | Variant, 238 | ] 239 | 240 | 241 | logging.getLogger("requests.packages.urllib3.connectionpool").setLevel(logging.WARNING) 242 | -------------------------------------------------------------------------------- /tests/push/test_schedule.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | import urbanairship as ua 5 | 6 | 7 | class TestSchedule(unittest.TestCase): 8 | def setUp(self): 9 | self.start_hour = 7 10 | self.end_hour = 15 11 | self.start_date = datetime.datetime(2021, 1, 15) 12 | self.end_date = datetime.datetime(2021, 4, 15) 13 | self.days_of_week = ["friday", "saturday", "sunday"] 14 | 15 | def test_scheduled_time(self): 16 | d = datetime.datetime(2013, 1, 1, 12, 56) 17 | self.assertEqual( 18 | ua.scheduled_time(d), {"scheduled_time": "2013-01-01T12:56:00"} 19 | ) 20 | 21 | def test_local_scheduled_time(self): 22 | d = datetime.datetime(2015, 1, 1, 12, 56) 23 | self.assertEqual( 24 | ua.local_scheduled_time(d), {"local_scheduled_time": "2015-01-01T12:56:00"} 25 | ) 26 | 27 | def test_best_time(self): 28 | d = datetime.datetime(2018, 10, 8) 29 | self.assertEqual(ua.best_time(d), {"best_time": {"send_date": "2018-10-08"}}) 30 | 31 | def test_schedule_exclusion(self): 32 | self.assertEqual( 33 | ua.schedule_exclusion( 34 | start_hour=self.start_hour, 35 | end_hour=self.end_hour, 36 | start_date=self.start_date, 37 | end_date=self.end_date, 38 | days_of_week=self.days_of_week, 39 | ), 40 | { 41 | "hour_range": "7-15", 42 | "date_range": "2021-01-15T00:00:00/2021-04-15T00:00:00", 43 | "days_of_week": ["friday", "saturday", "sunday"], 44 | }, 45 | ) 46 | 47 | def test_schedule_exclusion_raises_bad_hour(self): 48 | with self.assertRaises(ValueError): 49 | ua.schedule_exclusion( 50 | start_hour=self.start_hour, 51 | end_hour=25, 52 | start_date=self.start_date, 53 | end_date=self.end_date, 54 | days_of_week=self.days_of_week, 55 | ) 56 | 57 | def test_schedule_exclusion_raises_bad_date(self): 58 | with self.assertRaises(ValueError): 59 | ua.schedule_exclusion( 60 | start_hour=self.start_hour, 61 | end_hour=self.end_hour, 62 | start_date=self.start_date, 63 | end_date="not_a_datetime", 64 | days_of_week=self.days_of_week, 65 | ) 66 | 67 | def test_schedule_exclusion_raises_bad_day_of_week(self): 68 | with self.assertRaises(ValueError): 69 | ua.schedule_exclusion( 70 | start_hour=self.start_hour, 71 | end_hour=self.end_hour, 72 | start_date=self.start_date, 73 | end_date=self.end_date, 74 | days_of_week=["fakesday"], 75 | ) 76 | 77 | def test_recurring_schedule_standard(self): 78 | self.assertEqual( 79 | ua.recurring_schedule( 80 | count=1, 81 | type="daily", 82 | end_time=datetime.datetime(2030, 1, 15, 12, 0, 0), 83 | paused=False, 84 | exclusions=[ 85 | ua.schedule_exclusion( 86 | start_hour=self.start_hour, 87 | end_hour=self.end_hour, 88 | start_date=self.start_date, 89 | end_date=self.end_date, 90 | days_of_week=self.days_of_week, 91 | ) 92 | ], 93 | ), 94 | { 95 | "recurring": { 96 | "cadence": {"type": "daily", "count": 1}, 97 | "end_time": "2030-01-15T12:00:00", 98 | "exclusions": [ 99 | { 100 | "hour_range": "7-15", 101 | "date_range": "2021-01-15T00:00:00/2021-04-15T00:00:00", 102 | "days_of_week": ["friday", "saturday", "sunday"], 103 | } 104 | ], 105 | "paused": False, 106 | } 107 | }, 108 | ) 109 | 110 | def test_recurring_schedule_weekly(self): 111 | self.assertEqual( 112 | ua.recurring_schedule( 113 | count=1, 114 | type="weekly", 115 | days_of_week=["monday", "wednesday", "friday"], 116 | end_time=datetime.datetime(2030, 1, 15, 12, 0, 0), 117 | paused=False, 118 | exclusions=[ 119 | ua.schedule_exclusion( 120 | start_hour=self.start_hour, 121 | end_hour=self.end_hour, 122 | start_date=self.start_date, 123 | end_date=self.end_date, 124 | days_of_week=self.days_of_week, 125 | ) 126 | ], 127 | ), 128 | { 129 | "recurring": { 130 | "cadence": { 131 | "type": "weekly", 132 | "count": 1, 133 | "days_of_week": ["monday", "wednesday", "friday"], 134 | }, 135 | "end_time": "2030-01-15T12:00:00", 136 | "exclusions": [ 137 | { 138 | "hour_range": "7-15", 139 | "date_range": "2021-01-15T00:00:00/2021-04-15T00:00:00", 140 | "days_of_week": ["friday", "saturday", "sunday"], 141 | } 142 | ], 143 | "paused": False, 144 | } 145 | }, 146 | ) 147 | 148 | def test_recurring_schedule_raises_bad_day(self): 149 | with self.assertRaises(ValueError): 150 | ua.recurring_schedule(count=1, type="weekly", days_of_week=["fakesday"]) 151 | 152 | def test_recurring_schedule_raises_bad_type(self): 153 | with self.assertRaises(ValueError): 154 | ua.recurring_schedule(count=1, type="fake_type") 155 | -------------------------------------------------------------------------------- /tests/devices/test_named_user.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | import mock 5 | import requests 6 | from mock import Mock 7 | 8 | import urbanairship as ua 9 | from tests import TEST_KEY, TEST_SECRET 10 | 11 | 12 | class TestNamedUser(unittest.TestCase): 13 | def test_Named_User(self): 14 | ok_true = json.dumps({"ok": True}).encode("utf-8") 15 | 16 | associate_response = requests.Response() 17 | associate_response.status_code = 200 18 | associate_response._content = ok_true 19 | 20 | disassociate_response = requests.Response() 21 | disassociate_response._content = ok_true 22 | disassociate_response.status_code = 200 23 | 24 | lookup_response = requests.Response() 25 | lookup_response._content = json.dumps( 26 | { 27 | "ok": True, 28 | "named_user": { 29 | "named_user_id": "name1", 30 | "tags": {"group_name": ["tag1", "tag2"]}, 31 | }, 32 | } 33 | ).encode("utf-8") 34 | 35 | ua.Airship._request = Mock() 36 | ua.Airship._request.side_effect = [ 37 | associate_response, 38 | disassociate_response, 39 | lookup_response, 40 | ] 41 | 42 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 43 | 44 | nu = ua.NamedUser(airship, "name1") 45 | 46 | associate = nu.associate(channel_id="channel_id", device_type="ios") 47 | self.assertEqual(associate.status_code, 200) 48 | self.assertEqual(associate.ok, True) 49 | 50 | disassociate = nu.disassociate(channel_id="channel_id", device_type="ios") 51 | self.assertEqual(disassociate.status_code, 200) 52 | self.assertEqual(disassociate.ok, True) 53 | 54 | lookup = nu.lookup() 55 | 56 | self.assertEqual(lookup["ok"], True) 57 | self.assertEqual( 58 | lookup["named_user"], 59 | {"named_user_id": "name1", "tags": {"group_name": ["tag1", "tag2"]}}, 60 | ) 61 | 62 | def test_channel_associate_payload_property(self): 63 | named_user = ua.NamedUser( 64 | airship=ua.Airship(TEST_KEY, TEST_SECRET), named_user_id="cowboy_dan" 65 | ) 66 | named_user.channel_id = "524bb82-8499-4ba5-b313-2157b1b1771f" 67 | named_user.device_type = "ios" 68 | 69 | self.assertEqual( 70 | named_user._channel_associate_payload, 71 | { 72 | "named_user_id": "cowboy_dan", 73 | "channel_id": "524bb82-8499-4ba5-b313-2157b1b1771f", 74 | "device_type": "ios", 75 | }, 76 | ) 77 | 78 | def test_email_associate_payload_property(self): 79 | named_user = ua.NamedUser( 80 | airship=ua.Airship(TEST_KEY, TEST_SECRET), named_user_id="cowboy_dan" 81 | ) 82 | named_user.email_address = "major_player@cowboyscene.net" 83 | 84 | self.assertEqual( 85 | named_user._email_associate_payload, 86 | { 87 | "named_user_id": "cowboy_dan", 88 | "email_address": "major_player@cowboyscene.net", 89 | }, 90 | ) 91 | 92 | def test_named_user_uninstall_raises(self): 93 | with self.assertRaises(ValueError): 94 | ua.NamedUser.uninstall( 95 | airship=ua.Airship(TEST_KEY, TEST_SECRET), 96 | named_users="should_be_a_list", 97 | ) 98 | 99 | def test_named_user_tag(self): 100 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 101 | nu = ua.NamedUser(airship, "named_user_id") 102 | 103 | self.assertRaises( 104 | ValueError, 105 | nu.tag, 106 | "tag_group_name", 107 | add={"group": "tag"}, 108 | set={"group": "other_tag"}, 109 | ) 110 | 111 | def test_named_user_update_raises(self): 112 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 113 | nu = ua.NamedUser(airship, "named_user_id") 114 | 115 | with self.assertRaises(ValueError): 116 | nu.update() 117 | 118 | def test_named_user_attributes_raises(self): 119 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 120 | nu = ua.NamedUser(airship, "named_user_id") 121 | 122 | with self.assertRaises(ValueError): 123 | nu.attributes( 124 | attributes={"action": "set", "key": "type", "value": "not_a_list"} 125 | ) 126 | 127 | 128 | class TestNamedUserList(unittest.TestCase): 129 | def test_NamedUserlist_iteration(self): 130 | with mock.patch.object(ua.Airship, "_request") as mock_request: 131 | response = requests.Response() 132 | response._content = json.dumps( 133 | { 134 | "named_users": [ 135 | {"named_user_id": "name1"}, 136 | {"named_user_id": "name2"}, 137 | {"named_user_id": "name3"}, 138 | ] 139 | } 140 | ).encode("utf-8") 141 | mock_request.return_value = response 142 | 143 | name_list = ["name3", "name2", "name1"] 144 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 145 | named_user_list = ua.NamedUserList(airship) 146 | 147 | for a in named_user_list: 148 | self.assertEqual(a.named_user_id, name_list.pop()) 149 | 150 | 151 | class TestNamedUserTags(unittest.TestCase): 152 | def setUp(self): 153 | self.airship = ua.Airship(TEST_KEY, TEST_SECRET) 154 | self.named_user_tags = ua.NamedUserTags(self.airship) 155 | self.mock_response = requests.Response() 156 | self.mock_response._content = json.dumps([{"ok": True}]).encode("utf-8") 157 | 158 | ua.Airship._request = mock.Mock() 159 | ua.Airship._request.side_effect = [self.mock_response] 160 | 161 | def test_set_audience(self): 162 | self.named_user_tags.set_audience(["user-1", "user-2"]) 163 | 164 | self.assertEqual( 165 | self.named_user_tags.audience, {"named_user_id": ["user-1", "user-2"]} 166 | ) 167 | 168 | def test_add(self): 169 | self.named_user_tags.set_audience(["user-1", "user-2"]) 170 | self.named_user_tags.add("group1", ["tag1", "tag2", "tag3"]) 171 | result = self.named_user_tags.send() 172 | 173 | self.assertEqual(result, [{"ok": True}]) 174 | -------------------------------------------------------------------------------- /urbanairship/custom_events/custom_events.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from typing import Any, Dict, Optional, Union, cast 4 | 5 | from urbanairship.client import BaseClient 6 | 7 | 8 | class CustomEvent: 9 | def __init__( 10 | self, 11 | airship: BaseClient, 12 | name: str, 13 | user: Dict, 14 | interaction_type: Optional[str] = None, 15 | interaction_id: Optional[str] = None, 16 | properties: Optional[Dict] = None, 17 | session_id: Optional[str] = None, 18 | transaction: Optional[str] = None, 19 | value: Optional[Union[int, float]] = None, 20 | occurred: Optional[datetime.datetime] = None, 21 | ) -> None: 22 | """ 23 | A class representing an Airship custom event. Please see the 24 | documentation at https://docs.airship.com/api/ua/?http#tag-custom-events for 25 | details on Custom Event usage. 26 | 27 | :param Airship: [required] An urbanairship.Airship instance initialized with 28 | bearer token authentication. 29 | :param name: [required] A plain-text name for the event. Airship's analytics 30 | systems will roll up events with the same name, providing counts and total 31 | value associated with the event. This value cannot contain upper-case 32 | characters. If the name contains upper-case characters, you will receive a 33 | 400 response. 34 | :param user: [required] An Airship channel identifier or named user 35 | for the user who triggered the event. 36 | :param interaction_id: [optional] The identifier defining where the event 37 | occurred. 38 | :param interaction_type: [optional] Describes the type of interaction that 39 | triggered the event 40 | :param properties: [optional] A dict containing custom event properties. 41 | :param session_id: [optional] The user session during which the event occurred. 42 | You must supply and maintain session identifiers. 43 | :param transaction: [optional] If the event is one in a series representing a 44 | single transaction, use the transaction field to tie events together. 45 | :param value: [optional] If the event is associated with a count or amount, 46 | the 'value' field carries that information. 47 | :param occurred: [optional] The date and time when the event occurred. Events 48 | must have occurred within the past 90 days. You cannot provide 49 | a future datetime. 50 | """ 51 | self.airship = airship 52 | self.name = name 53 | self.user = user 54 | self.interaction_type = interaction_type 55 | self.interaction_id = interaction_id 56 | self.properties = properties 57 | self.session_id = session_id 58 | self.transaction = transaction 59 | self.value = value 60 | self.occurred = occurred 61 | 62 | @property 63 | def name(self) -> str: 64 | return self._name 65 | 66 | @name.setter 67 | def name(self, value: str) -> None: 68 | self._name = value 69 | 70 | @property 71 | def user(self) -> Dict: 72 | if "named_user" in self._user.keys(): 73 | return {"named_user_id": self._user["named_user"]} 74 | 75 | return self._user 76 | 77 | @user.setter 78 | def user(self, value: Dict) -> None: 79 | self._user = value 80 | 81 | @property 82 | def interaction_id(self) -> Optional[str]: 83 | return self._interaction_id 84 | 85 | @interaction_id.setter 86 | def interaction_id(self, value: Optional[str]) -> None: 87 | self._interaction_id = value 88 | 89 | @property 90 | def interaction_type(self) -> Optional[str]: 91 | return self._interaction_type 92 | 93 | @interaction_type.setter 94 | def interaction_type(self, value: Optional[str]) -> None: 95 | self._interaction_type = value 96 | 97 | @property 98 | def properties(self) -> Optional[Dict]: 99 | return self._properties 100 | 101 | @properties.setter 102 | def properties(self, value: Optional[Dict]) -> None: 103 | self._properties = value 104 | 105 | @property 106 | def session_id(self) -> Optional[str]: 107 | return self._session_id 108 | 109 | @session_id.setter 110 | def session_id(self, value: Optional[str]) -> None: 111 | self._session_id = value 112 | 113 | @property 114 | def transaction(self) -> Optional[str]: 115 | return self._transaction 116 | 117 | @transaction.setter 118 | def transaction(self, value: Optional[str]) -> None: 119 | self._transaction = value 120 | 121 | @property 122 | def value(self) -> Optional[Union[int, float]]: 123 | return self._value 124 | 125 | @value.setter 126 | def value(self, value: Optional[Union[int, float]]): 127 | self._value = value 128 | 129 | @property 130 | def occurred(self) -> Optional[datetime.datetime]: 131 | return self._occurred 132 | 133 | @occurred.setter 134 | def occurred(self, value: Optional[datetime.datetime]) -> None: 135 | self._occurred = value 136 | 137 | @property 138 | def _payload(self) -> Dict: 139 | event_payload: Dict = {"user": self.user} 140 | body: Dict = {"name": self.name} 141 | 142 | for payload_attr in ["occurred"]: 143 | if getattr(self, payload_attr) is not None: 144 | event_payload[payload_attr] = getattr(self, payload_attr).strftime( 145 | "%Y-%m-%dT%H:%M:%S" 146 | ) 147 | 148 | for body_attr in [ 149 | "value", 150 | "transaction", 151 | "interaction_id", 152 | "interaction_type", 153 | "properties", 154 | "session_id", 155 | ]: 156 | if getattr(self, body_attr) is not None: 157 | body[body_attr] = getattr(self, body_attr) 158 | 159 | event_payload["body"] = body 160 | 161 | return event_payload 162 | 163 | def send(self) -> Dict: 164 | """Send the Custom Event to Airship systems 165 | 166 | :returns: API response dict 167 | """ 168 | response = self.airship.request( 169 | method="POST", 170 | body=json.dumps(self._payload), 171 | url=self.airship.urls.get("custom_events_url"), 172 | content_type="application/json", 173 | version=3, 174 | ) 175 | 176 | return cast(Dict[Any, Any], response.json()) 177 | -------------------------------------------------------------------------------- /urbanairship/reports/reports.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from urbanairship import common 5 | from urbanairship.client import BaseClient 6 | 7 | DATE_FORMAT_STR: str = "%Y-%m-%d %H:%M:%S" 8 | 9 | 10 | class IndividualResponseStats(object): 11 | """Returns detailed reports information about a specific push notification.""" 12 | 13 | def __init__(self, airship: BaseClient) -> None: 14 | self.airship = airship 15 | 16 | def get(self, push_id: str) -> common.IteratorDataObj: 17 | url = self.airship.urls.get("reports_url") + "responses/" + push_id 18 | response = self.airship.request(method="GET", body="", url=url, version=3) 19 | payload = response.json() 20 | return common.IteratorDataObj.from_payload(payload) 21 | 22 | 23 | class ResponseList(common.IteratorParent): 24 | """Get a listing of all pushes, plus basic response information, in a given 25 | timeframe. Start and end date times are required parameters. 26 | """ 27 | 28 | next_url: Optional[str] = None 29 | data_attribute: str = "pushes" 30 | 31 | def __init__( 32 | self, 33 | airship: BaseClient, 34 | start_date: datetime, 35 | end_date: datetime, 36 | limit: Optional[str] = None, 37 | start_id: Optional[str] = None, 38 | ): 39 | if not airship or not start_date or not end_date: 40 | raise TypeError("airship, start_date, & end_date cannot be empty") 41 | if not isinstance(start_date, datetime) or not isinstance(end_date, datetime): 42 | raise TypeError("start_date and end_date must be datetime objects") 43 | params = { 44 | "start": start_date.strftime(DATE_FORMAT_STR), 45 | "end": end_date.strftime(DATE_FORMAT_STR), 46 | } 47 | if limit: 48 | params["limit"] = limit 49 | if start_id: 50 | params["start_id"] = start_id 51 | self.next_url = airship.urls.get("reports_url") + "responses/list" 52 | super(ResponseList, self).__init__(airship, params) 53 | 54 | 55 | class DevicesReport(object): 56 | """Returns a project's opted-in and installed device counts broken out by device 57 | type as a daily snapshot. This endpoint returns the same data that populates the 58 | Devices Report on the web dashboard.""" 59 | 60 | def __init__(self, airship: BaseClient): 61 | self.airship = airship 62 | 63 | def get(self, date: datetime): 64 | if not date: 65 | raise TypeError("date cannot be empty") 66 | if not isinstance(date, datetime): 67 | raise ValueError("date must be a datetime object") 68 | url = self.airship.urls.get("reports_url") + "devices/" 69 | params = {"date": date.strftime(DATE_FORMAT_STR)} 70 | response = self.airship._request( 71 | method="GET", body="", url=url, version=3, params=params 72 | ) 73 | return response.json() 74 | 75 | 76 | class ReportsList(common.IteratorParent): 77 | """Parent class for reports""" 78 | 79 | next_url: Optional[str] = None 80 | data_attribute: Optional[str] = None 81 | 82 | def __init__( 83 | self, 84 | airship: BaseClient, 85 | start_date: datetime, 86 | end_date: datetime, 87 | precision: str, 88 | ): 89 | if not airship or not start_date or not end_date or not precision: 90 | raise TypeError("None of the function parameters can be empty") 91 | 92 | if not isinstance(start_date, datetime) or not isinstance(end_date, datetime): 93 | raise TypeError("start_date and end_date must be datetime objects") 94 | 95 | if precision not in ["HOURLY", "DAILY", "MONTHLY"]: 96 | raise ValueError("Precision must be 'HOURLY', 'DAILY', or 'MONTHLY'") 97 | 98 | base_url = airship.urls.get("reports_url") 99 | 100 | params = { 101 | "start": start_date.strftime(DATE_FORMAT_STR), 102 | "end": end_date.strftime(DATE_FORMAT_STR), 103 | "precision": precision, 104 | } 105 | 106 | if self.data_attribute == "optins": 107 | self.next_url = base_url + "optins/" 108 | elif self.data_attribute == "optouts": 109 | self.next_url = base_url + "optouts/" 110 | elif self.data_attribute == "sends": 111 | self.next_url = base_url + "sends/" 112 | elif self.data_attribute == "responses": 113 | self.next_url = base_url + "responses/" 114 | elif self.data_attribute == "opens": 115 | self.next_url = base_url + "opens/" 116 | elif self.data_attribute == "timeinapp": 117 | self.next_url = base_url + "timeinapp/" 118 | elif self.data_attribute == "events": 119 | self.next_url = base_url + "events/" 120 | elif self.data_attribute == "total_counts": 121 | self.next_url = base_url + "web/interaction/" 122 | params["app_key"] = airship.key 123 | 124 | super(ReportsList, self).__init__(airship, params) 125 | 126 | 127 | class OptInList(ReportsList): 128 | """Get the number of opted-in Push users who access the app within the specified 129 | time period. 130 | """ 131 | 132 | data_attribute = "optins" 133 | 134 | 135 | class OptOutList(ReportsList): 136 | """Get the number of opted-out Push users who access the app within the 137 | specified time period 138 | """ 139 | 140 | data_attribute = "optouts" 141 | 142 | 143 | class PushList(ReportsList): 144 | """Get the number of pushes you have sent within a specified time period.""" 145 | 146 | data_attribute = "sends" 147 | 148 | 149 | class ResponseReportList(ReportsList): 150 | """Get the number of direct and influenced opens of your app.""" 151 | 152 | data_attribute = "responses" 153 | 154 | 155 | class AppOpensList(ReportsList): 156 | """Get the number of users who have opened your app within the specified time period.""" 157 | 158 | data_attribute = "opens" 159 | 160 | 161 | class TimeInAppList(ReportsList): 162 | """Get the average amount of time users have spent in your app within the specified 163 | time period. 164 | """ 165 | 166 | data_attribute = "timeinapp" 167 | 168 | 169 | class CustomEventsList(ReportsList): 170 | """Get a summary of custom event counts and values, by custom event, within the 171 | specified time period. 172 | """ 173 | 174 | data_attribute = "events" 175 | 176 | 177 | class WebResponseReport(ReportsList): 178 | """Get the web interaction data for the given app key. Accepts a required start 179 | time and optional end time and precision parameters. 180 | """ 181 | 182 | data_attribute = "total_counts" 183 | -------------------------------------------------------------------------------- /urbanairship/devices/open_channel.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import logging 4 | import re 5 | from typing import Any, Dict, List, Optional 6 | 7 | from requests import Response 8 | 9 | from urbanairship.client import BaseClient 10 | 11 | VALID_UUID = re.compile(r"[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}\Z") 12 | 13 | logger = logging.getLogger("urbanairship") 14 | 15 | 16 | class OpenChannel(object): 17 | """ 18 | Represents an open channel. 19 | 20 | :param channel_id: The Airship generated channel_id value for the open channel 21 | :param address: The open channel's address 22 | :param open_platform: The open platform associated with the channel 23 | """ 24 | 25 | channel_id: Optional[str] = None 26 | address: Optional[str] = None 27 | open_platform: Optional[str] = None 28 | identifiers: Optional[str] = None 29 | opt_in: Optional[bool] = None 30 | installed: Optional[bool] = None 31 | created: Optional[str] = None 32 | last_registration: Optional[str] = None 33 | tags: Optional[List] = None 34 | template_fields: Optional[Dict] = None 35 | 36 | def __init__(self, airship: BaseClient) -> None: 37 | self.airship = airship 38 | 39 | @property 40 | def create_and_send_audience(self) -> Dict: 41 | if not self.address: 42 | raise ValueError("open channel address must be set") 43 | 44 | audience = {"ua_address": self.address} 45 | 46 | if self.template_fields: 47 | audience.update(self.template_fields) 48 | 49 | return audience 50 | 51 | def create(self) -> Response: 52 | """Create this OpenChannel object with the API.""" 53 | 54 | if not self.address: 55 | raise ValueError("Must set address before creation.") 56 | 57 | if not self.open_platform: 58 | raise ValueError("Must set open_platform before creation.") 59 | 60 | if not isinstance(self.opt_in, bool): 61 | raise ValueError("Must set opt_in before creation.") 62 | 63 | if self.tags and not isinstance(self.tags, list): 64 | raise TypeError('"tags" must be a list') 65 | 66 | url = self.airship.urls.get("open_channel_url") 67 | 68 | channel_data: Dict[str, Any] = { 69 | "type": "open", 70 | "address": self.address, 71 | "opt_in": self.opt_in, 72 | "open": {"open_platform_name": self.open_platform}, 73 | } 74 | 75 | if self.tags: 76 | channel_data["tags"] = self.tags 77 | if self.identifiers: 78 | channel_data["open"]["identifiers"] = self.identifiers 79 | 80 | body = json.dumps({"channel": channel_data}) 81 | response = self.airship.request(method="POST", body=body, url=url, version=3) 82 | 83 | self.channel_id = response.json().get("channel_id") 84 | 85 | logger.info( 86 | "Successful open channel creation: %s (%s)", self.channel_id, self.address 87 | ) 88 | 89 | return response 90 | 91 | def update(self) -> Response: 92 | """Update this OpenChannel object.""" 93 | 94 | if not self.address and not self.channel_id: 95 | raise ValueError("Must set address or channel ID to update.") 96 | 97 | if not self.open_platform: 98 | raise ValueError("Must set open_platform.") 99 | 100 | if not isinstance(self.opt_in, bool): 101 | raise ValueError("Must set opt_in.") 102 | 103 | if not self.address and self.opt_in is True: 104 | raise ValueError("Address must be set for opted in channels.") 105 | 106 | url = self.airship.urls.get("open_channel_url") 107 | 108 | channel_data: Dict[str, Any] = { 109 | "type": "open", 110 | "open": {"open_platform_name": self.open_platform}, 111 | "opt_in": self.opt_in, 112 | } 113 | if self.channel_id: 114 | channel_data["channel_id"] = self.channel_id 115 | if self.address: 116 | channel_data["address"] = self.address 117 | if self.tags: 118 | channel_data["tags"] = self.tags 119 | if self.identifiers: 120 | channel_data["open"]["identifiers"] = self.identifiers 121 | 122 | body = json.dumps({"channel": channel_data}) 123 | response = self.airship.request(method="POST", body=body, url=url, version=3) 124 | 125 | self.channel_id = response.json().get("channel_id") 126 | 127 | logger.info( 128 | "Successful open channel update: %s (%s)", self.channel_id, self.address 129 | ) 130 | 131 | return response 132 | 133 | @classmethod 134 | def from_payload(cls, payload: Dict, airship: BaseClient): 135 | """Instantiate an OpenChannel from a payload.""" 136 | obj = cls(airship) 137 | for key in payload: 138 | # Extract the open channel data 139 | if key == "open": 140 | obj.open_platform = payload["open"].get("open_platform_name") 141 | obj.identifiers = payload["open"].get("identifiers", []) 142 | continue 143 | 144 | if key in ("created", "last_registration"): 145 | try: 146 | payload[key] = datetime.datetime.strptime( 147 | payload[key], "%Y-%m-%dT%H:%M:%S" 148 | ) 149 | except (KeyError, ValueError): 150 | payload[key] = "UNKNOWN" 151 | setattr(obj, key, payload[key]) 152 | 153 | return obj 154 | 155 | def lookup(self, channel_id: str): 156 | """Retrieves an open channel from the provided channel ID.""" 157 | url = self.airship.urls.get("channel_url") + channel_id 158 | response = self.airship._request(method="GET", body=None, url=url, version=3) 159 | payload = response.json().get("channel") 160 | 161 | return self.from_payload(payload, self.airship) 162 | 163 | def uninstall(self) -> Response: 164 | """Mark this OpenChannel object uninstalled""" 165 | url = self.airship.urls.get("open_channel_url") + "uninstall/" 166 | if self.address is None or self.open_platform is None: 167 | raise ValueError('"address" and "open_platform" are required attributes') 168 | 169 | channel_data = { 170 | "address": self.address, 171 | "open_platform_name": self.open_platform, 172 | } 173 | 174 | body = json.dumps(channel_data) 175 | response = self.airship.request(method="POST", body=body, url=url, version=3) 176 | 177 | logger.info( 178 | "Successfully uninstalled open channel %s" 179 | % channel_data["open_platform_name"] 180 | ) 181 | 182 | return response 183 | -------------------------------------------------------------------------------- /urbanairship/push/schedule.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Dict, List, Optional, Type 3 | 4 | from urbanairship import common 5 | from urbanairship.client import BaseClient 6 | from urbanairship.push.core import ScheduledPush 7 | 8 | VALID_DAYS: List[str] = [ 9 | "monday", 10 | "tuesday", 11 | "wednesday", 12 | "thursday", 13 | "friday", 14 | "saturday", 15 | "sunday", 16 | ] 17 | 18 | VALID_RECURRING_TYPES: List[str] = ["hourly", "daily", "weekly", "monthly", "yearly"] 19 | DT_FORMAT_STR: str = "%Y-%m-%dT%H:%M:%S" 20 | 21 | 22 | class ScheduledList(common.IteratorParent): 23 | """ 24 | Iterator for listing all scheduled messages. 25 | 26 | :keyword limit: Number of entries to fetch in a paginated request. 27 | 28 | :returns: Each ``next`` returns a :py:class:`ScheduledPush` object. 29 | """ 30 | 31 | next_url: Optional[str] = None 32 | data_attribute: str = "schedules" 33 | id_key: str = "url" 34 | instance_class: Type[ScheduledPush] = ScheduledPush 35 | 36 | def __init__(self, airship: BaseClient, limit: Optional[int] = None) -> None: 37 | self.next_url = airship.urls.get("schedules_url") 38 | params = {"limit": limit} if limit else {} 39 | super(ScheduledList, self).__init__(airship, params) 40 | 41 | 42 | def scheduled_time(timestamp: datetime) -> Dict[str, Any]: 43 | """Specify a time for the delivery of this push. 44 | 45 | :param timestamp: A ``datetime.datetime`` object. 46 | 47 | """ 48 | 49 | return {"scheduled_time": timestamp.strftime(DT_FORMAT_STR)} 50 | 51 | 52 | def local_scheduled_time(timestamp: datetime) -> Dict[str, Any]: 53 | """Specify a time for the delivery of this push in device local time. 54 | 55 | :param timestamp: A ``datetime.datetime`` object. 56 | 57 | """ 58 | 59 | return {"local_scheduled_time": timestamp.strftime(DT_FORMAT_STR)} 60 | 61 | 62 | def best_time(timestamp: datetime) -> Dict[str, Any]: 63 | """Specify a date to send the push at the best time per-device. 64 | Only YYYY_MM_DD are needed. Hour/minute/second information is discarded. 65 | 66 | :param timestamp: A ``datetime.datetime`` object. 67 | """ 68 | 69 | return {"best_time": {"send_date": timestamp.strftime("%Y-%m-%d")}} 70 | 71 | 72 | def schedule_exclusion( 73 | start_hour: Optional[int] = None, 74 | end_hour: Optional[int] = None, 75 | start_date: Optional[datetime] = None, 76 | end_date: Optional[datetime] = None, 77 | days_of_week: Optional[List[str]] = None, 78 | ) -> Dict[str, Any]: 79 | """ 80 | Date-time ranges when messages are not sent. 81 | at least one of start_hour and end_hour, start_date and end_date, or days_of_week 82 | must be included. All dates and times are inclusive. 83 | 84 | :param start_hour: Optional. An integer 0-23 representing the UTC hour to start 85 | exclusion. 86 | :param end_hour: Optional. An integer 0-23 representing the UTC hour to stop 87 | exclusion. Must be included if start_hour is used. 88 | :param start_date: Optional. A datetime.datetime object representing the UTC date 89 | to start exclusion. Hour/minute/seconds will be excluded. 90 | :param start_date: Optional. A datetime.datetime object representing the UTC date 91 | to stop exclusion. Hour/minute/seconds will be excluded. Must be included if 92 | start_date is used. 93 | :param days_of_week: Optional. A list of the days of the week to exclude on. 94 | Possible values: monday, tuesday, wednesday, thursday, friday, saturday, sunday 95 | """ 96 | 97 | exclusion: Dict[str, Any] = {} 98 | 99 | if isinstance(start_hour, int) and isinstance(end_hour, int): 100 | if not all([0 < start_hour < 24, 0 < end_hour < 24]): 101 | raise ValueError("start_hour and end_hour must be int 0-23") 102 | else: 103 | exclusion["hour_range"] = "{}-{}".format(start_hour, end_hour) 104 | 105 | if isinstance(start_date, datetime) and isinstance(end_date, datetime): 106 | exclusion["date_range"] = "{}/{}".format( 107 | start_date.strftime(DT_FORMAT_STR), 108 | end_date.strftime(DT_FORMAT_STR), 109 | ) 110 | else: 111 | raise ValueError("start_date and end_date must be datetime.datetime") 112 | 113 | if days_of_week: 114 | for day in days_of_week: 115 | if day not in VALID_DAYS: 116 | raise ValueError("days_of_week must be {}".format(VALID_DAYS)) 117 | 118 | exclusion["days_of_week"] = days_of_week 119 | 120 | return exclusion 121 | 122 | 123 | def recurring_schedule( 124 | count: int, 125 | type: str, 126 | end_time: Optional[datetime] = None, 127 | days_of_week: Optional[List[str]] = None, 128 | exclusions: Optional[List[Dict[str, Any]]] = None, 129 | paused: Optional[bool] = False, 130 | ) -> Dict[str, Any]: 131 | """ 132 | Sets the cadence, end time, and excluded times for a recurring scheduled 133 | message. 134 | 135 | :param count: Required. The frequency of messaging corresponding to the type. 136 | For example, a count of 2 results in a message every 2 hours, days, weeks, 137 | months, etc based on the type. 138 | :param type: Required. The unit of measurement for the cadence. Possible 139 | values: hourly, daily, monthly, yearly. 140 | :param days_of_week: Required when type is weekly. The days of the week on which 141 | Airship can send your message. 142 | :param end_time: Optional. A datetime.datetime object representing when the 143 | scheduled send will end and stop sending messages. 144 | :param exclusions: Optional. A list of urbanaiship.schedule_exclusion defining 145 | times in which Airship will not send your message. 146 | :param paused: Optional. A boolean value respesnting the paused state of the 147 | scheduled message. 148 | """ 149 | if days_of_week is not None: 150 | for day in days_of_week: 151 | if day not in VALID_DAYS: 152 | raise ValueError("days of week can only include {}".format(VALID_DAYS)) 153 | 154 | if type not in VALID_RECURRING_TYPES: 155 | raise ValueError("type must be one of {}".format(VALID_RECURRING_TYPES)) 156 | 157 | cadence: Dict[str, Any] = {"type": type, "count": count} 158 | 159 | if type == "weekly": 160 | cadence["days_of_week"] = days_of_week 161 | 162 | recurring: Dict[str, Any] = {"cadence": cadence} 163 | 164 | if end_time: 165 | recurring["end_time"] = end_time.strftime("%Y-%m-%dT%H:%M:%S") 166 | if exclusions: 167 | recurring["exclusions"] = exclusions 168 | if paused is not None: 169 | recurring["paused"] = paused 170 | 171 | return {"recurring": recurring} 172 | -------------------------------------------------------------------------------- /urbanairship/devices/static_lists.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import datetime 3 | import gzip 4 | import json 5 | from io import TextIOWrapper 6 | from typing import Any, Dict, Optional, cast 7 | 8 | from requests import Response 9 | 10 | from urbanairship import common 11 | from urbanairship.client import BaseClient 12 | 13 | CHUNK = 16 * 1024 14 | 15 | 16 | class StaticList: 17 | def __init__(self, airship: BaseClient, name: str) -> None: 18 | self.airship = airship 19 | self.name = name 20 | self.description = None 21 | self.extra = None 22 | 23 | def create(self) -> Dict[Any, Any]: 24 | """Create a Static List""" 25 | payload = {"name": self.name} 26 | if self.description: 27 | payload["description"] = self.description 28 | if self.extra: 29 | payload["extra"] = self.extra 30 | 31 | body = json.dumps(payload) 32 | response = self.airship._request( 33 | method="POST", 34 | body=body, 35 | url=self.airship.urls.get("lists_url"), 36 | content_type="application/json", 37 | version=3, 38 | ) 39 | result = response.json() 40 | return cast(Dict[Any, Any], result) 41 | 42 | def upload(self, csv_file: TextIOWrapper) -> Dict[Any, Any]: 43 | """Upload a CSV file to a static list 44 | 45 | :param csv_file: open file descriptor with two column format: 46 | identifier_type, identifier 47 | 48 | :return: http response 49 | """ 50 | 51 | zipped = GzipCompressReadStream(csv_file) 52 | url = self.airship.urls.get("lists_url") + self.name + "/csv/" 53 | response = self.airship._request( 54 | method="PUT", 55 | body=zipped, 56 | url=url, 57 | content_type="text/csv", 58 | version=3, 59 | encoding="gzip", 60 | ) 61 | return cast(Dict[Any, Any], response.json()) 62 | 63 | def update(self) -> Dict[Any, Any]: 64 | """Update the metadata in a static list 65 | 66 | :return: http response 67 | """ 68 | 69 | if self.description is None and self.extra is None: 70 | raise ValueError("Either description or extra must be non-empty.") 71 | 72 | payload = {} 73 | 74 | if self.description is not None: 75 | payload["description"] = self.description 76 | if self.extra is not None: 77 | payload["extra"] = self.extra 78 | 79 | body = json.dumps(payload).encode("utf-8") 80 | url = self.airship.urls.get("lists_url") + self.name 81 | 82 | response = self.airship._request("PUT", body, url, "application/json", version=3) 83 | result = response.json() 84 | return cast(Dict[Any, Any], result) 85 | 86 | @classmethod 87 | def download(cls, airship: BaseClient, list_name: str) -> Response: 88 | """ 89 | Allows you to download the contents of a static list. Alias and named_user 90 | values are resolved to channels. 91 | 92 | :param airship: Required. An urbanairship.Airship instance. 93 | :param list_name: Required. Name of an existing list to download. 94 | 95 | :return: csv list data 96 | """ 97 | response = airship._request( 98 | method="GET", 99 | url=airship.urls.get("lists_url") + list_name + "/csv/", 100 | body={}, 101 | ) 102 | 103 | return response 104 | 105 | @classmethod 106 | def from_payload(cls, payload: Dict[Any, Any], airship: BaseClient): 107 | for key in payload: 108 | if key == "created" or key == "last_updated": 109 | payload[key] = datetime.datetime.strptime(payload[key], "%Y-%m-%dT%H:%M:%S") 110 | setattr(cls, key, payload[key]) 111 | return cls 112 | 113 | def lookup(self): 114 | """ 115 | Get Information about the static list 116 | 117 | :return: urbanairship.StaticList objects 118 | """ 119 | 120 | url = self.airship.urls.get("lists_url") + self.name 121 | response = self.airship._request("GET", None, url, version=3) 122 | payload = response.json() 123 | return self.from_payload(payload, self.airship) 124 | 125 | def delete(self) -> Response: 126 | """ 127 | Delete the static list 128 | """ 129 | url = self.airship.urls.get("lists_url") + self.name 130 | return self.airship._request("DELETE", None, url, version=3) 131 | 132 | 133 | class StaticLists(common.IteratorParent): 134 | next_url: Optional[str] = None 135 | data_attribute: str = "lists" 136 | 137 | def __init__(self, airship): 138 | """Gets an iterable listing of existing static lists""" 139 | self.next_url = airship.urls.get("lists_url") 140 | super(StaticLists, self).__init__(airship, None) 141 | 142 | 143 | class Buffer(object): 144 | def __init__(self): 145 | self.__buf = collections.deque() 146 | self.__size = 0 147 | 148 | def __len__(self): 149 | return self.__size 150 | 151 | def write(self, data): 152 | self.__buf.append(data) 153 | self.__size += len(data) 154 | 155 | def read(self, size): 156 | ret_list = [] 157 | while size > 0 and len(self.__buf): 158 | chunk_of_file = self.__buf.popleft() 159 | size -= len(chunk_of_file) 160 | ret_list.append(chunk_of_file) 161 | if size < 0: 162 | ret_list[-1], remainder = ret_list[-1][:size], ret_list[-1][size:] 163 | self.__buf.appendleft(remainder) 164 | ret = b"".join(ret_list) 165 | self.__size -= len(ret) 166 | return ret 167 | 168 | def flush(self): 169 | pass 170 | 171 | def close(self): 172 | pass 173 | 174 | 175 | class GzipCompressReadStream(object): 176 | def __init__(self, file_obj): 177 | self.__input = file_obj 178 | self.__buf = Buffer() 179 | self.__gzip = gzip.GzipFile(None, mode="wb", fileobj=self.__buf) 180 | self.is_finished = False 181 | 182 | def read(self, size): 183 | while len(self.__buf) < size: 184 | chunk_of_file = self.__input.read(CHUNK) 185 | if not chunk_of_file: 186 | self.__gzip.close() 187 | self.is_finished = True 188 | break 189 | self.__gzip.write(chunk_of_file) 190 | self.__gzip.flush() 191 | return self.__buf.read(size) 192 | 193 | def __iter__(self): 194 | return self 195 | 196 | def __next__(self): 197 | if self.is_finished: 198 | raise StopIteration 199 | else: 200 | return self.read(CHUNK) 201 | 202 | def next(self): 203 | return self.__next__() 204 | -------------------------------------------------------------------------------- /urbanairship/devices/tag.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import Any, Dict, List, Optional, cast 4 | 5 | from urbanairship.client import BaseClient 6 | 7 | logger = logging.getLogger("urbanairship") 8 | 9 | 10 | class ChannelTags(object): 11 | """Modify the tags for a channel 12 | 13 | :param airship: An urbanairship.Airship instance. 14 | """ 15 | 16 | def __init__(self, airship: BaseClient) -> None: 17 | self.url = airship.urls.get("channel_url") + "tags/" 18 | self._airship = airship 19 | self.audience: Dict[str, Any] = {} 20 | self.add_group: Dict[str, Any] = {} 21 | self.remove_group: Dict[str, Any] = {} 22 | self.set_group: Dict[str, Any] = {} 23 | 24 | def set_audience( 25 | self, 26 | user_ids: Optional[List[str]] = None, 27 | ios: Optional[str] = None, 28 | android: Optional[str] = None, 29 | amazon: Optional[str] = None, 30 | web: Optional[str] = None, 31 | ) -> None: 32 | """Sets the audience to be modified 33 | :param ios: an ios channel 34 | :param android: an android channel 35 | :param amazon: an amazon channel 36 | :param web: a web channel 37 | """ 38 | if ios is not None: 39 | self.audience["ios_channel"] = ios 40 | if android is not None: 41 | self.audience["android_channel"] = android 42 | if amazon is not None: 43 | self.audience["amazon_channel"] = amazon 44 | if web is not None: 45 | self.audience["channel"] = web 46 | 47 | def add(self, group_name: str, tags: List[str]) -> None: 48 | """Sets group and tags to add 49 | 50 | :param group_name: The name of the tag group to add 51 | :param tags: The tags to add 52 | """ 53 | self.add_group[group_name] = tags 54 | 55 | def remove(self, group_name: str, tags: List[str]) -> None: 56 | """Sets group and tags to remove 57 | 58 | :param group_name: The name of the tag group to remove 59 | :param tags: The tags to addremove 60 | """ 61 | self.remove_group[group_name] = tags 62 | 63 | def set(self, group_name: str, tags: List[str]) -> None: 64 | """ 65 | Sets group and tags to set. Note that a ``set`` operation replaces all 66 | tags on the audience upon send. 67 | 68 | :param group_name: The name of the tag group to set 69 | :param tags: The tags to set 70 | """ 71 | self.set_group[group_name] = tags 72 | 73 | def send(self) -> Dict[Any, Any]: 74 | """Perform the Channel Tag operations. 75 | 76 | :returns: JSON response from the API 77 | """ 78 | payload = {} 79 | 80 | if not self.audience: 81 | raise ValueError("A audience is required for modifying tags") 82 | payload["audience"] = self.audience 83 | 84 | if self.add_group: 85 | if self.set_group: 86 | raise ValueError( 87 | 'A tag request cannot contain both an "add"' ' and a "set" field.' 88 | ) 89 | payload["add"] = self.add_group 90 | 91 | if self.remove_group: 92 | if self.set_group: 93 | raise ValueError( 94 | 'A tag request cannot contain both a "remove"' ' and a "set" field.' 95 | ) 96 | payload["remove"] = self.remove_group 97 | 98 | if self.set_group: 99 | payload["set"] = self.set_group 100 | 101 | if not self.add_group and not self.remove_group and not self.set_group: 102 | raise ValueError("An add, remove, or set field was not set") 103 | 104 | body = json.dumps(payload) 105 | response = self._airship._request("POST", body, self.url, "application/json", version=3) 106 | return cast(Dict[Any, Any], response.json()) 107 | 108 | 109 | class OpenChannelTags(object): 110 | """Modify the tags for an open channel""" 111 | 112 | def __init__(self, airship: BaseClient) -> None: 113 | self.url = airship.urls.get("open_channel_url") + "tags/" 114 | self._airship = airship 115 | self.audience: Dict = {} 116 | self.add_group: Dict = {} 117 | self.remove_group: Dict = {} 118 | self.set_group: Dict = {} 119 | 120 | def set_audience(self, address: str, open_platform: str) -> None: 121 | """Sets the audience to be modified. 122 | 123 | :param address: the open channel to be modified 124 | :param open_platform: the name of the open platform the channel belongs to. 125 | """ 126 | self.audience = {"address": address, "open_platform_name": open_platform} 127 | 128 | def add(self, group_name: str, tags: List[str]) -> None: 129 | """Sets group and tags to add 130 | 131 | :param group_name: The name of the tag group to add 132 | :param tags: The tags to add 133 | """ 134 | self.add_group[group_name] = tags 135 | 136 | def remove(self, group_name: str, tags: List[str]) -> None: 137 | """Sets group and tags to remove 138 | 139 | :param group_name: The name of the tag group to remove 140 | :param tags: The tags to addremove 141 | """ 142 | self.remove_group[group_name] = tags 143 | 144 | def set(self, group_name: str, tags: List[str]) -> None: 145 | """ 146 | Sets group and tags to set. Note that a ``set`` operation replaces all tags on 147 | the audience upon send. 148 | 149 | :param group_name: The name of the tag group to set 150 | :param tags: The tags to set 151 | """ 152 | self.set_group[group_name] = tags 153 | 154 | def send(self) -> Dict[Any, Any]: 155 | """Perform the Open Channel Tag operations. 156 | 157 | :returns: JSON response from the API 158 | """ 159 | payload = {} 160 | 161 | if not self.audience: 162 | raise ValueError("An audience is required to modify tags") 163 | payload["audience"] = self.audience 164 | 165 | if not self.add_group and not self.remove_group and not self.set_group: 166 | raise ValueError("An add, remove, or set field was not set") 167 | 168 | if self.set_group: 169 | if self.add_group or self.remove_group: 170 | raise ValueError('A "set" tag request cannot contain "add" or "remove" fields') 171 | payload["set"] = self.set_group 172 | 173 | if self.add_group: 174 | payload["add"] = self.add_group 175 | 176 | if self.remove_group: 177 | payload["remove"] = self.remove_group 178 | 179 | body = json.dumps(payload) 180 | response = self._airship._request("POST", body, self.url, "application/json", version=3) 181 | return cast(Dict[Any, Any], response.json()) 182 | -------------------------------------------------------------------------------- /tests/devices/test_tags.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | import mock 5 | import requests 6 | 7 | import urbanairship as ua 8 | from tests import TEST_KEY, TEST_SECRET 9 | 10 | 11 | class TestChannelTags(unittest.TestCase): 12 | def setUp(self): 13 | self.airship = ua.Airship(TEST_KEY, TEST_SECRET) 14 | self.channel_tags = ua.ChannelTags(self.airship) 15 | self.mock_response = requests.Response() 16 | self.mock_response._content = json.dumps( 17 | [ 18 | { 19 | "ok": True, 20 | } 21 | ] 22 | ).encode("utf-8") 23 | 24 | ua.Airship._request = mock.Mock() 25 | ua.Airship._request.side_effect = [self.mock_response] 26 | 27 | def test_ios_audience(self): 28 | self.channel_tags.set_audience(ios="ios_audience") 29 | self.channel_tags.add("group_name", "tag1") 30 | result = self.channel_tags.send() 31 | 32 | self.assertEqual(result, [{"ok": True}]) 33 | 34 | def test_android_audience(self): 35 | self.channel_tags.set_audience(android="android_audience") 36 | self.channel_tags.add("group_name", "tag1") 37 | result = self.channel_tags.send() 38 | 39 | self.assertEqual(result, [{"ok": True}]) 40 | 41 | def test_amazon_audience(self): 42 | self.channel_tags.set_audience(amazon="android_audience") 43 | self.channel_tags.add("group_name", "tag1") 44 | result = self.channel_tags.send() 45 | 46 | self.assertEqual(result, [{"ok": True}]) 47 | 48 | def test_web_audience(self): 49 | self.channel_tags.set_audience(web="web_audience") 50 | self.channel_tags.add("group_name", "tag4") 51 | result = self.channel_tags.send() 52 | 53 | self.assertEqual(result, [{"ok": True}]) 54 | 55 | def test_all_audiences(self): 56 | self.channel_tags.set_audience( 57 | "ios_audience", "android_audience", "amazon_audience", "web_audience" 58 | ) 59 | self.channel_tags.add("group_name", "tag1") 60 | result = self.channel_tags.send() 61 | 62 | self.assertEqual(result, [{"ok": True}]) 63 | 64 | def test_add_and_remove(self): 65 | self.channel_tags.set_audience( 66 | "ios_audience", "android_audience", "amazon_audience", "web_audience" 67 | ) 68 | self.channel_tags.add("group_name", "tag1") 69 | self.channel_tags.remove("group2_name", "tag2") 70 | result = self.channel_tags.send() 71 | 72 | self.assertEqual(result, [{"ok": True}]) 73 | 74 | def test_add_and_remove_and_set(self): 75 | self.channel_tags.set_audience( 76 | "ios_audience", "android_audience", "amazon_audience" 77 | ) 78 | self.channel_tags.add("group_name", "tag1") 79 | self.channel_tags.remove("group2_name", "tag2") 80 | self.channel_tags.set("group3_name", "tag3") 81 | 82 | self.assertRaises( 83 | ValueError, 84 | self.channel_tags.send, 85 | ) 86 | 87 | def test_remove_and_set(self): 88 | self.channel_tags.set_audience( 89 | "ios_audience", "android_audience", "amazon_audience" "web_audience" 90 | ) 91 | self.channel_tags.remove("group2_name", "tag2") 92 | self.channel_tags.set("group3_name", "tag3") 93 | 94 | self.assertRaises( 95 | ValueError, 96 | self.channel_tags.send, 97 | ) 98 | 99 | def test_set(self): 100 | self.channel_tags.set_audience( 101 | "ios_audience", "android_audience", "amazon_audience", "web_audience" 102 | ) 103 | self.channel_tags.set("group3_name", "tag3") 104 | result = self.channel_tags.send() 105 | 106 | self.assertEqual(result, [{"ok": True}]) 107 | 108 | def test_tag_lists(self): 109 | self.channel_tags.set_audience( 110 | "ios_audience", "android_audience", "amazon_audience" 111 | ) 112 | self.channel_tags.set("group3_name", ["tag1", "tag2", "tag3"]) 113 | result = self.channel_tags.send() 114 | 115 | self.assertEqual(result, [{"ok": True}]) 116 | 117 | 118 | class TestOpenChannelTags(unittest.TestCase): 119 | def setUp(self): 120 | self.airship = ua.Airship(TEST_KEY, TEST_SECRET) 121 | self.open_channel_tags = ua.OpenChannelTags(self.airship) 122 | self.mock_response = requests.Response() 123 | self.mock_response._content = json.dumps([{"ok": True}]).encode("utf-8") 124 | 125 | ua.Airship._request = mock.Mock() 126 | ua.Airship._request.side_effect = [self.mock_response] 127 | 128 | def test_set_audience(self): 129 | self.open_channel_tags.set_audience("new_email@example.com", "email") 130 | 131 | self.assertEqual( 132 | self.open_channel_tags.audience, 133 | {"address": "new_email@example.com", "open_platform_name": "email"}, 134 | ) 135 | 136 | def test_add(self): 137 | self.open_channel_tags.set_audience("new_email@example.com", "email") 138 | self.open_channel_tags.add("group1", ["tag1", "tag2", "tag3"]) 139 | result = self.open_channel_tags.send() 140 | 141 | self.assertEqual(result, [{"ok": True}]) 142 | 143 | def test_remove(self): 144 | self.open_channel_tags.set_audience("new_email@example.com", "email") 145 | self.open_channel_tags.remove("group1", ["tag1", "tag2", "tag3"]) 146 | result = self.open_channel_tags.send() 147 | 148 | self.assertEqual(result, [{"ok": True}]) 149 | 150 | def test_set(self): 151 | self.open_channel_tags.set_audience("new_email@example.com", "email") 152 | self.open_channel_tags.set("group1", ["tag1", "tag2", "tag3"]) 153 | result = self.open_channel_tags.send() 154 | 155 | self.assertEqual(result, [{"ok": True}]) 156 | 157 | def test_add_remove(self): 158 | self.open_channel_tags.set_audience("new_email@example.com", "email") 159 | self.open_channel_tags.add("group1", ["tag1", "tag2", "tag3"]) 160 | self.open_channel_tags.remove("group2", ["tag21", "tag22", "tag23"]) 161 | result = self.open_channel_tags.send() 162 | 163 | self.assertEqual(result, [{"ok": True}]) 164 | 165 | def test_add_remove_set(self): 166 | self.open_channel_tags.set_audience("new_email@example.com", "email") 167 | self.open_channel_tags.add("group1", ["tag1", "tag2", "tag3"]) 168 | self.open_channel_tags.remove("group2", ["tag21", "tag22", "tag23"]) 169 | self.open_channel_tags.set("group1", ["tag1", "tag2", "tag3"]) 170 | 171 | with self.assertRaises(ValueError): 172 | self.open_channel_tags.send() 173 | 174 | def test_send_no_tags(self): 175 | self.open_channel_tags.set_audience("new_email@example.com", "email") 176 | 177 | with self.assertRaises(ValueError): 178 | self.open_channel_tags.send() 179 | -------------------------------------------------------------------------------- /docs/push.rst: -------------------------------------------------------------------------------- 1 | Sending Notifications 2 | ********************* 3 | 4 | Sending a Notification 5 | ======================= 6 | 7 | The Airship Python Library strives to match the standard Airship API v3 JSON format 8 | for specifying notifications. When creating a notification, you: 9 | 10 | #. Select the audience 11 | #. Define the notification payload 12 | #. Specify device types 13 | #. Deliver the notification 14 | 15 | This example performs a broadcast with the same alert to all recipients 16 | to a specific device type which can be a list of types: 17 | 18 | .. code-block:: python 19 | 20 | import urbanairship as ua 21 | client = ua.client.BasicAuthClient(app_key, master_secret) 22 | 23 | push = ua.Push(client) 24 | push.audience = ua.all_ 25 | push.notification = ua.notification(alert='Hello, world!') 26 | push.device_types = ua.device_types('android', 'ios') 27 | push.send() 28 | 29 | Audience Selectors 30 | ------------------ 31 | 32 | An audience should specify one or more devices. An audience can be a 33 | device, such as a ``channel``, a tag, 34 | alias, segment, location, or a combination. Audience selectors are 35 | combined with ``and_``, ``or_``, and ``not_``. 36 | 37 | .. py:data:: urbanairship.push.all_ 38 | 39 | Select all, to do a broadcast. 40 | 41 | .. code-block:: python 42 | 43 | push.audience = ua.all_ 44 | 45 | 46 | .. automodule:: urbanairship.push.audience 47 | :members: 48 | :noindex: 49 | 50 | Notification Payload 51 | -------------------- 52 | 53 | The notification payload determines what message and data is sent to a 54 | device. At its simplest, it consists of a single string-valued 55 | attribute, ``alert``, which sends a push notification consisting of a 56 | single piece of text: 57 | 58 | .. code-block:: python 59 | 60 | push.notification = ua.notification(alert='Hello, world!') 61 | 62 | You can override the payload with platform-specific values as well: 63 | 64 | .. code-block:: python 65 | 66 | push.notification = ua.notification( 67 | ios=ua.ios(alert='Hello iOS', badge=1), 68 | android=ua.android(alert='Hello Android'), 69 | web=ua.web(alert='Hello Web') 70 | ) 71 | 72 | .. automodule:: urbanairship.push.payload 73 | :members: 74 | :exclude-members: device_types 75 | 76 | Device Types 77 | ------------ 78 | 79 | In addition to specifying the audience, you must specify the device types 80 | you wish to target with one or more strings: 81 | 82 | .. code-block:: python 83 | 84 | push.device_types = ua.device_types('ios') 85 | 86 | .. code-block:: python 87 | 88 | push.device_types = ua.device_types('android', 'ios', 'web') 89 | 90 | .. autofunction:: urbanairship.push.payload.device_types 91 | 92 | Immediate Delivery 93 | ------------------- 94 | 95 | Once you have set the ``audience``, ``notification``, and ``device_types`` 96 | attributes, the notification is ready for delivery. Use ``Push.send()`` to send immediately. 97 | 98 | .. code-block:: python 99 | 100 | push.send() 101 | 102 | If the request is unsuccessful, an :py:class:`AirshipFailure` exception 103 | will be raised. 104 | 105 | If the connection is unsuccessful, an :py:class:`ConnectionFailure` exception 106 | will be raised. 107 | 108 | .. autoclass:: urbanairship.push.core.Push 109 | :members: send, validate 110 | 111 | Scheduled Delivery 112 | ================== 113 | 114 | Schedule notifications for later delivery. 115 | 116 | Examples can be found in the `documentation here. `_ 117 | 118 | .. autoclass:: urbanairship.push.core.ScheduledPush 119 | :members: 120 | :exclude-members: from_url, from_payload 121 | :noindex: 122 | 123 | Scheduled Time Builders 124 | ----------------------- 125 | 126 | .. autofunction:: urbanairship.push.schedule.scheduled_time 127 | .. autofunction:: urbanairship.push.schedule.local_scheduled_time 128 | .. autofunction:: urbanairship.push.schedule.best_time 129 | .. autofunction:: urbanairship.push.schedule.recurring_schedule 130 | .. autofunction:: urbanairship.push.schedule.schedule_exclusion 131 | 132 | List Scheduled Notifications 133 | ----------------------------- 134 | 135 | .. autoclass:: urbanairship.push.schedule.ScheduledList 136 | :members: 137 | :exclude-members: instance_class 138 | 139 | Personalization 140 | ================ 141 | Send a notification with personalized content. 142 | 143 | Examples can be found in `the schedules documentation here. `_ 144 | 145 | .. autoclass:: urbanairship.push.core.TemplatePush 146 | :members: 147 | 148 | Template 149 | -------- 150 | 151 | .. autoclass:: urbanairship.push.template.Template 152 | :members: 153 | :exclude-members: lookup, from_payload 154 | :noindex: 155 | 156 | Template Lookup 157 | --------------- 158 | 159 | .. autoclass:: urbanairship.push.template.Template 160 | :members: lookup 161 | :exclude-members: from_payload 162 | :noindex: 163 | 164 | Template Listing 165 | ---------------- 166 | 167 | .. autoclass:: urbanairship.push.template.TemplateList 168 | :members: 169 | :exclude-members: instance_class 170 | 171 | Merge Data 172 | ----------- 173 | 174 | .. autofunction:: urbanairship.push.template.merge_data 175 | 176 | Create and Send 177 | ================ 178 | Simultaneously send a notification to an audience of SMS, email, or open channel addresses and register channels for new addresses in your audience. 179 | 180 | Examples can be found in `the create and send documentation here. `_ 181 | 182 | .. autoclass:: urbanairship.push.core.CreateAndSendPush 183 | :members: 184 | :noindex: 185 | 186 | Automation 187 | ======================= 188 | 189 | With the automation pipelines endpoint you can manage automations for 190 | an Airship project. Pipelines define the behavior to be triggered on user-defined 191 | events. For more information, see `the documentation on Automation 192 | `__. 193 | 194 | .. autoclass:: urbanairship.automation.core.Automation 195 | :members: 196 | :noindex: 197 | 198 | Pipeline 199 | -------- 200 | 201 | A pipeline object encapsulates the complete set of objects that define an Automation pipeline: Triggers, Outcomes, and metadata. 202 | 203 | .. autoclass:: urbanairship.automation.pipeline.Pipeline 204 | :members: 205 | :noindex: 206 | 207 | A/B Tests 208 | ========== 209 | An A/B test is a set of distinct push notification variants sent to subsets of an audience. You can create up to 26 notification variants and send each variant to an audience subset. 210 | 211 | Examples can be found in `the A/B Tests documentation here. `_ 212 | 213 | .. autoclass:: urbanairship.experiments.core.ABTest 214 | :members: 215 | :noindex: 216 | 217 | Experiment 218 | ---------- 219 | 220 | .. autoclass:: urbanairship.experiments.experiment.Experiment 221 | :members: 222 | 223 | Variant 224 | ------- 225 | 226 | .. autoclass:: urbanairship.experiments.variant.Variant 227 | :members: 228 | :exclude-members: payload 229 | -------------------------------------------------------------------------------- /tests/experiments/test_experiment.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import unittest 4 | 5 | import mock 6 | import requests 7 | 8 | import urbanairship as ua 9 | from tests import TEST_KEY, TEST_SECRET 10 | 11 | 12 | class TestExperiment(unittest.TestCase): 13 | def setUp(self): 14 | self.maxDiff = None 15 | self.airship = ua.Airship(TEST_KEY, TEST_SECRET) 16 | self.name = "Experiment Test" 17 | self.audience = "all" 18 | self.description = "just testing" 19 | self.device_types = ["ios", "android"] 20 | self.campaigns = ua.campaigns(categories=["campaign", "categories"]) 21 | self.operation_id = "d67d4de6-934f-4ebb-aef0-250d89699b6b" 22 | self.experiment_id = "f0c975e4-c01a-436b-92a0-2a360f87b211" 23 | self.push_id = "0edb9e6f-2198-4c42-aada-5a49eb03bcbb" 24 | 25 | # push_1 = self.airship.create_push() 26 | push_1 = ua.Push(self.airship) 27 | push_1.notification = ua.notification(alert="test message 1") 28 | 29 | push_2 = ua.Push(self.airship) 30 | push_2.notification = ua.notification(alert="test message 2") 31 | 32 | push_3 = ua.Push(self.airship) 33 | push_3.notification = ua.notification(alert="test message 1") 34 | push_3.in_app = ua.in_app( 35 | alert="This part appears in-app!", display_type="banner" 36 | ) 37 | 38 | push_4 = ua.Push 39 | push_4.notification = ua.notification(alert="test message 2") 40 | push_4.in_app = ua.in_app( 41 | alert="This part appears in-app!", 42 | display_type="banner", 43 | expiry="2025-10-14T12:00:00", 44 | display={"position": "top"}, 45 | actions={"add_tag": "in-app"}, 46 | ) 47 | 48 | variant_1 = ua.Variant( 49 | push_1, description="A description of the variant", name="Testing" 50 | ) 51 | variant_2 = ua.Variant(push_2) 52 | 53 | variant_3 = ua.Variant( 54 | push_3, 55 | description="A description of the variant one", 56 | name="Testing", 57 | schedule=ua.scheduled_time(datetime.datetime(2025, 10, 10, 18, 45, 30)), 58 | weight=2, 59 | ) 60 | variant_4 = ua.Variant( 61 | push_4, 62 | description="A description of the variant two", 63 | name="Testing", 64 | schedule=ua.scheduled_time(datetime.datetime(2025, 10, 10, 18, 45, 30)), 65 | weight=3, 66 | ) 67 | 68 | self.variants_1 = [variant_1, variant_2] 69 | self.variants_2 = [variant_3, variant_4] 70 | 71 | def test_simple_experiment(self): 72 | with mock.patch.object(ua.Airship, "_request") as mock_request: 73 | response = requests.Response() 74 | response._content = json.dumps( 75 | { 76 | "ok": True, 77 | "operation_id": self.operation_id, 78 | "experiment_id": self.experiment_id, 79 | "push_id": self.push_id, 80 | } 81 | ).encode("utf-8") 82 | response.status_code = 201 83 | mock_request.return_value = response 84 | 85 | experiment_object = ua.Experiment( 86 | name=self.name, 87 | audience=self.audience, 88 | device_types=self.device_types, 89 | campaigns=self.campaigns, 90 | variants=self.variants_1, 91 | description=self.description, 92 | ) 93 | experiment_payload = { 94 | "name": "Experiment Test", 95 | "audience": "all", 96 | "description": "just testing", 97 | "device_types": ["ios", "android"], 98 | "campaigns": {"categories": ["campaign", "categories"]}, 99 | "variants": [ 100 | { 101 | "description": "A description of the variant", 102 | "name": "Testing", 103 | "push": {"notification": {"alert": "test message 1"}}, 104 | }, 105 | {"push": {"notification": {"alert": "test message 2"}}}, 106 | ], 107 | } 108 | self.assertDictEqual(experiment_object.payload, experiment_payload) 109 | 110 | def test_full_experiment(self): 111 | with mock.patch.object(ua.Airship, "_request") as mock_request: 112 | response = requests.Response() 113 | response._content = json.dumps( 114 | { 115 | "ok": True, 116 | "operation_id": self.operation_id, 117 | "experiment_id": self.experiment_id, 118 | "push_id": self.push_id, 119 | } 120 | ).encode("utf-8") 121 | response.status_code = 201 122 | mock_request.return_value = response 123 | 124 | experiment_object = ua.Experiment( 125 | name=self.name, 126 | audience=self.audience, 127 | control=0.5, 128 | description=self.description, 129 | device_types=self.device_types, 130 | campaigns=self.campaigns, 131 | variants=self.variants_2, 132 | ) 133 | experiment_payload = { 134 | "name": "Experiment Test", 135 | "audience": "all", 136 | "control": 0.5, 137 | "description": "just testing", 138 | "device_types": ["ios", "android"], 139 | "campaigns": {"categories": ["campaign", "categories"]}, 140 | "variants": [ 141 | { 142 | "description": "A description of the variant one", 143 | "name": "Testing", 144 | "schedule": {"scheduled_time": "2025-10-10T18:45:30"}, 145 | "weight": 2, 146 | "push": { 147 | "notification": {"alert": "test message 1"}, 148 | "in_app": { 149 | "alert": "This part appears in-app!", 150 | "display_type": "banner", 151 | }, 152 | }, 153 | }, 154 | { 155 | "description": "A description of the variant two", 156 | "name": "Testing", 157 | "schedule": {"scheduled_time": "2025-10-10T18:45:30"}, 158 | "weight": 3, 159 | "push": { 160 | "notification": {"alert": "test message 2"}, 161 | "in_app": { 162 | "alert": "This part appears in-app!", 163 | "display_type": "banner", 164 | "expiry": "2025-10-14T12:00:00", 165 | "display": {"position": "top"}, 166 | "actions": {"add_tag": "in-app"}, 167 | }, 168 | }, 169 | }, 170 | ], 171 | } 172 | print(str(experiment_object.payload)) 173 | self.assertEqual(experiment_object.payload, experiment_payload) 174 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/UrbanAirshipPythonLibrary.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/UrbanAirshipPythonLibrary.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/UrbanAirshipPythonLibrary" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/UrbanAirshipPythonLibrary" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /tests/devices/test_attributes.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import unittest 4 | 5 | import mock 6 | import requests 7 | 8 | import urbanairship as ua 9 | from tests import TEST_KEY, TEST_SECRET 10 | 11 | 12 | class TestAttribute(unittest.TestCase): 13 | def setUp(self): 14 | self.test_time = datetime.datetime.utcnow() 15 | self.test_time_str = self.test_time.replace(microsecond=0).isoformat() 16 | 17 | def test_set_attribute(self): 18 | a = ua.Attribute( 19 | action="set", key="test_key", value="test_value", timestamp=self.test_time 20 | ) 21 | 22 | self.assertEqual( 23 | a.payload, 24 | { 25 | "action": "set", 26 | "key": "test_key", 27 | "value": "test_value", 28 | "timestamp": self.test_time_str, 29 | }, 30 | ) 31 | 32 | def test_remove_attribute(self): 33 | a = ua.Attribute( 34 | action="remove", 35 | key="test_key", 36 | value="test_value", 37 | timestamp=self.test_time, 38 | ) 39 | 40 | self.assertEqual( 41 | a.payload, 42 | { 43 | "action": "remove", 44 | "key": "test_key", 45 | "value": "test_value", 46 | "timestamp": self.test_time_str, 47 | }, 48 | ) 49 | 50 | def test_no_value_with_set(self): 51 | with self.assertRaises(ValueError) as err_ctx: 52 | ua.Attribute(action="set", key="test_key") 53 | 54 | self.assertEqual( 55 | err_ctx.message, "A value must be included with 'set' actions" 56 | ) 57 | 58 | def test_str_timestamp(self): 59 | with self.assertRaises(ValueError) as err_ctx: 60 | ua.Attribute( 61 | action="set", 62 | key="test_key", 63 | value="test_value", 64 | timestamp=self.test_time_str, 65 | ) 66 | 67 | self.assertEqual( 68 | err_ctx.message, "timestamp must be a datetime.datetime object" 69 | ) 70 | 71 | def test_incorrect_action(self): 72 | with self.assertRaises(ValueError) as err_ctx: 73 | ua.Attribute( 74 | action="incorrect", 75 | key="test_key", 76 | value="test_value", 77 | timestamp=self.test_time, 78 | ) 79 | self.assertEqual(err_ctx.message, "Action must be one of 'set' or 'remove'") 80 | 81 | 82 | class TestModifyAttributes(unittest.TestCase): 83 | def setUp(self): 84 | self.set_attribute = ua.Attribute( 85 | action="set", key="test_key", value="test_value" 86 | ) 87 | self.remove_attribute = ua.Attribute( 88 | action="remove", key="test_key", value="test_value" 89 | ) 90 | self.test_channel = "c9c162d0-2c17-486b-938d-7b3eed5d8793" 91 | self.test_named_user = ua.named_user("my_cool_named_user") 92 | self.test_airship = ua.Airship(TEST_KEY, TEST_SECRET) 93 | 94 | def test_channel_payload(self): 95 | m = ua.ModifyAttributes( 96 | airship=self.test_airship, 97 | attributes=[self.set_attribute, self.remove_attribute], 98 | channel=self.test_channel, 99 | ) 100 | self.assertEqual( 101 | m.payload, 102 | { 103 | "attributes": [ 104 | {"action": "set", "key": "test_key", "value": "test_value"}, 105 | {"action": "remove", "key": "test_key", "value": "test_value"}, 106 | ], 107 | "audience": {"channel": [self.test_channel]}, 108 | }, 109 | ) 110 | 111 | def test_named_user_payload(self): 112 | m = ua.ModifyAttributes( 113 | airship=self.test_airship, 114 | attributes=[self.set_attribute, self.remove_attribute], 115 | named_user=self.test_named_user, 116 | ) 117 | self.assertEqual( 118 | m.payload, 119 | { 120 | "attributes": [ 121 | {"action": "set", "key": "test_key", "value": "test_value"}, 122 | {"action": "remove", "key": "test_key", "value": "test_value"}, 123 | ], 124 | "audience": {"named_user_id": [self.test_named_user]}, 125 | }, 126 | ) 127 | 128 | def test_attribute_alone(self): 129 | with self.assertRaises(ValueError) as err_ctx: 130 | ua.ModifyAttributes( 131 | airship=self.test_airship, 132 | attributes=self.set_attribute, 133 | channel=self.test_channel, 134 | ) 135 | 136 | self.assertEqual( 137 | err_ctx.message, "attributes must be a list of Attribute objects" 138 | ) 139 | 140 | def test_no_devices(self): 141 | with self.assertRaises(ValueError) as err_ctx: 142 | ua.ModifyAttributes( 143 | airship=self.test_airship, attributes=[self.set_attribute] 144 | ) 145 | 146 | self.assertEqual( 147 | err_ctx.message, "Either channel or named_user must be included" 148 | ) 149 | 150 | def test_both_devices(self): 151 | with self.assertRaises(ValueError) as err_ctx: 152 | ua.ModifyAttributes( 153 | airship=self.test_airship, 154 | attributes=[self.set_attribute], 155 | channel=self.test_channel, 156 | named_user=self.test_named_user, 157 | ) 158 | 159 | self.assertEqual( 160 | err_ctx.message, 161 | "Either channel or named_user must be included, not both", 162 | ) 163 | 164 | 165 | class TestAttributeList(unittest.TestCase): 166 | def setUp(self): 167 | self.airship = ua.Airship(TEST_KEY, TEST_SECRET) 168 | self.list_name = "my_test_list" 169 | self.description = "this is my cool list" 170 | self.extra = {"key": "value"} 171 | self.file_path = "tests/data/attribute_list.csv" 172 | self.attr_list = ua.AttributeList( 173 | airship=self.airship, 174 | list_name=self.list_name, 175 | description=self.description, 176 | extra=self.extra, 177 | ) 178 | 179 | def test_create(self): 180 | with mock.patch.object(ua.Airship, "_request") as mock_request: 181 | response = requests.Response() 182 | response._content = json.dumps({"ok": True}).encode("UTF-8") 183 | mock_request.return_value = response 184 | 185 | result = self.attr_list.create() 186 | 187 | self.assertEqual(result.json(), {"ok": True}) 188 | 189 | def test_upload(self): 190 | with mock.patch.object(ua.Airship, "_request") as mock_request: 191 | response = requests.Response() 192 | response._content = json.dumps({"ok": True}).encode("UTF-8") 193 | mock_request.return_value = response 194 | 195 | result = self.attr_list.upload(file_path=self.file_path) 196 | 197 | self.assertEqual(result.json(), {"ok": True}) 198 | 199 | def test_create_payload_property(self): 200 | self.assertEqual( 201 | self.attr_list._create_payload, 202 | { 203 | "name": self.list_name, 204 | "description": self.description, 205 | "extra": self.extra, 206 | }, 207 | ) 208 | -------------------------------------------------------------------------------- /urbanairship/core.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import warnings 4 | from typing import Any, Dict, Optional 5 | 6 | import backoff 7 | import requests 8 | 9 | from . import __about__, client, common 10 | from .urls import Urls 11 | 12 | logger = logging.getLogger("urbanairship") 13 | 14 | VALID_KEY = re.compile(r"^[\w-]{22}$") 15 | VALID_LOCATIONS = ["eu", "us", None] 16 | 17 | 18 | class Airship(client.BaseClient): 19 | def __init__( 20 | self, 21 | key: str, 22 | secret: Optional[str] = None, 23 | token: Optional[str] = None, 24 | location: str = "us", 25 | timeout: Optional[int] = None, 26 | base_url: Optional[str] = None, 27 | retries: int = 0, 28 | ): 29 | """Main client class for interacting with the Airship API. 30 | 31 | :param key: [required] An Airship project key used to authenticate 32 | :param secret: [optional] The Airship-generated app or master secret for the 33 | provided key 34 | :param token: [optional] An Airship-generated bearer token for the provided key 35 | :param location: [optional] The Airship cloud site your project is associated 36 | with. Possible values: 'us', 'eu'. Defaults to 'us'. 37 | :param: timeout: [optional] An integer specifying the number of seconds used 38 | for a response timeout threshold 39 | :param base_url: [optional] A string defining an arbitrary base_url to use 40 | for requests to the Airship API. To be used in place of location. 41 | :param retries: [optional] An integer specifying the number of times to retry a 42 | failed request. Retried requests use exponential backoff between requests. 43 | Defaults to 0, no retry. 44 | """ 45 | warnings.warn( 46 | category=DeprecationWarning, 47 | message="The Airship client class is deprecated. The Airship class has been " 48 | "aliased to clients.BasicAuthClient. " 49 | "This class will be removed in version 8.0. If you are importing this " 50 | "directly, please migrate to clients.BasicAuthClient which should be " 51 | "a drop-in replacement.", 52 | stacklevel=2, 53 | ) 54 | 55 | self.key: str = key 56 | self.secret: Optional[str] = secret 57 | self.token: Optional[str] = token 58 | self.location: str = location 59 | self.timeout: Optional[int] = timeout 60 | self.retries = retries 61 | self.urls: Urls = Urls(location=self.location, base_url=base_url) 62 | 63 | if all([secret, token]): 64 | raise ValueError("One of token or secret must be used, not both") 65 | 66 | self.session = requests.Session() 67 | if isinstance(token, str): 68 | self.session.headers.update( 69 | {"X-UA-Appkey": key, "Authorization": f"Bearer {self.token}"} 70 | ) 71 | elif isinstance(secret, str): 72 | self.session.auth = (key, secret) 73 | else: 74 | raise ValueError("Either token or secret must be included") 75 | 76 | @property 77 | def retries(self) -> int: 78 | return self._retries 79 | 80 | @retries.setter 81 | def retries(self, value: int): 82 | self._retries = value 83 | 84 | @property 85 | def timeout(self) -> Optional[int]: 86 | return self._timeout 87 | 88 | @timeout.setter 89 | def timeout(self, value: Optional[int]) -> None: 90 | if not isinstance(value, int) and value is not None: 91 | raise ValueError("Timeout must be an integer") 92 | self._timeout = value 93 | 94 | @property 95 | def key(self) -> str: 96 | return self._key 97 | 98 | @key.setter 99 | def key(self, value: str) -> None: 100 | if not VALID_KEY.match(value): 101 | raise ValueError("keys must be 22 characters") 102 | self._key = value 103 | 104 | @property 105 | def location(self) -> str: 106 | return self._location 107 | 108 | @location.setter 109 | def location(self, value: str): 110 | if value not in VALID_LOCATIONS: 111 | raise ValueError("location must be one of {}".format(VALID_LOCATIONS)) 112 | self._location = value 113 | 114 | @property 115 | def secret(self) -> Optional[str]: 116 | return self._secret 117 | 118 | @secret.setter 119 | def secret(self, value: Optional[str]) -> None: 120 | if isinstance(value, str) and not VALID_KEY.match(value): 121 | raise ValueError("secrets must be 22 characters") 122 | self._secret = value 123 | 124 | @property 125 | def token(self) -> Optional[str]: 126 | return self._token 127 | 128 | @token.setter 129 | def token(self, value: Optional[str]) -> None: 130 | self._token = value 131 | 132 | def request( 133 | self, 134 | method: str, 135 | body: Any, 136 | url: str, 137 | content_type: Optional[str] = None, 138 | version: Optional[int] = None, 139 | params: Optional[Dict[str, Any]] = None, 140 | encoding: Optional[str] = None, 141 | ) -> requests.Response: 142 | return self._request(method, body, url, content_type, version, params, encoding) 143 | 144 | def _request( 145 | self, 146 | method: str, 147 | body: Any, 148 | url: str, 149 | content_type: Optional[str] = None, 150 | version: Optional[int] = None, 151 | params: Optional[Dict[str, Any]] = None, 152 | encoding: Optional[str] = None, 153 | ) -> requests.Response: 154 | headers: Dict[str, str] = { 155 | "User-agent": "UAPythonLib/{0} {1}".format(__about__.__version__, self.key) 156 | } 157 | if content_type: 158 | headers["Content-type"] = content_type 159 | if version: 160 | headers["Accept"] = "application/vnd.urbanairship+json; " "version=%d;" % version 161 | if encoding: 162 | headers["Content-Encoding"] = encoding 163 | 164 | @backoff.on_exception( 165 | backoff.expo, 166 | (common.AirshipFailure, common.ConnectionFailure), 167 | max_tries=(self.retries + 1), 168 | ) 169 | def make_retryable_request( 170 | method: str, 171 | url: str, 172 | body: Any, 173 | params: Optional[Dict[str, Any]], 174 | headers: Dict[str, Any], 175 | ) -> requests.Response: 176 | try: 177 | response: requests.Response = self.session.request( 178 | method, 179 | url, 180 | data=body, 181 | params=params, 182 | headers=headers, 183 | timeout=self.timeout, 184 | ) 185 | except requests.exceptions.ConnectionError as err: 186 | raise common.ConnectionFailure(str(err)) 187 | 188 | logger.debug( 189 | "Making %s request to %s. Headers:\n\t%s\nBody:\n\t%s", 190 | method, 191 | url, 192 | "\n\t".join("%s: %s" % (key, value) for (key, value) in headers.items()), 193 | body, 194 | ) 195 | 196 | logger.debug( 197 | "Received %s response. Headers:\n\t%s\nBody:\n\t%s", 198 | response.status_code, 199 | "\n\t".join("%s: %s" % (key, value) for (key, value) in response.headers.items()), 200 | response.content, 201 | ) 202 | 203 | if response.status_code == 401: 204 | raise common.Unauthorized 205 | elif not (200 <= response.status_code < 300): 206 | raise common.AirshipFailure.from_response(response) 207 | 208 | result: requests.Response = response 209 | return result 210 | 211 | return make_retryable_request(method, url, body, params, headers) 212 | -------------------------------------------------------------------------------- /tests/devices/test_open_channel.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import unittest 4 | 5 | import mock 6 | import requests 7 | 8 | import urbanairship as ua 9 | from tests import TEST_KEY, TEST_SECRET 10 | 11 | 12 | class TestOpenChannel(unittest.TestCase): 13 | def test_create_channel(self): 14 | channel_id = "37b4f6e9-8e50-4400-8246-bdfcbf7ed3be" 15 | address = "some_address" 16 | platform = "a_platform" 17 | identifiers = { 18 | "com.example.external_id": "df6a6b50-9843-7894-1235-12aed4489489", 19 | "another_example_identifier": "some_hash", 20 | } 21 | 22 | with mock.patch.object(ua.Airship, "_request") as mock_request: 23 | response = requests.Response() 24 | response._content = json.dumps({"channel_id": channel_id}).encode("utf-8") 25 | response.status_code = 200 26 | mock_request.return_value = response 27 | 28 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 29 | channel = ua.OpenChannel(airship) 30 | channel.address = address 31 | channel.open_platform = platform 32 | channel.opt_in = True 33 | channel.identifiers = identifiers 34 | 35 | channel.create() 36 | 37 | self.assertEqual(channel.channel_id, channel_id) 38 | 39 | def test_create_channel_with_tags(self): 40 | channel_id = "37b4f6e9-8e50-4400-8246-bdfcbf7ed3be" 41 | address = "some_address" 42 | platform = "a_platform" 43 | 44 | with mock.patch.object(ua.Airship, "_request") as mock_request: 45 | response = requests.Response() 46 | response._content = json.dumps({"channel_id": channel_id}).encode("utf-8") 47 | response.status_code = 200 48 | mock_request.return_value = response 49 | 50 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 51 | channel = ua.OpenChannel(airship) 52 | channel.address = address 53 | channel.open_platform = platform 54 | channel.opt_in = True 55 | channel.tags = ["a_tag"] 56 | 57 | channel.create() 58 | 59 | self.assertEqual(channel.channel_id, channel_id) 60 | 61 | def test_create_channel_requires_platform(self): 62 | address = "some_address" 63 | 64 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 65 | channel = ua.OpenChannel(airship) 66 | # Do not set platform 67 | channel.address = address 68 | channel.opt_in = True 69 | 70 | self.assertRaises(ValueError, channel.create) 71 | 72 | def test_create_channel_requires_address(self): 73 | platform = "a_platform" 74 | 75 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 76 | channel = ua.OpenChannel(airship) 77 | # Do not set address 78 | channel.open_platform = platform 79 | channel.opt_in = True 80 | 81 | self.assertRaises(ValueError, channel.create) 82 | 83 | def test_create_channel_requires_opt_in(self): 84 | address = "some_address" 85 | platform = "a_platform" 86 | 87 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 88 | channel = ua.OpenChannel(airship) 89 | # Do not set opt in 90 | channel.address = address 91 | channel.open_platform = platform 92 | 93 | self.assertRaises(ValueError, channel.create) 94 | 95 | def test_open_channel_lookup(self): 96 | with mock.patch.object(ua.Airship, "_request") as mock_request: 97 | response = requests.Response() 98 | response._content = json.dumps( 99 | { 100 | "ok": "true", 101 | "channel": { 102 | "channel_id": "b8f9b663-0a3b-cf45-587a-be880946e881", 103 | "device_type": "open", 104 | "installed": "true", 105 | "named_user_id": "john_doe_123", 106 | "tags": ["tag_a", "tag_b"], 107 | "tag_groups": { 108 | "timezone": ["America/Los_Angeles"], 109 | "locale_country": ["US"], 110 | "locale_language": ["en"], 111 | "tag_group_1": ["tag1", "tag2"], 112 | "tag_group_2": ["tag1", "tag2"], 113 | }, 114 | "created": "2017-08-08T20:41:06", 115 | "address": "example@example.com", 116 | "opt_in": "true", 117 | "open": { 118 | "open_platform_name": "email", 119 | "identifiers": { 120 | "com.example.external_id": "df6a6b50-9843-7894-1235-12aed4489489", 121 | "another_example_identifier": "some_hash", 122 | }, 123 | }, 124 | "last_registration": "2017-09-01T18:00:27", 125 | }, 126 | } 127 | ).encode("utf-8") 128 | 129 | response.status_code = 200 130 | mock_request.return_value = response 131 | 132 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 133 | channel_id = "b8f9b663-0a3b-cf45-587a-be880946e881" 134 | open_channel_lookup = ua.OpenChannel(airship).lookup(channel_id) 135 | 136 | date_created = datetime.datetime.strptime( 137 | "2017-08-08T20:41:06", "%Y-%m-%dT%H:%M:%S" 138 | ) 139 | date_last_registration = datetime.datetime.strptime( 140 | "2017-09-01T18:00:27", "%Y-%m-%dT%H:%M:%S" 141 | ) 142 | 143 | self.assertEqual(open_channel_lookup.channel_id, channel_id) 144 | self.assertEqual(open_channel_lookup.device_type, "open") 145 | self.assertEqual(open_channel_lookup.installed, "true") 146 | self.assertEqual(open_channel_lookup.opt_in, "true") 147 | self.assertEqual(open_channel_lookup.named_user_id, "john_doe_123") 148 | self.assertEqual(open_channel_lookup.created, date_created) 149 | self.assertEqual(open_channel_lookup.open_platform, "email") 150 | self.assertEqual( 151 | open_channel_lookup.last_registration, date_last_registration 152 | ) 153 | self.assertEqual(open_channel_lookup.address, "example@example.com") 154 | self.assertListEqual(open_channel_lookup.tags, ["tag_a", "tag_b"]) 155 | self.assertDictEqual( 156 | open_channel_lookup.identifiers, 157 | { 158 | "com.example.external_id": "df6a6b50-9843-7894-1235-12aed4489489", 159 | "another_example_identifier": "some_hash", 160 | }, 161 | ) 162 | 163 | def test_open_channel_update(self): 164 | channel_id = "b8f9b663-0a3b-cf45-587a-be880946e881" 165 | 166 | with mock.patch.object(ua.Airship, "_request") as mock_request: 167 | response = requests.Response() 168 | response._content = json.dumps( 169 | {"ok": True, "channel_id": channel_id} 170 | ).encode("utf-8") 171 | response.status_code = 200 172 | mock_request.return_value = response 173 | 174 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 175 | channel_to_update = ua.OpenChannel(airship) 176 | channel_to_update.channel_id = channel_id 177 | channel_to_update.open_platform = "email" 178 | channel_to_update.tags = ["a_new_tag"] 179 | channel_to_update.opt_in = True 180 | channel_to_update.address = "example@example.com" 181 | channel_to_update.update() 182 | 183 | self.assertEqual(channel_to_update.channel_id, channel_id) 184 | 185 | def test_open_channel_uninstall(self): 186 | with mock.patch.object(ua.Airship, "_request") as mock_request: 187 | response = requests.Response() 188 | response._content = json.dumps({"ok": True}) 189 | response.status_code = 200 190 | mock_request.return_value = response 191 | 192 | airship = ua.Airship(TEST_KEY, TEST_SECRET) 193 | channel = ua.OpenChannel(airship) 194 | channel.address = "new_email@example.com" 195 | channel.open_platform = "email" 196 | 197 | un_res = json.loads(channel.uninstall().content) 198 | 199 | self.assertEqual(un_res["ok"], True) 200 | -------------------------------------------------------------------------------- /urbanairship/push/audience.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, Dict, Optional, Union 3 | 4 | DEVICE_TOKEN_FORMAT = re.compile(r"^[0-9a-fA-F]{64}$") 5 | UUID_FORMAT = re.compile( 6 | r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}" r"-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" 7 | ) 8 | SMS_SENDER_FORMAT = re.compile(r"^[0-9]*$") 9 | SMS_MSISDN_FORMAT = re.compile(r"^[0-9]*$") 10 | 11 | 12 | # Value selectors; device IDs, aliases, tags, etc. 13 | def ios_channel(uuid: str) -> Dict[str, str]: 14 | """Select a single iOS Channel""" 15 | if not UUID_FORMAT.match(uuid): 16 | raise ValueError("Invalid iOS Channel") 17 | return {"ios_channel": uuid.lower().strip()} 18 | 19 | 20 | def android_channel(uuid: str) -> Dict[str, str]: 21 | """Select a single Android Channel""" 22 | if not UUID_FORMAT.match(uuid): 23 | raise ValueError("Invalid Android Channel") 24 | return {"android_channel": uuid.lower().strip()} 25 | 26 | 27 | def amazon_channel(uuid: str) -> Dict[str, str]: 28 | """Select a single Amazon Channel""" 29 | if not UUID_FORMAT.match(uuid): 30 | raise ValueError("Invalid Amazon Channel") 31 | return {"amazon_channel": uuid.lower().strip()} 32 | 33 | 34 | def device_token(token: str) -> Dict[str, str]: 35 | """Select a single iOS device token""" 36 | # Ensure the device token is valid 37 | if not DEVICE_TOKEN_FORMAT.match(token): 38 | raise ValueError("Invalid device token") 39 | return {"device_token": token.upper().strip()} 40 | 41 | 42 | def apid(uuid: str) -> Dict[str, str]: 43 | """Select a single Android APID""" 44 | if not UUID_FORMAT.match(uuid): 45 | raise ValueError("Invalid APID") 46 | return {"apid": uuid.lower().strip()} 47 | 48 | 49 | def channel(uuid: str) -> Dict[str, str]: 50 | """Select a single channel. 51 | This selector may be used for any channel_id, regardless of device type""" 52 | if not UUID_FORMAT.match(uuid): 53 | raise ValueError("Invalid Channel") 54 | return {"channel": uuid.lower().strip()} 55 | 56 | 57 | def open_channel(uuid: str) -> Dict[str, str]: 58 | """Select a single Open Channel""" 59 | if not UUID_FORMAT.match(uuid): 60 | raise ValueError("Invalid Open Channel") 61 | return {"open_channel": uuid.lower().strip()} 62 | 63 | 64 | def sms_sender(sender: str) -> Dict[str, str]: 65 | """Select an SMS Sender""" 66 | if not (isinstance(sender, str) and SMS_SENDER_FORMAT.match(sender)): 67 | raise ValueError("sms_sender value must be a numeric string.") 68 | return {"sms_sender": sender} 69 | 70 | 71 | def sms_id(msisdn: str, sender: str) -> Dict[str, Dict]: 72 | """Select an SMS MSISDN""" 73 | if not (isinstance(msisdn, str) and SMS_MSISDN_FORMAT.match(msisdn)): 74 | raise ValueError("msisdn value must be a numeric string.") 75 | if not (isinstance(sender, str) and SMS_SENDER_FORMAT.match(sender)): 76 | raise ValueError("sender value must be a numeric string.") 77 | return {"sms_id": {"sender": sender, "msisdn": msisdn}} 78 | 79 | 80 | def wns(uuid: str) -> Dict[str, str]: 81 | """Select a single Windows 8 APID""" 82 | if not UUID_FORMAT.match(uuid): 83 | raise ValueError("Invalid wns") 84 | return {"wns": uuid.lower().strip()} 85 | 86 | 87 | def tag(tag: str) -> Dict[str, str]: 88 | """Select a single device tag.""" 89 | return {"tag": tag} 90 | 91 | 92 | def tag_group(tag_group: str, tag: str) -> Dict[str, str]: 93 | """Select a tag group and a tag.""" 94 | payload = {"group": tag_group, "tag": tag} 95 | return payload 96 | 97 | 98 | def alias(alias: str) -> Dict[str, str]: 99 | """Select a single alias.""" 100 | return {"alias": alias} 101 | 102 | 103 | def segment(segment: str) -> Dict[str, str]: 104 | """Select a single segment.""" 105 | return {"segment": segment} 106 | 107 | 108 | def named_user(name: str) -> Dict[str, str]: 109 | """Select a Named User ID""" 110 | return {"named_user": name} 111 | 112 | 113 | def subscription_list(list_id: str) -> Dict[str, str]: 114 | """Select a subscription list""" 115 | return {"subscription_lists": list_id} 116 | 117 | 118 | def static_list(list_id: str) -> Dict[str, str]: 119 | """Select a static list""" 120 | return {"static_list": list_id} 121 | 122 | 123 | # Attribute selectors 124 | def date_attribute( 125 | attribute: str, 126 | operator: str, 127 | precision: Optional[str] = None, 128 | value: Optional[Union[str, int]] = None, 129 | ) -> Dict[str, Any]: 130 | """ 131 | Select an audience to send to based on an attribute object with a DATE schema type, 132 | including predefined and device attributes. 133 | Please refer to https://docs.airship.com/api/ua/?http#schemas-dateattribute for 134 | more information about using this selector, including information about required 135 | data formatting for values. 136 | Custom attributes must be defined in the Airship UI prior to use. 137 | """ 138 | if operator not in ["is_empty", "before", "after", "range", "equals"]: 139 | raise ValueError( 140 | "operator must be one of: 'is_empty', 'before', 'after', 'range', 'equals'" 141 | ) 142 | 143 | selector: Dict[str, Any] = {"attribute": attribute, "operator": operator} 144 | 145 | if operator == "range": 146 | if value is None: 147 | raise ValueError( 148 | "value must be included when using the '{0}' operator".format(operator) 149 | ) 150 | 151 | selector["value"] = value 152 | 153 | if operator in ["before", "after", "equals"]: 154 | if value is None: 155 | raise ValueError( 156 | "value must be included when using the '{0}' operator".format(operator) 157 | ) 158 | if precision is None: 159 | raise ValueError( 160 | "precision must be included when using the '{0}' operator".format(operator) 161 | ) 162 | 163 | selector["value"] = value 164 | selector["precision"] = precision 165 | 166 | return selector 167 | 168 | 169 | def text_attribute(attribute: str, operator: str, value: str) -> Dict[str, Any]: 170 | """ 171 | Select an audience to send to based on an attribute object with a TEXT schema type, 172 | including predefined and device attributes. 173 | 174 | Please refer to https://docs.airship.com/api/ua/?http#schemas-textattribute for 175 | more information about using this selector, including information about required 176 | data formatting for values. 177 | 178 | Custom attributes must be defined in the Airship UI prior to use. 179 | """ 180 | if operator not in ["equals", "contains", "less", "greater", "is_empty"]: 181 | raise ValueError( 182 | "operator must be one of 'equals', 'contains', 'less', 'greater', 'is_empty'" 183 | ) 184 | 185 | if type(value) is not str: 186 | raise ValueError("value must be a string") 187 | 188 | return {"attribute": attribute, "operator": operator, "value": value} 189 | 190 | 191 | def number_attribute(attribute: str, operator: str, value: int) -> Dict[str, Any]: 192 | """ 193 | Select an audience to send to based on an attribute object with a INTEGER schema 194 | type, including predefined and device attributes. 195 | 196 | Please refer to https://docs.airship.com/api/ua/?http#schemas-numberattribute for 197 | more information about using this selector, including information about required 198 | data formatting for values. 199 | 200 | Custom attributes must be defined in the Airship UI prior to use. 201 | """ 202 | if operator not in ["equals", "contains", "less", "greater", "is_empty"]: 203 | raise ValueError( 204 | "operator must be one of 'equals', 'contains', 'less', 'greater', 'is_empty'" 205 | ) 206 | 207 | if type(value) is not int: 208 | raise ValueError("value must be an integer") 209 | 210 | return {"attribute": attribute, "operator": operator, "value": value} 211 | 212 | 213 | # Compound selectors 214 | def or_(*children: Any) -> Dict[str, Any]: 215 | """Select devices that match at least one of the given selectors. 216 | 217 | >>> or_(tag('sports'), tag('business')) 218 | {'or': [{'tag': 'sports'}, {'tag': 'business'}]} 219 | 220 | """ 221 | return {"or": [child for child in children]} 222 | 223 | 224 | def and_(*children: Any) -> Dict[str, Any]: 225 | """Select devices that match all of the given selectors. 226 | 227 | >>> and_(tag('sports'), tag('business')) 228 | {'and': [{'tag': 'sports'}, {'tag': 'business'}]} 229 | 230 | """ 231 | return {"and": [child for child in children]} 232 | 233 | 234 | def not_(child: Any) -> Dict[str, Any]: 235 | """Select devices that does not match the given selectors. 236 | 237 | >>> not_(and_(tag('sports'), tag('business'))) 238 | {'not': {'and': [{'tag': 'sports'}, {'tag': 'business'}]}} 239 | 240 | """ 241 | return {"not": child} 242 | --------------------------------------------------------------------------------