Metadata-Version: 2.4
Name: lucid-config
Version: 0.1.0
Summary: Cascading configuration with dot-notation access, env file support, and type casting.
Project-URL: Homepage, https://github.com/sharik709/lucid-config
Project-URL: Documentation, https://github.com/sharik709/lucid-config#readme
Project-URL: Repository, https://github.com/sharik709/lucid-config
Project-URL: Issues, https://github.com/sharik709/lucid-config/issues
Author-email: Sharik Shaikh <shaikhsharik709@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: config,configuration,dotenv,env,environment,settings,toml,type-casting,yaml
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: all
Requires-Dist: pyyaml>=6.0; extra == 'all'
Requires-Dist: tomli>=2.0; (python_version < '3.11') and extra == 'all'
Provides-Extra: dev
Requires-Dist: mypy>=1.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.1; extra == 'dev'
Provides-Extra: toml
Requires-Dist: tomli>=2.0; (python_version < '3.11') and extra == 'toml'
Provides-Extra: yaml
Requires-Dist: pyyaml>=6.0; extra == 'yaml'
Description-Content-Type: text/markdown

# Lucid Config

**Configuration that knows where to look. Merge env vars, `.env` files, and config dicts with dot-notation access, type casting, and zero fuss.**

Every Python project reinvents config loading. Some use `os.environ` everywhere. Some parse `.env` with python-dotenv and hope for the best. Some write a `settings.py` that turns into a 400-line monster. Then testing becomes a nightmare because config is scattered across module-level globals. Lucid Config gives you one clean layer that merges all your sources, casts types, and plugs straight into the Lucid Container.

