IS 4010: Application Development with Artificial Intelligence

Week 08: Building professional Python applications

Brandon M. Greenwell

Building professional Python applications

From scripts to maintainable tools

  • Application development adds a clear interface, structure, tests, and documentation to a script
  • Command-line interfaces (CLIs): A composable interface for automation and developer tools
  • Package structure: Organizing code for reusability, testing, and distribution
  • Final project connection: Every project needs a usable CLI, tests, documentation, and a repeatable environment

Part 1: command-line interfaces

Why CLIs matter: automation

The problem: manual, repetitive tasks

  • Scenario: You need to fetch data from an API multiple times per day
  • Manual approach: Open browser, navigate to site, copy data, paste into file
  • Repeated work: Manual steps take time and can be difficult to reproduce
  • Error-prone: Manual copy-paste leads to mistakes and inconsistencies
  • Solution: A CLI tool that makes the steps repeatable with one command

CLI tools in the wild

  • Version control: git - git add, git commit, git push
  • Python project management: uv - uv sync, uv add, uv run
  • Web frameworks: django-admin - startproject, migrate, runserver
  • AWS CLI: aws - Manage cloud infrastructure from the terminal
  • Data science: jupyter - jupyter notebook, jupyter lab
  • Your final project: Provide the working CLI required by the rubric

Introducing argparse: Python’s built-in CLI library

  • argparse is Python’s standard library for creating command-line interfaces
  • Built-in: No installation required, part of Python’s standard library
  • Capabilities: Handles arguments, flags, subcommands, help text, and validation
  • Automatic help: Generates --help output from the parser definition
  • Established tool: Used by many Python command-line applications
  • Alternatives: Click and Typer offer different APIs when a project needs them

CLI design patterns: arguments and flags

Three types of CLI inputs

  • Positional arguments: Required, order matters - git commit message.txt
  • Optional arguments (flags): Named, order flexible - git commit --amend -m "Fix bug"
  • Subcommands: Different actions in one tool - git add vs git commit

Design principles:

  • Intuitive: Users should guess the correct syntax
  • Consistent: Follow conventions from popular tools
  • Helpful: Provide clear error messages and help text
  • Forgiving: Accept common variations and abbreviations

Basic argparse: your first CLI

"""simple_greeter.py - A friendly CLI greeter."""
import argparse

def main():
    # Create the parser
    parser = argparse.ArgumentParser(
        description="A simple greeting tool",
        epilog="Thanks for using the greeter!"
    )

    # Add positional argument (required)
    parser.add_argument(
        "name",
        help="The name of the person to greet"
    )

    # Add optional flag
    parser.add_argument(
        "--loud",
        action="store_true",
        help="Greet in ALL CAPS"
    )

    # Parse the arguments
    args = parser.parse_args()

    # Use the arguments
    greeting = f"Hello, {args.name}!"
    if args.loud:
        greeting = greeting.upper()

    print(greeting)

if __name__ == "__main__":
    main()

Running the CLI: usage examples

Getting help:

$ uv run python simple_greeter.py --help
usage: simple_greeter.py [-h] [--loud] name

A simple greeting tool

positional arguments:
  name        The name of the person to greet

options:
  -h, --help  show this help message and exit
  --loud      Greet in ALL CAPS

Thanks for using the greeter!

Using the tool:

$ uv run python simple_greeter.py Alice
Hello, Alice!

$ uv run python simple_greeter.py Bob --loud
HELLO, BOB!

$ uv run python simple_greeter.py
usage: simple_greeter.py [-h] [--loud] name
simple_greeter.py: error: the following arguments are required: name

Advanced argparse: multiple arguments and types

"""api_fetcher.py - Fetch data from APIs with configurable options."""
import argparse

def fetch_data(api_name: str, resource: str, limit: int, output_file: str = None):
    """Fetch data from an API (implementation details omitted)."""
    print(f"Fetching {limit} {resource} from {api_name}...")
    if output_file:
        print(f"Saving to {output_file}")
    # Actual API call would go here
    return {"status": "success", "count": limit}

def main():
    parser = argparse.ArgumentParser(
        description="Fetch data from various APIs"
    )

    # Positional arguments
    parser.add_argument("api", help="API name (e.g., 'pokemon', 'jokes', 'weather')")
    parser.add_argument("resource", help="Resource to fetch (e.g., 'pikachu', 'random')")

    # Optional arguments with types
    parser.add_argument(
        "-l", "--limit",
        type=int,
        default=10,
        help="Number of items to fetch (default: 10)"
    )

    parser.add_argument(
        "-o", "--output",
        help="Output file path (if not specified, prints to console)"
    )

    parser.add_argument(
        "--format",
        choices=["json", "csv", "txt"],
        default="json",
        help="Output format (default: json)"
    )

    args = parser.parse_args()

    # Call the function with parsed arguments
    result = fetch_data(args.api, args.resource, args.limit, args.output)
    print(f"Result: {result}")

if __name__ == "__main__":
    main()

Subcommands: building multi-function tools

"""project_manager.py - Manage your project with subcommands."""
import argparse

def cmd_init(args):
    """Initialize a new project."""
    print(f"Initializing project: {args.name}")
    print(f"Template: {args.template}")

def cmd_build(args):
    """Build the project."""
    print(f"Building project...")
    if args.verbose:
        print("Verbose output enabled")

def cmd_deploy(args):
    """Deploy the project."""
    print(f"Deploying to {args.environment}")

def main():
    parser = argparse.ArgumentParser(description="Project management tool")
    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    # 'init' subcommand
    init_parser = subparsers.add_parser("init", help="Initialize a new project")
    init_parser.add_argument("name", help="Project name")
    init_parser.add_argument("--template", default="basic", help="Project template")
    init_parser.set_defaults(func=cmd_init)

    # 'build' subcommand
    build_parser = subparsers.add_parser("build", help="Build the project")
    build_parser.add_argument("-v", "--verbose", action="store_true")
    build_parser.set_defaults(func=cmd_build)

    # 'deploy' subcommand
    deploy_parser = subparsers.add_parser("deploy", help="Deploy the project")
    deploy_parser.add_argument("environment", choices=["dev", "staging", "prod"])
    deploy_parser.set_defaults(func=cmd_deploy)

    args = parser.parse_args()

    # Call the appropriate function
    if hasattr(args, "func"):
        args.func(args)
    else:
        parser.print_help()

if __name__ == "__main__":
    main()

CLI usability practices

  • Clear error messages: “Error: API key not found. Set POKEAPI_KEY environment variable.”
  • Progress indicators: Show activity for long-running operations
  • Colorized output: Use libraries like rich or colorama for visual hierarchy
  • Input validation: Fail fast with helpful messages, not cryptic stack traces
  • Exit codes: Return 0 for success, non-zero for failures (enables shell scripting)
  • Confirmation prompts: Ask before destructive operations
  • Verbose mode: --verbose or -v flag for debugging
  • Version flag: --version shows tool version

Practical example: API CLI

"""Command-line interface for an API project."""
import argparse
import sys
from typing import Optional

def fetch_resource(resource_type: str, resource_id: Optional[str], limit: int):
    """Fetch data from your API."""
    # Your API integration code here
    print(f"Fetching {resource_type}...")
    if resource_id:
        print(f"ID: {resource_id}")
    print(f"Limit: {limit}")
    # Return the data

def main():
    parser = argparse.ArgumentParser(
        prog="my_api_tool",
        description="Interact with [Your API Name] from the command line",
        epilog="Example: uv run python my_api_tool.py pokemon pikachu --format json"
    )

    parser.add_argument("resource", help="Resource type to fetch")
    parser.add_argument("id", nargs="?", help="Optional resource ID")
    parser.add_argument("-l", "--limit", type=int, default=10, help="Number of items")
    parser.add_argument("--format", choices=["json", "pretty"], default="pretty")
    parser.add_argument("-o", "--output", help="Save to file instead of printing")

    try:
        args = parser.parse_args()
        result = fetch_resource(args.resource, args.id, args.limit)
        print("Success!")
        return 0  # Success exit code
    except (OSError, ValueError) as error:
        print(f"Error: {error}", file=sys.stderr)
        return 1  # Error exit code

if __name__ == "__main__":
    sys.exit(main())

AI-assisted CLI development

Prompt strategies for building CLIs:

  • Starter prompt: “Create a Python argparse CLI for fetching data from the [API Name] API with options for resource type, limit, and output format”
  • Enhancement prompt: “Add subcommands for ‘fetch’, ‘list’, and ‘search’ operations”
  • Refinement prompt: “Improve error handling and add input validation for API parameters”
  • Testing prompt: “Generate test cases for this CLI including error scenarios”

