starbash 0.1.7__py3-none-any.whl → 0.1.8__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.

Potentially problematic release.


This version of starbash might be problematic. Click here for more details.

repo/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ The repo package handles finding, loading and searching starbash repositories.
3
+ """
4
+
5
+ from .manager import RepoManager
6
+
7
+ __all__ = ["RepoManager"]
repo/manager.py ADDED
@@ -0,0 +1,381 @@
1
+ """
2
+ Manages the repository of processing recipes and configurations.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import logging
7
+ from pathlib import Path
8
+ from importlib import resources
9
+ from typing import Any
10
+
11
+ import tomlkit
12
+ from tomlkit.toml_file import TOMLFile
13
+ from tomlkit.items import AoT
14
+ from multidict import MultiDict
15
+
16
+
17
+ repo_suffix = "starbash.toml"
18
+
19
+ REPO_REF = "repo-ref"
20
+
21
+
22
+ class Repo:
23
+ """
24
+ Represents a single starbash repository."""
25
+
26
+ def __init__(self, manager: RepoManager, url: str):
27
+ """
28
+ Initializes a Repo instance.
29
+
30
+ Args:
31
+ url: The URL to the repository (file or general http/https urls are acceptable).
32
+ """
33
+ self.manager = manager
34
+ self.url = url
35
+ self.config = self._load_config()
36
+
37
+ def __str__(self) -> str:
38
+ """Return a concise one-line description of this repo.
39
+
40
+ Example: "Repo(kind=recipe, local=True, url=file:///path/to/repo)"
41
+ """
42
+ return f"Repo(kind={self.kind}, url={self.url})"
43
+
44
+ __repr__ = __str__
45
+
46
+ def kind(self, unknown_kind: str = "unknown") -> str:
47
+ """
48
+ Read-only attribute for the repository kind (e.g., "recipe", "data", etc.).
49
+
50
+ Returns:
51
+ The kind of the repository as a string.
52
+ """
53
+ c = self.get("repo.kind", unknown_kind)
54
+ return str(c)
55
+
56
+ def add_repo_ref(self, dir: str) -> Repo | None:
57
+ """
58
+ Adds a new repo-ref to this repository's configuration.
59
+ if new returns the newly added Repo object, if already exists returns None"""
60
+
61
+ # if dir is not absolute, we need to resolve it relative to the cwd
62
+ if not Path(dir).is_absolute():
63
+ dir = str((Path.cwd() / dir).resolve())
64
+
65
+ # Add the ref to this repo
66
+ aot = self.config.get(REPO_REF, None)
67
+ if aot is None:
68
+ aot = tomlkit.aot()
69
+ self.config[REPO_REF] = aot # add an empty AoT at the end of the file
70
+
71
+ if type(aot) is not AoT:
72
+ raise ValueError(f"repo-ref in {self.url} is not an array")
73
+
74
+ for t in aot:
75
+ if "dir" in t and t["dir"] == dir:
76
+ logging.warning(f"Repo ref {dir} already exists - ignoring.")
77
+ return None # already exists
78
+
79
+ ref = {"dir": dir}
80
+ aot.append(ref)
81
+
82
+ # Also add the repo to the manager
83
+ return self.add_from_ref(ref)
84
+
85
+ def write_config(self) -> None:
86
+ """
87
+ Writes the current (possibly modified) configuration back to the repository's config file.
88
+
89
+ Raises:
90
+ ValueError: If the repository is not a local file repository.
91
+ """
92
+ base_path = self.get_path()
93
+ if base_path is None:
94
+ raise ValueError("Cannot resolve path for non-local repository")
95
+
96
+ config_path = base_path / repo_suffix
97
+ # FIXME, be more careful to write the file atomically (by writing to a temp file and renaming)
98
+ TOMLFile(config_path).write(self.config)
99
+ logging.debug(f"Wrote config to {config_path}")
100
+
101
+ def is_scheme(self, scheme: str = "file") -> bool:
102
+ """
103
+ Read-only attribute indicating whether the repository URL points to a
104
+ local file system path (file:// scheme).
105
+
106
+ Returns:
107
+ bool: True if the URL is a local file path, False otherwise.
108
+ """
109
+ return self.url.startswith(f"{scheme}://")
110
+
111
+ def get_path(self) -> Path | None:
112
+ """
113
+ Resolves the URL to a local file system path if it's a file URI.
114
+
115
+ Args:
116
+ url: The repository URL.
117
+
118
+ Returns:
119
+ A Path object if the URL is a local file, otherwise None.
120
+ """
121
+ if self.is_scheme("file"):
122
+ return Path(self.url[len("file://") :])
123
+
124
+ return None
125
+
126
+ def add_from_ref(self, ref: dict) -> Repo:
127
+ """
128
+ Adds a repository based on a repo-ref dictionary.
129
+ """
130
+ if "url" in ref:
131
+ url = ref["url"]
132
+ elif "dir" in ref:
133
+ # FIXME don't allow ~ or .. in file paths for security reasons?
134
+ if self.is_scheme("file"):
135
+ path = Path(ref["dir"])
136
+ base_path = self.get_path()
137
+
138
+ if base_path and not path.is_absolute():
139
+ # Resolve relative to the current TOML file's directory
140
+ path = (base_path / path).resolve()
141
+ else:
142
+ # Expand ~ and resolve from CWD
143
+ path = path.expanduser().resolve()
144
+ url = f"file://{path}"
145
+ else:
146
+ # construct an URL relative to this repo's URL
147
+ url = self.url.rstrip("/") + "/" + ref["dir"].lstrip("/")
148
+ else:
149
+ raise ValueError(f"Invalid repo reference: {ref}")
150
+ return self.manager.add_repo(url)
151
+
152
+ def add_by_repo_refs(self) -> None:
153
+ """Add all repos mentioned by repo-refs in this repo's config."""
154
+ repo_refs = self.config.get(REPO_REF, [])
155
+
156
+ for ref in repo_refs:
157
+ self.add_from_ref(ref)
158
+
159
+ def _read_file(self, filepath: str) -> str:
160
+ """
161
+ Read a filepath relative to the base of this repo. Return the contents in a string.
162
+
163
+ Args:
164
+ filepath: The path to the file, relative to the repository root.
165
+
166
+ Returns:
167
+ The content of the file as a string.
168
+ """
169
+ base_path = self.get_path()
170
+ if base_path is None:
171
+ raise ValueError("Cannot read files from non-local repositories")
172
+ target_path = (base_path / filepath).resolve()
173
+
174
+ # Security check to prevent reading files outside the repo directory.
175
+ # FIXME SECURITY - temporarily disabled because I want to let file urls say things like ~/foo.
176
+ # it would false trigger if user homedir path has a symlink in it (such as /home -> /var/home)
177
+ # base_path = PosixPath('/home/kevinh/.config/starbash') │ │
178
+ # filepath = 'starbash.toml' │ │
179
+ # self = <repr-error 'maximum recursion depth exceeded'> │ │
180
+ # target_path = PosixPath('/var/home/kevinh/.config/starbash/starbash.toml')
181
+ #
182
+ # if base_path not in target_path.parents and target_path != base_path:
183
+ # raise PermissionError("Attempted to read file outside of repository")
184
+
185
+ return target_path.read_text()
186
+
187
+ def _read_resource(self, filepath: str) -> str:
188
+ """
189
+ Read a resource from the installed starbash package using a pkg:// URL.
190
+
191
+ Assumptions (simplified per project constraints):
192
+ - All pkg URLs point somewhere inside the already-imported 'starbash' package.
193
+ - The URL is treated as a path relative to the starbash package root.
194
+
195
+ Examples:
196
+ url: pkg://defaults + filepath: "starbash.toml"
197
+ -> reads starbash/defaults/starbash.toml
198
+
199
+ Args:
200
+ filepath: Path within the base resource directory for this repo.
201
+
202
+ Returns:
203
+ The content of the resource as a string (UTF-8).
204
+ """
205
+ # Path portion after pkg://, interpreted relative to the 'starbash' package
206
+ subpath = self.url[len("pkg://") :].strip("/")
207
+
208
+ res = resources.files("starbash").joinpath(subpath).joinpath(filepath)
209
+ return res.read_text()
210
+
211
+ def _load_config(self) -> tomlkit.TOMLDocument:
212
+ """
213
+ Loads the repository's configuration file (e.g., repo.sb.toml).
214
+
215
+ If the config file does not exist, it logs a warning and returns an empty dict.
216
+
217
+ Returns:
218
+ A dictionary containing the parsed configuration.
219
+ """
220
+ try:
221
+ if self.is_scheme("file"):
222
+ config_content = self._read_file(repo_suffix)
223
+ elif self.is_scheme("pkg"):
224
+ config_content = self._read_resource(repo_suffix)
225
+ else:
226
+ raise ValueError(f"Unsupported URL scheme for repo: {self.url}")
227
+ logging.debug(f"Loading repo config from {repo_suffix}")
228
+ return tomlkit.parse(config_content)
229
+ except FileNotFoundError:
230
+ logging.debug(
231
+ f"No {repo_suffix} found"
232
+ ) # we currently make it optional to have the config file at root
233
+ return tomlkit.TOMLDocument() # empty placeholder
234
+
235
+ def get(self, key: str, default: Any | None = None) -> Any | None:
236
+ """
237
+ Gets a value from this repo's config for a given key.
238
+ The key can be a dot-separated string for nested values.
239
+
240
+ Args:
241
+ key: The dot-separated key to search for (e.g., "repo.kind").
242
+ default: The value to return if the key is not found.
243
+
244
+ Returns:
245
+ The found value or the default.
246
+ """
247
+ value = self.config
248
+ for k in key.split("."):
249
+ if not isinstance(value, dict):
250
+ return default
251
+ value = value.get(k)
252
+ return value if value is not None else default
253
+
254
+ def set(self, key: str, value: Any) -> None:
255
+ """
256
+ Sets a value in this repo's config for a given key.
257
+ The key can be a dot-separated string for nested values.
258
+ Creates nested Table structures as needed.
259
+
260
+ Args:
261
+ key: The dot-separated key to set (e.g., "repo.kind").
262
+ value: The value to set.
263
+
264
+ Example:
265
+ repo.set("repo.kind", "preferences")
266
+ repo.set("user.name", "John Doe")
267
+ """
268
+ keys = key.split(".")
269
+ current: Any = self.config
270
+
271
+ # Navigate/create nested structure for all keys except the last
272
+ for k in keys[:-1]:
273
+ if k not in current:
274
+ # Create a new nested table
275
+ current[k] = tomlkit.table()
276
+ elif not isinstance(current[k], dict):
277
+ # Overwrite non-dict value with a table
278
+ current[k] = tomlkit.table()
279
+ current = current[k]
280
+
281
+ # Set the final value
282
+ current[keys[-1]] = value
283
+
284
+
285
+ class RepoManager:
286
+ """
287
+ Manages the collection of starbash repositories.
288
+
289
+ This class is responsible for finding, loading, and providing an API
290
+ for searching through known repositories defined in TOML configuration
291
+ files (like appdefaults.sb.toml).
292
+ """
293
+
294
+ def __init__(self):
295
+ """
296
+ Initializes the RepoManager by loading the application default repos.
297
+ """
298
+ self.repos = []
299
+
300
+ # We expose the app default preferences as a special root repo with a private URL
301
+ # root_repo = Repo(self, "pkg://starbash-defaults", config=app_defaults)
302
+ # self.repos.append(root_repo)
303
+
304
+ # Most users will just want to read from merged
305
+ self.merged = MultiDict()
306
+
307
+ @property
308
+ def regular_repos(self) -> list[Repo]:
309
+ "We exclude certain repo types (preferences, recipe) from the list of repos users care about."
310
+ return [
311
+ r
312
+ for r in self.repos
313
+ if r.kind() not in ("preferences") and not r.is_scheme("pkg")
314
+ ]
315
+
316
+ def add_repo(self, url: str) -> Repo:
317
+ logging.debug(f"Adding repo: {url}")
318
+ r = Repo(self, url)
319
+ self.repos.append(r)
320
+
321
+ # FIXME, generate the merged dict lazily
322
+ self._add_merged(r)
323
+
324
+ # if this new repo has sub-repos, add them too
325
+ r.add_by_repo_refs()
326
+
327
+ return r
328
+
329
+ def get(self, key: str, default=None):
330
+ """
331
+ Searches for a key across all repositories and returns the first value found.
332
+ The search is performed in reverse order of repository loading, so the
333
+ most recently added repositories have precedence.
334
+
335
+ Args:
336
+ key: The dot-separated key to search for (e.g., "repo.kind").
337
+ default: The value to return if the key is not found in any repo.
338
+
339
+ Returns:
340
+ The found value or the default.
341
+ """
342
+ # Iterate in reverse to give precedence to later-loaded repos
343
+ for repo in reversed(self.repos):
344
+ value = repo.get(key)
345
+ if value is not None:
346
+ return value
347
+
348
+ return default
349
+
350
+ def dump(self):
351
+ """
352
+ Prints a detailed, multi-line description of the combined top-level keys
353
+ and values from all repositories, using a MultiDict for aggregation.
354
+ This is useful for debugging and inspecting the consolidated configuration.
355
+ """
356
+
357
+ combined_config = self.merged
358
+ logging.info("RepoManager Dump")
359
+ for key, value in combined_config.items():
360
+ # tomlkit.items() can return complex types (e.g., ArrayOfTables, Table)
361
+ # For a debug dump, a simple string representation is usually sufficient.
362
+ logging.info(f" %s: %s", key, value)
363
+
364
+ def _add_merged(self, repo: Repo) -> None:
365
+ for key, value in repo.config.items():
366
+ # if the toml object is an AoT type, monkey patch each element in the array instead
367
+ if isinstance(value, AoT):
368
+ for v in value:
369
+ setattr(v, "source", repo)
370
+ else:
371
+ # We monkey patch source into any object that came from a repo, so that users can
372
+ # find the source repo (for attribution, URL relative resolution, whatever...)
373
+ setattr(value, "source", repo)
374
+
375
+ self.merged.add(key, value)
376
+
377
+ def __str__(self):
378
+ lines = [f"RepoManager with {len(self.repos)} repositories:"]
379
+ for i, repo in enumerate(self.repos):
380
+ lines.append(f" [{i}] {repo.url}")
381
+ return "\n".join(lines)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: starbash
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: A tool for automating/standardizing/sharing astrophotography workflows.
5
5
  License-File: LICENSE
