Skip to content
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

feat: add undefined type #966

Closed
wants to merge 16 commits into from

Conversation

wusteven815
Copy link
Contributor

@wusteven815 wusteven815 marked this pull request as ready for review November 4, 2024 14:22
@wusteven815 wusteven815 requested review from a team and jnumainville and removed request for a team November 4, 2024 14:22
Copy link
Collaborator

@jnumainville jnumainville left a comment

Choose a reason for hiding this comment

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

Generally looks good. Just to verify, there is currently no spot where we want to allow None differentiated from UNDEFINED? I didn't see any and it's fine if we don't want to allow it anywhere but I want to make sure.

@mofojed mofojed self-requested a review November 6, 2024 15:41
@@ -613,3 +613,15 @@ class DatabarConfig(TypedDict):
"""
Opacity of the databar fill.
"""


class Undefined:
Copy link
Member

Choose a reason for hiding this comment

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

I want us to be very careful when defining this type... @jnumainville perhaps you can provide some guidance as well.
Some test cases I would add specifically for testing the Undefined type:

  1. Undefined == None should return True (similar to undefined == null in JS)
  2. Undefined is None should return False (similar to undefined === null in JS)
  3. String representation should print Undefined
  4. Boolean representation should be False
  5. Name of the object used should be Undefined (matching None)
  6. The underlying class should be private (no need to expose Undefined and UNDEFINED, kind of confusing)

Right now with this solution, I think you just have 2 and 3 passing. A suggested solution:

class _Undefined: # Using `_` to mark as a private class, don't expose
    def __repr__(self):
        return None
    
    def __str__(self):
        return "Undefined"
    
    def __eq__(self, other):
        """
        Ensure if we use ==, we match objects of type None
        """
        return other is type(Undefined) or other == None


Undefined = _Undefined()

Testing that out:

print('Undefined == None: ', Undefined == None)
print('None == Undefined: ', None == Undefined)
print('Undefined is None: ', Undefined is None)
print('None is Undefined: ', None is Undefined)
print('str(Undefined): ', str(Undefined))
print('bool(Undefined): ', bool(Undefined))

That results with:

Undefined == None:  True
None == Undefined:  True
Undefined is None:  False
None is Undefined:  False
str(Undefined):  Undefined
bool(Undefined):  True

Write up unit tests to that effect as well.
@jnumainville or @mattrunyon , call out if I'm possibly missing anything here or any pitfalls you might see.

Copy link
Collaborator

Choose a reason for hiding this comment

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

We need to audit anywhere we're using is None probably because that is the preferred way in Python (since classes can implement an arbitrary equality check). So if we are changing defaults to Undefined (which I think makes sense so users can just use None in place of null) we need to make sure logic written around is None doesn't break and == None is what we want instead

Copy link
Collaborator

Choose a reason for hiding this comment

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

I also just checked that json.dumps({ "key": None }) results in { "key": null } in the JSON string

Copy link
Member

Choose a reason for hiding this comment

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

Hmm I don't know if we could extend NodeEncoder to dump undefined when it encounters Undefined... maybe there's a way to skip the property at that point?

Copy link
Collaborator

@mattrunyon mattrunyon Nov 7, 2024

Choose a reason for hiding this comment

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

We'd want to/have to skip it (or embed it specially if we really needed it). undefined isn't valid JSON

I don't think we should embed undefined. If we're already embedding/differentiating null, then we say no prop = undefined

We might need to modify our call to serialize elements so it drops undefined. If it were used in a nested config then it might not get dropped by default and cause a serialization error. If the encoder default doesn't let us drop keys (doesn't look like we can), then we would need to have some special string we know to replace on the client

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For the __eq__ expression, other is type(Undefined) is always returning false for the examples. If we do Undefined == Undefined, the other == None part will recurse and call __eq__ for Undefined == None.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think you need isinstance(other, Undefined). type doesn't check anything from what I can tell and is requires pointing to the same object in memory. So a is type(a) will never be true because type(a) will create a new object every time it's called

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've added the following magic methods:

  • __bool__ - so it's False
  • __copy__ and __deepcopy__ - prevents a new instance from being created and breaking the is operator (e.g., used by dataclasses.asdict)

plugins/ui/src/deephaven/ui/_internal/utils.py Outdated Show resolved Hide resolved


