-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Allow controlling warnings when converting deprecated units to strings #18586
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
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 |
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, similarly to how `Unit(..., parse_strict=...)` allows handling warnings when parsing strings to units.
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.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A nice cleanup.
@@ -145,6 +157,10 @@ def to_string( | |||
---------- | |||
unit : |Unit| | |||
The unit to convert. | |||
deprecations : {"warn", "silent", "raise", "convert"}, optional, keyword-only |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are strings permitted?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Then are the type annotations correct?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is slightly more compact and avoids assigning to intermediate variables unless needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The creation of msg
in separate branches may be an over-optimization.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
WARN = auto() | ||
RAISE = auto() | ||
CONVERT = auto() | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@classmethod | |
def _missing_(cls, value): | |
valid_options = ", ".join(repr(member.value) for member in cls) | |
raise ValueError( | |
f"Invalid deprecation handling option: {value!r}. Valid " | |
f"options are {valid_options}." | |
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Possibly even better.
@classmethod
@lru_cache(maxsize=1)
def _get_valid_options(cls) -> str:
"""Get a comma-separated string of valid options (cached)."""
return ", ".join(repr(member.value) for member in cls)
@classmethod
def _missing_(cls, value):
raise ValueError(
f"Invalid deprecation handling option: {value!r}. Valid "
f"options are {cls._get_valid_options()}."
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The _missing_()
method might be better if there were multiple locations that could raise the ValueError
because in that case the method would reduce code duplication. But there is (currently) only one place this error could come from, so duplication cannot be reduced.
Your suggestion of caching the list of valid options is not worthwhile because it cannot possibly improve performance unless the user specifies an invalid option at least twice.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
cls, | ||
unit: UnitBase, | ||
fraction: bool | Literal["inline", "multiline"] = "inline", | ||
deprecations: DeprecatedUnitAction = DeprecatedUnitAction.WARN, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is it not a str
here, as in all the other format classes?
def _decompose_to_known_units( | ||
cls, | ||
unit: CompositeUnit | NamedUnit, | ||
deprecations: DeprecatedUnitAction = DeprecatedUnitAction.WARN, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here, it is OK, since it is a private method.
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 newdeprecations
parameter to theUnitBase
and unit formatterto_string()
methods which allows silencing those warnings or replacing them with errors. This is analogous to theparse_strict
parameter the unit parsing methods have. The default is to warn, which is howastropy
behaves right now, but it is also sensible default behavior.The second commit introduces a fourth
"convert"
option, which tellsastropy
to 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.I briefly considered adding a new method to
UnitBase
instead 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