confarg 0.0.1.dev2__tar.gz → 0.0.1.dev3__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.
Files changed (25) hide show
  1. confarg-0.0.1.dev3/PKG-INFO +460 -0
  2. confarg-0.0.1.dev3/README.md +447 -0
  3. {confarg-0.0.1.dev2 → confarg-0.0.1.dev3}/pyproject.toml +6 -1
  4. {confarg-0.0.1.dev2 → confarg-0.0.1.dev3}/src/confarg/__init__.py +60 -40
  5. {confarg-0.0.1.dev2 → confarg-0.0.1.dev3}/src/confarg/_argparse.py +148 -136
  6. {confarg-0.0.1.dev2 → confarg-0.0.1.dev3}/src/confarg/_callable.py +79 -65
  7. {confarg-0.0.1.dev2 → confarg-0.0.1.dev3}/src/confarg/_completion.py +41 -35
  8. {confarg-0.0.1.dev2 → confarg-0.0.1.dev3}/src/confarg/_defaults.py +15 -15
  9. {confarg-0.0.1.dev2 → confarg-0.0.1.dev3}/src/confarg/_errors.py +9 -0
  10. {confarg-0.0.1.dev2 → confarg-0.0.1.dev3}/src/confarg/_files.py +74 -50
  11. confarg-0.0.1.dev3/src/confarg/_merge.py +347 -0
  12. confarg-0.0.1.dev3/src/confarg/_parse_cli.py +656 -0
  13. confarg-0.0.1.dev3/src/confarg/_parse_env.py +302 -0
  14. {confarg-0.0.1.dev2 → confarg-0.0.1.dev3}/src/confarg/_serialize.py +36 -20
  15. {confarg-0.0.1.dev2 → confarg-0.0.1.dev3}/src/confarg/_types.py +9 -5
  16. {confarg-0.0.1.dev2 → confarg-0.0.1.dev3}/src/confarg/dictexpr/_expressions.py +605 -566
  17. confarg-0.0.1.dev3/src/confarg/typedload/_coerce.py +204 -0
  18. {confarg-0.0.1.dev2 → confarg-0.0.1.dev3}/src/confarg/typedload/_construct.py +800 -685
  19. confarg-0.0.1.dev2/PKG-INFO +0 -9
  20. confarg-0.0.1.dev2/src/confarg/_merge.py +0 -284
  21. confarg-0.0.1.dev2/src/confarg/_parse_cli.py +0 -507
  22. confarg-0.0.1.dev2/src/confarg/_parse_env.py +0 -279
  23. confarg-0.0.1.dev2/src/confarg/typedload/_coerce.py +0 -178
  24. {confarg-0.0.1.dev2 → confarg-0.0.1.dev3}/src/confarg/dictexpr/__init__.py +0 -0
  25. {confarg-0.0.1.dev2 → confarg-0.0.1.dev3}/src/confarg/typedload/__init__.py +0 -0
