Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bin/generate-python-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ function generateMarks(defs) {
// and any non-identifier keys (e.g. a stray `$schema`).
const props = Object.keys(propsObj)
.filter(p => p !== 'mark' && p !== 'data' && /^[A-Za-z][A-Za-z0-9]*$/.test(p));
const params = props.map(p => ` ${ident(p)}: ChannelValue = UNSET,`);
const params = props.map(p => ` ${ident(p)}: ChannelValue | UNSET = UNSET,`);
const doc = docline(description, `The ${mark} mark.`);
out.push(
`def ${fn}(`,
Expand Down Expand Up @@ -195,7 +195,7 @@ function generateEncodings(defs) {
const [min, max] = argRange(prop);
const args = (TRANSFORM_ARGS[key] ?? ['col']).slice(0, max);
while (args.length < max) args.push(`arg${args.length + 1}`);
const params = args.map((a, i) => `${a}: TransformArg${i < min ? '' : ' = UNSET'}`);
const params = args.map((a, i) => `${a}: TransformArg${i < min ? '' : ' | UNSET = UNSET'}`);
const body = max === 0
? ` return {${JSON.stringify(key)}: None, **options}`
: ` return _transform(${JSON.stringify(key)}, (${args.join(', ')}${args.length === 1 ? ',' : ''}), options)`;
Expand Down
70 changes: 70 additions & 0 deletions packages/vgplot/vgplot-python/tests/test_unset_sentinel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from __future__ import annotations

import copy
from typing import TYPE_CHECKING, Any, Union, get_type_hints

if TYPE_CHECKING:
# NOTE: Don't move this into a runtime import (yet)
from vgplot._types import UNSET


def _import_unset() -> UNSET:
# NOTE: A regular import would add:
# - `vgplot._types` to `sys.modules`
# - `UNSET` to `globals`
# And we can't use `importlib.reload`, since that would create a new object
from vgplot._types import UNSET

return UNSET


def test_unset_identity() -> None:
unset_1 = _import_unset()
unset_2 = _import_unset()
assert unset_1 is unset_2


def test_unset_repr() -> None:
assert repr(_import_unset()) == "UNSET"


def test_unset_pickle() -> None:
import pickle

unset = _import_unset()
assert pickle.loads(pickle.dumps(unset)) is unset # noqa: S301


def test_unset_type_expression_union() -> None:
# Adapted from https://github.com/python/typing_extensions/blob/83400e979b8e3b0b647f9a6a57f0275230e5f19f/src/test_typing_extensions.py#L9694-L9701
from vgplot._types import UNSET

def func1(a: int | UNSET = UNSET) -> None: ...
def func2(a: UNSET | int = UNSET) -> None: ...

assert get_type_hints(func1, localns=locals())["a"] == Union[int, UNSET] # noqa: UP007
assert get_type_hints(func2, localns=locals())["a"] == Union[UNSET, int] # noqa: UP007


def test_unset_copy_identity() -> None:
# Adapted from https://github.com/python/typing_extensions/blob/83400e979b8e3b0b647f9a6a57f0275230e5f19f/src/test_typing_extensions.py#L9711-L9713
unset = _import_unset()
assert unset is copy.copy(unset)
assert unset is copy.deepcopy(unset)


def test_unset_union_identity() -> None:
unset = _import_unset()
assert (unset | unset) is unset


if TYPE_CHECKING:
from typing_extensions import assert_type

def typing_unset(
a: UNSET, b: str | UNSET, c: Any | UNSET, d: int | None | UNSET = UNSET
) -> None:
assert_type(a, UNSET)
assert_type(b, str | UNSET)
assert_type(c, Any | UNSET)
assert_type(d, int | None | UNSET)
103 changes: 103 additions & 0 deletions packages/vgplot/vgplot-python/vgplot/_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Backwards compatibility for features not available at our [requires-python][1].

Import from here to avoid introducing runtime dependency on [typing_extensions][2].

[1]: https://packaging.python.org/en/latest/specifications/pyproject-toml/#requires-python
[2]: https://github.com/python/typing_extensions

## sentinel
[`sentinel`][3] was introduced in `3.15` (see [PEP 661][4]).

We can remove the backport after [3.15 end-of-life][5].

[3]: https://docs.python.org/3.15/library/functions.html#sentinel
[4]: https://peps.python.org/pep-0661/
[5]: https://peps.python.org/pep-0790/#lifespan
"""

from __future__ import annotations

import sys
import typing

# ruff: noqa: A002
from contextlib import suppress
from importlib.util import find_spec
from typing import TYPE_CHECKING, Any, ClassVar


def _sentinel_backport_pre_typing_extensions_4_16() -> Any:
Comment thread
dangotbanned marked this conversation as resolved.
class _sentinel_backport:
"""Create a unique sentinel object.

*name* should be the name of the variable to which the return value shall be assigned.
"""

def __init__(self, name: str, /, *, repr: str | None = None) -> None:
self.__name__: str = name
self._repr: str = repr if repr is not None else name
# TODO @dangotbanned: Figure out why they didn't use the `"__main__"` default here?
module: str | None = None
if hasattr(sys, "_getframemodulename"):
module = sys._getframemodulename(1) or "__main__"
elif hasattr(sys, "_getframe"):
with suppress(ValueError):
module = sys._getframe(1).f_globals.get("__name__", "__main__")

# For pickling as a singleton
self.__module__ = module # pyright: ignore[reportAttributeAccessIssue] # ty: ignore[invalid-assignment]

__init_subclass__: ClassVar[None] = None

def __repr__(self) -> str:
return self._repr

if sys.version_info < (3, 11):
# The presence of this method convinces typing._type_check that Sentinels are types.
def __call__(self, *args: Any, **kwargs: Any) -> Any:
msg = f"{type(self).__name__!r} object is not callable"
raise TypeError(msg)

def __or__(self, other: Any) -> Any:
return typing.Union[self, other] # noqa: UP007

def __ror__(self, other: Any) -> Any:
return typing.Union[other, self] # noqa: UP007

def __reduce__(self) -> str:
return self.__name__

return _sentinel_backport


def _sentinel_backport_pre_py_3_15() -> Any:
"""Return a [PEP 661]-compatible [`sentinel`](https://docs.python.org/3.15/library/functions.html#sentinel) factory.

[PEP 661]: https://peps.python.org/pep-0661/

## Notes
- Does not depend on `typing_extensions`, but will use it if a suitable version is available
- Fallback is adapted from [`typing_extensions==4.16.0`](https://github.com/python/typing_extensions/blob/f29cd28d8ed7642cafb1d18daf5aa41be6a5c0aa/src/typing_extensions.py#L176-L271)
"""
if find_spec("typing_extensions"):
import typing_extensions

# NOTE: In the same release the name changed, this guy landed https://github.com/python/typing_extensions/pull/617
# `4.14-4.15` is fine for typing, but the runtime changes are too big to rely on
if hasattr(typing_extensions, "sentinel"):
return getattr(typing_extensions, "sentinel", typing_extensions.Sentinel)

return _sentinel_backport_pre_typing_extensions_4_16()


if TYPE_CHECKING:
# Was renamed in https://github.com/python/typing_extensions/releases/tag/4.16.0
from typing_extensions import Sentinel as sentinel # noqa: N813
else: # noqa: PLR5501
if sys.version_info >= (3, 15):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@domoritz

Can we remove this in the future? Would be good to add a note saying when we can remove this?

So this part would be detected by Ruff via outdated-version-block (UP036)

Anything that uses sys.version_info works the same and will be flagged in-step with

requires-python = ">=3.10"

But on this part:

when we can remove this?

Without dependencies?
October 2031

With "typing_extensions>=4.16"?
Any time 😄

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I could add some of that here I suppose 😅

image

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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'm generally a fan of updating version requirements when it helps us clean up stuff. We don't need to support the oldest Python versions so feel free to update version requirements.

@dangotbanned dangotbanned Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

If we were to update requirements, adding this would be my preference as it is a single file dependency and very common:

dependencies = [
  "typing-extensions>=4.16 ; python_full_version < '3.15'",
]

Depending on the latest python version can simplify maintainence, but it will hurt adoption of Mosaic.
It would mean that no downstream packages can offer backwards compatibility, if they have vgplot as a required dependency

from builtins import sentinel
else:
sentinel = _sentinel_backport_pre_py_3_15()


__all__ = ("sentinel",)
12 changes: 6 additions & 6 deletions packages/vgplot/vgplot-python/vgplot/_generated/encodings.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def column(col: TransformArg, **options: Any) -> dict[str, Any]:
return _transform("column", (col,), options)


def count(col: TransformArg = UNSET, **options: Any) -> dict[str, Any]:
def count(col: TransformArg | UNSET = UNSET, **options: Any) -> dict[str, Any]:
"""Compute the count of records in an aggregation group."""
return _transform("count", (col,), options)

Expand Down Expand Up @@ -101,8 +101,8 @@ def geojson(col: TransformArg, **options: Any) -> dict[str, Any]:

def lag(
col: TransformArg,
offset: TransformArg = UNSET,
default: TransformArg = UNSET,
offset: TransformArg | UNSET = UNSET,
default: TransformArg | UNSET = UNSET,
**options: Any,
) -> dict[str, Any]:
"""Compute lagging values in a column."""
Expand All @@ -121,8 +121,8 @@ def last_value(col: TransformArg, **options: Any) -> dict[str, Any]:

def lead(
col: TransformArg,
offset: TransformArg = UNSET,
default: TransformArg = UNSET,
offset: TransformArg | UNSET = UNSET,
default: TransformArg | UNSET = UNSET,
**options: Any,
) -> dict[str, Any]:
"""Compute leading values in a column."""
Expand Down Expand Up @@ -150,7 +150,7 @@ def mode(col: TransformArg, **options: Any) -> dict[str, Any]:


def nth_value(
col: TransformArg, offset: TransformArg = UNSET, **options: Any
col: TransformArg, offset: TransformArg | UNSET = UNSET, **options: Any
) -> dict[str, Any]:
"""Get the nth value of the given column in the current window frame, counting from one."""
return _transform("nth_value", (col, offset), options)
Expand Down
Loading