CLI tasks an assistant can draft:

  • Generating boilerplate argparse setup
  • Drafting help text from the specified arguments
  • Adding input validation logic
  • Designing argument structures

Part 2: Python packages and project structure

From scripts to packages

One possible progression

  • Stage 1: Single .py file - my_script.py does everything
  • Stage 2: Multiple files - utils.py, api.py, cli.py in one folder
  • Stage 3: Package structure - Organized directories with __init__.py files
  • Stage 4: Installable package - Can run your own code through uv
  • Design choice: Add structure when it improves imports, tests, or maintenance

Why package structure matters

  • Code organization: Logical grouping of related functionality
  • Reusability: Import code across multiple projects
  • Collaboration: Team members work on different modules without conflicts
  • Testing: Easier to test individual components in isolation
  • Distribution: Share your code with others via PyPI
  • Reviewability: Makes module responsibilities and imports visible

Examples

  • Django: Models, views, templates organized in packages
  • Requests: requests/, requests/auth/, requests/models/
  • Your final project: Use modules or a package when that structure improves the chosen project

What is a Python package?

  • A regular package is a directory containing an __init__.py file
  • Module: A single .py file
  • Package: A directory of modules with __init__.py
  • Subpackage: A package inside another package
  • __init__.py: Marks directory as package, can contain initialization code
  • Namespace packages: Can omit __init__.py; this course uses regular packages unless a namespace is intentionally required

Basic package structure: a practical example

my_api_project/
│
├── my_api/                  # Main package directory
│   ├── __init__.py         # Marks this as a package
│   ├── api.py              # API client code
│   ├── cli.py              # CLI interface
│   └── utils.py            # Utility functions
│
├── tests/                   # Test directory
│   ├── __init__.py
│   ├── test_api.py
│   └── test_cli.py
│
├── main.py                  # Entry point script
├── pyproject.toml           # Project metadata and dependencies
├── uv.lock                  # Exact resolved environment
└── README.md               # Documentation

Using this structure:

# In main.py
from my_api.cli import main
from my_api.api import fetch_data
from my_api.utils import format_output

if __name__ == "__main__":
    main()

The __init__.py file: package initialization

Empty __init__.py (most common):

# my_api/__init__.py
# Empty file - marks directory as package

With initialization code:

# my_api/__init__.py
"""Public interface for the API client package."""

# Import key functions for convenient access
from my_api.api import fetch_data, APIClient
from my_api.utils import format_output

# Package metadata
__version__ = "1.0.0"
__author__ = "Your Name"

# Define public API - controls "from my_api import *"
__all__ = ["fetch_data", "APIClient", "format_output"]

Usage becomes cleaner:

# Instead of: from my_api.api import fetch_data
# You can do: from my_api import fetch_data

import my_api
print(my_api.__version__)  # 1.0.0

Import systems: absolute and relative

Absolute imports (preferred):

# my_api/cli.py
from my_api.api import fetch_data
from my_api.utils import format_output
import my_api

Relative imports (within a package):

# my_api/cli.py
from .api import fetch_data        # Same directory
from .utils import format_output   # Same directory
from ..other_package import helper # Parent directory

When to use each:

  • Absolute imports: Often clearer about the package a name comes from
  • Relative imports: Useful for large packages, makes refactoring easier
  • PEP 8 recommendation: Prefer absolute imports unless path becomes excessively long

The __name__ == "__main__" pattern

Understanding Python execution:

  • When Python runs a file directly: __name__ is set to "__main__"
  • When Python imports a file: __name__ is the module name
  • This allows code to be both a script (runnable) and a module (importable)

The pattern:

# my_api/api.py

def fetch_data(api_name: str):
    """Fetch data from an API."""
    print(f"Fetching from {api_name}")
    return {"data": "example"}

def main():
    """Entry point for running as a script."""
    result = fetch_data("pokemon")
    print(result)

# Script mode: runs when file is executed directly
if __name__ == "__main__":
    main()

Usage:

# Run as script
$ uv run python my_api/api.py
Fetching from pokemon
{'data': 'example'}
# Import as module (main() doesn't run)
from my_api.api import fetch_data

The src/ project layout

Example structure

my_api_project/
│
├── src/                     # Optional source-layout directory
│   └── my_api/
│       ├── __init__.py
│       ├── api.py
│       ├── cli.py
│       └── utils.py
│
├── tests/                   # Tests outside source tree
│   ├── __init__.py
│   ├── test_api.py
│   └── test_cli.py
│
├── docs/                    # Documentation
│   └── index.md
│
├── pyproject.toml           # Modern Python project metadata
├── README.md
└── .gitignore

