DEV Community

Avery Lin
Avery Lin

Posted on

Seed Config Docs From Dataclass Fields; Humans Still Classify Secrets

Config documentation fails when a generated table invents production defaults or treats every environment variable as a public example. A typed settings class already encodes names, types, and prefixes that a compiler can extract without guessing runtime values. Models may draft short field descriptions from those extracted names, while humans must still classify secrets, missing-key failure, and blast radius. The workflow below keeps that split explicit so a README cannot silently promote a database password into a copy-paste snippet.

This article proposes an unexecuted, stdlib-only extractor for Python dataclasses used as settings objects. It does not claim production incident rates, vendor benchmarks, or traffic numbers, because those figures were not supplied for review. The method is useful only when configuration already lives in annotated fields rather than stringly typed dictionaries. Teams that construct environment names at runtime should treat the catalog as a lower bound, not as a complete inventory.

What a compiler can see, and what it cannot

A settings dataclass usually exposes field names, type annotations, default sentinels, and sometimes an environment prefix. Those facts are testable: if a field appears in source, the catalog row must exist, and a unit test can fail the build when it does not. Descriptions inferred from names are not testable in the same way, because several honest sentences can match one identifier. Secret classification is also not testable from the type system, because str does not mean “public example value.”

Missing-key behavior is a product decision rather than a parsing result. Some services must refuse to boot when a signing key is absent, while others may start with a cache disabled and a degraded path. Outage copy, customer-visible status text, and pager ownership cannot be compiled from annotations. Those cells stay blank until a human signs them, even if a model drafts nearby prose.

Lane table for each settings field

Treat every extracted field as a row with two lane groups. The compile lane is filled by the extractor. The signature lane stays empty until review. Proposed columns follow; they are a template, not a measured industry standard.

Column Lane Source of truth Allowed into a model prompt
key compile field name plus env prefix yes, if names are not themselves classified
python_type compile annotation string yes
has_default compile presence of a default or factory yes
default_repr compile, redacted public defaults only never for secrets
description model draft human-editable after draft yes, names and types only
secret_class human sign threat model no
missing_key human sign runbook and SLOs no
blast_radius human sign ownership map no

The table is the artifact’s contract. A model that fills secret_class or missing_key is out of policy, even when the sentence sounds confident. Public example values belong only on rows that a reviewer already marked non-secret.

Artifact: extract rows, then refuse unsigned secrets

The extractor walks a dataclass, emits JSON rows, and leaves signature cells null. A second function renders Markdown with placeholders that continuous integration can grep. Label this as sample code for a proposed workflow, not as a harvested production module.

# settings_catalog.py — proposed extractor, stdlib only
from __future__ import annotations

import dataclasses
import json
from typing import Any, get_type_hints


@dataclasses.dataclass(frozen=True)
class CatalogRow:
    key: str
    python_type: str
    has_default: bool
    default_repr: str | None
    description: str | None
    secret_class: str | None
    missing_key: str | None
    blast_radius: str | None


def _annotation_name(hint: Any) -> str:
    return getattr(hint, "__name__", str(hint))


def extract_settings_catalog(
    cls: type,
    env_prefix: str = "",
    redact_defaults: bool = True,
) -> list[dict[str, Any]]:
    if not dataclasses.is_dataclass(cls):
        raise TypeError(f"{cls!r} is not a dataclass")
    hints = get_type_hints(cls)
    rows: list[CatalogRow] = []
    for field in dataclasses.fields(cls):
        key = f"{env_prefix}{field.name}".upper() if env_prefix else field.name
        has_default = field.default is not dataclasses.MISSING or (
            field.default_factory is not dataclasses.MISSING  # type: ignore[attr-defined]
        )
        default_repr = None
        if has_default and not redact_defaults and field.default is not dataclasses.MISSING:
            default_repr = repr(field.default)
        rows.append(
            CatalogRow(
                key=key,
                python_type=_annotation_name(hints.get(field.name, field.type)),
                has_default=has_default,
                default_repr=default_repr,
                description=None,
                secret_class=None,
                missing_key=None,
                blast_radius=None,
            )
        )
    return [dataclasses.asdict(row) for row in rows]


def assert_signature_complete(rows: list[dict[str, Any]]) -> None:
    unsigned = [
        row["key"]
        for row in rows
        if row.get("secret_class") in (None, "")
        or row.get("missing_key") in (None, "")
        or row.get("blast_radius") in (None, "")
    ]
    if unsigned:
        raise AssertionError("unsigned config keys: " + ", ".join(unsigned))
Enter fullscreen mode Exit fullscreen mode

A small settings object makes the compile lane visible. Keep secret-looking defaults out of the extract call so the JSON file cannot leak a password into pull-request diffs.

# example_settings.py — sample input only
from dataclasses import dataclass

@dataclass
class ServiceSettings:
    database_url: str
    signing_key: str
    cache_ttl_seconds: int = 60
    public_base_url: str = "https://api.example.test"
Enter fullscreen mode Exit fullscreen mode
python - <<'PY'
from example_settings import ServiceSettings
from settings_catalog import extract_settings_catalog
import json
print(json.dumps(extract_settings_catalog(ServiceSettings, env_prefix="APP_"), indent=2))
PY
Enter fullscreen mode Exit fullscreen mode

Expected compile-lane shape, with signature cells still null:

[
  {
    "key": "APP_DATABASE_URL",
    "python_type": "str",
    "has_default": false,
    "default_repr": null,
    "description": null,
    "secret_class": null,
    "missing_key": null,
    "blast_radius": null
  }
]
Enter fullscreen mode Exit fullscreen mode

