Use type hints on properties instead of model argument#127
Use type hints on properties instead of model argument#127
model argument#127Conversation
model argument
Barecheck - Code coverage reportTotal: 92.34%Your code coverage diff: 0.17% ▴ Uncovered files and lines
|
|
To be clear, I think this is mergeable but after v0.0.10. I propose:
|
Thinking about this a bit more whilst walking, the name It may be useful to also have |
I think there are cases for running code both before and after the value is set. I was envisaging that In Javascript, the various I can't immediately see a use case for |
In order to eliminate the `model` argument, we need to inspect the type annotation after __init__. This commit implements that behaviour, and adds some docstrings that were missing. In doing this, I think I really want to refactor and clarify the ThingProperty class - I will make an issue.
Now that ThingProperty is generic, we should make the decorator @thing_property generic. This should ensure properties have the type of the getter's return value.
This commit changes ThingProperty to raise an error if it is used without type hints. I have updated the test code accordingly, including tests for un-annotated properties raising an error. I've also tweaked the type hints on the @thing_property and @thing_setting decorators. MyPy was giving errors that their argument was a subtype of Thing rather than Thing, so I have now used `Callable[..., Value]` (i.e. the arguments are not checked, but the return value is).
Python < 3.12 wraps errors in __set_name__ in a RuntimeError. I've updated the test accordingly. We now test that either the exception is the expected one, or that the expected exception is the direct cause.
This was left behind when performing a rebase
e7412fe to
2e684e7
Compare
julianstirling
left a comment
There was a problem hiding this comment.
I have left quite a few comments, mostly on doc strings where I was confused.
Having multiple ways to define the type seems very confusing. As do having so many modes of operation, especially modes that do not do what would be expected.
| @@ -0,0 +1,292 @@ | |||
| import logging | |||
There was a problem hiding this comment.
This was added back in on the rabase! Sorry about this I will fix it.
| @@ -0,0 +1,227 @@ | |||
| from __future__ import annotations | |||
There was a problem hiding this comment.
This was also added back in on the rabase! Sorry about this I will fix it.
|
|
||
|
|
||
| class ThingProperty: | ||
| class MissingTypeError(TypeError): |
There was a problem hiding this comment.
Are we assuming these are internal only or should we expose them in exceptions.py?
There was a problem hiding this comment.
It's probably worth exposing. That could be in this PR or a future one - given that I'm planning to refactor it anyway it wouldn't be ridiculous to move the exception then?
| # There was an intention to make ThingProperty generic in 2 variables, one for | ||
| # the value and one for the owner, but this was problematic. | ||
| # For now, we'll stick to typing the owner as a Thing. | ||
| # We may want to search-and-replace the Owner symbol, but I think it is | ||
| # helpful for now. I don't think NewType would be appropriate here, | ||
| # as it would probably raise errors when defining getter/setter methods. |
There was a problem hiding this comment.
I am suggesting updating this as I had to read it at least 10 times to understand what it meant
| # There was an intention to make ThingProperty generic in 2 variables, one for | |
| # the value and one for the owner, but this was problematic. | |
| # For now, we'll stick to typing the owner as a Thing. | |
| # We may want to search-and-replace the Owner symbol, but I think it is | |
| # helpful for now. I don't think NewType would be appropriate here, | |
| # as it would probably raise errors when defining getter/setter methods. | |
| # Note: ThingProperty has a generic TypeVar for the value, but the `Owner` type | |
| # is hard coded to `Thing` via a type alias. | |
| # Making the `Owner` type generic has proved problematic. However, we have | |
| # kept the `Owner` type to minimise code changes if this becomes more generic. | |
| # Using `NewType` was considered, but not used as it will probably raise errors when | |
| # defining getter/setter methods. |
There was a problem hiding this comment.
Fine by me - I won't commit it yet though, as it sounds like you might have more commits to make.
| """ | ||
| self._name = name | ||
| value_types: dict[str, type[Value]] = {} | ||
| if hasattr(self, "_model_arg"): |
There was a problem hiding this comment.
Using hasattr rather than ensuring that the variable is set at _init_ is confusing. Setting the type to Optional, initialising with None, and checking is None is more explicit and clear.
There was a problem hiding this comment.
There are a few places where None needs to be handled differently from an unset variable. I experimented with sentinel values but that seemed to be adding complication to something that could be solved more cleanly with hasattr.
If hasattr is considered bad I can probably reduce its use, though i will be hard to eliminate completely.
Is there a style guide that this comes from? If so I should probably read it...
| # Try to generate a DataSchema, so that we can raise an error that's easy to | ||
| # link to the offending ThingProperty |
There was a problem hiding this comment.
I don't understand this comment. Is it a TODO? or is it explaining something else?
There was a problem hiding this comment.
Ah, that comment referred to the line below (line 60 as-was): we ran type_to_dataschema(self.model) to ensure that any exception it raised would happen when the property was defined, and thus reference the right bit of code.
The function call and a similar comment should now be in __set_name__. This comment can be deleted.
| # Try to generate a DataSchema, so that we can raise an error that's easy to | |
| # link to the offending ThingProperty |
| if name in owner_annotations: | ||
| # If the property has a type annotation on the owning class, we can use that | ||
| value_types["class_annotation"] = owner_annotations[name] | ||
| if hasattr(self, "__orig_class__"): |
There was a problem hiding this comment.
Please define variable at init rather than using hasattr(self, ...
There was a problem hiding this comment.
That is not possible. __orig_class__ is added by Python, after __init__ is called but before __set_name__. It's not a published language feature though, so I should probably add a link or two. While at least one of the posters warns it's not supported, it does appear to work in all the Python versions we support.
While it's technically an implementation detail, it has been available since Python 3.5 and the situations where it doesn't work don't apply to ThingProperty.
| # We were instantiated as BaseThingProperty[ModelType] so can use that type | ||
| value_types["__orig_class__"] = typing.get_args(self.__orig_class__)[0] |
There was a problem hiding this comment.
I have no idea what this means!?
There was a problem hiding this comment.
There are several ways to set the type. Rather than have a long and complicated nested if/else structure, I put all the specified types into the dictionary, then check all the values agree if there is more than one. I thought I'd added a comment to say so, but maybe it need improving...
| ) | ||
| print( | ||
| f"Initializing property '{name}' on '{owner}', {value_types}." | ||
| ) # TODO: Debug print statement, remove |
There was a problem hiding this comment.
Comments on the end of lines cause havoc with autoformatting because they unnecessarily wrap short lines making readability worse
There was a problem hiding this comment.
eugh, yes that is nasty. I must have not checked that after running ruff format.
Though, in this case I should also just have deleted that line!
| type_to_dataschema(self.model) | ||
|
|
||
| def __set_name__(self, owner, name: str): | ||
| def __set_name__(self, owner: type[Owner], name: str) -> None: |
There was a problem hiding this comment.
This function is doing so much I got to the end and had no idea what it had done. Does it need to be this complex? I think actually trying to write real unit tests that test every permutation of this function is going to be a close to impossible.
rwb27
left a comment
There was a problem hiding this comment.
I think there's a discussion to be had about whether this PR still makes sense, or whether it should be rolled up with a bigger change, that alters the signature of ThingProperty and gets rid of confusing behaviour.
If it is valuable to merge this in, as something that adds type hints but doesn't intentionally change existing behaviour (beyond raising errors if types are missisng), then it's worth revising and merging. However, after reading your comments I am starting to think it might make more sense to refactor ThingProperty as we've discussed elsewhere, and either alter this PR to do that, or close this one and start a new one - that might be altogether clearer.
I should also give thought to where I've used hasattr. I can probably eliminate some, though not all, uses of it.
|
|
||
|
|
||
| class ThingProperty: | ||
| class MissingTypeError(TypeError): |
There was a problem hiding this comment.
It's probably worth exposing. That could be in this PR or a future one - given that I'm planning to refactor it anyway it wouldn't be ridiculous to move the exception then?
| # There was an intention to make ThingProperty generic in 2 variables, one for | ||
| # the value and one for the owner, but this was problematic. | ||
| # For now, we'll stick to typing the owner as a Thing. | ||
| # We may want to search-and-replace the Owner symbol, but I think it is | ||
| # helpful for now. I don't think NewType would be appropriate here, | ||
| # as it would probably raise errors when defining getter/setter methods. |
There was a problem hiding this comment.
Fine by me - I won't commit it yet though, as it sounds like you might have more commits to make.
| _model_arg: type[Value] | ||
| """The type of the model argument, if specified.""" | ||
| _value_type: type[Value] | ||
| """The type of the value, may or may not be a Pydantic model.""" |
There was a problem hiding this comment.
None is a valid type annotation - a perverse one, but it's valid. I settled on not setting the attribute when it has no value rather than using None because it eliminates some unnecessary type checking code.
| behaves like a variable. The `getter` is only on first access. | ||
| The property may be written to locally, and whether it's writable | ||
| via HTTP depends on the `readonly` argument. | ||
| - If both a `getter` and `setter` are specified and `observable` is `False`, |
There was a problem hiding this comment.
hmm, yes that is ambiguous. I remembered incorrectly that a DirectThingClient could write to read-only properties that have a setter. Looking at the code, I see that the DirectThingClient property is made read-only. So readonly means that it's read/write on the Thing but read only from a ThingClient or DirectThingClient.
"locally" is the wrong term in that case - how about "may be written to when accessed as an attribute of the .Thing, but not by clients accessing it over HTTP or via a .DirectThingClient"?
| documentation. Defaults to the first line of the docstring, or the name | ||
| of the property. |
There was a problem hiding this comment.
yes, defaults to the first line of the docstring if it's available, else the name of the property.
| # Try to generate a DataSchema, so that we can raise an error that's easy to | ||
| # link to the offending ThingProperty |
There was a problem hiding this comment.
Ah, that comment referred to the line below (line 60 as-was): we ran type_to_dataschema(self.model) to ensure that any exception it raised would happen when the property was defined, and thus reference the right bit of code.
The function call and a similar comment should now be in __set_name__. This comment can be deleted.
| # Try to generate a DataSchema, so that we can raise an error that's easy to | |
| # link to the offending ThingProperty |
| """ | ||
| self._name = name | ||
| value_types: dict[str, type[Value]] = {} | ||
| if hasattr(self, "_model_arg"): |
There was a problem hiding this comment.
There are a few places where None needs to be handled differently from an unset variable. I experimented with sentinel values but that seemed to be adding complication to something that could be solved more cleanly with hasattr.
If hasattr is considered bad I can probably reduce its use, though i will be hard to eliminate completely.
Is there a style guide that this comes from? If so I should probably read it...
| if name in owner_annotations: | ||
| # If the property has a type annotation on the owning class, we can use that | ||
| value_types["class_annotation"] = owner_annotations[name] | ||
| if hasattr(self, "__orig_class__"): |
There was a problem hiding this comment.
That is not possible. __orig_class__ is added by Python, after __init__ is called but before __set_name__. It's not a published language feature though, so I should probably add a link or two. While at least one of the posters warns it's not supported, it does appear to work in all the Python versions we support.
While it's technically an implementation detail, it has been available since Python 3.5 and the situations where it doesn't work don't apply to ThingProperty.
| # We were instantiated as BaseThingProperty[ModelType] so can use that type | ||
| value_types["__orig_class__"] = typing.get_args(self.__orig_class__)[0] |
There was a problem hiding this comment.
There are several ways to set the type. Rather than have a long and complicated nested if/else structure, I put all the specified types into the dictionary, then check all the values agree if there is more than one. I thought I'd added a comment to say so, but maybe it need improving...
| ) | ||
| print( | ||
| f"Initializing property '{name}' on '{owner}', {value_types}." | ||
| ) # TODO: Debug print statement, remove |
There was a problem hiding this comment.
eugh, yes that is nasty. I must have not checked that after running ruff format.
Though, in this case I should also just have deleted that line!
|
On discussion with @julianstirling I think it is likely to be a poor use of time to bring this up to scratch. I am going to close it in favour of a more extensive refactor, which makes use of some of the code changes in here. |
This branch builds on @julianstirling's type hint branch to eliminate the
modelargument from properties.Both forms are now properly type hinted, tests with mypy to ensure inferred types are correct will happen in due course.
The tests are updated to use the new syntax, and
ThingPropertywill raise an exception if type hints are missing. Currently, specifying amodelargument and no type hints will raise an error - this behaviour could be changed, but would result in less good type hints if it's allowed.I've left the
modelargument as a double-check, if present it will be compared to the type hint and raise an error if it differs. We will probably remove it in due course.