Step-by-Step Guide to Building Your First Python Package

Creating a Python module requires more than just writing code. Modern development demands proper tooling, standardized workflows, and automation. In this guide, we’ll walk through setting up a robust Python project, complete with testing, linting, CI/CD pipelines, and PyPI publishing.


TL;DR

  • Use pyproject.toml as your primary configuration file-it’s the modern standard (PEP 518/621) for Python project metadata, replacing setup.py for most use cases.
  • Structure your project with src/ layout-place your package code in src/your_package_name/ to enforce clean imports and prevent testing against the source directory.
  • Automate development tasks with a Makefile-define targets for testing, linting, formatting, and building to ensure consistency across your team.
  • Write unit tests with pytest-it’s the most popular and flexible testing framework for Python, with built-in discovery and powerful plugins.
  • Publish to PyPI using GitHub Actions-set up Trusted Publishers for secure, automated releases triggered by GitHub releases.

Setting Up Your Development Environment

Before diving into module creation, ensure you have Python installed. As of August 2026, Python 3.13.3 is the latest stable release.

Installation by Operating System

Windows:

  1. Download the latest Python installer from python.org
  2. Run the installer-ensure you check “Add Python to PATH”
  3. Verify installation: python --version in PowerShell/CMD

Linux (Debian/Ubuntu):

sudo apt update
sudo apt install python3 python3-pip python3-venv

Linux (Fedora/CentOS/RHEL):

sudo dnf install python3 python3-pip

macOS:

brew update
brew install python@3.13

Recommendation for Windows developers: Use WSL2 (Windows Subsystem for Linux) for a more seamless Python development experience. Many Python tools are optimized for Unix-like environments.

Essential Development Tools

Install these tools globally or in each virtual environment:

pip install setuptools wheel flake8 pytest black pytest-cov mypy
ToolPurpose
setuptoolsPackaging and distribution
wheelBuilt package format
flake8Code linting (PEP 8 compliance)
pytestUnit testing framework
blackCode formatting (opinionated, automatic)
pytest-covTest coverage reporting
mypyStatic type checking

Project Folder Structure

A well-defined structure is crucial for maintainability. Here’s the recommended layout:

your_package_name/
├── .github/
│ └── workflows/
│ ├── python-publish.yml # PyPI publishing workflow
│ └── tests.yml # CI test workflow
├── src/
│ └── your_package_name/
│ ├── __init__.py
│ ├── core.py
│ └── utils.py
├── tests/
│ ├── __init__.py
│ └── test_core.py
├── .gitattributes
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── Makefile
├── pyproject.toml
├── README.md
└── requirements.txt

Directory Breakdown

ComponentPurpose
.github/workflows/GitHub Actions CI/CD pipelines
src/your_package_name/Package source code (the src/ layout is now recommended over placing code at the root)
tests/Unit test files
pyproject.tomlModern project metadata and build configuration
MakefileAutomation for development tasks
README.mdProject documentation (first point of contact for users)
CHANGELOG.mdVersion history following Keep a Changelog
LICENSEOpen-source license terms

Why the src/ Layout?

The src/ layout (placing package code in src/your_package_name/) is now the recommended practice:

  1. Prevents accidental testing against the source directory-without src/, tests may import the local source instead of the installed package
  2. Enforces proper packaging-you test what users actually install
  3. Cleaner project root-distinguishes between code and project configuration

Automating Tasks with a Makefile

A Makefile automates repetitive development tasks. It ensures consistency across team members and reduces human error.

Example Makefile

.PHONY: test lint format install uninstall coverage clean check
# Default target
help:
@echo "Available commands:"
@echo " make test - Run all tests with pytest"
@echo " make lint - Run flake8 linting"
@echo " make format - Format code with black"
@echo " make coverage - Generate test coverage report"
@echo " make install - Install package in editable mode"
@echo " make uninstall - Uninstall the package"
@echo " make check - Run both lint and test"
@echo " make clean - Remove build artifacts"
test:
pytest tests/ -v
lint:
flake8 src/ tests/
format:
black src/ tests/
coverage:
pytest --cov=your_package_name tests/ --cov-report=term --cov-report=html
@echo "HTML coverage report generated in htmlcov/"
install:
pip install -e .
uninstall:
pip uninstall -y your_package_name
check: lint test
clean:
rm -rf build/ dist/ *.egg-info htmlcov/ .pytest_cache/
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true

Key Commands

make check # Run linting and tests before committing
make test # Run only tests
make coverage # Generate coverage report
make install # Install package in editable mode (-e)
make clean # Remove build artifacts

