Standard way to deprecate a keyword argument

Is there a simple and standard way to deprecate a keyword argument? From reading through past threads it sounds like you need to do this manually, e.g., Deprecate keyword argument. But surely there’s a nice macro for this somewhere?

Here’s the syntax I am looking for to deprecate a in favor of the new kw b:

function f(; @deprecate_kw(a, b=2))
    return b
end

f(a=2)  # Works, but raises a depwarn
f(b=2)  # Also works

Here’s a simple attempt which works without needing to wrap the entire function signature. However, I don’t like this because:

  1. I still need to pass deprecated_kw=nothing.
    • Aside: Is there any way to generate multiple keywords using a macro that is used inline in the function signature?
  2. The depwarn doesn’t know the function.
    • Aside: I tried calling stacktrace(backtrace()) from within the kwcall to get the function name, but it seemed too hacky.
  3. Code analyzers like the one used in VSCode do not know about the new keyword.
(expand)
macro deprecate_kw(deprecated_kw, new_kw)
    return _deprecate_kw(deprecated_kw, new_kw) |> esc
end

function _deprecate_kw(deprecated_kw::Symbol, new_kw::Expr)
    @assert(new_kw.head == :(=), "Must pass a simple assignment expression.")
    default_value = new_kw.args[2]
    depwarn_string = "The keyword argument $(deprecated_kw) is deprecated in favor of $(new_kw.args[1])."
    new_kw.args[2] = quote
        if $deprecated_kw != nothing
            # Get function name to put in depwarn:
            Base.depwarn($depwarn_string, :nothing)
            $deprecated_kw
        else
            $default_value
        end
    end
    return Expr(:kw, new_kw.args[1], new_kw.args[2])
end

Then I can use this with:

function f(;
    a=nothing, @deprecate_kw(a, b=2),
    c=nothing, @deprecate_kw(c, d=3),
)
    return b + d
end

with outputs (under julia --depwarn=yes):

julia> f(a=2)
┌ Warning: The keyword argument a is deprecated in favor of b.
│   caller = ip:0x0
└ @ Core :-1
5

julia> f(b=2)
5

Is there a better way to do this? Or is manually swapping argument values in the function body the only way?


Here's the macro expansion for clarity:
julia> @macroexpand @deprecate_kw(a, b=2)
:($(Expr(:kw, :b, quote
    if a != nothing
        Base.depwarn("The keyword argument a is deprecated in favor of b.", :nothing)
        a
    else
        2
    end
end)))
1 Like

I’d prefer to not have to wrap the entire signature, but I guess it might be necessary for (1) the depwarn to know the function, and (2) to allow for macros which return multiple keywords. So maybe something like

@deprecate_kws (b=a, d=c) function f(; b=2, d=3)
    return b + d
end

could also be an option, though it’s not very elegant…

Okay, here’s a way to get the desired syntax above to work:

using MacroTools

macro deprecate_kws(deprecations, def)
    return esc(_deprecate_kws(deprecations, def))
end

function _deprecate_kws(deprecations, def)
    @assert(
        deprecations.head == :tuple,
        "First argument to `deprecate_kws` must be passed as a tuple, like `(new_kw1=old_kw1, new_kw2=old_kw2)`"
    )
    sdef = splitdef(def)
    func_symbol = Expr(:quote, sdef[:name])  # Double quote for expansion

    new_symbols = [_get_symbol(k.args[1]) for k in deprecations.args]
    deprecated_symbols = [_get_symbol(k.args[2]) for k in deprecations.args]
    symbol_mapping = Dict(new_symbols .=> deprecated_symbols)

    # Add deprecated kws:
    for deprecated_symbol in deprecated_symbols
        pushfirst!(sdef[:kwargs], Expr(:kw, deprecated_symbol, :nothing))
    end

    # Update new symbols to use deprecated kws if passed:
    for (i, kw) in enumerate(sdef[:kwargs])
        new_kw = kw.args[1]
        default = kw.args[2]
        _get_symbol(new_kw) in deprecated_symbols && continue
        !(_get_symbol(new_kw) in new_symbols) && continue
        deprecated_symbol = symbol_mapping[_get_symbol(new_kw)]
        depwarn_string = "Keyword argument `$(deprecated_symbol)` is deprecated. Use `$(_get_symbol(new_kw))` instead."
        new_kwcall = quote
            if $deprecated_symbol !== nothing
                Base.depwarn($depwarn_string, $func_symbol)
                $deprecated_symbol
            else
                $default
            end
        end
        sdef[:kwargs][i] = Expr(:kw, new_kw, new_kwcall)
    end

    return combinedef(sdef)
end

# This is used to go from a::Int to a
_get_symbol(e::Expr) = first(map(_get_symbol, e.args))
_get_symbol(e::Symbol) = e

With this we can write:

@deprecate_kws (b=a, d=c) function f(; b::Int=2, d=3)
    return b + d
