├── .coveragerc ├── .flake8 ├── .github ├── CODEOWNERS └── workflows │ ├── ci.yml │ ├── initiate_release.yml │ ├── release.yml │ └── reviewdog.yml ├── .gitignore ├── .versionrc.js ├── CHANGELOG.md ├── ISSUE_TEMPLATE.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── SECURITY.md ├── assets └── logo.svg ├── dotgit ├── hooks-wrapper ├── hooks │ └── pre-commit-format.sh └── setup-hooks.sh ├── pyproject.toml ├── scripts └── get_changelog_diff.js ├── setup.py └── stream ├── __init__.py ├── client ├── __init__.py ├── async_client.py ├── base.py └── client.py ├── collections ├── __init__.py ├── base.py └── collections.py ├── exceptions.py ├── feed ├── __init__.py ├── base.py └── feeds.py ├── personalization ├── __init__.py ├── base.py └── personalizations.py ├── reactions ├── __init__.py ├── base.py └── reaction.py ├── serializer.py ├── tests ├── __init__.py ├── conftest.py ├── test_async_client.py └── test_client.py ├── users ├── __init__.py ├── base.py └── user.py └── utils.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = stream/tests/* -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501,W503,E203,E731 3 | max-line-length = 110 4 | select = C,E,F,W,B,B950 5 | exclude = .eggs/*,docs/*,lib,src,bin,include,share 6 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @JimmyPettersson85 @xernobyl 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - 'main' 6 | pull_request: 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.head_ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | build: 14 | name: 🧪 Test & lint 15 | runs-on: ubuntu-latest 16 | strategy: 17 | max-parallel: 1 18 | matrix: 19 | python: ['3.8', '3.9', '3.10', '3.11', '3.12'] 20 | steps: 21 | - uses: actions/checkout@v3 22 | with: 23 | fetch-depth: 0 # gives the commit linter access to previous commits 24 | 25 | - uses: actions/setup-python@v3 26 | with: 27 | python-version: ${{ matrix.python }} 28 | 29 | - name: Install deps with ${{ matrix.python }} 30 | run: pip install -q ".[test, ci]" 31 | 32 | - name: Lint with ${{ matrix.python }} 33 | if: ${{ matrix.python == '3.8' }} 34 | run: make lint 35 | 36 | - name: Install, test and code coverage with ${{ matrix.python }} 37 | env: 38 | STREAM_KEY: ${{ secrets.STREAM_KEY }} 39 | STREAM_SECRET: ${{ secrets.STREAM_SECRET }} 40 | PYTHONPATH: ${{ github.workspace }} 41 | run: make test 42 | -------------------------------------------------------------------------------- /.github/workflows/initiate_release.yml: -------------------------------------------------------------------------------- 1 | name: Create release PR 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "The new version number with 'v' prefix. Example: v1.40.1" 8 | required: true 9 | 10 | jobs: 11 | init_release: 12 | name: 🚀 Create release PR 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 # gives the changelog generator access to all previous commits 18 | 19 | - name: Update CHANGELOG.md, __pkg__.py and push release branch 20 | env: 21 | VERSION: ${{ github.event.inputs.version }} 22 | run: | 23 | npx --yes standard-version@9.3.2 --release-as "$VERSION" --skip.tag --skip.commit --tag-prefix=v 24 | git config --global user.name 'github-actions' 25 | git config --global user.email 'release@getstream.io' 26 | git checkout -q -b "release-$VERSION" 27 | git commit -am "chore(release): $VERSION" 28 | git push -q -u origin "release-$VERSION" 29 | 30 | - name: Get changelog diff 31 | uses: actions/github-script@v5 32 | with: 33 | script: | 34 | const get_change_log_diff = require('./scripts/get_changelog_diff.js') 35 | core.exportVariable('CHANGELOG', get_change_log_diff()) 36 | 37 | - name: Open pull request 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | run: | 41 | gh pr create \ 42 | -t "chore(release): ${{ github.event.inputs.version }}" \ 43 | -b "# :rocket: ${{ github.event.inputs.version }} 44 | Make sure to use squash & merge when merging! 45 | Once this is merged, another job will kick off automatically and publish the package. 46 | # :memo: Changelog 47 | ${{ env.CHANGELOG }}" 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | pull_request: 5 | types: [closed] 6 | branches: 7 | - main 8 | 9 | jobs: 10 | Release: 11 | name: 🚀 Release 12 | if: github.event.pull_request.merged && startsWith(github.head_ref, 'release-') 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | 19 | - uses: actions/github-script@v5 20 | with: 21 | script: | 22 | const get_change_log_diff = require('./scripts/get_changelog_diff.js') 23 | core.exportVariable('CHANGELOG', get_change_log_diff()) 24 | 25 | // Getting the release version from the PR source branch 26 | // Source branch looks like this: release-1.0.0 27 | const version = context.payload.pull_request.head.ref.split('-')[1] 28 | core.exportVariable('VERSION', version) 29 | 30 | - uses: actions/setup-python@v3 31 | with: 32 | python-version: "3.10" 33 | 34 | - name: Publish to PyPi 35 | env: 36 | TWINE_USERNAME: "__token__" 37 | TWINE_PASSWORD: "${{ secrets.PYPI_TOKEN }}" 38 | run: | 39 | pip install -q twine==3.7.1 wheel==0.37.1 40 | python setup.py sdist bdist_wheel 41 | twine upload --non-interactive dist/* 42 | 43 | - name: Create release on GitHub 44 | uses: ncipollo/release-action@v1 45 | with: 46 | body: ${{ env.CHANGELOG }} 47 | tag: ${{ env.VERSION }} 48 | token: ${{ secrets.GITHUB_TOKEN }} 49 | -------------------------------------------------------------------------------- /.github/workflows/reviewdog.yml: -------------------------------------------------------------------------------- 1 | name: reviewdog 2 | on: 3 | pull_request: 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.head_ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | reviewdog: 11 | name: 🐶 Reviewdog 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - uses: reviewdog/action-setup@v1 17 | with: 18 | reviewdog_version: latest 19 | 20 | - uses: actions/setup-python@v3 21 | with: 22 | python-version: "3.10" 23 | 24 | - name: Install deps 25 | run: pip install ".[ci]" 26 | 27 | - name: Reviewdog 28 | env: 29 | REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | run: make reviewdog 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | .eggs/ 25 | 26 | # Installer logs 27 | pip-log.txt 28 | pip-delete-this-directory.txt 29 | 30 | # Unit test / coverage reports 31 | htmlcov/ 32 | .tox/ 33 | .coverage 34 | .cache 35 | nosetests.xml 36 | coverage.xml 37 | 38 | # Translations 39 | *.mo 40 | 41 | # Mr Developer 42 | .mr.developer.cfg 43 | .project 44 | .pydevproject 45 | 46 | # Rope 47 | .ropeproject 48 | 49 | # Django stuff: 50 | *.log 51 | *.pot 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | 57 | .python-version 58 | secrets.*sh 59 | .idea 60 | .vscode/ 61 | .python-version 62 | 63 | .venv 64 | .venv3.7 65 | .venv3.8 66 | .venv3.9 67 | .venv3.10 68 | .venv3.11 69 | .envrc 70 | -------------------------------------------------------------------------------- /.versionrc.js: -------------------------------------------------------------------------------- 1 | const pkgUpdater = { 2 | VERSION_REGEX: /__version__ = "(.+)"/, 3 | 4 | readVersion: function (contents) { 5 | const version = this.VERSION_REGEX.exec(contents)[1]; 6 | return version; 7 | }, 8 | 9 | writeVersion: function (contents, version) { 10 | return contents.replace(this.VERSION_REGEX.exec(contents)[0], `__version__ = "${version}"`); 11 | } 12 | } 13 | 14 | module.exports = { 15 | bumpFiles: [{ filename: './stream/__init__.py', updater: pkgUpdater }], 16 | } 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [5.3.1](https://github.com/GetStream/stream-python/compare/v5.2.1...v5.3.1) (2023-10-25) 6 | 7 | ### [5.2.1](https://github.com/GetStream/stream-python/compare/v5.2.0...v5.2.1) (2023-02-27) 8 | 9 | ## [5.2.0](https://github.com/GetStream/stream-python/compare/v5.1.1...v5.2.0) (2023-02-16) 10 | 11 | 12 | ### Features 13 | 14 | * add support for 3.11 ([2eae7d7](https://github.com/GetStream/stream-python/commit/2eae7d7958f3b869982701188fc0d04a5b8ab021)) 15 | * added async support ([b4515d3](https://github.com/GetStream/stream-python/commit/b4515d337be88ff50ba1cbad8645b1fbc8862ce0)) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * tests and linting ([cfacbbc](https://github.com/GetStream/stream-python/commit/cfacbbcadf45ca91d3e6c2a310dfd6fea1a03146)) 21 | * redirect, uniqueness and deprecations ([aefdcd3](https://github.com/GetStream/stream-python/commit/aefdcd39ff8a41a443455f1a41cc819039015cdb)) 22 | 23 | ## 5.1.1 - 2022-01-18 24 | 25 | * Handle backward compatible pyjwt 1.x support for token generation 26 | 27 | ## 5.1.0 - 2021-04-16 28 | 29 | * Add analytics support for `track_engagements` and `track_impressions` 30 | * Update license to BSD-3 canonical description 31 | 32 | ## 5.0.1 - 2021-01-22 33 | 34 | * Bump pyjwt to 2.x 35 | 36 | ## 5.0.0 - 2020-09-17 37 | 38 | * Drop python 3.5 and add 3.9 39 | * Improve install and CI 40 | 41 | ## 4.0.0 - 2020-09-02 42 | 43 | * Drop old create_user_session_token in favor create_user_token 44 | * Drop python support before 3.4 45 | * Allow custom data in client.create_jwt_token 46 | * Add kind filter for reactions in enrichment 47 | * Add follow stat support 48 | * Move to github actions from travis and improve static analysis 49 | * Update readme for old docs 50 | * Update some crypto dependencies 51 | 52 | ## 3.5.1 - 2020-06-08 53 | 54 | * Handle warning in JWT decode regarding missing algorithm 55 | 56 | ## 3.5.0 - 2020-06-08 57 | 58 | * Add enrichment support to direct activity get 59 | 60 | ## 3.4.0 - 2020-05-11 61 | 62 | * Expose target_feeds_extra_data to add extra data to activities from reactions 63 | 64 | ## 3.3.0 - 2020-05-04 65 | 66 | * Add batch unfollow support 67 | 68 | ## 3.2.1 - 2020-03-17 69 | 70 | * Set timezone as utc in serialization hooks 71 | 72 | ## 3.2.0 - 2020-03-17 73 | 74 | * Add open graph scrape support 75 | * Update python support (drop 2.6, add 3.8) 76 | * Fixes in docs for collections and personalization 77 | 78 | ## 3.1.1 - 2019-11-07 79 | 80 | * Bump crypto deps 81 | 82 | ## 3.1.0 - 2018-05-24 83 | 84 | * Batch partial update 85 | 86 | ## 3.0.2 - 2018-05-24 87 | 88 | * Fixes for filtering by reactions by kind 89 | 90 | ## 3.0.1 - 2018-12-04 91 | 92 | * Add short-hand version for collections.create_reference() 93 | 94 | ## 3.0.0 - 2018-12-03 95 | 96 | * Add support for reactions 97 | * Add support for users 98 | * Removed HTTP Signatures based auth 99 | * Use JWT auth for everything 100 | * Add feed.get enrichment params 101 | 102 | ## 2.12.0 - 2018-10-08 103 | 104 | * Add user-session-token support 105 | 106 | ## 2.11.0 - 2017-08-23 107 | 108 | * Add collection helpers to create refs 109 | 110 | ## 2.10.0 - 2017-07-30 111 | 112 | * Partial activity API endpoint 113 | 114 | ## 2.9.3 - 2017-07-20 115 | 116 | * Use Readme.md content as package long description 117 | 118 | ## 2.9.2 - 2017-07-20 119 | 120 | * Fixed deserialization problem with datetime objects with zeroed microseconds 121 | * Support newer versions of the pyJWT lib 122 | 123 | ## 2.9.1 - 2017-07-18 124 | 125 | Renamed client.get_activities' foreign_id_time param to foreign_id_times 126 | 127 | ## 2.9.0 - 2017-07-05 128 | 129 | * Add support for get activity API endpoint 130 | 131 | ## 2.8.1 - 2017-12-21 132 | 133 | * Fixes a regression with embedded httpsig and Python 3 134 | 135 | ## 2.8.0 - 2017-12-21 136 | 137 | * Fixes install issues on Windows 138 | * Bundle http-sig library 139 | * Use pycryptodomex instead of the discontinued pycrypto library 140 | 141 | ## 2.7.0 - 2017-12-14 142 | 143 | * All client methods that make requests will return the response 144 | 145 | ## 2.6.2 - 2017-12-08 146 | 147 | * Consolidate API URL generation across API, Collections and Personalization services 148 | 149 | ## 2.6.0 - 2017-12-08 150 | 151 | Support the new collections endpoint and flexible get requests for personalization 152 | 153 | ## 2.5.0 - 2017-10-19 154 | 155 | * Use new .com domain for API and Analytics 156 | 157 | ## 2.4.0 - 2017-08-31 158 | 159 | * Added support for To target update endpoint 160 | 161 | ## 2.3.11 - 2017-05-22 162 | 163 | * Added support for Python 2.6.9 and downgrade to requests 2.2.1 164 | 165 | ## 2.3.9 - 2016-12-20 166 | 167 | * Fix errors_from_fields function so it displays the extra data returned by the 168 | server about InputException errors. 169 | 170 | ## 2.3.8 - 2016-06-09 171 | 172 | * Add support for keep_history on unfollow 173 | 174 | ## 2.3.7 - 2016-06-02 175 | 176 | * Add HTTP Signature auth method (for application auth resources) 177 | * Add support for follow_many batch operation 178 | * Add support for add_to_many batch operation 179 | * Decode JWT from bytes to UTF-8 180 | * Skip add_activities API call if activity_list is empty 181 | * Fix feed group and id validation, dashes are now allowed 182 | 183 | ## 2.3.5 - 2015-10-07 184 | 185 | * Added support for activity update 186 | 187 | ## 2.3.3 - 2015-10-07 188 | 189 | * Added support for creating redirect urls 190 | 191 | ## 2.3.0 - 2015-06-11 192 | 193 | * Added support for read-only tokens 194 | 195 | ## 2.1.4 - 2015-01-14 196 | 197 | * Added support for extra data for follow actions 198 | 199 | ## 2.1.3 - 2015-01-05 200 | 201 | * Bugfix, mark_seen and mark_read now work 202 | 203 | ## 2.1.0 - 2014-12-19 204 | 205 | * Added location support to reduce latency 206 | 207 | ## 2.0.1 - 2014-11-18 208 | 209 | * Additional validation on feed_slug and user_id 210 | 211 | ## 2.0.0 - 2014-11-10 212 | 213 | * Breaking change: New style feed syntax, client.feed('user', '1') instead of client.feed('user:3') 214 | * Breaking change: New style follow syntax, feed.follow('user', 3) 215 | * API versioning support 216 | * Configurable timeouts 217 | * Python 3 support 218 | 219 | ## 1.1.1 - 2014-09-20 220 | 221 | * Add HTTP client retries 222 | 223 | ## 1.1.0 -2014-09-08 224 | 225 | * Add support for mark read (notifications feeds) 226 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Problem explanation 2 | 3 | 4 | 5 | 6 | ## Steps to reproduce 7 | 8 | 9 | 10 | ## Environment info 11 | 12 | Operating System: 13 | Python version: 14 | Library version: 15 | 16 | 17 | ## Error traceback (if applicable) 18 | 19 | ``` 20 | [put traceback here] 21 | ``` 22 | 23 | ## Code snippet that causes the problem 24 | 25 | ```python 26 | [put code here] 27 | ``` 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2021, Stream.io Inc, and individual contributors. 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted 6 | provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this list of 9 | conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of 12 | conditions and the following disclaimer in the documentation and/or other materials 13 | provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its contributors may 16 | be used to endorse or promote products derived from this software without specific prior 17 | written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR 20 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY 21 | AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 22 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 23 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 26 | OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | STREAM_KEY ?= NOT_EXIST 2 | STREAM_SECRET ?= NOT_EXIST 3 | 4 | # These targets are not files 5 | .PHONY: help check test lint lint-fix 6 | 7 | help: ## Display this help message 8 | @echo "Please use \`make \` where is one of" 9 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; \ 10 | {printf "\033[36m%-40s\033[0m %s\n", $$1, $$2}' 11 | 12 | lint: ## Run linters 13 | black --check stream 14 | flake8 --ignore=E501,E225,W293,W503,F401 stream 15 | 16 | lint-fix: 17 | black stream 18 | 19 | test: ## Run tests 20 | STREAM_KEY=$(STREAM_KEY) STREAM_SECRET=$(STREAM_SECRET) pytest stream/tests 21 | 22 | check: lint test ## Run linters + tests 23 | 24 | reviewdog: 25 | black --check --diff --quiet stream | reviewdog -f=diff -f.diff.strip=0 -filter-mode="diff_context" -name=black -reporter=github-pr-review 26 | flake8 --ignore=E501,W503,E225,W293,F401 stream | reviewdog -f=flake8 -name=flake8 -reporter=github-pr-review 27 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Summary: 2 | 3 | 4 | ## Submitter checklist: 5 | - [ ] `CHANGELOG` updated or N/A 6 | - [ ] Documentation updated or N/A 7 | 8 | ## Merger checklist: 9 | - [ ] ALL tests have passed 10 | - [ ] Code Review is done 11 | - [ ] Dependencies satisfied 12 | 13 | ## Dependencies: 14 | 16 | - [ ] 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Official Python SDK for [Stream Feeds](https://getstream.io/activity-feeds/) 2 | 3 | [![build](https://github.com/GetStream/stream-python/workflows/build/badge.svg)](https://github.com/GetStream/stream-python/actions) [![PyPI version](https://badge.fury.io/py/stream-python.svg)](http://badge.fury.io/py/stream-python) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/stream-python.svg) 4 | 5 |

6 | 7 |

8 |

9 | Official Python API client for Stream Feeds, a web service for building scalable newsfeeds and activity streams. 10 |
11 | Explore the docs » 12 |
13 |
14 | Django Code Sample 15 | · 16 | Report Bug 17 | · 18 | Request Feature 19 |

20 | 21 | ## 📝 About Stream 22 | 23 | > 💡 Note: this is a library for the **Feeds** product. The Chat SDKs can be found [here](https://getstream.io/chat/docs/). 24 | 25 | You can sign up for a Stream account at our [Get Started](https://getstream.io/get_started/) page. 26 | 27 | You can use this library to access feeds API endpoints server-side. 28 | 29 | For the client-side integrations (web and mobile) have a look at the JavaScript, iOS and Android SDK libraries ([docs](https://getstream.io/activity-feeds/)). 30 | 31 | > 💡 We have a Django integration available [here](https://github.com/GetStream/stream-django). 32 | 33 | ## ⚙️ Installation 34 | 35 | 36 | ```bash 37 | $ pip install stream-python 38 | ``` 39 | 40 | ## 📚 Full documentation 41 | 42 | Documentation for this Python client are available at the [Stream website](https://getstream.io/docs/?language=python). 43 | 44 | ## ✨ Getting started 45 | 46 | ```python 47 | import datetime 48 | 49 | # Create a new client 50 | import stream 51 | client = stream.connect('YOUR_API_KEY', 'API_KEY_SECRET') 52 | 53 | # Create a new client specifying data center location 54 | client = stream.connect('YOUR_API_KEY', 'API_KEY_SECRET', location='us-east') 55 | # Find your API keys here https://getstream.io/dashboard/ 56 | 57 | # Create a feed object 58 | user_feed_1 = client.feed('user', '1') 59 | 60 | # Get activities from 5 to 10 (slow pagination) 61 | result = user_feed_1.get(limit=5, offset=5) 62 | # (Recommended & faster) Filter on an id less than the given UUID 63 | result = user_feed_1.get(limit=5, id_lt="e561de8f-00f1-11e4-b400-0cc47a024be0") 64 | 65 | # Create a new activity 66 | activity_data = {'actor': 1, 'verb': 'tweet', 'object': 1, 'foreign_id': 'tweet:1'} 67 | activity_response = user_feed_1.add_activity(activity_data) 68 | # Create a bit more complex activity 69 | activity_data = {'actor': 1, 'verb': 'run', 'object': 1, 'foreign_id': 'run:1', 70 | 'course': {'name': 'Golden Gate park', 'distance': 10}, 71 | 'participants': ['Thierry', 'Tommaso'], 72 | 'started_at': datetime.datetime.now() 73 | } 74 | user_feed_1.add_activity(activity_data) 75 | 76 | # Remove an activity by its id 77 | user_feed_1.remove_activity("e561de8f-00f1-11e4-b400-0cc47a024be0") 78 | # or by foreign id 79 | user_feed_1.remove_activity(foreign_id='tweet:1') 80 | 81 | # Follow another feed 82 | user_feed_1.follow('flat', '42') 83 | 84 | # Stop following another feed 85 | user_feed_1.unfollow('flat', '42') 86 | 87 | # List followers/following 88 | following = user_feed_1.following(offset=0, limit=2) 89 | followers = user_feed_1.followers(offset=0, limit=10) 90 | 91 | # Creates many follow relationships in one request 92 | follows = [ 93 | {'source': 'flat:1', 'target': 'user:1'}, 94 | {'source': 'flat:1', 'target': 'user:2'}, 95 | {'source': 'flat:1', 'target': 'user:3'} 96 | ] 97 | client.follow_many(follows) 98 | 99 | # Batch adding activities 100 | activities = [ 101 | {'actor': 1, 'verb': 'tweet', 'object': 1}, 102 | {'actor': 2, 'verb': 'watch', 'object': 3} 103 | ] 104 | user_feed_1.add_activities(activities) 105 | 106 | # Add an activity and push it to other feeds too using the `to` field 107 | activity = { 108 | "actor":"1", 109 | "verb":"like", 110 | "object":"3", 111 | "to":["user:44", "user:45"] 112 | } 113 | user_feed_1.add_activity(activity) 114 | 115 | # Retrieve an activity by its ID 116 | client.get_activities(ids=[activity_id]) 117 | 118 | # Retrieve an activity by the combination of foreign_id and time 119 | client.get_activities(foreign_id_times=[ 120 | (foreign_id, activity_time), 121 | ]) 122 | 123 | # Enrich while getting activities 124 | client.get_activities(ids=[activity_id], enrich=True, reactions={"counts": True}) 125 | 126 | # Update some parts of an activity with activity_partial_update 127 | set = { 128 | 'product.name': 'boots', 129 | 'colors': { 130 | 'red': '0xFF0000', 131 | 'green': '0x00FF00' 132 | } 133 | } 134 | unset = [ 'popularity', 'details.info' ] 135 | # ...by ID 136 | client.activity_partial_update(id=activity_id, set=set, unset=unset) 137 | # ...or by combination of foreign_id and time 138 | client.activity_partial_update(foreign_id=foreign_id, time=activity_time, set=set, unset=unset) 139 | 140 | # Generating user token for client side usage (JS client) 141 | user_token = client.create_user_token("user-42") 142 | 143 | # Javascript client side feed initialization 144 | # client = stream.connect(apiKey, userToken, appId); 145 | 146 | # Generate a redirect url for the Stream Analytics platform to track 147 | # events/impressions on url clicks 148 | impression = { 149 | 'content_list': ['tweet:1', 'tweet:2', 'tweet:3'], 150 | 'user_data': 'tommaso', 151 | 'location': 'email', 152 | 'feed_id': 'user:global' 153 | } 154 | 155 | engagement = { 156 | 'content': 'tweet:2', 157 | 'label': 'click', 158 | 'position': 1, 159 | 'user_data': 'tommaso', 160 | 'location': 'email', 161 | 'feed_id': 162 | 'user:global' 163 | } 164 | 165 | events = [impression, engagement] 166 | 167 | redirect_url = client.create_redirect_url('http://google.com/', 'user_id', events) 168 | ``` 169 | 170 | ### Async code usage 171 | ```python 172 | import datetime 173 | import stream 174 | client = stream.connect('YOUR_API_KEY', 'API_KEY_SECRET', use_async=True) 175 | 176 | 177 | # Create a new client specifying data center location 178 | client = stream.connect('YOUR_API_KEY', 'API_KEY_SECRET', location='us-east', use_async=True) 179 | # Find your API keys here https://getstream.io/dashboard/ 180 | 181 | # Create a feed object 182 | user_feed_1 = client.feed('user', '1') 183 | 184 | # Get activities from 5 to 10 (slow pagination) 185 | result = await user_feed_1.get(limit=5, offset=5) 186 | # (Recommended & faster) Filter on an id less than the given UUID 187 | result = await user_feed_1.get(limit=5, id_lt="e561de8f-00f1-11e4-b400-0cc47a024be0") 188 | 189 | # Create a new activity 190 | activity_data = {'actor': 1, 'verb': 'tweet', 'object': 1, 'foreign_id': 'tweet:1'} 191 | activity_response = await user_feed_1.add_activity(activity_data) 192 | # Create a bit more complex activity 193 | activity_data = {'actor': 1, 'verb': 'run', 'object': 1, 'foreign_id': 'run:1', 194 | 'course': {'name': 'Golden Gate park', 'distance': 10}, 195 | 'participants': ['Thierry', 'Tommaso'], 196 | 'started_at': datetime.datetime.now() 197 | } 198 | await user_feed_1.add_activity(activity_data) 199 | 200 | # Remove an activity by its id 201 | await user_feed_1.remove_activity("e561de8f-00f1-11e4-b400-0cc47a024be0") 202 | # or by foreign id 203 | await user_feed_1.remove_activity(foreign_id='tweet:1') 204 | 205 | # Follow another feed 206 | await user_feed_1.follow('flat', '42') 207 | 208 | # Stop following another feed 209 | await user_feed_1.unfollow('flat', '42') 210 | 211 | # List followers/following 212 | following = await user_feed_1.following(offset=0, limit=2) 213 | followers = await user_feed_1.followers(offset=0, limit=10) 214 | 215 | # Creates many follow relationships in one request 216 | follows = [ 217 | {'source': 'flat:1', 'target': 'user:1'}, 218 | {'source': 'flat:1', 'target': 'user:2'}, 219 | {'source': 'flat:1', 'target': 'user:3'} 220 | ] 221 | await client.follow_many(follows) 222 | 223 | # Batch adding activities 224 | activities = [ 225 | {'actor': 1, 'verb': 'tweet', 'object': 1}, 226 | {'actor': 2, 'verb': 'watch', 'object': 3} 227 | ] 228 | await user_feed_1.add_activities(activities) 229 | 230 | # Add an activity and push it to other feeds too using the `to` field 231 | activity = { 232 | "actor":"1", 233 | "verb":"like", 234 | "object":"3", 235 | "to":["user:44", "user:45"] 236 | } 237 | await user_feed_1.add_activity(activity) 238 | 239 | # Retrieve an activity by its ID 240 | await client.get_activities(ids=[activity_id]) 241 | 242 | # Retrieve an activity by the combination of foreign_id and time 243 | await client.get_activities(foreign_id_times=[ 244 | (foreign_id, activity_time), 245 | ]) 246 | 247 | # Enrich while getting activities 248 | await client.get_activities(ids=[activity_id], enrich=True, reactions={"counts": True}) 249 | 250 | # Update some parts of an activity with activity_partial_update 251 | set = { 252 | 'product.name': 'boots', 253 | 'colors': { 254 | 'red': '0xFF0000', 255 | 'green': '0x00FF00' 256 | } 257 | } 258 | unset = [ 'popularity', 'details.info' ] 259 | # ...by ID 260 | await client.activity_partial_update(id=activity_id, set=set, unset=unset) 261 | # ...or by combination of foreign_id and time 262 | await client.activity_partial_update(foreign_id=foreign_id, time=activity_time, set=set, unset=unset) 263 | 264 | # Generating user token for client side usage (JS client) 265 | user_token = client.create_user_token("user-42") 266 | 267 | # Javascript client side feed initialization 268 | # client = stream.connect(apiKey, userToken, appId); 269 | 270 | # Generate a redirect url for the Stream Analytics platform to track 271 | # events/impressions on url clicks 272 | impression = { 273 | 'content_list': ['tweet:1', 'tweet:2', 'tweet:3'], 274 | 'user_data': 'tommaso', 275 | 'location': 'email', 276 | 'feed_id': 'user:global' 277 | } 278 | 279 | engagement = { 280 | 'content': 'tweet:2', 281 | 'label': 'click', 282 | 'position': 1, 283 | 'user_data': 'tommaso', 284 | 'location': 'email', 285 | 'feed_id': 286 | 'user:global' 287 | } 288 | 289 | events = [impression, engagement] 290 | 291 | redirect_url = client.create_redirect_url('http://google.com/', 'user_id', events) 292 | 293 | ``` 294 | 295 | [JS client](http://github.com/getstream/stream-js). 296 | 297 | ## ✍️ Contributing 298 | ======= 299 | 300 | We welcome code changes that improve this library or fix a problem, please make sure to follow all best practices and add tests if applicable before submitting a Pull Request on Github. We are very happy to merge your code in the official repository. Make sure to sign our [Contributor License Agreement (CLA)](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) first. See our [license file](./LICENSE) for more details. 301 | 302 | ## 🧑‍💻 We are hiring! 303 | 304 | We've recently closed a [$38 million Series B funding round](https://techcrunch.com/2021/03/04/stream-raises-38m-as-its-chat-and-activity-feed-apis-power-communications-for-1b-users/) and we keep actively growing. 305 | Our APIs are used by more than a billion end-users, and you'll have a chance to make a huge impact on the product within a team of the strongest engineers all over the world. 306 | 307 | Check out our current openings and apply via [Stream's website](https://getstream.io/team/#jobs). 308 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting a Vulnerability 2 | At Stream we are committed to the security of our Software. We appreciate your efforts in disclosing vulnerabilities responsibly and we will make every effort to acknowledge your contributions. 3 | 4 | Report security vulnerabilities at the following email address: 5 | ``` 6 | [security@getstream.io](mailto:security@getstream.io) 7 | ``` 8 | Alternatively it is also possible to open a new issue in the affected repository, tagging it with the `security` tag. 9 | 10 | A team member will acknowledge the vulnerability and will follow-up with more detailed information. A representative of the security team will be in touch if more information is needed. 11 | 12 | # Information to include in a report 13 | While we appreciate any information that you are willing to provide, please make sure to include the following: 14 | * Which repository is affected 15 | * Which branch, if relevant 16 | * Be as descriptive as possible, the team will replicate the vulnerability before working on a fix. 17 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | STREAM MARK 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /dotgit/hooks-wrapper: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Runs all executable pre-commit-* hooks and exits after, 4 | # if any of them was not successful. 5 | # 6 | # Based on 7 | # https://github.com/ELLIOTTCABLE/Paws.js/blob/Master/Scripts/git-hooks/chain-hooks.sh 8 | # http://osdir.com/ml/git/2009-01/msg00308.html 9 | # 10 | # assumes your scripts are located at /bin/git/hooks 11 | exitcodes=() 12 | hookname=`basename $0` 13 | # our special hooks folder 14 | CUSTOM_HOOKS_DIR=$(git rev-parse --show-toplevel)/dotgit/hooks 15 | # find gits native hooks folder 16 | NATIVE_HOOKS_DIR=$(git rev-parse --show-toplevel)/.git/hooks 17 | 18 | # Run each hook, passing through STDIN and storing the exit code. 19 | # We don't want to bail at the first failure, as the user might 20 | # then bypass the hooks without knowing about additional issues. 21 | 22 | for hook in $CUSTOM_HOOKS_DIR/$(basename $0)-*; do 23 | test -x "$hook" || continue 24 | $hook "$@" 25 | exitcodes+=($?) 26 | done 27 | 28 | # check if there was a local hook that was moved previously 29 | if [ -f "$NATIVE_HOOKS_DIR/$hookname.local" ]; then 30 | out=`$NATIVE_HOOKS_DIR/$hookname.local "$@"` 31 | exitcodes+=($?) 32 | echo "$out" 33 | fi 34 | 35 | # If any exit code isn't 0, bail. 36 | for i in "${exitcodes[@]}"; do 37 | [ "$i" == 0 ] || exit $i 38 | done -------------------------------------------------------------------------------- /dotgit/hooks/pre-commit-format.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | if ! black stream --check -q; then 6 | black stream 7 | echo 8 | echo "some files were not formatted correctly (black) commit aborted!" 9 | echo "your changes are still staged, you can accept formatting changes with git add or ignore them by adding --no-verify to git commit" 10 | exit 1 11 | fi 12 | 13 | if ! flake8 --ignore=E501,E225,W293,W503,F401 stream; then 14 | echo 15 | echo "commit is aborted because there are some error prone issues in your changes as printed above" 16 | echo "your changes are still staged, you can accept formatting changes with git add or ignore them by adding --no-verify to git commit" 17 | exit 1 18 | fi 19 | -------------------------------------------------------------------------------- /dotgit/setup-hooks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # based on http://stackoverflow.com/a/3464399/1383268 3 | # assumes that the hooks-wrapper script is located at /bin/git/hooks-wrapper 4 | 5 | HOOK_NAMES="applypatch-msg pre-applypatch post-applypatch pre-commit prepare-commit-msg commit-msg post-commit pre-rebase post-checkout post-merge pre-receive update post-receive post-update pre-auto-gc pre-push" 6 | # find gits native hooks folder 7 | HOOKS_DIR=$(git rev-parse --show-toplevel)/.git/hooks 8 | 9 | for hook in $HOOK_NAMES; do 10 | # If the hook already exists, is a file, and is not a symlink 11 | if [ ! -h $HOOKS_DIR/$hook ] && [ -f $HOOKS_DIR/$hook ]; then 12 | mv $HOOKS_DIR/$hook $HOOKS_DIR/$hook.local 13 | fi 14 | # create the symlink, overwriting the file if it exists 15 | # probably the only way this would happen is if you're using an old version of git 16 | # -- back when the sample hooks were not executable, instead of being named ____.sample 17 | ln -s -f ../../dotgit/hooks-wrapper $HOOKS_DIR/$hook 18 | done -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ['py38'] 4 | include = '\.pyi?$' 5 | exclude = ''' 6 | /( 7 | \.git 8 | | \.hg 9 | | \.egg 10 | | \.eggs 11 | | \.mypy_cache 12 | | \.tox 13 | | _build 14 | | \.venv 15 | | src 16 | | bin 17 | | stream_python\.egg-info 18 | | fabfile.py 19 | | lib 20 | | docs 21 | | buck-out 22 | | build 23 | | dist 24 | )/ 25 | ''' 26 | -------------------------------------------------------------------------------- /scripts/get_changelog_diff.js: -------------------------------------------------------------------------------- 1 | /* 2 | Here we're trying to parse the latest changes from CHANGELOG.md file. 3 | The changelog looks like this: 4 | 5 | ## 0.0.3 6 | - Something #3 7 | ## 0.0.2 8 | - Something #2 9 | ## 0.0.1 10 | - Something #1 11 | 12 | In this case we're trying to extract "- Something #3" since that's the latest change. 13 | */ 14 | module.exports = () => { 15 | const fs = require('fs') 16 | 17 | changelog = fs.readFileSync('CHANGELOG.md', 'utf8') 18 | releases = changelog.match(/## [?[0-9](.+)/g) 19 | 20 | current_release = changelog.indexOf(releases[0]) 21 | previous_release = changelog.indexOf(releases[1]) 22 | 23 | latest_changes = changelog.substr(current_release, previous_release - current_release) 24 | 25 | return latest_changes 26 | } 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | 4 | from setuptools import setup, find_packages 5 | from stream import __version__, __maintainer__, __email__, __license__ 6 | 7 | install_requires = [ 8 | "requests>=2.31.0,<3", 9 | "pyjwt>=2.8.0,<3", 10 | "pytz>=2023.3.post1", 11 | "aiohttp>=3.9.0b0", 12 | ] 13 | tests_require = ["pytest", "pytest-cov", "python-dateutil", "pytest-asyncio"] 14 | ci_require = ["black", "flake8", "pytest-cov"] 15 | 16 | long_description = open("README.md", "r").read() 17 | 18 | setup( 19 | name="stream-python", 20 | version=__version__, 21 | author=__maintainer__, 22 | author_email=__email__, 23 | url="http://github.com/GetStream/stream-python", 24 | description="Client for getstream.io. Build scalable newsfeeds & activity streams in a few hours instead of weeks.", 25 | long_description=long_description, 26 | long_description_content_type="text/markdown", 27 | project_urls={ 28 | "Bug Tracker": "https://github.com/GetStream/stream-python/issues", 29 | "Documentation": "https://getstream.io/activity-feeds/docs/python/?language=python", 30 | "Release Notes": "https://github.com/GetStream/stream-python/releases/tag/v{}".format( 31 | __version__ 32 | ), 33 | }, 34 | license=__license__, 35 | packages=find_packages(exclude=["*tests*"]), 36 | zip_safe=False, 37 | install_requires=install_requires, 38 | extras_require={"test": tests_require, "ci": ci_require}, 39 | tests_require=tests_require, 40 | include_package_data=True, 41 | python_requires=">=3.7", 42 | classifiers=[ 43 | "Intended Audience :: Developers", 44 | "Intended Audience :: System Administrators", 45 | "Operating System :: OS Independent", 46 | "Topic :: Software Development", 47 | "Development Status :: 5 - Production/Stable", 48 | "License :: OSI Approved :: BSD License", 49 | "Natural Language :: English", 50 | "Programming Language :: Python :: 3", 51 | "Programming Language :: Python :: 3.7", 52 | "Programming Language :: Python :: 3.8", 53 | "Programming Language :: Python :: 3.9", 54 | "Programming Language :: Python :: 3.10", 55 | "Programming Language :: Python :: 3.11", 56 | "Topic :: Software Development :: Libraries :: Python Modules", 57 | ], 58 | ) 59 | -------------------------------------------------------------------------------- /stream/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | __author__ = "Thierry Schellenbach" 5 | __copyright__ = "Copyright 2022, Stream.io, Inc" 6 | __credits__ = ["Thierry Schellenbach, mellowmorning.com, @tschellenbach"] 7 | __license__ = "BSD-3-Clause" 8 | __version__ = "5.3.1" 9 | __maintainer__ = "Thierry Schellenbach" 10 | __email__ = "support@getstream.io" 11 | __status__ = "Production" 12 | 13 | 14 | def connect( 15 | api_key=None, 16 | api_secret=None, 17 | app_id=None, 18 | version="v1.0", 19 | timeout=3.0, 20 | location=None, 21 | base_url=None, 22 | use_async=False, 23 | ): 24 | """ 25 | Returns a Client object 26 | 27 | :param api_key: your api key or heroku url 28 | :param api_secret: the api secret 29 | :param app_id: the app id (used for listening to feed changes) 30 | :param use_async: flag to set AsyncClient 31 | """ 32 | from stream.client import AsyncStreamClient, StreamClient 33 | 34 | if location is None: 35 | location = os.environ.get("STREAM_REGION") 36 | 37 | stream_url = os.environ.get("STREAM_URL") 38 | # support for the heroku STREAM_URL syntax 39 | if stream_url and not api_key: 40 | pattern = re.compile( 41 | r"https\:\/\/(\w+)\:(\w+)\@([\w-]*).*\?app_id=(\d+)", re.IGNORECASE 42 | ) 43 | result = pattern.match(stream_url) 44 | if result and len(result.groups()) == 4: 45 | api_key, api_secret, location, app_id = result.groups() 46 | location = None if location in ("getstream", "stream-io-api") else location 47 | else: 48 | raise ValueError("Invalid api key or heroku url") 49 | 50 | if use_async: 51 | return AsyncStreamClient( 52 | api_key, 53 | api_secret, 54 | app_id, 55 | version, 56 | timeout, 57 | location=location, 58 | base_url=base_url, 59 | ) 60 | 61 | return StreamClient( 62 | api_key, 63 | api_secret, 64 | app_id, 65 | version, 66 | timeout, 67 | location=location, 68 | base_url=base_url, 69 | ) 70 | -------------------------------------------------------------------------------- /stream/client/__init__.py: -------------------------------------------------------------------------------- 1 | from .async_client import AsyncStreamClient 2 | from .client import StreamClient 3 | -------------------------------------------------------------------------------- /stream/client/async_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import aiohttp 4 | from aiohttp import ClientConnectionError 5 | 6 | from stream import serializer 7 | from stream.client.base import BaseStreamClient 8 | from stream.collections import AsyncCollections 9 | from stream.feed.feeds import AsyncFeed 10 | from stream.personalization import AsyncPersonalization 11 | from stream.reactions import AsyncReactions 12 | from stream.serializer import _datetime_encoder 13 | from stream.users import AsyncUsers 14 | from stream.utils import ( 15 | get_reaction_params, 16 | validate_feed_slug, 17 | validate_foreign_id_time, 18 | validate_user_id, 19 | ) 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class AsyncStreamClient(BaseStreamClient): 25 | def __init__( 26 | self, 27 | api_key, 28 | api_secret, 29 | app_id, 30 | version="v1.0", 31 | timeout=6.0, 32 | base_url=None, 33 | location=None, 34 | ): 35 | super().__init__( 36 | api_key, 37 | api_secret, 38 | app_id, 39 | version=version, 40 | timeout=timeout, 41 | base_url=base_url, 42 | location=location, 43 | ) 44 | token = self.create_jwt_token("collections", "*", feed_id="*", user_id="*") 45 | self.collections = AsyncCollections(self, token) 46 | 47 | token = self.create_jwt_token("personalization", "*", feed_id="*", user_id="*") 48 | self.personalization = AsyncPersonalization(self, token) 49 | 50 | token = self.create_jwt_token("reactions", "*", feed_id="*") 51 | self.reactions = AsyncReactions(self, token) 52 | 53 | token = self.create_jwt_token("users", "*", feed_id="*") 54 | self.users = AsyncUsers(self, token) 55 | 56 | def feed(self, feed_slug, user_id): 57 | feed_slug = validate_feed_slug(feed_slug) 58 | user_id = validate_user_id(user_id) 59 | token = self.create_jwt_token("feed", "*", feed_id="*") 60 | return AsyncFeed(self, feed_slug, user_id, token) 61 | 62 | async def put(self, *args, **kwargs): 63 | return await self._make_request("PUT", *args, **kwargs) 64 | 65 | async def post(self, *args, **kwargs): 66 | return await self._make_request("POST", *args, **kwargs) 67 | 68 | async def get(self, *args, **kwargs): 69 | return await self._make_request("GET", *args, **kwargs) 70 | 71 | async def delete(self, *args, **kwargs): 72 | return await self._make_request("DELETE", *args, **kwargs) 73 | 74 | async def add_to_many(self, activity, feeds): 75 | data = {"activity": activity, "feeds": feeds} 76 | token = self.create_jwt_token("feed", "*", feed_id="*") 77 | return await self.post("feed/add_to_many/", token, data=data) 78 | 79 | async def follow_many(self, follows, activity_copy_limit=None): 80 | params = None 81 | 82 | if activity_copy_limit is not None: 83 | params = dict(activity_copy_limit=activity_copy_limit) 84 | token = self.create_jwt_token("follower", "*", feed_id="*") 85 | return await self.post("follow_many/", token, params=params, data=follows) 86 | 87 | async def unfollow_many(self, unfollows): 88 | params = None 89 | 90 | token = self.create_jwt_token("follower", "*", feed_id="*") 91 | return await self.post("unfollow_many/", token, params=params, data=unfollows) 92 | 93 | async def update_activities(self, activities): 94 | if not isinstance(activities, (list, tuple, set)): 95 | raise TypeError("Activities parameter should be of type list") 96 | 97 | auth_token = self.create_jwt_token("activities", "*", feed_id="*") 98 | data = dict(activities=activities) 99 | return await self.post("activities/", auth_token, data=data) 100 | 101 | async def update_activity(self, activity): 102 | return await self.update_activities([activity]) 103 | 104 | async def get_activities( 105 | self, ids=None, foreign_id_times=None, enrich=False, reactions=None, **params 106 | ): 107 | auth_token = self.create_jwt_token("activities", "*", feed_id="*") 108 | 109 | if ids is None and foreign_id_times is None: 110 | raise TypeError( 111 | "One the parameters ids or foreign_id_time must be provided and not None" 112 | ) 113 | 114 | if ids is not None and foreign_id_times is not None: 115 | raise TypeError( 116 | "At most one of the parameters ids or foreign_id_time must be provided" 117 | ) 118 | 119 | endpoint = "activities/" 120 | if enrich or reactions is not None: 121 | endpoint = "enrich/" + endpoint 122 | 123 | query_params = {**params} 124 | 125 | if ids is not None: 126 | query_params["ids"] = ",".join(ids) 127 | 128 | if foreign_id_times is not None: 129 | validate_foreign_id_time(foreign_id_times) 130 | foreign_ids, timestamps = zip(*foreign_id_times) 131 | timestamps = map(_datetime_encoder, timestamps) 132 | query_params["foreign_ids"] = ",".join(foreign_ids) 133 | query_params["timestamps"] = ",".join(timestamps) 134 | 135 | query_params.update(get_reaction_params(reactions)) 136 | 137 | return await self.get(endpoint, auth_token, params=query_params) 138 | 139 | async def activity_partial_update( 140 | self, id=None, foreign_id=None, time=None, set=None, unset=None 141 | ): 142 | if id is None and (foreign_id is None or time is None): 143 | raise TypeError( 144 | "The id or foreign_id+time parameters must be provided and not be None" 145 | ) 146 | if id is not None and (foreign_id is not None or time is not None): 147 | raise TypeError( 148 | "Only one of the id or the foreign_id+time parameters can be provided" 149 | ) 150 | 151 | data = {"set": set or {}, "unset": unset or []} 152 | 153 | if id is not None: 154 | data["id"] = id 155 | else: 156 | data["foreign_id"] = foreign_id 157 | data["time"] = time 158 | 159 | return await self.activities_partial_update(updates=[data]) 160 | 161 | async def activities_partial_update(self, updates=None): 162 | auth_token = self.create_jwt_token("activities", "*", feed_id="*") 163 | 164 | data = {"changes": updates or []} 165 | 166 | return await self.post("activity/", auth_token, data=data) 167 | 168 | async def track_engagements(self, engagements): 169 | auth_token = self.create_jwt_token("*", "*", feed_id="*") 170 | await self.post( 171 | "engagement/", 172 | auth_token, 173 | data={"content_list": engagements}, 174 | service_name="analytics", 175 | ) 176 | 177 | async def track_impressions(self, impressions): 178 | auth_token = self.create_jwt_token("*", "*", feed_id="*") 179 | await self.post( 180 | "impression/", auth_token, data=impressions, service_name="analytics" 181 | ) 182 | 183 | async def og(self, target_url): 184 | auth_token = self.create_jwt_token("*", "*", feed_id="*") 185 | params = {"url": target_url} 186 | return await self.get("og/", auth_token, params=params) 187 | 188 | async def follow_stats(self, feed_id, followers_slugs=None, following_slugs=None): 189 | auth_token = self.create_jwt_token("*", "*", feed_id="*") 190 | params = {"followers": feed_id, "following": feed_id} 191 | 192 | if followers_slugs: 193 | params["followers_slugs"] = ( 194 | ",".join(followers_slugs) 195 | if isinstance(followers_slugs, list) 196 | else followers_slugs 197 | ) 198 | 199 | if following_slugs: 200 | params["following_slugs"] = ( 201 | ",".join(following_slugs) 202 | if isinstance(following_slugs, list) 203 | else following_slugs 204 | ) 205 | 206 | return await self.get("stats/follow/", auth_token, params=params) 207 | 208 | async def _make_request( 209 | self, 210 | method, 211 | relative_url, 212 | signature, 213 | service_name="api", 214 | params=None, 215 | data=None, 216 | ): 217 | params = params or {} 218 | data = data or {} 219 | serialized = None 220 | default_params = self.get_default_params() 221 | params = self._check_params(params) 222 | default_params.update(params) 223 | headers = self.get_default_header() 224 | headers["Authorization"] = signature 225 | headers["stream-auth-type"] = "jwt" 226 | 227 | if not relative_url.endswith("/"): 228 | relative_url += "/" 229 | 230 | url = self.get_full_url(service_name, relative_url) 231 | 232 | if method.lower() in ["post", "put", "delete"]: 233 | serialized = serializer.dumps(data) 234 | 235 | async with aiohttp.ClientSession() as session: 236 | async with session.request( 237 | method, 238 | url, 239 | data=serialized, 240 | headers=headers, 241 | params=default_params, 242 | timeout=self.timeout, 243 | ) as response: 244 | # remove JWT from logs 245 | headers_to_log = headers.copy() 246 | headers_to_log.pop("Authorization", None) 247 | logger.debug( 248 | f"stream api call {response}, headers {headers_to_log} data {data}", 249 | ) 250 | return await self._parse_response(response) 251 | 252 | async def _parse_response(self, response): 253 | try: 254 | parsed_result = serializer.loads(await response.text()) 255 | except (ValueError, ClientConnectionError): 256 | parsed_result = None 257 | if ( 258 | parsed_result is None 259 | or parsed_result.get("exception") 260 | or response.status >= 500 261 | ): 262 | self.raise_exception(parsed_result, status_code=response.status) 263 | 264 | return parsed_result 265 | 266 | def _check_params(self, params): 267 | """There is no standard for boolean representation of boolean values in YARL""" 268 | if not isinstance(params, dict): 269 | raise TypeError("Invalid params type") 270 | 271 | for key, value in params.items(): 272 | if isinstance(value, bool): 273 | params[key] = str(value) 274 | 275 | return params 276 | -------------------------------------------------------------------------------- /stream/client/base.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from abc import ABC, abstractmethod 4 | 5 | import requests 6 | 7 | from stream import exceptions 8 | 9 | try: 10 | from urllib.parse import urlparse 11 | except ImportError: 12 | from urlparse import urlparse 13 | 14 | import jwt 15 | 16 | 17 | class AbstractStreamClient(ABC): 18 | @abstractmethod 19 | def feed(self, feed_slug, user_id): 20 | """ 21 | Returns a Feed object 22 | 23 | :param feed_slug: the slug of the feed 24 | :param user_id: the user id 25 | """ 26 | pass 27 | 28 | @abstractmethod 29 | def get_default_params(self): 30 | """ 31 | Returns the params with the API key present 32 | """ 33 | pass 34 | 35 | @abstractmethod 36 | def get_default_header(self): 37 | pass 38 | 39 | @abstractmethod 40 | def get_full_url(self, service_name, relative_url): 41 | pass 42 | 43 | @abstractmethod 44 | def get_user_agent(self): 45 | pass 46 | 47 | @abstractmethod 48 | def create_user_token(self, user_id, **extra_data): 49 | """ 50 | Setup the payload for the given user_id with optional 51 | extra data (key, value pairs) and encode it using jwt 52 | """ 53 | pass 54 | 55 | @abstractmethod 56 | def create_jwt_token(self, resource, action, feed_id=None, user_id=None, **params): 57 | """ 58 | Set up the payload for the given resource, action, feed or user 59 | and encode it using jwt 60 | """ 61 | pass 62 | 63 | @abstractmethod 64 | def raise_exception(self, result, status_code): 65 | """ 66 | Map the exception code to an exception class and raise it 67 | If result.exception and result.detail are available use that 68 | Otherwise just raise a generic error 69 | """ 70 | pass 71 | 72 | @abstractmethod 73 | def put(self, *args, **kwargs): 74 | """ 75 | Shortcut for make request 76 | """ 77 | pass 78 | 79 | @abstractmethod 80 | def post(self, *args, **kwargs): 81 | """ 82 | Shortcut for make request 83 | """ 84 | pass 85 | 86 | @abstractmethod 87 | def get(self, *args, **kwargs): 88 | """ 89 | Shortcut for make request 90 | """ 91 | pass 92 | 93 | @abstractmethod 94 | def delete(self, *args, **kwargs): 95 | """ 96 | Shortcut for make request 97 | """ 98 | pass 99 | 100 | @abstractmethod 101 | def add_to_many(self, activity, feeds): 102 | """ 103 | Adds an activity to many feeds 104 | 105 | :param activity: the activity data 106 | :param feeds: the list of follows (eg. ['feed:1', 'feed:2']) 107 | 108 | """ 109 | pass 110 | 111 | @abstractmethod 112 | def follow_many(self, follows, activity_copy_limit=None): 113 | """ 114 | Creates many follows 115 | :param follows: the list of follow relations 116 | 117 | eg. [{'source': source, 'target': target}] 118 | 119 | """ 120 | pass 121 | 122 | @abstractmethod 123 | def unfollow_many(self, unfollows): 124 | """ 125 | Unfollows many feeds at batch 126 | :param unfollows: the list of unfollow relations 127 | 128 | eg. [{'source': source, 'target': target, 'keep_history': keep_history}] 129 | """ 130 | pass 131 | 132 | @abstractmethod 133 | def update_activities(self, activities): 134 | """ 135 | Update or create activities 136 | """ 137 | pass 138 | 139 | @abstractmethod 140 | def update_activity(self, activity): 141 | """ 142 | Update a single activity 143 | """ 144 | pass 145 | 146 | @abstractmethod 147 | def get_activities( 148 | self, ids=None, foreign_id_times=None, enrich=False, reactions=None, **params 149 | ): 150 | """ 151 | Retrieves activities by their ID or foreign_id + time combination 152 | 153 | Pass enrich and reactions options for enrichment 154 | 155 | ids: list of activity IDs 156 | foreign_id_time: list of tuples (foreign_id, time) 157 | """ 158 | pass 159 | 160 | @abstractmethod 161 | def activity_partial_update( 162 | self, id=None, foreign_id=None, time=None, set=None, unset=None 163 | ): 164 | """ 165 | Partial update activity, via activity ID or Foreign ID + timestamp 166 | 167 | id: the activity ID 168 | foreign_id: the activity foreign ID 169 | time: the activity time 170 | set: object containing the set operations 171 | unset: list of unset operations 172 | """ 173 | pass 174 | 175 | @abstractmethod 176 | def activities_partial_update(self, updates=None): 177 | """ 178 | Partial update activity, via activity ID or Foreign ID + timestamp 179 | 180 | :param updates: list of partial updates to perform. 181 | 182 | eg. 183 | [ 184 | { 185 | "foreign_id": "post:1", 186 | "time": datetime.datetime.utcnow(), 187 | "set": { 188 | "product.name": "boots", 189 | "product.price": 7.99, 190 | "popularity": 1000, 191 | "foo": {"bar": {"baz": "qux"}}, 192 | }, 193 | "unset": ["product.color"] 194 | } 195 | ] 196 | """ 197 | pass 198 | 199 | @abstractmethod 200 | def create_redirect_url(self, target_url, user_id, events): 201 | """ 202 | Creates a redirect url for tracking the given events in the context 203 | of an email using Stream's analytics platform. Learn more at 204 | getstream.io/personalization 205 | """ 206 | pass 207 | 208 | @abstractmethod 209 | def track_engagements(self, engagements): 210 | """ 211 | Creates a list of engagements 212 | 213 | ;param engagements: Slice of engagements to create. 214 | 215 | eg. 216 | [ 217 | { 218 | "content": "1", 219 | "label": "click", 220 | "features": [ 221 | {"group": "topic", "value": "js"}, 222 | {"group": "user", "value": "tommaso"}, 223 | ], 224 | "user_data": "tommaso", 225 | }, 226 | { 227 | "content": "2", 228 | "label": "click", 229 | "features": [ 230 | {"group": "topic", "value": "go"}, 231 | {"group": "user", "value": "tommaso"}, 232 | ], 233 | "user_data": {"id": "486892", "alias": "Julian"}, 234 | }, 235 | { 236 | "content": "3", 237 | "label": "click", 238 | "features": [{"group": "topic", "value": "go"}], 239 | "user_data": {"id": "tommaso", "alias": "tommaso"}, 240 | }, 241 | ] 242 | """ 243 | pass 244 | 245 | @abstractmethod 246 | def track_impressions(self, impressions): 247 | """ 248 | Creates a list of impressions 249 | 250 | ;param impressions: Slice of impressions to create. 251 | 252 | eg. 253 | [ 254 | { 255 | "content_list": ["1", "2", "3"], 256 | "features": [ 257 | {"group": "topic", "value": "js"}, 258 | {"group": "user", "value": "tommaso"}, 259 | ], 260 | "user_data": {"id": "tommaso", "alias": "tommaso"}, 261 | }, 262 | { 263 | "content_list": ["2", "3", "5"], 264 | "features": [{"group": "topic", "value": "js"}], 265 | "user_data": {"id": "486892", "alias": "Julian"}, 266 | }, 267 | ] 268 | """ 269 | pass 270 | 271 | @abstractmethod 272 | def og(self, target_url): 273 | """ 274 | Retrieve open graph information from a URL which you can 275 | then use to add images and a description to activities. 276 | """ 277 | pass 278 | 279 | @abstractmethod 280 | def follow_stats(self, feed_id, followers_slugs=None, following_slugs=None): 281 | """ 282 | Retrieve the number of follower and following feed stats of a given feed. 283 | For each count, feed slugs can be provided to filter counts accordingly. 284 | 285 | eg. 286 | client.follow_stats( 287 | me, followers_slugs=['user'], following_slugs=['commodities'] 288 | ) 289 | this means to find counts of users following me and count 290 | of commodities I am following 291 | """ 292 | pass 293 | 294 | @abstractmethod 295 | def _make_request( 296 | self, 297 | method, 298 | relative_url, 299 | signature, 300 | service_name="api", 301 | params=None, 302 | data=None, 303 | ): 304 | pass 305 | 306 | @abstractmethod 307 | def _parse_response(self, response): 308 | pass 309 | 310 | 311 | class BaseStreamClient(AbstractStreamClient, ABC): 312 | """ 313 | Initialize the client with the given api key and secret 314 | 315 | :param api_key: the api key 316 | :param api_secret: the api secret 317 | :param app_id: the app id 318 | 319 | **Example usage**:: 320 | 321 | import stream 322 | # initialize the client 323 | client = stream.connect('key', 'secret') 324 | # get a feed object 325 | feed = client.feed('aggregated:1') 326 | # write data to the feed 327 | activity_data = {'actor': 1, 'verb': 'tweet', 'object': 1} 328 | activity_id = feed.add_activity(activity_data)['id'] 329 | activities = feed.get() 330 | 331 | feed.follow('flat:3') 332 | activities = feed.get() 333 | feed.unfollow('flat:3') 334 | feed.remove_activity(activity_id) 335 | """ 336 | 337 | def __init__( 338 | self, 339 | api_key, 340 | api_secret, 341 | app_id, 342 | version="v1.0", 343 | timeout=6.0, 344 | base_url=None, 345 | location=None, 346 | ): 347 | self.api_key = api_key 348 | self.api_secret = api_secret 349 | self.app_id = app_id 350 | self.version = version 351 | self.timeout = timeout 352 | self.location = location 353 | self.base_domain_name = "stream-io-api.com" 354 | self.api_location = location 355 | self.custom_api_port = None 356 | self.protocol = "https" 357 | 358 | if os.environ.get("LOCAL"): 359 | self.base_domain_name = "localhost" 360 | self.protocol = "http" 361 | self.custom_api_port = 8000 362 | self.timeout = 20 363 | elif base_url is not None: 364 | parsed_url = urlparse(base_url) 365 | self.base_domain_name = parsed_url.hostname 366 | self.protocol = parsed_url.scheme 367 | self.custom_api_port = parsed_url.port 368 | self.api_location = "" 369 | elif location is not None: 370 | self.location = location 371 | 372 | self.base_analytics_url = "https://analytics.stream-io-api.com/analytics/" 373 | 374 | def create_user_token(self, user_id, **extra_data): 375 | payload = {"user_id": user_id} 376 | for k, v in extra_data.items(): 377 | payload[k] = v 378 | return jwt.encode(payload, self.api_secret, algorithm="HS256") 379 | 380 | def create_jwt_token(self, resource, action, feed_id=None, user_id=None, **params): 381 | payload = {**params, "action": action, "resource": resource} 382 | if feed_id is not None: 383 | payload["feed_id"] = feed_id 384 | if user_id is not None: 385 | payload["user_id"] = user_id 386 | return jwt.encode(payload, self.api_secret, algorithm="HS256") 387 | 388 | def raise_exception(self, result, status_code): 389 | from stream.exceptions import get_exception_dict 390 | 391 | exception_class = exceptions.StreamApiException 392 | 393 | def errors_from_fields(exception_fields): 394 | result = [] 395 | if not isinstance(exception_fields, dict): 396 | return exception_fields 397 | 398 | for field, errors in exception_fields.items(): 399 | result.append(f'Field "{field}" errors: {repr(errors)}') 400 | return result 401 | 402 | if result is not None: 403 | error_message = result["detail"] 404 | exception_fields = result.get("exception_fields") 405 | if exception_fields is not None: 406 | if isinstance(exception_fields, list): 407 | errors = [ 408 | errors_from_fields(exception_dict) 409 | for exception_dict in exception_fields 410 | ] 411 | errors = [item for sublist in errors for item in sublist] 412 | else: 413 | errors = errors_from_fields(exception_fields) 414 | 415 | error_message = "\n".join(errors) 416 | error_code = result.get("code") 417 | exception_dict = get_exception_dict() 418 | exception_class = exception_dict.get( 419 | error_code, exceptions.StreamApiException 420 | ) 421 | else: 422 | error_message = f"GetStreamAPI{status_code}" 423 | exception = exception_class(error_message, status_code=status_code) 424 | raise exception 425 | 426 | def create_redirect_url(self, target_url, user_id, events): 427 | # generate the JWT token 428 | auth_token = self.create_jwt_token( 429 | "redirect_and_track", "*", "*", user_id=user_id 430 | ) 431 | # setup the params 432 | params = dict(auth_type="jwt", authorization=auth_token, url=target_url) 433 | params["api_key"] = self.api_key 434 | params["events"] = json.dumps(events) 435 | url = f"{self.base_analytics_url}redirect/" 436 | # we get the url from the prepare request, this skips issues with 437 | # python's urlencode implementation 438 | request = requests.Request("GET", url, params=params) 439 | prepared_request = request.prepare() 440 | # validate the target url is valid 441 | requests.Request("GET", target_url).prepare() 442 | return prepared_request.url 443 | 444 | def get_full_url(self, service_name, relative_url): 445 | if self.api_location: 446 | hostname = "{}{}.{}".format( 447 | self.api_location, 448 | "" if service_name == "analytics" else f"-{service_name}", 449 | self.base_domain_name, 450 | ) 451 | elif service_name: 452 | hostname = f"{service_name}.{self.base_domain_name}" 453 | else: 454 | hostname = self.base_domain_name 455 | 456 | if self.base_domain_name == "localhost": 457 | hostname = "localhost" 458 | 459 | base_url = f"{self.protocol}://{hostname}" 460 | 461 | if self.custom_api_port: 462 | base_url = f"{base_url}:{self.custom_api_port}" 463 | 464 | url = ( 465 | base_url 466 | + "/" 467 | + service_name 468 | + "/" 469 | + self.version 470 | + "/" 471 | + relative_url.replace( 472 | "//", "/" 473 | ) # non-standard url will cause redirect and so can lose its body 474 | ) 475 | 476 | return url 477 | 478 | def get_default_params(self): 479 | params = dict(api_key=self.api_key) 480 | return params 481 | 482 | def get_default_header(self): 483 | base_headers = { 484 | "Content-type": "application/json", 485 | "X-Stream-Client": self.get_user_agent(), 486 | } 487 | return base_headers 488 | 489 | def get_user_agent(self): 490 | from stream import __version__ 491 | 492 | return f"stream-python-client-{__version__}" 493 | -------------------------------------------------------------------------------- /stream/client/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | import requests 5 | from requests import Request 6 | 7 | from stream import serializer 8 | from stream.client.base import BaseStreamClient 9 | from stream.collections.collections import Collections 10 | from stream.feed import Feed 11 | from stream.personalization import Personalization 12 | from stream.reactions import Reactions 13 | from stream.serializer import _datetime_encoder 14 | from stream.users import Users 15 | from stream.utils import ( 16 | get_reaction_params, 17 | validate_feed_slug, 18 | validate_foreign_id_time, 19 | validate_user_id, 20 | ) 21 | 22 | try: 23 | from urllib.parse import urlparse 24 | except ImportError: 25 | pass 26 | # from urlparse import urlparse 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | class StreamClient(BaseStreamClient): 32 | def __init__( 33 | self, 34 | api_key, 35 | api_secret, 36 | app_id, 37 | version="v1.0", 38 | timeout=6.0, 39 | base_url=None, 40 | location=None, 41 | ): 42 | super().__init__( 43 | api_key, 44 | api_secret, 45 | app_id, 46 | version=version, 47 | timeout=timeout, 48 | base_url=base_url, 49 | location=location, 50 | ) 51 | 52 | self.session = requests.Session() 53 | 54 | token = self.create_jwt_token("personalization", "*", feed_id="*", user_id="*") 55 | self.personalization = Personalization(self, token) 56 | 57 | token = self.create_jwt_token("collections", "*", feed_id="*", user_id="*") 58 | self.collections = Collections(self, token) 59 | 60 | token = self.create_jwt_token("reactions", "*", feed_id="*") 61 | self.reactions = Reactions(self, token) 62 | 63 | token = self.create_jwt_token("users", "*", feed_id="*") 64 | self.users = Users(self, token) 65 | 66 | def feed(self, feed_slug, user_id): 67 | feed_slug = validate_feed_slug(feed_slug) 68 | user_id = validate_user_id(user_id) 69 | token = self.create_jwt_token("feed", "*", feed_id="*") 70 | return Feed(self, feed_slug, user_id, token) 71 | 72 | def put(self, *args, **kwargs): 73 | return self._make_request(self.session.put, *args, **kwargs) 74 | 75 | def post(self, *args, **kwargs): 76 | return self._make_request(self.session.post, *args, **kwargs) 77 | 78 | def get(self, *args, **kwargs): 79 | return self._make_request(self.session.get, *args, **kwargs) 80 | 81 | def delete(self, *args, **kwargs): 82 | return self._make_request(self.session.delete, *args, **kwargs) 83 | 84 | def add_to_many(self, activity, feeds): 85 | data = {"activity": activity, "feeds": feeds} 86 | token = self.create_jwt_token("feed", "*", feed_id="*") 87 | return self.post("feed/add_to_many/", token, data=data) 88 | 89 | def follow_many(self, follows, activity_copy_limit=None): 90 | params = None 91 | 92 | if activity_copy_limit is not None: 93 | params = dict(activity_copy_limit=activity_copy_limit) 94 | token = self.create_jwt_token("follower", "*", feed_id="*") 95 | return self.post("follow_many/", token, params=params, data=follows) 96 | 97 | def unfollow_many(self, unfollows): 98 | params = None 99 | 100 | token = self.create_jwt_token("follower", "*", feed_id="*") 101 | return self.post("unfollow_many/", token, params=params, data=unfollows) 102 | 103 | def update_activities(self, activities): 104 | if not isinstance(activities, (list, tuple, set)): 105 | raise TypeError("Activities parameter should be of type list") 106 | 107 | auth_token = self.create_jwt_token("activities", "*", feed_id="*") 108 | data = dict(activities=activities) 109 | return self.post("activities/", auth_token, data=data) 110 | 111 | def update_activity(self, activity): 112 | return self.update_activities([activity]) 113 | 114 | def get_activities( 115 | self, ids=None, foreign_id_times=None, enrich=False, reactions=None, **params 116 | ): 117 | auth_token = self.create_jwt_token("activities", "*", feed_id="*") 118 | 119 | if ids is None and foreign_id_times is None: 120 | raise TypeError( 121 | "One the parameters ids or foreign_id_time must be provided and not None" 122 | ) 123 | 124 | if ids is not None and foreign_id_times is not None: 125 | raise TypeError( 126 | "At most one of the parameters ids or foreign_id_time must be provided" 127 | ) 128 | 129 | endpoint = "activities/" 130 | if enrich or reactions is not None: 131 | endpoint = "enrich/" + endpoint 132 | 133 | query_params = {**params} 134 | 135 | if ids is not None: 136 | query_params["ids"] = ",".join(ids) 137 | 138 | if foreign_id_times is not None: 139 | validate_foreign_id_time(foreign_id_times) 140 | foreign_ids, timestamps = zip(*foreign_id_times) 141 | timestamps = map(_datetime_encoder, timestamps) 142 | query_params["foreign_ids"] = ",".join(foreign_ids) 143 | query_params["timestamps"] = ",".join(timestamps) 144 | 145 | query_params.update(get_reaction_params(reactions)) 146 | 147 | return self.get(endpoint, auth_token, params=query_params) 148 | 149 | def activity_partial_update( 150 | self, id=None, foreign_id=None, time=None, set=None, unset=None 151 | ): 152 | if id is None and (foreign_id is None or time is None): 153 | raise TypeError( 154 | "The id or foreign_id+time parameters must be provided and not be None" 155 | ) 156 | if id is not None and (foreign_id is not None or time is not None): 157 | raise TypeError( 158 | "Only one of the id or the foreign_id+time parameters can be provided" 159 | ) 160 | 161 | data = {"set": set or {}, "unset": unset or []} 162 | 163 | if id is not None: 164 | data["id"] = id 165 | else: 166 | data["foreign_id"] = foreign_id 167 | data["time"] = time 168 | 169 | return self.activities_partial_update(updates=[data]) 170 | 171 | def activities_partial_update(self, updates=None): 172 | auth_token = self.create_jwt_token("activities", "*", feed_id="*") 173 | 174 | data = {"changes": updates or []} 175 | 176 | return self.post("activity/", auth_token, data=data) 177 | 178 | def create_redirect_url(self, target_url, user_id, events): 179 | # generate the JWT token 180 | auth_token = self.create_jwt_token( 181 | "redirect_and_track", "*", "*", user_id=user_id 182 | ) 183 | # setup the params 184 | params = dict(auth_type="jwt", authorization=auth_token, url=target_url) 185 | params["api_key"] = self.api_key 186 | params["events"] = json.dumps(events) 187 | url = f"{self.base_analytics_url}redirect/" 188 | # we get the url from the prepare request, this skips issues with 189 | # python's urlencode implementation 190 | request = Request("GET", url, params=params) 191 | prepared_request = request.prepare() 192 | # validate the target url is valid 193 | Request("GET", target_url).prepare() 194 | return prepared_request.url 195 | 196 | def track_engagements(self, engagements): 197 | auth_token = self.create_jwt_token("*", "*", feed_id="*") 198 | self.post( 199 | "engagement/", 200 | auth_token, 201 | data={"content_list": engagements}, 202 | service_name="analytics", 203 | ) 204 | 205 | def track_impressions(self, impressions): 206 | auth_token = self.create_jwt_token("*", "*", feed_id="*") 207 | self.post("impression/", auth_token, data=impressions, service_name="analytics") 208 | 209 | def og(self, target_url): 210 | auth_token = self.create_jwt_token("*", "*", feed_id="*") 211 | params = {"url": target_url} 212 | return self.get("og/", auth_token, params=params) 213 | 214 | def follow_stats(self, feed_id, followers_slugs=None, following_slugs=None): 215 | auth_token = self.create_jwt_token("*", "*", feed_id="*") 216 | params = { 217 | "followers": feed_id, 218 | "following": feed_id, 219 | } 220 | 221 | if followers_slugs: 222 | params["followers_slugs"] = ( 223 | ",".join(followers_slugs) 224 | if isinstance(followers_slugs, list) 225 | else followers_slugs 226 | ) 227 | 228 | if following_slugs: 229 | params["following_slugs"] = ( 230 | ",".join(following_slugs) 231 | if isinstance(following_slugs, list) 232 | else following_slugs 233 | ) 234 | 235 | return self.get("stats/follow/", auth_token, params=params) 236 | 237 | def _make_request( 238 | self, 239 | method, 240 | relative_url, 241 | signature, 242 | service_name="api", 243 | params=None, 244 | data=None, 245 | ): 246 | params = params or {} 247 | data = data or {} 248 | serialized = None 249 | default_params = self.get_default_params() 250 | default_params.update(params) 251 | headers = self.get_default_header() 252 | headers["Authorization"] = signature 253 | headers["stream-auth-type"] = "jwt" 254 | 255 | if not relative_url.endswith("/"): 256 | relative_url += "/" 257 | 258 | url = self.get_full_url(service_name, relative_url) 259 | 260 | if method.__name__ in ["post", "put", "delete"]: 261 | serialized = serializer.dumps(data) 262 | response = method( 263 | url, 264 | data=serialized, 265 | headers=headers, 266 | params=default_params, 267 | timeout=self.timeout, 268 | ) 269 | # remove JWT from logs 270 | headers_to_log = headers.copy() 271 | headers_to_log.pop("Authorization", None) 272 | logger.debug( 273 | f"stream api call {response.url}, headers {headers_to_log} data {data}" 274 | ) 275 | return self._parse_response(response) 276 | 277 | def _parse_response(self, response): 278 | try: 279 | parsed_result = serializer.loads(response.text) 280 | except ValueError: 281 | parsed_result = None 282 | if ( 283 | parsed_result is None 284 | or parsed_result.get("exception") 285 | or response.status_code >= 500 286 | ): 287 | self.raise_exception(parsed_result, status_code=response.status_code) 288 | 289 | return parsed_result 290 | -------------------------------------------------------------------------------- /stream/collections/__init__.py: -------------------------------------------------------------------------------- 1 | from .collections import AsyncCollections, Collections 2 | -------------------------------------------------------------------------------- /stream/collections/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class AbstractCollection(ABC): 5 | @abstractmethod 6 | def create_reference(self, collection_name=None, id=None, entry=None): 7 | pass 8 | 9 | @abstractmethod 10 | def upsert(self, collection_name, data): 11 | """ 12 | "Insert new or update existing data. 13 | :param collection_name: Collection Name i.e 'user' 14 | :param data: list of dictionaries 15 | :return: http response, 201 if successful along with data posted. 16 | 17 | **Example**:: 18 | client.collections.upsert( 19 | 'user', [ 20 | {"id": '1', "name": "Juniper", "hobbies": ["Playing", "Sleeping", "Eating"]}, 21 | {"id": '2', "name": "Ruby", "interests": ["Sunbeams", "Surprise Attacks"]} 22 | ] 23 | ) 24 | """ 25 | pass 26 | 27 | @abstractmethod 28 | def select(self, collection_name, ids): 29 | """ 30 | Retrieve data from meta endpoint, can include data you've uploaded or 31 | personalization/analytic data 32 | created by the stream team. 33 | :param collection_name: Collection Name i.e 'user' 34 | :param ids: list of ids of feed group i.e [123,456] 35 | :return: meta data as json blob 36 | 37 | **Example**:: 38 | client.collections.select('user', 1) 39 | client.collections.select('user', [1,2,3]) 40 | """ 41 | pass 42 | 43 | @abstractmethod 44 | def delete_many(self, collection_name, ids): 45 | """ 46 | Delete data from meta. 47 | :param collection_name: Collection Name i.e 'user' 48 | :param ids: list of ids to delete i.e [123,456] 49 | :return: data that was deleted if successful or not. 50 | 51 | **Example**:: 52 | client.collections.delete('user', '1') 53 | client.collections.delete('user', ['1','2','3']) 54 | """ 55 | pass 56 | 57 | @abstractmethod 58 | def add(self, collection_name, data, id=None, user_id=None): 59 | pass 60 | 61 | @abstractmethod 62 | def get(self, collection_name, id): 63 | pass 64 | 65 | @abstractmethod 66 | def update(self, collection_name, id, data=None): 67 | pass 68 | 69 | @abstractmethod 70 | def delete(self, collection_name, id): 71 | pass 72 | 73 | 74 | class BaseCollection(AbstractCollection, ABC): 75 | URL = "collections/" 76 | SERVICE_NAME = "api" 77 | 78 | def __init__(self, client, token): 79 | """ 80 | Used to manipulate data at the 'meta' endpoint 81 | :param client: the api client 82 | :param token: the token 83 | """ 84 | 85 | self.client = client 86 | self.token = token 87 | 88 | def create_reference(self, collection_name=None, id=None, entry=None): 89 | if isinstance(entry, dict): 90 | _collection = entry["collection"] 91 | _id = entry["id"] 92 | elif collection_name is not None and id is not None: 93 | _collection = collection_name 94 | _id = id 95 | else: 96 | raise ValueError( 97 | "must call with collection_name and id or with entry arguments" 98 | ) 99 | return f"SO:{_collection}:{_id}" 100 | -------------------------------------------------------------------------------- /stream/collections/collections.py: -------------------------------------------------------------------------------- 1 | from stream.collections.base import BaseCollection 2 | 3 | 4 | class Collections(BaseCollection): 5 | def upsert(self, collection_name, data): 6 | if not isinstance(data, list): 7 | data = [data] 8 | 9 | data_json = {collection_name: data} 10 | 11 | return self.client.post( 12 | self.URL, 13 | service_name=self.SERVICE_NAME, 14 | signature=self.token, 15 | data={"data": data_json}, 16 | ) 17 | 18 | def select(self, collection_name, ids): 19 | if not isinstance(ids, list): 20 | ids = [ids] 21 | 22 | foreign_ids = ",".join(f"{collection_name}:{k}" for i, k in enumerate(ids)) 23 | 24 | return self.client.get( 25 | self.URL, 26 | service_name=self.SERVICE_NAME, 27 | params={"foreign_ids": foreign_ids}, 28 | signature=self.token, 29 | ) 30 | 31 | def delete_many(self, collection_name, ids): 32 | if not isinstance(ids, list): 33 | ids = [ids] 34 | ids = [str(i) for i in ids] 35 | 36 | params = {"collection_name": collection_name, "ids": ids} 37 | 38 | return self.client.delete( 39 | self.URL, 40 | service_name=self.SERVICE_NAME, 41 | params=params, 42 | signature=self.token, 43 | ) 44 | 45 | def add(self, collection_name, data, id=None, user_id=None): 46 | payload = dict(id=id, data=data, user_id=user_id) 47 | return self.client.post( 48 | f"{self.URL}/{collection_name}", 49 | service_name=self.SERVICE_NAME, 50 | signature=self.token, 51 | data=payload, 52 | ) 53 | 54 | def get(self, collection_name, id): 55 | return self.client.get( 56 | f"{self.URL}/{collection_name}/{id}", 57 | service_name=self.SERVICE_NAME, 58 | signature=self.token, 59 | ) 60 | 61 | def update(self, collection_name, id, data=None): 62 | payload = dict(data=data) 63 | return self.client.put( 64 | f"{self.URL}/{collection_name}/{id}", 65 | service_name=self.SERVICE_NAME, 66 | signature=self.token, 67 | data=payload, 68 | ) 69 | 70 | def delete(self, collection_name, id): 71 | return self.client.delete( 72 | f"{self.URL}/{collection_name}/{id}", 73 | service_name=self.SERVICE_NAME, 74 | signature=self.token, 75 | ) 76 | 77 | 78 | class AsyncCollections(BaseCollection): 79 | async def upsert(self, collection_name, data): 80 | if not isinstance(data, list): 81 | data = [data] 82 | 83 | data_json = {collection_name: data} 84 | 85 | return await self.client.post( 86 | self.URL, 87 | service_name=self.SERVICE_NAME, 88 | signature=self.token, 89 | data={"data": data_json}, 90 | ) 91 | 92 | async def select(self, collection_name, ids): 93 | if not isinstance(ids, list): 94 | ids = [ids] 95 | 96 | foreign_ids = ",".join(f"{collection_name}:{k}" for i, k in enumerate(ids)) 97 | 98 | return await self.client.get( 99 | self.URL, 100 | service_name=self.SERVICE_NAME, 101 | params={"foreign_ids": foreign_ids}, 102 | signature=self.token, 103 | ) 104 | 105 | async def delete_many(self, collection_name, ids): 106 | if not isinstance(ids, list): 107 | ids = [ids] 108 | ids = [str(i) for i in ids] 109 | 110 | params = {"collection_name": collection_name, "ids": ids} 111 | return await self.client.delete( 112 | self.URL, 113 | service_name=self.SERVICE_NAME, 114 | params=params, 115 | signature=self.token, 116 | ) 117 | 118 | async def get(self, collection_name, id): 119 | return await self.client.get( 120 | f"{self.URL}/{collection_name}/{id}", 121 | service_name=self.SERVICE_NAME, 122 | signature=self.token, 123 | ) 124 | 125 | async def add(self, collection_name, data, id=None, user_id=None): 126 | payload = dict(id=id, data=data, user_id=user_id) 127 | return await self.client.post( 128 | f"{self.URL}/{collection_name}", 129 | service_name=self.SERVICE_NAME, 130 | signature=self.token, 131 | data=payload, 132 | ) 133 | 134 | async def update(self, collection_name, id, data=None): 135 | payload = dict(data=data) 136 | return await self.client.put( 137 | f"{self.URL}/{collection_name}/{id}", 138 | service_name=self.SERVICE_NAME, 139 | signature=self.token, 140 | data=payload, 141 | ) 142 | 143 | async def delete(self, collection_name, id): 144 | return await self.client.delete( 145 | f"{self.URL}/{collection_name}/{id}", 146 | service_name=self.SERVICE_NAME, 147 | signature=self.token, 148 | ) 149 | -------------------------------------------------------------------------------- /stream/exceptions.py: -------------------------------------------------------------------------------- 1 | class StreamApiException(Exception): 2 | def __init__(self, error_message, status_code=None): 3 | Exception.__init__(self, error_message) 4 | self.detail = error_message 5 | if status_code is not None: 6 | self.status_code = status_code 7 | 8 | code = 1 9 | 10 | def __repr__(self): 11 | return f"{self.__class__.__name__} ({self.detail})" 12 | 13 | def __unicode__(self): 14 | return f"{self.__class__.__name__} ({self.detail})" 15 | 16 | 17 | class ApiKeyException(StreamApiException): 18 | 19 | """ 20 | Raised when there is an issue with your Access Key 21 | """ 22 | 23 | status_code = 401 24 | code = 2 25 | 26 | 27 | class SignatureException(StreamApiException): 28 | 29 | """ 30 | Raised when there is an issue with the signature you provided 31 | """ 32 | 33 | status_code = 401 34 | code = 3 35 | 36 | 37 | class InputException(StreamApiException): 38 | 39 | """ 40 | Raised when you send the wrong data to the API 41 | """ 42 | 43 | status_code = 400 44 | code = 4 45 | 46 | 47 | class CustomFieldException(StreamApiException): 48 | 49 | """ 50 | Raised when there are missing or misconfigured custom fields 51 | """ 52 | 53 | status_code = 400 54 | code = 5 55 | 56 | 57 | class FeedConfigException(StreamApiException): 58 | 59 | """ 60 | Raised when there are missing or misconfigured custom fields 61 | """ 62 | 63 | status_code = 400 64 | code = 6 65 | 66 | 67 | class SiteSuspendedException(StreamApiException): 68 | 69 | """ 70 | Raised when the site requesting the data is suspended 71 | """ 72 | 73 | status_code = 401 74 | code = 7 75 | 76 | 77 | class InvalidPaginationException(StreamApiException): 78 | 79 | """ 80 | Raised when there is an issue with your Access Key 81 | """ 82 | 83 | status_code = 401 84 | code = 8 85 | 86 | 87 | class MissingRankingException(FeedConfigException): 88 | """ 89 | Raised when you didn't configure the ranking for the given feed 90 | """ 91 | 92 | status_code = 400 93 | code = 12 94 | 95 | 96 | class MissingUserException(MissingRankingException): 97 | status_code = 400 98 | code = 10 99 | 100 | 101 | class RankingException(FeedConfigException): 102 | """ 103 | Raised when there is a runtime issue with ranking the feed 104 | """ 105 | 106 | status_code = 400 107 | code = 11 108 | 109 | 110 | class RateLimitReached(StreamApiException): 111 | 112 | """ 113 | Raised when too many requests are performed 114 | """ 115 | 116 | status_code = 429 117 | code = 9 118 | 119 | 120 | class OldStorageBackend(StreamApiException): 121 | """ 122 | Raised if you try to perform an action which only works with the new storage 123 | """ 124 | 125 | status_code = 400 126 | code = 13 127 | 128 | 129 | class BestPracticeException(StreamApiException): 130 | """ 131 | Raised if best practices are enforced and you do something that 132 | would break a high volume integration 133 | """ 134 | 135 | status_code = 400 136 | code = 15 137 | 138 | 139 | class DoesNotExistException(StreamApiException): 140 | """ 141 | Raised when the requested resource could not be found. 142 | """ 143 | 144 | status_code = 404 145 | code = 16 146 | 147 | 148 | class NotAllowedException(StreamApiException): 149 | """ 150 | Raised when the requested action is not allowed for some reason. 151 | """ 152 | 153 | status_code = 403 154 | code = 17 155 | 156 | 157 | def get_exceptions(): 158 | from stream import exceptions 159 | 160 | classes = [] 161 | for k in dir(exceptions): 162 | a = getattr(exceptions, k) 163 | try: 164 | if a and issubclass(a, StreamApiException): 165 | classes.append(a) 166 | except TypeError: 167 | pass 168 | return classes 169 | 170 | 171 | def get_exception_dict(): 172 | exception_dict = {} 173 | for c in get_exceptions(): 174 | exception_dict[c.code] = c 175 | return exception_dict 176 | -------------------------------------------------------------------------------- /stream/feed/__init__.py: -------------------------------------------------------------------------------- 1 | from .feeds import AsyncFeed, Feed 2 | -------------------------------------------------------------------------------- /stream/feed/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from stream.utils import validate_feed_id 4 | 5 | 6 | class AbstractFeed(ABC): 7 | @abstractmethod 8 | def create_scope_token(self, resource, action): 9 | """ 10 | creates the JWT token to perform an action on a owned resource 11 | """ 12 | pass 13 | 14 | @abstractmethod 15 | def get_readonly_token(self): 16 | """ 17 | creates the JWT token to perform readonly operations 18 | """ 19 | pass 20 | 21 | @abstractmethod 22 | def add_activity(self, activity_data): 23 | """ 24 | Adds an activity to the feed, this will also trigger an update 25 | to all the feeds which follow this feed 26 | 27 | :param activity_data: a dict with the activity data 28 | 29 | **Example**:: 30 | 31 | activity_data = {'actor': 1, 'verb': 'tweet', 'object': 1} 32 | activity_id = feed.add_activity(activity_data) 33 | """ 34 | pass 35 | 36 | @abstractmethod 37 | def add_activities(self, activity_list): 38 | """ 39 | Adds a list of activities to the feed 40 | 41 | :param activity_list: a list with the activity data dicts 42 | 43 | **Example**:: 44 | 45 | activity_data = [ 46 | {'actor': 1, 'verb': 'tweet', 'object': 1}, 47 | {'actor': 2, 'verb': 'watch', 'object': 2}, 48 | ] 49 | result = feed.add_activities(activity_data) 50 | """ 51 | pass 52 | 53 | @abstractmethod 54 | def remove_activity(self, activity_id=None, foreign_id=None): 55 | """ 56 | Removes an activity from the feed 57 | 58 | :param activity_id: the activity id to remove from this feed 59 | (note this will also remove the activity from feeds which follow this feed) 60 | :param foreign_id: the foreign id you provided when adding the activity 61 | """ 62 | pass 63 | 64 | @abstractmethod 65 | def get(self, enrich=False, reactions=None, **params): 66 | """ 67 | Get the activities in this feed 68 | 69 | **Example**:: 70 | 71 | # fast pagination using id filtering 72 | feed.get(limit=10, id_lte=100292310) 73 | 74 | # slow pagination using offset 75 | feed.get(limit=10, offset=10) 76 | """ 77 | pass 78 | 79 | @abstractmethod 80 | def follow( 81 | self, target_feed_slug, target_user_id, activity_copy_limit=None, **extra_data 82 | ): 83 | """ 84 | Follows the given feed 85 | 86 | :param activity_copy_limit: how many activities should be copied from target 87 | feed 88 | :param target_feed_slug: the slug of the target feed 89 | :param target_user_id: the user id 90 | """ 91 | pass 92 | 93 | @abstractmethod 94 | def unfollow(self, target_feed_slug, target_user_id, keep_history=False): 95 | """ 96 | Unfollow the given feed 97 | """ 98 | pass 99 | 100 | @abstractmethod 101 | def followers(self, offset=0, limit=25, feeds=None): 102 | """ 103 | Lists the followers for the given feed 104 | """ 105 | pass 106 | 107 | @abstractmethod 108 | def following(self, offset=0, limit=25, feeds=None): 109 | """ 110 | List the feeds which this feed is following 111 | """ 112 | pass 113 | 114 | @abstractmethod 115 | def add_to_signature(self, recipients): 116 | """ 117 | Takes a list of recipients such as ['user:1', 'user:2'] 118 | and turns it into a list with the tokens included 119 | ['user:1 token', 'user:2 token'] 120 | """ 121 | pass 122 | 123 | @abstractmethod 124 | def update_activity_to_targets( 125 | self, 126 | foreign_id, 127 | time, 128 | new_targets=None, 129 | added_targets=None, 130 | removed_targets=None, 131 | ): 132 | pass 133 | 134 | 135 | class BaseFeed(AbstractFeed, ABC): 136 | def __init__(self, client, feed_slug, user_id, token): 137 | """ 138 | Initializes the Feed class 139 | 140 | :param client: the api client 141 | :param feed_slug: the slug of the feed, ie user, flat, notification 142 | :param user_id: the id of the user 143 | :param token: the token 144 | """ 145 | self.client = client 146 | self.slug = feed_slug 147 | self.user_id = f"{user_id}" 148 | self.id = f"{feed_slug}:{user_id}" 149 | self.token = token.decode("utf-8") if isinstance(token, bytes) else token 150 | _id = self.id.replace(":", "/") 151 | self.feed_url = f"feed/{_id}/" 152 | self.enriched_feed_url = f"enrich/feed/{_id}/" 153 | self.feed_targets_url = f"feed_targets/{_id}/" 154 | self.feed_together = self.id.replace(":", "") 155 | self.signature = f"{self.feed_together} {self.token}" 156 | 157 | def create_scope_token(self, resource, action): 158 | return self.client.create_jwt_token( 159 | resource, action, feed_id=self.feed_together 160 | ) 161 | 162 | def get_readonly_token(self): 163 | return self.create_scope_token("*", "read") 164 | 165 | def add_to_signature(self, recipients): 166 | data = [] 167 | for recipient in recipients: 168 | validate_feed_id(recipient) 169 | feed_slug, user_id = recipient.split(":") 170 | feed = self.client.feed(feed_slug, user_id) 171 | data.append(f"{recipient} {feed.token}") 172 | return data 173 | -------------------------------------------------------------------------------- /stream/feed/feeds.py: -------------------------------------------------------------------------------- 1 | from stream.feed.base import BaseFeed 2 | from stream.utils import get_reaction_params, validate_feed_slug, validate_user_id 3 | 4 | 5 | class Feed(BaseFeed): 6 | def add_activity(self, activity_data): 7 | if activity_data.get("to") and not isinstance( 8 | activity_data.get("to"), (list, tuple, set) 9 | ): 10 | raise TypeError( 11 | "please provide the activity's to field as a list not a string" 12 | ) 13 | 14 | if activity_data.get("to"): 15 | activity_data = activity_data.copy() 16 | activity_data["to"] = self.add_to_signature(activity_data["to"]) 17 | 18 | token = self.create_scope_token("feed", "write") 19 | return self.client.post(self.feed_url, data=activity_data, signature=token) 20 | 21 | def add_activities(self, activity_list): 22 | activities = [] 23 | for activity_data in activity_list: 24 | activity_data = activity_data.copy() 25 | activities.append(activity_data) 26 | if activity_data.get("to"): 27 | activity_data["to"] = self.add_to_signature(activity_data["to"]) 28 | token = self.create_scope_token("feed", "write") 29 | data = dict(activities=activities) 30 | if activities: 31 | return self.client.post(self.feed_url, data=data, signature=token) 32 | return None 33 | 34 | def remove_activity(self, activity_id=None, foreign_id=None): 35 | identifier = activity_id or foreign_id 36 | if not identifier: 37 | raise ValueError("please either provide activity_id or foreign_id") 38 | url = f"{self.feed_url}{identifier}/" 39 | params = dict() 40 | token = self.create_scope_token("feed", "delete") 41 | if foreign_id is not None: 42 | params["foreign_id"] = "1" 43 | return self.client.delete(url, signature=token, params=params) 44 | 45 | def get(self, enrich=False, reactions=None, **params): 46 | for field in ["mark_read", "mark_seen"]: 47 | value = params.get(field) 48 | if isinstance(value, (list, tuple)): 49 | params[field] = ",".join(value) 50 | token = self.create_scope_token("feed", "read") 51 | 52 | if enrich or reactions is not None: 53 | feed_url = self.enriched_feed_url 54 | else: 55 | feed_url = self.feed_url 56 | 57 | params.update(get_reaction_params(reactions)) 58 | return self.client.get(feed_url, params=params, signature=token) 59 | 60 | def follow( 61 | self, target_feed_slug, target_user_id, activity_copy_limit=None, **extra_data 62 | ): 63 | target_feed_slug = validate_feed_slug(target_feed_slug) 64 | target_user_id = validate_user_id(target_user_id) 65 | target_feed_id = f"{target_feed_slug}:{target_user_id}" 66 | url = f"{self.feed_url}follows/" 67 | target_token = self.client.feed(target_feed_slug, target_user_id).token 68 | data = {"target": target_feed_id, "target_token": target_token} 69 | if activity_copy_limit is not None: 70 | data["activity_copy_limit"] = activity_copy_limit 71 | token = self.create_scope_token("follower", "write") 72 | data.update(extra_data) 73 | return self.client.post(url, data=data, signature=token) 74 | 75 | def unfollow(self, target_feed_slug, target_user_id, keep_history=False): 76 | target_feed_slug = validate_feed_slug(target_feed_slug) 77 | target_user_id = validate_user_id(target_user_id) 78 | target_feed_id = f"{target_feed_slug}:{target_user_id}" 79 | token = self.create_scope_token("follower", "delete") 80 | url = f"{self.feed_url}follows/{target_feed_id}/" 81 | params = {} 82 | if keep_history: 83 | params["keep_history"] = True 84 | return self.client.delete(url, signature=token, params=params) 85 | 86 | def followers(self, offset=0, limit=25, feeds=None): 87 | feeds = ",".join(feeds) if feeds is not None else "" 88 | params = {"limit": limit, "offset": offset, "filter": feeds} 89 | url = f"{self.feed_url}followers/" 90 | token = self.create_scope_token("follower", "read") 91 | return self.client.get(url, params=params, signature=token) 92 | 93 | def following(self, offset=0, limit=25, feeds=None): 94 | feeds = ",".join(feeds) if feeds is not None else "" 95 | params = {"offset": offset, "limit": limit, "filter": feeds} 96 | url = f"{self.feed_url}follows/" 97 | token = self.create_scope_token("follower", "read") 98 | return self.client.get(url, params=params, signature=token) 99 | 100 | def update_activity_to_targets( 101 | self, 102 | foreign_id, 103 | time, 104 | new_targets=None, 105 | added_targets=None, 106 | removed_targets=None, 107 | ): 108 | data = {"foreign_id": foreign_id, "time": time} 109 | 110 | if new_targets is not None: 111 | data["new_targets"] = new_targets 112 | if added_targets is not None: 113 | data["added_targets"] = added_targets 114 | if removed_targets is not None: 115 | data["removed_targets"] = removed_targets 116 | 117 | url = f"{self.feed_targets_url}activity_to_targets/" 118 | token = self.create_scope_token("feed_targets", "write") 119 | return self.client.post(url, data=data, signature=token) 120 | 121 | 122 | class AsyncFeed(BaseFeed): 123 | async def add_activity(self, activity_data): 124 | if activity_data.get("to") and not isinstance( 125 | activity_data.get("to"), (list, tuple, set) 126 | ): 127 | raise TypeError( 128 | "please provide the activity's to field as a list not a string" 129 | ) 130 | 131 | if activity_data.get("to"): 132 | activity_data = activity_data.copy() 133 | activity_data["to"] = self.add_to_signature(activity_data["to"]) 134 | 135 | token = self.create_scope_token("feed", "write") 136 | return await self.client.post( 137 | self.feed_url, data=activity_data, signature=token 138 | ) 139 | 140 | async def add_activities(self, activity_list): 141 | activities = [] 142 | for activity_data in activity_list: 143 | activity_data = activity_data.copy() 144 | activities.append(activity_data) 145 | if activity_data.get("to"): 146 | activity_data["to"] = self.add_to_signature(activity_data["to"]) 147 | token = self.create_scope_token("feed", "write") 148 | data = dict(activities=activities) 149 | if not activities: 150 | return 151 | 152 | return await self.client.post(self.feed_url, data=data, signature=token) 153 | 154 | async def remove_activity(self, activity_id=None, foreign_id=None): 155 | identifier = activity_id or foreign_id 156 | if not identifier: 157 | raise ValueError("please either provide activity_id or foreign_id") 158 | url = f"{self.feed_url}{identifier}/" 159 | params = dict() 160 | token = self.create_scope_token("feed", "delete") 161 | if foreign_id is not None: 162 | params["foreign_id"] = "1" 163 | return await self.client.delete(url, signature=token, params=params) 164 | 165 | async def get(self, enrich=False, reactions=None, **params): 166 | for field in ["mark_read", "mark_seen"]: 167 | value = params.get(field) 168 | if isinstance(value, (list, tuple)): 169 | params[field] = ",".join(value) 170 | 171 | token = self.create_scope_token("feed", "read") 172 | if enrich or reactions is not None: 173 | feed_url = self.enriched_feed_url 174 | else: 175 | feed_url = self.feed_url 176 | 177 | params.update(get_reaction_params(reactions)) 178 | return await self.client.get(feed_url, params=params, signature=token) 179 | 180 | async def follow( 181 | self, target_feed_slug, target_user_id, activity_copy_limit=None, **extra_data 182 | ): 183 | target_feed_slug = validate_feed_slug(target_feed_slug) 184 | target_user_id = validate_user_id(target_user_id) 185 | target_feed_id = f"{target_feed_slug}:{target_user_id}" 186 | url = f"{self.feed_url}follows/" 187 | target_token = self.client.feed(target_feed_slug, target_user_id).token 188 | data = {"target": target_feed_id, "target_token": target_token} 189 | if activity_copy_limit is not None: 190 | data["activity_copy_limit"] = activity_copy_limit 191 | token = self.create_scope_token("follower", "write") 192 | data.update(extra_data) 193 | return await self.client.post(url, data=data, signature=token) 194 | 195 | async def unfollow(self, target_feed_slug, target_user_id, keep_history=False): 196 | target_feed_slug = validate_feed_slug(target_feed_slug) 197 | target_user_id = validate_user_id(target_user_id) 198 | target_feed_id = f"{target_feed_slug}:{target_user_id}" 199 | token = self.create_scope_token("follower", "delete") 200 | url = f"{self.feed_url}follows/{target_feed_id}/" 201 | params = {} 202 | if keep_history: 203 | params["keep_history"] = True 204 | return await self.client.delete(url, signature=token, params=params) 205 | 206 | async def followers(self, offset=0, limit=25, feeds=None): 207 | feeds = ",".join(feeds) if feeds is not None else "" 208 | params = {"limit": limit, "offset": offset, "filter": feeds} 209 | url = f"{self.feed_url}followers/" 210 | token = self.create_scope_token("follower", "read") 211 | return await self.client.get(url, params=params, signature=token) 212 | 213 | async def following(self, offset=0, limit=25, feeds=None): 214 | feeds = ",".join(feeds) if feeds is not None else "" 215 | params = {"offset": offset, "limit": limit, "filter": feeds} 216 | url = f"{self.feed_url}follows/" 217 | token = self.create_scope_token("follower", "read") 218 | return await self.client.get(url, params=params, signature=token) 219 | 220 | async def update_activity_to_targets( 221 | self, 222 | foreign_id, 223 | time, 224 | new_targets=None, 225 | added_targets=None, 226 | removed_targets=None, 227 | ): 228 | data = {"foreign_id": foreign_id, "time": time} 229 | if new_targets is not None: 230 | data["new_targets"] = new_targets 231 | if added_targets is not None: 232 | data["added_targets"] = added_targets 233 | if removed_targets is not None: 234 | data["removed_targets"] = removed_targets 235 | 236 | url = f"{self.feed_targets_url}activity_to_targets/" 237 | token = self.create_scope_token("feed_targets", "write") 238 | return await self.client.post(url, data=data, signature=token) 239 | -------------------------------------------------------------------------------- /stream/personalization/__init__.py: -------------------------------------------------------------------------------- 1 | from .personalizations import AsyncPersonalization, Personalization 2 | -------------------------------------------------------------------------------- /stream/personalization/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class AbstractPersonalization(ABC): 5 | @abstractmethod 6 | def get(self, resource, **params): 7 | pass 8 | 9 | @abstractmethod 10 | def post(self, resource, **params): 11 | pass 12 | 13 | @abstractmethod 14 | def delete(self, resource, **params): 15 | pass 16 | 17 | 18 | class BasePersonalization(AbstractPersonalization, ABC): 19 | SERVICE_NAME = "personalization" 20 | 21 | def __init__(self, client, token): 22 | """ 23 | Methods to interact with personalized feeds. 24 | :param client: the api client 25 | :param token: the token 26 | """ 27 | 28 | self.client = client 29 | self.token = token 30 | -------------------------------------------------------------------------------- /stream/personalization/personalizations.py: -------------------------------------------------------------------------------- 1 | from stream.personalization.base import BasePersonalization 2 | 3 | 4 | class Personalization(BasePersonalization): 5 | def get(self, resource, **params): 6 | """ 7 | Get personalized activities for this feed 8 | :param resource: personalized resource endpoint i.e "follow_recommendations" 9 | :param params: params to pass to url i.e user_id = "user:123" 10 | :return: personalized feed 11 | 12 | **Example**:: 13 | personalization.get('follow_recommendations', user_id=123, limit=10, offset=10) 14 | """ 15 | 16 | return self.client.get( 17 | resource, 18 | service_name=self.SERVICE_NAME, 19 | params=params, 20 | signature=self.token, 21 | ) 22 | 23 | def post(self, resource, **params): 24 | """ 25 | Generic function to post data to personalization endpoint 26 | :param resource: personalized resource endpoint i.e "follow_recommendations" 27 | :param params: params to pass to url (data is a reserved keyword to post to body) 28 | 29 | 30 | **Example**:: 31 | #Accept or reject recommendations. 32 | personalization.post('follow_recommendations', user_id=123, accepted=[123,345], 33 | rejected=[456]) 34 | """ 35 | 36 | data = params["data"] or None 37 | 38 | return self.client.post( 39 | resource, 40 | service_name=self.SERVICE_NAME, 41 | params=params, 42 | signature=self.token, 43 | data=data, 44 | ) 45 | 46 | def delete(self, resource, **params): 47 | """ 48 | shortcut to delete metadata or activities 49 | :param resource: personalized url endpoint typical "meta" 50 | :param params: params to pass to url i.e user_id = "user:123" 51 | :return: data that was deleted if successful or not. 52 | """ 53 | 54 | return self.client.delete( 55 | resource, 56 | service_name=self.SERVICE_NAME, 57 | params=params, 58 | signature=self.token, 59 | ) 60 | 61 | 62 | class AsyncPersonalization(BasePersonalization): 63 | async def get(self, resource, **params): 64 | """ 65 | Get personalized activities for this feed 66 | :param resource: personalized resource endpoint i.e "follow_recommendations" 67 | :param params: params to pass to url i.e user_id = "user:123" 68 | :return: personalized feed 69 | 70 | **Example**:: 71 | personalization.get('follow_recommendations', user_id=123, limit=10, offset=10) 72 | """ 73 | 74 | return await self.client.get( 75 | resource, 76 | service_name=self.SERVICE_NAME, 77 | params=params, 78 | signature=self.token, 79 | ) 80 | 81 | async def post(self, resource, **params): 82 | """ 83 | Generic function to post data to personalization endpoint 84 | :param resource: personalized resource endpoint i.e "follow_recommendations" 85 | :param params: params to pass to url (data is a reserved keyword to post to body) 86 | 87 | 88 | **Example**:: 89 | #Accept or reject recommendations. 90 | personalization.post('follow_recommendations', user_id=123, accepted=[123,345], 91 | rejected=[456]) 92 | """ 93 | 94 | data = params["data"] or None 95 | 96 | return await self.client.post( 97 | resource, 98 | service_name=self.SERVICE_NAME, 99 | params=params, 100 | signature=self.token, 101 | data=data, 102 | ) 103 | 104 | async def delete(self, resource, **params): 105 | """ 106 | shortcut to delete metadata or activities 107 | :param resource: personalized url endpoint typical "meta" 108 | :param params: params to pass to url i.e user_id = "user:123" 109 | :return: data that was deleted if successful or not. 110 | """ 111 | 112 | return await self.client.delete( 113 | resource, 114 | service_name=self.SERVICE_NAME, 115 | params=params, 116 | signature=self.token, 117 | ) 118 | -------------------------------------------------------------------------------- /stream/reactions/__init__.py: -------------------------------------------------------------------------------- 1 | from .reaction import AsyncReactions, Reactions 2 | -------------------------------------------------------------------------------- /stream/reactions/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class AbstractReactions(ABC): 5 | @abstractmethod 6 | def add( 7 | self, 8 | kind, 9 | activity_id, 10 | user_id, 11 | data=None, 12 | target_feeds=None, 13 | target_feeds_extra_data=None, 14 | ): 15 | pass 16 | 17 | @abstractmethod 18 | def get(self, reaction_id): 19 | pass 20 | 21 | @abstractmethod 22 | def update(self, reaction_id, data=None, target_feeds=None): 23 | pass 24 | 25 | @abstractmethod 26 | def delete(self, reaction_id, soft=False): 27 | pass 28 | 29 | @abstractmethod 30 | def restore(self, reaction_id): 31 | pass 32 | 33 | @abstractmethod 34 | def add_child( 35 | self, 36 | kind, 37 | parent_id, 38 | user_id, 39 | data=None, 40 | target_feeds=None, 41 | target_feeds_extra_data=None, 42 | ): 43 | pass 44 | 45 | @abstractmethod 46 | def filter(self, **params): 47 | pass 48 | 49 | 50 | class BaseReactions(AbstractReactions, ABC): 51 | API_ENDPOINT = "reaction/" 52 | SERVICE_NAME = "api" 53 | 54 | def __init__(self, client, token): 55 | self.client = client 56 | self.token = token 57 | 58 | def _prepare_endpoint_for_filter(self, **params): 59 | lookup_field = "" 60 | lookup_value = "" 61 | 62 | kind = params.pop("kind", None) 63 | 64 | if params.get("reaction_id"): 65 | lookup_field = "reaction_id" 66 | lookup_value = params.pop("reaction_id") 67 | elif params.get("activity_id"): 68 | lookup_field = "activity_id" 69 | lookup_value = params.pop("activity_id") 70 | elif params.get("user_id"): 71 | lookup_field = "user_id" 72 | lookup_value = params.pop("user_id") 73 | 74 | endpoint = f"{self.API_ENDPOINT}{lookup_field}/{lookup_value}/" 75 | if kind is not None: 76 | endpoint += f"{kind}/" 77 | 78 | return endpoint 79 | -------------------------------------------------------------------------------- /stream/reactions/reaction.py: -------------------------------------------------------------------------------- 1 | from stream.reactions.base import BaseReactions 2 | 3 | 4 | class Reactions(BaseReactions): 5 | def add( 6 | self, 7 | kind, 8 | activity_id, 9 | user_id, 10 | data=None, 11 | target_feeds=None, 12 | target_feeds_extra_data=None, 13 | ): 14 | payload = dict( 15 | kind=kind, 16 | activity_id=activity_id, 17 | data=data, 18 | target_feeds=target_feeds, 19 | target_feeds_extra_data=target_feeds_extra_data, 20 | user_id=user_id, 21 | ) 22 | return self.client.post( 23 | self.API_ENDPOINT, 24 | service_name=self.SERVICE_NAME, 25 | signature=self.token, 26 | data=payload, 27 | ) 28 | 29 | def get(self, reaction_id): 30 | url = f"{self.API_ENDPOINT}{reaction_id}" 31 | return self.client.get( 32 | url, service_name=self.SERVICE_NAME, signature=self.token 33 | ) 34 | 35 | def update(self, reaction_id, data=None, target_feeds=None): 36 | payload = dict(data=data, target_feeds=target_feeds) 37 | url = f"{self.API_ENDPOINT}{reaction_id}" 38 | return self.client.put( 39 | url, 40 | service_name=self.SERVICE_NAME, 41 | signature=self.token, 42 | data=payload, 43 | ) 44 | 45 | def delete(self, reaction_id, soft=False): 46 | url = f"{self.API_ENDPOINT}{reaction_id}" 47 | return self.client.delete( 48 | url, 49 | service_name=self.SERVICE_NAME, 50 | signature=self.token, 51 | params={"soft": soft}, 52 | ) 53 | 54 | def restore(self, reaction_id): 55 | url = f"{self.API_ENDPOINT}{reaction_id}/restore" 56 | return self.client.put( 57 | url, service_name=self.SERVICE_NAME, signature=self.token 58 | ) 59 | 60 | def add_child( 61 | self, 62 | kind, 63 | parent_id, 64 | user_id, 65 | data=None, 66 | target_feeds=None, 67 | target_feeds_extra_data=None, 68 | ): 69 | payload = dict( 70 | kind=kind, 71 | parent=parent_id, 72 | data=data, 73 | target_feeds=target_feeds, 74 | target_feeds_extra_data=target_feeds_extra_data, 75 | user_id=user_id, 76 | ) 77 | return self.client.post( 78 | self.API_ENDPOINT, 79 | service_name=self.SERVICE_NAME, 80 | signature=self.token, 81 | data=payload, 82 | ) 83 | 84 | def filter(self, **params): 85 | endpoint = self._prepare_endpoint_for_filter(**params) 86 | return self.client.get( 87 | endpoint, 88 | service_name=self.SERVICE_NAME, 89 | signature=self.token, 90 | params=params, 91 | ) 92 | 93 | 94 | class AsyncReactions(BaseReactions): 95 | async def add( 96 | self, 97 | kind, 98 | activity_id, 99 | user_id, 100 | data=None, 101 | target_feeds=None, 102 | target_feeds_extra_data=None, 103 | ): 104 | payload = dict( 105 | kind=kind, 106 | activity_id=activity_id, 107 | data=data, 108 | target_feeds=target_feeds, 109 | target_feeds_extra_data=target_feeds_extra_data, 110 | user_id=user_id, 111 | ) 112 | return await self.client.post( 113 | self.API_ENDPOINT, 114 | service_name=self.SERVICE_NAME, 115 | signature=self.token, 116 | data=payload, 117 | ) 118 | 119 | async def get(self, reaction_id): 120 | url = f"{self.API_ENDPOINT}{reaction_id}" 121 | return await self.client.get( 122 | url, service_name=self.SERVICE_NAME, signature=self.token 123 | ) 124 | 125 | async def update(self, reaction_id, data=None, target_feeds=None): 126 | payload = dict(data=data, target_feeds=target_feeds) 127 | url = f"{self.API_ENDPOINT}{reaction_id}" 128 | return await self.client.put( 129 | url, 130 | service_name=self.SERVICE_NAME, 131 | signature=self.token, 132 | data=payload, 133 | ) 134 | 135 | async def delete(self, reaction_id, soft=False): 136 | url = f"{self.API_ENDPOINT}{reaction_id}" 137 | return await self.client.delete( 138 | url, 139 | service_name=self.SERVICE_NAME, 140 | signature=self.token, 141 | params={"soft": soft}, 142 | ) 143 | 144 | async def restore(self, reaction_id): 145 | url = f"{self.API_ENDPOINT}{reaction_id}/restore" 146 | return await self.client.put( 147 | url, service_name=self.SERVICE_NAME, signature=self.token 148 | ) 149 | 150 | async def add_child( 151 | self, 152 | kind, 153 | parent_id, 154 | user_id, 155 | data=None, 156 | target_feeds=None, 157 | target_feeds_extra_data=None, 158 | ): 159 | payload = dict( 160 | kind=kind, 161 | parent=parent_id, 162 | data=data, 163 | target_feeds=target_feeds, 164 | target_feeds_extra_data=target_feeds_extra_data, 165 | user_id=user_id, 166 | ) 167 | return await self.client.post( 168 | self.API_ENDPOINT, 169 | service_name=self.SERVICE_NAME, 170 | signature=self.token, 171 | data=payload, 172 | ) 173 | 174 | async def filter(self, **params): 175 | endpoint = self._prepare_endpoint_for_filter(**params) 176 | return await self.client.get( 177 | endpoint, 178 | service_name=self.SERVICE_NAME, 179 | signature=self.token, 180 | params=params, 181 | ) 182 | -------------------------------------------------------------------------------- /stream/serializer.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | 4 | import pytz 5 | 6 | """ 7 | Adds the ability to send date and datetime objects to the API 8 | Datetime objects will be encoded/ decoded with microseconds 9 | The date and datetime formats from the API are automatically supported and parsed 10 | """ 11 | DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" 12 | DATE_FORMAT = "%Y-%m-%d" 13 | 14 | 15 | def _datetime_encoder(obj): 16 | if isinstance(obj, datetime.datetime): 17 | if obj.utcoffset() is None: # 3.5 18 | obj = pytz.utc.localize(obj) 19 | return datetime.datetime.strftime(obj.astimezone(pytz.utc), DATETIME_FORMAT) 20 | if isinstance(obj, datetime.date): 21 | return datetime.datetime.strftime(obj, DATE_FORMAT) 22 | return None 23 | 24 | 25 | def _datetime_decoder(dict_): 26 | for key, value in dict_.items(): 27 | # The built-in `json` library will `unicode` strings, except for empty 28 | # strings which are of type `str`. `jsondate` patches this for 29 | # consistency so that `unicode` is always returned. 30 | if value == "": 31 | dict_[key] = "" 32 | continue 33 | 34 | if value is not None and isinstance(value, str): 35 | try: 36 | # The api always returns times like this 37 | # 2014-07-25T09:12:24.735 38 | datetime_obj = pytz.utc.localize( 39 | datetime.datetime.strptime(value, DATETIME_FORMAT) 40 | ) 41 | dict_[key] = datetime_obj 42 | except (ValueError, TypeError): 43 | try: 44 | # The api always returns times like this 45 | # 2014-07-25T09:12:24.735 46 | datetime_obj = pytz.utc.localize( 47 | datetime.datetime.strptime(value, DATE_FORMAT) 48 | ) 49 | dict_[key] = datetime_obj.date() 50 | except (ValueError, TypeError): 51 | continue 52 | return dict_ 53 | 54 | 55 | def dumps(*args, **kwargs): 56 | kwargs["default"] = _datetime_encoder 57 | return json.dumps(*args, **kwargs) 58 | 59 | 60 | def loads(*args, **kwargs): 61 | kwargs["object_hook"] = _datetime_decoder 62 | return json.loads(*args, **kwargs) 63 | -------------------------------------------------------------------------------- /stream/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GetStream/stream-python/9c8b9cc9c3f9a3134532e63c383dab1e0718fc6c/stream/tests/__init__.py -------------------------------------------------------------------------------- /stream/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import sys 4 | import pytest_asyncio 5 | from uuid import uuid4 6 | 7 | 8 | from stream import connect 9 | 10 | 11 | def wrapper(meth): 12 | async def _parse_response(*args, **kwargs): 13 | response = await meth(*args, **kwargs) 14 | assert "duration" in response 15 | return response 16 | 17 | return _parse_response 18 | 19 | 20 | @pytest_asyncio.fixture(scope="module") 21 | def event_loop(): 22 | """Create an instance of the default event loop for each test case.""" 23 | loop = asyncio.get_event_loop_policy().new_event_loop() 24 | yield loop 25 | loop.close() 26 | 27 | 28 | @pytest_asyncio.fixture 29 | async def async_client(): 30 | key = os.getenv("STREAM_KEY") 31 | secret = os.getenv("STREAM_SECRET") 32 | if not key or not secret: 33 | print( 34 | "To run the tests the STREAM_KEY and STREAM_SECRET variables " 35 | "need to be available. \n" 36 | "Please create a pull request if you are an external " 37 | "contributor, because these variables are automatically added " 38 | "by Travis." 39 | ) 40 | sys.exit(1) 41 | 42 | client = connect(key, secret, location="qa", timeout=30, use_async=True) 43 | wrapper(client._parse_response) 44 | yield client 45 | 46 | 47 | @pytest_asyncio.fixture 48 | def user1(async_client): 49 | return async_client.feed("user", f"1-{uuid4()}") 50 | 51 | 52 | @pytest_asyncio.fixture 53 | def user2(async_client): 54 | return async_client.feed("user", f"2-{uuid4()}") 55 | 56 | 57 | @pytest_asyncio.fixture 58 | def aggregated2(async_client): 59 | return async_client.feed("aggregated", f"2-{uuid4()}") 60 | 61 | 62 | @pytest_asyncio.fixture 63 | def aggregated3(async_client): 64 | return async_client.feed("aggregated", f"3-{uuid4()}") 65 | 66 | 67 | @pytest_asyncio.fixture 68 | def topic(async_client): 69 | return async_client.feed("topic", f"1-{uuid4()}") 70 | 71 | 72 | @pytest_asyncio.fixture 73 | def flat3(async_client): 74 | return async_client.feed("flat", f"3-{uuid4()}") 75 | -------------------------------------------------------------------------------- /stream/tests/test_async_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | from datetime import datetime, timedelta 4 | from uuid import uuid1, uuid4 5 | 6 | import pytest 7 | import pytz 8 | from dateutil.tz import tzlocal 9 | 10 | import stream 11 | from stream.exceptions import ApiKeyException, InputException, DoesNotExistException 12 | 13 | 14 | def assert_first_activity_id_equal(activities, correct_activity_id): 15 | activity_id = None 16 | if activities: 17 | activity_id = activities[0]["id"] 18 | assert activity_id == correct_activity_id 19 | 20 | 21 | def assert_first_activity_id_not_equal(activities, correct_activity_id): 22 | activity_id = None 23 | if activities: 24 | activity_id = activities[0]["id"] 25 | assert activity_id != correct_activity_id 26 | 27 | 28 | def _get_first_aggregated_activity(activities): 29 | try: 30 | return activities[0]["activities"][0] 31 | except IndexError: 32 | pass 33 | 34 | 35 | def _get_first_activity(activities): 36 | try: 37 | return activities[0] 38 | except IndexError: 39 | pass 40 | 41 | 42 | def assert_datetime_almost_equal(a, b): 43 | difference = abs(a - b) 44 | if difference > timedelta(milliseconds=1): 45 | assert a == b 46 | 47 | 48 | def assert_clearly_not_equal(a, b): 49 | difference = abs(a - b) 50 | if difference < timedelta(milliseconds=1): 51 | raise ValueError("the dates are too close") 52 | 53 | 54 | async def _test_sleep(production_wait): 55 | """ 56 | when testing against a live API, sometimes we need a small sleep to 57 | ensure data stability, however when testing locally the wait does 58 | not need to be as long 59 | :param production_wait: float, number of seconds to sleep when hitting real API 60 | :return: None 61 | """ 62 | sleep_time = production_wait 63 | await asyncio.sleep(sleep_time) 64 | 65 | 66 | @pytest.mark.asyncio 67 | async def test_update_activities_create(async_client): 68 | activities = [ 69 | { 70 | "actor": "user:1", 71 | "verb": "do", 72 | "object": "object:1", 73 | "foreign_id": "object:1", 74 | "time": datetime.utcnow().isoformat(), 75 | } 76 | ] 77 | 78 | response = await async_client.update_activities(activities) 79 | assert response 80 | 81 | 82 | @pytest.mark.asyncio 83 | async def test_add_activity(async_client): 84 | feed = async_client.feed("user", f"py1-{uuid4()}") 85 | activity_data = {"actor": 1, "verb": "tweet", "object": 1} 86 | response = await feed.add_activity(activity_data) 87 | activity_id = response["id"] 88 | response = await feed.get(limit=1) 89 | activities = response["results"] 90 | assert activities[0]["id"] == activity_id 91 | 92 | 93 | @pytest.mark.asyncio 94 | async def test_add_activity_to_inplace_change(async_client): 95 | feed = async_client.feed("user", f"py1-{uuid4()}") 96 | team_feed = async_client.feed("user", "teamy") 97 | activity_data = {"actor": 1, "verb": "tweet", "object": 1} 98 | activity_data["to"] = [team_feed.id] 99 | await feed.add_activity(activity_data) 100 | assert activity_data["to"] == [team_feed.id] 101 | 102 | 103 | @pytest.mark.asyncio 104 | async def test_add_activities_to_inplace_change(async_client): 105 | feed = async_client.feed("user", f"py1-{uuid4()}") 106 | team_feed = async_client.feed("user", f"teamy-{uuid4()}") 107 | activity_data = {"actor": 1, "verb": "tweet", "object": 1} 108 | activity_data["to"] = [team_feed.id] 109 | await feed.add_activities([activity_data]) 110 | assert activity_data["to"] == [team_feed.id] 111 | 112 | 113 | @pytest.mark.asyncio 114 | async def test_add_activity_to(async_client): 115 | # test for sending an activities to the team feed using to 116 | feeds = ["user", "teamy", "team_follower"] 117 | user_feed, team_feed, team_follower_feed = map( 118 | lambda x: async_client.feed("user", f"{x}-{uuid4()}"), feeds 119 | ) 120 | await team_follower_feed.follow(team_feed.slug, team_feed.user_id) 121 | activity_data = {"actor": 1, "verb": "tweet", "object": 1, "to": [team_feed.id]} 122 | activity = await user_feed.add_activity(activity_data) 123 | activity_id = activity["id"] 124 | 125 | # see if the new activity is also in the team feed 126 | response = await team_feed.get(limit=1) 127 | activities = response["results"] 128 | assert activities[0]["id"] == activity_id 129 | assert activities[0]["origin"] is None 130 | # see if the fanout process also works 131 | response = await team_follower_feed.get(limit=1) 132 | activities = response["results"] 133 | assert activities[0]["id"] == activity_id 134 | assert activities[0]["origin"] == team_feed.id 135 | # and validate removing also works 136 | await user_feed.remove_activity(activity["id"]) 137 | # check the user pyto feed 138 | response = await team_feed.get(limit=1) 139 | activities = response["results"] 140 | assert_first_activity_id_not_equal(activities, activity_id) 141 | # and the flat feed 142 | response = await team_follower_feed.get(limit=1) 143 | activities = response["results"] 144 | assert_first_activity_id_not_equal(activities, activity_id) 145 | 146 | 147 | @pytest.mark.asyncio 148 | async def test_remove_activity(user1): 149 | activity_data = {"actor": 1, "verb": "tweet", "object": 1} 150 | activity = await user1.add_activity(activity_data) 151 | activity_id = activity["id"] 152 | response = await user1.get(limit=8) 153 | activities = response["results"] 154 | assert len(activities) == 1 155 | 156 | await user1.remove_activity(activity_id) 157 | # verify that no activities were returned 158 | response = await user1.get(limit=8) 159 | activities = response["results"] 160 | assert len(activities) == 0 161 | 162 | 163 | @pytest.mark.asyncio 164 | async def test_remove_activity_by_foreign_id(user1): 165 | activity_data = { 166 | "actor": 1, 167 | "verb": "tweet", 168 | "object": 1, 169 | "foreign_id": "tweet:10", 170 | } 171 | 172 | await user1.add_activity(activity_data) 173 | response = await user1.get(limit=8) 174 | activities = response["results"] 175 | assert len(activities) == 1 176 | assert activities[0]["id"] != "" 177 | assert activities[0]["foreign_id"] == "tweet:10" 178 | 179 | await user1.remove_activity(foreign_id="tweet:10") 180 | # verify that no activities were returned 181 | response = await user1.get(limit=8) 182 | activities = response["results"] 183 | assert len(activities) == 0 184 | # verify this doesn't raise an error, but fails silently 185 | await user1.remove_activity(foreign_id="tweet:unknownandmissing") 186 | 187 | 188 | @pytest.mark.asyncio 189 | async def test_add_activities(user1): 190 | activity_data = [ 191 | {"actor": 1, "verb": "tweet", "object": 1}, 192 | {"actor": 2, "verb": "watch", "object": 2}, 193 | ] 194 | response = await user1.add_activities(activity_data) 195 | activity_ids = [a["id"] for a in response["activities"]] 196 | response = await user1.get(limit=2) 197 | activities = response["results"] 198 | get_activity_ids = [a["id"] for a in activities] 199 | assert get_activity_ids == activity_ids[::-1] 200 | 201 | 202 | @pytest.mark.asyncio 203 | async def test_add_activities_to(async_client, user1): 204 | pyto2 = async_client.feed("user", f"pyto2-{uuid4()}") 205 | pyto3 = async_client.feed("user", f"pyto3-{uuid4()}") 206 | 207 | to = [pyto2.id, pyto3.id] 208 | activity_data = [ 209 | {"actor": 1, "verb": "tweet", "object": 1, "to": to}, 210 | {"actor": 2, "verb": "watch", "object": 2, "to": to}, 211 | ] 212 | response = await user1.add_activities(activity_data) 213 | activity_ids = [a["id"] for a in response["activities"]] 214 | response = await user1.get(limit=2) 215 | activities = response["results"] 216 | get_activity_ids = [a["id"] for a in activities] 217 | assert get_activity_ids == activity_ids[::-1] 218 | # test first target 219 | response = await pyto2.get(limit=2) 220 | activities = response["results"] 221 | get_activity_ids = [a["id"] for a in activities] 222 | assert get_activity_ids == activity_ids[::-1] 223 | # test second target 224 | response = await pyto3.get(limit=2) 225 | activities = response["results"] 226 | get_activity_ids = [a["id"] for a in activities] 227 | assert get_activity_ids == activity_ids[::-1] 228 | 229 | 230 | @pytest.mark.asyncio 231 | async def test_follow_and_source(async_client): 232 | feed = async_client.feed("user", f"test_follow-{uuid4()}") 233 | agg_feed = async_client.feed("aggregated", "test_follow") 234 | actor_id = random.randint(10, 100000) 235 | activity_data = {"actor": actor_id, "verb": "tweet", "object": 1} 236 | response = await feed.add_activity(activity_data) 237 | activity_id = response["id"] 238 | await agg_feed.follow(feed.slug, feed.user_id) 239 | 240 | response = await agg_feed.get(limit=3) 241 | activities = response["results"] 242 | activity = _get_first_aggregated_activity(activities) 243 | activity_id_found = activity["id"] if activity is not None else None 244 | assert activity["origin"] == feed.id 245 | assert activity_id_found == activity_id 246 | 247 | 248 | @pytest.mark.asyncio 249 | async def test_empty_followings(async_client): 250 | asocial = async_client.feed("user", f"asocialpython-{uuid4()}") 251 | followings = await asocial.following() 252 | assert followings["results"] == [] 253 | 254 | 255 | @pytest.mark.asyncio 256 | async def test_get_followings(async_client): 257 | social = async_client.feed("user", f"psocial-{uuid4()}") 258 | await social.follow("user", "apy") 259 | await social.follow("user", "bpy") 260 | await social.follow("user", "cpy") 261 | followings = await social.following(offset=0, limit=2) 262 | assert len(followings["results"]) == 2 263 | assert followings["results"][0]["feed_id"] == social.id 264 | assert followings["results"][0]["target_id"] == "user:cpy" 265 | followings = await social.following(offset=1, limit=2) 266 | assert len(followings["results"]) == 2 267 | assert followings["results"][0]["feed_id"] == social.id 268 | assert followings["results"][0]["target_id"] == "user:bpy" 269 | 270 | 271 | @pytest.mark.asyncio 272 | async def test_empty_followers(async_client): 273 | asocial = async_client.feed("user", f"asocialpython-{uuid4()}") 274 | followers = await asocial.followers() 275 | assert followers["results"] == [] 276 | 277 | 278 | @pytest.mark.asyncio 279 | async def test_get_followers(async_client): 280 | social = async_client.feed("user", f"psocial-{uuid4()}") 281 | spammy1 = async_client.feed("user", f"spammy1-{uuid4()}") 282 | spammy2 = async_client.feed("user", f"spammy2-{uuid4()}") 283 | spammy3 = async_client.feed("user", f"spammy3-{uuid4()}") 284 | for feed in [spammy1, spammy2, spammy3]: 285 | await feed.follow("user", social.user_id) 286 | followers = await social.followers(offset=0, limit=2) 287 | assert len(followers["results"]) == 2 288 | assert followers["results"][0]["feed_id"] == spammy3.id 289 | assert followers["results"][0]["target_id"] == social.id 290 | followers = await social.followers(offset=1, limit=2) 291 | assert len(followers["results"]) == 2 292 | assert followers["results"][0]["feed_id"] == spammy2.id 293 | assert followers["results"][0]["target_id"] == social.id 294 | 295 | 296 | @pytest.mark.asyncio 297 | async def test_empty_do_i_follow(async_client): 298 | social = async_client.feed("user", f"psocial-{uuid4()}") 299 | await social.follow("user", "apy") 300 | await social.follow("user", "bpy") 301 | followings = await social.following(feeds=["user:missingpy"]) 302 | assert followings["results"] == [] 303 | 304 | 305 | @pytest.mark.asyncio 306 | async def test_do_i_follow(async_client): 307 | social = async_client.feed("user", f"psocial-{uuid4()}") 308 | await social.follow("user", "apy") 309 | await social.follow("user", "bpy") 310 | followings = await social.following(feeds=["user:apy"]) 311 | assert len(followings["results"]) == 1 312 | assert followings["results"][0]["feed_id"] == social.id 313 | assert followings["results"][0]["target_id"] == "user:apy" 314 | 315 | 316 | @pytest.mark.asyncio 317 | async def test_update_activity_to_targets(user1): 318 | now = datetime.utcnow().isoformat() 319 | foreign_id = "user:1" 320 | activity_data = { 321 | "actor": 1, 322 | "verb": "tweet", 323 | "object": 1, 324 | "foreign_id": foreign_id, 325 | "time": now, 326 | "to": ["user:1", "user:2"], 327 | } 328 | await user1.add_activity(activity_data) 329 | 330 | ret = await user1.update_activity_to_targets( 331 | foreign_id, now, new_targets=["user:3", "user:2"] 332 | ) 333 | assert len(ret["activity"]["to"]) == 2 334 | assert "user:2" in ret["activity"]["to"] 335 | assert "user:3" in ret["activity"]["to"] 336 | 337 | ret = await user1.update_activity_to_targets( 338 | foreign_id, 339 | now, 340 | added_targets=["user:4", "user:5"], 341 | removed_targets=["user:3"], 342 | ) 343 | assert len(ret["activity"]["to"]) == 3 344 | assert "user:2" in ret["activity"]["to"] 345 | assert "user:4" in ret["activity"]["to"] 346 | assert "user:5" in ret["activity"]["to"] 347 | 348 | 349 | @pytest.mark.asyncio 350 | async def test_get(user1): 351 | activity_data = {"actor": 1, "verb": "tweet", "object": 1} 352 | response1 = await user1.add_activity(activity_data) 353 | activity_id = response1["id"] 354 | activity_data = {"actor": 2, "verb": "add", "object": 2} 355 | response2 = await user1.add_activity(activity_data) 356 | activity_id_two = response2["id"] 357 | activity_data = {"actor": 3, "verb": "watch", "object": 2} 358 | response3 = await user1.add_activity(activity_data) 359 | activity_id_three = response3["id"] 360 | response = await user1.get(limit=2) 361 | activities = response["results"] 362 | # verify the first two results 363 | assert len(activities) == 2 364 | assert activities[0]["id"] == activity_id_three 365 | assert activities[1]["id"] == activity_id_two 366 | # try offset based 367 | response = await user1.get(limit=2, offset=1) 368 | activities = response["results"] 369 | assert activities[0]["id"] == activity_id_two 370 | # try id_lt based 371 | response = await user1.get(limit=2, id_lt=activity_id_two) 372 | activities = response["results"] 373 | assert activities[0]["id"] == activity_id 374 | 375 | 376 | @pytest.mark.asyncio 377 | async def test_get_not_marked_seen(async_client): 378 | notification_feed = async_client.feed("notification", f"test_mark_seen-{uuid4()}") 379 | response = await notification_feed.get(limit=3) 380 | activities = response["results"] 381 | for activity in activities: 382 | assert not activity["is_seen"] 383 | 384 | 385 | @pytest.mark.asyncio 386 | async def test_mark_seen_on_get(async_client): 387 | notification_feed = async_client.feed("notification", f"test_mark_seen-{uuid4()}") 388 | response = await notification_feed.get(limit=100) 389 | activities = response["results"] 390 | for activity in activities: 391 | await notification_feed.remove_activity(activity["id"]) 392 | 393 | old_activities = [ 394 | await notification_feed.add_activity( 395 | {"actor": 1, "verb": "tweet", "object": 1} 396 | ), 397 | await notification_feed.add_activity( 398 | {"actor": 2, "verb": "add", "object": 2} 399 | ), 400 | await notification_feed.add_activity( 401 | {"actor": 3, "verb": "watch", "object": 3} 402 | ), 403 | ] 404 | 405 | await notification_feed.get( 406 | mark_seen=[old_activities[0]["id"], old_activities[1]["id"]] 407 | ) 408 | 409 | response = await notification_feed.get(limit=3) 410 | activities = response["results"] 411 | 412 | # is the seen state correct 413 | for activity in activities: 414 | # using a loop in case we're retrieving activities in a different 415 | # order than old_activities 416 | if old_activities[0]["id"] == activity["id"]: 417 | assert activity["is_seen"] 418 | if old_activities[1]["id"] == activity["id"]: 419 | assert activity["is_seen"] 420 | if old_activities[2]["id"] == activity["id"]: 421 | assert not activity["is_seen"] 422 | 423 | # see if the state properly resets after we add another activity 424 | await notification_feed.add_activity( 425 | {"actor": 3, "verb": "watch", "object": 3} 426 | ) # ['id'] 427 | response = await notification_feed.get(limit=3) 428 | activities = response["results"] 429 | assert not activities[0]["is_seen"] 430 | assert len(activities[0]["activities"]) == 2 431 | 432 | 433 | @pytest.mark.asyncio 434 | async def test_mark_read_by_id(async_client): 435 | notification_feed = async_client.feed("notification", f"py2-{uuid4()}") 436 | response = await notification_feed.get(limit=3) 437 | activities = response["results"] 438 | ids = [] 439 | for activity in activities: 440 | ids.append(activity["id"]) 441 | assert not activity["is_read"] 442 | ids = ids[:2] 443 | await notification_feed.get(mark_read=ids) 444 | response = await notification_feed.get(limit=3) 445 | activities = response["results"] 446 | for activity in activities: 447 | if activity["id"] in ids: 448 | assert activity["is_read"] 449 | assert not activity["is_seen"] 450 | 451 | 452 | @pytest.mark.asyncio 453 | async def test_api_key_exception(): 454 | client = stream.connect( 455 | "5crf3bhfzesnMISSING", 456 | "tfq2sdqpj9g446sbv653x3aqmgn33hsn8uzdc9jpskaw8mj6vsnhzswuwptuj9su", 457 | use_async=True, 458 | ) 459 | user1 = client.feed("user", "1") 460 | activity_data = { 461 | "actor": 1, 462 | "verb": "tweet", 463 | "object": 1, 464 | "debug_example_undefined": "test", 465 | } 466 | with pytest.raises(ApiKeyException): 467 | await user1.add_activity(activity_data) 468 | 469 | 470 | @pytest.mark.asyncio 471 | async def test_complex_field(user1): 472 | activity_data = { 473 | "actor": 1, 474 | "verb": "tweet", 475 | "object": 1, 476 | "participants": ["Tommaso", "Thierry"], 477 | } 478 | response = await user1.add_activity(activity_data) 479 | activity_id = response["id"] 480 | response = await user1.get(limit=1) 481 | activities = response["results"] 482 | assert activities[0]["id"] == activity_id 483 | assert activities[0]["participants"] == ["Tommaso", "Thierry"] 484 | 485 | 486 | @pytest.mark.asyncio 487 | async def test_uniqueness(user1): 488 | """ 489 | In order for things to be considere unique they need: 490 | a.) The same time and activity data 491 | b.) The same time and foreign id 492 | """ 493 | 494 | utcnow = datetime.now(tz=pytz.UTC) 495 | activity_data = {"actor": 1, "verb": "tweet", "object": 1, "time": utcnow} 496 | await user1.add_activity(activity_data) 497 | await user1.add_activity(activity_data) 498 | response = await user1.get(limit=2) 499 | activities = response["results"] 500 | assert_datetime_almost_equal(activities[0]["time"], utcnow) 501 | if len(activities) > 1: 502 | assert_clearly_not_equal(activities[1]["time"], utcnow) 503 | 504 | 505 | @pytest.mark.asyncio 506 | async def test_uniqueness_topic(flat3, topic, user1): 507 | """ 508 | In order for things to be considere unique they need: 509 | a.) The same time and activity data, or 510 | b.) The same time and foreign id 511 | """ 512 | # follow both the topic and the user 513 | await flat3.follow("topic", topic.user_id) 514 | await flat3.follow("user", user1.user_id) 515 | # add the same activity twice 516 | now = datetime.now(tzlocal()) 517 | tweet = f"My Way {uuid4()}" 518 | activity_data = { 519 | "actor": 1, 520 | "verb": "tweet", 521 | "object": 1, 522 | "time": now, 523 | "tweet": tweet, 524 | } 525 | await topic.add_activity(activity_data) 526 | await user1.add_activity(activity_data) 527 | # verify that flat3 contains the activity exactly once 528 | response = await flat3.get(limit=3) 529 | activity_tweets = [a.get("tweet") for a in response["results"]] 530 | assert activity_tweets.count(tweet) == 1 531 | 532 | 533 | @pytest.mark.asyncio 534 | async def test_uniqueness_foreign_id(user1): 535 | now = datetime.now(tzlocal()) 536 | utcnow = now.astimezone(pytz.utc) 537 | 538 | activity_data = { 539 | "actor": 1, 540 | "verb": "tweet", 541 | "object": 1, 542 | "foreign_id": "tweet:11", 543 | "time": utcnow, 544 | } 545 | await user1.add_activity(activity_data) 546 | 547 | activity_data = { 548 | "actor": 2, 549 | "verb": "tweet", 550 | "object": 3, 551 | "foreign_id": "tweet:11", 552 | "time": utcnow, 553 | } 554 | await user1.add_activity(activity_data) 555 | response = await user1.get(limit=10) 556 | activities = response["results"] 557 | # the second post should have overwritten the first one (because they 558 | # had same id) 559 | 560 | assert len(activities) == 1 561 | assert activities[0]["object"] == "3" 562 | assert activities[0]["foreign_id"] == "tweet:11" 563 | assert_datetime_almost_equal(activities[0]["time"], utcnow) 564 | 565 | 566 | @pytest.mark.asyncio 567 | async def test_time_ordering(user2): 568 | """ 569 | datetime.datetime.now(tz=pytz.utc) is our recommended approach 570 | so if we add an activity 571 | add one using time 572 | add another activity it should be in the right spot 573 | """ 574 | 575 | # timedelta is used to "make sure" that ordering is known even though 576 | # server time is not 577 | custom_time = datetime.now(tz=pytz.utc) - timedelta(days=1) 578 | 579 | feed = user2 580 | for index, activity_time in enumerate([None, custom_time, None]): 581 | await _test_sleep(1) # so times are a bit different 582 | activity_data = { 583 | "actor": 1, 584 | "verb": "tweet", 585 | "object": 1, 586 | "foreign_id": f"tweet:{index}", 587 | "time": activity_time, 588 | } 589 | await feed.add_activity(activity_data) 590 | 591 | response = await feed.get(limit=3) 592 | activities = response["results"] 593 | # the second post should have overwritten the first one (because they 594 | # had same id) 595 | assert activities[0]["foreign_id"] == "tweet:2" 596 | assert activities[1]["foreign_id"] == "tweet:0" 597 | assert activities[2]["foreign_id"] == "tweet:1" 598 | assert_datetime_almost_equal(activities[2]["time"], custom_time) 599 | 600 | 601 | @pytest.mark.asyncio 602 | async def test_missing_actor(user1): 603 | activity_data = { 604 | "verb": "tweet", 605 | "object": 1, 606 | "debug_example_undefined": "test", 607 | } 608 | try: 609 | await user1.add_activity(activity_data) 610 | raise ValueError("should have raised InputException") 611 | except InputException: 612 | pass 613 | 614 | 615 | @pytest.mark.asyncio 616 | async def test_follow_many(async_client): 617 | sources = [async_client.feed("user", f"{i}-{uuid4()}").id for i in range(10)] 618 | targets = [async_client.feed("flat", f"{i}-{uuid4()}").id for i in range(10)] 619 | feeds = [{"source": s, "target": t} for s, t in zip(sources, targets)] 620 | await async_client.follow_many(feeds) 621 | 622 | for target in targets: 623 | response = await async_client.feed(*target.split(":")).followers() 624 | follows = response["results"] 625 | assert len(follows) == 1 626 | assert follows[0]["feed_id"] in sources 627 | assert follows[0]["target_id"] == target 628 | 629 | for source in sources: 630 | response = await async_client.feed(*source.split(":")).following() 631 | follows = response["results"] 632 | assert len(follows) == 1 633 | assert follows[0]["feed_id"] == source 634 | assert follows[0]["target_id"] in targets 635 | 636 | 637 | @pytest.mark.asyncio 638 | async def test_follow_many_acl(async_client): 639 | sources = [async_client.feed("user", f"{i}-{uuid4()}") for i in range(10)] 640 | # ensure every source is empty first 641 | for feed in sources: 642 | response = await feed.get(limit=100) 643 | activities = response["results"] 644 | for activity in activities: 645 | await feed.remove_activity(activity["id"]) 646 | 647 | targets = [async_client.feed("flat", f"{i}-{uuid4()}") for i in range(10)] 648 | # ensure every source is empty first 649 | for feed in targets: 650 | response = await feed.get(limit=100) 651 | activities = response["results"] 652 | for activity in activities: 653 | await feed.remove_activity(activity["id"]) 654 | # add activity to each target feed 655 | activity = { 656 | "actor": "barry", 657 | "object": "09", 658 | "verb": "tweet", 659 | "time": datetime.utcnow().isoformat(), 660 | } 661 | for feed in targets: 662 | await feed.add_activity(activity) 663 | response = await feed.get(limit=5) 664 | assert len(response["results"]) == 1 665 | 666 | sources_id = [feed.id for feed in sources] 667 | targets_id = [target.id for target in targets] 668 | feeds = [{"source": s, "target": t} for s, t in zip(sources_id, targets_id)] 669 | 670 | await async_client.follow_many(feeds, activity_copy_limit=0) 671 | 672 | for feed in sources: 673 | response = await feed.get(limit=5) 674 | activities = response["results"] 675 | assert len(activities) == 0 676 | 677 | 678 | @pytest.mark.asyncio 679 | async def test_unfollow_many(async_client): 680 | unfollows = [ 681 | {"source": "user:1", "target": "timeline:1"}, 682 | {"source": "user:2", "target": "timeline:2", "keep_history": False}, 683 | ] 684 | 685 | await async_client.unfollow_many(unfollows) 686 | unfollows.append({"source": "user:1", "target": 42}) 687 | 688 | async def failing_unfollow(): 689 | await async_client.unfollow_many(unfollows) 690 | 691 | with pytest.raises(InputException): 692 | await failing_unfollow() 693 | 694 | 695 | @pytest.mark.asyncio 696 | async def test_add_to_many(async_client): 697 | activity = {"actor": 1, "verb": "tweet", "object": 1, "custom": "data"} 698 | feeds = [async_client.feed("flat", f"{i}-{uuid4()}").id for i in range(10, 20)] 699 | await async_client.add_to_many(activity, feeds) 700 | 701 | for feed in feeds: 702 | feed = async_client.feed(*feed.split(":")) 703 | response = await feed.get() 704 | assert response["results"][0]["custom"] == "data" 705 | 706 | 707 | @pytest.mark.asyncio 708 | async def test_get_activities_empty_ids(async_client): 709 | response = await async_client.get_activities(ids=[str(uuid1())]) 710 | assert len(response["results"]) == 0 711 | 712 | 713 | @pytest.mark.asyncio 714 | async def test_get_activities_empty_foreign_ids(async_client): 715 | response = await async_client.get_activities( 716 | foreign_id_times=[("fid-x", datetime.utcnow())] 717 | ) 718 | assert len(response["results"]) == 0 719 | 720 | 721 | @pytest.mark.asyncio 722 | async def test_get_activities_full(async_client): 723 | dt = datetime.utcnow() 724 | fid = "awesome-test" 725 | 726 | activity = { 727 | "actor": "barry", 728 | "object": "09", 729 | "verb": "tweet", 730 | "time": dt, 731 | "foreign_id": fid, 732 | } 733 | 734 | feed = async_client.feed("user", f"test_get_activity-{uuid4()}") 735 | response = await feed.add_activity(activity) 736 | 737 | response = await async_client.get_activities(ids=[response["id"]]) 738 | assert len(response["results"]) == 1 739 | foreign_id = response["results"][0]["foreign_id"] 740 | assert activity["foreign_id"] == foreign_id 741 | 742 | response = await async_client.get_activities(foreign_id_times=[(fid, dt)]) 743 | assert len(response["results"]) == 1 744 | foreign_id = response["results"][0]["foreign_id"] 745 | assert activity["foreign_id"] == foreign_id 746 | 747 | 748 | @pytest.mark.asyncio 749 | async def test_get_activities_full_with_enrichment(async_client): 750 | dt = datetime.utcnow() 751 | fid = "awesome-test" 752 | 753 | actor = await async_client.users.add(str(uuid1()), data={"name": "barry"}) 754 | activity = { 755 | "actor": async_client.users.create_reference(actor["id"]), 756 | "object": "09", 757 | "verb": "tweet", 758 | "time": dt, 759 | "foreign_id": fid, 760 | } 761 | 762 | feed = async_client.feed("user", f"test_get_activity-{uuid4()}") 763 | activity = await feed.add_activity(activity) 764 | 765 | reaction1 = await async_client.reactions.add("like", activity["id"], "liker") 766 | reaction2 = await async_client.reactions.add("reshare", activity["id"], "sharer") 767 | 768 | def validate(response): 769 | assert len(response["results"]) == 1 770 | assert response["results"][0]["id"] == activity["id"] 771 | assert response["results"][0]["foreign_id"] == activity["foreign_id"] 772 | assert response["results"][0]["actor"]["data"]["name"] == "barry" 773 | latest_reactions = response["results"][0]["latest_reactions"] 774 | assert len(latest_reactions) == 2 775 | assert latest_reactions["like"][0]["id"] == reaction1["id"] 776 | assert latest_reactions["reshare"][0]["id"] == reaction2["id"] 777 | assert response["results"][0]["reaction_counts"] == {"like": 1, "reshare": 1} 778 | 779 | reactions = {"recent": True, "counts": True} 780 | validate( 781 | await async_client.get_activities(ids=[activity["id"]], reactions=reactions) 782 | ) 783 | validate( 784 | await async_client.get_activities( 785 | foreign_id_times=[(fid, dt)], reactions=reactions 786 | ) 787 | ) 788 | 789 | 790 | @pytest.mark.asyncio 791 | async def test_get_activities_full_with_enrichment_and_reaction_kinds(async_client): 792 | dt = datetime.utcnow() 793 | fid = "awesome-test" 794 | 795 | actor = await async_client.users.add(str(uuid1()), data={"name": "barry"}) 796 | activity = { 797 | "actor": async_client.users.create_reference(actor["id"]), 798 | "object": "09", 799 | "verb": "tweet", 800 | "time": dt, 801 | "foreign_id": fid, 802 | } 803 | 804 | feed = async_client.feed("user", f"test_get_activity-{uuid4()}") 805 | activity = await feed.add_activity(activity) 806 | 807 | await async_client.reactions.add("like", activity["id"], "liker") 808 | await async_client.reactions.add("reshare", activity["id"], "sharer") 809 | await async_client.reactions.add("comment", activity["id"], "commenter") 810 | 811 | reactions = {"recent": True, "counts": True, "kinds": "like,comment"} 812 | response = await async_client.get_activities( 813 | ids=[activity["id"]], reactions=reactions 814 | ) 815 | assert len(response["results"]) == 1 816 | assert response["results"][0]["id"] == activity["id"] 817 | assert sorted(response["results"][0]["latest_reactions"].keys()) == [ 818 | "comment", 819 | "like", 820 | ] 821 | 822 | assert response["results"][0]["reaction_counts"] == {"like": 1, "comment": 1} 823 | 824 | reactions = { 825 | "recent": True, 826 | "counts": True, 827 | "kinds": ["", "reshare ", "comment\n"], 828 | } 829 | response = await async_client.get_activities( 830 | foreign_id_times=[(fid, dt)], reactions=reactions 831 | ) 832 | assert len(response["results"]) == 1 833 | assert response["results"][0]["id"] == activity["id"] 834 | assert sorted(response["results"][0]["latest_reactions"].keys()) == [ 835 | "comment", 836 | "reshare", 837 | ] 838 | assert response["results"][0]["reaction_counts"] == {"comment": 1, "reshare": 1} 839 | 840 | 841 | @pytest.mark.asyncio 842 | async def test_activity_partial_update(async_client): 843 | now = datetime.utcnow() 844 | feed = async_client.feed("user", uuid4()) 845 | await feed.add_activity( 846 | { 847 | "actor": "barry", 848 | "object": "09", 849 | "verb": "tweet", 850 | "time": now, 851 | "foreign_id": "fid:123", 852 | "product": {"name": "shoes", "price": 9.99, "color": "blue"}, 853 | } 854 | ) 855 | response = await feed.get() 856 | activity = response["results"][0] 857 | 858 | to_set = { 859 | "product.name": "boots", 860 | "product.price": 7.99, 861 | "popularity": 1000, 862 | "foo": {"bar": {"baz": "qux"}}, 863 | } 864 | to_unset = ["product.color"] 865 | 866 | # partial update by ID 867 | await async_client.activity_partial_update( 868 | id=activity["id"], set=to_set, unset=to_unset 869 | ) 870 | response = await feed.get() 871 | updated = response["results"][0] 872 | expected = activity 873 | expected["product"] = {"name": "boots", "price": 7.99} 874 | expected["popularity"] = 1000 875 | expected["foo"] = {"bar": {"baz": "qux"}} 876 | assert updated == expected 877 | 878 | # partial update by foreign ID + time 879 | to_set = {"foo.bar.baz": 42, "popularity": 9000} 880 | to_unset = ["product.price"] 881 | await async_client.activity_partial_update( 882 | foreign_id=activity["foreign_id"], 883 | time=activity["time"], 884 | set=to_set, 885 | unset=to_unset, 886 | ) 887 | response = await feed.get() 888 | updated = response["results"][0] 889 | expected["product"] = {"name": "boots"} 890 | expected["foo"] = {"bar": {"baz": 42}} 891 | expected["popularity"] = 9000 892 | assert updated == expected 893 | 894 | 895 | @pytest.mark.asyncio 896 | async def test_activities_partial_update(async_client): 897 | feed = async_client.feed("user", uuid4()) 898 | await feed.add_activities( 899 | [ 900 | { 901 | "actor": "barry", 902 | "object": "09", 903 | "verb": "tweet", 904 | "time": datetime.utcnow(), 905 | "foreign_id": "fid:123", 906 | "product": {"name": "shoes", "price": 9.99, "color": "blue"}, 907 | }, 908 | { 909 | "actor": "jerry", 910 | "object": "10", 911 | "verb": "tweet", 912 | "time": datetime.utcnow(), 913 | "foreign_id": "fid:456", 914 | "product": {"name": "shoes", "price": 9.99, "color": "blue"}, 915 | }, 916 | { 917 | "actor": "tommy", 918 | "object": "09", 919 | "verb": "tweet", 920 | "time": datetime.utcnow(), 921 | "foreign_id": "fid:789", 922 | "product": {"name": "shoes", "price": 9.99, "color": "blue"}, 923 | }, 924 | ] 925 | ) 926 | response = await feed.get() 927 | activities = response["results"] 928 | 929 | batch = [ 930 | { 931 | "id": activities[0]["id"], 932 | "set": {"product.color": "purple", "custom": {"some": "extra data"}}, 933 | "unset": ["product.price"], 934 | }, 935 | { 936 | "id": activities[2]["id"], 937 | "set": {"product.price": 9001, "on_sale": True}, 938 | }, 939 | ] 940 | 941 | # partial update by ID 942 | await async_client.activities_partial_update(batch) 943 | response = await feed.get() 944 | updated = response["results"] 945 | expected = activities 946 | expected[0]["product"] = {"name": "shoes", "color": "purple"} 947 | expected[0]["custom"] = {"some": "extra data"} 948 | expected[2]["product"] = {"name": "shoes", "price": 9001, "color": "blue"} 949 | expected[2]["on_sale"] = True 950 | assert updated == expected 951 | 952 | # partial update by foreign ID + time 953 | batch = [ 954 | { 955 | "foreign_id": activities[1]["foreign_id"], 956 | "time": activities[1]["time"], 957 | "set": {"product.color": "beeeeeeige", "custom": {"modified_by": "me"}}, 958 | "unset": ["product.name"], 959 | }, 960 | { 961 | "foreign_id": activities[2]["foreign_id"], 962 | "time": activities[2]["time"], 963 | "unset": ["on_sale"], 964 | }, 965 | ] 966 | await async_client.activities_partial_update(batch) 967 | response = await feed.get() 968 | updated = response["results"] 969 | 970 | expected[1]["product"] = {"price": 9.99, "color": "beeeeeeige"} 971 | expected[1]["custom"] = {"modified_by": "me"} 972 | del expected[2]["on_sale"] 973 | assert updated == expected 974 | 975 | 976 | @pytest.mark.asyncio 977 | async def test_reaction_add(async_client): 978 | await async_client.reactions.add( 979 | "like", "54a60c1e-4ee3-494b-a1e3-50c06acb5ed4", "mike" 980 | ) 981 | 982 | 983 | @pytest.mark.asyncio 984 | async def test_reaction_add_to_target_feeds(async_client): 985 | feed_id = f"user:michelle-{uuid4()}" 986 | r = await async_client.reactions.add( 987 | "superlike", 988 | "54a60c1e-4ee3-494b-a1e3-50c06acb5ed4", 989 | "mike", 990 | data={"popularity": 50}, 991 | target_feeds=[feed_id], 992 | target_feeds_extra_data={"popularity": 100}, 993 | ) 994 | assert r["data"]["popularity"] == 50 995 | feed = async_client.feed(*feed_id.split(":")) 996 | response = await feed.get(limit=1) 997 | a = response["results"][0] 998 | assert r["id"] in a["reaction"] 999 | assert a["verb"] == "superlike" 1000 | assert a["popularity"] == 100 1001 | 1002 | child = await async_client.reactions.add_child( 1003 | "superlike", 1004 | r["id"], 1005 | "rob", 1006 | data={"popularity": 60}, 1007 | target_feeds=[feed_id], 1008 | target_feeds_extra_data={"popularity": 200}, 1009 | ) 1010 | 1011 | assert child["data"]["popularity"] == 60 1012 | response = await feed.get(limit=1) 1013 | a = response["results"][0] 1014 | assert child["id"] in a["reaction"] 1015 | assert a["verb"] == "superlike" 1016 | assert a["popularity"] == 200 1017 | 1018 | 1019 | @pytest.mark.asyncio 1020 | async def test_reaction_get(async_client): 1021 | response = await async_client.reactions.add( 1022 | "like", "54a60c1e-4ee3-494b-a1e3-50c06acb5ed4", "mike" 1023 | ) 1024 | reaction = await async_client.reactions.get(response["id"]) 1025 | assert reaction["parent"] == "" 1026 | assert reaction["data"] == {} 1027 | assert reaction["latest_children"] == {} 1028 | assert reaction["children_counts"] == {} 1029 | assert reaction["activity_id"] == "54a60c1e-4ee3-494b-a1e3-50c06acb5ed4" 1030 | assert reaction["kind"] == "like" 1031 | assert "created_at" in reaction 1032 | assert "updated_at" in reaction 1033 | assert "id" in reaction 1034 | 1035 | 1036 | @pytest.mark.asyncio 1037 | async def test_reaction_update(async_client): 1038 | response = await async_client.reactions.add( 1039 | "like", "54a60c1e-4ee3-494b-a1e3-50c06acb5ed4", "mike" 1040 | ) 1041 | await async_client.reactions.update(response["id"], {"changed": True}) 1042 | 1043 | 1044 | @pytest.mark.asyncio 1045 | async def test_reaction_delete(async_client): 1046 | response = await async_client.reactions.add( 1047 | "like", "54a60c1e-4ee3-494b-a1e3-50c06acb5ed4", "mike" 1048 | ) 1049 | await async_client.reactions.delete(response["id"]) 1050 | 1051 | 1052 | @pytest.mark.asyncio 1053 | async def test_reaction_hard_delete(async_client): 1054 | response = await async_client.reactions.add( 1055 | "like", "54a60c1e-4ee3-494b-a1e3-50c06acb5ed4", "mike" 1056 | ) 1057 | await async_client.reactions.delete(response["id"], soft=False) 1058 | 1059 | 1060 | @pytest.mark.asyncio 1061 | async def test_reaction_soft_delete(async_client): 1062 | response = await async_client.reactions.add( 1063 | "like", "54a60c1e-4ee3-494b-a1e3-50c06acb5ed4", "mike" 1064 | ) 1065 | await async_client.reactions.delete(response["id"], soft=True) 1066 | 1067 | 1068 | @pytest.mark.asyncio 1069 | async def test_reaction_soft_delete_and_restore(async_client): 1070 | response = await async_client.reactions.add( 1071 | "like", "54a60c1e-4ee3-494b-a1e3-50c06acb5ed4", "mike" 1072 | ) 1073 | await async_client.reactions.delete(response["id"], soft=True) 1074 | r1 = await async_client.reactions.get(response["id"]) 1075 | assert r1.get("deleted_at", None) is not None 1076 | await async_client.reactions.restore(response["id"]) 1077 | r1 = await async_client.reactions.get(response["id"]) 1078 | assert "deleted_at" not in r1 1079 | 1080 | 1081 | @pytest.mark.asyncio 1082 | async def test_reaction_invalid_restore(async_client): 1083 | response = await async_client.reactions.add( 1084 | "like", "54a60c1e-4ee3-494b-a1e3-50c06acb5ed4", "mike" 1085 | ) 1086 | with pytest.raises(DoesNotExistException): 1087 | await async_client.reactions.restore(response["id"]) 1088 | 1089 | 1090 | @pytest.mark.asyncio 1091 | async def test_reaction_add_child(async_client): 1092 | response = await async_client.reactions.add( 1093 | "like", "54a60c1e-4ee3-494b-a1e3-50c06acb5ed4", "mike" 1094 | ) 1095 | await async_client.reactions.add_child("like", response["id"], "rob") 1096 | 1097 | 1098 | @pytest.mark.asyncio 1099 | async def test_reaction_filter_random(async_client): 1100 | await async_client.reactions.filter( 1101 | kind="like", 1102 | reaction_id="87a9eec0-fd5f-11e8-8080-80013fed2f5b", 1103 | id_lte="87a9eec0-fd5f-11e8-8080-80013fed2f5b", 1104 | ) 1105 | await async_client.reactions.filter( 1106 | activity_id="87a9eec0-fd5f-11e8-8080-80013fed2f5b", 1107 | id_lte="87a9eec0-fd5f-11e8-8080-80013fed2f5b", 1108 | ) 1109 | await async_client.reactions.filter( 1110 | user_id="mike", id_lte="87a9eec0-fd5f-11e8-8080-80013fed2f5b" 1111 | ) 1112 | 1113 | 1114 | def _first_result_should_be(response, element): 1115 | el = element.copy() 1116 | el.pop("duration") 1117 | assert len(response["results"]) == 1 1118 | assert response["results"][0] == el 1119 | 1120 | 1121 | @pytest.mark.asyncio 1122 | async def test_reaction_filter(async_client): 1123 | activity_id = str(uuid1()) 1124 | user = str(uuid1()) 1125 | 1126 | response = await async_client.reactions.add("like", activity_id, user) 1127 | child = await async_client.reactions.add_child("like", response["id"], user) 1128 | reaction = await async_client.reactions.get(response["id"]) 1129 | 1130 | response = await async_client.reactions.add("comment", activity_id, user) 1131 | reaction_comment = await async_client.reactions.get(response["id"]) 1132 | 1133 | r = await async_client.reactions.filter(reaction_id=reaction["id"]) 1134 | _first_result_should_be(r, child) 1135 | 1136 | r = await async_client.reactions.filter( 1137 | kind="like", activity_id=activity_id, id_lte=reaction["id"] 1138 | ) 1139 | _first_result_should_be(r, reaction) 1140 | 1141 | r = await async_client.reactions.filter( 1142 | kind="like", user_id=user, id_lte=reaction["id"] 1143 | ) 1144 | _first_result_should_be(r, reaction) 1145 | 1146 | r = await async_client.reactions.filter(kind="comment", activity_id=activity_id) 1147 | _first_result_should_be(r, reaction_comment) 1148 | 1149 | 1150 | @pytest.mark.asyncio 1151 | async def test_user_add(async_client): 1152 | await async_client.users.add(str(uuid1())) 1153 | 1154 | 1155 | @pytest.mark.asyncio 1156 | async def test_user_add_get_or_create(async_client): 1157 | user_id = str(uuid1()) 1158 | r1 = await async_client.users.add(user_id) 1159 | r2 = await async_client.users.add(user_id, get_or_create=True) 1160 | assert r1["id"] == r2["id"] 1161 | assert r1["created_at"] == r2["created_at"] 1162 | assert r1["updated_at"] == r2["updated_at"] 1163 | 1164 | 1165 | @pytest.mark.asyncio 1166 | async def test_user_get(async_client): 1167 | response = await async_client.users.add(str(uuid1())) 1168 | user = await async_client.users.get(response["id"]) 1169 | assert user["data"] == {} 1170 | assert "created_at" in user 1171 | assert "updated_at" in user 1172 | assert "id" in user 1173 | 1174 | 1175 | @pytest.mark.asyncio 1176 | async def test_user_get_with_follow_counts(async_client): 1177 | response = await async_client.users.add(str(uuid1())) 1178 | user = await async_client.users.get(response["id"], with_follow_counts=True) 1179 | assert user["id"] == response["id"] 1180 | assert "followers_count" in user 1181 | assert "following_count" in user 1182 | 1183 | 1184 | @pytest.mark.asyncio 1185 | async def test_user_update(async_client): 1186 | response = await async_client.users.add(str(uuid1())) 1187 | await async_client.users.update(response["id"], {"changed": True}) 1188 | 1189 | 1190 | @pytest.mark.asyncio 1191 | async def test_user_delete(async_client): 1192 | response = await async_client.users.add(str(uuid1())) 1193 | await async_client.users.delete(response["id"]) 1194 | 1195 | 1196 | @pytest.mark.asyncio 1197 | async def test_collections_add(async_client): 1198 | await async_client.collections.add( 1199 | "items", {"data": 1}, id=str(uuid1()), user_id="tom" 1200 | ) 1201 | 1202 | 1203 | @pytest.mark.asyncio 1204 | async def test_collections_add_no_id(async_client): 1205 | await async_client.collections.add("items", {"data": 1}) 1206 | 1207 | 1208 | @pytest.mark.asyncio 1209 | async def test_collections_get(async_client): 1210 | response = await async_client.collections.add("items", {"data": 1}, id=str(uuid1())) 1211 | entry = await async_client.collections.get("items", response["id"]) 1212 | assert entry["data"] == {"data": 1} 1213 | assert "created_at" in entry 1214 | assert "updated_at" in entry 1215 | assert "id" in entry 1216 | 1217 | 1218 | @pytest.mark.asyncio 1219 | async def test_collections_update(async_client): 1220 | response = await async_client.collections.add("items", {"data": 1}, str(uuid1())) 1221 | await async_client.collections.update( 1222 | "items", response["id"], data={"changed": True} 1223 | ) 1224 | entry = await async_client.collections.get("items", response["id"]) 1225 | assert entry["data"] == {"changed": True} 1226 | 1227 | 1228 | @pytest.mark.asyncio 1229 | async def test_collections_delete(async_client): 1230 | response = await async_client.collections.add("items", {"data": 1}, str(uuid1())) 1231 | await async_client.collections.delete("items", response["id"]) 1232 | 1233 | 1234 | @pytest.mark.asyncio 1235 | async def test_feed_enrichment_collection(async_client): 1236 | entry = await async_client.collections.add("items", {"name": "time machine"}) 1237 | entry.pop("duration") 1238 | f = async_client.feed("user", f"mike-{uuid4()}") 1239 | activity_data = { 1240 | "actor": "mike", 1241 | "verb": "buy", 1242 | "object": async_client.collections.create_reference(entry=entry), 1243 | } 1244 | await f.add_activity(activity_data) 1245 | response = await f.get() 1246 | assert set(activity_data.items()).issubset(set(response["results"][0].items())) 1247 | enriched_response = await f.get(enrich=True) 1248 | assert enriched_response["results"][0]["object"] == entry 1249 | 1250 | 1251 | @pytest.mark.asyncio 1252 | async def test_feed_enrichment_user(async_client): 1253 | user = await async_client.users.add(str(uuid1()), {"name": "Mike"}) 1254 | user.pop("duration") 1255 | f = async_client.feed("user", f"mike-{uuid4()}") 1256 | activity_data = { 1257 | "actor": async_client.users.create_reference(user), 1258 | "verb": "buy", 1259 | "object": "time machine", 1260 | } 1261 | await f.add_activity(activity_data) 1262 | response = await f.get() 1263 | assert set(activity_data.items()).issubset(set(response["results"][0].items())) 1264 | enriched_response = await f.get(enrich=True) 1265 | assert enriched_response["results"][0]["actor"] == user 1266 | 1267 | 1268 | @pytest.mark.asyncio 1269 | async def test_feed_enrichment_own_reaction(async_client): 1270 | f = async_client.feed("user", f"mike-{uuid4()}") 1271 | activity_data = {"actor": "mike", "verb": "buy", "object": "object"} 1272 | response = await f.add_activity(activity_data) 1273 | reaction = await async_client.reactions.add("like", response["id"], "mike") 1274 | reaction.pop("duration") 1275 | enriched_response = await f.get(reactions={"own": True}, user_id="mike") 1276 | assert enriched_response["results"][0]["own_reactions"]["like"][0] == reaction 1277 | 1278 | 1279 | @pytest.mark.asyncio 1280 | async def test_feed_enrichment_recent_reaction(async_client): 1281 | f = async_client.feed("user", f"mike-{uuid4()}") 1282 | activity_data = {"actor": "mike", "verb": "buy", "object": "object"} 1283 | response = await f.add_activity(activity_data) 1284 | reaction = await async_client.reactions.add("like", response["id"], "mike") 1285 | reaction.pop("duration") 1286 | enriched_response = await f.get(reactions={"recent": True}) 1287 | assert enriched_response["results"][0]["latest_reactions"]["like"][0] == reaction 1288 | 1289 | 1290 | @pytest.mark.asyncio 1291 | async def test_feed_enrichment_reaction_counts(async_client): 1292 | f = async_client.feed("user", f"mike-{uuid4()}") 1293 | activity_data = {"actor": "mike", "verb": "buy", "object": "object"} 1294 | response = await f.add_activity(activity_data) 1295 | reaction = await async_client.reactions.add("like", response["id"], "mike") 1296 | reaction.pop("duration") 1297 | enriched_response = await f.get(reactions={"counts": True}) 1298 | assert enriched_response["results"][0]["reaction_counts"]["like"] == 1 1299 | 1300 | 1301 | @pytest.mark.asyncio 1302 | async def test_track_engagements(async_client): 1303 | engagements = [ 1304 | { 1305 | "content": "1", 1306 | "label": "click", 1307 | "features": [ 1308 | {"group": "topic", "value": "js"}, 1309 | {"group": "user", "value": "tommaso"}, 1310 | ], 1311 | "user_data": "tommaso", 1312 | }, 1313 | { 1314 | "content": "2", 1315 | "label": "click", 1316 | "features": [ 1317 | {"group": "topic", "value": "go"}, 1318 | {"group": "user", "value": "tommaso"}, 1319 | ], 1320 | "user_data": {"id": "486892", "alias": "Julian"}, 1321 | }, 1322 | { 1323 | "content": "3", 1324 | "label": "click", 1325 | "features": [{"group": "topic", "value": "go"}], 1326 | "user_data": {"id": "tommaso", "alias": "tommaso"}, 1327 | }, 1328 | ] 1329 | await async_client.track_engagements(engagements) 1330 | 1331 | 1332 | @pytest.mark.asyncio 1333 | async def test_track_impressions(async_client): 1334 | impressions = [ 1335 | { 1336 | "content_list": ["1", "2", "3"], 1337 | "features": [ 1338 | {"group": "topic", "value": "js"}, 1339 | {"group": "user", "value": "tommaso"}, 1340 | ], 1341 | "user_data": {"id": "tommaso", "alias": "tommaso"}, 1342 | }, 1343 | { 1344 | "content_list": ["2", "3", "5"], 1345 | "features": [{"group": "topic", "value": "js"}], 1346 | "user_data": {"id": "486892", "alias": "Julian"}, 1347 | }, 1348 | ] 1349 | await async_client.track_impressions(impressions) 1350 | 1351 | 1352 | @pytest.mark.asyncio 1353 | async def test_og(async_client): 1354 | response = await async_client.og("https://google.com") 1355 | assert "title" in response 1356 | assert "description" in response 1357 | 1358 | 1359 | @pytest.mark.asyncio 1360 | async def test_follow_stats(async_client): 1361 | uniq = uuid4() 1362 | f = async_client.feed("user", uniq) 1363 | await f.follow("user", uuid4()) 1364 | await f.follow("user", uuid4()) 1365 | await f.follow("user", uuid4()) 1366 | 1367 | await async_client.feed("user", uuid4()).follow("user", uniq) 1368 | await async_client.feed("timeline", uuid4()).follow("user", uniq) 1369 | 1370 | feed_id = "user:" + str(uniq) 1371 | response = await async_client.follow_stats(feed_id) 1372 | result = response["results"] 1373 | assert result["following"]["count"] == 3 1374 | assert result["followers"]["count"] == 2 1375 | 1376 | response = await async_client.follow_stats( 1377 | feed_id, followers_slugs=["timeline"], following_slugs=["timeline"] 1378 | ) 1379 | result = response["results"] 1380 | assert result["following"]["count"] == 0 1381 | assert result["followers"]["count"] == 1 1382 | -------------------------------------------------------------------------------- /stream/users/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import AsyncUsers, Users 2 | -------------------------------------------------------------------------------- /stream/users/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class AbstractUsers(ABC): 5 | @abstractmethod 6 | def create_reference(self, id): 7 | pass 8 | 9 | @abstractmethod 10 | def add(self, user_id, data=None, get_or_create=False): 11 | pass 12 | 13 | @abstractmethod 14 | def get(self, user_id, **params): 15 | pass 16 | 17 | @abstractmethod 18 | def update(self, user_id, data=None): 19 | pass 20 | 21 | @abstractmethod 22 | def delete(self, user_id): 23 | pass 24 | 25 | 26 | class BaseUsers(AbstractUsers, ABC): 27 | API_ENDPOINT = "user/" 28 | SERVICE_NAME = "api" 29 | 30 | def __init__(self, client, token): 31 | self.client = client 32 | self.token = token 33 | 34 | def create_reference(self, id): 35 | _id = id 36 | if isinstance(id, (dict,)) and id.get("id") is not None: 37 | _id = id.get("id") 38 | return f"SU:{_id}" 39 | -------------------------------------------------------------------------------- /stream/users/user.py: -------------------------------------------------------------------------------- 1 | from stream.users.base import BaseUsers 2 | 3 | 4 | class Users(BaseUsers): 5 | def add(self, user_id, data=None, get_or_create=False): 6 | payload = dict(id=user_id, data=data) 7 | return self.client.post( 8 | self.API_ENDPOINT, 9 | service_name=self.SERVICE_NAME, 10 | signature=self.token, 11 | data=payload, 12 | params={"get_or_create": get_or_create}, 13 | ) 14 | 15 | def get(self, user_id, **params): 16 | return self.client.get( 17 | f"{self.API_ENDPOINT}/{user_id}", 18 | service_name=self.SERVICE_NAME, 19 | params=params, 20 | signature=self.token, 21 | ) 22 | 23 | def update(self, user_id, data=None): 24 | payload = dict(data=data) 25 | return self.client.put( 26 | f"{self.API_ENDPOINT}/{user_id}", 27 | service_name=self.SERVICE_NAME, 28 | signature=self.token, 29 | data=payload, 30 | ) 31 | 32 | def delete(self, user_id): 33 | return self.client.delete( 34 | f"{self.API_ENDPOINT}/{user_id}", 35 | service_name=self.SERVICE_NAME, 36 | signature=self.token, 37 | ) 38 | 39 | 40 | class AsyncUsers(BaseUsers): 41 | async def add(self, user_id, data=None, get_or_create=False): 42 | payload = dict(id=user_id, data=data) 43 | return await self.client.post( 44 | self.API_ENDPOINT, 45 | service_name=self.SERVICE_NAME, 46 | signature=self.token, 47 | data=payload, 48 | params={"get_or_create": str(get_or_create)}, 49 | ) 50 | 51 | async def get(self, user_id, **params): 52 | return await self.client.get( 53 | f"{self.API_ENDPOINT}/{user_id}", 54 | service_name=self.SERVICE_NAME, 55 | params=params, 56 | signature=self.token, 57 | ) 58 | 59 | async def update(self, user_id, data=None): 60 | payload = dict(data=data) 61 | return await self.client.put( 62 | f"{self.API_ENDPOINT}/{user_id}", 63 | service_name=self.SERVICE_NAME, 64 | signature=self.token, 65 | data=payload, 66 | ) 67 | 68 | async def delete(self, user_id): 69 | return await self.client.delete( 70 | f"{self.API_ENDPOINT}/{user_id}", 71 | service_name=self.SERVICE_NAME, 72 | signature=self.token, 73 | ) 74 | -------------------------------------------------------------------------------- /stream/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | valid_re = re.compile(r"^[\w-]+$") 4 | 5 | 6 | def validate_feed_id(feed_id): 7 | """ 8 | Validates the input is in the format of user:1 9 | 10 | :param feed_id: a feed such as user:1 11 | 12 | Raises ValueError if the format doesn't match 13 | """ 14 | feed_id = str(feed_id) 15 | if len(feed_id.split(":")) != 2: 16 | msg = ( 17 | f"Invalid feed_id spec {feed_id}, " 18 | f"please specify the feed_id as feed_slug:feed_id" 19 | ) 20 | raise ValueError(msg) 21 | 22 | feed_slug, user_id = feed_id.split(":") 23 | validate_feed_slug(feed_slug) 24 | validate_user_id(user_id) 25 | return feed_id 26 | 27 | 28 | def validate_feed_slug(feed_slug): 29 | """ 30 | Validates the feed slug 31 | """ 32 | feed_slug = str(feed_slug) 33 | if not valid_re.match(feed_slug): 34 | msg = f"Invalid feed slug {feed_slug}, please only use letters, numbers and _" 35 | raise ValueError(msg) 36 | return feed_slug 37 | 38 | 39 | def validate_user_id(user_id): 40 | """ 41 | Validates the user id 42 | """ 43 | user_id = str(user_id) 44 | if not valid_re.match(user_id): 45 | msg = f"Invalid user id {user_id}, please only use letters, numbers and _" 46 | raise ValueError(msg) 47 | return user_id 48 | 49 | 50 | def validate_foreign_id_time(foreign_id_time): 51 | if not isinstance(foreign_id_time, (list, tuple)): 52 | raise ValueError("foreign_id_time should be a list of tuples") 53 | 54 | for v in foreign_id_time: 55 | if not isinstance(v, (list, tuple)): 56 | raise ValueError("foreign_id_time elements should be lists or tuples") 57 | 58 | if len(v) != 2: 59 | raise ValueError("foreign_id_time elements should have two elements") 60 | 61 | 62 | def get_reaction_params(reactions): 63 | if reactions is not None and not isinstance(reactions, (dict,)): 64 | raise TypeError("reactions argument should be a dictionary") 65 | 66 | params = {} 67 | if reactions is not None: 68 | if reactions.get("own"): 69 | params["withOwnReactions"] = True 70 | if reactions.get("recent"): 71 | params["withRecentReactions"] = True 72 | if reactions.get("counts"): 73 | params["withReactionCounts"] = True 74 | kinds = reactions.get("kinds") 75 | if kinds: 76 | if isinstance(kinds, list): 77 | kinds = ",".join(k.strip() for k in kinds if k.strip()) 78 | params["reactionKindsFilter"] = kinds 79 | return params 80 | --------------------------------------------------------------------------------