Package: enviable
Author:Keryn KnightVersion:1.0.0A small module for wrapping over environment variables (pulled fromos.environ)
which provides convenience methods to fetch and check various data types
(including iterables) in what I’d charitably hope is a semi-sensible way.RationaleI’ve worked on enough projects of long lifespan that ultimately everyone ends up with
their own environment variables configured, invariably differently and often out-of-date
or stale in unique ways, depending on when they arrive and the maturity and state of
the project at the time.This is an attempt to resolve that by having a single configuration, read from env vars,
and capable of dumping an env file full of valid example values which should be enough
to run safely locally.Explicitlydoesn’tattempt to read from any.envor.envrcfile, because that
doesn’t describe valid examples orwhichthings may/should be set into the
environment. It becomes an absolute pot-luck. It also means you end up carrying around
stale environment variables because some change or other wasn’t mentioned/documented
and so env varMY_COOL_ENVisn’t used any more.Tracks requested environment variables and their default/fallback/example values, and
whether or not the fallback was used. Never tracks the actual environment value.If this package isn’t to your liking, there’splentyof others, and I’m
largely suffering fromNot-Invented-Here syndrome.All methods exposed by the Environment accept a key and a default.The key is the environment variable to search for.The defaultMUSTbe a string, as it is subject to the same parsing as if it had
been found in the environment, and thus serves as a documented example of a valid
value to export as an environment variable. Enforced value documentation!A series of examplesA short overview of all of the available check/cast methods on anEnvironmentfollowsAssume all examples are prefixed with:from enviable import envwhich is roughly equivalent to:from enviable import Environment
import os
env = Environment(os.environ)Remember, thesecondargument (default) is always a string, and always
gets parsed the same as a real value, so treat it as an example value in the following…Conversions and validationsTo trim any accidental quotes or whitespace from the beginning and end of the value:env.text("VAR_NAME", "'   test '") == "test"To convert an incoming string to an integer:env.int("VAR_NAME", "3") == 3To convert an incoming string to real boolean (TrueorFalse), note
that upper or lower case doesn’t matter:env.bool("VAR_NAME", "true") is True
env.bool("VAR_NAME", "on") is True
env.bool("VAR_NAME", "1") is True
env.bool("VAR_NAME", "yes") is True
env.bool("VAR_NAME", "y") is True

