Skip to content

Conversation

@chadrik
Copy link
Contributor

@chadrik chadrik commented May 19, 2024

This is a first pass at adding type annotations throughout the code-base. Mypy is not fully passing yet, but it's getting close.

Fixes #1631

@chadrik chadrik requested a review from a team as a code owner May 19, 2024 17:38
@linux-foundation-easycla
Copy link

linux-foundation-easycla bot commented May 19, 2024

CLA Not Signed

for variant in self.iter_variants():
if variant.index == index:
return variant
return None
Copy link
Contributor Author

Choose a reason for hiding this comment

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

mypy prefer explicit return None statements

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 is a safe change so I left it.

raise ResolvedContextError(
"Cannot perform operation in a failed context")
return _check

Copy link
Contributor Author

Choose a reason for hiding this comment

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

mypy does not like these decorators defined at the class-level.

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 is a relatively safe change so I left it

@chadrik
Copy link
Contributor Author

chadrik commented May 19, 2024

Protocol and TypedDict are not available in the typing module until python 3.8.

We have a few options:

  1. vendor typing_extensions
  2. remove use of these typing classes until Rez drops support for python 3.7
  3. I can create a mock of Protocol and trick mypy into using it which is safe because it has no runtime behavior. Doing the same thing for TypedDict is more complicated, but possible.

@codecov
Copy link

codecov bot commented May 19, 2024

Codecov Report

Attention: Patch coverage is 86.34008% with 290 lines in your changes missing coverage. Please review.

Project coverage is 60.04%. Comparing base (dac6bcb) to head (faca3a9).

Files with missing lines Patch % Lines
src/rez/resolved_context.py 80.43% 25 Missing and 2 partials ⚠️
src/rez/resolver.py 51.85% 25 Missing and 1 partial ⚠️
src/rez/suite.py 68.65% 20 Missing and 1 partial ⚠️
src/rez/plugin_managers.py 75.00% 15 Missing and 1 partial ⚠️
src/rez/version/_version.py 92.26% 10 Missing and 4 partials ⚠️
src/rez/package_order.py 84.70% 11 Missing and 2 partials ⚠️
src/rez/build_system.py 64.28% 9 Missing and 1 partial ⚠️
src/rez/package_resources.py 79.59% 8 Missing and 2 partials ⚠️
src/rez/packages.py 87.17% 9 Missing and 1 partial ⚠️
src/rez/pip.py 0.00% 7 Missing ⚠️
... and 49 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1761      +/-   ##
==========================================
- Coverage   60.11%   60.04%   -0.08%     
==========================================
  Files         163      164       +1     
  Lines       20098    20514     +416     
  Branches     3494     3534      +40     
==========================================
+ Hits        12082    12317     +235     
- Misses       7205     7348     +143     
- Partials      811      849      +38     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@chadrik
Copy link
Contributor Author

chadrik commented May 20, 2024

I got bored and added lots more, particularly focused on the solver module. Once the solver module is complete, we can experiment with compiling it to a c-extension using mypyc, which could provide a big speed boost!

@chadrik
Copy link
Contributor Author

chadrik commented May 21, 2024

I now have rez.solver compiling as a C-extension with all tests passing. I'm very interested to see how the performance compares. Does anyone want to volunteer to help put together a performance comparison? Are there any known complex collection of packages to test against?

self.dirty = True
return super().append(*args, **kwargs)
if not TYPE_CHECKING:
def append(self, *args, **kwargs):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since this class inherits from list it's easier to rely on the type hints coming from that base class than to redefine them here, so we hide them by placing them behind not TYPE_CHECKING. In reality, the runtime value of TYPE_CHECKING is always False.


def get_plugin_class(self, plugin_type, plugin_name, expected_type=None):
"""Return the class registered under the given plugin name."""
plugin = self._get_plugin_type(plugin_type)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a new argument here to validate the returned result. This provides both runtime and static validation.

Choose a reason for hiding this comment

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

Could plugin_type be an enum or something like that? This would remove the need for expected_type right? Or maybe we could use overloads with Literals for expected_type?

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 uses type vars to describe a generic function, i.e. a function where the types of its arguments are related to each other. In our case, the type of the argument expected_type is related to the output type.

Using a literal would mean we would have to define a literal value and a function overload for every possible output type, like this:

    @overload
    def get_plugin_class(self, plugin_type: str, plugin_name: str, expected_type: Literal["Foo"]) -> type[Foo]:
        pass

    @overload
    def get_plugin_class(self, plugin_type: str, plugin_name: str, expected_type: Literal["Bar"]) -> type[Bar]:
        pass

In a plugin environment where users can define their own types, we obviously cannot define a string constant for every possible type.

Choose a reason for hiding this comment

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

Users can't define their own types. They can only create new plugin of some pre-defined types. Anybody adding new plugin types should do so in rez itself and not outside.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I implemented the Literal suggestion.

Args:
package_requests (list[typing.Union[str, PackageRequest]]): request
package_requests (list[typing.Union[str, Requirement]]): request
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I noticed that everywhere that we've documented types as PackageRequest, they appear to actually be Requirement. I'm not sure if there any real-world exceptions to this.

Choose a reason for hiding this comment

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

Here it's really supposed to be a PackageRequest if I'm not mistaken. But there is technically no differences between the two once the instantiated since PackageRequest inherits from Requirement and only overloads __init__ to check the inputs.

Copy link
Contributor Author

@chadrik chadrik Oct 15, 2024

Choose a reason for hiding this comment

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

The kind of haphazard use and documentation of PackageRequest and Requirement results in some very difficult situations to accurately add type annotations. If you want to see for yourself, check out the code, change this to PackageRequest and observe the new errors produced by mypy.


_pr("resolved packages:", heading)
rows = []
rows3: list[tuple[str, str, str]] = []
Copy link
Contributor Author

Choose a reason for hiding this comment

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

you can't redefine types with mypy, so you need to use new variable names.

if TYPE_CHECKING:
cached_property = property
else:
class cached_property(object):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

it's much easier to pretend that cached_property is property than to type hint all the subtleties of a descriptor.

Choose a reason for hiding this comment

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

But we loose the uncache method.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

typing.TYPE_CHECKING always resolve to False at runtime and True only during static analysis. So the code within the if TYPE_CHECKING block will never run. It's a way to simplify certain type analysis situations that arise.

Choose a reason for hiding this comment

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

typing.TYPE_CHECKING always resolve to False

I know, but we are loosing stuff during typing. That's my whole point (the same apply to all my comments that are similar to this one).

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 what way would the static analysis be degraded by using property instead of cached_property? To my knowledge, they are functionally equivalent from a static analysis POV: the types returned are the same.

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 another thread you mentioned losing the uncache method. Unfortunately, it's not possible to type annotate this in a way both the return type and preserve the uncached method. It's something I've looked into pretty extensively.

IMO, the best path forward is to migrate to functools.cached_property. Note that to clear the cache with functools.cached_property you simply delete the attribute.

"""Reset the solver, removing any current solve."""
if not self.request_list.conflict:
phase = _ResolvePhase(self.request_list.requirements, solver=self)
phase = _ResolvePhase(solver=self)
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 appears to be a bug: _ResolvePhase only takes one argument. mypy to the rescue.

@chadrik
Copy link
Contributor Author

chadrik commented May 21, 2024

I found rez-benchmark. Interestingly, rez is slower with the compiled rez.solver. It could be because there are many modules and classes used by rez.solver which have not been compiled.

I probably won't have time to dig into this much more, but once this PR is merged I'll make a new PR with the changes necessary for people to test the compiled version of rez.

@chadrik
Copy link
Contributor Author

chadrik commented May 21, 2024

Note: this PR likely invalidates #1745

@chadrik
Copy link
Contributor Author

chadrik commented Jun 5, 2024

@instinct-vfx Can you or someone from the Rez group have a look at this, please?

@JeanChristopheMorinPerso JeanChristopheMorinPerso added the Blocked by CLA Waiting on CLA to be signed label Jun 22, 2024
return self.build_system.working_dir

