Bài 19: Python Best Practices

Mục Tiêu Bài Học

Sau khi hoàn thành bài này, bạn sẽ:

  • ✅ Follow PEP 8 style guide
  • ✅ Organize code hiệu quả
  • ✅ Viết documentation (docstrings)
  • ✅ Áp dụng comments best practices
  • ✅ Hiểu SOLID principles
  • ✅ Sử dụng design patterns

Tại Sao Cần Best Practices?

Best practices giúp:

  • Readable code - Dễ đọc và maintain
  • Collaboration - Team work hiệu quả
  • Bug prevention - Tránh common mistakes
  • Professional - Code quality cao
# ❌ Bad codedef f(x,y):    return x+y # ✅ Good codedef add_numbers(first_number: int, second_number: int) -> int:    """Add two numbers and return the result."""    return first_number + second_number

1. PEP 8 - Python Style Guide

PEP 8 là official style guide cho Python code.

Indentation và Spacing

# ✅ Use 4 spaces per indentation leveldef function():    if condition:        do_something()        do_another_thing() # ❌ Don't use tabs or inconsistent spacingdef function():  if condition:  # 2 spaces      do_something()  # Mix tabs and spaces # ✅ Line length: max 79 characters (or 120 for modern)result = some_function(    first_argument,    second_argument,    third_argument) # ✅ Blank linesclass MyClass:    """Class docstring."""        def method_one(self):        """Method docstring."""        pass        def method_two(self):        """Another method."""        pass  # Two blank lines between top-level definitionsdef standalone_function():    """Function docstring."""    pass

Imports

# ✅ Imports at top of file, groupedimport osimport sysfrom typing import List, Dict import requestsimport numpy as np from mypackage import module1from mypackage.subpackage import module2 # ❌ Don't use wildcard importsfrom module import *  # Unclear what's imported # ❌ Don't use multiple imports on one lineimport os, sys  # Hard to read # ✅ One import per lineimport osimport sys # ✅ Group imports:# 1. Standard library# 2. Third-party packages# 3. Local modules

Naming Conventions

# ✅ snake_case for functions and variablesdef calculate_total_price(items):    total_price = 0    return total_price # ✅ PascalCase for classesclass ShoppingCart:    pass class UserAccount:    pass # ✅ UPPER_CASE for constantsMAX_RETRY_COUNT = 3DEFAULT_TIMEOUT = 30API_BASE_URL = "https://api.example.com" # ✅ _single_leading_underscore for internal useclass MyClass:    def __init__(self):        self._internal_variable = 10        def _internal_method(self):        pass # ✅ __double_leading_underscore for name manglingclass Parent:    def __init__(self):        self.__private = 10  # Becomes _Parent__private # ❌ Avoid single letter names (except counters)def f(x, y):  # Unclear    return x + y # ✅ Use descriptive namesdef calculate_distance(point1, point2):    return ((point2[0] - point1[0])**2 + (point2[1] - point1[1])**2)**0.5

Whitespace

# ✅ Operatorsresult = x + yis_equal = a == bvalue = x * 2 + y * 3 # ❌ Don't add extra spacesresult = x+y  # No spacesresult = x  +  y  # Too many spaces # ✅ Function callsfunction(arg1, arg2, kwarg1=value1) # ❌ Don't add spacesfunction ( arg1 , arg2 )  # Wrongfunction(arg1,arg2)  # Missing spaces # ✅ Slicingmy_list[1:5]my_list[start:end]my_list[::2] # ❌ Slicing with spacesmy_list[1 : 5]  # Wrong # ✅ Trailing commas (for multi-line)items = [    'item1',    'item2',    'item3',  # Trailing comma makes diffs cleaner]

Tools để Check PEP 8

# Install toolspip install flake8 black isort # Check style with flake8flake8 mycode.py # Auto-format with blackblack mycode.py # Sort imports with isortisort mycode.py # Configure in pyproject.toml[tool.black]line-length = 88target-version = ['py38', 'py39', 'py310'] [tool.isort]profile = "black"line_length = 88

2. Code Organization

Project Structure

