Repository: mesonbuild/meson

Issue:
default_options in subprojects override commandline options
If I run `meson setup --optimization=0` and a subproject has

```meson
project('mylib', 'c',
        default_options: ['optimization=3'])
```

Then the subproject is built with /O3.

Minimal sample: [meson-issue.zip](https://github.com/user-attachments/files/20643512/meson-issue.zip)


Discussion:

Patch:
diff --git a/.github/workflows/os_comp.yml b/.github/workflows/os_comp.yml
index 4b9b7a4a6eae..91ca62eed4ec 100644
--- a/.github/workflows/os_comp.yml
+++ b/.github/workflows/os_comp.yml
@@ -100,10 +100,12 @@ jobs:
           - CC: 'gcc'
             CXX: 'g++'
           - MESON_ARGS: '--unity=on -Ddefault_library=static'
+            MESON_TEST_DDEFAULT_LIBRARY: yes
             RUN_TESTS_ARGS: '--no-unittests'
             CC: 'gcc'
             CXX: 'g++'
           - MESON_ARGS: '-Ddefault_library=both'
+            MESON_TEST_DDEFAULT_LIBRARY: yes
             RUN_TESTS_ARGS: '--no-unittests'
             CC: 'gcc'
             CXX: 'g++'
@@ -130,6 +132,7 @@ jobs:
       env:
         MESON_RSP_THRESHOLD: ${{ matrix.cfg.MESON_RSP_THRESHOLD }}
         MESON_ARGS: ${{ matrix.cfg.MESON_ARGS }}
+        MESON_TEST_DDEFAULT_LIBRARY: ${{ matrix.cfg.MESON_TEST_DDEFAULT_LIBRARY }}
         RUN_TESTS_ARGS: ${{ matrix.cfg.RUN_TESTS_ARGS }}
         CC: ${{ matrix.cfg.CC }}
         CXX: ${{ matrix.cfg.CXX }}
diff --git a/mesonbuild/coredata.py b/mesonbuild/coredata.py
index 487bade53463..26ef1b805e25 100644
--- a/mesonbuild/coredata.py
+++ b/mesonbuild/coredata.py
@@ -416,8 +416,8 @@ def get_option_for_target(self, target: 'BuildTarget', key: T.Union[str, OptionK
     def set_from_configure_command(self, options: SharedCMDOptions) -> bool:
         unset_opts = getattr(options, 'unset_opts', [])
         all_D = options.projectoptions[:]
-        for keystr, valstr in options.cmd_line_options.items():
-            all_D.append(f'{keystr}={valstr}')
+        for key, valstr in options.cmd_line_options.items():
+            all_D.append(f'{key!s}={valstr}')
         return self.optstore.set_from_configure_command(all_D, unset_opts)
 
     def set_option(self, key: OptionKey, value, first_invocation: bool = False) -> bool:
@@ -712,9 +712,10 @@ def parse_cmd_line_options(args: SharedCMDOptions) -> None:
     args.cmd_line_options = {}
     for o in args.projectoptions:
         try:
-            (key, value) = o.split('=', 1)
+            keystr, value = o.split('=', 1)
         except ValueError:
             raise MesonException(f'Option {o!r} must have a value separated by equals sign.')
+        key = OptionKey.from_string(keystr)
         args.cmd_line_options[key] = value
 
     # Merge builtin options set with --option into the dict.
@@ -730,7 +731,7 @@ def parse_cmd_line_options(args: SharedCMDOptions) -> None:
                 cmdline_name = options.argparse_name_to_arg(name)
                 raise MesonException(
                     f'Got argument {name} as both -D{name} and {cmdline_name}. Pick one.')
-            args.cmd_line_options[key.name] = value
+            args.cmd_line_options[key] = value
             delattr(args, name)
 
 
diff --git a/mesonbuild/environment.py b/mesonbuild/environment.py
index 2a9cf165b191..2c3bdecf3d46 100644
--- a/mesonbuild/environment.py
+++ b/mesonbuild/environment.py
@@ -43,7 +43,7 @@
 if T.TYPE_CHECKING:
     from .compilers import Compiler
     from .compilers.mixins.visualstudio import VisualStudioLikeCompiler
-    from .options import ElementaryOptionValues
+    from .options import OptionDict, ElementaryOptionValues
     from .wrap.wrap import Resolver
     from . import cargo
 
@@ -646,12 +646,12 @@ def __init__(self, source_dir: str, build_dir: T.Optional[str], cmd_options: cor
         #
         # Note that order matters because of 'buildtype', if it is after
         # 'optimization' and 'debug' keys, it override them.
-        self.options: T.MutableMapping[OptionKey, ElementaryOptionValues] = collections.OrderedDict()
+        self.options: OptionDict = collections.OrderedDict()
 
         # Environment variables with the name converted into an OptionKey type.
         # These have subtly different behavior compared to machine files, so do
         # not store them in self.options.  See _set_default_options_from_env.
-        self.env_opts: T.MutableMapping[OptionKey, ElementaryOptionValues] = {}
+        self.env_opts: OptionDict = {}
 
         self.machinestore = machinefile.MachineFileStore(self.coredata.config_files, self.coredata.cross_files, self.source_dir)
 
diff --git a/mesonbuild/interpreter/dependencyfallbacks.py b/mesonbuild/interpreter/dependencyfallbacks.py
index 3bbb434046ef..f415026aa05c 100644
--- a/mesonbuild/interpreter/dependencyfallbacks.py
+++ b/mesonbuild/interpreter/dependencyfallbacks.py
@@ -122,17 +122,17 @@ def _do_subproject(self, kwargs: TYPE_nkwargs, func_args: TYPE_nvar, func_kwargs
         # dependency('foo', static: true) should implicitly add
         # default_options: ['default_library=static']
         static = kwargs.get('static')
-        extra_default_options = {}
+        forced_options = {}
         if static is not None:
             default_library = 'static' if static else 'shared'
             mlog.log(f'Building fallback subproject with default_library={default_library}')
-            extra_default_options[OptionKey('default_library')] = default_library
+            forced_options[OptionKey('default_library')] = default_library
 
         # Configure the subproject
         subp_name = self.subproject_name
         varname = self.subproject_varname
         func_kwargs.setdefault('version', [])
-        self.interpreter.do_subproject(subp_name, func_kwargs, extra_default_options=extra_default_options)
+        self.interpreter.do_subproject(subp_name, func_kwargs, forced_options=forced_options)
         return self._get_subproject_dep(subp_name, varname, kwargs)
 
     def _get_subproject(self, subp_name: str) -> T.Optional[SubprojectHolder]:
diff --git a/mesonbuild/interpreter/interpreter.py b/mesonbuild/interpreter/interpreter.py
index cd3318b45949..29bb705b1d55 100644
--- a/mesonbuild/interpreter/interpreter.py
+++ b/mesonbuild/interpreter/interpreter.py
@@ -271,7 +271,7 @@ def __init__(
                 subproject: str = '',
                 subdir: str = '',
                 subproject_dir: str = 'subprojects',
-                invoker_method_default_options: T.Optional[T.Dict[OptionKey, str]] = None,
+                invoker_method_default_options: T.Optional[OptionDict] = None,
                 ast: T.Optional[mparser.CodeBlockNode] = None,
                 relaxations: T.Optional[T.Set[InterpreterRuleRelaxation]] = None,
                 user_defined_options: T.Optional[coredata.SharedCMDOptions] = None,
@@ -301,7 +301,7 @@ def __init__(
             self.invoker_method_default_options = invoker_method_default_options
         else:
             self.invoker_method_default_options = {}
-        self.project_default_options: T.List[str] = []
+        self.project_default_options: OptionDict = {}
         self.build_func_dict()
         self.build_holder_map()
         self.user_defined_options = user_defined_options
@@ -870,7 +870,7 @@ def disabled_subproject(self, subp_name: str, disabled_feature: T.Optional[str]
         return sub
 
     def do_subproject(self, subp_name: str, kwargs: kwtypes.DoSubproject, force_method: T.Optional[wrap.Method] = None,
-                      extra_default_options: T.Optional[OptionDict] = None) -> SubprojectHolder:
+                      forced_options: T.Optional[OptionDict] = None) -> SubprojectHolder:
         if subp_name == 'sub_static':
             pass
         disabled, required, feature = extract_required_kwarg(kwargs, self.subproject)
@@ -880,8 +880,16 @@ def do_subproject(self, subp_name: str, kwargs: kwtypes.DoSubproject, force_meth
             return self.disabled_subproject(subp_name, disabled_feature=feature)
 
         default_options = kwargs['default_options']
-        if extra_default_options:
-            default_options = {**extra_default_options, **default_options}
+
+        # This in practice is only used for default_library.  forced_options is the
+        # only case in which a meson.build file overrides the machine file or the
+        # command line.
+        if forced_options:
+            for k, v in forced_options.items():
+                # FIXME: this should have no business poking at augments[],
+                # but set_option() does not do what we want
+                self.coredata.optstore.augments[k.evolve(subproject=subp_name)] = v
+            default_options = {**forced_options, **default_options}
 
         if subp_name == '':
             raise InterpreterException('Subproject name must not be empty.')
@@ -934,7 +942,8 @@ def do_subproject(self, subp_name: str, kwargs: kwtypes.DoSubproject, force_meth
             m += ['method', mlog.bold(method)]
         mlog.log(*m, '\n', nested=False)
 
-        methods_map: T.Dict[wrap.Method, T.Callable[[str, str, T.Dict[OptionKey, str, kwtypes.DoSubproject]], SubprojectHolder]] = {
+        methods_map: T.Dict[wrap.Method, T.Callable[[str, str, OptionDict, kwtypes.DoSubproject],
+                                                    SubprojectHolder]] = {
             'meson': self._do_subproject_meson,
             'cmake': self._do_subproject_cmake,
             'cargo': self._do_subproject_cargo,
@@ -956,7 +965,7 @@ def do_subproject(self, subp_name: str, kwargs: kwtypes.DoSubproject, force_meth
             raise e
 
     def _do_subproject_meson(self, subp_name: str, subdir: str,
-                             default_options: T.Dict[str, options.ElementaryOptionValues],
+                             default_options: OptionDict,
                              kwargs: kwtypes.DoSubproject,
                              ast: T.Optional[mparser.CodeBlockNode] = None,
                              build_def_files: T.Optional[T.List[str]] = None,
@@ -1016,7 +1025,7 @@ def _do_subproject_meson(self, subp_name: str, subdir: str,
         return self.subprojects[subp_name]
 
     def _do_subproject_cmake(self, subp_name: str, subdir: str,
-                             default_options: T.Dict[str, options.ElementaryOptionValues],
+                             default_options: OptionDict,
                              kwargs: kwtypes.DoSubproject) -> SubprojectHolder:
         from ..cmake import CMakeInterpreter
         with mlog.nested(subp_name):
@@ -1043,7 +1052,7 @@ def _do_subproject_cmake(self, subp_name: str, subdir: str,
         return result
 
     def _do_subproject_cargo(self, subp_name: str, subdir: str,
-                             default_options: T.Dict[str, options.ElementaryOptionValues],
+                             default_options: OptionDict,
                              kwargs: kwtypes.DoSubproject) -> SubprojectHolder:
         from .. import cargo
         FeatureNew.single_use('Cargo subproject', '1.3.0', self.subproject, location=self.current_node)
@@ -1189,9 +1198,6 @@ def func_project(self, node: mparser.FunctionNode, args: T.Tuple[str, T.List[str
         self._load_option_file()
 
         self.project_default_options = kwargs['default_options']
-        if isinstance(self.project_default_options, str):
-            self.project_default_options = [self.project_default_options]
-        assert isinstance(self.project_default_options, (list, dict))
         if self.environment.first_invocation or (self.subproject != '' and self.subproject not in self.coredata.initialized_subprojects):
             if self.subproject == '':
                 self.coredata.optstore.initialize_from_top_level_project_call(self.project_default_options,
@@ -1201,7 +1207,8 @@ def func_project(self, node: mparser.FunctionNode, args: T.Tuple[str, T.List[str
                 self.coredata.optstore.initialize_from_subproject_call(self.subproject,
                                                                        self.invoker_method_default_options,
                                                                        self.project_default_options,
-                                                                       self.user_defined_options.cmd_line_options)
+                                                                       self.user_defined_options.cmd_line_options,
+                                                                       self.environment.options)
                 self.coredata.initialized_subprojects.add(self.subproject)
 
         if not self.is_subproject():
@@ -1628,7 +1635,7 @@ def notfound_program(self, args: T.List[mesonlib.FileOrString]) -> ExternalProgr
     # the host machine.
     def find_program_impl(self, args: T.List[mesonlib.FileOrString],
                           for_machine: MachineChoice = MachineChoice.HOST,
-                          default_options: T.Optional[T.Dict[OptionKey, options.ElementaryOptionValues]] = None,
+                          default_options: T.Optional[OptionDict] = None,
                           required: bool = True, silent: bool = True,
                           wanted: T.Union[str, T.List[str]] = '',
                           search_dirs: T.Optional[T.List[str]] = None,
@@ -1659,7 +1666,7 @@ def find_program_impl(self, args: T.List[mesonlib.FileOrString],
         return progobj
 
     def program_lookup(self, args: T.List[mesonlib.FileOrString], for_machine: MachineChoice,
-                       default_options: T.Optional[T.Dict[OptionKey, options.ElementaryOptionValues]],
+                       default_options: T.Optional[OptionDict],
                        required: bool,
                        search_dirs: T.Optional[T.List[str]],
                        wanted: T.Union[str, T.List[str]],
@@ -1727,7 +1734,7 @@ def check_program_version(self, progobj: T.Union[ExternalProgram, build.Executab
         return True
 
     def find_program_fallback(self, fallback: str, args: T.List[mesonlib.FileOrString],
-                              default_options: T.Dict[OptionKey, options.ElementaryOptionValues],
+                              default_options: OptionDict,
                               required: bool, extra_info: T.List[mlog.TV_Loggable]
                               ) -> T.Optional[T.Union[ExternalProgram, build.Executable, OverrideProgram]]:
         mlog.log('Fallback to subproject', mlog.bold(fallback), 'which provides program',
diff --git a/mesonbuild/msetup.py b/mesonbuild/msetup.py
index 32344cae689b..b08d5e8a378a 100644
--- a/mesonbuild/msetup.py
+++ b/mesonbuild/msetup.py
@@ -193,7 +193,7 @@ def generate(self, capture: bool = False, vslite_ctx: T.Optional[dict] = None) -
                                     'Some other Meson process is already using this build directory. Exiting.'):
             return self._generate(env, capture, vslite_ctx)
 
-    def check_unused_options(self, coredata: 'coredata.CoreData', cmd_line_options: T.Any, all_subprojects: T.Mapping[str, object]) -> None:
+    def check_unused_options(self, coredata: 'coredata.CoreData', cmd_line_options: T.Dict[OptionKey, str], all_subprojects: T.Mapping[str, object]) -> None:
         pending = coredata.optstore.pending_options
         errlist: T.List[str] = []
         known_subprojects = all_subprojects.keys()
@@ -202,9 +202,8 @@ def check_unused_options(self, coredata: 'coredata.CoreData', cmd_line_options:
             # because they might be used in future reconfigurations
             if coredata.optstore.accept_as_pending_option(opt, known_subprojects):
                 continue
-            keystr = str(opt)
-            if keystr in cmd_line_options:
-                errlist.append(f'"{keystr}"')
+            if opt in cmd_line_options:
+                errlist.append(f'"{opt}"')
         if errlist:
             errstr = ', '.join(errlist)
             raise MesonException(f'Unknown options: {errstr}')
@@ -348,17 +347,18 @@ def run_genvslite_setup(options: CMDOptions) -> None:
     # invoke the appropriate 'meson compile ...' build commands upon the normal visual studio build/rebuild/clean actions, instead of using
     # the native VS/msbuild system.
     builddir_prefix = options.builddir
-    genvsliteval = options.cmd_line_options.pop('genvslite') # type: ignore [call-overload]
+    k_genvslite = OptionKey('genvslite')
+    genvsliteval = options.cmd_line_options.pop(k_genvslite)
     # The command line may specify a '--backend' option, which doesn't make sense in conjunction with
     # '--genvslite', where we always want to use a ninja back end -
-    k_backend = 'backend'
+    k_backend = OptionKey('backend')
     if k_backend in options.cmd_line_options.keys():
-        if options.cmd_line_options[k_backend] != 'ninja': # type: ignore [index]
+        if options.cmd_line_options[k_backend] != 'ninja':
             raise MesonException('Explicitly specifying a backend option with \'genvslite\' is not necessary '
                                  '(the ninja backend is always used) but specifying a non-ninja backend '
                                  'conflicts with a \'genvslite\' setup')
     else:
-        options.cmd_line_options[k_backend] = 'ninja' # type: ignore [index]
+        options.cmd_line_options[k_backend] = 'ninja'
     buildtypes_list = coredata.get_genvs_default_buildtype_list()
     vslite_ctx = {}
 
@@ -369,7 +369,7 @@ def run_genvslite_setup(options: CMDOptions) -> None:
         vslite_ctx[buildtypestr] = app.generate(capture=True)
     #Now for generating the 'lite' solution and project files, which will use these builds we've just set up, above.
     options.builddir = f'{builddir_prefix}_vs'
-    options.cmd_line_options[OptionKey('genvslite')] = genvsliteval
+    options.cmd_line_options[k_genvslite] = genvsliteval
     app = MesonApp(options)
     app.generate(capture=False, vslite_ctx=vslite_ctx)
 
@@ -385,7 +385,7 @@ def run(options: T.Union[CMDOptions, T.List[str]]) -> int:
     # lie
     options.pager = False
 
-    if 'genvslite' in options.cmd_line_options.keys():
+    if OptionKey('genvslite') in options.cmd_line_options.keys():
         run_genvslite_setup(options)
     else:
         app = MesonApp(options)
diff --git a/mesonbuild/options.py b/mesonbuild/options.py
index cd7d2eb6d1d3..4f51c38b71ab 100644
--- a/mesonbuild/options.py
+++ b/mesonbuild/options.py
@@ -310,7 +310,7 @@ def is_for_build(self) -> bool:
         return self.machine is MachineChoice.BUILD
 
 if T.TYPE_CHECKING:
-    OptionDict: TypeAlias = T.Dict[T.Union[OptionKey, str], ElementaryOptionValues]
+    OptionDict: TypeAlias = T.Dict[OptionKey, ElementaryOptionValues]
 
 @dataclasses.dataclass
 class UserOption(T.Generic[_T], HoldableObject):
@@ -809,14 +809,13 @@ def __init__(self, is_cross: bool) -> None:
         self.module_options: T.Set[OptionKey] = set()
         from .compilers import all_languages
         self.all_languages = set(all_languages)
-        self.project_options = set()
-        self.augments: T.Dict[OptionKey, ElementaryOptionValues] = {}
+        self.augments: OptionDict = {}
         self.is_cross = is_cross
 
         # Pending options are options that need to be initialized later, either
         # configuration dependent options like compiler options, or options for
         # a different subproject
-        self.pending_options: T.Dict[OptionKey, ElementaryOptionValues] = {}
+        self.pending_options: OptionDict = {}
 
     def clear_pending(self) -> None:
         self.pending_options = {}
@@ -999,6 +998,10 @@ def sanitize_dir_option_value(self, prefix: str, option: OptionKey, value: T.Any
         return value.as_posix()
 
     def set_option(self, key: OptionKey, new_value: ElementaryOptionValues, first_invocation: bool = False) -> bool:
+        error_key = key
+        if error_key.subproject == '':
+            error_key = error_key.evolve(subproject=None)
+
         if key.name == 'prefix':
             assert isinstance(new_value, str), 'for mypy'
             new_value = self.sanitize_prefix(new_value)
@@ -1010,26 +1013,26 @@ def set_option(self, key: OptionKey, new_value: ElementaryOptionValues, first_in
         try:
             opt = self.get_value_object_for(key)
         except KeyError:
-            raise MesonException(f'Unknown options: "{key!s}" not found.')
+            raise MesonException(f'Unknown option: "{error_key}".')
 
         if opt.deprecated is True:
-            mlog.deprecation(f'Option {key.name!r} is deprecated')
+            mlog.deprecation(f'Option "{error_key}" is deprecated')
         elif isinstance(opt.deprecated, list):
             for v in opt.listify(new_value):
                 if v in opt.deprecated:
-                    mlog.deprecation(f'Option {key.name!r} value {v!r} is deprecated')
+                    mlog.deprecation(f'Option "{error_key}" value {v!r} is deprecated')
         elif isinstance(opt.deprecated, dict):
             def replace(v: str) -> str:
                 assert isinstance(opt.deprecated, dict) # No, Mypy can not tell this from two lines above
                 newvalue = opt.deprecated.get(v)
                 if newvalue is not None:
-                    mlog.deprecation(f'Option {key.name!r} value {v!r} is replaced by {newvalue!r}')
+                    mlog.deprecation(f'Option "{error_key}" value {v!r} is replaced by {newvalue!r}')
                     return newvalue
                 return v
             valarr = [replace(v) for v in opt.listify(new_value)]
             new_value = ','.join(valarr)
         elif isinstance(opt.deprecated, str):
-            mlog.deprecation(f'Option {key.name!r} is replaced by {opt.deprecated!r}')
+            mlog.deprecation(f'Option "{error_key}" is replaced by {opt.deprecated!r}')
             # Change both this aption and the new one pointed to.
             dirty = self.set_option(key.evolve(name=opt.deprecated), new_value)
             dirty |= opt.set_value(new_value)
@@ -1039,7 +1042,7 @@ def replace(v: str) -> str:
         changed = opt.set_value(new_value)
 
         if opt.readonly and changed and not first_invocation:
-            raise MesonException(f'Tried to modify read only option {str(key)!r}')
+            raise MesonException(f'Tried to modify read only option "{error_key}"')
 
         if key.name == 'prefix' and first_invocation and changed:
             assert isinstance(old_value, str), 'for mypy'
@@ -1056,19 +1059,30 @@ def replace(v: str) -> str:
 
         return changed
 
-    def set_option_maybe_root(self, o: OptionKey, new_value: str) -> bool:
+    def set_option_maybe_root(self, o: OptionKey, new_value: ElementaryOptionValues, first_invocation: bool = False) -> bool:
         if not self.is_cross and o.is_for_build():
             return False
 
+        # This is complicated by the fact that a string can have two meanings:
+        #
+        # default_options: 'foo=bar'
+        #
+        # can be either
+        #
+        # A) a system option in which case the subproject is None
+        # B) a project option, in which case the subproject is '' (this method is only called from top level)
+        #
+        # The key parsing function can not handle the difference between the two
+        # and defaults to A.
         if o in self.options:
-            return self.set_option(o, new_value)
-        if self.accept_as_pending_option(o):
+            return self.set_option(o, new_value, first_invocation)
+        if self.accept_as_pending_option(o, first_invocation=first_invocation):
             old_value = self.pending_options.get(o, None)
             self.pending_options[o] = new_value
             return old_value is None or str(old_value) == new_value
         else:
             o = o.as_root()
-            return self.set_option(o, new_value)
+            return self.set_option(o, new_value, first_invocation)
 
     def set_from_configure_command(self, D_args: T.List[str], U_args: T.List[str]) -> bool:
         dirty = False
@@ -1234,7 +1248,7 @@ def prefix_split_options(self, coll: OptionDict) -> T.Tuple[T.Optional[str], Opt
         prefix = None
         others_d: OptionDict = {}
         for k, v in coll.items():
-            if k == 'prefix' or (isinstance(k, OptionKey) and k.name == 'prefix'):
+            if k.name == 'prefix':
                 if not isinstance(v, str):
                     raise MesonException('Incorrect type for prefix option (expected string)')
                 prefix = v
@@ -1245,13 +1259,10 @@ def prefix_split_options(self, coll: OptionDict) -> T.Tuple[T.Optional[str], Opt
     def first_handle_prefix(self,
                             project_default_options: OptionDict,
                             cmd_line_options: OptionDict,
-                            machine_file_options: T.Mapping[OptionKey, ElementaryOptionValues]) \
-            -> T.Tuple[OptionDict,
-                       OptionDict,
-                       T.MutableMapping[OptionKey, ElementaryOptionValues]]:
+                            machine_file_options: OptionDict) \
+            -> T.Tuple[OptionDict, OptionDict, OptionDict]:
         # Copy to avoid later mutation
-        nopref_machine_file_options = T.cast(
-            'T.MutableMapping[OptionKey, ElementaryOptionValues]', copy.copy(machine_file_options))
+        nopref_machine_file_options = copy.copy(machine_file_options)
 
         prefix = None
         (possible_prefix, nopref_project_default_options) = self.prefix_split_options(project_default_options)
@@ -1284,81 +1295,45 @@ def hard_reset_from_prefix(self, prefix: str) -> None:
     def initialize_from_top_level_project_call(self,
                                                project_default_options_in: OptionDict,
                                                cmd_line_options_in: OptionDict,
-                                               machine_file_options_in: T.Mapping[OptionKey, ElementaryOptionValues]) -> None:
-        first_invocation = True
+                                               machine_file_options_in: OptionDict) -> None:
         (project_default_options, cmd_line_options, machine_file_options) = self.first_handle_prefix(project_default_options_in,
                                                                                                      cmd_line_options_in,
                                                                                                      machine_file_options_in)
-        for keystr, valstr in project_default_options.items():
-            # Ths is complicated by the fact that a string can have two meanings:
-            #
-            # default_options: 'foo=bar'
-            #
-            # can be either
-            #
-            # A) a system option in which case the subproject is None
-            # B) a project option, in which case the subproject is '' (this method is only called from top level)
-            #
-            # The key parsing function can not handle the difference between the two
-            # and defaults to A.
-            if isinstance(keystr, str):
-                key = OptionKey.from_string(keystr)
-            else:
-                key = keystr
+        for key, valstr in project_default_options.items():
             # Due to backwards compatibility we ignore build-machine options
             # when building natively.
             if not self.is_cross and key.is_for_build():
                 continue
             if key.subproject:
-                self.augments[key] = valstr
-            elif key in self.options:
-                self.set_option(key, valstr, first_invocation)
+                # do apply project() default_options for subprojects here, because
+                # they have low priority
+                self.pending_options[key] = valstr
             else:
-                # Setting a project option with default_options.
-                # Argubly this should be a hard error, the default
+                # Setting a project option with default_options
+                # should arguably be a hard error; the default
                 # value of project option should be set in the option
                 # file, not in the project call.
-                proj_key = key.as_root()
-                if self.is_project_option(proj_key):
-                    self.set_option(proj_key, valstr)
-                else:
-                    self.pending_options[key] = valstr
+                self.set_option_maybe_root(key, valstr, True)
+
+        # ignore subprojects for now for machine file and command line
+        # options; they are applied later
         for key, valstr in machine_file_options.items():
             # Due to backwards compatibility we ignore all build-machine options
             # when building natively.
             if not self.is_cross and key.is_for_build():
                 continue
-            if key.subproject:
-                self.augments[key] = valstr
-            elif key in self.options:
-                self.set_option(key, valstr, first_invocation)
-            else:
-                proj_key = key.as_root()
-                if proj_key in self.options:
-                    self.set_option(proj_key, valstr, first_invocation)
-                else:
-                    self.pending_options[key] = valstr
-        for keystr, valstr in cmd_line_options.items():
-            if isinstance(keystr, str):
-                key = OptionKey.from_string(keystr)
-            else:
-                key = keystr
+            if not key.subproject:
+                self.set_option_maybe_root(key, valstr, True)
+        for key, valstr in cmd_line_options.items():
             # Due to backwards compatibility we ignore all build-machine options
             # when building natively.
             if not self.is_cross and key.is_for_build():
                 continue
-            if key.subproject:
-                self.augments[key] = valstr
-            elif key in self.options:
-                self.set_option(key, valstr, True)
-            else:
-                proj_key = key.as_root()
-                if proj_key in self.options:
-                    self.set_option(proj_key, valstr, True)
-                else:
-                    self.pending_options[key] = valstr
+            if not key.subproject:
+                self.set_option_maybe_root(key, valstr, True)
 
-    def accept_as_pending_option(self, key: OptionKey, known_subprojects: T.Optional[T.Union[T.Set[str], T.KeysView[str]]] = None) -> bool:
+    def accept_as_pending_option(self, key: OptionKey, known_subprojects: T.Optional[T.Container[str]] = None,
+                                 first_invocation: bool = False) -> bool:
         # Fail on unknown options that we can know must exist at this point in time.
         # Subproject and compiler options are resolved later.
         #
@@ -1369,17 +1344,14 @@ def accept_as_pending_option(self, key: OptionKey, known_subprojects: T.Optional
                 return True
         if self.is_compiler_option(key):
             return True
+        if first_invocation and self.is_backend_option(key):
+            return True
         return (self.is_base_option(key) and
                 key.evolve(subproject=None, machine=MachineChoice.HOST) in COMPILER_BASE_OPTIONS)
 
     def validate_cmd_line_options(self, cmd_line_options: OptionDict) -> None:
         unknown_options = []
-        for keystr, valstr in cmd_line_options.items():
-            if isinstance(keystr, str):
-                key = OptionKey.from_string(keystr)
-            else:
-                key = keystr
-
+        for key, valstr in cmd_line_options.items():
             if key in self.pending_options and not self.accept_as_pending_option(key):
                 unknown_options.append(f'"{key}"')
 
@@ -1391,34 +1363,38 @@ def initialize_from_subproject_call(self,
                                         subproject: str,
                                         spcall_default_options: OptionDict,
                                         project_default_options: OptionDict,
-                                        cmd_line_options: OptionDict) -> None:
-        is_first_invocation = True
-        for keystr, valstr in itertools.chain(project_default_options.items(), spcall_default_options.items()):
-            if isinstance(keystr, str):
-                key = OptionKey.from_string(keystr)
-            else:
-                key = keystr
+                                        cmd_line_options: OptionDict,
+                                        machine_file_options: OptionDict) -> None:
+        # pick up pending per-project settings from the toplevel project() invocation
+        options = {k: v for k, v in self.pending_options.items() if k.subproject == subproject}
+
+        # apply project() and subproject() default_options
+        for key, valstr in itertools.chain(project_default_options.items(), spcall_default_options.items()):
             if key.subproject is None:
                 key = key.evolve(subproject=subproject)
             elif key.subproject == subproject:
                 without_subp = key.evolve(subproject=None)
                 raise MesonException(f'subproject name not needed in default_options; use "{without_subp}" instead of "{key}"')
-            # If the key points to a project option, set the value from that.
-            # Otherwise set an augment.
-            if key in self.project_options:
-                self.set_option(key, valstr, is_first_invocation)
-            else:
-                self.pending_options.pop(key, None)
-                self.augments[key] = valstr
-        # Check for pending options
-        for key, valstr in cmd_line_options.items(): # type: ignore [assignment]
-            if not isinstance(key, OptionKey):
-                key = OptionKey.from_string(key)
-            if key.subproject != subproject:
-                continue
+            options[key] = valstr
+
+        # then global settings from machine file and command line
+        for key, valstr in itertools.chain(machine_file_options.items(), cmd_line_options.items()):
+            if key.subproject is None:
+                subp_key = key.evolve(subproject=subproject)
+                self.pending_options.pop(subp_key, None)
+                options.pop(subp_key, None)
+
+        # then finally per project augments from machine file and command line
+        for key, valstr in itertools.chain(machine_file_options.items(), cmd_line_options.items()):
+            if key.subproject == subproject:
+                options[key] = valstr
+
+        # merge everything that has been computed above, while giving self.augments priority
+        for key, valstr in options.items():
             self.pending_options.pop(key, None)
-            if key in self.options:
-                self.set_option(key, valstr, is_first_invocation)
+            valstr = self.augments.pop(key, valstr)
+            if key in self.project_options:
+                self.set_option(key, valstr, True)
             else:
                 self.augments[key] = valstr
 


Test patch:
diff --git a/test cases/common/223 persubproject options/meson.build b/test cases/common/223 persubproject options/meson.build
index 25a01008ac29..abe3bee7efe7 100644
--- a/test cases/common/223 persubproject options/meson.build
+++ b/test cases/common/223 persubproject options/meson.build
@@ -1,5 +1,5 @@
 project('persubproject options', 'c', 'cpp',
-  default_options : ['werror=true',
+  default_options : ['werror=true', 'default_library=both',
                      'warning_level=3',
                      'cpp_std=c++11'])
 
diff --git a/test cases/common/223 persubproject options/test.json b/test cases/common/223 persubproject options/test.json
index ccfa9ff5f27b..ef50ab589841 100644
--- a/test cases/common/223 persubproject options/test.json
+++ b/test cases/common/223 persubproject options/test.json
@@ -1,7 +1,3 @@
 {
-    "matrix": {
-        "options": {
-            "default_library": [ { "val": "both" } ]
-        }
-    }
+  "skip_on_env": ["MESON_TEST_DDEFAULT_LIBRARY"]
 }
diff --git a/test cases/common/247 deprecated option/test.json b/test cases/common/247 deprecated option/test.json
index a644b0405a5c..4b9a4750a12f 100644
--- a/test cases/common/247 deprecated option/test.json
+++ b/test cases/common/247 deprecated option/test.json
@@ -26,77 +26,77 @@
   },
   "stdout": [
     {
-      "line": ".*DEPRECATION: Option 'o1' is deprecated",
+      "line": ".*DEPRECATION: Option \"o1\" is deprecated",
       "match": "re",
       "count": 1
     },
     {
-      "line": ".*DEPRECATION: Option 'o2' value 'a' is deprecated",
+      "line": ".*DEPRECATION: Option \"o2\" value 'a' is deprecated",
       "match": "re",
       "count": 1
     },
     {
-      "line": ".*DEPRECATION: Option 'o3' value 'a' is replaced by 'c'",
+      "line": ".*DEPRECATION: Option \"o3\" value 'a' is replaced by 'c'",
       "match": "re",
       "count": 1
     },
     {
-      "line": ".*DEPRECATION: Option 'o4' value 'true' is replaced by 'enabled'",
+      "line": ".*DEPRECATION: Option \"o4\" value 'true' is replaced by 'enabled'",
       "match": "re",
       "count": 1
     },
     {
-      "line": ".*DEPRECATION: Option 'o5' value 'auto' is replaced by 'false'",
+      "line": ".*DEPRECATION: Option \"o5\" value 'auto' is replaced by 'false'",
       "match": "re",
       "count": 1
     },
     {
-      "line": ".*DEPRECATION: Option 'p1' is deprecated",
+      "line": ".*DEPRECATION: Option \"p1\" is deprecated",
       "match": "re",
       "count": 1
     },
     {
-      "line": ".*DEPRECATION: Option 'p2' value 'a' is deprecated",
+      "line": ".*DEPRECATION: Option \"p2\" value 'a' is deprecated",
       "match": "re",
       "count": 1
     },
     {
-      "line": ".*DEPRECATION: Option 'p3' value 'a' is replaced by 'c'",
+      "line": ".*DEPRECATION: Option \"p3\" value 'a' is replaced by 'c'",
       "match": "re",
       "count": 1
     },
     {
-      "line": ".*DEPRECATION: Option 'p4' value 'true' is replaced by 'enabled'",
+      "line": ".*DEPRECATION: Option \"p4\" value 'true' is replaced by 'enabled'",
       "match": "re",
       "count": 1
     },
     {
-      "line": ".*DEPRECATION: Option 'p5' value 'auto' is replaced by 'false'",
+      "line": ".*DEPRECATION: Option \"p5\" value 'auto' is replaced by 'false'",
       "match": "re",
       "count": 1
     },
     {
-      "line": ".*DEPRECATION: Option 'c1' is deprecated",
+      "line": ".*DEPRECATION: Option \"c1\" is deprecated",
       "match": "re",
       "count": 1
     },
     {
-      "line": ".*DEPRECATION: Option 'c2' value 'a' is deprecated",
+      "line": ".*DEPRECATION: Option \"c2\" value 'a' is deprecated",
       "match": "re",
       "count": 1
     },
     {
-      "line": ".*DEPRECATION: Option 'c3' value 'a' is replaced by 'c'",
+      "line": ".*DEPRECATION: Option \"c3\" value 'a' is replaced by 'c'",
       "match": "re",
       "count": 1
     },
     {
-      "line": ".*DEPRECATION: Option 'c4' value 'true' is replaced by 'enabled'",
+      "line": ".*DEPRECATION: Option \"c4\" value 'true' is replaced by 'enabled'",
       "match": "re",
       "count": 1
     },
     {
-      "line": ".*DEPRECATION: Option 'c5' value 'auto' is replaced by 'false'",
+      "line": ".*DEPRECATION: Option \"c5\" value 'auto' is replaced by 'false'",
       "match": "re",
       "count": 1
     }
diff --git a/unittests/machinefiletests.py b/unittests/machinefiletests.py
index b2839e6b289a..7f88a54bf038 100644
--- a/unittests/machinefiletests.py
+++ b/unittests/machinefiletests.py
@@ -550,8 +550,8 @@ def test_builtin_options_subprojects(self):
         # into augments.
         self.assertEqual(found, 2, 'Did not find all two sections')
 
-    def test_builtin_options_subprojects_overrides_buildfiles(self):
-        # If the buildfile says subproject(... default_library: shared), ensure that's overwritten
+    def test_builtin_options_machinefile_overrides_subproject(self):
+        # The buildfile says subproject(... default_library: static), the machinefile overrides it
         testcase = os.path.join(self.common_test_dir, '223 persubproject options')
         config = self.helper_create_native_file({'sub2:built-in options': {'default_library': 'shared'}})
 
@@ -563,11 +563,18 @@ def test_builtin_options_subprojects_overrides_buildfiles(self):
                 check = cm.exception.stdout
             self.assertIn(check, 'Parent should override default_library')
 
-    def test_builtin_options_subprojects_dont_inherits_parent_override(self):
-        # If the buildfile says subproject(... default_library: shared), ensure that's overwritten
+    def test_builtin_options_machinefile_global_overrides_subproject(self):
+        # The buildfile says subproject(... default_library: static), ensure that's overridden
         testcase = os.path.join(self.common_test_dir, '223 persubproject options')
         config = self.helper_create_native_file({'built-in options': {'default_library': 'both'}})
-        self.init(testcase, extra_args=['--native-file', config])
+
+        with self.assertRaises((RuntimeError, subprocess.CalledProcessError)) as cm:
+            self.init(testcase, extra_args=['--native-file', config])
+            if isinstance(cm, RuntimeError):
+                check = str(cm.exception)
+            else:
+                check = cm.exception.stdout
+            self.assertIn(check, 'Parent should override default_library')
 
     def test_builtin_options_compiler_properties(self):
         # the properties section can have lang_args, and those need to be
diff --git a/unittests/optiontests.py b/unittests/optiontests.py
index ee84f9582261..3e87b5ca21df 100644
--- a/unittests/optiontests.py
+++ b/unittests/optiontests.py
@@ -55,9 +55,9 @@ def test_subproject_system_option(self):
         """Test that subproject system options get their default value from the global
            option (e.g. "sub:b_lto" can be initialized from "b_lto")."""
         optstore = OptionStore(False)
-        name = 'someoption'
-        default_value = 'somevalue'
-        new_value = 'new_value'
+        name = 'b_lto'
+        default_value = 'false'
+        new_value = 'true'
         k = OptionKey(name)
         subk = k.evolve(subproject='sub')
         optstore.initialize_from_top_level_project_call({}, {}, {OptionKey(name): new_value})
@@ -224,6 +224,12 @@ def test_b_nonexistent(self):
         self.assertTrue(optstore.accept_as_pending_option(OptionKey('b_ndebug')))
         self.assertFalse(optstore.accept_as_pending_option(OptionKey('b_whatever')))
 
+    def test_backend_option_pending(self):
+        optstore = OptionStore(False)
+        # backend options are known after the first invocation
+        self.assertTrue(optstore.accept_as_pending_option(OptionKey('backend_whatever'), set(), True))
+        self.assertFalse(optstore.accept_as_pending_option(OptionKey('backend_whatever'), set(), False))
+
     def test_reconfigure_b_nonexistent(self):
         optstore = OptionStore(False)
         optstore.set_from_configure_command(['b_ndebug=true'], [])
@@ -234,6 +240,68 @@ def test_subproject_nonexistent(self):
         self.assertFalse(optstore.accept_as_pending_option(OptionKey('foo', subproject='found'), subprojects))
         self.assertTrue(optstore.accept_as_pending_option(OptionKey('foo', subproject='whatisthis'), subprojects))
 
+    def test_subproject_cmdline_override_global(self):
+        name = 'optimization'
+        subp = 'subp'
+        new_value = '0'
+
+        optstore = OptionStore(False)
+        prefix = UserStringOption('prefix', 'This is needed by OptionStore', '/usr')
+        optstore.add_system_option('prefix', prefix)
+        o = UserComboOption(name, 'Optimization level', '0', choices=['plain', '0', 'g', '1', '2', '3', 's'])
+        optstore.add_system_option(name, o)
+
+        toplevel_proj_default = {OptionKey(name): 's'}
+        subp_proj_default = {OptionKey(name): '3'}
+        cmd_line = {OptionKey(name): new_value}
+
+        optstore.initialize_from_top_level_project_call(toplevel_proj_default, cmd_line, {})
+        optstore.initialize_from_subproject_call(subp, {}, subp_proj_default, cmd_line, {})
+        self.assertEqual(optstore.get_value_for(name, subp), new_value)
+        self.assertEqual(optstore.get_value_for(name), new_value)
+
+    def test_subproject_cmdline_override_global_and_augment(self):
+        name = 'optimization'
+        subp = 'subp'
+        global_value = 's'
+        new_value = '0'
+
+        optstore = OptionStore(False)
+        prefix = UserStringOption('prefix', 'This is needed by OptionStore', '/usr')
+        optstore.add_system_option('prefix', prefix)
+        o = UserComboOption(name, 'Optimization level', '0', choices=['plain', '0', 'g', '1', '2', '3', 's'])
+        optstore.add_system_option(name, o)
+
+        toplevel_proj_default = {OptionKey(name): '1'}
+        subp_proj_default = {OptionKey(name): '3'}
+        cmd_line = {OptionKey(name): global_value, OptionKey(name, subproject=subp): new_value}
+
+        optstore.initialize_from_top_level_project_call(toplevel_proj_default, cmd_line, {})
+        optstore.initialize_from_subproject_call(subp, {}, subp_proj_default, cmd_line, {})
+        self.assertEqual(optstore.get_value_for(name, subp), new_value)
+        self.assertEqual(optstore.get_value_for(name), global_value)
+
+    def test_subproject_cmdline_override_toplevel(self):
+        name = 'default_library'
+        subp = 'subp'
+        toplevel_value = 'both'
+        subp_value = 'static'
+
+        optstore = OptionStore(False)
+        prefix = UserStringOption('prefix', 'This is needed by OptionStore', '/usr')
+        optstore.add_system_option('prefix', prefix)
+        o = UserComboOption(name, 'Kind of library', 'both', choices=['shared', 'static', 'both'])
+        optstore.add_system_option(name, o)
+
+        toplevel_proj_default = {OptionKey(name): 'shared'}
+        subp_proj_default = {OptionKey(name): subp_value}
+        cmd_line = {OptionKey(name, subproject=''): toplevel_value}
+
+        optstore.initialize_from_top_level_project_call(toplevel_proj_default, cmd_line, {})
+        optstore.initialize_from_subproject_call(subp, {}, subp_proj_default, cmd_line, {})
+        self.assertEqual(optstore.get_value_for(name, subp), subp_value)
+        self.assertEqual(optstore.get_value_for(name), toplevel_value)
+
     def test_deprecated_nonstring_value(self):
         # TODO: add a lot more deprecated option tests
         optstore = OptionStore(False)
diff --git a/unittests/platformagnostictests.py b/unittests/platformagnostictests.py
index 75071d9da55d..ba2c38f7537c 100644
--- a/unittests/platformagnostictests.py
+++ b/unittests/platformagnostictests.py
@@ -175,7 +175,7 @@ def test_change_backend(self):
         with self.subTest('Changing the backend'):
             with self.assertRaises(subprocess.CalledProcessError) as cm:
                 self.setconf('-Dbackend=none')
-            self.assertIn("ERROR: Tried to modify read only option 'backend'", cm.exception.stdout)
+            self.assertIn('ERROR: Tried to modify read only option "backend"', cm.exception.stdout)
 
         # Check that the new value was not written in the store.
         with self.subTest('option is stored correctly'):
@@ -421,12 +421,12 @@ def test_setup_with_unknown_option(self):
 
         with self.subTest('unknown user option'):
             out = self.init(testdir, extra_args=['-Dnot_an_option=1'], allow_fail=True)
-            self.assertIn('ERROR: Unknown options: "not_an_option"', out)
+            self.assertIn('ERROR: Unknown option: "not_an_option"', out)
 
         with self.subTest('unknown builtin option'):
             self.new_builddir()
             out = self.init(testdir, extra_args=['-Db_not_an_option=1'], allow_fail=True)
-            self.assertIn('ERROR: Unknown options: "b_not_an_option"', out)
+            self.assertIn('ERROR: Unknown option: "b_not_an_option"', out)
 
 
     def test_configure_new_option(self) -> None:
@@ -451,7 +451,7 @@ def test_configure_removed_option(self) -> None:
                 f.write(line)
         with self.assertRaises(subprocess.CalledProcessError) as e:
             self.setconf('-Dneg_int_opt=0')
-        self.assertIn('Unknown options: ":neg_int_opt"', e.exception.stdout)
+        self.assertIn('Unknown option: "neg_int_opt"', e.exception.stdout)
 
     def test_reconfigure_option(self) -> None:
         testdir = self.copy_srcdir(os.path.join(self.common_test_dir, '40 options'))
@@ -501,7 +501,7 @@ def test_configure_options_file_deleted(self) -> None:
         os.unlink(os.path.join(testdir, 'meson_options.txt'))
         with self.assertRaises(subprocess.CalledProcessError) as e:
             self.setconf('-Dneg_int_opt=0')
-        self.assertIn('Unknown options: ":neg_int_opt"', e.exception.stdout)
+        self.assertIn('Unknown option: "neg_int_opt"', e.exception.stdout)
 
     def test_configure_options_file_added(self) -> None:
         """A new project option file should be detected."""