From 91691b85d884200f898bdd12784204219b1e5516 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 May 2025 21:15:15 +0000 Subject: [PATCH 1/2] Initial plan for issue From feb60bfb800a81c222c7e20bd7c359bc455de6f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:59:13 +0000 Subject: [PATCH 2/2] Add expand_slash_keys utility, tests, and fix CI configuration Co-authored-by: stefankoegl <184196+stefankoegl@users.noreply.github.com> Agent-Logs-Url: https://github.com/stefankoegl/python-json-patch/sessions/d432f792-40d4-47b2-9e6b-8697b7330028 --- .github/workflows/test.yaml | 4 +-- jsonpatch.py | 52 +++++++++++++++++++++++++++++++++++++ tests.py | 32 +++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 639e18d..14721f3 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["2.7", "3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 @@ -31,4 +31,4 @@ jobs: # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test run: | - coverage run --source=jsonpointer tests.py + coverage run --source=jsonpatch tests.py diff --git a/jsonpatch.py b/jsonpatch.py index d3fc26d..c413e24 100644 --- a/jsonpatch.py +++ b/jsonpatch.py @@ -924,6 +924,58 @@ def _compare_values(self, path, key, src, dst): self._item_replaced(path, key, dst) +def expand_slash_keys(obj): + """Expand slash-separated keys in a dict into nested dicts. + + Keys containing '/' are split on '/' and expanded into nested + dictionaries. This is useful when a flat dictionary uses slash-separated + path-like keys and you want to generate JSON Patch paths that traverse + nested objects rather than addressing a literal key that contains '/'. + + Leading and trailing '/' characters in keys are ignored (empty path + segments are skipped). + + Raises :exc:`ValueError` if two keys produce conflicting paths (e.g. + ``'a'`` and ``'a/b'`` both appear in *obj*). + + :param obj: The flat dictionary whose keys should be expanded. + :type obj: dict + + :return: A new dictionary with slash-separated keys expanded into + nested dicts. + :rtype: dict + + >>> expand_slash_keys({'/fields/test': '123456'}) + {'fields': {'test': '123456'}} + >>> expand_slash_keys({'a/b': 1, 'c': 2}) == {'a': {'b': 1}, 'c': 2} + True + """ + result = {} + for key, value in obj.items(): + parts = [p for p in str(key).split('/') if p] + if not parts: + result[key] = value + continue + d = result + for part in parts[:-1]: + if part not in d: + d[part] = {} + elif not isinstance(d[part], dict): + raise ValueError( + "Key conflict: '{0}' is both a value and a path " + "prefix in the source dict".format(part) + ) + d = d[part] + last = parts[-1] + if last in d and isinstance(d[last], dict): + raise ValueError( + "Key conflict: '{0}' is both a path prefix and a value " + "in the source dict".format(last) + ) + d[last] = value + return result + + def _path_join(path, key): if key is None: return path diff --git a/tests.py b/tests.py index d9eea92..c391da7 100755 --- a/tests.py +++ b/tests.py @@ -889,6 +889,38 @@ def test_copy_operation_structure(self): with self.assertRaises(jsonpatch.JsonPatchConflict): jsonpatch.CopyOperation({'path': '/target', 'from': '/source'}).apply({}) + def test_expand_slash_keys_simple(self): + """Slashed keys are expanded into nested dicts.""" + result = jsonpatch.expand_slash_keys({'/fields/test': '123456'}) + self.assertEqual(result, {'fields': {'test': '123456'}}) + + def test_expand_slash_keys_mixed(self): + """Keys with and without slashes are handled correctly.""" + result = jsonpatch.expand_slash_keys({'a/b': 1, 'c': 2}) + self.assertEqual(result, {'a': {'b': 1}, 'c': 2}) + + def test_expand_slash_keys_deep(self): + """Keys with multiple slash levels produce deeply nested dicts.""" + result = jsonpatch.expand_slash_keys({'a/b/c': 42}) + self.assertEqual(result, {'a': {'b': {'c': 42}}}) + + def test_expand_slash_keys_no_slashes(self): + """Dicts without slashed keys are returned unchanged.""" + result = jsonpatch.expand_slash_keys({'foo': 'bar', 'baz': 1}) + self.assertEqual(result, {'foo': 'bar', 'baz': 1}) + + def test_expand_slash_keys_make_patch(self): + """expand_slash_keys allows make_patch to produce readable paths.""" + src = {} + dst = jsonpatch.expand_slash_keys({'/fields/test': '123456'}) + patch = jsonpatch.make_patch(src, dst) + self.assertEqual(patch.patch, [{'op': 'add', 'path': '/fields', 'value': {'test': '123456'}}]) + + def test_expand_slash_keys_conflict(self): + """Conflicting keys raise ValueError.""" + with self.assertRaises(ValueError): + jsonpatch.expand_slash_keys({'a': 1, 'a/b': 2}) + class CustomJsonPointer(jsonpointer.JsonPointer): pass