Python Next Steps

If you’ve worked through variables, loops, functions, classes, and modules, you already know Python. The gap between “knowing Python” and “building real things with Python” isn’t more syntax — it’s a handful of ecosystem skills: isolating dependencies, installing third-party packages, structuring a project, testing it, and making it observable. This lesson is a map of that territory, with runnable examples of the standard-library tools that get you started on each one.

None of this is exotic. Every professional Python project you’ll ever open touches every topic below.

Overview: how the Python ecosystem fits together

When you write import requests, Python doesn’t do anything magical — it searches a list of directories called sys.path for a module or package named requests, and imports the first one it finds. sys.path normally includes: the directory of the script you ran, entries from the PYTHONPATH environment variable, and your installation’s site-packages directory, where third-party packages live after you install them.

That last part is where pip and PyPI (the Python Package Index) come in. pip is the package installer bundled with Python; PyPI is the public registry it downloads from by default. When you run pip install requests, pip resolves the package’s dependencies, downloads a pre-built wheel (a .whl file, essentially a zip of the package plus metadata), and unpacks it into site-packages so future import statements can find it.

The catch: if every project on your machine installs packages into the same global site-packages, two projects that need different versions of the same library will collide. That’s what a virtual environment (venv) solves — it’s a self-contained directory with its own site-packages and its own copy of the python executable’s launcher, so pip install inside it never touches anything outside it. Activating a venv just changes your shell’s PATH so that python and pip resolve to the versions inside the environment first.

Once you’re comfortable with isolation and installation, the remaining “next step” skills are about writing software that survives contact with reality: automated tests that catch regressions, structured logging instead of scattered print() calls, and command-line interfaces that don’t require editing source code to change behavior.

Syntax: the core commands and modules

Tool Command / import Purpose
Virtual environment python -m venv .venv Create an isolated environment in .venv
Activate (Linux/Mac) source .venv/bin/activate Use the venv’s Python and pip
Install a package pip install requests Download and install from PyPI
Freeze dependencies pip freeze > requirements.txt Record exact versions for reproducibility
Argument parsing import argparse Build real command-line interfaces
Testing import unittest Write automated, repeatable tests
Logging import logging Structured, leveled diagnostic output

Examples

Example 1: A real command-line tool with argparse

Scripts that hardcode their inputs don’t scale. argparse, part of the standard library, turns a script into a proper CLI tool with help text and validation for free.

import argparse

def convert_temperature(value: float, to_unit: str) -> float:
    if to_unit == "f":
        return value * 9 / 5 + 32
    if to_unit == "c":
        return (value - 32) * 5 / 9
    raise ValueError(f"Unknown unit: {to_unit}")

def main() -> None:
    parser = argparse.ArgumentParser(description="Convert a temperature.")
    parser.add_argument("value", type=float, help="The temperature to convert")
    parser.add_argument("--to", choices=["c", "f"], default="f", help="Target unit")
    args = parser.parse_args(["100", "--to", "f"])

    result = convert_temperature(args.value, args.to)
    print(f"{args.value} converted to {args.to.upper()} is {result:.1f}")

main()

Output:

100.0 converted to F is 212.0

Notice parser.parse_args(["100", "--to", "f"]) passes an explicit argument list — normally you’d call parser.parse_args() with no arguments and it would read from sys.argv, the real command-line input. Passing a list here just keeps the example self-contained and runnable without a terminal.

Example 2: Writing an automated test

A function you “tested” by eyeballing its output once is a function that will silently break later. unittest lets you encode that check permanently.

import unittest

def convert_temperature(value: float, to_unit: str) -> float:
    if to_unit == "f":
        return value * 9 / 5 + 32
    if to_unit == "c":
        return (value - 32) * 5 / 9
    raise ValueError(f"Unknown unit: {to_unit}")

class TestTemperatureConversion(unittest.TestCase):
    def test_celsius_to_fahrenheit(self):
        self.assertEqual(convert_temperature(100, "f"), 212.0)

    def test_fahrenheit_to_celsius(self):
        self.assertAlmostEqual(convert_temperature(32, "c"), 0.0)

    def test_invalid_unit_raises(self):
        with self.assertRaises(ValueError):
            convert_temperature(0, "k")

result = unittest.main(argv=["ignored"], exit=False, verbosity=2)

Output:

test_celsius_to_fahrenheit (__main__.TestTemperatureConversion) ... ok
test_fahrenheit_to_celsius (__main__.TestTemperatureConversion) ... ok
test_invalid_unit_raises (__main__.TestTemperatureConversion) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

