Skip to content

[tunables] Make Tunable a generic instead of using typing.Any #323

Description

@bhall-ctre

Currently, all the methods on Tunable operate on typing.Any, which sacrifices type checking on the three APIs (get, set, and mutate). If Tunable was instead a Generic, type checking would properly warn of invalid operations.

Below is an example of what the adjusted API would look like.

# Current API, renamed to _Tunable
class _Tunable:
    """
    A Python-backed tunable value.
    
    Tunables can be published with publish() or added and published in one step
    with one of the add() functions.
    """
    def __init__(self, value: typing.Any, *, getter: collections.abc.Callable[[], typing.Any] | None = None, setter: collections.abc.Callable[[typing.Any], None] | None = None, on_tune: collections.abc.Callable[[typing.Any], None] | None = None, robust: bool = False, mutable: bool = True, value_type: type[typing.Any] | None = None, element_type: type[typing.Any] | None = None, properties: dict[str, typing.Any] | None = None, type_string: str = '', always_get: bool = False) -> None:
        """
        Creates an unpublished tunable. The tunable type is inferred from value
        unless an explicit type selector is provided. Bytes, bytearray, and
        memoryview values infer a raw tunable. Wrap other buffer exporters in
        memoryview to explicitly use their raw bytes in C order.
        
        :param value:        initial value
        :param getter:       optional function that supplies the current local value
        :param setter:       optional function that receives values set locally or remotely
        :param on_tune:      callback that receives the value after a remote update
        :param robust:       whether to separately echo a remotely set value
        :param mutable:      whether remote updates may change the tunable
        :param value_type:   explicit value type, or None to infer it from value
        :param element_type: explicit sequence element type, or None to infer it
        :param properties:   additional tunable properties
        :param type_string:  custom tunable type string
        :param always_get:   whether to call getter on every backend update
        """
    def get(self) -> typing.Any:
        """
        Gets the current local value. If a getter was provided, this calls it;
        otherwise, it returns the stored value.
        
        :returns: current value
        """
    def mutate(self) -> typing.Any:
        """
        Gets the current individual WPIStruct object for in-place mutation and
        marks the tunable as changed. The object is returned without copying it.
        If a getter was provided, it supplies the current object.
        
        Raises TypeError for all other types, including struct arrays. Use get(),
        edit the value, and call set() for those types instead.
        Call mutate() again before further edits after a backend update.
        
        :returns: struct object to mutate
        """
    def set(self, value: typing.Any) -> None:
        """
        Sets the local value and marks the tunable as changed. If a setter was
        provided, it is called with value. If a getter was also provided, its
        result becomes the stored value.
        
        :param value: new value
        """

# New generic API
T = TypeVar("T")
class Tunable(Generic[T]):
    """
    A Python-backed tunable value.
    
    Tunables can be published with publish() or added and published in one step
    with one of the add() functions.
    """
    def __init__(self, value: T, *, getter: collections.abc.Callable[[], T] | None = None, setter: collections.abc.Callable[[T], None] | None = None, on_tune: collections.abc.Callable[[T], None] | None = None, robust: bool = False, mutable: bool = True, value_type: type[T] | None = None, element_type: type[typing.Any] | None = None, properties: dict[str, typing.Any] | None = None, type_string: str = '', always_get: bool = False) -> None:
        """
        Creates an unpublished tunable. The tunable type is inferred from value
        unless an explicit type selector is provided. Bytes, bytearray, and
        memoryview values infer a raw tunable. Wrap other buffer exporters in
        memoryview to explicitly use their raw bytes in C order.
        
        :param value:        initial value
        :param getter:       optional function that supplies the current local value
        :param setter:       optional function that receives values set locally or remotely
        :param on_tune:      callback that receives the value after a remote update
        :param robust:       whether to separately echo a remotely set value
        :param mutable:      whether remote updates may change the tunable
        :param value_type:   explicit value type, or None to infer it from value
        :param element_type: explicit sequence element type, or None to infer it
        :param properties:   additional tunable properties
        :param type_string:  custom tunable type string
        :param always_get:   whether to call getter on every backend update
        """
        self._tunable = _Tunable(
            value,
            getter=getter,
            setter=setter,
            on_tune=on_tune,
            robust=robust,
            mutable=mutable,
            value_type=value_type,
            element_type=element_type,
            properties=properties,
            type_string=type_string,
            always_get=always_get,
        )

    def get(self) -> T:
        """
        Gets the current local value. If a getter was provided, this calls it;
        otherwise, it returns the stored value.
        
        :returns: current value
        """
        return self._tunable.get()

    def mutate(self) -> T:
        """
        Gets the current individual WPIStruct object for in-place mutation and
        marks the tunable as changed. The object is returned without copying it.
        If a getter was provided, it supplies the current object.
        
        Raises TypeError for all other types, including struct arrays. Use get(),
        edit the value, and call set() for those types instead.
        Call mutate() again before further edits after a backend update.
        
        :returns: struct object to mutate
        """
        return self._tunable.mutate()

    def set(self, value: T) -> None:
        """
        Sets the local value and marks the tunable as changed. If a setter was
        provided, it is called with value. If a getter was also provided, its
        result becomes the stored value.
        
        :param value: new value
        """
        return self._tunable.set(value)


# Example usages
setpoint = Tunable[float](0.0)  # setpoint is a float
s: float = setpoint.get()       # Good, types match

setpoint.set("string")          # Type checking error: Argument 1 to "set" of "Tunable"
                                # has incompatible type "str"; expected "float"

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions