Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(pacmak): allow typeguard==3.x and typeguard==4.x #4611

Merged
merged 20 commits into from
Aug 27, 2024

Conversation

iliapolo
Copy link
Contributor

@iliapolo iliapolo commented Aug 22, 2024

Currently python projects generated by pacmak define a typeguard dependency range of typeguard~=2.13.3, which prevents users from brining newer major version of typeguard into their projects.

This PR adds support for typeguard==3.x and typguard==4.x, which are the latest versions currently available. We intentionally do not allow an open range because every major version brings breaking changes with it that might need to be addressed.

Notes

  • We couldn't just drop support for typeguard==2.x because that would be a breaking change.
  • We could potentially release a new major version of pacmak to make the code more maintainable. My take is that this PR doesn't complicate the code to the extent of justifying a new major version, but lets discuss.

Fixes #4469


By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@mergify mergify bot added the contribution/core This is a PR that came from AWS. label Aug 22, 2024
@iliapolo iliapolo changed the title feat: allow any major version of typeguard feat(pacmak): allow typeguard==3.x and typeguard==4.x Aug 27, 2024
@@ -168,6 +168,10 @@ def implements(*interfaces: Type[Any]) -> Callable[[T], T]:
def deco(cls):
cls.__jsii_type__ = getattr(cls, "__jsii_type__", None)
cls.__jsii_ifaces__ = getattr(cls, "__jsii_ifaces__", []) + list(interfaces)
cls.__jsii_proxy_class__ = lambda: getattr(cls, "__jsii_proxy_class__", None)
Copy link
Contributor Author

@iliapolo iliapolo Aug 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is required because __jsii_proxy_class__ is added to protocols, and therefore should be implemented in classes.

code.line(
'# Adding a "__jsii_proxy_class__(): typing.Type" function to the interface',
);

For pacmak generated classes, __jsii_proxy_class__ is autoamtically added:

code.line(
'# Adding a "__jsii_proxy_class__(): typing.Type" function to the abstract class',
);

But in order to support user defined protocol implementations, this needs to be added via the @jsii.implements decorator as well.

Note that prior to 4.x, typeguard didn't actually type check our protocols so this wasn't needed.

@@ -27,17 +27,6 @@ def test_inheritance_maintained(self):

assert base_names == ["DerivedStruct", "MyFirstStruct"]

def test_descriptive_error_when_passing_function(self):
Copy link
Contributor Author

@iliapolo iliapolo Aug 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to test_runtime_checking.py, where all other type checking is asserted on.

