Skip to content
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

Feat: Adds type annotations #43

Merged
merged 20 commits into from
Oct 30, 2022
Merged

Conversation

peterschutt
Copy link
Contributor

@peterschutt peterschutt commented Oct 21, 2022

  • Used MonkeyType and the test suite to generate a set of annotations
  • Added mypy and necessary stub dependencies to dev extras
  • Adds types.py module for type definitions/aliases etc
  • Adds py.typed
  • Removes support for 3.7

Related to #39

- generated using MonkeyType
- added `from __future__ import annotations`
- introduced imports moved into `if TYPE_CHECKING` block
This commit has `python -m mypy saq/queue.py` running without issue under current configuration.
@peterschutt
Copy link
Contributor Author

@tobymao you should be able to run python -m mypy saq/queue.py without error - I've focused my attention there so far and would like to give you the chance to weigh in before I go any further.

I'm running with default config to start with, we can ramp up the strictness settings of mypy once I'm past the initial batch of errors.

Found an issue with the redis stubs in typeshed: python/typeshed#8960

saq/job.py Outdated Show resolved Hide resolved
saq/job.py Outdated Show resolved Hide resolved
saq/job.py Outdated Show resolved Hide resolved
saq/queue.py Outdated Show resolved Hide resolved
Copy link
Collaborator

@barakalon barakalon left a comment

Choose a reason for hiding this comment

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

yay!

Let's add mypy to run_checks.sh

saq/queue.py Outdated Show resolved Hide resolved
saq/types.py Outdated Show resolved Hide resolved
saq/types.py Outdated Show resolved Hide resolved
saq/types.py Outdated Show resolved Hide resolved
@peterschutt
Copy link
Contributor Author

Thanks for the reviews! I'll keep working at it over the weekend.

Let's add mypy to run_checks.sh

172 errors still when I run on saq and tests :) will def. add it in when I get a clean run.

@tobymao
Copy link
Owner

tobymao commented Oct 21, 2022

let me know when is hould take another look

Ensures `max_delay` parameter to `exponential_backoff()` called with
value derived from `Job.retry_backoff`.
@peterschutt
Copy link
Contributor Author

A good chunk of the mypy errors revolve around attribute access on Job.queue as it might be None:

saq/job.py:117: error: Item "None" of "Optional[Queue]" has no attribute "name"  [union-attr]
saq/job.py:139: error: Item "None" of "Optional[Queue]" has no attribute "job_id"  [union-attr]
saq/job.py:217: error: Item "None" of "Optional[Queue]" has no attribute "abort"  [union-attr]
saq/job.py:221: error: Item "None" of "Optional[Queue]" has no attribute "finish"  [union-attr]
saq/job.py:225: error: Item "None" of "Optional[Queue]" has no attribute "retry"  [union-attr]
saq/job.py:235: error: Item "None" of "Optional[Queue]" has no attribute "update"  [union-attr]
saq/job.py:244: error: Item "None" of "Optional[Queue]" has no attribute "job"  [union-attr]
saq/job.py:257: error: Item "None" of "Optional[Queue]" has no attribute "listen"  [union-attr]

These are currently AttributeErrors:

Traceback (most recent call last):
  File "/home/peter/.pyenv/versions/3.10.8/lib/python3.10/code.py", line 90, in runcode
    exec(code, self.locals)
  File "<input>", line 1, in <module>
  File "/home/peter/PycharmProjects/saq/saq/job.py", line 139, in id
    return self.queue.job_id(self.key)
AttributeError: 'NoneType' object has no attribute 'job_id'

One idea is to add an access method to centralise the exception logic and type narrowing, such as:

class Job:
    ...
    def get_queue(self) -> Queue:
        if self.queue is None:
            raise TypeError("`Job` must be associated with a `Queue` before this operation can proceed")
        return self.queue

    @property
    def id(self) -> str:
        return self.get_queue().job_id(self.key)

It could also be a property and/or memoized but I doubt we'd be looking at too much overhead with the plain method.

Any opinion here @tobymao @barakalon?

A common class of mypy error is "`Optional[...]` has no attribute ...".

