First implementation

This commit is contained in:
Lucidiot 2019-09-16 10:53:38 +02:00
parent 616a553281
commit 2518e8a9aa
No known key found for this signature in database
GPG Key ID: FF629EE969FFE294
5 changed files with 370 additions and 0 deletions

7
madeleine/__init__.py Normal file
View File

@ -0,0 +1,7 @@
from madeleine.base import ( # noqa: F401
Component, Reference, Value,
)
from madeleine.generator import Generator # noqa: F401
from madeleine.multiple import ( # noqa: F401
CompoundComponent, Repeat, AllOf, OneOf, Pick,
)

135
madeleine/base.py Normal file
View File

@ -0,0 +1,135 @@
from abc import ABCMeta, abstractmethod, abstractproperty
from collections.abc import Mapping, Iterable
from enum import Enum
from pathlib import Path
from objtools.registry import ClassRegistry
import json
import random
import yaml
class FileFormat(Enum):
JSON = 'json'
YAML = 'yaml'
class ComponentRegistry(ClassRegistry):
def check_value(self, value):
assert issubclass(value, Component)
registry = ComponentRegistry()
register = registry.register
unregister = registry.unregister
class ComponentMetaclass(registry.metaclass, ABCMeta):
pass
class Component(metaclass=ComponentMetaclass, register=False):
"""
Describes any generator component.
"""
def __init__(self, **data):
self.__dict__.update(**data)
def resolve_references(self, references):
"""
Complex components might hold references to other components:
they should leave them unresolved until the Generator builds all the
components, then calls this method with all of the referencable names.
"""
@abstractmethod
def generate(self):
pass
@abstractproperty
def combinations(self):
"""
Should return how many combinations a single component may have.
Useful to perform some checks after parsing.
"""
@classmethod
def from_path(cls, path, fmt=FileFormat.YAML):
assert isinstance(fmt, FileFormat)
if not isinstance(path, Path):
path = Path(path)
with path.open() as f:
method = json.load if fmt == FileFormat.JSON else yaml.safe_load
if cls is Component:
# Guess the component type when calling Component.from_path
return Component._make(method(f))
return cls(**method(f))
@staticmethod
def _make(data):
"""
Guess which component subclass to use for a given component data,
then build and return the resulting components.
"""
if isinstance(data, str):
data = {'value': data}
if not isinstance(data, Mapping):
if isinstance(data, Iterable):
return list(map(Component._make, data))
raise ValueError('Component description should be a mapping')
for key, component_class in registry.items():
if key in data:
return component_class(**data)
raise ValueError('Could not parse component description')
class Reference(Component, key='ref'):
"""
A component used to temporarily hold references to other components
during the first pass of schema loading, when not all referenced names
are available, before a second pass allows resolution of all references.
"""
@property
def combinations(self):
raise TypeError('Unresolved reference to {!r}'.format(self.ref))
def generate(self):
raise TypeError('Unresolved reference to {!r}'.format(self.ref))
class Value(Component, key='value'):
"""
A component that holds a string value.
Will return the value on every generation, unless `optional` is True,
in which case it may return None 50% of the time.
"""
def __init__(self, value, optional=False, **kwargs):
self.value = value
self.optional = optional
super().__init__(**kwargs)
@property
def combinations(self):
return 1 + self.optional
def generate(self):
if not self.optional or random.randrange(2):
return self.value
class Include(Component, type, key='include'):
"""
Automatically include another generator file.
"""
def __new__(*args, include=None, format=None, **kwargs):
if format is None:
format = FileFormat.YAML
if isinstance(format, str):
format = FileFormat(format)
return Component.from_path(include, fmt=format)

34
madeleine/generator.py Normal file
View File

@ -0,0 +1,34 @@
from collections.abc import Mapping
from madeleine import Component
class Generator(Component, key='main'):
def __init__(self, **data):
assert 'version' in data, 'Missing generator spec version'
assert data['version'] == '1', 'Incompatible generator spec version'
assert 'main' in data, 'Missing main component'
data.setdefault('components', {})
assert isinstance(data['components'], Mapping), \
'Components should be a mapping'
self.version = data['version']
self.components = {}
for k, v in data['components'].items():
self.components[k] = Component._make(v)
self.main = Component._make(data['main'])
# Allows components to reference the main component
self.components.setdefault('main', self.main)
# Let components resolve all their references
for k in self.components:
self.components[k].resolve_references(self.components)
self.main.resolve_references(self.components)
@property
def combinations(self):
return self.main.combinations
def generate(self):
return self.main.generate()

12
madeleine/helpers.py Normal file
View File