code.closeBlock();
code.openBlock('else');
code.line(
'if isinstance(value, jsii._reference_map.InterfaceDynamicProxy): # pyright: ignore [reportAttributeAccessIssue]',
Copy link
Contributor Author

@iliapolo iliapolo Aug 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typeguard doesn't support type checking against dynamic proxies because it only compares against type declaration:

def check_protocol(
    value: Any,
    origin_type: Any,
    args: tuple[Any, ...],
    memo: TypeCheckMemo,
) -> None:
    subject: type[Any] = value if isclass(value) else type(value)

Prior to version 4.x, this wasn't needed because typeguard did not even attempt it. Its protocol checking logic was completely different:

def check_protocol(argname: str, value, expected_type):
    # TODO: implement proper compatibility checking and support non-runtime protocols
    if getattr(expected_type, '_is_runtime_protocol', False):
        if not isinstance(value, expected_type):
            raise TypeError('type of {} ({}) is not compatible with the {} protocol'.
                            format(argname, type(value).__qualname__, expected_type.__qualname__))

This code never got executed on our protocols because _is_runtime_protocol defaults to False, and is only set to True if the protocol is decorated with @typing.runtime_checkable. Since our protocols aren't decorated with it, the protocol type checking never took place.

I considered adding the @typing.runtime_checkable decorator to gain protocol checking with versions 2.x and 3.x, but decided against it since i'm not sure what the implications would be and it might break existing projects.

So with support for 4.x we are now adding protocol checking, but still avoiding runtime proxies because they are not handled properly by typeguard.

@@ -28,9 +28,11 @@
"build": "cp ../../../README.md . && rm -f jsii-*.whl && npm run generate && npm run deps && npm run lint",
"lint": "ts-node build-tools/venv.ts black .",
"package": "package-python && package-private",
"test": "npm run test:gen && npm run test:run && npm run test:types",
"test": "npm run test:gen && npm run test:run:typeguard-2 && npm run test:run:typeguard-3 && npm run test:run:typeguard-4 && npm run test:types",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will now run three test suites, for each typeguard major version we support. These tests are pretty quick so compared to the current latency of PR validation, they don't add much.

code.line('from typeguard import check_type');

code.line('import typeguard');
code.line('from importlib.metadata import version as _metadata_package_version');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was added in Python 3.8, which is the lowest version of python we support - so its safe to use. Aliasing to avoid possible conflicts with other generated code.



@jsii.implements(jsii_calc.IBellRinger)
class PythonInvalidBellRinger:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for 4.x we can now statically typecheck protocols so added a test for it.



@pytest.mark.skipif(TYPEGUARD_MAJOR_VERSION != 3, reason="requires typeguard 3.x")
class TestRuntimeTypeCheckingTypeGuardV3:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is the primary maintenance burden we introduce with supporting multiple major versions. We have 3 classes, each testing a different major version, but containing 99% equal test cases. Note however that assertions are different because typeguard changed both error messages and exception types.

Given that typeguard only actively maintains a single version line (current 4.x) I think our most likely changes to this file are additional test cases that are only supported in 4.x, and therefore do not need backporting to the older test suites. If however we identify a coverage gap that applies to all major versions, we will have to add the test 3 times. I feel this possible overhead doesn't merit a new major version of pacmak, but interested in more thoughts here.

There are probably also ways to increase code sharing between the different 3 test classes that I didn't dive deeper into, but we could also investigate this route if you think its worth while.

code.openBlock('else');
code.openBlock('if TYPEGUARD_MAJOR_VERSION == 3');
code.line(
'typeguard.config.collection_check_strategy = typeguard.CollectionCheckStrategy.ALL_ITEMS',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In version 3.x collection_check_strategy can only be configured globally, while in 4.x it can be specified as an argument to check_type.

@iliapolo iliapolo marked this pull request as ready for review August 27, 2024 09:26
Copy link
Contributor

mergify bot commented Aug 27, 2024

Thank you for contributing! ❤️ I will now look into making sure the PR is up-to-date, then proceed to try and merge it!

@mergify mergify bot added the pr/ready-to-merge This PR is ready to be merged. label Aug 27, 2024
Copy link
Contributor

mergify bot commented Aug 27, 2024

Merging (with squash)...

@mergify mergify bot merged commit 32ae5b0 into main Aug 27, 2024
38 checks passed
@mergify mergify bot deleted the epolon/upgrade-typeguard branch August 27, 2024 14:04
@mergify mergify bot removed the pr/ready-to-merge This PR is ready to be merged. label Aug 27, 2024
mergify bot pushed a commit that referenced this pull request Aug 29, 2024
…served (#4625)

In #4611, we added the `_jsii_proxy_class__` attributes to the `@jsii.interface` implementations. This was required in order to comply with `typeguard` protocol checking. We didn't implement it correctly, accidentally overriding user defined proxy classes.

## Note

I have been wrecking my brain trying to understand if this bug has any runtime implications, and I couldn't find any.

#### How so?

At runtime, from what I could gather, the `__jsii_proxy_class__` attribute is only used when we try to instantiate a subclass of an abstract class:

https://github.com/aws/jsii/blob/dc77d6c7016bcb7531f6e374243410f969ea1fbf/packages/%40jsii/python-runtime/src/jsii/_reference_map.py#L65-L70

However, for abstract classes, we assign an explicit value to `__jsii_proxy_class__`:

https://github.com/aws/jsii/blob/dc77d6c7016bcb7531f6e374243410f969ea1fbf/packages/jsii-pacmak/lib/targets/python.ts#L1496-L1501

Luckily, this happens **AFTER** the `@jsii.implements` decorator has finished, thus overriding the mistake in the decorator.
Presumably, this would still be a problem for user defined abstract classes (since they don't have this assignment). However, reference resolving for user defined classes is done via native reference lookup:

https://github.com/aws/jsii/blob/dc77d6c7016bcb7531f6e374243410f969ea1fbf/packages/%40jsii/python-runtime/src/jsii/_reference_map.py#L48-L54

This is also why I couldn't come up with a real life test case, and had to resort to an artificial one. 

---

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
mrgrain pushed a commit that referenced this pull request Aug 30, 2024
…served (#4625)

In #4611, we added the `_jsii_proxy_class__` attributes to the `@jsii.interface` implementations. This was required in order to comply with `typeguard` protocol checking. We didn't implement it correctly, accidentally overriding user defined proxy classes.

## Note

I have been wrecking my brain trying to understand if this bug has any runtime implications, and I couldn't find any.

#### How so?

At runtime, from what I could gather, the `__jsii_proxy_class__` attribute is only used when we try to instantiate a subclass of an abstract class:

https://github.com/aws/jsii/blob/dc77d6c7016bcb7531f6e374243410f969ea1fbf/packages/%40jsii/python-runtime/src/jsii/_reference_map.py#L65-L70

However, for abstract classes, we assign an explicit value to `__jsii_proxy_class__`:

https://github.com/aws/jsii/blob/dc77d6c7016bcb7531f6e374243410f969ea1fbf/packages/jsii-pacmak/lib/targets/python.ts#L1496-L1501

Luckily, this happens **AFTER** the `@jsii.implements` decorator has finished, thus overriding the mistake in the decorator.
Presumably, this would still be a problem for user defined abstract classes (since they don't have this assignment). However, reference resolving for user defined classes is done via native reference lookup:

https://github.com/aws/jsii/blob/dc77d6c7016bcb7531f6e374243410f969ea1fbf/packages/%40jsii/python-runtime/src/jsii/_reference_map.py#L48-L54

This is also why I couldn't come up with a real life test case, and had to resort to an artificial one. 

---

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
contribution/core This is a PR that came from AWS.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Upgrade version of typeguard in python runtime
2 participants