ghostconfig 0.1.0__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.
@@ -0,0 +1,3 @@
1
+ from ghostconfig.ghost_config import GhostConfig, MissingConfigError
2
+
3
+ __all__ = ["GhostConfig", "MissingConfigError"]
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import pathlib
5
+ from typing import Any
6
+
7
+ from omegaconf import DictConfig, ListConfig, OmegaConf
8
+
9
+ from . import suggestions as suggestions_module
10
+ from . import tracker as tracker_module
11
+
12
+ _SENTINEL = object()
13
+ _GHOST_LIST_LENGTH = 2
14
+
15
+
16
+ class MissingConfigError(Exception):
17
+ pass
18
+
19
+
20
+ class GhostConfig:
21
+ def __init__(
22
+ self,
23
+ source: dict | pathlib.Path | str | Any,
24
+ *,
25
+ _tracker: tracker_module.AccessTracker | None = None,
26
+ _prefix: str = "",
27
+ _is_ghost: bool = False,
28
+ ) -> None:
29
+ if _tracker is not None:
30
+ self._data = source
31
+ self._tracker = _tracker
32
+ self._prefix = _prefix
33
+ self._is_ghost = _is_ghost
34
+ return
35
+
36
+ self._is_ghost = False
37
+ self._prefix = ""
38
+
39
+ if isinstance(source, (str, pathlib.Path)):
40
+ path = pathlib.Path(source)
41
+ if path.suffix in (".yaml", ".yml"):
42
+ self._data = OmegaConf.load(path)
43
+ self._tracker = tracker_module.AccessTracker(source_format="yaml", source_path=path)
44
+ elif path.suffix == ".json":
45
+ with path.open() as file:
46
+ data = json.load(file)
47
+ self._data = OmegaConf.create(data)
48
+ self._tracker = tracker_module.AccessTracker(source_format="json", source_path=path)
49
+ else:
50
+ raise ValueError(
51
+ f"Unsupported file extension: {path.suffix!r}. Use .yaml, .yml, or .json."
52
+ )
53
+ elif isinstance(source, dict):
54
+ self._data = OmegaConf.create(source)
55
+ self._tracker = tracker_module.AccessTracker(source_format="dict")
56
+ else:
57
+ raise TypeError(f"Expected a dict or file path, got {type(source).__name__!r}.")
58
+
59
+ def get(self, key: str | int, default: Any = _SENTINEL) -> Any:
60
+ full_key = f"{self._prefix}.{key}" if self._prefix else str(key)
61
+
62
+ if self._is_ghost:
63
+ if default is _SENTINEL:
64
+ return GhostConfig(
65
+ None, _tracker=self._tracker, _prefix=full_key, _is_ghost=True
66
+ )
67
+ self._tracker.record_missing(full_key, default)
68
+ return default
69
+
70
+ found, value = self._lookup(key)
71
+
72
+ if not found:
73
+ if default is _SENTINEL:
74
+ return GhostConfig(
75
+ None, _tracker=self._tracker, _prefix=full_key, _is_ghost=True
76
+ )
77
+ self._tracker.record_missing(full_key, default)
78
+ return default
79
+
80
+ if isinstance(value, DictConfig):
81
+ if default is not _SENTINEL:
82
+ raise TypeError(
83
+ f"Key {key!r} holds a nested config, but a default was provided. "
84
+ "Call get(key) without a default to navigate into a sub-config."
85
+ )
86
+ return GhostConfig(
87
+ value, _tracker=self._tracker, _prefix=full_key, _is_ghost=False
88
+ )
89
+
90
+ if isinstance(value, ListConfig):
91
+ if default is not _SENTINEL:
92
+ return OmegaConf.to_container(value, resolve=True)
93
+ return GhostConfig(
94
+ value, _tracker=self._tracker, _prefix=full_key, _is_ghost=False
95
+ )
96
+
97
+ return value
98
+
99
+ def check(self) -> None:
100
+ if not self._tracker.missing:
101
+ return
102
+ suggestion = suggestions_module.format_suggestion(
103
+ self._tracker.missing,
104
+ self._tracker.source_format,
105
+ )
106
+ source_description = self._tracker.source_format
107
+ if self._tracker.source_path is not None:
108
+ source_description += f" ({self._tracker.source_path})"
109
+ missing_paths = "\n".join(f" {key}: {value!r}" for key, value in self._tracker.missing.items())
110
+ raise MissingConfigError(
111
+ f"The following parameters were used but missing from the config.\n"
112
+ f"Missing keys:\n{missing_paths}\n\n"
113
+ f"Since this started from a {source_description}, you should add:\n\n"
114
+ f"{suggestion}"
115
+ )
116
+
117
+ def __iter__(self): # type: ignore[override]
118
+ if self._is_ghost:
119
+ for index in range(_GHOST_LIST_LENGTH):
120
+ full_key = f"{self._prefix}.{index}" if self._prefix else str(index)
121
+ yield GhostConfig(
122
+ None, _tracker=self._tracker, _prefix=full_key, _is_ghost=True
123
+ )
124
+ return
125
+
126
+ if isinstance(self._data, ListConfig):
127
+ for index, item in enumerate(self._data):
128
+ full_key = f"{self._prefix}.{index}" if self._prefix else str(index)
129
+ if isinstance(item, (DictConfig, ListConfig)):
130
+ yield GhostConfig(
131
+ item, _tracker=self._tracker, _prefix=full_key, _is_ghost=False
132
+ )
133
+ else:
134
+ yield item
135
+ return
136
+
137
+ raise TypeError(
138
+ f"Cannot iterate over a non-list config at key {self._prefix!r}. "
139
+ "Use get(key) without a default to navigate into a sub-config."
140
+ )
141
+
142
+ def _lookup(self, key: str | int) -> tuple[bool, Any]:
143
+ if isinstance(self._data, DictConfig) and isinstance(key, str):
144
+ try:
145
+ value = self._data[key]
146
+ return True, value
147
+ except (KeyError, Exception):
148
+ return False, None
149
+
150
+ if isinstance(self._data, ListConfig) and isinstance(key, int):
151
+ if 0 <= key < len(self._data):
152
+ return True, self._data[key]
153
+ return False, None
154
+
155
+ return False, None
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import pprint
5
+ from typing import Any, Literal, cast
6
+
7
+ import yaml
8
+
9
+
10
+ def build_nested(missing: dict[str, Any]) -> Any:
11
+ """Explode dotted-path keys back into a nested dict.
12
+
13
+ Path segments that are consecutive integers starting from 0 are converted
14
+ to lists so the YAML/JSON suggestion uses proper list syntax.
15
+ """
16
+ nested: dict[str, Any] = {}
17
+ for dotted_path, default_value in missing.items():
18
+ parts = dotted_path.split(".")
19
+ node = nested
20
+ for part in parts[:-1]:
21
+ if part not in node or not isinstance(node[part], dict):
22
+ node[part] = {}
23
+ node = node[part]
24
+ node[parts[-1]] = default_value
25
+ return _convert_integer_keys_to_lists(nested)
26
+
27
+
28
+ def _convert_integer_keys_to_lists(node: Any) -> Any:
29
+ """Recursively replace dicts whose keys are consecutive ints (0, 1, …) with lists."""
30
+ if not isinstance(node, dict):
31
+ return node
32
+ converted = {key: _convert_integer_keys_to_lists(value) for key, value in node.items()}
33
+ try:
34
+ integer_keys = sorted(int(key) for key in converted)
35
+ if integer_keys == list(range(len(integer_keys))):
36
+ return [converted[str(i)] for i in integer_keys]
37
+ except ValueError:
38
+ pass
39
+ return converted
40
+
41
+
42
+ def format_suggestion(
43
+ missing: dict[str, Any],
44
+ source_format: Literal["yaml", "json", "dict"],
45
+ ) -> str:
46
+ nested = build_nested(missing)
47
+ if source_format == "yaml":
48
+ return cast(str, yaml.safe_dump(nested, sort_keys=False, default_flow_style=False))
49
+ if source_format == "json":
50
+ return json.dumps(nested, indent=2)
51
+ return pprint.pformat(nested, sort_dicts=False)
ghostconfig/tracker.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+ from typing import Any, Literal
5
+
6
+
7
+ class AccessTracker:
8
+ """Tracks leaf accesses that could not be satisfied from the backing config data."""
9
+
10
+ def __init__(
11
+ self,
12
+ source_format: Literal["yaml", "json", "dict"] = "dict",
13
+ source_path: pathlib.Path | str | None = None,
14
+ ) -> None:
15
+ self.source_format = source_format
16
+ self.source_path = pathlib.Path(source_path) if source_path is not None else None
17
+ self.missing: dict[str, Any] = {}
18
+
19
+ def record_missing(self, dotted_path: str, default: Any) -> None:
20
+ """Record a missing key. First write wins (repeated access keeps first default)."""
21
+ if dotted_path not in self.missing:
22
+ self.missing[dotted_path] = default
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghostconfig
3
+ Version: 0.1.0
4
+ Summary: A config system that is lazily validated as parameters are used
5
+ Project-URL: Homepage, https://github.com/ChainBreak/ghostconfig
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Thomas Rowntree
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Classifier: License :: OSI Approved :: MIT License
29
+ Classifier: Operating System :: OS Independent
30
+ Classifier: Programming Language :: Python :: 3
31
+ Requires-Python: >=3.9
32
+ Requires-Dist: omegaconf
33
+ Provides-Extra: dev
34
+ Requires-Dist: mypy; extra == 'dev'
35
+ Requires-Dist: pytest; extra == 'dev'
36
+ Requires-Dist: ruff; extra == 'dev'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # ghostconfig
40
+
41
+ A config system that is lazily validated as parameters are used.
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install ghostconfig
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ### Creating a config
52
+
53
+ ```python
54
+ import ghostconfig
55
+
56
+ # From a YAML file (OmegaConf interpolations supported)
57
+ config = ghostconfig.GhostConfig("path/to/config.yaml")
58
+
59
+ # From a JSON file
60
+ config = ghostconfig.GhostConfig("path/to/config.json")
61
+
62
+ # From a plain Python dict
63
+ config = ghostconfig.GhostConfig({"num_epochs": 10, "learning_rate": 0.001})
64
+ ```
65
+
66
+ ### Reading values
67
+
68
+ `get(key)` with no default returns a `GhostConfig` sub-config (real or ghost).
69
+ `get(key, default)` returns the leaf value (type inferred from the default).
70
+
71
+ ```python
72
+ # Given config.yaml:
73
+ # model:
74
+ # layers: 4
75
+ # block: resnet
76
+ # dataset:
77
+ # path: my/data/
78
+ # augmentations: [crop]
79
+
80
+ model_config = config.get("model") # GhostConfig
81
+ layers = model_config.get("layers", 1) # 4
82
+
83
+ # Accessing a key that doesn't exist in the YAML is fine at this point —
84
+ # a "ghost" GhostConfig is returned and the access is recorded.
85
+ training_config = config.get("training")
86
+ learning_rate = training_config.get("learning_rate", 0.001) # returns 0.001
87
+ ```
88
+
89
+ ### Validating at setup time
90
+
91
+ Call `check()` once all parameters have been read. It raises `MissingConfigError`
92
+ with a suggestion showing exactly what to add to your config file.
93
+
94
+ ```python
95
+ config.check()
96
+ # MissingConfigError:
97
+ # The following parameters were used but missing from the config.
98
+ # Since this started from a yaml (path/to/config.yaml), you should add:
99
+ #
100
+ # training:
101
+ # learning_rate: 0.001
102
+ ```
103
+
104
+ If multiple keys are missing they are merged into a single suggestion block.
105
+ The format matches the source: YAML for `.yaml` files, JSON for `.json` files,
106
+ and a Python dict literal for dict-based configs.
107
+
108
+ ## Development
109
+
110
+ ### Setup
111
+
112
+ ```bash
113
+ pip install -e ".[dev]"
114
+ ```
115
+
116
+ Or install with test dependencies:
117
+
118
+ ```bash
119
+ pip install pytest
120
+ pip install -e .
121
+ ```
122
+
123
+ ### Running Tests
124
+
125
+ ```bash
126
+ pytest
127
+ ```
128
+
129
+ To run with verbose output:
130
+
131
+ ```bash
132
+ pytest -v
133
+ ```
134
+
135
+ ## Building and Publishing to PyPI
136
+
137
+ ### Prerequisites
138
+
139
+ ```bash
140
+ pip install build twine
141
+ ```
142
+
143
+ ### Build the distribution
144
+
145
+ ```bash
146
+ python -m build
147
+ ```
148
+
149
+ This creates a `dist/` directory containing a `.whl` and `.tar.gz` file.
150
+
151
+ ### Upload to PyPI
152
+
153
+ First, upload to [TestPyPI](https://test.pypi.org/) to verify everything looks correct:
154
+
155
+ ```bash
156
+ twine upload --repository testpypi dist/*
157
+ ```
158
+
159
+ When ready, upload to the real PyPI:
160
+
161
+ ```bash
162
+ twine upload dist/*
163
+ ```
164
+
165
+ You will be prompted for your PyPI credentials. It is recommended to use an [API token](https://pypi.org/manage/account/token/) instead of your password.
166
+
167
+ To avoid entering credentials each time, create a `~/.pypirc` file:
168
+
169
+ ```ini
170
+ [pypi]
171
+ username = __token__
172
+ password = pypi-your-api-token-here
173
+ ```
@@ -0,0 +1,8 @@
1
+ ghostconfig/__init__.py,sha256=HAr141EZVz0U4Ol_KyzTqy0SvwvmyCn6tfb38wOAJ00,118
2
+ ghostconfig/ghost_config.py,sha256=UdwlE9mFRo-mkYX6m0LO82w8irP2zmpm3ad-8TZ-JRY,5682
3
+ ghostconfig/suggestions.py,sha256=FpyJ6GZKT-wqh25PX509jDyI7rvPIgMg90B2gdx4pzo,1721
4
+ ghostconfig/tracker.py,sha256=kXdskexhXscV9mBpUhOU37TE_GyxIn5hkgGjx7xIJeM,793
5
+ ghostconfig-0.1.0.dist-info/METADATA,sha256=wVAsizDX0OC4_Ujfxb3gv_dUsI3LkiIg2L-Q07zZDOo,4599
6
+ ghostconfig-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
7
+ ghostconfig-0.1.0.dist-info/licenses/LICENSE,sha256=_9m5tH_fHIphCPTVjZ2KgcjC7I3HbxroRNAsnBcJPu8,1072
8
+ ghostconfig-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Rowntree
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.