Conversation
This commit swaps ThingProperty for two descriptor classes, FunctionalProperty (using getters/setters) and DataProperty (variable-like). This preserves the two useful behaviours out of the six (!) possible behaviours implemented by the old ThingProperty. I've also added a `property` function that provides a single interface to both: it may be used either as a decorator, or as a field specifier. This does not yet pass tests/type checking.
This includes a few typing fixes, but does not yet pass mypy
Tests now all collect, though several are still failing and the websocket test is hanging. The DocstringToMessage mixin was not working, this is now fixed. I now use ... as a default value for `default` in properties/settings, because None is a legitimate value.
This caught a few errors in my rearrangement - most notably that only DataProperties were getting added to the HTTP API. I've fixed things so all properties now appear on HTTP, and improved model generation so it has helpful errors and works for both data and functional properties. I also removed the numpy mypy plugin because the warning got annoying.
|
One thing it would be nice to consider that is causing us a bit of an issue right now in the OpenFlexure Server development is making a setting readonly, or setting limits (so the UI can validate). Settings need to have a setter, because that setter is used for loading from disk. This should be quite easy if the setting is a descriptor type. But any read-only setting with complex logic that requires a getter and a setter needs a way to communicate that the setting it is read only. It would be good to think of a way where the code to specify this is very explicit. I think for a ThingProperty it is easy. Don't set a setter and it should be read only, this matches Python's built in behaviour. I was wondering if for some of the values required for dataschema could just be set based on a dataclass/model? Making a contrived example where setters and getters are needed to "process data", in reality this would probably be hardware interaction: MIN_LEN = 4
class Announcer(lt.Thing):
repeat: int = lt.setting(default=1, minimum=1)
self._message = "I can"
@lt.setting
def message (self) -> str:
"My current message."
return "I do proclaim that " + " ".join([self._message]*self.repeat)
@message.setter
def message(self, value: str) -> None:
self._message = value.strip()
@message.meta_info
def message(self) -> lt.PropertyInfo:
return PropertyInfo(
readonly=True,
)
self._incoming_message = "I cannot"
@lt.setting
def incoming_message (self) -> str:
"Your current message."
return "You have proclaimed that " + " ".join([self._message]*self.repeat)
@incoming_message.setter
def incoming_message(self, value: str) -> None:
value = value.strip()
if len(value) < MIN_LEN
raise ValueError("Can't set that!")
self._incoming_message = value
@incoming_message.meta_info
def incoming_message(self) -> lt.PropertyInfo:
return PropertyInfo(
min_length=MIN_LEN,
)This would very explicitly allow the a setting to inform the UI to set a min length. As it is better to have a UI that doesn't allow a bad value than to just throw and error if incorrect. It also allows the settings describe that it is MIN_LEN = 4
class Announcer(lt.Thing):
repeat: int = lt.setting(default=1, minimum=1)
self._message = "I can"
@lt.setting
def message (self) -> str:
"My current message."
return "I do proclaim that " + " ".join([self._message]*self.repeat)
@message.set_from_cache
def message(self, value: str) -> None:
self._message = value.strip()
self._incoming_message = "I cannot"
@lt.setting
def incoming_message (self) -> str:
"Your current message."
return "You have proclaimed that " + " ".join([self._message]*self.repeat)
@incoming_message.setter
def incoming_message(self, value: str) -> None:
value = value.strip()
if len(value) < MIN_LEN
raise ValueError("Can't set that!")
self._incoming_message = value
@incoming_message.meta_info
def incoming_message(self) -> lt.PropertyInfo:
return PropertyInfo(
min_length=MIN_LEN,
)Just a note (I know this is early days), but the |
Thanks @julianstirling for spotting that one.
I've not yet fully worked through settings - that's a todo I should make more explicit. I think, though, that I am very keen to enforce variable-like behaviour for settings - and I think that means settings should not be implemented with getters and setters. I should write my thinking about this down more carefully than I'm likely to do in this reply - but I think the key point is that settings need to return, when they are read, the same value that goes in when they are written to. If that's not the case, the whole thing gets very confusing, and strange behaviour results. Taking your two examples, which I realise you have said are quite contrived, if we are to save those to disk then reload them, the values get longer each time. The first time we load up an Announcer, the message will be In various other channels, we've described two scenarios where it's important to run code as a result of interacting with a
I think both of these could be implemented with some sort of event handler, that for now I will call
That should work fine for validation. I prefer it to a setter because it's not responsible for storing the value: I think this makes it clearer that the value is managed externally. The mechanism described above would also work for well-behaved hardware, by which I mean that the hardware will accept the value that's written to it, and it won't change subsequently. However, this doesn't work nicely for hardware that may accept a value for a setting, but then quietly modify it. A possible solution would be for def CameraWithFunnySetting(lt.Thing):
exposure_time: float = lt.setting(10)
@on_set("exposure_time")
def set_exposure_time(self, new_value: float) -> float:
"""Set the exposure time, and check the value actually used."""
self._hardware.set_exposure(new_value)
time.sleep(0.1)
return self._hardware.read_exposure()Returning the value would allow the code in In the not-totally-uncommon case where there is a set of valid values and we pick the closest, the code above makes sense: the value is modified the first time it's written, but on subsequent writes (e.g. loading/saving when the server is restarted) there isn't any change. We could even check for that - making sure The aim of simplifying properties was to remove confusing behaviour, and I think properties do look much better after this change. It would be possible to have a Ultimately, if custom getter/setter code is required, I think there is a very reasonable fallback, which is to use a I thought we'd talked through this in a call a week or two ago, but perhaps my memory of that is not the same as yours. Probably what we need is to figure out how it would change code that uses settings, and I'm guessing the critical example is the camera code in the openflexure microscope. Perhaps it's worth writing a few more examples and thinking through them. I might take a stab at revising your two examples, just in case it's helpful to see how I'd approach them.
Ah, that was a typo. This is what I get for writing example code that's not tested... There should not be any recursion! |
from pydantic import RootModel, Field
import labthings_fastapi as lt
MIN_LEN = 4
class Announcer(lt.Thing):
repeat: int = lt.setting(default=1)
self._message: str = lt.setting(default="I can", readonly=true)
@lt.property
def message (self) -> str:
"My current message."
return "I do proclaim that " + " ".join([self._message]*self.repeat)
@message.setter
def message(self, value: str) -> None:
self._message = value.strip()
@message.readonly = True
self._incoming_message = lt.setting(default="I cannot")
@lt.property
def incoming_message (self) -> str:
"Your current message."
return "You have proclaimed that " + " ".join([self._incoming_message]*self.repeat)
@incoming_message.setter
def incoming_message(self, value: str) -> None:
value = value.strip()
if len(value) < MIN_LEN
raise ValueError("Can't set that!")
self._incoming_message = valueSome things I will take away from that example:
The main thing I changed is that it's |
I have found so far most settings I have needed to use getters and setters in the microscope code base. If getters and setters are not allowed then we need to make sure that the descriptors are poweful enogh for all use cases. For example we have a lot of things where the information is held in other classes so we need to be able to iterate through them on get and set. Without getters and setters we would need a complex signalling mechanism that would go wrong. |
I should look through those and figure out if there's a way it would work nicely, or if my assumptions are just wrong. I'm aware of the ones in the camera - I will have a look in the microscope codebase for others, but might miss stuff if it's in branches. Pointing me at examples of where you've used getters/setters for settings would be helpful. |
I would just do a search of
|
Of course - that is something I'll do, along with testing this branch together with a branch of the microscope server. Thanks for highlighting the background detect example though, that is a really useful one. I am persuaded that trying to eliminate functional settings in this PR will cause a jam, so I'll add some multiple inheritance and work around it. I will move discussion of whether functional settings are a good idea to an issue thread. I think it really depends on how we see the relationship between individual settings and the settings file. This changed significantly in #110 and I think we need to revisit some assumptions to make it clear how settings ought to work. Once that's well-defined, we can make sure the implementation matches the intention. |
I had hoped to limit settings to a subclass of Data Property, however this would require downstream changes. I've now reimplemented them (and added a couple of tests). I've created #159 to discuss whether FunctionalSetting is a good idea long-term. I've also fixed a type error by asserting we won't generate property affordances without a valid path.
I would love to get doctest working for this, but I'll leave that for a future PR.
Currently, mypy checks the package for type safety. It would be really useful to check that code using the library will be typed correctly, so that Thing subclasses will play nicely with mypy and friends. This commit starts that process, by adding a folder of test code that will be run through mypy, with `--warn-unused-ignores`. Activating the `unused-ignores` error means that we can check that code raises an error just by adding a # type: ignore comment. If the error isn't raised, the unused # type: ignore will cause an error. This is not yet passing, because the getter/setter form of property triggers an error, which seems to be a difficult one to work around. This has been described previously: python/typing#1102
I've used overloads to make it clear to mypy that exactly one of `default` and `default_factory` is required. This ended up duplicating some logic in `DataProperty.__init__`, `property`, and `setting`, so I've consolidated that into a function.
|
I've now written a first typing test - this runs This helped me spot a couple of errors, and I think it's a really useful way to test the package. I don't think we need anything more complicated in the short term. There is, however, one error I can't get rid of (without adding a I feel like it really ought to be possible to type properties rigorously, but it's frustrating that we'd currently need to add an ignore, either on the line or globally. |
I should correct myself: it is possible for this to work properly - we just need to deviate slightly from what import labthings_fastapi as lt
class MyThing(Thing):
@lt.property
def the_answer(self) -> int:
return 42
@the_answer.setter
def set_the_answer(self, val: int) -> None:
if val != 42:
raise ValueError("Don't be ridiculous.")This isn't as beautiful a fix as I'd like, but I think it's OK, and it is probably as good as we're likely to get in the short term. It allows rigorous type checking and is familiar enough syntax that it's clear - we just need to make sure there's an explanation somewhere obvious, like the docstring for |
|
That's a shame! Perhaps a conventions such as a leading underscore, with a docstring highlighting it in examples. import labthings_fastapi as lt
class MyThing(Thing):
@lt.property
def the_answer(self) -> int:
return 42
@the_answer.setter
def _the_answer(self, val: int) -> None:
"""Note the leading underscore! Blah blah explanation"""
if val != 42:
raise ValueError("Don't be ridiculous.")It may be possible to write a MyPy plugin to get around this for |
Typing for functional properties/settings can be fixed if we improve the type hints that are used to pick between @overload definitions. The main change is that `default` must become a keyword-only argument.
I've had a look through the plugin hooks that are provided, and (while I've not attempted to actually write anything) I don't think this is possible. If it is possible, it is a big job, and one one that I think makes sense for us to tackle soon. |
|
I've also realised (thanks to my typing checks) that the descriptors don't have their type inferred properly when defined with a getter and setter. This can be resolved in one of two ways:
I've gone for option 2, on the grounds that it probably is helpful to be explicit that the value is a default, and I think it looks better to call both things |
A convention is helpful for sure - I'd favour |
I think these were last locked under Linux, and this added a dependency on `uvloop` which breaks installation on Windows. Given that CI has been passing with dependencies locked under Windows, I think we can safely remove uvloop from dev-requirements.txt and assume it's added under Linux.
Codespell should ignore docs/build and docs/src/autoapi, as both of these are generated files.
I've globally disabled some whitespace-checking as this is done better by ruff (in particular, it handles @overload better). I've disabled a few more warnings related to @overload definitions line-by-line, with a block comment explaining why at the top of the module. This is mostly flake8 not understanding @overload or @builtins.property.
This may yet have confusing side effects.
After writing #164 I realised it was much cleaner to simply avoid returning a descriptor from `FunctionalProperty.setter` if the names didn't match. This means there will only ever be one descriptor object so we don't confuse code that looks for descriptors.
cd6bf64 to
55dcb00
Compare
I added an __init__.py to the tests folder in order to nicely import `poll_task`. This means I need to change all the other imports of test code to use relative imports starting with `.`.
See explanation in `raises_or_is_caused_by` in `test_base_descriptor`.
I've moved this useful function into a utilities module.
|
I have tested MR !348 of the OpenFlexure Microscope which uses the new syntax from this branch (and with this branch checked out). Everything works as expected. The code looks much much cleaner with the new syntax which is great. @rwb27; this is still in draft. What do we need to get it in? |
|
Thanks very much for testing it - I finally made a start on a new test microscope, but it's currently only 2/3 built. Hopefully up and running on Monday. I've reviewed the todos and was able to tick off several. There are three outstanding checkboxes, which are:
|
|
OK, I realised that re-testing whether having It's possible some tools will work better if Inheriting from I've added a test for the |
I've also added a few more comments to the typing test, explaining what some of the lines are checking for.
`pydocstyle` linter rules ("D" codes) were running in `flake8` and `ruff`.
`ruff` is recommended by `pydocstyle` as having feature parity and being actively maintained.
`flake8` didn't recognise `builtins.property` as a property and needed D401 ignored each time.
I've removed `flake8-docstrings` (plugin for pydocstyle) and the associated #noqa statements. The relevant rules are still checked by `ruff`. I don't believe this has relaxed any linter rules, just deduplicated.
The module that includes `property` and `setting` is renamed. There are two main reasons for this: 1. The "thing" in the name is a bit redundant, and it matches "actions". 2. `lt.thing_property` is a symbol we've removed in this branch. Using it as a module name makes for confusing error messages.
julianstirling
left a comment
There was a problem hiding this comment.
Thanks for the updates

