Repository: electro-smith/json2daisy (language: python)

Issue:
Set up CI to run tests
With the tests at least partially set up, now would be a good time to implement automatic testing here.


Discussion / hints:
As I suggested before: perhaps add flake8 check to that.

ps; I tried running the tests locally, but haven't had any luck getting them to pass in the current state.

Fix patch:
diff --git a/src/json2daisy/__init__.py b/src/json2daisy/__init__.py
index 70798b3..76fcf71 100644
--- a/src/json2daisy/__init__.py
+++ b/src/json2daisy/__init__.py
@@ -1,1 +1,1 @@
-from .json2daisy import *
+from .json2daisy import *  # noqa
diff --git a/src/json2daisy/__main__.py b/src/json2daisy/__main__.py
index 7beb939..4d2a224 100644
--- a/src/json2daisy/__main__.py
+++ b/src/json2daisy/__main__.py
@@ -1,29 +1,31 @@
 import os
 
-from .json2daisy import *
+from .json2daisy import generate_header_from_file, generate_header_from_name
+
 
 if __name__ == '__main__':
-  import argparse
+    import argparse
+
+    parser = argparse.ArgumentParser(prog='python3 -m json2daisy',
+                                     description='Convert JSON board descriptions to C++ headers.')
+    parser.add_argument('source', type=str,
+                        help='Daisy board name or path to JSON board description '
+                             '(e.g. "patch" or "path/to/myboard.json")')
+    parser.add_argument('-o', metavar='outfile', type=str, default=None,
+                        help='output file name')
+
+    args = parser.parse_args()
+    if os.path.exists(args.source) and os.path.isfile(args.source):
+        header, info = generate_header_from_file(args.source)
+    else:
+        # In case a file is passed but does not exist
+        if args.source[-4:] == '.json':
+            print(f'Error: cannot find file "{args.source}"')
+            exit(1)
+        header, info = generate_header_from_name(args.source)
+
+    outfile = args.o if args.o is not None else f'j2daisy_{info["name"]}.h'
+    with open(outfile, 'w') as file:
+        file.write(header)
 
-  parser = argparse.ArgumentParser(prog='python3 -m json2daisy',
-                                  description='Convert JSON board descriptions to C++ headers.')
-  parser.add_argument('source', type=str,
-                      help='Daisy board name or path to JSON board description (e.g. "patch" or "path/to/myboard.json")')
-  parser.add_argument('-o', metavar='outfile', type=str, default=None,
-                      help='output file name')
-  
-  args = parser.parse_args()
-  if os.path.exists(args.source) and os.path.isfile(args.source):
-    header, info = generate_header_from_file(args.source)
-  else:
-    # In case a file is passed but does not exist
-    if args.source[-4:] == '.json':
-      print(f'Error: cannot find file "{args.source}"')
-      exit(1)
-    header, info = generate_header_from_name(args.source)
-    
-  outfile = args.o if args.o is not None else f'j2daisy_{info["name"]}.h'
-  with open(outfile, 'w') as file:
-    file.write(header)
-  
-  print(f'Generated Daisy C++ header in "{outfile}"')
+    print(f'Generated Daisy C++ header in "{outfile}"')
diff --git a/src/json2daisy/json2daisy.py b/src/json2daisy/json2daisy.py
index 92edb79..f76482a 100644
--- a/src/json2daisy/json2daisy.py
+++ b/src/json2daisy/json2daisy.py
@@ -3,282 +3,360 @@
 import os
 import pkg_resources
 
+from typing import Optional
+
 json_defs_file = ''
 
+
 # helper for loading and processing the definitions, component list, etc
-def map_load(pair):
-	# load the default components
-	comp_string = pkg_resources.resource_string(__name__, json_defs_file)
-	component_defs = json.loads(comp_string)
-
-	pair[1]['name'] = pair[0]
-
-	# the default if it exists
-	component = component_defs.get(pair[1]['component'], None)
-	if(component):
-		# copy component defs into the def
-		# TODO this should be recursive for object structures..
-		for k in component:
-			if not k in pair[1]: 
-				pair[1][k] = component[k]
-	else:
-		raise Exception(f'unknown component "{pair[1]["component"]}"')
-
-	return pair[1]
-
-def filter_match(set, key, match, key_exclude=None, match_exclude=None):
-	if (key_exclude is not None and match_exclude is not None):
-		return filter(lambda x: x.get(key, '') == match and x.get(key_exclude, '') != match_exclude, set)
-	else:
-		return filter(lambda x: x.get(key, '') == match, set)
-
-def filter_matches(set, key, matches, key_exclude=None, match_exclude=None):
-	if (key_exclude is not None and match_exclude is not None):
-		return filter(lambda x: x.get(key, '') in matches and x.get(key_exclude, '') != match_exclude, set)
-	else:
-		return filter(lambda x: x.get(key, '') in matches, set)
-
-def filter_has(set, key, key_exclude=None, match_exclude=None):
-	if (key_exclude is not None and match_exclude is not None):
-		return filter(lambda x: x.get(key, '') != '' and x.get(key_exclude, '') != match_exclude, set)
-	else:
-		return filter(lambda x: x.get(key, '') != '', set)
+def map_load(pair: list):
+    # load the default components
+    comp_string = pkg_resources.resource_string(__name__, json_defs_file)
+    component_defs = json.loads(comp_string)
+
+    pair[1]['name'] = pair[0]
+
+    # the default if it exists
+    component = component_defs.get(pair[1]['component'], None)
+    if (component):
+        # copy component defs into the def
+        # TODO this should be recursive for object structures..
+        for k in component:
+            if k not in pair[1]:
+                pair[1][k] = component[k]
+    else:
+        raise Exception(f'unknown component "{pair[1]["component"]}"')
+
+    return pair[1]
+
+
+def filter_match(
+    component_set: list,
+    key: str,
+    match: str,
+    key_exclude: Optional[str] = None,
+    match_exclude: bool = False
+) -> filter:
+    if (key_exclude is not None and match_exclude):
+        return filter(lambda x: x.get(key, '') == match and x.get(key_exclude, '') != match_exclude, component_set)
+    else:
+        return filter(lambda x: x.get(key, '') == match, component_set)
+
+
+def filter_matches(
+    component_set: list,
+    key: str,
+    matches: list,
+    key_exclude: Optional[str] = None,
+    match_exclude: bool = False
+) -> filter:
+    if (key_exclude is not None and match_exclude):
+        return filter(lambda x: x.get(key, '') in matches and x.get(key_exclude, '') != match_exclude, component_set)
+    else:
+        return filter(lambda x: x.get(key, '') in matches, component_set)
+
+
+def filter_has(
+    component_set: list,
+    key: str,
+    key_exclude: Optional[str] = None,
+    match_exclude: bool = False
+) -> filter:
+    if (key_exclude is not None and match_exclude):
+        return filter(lambda x: x.get(key, '') != '' and x.get(key_exclude, '') != match_exclude, component_set)
+    else:
+        return filter(lambda x: x.get(key, '') != '', component_set)
 
