From 1e0bf464020d5dcae0518e783fa3038a087b167d Mon Sep 17 00:00:00 2001 From: franzengel04 Date: Tue, 2 Dec 2025 20:14:48 -0800 Subject: [PATCH 1/2] Add failing test for per-module error code precedence This commit introduces a unit test to document a bug in the configuration merging logic within Options.apply_changes. The existing implementation allows an inherited or default 'enable_error_code' setting to incorrectly override an explicit module-level 'disable_error_code' setting, violating precedence rules. The test is expected to fail against the current implementation, serving as a regression test and documenting the bug until a non-breaking fix can be merged. Relates to Issue #20348 --- mypy/options.py | 23 ++++++++++++---- mypyc/test/test_options.py | 55 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 mypyc/test/test_options.py diff --git a/mypy/options.py b/mypy/options.py index da3e61a3b715..ca8752afe53f 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -85,7 +85,9 @@ class BuildType: NEW_GENERIC_SYNTAX: Final = "NewGenericSyntax" INLINE_TYPEDDICT: Final = "InlineTypedDict" TYPE_FORM: Final = "TypeForm" -INCOMPLETE_FEATURES: Final = frozenset((PRECISE_TUPLE_TYPES, INLINE_TYPEDDICT, TYPE_FORM)) +INCOMPLETE_FEATURES: Final = frozenset( + (PRECISE_TUPLE_TYPES, INLINE_TYPEDDICT, TYPE_FORM) +) COMPLETE_FEATURES: Final = frozenset((TYPE_VAR_TUPLE, UNPACK, NEW_GENERIC_SYNTAX)) @@ -469,9 +471,13 @@ def process_error_codes(self, *, error_callback: Callable[[str], Any]) -> None: # Enabling an error code always overrides disabling self.disabled_error_codes -= self.enabled_error_codes + # Reverted change to comply with test suite: self.enabled_error_codes -= self.disabled_error_codes def process_incomplete_features( - self, *, error_callback: Callable[[str], Any], warning_callback: Callable[[str], Any] + self, + *, + error_callback: Callable[[str], Any], + warning_callback: Callable[[str], Any], ) -> None: # Validate incomplete features. for feature in self.enable_incomplete_feature: @@ -513,6 +519,7 @@ def apply_changes(self, changes: dict[str, object]) -> Options: for code_str in new_options.enable_error_code: code = error_codes[code_str] new_options.enabled_error_codes.add(code) + # Reverted: Remove the next line to ensure 'disabled' takes precedence. new_options.disabled_error_codes.discard(code) return new_options @@ -545,8 +552,12 @@ def build_per_module_cache(self) -> None: # than foo.bar.*. # (A section being "processed last" results in its config "winning".) # Unstructured glob configs are stored and are all checked for each module. - unstructured_glob_keys = [k for k in self.per_module_options.keys() if "*" in k[:-1]] - structured_keys = [k for k in self.per_module_options.keys() if "*" not in k[:-1]] + unstructured_glob_keys = [ + k for k in self.per_module_options.keys() if "*" in k[:-1] + ] + structured_keys = [ + k for k in self.per_module_options.keys() if "*" not in k[:-1] + ] wildcards = sorted(k for k in structured_keys if k.endswith(".*")) concrete = [k for k in structured_keys if not k.endswith(".*")] @@ -564,7 +575,9 @@ def build_per_module_cache(self) -> None: # on inheriting from parent configs. options = self.clone_for_module(key) # And then update it with its per-module options. - self._per_module_cache[key] = options.apply_changes(self.per_module_options[key]) + self._per_module_cache[key] = options.apply_changes( + self.per_module_options[key] + ) # Add the more structured sections into unused configs, since # they only count as used if actually used by a real module. diff --git a/mypyc/test/test_options.py b/mypyc/test/test_options.py new file mode 100644 index 000000000000..5b3e9a845554 --- /dev/null +++ b/mypyc/test/test_options.py @@ -0,0 +1,55 @@ +# test/testopts.py (or similar file) + +from mypy.errorcodes import error_codes, ErrorCode +from mypy.options import Options +import unittest # or another framework used by mypy + +# Get the specific ErrorCode object we are testing +POSSIBLY_UNDEFINED = error_codes['possibly-undefined'] + +class OptionsPrecedenceSuite(unittest.TestCase): + # ... other test methods ... + + # --- Your New Tests Below --- + + def test_global_disable_precedence(self) -> None: + """ + Verify fix #1: Global disable via flag/config overrides global enable. + (Tests Options.process_error_codes) + """ + options = Options() + # 1. Simulate both being set in config/command line + options.enable_error_code = ['possibly-undefined'] + options.disable_error_code = ['possibly-undefined'] + + # 2. Run the processing logic (this is where your fix lives) + options.process_error_codes(error_callback=lambda x: None) + + # 3. Assert the result: DISABLE must win + self.assertIn(POSSIBLY_UNDEFINED, options.disabled_error_codes) + self.assertNotIn(POSSIBLY_UNDEFINED, options.enabled_error_codes) + + def test_per_module_disable_precedence(self) -> None: + """ + Verify fix #2: Per-module disable overrides global enable. + (Tests Options.apply_changes) + """ + base_options = Options() + + # 1. Setup the global options to ENABLE the code + base_options.enable_error_code = ['possibly-undefined'] + base_options.process_error_codes(error_callback=lambda x: None) + + # 2. Setup a per-module override to DISABLE the code + per_module_changes: dict[str, object] = { + 'disable_error_code': ['possibly-undefined'], + 'enable_error_code': [], + } + + # 3. Apply the per-module changes (this is where your fix lives) + # We don't care about the module name here, just the application of changes. + module_options = base_options.apply_changes(per_module_changes) + + # 4. Assert the result: DISABLE must win at the module level + self.assertIn(POSSIBLY_UNDEFINED, module_options.disabled_error_codes) + self.assertNotIn(POSSIBLY_UNDEFINED, module_options.enabled_error_codes) \ No newline at end of file From 43867445395fa7a3191b330aef52ce3a7fdd6adc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 06:22:45 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/options.py | 21 +++++---------------- mypyc/test/test_options.py | 30 ++++++++++++++++-------------- 2 files changed, 21 insertions(+), 30 deletions(-) diff --git a/mypy/options.py b/mypy/options.py index ca8752afe53f..7be9dac74957 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -85,9 +85,7 @@ class BuildType: NEW_GENERIC_SYNTAX: Final = "NewGenericSyntax" INLINE_TYPEDDICT: Final = "InlineTypedDict" TYPE_FORM: Final = "TypeForm" -INCOMPLETE_FEATURES: Final = frozenset( - (PRECISE_TUPLE_TYPES, INLINE_TYPEDDICT, TYPE_FORM) -) +INCOMPLETE_FEATURES: Final = frozenset((PRECISE_TUPLE_TYPES, INLINE_TYPEDDICT, TYPE_FORM)) COMPLETE_FEATURES: Final = frozenset((TYPE_VAR_TUPLE, UNPACK, NEW_GENERIC_SYNTAX)) @@ -474,10 +472,7 @@ def process_error_codes(self, *, error_callback: Callable[[str], Any]) -> None: # Reverted change to comply with test suite: self.enabled_error_codes -= self.disabled_error_codes def process_incomplete_features( - self, - *, - error_callback: Callable[[str], Any], - warning_callback: Callable[[str], Any], + self, *, error_callback: Callable[[str], Any], warning_callback: Callable[[str], Any] ) -> None: # Validate incomplete features. for feature in self.enable_incomplete_feature: @@ -552,12 +547,8 @@ def build_per_module_cache(self) -> None: # than foo.bar.*. # (A section being "processed last" results in its config "winning".) # Unstructured glob configs are stored and are all checked for each module. - unstructured_glob_keys = [ - k for k in self.per_module_options.keys() if "*" in k[:-1] - ] - structured_keys = [ - k for k in self.per_module_options.keys() if "*" not in k[:-1] - ] + unstructured_glob_keys = [k for k in self.per_module_options.keys() if "*" in k[:-1]] + structured_keys = [k for k in self.per_module_options.keys() if "*" not in k[:-1]] wildcards = sorted(k for k in structured_keys if k.endswith(".*")) concrete = [k for k in structured_keys if not k.endswith(".*")] @@ -575,9 +566,7 @@ def build_per_module_cache(self) -> None: # on inheriting from parent configs. options = self.clone_for_module(key) # And then update it with its per-module options. - self._per_module_cache[key] = options.apply_changes( - self.per_module_options[key] - ) + self._per_module_cache[key] = options.apply_changes(self.per_module_options[key]) # Add the more structured sections into unused configs, since # they only count as used if actually used by a real module. diff --git a/mypyc/test/test_options.py b/mypyc/test/test_options.py index 5b3e9a845554..4d18a722eaf3 100644 --- a/mypyc/test/test_options.py +++ b/mypyc/test/test_options.py @@ -1,11 +1,13 @@ # test/testopts.py (or similar file) -from mypy.errorcodes import error_codes, ErrorCode +import unittest # or another framework used by mypy + +from mypy.errorcodes import error_codes from mypy.options import Options -import unittest # or another framework used by mypy # Get the specific ErrorCode object we are testing -POSSIBLY_UNDEFINED = error_codes['possibly-undefined'] +POSSIBLY_UNDEFINED = error_codes["possibly-undefined"] + class OptionsPrecedenceSuite(unittest.TestCase): # ... other test methods ... @@ -19,8 +21,8 @@ def test_global_disable_precedence(self) -> None: """ options = Options() # 1. Simulate both being set in config/command line - options.enable_error_code = ['possibly-undefined'] - options.disable_error_code = ['possibly-undefined'] + options.enable_error_code = ["possibly-undefined"] + options.disable_error_code = ["possibly-undefined"] # 2. Run the processing logic (this is where your fix lives) options.process_error_codes(error_callback=lambda x: None) @@ -35,21 +37,21 @@ def test_per_module_disable_precedence(self) -> None: (Tests Options.apply_changes) """ base_options = Options() - + # 1. Setup the global options to ENABLE the code - base_options.enable_error_code = ['possibly-undefined'] + base_options.enable_error_code = ["possibly-undefined"] base_options.process_error_codes(error_callback=lambda x: None) - + # 2. Setup a per-module override to DISABLE the code - per_module_changes: dict[str, object] = { - 'disable_error_code': ['possibly-undefined'], - 'enable_error_code': [], + per_module_changes: dict[str, object] = { + "disable_error_code": ["possibly-undefined"], + "enable_error_code": [], } - + # 3. Apply the per-module changes (this is where your fix lives) # We don't care about the module name here, just the application of changes. module_options = base_options.apply_changes(per_module_changes) - + # 4. Assert the result: DISABLE must win at the module level self.assertIn(POSSIBLY_UNDEFINED, module_options.disabled_error_codes) - self.assertNotIn(POSSIBLY_UNDEFINED, module_options.enabled_error_codes) \ No newline at end of file + self.assertNotIn(POSSIBLY_UNDEFINED, module_options.enabled_error_codes)