Trey Hunner

I help developers level-up their Python skills

Hire Me For Training

Reorganizing Python's sys module

| Comments

The sys module is the primary junk drawer of the Python standard library.

A good junk drawer holds miscellaneous items that don’t have another sensible home.

I often think of utils modules as “junk drawer” modules. I believe that both utils modules and junk drawers have their purpose, but junk drawers can get out of hand.

As I recently noted in my talk about pathlib, I see both the sys module and the os module as junk drawer modules. They serve a similar purpose to utils modules, but they have a different name.

In this post, I wonder: what would Python’s sys be like if it was designed today, from scratch?

The overview: 9 new sys submodules

As of Python 3.15, the sys module has 115-121 attributes (depending on whether you’re in the REPL and whether an exception has occurred), and 63 of those are functions.

All of those names need permanent homes.

How can we reorganize a utils-style module that’s grown quite large?

Give it submodules!

No, really… this isn’t the worst solution. Django’s utils package isn’t so bad.

So we could split sys into these 9 submodules:

  • sys.cli: for command-line argument handling and program control
  • sys.imports: related to imports and modules
  • sys.io: handles standard I/O streams
  • sys.repl: handles REPL display control
  • sys.interpreter: system information and installation details
  • sys.memory: memory management tools used for profiling, optimization, and debugging
  • sys.exceptions: exception handling
  • sys.profile: profiling and introspection
  • sys.runtime: interpreter runtime behavior

You may notice some similarities to other modules in the Python standard library. That’s a hint that it might be worth moving some of these utilities into other parts of the standard library. But moving functionality between top-level modules is a much bigger change, so let’s set that idea aside.

For now, let’s take a closer look at our tentatively re-organized sys package.

The sys package and its submodules

A couple of these 9 submodules have only a handful of attributes, a couple have over 20, and the rest fall somewhere in the middle.

sys.cli

This submodule would handle command-line arguments and program control:

  • argv: Command-line arguments list
  • orig_argv: Original unmodified arguments
  • exit(): Function to terminate Python
  • flags: Named tuple of interpreter flags
  • _xoptions: Dictionary of -X command options

sys.imports

Everything related to imports and modules:

  • path: Module search path list
  • modules: Dictionary of loaded modules
  • builtin_module_names: Tuple of built-in modules
  • stdlib_module_names: Frozen set of standard library modules
  • path_hooks: List of path-to-finder callables
  • path_importer_cache: Finder object cache
  • meta_path: Meta path finder objects
  • pycache_prefix: Bytecode cache directory
  • set_lazy_imports(), get_lazy_imports(), set_lazy_imports_filter(), get_lazy_imports_filter(), lazy_modules: Lazy import controls (Python 3.15+)

sys.io

The standard I/O streams:

  • stdin: Standard input stream
  • stdout: Standard output stream
  • stderr: Standard error stream
  • __stdin__, __stdout__, __stderr__: The original values of those three streams (useful for restoring them after replacing them)

sys.repl

Hooks and settings for Python’s interactive prompt:

  • displayhook(): Called to show the result of each REPL expression
  • __displayhook__: The original value of displayhook
  • ps1: The primary prompt string (>>>)
  • ps2: The continuation prompt string (...)
  • __interactivehook__: Called when an interactive session starts up
  • _baserepl(): Starts the basic fallback REPL

sys.interpreter

