diff --git a/dash/development/base_component.py b/dash/development/base_component.py index d647df2411..e79c856fe8 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -1,5 +1,8 @@ import collections import copy +import os +import inspect +import yaml def is_number(s): @@ -18,7 +21,65 @@ def _check_if_has_indexable_children(item): raise KeyError +def _explicitize_args(func): + # Python 2 + if hasattr(func, 'func_code'): + varnames = func.func_code.co_varnames + # Python 3 + else: + varnames = func.__code__.co_varnames + + def wrapper(*args, **kwargs): + if '_explicit_args' in kwargs.keys(): + raise Exception('Variable _explicit_args should not be set.') + kwargs['_explicit_args'] = \ + list( + set( + list(varnames[:len(args)]) + [k for k, _ in kwargs.items()] + ) + ) + if 'self' in kwargs['_explicit_args']: + kwargs['_explicit_args'].remove('self') + return func(*args, **kwargs) + + # If Python 3, we can set the function signature to be correct + if hasattr(inspect, 'signature'): + # pylint: disable=no-member + new_sig = inspect.signature(wrapper).replace( + parameters=inspect.signature(func).parameters.values() + ) + wrapper.__signature__ = new_sig + return wrapper + + +def _convert_js_arg(value): + if 'defaultValue' in value: + # Convert custom types + if value['type']['name'] == 'custom' and 'raw' in value['type']: + value['type']['name'] =\ + value['type']['raw'].replace('PropTypes.', '') + type_string = js_to_py_type(type_object=value['type']) + default_value = value['defaultValue']['value'] + if type_string == 'boolean': + return default_value == 'true' + elif type_string.startswith('dict'): + d = yaml.load(default_value) + return str(d) + elif type_string in ['string', 'list', 'number', 'integer']: + return default_value + return 'Component._NO_DEFAULT_ARG' + + class Component(collections.MutableMapping): + class NO_DEFAULT_ARG(object): + def __repr__(self): + return 'NO_DEFAULT_ARG' + + def __str__(self): + return 'NO_DEFAULT_ARG' + + _NO_DEFAULT_ARG = NO_DEFAULT_ARG() + def __init__(self, **kwargs): # pylint: disable=super-init-not-called for k, v in list(kwargs.items()): @@ -35,7 +96,8 @@ def __init__(self, **kwargs): ', '.join(sorted(self._prop_names)) ) ) - setattr(self, k, v) + if v is not self._NO_DEFAULT_ARG: + setattr(self, k, v) def to_plotly_json(self): # Add normal properties @@ -200,9 +262,10 @@ def __len__(self): # pylint: disable=unused-argument -def generate_class(typename, props, description, namespace): +def generate_class_string(typename, props, description, namespace): + # pylint: disable=too-many-locals """ - Dynamically generate classes to have nicely formatted docstrings, + Dynamically generate class strings to have nicely formatted docstrings, keyword arguments, and repr Inspired by http://jameso.be/2013/08/06/namedtuple.html @@ -216,6 +279,7 @@ def generate_class(typename, props, description, namespace): Returns ------- + string """ # TODO _prop_names, _type, _namespace, available_events, @@ -236,7 +300,8 @@ def generate_class(typename, props, description, namespace): # not all component authors will supply those. c = '''class {typename}(Component): """{docstring}""" - def __init__(self, {default_argtext}): + @_explicitize_args + def __init__(self, {default_argtext}, **kwargs): self._prop_names = {list_of_valid_keys} self._type = '{typename}' self._namespace = '{namespace}' @@ -247,11 +312,16 @@ def __init__(self, {default_argtext}): self.available_wildcard_properties =\ {list_of_valid_wildcard_attr_prefixes} + _explicit_args = kwargs.pop('_explicit_args') + _locals = locals() + args = {{k: _locals[k] for k in self._prop_names + if k != 'children' and not k.endswith('-*')}} + args.update(kwargs) # For wildcard attrs + for k in {required_args}: - if k not in kwargs: + if k not in _explicit_args: raise TypeError( 'Required argument `' + k + '` was not specified.') - super({typename}, self).__init__({argtext}) def __repr__(self): @@ -276,13 +346,15 @@ def __repr__(self): return ( '{typename}(' + repr(getattr(self, self._prop_names[0], None)) + ')') - ''' +''' + omitted_props = ['dashEvents', 'fireEvent', 'setProps'] filtered_props = reorder_props(filter_props(props)) # pylint: disable=unused-variable list_of_valid_wildcard_attr_prefixes = repr(parse_wildcards(props)) # pylint: disable=unused-variable - list_of_valid_keys = repr(list(filtered_props.keys())) + list_of_valid_keys = repr([str(x) for x in filtered_props.keys() + if x not in omitted_props]) # pylint: disable=unused-variable docstring = create_docstring( component_name=typename, @@ -292,19 +364,84 @@ def __repr__(self): # pylint: disable=unused-variable events = '[' + ', '.join(parse_events(props)) + ']' + prop_keys = list(props.keys()) if 'children' in props: - default_argtext = 'children=None, **kwargs' + exclude_children = True + default_argtext = "children=None, " # pylint: disable=unused-variable - argtext = 'children=children, **kwargs' + argtext = 'children=children, **args' else: - default_argtext = '**kwargs' - argtext = '**kwargs' + exclude_children = False + default_argtext = "" + argtext = '**args' + default_argtext += ", ".join([ + '{:s}={}'.format( + p, v + ) for p, v in [(p, _convert_js_arg(v)) for p, v in props.items()] + if not ( + p.endswith("-*") or + (exclude_children and p == "children") or + p in omitted_props + )]) required_args = required_props(props) + return c.format(**locals()) + + +# pylint: disable=unused-argument +def generate_class_file(typename, props, description, namespace): + """ + Generate a python class file (.py) given a class string + + Parameters + ---------- + typename + props + description + namespace + + Returns + ------- + + """ + import_string =\ + "# AUTO GENERATED FILE - DO NOT EDIT\n\n" + \ + "from dash.development.base_component import " + \ + "Component, _explicitize_args\n\n\n" + class_string = generate_class_string( + typename, + props, + description, + namespace + ) + file_name = "{:s}.py".format(typename) + + file_path = os.path.join(namespace, file_name) + with open(file_path, 'w') as f: + f.write(import_string) + f.write(class_string) + + +# pylint: disable=unused-argument +def generate_class(typename, props, description, namespace): + """ + Generate a python class object given a class string - scope = {'Component': Component} + Parameters + ---------- + typename + props + description + namespace + + Returns + ------- + + """ + string = generate_class_string(typename, props, description, namespace) + scope = {'Component': Component, '_explicitize_args': _explicitize_args} # pylint: disable=exec-used - exec(c.format(**locals()), scope) + exec(string, scope) result = scope[typename] return result @@ -573,6 +710,7 @@ def map_js_to_py_types_prop_types(type_object): array=lambda: 'list', bool=lambda: 'boolean', number=lambda: 'number', + integer=lambda: 'integer', string=lambda: 'string', object=lambda: 'dict', any=lambda: 'boolean | number | string | dict | list', diff --git a/dash/development/component_loader.py b/dash/development/component_loader.py index cb82a609e9..74d2e557d4 100644 --- a/dash/development/component_loader.py +++ b/dash/development/component_loader.py @@ -1,6 +1,18 @@ import collections import json +import os from .base_component import generate_class +from .base_component import generate_class_file + + +def _get_metadata(metadata_path): + # Start processing + with open(metadata_path) as data_file: + json_string = data_file.read() + data = json\ + .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ + .decode(json_string) + return data def load_components(metadata_path, @@ -20,12 +32,7 @@ def load_components(metadata_path, components = [] - # Start processing - with open(metadata_path) as data_file: - json_string = data_file.read() - data = json\ - .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ - .decode(json_string) + data = _get_metadata(metadata_path) # Iterate over each property name (which is a path to the component) for componentPath in data: @@ -47,3 +54,56 @@ def load_components(metadata_path, components.append(component) return components + + +def generate_classes(namespace, metadata_path='lib/metadata.json'): + """Load React component metadata into a format Dash can parse, + then create python class files. + + Usage: generate_classes() + + Keyword arguments: + namespace -- name of the generated python package (also output dir) + + metadata_path -- a path to a JSON file created by + [`react-docgen`](https://github.com/reactjs/react-docgen). + + Returns: + """ + + data = _get_metadata(metadata_path) + imports_path = os.path.join(namespace, '_imports_.py') + + # Make sure the file doesn't exist, as we use append write + if os.path.exists(imports_path): + os.remove(imports_path) + + # Iterate over each property name (which is a path to the component) + for componentPath in data: + componentData = data[componentPath] + + # Extract component name from path + # e.g. src/components/MyControl.react.js + # TODO Make more robust - some folks will write .jsx and others + # will be on windows. Unfortunately react-docgen doesn't include + # the name of the component atm. + name = componentPath.split('/').pop().split('.')[0] + generate_class_file( + name, + componentData['props'], + componentData['description'], + namespace + ) + + # Add an import statement for this component + with open(imports_path, 'a') as f: + f.write('from .{0:s} import {0:s}\n'.format(name)) + + # Add the __all__ value so we can import * from _imports_ + all_imports = [p.split('/').pop().split('.')[0] for p in data] + with open(imports_path, 'a') as f: + array_string = '[\n' + for a in all_imports: + array_string += ' "{:s}",\n'.format(a) + array_string += ']\n' + f.write('\n\n__all__ = {:s}'.format(array_string)) diff --git a/dash/version.py b/dash/version.py index 8c306aa668..81edede8b4 100644 --- a/dash/version.py +++ b/dash/version.py @@ -1 +1 @@ -__version__ = '0.21.1' +__version__ = '0.22.0' diff --git a/dev-requirements.txt b/dev-requirements.txt index 282b362edc..e9bf7469a9 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -13,3 +13,4 @@ plotly>=2.0.8 requests[security] flake8 pylint +PyYAML \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 4eb352abdc..5e29494157 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,6 +35,7 @@ py==1.4.33 Pygments==2.2.0 python-dateutil==2.5.3 pytz==2017.2 +PyYAML==3.12.0 requests==2.13.0 requests-file==1.4.1 scandir==1.5 diff --git a/tests/development/metadata_test.py b/tests/development/metadata_test.py new file mode 100644 index 0000000000..452d3314c6 --- /dev/null +++ b/tests/development/metadata_test.py @@ -0,0 +1,83 @@ +# AUTO GENERATED FILE - DO NOT EDIT + +from dash.development.base_component import Component, _explicitize_args + + +class Table(Component): + """A Table component. +This is a description of the component. +It's multiple lines long. + +Keyword arguments: +- children (a list of or a singular dash component, string or number; optional) +- optionalArray (list; optional): Description of optionalArray +- optionalBool (boolean; optional) +- optionalNumber (number; optional) +- optionalObject (dict; optional) +- optionalString (string; optional) +- optionalNode (a list of or a singular dash component, string or number; optional) +- optionalElement (dash component; optional) +- optionalEnum (a value equal to: 'News', 'Photos'; optional) +- optionalUnion (string | number; optional) +- optionalArrayOf (list; optional) +- optionalObjectOf (dict with strings as keys and values of type number; optional) +- optionalObjectWithShapeAndNestedDescription (optional): . optionalObjectWithShapeAndNestedDescription has the following type: dict containing keys 'color', 'fontSize', 'figure'. +Those keys have the following types: + - color (string; optional) + - fontSize (number; optional) + - figure (optional): Figure is a plotly graph object. figure has the following type: dict containing keys 'data', 'layout'. +Those keys have the following types: + - data (list; optional): data is a collection of traces + - layout (dict; optional): layout describes the rest of the figure +- optionalAny (boolean | number | string | dict | list; optional) +- customProp (optional) +- customArrayProp (list; optional) +- data-* (string; optional) +- aria-* (string; optional) +- id (string; optional) + +Available events: 'restyle', 'relayout', 'click'""" + @_explicitize_args + def __init__(self, children=None, optionalArray=Component._NO_DEFAULT_ARG, optionalBool=Component._NO_DEFAULT_ARG, optionalFunc=Component._NO_DEFAULT_ARG, optionalNumber=42, optionalObject=Component._NO_DEFAULT_ARG, optionalString='hello world', optionalSymbol=Component._NO_DEFAULT_ARG, optionalNode=Component._NO_DEFAULT_ARG, optionalElement=Component._NO_DEFAULT_ARG, optionalMessage=Component._NO_DEFAULT_ARG, optionalEnum=Component._NO_DEFAULT_ARG, optionalUnion=Component._NO_DEFAULT_ARG, optionalArrayOf=Component._NO_DEFAULT_ARG, optionalObjectOf=Component._NO_DEFAULT_ARG, optionalObjectWithShapeAndNestedDescription=Component._NO_DEFAULT_ARG, optionalAny=Component._NO_DEFAULT_ARG, customProp=Component._NO_DEFAULT_ARG, customArrayProp=Component._NO_DEFAULT_ARG, id=Component._NO_DEFAULT_ARG, **kwargs): + self._prop_names = ['children', 'optionalArray', 'optionalBool', 'optionalNumber', 'optionalObject', 'optionalString', 'optionalNode', 'optionalElement', 'optionalEnum', 'optionalUnion', 'optionalArrayOf', 'optionalObjectOf', 'optionalObjectWithShapeAndNestedDescription', 'optionalAny', 'customProp', 'customArrayProp', 'data-*', 'aria-*', 'id'] + self._type = 'Table' + self._namespace = 'TableComponents' + self._valid_wildcard_attributes = ['data-', 'aria-'] + self.available_events = ['restyle', 'relayout', 'click'] + self.available_properties = ['children', 'optionalArray', 'optionalBool', 'optionalNumber', 'optionalObject', 'optionalString', 'optionalNode', 'optionalElement', 'optionalEnum', 'optionalUnion', 'optionalArrayOf', 'optionalObjectOf', 'optionalObjectWithShapeAndNestedDescription', 'optionalAny', 'customProp', 'customArrayProp', 'data-*', 'aria-*', 'id'] + self.available_wildcard_properties = ['data-', 'aria-'] + + _explicit_args = kwargs.pop('_explicit_args') + _locals = locals() + args = {k: _locals[k] for k in self._prop_names + if k != 'children' and not k.endswith('-*')} + args.update(kwargs) # For wildcard attrs + + for k in []: + if k not in _explicit_args: + raise TypeError( + 'Required argument `' + k + '` was not specified.') + super(Table, self).__init__(children=children, **args) + + def __repr__(self): + if(any(getattr(self, c, None) is not None + for c in self._prop_names + if c is not self._prop_names[0]) + or any(getattr(self, c, None) is not None + for c in self.__dict__.keys() + if any(c.startswith(wc_attr) + for wc_attr in self._valid_wildcard_attributes))): + props_string = ', '.join([c+'='+repr(getattr(self, c, None)) + for c in self._prop_names + if getattr(self, c, None) is not None]) + wilds_string = ', '.join([c+'='+repr(getattr(self, c, None)) + for c in self.__dict__.keys() + if any([c.startswith(wc_attr) + for wc_attr in + self._valid_wildcard_attributes])]) + return ('Table(' + props_string + + (', ' + wilds_string if wilds_string != '' else '') + ')') + else: + return ( + 'Table(' + + repr(getattr(self, self._prop_names[0], None)) + ')') diff --git a/tests/development/test_base_component.py b/tests/development/test_base_component.py index 9b4b3b609b..bbd7573023 100644 --- a/tests/development/test_base_component.py +++ b/tests/development/test_base_component.py @@ -3,12 +3,16 @@ import inspect import json import os +import shutil import unittest import plotly from dash.development.base_component import ( generate_class, + generate_class_string, + generate_class_file, Component, + _explicitize_args, js_to_py_type, create_docstring, parse_events @@ -488,6 +492,69 @@ def test_pop(self): self.assertTrue(c2_popped is c2) +class TestGenerateClassFile(unittest.TestCase): + def setUp(self): + json_path = os.path.join('tests', 'development', 'metadata_test.json') + with open(json_path) as data_file: + json_string = data_file.read() + data = json\ + .JSONDecoder(object_pairs_hook=collections.OrderedDict)\ + .decode(json_string) + self.data = data + + # Create a folder for the new component file + os.makedirs('TableComponents') + + # Import string not included in generated class string + import_string =\ + "# AUTO GENERATED FILE - DO NOT EDIT\n\n" + \ + "from dash.development.base_component import" + \ + " Component, _explicitize_args\n\n\n" + + # Class string generated from generate_class_string + self.component_class_string = import_string + generate_class_string( + typename='Table', + props=data['props'], + description=data['description'], + namespace='TableComponents' + ) + + # Class string written to file + generate_class_file( + typename='Table', + props=data['props'], + description=data['description'], + namespace='TableComponents' + ) + written_file_path = os.path.join( + 'TableComponents', "Table.py" + ) + with open(written_file_path, 'r') as f: + self.written_class_string = f.read() + + # The expected result for both class string and class file generation + expected_string_path = os.path.join( + 'tests', 'development', 'metadata_test.py' + ) + with open(expected_string_path, 'r') as f: + self.expected_class_string = f.read() + + def tearDown(self): + shutil.rmtree('TableComponents') + + def test_class_string(self): + self.assertEqual( + self.expected_class_string, + self.component_class_string + ) + + def test_class_file(self): + self.assertEqual( + self.expected_class_string, + self.written_class_string + ) + + class TestGenerateClass(unittest.TestCase): def setUp(self): path = os.path.join('tests', 'development', 'metadata_test.json') @@ -526,10 +593,12 @@ def test_to_plotly_json(self): c = self.ComponentClass() self.assertEqual(c.to_plotly_json(), { 'namespace': 'TableComponents', - 'type': 'Table', 'props': { - 'children': None - } + 'children': None, + 'optionalNumber': 42, + 'optionalString': 'hello world' + }, + 'type': 'Table' }) c = self.ComponentClass(id='my-id') @@ -538,7 +607,9 @@ def test_to_plotly_json(self): 'type': 'Table', 'props': { 'children': None, - 'id': 'my-id' + 'id': 'my-id', + 'optionalNumber': 42, + 'optionalString': 'hello world' } }) @@ -549,7 +620,9 @@ def test_to_plotly_json(self): 'props': { 'children': None, 'id': 'my-id', - 'optionalArray': None + 'optionalArray': None, + 'optionalNumber': 42, + 'optionalString': 'hello world' } }) @@ -568,18 +641,21 @@ def test_repr_single_default_argument(self): c2 = self.ComponentClass(children='text children') self.assertEqual( repr(c1), - "Table('text children')" + "Table(children='text children', optionalNumber=42, " + "optionalString='hello world')" ) self.assertEqual( repr(c2), - "Table('text children')" + "Table(children='text children', optionalNumber=42, " + "optionalString='hello world')" ) def test_repr_single_non_default_argument(self): c = self.ComponentClass(id='my-id') self.assertEqual( repr(c), - "Table(id='my-id')" + "Table(optionalNumber=42, optionalString='hello world', " + "id='my-id')" ) def test_repr_multiple_arguments(self): @@ -588,7 +664,8 @@ def test_repr_multiple_arguments(self): c = self.ComponentClass(id='my id', optionalArray=[1, 2, 3]) self.assertEqual( repr(c), - "Table(optionalArray=[1, 2, 3], id='my id')" + "Table(optionalArray=[1, 2, 3], optionalNumber=42, " + "optionalString='hello world', id='my id')" ) def test_repr_nested_arguments(self): @@ -597,14 +674,21 @@ def test_repr_nested_arguments(self): c3 = self.ComponentClass(children=c2) self.assertEqual( repr(c3), - "Table(Table(children=Table(id='1'), id='2'))" + "Table(children=Table(children=Table(optionalNumber=42, " + "optionalString='hello world', id='1'), optionalNumber=42, " + "optionalString='hello world', id='2'), optionalNumber=42, " + "optionalString='hello world')" ) def test_repr_with_wildcards(self): - c = self.ComponentClass(id='1', **{"data-one": "one", - "aria-two": "two"}) - data_first = "Table(id='1', data-one='one', aria-two='two')" - aria_first = "Table(id='1', aria-two='two', data-one='one')" + c = self.ComponentClass(id='1', **{ + "data-one": "one", + "aria-two": "two" + }) + data_first = ("Table(optionalNumber=42, optionalString='hello world', " + "id='1', data-one='one', aria-two='two')") + aria_first = ("Table(optionalNumber=42, optionalString='hello world', " + "id='1', aria-two='two', data-one='one')") repr_string = repr(c) if not (repr_string == data_first or repr_string == aria_first): raise Exception("%s\nDoes not equal\n%s\nor\n%s" % @@ -619,17 +703,52 @@ def test_events(self): ['restyle', 'relayout', 'click'] ) + # This one is kind of pointless now def test_call_signature(self): + __init__func = self.ComponentClass.__init__ # TODO: Will break in Python 3 # http://stackoverflow.com/questions/2677185/ self.assertEqual( - inspect.getargspec(self.ComponentClass.__init__).args, - ['self', 'children'] + inspect.getargspec(__init__func).args, + ['self', + 'children', + 'optionalArray', + 'optionalBool', + 'optionalFunc', + 'optionalNumber', + 'optionalObject', + 'optionalString', + 'optionalSymbol', + 'optionalNode', + 'optionalElement', + 'optionalMessage', + 'optionalEnum', + 'optionalUnion', + 'optionalArrayOf', + 'optionalObjectOf', + 'optionalObjectWithShapeAndNestedDescription', + 'optionalAny', + 'customProp', + 'customArrayProp', + 'id'] if hasattr(inspect, 'signature') else [] + + + ) + self.assertEqual( + inspect.getargspec(__init__func).varargs, + None if hasattr(inspect, 'signature') else 'args' ) self.assertEqual( - inspect.getargspec(self.ComponentClass.__init__).defaults, - (None, ) + inspect.getargspec(__init__func).keywords, + 'kwargs' ) + if hasattr(inspect, 'signature'): + self.assertEqual( + [str(x) if isinstance(x, Component.NO_DEFAULT_ARG) else x + for x in inspect.getargspec(__init__func).defaults], + ([None] + ['NO_DEFAULT_ARG'] * 3 + [42] + ['NO_DEFAULT_ARG'] + + ['hello world'] + ['NO_DEFAULT_ARG'] * 13) + ) def test_required_props(self): with self.assertRaises(Exception): diff --git a/tests/development/test_component_loader.py b/tests/development/test_component_loader.py index 3748705a90..6e2de8f601 100644 --- a/tests/development/test_component_loader.py +++ b/tests/development/test_component_loader.py @@ -1,9 +1,13 @@ import collections import json import os +import shutil import unittest -from dash.development.component_loader import load_components -from dash.development.base_component import generate_class, Component +from dash.development.component_loader import load_components, generate_classes +from dash.development.base_component import ( + generate_class, + Component +) METADATA_PATH = 'metadata.json' @@ -150,3 +154,69 @@ def test_loadcomponents(self): repr(A(**AKwargs)), repr(c[1](**AKwargs)) ) + + +class TestGenerateClasses(unittest.TestCase): + def setUp(self): + with open(METADATA_PATH, 'w') as f: + f.write(METADATA_STRING) + os.makedirs('default_namespace') + + init_file_path = 'default_namespace/__init__.py' + with open(init_file_path, 'a'): + os.utime(init_file_path, None) + + def tearDown(self): + os.remove(METADATA_PATH) + shutil.rmtree('default_namespace') + + def test_loadcomponents(self): + MyComponent_runtime = generate_class( + 'MyComponent', + METADATA['MyComponent.react.js']['props'], + METADATA['MyComponent.react.js']['description'], + 'default_namespace' + ) + + A_runtime = generate_class( + 'A', + METADATA['A.react.js']['props'], + METADATA['A.react.js']['description'], + 'default_namespace' + ) + + generate_classes('default_namespace', METADATA_PATH) + from default_namespace.MyComponent import MyComponent \ + as MyComponent_buildtime + from default_namespace.A import A as A_buildtime + + MyComponentKwargs = { + 'foo': 'Hello World', + 'bar': 'Lah Lah', + 'baz': 'Lemons', + 'data-foo': 'Blah', + 'aria-bar': 'Seven', + 'baz': 'Lemons', + 'children': 'Child' + } + AKwargs = { + 'children': 'Child', + 'href': 'Hello World' + } + + self.assertTrue( + isinstance( + MyComponent_buildtime(**MyComponentKwargs), + Component + ) + ) + + self.assertEqual( + repr(MyComponent_buildtime(**MyComponentKwargs)), + repr(MyComponent_runtime(**MyComponentKwargs)), + ) + + self.assertEqual( + repr(A_runtime(**AKwargs)), + repr(A_buildtime(**AKwargs)) + )