A minimal Python project that prints a greeting, accepts a name from the command line, includes a pytest test suite, and uses a Makefile to drive common tasks. This tutorial covers project setup with uv, argument parsing, testing with pytest, and automation for a simple but complete workflow.

Prerequisites

  • Python 3.10 or later
  • uv 0.4 or later (curl -LsSf https://astral.sh/uv/install.sh | sh)
  • A Unix-like terminal (macOS, Linux, or WSL on Windows)
  • make installed (pre-installed on macOS and most Linux distributions)

Step 1: Initialize the project with uv

Create the file

uv init hello-world
cd hello-world

Detailed breakdown

  • uv init hello-world scaffolds a new Python project with a pyproject.toml, a main.py sample file, and a .python-version file.
  • pyproject.toml serves as the project manifest, replacing the need for setup.py or requirements.txt.
  • uv automatically creates a .venv virtual environment on first run.

Step 2: Add pytest and set up the project

Create the file

Remove the generated sample file and add pytest as a dev dependency:

rm main.py README.md
uv add --dev pytest

Detailed breakdown

  • rm main.py README.md removes the placeholder files generated by uv init since the project will use its own module structure.
  • uv add --dev pytest installs pytest and records it under [dependency-groups] in pyproject.toml. Dev dependencies are only needed during development and testing.

Step 3: Set up the project structure

Create the file

touch .gitignore

Add the code: .gitignore

__pycache__/
*.pyc
.venv/
.pytest_cache/
.DS_Store
*.log
tmp/

Detailed breakdown

  • __pycache__/ and *.pyc exclude Python bytecode files generated at runtime.
  • .venv/ excludes the virtual environment that uv manages locally.
  • .pytest_cache/ excludes pytest cache directories.
  • .DS_Store and tmp/ cover common OS and temporary artifacts.

Step 4: Create the greeter module

This module contains the core greeting logic, separated from the entry point so it can be tested independently.

Create the file

mkdir -p src
touch src/__init__.py
touch src/greeter.py

Add the code: src/greeter.py

"""Core greeting logic."""


def greet(name: str = "World") -> str:
    """Return a greeting string for the given name.

    Args:
        name: The name to greet. Defaults to "World".

    Returns:
        A formatted greeting string.
    """
    return f"Hello, {name}!"

Detailed breakdown

  • greet accepts an optional name parameter and defaults to "World" when no name is provided.
  • The function returns a string rather than printing directly. This makes it easy to test the return value without capturing stdout.
  • The module lives under src/ to keep application code separate from tests and project-root files.

Step 5: Create the command-line entry point

The entry point parses an optional --name argument and prints the greeting.

Create the file

touch src/main.py

Add the code: src/main.py

"""Command-line entry point for the hello-world application."""

import argparse

from src.greeter import greet


def main() -> None:
    """Parse arguments and print a greeting."""
    parser = argparse.ArgumentParser(description="Print a greeting.")
    parser.add_argument(
        "--name",
        default="World",
        help="Name to greet (default: World)",
    )
    args = parser.parse_args()
    print(greet(args.name))


if __name__ == "__main__":
    main()

Detailed breakdown

  • argparse is used instead of reading sys.argv directly. This gives automatic --help support and clean error messages for invalid arguments.
  • The --name flag defaults to "World", matching the greet function default, so running with no arguments produces Hello, World!.
  • The if __name__ == "__main__" guard ensures main() only runs when the file is executed directly.
  • The import from src.greeter import greet assumes execution from the project root directory.

Step 6: Create the test suite

Tests use pytest to verify both the default greeting and a custom name greeting.

Create the file

mkdir -p tests
touch tests/__init__.py
touch tests/test_greeter.py

Add the code: tests/test_greeter.py

"""Tests for the greeter module."""

from src.greeter import greet


def test_default_greeting() -> None:
    """greet() with no arguments returns 'Hello, World!'."""
    assert greet() == "Hello, World!"


def test_custom_name() -> None:
    """greet('Python') returns 'Hello, Python!'."""
    assert greet("Python") == "Hello, Python!"


def test_empty_string() -> None:
    """greet('') returns 'Hello, !'."""
    assert greet("") == "Hello, !"

Detailed breakdown

  • Tests use plain functions with assert statements instead of unittest.TestCase classes. This is the idiomatic pytest style — simpler and less boilerplate.
  • test_default_greeting verifies the default parameter path.
  • test_custom_name verifies that a supplied name appears in the output.
  • test_empty_string documents the edge case behavior when an empty string is passed.
  • Each function name starts with test_ so pytest discovers them automatically.

Step 7: Create the Makefile

The Makefile provides a single command interface for running, testing, and cleaning the project. Running make with no arguments prints a help screen.

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

.PHONY: help run test clean

help: ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "  %-15s %s\n", $$1, $$2}'

run: ## Run the application (use NAME= to set a custom name)
	uv run python -m src.main $(if $(NAME),--name "$(NAME)",)

test: ## Run the test suite
	uv run pytest tests/ -v

clean: ## Remove bytecode and cache files
	find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
	find . -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true
	find . -name '*.pyc' -delete 2>/dev/null || true

Detailed breakdown

  • .DEFAULT_GOAL := help ensures that running plain make prints the help screen rather than executing a build target.
  • The help target uses grep and awk to extract targets annotated with ## comments and format them into a readable list.
  • run uses uv run python -m src.main to execute the module inside the uv-managed virtual environment. The optional NAME variable lets users run make run NAME=Python.
  • test uses uv run pytest tests/ -v to run all test files under tests/ with verbose output.
  • clean removes __pycache__, .pytest_cache, and .pyc files. The || true prevents make from failing if no matches are found.

Step 8: Run and validate

Execute the following commands from the hello-world/ directory.

Verify the help screen

cd hello-world && make

Expected output:

  help            Show this help screen
  run             Run the application (use NAME= to set a custom name)
  test            Run the test suite
  clean           Remove bytecode and cache files

Run the application

make run

Expected output:

Hello, World!

Run with a custom name

make run NAME=Python

Expected output:

Hello, Python!

Run the tests

make test

Expected output:

tests/test_greeter.py::test_default_greeting PASSED
tests/test_greeter.py::test_custom_name PASSED
tests/test_greeter.py::test_empty_string PASSED

Troubleshooting

  • ModuleNotFoundError: No module named 'src': Make sure you run commands from the hello-world/ project root, not from inside src/ or tests/.
  • make: *** No rule to make target ...: Verify the Makefile uses tabs for indentation, not spaces. Many editors convert tabs to spaces by default.
  • uv: command not found: Install uv with curl -LsSf https://astral.sh/uv/install.sh | sh and restart your shell.

Recap

This tutorial built a complete Python hello-world project with:

  1. A greeter module with a testable greet function
  2. A command-line entry point using argparse
  3. A pytest test suite with three test cases in a tests/ directory
  4. A Makefile with a default help screen, run, test, and clean targets
  5. Project managed with uv for dependency management and script execution

Next improvements could include adding type checking with mypy, a formatter like ruff, or adding a script entry point in pyproject.toml.