Hedronite · Dev Lesson · Polyglot-Dev / Python · Sun 2026-08-23

Python's __repr__ and field(repr=False) — the credential that is not a key

The print is not the value. The log is a second process.

Lesson Class: Dev (Python language depth — object representation)
Language: __repr__ is the handle. field(repr=False) mutes a field. asdict does not.
Paired Ops: Key Vault census that refuses get_secret
Paired Cert: AZ-900 Entra / AuthN vs AuthZ / Conditional Access
Grounding: Ramalho Ch.11 String Representation · Lutz __repr__ / __str__
__repr__
Debugger, %r, and traceback locals all call it.
repr=False
Mutes one field. asdict still copies the payload.
The print
A constructor string that holds a password is a password.
The print is not the value. The log is a second process.

<!-- hal:authoritative:yaml -->

The print is not the value. The log is a second process.

§I — Frame

Thursday's Dev lesson named identity versus equality. is is a pointer. == is a value. Default __eq__ compares ids. A dataclass that compared MIG members by name treated a recycled last segment as the old object. Coin of that day: the instance that is not a name.

Today the Ops tool lists Key Vault secret properties and refuses get_secret. The language surface is one step later. Sometimes a value object must exist: a rotator fetched it, a test fixture holds it, a function return type carries it to the next call. The moment that object is interpolated into an f-string, a log record, a traceback, or a print in a notebook, the payload has left the process.

Lutz splits the two dunders the language already gave you. __str__ is for users. __repr__ is for developers. The default object repr is the type and the id (Lutz, String Representation: __repr__ and __str__). Ramalho's Vector2d chapter makes __repr__ the constructor call you could paste back into a prompt: Vector2d(3.0, 4.0), built from type(self).__name__ and {!r} so a subclass keeps its own name (Fluent Python 2ed, Ch.11, String Representation).

A secret is the object for which that Ramalho ideal is the wrong ideal. An eval-able repr is the leak. Coin the correction: the print that is not the value. Same trio coin as Ops: the credential that is not a key.

This lesson does not reopen secrets.compare_digest (05-27). It does not reopen contextvars (05-21). It does not reopen is versus == (08-20). The dunder under the knife is __repr__, and the dataclass flag that turns it off per field.

§II — Language idiom: two dunders, one leak

**__str__ is a sentence. __repr__ is a handle.**

print(obj) and f"{obj}" call __str__. If __str__ is missing, they fall through to __repr__. repr(obj), {obj!r}, the interactive prompt, and the %r logging style call __repr__ directly. A class that defines only __str__ still dumps the default type-plus-id when a debugger or a logger asks for !r. A class that defines only __repr__ is safe for both paths, which is why Ramalho implements __repr__ first and lets __str__ wait.

Lutz's default is the one you have already seen in a traceback: <__main__.Thing object at 0x...>. That default does not contain field values. A @dataclass replaces that default with a constructor-shaped string that contains every field. The convenience is the leak.

from dataclasses import dataclass

@dataclass
class VaultSecret:
    name: str
    value: str
    version: str

VaultSecret("db-password", "s3cret", "a1b2") prints as VaultSecret(name='db-password', value='s3cret', version='a1b2'). Every LOG.info("fetched %r", secret) writes the password. Every raise RuntimeError(f"bad secret {secret}") writes the password into the stack. Every Jupyter cell that ends on the name secret writes the password into the notebook JSON.

**field(repr=False) is the per-field mute.**

from dataclasses import dataclass, field

@dataclass
class VaultSecret:
    name: str
    version: str
    value: str = field(repr=False)

repr(VaultSecret("db-password", "a1b2", "s3cret")) is now VaultSecret(name='db-password', version='a1b2'). The value is still on the instance. secret.value still returns it. __repr__ no longer will. Ramalho's dataclass notes treat field() as the place flags live, next to init, default, and hash (Fluent Python 2ed, Post-init Processing / field flags). repr=False is the flag this lesson spends.

compare=False is a different flag. 08-20 already taught eq=False when a name must not be the equality key. Do not collapse the two. A secret can be equal to another secret by name and version, and still refuse to print its value.

**{!r} is how Ramalho builds an honest repr. It is also how a leak travels.**

Ramalho's Vector2d:

def __repr__(self):
    class_name = type(self).__name__
    return "{}({!r}, {!r})".format(class_name, *self)

{!r} calls repr on each coordinate so a string coordinate would come out quoted. Paste that shape onto a secret and you have quoted the password. The discipline for a credential object is the inverse: __repr__ may use {!r} on the name and the version. It must not receive the value as an argument at all.

def __repr__(self):
    cls = type(self).__name__
    return f"{cls}(name={self.name!r}, version={self.version!r})"

The value is on self. It is not in the format call. A future editor who adds , value={self.value!r} to "help debugging" has spent the coin.