@ -0,0 +1,12 @@
def binom(n, k):
"""
Computes the binomial coefficient using a multiplicative formula.
Stolen from https://stackoverflow.com/a/46778364/5990435
"""
assert k >= 0 and k <= n
if k == 0 or k == n:
return 1
b = 1
for i in range(min(k, n-k)):
b = b * (n - i) // (i + 1)
return b

182
madeleine/multiple.py Normal file
View File

@ -0,0 +1,182 @@
from functools import reduce, partial
from itertools import combinations, combinations_with_replacement
from madeleine import Component, Reference
from madeleine.helpers import binom
import operator
import random
class Repeat(Component, key='repeat'):
"""
Repeat another component N times. The amount of times a component should
be repeated can be defined exactly using the `n` parameter, or be generated
randomly at each generation with a `min` and `max`.
Setting `unique` to True will ensure every item in the result is unique.
CAUTION: Ensure that the repeated component can return enough different
values to fill the unique constraint, or the generator will end up in an
infinite loop.
"""
def __init__(self,
repeat,
n=None,
min=0,
max=None,
unique=False,
separator=' ',
**kwargs):
self.repeat = Component._make(repeat)
assert (n is not None) ^ (max is not None), 'Either set `n` or `max`'
self.n = n
self.min = min
self.max = max
self.unique = unique
self.separator = separator
super().__init__(**kwargs)
def resolve_references(self, references):
if isinstance(self.repeat, Reference):
self.repeat = references[self.repeat.ref]
self.repeat.resolve_references(references)
@property
def combinations(self):
if self.n is None:
base_combinations = self.repeat.combinations
if self.unique:
return sum(binom(base_combinations, k)
for k in range(self.min, self.max+1))
else:
return sum(base_combinations ** k
for k in range(self.min, self.max+1))
else:
if self.unique:
return binom(self.repeat.combinations, self.n)
else:
return self.repeat.combinations ** self.n
def generate(self):
amount = self.n
if amount is None:
amount = random.randint(self.min, self.max)
results = []
while len(results) < amount:
result = self.repeat.generate()
if self.unique and result in results:
continue
results.append(result)
return self.separator.join(results)
class CompoundComponent(Component, register=False):
"""
Abstract component for all components which hold multiple child components.
"""
items_key = None
def __init__(self, **data):
assert self.items_key, \
'Missing {}.items_key attribute'.format(self.__class__.__name__)
self.items = list(map(Component._make, data.pop(self.items_key)))
super().__init__(**data)
def resolve_references(self, references):
for i in range(len(self.items)):
if isinstance(self.items[i], Reference):
self.items[i] = references[self.items[i].ref]
self.items[i].resolve_references(references)
class AllOf(CompoundComponent, key='allOf'):
"""
Component which simply joins all of its child components.
"""
items_key = 'allOf'
def __init__(self, separator=' ', **data):
self.separator = separator
super().__init__(**data)
@property
def combinations(self):
return reduce(operator.mul, map(
operator.attrgetter('combinations'),
self.items,
))
def generate(self):
return self.separator.join(
filter(None, [c.generate() for c in self.items])
)
class Pick(CompoundComponent, key='pick'):
"""
Component which randomly picks N of its child components.
"""
items_key = 'pick'
def __init__(self,
separator=' ',
unique=False,
n=None,
min=0,
max=None,
**data):
assert (n is not None) ^ (max is not None), 'Either set `n` or `max`'
self.n = n
self.min = min
self.max = max
self.unique = unique
self.separator = separator
super().__init__(**data)
@property
def combinations(self):
child_combinations = tuple(
map(operator.attrgetter('combinations'), self.items)
)
method = combinations if self.unique else combinations_with_replacement
if self.n is None:
pick_range = range(max(self.min, 1), self.max)
else:
pick_range = (self.n, )
# There probably is a neat formula to compute the combinations
# with(out) replacement and include the combinations of the subsequent
# draws, knowing each item has a different number of combinations,
# but this goes well above my understanding of Wikiversity's
# combinatorics course.
# For each possible amount k of picks, multiply the combinations of
# each combination of k items with(out) remplacement.
return sum(
sum(map(
partial(reduce, operator.mul),
method(child_combinations, k),
))
for k in pick_range
) + (self.n is None and self.min == 0) # Add 1 if it can pick zero
def generate(self):
amount = self.n
if amount is None:
amount = random.randint(self.min, self.max)
method = random.sample if self.unique else random.choices
return self.separator.join(
filter(None, [
c.generate() for c in method(self.items, k=amount)
]),
)
class OneOf(CompoundComponent, key='oneOf'):
"""
Component which randomly picks one of its child components.
"""
items_key = 'oneOf'
@property
def combinations(self):
return sum(map(operator.attrgetter('combinations'), self.items))
def generate(self):
return random.choice(self.items).generate()