Repository: python-social-auth/social-core
Pull request #986 (2025-01-10 06:34:10)

Type Annotate the *heck* out of social-core
## Proposed changes

This is implementing the type checking I suggested in #949. 

## Types of changes

Please check the type of change your PR introduces:

- [ ] Release (new release request)
- [ ] Bugfix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [x] Code style update (PEP8, lint, formatting, renaming, etc)
- [x] Refactoring (no functional changes, no api changes)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [x] Build related changes (build process, tests runner, etc)
- [x] Other (please describe):

## Checklist

_Put an `x` in the boxes that apply. You can also fill these out after creating
the PR. If you're unsure about any of them, don't hesitate to ask. We're here to
help! This is simply a reminder of what we are going to look for before merging
your code._

- [x] Lint and unit tests pass locally with my changes
- [x] I have added tests that prove my fix is effective or that my feature works
- [ ] I have added documentation to https://github.com/python-social-auth/social-docs

## Other information

This touches most of social-core, and entails some compromises due to overrides that pyright really does not like. These can be broken down into a few categories:

* cases where a value can be `None` but is being treated as not, because it's set outside of the scope of the method. In these cases, I have added `assert <var>` statements to tell the type checker that the value is non-null; this code would have raised an exception at this point anyway
* method overrides that are incompatible with the base class. `get_user_id()` and `get_user_details()` and the like are particularly egregious in this regard; I have added type:ignore comments in these cases.
* Incorrect types in library values. **These probably bugs**. Look for `TODO` comments on these; I didn't try fix them here out of an abundance of caution, since I don't use those authentication providers.
* the Model mixins all used `get()` methods, which actually led to type confusion, since sometimes more than one was mixed in. I renamed `get()` on `Nonce` to `get_nonce()` and on `Association` to `get_association()` in those cases.
* The signature types are definitely wrong somehow; `public_key = RSAAlgorithm.from_jwk(...)` is _not_ returning a public key, it's returning an allowed keys type, at least with modern pyca-cryptography; it is probably wrong, but it's working. type:ignore'd
* Annotated several return types that were mis-inferred. Often pyright would infer a literal return type, because pyright is overly picky.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) Report
Attention: Patch coverage is `78.07018%` with `25 lines` in your changes missing coverage. Please review.
> Project coverage is 77.88%. Comparing base [(`326d4b3`)](https://app.codecov.io/gh/python-social-auth/social-core/commit/326d4b3c4aa69e92c19b472b437dd718c5ee5bd8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) to head [(`50b6990`)](https://app.codecov.io/gh/python-social-auth/social-core/commit/50b6990b077c0a60641102208df9049a95596d0e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth).
> Report is 35 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) | Patch % | Lines |
|---|---|---|
| [social\_core/backends/apple.py](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&filepath=social_core%2Fbackends%2Fapple.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth#diff-c29jaWFsX2NvcmUvYmFja2VuZHMvYXBwbGUucHk=) | 50.00% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) |
| [social\_core/backends/mediawiki.py](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&filepath=social_core%2Fbackends%2Fmediawiki.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth#diff-c29jaWFsX2NvcmUvYmFja2VuZHMvbWVkaWF3aWtpLnB5) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) |
| [social\_core/backends/uffd.py](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&filepath=social_core%2Fbackends%2Fuffd.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth#diff-c29jaWFsX2NvcmUvYmFja2VuZHMvdWZmZC5weQ==) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) |
| [social\_core/backends/oauth.py](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&filepath=social_core%2Fbackends%2Foauth.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth#diff-c29jaWFsX2NvcmUvYmFja2VuZHMvb2F1dGgucHk=) | 86.66% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) |
| [social\_core/storage.py](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&filepath=social_core%2Fstorage.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth#diff-c29jaWFsX2NvcmUvc3RvcmFnZS5weQ==) | 75.00% | [0 Missing and 2 partials :warning: ](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) |
| [social\_core/tests/backends/test\_saml.py](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&filepath=social_core%2Ftests%2Fbackends%2Ftest_saml.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth#diff-c29jaWFsX2NvcmUvdGVzdHMvYmFja2VuZHMvdGVzdF9zYW1sLnB5) | 66.66% | [2 Missing :warning: ](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) |
| [social\_core/tests/test\_exceptions.py](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&filepath=social_core%2Ftests%2Ftest_exceptions.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth#diff-c29jaWFsX2NvcmUvdGVzdHMvdGVzdF9leGNlcHRpb25zLnB5) | 0.00% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) |
| [social\_core/backends/auth0.py](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&filepath=social_core%2Fbackends%2Fauth0.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth#diff-c29jaWFsX2NvcmUvYmFja2VuZHMvYXV0aDAucHk=) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) |
| [social\_core/backends/bitbucket.py](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&filepath=social_core%2Fbackends%2Fbitbucket.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth#diff-c29jaWFsX2NvcmUvYmFja2VuZHMvYml0YnVja2V0LnB5) | 66.66% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) |
| [social\_core/backends/facebook.py](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&filepath=social_core%2Fbackends%2Ffacebook.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth#diff-c29jaWFsX2NvcmUvYmFja2VuZHMvZmFjZWJvb2sucHk=) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) |
| ... and [5 more](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) | |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #986      +/-   ##
==========================================
- Coverage   77.88%   77.88%   -0.01%     
==========================================
  Files         347      347              
  Lines       10669    10714      +45     
  Branches      504      456      -48     
==========================================
+ Hits         8310     8345      +35     
- Misses       2200     2208       +8     
- Partials      159      161       +2     
```

| [Flag](https://app.codecov.io/gh/python-social-auth/social-core/pull/986/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/python-social-auth/social-core/pull/986/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth) | `77.88% <78.07%> (-0.01%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/python-social-auth/social-core/pull/986?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-social-auth).

offbyone: No... I just accidentally left in a test assertion. Fixing...
offbyone: > Can you please constantly use future typing with `Type | None` syntax instead of mixing it with `Optional[Type]` in some files?

Happy to; that was accidental in the early steps, where I forgot that we had Python new enough to use it. I'll revise ASAP.

Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 6ec6d3f8..b5fdf093 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -119,6 +119,8 @@ Homepage = "https://github.com/python-social-auth/social-core"
 [project.optional-dependencies]
 saml = [
    "python3-saml>=1.5.0",
+   # pinned to 5.2 until a new wheel of xmlsec is released
+   "lxml~=5.2.1",
 ]
 azuread = [
     "cryptography>=2.1.1",
@@ -140,6 +142,7 @@ dev = [
     "pytest-cov>=2.7.1",
     # pinned because of https://github.com/gabrielfalcao/HTTPretty/issues/484
     "urllib3~=2.2.0",
+    "pyright>=1.1.391",
 ]
 
 [build-system]
@@ -155,4 +158,6 @@ dev = [
     "pytest-cov>=2.7.1",
     # pinned because of https://github.com/gabrielfalcao/HTTPretty/issues/484
     "urllib3~=2.2.0",
+    "pyright>=1.1.391",
+    "pytest-xdist>=3.6.1",
 ]
diff --git a/pyrightconfig.json b/pyrightconfig.json
new file mode 100644
index 00000000..00901b39
--- /dev/null
+++ b/pyrightconfig.json
@@ -0,0 +1,4 @@
+{
+    "reportOptionalMemberAccess": "none",
+    "reportPossiblyUnboundVariable": "none"
+}
diff --git a/social_core/actions.py b/social_core/actions.py
index e9a0d0af..289da925 100644
--- a/social_core/actions.py
+++ b/social_core/actions.py
@@ -101,6 +101,8 @@ def do_complete(backend, login, user=None, redirect_name="next", *args, **kwargs
     else:
         url = setting_url(backend, "LOGIN_ERROR_URL", "LOGIN_URL")
 
+    assert url, "By this point URL has to have been set"
+
     if redirect_value and redirect_value != url:
         redirect_value = quote(redirect_value)
         url += ("&" if "?" in url else "?") + f"{redirect_name}={redirect_value}"
diff --git a/social_core/backends/apple.py b/social_core/backends/apple.py
index 6d58bc2a..297a7898 100644
--- a/social_core/backends/apple.py
+++ b/social_core/backends/apple.py
@@ -21,8 +21,11 @@ Settings:
                                                    login
 """
 
+from __future__ import annotations
+
 import json
 import time
+from typing import TYPE_CHECKING
 
 import jwt
 from jwt.algorithms import RSAAlgorithm
@@ -31,6 +34,9 @@ from jwt.exceptions import PyJWTError
 from social_core.backends.oauth import BaseOAuth2
 from social_core.exceptions import AuthFailed
 
+if TYPE_CHECKING:
+    from jwt.types import JWKDict
+
 
 class AppleIdAuth(BaseOAuth2):
     name = "apple-id"
@@ -96,7 +102,7 @@ class AppleIdAuth(BaseOAuth2):
         client_secret = self.generate_client_secret()
         return client_id, client_secret
 
-    def get_apple_jwk(self, kid=None):
+    def get_apple_jwk(self, kid=None) -> str | JWKDict:
         """
         Return requested Apple public key or all available.
         """
@@ -107,7 +113,9 @@ class AppleIdAuth(BaseOAuth2):
 
         if kid:
             return json.dumps(next(key for key in keys if key["kid"] == kid))
-        return (json.dumps(key) for key in keys)
+        # TODO: this should actually return a JWKDict; the caller expects it.
+        # I suspect this code path is never hit in practice
+        return (json.dumps(key) for key in keys)  # type:ignore[reportReturnType]
 
     def decode_id_token(self, id_token):
         """
@@ -120,9 +128,10 @@ class AppleIdAuth(BaseOAuth2):
         try:
             kid = jwt.get_unverified_header(id_token).get("kid")
             public_key = RSAAlgorithm.from_jwk(self.get_apple_jwk(kid))
+
             decoded = jwt.decode(
                 id_token,
-                key=public_key,
+                key=public_key,  # type: ignore[reportArgumentType]
                 audience=self.get_audience(),
                 algorithms=["RS256"],
             )
diff --git a/social_core/backends/auth0.py b/social_core/backends/auth0.py
index c2d18813..74a4dc9c 100644
--- a/social_core/backends/auth0.py
+++ b/social_core/backends/auth0.py
@@ -61,6 +61,7 @@ class Auth0OAuth2(BaseOAuth2):
             else:
                 break
         else:
+            assert signature_error is not None
             # raise last esception found during iteration
             raise signature_error
 
diff --git a/social_core/backends/azuread_b2c.py b/social_core/backends/azuread_b2c.py
index 0fd27be8..bd4ad2a3 100644
--- a/social_core/backends/azuread_b2c.py
+++ b/social_core/backends/azuread_b2c.py
@@ -128,7 +128,10 @@ class AzureADB2COAuth2(AzureADOAuth2):
         Builds a PEM formatted key string from a JWT public key dict.
         """
         pub_key = RSAAlgorithm.from_jwk(json.dumps(key_json_dict))
-        return pub_key.public_bytes(
+
+        # TODO: clarify the types of this; JWKs can apparently include both public and private,
+        # but this code assumes public.
+        return pub_key.public_bytes(  # type: ignore[reportAttributeAccessIssue]
             encoding=serialization.Encoding.PEM,
             format=serialization.PublicFormat.SubjectPublicKeyInfo,
         )
diff --git a/social_core/backends/azuread_tenant.py b/social_core/backends/azuread_tenant.py
index 1e55edb3..ba9ec666 100644
--- a/social_core/backends/azuread_tenant.py
+++ b/social_core/backends/azuread_tenant.py
@@ -97,7 +97,7 @@ class AzureADTenantOAuth2(AzureADOAuth2):
 
             return jwt_decode(
                 id_token,
-                key=certificate.public_key(),
+                key=certificate.public_key(),  # type: ignore[reportArgumentType]
                 algorithms=["RS256"],
                 audience=self.setting("KEY"),
             )
diff --git a/social_core/backends/base.py b/social_core/backends/base.py
index 4f312f2f..eee1b5da 100644
--- a/social_core/backends/base.py
+++ b/social_core/backends/base.py
@@ -1,4 +1,5 @@
 import time
+from typing import Any
 
 from requests import ConnectionError, request
 
@@ -118,7 +119,9 @@ class BaseAuth:
             out.update(result)
         return out
 
-    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+    def extra_data(
+        self, user, uid, response, details=None, *args, **kwargs
+    ) -> dict[str, Any]:
         """Return default extra data to store in extra_data field"""
         data = {
             # store the last time authentication toke place
@@ -137,9 +140,9 @@ class BaseAuth:
             size = len(entry)
             if size >= 1 and size <= 3:
                 if size == 3:
-                    name, alias, discard = entry
+                    name, alias, discard = entry  # type: ignore[reportAssignmentType]
                 elif size == 2:
-                    (name, alias), discard = entry, False
+                    (name, alias), discard = entry, False  # type: ignore[reportAssignmentType]
                 elif size == 1:
                     name = alias = entry[0]
                     discard = False
@@ -213,7 +216,7 @@ class BaseAuth:
         )
         return extra_arguments
 
-    def uses_redirect(self):
+    def uses_redirect(self) -> bool:
         """Return True if this provider uses redirect url method,
         otherwise return false."""
         return True
diff --git a/social_core/backends/bitbucket.py b/social_core/backends/bitbucket.py
index bc65759a..ca9ef9f3 100644
--- a/social_core/backends/bitbucket.py
+++ b/social_core/backends/bitbucket.py
@@ -12,13 +12,13 @@ class BitbucketOAuthBase:
 
     def get_user_id(self, details, response):
         id_key = self.ID_KEY
-        if self.setting("USERNAME_AS_ID", False):
+        if self.setting("USERNAME_AS_ID", False):  # type: ignore[reportAttributeAccessIssue]
             id_key = "username"
         return response.get(id_key)
 
     def get_user_details(self, response):
         """Return user details from Bitbucket account"""
-        fullname, first_name, last_name = self.get_user_names(response["display_name"])
+        fullname, first_name, last_name = self.get_user_names(response["display_name"])  # type: ignore[reportAttributeAccessIssue]
 
         return {
             "username": response.get("username", ""),
@@ -38,7 +38,7 @@ class BitbucketOAuthBase:
             if address["is_primary"]:
                 break
 
-        if self.setting("VERIFIED_EMAILS_ONLY", False) and not address["is_confirmed"]:
+        if self.setting("VERIFIED_EMAILS_ONLY", False) and not address["is_confirmed"]:  # type: ignore[reportAttributeAccessIssue]
             raise AuthForbidden(self, "Bitbucket account has no verified email")
 
         user = self._get_user(access_token)
diff --git a/social_core/backends/clef.py b/social_core/backends/clef.py
index f6e792d0..764a8003 100644
--- a/social_core/backends/clef.py
+++ b/social_core/backends/clef.py
@@ -24,7 +24,7 @@ class ClefOAuth2(BaseOAuth2):
         params["redirect_url"] = params.pop("redirect_uri")
         return params
 
-    def get_user_id(self, response, details):
+    def get_user_id(self, response, details):  # type: ignore[reportIncompatibleMethodOverride]
         return details.get("info").get("id")
 
     def get_user_details(self, response):
diff --git a/social_core/backends/discogs.py b/social_core/backends/discogs.py
index dad46eef..93de5e1e 100644
--- a/social_core/backends/discogs.py
+++ b/social_core/backends/discogs.py
@@ -19,7 +19,7 @@ class DiscogsOAuth1(BaseOAuth1):
     REQUEST_TOKEN_URL = "https://api.discogs.com/oauth/request_token"
     ACCESS_TOKEN_URL = "https://api.discogs.com/oauth/access_token"
 
-    def get_user_details(self, user_data):
+    def get_user_details(self, user_data):  # type: ignore[reportIncompatibleMethodOverride]
         return {
             "username": user_data["username"],
             "id": user_data["id"],
diff --git a/social_core/backends/discourse.py b/social_core/backends/discourse.py
index 62dd91b0..8ebe99e2 100644
--- a/social_core/backends/discourse.py
+++ b/social_core/backends/discourse.py
@@ -53,7 +53,7 @@ class DiscourseAuth(BaseAuth):
         self.strategy.storage.nonce.use(self.setting("SERVER_URL"), time.time(), nonce)
 
     def get_nonce(self, nonce):
-        return self.strategy.storage.nonce.get(self.setting("SERVER_URL"), nonce)
+        return self.strategy.storage.nonce.get_nonce(self.setting("SERVER_URL"), nonce)
 
     def delete_nonce(self, nonce):
         self.strategy.storage.nonce.delete(nonce)
diff --git a/social_core/backends/facebook.py b/social_core/backends/facebook.py
index cbba9b76..9a628027 100644
--- a/social_core/backends/facebook.py
+++ b/social_core/backends/facebook.py
@@ -191,6 +191,7 @@ class FacebookAppOAuth2(FacebookOAuth2):
         if "signed_request" in self.data:
             key, secret = self.get_key_and_secret()
             response = self.load_signed_request(self.data["signed_request"])
+            assert response, "Missing signed_request response"
             if "user_id" not in response and "oauth_token" not in response:
                 raise AuthException(self)
 
diff --git a/social_core/backends/five_hundred_px.py b/social_core/backends/five_hundred_px.py
index 317701bc..dcc6426f 100644
--- a/social_core/backends/five_hundred_px.py
+++ b/social_core/backends/five_hundred_px.py
@@ -14,7 +14,7 @@ class FiveHundredPxOAuth(BaseOAuth1):
     REQUEST_TOKEN_URL = "https://api.500px.com/v1/oauth/request_token"
     ACCESS_TOKEN_URL = "https://api.500px.com/v1/oauth/access_token"
 
-    def get_user_details(self, user):
+    def get_user_details(self, user):  # type: ignore[reportIncompatibleMethodOverride]
         """Return user details from 500px account"""
         fullname, first_name, last_name = self.get_user_names(user.get("fullname"))
         return {
diff --git a/social_core/backends/gae.py b/social_core/backends/gae.py
index 1fc3c1a5..35436d75 100644
--- a/social_core/backends/gae.py
+++ b/social_core/backends/gae.py
@@ -2,7 +2,7 @@
 Google App Engine support using User API
 """
 
-from google.appengine.api import users
+from google.appengine.api import users  # type: ignore[reportMissingImports]
 
 from ..exceptions import AuthException
 from .base import BaseAuth
diff --git a/social_core/backends/github.py b/social_core/backends/github.py
index 14bb7931..521678ec 100644
--- a/social_core/backends/github.py
+++ b/social_core/backends/github.py
@@ -30,7 +30,7 @@ class GithubOAuth2(BaseOAuth2):
         ("refresh_token", "refresh_token"),
     ]
 
-    def api_url(self):
+    def api_url(self) -> str:
         return self.API_URL
 
     def get_user_details(self, response):
diff --git a/social_core/backends/github_enterprise.py b/social_core/backends/github_enterprise.py
index 907177a7..dd1a3e11 100644
--- a/social_core/backends/github_enterprise.py
+++ b/social_core/backends/github_enterprise.py
@@ -11,7 +11,7 @@ from .github import GithubOAuth2, GithubOrganizationOAuth2, GithubTeamOAuth2
 
 class GithubEnterpriseMixin:
     def api_url(self):
-        return append_slash(self.setting("API_URL"))
+        return append_slash(self.setting("API_URL"))  # type: ignore[reportAttributeAccessIssue]
 
     def authorization_url(self):
         return self._url("login/oauth/authorize")
@@ -20,7 +20,7 @@ class GithubEnterpriseMixin:
         return self._url("login/oauth/access_token")
 
     def _url(self, path):
-        return urljoin(append_slash(self.setting("URL")), path)
+        return urljoin(append_slash(self.setting("URL")), path)  # type: ignore[reportAttributeAccessIssue]
 
 
 class GithubEnterpriseOAuth2(GithubEnterpriseMixin, GithubOAuth2):
diff --git a/social_core/backends/google.py b/social_core/backends/google.py
index d73674be..c763a0f8 100644
--- a/social_core/backends/google.py
+++ b/social_core/backends/google.py
@@ -3,12 +3,14 @@ Google OpenId, OAuth2, OAuth1, Google+ Sign-in backends, docs at:
     https://python-social-auth.readthedocs.io/en/latest/backends/google.html
 """
 
+from social_core.backends.base import BaseAuth
+
 from ..exceptions import AuthMissingParameter
 from ..utils import handle_http_errors
 from .oauth import BaseOAuth1, BaseOAuth2
 
 
-class BaseGoogleAuth:
+class BaseGoogleAuth(BaseAuth):
     def get_user_id(self, details, response):
         """Use google email as unique id"""
         if self.setting("USE_UNIQUE_USER_ID", False):
diff --git a/social_core/backends/khanacademy.py b/social_core/backends/khanacademy.py
index 16c65cf9..120dca4d 100644
--- a/social_core/backends/khanacademy.py
+++ b/social_core/backends/khanacademy.py
@@ -44,13 +44,13 @@ class BrowserBasedOAuth1(BaseOAuth1):
             callback_uri=self.get_redirect_uri(state),
             signature_method=SIGNATURE_HMAC,
             signature_type=SIGNATURE_TYPE_QUERY,
-            decoding=None,
+            decoding=None,  # type: ignore[reportArgumentType]
         )
         url = self.REQUEST_TOKEN_URL + "?" + urlencode(params)
         url, _, _ = auth.client.sign(url)
         return url
 
-    def oauth_auth(self, token=None, oauth_verifier=None):
+    def oauth_auth(self, token=None, oauth_verifier=None):  # type: ignore[reportIncompatibleMethodOverride]
         key, secret = self.get_key_and_secret()
         oauth_verifier = oauth_verifier or self.data.get("oauth_verifier")
         token = token or {}
@@ -64,7 +64,7 @@ class BrowserBasedOAuth1(BaseOAuth1):
             verifier=oauth_verifier,
             signature_method=SIGNATURE_HMAC,
             signature_type=SIGNATURE_TYPE_QUERY,
-            decoding=None,
+            decoding=None,  # type: ignore[reportArgumentType]
         )
 
 
diff --git a/social_core/backends/mediawiki.py b/social_core/backends/mediawiki.py
index f6dd1a66..f027f345 100644
--- a/social_core/backends/mediawiki.py
+++ b/social_core/backends/mediawiki.py
@@ -63,8 +63,7 @@ class MediaWiki(BaseOAuth1):
         """
         if not isinstance(token, dict):
             token = parse_qs(token)
-
-        oauth_token = token.get(self.OAUTH_TOKEN_PARAMETER_NAME)[0]
+        oauth_token = token.get(self.OAUTH_TOKEN_PARAMETER_NAME)[0]  # type: ignore[reportOptionalSubscript]
         state = self.get_or_create_state()
         base_url = self.setting("MEDIAWIKI_URL")
 
@@ -93,8 +92,8 @@ class MediaWiki(BaseOAuth1):
         if response.content.decode().startswith("Error"):
             raise AuthException(self, response.content.decode())
         credentials = parse_qs(response.content)
-        oauth_token_key = credentials.get(b"oauth_token")[0]
-        oauth_token_secret = credentials.get(b"oauth_token_secret")[0]
+        oauth_token_key = credentials.get(b"oauth_token")[0]  # type: ignore[reportOptionalSubscript]
+        oauth_token_secret = credentials.get(b"oauth_token_secret")[0]  # type: ignore[reportOptionalSubscript]
         oauth_token_key = oauth_token_key.decode()
         oauth_token_secret = oauth_token_secret.decode()
 
diff --git a/social_core/backends/oauth.py b/social_core/backends/oauth.py
index f139186f..52d6d3de 100644
--- a/social_core/backends/oauth.py
+++ b/social_core/backends/oauth.py
@@ -1,5 +1,8 @@
+from __future__ import annotations
+
 import base64
 import hashlib
+from typing import TYPE_CHECKING, Any
 from urllib.parse import urlencode
 
 from oauthlib.oauth1 import SIGNATURE_TYPE_AUTH_HEADER
@@ -23,6 +26,9 @@ from ..utils import (
 )
 from .base import BaseAuth
 
+if TYPE_CHECKING:
+    from collections.abc import Mapping, MutableMapping
+
 
 class OAuthAuth(BaseAuth):
     """OAuth authentication backend base class.
@@ -124,23 +130,23 @@ class OAuthAuth(BaseAuth):
             param[self.SCOPE_PARAMETER_NAME] = self.SCOPE_SEPARATOR.join(scope)
         return param
 
-    def user_data(self, access_token, *args, **kwargs):
+    def user_data(self, access_token, *args, **kwargs) -> dict[str, Any] | None:
         """Loads user data from service. Implement in subclass"""
         return {}
 
-    def authorization_url(self):
+    def authorization_url(self) -> str:
         return self.AUTHORIZATION_URL
 
-    def access_token_url(self):
+    def access_token_url(self) -> str:
         return self.ACCESS_TOKEN_URL
 
-    def revoke_token_url(self, token, uid):
+    def revoke_token_url(self, token, uid) -> str | None:
         return self.REVOKE_TOKEN_URL
 
-    def revoke_token_params(self, token, uid):
+    def revoke_token_params(self, token, uid) -> dict[str, Any]:
         return {}
 
-    def revoke_token_headers(self, token, uid):
+    def revoke_token_headers(self, token, uid) -> dict[str, Any]:
         return {}
 
     def process_revoke_token_response(self, response):
@@ -178,7 +184,7 @@ class BaseOAuth1(OAuthAuth):
     REDIRECT_URI_PARAMETER_NAME = "redirect_uri"
     UNATHORIZED_TOKEN_SUFIX = "unauthorized_token_name"
 
-    def auth_url(self):
+    def auth_url(self) -> str | bytes | None:
         """Return redirect url"""
         token = self.set_unauthorized_token()
         return self.oauth_authorization_request(token)
@@ -334,10 +340,10 @@ class BaseOAuth2(OAuthAuth):
     STATE_PARAMETER = True
     USE_BASIC_AUTH = False
 
-    def use_basic_auth(self):
+    def use_basic_auth(self) -> bool:
         return self.USE_BASIC_AUTH
 
-    def auth_params(self, state=None):
+    def auth_params(self, state=None) -> MutableMapping[str, Any]:
         client_id, client_secret = self.get_key_and_secret()
         params = {"client_id": client_id, "redirect_uri": self.get_redirect_uri(state)}
         if self.STATE_PARAMETER and state:
@@ -346,7 +352,7 @@ class BaseOAuth2(OAuthAuth):
             params["response_type"] = self.RESPONSE_TYPE
         return params
 
-    def auth_url(self):
+    def auth_url(self) -> str | bytes | None:
         """Return redirect url"""
         state = self.get_or_create_state()
         params = self.auth_params(state)
@@ -380,7 +386,7 @@ class BaseOAuth2(OAuthAuth):
             return self.get_key_and_secret()
         return None
 
-    def auth_headers(self):
+    def auth_headers(self) -> Mapping[str, str | bytes]:
         return {
             "Content-Type": "application/x-www-form-urlencoded",
             "Accept": "application/json",
diff --git a/social_core/backends/okta.py b/social_core/backends/okta.py
index 0aee79b6..96b74be6 100644
--- a/social_core/backends/okta.py
+++ b/social_core/backends/okta.py
@@ -9,7 +9,7 @@ from ..utils import append_slash
 from .oauth import BaseOAuth2
 
 
-class OktaMixin:
+class OktaMixin(BaseOAuth2):
     def api_url(self):
         return append_slash(self.setting("API_URL"))
 
diff --git a/social_core/backends/open_id.py b/social_core/backends/open_id.py
index ed694abd..ba0a28eb 100644
--- a/social_core/backends/open_id.py
+++ b/social_core/backends/open_id.py
@@ -63,7 +63,8 @@ class OpenIdAuth(BaseAuth):
 
         # Use Simple Registration attributes if provided
         if sreg_names:
-            resp = sreg.SRegResponse.fromSuccessResponse(response)
+            # pyright does not detect the classmethod correctly
+            resp = sreg.SRegResponse.fromSuccessResponse(response)  # type: ignore[reportCallIssue]
             if resp:
                 values.update(
                     (alias, resp.get(name) or "") for name, alias in sreg_names
@@ -71,11 +72,13 @@ class OpenIdAuth(BaseAuth):
 
         # Use Attribute Exchange attributes if provided
         if ax_names:
-            resp = ax.FetchResponse.fromSuccessResponse(response)
+            # pyright does not detect the classmethod correctly
+            resp = ax.FetchResponse.fromSuccessResponse(response)  # type: ignore[reportCallIssue]
             if resp:
                 for src, alias in ax_names:
                     name = alias.replace("old_", "")
                     values[name] = resp.getSingle(src, "") or values.get(name)
+
         return values
 
     def get_user_details(self, response):
diff --git a/social_core/backends/open_id_connect.py b/social_core/backends/open_id_connect.py
index 34fbde3d..3c9108a1 100644
--- a/social_core/backends/open_id_connect.py
+++ b/social_core/backends/open_id_connect.py
@@ -141,7 +141,7 @@ class OpenIdConnectAuth(BaseOAuth2):
 
     def get_nonce(self, nonce):
         try:
-            return self.strategy.storage.association.get(
+            return self.strategy.storage.association.get_association(
                 server_url=self.authorization_url(), handle=nonce
             )[0]
         except IndexError:
@@ -184,7 +184,7 @@ class OpenIdConnectAuth(BaseOAuth2):
                 # In case the key id is not found in the cached keys, just
                 # reload the JWKS keys. Ideally this should be done by
                 # invalidating the cache.
-                self.get_jwks_keys.invalidate()
+                self.get_jwks_keys.invalidate()  # type: ignore[reportFunctionMemberAccess]
                 keys = self.get_jwks_keys()
 
         for key in keys:
diff --git a/social_core/backends/ping.py b/social_core/backends/ping.py
index 5d6b0d23..5ef10a1b 100644
--- a/social_core/backends/ping.py
+++ b/social_core/backends/ping.py
@@ -3,7 +3,7 @@ Ping Auth OpenID Connect backend
 """
 
 from jose import jwk, jwt
-from jose.jwt import ExpiredSignatureError, JWTClaimsError, JWTError
+from jose.exceptions import ExpiredSignatureError, JWTClaimsError, JWTError
 from jose.utils import base64url_decode
 
 from social_core.backends.open_id_connect import OpenIdConnectAuth
diff --git a/social_core/backends/qiita.py b/social_core/backends/qiita.py
index 1202a372..cc14d56e 100644
--- a/social_core/backends/qiita.py
+++ b/social_core/backends/qiita.py
@@ -43,7 +43,9 @@ class QiitaOAuth2(BaseOAuth2):
         ("image_monthly_upload_remaining", "image_monthly_upload_remaining"),
     ]
 
-    def auth_complete_params(self, state=None):
+    # TODO: I am pretty sure this method returns the wrong type; it should
+    # return a dict
+    def auth_complete_params(self, state=None):  # type: ignore[reportIncompatibleMethodOverride]
         data = super().auth_complete_params(state)
         if "grant_type" in data:
             del data["grant_type"]
diff --git a/social_core/backends/rdio.py b/social_core/backends/rdio.py
index 8acf810c..71735756 100644
--- a/social_core/backends/rdio.py
+++ b/social_core/backends/rdio.py
@@ -47,7 +47,8 @@ class RdioOAuth1(BaseRdio, BaseOAuth1):
             "extras": "username,displayName,streamRegion",
         }
         request = self.oauth_request(access_token, RDIO_API, params, method="POST")
-        return self.get_json(request.url, method="POST", data=request.to_postdata())[
+        # TODO: I don't think to_postdata exists.
+        return self.get_json(request.url, method="POST", data=request.to_postdata())[  # type: ignore[reportAttributeAccessIssue]
             "result"
         ]
 
diff --git a/social_core/backends/saml.py b/social_core/backends/saml.py
index 3c18bb83..c1e93366 100644
--- a/social_core/backends/saml.py
+++ b/social_core/backends/saml.py
@@ -10,8 +10,10 @@ Terminology:
 
 import json
 
-from onelogin.saml2.auth import OneLogin_Saml2_Auth
-from onelogin.saml2.settings import OneLogin_Saml2_Settings
+from onelogin.saml2.auth import OneLogin_Saml2_Auth  # type: ignore reportMissingImports
+from onelogin.saml2.settings import (  # type: ignore reportMissingImports
+    OneLogin_Saml2_Settings,
+)
 
 from ..exceptions import AuthFailed, AuthMissingParameter
 from .base import BaseAuth
diff --git a/social_core/backends/scistarter.py b/social_core/backends/scistarter.py
index c6b91ac4..14b04e60 100644
--- a/social_core/backends/scistarter.py
+++ b/social_core/backends/scistarter.py
@@ -45,9 +45,11 @@ class SciStarterOAuth2(BaseOAuth2):
 
     def access_token(self, token):
         """Return request for access token value"""
+        # TODO: confirm if this should be OAuth2 or OAuth1; the `oauth_auth` method
+        # is for OAuth1, but this class inherits from OAuth2
         return self.get_querystring(
             self.access_token_url(),
-            auth=self.oauth_auth(token),
+            auth=self.oauth_auth(token),  # type: ignore[reportAttributeAccessIssue]
             method=self.ACCESS_TOKEN_METHOD,
         )
 
diff --git a/social_core/backends/shopify.py b/social_core/backends/shopify.py
index f5933306..abef6d8f 100644
--- a/social_core/backends/shopify.py
+++ b/social_core/backends/shopify.py
@@ -3,7 +3,7 @@ Shopify OAuth2 backend, docs at:
     https://python-social-auth.readthedocs.io/en/latest/backends/shopify.html
 """
 
-import imp
+import imp  # type: ignore[reportMissingImports]
 
 from ..exceptions import AuthCanceled, AuthFailed
 from ..utils import handle_http_errors
diff --git a/social_core/backends/twitch.py b/social_core/backends/twitch.py
index 9c47df5a..63a60175 100644
--- a/social_core/backends/twitch.py
+++ b/social_core/backends/twitch.py
@@ -25,6 +25,8 @@ class TwitchOpenIdConnect(OpenIdConnectAuth):
         return params
 
     def get_user_details(self, response):
+        assert self.id_token, "No id_token to parse"
+
         return {
             "username": self.id_token["preferred_username"],
             "email": self.id_token["email"],
diff --git a/social_core/backends/udata.py b/social_core/backends/udata.py
index 2c56a758..d1315f77 100644
--- a/social_core/backends/udata.py
+++ b/social_core/backends/udata.py
@@ -15,6 +15,7 @@ class UdataBaseOAuth2(BaseOAuth2):
     REDIRECT_STATE = False
     DEFAULT_SCOPE = ["default"]
     ACCESS_TOKEN_METHOD = "POST"
+    USER_DATA_URL = None
 
     def get_user_details(self, response):
         """Return user details from Udata account."""
diff --git a/social_core/backends/uffd.py b/social_core/backends/uffd.py
index 94c4ec08..3cf4543a 100644
--- a/social_core/backends/uffd.py
+++ b/social_core/backends/uffd.py
@@ -1,3 +1,4 @@
+from typing import Any
 from urllib.parse import urlencode
 
 from .oauth import BaseOAuth2
@@ -39,7 +40,8 @@ class UffdOAuth2(BaseOAuth2):
         """Loads user data from service"""
         url = self.userinfo_url() + "?" + urlencode({"access_token": access_token})
         try:
-            return self.get_json(url)
+            user_data: dict[str, Any] = self.get_json(url)
+            return user_data
         except ValueError:
             return None
 
diff --git a/social_core/backends/yahoo.py b/social_core/backends/yahoo.py
index 20c18da9..fba78026 100644
--- a/social_core/backends/yahoo.py
+++ b/social_core/backends/yahoo.py
@@ -72,7 +72,7 @@ class YahooOAuth2(BaseOAuth2):
         ("token_type", "token_type"),
     ]
 
-    def get_user_names(self, first_name, last_name):
+    def get_user_names(self, first_name, last_name):  # type: ignore[reportIncompatibleMethodOverride]
         if first_name or last_name:
             return f"{first_name} {last_name}", first_name, last_name
         return None, None, None
diff --git a/social_core/py.typed b/social_core/py.typed
new file mode 100644
index 00000000..e69de29b
diff --git a/social_core/storage.py b/social_core/storage.py
index 7116ca31..af466347 100644
--- a/social_core/storage.py
+++ b/social_core/storage.py
@@ -4,6 +4,7 @@ import base64
 import re
 import uuid
 import warnings
+from abc import abstractmethod
 from datetime import datetime, timedelta, timezone
 
 from openid.association import Association as OpenIdAssociation
@@ -23,6 +24,9 @@ class UserMixin:
     uid = None
     extra_data = None
 
+    @abstractmethod
+    def save(self): ...
+
     def get_backend(self, strategy):
         return strategy.get_backend_class(self.provider)
 
@@ -200,7 +204,7 @@ class NonceMixin:
         raise NotImplementedError("Implement in subclass")
 
     @classmethod
-    def get(cls, server_url, salt):
+    def get_nonce(cls, server_url, salt):
         """Retrieve a Nonce instance"""
         raise NotImplementedError("Implement in subclass")
 
@@ -226,7 +230,10 @@ class AssociationMixin:
         if handle is not None:
             kwargs["handle"] = handle
         return sorted(
-            ((assoc.id, cls.openid_association(assoc)) for assoc in cls.get(**kwargs)),
+            (
+                (assoc.id, cls.openid_association(assoc))
+                for assoc in cls.get_association(**kwargs)
+            ),
             key=lambda x: x[1].issued,
             reverse=True,
         )
@@ -250,7 +257,7 @@ class AssociationMixin:
         raise NotImplementedError("Implement in subclass")
 
     @classmethod
-    def get(cls, *args, **kwargs):
+    def get_association(cls, *args, **kwargs):
         """Get an Association instance"""
         raise NotImplementedError("Implement in subclass")
 
@@ -265,6 +272,9 @@ class CodeMixin:
     code = ""
     verified = False
 
+    @abstractmethod
+    def save(self): ...
+
     def verify(self):
         self.verified = True
         self.save()
@@ -289,7 +299,7 @@ class CodeMixin:
 
 class PartialMixin:
     token = ""
-    data = ""
+    data = {}
     next_step = ""
     backend = ""
 
diff --git a/social_core/utils.py b/social_core/utils.py
index 3186aac4..74742a10 100644
--- a/social_core/utils.py
+++ b/social_core/utils.py
@@ -275,7 +275,10 @@ class cache:
             cached_value = None
             if this.__class__ in self.cache:
                 last_updated, cached_value = self.cache[this.__class__]
-            if not cached_value or now - last_updated > self.ttl:
+
+            # ignoring this type issue is safe; if cached_value is returned, last_updated
+            # is also set, but the type checker doesn't know it.
+            if not cached_value or now - last_updated > self.ttl:  # type: ignore[reportOperatorIssue]
                 try:
                     cached_value = fn(this)
                     self.cache[this.__class__] = (now, cached_value)
@@ -285,7 +288,7 @@ class cache:
                         raise
             return cached_value
 
-        wrapped.invalidate = self._invalidate
+        wrapped.invalidate = self._invalidate  # type: ignore[reportFunctionMemberAccess]
         return wrapped
 
     def _invalidate(self):
diff --git a/tox.ini b/tox.ini
index 37311e7d..86b01f87 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,7 +4,7 @@
 # and then run "tox" from this directory.
 
 [tox]
-envlist = py39,py310,py311,py312,py313
+envlist = py39,py310,py311,py312,py313,py39-pyright,p313-pyright
 
 [testenv]
 passenv = *
@@ -12,3 +12,15 @@ deps =
   py{39,310,311,312,313}: -e .[dev,all]
 commands =
   pytest {posargs:-v --cov=social_core}
+
+[testenv:py39-pyright]
+deps =
+  -e .[dev,all]
+commands =
+ pyright
+
+[testenv:py313-pyright]
+deps =
+  -e .[dev,all]
+commands =
+ pyright


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index d85563bd..e9535f1c 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -3,6 +3,43 @@ name: Tests
 on: [push, pull_request]
 
 jobs:
+  types:
+    runs-on: ubuntu-22.04
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version:
+        - '3.9'
+        - '3.13'
+    env:
+      PYTHON_VERSION: ${{ matrix.python-version }}
+      PYTHONUNBUFFERED: 1
+
+    steps:
+    - uses: actions/checkout@v4
+      with:
+        persist-credentials: false
+
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v5
+      with:
+        python-version: ${{ matrix.python-version }}
+        cache: pip
+        cache-dependency-path: requirements*.txt
+
+    - name: Install System dependencies
+      run: |
+        sudo apt-get update
+        sudo apt-get install -qq -y --no-install-recommends libxmlsec1-dev swig
+
+    - name: Install Python dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install tox
+
+    - name: Type check with tox
+      run: tox -e "py${PYTHON_VERSION/\./}-pyright"
+
   test:
     runs-on: ubuntu-22.04
     strategy:
diff --git a/social_core/tests/backends/base.py b/social_core/tests/backends/base.py
index c64a70c1..286920ce 100644
--- a/social_core/tests/backends/base.py
+++ b/social_core/tests/backends/base.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import unittest
 
 import requests
diff --git a/social_core/tests/backends/legacy.py b/social_core/tests/backends/legacy.py
index 7453a3ae..41d5f789 100644
--- a/social_core/tests/backends/legacy.py
+++ b/social_core/tests/backends/legacy.py
@@ -24,21 +24,24 @@ class BaseLegacyTest(BaseBackendTest):
 
     def do_start(self):
         start_url = self.strategy.build_absolute_uri(self.backend.start().url)
+        complete_url = self.complete_url
+        assert complete_url, "Subclasses must set the complete_url attribute"
+
         HTTPretty.register_uri(
             HTTPretty.GET,
             start_url,
             status=200,
-            body=self.form.format(self.complete_url),
+            body=self.form.format(complete_url),
         )
         HTTPretty.register_uri(
             HTTPretty.POST,
-            self.complete_url,
+            complete_url,
             status=200,
             body=self.response_body,
             content_type="application/x-www-form-urlencoded",
         )
         response = requests.get(start_url)
-        self.assertEqual(response.text, self.form.format(self.complete_url))
-        response = requests.post(self.complete_url, data=parse_qs(self.response_body))
+        self.assertEqual(response.text, self.form.format(complete_url))
+        response = requests.post(complete_url, data=parse_qs(self.response_body))
         self.strategy.set_request_data(parse_qs(response.text), self.backend)
         return self.backend.complete()
diff --git a/social_core/tests/backends/oauth.py b/social_core/tests/backends/oauth.py
index e91264ff..f98fb652 100644
--- a/social_core/tests/backends/oauth.py
+++ b/social_core/tests/backends/oauth.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 from unittest.mock import patch
 from urllib.parse import urlparse
 
@@ -21,6 +23,7 @@ class BaseOAuthTest(BaseBackendTest):
     expected_username = ""
 
     def extra_settings(self):
+        assert self.name, "Subclasses must set the name attribute"
         return {
             "SOCIAL_AUTH_" + self.name + "_KEY": "a-key",
             "SOCIAL_AUTH_" + self.name + "_SECRET": "a-secret-key",
@@ -91,6 +94,7 @@ class OAuth1Test(BaseOAuthTest):
     raw_complete_url = "/complete/{0}/?oauth_verifier=bazqux&oauth_token=foobar"
 
     def request_token_handler(self):
+        assert self.request_token_body, "Subclasses must set request_token_body"
         HTTPretty.register_uri(
             self._method(self.backend.REQUEST_TOKEN_METHOD),
             self.backend.REQUEST_TOKEN_URL,
diff --git a/social_core/tests/backends/open_id.py b/social_core/tests/backends/open_id.py
index 67885bec..b0723b5d 100644
--- a/social_core/tests/backends/open_id.py
+++ b/social_core/tests/backends/open_id.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import sys
 from html.parser import HTMLParser
 
@@ -92,10 +94,12 @@ class OpenIdTest(BaseBackendTest):
         start = self.backend.start()
         self.post_start()
         form, inputs = self.get_form_data(start)
+        action = form.get("action")
+        assert action, "The form action must be set in the test"
         HTTPretty.register_uri(
-            HTTPretty.POST, form.get("action"), status=200, body=self.server_response
+            HTTPretty.POST, action, status=200, body=self.server_response
         )
-        response = requests.post(form.get("action"), data=inputs)
+        response = requests.post(action, data=inputs)
         self.strategy.set_request_data(parse_qs(response.content), self.backend)
         HTTPretty.register_uri(
             HTTPretty.POST, form.get("action"), status=200, body="is_valid:true\n"
diff --git a/social_core/tests/backends/test_apple.py b/social_core/tests/backends/test_apple.py
index c6471230..bbb3b076 100644
--- a/social_core/tests/backends/test_apple.py
+++ b/social_core/tests/backends/test_apple.py
@@ -30,6 +30,7 @@ class AppleIdTest(OAuth2Test, BaseAuthUrlTestMixin):
     expected_username = token_data["sub"]
 
     def extra_settings(self):
+        assert self.name, "Name must be set in subclasses"
         return {
             "SOCIAL_AUTH_" + self.name + "_TEAM": "a-team-id",
             "SOCIAL_AUTH_" + self.name + "_KEY": "a-key-id",
diff --git a/social_core/tests/backends/test_auth0.py b/social_core/tests/backends/test_auth0.py
index e77723b0..241ef26a 100644
--- a/social_core/tests/backends/test_auth0.py
+++ b/social_core/tests/backends/test_auth0.py
@@ -56,6 +56,7 @@ class Auth0OAuth2Test(OAuth2Test, BaseAuthUrlTestMixin):
     jwks_url = "https://foobar.auth0.com/.well-known/jwks.json"
 
     def extra_settings(self):
+        assert self.name, "Subclasses must set the name attribute"
         settings = super().extra_settings()
         settings["SOCIAL_AUTH_" + self.name + "_DOMAIN"] = DOMAIN
         return settings
diff --git a/social_core/tests/backends/test_azuread_b2c.py b/social_core/tests/backends/test_azuread_b2c.py
index 709b7da0..d87d646b 100644
--- a/social_core/tests/backends/test_azuread_b2c.py
+++ b/social_core/tests/backends/test_azuread_b2c.py
@@ -107,7 +107,7 @@ class AzureADB2COAuth2Test(OAuth2Test, BaseAuthUrlTestMixin):
             "access_token": "foobar",
             "token_type": "bearer",
             "id_token": jwt.encode(
-                key=RSAAlgorithm.from_jwk(json.dumps(RSA_PRIVATE_JWT_KEY)),
+                key=RSAAlgorithm.from_jwk(json.dumps(RSA_PRIVATE_JWT_KEY)),  # type: ignore reportOperatorIssue
                 headers={
                     "kid": RSA_PRIVATE_JWT_KEY["kid"],
                 },
@@ -139,6 +139,7 @@ class AzureADB2COAuth2Test(OAuth2Test, BaseAuthUrlTestMixin):
 
     def extra_settings(self):
         settings = super().extra_settings()
+        assert self.name, "Name must be set in subclasses"
         settings.update(
             {
                 "SOCIAL_AUTH_" + self.name + "_POLICY": "b2c_1_signin",
diff --git a/social_core/tests/backends/test_bitbucket_datacenter.py b/social_core/tests/backends/test_bitbucket_datacenter.py
index dda5fbb6..a2b2050b 100644
--- a/social_core/tests/backends/test_bitbucket_datacenter.py
+++ b/social_core/tests/backends/test_bitbucket_datacenter.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import json
 
 from httpretty import HTTPretty
diff --git a/social_core/tests/backends/test_cas.py b/social_core/tests/backends/test_cas.py
index 1d182140..e3f4a6c1 100644
--- a/social_core/tests/backends/test_cas.py
+++ b/social_core/tests/backends/test_cas.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import json
 
 from httpretty import HTTPretty
diff --git a/social_core/tests/backends/test_cognito.py b/social_core/tests/backends/test_cognito.py
index b0956345..ef6a4478 100644
--- a/social_core/tests/backends/test_cognito.py
+++ b/social_core/tests/backends/test_cognito.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import json
 
 from .oauth import BaseAuthUrlTestMixin, OAuth2Test
@@ -23,6 +25,7 @@ class CognitoAuth2Test(OAuth2Test, BaseAuthUrlTestMixin):
 
     def extra_settings(self):
         settings = super().extra_settings()
+        assert self.name, "subclasses must set the name attribute"
         settings.update(
             {
                 "SOCIAL_AUTH_" + self.name + "_POOL_DOMAIN": self.pool_domain,
diff --git a/social_core/tests/backends/test_dummy.py b/social_core/tests/backends/test_dummy.py
index 49e59923..b4e9cc95 100644
--- a/social_core/tests/backends/test_dummy.py
+++ b/social_core/tests/backends/test_dummy.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import datetime
 import json
 
diff --git a/social_core/tests/backends/test_egi_checkin.py b/social_core/tests/backends/test_egi_checkin.py
index 3df87dca..2fb34a61 100644
--- a/social_core/tests/backends/test_egi_checkin.py
+++ b/social_core/tests/backends/test_egi_checkin.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 from .oauth import BaseAuthUrlTestMixin, OAuth2Test
 from .test_open_id_connect import OpenIdConnectTestMixin
 
diff --git a/social_core/tests/backends/test_etsy.py b/social_core/tests/backends/test_etsy.py
index f7d1e9cc..bd3ff49b 100644
--- a/social_core/tests/backends/test_etsy.py
+++ b/social_core/tests/backends/test_etsy.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import json
 
 from .oauth import OAuth2PkceS256Test
diff --git a/social_core/tests/backends/test_google.py b/social_core/tests/backends/test_google.py
index d9c3ff5b..b990fc16 100644
--- a/social_core/tests/backends/test_google.py
+++ b/social_core/tests/backends/test_google.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import json
 from urllib.parse import urlencode
 
diff --git a/social_core/tests/backends/test_linkedin.py b/social_core/tests/backends/test_linkedin.py
index 2fcd417d..b145a63b 100644
--- a/social_core/tests/backends/test_linkedin.py
+++ b/social_core/tests/backends/test_linkedin.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import json
 
 from .oauth import BaseAuthUrlTestMixin, OAuth2Test
diff --git a/social_core/tests/backends/test_okta.py b/social_core/tests/backends/test_okta.py
index a78e9982..9fb91cad 100644
--- a/social_core/tests/backends/test_okta.py
+++ b/social_core/tests/backends/test_okta.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import json
 
 from httpretty import HTTPretty
diff --git a/social_core/tests/backends/test_open_id_connect.py b/social_core/tests/backends/test_open_id_connect.py
index 5dacb96c..6bff4d0c 100644
--- a/social_core/tests/backends/test_open_id_connect.py
+++ b/social_core/tests/backends/test_open_id_connect.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import base64
 import datetime
 import json
@@ -65,6 +67,8 @@ class OpenIdConnectTestMixin:
         self.key = JWK_KEY.copy()
         self.public_key = JWK_PUBLIC_KEY.copy()
 
+        assert self.openid_config_body, "openid_config_body must be set"
+
         HTTPretty.register_uri(
             HTTPretty.GET,
             self.backend.oidc_endpoint() + "/.well-known/openid-configuration",
@@ -156,7 +160,7 @@ class OpenIdConnectTestMixin:
         body["id_token"] = jwt.encode(
             id_token,
             key=jwt.PyJWK(
-                dict(self.key, iat=timegm(issue_datetime.timetuple()), nonce=nonce)
+                dict(self.key, iat=timegm(issue_datetime.timetuple()), nonce=nonce)  # type: ignore reportCallIssue
             ).key,
             algorithm="RS256",
             headers={"kid": kid} if kid else None,
diff --git a/social_core/tests/backends/test_saml.py b/social_core/tests/backends/test_saml.py
index 538c0d13..9d43c644 100644
--- a/social_core/tests/backends/test_saml.py
+++ b/social_core/tests/backends/test_saml.py
@@ -8,7 +8,15 @@ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
 
 import requests
 from httpretty import HTTPretty
-from onelogin.saml2.utils import OneLogin_Saml2_Utils
+
+try:
+    from onelogin.saml2.utils import (  # type: ignore reportMissingImports
+        OneLogin_Saml2_Utils,
+    )
+
+    SAML_MODULE_ENABLED = True
+except ImportError:
+    SAML_MODULE_ENABLED = False
 
 from ...exceptions import AuthMissingParameter
 from .base import BaseBackendTest
@@ -19,6 +27,7 @@ DATA_DIR = path.join(path.dirname(__file__), "data")
 @unittest.skipIf(
     "__pypy__" in sys.builtin_module_names, "dm.xmlsec not compatible with pypy"
 )
+@unittest.skipUnless(SAML_MODULE_ENABLED, "Only run if onelogin.saml2 is installed")
 class SAMLTest(BaseBackendTest):
     backend_path = "social_core.backends.saml.SAMLAuth"
     expected_username = "myself"
diff --git a/social_core/tests/backends/test_stripe.py b/social_core/tests/backends/test_stripe.py
index fae311ab..ed3c3c12 100644
--- a/social_core/tests/backends/test_stripe.py
+++ b/social_core/tests/backends/test_stripe.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import json
 
 import requests
diff --git a/social_core/tests/backends/test_twitter_oauth2.py b/social_core/tests/backends/test_twitter_oauth2.py
index 8a40ed29..35daf737 100644
--- a/social_core/tests/backends/test_twitter_oauth2.py
+++ b/social_core/tests/backends/test_twitter_oauth2.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import json
 
 from social_core.exceptions import AuthException
diff --git a/social_core/tests/backends/test_vault.py b/social_core/tests/backends/test_vault.py
index 54da6af3..74646b03 100644
--- a/social_core/tests/backends/test_vault.py
+++ b/social_core/tests/backends/test_vault.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import json
 
 from httpretty import HTTPretty
diff --git a/social_core/tests/backends/test_yahoo.py b/social_core/tests/backends/test_yahoo.py
index 872bb1aa..f1274301 100644
--- a/social_core/tests/backends/test_yahoo.py
+++ b/social_core/tests/backends/test_yahoo.py
@@ -1,3 +1,5 @@
+# pyright: reportAttributeAccessIssue=false
+
 import json
 from urllib.parse import urlencode
 
diff --git a/social_core/tests/models.py b/social_core/tests/models.py
index 8fa0b9f4..2d05bc12 100644
--- a/social_core/tests/models.py
+++ b/social_core/tests/models.py
@@ -1,4 +1,8 @@
+# pyright: reportAttributeAccessIssue=false
+from __future__ import annotations
+
 import base64
+from typing import TypeVar
 
 from ..storage import (
     AssociationMixin,
@@ -9,6 +13,8 @@ from ..storage import (
     UserMixin,
 )
 
+ModelT = TypeVar("ModelT", bound="BaseModel")
+
 
 class BaseModel:
     @classmethod
@@ -17,7 +23,7 @@ class BaseModel:
         return cls.NEXT_ID - 1
 
     @classmethod
-    def get(cls, key):
+    def get(cls: type[ModelT], key) -> ModelT | None:
         return cls.cache.get(key)
 
     @classmethod
@@ -57,6 +63,8 @@ class User(BaseModel):
 
 
 class TestUserSocialAuth(UserMixin, BaseModel):
+    __test__ = False
+
     NEXT_ID = 1
     cache = {}
     cache_by_uid = {}
@@ -143,6 +151,8 @@ class TestUserSocialAuth(UserMixin, BaseModel):
 
 
 class TestNonce(NonceMixin, BaseModel):
+    __test__ = False
+
     NEXT_ID = 1
     cache = {}
 
@@ -159,7 +169,7 @@ class TestNonce(NonceMixin, BaseModel):
         return nonce
 
     @classmethod
-    def get(cls, server_url, salt):
+    def get_nonce(cls, server_url, salt):
         return TestNonce.cache[server_url]
 
     @classmethod
@@ -169,6 +179,8 @@ class TestNonce(NonceMixin, BaseModel):
 
 
 class TestAssociation(AssociationMixin, BaseModel):
+    __test__ = False
+
     NEXT_ID = 1
     cache = {}
 
@@ -192,7 +204,11 @@ class TestAssociation(AssociationMixin, BaseModel):
         assoc.save()
 
     @classmethod
-    def get(cls, server_url=None, handle=None):
+    def get_association(
+        cls: type[TestAssociation],
+        server_url=None,
+        handle=None,
+    ) -> list[AssociationMixin]:
         result = []
         for assoc in TestAssociation.cache.values():
             if server_url and assoc.server_url != server_url:
@@ -210,6 +226,8 @@ class TestAssociation(AssociationMixin, BaseModel):
 
 
 class TestCode(CodeMixin, BaseModel):
+    __test__ = False
+
     NEXT_ID = 1
     cache = {}
 
@@ -222,6 +240,8 @@ class TestCode(CodeMixin, BaseModel):
 
 
 class TestPartial(PartialMixin, BaseModel):
+    __test__ = False
+
     NEXT_ID = 1
     cache = {}
 
@@ -238,6 +258,8 @@ class TestPartial(PartialMixin, BaseModel):
 
 
 class TestStorage(BaseStorage):
+    __test__ = False
+
     user = TestUserSocialAuth
     nonce = TestNonce
     association = TestAssociation
@@ -245,5 +267,5 @@ class TestStorage(BaseStorage):
     partial = TestPartial
 
     @classmethod
-    def is_integrity_error(cls, exception):
+    def is_integrity_error(cls, exception) -> bool | None:
         pass
diff --git a/social_core/tests/strategy.py b/social_core/tests/strategy.py
index 08972015..1a333e7e 100644
--- a/social_core/tests/strategy.py
+++ b/social_core/tests/strategy.py
@@ -10,6 +10,8 @@ class Redirect:
 
 
 class TestTemplateStrategy(BaseTemplateStrategy):
+    __test__ = False
+
     def render_template(self, tpl, context):
         return tpl
 
@@ -18,6 +20,8 @@ class TestTemplateStrategy(BaseTemplateStrategy):
 
 
 class TestStrategy(BaseStrategy):
+    __test__ = False
+
     DEFAULT_TEMPLATE_STRATEGY = TestTemplateStrategy
 
     def __init__(self, storage, tpl=None):
diff --git a/social_core/tests/test_exceptions.py b/social_core/tests/test_exceptions.py
index 2f433625..22e451d3 100644
--- a/social_core/tests/test_exceptions.py
+++ b/social_core/tests/test_exceptions.py
@@ -28,6 +28,9 @@ class BaseExceptionTestCase(unittest.TestCase):
     def test_exception_message(self):
         if self.exception is None and self.expected_message == "":
             return
+
+        if self.exception is None:
+            assert self.exception, "exception is not defined"
         try:
             raise self.exception
         except SocialAuthBaseException as err:
diff --git a/social_core/tests/test_storage.py b/social_core/tests/test_storage.py
index 3e328d04..e4f4ece7 100644
--- a/social_core/tests/test_storage.py
+++ b/social_core/tests/test_storage.py
@@ -90,7 +90,7 @@ class BrokenAssociationTests(unittest.TestCase):
 
     def test_get(self):
         with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
-            self.association.get()
+            self.association.get_association()
 
     def test_remove(self):
         with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
diff --git a/social_core/tests/test_utils.py b/social_core/tests/test_utils.py
index 77a97a8f..cedef638 100644
--- a/social_core/tests/test_utils.py
+++ b/social_core/tests/test_utils.py
@@ -130,6 +130,7 @@ class BuildAbsoluteURITest(unittest.TestCase):
         )
 
     def test_host_ends_with_slash_and_path_starts_with_slash(self):
+        assert self.host, "Subclasses must set the host attribute"
         self.assertEqual(
             build_absolute_uri(self.host + "/", "/foo/bar"), "http://foobar.com/foo/bar"
         )