Skip to main content

v0.7.0 - Lifecycle drop

Released Pre-release.

When you type python -c "print('hi')" at a shell, an enormous amount of work happens before that one line ever runs. Python reads environment variables to figure out what locale you're in. It parses -c, -m, -W, and -X flags. It computes sys.prefix and sys.exec_prefix from the executable's location. It initializes the interpreter state. It builds the GIL. It imports builtins. It imports sys. It imports _warnings. It seeds the warnings filter list. It runs the source you handed it through the compile / eval pipeline. And then, when the source finishes, it walks an ordered shutdown: registered atexit callbacks, module finalizers, sys teardown, GIL release. CPython's Python/pylifecycle.c is north of two thousand lines for a reason.

For most of gopy's life up through v0.6, we didn't have this. We had a hand-rolled entry that read argv with a Go-style flag package, called into an eval loop with a hard-coded module namespace, and called os.Exit(0) when the user program finished. The result was usable for our own smoke tests and almost nothing else. gopy was a runner that ran code, but it wasn't a Python that booted.

v0.7.0 is the lifecycle drop. After this release the runtime boots through the real CPython init path: PyPreConfig and PyConfig parse env and CLI, the init phases hand off to pythonrun, and gopy -c, gopy script.py, and the bare REPL all share one entry. The builtins, sys, and _warnings surface those entry points need lands alongside.

Highlights

Three pieces of work define this release.

One entry, three arms

gopy -c "expr", gopy script.py, and gopy (no argv) all flow through the same Py_Main shaped function. argv goes into a PyConfig. The config drives init. Init hands off to a single dispatch that picks the right PyRun_* arm. Finalize runs on the way out.

# All three of these route through one entry.
gopy -c "import sys; print(sys.argv)"
gopy ./script.py arg1 arg2
gopy

The win is that everything past the dispatch sees the same world. sys.argv is stamped the same way for all three. sys.path resolves the same way. The warnings filter starts from the same seed. The finalize order is the same. Bugs that used to reproduce in one arm and not the others (because each arm had its own argv parser, its own sys init, its own teardown) just stop existing.

Real PyPreConfig and PyConfig

CPython splits initialization into two phases. PyPreConfig settles the questions that have to be answered before you can read anything else: which locale, whether to coerce C locale to UTF-8, whether to honor PYTHONUTF8. PyConfig then carries the rest: argv, executable path, prefix, exec_prefix, sys.path, warning options, optimization level, the -X panel.

// Three named initializers, one for each profile CPython exposes.
pre := initconfig.NewPreConfig()
initconfig.PyPreConfig_InitPythonConfig(pre)
// or _InitIsolatedConfig for -I behavior
// or _InitCompatConfig for the deprecated compat profile

cfg := initconfig.NewConfig()
initconfig.PyConfig_InitPythonConfig(cfg)
initconfig.PyConfig_Read(cfg) // merges env, CLI, defaults

The named initializers exist because CPython treats "isolated" (-I), "python" (default), and "compat" as three distinct starting points with different defaults for use_environment, user_site_directory, parse_argv, and a handful of others. Picking the wrong baseline silently flips behavior for code that relies on env vars or site packages. We kept the three apart.

Builtins, sys, and _warnings

You can't have a working -c without print. You can't have a working print without sys.stdout. You can't have a working sys.stdout until you've stamped the PyConfig-driven attributes. You can't have a useful failure mode without sys.stderr plus _PyErr_Print. And you can't honor python -W until you have a working _warnings module.

The dependency tree got resolved in one release. Builtins ship five panels (iteration, reflection, attrs, aggregate, numeric, constructors). sys ships create plus the PyConfig stamp. _warnings ships the action / category / filter model with the default filter seed CPython uses.

# All of these work after v0.7.
import sys
print(sys.argv, sys.path, sys.flags.optimize)

import warnings
warnings.simplefilter('error', DeprecationWarning)

What's new

The full feature breakdown, grouped by where it landed.

initconfig/

The config panel. Six files port the C side of CPython's two-phase init.

  • preconfig.go from Python/preconfig.c. PyPreConfig with the three named initializers (PyPreConfig_InitPythonConfig, _InitIsolatedConfig, _InitCompatConfig) and the env-var merge. The locale, coerce, and utf8 fields are reserved for v0.8 alongside the locale glue. We didn't try to ship the full locale pipeline in this drop because it has its own transitive dependencies (the codecs registry, the filesystem encoding panel) that we knew were coming in v0.8.
  • getenv.go from Python/getenv.c. _Py_GetEnv, _Py_str_to_int, _Py_get_env_flag. Every config helper that consults the environment reads through these three. We kept them in their own file because CPython does, and because they're the exact functions you want to grep for when you're debugging "why did the runtime pick up PYTHONOPTIMIZE here but not there".
  • config.go from Python/initconfig.c. PyConfig (the v0.7 subset of the C struct), plus PyConfig_InitPythonConfig, PyConfig_InitIsolatedConfig, and PyConfig_InitCompatConfig. The struct is intentionally not the full CPython surface yet. Fields that depend on subsystems we haven't ported (the faulthandler config, the tracemalloc config) are absent so we don't carry dead state through the runtime.
  • status.go from Python/initconfig.c. PyStatus error, exit, and ok constructors plus the env-merge into PyConfig. CPython propagates init errors through a return value rather than exceptions because exceptions aren't initialized yet at the point preconfig runs. We kept the same shape.
  • cmdline.go from Python/getopt.c plus Python/initconfig.c config_parse_cmdline. The -c / -m / -W / -X panel and the long-option subset gopy honors today (--help, --version, --check-hash-based-pycs). The _PyOS_GetOpt shape comes from Python/getopt.c byte for byte; we ported the table format rather than translate it because the table is the readable description of the CLI.
  • read.go from Python/initconfig.c _PyConfig_Read. Walks the precedence chain (compile-time defaults, env, command line, explicit writes) and resolves to the final config. The order matters: an explicit cfg.Argv = ... write should beat a PYTHONARGV env var should beat the compile-time default.

pathconfig/

sys.prefix and sys.path aren't constants. CPython walks the executable location plus a handful of marker files (pyvenv.cfg, pybuilddir.txt, lib/pythonX.Y/os.py) to compute them at runtime. We ported the Darwin and Linux versions in this release.

  • pathconfig_darwin.go and pathconfig_unix.go from Python/getpath.c plus the platform getpath modules. Resolves prefix, exec_prefix, and sys.path from the executable's location with the documented fallbacks. Windows lands in v0.8. The two-file split mirrors CPython's Modules/getpath.py generated tree; the resolution logic is identical between the two platforms but the marker files and the search root differ.

We considered hard-coding the paths against a build-time configuration and shipping a single pathconfig.go. We didn't, because the moment you do that, gopy stops working when you move the binary, and every "but it works on my machine" Python bug story gets a gopy chapter.

lifecycle/

The actual init / finalize sequence.

  • init.go from Python/pylifecycle.c pyinit_core and pyinit_main. Phase 1 (pyinit_core) brings the core runtime up: interpreter state, GIL, builtins, sys. Phase 2 (pyinit_main) imports the user-facing modules and runs the _PyRunMain equivalent setup. The split exists in CPython because embedders (the Py_InitializeFromConfig flow) sometimes want to inject work between the two phases. We preserved the split even though gopy doesn't have embedders yet, because retrofitting it later would have meant rewriting every consumer.
  • finalize.go from Python/pylifecycle.c Py_FinalizeEx. The shutdown order CPython documents: registered atexit callbacks, module finalizers, sys teardown, GIL release. We kept the order exact because real Python programs depend on it. atexit callbacks fire before sys.stderr closes; module finalizers fire before the GIL releases. Inverting either of those breaks observable behavior.
  • main.go from Modules/main.c Py_Main. The unified entry: read argv into PyConfig, init, dispatch through pythonrun, finalize. This is the function cmd/gopy/main.go calls.

pythonrun/

The dispatch arms. -c, file, REPL.

  • runstring.go from Python/pythonrun.c PyRun_SimpleStringFlags and PyRun_StringFlags. The -c smoke fixtures from v0.6 re-root here; their assertions stayed unchanged. That was a forcing function: if a test that used to pass against the hand-rolled runner failed against the new one, the new one was wrong.
  • runfile.go from Python/pythonrun.c PyRun_AnyFileExFlags. File open, source decode, parse, compile, eval. The file-positional caller in cmd/gopy routes through this. Source decode goes through the codecs layer that lands in v0.8 today, with a utf-8 fast path baked into the v0.7 cut.
  • repl.go from Python/pythonrun.c PyRun_InteractiveLoopFlags. Basic READ / EVAL / PRINT loop. Readline editing and PS1 / PS2 customization land with the readline port in v0.9. We didn't block the REPL on readline because the dumb loop is useful for testing the dispatch path, and readline brings in terminal handling that's its own multi-day project.
  • dispatch.go covers the three-arm matrix (-c, file, REPL) so lifecycle.Main calls a single switch rather than open-coding the choice. Every arm reads from the same PyConfig fields; every arm writes to the same sys attributes; every arm returns through the same finalize path.

errors/

We needed at least the printing path to fail usefully.

  • print.go from Python/pythonrun.c _PyErr_Print. SystemExit short-circuits to exit-code translation; everything else renders through the traceback panel and writes to sys.stderr. The traceback panel itself is still the v0.6 placeholder (file:line:col without PEP 657 caret pinning); we marked that as pending and moved on rather than block the lifecycle drop on a caret renderer.

sys/

The sys module gets a proper bring-up.

  • create.go from Python/sysmodule.c _PySys_Create. Builds the module dict and stamps the static attributes that don't depend on PyConfig (maxsize, byteorder, int_info, float_info, hash_info, thread_info).
  • config_attrs.go and flags.go stamp the PyConfig-driven slice: sys.argv, sys.path, sys.path0, sys.executable, the sys.flags named tuple, sys.warnoptions, sys.dont_write_bytecode. These are re-stamped if PyConfig changes between phase 1 and phase 2, matching CPython's late-binding shape.
  • runtime.go from Python/sysmodule.c. sys.exit, getrecursionlimit / setrecursionlimit, getrefcount (always 1 in gopy because we GC through Go), intern, gettrace / settrace, getfilesystemencoding. gettrace and settrace are the hooks profilers and debuggers use; we shipped the surface but the tracer call hook on every line lands alongside the trace plumbing in a later release.
  • implementation.go pins sys.implementation to the gopy-specific fields: name = "gopy", cache_tag = "gopy-3140", version, hexversion. We chose gopy-3140 because the __pycache__ directory format encodes the implementation, and we didn't want our .pyc files to collide with CPython's against the same source tree.

builtins/

The builtin namespace lands in six panels, each ported from a clean slice of Python/bltinmodule.c.

  • iter.go. The iteration panel: iter, next, enumerate, zip, range, reversed, map, filter. Each of these cooperates with the iterator protocol on the object side; the builtins are thin wrappers that walk into tp_iter and tp_iternext.
  • reflect.go. The reflection panel: type, isinstance, issubclass, id, hash, repr, len, callable. id is the address of the underlying Go pointer rather than a refcount bump (gopy has no refcounts); we documented this in the runtime notes so embedders don't expect refcount semantics.
  • attrs.go. The attribute panel: getattr, hasattr, setattr, delattr. vars and dir are parked behind frame-locals access in v0.8. We picked this split because vars() with no arguments has to walk the calling frame's locals dict, and the locals-dict story was its own dependency chain.
  • aggregate.go. The aggregation panel: sum, min, max, any, all, sorted. The key= and default= kwargs match CPython behavior; sorted(reverse=True) inverts the comparison rather than reversing the result, which produces stable behavior when the key function ties.
  • numeric.go. The numeric and format panel: abs, divmod, pow, chr, ord, bin, oct, hex, ascii, format. Routes through the format/ package for the [[fill]align][sign][...][type] mini-language. We ported the mini-language parser as its own subsystem rather than inline it in the builtins because str.format, f-strings, and format() all consume it.
  • ctor.go. The constructor wrappers: int, float, bool, list, tuple, dict. PEP 515 underscore stripping in int(...) parsing is honored at the panel level rather than the int parser, because the underscore rule is a surface-language concession that the parser shouldn't carry. set / frozenset wait on the set type in objects (which arrives in v0.8 alongside imports).

warnings/

Warnings are part of the boot. python -W error::DeprecationWarning has to take effect before the first import can fire a warning, so we landed the full filter machinery here rather than wait.

  • filters.go and warnings.go from Python/_warnings.c. The Action / Category / Filter model, the defaultFilters seed (matches _PyWarnings_InitState), and _Py_Warn dispatch. Categories use a Go struct hierarchy with a Parent pointer until the warning class machinery lands with the type port. We didn't block this drop on a Python-level warning class hierarchy because the C struct hierarchy with a Parent pointer is exactly what _Py_Warn walks anyway.
  • registry.go ports the per-module __warningregistry__ dedup. ActionOnce shares a synthetic __once__ bucket so dedup is global across modules; the other narrowing actions key by (action, category, message, module, lineno) shape. Getting the dedup key right matters because a chatty DeprecationWarning on a tight loop can produce thousands of lines of stderr noise if the key is wrong.
  • api.go ports the user-facing simplefilter, filterwarnings, resetwarnings from Lib/_py_warnings.py. Action validation, the lineno guard, and the _add_filter dedup-and-prepend semantics with optional append=True. The Python-side surface matches the docs verbatim.

Why we built it this way

A few decisions in this release deserve a callout.

The two-phase init is worth the cost. Folding phase 1 and phase 2 into a single function would have saved a few hundred lines and looked simpler at the call site. We didn't, because real CPython embedders (Blender, GIMP, gdb, lldb) all rely on the gap between the phases to inject host-specific setup. We don't have an embedder API yet, but we will, and retrofitting the split later means rewriting every consumer of pyinit_core. Shipping the split now costs nothing; not shipping it costs a lot later.

We didn't unify the dispatch arms into one Run function. You could argue runstring, runfile, and repl are three spellings of the same thing: decode source, compile, eval. The trouble is that each arm has its own error reporting expectations. -c wants the error message to mention <string> as the filename. The file arm wants the actual filename. The REPL wants the input number plus a recompile-with-an-extra-newline retry when the source ends mid-statement. CPython keeps the three separate; we kept the three separate.

getrefcount returns 1 unconditionally. CPython programs sometimes call sys.getrefcount(x) for debugging or to dispatch on whether something is uniquely held. In gopy we GC through Go, which means there's no refcount to read. We returned a constant 1 (rather than NotImplemented or an error) because the most common idiom is if sys.getrefcount(x) == 2: (the 2 accounts for the caller's argument binding), and any nonzero answer makes that branch fall through harmlessly. The alternative behaviors broke real code.

Where it lives

The new packages, with their entry points.

  • initconfig/. preconfig.go, getenv.go, config.go, status.go, cmdline.go, read.go.
  • pathconfig/. pathconfig_darwin.go, pathconfig_unix.go.
  • lifecycle/. init.go, finalize.go, main.go. Entry point is lifecycle.Main.
  • pythonrun/. runstring.go, runfile.go, repl.go, dispatch.go.
  • errors/. print.go (joins the prior errors panel).
  • sys/. create.go, config_attrs.go, flags.go, runtime.go, implementation.go.
  • builtins/. iter.go, reflect.go, attrs.go, aggregate.go, numeric.go, ctor.go.
  • warnings/. filters.go, warnings.go, registry.go, api.go.

Compatibility

A few user-visible changes are worth flagging if you were tracking gopy through v0.6.

  • gopy invocation behaves like python now. Flags before the script name are interpreted; flags after the script name go into sys.argv. v0.6 used a Go flag package that didn't honor that boundary.
  • sys.argv[0] is the script path for the file arm, -c for the -c arm, and `` (empty string) for the REPL. v0.6 used the gopy binary path in all three cases.
  • Exit codes follow CPython. SystemExit(2) exits with code 2. SystemExit('msg') writes 'msg' to stderr and exits 1. An uncaught exception exits 1. v0.6 always exited 0 unless the Go runtime itself crashed.
  • PYTHONOPTIMIZE, PYTHONDONTWRITEBYTECODE, PYTHONIOENCODING and the rest of the env-var panel actually work. They didn't in v0.6 because the runtime never read them.

What's next

The v0.8 release is the import drop. Highlights:

  • import and the source-encoding handshake. gopy -m foo returns ErrNotImplemented until the import system lands. v0.8 closes that gap with marshal, codecs (utf-8, ascii, latin-1), and a full sys.modules / frozen / builtin lookup chain.
  • Sub-interpreters and Py_NewInterpreter. The thread-state and interpreter-state machinery is in place but only one interpreter runs.
  • compile / exec / eval builtins beyond the smoke wrappers. The pieces exist (the parser, the bytecode assembler, the eval loop) but the builtin surface for user-level metaprogramming isn't wired yet.
  • Windows pathconfig. Darwin and Linux are covered; Windows lands with v0.8 to keep parity with the import work.
  • PEP 657 caret pinning in tracebacks. v0.7 still emits file:line:col only.
  • The remaining bytecode handlers parked behind future spec blocks. LOAD_SPECIAL / LOAD_SUPER_ATTR, IMPORT_NAME / FROM / STAR, SETUP_FINALLY / WITH_EXCEPT_START / CLEANUP_THROW / BEFORE_WITH, CHECK_EG_MATCH / LOAD_ASSERTION_ERROR, YIELD_VALUE / SEND / GET_AWAITABLE / RETURN_GENERATOR, MATCH. Most of these land in v0.8 and v0.9.

The lifecycle is a foundation release. None of the work in this drop is a feature you'd put on a marketing page. It's the plumbing that lets every later feature have somewhere to plug in. With v0.7 shipped, every subsystem we add from here on out walks through real config, real init, real dispatch, real finalize, real sys, real builtins, real warnings. That's a much better starting point than the v0.6 ad-hoc runner.

Acknowledgments

This release lines up against Python/pylifecycle.c, Python/initconfig.c, Python/preconfig.c, Python/getpath.c, Python/pythonrun.c, Python/sysmodule.c, Python/bltinmodule.c, Python/_warnings.c, Python/getenv.c, and Modules/main.c in the CPython 3.14 tree. Each ported file in gopy carries a citation to the originating function so a future reader can compare line by line.