Static data
[project] name = "demo" version = "1.0.0" dependencies = [ "packaging", ]
Executable code
import os
from setuptools import setup
deps = ["packaging"]
if os.environ.get("CI"):
deps.append("six")
setup(install_requires=deps)
setup.py scripts are executable Python build files for setuptools that run arbitrary code whenever a package's source distribution is built or installed.
Also known as setup script, setup.py script, setuptools setup script
setup.py is a Python script that configures the setuptools build backend by calling setuptools.setup(). Because setup.py scripts are ordinary executable Python rather than static data, they run arbitrary code every time a package's source distribution is built or installed. That execution is the most common entry point for Python supply chain attacks.
setup.py is a plain-text Python file in a project's root directory that calls setuptools.setup() with the package's metadata and build configuration. setuptools reads the arguments to that call, such as name, version, packages, and install_requires, to build the two archives a registry serves: a source distribution and a wheel.
from setuptools import setup, find_packages
setup(
name="demo",
version="1.0.0",
packages=find_packages(),
install_requires=["packaging"],
)setup.py is not an installer and not a registry. pip and uv are frontends that resolve and install packages from the Python Package Index; setuptools is the backend they call to turn source into a wheel. setup.py is one of three ways to configure that backend, and the only executable one. The [project] table in pyproject.toml and the older setup.cfg are declarative data a tool can read without running anything. setup.py is a program, so a tool learns its contents only by executing it.
As of November 2026, the current setuptools release is 84.2.1, and setup.py remains in wide use for packages with C extensions and for projects predating pyproject.toml. Python 3.12 stopped pre-installing setuptools in new virtual environments, so a fresh python -m venv on Python 3.13 contains only pip; setuptools, and any setup.py that depends on it, arrives when a build asks for it.
[project] name = "demo" version = "1.0.0" dependencies = [ "packaging", ]
import os
from setuptools import setup
deps = ["packaging"]
if os.environ.get("CI"):
deps.append("six")
setup(install_requires=deps)
The figure contrasts the two configuration styles. A pyproject.toml [project] table lists dependencies as static data, so a tool reads them by parsing the file and no code runs. An equivalent setup.py computes install_requires in Python, so a tool learns the dependency list only by running the script, which is free to run anything else at the same time.
You no longer run setup.py scripts directly. The Python Packaging Authority deprecated invoking setup.py as a command-line tool and maps each old command to a frontend. The file itself is not deprecated; python setup.py ... as a CLI is.
| Deprecated command | Modern replacement | What runs it |
|---|---|---|
| python setup.py install | python -m pip install . | pip builds a wheel, then installs it |
| python setup.py develop | python -m pip install -e . | pip, editable install |
| python setup.py sdist / bdist_wheel | python -m build | the build frontend, in an isolated env |
| python setup.py test | pytest | the test runner directly |
| python setup.py upload | twine upload | twine, over HTTPS to the index |
setuptools deprecated python setup.py install in version 58.3.0, and the packaging guide now routes every command through a frontend. The reason is bootstrapping: a direct invocation cannot install its own build dependencies before it runs. As setuptools maintainer Paul Ganssle put it, “The only thing you must stop doing is directly executing the setup.py file.”
That limit is the problem PEP 518 was written to solve in 2016. Its authors described setup.py as “a catch-22 of a file not being runnable without knowing its own contents.” You cannot read a setup.py's build requirements without executing it, and you cannot safely execute it until those requirements are installed. pyproject.toml's [build-system] table breaks the loop by declaring build dependencies as static data the frontend installs first.
Yes. A package's setup.py runs several times whenever pip or uv installs that package from a source distribution, because turning the sdist into a wheel means executing the script. We measured it with a package whose setup.py logs each top-level run, built as an sdist with setuptools 84.2.1 and installed with pip 26.2.2 on Python 3.13.
$ pip install demo-1.0.0.tar.gz
# setup.py executed 3 times:
# egg_info (read metadata)
# dist_info (prepare wheel metadata)
# bdist_wheel (build the wheel)Three executions for one install, and none of them is setup.py install. Modern pip never runs that command; it asks setuptools for a wheel, and building the wheel runs the script. Execution also starts before anything is installed: pip download and pip install --dry-run call setup.py to read an sdist's dependencies, so merely resolving a dependency tree can run project code. The sequence of PEP 517 hooks behind these runs is covered in our setuptools entry.
A wheel avoids all of this. A wheel is a pre-built ZIP archive with static metadata, so installing one runs no project code at all. The gap between the two formats is the whole security argument: for Python, the sdist's setup.py is the equivalent of npm's install scripts, and install time is where most supply chain attacks execute.
setup.py scripts are the most common place Python malware runs, because install-time execution fires automatically on every developer and CI machine that installs the package. Two shapes recur in the attacks we analyze: a malicious package overrides setuptools' install command through cmdclass, or it puts a payload at the top level of setup.py so it runs on import of the script.
The browser-cookie3 typosquat, a typosquatting copy of a popular cookie library, overrode the install command to launch a credential stealer that exfiltrated Chrome passwords, screenshots, and Discord tokens. The crytic-compilers trojan ran a Windows executable from a single line of setup.py and was downloaded 388 times before removal. The enchantv and vibrant packages hid a base64-encoded command in setup.py that pulled a batch-script payload from a Discord CDN; the two drew 279 and 7,697 downloads. None of these exploited a bug in setuptools. They used install-time execution as designed.
The same primitive now shows up in AI-driven incidents. During a 2026 UK AI Security Institute evaluation, an agent pushed malicious package metadata whose setup.py executed inside at least 53 GitHub-operated Dependabot containers, as covered in our report on the AISI cyber test. In a separate Anthropic test, a Claude model published a malicious package to PyPI that ran on 15 real systems in the hour it was live. A public registry is production for everyone downstream of it, and setup.py is what runs when they install.
None of this produces a CVE. Malware does not get a vulnerability identifier, so a scanner that only matches known CVEs sees a clean package. The defense is to analyze what the package actually does, by reading or sandboxing the install-time code, rather than trusting the metadata it declares.
Because setup.py runs at build time, a package's declared dependencies can differ depending on where the sdist is built. We built an sdist whose setup.py appends an extra dependency only when GITHUB_ACTIONS is set, then installed the same file two ways.
import os
from setuptools import setup
deps = ["packaging"]
if os.environ.get("GITHUB_ACTIONS") == "true":
deps.append("six") # an attacker would add something worse
setup(name="demo", version="1.0.0", install_requires=deps)Requires-Dist: packagingDynamic: requires-distThe sdist's own PKG-INFO lists only Requires-Dist: packaging and marks the field Dynamic: requires-dist. Installed on a laptop, the package pulls in packaging alone. Installed under GITHUB_ACTIONS=true, setup.py runs, appends six, and the installed metadata lists both. One sdist, two different dependency sets, decided by the build environment.
This is exactly the case PEP 643 addresses. Core metadata version 2.2 added the Dynamic marker so a backend can flag fields it computes at build time, and the spec tells installers they “MUST NOT treat this value as canonical” for a field so marked. pip and uv will read a sdist's static PKG-INFO to resolve dependencies without building it, but only when the fields are not Dynamic. A Requires-Dist marked Dynamic sends them back to running the build, which is to say, back to running setup.py. A reviewer auditing the sdist and the machine installing it can see two different dependency lists.
Write a setup.py only when your build genuinely needs to run code, such as compiling a C extension. For a pure-Python package, a static pyproject.toml carries the same metadata without the executable surface. The [project] table standardized in PEP 621 is declarative, and backends such as uv_build or hatchling without hooks run no project code at build time, so a reviewer can confirm from pyproject.toml alone that a build is inert. setuptools remains the right backend for C extensions and for established projects whose builds depend on setup.py.
On the consuming side, the control is to install wheels and refuse to build from source by default, with pip install --only-binary :all: or uv's --no-build. No sdist build means no setup.py runs. npm made the parallel decision for its ecosystem when npm v12 shipped with install scripts off by default in 2026; Python's equivalent choice belongs to installers, not to setuptools.
setup.py is not going away. C extensions need a build step, and for any package that ships only an sdist, pip and uv still build it by default, which means the script still runs the moment you install. The execution is opt-out, and most people never opt out.
Manifest confusion is a mismatch between an npm package's registry manifest and the package.json in its tarball, two copies that tools trust inconsistently.
A software bill of materials (SBOM) is a machine-readable inventory of the components, versions, and dependency relationships inside a piece of software.
Software composition analysis (SCA) inventories the third-party code in an application and checks each component for vulnerabilities, licenses, and malware.