Skip to content

Commit 2eb365b

Browse files
committed
🧪 Enforce type annotations with MyPy
1 parent ed0c539 commit 2eb365b

File tree

6 files changed

+212
-16
lines changed

6 files changed

+212
-16
lines changed

.mypy.ini

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
[mypy]
2+
python_version = 3.11
3+
color_output = true
4+
error_summary = true
5+
files =
6+
.,
7+
# src/,
8+
# tests/,
9+
10+
check_untyped_defs = true
11+
12+
disallow_any_explicit = true
13+
disallow_any_expr = true
14+
disallow_any_decorated = true
15+
disallow_any_generics = true
16+
disallow_any_unimported = true
17+
disallow_incomplete_defs = true
18+
disallow_subclassing_any = true
19+
disallow_untyped_calls = true
20+
disallow_untyped_decorators = true
21+
disallow_untyped_defs = true
22+
23+
enable_error_code =
24+
ignore-without-code
25+
26+
explicit_package_bases = true
27+
28+
extra_checks = true
29+
30+
follow_imports = normal
31+
32+
ignore_missing_imports = false
33+
34+
local_partial_types = true
35+
36+
mypy_path = ${MYPY_CONFIG_FILE_DIR}:${MYPY_CONFIG_FILE_DIR}/bin:${MYPY_CONFIG_FILE_DIR}/src:${MYPY_CONFIG_FILE_DIR}/_type_stubs
37+
38+
namespace_packages = true
39+
40+
no_implicit_reexport = true
41+
42+
pretty = true
43+
44+
show_column_numbers = true
45+
show_error_code_links = true
46+
show_error_codes = true
47+
show_error_context = true
48+
show_error_end = true
49+
50+
# `strict` will pick up any future strictness-related settings:
51+
strict = true
52+
strict_equality = true
53+
strict_optional = true
54+
55+
warn_no_return = true
56+
warn_redundant_casts = true
57+
warn_return_any = true
58+
warn_unused_configs = true
59+
warn_unused_ignores = true
60+
61+
[mypy-tests.*]
62+
# crashes with some decorators like `@functools.cache` and `@pytest.mark.parametrize`:
63+
disallow_any_expr = false
64+
# fails on `@hypothesis.given()`:
65+
disallow_any_decorated = false

.pre-commit-config.yaml

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,70 @@ repos:
115115
- flake8-pytest-style ~= 2.1.0
116116
- wemake-python-styleguide ~= 1.0.0
117117

118+
- repo: https://github.com/pre-commit/mirrors-mypy.git
119+
rev: v1.16.1
120+
hooks:
121+
- id: mypy
122+
alias: mypy-py313
123+
name: MyPy, for Python 3.13
124+
additional_dependencies:
125+
- id # used by `oidc-exchange.py`
126+
- lxml # dep of `--txt-report`, `--cobertura-xml-report` & `--html-report`
127+
- packaging # used by `print-pkg-names.py`
128+
- pypi-attestations # used by `attestations.py`
129+
- sigstore # used by `attestations.py`
130+
- types-requests # used by `oidc-exchange.py`
131+
args:
132+
- --python-version=3.13
133+
- --any-exprs-report=.tox/.tmp/.test-results/mypy--py-3.13
134+
- --cobertura-xml-report=.tox/.tmp/.test-results/mypy--py-3.13
135+
- --html-report=.tox/.tmp/.test-results/mypy--py-3.13
136+
- --linecount-report=.tox/.tmp/.test-results/mypy--py-3.13
137+
- --linecoverage-report=.tox/.tmp/.test-results/mypy--py-3.13
138+
- --lineprecision-report=.tox/.tmp/.test-results/mypy--py-3.13
139+
- --txt-report=.tox/.tmp/.test-results/mypy--py-3.13
140+
pass_filenames: false
141+
- id: mypy
142+
alias: mypy-py312
143+
name: MyPy, for Python 3.12
144+
additional_dependencies:
145+
- id # used by `oidc-exchange.py`
146+
- lxml # dep of `--txt-report`, `--cobertura-xml-report` & `--html-report`
147+
- packaging # used by `print-pkg-names.py`
148+
- pypi-attestations # used by `attestations.py`
149+
- sigstore # used by `attestations.py`
150+
- types-requests # used by `oidc-exchange.py`
151+
args:
152+
- --python-version=3.12
153+
- --any-exprs-report=.tox/.tmp/.test-results/mypy--py-3.12
154+
- --cobertura-xml-report=.tox/.tmp/.test-results/mypy--py-3.12
155+
- --html-report=.tox/.tmp/.test-results/mypy--py-3.12
156+
- --linecount-report=.tox/.tmp/.test-results/mypy--py-3.12
157+
- --linecoverage-report=.tox/.tmp/.test-results/mypy--py-3.12
158+
- --lineprecision-report=.tox/.tmp/.test-results/mypy--py-3.12
159+
- --txt-report=.tox/.tmp/.test-results/mypy--py-3.12
160+
pass_filenames: false
161+
- id: mypy
162+
alias: mypy-py311
163+
name: MyPy, for Python 3.11
164+
additional_dependencies:
165+
- id # used by `oidc-exchange.py`
166+
- lxml # dep of `--txt-report`, `--cobertura-xml-report` & `--html-report`
167+
- packaging # used by `print-pkg-names.py`
168+
- pypi-attestations # used by `attestations.py`
169+
- sigstore # used by `attestations.py`
170+
- types-requests # used by `oidc-exchange.py`
171+
args:
172+
- --python-version=3.11
173+
- --any-exprs-report=.tox/.tmp/.test-results/mypy--py-3.11
174+
- --cobertura-xml-report=.tox/.tmp/.test-results/mypy--py-3.11
175+
- --html-report=.tox/.tmp/.test-results/mypy--py-3.11
176+
- --linecount-report=.tox/.tmp/.test-results/mypy--py-3.11
177+
- --linecoverage-report=.tox/.tmp/.test-results/mypy--py-3.11
178+
- --lineprecision-report=.tox/.tmp/.test-results/mypy--py-3.11
179+
- --txt-report=.tox/.tmp/.test-results/mypy--py-3.11
180+
pass_filenames: false
181+
118182
- repo: https://github.com/PyCQA/pylint.git
119183
rev: v3.3.4
120184
hooks:

_type_stubs/id.pyi

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class IdentityError(Exception): ...
2+
3+
def detect_credential(audience: str) -> str | None: ...

attestations.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
sigstore_logger.setLevel(logging.DEBUG)
1414
sigstore_logger.addHandler(logging.StreamHandler())
1515

16-
_GITHUB_STEP_SUMMARY = Path(os.getenv('GITHUB_STEP_SUMMARY'))
16+
_GITHUB_STEP_SUMMARY = Path(os.environ['GITHUB_STEP_SUMMARY'])
1717

1818
# The top-level error message that gets rendered.
1919
# This message wraps one of the other templates/messages defined below.
@@ -122,6 +122,8 @@ def get_identity_token() -> IdentityToken:
122122
# from the environment or if the token is malformed.
123123
# NOTE: audience is always sigstore.
124124
oidc_token = detect_credential()
125+
if oidc_token is None:
126+
raise IdentityError('Attempted to discover OIDC in broken environment')
125127
return IdentityToken(oidc_token)
126128

127129

oidc-exchange.py

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import id # pylint: disable=redefined-builtin
1111
import requests
1212

13-
_GITHUB_STEP_SUMMARY = Path(os.getenv('GITHUB_STEP_SUMMARY'))
13+
_GITHUB_STEP_SUMMARY = Path(os.environ['GITHUB_STEP_SUMMARY'])
1414

1515
# The top-level error message that gets rendered.
1616
# This message wraps one of the other templates/messages defined below.
@@ -155,7 +155,7 @@ def warn(msg: str) -> None:
155155
print(f'::warning::Potential workflow misconfiguration: {msg}', file=sys.stderr)
156156

157157

158-
def debug(msg: str):
158+
def debug(msg: str) -> None:
159159
print(f'::debug::{msg.title()}', file=sys.stderr)
160160

161161

@@ -166,7 +166,7 @@ def get_normalized_input(name: str) -> str | None:
166166
return os.getenv(name.replace('-', '_'))
167167

168168

169-
def assert_successful_audience_call(resp: requests.Response, domain: str):
169+
def assert_successful_audience_call(resp: requests.Response, domain: str) -> None:
170170
if resp.ok:
171171
return
172172

@@ -194,17 +194,32 @@ def assert_successful_audience_call(resp: requests.Response, domain: str):
194194
)
195195

196196

197-
def extract_claims(token: str) -> dict[str, object]:
197+
class TrustedPublishingClaims(t.TypedDict):
198+
sub: str
199+
repository: str
200+
repository_owner: str
201+
repository_owner_id: str
202+
workflow_ref: str
203+
job_workflow_ref: str
204+
ref: str
205+
environment: str
206+
207+
208+
def extract_claims(token: str) -> TrustedPublishingClaims:
198209
_, payload, _ = token.split('.', 2)
199210

200211
# urlsafe_b64decode needs padding; JWT payloads don't contain any.
201212
payload += '=' * (4 - (len(payload) % 4))
202-
return json.loads(base64.urlsafe_b64decode(payload))
213+
214+
oidc_claims: TrustedPublishingClaims = json.loads(
215+
base64.urlsafe_b64decode(payload),
216+
)
217+
return oidc_claims
203218

204219

205-
def render_claims(claims: dict[str, object]) -> str:
220+
def render_claims(claims: TrustedPublishingClaims) -> str:
206221
def _get(name: str) -> str: # noqa: WPS430
207-
return claims.get(name, 'MISSING')
222+
return str(claims.get(name, 'MISSING'))
208223

209224
return _RENDERED_CLAIMS.format(
210225
sub=_get('sub'),
@@ -218,7 +233,7 @@ def _get(name: str) -> str: # noqa: WPS430
218233
)
219234