Note: On Windows without make, you can use pytest and flake8 directly, or install make via Chocolatey: choco install make

Managing Project Metadata with pyproject.toml

pyproject.toml is the modern standard for Python project configuration, defined by PEP 518 (build system requirements) and PEP 621 (project metadata). It replaces setup.py and setup.cfg for most use cases.

Example pyproject.toml

[project]
name = "your_package_name"
version = "1.0.0"
description = "A brief description of your package"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
authors = [
 {name = "Your Name", email = "your.email@example.com"}
]
maintainers = [
 {name = "Your Name", email = "your.email@example.com"}
]
keywords = ["python", "library", "utilities"]
classifiers = [
 "Development Status :: 5 - Production/Stable",
 "Intended Audience :: Developers",
 "License :: OSI Approved :: MIT License",
 "Programming Language :: Python :: 3",
 "Programming Language :: Python :: 3.10",
 "Programming Language :: Python :: 3.11",
 "Programming Language :: Python :: 3.12",
 "Programming Language :: Python :: 3.13",
 "Typing :: Typed",
]
dependencies = [
 "typing-extensions>=4.0.0; python_version<'3.11'",
]
[project.optional-dependencies]
dev = [
 "pytest>=8.0.0",
 "pytest-cov>=4.0.0",
 "flake8>=7.0.0",
 "black>=24.0.0",
 "mypy>=1.0.0",
]
[project.urls]
Homepage = "https://github.com/your_username/your_package_name"
Repository = "https://github.com/your_username/your_package_name"
"Bug Tracker" = "https://github.com/your_username/your_package_name/issues"
"Change Log" = "https://github.com/your_username/your_package_name/blob/main/CHANGELOG.md"
[build-system]
build-backend = "setuptools.build_meta"
requires = ["setuptools>=65.0.0", "wheel"]
[tool.black]
line-length = 88
target-version = ['py310', 'py311', 'py312', 'py313']
[tool.pytest.ini_options]
minversion = "8.0"
addopts = "-ra -q"
testpaths = ["tests"]
[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true
ignore_missing_imports = true

Key Sections Explained

SectionPurpose
[project]Core metadata: name, version, dependencies, license
[project.optional-dependencies]Development and extra dependencies (install with pip install .[dev])
[project.urls]Links to project resources (Homepage, Repository, Bug Tracker)
[build-system]Specifies the build backend and requirements
[tool.*]Configuration for development tools (black, pytest, mypy)

Installing with Optional Dependencies

# Install with development dependencies
pip install -e .[dev]
# Install with specific extras
pip install your_package_name[test,aws]

Writing Unit Tests with pytest

Unit tests verify individual components of your code. pytest is the most widely used testing framework in the Python ecosystem.

Example Test File: tests/test_core.py

import pytest
from your_package_name.core import add, subtract, divide
def test_add():
 assert add(2, 3) == 5
 assert add(-1, 1) == 0
 assert add(0, 0) == 0
def test_subtract():
 assert subtract(10, 4) == 6
 assert subtract(0, 5) == -5
def test_divide():
 assert divide(10, 2) == 5.0
 assert divide(5, 2) == 2.5
def test_divide_by_zero():
 with pytest.raises(ZeroDivisionError):
 divide(10, 0)
# Parameterized tests for multiple cases
@pytest.mark.parametrize("a, b, expected", [
 (1, 2, 3),
 (10, 20, 30),
 (-5, 5, 0),
])
def test_add_parametrized(a, b, expected):
 assert add(a, b) == expected

Running Tests

# Run all tests
make test
# Run with coverage
make coverage
# Run specific test file
pytest tests/test_core.py -v
# Run tests matching a pattern
pytest -k "test_add"

Benefits of Unit Testing

  • Early bug detection-catch issues before they reach production
  • Code confidence-changes won’t introduce regressions
  • Living documentation-tests demonstrate intended usage
  • Better design-testability forces modular, decoupled code

Publishing to PyPI

The Python Package Index (PyPI) is the official repository for Python packages. Publishing your package makes it available via pip install your_package_name.

Step 1: Prepare Your Environment

# Format and lint your code
make format
make lint
# Run all tests
make test
# Build the package
python -m build

Step 2: Set Up PyPI Account

  1. Create an account at pypi.org
  2. Set up Trusted Publishers (recommended) or create an API token

Trusted Publishers is the modern, secure method for publishing to PyPI. It uses OIDC (OpenID Connect) to authenticate without storing tokens as secrets.

  1. Go to PyPI → Account settings → Publishing
  2. Click Add a new pending publisher
  3. Fill in:
    • PyPI Project Name:your_package_name
    • Owner: Your GitHub username or organization
    • Repository name: Your GitHub repository name
    • Workflow name:python-publish.yml

Step 4: Create GitHub Actions Workflow

Create .github/workflows/python-publish.yml:

name: Publish Python Package
on:
 release:
 types: [published]
jobs:
 deploy:
 runs-on: ubuntu-latest
 permissions:
 id-token: write # Required for OIDC (Trusted Publishers)
 contents: read
 steps:
 - uses: actions/checkout@v4
 - name: Set up Python
 uses: actions/setup-python@v5
 with:
 python-version: '3.13'
 - name: Install dependencies
 run: |
 python -m pip install --upgrade pip
 pip install build
 - name: Build package
 run: python -m build
 - name: Publish to PyPI
 uses: pypa/gh-action-pypi-publish@release/v1

Step 5: Create a GitHub Release

  1. Go to your GitHub repository → ReleasesCreate a new release
  2. Choose a tag (e.g., v1.0.0)
  3. Add release notes from your CHANGELOG.md
  4. Publish the release

The GitHub Actions workflow will automatically build and publish your package to PyPI.

Alternative: API Token Authentication

If you prefer using an API token:

  1. Generate an API token on PyPI
  2. Add it as PYPI_API_TOKEN in GitHub Secrets
  3. Update the publish workflow to use the token:
- name: Publish to PyPI
 uses: pypa/gh-action-pypi-publish@release/v1
 with:
 password: ${{ secrets.PYPI_API_TOKEN }}

CI Testing Workflow

Create .github/workflows/tests.yml to run tests on every push:

name: Run Tests
on:
 push:
 branches: [ main ]
 pull_request:
 branches: [ main ]
jobs:
 test:
 runs-on: ubuntu-latest
 strategy:
 matrix:
 python-version: ['3.10', '3.11', '3.12', '3.13']
 steps:
 - uses: actions/checkout@v4
 - name: Set up Python ${{ matrix.python-version }}
 uses: actions/setup-python@v5
 with:
 python-version: ${{ matrix.python-version }}
 - name: Install dependencies
 run: |
 python -m pip install --upgrade pip
 pip install -e .[dev]
 - name: Lint
 run: flake8 src/ tests/
 - name: Test
 run: pytest tests/ -v --cov=your_package_name
 - name: Upload coverage
 uses: codecov/codecov-action@v4

Best Practices Summary

AreaRecommendation
StructureUse src/ layout with pyproject.toml as the primary config
VersioningFollow Semantic Versioning (MAJOR.MINOR.PATCH)
TestingWrite tests for edge cases, not just happy paths. Use parametrize for multiple inputs
DocumentationKeep README.md and CHANGELOG.md updated with each release
AutomationUse make for local tasks, GitHub Actions for CI/CD
PublishingUse Trusted Publishers (OIDC) for security; avoid storing long-lived tokens

Key Takeaways

  1. Use pyproject.toml as your primary configuration file-it’s the modern standard and supports both build configuration and tool settings.
  2. The src/ layout is recommended-it prevents accidental imports from the source directory and tests what users actually install.
  3. Automate development tasks with a Makefile-standardize testing, linting, formatting, and building across your team.
  4. Write comprehensive tests with pytest-include parametrized tests, edge cases, and coverage reporting.
  5. Publish to PyPI using Trusted Publishers-the OIDC-based method is more secure than API tokens and requires no secret management.
  6. Use GitHub Actions for CI/CD-automate testing on every push and publishing on every release.

Conclusion

Building a professional Python package involves more than writing code. A well-structured project with automated testing, linting, and CI/CD reduces technical debt, improves collaboration, and makes your package easier for others to use.

Start with a clean project structure using the src/ layout and pyproject.toml. Add a Makefile to automate common tasks. Write thorough tests with pytest. Finally, set up GitHub Actions to test every commit and publish to PyPI on every release.

These practices will serve you whether you’re building a utility library for internal use or a widely used open-source project.

Resources

Need help with your Python development? Playful Sparkle has been engineering digital products since 2004, offering App Development, Web Development, and UI/UX & Web Design services. Contact us to discuss how we can help with your next project.

Was this helpful - Post
Zsolt Oroszlány

Zsolt Oroszlány

Founder & Chief Creative Officer of Playful Sparkle since 2004, combining business leadership, digital strategy, design, and software engineering to help organizations build effective digital solutions. Regularly publishes insights on web development, SEO, design, and emerging technologies.