Skip to main content

A type-safe, validated library for loading and retrieving environment variables.

Project description

🌄 Kankyō

A type-safe, validated library for loading and retrieving environment variables.

Features

  • Layered loading — merges .env files in the standard priority order used by Next.js / Vite / dotenv-flow
  • Type coercionstr, int, float, bool, list, json, Path, URL, Enum, and secrets
  • Extended typesDecimal, timedelta, IPv4/IPv6, email, UUID, and literal values
  • Rich validation — length bounds, numeric ranges, regex patterns, allowed-value lists, custom validators
  • Safe defaults — defaults are validated/coerced and can use default_factory for mutable values
  • Variable expansion — optional ${VAR} expansion with cycle detection
  • Source tracing — inspect winning source and override history with env.trace("KEY")
  • Strict mode toggles — stricter parsing and mutable-default safeguards
  • Declarative schemas — define all your variables in one place with EnvSchema, fail fast on startup
  • Schema composition — optional/union/mapping specs plus nested/computed schema fields
  • Test-friendlyenv.patch({...}) context manager for safe test isolation

Loading Priority

Files are merged in this order (each one wins over the previous):

Priority Source
1 (low) .env
2 .env.<environment>
3 .env.local
4 .env.<environment>.local
5 (high) os.environ (process env)

extra kwargs (for tests) override everything.


Installation

pip install kankyo

Quick Start

from kankyo import Env, EnvStr, EnvInt, EnvBool

env = Env(environment='production')  # loads .env, .env.production, .env.local, etc.

port  = env.get('PORT',  EnvInt(ge=1024, le=65535, default=8080))
debug = env.get('DEBUG', EnvBool(default=False))
host  = env.get('HOST',  EnvStr(default='0.0.0.0'))

Enable expansion/strict mode:

env = Env(
    environment='production',
    expand_vars=True,   # resolve ${VAR} references
    strict=True,        # stricter behavior defaults
)

Declarative Schema

Define all variables once and validate them eagerly at startup:

from enum import StrEnum
from kankyo import Env, EnvSchema, EnvVar, EnvStr, EnvInt, EnvBool, EnvUrl, EnvEnum, EnvSecret

class LogLevel(StrEnum):
    DEBUG   = 'debug'
    INFO    = 'info'
    WARNING = 'warning'
    ERROR   = 'error'

class AppConfig(EnvSchema):
    # Required (no default) — missing → EnvSchemaError at startup
    database_url: str = EnvVar('DATABASE_URL', EnvStr())
    api_key:      str = EnvVar('API_KEY',      EnvSecret())

    # Optional with defaults
    port:      int      = EnvVar('PORT',      EnvInt(ge=1024, le=65535, default=8080))
    debug:     bool     = EnvVar('DEBUG',     EnvBool(default=False))
    log_level: LogLevel = EnvVar('LOG_LEVEL', EnvEnum(LogLevel, default=LogLevel.INFO))
    api_url:   str      = EnvVar('API_URL',   EnvUrl(allowed_schemes=['https']))

env = Env(environment='production')
cfg = AppConfig(env)          # raises EnvSchemaError listing ALL problems if any variable fails

print(cfg.port)               # 8080 (int)
print(cfg.log_level)          # LogLevel.INFO
print(cfg.as_dict())          # {'port': 8080, 'debug': False, ...}

Nested/computed schema composition:

from kankyo import EnvSchema, EnvVar, EnvNested, EnvComputed, EnvStr, EnvInt

class DBConfig(EnvSchema):
    host: str = EnvVar('HOST', EnvStr())
    port: int = EnvVar('PORT', EnvInt())

class AppConfig(EnvSchema):
    db: DBConfig = EnvNested(DBConfig, prefix='DB')  # DB__HOST, DB__PORT
    database_url: str = EnvComputed(lambda cfg: f'postgres://{cfg.db.host}:{cfg.db.port}')

All Types

EnvStr

env.get('NAME', EnvStr(
    min_length=1,
    max_length=128,
    pattern=r'[a-z][a-z0-9_-]*',   # re.fullmatch
    choices=['dev', 'staging', 'production'],
    strip=True,                      # default
    default='unnamed',
))

EnvInt

env.get('PORT', EnvInt(
    ge=1024,      # >= 1024
    le=65535,     # <= 65535
    gt=0,         # > 0  (exclusive)
    lt=100,       # < 100 (exclusive)
    base=10,      # use base=0 for 0x… / 0o… / 0b… auto-detection
    choices=[80, 443, 8080],
    default=8080,
))

EnvFloat

env.get('LEARNING_RATE', EnvFloat(gt=0.0, le=1.0, default=1e-3))

EnvDecimal

from decimal import Decimal
env.get('PRICE', EnvDecimal(ge=Decimal('0')))

EnvBool

Truthy strings: 1 true yes on enable enabled Falsy strings: 0 false no off disable disabled (case-insensitive)

env.get('DEBUG', EnvBool(default=False))

EnvList

env.get('ALLOWED_HOSTS', EnvList(
    subtype=EnvStr(),    # applied to each element
    delimiter=',',       # default
    min_length=1,
    max_length=10,
    default=['localhost'],
))

# List of ints:
env.get('PORTS', EnvList(subtype=EnvInt(ge=1)))

EnvJson

env.get('FEATURE_FLAGS', EnvJson(
    expected_type=dict,   # validated after JSON decode
    default={},
))

EnvPath

env.get('CONFIG_FILE', EnvPath(
    must_exist=True,
    must_be_file=True,
    expanduser=True,      # expand ~ (default)
    default='~/.myapp/config.yaml',
))

EnvTimedelta

env.get('CACHE_TTL', EnvTimedelta())      # '1h30m', '45s', or numeric seconds

EnvUrl

