Skip to content

Add stub files with unit definitions - #17902

Closed
eerovaher wants to merge 1 commit into
astropy:mainfrom
eerovaher:unit-definition-stub-files
Closed

eerovaher wants to merge 1 commit into
astropy:mainfrom
eerovaher:unit-definition-stub-files

Conversation

@eerovaher

Copy link
Copy Markdown
Member

Description

astropy defines too many units for their definitions to be written out conventionally. Instead we have a def_unit() function that creates the units dynamically. However, def_unit() is completely opaque to static code analysis tools such as type checkers or IDEs. We can help out such tools by generating stub files that list which units are defined and what their types are. We have two options for distributing the stub files - we could include them in astropy like is being done here, or we could create a separate astropy-stubs package. I would be fine with either option. The main reason for opening the pull request in astropy was that I can't open a pull request in astropy-stubs because it doesn't exist yet.

The stub files were all generated with

import importlib
import unicodedata
from pathlib import Path

from astropy.units import UnitBase


def make_stubs(modules: list[str]) -> None:
    for module_name in modules:
        module = importlib.import_module(f"astropy.units.{module_name}")
        units = {
            unicodedata.normalize("NFKC", name): type(obj).__name__
            for name, obj in vars(module).items()
            if isinstance(obj, UnitBase)
        }
        unit_classes = sorted(set(units.values()))
        with Path(module.__file__).with_suffix(".pyi").open("wt") as stub_file:
            stub_file.write(
                "# Licensed under a 3-clause BSD style license - see LICENSE.rst\n\n"
            )
            if not all(name.isascii() for name in units):
                stub_file.write("# ruff: noqa: PLC2401\n\n")
            stub_file.write(f"from astropy.units import {', '.join(unit_classes)}\n\n")
            for unit in sorted(units):
                stub_file.write(f"{unit}: {units[unit]}\n")
  • By checking this box, the PR author has requested that maintainers do NOT use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

@eerovaher eerovaher added this to the v7.1.0 milestone Mar 17, 2025
@eerovaher
eerovaher requested review from mhvk and nstarman March 17, 2025 21:48
@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.

  • Do the proposed changes actually accomplish desired goals?
  • Do the proposed changes follow the Astropy coding guidelines?
  • Are tests added/updated as required? If so, do they follow the Astropy testing guidelines?
  • Are docs added/updated as required? If so, do they follow the Astropy documentation guidelines?
  • Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for rebase and squash.
  • Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the bot.
  • Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  • Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  • At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) before merge.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We shouldn't bother with annotations for deprecated units, but despite the name of the module the units it defines are not actually deprecated.

