diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index abb4215..bb3274d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,6 +53,7 @@ jobs: pushd example python example.py python example_capi.py + python example_builder.py popd - name: Run type checking diff --git a/example/example_builder.py b/example/example_builder.py new file mode 100644 index 0000000..09557b9 --- /dev/null +++ b/example/example_builder.py @@ -0,0 +1,55 @@ +from pypict.cmd import from_model + + +def build_model(): + from pypict.builder import Model, Parameter, IF, ALL, ANY, NOT + + # https://github.com/microsoft/pict/blob/main/doc/pict.md + type = Parameter('Type', ['Single', 'Span', 'Stripe', 'Mirror', 'RAID-5']) + size = Parameter('Size', [10, 100, 500, 1000, 5000, 10000, 40000]) + format_method = Parameter('Format method', ['Quick', 'Slow']) + filesys = Parameter('File System', ['FAT', 'FAT32', 'NTFS']) + cluster_size = Parameter( + 'Cluster size', [512, 1024, 2048, 4096, 8192, 16384, 32768, 65536]) + compression = Parameter('Compression', ['On', 'Off']) + + model = Model() + model.parameters( + type, size, format_method, filesys, cluster_size, compression + ) + model.constraints( + IF(filesys == 'FAT').THEN(size <= 4096), + + IF(filesys == 'FAT32').THEN(size <= 32000), + + size < 10000, + + compression == 'OFF', + + NOT(filesys.LIKE('FAT*')), + + IF(cluster_size.IN(512, 1024, 2048)).THEN(compression == 'off'), + + IF(filesys.IN('FAT', 'FAT32')).THEN(compression == 'off'), + + IF( + ANY( + filesys != 'NTFS', + ALL( + filesys == 'NTFS', + cluster_size > 4096, + ), + ) + ).THEN(compression == 'Off'), + ) + return model + + +model = build_model().to_string() +print('# --- PICT model --- #') +print(model) +print() +print('# --- Generated cases --- #') +params, rows = from_model(model) +for row in rows: + print(dict(zip(params, row))) diff --git a/pypict/__init__.py b/pypict/__init__.py index 4f73309..df5efd0 100644 --- a/pypict/__init__.py +++ b/pypict/__init__.py @@ -2,3 +2,5 @@ from pypict.capi import PAIRWISE_GENERATION # NOQA from pypict.api import Task # NOQA + +from pypict import builder # NOQA diff --git a/pypict/_builder/__init__.py b/pypict/_builder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pypict/_builder/constraint.py b/pypict/_builder/constraint.py new file mode 100644 index 0000000..4c43cc2 --- /dev/null +++ b/pypict/_builder/constraint.py @@ -0,0 +1,177 @@ +import enum +import json +from typing import Dict, Iterable, Optional, Union + +from pypict._builder import parameter +from pypict._builder.dtype import NumericType, StringType, DataTypes + + +""" +Implements the constraints grammar as defined in: +https://github.com/microsoft/pict/blob/main/doc/pict.md +""" + + +class _Operator(enum.Enum): + _GT = '>' + _GE = '>=' + _LT = '<' + _LE = '<=' + _EQ = '=' + _NE = '<>' + _IN = 'IN' + _LIKE = 'LIKE' + + +class _Constraint: + def to_string(self) -> str: + raise NotImplementedError + + def evaluate(self, combination: Dict[str, DataTypes]) -> bool: + raise NotImplementedError + + def __str__(self) -> str: + return self.to_string() + + def __repr__(self) -> str: + return f'' + + def __bool__(self) -> bool: + raise ValueError( + 'cannot apply Python logical operators on constraints') + + +class _Predicate(_Constraint): + pass + + +class _Relation(_Predicate): + def __init__( + self, + param: 'parameter.Parameter', + op: _Operator, + operand: Union[DataTypes, 'parameter.Parameter', '_ValueSet']): + self._param = param + self._op = op + self._operand = operand + + def to_string(self) -> str: + return (f'{_as_str(self._param)} ' + f'{self._op.value} {_as_str(self._operand)}') + + def evaluate(self, combination: Dict[str, DataTypes]) -> bool: + if self._param.name not in combination: + return True + value = combination[self._param.name] + # TODO + if self._op is _Operator._GT: + return value > self._operand + + +class _ValueSet: + def __init__(self, values: Iterable[DataTypes]): + self._values = values + + def to_string(self) -> str: + return '{ ' + ', '.join([_as_str(x) for x in self._values]) + ' }' + + +def _as_str(v: Union[DataTypes, 'parameter.Parameter', '_ValueSet']) -> str: + if isinstance(v, parameter.Parameter): + return f'[{v.name}]' + elif isinstance(v, _ValueSet): + return v.to_string() + elif isinstance(v, NumericType): + return str(v) + elif isinstance(v, StringType): + # Escape double-quotes in the string then quote the entire string. + return json.dumps(v) + raise ValueError(v) + + +def _check_predicates(*preds: _Predicate): + if len(preds) == 0: + raise ValueError('at least one predicate must be specified') + for pred in preds: + if not isinstance(pred, _Predicate): + raise ValueError( + 'expected predicate, ' + f'but got {pred} of {type(pred)}') + + +class _LogicalOp(_Predicate): + _op: str = '' # to be overridden + + def __init__(self, *preds: _Predicate): + _check_predicates(*preds) + self._preds = preds + + def to_string(self) -> str: + return '(' + f' {self._op} '.join([str(x) for x in self._preds]) + ')' + + +class ALL(_LogicalOp): + _op = 'AND' + + def evaluate(self, combination) -> bool: + return all((p.evaluate(combination) for p in self._preds)) + + +class ANY(_LogicalOp): + _op = 'OR' + + def evaluate(self, combination) -> bool: + return any((p.evaluate(combination) for p in self._preds)) + + +class NOT(_Predicate): + def __init__(self, pred: _Predicate): + _check_predicates(pred) + self._pred = pred + + def to_string(self) -> str: + return f'NOT {self._pred}' + + def evaluate(self, combination: Dict[str, DataTypes]) -> bool: + return not self._pred.evaluate(combination) + + +class IF(_Constraint): + def __init__(self, pred: _Predicate): + _check_predicates(pred) + self._if = pred + self._then: Optional[_Predicate] = None + self._else: Optional[_Predicate] = None + + def THEN(self, pred: _Predicate) -> 'IF': + _check_predicates(pred) + if self._then is not None: + raise ValueError('THEN cannot be repeated') + self._then = pred + return self + + def ELSE(self, pred: _Predicate) -> 'IF': + _check_predicates(pred) + if self._then is None: + raise ValueError('THEN must be given before ELSE') + if self._else is not None: + raise ValueError('ELSE cannot be repeated') + self._else = pred + + def to_string(self) -> str: + if self._then is None: + raise ValueError('THEN must be given') + return ( + f'IF {self._if}' + + f'\nTHEN {self._then}' + + (f'\nELSE {self._else}' if self._else else '') + ) + + def evaluate(self, combination) -> bool: + if self._then is None: + raise ValueError('cannot evaluate without THEN') + if self._if.evaluate(combination): + return self._then.evaluate(combination) + elif self._else is not None: + return self._else.evaluate(combination) + return True diff --git a/pypict/_builder/dtype.py b/pypict/_builder/dtype.py new file mode 100644 index 0000000..d613405 --- /dev/null +++ b/pypict/_builder/dtype.py @@ -0,0 +1,7 @@ +import numbers +from typing import Union + + +NumericType = numbers.Real +StringType = str +DataTypes = Union[NumericType, StringType] diff --git a/pypict/_builder/model.py b/pypict/_builder/model.py new file mode 100644 index 0000000..4b3a9c3 --- /dev/null +++ b/pypict/_builder/model.py @@ -0,0 +1,65 @@ +from typing import List, Optional, Tuple + +from pypict._builder.parameter import Parameter +from pypict._builder.constraint import _Constraint + + +class Model: + def __init__(self): + self._parameters: List[Parameter] = [] + self._submodels: List[_SubModel] = [] + self._constraints: List[_Constraint] = [] + + def parameters(self, *params: Parameter) -> 'Model': + self._parameters += params + return self + + def submodel( + self, + params: Tuple[Parameter], + order: Optional[int] = None) -> 'Model': + self._submodels.append(_SubModel(params, order)) + return self + + def constraints(self, *constraints: _Constraint) -> 'Model': + self._constraints += constraints + return self + + def to_string(self) -> str: + lines: List[str] = [] + + # Parameter definitions + if len(self._parameters) == 0: + raise ValueError('no parameters are added to the model') + # TODO check uniqueness of names + for p in self._parameters: + lines.append(p.to_string()) + lines.append('') + + # Sub-model definitions + if len(self._submodels) != 0: + for s in self._submodels: + lines.append(s.to_string()) + lines.append('') + + # Constraint definitions + if len(self._constraints) != 0: + for c in self._constraints: + lines.append(c.to_string() + ';') + lines.append('') + + return '\n'.join(lines) + + +class _SubModel: + def __init__(self, params: Tuple[Parameter], order: Optional[int] = None): + self.params = params + self.order = order + + def to_string(self) -> str: + return ( + '{ ' + + ', '.join([param.name for param in self.params]) + + ' }' + + '' if self.order is None else f' @ {self.order}' + ) diff --git a/pypict/_builder/parameter.py b/pypict/_builder/parameter.py new file mode 100644 index 0000000..0bc78dc --- /dev/null +++ b/pypict/_builder/parameter.py @@ -0,0 +1,115 @@ +from typing import Iterable, Tuple, Union + +from pypict._builder import constraint +from pypict._builder.dtype import NumericType, StringType, DataTypes + + +# Values can be a list of (literals or tuple of (literal, weight)). +ValuesType = Iterable[Union[DataTypes, Tuple[DataTypes, int]]] + + +class Parameter: + def __init__(self, name: str, values: ValuesType): + # TODO support aliases + if ':' in name: + raise ValueError(f'invalid parameter name: {name}') + self.name = name + self.values = values + self._is_numeric = self._check_values(values) + + @staticmethod + def _check_values(values: ValuesType) -> bool: + is_numeric = True + for x in values: + if isinstance(x, tuple): + value, weight = x + else: + value, weight = x, 1 + if isinstance(value, NumericType): + pass + elif isinstance(value, StringType): + is_numeric = False + else: + raise ValueError( + 'expected numeric or string, ' + f'but got {value} of {type(value)}') + if not isinstance(weight, int): + raise ValueError( + 'weight must be int, ' + f'but got {weight} of {type(weight)}') + return is_numeric + + def __str__(self) -> str: + return self.to_string() + + def __repr__(self) -> str: + return f'' + + def to_string(self, separator: str = ',') -> str: + return self.name + ': ' + f'{separator} '.join([ + f'{x[0]} ({x[1]})' if isinstance(x, tuple) else str(x) + for x in self.values + ]) + + def _check_operand( + self, + other: Union[DataTypes, 'Parameter'], + *, + no_string: bool = False): + if isinstance(other, Parameter): + if self._is_numeric != other._is_numeric: + raise ValueError( + 'cannot compare numeric and non-numeric parameters') + elif isinstance(other, NumericType): + if not self._is_numeric: + raise ValueError( + 'cannot compare string-typed parameter ' + 'with numeric constant') + elif isinstance(other, StringType): + if no_string: + raise ValueError('strings cannot be compared') + if self._is_numeric: + raise ValueError( + 'cannot compare numeric-typed parameter ' + 'with string constant') + else: + raise ValueError(f'cannot compare with {other} of {type(other)}') + + def __gt__(self, other: Union[NumericType, 'Parameter']): + self._check_operand(other, no_string=True) + return constraint._Relation(self, constraint._Operator._GT, other) + + def __ge__(self, other: Union[NumericType, 'Parameter']): + self._check_operand(other, no_string=True) + return constraint._Relation(self, constraint._Operator._GE, other) + + def __lt__(self, other: Union[NumericType, 'Parameter']): + self._check_operand(other, no_string=True) + return constraint._Relation(self, constraint._Operator._LT, other) + + def __le__(self, other: Union[NumericType, 'Parameter']): + self._check_operand(other, no_string=True) + return constraint._Relation(self, constraint._Operator._LE, other) + + def __eq__(self, other: Union[DataTypes, 'Parameter']): + self._check_operand(other) + return constraint._Relation(self, constraint._Operator._EQ, other) + + def __ne__(self, other: Union[DataTypes, 'Parameter']): + self._check_operand(other) + return constraint._Relation(self, constraint._Operator._NE, other) + + def IN(self, *values: DataTypes): + for x in values: + self._check_operand(x) + return constraint._Relation( + self, constraint._Operator._IN, constraint._ValueSet(values)) + + def LIKE(self, value: StringType): + if self._is_numeric: + raise ValueError('LIKE operator is only for string parameter') + if not isinstance(value, StringType): + raise ValueError( + 'expected wildcard pattern string, ' + f'but got {value} of {type(value)}') + return constraint._Relation(self, constraint._Operator._LIKE, value) diff --git a/pypict/_builder/seeding.py b/pypict/_builder/seeding.py new file mode 100644 index 0000000..bf307bf --- /dev/null +++ b/pypict/_builder/seeding.py @@ -0,0 +1,14 @@ +from typing import Dict + + +class SeedGenerator: + def __init__(self, model): + self._model = model + self._seeds = [] + + def seed(self, case: Dict): + self._seeds.append( + (case[x] if x in case else None for x in self._model._parameters)) + + def to_string(self): + pass diff --git a/pypict/builder.py b/pypict/builder.py new file mode 100644 index 0000000..e1bbc82 --- /dev/null +++ b/pypict/builder.py @@ -0,0 +1,3 @@ +from pypict._builder.model import Model # NOQA +from pypict._builder.parameter import Parameter # NOQA +from pypict._builder.constraint import ALL, ANY, NOT, IF # NOQA diff --git a/tests/builder_tests/__init__.py b/tests/builder_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/builder_tests/test_builder.py b/tests/builder_tests/test_builder.py new file mode 100644 index 0000000..624be35 --- /dev/null +++ b/tests/builder_tests/test_builder.py @@ -0,0 +1,20 @@ +import unittest + +from pypict.builder import Model, Parameter +from pypict.builder import IF + + +class TestBuilder(unittest.TestCase): + def test_basic_usecase(self): + type = Parameter( + 'Type', ['Single', 'Span', 'Stripe', 'Mirror', 'RAID-5']) + size = Parameter('Size', [10, 100, 500, 1000, 5000, 10000, 40000]) + filesys = Parameter('File System', ['FAT', 'FAT32', 'NTFS']) + + m = Model() + m.parameters(type, size, filesys) + m.constraints( + IF(filesys == 'FAT').THEN(size <= 4096), + IF(filesys == 'FAT32').THEN(size <= 32000), + ) + print(m.to_string())