Runtime Typing

Open in Colab

Typing is at the very core of Symbolic Programming as a way of constraining symbolic attributes, it also maximizes the productivity when developing new components with PyGlove. pg.typing provides a runtime typing system for such purposes.

!pip install pyglove
import pyglove as pg

1. Get started with pg.typing

@pg.members([
  ('x', pg.typing.Int(min_value=1, max_value=10).noneable()),
  ('y', pg.typing.Union([
      pg.typing.Int(), pg.typing.Enum('a', ['a', 'b', 'c'])
  ], default=1))
])
class Foo(pg.Object):
  pass

import contextlib

@contextlib.contextmanager
def expect_error(error_type):
  has_error = False
  try:
    yield
  except error_type as e:
    print(f'ERROR: {e!r}')
    has_error = True
  finally:
    assert has_error
# Use the defaults: x=0, y=1
f = Foo()
print(f)
Foo(
  x = None,
  y = 1
)
# Raises as `x` is out of range ([1, 10]).
with expect_error(ValueError):
  _ = Foo(x=11)
ERROR: ValueError('Value 11 is out of range (min=1, max=10). (path=x)')
# Raises as `y` is not an integer nor among ['a', 'b', 'c'].
with expect_error(ValueError):
  _ = Foo(y='d')
ERROR: ValueError("Value 'd' is not in candidate list ['a', 'b', 'c']. (path=y)")

2. Applicability of pg.typing

pg.typing is available for specifying the constraints for all symbolic types.

2.1 pg.List and pg.Dict

l = pg.List(value_spec=pg.typing.List(pg.typing.Int()))

# Okay
l.append(1)

# Not okay. 1.0 is not an integer.
with expect_error(TypeError):
  l.append(1.0)
ERROR: TypeError("Expect <class 'int'> but encountered <class 'float'>: 1.0. (path=[1])")
d = pg.Dict(x='foo', value_spec=pg.typing.Dict([
  ('x', pg.typing.Any()),
  ('y', pg.typing.Int().noneable())
]))

print(d)

# Okay. 1 is an integer.
d.y = 1

# Not okay, 'z' is not allowed as a key in the Dict.
with expect_error(KeyError):
  d.z = True
{
  x = 'foo',
  y = None
}
ERROR: KeyError("Key 'z' is not allowed for <class 'pyglove.core.symbolic.Dict'>. (path=)")
# Define a dict of str to integers.
d = pg.Dict(value_spec=pg.typing.Dict([
  (pg.typing.StrKey(), pg.typing.Int()),
]))

# Okay. string keys with integer values.
d.x = 1
d.y = 2

# Not okay. The value is not an integer.
with expect_error(TypeError):
  d.z = 'foo'
ERROR: TypeError("Expect <class 'int'> but encountered <class 'str'>: foo. (path=z)")
# Constraint on key names
d = pg.Dict(value_spec=pg.typing.Dict([
  (pg.typing.StrKey('.*_file'), pg.typing.Str()),
]))

# Okay. String keys ended with '_file'.
d.data1_file = 'abc'
d.data2_file = 'cde'

# Not okay. Key does not end with '_file'.
with expect_error(KeyError):
  d.data1 = 'efg'
ERROR: KeyError("Key 'data1' is not allowed for <class 'pyglove.core.symbolic.Dict'>. (path=)")

2.2 pg.Object subclasses

# pg.typing.Dict makes it convenient to define hierarchical 
# symbolic attributes.
@pg.members([
  ('x', pg.typing.Int()),
  ('y', pg.typing.Dict([
      ('z', pg.typing.Float(default=1.0))
  ]))
])
class Foo(pg.Object):
  pass

f = Foo(x=1, y=pg.Dict(z=2.0))
print(f)
Foo(
  x = 1,
  y = {
    z = 2.0
  }
)

2.3. Functors via pg.functor.

# Type definition in functor can inherit default values
# from the function signature.
@pg.functor([
  ('x', pg.typing.Float()),
  ('y', pg.typing.Float())
])
def foo(x=1.0, y=2.0):
  pass

f = foo()
print(f)
foo(
  x = 1.0,
  y = 2.0
)

2.4 Class wrappers via pg.symbolize

class _Foo:
  def __init__(self, x, y):
    pass

# Type constraint can be passed as the second argument
# of `pg.symbolize`.
Foo = pg.symbolize(_Foo, [
  ('x', pg.typing.Int()),
  ('y', pg.typing.Int()),
])

# Not okay. 1.0 is not an integer.
with expect_error(TypeError):
  _ = Foo(1.0, 2)
