Skip to content

Add type annotations#1761

Open
chadrik wants to merge 1 commit intoAcademySoftwareFoundation:mainfrom
chadrik:typing
Open

Add type annotations#1761
chadrik wants to merge 1 commit intoAcademySoftwareFoundation:mainfrom
chadrik:typing

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 Signed

The committers listed above are authorized under a signed CLA.

  • ✅ login: chadrik / name: Chad Dombrova (8b590da)

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

❌ Patch coverage is 88.06604% with 253 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.08%. Comparing base (9afb325) to head (8b590da).
⚠️ Report is 12 commits behind head on main.

Files with missing lines Patch % Lines
src/rez/resolved_context.py 80.43% 25 Missing and 2 partials ⚠️
src/rez/resolver.py 67.30% 16 Missing and 1 partial ⚠️
src/rez/plugin_managers.py 76.19% 14 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/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 ⚠️
src/rez/build_process.py 77.77% 5 Missing and 1 partial ⚠️
src/rez/solver.py 96.68% 5 Missing and 1 partial ⚠️
... and 48 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1761      +/-   ##
==========================================
+ Coverage   59.98%   60.08%   +0.10%     
==========================================
  Files         163      164       +1     
  Lines       20118    20527     +409     
  Branches     3519     3558      +39     
==========================================
+ Hits        12067    12333     +266     
- Misses       7230     7336     +106     
- Partials      821      858      +37     

☔ 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.


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.

Choose a reason for hiding this comment

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

Ok, I took a look and I think you are right. I was afraid that something down the line would use the .ephemeral attribute of PackageRequest, but it doesn't look like it. And Honestly, the ResolvedContext is cursed. PackageRequest exists to validate the name in the request, but it's kind of used both as a validation layer and just as a simple Requirement. Its usage is pretty inconsistent, and it's also used in places where I wouldn't expect it to be used.


_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

Choose a reason for hiding this comment

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

Thank you. I guess we could add this to our contribution docs.

@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?

Copy link
Member

@JeanChristopheMorinPerso JeanChristopheMorinPerso left a comment

Choose a reason for hiding this comment

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

Second batch of comments. Note that I'm not yet done with the review. Please don't push changes until I'm done.

@chadrik
Copy link
Contributor Author

chadrik commented Mar 1, 2025

It would be great if all this work didn't go to waste. If I rebase this, can we get it merged?

@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 3 times, most recently from c0a653d to f83ccc6 Compare June 27, 2025 16:37
@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.

@chadrik
Copy link
Contributor Author

chadrik commented Dec 16, 2025

@JeanChristopheMorinPerso this is rebased and all tests are passing. I just have to get the CLA fixed up.

Signed-off-by: Chad Dombrova <chadrik@gmail.com>
@chadrik
Copy link
Contributor Author

chadrik commented Jan 6, 2026

Happy New Year! CLA and commits are both signed.

@JeanChristopheMorinPerso
Copy link
Member

Yay! Thank you @chadrik! This will go in the next release. I'll do one last review (maybe later this week) before merging, just in case.

Thanks again!

Copy link
Member

@JeanChristopheMorinPerso JeanChristopheMorinPerso left a comment

Choose a reason for hiding this comment

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

Thanks @chadrik I left a few comments. Most are very easy to address. I also left a few questions that I would like to get an answer on before merging, if possible.

I promise, this is the last batch of comments and questions :)

self.value = value

def __eq__(self, other):
def __eq__(self, other: object) -> bool:

Choose a reason for hiding this comment

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

In all the other classes in this module, you use the actual class for other in the operators. Is there a reason why you use object here instead of the class?

_Bound.any = _Bound()


def action(fn: CallableT) -> CallableT:

Choose a reason for hiding this comment

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

Suggested change
def action(fn: CallableT) -> CallableT:
def _action(fn: CallableT) -> CallableT:

I feel like this should be a private function.

return result
return fn_

@action

Choose a reason for hiding this comment

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

Suggested change
@action
@_action


self.bounds.append(_Bound(lower_bound, upper_bound))

@action

Choose a reason for hiding this comment

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

Suggested change
@action
@_action


self.bounds.append(_Bound(lower_bound, upper_bound))

@action

Choose a reason for hiding this comment

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

Suggested change
@action
@_action


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

Choose a reason for hiding this comment

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

Ok, I took a look and I think you are right. I was afraid that something down the line would use the .ephemeral attribute of PackageRequest, but it doesn't look like it. And Honestly, the ResolvedContext is cursed. PackageRequest exists to validate the name in the request, but it's kind of used both as a validation layer and just as a simple Requirement. Its usage is pretty inconsistent, and it's also used in places where I wouldn't expect it to be used.

request: list[Requirement | PackageRequest] = []
for variant in self.resolved_packages:
req = PackageRequest(variant.qualified_package_name)
request.append(req)

Choose a reason for hiding this comment

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

This seems weird to me. I believe this was removed by mistake?

@@ -5,6 +5,8 @@
"""
Builds packages on local host
"""
from __future__ import annotations

from rez.config import config
from rez.package_repository import package_repository_manager
from rez.build_process import BuildProcessHelper, BuildType

Choose a reason for hiding this comment

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

Suggested change
from rez.build_process import BuildProcessHelper, BuildType
from rez.build_process import BuildProcessHelper, BuildType
from rez.build_system import BuildResult

Comment on lines +34 to +43
from rez.build_system import BuildResult

# FIXME: move this out of TYPE_CHECKING block when python 3.7 support is dropped
class LocalBuildResult(BuildResult, total=False):
package_install_path: str
variant_install_path: str

else:
LocalBuildResult = dict

Choose a reason for hiding this comment

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

Suggested change
from rez.build_system import BuildResult
# FIXME: move this out of TYPE_CHECKING block when python 3.7 support is dropped
class LocalBuildResult(BuildResult, total=False):
package_install_path: str
variant_install_path: str
else:
LocalBuildResult = dict
class LocalBuildResult(BuildResult, total=False):
package_install_path: str
variant_install_path: str

else:
expanded_value = winreg.ExpandEnvironmentStrings(reg_value)
paths.extend(expanded_value.split(os.pathsep))
if sys.platform == "win32":

Choose a reason for hiding this comment

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

Why is this if needed?

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