more f-strings too complex to be caught by pyupgrade

pull/8968/head
Eli Schwartz 4 years ago committed by Jussi Pakkanen
parent 566efba216
commit dd31891c1f
  1. 2
      mesonbuild/backend/xcodebackend.py
  2. 4
      mesonbuild/cmake/traceparser.py
  3. 7
      mesonbuild/compilers/cpp.py
  4. 12
      mesonbuild/compilers/detect.py
  5. 5
      mesonbuild/compilers/fortran.py
  6. 2
      mesonbuild/compilers/mixins/gnu.py
  7. 4
      mesonbuild/compilers/mixins/islinker.py
  8. 18
      mesonbuild/coredata.py
  9. 4
      mesonbuild/dependencies/base.py
  10. 3
      mesonbuild/dependencies/configtool.py
  11. 4
      mesonbuild/dependencies/cuda.py
  12. 7
      mesonbuild/dependencies/misc.py
  13. 4
      mesonbuild/environment.py
  14. 27
      mesonbuild/interpreter/interpreter.py
  15. 4
      mesonbuild/interpreter/interpreterobjects.py
  16. 8
      mesonbuild/interpreter/mesonmain.py
  17. 12
      mesonbuild/interpreterbase/interpreterbase.py
  18. 8
      mesonbuild/linkers/linkers.py
  19. 4
      mesonbuild/mcompile.py
  20. 34
      mesonbuild/mesonlib/universal.py

@ -1565,7 +1565,7 @@ class XCodeBackend(backends.Backend):
pchs = [pch for pch in pchs if pch.endswith('.h') or pch.endswith('.hh') or pch.endswith('hpp')]
if pchs:
if len(pchs) > 1:
mlog.warning('Unsupported Xcode configuration: More than 1 precompiled header found "{}". Target "{}" might not compile correctly.'.format(str(pchs), target.name))
mlog.warning(f'Unsupported Xcode configuration: More than 1 precompiled header found "{pchs!s}". Target "{target.name}" might not compile correctly.')
relative_pch_path = os.path.join(target.get_subdir(), pchs[0]) # Path relative to target so it can be used with "$(PROJECT_DIR)"
settings_dict.add_item('GCC_PRECOMPILE_PREFIX_HEADER', 'YES')
settings_dict.add_item('GCC_PREFIX_HEADER', f'"$(PROJECT_DIR)/{relative_pch_path}"')

@ -157,7 +157,7 @@ class CMakeTraceParser:
# First load the trace (if required)
if not self.requires_stderr():
if not self.trace_file_path.exists and not self.trace_file_path.is_file():
raise CMakeException('CMake: Trace file "{}" not found'.format(str(self.trace_file_path)))
raise CMakeException(f'CMake: Trace file "{self.trace_file_path!s}" not found')
trace = self.trace_file_path.read_text(errors='ignore', encoding='utf-8')
if not trace:
raise CMakeException('CMake: The CMake trace was not provided or is empty')
@ -652,7 +652,7 @@ class CMakeTraceParser:
if not args:
mlog.error('Invalid preload.cmake script! At least one argument to `meson_ps_disabled_function` is expected')
return
mlog.warning('The CMake function "{}" was disabled to avoid compatibility issues with Meson.'.format(args[0]))
mlog.warning(f'The CMake function "{args[0]}" was disabled to avoid compatibility issues with Meson.')
def _lex_trace_human(self, trace: str) -> T.Generator[CMakeTraceLine, None, None]:
# The trace format is: '<file>(<line>): <func>(<args -- can contain \n> )\n'