-# filter out the components we need, then map them onto the init for that part
-def filter_map_init(set, key, match, key_exclude=None, match_exclude=None):
-	filtered = filter_match(set, key, match, key_exclude=key_exclude, match_exclude=match_exclude)
-	return "\n    ".join(map(lambda x: x['map_init'].format_map(x), filtered)) 
 
-def filter_map_set(set, key, match, key_exclude=None, match_exclude=None):
-	filtered = filter_match(set, key, match, key_exclude=key_exclude, match_exclude=match_exclude)
-	return "\n    ".join(map(lambda x: x['mapping'][0]['set'].format_map(x['mapping'][0]['name'].format_map(x)), filtered))
+# filter out the components we need, then map them onto the init for that part
+def filter_map_init(
+    component_set: list,
+    key: str,
+    match: str,
+    key_exclude: Optional[str] = None,
+    match_exclude: bool = False
+) -> str:
+    filtered = filter_match(component_set, key, match, key_exclude=key_exclude, match_exclude=match_exclude)
+    return "\n    ".join(map(lambda x: x['map_init'].format_map(x), filtered))
+
+
+# this function is not used anywhere?
+def filter_map_set(
+    component_set: list,
+    key: str,
+    match: str,
+    key_exclude: Optional[str] = None,
+    match_exclude: bool = False
+) -> str:
+    filtered = filter_match(component_set, key, match, key_exclude=key_exclude, match_exclude=match_exclude)
+    return "\n    ".join(
+        map(lambda x: x['mapping'][0]['set'].format_map(x['mapping'][0]['name'].format_map(x)), filtered)
+    )
+
+
+def filter_map_ctrl(
+    component_set: list,
+    key: str,
+    matches: list,
+    init_key: str,
+    key_exclude: Optional[str] = None,
+    match_exclude: bool = False
+) -> str:
+    component_filt = filter_matches(component_set, key, matches, key_exclude=key_exclude, match_exclude=match_exclude)
+    component_map = map(lambda x, i: {**x, 'i': i}, component_filt, range(1000))
+    return "\n    ".join(map(lambda x: x[init_key].format_map(x), component_map))
 
-def filter_map_ctrl(set, key, matches, init_key, key_exclude=None, match_exclude=None):
-	set = filter_matches(set, key, matches, key_exclude=key_exclude, match_exclude=match_exclude)
-	set = map(lambda x, i: {**x, 'i': i}, set, range(1000))
-	return "\n    ".join(map(lambda x: x[init_key].format_map(x), set))
 
 # filter out the components with a certain field, then fill in the template
-def filter_map_template(set, name, key_exclude=None, match_exclude=None):
-	filtered = filter_has(set, name, key_exclude=key_exclude, match_exclude=match_exclude)
-	return "\n    ".join(map(lambda x: x[name].format_map(x), filtered))
-
-def flatten_pin_dicts(comp):
-	newcomp = {}
-	for key,val in comp.items():
-		if (isinstance(val, dict) and key == 'pin'):
-			for subkey, subval in val.items():
-				newcomp[key + '_' + subkey] = subval
-		else:
-			newcomp[key] = val
-
-	return newcomp
-
-def flatten_index_dicts(comp):
-	newcomp = {}
-	for key,val in comp.items():
-		if (isinstance(val, dict) and key == 'index'):
-			for subkey, subval in val.items():
-				newcomp[key + '_' + subkey] = subval
-		else:
-			newcomp[key] = val
-
-	return newcomp
+def filter_map_template(
+    component_set: list,
+    name: str,
+    key_exclude: Optional[str] = None,
+    match_exclude: bool = False
+) -> str:
+    filtered = filter_has(component_set, name, key_exclude=key_exclude, match_exclude=match_exclude)
+    return "\n    ".join(map(lambda x: x[name].format_map(x), filtered))
+
+
+def flatten_pin_dicts(comp: dict) -> dict:
+    newcomp = {}
+    for key, val in comp.items():
+        if (isinstance(val, dict) and key == 'pin'):
+            for subkey, subval in val.items():
+                newcomp[f'{key}_{subkey}'] = subval
+        else:
+            newcomp[key] = val
+
+    return newcomp
+
+
+def flatten_index_dicts(comp: dict) -> dict:
+    newcomp = {}
+    for key, val in comp.items():
+        if (isinstance(val, dict) and key == 'index'):
+            for subkey, subval in val.items():
+                newcomp[f'{key}_{subkey}'] = subval
+        else:
+            newcomp[key] = val
+
+    return newcomp
+
 
 def generate_header(board_description_dict: dict) -> 'tuple[str, dict]':
