Skip to content

Commit

Permalink
Merge pull request #170 from chrispyles/structural-patterns
Browse files Browse the repository at this point in the history
Structural patterns
  • Loading branch information
leestott authored Apr 28, 2022
2 parents 48f6579 + 3582cab commit 131ad0a
Show file tree
Hide file tree
Showing 13 changed files with 473 additions and 5 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file, and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.7.0 - 2022-04-28

* Added structural pattern matching

## 0.6.1 - 2022-04-06

* Fixed bug causing NumPy fixed-width integer overflows for large integers per [#142](https://github.com/microsoft/pybryt/issues/142)
Expand Down
6 changes: 2 additions & 4 deletions docs/annotations/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@ Annotations
:maxdepth: 3
:hidden:

value_annotations
value_annotations/index
relational_annotations
complexity_annotations
type_annotations
import_annotations
collections
initial_conditions
invariants

Annotations are the basic building blocks, out of which reference
implementations are constructed. The annotations represent a single condition
Expand All @@ -28,7 +26,7 @@ boolean logic surrounding the presence or absence of those values.
All annotations are created by instantiating subclasses of the abstract
:py:class:`Annotation<pybryt.annotations.annotation.Annotation>` class. There
are five main types of annotations:

* :ref:`value annotations<value>`
* :ref:`relational annotations<relational>`
* :ref:`complexity annotations<complexity>`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
Value Annotations
=================

.. toctree::
:maxdepth: 3
:hidden:

initial_conditions
structural_patterns
invariants

Value annotations are the most basic type of annotation. They expect a specific
value to appear while executing the student's code. To create a value
annotation, create an instance of :py:class:`Value<pybryt.annotations.value.Value>` and pass to
Expand Down
File renamed without changes.
File renamed without changes.
69 changes: 69 additions & 0 deletions docs/annotations/value_annotations/structural_patterns.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
Structural Pattern Matching
===========================

PyBryt supports structural pattern matching in order to allow you to create annotations that check
the structure of objects instead of using an ``==`` check. Structural patterns can be created by
accessing attributes of the singleton
:py:obj:`pybryt.structural<pybryt.annotations.structural.structural>` and calling them
with attribute-value pairs as arguments. For example, if you're matching an instance of
``mypackage.Foo`` with attribute ``bar`` set to ``2``; a structural pattern for this could be
created with

.. code-block:: python
pybryt.structural.mypackage.Foo(bar=2)
If there are attributes you want to look for without a specific name, you can pass these as
positional arguments:

.. code-block:: python
pybryt.structural.mypackage.Foo(3, bar=2)
To determine whether an object matches the structural pattern, PyBryt imports the package and
retrieves the specified class. In the examples above, this would look like

.. code-block:: python
getattr(importlib.import_module("mypackage"), "Foo")
If the provided object is an instance of this class and has the specified attributes, the object
matches. You can determine if an object matches a structural pattern using an ``==`` comparison.

If no package is specified for the class, the pattern just checks that the name of the class
matches the name of the class in the structural pattern, without importing any modules. For
example:

.. code-block:: python
df_pattern = pybryt.structural.DataFrame()
df_pattern == pd.DataFrame() # returns True
class DataFrame:
pass
df_pattern == DataFrame() # returns True
Attribute values are matched using the same algorithm as
:py:class:`Value<pybryt.annotations.value.Value>` annotations. If you would like to make use of
the options available to :py:class:`Value<pybryt.annotations.value.Value>` annotations, you can
also pass an annotation as an attribute value:

.. code-block:: python
pybryt.structural.mypackage.Foo(pi=pybryt.Value(np.pi, atol=1e-5))
For checking whether an object contains specific members (determined via the use of Python's
``in`` operator), use the ``contains_`` method:

.. code-block:: python
pybryt.structural.mypackage.MyList().contains_(1, 2, 3)
To use structural patterns, pass them as values to :py:class:`Value<pybryt.annotations.value.Value>`
annotations. When a value annotation is checking for a structural pattern, it uses the pattern's
``==`` check to determine whether any object in the memory footprint matches.

.. code-block:: python
pybryt.Value(pybryt.structural.mypackage.MyList())
7 changes: 7 additions & 0 deletions docs/api_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ Value Annotations
:undoc-members:


Structural Patterns
???????????????????

.. autodata:: pybryt.annotations.structural.structural
:annotation:


.. _invariants_ref:

Invariants
Expand Down
1 change: 1 addition & 0 deletions pybryt/annotations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
from .import_ import *
from .initial_condition import *
from .relation import *
from .structural import *
from .type_ import *
from .value import *
206 changes: 206 additions & 0 deletions pybryt/annotations/structural.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"""Annotation helpers for structural pattern matching"""

__all__ = ["structural"]

import importlib

from typing import Any, Dict, List, Optional, Tuple


class _StructuralPattern:
"""
A singleton that can be used for structural pattern matching.
Structural patterns can be created by accessing attributes of this singleton and calling them
with attribute-value pairs as arguments. For example, if you're matching an instance of
``mypackage.Foo`` with attribute ``bar`` set to ``2``, a structural pattern for this could be
created with
.. code-block:: python
pybryt.structural.mypackage.Foo(bar=2)
If there are attributes you want to look for without a specific name, you can pass these as
positional arguments:
.. code-block:: python
pybryt.structural.mypackage.Foo(3, bar=2)
To determine whether an object matches the structural pattern, PyBryt imports the package and
retrieves the specified class. In the examples above, this would look like
.. code-block:: python
getattr(importlib.import_module("mypackage"), "Foo")
If the provided object is an instance of this class and has the specified attributes, the object
matches. You can determine if an object matches a structural pattern using an ``==`` comparison.
If no package is specified for the class, the pattern just checks that the name of the class
matches the name of the class in the structural pattern, without importing any modules. For
example:
.. code-block:: python
df_pattern = pybryt.structural.DataFrame()
df_pattern == pd.DataFrame() # returns True
class DataFrame:
pass
df_pattern == DataFrame() # returns True
Attribute values are matched using the same algorithm as
:py:class:`Value<pybryt.annotations.value.Value>` annotations. If you would like to make use of
the options available to :py:class:`Value<pybryt.annotations.value.Value>` annotations, you can
also pass an annotation as an attribute value:
.. code-block:: python
pybryt.structural.mypackage.Foo(pi=pybryt.Value(np.pi, atol=1e-5))
For checking whether an object contains specific members (determined via the use of Python's
``in`` operator), use the ``contains_`` method:
.. code-block:: python
pybryt.structural.mypackage.MyList().contains_(1, 2, 3)
"""

_parents: List[str]
"""the package hierarchy this module or class is in"""

_curr: Optional[str]
"""the name of this module or class"""

_unnamed_attrs: List[Any]
"""a list of attributes the object described should have, ignoring the names of those attributes"""

_named_attrs: Dict[str, Any]
"""attributes the object described should have by their names"""

_elements: List[Any]
"""elements expected to be contained by a matching object"""

def __init__(self, _parents=None, _curr=None, _unnamed_attrs=None, _elements=None, **named_attrs):
self._parents = [] if _parents is None else _parents
self._curr = _curr
self._unnamed_attrs = [] if _unnamed_attrs is None else _unnamed_attrs
self._elements = [] if _elements is None else _elements
self._named_attrs = named_attrs

def _get_mod_cls(self) -> Tuple[str, str]:
"""
Get a tuple containing the importable module and class names.
Returns:
``tuple[str, str]``: the module name and class name
"""
return ".".join(self._parents), self._curr

def __repr__(self):
mod, cls = self._get_mod_cls()
mod = mod + "." if mod else mod
return f"pybryt.structural.{mod}{cls}({', '.join(f'{k}={v}' for k, v in self._named_attrs.items())})"

def __getattr__(self, attr: str) -> "_StructuralPattern":
if attr in {"__getstate__", "__slots__", "__setstate__"}: # for dill
raise AttributeError

parents = self._parents.copy()
if self._curr:
parents += [self._curr]

return type(self)(_parents=parents, _curr=attr)

def __call__(self, *unnamed_attrs, **named_attrs) -> "_StructuralPattern":
return type(self)(
_parents=self._parents,
_curr=self._curr,
_unnamed_attrs=list(unnamed_attrs),
_elements=self._elements.copy(),
**named_attrs,
)

def _check_object_attrs(self, obj: Any) -> bool:
"""
Check whether the specified object's attributes match those specified by this pattern.
Args:
obj (``object``): the object to check
Returns:
``bool``: whether the object's attributes match
"""
for a, v in self._named_attrs.items():
if not hasattr(obj, a):
return False

if isinstance(v, type(self)):
if v != getattr(obj, a):
return False

else:
v = v if isinstance(v, Value) else Value(v)
if not v.check_against(getattr(obj, a)):
return False

for v in self._unnamed_attrs:
has_attr = False
for a in dir(obj):
if getattr(obj, a) == v:
has_attr = True
break

if not has_attr:
return False

for e in self._elements:
try:
if e not in obj:
return False
except TypeError:
return False

return True

def contains_(self, *elements: Any) -> "_StructuralPattern":
"""
Add a clause to this structural pattern indicating that a matching object should contain
the specified elements.
Args:
*elements (``object``): the elements to check for
Returns:
a new structural pattern with the added condition
"""
return type(self)(
_parents=self._parents,
_curr=self._curr,
_unnamed_attrs=self._unnamed_attrs.copy(),
_elements=list(elements),
**self._named_attrs,
)

def __eq__(self, other: Any) -> bool:
"""
Determine whether another object matches this structural pattern.
Args:
other (``object``): the object to check
Returns:
``bool``: whether the object matches
"""
mod, cls = self._get_mod_cls()
class_ = getattr(importlib.import_module(mod), cls) if mod else None
is_instance = isinstance(other, class_) if mod else other.__class__.__name__ == cls
return is_instance and self._check_object_attrs(other)


structural = _StructuralPattern()


from .value import Value
4 changes: 4 additions & 0 deletions pybryt/annotations/value.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .annotation import Annotation, AnnotationResult
from .initial_condition import InitialCondition
from .invariants import invariant
from .structural import _StructuralPattern

from ..debug import _debug_mode_enabled
from ..execution import Event, MemoryFootprint, MemoryFootprintValue
Expand Down Expand Up @@ -292,6 +293,9 @@ def check_values_equal(value, other_value, atol = None, rtol = None, equivalence

return ret

if isinstance(value, _StructuralPattern):
return value == other_value

if isinstance(value, Iterable) ^ isinstance(other_value, Iterable):
return False

Expand Down
Loading

0 comments on commit 131ad0a

Please sign in to comment.