Skip to content

modifiers and visibility

basedpython promotes commonly-used decorators and typing annotations into first-class keyword modifiers on classes, functions, and assignments. the surface keywords replace boilerplate decorator/annotation pairs at transpile time:

data class Point:
    x: int
    y: int

    override def __repr__(self) -> str:
        return f"({self.x}, {self.y})"

let ORIGIN = Point(0, 0)

transpiles to:

from dataclasses import dataclass
from typing import Final
from typing_extensions import override

@dataclass(slots=True)
class Point:
    x: int
    y: int

    @override
    def __repr__(self) -> str:
        return f"({self.x}, {self.y})"

ORIGIN: Final = Point(0, 0)

class modifiers

basedpython Python output
final class Foo @final + class Foo
abstract class Foo class Foo (keyword stripped, no decorator)
open class Foo class Foo (keyword stripped, no decorator)
data class Foo @dataclass(slots=True) + class Foo
frozen data class Foo @dataclass(frozen=True, slots=True) + class Foo
protocol Foo class Foo(Protocol) (base added)
sealed class Foo class Foo + Foo.__sealed_members__ = (...)

dataclass(slots=True) is new in python 3.10. below it, a data class is decorated with a runtime helper that makes it slotted the way that option would. the option makes a new class, and before 3.13 it leaves a zero-argument super() in the class's methods pointing at the class it replaced, where it raises a TypeError. so below 3.13 a data class whose body names super or __class__ is given the helper too

abstract is a marker for the type checker; it has no runtime decorator. open is the inverse of final — a marker that the class is intended to be subclassed. neither emits a runtime artefact

sealed declares a closed subclass hierarchy — see sealed classes

frozen data class rejects every attribute write after construction, so its fields are read-only and the class is inferred covariant in their types:

frozen data class D[T]:
    t: T

d: D[object] = D[int](t=1)   # ok — `D` is covariant in `T`

a plain data class is mutable, so it stays invariant. the same rule applies to any frozen dataclass-like class: @dataclass(frozen=True), a @dataclass_transform(frozen_default=True) base, a frozen pydantic model, and individual pydantic fields marked Field(frozen=True)

enum class is not a modifier but its own declaration form — see based enums

function modifiers

basedpython Python output
final def m @final def m
abstract def m @abstractmethod def m
override def m @override def m
static def m @staticmethod def m
class def m @classmethod def m

override is sourced from typing on 3.12+ and typing_extensions below. abstract def with no body is filled in with : raise NotImplementedError instead of the usual : ...

override, static and class are method modifiers: they say how a class dispatches one of its members, or that the member replaces one it inherits. a def that no class body owns is not a member of anything, so writing one on it is an error:

static def helper()   # error: `static` is only a modifier on a method

final, abstract and the visibility keywords read on a function wherever it is written

let / var / class-var / newtype

basedpython Python output
let MAX = 100 MAX: Final = 100
let x: int x: Final[int]
let x x: Final
var x = 1 x = 1
var x: int = 1 x: int = 1
var x: int x: int
class count = 0 count: ClassVar = 0 (inside a class)
class var count: int count: ClassVar[int]
class let ORIGIN: P = P() ORIGIN: Final[P] = P()
newtype UserId = int UserId = NewType("UserId", int)

let works at module and class scope. inside a class, class x = ... is the class-variable form (distinct from the regular let x = ... which is Final). the initializer may be omitted: let x: int declares a read-only attribute and a bare let x an uninitialized Final, both bound by a single later assignment. newtype introduces a distinct typing.NewType-backed type at module scope

a let or var written inside a block — an if body, a loop body, a try clause — belongs to that block and is gone after it. see block scoping

class var x: T is the same class variable with its type declared rather than read off a value, which is the only form a stub can write. class let x: T = v is the read-only one — python spells that Final, which in a class body cannot be reassigned through the class or an instance. a class let needs a value: __init__ binds an instance, so there is no later place for one to arrive. class is a class-body modifier — at module scope there is no class for the variable to belong to, and it is an error there

var

var is the mutable counterpart of let: it marks the declaration site of a variable and nothing else. the keyword is stripped at transpile time and the statement means exactly what the assignment under it means — no Final, and an untyped var puts no declared type on the name:

var count = 0
count = 1        # fine; `let count = 0` would reject this

var name: str = ""
name = 1         # error: `str` is declared

var works at module, class, and function scope, and composes with the modifier keywords (private var x = 1). unlike let, it may not be written bare: var x states neither a type nor a value, so there is nothing to declare and it is rejected — write var x: T or let x

