Skip to content

Commit dae724c

Browse files
authored
fix(python): KeyError in type checks when decorating methods (#3791)
When methods are decorated by users (e.g: replaced with an alternate function that delegates back to the original one), type annotations are not carried over to the new function. Since type checking code relied on dynamically accessing the checked function for the purpose of getting type hints,this resulted in unexpected errors when executing type checking code. In order to address this, the type checking code now declares a stub function locally with the relevant type information in order to have a reliable/stable source of type annotations (these cannot be constructed dynamically as Python does not expose the necessary constructors). --- By submitting this pull request, I confirm that my contribution is made under the terms of the [Apache 2.0 license]. [Apache 2.0 license]: https://www.apache.org/licenses/LICENSE-2.0
1 parent 27cd853 commit dae724c

File tree

6 files changed

+1408
-543
lines changed

6 files changed

+1408
-543
lines changed

packages/@jsii/python-runtime/src/jsii/_runtime.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,22 @@
44

55
import attr
66

7-
from typing import Sequence, cast, Any, Callable, List, Optional, Mapping, Type, TypeVar
7+
from typing import (
8+
Any,
9+
Callable,
10+
cast,
11+
List,
12+
Mapping,
13+
Optional,
14+
Sequence,
15+
Type,
16+
TypeVar,
17+
)
818

919
from . import _reference_map
1020
from ._compat import importlib_resources
1121
from ._kernel import Kernel
1222
from .python import _ClassPropertyMeta
13-
from ._kernel.types import ObjRef
1423

1524

1625
# Yea, a global here is kind of gross, however, there's not really a better way of

packages/@jsii/python-runtime/tests/test_runtime_type_checking.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,30 @@ def test_constructor(self):
2121
):
2222
jsii_calc.Calculator(initial_value="nope") # type:ignore
2323

