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.
- ghostconfig/__init__.py +3 -0
- ghostconfig/ghost_config.py +155 -0
- ghostconfig/suggestions.py +51 -0
- ghostconfig/tracker.py +22 -0
- ghostconfig-0.1.0.dist-info/METADATA +173 -0
- ghostconfig-0.1.0.dist-info/RECORD +8 -0
- ghostconfig-0.1.0.dist-info/WHEEL +4 -0
- ghostconfig-0.1.0.dist-info/licenses/LICENSE +21 -0
ghostconfig/__init__.py
ADDED
|
@@ -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,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.
|