diff --git a/ibis/common/patterns.py b/ibis/common/patterns.py index 7f230ff0b48d..0f7b502d5be2 100644 --- a/ibis/common/patterns.py +++ b/ibis/common/patterns.py @@ -322,7 +322,7 @@ def __eq__(self, other): ... @abstractmethod - def make(self, context: dict): + def build(self, context: dict): """Construct a new object from the context. Parameters @@ -337,36 +337,6 @@ def make(self, context: dict): """ -def builder(obj): - """Convert an object to a builder. - - It encapsulates: - - callable objects into a `Factory` builder - - non-callable objects into a `Just` builder - - Parameters - ---------- - obj - The object to convert to a builder. - - Returns - ------- - The builder instance. - """ - # TODO(kszucs): the replacer object must be handled differently from patterns - # basically a replacer is just a lazy way to construct objects from the context - # we should have a separate base class for replacers like Variable, Function, - # Just, Apply and Call. Something like Replacer with a specific method e.g. - # apply() could work - if isinstance(obj, Builder): - return obj - elif callable(obj): - # not function but something else - return Factory(obj) - else: - return Just(obj) - - class Variable(Slotted, Builder): """Retrieve a value from the context. @@ -382,7 +352,7 @@ class Variable(Slotted, Builder): def __init__(self, name): super().__init__(name=name) - def make(self, context): + def build(self, context): return context[self] def __getattr__(self, name): @@ -404,11 +374,17 @@ class Just(Slotted, Builder): __slots__ = ("value",) value: AnyType + @classmethod + def __create__(cls, value): + if isinstance(value, Just): + return value + return super().__create__(value) + def __init__(self, value): assert not isinstance(value, (Pattern, Builder)) super().__init__(value=value) - def make(self, context): + def build(self, context): return self.value @@ -434,7 +410,7 @@ def __init__(self, func): assert callable(func) super().__init__(func=func) - def make(self, context): + def build(self, context): value = context[_] return self.func(value, context) @@ -465,9 +441,9 @@ def __init__(self, func, *args, **kwargs): kwargs = frozendict({k: builder(v) for k, v in kwargs.items()}) super().__init__(func=func, args=args, kwargs=kwargs) - def make(self, context): - args = tuple(arg.make(context) for arg in self.args) - kwargs = {k: v.make(context) for k, v in self.kwargs.items()} + def build(self, context): + args = tuple(arg.build(context) for arg in self.args) + kwargs = {k: v.build(context) for k, v in self.kwargs.items()} return self.func(*args, **kwargs) def __call__(self, *args, **kwargs): @@ -494,7 +470,7 @@ def namespace(cls, module) -> Namespace: >>> pattern = c.Negate(x) >>> pattern Call(func=, args=(Variable(name='x'),), kwargs=FrozenDict({})) - >>> pattern.make({x: 5}) + >>> pattern.build({x: 5}) """ return Namespace(cls, module) @@ -591,7 +567,16 @@ def match(self, value, context): # use the `_` reserved variable to record the value being replaced # in the context, so that it can be used in the replacer pattern context[_] = value - return self.builder.make(context) + return self.builder.build(context) + + +def replace(matcher): + """More convenient syntax for replacing a value with the output of a function.""" + + def decorator(replacer): + return Replace(matcher, replacer) + + return decorator class Check(Slotted, Pattern): @@ -1175,6 +1160,31 @@ def match(self, value, context): return value +class Between(Slotted, Pattern): + """Match a value between two bounds. + + Parameters + ---------- + lower + The lower bound. + upper + The upper bound. + """ + + __slots__ = ("lower", "upper") + lower: float + upper: float + + def __init__(self, lower: float = -math.inf, upper: float = math.inf): + super().__init__(lower=lower, upper=upper) + + def match(self, value, context): + if self.lower <= value <= self.upper: + return value + else: + return NoMatch + + class Contains(Slotted, Pattern): """Pattern that matches if a value contains a given value. @@ -1247,7 +1257,8 @@ class SequenceOf(Slotted, Pattern): item: Pattern type: type - def __new__( + @classmethod + def __create__( cls, item, type: type = tuple, @@ -1264,8 +1275,7 @@ def __new__( return GenericSequenceOf( item, type=type, exactly=exactly, at_least=at_least, at_most=at_most ) - else: - return super().__new__(cls) + return super().__create__(item, type=type) def __init__(self, item, type=tuple): super().__init__(item=pattern(item), type=type) @@ -1311,7 +1321,8 @@ class GenericSequenceOf(Slotted, Pattern): type: Pattern length: Length - def __new__( + @classmethod + def __create__( cls, item: Pattern, type: type = tuple, @@ -1327,7 +1338,7 @@ def __new__( ): return SequenceOf(item, type=type) else: - return super().__new__(cls) + return super().__create__(item, type, exactly, at_least, at_most) def __init__( self, @@ -1372,11 +1383,11 @@ class TupleOf(Slotted, Pattern): __slots__ = ("fields",) fields: tuple[Pattern, ...] - def __new__(cls, fields): - if isinstance(fields, tuple): - return super().__new__(cls) - else: + @classmethod + def __create__(cls, fields): + if not isinstance(fields, tuple): return SequenceOf(fields, tuple) + return super().__create__(fields) def __init__(self, fields): fields = tuple(map(pattern, fields)) @@ -1490,11 +1501,11 @@ class Object(Slotted, Pattern): args: tuple[Pattern, ...] kwargs: FrozenDict[str, Pattern] - def __new__(cls, type, *args, **kwargs): + @classmethod + def __create__(cls, type, *args, **kwargs): if not args and not kwargs: return InstanceOf(type) - else: - return super().__new__(cls) + return super().__create__(type, *args, **kwargs) def __init__(self, type, *args, **kwargs): type = pattern(type) @@ -1704,29 +1715,47 @@ def match(self, value, context): return dict(zip(keys, values)) -class Between(Slotted, Pattern): - """Match a value between two bounds. +class Topmost(Slotted, Pattern): + """Traverse the value tree topmost first and match the first value that matches.""" - Parameters - ---------- - lower - The lower bound. - upper - The upper bound. - """ + __slots__ = ("pattern", "filter") + pattern: Pattern + filter: AnyType - __slots__ = ("lower", "upper") - lower: float - upper: float + def __init__(self, searcher, filter=None): + super().__init__(pattern=pattern(searcher), filter=filter) - def __init__(self, lower: float = -math.inf, upper: float = math.inf): - super().__init__(lower=lower, upper=upper) + def match(self, value, context): + result = self.pattern.match(value, context) + if result is not NoMatch: + return result + + for child in value.__children__(self.filter): + result = self.match(child, context) + if result is not NoMatch: + return result + + return NoMatch + + +class Innermost(Slotted, Pattern): + # matches items in the innermost layer first, but all matches belong to the same layer + """Traverse the value tree innermost first and match the first value that matches.""" + + __slots__ = ("pattern", "filter") + pattern: Pattern + filter: AnyType + + def __init__(self, searcher, filter=None): + super().__init__(pattern=pattern(searcher), filter=filter) def match(self, value, context): - if self.lower <= value <= self.upper: - return value - else: - return NoMatch + for child in value.__children__(self.filter): + result = self.match(child, context) + if result is not NoMatch: + return result + + return self.pattern.match(value, context) def NoneOf(*args) -> Pattern: @@ -1749,6 +1778,39 @@ def FrozenDictOf(key_pattern, value_pattern): return MappingOf(key_pattern, value_pattern, type=frozendict) +def builder(obj): + """Convert an object to a builder. + + It encapsulates: + - callable objects into a `Factory` builder + - non-callable objects into a `Just` builder + + Parameters + ---------- + obj + The object to convert to a builder. + + Returns + ------- + The builder instance. + """ + if isinstance(obj, Builder): + # already a builder, no need to convert + return obj + elif callable(obj): + # the callable builds the substitution + return Factory(obj) + elif isinstance(obj, Sequence): + # allow nesting builder patterns in tuples/lists + return Call(lambda *args: type(obj)(args), *obj) + elif isinstance(obj, Mapping): + # allow nesting builder patterns in dicts + return Call(type(obj), **obj) + else: + # the object is used as a constant value + return Just(obj) + + def pattern(obj: AnyType) -> Pattern: """Create a pattern from various types. @@ -1837,49 +1899,6 @@ def match( return NoMatch if result is NoMatch else result -class Topmost(Slotted, Pattern): - """Traverse the value tree topmost first and match the first value that matches.""" - - __slots__ = ("pattern", "filter") - pattern: Pattern - filter: AnyType - - def __init__(self, searcher, filter=None): - super().__init__(pattern=pattern(searcher), filter=filter) - - def match(self, value, context): - result = self.pattern.match(value, context) - if result is not NoMatch: - return result - - for child in value.__children__(self.filter): - result = self.match(child, context) - if result is not NoMatch: - return result - - return NoMatch - - -class Innermost(Slotted, Pattern): - # matches items in the innermost layer first, but all matches belong to the same layer - """Traverse the value tree innermost first and match the first value that matches.""" - - __slots__ = ("pattern", "filter") - pattern: Pattern - filter: AnyType - - def __init__(self, searcher, filter=None): - super().__init__(pattern=pattern(searcher), filter=filter) - - def match(self, value, context): - for child in value.__children__(self.filter): - result = self.match(child, context) - if result is not NoMatch: - return result - - return self.pattern.match(value, context) - - IsTruish = Check(lambda x: bool(x)) IsNumber = InstanceOf(numbers.Number) & ~InstanceOf(bool) IsString = InstanceOf(str) diff --git a/ibis/common/tests/test_patterns.py b/ibis/common/tests/test_patterns.py index 34edc329ed2f..c7f4b44261e2 100644 --- a/ibis/common/tests/test_patterns.py +++ b/ibis/common/tests/test_patterns.py @@ -39,6 +39,7 @@ Contains, DictOf, EqualTo, + Factory, FrozenDictOf, Function, GenericInstanceOf, @@ -69,8 +70,10 @@ TypeOf, Variable, _, + builder, match, pattern, + replace, ) @@ -123,8 +126,11 @@ def test_nothing(): def test_just(): p = Just(1) - assert p.make({}) == 1 - assert p.make({"a": 1}) == 1 + assert p.build({}) == 1 + assert p.build({"a": 1}) == 1 + + # unwrap subsequently nested Just instances + assert Just(p) == p def test_min(): @@ -147,7 +153,7 @@ def test_any(): def test_variable(): p = Variable("other") context = {p: 10} - assert p.make(context) == 10 + assert p.build(context) == 10 def test_pattern_factory_wraps_variable_with_capture(): @@ -748,6 +754,15 @@ def test_replace_in_nested_object_pattern(): assert h1.b.b == 3 +def test_replace_decorator(): + @replace(int) + def sub(value, ctx): + return value - 1 + + assert match(sub, 1) == 0 + assert match(sub, 2) == 1 + + def test_matching_sequence_pattern(): assert match([], []) == [] assert match([], [1]) is NoMatch @@ -1206,3 +1221,57 @@ def test_node(): ) result = six.replace(pat) assert result == Mul(two, Add(Lit(101), two)) + + +def test_factory(): + f = Factory(lambda x, ctx: x + 1) + assert f.build({_: 1}) == 2 + assert f.build({_: 2}) == 3 + + def fn(x, ctx): + assert ctx == {_: 10, "a": 5} + assert x == 10 + return -1 + + f = Factory(fn) + assert f.build({_: 10, "a": 5}) == -1 + + +def test_call(): + def fn(a, b, c=1): + return a + b + c + + c = Call(fn, 1, 2, c=3) + assert c.build({}) == 6 + + c = Call(fn, Just(-1), Just(-2)) + assert c.build({}) == -2 + + c = Call(dict, a=1, b=2) + assert c.build({}) == {"a": 1, "b": 2} + + +def test_builder(): + def fn(x, ctx): + return x + 1 + + assert builder(1) == Just(1) + assert builder(Just(1)) == Just(1) + assert builder(Just(Just(1))) == Just(1) + assert builder(fn) == Factory(fn) + + b = builder((1, 2, 3)) + assert b.args == (Just(1), Just(2), Just(3)) + assert b.build({}) == (1, 2, 3) + + b = builder([1, 2, 3]) + assert b.args == (Just(1), Just(2), Just(3)) + assert b.build({}) == [1, 2, 3] + + b = builder({"a": 1, "b": 2}) + assert b.kwargs == {"a": Just(1), "b": Just(2)} + assert b.build({}) == {"a": 1, "b": 2} + + b = builder(FrozenDict({"a": 1, "b": 2, "c": 3})) + assert b.kwargs == {"a": Just(1), "b": Just(2), "c": Just(3)} + assert b.build({}) == FrozenDict({"a": 1, "b": 2, "c": 3})