+    """
+    Generate a C++ Daisy board header from a dictionary board description.
 
-  """
-  Generate a C++ Daisy board header from a dictionary board description. 
-
-  Returns a tuple containing the board 
-  header as a string and an information dictionary.
-
-  The dictionary provides sufficient information to
-  generate interface code, including component getters
-  and setters, audio channel count, etc.
-  """
-
-  target = board_description_dict
-  
-  # flesh out target components:
-  components = target.get('components', {})
-  parents = target.get('parents', {})
-
-  for key in parents:
-    parents[key]['is_parent'] = True
-  components.update(parents)
-
-  seed_defs = os.path.join("resources", 'component_defs.json')
-  patchsm_defs = os.path.join("resources", 'component_defs_patchsm.json')
-  petalsm_defs = os.path.join("resources", 'component_defs_petalsm.json')
-  definitions = {'seed': seed_defs, 'patch_sm': patchsm_defs, 'petal_125b_sm': petalsm_defs}
-  som = target.get('som', 'seed')
-
-  global json_defs_file
-  try:
-    json_defs_file = definitions[som]
-  except KeyError:
-    raise NameError(f'Unkown som "{som}"')
-
-  # alphabetize by component name
-  components = sorted(components.items(), key=lambda x: x[1]['component'])
-  components = list(map(map_load, components))
-
-  # flatten pin dicts into multiple entries
-  # e.g. "pin": {"a": 12} => "pin_a": 12
-  components = [flatten_pin_dicts(comp) for comp in components]
-  components = [flatten_index_dicts(comp) for comp in components]
-
-  target['components'] = components
-  if not 'name' in target:
-    target['name'] = 'custom'
-
-  if not 'aliases' in target:
-    target['aliases'] = {}
-
-  if 'display' in target:
-    # apply defaults
-    target['display'] = {
-      'driver': "daisy::SSD130x4WireSpi128x64Driver",
-      'config': [],
-      'dim': [128, 64]
-    }
-    
-    target['defines']['OOPSY_TARGET_HAS_OLED'] = 1
-    target['defines']['OOPSY_OLED_DISPLAY_WIDTH'] = target['display']['dim'][0]
-    target['defines']['OOPSY_OLED_DISPLAY_HEIGHT'] = target['display']['dim'][1]
-
-  replacements = {}
-  replacements['name'] = target['name']
-  replacements['som'] = som
-  replacements['external_codecs'] = target.get('external_codecs', [])
-
-  som_classes = {
-    'seed': 'daisy::DaisySeed',
-    'patch_sm': 'daisy::patch_sm::DaisyPatchSM',
-    'petal_125b_sm': 'daisy::Petal125BSM',
-  }
-
-  replacements['som_class'] = som_classes.get(som, som_classes['seed'])
-
-  replacements['display_conditional'] = ('#include "dev/oled_ssd130x.h"' if ('display' in target) else  "")
-  replacements['target_name'] = target['name']
-  replacements['init'] = filter_map_template(components, 'init', key_exclude='default', match_exclude=True)
-
-  replacements['analogcount'] = len(list(filter_matches(components, 'component', ['AnalogControl', 'AnalogControlBipolar', 'CD4051'], key_exclude='default', match_exclude=True)))
-
-  replacements['init_single'] = filter_map_ctrl(components, 'component', ['AnalogControl', 'AnalogControlBipolar', 'CD4051'], 'init_single', key_exclude='default', match_exclude=True)
-  replacements['ctrl_init'] = filter_map_ctrl(components, 'component', ['AnalogControl', 'AnalogControlBipolar'], 'map_init', key_exclude='default', match_exclude=True)	
-
-  comp_string = pkg_resources.resource_string(__name__, json_defs_file)
-  definitions_dict = json.loads(comp_string)
-
-  for name in definitions_dict:
-    if name not in ('AnalogControl', 'AnalogControlBipolar', 'CD4051'):
-      replacements[name] = filter_map_init(components, 'component', name, key_exclude='default', match_exclude=True)
-  
-  if 'display' in target:
-    replacements['dispdec'] = f'daisy::OledDisplay<{target["display"]["driver"]}> display;'
-    replacements['display'] = f"""
-    daisy::OledDisplay<{target['display']['driver']}>::Config display_config;
-    display_config.driver_config.transport_config.Defaults();
-    {"".join(map(lambda x: x, target['display'].get('config', {})))}
-    display.Init(display_config);
-      display.Fill(0);
-      display.Update();
+    Returns a tuple containing the board
+    header as a string and an information dictionary.
+
+    The dictionary provides sufficient information to
+    generate interface code, including component getters
+    and setters, audio channel count, etc.
     """
-  else:
-    replacements['display'] = ''
-
-  replacements['process'] = filter_map_template(components, 'process', key_exclude='default', match_exclude=True)
-  replacements['loopprocess'] = filter_map_template(components, 'loopprocess', key_exclude='default', match_exclude=True)
-
-  replacements['postprocess'] = filter_map_template(components, 'postprocess', key_exclude='default', match_exclude=True)
-  replacements['displayprocess'] = filter_map_template(components, 'display', key_exclude='default', match_exclude=True)
-  replacements['hidupdaterates'] = filter_map_template(components, 'updaterate', key_exclude='default', match_exclude=True)
-
-  license_string = pkg_resources.resource_string(__package__, 'resources/LICENSE').decode('utf-8')
-  replacements['license'] = '/*\n * ' + '\n * '.join([line for line in license_string.split('\n')]) + '\n */'
-
-  component_declarations = list(filter(lambda x: not x.get('default', False), components))
-  component_declarations = list(filter(lambda x: x.get('typename', '') != '', component_declarations))
-  if len(component_declarations) > 0:
-    replacements['comps'] = ";\n  ".join(map(lambda x: x['typename'].format_map(x) + ' ' + x['name'], component_declarations)) + ';'
-  non_class_declarations = list(filter(lambda x: 'non_class_decl' in x, component_declarations))
-  if len(non_class_declarations) > 0:
-    replacements['non_class_declarations'] = "\n".join(map(lambda x: x['non_class_decl'].format_map(x), non_class_declarations))
-  
-  env_opts = {"trim_blocks": True, "lstrip_blocks": True}
-
-  # Ideally, this would be what we use, but we'll need to get the jinja PackageLoader class working
-  # loader = jinja2.PackageLoader(__name__)
-  # env = jinja2.Environment(loader=loader, **env_opts)
-  # rendered_header = env.get_template('daisy.h').render(replacements)
-
-  # This following works, but is really annoying
-  header_str = pkg_resources.resource_string(__name__, os.path.join('templates', 'daisy.h'))
-  header_env = jinja2.Environment(loader=jinja2.BaseLoader(), **env_opts).from_string(header_str.decode('utf-8'))
-
-  rendered_header = header_env.render(replacements)
-  
-  # removing all unnecessary fields
-  for comp in components:
-    if 'map_init' in comp:
-      del comp['map_init']
-    if 'typename' in comp:
-      del comp['typename']
-
-  audio_info = target.get('audio', None)
-  audio_channels = audio_info.get('channels', 2) if audio_info is not None else 2
-
-  # This dictionary contains the necessary information to automatically (or manually) 
-  # write code to interface with the generated board
-  board_info = {
-    'name': target['name'],
-    'components': components,
-    'aliases': target['aliases'],
-    'channels': audio_channels
-  }
-
-  return rendered_header, board_info
+
+    target = board_description_dict
+
+    # flesh out target components:
+    components = target.get('components', {})
+    parents = target.get('parents', {})
+
+    for key in parents:
+        parents[key]['is_parent'] = True
+    components.update(parents)
+
+    seed_defs = os.path.join("resources", 'component_defs.json')
+    patchsm_defs = os.path.join("resources", 'component_defs_patchsm.json')
+    petalsm_defs = os.path.join("resources", 'component_defs_petalsm.json')
+    definitions = {'seed': seed_defs, 'patch_sm': patchsm_defs, 'petal_125b_sm': petalsm_defs}
+    som = target.get('som', 'seed')
+
+    global json_defs_file
+    try:
+        json_defs_file = definitions[som]
+    except KeyError:
+        raise NameError(f'Unkown som "{som}"')
+
+    # alphabetize by component name
+    components = sorted(components.items(), key=lambda x: x[1]['component'])
+    components = list(map(map_load, components))
+
+    # flatten pin dicts into multiple entries
+    # e.g. "pin": {"a": 12} => "pin_a": 12
+    components = [flatten_pin_dicts(comp) for comp in components]
+    components = [flatten_index_dicts(comp) for comp in components]
+
+    target['components'] = components
+    if 'name' not in target:
+        target['name'] = 'custom'
+
+    if 'aliases' not in target:
+        target['aliases'] = {}
+
+    if 'display' in target:
+        # apply defaults
+        target['display'] = {
+          'driver': "daisy::SSD130x4WireSpi128x64Driver",
+          'config': [],
+          'dim': [128, 64]
+        }
+
+        target['defines']['OOPSY_TARGET_HAS_OLED'] = 1
+        target['defines']['OOPSY_OLED_DISPLAY_WIDTH'] = target['display']['dim'][0]
+        target['defines']['OOPSY_OLED_DISPLAY_HEIGHT'] = target['display']['dim'][1]
+
+    replacements = {}
+    replacements['name'] = target['name']
+    replacements['som'] = som
+    replacements['external_codecs'] = target.get('external_codecs', [])
+
+    som_classes = {
+        'seed': 'daisy::DaisySeed',
+        'patch_sm': 'daisy::patch_sm::DaisyPatchSM',
+        'petal_125b_sm': 'daisy::Petal125BSM',
+    }
+
+    replacements['som_class'] = som_classes.get(som, som_classes['seed'])
+
+    replacements['display_conditional'] = ('#include "dev/oled_ssd130x.h"' if ('display' in target) else "")
+    replacements['target_name'] = target['name']
+    replacements['init'] = filter_map_template(components, 'init', key_exclude='default', match_exclude=True)
+
+    replacements['analogcount'] = len(list(filter_matches(
+        components, 'component', ['AnalogControl', 'AnalogControlBipolar', 'CD4051'],
+        key_exclude='default', match_exclude=True)))
+
+    replacements['init_single'] = filter_map_ctrl(
+        components, 'component', ['AnalogControl', 'AnalogControlBipolar', 'CD4051'],
+        'init_single', key_exclude='default', match_exclude=True)
+    replacements['ctrl_init'] = filter_map_ctrl(
+        components, 'component', ['AnalogControl', 'AnalogControlBipolar'],
+        'map_init', key_exclude='default', match_exclude=True)
+
+    comp_string = pkg_resources.resource_string(__name__, json_defs_file)
+    definitions_dict = json.loads(comp_string)
+
+    for name in definitions_dict:
+        if name not in ('AnalogControl', 'AnalogControlBipolar', 'CD4051'):
+            replacements[name] = filter_map_init(
+                components, 'component', name, key_exclude='default', match_exclude=True)
+
+    if 'display' in target:
+        replacements['dispdec'] = f'daisy::OledDisplay<{target["display"]["driver"]}> display;'
+        replacements['display'] = f"""
+        daisy::OledDisplay<{target['display']['driver']}>::Config display_config;
+        display_config.driver_config.transport_config.Defaults();
+        {"".join(map(lambda x: x, target['display'].get('config', {})))}
+        display.Init(display_config);
+          display.Fill(0);
+          display.Update();
+        """
+    else:
+        replacements['display'] = ''
+
+    replacements['process'] = filter_map_template(
+        components, 'process', key_exclude='default', match_exclude=True)
+    replacements['loopprocess'] = filter_map_template(
+        components, 'loopprocess', key_exclude='default', match_exclude=True)
+
+    replacements['postprocess'] = filter_map_template(
+        components, 'postprocess', key_exclude='default', match_exclude=True)
+    replacements['displayprocess'] = filter_map_template(
+        components, 'display', key_exclude='default', match_exclude=True)
+    replacements['hidupdaterates'] = filter_map_template(
+        components, 'updaterate', key_exclude='default', match_exclude=True)
+
+    license_string = pkg_resources.resource_string(__package__, 'resources/LICENSE').decode('utf-8')
+    replacements['license'] = '/*\n * ' + '\n * '.join([line for line in license_string.split('\n')]) + '\n */'
+
+    component_declarations = list(filter(lambda x: not x.get('default', False), components))
+    component_declarations = list(filter(lambda x: x.get('typename', '') != '', component_declarations))
+
+    if len(component_declarations) > 0:
+        replacements['comps'] = ";\n  ".join(
+            map(lambda x: x['typename'].format_map(x) + ' ' + x['name'], component_declarations)
+        ) + ';'
+
+    non_class_declarations = list(filter(lambda x: 'non_class_decl' in x, component_declarations))
+
+    if len(non_class_declarations) > 0:
+        replacements['non_class_declarations'] = "\n".join(
+            map(lambda x: x['non_class_decl'].format_map(x), non_class_declarations)
+        )
+
+    # env_opts = {"trim_blocks": True, "lstrip_blocks": True}
+
+    # Ideally, this would be what we use, but we'll need to get the jinja PackageLoader class working
+    # loader = jinja2.PackageLoader(__name__)
+    # env = jinja2.Environment(loader=loader, **env_opts)
+    # rendered_header = env.get_template('daisy.h').render(replacements)
+
+    # This following works, but is really annoying
+    header_str = pkg_resources.resource_string(__name__, os.path.join('templates', 'daisy.h'))
+    header_env = jinja2.Environment(
+        loader=jinja2.BaseLoader(),
+        trim_blocks=True,
+        lstrip_blocks=True
+    ).from_string(header_str.decode('utf-8'))
+
+    rendered_header = header_env.render(replacements)
+
+    # removing all unnecessary fields
+    for comp in components:
+        if 'map_init' in comp:
+            del comp['map_init']
+        if 'typename' in comp:
+            del comp['typename']
+
+    audio_info = target.get('audio', None)
+    audio_channels = audio_info.get('channels', 2) if audio_info is not None else 2
+
+    # This dictionary contains the necessary information to automatically (or manually)
+    # write code to interface with the generated board
+    board_info = {
+        'name': target['name'],
+        'components': components,
+        'aliases': target['aliases'],
+        'channels': audio_channels
+      }
+
+    return rendered_header, board_info
+
 
 def generate_header_from_file(description_file: str) -> 'tuple[str, dict]':
+    """
+    Generate a C++ Daisy board header from a JSON description file.
+
+    Returns a tuple containing the board
+    header as a string and an information dictionary.
 
