Virtual Environments — Your Project's Bubble
Every Python project needs its own isolated environment. Without this, you'll spend hours debugging dependency conflicts that have nothing to do with your code.
Imagine installing requests 2.28 globally for Project A. Then Project B needs requests 2.20 because it depends on an older library. You can't have both installed globally at the same time. One project breaks.
This is the dependency conflict problem. It happens constantly in real development. Virtual environments solve it by giving each project its own isolated Python installation and package directory — completely separate from everything else on your system.
venv/ or .venv/) containing a copy of the Python interpreter, a pip executable, and an empty site-packages/ directory where packages get installed. When you "activate" it, your terminal temporarily redirects python and pip commands to point inside that folder instead of the global Python. Deactivate it and you're back to global. No magic — just path manipulation.
# Step 1 — Create the virtual environment # Run this ONCE per project, in the project root python -m venv .venv # Creates a .venv/ folder. The dot keeps it hidden in file explorers. # Step 2 — Activate it # macOS / Linux: source .venv/bin/activate # Windows (Command Prompt): .venv\Scripts\activate.bat # Windows (PowerShell): .venv\Scripts\Activate.ps1 # Your prompt changes to show the active env: (.venv) PS C:\Users\jayka\projects\my-app> # Step 3 — Install packages (now go into .venv ONLY) pip install requests rich typer # Step 4 — Work on your project normally python main.py # Step 5 — Deactivate when done deactivate # Prompt returns to normal. Global Python unaffected.
.venv/ to your .gitignore. Your requirements.txt or pyproject.toml is what other developers use to recreate the environment.Ctrl+Shift+P → "Python: Select Interpreter" → choose the one inside .venv/. VS Code will then activate the environment automatically in its integrated terminal, and your IntelliSense will see all installed packages..venv/ folder to Git?pip & Dependency Management
pip is Python's package installer. But knowing pip commands is only half the job — the other half is properly recording and sharing your project's dependencies so anyone can reproduce your environment exactly.
# Install a package pip install requests # Install a specific version pip install requests==2.31.0 # Install minimum version (common in requirements files) pip install "requests>=2.28" # Install multiple packages at once pip install requests rich typer pydantic # Upgrade a package to latest pip install --upgrade requests # Uninstall pip uninstall requests # List everything installed in the current environment pip list # See details about one package (version, location, dependencies) pip show requests # Search PyPI (the Python Package Index) for packages pip index versions requests # Save current environment to requirements.txt pip freeze > requirements.txt # Install everything from requirements.txt # (run this after cloning a project to restore the environment) pip install -r requirements.txt
There are two ways to record dependencies. Both are valid — understand the difference so you use the right one.
| Format | Best For | Created By |
|---|---|---|
requirements.txt | Simple scripts, quick projects, exact reproducibility | pip freeze > requirements.txt |
pyproject.toml | Proper packages, libraries, modern projects | Manually or with poetry init |
# Exact pinned versions requests==2.31.0 rich==13.7.0 typer==0.9.0 pydantic==2.5.0 # Recreate with: # pip install -r requirements.txt
[project] name = "my-app" version = "0.1.0" requires-python = ">=3.11" dependencies = [ "requests>=2.28", "rich>=13.0", "typer>=0.9", "pydantic>=2.0", ]
Why pinned versions matter
pip freeze dumps every installed package with its exact version number. This creates a "snapshot" of your working environment. When another developer runs pip install -r requirements.txt, they get exactly the same versions you tested with — not whatever the latest version happens to be that day. This prevents the "works on my machine" problem.
The trade-off: pinned versions go stale. Security patches and bug fixes in newer versions won't reach you unless you explicitly update. Make a habit of running pip install --upgrade -r requirements.txt and re-testing periodically.
pypi.org is the Python Package Index — over 500,000 packages. Not all are good. Here's how to evaluate one quickly:
- Weekly downloads — millions = trusted. Hundreds = niche but may be fine. Tens = experimental.
- Last release date — abandoned for 3+ years is a yellow flag for security. Abandoned for 5+ is a red flag.
- GitHub stars & open issues — click the GitHub link. Are issues being responded to?
- Dependencies — a package that pulls in 50 sub-dependencies is a liability. Check the dependency count.
- License — MIT, Apache 2.0, BSD = use freely. GPL = check if it affects your project's license requirements.
Requests — HTTP for Humans
requests is the most downloaded Python package in history. It turns HTTP — normally a tedious low-level protocol — into something you can do in one readable line.
Every time your browser loads a page, fetches data, or submits a form, it sends an HTTP request. Your Python code can do the same — call weather APIs, GitHub, OpenAI, your own backend, anything with a URL.
| HTTP Method | What It Does | Example |
|---|---|---|
GET | Retrieve data | Fetch a user's profile |
POST | Send data, create resource | Submit a form, create a task |
PUT | Replace a resource | Update entire user record |
PATCH | Partially update | Change just the user's name |
DELETE | Remove a resource | Delete a task |
pip install requests
import requests # GET request — fetch data from a public API response = requests.get("https://api.github.com/users/torvalds") # The response object contains everything print(response.status_code) # 200 = success, 404 = not found, etc. print(response.headers) # dict of response headers print(response.text) # raw response body as string # .json() parses JSON response into a Python dict — use this constantly data = response.json() print(data["name"]) # "Linus Torvalds" print(data["public_repos"]) # number of public repos print(data["followers"]) # follower count
import requests # ── Query parameters — appended to URL as ?key=value ── params = {"q": "python", "sort": "stars", "per_page": 5} r = requests.get("https://api.github.com/search/repositories", params=params) # Requests builds: ?q=python&sort=stars&per_page=5 — no manual string building repos = r.json()["items"] for repo in repos: print(f{repo['full_name']:40} ⭐ {repo['stargazers_count']:,}") # ── Headers — authentication, content type ── headers = { "Authorization": "Bearer YOUR_TOKEN_HERE", "Accept": "application/vnd.github+json", } r = requests.get("https://api.github.com/user", headers=headers) # ── POST — send JSON data ── payload = {"title": "Learn requests", "done": False} r = requests.post( "https://jsonplaceholder.typicode.com/todos", json=payload # automatically serializes dict to JSON + sets Content-Type ) print(r.status_code) # 201 Created print(r.json()) # {'title': 'Learn requests', 'done': False, 'id': 201} # ── Timeout — always set this in production ── # Without timeout, a hanging server will hang your program forever try: r = requests.get("https://api.example.com/data", timeout=5) # timeout=5 means: 5 seconds to connect AND 5 seconds to read r.raise_for_status() # raises HTTPError if status is 4xx or 5xx data = r.json() except requests.Timeout: print("Request timed out") except requests.HTTPError as e: print(f"HTTP error: {e.response.status_code}") except requests.ConnectionError: print("No internet / DNS failure") # ── Sessions — reuse connections, share headers ── # More efficient when making many calls to the same API with requests.Session() as session: session.headers.update({"Authorization": "Bearer TOKEN"}) users = session.get("https://api.example.com/users").json() tasks = session.get("https://api.example.com/tasks").json() # Auth header sent automatically on every request in this session
requests won't raise an exception unless you call raise_for_status() or check response.status_code yourself. Never assume success just because you got a response.Use the free Open-Meteo weather API (no API key needed)
- URL:
https://api.open-meteo.com/v1/forecast - Params:
latitude=32.52,longitude=-93.75(Bossier City),current=temperature_2m,wind_speed_10m - Parse the response and print the current temperature and wind speed
- Add proper timeout and error handling
- Extend it: take latitude/longitude as function arguments so it works for any city
Rich — Beautiful Terminal Output
Rich turns your terminal output from plain text into something that looks professional — colored syntax, tables, progress bars, panels, and live-updating displays. One import changes everything.
pip install rich
from rich import print # shadows built-in print — same API # Markup syntax for colors and styles print("[bold green]✓ Task completed![/bold green]") print("[red]✗ Error occurred[/red]") print("[bold yellow]⚠ Warning:[/bold yellow] check your config") print("[cyan]https://teqvault.study[/cyan]") # Rich auto-highlights Python objects data = {"name": "Jude", "skills": ["python", "unity"], "level": 5} print(data) # dict with colored keys, strings, numbers — automatic
from rich.console import Console from rich.table import Table from rich.panel import Panel from rich.syntax import Syntax console = Console() # main Rich object — use instead of print() # ── Styled panels ── console.print(Panel( "[bold]Welcome to Teqvault[/bold]\nYour Python learning platform.", title="[green]Phase 3[/green]", border_style="green" )) # ── Tables ── table = Table(title="Task Manager", border_style="bright_black") table.add_column("#", style="dim", width=4) table.add_column("Title", style="bold white") table.add_column("Priority", justify="center") table.add_column("Status", justify="center") table.add_row("1", "Learn Requests", "[yellow]●[/yellow] High", "[green]✓ Done[/green]") table.add_row("2", "Ship Death Road", "[red]● Critical[/red]", "[yellow]○ Pending[/yellow]") table.add_row("3", "Read Pragmatic Prog","[blue]● Normal[/blue]", "[yellow]○ Pending[/yellow]") console.print(table) # ── Syntax highlighting for code output ── code = """ def fibonacci(n): if n < 2: return n return fibonacci(n-1) + fibonacci(n-2) """ console.print(Syntax(code, "python", theme="monokai", line_numbers=True))
from rich.progress import track, Progress, SpinnerColumn, TimeElapsedColumn import time # ── Simple — wrap any iterable with track() ── for item in track(range(20), description="Processing..."): time.sleep(0.05) # Shows: Processing... ━━━━━━━━━━━━━━━━ 20/20 0:00:01 # ── Advanced — multiple tasks, custom columns ── with Progress( SpinnerColumn(), "[progress.description]{task.description}", TimeElapsedColumn(), ) as progress: task1 = progress.add_task("[green]Fetching data...", total=100) task2 = progress.add_task("[yellow]Processing...", total=100) while not progress.finished: progress.update(task1, advance=2) progress.update(task2, advance=1) time.sleep(0.05)
Typer — Real CLI Tools in Minutes
Typer turns your Python functions into command-line interfaces automatically — argument parsing, help text, type checking, and tab completion, all from your existing type hints.
Python has a built-in argparse module for CLIs. Typer is better for almost everything because it uses type hints you're already writing — no duplicate declarations, much less code, automatic help text, and it integrates perfectly with Rich for colored output.
pip install "typer[all]" # [all] includes rich + shell completion
import typer from rich import print from typing import Optional app = typer.Typer(help="Task Manager CLI — manage your tasks from the terminal.") @app.command() def add( title: str = typer.Argument(..., help="Task title"), priority: int = typer.Option(2, help="Priority 1-5"), due: Optional[str] = typer.Option(None, help="Due date YYYY-MM-DD"), ): """Add a new task.""" print(f"[green]✓[/green] Added: [bold]{title}[/bold] (priority {priority})") @app.command() def list( show_done: bool = typer.Option(False, "--done", help="Include completed tasks"), ): """List all tasks.""" print("[bold]Your tasks:[/bold]") print(" [yellow]○[/yellow] Ship Death Road prototype") print(" [yellow]○[/yellow] Learn Phase 4") if show_done: print(" [green]✓[/green] Complete Phase 3") @app.command() def complete(title: str = typer.Argument(..., help="Task title to mark done")): """Mark a task as complete.""" print(f"[green]✓ Completed:[/green] {title}") if __name__ == "__main__": app()
Usage: cli.py [OPTIONS] COMMAND [ARGS]...
Task Manager CLI — manage your tasks from the terminal.
╭─ Commands ───────────────────────────────────────╮ │ add Add a new task. │ │ list List all tasks. │ │ complete Mark a task as complete. │ ╰──────────────────────────────────────────────────╯
python cli.py add "Ship Death Road" --priority 5 --due 2026-07-01 ✓ Added: Ship Death Road (priority 5)
python cli.py list --done Your tasks: ○ Ship Death Road prototype ✓ Complete Phase 3
Two kinds of CLI inputs
Arguments are positional — the user types them without a flag name: python cli.py add "My Task". Use typer.Argument(). Required by default.
Options are named with --flag: python cli.py add "My Task" --priority 3. Use typer.Option(). Optional by default, with a default value. Boolean options become --flag / --no-flag pairs automatically.
Pydantic — Data Validation That Actually Works
Pydantic validates data at the boundary of your application — when data comes in from APIs, files, or users — and gives you clear errors when it's wrong. It's the backbone of FastAPI and essential for any serious Python project.
Data coming from outside your program (API responses, JSON files, form submissions) is untrustworthy. Fields might be missing, wrong types, or invalid values. Without validation, you get cryptic KeyError or TypeError crashes deep in your code — far from where the bad data entered. Pydantic catches it at the door.
pip install pydantic # v2 — current version
from pydantic import BaseModel, Field, EmailStr, validator from typing import Optional from datetime import date class Task(BaseModel): title: str = Field(..., min_length=1, max_length=200) priority: int = Field(default=2, ge=1, le=5) # ge=≥, le=≤ done: bool = False due: Optional[date] = None tags: list[str] = [] # ── Valid data — works perfectly ── task = Task(title="Learn Pydantic", priority=4, tags=["python", "learning"]) print(task) # title='Learn Pydantic' priority=4 done=False due=None tags=['python', 'learning'] # ── Pydantic coerces compatible types automatically ── task2 = Task(title="Another task", priority="3") # "3" → 3, auto-coerced # ── Invalid data — immediate, clear error ── try: bad = Task(title="", priority=10) # empty title, priority > 5 except Exception as e: print(e) # 2 validation errors for Task # title: String should have at least 1 character [min_length=1] # priority: Input should be less than or equal to 5 [le=5]
The most common real-world use: you get a JSON blob from an API and want structured, validated, typed Python objects instead of raw dicts.
import requests from pydantic import BaseModel, Field from typing import Optional # Define the shape of what the API returns class GitHubUser(BaseModel): login: str name: Optional[str] = None public_repos: int followers: int bio: Optional[str] = None html_url: str # model_config lets you configure behavior model_config = {"extra": "ignore"} # ignore fields not in the model def get_github_user(username: str) -> GitHubUser: r = requests.get( f"https://api.github.com/users/{username}", timeout=5 ) r.raise_for_status() # model_validate parses the dict and validates every field return GitHubUser.model_validate(r.json()) user = get_github_user("torvalds") print(f"{user.name} — {user.public_repos} repos — {user.followers:,} followers") # Linus Torvalds — 7 repos — 236,174 followers # user is now a proper GitHubUser object, not a raw dict # user.login, user.followers, etc. all have the right types
priority: int = Field(ge=1, le=5) and pass priority="3" (a string). What happens?Pandas — Working With Tabular Data
You don't need to become a data scientist. But every real project eventually touches CSV files, spreadsheets, or structured data. Pandas makes that work feel like nothing.
pip install pandas
import pandas as pd # Series — a single column with an index scores = pd.Series([85, 92, 78, 96], index=["Jude", "Ana", "Max", "Zoe"]) print(scores["Jude"]) # 85 print(scores.mean()) # 87.75 print(scores[scores > 85]) # Ana:92, Zoe:96 — boolean indexing # DataFrame — a table: multiple columns, each is a Series df = pd.DataFrame({ "name": ["Jude", "Ana", "Max", "Zoe"], "score": [85, 92, 78, 96], "level": [5, 7, 3, 9], "passing": [True, True, False, True], }) print(df) print(df.dtypes) # column types print(df.describe()) # count, mean, std, min, max for numeric columns
import pandas as pd # ── Reading files — the most common entry point ── df = pd.read_csv("tasks.csv") # CSV df = pd.read_excel("data.xlsx") # Excel df = pd.read_json("data.json") # JSON # ── Quick inspection — always do this first ── print(df.head()) # first 5 rows print(df.tail(3)) # last 3 rows print(df.shape) # (rows, columns) print(df.columns.tolist()) # list of column names print(df.isnull().sum()) # count missing values per column # ── Selecting data ── df["score"] # single column → Series df[["name", "score"]] # multiple columns → DataFrame df.iloc[0] # first row by integer index df.loc[df["name"] == "Jude"] # rows where name is "Jude" # ── Filtering — boolean indexing ── passing = df[df["score"] >= 80] high_level = df[(df["level"] > 5) & (df["passing"] == True)] # ── Sorting ── df.sort_values("score", ascending=False) # by score descending df.sort_values(["level", "score"]) # multi-column sort # ── Aggregation ── print(df["score"].mean()) # average score print(df["score"].max()) # highest score print(df.groupby("passing")["score"].mean()) # mean score per group # ── Adding/modifying columns ── df["grade"] = df["score"].apply(lambda s: "A" if s >= 90 else "B") df["bonus_score"] = df["score"] * 1.1 # vectorized — no loop needed # ── Saving results ── df.to_csv("results.csv", index=False) # index=False avoids extra column df.to_json("results.json", orient="records") df.to_excel("results.xlsx", index=False)
Pathlib — The Modern File System
Python's old os.path module works but it's clunky. pathlib — built into Python since 3.4 — treats file paths as objects with methods. It's cleaner, cross-platform, and the right way to work with files in 2026.
from pathlib import Path # Create path objects — note the / operator for joining! home = Path.home() # /Users/jayka or C:/Users/jayka project = Path(".") # current directory data = project / "data" / "tasks.json" # join with / # No os.path.join() needed. The / works on all platforms. # ── Inspect paths ── p = Path("/home/jayka/projects/app/main.py") print(p.name) # "main.py" — filename with extension print(p.stem) # "main" — filename without extension print(p.suffix) # ".py" — extension print(p.parent) # /home/jayka/projects/app print(p.parts) # ('/', 'home', 'jayka', 'projects', 'app', 'main.py') # ── Check existence ── print(p.exists()) # True / False print(p.is_file()) # True if it's a file print(p.is_dir()) # True if it's a directory # ── Read and write — no open() needed for simple cases ── data_file = Path("tasks.json") content = data_file.read_text(encoding="utf-8") # read whole file as string data_file.write_text('{"tasks": []}', encoding="utf-8") # write string to file # ── Create directories ── output = Path("output/reports/2026") output.mkdir(parents=True, exist_ok=True) # parents=True creates all intermediate dirs # exist_ok=True won't error if it already exists # ── Find files — glob ── project = Path(".") # All Python files in current directory (not recursive) py_files = list(project.glob("*.py")) # All Python files recursively (** = any depth) all_py = list(project.glob("**/*.py")) # All JSON files in data/ subdirectory json_files = list((project / "data").glob("*.json")) # Iterate a directory for item in project.iterdir(): if item.is_file(): print(f"{item.name:30} {item.stat().st_size:>8} bytes")
from pathlib import Path import shutil, json # ── Config file in user's home directory ── config_path = Path.home() / ".myapp" / "config.json" config_path.parent.mkdir(parents=True, exist_ok=True) if not config_path.exists(): config_path.write_text(json.dumps({"theme": "dark"}, indent=2)) config = json.loads(config_path.read_text()) # ── Rename / move a file ── old_path = Path("report.txt") new_path = old_path.with_name("final_report.txt") # same dir, new name new_ext = old_path.with_suffix(".md") # same name, new extension # ── Copy file ── shutil.copy2(Path("source.txt"), Path("backup.txt")) # ── Delete ── Path("temp.txt").unlink(missing_ok=True) # delete file, no error if missing shutil.rmtree(Path("old_folder")) # delete entire directory tree # ── Get file stats ── p = Path("tasks.json") stat = p.stat() print(f"Size: {stat.st_size} bytes") print(f"Modified: {stat.st_mtime}")
Professional Project Structure
How you organize your project is architecture. A bad structure creates friction every day. A good one makes the codebase feel obvious to anyone who opens it — including future you.
Never hardcode API keys, database passwords, or secrets in your code. Load them from environment variables at runtime using python-dotenv.
pip install python-dotenv
# Real secrets go here
OPENAI_API_KEY=sk-proj-abc123
GITHUB_TOKEN=ghp_xyz789
DATABASE_URL=postgresql://user:pass@localhost/db
DEBUG=true
APP_PORT=8000
# Copy to .env and fill in values
OPENAI_API_KEY=
GITHUB_TOKEN=
DATABASE_URL=
DEBUG=false
APP_PORT=8000
from dotenv import load_dotenv from pydantic import BaseSettings # pydantic-settings package import os load_dotenv() # loads .env into os.environ # Simple approach — os.environ API_KEY = os.getenv("OPENAI_API_KEY") if not API_KEY: raise RuntimeError("OPENAI_API_KEY is not set — check your .env file") # Better approach — Pydantic Settings (validates env vars) from pydantic_settings import BaseSettings class Settings(BaseSettings): openai_api_key: str github_token: str debug: bool = False app_port: int = 8000 class Config: env_file = ".env" # auto-loads from .env settings = Settings() # raises error if required vars missing print(settings.app_port) # 8000 (from .env or default)
# Virtual environment .venv/ venv/ env/ # Secrets .env # Python cache __pycache__/ *.pyc *.pyo *.pyd .Python # Testing .pytest_cache/ .coverage htmlcov/ # Editor .vscode/ .idea/ *.swp # Distribution dist/ build/ *.egg-info/
Command Center v3 — Live API Integration
Extend the Phase 2 task manager into a real CLI tool that pulls live data from external APIs, uses Rich for beautiful output, and follows professional project structure.
✅ Virtual environment — project runs in its own
.venv/
✅ pyproject.toml or requirements.txt — all deps recorded
✅ Typer CLI — commands:
add, list, complete, weather, github
✅ Requests —
weather command fetches real weather for your city
✅ Pydantic — validate API responses and task input
✅ Rich — all output uses tables, panels, and color
✅ Pathlib — tasks saved to
~/.commandcenter/tasks.json
✅ .env — GitHub token loaded from environment (for
github command)
✅ Project structure — proper folders,
.gitignore, README.md
import typer from rich.console import Console from commandcenter import storage, weather, github, display from commandcenter.models import Task app = typer.Typer(help="Command Center — your personal productivity CLI.") console = Console() @app.command() def add(title: str, priority: int = 2, due: str = None): """Add a new task.""" tasks = storage.load() task = Task(title=title, _priority=priority, due=due) tasks.append(task) storage.save(tasks) console.print(display.task_added(task)) @app.command(name="list") def list_tasks(done: bool = False): """List all tasks.""" tasks = storage.load() console.print(display.task_table(tasks, show_done=done)) @app.command() def weather_cmd(city: str = "Bossier City"): """Show current weather for a city.""" data = weather.fetch(city) console.print(display.weather_panel(data)) @app.command() def gh(username: str): """Show a GitHub user's profile.""" user = github.get_user(username) console.print(display.github_panel(user)) if __name__ == "__main__": app()
After the baseline works, push further:
- Add a
statscommand that shows a Rich table: total tasks, pending, overdue, completion rate - Add a
exportcommand that saves tasks to CSV using pandas - Add a
--jsonflag to thelistcommand that outputs raw JSON (useful for piping to other tools) - Wrap all API calls in the
@retrydecorator from Phase 2 - Add a
searchcommand that filters tasks by keyword and uses Rich to highlight matches