Why src/ layout?

  • Import discipline: Ensures you test installed version, not local files
  • Namespace clarity: Prevents accidental relative imports
  • Documented option: PyPA compares source and flat layouts
  • Build isolation: Cleaner separation of source and build artifacts

Making code installable: pyproject.toml

Modern Python packaging with pyproject.toml:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "my-api-tool"
version = "0.1.0"
description = "An API client for [Your API]"
authors = [{name = "Your Name", email = "you@example.com"}]
readme = "README.md"
requires-python = ">=3.12"
dependencies = ["requests"]

[project.optional-dependencies]
dev = ["pytest", "ruff"]

[project.scripts]
my-api = "my_api.cli:main"  # Creates a 'my-api' command

Installing in editable mode:

# Install and lock the project for development
uv sync

# Now you can import it anywhere
uv run python -c "from my_api import fetch_data"

# And use the CLI command
uv run my-api pokemon pikachu

One project workflow with uv

  • uv manages Python versions, environments, dependencies, commands, and lockfiles
  • Reproducible: pyproject.toml declares dependencies and uv.lock records exact resolutions
  • No activation step: uv run executes inside the project environment
  • One local/CI interface: The same commands work on a laptop and in GitHub Actions
  • Written in Rust: A useful bridge to the next half of the course

Basic usage:

# Reproduce a committed project
uv sync --locked

# Add dependencies in your own project
uv add requests
uv add --dev pytest

# Run inside the project environment
uv run python -m pytest
uv run my-api --help

AI-assisted package scaffolding

Prompt strategies for project structure:

  • Initial scaffold: “Propose the smallest Python project tree for this API client, CLI, and tests. Explain the purpose of every file”
  • Alternative: “Compare a flat layout with a src/ layout for this project and name the configuration each requires”
  • Migration: “Plan a behavior-preserving split of this script into modules; do not edit until the current tests pass”
  • Documentation: “Generate a README with installation and usage instructions for my package”

Package tasks an assistant can draft:

  • Generating boilerplate directory structures
  • Creating __init__.py files with proper imports
  • Writing pyproject.toml configurations
  • Setting up testing frameworks
  • Drafting README sections from verified commands and behavior

Writing your own AGENTS.md

Week 02 introduced the format; your project needs one

# AGENTS.md: weather CLI

A Python 3.12 CLI that reports forecasts from the Open-Meteo API.
Environment managed with `uv`. Source lives in `src/weather_cli/`.

## Commands

- Install: `uv sync`
- Test: `uv run python -m pytest tests/ -v`
- Run: `uv run weather --city Cincinnati`

## Conventions

- NumPy-style docstrings on every public function
- HTTP calls belong in `api_client.py` and are mocked in tests
- Read the API key from the `WEATHER_API_KEY` environment variable; never commit it

## Before you finish

Run the tests. If one fails, fix the implementation, not the test.

This is not your README

  • The README tells a person what the project is and how to use it
  • AGENTS.md tells an agent how to work in the repository without breaking it
  • Commands, conventions, and constraints that would clutter a README belong here
  • Overlap is fine; link between the two instead of maintaining both

Practical example: final-project structure

One possible structure for the final project

my_final_project/
│
├── src/
│   └── my_api_tool/
│       ├── __init__.py          # Package initialization
│       ├── api_client.py        # API interaction logic
│       ├── cli.py               # argparse CLI interface
│       ├── models.py            # Data models/classes
│       └── utils.py             # Helper functions
│
├── tests/
│   ├── __init__.py
│   ├── test_api_client.py      # API tests
│   ├── test_cli.py              # CLI tests
│   └── test_models.py           # Model tests
│
├── .github/
│   └── workflows/
│       └── tests.yml            # GitHub Actions CI/CD
│
├── pyproject.toml               # Project configuration
├── README.md                    # Your documentation
├── AGENTS.md                    # Briefing for coding agents
├── .gitignore                   # Git exclusions
└── uv.lock                      # Exact resolved dependencies

Putting it all together: CLI and package structure

Entry point (main.py or src/my_api_tool/__main__.py):

"""Entry point for the my_api_tool CLI."""
from my_api_tool.cli import main

if __name__ == "__main__":
    main()