def range_slider(
start_name: str | None = None,
end_name: str | None = None,
start_name: str | Undefined = UNDEFINED,
Copy link
Member

Choose a reason for hiding this comment

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

I think with my other suggestion, this would be: start_name: str | Literal[Undefined] = Undefined.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Looks like you can't get the type from a variable currently in Python. python/typing#769

Copy link
Contributor Author

Choose a reason for hiding this comment

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

IDEs/Pyright doesn't seem to like when Literal is used for values that are not primitive or enum. I've opted to type alias the class as UndefinedType (like NoneType, IDE will show the variable as a type instead of class). Unfortunately, UndefinedType() is still a valid way to create an instance. We could make it more difficult to create a new instance by doing:

_DISABLE_UNDEFINED_CONSTRUCTOR = False


class _Undefined:
    def __init__(self) -> None:
        if _DISABLE_UNDEFINED_CONSTRUCTOR:
            raise NotImplementedError()

    ...


Undefined = _Undefined()
UndefinedType: TypeAlias = _Undefined
_DISABLE_UNDEFINED_CONSTRUCTOR = True

plugins/ui/src/deephaven/ui/components/combo_box.py Outdated Show resolved Hide resolved
@@ -481,7 +496,7 @@ def _get_first_set_key(props: dict[str, Any], sequence: Sequence[str]) -> str |
The first non-None prop, or None if all props are None.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
The first non-None prop, or None if all props are None.
The first non-nullish prop, or None if all props are None.

and converter != to_j_local_date
):
props[granularity_key] = "SECOND"

# now that the converter is set, we can convert simple props to strings
# no.w that the converter is set, we can convert simple props to strings
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
# no.w that the converter is set, we can convert simple props to strings
# now that the converter is set, we can convert simple props to strings

@bmingles
Copy link
Contributor

@wusteven815 Is there a basic example script to run to test this works?

Copy link
Contributor

@bmingles bmingles left a comment

Choose a reason for hiding this comment

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

Left a few comments. Otherwise code changes look good to me.

@mattrunyon
Copy link
Collaborator

mattrunyon commented Nov 14, 2024

I'm wondering if this should be the default or if we should keep the defaults of None and only use undefined where it's actually needed. In the vast majority of components, treating None as undefined (i.e., dropping it from props) is perfectly fine. Distinguishing between the 2 seems to be the exception, not the rule.

Should we instead have a flag in the component decorator or constructor that says to differentiate null and undefined for rendering? I'm also thinking about future element components where a user implements their own components and must use undefined to prevent sending a bunch of nulls.

The other option would be to make the sentinel represent null instead. Although in the exception case, we want the user to just provide None for null probably, not the sentinel.

Comment on lines +129 to +132
{"foo": "bar", "biz": None, "baz": 0},
)
self.assertDictEqual(
remove_empty_keys({"foo": "bar", "biz": Undefined, "baz": 0}),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should add tests for comparing Undefined and None. All the cases Bender mentioned in an earlier comment would make good test cases

Copy link
Member

@mofojed mofojed left a comment

Choose a reason for hiding this comment

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

@mattrunyon 's point is a good one - with these changes, we're having to make a lot of changes to accommodate Undefined, which is annoying when we add new features, and as a new dev using the library can be weird.
Thinking we should be able to specify in the component itself just which props we need to differentiate between null and undefined, and just have special cases for those.
Not 100% sure the best way to handle that. Most components just call component_element, but that already has *args and **kwargs filled in... maybe we have a special prop __nullable_props that we can pass in that gets read and removed when converting the props...?
E.g. Changing the signature of component_element to:

def component_element(name: str, /, *children: Any, _nullable_props: list[str], **props: Any) -> BaseElement:

Then a component can specify which props need to differentiate None vs. Undefined, e.g. for Calendar:

return component_element("Calendar", _nullable_props=['min_value'], **props)

@@ -163,7 +178,7 @@ def remove_empty_keys(dict: dict[str, Any]) -> dict[str, Any]:
Returns:
The dict with keys removed.
"""
return {k: v for k, v in dict.items() if v is not None}
return {k: v for k, v in dict.items() if v is not Undefined}
Copy link
Member

Choose a reason for hiding this comment

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

In theory after this change, this should be the only place that really needs to check for Undefined ... we should be removing it at this point so it shouldn't need to be known at the ElementMessageStream level or anything.

@wusteven815
Copy link
Contributor Author

Continued by #1026, which simplifies the amount of code needed to be refactored and implemented review suggestions.

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.

Python - Differentiate null from undefined
5 participants