Repository: annotated-types/annotated-types

Issue:
Feature (discussion): Units
Hi all.  I know this library has focused mostly on validation constraints for pydantic, and I'll also copy the minimalist mission mentioned in the readme:

> we expect that staying simple and covering *only* the most common use-cases will give users and maintainers the best experience we can. If you'd like more constraints for your types - follow our lead, by defining them and documenting them downstream!

I do, find the concept of a small no-dependency library that can be used to annotate things super useful as a way for multiple libraries to communicate information without tight coupling of types.  I find myself specifically thinking about units these days (for example, in the context of device driver specifications, or other places where it's critical to indicate a quantity of something rather than a scalar).

I did a quick search through the issues here for "units" or "pint" or "astropy", and I also searched through the issues of those libraries for discussions of "annotated-types", but have not found a discussion so far (other than the obvious discussions of adding typing within the library itself).  So, I just wanted to see if this is something that you here, or @hgrecco at pint, have thought of including?  I'm thinking of something perhaps as simple as:

```python
@dataclass(frozen=True)
class Quantity(BaseMetadata):
    """Quantity(unit='some_unit') implies that the value is a physical quantity with the specified unit.

    It can be used with any numeric type (`numbers.Number`).
    Interpretation of the unit string is left to the discretion of the consumer.
    """

    unit: str

# and in usage
@dataclass
class Parameters:
    width: Annotated[float, Quantity(unit='m')] = 1
    angle: Annotated[float, Quantity(unit='rad') = 0
    temperature: Annotated[float, Quantity(unit='celsius')] = 100
    weight: Annotated[float, Quantity(unit='kg') = 42
```

**`annotated_types` itself would, of course, make no attempt to parse the unit string... that would be left to the end user, using libraries like `pint`.**

Curious to hear your thoughts, @adriangb, @samuelcolvin, @hgrecco, et al.

Discussion:
@adriangb: I was quite concerned at first when I saw units... But if we are not parsing or defining units in any way then I think this is great. Libraries can work out what the units mean to them and it will be perfectly fine for most commonly used units (if someone wants to define `kg` as something else...).@tlambert03: > I was quite concerned at first when I saw units... 

:joy: sorry to scare you.  I will bold that line above :)@tlambert03: i made a PR if it helps for the sake of discussion, but definitely expect the discussion to take some time.@Zac-HD: I'd certainly be happy to see a standard way to include units in type annotations!  Todo list:

