Skip to content

Raise UnitError when passing unit=None in .to() for objects with unit, avoiding silent no-op - #3945

Merged
jokasimr merged 5 commits into
mainfrom
to-allow-none
Aug 19, 2026
Merged

jokasimr merged 5 commits into
mainfrom
to-allow-none

Conversation

@jokasimr

Copy link
Copy Markdown
Contributor

None is a valid .unit value and we might want to do things like:

def do_something(a, b):
    return a + b.to(unit=a.unit)

But if a.unit is None then .to() will assume the method was called without any argument, and it will raise.

To fix that, make the default unit argument of .to() be a sentinel so that we can clearly distinguish the case when no argument was provided.

@SimonHeybrock

SimonHeybrock commented Aug 14, 2026 •

Copy link
Copy Markdown
Member

Please see discussion in #2468. This is not an oversight to "fix", but was a deliberate choice, since this is semantically different.

I think I was misled by both the title and lack of a test: Your intention is that sc.scalar(1,'m').to(unit=None) will raise, right?

@SimonHeybrock SimonHeybrock left a comment •

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 looked closer (and corrected my earlier comment): this does not enable conversion to/from None -- that is still rejected in C++ (Unit conversion to / from None is not permitted). It only removes the silent no-op for unit=None, which is exactly what #2468 asks for as the alternative. So this enforces that decision rather than overturning it. Agreed on the intent.

One more: to(unit=None, dtype=...) previously meant "dtype only" and now raises. Minor, but it is an API break and might warrant a release note.

Comment on lines +23 to +27
class _NoUnitProvided:
"""Sentinel type indicating that no unit argument was passed."""


_no_unit_provided = _NoUnitProvided()

@SimonHeybrock SimonHeybrock Aug 14, 2026 •

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 sentinel is defined twice, here and in data_group.py, presumably because operations imports data_group and not the other way round. Please define it once, in a module both can import -- two independent sentinel objects for the same concept is a trap waiting for the first is comparison across module boundaries. Whether that single definition also needs to be publicly exported is the question in my comment on the signature below.

var: Variable,
*,
unit: _cpp.Unit | str | None = None,
unit: _cpp.Unit | str | None | _NoUnitProvided = _no_unit_provided,

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 puts a private type into the public signature and the rendered docs. It also hands the original problem straight to every wrapper of to: downstream code that forwards unit now cannot spell "not provided" without reaching for a private name. DataGroup.to below already has to work around it, and every ess-* wrapper will end up copying that. If we take the sentinel, I think it has to be public and documented.

@jokasimr jokasimr Aug 14, 2026 •

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.

This is not a real problem. We don't define a global concept for "no argument provided" that is re-used across the project.

This is just a local type to replace None as the default argument. I think sharing that across modules is messy and confusing.

Every ess-* module will not have to copy that because it's not a public interface. to() is not meant to be called without arguments.

Comment on lines +487 to +491
if isinstance(unit, _NoUnitProvided) and dtype is None:
raise ValueError("Must provide dtype or unit or both")

if isinstance(unit, _NoUnitProvided):
return var.astype(dtype, copy=copy)

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.

isinstance here, is not _no_unit_provided in data_group.py. Use the identity check in both -- that is the point of a sentinel instance, and it keeps the class an implementation detail.

Comment on lines +513 to +516
kwargs = {'dtype': dtype, 'copy': copy}
if unit is not _no_unit_provided:
kwargs['unit'] = unit
return self.apply(operator.methodcaller('to', **kwargs))

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 kwargs dance is the cost of the sentinel leaking into the signature, made visible. Fine here, but it is the pattern every caller that forwards unit will now have to reproduce -- see my comment on the signature in operations.py.

Comment thread tests/variable_test.py
Comment on lines +735 to +738
def test_to_with_unit_none() -> None:
data = sc.array(dims=["x"], values=[1, 2, 3], dtype="int64", unit=None)

assert sc.identical(data.to(unit=None), data)

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.

All the new tests cover None -> None, which was already a no-op before this PR (silently, for the wrong reason). The behaviour that actually changes is untested: sc.scalar(1, unit='m').to(unit=None) goes from a silent no-op to UnitError. That is the whole point of the change and the only thing that can regress -- please add it here and for DataArray/DataGroup.

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.

This is a misunderstanding. None -> None was not a no-op before this PR, it was a ValueError that is the issue that the PR addresses.

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 example should have been:

var.to(unit=None, dtype='int64')

which used to be a silent no-op (aside from the dtype conversion). But the new behavior is that this raises, right?

@jokasimr jokasimr Aug 17, 2026 •

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.

I don't see why you think there's something wrong with the example, there seems to be a misunderstanding, let's chat in person.

var.to(unit=None, dtype='int64')

if var has dtype int64 then the above used to be a no-op, but now it will raise and say you can't convert the unit to None.

So this change is breaking.

var.to(dtype='int64')

Will do the same thing before or after this change regardless of the unit of var.

@SimonHeybrock SimonHeybrock Aug 17, 2026 •

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.

I don't see why you think there's something wrong with the example

You pointed out (correctly) "None -> None was not a no-op before this PR, it was a ValueError that is the issue that the PR addresses.", I looked at my example, "sc.scalar(1, unit='m').to(unit=None) goes from a silent no-op to UnitError." and realized that it indeed does not make sense as written there, i.e., was "wrong" for the behavior-change test that my original comment requested — the behavior change only shows in the presence of a dtype` arg, which my modified code example exercises.

if var has dtype int64 then the above used to be a no-op, but now it will raise and say you can't convert the unit to None.

Yes, that is what I am saying. Where are the tests for that?

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.

What is var here? I can't answer without knowing.

@SimonHeybrock SimonHeybrock Aug 17, 2026 •

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.

var = sc.scalar(1.0, unit='m')
with pytest.raises(sc.UnitError):
    sc.to_unit(var, unit=None, dtype='float32')

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.

That test raises TypeError: to_unit() got an unexpected keyword argument 'dtype'

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.

Typo, I meant to, not to_unit.

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.

The test has been added 👍

@jokasimr

Copy link
Copy Markdown
Contributor Author

I think I was misled by both the title and lack of a test: Your intention is that sc.scalar(1,'m').to(unit=None) will raise, right?

Yes that should raise with a UnitError. Before this change it raised with a ValueError.

@jokasimr
jokasimr requested a review from SimonHeybrock August 14, 2026 10:11
@SimonHeybrock SimonHeybrock changed the title fix: allow unit=None in .to() Raise UnitError when passing unit=None in .to() Aug 17, 2026
@jokasimr

Copy link
Copy Markdown
Contributor Author

I'm not sure the new title is more accurate than before 🤔

We don't raise unconditionally if the unit argument is None, only if the unit of the variable is not None.

@SimonHeybrock SimonHeybrock changed the title Raise UnitError when passing unit=None in .to() Raise UnitError when passing unit=None in .to() for objects with unit, avoiding silent no-op Aug 17, 2026
Comment thread tests/data_group_test.py Outdated
@jokasimr
jokasimr merged commit 98fc09a into main Aug 19, 2026
4 checks passed
@jokasimr
jokasimr deleted the to-allow-none branch August 19, 2026 07:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants