Python editable installs and nested scripts cover image

Python editable installs and nested scripts

Intro

I have been working towards a standard structure for all my python projects - and I mean much more than just uv init or something along those lines. But one of the things that got me recently was starting to keep a scripts/ folder that ran code based on what was in src/ or app/ (I'm working on that...) being installed in the virtual environment... so I'd uv pip install -e . or uv pip install -e src (kind of depends on the structure) and then in scripts/foo.py I'd have from my_cool_code.module import thing....

Running scripts has never been a problem... but I've always kept them in the root of the repo OR inside app or src in a nested directory...

So here's where I learned a little something about python my_script.py vs python -m my_script

Example

I setup a short example repo to illustrate the problem

link to repo

The repo structure is very simple...


python-e-example
  ├── foo.py
  ├── pyproject.toml
  ├── src
  │   ├── module.py
  ├── README.md
  └── scripts
      └── bar.py

scripts/bar.py and foo.py are the same code:


from src.module import my_func

my_func()

and then src/module.py:


def my_func():
    print("it worked!")

The rest of the structure is from uv init basically, then uv venv will make your virtual environment, and uv pip install -e . will install the library in editable mode.

So what's the problem?

Let's run foo.py


nic in python-e-example   main   ×7 via   v3.13.1(python-e-example)   (dev) 󰒄
⬢ [devbox]  python foo.py
it worked!

Nice! It works just like we'd expect...

What about python scripts/bar.py?


nic in python-e-example   main   ×7 via   v3.13.1(python-e-example)   (dev) 󰒄
⬢ [devbox]  python scripts/bar.py
Traceback (most recent call last):
  File "/tmp/python-e-example/scripts/bar.py", line 8, in <module>
    from src.module import my_func
ModuleNotFoundError: No module named 'src'

Come again for big fudge...

Ok let's stay calm and troubleshoot

What if we open a python shell...


Python 3.13.1 (main, Dec 19 2024, 14:32:25) [Clang 18.1.8 ] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from src.module import my_func
>>> my_func()
it worked!

ok... as expected it worked, and we've learned nothing...

implicit behavior

We didn't learn anything from the example but something is present that we don't see... python foo.py implicitly adds the path to the script to sys.path and so the src library is in the same directory as the script foo.py so the library is on sys.path.... take note of this

The Problem

Turns out uv pip install . and uv pip install -e . are actually different

20250705005214_a83dd563.png

The editable install adds a .pth file into site-packages which acts as a shell hook.


# .venv/lib/python3.13/site-packages/__editable__.python_e_example-0.1.0.pth

/tmp/python-e-example/src

So when python runs a script, the location of the script is implicitly added to sys.path as noted above. The python process invoked by python foo.py has $PWD on sys.path which is /tmp/python-e-example in the example but python scripts/bar.py has /tmp/python-e-example/scripts on sys.path... which means that /tmp/python-e-example is NOT on sys.path in the process that's running scripts/bar.py and so the src folder won't be searched by python because the .pth file points to a directory that's no on the path!

The Solution

We can instead of running the script "as a script" we can tell python to execute it as as a module with python -m scripts.bar. This is syntatically similar to python path/to/script.py but instead tells the python interpreter to execute the script as a module so the path is replaced by import syntax.

This works for foo.py too


 [devbox]  python -m foo
it worked!

⬢ [devbox]  python -m scripts.bar
it worked!

ChatGPT

It's noteworthy that jipity was helpful in exploring these issues I was running into

link to convo