Executing Python command across multiple versions

Setting up a robust data science development environment takes time, and it’s a process that’s rarely ever finished. If you’re the type who likes to get the most out of your tools, you’ll likely enjoy tweaking, optimising, and layering your workspace with productivity enhancements. That might mean refining your Python setup to easily manage multiple language versions and dependencies, or expanding your text editor with plugins for linting, code suggestions, unit test execution, and CI/CD integration.

The only constant is that your environment is always evolving. I recently moved from vim to nvim and rewrote much of my VimL-based configuration in Lua—something I’d never touched before. If you’re experimenting, learning, and building in parallel, having a clean way to isolate and test changes becomes invaluable.

Managing tools without installing them

In most cases, I use Homebrew to manage system components on my machine, and it works well. But there are situations where installing something locally feels excessive—especially if I only need it temporarily.

Solution

Examples provided below work on the same basis, the code and commands are executed within disposable Docker containers. The process of needing to install software on local machine is completely removed from the system

Example: Python AST across versions

Suppose you’re working with a simple Python script using the ast module, which allows you to parse and analyse Python code as an abstract syntax tree. This is commonly used in tools like linters or code formatters, but also in more advanced metaprogramming scenarios.

Here’s a minimal script that parses the assignment x = 42 and checks whether the literal 42 is represented using ast.Num.

Intuitively, we might expect this to return True—after all, 42 is a number, and ast.Num seems appropriate. But that’s not always the case.

Now we run it across various Python versions using Docker.

1
2
import ast
print(type(ast.parse("x = 42").body[0].value) is ast.Num)

I will store this script as /tmp/check_ast.py. Using the docker one liner, I will execute the script in multiple version of Python.

1
2
3
4
for version in 2.7 3.5 3.6 3.7 3.8 3.9 3.10 3.11 3.12 3.13; do
    echo "Python ${version}:"
    docker run --rm -v /tmp/check_ast.py:/check_ast.py python:$version python /check_ast.py
done
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
## Python 2.7:
## True
## Python 3.5:
## True
## Python 3.6:
## True
## Python 3.7:
## True
## Python 3.8:
## False
## Python 3.9:
## False
## Python 3.10:
## False
## Python 3.11:
## False
## Python 3.12:
## /check_ast.py:2: DeprecationWarning: ast.Num is deprecated and will be removed in Python 3.14; use ast.Constant instead
##   print(type(ast.parse("x = 42").body[0].value) is ast.Num)
## False
## Python 3.13:
## False
## /check_ast.py:2: DeprecationWarning: ast.Num is deprecated and will be removed in Python 3.14; use ast.Constant instead
##   print(type(ast.parse("x = 42").body[0].value) is ast.Num)

You’ll notice the results vary. Some versions return True, others return False. This reflects the evolution of the ast module. Although ast.Constant was introduced in Python 3.8 to unify literals like numbers, strings, and constants under one node type, ast.Num, ast.Str, and related nodes were not immediately retired. In fact, ast.parse() continued to return ast.Num in Python 3.8, 3.9, and even 3.10.0, to preserve compatibility.

As a result, isinstance(..., ast.Num) still returned True in those versions. The complete shift to ast.Constant occurred in Python 3.11, where ast.parse() finally stopped emitting the older nodes. This is a good example of how language-level changes may be rolled out gradually, and why it’s useful to test behaviour directly—rather than rely on changelog summaries alone.

Other interesting uses

For quick evaluation it is possible to direcltly jump into ipython console. I find this partilculary useful if I want to check running some code interactively in a specific version of Python.

1
docker run -it --rm python:3.8 bash -c "pip install ipython && ipython"

The other trick that I find useful in those scenarios is to install packages while in Python interactive session by calling subprocess, this can be easily achieved via running subprocess command pointing to pip as shown below:

1
2
3
4
5
import subprocess
import sys
subprocess.run([sys.executable, "-m", "pip", "install", "pandas"], check=True)
import pandas as pd
print(pd.__version__)

Summary

One-line Docker commands are a lightweight, repeatable, and isolated way to test and explore code across environments—without cluttering your system. They’re particularly useful when comparing behaviour across language versions or running quick experiments in tools you don’t use daily.