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/__init__.py +19 -0
- toml_repo/http_client.py +5 -0
- toml_repo/manager.py +141 -0
- toml_repo/py.typed +1 -0
- toml_repo/repo.py +735 -0
- toml_repo-0.1.2.dist-info/METADATA +151 -0
- toml_repo-0.1.2.dist-info/RECORD +9 -0
- toml_repo-0.1.2.dist-info/WHEEL +4 -0
- toml_repo-0.1.2.dist-info/licenses/LICENSE +674 -0
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
|