This PR significantly reorganises how properties work. The functionality is split into separate classes with a common base, and there are fewer, better-defined ways for a property to work.
DataPropertyis a variable-like property. It may not have a getter/setter, and functions just like a variable. You may specify whether it can be written to from client code, that's currently its only option.FunctionalPropertyuses getter/setter. It works pretty much exactly like Python'sproperty.lt.property. See example below.lt.settingfunctions just likelt.property, but the values are remembered in the settings file for theThing. The implementation of saving/loading settings has not changed.The two ways for defining properties now look like:
I am happy with this syntax. It's not exactly what I wanted, but I think it is the best compromise between elegance and practicality. The two quirks I would like to draw attention to are:
Both
defaultanddefault_factoryare keyword-only arguments. This enablesmypyto distinguish between use as a decorator and use as a field, so that both cases are typed correctly.An earlier commit allowed default to be the (first and only) positional argument: it worked fine, but it confused
mypy. We care about type checking in code that definesThings, so it's worth adopting the form thatmypycan understand.When using
@lt.propertyor@lt.settingas a decorator,mypywill raise an error if the same name is used for the getter and setter functions, because doing so means the descriptor is overwritten (with itself). The descriptor works in both cases.There are quite a few things remaining to do before this is ready to be merged. These include:
BaseProperty.modelto ensure non-pydantic types can be wrapped in aRootModelwhen needed.Add a way to attach anThis can be postponed, because getters/setters are back in.on_setfunction to aDataProperty. This would allow validation and synchronisation ofDataPropertyinstances, which is required because settings are nowDataPropertyinstances.thing_propertyandbase_descriptoropenflexure-microscope-serverwith this branch.propertyjust to make sure that doesn't solve one of our problems.Add a documentation page on settings.moved to Add a tutorial page onsettings#171.