Skip to content
intermediate Phase 7 · Python Packages & Projects

Project Structure & Packaging

Set up pyproject.toml, setup.py, and standard Python project layout.

1h
0 problems
Topic Progress 0%

Project Layout

Standard Project Structure

my_project/
├── src/
│   └── my_project/          # Main package
│       ├── __init__.py
│       ├── core.py           # Core logic
│       ├── models.py         # Data models
│       ├── services.py       # Business logic
│       └── utils.py          # Utilities
├── tests/                    # Test directory
│   ├── __init__.py
│   ├── conftest.py           # Pytest fixtures
│   ├── test_core.py
│   └── test_services.py
├── config/                   # Configuration files
│   ├── settings.py
│   └── logging.py
├── scripts/                  # Utility scripts
│   └── seed_database.py
├── docs/                     # Documentation
│   └── README.md
├── .env                      # Environment variables (not in git)
├── .gitignore
├── pyproject.toml            # Project metadata
├── requirements.txt          # Dependencies
├── requirements-dev.txt      # Development dependencies
└── README.md

src Layout vs Flat Layout

# src layout (recommended)
src/
└── my_package/
    └── __init__.py

# Flat layout (simpler)
my_package/
└── __init__.py

# src layout benefits:
# - Prevents accidental imports from project root
# - Clearer package boundaries
# - Better for testing

init.py Best Practices

# my_package/__init__.py

# Option 1: Empty (just marks as package)

# Option 2: Export public API
from .core import main_function
from .models import User, Product
from .utils import helper_function

__all__ = ['main_function', 'User', 'Product', 'helper_function']

# Option 3: Lazy imports (for large packages)
import importlib

def __getattr__(name):
    if name == 'heavy_module':
        return importlib.import_module('.heavy_module', __name__)
    raise AttributeError(f'module {__name__!r} has no attribute {name!r}')

Configuration Management

Environment Variables

# config/settings.py
import os
from pathlib import Path

# Base directory
BASE_DIR = Path(__file__).resolve().parent.parent

# Database
DATABASE_URL = os.getenv('DATABASE_URL', 'sqlite:///db.sqlite3')

# API Keys
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key')
API_KEY = os.getenv('API_KEY')

# Debug mode
DEBUG = os.getenv('DEBUG', 'False').lower() == 'true'

# Logging
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')

.env File

# .env (not committed to git)
DATABASE_URL=postgresql://user:pass@localhost/mydb
SECRET_KEY=my-secret-key
DEBUG=true
LOG_LEVEL=DEBUG

python-dotenv

# Load .env file
from dotenv import load_dotenv
load_dotenv()  # Loads from .env in current directory

# Or specify path
from pathlib import Path
load_dotenv(Path(__file__).parent / '.env')

# Now access with os.getenv
import os
db_url = os.getenv('DATABASE_URL')

Configuration Classes

from dataclasses import dataclass
import os

@dataclass
class DatabaseConfig:
    url: str = 'sqlite:///db.sqlite3'
    pool_size: int = 5
    echo: bool = False

@dataclass
class AppConfig:
    debug: bool = False
    secret_key: str = ''
    database: DatabaseConfig = None
    
    def __post_init__(self):
        if self.database is None:
            self.database = DatabaseConfig()

# Load from environment
def load_config() -> AppConfig:
    return AppConfig(
        debug=os.getenv('DEBUG', 'False').lower() == 'true',
        secret_key=os.getenv('SECRET_KEY', ''),
        database=DatabaseConfig(
            url=os.getenv('DATABASE_URL', 'sqlite:///db.sqlite3')
        )
    )

config = load_config()

Logging Setup

import logging
from config.settings import LOG_LEVEL

def setup_logging():
    logging.basicConfig(
        level=getattr(logging, LOG_LEVEL),
        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
        handlers=[
            logging.StreamHandler(),
            logging.FileHandler('app.log')
        ]
    )

# Usage
logger = logging.getLogger(__name__)

def my_function():
    logger.info('Starting function')
    logger.debug('Debug info')
    logger.warning('Warning!')
    logger.error('Error occurred')

Testing Structure

Test Directory Structure

tests/
├── __init__.py
├── conftest.py           # Shared fixtures
├── unit/                 # Unit tests
│   ├── __init__.py
│   ├── test_models.py
│   └── test_utils.py
├── integration/          # Integration tests
│   ├── __init__.py
│   └── test_database.py
└── fixtures/             # Test data
    ├── users.json
    └── products.json

conftest.py

# tests/conftest.py
import pytest
from my_package.models import User
from my_package.database import create_test_db

@pytest.fixture
def sample_user():
    return User(name='Alice', email='alice@example.com')

@pytest.fixture
def test_db():
    db = create_test_db()
    yield db
    db.cleanup()

@pytest.fixture
def client(app):
    return app.test_client()

Test Naming Conventions

# tests/unit/test_models.py
import pytest
from my_package.models import User

class TestUser:
    def test_creation(self, sample_user):
        assert sample_user.name == 'Alice'
    
    def test_email_validation(self):
        with pytest.raises(ValueError):
            User(name='Alice', email='invalid')
    
    def test_repr(self, sample_user):
        assert 'Alice' in repr(sample_user)

# Or function-based
def test_user_creation():
    user = User(name='Alice', email='alice@example.com')
    assert user.name == 'Alice'

# Pattern: test_<what>_<condition>_<expected>
def test_user_creation_with_valid_email_succeeds():
    pass

def test_user_creation_with_invalid_email_raises_error():
    pass

Running Tests

# Run all tests
pytest

# Run specific file
pytest tests/unit/test_models.py

# Run with coverage
pytest --cov=my_package

# Run only failed tests
pytest --lf

# Verbose output
pytest -v

# Stop on first failure
pytest -x