# ✅ Good project structuremyproject/├── README.md├── LICENSE├── setup.py├── pyproject.toml├── requirements.txt├── .gitignore├── src/│   └── myproject/│       ├── __init__.py│       ├── core/│       │   ├── __init__.py│       │   ├── engine.py│       │   └── processor.py│       ├── utils/│       │   ├── __init__.py│       │   ├── helpers.py│       │   └── validators.py│       └── config/│           ├── __init__.py│           └── settings.py├── tests/│   ├── __init__.py│   ├── test_core.py│   └── test_utils.py├── docs/│   ├── index.md│   └── api.md└── examples/    └── basic_usage.py

Module Organization

# ✅ Good module structure"""Module docstring explaining purpose. This module provides utilities for data processing.""" # Standard library importsimport osimport sysfrom typing import List, Dict # Third-party importsimport requestsimport pandas as pd # Local importsfrom myproject.utils import helper # ConstantsMAX_RETRIES = 3DEFAULT_TIMEOUT = 30 # Module-level variables_cache = {} # Classesclass DataProcessor:    """Main data processor class."""    pass # Functionsdef process_data(data: List[Dict]) -> List[Dict]:    """Process data and return results."""    pass # Main executionif __name__ == "__main__":    # Test code    pass

Function Organization

# ✅ Functions should do ONE thingdef get_user_and_send_email(user_id):  # ❌ Does two things    user = get_user(user_id)    send_email(user.email) # ✅ Split into separate functionsdef get_user(user_id):    """Get user by ID."""    # Implementation    pass def send_email(email):    """Send email to user."""    # Implementation    pass def notify_user(user_id):    """Get user and send notification."""    user = get_user(user_id)    send_email(user.email) # ✅ Keep functions short (< 50 lines ideally)# ✅ Clear function names that describe what they do# ✅ Limit parameters (max 3-5)

Class Organization

# ✅ Good class structureclass UserAccount:    """    User account management.        Attributes:        username: User's username        email: User's email address    """        # Class variables    MAX_LOGIN_ATTEMPTS = 3        def __init__(self, username: str, email: str):        """Initialize user account."""        # Public attributes        self.username = username        self.email = email                # Protected attributes (internal use)        self._login_attempts = 0                # Private attributes (name mangling)        self.__password_hash = None        # Public methods    def login(self, password: str) -> bool:        """Attempt to login user."""        pass        def reset_password(self):        """Reset user password."""        pass        # Protected methods    def _verify_password(self, password: str) -> bool:        """Verify password (internal use)."""        pass        # Private methods    def __hash_password(self, password: str) -> str:        """Hash password (strongly internal)."""        pass        # Properties    @property    def is_active(self) -> bool:        """Check if account is active."""        return self._login_attempts < self.MAX_LOGIN_ATTEMPTS        # Magic methods    def __str__(self) -> str:        return f"UserAccount(username={self.username})"        def __repr__(self) -> str:        return f"UserAccount(username={self.username!r}, email={self.email!r})"

3. Documentation

Docstrings

# ✅ Module docstring"""User authentication module. This module provides functions and classes for user authentication,including login, logout, and session management. Example:    >>> from myproject import auth    >>> user = auth.login('username', 'password')""" # ✅ Class docstring (Google style)class UserAccount:    """    Represents a user account in the system.        This class handles user account operations including authentication,    profile management, and permissions.        Attributes:        username (str): The user's unique username.        email (str): The user's email address.        created_at (datetime): Account creation timestamp.        Example:        >>> user = UserAccount("john_doe", "[email protected]")        >>> user.login("password123")        True    """        def __init__(self, username: str, email: str):        """        Initialize a new user account.                Args:            username: The unique username for this account.            email: The user's email address.                Raises:            ValueError: If username or email is invalid.        """        pass # ✅ Function docstring (Google style)def calculate_discount(    price: float,    discount_percent: float,    max_discount: float = 100.0) -> float:    """    Calculate discounted price with optional maximum discount.        This function calculates the final price after applying a percentage    discount, ensuring the discount doesn't exceed the maximum allowed.        Args:        price: Original price before discount.        discount_percent: Discount percentage (0-100).        max_discount: Maximum discount amount in currency units.            Defaults to 100.0.        Returns:        Final price after applying discount.        Raises:        ValueError: If price is negative or discount_percent is invalid.        Example:        >>> calculate_discount(100, 20)        80.0        >>> calculate_discount(100, 50, max_discount=30)        70.0    """    if price < 0:        raise ValueError("Price cannot be negative")        if not 0 <= discount_percent <= 100:        raise ValueError("Discount percent must be between 0 and 100")        discount = price * (discount_percent / 100)    discount = min(discount, max_discount)        return price - discount # ✅ NumPy/SciPy style docstring (alternative)def process_data(data, normalize=True):    """    Process input data with optional normalization.        Parameters    ----------    data : array_like        Input data to process.    normalize : bool, optional        Whether to normalize data. Default is True.        Returns    -------    processed_data : ndarray        Processed and optionally normalized data.        See Also    --------    normalize_data : Function to normalize data separately.        Notes    -----    The normalization uses min-max scaling.        Examples    --------    >>> data = [1, 2, 3, 4, 5]    >>> process_data(data)    array([0. , 0.25, 0.5 , 0.75, 1. ])    """    pass