@nstarman nstarman left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this is great. And I think having the stubs within Astropy is the correct place.
My one comment is that there's some maybe-public (🤷 IDK it's hard to tell (and if a maintainer can't easily tell, then how can a user?)) stuff that aren't units in these modules, like the bases set in si.py. if these are public then they should be in the pyi file.

@neutrinoceros

Copy link
Copy Markdown
Contributor

I also think that these stubs belong in astropy itself. The generation script should probably be included in the repo (if not distributed) too.

Comment thread astropy/units/cgs.pyi
@@ -0,0 +1,656 @@
# Licensed under a 3-clause BSD style license - see LICENSE.rst

# ruff: noqa: PLC2401

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 would suggest adding the human readable lint id (non-ascii-name) to this noqa comment to save future readers from looking it up

@mhvk mhvk 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.

It still seems somewhat stupid that it would not be possible to annotate a module that all entries are of a given by (ideally, by default) , so I raised an issue over at mypy: python/mypy#18813

If we do go this route, then we definitely need to include the scripts that generate the files, as well as a test that the stubs are complete (which may mean running the generator code with output to temporary files and checking for differences).

Another question, more for us: is it really not possible to generate these files on the user side (on first import)?

(Noting request changes only because for sure tests are needed, so this shouldn't be merged as is, yet there are already approvals.)

@neutrinoceros

Copy link
Copy Markdown
Contributor

is it really not possible to generate these files on the user side (on first import)?

This sounds like a build step, I do not see the benefit of deferring it to import time.

@mhvk

mhvk commented Mar 18, 2025

Copy link
Copy Markdown
Contributor

This sounds like a build step, I do not see the benefit of deferring it to import time.

To me, it is more similar to how pyc files are created on first import.

@nstarman

Copy link
Copy Markdown
Member

To me, it is more similar to how pyc files are created on first import.

pyc files are system specific. stub files are not. It's more like how the unit lexers are built.

@neutrinoceros

neutrinoceros commented Mar 18, 2025 •

Copy link
Copy Markdown
Contributor

To me, it is more similar to how pyc files are created on first import.

With an important differences that we would be responsible for maintaining this mechanism, and that it could make installation less reproducible and harder to debug. I'd much prefer we ship identical stub files to everyone and avoid uncertainty in that area.

@mhvk

mhvk commented Mar 18, 2025

Copy link
Copy Markdown
Contributor

OK, makes sense to not "roll our own" -- though the lexers have moved away from pre-generated files to just reconstructing them on the fly (we just haven't caught up).

My real issue remains with mypy (or other type checkers) not being able to provide a default type. But perhaps my issue will lead to someone answering that there is actually a mechanism that we're simply not aware of. Isn't it true that if we always got units from a dict[str, Unit], that then it would be fine?

Anyway, this may be unlikely, and I don't want to hold us all up waiting for a magical answer. For sure, though, we need the generator functions included and have tests for them.

@mhvk

mhvk commented Mar 18, 2025 •

Copy link
Copy Markdown
Contributor

Somewhat of an aside, but relevant: I've been wondering whether it wouldn't make sense to create the u. unit attributes on-demand using __getattr__ rather than always defined, given that most units are never used. (In interactive use, the first tab completion would then be a bit slower, of course, as __dir__ would have to generate the units.)

In part, this comes about because of something @cdeil mentioned way back, that he generally uses the string versions of units. I.e., perhaps the recommended way of using units should be to do from astropy.units importing Unit as U and then use U("km/s2") over u.km / u.s**2 throughout - it is more readable and would be faster if we had a bit of caching on U (the cache doesn't even have to be all that large, as most code does not use an enormous number of units).

EDIT: another reason was that for a little while, it seemed like it might become possible to define one's own string types, so one could have U"m/s2" - though that is now gone, in favour of a new template string class - see https://peps.python.org/pep-0750/

@eerovaher

Copy link
Copy Markdown
Member Author

My real issue remains with mypy (or other type checkers) not being able to provide a default type.

Do you mean that whenever type checkers encounter some attribute they don't know about they should assume it has some default type? If that would be the case then type checkers would not be able to raise errors when an invalid attribute is accessed (e.g. because of a typo).

Isn't it true that if we always got units from a dict[str, Unit], that then it would be fine?

Type checkers would indeed know about the types of the values of the dictionary, but they would lose the ability to check the keys. Unless you use TypedDict, but that wouldn't be any less verbose than the stub file.

Somewhat of an aside, but relevant: I've been wondering whether it wouldn't make sense to create the u. unit attributes on-demand using __getattr__ rather than always defined, given that most units are never used. (In interactive use, the first tab completion would then be a bit slower, of course, as __dir__ would have to generate the units.)

Have thought about how Sphinx would know what units to include in the documentation?

It should be pointed out that the stub files are not just for type checkers. They also tell IDEs what units exist so that IDEs can provide autocompletion. Dynamic approaches do not allow for that.

In part, this comes about because of something @cdeil mentioned way back, that he generally uses the string versions of units. I.e., perhaps the recommended way of using units should be to do from astropy.units importing Unit as U and then use U("km/s2") over u.km / u.s**2 throughout - it is more readable and would be faster if we had a bit of caching on U (the cache doesn't even have to be all that large, as most code does not use an enormous number of units).

What's the problem with using unit instances? Is it that the leading u. is distracting? It is already possible to avoid:

>>> from astropy.units import km, s
>>> km / s**2
Unit("km / s2")

Another question, more for us: is it really not possible to generate these files on the user side (on first import)?

I don't like the idea of generating the stubs on first import, but I did consider generating the stubs during installation. In particular, I thought that we could introduce astropy/units/setup_package.py, but our documentation says

It is important to make sure that ``setup_package.py`` files do not trigger an
import of the package they are in - so they should be able to be executed without
relying on imports to other parts of the package.

I don't know how we could automatically generate the stubs if we can't use setup_package.py for it.

@mhvk

mhvk commented Mar 18, 2025 •

Copy link
Copy Markdown
Contributor

Is an answer on my mypy issue (see python/mypy#18813 (comment)) something that might work?

Does module-level __getattr__ work for you?

I can see one could annotate that appropriately, if everything else that could possibly come from the module is already defined. And it would basically implement my "on demand" suggestion. But I'm not by any means well-versed enough in the typing stuff to be sure.

EDIT: really asking just about the typing now, i.e., leaving aside for now how to deal with sphinx, or whether this is super practical in general.

@mhvk

mhvk commented Mar 19, 2025

Copy link
Copy Markdown
Contributor

Hmm, this seems very useful!!!

on Mar 18, 2025
Collaborator

You can lie in the stub and have a getattr in the .pyi file even if that's not the strategy you take to creating attributes at runtime in the .py file

@mhvk

mhvk commented Mar 19, 2025

Copy link
Copy Markdown
Contributor

Thinking more about this PR, which is moving well beyond just annotating (which is useful independently of whether the typing is 100% correct), I think it and similar ones should start meeting the bar for code contributions, i.e., they should come with proper typing test cases and documentation. This is particularly important since it seems there are multiple ways to implement the typing for all units, and we should be able to make changes knowing we don't break things.

As a point of reference, I noticed that in numpy, they have a specific typing/tests directory, which includes checks for particular failures (see https://github.com/numpy/numpy/tree/main/numpy/typing/tests/data/fail). Something like that may work for us too (perhaps on a subpackage basis).

Anyway, bottom line, I think this PR will need to wait on #16312 (or an equivalent)

@mhvk mhvk mentioned this pull request Mar 19, 2025
@mhvk

mhvk commented Mar 21, 2025

Copy link
Copy Markdown
Contributor

I don't know how we could automatically generate the stubs if we can't use setup_package.py for it.

I think it might work if we went with something like the units definition file that pint seems to use. Though that breaks then being able to use constants, which seems a show stopper (or, rather, would require defining a mini-language in the input file that might as well just be plain python, which is what we have...).

@nstarman

Copy link
Copy Markdown
Member

You can lie in the stub and have a getattr in the .pyi file even if that's not the strategy you take to creating attributes at runtime in the .py file

That seems very useful!

@fritz1005

Copy link
Copy Markdown

You can lie in the stub and have a getattr in the .pyi file even if that's not the strategy you take to creating attributes at runtime in the .py file

That seems very useful!

Not sure it is that useful. Importing a unit that does not exist should give a type error. Similar to documentation: "wrong documentation is worse than no documenation"

@mhvk

mhvk commented Mar 24, 2025

Copy link
Copy Markdown
Contributor

You can lie in the stub and have a getattr in the .pyi file even if that's not the strategy you take to creating attributes at runtime in the .py file

That seems very useful!

Not sure it is that useful. Importing a unit that does not exist should give a type error. Similar to documentation: "wrong documentation is worse than no documenation"

I guess the converse is really that the (current!) type system is not advanced enough to autogenerate files on the fly, so one gets stuck with shipping 100kB files just for the typing. But surely that will come, and in the meantime perhaps we can keep things simple for modules where we know exactly what is possible.

@fritz1005

Copy link
Copy Markdown

I guess the converse is really that the (current!) type system is not advanced enough to autogenerate files on the fly, so one gets stuck with shipping 100kB files just for the typing. But surely that will come, and in the meantime perhaps we can keep things simple for modules where we know exactly what is possible.

What do you mean by the current type system: python type hints in general or type system of astropy

@nstarman

nstarman commented Mar 24, 2025 •

Copy link
Copy Markdown
Member

The point is that with __getattr__ in the .pyi we don't need to manually enumerate all the units.
It can be dynamically (in the pyi) generated based on simple rules. That should significantly shorten the pyi files in this PR.

E.g. (pseudocode)

main_units = []
prefixes = []
def __getattr__(name):
     mu, pre = parse_name(name)
     if mu in main_units....
     if pre in prefixes
     return the_inferred_type

So this is potentially immensely useful for reducing 5k lines of code diff down to a much smaller number.

@fritz1005

fritz1005 commented Mar 24, 2025 •

Copy link
Copy Markdown

The point is that with __getattr__ in the .pyi we don't need to manually enumerate all the units. It can be dynamically (in the pyi) generated based on simple rules. That should significantly shorten the pyi files in this PR.

E.g. (pseudocode)

main_units = []
prefixes = []
def __getattr__(name):
     mu, pre = parse_name(name)
     if mu in main_units....
     if pre in prefixes
     return the_inferred_type

So this is potentially immensely useful for reducing 5k lines of code diff down to a much smaller number.

AFAIK .pyi files are not executed so you can not do anything dynamically in a .pyi file. The only thing you can do is

def __getattr__(name) -> Unit: ...

So basically, every import from that module (except explicit definitions with a different type) will be considered valid Unit objects. In reality (.py) not every name exists as a Unit. So you could import something like notaunit and the type checker would not show it as an error.

But I could be wrong and would be happy to learn that there is a way.

@mhvk

mhvk commented Mar 24, 2025

Copy link
Copy Markdown
Contributor

@fritz1005 - I think you right that we cannot do any type of calculations in the .pyi file - which is the kind of limitation I meant with the current type system - there doesn't seem any deep reason why type checkers should not be able to auto-generate the types on the fly - they have to deal with far more complicated things than this!

Anyway, personally I think for our initial goal of getting typing more or less set up, it is fine to not worry about u.<something_meaningless>. Being able to tell generally that u.<something> is a unit is not that different from telling that Unit("something") is a unit -- and it seems OK for now if the type system cannot tell that it is a valid unit in either case.

@fritz1005

fritz1005 commented Mar 24, 2025 •

Copy link
Copy Markdown

We are using type hints for about 3 years now in the company I work at, and I think it is essential for large codebases worked on by many developers. Basically everyone has the same experience, at first some reservation, then the learning curve and at the end nobody could live without it anymore with everyone contributing to add type hints wherever possible. So sure, it has its limitations, but it also already has so many substantial benefits. Some of its limitiations are really the same as in statically typed languages, so they are only limitations because we are used to the dynamic nature of python. While that is useful in many instances, in my experience it is best to have short distances of dynamic types between anchors of static types, to not have to go to far up or down the stack to know what type we are actually looking at.

That being said, I think having the (prefixed) unit instances addressed by a static __getattr__ as you suggest is probably an acceptable way (IMO) to get things up an running.

And I understand the concern about bloating the library with type hints. Maybe an alternative could be to move the autogenerated type hints to https://github.com/python/typeshed for now?

Anyways, thx for the discussion

@fritz1005

Copy link
Copy Markdown

Offtopic but a bit more context:
In our company we control a highly complex physical system using python and ATM we use a different library for physical units throughout our code base. We are not entirely happy with the current library and are actively looking for alternatives and evaluating the available options. Type hints is one of the major criteria for us, that is why I am so interested in this topic, because other than type hints astropy seems like a good option. But TBH type hints aren't great for any of the alternatives as well, so it might not be the deciding factor.

@nstarman

Copy link
Copy Markdown
Member

Or could we revisit generating the pyi files as part of the release process? It should be automatable with @eerovaher's script.

@mhvk

mhvk commented Mar 25, 2025 •

Copy link
Copy Markdown
Contributor

Given @eerovaher's very pessimistic #17918 (comment), I now really see no point in adding stub files to either the repository or to releases and all the more reason for just adding a stub (EDIT: with __getattr__) that is good enough to make parts that use units work, even if it means they don't get errors for u.mistyped_unit.

@eerovaher

Copy link
Copy Markdown
Member Author

If the main problem with these stubs is their size then why don't we create the astropy-stubs package?

@mhvk

mhvk commented Mar 25, 2025

Copy link
Copy Markdown
Contributor

I'm fine with a separate package, which, say, astropy[typing] can depend on. It also means that development can be quicker, perhaps (certainly wouldn't have me holding things up!)

@eerovaher

Copy link
Copy Markdown
Member Author

Just to be clear here, I still think we should use inline annotations as much as possible. The astropy-stubs package I have in mind would contain only the objects that cannot be annotated inline because they are generated dynamically at runtime.

@astrofrog astrofrog modified the milestones: v7.1.0, v7.2.0 Apr 28, 2025
@github-actions github-actions Bot added the Close? Tell stale bot that this issue/PR is stale label Aug 15, 2025
@github-actions

Copy link
Copy Markdown
Contributor

Hi humans 👋 - this pull request hasn't had any new commits for approximately 4 months. I plan to close this in 30 days if the pull request doesn't have any new commits by then.

In lieu of a stalled pull request, please consider closing this and open an issue instead if a reminder is needed to revisit in the future. Maintainers may also choose to add keep-open label to keep this PR open but it is discouraged unless absolutely necessary.

If this PR still needs to be reviewed, as an author, you can rebase it to reset the clock.

If you believe I commented on this pull request incorrectly, please report this here.

@github-actions

Copy link
Copy Markdown
Contributor

I'm going to close this pull request as per my previous message. If you think what is being added/fixed here is still important, please remember to open an issue to keep track of it. Thanks!

If this is the first time I am commenting on this issue, or if you believe I closed this issue incorrectly, please report this here.

@github-actions github-actions Bot closed this Sep 15, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Close? Tell stale bot that this issue/PR is stale closed-by-bot Closed by stale bot Experimental installation no-changelog-entry-needed typing related to type annotations units

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Typing Error due to return type of astropy.units.core.def_unit Add a type stub file for dynamically generated unit objects in astropy.units

6 participants