blob: ce79ca72eade2be9e14357289309a9aeb2113aa8 [file] [log] [blame]
"""
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import json
import os
import sys
import inspect
import re
import base64
import marshal
import types
import copy
from composer import __version__
undefined = object() # special undefined value
composer = types.SimpleNamespace() # Simple object with attributes
composer.util = {
'version': __version__
}
# Utility functions
def get_value(env, args):
return env['value']
def set_params(env, args):
env['params'] = args
def get_params(env, args):
return env['params']
def retain_result(env, args):
return { 'params': env['params'], 'result': args }
def retain_nested_result(env, args):
return { 'params': args['params'], 'result': args['result']['result'] }
def dec_count(env, args):
c = env['count']
env['count'] -= 1
return c > 0
def set_nested_params(env, args):
return { 'params': args }
def get_nested_params(env, args):
return args['params']
def set_nested_result(env, args):
return { 'result': args }
def get_nested_result(env, args):
return args['result']
def retry_cond(env, args):
result = args['result']
count = env['count']
env['count'] -= 1
return 'error' in result and count > 0
# lowerer
lowerer = types.SimpleNamespace()
loweropt = lambda f: setattr(lowerer, f.__name__, f)
@loweropt
def literal(value):
return composer.let({ 'value': value }, lambda env, args: env['value'])
@loweropt
def retain(*components):
return composer.let(
{ 'params': None },
composer.ensure(
set_params,
composer.seq(composer.mask(*components), retain_result)))
@loweropt
def retain_catch(*components):
return composer.seq(
composer.retain(
composer.ensure(
composer.seq(*components),
lambda env, args: { 'result' : args })),
retain_nested_result)
@loweropt
def when(test, consequent, alternate):
return composer.let(
{ 'params': None },
set_params,
composer.ensure(
set_params,
composer.when_nosave(
composer.mask(test),
composer.ensure(get_params, composer.mask(consequent)),
composer.ensure(get_params, composer.mask(alternate)))))
@loweropt
def loop(test, body):
return composer.let(
{ 'params': None },
composer.ensure(
set_params,
composer.seq(composer.loop_nosave(
composer.mask(test),
composer.ensure(get_params, composer.seq(composer.mask(body), set_params))),
get_params)))
@loweropt
def doloop(body, test):
return composer.let(
{ 'params': None },
composer.ensure(
set_params,
composer.seq(composer.doloop_nosave(
composer.ensure(get_params, composer.seq(composer.mask(body), set_params)),
composer.mask(test)),
get_params)))
@loweropt
def repeat(count, *components):
return composer.let(
{ 'count': count },
composer.loop(
dec_count,
composer.mask(*components)))
@loweropt
def retry(count, *components):
return composer.let(
{ 'count': count },
set_nested_params,
composer.doloop(
composer.ensure(get_nested_params, composer.mask(composer.retain_catch(*components))),
retry_cond),
get_nested_result)
@loweropt
def merge (*components):
return composer.seq(composer.retain(*components), lambda env, args: args['params'].update(args['result']))
# == Done lowerer
def visit(composition, f):
''' apply f to all fields of type composition '''
composition = copy.copy(composition if isinstance(composition, dict) else composition.__dict__)
combinator = composition['.combinator']()
if 'components' in combinator:
composition['components'] = list(map(lambda v: f(v, None), composition['components']))
if 'args' in combinator:
for arg in combinator['args']:
if 'type' not in arg and arg['name'] in composition:
composition[arg['name']] = f(composition[arg['name']], arg['name'])
return Composition(composition)
def label(composition):
''' recursively label combinators with the json path '''
def label(path):
def labeler(composition, name=None, array=False):
nonlocal path
segment = ''
if name is not None:
if array:
segment = '['+name+']'
else:
segment = '.'+name
p = path + segment
composition = visit(composition, label(p))
composition.path = p
return composition
return labeler
return label('')(composition)
def declare(combinators, prefix=None):
'''
derive combinator methods from combinator table
check argument count and map argument positions to argument names
delegate to Composition constructor for the rest of the validation
'''
if not isinstance(combinators, dict):
raise ComposerError('Invalid argument "combinators" in "declare"', combinators)
if prefix is not None and not isinstance(prefix, str):
raise ComposerError('Invalid argument "prefix" in "declare"', prefix)
composer = types.SimpleNamespace()
for key in combinators:
type_ = prefix + '.' + key if prefix is not None else key
combinator = combinators[key]
if not isinstance(combinator, dict) or ('args' in combinator and not isinstance(combinator['args'], list)):
raise ComposerError('Invalid "'+type_+'" combinator specification in "declare"', combinator)
if 'args' in combinator:
for arg in combinator['args']:
if not isinstance(arg['name'], str):
raise ComposerError('Invalid "'+type_+'" combinator specification in "declare"', combinator)
# Javascript capturing rules differ from python3 ones.
def capture(combinator=combinator, type_=type_):
def combine(*arguments):
composition = { 'type': type_, '.combinator': lambda : combinator }
skip = len(combinator.get('args', []))
if 'components' not in combinator and len(arguments) > skip:
raise ComposerError('Too many arguments in "'+type_+'" combinator')
for i in range(skip):
composition[combinator['args'][i]['name']] = arguments[i]
if 'components' in combinator:
composition['components'] = arguments[skip:]
return Composition(composition)
return combine
setattr(composer, key, capture())
return composer
def serialize(obj):
return obj.__dict__
class Composition:
def __init__(self, composition):
''' construct a composition object with the specified fields '''
# shallow copy of obj attributes
items = composition.items() if isinstance(composition, dict) else composition.__dict__.items() if isinstance(composition, Composition) else None
if items is None:
raise ComposerError('Invalid argument', composition)
for k, v in items:
setattr(self, k, v)
combinator = composition['.combinator']()
if 'args' in combinator:
for arg in combinator['args']:
optional = arg.get('optional', False)
if arg['name'] not in composition and optional and 'type' in arg:
continue
if 'type' not in arg:
try:
value = composition.get(arg['name'], None if optional else undefined)
setattr(self, arg['name'], composer.task(value))
except Exception:
raise ComposerError('Invalid argument "'+arg['name']+'" in "'+composition['type']+' combinator"', value)
elif arg['type'] == 'name':
try:
setattr(self, arg['name'], parse_action_name(composition.get(arg['name'])))
except ComposerError as ce:
raise ComposerError(ce.message + ' in "'+composition['type']+' combinator"', composition.get(arg['name']))
elif arg['type'] == 'value':
if callable(composition.get(arg['name'])) or arg['name'] not in composition:
raise ComposerError('Invalid argument "' + arg['name']+'" in "'+ composition['type']+' combinator"', composition.get(arg['name']))
elif arg['type'] == 'object':
if not isinstance(composition.get(arg['name']), dict):
raise ComposerError('Invalid argument "' + arg['name']+'" in "'+ composition['type']+' combinator"', composition.get(arg['name']))
else:
if type(composition.get(arg['name'])).__name__ != arg['type']:
raise ComposerError('Invalid argument "' + arg['name']+'" in "'+ composition['type']+' combinator"', composition.get(arg['name']))
if 'components' in combinator:
self.components = list(map(composer.task, composition.get('components', [])))
def __str__(self):
return json.dumps(self.__dict__, default=serialize, ensure_ascii=True)
def compile(self):
''' compile composition. Returns a dictionary '''
actions = []
def flatten(composition, _=None):
composition = visit(composition, flatten)
if composition.type == 'action' and hasattr(composition, 'action'): # pylint: disable=E1101
actions.append({ 'name': composition.name, 'action': composition.action })
del composition.action # pylint: disable=E1101
return composition
obj = { 'composition': label(flatten(self)).lower(), 'ast': self, 'version': __version__ }
if len(actions) > 0:
obj['actions'] = actions
return obj
def lower(self, combinators = []):
''' recursively lower combinators to the desired set of combinators (including primitive combinators) '''
if not isinstance(combinators, list) and not isinstance(combinators, str):
raise ComposerError('Invalid argument "combinators" in "lower"', combinators)
def lower(composition, _):
# repeatedly lower root combinator
while 'def' in getattr(composition, '.combinator')():
path = composition.path if hasattr(composition, 'path') else None
combinator = getattr(composition, '.combinator')()
if isinstance(combinator, list) and combinator.indexOf(composition.type) >= 0:
break
# map argument names to positions
args = []
skip = len(combinator.get('args', []))
for i in range(skip):
args.append(getattr(composition, combinator['args'][i]['name']))
if 'components' in combinator:
args.extend(composition.components)
composition = combinator['def'](*args)
# preserve path
if path is not None:
composition.path = path
return visit(composition, lower)
return lower(self, None)
# primitive combinators
combinators = {
'sequence': { 'components': True, 'since': '0.4.0' },
# if_nosave
'when_nosave': { 'args': [{ 'name': 'test' }, { 'name': 'consequent' }, { 'name': 'alternate', 'optional': True }], 'since': '0.4.0' },
# while_nosave
'loop_nosave': { 'args': [{ 'name': 'test' }, { 'name': 'body' }], 'since': '0.4.0' },
# dowhile_nosave
'doloop_nosave': { 'args': [{ 'name': 'body' }, { 'name': 'test' }], 'since': '0.4.0' },
# try
'do': { 'args': [{ 'name': 'body' }, { 'name': 'handler' }], 'since': '0.4.0' },
# finally
'ensure': { 'args': [{ 'name': 'body' }, { 'name': 'finalizer' }], 'since': '0.4.0' },
'let': { 'args': [{ 'name': 'declarations', 'type': 'object' }], 'components': True, 'since': '0.4.0' },
'mask': { 'components': True, 'since': '0.4.0' },
'action': { 'args': [{ 'name': 'name', 'type': 'name' }, { 'name': 'action', 'type': 'object', 'optional': True }], 'since': '0.4.0' },
'function': { 'args': [{ 'name': 'function', 'type': 'object' }], 'since': '0.4.0' },
'asynchronous': { 'components': True, 'since': '0.6.0' },
'execute': { 'since': '0.5.2' },
'map': { 'components': True, 'since': '0.6.0' },
'composition': { 'args': [{ 'name': 'name', 'type': 'name' }], 'since': '0.6.0' }
}
composer.__dict__.update(declare(combinators).__dict__)
# derived combinators
extra = {
'empty': { 'since': '0.4.0', 'def': composer.sequence },
'seq': { 'components': True, 'since': '0.4.0', 'def': composer.sequence },
# if
'when': { 'args': [{ 'name': 'test' }, { 'name': 'consequent' }, { 'name': 'alternate', 'optional': True }], 'since': '0.4.0', 'def': lowerer.when },
# while
'loop': { 'args': [{ 'name': 'test' }, { 'name': 'body' }], 'since': '0.4.0', 'def': lowerer.loop },
# dowhile
'doloop': { 'args': [{ 'name': 'body' }, { 'name': 'test' }], 'since': '0.4.0', 'def': lowerer.doloop },
'repeat': { 'args': [{ 'name': 'count', 'type': 'int' }], 'components': True, 'since': '0.4.0', 'def': lowerer.repeat },
'retry': { 'args': [{ 'name': 'count', 'type': 'int' }], 'components': True, 'since': '0.4.0', 'def': lowerer.retry },
'retain': { 'components': True, 'since': '0.4.0', 'def': lowerer.retain },
'retain_catch': { 'components': True, 'since': '0.4.0', 'def': lowerer.retain_catch },
'value': { 'args': [{ 'name': 'value', 'type': 'value' }], 'since': '0.4.0', 'def': lowerer.literal },
'literal': { 'args': [{ 'name': 'value', 'type': 'value' }], 'since': '0.4.0', 'def': lowerer.literal },
'merge': { 'components': True, 'since': '0.13.0', 'def': lowerer.merge }
}
composer.__dict__.update(declare(extra).__dict__)
# add or override definitions of some combinators
combinator = lambda f: setattr(composer, f.__name__, f)
def task(task):
''' detect task type and create corresponding composition object '''
if task is undefined:
raise ComposerError('Invalid argument in "task" combinator', task)
if task is None:
return composer.empty()
if isinstance(task, Composition):
return task
if callable(task):
return composer.function(task)
if isinstance(task, str): # python3 only
return composer.action(task)
raise ComposerError('Invalid argument "task" in "task" combinator', task)
composer.task = task
def function(fun):
''' function combinator: stringify def/lambda code '''
if getattr(fun, '__name__', '') == '<lambda>':
exc = str(base64.b64encode(marshal.dumps(fun.__code__)), 'ASCII')
elif callable(fun):
try:
exc = inspect.getsource(fun)
except OSError:
raise ComposerError('Invalid argument', fun)
else:
exc = fun
if isinstance(exc, str):
if exc.startswith('def'):
# standardize function name
pattern = re.compile(r'def\s+([a-zA-Z_][a-zA-Z_0-9]*)\s*\(')
match = pattern.match(exc)
functionName = match.group(1)
exc = { 'kind': 'python:3', 'code': exc, 'functionName': functionName }
else: # lambda
exc = { 'kind': 'python:3+lambda', 'code': exc }
if not isinstance(exc, dict) or exc is None:
raise ComposerError('Invalid argument "function" in "function" combinator', fun)
return Composition({'type':'function', 'function':{ 'exec': exc }, '.combinator': lambda: combinators['function'] })
composer.function = function
def action(name, options = {}):
''' action combinator '''
if not isinstance(options, dict):
raise ComposerError('Invalid argument "options" in "action" combinator', options)
exc = None
if 'sequence' in options and isinstance(options['sequence'], list): # native sequence
exc = { 'kind': 'sequence', 'components': tuple(map(parse_action_name, options['sequence'])) }
elif 'filename' in options and isinstance(options['filename'], str): # read action code from file
raise ComposerError('read from file not implemented')
# exc = fs.readFileSync(options.filename, { encoding: 'utf8' })
elif 'action' in options and callable(options['action']):
if options['action'].__name__ == '<lambda>':
l = str(base64.b64encode(marshal.dumps(options['action'].__code__)), 'ASCII')
exc = '''import types\nimport marshal\nimport base64
__code__= types.FunctionType(marshal.loads(base64.b64decode(bytearray(\''''+ l +'''\', 'ASCII'))), {})
def main(args):
return __code__(args)
'''
else:
try:
exc = inspect.getsource(options['action'])
except OSError:
raise ComposerError('Invalid argument "options" in "action" combinator', options['action'])
elif 'action' in options and (isinstance(options['action'], str) or isinstance(options['action'], dict)):
exc = options['action']
if isinstance(exc, str):
exc = { 'kind': 'python:3', 'code': exc }
composition = { 'type': 'action', 'name': name, '.combinator': lambda: combinators['action']}
if exc is not None:
composition['action'] = { 'exec': exc }
return Composition(composition)
composer.action = action
@combinator
def parse(composition):
''' recursively deserialize composition '''
if not isinstance(composition, dict):
raise ComposerError('Invalid argument "composition" in "parse" combinator', composition)
combinator = composition['.combinator']() if '.combinator' in composition and callable(composition['.combinator']) else combinators[composition['type']]
if not isinstance(combinator, dict):
raise ComposerError('Invalid composition type in "parse" combinator', composition)
extended = { '.combinator': lambda : combinator }
extended.update(composition)
return visit(extended, lambda composition, _: composer.parse(composition))
composer.action = action
def parse_action_name(name):
'''
Parses a (possibly fully qualified) resource name and validates it. If it's not a fully qualified name,
then attempts to qualify it.
Examples string to namespace, [package/]action name
foo => /_/foo
pkg/foo => /_/pkg/foo
/ns/foo => /ns/foo
/ns/pkg/foo => /ns/pkg/foo
'''
if not isinstance(name, str):
raise ComposerError('Name must be a string')
name = name.strip()
if len(name) == 0:
raise ComposerError('Name is not valid')
delimiter = '/'
parts = name.split(delimiter)
n = len(parts)
leadingSlash = name[0] == delimiter if len(name) > 0 else False
# no more than /ns/p/a
if n < 1 or n > 4 or (leadingSlash and n == 2) or (not leadingSlash and n == 4):
raise ComposerError('Name is not valid')
# skip leading slash, all parts must be non empty (could tighten this check to match EntityName regex)
for part in parts[1:]:
if len(part.strip()) == 0:
raise ComposerError('Name is not valid')
newName = delimiter.join(parts)
if leadingSlash:
return newName
elif n < 3:
return delimiter+'_'+delimiter+newName
else:
return delimiter+newName
class ComposerError(Exception):
def __init__(self, message, *arguments):
self.message = message
self.argument = arguments