env.get('API_ENDPOINT', EnvUrl(
    allowed_schemes=['https'],
    require_tld=True,
))

EnvIPv4 / EnvIPv6

env.get('BIND_IPV4', EnvIPv4())
env.get('BIND_IPV6', EnvIPv6())

EnvEmail

env.get('SUPPORT_EMAIL', EnvEmail())

EnvUUID

env.get('REQUEST_ID', EnvUUID())

EnvLiteral

env.get('MODE', EnvLiteral(['dev', 'staging', 'prod']))
env.get('RETRIES', EnvLiteral([0, 1, 2, 3]))

EnvOptional

env.get('OPTIONAL_PORT', EnvOptional(EnvInt()))  # int | None

EnvUnion

env.get('WORKERS', EnvUnion([EnvInt(ge=1), EnvLiteral(['auto'])]))

EnvMapping

env.get('DB', EnvMapping({
    'host': EnvStr(),
    'port': EnvInt(ge=1),
    'ssl': EnvBool(default=False),
}))

EnvListOfSchema

env.get('BACKENDS', EnvListOfSchema({
    'name': EnvStr(min_length=1),
    'port': EnvInt(ge=1),
}))

EnvEnum

class Mode(str, Enum):
    DEBUG   = 'debug'
    RELEASE = 'release'

env.get('BUILD_MODE', EnvEnum(Mode, default=Mode.RELEASE))

Lookup tries value first, then name, case-insensitively by default.

EnvSecret

Like EnvStr but the value is masked in repr() so it never leaks into logs:

token = env.get('API_TOKEN', EnvSecret())
print(repr(token))   # '********'
print(str(token))    # actual value

Custom Validators

Every type accepts a validators list of callables (key: str, value: T) -> None. Raise EnvValidationError to fail.

from kankyo import EnvStr
from kankyo.exceptions import EnvValidationError

def must_be_slug(key, value):
    import re
    if not re.fullmatch(r'[a-z0-9-]+', value):
        raise EnvValidationError(key, value, 'must be a URL slug (a-z, 0-9, hyphens)')

env.get('APP_SLUG', EnvStr(validators=[must_be_slug]))

Bulk Retrieval

Collect all errors in one call rather than failing on the first:

result = env.get_many({
    'PORT':  EnvInt(default=8080),
    'DEBUG': EnvBool(default=False),
    'HOST':  EnvStr(default='0.0.0.0'),
})
# result = {'PORT': 8080, 'DEBUG': False, 'HOST': '0.0.0.0'}

Test Isolation

def test_uses_custom_port():
    env = Env(root=Path('fixtures'))
    with env.patch({'PORT': '9999', 'DEBUG': 'true'}):
        cfg = AppConfig(env)
        assert cfg.port == 9999
    # original values restored after the with block

Env API Reference

Method Description
env.get(key, spec) Retrieve a typed value; uses spec default if absent
env.require(key, spec) Like get but raises even when spec has a default
env.get_raw(key, default=None) Return the raw string (or default)
env.is_set(key) True if the key exists in any source
env.get_many(specs) Bulk retrieval, collects all errors
env.snapshot() Shallow copy of raw merged data
env.reload() Re-read all source files
env.patch(overrides) Context manager for test injection
env.trace(key) Show winner + source/value history for a key

Source Tracing

trace = env.trace('DATABASE_URL')
if trace:
    print(trace.winner)   # e.g. 'os.environ', 'extra', '.env.local'
    for entry in trace.history:
        print(entry.source, entry.value)

Strict Mode

You can enable strict mode at the environment or type level:

env = Env(strict=True, expand_vars=True)
port = env.get('PORT', EnvInt(strict=True))

In strict mode:

  • Mutable defaults must use default_factory
  • Some implicit default coercions are rejected
  • Expansion can fail on unresolved ${VAR} references
  • Env(strict=True) applies strict parsing to specs that do not set strict=... explicitly

.env File Format

# Full-line comments
APP_NAME=my-service

# Quoted values (whitespace preserved)
GREETING='Hello, World!'
PATH_VAL='/home/user/data'

# Double-quoted: escape sequences interpreted (\n \t \r)
MULTILINE="line1\nline2"

# export prefix supported
export SECRET_KEY=abc123

# Inline comments stripped for unquoted values
PORT=8080   # web port

Error Types

Exception Raised when
EnvMissingError Required variable not found in any source
EnvParseError Raw string cannot be coerced to target type
EnvValidationError Coerced value fails a validation constraint
EnvSchemaError EnvSchema construction fails / bad schema definition

All inherit from EnvError.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

kankyo-1.0.0.tar.gz (25.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

kankyo-1.0.0-py3-none-any.whl (28.3 kB view details)

Uploaded Python 3

File details

Details for the file kankyo-1.0.0.tar.gz.

File metadata

  • Download URL: kankyo-1.0.0.tar.gz
  • Upload date:
  • Size: 25.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.3

File hashes

Hashes for kankyo-1.0.0.tar.gz
Algorithm Hash digest
SHA256 e81f8108cdcd1406a04c9a25261060876ecb525a5b0fc7f7cffc67998394dbf5
MD5 ff30493f8bb6ef00ab6212b230874b69
BLAKE2b-256 05f58b7ecdca6547b1b05fa6c4eea42fe680c27f6d9393f9c1b9f241c47a7127

See more details on using hashes here.

File details

Details for the file kankyo-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: kankyo-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 28.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.3

File hashes

Hashes for kankyo-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2e162831a66b9d0d2eb08d6a5f827d374460161a369333b77610b049d41668a9
MD5 69c87d51c3a66f92319e1cc8222eab3e
BLAKE2b-256 b431589eaf9bfad2c2b10f94a38626f1a943c694b3d19124c9a6cca1afb86a02

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page