6
6
  Author: Kevin Hester
@@ -1,3 +1,5 @@
1
+ repo/__init__.py,sha256=TqspuLjPSNnO38tvCGa0fJvvasgecHl6fE7m0-Lj8ho,148
2
+ repo/manager.py,sha256=Fc_akPK7D8sw3961DM5BCQ7O27tnEdAutg8TSNiopgI,13903
1
3
  starbash/__init__.py,sha256=JNkBTu--Wf10eSHDwcpMS2hOp1VeTHmOGAcKPSlLbyo,618
2
4
  starbash/analytics.py,sha256=J1J8oXGZkdovXNkqOYGv12vl3p5EZEXHSXpcMrnUwzE,4122
3
5
  starbash/app.py,sha256=-dx92jUc7_jOJEsncubiRy5SUqMy10343MlRcm1zd9k,17921
@@ -24,8 +26,8 @@ starbash/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuF
24
26
  starbash/templates/userconfig.toml,sha256=LCV69jAxLrIPXrkt6U-gU4KLKeb9MHCD1ownqG211Ns,1442
25
27
  starbash/tool.py,sha256=S1kOTbeHTrA0meqwftgL0SA4VhJdZWWx2h1Wtwu1Izg,8749
26
28
  starbash/url.py,sha256=lorxQJ27jSfzsKCb0QvpcvLiPZG55Dkd_c1JPFbni4I,402