24+
def test_constructor_decorated(self):
25+
"""
26+
This test verifies that runtime type checking is not broken when a function is wrapped with a decorator, as was
27+
the case with the original implementation (due to a dynamic access to type_hints for the method via the type).
28+
"""
29+
30+
with pytest.raises(
31+
TypeError,
32+
match=re.escape(
33+
"type of argument maximum_value must be one of (int, float, NoneType); got str instead"
34+
),
35+
):
36+
orig_init = jsii_calc.Calculator.__init__
37+
# For toy, swap initial_value and maximum_values here
38+
jsii_calc.Calculator.__init__ = (
39+
lambda self, *, initial_value=None, maximum_value=None: orig_init(
40+
self, initial_value=maximum_value, maximum_value=initial_value
41+
)
42+
)
43+
try:
44+
jsii_calc.Calculator(initial_value="nope") # type:ignore
45+
finally:
46+
jsii_calc.Calculator.__init__ = orig_init
47+
2448
def test_struct(self):
2549
with pytest.raises(
2650
TypeError,

packages/jsii-pacmak/lib/targets/python.ts

Lines changed: 36 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import {
2424
PythonImports,
2525
mergePythonImports,
2626
toPackageName,
27-
toPythonFullName,
2827
} from './python/type-name';
2928
import { die, toPythonIdentifier } from './python/util';
3029
import { toPythonVersionRange, toReleaseVersion } from './version-utils';
@@ -123,6 +122,9 @@ interface EmitContext extends NamingContext {
123122

124123
/** Whether to emit runtime type checking code */
125124
readonly runtimeTypeChecking: boolean;
125+
126+
/** Whether to runtime type check keyword arguments (i.e: struct constructors) */
127+
readonly runtimeTypeCheckKwargs?: boolean;
126128
}
127129

128130
const pythonModuleNameToFilename = (name: string): string => {
@@ -664,14 +666,7 @@ abstract class BaseMethod implements PythonBase {
664666
(this.shouldEmitBody || forceEmitBody) &&
665667
(!renderAbstract || !this.abstract)
666668
) {
667-
emitParameterTypeChecks(
668-
code,
669-
context,
670-
pythonParams.slice(1),
671-
`${toPythonFullName(this.parent.fqn, context.assembly)}.${
672-
this.pythonName
673-
}`,
674-
);
669+
emitParameterTypeChecks(code, context, pythonParams.slice(1));
675670
}
676671
this.emitBody(
677672
code,
@@ -945,18 +940,7 @@ abstract class BaseProperty implements PythonBase {
945940
(this.shouldEmitBody || forceEmitBody) &&
946941
(!renderAbstract || !this.abstract)
947942
) {
948-
emitParameterTypeChecks(
949-
code,
950-
context,
951-
[`value: ${pythonType}`],
952-
// In order to get a property accessor, we must resort to getting the
953-
// attribute on the type, instead of the value (where the getter would
954-
// be implicitly invoked for us...)
955-
`getattr(${toPythonFullName(
956-
this.parent.fqn,
957-
context.assembly,
958-
)}, ${JSON.stringify(this.pythonName)}).fset`,
959-
);
943+
emitParameterTypeChecks(code, context, [`value: ${pythonType}`]);
960944
code.line(
961945
`jsii.${this.jsiiSetMethod}(${this.implicitParameter}, "${this.jsName}", value)`,
962946
);
@@ -1142,12 +1126,14 @@ class Struct extends BasePythonClassType {
11421126
code.line(`${member.pythonName} = ${typeName}(**${member.pythonName})`);
11431127
code.closeBlock();
11441128
}
1145-
emitParameterTypeChecks(
1146-
code,
1147-
context,
1148-
kwargs,
1149-
`${toPythonFullName(this.spec.fqn, context.assembly)}.__init__`,
1150-
);
1129+
if (kwargs.length > 0) {
1130+
emitParameterTypeChecks(
1131+
code,
1132+
// Runtime type check keyword args as this is a struct __init__ function.
1133+
{ ...context, runtimeTypeCheckKwargs: true },
1134+
['*', ...kwargs],
1135+
);
1136+
}
11511137

11521138
// Required properties, those will always be put into the dict
11531139
assignDictionary(
@@ -3055,14 +3041,13 @@ function openSignature(
30553041
* Emits runtime type checking code for parameters.
30563042
*
30573043
* @param code the CodeMaker to use for emitting code.
3044+
* @param context the emit context used when emitting this code.
30583045
* @param params the parameter signatures to be type-checked.
3059-
* @param typedEntity the type-annotated entity.
30603046
*/
30613047
function emitParameterTypeChecks(
30623048
code: CodeMaker,
30633049
context: EmitContext,
30643050
params: readonly string[],
3065-
typedEntity: string,
30663051
): void {
30673052
if (!context.runtimeTypeChecking) {
30683053
return;
@@ -3078,24 +3063,36 @@ function emitParameterTypeChecks(
30783063
return { name };
30793064
});
30803065

3081-
const typesVar = slugifyAsNeeded(
3082-
'type_hints',
3083-
paramInfo
3084-
.filter((param) => param.name != null)
3085-
.map((param) => param.name!.split(/\s*:\s*/)[0]),
3086-
);
3066+
const paramNames = paramInfo
3067+
.filter((param) => param.name != null)
3068+
.map((param) => param.name!.split(/\s*:\s*/)[0]);
3069+
const typesVar = slugifyAsNeeded('type_hints', paramNames);
30873070

30883071
let openedBlock = false;
30893072
for (const { is_rest, kwargsMark, name } of paramInfo) {
30903073
if (kwargsMark) {
3091-
// This is the keyword-args separator, we won't check keyword arguments here because the kwargs will be rolled
3092-
// up into a struct instance, and that struct's constructor will be checking again...
3093-
break;
3074+
if (!context.runtimeTypeCheckKwargs) {
3075+
// This is the keyword-args separator, we won't check keyword arguments here because the kwargs will be rolled
3076+
// up into a struct instance, and that struct's constructor will be checking again...
3077+
break;
3078+
}
3079+
// Skip this (there is nothing to be checked as this is just a marker...)
3080+
continue;
30943081
}
30953082

30963083
if (!openedBlock) {
30973084
code.openBlock('if __debug__');
3098-
code.line(`${typesVar} = typing.get_type_hints(${typedEntity})`);
3085+
const stubVar = slugifyAsNeeded('stub', [...paramNames, typesVar]);
3086+
// Inline a stub function to be able to have the required type hints regardless of what customers do with the
3087+
// code. Using a reference to the `Type.function` may result in incorrect data if some function was replaced (e.g.
3088+
// by a decorated version with different type annotations). We also cannot construct the actual value expected by
3089+
// typeguard's `check_type` because Python does not expose the APIs necessary to build many of these objects in
3090+
// regular Python code.
3091+
openSignature(code, 'def', stubVar, params, 'None');
3092+
code.line('...');
3093+
code.closeBlock();
3094+
3095+
code.line(`${typesVar} = typing.get_type_hints(${stubVar})`);
30993096
openedBlock = true;
31003097
}
31013098

packages/jsii-pacmak/lib/targets/python/type-name.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -403,20 +403,6 @@ export function toPythonFqn(fqn: string, rootAssm: Assembly) {
403403
return { assemblyName, packageName, pythonFqn: fqnParts.join('.') };
404404
}
405405

406-
/**
407-
* Computes the nesting-qualified name of a type.
408-
*
409-
* @param fqn the fully qualified jsii name of the type.
410-
* @param rootAssm the root assembly for the project.
411-
*
412-
* @returns the nesting-qualified python type name (the name of the class,
413-
* qualified with all nesting parent classes).
414-
*/
415-
export function toPythonFullName(fqn: string, rootAssm: Assembly): string {
416-
const { packageName, pythonFqn } = toPythonFqn(fqn, rootAssm);
417-
return pythonFqn.slice(packageName.length + 1);
418-
}
419-
420406
/**
421407
* Computes the python relative import path from `fromModule` to `toModule`.
422408
*

packages/jsii-pacmak/test/generated-code/__snapshots__/examples.test.js.snap

Lines changed: 21 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)