Repository navigation
Allow controlling warnings when converting deprecated units to strings - #18586
Conversation
|
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.
|
|
👋 Thank you for your draft pull request! Do you know that you can use |
32ceac9 to
f6b3199
Compare
| If automatic replacement is not possible then the deprecated unit is used for | ||
| constructing the string and a warning is emitted. |
There was a problem hiding this comment.
It would be good to add a code example, but our public API does not directly expose any units suitable for this purpose and parsing a string would emit a different warning. In the unit tests I avoided that warning by going through private API, but in user documentation that is unacceptable.
There was a problem hiding this comment.
Not the prettiest, but in principle one could do,
from astropy.units import cds
cds.Crab.to_string(format="ogip")
There was a problem hiding this comment.
p.s. It is indeed a bit annoying that u.Unit("Crab", format="ogip", parse_strict="silent") actually is not silent...
There was a problem hiding this comment.
Using cds here would make the example too confusing.
| ---------- | ||
| unit : |Unit| | ||
| The unit to convert. | ||
| deprecations : {"warn", "silent", "raise", "convert"}, optional, keyword-only |
There was a problem hiding this comment.
It is best if these options do not differ needlessly from the parse_strict options the unit parsing methods have, and those are all strings.
There was a problem hiding this comment.
Then are the type annotations correct?
There was a problem hiding this comment.
The tests and documentation only use strings.
| if s in cls._deprecated_units and deprecations != DeprecatedUnitAction.SILENT: | ||
| alternative = ( | ||
| unit.represents if isinstance(unit := cls._units[s], Unit) else None | ||
| ) | ||
| msg = f"The unit {s!r} has been deprecated in the {cls.__name__} standard." | ||
| if alternative: | ||
| if deprecations == DeprecatedUnitAction.CONVERT: | ||
| return alternative | ||
| msg += f" Suggested: {cls.to_string(alternative)}." | ||
| warnings.warn(msg, UnitsWarning) | ||
|
|
||
| if deprecations == DeprecatedUnitAction.RAISE: | ||
| raise UnitsError(msg) | ||
| if deprecations == DeprecatedUnitAction.WARN: | ||
| warnings.warn(msg, UnitsWarning) | ||
| elif deprecations == DeprecatedUnitAction.CONVERT: | ||
| warnings.warn( | ||
| msg + " It cannot be automatically converted.", UnitsWarning | ||
| ) | ||
| else: | ||
| valid_options = ", ".join(map(repr, map(str, DeprecatedUnitAction))) | ||
| raise ValueError( | ||
| f"invalid deprecation handling option: {deprecations!r}. Valid " | ||
| f"options are {valid_options}." | ||
| ) |
There was a problem hiding this comment.
| if s in cls._deprecated_units and deprecations != DeprecatedUnitAction.SILENT: | |
| alternative = ( | |
| unit.represents if isinstance(unit := cls._units[s], Unit) else None | |
| ) | |
| msg = f"The unit {s!r} has been deprecated in the {cls.__name__} standard." | |
| if alternative: | |
| if deprecations == DeprecatedUnitAction.CONVERT: | |
| return alternative | |
| msg += f" Suggested: {cls.to_string(alternative)}." | |
| warnings.warn(msg, UnitsWarning) | |
| if deprecations == DeprecatedUnitAction.RAISE: | |
| raise UnitsError(msg) | |
| if deprecations == DeprecatedUnitAction.WARN: | |
| warnings.warn(msg, UnitsWarning) | |
| elif deprecations == DeprecatedUnitAction.CONVERT: | |
| warnings.warn( | |
| msg + " It cannot be automatically converted.", UnitsWarning | |
| ) | |
| else: | |
| valid_options = ", ".join(map(repr, map(str, DeprecatedUnitAction))) | |
| raise ValueError( | |
| f"invalid deprecation handling option: {deprecations!r}. Valid " | |
| f"options are {valid_options}." | |
| ) | |
| if s in cls._deprecated_units and deprecations != DeprecatedUnitAction.SILENT: | |
| if isinstance(unit := cls._units[s], Unit): | |
| if deprecations == DeprecatedUnitAction.CONVERT: | |
| return unit.represents | |
| msg = ( | |
| DEPRECATED_UNIT_MSG.format(s, cls.__name__) | |
| + f" Suggested: {cls.to_string(unit.represents)}." | |
| ) | |
| else: | |
| msg = DEPRECATED_UNIT_MSG.format(s, cls.__name__) | |
| match DeprecatedUnitAction(deprecations): | |
| case DeprecatedUnitAction.RAISE: | |
| raise UnitsError(msg) | |
| case DeprecatedUnitAction.WARN: | |
| warnings.warn(msg, UnitsWarning) | |
| case DeprecatedUnitAction.CONVERT: | |
| warnings.warn( | |
| msg + " It cannot be automatically converted.", UnitsWarning | |
| ) |
With the following defined elsewhere
DEPRECATED_UNIT_MSG: Final = "The unit {} has been deprecated in the {} standard."I'm not sure deprecations = DeprecatedUnitAction(deprecations) is necessary given the type annotation, but the docstring made me put it in.
There was a problem hiding this comment.
This is slightly more compact and avoids assigning to intermediate variables unless needed.
There was a problem hiding this comment.
The creation of msg in separate branches may be an over-optimization.
There was a problem hiding this comment.
First of all your suggestion would mean the deprecations value is checked whether or not there are any deprecated units, whereas with my code deprecations is ignored unless it is actually needed. That is a visible difference in code behavior.
For code readability it is best if things are defined close to where they are used unless doing so would cause code duplication. Unfortunately some of your suggestions move definitions away from usage while also increasing duplication. The claim that the suggested code is more compact is not true because shortening the function here by a few lines is achieved by adding even more lines elsewhere.
There was a problem hiding this comment.
Given that it's a str enum, can't the parse step be moved anywhere?
Edit: I just did. I moved it to address your first point.
And doesn't _missing_ move things closer to their usage? It's like __contains__ and performs a similar separation of concerns between determining if an action is valid and performing that action. And Enums naturally contain a _missing_ check, which the proposed code doesn't use when raising on an incompatible input. This suggestion customizes _missing_ and uses the Enum mechanism.
There was a problem hiding this comment.
I quite like the match here, as well as using the StrEnum (if we're using it at all) to generate the error message. I would not worry at all about performance in this path, where we're already dealing with a deprecated unit, so in an unlikely scenario.
I'd do CONVERT first, since then alternative is still close by.
mhvk
left a comment
There was a problem hiding this comment.
Thanks for doing this - I really like the overall idea.
My one doubt is about the enum -- I don't really see the benefit of it, especially because it is not used throughout (and I agree we cannot use it in user-facing parts). I would just stick with strings, which means fewer lines added too.
| If automatic replacement is not possible then the deprecated unit is used for | ||
| constructing the string and a warning is emitted. |
There was a problem hiding this comment.
Not the prettiest, but in principle one could do,
from astropy.units import cds
cds.Crab.to_string(format="ogip")
| If automatic replacement is not possible then the deprecated unit is used for | ||
| constructing the string and a warning is emitted. |
There was a problem hiding this comment.
p.s. It is indeed a bit annoying that u.Unit("Crab", format="ogip", parse_strict="silent") actually is not silent...
| if s in cls._deprecated_units and deprecations != DeprecatedUnitAction.SILENT: | ||
| alternative = ( | ||
| unit.represents if isinstance(unit := cls._units[s], Unit) else None | ||
| ) | ||
| msg = f"The unit {s!r} has been deprecated in the {cls.__name__} standard." | ||
| if alternative: | ||
| if deprecations == DeprecatedUnitAction.CONVERT: | ||
| return alternative | ||
| msg += f" Suggested: {cls.to_string(alternative)}." | ||
| warnings.warn(msg, UnitsWarning) | ||
|
|
||
| if deprecations == DeprecatedUnitAction.RAISE: | ||
| raise UnitsError(msg) | ||
| if deprecations == DeprecatedUnitAction.WARN: | ||
| warnings.warn(msg, UnitsWarning) | ||
| elif deprecations == DeprecatedUnitAction.CONVERT: | ||
| warnings.warn( | ||
| msg + " It cannot be automatically converted.", UnitsWarning | ||
| ) | ||
| else: | ||
| valid_options = ", ".join(map(repr, map(str, DeprecatedUnitAction))) | ||
| raise ValueError( | ||
| f"invalid deprecation handling option: {deprecations!r}. Valid " | ||
| f"options are {valid_options}." | ||
| ) |
There was a problem hiding this comment.
I quite like the match here, as well as using the StrEnum (if we're using it at all) to generate the error message. I would not worry at all about performance in this path, where we're already dealing with a deprecated unit, so in an unlikely scenario.
I'd do CONVERT first, since then alternative is still close by.
f6b3199 to
d821e67
Compare
The docstrings of |
| msg + " It cannot be automatically converted.", UnitsWarning | ||
| ) | ||
| case _: | ||
| assert_never(deprecations) |
mhvk
left a comment
There was a problem hiding this comment.
Thanks, I like the match. As I write inline, I think it is a place where clarity is more important than performance, so would prefer to keep it simple. But if you prefer not to, that's fine too, so approving now.
| def _validate_unit( | ||
| cls, s: str, deprecations: DeprecatedUnitAction = DeprecatedUnitAction.WARN | ||
| ) -> UnitBase: | ||
| if ( |
There was a problem hiding this comment.
I think the match is working out! But I'd still prefer to forego performance here and just start the match after the existing
if s in cls._deprecated_units:
msg = "..."
if (alternative := ...):
msg += ...
match DeprecatedUnitAction(deprecations):
case DeprecatedUnitAction.CONVERT:
if alternative:
return alternative
warnings.warn(msg + ...)
case ...
It keeps the code easy to follow, with all logic to the various deprecated unit possibilities in one place; performance really is not a worry here.
|
It looks like the demonstration of |
The `"ogip"` and `"vounit"` formats have deprecated units. Converting such units to strings has so far caused an unavoidable warning. A new parameter of the `UnitBase.to_string()` and unit formatter `to_string()` methods allows silencing that warning or raising an error instead. This is analogous to the `parse_strict` parameter of unit parsing methods. The `StrEnum` that controls the new behavior is defined in a new module so that it could be imported without having to worry about import loops.
The "vounit" and "ogip" formats have deprecated units. It is now
possible to instruct the unit formatters to try to replace deprecated
units when writing them to strings. If automatic replacement is not
possible the deprecated unit is used and a warning is emitted.
An alternative would have been to implement a new method in `UnitBase`
to replace deprecated units, which could be used like
`u.erg.convert_deprecated("vounit").to_string(format="vounit")`
but then the user would have to specify the unit format twice and such
repetition should be avoided.
d821e67 to
4088611
Compare
mhvk
left a comment
There was a problem hiding this comment.
Great, thanks for making that last change.
|
|
||
| If automatic replacement is not possible then the deprecated unit is used for | ||
| constructing the string and a warning is emitted. | ||
| As a last resort, it is possible to silence the warnings:: |
There was a problem hiding this comment.
Late to this party, but calling this as the last resort is a bit overly judgy -- erg and Angstroms will likely stay with us for a while, no need for this snarky comment in the docs.
Description
The
"vounit"and"ogip"unit formats have deprecated units and converting them to strings raises warnings that currently cannot be avoided. The first commit introduces a newdeprecationsparameter to theUnitBaseand unit formatterto_string()methods which allows silencing those warnings or replacing them with errors. This is analogous to theparse_strictparameter the unit parsing methods have. The default is to warn, which is howastropybehaves right now, but it is also sensible default behavior.The second commit introduces a fourth
"convert"option, which tellsastropyto automatically replace deprecated units. If that is not possible then the deprecated unit is written out and a warning is emitted. In practice, with"vounit"automatic replacement is always possible and with"ogip"it is never possible.The four options are implemented as members of a
StrEnum, but the enum is not exposed to the users yet, nor is it explicitly used in the tests. This implementation demonstrates that we can start using enums internally, so that when we do make them public the amount of code changes will be smaller. Furthermore, by usingStrEnumwe can update the public API without breaking backwards compatibility in practice. The enum is implemented in a separate module so that it (and, in the future, other enums) could be imported without having to worry about import loops.I briefly considered adding a new method to
UnitBaseinstead of adding a fourth option for handling deprecated units, but using that design would look something likei.e. the user would have to specify the unit format twice. Such repetition should be avoided, so the
deprecations="convert"option is better.Closes #18304