1. Support multiple units libraries: at least `astropy.units` and `pint`; ideally also compatbile with `unyt`.
2. [`astropy.units` already has docs for use with `Annotated`](https://docs.astropy.org/en/stable/units/type_hints.html), and `pint` has [this issue](https://github.com/hgrecco/pint/issues/1770) and [these requirements](https://github.com/hgrecco/pint/issues/1166#issuecomment-751725459).  What would this proposal add?
3. `Quantity` is definitely the wrong name for this annotation - a quantity is the product of a unit with the numeric magnitude.  Let's call it `Unit` instead, if we go ahead despite (2).  Users can define their own `Quantity[magnitude, Union[unit, dimension]` types.
4. In many cases, users might want to specify the [physical type (e.g. "length" or "duration" or "energy density")](https://docs.astropy.org/en/stable/units/physical_types.html) rather than the specific unit, and let the library handle converting feet to light-nanoseconds or whatever.  Should we support expressing this?  If so, how?

Overall, I don't think we should proceed without talking to users or perhaps maintainers of these libraries; at latest I could do that at PyCon US and/or SciPy.@tlambert03: Thanks for your input @Zac-HD.  Some thoughts in response to your points

> 1. Support multiple units libraries: at least astropy.units and pint; ideally also compatbile with unyt.

This proposal abstains from explicit support of any specific library.  Instead, those libraries themselves (or downstream type annotation consumers depending on those libraries) would worry about using the metadata.  This is similar to how annotating with `age: Annotated[int, annotated_types.Gt(18)]` doesn't actually *do* anything to ensure that `age` is greater than 18.  That is left to pydantic or other validation libraries.  I think the farthest this should go in terms of "supporting" those third party libraries is just to ensure that we're not constraining anything unduly.

> 2. [astropy.units already has docs for use with Annotated](https://docs.astropy.org/en/stable/units/type_hints.html), and pint has https://github.com/hgrecco/pint/issues/1770 and https://github.com/hgrecco/pint/issues/1166#issuecomment-751725459. What would this proposal add?

This is what I meant in my original post by "other than the obvious discussions of adding typing within the library itself".  Naturally, those libraries will have ways to annotate something as being one of their own types.  Both of those issues you linked discuss ways in which those types can support `__class_getitem__` to parametrize the type hint when using the type itself.  (e.g. `astropy.units.Quantity. __class_getitem__(param)` returns `Annotated[astropy.units.Quantity, param]`, and pint is discussing similar strategies  to enable `Quantity['length']`)

 This proposal adds a _library-agnostic_ way to indicate the unit of a quantity.

To elaborate, the following code block tells me that the function is going to *actually give me* an instance of `astropy.units.Quantity`, and it gives me a hint about the specific dimensionality of that type

```python
import astropy.units as u
from astropy.units import Quantity

def func() -> Quantity[u.m]: ...
```

Whereas this block tells me that `func` will give me a float, and *should I care to validate or cast or convert that with some third party library like pint or astropy*, the units are `'m'`

```python
from annotated_types import Quantity  # or Unit, or whatever we decide to name it
from typing import Annotated

def func() -> Annotated[float, Quantity(unit='m')]: ...
```

This is the main point. If I'm creating a specification, and I want you (the consumer) to know that the number I'm going to give you is going to be in some unit, but I don't care whether you use `pint`, or `astropy`, or `unyt` ... the most common practice is probably to put the unit in the function or attribute name `get_length_meters() -> float`.  Here, we add more information without imposing a specific library other than this very minimal `annotated-types`.

> 3. `Quantity` is definitely the wrong name for this annotation - a quantity is the product of a unit with the numeric magnitude. Let's call it `Unit` instead

I don't have terribly strong feelings on that one, but I'm not sure it's a no-brainer.  I am indeed aware of the meaning of quantity as the product of scalar magnitude and a unit :) ... I chose "Quantity(unit=...)" rather than `Unit(unit=...)` partially because I was thinking of how it *annotates* the associated scalar value, but also because i thought that `Quantity` might be slightly more extensible in case an additional field were to be added in the future.  That said, I have no opposition to using `Unit(unit=...)` if that's what people want.

> 4. In many cases, users might want to specify the [physical type (e.g. "length" or "duration" or "energy density")](https://docs.astropy.org/en/stable/units/physical_types.html) rather than the specific unit. Should we support expressing this? If so, how?

This is actually one of the use cases I was imagining that made me pick the name `Quantity` rather than `Unit`.  Because I can imagine `dimension` being another field:  `Annotated[float, Quantity(dimension='mass')]`.  I stopped short of adding that, because generally speaking, if i give you `unit='gram'`, most units libraries will be able to infer the dimension, and if I *only* give you the dimension, it's unclear how useful that is in terms of casting and or validation... but we could add it if people want it@tlambert03: > Overall, I don't think we should proceed without talking to users or perhaps maintainers of these libraries; at latest I could do that at PyCon US and/or SciPy.

yes of course.  That's why I pinged @hgrecco.  Indeed, feel free to ping anyone else you think would like to weigh in.  Ideally, this could happen in this discussion or other public forum (in addition to any direct meetings you have at PyCon/SciPy :))@Zac-HD: Ahhh, I was thinking about parameter-type annotations; return-type annotations are a much more natural fit for your proposal.

Thinking again about `Quantity`/`Unit`/`Dimension`, I'm still inclined to go with `Unit` - partly because a `Dimension`-only annotation doesn't make sense for the return type (you must have the unit!), and for a parameter type it only really makes sense if you can convert the quantity to a known unit (in which case you can use `astropy.units`, `pint`, or whatever).

I'll be very happy if we can resolve everything in this or other threads on the internet before conference time too!@adriangb: > Ahhh, I was thinking about parameter-type annotations; return-type annotations are a much more natural fit for your proposal.

I may have missed something, but why would parameter annotations be a bad fit? I don't have a specific use case in mind but it doesn't _not_ make sense to me.@tlambert03: yeah, I don't see it as all that dissimilar to parameter annotations on a pydantic model.  what you do with it might be slightly different from a classic pydantic validation, but ultimately, it's just there to say "please give me a number, and I expect the units of that number to be whatever"@adriangb: I don't think Pydantic would ever attempt to do that sort of validation _but_ users or a library could write something that does for specific types.@tlambert03: yeah, that's what I meant by "what you do with it might be different".  I also wouldn't expect pydantic to use this.  I'm just saying it's not dissimilar in spirit.@hgrecco: Thanks for the ping @tlambert03 

I do think it is a great idea to build a standard for this. Before typing, I developed several packages that were using decorators to annotate, validate dimensions/units, and convert units. A few common operations are:
1. A function that only accepts quantities with certain dimensionality (only validation is needed)
2. A function that only accepts quantities with certain units (only validation is needed)
3. A function that only accept quantities with certain dimensionality and the body of the function requires the magnitude of that quantity expressed in specific units.
4. Wrap functions that return floats but it actually a quantity in certain units. 

(I can give concrete example for each of them if you want to understand the use cases)

Python typing changes the game, and I am still not sure how far we can take it. For sure, having a cross package annotation that can then be consumed at runtime would be great. A tool to check a few things statically that could be built upon would be great as well.

A few general comments about what has been proposed.

As mentioned, a quantity consists of a magnitude and a unit. So I agree that `Annotated[float, Quantity(unit='meter')]` is not a good idea as the first argument of annotated is already the magnitude. `Annotated[float, Unit('meter')]`  makes more sense to me.

In relation to `only annotation doesn't make sense for the return type (you must have the unit!)`. This is not entirely correct. You might want to annotate. Let me give you a stupid example:

```python
def calculate_average_velocity(length, time):
    return length / time
```

The annotation for the input is length dimension, for the output is time dimension and for the return is velocity dimension. And this annotation would be useful when you then want to combine the output of this function with another one:

```python
def is_too_fast(speed):
     return speed > max_speed
```

Having dimensions as annotated types would be useful here (And in other not stupid cases!)


@tlambert03: thanks @hgrecco!  I changed  PR #65 to use  `Unit` .  just to double check, do we like `Unit(...)` over `Units(...)`?

> Having dimensions as annotated types would be useful here (And in other not stupid cases!)

Do you think a second field `dimension` should be added to the `Unit` dataclass?  specifically, what are your thoughts on the comment above:

> ... I can imagine dimension being another field:  `Annotated[float, Unit(dimension='mass')]`. I stopped short of adding that, because generally speaking, if i give you unit='gram', most units libraries will be able to infer the dimension, and if I only give you the dimension, it's unclear how useful that is in terms of casting and or validation... but we could add it if people want it

and if so, would you have it be:

```python
@dataclass
class Unit(BaseMetadata):
    unit: str = ''
    dimension: str = ''
```

... with a `__post_init__` to ensure that one of the two is provided?    or would you have it be 

```python
@dataclass
class Unit(BaseMetadata):
    unit: str
```

And encourage usage as `Unit('[time]')` for a unit-free dimension.  (Neither seem particularly stellar to me, just curious whether your statement about "having dimensions" implied something in addition to what has already been proposed)@hgrecco: I like `Unit('[time]')` and `Unit('seconds')` more than `Unit(dimension='time')` and `Unit(unit='second')`. Not only it is shorter, but also by design disallows statements that are either redundant (e.g. `Unit(unit='seconds', dimension='time')`) or invalid  (e.g. `Unit(unit='second', dimension='length')`). 

People will need to *learn* that square brackets means dimensionality. I think it is a small price to pay (and we have use it in pint since 0.1 with no complaints or confusions)@tlambert03: yep, I agree!  k, this is the state of things currently in #65 @tlambert03: @Zac-HD, what would you want to see next in order to remove your request for changes on #65?
not trying to hurry you, just tracking progress and things we should do next.  Looks like at least @adriangb and @hgrecco are roughly on board with the current proposal.  Is there anyone specific you'd like to ping here?@Zac-HD: I think the current implementation looks fine; if we can add some more documentation (including in the README) + describe the use of square brackets for dimensions + refer users to pint and astropy for further discussion, I think we'll probably be ready to merge.@Zac-HD: Issues closed by merging #65 🙂 

Solution patch:
diff --git a/README.md b/README.md
index 894898a..5c54d7b 100644
--- a/README.md
+++ b/README.md
@@ -126,6 +126,48 @@ allow a specific timezone, though we note that this is often a symptom of fragil
 * `Timezone` accepts [`tzinfo`](https://docs.python.org/3/library/datetime.html#tzinfo-objects) objects instead of
   `timezone`, extending compatibility to [`zoneinfo`](https://docs.python.org/3/library/zoneinfo.html) and third party libraries.
 
+### Unit
+
+`Unit(unit: str)` expresses that the annotated numeric value is the magnitude of
+a quantity with the specified unit. For example, `Annotated[float, Unit("m/s")]`
+would be a float representing a velocity in meters per second.
+
+Please note that `annotated_types` itself makes no attempt to parse or validate
+the unit string in any way. That is left entirely to downstream libraries,
+such as [`pint`](https://pint.readthedocs.io) or
+[`astropy.units`](https://docs.astropy.org/en/stable/units/).
+
+An example of how a library might use this metadata:
+
+```python
+from annotated_types import Unit
+from typing import Annotated, TypeVar, Callable, Any, get_origin, get_args
+
+# given a type annotated with a unit:
+Meters = Annotated[float, Unit("m")]
+
+
+# you can cast the annotation to a specific unit type with any
+# callable that accepts a string and returns the desired type
+T = TypeVar("T")
+def cast_unit(tp: Any, unit_cls: Callable[[str], T]) -> T | None:
+    if get_origin(tp) is Annotated:
+        for arg in get_args(tp):
+            if isinstance(arg, Unit):
+                return unit_cls(arg.unit)
+    return None
+
+
+# using `pint`
+import pint
+pint_unit = cast_unit(Meters, pint.Unit)
+
+
+# using `astropy.units`
+import astropy.units as u
+astropy_unit = cast_unit(Meters, u.Unit)
+```
+
 ### Predicate
 
 `Predicate(func: Callable)` expresses that `func(value)` is truthy for valid values.
diff --git a/annotated_types/__init__.py b/annotated_types/__init__.py
index 3647a5a..dc23bed 100644
--- a/annotated_types/__init__.py
+++ b/annotated_types/__init__.py
@@ -296,6 +296,29 @@ class Timezone(BaseMetadata):
     tz: Union[str, tzinfo, EllipsisType, None]
 
 
+@dataclass(frozen=True, **SLOTS)
+class Unit(BaseMetadata):
+    """Indicates that the value is a physical quantity with the specified unit.
+
+    It is intended for usage with numeric types, where the value represents the
+    magnitude of the quantity. For example, ``distance: Annotated[float, Unit('m')]``
+    or ``speed: Annotated[float, Unit('m/s')]``.
+
+    Interpretation of the unit string is left to the discretion of the consumer.
+    It is suggested to follow conventions established by python libraries that work
+    with physical quantities, such as
+
+    - ``pint`` : <https://pint.readthedocs.io/en/stable/>
+    - ``astropy.units``: <https://docs.astropy.org/en/stable/units/>
+
+    For indicating a quantity with a certain dimensionality but without a specific unit
+    it is recommended to use square brackets, e.g. `Annotated[float, Unit('[time]')]`.
+    Note, however, ``annotated_types`` itself makes no use of the unit string.
+    """
+
+    unit: str
+
+
 @dataclass(frozen=True, **SLOTS)
 class Predicate(BaseMetadata):
     """``Predicate(func: Callable)`` implies `func(value)` is truthy for valid values.


Test patch:
diff --git a/annotated_types/test_cases.py b/annotated_types/test_cases.py
index 2b5b253..d9164d6 100644
--- a/annotated_types/test_cases.py
+++ b/annotated_types/test_cases.py
@@ -117,6 +117,10 @@ def cases() -> Iterable[Case]:
         [datetime(2000, 1, 1), datetime(2000, 1, 1, tzinfo=timezone(timedelta(hours=6)))],
     )
 
+    # Quantity
+
+    yield Case(Annotated[float, at.Unit(unit='m')], (5, 4.2), ('5m', '4.2m'))
+
     # predicate types
 
     yield Case(at.LowerCase[str], ['abc', 'foobar'], ['', 'A', 'Boom'])
diff --git a/tests/test_main.py b/tests/test_main.py
index 4384dbd..ce899a1 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -77,6 +77,11 @@ def check_timezone(constraint: Constraint, val: Any) -> bool:
     return val.tzinfo is not None
 
 
+def check_quantity(constraint: Constraint, val: Any) -> bool:
+    assert isinstance(constraint, annotated_types.Unit)
+    return isinstance(val, (float, int))
+
+
 Validator = Callable[[Constraint, Any], bool]
 
 
@@ -90,6 +95,7 @@ VALIDATORS: Dict[Type[Constraint], Validator] = {
     annotated_types.MinLen: check_min_len,
     annotated_types.MaxLen: check_max_len,
     annotated_types.Timezone: check_timezone,
+    annotated_types.Unit: check_quantity,
 }