Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

eerovaher
Copy link
Member

@eerovaher eerovaher commented Sep 2, 2025

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 new deprecations parameter to the UnitBase and unit formatter to_string() methods which allows silencing those warnings or replacing them with errors. This is analogous to the parse_strict parameter the unit parsing methods have. The default is to warn, which is how astropy behaves right now, but it is also sensible default behavior.

The second commit introduces a fourth "convert" option, which tells astropy 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 like

u.erg.replace_deprecated("vounit").to_string(format="vounit")

i.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

  • 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.2.0 milestone Sep 2, 2025
@github-actions github-actions bot added the Docs label Sep 2, 2025
Copy link
Contributor

github-actions bot commented Sep 2, 2025

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
Contributor

github-actions bot commented Sep 2, 2025

👋 Thank you for your draft pull request! Do you know that you can use [ci skip] or [skip ci] in your commit messages to skip running continuous integration tests until you are ready?

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.
@eerovaher eerovaher force-pushed the writing-deprecated-units branch from 32ceac9 to f6b3199 Compare September 11, 2025 15:02
Comment on lines +143 to +144
If automatic replacement is not possible then the deprecated unit is used for
constructing the string and a warning is emitted.
Copy link
Member Author

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.

Copy link
Contributor

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")

Copy link
Contributor

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

@eerovaher eerovaher marked this pull request as ready for review September 11, 2025 15:29
@eerovaher eerovaher requested review from mhvk and nstarman September 11, 2025 15:29
Copy link
Member

@nstarman nstarman left a 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
Copy link
Member

Choose a reason for hiding this comment

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

Are strings permitted?

Copy link
Member Author

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.

Copy link
Member

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?

Copy link
Member Author

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.

Comment on lines +278 to +301
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}."
)
Copy link
Member

@nstarman nstarman Sep 11, 2025

Choose a reason for hiding this comment

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

Suggested change
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.

Copy link
Member

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.

Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member

@nstarman nstarman Sep 12, 2025

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.

Copy link
Contributor

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()

Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
@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}."
)

Copy link
Member

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()}."
        )

Copy link
Member Author

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.

Copy link
Contributor

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

Comment on lines +143 to +144
If automatic replacement is not possible then the deprecated unit is used for
constructing the string and a warning is emitted.
Copy link
Contributor

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")

Comment on lines +143 to +144
If automatic replacement is not possible then the deprecated unit is used for
constructing the string and a warning is emitted.
Copy link
Contributor

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

Comment on lines +278 to +301
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}."
)
Copy link
Contributor

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,
Copy link
Contributor

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,
Copy link
Contributor

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Allow silencing/erroring on outputting deprecated units to string
3 participants