Shape typing in Python
jameshfisher.com
jameshfisher.com
This is where some of the stuff in the TypeScript ecosystem really shines, IMHO — being able to have a completely typesafe ORM such as Drizzle (https://orm.drizzle.team/) feels like a Rubicon moment, and touching anything else feels like a significant step backwards.
For the latter cases, it's not easy because typed APIs require different principles than dynamic/duck-typed ones. Still, I think it's safe to say that the community is trending towards more typing over time, especially greenfield projects. Personally, all my new projects are 100% typed, with type-safe wrappers around untyped libraries.
For what it's worth, since Python 3.12 (or with typing_extensions for earlier versions), it's also possible to use Unpack and TypedDict to type kwargs.
Could be a nice showcase project for Copilot.
So, I actually tried this. I tried to use copilot to help generate type stubs for a third party library, hoping to be pleasantly surprised.
Copilot generated reasonable-looking type stubs that were not close enough to correct to be of any value. Even with the full source code in context, it failed to "reason" correctly about any of the hard stuff (unions, generics, overloads, variadics, quasi-structured mappings, weird internal proxy types, state-dependent responses, etc. etc.).
In my experience, bolting types onto a duck-typed API always produces somewhat kludgy results that won't be as nice as a system designed around static typing. So _of course_ an LLM can't solve that problem any more than adding type stubs can.
But really, the answer to "will LLMs fix $hard_problem for us?" is almost always "no", because $hard_problem can rarely be solved by just writing some code.
sqlalchemy.orm.relationship(argument: _RelationshipArgumentType[Any] | None = None, secondary: _RelationshipSecondaryArgument | None = None, *, uselist: bool | None = None, collection_class: Type[Collection[Any]] | Callable[[], Collection[Any]] | None = None, primaryjoin: _RelationshipJoinConditionArgument | None = None, secondaryjoin: _RelationshipJoinConditionArgument | None = None, back_populates: str | None = None, order_by: _ORMOrderByArgument = False, backref: ORMBackrefArgument | None = None, overlaps: str | None = None, post_update: bool = False, cascade: str = 'save-update, merge', viewonly: bool = False, init: _NoArg | bool = _NoArg.NO_ARG, repr: _NoArg | bool = _NoArg.NO_ARG, default: _NoArg | _T = _NoArg.NO_ARG, default_factory: _NoArg | Callable[[], _T] = _NoArg.NO_ARG, compare: _NoArg | bool = _NoArg.NO_ARG, kw_only: _NoArg | bool = _NoArg.NO_ARG, lazy: _LazyLoadArgumentType = 'select', passive_deletes: Literal['all'] | bool = False, passive_updates: bool = True, active_history: bool = False, enable_typechecks: bool = True, foreign_keys: _ORMColCollectionArgument | None = None, remote_side: _ORMColCollectionArgument | None = None, join_depth: int | None = None, comparator_factory: Type[RelationshipProperty.Comparator[Any]] | None = None, single_parent: bool = False, innerjoin: bool = False, distinct_target_key: bool | None = None, load_on_pending: bool = False, query_class: Type[Query[Any]] | None = None, info: _InfoType | None = None, omit_join: Literal[None, False] = None, sync_backref: bool | None = None, **kw: Any) → Relationship[Any]
https://docs.sqlalchemy.org/en/20/orm/relationship_api.html#...Maybe try using an IDE? Without one any language's type system will feel more frustrating than it's worth, since you won't get inline error messages either.
> Even strongly typed languages like rust have ergonomics to help you avoid explicitly specifying types like let x = 1.
This is called type inference, and as far as I can tell this level of basic type inference is supported by the major python type checkers. If you're seeing people explicitly annotate types on local variables that's a cultural problem with people who are unaccustomed to using types.
As for that function signature, it would be bonkers with or without types. The types themselves look pretty straightforward, the problem is just that they formatted it all on one line and have a ridiculous number of keyword arguments.
I agree part of the problem is cultural. Maybe a bunch of Python coders are eager to use types, or maybe linters are pushing them to type every last variable because that is “right.” I don’t know.
I don’t hate typed languages at all. In fact I love writing Rust. Even C++ is tolerable from a type perspective. I don’t agree that _RelationshipJoinConditionArgument is a meaningful type. It feels like bolting a type system onto the language after the fact is weird and necessitates crazy types like that to make some linter happy, maybe to make VS Code users happy, at the expense of readability.
As for SQLAlchemy, I wouldn't assume that the object model would be particularly different in any other OO language for the problem it's solving.
Is it particularly different from Rust's unusual types like `Map<Chain<FromRef<Box dyn Vec<Foo>>>>>` that you can get when doing chained operations on iterators?
Protocol/Trait based typing necessitates weird names for in-practice traits/protocols that are used.
Edit: IDK why that function signature is that ridiculous, reformatting it as:
sqlalchemy.orm.relationship(
argument: _RelationshipArgumentType[Any] | None = None,
secondary: _RelationshipSecondaryArgument | None = None,
*,
uselist: bool | None = None,
collection_class: Type[Collection[Any]] | Callable[[], Collection[Any]] | None = None,
primaryjoin: _RelationshipJoinConditionArgument | None = None,
secondaryjoin: _RelationshipJoinConditionArgument | None = None,
back_populates: str | None = None,
order_by: _ORMOrderByArgument = False,
backref: ORMBackrefArgument | None = None,
overlaps: str | None = None,
post_update: bool = False,
cascade: str = 'save-update, merge',
viewonly: bool = False,
init: _NoArg | bool = _NoArg.NO_ARG,
repr: _NoArg | bool = _NoArg.NO_ARG,
default: _NoArg | _T = _NoArg.NO_ARG,
default_factory: _NoArg | Callable[[], _T] = _NoArg.NO_ARG,
compare: _NoArg | bool = _NoArg.NO_ARG,
kw_only: _NoArg | bool = _NoArg.NO_ARG,
lazy: _LazyLoadArgumentType = 'select',
passive_deletes: Literal['all'] | bool = False,
passive_updates: bool = True,
active_history: bool = False,
enable_typechecks: bool = True,
foreign_keys: _ORMColCollectionArgument | None = None,
remote_side: _ORMColCollectionArgument | None = None,
join_depth: int | None = None,
comparator_factory: Type[RelationshipProperty.Comparator[Any]] | None = None,
single_parent: bool = False,
innerjoin: bool = False,
distinct_target_key: bool | None = None,
load_on_pending: bool = False,
query_class: Type[Query[Any]] | None = None,
info: _InfoType | None = None,
omit_join: Literal[None, False] = None,
sync_backref: bool | None = None,
**kw: Any
) → Relationship[Any]
It's 2-3 expected arguments and then ~30 options (that are all like Optional[bool] or Optional[str] to customize the relationship factory. Types like `_ORMColCollectionArgument` do stick out, but they're mainly there because these functions accept `Union[str, ResolvedORMType]` and will convert some sql string to a resolved type for you, and like, this is an ORM, there are going to be some weird ORM types.I have MyPy and Ruff going all the time and generally aim for zero linter errors.
I disagree, for me the integration with the editor mostly shortens feedback cycles, and enables some more advanced features. The utility of identifying problems without running the code is still there.
This is a reasonable take if you're a solo developer working without an IDE. Though I suspect you'd still find a few missing None checks with type checking.
If you're working on a team, though, the idea is to put type-checking into your build server, alongside your tests, linting, and whatnot.
> You see extraneous code like x: int = 1 in Python now.
This shouldn't be necessary in most cases; Python type checkers are fine with inferring types.
> Third party libs have bonkers types. This function signature is ridiculous:
It is. Part of that is that core infrastructure libraries tend to have wonky signatures just by their nature. A bigger part, though, is that a lot of APIs in popular Python libraries are poorly designed, in that they're extremely permissive (like pandas APIs allowing dataframes, ndarrays, list of dicts, and whatever else) and use kwargs inappropriately. Type declarations just bring that to the surface.
I suspect they probably confuse AI tools more than restrictive APIs too, and give non-AI auto complete less to go on.
For starters Rust's official linter, clippy, would also tell you that this function has too many arguments. ;) The default (max) is seven[1].
The above function has 36 named arguments ... That is a code UX wtf with or without type annotations.
[1] https://rust-lang.github.io/rust-clippy/master/index.html#/t...
Ditto for vim!
Alright, but there's nothing stopping you from having a completely typesafe ORM in python, is there?
Sure, there's isn't really one that everyone uses yet, but the python community tends to be a bit more cautious and slower to adopt big changes like that.
Since then, I have used established libraries like Beautiful Soup, Jinja, Pillow, platformdirs, psutil, python-dateutil, redis-py, and xmltodict with either official or third-party types. I remember their types being useful to varying degrees and not a problem. I have replaced Requests with the very similar but typed and optionally async HTTPX. My most objectionable experience with types in Python so far has been having to write
root = cast(
lxml.etree._Element, # noqa: SLF001
html5.parse(html, return_root=True),
)
when I used types-lxml with https://github.com/kovidgoyal/html5-parser. In return I have been able to catch some bugs early and to "fearlessly"refactor code with few or no unit tests, only integration tests. The style I have arrived at is close to https://kobzol.github.io/rust/python/2023/05/20/writing-pyth....Admittedly, I don't use Django. Maybe I won't like typed Django if I do. My choice of type checker is Pyright in non-strict mode. It seems to usually, though not always, catch more and more subtle type errors than mypy. I understand that for Django, mypy with a Django plugin is preferred.
I don’t think it’s very scalable, and having the library itself or a stubs package come with types is the only “good”-feeling route, but you at least have a somewhat decent path to still getting it decent without any intervention on the library’s part. It may even be sufficient, if (like in most situations) you only use a few functions from a library (which may in turn call others, but you only care about the ones your code directly touches), and therefore only need to type those ones.
I find myself doing a lot of isinstance() and raise TypeError, but that's still a huge win, protecting everything after I've asserted the duck type is what it should be.
I also use beartype for runtime protection.
Typescript is pretty amazing though. I really like how integrated the ecosystem is.
from typing import Protocol, Tuple, TypedDict
class Foo(TypedDict):
foo: str
bar: int
baz: Tuple[str, int]
baaz: Tuple[float, ...]
class Functionality(Protocol):
def do(self, it: Foo): ...
class MyFunctionality: # not explicitly implemented
def do(self, it: Foo): ...
class DoIt:
def execute(self, it: Foo, func: Functionality): ...
doit = DoIt().execute({ # Type checks
"foo": "foo",
"bar": 7,
"baz": ("str", 2),
"baaz": (1.0, 2.0)}, MyFunctionality())
Protocols and TypedDicts let you do nice structural stuff, similar typescript (though not as feature complete). Types are good enough on python that I would never consider a project without them, and I work with pandas and numpy a lot. You change your workflow a little bit so that you end up quarantining the code that interfaces with third-party libraries that don't have good type support behind your own functions that do. There are other pretty cool type things as well. Python is definitely in much better shape than it was.Combine all of that with Pyright's ability to do more advanced type inference and its like a whole new language experience.
It’s even more disappointing because this isn’t just an oversight. The authors have deliberately made having additional keys an error. Apparently, this even a divergence from how TypeScript checks dictionaries.
from typing import TypedDict, Unpack, NotRequired
class Movie(TypedDict):
name: str
year: NotRequired[int]
def foo(**kwargs: Unpack[Movie]) -> None: ...
[1] https://typing.readthedocs.io/en/latest/spec/callables.html#...
[2] https://peps.python.org/pep-0692/ # Simple case, any halfway-decent type system should be able to handle this.
def inner(*, i): pass
def middle(*, m, **kwargs): inner(**kwargs)
def outer(*, o, **kwargs): middle(**kwargs)
kwargs = ...; outer(**kwargs)
# More complicated case, but fairly common in Python code.
class A:
def __init__(self, *, a):
pass
class B(A):
def __init__(self, *, b, **kwargs):
super().__init__(**kwargs)
class C(A):
def __init__(self, *, c, **kwargs):
super().__init__(**kwargs)
class D(B, C):
def __init__(self, *, d, **kwargs):
super().__init__(**kwargs)
kwargs = ...; D(**kwargs)
# An even more complicated case involves `kwargs.pop()`, forwarding without `**`.
Can the above be typechecked yet?Specifically: absent optional keys + extra keys is fundamentally indistinguishable from miseptl keys.
TypedDicts are disappointing because you can't partially define a type? That seems like a success
In go you would need to define all fields in your struct and if you needed unstructured data you would have to define a map, which even then is partially typed
How should extra keys behave in "typed python?"
Python is a dynamic language where one of its major features is structural subtyping, aka duck typing. It’s effectively an alternative to inheritance. Features have been added to help support this, like TypedDicts and Protocols already. They don’t go far enough.
I want to be able to say “this function argument is a dictionary that has at least the keys a, b, and c.” It gives the contract that the function only accesses those keys, and others will be ignored. The type checker can check that the function doesn’t access undeclared keys and the annotation helps communicate to client code what the interface is supposed to be.
Lots of Python’s bolted on type checking seems to be straight jacketed to match what’s in C, Java, Go, etc. Those languages don’t contain the only possible type systems or static checkers. There’s a serious lack of imagination. Python’s type system should be designed around enabling and making safer what the language is already good at.
Do we agree that this is the behavior of regular dicts in python? How should TypedDicts be different?
Surely, the goal of typing in python should not be to match behavior without
We've used it to great effect.
As far as I can tell, it's runtime, not static, so it won't help during our mypy static checks period?
As intuited by the poster above, we already do generally stick to Apache Arrow column types for data we want to control. Anything we do there is already checked dynamically, such as at file loads and network IO (essentially contracts), and Arrow IO conversions generally already do checks at those points. I guess this is a lightweight way to add stronger dynamically-checked contracts at intermediate function points?
https://dev.arie.bovenberg.net/blog/python-datetime-pitfalls...
Having a type checking system respect arrow schemas is indeed our ideal. Will polars during mypy static type checking invocations catch something like `df.this_col_is_missing` as an error? If so, that's what we want, that's great!
FWIW, we donated some of the first versions of what became apache arrow ;-)
Once accustomed to shape checking it‘s quite a boost in productivity for us, no more fiddling around with invalid dimensions.
People here are suggesting that without an ide typing in Python doesn't make sense. I'm finding that as an emacs user this feels true.
Is anyone using emacs primarily and if so, do you have suggestions on what to do to benefit from Python typing?
Also, I've been struggling with python in the repl. Is there a way to see types in a more dynamic way there? Obviously autocomplete works but I wish a function call would suggest the type. I assume I'm not using things correctly.
I'll try and put together a Numpy/JAX wrapper for this, because I've been looking for something that does compile-time shape checking properly for a long time!
Still wish Python's type system was as powerful as Typescript's... it's got potential that it just doesn't live up to.