Conversation
|
I'm not the maintainer, but I don't think this should be added without type checking running in the CI. I also don't think pyright specific ignores should be added unless that's the type checker that's being run in the CI. The internal bits of the code will not be tested when this library is used, but instead testing the external interfaces with multiple type checkers is reasonable. |
|
It might be not obvious but I originally contributed typing to this module already (not sure where they went but it's not important). The goal for having the pyright ignores is to be able to develop this module with using pyright type checking. In my case, I use basedpyright which is even more strict but I didn't want to go mad just by satisfying its needs, so this PR only uses the vanilla pyright ignores. I needed proper typing because there are a couple projects I use this module in, and with 4.0.0 the typings were lost. So I thought I'll add them back, and do it properly while I'm already at it. |
terencehonles
left a comment
There was a problem hiding this comment.
We have very minimal type stubs in our project, but these changes mostly agree with out types (doesn't mean too much as they are very minimal). However, I've added some comments where they do diverge.
| def recursive( | ||
| cls, | ||
| make_cte_queryset: Callable[[CTE[_QS]], _QS], | ||
| name: str = "cte", | ||
| materialized: bool = False, | ||
| ) -> CTE[_QS]: |
There was a problem hiding this comment.
I don't think these should be the same type variable, since the return value is what's used for the CTE[_QS]. Also, I think you can use Self here (and that could be your second type variable):
| def recursive( | |
| cls, | |
| make_cte_queryset: Callable[[CTE[_QS]], _QS], | |
| name: str = "cte", | |
| materialized: bool = False, | |
| ) -> CTE[_QS]: | |
| def recursive( | |
| cls: type[Self], | |
| make_cte_queryset: Callable[[Self], _QS], | |
| name: str = "cte", | |
| materialized: bool = False, | |
| ) -> Self[_QS]: |
if Self is not generic (for the return value), then you can create another type variable for that purpose.
There was a problem hiding this comment.
Thanks, but neither variant type-checks: Self can't take type arguments (PEP 673), and a TypeVar can't be subscripted (no higher-kinded types). mypy and pyright both reject Self[_QS] and _C[_QS].
The shared _QS is deliberate. The CTE passed to the callback is the same object recursive() returns, so both have the same body type. As it stands, both checkers infer CTE[BookQS]. The cost is that a subclass gets back CTE[_QS] instead of its own type, which is what the cast is for.
There was a problem hiding this comment.
Ok, good to know about Self, but I was referring to the definition of make_cte_queryset which has two _QS type variables. However, thinking about it more I realized that what I'm trying to express can't be typed by the type checker as the code stands and what you have is fine.
What I was trying to express can be done type-checking only or also at runtime, but it would be roughly as follows:
class _CTEBuilder:
# IIRC join is the only method that should be called in `make_cte_queryset`
def join[T: Model, U](
self,
model_or_queryset: type[T] | QuerySet[T, U],
*filter_q: Q,
_join_type: str = ...,
**filter_kw: Any,
) -> QuerySet[T, U]: ...
class CTE(_CTEBuilder): # join can be called on a CTE so subclass to get the functionality
@classmethod
def recursive[T: QuerySet](
cls,
make_cte_queryset: Callable[[_CTEBuilder], T],
name: str = "cte",
materialized: bool = False,
) -> CTE[T]: ...This would disallow calling the CTE methods that need a queryset too early, and makes it clear that there is no queryset type information coming from the first argument to CTE when written as the _CTEBuilder. You're not using _QS_co so there is no disagreement there.
| @overload | ||
| def join( | ||
| self, | ||
| model_or_queryset: _QS, | ||
| *filter_q: Q, | ||
| _join_type: str = ..., | ||
| **filter_kw: Any, | ||
| ) -> _QS: ... | ||
| @overload | ||
| def join( | ||
| self, | ||
| model_or_queryset: type[_M], | ||
| *filter_q: Q, | ||
| _join_type: str = ..., | ||
| **filter_kw: Any, | ||
| ) -> QuerySet[_M, _M]: ... | ||
| def join(self, model_or_queryset, *filter_q, **filter_kw): |
There was a problem hiding this comment.
This is a trivial overload and should just be declared as a union. Also, you've not typed the implementation so that's effectively Any everywhere. The following would be better, but note that _join_type has been moved to the signature so the function body will need to reference it by name and not via kwargs.
| @overload | |
| def join( | |
| self, | |
| model_or_queryset: _QS, | |
| *filter_q: Q, | |
| _join_type: str = ..., | |
| **filter_kw: Any, | |
| ) -> _QS: ... | |
| @overload | |
| def join( | |
| self, | |
| model_or_queryset: type[_M], | |
| *filter_q: Q, | |
| _join_type: str = ..., | |
| **filter_kw: Any, | |
| ) -> QuerySet[_M, _M]: ... | |
| def join(self, model_or_queryset, *filter_q, **filter_kw): | |
| def join( | |
| self, | |
| model_or_queryset: type[_M] | _QS, | |
| *filter_q: Q, | |
| _join_type: str = ..., | |
| **filter_kw: Any, | |
| ) -> _QS: ... |
There was a problem hiding this comment.
With this suggestion you lose the typing information of the second overridden variant (model_or_queryset: type[_M],) in which case the join returns a queryset packed with those model types. It is why originally was implemented that way.
There was a problem hiding this comment.
Yes, that's true. I copied my stubs incorrectly, and is because I was re-using your _QS. You can parameterize that differently as follows:
| @overload | |
| def join( | |
| self, | |
| model_or_queryset: _QS, | |
| *filter_q: Q, | |
| _join_type: str = ..., | |
| **filter_kw: Any, | |
| ) -> _QS: ... | |
| @overload | |
| def join( | |
| self, | |
| model_or_queryset: type[_M], | |
| *filter_q: Q, | |
| _join_type: str = ..., | |
| **filter_kw: Any, | |
| ) -> QuerySet[_M, _M]: ... | |
| def join(self, model_or_queryset, *filter_q, **filter_kw): | |
| def join( | |
| self, | |
| model_or_queryset: type[_M] | QuerySet[_M, _R_co], | |
| *filter_q: Q, | |
| _join_type: str = ..., | |
| **filter_kw: Any, | |
| ) -> QuerySet[_M, _R_co]: ... |
According to GitHub it says this is your first PR. Did you accidentally just create a PR in your own fork? (I've done that).
That makes sense, but I do think the CI will need to be updated to include type checking otherwise they will inevitably become stale / incorrect. |
|
@millerdev, care to approve the workflow so we can see if tests run clear? |
e1dfd1d to
26042a9
Compare
|
Personally, I am not a fan of type hints in Python. I think they are ugly. They make the code harder to read and more difficult to maintain. One marginal benefit they add is auto-completion in an IDE, which is nice but I'm not sure if it's worth the cost. I suppose automated type checking can be useful in large code bases, but that is no substitute for automated tests, which do a better job of enforcing correctness when done well. I will not accept this without automated tests that check the hints and ensure they stay in sync with the code. It's way too easy to add them incorrectly, resulting in misleading and/or wrong type information. This PR must include a new Github Actions node to run automated type checking with something like pyright or ty. |
|
Your command is my wish, coming soon when I'll have time for it. :) |
Ship type information with the package so users of mypy, pyright and other type checkers get typed signatures for the public API. The types live in hand-written .pyi stubs next to the modules, so the runtime code stays unannotated: - `CTE` is generic over the type of its body queryset, e.g. `CTE[QuerySet[Order, dict[str, Any]]]`, and it can be subscripted at runtime. - Overloads for `with_cte()` and `CTE.join()` return the queryset type that matches the given model, queryset or CTE; `CTE()` infers the body type (`CTE[...]`) from the given queryset or raw CTE SQL, and `CTE.recursive()` from what `make_cte_queryset` returns. - `CTE.recursive()`, `CTE.queryset()`, `cte.col.<name>`, `raw_cte_sql()` and the deprecated `With`, `CTEQuerySet` and `CTEManager` are typed. Type checkers report the deprecated API as deprecated. - `py.typed` marks the package as typed (PEP 561). The stubs need no new runtime dependencies. A few small runtime changes make the modules match them: - `CTE` defines `__class_getitem__`, so `CTE[...]` works at runtime. - `raw_cte_sql()` returns a subclass of the new `RawCTEQuerySet` marker class. - `CTEColumn.relabeled_clone()` names its parameter `change_map`, as in Django. This breaks keyword calls (`relabels=...`); Django passes it by position. - `CTEManager` derives from a named `_CTEManagerBase`, as in the stubs. A new `stubs` CI job checks the stubs: stubtest compares them with the runtime (known false positives are listed in stubtest-whitelist.txt), and basedpyright checks them with its strict defaults. Its tools are in a separate `typing` dependency group.
26042a9 to
eaef090
Compare
|
So, I've made the requested changes, we now have 2 stubs tests (mypy+basedpyright) and the typings are removed into their separate typing files to not hurt your eyes. |
Ship type information with the package so users of mypy, pyright and other type checkers get typed signatures for the public API:
CTEis generic over the type of its body queryset, e.g.CTE[QuerySet[Order, dict[str, Any]]], and it can be subscripted at runtime.with_cte(),CTE()andCTE.join()return the queryset type that matches the given model, queryset, CTE or raw CTE SQL.CTE.recursive(),CTE.queryset(),cte.col.<name>,raw_cte_sql()and the deprecatedWith,CTEQuerySetandCTEManagerare typed. Type checkers report the deprecated API as deprecated.py.typedmarks the package as typed (PEP 561).Typing-only imports (typing_extensions, django-stubs types) are guarded by
TYPE_CHECKING, so there are no new runtime dependencies.The package source now passes mypy and pyright (with django-stubs) on Python 3.10 through 3.13. This needed a few small internal changes:
Query/SQLCompileras their base for type checkers only; at runtime their base staysobject.raw_cte_sql()returns a subclass of the newRawCTEQuerySetmarker class.as_sql()of CTE column expressions returns params as a tuple.CTEColumn.relabeled_clone()andNoAliasCompiler.get_select()use the parameter names and kinds of the Django base methods.CTEQuerySet.as_managerclassmethod is declared with decorators.warnings.deprecatedfallback is selected by Python version.