1
1
import json
2
2
import logging
3
3
import os
4
- import shutil
5
4
import time
6
5
from copy import deepcopy
7
6
from typing import List , Optional , Type , Union
8
7
9
- from deepmerge import always_merger as Merger
8
+ from deepmerge import Merger , always_merger
10
9
from jsonschema import Draft202012Validator as Validator
11
10
from jupyter_ai .models import DescribeConfigResponse , GlobalConfig , UpdateConfigRequest
12
11
from jupyter_ai_magics import JupyternautPersona , Persona
@@ -178,11 +177,23 @@ def _init_config_schema(self):
178
177
with open (OUR_SCHEMA_PATH , encoding = "utf-8" ) as f :
179
178
default_schema = json .load (f )
180
179
180
+ # Create a custom `deepmerge.Merger` object to merge lists using the
181
+ # 'append_unique' strategy.
182
+ #
183
+ # This stops type union declarations like `["string", "null"]` from
184
+ # growing into `["string", "null", "string", "null"]` on restart.
185
+ # This fixes issue #1320.
186
+ merger = Merger (
187
+ [(list , ["append_unique" ]), (dict , ["merge" ]), (set , ["union" ])],
188
+ ["override" ],
189
+ ["override" ],
190
+ )
191
+
181
192
# merge existing_schema into default_schema
182
193
# specifying existing_schema as the second argument ensures that
183
194
# existing_schema always overrides existing keys in default_schema, i.e.
184
195
# this call only adds new keys in default_schema.
185
- schema = Merger .merge (default_schema , existing_schema )
196
+ schema = merger .merge (default_schema , existing_schema )
186
197
with open (self .schema_path , encoding = "utf-8" , mode = "w" ) as f :
187
198
json .dump (schema , f , indent = self .indentation_depth )
188
199
@@ -194,15 +205,19 @@ def _init_validator(self) -> None:
194
205
195
206
def _init_config (self ):
196
207
default_config = self ._init_defaults ()
197
- if os .path .exists (self .config_path ):
208
+ # if the config file exists, read from it and use our defaults to fill
209
+ # out any missing fields. otherwise, create a new config file from our
210
+ # defaults.
211
+ # the `st_size` check treats empty 0-byte config files as non-existent.
212
+ if os .path .exists (self .config_path ) and os .stat (self .config_path ).st_size != 0 :
198
213
self ._process_existing_config (default_config )
199
214
else :
200
215
self ._create_default_config (default_config )
201
216
202
217
def _process_existing_config (self , default_config ):
203
218
with open (self .config_path , encoding = "utf-8" ) as f :
204
219
existing_config = json .loads (f .read ())
205
- merged_config = Merger .merge (
220
+ merged_config = always_merger .merge (
206
221
default_config ,
207
222
{k : v for k , v in existing_config .items () if v is not None },
208
223
)
@@ -481,7 +496,7 @@ def update_config(self, config_update: UpdateConfigRequest): # type:ignore
481
496
raise KeyEmptyError ("API key value cannot be empty." )
482
497
483
498
config_dict = self ._read_config ().model_dump ()
484
- Merger .merge (config_dict , config_update .model_dump (exclude_unset = True ))
499
+ always_merger .merge (config_dict , config_update .model_dump (exclude_unset = True ))
485
500
self ._write_config (GlobalConfig (** config_dict ))
486
501
487
502
# this cannot be a property, as the parent Configurable already defines the
0 commit comments