27
- starbash-0.1.7.dist-info/METADATA,sha256=GkrnXHF6cmTDWpGzXmUYDkscIlnXszGiLuZB21I6QXU,7215
28
- starbash-0.1.7.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
29
- starbash-0.1.7.dist-info/entry_points.txt,sha256=REQyWs8e5TJsNK7JVVWowKVoytMmKlUwuFHLTmSX4hc,67
30
- starbash-0.1.7.dist-info/licenses/LICENSE,sha256=ixuiBLtpoK3iv89l7ylKkg9rs2GzF9ukPH7ynZYzK5s,35148
31
- starbash-0.1.7.dist-info/RECORD,,
29
+ starbash-0.1.8.dist-info/METADATA,sha256=mEG49YmhE8HSzhWni1dfS8JOzJ2rDYIB_vKI3nmFAUA,7215
30
+ starbash-0.1.8.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
31
+ starbash-0.1.8.dist-info/entry_points.txt,sha256=REQyWs8e5TJsNK7JVVWowKVoytMmKlUwuFHLTmSX4hc,67
32
+ starbash-0.1.8.dist-info/licenses/LICENSE,sha256=ixuiBLtpoK3iv89l7ylKkg9rs2GzF9ukPH7ynZYzK5s,35148
33
+ starbash-0.1.8.dist-info/RECORD,,