end
which expands to (click me):
function f(; c = nothing, a = nothing, b::Int = begin
                  if a !== nothing
                      Base.depwarn("Keyword argument `a` is deprecated. Use `b` instead.", :f)
                      a
                  else
                      2
                  end
              end, d = begin
                  if c !== nothing
                      Base.depwarn("Keyword argument `c` is deprecated. Use `d` instead.", :f)
                      c
                  else
                      3
                  end
              end)
      return b + d
  end

this has everything work as you might expect:

julia> f(a=2)
┌ Warning: Keyword argument `a` is deprecated. Use `b` instead.
│   caller = top-level scope at REPL[5]:1
└ @ Core REPL[5]:1
5

julia> f(b=2)
5

julia> f(d=2)
4

julia> f(c=3)
┌ Warning: Keyword argument `c` is deprecated. Use `d` instead.
│   caller = top-level scope at REPL[8]:1
└ @ Core REPL[8]:1
5

Even the ::Int still works:

julia> f(b=2.0)
ERROR: TypeError: in keyword argument b, expected Int64, got a value of type Float64
Stacktrace:
 [1] top-level scope
   @ REPL[9]:1

though the error is a bit weird for a=2.0 (at least it throws an error though)

(expand)
julia> f(a=2.0)
┌ Warning: Keyword argument `a` is deprecated. Use `b` instead.
│   caller = top-level scope at REPL[10]:1
└ @ Core REPL[10]:1
ERROR: MethodError: no method matching var"#f#13"(::Nothing, ::Float64, ::Float64, ::Int64, ::typeof(f))

Closest candidates are:
  var"#f#13"(::Any, ::Any, ::Int64, ::Any, ::typeof(f))
   @ Main REPL[4]:1

Stacktrace:
 [1] top-level scope
   @ REPL[10]:1

Is this approach the optimal way of solving this sort of thing? Let me know if you know of something better.

One concern I have is that this approach might not be type stable.

2 Likes

Okay I have since made a mini package for this:

GitHub - MilesCranmer/DeprecateKeywords.jl: Macro for deprecating keyword parameters.

Here’s the description on the readme:


DeprecateKeywords.jl

Dev Build Status Coverage

DeprecateKeywords defines a macro for keyword deprecation. For example, let’s say we wish to deprecate the keyword old_kw1 in favor of new_kw1, and likewise for old_kw2.

using DeprecateKeywords

@deprecate_kws function foo(;
    new_kw1=2,
    new_kw2=3,
    @deprecate(old_kw1, new_kw1),
    @deprecate(old_kw2, new_kw2)
)
    new_kw1 + new_kw2
end

With this, we can use both the old and new keywords. If using the old keyword, it will automatically be passed to the new keyword, but with a deprecation warning.

julia> foo(new_kw1=1, new_kw2=2)
3

julia> foo(old_kw1=1, new_kw2=2)
┌ Warning: Keyword argument `old_kw1` is deprecated. Use `new_kw1` instead.
│   caller = top-level scope at REPL[5]:1
└ @ Core REPL[5]:1
3

(The warning uses depwarn, so is only visible if one starts with --depwarn=yes)


Internally, the @deprecated_kws reads through the signature and replaces @deprecated(a, b) (which is not currently usable with keywords) with some logic at the beginning of the kwargs. Here is what this code expands to:

:(function foo(; old_kw2 = nothing, old_kw1 = nothing, new_kw1 = begin
                  #= /Users/mcranmer/Documents/DeprecateKeyword.jl/src/DeprecateKeywords.jl:75 =#
                  if old_kw1 !== nothing
                      #= /Users/mcranmer/Documents/DeprecateKeyword.jl/src/DeprecateKeywords.jl:76 =#
                      Base.depwarn("Keyword argument `old_kw1` is deprecated. Use `new_kw1` instead.", :foo)
                      #= /Users/mcranmer/Documents/DeprecateKeyword.jl/src/DeprecateKeywords.jl:77 =#
                      old_kw1
                  else
                      #= /Users/mcranmer/Documents/DeprecateKeyword.jl/src/DeprecateKeywords.jl:79 =#
                      2
                  end
              end, new_kw2 = begin
                  #= /Users/mcranmer/Documents/DeprecateKeyword.jl/src/DeprecateKeywords.jl:75 =#
                  if old_kw2 !== nothing
                      #= /Users/mcranmer/Documents/DeprecateKeyword.jl/src/DeprecateKeywords.jl:76 =#
                      Base.depwarn("Keyword argument `old_kw2` is deprecated. Use `new_kw2` instead.", :foo)
                      #= /Users/mcranmer/Documents/DeprecateKeyword.jl/src/DeprecateKeywords.jl:77 =#
                      old_kw2
                  else
                      #= /Users/mcranmer/Documents/DeprecateKeyword.jl/src/DeprecateKeywords.jl:79 =#
                      3
                  end
              end)
      #= REPL[3]:1 =#
      #= REPL[3]:7 =#
      new_kw1 + new_kw2
  end)

One thing that is missing is a check for both kwargs being set (right now it silently takes the new kw)

2 Likes