@ -110,12 +110,11 @@ class CPPCompiler(CLikeCompiler, Compiler):
# Check if it's a class or a template
if extra_args is None:
extra_args = []
fargs = {'prefix': prefix, 'header': hname, 'symbol': symbol}
t = '''{prefix}
#include <{header}>
t = f'''{prefix}
#include <{hname}>
using {symbol};
int main(void) {{ return 0; }}'''
return self.compiles(t.format(**fargs), env, extra_args=extra_args,
return self.compiles(t, env, extra_args=extra_args,
dependencies=dependencies)
def _test_cpp_std_arg(self, cpp_std_value: str) -> bool:

@ -548,15 +548,15 @@ def _detect_c_or_cpp_compiler(env: 'Environment', lang: str, for_machine: Machin
if version != 'unknown version':
break
else:
m = 'Failed to detect MSVC compiler version: stderr was\n{!r}'
raise EnvironmentException(m.format(err))
m = f'Failed to detect MSVC compiler version: stderr was\n{err!r}'
raise EnvironmentException(m)
cl_signature = lookat.split('\n')[0]
match = re.search(r'.*(x86|x64|ARM|ARM64)([^_A-Za-z0-9]|$)', cl_signature)
if match:
target = match.group(1)
else:
m = 'Failed to detect MSVC compiler target architecture: \'cl /?\' output is\n{}'
raise EnvironmentException(m.format(cl_signature))
m = f'Failed to detect MSVC compiler target architecture: \'cl /?\' output is\n{cl_signature}'
raise EnvironmentException(m)
cls = VisualStudioCCompiler if lang == 'c' else VisualStudioCPPCompiler
linker = guess_win_linker(env, ['link'], cls, for_machine)
return cls(
@ -1049,8 +1049,8 @@ def detect_d_compiler(env: 'Environment', for_machine: MachineChoice) -> Compile
# up to date language version at time (2016).
if os.path.basename(exelist[-1]).startswith(('ldmd', 'gdmd')):
raise EnvironmentException(
'Meson does not support {} as it is only a DMD frontend for another compiler.'
'Please provide a valid value for DC or unset it so that Meson can resolve the compiler by itself.'.format(exelist[-1]))
f'Meson does not support {exelist[-1]} as it is only a DMD frontend for another compiler.'
'Please provide a valid value for DC or unset it so that Meson can resolve the compiler by itself.')
try:
p, out = Popen_safe(exelist + ['--version'])[0:2]
except OSError as e:

@ -222,9 +222,8 @@ class GnuFortranCompiler(GnuCompiler, FortranCompiler):
__has_include which breaks with GCC-Fortran 10:
https://github.com/mesonbuild/meson/issues/7017
'''
fargs = {'prefix': prefix, 'header': hname}
code = '{prefix}\n#include <{header}>'
return self.compiles(code.format(**fargs), env, extra_args=extra_args,
code = f'{prefix}\n#include <{hname}>'
return self.compiles(code, env, extra_args=extra_args,
dependencies=dependencies, mode='preprocess', disable_cache=disable_cache)

@ -317,7 +317,7 @@ class GnuLikeCompiler(Compiler, metaclass=abc.ABCMeta):
if linker not in {'gold', 'bfd', 'lld'}:
raise mesonlib.MesonException(
'Unsupported linker, only bfd, gold, and lld are supported, '
'not {}.'.format(linker))
f'not {linker}.')
return [f'-fuse-ld={linker}']
def get_coverage_args(self) -> T.List[str]:

@ -90,8 +90,8 @@ class BasicLinkerIsCompilerMixin(Compiler):
f'Linker {self.id} does not support allow undefined')
def get_pie_link_args(self) -> T.List[str]:
m = 'Linker {} does not support position-independent executable'
raise mesonlib.EnvironmentException(m.format(self.id))
m = f'Linker {self.id} does not support position-independent executable'
raise mesonlib.EnvironmentException(m)
def get_undefined_link_args(self) -> T.List[str]:
return []

@ -55,9 +55,8 @@ _T = T.TypeVar('_T')
class MesonVersionMismatchException(MesonException):
'''Build directory generated with Meson version is incompatible with current version'''
def __init__(self, old_version: str, current_version: str) -> None:
super().__init__('Build directory has been generated with Meson version {}, '
'which is incompatible with the current version {}.'
.format(old_version, current_version))
super().__init__(f'Build directory has been generated with Meson version {old_version}, '
f'which is incompatible with the current version {current_version}.')
self.old_version = old_version
self.current_version = current_version
@ -237,7 +236,7 @@ class UserArrayOption(UserOption[T.List[str]]):
mlog.deprecation(msg)
for i in newvalue:
if not isinstance(i, str):
raise MesonException('String array element "{}" is not a string.'.format(str(newvalue)))
raise MesonException(f'String array element "{newvalue!s}" is not a string.')
if self.choices:
bad = [x for x in newvalue if x not in self.choices]
if bad:
@ -521,8 +520,7 @@ class CoreData:
def sanitize_prefix(self, prefix):
prefix = os.path.expanduser(prefix)
if not os.path.isabs(prefix):
raise MesonException('prefix value {!r} must be an absolute path'
''.format(prefix))
raise MesonException(f'prefix value {prefix!r} must be an absolute path')
if prefix.endswith('/') or prefix.endswith('\\'):
# On Windows we need to preserve the trailing slash if the
# string is of type 'C:\' because 'C:' is not an absolute path.
@ -904,7 +902,7 @@ class MachineFileParser():
except MesonException:
raise EnvironmentException(f'Malformed value in machine file variable {entry!r}.')
except KeyError as e:
raise EnvironmentException('Undefined constant {!r} in machine file variable {!r}.'.format(e.args[0], entry))
raise EnvironmentException(f'Undefined constant {e.args[0]!r} in machine file variable {entry!r}.')
section[entry] = res
self.scope[entry] = res
return section
@ -1007,9 +1005,9 @@ def load(build_dir: str) -> CoreData:
raise MesonException(load_fail_msg)
except (ModuleNotFoundError, AttributeError):
raise MesonException(
"Coredata file {!r} references functions or classes that don't "
f"Coredata file {filename!r} references functions or classes that don't "
"exist. This probably means that it was generated with an old "
"version of meson.".format(filename))
"version of meson.")
if not isinstance(obj, CoreData):
raise MesonException(load_fail_msg)
if major_versions_differ(obj.version, version):
@ -1070,7 +1068,7 @@ def parse_cmd_line_options(args: argparse.Namespace) -> None:
if key in args.cmd_line_options:
cmdline_name = BuiltinOption.argparse_name_to_arg(name)
raise MesonException(
'Got argument {0} as both -D{0} and {1}. Pick one.'.format(name, cmdline_name))
f'Got argument {name} as both -D{name} and {cmdline_name}. Pick one.')
args.cmd_line_options[key] = value
delattr(args, name)

@ -373,8 +373,8 @@ class ExternalDependency(Dependency, HasNativeKwarg):
mlog.log(*found_msg)
if self.required:
m = 'Unknown version of dependency {!r}, but need {!r}.'
raise DependencyException(m.format(self.name, self.version_reqs))
m = f'Unknown version of dependency {self.name!r}, but need {self.version_reqs!r}.'
raise DependencyException(m)
else:
(self.is_found, not_found, found) = \

@ -135,8 +135,7 @@ class ConfigToolDependency(ExternalDependency):
if p.returncode != 0:
if self.required:
raise DependencyException(
'Could not generate {} for {}.\n{}'.format(
stage, self.name, err))
f'Could not generate {stage} for {self.name}.\n{err}')
return []
return split_args(out)

@ -177,7 +177,7 @@ class CudaDependency(SystemDependency):
else:
mlog.warning(f'Could not detect CUDA Toolkit version for {path}')
except Exception as e:
mlog.warning('Could not detect CUDA Toolkit version for {}: {}'.format(path, str(e)))
mlog.warning(f'Could not detect CUDA Toolkit version for {path}: {e!s}')
return '0.0'
@ -208,7 +208,7 @@ class CudaDependency(SystemDependency):
if m:
return self._strip_patch_version(m.group(1))
except Exception as e:
mlog.debug('Could not read CUDA Toolkit\'s version file {}: {}'.format(version_file_path, str(e)))
mlog.debug(f'Could not read CUDA Toolkit\'s version file {version_file_path}: {e!s}')
return None

@ -188,8 +188,7 @@ class Python3DependencySystem(SystemDependency):
elif pycc.startswith(('i686', 'i386')):
return '32'
else:
mlog.log('MinGW Python built with unknown CC {!r}, please file'
'a bug'.format(pycc))
mlog.log(f'MinGW Python built with unknown CC {pycc!r}, please file a bug')
return None
elif pyplat == 'win32':
return '32'
@ -399,8 +398,8 @@ class ShadercDependency(SystemDependency):
self.is_found = True
if self.static and lib != static_lib:
mlog.warning('Static library {!r} not found for dependency {!r}, may '
'not be statically linked'.format(static_lib, self.name))
mlog.warning(f'Static library {static_lib!r} not found for dependency '
f'{self.name!r}, may not be statically linked')
break