- Adds `Job.get_queue()` which raises for no queue and narrows type.
- Adds `enqueue()`, `dequeue()`, `count()` and `finish()` methods to
`TestQueue` class that are queue methods commonly called in the tests
and they narrow type where applicable.
- Adds `TestWorker.enqueue()` for type narrowing to `Job`.
- Other handling of specific instances where optional types needed to
be narrowed.
@tobymao
Copy link
Owner

tobymao commented Oct 23, 2022

A good chunk of the mypy errors revolve around attribute access on Job.queue as it might be None:

saq/job.py:117: error: Item "None" of "Optional[Queue]" has no attribute "name"  [union-attr]
saq/job.py:139: error: Item "None" of "Optional[Queue]" has no attribute "job_id"  [union-attr]
saq/job.py:217: error: Item "None" of "Optional[Queue]" has no attribute "abort"  [union-attr]
saq/job.py:221: error: Item "None" of "Optional[Queue]" has no attribute "finish"  [union-attr]
saq/job.py:225: error: Item "None" of "Optional[Queue]" has no attribute "retry"  [union-attr]
saq/job.py:235: error: Item "None" of "Optional[Queue]" has no attribute "update"  [union-attr]
saq/job.py:244: error: Item "None" of "Optional[Queue]" has no attribute "job"  [union-attr]
saq/job.py:257: error: Item "None" of "Optional[Queue]" has no attribute "listen"  [union-attr]

These are currently AttributeErrors:

Traceback (most recent call last):
  File "/home/peter/.pyenv/versions/3.10.8/lib/python3.10/code.py", line 90, in runcode
    exec(code, self.locals)
  File "<input>", line 1, in <module>
  File "/home/peter/PycharmProjects/saq/saq/job.py", line 139, in id
    return self.queue.job_id(self.key)
AttributeError: 'NoneType' object has no attribute 'job_id'

One idea is to add an access method to centralise the exception logic and type narrowing, such as:

class Job:
    ...
    def get_queue(self) -> Queue:
        if self.queue is None:
            raise TypeError("`Job` must be associated with a `Queue` before this operation can proceed")
        return self.queue

    @property
    def id(self) -> str:
        return self.get_queue().job_id(self.key)

It could also be a property and/or memoized but I doubt we'd be looking at too much overhead with the plain method.

Any opinion here @tobymao @barakalon?

that’s fine

For py37 we `TypedDict` and `Literal` come from `typing_extensions`.
Includes import handling in `compat.py`.
- uses builtin generics, e.g., `list[...]` instead of `t.List[...]`.
We are able to do this due to the `__future__.annotations` and
`if TYPE_CHECKING` blocks preventing any runtime parsing of annotations.
- mypy config - increase strictness and warnings.
- adds `py.typed` file.
- adds mypy to `run_checks.sh`.
@@ -233,7 +236,7 @@ async def update(self, **kwargs) -> None:
setattr(self, k, v)
await self.get_queue().update(self)

async def refresh(self, until_complete: t.Optional[float] = None) -> None:
async def refresh(self, until_complete: float | None = None) -> None:
Copy link
Owner

Choose a reason for hiding this comment

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

is this syntax legal in 3.7?

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 was just about to write an explanation.

The from __future__ import annotations means that all annotations are strings at runtime, so no runtime errors irrespective of version, and type checkers are able to use the current syntax irrespective of the version.

I've been running the tests locally on 3.8 without issue and that syntax was introduced in 3.10. Similarly, the builtin subscription syntax, e.g., list[...] is 3.9+ but the future annotations mean we don't need to worry about the runtime errors.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Of course, if there is an issue I'm missing here, or you'd prefer to stay syntactically valid with the min version irrespective of the future annotations, I'll be happy to roll them back. Just that the current syntax is a lot nicer than what was available in 3.7.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think mypy will automatically add an Optional type when the default value is None? I could have that wrong, though.

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 are correct, but reliance on it is discouraged: python/peps#689 and python/mypy#9091

@tobymao tobymao marked this pull request as ready for review October 24, 2022 01:14
schedule: int
stats: int
sweep: int
abort: int
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@tobymao Note in this module that I've used the typing versions of generics as it would be feasible that a user would import something from here to annotate their own code, and we can't be sure they'll use it with from __future__ import annotations, so needs to be compatible with 3.7 runtime.

This is also why "Job" and "Status" are forward refs here, as if this module is imported outside of a if TYPE_CHECKING block, their reference would otherwise fail.

Copy link
Owner

Choose a reason for hiding this comment

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

can you double check everything works fine in 3.7?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes I will verify

Copy link
Collaborator

Choose a reason for hiding this comment

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

Copy link
Contributor Author

@peterschutt peterschutt Oct 24, 2022

Choose a reason for hiding this comment

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

IsolatedAsyncioTestCase not available in 3.7. I tried to do a compat with aiounittest for 3.7 (import aiounittest.AsyncTestCase as IsolatedAsyncioTestCase) but there were a stack of errors.

Perhaps a simple integration test script can be added to CI for 3.7 only?

3.7 EOL is 27th June 2023.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Then maybe we ignore 3.7?

Copy link
Owner

Choose a reason for hiding this comment

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

yes

Copy link
Contributor Author

Choose a reason for hiding this comment

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

that would certainly simplify things

Copy link
Contributor Author

Choose a reason for hiding this comment

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

3.7 removed and added 3.11

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 we still need to use the typing module generics here unless we go back to:

LoadType: t.TypeAlias = "Callable[[bytes | str], t.Any]"

Six to one, half a dozen to the other in my mind, so I'll leave as is unless you'd prefer otherwise.

saq/types.py Outdated
CountKind = Literal["queued", "active", "incomplete"]
DumpType = t.Callable[[t.Dict], str]
DurationKind = Literal["process", "start", "total", "running"]
Function = t.Callable
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 just a stub for a better type.

I tried different configurations with Protocol and __call__ and @overload to try and satisfy requirement that these can be both sync and async, accept a single pos arg, either accept kwargs, or not, as well as having the __qualname__ attribute, but couldn't find a solution that worked.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Going 3.8+ might help with this as positional only parameters were introduced in 3.8.

@peterschutt peterschutt changed the title WIP: Type annotations Feat: Adds type annotations Oct 24, 2022
From running command `pyright --verifytypes saq --ignoreexternal`

This change addresses highlighted to bring % of public interfaced typed
up to 100%.

```
(.venv) peter@peter-Inspiron-15-5510:~/test-saq$ pyright --verifytypes saq --ignoreexternal
Module name: "saq"
Package directory: "/home/peter/PycharmProjects/saq/saq"
Module directory: "/home/peter/PycharmProjects/saq/saq"
Path of py.typed file: "/home/peter/PycharmProjects/saq/saq/py.typed"

Public modules: 8
   saq
   saq.__main__
   saq.job
   saq.queue
   saq.types
   saq.utils
   saq.web
   saq.worker

Symbols used in public interface:

Symbols exported by "saq": 212
  With known type: 212
  With ambiguous type: 0
  With unknown type: 0
    (Ignoring unknown types imported from other packages)
  Functions without docstring: 50
  Functions without default param: 0
  Classes without docstring: 6

Other symbols referenced but not exported by "saq": 0
  With known type: 0
  With ambiguous type: 0
  With unknown type: 0

Type completeness score: 100%

Completed in 0.631sec
```
@peterschutt
Copy link
Contributor Author

I just added a commit that addresses issues with public interface types highlighted by the Pyright --verifytypes tool. For context, this was the output of the first run of the tool before any fixes.

Most of the issues were from use of generic types without specifying type arguments.

One interesting case to highlight was this:

saq.queue.Queue.__init__
  /home/peter/PycharmProjects/saq/saq/queue.py:72:9 - error: Type of parameter "redis" is partially unknown
    Parameter type is "Redis[Unknown]"
      Type argument 1 for class "Redis" has unknown type

Redis can be configured with decode_responses=True so that it returns str from commands, which would mean code like this would fail:

        for key in await self.redis.zrangebyscore(self._stats, now(), "inf"):
            key_str = key.decode("utf-8")