Render Markdown only after a reviewed sidecar file supplies the three human columns. The renderer should refuse to print example values for any row whose secret_class is not public.

# render_catalog.py — proposed renderer
from __future__ import annotations

SIGNED = ("secret_class", "missing_key", "blast_radius")


def render_markdown(rows: list[dict], signed: dict[str, dict]) -> str:
    lines = [
        "| Key | Type | Default in source | Description | Secret class | Missing key | Blast radius |",
        "| --- | --- | --- | --- | --- | --- | --- |",
    ]
    for row in rows:
        extra = signed.get(row["key"], {})
        merged = {**row, **extra}
        for col in SIGNED:
            if not merged.get(col):
                raise ValueError(f"{row['key']} missing signed column {col}")
        default_cell = ""
        if merged["secret_class"] == "public" and merged.get("default_repr"):
            default_cell = merged["default_repr"]
        elif merged["has_default"]:
            default_cell = "present (redacted)"
        desc = merged.get("description") or "_unsigned description_"
        lines.append(
            f"| `{merged['key']}` | `{merged['python_type']}` | {default_cell} | "
            f"{desc} | {merged['secret_class']} | {merged['missing_key']} | {merged['blast_radius']} |"
        )
    return "\n".join(lines) + "\n"
Enter fullscreen mode Exit fullscreen mode

Numbered workflow

  1. Freeze the settings type in review, including the environment prefix and which modules are allowed to read os.environ directly. Direct reads outside the dataclass are catalog holes and should fail a grep-based test.
  2. Run extract_settings_catalog in continuous integration and commit the JSON as a generated artifact next to a human sidecar, not as a chat transcript. The generated file should be deterministic so diffs stay small when a field is renamed.
  3. Optionally draft the description column from names and types only. Do not include live values, .env files, or cluster secrets in that prompt. A hosted editor is enough if it cannot see the sidecar’s secret classes.
  4. Require a reviewer to fill secret_class, missing_key, and blast_radius in the sidecar. Suggested vocabularies are public | confidential | secret and refuse_boot | degrade | ignore, but teams should replace those labels with their own incident taxonomy.
  5. Render Markdown only through render_catalog, then publish that file. Hand-edited HTML in the docs site is a third source of truth and will drift.
  6. Add a unit test that new dataclass fields cannot merge until the sidecar has matching keys. Unsigned rows must break the build rather than shipping with placeholder prose.
# test_settings_catalog.py — proposed contract test
from example_settings import ServiceSettings
from settings_catalog import extract_settings_catalog, assert_signature_complete


def test_every_field_has_a_row():
    rows = extract_settings_catalog(ServiceSettings, env_prefix="APP_")
    keys = {row["key"] for row in rows}
    assert keys == {
        "APP_DATABASE_URL",
        "APP_SIGNING_KEY",
        "APP_CACHE_TTL_SECONDS",
        "APP_PUBLIC_BASE_URL",
    }


def test_unsigned_rows_fail_the_build():
    rows = extract_settings_catalog(ServiceSettings, env_prefix="APP_")
    try:
        assert_signature_complete(rows)
    except AssertionError as exc:
        assert "APP_SIGNING_KEY" in str(exc)
    else:
        raise AssertionError("unsigned catalog must not pass")
Enter fullscreen mode Exit fullscreen mode
python -m unittest test_settings_catalog.py
Enter fullscreen mode Exit fullscreen mode

Where a free model and a free server fit

Description drafting is the only model-shaped step in this workflow, and it still needs a human edit pass. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode’s free model access and free server option can run the extractor and host a draft of the description column while the signed sidecar stays off the prompt. That split is the product-relevant part; this article does not assert model names, quotas, hardware, duration, or quality rankings, because those details were not provided as verified facts.

Keep the prompt to compile-lane JSON plus a one-paragraph style guide for descriptions. Reject any completion that writes secret_class, invents a production default, or emits an example connection string. If field names themselves are sensitive, skip the model step and write descriptions on the workstation that already holds the repository.

Failure analysis the tests will not catch

Dynamically built keys such as APP_TENANT_{id}_TOKEN never appear on the dataclass and therefore never appear in the catalog. Feature flags loaded from a remote service have the same gap. Operators who export secrets as shell functions rather than environment variables will also drift from the extracted names. Those cases need a second inventory, not a more aggressive language model.

Default factories that read files at import time can still leak into traces if logging prints the settings object. The extractor redacts default_repr by default, but application logs are a separate channel. Reviewers should forbid __repr__ on settings types that hold credentials, independent of documentation generation.

Limitations and who should not use this

The method assumes one dataclass, or a small set of dataclasses, is the only legal reader of process environment. Polyglot services with duplicated keys in YAML, Helm, and Terraform need a cross-language inventory that this script does not provide. It also assumes reviewers will actually sign three columns; an empty sidecar that is force-merged makes the Markdown renderer a false control.

Do not use this approach when configuration is entirely dynamic, when field names are classified, or when legal copy such as retention periods must be drafted by counsel rather than by engineering review. Do not use a model to invent missing-key behavior for payment, identity, or medical systems. The compiler can list the knobs; it cannot accept residual risk on behalf of the operator.

The durable output is a catalog whose compile columns change only when code changes, and whose signature columns change only when a human accepts operational meaning. Descriptions may be drafted, edited, or discarded. Secrets, boot-fail policy, and blast radius remain unsigned until a reviewer writes them, which is the entire point of keeping documentation generation on a short leash.

Top comments (0)