Comments

# ✅ Good comments explain WHY, not WHAT# Bad: Add 1 to countercounter += 1 # Good: Increment counter to track processed itemscounter += 1 # ✅ Use comments for complex logicdef calculate_tax(amount, rate):    """Calculate tax on amount."""    # Apply progressive tax rate based on brackets    # Bracket 1: 0-10000 at 10%    # Bracket 2: 10001-50000 at 20%    # Bracket 3: 50001+ at 30%        if amount <= 10000:        return amount * 0.10    elif amount <= 50000:        return 1000 + (amount - 10000) * 0.20    else:        return 9000 + (amount - 50000) * 0.30 # ✅ TODO comments for future workdef process_payment(amount):    """Process payment."""    # TODO: Add support for multiple currencies    # TODO: Implement retry logic for failed payments    pass # ✅ FIXME comments for known issuesdef calculate_total(items):    """Calculate total price."""    # FIXME: This doesn't handle negative prices correctly    return sum(item.price for item in items) # ❌ Don't comment obvious codex = x + 1  # Increment x by 1  (unnecessary) # ❌ Don't leave commented-out code# def old_function():#     pass  # Remove instead of commenting

4. Error Handling

Exception Handling Best Practices

# ✅ Catch specific exceptionstry:    file = open('data.txt')    data = file.read()except FileNotFoundError:    print("File not found")except PermissionError:    print("Permission denied")finally:    file.close() # ❌ Don't catch all exceptions blindlytry:    risky_operation()except:  # Catches everything, even KeyboardInterrupt!    pass # ✅ Use context managerswith open('data.txt') as file:    data = file.read()# File automatically closed # ✅ Custom exceptionsclass ValidationError(Exception):    """Raised when data validation fails."""    pass class DatabaseError(Exception):    """Raised when database operation fails."""    pass def validate_email(email):    if '@' not in email:        raise ValidationError(f"Invalid email: {email}") # ✅ Chain exceptions for contexttry:    process_data()except ValueError as e:    raise DataProcessingError("Failed to process data") from e # ✅ Log exceptions properlyimport logging logger = logging.getLogger(__name__) try:    risky_operation()except Exception as e:    logger.exception("Operation failed")  # Logs with traceback    raise

Defensive Programming

# ✅ Validate inputsdef divide(a: float, b: float) -> float:    """    Divide two numbers.        Args:        a: Dividend        b: Divisor        Returns:        Result of division        Raises:        ValueError: If b is zero    """    if b == 0:        raise ValueError("Cannot divide by zero")        return a / b # ✅ Use assertions for internal checksdef calculate_average(numbers: list) -> float:    """Calculate average of numbers."""    assert len(numbers) > 0, "List cannot be empty"    assert all(isinstance(n, (int, float)) for n in numbers), "All items must be numbers"        return sum(numbers) / len(numbers) # ✅ Provide defaultsdef get_config(key: str, default=None):    """Get configuration value with optional default."""    return config.get(key, default) # ✅ Early returns for error conditionsdef process_user(user):    """Process user data."""    if not user:        return None        if not user.is_active:        return None        if not user.has_permission():        return None        # Main logic here    return process(user)

5. Testing Best Practices

Test Organization

