Proposal to Improve Support for Other Python Platforms in the Typing Specification

This is my first post to the Python discuss, so please be gentle. I am a long-time MicroPython user and have been working on type stubs for MicroPython for the past few year, but I still enjoy learning from others each day.

The Python typing community has made significant strides in improving type-checking capabilities across various Python implementations. However, in my little corner of the Python-ecosystem the appetite for static type checking is held back by some limitations of the specification on how type-checkers can apply the available the platform characteristics, to type-stubs.
For MicroPython the challenge is to distinguishing MicroPython-specific code and differentiating type stubs for various ports. This is currently not possible, and as a result I currently maintain multiple stub packages - each for a separate port board where less than 20% of the stubs are actually different.
This proposal aims to introduce enhancements to type-checkers to better support MicroPython and its diverse platform values.
Current platform checks by type checkers are intentionally limited in the typing spec: Type checker directives — typing documentation

I would like to discus and brainstorm the following proposal with the community, and based on the feedback, I aim to create a PR with a proposal to update the typing spec.

Current Challenges

Currently, platform checks are underspecified in the typing spec: Type checker directives — typing documentation, which only mentions sys.platform and sys.version_info.

MicroPython utilizes over 12 additional sys.platform values, such as esp32, esp8266, mimxrt, nrf, renesas-ra, rp2, samd, stm32, unix, webassembly, windows, and zephyr. Currently, developers need to write long and repetitive boilerplate code to handle these platform checks, which is both cumbersome and error-prone.

For example:

if sys.platform == "esp32" or sys.platform ==  "esp8266" or sys.platform ==  "mimxrt" or sys.platform ==  "nrf" or sys.platform ==  "renesas-ra" or sys.platform ==  "rp2" or sys.platform ==  "samd" or sys.platform ==  "stm32" or sys.platform ==  "unix" or sys.platform ==  "webassembly" or sys.platform ==  "windows" or sys.platform ==  "zephyr" : 
     @overload
     def sleep_ms(delay: float) -> None: ...

While MicroPython defines sys.implementation.name as ‘micropython’, a conditional such as if sys.implementation.name == "micropython": does not work as expected with type-checkers as the use of this attribute is currently not part of the typing spec, and therefore not implemented. sys.implementation.name can be used to distinguish not only micropython, but circuitpython, pypy, cpython, ironpython and other implementations that may benefit for from this proposal.

sys.impementation.version is also not supported in the typing spec, and therefore not implemented. MicroPython has a versioning scheme that is different from CPython, and, same as with CPython, it is important to be able to distinguish in the type-stubs between different versions of MicroPython. For example, the current version of MicroPython v1.25.0 is sys.version_info = (3,4,0) and its sys.implementation.version = (1,25,0).
As the type_checkers will not run on micropython, the versions will need to be specified though configuration or commandline arguments.

Possible Solution
To address these challenges, I propose the following enhancements to the Python typing specification and type-checkers:

  1. Support a limited number of additional attributes, such as sys.implementation.name in type checkers:

    When type-checkers correctly process sys.implementation.name, this will allow type stub authors to use if sys.implementation.name == "micropython": or if sys.implementation.name == "pypy": to simplify authoring of type stubs for these platforms.
    my current thinking is that this should be limited to a small number of attributes, such as sys.implementation.name and sys.implementation.version, to avoid overcomplicating the typing spec.

  2. Allow for additional checks:
    Introduce support for more detailed sys.platform checks, allowing patterns such as if sys.platform in ('esp32', 'esp8266', 'mimxrt', 'nrf', 'renesas-ra', 'rp2', 'samd', 'stm32', 'unix', 'webassembly', 'windows', 'zephyr'):. This would significantly reduce boilerplate code and improve readability.

    • == and != are already supported
    • in and not in a set, list or tuple should be supported as well

Benefits