var on an init(...) parameter is a different feature — the attribute shorthand described in init method

assignment modifiers

override, final override, and abstract may also appear on assignments and annotated assignments. the modifier keyword is stripped at transpile time:

basedpython Python output
override x = 1 x = 1
final override x = 1 x = 1
abstract x: T x: T

these are compile-time-only markers — they constrain how the symbol is checked but emit no runtime artefact

a bare final x = 1 (with no override) is not an assignment modifier: it would strip to a plain x = 1 and declare nothing final. outside a class body ty rejects it with final-on-variable and points you to let, which lowers to Final. inside a class body it is a plain attribute, matching let there, and is not flagged

export / public

basedpython infers __all__ from explicit visibility keywords:

export def public_api(): ...
public def also_exported(): ...
private def helper(): ...

transpiles to:

__all__ = ["public_api", "also_exported"]

def public_api(): ...
def also_exported(): ...
def _helper(): ...

export and public are aliases. each marked symbol is added to a synthesized __all__ list at module level. inside a class body they are stripped: a class member is not a module export

a module-level private strips the keyword and gives the symbol a leading underscore at the definition site and every same-module call site. a name that already has one keeps it — a second would make it a __name, which python name-mangles wherever a class body reads it. when the module already has the underscored name — its own _helper, or one the lowering binds — the symbol is emitted as _helper2 instead, so it never takes over a name the module has. it is excluded from __all__ even when no export/public declarations exist, and another module that imports it is reported by private-import

a module-level private works on a variable exactly as it does on a function, class or type alias — private count: int = 0 is emitted as _count, and so is every reference to it in the module. a parameter, local or class attribute that merely shares the name is a different binding and keeps it. reaching a private symbol as an attribute of its module from another one (helpers.count) is inaccessible-member, for the same reason importing it is private-import

the rename follows the symbol wherever the module binds it again — a def or class of the same name, an import (from m import count becomes from m import count as _count), an except ... as count, a match capture, and a global count in a function. a dotted import count.sub binds its top-level package, which no alias can keep under _count, so it is an error. a private symbol is left out of from m import *, and listing one in __all__ is private-export

private and protected

private and protected say who may reach a class member:

class Account:
    protected rate: float = 0.05
    init(private let balance: int)

    def interest(self) -> float:
        return self.balance * self.rate


class Savings(Account):
    def bonus(self) -> float:
        return self.rate * 2        # `protected` — a subclass may
  • private — only the declaring class's own body
  • protected — that, and the body of any subclass

reaching one from anywhere else is inaccessible-member:

def audit(account: Account) -> int:
    return account.balance  # error: `balance` is private to `Account`

the keywords work on every kind of member — a def, an attribute, a nested class, a property, an init parameter — and the member is written by the name it was declared with wherever it may be reached. what changes is the name it is emitted under, because that is the only enforcement python itself offers: private becomes __name, which python name-mangles per class, and protected becomes _name, the convention python uses for the same thing. a member no widened view can reach is also what safe variance rests on

the mangled name is spelled out at the access site — self.helper() becomes self._A__helper(). python mangles lexically, so a bare self.__helper written in a nested scope would name whatever class encloses it, and the full spelling names the same attribute from every one of them

a visibility keyword on a name python looks up verbatim — a dunder, or _ — is reported as having no effect. mangling applies only to a name with at most one trailing underscore, so renaming would change what the member is rather than who can reach it, and leaving it alone would make the modifier do nothing. the one dunder where the keyword says something is init, which is checked at the construction site instead

protected needs a class for the "and its subclasses" half to mean anything, so it is only a modifier on a class member:

protected def helper(): ...   # error: `protected` is only a modifier on a class member

a class variable takes one modifier besides its own class keyword — a visibility keyword, which composes with any declaration. the class reaches it through the class object as readily as through an instance:

class Counter:
    private class var made: int = 0
    protected class let LIMIT: int = 3

    @classmethod
    def total(cls) -> int:
        return cls.made + cls.LIMIT

a type alias declared in a class body takes private too, and is a private member like any other: the class's own body and its methods' signatures may name it, and nothing else may, a subclass included

class Shape:
    private type Size = int | float

    width: Size = 1

    def area(self) -> Size:
        return self.width ** 2

def measure(shape: Shape) -> Shape.Size: ...  # error: `Size` is private to `Shape`

it is emitted as type __Size, and every reference to it as the mangled _Shape__Size, so an annotation python keeps as a string still names it

visibility and inheritance

