# Why Installing a Python Package Can Run Code


`pip install` has never meant "copy some files into site-packages." A Python package gets four separate chances to run code on your machine: once when it installs, once whenever it gets imported, and twice more every time you start Python afterward.

In March 2026, a malicious release of [litellm](https://pydevtools.com/blog/litellm-supply-chain-attack-and-securing-python-dependencies.md) exploited one of them. The payload was a `.pth` file that ran on every Python invocation and quietly sent cloud credentials and Kubernetes secrets to an attacker-controlled server. Six weeks later, the [lightning compromise](https://pydevtools.com/blog/lightning-pypi-compromise-import-time-supply-chain-attack.md) used a different surface: a daemon thread spawned from the package's `__init__.py` on import. The other surfaces are just as available, and the handbook's supply-chain defenses only make sense once you can see all four.

## Code runs when the package installs

When [pip](https://pydevtools.com/handbook/reference/pip.md) installs a package from a [source distribution](https://pydevtools.com/handbook/reference/sdist.md) (an sdist), it doesn't just copy files. It asks the package to build itself. That "ask" runs package-controlled Python code on your machine, with your permissions, before you have imported anything from the package.

In older projects the build code lives in `setup.py` at the project root. `setup.py` is an ordinary Python program: it can read your SSH keys or drop a payload into `site-packages` for later.

Modern projects declare their build backend in `pyproject.toml`. This is the [PEP 517](https://peps.python.org/pep-0517/) build-system interface: pip reads the declaration and calls the named backend through a standardized API. Common backends are [hatchling](https://pydevtools.com/handbook/reference/hatch.md) and the [setuptools](https://pydevtools.com/handbook/reference/setuptools.md) backend.

The backend runs in an isolated build environment, which is a real improvement over the old implicit `setup.py` workflow. Isolation is not sandboxing, though: the backend is still an ordinary Python program with the same ability to read your files or drop payloads as any legacy `setup.py`. Christopher Ariza reported at PyTexas 2026 that 454 of the top 1000 PyPI packages still ship a `setup.py` (per the [bernat.tech recap](https://bernat.tech/posts/pytexas-2026-recap/)), so this install-time surface is live today.

Installing a pre-built [wheel](https://pydevtools.com/handbook/reference/wheel.md) sidesteps this surface. Wheels are archives; installing one unpacks files into `site-packages` without invoking a backend. But the files a wheel unpacks can include the next three surfaces.

## Code runs when you import the package

The most ordinary execution surface is the one users intend. A package's `__init__.py` is just Python: every `import foo` runs it, with the user's permissions, in the user's working directory. Anything the package wants to do at import time happens before the import statement returns.

The April 2026 release of [lightning](https://pydevtools.com/blog/lightning-pypi-compromise-import-time-supply-chain-attack.md) used this surface. Versions 2.6.2 and 2.6.3 shipped a hidden `_runtime/` directory; `__init__.py` spawned a daemon thread that ran the payload chain in the background while the import returned normally. Anyone who installed a bad version and then ran `import lightning` (the standard opener for a PyTorch Lightning workflow) triggered the chain.

Compared to `.pth`, the import surface is narrower and broader at the same time. Narrower because the user has to import the package; a malicious release that nobody imports does nothing. Broader because most installed packages do get imported eventually, and the import is part of a workflow the developer is already running, so the payload blends into a process the user expects to be active. Like the install-time surface, the import-time surface uses ordinary Python: anything `__init__.py` would legitimately run for logging setup, deferred imports, or plugin discovery is also available to a malicious release.

## Code runs at every Python startup through `.pth` files

A `.pth` file is a plain text file that lives in `site-packages`. When the interpreter starts, Python reads every `.pth` file it finds. Most lines are directory names that get added to `sys.path`, which is the benign use.

Lines that begin with the word `import` are something else. Python executes them as code at every interpreter startup, and keeps doing so until the file is removed. The user does not have to import the package, or even know the file exists.

This is what the litellm attack used. The malicious `litellm==1.82.8` release shipped a file called `litellm_init.pth`. Anyone who installed that version (directly or as a transitive dependency) fired the payload the next time they typed *any* `python` command at all, including `python -c "print(1)"` in an unrelated terminal window.

Wheels can ship `.pth` files just as easily as sdists. A build backend feature that maps arbitrary filesystem paths into a wheel ([hatch](https://pydevtools.com/handbook/reference/hatch.md) exposes this via `tool.hatch.build.targets.wheel.force-include`) will bundle one, and attackers have also been seen modifying legitimate wheels to slip a `.pth` in before redistributing them. The wheel format protects you at install time but not from what runs next.

### Python 3.15 retires the `.pth` `import` line

[PEP 829: Package Startup Configuration Files](https://pydevtools.com/handbook/explanation/what-is-pep-829.md) (accepted 24 April 2026) closes this surface from the runtime side over three releases. Python 3.15 introduces a new `<name>.start` file format that holds initialization entry points in the colon form `pkg.mod:callable`, resolved via [`pkgutil.resolve_name()`](https://docs.python.org/3/library/pkgutil.html#pkgutil.resolve_name). `.pth` keeps the `sys.path` extension job; the `import` line goes away.

The deprecation runs over five years.

- Python 3.15 through 3.17 still execute `.pth` `import` lines, except where a matching `<name>.start` file shadows them.
- Python 3.18 and 3.19 silently ignore `.pth` `import` lines.
- Python 3.20 and later emit a warning.

The security implication is that on Python 3.18 and later, a future litellm-style payload shipped as a `.pth` `import` line stops running on its own. The package author does not have to do anything for the surface to close on a current interpreter. Until 3.18 is the floor on the systems a project runs on, the surface is open and the defenses on this page still apply.

## Code runs at every Python startup through `sitecustomize` and `usercustomize`

After Python processes `.pth` files, it looks for a module called `sitecustomize` anywhere on `sys.path` and imports it. Whatever it finds first wins. The legitimate use is a system administrator adjusting defaults on a shared machine. The abuse case: a package drops a `sitecustomize.py` into `site-packages`, and from then on every `python` invocation in that environment runs it.

`usercustomize` is the per-user analogue. When the user's `site-packages` directory is active (the default outside virtual environments), Python looks for `usercustomize` there and imports it the same way.

## Match each defense to the surface it blocks

The handbook's supply-chain guidance makes more sense once the surfaces are explicit.

[Trusted publishing](https://pydevtools.com/handbook/explanation/why-use-trusted-publishing-for-pypi.md) and [digital attestations](https://pydevtools.com/handbook/how-to/how-to-publish-python-packages-with-digital-attestations.md) defend the *upload* side. If an attacker cannot upload a malicious release to [PyPI](https://pydevtools.com/handbook/explanation/what-is-pypi.md) in the first place, no downstream surface matters.

[Pinning dependencies with hashes](https://pydevtools.com/handbook/how-to/how-to-pin-dependencies-with-hashes-in-uv.md) defends the *retrieval* side. An installer like [uv](https://pydevtools.com/handbook/reference/uv.md) that verifies each artifact against a recorded hash refuses substituted or tampered wheels, which removes the opportunity to inject a `.pth` file or a malicious `__init__.py` between PyPI and the destination machine.

[Dependency cooldown via `exclude-newer`](https://pydevtools.com/handbook/how-to/how-to-protect-against-python-supply-chain-attacks-with-uv.md) defends the *timing* side. Most compromised releases are yanked within hours or days; a rolling cooldown that refuses recently-published packages turns the community's detection delay into insulation.

[Running `uv audit` or pip-audit](https://pydevtools.com/handbook/how-to/how-to-scan-python-dependencies-for-vulnerabilities.md) defends the *disclosure* side. Once an advisory database flags a compromised version, a CI-time scan catches a pinned-but-vulnerable dependency before it reaches production.

For direct inspection of an environment, [`fetter`](https://github.com/fetter-io/fetter-py) enumerates every file runnable at interpreter startup and validates the installed set against a lockfile. That covers `.pth` and `sitecustomize`, but the import surface is harder to audit retrospectively: an `__init__.py` only runs when the user imports the package, so a static scan cannot observe its behavior without executing the code under inspection.

Because each measure blocks a different failure mode, the handbook recommends layering them rather than picking one.

## Details worth knowing

When you are auditing an environment rather than reading for orientation, a few precise mechanics matter.

Failures from `.pth` execution are suppressed. If a line raises any exception that inherits from `Exception`, Python catches it, writes a short message to stderr, and skips the rest of that `.pth` file. On a CI runner or an IDE terminal the message is often invisible, and whatever the payload did before raising has already happened.

Failures from `sitecustomize` and `usercustomize` are suppressed too. An `ImportError` whose `name` attribute equals `'sitecustomize'` (the "module doesn't exist" case) is silenced. Any other exception is caught and reported to stderr, without a traceback unless `PYTHONVERBOSE` is set, and then suppressed; a payload that runs without raising leaves no trace at all.

Three CLI flags disable parts of this for audit work.

- `python -S` skips `site` processing entirely. No `.pth` files, no `sitecustomize`, no `usercustomize`. This is the clean-room mode for auditing an environment you do not trust yet.
- `python -I` (isolated mode) keeps `site` enabled but disables the user `site-packages` directory and ignores all `PYTHON*` environment variables.
- `python -s` disables just the user `site-packages` directory.

## Learn More

- [Should I run `python setup.py`?](https://pydevtools.com/handbook/explanation/should-i-run-python-setuppy-commands.md) covers why the legacy build commands are deprecated.
- [How to protect against Python supply-chain attacks with uv](https://pydevtools.com/handbook/how-to/how-to-protect-against-python-supply-chain-attacks-with-uv.md) walks through configuring `exclude-newer`.
- [How to pin dependencies with hashes in uv](https://pydevtools.com/handbook/how-to/how-to-pin-dependencies-with-hashes-in-uv.md) shows how to produce and consume hash-verified locks.
- [How to scan Python dependencies for vulnerabilities](https://pydevtools.com/handbook/how-to/how-to-scan-python-dependencies-for-vulnerabilities.md) covers `uv audit` and pip-audit.
- [Why use trusted publishing for PyPI?](https://pydevtools.com/handbook/explanation/why-use-trusted-publishing-for-pypi.md) explains OIDC-based uploads.
- [What is PEP 829?](https://pydevtools.com/handbook/explanation/what-is-pep-829.md) covers the Python 3.15 replacement that closes the `.pth` `import`-line surface from the runtime side.
- [LiteLLM Got Owned, and Your Dependencies Might Be Next](https://pydevtools.com/blog/litellm-supply-chain-attack-and-securing-python-dependencies.md) covers the March 2026 `.pth` incident end-to-end.
- [Lightning Got Owned: When `import lightning` Steals Your Credentials](https://pydevtools.com/blog/lightning-pypi-compromise-import-time-supply-chain-attack.md) covers the April 2026 import-time daemon-thread incident.
- The [Python `site` module documentation](https://docs.python.org/3/library/site.html) is the authoritative spec for `.pth`, `sitecustomize`, `usercustomize`, and `python -S`.
- [pip's secure installs guide](https://pip.pypa.io/en/stable/topics/secure-installs/) covers the installer side in depth.
- [`fetter`](https://github.com/fetter-io/fetter-py) scans site-packages for suspicious startup code.
- Christopher Ariza's talk "Why Installing Python Packages Is Still a Security Risk" is summarized in the [bernat.tech PyTexas 2026 recap](https://bernat.tech/posts/pytexas-2026-recap/).