@ -352,9 +352,9 @@ def detect_cpu_family(compilers: CompilersDict) -> str:
trial = 'ppc64'
if trial not in known_cpu_families:
mlog.warning('Unknown CPU family {!r}, please report this at '
mlog.warning(f'Unknown CPU family {trial!r}, please report this at '
'https://github.com/mesonbuild/meson/issues/new with the '
'output of `uname -a` and `cat /proc/cpuinfo`'.format(trial))
'output of `uname -a` and `cat /proc/cpuinfo`')
return trial

@ -786,8 +786,7 @@ external dependencies (including libraries) must go to "dependencies".''')
cmd = exelist[0]
prog = ExternalProgram(cmd, silent=True)
if not prog.found():
raise InterpreterException('Program {!r} not found '
'or not executable'.format(cmd))
raise InterpreterException(f'Program {cmd!r} not found or not executable')
cmd = prog
expanded_args = exelist[1:]
else:
@ -799,8 +798,7 @@ external dependencies (including libraries) must go to "dependencies".''')
search_dir = os.path.join(srcdir, self.subdir)
prog = ExternalProgram(cmd, silent=True, search_dir=search_dir)
if not prog.found():
raise InterpreterException('Program or command {!r} not found '
'or not executable'.format(cmd))
raise InterpreterException(f'Program or command {cmd!r} not found or not executable')
cmd = prog
for a in listify(cargs):
if isinstance(a, str):
@ -1407,8 +1405,7 @@ external dependencies (including libraries) must go to "dependencies".''')
search_dir = source_dir
extra_search_dirs = search_dirs
else:
raise InvalidArguments('find_program only accepts strings and '
'files, not {!r}'.format(exename))
raise InvalidArguments(f'find_program only accepts strings and files, not {exename!r}')
extprog = ExternalProgram(exename, search_dir=search_dir,
extra_search_dirs=extra_search_dirs,
silent=True)
@ -1433,11 +1430,9 @@ external dependencies (including libraries) must go to "dependencies".''')
def add_find_program_override(self, name, exe):
if name in self.build.searched_programs:
raise InterpreterException('Tried to override finding of executable "%s" which has already been found.'
% name)
raise InterpreterException(f'Tried to override finding of executable "{name}" which has already been found.')
if name in self.build.find_overrides:
raise InterpreterException('Tried to override executable "%s" which has already been overridden.'
% name)
raise InterpreterException(f'Tried to override executable "{name}" which has already been overridden.')
self.build.find_overrides[name] = exe
def notfound_program(self, args):
@ -2186,9 +2181,9 @@ This will become a hard error in the future.''' % kwargs['input'], location=self
if confdata_useless:
ifbase = os.path.basename(inputs_abs[0])
mlog.warning('Got an empty configuration_data() object and found no '
'substitutions in the input file {!r}. If you want to '
f'substitutions in the input file {ifbase!r}. If you want to '
'copy a file to the build dir, use the \'copy:\' keyword '
'argument added in 0.47.0'.format(ifbase), location=node)
'argument added in 0.47.0', location=node)
else:
mesonlib.dump_conf_header(ofile_abs, conf.conf_data, output_format)
conf.mark_used()
@ -2432,12 +2427,12 @@ This will become a hard error in the future.''' % kwargs['input'], location=self
def _add_global_arguments(self, node: mparser.FunctionNode, argsdict: T.Dict[str, T.List[str]],
args: T.List[str], kwargs: 'kwargs.FuncAddProjectArgs') -> None:
if self.is_subproject():
msg = 'Function \'{}\' cannot be used in subprojects because ' \
msg = f'Function \'{node.func_name}\' cannot be used in subprojects because ' \
'there is no way to make that reliable.\nPlease only call ' \
'this if is_subproject() returns false. Alternatively, ' \
'define a variable that\ncontains your language-specific ' \
'arguments and add it to the appropriate *_args kwarg ' \
'in each target.'.format(node.func_name)
'in each target.'
raise InvalidCode(msg)
frozen = self.project_args_frozen or self.global_args_frozen
self._add_arguments(node, argsdict, frozen, args, kwargs)
@ -2452,9 +2447,9 @@ This will become a hard error in the future.''' % kwargs['input'], location=self
def _add_arguments(self, node: mparser.FunctionNode, argsdict: T.Dict[str, T.List[str]],
args_frozen: bool, args: T.List[str], kwargs: 'kwargs.FuncAddProjectArgs') -> None:
if args_frozen:
msg = 'Tried to use \'{}\' after a build target has been declared.\n' \
msg = f'Tried to use \'{node.func_name}\' after a build target has been declared.\n' \
'This is not permitted. Please declare all ' \
'arguments before your targets.'.format(node.func_name)
'arguments before your targets.'
raise InvalidCode(msg)
self._warn_about_builtin_args(args)

@ -340,10 +340,10 @@ class ConfigurationDataObject(MutableInterpreterObject, MesonInterpreterObject):
raise InterpreterException("Can not set values on configuration object that has been used.")
name, val = args
if not isinstance(val, (int, str)):
msg = 'Setting a configuration data value to {!r} is invalid, ' \
msg = f'Setting a configuration data value to {val!r} is invalid, ' \
'and will fail at configure_file(). If you are using it ' \
'just to store some values, please use a dict instead.'
mlog.deprecation(msg.format(val), location=self.current_node)
mlog.deprecation(msg, location=self.current_node)
desc = kwargs.get('description', None)
if not isinstance(name, str):
raise InterpreterException("First argument to set must be a string.")

@ -98,12 +98,12 @@ class MesonMain(MesonInterpreterObject):
new = True
else:
raise InterpreterException(
'Arguments to {} must be strings, Files, or CustomTargets, '
'Indexes of CustomTargets'.format(name))
f'Arguments to {name} must be strings, Files, or CustomTargets, '
'Indexes of CustomTargets')
if new:
FeatureNew.single_use(
'Calling "{}" with File, CustomTaget, Index of CustomTarget, '
'Executable, or ExternalProgram'.format(name),
f'Calling "{name}" with File, CustomTaget, Index of CustomTarget, '
'Executable, or ExternalProgram',
'0.55.0', self.interpreter.subproject)
return script_args

@ -675,13 +675,13 @@ The result of this is undefined and will become a hard error in a future Meson r
@staticmethod
def _get_one_string_posarg(posargs: T.List[TYPE_var], method_name: str) -> str:
if len(posargs) > 1:
m = '{}() must have zero or one arguments'
raise InterpreterException(m.format(method_name))
m = f'{method_name}() must have zero or one arguments'
raise InterpreterException(m)
elif len(posargs) == 1:
s = posargs[0]
if not isinstance(s, str):
m = '{}() argument must be a string'
raise InterpreterException(m.format(method_name))
m = f'{method_name}() argument must be a string'
raise InterpreterException(m)
return s
return None
@ -820,8 +820,8 @@ The result of this is undefined and will become a hard error in a future Meson r
return self.evaluate_statement(fallback)
return fallback
return obj[index]
m = 'Arrays do not have a method called {!r}.'
raise InterpreterException(m.format(method_name))
m = f'Arrays do not have a method called {method_name!r}.'
raise InterpreterException(m)
@builtinMethodNoKwargs
def dict_method_call(self,

@ -406,8 +406,8 @@ class DynamicLinker(metaclass=abc.ABCMeta):
return []
def has_multi_arguments(self, args: T.List[str], env: 'Environment') -> T.Tuple[bool, bool]:
m = 'Language {} does not support has_multi_link_arguments.'
raise mesonlib.EnvironmentException(m.format(self.id))
m = f'Language {self.id} does not support has_multi_link_arguments.'
raise mesonlib.EnvironmentException(m)
def get_debugfile_name(self, targetfile: str) -> str:
'''Name of debug file written out (see below)'''
@ -432,8 +432,8 @@ class DynamicLinker(metaclass=abc.ABCMeta):
# TODO: this really needs to take a boolean and return the args to
# disable pie, otherwise it only acts to enable pie if pie *isn't* the
# default.
m = 'Linker {} does not support position-independent executable'
raise mesonlib.EnvironmentException(m.format(self.id))
m = f'Linker {self.id} does not support position-independent executable'
raise mesonlib.EnvironmentException(m)
def get_lto_args(self) -> T.List[str]:
return []

@ -38,10 +38,10 @@ def array_arg(value: str) -> T.List[str]:
def validate_builddir(builddir: Path) -> None:
if not (builddir / 'meson-private' / 'coredata.dat' ).is_file():
raise MesonException('Current directory is not a meson build directory: `{}`.\n'
raise MesonException(f'Current directory is not a meson build directory: `{builddir}`.\n'
'Please specify a valid build dir or change the working directory to it.\n'
'It is also possible that the build directory was generated with an old\n'
'meson version. Please regenerate it in this case.'.format(builddir))
'meson version. Please regenerate it in this case.')
def get_backend_from_coredata(builddir: Path) -> str:
"""

@ -258,11 +258,11 @@ def check_direntry_issues(direntry_array: T.Union[T.List[T.Union[str, bytes]], s
for de in direntry_array:
if is_ascii_string(de):
continue
mlog.warning(textwrap.dedent('''
You are using {!r} which is not a Unicode-compatible
locale but you are trying to access a file system entry called {!r} which is
mlog.warning(textwrap.dedent(f'''
You are using {e!r} which is not a Unicode-compatible
locale but you are trying to access a file system entry called {de!r} which is
not pure ASCII. This may cause problems.
'''.format(e, de)), file=sys.stderr)
'''), file=sys.stderr)
# Put this in objects that should not get dumped to pickle files
@ -330,11 +330,11 @@ class FileMode:
return -1
eg = 'rwxr-xr-x'
if not isinstance(perms_s, str):
msg = 'Install perms must be a string. For example, {!r}'
raise MesonException(msg.format(eg))
msg = f'Install perms must be a string. For example, {eg!r}'
raise MesonException(msg)
if len(perms_s) != 9 or not cls.symbolic_perms_regex.match(perms_s):
msg = 'File perms {!r} must be exactly 9 chars. For example, {!r}'
raise MesonException(msg.format(perms_s, eg))
msg = f'File perms {perms_s!r} must be exactly 9 chars. For example, {eg!r}'
raise MesonException(msg)
perms = 0
# Owner perms
if perms_s[0] == 'r':
@ -1126,9 +1126,9 @@ def do_replacement(regex: T.Pattern[str], line: str, variable_format: str,
elif isinstance(var, int):
var_str = str(var)
else:
msg = 'Tried to replace variable {!r} value with ' \
'something other than a string or int: {!r}'
raise MesonException(msg.format(varname, var))
msg = f'Tried to replace variable {varname!r} value with ' \
f'something other than a string or int: {var!r}'
raise MesonException(msg)
else:
missing_variables.add(varname)
return var_str
@ -1227,7 +1227,7 @@ def do_conf_file(src: str, dst: str, confdata: 'ConfigurationData', variable_for
with open(src, encoding=encoding, newline='') as f:
data = f.readlines()
except Exception as e:
raise MesonException('Could not read input file {}: {}'.format(src, str(e)))
raise MesonException(f'Could not read input file {src}: {e!s}')
(result, missing_variables, confdata_useless) = do_conf_str(src, data, confdata, variable_format, encoding)
dst_tmp = dst + '~'
@ -1235,7 +1235,7 @@ def do_conf_file(src: str, dst: str, confdata: 'ConfigurationData', variable_for
with open(dst_tmp, 'w', encoding=encoding, newline='') as f:
f.writelines(result)
except Exception as e:
raise MesonException('Could not write output file {}: {}'.format(dst, str(e)))
raise MesonException(f'Could not write output file {dst}: {e!s}')
shutil.copymode(src, dst_tmp)
replace_if_different(dst, dst_tmp)
return missing_variables, confdata_useless
@ -1451,15 +1451,15 @@ def _substitute_values_check_errors(command: T.List[str], values: T.Dict[str, st
# Error out if any input-derived templates are present in the command
match = iter_regexin_iter(inregex, command)
if match:
m = 'Command cannot have {!r}, since no input files were specified'
raise MesonException(m.format(match))
m = f'Command cannot have {match!r}, since no input files were specified'
raise MesonException(m)
else:
if len(values['@INPUT@']) > 1:
# Error out if @PLAINNAME@ or @BASENAME@ is present in the command
match = iter_regexin_iter(inregex[1:], command)
if match:
raise MesonException('Command cannot have {!r} when there is '
'more than one input file'.format(match))
raise MesonException(f'Command cannot have {match!r} when there is '
'more than one input file')
# Error out if an invalid @INPUTnn@ template was specified
for each in command:
if not isinstance(each, str):

Loading…
Cancel
Save