-  """
-  Generate a C++ Daisy board header from a JSON description file.
+    The dictionary provides sufficient information to
+    generate interface code, including component getters
+    and setters, audio channel count, etc.
+    """
 
-  Returns a tuple containing the board 
-  header as a string and an information dictionary.
-  
-  The dictionary provides sufficient information to
-  generate interface code, including component getters
-  and setters, audio channel count, etc.
-  """
+    with open(description_file, 'rb') as file:
+        daisy_description = json.load(file)
+
+    return generate_header(daisy_description)
 
-  with open(description_file, 'rb') as file:
-    daisy_description = json.load(file)
-  
-  return generate_header(daisy_description)
 
 def generate_header_from_name(board_name: str) -> 'tuple[str, dict]':
+    """
+    Generate a C++ Daisy board header for an existing daisy board.
+
+    Returns a tuple containing the board
+    header as a string and an information dictionary.
+
+    The dictionary provides sufficient information to
+    generate interface code, including component getters
+    and setters, audio channel count, etc.
+    """
+
+    try:
+        description_file = os.path.join('resources', f'{board_name}.json')
+        daisy_description = pkg_resources.resource_string(__name__, description_file)
+        daisy_description_dict = json.loads(daisy_description)
+    except FileNotFoundError:
+        raise FileNotFoundError(f'Unknown Daisy board   "{board_name}"')
 
-  """
-  Generate a C++ Daisy board header for an existing daisy board.
-
-  Returns a tuple containing the board 
-  header as a string and an information dictionary.
-  
-  The dictionary provides sufficient information to
-  generate interface code, including component getters
-  and setters, audio channel count, etc.
-  """
-
-  try:
-    description_file = os.path.join('resources', f'{board_name}.json')
-    daisy_description = pkg_resources.resource_string(__name__, description_file)
-    daisy_description = json.loads(daisy_description)
-  except FileNotFoundError:
-    raise FileNotFoundError(f'Unknown Daisy board "{board_name}"')
-  
-  return generate_header(daisy_description)
-  
\ No newline at end of file
+    return generate_header(daisy_description_dict)
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..067155f
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,47 @@
+; Tox config
+[tox]
+envlist = flake8, mypy, py37, py38, py39, py310, py311
+skipsdist = true
+
+[gh-actions]
+python =
+    3.7: py37
+    3.8: py38
+    3.9: py39
+    3.10: py310
+    3.11: flake8, mypy, py311
+
+; Test config
+[testenv]
+deps =
+    jinja2
+    pytest-cov
+commands =
+    python -m pytest --cov-config=tox.ini --cov=json2daisy tests/
+
+[testenv:flake8]
+deps =
+    flake8
+basepython =
+    python3
+commands =
+    flake8
+
+[testenv:mypy]
+deps =
+    jinja2
+    types-setuptools
+    mypy
+basepython =
+    python3
+commands =
+    mypy .
+
+[run]
+ignore = examples/*
+cov-report = html,term
+omit = .tox/*,venv/*,tests/*,setup.py
+
+[flake8]
+max-line-length = 120
+exclude = .tox,venv
diff --git a/util/gen_iotable.py b/util/gen_iotable.py
index da9dbb2..4c19502 100644
--- a/util/gen_iotable.py
+++ b/util/gen_iotable.py
@@ -10,98 +10,103 @@
 non_templates = ['component_defaults', 'component_defaults_patchsm', 'LICENSE']
 
 clarified_components = {
-  'AnalogControl': 'Voltage Input',
-  'AnalogControlBipolar': 'Bipolar Voltage Input',
-  'CD4051AnalogControl': 'Voltage Input',
-  'RgbLed': 'RGB LED',
-  'Led': 'LED',
-  'PCA9685RgbLed': 'RGB LED',
-  'PCA9685Led': 'LED',
-  'GateOut': 'Gate Out',
-  'GateIn': 'Gate In',
-  'CVOuts': 'CV Out',
-  'Switch3': 'SPDT Switch',
-  'CD4021Switch': 'Switch'
+    'AnalogControl': 'Voltage Input',
+    'AnalogControlBipolar': 'Bipolar Voltage Input',
+    'CD4051AnalogControl': 'Voltage Input',
+    'RgbLed': 'RGB LED',
+    'Led': 'LED',
+    'PCA9685RgbLed': 'RGB LED',
+    'PCA9685Led': 'LED',
+    'GateOut': 'Gate Out',
+    'GateIn': 'Gate In',
+    'CVOuts': 'CV Out',
+    'Switch3': 'SPDT Switch',
+    'CD4021Switch': 'Switch'
 }
 
-def gen_row(items):
-  return f'| {" | ".join(items)} |\n'
-
-def gen_table(column_headers, rows):
-  for row in rows:
-    if len(row) > len(column_headers):
-      raise OverflowError("A row exceeds the given table width")
-    elif len(row) < len(column_headers):
-      row += ['---' for i in range(len(column_headers) - len(row))]
-    for i in range(len(row)):
-      if row[i] == '':
-        row[i] = '---'
-  
-  table = gen_row(column_headers)
-  table += gen_row(['---' for i in range(len(column_headers))])
-
-  for row in rows:
-    table += gen_row(row)
-  
-  return table
-
-def get_default(som, component):
-  defaults_path = path.join(json_path, 'component_defaults.json')
-  defaults_sm_path = path.join(json_path, 'component_defaults_patchsm.json')
-  if som == 'seed':
-    with open(defaults_path, 'rb') as file:
-      defaults = json.load(file)
-  elif som == 'patch_sm':
-    with open(defaults_sm_path, 'rb') as file:
-      defaults = json.load(file)
-  return defaults[component]
-
-def extract_rows(json_file):
-  with open(json_file, 'rb') as file:
-    description = json.load(file)
-
-  som = description.get('som', 'seed')
-  
-  rows = []
-  for compname, comp in description['components'].items():
-    temprow = [compname]
-    aliases = []
-    for alias, root in description['aliases'].items():
-      if root == compname:
-        aliases.append(alias)
-    temprow.append(', '.join(aliases))
-    if comp['component'] in clarified_components:
-      temprow.append(clarified_components[comp['component']])
-    else:
-      temprow.append(comp['component'])
-
-    comp_desc = get_default(som, comp['component'])
-    variants = []
-    for mapping in comp_desc['mapping']:
-      var_name = mapping['name'].format_map({'name': compname})
-      if var_name != compname and 'cvout' not in var_name:
-        variants.append(var_name)
-    temprow.append(', '.join(variants))
-
-    rows.append(temprow)
-  
-  return rows
+
+def gen_row(items: list) -> str:
+    return f'| {" | ".join(items)} |\n'
+
+
+def gen_table(column_headers: list, rows: list) -> str:
+    for row in rows:
+        if len(row) > len(column_headers):
+            raise OverflowError("A row exceeds the given table width")
+        elif len(row) < len(column_headers):
+            row += ['---' for i in range(len(column_headers) - len(row))]
+        for i in range(len(row)):
+            if row[i] == '':
+                row[i] = '---'
+
+    table = gen_row(column_headers)
+    table += gen_row(['---' for i in range(len(column_headers))])
+
+    for row in rows:
+        table += gen_row(row)
+
+    return table
+
+
+def get_default(som: str, component: str) -> dict:
+    defaults_path = path.join(json_path, 'component_defaults.json')
+    defaults_sm_path = path.join(json_path, 'component_defaults_patchsm.json')
+    if som == 'seed':
+        with open(defaults_path, 'rb') as file:
+            defaults = json.load(file)
+    elif som == 'patch_sm':
+        with open(defaults_sm_path, 'rb') as file:
+            defaults = json.load(file)
+    return defaults[component]
+
+
+def extract_rows(json_file: str) -> list:
+    with open(json_file, 'rb') as file:
+        description = json.load(file)
+
+    som = description.get('som', 'seed')
+
+    rows = []
+    for compname, comp in description['components'].items():
+        temprow = [compname]
+        aliases = []
+        for alias, root in description['aliases'].items():
+            if root == compname:
+                aliases.append(alias)
+        temprow.append(', '.join(aliases))
+        if comp['component'] in clarified_components:
+            temprow.append(clarified_components[comp['component']])
+        else:
+            temprow.append(comp['component'])
+
+        comp_desc = get_default(som, comp['component'])
+        variants = []
+        for mapping in comp_desc['mapping']:
+            var_name = mapping['name'].format_map({'name': compname})
+            if var_name != compname and 'cvout' not in var_name:
+                variants.append(var_name)
+        temprow.append(', '.join(variants))
+
+        rows.append(temprow)
+
+    return rows
+
 
 if __name__ == '__main__':
-  included_json = [item.replace('.json', '') for item in listdir(json_path)]
-  headers = ['Name', 'Aliases', 'Type', 'Variants']
-
-  tables = {}
-  for description in included_json:
-    if description not in non_templates:
-      rows = extract_rows(path.join(json_path, description + '.json'))
-      temp_table = gen_table(headers, rows)
-      tables[description] = temp_table
-  
-  tables_path = 'io_tables.md'
-  with open(tables_path, 'w') as file:
-    for name, table in tables.items():
-      file.write(f'## {name}\n\n')
-      file.write(table + '\n')
-  
-  print(f'Successfully generated table in "{tables_path}"')
\ No newline at end of file
+    included_json = [item.replace('.json', '') for item in listdir(json_path)]
+    headers = ['Name', 'Aliases', 'Type', 'Variants']
+
+    tables = {}
+    for description in included_json:
+        if description not in non_templates:
+            rows = extract_rows(path.join(json_path, description + '.json'))
+            temp_table = gen_table(headers, rows)
+            tables[description] = temp_table
+
+    tables_path = 'io_tables.md'
+    with open(tables_path, 'w') as file:
+        for name, table in tables.items():
+            file.write(f'## {name}\n\n')
+            file.write(table + '\n')
+
+    print(f'Successfully generated table in "{tables_path}"')


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..a6f0309
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,27 @@
+name: json2daisy
+
+on:
+  push:
+  pull_request:
+    branches: [ develop ]
+
+jobs:
+  build:
+
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
+
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v3
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install tox tox-gh-actions
+      - name: Run tox
+        run: tox
diff --git a/tests/data/integration/expected_field.h b/tests/data/integration/expected_field.h
index a7ba085..49de737 100644
--- a/tests/data/integration/expected_field.h
+++ b/tests/data/integration/expected_field.h
@@ -119,13 +119,13 @@ struct DaisyField {
 
     // Display
     
-    daisy::OledDisplay<daisy::SSD130x4WireSpi128x64Driver>::Config display_config;
-    display_config.driver_config.transport_config.Defaults();
-    
-    display.Init(display_config);
-      display.Fill(0);
-      display.Update();
-     
+        daisy::OledDisplay<daisy::SSD130x4WireSpi128x64Driver>::Config display_config;
+        display_config.driver_config.transport_config.Defaults();
+        
+        display.Init(display_config);
+          display.Fill(0);
+          display.Update();
+         
 
     som.adc.Start();
   }
diff --git a/tests/data/integration/expected_patch.h b/tests/data/integration/expected_patch.h
index 7c9137b..b26f099 100644
--- a/tests/data/integration/expected_patch.h
+++ b/tests/data/integration/expected_patch.h
@@ -95,13 +95,13 @@ struct DaisyPatch {
 
     // Display
     
-    daisy::OledDisplay<daisy::SSD130x4WireSpi128x64Driver>::Config display_config;
-    display_config.driver_config.transport_config.Defaults();
-    
-    display.Init(display_config);
-      display.Fill(0);
-      display.Update();
-     
+        daisy::OledDisplay<daisy::SSD130x4WireSpi128x64Driver>::Config display_config;
+        display_config.driver_config.transport_config.Defaults();
+        
+        display.Init(display_config);
+          display.Fill(0);
+          display.Update();
+         
 
     // External Codec Initialization
     daisy::SaiHandle::Config sai_config[2];
diff --git a/tests/data/integration/expected_pod.h b/tests/data/integration/expected_pod.h
index de7ae94..cba8f7d 100644
--- a/tests/data/integration/expected_pod.h
+++ b/tests/data/integration/expected_pod.h
@@ -74,13 +74,13 @@ struct DaisyPod {
 
     // Display
     
-    daisy::OledDisplay<daisy::SSD130x4WireSpi128x64Driver>::Config display_config;
-    display_config.driver_config.transport_config.Defaults();
-    
-    display.Init(display_config);
-      display.Fill(0);
-      display.Update();
-     
+        daisy::OledDisplay<daisy::SSD130x4WireSpi128x64Driver>::Config display_config;
+        display_config.driver_config.transport_config.Defaults();
+        
+        display.Init(display_config);
+          display.Fill(0);
+          display.Update();
+         
 
     som.adc.Start();
   }
diff --git a/tests/gen_integration_tests.py b/tests/gen_integration_tests.py
index 0153dec..26408ca 100644
--- a/tests/gen_integration_tests.py
+++ b/tests/gen_integration_tests.py
@@ -17,10 +17,10 @@
 data_path = path.join(abs_path, 'data')
 integration_path = path.join(data_path, 'integration')
 
-import json2daisy
+import json2daisy  # noqa
 
 if path.isdir(integration_path):
-  shutil.rmtree(integration_path)
+    shutil.rmtree(integration_path)
 mkdir(integration_path)
 
 non_templates = ['component_defs', 'component_defs_patchsm', 'LICENSE']
@@ -51,12 +51,12 @@ def test_{name}(self):
 """
 
 for template_name in included_json:
