Development

Code Style

Coding standards and best practices for ZineCore2

This guide covers coding standards, naming conventions, and best practices for ZineCore2 development.

Python Style Guide

ZineCore2 follows PEP 8 with some project-specific conventions.

Line Length

Maximum 88 characters (Black formatter default).

# Good
def create_zine_from_csv(
    csv_path, validate=True, skip_errors=False
):
    pass

# Bad (too long)
def create_zine_from_csv(csv_path, validate=True, skip_errors=False, log_errors=True, dry_run=False):
    pass

Imports

Order: Standard library → Third party → Local imports

# Good
import os
import sys
from datetime import datetime

from django.db import models
from rest_framework import serializers

from core.models import TimestampedModel
from catalog.utils import validate_zine_id

# Bad (mixed order)
from catalog.utils import validate_zine_id
import os
from django.db import models

Grouping: Separate groups with blank line.

Absolute imports preferred:

# Good
from catalog.models import Zine

# Avoid
from .models import Zine

Naming Conventions

Variables and functions: snake_case

zine_id = "zine_001"
created_at = timezone.now()

def get_zine_by_id(zine_id):
    pass

Classes: PascalCase

class Zine(TimestampedModel):
    pass

class ZineWriteSerializer(serializers.ModelSerializer):
    pass

Constants: UPPER_SNAKE_CASE

DEFAULT_PAGE_SIZE = 25
MAX_UPLOAD_SIZE = 10 * 1024 * 1024  # 10MB

Private/internal: Prefix with underscore

def _internal_helper():
    pass

_cache = {}

Django-Specific Conventions

Model Fields

Order:

  1. ID fields (id, external_id)
  2. Required fields
  3. Optional fields
  4. Timestamps (from TimestampedModel)
class Zine(TimestampedModel):
    # ID fields
    id = models.BigAutoField(primary_key=True)
    zine_id = models.CharField(max_length=255, unique=True)

    # Required fields
    title = models.CharField(max_length=500)
    creator = ArrayField(models.CharField(max_length=255))

    # Optional fields
    abstract = models.TextField(blank=True)
    isbn = models.CharField(max_length=13, blank=True)

    # Timestamps inherited from TimestampedModel

    class Meta:
        ordering = ['-created_at']

    def __str__(self):
        return self.title

Field arguments order:

  1. Field type positional args
  2. max_length / choices
  3. blank / null
  4. default
  5. help_text
status = models.CharField(
    max_length=50,
    choices=[('draft', 'Draft'), ('published', 'Published')],
    blank=True,
    default='draft',
    help_text="Publication status"
)

Serializers

Name pattern: {Model}{Read|Write}Serializer

class ZineReadSerializer(serializers.ModelSerializer):
    """Read serializer with nested objects"""
    pass

class ZineWriteSerializer(serializers.ModelSerializer):
    """Write serializer with simple IDs"""
    pass

Meta class order:

class ZineWriteSerializer(serializers.ModelSerializer):
    class Meta:
        model = Zine
        fields = ['zine_id', 'title', ...]
        read_only_fields = ['created_at', 'updated_at']

ViewSets

class ZineViewSet(viewsets.ModelViewSet):
    """
    ViewSet for Zine CRUD operations.

    Supports:
    - List: GET /api/zines/
    - Create: POST /api/zines/
    - Retrieve: GET /api/zines/{zine_id}/
    - Update: PUT/PATCH /api/zines/{zine_id}/
    - Delete: DELETE /api/zines/{zine_id}/
    """
    queryset = Zine.objects.all()
    lookup_field = 'zine_id'
    permission_classes = [IsAuthenticatedOrReadOnly]

    def get_serializer_class(self):
        if self.action in ['create', 'update', 'partial_update']:
            return ZineWriteSerializer
        return ZineReadSerializer

Documentation

Docstrings

Use Google-style docstrings:

def create_zine(zine_data, validate=True):
    """
    Create a new zine record.

    Args:
        zine_data (dict): Zine data including title, creator, etc.
        validate (bool): Whether to validate before saving. Default True.

    Returns:
        Zine: Created zine instance.

    Raises:
        ValidationError: If data is invalid and validate=True.
        ValueError: If zine_id already exists.

    Example:
        >>> zine = create_zine({
        ...     'zine_id': 'zine_001',
        ...     'title': 'My Zine',
        ...     'creator': ['agent_001']
        ... })
    """
    pass

Class docstrings:

class Zine(TimestampedModel):
    """
    Zine bibliographic record (ZineCore2).

    Represents a specific zine issue with metadata following the
    ZineCore2 Dublin Core Application Profile.

    Attributes:
        zine_id (str): External unique identifier.
        title (str): Primary title of the zine.
        creator (list): Agent IDs of creators.
    """
    pass

Function docstrings (simple):

def validate_zine_id(zine_id):
    """Check if zine_id follows naming convention."""
    return zine_id.startswith('zine_')

Inline Comments

Use sparingly — code should be self-documenting.

Good use cases:

# Optimization: Cache frequently accessed data
cache = {}

# TODO: Implement bulk update endpoint
# FIXME: Handle edge case with empty arrays
# HACK: Temporary workaround for Django bug #12345

Bad use cases:

# Loop through zines
for zine in zines:
    # Print title
    print(zine.title)  # This prints the title

Code Formatting

Use Black

# Format all files
black .

# Check without modifying
black --check .

# Format specific file
black catalog/models.py

Black configuration in pyproject.toml:

[tool.black]
line-length = 88
target-version = ['py312']
include = '\.pyi?$'
extend-exclude = '''
/(
    \.git
  | \.venv
  | migrations
)/
'''

String Formatting

Prefer f-strings:

# Good
message = f"Created zine: {zine.title}"

