diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 59a77d18..dbf2cc00 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,47 +14,49 @@ jobs:
matrix:
python-version: [3.6, 3.7, 3.8, 3.9, '3.10', 3.11]
framework:
+ - NONE
- FLASK_VERSION=1.1.4
- - FLASK_VERSION=2.2.3
- - DJANGO_VERSION=1.11.29
- - DJANGO_VERSION=2.2.28
- - DJANGO_VERSION=3.2.18
- - DJANGO_VERSION=4.0.10
- - DJANGO_VERSION=4.1.7
+ - FLASK_VERSION=2.3.3
+ - FLASK_VERSION=3.0.3
+ - DJANGO_VERSION=3.2.25
+ - DJANGO_VERSION=4.2.15
+ - DJANGO_VERSION=5.0.8
- TWISTED_VERSION=20.3.0
- TWISTED_VERSION=21.7.0
- TWISTED_VERSION=22.10.0
- PYRAMID_VERSION=1.10.8
- - STARLETTE_VERSION=0.12.13 httpx==0.18.1 python-multipart==0.0.5
- - STARLETTE_VERSION=0.14.2 httpx==0.18.1 python-multipart==0.0.5
- - FASTAPI_VERSION=0.40.0 httpx==0.18.1 python-multipart==0.0.5
- - FASTAPI_VERSION=0.50.0 httpx==0.18.1 python-multipart==0.0.5
- - FASTAPI_VERSION=0.63.0 httpx==0.18.1 python-multipart==0.0.5
+ - PYRAMID_VERSION=2.0.2
+ - STARLETTE_VERSION=0.30.0 httpx==0.24.1 python-multipart==0.0.9
+ - STARLETTE_VERSION=0.38.2 httpx==0.27.0 python-multipart==0.0.9
+ - FASTAPI_VERSION=0.101.1 httpx==0.24.1 python-multipart==0.0.9
+ - FASTAPI_VERSION=0.112.1 httpx==0.27.0 python-multipart==0.0.9
exclude:
# Test frameworks on the python versions they support, according to pypi registry
# Flask
- - framework: FLASK_VERSION=2.2.3
+ - framework: FLASK_VERSION=2.3.3
python-version: 3.6
+ - framework: FLASK_VERSION=2.3.3
+ python-version: 3.7
+ - framework: FLASK_VERSION=3.0.3
+ python-version: 3.6
+ - framework: FLASK_VERSION=3.0.3
+ python-version: 3.7
# Django
- - framework: DJANGO_VERSION=1.11.29
- python-version: 3.8
- - framework: DJANGO_VERSION=1.11.29
- python-version: 3.9
- - framework: DJANGO_VERSION=1.11.29
- python-version: '3.10'
- - framework: DJANGO_VERSION=1.11.29
+ - framework: DJANGO_VERSION=3.2.25
python-version: 3.11
- - framework: DJANGO_VERSION=4.0.10
+ - framework: DJANGO_VERSION=4.2.15
python-version: 3.6
- - framework: DJANGO_VERSION=4.0.10
+ - framework: DJANGO_VERSION=4.2.15
python-version: 3.7
- - framework: DJANGO_VERSION=4.1.7
- python-version: 3.5
- - framework: DJANGO_VERSION=4.1.7
+ - framework: DJANGO_VERSION=5.0.8
python-version: 3.6
- - framework: DJANGO_VERSION=4.1.7
+ - framework: DJANGO_VERSION=5.0.8
python-version: 3.7
+ - framework: DJANGO_VERSION=5.0.8
+ python-version: 3.8
+ - framework: DJANGO_VERSION=5.0.8
+ python-version: 3.9
# Twisted
- framework: TWISTED_VERSION=20.3.0
@@ -62,6 +64,26 @@ jobs:
- framework: TWISTED_VERSION=22.10.0
python-version: 3.6
+ # Starlette
+ - framework: STARLETTE_VERSION=0.30.0 httpx==0.24.1 python-multipart==0.0.9
+ python-version: 3.6
+ - framework: STARLETTE_VERSION=0.30.0 httpx==0.24.1 python-multipart==0.0.9
+ python-version: 3.7
+ - framework: STARLETTE_VERSION=0.38.2 httpx==0.27.0 python-multipart==0.0.9
+ python-version: 3.6
+ - framework: STARLETTE_VERSION=0.38.2 httpx==0.27.0 python-multipart==0.0.9
+ python-version: 3.7
+
+ # FastAPI
+ - framework: FASTAPI_VERSION=0.101.1 httpx==0.24.1 python-multipart==0.0.9
+ python-version: 3.6
+ - framework: FASTAPI_VERSION=0.101.1 httpx==0.24.1 python-multipart==0.0.9
+ python-version: 3.7
+ - framework: FASTAPI_VERSION=0.112.1 httpx==0.27.0 python-multipart==0.0.9
+ python-version: 3.6
+ - framework: FASTAPI_VERSION=0.112.1 httpx==0.27.0 python-multipart==0.0.9
+ python-version: 3.7
+
steps:
- uses: actions/checkout@v2
with:
@@ -75,7 +97,7 @@ jobs:
- name: Install Python 3.6 dependencies
if: ${{ contains(matrix.python-version, '3.6') }}
# typing-extensions dropped support for Python 3.6 in version 4.2
- run: pip install "typing-extensions<4.2" requests==2.27.0 blinker==1.5 immutables==0.19
+ run: pip install "typing-extensions<4.2" requests==2.27.0 blinker==1.5 immutables==0.19 webob blinker httpx aiocontextvars
- name: Install Python 3.7 dependencies
if: ${{ contains(matrix.python-version, '3.7') }}
@@ -83,6 +105,7 @@ jobs:
run: pip install immutables==0.19
- name: Set the framework
+ if: ${{ matrix.framework != 'NONE' }}
run: echo ${{ matrix.framework }} >> $GITHUB_ENV
- name: Install Flask
@@ -109,5 +132,13 @@ jobs:
if: ${{ contains(matrix.framework, 'FASTAPI_VERSION') }}
run: pip install fastapi==$FASTAPI_VERSION
- - name: Run tests
- run: python setup.py test
+ - name: Install Tox
+ run: pip install tox
+
+ - name: Run tests (skip on Python 3.6)
+ if: ${{ !contains(matrix.python-version, '3.6') }}
+ run: tox
+
+ - name: Run tests (Python 3.6)
+ if: ${{ contains(matrix.python-version, '3.6') }}
+ run: python -m unittest rollbar.test.discover
diff --git a/.gitignore b/.gitignore
index f82be943..4cbc538a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ Pipfile
Pipfile.lock
.pytest_cache/
.python-version
+.tox/
diff --git a/README.md b/README.md
index 59006709..5a476da6 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ Python notifier for reporting exceptions, errors, and log messages to [Rollbar](
- **Advanced search:** Filter items by many different properties. Learn more about search.
- **Customizable notifications:** Rollbar supports several messaging and incident management tools where your team can get notified about errors and important events by real-time alerts. Learn more about Rollbar notifications.
-## Versions Supported
+## Python Versions Supported
| PyRollbar Version | Python Version Compatibility | Support Level |
|-------------------|-----------------------------------------------|---------------------|
@@ -33,6 +33,26 @@ Python notifier for reporting exceptions, errors, and log messages to [Rollbar](
**Security Fixes Only** - We will only provide critical security fixes for the library.
+## Frameworks Supported
+
+Generally, PyRollbar can be used with any Python framework. However, we have official support for the following frameworks:
+
+| Framework | Support Duration | Tested Versions |
+|-----------|----------------------------|-----------------|
+| Celery | Release +1 year | None |
+| Django | Release or LTS end +1 year | 3.2, 4.2, 5.0 |
+| FastAPI | Release +1 year | 0.101, 0.112 |
+| Flask | Release +1 year | 1.1, 2.3, 3.0 |
+| Pyramid | Release +1 year | 1.10, 2.0 |
+
+Official support means that we ship and maintain integrations for these frameworks. It also means that we test against these frameworks as part of our CI pipeline.
+
+Generally, we will support the last year of releases for a framework. If a framework has a defined support period (including LTS releases), we will support the release for the duration of that period plus one year.
+
+### Community Supported
+
+There are also a number of community-supported integrations available. For more information, see the [Python SDK docs](https://docs.rollbar.com/docs/python-community-supported-sdks).
+
## Setup Instructions
1. [Sign up for a Rollbar account](https://rollbar.com/signup)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..9595f351
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,54 @@
+[project]
+name = "rollbar"
+dynamic = ["version"]
+description = "Easy and powerful exception tracking with Rollbar. Send messages and exceptions with arbitrary context, get back aggregates, and debug production issues quickly."
+readme = "README.md"
+license = {file = "LICENSE"}
+maintainers = [{name = "Rollbar, Inc.", email = "support@rollbar.com"}]
+classifiers = [
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.6",
+ "Programming Language :: Python :: 3.7",
+ "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3 :: Only",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Development Status :: 5 - Production/Stable",
+ "Environment :: Web Environment",
+ "Framework :: AsyncIO",
+ "Framework :: Bottle",
+ "Framework :: Django",
+ "Framework :: Flask",
+ "Framework :: Pylons",
+ "Framework :: Pyramid",
+ "Framework :: Twisted",
+ "Intended Audience :: Developers",
+ "Topic :: Internet :: WWW/HTTP",
+ "Topic :: Software Development",
+ "Topic :: Software Development :: Bug Tracking",
+ "Topic :: Software Development :: Testing",
+ "Topic :: Software Development :: Quality Assurance",
+ "Topic :: System :: Logging",
+ "Topic :: System :: Monitoring",
+]
+requires-python = ">=3.6"
+dependencies = [
+ "requests>=0.12.1",
+]
+
+[project.urls]
+Homepage = "https://rollbar.com/"
+Documentation = "https://docs.rollbar.com/docs/python"
+Changes = "https://github.com/rollbar/pyrollbar/blob/master/CHANGELOG.md"
+Source = "https://github.com/rollbar/pyrollbar/"
+
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.dynamic]
+version = {attr = "rollbar.__version__"}
diff --git a/rollbar/__init__.py b/rollbar/__init__.py
index 905fd9a5..d2c82edd 100644
--- a/rollbar/__init__.py
+++ b/rollbar/__init__.py
@@ -1372,7 +1372,7 @@ def _build_starlette_request_data(request):
'params': dict(request.path_params),
}
- if hasattr(request, '_form'):
+ if hasattr(request, '_form') and request._form is not None:
request_data['POST'] = {
k: v.filename if isinstance(v, UploadFile) else v
for k, v in request._form.items()
@@ -1772,4 +1772,8 @@ def _wsgi_extract_user_ip(environ):
def _starlette_extract_user_ip(request):
+ if not hasattr(request, 'client'):
+ return _extract_user_ip_from_headers(request)
+ if not hasattr(request.client, 'host'):
+ return _extract_user_ip_from_headers(request)
return request.client.host or _extract_user_ip_from_headers(request)
diff --git a/rollbar/lib/transforms/__init__.py b/rollbar/lib/transforms/__init__.py
index 3f1b8802..4923b9c3 100644
--- a/rollbar/lib/transforms/__init__.py
+++ b/rollbar/lib/transforms/__init__.py
@@ -34,6 +34,8 @@ def transform(obj, transforms, key=None, batch_transforms=False):
transforms = [BatchedTransform(transforms)]
for transform in transforms:
+ if not isinstance(transform, Transform):
+ continue
obj = _transform(obj, transform, key=key)
return obj
diff --git a/rollbar/test/fastapi_tests/test_logger.py b/rollbar/test/fastapi_tests/test_logger.py
index 49a4a8a6..99a8d9a2 100644
--- a/rollbar/test/fastapi_tests/test_logger.py
+++ b/rollbar/test/fastapi_tests/test_logger.py
@@ -38,6 +38,7 @@ def test_should_add_framework_version_to_payload(self, mock_send_payload, *mocks
app = FastAPI()
app.add_middleware(LoggerMiddleware)
+ app.build_middleware_stack()
rollbar.report_exc_info()
@@ -70,10 +71,10 @@ def test_should_store_current_request(self, store_current_request):
'client': ['testclient', 50000],
'headers': [
(b'host', b'testserver'),
- (b'user-agent', b'testclient'),
- (b'accept-encoding', b'gzip, deflate'),
(b'accept', b'*/*'),
+ (b'accept-encoding', b'gzip, deflate'),
(b'connection', b'keep-alive'),
+ (b'user-agent', b'testclient'),
],
'http_version': '1.1',
'method': 'GET',
diff --git a/rollbar/test/fastapi_tests/test_middleware.py b/rollbar/test/fastapi_tests/test_middleware.py
index c49336e4..023754a0 100644
--- a/rollbar/test/fastapi_tests/test_middleware.py
+++ b/rollbar/test/fastapi_tests/test_middleware.py
@@ -16,6 +16,7 @@
import rollbar
from rollbar.lib._async import AsyncMock
from rollbar.test import BaseTest
+from rollbar.test.utils import get_public_attrs
ALLOWED_PYTHON_VERSION = sys.version_info >= (3, 6)
@@ -152,6 +153,7 @@ def test_should_add_framework_version_to_payload(self, mock_send_payload, *mocks
app = FastAPI()
app.add_middleware(ReporterMiddleware)
+ app.build_middleware_stack()
rollbar.report_exc_info()
@@ -272,10 +274,10 @@ def test_should_store_current_request(self, store_current_request):
'client': ['testclient', 50000],
'headers': [
(b'host', b'testserver'),
- (b'user-agent', b'testclient'),
- (b'accept-encoding', b'gzip, deflate'),
(b'accept', b'*/*'),
+ (b'accept-encoding', b'gzip, deflate'),
(b'connection', b'keep-alive'),
+ (b'user-agent', b'testclient'),
],
'http_version': '1.1',
'method': 'GET',
@@ -324,7 +326,7 @@ def test_should_return_current_request(self):
async def read_root(original_request: Request):
request = get_current_request()
- self.assertEqual(request, original_request)
+ self.assertEqual(get_public_attrs(request), get_public_attrs(original_request))
client = TestClient(app)
client.get('/')
diff --git a/rollbar/test/starlette_tests/test_logger.py b/rollbar/test/starlette_tests/test_logger.py
index 3ed51e68..5981e5cb 100644
--- a/rollbar/test/starlette_tests/test_logger.py
+++ b/rollbar/test/starlette_tests/test_logger.py
@@ -38,6 +38,7 @@ def test_should_add_framework_version_to_payload(self, mock_send_payload, *mocks
app = Starlette()
app.add_middleware(LoggerMiddleware)
+ app.build_middleware_stack()
rollbar.report_exc_info()
@@ -67,10 +68,10 @@ def test_should_store_current_request(self, store_current_request):
'client': ['testclient', 50000],
'headers': [
(b'host', b'testserver'),
- (b'user-agent', b'testclient'),
- (b'accept-encoding', b'gzip, deflate'),
(b'accept', b'*/*'),
+ (b'accept-encoding', b'gzip, deflate'),
(b'connection', b'keep-alive'),
+ (b'user-agent', b'testclient'),
],
'http_version': '1.1',
'method': 'GET',
diff --git a/rollbar/test/starlette_tests/test_middleware.py b/rollbar/test/starlette_tests/test_middleware.py
index 7c9f6554..e7415af0 100644
--- a/rollbar/test/starlette_tests/test_middleware.py
+++ b/rollbar/test/starlette_tests/test_middleware.py
@@ -16,6 +16,7 @@
import rollbar
from rollbar.lib._async import AsyncMock
from rollbar.test import BaseTest
+from rollbar.test.utils import get_public_attrs
ALLOWED_PYTHON_VERSION = sys.version_info >= (3, 6)
@@ -138,6 +139,7 @@ def test_should_add_framework_version_to_payload(self, mock_send_payload, *mocks
app = Starlette()
app.add_middleware(ReporterMiddleware)
+ app.build_middleware_stack()
rollbar.report_exc_info()
@@ -243,10 +245,10 @@ def test_should_store_current_request(self, store_current_request):
'client': ['testclient', 50000],
'headers': [
(b'host', b'testserver'),
- (b'user-agent', b'testclient'),
- (b'accept-encoding', b'gzip, deflate'),
(b'accept', b'*/*'),
+ (b'accept-encoding', b'gzip, deflate'),
(b'connection', b'keep-alive'),
+ (b'user-agent', b'testclient'),
],
'http_version': '1.1',
'method': 'GET',
@@ -290,7 +292,7 @@ def test_should_return_current_request(self):
async def root(original_request):
request = get_current_request()
- self.assertEqual(request, original_request)
+ self.assertEqual(get_public_attrs(request), get_public_attrs(original_request))
return PlainTextResponse('OK')
diff --git a/rollbar/test/starlette_tests/test_requests.py b/rollbar/test/starlette_tests/test_requests.py
index 75bacb1d..09b0c292 100644
--- a/rollbar/test/starlette_tests/test_requests.py
+++ b/rollbar/test/starlette_tests/test_requests.py
@@ -10,6 +10,7 @@
import unittest
from rollbar.test import BaseTest
+from rollbar.test.utils import get_public_attrs
ALLOWED_PYTHON_VERSION = sys.version_info >= (3, 6)
@@ -49,7 +50,7 @@ def test_should_accept_request_param(self):
stored_request = store_current_request(request)
- self.assertEqual(request, stored_request)
+ self.assertEqual(get_public_attrs(request), get_public_attrs(stored_request))
def test_should_accept_scope_param_if_http_type(self):
from starlette.requests import Request
@@ -81,7 +82,7 @@ def test_should_accept_scope_param_if_http_type(self):
request = store_current_request(scope, receive)
- self.assertEqual(request, expected_request)
+ self.assertEqual(get_public_attrs(request), get_public_attrs(expected_request))
def test_should_not_accept_scope_param_if_not_http_type(self):
from rollbar.contrib.starlette.requests import store_current_request
diff --git a/rollbar/test/test_rollbar.py b/rollbar/test/test_rollbar.py
index 3107e9e4..35b69766 100644
--- a/rollbar/test/test_rollbar.py
+++ b/rollbar/test/test_rollbar.py
@@ -20,6 +20,7 @@
from rollbar.lib import string_types
from rollbar.test import BaseTest
+from rollbar.test.utils import get_public_attrs
try:
eval("""
@@ -173,6 +174,7 @@ def test_starlette_request_data_with_consumed_body(self):
body = b'body body body'
scope = {
'type': 'http',
+ 'client': ('127.0.0.1', 1453),
'headers': [
(b'content-type', b'text/html'),
(b'content-length', str(len(body)).encode('latin-1')),
@@ -410,7 +412,7 @@ def test_get_request_starlette_middleware(self):
def root(starlette_request):
current_request = rollbar.get_request()
- self.assertEqual(current_request, starlette_request)
+ self.assertEqual(get_public_attrs(current_request), get_public_attrs(starlette_request))
return PlainTextResponse("bye bye")
@@ -437,7 +439,7 @@ def test_get_request_starlette_logger(self):
def root(starlette_request):
current_request = rollbar.get_request()
- self.assertEqual(current_request, starlette_request)
+ self.assertEqual(get_public_attrs(current_request), get_public_attrs(starlette_request))
return PlainTextResponse("bye bye")
@@ -465,7 +467,7 @@ def test_get_request_fastapi_middleware(self):
def root(param, fastapi_request: Request):
current_request = rollbar.get_request()
- self.assertEqual(current_request, fastapi_request)
+ self.assertEqual(get_public_attrs(current_request), get_public_attrs(fastapi_request))
root = fastapi_add_route_with_request_param(
app, root, '/{param}', 'fastapi_request'
@@ -492,7 +494,7 @@ def test_get_request_fastapi_logger(self):
def root(fastapi_request: Request):
current_request = rollbar.get_request()
- self.assertEqual(current_request, fastapi_request)
+ self.assertEqual(get_public_attrs(current_request), get_public_attrs(fastapi_request))
root = fastapi_add_route_with_request_param(
app, root, '/{param}', 'fastapi_request'
@@ -523,7 +525,7 @@ def test_get_request_fastapi_router(self):
def root(fastapi_request: Request):
current_request = rollbar.get_request()
- self.assertEqual(current_request, fastapi_request)
+ self.assertEqual(get_public_attrs(current_request), get_public_attrs(fastapi_request))
root = fastapi_add_route_with_request_param(
app, root, '/{param}', 'fastapi_request'
diff --git a/rollbar/test/utils.py b/rollbar/test/utils.py
new file mode 100644
index 00000000..88294592
--- /dev/null
+++ b/rollbar/test/utils.py
@@ -0,0 +1,4 @@
+from collections.abc import Mapping
+
+def get_public_attrs(obj: Mapping) -> dict:
+ return {k: obj[k] for k in obj if not k.startswith('_')}
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 87f4fc1a..00000000
--- a/setup.py
+++ /dev/null
@@ -1,79 +0,0 @@
-import re
-import os.path
-from setuptools import setup, find_packages
-
-HERE = os.path.abspath(os.path.dirname(__file__))
-
-README_PATH = os.path.join(HERE, 'README.md')
-try:
- with open(README_PATH) as fd:
- README = fd.read()
-except IOError:
- README = ''
-
-INIT_PATH = os.path.join(HERE, 'rollbar/__init__.py')
-with open(INIT_PATH) as fd:
- INIT_DATA = fd.read()
- VERSION = re.search(r"^__version__ = ['\"]([^'\"]+)['\"]", INIT_DATA, re.MULTILINE).group(1)
-
-tests_require = [
- 'webob',
- 'blinker',
- 'httpx',
- 'aiocontextvars; python_version == "3.6"'
-]
-
-setup(
- name='rollbar',
- packages=find_packages(),
- version=VERSION,
- entry_points={
- 'paste.filter_app_factory': [
- 'pyramid=rollbar.contrib.pyramid:create_rollbar_middleware'
- ],
- 'console_scripts': ['rollbar=rollbar.cli:main']
- },
- description='Easy and powerful exception tracking with Rollbar. Send '
- 'messages and exceptions with arbitrary context, get back '
- 'aggregates, and debug production issues quickly.',
- long_description=README,
- long_description_content_type="text/markdown",
- author='Rollbar, Inc.',
- author_email='support@rollbar.com',
- test_suite='rollbar.test.discover',
- url='http://github.com/rollbar/pyrollbar',
- classifiers=[
- "Programming Language :: Python",
- "Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.6",
- "Programming Language :: Python :: 3.7",
- "Programming Language :: Python :: 3.8",
- "Programming Language :: Python :: 3.9",
- "Programming Language :: Python :: 3.10",
- "Programming Language :: Python :: 3.11",
- "Programming Language :: Python :: 3 :: Only",
- "License :: OSI Approved :: MIT License",
- "Operating System :: OS Independent",
- "Development Status :: 5 - Production/Stable",
- "Environment :: Web Environment",
- "Framework :: AsyncIO",
- "Framework :: Bottle",
- "Framework :: Django",
- "Framework :: Flask",
- "Framework :: Pylons",
- "Framework :: Pyramid",
- "Framework :: Twisted",
- "Intended Audience :: Developers",
- "Topic :: Internet :: WWW/HTTP",
- "Topic :: Software Development",
- "Topic :: Software Development :: Bug Tracking",
- "Topic :: Software Development :: Testing",
- "Topic :: Software Development :: Quality Assurance",
- "Topic :: System :: Logging",
- "Topic :: System :: Monitoring",
- ],
- install_requires=[
- 'requests>=0.12.1',
- ],
- tests_require=tests_require,
-)
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 00000000..a05ac4b2
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,9 @@
+[tox]
+requires = tox>=4
+
+[testenv]
+deps = webob
+ blinker
+ httpx
+description = run unit tests
+commands = python -m unittest rollbar.test.discover