CLI module (src/my_api_tool/cli.py):

"""Command-line interface for API tool."""
import argparse
import sys
from my_api_tool.api_client import APIClient
from my_api_tool.utils import format_output

def main():
    parser = argparse.ArgumentParser(description="API client tool")
    parser.add_argument("resource", help="Resource to fetch")
    parser.add_argument("--format", choices=["json", "pretty"], default="pretty")

    args = parser.parse_args()

    try:
        client = APIClient()
        data = client.fetch(args.resource)
        print(format_output(data, args.format))
        return 0
    except (OSError, ValueError) as error:
        print(f"Error: {error}", file=sys.stderr)
        return 1

if __name__ == "__main__":
    sys.exit(main())

Usage after install:

uv sync
uv run my-api-tool pokemon --format json

Bridge to Rust: a preview of next week

Python packaging challenges we’ve seen:

  • Manual structure: Create directories, __init__.py files by hand
  • Import complexity: Absolute vs relative, __name__ == "__main__" patterns
  • Dependency management: Multiple tools (pip, venv, pyproject.toml)
  • Build system: Choose from many options (setuptools, hatch, poetry, PDM)

Next week in Rust:

  • Cargo provides one interface for common Rust project tasks
  • One command: cargo new my_project creates a conventional starting structure
  • Integrated commands: Dependency management, building, testing, and documentation
  • Conventions: cargo new supplies defaults, while larger projects still require design decisions
  • Comparison point: Compare Cargo’s defaults with Python’s explicit project configuration

Resources and further learning

Official documentation

Advanced topics

  • PEP 8 - Python style guide
  • PEP 621 - Project metadata in pyproject.toml
  • Entry points - Creating console scripts
  • Namespace packages - Splitting packages across distributions

Applying this to the final project

This week’s work session

  • Choose a clear structure; a src/ package is useful for larger Python projects but is not required
  • Add a CLI interface with argparse for easy testing and demonstration
  • Create clean imports between your modules
  • Consider an entry point in pyproject.toml when it improves installation and use
  • Document usage in your README with examples
  • Add an AGENTS.md with your real commands, conventions, and constraints
  • Test your CLI with different arguments and edge cases

Success criteria

  • Can run uv sync and launch the project with uv run
  • CLI works with intuitive arguments and helpful error messages
  • Code is organized in logical modules with clean imports
  • --help flag provides clear usage instructions
  • README explains installation and usage
  • AGENTS.md would orient an agent that has never seen the repository

Summary: key takeaways

  • CLI with argparse: Create command-line tools with generated help and validation
  • Package structure: Organize code with __init__.py, proper imports, and logical modules
  • __name__ == "__main__": Make code both runnable and importable
  • Modern tooling: pyproject.toml describes a project and uv reproduces its environment
  • Integration: A CLI plus clear module boundaries can improve testing and maintenance
  • Final project application: Apply the patterns that make your chosen project easier to run and test
  • Next week: Compare this workflow with Cargo’s defaults and commands

Lab 08 checkpoint: weather CLI

Complete the supplied three-module contract:

  • favorites.py: FavoritesManager stores case-insensitive favorites in JSON and recovers from missing or corrupted files
  • weather_api.py: WeatherAPI calls the specified current and forecast endpoints with params and timeout=10; format_current_weather() and format_forecast() produce the required text
  • weather.py: load_api_key(), build_parser(), and main(argv=None) implement the documented commands, favorite lookup, standard-error messages, and exit status
  • Automated tests use fake responses; manual API use requires an ignored config.py or the WEATHER_API_KEY environment variable
  • Do not modify the supplied tests, workflow, config.example.py, or root .gitignore
uv run python -m pytest week08/tests/ -v
git add week08/favorites.py week08/weather_api.py week08/weather.py
git commit -m "Complete Lab 08"
git push origin main

Confirm the Week 08 README badge is green; that badge is the complete 10-point grade.

Instructions: week08/lab08.md

Work session: build a maintainable Python application

Today’s goals

  • Choose a structure that fits the size of your final project
  • Implement a CLI interface with argparse
  • Create clean module organization
  • Test your CLI and fix any issues
  • Update your README with installation and usage instructions

Need help?

  • Review the slide examples
  • Use AI assistants for structure scaffolding
  • Ask questions as you work
  • Reference the official documentation
  • Test frequently as you refactor

Build one small, testable improvement before adding more structure.