{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"packaging-environments-modules-virtual-environments-and-pyproject-toml-22hsh","url":"https://zyvop.com/packaging-environments-modules-virtual-environments-and-pyproject-toml-22hsh","title":"Packaging & Environments: Modules, Virtual Environments, and pyproject.toml","subtitle":"Split code into modules and packages, give every project its own virtual environment, and describe it with pyproject.toml, using both classic pip and the modern uv tool.","tldr":"Tired of “it works on my machine”? This post explains modules, packages, virtual environments, uv, and pyproject.toml in plain language, plus how to keep API keys safe, then turns a word counter into a package with its own command.","keywords":["uv","Virtual Environments","Python","AI Engineer","Packaging","AI","Zero to Top AI Engineer (2026): Full Series Plan"],"entities":["Pradeep Kumar","uv","Virtual Environments","Python","AI Engineer","Packaging","AI","Zero to Top AI Engineer (2026): Full Series Plan","ZyVOP"],"keyTakeaways":["Every programmer eventually says the same sentence: \"But it works on my machine.\" Usually it means two computers have different versions of Python, or different libraries installed, or both.","This post is about fixing that, and it's one of the least glamorous and most useful things you'll learn.","By the end you'll know how to split code into files, keep each project's libraries separate, and describe a project so anyone can install it."],"headings":["Modules: a file is a module","The __name__ trick","A mistake that confuses everyone once","Packages: a folder of modules","The problem environments solve","Virtual environments: one box per project","uv: the modern way","pyproject.toml: the project's ID card","Versions and lock files","Another kind of environment: environment variables","Mini project: your first package","Make it yours","Practice","What to take away"],"outboundLinks":[],"contentText":"Every programmer eventually says the same sentence: \"But it works on my machine.\" Usually it means two computers have different versions of Python, or different libraries installed, or both. This post is about fixing that, and it's one of the least glamorous and most useful things you'll learn. It's core. By the end you'll know how to split code into files, keep each project's libraries separate, and describe a project so anyone can install it. We'll also fix the habit from Post 10, where I told you to just run pip install and promised a tidier way later. This is the tidier way. Modules: a file is a module A module is just a Python file. When one file uses code from another, it imports it. You've been doing this with built-in modules like time and logging already. Now let's do it with your own. Make a file called helpers.py: def shout(text): return text.upper() + \"!\"And another called main.py in the same folder: import helpers from helpers import shout print(helpers.shout(\"hi\")) # HI! print(shout(\"hello\")) # HELLO!There are two ways to import. import helpers brings in the whole module, and you use helpers.shout. from helpers import shout brings in just that one name. Both are fine. Many people also shorten long names with as, like import numpy as np, which you'll see everywhere later. A habit worth building: don't write from something import *. It dumps every name from the module into your file, and then nobody, including you, can tell where a name came from. The __name__ trick Here's a pattern you'll see at the bottom of almost every script: def shout(text): return text.upper() + \"!\" if __name__ == \"__main__\": print(shout(\"testing\"))Every module has a built-in variable called __name__. When you run a file directly with python helpers.py, Python sets it to \"__main__\". When another file imports it, it's set to the module's name instead, which is \"helpers\". So the if block runs when you run the file, and stays quiet when someone imports it. That's how one file can be both a reusable module and a runnable script. A mistake that confuses everyone once Python looks for imports in your current folder first. So if you name your own file after a standard library module, yours wins, and things break in confusing ways. Say you save a file as json.py, then in another file in the same folder you write import json and call json.dumps(...): AttributeError: module 'json' has no attribute 'dumps'It's importing your file, not Python's. Don't name files json.py, random.py, test.py, email.py, and so on. If you ever see this kind of error and the code looks right, check your filenames. Packages: a folder of modules When a project grows, you group modules into a folder. A folder of modules that can be imported is called a package. A tidy layout looks like this: textkit_project/ ├── pyproject.toml ├── README.md ├── src/ │ └── textkit/ │ ├── __init__.py │ ├── counting.py │ └── cli.py └── tests/A few notes on that layout: __init__.py marks the folder as a package, and it runs when the package is imported. It's often short, and just re-exports the main names so users can write from textkit import count_words. Putting the package inside src/ is a common choice. It stops you from accidentally importing the local folder instead of the properly installed package, which hides bugs. tests/ is where your tests go. That's Post 13. Inside a package, import from your own modules using the full name, like from textkit.counting import top_words. The problem environments solve Now the bigger issue. When you run pip install something, it puts the library into the Python you're using. Do that for every project, and soon: Project A needs version 1 of a library, and Project B needs version 2. You can only have one installed. You can't tell which libraries each project really needs. You break something on your computer's own Python that other programs depend on. That last one is real. On many Linux systems and some Macs, installing into the system Python gets you an \"externally managed environment\" error. Python is protecting itself. Don't fight it with a scary flag. Use a virtual environment, which is the proper fix. Virtual environments: one box per project A virtual environment is a private folder holding its own Python and its own installed libraries. Each project gets one, so they never interfere. Create one inside your project folder: python -m venv .venvThat makes a folder called .venv. Now activate it, which tells your terminal to use that Python: # macOS and Linux source .venv/bin/activate # Windows (PowerShell) .venv\\Scripts\\Activate.ps1Your prompt will usually change to show (.venv). You can double-check which Python you're using: which python # macOS and Linux (on Windows: where python)It should point inside .venv. Now anything you install goes only into this box: pip install pydantic pip listWhen you're done, type deactivate. A few things that help: Reproduce an environment. pip freeze &gt; requirements.txt saves a list of what's installed, with versions. Someone else (or you, next year) can rebuild it with pip install -r requirements.txt. Never commit .venv to Git. It's big and it's specific to your machine. Add .venv/ to your .gitignore file. Tell your editor. In VS Code, open the command palette and choose \"Python: Select Interpreter,\" then pick the one inside .venv. Otherwise your editor will underline imports as missing even though they work. Wrong environment is the most common cause of ModuleNotFoundError. You installed the library in one place and ran your code in another. Check which Python is active before anything else. uv: the modern way The tools above work, and you should understand them. But there's a newer tool called uv that does the same jobs far faster and in one place: it creates environments, installs libraries, manages Python versions, and keeps a lock file. Many Python projects use it now, and I'd suggest you do too. You can install it with pip install uv, or use the standalone installer from its official site (docs.astral.sh/uv). Tools in this area change quickly, so if a command here doesn't match what you see, check the official docs. Starting a new project: uv init my_app cd my_appThat creates a small project: a pyproject.toml, a README.md, a main.py, a .python-version file that says which Python to use, and a .gitignore that already excludes .venv. (The exact files vary a little between versions. It also sets up a Git repository for you.) Adding a library: uv add pydanticThat does three things at once: it creates the virtual environment if there isn't one, installs the library, and records it in pyproject.toml. It also writes a uv.lock file that pins the exact versions of everything. Run your code with: uv run main.pyuv run always uses the project's environment, so you never have to remember to activate anything. If a friend clones your project, one command, uv sync, rebuilds the exact same environment. Here's how the two approaches compare: Job pip and venv uv Create an environment python -m venv .venv automatic Activate it source .venv/bin/activate not needed with uv run Add a library pip install name uv add name Record dependencies pip freeze &gt; requirements.txt automatic in pyproject.toml and uv.lock Rebuild on another machine pip install -r requirements.txt uv sync Which should you use? Whichever your team or tutorial uses. If you're free to choose, pick uv. This series will show the simplest version of each command, and the ideas are identical either way. pyproject.toml: the project's ID card pyproject.toml is a single file that describes your project. Nearly every modern Python tool reads it. Here's the one for the small project we'll build below: [project] name = \"textkit\" version = \"0.1.0\" description = \"Small tools for counting words in text\" readme = \"README.md\" requires-python = \"&gt;=3.11\" dependencies = [] [project.optional-dependencies] dev = [\"pytest&gt;=8.0\"] [project.scripts] textkit = \"textkit.cli:main\" [build-system] requires = [\"setuptools&gt;=68\"] build-backend = \"setuptools.build_meta\"The format is called TOML. It's plain text with sections in square brackets. What each part does: [project] has the basics: the name, the version, a description, and the oldest Python it supports. dependencies lists the libraries your project needs to run. Ours is empty because it only uses Python itself. [project.optional-dependencies] lists extras. Here, pytest is only needed by people working on the project, not by people using it, so it goes in a dev group. [project.scripts] turns a function into a command. The line says: create a command called textkit that runs the main function in textkit/cli.py. After installing, you can type textkit in your terminal. [build-system] says which tool turns your folder into an installable package. setuptools is the long-standing choice. Others, like hatchling, work too, and you'll see them in plenty of projects. You rarely need to care which. Many other tools, such as test runners and code formatters, also read settings from the same file, which keeps your project in one place. Versions and lock files Dependencies usually come with a version rule: Written as Meaning pydantic&gt;=2.0 Version 2.0 or newer pydantic==2.5.1 Exactly this version pydantic~=2.5 2.5 or newer, but below 3.0 pydantic&gt;=2.0,&lt;3 At least 2.0, but below 3 Loose rules in pyproject.toml say what your project can work with. A lock file (uv.lock, or a pinned requirements.txt) records the exact versions you actually tested, including libraries your libraries depend on. Together they give you flexibility on paper and reliability in practice. A quick word on trust. Installing a library runs other people's code on your computer. Before you install something, check that you've spelled the name right (fake packages with near-identical names do exist), and that it's a real, maintained project. It takes ten seconds and avoids a lot of trouble. Another kind of environment: environment variables AI work adds one more thing. You'll use API keys, which are like passwords for model services. They must never appear in your code or in Git. The standard solution is an environment variable, a setting that lives outside your code: import os api_key = os.environ.get(\"MODEL_API_KEY\") if api_key is None: raise RuntimeError(\"Set the MODEL_API_KEY environment variable first\")For local work, people keep their variables in a file called .env: MODEL_API_KEY=your-key-goes-hereThen they load it at startup with a library like python-dotenv (load_dotenv()). The rules: Add .env to .gitignore so it never gets uploaded. Commit a .env.example file with the names but fake values, so others know what to set. If a key ever does leak, treat it as stolen: revoke it and make a new one. Mini project: your first package Let's turn the word counter from Post 5 into a real package you can install, with its own command. Build the folder layout shown earlier. First, src/textkit/counting.py: def count_words(text: str) -&gt; dict[str, int]: \"\"\"Count how often each word appears in a piece of text.\"\"\" words = text.lower().replace(\".\", \"\").replace(\",\", \"\").split() counts: dict[str, int] = {} for word in words: counts[word] = counts.get(word, 0) + 1 return counts def top_words(text: str, n: int = 5) -&gt; list[tuple[str, int]]: \"\"\"Return the n most common words with their counts.\"\"\" counts = count_words(text) ranked = sorted(counts, key=counts.get, reverse=True) return [(word, counts[word]) for word in ranked[:n]]Then src/textkit/__init__.py: from textkit.counting import count_words, top_words __all__ = [\"count_words\", \"top_words\"]And src/textkit/cli.py: import sys from textkit.counting import top_words def main() -&gt; None: text = \" \".join(sys.argv[1:]) or sys.stdin.read() for word, count in top_words(text): print(f\"{word}: {count}\") if __name__ == \"__main__\": main()Add the pyproject.toml from above, and a README.md containing a title. Now create an environment and install your package in editable mode, which means changes to your code take effect immediately without reinstalling: python -m venv .venv source .venv/bin/activate # on Windows, use the activate line from earlier pip install -e .(With uv, the equivalent is uv pip install -e . inside a project, or you can structure it as a uv project from the start with uv init --lib.) Now try it from any folder: python -c \"import textkit; print(textkit.top_words('The cat sat on the mat. The dog sat on the log.', n=3))\" # [('the', 4), ('sat', 2), ('on', 2)] textkit the cat sat on the mat # the: 2 # cat: 1 # sat: 1 # on: 1 # mat: 1 echo \"the dog and the cat\" | textkit # the: 2 # dog: 1 # and: 1 # cat: 1Take a moment to appreciate that. You typed textkit in a terminal, and your own code ran. It's no longer a script in a folder. It's a package, with a name, a version, and a command. Notice, too, that pip list now shows textkit with the path of your folder next to it. That's what \"editable\" means. Make it yours Add a clean.py module with a function that removes punctuation more thoroughly, and use it from counting.py. Add a real dependency, such as rich for colored output, and see how it shows up in pyproject.toml. Add a --top 10 option to the command. (Look up argparse in the Python docs.) Repeat the whole thing with uv, and compare the steps. Practice About 20 minutes of real effort on each before asking for help. Create a virtual environment, install a library, and confirm it with pip list. Then delete the environment and rebuild it from a requirements.txt file. Make two environments. Install requests in only one of them. Show that import requests works in one and fails in the other. Break things on purpose: create a file called json.py, and watch what happens to a script that imports json. Write a module that behaves differently when it's imported than when it's run directly, using if __name__ == \"__main__\":. Write a .gitignore for the project above and explain each line in your own words. Find a small open-source Python project on GitHub, open its pyproject.toml, and list its dependencies. Then ask an AI tutor to review your answers, and don't let it rewrite them. What to take away A module is a Python file, and a package is a folder of modules. if __name__ == \"__main__\": lets a file work both as a script and as an importable module. Never name your files after standard library modules. A virtual environment gives each project its own libraries, so projects can't interfere with each other. uv does everything pip and venv do, faster and in one tool, but the ideas are the same. pyproject.toml describes your project: its name, Python version, dependencies, and commands. Lock files pin exact versions so every machine gets the same setup. API keys belong in environment variables, never in code or Git. Previous: Post 11, Errors, Logging &amp; Debugging Next in the series: Post 13, Testing with pytest, where we write tests, mock model calls, and stop worrying about breaking things.","contentHash":"sha256:104729a09e3b6236242f58bdf8ee79f93cbd9863dc6d8f4d927e9fdead357103","authorName":"Pradeep Kumar","authorUrl":"https://zyvop.com/author/pradeep","authorSameAs":[],"category":"AI","tags":["uv","Virtual Environments","Python","AI Engineer","Packaging"],"audience":"Software engineers and developers building applications with AI","tone":"Practical and evidence-based engineering guidance","readingTimeMinutes":10,"wordCount":2499,"faqs":null,"primaryTopic":"AI","publishedAt":"2026-10-21T06:13:25.490Z","updatedAt":"2026-10-21T06:13:25.490Z","canonicalUrl":"https://zyvop.com/packaging-environments-modules-virtual-environments-and-pyproject-toml-22hsh"}