Skip to content

Commit

Permalink
Release/2.2.0 (#85)
Browse files Browse the repository at this point in the history
* Added history.

* Added support for expressing "at least N dimensions".

Co-authored-by: Ramon <p8u7wAPC5Pg9HYkkCkzA>
  • Loading branch information
ramonhagenaars committed Jun 26, 2022
1 parent 0e37975 commit 260da26
Show file tree
Hide file tree
Showing 6 changed files with 81 additions and 45 deletions.
4 changes: 4 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# History

## 2.2.0 (2022-06-26)

- Added support for expressing "at least N dimensions".

## 2.1.3 (2022-06-19)

- Fixed typing issue with Pyright/Pylance that caused the message: "Literal" is not a class
Expand Down
15 changes: 14 additions & 1 deletion USERDOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ labels, wildcards and dimension breakdowns, they are described in the following
The syntax of a shape expression can be formalized in BNF. Extra whitespacing is allowed (e.g. around commas), but this
is not included in the schema below (to avoid extra complexity).
```
shape-expression = <dimensions>|<dimension>","<ellipsis>
shape-expression = <dimensions>|<dimensions>","<ellipsis>
dimensions = <dimension>|<dimension>","<dimensions>
dimension = <unlabeled-dimension>|<labeled-dimension>
labeled-dimension = <unlabeled-dimension>" "<label>
Expand Down Expand Up @@ -201,6 +201,19 @@ True
```
The shape in the above example can be replaced with `typing.Any` to have the same effect.

You can also express "at least N dimensions":
```python
>>> isinstance(random.randn(2, 2), NDArray[Shape["2, 2, ..."], Any])
True
>>> isinstance(random.randn(2, 2, 2, 2), NDArray[Shape["2, 2, ..."], Any])
True
>>> isinstance(random.randn(2), NDArray[Shape["2, 2, ..."], Any])
False

```



#### Dimension breakdowns
A dimension can be broken down into more detail. We call this a **dimension breakdown**. This can be useful to clearly
describe what a dimension means. Example:
Expand Down
2 changes: 1 addition & 1 deletion nptyping/package_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
SOFTWARE.
"""
__title__ = "nptyping"
__version__ = "2.1.3"
__version__ = "2.2.0"
__author__ = "Ramon Hagenaars"
__author_email__ = "[email protected]"
__description__ = "Type hints for NumPy."
Expand Down
85 changes: 42 additions & 43 deletions nptyping/shape_expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ def check_shape(shape: ShapeTuple, target: "Shape") -> bool:
:param target: the shape expression to which shape is tested.
:return: True if the given shape corresponds to shape_expression.
"""
dim_strings = _handle_ellipsis(target.prepared_args, shape)
return _check_dimensions_against_shape(dim_strings, shape)
target_shape = _handle_ellipsis(shape, target.prepared_args)
return _check_dimensions_against_shape(shape, target_shape)


def validate_shape_expression(shape_expression: Union[ShapeExpression, Any]) -> None:
Expand Down Expand Up @@ -113,60 +113,61 @@ def remove_labels(dimensions: List[str]) -> List[str]:
:param dimensions: a list of dimensions.
:return: a copy of the given list without labels.
"""
return [re.sub(r"\b[a-z]\w*", "", dim) for dim in dimensions]
return [re.sub(r"\b[a-z]\w*", "", dim).strip() for dim in dimensions]


def _check_dimensions_against_shape(dimensions: List[str], shape: ShapeTuple) -> bool:
# Walk through the dimensions and test them against the given shape,
def _check_dimensions_against_shape(shape: ShapeTuple, target: List[str]) -> bool:
# Walk through the shape and test them against the given target,
# taking into consideration variables, wildcards, etc.
if len(shape) != len(dimensions):

if len(shape) != len(target):
return False
assigned_variables: Dict[str, str] = {}
for inst_dim, cls_dim in zip(shape, dimensions):
cls_dim_ = cls_dim.strip()
inst_dim_ = str(inst_dim)
if _is_variable(cls_dim_):
# Since cls_dim_ is a variable, try to assign it with
# inst_dim_. This always succeeds if a variable with the same
# name hasn't been assigned already.
if (
cls_dim_ in assigned_variables
and assigned_variables[cls_dim_] != inst_dim_
):
result = False
break
assigned_variables[cls_dim_] = inst_dim_
elif inst_dim_ != cls_dim_ and not _is_wildcard(cls_dim_):
# Identical dimension sizes or wildcards are fine.
result = False
break
else:
# All is well, no errors have been encountered.
result = True
return result
shape_as_strings = (str(dim) for dim in shape)
variables: Dict[str, str] = {}
for dim, target_dim in zip(shape_as_strings, target):
if _is_wildcard(target_dim) or _is_assignable_var(dim, target_dim, variables):
continue
if dim != target_dim:
return False
return True


def _handle_ellipsis(shape: ShapeTuple, target: List[str]) -> List[str]:
# Let the ellipsis allows for any number of dimensions by replacing the
# ellipsis with the dimension size repeated the number of times that
# corresponds to the shape of the instance.
if target[-1] == "...":
dim_to_repeat = target[-2]
target = target[0:-1]
if len(shape) > len(target):
difference = len(shape) - len(target)
target += difference * [dim_to_repeat]
return target


def _is_assignable_var(dim: str, target_dim: str, variables: Dict[str, str]) -> bool:
# Return whether target_dim is a variable and can be assigned with dim.
return _is_variable(target_dim) and _can_assign_variable(dim, target_dim, variables)


def _is_variable(dim: str) -> bool:
# Return whether dim is a variable.
return dim[0] in string.ascii_uppercase


def _can_assign_variable(dim: str, target_dim: str, variables: Dict[str, str]) -> bool:
# Check and assign a variable.
assignable = variables.get(target_dim) in (None, dim)
variables[target_dim] = dim
return assignable


def _is_wildcard(dim: str) -> bool:
# Return whether dim is a wildcard (i.e. the character that takes any
# dimension size).
return dim == "*"


def _handle_ellipsis(dimensions: List[str], shape: ShapeTuple) -> List[str]:
# Let the ellipsis allows for any number of dimensions by replacing the
# ellipsis with the dimension size repeated the number of times that
# corresponds to the shape of the instance.
result = dimensions
if len(dimensions) == 2 and dimensions[1].strip() == "...":
result = dimensions[0:1] * len(shape)
return result


_REGEX_SEPARATOR = r"(\s*,\s*)"
_REGEX_DIMENSION_SIZE = r"(\s*[0-9]+\s*)"
_REGEX_VARIABLE = r"(\s*\b[A-Z]\w*\s*)"
Expand All @@ -184,7 +185,5 @@ def _handle_ellipsis(dimensions: List[str], shape: ShapeTuple) -> List[str]:
_REGEX_DIMENSIONS = (
rf"{_REGEX_DIMENSION_WITH_LABEL}({_REGEX_SEPARATOR}{_REGEX_DIMENSION_WITH_LABEL})*"
)
_REGEX_DIMENSION_ELLIPSIS = (
rf"({_REGEX_DIMENSION_WITH_LABEL}{_REGEX_SEPARATOR}\.\.\.\s*)"
)
_REGEX_SHAPE_EXPRESSION = rf"^({_REGEX_DIMENSIONS}|{_REGEX_DIMENSION_ELLIPSIS})$"
_REGEX_DIMENSIONS_ELLIPSIS = rf"({_REGEX_DIMENSIONS}{_REGEX_SEPARATOR}\.\.\.\s*)"
_REGEX_SHAPE_EXPRESSION = rf"^({_REGEX_DIMENSIONS}|{_REGEX_DIMENSIONS_ELLIPSIS})$"
18 changes: 18 additions & 0 deletions tests/test_ndarray.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,31 @@ def test_isinstance_succeeds_with_ellipsis(self):
NDArray[Shape["*, ..."], Any],
"This should match with an array of any dimensions of any size.",
)
self.assertIsInstance(
np.array([[0]]),
NDArray[Shape["1, 1, ..."], Any],
"This should match with an array of shape (1, 1).",
)
self.assertIsInstance(
np.array([[[[0]]]]),
NDArray[Shape["1, 1, ..."], Any],
"This should match with an array of shape (1, 1, 1, 1).",
)
self.assertIsInstance(
np.array([[[[0, 0], [0, 0]], [[0, 0], [0, 0]]]]),
NDArray[Shape["1, 2, ..."], Any],
)

def test_isinstance_fails_with_ellipsis(self):
self.assertNotIsInstance(
np.array([[[[[[0, 0]]]]]]),
NDArray[Shape["1, ..."], Any],
"This should match with an array of any dimensions of size 1.",
)
self.assertNotIsInstance(
np.array([[[[[0], [0]], [[0], [0]]], [[[0], [0]], [[0], [0]]]]]),
NDArray[Shape["1, 2, ..."], Any],
)

def test_isinstance_succeeds_with_dim_breakdown(self):
self.assertIsInstance(
Expand Down
2 changes: 2 additions & 0 deletions tests/test_shape_expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ def test_validate_shape_expression_success(self):
validate_shape_expression("1, 2, 3")
validate_shape_expression("1, ...")
validate_shape_expression("*, ...")
validate_shape_expression("*, *, ...")
validate_shape_expression("VaRiAbLe123, ...")
validate_shape_expression("[a, b], ...")
validate_shape_expression("2, 3, ...")
validate_shape_expression("*, *, *")
validate_shape_expression("VaRiAbLe123, VaRiAbLe123, VaRiAbLe123")
validate_shape_expression("[a]")
Expand Down

0 comments on commit 260da26

Please sign in to comment.