Skip to content

Commit 42ff451

Browse files
authored
[2.x] Expand edge case handling in ConfigManager (#1321)
* add test catching error with empty touched config.json * handle empty touched config.json, passes test * ensure schema file doesn't have duplicated types * pre-commit * add inline comment explaining st_size check
1 parent 39f3773 commit 42ff451

File tree

2 files changed

+38
-8
lines changed

2 files changed

+38
-8
lines changed

packages/jupyter-ai/jupyter_ai/config_manager.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import json
22
import logging
33
import os
4-
import shutil
54
import time
65
from copy import deepcopy
76
from typing import List, Optional, Type, Union
87

9-
from deepmerge import always_merger as Merger
8+
from deepmerge import Merger, always_merger
109
from jsonschema import Draft202012Validator as Validator
1110
from jupyter_ai.models import DescribeConfigResponse, GlobalConfig, UpdateConfigRequest
1211
from jupyter_ai_magics import JupyternautPersona, Persona
@@ -178,11 +177,23 @@ def _init_config_schema(self):
178177
with open(OUR_SCHEMA_PATH, encoding="utf-8") as f:
179178
default_schema = json.load(f)
180179

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+
181192
# merge existing_schema into default_schema
182193
# specifying existing_schema as the second argument ensures that
183194
# existing_schema always overrides existing keys in default_schema, i.e.
184195
# 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)
186197
with open(self.schema_path, encoding="utf-8", mode="w") as f:
187198
json.dump(schema, f, indent=self.indentation_depth)
188199

@@ -194,15 +205,19 @@ def _init_validator(self) -> None:
194205

195206
def _init_config(self):
196207
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:
198213
self._process_existing_config(default_config)
199214
else:
200215
self._create_default_config(default_config)
201216

202217
def _process_existing_config(self, default_config):
203218
with open(self.config_path, encoding="utf-8") as f:
204219
existing_config = json.loads(f.read())
205-
merged_config = Merger.merge(
220+
merged_config = always_merger.merge(
206221
default_config,
207222
{k: v for k, v in existing_config.items() if v is not None},
208223
)
@@ -481,7 +496,7 @@ def update_config(self, config_update: UpdateConfigRequest): # type:ignore
481496
raise KeyEmptyError("API key value cannot be empty.")
482497

483498
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))
485500
self._write_config(GlobalConfig(**config_dict))
486501

487502
# this cannot be a property, as the parent Configurable already defines the

packages/jupyter-ai/jupyter_ai/tests/test_config_manager.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import logging
33
import os
4+
from pathlib import Path
45
from unittest.mock import mock_open, patch
56

67
import pytest
@@ -513,8 +514,8 @@ def test_config_manager_does_not_write_to_defaults(
513514
def test_config_manager_updates_schema(jp_data_dir, common_cm_kwargs):
514515
"""
515516
Asserts that the ConfigManager adds new keys to the user's config schema
516-
which are present in Jupyter AI's schema on init. Asserts that #1291 does
517-
not occur again in the future.
517+
which are present in Jupyter AI's schema on init. Asserts that the main
518+
issue reported in #1291 does not occur again in the future.
518519
"""
519520
schema_path = str(jp_data_dir / "config_schema.json")
520521
with open(schema_path, "w") as file:
@@ -547,3 +548,17 @@ def test_config_manager_updates_schema(jp_data_dir, common_cm_kwargs):
547548
assert "fields" in new_schema["properties"]
548549
assert "embeddings_fields" in new_schema["properties"]
549550
assert "completions_fields" in new_schema["properties"]
551+
552+
553+
def test_config_manager_handles_empty_touched_file(common_cm_kwargs):
554+
"""
555+
Asserts that ConfigManager does not fail at runtime if `config.json` is a
556+
"touched file", a completely empty file with 0 bytes. This may happen if a
557+
user / build system runs `touch config.json` by accident.
558+
559+
Asserts that the second issue reported in #1291 does not occur again in the
560+
future.
561+
"""
562+
config_path = common_cm_kwargs["config_path"]
563+
Path(config_path).touch()
564+
ConfigManager(**common_cm_kwargs)

0 commit comments

Comments
 (0)