Skip to content
This repository was archived by the owner on May 5, 2025. It is now read-only.

Commit 609e56d

Browse files
chore(types): Fix few type errors around bots/ (#463)
Fixes some type errors flagged by mypy in the `bots/` folder (mostly). Pydantic is being added to create the `DedicatedApp` model, that validates config pulled from the install YAML. Might be overkill, but it's a direction the team wants to go in, I believe.
1 parent c3288a0 commit 609e56d

File tree

10 files changed

+243
-80
lines changed

10 files changed

+243
-80
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dependencies = [
3535
"sentry-sdk>=2.13.0",
3636
"sqlalchemy<2",
3737
"zstandard>=0.23.0",
38+
"pydantic>=2.10.4",
3839
]
3940

4041
[build-system]
@@ -61,4 +62,5 @@ dev-dependencies = [
6162
# `oauth2` / `minio` deal with requests forces us to downgrade `urllib3`:
6263
"urllib3==1.26.19",
6364
"vcrpy>=4.1.1",
65+
"types-requests>=2.31.0.6",
6466
]

shared/bots/__init__.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import logging
2-
from typing import Any, List, Optional
2+
from typing import Any, List
33

44
from shared.bots.github_apps import get_github_app_info_for_owner
55
from shared.bots.owner_bots import get_owner_appropriate_bot_token
66
from shared.bots.public_bots import get_token_type_mapping
77
from shared.bots.repo_bots import get_repo_appropriate_bot_token
8-
from shared.bots.types import (
9-
AdapterAuthInformation,
10-
)
8+
from shared.bots.types import AdapterAuthInformation
119
from shared.django_apps.codecov_auth.models import (
1210
GITHUB_APP_INSTALLATION_DEFAULT_NAME,
1311
Owner,
@@ -18,12 +16,13 @@
1816

1917
log = logging.getLogger(__name__)
2018

19+
SQLAlchemyOwner = Any
20+
SQLAlchemyRepository = Any
21+
2122

2223
def get_adapter_auth_information(
23-
# Owner type can be a Django Owner | SQLAlchemy Owner - added Any to help with IDE typing
24-
owner: Owner | Any,
25-
# Owner type can be a Django Repository | SQLAlchemy Repository - added Any to help with IDE typing
26-
repository: Optional[Repository] | Any = None,
24+
owner: Owner | SQLAlchemyOwner,
25+
repository: Repository | SQLAlchemyRepository | None = None,
2726
*,
2827
ignore_installations: bool = False,
2928
installation_name_to_use: str = GITHUB_APP_INSTALLATION_DEFAULT_NAME,

shared/bots/github_apps.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
import random
33
from datetime import datetime, timezone
4-
from typing import Dict, List, Optional
4+
from typing import Dict, List
55

66
from shared.bots.exceptions import NoConfiguredAppsAvailable, RequestedGithubAppNotFound
77
from shared.bots.types import TokenWithOwner
@@ -12,10 +12,7 @@
1212
Service,
1313
)
1414
from shared.django_apps.core.models import Repository
15-
from shared.github import (
16-
InvalidInstallationError,
17-
get_github_integration_token,
18-
)
15+
from shared.github import InvalidInstallationError, get_github_integration_token
1916
from shared.helpers.redis import get_redis_connection
2017
from shared.orms.owner_helper import DjangoSQLAlchemyOwnerWrapper
2118
from shared.rate_limits import determine_if_entity_is_rate_limited, gh_app_key_name
@@ -42,7 +39,7 @@ def _get_installation_weight(installation: GithubAppInstallation) -> int:
4239

4340

4441
def _can_use_this_app(
45-
app: GithubAppInstallation, installation_name: str, repository: Optional[Repository]
42+
app: GithubAppInstallation, installation_name: str, repository: Repository | None
4643
) -> bool:
4744
return (
4845
app.name == installation_name
@@ -54,7 +51,7 @@ def _can_use_this_app(
5451

5552

5653
def _get_apps_from_weighted_selection(
57-
owner: Owner, installation_name: str, repository: Optional[Repository]
54+
owner: Owner, installation_name: str, repository: Repository | None
5855
) -> List[GithubAppInstallation]:
5956
"""This function returns an ordered list of GithubAppInstallations that can be used to communicate with GitHub
6057
in behalf of the owner. The list is ordered in such a way that the 1st element is the app to be used in Torngit,
@@ -162,12 +159,16 @@ def get_github_app_token(
162159
github_token = get_github_integration_token(
163160
service.value,
164161
installation_id,
165-
app_id=installation_info.get("app_id", None),
162+
app_id=str(app_id) if app_id else None,
166163
pem_path=installation_info.get("pem_path", None),
167164
)
168165
installation_token = Token(
169166
key=github_token,
170-
entity_name=gh_app_key_name(installation_id=installation_id, app_id=app_id),
167+
username=f"installation_{installation_id}",
168+
entity_name=gh_app_key_name(
169+
installation_id=installation_id,
170+
app_id=app_id,
171+
),
171172
)
172173
# The token is not owned by an Owner object, so 2nd arg is None
173174
return installation_token, None
@@ -191,7 +192,7 @@ def get_specific_github_app_details(
191192
The assumption is that we need this specific app for a reason, and if we can't find the app
192193
it's better to just fail
193194
"""
194-
app: GithubAppInstallation = next(
195+
app: GithubAppInstallation | None = next(
195196
(
196197
obj
197198
for obj in DjangoSQLAlchemyOwnerWrapper.get_github_app_installations(owner)
@@ -242,7 +243,7 @@ def _filter_suspended_apps(
242243
def get_github_app_info_for_owner(
243244
owner: Owner,
244245
*,
245-
repository: Optional[Repository] = None,
246+
repository: Repository | None = None,
246247
installation_name: str = GITHUB_APP_INSTALLATION_DEFAULT_NAME,
247248
) -> List[GithubInstallationInfo]:
248249
"""Gets the GitHub app info needed to communicate with GitHub using an app for this owner.

shared/bots/helpers.py

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import logging
2-
from typing import Any, Dict, Optional
2+
from typing import Self
3+
4+
from pydantic import BaseModel, ValidationError
35

46
from shared.bots.exceptions import RepositoryWithoutValidBotError
57
from shared.config import get_config
@@ -21,38 +23,59 @@
2123
@cache.cache_function(ttl=1800)
2224
def get_github_integration_token(
2325
service: str,
24-
installation_id: int = None,
25-
app_id: Optional[str] = None,
26-
pem_path: Optional[str] = None,
26+
installation_id: int | None = None,
27+
app_id: str | None = None,
28+
pem_path: str | None = None,
2729
):
2830
try:
2931
return _get_github_integration_token(
30-
service, integration_id=installation_id, app_id=app_id, pem_path=pem_path
32+
service,
33+
integration_id=installation_id,
34+
app_id=app_id,
35+
pem_path=pem_path,
3136
)
3237
except InvalidInstallationError:
3338
log.warning("Failed to get installation token")
3439
raise RepositoryWithoutValidBotError()
3540

3641

42+
class DedicatedApp(BaseModel):
43+
id: str | int
44+
installation_id: int
45+
pem: str
46+
47+
@classmethod
48+
def validate_or_none(cls, value: object) -> Self | None:
49+
try:
50+
return cls.model_validate(value)
51+
except ValidationError:
52+
return None
53+
54+
def pem_path(self, service: str, token_type: TokenType) -> str:
55+
return f"yaml+file://{service}.dedicated_apps.{token_type.value}.pem"
56+
57+
3758
def get_dedicated_app_token_from_config(
3859
service: str, token_type: TokenType
3960
) -> Token | None:
4061
# GitHub can have 'dedicated_apps', and those are preferred
41-
dedicated_app: Dict[str, Any] = get_config(
42-
service, "dedicated_apps", token_type.value, default={}
62+
dedicated_app = DedicatedApp.validate_or_none(
63+
get_config(service, "dedicated_apps", token_type.value, default={})
64+
)
65+
if dedicated_app is None:
66+
return None
67+
68+
actual_token = get_github_integration_token(
69+
service,
70+
app_id=str(dedicated_app.id),
71+
installation_id=dedicated_app.installation_id,
72+
pem_path=dedicated_app.pem_path(service, token_type),
73+
)
74+
return Token(
75+
key=actual_token,
76+
username=f"{token_type.value}_dedicated_app",
77+
entity_name=token_type.value,
4378
)
44-
app_id = dedicated_app.get("id")
45-
installation_id = dedicated_app.get("installation_id")
46-
pem_exists = "pem" in dedicated_app
47-
if app_id and installation_id and pem_exists:
48-
actual_token = get_github_integration_token(
49-
service,
50-
app_id=app_id,
51-
installation_id=installation_id,
52-
pem_path=f"yaml+file://{service}.dedicated_apps.{token_type.value}.pem",
53-
)
54-
return Token(key=actual_token, username=f"{token_type.value}_dedicated_app")
55-
return None
5679

5780

5881
def get_token_type_from_config(service: str, token_type: TokenType) -> Token | None:
@@ -65,4 +88,7 @@ def get_token_type_from_config(service: str, token_type: TokenType) -> Token | N
6588
if dedicated_app_config:
6689
return dedicated_app_config
6790

68-
return get_config(service, "bots", token_type.value)
91+
token_from_config = get_config(service, "bots", token_type.value)
92+
if token_from_config:
93+
token_from_config["entity_name"] = token_type.value
94+
return token_from_config

shared/github/__init__.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
from time import time
3-
from typing import Literal, Optional
3+
from typing import Literal
44
from urllib.parse import urlparse
55

66
import jwt
@@ -29,7 +29,7 @@ def load_pem_from_path(pem_path: str) -> str:
2929
raise Exception("Unknown schema to load PEM")
3030

3131

32-
def get_pem(*, pem_name: Optional[str] = None, pem_path: Optional[str] = None) -> str:
32+
def get_pem(pem_name: str | None = None, pem_path: str | None = None) -> str:
3333
if pem_path:
3434
return load_pem_from_path(pem_path)
3535
if pem_name:
@@ -38,14 +38,15 @@ def get_pem(*, pem_name: Optional[str] = None, pem_path: Optional[str] = None) -
3838
raise Exception("No PEM provided to get installation token")
3939

4040

41-
InstallationErrorCause = (
42-
Literal["installation_not_found"]
43-
| Literal["permission_error"]
44-
| Literal["requires_authentication"]
45-
| Literal["installation_suspended"]
46-
| Literal["validation_failed_or_spammed"]
47-
| Literal["rate_limit"]
48-
)
41+
InstallationErrorCause = Literal[
42+
"installation_not_found",
43+
"permission_error",
44+
"requires_authentication",
45+
"installation_suspended",
46+
"validation_failed_or_spammed",
47+
"rate_limit",
48+
"unknown_error",
49+
]
4950

5051

5152
class InvalidInstallationError(Exception):
@@ -78,11 +79,13 @@ def decide_installation_error_cause(
7879
return "rate_limit"
7980
else:
8081
return "permission_error"
82+
case _:
83+
return "unknown_error"
8184

8285

8386
def get_github_jwt_token(
84-
service: str, app_id: Optional[str] = None, pem_path: Optional[str] = None
85-
) -> Optional[str]:
87+
service: str, app_id: str | None = None, pem_path: str | None = None
88+
) -> str:
8689
# https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/
8790
now = int(time())
8891
payload = {
@@ -100,9 +103,9 @@ def get_github_jwt_token(
100103
def get_github_integration_token(
101104
service,
102105
integration_id=None,
103-
app_id: Optional[str] = None,
104-
pem_path: Optional[str] = None,
105-
) -> Optional[str]:
106+
app_id: str | None = None,
107+
pem_path: str | None = None,
108+
) -> str:
106109
# https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/
107110
token = get_github_jwt_token(service, app_id, pem_path)
108111
if integration_id:

shared/rate_limits/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
RATE_LIMIT_REDIS_KEY_PREFIX = "rate_limited_entity_"
1212

1313

14-
def gh_app_key_name(installation_id: int, app_id: Optional[int] = None) -> str:
15-
app_id = app_id or "default_app"
14+
def gh_app_key_name(installation_id: int, app_id: str | int | None = None) -> str:
15+
if app_id is None:
16+
return f"default_app_{installation_id}"
1617
return f"{app_id}_{installation_id}"
1718

1819

shared/typings/torngit.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,19 @@ class RepoInfo(TypedDict):
1818

1919

2020
class GithubInstallationInfo(TypedDict):
21-
"""Required info to get a token from Github for a given installation"""
22-
23-
id: int
21+
"""
22+
Information about a Github installation.
23+
`id` - The id of the GithubAppInstallation object in the database
24+
If using the deprecated owner.integration_id it doesn't exist.
25+
`installation_id` - Required info to get a token from Github for a given installation.
26+
"""
27+
28+
id: NotRequired[int]
2429
installation_id: int
25-
# The default app (configured via yaml) doesn't need this info.
26-
# All other apps need app_id and pem_path
27-
app_id: Optional[int]
28-
pem_path: Optional[str]
30+
# The default app (configured via yaml) doesn't need `app_id` and `pem_path`.
31+
# All other apps need `app_id` and `pem_path`.
32+
app_id: NotRequired[int | None]
33+
pem_path: NotRequired[str | None]
2934

3035

3136
class AdditionalData(TypedDict):

0 commit comments

Comments
 (0)