ERROR: TypeError("Expect <class 'int'> but encountered <class 'float'>: 1.0. (path=x)")

3. Value specifications

PyGlove supports value specifications for almost all common types in Python.

A value specification is an object of a pg.typing.ValueSpec subclass. All ValueSpec subclasses have common traits:

  • default argument: Set the default value of current field.

  • .noneable(): Marks the field can be None, and use None as the default value if the default is not specified.

3.1 Any type

print(pg.typing.Any())
Any()

3.2 Numbers and string

# Boolean
print(pg.typing.Bool(default=True))

# Integer: with optional min/max constraint.
print(pg.typing.Int(min_value=0, max_value=10, default=1))

# Float: with optional min/max constraint.
print(pg.typing.Float(min_value=0.0, max_value=1.0).noneable())

# Str: with optinal constraint with regular expression.
print(pg.typing.Str(regex='.*file'))
Bool(default=True)
Int(default=1, min=0, max=10)
Float(default=None, min=0.0, max=1.0, noneable=True)
Str(regex='.*file')

3.3 Lists

# Lists of non-negative integers.
print(pg.typing.List(pg.typing.Int(min_value=0)))

# Lists of fixed size (2).
print(pg.typing.List(pg.typing.Int(), size=2))

# Lists of min/max size.
print(pg.typing.List(pg.typing.Any(), min_size=1, max_size=10))
List(Int(min=0))
List(Int(), min_size=2, max_size=2)
List(Any(), min_size=1, max_size=10)

3.4 Dicts

# Free-form dict.
print(pg.typing.Dict())

# Dict with fixed schema.
print(pg.typing.Dict([
  ('x', pg.typing.Int(), 'Optional docstr for x'),
  ('y', pg.typing.Float().noneable())
]))

# Dict with any string keys.
print(pg.typing.Dict([
  (pg.typing.StrKey(), pg.typing.Int())
]))

# Dict with string keys with a regex pattern.
print(pg.typing.Dict([
  (pg.typing.StrKey('.*file'), pg.typing.Int())
]))
Dict()
Dict({
  # Optional docstr for x
  x = Int(),

  y = Float(default=None, noneable=True)
})
Dict({
  StrKey() = Int()
})
Dict({
  StrKey(regex='.*file') = Int()
})

3.5. Object of a class

class Foo:
  pass

# Object of Foo.
print(pg.typing.Object(Foo))
Object(Foo)

3.6. Type

# Subclasses of Foo.
print(pg.typing.Type(Foo))
Type(<class '__main__.Foo'>)

3.7. Callable

# Any callable.
print(pg.typing.Callable())

# Callable with 2 positional integer arguments.
print(pg.typing.Callable([
  pg.typing.Int(), pg.typing.Int()
]))

# Callable with 1 positional argument, 1 keyword argument
# and requires the return value to be a boolean.
print(pg.typing.Callable(
    [pg.typing.Int()], 
    kw=[('x', pg.typing.Float())], 
    returns=pg.typing.Bool()))
Callable()
Callable(args=[Int(), Int()])
Callable(args=[Int()], kw=[('x', Float())], returns=Bool())

3.8 Union

class Foo:
  pass

class Bar:
  pass

# An union of int, float, Foo or Bar.
print(pg.typing.Union([
  pg.typing.Int(),
  pg.typing.Float(),
  pg.typing.Object(Foo),
  pg.typing.Object(Bar)
]))

# An union of a nested union and a callable.
print(pg.typing.Union([
  pg.typing.Union([pg.typing.Int(), pg.typing.Float()])                       ,
  pg.typing.Callable(returns=pg.typing.Int())
]))
Union([
    Int(),
    Float(),
    Object(Foo),
    Object(Bar)
  ])
Union([
    Union([Int(), Float()]),
    Callable(returns=Int())
  ])

4. Automatic type conversions

In programming language like C++, types can define automatic conversion rules, e.g:

class MyType {
  public:
     operator int() { return this->value }
};

a = 1 + MyType(1)

There is no such concept of implicit type conversion in Python. However, the need of implicit conversion is necessary. For example, for a pg.typing.Int(), it should also accept a numpy.integer.

This can be done with pg.typing.register_converter.

import numpy as np

# Not okay. np.int32 is not int.
with expect_error(TypeError):
  pg.typing.Int().apply(np.int32(0))

# Register automatic conversion
pg.typing.register_converter(np.int32, int, int)

# Okay. Conversion is effective.
pg.typing.Int().apply(np.int32(0))
ERROR: TypeError("Expect <class 'int'> but encountered <class 'numpy.int32'>: 0. (path=)")
0