2022-05-23 09:30:13 -04:00
|
|
|
# SPDX-FileCopyrightText: 2022 Espressif Systems (Shanghai) CO LTD
|
|
|
|
# SPDX-License-Identifier: Apache-2.0
|
2019-10-03 12:26:44 -04:00
|
|
|
import os
|
|
|
|
import re
|
|
|
|
import subprocess
|
|
|
|
import sys
|
2019-11-04 10:49:14 -05:00
|
|
|
from io import open
|
2022-06-03 08:46:56 -04:00
|
|
|
from typing import Any, List
|
2019-10-03 12:26:44 -04:00
|
|
|
|
2021-01-25 21:49:01 -05:00
|
|
|
import click
|
|
|
|
|
2019-10-24 07:20:25 -04:00
|
|
|
from .constants import GENERATORS
|
2019-10-03 12:26:44 -04:00
|
|
|
from .errors import FatalError
|
|
|
|
|
|
|
|
|
|
|
|
def executable_exists(args):
|
|
|
|
try:
|
|
|
|
subprocess.check_output(args)
|
|
|
|
return True
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def realpath(path):
|
|
|
|
"""
|
|
|
|
Return the cannonical path with normalized case.
|
|
|
|
|
|
|
|
It is useful on Windows to comparision paths in case-insensitive manner.
|
|
|
|
On Unix and Mac OS X it works as `os.path.realpath()` only.
|
|
|
|
"""
|
|
|
|
return os.path.normcase(os.path.realpath(path))
|
|
|
|
|
|
|
|
|
|
|
|
def _idf_version_from_cmake():
|
2021-01-25 21:49:01 -05:00
|
|
|
version_path = os.path.join(os.environ['IDF_PATH'], 'tools/cmake/version.cmake')
|
|
|
|
regex = re.compile(r'^\s*set\s*\(\s*IDF_VERSION_([A-Z]{5})\s+(\d+)')
|
2019-10-03 12:26:44 -04:00
|
|
|
ver = {}
|
|
|
|
try:
|
|
|
|
with open(version_path) as f:
|
|
|
|
for line in f:
|
|
|
|
m = regex.match(line)
|
|
|
|
|
|
|
|
if m:
|
|
|
|
ver[m.group(1)] = m.group(2)
|
|
|
|
|
2021-01-25 21:49:01 -05:00
|
|
|
return 'v%s.%s.%s' % (ver['MAJOR'], ver['MINOR'], ver['PATCH'])
|
2019-10-03 12:26:44 -04:00
|
|
|
except (KeyError, OSError):
|
2021-01-25 21:49:01 -05:00
|
|
|
sys.stderr.write('WARNING: Cannot find ESP-IDF version in version.cmake\n')
|
2019-10-03 12:26:44 -04:00
|
|
|
return None
|
|
|
|
|
|
|
|
|
2021-06-03 11:14:38 -04:00
|
|
|
def get_target(path, sdkconfig_filename='sdkconfig'):
|
|
|
|
path = os.path.join(path, sdkconfig_filename)
|
|
|
|
return get_sdkconfig_value(path, 'CONFIG_IDF_TARGET')
|
|
|
|
|
|
|
|
|
2019-10-03 12:26:44 -04:00
|
|
|
def idf_version():
|
|
|
|
"""Print version of ESP-IDF"""
|
|
|
|
|
|
|
|
# Try to get version from git:
|
|
|
|
try:
|
|
|
|
version = subprocess.check_output([
|
2021-01-25 21:49:01 -05:00
|
|
|
'git',
|
|
|
|
'--git-dir=%s' % os.path.join(os.environ['IDF_PATH'], '.git'),
|
2021-02-17 08:04:04 -05:00
|
|
|
'--work-tree=%s' % os.environ['IDF_PATH'],
|
|
|
|
'describe', '--tags', '--dirty', '--match', 'v*.*',
|
2019-10-03 12:26:44 -04:00
|
|
|
]).decode('utf-8', 'ignore').strip()
|
|
|
|
except (subprocess.CalledProcessError, UnicodeError):
|
|
|
|
# if failed, then try to parse cmake.version file
|
2021-01-25 21:49:01 -05:00
|
|
|
sys.stderr.write('WARNING: Git version unavailable, reading from source\n')
|
2019-10-03 12:26:44 -04:00
|
|
|
version = _idf_version_from_cmake()
|
|
|
|
|
|
|
|
return version
|
|
|
|
|
|
|
|
|
2021-08-18 09:14:47 -04:00
|
|
|
def run_tool(tool_name, args, cwd, env=dict(), custom_error_handler=None):
|
2019-10-03 12:26:44 -04:00
|
|
|
def quote_arg(arg):
|
|
|
|
" Quote 'arg' if necessary "
|
2021-01-25 21:49:01 -05:00
|
|
|
if ' ' in arg and not (arg.startswith('"') or arg.startswith("'")):
|
2019-10-03 12:26:44 -04:00
|
|
|
return "'" + arg + "'"
|
|
|
|
return arg
|
|
|
|
|
2020-07-09 02:23:02 -04:00
|
|
|
args = [str(arg) for arg in args]
|
2021-01-25 21:49:01 -05:00
|
|
|
display_args = ' '.join(quote_arg(arg) for arg in args)
|
|
|
|
print('Running %s in directory %s' % (tool_name, quote_arg(cwd)))
|
2019-10-03 12:26:44 -04:00
|
|
|
print('Executing "%s"...' % str(display_args))
|
2019-12-19 20:55:39 -05:00
|
|
|
|
|
|
|
env_copy = dict(os.environ)
|
|
|
|
env_copy.update(env)
|
|
|
|
|
2020-01-21 13:45:46 -05:00
|
|
|
if sys.version_info[0] < 3:
|
|
|
|
# The subprocess lib cannot accept environment variables as "unicode". Convert to str.
|
|
|
|
# This encoding step is required only in Python 2.
|
|
|
|
for (key, val) in env_copy.items():
|
|
|
|
if not isinstance(val, str):
|
|
|
|
env_copy[key] = val.encode(sys.getfilesystemencoding() or 'utf-8')
|
|
|
|
|
2019-10-03 12:26:44 -04:00
|
|
|
try:
|
|
|
|
# Note: we explicitly pass in os.environ here, as we may have set IDF_PATH there during startup
|
2019-12-19 20:55:39 -05:00
|
|
|
subprocess.check_call(args, env=env_copy, cwd=cwd)
|
2019-10-03 12:26:44 -04:00
|
|
|
except subprocess.CalledProcessError as e:
|
2021-08-18 09:14:47 -04:00
|
|
|
if custom_error_handler:
|
|
|
|
custom_error_handler(e)
|
|
|
|
else:
|
|
|
|
raise FatalError('%s failed with exit code %d' % (tool_name, e.returncode))
|
2019-10-03 12:26:44 -04:00
|
|
|
|
|
|
|
|
2021-08-18 09:14:47 -04:00
|
|
|
def run_target(target_name, args, env=dict(), custom_error_handler=None):
|
2021-01-25 21:49:01 -05:00
|
|
|
generator_cmd = GENERATORS[args.generator]['command']
|
2019-12-19 20:55:39 -05:00
|
|
|
|
|
|
|
if args.verbose:
|
2021-01-25 21:49:01 -05:00
|
|
|
generator_cmd += [GENERATORS[args.generator]['verbose_flag']]
|
2019-12-19 20:55:39 -05:00
|
|
|
|
2021-08-18 09:14:47 -04:00
|
|
|
run_tool(generator_cmd[0], generator_cmd + [target_name], args.build_dir, env, custom_error_handler)
|
2019-12-19 20:55:39 -05:00
|
|
|
|
|
|
|
|
2019-10-03 12:26:44 -04:00
|
|
|
def _strip_quotes(value, regexp=re.compile(r"^\"(.*)\"$|^'(.*)'$|^(.*)$")):
|
|
|
|
"""
|
|
|
|
Strip quotes like CMake does during parsing cache entries
|
|
|
|
"""
|
|
|
|
|
|
|
|
return [x for x in regexp.match(value).groups() if x is not None][0].rstrip()
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_cmakecache(path):
|
|
|
|
"""
|
|
|
|
Parse the CMakeCache file at 'path'.
|
|
|
|
|
|
|
|
Returns a dict of name:value.
|
|
|
|
|
|
|
|
CMakeCache entries also each have a "type", but this is currently ignored.
|
|
|
|
"""
|
|
|
|
result = {}
|
2019-11-04 10:49:14 -05:00
|
|
|
with open(path, encoding='utf-8') as f:
|
2019-10-03 12:26:44 -04:00
|
|
|
for line in f:
|
|
|
|
# cmake cache lines look like: CMAKE_CXX_FLAGS_DEBUG:STRING=-g
|
|
|
|
# groups are name, type, value
|
2021-01-25 21:49:01 -05:00
|
|
|
m = re.match(r'^([^#/:=]+):([^:=]+)=(.*)\n$', line)
|
2019-10-03 12:26:44 -04:00
|
|
|
if m:
|
|
|
|
result[m.group(1)] = m.group(3)
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
def _new_cmakecache_entries(cache_path, new_cache_entries):
|
|
|
|
if not os.path.exists(cache_path):
|
|
|
|
return True
|
|
|
|
|
|
|
|
if new_cache_entries:
|
|
|
|
current_cache = _parse_cmakecache(cache_path)
|
|
|
|
|
|
|
|
for entry in new_cache_entries:
|
2021-01-25 21:49:01 -05:00
|
|
|
key, value = entry.split('=', 1)
|
2019-10-03 12:26:44 -04:00
|
|
|
current_value = current_cache.get(key, None)
|
|
|
|
if current_value is None or _strip_quotes(value) != current_value:
|
|
|
|
return True
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def _detect_cmake_generator(prog_name):
|
|
|
|
"""
|
|
|
|
Find the default cmake generator, if none was specified. Raises an exception if no valid generator is found.
|
|
|
|
"""
|
2019-11-08 10:46:02 -05:00
|
|
|
for (generator_name, generator) in GENERATORS.items():
|
2021-01-25 21:49:01 -05:00
|
|
|
if executable_exists(generator['version']):
|
2019-11-08 10:46:02 -05:00
|
|
|
return generator_name
|
2019-10-03 12:26:44 -04:00
|
|
|
raise FatalError("To use %s, either the 'ninja' or 'GNU make' build tool must be available in the PATH" % prog_name)
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_build_directory(args, prog_name, always_run_cmake=False):
|
|
|
|
"""Check the build directory exists and that cmake has been run there.
|
|
|
|
|
|
|
|
If this isn't the case, create the build directory (if necessary) and
|
|
|
|
do an initial cmake run to configure it.
|
|
|
|
|
|
|
|
This function will also check args.generator parameter. If the parameter is incompatible with
|
|
|
|
the build directory, an error is raised. If the parameter is None, this function will set it to
|
|
|
|
an auto-detected default generator or to the value already configured in the build directory.
|
|
|
|
"""
|
|
|
|
project_dir = args.project_dir
|
|
|
|
# Verify the project directory
|
|
|
|
if not os.path.isdir(project_dir):
|
|
|
|
if not os.path.exists(project_dir):
|
2021-01-25 21:49:01 -05:00
|
|
|
raise FatalError('Project directory %s does not exist' % project_dir)
|
2019-10-03 12:26:44 -04:00
|
|
|
else:
|
2021-01-25 21:49:01 -05:00
|
|
|
raise FatalError('%s must be a project directory' % project_dir)
|
|
|
|
if not os.path.exists(os.path.join(project_dir, 'CMakeLists.txt')):
|
|
|
|
raise FatalError('CMakeLists.txt not found in project directory %s' % project_dir)
|
2019-10-03 12:26:44 -04:00
|
|
|
|
|
|
|
# Verify/create the build directory
|
|
|
|
build_dir = args.build_dir
|
|
|
|
if not os.path.isdir(build_dir):
|
|
|
|
os.makedirs(build_dir)
|
2020-01-31 05:08:22 -05:00
|
|
|
|
|
|
|
# Parse CMakeCache, if it exists
|
2021-01-25 21:49:01 -05:00
|
|
|
cache_path = os.path.join(build_dir, 'CMakeCache.txt')
|
2020-01-31 05:08:22 -05:00
|
|
|
cache = _parse_cmakecache(cache_path) if os.path.exists(cache_path) else {}
|
|
|
|
|
|
|
|
# Validate or set IDF_TARGET
|
|
|
|
_guess_or_check_idf_target(args, prog_name, cache)
|
2019-10-03 12:26:44 -04:00
|
|
|
|
2021-01-25 21:49:01 -05:00
|
|
|
args.define_cache_entry.append('CCACHE_ENABLE=%d' % args.ccache)
|
2019-10-03 12:26:44 -04:00
|
|
|
|
|
|
|
if always_run_cmake or _new_cmakecache_entries(cache_path, args.define_cache_entry):
|
|
|
|
if args.generator is None:
|
|
|
|
args.generator = _detect_cmake_generator(prog_name)
|
|
|
|
try:
|
|
|
|
cmake_args = [
|
2021-01-25 21:49:01 -05:00
|
|
|
'cmake',
|
|
|
|
'-G',
|
2019-10-03 12:26:44 -04:00
|
|
|
args.generator,
|
2021-01-25 21:49:01 -05:00
|
|
|
'-DPYTHON_DEPS_CHECKED=1',
|
|
|
|
'-DESP_PLATFORM=1',
|
2019-10-03 12:26:44 -04:00
|
|
|
]
|
2020-08-25 03:04:18 -04:00
|
|
|
if args.cmake_warn_uninitialized:
|
2021-01-25 21:49:01 -05:00
|
|
|
cmake_args += ['--warn-uninitialized']
|
2019-10-03 12:26:44 -04:00
|
|
|
|
|
|
|
if args.define_cache_entry:
|
2021-01-25 21:49:01 -05:00
|
|
|
cmake_args += ['-D' + d for d in args.define_cache_entry]
|
2019-10-03 12:26:44 -04:00
|
|
|
cmake_args += [project_dir]
|
|
|
|
|
2021-01-25 21:49:01 -05:00
|
|
|
run_tool('cmake', cmake_args, cwd=args.build_dir)
|
2019-10-03 12:26:44 -04:00
|
|
|
except Exception:
|
|
|
|
# don't allow partially valid CMakeCache.txt files,
|
|
|
|
# to keep the "should I run cmake?" logic simple
|
|
|
|
if os.path.exists(cache_path):
|
|
|
|
os.remove(cache_path)
|
|
|
|
raise
|
|
|
|
|
2020-03-05 08:53:47 -05:00
|
|
|
# need to update cache so subsequent access in this method would reflect the result of the previous cmake run
|
|
|
|
cache = _parse_cmakecache(cache_path) if os.path.exists(cache_path) else {}
|
|
|
|
|
2019-10-03 12:26:44 -04:00
|
|
|
try:
|
2021-01-25 21:49:01 -05:00
|
|
|
generator = cache['CMAKE_GENERATOR']
|
2019-10-03 12:26:44 -04:00
|
|
|
except KeyError:
|
|
|
|
generator = _detect_cmake_generator(prog_name)
|
|
|
|
if args.generator is None:
|
|
|
|
args.generator = (generator) # reuse the previously configured generator, if none was given
|
|
|
|
if generator != args.generator:
|
|
|
|
raise FatalError("Build is configured for generator '%s' not '%s'. Run '%s fullclean' to start again." %
|
|
|
|
(generator, args.generator, prog_name))
|
|
|
|
|
|
|
|
try:
|
2021-01-25 21:49:01 -05:00
|
|
|
home_dir = cache['CMAKE_HOME_DIRECTORY']
|
2019-10-03 12:26:44 -04:00
|
|
|
if realpath(home_dir) != realpath(project_dir):
|
|
|
|
raise FatalError(
|
|
|
|
"Build directory '%s' configured for project '%s' not '%s'. Run '%s fullclean' to start again." %
|
|
|
|
(build_dir, realpath(home_dir), realpath(project_dir), prog_name))
|
|
|
|
except KeyError:
|
|
|
|
pass # if cmake failed part way, CMAKE_HOME_DIRECTORY may not be set yet
|
|
|
|
|
|
|
|
|
|
|
|
def merge_action_lists(*action_lists):
|
|
|
|
merged_actions = {
|
2021-01-25 21:49:01 -05:00
|
|
|
'global_options': [],
|
|
|
|
'actions': {},
|
|
|
|
'global_action_callbacks': [],
|
2019-10-03 12:26:44 -04:00
|
|
|
}
|
|
|
|
for action_list in action_lists:
|
2021-01-25 21:49:01 -05:00
|
|
|
merged_actions['global_options'].extend(action_list.get('global_options', []))
|
|
|
|
merged_actions['actions'].update(action_list.get('actions', {}))
|
|
|
|
merged_actions['global_action_callbacks'].extend(action_list.get('global_action_callbacks', []))
|
2019-10-03 12:26:44 -04:00
|
|
|
return merged_actions
|
2020-01-30 11:18:20 -05:00
|
|
|
|
|
|
|
|
|
|
|
def get_sdkconfig_value(sdkconfig_file, key):
|
|
|
|
"""
|
|
|
|
Return the value of given key from sdkconfig_file.
|
|
|
|
If sdkconfig_file does not exist or the option is not present, returns None.
|
|
|
|
"""
|
2021-01-25 21:49:01 -05:00
|
|
|
assert key.startswith('CONFIG_')
|
2020-01-30 11:18:20 -05:00
|
|
|
if not os.path.exists(sdkconfig_file):
|
|
|
|
return None
|
|
|
|
# keep track of the last seen value for the given key
|
|
|
|
value = None
|
|
|
|
# if the value is quoted, this excludes the quotes from the value
|
|
|
|
pattern = re.compile(r"^{}=\"?([^\"]*)\"?$".format(key))
|
2021-01-25 21:49:01 -05:00
|
|
|
with open(sdkconfig_file, 'r') as f:
|
2020-01-30 11:18:20 -05:00
|
|
|
for line in f:
|
|
|
|
match = re.match(pattern, line)
|
|
|
|
if match:
|
|
|
|
value = match.group(1)
|
|
|
|
return value
|
2020-01-31 05:08:22 -05:00
|
|
|
|
|
|
|
|
2020-04-06 10:41:44 -04:00
|
|
|
def is_target_supported(project_path, supported_targets):
|
|
|
|
"""
|
|
|
|
Returns True if the active target is supported, or False otherwise.
|
|
|
|
"""
|
2021-06-03 11:14:38 -04:00
|
|
|
return get_target(project_path) in supported_targets
|
2020-04-06 10:41:44 -04:00
|
|
|
|
|
|
|
|
2020-01-31 05:08:22 -05:00
|
|
|
def _guess_or_check_idf_target(args, prog_name, cache):
|
|
|
|
"""
|
|
|
|
If CMakeCache.txt doesn't exist, and IDF_TARGET is not set in the environment, guess the value from
|
|
|
|
sdkconfig or sdkconfig.defaults, and pass it to CMake in IDF_TARGET variable.
|
|
|
|
|
|
|
|
Otherwise, cross-check the three settings (sdkconfig, CMakeCache, environment) and if there is
|
|
|
|
mismatch, fail with instructions on how to fix this.
|
|
|
|
"""
|
|
|
|
# Default locations of sdkconfig files.
|
|
|
|
# FIXME: they may be overridden in the project or by a CMake variable (IDF-1369).
|
|
|
|
# These are used to guess the target from sdkconfig, or set the default target by sdkconfig.defaults.
|
2021-06-03 11:14:38 -04:00
|
|
|
idf_target_from_sdkconfig = get_target(args.project_dir)
|
|
|
|
idf_target_from_sdkconfig_defaults = get_target(args.project_dir, 'sdkconfig.defaults')
|
2021-01-25 21:49:01 -05:00
|
|
|
idf_target_from_env = os.environ.get('IDF_TARGET')
|
|
|
|
idf_target_from_cache = cache.get('IDF_TARGET')
|
2020-01-31 05:08:22 -05:00
|
|
|
|
|
|
|
if not cache and not idf_target_from_env:
|
|
|
|
# CMakeCache.txt does not exist yet, and IDF_TARGET is not set in the environment.
|
|
|
|
guessed_target = idf_target_from_sdkconfig or idf_target_from_sdkconfig_defaults
|
|
|
|
if guessed_target:
|
|
|
|
if args.verbose:
|
|
|
|
print("IDF_TARGET is not set, guessed '%s' from sdkconfig" % (guessed_target))
|
2021-01-25 21:49:01 -05:00
|
|
|
args.define_cache_entry.append('IDF_TARGET=' + guessed_target)
|
2020-01-31 05:08:22 -05:00
|
|
|
|
|
|
|
elif idf_target_from_env:
|
|
|
|
# Let's check that IDF_TARGET values are consistent
|
|
|
|
if idf_target_from_sdkconfig and idf_target_from_sdkconfig != idf_target_from_env:
|
|
|
|
raise FatalError("Project sdkconfig was generated for target '{t_conf}', but environment variable IDF_TARGET "
|
|
|
|
"is set to '{t_env}'. Run '{prog} set-target {t_env}' to generate new sdkconfig file for target {t_env}."
|
|
|
|
.format(t_conf=idf_target_from_sdkconfig, t_env=idf_target_from_env, prog=prog_name))
|
|
|
|
|
|
|
|
if idf_target_from_cache and idf_target_from_cache != idf_target_from_env:
|
|
|
|
raise FatalError("Target settings are not consistent: '{t_env}' in the environment, '{t_cache}' in CMakeCache.txt. "
|
|
|
|
"Run '{prog} fullclean' to start again."
|
|
|
|
.format(t_env=idf_target_from_env, t_cache=idf_target_from_cache, prog=prog_name))
|
|
|
|
|
|
|
|
elif idf_target_from_cache and idf_target_from_sdkconfig and idf_target_from_cache != idf_target_from_sdkconfig:
|
|
|
|
# This shouldn't happen, unless the user manually edits CMakeCache.txt or sdkconfig, but let's check anyway.
|
|
|
|
raise FatalError("Project sdkconfig was generated for target '{t_conf}', but CMakeCache.txt contains '{t_cache}'. "
|
|
|
|
"To keep the setting in sdkconfig ({t_conf}) and re-generate CMakeCache.txt, run '{prog} fullclean'. "
|
|
|
|
"To re-generate sdkconfig for '{t_cache}' target, run '{prog} set-target {t_cache}'."
|
|
|
|
.format(t_conf=idf_target_from_sdkconfig, t_cache=idf_target_from_cache, prog=prog_name))
|
2020-04-03 10:21:24 -04:00
|
|
|
|
|
|
|
|
|
|
|
class TargetChoice(click.Choice):
|
|
|
|
"""
|
|
|
|
A version of click.Choice with two special features:
|
|
|
|
- ignores hyphens
|
|
|
|
- not case sensitive
|
|
|
|
"""
|
2022-06-03 08:46:56 -04:00
|
|
|
def __init__(self, choices: List) -> None:
|
2020-04-03 10:21:24 -04:00
|
|
|
super(TargetChoice, self).__init__(choices, case_sensitive=False)
|
|
|
|
|
2022-06-03 08:46:56 -04:00
|
|
|
def convert(self, value: Any, param: click.Parameter, ctx: click.Context) -> Any:
|
|
|
|
def normalize(string: str) -> str:
|
|
|
|
return string.lower().replace('-', '')
|
2020-04-03 10:21:24 -04:00
|
|
|
|
|
|
|
saved_token_normalize_func = ctx.token_normalize_func
|
|
|
|
ctx.token_normalize_func = normalize
|
|
|
|
|
|
|
|
try:
|
|
|
|
return super(TargetChoice, self).convert(value, param, ctx)
|
|
|
|
finally:
|
|
|
|
ctx.token_normalize_func = saved_token_normalize_func
|
2022-06-03 08:46:56 -04:00
|
|
|
|
|
|
|
|
|
|
|
class PropertyDict(dict):
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
|
|
if name in self:
|
|
|
|
return self[name]
|
|
|
|
else:
|
|
|
|
raise AttributeError("'PropertyDict' object has no attribute '%s'" % name)
|
|
|
|
|
|
|
|
def __setattr__(self, name: str, value: Any) -> None:
|
|
|
|
self[name] = value
|
|
|
|
|
|
|
|
def __delattr__(self, name: str) -> None:
|
|
|
|
if name in self:
|
|
|
|
del self[name]
|
|
|
|
else:
|
|
|
|
raise AttributeError("'PropertyDict' object has no attribute '%s'" % name)
|