env.bool("VAR_NAME", "false") is False
env.bool("VAR_NAME", "off") is False
env.bool("VAR_NAME", "0") is False
env.bool("VAR_NAME", "no") is False
env.bool("VAR_NAME", "n") is FalseTo make auuid.UUIDfrom an optionally hyphenated string:env.uuid("VAR_NAME", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") == UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
env.uuid("VAR_NAME", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") == UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')Tocheckif an emaillooksvalid:env.email("VAR_NAME", "a@b.com") == "a@b.com"To verify if a string is all hexi characters:env.hex("VAR_NAME", "abcdef12345ABCDEF") == "abcdef12345ABCDEF"Confirm that a string can be decoded from a base64 encoded value:env.b64("VAR_NAME", "d29vZg==") == 'd29vZg=='There’s no support forfloatbecause it’s lossy, but you can have decimals:env.decimal("VAR_NAME", "1.25") == Decimal("1.25")To confirm that a string looks like it might be an importable python thing:env.importable("VAR_NAME", "path.to.my.module") == "path.to.my.module"To make sure a string represents an existing,readablefile on disk:env.filepath("VAR_NAME", "/path/to/my/valid_file.json") == "/path/to/my/valid_file.json"To make sure a string is a directory which exists:env.directory("VAR_NAME", "/path/to/my") == "/path/to/my"Tovaguelysanity-check URLs (must start withhttp://orhttps://or//or/...):env.web_address("VAR_NAME", "http://example.com/")To constrain a value to one of a few valid options (wherechoicesis parsed the same way asIterables):env.one_of("VAR_NAME", "3", choices="1,2,3,4")and to go off-reservation, you can get JSON out, or the raw environment string:env.json("VAR_NAME", "{}") == {}
env.raw("VAR_NAME", "'   ...  '") == "'   ...  '"Temporal values (datetimes, dates, times, timedeltas)If you haveDjango(or Python3.7+) installed (because that’s my main use case and I’m lazy)
you can also get datetimes if you provide a value in ISO 8601 format:env.datetime("VAR_NAME", "2019-11-21 16:12:56.002344")
env.datetime("VAR_NAME", "2019-11-21 16:12:56.002344+20:00")
env.datetime("VAR_NAME", "2019-11-21")Similarly you can ask for dates:env.date("VAR_NAME", "2019-11-21")
env.date("VAR_NAME", "2019-11-2")
env.date("VAR_NAME", "2019-3-2")or times:env.time("VAR_NAME", "13:13:13.000123")
env.time("VAR_NAME", "13:13:13.123")
env.time("VAR_NAME", "13:13:13")
env.time("VAR_NAME", "13:13")or timedeltas (whichdo notdepend onDjango):env.timedelta("VAR_NAME", "1 day, 10 minutes")
env.timedelta("VAR_NAME", "1 day, 10 minutes; 4 seconds; 10 millisecond")
env.timedelta("VAR_NAME", "1 minutes, 3secs")
env.timedelta("VAR_NAME", "10wks, 4min, 10s, 9ms, 4us")
env.timedelta("VAR_NAME", "-13d19m")
env.timedelta("VAR_NAME", "-1 day, 23:59:59.999000")
env.timedelta("VAR_NAME", "0:00:00.001000")IterablesIt’s additionally possible to consume a string and cast it to a sequence etc:env.tuple("VAR_NAME", "123,4356,235") == ("123", "4356", "235")
env.list("VAR_NAME", "123,4356,235") == ["123", "4356", "235"]
env.set("VAR_NAME", "123,4356,235") == {"123", "4356", "235"}
env.frozenset("VAR_NAME", "123,4356,235") == {"123", "4356", "235"}
env.dict("VAR_NAME", "a=1, b=2, c=3") == {"a": "1", "b": "2", "c": "3"}Commas are treated as delimiters, and may optionally have a single space after each one.Leading python-iterable characters are dropped if they are present from both sides,
and their python type is ignored:env.tuple("VAR_NAME", "[123, 4356, 235]") == ("123", "4356", "235")
env.tuple("VAR_NAME", "(123, 4356, 235)") == ("123", "4356", "235")
env.tuple("VAR_NAME", "{123, 4356, 235}") == ("123", "4356", "235")Casting on iterablesUsing any ofenv.tuple,env.list,env.set,env.frozenset,
orenv.dictallows each parsed value to be validated and optionally cast,
with the caveat that theiterable is homogenous(that is, everything can be
converted to anintor auuidor whatever)Each value may be cast to any of the non-iterable methods documented above, by usingenv.ensure.methodnameinstead ofenv.methodname, for example:env.tuple("VAR_NAME", "123,4356,235", converter=env.ensure.int) == (123, 4356, 235)
env.set("VAR_NAME", "123,4356,235", converter=env.ensure.hex) == {"123", "4356", "235"}
env.list("VAR_NAME", "a@b.com, b@c.com, def@ghi", env.ensure.email) == ['a@b.com', 'b@c.com', 'def@ghi']env.dictis slightly special in that it has arguments forkey_converterandvalue_converterso that keys can have a different type to values. Both must still be homogenous:env.dict("VAR_NAME", "a=1, b=2, c=3", key_converter=env.ensure.hex, value_converter=env.ensure.int) == {'a': 1, 'c': 3, 'b': 2}Django database URLsTo facilitate an easier transition fromdj-database-urlordjango-environ,
there’s support for reading aDATABASE_URL(or other named env var) into
the correct format to fit theDjangoDATABASESdictionaries:# default key is implicitly "DATABASE_URL"
env.django_database_url(default="sqlite://:memory:")
env.django_database_url("MY_DB", "postgres://user:pass@host:1234/dbname?conn_max_age=600")
env.django_database_url("DATABASE_URL", "psql://user:pass@host:1234/dbname?conn_max_age=None&autocommit=True&atomic_requests=False")
env.django_database_url("MYSQL_DBASE", "mysql://user:pass@host:1234/dbname?init_command=SET storage_engine=INNODB")
env.django_database_url("PG_SOCKET", "postgres:////var/run/postgresql/db")so you can ultimately do:DATABASES = {
    "default": env.django_database_url("postgres://localhost?conn_max_age=1"),
    "other_db": env.django_database_url("OTHER_DB", "sqlite://:memory:"),
}Differences from those librariesUsing themssqlscheme sets the driver to usemssql-python.Providing a URL fragment (?query_string...#x=10&y=True) parses those key/values
the same as the query string, butalwaysputs them into the dictionary’s global
options, never into the'OPTIONS'key.the functiondjango_database_urldoesn’t accept theconn_max_ageandssl_requireused bydj-database-urlHandling errorsFailing to successfully convert (or just validate) the value (whether from
the environment or from the fallback) immediately halts execution by raisingEnvironmentCastErrorwhich is a subclass ofValueError.Failing to provide astringfor a default/fallback value will
raiseEnvironmentDefaultErrorwhich isalsoa subclass ofValueError.To catch anyanticipatederror then, is to:try:
    ...
except (EnvironmentCastError, EnvironmentDefaultError) as e:
    ...Checking for existenceTo find out if an environment variable is set,regardless of it’s value, you can
use normalintesting:if "MY_KEY" in env:
    do_something_special()which allows you to change behaviour based on seeing certain variables in the
running environment.Tracking the requestsEvery access of anEnvironment(eg: the defaultenv) keeps an internal
log of the key requested + whether or not it was found and used in the environment.These are available underenv.usedandenv.fallbacksbut may be accessed
together by iterating over theEnvironmentin question, where each iteration
will yield a3-tupleof:environment variable name requestedthedefaultorfallbackvalueaboolof whether or not the environment variable was used or whether the fallback was.Trueif found in the environment,Falseif the fallback was used.For example, to output everything, you might do:from enviable import env, Environment
import sys
env.int("TEST", "4")
myenv = Environment({"TESTING": "1"})
myenv.bool("TESTING", "0")
if __name__ == "__main__":
    for env_var_name, env_var_example, was_read_from_env in env:
        if was_read_from_env is True:
            sys.stdout.write("{} was in the environment\n".format(env_var_name))
        else:
            sys.stdout.write("{} was NOT in the environment, used default value of {}\n".format(env_var_name, env_var_example))Note that in the above scenario, becauseenvandmyenvare different
instances with their own individual tracking, the request forTESTINGwill
not output, butTESTwill.Generating an env fileTo avoid carrying around stale environment variables on projects of longevity, theEnvironmentprovides a self-documenting mechanism to output all of the environment variables and
example values to stdout (or any stream), so you can just pipe to a file and you’ve got a usable env, give or
take adjusting the values for your machine:# my_cool_settings.py
from enviable import env
env.int("TEST", "4")
env.hex("MY_COOL_VAR", "ABCDEF24")
if __name__ == "__main__":
    env.print()the above will print to stdout when you callpython-mmy_cool_settingslike so:export MY_COOL_VAR='ABCDEF24'
export TEST='4'You can adjust the output to say, be.envfile compatible like so:env.print("{key!s}='{value!s}'")which would instead output:MY_COOL_VAR='ABCDEF24'
TEST='4'Onlykeyandvalueformat-kwargs are provided to the output template.Running the testsGiven a copy of the fileenviable.pyyou ought to be able to do either of the following:$ python enviable.py
$ python -m enviableand see the output of the various tests I’ve bothered with. If mypy is installed,
it will also type-check the file.TODOMore testsThe licenseIt’sFreeBSD. There’s should be aLICENSEfile in the root of the repository, and in any archives.Copyright (c) 2019, Keryn Knight
All rights reserved.Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.Change history for enviable1.0.0Breaking change: thedefaultargument ofenv.int,env.text, etc is now required, rather than defaulting to"".Feature: added atimedeltamethod to theenv(orEnvironment) for parsing strings like1 day, 10 minutes.Feature: added adjango_database_urlmethod to theenv(orEnvironment) to allow for readingDATABASE_URLetc.Feature: add aprint()method to theenv(orEnvironment) to output in.envor.envrcformat, for easy generation of config files.Feature: parse temporal values without Django, as long as Python 3.7+ is available.0.2.2Bugfix: thetypingmodule is no longer a hard-dependency, because whilst you have it on py3, you onlymighthave it in a py2 environment. SeeGitHub issue 20.2.1Feature: Testing if a key exists in the environment can now be done using"KEYNAME" in envBugfix: usingenv.one_of(key,default="",choices="")without providing a custom converter no longer throws aTypeErrorMisc: Updated type hints across the module to keepmypyhappy, and me deeply unhappy.Misc: Before executing tests when doingpython-menviable, try and runmypy0.2.0Bugfix: Resolved issue where by default it wasn’t stripping leading/trailing whitespace from incoming stringsFeature: Added new convienience methodenv.one_of(key,default="",choices="",converter=None)for providing a constrainted list of options which are valid.Misc: Updated the default converter for iterables to be apartialBugfix: Resolved issue wherestr()andrepr()for anEnvironmentwould leave duplicate values in if the same variable is searched for multiple times with different example/default values.0.1.0Initial export