|
1 | 1 | from __future__ import annotations
|
2 | 2 |
|
| 3 | +import contextlib |
3 | 4 | import copy
|
4 | 5 | from typing import TYPE_CHECKING, Any
|
5 | 6 |
|
6 | 7 | import mergedeep
|
7 | 8 | import tomlkit.api
|
| 9 | +import tomlkit.items |
8 | 10 | from pydantic import TypeAdapter
|
9 | 11 | from tomlkit import TOMLDocument
|
10 | 12 | from tomlkit.exceptions import TOMLKitError
|
@@ -121,50 +123,62 @@ def set_value(
|
121 | 123 | """
|
122 | 124 | toml_document = copy.copy(self.get())
|
123 | 125 |
|
| 126 | + if not keys: |
| 127 | + # Root level config - value must be a mapping. |
| 128 | + TypeAdapter(dict).validate_python(toml_document) |
| 129 | + assert isinstance(toml_document, dict) |
| 130 | + TypeAdapter(dict).validate_python(value) |
| 131 | + assert isinstance(value, dict) |
| 132 | + if not toml_document or exists_ok: |
| 133 | + toml_document.update(value) |
| 134 | + self.commit(toml_document) |
| 135 | + return |
| 136 | + |
| 137 | + d, parent = toml_document, {} |
| 138 | + shared_keys = [] |
124 | 139 | try:
|
125 | 140 | # Index our way into each ID key.
|
126 | 141 | # Eventually, we should land at a final dict, which is the one we are setting.
|
127 |
| - d, parent = toml_document, {} |
128 |
| - if not keys: |
129 |
| - # Root level config - value must be a mapping. |
130 |
| - TypeAdapter(dict).validate_python(d) |
131 |
| - assert isinstance(d, dict) |
132 |
| - TypeAdapter(dict).validate_python(value) |
133 |
| - assert isinstance(value, dict) |
134 |
| - if not d: |
135 |
| - raise KeyError |
136 | 142 | for key in keys:
|
137 | 143 | TypeAdapter(dict).validate_python(d)
|
138 | 144 | assert isinstance(d, dict)
|
139 | 145 | d, parent = d[key], d
|
| 146 | + shared_keys.append(key) |
140 | 147 | except KeyError:
|
141 | 148 | # The old configuration should be kept for all ID keys except the
|
142 | 149 | # final/deepest one which shouldn't exist anyway since we checked as much,
|
143 | 150 | # above. For example, if there is [tool.ruff] then we shouldn't overwrite it
|
144 | 151 | # with [tool.deptry]; they should coexist. So under the "tool" key, we need
|
145 |
| - # to merge the two dicts. |
146 |
| - contents = value |
147 |
| - for key in reversed(keys): |
148 |
| - contents = {key: contents} |
149 |
| - toml_document = mergedeep.merge(toml_document, contents) # type: ignore[reportAssignmentType] |
150 |
| - assert isinstance(toml_document, TOMLDocument) |
| 152 | + # to "merge" the two dicts. |
| 153 | + |
| 154 | + if len(keys) <= 3: |
| 155 | + contents = value |
| 156 | + for key in reversed(keys): |
| 157 | + contents = {key: contents} |
| 158 | + toml_document = mergedeep.merge(toml_document, contents) # type: ignore[reportAssignmentType] |
| 159 | + assert isinstance(toml_document, TOMLDocument) |
| 160 | + else: |
| 161 | + # Note that this alternative logic is just to avoid a bug: |
| 162 | + # https://github.com/nathanjmcdougall/usethis-python/issues/507 |
| 163 | + TypeAdapter(dict).validate_python(d) |
| 164 | + assert isinstance(d, dict) |
| 165 | + |
| 166 | + unshared_keys = keys[len(shared_keys) :] |
| 167 | + |
| 168 | + d[_get_unified_key(unshared_keys)] = value |
151 | 169 | else:
|
152 | 170 | if not exists_ok:
|
153 | 171 | # The configuration is already present, which is not allowed.
|
154 | 172 | if keys:
|
155 | 173 | msg = f"Configuration value '{'.'.join(keys)}' is already set."
|
156 | 174 | else:
|
157 |
| - msg = "Configuration value is at root level is already set." |
| 175 | + msg = "Configuration value at root level is already set." |
158 | 176 | raise TOMLValueAlreadySetError(msg)
|
159 | 177 | else:
|
160 | 178 | # The configuration is already present, but we're allowed to overwrite it.
|
161 | 179 | TypeAdapter(dict).validate_python(parent)
|
162 | 180 | assert isinstance(parent, dict)
|
163 |
| - if parent: |
164 |
| - parent[keys[-1]] = value |
165 |
| - else: |
166 |
| - # i.e. the case where we're creating the root of the document |
167 |
| - toml_document.update(value) |
| 181 | + parent[keys[-1]] = value |
168 | 182 |
|
169 | 183 | self.commit(toml_document)
|
170 | 184 |
|
@@ -204,7 +218,14 @@ def __delitem__(self, keys: list[str]) -> None:
|
204 | 218 | for key in list(d.keys()):
|
205 | 219 | del d[key]
|
206 | 220 | else:
|
207 |
| - del d[keys[-1]] |
| 221 | + with contextlib.suppress(KeyError): |
| 222 | + # There is a strange behaviour (bug?) in tomlkit where deleting a key |
| 223 | + # has two separate lines: |
| 224 | + # self._value.remove(key) # noqa: ERA001 |
| 225 | + # dict.__delitem__(self, key) # noqa: ERA001 |
| 226 | + # but it's not clear why there's this duplicate and it causes a KeyError |
| 227 | + # in some cases. |
| 228 | + d.remove(keys[-1]) |
208 | 229 |
|
209 | 230 | # Cleanup: any empty sections should be removed.
|
210 | 231 | for idx in range(len(keys) - 1):
|
@@ -286,3 +307,12 @@ def remove_from_list(self, *, keys: list[str], values: list[Any]) -> None:
|
286 | 307 | p_parent[keys[-1]] = new_values
|
287 | 308 |
|
288 | 309 | self.commit(toml_document)
|
| 310 | + |
| 311 | + |
| 312 | +def _get_unified_key(keys: list[str]) -> tomlkit.items.Key: |
| 313 | + single_keys = [tomlkit.items.SingleKey(key) for key in keys] |
| 314 | + if len(single_keys) == 1: |
| 315 | + (unified_key,) = single_keys |
| 316 | + else: |
| 317 | + unified_key = tomlkit.items.DottedKey(single_keys) |
| 318 | + return unified_key |
0 commit comments