# ✅ Good test structureimport unittestfrom myproject import calculator class TestCalculator(unittest.TestCase):    """Test calculator functions."""        def setUp(self):        """Set up test fixtures."""        self.calc = calculator.Calculator()        def tearDown(self):        """Clean up after tests."""        pass        def test_add_positive_numbers(self):        """Test adding positive numbers."""        result = self.calc.add(2, 3)        self.assertEqual(result, 5)        def test_add_negative_numbers(self):        """Test adding negative numbers."""        result = self.calc.add(-2, -3)        self.assertEqual(result, -5)        def test_divide_by_zero_raises_error(self):        """Test that dividing by zero raises ValueError."""        with self.assertRaises(ValueError):            self.calc.divide(10, 0) # ✅ Use descriptive test names# Pattern: test_<method>_<condition>_<expected_result>def test_login_with_valid_credentials_returns_token(self):    pass def test_login_with_invalid_password_raises_error(self):    pass # ✅ AAA pattern: Arrange, Act, Assertdef test_user_creation():    # Arrange    username = "test_user"    email = "[email protected]"        # Act    user = User(username, email)        # Assert    assert user.username == username    assert user.email == email

Test Coverage

# Install coveragepip install coverage pytest-cov # Run with coveragepytest --cov=myproject --cov-report=html # View reportopen htmlcov/index.html # Aim for 80-90% coverage (not 100%)# Focus on critical paths

5 Ứng Dụng Thực Tế

1. Code Quality Checker

import astimport osfrom pathlib import Pathfrom typing import List, Dict class CodeQualityChecker:    """Check code quality metrics."""        def __init__(self):        self.issues = []        def check_file(self, filepath: str) -> Dict[str, any]:        """Check code quality of a Python file."""        with open(filepath) as f:            content = f.read()                tree = ast.parse(content)                metrics = {            'file': filepath,            'lines': len(content.splitlines()),            'functions': 0,            'classes': 0,            'complex_functions': [],            'long_functions': [],            'missing_docstrings': []        }                for node in ast.walk(tree):            # Count functions and classes            if isinstance(node, ast.FunctionDef):                metrics['functions'] += 1                self._check_function(node, metrics)                        elif isinstance(node, ast.ClassDef):                metrics['classes'] += 1                self._check_class(node, metrics)                return metrics        def _check_function(self, node: ast.FunctionDef, metrics: Dict):        """Check function quality."""        # Check docstring        docstring = ast.get_docstring(node)        if not docstring:            metrics['missing_docstrings'].append(node.name)                # Check function length        lines = node.end_lineno - node.lineno        if lines > 50:            metrics['long_functions'].append({                'name': node.name,                'lines': lines            })                # Check complexity (count if/for/while/try)        complexity = sum(            1 for child in ast.walk(node)            if isinstance(child, (ast.If, ast.For, ast.While, ast.Try))        )                if complexity > 10:            metrics['complex_functions'].append({                'name': node.name,                'complexity': complexity            })        def _check_class(self, node: ast.ClassDef, metrics: Dict):        """Check class quality."""        docstring = ast.get_docstring(node)        if not docstring:            metrics['missing_docstrings'].append(node.name)        def check_project(self, project_path: str) -> List[Dict]:        """Check all Python files in project."""        results = []                for filepath in Path(project_path).rglob("*.py"):            if 'venv' in str(filepath) or '__pycache__' in str(filepath):                continue                        metrics = self.check_file(str(filepath))            results.append(metrics)                return results        def print_report(self, results: List[Dict]):        """Print quality report."""        print("\n" + "="*80)        print("CODE QUALITY REPORT")        print("="*80)                total_lines = sum(r['lines'] for r in results)        total_functions = sum(r['functions'] for r in results)        total_classes = sum(r['classes'] for r in results)                print(f"\nProject Statistics:")        print(f"  Total files: {len(results)}")        print(f"  Total lines: {total_lines}")        print(f"  Total functions: {total_functions}")        print(f"  Total classes: {total_classes}")                # Issues        all_missing_docs = []        all_long_functions = []        all_complex_functions = []                for result in results:            all_missing_docs.extend(result['missing_docstrings'])            all_long_functions.extend(result['long_functions'])            all_complex_functions.extend(result['complex_functions'])                if all_missing_docs:            print(f"\n⚠️  Missing Docstrings: {len(all_missing_docs)}")            for name in all_missing_docs[:5]:                print(f"    - {name}")                if all_long_functions:            print(f"\n⚠️  Long Functions (>50 lines): {len(all_long_functions)}")            for func in all_long_functions[:5]:                print(f"    - {func['name']}: {func['lines']} lines")                if all_complex_functions:            print(f"\n⚠️  Complex Functions (complexity >10): {len(all_complex_functions)}")            for func in all_complex_functions[:5]:                print(f"    - {func['name']}: complexity {func['complexity']}") # Sử dụngchecker = CodeQualityChecker()results = checker.check_project("./myproject")checker.print_report(results)

