From 94216cbe674450890e897a97be6096994158781b Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Sat, 8 Aug 2026 08:37:33 -0600 Subject: [PATCH 1/4] revert parameters in old function signatures to prevent errors when the name is specified in the signature --- src/diffpy/srfit/fitbase/parameter.py | 4 +- src/diffpy/srfit/fitbase/recipeorganizer.py | 41 ++++++++++----------- tests/test_parameter.py | 2 +- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/diffpy/srfit/fitbase/parameter.py b/src/diffpy/srfit/fitbase/parameter.py index d6a52d1a..74cab96c 100644 --- a/src/diffpy/srfit/fitbase/parameter.py +++ b/src/diffpy/srfit/fitbase/parameter.py @@ -182,13 +182,13 @@ def bound_range(self, lower_bound=None, upper_bound=None): return self @deprecated(boundRange_dep_msg) - def boundRange(self, lower_bound=None, upper_bound=None): + def boundRange(self, lb=None, ub=None): """This function has been deprecated and will be removed in version 4.0.0. Please use diffpy.srfit.fitbase.Parameter.bound_range instead. """ - self.bound_range(lower_bound, upper_bound) + self.bound_range(lb, ub) return self def bound_window(self, lower_radius=0, upper_radius=None): diff --git a/src/diffpy/srfit/fitbase/recipeorganizer.py b/src/diffpy/srfit/fitbase/recipeorganizer.py index 0e7e54cd..caa28aed 100644 --- a/src/diffpy/srfit/fitbase/recipeorganizer.py +++ b/src/diffpy/srfit/fitbase/recipeorganizer.py @@ -941,23 +941,28 @@ def register_function(self, function, name=None, argnames=None): import inspect + # A decorator such as `deprecated` replaces the code object with + # that of its (*args, **kwargs) wrapper, so introspect the + # function it wraps while still registering the decorated one. + wrapped_function = inspect.unwrap(function) + fncode = None # This will let us offset the argument list to eliminate 'self' offset = 0 # check regular functions - if inspect.isfunction(function): - fncode = function.__code__ + if inspect.isfunction(wrapped_function): + fncode = wrapped_function.__code__ # check class method elif inspect.ismethod(function): fncode = function.__func__.__code__ offset = 1 # check functor - elif hasattr(function, "__call__") and hasattr( - function.__call__, "__func__" + elif hasattr(wrapped_function, "__call__") and hasattr( + wrapped_function.__call__, "__func__" ): - fncode = function.__call__.__func__.__code__ + fncode = wrapped_function.__call__.__func__.__code__ offset = 1 else: m = "Cannot extract name or argnames" @@ -1182,7 +1187,7 @@ def add_constraint(self, parameter, constraint_eq, params={}): return @deprecated(constrain_deprecation_msg) - def constrain(self, parameter, constraint_eq, params={}): + def constrain(self, par, con, ns={}): """This function has been deprecated and will be removed in version 4.0.0. @@ -1190,7 +1195,7 @@ def constrain(self, parameter, constraint_eq, params={}): diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.add_constraint instead. """ - self.add_constraint(parameter, constraint_eq, params=params) + self.add_constraint(par, con, params=ns) return def is_constrained(self, parameter): @@ -1214,7 +1219,7 @@ def is_constrained(self, parameter): return parameter in self._constraints @deprecated(isConstrained_deprecation_msg) - def isConstrained(self, parameter): + def isConstrained(self, par): """This function has been deprecated and will be removed in version 4.0.0. @@ -1222,7 +1227,7 @@ def isConstrained(self, parameter): diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer.is_constrained instead. """ - return self.is_constrained(parameter) + return self.is_constrained(par) def remove_constraint(self, *pars): """Unconstrain a Parameter. @@ -1424,15 +1429,7 @@ def add_soft_bounds( return param_or_eq @deprecated(restrain_deprecation_msg) - def restrain( - self, - param_or_eq, - lower_bound=-inf, - upper_bound=inf, - sig=1, - scaled=False, - params={}, - ): + def restrain(self, res, lb=-inf, ub=inf, sig=1, scaled=False, ns={}): """This function has been deprecated and will be removed in version 4.0.0. @@ -1441,12 +1438,12 @@ def restrain( instead. """ return self.add_soft_bounds( - param_or_eq, - lower_bound=lower_bound, - upper_bound=upper_bound, + res, + lower_bound=lb, + upper_bound=ub, sig=sig, scaled=scaled, - params=params, + params=ns, ) def register_soft_bounds(self, res): diff --git a/tests/test_parameter.py b/tests/test_parameter.py index a56d3ddf..c954256b 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -165,7 +165,7 @@ def test_boundRange(lower, upper, expected): # If testing overwrite, pre-set bounds to see overwrite effect if expected == [2, 6]: p.boundRange(0, 10) - p.boundRange(lower_bound=lower, upper_bound=upper) + p.boundRange(lb=lower, ub=upper) actual = p.bounds assert actual == expected From 5fc975191e23e5479a3f4faa24c6fbd0b863d1b1 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Sat, 8 Aug 2026 10:03:14 -0600 Subject: [PATCH 2/4] Change how the deprecated characteristic functions handle the signature change --- .../srfit/pdf/characteristicfunctions.py | 189 +++++++++--------- 1 file changed, 99 insertions(+), 90 deletions(-) diff --git a/src/diffpy/srfit/pdf/characteristicfunctions.py b/src/diffpy/srfit/pdf/characteristicfunctions.py index 742e5ae7..4e760538 100644 --- a/src/diffpy/srfit/pdf/characteristicfunctions.py +++ b/src/diffpy/srfit/pdf/characteristicfunctions.py @@ -55,53 +55,69 @@ removal_version = "4.0.0" cf_base = "diffpy.srfit.pdf.characteristicfunctions" -sphericalCF_dep_msg = build_deprecation_message( - cf_base, + +def _build_dep_msg(old_name, new_name, signature_note=None): + """Build the deprecation message for a camel case function. + + The note describing how the signature changed, when there is one, is + appended to the standard message from `build_deprecation_message`. + """ + message = build_deprecation_message( + cf_base, old_name, new_name, removal_version + ) + if signature_note is None: + return message + return f"{message} {signature_note}" + + +sphericalCF_dep_msg = _build_dep_msg( "sphericalCF", "spherical_particle", - removal_version, + "Additionally, the signature has changed. Please pass the parameter " + "'psize' as 'particle_diameter'.", ) -spheroidalCF_dep_msg = build_deprecation_message( - cf_base, +spheroidalCF_dep_msg = _build_dep_msg( "spheroidalCF", "spheroidal_particle", - removal_version, + "Additionally, the signature has changed. Please pass the parameters " + "'erad' and 'prad' as 'equatorial_radius' and 'polar_radius', " + "respectively.", ) -spheroidalCF2_dep_msg = build_deprecation_message( - cf_base, +spheroidalCF2_dep_msg = _build_dep_msg( "spheroidalCF2", "spheroidal_particle", - removal_version, + "Additionally, the parameterization has changed. 'spheroidalCF2' took " + "the equatorial diameter 'psize' and the axis ratio 'axrat', while " + "'spheroidal_particle' takes radii. Please pass " + "equatorial_radius = psize / 2 and polar_radius = axrat * psize / 2.", ) -lognormalSphericalCF_dep_msg = build_deprecation_message( - cf_base, +lognormalSphericalCF_dep_msg = _build_dep_msg( "lognormalSphericalCF", "lognormal_spherical_particle", - removal_version, + "Additionally, the signature has changed. Please pass the parameters " + "'psize' and 'psig' as 'particle_diameter' and " + "'particle_diameter_sigma', respectively.", ) -sheetCF_dep_msg = build_deprecation_message( - cf_base, +sheetCF_dep_msg = _build_dep_msg( "sheetCF", "sheet_particle", - removal_version, + "Additionally, the signature has changed. Please pass the parameter " + "'sthick' as 'sheet_thickness'.", ) -shellCF_dep_msg = build_deprecation_message( - cf_base, - "shellCF", - "shell_particle", - removal_version, -) +shellCF_dep_msg = _build_dep_msg("shellCF", "shell_particle") -shellCF2_dep_msg = build_deprecation_message( - cf_base, +shellCF2_dep_msg = _build_dep_msg( "shellCF2", "shell_particle", - removal_version, + "Additionally, the parameterization has changed. 'shellCF2' took the " + "central radius 'a' and the shell thickness 'delta', while " + "'shell_particle' takes the inner radius. Please pass " + "radius = a - delta / 2 and thickness = delta.", ) @@ -175,18 +191,6 @@ def spherical_particle(r, particle_diameter): return characteristic_function -@deprecated(sphericalCF_dep_msg) -def sphericalCF(r, psize): - """This function is deprecated and will be removed in version - 4.0.0. - - Please use - diffpy.srfit.pdf.characteristicfunctions.spherical_particle - instead. - """ - return spherical_particle(r, psize) - - def spheroidal_particle(r, equatorial_radius, polar_radius): """Compute the spheroidal nanoparticle characteristic function. @@ -321,32 +325,6 @@ def spheroidal_particle(r, equatorial_radius, polar_radius): return f -@deprecated(spheroidalCF_dep_msg) -def spheroidalCF(r, erad, prad): - """This function is deprecated and will be removed in version - 4.0.0. - - Please use - diffpy.srfit.pdf.characteristicfunctions.spheroidal_particle - instead. - """ - return spheroidal_particle(r, erad, prad) - - -@deprecated(spheroidalCF2_dep_msg) -def spheroidalCF2(r, psize, axrat): - """This function is deprecated and will be removed in version - 4.0.0. - - Please use - diffpy.srfit.pdf.characteristicfunctions.spheroidal_particle - instead. - """ - equatorial_radius = 0.5 * psize - polar_radius = axrat * equatorial_radius - return spheroidal_particle(r, equatorial_radius, polar_radius) - - def lognormal_spherical_particle( r, particle_diameter, particle_diameter_sigma ): @@ -443,18 +421,6 @@ def lognormal_spherical_particle( ) -@deprecated(lognormalSphericalCF_dep_msg) -def lognormalSphericalCF(r, psize, psig): - """This function is deprecated and will be removed in version - 4.0.0. - - Please use - diffpy.srfit.pdf.characteristicfunctions.lognormal_spherical_particle - instead. - """ - return lognormal_spherical_particle(r, psize, psig) - - def sheet_particle(r, sheet_thickness): """Compute the nanosheet characteristic function. @@ -506,17 +472,6 @@ def sheet_particle(r, sheet_thickness): return characteristic_function -@deprecated(sheetCF_dep_msg) -def sheetCF(r, sthick): - """This function is deprecated and will be removed in version - 4.0.0. - - Please use diffpy.srfit.pdf.characteristicfunctions.sheet_particle - instead. - """ - return sheet_particle(r, sthick) - - def shell_particle(r, radius, thickness): """Compute the spherical shell characteristic function. @@ -582,9 +537,65 @@ def shell_particle(r, radius, thickness): return f +@deprecated(sphericalCF_dep_msg) +def sphericalCF(r, psize): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.pdf.characteristicfunctions.spherical_particle + instead. + """ + return spherical_particle(r, psize) + + +@deprecated(spheroidalCF_dep_msg) +def spheroidalCF(r, erad, prad): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.pdf.characteristicfunctions.spheroidal_particle + instead. + """ + return spheroidal_particle(r, erad, prad) + + +@deprecated(spheroidalCF2_dep_msg) +def spheroidalCF2(r, psize, axrat): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.pdf.characteristicfunctions.spheroidal_particle + instead. + """ + return spheroidal_particle(r, psize / 2, axrat * psize / 2) + + +@deprecated(lognormalSphericalCF_dep_msg) +def lognormalSphericalCF(r, psize, psig): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.pdf.characteristicfunctions.lognormal_spherical_particle + instead. + """ + return lognormal_spherical_particle(r, psize, psig) + + +@deprecated(sheetCF_dep_msg) +def sheetCF(r, sthick): + """This function has been deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.pdf.characteristicfunctions.sheet_particle + instead. + """ + return sheet_particle(r, sthick) + + @deprecated(shellCF_dep_msg) def shellCF(r, radius, thickness): - """This function is deprecated and will be removed in version + """This function has been deprecated and will be removed in version 4.0.0. Please use diffpy.srfit.pdf.characteristicfunctions.shell_particle @@ -595,15 +606,13 @@ def shellCF(r, radius, thickness): @deprecated(shellCF2_dep_msg) def shellCF2(r, a, delta): - """This function is deprecated and will be removed in version + """This function has been deprecated and will be removed in version 4.0.0. Please use diffpy.srfit.pdf.characteristicfunctions.shell_particle instead. """ - radius = a - 0.5 * delta - thickness = delta - return shell_particle(r, radius, thickness) + return shell_particle(r, a - delta / 2, delta) class SASCF(Calculator): From 953ef60a5266482f4dee423e42e732b88788c87c Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Sat, 8 Aug 2026 10:03:42 -0600 Subject: [PATCH 3/4] Add test cases for the deprecated functions signature changes --- tests/test_characteristicfunctions.py | 231 +++++++++++++++++++++++--- tests/test_recipeorganizer.py | 45 ++++- 2 files changed, 250 insertions(+), 26 deletions(-) diff --git a/tests/test_characteristicfunctions.py b/tests/test_characteristicfunctions.py index d40068a1..98c8c4ab 100644 --- a/tests/test_characteristicfunctions.py +++ b/tests/test_characteristicfunctions.py @@ -594,85 +594,272 @@ def test_non_physical_input_warns_only_once( # ---------------------------------------------------------------------------- # sphericalCF, spheroidalCF, spheroidalCF2, lognormalSphericalCF, sheetCF, # shellCF, and shellCF2 are deprecated in favor of the snake_case functions -# above. Each old name must still work, emit a DeprecationWarning naming its -# replacement, and forward to the new implementation (translating arguments -# where the old and new parameterizations differ). +# above, and forward to them directly (translating arguments where the old +# and new parameterizations differ). Consequently they also inherit the +# replacement's handling of non-physical input in full: values that the old, +# pre-3.3.0 implementation used to accept silently, or handled with its own +# quirks, now warn and return zero exactly like the replacement. Each old +# name must still work, forward the (translated) arguments, and emit a +# DeprecationWarning naming its replacement. -module_path = "diffpy.srfit.pdf.characteristicfunctions" +# The warning must also tell the caller how to translate the old arguments, +# so each case pins the exact note appended to the standard message. A case +# whose parameters did not change appends nothing. @pytest.mark.parametrize( - "old_name, new_name, old_args, expected_characteristic_function", + "old_name, new_name, expected_signature_note, old_args, new_args", [ - # C1: sphericalCF forwards directly to spherical_particle. + # C1: sphericalCF forwards its arguments unchanged, only renamed. + # Expected: The note names the renamed parameter. ( "sphericalCF", "spherical_particle", + "Additionally, the signature has changed. Please pass the " + "parameter 'psize' as 'particle_diameter'.", + (numpy.array([0.0, 5.0, 10.0]), 10.0), (numpy.array([0.0, 5.0, 10.0]), 10.0), - cf.spherical_particle(numpy.array([0.0, 5.0, 10.0]), 10.0), ), - # C2: spheroidalCF forwards directly to spheroidal_particle. + # C2: spheroidalCF forwards its arguments unchanged, only renamed. + # Expected: The note names both renamed parameters. ( "spheroidalCF", "spheroidal_particle", + "Additionally, the signature has changed. Please pass the " + "parameters 'erad' and 'prad' as 'equatorial_radius' and " + "'polar_radius', respectively.", + (numpy.array([0.0, 5.0, 10.0]), 10.0, 15.0), (numpy.array([0.0, 5.0, 10.0]), 10.0, 15.0), - cf.spheroidal_particle(numpy.array([0.0, 5.0, 10.0]), 10.0, 15.0), ), # C3: spheroidalCF2 used the raw (diameter, axis ratio) # parameterization, which must be converted to (equatorial_radius, # polar_radius) before forwarding to spheroidal_particle. + # Expected: The note gives the conversion, not a rename. ( "spheroidalCF2", "spheroidal_particle", + "Additionally, the parameterization has changed. 'spheroidalCF2' " + "took the equatorial diameter 'psize' and the axis ratio " + "'axrat', while 'spheroidal_particle' takes radii. Please pass " + "equatorial_radius = psize / 2 and " + "polar_radius = axrat * psize / 2.", (numpy.array([0.0, 5.0, 10.0]), 20.0, 1.5), - cf.spheroidal_particle(numpy.array([0.0, 5.0, 10.0]), 10.0, 15.0), + (numpy.array([0.0, 5.0, 10.0]), 10.0, 15.0), ), - # C4: lognormalSphericalCF forwards directly to - # lognormal_spherical_particle. + # C4: lognormalSphericalCF forwards its arguments unchanged, only + # renamed. + # Expected: The note names both renamed parameters. ( "lognormalSphericalCF", "lognormal_spherical_particle", + "Additionally, the signature has changed. Please pass the " + "parameters 'psize' and 'psig' as 'particle_diameter' and " + "'particle_diameter_sigma', respectively.", + (numpy.array([1.0, 5.0, 10.0]), 10.0, 2.0), (numpy.array([1.0, 5.0, 10.0]), 10.0, 2.0), - cf.lognormal_spherical_particle( - numpy.array([1.0, 5.0, 10.0]), 10.0, 2.0 - ), ), - # C5: sheetCF forwards directly to sheet_particle. + # C5: sheetCF forwards its arguments unchanged, only renamed. + # Expected: The note names the renamed parameter. ( "sheetCF", "sheet_particle", + "Additionally, the signature has changed. Please pass the " + "parameter 'sthick' as 'sheet_thickness'.", + (numpy.array([0.0, 2.0, 4.0]), 4.0), (numpy.array([0.0, 2.0, 4.0]), 4.0), - cf.sheet_particle(numpy.array([0.0, 2.0, 4.0]), 4.0), ), - # C6: shellCF forwards directly to shell_particle. + # C6: shellCF, whose parameters kept their names and meanings. + # Expected: The standard message with no note appended. ( "shellCF", "shell_particle", + "", + (numpy.array([0.0, 5.0, 12.5]), 10.0, 5.0), (numpy.array([0.0, 5.0, 12.5]), 10.0, 5.0), - cf.shell_particle(numpy.array([0.0, 5.0, 12.5]), 10.0, 5.0), ), # C7: shellCF2 used the raw (central_radius, thickness) # parameterization, which must be converted to (radius, thickness) # before forwarding to shell_particle. + # Expected: The note gives the conversion, not a rename. ( "shellCF2", "shell_particle", + "Additionally, the parameterization has changed. 'shellCF2' took " + "the central radius 'a' and the shell thickness 'delta', while " + "'shell_particle' takes the inner radius. Please pass " + "radius = a - delta / 2 and thickness = delta.", (numpy.array([0.0, 5.0, 12.5]), 12.5, 5.0), - cf.shell_particle(numpy.array([0.0, 5.0, 12.5]), 10.0, 5.0), + (numpy.array([0.0, 5.0, 12.5]), 10.0, 5.0), ), ], ) def test_deprecated_functions_warn_and_forward( - old_name, new_name, old_args, expected_characteristic_function + old_name, + new_name, + expected_signature_note, + old_args, + new_args, ): old_function = getattr(cf, old_name) + new_function = getattr(cf, new_name) + module_path = "diffpy.srfit.pdf.characteristicfunctions" + expected_characteristic_function = new_function(*new_args) expected_msg = ( f"'{module_path}.{old_name}' is deprecated and will be removed " f"in version 4.0.0. Please use '{module_path}.{new_name}' " "instead." ) - with pytest.warns(DeprecationWarning, match=re.escape(expected_msg)): + if expected_signature_note: + expected_msg = f"{expected_msg} {expected_signature_note}" + # Capture every warning rather than using pytest.warns, which only + # checks that a matching warning occurred somewhere and would not + # notice a second, irrelevant DeprecationWarning from a call chained + # through another deprecated function. + with warnings.catch_warnings(record=True) as raised_warnings: + warnings.simplefilter("always") actual_characteristic_function = old_function(*old_args) + deprecation_warnings = [ + raised + for raised in raised_warnings + if raised.category is DeprecationWarning + ] + actual_deprecation_count = len(deprecation_warnings) + expected_deprecation_count = 1 + assert actual_deprecation_count == expected_deprecation_count + actual_msg = str(deprecation_warnings[0].message) + assert actual_msg == expected_msg + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + +# Because the deprecated functions forward to their replacements, they must +# also forward the replacement's non-physical-input handling: a +# RuntimeWarning alongside the DeprecationWarning, and a result of zero (or, +# for a translated case, zero reached via the translated argument). + + +@pytest.mark.parametrize( + "old_name, new_name, old_args, new_args, expected_requirement", + [ + # C1: sphericalCF forwards a non-positive diameter unchanged. + # Expected: the RuntimeWarning names the forwarded parameter. + ( + "sphericalCF", + "spherical_particle", + (numpy.array([1.0, 5.0]), -10.0), + (numpy.array([1.0, 5.0]), -10.0), + "'particle_diameter' must be positive", + ), + # C2: spheroidalCF2 translates a non-positive equatorial diameter + # into a non-positive equatorial radius. + # Expected: the RuntimeWarning names the translated parameter. + ( + "spheroidalCF2", + "spheroidal_particle", + (numpy.array([1.0, 5.0]), -20.0, 1.5), + (numpy.array([1.0, 5.0]), -10.0, -15.0), + "'equatorial_radius' must be positive", + ), + # C3: lognormalSphericalCF forwards a negative distribution width + # unchanged. This is the case where the old, pre-3.3.0 code and the + # new function most disagreed: the old code treated a negative width + # as the zero-width (sphere) limit, where the new function rejects + # it outright. + # Expected: the RuntimeWarning that the old code never raised. + ( + "lognormalSphericalCF", + "lognormal_spherical_particle", + (numpy.array([1.0, 5.0, 10.0]), 10.0, -2.0), + (numpy.array([1.0, 5.0, 10.0]), 10.0, -2.0), + "'particle_diameter_sigma' must not be negative", + ), + # C4: sheetCF forwards a non-positive thickness unchanged. The old + # code returned a bare scalar 0 regardless of the shape of r, where + # the new function returns an array shaped like r. + # Expected: an array of zeros shaped like r. + ( + "sheetCF", + "sheet_particle", + (numpy.array([1.0, 5.0]), 0.0), + (numpy.array([1.0, 5.0]), 0.0), + "'sheet_thickness' must be positive", + ), + # C5: shellCF forwards a zero thickness unchanged. The old code hit + # a vanishing normalization denominator and substituted one, where + # the new function rejects the input outright. + # Expected: zero everywhere rather than the old fallback of one. + ( + "shellCF", + "shell_particle", + (numpy.array([1.0, 5.0]), 10.0, 0.0), + (numpy.array([1.0, 5.0]), 10.0, 0.0), + "'thickness' must be positive", + ), + # C6: shellCF2 translates a central radius smaller than half the + # thickness into a negative inner radius. + # Expected: the RuntimeWarning names the translated parameter. + ( + "shellCF2", + "shell_particle", + (numpy.array([1.0, 5.0]), 1.0, 5.0), + (numpy.array([1.0, 5.0]), -1.5, 5.0), + "'radius' must not be negative", + ), + ], +) +def test_deprecated_functions_forward_non_physical_input( + old_name, + new_name, + old_args, + new_args, + expected_requirement, + reset_characteristic_function_warnings, +): + old_function = getattr(cf, old_name) + new_function = getattr(cf, new_name) + expected_characteristic_function = new_function(*new_args) + + # The oracle call above already consumed the once-per-process + # RuntimeWarning for this (function, requirement) pair; clear the + # record so the call under test below is free to raise it again. + cf._warned_non_physical.clear() + + with warnings.catch_warnings(record=True) as raised_warnings: + warnings.simplefilter("always") + actual_characteristic_function = old_function(*old_args) + + # Exactly two warnings are expected: the DeprecationWarning for the old + # name, and the RuntimeWarning forwarded from the new function. A stray + # third warning, such as a second DeprecationWarning from a call + # chained through another deprecated function, must fail this check. + actual_warning_count = len(raised_warnings) + expected_warning_count = 2 + assert actual_warning_count == expected_warning_count + + actual_categories = {raised.category for raised in raised_warnings} + expected_categories = {DeprecationWarning, RuntimeWarning} + assert actual_categories == expected_categories + + # The RuntimeWarning always names the new function, since that is where + # it is actually raised, regardless of which name the caller used. + expected_remedy = ( + "The characteristic function was set to zero for every r, which " + "flattens the fit residual so a refinement cannot recover on its " + "own. Please use a physically meaningful starting value, or keep " + "the parameter in range with FitRecipe.add_soft_bounds." + ) + expected_runtime_msg = ( + f"In '{new_name}', {expected_requirement}. {expected_remedy}" + ) + runtime_warning = next( + raised + for raised in raised_warnings + if raised.category is RuntimeWarning + ) + actual_runtime_msg = str(runtime_warning.message) + assert actual_runtime_msg == expected_runtime_msg + npt.assert_allclose( actual_characteristic_function, expected_characteristic_function ) diff --git a/tests/test_recipeorganizer.py b/tests/test_recipeorganizer.py index 99d8ee0d..ac00dad5 100644 --- a/tests/test_recipeorganizer.py +++ b/tests/test_recipeorganizer.py @@ -15,8 +15,10 @@ """Tests for refinableobj module.""" import unittest +import warnings import numpy +import pytest from diffpy.srfit.equation.builder import EquationFactory from diffpy.srfit.fitbase.calculator import Calculator @@ -27,6 +29,7 @@ equationFromString, get_equation_from_string, ) +from diffpy.utils._deprecator import deprecated # ---------------------------------------------------------------------------- @@ -346,16 +349,14 @@ def test_add_restraint(self): self.m.remove_soft_bounds(r) self.assertEqual(0, len(self.m._restraints)) - r = self.m.restrain(p1, upper_bound=10) + r = self.m.restrain(p1, ub=10) self.assertEqual(1, len(self.m._restraints)) p1.set_value(11) self.assertEqual(1, r.penalty()) # Check errors on unregistered parameters self.assertRaises(ValueError, self.m.restrain, "2*p3") - self.assertRaises( - ValueError, self.m.restrain, "2*p2", params={"p2": p3} - ) + self.assertRaises(ValueError, self.m.restrain, "2*p2", ns={"p2": p3}) return def testGetConstraints(self): @@ -658,5 +659,41 @@ def capture_show(*args, **kwargs): # ---------------------------------------------------------------------------- +# `register_function` extracts the name and the argument names from the code +# object of the function it is given. A decorator such as `deprecated` +# replaces that code object with the one of its (*args, **kwargs) wrapper, so +# a decorated function must be introspected through the wrapper to give the +# same result as the undecorated function. + + +def _gaussian(A, c, w, x): + return A * numpy.exp(-0.5 * ((x - c) / w) ** 2) + + +@pytest.mark.parametrize( + "input_function", + [ + # C1: Plain undecorated function. + # Expected: The name and the argument names come from the function. + _gaussian, + # C2: The same function behind a deprecation decorator. + # Expected: The name and the argument names of the wrapped function, + # not those of the wrapper. + deprecated("'_gaussian' is deprecated.")(_gaussian), + ], +) +def test_register_function_introspects_through_a_decorator(input_function): + organizer = RecipeOrganizer("organizer") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + equation = organizer.register_function(input_function) + actual_name = equation.name + expected_name = "_eq__gaussian" + actual_argnames = list(equation.argdict.keys()) + expected_argnames = ["A", "c", "w", "x"] + assert actual_name == expected_name + assert actual_argnames == expected_argnames + + if __name__ == "__main__": unittest.main() From f4c73140bdc14185c7d1f7e92ca28ce523dae7c3 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Sat, 8 Aug 2026 10:03:55 -0600 Subject: [PATCH 4/4] news --- news/cf-non-physical-input.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/cf-non-physical-input.rst b/news/cf-non-physical-input.rst index 85eb1004..a176945f 100644 --- a/news/cf-non-physical-input.rst +++ b/news/cf-non-physical-input.rst @@ -4,7 +4,7 @@ **Changed:** -* Change the characteristic functions in ``diffpy.srfit.pdf.characteristicfunctions`` to emit a ``RuntimeWarning`` when a non-physical shape parameter makes them return zero, since a zero return flattens the fit residual and stalls a refinement without any other sign to the user. The warning is issued once per process for each distinct problem so a refinement loop does not repeat it. +* Change the current characteristic functions in ``diffpy.srfit.pdf.characteristicfunctions`` (``spherical_particle``, ``spheroidal_particle``, ``lognormal_spherical_particle``, ``sheet_particle`` and ``shell_particle``) to emit a ``RuntimeWarning`` when a non-physical shape parameter makes them return zero, since a zero return flattens the fit residual and stalls a refinement without any other sign to the user. The warning is issued once per process for each distinct problem so a refinement loop does not repeat it. The deprecated camel-case functions forward to these functions directly, so they emit the same warning and return the same result for non-physical input. * Change ``lognormal_spherical_particle`` in ``diffpy.srfit.pdf.characteristicfunctions`` to return zero for a negative ``particle_diameter_sigma`` instead of silently returning ``spherical_particle``. A ``particle_diameter_sigma`` of zero is still the sphere limit. * Change ``sheet_particle`` in ``diffpy.srfit.pdf.characteristicfunctions`` to return an array of zeros for a non-positive ``sheet_thickness`` when ``r`` is an array, instead of a scalar zero.