**__str__ is not a hiding place.**

A class that implements __str__ as "<redacted>" and leaves dataclass __repr__ intact still leaks under !r. Loggers default to %s in some codebases and %r in others. logging.Logger.debug("%s", obj) uses __str__. logging.Logger.debug("%r", obj) uses __repr__. logging.Logger.exception("failed on %s", obj) uses __str__, then the traceback uses __repr__ for any frame local the debugger prints. Hide the value on __repr__. Let __str__ be a short human line that also omits it, or omit __str__ and inherit the safe __repr__.

**format, json, and the debugger are three more printers.**

format(obj, spec) calls __format__. The default __format__ with an empty spec falls through to __str__. A non-empty spec on an object that did not implement __format__ raises TypeError. Ramalho's Vector2d chapter adds __format__ so format(v, '.3f') prints coordinates. A secret must not grow a __format__ that accepts a spec meaning "show the value." There is no debug spec. There is secret.value, called on purpose, at a call site you can grep.

json.dumps(secret) fails on a dataclass unless you pass a default. dataclasses.asdict(secret) builds a dict that includes value, because repr=False does not hide the field from asdict or astuple. json.dumps(asdict(secret)) is a leak with a different name. If you must serialize, build the dict yourself: {"name": secret.name, "version": secret.version, "enabled": secret.enabled}. Never asdict a type that holds a payload.

pdb, breakpoint(), and a failing pytest --showlocals all print locals with __repr__. That is why the flag lives on __repr__ and not on a helper named safe_str that only the happy path calls.

Nested containers do not inherit the mute.

bundle = {"primary": secret, "note": "rotate Friday"}
repr(bundle)

dict.__repr__ calls repr on each value. The inner VaultSecret still omits value. The leak this time is the opposite shape: someone stored the raw string in the dict next to the object, {"primary": secret, "raw": secret.value}, and repr(bundle) prints the raw key. A container is only as quiet as its quietest policy. Keep the payload on the typed object. Do not copy it onto a parallel key.

list, tuple, and set behave the same. A set of VaultSecret also needs the 08-20 hash contract: if you freeze the dataclass, __hash__ will include value unless compare=False already dropped it from the equality tuple. compare=False on value is doing two jobs here. Keep it.

§III — Code worked example: the object the census may hold

The Ops lesson never calls get_secret. A rotator must. The rotator returns an object. That object is what this lesson types.

from dataclasses import dataclass, field


@dataclass
class VaultSecret:
    name: str
    version: str
    enabled: bool
    value: str = field(repr=False, compare=False)

    def __str__(self):
        state = "enabled" if self.enabled else "disabled"
        return f"{self.name}@{self.version} {state}"

Four field decisions, each argued.

value is repr=False so the generated __repr__ is VaultSecret(name='db-password', version='a1b2', enabled=True). compare=False so two fetches of the same name and version compare equal even if the rotator already wrote a new payload you have not re-fetched; equality is identity-of-handle, the 08-20 lesson in a different costume. enabled stays in the repr because the census prints enabled, and a disabled secret in a rotator return is a finding.

__str__ is the one-line status the cron already prints: db-password@a1b2 enabled. No payload. A maintainer who writes print(secret) gets the status line. A maintainer who writes print(f"{secret!r}") gets the dataclass repr without the value. Both paths hold.

The test is not "does it rotate." The test is "does a captured log contain the payload."

import io
import logging

def test_repr_omits_value():
    secret = VaultSecret("db-password", "a1b2", True, "s3cret")
    text = f"{secret!r} {secret}"
    assert "s3cret" not in text
    assert "db-password" in text


def test_logger_omits_value():
    secret = VaultSecret("db-password", "a1b2", True, "s3cret")
    buf = io.StringIO()
    log = logging.getLogger("vault-rotator")
    log.setLevel(logging.DEBUG)
    handler = logging.StreamHandler(buf)
    log.addHandler(handler)
    log.debug("fetched %r", secret)
    log.exception("failed on %s", secret)
    handler.flush()
    dumped = buf.getvalue()
    assert "s3cret" not in dumped

log.exception without an active exception still formats the message. In a real except block the traceback follows. Frame locals that a post-mortem printer walks will call __repr__ on secret. The repr=False flag is what that printer sees. A custom __repr__ that forgets the flag and interpolates self.value fails both tests.

A third test pins the generated repr shape so a future field addition is reviewed:

def test_repr_shape():
    secret = VaultSecret("db-password", "a1b2", True, "s3cret")
    assert repr(secret) == (
        "VaultSecret(name='db-password', version='a1b2', enabled=True)"
    )

Add a field named note and this test fails. The failure is the review. If note can hold a pasted password, it wants repr=False too.

§IV — Connection to today's Ops lesson