Implementing these enhancements will provide several benefits:

  • Reduced Boilerplate Code: Developers will no longer need to write long and repetitive platform checks, leading to cleaner and more maintainable code.
  • Improved Type-Checking Accuracy: Type-checkers will be able to accurately distinguish MicroPython, or PyPy specific code and differentiate type stubs for various ports, improving overall type-checking accuracy.

Related discussions :

19 Likes

I can’t say how good timed this is. I’ve been using MicroPy for quite some time, and I always try to annotate all my code, so in case you would need any help, I would love to give that. The lack of type-hints in MicroPy made using older code more complicated than it is, and it would be sick to see MicroPy of Python 3.6 (or above)!

Also regarding the core problem, couldn’t the developers reduce some boilerplate by defining shorter names at the beginning of code (e.g.:

esp32 = sys.platform == "esp32"

(just to improve some DRY)?!

Thanks for the post. I appreciate the clear articulation of the problem and the menu of possible solutions.

I think that both of your proposed solutions are possible, but we should understand the advantages and costs associated with each.

It’s important to understand that these conditional platform expressions need to be evaluated by a static type checker very early in the analysis process. In pyright (and presumably other type checkers), the evaluation of these expressions informs the construction of the code flow graph. The code flow graph is needed to evaluate the type of any expression (including type annotations and type qualifiers), and determine the types of imported symbols. That means these platform expressions need to be evaluated before any type evaluation is possible. It’s a classic chicken-and-egg problem. To make this work, type checkers need to work directly from the AST looking for specific syntactical forms. This is why the typing spec is so specific in prescribing which forms are supported. Even small deviations from the supported forms will prevent it from working. For example, if you change import sys; if sys.platform == "darwin": ... to from sys import platform; if platform == "darwin"; ... it will no longer work. These two variants are semantically equivalent but syntactically different, and the syntax matters here. Likewise, if you swap the operands (if "darwin" == sys.platform) or attempt to use a variable (as suggested above by @JoBe), it will not work. Any solution needs to respect the constraints faced by type checker implementations. For more details, refer to this pyright documentation.

Supporting sys.implementation.name is possible, but it has some (perhaps non-obvious) costs. It would require type checkers to add a new configuration mechanism for setting the desired implementation name. It would also require adding command-line switches, and it would require auto-detection of this value if it’s not specified in the configuration or command line. This wouldn’t be so bad if it were just type checkers, but many CI scripts, test harnesses, typing-related tools (e.g. mypy_primer), etc. also provide ways to configure the pythonVersion and pythonPlatform. This approach would compel all of these tools and scripts to plumb through another configuration setting. That’s doable, but it will take time and impose some not-insignificant cost across the ecosystem.

Supporting alternative syntax forms for sys.platform checks is possible and would be limited to just type checkers, linters, stub generators, stub testers, and similar tools. It wouldn’t require new configuration or auto-discovery mechanisms. Most other tooling and scripts would not need to be modified because they already support a pythonPlatform configuration setting and command-line switches. So this option is less costly than the first. If we were to adopt this option, I would recommend against supporting tuples, lists, and sets. I would pick one of them — preferably tuples — and mandate that everyone use that. This isn’t a situation where supporting lots of freedom, creativity, and redundant approaches is helpful. Picking one supported syntactic form is beneficial. Supporting both in and not in operators is straightforward.

Let me suggest a third option that may partly address the problem without requiring any new functionality. Both mypy and pyright allow developers to define “constants” in the config file. In pyright, this is done with the defineConstant setting, and both boolean and string constants are supported. Mypy supports always_true and always_false, which limits it to booleans. You could standardize a symbol such as MICROPYTHON (all caps to indicate that it’s a constant) that you use within your stubs. Consumers of the stubs could then modify their type checker configuration to indicate that MICROPYTHON should always be evaluated as True. If mypy were extended to support string-based constants (as pyright already does), you could standardize a symbol such as IMPLEMENTATION_NAME that takes on the value of micropython. Some variation of this idea may address part of the problem you’re facing.

I’ll note that none of the above solutions (including the two you proposed) address the “implementation version” requirement. Relative to platform comparisons, how important are version comparisons?

4 Likes

This requires the project to support only one IMPLEMENTATION_NAME value. But libraries often tend to support more than one implementation.

2 Likes

Simplicity and predictability

I fully agree on a tightly constrained set of conditions and evaluations.
But today the constrain is worded as “Don’t expect a checker to understand obfuscations” which, at least to me, is neither precise nor clear.

Thanks for the link to pyright’s implementation of this, which is definitely clearer, and seems a bit broader as well.

As a stub author I am faced with the unclarity of what may or may not be supported by other type checkers. so if we can bring the same level of clarity to the typing spec that would definitely be helpful.
I have adopted that notation in this response .

Tooling Implementation effort
Im very aware that there is effort, and likely cost, involved with any change, and that that should be balanced against its benefits.
There are also costs for not implementing this, we are replacing that effort on tooling by effort of some individuals to make it work. I know of several individual and community projects that have stopped maintaining, as it was too difficult/impossible to create one set of stubs that could be used by multiple platforms ( ports / boards) and versions. I have been at this for some 5 years now, and have been able to keep up, mostly though good support and clarity in the standards.

I cannot judge if its riskier to change an existing config options or add a new one,
but minimizing the change needed is definitely part of the equations that we need to discuss and solve.

Allow the use of externally defined constants as string literals

Before raising this I already tested if that would be possible. As my aim is to support multiply type checkers this would be limited to True False for MyPy.

Using defined constants as string literals would be a great addition to the spec, espcially if that would also allow for a in <tuple> check.

  • <DEFINED_CONSTANT> == <string literal>
  • <DEFINED_CONSTANT> in <tuple of string literals>

The teachability of this would be a bit more difficult, as we will need to educate creators and users of stubs to use the same constant name, which in the case if MicroPython will be derrived from sys.platform and sys.implementation attributes that seem to be defined for such a purpose.

my preference would be to be able use :

  • sys.implementation.name == <string literal>
  • sys.implementation.name in <tuple of string literals>

but indeed the defined constants could be a good alternative if added to the typing spec.

Allow the use sys.implementation.version

  • sys.implementation.version <comparison> <tuple>

I previously tested this to see if I could use a DEFINED constant for the MicroPython version but never got that to work properly there is only a <DEFINED_CONSTANT> == True check in mypy.

Even in pyright with a <DEFINED_CONSTANT> == <string literal> I would need to enumerate all the versions as literal strings, and update them as new versions come available. In the CI tests that I run to verify the stubs i create against mypy and pyright I currently have 42 check that are version specific, either to the introduction, removal of a new feature or the change of an API.

MicroPython is also used more and mode by professionals to create devices used by consumers, and in manufacturing and medical environments where the lifecycle of producs can be quite long. By allowing a type-stub to be valid for multiple versions of MicroPython this would allow code for devices to be maintained for a longer time, using the improvements in the recent stubs. This is no different than the extensive use of sys.version_info <comparison> <tuple> in the standard library to allow for backwards compatibility.

Without a way to check the version of the implementation I have to maintain each version of the stubs separately. I have created rather extensive automation for this but it would be great if I could use a single set of stubs for multiple versions of MicroPython. With 12 different ports ( not to mention the 180+ different boards) this is a lot of work to maintain.

Allowing for sys.implementation.version <comparison> <tuple> would be a great addition for MicroPython, but I cant assess this for other platforms such as pypy.

6 Likes

I would like to revisit this conversation with a concrete proposal in the hope of gaining additional feedback.

Proposed Specification Text

The following is a concrete proposal for updating the Version and Platform Checking section of the Python typing specification.
This also contains a rewording of the existing text for clarity and completeness.

========================================================================

Version and platform checking

Type checkers should support narrowing based on sys.version_info, sys.platform, and sys.implementation.name checks.

sys.version_info checks

Type checkers should support sys.version_info comparisons:

import sys

if sys.version_info >= (3, 12):
    # Python 3.12+
else:
    # Python 3.11 and lower

Supported patterns:

  • Equality: sys.version_info == (3, 10)
  • Inequality: sys.version_info != (3, 9)
  • Comparison: sys.version_info >= (3, 10), sys.version_info < (3, 12)
  • Tuple slicing: sys.version_info[:2] >= (3, 10)
  • Element access: sys.version_info[0] >= 3

sys.platform checks

Type checkers should support sys.platform comparisons:

import sys

if sys.platform == 'win32':
    # Windows specific definitions

if sys.platform in ("linux", "darwin"):
    # Platform-specific stubs for Linux and macOS
    ...

Supported patterns:

  • Equality: sys.platform == "linux"
  • Inequality: sys.platform != "win32"
  • Membership: sys.platform in ("linux", "darwin")
  • Negative membership: sys.platform not in ("win32", "cygwin")

sys.implementation.name checks

Type checkers should support sys.implementation.name comparisons:

import sys
if sys.implementation.name == "cpython":
    # CPython-specific stub
if sys.implementation.name == "micropython":
    # MicroPython-specific stub

Supported patterns:

  • Equality: sys.implementation.name == "cpython"
  • Inequality: sys.implementation.name != "cpython"
  • Membership: sys.implementation.name in ("pypy", "graalpy")
  • Negative membership: sys.implementation.name not in ("cpython", "pypy")

Common values: "cpython", "pypy", "micropython", "graalpy", "jython", "ironpython"

Configuration

Type checkers should provide configuration to specify target version, platform, and implementation. The exact mechanism is implementation-defined.

========================================================================

Open Questions

  1. Scope of sys.platform collections:

    • Should we support any iterable, or limit to tuple or set literals? Recommend: tuple literals; set literals if feasible for type checkers
    • Should we support not in checks? Recommend: Yes
  2. sys.implementation attributes:

    • Should we support sys.implementation.version comparisons (similar to sys.version_info)?
  3. Combining version, platform, and implementation checks:

    • Should we explicitly allow combined checks (e.g., if sys.platform == "linux" and sys.implementation.name == "pypy":)?
      Recommend: Yes, as long as each individual check is supported. This is not explicitly specified in the proposed text, as the implementation complexity for type checkers needs further assessment.

I’d appreciate feedback on:

  • Syntax: Are the proposed patterns (especially collection membership) the right approach?
  • Use cases: Are there other alternative implementations or patterns that should be considered?
  • Implementation concerns: Type checker maintainers - are there technical challenges I haven’t considered?
  • Specification wording: What level of detail should be in the typing spec vs. left to implementation?

Related discussions:

Thank you for considering this proposal.

9 Likes

To get a sense of what features are supported, I (well, Codex) wrote a set of scripts to compare across mypy, pyright, pyrefly, and ty.

This is for sys.platform.

Feature mypy pyright ty pyrefly
p = sys.platform :cross_mark: :cross_mark: :white_check_mark: :cross_mark:
TARGETS tuple :cross_mark: :cross_mark: :cross_mark: :cross_mark:
or :white_check_mark: :white_check_mark: :white_check_mark: :white_check_mark:
== :white_check_mark: :white_check_mark: :white_check_mark: :white_check_mark:
from sys import platform :cross_mark: :cross_mark: :white_check_mark: :cross_mark:
import sys as other_name :cross_mark: :white_check_mark: :white_check_mark: :cross_mark:
in (list) :cross_mark: :cross_mark: :cross_mark: :cross_mark:
in (set) :cross_mark: :cross_mark: :cross_mark: :cross_mark:
in (tuple) :cross_mark: :cross_mark: :cross_mark: :cross_mark:
!= :white_check_mark: :white_check_mark: :white_check_mark: :white_check_mark:
startswith :white_check_mark: :cross_mark: :white_check_mark: :white_check_mark:
not (==) :white_check_mark: :white_check_mark: :white_check_mark: :white_check_mark:
not in (list) :cross_mark: :cross_mark: :cross_mark: :cross_mark:
not in (set) :cross_mark: :cross_mark: :cross_mark: :cross_mark:
not in (tuple) :cross_mark: :cross_mark: :cross_mark: :cross_mark:
reverse == :cross_mark: :cross_mark: :white_check_mark: :white_check_mark:
reverse != :cross_mark: :cross_mark: :white_check_mark: :white_check_mark:

I published a repo for this at GitHub - JelleZijlstra/typechecker-matrix; check typechecker-matrix/samples/sys-platform at main · JelleZijlstra/typechecker-matrix · GitHub for more details on the features tested here.

In summary, the only universally supported checks are of the style sys.platform == "foo", !=, not (sys.platform == "foo"), and or of those. Surprisingly (to me), all type checkers but pyright supports startswith checks like if sys.platform.startswith("darwin"). No type checker currently supports sys.platform in (...).


In the spec, we should at a minimum require support for all the universally supported patterns. I’m open to adding support for more patterns. startswith makes some sense because on some OSes a version is appended to the name (see the docs: sys — System-specific parameters and functions — Python 3.14.3 documentation), and is already supported by most type checkers.

@Jos_Verlinde is asking for support for in. I’m open to that, but I’m surprised no type checker has added support for this pattern yet; that suggests it’s not commonly needed.

I’ll do a similar exercise for sys.version_info next.

6 Likes

And here’s a similar table for sys.version_info.

Feature mypy pyright ty pyrefly
chained range :cross_mark: :cross_mark: :white_check_mark: :cross_mark:
from sys import version_info as v :cross_mark: :cross_mark: :white_check_mark: :cross_mark:
from sys import version_info :cross_mark: :cross_mark: :white_check_mark: :cross_mark:
>= tuple :white_check_mark: :white_check_mark: :white_check_mark: :white_check_mark:
>= tuple (5-part) :cross_mark: :white_check_mark: :cross_mark: :cross_mark:
>= tuple (3-part) :cross_mark: :white_check_mark: :cross_mark: :cross_mark:
import sys as other_name :cross_mark: :white_check_mark: :white_check_mark: :cross_mark:
in supported set :cross_mark: :cross_mark: :cross_mark: :cross_mark:
[0] == :white_check_mark: :white_check_mark: :white_check_mark: :cross_mark:
local alias slice == :cross_mark: :cross_mark: :white_check_mark: :cross_mark:
< tuple :white_check_mark: :white_check_mark: :white_check_mark: :white_check_mark:
.major == :cross_mark: :cross_mark: :white_check_mark: :cross_mark:
(major, minor) == :cross_mark: :cross_mark: :white_check_mark: :cross_mark:
.minor == :cross_mark: :cross_mark: :white_check_mark: :cross_mark:
not (>=) :white_check_mark: :white_check_mark: :white_check_mark: :white_check_mark:
reverse <= tuple :white_check_mark: :cross_mark: :white_check_mark: :white_check_mark:
reverse == slice :white_check_mark: :cross_mark: :white_check_mark: :cross_mark:
[:2] == tuple :white_check_mark: :cross_mark: :white_check_mark: :cross_mark:

Code for these features is in typechecker-matrix/samples/sys-version-info at main · JelleZijlstra/typechecker-matrix · GitHub

Here the only universally supported patterns are the obvious ones, if sys.version_info >= (3, n) and < (3, n), as well as combining those with not. Once again, ty supports the most patterns, though pyright is unique this time in supporting comparisons against version tuples with >2 elements.


I don’t see a strong case for adding support for any other pattern, but we could prescribe support for some others if users find it useful.

8 Likes

Having this would allow condensing some branches around things like specific signal support (supported on multiple sets of platforms, and not on other sets), but I haven’t found it too annoying that it isn’t a supported pattern, as most of this ends up either with conditional definitions or conditional imports at the top of a file. If this section gets unwieldy, it’s generally possible to move it to another file and import from that.

I think standardizing what’s needed to be supported is a good goal here, and would say that while doing it, the cost of in should theoretically be low, so it makes sense to do so that users don’t need to reference external documentation to find out what the exact right way to tell a type checker this is. Whatever reasonable choice they make should “just work”, at least for platform information the type system allows narrowing on.

3 Likes

In typeshed, I often wish we could use three-tuple checks, e.g. if sys.version >= (3, 12, 3). Micro Python versions add functions or arguments fairly regularly, especially early in the development cycle. Expressing this in the stubs (at least as documentation) seems useful to me. It could also alleviate some problems we have with stubtest, where different platforms use different Python micro versions in GitHub Actions runners (or when running stubtest locally).

2 Likes

That sounds like a misunderstanding. “Early development cycle” sounds like alphas/betas rather than micro versions. Micro versions are supposed to never change signatures or APIs (with rare exceptions only for security fixes).

2 Likes

But experience show that they do. Sometimes even new features are squeezed into .1 or .2 versions.

2 Likes

For an example of this see typeshed/stdlib/heapq.pyi at aab5cc601db53c4d3c72869c1257aa39bf88670f · python/typeshed · GitHub

if sys.version_info >= (3, 14):
    # Added to __all__ in 3.14.1
    __all__ += ["heapify_max", "heappop_max", "heappush_max", "heappushpop_max", "heapreplace_max"]
2 Likes

What is a type checker supposed to do when it encounters a sys.version_info check that includes the micro version, but the type checker’s source of Python version information (e.g. the project’s pyproject.tomlor the user’s type checker’s configuration) only specifies major and minor version? (Do any type checkers today support configuring Python version with micro?)

I don’t think that more accurate stubs in a few rare edge cases is worth making it more complicated for every type checker user to configure their type checker.

2 Likes

In case of (at least) pyright and mypy, if you don’t explicitly specify a python version for them to use, then they assume the Python version from the current environment. So that would be one way.

What is a type checker supposed to do when it encounters a sys.version_info check that includes the micro version, but the type checker’s source of Python version information (e.g. the project’s pyproject.toml or the user’s type checker’s configuration) only specifies major and minor version? (Do any type checkers today support configuring Python version with micro?)

If the type checker doesn’t have a more precise way to determine the Python version, it can always fall back to treating >= (x, y, z) as >= (x, y). But tools that can profit from the more precise annotations (like stubtest or IDEs) will still benefit from it, without any downsides to users.

1 Like

So a type checker configured to check for 3.14 should treat sys.version_info >= (3, 14, 9999) as “always true”?

This would look like a bug to me.

1 Like

This sounds hypothetical to me. I prefer to deal with real-world problems.

I don’t think it’s a hypothetical.

If you’re relying on >= (3, 14, 1) (3, 14, 0) should fail, not be silently allowed.

Can specify that when a type checker has only the information (x, y) and is compared to (x, y, z) for sys.version_info, that y is ±1 (depending on > or <), but that would run into another false issue in the alternative branches

I guess you could make both of these branches false when lacking enough granular information (because of the potential of 3.14.0)

if version >= (3, 14, 1):
    ...
else:
    ...

but I think the more principled answer is to not break apis

1 Like

The interpretation here isn’t that (3, 14) >= (3, 14, 1) should be true, it’s that the correct way to interpret a version string like "3.14" is as (3, 14, ω), such that \omega \ge n\ \forall n — i.e. consistent with how they’re interpreted when selecting an installation candidate from available package versions.

Hard disagree; I’d consider it a better answer to have the type checker interpret both branches as true, though even that isn’t better than interpreting partial versions correctly. Consider what sorts of featural changes actually happen in a micro version:

  • Add a feature the checker’s user isn’t affected by.
  • Add a feature the checker’s user actively wants information about.
  • Remove a feature that probably shouldn’t be used even on the .0 release.

It’s not impossible that a user might introduce a defect into their code related to the existence of an extra kwarg the didn’t know about — but a much more likely defect is one where they introduce the usage of a kwarg the checker recommended that vanishes as soon as their package is reinstalled somewhere else with 3.14.1 instead of 3.14.0 exactly as specified by “3.14”

2 Likes