Skip to content

Add inline type annotations and py.typed marker - #148

Open
karolyi wants to merge 1 commit into
dimagi:mainfrom
karolyi:inline-type-annotations
Open

karolyi wants to merge 1 commit into
dimagi:mainfrom
karolyi:inline-type-annotations

Conversation

@karolyi

@karolyi karolyi commented Sep 29, 2026

Copy link
Copy Markdown

Ship type information with the package so users of mypy, pyright and other type checkers get typed signatures for the public API:

  • 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(), CTE() and CTE.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 deprecated With, CTEQuerySet and CTEManager are typed. Type checkers report the deprecated API as deprecated.
  • py.typed marks 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:

  • The JIT mixins declare Query / SQLCompiler as their base for type checkers only; at runtime their base stays object.
  • raw_cte_sql() returns a subclass of the new RawCTEQuerySet marker class.
  • as_sql() of CTE column expressions returns params as a tuple.
  • CTEColumn.relabeled_clone() and NoAliasCompiler.get_select() use the parameter names and kinds of the Django base methods.
  • The CTEQuerySet.as_manager classmethod is declared with decorators.
  • The warnings.deprecated fallback is selected by Python version.

@terencehonles

Copy link
Copy Markdown
Contributor

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.

Comment thread django_cte/cte.py Outdated
@karolyi

karolyi commented Sep 29, 2026 •

Copy link
Copy Markdown
Author

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 terencehonles left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread django_cte/cte.py Outdated
Comment on lines +120 to +125
def recursive(
cls,
make_cte_queryset: Callable[[CTE[_QS]], _QS],
name: str = "cte",
materialized: bool = False,
) -> CTE[_QS]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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):

Suggested change
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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread django_cte/cte.py Outdated
Comment on lines 142 to 158
@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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
@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: ...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

Suggested change
@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]: ...

Comment thread django_cte/cte.py Outdated
@terencehonles

Copy link
Copy Markdown
Contributor

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.

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).

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.

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.

@karolyi

karolyi commented Sep 29, 2026

Copy link
Copy Markdown
Author

@millerdev, care to approve the workflow so we can see if tests run clear?

@karolyi
karolyi force-pushed the inline-type-annotations branch from e1dfd1d to 26042a9 Compare September 30, 2026 00:03
@millerdev

Copy link
Copy Markdown
Contributor

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.

@karolyi

karolyi commented Oct 1, 2026

Copy link
Copy Markdown
Author

Your command is my wish, coming soon when I'll have time for it. :)
Just make sure you merge in a timely fashion when it arrives and checks out.

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.
@karolyi
karolyi force-pushed the inline-type-annotations branch from 26042a9 to eaef090 Compare October 2, 2026 23:16
@karolyi

karolyi commented Oct 2, 2026

Copy link
Copy Markdown
Author

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.

This branch has not been deployed

No deployments
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