-  if template_name not in non_templates:
-    header, info = json2daisy.generate_header_from_name(template_name)
-    template_test_path = path.join('integration', f'expected_{info["name"]}.h')
-    with open(path.join(data_path, template_test_path), 'w') as file:
-      file.write(header)
-    test_file += test_template.format_map({'name': info['name']})
+    if template_name not in non_templates:
+        header, info = json2daisy.generate_header_from_name(template_name)
+        template_test_path = path.join('integration', f'expected_{info["name"]}.h')
+        with open(path.join(data_path, template_test_path), 'w') as file:
+            file.write(header)
+        test_file += test_template.format_map({'name': info['name']})
 
 with open(path.join(abs_path, 'integration_test.py'), 'w') as file:
-  file.write(test_file)
+    file.write(test_file)
diff --git a/tests/integration_test.py b/tests/integration_test.py
index 1c5fc47..17ce316 100644
--- a/tests/integration_test.py
+++ b/tests/integration_test.py
@@ -8,7 +8,8 @@
 # Grabbing the absolute path to test data
 data_path = path.join(path.dirname(path.abspath(__file__)), 'data')
 
-import json2daisy
+import json2daisy  # noqa
+
 
 class TestIntegration(unittest.TestCase):
 
@@ -16,34 +17,34 @@ def test_patch(self):
         self.maxDiff = None
         header, info = json2daisy.generate_header_from_name('patch')
         with open(path.join(data_path, 'integration', 'expected_patch.h'), 'r') as file:
