デフォルト引数の挙動が思っていたのと違いました

>>> foo = 100
>>> def fn(x=foo):
... return x
...
>>> fn(10)
10
>>> fn()
100
>>> foo = 200
>>> fn()
100

デフォルト引数には外側スコープの変数を指定しています
その変数を実行前に変えたら実行時にも変わってると思っていたのに変わりませんでした
関数は labmda でも一緒です

>>> foo = 100
>>> fn = lambda x=foo: x
>>> fn(10)
10
>>> fn()
100
>>> foo = 200
>>> fn()
100

外側の変数を実行時に見るにはデフォルト引数を使わず関数内で参照します
関数内だと毎回実行時に参照してくれます

>>> foo = 100
>>> def fn(x=None):
... xx = foo if x is None else x
... return xx
...
>>> fn(10)
10
>>> fn()
100
>>> foo = 200
>>> fn()
200

JavaScript だとそんなことないので同じ感覚で書くとハマりますね

let foo = 100
const fn = (x=foo) => x
console.log(fn(10)) // 10
console.log(fn()) // 100
foo = 200
console.log(fn()) // 200

Python のデフォルト引数は毎回同じオブジェクトになるという変わった動きなのでこれと同じ原因の気がします

>>> def fn(x=[]):
... x.append(1)
... print(x)
...
>>> fn()
[1]
>>> fn()
[1, 1]

リテラルや関数実行じゃなくて既存変数の参照なので関係ないように思ってましたが 動作的には関数作成時にデフォルト引数の値を評価して内部で保持しているようです

⇩のコードが

def fn(a=1, b=2):
print(a, b)

fn()
# 1 2
fn(10)
# 10 2
fn(10, 20)
# 10 20

⇩のような処理に変換されてると考えるとわかりやすいかもしれません

_fn_default_args = (1, 2)

def _fn(a, b):
print(a, b)

def fn(*args):
lack = 2 - len(args)
padded_args = args if lack == 0 else [*args, *_fn_default_args[-lack:]]
return _fn(*padded_args)

fn()
# 1 2
fn(10)
# 10 2
fn(10, 20)
# 10 20

それなら関数定義後に変数の値を変更しても変わらないのも納得です

_fn_default_args に当たるものは見えたりしないのかなと思って探してみると関数の __defaults__ 属性に入っていました

>>> def fn(a, b=1, c={}): pass
...
>>> fn.__defaults__
(1, {})

__defaults__ はあとから書き換えできて デフォルト引数の数も変えられます

>>> def fn(a, b, c, d): print(a, b, c, d)
...
>>> fn.__defaults__ = (10, 20)
>>> fn(1, 2)
1 2 10 20
>>> fn(1, 2, 3)
1 2 3 20
>>> fn(1, 2, 3, 4)
1 2 3 4
>>> fn.__defaults__ = (0, 0, 0, 0)
>>> fn()
0 0 0 0
>>> fn.__defaults__ = None
>>> fn()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: fn() missing 4 required positional arguments: 'a', 'b', 'c', and 'd'

思った以上に自由度がありました