-
Notifications
You must be signed in to change notification settings - Fork 126
refactor: rely on type annotations instead of casts in hookcmds #2179
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
refactor: rely on type annotations instead of casts in hookcmds #2179
Conversation
ops/hookcmds/_action.py
Outdated
| result = cast('dict[str, Any]', json.loads(stdout)) if key is None else json.loads(stdout) | ||
| return result | ||
| return json.loads(stdout) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This logic is captured by the overloads.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Curious, I would have imagined that the cast was there specifically to assuage pyright fears wrt the overloads.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The cast was added to explicitly document what type we get back from Juju (consistently, across the entire package. Since json.loads returns Any, the awful "ignore all typing" trick, the casting isn't needed if you're willing to trust that the combination of overloads (if any) and primary method signature are enough.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note also that any types declared in the function body don't directly interact with the overload signatures. Pyright only checks that the implementation signature is compatible with the overload signatures, and checks that what it knows about the real return type matches the implementation signature.
ops/hookcmds/_other.py
Outdated
| else: | ||
| result = cast('dict[str, bool | int | float | str]', json.loads(stdout)) | ||
| return result | ||
| return json.loads(stdout) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This logic is captured by the overloads.
ops/hookcmds/_relation.py
Outdated
| else: | ||
| result = cast('dict[str, str]', json.loads(stdout)) | ||
| return result | ||
| return json.loads(stdout) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This logic is captured by the overloads.
ops/hookcmds/_state.py
Outdated
| result = ( | ||
| cast('dict[str, str]', json.loads(stdout)) | ||
| if key is None | ||
| else cast('str', json.loads(stdout)) | ||
| ) | ||
| return result | ||
| return json.loads(stdout) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This logic is captured by the overloads.
benhoyt
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the simplifications, and prefer the type declaration style over the cast() too.
Any reason this should still be a draft PR?
I have no objection to this change.
I feel like this goes in the opposite direction from the decision we just made to always have a return type annotation. The argument then was that it's protection against the code and the return value type accidentally diverging, because you get the inferred type and the explicit type. Here, the suggestion is that we go away from the explicit type in the return and explicit casting (or annotating) in the body to only having one of those. I'm also unsure whether we should be relying on overloads to be the documentation for what Juju returns (having this documentation is the critical point here, in my opinion, because it's not anywhere else other than the Juju code, and it was a lot of work to figure it all out, and we don't want to make it hard for people to work with this in the future). For example, this is a common pattern: @overload
def tool(key: None = None) -> dict[str, str]: ...
def tool(key: str) -> str: ...
def tool(key: str | None = None) -> str | dict[str, str]:
...
if key is not None:
result: str = json.loads(stdout)
else:
result: dict[str, str] = json.loads(stdout)
return resultIf that is instead: @overload
def tool(key: None = None) -> dict[str, str]: ...
def tool(key: str) -> str: ...
def tool(key: str | None = None) -> str | dict[str, str]:
...
return json.loads(stdout)It's shorter and simpler and will pass the type checks, and the type checker will be able to figure out whether the caller is getting a @benhoyt asked for all of
I have some sympathy for this argument. However, it's typically just going to be a cast or annotation, so I don't think there's much risk of, for example, code wrongly diverging. From a coverage point of view, I feel it's actually nicer, since you can tell if there are tests exercising each type, which I don't think you can get otherwise (there's no "typing coverage", right?). I suspect that this is best discussed in real-time rather than solely in a PR. |
tonyandrewmeyer
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this approach was specifically requested in a PR review only a couple of months ago, and since it breaks the consistency that was particularly wanted, and since I'm not convinced that we gain enough from it, or that we are left with enough documentation for humans, I would prefer that this is discussed in real-time before giving this an "approve". Please organise a call (or use the daily Charm Tech sync) for that.
ops/hookcmds/_action.py
Outdated
| result = cast('dict[str, Any]', json.loads(stdout)) if key is None else json.loads(stdout) | ||
| return result | ||
| return json.loads(stdout) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The cast was added to explicitly document what type we get back from Juju (consistently, across the entire package. Since json.loads returns Any, the awful "ignore all typing" trick, the casting isn't needed if you're willing to trust that the combination of overloads (if any) and primary method signature are enough.
Merge branch 'main' into 25-11+refactor+drop-unnecessary-cast-from-hooktools
tonyandrewmeyer
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks!
hookcmdsfunctions that return values generally work by callingjson.loadson the result of a hook command CLI invocation. Values returned byjson.loadsare typed asAny, but we know more about the values returned by different hook commands than this.Currently, this is expressed by calling
typing.castwhen callingjson.loads. This PR changes this to be expressed with type annotations, for improved readability and lower runtime overhead.