-          self.assertEqual(header, file.read(), 'The output string should match "expected_patch.h" exactly')
+            self.assertEqual(header, file.read(), 'The output string should match "expected_patch.h" exactly')
 
     def test_patch_init(self):
         self.maxDiff = None
         header, info = json2daisy.generate_header_from_name('patch_init')
         with open(path.join(data_path, 'integration', 'expected_patch_init.h'), 'r') as file:
-          self.assertEqual(header, file.read(), 'The output string should match "expected_patch_init.h" exactly')
+            self.assertEqual(header, file.read(), 'The output string should match "expected_patch_init.h" exactly')
 
     def test_petal(self):
         self.maxDiff = None
         header, info = json2daisy.generate_header_from_name('petal')
         with open(path.join(data_path, 'integration', 'expected_petal.h'), 'r') as file:
-          self.assertEqual(header, file.read(), 'The output string should match "expected_petal.h" exactly')
+            self.assertEqual(header, file.read(), 'The output string should match "expected_petal.h" exactly')
 
     def test_petal_125b_sm(self):
         self.maxDiff = None
         header, info = json2daisy.generate_header_from_name('petal_125b_sm')
         with open(path.join(data_path, 'integration', 'expected_petal_125b_sm.h'), 'r') as file:
-          self.assertEqual(header, file.read(), 'The output string should match "expected_petal_125b_sm.h" exactly')
+            self.assertEqual(header, file.read(), 'The output string should match "expected_petal_125b_sm.h" exactly')
 
     def test_pod(self):
         self.maxDiff = None
         header, info = json2daisy.generate_header_from_name('pod')
         with open(path.join(data_path, 'integration', 'expected_pod.h'), 'r') as file:
-          self.assertEqual(header, file.read(), 'The output string should match "expected_pod.h" exactly')
+            self.assertEqual(header, file.read(), 'The output string should match "expected_pod.h" exactly')
 
     def test_field(self):
         self.maxDiff = None
         header, info = json2daisy.generate_header_from_name('field')
         with open(path.join(data_path, 'integration', 'expected_field.h'), 'r') as file:
-          self.assertEqual(header, file.read(), 'The output string should match "expected_field.h" exactly')
+            self.assertEqual(header, file.read(), 'The output string should match "expected_field.h" exactly')
diff --git a/tests/unit_test.py b/tests/unit_test.py
index c4384b7..628f7b4 100644
--- a/tests/unit_test.py
+++ b/tests/unit_test.py
@@ -7,64 +7,66 @@
 # Grabbing the absolute path to test data
 data_path = path.join(path.dirname(path.abspath(__file__)), 'data')
 
-import json2daisy
+import json2daisy  # noqa
+
 
 # A simple comparison for strings / literals / dicts within dicts
-def compare_dicts(d1, d2):
-  for key, item in d1.items():
-    try:
-      if isinstance(item, dict):
-        if not compare_dicts(item, d2[key]):
-          return False
-      else:
-        if item != d2[key]:
-          return False
-    except (KeyError, IndexError) as e:
-      return False
-  return True
+def compare_dicts(d1: dict, d2: dict) -> bool:
+    for key, item in d1.items():
+        try:
+            if isinstance(item, dict):
+                if not compare_dicts(item, d2[key]):
+                    return False
+            else:
+                if item != d2[key]:
+                    return False
+        except (KeyError, IndexError) as e:
+            print(f"Exception: {e}")
+            return False
+    return True
 
 
 class TestFunctions(unittest.TestCase):
 
