Package: umwelt
UmweltDescribe a configuration schema withdataclassesorpydanticand
load values from the environment, in a static-typing-friendly way.ExamplesFlat>>>os.environ["APP_HOSTS"]='["b.org","sky.net"]'>>>os.environ["APP_TOKEN"]="very secret"fromtypingimportSequencefrompydanticimportSecretStrimportumweltclassMyConfig:hosts:Sequence[str]token:SecretStrreplicas:int=2config=umwelt.new(MyConfig,prefix="app")>>>dataclasses.is_dataclass(config)True>>>config.hosts["b.org","sky.net"]>>>config.tokenSecretStr('**********')>>>config.replicas2Nested>>>os.environ["APP_DB_PORT"]="32"from__future__importannotations# for forward-referencesfrompydanticimportUrlStrimportumweltclassMyConfig:db:DbConfighost:UrlStr="http://b.org"@umwelt.subconfigclassDbConfig:port:intdebug:bool=Falseconfig=umwelt.new(MyConfig,prefix="app")>>>config.host"http://b.org">>>config.db.port32Install$pipinstallumweltFeaturesumwelt.newumwelt.newexpects one positional argument: the config class to fill.
Umwelt will convert it into adataclassif it's not one already.umwelt.newalso accepts named arguments:source(by defaultos.environ) is aMapping[str, str]from which
values are extracted.prefixcan be a string or a callable. As a string, it is prepended to
the config field's name. As a callable, it receives the config field's name and
its result is the source key name.decoderis a callable expecting a type and a string, and returns a
conversion of that string in that type, or in a type that pydantic can convert
in that type.
For example, when umwelt's defaultdecoderis called with (List[Set[int]],"[[1]]"), it simply decodes the string from JSON and hence returns a list oflists, which pydantic properly converts into a list ofsets.@umwelt.subconfig@umwelt.subconfigtags classes so that, when they appear as field annotations
in another config class,umwelt.newdoesn't instantiate them from a singlesourcevalue, but rather from onesourcevalueper class field.Example:classPoint:# no @subconfigdef__init__(self,s:str):# string inputself.x,self.y=s.split(",",1)# arbitrary implementationclassMyConf:point:Pointconf=umwelt.new(MyConf,source={"POINT":"1,2"})# one source entryconf.point# <Point at 0x7f07b1d04750>conf.pointis an instance ofPoint, built by passing the input value"1,2"directly toPoint.__new__.
There is only onesourcekey:POINT.Now compare with@umwelt.subconfig:@umwelt.subconfigclassPoint:x:inty:intclassMyConf:point:Pointconf=umwelt.new(MyConf,source={"POINT_X":"1","POINT_Y":"2"})conf.point# Point(x=1, y=2)conf.pointis still an instance ofPoint(Pointhas been made a
dataclass by Umwelt, hence the automatic__str__implementation).
There aretwosourcekeys:POINT_XandPOINT_Y, each corresponding to
a field of thePointclass.Comparison with EcologicalI've usedEcologicalfor a long time.
Today, a large part of Ecological's codebase implements features already found
indataclassesandpydantic, which are more mature.
I believe Ecological's design can be dramatically simplifiedandimproved by
enforcing a strict separation of concerns:class scaffolding is the responsibility ofdataclasses(which, compared
to metaclasses, is simpler, more introspectable, and comes with helpers likeasdict);type coercion and validation is the responsibility ofpydantic(which has
more features, e.g. nested data types, JSON Schema, serialization, etc.);mapping apydanticschema (the configuration class) to a string-to-string
dict (likeos.environ) is the responsibility of Umwelt.Some compatibility-breaking decisions prevent from doing this in Ecological:Don't autoload configuration values, especially not at class definition time.
Instead, offer just one function (umwelt.new) that loads the configuration
when it is called.Don't tie variable prefixes to configuration classes, as that doesn't play
well with nested configurations.