Information about the Python build, the Python installation, and the operating system:

  • platform: Platform identifier string (linux, darwin, win32, etc.)
  • version: Python version string
  • version_info: Python version as a named tuple
  • implementation: Python implementation details (CPython, PyPy, etc.)
  • executable: Path to the Python interpreter
  • prefix, exec_prefix: Installation prefixes
  • base_prefix, base_exec_prefix: Installation prefixes, ignoring virtual environments
  • platlibdir: Platform-specific library directory name
  • maxunicode: Maximum Unicode code point (1114111)
  • maxsize: Maximum size of containers
  • byteorder: Native byte order ('little' or 'big')
  • hexversion: Version encoded as single integer
  • api_version: C API version
  • copyright: Python copyright notice
  • abiflags: ABI flags from PEP 3149
  • abi_info: ABI details namespace (Python 3.15+)
  • float_info: Floating point implementation details
  • int_info: Integer implementation details
  • hash_info: Hash algorithm parameters
  • float_repr_style: float repr style ('short' or 'legacy')
  • winver, dllhandle, getwindowsversion(): Windows-specific details
  • getandroidapilevel(): Android-specific detail
  • _base_executable, _framework, _git, _home, _stdlib_dir: Assorted build and installation details

sys.memory

Memory management tools used for profiling, optimization, and debugging:

  • getsizeof(): Size of an object in bytes
  • getrefcount(): Number of references to an object
  • getallocatedblocks(): Number of allocated memory blocks
  • getunicodeinternedsize(): Number of interned strings
  • intern(): Intern a string
  • _is_interned(): Check whether a string is interned
  • _is_immortal(): Check whether an object is immortal
  • _debugmallocstats(): Print memory allocator statistics
  • _clear_type_cache(), _clear_internal_caches(): Clear interpreter caches

sys.exceptions

Tools for accessing and handling exceptions:

  • exc_info(): Currently handled exception, as a 3-tuple
  • exception(): Currently handled exception (Python 3.11+)
  • last_exc, last_type, last_value, last_traceback: The most recent unhandled exception, mostly for REPL use
  • excepthook(): Called to display unhandled exceptions
  • __excepthook__: The original value of excepthook
  • unraisablehook(): Called for exceptions that can’t be raised
  • __unraisablehook__: The original value of unraisablehook
  • tracebacklimit: Maximum number of traceback levels to display

sys.profile

Profiling, tracing, auditing, and other runtime introspection:

  • setprofile(), getprofile(): Profiling hooks
  • _setprofileallthreads(): Set profile function for all threads (Python 3.12+)
  • settrace(), gettrace(): Tracing hooks
  • _settraceallthreads(): Set trace function for all threads (Python 3.12+)
  • call_tracing(): Call a function with tracing enabled
  • monitoring: Low-overhead monitoring events namespace (Python 3.12+)
  • _getframe(), _getframemodulename(): Frame inspection
  • _current_frames(), _current_exceptions(): Frames and exceptions across all threads
  • activate_stack_trampoline(), deactivate_stack_trampoline(), is_stack_trampoline_active(): Support for the perf profiler (Python 3.12+)
  • audit(): Raise an auditing event
  • addaudithook(): Register an audit hook
  • remote_exec(), is_remote_debug_enabled(): Remote debugging support (Python 3.14+)

sys.runtime

Settings that control interpreter runtime behavior:

  • setrecursionlimit(), getrecursionlimit(): Recursion depth control
  • setswitchinterval(), getswitchinterval(): Thread switching control
  • is_finalizing(): Whether the interpreter is shutting down
  • breakpointhook(): Called by the built-in breakpoint function
  • __breakpointhook__: The original value of breakpointhook
  • dont_write_bytecode: Suppress .pyc file generation
  • warnoptions: Warning filter settings
  • getfilesystemencoding(), getfilesystemencodeerrors(): Filesystem encoding details
  • getdefaultencoding(): Default string encoding (always utf-8)
  • get_int_max_str_digits(), set_int_max_str_digits(): Limit on int-to-string conversion (Python 3.11+)
  • setdlopenflags(), getdlopenflags(): Dynamic loading control
  • thread_info: Thread implementation details
  • _is_gil_enabled(): Whether the GIL is enabled (Python 3.13+)
  • _jit: JIT compiler introspection namespace
  • _dump_tracelets(): Dump the JIT’s internal tracelets
  • _get_cpu_count_config(): Configured CPU count override
  • get_asyncgen_hooks(), set_asyncgen_hooks(): Async generator lifecycle hooks
  • get_coroutine_origin_tracking_depth(), set_coroutine_origin_tracking_depth(): Coroutine debugging depth
  • _enablelegacywindowsfsencoding(): Windows mbcs encoding compatibility

