Make typing checks more strict#14429
Conversation
MartinHjelmare
left a comment
There was a problem hiding this comment.
Looks good. One comment.
homeassistant/loader.py
Outdated
| # Typing imports | ||
| # Typing imports that create a circular dependency | ||
| # pylint: disable=using-constant-test,unused-import | ||
| if False: |
There was a problem hiding this comment.
Use typing.TYPE_CHECKING. See https://mypy.readthedocs.io/en/latest/common_issues.html#import-cycles, and https://docs.python.org/3/library/typing.html#typing.TYPE_CHECKING.
| return yaml.load(conf_file, Loader=SafeLineLoader) or OrderedDict() | ||
| except yaml.YAMLError as exc: | ||
| _LOGGER.error(exc) | ||
| _LOGGER.error(str(exc)) |
There was a problem hiding this comment.
Exceptions have a __str__ magic method so this shouldn't be needed.
There was a problem hiding this comment.
mypy complains that it is expecting str and got an Exception. Rather than add # type: ignore I think an explicit cast is more readable.
There was a problem hiding this comment.
I think mypy is wrong in this case. I'd prefer the ignore, but you decide, 🤷♂️.
There was a problem hiding this comment.
I think I am leaning towards str
homeassistant/util/json.py
Outdated
|
|
||
|
|
||
| def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \ | ||
| def load_json(filename: str, default: Union[List, Dict, int] = _UNDEFINED) \ |
There was a problem hiding this comment.
Why is it better to use _UNDEFINED than None?
homeassistant/util/dt.py
Outdated
| return "1 %s" % unit | ||
| elif number > 1: | ||
| return "%d %ss" % (number, unit) | ||
| return "%d %ss" % (number, unit) |
There was a problem hiding this comment.
Use new style string formatting instead. https://pyformat.info/#number
pvizeli
left a comment
There was a problem hiding this comment.
Return None is a PEP 8 convention and checked by pylint. Strange that is not type conform.
homeassistant/util/yaml.py
Outdated
| try: | ||
| secrets = load_yaml(secret_path) | ||
| if not isinstance(secrets, dict): | ||
| raise HomeAssistantError |
|
Addressed all comments. |
homeassistant/core.py
Outdated
| async def wrapper() -> Any: | ||
| """Wrapper coroutine around a callback.""" | ||
| return target(*args) | ||
| task = self.loop.create_task(wrapper()) |
There was a problem hiding this comment.
Don't wrap it into a slow coroutine.
There was a problem hiding this comment.
Any suggestions how to properly do that?
-
If the caller knows that the target is a @callback and is not afraid of exceptions - they can call it directly.
-
If the caller knows that the target is a @callback, they are afraid of exceptions and don't care about return value - they can call
loop.call_soonmanually (i.e. the previous implementation) -
If the caller knows that the target is a @callback, they are afraid of exceptions and they want the return value - the old implementation didn't provide that, the new one does.
-
If the caller doesn't know what
targetis and they need the return value - same as (3) -
If the caller doesn't know what
targetis and they don't need the return value - I agree that the old implementation is better, but it would be also better for coro not to be wrapped in task for such a case.
Generally having a single function with different return types is bad.
There was a problem hiding this comment.
Generally having a single function with different return types is bad.
For typing, yes but not for python itself
This change is very bad for the core and extend the memory footprint. We should hold the core slim and don't blow it up for a not exists type checking. Python is not strict and I hope that will be change in 4.x like c++ but actually I see no reason to make this bad change. In that case, I dislike this.
they can call loop.call_soon manually
And no, user should use our internal job handler or events stuff and not use the raw asyncio functions.
And the real solution is to remove @callback and make all that coreroutine. But it is good like it is now.
There was a problem hiding this comment.
So I would say that it's kinda a miracle that not returning anything has never caused any issues. The majority of the callbacks in use are actually being used inside helpers/event.py. There we call hass.async_run_job which will run callbacks right away and enqueue the other types of functions.
Let's measure how much overhead this adds with our benchmark module.
hass --script benchmark async_million_state_changed_helperThere was a problem hiding this comment.
On dev
› hass --script benchmark async_million_state_changed_helper
Using event loop: asyncio.unix_events
Benchmark async_million_state_changed_helper done in 4.237771602987777s
Benchmark async_million_state_changed_helper done in 4.250693870999385s
Benchmark async_million_state_changed_helper done in 4.256614304991672s
Benchmark async_million_state_changed_helper done in 4.24649005298852s
With this PR:
› hass --script benchmark async_million_state_changed_helper
Using event loop: asyncio.unix_events
Benchmark async_million_state_changed_helper done in 5.726596449007047s
Benchmark async_million_state_changed_helper done in 5.9516269309970085s
Benchmark async_million_state_changed_helper done in 5.672646453007474s
Benchmark async_million_state_changed_helper done in 5.7938863370072795s
That's just a quick check of running it a couple of times. That's a significant slowdown.
There was a problem hiding this comment.
(this converted both the listener inside the benchmark as the function defined inside async_track_state_change to async functions)
There was a problem hiding this comment.
Interestingly, if I just convert the function inside track_state_change to be async, I get back to ~5.5 seconds per run
There was a problem hiding this comment.
I think a proper solution is to make 5 new (type-safe) functions for all the usecases I mentioned above, then convert the callers and then delete the functions that are not actually used
There was a problem hiding this comment.
Hmm. That is python and not type safe. To make the code complex for nothing is not realy cool. If that is a c++ project, I would agree but not with python...
|
I created Fixed core callers to use the new function or |
|
So we have a async_add_executor_job is okay, I do this also in other project and help to read the code. Anyway, that give a bad mixup. The benefit of this function was a simple caller function for developers, they don't know what he do, and the core make the correct one. As I replace the thread poll and create this function with magic of python, I never want to this splitup. Now it's a bad and wired situation. And the benefit from all? We are typesafe in a none type safe language 👍 contratulation. All language they I known can handle a function like |
|
asyncio itself does not offer a good system to keep track of what is coming from where. We often get reports from random stack traces that lack context and instead point at the task running code inside the event loop. Which task spawned the task that just blew up? And what event caused any of these tasks to start to begin with? (support for this will get significantly better in Python 3.7 with context vars) By having created a catch all function, we initially made it even worse knowing what came from where or know what is happening by reading the code. It was needed to make the transition to asyncio from a sync world easier (or so I thought with my limited asyncio experience). We want to be able to track jobs so that we can block until all work is done during startup, shutdown and tests. I think that adding This is a great article about asyncio and correctness of spawned tasks that I read recently. It made me realize that switching to specific tasks is a good thing. I think that
About typing: I am a fan of adding types to the core. Todays benefit is checking if code is correct and better autocomplete in editors. However, in the future I expect it to help compilers to be able to better optimize our code and allow it to run faster. |
|
@balloob So what are the action items for this PR? I would like to finish making core type safe before refactoring async_add_job callers (if at all) |
|
Let's make async_create_task only accept a single argument: a coroutine. (like the event loop API it wraps). After that I think this PR is ok to go.
|
|
We should start with updating the developer docs. |
homeassistant/core.py
Outdated
|
|
||
| target: target to call. | ||
| """ | ||
| if asyncio.iscoroutine(target): |
homeassistant/core.py
Outdated
| raise ValueError( | ||
| "async_create_task can be called on coroutine only.") | ||
|
|
||
| # If a task is scheduled |
There was a problem hiding this comment.
# If we're tracking tasks or one can drop the comment
homeassistant/helpers/storage.py
Outdated
| else: | ||
| data = await self.hass.async_add_executor_job( | ||
| json.load_json, self.path, None) | ||
| json.load_json, self.path, []) |
There was a problem hiding this comment.
This is not technically the same. If we would store an empty list or dictionary in JSON, we would now treat it as no config file being available.
There was a problem hiding this comment.
Changed to {} which is the default value.
Any config is expected to have version attribute in the line below.
| def utc_from_timestamp(timestamp: float) -> dt.datetime: | ||
| """Return a UTC time from a timestamp.""" | ||
| return dt.datetime.utcfromtimestamp(timestamp).replace(tzinfo=UTC) | ||
| return UTC.localize(dt.datetime.utcfromtimestamp(timestamp)) |
There was a problem hiding this comment.
Is this the same? Is a non timezone aware date that is being localized treated as having been UTC time? Or would it treat it as local time?
There was a problem hiding this comment.
Here is the implementation of UTC.localize
def localize(self, dt, is_dst=False):
'''Convert naive time to local time'''
if dt.tzinfo is not None:
raise ValueError('Not naive datetime (tzinfo is already set)')
return dt.replace(tzinfo=self)
| _LOGGER.exception('JSON file reading failed: %s', filename) | ||
| raise HomeAssistantError(error) | ||
| return {} if default is _UNDEFINED else default | ||
| return {} if default is None else default |
There was a problem hiding this comment.
This not the same. By removing the sentinel value, it is no longer possible to have None be the default value.
There was a problem hiding this comment.
There were no uses of None as default value except the place I changed in storage.py
There was a problem hiding this comment.
There was no use yet, as the storage helper is very new. I don't think that we should change this.
There was a problem hiding this comment.
The storage helper was the lone use. I meant that no one else used None for load_json
By using sentinel value the function becomes not type-safe, as it accepts object and thus could return object if one were to pass it as default value.
Another option is to have {} as the default value and ignore that warning that generates with a comment that we are not changing it, nor do we return it as-is for the caller to modify.
There was a problem hiding this comment.
I don't feel too strongly I guess.
Any place that was using util.json or util.yaml needs to be rewritten to use the storage helper anyway.
There was a problem hiding this comment.
Actually, you could also make the sentinal value an empty dict, that way you can keep it all as dict. Comparison is still done with is so will work the same.
There was a problem hiding this comment.
It will generate a warning that the default value is mutable.
Lets keep it None
## Description: Make typing checks more strict: add `--strict-optional` flag that forbids implicit None return type. This flag will become default in the next version of mypy (0.600) Add `homeassistant/util/` to checked dirs. ## Checklist: - [x] The code change is tested and works locally. - [x] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass**
## Description: Make typing checks more strict: add `--strict-optional` flag that forbids implicit None return type. This flag will become default in the next version of mypy (0.600) Add `homeassistant/util/` to checked dirs. ## Checklist: - [x] The code change is tested and works locally. - [x] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass**
## Description: Make typing checks more strict: add `--strict-optional` flag that forbids implicit None return type. This flag will become default in the next version of mypy (0.600) Add `homeassistant/util/` to checked dirs. ## Checklist: - [x] The code change is tested and works locally. - [x] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass**
Description:
Make typing checks more strict: add
--strict-optionalflag that forbids implicit None return type. This flag will become default in the next version of mypy (0.600)Add
homeassistant/util/to checked dirs.Checklist:
tox. Your PR cannot be merged unless tests pass