-  def test_compare_dicts(self):
-    d1_true = {'key1': 1, 'key2': {'nested_key': 2}}
-    d2_true = {'key1': 1, 'key2': {'nested_key': 2}}
-    d2_false = {'key1': 1, 'key2': {'nested_key': 3}}
-    d2_false_2 = {'key1': 1}
-    self.assertTrue(compare_dicts(d1_true, d2_true))
-    self.assertFalse(compare_dicts(d1_true, d2_false))
-    self.assertFalse(compare_dicts(d1_true, d2_false_2))
+    def test_compare_dicts(self):
+        d1_true = {'key1': 1, 'key2': {'nested_key': 2}}
+        d2_true = {'key1': 1, 'key2': {'nested_key': 2}}
+        d2_false = {'key1': 1, 'key2': {'nested_key': 3}}
+        d2_false_2 = {'key1': 1}
+        self.assertTrue(compare_dicts(d1_true, d2_true))
+        self.assertFalse(compare_dicts(d1_true, d2_false))
+        self.assertFalse(compare_dicts(d1_true, d2_false_2))
 
-  def test_flatten_pin_dicts(self):
-    input_component = {
-      'name': 'example',
-      'component': 'Switch',
-      'pin': {'a': 1, 'b': 2},
-      'index': {'a': 1, 'b': 2}
-    }
-    expected_output = {
-      'name': 'example',
-      'component': 'Switch',
-      'pin_a': 1,
-      'pin_b': 2,
-      'index': {'a': 1, 'b': 2}
-    }
-    test_output = json2daisy.flatten_pin_dicts(input_component)
-    self.assertTrue(compare_dicts(test_output, expected_output))
+    def test_flatten_pin_dicts(self):
+        input_component = {
+            'name': 'example',
+            'component': 'Switch',
+            'pin': {'a': 1, 'b': 2},
+            'index': {'a': 1, 'b': 2}
+        }
+        expected_output = {
+            'name': 'example',
+            'component': 'Switch',
+            'pin_a': 1,
+            'pin_b': 2,
+            'index': {'a': 1, 'b': 2}
+        }
+        test_output = json2daisy.flatten_pin_dicts(input_component)
+        self.assertTrue(compare_dicts(test_output, expected_output))
 
-  def test_flatten_index_dicts(self):
-    input_component = {
-      'name': 'example',
-      'component': 'Switch',
-      'pin': {'a': 1, 'b': 2},
-      'index': {'a': 1, 'b': 2}
-    }
-    expected_output = {
-      'name': 'example',
-      'component': 'Switch',
-      'pin': {'a': 1, 'b': 2},
-      'index_a': 1,
-      'index_b': 2
-    }
-    test_output = json2daisy.flatten_index_dicts(input_component)
-    self.assertTrue(compare_dicts(test_output, expected_output))
+    def test_flatten_index_dicts(self):
+        input_component = {
+            'name': 'example',
+            'component': 'Switch',
+            'pin': {'a': 1, 'b': 2},
+            'index': {'a': 1, 'b': 2}
+        }
+        expected_output = {
+            'name': 'example',
+            'component': 'Switch',
+            'pin': {'a': 1, 'b': 2},
+            'index_a': 1,
+            'index_b': 2
+        }
+        test_output = json2daisy.flatten_index_dicts(input_component)
+        self.assertTrue(compare_dicts(test_output, expected_output))