a visibility keyword decides the name the member is emitted under, so a member emitted under a different name from the one it inherits does not override it. it sits beside it, and the inherited member is still what a call finds — which is never what the declaration looks like it does, so it is invalid-override-visibility. that happens in two ways: a keyword declaring the member narrower than what it inherits, and a plain declaration over an inherited protected member

class A:
    def f(self) -> int:
        return 1

    protected def g(self) -> int:
        return 1


class B(A):
    private def f(self) -> int:  # error: `f` is public on `A`
        return 2

    def g(self) -> int:          # error: `g` is protected on `A`
        return 2

a private member is the exception on the other side. two private members that share a name are mangled apart, so neither overrides the other and neither has to match the other's signature, and a subclass is free to declare a public member under a name its base kept private:

class Base:
    private def step(self, n: int) -> int:
        return n


class Derived(Base):
    def step(self) -> str:      # a new member, not a replacement
        return "x"

a protected member keeps one name across the hierarchy, so it overrides like any other member and is checked like one

the emitted name can already belong to another member — the class's own, a base class's or a subclass's. at runtime one of the two would replace the other, while both still looked distinct, so that is invalid-visibility:

class A:
    def _m(self) -> int:
        return 1

    protected def m(self) -> int:  # error: `m` cannot be `protected` here: `_m` is taken
        return 2

the same holds for private beside a name python mangles to the same attribute (def __m in A is stored as _A__m), and for two private members of classes that share a name

names a member is looked up by

a visibility keyword renames every place a member is named, not only attribute accesses: a bare name in the class body (y = x + 1, @size.setter), a string in __slots__ or __match_args__, and a keyword in a class pattern (case Point(x=0)). each is spelled the way that position needs — __slots__ takes the class-body spelling python mangles, __match_args__ and a pattern keyword the one getattr reaches

where a visibility keyword cannot go

some names are how the class works at runtime, and renaming them would change what the class does rather than who can reach it. a visibility keyword on one is invalid-visibility:

  • a field of a dataclass-like class, a named tuple or a typed dict — the field's name is its constructor's keyword, or its key
  • an enum member — its name is how the enum looks it up
  • an abstract method — a private one is renamed per class, so no subclass could ever override it

a keyword that cannot act on its name is ineffective-private: a dunder, protected on a name that already starts with __ (python mangles it, which makes it private), and private in a class named only with underscores (python mangles nothing there)

a visibility keyword says who may reach a class member, or marks a module-level declaration as the module's own. a declaration inside a function body is a local, which nothing outside the function reaches anyway, so a keyword on one is an error

a leading underscore means unused

privacy is spelled with private and protected, so in a .by file a leading underscore says only that a name is unused. using one is used-underscore-name, a warning:

def _parse(text: str) -> int:
    return int(text)

_parse("1")  # warning: `_parse` is used, but its leading underscore marks it unused

drop the underscore, or say what the underscore stood for:

private def parse(text: str) -> int:
    return int(text)

parse("1")

on a class member the keyword to reach for is protected, not private: both draw a boundary, but private is emitted as __name, which python mangles, while protected is emitted as _name — the name python code already reads it by

every use of a name the .by source spelled is reported: a variable, a parameter, a function or class, a module of a .by package, an import from another .by module, a keyword argument naming a parameter or a dataclass field, the target of a del, and a member of a .by class — whether it is read through self, super(), an instance, the class, a class pattern keyword, an extension, an inline protocol, or a trailing lambda block

a name someone else spelled is left alone, because it is not yours to rename:

  • a name declared in python or a stub — a library's _internal, a named tuple's _asdict, sys._getframe
  • an override of a member a python or stub base class declares — _missing_ on an enum keeps the name Enum gave it
  • a member of a class whose bases cannot all be resolved, which may declare it
  • a name basedpython spells for you — a property's field storage, the _0 fields of an enum variant, the self._x = _x an init(let _x: int) stands for
  • _ and dunders, which python gives meanings of their own

an import alias spells a name of its own: from lib import helper as _helper is reported where _helper is used, and from helpers import _parse as parse only at the import. a .py file keeps python's convention and is never reported

python converted with by transpile --reverse keeps the names it was written with, underscores included, so a converted file reports every use of one. that is the question worth answering once per name: rename it, or say protected or private and keep the python spelling the keyword emits

inlay hints

a method that overrides a superclass member without saying so gets an override inlay hint, written where the modifier would go. the hint navigates to the superclass it overrides:

class B(A):
    ⟨override ⟩def f(self): ...

constructor-like methods (__init__, __new__, __post_init__, __init_subclass__) and name-mangled private methods are exempt, matching missing-override-decorator