Therefore Queue specifically expects decode_responses=False which is Redis[bytes].

On the negative, it raised a few like this:

saq.worker.logger
  /home/peter/PycharmProjects/saq/saq/worker.py:27:1 - error: Type is missing type annotation and could be inferred differently by type checkers
    Inferred type is "Logger"

Meaning it wants:

logger: logging.Logger = logging.getLogger("saq")

Pretty gross, and I'm not sure how that could be inferred to be anything different by other type checkers.

Full output:

(.venv) peter@peter-Inspiron-15-5510:~/test-saq$ pyright --verifytypes saq --ignoreexternal
Module name: "saq"
Package directory: "/home/peter/PycharmProjects/saq/saq"
Module directory: "/home/peter/PycharmProjects/saq/saq"
Path of py.typed file: "/home/peter/PycharmProjects/saq/saq/py.typed"

Public modules: 8
   saq
   saq.__main__
   saq.job
   saq.queue
   saq.types
   saq.utils
   saq.web
   saq.worker

Symbols used in public interface:
saq.job.Job.kwargs
  /home/peter/PycharmProjects/saq/saq/job.py:91:5 - error: Type argument 1 for class "dict" has unknown type
  /home/peter/PycharmProjects/saq/saq/job.py:91:5 - error: Type argument 2 for class "dict" has unknown type
saq.queue.Queue.__init__
  /home/peter/PycharmProjects/saq/saq/queue.py:72:9 - error: Type of parameter "redis" is partially unknown
    Parameter type is "Redis[Unknown]"
      Type argument 1 for class "Redis" has unknown type
saq.queue.Queue.redis
  /home/peter/PycharmProjects/saq/saq/queue.py:80:14 - error: Type argument 1 for class "Redis" has unknown type
saq.queue.Queue.uuid
  /home/peter/PycharmProjects/saq/saq/queue.py:82:14 - error: Type is missing type annotation and could be inferred differently by type checkers
    Inferred type is "str"
saq.queue.Queue.started
  /home/peter/PycharmProjects/saq/saq/queue.py:83:14 - error: Type is missing type annotation and could be inferred differently by type checkers
    Inferred type is "int"
saq.job.Job.meta
  /home/peter/PycharmProjects/saq/saq/job.py:110:5 - error: Type argument 1 for class "dict" has unknown type
  /home/peter/PycharmProjects/saq/saq/job.py:110:5 - error: Type argument 2 for class "dict" has unknown type
saq.job.Job.enqueue
  /home/peter/PycharmProjects/saq/saq/job.py:204:15 - error: Type of parameter "queue" is partially unknown
    Parameter type is "Queue | None"
saq.job.Job.get_queue
  /home/peter/PycharmProjects/saq/saq/job.py:266:9 - error: Return type is partially unknown
    Return type is "Queue"
saq.queue.logger
  /home/peter/PycharmProjects/saq/saq/queue.py:38:1 - error: Type is missing type annotation and could be inferred differently by type checkers
    Inferred type is "Logger"
saq.queue.JobError.__init__
  /home/peter/PycharmProjects/saq/saq/queue.py:44:9 - error: Type of parameter "job" is partially unknown
    Parameter type is "Job"
saq.types.QueueInfo.workers
  /home/peter/PycharmProjects/saq/saq/types.py:21:5 - error: Type argument 1 for class "dict" has unknown type
  /home/peter/PycharmProjects/saq/saq/types.py:21:5 - error: Type argument 2 for class "dict" has unknown type
saq.types.QueueInfo.jobs
  /home/peter/PycharmProjects/saq/saq/types.py:26:5 - error: Type argument 1 for class "list" has partially unknown type
    Type is dict[Unknown, Unknown]
saq.web.static
  /home/peter/PycharmProjects/saq/saq/web.py:23:1 - error: Type is missing type annotation and could be inferred differently by type checkers
    Inferred type is "str"
saq.worker.logger
  /home/peter/PycharmProjects/saq/saq/worker.py:27:1 - error: Type is missing type annotation and could be inferred differently by type checkers
    Inferred type is "Logger"