# Avoid
message = "Created zine: {}".format(zine.title)
message = "Created zine: " + zine.title

Multi-line strings:

# Triple quotes for multi-line
description = """
This is a long description
that spans multiple lines.
"""

# Or explicit line breaks
message = (
    "This is a very long message "
    "that is split across multiple lines "
    "for readability."
)

Type Hints

Use type hints for function signatures:

from typing import List, Dict, Optional

def get_zines_by_subject(
    subject_code: str,
    limit: Optional[int] = None
) -> List[Zine]:
    """Get zines by subject code."""
    queryset = Zine.objects.filter(subject__contains=[subject_code])

    if limit:
        queryset = queryset[:limit]

    return list(queryset)

Return type hints:

def create_zine(data: Dict) -> Zine:
    pass

def get_zine_count() -> int:
    pass

def process_data() -> None:
    pass

Error Handling

Exceptions

Be specific:

# Good
try:
    zine = Zine.objects.get(zine_id=zine_id)
except Zine.DoesNotExist:
    raise ValueError(f"Zine {zine_id} not found")

# Bad
try:
    zine = Zine.objects.get(zine_id=zine_id)
except Exception:  # Too broad
    pass

Don't swallow exceptions:

# Bad
try:
    process_zine(zine)
except Exception:
    pass  # Silent failure

# Good
try:
    process_zine(zine)
except ValidationError as e:
    logger.error(f"Validation failed: {e}")
    raise

Logging

import logging

logger = logging.getLogger(__name__)

def import_zines(csv_path):
    logger.info(f"Starting import from {csv_path}")

    try:
        # Import logic
        logger.debug(f"Processed {count} records")
    except Exception as e:
        logger.error(f"Import failed: {e}", exc_info=True)
        raise

Log levels:

  • DEBUG — Detailed information
  • INFO — General information
  • WARNING — Warning messages
  • ERROR — Error messages
  • CRITICAL — Critical errors

Testing Code Style

Test Naming

Pattern: test_{what}_{condition}_{expected_result}

class TestZineAPI(APITestCase):
    def test_create_zine_valid_data_returns_201(self):
        pass

    def test_create_zine_missing_title_returns_400(self):
        pass

    def test_get_zine_nonexistent_id_returns_404(self):
        pass

Test Structure

Arrange-Act-Assert:

def test_create_zine(self):
    # Arrange
    data = {
        'zine_id': 'zine_001',
        'title': 'Test Zine',
        # ...
    }

    # Act
    response = self.client.post('/api/zines/', data)

    # Assert
    self.assertEqual(response.status_code, 201)
    self.assertEqual(response.data['title'], 'Test Zine')

Git Commit Messages

Format

<type>: <subject>

<body>

<footer>

Types

  • feat — New feature
  • fix — Bug fix
  • docs — Documentation
  • style — Code style (formatting)
  • refactor — Code refactoring
  • test — Tests
  • chore — Maintenance

Examples

Simple:

fix: Prevent 500 error when creator array is empty

With body:

feat: Add bulk delete endpoint for zines

Add DELETE /api/zines/bulk-delete/ endpoint that accepts
an array of zine IDs and deletes them in a single transaction.

Returns count of deleted zines and any errors encountered.

With footer:

fix: Correct validation for empty subject array

Subject array must contain at least one element.

Fixes #123

Guidelines:

  • Subject: 50 characters or less
  • Imperative mood ("Add" not "Added" or "Adds")
  • No period at end of subject
  • Body: Wrap at 72 characters
  • Blank line between subject and body

Best Practices

DRY (Don't Repeat Yourself)

Extract common code:

# Bad
def get_zine(zine_id):
    try:
        return Zine.objects.get(zine_id=zine_id)
    except Zine.DoesNotExist:
        raise ValueError(f"Zine {zine_id} not found")

def get_agent(agent_id):
    try:
        return Agent.objects.get(agent_id=agent_id)
    except Agent.DoesNotExist:
        raise ValueError(f"Agent {agent_id} not found")

# Good
def get_object_or_error(model, field, value):
    try:
        return model.objects.get(**{field: value})
    except model.DoesNotExist:
        raise ValueError(f"{model.__name__} {value} not found")

# Usage
zine = get_object_or_error(Zine, 'zine_id', zine_id)
agent = get_object_or_error(Agent, 'agent_id', agent_id)

KISS (Keep It Simple)

Prefer simple, readable code:

# Bad (too clever)
zines = [z for z in Zine.objects.all() if z.subject and any(s in z.subject for s in ['feminism', 'punk'])]

# Good (clear)
zines = Zine.objects.filter(
    models.Q(subject__contains=['feminism']) |
    models.Q(subject__contains=['punk'])
)

Single Responsibility

Each function/class does one thing:

# Bad (does too much)
def process_zine(data):
    # Validate
    # Save to database
    # Send notification
    # Update cache
    pass

# Good (separate concerns)
def validate_zine(data):
    pass

def save_zine(data):
    pass

def notify_zine_created(zine):
    pass

def update_zine_cache(zine):
    pass

Code Review Checklist

  • Follows PEP 8
  • Formatted with Black
  • Type hints on function signatures
  • Docstrings on classes and complex functions
  • No commented-out code
  • Tests added for new functionality
  • No hardcoded values (use settings/constants)
  • Error handling is appropriate
  • Logging added where appropriate
  • No security vulnerabilities

Tools

Black (Code Formatter)

pip install black
black .

Flake8 (Linter)

pip install flake8
flake8 .

isort (Import Sorter)

pip install isort
isort .

mypy (Type Checker)

pip install mypy
mypy .

Pre-commit Hooks

pip install pre-commit
pre-commit install

Next Steps

Code formatted and styled! Continue to Troubleshooting for debugging help.
Copyright ©2026 ZineCore2 Contributors,