From 40145a4cdd2ef32ff69354a8bdf0ec10551579ba Mon Sep 17 00:00:00 2001 From: Aryan Sachin Date: Fri, 30 Oct 2026 13:57:04 +0530 Subject: [PATCH] fix(decorators): avoid mutating original function metadata on functools.wraps When decorators like SetParseFn and SetParseFns are applied to functions wrapped with functools.wraps, copying existing parse_fns and metadata dictionaries prevents mutating the underlying function's metadata in place. Fixes #728. Signed-off-by: Aryan Sachin --- fire/decorators.py | 17 +++++++++++++---- fire/decorators_test.py | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/fire/decorators.py b/fire/decorators.py index 547153c65..66c69ecb7 100644 --- a/fire/decorators.py +++ b/fire/decorators.py @@ -37,7 +37,12 @@ def SetParseFn(fn, *arguments): The decorated function, which now has metadata telling Fire how to perform. """ def _Decorator(func): - parse_fns = GetParseFns(func) + existing = GetParseFns(func) + parse_fns = { + 'default': existing.get('default'), + 'positional': list(existing.get('positional', [])), + 'named': dict(existing.get('named', {})), + } if not arguments: parse_fns['default'] = fn else: @@ -66,8 +71,12 @@ def SetParseFns(*positional, **named): The decorated function, which now has metadata telling Fire how to perform. """ def _Decorator(fn): - parse_fns = GetParseFns(fn) - parse_fns['positional'] = positional + existing = GetParseFns(fn) + parse_fns = { + 'default': existing.get('default'), + 'positional': positional if positional else tuple(existing.get('positional', ())), + 'named': dict(existing.get('named', {})), + } parse_fns['named'].update(named) _SetMetadata(fn, FIRE_PARSE_FNS, parse_fns) return fn @@ -76,7 +85,7 @@ def _Decorator(fn): def _SetMetadata(fn, attribute, value): - metadata = GetMetadata(fn) + metadata = dict(GetMetadata(fn)) metadata[attribute] = value setattr(fn, FIRE_METADATA, metadata) diff --git a/fire/decorators_test.py b/fire/decorators_test.py index 9988743cc..03d83585a 100644 --- a/fire/decorators_test.py +++ b/fire/decorators_test.py @@ -14,6 +14,7 @@ """Tests for the decorators module.""" +import functools from fire import core from fire import decorators from fire import testutils @@ -165,6 +166,32 @@ def testSetParseFn(self): command=['example7', '1', '--arg2=2', '3', '4', '--kwarg=5']), ('1', '2', ('3', '4'), {'kwarg': '5'})) + def testSetParseFnsFunctoolsWraps(self): + @decorators.SetParseFns(value=int) + def original(value): + return type(value).__name__ + + @decorators.SetParseFns(value=str) + @functools.wraps(original) + def wrapped(value): + return original(value) + + self.assertEqual(core.Fire(original, command=['01']), 'int') + self.assertEqual(core.Fire(wrapped, command=['01']), 'str') + + def testSetParseFnFunctoolsWraps(self): + @decorators.SetParseFn(int, 'value') + def original(value): + return type(value).__name__ + + @decorators.SetParseFn(str, 'value') + @functools.wraps(original) + def wrapped(value): + return original(value) + + self.assertEqual(core.Fire(original, command=['01']), 'int') + self.assertEqual(core.Fire(wrapped, command=['01']), 'str') + if __name__ == '__main__': testutils.main()