saq.worker.Worker.__init__
  /home/peter/PycharmProjects/saq/saq/worker.py:50:9 - error: Type of parameter "queue" is partially unknown
    Parameter type is "Queue"
  /home/peter/PycharmProjects/saq/saq/worker.py:50:9 - error: Type of parameter "functions" is partially unknown
    Parameter type is "Collection[((...) -> Unknown) | tuple[str, (...) -> Unknown]]"
      Type argument 1 for class "Collection" has partially unknown type
saq.worker.Worker.upkeep
  /home/peter/PycharmProjects/saq/saq/worker.py:161:15 - error: Return type is partially unknown
    Return type is "Coroutine[Any, Any, list[Task[Unknown]]]"
      Type argument 3 for class "Coroutine" has partially unknown type
saq.worker.Worker.event
  /home/peter/PycharmProjects/saq/saq/worker.py:111:18 - error: Type is missing type annotation and could be inferred differently by type checkers
    Inferred type is "Event"
saq.worker.Worker.functions
  /home/peter/PycharmProjects/saq/saq/worker.py:80:14 - error: Type is missing type annotation and could be inferred differently by type checkers
    Inferred type is "dict[Unknown, Unknown]"
  /home/peter/PycharmProjects/saq/saq/worker.py:80:14 - error: Type argument 1 for class "dict" has unknown type
  /home/peter/PycharmProjects/saq/saq/worker.py:80:14 - error: Type argument 2 for class "dict" has unknown type
saq.worker.Worker.cron_jobs
  /home/peter/PycharmProjects/saq/saq/worker.py:81:14 - error: Type is missing type annotation and could be inferred differently by type checkers
    Inferred type is "Collection[CronJob] | list[CronJob]"
saq.worker.Worker.tasks
  /home/peter/PycharmProjects/saq/saq/worker.py:83:14 - error: Type argument 1 for class "set" has partially unknown type
    Type is Task[Unknown]
saq.worker.Worker.job_task_contexts
  /home/peter/PycharmProjects/saq/saq/worker.py:84:14 - error: Type argument 2 for class "dict" has partially unknown type
    Type is dict[Unknown, Unknown]
saq.worker.async_check_health
  /home/peter/PycharmProjects/saq/saq/worker.py:332:11 - error: Type of parameter "queue" is partially unknown
    Parameter type is "Queue"

Symbols exported by "saq": 203
  With known type: 173
  With ambiguous type: 7
  With unknown type: 23
    (Ignoring unknown types imported from other packages)
  Functions without docstring: 50
  Functions without default param: 0
  Classes without docstring: 5

Other symbols referenced but not exported by "saq": 0
  With known type: 0
  With ambiguous type: 0
  With unknown type: 0

Type completeness score: 85.2%

Completed in 0.874sec

@tobymao
Copy link
Owner

tobymao commented Oct 30, 2022

seems reasonable to me, is this ready to go?

@peterschutt
Copy link
Contributor Author

seems reasonable to me, is this ready to go?

Close, I'm experimenting with it downstream. The top-level import aren't being recognized by mypy, e.g.,:

import saq

q = saq.Queue()   # Module has no attribute "Queue"  [attr-defined]

Might need an __all__ but I'm looking into it.

@peterschutt
Copy link
Contributor Author

seems reasonable to me, is this ready to go?

Close, I'm experimenting with it downstream. The top-level import aren't being recognized by mypy, e.g.,:

import saq

q = saq.Queue()   # Module has no attribute "Queue"  [attr-defined]

Might need an __all__ but I'm looking into it.

That was it (ref).

Should be good to go now.

@tobymao
Copy link
Owner

tobymao commented Oct 30, 2022

@peterschutt thanks again for this, great work!

@tobymao tobymao merged commit 877c5dd into tobymao:master Oct 30, 2022
@peterschutt
Copy link
Contributor Author

@peterschutt thanks again for this, great work!

No worries! Feel free to ping me if there are any issues.

@peterschutt peterschutt deleted the type-annotations branch October 30, 2022 05:02
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.

3 participants