@@ -0,0 +1,460 @@
1
+ Metadata-Version: 2.3
2
+ Name: confarg
3
+ Version: 0.0.1.dev3
4
+ Summary: A tool to manage complex, dynamic configurations.
5
+ Author: confarg
6
+ Author-email: confarg <280620574+confarg@users.noreply.github.com>
7
+ Requires-Dist: argcomplete>=3.0 ; extra == 'completion'
8
+ Requires-Python: >=3.12
9
+ Project-URL: Documentation, https://confarg.github.io/confarg
10
+ Project-URL: Repository, https://github.com/confarg/confarg
11
+ Provides-Extra: completion
12
+ Description-Content-Type: text/markdown
13
+
14
+ # A tool to manage complex, dynamic configurations
15
+
16
+
17
+ ## What is `confarg`?
18
+
19
+ `confarg` is a Python library that helps you load your app configuration in a modular fashion from multiple sources: one or more configuration files, environment variables, and command line arguments.
20
+
21
+ It strives to have minimal footprint on your data and app, to make it easy to switch to it, or switch from it.
22
+
23
+ It can handle deeply nested configurations, type unions, derived classes, expressions and variable interpolation, configuration compositions, and can coexist with your favorite argument parser library such as `argparse`, `click`, `typer` or `cyclopts`.
24
+
25
+ If none of this makes sense to you, read along.
26
+
27
+
28
+ ## What is not `confarg`?
29
+
30
+ `confarg` is deliberately not a framework, but just a tool.
31
+
32
+ It does not own the interface with the command line, and it won't help you build a beautiful CLI. However, it can coexist with the one you might be already using.
33
+
34
+ It doesn't require you to use custom data classes, or to use custom annotations.
35
+
36
+ The scope of `confarg` is limited to the deserialization and serialization of complex configurations. By limiting itself to those transient moments in the lifetime of your application, the footprint of `confarg` in your app is limited to a few lines of code.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install confarg
42
+ ```
43
+
44
+ `confarg` is a stand-alone library that comes with no required dependencies. Installing libraries such as `pyyaml` unlocks the support of additioanl configuration file formats.
45
+
46
+ ## Getting started
47
+
48
+ > All the examples presented in this section (and more) are available in the `examples/` folder.
49
+
50
+ Imagine that you have an app that depends on some parameters that you have collected into a `dataclass` like so:
51
+
52
+ ```python
53
+ @dataclass
54
+ class DBConfig:
55
+ host: str
56
+ port: int
57
+ name: str
58
+ ```
59
+
60
+ In your app, you use `confarg` to instantiate this configuration:
61
+
62
+ ```python
63
+ db_config = confarg.load(DBConfig)
64
+ ```
65
+
66
+ This allows you to construct a `DBConfig` object by collecting data from three possible sources.
67
+
68
+ 1. From a configuration file. By passing `--config <config_file>` to your app, `confarg` will load the content of the file and fill the `DBConfig` object. For example, a config file could look like so:
69
+
70
+ ```yaml
71
+ # config.yaml
72
+ host: example.com
73
+ port: 1234
74
+ name: mydb
75
+ ```
76
+
77
+ You would then call your application as
78
+
79
+ ```console notest
80
+ $ myapp.py --config config.yaml
81
+ DBConfig(host='example.com', port=1234, name='mydb')
82
+ ```
83
+
84
+ Configuration files in TOML and JSON formats are also supported.
85
+
86
+ > You can change the default `config` flag to something else using the `config_flag` parameter.
87
+
88
+ 2. From environment variables. You can declare
89
+
90
+ ```properties
91
+ MYAPP_HOST=example.com
92
+ MYAPP_PORT=1234
93
+ MYAPP_NAME=mydb
94
+ ```
95
+
96
+ for the same effect.
97
+
98
+ > Note that the environment variable prefix of your app should actually be passed to `confarg.load` like so:
99
+ >
100
+ > ```python
101
+ > db_config = confarg.load(DBConfig, env_prefix="MYAPP_")
102
+ > ```
103
+
104
+ 3. From command line arguments.
105
+
106
+ ```console notest
107
+ $ my_app --host example.com --port 1234 --name mydb
108
+ DBConfig(host='example.com', port=1234, name='mydb')
109
+ ```
110
+
111
+ ### Progressive build-up
112
+
113
+ The examples above presented different sources to feed your configuration. They are not mutually exclusive — in fact, they are intended to be used simultaneously.
114
+
115
+ Note that no one source needs to provide a complete configuration, as long as the configuration resulting from this progressive build-up is complete.
116
+
117
+ For example, taking our previous example, you could have a partial configuration file containing only host information,
118
+
119
+ ```yaml
120
+ # partial_config.yaml
121
+ host: example.com
122
+ port: 1234
123
+ ```
124
+
125
+ and provide the schema name from the command line:
126
+
127
+ ```console notest
128
+ $ myapp.py --config partial_config.yaml --name mydb
129
+ DBConfig(host='example.com', port=1234, name='mydb')
130
+ ```
131
+
132
+ ### Source precedence
133
+
134
+ Configuration data is read in the following order, later read overwriting existing data:
135
+
136
+ 1. configuration files are read first;
137
+ 2. then environment variables;
138
+ 3. finally, command line arguments.
139
+
140
+ This allows for surgical modifications of configuration files. For example, one could overwrite the schema configuration from our existing `full_config` from the command line like so:
141
+
142
+ ```console notest
143
+ $ # Overwrite the schema name defined in the config file from the command line
144
+ $ myapp.py --config config.yaml --name otherdb
145
+ DBConfig(host='example.com', port=1234, name='otherdb')
146
+ ```
147
+
148
+ ### Unions
149
+
150
+ Let's say your app needs to support SQLite databases. You now have two different, incompatible DB configurations:
151
+
152
+ ```python
153
+ @dataclass
154
+ class DBServerConfig:
155
+ host: str
156
+ port: int
157
+ name: str
158
+
159
+ @dataclass
160
+ class SQLiteConfig:
161
+ dbpath: str
162
+ ```
163
+
164
+ The DB configuration needs to be either one or the other, which we declare like so:
165
+
166
+ ```python
167
+ type DBConfig = SQLiteConfig | DBServerConfig
168
+ ```
169
+
170
+ `confarg` can handle this new union type and figure out which configuration is desired based on the arguments it got:
171
+
172
+ ```console notest
173
+ $ # Pass DBServerConfig parameters, and you get a DBServerConfig
174
+ $ myapp.py --host example.com --port 1234 --name mydb
175
+ DBServerConfig(host='example.com', port=1234, name='mydb')
176
+ $ # Pass SQLiteConfig parameters, and you get a SQLiteConfig
177
+ $ myapp.py --dbpath db.sqlite
178
+ SQLiteConfig(dbpath='db.sqlite')
179
+ ```
180
+
181
+ ### Disambiguation tags
182
+
183
+ For simple configurations, the above automatic disambiguation is enough and convenient.
184
+
185
+ In more complex configuration scenarios, this automatic disambiguation may not be not possible. For example, different configurations may share the exact same fields.
186
+
187
+ Even when disambiguation is possible, it may not be obvious to the human eye which object class should be return from the provided parameters.
188
+
189
+ Therefore, by necessity or for the sake of clarity, you can provide the class path of the required configuration by using the `class` tag, like so
190
+
191
+ ```console notest
192
+ $ # Explicitly ask for a SQLiteConfig
193
+ $ myapp.py --class myapp.SQLiteConfig --dbpath db.sqlite
194
+ SQLiteConfig(dbpath='db.sqlite')
195
+ ```
196
+
197
+ One example where it is necessary to provide the `class` path is to overwrite the configuration with a new class. Without it, command line arguments are added to the configuration, resulting in an invalid input.
198
+
199
+ ```console notest
200
+ $ # Config file contains a DBServerConfig
201
+ $ myapp.py --config db_server.yaml
202
+ DBServerConfig(host='example.com', port=1234, name='mydb')
203
+ $ # Fails: dbpath is not a DBServerConfig key
204
+ $ myapp.py --config db_server.yaml --dbpath db.sqlite
205
+ ...
206
+ $ # OK: using class signals overwrite existing DB config
207
+ $ myapp.py --config db_server.yaml --class myapp.SQLiteConfig --dbpath db.sqlite
208
+ SQLiteConfig(dbpath='db.sqlite')
209
+ ```
210
+
211
+ ### Inheritance
212
+
213
+ Another way to provide a flexible configuration is to derive akin configuration classes from a common base class.
214
+
215
+ ```python
216
+ @dataclass
217
+ class DBConfig:
218
+ pass
219
+
220
+ @dataclass
221
+ class DBServerConfig(DBConfig):
222
+ host: str
223
+ port: int
224
+ name: str
225
+
226
+ @dataclass
227
+ class SQLiteConfig(DBConfig):
228
+ dbpath: str
229
+ ```
230
+
231
+ This allows configurations to be easily extensible. Contrast with unions, where a class must be explicitly listed to be supported.
232
+
233
+ The downside is that the concrete class must be tagged, as `confarg` cannot discover classes derived from a given class.
234
+
235
+ ```console notest
236
+ $ # Fails: derived class not specified
237
+ $ uv run myapp.py --dbpath db.sqlite
238
+ ...
239
+ $ # OK: explicit class path provided
240
+ $ uv run myapp.py --dbpath db.sqlite --class myapp.SQLiteConfig
241
+ SQLiteConfig(dbpath='db.sqlite')
242
+ ```
243
+
244
+ ### Configuration hierarchies
245
+
246
+ The configurations discussed so far has been rather simple, composed of values grouped together in a `dataclass`. However, it needs not be. Configurations are generally deeply nested hierarchies, which `confarg` supports.
247
+
248
+ Let's say you want to add a log level to your application. You place it at the root level of a new `Config` object, along with the DB configuration, that is now one level down under the `db` key.
249
+
250
+ ```python
251
+ @dataclass
252
+ class Config:
253
+ db: DBConfig
254
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
255
+ ```
256
+
257
+ You now parse your new top-level `Config` instead of `DBConfig`.
258
+
259
+ ```python
260
+ config = confarg.load(Config)
261
+ ```
262
+
263
+ Our DB configuration, which used to be the root configuration, is now located under the `db` key. This has the following impact.
264
+
265
+ For command line arguments, we follow the common convention of using dot-separated paths to address nested fields. Previous command line arguments for `DBConfig` are now prefixed by `db.`, like so:
266
+
267
+ ```console notest
268
+ $ myapp.py --db.class myapp.SQLiteConfig --db.dbpath db.sqlite
269
+ Config(db=SQLiteConfig(dbpath='db.sqlite'), log_level='INFO')
270
+ ```
271
+
272
+ The configuration file is also modified accordingly,
273
+
274
+ ```yaml
275
+ # config.yaml
276
+ db:
277
+ class: myapp.DBServerConfig
278
+ host: example.com
279
+ name: mydb
280
+ port: 1234
281
+ ```
282
+
283
+ and is used just like before:
284
+
285
+ ```console notest
286
+ $ myapp.py --config config.yaml
287
+ Config(db=DBServerConfig(host='example.com', port=1234, name='mydb'),
288
+ log_level='DEBUG')
289
+ ```
290
+
291
+ ### Leaf data type and type coercion
292
+
293
+ You may have noticed that the previous section introduced a `log_level` parameter that has two interesting features: first, it is not of a simple type (`str`, `int`, `float`, `bool` or `None`); second, it comes with a default value.
294
+
295
+ Default values are honored, and you may have noticed that we did not provide any value to `log_level`. You can of course override a default value.
296
+
297
+ As for leaf node data type, `confarg` coerces `Enum` and `Path` types as special exceptions to simple types. Other types are treated as classes and must follow the same rules.
298
+
299
+ ### Expressions and variable interpolation
300
+
301
+ Your application is becoming more complex by the day, and is now requiring a resources configuration.
302
+
303
+ ```python
304
+ @dataclass
305
+ class Resources:
306
+ cpu_count: int
307
+ memory_gb: int
308
+ max_heap_size_mb: int
309
+ ```
310
+
311
+ It is added to the global configuration under the `resources` key:
312
+
313
+ ```python
314
+ @dataclass
315
+ class Config:
316
+ db: DBConfig
317
+ resources: Resources
318
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
319
+ ```
320
+
321
+ Your configuration file has become,
322
+
323
+ ```yaml
324
+ # config.yaml
325
+ db:
326
+ class: myapp.DBServerConfig
327
+ host: example.com
328
+ name: mydb
329
+ port: 1234
330
+
331
+ resources:
332
+ cpu_count: 4
333
+ memory_gb: 16
334
+ max_heap_size_mb: 131072
335
+ ```
336
+
337
+ This works fine. However, you want to better express the fact that `max_heap_size_mb` is chosen to be 80% of the host memory by default. To achieve this, you can write expressions relying on variable interpolation using the `${...}` syntax, like so:
338
+
339
+ ```yaml
340
+ # expression_config.yaml
341
+ db:
342
+ class: myapp.DBServerConfig
343
+ host: example.com
344
+ name: mydb
345
+ port: 1234
346
+
347
+ resources:
348
+ cpu_count: 4
349
+ memory_gb: 16
350
+ max_heap_size_mb: ${int(resources.memory_gb * 1024 * 0.8)}
351
+ ```
352
+
353
+ ```console notest
354
+ $ myapp.py --config expression_config.yaml
355
+ Config(db=SQLiteConfig(dbpath='db.sqlite'),
356
+ resources=Resources(cpu_count=4, memory_gb=16, max_heap_size_mb=13107),
357
+ log_level='INFO')
358
+ ```
359
+
360
+ Note that variable interpolation occurs after all configuration data is read. This means here that you can override `memory_gb` from the command line, and `max_heap_size_mb` will be adjusted accordingly, even though the expression is defined in the configuration file.
361
+
362
+ ```console notest
363
+ $ # Max heap is recomputed according to the expression in the config file
364
+ $ myapp.py --config expression_config.yaml --resources.memory_gb 8
365
+ Config(db=SQLiteConfig(dbpath='db.sqlite'),
366
+ resources=Resources(cpu_count=4, memory_gb=8, max_heap_size_mb=6553),
367
+ log_level='INFO')
368
+ ```
369
+
370
+ ### Building large configurations from parts
371
+
372
+ Large configurations are often made up of independent components, and as such, you may want to split them accordingly. It is easier to navigate, but it also makes it possible to reuse configuration parts and to build multiple complex configurations from the same set of atomic configuration components.
373
+
374
+ Some configuration components may even be generated automatically, in which case being able to isolate those parts from the rest is a must.
375
+
376
+ `confarg` lets you do this in different ways.
377
+
378
+ From the command line, the `--config` flag can be suffixed with a key path to load configurations there. For example,
379
+
380
+ ```console notest
381
+ # Load a config file specific to the `db` key
382
+ $ myapp.py --config.db db_config.yaml
383
+ Config(db=DBServerConfig(host='example.com', port=1234, name='mydb'), log_level='INFO')
384
+ ```
385
+
386
+ A similar pattern applies to environment variables:
387
+
388
+ ```console notest
389
+ $ MYAPP_CONFIG_DB=db_config.py myapp.py
390
+ Config(db=DBServerConfig(host='example.com', port=1234, name='mydb'), log_level='INFO')
391
+ ```
392
+
393
+ > Note that `db_config.yaml` does *not* contain the `db` key. It does not need to know the path it is loaded to.
394
+
395
+ In config files, you can load a configuration by specifying the special `__include__` key, followed by the path to the sub-configuration to load, like so:
396
+
397
+ ```yaml
398
+ # set everything under the `db` key from another file
399
+ db:
400
+ __include__: ./db_config.yaml
401
+ ```
402
+
403
+ The `__include__` keyword can also be used at the top-level, to create a new config that amends an existing config.
404
+
405
+ ```yaml
406
+ # start from this base configuration
407
+ __include__: base_config.yaml
408
+
409
+ # set or overwrite everything under the `db` key
410
+ db:
411
+ __include__: ./db_config.yaml
412
+ ```
413
+
414
+ ## `confarg` and command-line interfaces
415
+
416
+ Command line arguments are an essential part of `confarg`. We have seen how they are parsed and consumed implicitly by `confarg.load`.
417
+
418
+ Although it is not needed for `confarg` to work, application generally provide a command line interface to offer some help and parse parameters.
419
+
420
+ ### What to expect from a CLI regarding complex configurations
421
+
422
+ We are used to the great user experience provided by CLI libraries such as `click`, `typer` or `cyclopts`. However, porting this great UX to complex configurations is no small feat because of their size and dynamic nature. Inline help is bound to be both very long, reflecting the configuration's complexity, and incomplete, as options coming from derived classes are not available. This could be frustrating.
423
+
424
+ At the same time, the command line is not the main configuration interface: configuration files are. Building a great CLI UX for complex configuration has a somewhat poor benefit/effort ratio.
425
+
426
+ ### Using a CLI library
427
+
428
+ The python ecosystem offers many libraries to build powerful and beautiful CLI apps, such as `click`, `typer` or `cyclopts`. Those libraries parse and consume command line arguments, but they also offer a rich user experience by providing help on available commands, sometimes even auto-completion. Some like `cyclopts` also parse concrete nested dataclasses using the dot-separated field command line argument convention used by `confarg` and similar libraries.
429
+
430
+ Should you use such a library, `confarg` can coexist with them by parsing unused arguments. Currently however, `confarg` will essentially work in "suppress" (`argparse` terminology) or "hidden" (`click` terminology) mode: the arguments won't show in the help generated by those libraries.
431
+
432
+ ### Building your interface with `argparse`
433
+
434
+ If you manage your interface yourself with `argparse`, `confarg` can step in and provide (limited) help for command line arguments. This is currently an experimental feature.
435
+
436
+ Not registering `confarg` with your `ArgumentParser` and running in hidden mode is of course an option.
437
+
438
+ ### Optional command line argument prefix
439
+
440
+ When mixing `confarg` arguments with other application arguments, you may worry about name conflicts, or you may want to clearly identify which arguments belong to the configuration handled by `confarg`, especially if `confarg` is running in hidden arguments mode.
441
+
442
+ For this purpose, you can specify a prefix for `confarg` command line arguments, using the `cli_prefix` parameter:
443
+
444
+ ```python
445
+ config = confarg.load(Config, args=rgs, cli_prefix="settings")
446
+ ```
447
+
448
+ The command line now cleanly conveys which arguments are routed to the configuration.
449
+
450
+ ```python
451
+ myapp.py --app_arg=hello --settings.config=config.yaml --settings.resources.cpu_count=2
452
+ ```
453
+
454
+ ## Next steps
455
+
456
+ We have more than scratched the surface, and you should have enough knowledge to cover most of your needs.
457
+
458
+ Again, all of the examples above and more are in the `examples/` folder, which is a great way to discover and experiment with the library features.
459
+
460
+ A documentation is also currently being written at https://confarg.github.io/confarg/.