220235

221-
def warn_on_reusable_workflow(claims: dict[str, object]) -> None:
236+
def warn_on_reusable_workflow(claims: TrustedPublishingClaims) -> None:
222237
# A reusable workflow is identified by having different values
223238
# for its workflow_ref (the initiating workflow) and job_workflow_ref
224239
# (the reusable workflow).
@@ -228,7 +243,27 @@ def warn_on_reusable_workflow(claims: dict[str, object]) -> None:
228243
if workflow_ref == job_workflow_ref:
229244
return
230245

231-
warn(_REUSABLE_WORKFLOW_WARNING.format_map(locals()))
246+
warn(
247+
_REUSABLE_WORKFLOW_WARNING.format(
248+
workflow_ref=workflow_ref, job_workflow_ref=job_workflow_ref,
249+
),
250+
)
251+
252+
253+
class PullRequestRepoGitHubEventObject(t.TypedDict):
254+
fork: bool
255+
256+
257+
class PullRequestHeadGitHubEventObject(t.TypedDict):
258+
repo: PullRequestRepoGitHubEventObject
259+
260+
261+
class PullRequestGitHubEventObject(t.TypedDict):
262+
head: PullRequestHeadGitHubEventObject
263+
264+
265+
class ThirdPartyPullRequestGitHubEvent(t.TypedDict):
266+
pull_request: PullRequestGitHubEventObject
232267

233268

234269
def event_is_third_party_pr() -> bool:
@@ -243,7 +278,9 @@ def event_is_third_party_pr() -> bool:
243278
return False
244279

245280
try:
246-
event = json.loads(Path(event_path).read_bytes())
281+
event: ThirdPartyPullRequestGitHubEvent = json.loads(
282+
Path(event_path).read_bytes(),
283+
)
247284
except json.JSONDecodeError:
248285
debug('unexpected: GITHUB_EVENT_PATH does not contain valid JSON')
249286
return False
@@ -255,7 +292,7 @@ def event_is_third_party_pr() -> bool:
255292

256293

257294
repository_url = get_normalized_input('repository-url')
258-
repository_domain = urlparse(repository_url).netloc
295+
repository_domain = str(urlparse(repository_url).netloc)
259296
token_exchange_url = f'https://{repository_domain}/_/oidc/mint-token'
260297

261298
# Indices are expected to support `https://{domain}/_/oidc/audience`,
@@ -264,12 +301,22 @@ def event_is_third_party_pr() -> bool:
264301
audience_resp = requests.get(audience_url, timeout=5) # S113 wants a timeout
265302
assert_successful_audience_call(audience_resp, repository_domain)
266303

267-
oidc_audience = audience_resp.json()['audience']
304+
305+
class TrustedPublishingAudience(t.TypedDict):
306+
audience: str
307+
308+
309+
oidc_audience_resp: TrustedPublishingAudience = audience_resp.json()
310+
oidc_audience = oidc_audience_resp['audience']
268311

269312
debug(f'selected trusted publishing exchange endpoint: {token_exchange_url}')
270313

271314
try:
272315
oidc_token = id.detect_credential(audience=oidc_audience)
316+
if oidc_token is None:
317+
raise id.IdentityError(
318+
'Attempted to discover OIDC in broken environment',
319+
)
273320
except id.IdentityError as identity_error:
274321
cause_msg_tmpl = (
275322
_TOKEN_RETRIEVAL_FAILED_FORK_PR_MESSAGE
@@ -285,15 +332,30 @@ def event_is_third_party_pr() -> bool:
285332
oidc_claims = extract_claims(oidc_token)
286333
warn_on_reusable_workflow(oidc_claims)
287334

335+
oidc_token_payload: dict[str, str] = {'token': oidc_token}
288336
# Now we can do the actual token exchange.
289337
mint_token_resp = requests.post(
290338
token_exchange_url,
291-
json={'token': oidc_token},
339+
json=oidc_token_payload,
292340
timeout=5, # S113 wants a timeout
293341
)
294342

343+
344+
class TrustedPublishingTokenRetrievalError(t.TypedDict):
345+
code: str
346+
description: str
347+
348+
349+
class TrustedPublishingToken(t.TypedDict):
350+
message: str
351+
errors: list[TrustedPublishingTokenRetrievalError]
352+
token: str
353+
success: bool
354+
expires: int
355+
356+
295357
try:
296-
mint_token_payload = mint_token_resp.json()
358+
mint_token_payload: TrustedPublishingToken = mint_token_resp.json()
297359
except requests.JSONDecodeError:
298360
# Token exchange failure normally produces a JSON error response, but
299361
# we might have hit a server error instead.

print-pkg-names.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from packaging import utils
55

66

7-
def debug(msg: str):
7+
def debug(msg: str) -> None:
88
print(f'::debug::{msg.title()}', file=sys.stderr)
99

1010

0 commit comments

Comments
 (0)