Each test_* method is an independent check. assertEqual and assertAlmostEqual compare values (the latter tolerates floating-point rounding), and assertRaises confirms that bad input fails the way it should. Running this file with python -m unittest would discover these tests automatically — no unittest.main() call needed at all in that case.

Example 3: Logging instead of print

print() is fine for a five-line script. Anything bigger needs leveled, timestamped, filterable output — that’s what logging provides, and it’s already installed.

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(levelname)s:%(name)s:%(message)s",
)
logger = logging.getLogger("temperature_app")

def convert_temperature(value: float, to_unit: str) -> float:
    logger.debug("Converting %s to %s", value, to_unit)
    if to_unit == "f":
        return value * 9 / 5 + 32
    if to_unit == "c":
        return (value - 32) * 5 / 9
    logger.error("Unsupported unit requested: %s", to_unit)
    raise ValueError(f"Unknown unit: {to_unit}")

logger.info("Starting conversion")
print(convert_temperature(100, "f"))

Output:

INFO:temperature_app:Starting conversion
212.0

The debug call never printed anything because the configured level is INFODEBUG messages are below that threshold and get suppressed. That’s the entire point: you can leave detailed diagnostic calls in your code permanently and turn their visibility on or off with one line, instead of adding and removing print() statements by hand.

Under the hood: what installing and importing actually does

  1. You run python -m venv .venv. Python copies (or symlinks) its interpreter into .venv/bin/ and creates an empty .venv/lib/pythonX.Y/site-packages directory.
  2. You activate it. The activation script prepends .venv/bin to your shell’s PATH, so typing python or pip now resolves to the venv’s copies first.
  3. You run pip install requests. Pip contacts PyPI, resolves requests‘s own dependencies (like urllib3 and certifi), downloads wheels for each, and unpacks them straight into the venv’s site-packages.
  4. You run import requests. The interpreter walks sys.path in order and finds the package inside the active venv’s site-packages — never the system-wide install, unless the venv doesn’t have it.

This is also why “it works on my machine” bugs happen: someone installed a package globally once, forgot about it, and their script silently depends on it. A `requirements.txt` (or a modern `pyproject.toml`) pinning exact versions, generated from inside a venv, is what makes a project reproducible on another machine.

Common Mistakes

Mistake 1: Installing packages globally instead of in a venv. Running pip install without activating a virtual environment first installs into your system Python. Over months, this turns your global environment into a swamp of conflicting versions, and one project’s upgrade can silently break another’s imports. Always create and activate a venv per project before installing anything.

Mistake 2: Debugging with scattered print() statements and forgetting to remove them. This works until the codebase grows, at which point you can no longer tell which prints matter. Replace ad-hoc prints with logging calls at appropriate levels (debug, info, warning, error) — you can silence or enable them globally without touching the code.

Mistake 3: Writing code with no tests, then being afraid to refactor it. Without tests, every change to a function is a leap of faith. Even three or four unittest cases covering the normal case, an edge case, and an invalid input give you the confidence to change code without breaking it silently.

Best Practices

  • Create a new virtual environment for every project — never install third-party packages globally.
  • Pin dependency versions in requirements.txt or pyproject.toml so builds are reproducible.
  • Write tests as you go, not after the fact — untested code accumulates risk silently.
  • Use logging instead of print() for anything beyond a throwaway script.
  • Follow PEP 8 (naming, spacing, line length) — tools like black and ruff can enforce it automatically.
  • Read the source code of libraries you use often; it’s the fastest way to learn idiomatic patterns.
  • Learn pdb (the built-in debugger) — stepping through real execution beats guessing from print output.
  • Pick one direction to specialize next: web (Flask/Django), data (pandas/NumPy), automation, or scripting — depth beats breadth once the fundamentals are solid.

Practice Exercises

Exercise 1: Create a virtual environment, activate it, and install the requests package. Write a script that uses it to fetch a URL and print the HTTP status code.

Exercise 2: Take any function you’ve written before (even a simple one, like a string reverser) and write three unittest test cases for it: a typical input, an edge case (like an empty string), and an invalid input that should raise an exception.

Exercise 3: Take a script that currently uses print() for debugging output and convert it to use the logging module instead, with at least one debug, one info, and one warning call.

Summary

  • Third-party packages come from PyPI via pip; imports are resolved by searching sys.path.
  • Virtual environments (venv) isolate each project’s dependencies so installs never collide.
  • argparse turns scripts into real command-line tools with built-in help and validation.
  • unittest lets you encode expected behavior as repeatable, automated checks.
  • logging replaces scattered print() debugging with leveled, filterable, structured output.
  • The path forward from here is specialization: pick a domain (web, data, automation) and go deep.