app-conf
: Python Application Configuration
[TOC]
@app
: Class to App.
Write a module with a class, e.g. like this:
import operator as OP
class Calc:
"""Calculator Demo"""
operator_function = 'add'
def do_run(calc, a, b):
"""Runs operator function on the arguments"""
a, b, = int(a), int(b)
return getattr(OP, calc.operator_function, calc.not_found)(a, b)
def not_found(calc, *a, **kw):
raise Exception('not supported:', calc.operator_function)
Lets make an application from that:
#!/usr/bin/env python
import operator as OP
from app_conf.app import app # one way of usage is via this decorator
@app # apply the decorator to your app holding class
class Calc:
"""Calculator Demo"""
operator_function = 'add'
# Simple PY2 compat type hints for less imperative code:
def do_run(calc, a=int, b=int):
"""Runs operator function on the arguments"""
return getattr(OP, calc.operator_function, calc.not_found)(a, b)
def not_found(calc, *a, **kw):
raise Exception('not supported:', calc.operator_function)
We
- added a hashbang and made the file executable.
- (optionally) provided type information.
- decorated the toplevel class.
Yes, that's all.
We can run the thing now from the CLI:
$ ./calc1.py a=2 b=4
6
$ ./calc1.py b=2 a=4 # mappable so supported
6
$ ./calc1.py 2 4.1 # positionally given, rounded
6
$ ./calc1.py a=2 4.1 # mapping found
6
$ ./calc1.py operator_function=mul a=2 b=4
8
$ ./calc1.py of=mul a=2 b=4 # short form for operator_function
8
More Actions
A class may have more action functions (by default prefixed with do_
.
do_run
was just the default action - run if no other is configured.
Lets add another one:
#!/usr/bin/env python
import operator as OP
from app_conf.app import app # one way of usage is via this decorator
@app # apply the decorator to your app holding class
class Calc:
"""Calculator Demo"""
operator_function = 'add'
op = lambda calc: getattr(OP, calc.operator_function, calc.not_found)
def do_run(calc, a=int, b=int):
"""Runs operator function on the arguments"""
return calc.op()(a, b)
def do_list_sum(calc, args=[0]):
"""Sums up all numbers given"""
return sum(args)
def not_found(calc, *a, **kw):
raise Exception('not supported:', calc.operator_function)
and run it:
$ ./calc2.py list_sum "1,2,3"
6
$ ./calc2.py ls "1, 2, 3" # short form for function supported
6
Help Output
-h
delivers a markdown formatted help output:
$ ./calc2.py -h
# Calc
Calculator Demo
## Parameters
| Name | Val | F | Dflt | Descr | Expl |
| ----------------- | --- | - | ---- | ----- | ---- |
| operator_function | add | | | | |
## Actions
### list_sum
Sums up all numbers given
> do_list_sum(args=[0])
### run
Runs operator function on the arguments
> do_run(a=<'int'>, b=<'int'>)
Markdown?
Because this allows to add a lot of structuring information - which we can use to nicely colorize the output, provide TOCs, put into README's and so on.
-hc
shows the implementation:
$ ./calc2.py -hc
# Calc
Calculator Demo
## Parameters
| Name | Val | F | Dflt | Descr | Expl |
| ----------------- | --- | - | ---- | ----- | ---- |
| operator_function | add | | | | |
## Actions
### list_sum
Sums up all numbers given
``python=
def do_list_sum(calc, args=[0]):
return sum(args)
``
### run
Runs operator function on the arguments
``python=
def do_run(calc, a=int, b=int):
return calc.op()(a, b)
``
If the terminal width is not wide enough for the parameter tables we render the parameters vertically.
-hu
(classic unix) forces this always.
Defaults Are Configurable
Lets check -h
output when arguments are supplied:
$ ./calc2.py of=multiply 1 -h | head -n 10
# Calc
Calculator Demo
## Parameters
| Name | Val | F | Dflt | Descr | Expl |
| ----------------- | -------- | - | ---- | ----- | ---- |
| operator_function | multiply | C | add | | |
As you can see our value from the CLI made it into the documentation.
The F
(From) column shows where the value was comming from.
Providers
Changing the defaults of an app makes more sense to do via other means than the CLI.
Built in we do have two more so called "providers", i.e. deliverers of config.
File
Lets create a config file, changing the default operator to mul
and also the default of the first function parameter a
:
$ python -c "if 1:
cfg = {'operator_function': 'mul', 'run': {'a': 10.3}}
# write to user config dir:
from appdirs import user_config_dir as ucd
from json import dumps
with open(ucd() + '/Calc.json', 'w') as fd:
fd.write(dumps(cfg))"
Now we can run the app without supplying a
and get a multiplication:
$ ./calc1.py b=42
420
Positionally you could overwrite
a
still on the CLI, so we do not map one value only tob
$ ./calc1.py 5 42
210
$ ./calc1.py 5
00000.02993107 [error ] Value required param=b type=int
Here is the output of -h
:
$ ./calc1.py -h
# Calc
Calculator Demo
## Parameters
| Name | Val | F | Dflt | Descr | Expl |
| ----------------- | --- | - | ---- | ----- | ---- |
| operator_function | mul | F | add | | |
## Actions
### run
Runs operator function on the arguments
:::warning
Defaults modified (by File):
- a: 10 (was <'int'>)
:::
> do_run(a=10, b=<'int'>)
Again the app was reconfigured - this time by the config file (F)
Observe the int value - it was converted from the float, since that is what the function explicitly asked for.
Yes, we did mutate inplace the defaults of the
Calc.do_run
function - i.e. process wide! Keep this in mind when using that feature - reading the source code is then misleading. Help output shows modifications and origin rather prominently as you can see.
We delete the file for now.
Environ
Supported as well - but you have to provide structed values in lit.eval form:
$ export Calc_operator_function=mul Calc_run='{"a":4.2}'; ./calc1.py b=3
12
By default we do NOT support short forms at the environ provider and also we are case sensitive. Note that the overridden defaults still had been casted to the original types of the function signature.
Help output, as expected:
$ export Calc_operator_function=xxx Calc_run='{"b":4.2}';./calc1.py a=2.1 -h
# Calc
Calculator Demo
## Parameters
| Name | Val | F | Dflt | Descr | Expl |
| ----------------- | --- | - | ---- | ----- | ---- |
| operator_function | xxx | E | add | | |
## Actions
### run
Runs operator function on the arguments
:::warning
Defaults modified (by Env):
- b: 4 (was <'int'>)
:::
> do_run(a=<'int'>, b=4)
Up to now there is no indication within the app regarding allowed values for the operator function. That is why we accepted the bogus value, when configuring the app.
Nesting Functional Blocks
When the app gets more complex you can recursively nest/compose functional blocks into each other
#!/usr/bin/env python
import operator as OP
from app_conf.app import app
class Log:
"""Print Logger"""
level = 10
def do_testmsg(log, ev='hello'):
log.msg(30, ev)
return ''
def msg(log, level, ev, **kw):
if level >= log.level:
print('[%s] %s %s' % (level, ev, kw))
@app
class Calc:
"""Calculator Demo"""
operator_function = 'add'
log = Log
def do_run(calc, a=int, b=int):
"""Runs operator function on the arguments"""
of = calc.operator_function
calc.log.msg(10, 'Calculating', operation=of, a=a, b=b)
res = getattr(OP, of, calc.not_found)(a, b)
calc.log.msg(20, 'Returning', result=res)
return res
def not_found(calc, *a, **kw):
raise Exception('not supported:', calc.operator_function)
$ ./calc_tree.py 1 299
[10] Calculating {'operation': 'add', 'a': 1, 'b': 299}
[20] Returning {'result': 300}
300
$ ./calc_tree.py log.level=20 of=mul 100 3
[20] Returning {'result': 300}
300
$ ./calc_tree.py l.l=20 of=mul 100 3 # shorthand notation for nested blocks
[20] Returning {'result': 300}
300
$ ./calc_tree.py l.t "hi there" # calling nested functions
[30] hi there {}
Of course you could have defined the inner class directly within the main app class as well
Help output (again with overridden defaults):
$ ./calc_tree.py l.l=20 l.t.ev=hi of=mul -h
# Calc
Calculator Demo
## Parameters
| Name | Val | F | Dflt | Descr | Expl |
| ----------------- | --- | - | ---- | ----- | ---- |
| operator_function | mul | C | add | | |
## Actions
### run
Runs operator function on the arguments
> do_run(a=<'int'>, b=<'int'>)
---
## log
Print Logger
### Parameters
| Name | Val | F | Dflt | Descr | Expl |
| ----- | --- | - | ---- | ----- | ---- |
| level | 20 | C | 10 | | |
### Actions
#### testmsg
:::warning
Defaults modified (by CLI):
- ev: hi (was hello)
:::
> do_testmsg(ev='hi')
Tree Navigation
The arrangement of nested classes can be navigated during runtime like so:
#!/usr/bin/env python
from __future__ import print_function # for Python2
import operator as OP
import attr
from app_conf import root, parent, as_dict
from app_conf import app
class Inner:
inner_var = 1
def do_nav_demo(inner):
return root(inner).do_run(inner.inner_var, inner.Deep.deep_var)
class Deep:
deep_var = 2
def do_nav_demo(deep):
print(root(deep).app_var, parent(deep).do_nav_demo())
return ''
@app.app
class App:
inner = Inner
app_var = 0
def do_run(app, a=1, b=2):
return a, b
def do_dump(app, asdict=False):
print(app if not asdict else as_dict(app))
return ''
Calling App.inner.Deep.do_nav_demo()
on a configured tree:
$ ./calc_tree_nav.py av=100 i.iv=200 i.D.dv=300 i.D.nd
100 (200, 300)
Serializing / PrettyPrint
Configurative state can be pretty printed and dict-dumped:
$ ./calc_tree_nav.py av=1 i.D.dv=42 du # du matched to dump
App(app_var=1, inner=Inner(Deep=Inner.Deep(deep_var=42), inner_var=1))
$ ./calc_tree_nav.py app_var=2 inner.Deep.deep_var=42 dump asdict=true
{'app_var': 2, 'inner': {'Deep': {'deep_var': 42}, 'inner_var': 1}}
The dict format can be piped as is into a config file for subsequent runs.
Currently we do not serialize function parameter changes.
Alternatives
Credits
Testing/CI: