clevis 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.
- clevis/__init__.py +359 -0
- clevis/py.typed +0 -0
- clevis-0.1.0.dist-info/METADATA +263 -0
- clevis-0.1.0.dist-info/RECORD +6 -0
- clevis-0.1.0.dist-info/WHEEL +4 -0
- clevis-0.1.0.dist-info/licenses/LICENSE +21 -0
clevis/__init__.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Clevis - Configuration management for Python projects.
|
|
3
|
+
|
|
4
|
+
Provides dataclass-based configuration with TOML file support,
|
|
5
|
+
environment variable interpolation, and CLI argument generation.
|
|
6
|
+
|
|
7
|
+
TOML Parser Selection (priority order):
|
|
8
|
+
1. envtoml - Env var interpolation (${VAR}) - install: pip install clevis[envtoml]
|
|
9
|
+
2. tomlev - Tomlev parser - install: pip install clevis[tomlev]
|
|
10
|
+
3. tomli - Pure Python TOML - install: pip install clevis[tomli]
|
|
11
|
+
4. tomllib - Stdlib (Python 3.11+) - no extras needed
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import functools
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import Field, fields, is_dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, get_args
|
|
20
|
+
|
|
21
|
+
from dacite import from_dict
|
|
22
|
+
from dacite.exceptions import DaciteError, MissingValueError, WrongTypeError
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# TOML Parser Selection
|
|
28
|
+
# ---------------------
|
|
29
|
+
# Tries parsers in this order: envtoml > tomlev > tomli > tomllib
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_toml_parser() -> Callable[[Any], dict[str, Any]]:
|
|
33
|
+
"""
|
|
34
|
+
Get the appropriate TOML parser based on installed packages.
|
|
35
|
+
|
|
36
|
+
Priority: envtoml > tomlev > tomli > tomllib (stdlib)
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A function that loads TOML from a file object
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ImportError: If no TOML parser is available
|
|
43
|
+
"""
|
|
44
|
+
# envtoml: supports ${VAR} interpolation
|
|
45
|
+
try:
|
|
46
|
+
import envtoml
|
|
47
|
+
|
|
48
|
+
return envtoml.load
|
|
49
|
+
except ImportError:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
# tomlev: supports ${VAR|default} interpolation
|
|
53
|
+
try:
|
|
54
|
+
import tomllib # type: ignore[import-not-found]
|
|
55
|
+
from tomlev.env_loader import expandvars # type: ignore[attr-defined]
|
|
56
|
+
|
|
57
|
+
def load_with_tomlev(file: Any) -> dict[str, Any]:
|
|
58
|
+
content = file.read()
|
|
59
|
+
if isinstance(content, bytes):
|
|
60
|
+
content = content.decode("utf-8")
|
|
61
|
+
expanded = expandvars(content)
|
|
62
|
+
return tomllib.loads(expanded) # type: ignore[no-any-return]
|
|
63
|
+
|
|
64
|
+
return load_with_tomlev
|
|
65
|
+
except ImportError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
# tomli: pure Python TOML (Python 3.10)
|
|
69
|
+
try:
|
|
70
|
+
import tomli
|
|
71
|
+
|
|
72
|
+
return tomli.load
|
|
73
|
+
except ImportError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
# tomllib: stdlib (Python 3.11+)
|
|
77
|
+
try:
|
|
78
|
+
import tomllib
|
|
79
|
+
|
|
80
|
+
return tomllib.load # type: ignore[no-any-return]
|
|
81
|
+
except ImportError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
raise ImportError(
|
|
85
|
+
"No TOML parser available.\n\n"
|
|
86
|
+
"Install one of:\n"
|
|
87
|
+
" pip install clevis[tomli] # Python 3.10\n"
|
|
88
|
+
" pip install clevis[envtoml] # Env var interpolation\n"
|
|
89
|
+
" pip install clevis[tomlev] # Env var with defaults\n\n"
|
|
90
|
+
"Note: Python 3.11+ has built-in tomllib (no extras needed)"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Module-level parser (loaded once)
|
|
95
|
+
_toml_load: Callable[[Any], dict[str, Any]] | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _load_toml(file: Any) -> dict[str, Any]:
|
|
99
|
+
"""
|
|
100
|
+
Load TOML from a file object using the selected parser.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
file: File object opened in binary mode
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Dictionary of parsed TOML data
|
|
107
|
+
"""
|
|
108
|
+
global _toml_load
|
|
109
|
+
if _toml_load is None:
|
|
110
|
+
_toml_load = _get_toml_parser()
|
|
111
|
+
return _toml_load(file)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ConfigError(Exception):
|
|
115
|
+
"""Raised when configuration is missing or invalid."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, message: str, field_path: str, config_name: str):
|
|
118
|
+
self.message = message
|
|
119
|
+
self.field_path = field_path
|
|
120
|
+
self.config_name = config_name
|
|
121
|
+
super().__init__(self._format_message())
|
|
122
|
+
|
|
123
|
+
def _format_message(self) -> str:
|
|
124
|
+
"""Format a helpful error message with actionable suggestions."""
|
|
125
|
+
lines = [f"\n{'=' * 70}"]
|
|
126
|
+
lines.append("Configuration Error")
|
|
127
|
+
lines.append(f"{'=' * 70}\n")
|
|
128
|
+
|
|
129
|
+
lines.append(f"Field: {self.field_path}")
|
|
130
|
+
lines.append(f"Issue: {self.message}\n")
|
|
131
|
+
|
|
132
|
+
lines.append("Provide this value in one of these ways:\n")
|
|
133
|
+
|
|
134
|
+
# Project config
|
|
135
|
+
lines.append(f" 1. Project config: ./{self.config_name}.toml")
|
|
136
|
+
parts = self.field_path.split(".")
|
|
137
|
+
if len(parts) == 1:
|
|
138
|
+
lines.append(f' {parts[0]} = "your_value"')
|
|
139
|
+
else:
|
|
140
|
+
lines.append(f" [{parts[0]}]")
|
|
141
|
+
lines.append(f' {".".join(parts[1:])} = "your_value"')
|
|
142
|
+
lines.append("")
|
|
143
|
+
|
|
144
|
+
# User config
|
|
145
|
+
lines.append(f" 2. User config: ~/.{self.config_name}.toml")
|
|
146
|
+
lines.append(" (same format as above)\n")
|
|
147
|
+
|
|
148
|
+
# CLI argument (use dashes for dots and underscores)
|
|
149
|
+
cli_arg = "--" + self.field_path.replace(".", "-").replace("_", "-")
|
|
150
|
+
lines.append(f" 3. CLI argument: {cli_arg} <value>\n")
|
|
151
|
+
|
|
152
|
+
lines.append(f"{'=' * 70}")
|
|
153
|
+
return "\n".join(lines)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def unpack_type(type_def: type) -> type:
|
|
157
|
+
"""
|
|
158
|
+
Given a type, if a union type, return the not-None type (dataclass).
|
|
159
|
+
|
|
160
|
+
For Optional[T] or T | None, returns T.
|
|
161
|
+
For non-union types, returns the type as-is.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
type_def: The type to unpack
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The non-None type from a union, or the type itself
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ValueError: If union has more than 2 types (not supported yet)
|
|
171
|
+
"""
|
|
172
|
+
types = get_args(type_def)
|
|
173
|
+
# not a union type
|
|
174
|
+
if len(types) == 0:
|
|
175
|
+
return type_def
|
|
176
|
+
# <type> | None is only supported combination
|
|
177
|
+
if len(types) > 2:
|
|
178
|
+
raise ValueError("Complex unions not supported")
|
|
179
|
+
return types[0] if types[1] is type(None) else types[1] # type: ignore[no-any-return]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def list_fields(clz: type, path: list[str] | None = None) -> list[tuple[Field[Any], list[str]]]:
|
|
183
|
+
"""
|
|
184
|
+
Recursively flatten and list all properties in nested dataclasses.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
clz: The dataclass to inspect
|
|
188
|
+
path: Current path in the hierarchy
|
|
189
|
+
|
|
190
|
+
Yields:
|
|
191
|
+
Tuples of (field, path) for each leaf field
|
|
192
|
+
"""
|
|
193
|
+
path = [] if not path else path
|
|
194
|
+
result = []
|
|
195
|
+
for f in fields(clz):
|
|
196
|
+
concrete_type = unpack_type(f.type) # type: ignore[arg-type]
|
|
197
|
+
if is_dataclass(concrete_type):
|
|
198
|
+
result.extend(list_fields(concrete_type, path=path + [f.name]))
|
|
199
|
+
else:
|
|
200
|
+
result.append((f, path))
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_args_config(clz: type, args: list[str] | None = None) -> dict[str, Any]:
|
|
205
|
+
"""
|
|
206
|
+
Construct an argparse parser from a dataclass hierarchy.
|
|
207
|
+
|
|
208
|
+
Creates CLI arguments for each leaf field in the dataclass,
|
|
209
|
+
using dashed notation for nested fields (e.g., --database-host).
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
clz: The dataclass type to generate parser for
|
|
213
|
+
args: Optional list of CLI arguments (defaults to sys.argv[1:])
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Dictionary of parsed arguments with dotted keys
|
|
217
|
+
Unprovided arguments have None values
|
|
218
|
+
"""
|
|
219
|
+
parser = argparse.ArgumentParser()
|
|
220
|
+
for f, path in list_fields(clz):
|
|
221
|
+
name = ".".join(path + [f.name]) # concatenate intermediate classes with "."
|
|
222
|
+
# Convert both dots and underscores to dashes for CLI args
|
|
223
|
+
cli_name = name.replace(".", "-").replace("_", "-")
|
|
224
|
+
arg = functools.partial(
|
|
225
|
+
parser.add_argument,
|
|
226
|
+
f"--{cli_name}",
|
|
227
|
+
dest=name, # name with dots
|
|
228
|
+
default=None, # Use None so TOML values aren't overridden
|
|
229
|
+
help=f"provide {name}",
|
|
230
|
+
)
|
|
231
|
+
# complete partial: boolean switch of store value
|
|
232
|
+
concrete_type = unpack_type(f.type) # type: ignore[arg-type]
|
|
233
|
+
if concrete_type is bool:
|
|
234
|
+
_ = arg(action="store_true")
|
|
235
|
+
else:
|
|
236
|
+
_ = arg(type=concrete_type)
|
|
237
|
+
|
|
238
|
+
return vars(parser.parse_args(args))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def apply_to_dict(args: dict[str, Any], dct: dict[str, Any]) -> None:
|
|
242
|
+
"""
|
|
243
|
+
Apply dotted command line arguments to a nested dictionary.
|
|
244
|
+
|
|
245
|
+
Modifies the dictionary in-place, creating nested structure as needed.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
args: Dictionary with dotted keys (e.g., "database.host")
|
|
249
|
+
dct: Target dictionary to modify
|
|
250
|
+
"""
|
|
251
|
+
for key, value in args.items():
|
|
252
|
+
if value is not None: # default optional value, can't be set through command line
|
|
253
|
+
parts = key.split(".")
|
|
254
|
+
final_key = parts.pop()
|
|
255
|
+
# follow path into hierarchy
|
|
256
|
+
scope = dct
|
|
257
|
+
for step in parts:
|
|
258
|
+
try:
|
|
259
|
+
scope = scope[step] # follow
|
|
260
|
+
except KeyError:
|
|
261
|
+
scope[step] = {} # create missing
|
|
262
|
+
scope = scope[step]
|
|
263
|
+
# set value
|
|
264
|
+
scope[final_key] = value # upsert key=value
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def get_config(
|
|
268
|
+
data_class: type,
|
|
269
|
+
name: str = "project",
|
|
270
|
+
user: bool = True,
|
|
271
|
+
project: bool = True,
|
|
272
|
+
args: list[str] | None = None,
|
|
273
|
+
) -> Any:
|
|
274
|
+
"""
|
|
275
|
+
Load configuration from TOML files and CLI arguments.
|
|
276
|
+
|
|
277
|
+
Merges configuration from (in order of precedence):
|
|
278
|
+
1. CLI arguments (highest priority)
|
|
279
|
+
2. Project-level TOML: ./{name}.toml
|
|
280
|
+
3. User-level TOML: ~/.{name}.toml
|
|
281
|
+
4. Dataclass defaults (lowest priority)
|
|
282
|
+
|
|
283
|
+
TOML Parser Selection:
|
|
284
|
+
Automatically selects parser based on installed extras:
|
|
285
|
+
- envtoml: Supports ${VAR} interpolation - pip install clevis[envtoml]
|
|
286
|
+
- tomlev: Alternative parser - pip install clevis[tomlev]
|
|
287
|
+
- tomli: Pure Python - pip install clevis[tomli]
|
|
288
|
+
- tomllib: Python 3.11+ stdlib (no extras needed)
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
data_class: The dataclass type to populate
|
|
292
|
+
name: Configuration file name (without .toml extension)
|
|
293
|
+
user: Whether to load user-level config(~/.{name}.toml)
|
|
294
|
+
project: Whether to load project-level config (./{name}.toml)
|
|
295
|
+
args: Optional list of CLI arguments (defaults to sys.argv[1:])
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
An instance of the dataclass with merged configuration
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
ConfigError: If required fields are missing or values have wrong type
|
|
302
|
+
ImportError: If no TOML parser is available
|
|
303
|
+
"""
|
|
304
|
+
cfg: dict[str, Any] = {}
|
|
305
|
+
|
|
306
|
+
# Load user-level config
|
|
307
|
+
if user:
|
|
308
|
+
user_path = Path.home() / f".{name}.toml"
|
|
309
|
+
if user_path.exists():
|
|
310
|
+
cfg.update(_load_toml(user_path.open("rb")))
|
|
311
|
+
|
|
312
|
+
# Load project-level config
|
|
313
|
+
if project:
|
|
314
|
+
project_path = Path.cwd() / f"{name}.toml"
|
|
315
|
+
if project_path.exists():
|
|
316
|
+
cfg.update(_load_toml(project_path.open("rb")))
|
|
317
|
+
|
|
318
|
+
# Get CLI args based on dataclass hierarchy
|
|
319
|
+
cli_args = get_args_config(data_class, args)
|
|
320
|
+
|
|
321
|
+
# Merge CLI args into config
|
|
322
|
+
apply_to_dict(cli_args, cfg)
|
|
323
|
+
|
|
324
|
+
# Convert dict to dataclass
|
|
325
|
+
try:
|
|
326
|
+
return from_dict(data_class=data_class, data=cfg)
|
|
327
|
+
except MissingValueError as e:
|
|
328
|
+
# Extract field path from dacite error message
|
|
329
|
+
# Format: 'missing value for field "database.host"'
|
|
330
|
+
error_msg = str(e)
|
|
331
|
+
if '"' in error_msg:
|
|
332
|
+
field_path = error_msg.split('"')[1]
|
|
333
|
+
else:
|
|
334
|
+
field_path = error_msg
|
|
335
|
+
raise ConfigError(
|
|
336
|
+
message="Required field has no value",
|
|
337
|
+
field_path=field_path,
|
|
338
|
+
config_name=name,
|
|
339
|
+
) from None
|
|
340
|
+
except WrongTypeError as e:
|
|
341
|
+
# Extract field path and type info from dacite error
|
|
342
|
+
error_msg = str(e)
|
|
343
|
+
if '"' in error_msg:
|
|
344
|
+
field_path = error_msg.split('"')[1]
|
|
345
|
+
else:
|
|
346
|
+
field_path = error_msg
|
|
347
|
+
raise ConfigError(
|
|
348
|
+
message="Wrong type for field",
|
|
349
|
+
field_path=field_path,
|
|
350
|
+
config_name=name,
|
|
351
|
+
) from None
|
|
352
|
+
except DaciteError as e:
|
|
353
|
+
# Catch any other dacite errors
|
|
354
|
+
raise ConfigError(
|
|
355
|
+
message=str(e),
|
|
356
|
+
field_path="unknown",
|
|
357
|
+
config_name=name,
|
|
358
|
+
) from None
|
|
359
|
+
|
clevis/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clevis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Configuration management for Python projects with dataclass-based schemas
|
|
5
|
+
Project-URL: Homepage, https://github.com/christophevg/clevis
|
|
6
|
+
Project-URL: Documentation, https://clevis.readthedocs.io
|
|
7
|
+
Project-URL: Repository, https://github.com/christophevg/clevis
|
|
8
|
+
Author-email: Christophe Van Ginneken <christophe@christophe.vg>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: argparse,cli,configuration,dataclass,toml
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: dacite
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
27
|
+
Requires-Dist: tox; extra == 'dev'
|
|
28
|
+
Requires-Dist: twine; extra == 'dev'
|
|
29
|
+
Provides-Extra: docs
|
|
30
|
+
Requires-Dist: myst-parser>=2.0.0; extra == 'docs'
|
|
31
|
+
Requires-Dist: sphinx-rtd-theme>=2.0.0; extra == 'docs'
|
|
32
|
+
Requires-Dist: sphinx>=7.0.0; extra == 'docs'
|
|
33
|
+
Provides-Extra: envtoml
|
|
34
|
+
Requires-Dist: envtoml; extra == 'envtoml'
|
|
35
|
+
Provides-Extra: tomlev
|
|
36
|
+
Requires-Dist: tomlev; extra == 'tomlev'
|
|
37
|
+
Provides-Extra: tomli
|
|
38
|
+
Requires-Dist: tomli; extra == 'tomli'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# Clevis
|
|
42
|
+
|
|
43
|
+
[![PyPI][pypi-badge]][pypi]
|
|
44
|
+
[![Python][python-badge]][python]
|
|
45
|
+
[![CI][ci-badge]][ci]
|
|
46
|
+
[![Coverage][coverage-badge]][coverage]
|
|
47
|
+
[![License][license-badge]][license]
|
|
48
|
+
[![Agentic][agentic-badge]][agentic]
|
|
49
|
+
|
|
50
|
+
> Configuration management for Python projects with dataclass-based schemas
|
|
51
|
+
|
|
52
|
+
Clevis provides type-safe configuration management for Python applications:
|
|
53
|
+
|
|
54
|
+
- **Dataclass schemas** — Define config structure with Python dataclasses
|
|
55
|
+
- **TOML support** — Load from `.toml` files
|
|
56
|
+
- **Env vars** — `${VAR}` interpolation (with envtoml/tomlev)
|
|
57
|
+
- **CLI generation** — Auto-generate argparse from dataclass
|
|
58
|
+
- **Layered config** — User config < project config < CLI args
|
|
59
|
+
|
|
60
|
+
## About the Name
|
|
61
|
+
|
|
62
|
+
A **clevis** is a U-shaped mechanical fastener that connects components while allowing pivoting. It's used in everything from agricultural equipment to aerospace control systems — a simple, robust connector that provides flexibility without compromising strength.
|
|
63
|
+
|
|
64
|
+
This library follows the same principle: it **connects** multiple configuration sources (TOML files, environment variables, CLI arguments) into a single, cohesive interface. Just as a mechanical clevis allows articulation, Clevis allows your configuration to flex and adapt — user-level defaults, project-level settings, and runtime overrides all pivot around a single dataclass schema.
|
|
65
|
+
|
|
66
|
+
## Quick Start
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Install (Python 3.11+)
|
|
70
|
+
pip install clevis
|
|
71
|
+
|
|
72
|
+
# Or with env var support
|
|
73
|
+
pip install clevis[envtoml]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from dataclasses import dataclass
|
|
78
|
+
from clevis import get_config
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Config:
|
|
82
|
+
name: str = "MyApp"
|
|
83
|
+
debug: bool = False
|
|
84
|
+
|
|
85
|
+
config = get_config(Config, name="app")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Installation
|
|
89
|
+
|
|
90
|
+
Choose your TOML parser based on needs:
|
|
91
|
+
|
|
92
|
+
| Extra | Features | Use When |
|
|
93
|
+
|-------|----------|----------|
|
|
94
|
+
| *(none)* | Stdlib `tomllib` | Python 3.11+, minimal deps |
|
|
95
|
+
| [`tomli`][tomli] | Pure Python TOML | Python 3.10 compatibility |
|
|
96
|
+
| [`envtoml`][envtoml] | `${VAR}` interpolation | Environment-based config |
|
|
97
|
+
| [`tomlev`][tomlev] | `${VAR\|default}` syntax | Env vars with defaults |
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# Python 3.11+ - no extras needed
|
|
101
|
+
pip install clevis
|
|
102
|
+
|
|
103
|
+
# Python 3.10
|
|
104
|
+
pip install clevis[tomli]
|
|
105
|
+
|
|
106
|
+
# Environment variable support
|
|
107
|
+
pip install clevis[envtoml]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Usage
|
|
111
|
+
|
|
112
|
+
### Define Your Config
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from dataclasses import dataclass, field
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class DatabaseConfig:
|
|
119
|
+
host: str = "localhost"
|
|
120
|
+
port: int = 5432
|
|
121
|
+
user: str | None = None
|
|
122
|
+
password: str | None = None
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class AppConfig:
|
|
126
|
+
name: str = "MyApp"
|
|
127
|
+
debug: bool = False
|
|
128
|
+
database: DatabaseConfig = field(default_factory=DatabaseConfig)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Load Configuration
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from clevis import get_config
|
|
135
|
+
|
|
136
|
+
# Load from ~/.myapp.toml and ./myapp.toml
|
|
137
|
+
config = get_config(AppConfig, name="myapp")
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Configuration layers (lowest to highest priority):
|
|
141
|
+
|
|
142
|
+
1. **Dataclass defaults**
|
|
143
|
+
2. **User-level TOML** — `~/.{name}.toml`
|
|
144
|
+
3. **Project-level TOML** — `./{name}.toml`
|
|
145
|
+
4. **CLI arguments** — `--database-host`, `--debug`
|
|
146
|
+
|
|
147
|
+
### TOML Files
|
|
148
|
+
|
|
149
|
+
Create `myapp.toml`:
|
|
150
|
+
|
|
151
|
+
```toml
|
|
152
|
+
name = "Production App"
|
|
153
|
+
debug = true
|
|
154
|
+
|
|
155
|
+
[database]
|
|
156
|
+
host = "db.example.com"
|
|
157
|
+
port = 5432
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
With env var support (`clevis[envtoml]` or `clevis[tomlev]`):
|
|
161
|
+
|
|
162
|
+
```toml
|
|
163
|
+
[database]
|
|
164
|
+
password = "${DB_PASSWORD}" # envtoml
|
|
165
|
+
host = "${DB_HOST|localhost}" # tomlev (with default)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### CLI Arguments
|
|
169
|
+
|
|
170
|
+
Clevis auto-generates CLI arguments:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
python app.py --database-host localhost
|
|
174
|
+
python app.py --database-port 5432
|
|
175
|
+
python app.py --debug
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Nested dataclasses become dashed arguments: `database.host` → `--database-host`
|
|
179
|
+
|
|
180
|
+
## Testing
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
# Run tests
|
|
184
|
+
make test
|
|
185
|
+
|
|
186
|
+
# Run with coverage
|
|
187
|
+
make test-cov
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## API Reference
|
|
191
|
+
|
|
192
|
+
### `get_config(data_class, name="project", user=True, project=True, args=None)`
|
|
193
|
+
|
|
194
|
+
Load configuration from TOML files and CLI arguments.
|
|
195
|
+
|
|
196
|
+
**Parameters:**
|
|
197
|
+
- `data_class` — The dataclass type to populate
|
|
198
|
+
- `name` — Config file name (without `.toml`)
|
|
199
|
+
- `user` — Load user-level config (`~/.{name}.toml`)
|
|
200
|
+
- `project` — Load project-level config (`./{name}.toml`)
|
|
201
|
+
- `args` — CLI arguments (defaults to `sys.argv[1:]`)
|
|
202
|
+
|
|
203
|
+
**Returns:** Instance of the dataclass with merged configuration
|
|
204
|
+
|
|
205
|
+
**Raises:**
|
|
206
|
+
- `ConfigError` — Missing required fields or wrong types
|
|
207
|
+
- `ImportError` — No TOML parser available
|
|
208
|
+
|
|
209
|
+
## Error Messages
|
|
210
|
+
|
|
211
|
+
Clevis provides helpful, actionable errors:
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
======================================================================
|
|
215
|
+
Configuration Error
|
|
216
|
+
======================================================================
|
|
217
|
+
|
|
218
|
+
Field: database.host
|
|
219
|
+
Issue: Required field has no value
|
|
220
|
+
|
|
221
|
+
Provide this value in one of these ways:
|
|
222
|
+
|
|
223
|
+
1. Project config: ./project.toml
|
|
224
|
+
[database]
|
|
225
|
+
host = "your_value"
|
|
226
|
+
|
|
227
|
+
2. User config: ~/.project.toml
|
|
228
|
+
(same format as above)
|
|
229
|
+
|
|
230
|
+
3. CLI argument: --database-host <value>
|
|
231
|
+
|
|
232
|
+
======================================================================
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Acknowledgments
|
|
236
|
+
|
|
237
|
+
Clevis builds on excellent work from the Python community:
|
|
238
|
+
|
|
239
|
+
- **[tomllib](https://docs.python.org/3/library/tomllib.html)** — Python 3.11+ stdlib
|
|
240
|
+
- **[tomli](https://github.com/hukkin/tomli)** — Pure Python TOML 1.0
|
|
241
|
+
- **[envtoml](https://github.com/sank8m/envtoml)** — Env var interpolation
|
|
242
|
+
- **[tomlev](https://github.com/thesimj/tomlev)** — Env vars with defaults
|
|
243
|
+
- **[dacite](https://github.com/konradhalas/dacite)** — Dict-to-dataclass conversion
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
MIT
|
|
248
|
+
|
|
249
|
+
[pypi]: https://pypi.org/project/clevis/
|
|
250
|
+
[pypi-badge]: https://img.shields.io/pypi/v/clevis.svg
|
|
251
|
+
[python]: https://www.python.org/
|
|
252
|
+
[python-badge]: https://img.shields.io/badge/Python-3.10+-blue.svg
|
|
253
|
+
[ci]: https://github.com/christophevg/clevis/actions/workflows/test.yml
|
|
254
|
+
[ci-badge]: https://img.shields.io/github/actions/workflow/status/christophevg/clevis/test.yml.svg
|
|
255
|
+
[coverage]: https://coveralls.io/github/christophevg/clevis
|
|
256
|
+
[coverage-badge]: https://img.shields.io/coveralls/github/christophevg/clevis.svg
|
|
257
|
+
[license]: LICENSE
|
|
258
|
+
[license-badge]: https://img.shields.io/github/license/christophevg/clevis.svg
|
|
259
|
+
[agentic]: https://christophe.vg/about/Agentic-Workflow
|
|
260
|
+
[agentic-badge]: https://img.shields.io/badge/workflow-agentic-blueviolet?style=flat-square
|
|
261
|
+
[tomli]: https://github.com/hukkin/tomli
|
|
262
|
+
[envtoml]: https://github.com/sank8m/envtoml
|
|
263
|
+
[tomlev]: https://github.com/thesimj/tomlev
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
clevis/__init__.py,sha256=_XKFN1QDr6ldIBEtR4cF5M4Ysgj4V3Wcn_Pr641vSp4,10611
|
|
2
|
+
clevis/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
clevis-0.1.0.dist-info/METADATA,sha256=6Y0d1Dlvj9PueqU6WtO-XGbIQScMeu-irsAlkLCEzUQ,7811
|
|
4
|
+
clevis-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
5
|
+
clevis-0.1.0.dist-info/licenses/LICENSE,sha256=HZGWgZq6Gti1o1soHE3jmA5UNMVv3gRkPSmOuJz5fdY,1080
|
|
6
|
+
clevis-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christophe Van Ginneken
|
|
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.
|