Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
97 commits
Select commit Hold shift + click to select a range
9d2d32a
Sort extras in pyproj
bmos Jul 5, 2026
49ea730
add solidarity-tech extra
bmos Jul 5, 2026
9be850f
Allow passing additional headers to APIConnector
bmos Jul 27, 2026
38201c2
Initial work on SolidarityTech connector
bmos Jul 27, 2026
198df60
Add SolidarityTech initialization tests
bmos Jul 27, 2026
8e9bb73
Ensure api token is saved in connector
bmos Jul 27, 2026
ba2764d
Merge branch 'main' into solidarity-tech
bmos Jul 27, 2026
f80ce20
Add methods for event attendances
bmos Jul 28, 2026
11ff17c
Use numpy.int64 where appropriate
bmos Jul 28, 2026
498740f
Update solidarity_tech_agent_assignments.py
bmos Jul 28, 2026
c364bd8
Add Event RSVP methods
bmos Jul 28, 2026
750645e
Allow passing params directly to _get_resources, to combine with the
bmos Jul 28, 2026
83ba623
Fix test folder name
bmos Jul 28, 2026
ffe00f5
Bump min numpy to 2.2
bmos Jul 28, 2026
c31d440
Adjust dependency versions for more python version coverage
bmos Jul 28, 2026
fad1af3
Bump min numpy for python 3.14
bmos Jul 28, 2026
ff58f33
fix method name in event_rsvps
bmos Jul 29, 2026
010b1a0
Add event sessions methods
bmos Jul 29, 2026
b8bcca1
add event methods
bmos Jul 29, 2026
0882b04
Add field survey urls method
bmos Jul 29, 2026
49d0834
Shorter file names
bmos Jul 29, 2026
bd3bd9c
fix survey url documentation link
bmos Jul 29, 2026
206f5bb
add organizations methods
bmos Jul 29, 2026
e999c89
improve parameter handling with error message
bmos Jul 29, 2026
f74616f
include response with HTTPErrors
bmos Jul 29, 2026
4c9bdca
add pages methods
bmos Jul 29, 2026
04eb650
add phonebank methods
bmos Jul 29, 2026
039c23f
move extra params to params arg of _get_resources
bmos Jul 29, 2026
964f266
improved params combining in base class
bmos Jul 29, 2026
8610a04
fix capitalization in phonebanks
bmos Jul 29, 2026
2fb6dc8
add scheduled calls methods
bmos Jul 29, 2026
e7e6178
add scheduled tasks methods
bmos Jul 29, 2026
efe5452
add task agents methods
bmos Jul 29, 2026
4d1cea5
fix scheduled calls urls
bmos Jul 29, 2026
4d17ba7
fix scheduled task url
bmos Jul 29, 2026
3b493ed
add task assignments methods
bmos Jul 29, 2026
9a7e22f
add team members methods
bmos Jul 29, 2026
9975062
type improvements
bmos Jul 29, 2026
17a58d4
Use shared literals
bmos Jul 29, 2026
76dc032
url fixes
bmos Jul 29, 2026
f652ced
add text blast methods
bmos Jul 29, 2026
8c378bc
add text templates methods
bmos Jul 29, 2026
40138bd
add textbank methods
bmos Jul 29, 2026
6832df7
add texts and user_actions methods
bmos Jul 29, 2026
d8417b2
add apiconnector tests including header init and combination
bmos Jul 30, 2026
0acb283
add user list methods
bmos Jul 30, 2026
2749474
docstrings to imperative mood
bmos Jul 30, 2026
a57760b
fix class name of SolidarityTechUserLists
bmos Jul 30, 2026
a7370f4
add user notes methods
bmos Jul 30, 2026
d2706be
generalize ratelimited_api_connector
bmos Jul 30, 2026
449be4a
improve documentating and typing of _base
bmos Jul 30, 2026
983b6bb
move pyrate to general dependencies
bmos Jul 30, 2026
252844f
add user relationships methods
bmos Jul 30, 2026
b8e06e6
Cast self.api_token as str to avoid warning
bmos Aug 1, 2026
9c3d720
Consolidate status code checks/error handling
bmos Aug 2, 2026
cc8a754
add users methods
bmos Aug 2, 2026
4116e58
include response data when initializing exceptions based on HTTPError
bmos Aug 2, 2026
57c126b
Improved return typing and data handling
bmos Aug 2, 2026
642b73b
Merge branch 'move-coop:main' into solidarity-tech
bmos Aug 2, 2026
335d44b
Revised additional_headers implementation (#55)
bmos Aug 2, 2026
9d9f4bf
ruff format
bmos Aug 2, 2026
24c0325
Updated ratelimited api connector (#56)
bmos Aug 2, 2026
a749f00
add first-pass documentation
bmos Aug 2, 2026
307ae3d
add solidarity tech to docs index
bmos Aug 2, 2026
3a1f7c2
Update env variable name
bmos Aug 2, 2026
e3ac4c2
Improve exceptions
bmos Aug 3, 2026
62cda3b
add SolidarityTech to parsons init
bmos Aug 3, 2026
68b7fcf
improve tests
bmos Aug 3, 2026
03905a0
fix kwarg remapping in get_resources
bmos Aug 3, 2026
32290df
improved testing
bmos Aug 3, 2026
48ca8de
add post tests
bmos Aug 3, 2026
fe8f943
test for params collisison in get_resources
bmos Aug 3, 2026
1b20126
test since datetime conversion in get_resources
bmos Aug 3, 2026
f3d3607
Convert literals to enums and use _add_if_field_not_empty to
bmos Aug 5, 2026
2cc5a8b
Update with latest APIConnector + associated type changes
bmos Aug 6, 2026
3c7f83a
Import from future annotations and utilize TYPE_CHECKING block more
bmos Aug 6, 2026
567c529
Remove APIConnector tests (out of scope here)
bmos Aug 6, 2026
f6f708c
Remove obsolete ratelimited API connector documentation
bmos Aug 6, 2026
1475754
Improve SolidarityTech connector documentation
bmos Aug 6, 2026
9f6ae98
Allow passing a requests.Session through SolidarityTech for advanced
bmos Aug 6, 2026
cfef616
Add additional external links in documentation
bmos Aug 6, 2026
410a6c5
Add exceptions to documentation
bmos Aug 6, 2026
797a95b
Shorter names for enum and base files
bmos Aug 6, 2026
64ff3bb
add enums to documentation
bmos Aug 6, 2026
76032fc
Type fixes in SolidarityTechBase
bmos Aug 7, 2026
61452dd
Document and improve SolidarityTech tests
bmos Aug 7, 2026
5c40049
Updated APIConnector typing
bmos Aug 7, 2026
2262d1a
Merge branch 'main' into solidarity-tech
bmos Aug 24, 2026
cfb9931
cleanup base
bmos Aug 25, 2026
f4263da
Improve tests to handle APIConnector changes
bmos Aug 25, 2026
3044156
remove unecessary pass statement
bmos Aug 27, 2026
e7bbcc0
Docstrings
bmos Aug 27, 2026
dca68d5
move exception text to variable
bmos Aug 27, 2026
cd42317
Don't shadow builtin id
bmos Aug 27, 2026
8c806e5
boolean keyword args
bmos Aug 27, 2026
9358f67
Update ratelimited API connector from #1908
bmos Aug 27, 2026
78f0a15
Create TypedDicts for return data
bmos Aug 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"psycopg2-binary": ("https://www.psycopg.org/docs/", None),
"pyairtable": ("https://pyairtable.readthedocs.io/en/stable/", None),
"PyGitHub": ("https://pygithub.readthedocs.io/en/latest/", None),
"pyratelimiter": ("https://pyratelimiter.readthedocs.io/en/latest/", None),
"requests-toolbelt": ("https://toolbelt.readthedocs.io/en/stable/", None),
"rich": ("https://rich.readthedocs.io/en/stable/", None),
"simple-salesforce": ("https://simple-salesforce.readthedocs.io/en/latest/", None),
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ Indices and tables
sftp
shopify
sisense
solidarity_tech
targetsmart
turbovote
twilio
Expand Down
78 changes: 78 additions & 0 deletions docs/solidarity_tech.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
###############
Solidarity Tech
###############

Overview
========

What is Solidarity Tech?
------------------------

`Solidarity Tech`_'s all-in-one nonprofit CRM includes digital tools like
`texting <https://www.solidarity.tech/texting>`__,
`calling <https://www.solidarity.tech/calling>`__,
`email <https://www.solidarity.tech/email>`__ &
`websites <https://www.solidarity.tech/website>`__ for
`advocacy groups <https://www.solidarity.tech/for/advocacy>`__,
`unions <https://www.solidarity.tech/for/unions>`__ &
grassroots organizers.

The SolidarityTech Connector
----------------------------

As of September 2026, parsons' :class:`~parsons.solidarity_tech.solidarity_tech.SolidarityTech` connector supports
all endpoints described in the `Solidarity Tech API Documentation <https://www.solidarity.tech/reference/solidarity-tech-api>`_.
The documented `rate limits <https://www.solidarity.tech/reference/solidarity-tech-api#api-authorization--rate-limits>`__
are applied automatically, although advanced configuration is available.

Quickstart
==========

To instantiate the :class:`~parsons.solidarity_tech.solidarity_tech.SolidarityTech` class,
you can either store your Solidarity Tech bearer authorization key as
an environmental variable (``SOLIDARITY_TECH_BEARER_KEY``) or pass it as a keyword argument.

.. code-block:: python
:caption: Load bearer authorization key from environment variable

from parsons import SolidarityTech
st = SolidarityTech()

.. code-block:: python
:caption: Pass bearer authorization key as argument

from parsons import SolidarityTech
st = SolidarityTech(api_token='SOME_BEARER_KEY')

You can then call various endpoints:

.. code-block:: python
:caption: Get all events

events = st.get_events()

.. code-block:: python
:caption: Create a new user

st.create_user(
first_name="Elizabeth",
last_name="Flynn",
email="egflynn@example.com"
)

API
====

.. autoclass:: parsons.solidarity_tech.solidarity_tech.SolidarityTech
:inherited-members:
:members:

.. automodule:: parsons.solidarity_tech.exceptions
:inherited-members:
:members:

.. automodule:: parsons.solidarity_tech.enums
:inherited-members:
:members:

.. _Solidarity Tech: https://www.solidarity.tech/
1 change: 1 addition & 0 deletions parsons/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"SFTP": "parsons.sftp.sftp",
"Shopify": "parsons.shopify.shopify",
"Sisense": "parsons.sisense.sisense",
"SolidarityTech": "parsons.solidarity_tech.solidarity_tech",
"TargetSmartAPI": "parsons.targetsmart.targetsmart_api",
"TargetSmartAutomation": "parsons.targetsmart.targetsmart_automation",
"TurboVote": "parsons.turbovote.turbovote",
Expand Down
3 changes: 3 additions & 0 deletions parsons/solidarity_tech/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from parsons.solidarity_tech.solidarity_tech import SolidarityTech

__all__ = ["SolidarityTech"]
223 changes: 223 additions & 0 deletions parsons/solidarity_tech/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
from __future__ import annotations

import logging
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING, Any, TypedDict, cast

import numpy as np
import pyrate_limiter
import requests_ratelimiter
from requests.structures import CaseInsensitiveDict

from parsons.solidarity_tech.exceptions import STFailedResponseError, STUnexpectedResponseError
from parsons.utilities import check_env
from parsons.utilities.api_connector import APIConnector, _JsonType

if TYPE_CHECKING:
from collections.abc import Mapping

import requests

logger = logging.getLogger(__name__)

ParamsType = _JsonType | np.int64


class Metadata(TypedDict):
total_count: int
limit: int
offset: int


class SolidarityTechBase:
"""Base class for interacting with the SolidarityTech API."""

def __init__(
self, api_token: str | None = None, *, session: requests.Session | None = None
) -> None:
"""
Instantiate the SolidarityTech class.

Args:
api_token:
A valid Bearer token for authorization.
Not required if the `SOLIDARITY_TECH_BEARER_KEY` env variable is set.
session:
A custom :class:`requests.Session` instance for advanced configuration.
Providing your own :class:`~requests.Session` will bypass
built-in rate limiting, so you will need to provide your own solution.

"""
self.api_token = cast("str", check_env.check("SOLIDARITY_TECH_BEARER_KEY", api_token))
self.headers = CaseInsensitiveDict({"authorization": f"Bearer {self.api_token}"})
self.api_url = "https://api.solidarity.tech/v1/"
self.api = APIConnector(
uri=self.api_url,
headers=self.headers,
ratelimiter=requests_ratelimiter.Limiter(
pyrate_limiter.Rate(60, pyrate_limiter.Duration.SECOND * 30)
)
if not session
else None,
session=session,
)

def _get_resources(self, endpoint: str, **kwargs) -> requests.Response:
"""
Process parameters and handle GET requests for lists of resources.

If provided as keyword args, ``limit``, ``cursor``, ``offset``, ``since``, and ``include_count``
will be added to params, prefaced with an underscore, and removed from kwargs.
If the ``params`` kwarg contains pairs with a value of None, they will be removed from ``params``.

Args:
endpoint:
The url request string.
If ``url`` is a relative URL,
it will be joined with the ``uri`` of the :class:`parsons.utilities.APIConnector`.
If ``url`` is an absolute URL,
it will be used as is.
**kwargs:
Additional parameters to pass to :meth:`parsons.utilities.APIConnector.request`.

Raises:
KeyError:
If one of the previously-mentioned parameters is provided as
a discrete kwarg AND via the ``params`` kwarg.

Returns:
The response from the API.

"""
since = kwargs.get("since")
if isinstance(since, datetime):
kwargs["since"] = int(since.timestamp())

param_mapping = {
"limit": "_limit",
"cursor": "_cursor",
"offset": "_offset",
"since": "_since",
"include_count": "_include_count",
}
params: dict[str, ParamsType] = {}
for key, value in param_mapping.items():
if key in kwargs:
params[value] = kwargs[key]
del kwargs[key]

if "params" in kwargs:
for key, value in kwargs.get("params", {}).items():
if key in params:
err_msg = f"Request param '{key}' already exists."
raise KeyError(err_msg)
if value is None:
continue
params[key] = value

if params:
kwargs["params"] = params

logger.debug("Processing GET request at endpoint: %s", endpoint, extra=params)
return self.api.request(url=endpoint, req_type="GET", **kwargs)

def _get_single_resource(self, endpoint: str, resource_id: int, **kwargs) -> requests.Response:
"""Handle GET requests for single resources."""
complete_endpoint = f"{endpoint}/{resource_id}"
logger.debug("Processing GET request at endpoint: %s", complete_endpoint)
return self.api.request(url=complete_endpoint, req_type="GET", **kwargs)

def _post_request(
self,
endpoint: str,
payload: Mapping[str, _JsonType] | None = None,
**kwargs,
) -> requests.Response:
"""Handle POST requests."""
logger.debug("Processing POST request at endpoint: %s", endpoint, extra=payload)
return self.api.request(url=endpoint, req_type="POST", json=payload, **kwargs)

def _put_request(
self,
endpoint: str,
resource_id: int,
payload: Mapping[str, _JsonType] | None = None,
**kwargs,
) -> requests.Response:
"""Handle PUT requests."""
complete_endpoint = f"{endpoint}/{resource_id}"
logger.debug("Processing PUT request at endpoint: %s", complete_endpoint, extra=payload)
return self.api.request(url=complete_endpoint, req_type="PUT", json=payload, **kwargs)

def _del_request(self, endpoint: str, resource_id: int | str, **kwargs) -> requests.Response:
"""Handle DEL requests."""
complete_endpoint = f"{endpoint}/{resource_id}"
logger.debug("Processing DEL request at endpoint: %s", complete_endpoint)
return self.api.request(url=complete_endpoint, req_type="DELETE", **kwargs)

def _handle_status_codes(
self, res: requests.Response, codes: dict[int, tuple[bool, str]]
) -> bool:
"""
Handle status codes.

Args:
res: The response object.
codes: Expected status codes and their corresponding pass/fail status and descriptive messages.

Raises:
:class:`STFailedResponseError`: If the operation fails with a known error code.
:class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code.

Returns:
bool: True if the status code indicates success, False otherwise.

"""
if res.status_code in codes:
success = codes[res.status_code][0]
result_message = codes[res.status_code][1]
if success is True:
logger.debug(result_message, extra={"status_code": res.status_code})
return success
raise STFailedResponseError(result_message, response=res)

raise STUnexpectedResponseError(response=res)

def _add_if_field_not_empty(
self, receiving_dict: dict, key: str, value: Any | None, *, overwrite: bool = False
) -> dict:
"""
Add a key/value pair to a dictionary if the value is not None.

Args:
receiving_dict:
The dictionary to add the key/value pair to.
key:
The key to add.
value:
The value to add.
overwrite:
Whether to overwrite the value if the key already exists.

Raises:
KeyError: If the key already exists in the dictionary and overwrite is not True.

Returns:
The updated dictionary.

"""
if overwrite is not True and key in receiving_dict:
err_msg = f"'{key}' already exists."
raise KeyError(err_msg)
if value:
receiving_dict[key] = value.value if isinstance(value, Enum) else value
logger.debug(
"Added '%s' with value '%s' to payload or parameters dictionary", key, value
)
else:
logger.debug(
"Skipping adding '%s' to payload or parameters dictionary as value is None",
key,
)
return receiving_dict
44 changes: 44 additions & 0 deletions parsons/solidarity_tech/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

from enum import Enum


class AttendanceStatus(Enum):
YES = "yes"
NO = "no"
MAYBE = "maybe"
WAITLISTED = "waitlisted"


class EventType(Enum):
VIRTUAL = "virtual"
IN_PERSON = "in_person"
HYBRID = "hybrid"


class FieldType(Enum):
INPUT = "input"
TEXT_AREA = "textarea"
NUMBER = "number"
DATE = "date"
CHECKBOX = "checkbox"
SELECT = "select"
RADIOS = "radios"
CHECKBOXES = "checkboxes"


class InviteType(Enum):
SMS = "sms"
EMAIL = "email"


class ScopeType(Enum):
ORGANIZATION = "Organization"
CHAPTER = "Chapter"


class InteractionType(Enum):
IN_PERSON = "in_person"
CALL = "call"
TEXT = "text"
EMAIL = "email"
Loading
Loading