toml-repo 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
toml_repo/repo.py ADDED
@@ -0,0 +1,735 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import MutableMapping
5
+ from importlib import resources
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any, overload
8
+
9
+ import tomlkit
10
+ from tomlkit.items import AoT
11
+ from tomlkit.toml_document import TOMLDocument
12
+ from tomlkit.toml_file import TOMLFile
13
+
14
+ from .http_client import http_session
15
+
16
+ if TYPE_CHECKING:
17
+ from .manager import RepoManager
18
+
19
+ # --- Module-level configuration ---
20
+ # These defaults can be overridden by the host application via the setter functions
21
+ # exported from the package.
22
+
23
+ _config_suffix: str = "repo.toml"
24
+ """The default filename to look for inside repo directories."""
25
+
26
+ _pkg_resource_root: str | None = None
27
+ """The Python package name used for pkg:// URL resolution.
28
+ Must be set by the host application before using pkg:// URLs."""
29
+
30
+
31
+ def get_config_suffix() -> str:
32
+ """Return the current config suffix (e.g. 'starbash.toml')."""
33
+ return _config_suffix
34
+
35
+
36
+ def set_config_suffix(suffix: str) -> None:
37
+ """Set the filename to look for inside repo directories.
38
+
39
+ Args:
40
+ suffix: The filename, e.g. ``"starbash.toml"`` or ``"myapp.toml"``.
41
+ """
42
+ global _config_suffix
43
+ _config_suffix = suffix
44
+
45
+
46
+ def set_pkg_resource_root(package_name: str) -> None:
47
+ """Set the Python package name used for ``pkg://`` URL resolution.
48
+
49
+ For example, calling ``set_pkg_resource_root("starbash")`` means that
50
+ ``pkg://defaults`` will resolve to ``importlib.resources.files("starbash") / "defaults"``.
51
+
52
+ Args:
53
+ package_name: The importable package name (e.g. ``"starbash"``).
54
+ """
55
+ global _pkg_resource_root
56
+ _pkg_resource_root = package_name
57
+
58
+
59
+ REPO_REF = "repo-ref"
60
+
61
+
62
+ class Repo:
63
+ """
64
+ Represents a single TOML-based repository.
65
+ """
66
+
67
+ def __init__(self, url_or_path: str | Path, default_toml: TOMLDocument | None = None):
68
+ """Initialize a Repo instance.
69
+
70
+ Args:
71
+ url_or_path: Either a string URL (e.g. file://, pkg://, http://...) or a Path.
72
+ If a Path is provided it will be converted to a file:// URL using its
73
+ absolute, resolved form.
74
+ default_toml: Optional fallback TOMLDocument to use when the config file
75
+ is missing or invalid.
76
+
77
+ Note:
78
+ If the URL/path ends with .toml, it's treated as a direct TOML file.
79
+ Otherwise, it's treated as a directory containing a config file named
80
+ by ``get_config_suffix()`` (default: ``"repo.toml"``).
81
+
82
+ Import Resolution:
83
+ After loading the TOML config, this constructor processes any 'import' keys
84
+ found in the configuration. Import syntax:
85
+
86
+ [import]
87
+ node = "some.dotted.path" # required: which node to import
88
+ file = "path/to/file.toml" # optional: source file (default: current file)
89
+ repo = "url_or_path" # optional: source repo (default: current repo)
90
+
91
+ The import key is replaced with the contents of the referenced node.
92
+ Files are cached during import resolution to avoid redundant reads.
93
+ """
94
+ if isinstance(url_or_path, Path):
95
+ # Always resolve to an absolute path to avoid ambiguity
96
+ resolved = url_or_path.expanduser().resolve()
97
+ url = f"file://{resolved}"
98
+ else:
99
+ url = str(url_or_path)
100
+
101
+ self.url: str = url
102
+ self._import_cache: dict[str, TOMLDocument] = {} # Cache for imported files
103
+ self.config: TOMLDocument = self._load_config(default_toml)
104
+ self._as_read = (
105
+ self.config.as_string()
106
+ ) # the contents of the toml as we originally read from disk
107
+
108
+ self._monkey_patch()
109
+ self._resolve_imports()
110
+
111
+ def _monkey_patch(self, o: Any | None = None) -> None:
112
+ """Add a 'source' back-ptr to all child items in the config.
113
+
114
+ so that users can find the source repo (for attribution, URL relative resolution, whatever...)
115
+ """
116
+ # base case - start us recursing
117
+ if o is None:
118
+ self._monkey_patch(self.config)
119
+ return
120
+
121
+ # We monkey patch source into any object that came from a repo,
122
+ try:
123
+ o.source = self
124
+
125
+ # Recursively patch dict-like objects
126
+ if isinstance(o, dict):
127
+ for value in o.values():
128
+ self._monkey_patch(value)
129
+ # Recursively patch list-like objects (including AoT)
130
+ elif hasattr(o, "__iter__") and not isinstance(o, str | bytes):
131
+ try:
132
+ for item in o:
133
+ self._monkey_patch(item)
134
+ except TypeError:
135
+ # Not actually iterable, skip
136
+ pass
137
+ except AttributeError:
138
+ pass # simple types like int, str, float, etc. can't have attributes set on them
139
+
140
+ def _resolve_imports_in_doc(self, doc: TOMLDocument) -> None:
141
+ """Helper to resolve imports in a standalone TOML document."""
142
+ self._resolve_imports(doc, None, None)
143
+
144
+ def _resolve_imports(
145
+ self, o: Any | None = None, parent: dict | None = None, key: str | None = None
146
+ ) -> None:
147
+ """Recursively resolve 'import' keys in the TOML structure.
148
+
149
+ Searches through the config dictionary tree looking for tables with an 'import' key.
150
+ When found, loads the referenced node from the specified file/repo and replaces
151
+ the entire table containing the import key with the imported content.
152
+
153
+ Args:
154
+ o: The current object being processed (None = start at root config)
155
+ parent: Parent dict containing the current object
156
+ key: Key in parent dict that references the current object
157
+
158
+ Import table structure:
159
+ [import]
160
+ node = "some.dotted.path" # required: which node to import
161
+ file = "path/to/file.toml" # optional: relative or absolute path
162
+ repo = "url_or_path" # optional: repo URL or path
163
+
164
+ Raises:
165
+ ValueError: If import is malformed or referenced content not found
166
+ """
167
+ # Base case - start recursion at root
168
+ if o is None:
169
+ self._resolve_imports(self.config, None, None)
170
+ return
171
+
172
+ # Check if this is a dict with an 'import' key
173
+ if isinstance(o, dict):
174
+ if "import" in o:
175
+ # Found an import directive - resolve it
176
+ import_spec = o["import"]
177
+ if not isinstance(import_spec, dict):
178
+ raise ValueError(
179
+ f"Import specification must be a table, got {type(import_spec)}"
180
+ )
181
+
182
+ # Extract import parameters
183
+ node_path = import_spec.get("node")
184
+ if not node_path:
185
+ raise ValueError("Import must specify a 'node' key")
186
+
187
+ file_path = import_spec.get("file")
188
+ repo_spec = import_spec.get("repo")
189
+
190
+ # Resolve the imported content
191
+ imported_content = self._resolve_import_node(node_path, file_path, repo_spec)
192
+
193
+ # Replace the entire parent table with the imported content
194
+ if parent is not None and key is not None:
195
+ parent[key] = imported_content
196
+ # Monkey patch the imported content to indicate its source
197
+ self._monkey_patch(parent[key])
198
+ else:
199
+ # Can't replace root config with an import
200
+ raise ValueError("Cannot use import at the root level of config")
201
+
202
+ # Don't recurse into the import spec - we've replaced it
203
+ return
204
+
205
+ # Not an import table, recurse into children
206
+ # We need to iterate over a copy because we might modify the dict
207
+ for k, v in list(o.items()):
208
+ self._resolve_imports(v, o, k)
209
+
210
+ # Recursively process list-like objects (including AoT)
211
+ elif hasattr(o, "__iter__") and not isinstance(o, str | bytes):
212
+ try:
213
+ # For lists, we need to iterate and process each item
214
+ # We can't easily replace items in tomlkit AoT structures,
215
+ # so we recurse into each item which should be a dict
216
+ for item in o:
217
+ # Each item in an AoT is a table (dict)
218
+ if isinstance(item, dict):
219
+ # Check for import at the table level
220
+ if "import" in item:
221
+ import_spec = item["import"]
222
+ if not isinstance(import_spec, dict):
223
+ raise ValueError(
224
+ f"Import specification must be a table, got {type(import_spec)}"
225
+ )
226
+ node_path = import_spec.get("node")
227
+ if not node_path:
228
+ raise ValueError("Import must specify a 'node' key")
229
+ file_path = import_spec.get("file")
230
+ repo_spec = import_spec.get("repo")
231
+
232
+ # Get imported content
233
+ imported_content = self._resolve_import_node(
234
+ node_path, file_path, repo_spec
235
+ )
236
+
237
+ # Merge imported content into this item (preserving other keys)
238
+ # First remove the import key
239
+ del item["import"]
240
+ # Then merge in the imported content
241
+ if isinstance(imported_content, dict):
242
+ for k, v in imported_content.items():
243
+ if k not in item: # Don't override existing keys
244
+ item[k] = v
245
+ self._monkey_patch(item)
246
+ else:
247
+ # No import, just recurse normally
248
+ self._resolve_imports(item, o, None)
249
+ except TypeError:
250
+ # Not actually iterable, skip
251
+ pass
252
+
253
+ def _resolve_import_node(
254
+ self, node_path: str, file_path: str | None, repo_spec: str | None
255
+ ) -> Any:
256
+ """Resolve and return the content of an imported node.
257
+
258
+ Args:
259
+ node_path: Dot-separated path to the node (e.g., "recipe.stage.light")
260
+ file_path: Optional path to TOML file (relative or absolute)
261
+ repo_spec: Optional repo URL or path
262
+
263
+ Returns:
264
+ The imported content (deep copy to avoid reference issues)
265
+
266
+ Raises:
267
+ ValueError: If the import cannot be resolved
268
+ """
269
+ import copy
270
+
271
+ # Determine which repo to use
272
+ if repo_spec:
273
+ # Import from a different repo - create a temporary repo instance
274
+ source_repo = Repo(repo_spec)
275
+ else:
276
+ # Import from current repo
277
+ source_repo = self
278
+
279
+ # Determine which file to load
280
+ if file_path:
281
+ # Load a different TOML file from the source repo
282
+ cache_key = f"{source_repo.url}::{file_path}"
283
+
284
+ if cache_key not in self._import_cache:
285
+ # Load and parse the TOML file
286
+ toml_content = source_repo.read(file_path)
287
+ parsed_doc = tomlkit.parse(toml_content)
288
+ # Process imports in the cached file recursively
289
+ self._resolve_imports_in_doc(parsed_doc)
290
+ self._import_cache[cache_key] = parsed_doc
291
+
292
+ source_doc = self._import_cache[cache_key]
293
+ else:
294
+ # Use the current file's config
295
+ source_doc = source_repo.config
296
+
297
+ # Navigate to the specified node
298
+ current = source_doc
299
+ for key in node_path.split("."):
300
+ if not isinstance(current, dict):
301
+ raise ValueError(f"Cannot navigate to '{key}' in path '{node_path}' - not a dict")
302
+ if key not in current:
303
+ raise ValueError(
304
+ f"Node '{key}' not found in path '{node_path}' while resolving import"
305
+ )
306
+ current = current[key]
307
+
308
+ # Return a deep copy to avoid reference issues
309
+ # Note: tomlkit objects need special handling for deep copy
310
+ return copy.deepcopy(current)
311
+
312
+ def __str__(self) -> str:
313
+ """Return a concise one-line description of this repo.
314
+
315
+ Example: "Repo(kind=recipe, url=file:///path/to/repo)"
316
+ """
317
+ return f"Repo(kind={self.kind()}, url={self.url})"
318
+
319
+ __repr__ = __str__
320
+
321
+ def __deepcopy__(self, memo):
322
+ # Supress deepcopy because users almost certainly don't want to deepcopy repos
323
+ return self
324
+
325
+ def kind(self, unknown_kind: str = "unknown") -> str:
326
+ """
327
+ Read-only attribute for the repository kind (e.g., "recipe", "data", etc.).
328
+
329
+ Returns:
330
+ The kind of the repository as a string.
331
+ """
332
+ c = self.get("repo.kind", unknown_kind)
333
+ return str(c)
334
+
335
+ @property
336
+ def config_url(self) -> str:
337
+ """
338
+ Returns the URL to the configuration file for this repository.
339
+
340
+ For direct .toml file URLs, returns the URL as-is.
341
+ For directory URLs, appends the config suffix to the URL.
342
+
343
+ Returns:
344
+ The complete URL to the configuration file.
345
+ """
346
+ if self._is_direct_toml_file():
347
+ return self.url
348
+ return f"{self.url.rstrip('/')}/{_config_suffix}"
349
+
350
+ def add_repo_ref(self, manager: RepoManager, dir: Path) -> Repo | None:
351
+ """
352
+ Adds a new repo-ref to this repository's configuration.
353
+ if new returns the newly added Repo object, if already exists returns None"""
354
+
355
+ # if dir is not absolute, we need to resolve it relative to the cwd
356
+ if not dir.is_absolute():
357
+ dir = (Path.cwd() / dir).resolve()
358
+
359
+ # Add the ref to this repo
360
+ aot = self.config.get(REPO_REF, None)
361
+ if aot is None:
362
+ aot = tomlkit.aot()
363
+ self.config[REPO_REF] = aot # add an empty AoT at the end of the file
364
+
365
+ if type(aot) is not AoT:
366
+ raise ValueError(f"repo-ref in {self.url} is not an array")
367
+
368
+ for t in aot:
369
+ if "dir" in t and t["dir"] == str(dir):
370
+ logging.warning(f"Repo ref {dir} already exists - ignoring.")
371
+ return None # already exists
372
+
373
+ ref = {"dir": str(dir)}
374
+ aot.append(ref)
375
+
376
+ # Also add the repo to the manager
377
+ return self.add_from_ref(manager, ref)
378
+
379
+ def write_config(self) -> None:
380
+ """
381
+ Writes the current (possibly modified) configuration back to the repository's config file.
382
+
383
+ Raises:
384
+ ValueError: If the repository is not a local file repository.
385
+ """
386
+ if not self.is_scheme("file"):
387
+ raise ValueError("Cannot write config for non-local repository")
388
+
389
+ if self._is_direct_toml_file():
390
+ config_path = Path(self.url[len("file://") :])
391
+ else:
392
+ base_path = self.get_path()
393
+ if base_path is None:
394
+ raise ValueError("Cannot resolve path for non-local repository")
395
+ config_path = base_path / _config_suffix
396
+
397
+ if self.config.as_string() == self._as_read:
398
+ logging.debug(f"Config unchanged, not writing: {config_path}")
399
+ else:
400
+ # FIXME, be more careful to write the file atomically (by writing to a temp file and renaming)
401
+ # create the output directory if it doesn't exist
402
+ config_path.parent.mkdir(parents=True, exist_ok=True)
403
+ TOMLFile(config_path).write(self.config)
404
+ logging.debug(f"Wrote config to {config_path}")
405
+
406
+ def _is_direct_toml_file(self) -> bool:
407
+ """
408
+ Check if the URL points directly to a .toml file.
409
+
410
+ Returns:
411
+ bool: True if the URL ends with .toml, False otherwise.
412
+ """
413
+ return self.url.endswith(".toml")
414
+
415
+ def is_scheme(self, scheme: str = "file") -> bool:
416
+ """
417
+ Check whether the repository URL uses the given scheme.
418
+
419
+ Args:
420
+ scheme: The URL scheme to check for (default: "file").
421
+
422
+ Returns:
423
+ bool: True if the URL starts with ``scheme://``, False otherwise.
424
+ """
425
+ return self.url.startswith(f"{scheme}://")
426
+
427
+ def get_path(self) -> Path | None:
428
+ """
429
+ Resolves the URL to a local file system path if it's a file URI.
430
+
431
+ For directory URLs, returns the directory path.
432
+ For .toml file URLs, returns the parent directory path.
433
+
434
+ Returns:
435
+ A Path object if the URL is a local file, otherwise None.
436
+ """
437
+ if self.is_scheme("file"):
438
+ path = Path(self.url[len("file://") :])
439
+ if self._is_direct_toml_file():
440
+ return path.parent
441
+ return path
442
+
443
+ return None
444
+
445
+ def add_from_ref(self, manager: RepoManager, ref: dict) -> Repo | None:
446
+ """
447
+ Adds a repository based on a repo-ref dictionary.
448
+ """
449
+ url: str | None = None # assume failure
450
+
451
+ if "url" in ref:
452
+ url = ref["url"]
453
+ elif "dir" in ref:
454
+ # FIXME don't allow ~ or .. in file paths for security reasons?
455
+ if self.is_scheme("file"):
456
+ path = Path(ref["dir"])
457
+ base_path = self.get_path()
458
+
459
+ if base_path and not path.is_absolute():
460
+ # Resolve relative to the current TOML file's directory
461
+ path = (base_path / path).resolve()
462
+ else:
463
+ # Expand ~ and resolve from CWD
464
+ path = path.expanduser().resolve()
465
+ url = f"file://{path}"
466
+ else:
467
+ # construct an URL relative to this repo's URL
468
+ url = self.url.rstrip("/") + "/" + ref["dir"].lstrip("/")
469
+
470
+ if url:
471
+ return manager.add_repo(url)
472
+ else:
473
+ logging.warning("Skipping empty repo reference")
474
+ return None
475
+
476
+ def add_by_repo_refs(self, manager: RepoManager) -> None:
477
+ """Add all repos mentioned by repo-refs in this repo's config."""
478
+ repo_refs = self.config.get(REPO_REF, [])
479
+
480
+ for ref in repo_refs:
481
+ self.add_from_ref(manager, ref)
482
+
483
+ def resolve_path(self, filepath: str | None = None) -> Path:
484
+ """
485
+ Resolve a filepath relative to the base of this repo.
486
+
487
+ For directory URLs, resolves relative to the directory.
488
+ For .toml file URLs, resolves relative to the parent directory.
489
+
490
+ Args:
491
+ filepath: The path to the file, relative to the repository root.
492
+
493
+ Returns:
494
+ The resolved Path object.
495
+ """
496
+ base_path = self.get_path()
497
+ if base_path is None:
498
+ raise ValueError("Cannot resolve filepaths for non-local repositories")
499
+
500
+ target_path = (base_path / filepath) if filepath else base_path
501
+ target_path = target_path.resolve()
502
+
503
+ return target_path
504
+
505
+ def _read_file(self, filepath: str) -> str:
506
+ """
507
+ Read a filepath relative to the base of this repo. Return the contents in a string.
508
+
509
+ Args:
510
+ filepath: The path to the file, relative to the repository root.
511
+ If empty, reads directly from the URL (for .toml file URLs).
512
+
513
+ Returns:
514
+ The content of the file as a string.
515
+ """
516
+ if not filepath:
517
+ # Read directly from the URL
518
+ path = Path(self.url[len("file://") :])
519
+ return path.read_text()
520
+
521
+ target_path = self.resolve_path(filepath)
522
+ return target_path.read_text()
523
+
524
+ def _read_http(self, filepath: str) -> str:
525
+ """
526
+ Read a resource from an HTTP(S) URL.
527
+
528
+ Args:
529
+ filepath: Path within the base resource directory for this repo.
530
+ If empty, reads directly from the URL (for .toml file URLs).
531
+
532
+ Returns:
533
+ The content of the resource as a string.
534
+
535
+ Raises:
536
+ ValueError: If the HTTP request fails.
537
+ """
538
+ # Construct the full URL by joining the base URL with the filepath
539
+ if filepath:
540
+ # If the URL points to a .toml file, strip the filename to get the directory
541
+ base_url = self.url
542
+ if self._is_direct_toml_file():
543
+ # Strip the .toml filename to get the parent directory URL
544
+ base_url = base_url.rsplit("/", 1)[0]
545
+ url = base_url.rstrip("/") + "/" + filepath.lstrip("/")
546
+ else:
547
+ url = self.url
548
+
549
+ try:
550
+ response = http_session.get(url)
551
+ response.raise_for_status() # Raise an exception for HTTP errors
552
+ return response.text
553
+ except Exception as e:
554
+ raise ValueError(f"Failed to read {url}: {e}") from e
555
+
556
+ def _read_resource(self, filepath: str) -> str:
557
+ """
558
+ Read a resource from a Python package using a ``pkg://`` URL.
559
+
560
+ The package name must be configured via ``set_pkg_resource_root()`` before
561
+ using ``pkg://`` URLs.
562
+
563
+ Examples:
564
+ With ``set_pkg_resource_root("starbash")``:
565
+ url: pkg://defaults + filepath: "starbash.toml"
566
+ -> reads starbash/defaults/starbash.toml
567
+
568
+ Args:
569
+ filepath: Path within the base resource directory for this repo.
570
+ If empty, reads directly from the URL (for .toml file URLs).
571
+
572
+ Returns:
573
+ The content of the resource as a string (UTF-8).
574
+
575
+ Raises:
576
+ ValueError: If ``set_pkg_resource_root()`` has not been called.
577
+ """
578
+ if _pkg_resource_root is None:
579
+ raise ValueError(
580
+ "pkg:// URLs require calling set_pkg_resource_root() first "
581
+ "to specify the Python package to load resources from."
582
+ )
583
+
584
+ # Path portion after pkg://, interpreted relative to the configured package
585
+ subpath = self.url[len("pkg://") :].strip("/")
586
+
587
+ if filepath:
588
+ res = resources.files(_pkg_resource_root).joinpath(subpath).joinpath(filepath)
589
+ else:
590
+ res = resources.files(_pkg_resource_root).joinpath(subpath)
591
+ return res.read_text()
592
+
593
+ def _load_config(
594
+ self, default_toml: tomlkit.TOMLDocument | None = None
595
+ ) -> tomlkit.TOMLDocument:
596
+ """
597
+ Loads the repository's configuration file.
598
+
599
+ For URLs ending with .toml, reads that file directly.
600
+ Otherwise, reads the config suffix file from the directory.
601
+
602
+ If the config file does not exist, it logs a warning and returns an empty dict.
603
+
604
+ Returns:
605
+ A TOMLDocument containing the parsed configuration.
606
+ """
607
+ if default_toml is None:
608
+ default_toml = tomlkit.TOMLDocument() # empty placeholder
609
+
610
+ try:
611
+ if self._is_direct_toml_file():
612
+ # Read the .toml file directly from the URL
613
+ config_content = self.read("")
614
+ logging.debug(f"Loading repo config from {self.url}")
615
+ else:
616
+ # Read the config suffix file from the directory
617
+ config_content = self.read(_config_suffix)
618
+ logging.debug(f"Loading repo config from {_config_suffix}")
619
+ parsed = tomlkit.parse(config_content)
620
+
621
+ # All repos must have a "repo" table inside, otherwise we assume the file is invalid and should
622
+ # be reinited from template.
623
+ return parsed if "repo" in parsed else default_toml
624
+
625
+ except FileNotFoundError:
626
+ logging.debug(f"No config file found for {self.url}, using template...")
627
+ return default_toml
628
+
629
+ def read(self, filepath: str) -> str:
630
+ """
631
+ Read a filepath relative to the base of this repo. Return the contents in a string.
632
+
633
+ Args:
634
+ filepath: The path to the file, relative to the repository root.
635
+
636
+ Returns:
637
+ The content of the file as a string.
638
+ """
639
+ if self.is_scheme("file"):
640
+ return self._read_file(filepath)
641
+ elif self.is_scheme("pkg"):
642
+ return self._read_resource(filepath)
643
+ elif self.is_scheme("http") or self.is_scheme("https"):
644
+ return self._read_http(filepath)
645
+ else:
646
+ raise ValueError(f"Unsupported URL scheme for repo: {self.url}")
647
+
648
+ @overload
649
+ def get(self, key: str) -> Any | None: ...
650
+
651
+ @overload
652
+ def get[T](self, key: str, default: T, do_create: bool = False) -> T: ...
653
+
654
+ def get(self, key: str, default: Any | None = None, do_create: bool = False) -> Any | None:
655
+ """
656
+ Gets a value from this repo's config for a given key.
657
+ The key can be a dot-separated string for nested values.
658
+
659
+ Args:
660
+ key: The dot-separated key to search for (e.g., "repo.kind").
661
+ default: The value to return if the key is not found.
662
+ do_create: If True, creates intermediate tables and stores the default.
663
+
664
+ Returns:
665
+ The found value or the default.
666
+ """
667
+ value = self.config
668
+ parent: MutableMapping = value # track our dict parent in case we need to add to it
669
+ last_name = key
670
+ for k in key.split("."):
671
+ if value is None and do_create and default is not None:
672
+ # If we are here that means the node above us in the dot path was missing, make it as a table
673
+ value = tomlkit.table()
674
+ parent[last_name] = value
675
+
676
+ if not isinstance(value, dict):
677
+ # Key path traverses through a non-dict value (including None), return default
678
+ return default
679
+
680
+ parent = value
681
+ value = value.get(k)
682
+ last_name = k
683
+
684
+ if value is None and default is not None:
685
+ # Try to convert 'dumb' list and dict defaults into tomlkit equivalents
686
+ # Check for AoT first (before list) since AoT is a subclass of list
687
+ if isinstance(default, AoT):
688
+ # Preserve AoT type - don't convert it
689
+ value = default
690
+ elif isinstance(default, list):
691
+ value = tomlkit.array()
692
+ for item in default:
693
+ value.append(item)
694
+ elif isinstance(default, dict):
695
+ value = tomlkit.table()
696
+ for k, v in default.items():
697
+ value[k] = v
698
+ else:
699
+ value = default
700
+
701
+ # We might add the default value into the config when not found, because client might mutate it and then want to save the file
702
+ if do_create:
703
+ parent[last_name] = value
704
+
705
+ return value
706
+
707
+ def set(self, key: str, value: Any) -> None:
708
+ """
709
+ Sets a value in this repo's config for a given key.
710
+ The key can be a dot-separated string for nested values.
711
+ Creates nested Table structures as needed.
712
+
713
+ Args:
714
+ key: The dot-separated key to set (e.g., "repo.kind").
715
+ value: The value to set.
716
+
717
+ Example:
718
+ repo.set("repo.kind", "preferences")
719
+ repo.set("user.name", "John Doe")
720
+ """
721
+ keys = key.split(".")
722
+ current: Any = self.config
723
+
724
+ # Navigate/create nested structure for all keys except the last
725
+ for k in keys[:-1]:
726
+ if k not in current:
727
+ # Create a new nested table
728
+ current[k] = tomlkit.table()
729
+ elif not isinstance(current[k], dict):
730
+ # Overwrite non-dict value with a table
731
+ current[k] = tomlkit.table()
732
+ current = current[k]
733
+
734
+ # Set the final value
735
+ current[keys[-1]] = value