2. Docstring Generator

import astimport inspectfrom typing import Callable class DocstringGenerator:    """Generate docstrings for functions and classes."""        def generate_function_docstring(self, func: Callable) -> str:        """        Generate Google-style docstring for function.                Args:            func: Function to generate docstring for                Returns:            Generated docstring        """        sig = inspect.signature(func)                # Function description        lines = [            f'"""',            f'{func.__name__.replace("_", " ").title()}.',            '',            'Args:'        ]                # Parameters        for name, param in sig.parameters.items():            param_type = 'any'            if param.annotation != inspect.Parameter.empty:                param_type = param.annotation.__name__                        default = ''            if param.default != inspect.Parameter.empty:                default = f' Defaults to {param.default}.'                        lines.append(f'    {name} ({param_type}): Description.{default}')                # Return type        if sig.return_annotation != inspect.Signature.empty:            return_type = sig.return_annotation.__name__            lines.extend([                '',                'Returns:',                f'    {return_type}: Description.'            ])                # Example        lines.extend([            '',            'Example:',            f'    >>> {func.__name__}()',            '    result',            '"""'        ])                return '\n'.join(lines)        def generate_class_docstring(self, cls: type) -> str:        """        Generate Google-style docstring for class.                Args:            cls: Class to generate docstring for                Returns:            Generated docstring        """        lines = [            '"""',            f'{cls.__name__} class.',            '',            'Brief description of the class.',            '',            'Attributes:'        ]                # Get __init__ signature for attributes        if hasattr(cls, '__init__'):            sig = inspect.signature(cls.__init__)            for name, param in sig.parameters.items():                if name == 'self':                    continue                                param_type = 'any'                if param.annotation != inspect.Parameter.empty:                    param_type = param.annotation.__name__                                lines.append(f'    {name} ({param_type}): Description.')                lines.extend([            '',            'Example:',            f'    >>> obj = {cls.__name__}()',            '    >>> obj.method()',            '"""'        ])                return '\n'.join(lines) # Sử dụnggenerator = DocstringGenerator() def example_function(x: int, y: str = "default") -> bool:    pass print(generator.generate_function_docstring(example_function))

3. Import Sorter

import astfrom pathlib import Pathfrom typing import List, Dict class ImportSorter:    """Sort and organize imports in Python files."""        def __init__(self):        self.stdlib_modules = {            'os', 'sys', 'time', 'datetime', 'json', 'logging',            'collections', 'itertools', 'functools', 'typing'        }        def parse_imports(self, filepath: str) -> Dict[str, List[str]]:        """Parse imports from Python file."""        with open(filepath) as f:            tree = ast.parse(f.read())                imports = {            'stdlib': [],            'third_party': [],            'local': []        }                for node in ast.walk(tree):            if isinstance(node, ast.Import):                for alias in node.names:                    module = alias.name.split('.')[0]                    import_line = f"import {alias.name}"                    if alias.asname:                        import_line += f" as {alias.asname}"                                        self._categorize_import(module, import_line, imports)                        elif isinstance(node, ast.ImportFrom):                if node.module:                    module = node.module.split('.')[0]                    names = ', '.join(a.name for a in node.names)                    import_line = f"from {node.module} import {names}"                                        self._categorize_import(module, import_line, imports)                return imports        def _categorize_import(self, module: str, import_line: str, imports: Dict):        """Categorize import into stdlib, third-party, or local."""        if module in self.stdlib_modules:            imports['stdlib'].append(import_line)        elif module.startswith('.') or module.startswith('myproject'):            imports['local'].append(import_line)        else:            imports['third_party'].append(import_line)        def format_imports(self, imports: Dict[str, List[str]]) -> str:        """Format imports according to PEP 8."""        sections = []                # Sort each section        for key in ['stdlib', 'third_party', 'local']:            if imports[key]:                sorted_imports = sorted(set(imports[key]))                sections.append('\n'.join(sorted_imports))                # Join sections with blank line        return '\n\n'.join(sections) # Sử dụngsorter = ImportSorter()imports = sorter.parse_imports("myfile.py")formatted = sorter.format_imports(imports)print(formatted)

Bài tiếp theo: Bài 19.2: Best Practices (Phần 2) - SOLID principles, Factory Pattern! ⭐