def build(self, install_path=None, clean=False, install=False, variants=None):
def build(self, install_path: str | None = None, clean: bool = False,

Choose a reason for hiding this comment

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

Using str | None means we need to drop support for python 3.7. I'm not sure we are ready for this yet.

Copy link
Contributor Author

@chadrik chadrik Jun 22, 2024

Choose a reason for hiding this comment

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

No, use of str | None is safe in python 3.7 as long as you use from __future__ import annotations. This backports behavior from python 3.9 that ensures that type annotations are recorded as strings within __annotations__ attributes, which means they are not evaluated at runtime unless inspect.get_annoations is called. The effect of from __future__ import annotations is that you can put basically anything you want into an annotation, it doesn't need to be valid at runtime.

The only thing breaking python 3.7 compatibility here is the use of TypedDict and Protocol, as mentioned in another comment. I presented 3 options for preserving the use of these classes in the other comment.

I noticed that the only python 3.7 tests that are currently run are for MacOS, which I took as an indicator that python 3.7 would be dropped soon. Is there a schedule for deprecation?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

By the way, I fixed the python 3.7 compatibility issue with TypedDict and Protocol, so that should not be a blocker anymore.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's been a year since this thread was started: has rez dropped python 3.7 support yet?

This PR will work for python 3.7, but I can remove some workarounds if we've dropped support.

Choose a reason for hiding this comment

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

Not yet, but we could do this in the next release.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a check to mypy to ensure it raises errors if we use any unsupported typing features.

There are a few cases of unsupported features:

  • dict[X], list[X]: use from __future__ import annotations
  • X | None: use from __future__ import annotations
  • typing.Self or other type not in typing: use if TYPE_CHECKING`

Should I document this somewhere?

For now, I recommend that we keep using from __future__ import annotations so that we can use the modern form.

Here's a doc I compiled of typing changes by python version:

https://docs.google.com/document/d/1uOJUvgjPDwP-PRYkmlypOAPtJ9s4nWYm6WRfrAAsn0g/edit?usp=sharing

@JeanChristopheMorinPerso
Copy link
Member

@chadrik You need to sign the CLA before we can even start to look at the PR.

@chadrik
Copy link
Contributor Author

chadrik commented Jun 22, 2024

@JeanChristopheMorinPerso

@chadrik You need to sign the CLA before we can even start to look at the PR.

I work for Scanline, which is owned by Netflix, and I'm meeting with our CLA manager on Monday. I made this contribution on my own time: can choose individual vs corporate CLA on a per PR basis?

@JeanChristopheMorinPerso
Copy link
Member

I made this contribution on my own time: can choose individual vs corporate CLA on a per PR basis?

I don't think you "can" but your account can be associated to an an ICLA and a CCLA. But I'm not a lawyer so I can't help more than that. If you and or your employer/CLA manager have questions, you can contact the LF support by following the link in the EasyCLA comment: #1761 (comment).

@chadrik chadrik force-pushed the typing branch 4 times, most recently from e73c6c1 to 961420b Compare June 22, 2024 23:10
@chadrik
Copy link
Contributor Author

chadrik commented Jul 1, 2024

CLA is signed!



class PackageOrderList(list):
class PackageOrderList(List[PackageOrder]):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note that we use typing.List here instead of list because list does not become indexable at runtime until python 3.9. It's still safe to use list[X] inside annotations as long as we use from __future__ import annotations.

@JeanChristopheMorinPerso JeanChristopheMorinPerso removed the Blocked by CLA Waiting on CLA to be signed label Jul 1, 2024
@chadrik
Copy link
Contributor Author

chadrik commented Jul 18, 2024

Any thoughts on this PR?

@JeanChristopheMorinPerso
Copy link
Member

Hey @chadrik, I made a first good read last week and I'll try to do another one soon. If you have the time, I would really love to see a GitHub Actions workflow that would run mypy on all pull requests.

@chadrik
Copy link
Contributor Author

chadrik commented Jul 21, 2024

I would really love to see a GitHub Actions workflow that would run mypy on all pull requests.

Me too!

The challenge is that there are still a lot of errors. These are not the result of incorrect annotations, but rather due to code patterns which are difficult to annotate correctly without more invasion refactors. For example, there are quite a few objects which dynamically generate attributes, and that's a static typing anti-pattern.

If you'd like an Action that runs mypy but allows failure for now, that's pretty easy, but if you want failures to block PRs, that'll take a lot more work. I'd prefer not to make that a blocker to merging this, though, because I've had to rebase and fix merge conflicts a few times already.

I do have a plan for how we can get to zero failures in the mid-term: I wrote a tool which allows you to specify patterns for errors to ignore, but I need to update it.

@JeanChristopheMorinPerso
Copy link
Member

I think we can introduce a workflow that will fail for newly introduced errors and warnings. I'm sure someone already thought of that somewhere and we can probably re-use what they did?

Basically, I'd like to verify that your changes work as expected and that we don't regress in the future and that new code is typed hint. Mypy can also be configured on a per module basis right?

@chadrik
Copy link
Contributor Author

chadrik commented Apr 24, 2025

I'm motivated again to get this merged. I've started addressing some of the outstanding notes here, but I would love it if we could focus on blocker issues.

@chadrik
Copy link
Contributor Author

chadrik commented Jun 20, 2025

In the next week or two I'll do a pass to rebase this and remove anything that looks even remotely like a runtime change.

@JeanChristopheMorinPerso
Copy link
Member

In the next week or two I'll do a pass to rebase this and remove anything that looks even remotely like a runtime change.

Thanks @chadrik. This will definitely help us get this merged with more confidence (not that we don't trust you, but this PR is quite big).

@chadrik chadrik force-pushed the typing branch 2 times, most recently from 4129ab0 to 1ca4a2f Compare July 9, 2025 03:21
@chadrik
Copy link
Contributor Author

chadrik commented Jul 9, 2025

@JeanChristopheMorinPerso I've removed everything that could be considered remotely risky.

@chadrik
Copy link
Contributor Author

chadrik commented Jul 19, 2025

Any chance that this can get merged? I’ve removed everything but the addition of annotations.

@JeanChristopheMorinPerso JeanChristopheMorinPerso added this to the Next milestone Oct 17, 2025
@JeanChristopheMorinPerso
Copy link
Member

Hey @chadrik when you get a chance, could you rebase your PR please? Now that we dropped support for Python 3.7, your PR is next in line to be released.

@JeanChristopheMorinPerso
Copy link
Member

You should also look into the CLA. EasyCLA doesn't seem to be happy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add type hinting

3 participants