by CLI reference¶
basedpython ships with two executables: by and buff. by is the basedpython driver — an extension of ty and includes the
type-checker, the transpiler, and a few project-level commands
buff is the basedpython version of ruff
in addition to the cli provided by ty, by includes:
| command | what it does |
|---|---|
run |
transpile and run a module with python -m <module> |
build |
transpile every .by/.byi file and write to build/ |
compile |
compile .by files to native CPython extension modules |
generate-api-file |
write a public-api lockfile (see api-lock) |
transpile |
transpile a single file to stdout (reads stdin if no file) |
by run¶
by run MODULE [ARGS...] # transpile + run with `python -m MODULE`
by run # run the configured entry point
by run MODULE --min-version 3.12 # target a specific runtime python version
equivalent to by build && python -m MODULE, but only transpiles the
modules required to import MODULE
the module can be left out when the project configures an entry point:
by run then runs app.cli. a module named on the command line always wins,
so by run other still runs other. the first positional argument is always
the module — to reach the entry point's own arguments, name it: by run app.cli --name asdf
everything after MODULE is forwarded to the program as sys.argv[1:],
including options — by run main --name asdf passes --name asdf on. the one
exception is a leading -h / --help, which prints by run's own help; write
by run main -- --help to reach the program's. when the program's entry point
is a main function, those arguments are parsed
into its parameters
the project is type-checked first, and a program with check errors is not run — the checker's verdict and the runtime must not diverge. warnings don't block; a rule can be downgraded in configuration where its error is unwanted
the interpreter is the project's own: the environment by check resolves
imports against — the environment.python the project configures, else an
activated virtual environment, a conda environment, or a .venv beside the
project's pyproject.toml. --python overrides it for one run, and PYTHON
stands in only where the project has no environment of its own, above the bare
python3 discovery falls back to. a third-party package lives in that
environment's site-packages and nowhere else, so running anywhere else fails
on an import the checker had no complaint about. all of that resolves against
the project root rather than the working directory, so by run in a
subdirectory is still this project
--launcher PATH starts the program through another program — a debugger that
has to be the process the program runs in, say — without choosing the
interpreter for it. discovery runs exactly as above, and the program starts as
PATH <interpreter> <runner> MODULE [ARGS...], so the launcher is handed the
interpreter by run chose and runs once, for the program: the version probe is
still made against the interpreter itself
--in-build runs the program in the tree by run staged rather than in the
directory it was typed in. the default is right for an application: a relative
path on the command line, and anything the program reads or writes beside the
project, mean what they meant when they were written. but the project is python
only in the staged tree, which is reachable through sys.path alone — and
sys.path serves an import and nothing else. so a program whose arguments are
the project's own files could not name one:
the only tests/test_calc in the working directory is a .by. with
--in-build the whole project is there — every transpiled module at its place
in the module tree, and beside them every file the build carries over unchanged,
pyproject.toml included — so the path resolves, the tool finds its own
configuration where it always was, and it reports against a tree laid out like
the project: tests/test_calc.py::TestGroup::test_add, whatever the source
layout
it is a flag rather than something inferred because only the caller knows which of the two directories they mean, and the program's name does not say
the emitted code targets that interpreter's version by default. an explicit
--min-version wins, but must not exceed the interpreter — by run refuses
rather than emit code the interpreter cannot parse. an interpreter older than
the version the project declares is refused too: the source may use syntax that
python has no lowering for, and the failure would land as a SyntaxError inside
generated code. passing --min-version for the interpreter's own version builds
for it instead
the program runs in the directory by run was invoked from, the way python -m
does. the transpiled tree lives in a temporary directory, which python puts at
the head of sys.path — so a relative path on the command line, and anything
the program reads or writes beside the project, resolve where they were written
to
hidden directories (.claude, .git, .venv, …) and build outputs are never
treated as project source: they are neither checked nor transpiled, by run
and build alike
by build¶
by build # transpile every .by/.byi the project claims
by build --min-version 3.12 # target a specific runtime python version
by build walks the project's own file set — the one by check walks — so
src.exclude and the ignore files it honours apply here too, and the two halves
of the toolchain never disagree about which files are in the project. hidden
directories (.claude, .git, .venv, …) and build outputs are skipped
without --min-version the emit target is the project's configured python
version (environment.python-version, else the requires-python lower bound),
so the checker and the emitter agree about which python this project targets
a file that fails to parse fails the build, but only for itself: every other module is still written. a code generator and a test runner are exactly what you reach for when one file is mid-edit
artifacts and exit status answer different questions. everything that could be
emitted is emitted, sourcemap and package markers included; the exit status says
whether anything was reported. so a build that prints an error exits 1 while
still leaving a usable build/, and by build && pytest build/tests runs the tests
only against a tree the checker had nothing to say about
writes the transpiled python to the project root's build/ — wherever in the
project it is run, as by compile does too; --out DIR names another directory,
relative to where you typed it — mirroring the module tree. a
src-layout project's src/package_name/main.by is the module
package_name.main, so it lands at build/package_name/main.py — build/ is a
directory you can put on sys.path as it stands, and run.main names a module
the same way an import does. by generate-api-file does not read build/ as
first-party source — it is regenerated on every build. by check still walks it,
though, so a project that keeps its output beside its sources will see
diagnostics reported against generated python; exclude it with
src.exclude = ["build"] if that is in the way
alongside the python it writes build/_by_sourcemap.py, mapping each generated
line back to the .by line it came from, with a digest of both files so a tool
reading it can tell whether it still describes what is on disk — see
sourcemaps
by compile¶
status: early. integer, float and bool arithmetic, control flow, and calls within a module compile natively. everything else falls back to the interpreted definition — see native compilation
by compile # every .by file under the project root → build/
by compile hot.by # one file
by compile -o native hot.by # a different output directory
by compile --verbose # report every function left interpreted, and why
by compile --emit-c-only # write the generated C without compiling it
by compile --no-any # refuse to leave a gradual-typed function interpreted
by compile --require-native # refuse to leave *any* function interpreted
by compile --licence-recheck # re-ask, at runtime, every lookup a call skipped
by compile --no-verify-install # leave out the import-time install check
the output directory mirrors the module tree, the way by build's does: the
package member pkg/sub/dup.py lands at build/pkg/sub/dup.cpython-313-darwin.so,
and the package pkg/sub/__init__.py at
build/pkg/sub/__init__.cpython-313-darwin.so — so build/ can go on sys.path as
it stands and every module imports under the dotted name it was compiled as
by compile writes everything by build writes, and the extensions as well.
a compiled module is not a program on its own: it reads its templates and
fixtures relative to itself, and imports the modules beside it. so the tree holds
the whole project as importable python — every .by transpiled, every
hand-written .py, py.typed, the data files, the sourcemap — with a native
extension beside each module that was compiled. python's finder prefers an
extension to source, so those modules load natively and the rest are interpreted,
which is what makes naming files a speed decision rather than a correctness one:
naming files decides what is compiled, not what is written: the whole project
is checked and transpiled either way, so by compile hot.by costs what a
by build costs plus the one module's native compile. that is the price of the
tree being a program rather than a heap.
build/ is a mirror rather than a pile. what the previous run wrote and this one
did not is taken back, which matters most for extensions: python prefers one to
source, so an extension left behind by a deleted or renamed module would go on
shadowing the .py written in its place. the artefacts therefore describe the
last invocation — by compile a.by then by compile b.by leaves b native and
a interpreted, and --emit-c-only, which stops before the C compiler, leaves a
tree with no extensions at all. in every case each module still imports, so this
costs speed rather than correctness; by compile with no arguments compiles the
whole project.
the directory is shared with by build on purpose — the same tree, built two
ways — and the two take each other's output back accordingly. it is also a name
setuptools and python -m build use, so a project using both writes them into one
directory; nothing is destroyed, because the manifest only ever takes back what
by itself wrote.
a directory holding a .by-manifest is a build output, whoever wrote it, and is
never read as source or carried into another tree — a project that builds into
two directories would otherwise put a copy of each inside the other. that is by
the marker rather than by the directory's name, because --out can say anything.
a project that deliberately ships a directory containing one has to rename the
marker or keep the directory outside its source roots.
upgrading from a version that wrote
out/: nothing migrates it, and nothing reads it any more. delete it — a staleout/holds importable python that no build refreshes.
--no-any buys no speed on its own — it is a predictability contract. a
gradual type is the commonest reason a function silently stays interpreted, and a
decline is invisible unless you look for it, so a module that means to be fully
compiled can say so and be held to it:
$ by compile app.by --no-any
error: could not compile app.by
Caused by:
`no-any` is on and 1 function(s) could not be compiled because a type was gradual:
loose: a gradual type has no known representation
--require-native asks a different, stricter question. --no-any asks is this
module fully typed; --require-native asks does this module compile
entirely, and so also fails on a type that is perfectly precise but that the
compiler does not represent yet:
$ by compile app.by --require-native
error: could not compile app.by
Caused by:
`require-native` is on and 1 function(s) were left interpreted:
describe: `list[int]` has no native representation yet
--licence-recheck is for chasing a wrong answer rather than for shipping. the
compiler licenses a call to go straight to a compiled body wherever nothing can
have put something else under the name — a class python can neither subclass nor
rebind a method on, a receiver whose type still matches the one the licence was
taken against. that decision is a claim nothing checks, and one that is wrong is
a wrong answer with nothing to report it: an override that stops being seen, a
rebinding nothing notices. under this flag each of those calls does the lookup it
was licensed to skip, compares where it lands with the body it is about to run,
and stops the process when the two disagree:
$ by compile app.py --licence-recheck -o out && python -c 'import app; app.go()'
by: licence re-check failed on Shape.area: the receiver is not this class (app.Square)
by: the compiled call was licensed to skip this lookup, and the lookup no longer agrees
it costs the lookup each licence exists to avoid, so it is slower than an ordinary build and slower than no compilation at all in the places licences do the most work. a program that passes under it answers exactly what it answers without it — a re-check that agrees changes nothing
a function the compiler cannot lower natively is not an error: the module's transpiled python is embedded in the extension and executed at import, so declined functions still exist and module-level code still runs. the natively compiled functions are installed over the top
$ by compile hot.by --verbose
hot.by -> build/hot.cpython-313-darwin.so
declined describe: `list[int]` has no native representation yet
compiled 1 module(s)
1 function(s) left to the interpreted definition
a class left interpreted is harder to see, because it answers exactly as the
compiled one would. so the emitted module checks its own classes once at import
and raises ImportError naming any that did not install as the compiler meant
them to, and BY_INSTALL_CENSUS names a file it writes the whole verdict into:
$ BY_INSTALL_CENSUS=census.tsv python -c 'import hot'
$ cat census.tsv
hot Held interpreted
hot Plain installed
comparing that against --annotate's class headings is what says whether a
report of n compiled classes is n classes that ran.
--no-verify-install leaves the check out.
the C toolchain and the cpython headers come from the same interpreter by run
uses, chosen the same way, and it must have development headers available. it
has to be the same one: an extension built against one abi is unimportable by
another, and by run --compiled imports what this wrote
by generate-api-file¶
by generate-api-file # writes ./api.lock
by generate-api-file --stdout # writes lockfile to stdout
by generate-api-file -o public.lock # custom output path
by generate-api-file --python-version 3.10 # target a specific python version
see api-lock for the lockfile format and workflow
by transpile¶
by transpile FILE # read FILE, write transpiled python to stdout
by transpile # read from stdin, write to stdout
by transpile FILE --reverse # convert python source into basedpython idioms
by transpile FILE --min-version 3.12 # target a specific runtime python version
echo 'x: int = 1' | by transpile
by transpile also accepts a directory, transpiling it in place (every .by →
.py, or with --reverse every .py → .by)
stops at the first transpile error and prints a diagnostic
a python source that declares its own encoding with a
PEP 263 comment is decoded as it is read.
what is written back out is utf-8, so the declaration is rewritten to say utf-8
— left alone it would name an encoding the file no longer has. utf-8 and the
latin-1 family are decodable; a file declaring anything else is skipped and named
rather than guessed at