The Ops census constructs SecretClient(vault_url, DefaultAzureCredential()) and loops list_properties_of_secrets(). SecretProperties already behaves like a well-mannered object: its useful fields are metadata. The SDK's KeyVaultSecret is the one that carries .value. The moment the rotator calls get_secret, the payload is in process.

Wrap it. Do not pass KeyVaultSecret to the rest of the program. Construct VaultSecret(name=fetched.name, version=fetched.properties.version, enabled=fetched.properties.enabled, value=fetched.value) at the call site, and let every downstream function take VaultSecret. The SDK type is an import the rotator file owns. The rest of the tree never sees .value unless it asks.

The environment finding in Ops (AZURE_CLIENT_SECRET is set) is a cousin. A string in os.environ has no __repr__ you control. repr(os.environ) prints every value. Do not log os.environ. Do not log dict(os.environ). Log sorted(k for k in os.environ if k.startswith("AZURE_")) if you must name which Azure variables exist.

The SDK type is the other cousin. KeyVaultSecret.__repr__ is not under your flag. Treat it as radioactive. Convert at the boundary. A function annotated def rotate(secret: VaultSecret) that is passed a KeyVaultSecret is a type error you want mypy to catch. If you must accept both for a sprint, check hasattr(secret, "properties") and wrap before any log line.

DefaultAzureCredential itself reprs as a chain summary, not as a token. That is the walker behaving. Do not repr(credential.get_token("https://vault.azure.net/.default")). An AccessToken has .token. The same rule applies: wrap or do not print.

The credential that is not a key is the walker. The print that is not the value is the object the walker fetched. One coin, two surfaces.

§V — Prior-lesson reach

08-20 stays identity versus equality. Today's __repr__ does not change __eq__. A VaultSecret with compare=False on value still compares by name and version. Two objects can be equal and still refuse to print the thing that would prove it to a human. That is the point.

08-17 stays weakref.finalize and the signal that must still fire. A __repr__ that raises is a signal that fires at the wrong time: a logger that cannot format the record drops the line, or worse, the logging module prints its own fallback which may include str(args). Keep __repr__ total. No I/O. No Key Vault call. No chance of ClientAuthenticationError inside a format string.

08-14 stays match/case on the subject. You may match a VaultSecret on name and version. You must not match on value in a case that then interpolates the subject into a message. The subject is the object. The keys you bind are the ones that may be printed.

05-27 already taught secrets over random and compare_digest. That lesson stays the comparison story. This lesson is the print story. Do not mix them in a single function named safe_secret that tries to do both.

A second prior-art collision to name so it is not "discovered" mid-edit: 08-08 taught the attribute protocol, __set_name__, __getattr__ versus __getattribute__. A descriptor that returns a secret on access is a fetch, not a print. The leak still happens at the caller's __repr__, not inside __get__. Fix the object that is printed. Do not wrap every attribute lookup in a proxy that then prints itself.

08-02 taught Protocols as the contract without a registry. A SecretLike Protocol with name, version, and value is useful for the rotator signature. The Protocol does not carry repr=False. Structural typing describes the shape. The mute is an implementation flag on the concrete class. A Protocol cannot save you from repr(obj) on an unknown concrete type. The test in §III can.

Count the printers again, because the list is the lesson:

  1. print(obj) and f"{obj}" go through __str__, then __repr__.
  2. repr(obj), {obj!r}, the prompt, %r, and debugger locals go through __repr__.
  3. format(obj) goes through __format__, then __str__.
  4. dataclasses.asdict / astuple / json.dumps ignore repr=False.
  5. logging uses the percent-style you gave it, then a traceback printer uses __repr__.
  6. Containers call repr on members and also print any raw string you stored beside them.

Six doors. One flag covers 1, 2, 5, and the inner call in 6. Door 3 is refused by not implementing a value spec. Door 4 is refused by not calling asdict on a payload type. The Ops census covers a seventh door by never constructing the payload object at all.

__slots__ is not a mute. Ramalho's Ch.11 list of Vector2d jobs includes saving memory with __slots__ (What's New in This Chapter). Slots change layout. They do not change __repr__. A slotted dataclass with a value field still prints value= unless repr=False is set. Do not reach for slots because a secret felt heavy. Reach for the flag.

§VI — Closing

Dataclass repr is a constructor string. A constructor string that contains a password is a password. field(repr=False) is the mute. A custom __repr__ that never receives the value is the same mute written by hand. __str__ is not enough. {!r} is how an honest Vector2d speaks and how a dishonest secret leaks.

Examine the next LOG.debug("%r", ...) in the rotator. If the argument has a .value, the coin is already in the file.

The Vector2d repr in Ch.11 is the right shape for a coordinate and the wrong shape for a password. Use the shape. Change the arguments. Name and version may be {!r}. The payload may not. Lutz's default type-plus-id was already safe and already ugly. Dataclass convenience took the safety. Put it back with one flag.

Related