Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 20 additions & 20 deletions astropy/units/physical.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

__all__ = ["def_physical_type", "get_physical_type", "PhysicalType"]

_units_and_physical_types = [
_units_and_physical_types : list[tuple(u.UnitBase, str | set(str))] = [

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.

Suggested change
_units_and_physical_types : list[tuple(u.UnitBase, str | set(str))] = [
_units_and_physical_types : list[tuple[u.UnitBase, str | set[str]]] = [

(core.dimensionless_unscaled, "dimensionless"),
(si.m, "length"),
(si.m**2, "area"),
Expand Down Expand Up @@ -179,7 +179,7 @@ def _replace_temperatures_with_kelvin(unit):
return unit


def _standardize_physical_type_names(physical_type_input):
def _standardize_physical_type_names(physical_type_input: str | set[str]) -> set[str]:
"""
Convert a string or `set` of strings into a `set` containing
string representations of physical types.
Expand Down Expand Up @@ -312,7 +312,7 @@ class PhysicalType:
PhysicalType('length')
"""

def __init__(self, unit, physical_types):
def __init__(self, unit: u.UnitBase, physical_types):
self._unit = _replace_temperatures_with_kelvin(unit)
self._physical_type_id = self._unit._get_physical_type_id()
self._physical_type = _standardize_physical_type_names(physical_types)
Expand All @@ -321,7 +321,7 @@ def __init__(self, unit, physical_types):
def __iter__(self):
yield from self._physical_type_list

def __eq__(self, other):
def __eq__(self, other: object) -> bool:

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.

Shouldn't this be

Suggested change
def __eq__(self, other: object) -> bool:
def __eq__(self, other: PhysicalType | str) -> bool:

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.

It's valid to do PhysicalType() == 4, which will return False after trying 4.__req__().

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.

Unresolving this, since I think @eerovaher's comment is actually relevant: isn't typing supposed to tell what is needed to make this function succeed? With PhysicalType|str, that is clear, with object you get zero information.

I guess for these dunder methods, it depends on what you mean by "succeed" - I'd argue that returning NotImplemented is a form of not succeeding, since if the reverse also does it, you get a TypeError. More generally, that the reverse is called is irrelevant for thinking about success of this function.

@nstarman nstarman Jan 24, 2024 •

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.

See https://discuss.python.org/t/make-type-hints-for-eq-of-primitives-less-strict/34240/10 for a discussion where people are advocating for making typing dunder methods less strict. The typing of most dunder methods is limited by inheritance from "object". Currently the correct annotation for equality is __eq__(self, other: object) -> bool. Not annotating the return type of NotImplemented is problematic but baked into Python ATM. Accepting any input is correct since evaluating an equality to False is a valid output, even if it happens from a double NotImplemented behind-the-scenes.

@nstarman nstarman Jan 24, 2024 •

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.

Having other be restricted only makes sense if the method errors or doesn't work on that input. __eq__ et al work, returning NotImplemented / False.

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 understand the logic, but it is not a useful one to humans or much in the spirit of what NotImplemented means - I'd say we don't annotate these until the discussion has concluded.

"""
Return `True` if ``other`` represents a physical type that is
consistent with the physical type of the `PhysicalType` instance.
Expand All @@ -334,14 +334,14 @@ def __eq__(self, other):
else:
return NotImplemented

def __ne__(self, other):
def __ne__(self, other: object) -> bool:
equality = self.__eq__(other)
return not equality if isinstance(equality, bool) else NotImplemented

def _name_string_as_ordered_set(self):
def _name_string_as_ordered_set(self) -> str:
return "{" + str(self._physical_type_list)[1:-1] + "}"

def __repr__(self):
def __repr__(self) -> str:
if len(self._physical_type) == 1:
names = "'" + self._physical_type_list[0] + "'"
else:
Expand All @@ -352,7 +352,7 @@ def __str__(self):
return "/".join(self._physical_type_list)

@staticmethod
def _dimensionally_compatible_unit(obj):
def _dimensionally_compatible_unit(obj: object) -> u.UnitBase | NotImplemented: # ???

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.

this is a good place for typing.overload.

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.

Same comment as for __eq__ - I think we'd want the type information to tell what gives success, not what gives failure, where for this function failure means getting NotImplemented.

Note that I really worry about "this is a good place for typing.overload - if we are adding more than annotations, suddenly files can become like this

    # Builtin types
    @overload
    def __new__(cls, dtype: type[builtins.bool], align: builtins.bool = ..., copy: builtins.bool = ..., metadata: dict[builtins.str, Any] = ...) -> dtype[np.bool]: ...
    @overload
    def __new__(cls, dtype: type[int], align: builtins.bool = ..., copy: builtins.bool = ..., metadata: dict[builtins.str, Any] = ...) -> dtype[int_]: ...
    @overload
    def __new__(cls, dtype: None | type[float], align: builtins.bool = ..., copy: builtins.bool = ..., metadata: dict[builtins.str, Any] = ...) -> dtype[float64]: ...
    @overload
    def __new__(cls, dtype: type[complex], align: builtins.bool = ..., copy: builtins.bool = ..., metadata: dict[builtins.str, Any] = ...) -> dtype[complex128]: ...
    @overload
    def __new__(cls, dtype: type[builtins.str], align: builtins.bool = ..., copy: builtins.bool = ..., metadata: dict[builtins.str, Any] = ...) -> dtype[str_]: ...
    @overload
    def __new__(cls, dtype: type[bytes], align: builtins.bool = ..., copy: builtins.bool = ..., metadata: dict[builtins.str, Any] = ...) -> dtype[bytes_]: ...

Can we pretty please agree that overload has no place in regular python code files?

"""
Return a unit that corresponds to the provided argument.

Expand All @@ -373,36 +373,36 @@ def _dimensionally_compatible_unit(obj):
else:
return NotImplemented

def _dimensional_analysis(self, other, operation):
def _dimensional_analysis(self, other: object, operation) -> Self | NotImplemented: # ???
other_unit = self._dimensionally_compatible_unit(other)
if other_unit is NotImplemented:
return NotImplemented
other_unit = _replace_temperatures_with_kelvin(other_unit)
new_unit = getattr(self._unit, operation)(other_unit)
return new_unit.physical_type

def __mul__(self, other):
def __mul__(self, other: object) -> Self | NotImplemented:

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.

The standard is actually to not put NotImplemented into the type hint of dunder methods.

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.

Bit of a flyby, as I was just looking how the typing "looks": these ones seem rather superfluous; are there not some standard defaults for dunder operations like these?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@mhvk — that is a great question that I do not know the answer to! I think that static type checkers like mypy still need type hints in order to check dunder methods, but I'm not sure.

When adding type hints to plasmapy.particles, I was able to use autotyping to automatically add type hints for dunder methods, which was pretty straightforward.

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.

See comment about __eq__ - it seems much more logical to me to put here things that allow __mul__ to succeed.

return self._dimensional_analysis(other, "__mul__")

def __rmul__(self, other):
def __rmul__(self, other: object) -> Self | NotImplemented:
return self.__mul__(other)

def __truediv__(self, other):
def __truediv__(self, other: object) -> Self | NotImplemented:
return self._dimensional_analysis(other, "__truediv__")

def __rtruediv__(self, other):
def __rtruediv__(self, other: object) -> Self | NotImplemented:
other = self._dimensionally_compatible_unit(other)
if other is NotImplemented:
return NotImplemented
return other.physical_type._dimensional_analysis(self, "__truediv__")

def __pow__(self, power):
def __pow__(self, power: object) -> Self | NotImplemented:
return (self._unit**power).physical_type

def __hash__(self):
return hash(self._physical_type_id)

def __len__(self):
def __len__(self) -> int:
return len(self._physical_type)

# We need to prevent operations like where a Unit instance left
Expand All @@ -413,7 +413,7 @@ def __len__(self):
__array__ = None


def def_physical_type(unit, name):
def def_physical_type(unit: u.UnitBase, name: str) -> None:
"""
Add a mapping between a unit and the corresponding physical type(s).

Expand Down Expand Up @@ -476,7 +476,7 @@ def def_physical_type(unit, name):
_attrname_physical_mapping[attr_name] = physical_type


def get_physical_type(obj):
def get_physical_type(obj: object) -> PhysicalType:

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.

Seems good to have QuantityLike and PhysicalTypeLike defined, use it here.

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.

👍 on using a type alias PhysicalTypeLike. This is again driven by use of type annotations as developer docs, not trying to please mypy. Whether PhysicalTypeLike tries to enumerate all the possibilities or if it is just an empty type alias makes no difference to me, but seeing that while developing code is very useful.

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.

Indeed, and I like that those can be stored in a types.py file, so one can fiddle with those definitions without having to change how the code itself looks.

"""
Return the physical type that corresponds to a unit (or another
physical type representation).
Expand Down Expand Up @@ -556,7 +556,7 @@ def get_physical_type(obj):


# For getting the physical types.
def __getattr__(name):
def __getattr__(name: str) -> PhysicalType:
"""Checks for physical types using lazy import.

This also allows user-defined physical types to be accessible from the
Expand Down Expand Up @@ -584,15 +584,15 @@ def __getattr__(name):
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


def __dir__():
def __dir__() -> list[str]:
"""Return contents directory (__all__ + all physical type names)."""
return list(set(__all__) | set(_attrname_physical_mapping.keys()))


# This generates a docstring addition for this module that describes all of the
# standard physical types defined here.
if __doc__ is not None:
doclines = [
doclines: list[str] = [

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.

Why does this need an annotation?

".. list-table:: Defined Physical Types",
" :header-rows: 1",
" :widths: 30 10 50",
Expand Down