[![PyPI version](https://badge.fury.io/py/lucid-config.svg)](https://pypi.org/project/lucid-config/)
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)

---

## Before & After

**Without Lucid Config:**

```python
import os
from dotenv import load_dotenv

load_dotenv()

DB_HOST = os.environ.get("DB_HOST", "localhost")
DB_PORT = int(os.environ.get("DB_PORT", "5432"))
DEBUG = os.environ.get("DEBUG", "false").lower() in ("true", "1", "yes")
CACHE_DRIVER = os.environ.get("CACHE_DRIVER", "memory")
REDIS_HOST = os.environ.get("REDIS_HOST", "localhost")
REDIS_PORT = int(os.environ.get("REDIS_PORT", "6379"))

# Scattered across files, no nesting, manual casting everywhere,
# testing means monkeypatching os.environ or reloading modules
```

**With Lucid Config:**

```python
from lucid_config import Config

config = Config()
config.load_env(".env")
config.load_dict({
    "database": {"host": "localhost", "port": 5432},
    "cache": {"driver": "memory", "redis": {"host": "localhost", "port": 6379}},
    "app": {"debug": False},
})

config.get("database.host")              # "localhost" (or DB_HOST from env)
config.integer("database.port")          # 5432
config.boolean("app.debug")             # False
config.get("cache.redis.host")          # "localhost"

# In tests — swap the whole thing, no monkeypatching
test_config = Config({"database.host": "test-db", "app.debug": True})
container.instance(ConfigContract, test_config)
```

---

## Installation

```bash
pip install lucid-config
```

Requires Python 3.10 or higher. No dependencies.

---

## Quick Start

### From a dict

```python
from lucid_config import Config

config = Config({
    "app": {
        "name": "my-project",
        "debug": False,
        "port": 8000,
    },
    "database": {
        "host": "localhost",
        "port": 5432,
        "name": "mydb",
    },
})

config.get("app.name")             # "my-project"
config.get("app.version", "0.0.1") # "0.0.1" (default)
config.integer("app.port")         # 8000
```

### From environment variables

```python
# Reads DATABASE_HOST, DATABASE_PORT, APP_DEBUG, etc.
config = Config()
config.load_env_prefix("APP")

# APP_NAME=my-project  →  config.get("app.name")  →  "my-project"
# APP_DEBUG=true       →  config.boolean("app.debug")  →  True
```

### From a `.env` file

```python
config = Config()
config.load_env(".env")
config.load_env(".env.local")  # overrides .env
```

---

## Source Priority

When the same key exists in multiple sources, the **last loaded source wins**. The intended load order (lowest to highest priority):

```
1. Default dict          (base config, committed to repo)
2. .env file             (shared defaults, committed or gitignored)
3. .env.local file       (personal overrides, always gitignored)
4. Environment variables (deployment overrides, set by infrastructure)
5. Runtime overrides     (set() calls in code)
```

```python
config = Config()

# 1. Base defaults
config.load_dict({
    "cache": {"driver": "memory"},
    "app": {"debug": False},
})

# 2-3. Env files (later files override earlier ones)
config.load_env(".env")
config.load_env(".env.local")

# 4. Real environment variables (highest priority from files)
config.load_env_vars()

# 5. Runtime override (highest of all)
config.set("app.debug", True)
```

At this point, if `APP_DEBUG=false` is in the real environment, `config.get("app.debug")` returns `"false"` (the env var overrides `.env`). But the `config.set()` call overrides everything.

---

## Full API Reference

### `Config(initial=None)`

Creates a new config repository, optionally pre-loaded with a dict.

| Parameter | Type             | Description                                    |
|-----------|------------------|------------------------------------------------|
| `initial` | `dict \| None`   | Optional nested dict of default values.        |

```python
config = Config()
config = Config({"app": {"name": "my-project"}})
```

---

### `.get(key, default=None)`

Retrieves a value using dot notation. Returns `default` if the key doesn't exist.

| Parameter | Type   | Description                              |
|-----------|--------|------------------------------------------|
| `key`     | `str`  | Dot-separated path (e.g., `"database.host"`). |
| `default` | `Any`  | Returned if key is not found.            |

```python
config.get("database.host")                  # "localhost"
config.get("database.options.timeout", 30)   # 30 (not set)
config.get("database")                       # {"host": "localhost", "port": 5432, ...}
```

Getting a key that points to a nested dict returns the entire sub-dict.

---

### `.set(key, value)`

Sets a value at the given dot-notation path. Creates intermediate dicts as needed.

```python
config.set("database.host", "prod-db.example.com")
config.set("services.payment.timeout", 60)  # creates services.payment dict
```

---

### `.has(key)`

Returns `True` if the key exists (even if its value is `None` or `False`).

```python
config.has("database.host")    # True
config.has("database.color")   # False
```

---

### `.all()`

Returns the entire config as a nested dict.

```python
everything = config.all()
# {"app": {"name": "my-project", ...}, "database": {...}, ...}
```

---

### `.forget(key)`

Removes a key and its value. If the key points to a nested dict, the entire subtree is removed.

```python
config.forget("database.options")
config.has("database.options")  # False
```

---

### `.merge(data)`

Deep-merges a dict into the existing config. Nested dicts are merged recursively, not replaced.

```python
config = Config({"database": {"host": "localhost", "port": 5432}})
config.merge({"database": {"port": 3306, "name": "newdb"}})

config.get("database.host")  # "localhost" (preserved)
config.get("database.port")  # 3306 (overwritten)
config.get("database.name")  # "newdb" (added)
```

---

### Type Casting Methods

All casting methods accept `key` and an optional `default`. They retrieve the raw value and cast it.

#### `.string(key, default="")`

Casts to `str`.

```python
config.string("app.name")             # "my-project"
config.string("app.port")             # "8000" (int → str)
```

#### `.integer(key, default=0)`

Casts to `int`. Handles string values from env vars.

```python
config.integer("database.port")       # 5432
config.integer("database.port")       # 5432 (even if stored as "5432" from env)
config.integer("missing.key", 3000)   # 3000
```

#### `.float(key, default=0.0)`

Casts to `float`.

```python
config.float("app.rate_limit", 1.5)   # 1.5
```

#### `.boolean(key, default=False)`

Casts to `bool`. Recognizes common truthy/falsy strings from env vars.

- **Truthy:** `True`, `"true"`, `"True"`, `"TRUE"`, `"1"`, `"yes"`, `"on"`
- **Falsy:** `False`, `"false"`, `"False"`, `"FALSE"`, `"0"`, `"no"`, `"off"`, `""`, `None`

```python
# APP_DEBUG=true in environment
config.boolean("app.debug")           # True
config.boolean("app.verbose", False)   # False (missing, uses default)
```

#### `.list(key, default=None, delimiter=",")`

Splits a string value into a list. If the value is already a list, returns it directly.

```python
# ALLOWED_HOSTS=localhost,example.com,api.example.com in env
config.list("app.allowed_hosts")
# ["localhost", "example.com", "api.example.com"]

# Already a list in dict config
config.set("app.cors_origins", ["http://localhost:3000"])
config.list("app.cors_origins")
# ["http://localhost:3000"]
```

---

### Environment Loading

#### `.load_env(path, override=True)`

Parses a `.env` file and loads its values into the config store.

| Parameter  | Type   | Description                                          |
|------------|--------|------------------------------------------------------|
| `path`     | `str`  | Path to the `.env` file.                             |
| `override` | `bool` | If `True`, values override existing keys.            |

Supports:

- `KEY=value` (basic)
- `KEY="quoted value"` and `KEY='quoted value'` (quotes stripped)
- `# comments` and blank lines (ignored)
- `export KEY=value` (export prefix stripped)
- Inline comments: `KEY=value # this is ignored`
- Multiline values with `"` or `'` quotes
- Variable interpolation: `URL=https://${HOST}:${PORT}` (resolves from already-loaded values and real env vars)

**.env files do NOT pollute `os.environ`.** Values are loaded into the config store only. Use `.load_env_vars()` if you need real environment variable overrides.

```python
config.load_env(".env")
config.load_env(".env.local")          # overrides .env values
config.load_env(".env.test", override=False)  # only fills missing keys
```

**Key mapping:** Environment variable names are converted to dot-notation config keys by lowercasing and replacing `_` with `.` for the first level. `DATABASE_HOST` becomes `database.host`. `APP_DEBUG` becomes `app.debug`. `REDIS_CACHE_TTL` becomes `redis.cache_ttl`.

The mapping rule: the **first** underscore is the namespace separator (becomes `.`), remaining underscores are preserved. This is configurable via `.load_env(path, separator="_", depth=1)`.

---

#### `.load_env_vars(prefix=None)`

Loads values from real `os.environ` into the config store.

| Parameter | Type         | Description                                               |
|-----------|--------------|-----------------------------------------------------------|
| `prefix`  | `str \| None`| If set, only loads vars starting with this prefix.        |

```python
# Load everything
config.load_env_vars()

# Load only vars starting with APP_
config.load_env_vars(prefix="APP")
# APP_NAME → config.get("app.name")
# APP_DEBUG → config.boolean("app.debug")
# DATABASE_HOST is ignored
```

---

#### `.load_env_prefix(prefix)`

Shorthand for `load_env_vars(prefix=prefix)`. Reads real environment variables with the given prefix.

```python
config.load_env_prefix("MYAPP")
# MYAPP_DB_HOST=localhost → config.get("myapp.db_host")
```

---

### Loading from Files

#### `.load_dict(data)`

Loads a nested dict. Same as passing `initial` to the constructor, but can be called multiple times. Later calls override earlier values (deep merge).

```python
config.load_dict({"app": {"name": "my-project"}})
config.load_dict({"app": {"version": "1.0"}})

config.get("app.name")     # "my-project" (preserved)
config.get("app.version")  # "1.0" (added)
```

---

#### `.load_json(path)`

Loads config from a JSON file.

```python
config.load_json("config/app.json")
```

---

#### `.load_toml(path)`

Loads config from a TOML file. Uses `tomllib` (stdlib in Python 3.11+, falls back to `tomli` for 3.10).

```python
config.load_toml("config/app.toml")
```

---

#### `.load_yaml(path)`

Loads config from a YAML file. Requires `pyyaml` to be installed — raises `ImportError` with a clear message if missing.

```python
config.load_yaml("config/app.yaml")
```

---

### Immutability

#### `.freeze()`

Makes the config read-only. Any subsequent `.set()`, `.merge()`, `.forget()`, or `.load_*()` calls raise `ConfigFrozenError`.

```python
config.freeze()
config.set("app.name", "nope")  # raises ConfigFrozenError
```

Useful after boot to prevent accidental mutations during request handling.

#### `.is_frozen`

Property that returns `True` if the config is frozen.

---

### Required Keys

#### `.require(*keys)`

Validates that all specified keys exist. Raises `MissingConfigError` with a list of all missing keys (not just the first one).

```python
config.require("database.host", "database.name", "app.secret_key")
# Raises: MissingConfigError: Missing required config keys: database.name, app.secret_key
```

Call this after loading all sources but before the app starts handling requests.

---

## Container Integration

### ConfigContract

```python
from abc import ABC, abstractmethod
from typing import Any

class ConfigContract(ABC):
    @abstractmethod
    def get(self, key: str, default: Any = None) -> Any: ...

    @abstractmethod
    def set(self, key: str, value: Any) -> None: ...

    @abstractmethod
    def has(self, key: str) -> bool: ...

    @abstractmethod
    def integer(self, key: str, default: int = 0) -> int: ...

    @abstractmethod
    def boolean(self, key: str, default: bool = False) -> bool: ...

    @abstractmethod
    def string(self, key: str, default: str = "") -> str: ...

    @abstractmethod
    def all(self) -> dict: ...
```

### ConfigServiceProvider

```python
from lucid_container import ServiceProvider
from lucid_config import Config, ConfigContract

class ConfigServiceProvider(ServiceProvider):

    def register(self):
        self.app.singleton(ConfigContract, lambda c: self._create_config())
        self.app.alias("config", ConfigContract)

    def _create_config(self):
        config = Config()

        # Load in priority order
        config.load_dict(self._load_defaults())
        config.load_env(".env")
        config.load_env(".env.local")
        config.load_env_vars()

        # Validate essentials
        config.require("app.name", "app.secret_key")

        return config

    def _load_defaults(self):
        return {
            "app": {"name": "lucid-app", "debug": False, "port": 8000},
            "database": {"host": "localhost", "port": 5432},
            "cache": {"driver": "memory"},
        }
```

### Usage in other providers

```python
class CacheServiceProvider(ServiceProvider):

    def register(self):
        self.app.singleton(CacheContract, lambda c: self._create_cache(c))

    def _create_cache(self, container):
        # ConfigContract is available because ConfigServiceProvider registered first
        config = container.make(ConfigContract)
        driver = config.get("cache.driver", "memory")

        if driver == "redis":
            return RedisCache(
                host=config.get("cache.redis.host", "localhost"),
                port=config.integer("cache.redis.port", 6379),
            )
        return InMemoryCache()
```

This is the pattern every future Lucid package follows: read config through the contract, never touch `os.environ` directly.

---

## Real-World Examples

### Typical project setup

```
my-project/
├── .env                 # shared defaults (committed or gitignored, team preference)
├── .env.local           # personal overrides (always gitignored)
├── config/
│   └── defaults.toml    # structured defaults
└── app.py
```

**.env:**

```
APP_NAME=my-project
APP_DEBUG=false
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=mydb
CACHE_DRIVER=memory
```

**.env.local:**

```
APP_DEBUG=true
DATABASE_HOST=dev-db.local
```

**app.py:**

```python
from lucid_config import Config

config = Config()
config.load_toml("config/defaults.toml")
config.load_env(".env")
config.load_env(".env.local")
config.load_env_vars()

config.require("app.name", "database.host", "database.name")
config.freeze()

print(config.get("app.name"))            # "my-project"
print(config.boolean("app.debug"))       # True (from .env.local)
print(config.get("database.host"))       # "dev-db.local" (from .env.local)
print(config.integer("database.port"))   # 5432
```

### Testing

```python
def test_user_creation():
    test_config = Config({
        "database": {"host": "localhost", "port": 5432, "name": "test_db"},
        "app": {"debug": True, "secret_key": "test-secret"},
        "mail": {"driver": "log"},
    })

    container = Container()
    container.instance(ConfigContract, test_config)
    container.register_provider(DatabaseServiceProvider)
    container.boot()

    # Everything uses the test config — no env vars, no .env files, no monkeypatching
    service = container.make(UserService)
    user = service.create("alice@test.com")
    assert user.email == "alice@test.com"
```

### Per-environment config files

```python
import os

config = Config()
config.load_toml("config/defaults.toml")

env = os.environ.get("APP_ENV", "development")
config.load_toml(f"config/{env}.toml")    # config/production.toml, config/staging.toml, etc.

config.load_env(".env")
config.load_env(f".env.{env}")            # .env.production, .env.staging, etc.
config.load_env_vars()
```

---

## Architecture

### Project Structure

```
lucid-config/
├── src/
│   └── lucid_config/
│       ├── __init__.py          # Public API exports
│       ├── config.py            # Config class
│       ├── contract.py          # ConfigContract ABC
│       ├── env_parser.py        # .env file parser
│       ├── key_mapper.py        # ENV_VAR_NAME → dot.notation mapping
│       └── exceptions.py        # MissingConfigError, ConfigFrozenError
├── tests/
│   ├── __init__.py
│   ├── test_config.py           # Core get/set/has/forget/all
│   ├── test_dot_notation.py     # Nested key access and creation
│   ├── test_type_casting.py     # string, integer, float, boolean, list
│   ├── test_env_parser.py       # .env file parsing (comments, quotes, interpolation)
│   ├── test_env_vars.py         # os.environ loading with prefix
│   ├── test_key_mapping.py      # ENV_VAR → dot.notation conversion
│   ├── test_merge.py            # Deep merge behavior
│   ├── test_source_priority.py  # Override order across sources
│   ├── test_freeze.py           # Immutability after freeze()
│   ├── test_require.py          # Required key validation
│   ├── test_file_loading.py     # JSON, TOML, YAML loading
│   └── test_edge_cases.py       # Empty config, None values, deeply nested keys
├── pyproject.toml
├── README.md
├── LICENSE
└── CHANGELOG.md
```

### Implementation Notes

**Config internals:**

The config stores everything in a single nested dict (`self._store: dict`). Dot-notation access walks the dict by splitting on `.`:

```python
def get(self, key: str, default=None):
    keys = key.split(".")
    current = self._store

    for k in keys:
        if isinstance(current, dict) and k in current:
            current = current[k]
        else:
            return default

    return current
```

`.set()` works in reverse — walks the dict creating intermediate dicts as needed, then sets the final value.

**Source layering:**

There is no separate "layer" system. Each `load_*()` call deep-merges into the same `_store` dict. Later loads overwrite earlier values for the same key. This keeps the implementation simple and the mental model clear: last write wins.

**Env file parser:**

The `.env` parser is a standalone module (`env_parser.py`) that handles:

```python
# Basic
KEY=value

# Quoted (quotes stripped, preserves inner whitespace)
KEY="hello world"
KEY='hello world'

# Comments and blanks
# This is a comment
  # Indented comment

# Export prefix (stripped)
export KEY=value

# Inline comments (only outside quotes)
KEY=value  # this part is ignored

# Multiline with quotes
KEY="line one
line two
line three"

# Variable interpolation (from already-loaded values and os.environ)
BASE_URL=https://${HOST}:${PORT}/api
```

The parser returns a flat `dict[str, str]`. All values are strings — type casting happens in the Config methods.

**Key mapping from env vars:**

`DATABASE_HOST` → `database.host`. The rule:

1. Lowercase the entire name.
2. Split by `_`.
3. First segment becomes the namespace, rest joins with `_` as the key.
4. `database` + `host` → `database.host`
5. `redis_cache_ttl` → `redis.cache_ttl`

For `load_env_vars(prefix="APP")`:

1. Strip the prefix: `APP_DATABASE_HOST` → `DATABASE_HOST`
2. Then apply the same mapping: `database.host`

This is configurable: `load_env(path, depth=2)` would treat the first two underscore-separated segments as namespace levels. `CACHE_REDIS_HOST` with `depth=2` → `cache.redis.host`.

**TOML loading (Python 3.10 compatibility):**

```python
def load_toml(self, path: str):
    try:
        import tomllib  # Python 3.11+
    except ImportError:
        try:
            import tomli as tomllib  # Fallback for 3.10
        except ImportError:
            raise ImportError(
                "TOML support requires Python 3.11+ or the 'tomli' package. "
                "Install it with: pip install tomli"
            )
    with open(path, "rb") as f:
        data = tomllib.load(f)
    self.merge(data)
```

Same pattern for YAML — try `import yaml`, raise clear error if missing.

**Deep merge algorithm:**

```python
def _deep_merge(self, base: dict, override: dict) -> dict:
    for key, value in override.items():
        if key in base and isinstance(base[key], dict) and isinstance(value, dict):
            self._deep_merge(base[key], value)
        else:
            base[key] = value
    return base
```

Dicts are merged recursively. Non-dict values (strings, ints, lists) are replaced entirely.

### Public API (what `__init__.py` exports)

```python
from lucid_config.config import Config
from lucid_config.contract import ConfigContract
from lucid_config.exceptions import MissingConfigError, ConfigFrozenError

__all__ = [
    "Config",
    "ConfigContract",
    "MissingConfigError",
    "ConfigFrozenError",
]
```

### Exceptions

| Exception            | When                                                     |
|----------------------|----------------------------------------------------------|
| `MissingConfigError` | `.require()` finds missing keys. Message lists all of them. |
| `ConfigFrozenError`  | Mutation attempted after `.freeze()`.                    |

---

## pyproject.toml Specification

```toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "lucid-config"
version = "0.1.0"
description = "Cascading configuration with dot-notation access, env file support, and type casting."
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
    { name = "Sharik Shaikh", email = "shaikhsharik709@gmail.com" },
]
keywords = [
    "config", "configuration", "settings", "dotenv", "env",
    "environment", "toml", "yaml", "type-casting",
]
classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
    "Topic :: Software Development :: Libraries :: Python Modules",
    "Typing :: Typed",
]

[project.urls]
Homepage = "https://github.com/sharik709/lucid-config"
Documentation = "https://github.com/sharik709/lucid-config#readme"
Repository = "https://github.com/sharik709/lucid-config"
Issues = "https://github.com/sharik709/lucid-config/issues"

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.mypy]
strict = true

[project.optional-dependencies]
dev = ["pytest>=7.0", "mypy>=1.0", "ruff>=0.1"]
toml = ["tomli>=2.0;python_version<'3.11'"]
yaml = ["pyyaml>=6.0"]
all = ["tomli>=2.0;python_version<'3.11'", "pyyaml>=6.0"]
```

---

## Test Cases to Implement

### Core (get / set / has / forget / all)

- `get()` returns value for top-level key
- `get()` returns value for nested dot-notation key
- `get()` returns default when key is missing
- `get()` returns `None` when key is missing and no default
- `get()` on a namespace key returns the sub-dict
- `set()` creates a top-level key
- `set()` creates nested key with intermediate dicts
- `set()` overwrites existing value
- `has()` returns True for existing keys (including None and False values)
- `has()` returns False for missing keys
- `has()` works with dot notation
- `forget()` removes a key
- `forget()` removes a namespace and all children
- `forget()` on missing key is a no-op (no error)
- `all()` returns complete nested dict
- `all()` returns a copy, not a reference to internal store

### Dot Notation

- Single-level key: `get("name")`
- Two-level key: `get("database.host")`
- Three-level key: `get("cache.redis.host")`
- Deeply nested (5+ levels) works
- `set()` with dot notation creates missing intermediate dicts
- `has()` returns False if intermediate key is missing (e.g., `has("a.b.c")` when `a` doesn't exist)

### Type Casting

- `string()` casts int to string
- `string()` returns default for missing key
- `integer()` casts string `"5432"` to `5432`
- `integer()` returns int as-is
- `integer()` returns default for missing key
- `integer()` raises `ValueError` for non-numeric string
- `float()` casts string `"3.14"` to `3.14`
- `float()` casts int `3` to `3.0`
- `float()` returns default for missing key
- `boolean()` — True for: `True`, `"true"`, `"TRUE"`, `"True"`, `"1"`, `"yes"`, `"on"`
- `boolean()` — False for: `False`, `"false"`, `"FALSE"`, `"0"`, `"no"`, `"off"`, `""`, `None`
- `boolean()` returns default for missing key
- `list()` splits `"a,b,c"` into `["a", "b", "c"]`
- `list()` strips whitespace from items: `"a, b, c"` → `["a", "b", "c"]`
- `list()` returns existing list as-is
- `list()` uses custom delimiter: `list("key", delimiter="|")`
- `list()` returns default for missing key

### Env File Parser

- Parses `KEY=value`
- Parses `KEY="quoted value"` (strips quotes)
- Parses `KEY='single quoted'`
- Ignores blank lines
- Ignores comment lines (`# comment`)
- Handles `export KEY=value` (strips export)
- Handles inline comments: `KEY=value # comment`
- Does NOT treat `#` inside quotes as comment
- Handles multiline values in double quotes
- Handles multiline values in single quotes
- Variable interpolation: `${OTHER_KEY}` resolved from loaded values
- Variable interpolation: `${MISSING}` left as empty string or raises
- File not found raises `FileNotFoundError`
- Empty file produces empty dict
- Values are NOT written to `os.environ`

### Environment Variable Loading

- `load_env_vars()` loads all `os.environ` into store
- `load_env_vars(prefix="APP")` only loads `APP_*` vars
- Prefix is stripped from key: `APP_DEBUG` → `app.debug`
- Key mapping: `DATABASE_HOST` → `database.host`
- Case insensitive: `DATABASE_HOST` and `database_host` both map correctly
- Variables override previously loaded values

### Merge

- Merging adds new top-level keys
- Merging adds new nested keys without removing siblings
- Merging overwrites existing leaf values
- Merging two nested dicts recursively merges
- Merging a dict over a non-dict replaces it
- Merging a non-dict over a dict replaces it
- `load_dict()` deep-merges into store

### Source Priority

- Dict loaded first, env file second → env file wins
- Env file loaded first, dict loaded second → dict wins
- `.env` then `.env.local` → `.env.local` wins
- All sources then `set()` → `set()` wins
- `load_env_vars()` after `load_env()` → real env vars win
- `override=False` only fills keys not already present

### Freeze

- After `freeze()`, `set()` raises `ConfigFrozenError`
- After `freeze()`, `merge()` raises `ConfigFrozenError`
- After `freeze()`, `forget()` raises `ConfigFrozenError`
- After `freeze()`, `load_dict()` raises `ConfigFrozenError`
- After `freeze()`, `load_env()` raises `ConfigFrozenError`
- After `freeze()`, `get()` still works
- `is_frozen` returns `False` before freeze, `True` after

### Require

- `require()` with all keys present does not raise
- `require()` with one missing key raises `MissingConfigError`
- `require()` with multiple missing keys lists all of them in error message
- `require()` checks dot-notation keys

### File Loading

- `load_json()` loads a valid JSON file
- `load_json()` with missing file raises `FileNotFoundError`
- `load_toml()` loads a valid TOML file
- `load_toml()` without `tomllib` or `tomli` raises `ImportError` with install instruction
- `load_yaml()` loads a valid YAML file
- `load_yaml()` without `pyyaml` raises `ImportError` with install instruction
- Multiple file loads deep-merge correctly

### Edge Cases

- Empty Config() — `get()` returns default, `all()` returns `{}`
- Key with `None` value — `get()` returns `None`, `has()` returns `True`
- Key with `False` value — `get()` returns `False`, `has()` returns `True`
- Key with `0` value — `get()` returns `0`, `has()` returns `True`
- Key with empty string — `get()` returns `""`, `has()` returns `True`
- Key with dots in the VALUE (not key) — value preserved as-is
- Setting a value to a dict — subsequent dot-notation access works
- Non-string key in `.get()` raises `TypeError`
- Very long dot paths (10+ levels) work
- Unicode keys and values work
- `all()` doesn't expose internal store (mutation safety)

---

## Publishing to PyPI

```bash
pip install build twine
python -m build
twine upload --repository testpypi dist/*
pip install --index-url https://test.pypi.org/simple/ lucid-config
twine upload dist/*
```

---

## Part of the Lucid Ecosystem

Lucid Config is the third package in the **Lucid** ecosystem — the bridge between your environment and every other Lucid package.

Released:

- `lucid-pipeline` — Clean, expressive pipelines for multi-step data processing.
- `lucid-container` — Dependency injection container with autowiring.

Coming soon:

- `lucid-cache` — Multi-driver cache with a unified API.
- `lucid-events` — Event dispatcher with listeners and subscribers.

---

## License

MIT License. See [LICENSE](LICENSE) for details.
