# ADR-032: `#[pyclass]` Python naming convention — clean names; `Py` prefix is Rust-internal **Status: accepted.** > Follows the ADR-026 precedent for converging divergent naming: > policy + mechanical enforcement + direct renames for the existing > population (no backward-compat alias shims), each rename in its own > task. ## Context The PyO3 layer (the `degenbot_rs` cdylib, Python module `degenbot._ffi`) has two coexisting conventions for registered `#[pyclass]` types: - **Clean Python-facing names**: `ArbitrageEngine`, `AlloyProvider`, `Contract`, `AsyncContract`, `AnvilFork`, `CancelHandle`, `BlockStream`, `PathIterator`, `LogFilter`, `AlloySubscription`, `Erc20TokenRow`, the `*Row` / `*RowInput` types, and the typed exceptions. - **`Py`-prefixed visible names**: the grandfather list in the Decision below (27 names at census time). At adoption time the consumer layer split policy: `degenbot/dispatch/__init__.py` stripped the prefix via alias re-exports (stable companion names for driver code), while `degenbot/bot/__init__.py` *recommended* the prefixed names (`from degenbot.bot import Bot, PyBot, PyBotIo`). New types had been picking a convention by drift, not by policy. (The post-adoption VD5MD5 rename below removed the prefixed companion names; the dispatch aliases now map clean FFI names.) ## Decision 1. New `#[pyclass]` types MUST use clean Python-facing names: leading capital, no `Py` prefix. (`Py`-prefixed *Rust* type names remain an acceptable Rust-internal convention; only the Python-facing `name=` registration is constrained here. The `*_py` pyfunction suffix stays where it marks the FFI seam — the companion packages strip it for driver code.) 2. A `Py`-prefixed Python-visible name is a Rust-internal seam type and may exist only on the grandfather list. The prefix is never extended. 3. **Grandfather list** (runtime census 2026-08-17, 27 names): `PyAavePriceOracle`, `PyBalanceVectorView`, `PyBot`, `PyBotIo`, `PyChainlinkPriceFeed`, `PyCollateralPositionData`, `PyConcentratedLiquidityView`, `PyDatabasePositionQuery`, `PyDatabaseSnapshot`, `PyDebtPositionData`, `PyDexIdentity`, `PyDispatchCandidate`, `PyDispatchOutcome`, `PyDispatcher`, `PyDivergentPool`, `PyErc20Token`, `PyLiquidityPool`, `PyPayloadComposer`, `PyPool`, `PyReservePairView`, `PySimulateContext`, `PySolveResult`, `PySubscription`, `PySubmitCandidate`, `PyTxParams`, `PyTxSigner`, `PyUserPositionSummary`. 4. **Retirement**: task `VD5MD5` (epic `C7D2CH`) renames the grandfather population to clean names per the ADR-026 precedent (direct rename, all import sites updated in-repo, no backward-compat aliases). Each rename removes the name from this list and from the gate test's grandfather set in the same commit. 5. **Enforcement**: `tests/ffi/test_ffi_registration_surface.py` walks the runtime `degenbot._ffi` module tree and asserts, in both directions, that the set of registered `Py`-prefixed class names equals the grandfather list. A new `Py`-prefixed registration — or a dead list entry — fails the test. ## Consequences - The `VD5MD5` rename is mechanical and auditable: the list IS the scope. - Companion packages (`degenbot.dispatch`, `degenbot.bot`) keep working without alias churn until the rename lands; after it, prefix-stripping aliases become the vestige to remove. - The gate is a runtime test over the built extension: it runs in the same `tests/ffi` suite as the registration↔stub drift gate (task `DSWX6Z`), so a renamed class that skips its stub update fails both gates. ## Post-adoption: VD5MD5 rename complete (2026-08-17) The grandfather list is **empty**: all 27 census names were renamed (task `VD5MD5`, direct rename, no backward-compat aliases, per the ADR-026 precedent). Mapping: | Python name before | Python name after | Note | | --- | --- | --- | | `PyBot` | `RustBot` | the Python session class `degenbot.bot.Bot` keeps the clean name; the Rust engine handle is origin-descriptive | | `PyBotIo` | `RustBotIo` | same collision reasoning | | `PyErc20Token` | `RustErc20Token` | the Python model class `degenbot.erc20.Erc20Token` keeps the clean name | | `PySubscription` | `PoolStateSubscription` | distinct from the Python `provider.Subscription` wrapper; role-specific name (pool-state-change subscription handle) | | `PyDatabaseSnapshot` | `RustDatabaseSnapshot` | the Python `uniswap.{v3,v4}_snapshot.DatabaseSnapshot` sources keep the clean name | | `PyDatabasePositionQuery` | `RustDatabasePositionQuery` | the Python `aave.analysis.orchestrator.DatabasePositionQuery` shell keeps the clean name | | `PyLiquidityPool` | `LiquidityPool` | mechanical | | `PyPool` | `Pool` | mechanical (structural handle mirroring `degenbot_pools::Pool`) | | `PyDexIdentity` | `DexIdentity` | mechanical; `degenbot.types` already exported this alias — now a direct import | | `PyDispatcher`, `PyTxSigner`, `PyDivergentPool`, `PySubmitCandidate`, `PyTxParams` | `Dispatcher`, `TxSigner`, `DivergentPool`, `SubmitCandidate`, `TxParams` | mechanical; companion aliases in `degenbot.dispatch` became direct imports | | `PySimulateContext`, `PyDispatchCandidate`, `PyDispatchOutcome`, `PySolveResult`, `PyPayloadComposer` | `SimulateContext`, `DispatchCandidate`, `DispatchOutcome`, `SolveResult`, `PayloadComposer` | mechanical | | `PyChainlinkPriceFeed`, `PyAavePriceOracle` | `ChainlinkPriceFeed`, `AavePriceOracle` | mechanical; `degenbot.chainlink` / `degenbot.aave` alias imports became direct | | `PyUserPositionSummary`, `PyCollateralPositionData`, `PyDebtPositionData` | `UserPositionSummary`, `CollateralPositionData`, `DebtPositionData` | mechanical (db analysis trio) | | `PyReservePairView`, `PyConcentratedLiquidityView`, `PyBalanceVectorView` | `ReservePairView`, `ConcentratedLiquidityView`, `BalanceVectorView` | mechanical (state views) | Mechanically: the 15 pyclasses with explicit `name = "PyX"` attributes took the new value; the 12 struct-default pyclasses gained an explicit `name = "X"` (Rust struct names keep the `Py` qualifier — Rust-internal, per D1). All Python import sites, companion alias imports (collapsed to direct imports), `.pyi` stubs, tests, and examples were updated in the same change; Python-visible repr/error-message strings in Rust were aligned. ## Considered alternatives: keeping `Py` on the colliding names Polars names its raw PyO3 layer `PySeries` / `PyDataFrame`, and that pattern was the original precedent for this repo's `PyBot`. It fits Polars because those names are **internal**: `polars.polars` is not part of the documented API, no example imports it, and the public surface is the clean Python class (`polars.Series`). There, `Py` works as a keep-out marker for internals, and "wrapping mechanism" is a fair label for things nobody is meant to touch directly. degenbot inverts that premise: `degenbot._ffi` is a **documented, first-class seam** — `.pyi`-stubbed, drift-gated against its registrations (task `DSWX6Z`), exercised by the README code-block (sybil) gate, and imported directly by companion modules and shipped examples. The seam's names are public surface, so: - `Py` is zero-information there: every name in the module is PyO3-managed, so the prefix disambiguates nothing and merely repeats the module path's fact. - the colliding names (`PyBot` vs. `degenbot.bot.Bot`, `PyErc20Token` vs. `degenbot.erc20.Erc20Token`, ...) collide on **layer**, not mechanism — Python driver class vs. Rust engine handle — so the informative qualifier is the layer origin: `RustBot` reads as "the Rust engine handle" at exactly the point where the reader crosses the boundary. - Rust is the sole core implementation by architectural commitment (Rust is the engine; Python is a driver shell), so `Rust` encodes a design promise rather than an implementation detail. **Rejected: keep `PyBot` etc. (Polars precedent).** The precedent depends on the raw layer staying hidden; degenbot's seam is a public, gated, documented surface, and the prefix there would be noise. **Rejected: uniform `Rust*` prefix on every handle.** Over-prefixing — clean names remain the default (D1); the prefix is reserved for names where the clean form is already occupied by a Python type. The Mapping table above lists each such case and why. ## Post-adoption, round 2 (2026-08-17): the Rust prefix is retired The five collision names kept their origin prefix under the D3 escape hatch. The fork decision retires the prefix entirely: the disambiguator is the module path, not the prefix. | Python name before | after | collision resolved by | | --- | --- | --- | | `RustBot` | `Bot` (on `degenbot._ffi`) | module path vs. `degenbot.bot.Bot` | | `RustBotIo` | `BotIo` (on `degenbot._ffi`) | no driver-side homonym | | `RustErc20Token` | `Erc20Token` (on `degenbot._ffi`) | module path vs. `degenbot.erc20.Erc20Token` | | `RustDatabaseSnapshot` | `DatabaseSnapshot` (on `degenbot._ffi.db`) | module path vs. the `uniswap.{v3,v4}_snapshot` shells | | `RustDatabasePositionQuery` | `DatabasePositionQuery` (on `degenbot._ffi.db`) | module path vs. `aave.analysis.orchestrator` | D3's collision escape (origin prefix on collision) is superseded. First-party code imports the handles directly from `_ffi` (the ADR-013 amendment); files that also bind a same-named Python class alias the import locally (e.g. `_TokenHandle`, `_EngineSnapshot`). The naming gate now asserts both the `Py*` and the `Rust*` registered-name sets are empty: no origin prefix of any kind, full stop.