typerconf 1.0__tar.gz
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.
- typerconf-1.0/LICENSE +21 -0
- typerconf-1.0/PKG-INFO +90 -0
- typerconf-1.0/README.md +61 -0
- typerconf-1.0/pyproject.toml +33 -0
- typerconf-1.0/src/typerconf/.gitignore +4 -0
- typerconf-1.0/src/typerconf/Makefile +18 -0
- typerconf-1.0/src/typerconf/__init__.py +217 -0
- typerconf-1.0/src/typerconf/init.nw +442 -0
typerconf-1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022--2023 Daniel Bosk
|
|
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.
|
typerconf-1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: typerconf
|
|
3
|
+
Version: 1.0
|
|
4
|
+
Summary: Library to read and write configs using API and CLI with Typer
|
|
5
|
+
Home-page: https://github.com/dbosk/typerconf
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: typer,conf,config,git-like,config lib,write conf
|
|
8
|
+
Author: Daniel Bosk
|
|
9
|
+
Author-email: daniel@bosk.se
|
|
10
|
+
Requires-Python: >=3.7,<4.0
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Dist: appdirs (>=1.4.4,<2.0.0)
|
|
23
|
+
Requires-Dist: typer (>=0.7.0,<0.8.0)
|
|
24
|
+
Project-URL: Bug Tracker, https://github.com/dbosk/typerconf/issues
|
|
25
|
+
Project-URL: Repository, https://github.com/dbosk/typerconf
|
|
26
|
+
Project-URL: Releases, https://github.com/dbosk/typerconf/releases
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
The configuration is a JSON structure. We'll use the following for the
|
|
30
|
+
coming examples.
|
|
31
|
+
|
|
32
|
+
```JSON
|
|
33
|
+
{
|
|
34
|
+
"courses": {
|
|
35
|
+
"datintro22": {
|
|
36
|
+
"timesheet": {
|
|
37
|
+
"url": "https://sheets.google..."
|
|
38
|
+
},
|
|
39
|
+
"schedule": {
|
|
40
|
+
"url": "https://timeedit.net/..."
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The format is actually irrelevant to anyone outside of this library,
|
|
48
|
+
since it will never be accessed directly anyway. But it will be used to
|
|
49
|
+
illustrate the examples.
|
|
50
|
+
|
|
51
|
+
We can access values by dot-separated addresses. For instance, we can
|
|
52
|
+
use `courses.datintro22.schedule.url` to access the TimeEdit URL of the
|
|
53
|
+
datintro22 course.
|
|
54
|
+
|
|
55
|
+
Let's have a look at some usage examples. Say we have the program
|
|
56
|
+
`nytid` that wants to use this config module and subcommand.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import typer
|
|
60
|
+
import typerconf as config
|
|
61
|
+
|
|
62
|
+
cli = typer.Typer()
|
|
63
|
+
# add some other subcommands
|
|
64
|
+
config.add_config_cmd(cli)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
We want the CLI command to have the following form when used with
|
|
68
|
+
`nytid`.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
nytid config courses.datintro22.schedule.url --set https://timeedit.net/...
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
will set the configuration value at the path, whereas
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
nytid config courses.datintro22.schedule.url
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
will return it.
|
|
81
|
+
|
|
82
|
+
Internally, `nytid`'s different parts can access the config through the
|
|
83
|
+
following API.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import typerconf as config
|
|
87
|
+
|
|
88
|
+
url = config.get("courses.datintro22.schedule.url")
|
|
89
|
+
```
|
|
90
|
+
|
typerconf-1.0/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
The configuration is a JSON structure. We'll use the following for the
|
|
2
|
+
coming examples.
|
|
3
|
+
|
|
4
|
+
```JSON
|
|
5
|
+
{
|
|
6
|
+
"courses": {
|
|
7
|
+
"datintro22": {
|
|
8
|
+
"timesheet": {
|
|
9
|
+
"url": "https://sheets.google..."
|
|
10
|
+
},
|
|
11
|
+
"schedule": {
|
|
12
|
+
"url": "https://timeedit.net/..."
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The format is actually irrelevant to anyone outside of this library,
|
|
20
|
+
since it will never be accessed directly anyway. But it will be used to
|
|
21
|
+
illustrate the examples.
|
|
22
|
+
|
|
23
|
+
We can access values by dot-separated addresses. For instance, we can
|
|
24
|
+
use `courses.datintro22.schedule.url` to access the TimeEdit URL of the
|
|
25
|
+
datintro22 course.
|
|
26
|
+
|
|
27
|
+
Let's have a look at some usage examples. Say we have the program
|
|
28
|
+
`nytid` that wants to use this config module and subcommand.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import typer
|
|
32
|
+
import typerconf as config
|
|
33
|
+
|
|
34
|
+
cli = typer.Typer()
|
|
35
|
+
# add some other subcommands
|
|
36
|
+
config.add_config_cmd(cli)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
We want the CLI command to have the following form when used with
|
|
40
|
+
`nytid`.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
nytid config courses.datintro22.schedule.url --set https://timeedit.net/...
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
will set the configuration value at the path, whereas
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
nytid config courses.datintro22.schedule.url
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
will return it.
|
|
53
|
+
|
|
54
|
+
Internally, `nytid`'s different parts can access the config through the
|
|
55
|
+
following API.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import typerconf as config
|
|
59
|
+
|
|
60
|
+
url = config.get("courses.datintro22.schedule.url")
|
|
61
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "typerconf"
|
|
3
|
+
version = "1.0"
|
|
4
|
+
description = "Library to read and write configs using API and CLI with Typer"
|
|
5
|
+
authors = ["Daniel Bosk <daniel@bosk.se>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
repository = "https://github.com/dbosk/typerconf"
|
|
9
|
+
keywords = ["typer", "conf", "config", "git-like", "config lib", "write conf"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Topic :: Utilities"
|
|
16
|
+
]
|
|
17
|
+
include = ["src/**/*.py"]
|
|
18
|
+
|
|
19
|
+
[tool.poetry.urls]
|
|
20
|
+
"Bug Tracker" = "https://github.com/dbosk/typerconf/issues"
|
|
21
|
+
"Releases" = "https://github.com/dbosk/typerconf/releases"
|
|
22
|
+
|
|
23
|
+
[tool.poetry.dependencies]
|
|
24
|
+
python = "^3.7"
|
|
25
|
+
appdirs = "^1.4.4"
|
|
26
|
+
typer = "^0.7.0"
|
|
27
|
+
|
|
28
|
+
[tool.poetry.group.dev.dependencies]
|
|
29
|
+
pytest = "^7.2.2"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["poetry-core>=1.0.0"]
|
|
33
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MODULES+= __init__.py
|
|
2
|
+
|
|
3
|
+
.PHONY: all
|
|
4
|
+
all: ${MODULES}
|
|
5
|
+
|
|
6
|
+
.INTERMEDIATE: init.py
|
|
7
|
+
__init__.py: init.py
|
|
8
|
+
${MV} $< $@
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
.PHONY: clean
|
|
12
|
+
clean:
|
|
13
|
+
${RM} -R ${MODULES} __pycache__
|
|
14
|
+
${RM} *.tex
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
INCLUDE_MAKEFILES=../../../../makefiles
|
|
18
|
+
include ${INCLUDE_MAKEFILES}/noweb.mk
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""The typerconf module and config subcommand"""
|
|
2
|
+
|
|
3
|
+
import appdirs
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import typer
|
|
9
|
+
import typing
|
|
10
|
+
|
|
11
|
+
dirs = appdirs.AppDirs(sys.argv[0])
|
|
12
|
+
|
|
13
|
+
class Config:
|
|
14
|
+
"""Navigates nested JSON structures by dot-separated addressing."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, json_data={}):
|
|
17
|
+
"""
|
|
18
|
+
Constructs a config object to navigate from JSON data `json_data`.
|
|
19
|
+
"""
|
|
20
|
+
self.__data = json_data
|
|
21
|
+
|
|
22
|
+
def get(self, path: str = "") -> typing.Any:
|
|
23
|
+
"""
|
|
24
|
+
Returns object at `path`.
|
|
25
|
+
Example:
|
|
26
|
+
- `path = "courses.datintro22.url"` and
|
|
27
|
+
- Config contains `{"courses": {"datintro22": {"url": "https://..."}}}`
|
|
28
|
+
will return "https://...".
|
|
29
|
+
|
|
30
|
+
Any part of the path that can be converted to an integer, will be converted
|
|
31
|
+
to an integer. This way we can access elements of lists too.
|
|
32
|
+
"""
|
|
33
|
+
structure = self.__data
|
|
34
|
+
|
|
35
|
+
if not path:
|
|
36
|
+
return structure
|
|
37
|
+
|
|
38
|
+
for part in path.split("."):
|
|
39
|
+
try:
|
|
40
|
+
part = int(part)
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
structure = structure[part]
|
|
46
|
+
except KeyError:
|
|
47
|
+
raise KeyError(f"{part} along {path} doesn't exist")
|
|
48
|
+
|
|
49
|
+
return structure
|
|
50
|
+
|
|
51
|
+
def set(self, path: str, value: typing.Any):
|
|
52
|
+
"""
|
|
53
|
+
Sets `value` at `path`. Any parts along the path that don't exist will be
|
|
54
|
+
created.
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
- `value = "https://..."`,
|
|
58
|
+
- `path = "courses.datintro22.url"`
|
|
59
|
+
will create `{"courses": {"datintro22": {"url": "https://..."}}}`.
|
|
60
|
+
|
|
61
|
+
Any part of the path that can be converted to an integer, will be converted
|
|
62
|
+
to an integer. This way we can access elements of lists too. However, we
|
|
63
|
+
cannot create index 3 in a list if it doesn't exist.
|
|
64
|
+
"""
|
|
65
|
+
structure = self.__data
|
|
66
|
+
|
|
67
|
+
parts = path.split(".")
|
|
68
|
+
for part in parts[:-1]:
|
|
69
|
+
try:
|
|
70
|
+
part = int(part)
|
|
71
|
+
except ValueError:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
structure = structure[part]
|
|
76
|
+
except KeyError:
|
|
77
|
+
structure[part] = {}
|
|
78
|
+
structure = structure[part]
|
|
79
|
+
|
|
80
|
+
part = parts[-1]
|
|
81
|
+
try:
|
|
82
|
+
part = int(part)
|
|
83
|
+
except ValueError:
|
|
84
|
+
pass
|
|
85
|
+
structure[part] = value
|
|
86
|
+
|
|
87
|
+
def paths(self, from_root=""):
|
|
88
|
+
"""
|
|
89
|
+
Returns all existing paths.
|
|
90
|
+
|
|
91
|
+
The optional argument `from_root` is a path and the method return all
|
|
92
|
+
subpaths rooted at that point.
|
|
93
|
+
"""
|
|
94
|
+
paths = []
|
|
95
|
+
root = self.get(from_root)
|
|
96
|
+
|
|
97
|
+
if isinstance(root, dict):
|
|
98
|
+
for part in root:
|
|
99
|
+
if from_root:
|
|
100
|
+
path = f"{from_root}.{part}"
|
|
101
|
+
else:
|
|
102
|
+
path = part
|
|
103
|
+
|
|
104
|
+
paths.append(path)
|
|
105
|
+
paths += self.paths(from_root=path)
|
|
106
|
+
elif isinstance(root, list):
|
|
107
|
+
paths += [f"{from_root}.{x}" for x in range(len(root))]
|
|
108
|
+
|
|
109
|
+
return paths
|
|
110
|
+
def add_config_cmd(cli: typer.Typer):
|
|
111
|
+
"""
|
|
112
|
+
Add config command to Typer cli
|
|
113
|
+
"""
|
|
114
|
+
path_arg = typer.Argument(...,
|
|
115
|
+
help="Path in config, e.g. 'courses.datintro22'. "
|
|
116
|
+
"Empty string is root of config.",
|
|
117
|
+
autocompletion=complete_path)
|
|
118
|
+
value_arg = typer.Option([], "-s", "--set",
|
|
119
|
+
help="Values to store. "
|
|
120
|
+
"More than one value makes a list. "
|
|
121
|
+
"Values are treated as JSON if possible.")
|
|
122
|
+
|
|
123
|
+
@cli.command(name="config")
|
|
124
|
+
def config_cmd(path: str = path_arg,
|
|
125
|
+
values: typing.List[str] = value_arg):
|
|
126
|
+
"""
|
|
127
|
+
Reads values from or writes values to the config.
|
|
128
|
+
"""
|
|
129
|
+
if values:
|
|
130
|
+
set(path, values)
|
|
131
|
+
else:
|
|
132
|
+
print_config(get(path), path)
|
|
133
|
+
def get(path: str = "") -> typing.Any:
|
|
134
|
+
"""
|
|
135
|
+
Returns the value stored at `path` in the config.
|
|
136
|
+
|
|
137
|
+
By default, `path = ""`, which returns the entire configuration as a Config
|
|
138
|
+
object.
|
|
139
|
+
"""
|
|
140
|
+
conf = read_config()
|
|
141
|
+
return conf.get(path)
|
|
142
|
+
|
|
143
|
+
def set(path: str, values: typing.List[typing.Any]):
|
|
144
|
+
"""
|
|
145
|
+
Sets `value` at `path` in the config. `value` will be interpreted as JSON, if
|
|
146
|
+
conversion to JSON fails, it will be used as is.
|
|
147
|
+
"""
|
|
148
|
+
conf = read_config()
|
|
149
|
+
for i in range(len(values)):
|
|
150
|
+
try:
|
|
151
|
+
values[i] = json.loads(values[i])
|
|
152
|
+
except json.decoder.JSONDecodeError:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
if len(values) == 1:
|
|
156
|
+
values = values[0]
|
|
157
|
+
|
|
158
|
+
conf.set(path, values)
|
|
159
|
+
write_config(conf)
|
|
160
|
+
def read_config(conf_path=f"{dirs.user_config_dir}/config.json"):
|
|
161
|
+
"""
|
|
162
|
+
Returns the config data structure (JSON).
|
|
163
|
+
`conf_path` is an optional argument providing the path to the config file.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
with open(conf_path) as conf_file:
|
|
167
|
+
return Config(json.load(conf_file))
|
|
168
|
+
except FileNotFoundError:
|
|
169
|
+
logging.warning(f"Config file {conf_path} could not be found.")
|
|
170
|
+
except json.decoder.JSONDecodeError as err:
|
|
171
|
+
logging.warning(f"Config file {conf_path} could not be decoded: {err}")
|
|
172
|
+
|
|
173
|
+
return Config()
|
|
174
|
+
|
|
175
|
+
def write_config(conf,
|
|
176
|
+
conf_path=f"{dirs.user_config_dir}/config.json"):
|
|
177
|
+
"""
|
|
178
|
+
Stores the config data `conf` (extracts JSON) in the config file.
|
|
179
|
+
`conf_path` is an optional argument providing the path to the config file.
|
|
180
|
+
"""
|
|
181
|
+
conf_dir = os.path.dirname(conf_path)
|
|
182
|
+
if not os.path.isdir(conf_dir):
|
|
183
|
+
os.mkdir(conf_dir)
|
|
184
|
+
|
|
185
|
+
with open(conf_path, "w") as conf_file:
|
|
186
|
+
json.dump(conf.get(), conf_file)
|
|
187
|
+
def complete_path(initial_path: str, conf: Config = None):
|
|
188
|
+
"""
|
|
189
|
+
Returns all valid paths in the config starting with `initial_path`.
|
|
190
|
+
If `conf` is not None, use that instead of the actual config.
|
|
191
|
+
"""
|
|
192
|
+
if not conf:
|
|
193
|
+
conf = Config(get())
|
|
194
|
+
|
|
195
|
+
return list(filter(lambda x: x.startswith(initial_path),
|
|
196
|
+
conf.paths()))
|
|
197
|
+
def print_config(conf, path=""):
|
|
198
|
+
"""
|
|
199
|
+
Prints the config tree contained in `conf` to stdout.
|
|
200
|
+
Optional `path` is prepended.
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
for key in conf.keys():
|
|
204
|
+
if path:
|
|
205
|
+
print_config(conf[key], f"{path}.{key}")
|
|
206
|
+
else:
|
|
207
|
+
print_config(conf[key], key)
|
|
208
|
+
except AttributeError:
|
|
209
|
+
print(f"{path} = {conf}")
|
|
210
|
+
|
|
211
|
+
def main():
|
|
212
|
+
cli = typer.Typer()
|
|
213
|
+
add_config_cmd(cli)
|
|
214
|
+
cli()
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
main()
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
\section{Code outline}\label{configmodule}
|
|
2
|
+
|
|
3
|
+
The base structure is standard.
|
|
4
|
+
<<init.py>>=
|
|
5
|
+
"""The typerconf module and config subcommand"""
|
|
6
|
+
|
|
7
|
+
import appdirs
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import typer
|
|
13
|
+
import typing
|
|
14
|
+
|
|
15
|
+
<<set up appdirs dirs>>
|
|
16
|
+
|
|
17
|
+
<<classes>>
|
|
18
|
+
<<helper functions>>
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
<<main body>>
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
main()
|
|
25
|
+
@
|
|
26
|
+
|
|
27
|
+
\subsection{Not using [[add_typer]]}
|
|
28
|
+
|
|
29
|
+
With the design outlined in \cref{Overview}, we can't use the default way of
|
|
30
|
+
Typer ([[.add_typer]]), as that would require another level of subcommands:
|
|
31
|
+
\begin{minted}{bash}
|
|
32
|
+
nytid config get courses.datintro22.schedule.url
|
|
33
|
+
\end{minted}
|
|
34
|
+
to read out the value, but we don't want that [[get]] part in there.
|
|
35
|
+
|
|
36
|
+
To work around that we need the [[cli]] object from the parent module here.
|
|
37
|
+
Thus, instead of that module doing something like
|
|
38
|
+
\begin{minted}{python}
|
|
39
|
+
import typerconf as config
|
|
40
|
+
# ...
|
|
41
|
+
cli.add_typer(config.cli, name="config")
|
|
42
|
+
\end{minted}
|
|
43
|
+
as is normally done with Typer, that module will actually have to do
|
|
44
|
+
\begin{minted}{python}
|
|
45
|
+
import typerconf as config
|
|
46
|
+
# ...
|
|
47
|
+
config.add_config_cmd(cli)
|
|
48
|
+
\end{minted}
|
|
49
|
+
So we need to provide such a function.
|
|
50
|
+
<<helper functions>>=
|
|
51
|
+
def add_config_cmd(cli: typer.Typer):
|
|
52
|
+
"""
|
|
53
|
+
Add config command to Typer cli
|
|
54
|
+
"""
|
|
55
|
+
<<config subcommands>>
|
|
56
|
+
@
|
|
57
|
+
|
|
58
|
+
To make this module runnable on its own (using [[main]]), we will create a
|
|
59
|
+
[[cli]] object in the [[main]] function, then add the config command and
|
|
60
|
+
finally run it.
|
|
61
|
+
(This is exactly how a program would add the [[config]] subcommand.)
|
|
62
|
+
<<main body>>=
|
|
63
|
+
cli = typer.Typer()
|
|
64
|
+
add_config_cmd(cli)
|
|
65
|
+
cli()
|
|
66
|
+
@
|
|
67
|
+
|
|
68
|
+
\subsection{Testing}
|
|
69
|
+
|
|
70
|
+
We also want to test all functions we provide in this module.
|
|
71
|
+
These test functions are all prepended [[test_]] to the function name.
|
|
72
|
+
We will run them using [[pytest]].
|
|
73
|
+
<<test init.py>>=
|
|
74
|
+
from typerconf import *
|
|
75
|
+
|
|
76
|
+
<<test functions>>
|
|
77
|
+
@
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
\section{Application directories}
|
|
81
|
+
|
|
82
|
+
We use the application directory locations as provided by the [[appdirs]]
|
|
83
|
+
package.
|
|
84
|
+
We automated the step with the application name by using the value of the
|
|
85
|
+
command run, \ie~[[sys.argv[0]]].
|
|
86
|
+
(However, this default can be updated by just replacing the value of [[dirs]].)
|
|
87
|
+
<<set up appdirs dirs>>=
|
|
88
|
+
dirs = appdirs.AppDirs(sys.argv[0])
|
|
89
|
+
@
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
\section{Accessing the configuration: the [[get]] and [[set]] functions}
|
|
93
|
+
|
|
94
|
+
We will provide two functions, [[get]] and [[set]], that modifies the config,
|
|
95
|
+
immediately syncing to the file system.
|
|
96
|
+
This is the API to be used by any part of a program using this module to manage
|
|
97
|
+
the configuration.
|
|
98
|
+
<<helper functions>>=
|
|
99
|
+
def get(path: str = "") -> typing.Any:
|
|
100
|
+
"""
|
|
101
|
+
Returns the value stored at `path` in the config.
|
|
102
|
+
|
|
103
|
+
By default, `path = ""`, which returns the entire configuration as a Config
|
|
104
|
+
object.
|
|
105
|
+
"""
|
|
106
|
+
<<read config from file>>
|
|
107
|
+
<<return the value at path in config>>
|
|
108
|
+
|
|
109
|
+
def set(path: str, values: typing.List[typing.Any]):
|
|
110
|
+
"""
|
|
111
|
+
Sets `value` at `path` in the config. `value` will be interpreted as JSON, if
|
|
112
|
+
conversion to JSON fails, it will be used as is.
|
|
113
|
+
"""
|
|
114
|
+
<<read config from file>>
|
|
115
|
+
<<set the value at path in config>>
|
|
116
|
+
<<write config back to file>>
|
|
117
|
+
@
|
|
118
|
+
|
|
119
|
+
Let's start with reading and writing the config file.
|
|
120
|
+
<<read config from file>>=
|
|
121
|
+
conf = read_config()
|
|
122
|
+
<<write config back to file>>=
|
|
123
|
+
write_config(conf)
|
|
124
|
+
@ We'll see these functions in \cref{ConfigFile}.
|
|
125
|
+
|
|
126
|
+
Now that we have the config structure in [[conf]] we can use it.
|
|
127
|
+
Let's start with getting a value.
|
|
128
|
+
<<return the value at path in config>>=
|
|
129
|
+
return conf.get(path)
|
|
130
|
+
@
|
|
131
|
+
|
|
132
|
+
Now, to set a value, we have several cases.
|
|
133
|
+
If the user supplied more than one value, we want a list.
|
|
134
|
+
If only one value, we don't want to store a list with only one value in it.
|
|
135
|
+
Also, we want to try interpret any value as JSON.
|
|
136
|
+
If that fails, we'll use the value as-is.
|
|
137
|
+
<<set the value at path in config>>=
|
|
138
|
+
for i in range(len(values)):
|
|
139
|
+
try:
|
|
140
|
+
values[i] = json.loads(values[i])
|
|
141
|
+
except json.decoder.JSONDecodeError:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
if len(values) == 1:
|
|
145
|
+
values = values[0]
|
|
146
|
+
|
|
147
|
+
conf.set(path, values)
|
|
148
|
+
@
|
|
149
|
+
|
|
150
|
+
Now let's cover [[read_config]], [[write_config]], [[set_path]] and
|
|
151
|
+
[[get_path]] below.
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
\section{Reading and writing the config file}\label{ConfigFile}
|
|
155
|
+
|
|
156
|
+
The configuration file is stored in a suitable system location.
|
|
157
|
+
For this we use the AppDirs package, we have the [[dirs]] instance above.
|
|
158
|
+
We want to read the config and return a JSON structure as outlined above
|
|
159
|
+
(\cref{ConfigStructure}).
|
|
160
|
+
And conversely, write one to the config file as well.
|
|
161
|
+
<<helper functions>>=
|
|
162
|
+
def read_config(conf_path=f"{dirs.user_config_dir}/config.json"):
|
|
163
|
+
"""
|
|
164
|
+
Returns the config data structure (JSON).
|
|
165
|
+
`conf_path` is an optional argument providing the path to the config file.
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
with open(conf_path) as conf_file:
|
|
169
|
+
return Config(json.load(conf_file))
|
|
170
|
+
except FileNotFoundError:
|
|
171
|
+
logging.warning(f"Config file {conf_path} could not be found.")
|
|
172
|
+
except json.decoder.JSONDecodeError as err:
|
|
173
|
+
logging.warning(f"Config file {conf_path} could not be decoded: {err}")
|
|
174
|
+
|
|
175
|
+
return Config()
|
|
176
|
+
|
|
177
|
+
def write_config(conf,
|
|
178
|
+
conf_path=f"{dirs.user_config_dir}/config.json"):
|
|
179
|
+
"""
|
|
180
|
+
Stores the config data `conf` (extracts JSON) in the config file.
|
|
181
|
+
`conf_path` is an optional argument providing the path to the config file.
|
|
182
|
+
"""
|
|
183
|
+
conf_dir = os.path.dirname(conf_path)
|
|
184
|
+
if not os.path.isdir(conf_dir):
|
|
185
|
+
os.mkdir(conf_dir)
|
|
186
|
+
|
|
187
|
+
with open(conf_path, "w") as conf_file:
|
|
188
|
+
json.dump(conf.get(), conf_file)
|
|
189
|
+
@
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
\section{Navigating config structures}\label{ConfigClass}
|
|
193
|
+
|
|
194
|
+
We provide the class [[Config]] to navigate the config structure.
|
|
195
|
+
This class has two methods that are central:
|
|
196
|
+
The first gets a value out, the other sets a value in.
|
|
197
|
+
Both work with these dot-separated addresses.
|
|
198
|
+
<<classes>>=
|
|
199
|
+
class Config:
|
|
200
|
+
"""Navigates nested JSON structures by dot-separated addressing."""
|
|
201
|
+
|
|
202
|
+
def __init__(self, json_data={}):
|
|
203
|
+
"""
|
|
204
|
+
Constructs a config object to navigate from JSON data `json_data`.
|
|
205
|
+
"""
|
|
206
|
+
self.__data = json_data
|
|
207
|
+
|
|
208
|
+
def get(self, path: str = "") -> typing.Any:
|
|
209
|
+
"""
|
|
210
|
+
Returns object at `path`.
|
|
211
|
+
Example:
|
|
212
|
+
- `path = "courses.datintro22.url"` and
|
|
213
|
+
- Config contains `{"courses": {"datintro22": {"url": "https://..."}}}`
|
|
214
|
+
will return "https://...".
|
|
215
|
+
|
|
216
|
+
Any part of the path that can be converted to an integer, will be converted
|
|
217
|
+
to an integer. This way we can access elements of lists too.
|
|
218
|
+
"""
|
|
219
|
+
<<get value at path>>
|
|
220
|
+
|
|
221
|
+
def set(self, path: str, value: typing.Any):
|
|
222
|
+
"""
|
|
223
|
+
Sets `value` at `path`. Any parts along the path that don't exist will be
|
|
224
|
+
created.
|
|
225
|
+
|
|
226
|
+
Example:
|
|
227
|
+
- `value = "https://..."`,
|
|
228
|
+
- `path = "courses.datintro22.url"`
|
|
229
|
+
will create `{"courses": {"datintro22": {"url": "https://..."}}}`.
|
|
230
|
+
|
|
231
|
+
Any part of the path that can be converted to an integer, will be converted
|
|
232
|
+
to an integer. This way we can access elements of lists too. However, we
|
|
233
|
+
cannot create index 3 in a list if it doesn't exist.
|
|
234
|
+
"""
|
|
235
|
+
<<set value at path>>
|
|
236
|
+
|
|
237
|
+
def paths(self, from_root=""):
|
|
238
|
+
"""
|
|
239
|
+
Returns all existing paths.
|
|
240
|
+
|
|
241
|
+
The optional argument `from_root` is a path and the method return all
|
|
242
|
+
subpaths rooted at that point.
|
|
243
|
+
"""
|
|
244
|
+
<<return list of all paths>>
|
|
245
|
+
@
|
|
246
|
+
|
|
247
|
+
We test these methods with the examples from the docstrings.
|
|
248
|
+
<<test functions>>=
|
|
249
|
+
conf = Config({
|
|
250
|
+
"courses": {
|
|
251
|
+
"datintro22": {
|
|
252
|
+
"url": "https://...",
|
|
253
|
+
"TAs": ["Asse", "Assa"]
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
def test_get_path():
|
|
259
|
+
assert conf.get("courses.datintro22.url") == "https://..."
|
|
260
|
+
assert conf.get("courses.datintro22.TAs.0") == "Asse"
|
|
261
|
+
|
|
262
|
+
def test_set_path():
|
|
263
|
+
value = "Asselina"
|
|
264
|
+
path = "courses.datintro22.TAs.0"
|
|
265
|
+
conf.set(path, value)
|
|
266
|
+
assert conf.get(path) == value
|
|
267
|
+
|
|
268
|
+
value = ["Asse", "Assa", "Asselina"]
|
|
269
|
+
path = "courses.prgx22.TAs"
|
|
270
|
+
conf.set(path, value)
|
|
271
|
+
assert len(conf.get(path)) == len(value)
|
|
272
|
+
|
|
273
|
+
def test_paths():
|
|
274
|
+
assert "courses.datintro22.TAs" in conf.paths()
|
|
275
|
+
for path in conf.paths():
|
|
276
|
+
assert conf.get(path)
|
|
277
|
+
@
|
|
278
|
+
|
|
279
|
+
\subsection{Getting values}
|
|
280
|
+
|
|
281
|
+
To get the value, we simply walk along the path and returns what remains.
|
|
282
|
+
<<get value at path>>=
|
|
283
|
+
structure = self.__data
|
|
284
|
+
|
|
285
|
+
if not path:
|
|
286
|
+
return structure
|
|
287
|
+
|
|
288
|
+
for part in path.split("."):
|
|
289
|
+
try:
|
|
290
|
+
part = int(part)
|
|
291
|
+
except ValueError:
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
structure = structure[part]
|
|
296
|
+
except KeyError:
|
|
297
|
+
raise KeyError(f"{part} along {path} doesn't exist")
|
|
298
|
+
|
|
299
|
+
return structure
|
|
300
|
+
@
|
|
301
|
+
|
|
302
|
+
\subsection{Setting values}
|
|
303
|
+
|
|
304
|
+
To set a value is a bit more complex.
|
|
305
|
+
We want to be able to specify a path and create all parents along the path if
|
|
306
|
+
they don't exist.
|
|
307
|
+
<<set value at path>>=
|
|
308
|
+
structure = self.__data
|
|
309
|
+
|
|
310
|
+
parts = path.split(".")
|
|
311
|
+
for part in parts[:-1]:
|
|
312
|
+
try:
|
|
313
|
+
part = int(part)
|
|
314
|
+
except ValueError:
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
structure = structure[part]
|
|
319
|
+
except KeyError:
|
|
320
|
+
structure[part] = {}
|
|
321
|
+
structure = structure[part]
|
|
322
|
+
|
|
323
|
+
part = parts[-1]
|
|
324
|
+
try:
|
|
325
|
+
part = int(part)
|
|
326
|
+
except ValueError:
|
|
327
|
+
pass
|
|
328
|
+
structure[part] = value
|
|
329
|
+
@
|
|
330
|
+
|
|
331
|
+
\subsection{All existing paths}
|
|
332
|
+
|
|
333
|
+
Lastly, what we want to do is to create a list containing all paths in the
|
|
334
|
+
config.
|
|
335
|
+
We will simply traverse the config tree and add paths as we go.
|
|
336
|
+
We need to treat dictionaries and lists differently.
|
|
337
|
+
And anything else is a leaf.
|
|
338
|
+
<<return list of all paths>>=
|
|
339
|
+
paths = []
|
|
340
|
+
root = self.get(from_root)
|
|
341
|
+
|
|
342
|
+
if isinstance(root, dict):
|
|
343
|
+
for part in root:
|
|
344
|
+
if from_root:
|
|
345
|
+
path = f"{from_root}.{part}"
|
|
346
|
+
else:
|
|
347
|
+
path = part
|
|
348
|
+
|
|
349
|
+
paths.append(path)
|
|
350
|
+
paths += self.paths(from_root=path)
|
|
351
|
+
elif isinstance(root, list):
|
|
352
|
+
paths += [f"{from_root}.{x}" for x in range(len(root))]
|
|
353
|
+
|
|
354
|
+
return paths
|
|
355
|
+
@
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
\section{The [[config]] command}
|
|
359
|
+
|
|
360
|
+
We will provide the [[config]] command as outlined above.
|
|
361
|
+
If it gets a value, it will set it as the value at path.
|
|
362
|
+
Otherwise, it will print the current value at path.
|
|
363
|
+
<<config subcommands>>=
|
|
364
|
+
path_arg = typer.Argument(...,
|
|
365
|
+
help="Path in config, e.g. 'courses.datintro22'. "
|
|
366
|
+
"Empty string is root of config.",
|
|
367
|
+
autocompletion=complete_path)
|
|
368
|
+
value_arg = typer.Option([], "-s", "--set",
|
|
369
|
+
help="Values to store. "
|
|
370
|
+
"More than one value makes a list. "
|
|
371
|
+
"Values are treated as JSON if possible.")
|
|
372
|
+
|
|
373
|
+
@cli.command(name="config")
|
|
374
|
+
def config_cmd(path: str = path_arg,
|
|
375
|
+
values: typing.List[str] = value_arg):
|
|
376
|
+
"""
|
|
377
|
+
Reads values from or writes values to the config.
|
|
378
|
+
"""
|
|
379
|
+
if values:
|
|
380
|
+
set(path, values)
|
|
381
|
+
else:
|
|
382
|
+
print_config(get(path), path)
|
|
383
|
+
@
|
|
384
|
+
|
|
385
|
+
\subsection{Autocompleting the path}
|
|
386
|
+
|
|
387
|
+
The [[complete_path]] functions returns the possible completions for an
|
|
388
|
+
incomplete path from the command line.
|
|
389
|
+
<<helper functions>>=
|
|
390
|
+
def complete_path(initial_path: str, conf: Config = None):
|
|
391
|
+
"""
|
|
392
|
+
Returns all valid paths in the config starting with `initial_path`.
|
|
393
|
+
If `conf` is not None, use that instead of the actual config.
|
|
394
|
+
"""
|
|
395
|
+
if not conf:
|
|
396
|
+
conf = Config(get())
|
|
397
|
+
|
|
398
|
+
return list(filter(lambda x: x.startswith(initial_path),
|
|
399
|
+
conf.paths()))
|
|
400
|
+
@
|
|
401
|
+
|
|
402
|
+
We test this function as follows.
|
|
403
|
+
<<test functions>>=
|
|
404
|
+
def test_complete_path():
|
|
405
|
+
conf = Config({
|
|
406
|
+
"courses": {
|
|
407
|
+
"datintro22": {
|
|
408
|
+
"url": "https://...",
|
|
409
|
+
"TAs": ["Asse", "Assa"]
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
incomplete = "courses.datintro22.T"
|
|
415
|
+
assert "courses.datintro22.TAs" in complete_path(incomplete, conf)
|
|
416
|
+
assert "courses.datintro22.url" not in complete_path(incomplete, conf)
|
|
417
|
+
assert len(complete_path(incomplete, conf)) >= 0
|
|
418
|
+
@
|
|
419
|
+
|
|
420
|
+
\subsection{Printing the config}
|
|
421
|
+
|
|
422
|
+
That [[print_config]] function should print the remaining levels of the config
|
|
423
|
+
tree.
|
|
424
|
+
And we want it to print on the format of
|
|
425
|
+
[[courses.datintro22.url = https://...]].
|
|
426
|
+
This function will do a depth-first traversal through the config to print all
|
|
427
|
+
values.
|
|
428
|
+
<<helper functions>>=
|
|
429
|
+
def print_config(conf, path=""):
|
|
430
|
+
"""
|
|
431
|
+
Prints the config tree contained in `conf` to stdout.
|
|
432
|
+
Optional `path` is prepended.
|
|
433
|
+
"""
|
|
434
|
+
try:
|
|
435
|
+
for key in conf.keys():
|
|
436
|
+
if path:
|
|
437
|
+
print_config(conf[key], f"{path}.{key}")
|
|
438
|
+
else:
|
|
439
|
+
print_config(conf[key], key)
|
|
440
|
+
except AttributeError:
|
|
441
|
+
print(f"{path} = {conf}")
|
|
442
|
+
@
|