Could this actually be done?

When I first pondered this experiment last year, this was a very hypothetical thought experiment that I assumed could never be done. I still mostly feel the same way.

There are a few big problems with such a refactoring:

  1. What would the transition period look like for the huge amount of code that currently uses existing sys features?
  2. Would backwards compatibility be maintained forever? If so, would this cause more confusion than it’s worth?
  3. How would code that monkey patches attributes like sys.stdout work?

I thought the third question was the biggest roadblock, but I now think it’s the first 2 questions.

Those first 2 questions are big questions and I haven’t thoroughly thought through the upsides and downsides of such a refactoring.

That third question is a technical one, but I think I can answer it… but the answer is messy.

The magic of module-level __setattr__

When Python users want to capture all output from their program to a file, they reassign sys.stdout to an in-memory file-like object. That’s how the contextlib.redirect_stdout helper works, and many testing tools use the same technique.

This monkey patching of sys.stdout is somewhat common, which poses a bit of a problem for us.

Imagine that stdout actually lived in a sys.io submodule. If sys.stdout and sys.io.stdout were two separate attributes, code that assigned to one would be invisible to code that read from the other.

We need a way to synchronize reads and writes of the old flat sys namespace to forward them to the newly nested namespace within the right submodule.

Python has supported customizing module-level attribute reads since Python 3.7, thanks to module-level __getattr__ functions (PEP 562). But Python doesn’t support module-level __setattr__ functions (that idea was proposed in PEP 726 and rejected).

Although… every Python module is an instance of ModuleType, and Python allows changing the class of a module object. If we swap in a ModuleType subclass, we can define whatever __getattr__ and __setattr__ behavior we’d like.

This trick is demonstrated in a proof-of-concept newsys package.

The newsys package reimplements sys as a package with 9 submodules. As a proof of concept, this module simply proxies to the sys module. All reads and writes of newsys.io.stdout or newsys.stdout proxy to the original sys.stdout (since that’s what everything else still uses under the hood in the existing Python interpreter).

What would the transition look like?

If this transition was ever actually done, I imagine it might look something like this:

  1. Add the submodules, with every old flat name still working via forwarding
  2. Update the documentation to nudge folks toward the new names
  3. Soft deprecate the flat names someday (or maybe never)
  4. (Likely never) hard deprecate the flat names

There’s a tiny bit of precedent for sys submodules: sys.monitoring (added in Python 3.12) is an actual module that lives under sys. But since sys isn’t a package, import sys.monitoring doesn’t work, as noted at the top of the sys.monitoring documentation page.

But, I doubt this will ever be done. Python isn’t known for reorganizing modules just to clean things up (outside of the big Python 2/3 split).

Removing names like sys.path and sys.argv would break a huge amount of code… and I can imagine Python tutorials and long-time Python users dragging their feet on re-learning “the new way”. After all… why re-learn something when the old version already works and isn’t going anywhere?

If this was ever done, the flat names might need to keep working forever, and permanent aliases might cause more confusion than such a reorganization is worth.

A thought experiment, not a proposal

I’m not seriously proposing that we actually reorganize sys… at least not seriously enough to draft a PEP.

But I do think there’s a practical takeaway here for our own code. When a utils module grows out of hand, submodules can help. And if other code relies on the old flat names, a module-level __getattr__ function (or that hacky __class__ trick) can keep the old names working while you reorganize.

I doubt sys will ever change, but I had fun imagining a version of Python where it did.

Comments

Write more Pythonic code

Need to fill-in gaps in your Python skills? I send regular emails designed to do just that.