Chapter 6: Digital Downloads and File Management

The Core of Digital Products

What distinguishes digital products from physical ones is the file delivery system. When a customer purchases your digital product, Etsy handles the secure delivery of your files. This chapter covers everything you need to know about managing digital files through the API.

Understanding Digital File Delivery

Etsy’s digital delivery system works as follows:

  1. You upload files to a listing
  2. Customer purchases the listing
  3. Etsy immediately provides download links
  4. Customer can download files up to 5 times
  5. Files remain accessible from their purchase history

Digital File Operations

# etsy_client/files.py
"""Digital file management for Etsy listings."""

from typing import Dict, Any, List, BinaryIO, Optional
from pathlib import Path
import mimetypes
from .client import EtsyClient
from .exceptions import ValidationError


class DigitalFileOperations:
    """Operations for managing digital download files."""
    
    # Etsy digital file constraints
    MAX_FILES_PER_LISTING = 5
    MAX_FILE_SIZE = 20 * 1024 * 1024  # 20MB per file
    MAX_TOTAL_SIZE = 100 * 1024 * 1024  # 100MB total per listing
    
    # Allowed file types for digital downloads
    ALLOWED_EXTENSIONS = {
        # Documents
        '.pdf', '.doc', '.docx', '.txt', '.rtf',
        # Images
        '.jpg', '.jpeg', '.png', '.gif', '.svg', '.tiff', '.bmp',
        # Design files
        '.psd', '.ai', '.eps', '.indd',
        # Archives
        '.zip', '.rar',
        # Spreadsheets
        '.xls', '.xlsx', '.csv',
        # Presentations
        '.ppt', '.pptx',
        # Audio
        '.mp3', '.wav', '.aac',
        # Video
        '.mp4', '.mov', '.avi',
        # Fonts
        '.ttf', '.otf', '.woff', '.woff2',
        # Code
        '.html', '.css', '.js', '.json',
    }
    
    def __init__(self, client: EtsyClient):
        self.client = client
    
    def _validate_file(self, file_path: Path) -> None:
        """Validate a file before upload."""
        if not file_path.exists():
            raise FileNotFoundError(f"File not found: {file_path}")
        
        # Check extension
        if file_path.suffix.lower() not in self.ALLOWED_EXTENSIONS:
            raise ValidationError(
                f"File type not allowed: {file_path.suffix}. "
                f"Allowed types: {', '.join(self.ALLOWED_EXTENSIONS)}"
            )
        
        # Check size
        size = file_path.stat().st_size
        if size > self.MAX_FILE_SIZE:
            raise ValidationError(
                f"File too large: {size / 1024 / 1024:.1f}MB. "
                f"Max: {self.MAX_FILE_SIZE / 1024 / 1024}MB"
            )
    
    def get_listing_files(
        self,
        shop_id: int,
        listing_id: int
    ) -> Dict[str, Any]:
        """
        Get all digital files attached to a listing.
        
        Args:
            shop_id: The shop ID
            listing_id: The listing ID
            
        Returns:
            List of file data including IDs and metadata
        """
        return self.client.get(
            f"/application/shops/{shop_id}/listings/{listing_id}/files"
        )
    
    def upload_listing_file(
        self,
        shop_id: int,
        listing_id: int,
        file_path: str,
        name: Optional[str] = None,
        rank: int = 1
    ) -> Dict[str, Any]:
        """
        Upload a digital file to a listing.
        
        Args:
            shop_id: The shop ID
            listing_id: The listing ID
            file_path: Path to the file to upload
            name: Display name for the file (optional)
            rank: File order (1-5)
            
        Returns:
            Uploaded file data
        """
        path = Path(file_path)
        self._validate_file(path)
        
        # Determine filename
        display_name = name or path.name
        
        # Get mime type
        mime_type, _ = mimetypes.guess_type(str(path))
        mime_type = mime_type or 'application/octet-stream'
        
        with open(path, 'rb') as f:
            files = {
                'file': (display_name, f, mime_type)
            }
            
            data = {
                'name': display_name,
                'rank': rank
            }
            
            return self.client.post(
                f"/application/shops/{shop_id}/listings/{listing_id}/files",
                files=files,
                data=data
            )
    
    def delete_listing_file(
        self,
        shop_id: int,
        listing_id: int,
        file_id: int
    ) -> Dict[str, Any]:
        """
        Delete a digital file from a listing.
        
        Args:
            shop_id: The shop ID
            listing_id: The listing ID
            file_id: The file ID to delete
            
        Returns:
            Deletion confirmation
        """
        return self.client.delete(
            f"/application/shops/{shop_id}/listings/{listing_id}/files/{file_id}"
        )
    
    def replace_listing_file(
        self,
        shop_id: int,
        listing_id: int,
        file_id: int,
        new_file_path: str,
        name: Optional[str] = None
    ) -> Dict[str, Any]:
        """
        Replace an existing file with a new version.
        
        Args:
            shop_id: The shop ID
            listing_id: The listing ID
            file_id: The file ID to replace
            new_file_path: Path to the new file
            name: New display name (optional)
            
        Returns:
            Updated file data
        """
        # Get current file info for rank
        files = self.get_listing_files(shop_id, listing_id)
        
        current_file = None
        for f in files.get('results', []):
            if f['listing_file_id'] == file_id:
                current_file = f
                break
        
        if not current_file:
            raise ValidationError(f"File {file_id} not found")
        
        # Delete old file
        self.delete_listing_file(shop_id, listing_id, file_id)
        
        # Upload new file with same rank
        return self.upload_listing_file(
            shop_id,
            listing_id,
            new_file_path,
            name=name or current_file.get('filename'),
            rank=current_file.get('rank', 1)
        )
    
    def upload_multiple_files(
        self,
        shop_id: int,
        listing_id: int,
        file_paths: List[str]
    ) -> List[Dict[str, Any]]:
        """
        Upload multiple files to a listing.
        
        Args:
            shop_id: The shop ID
            listing_id: The listing ID
            file_paths: List of file paths to upload
            
        Returns:
            List of upload results
        """
        if len(file_paths) > self.MAX_FILES_PER_LISTING:
            raise ValidationError(
                f"Too many files. Max: {self.MAX_FILES_PER_LISTING}"
            )
        
        # Check total size
        total_size = sum(Path(p).stat().st_size for p in file_paths)
        if total_size > self.MAX_TOTAL_SIZE:
            raise ValidationError(
                f"Total size too large: {total_size / 1024 / 1024:.1f}MB. "
                f"Max: {self.MAX_TOTAL_SIZE / 1024 / 1024}MB"
            )
        
        results = []
        
        for rank, path in enumerate(file_paths, 1):
            try:
                result = self.upload_listing_file(
                    shop_id, listing_id, path, rank=rank
                )
                results.append({
                    'path': path,
                    'success': True,
                    'file_id': result.get('listing_file_id'),
                    'filename': result.get('filename')
                })
            except Exception as e:
                results.append({
                    'path': path,
                    'success': False,
                    'error': str(e)
                })
        
        return results
    
    def get_file_info(
        self,
        shop_id: int,
        listing_id: int,
        file_id: int
    ) -> Optional[Dict[str, Any]]:
        """
        Get information about a specific file.
        
        Args:
            shop_id: The shop ID
            listing_id: The listing ID
            file_id: The file ID
            
        Returns:
            File information or None if not found
        """
        files = self.get_listing_files(shop_id, listing_id)
        
        for f in files.get('results', []):
            if f['listing_file_id'] == file_id:
                return f
        
        return None


class DigitalProductPackager:
    """Helper class for packaging digital products."""
    
    def __init__(self):
        self.files: List[Path] = []
    
    def add_file(self, file_path: str) -> 'DigitalProductPackager':
        """Add a file to the package."""
        path = Path(file_path)
        if not path.exists():
            raise FileNotFoundError(f"File not found: {file_path}")
        self.files.append(path)
        return self
    
    def add_directory(
        self,
        dir_path: str,
        pattern: str = "*"
    ) -> 'DigitalProductPackager':
        """Add all matching files from a directory."""
        path = Path(dir_path)
        if not path.is_dir():
            raise NotADirectoryError(f"Not a directory: {dir_path}")
        
        for file_path in path.glob(pattern):
            if file_path.is_file():
                self.files.append(file_path)
        
        return self
    
    def create_zip(
        self,
        output_path: str,
        compression_level: int = 6
    ) -> Path:
        """
        Create a ZIP archive of all files.
        
        Args:
            output_path: Path for the output ZIP file
            compression_level: ZIP compression (0-9)
            
        Returns:
            Path to created ZIP file
        """
        import zipfile
        
        output = Path(output_path)
        
        with zipfile.ZipFile(
            output, 'w',
            compression=zipfile.ZIP_DEFLATED,
            compresslevel=compression_level
        ) as zf:
            for file_path in self.files:
                # Use just the filename in the archive
                zf.write(file_path, file_path.name)
        
        return output
    
    def total_size(self) -> int:
        """Get total size of all files in bytes."""
        return sum(f.stat().st_size for f in self.files)
    
    def summary(self) -> str:
        """Get a summary of the package contents."""
        lines = [f"Package contains {len(self.files)} files:"]
        
        for f in self.files:
            size = f.stat().st_size
            lines.append(f"  - {f.name} ({size / 1024:.1f} KB)")
        
        total = self.total_size()
        lines.append(f"\nTotal size: {total / 1024 / 1024:.2f} MB")
        
        return '\n'.join(lines)

Practical File Upload Workflows

Single File Digital Product

# scripts/upload_single_file.py
"""Upload a single digital file to a listing."""

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from config import Config
from etsy_client import EtsyClient
from etsy_client.files import DigitalFileOperations
from etsy_client.users import get_my_shop_id


def upload_digital_file(listing_id: int, file_path: str):
    """
    Upload a digital file to an existing listing.
    
    Args:
        listing_id: The listing to upload to
        file_path: Path to the file
    """
    client = EtsyClient(
        api_key=Config.ETSY_API_KEY,
        access_token=Config.ETSY_ACCESS_TOKEN
    )
    
    shop_id = get_my_shop_id(client)
    file_ops = DigitalFileOperations(client)
    
    # Check existing files
    existing = file_ops.get_listing_files(shop_id, listing_id)
    existing_count = len(existing.get('results', []))
    
    print(f"Listing {listing_id} has {existing_count} existing files")
    
    if existing_count >= 5:
        print("Error: Maximum 5 files per listing")
        return
    
    # Upload file
    print(f"Uploading {file_path}...")
    
    result = file_ops.upload_listing_file(
        shop_id=shop_id,
        listing_id=listing_id,
        file_path=file_path,
        rank=existing_count + 1
    )
    
    print(f"✓ File uploaded successfully!")
    print(f"  File ID: {result.get('listing_file_id')}")
    print(f"  Filename: {result.get('filename')}")
    print(f"  Size: {result.get('filesize', 0) / 1024:.1f} KB")


if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("Usage: python upload_single_file.py <listing_id> <file_path>")
        sys.exit(1)
    
    listing_id = int(sys.argv[1])
    file_path = sys.argv[2]
    
    upload_digital_file(listing_id, file_path)

Multi-Format Digital Product

Many digital products come in multiple formats:

# scripts/upload_multi_format.py
"""Upload a digital product in multiple formats."""

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from config import Config
from etsy_client import EtsyClient
from etsy_client.files import DigitalFileOperations, DigitalProductPackager
from etsy_client.users import get_my_shop_id


def upload_multi_format_product(
    listing_id: int,
    base_name: str,
    formats_dir: str
):
    """
    Upload a product available in multiple formats.
    
    Expected directory structure:
    formats_dir/
        product.pdf
        product.png
        product.svg
        product.jpg
    
    Args:
        listing_id: The listing ID
        base_name: Base product name (for display)
        formats_dir: Directory containing format files
    """
    client = EtsyClient(
        api_key=Config.ETSY_API_KEY,
        access_token=Config.ETSY_ACCESS_TOKEN
    )
    
    shop_id = get_my_shop_id(client)
    file_ops = DigitalFileOperations(client)
    
    # Find all format files
    formats_path = Path(formats_dir)
    
    if not formats_path.is_dir():
        print(f"Error: {formats_dir} is not a directory")
        return
    
    # Define format priority (order for upload)
    format_priority = ['.pdf', '.png', '.svg', '.jpg', '.jpeg']
    
    files_to_upload = []
    
    for ext in format_priority:
        matching = list(formats_path.glob(f"*{ext}"))
        files_to_upload.extend(matching)
    
    # Add any remaining files
    for f in formats_path.iterdir():
        if f.is_file() and f not in files_to_upload:
            files_to_upload.append(f)
    
    if len(files_to_upload) > 5:
        print(f"Warning: Found {len(files_to_upload)} files, but max is 5")
        print("Consider creating a ZIP archive instead")
        files_to_upload = files_to_upload[:5]
    
    print(f"Uploading {len(files_to_upload)} format files...")
    
    results = file_ops.upload_multiple_files(
        shop_id,
        listing_id,
        [str(f) for f in files_to_upload]
    )
    
    # Print results
    success_count = sum(1 for r in results if r['success'])
    
    print(f"\n✓ Uploaded {success_count}/{len(results)} files")
    
    for result in results:
        if result['success']:
            print(f"{Path(result['path']).name}")
        else:
            print(f"{Path(result['path']).name}: {result['error']}")


def create_and_upload_bundle(
    listing_id: int,
    source_dir: str,
    bundle_name: str
):
    """
    Create a ZIP bundle and upload it.
    
    Useful when you have many files or want to preserve folder structure.
    
    Args:
        listing_id: The listing ID
        source_dir: Directory with files to bundle
        bundle_name: Name for the ZIP file
    """
    client = EtsyClient(
        api_key=Config.ETSY_API_KEY,
        access_token=Config.ETSY_ACCESS_TOKEN
    )
    
    shop_id = get_my_shop_id(client)
    file_ops = DigitalFileOperations(client)
    
    # Create package
    packager = DigitalProductPackager()
    packager.add_directory(source_dir)
    
    print(packager.summary())
    
    # Create ZIP
    zip_path = f"/tmp/{bundle_name}.zip"
    packager.create_zip(zip_path)
    
    print(f"\nCreated ZIP: {zip_path}")
    print(f"ZIP size: {Path(zip_path).stat().st_size / 1024 / 1024:.2f} MB")
    
    # Upload
    print("\nUploading to Etsy...")
    
    result = file_ops.upload_listing_file(
        shop_id=shop_id,
        listing_id=listing_id,
        file_path=zip_path,
        name=f"{bundle_name}.zip"
    )
    
    print(f"✓ Bundle uploaded!")
    print(f"  File ID: {result.get('listing_file_id')}")
    
    # Cleanup
    Path(zip_path).unlink()


if __name__ == "__main__":
    # Example usage
    # upload_multi_format_product(123456, "Wall Art Print", "./products/wall-art-001")
    pass

File Version Management

Managing multiple versions of digital files:

# etsy_client/versioning.py
"""Version management for digital product files."""

import json
import hashlib
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, List, Optional
from dataclasses import dataclass, asdict
from .client import EtsyClient
from .files import DigitalFileOperations


@dataclass
class FileVersion:
    """Represents a version of a digital file."""
    version: str
    file_hash: str
    filename: str
    uploaded_at: str
    etsy_file_id: Optional[int] = None
    notes: str = ""


class VersionManager:
    """
    Manage versions of digital product files.
    
    Tracks file versions locally and syncs with Etsy.
    """
    
    def __init__(
        self,
        client: EtsyClient,
        shop_id: int,
        version_file: str = "file_versions.json"
    ):
        self.client = client
        self.shop_id = shop_id
        self.file_ops = DigitalFileOperations(client)
        self.version_file = Path(version_file)
        
        self._versions: Dict[int, List[FileVersion]] = {}
        self._load_versions()
    
    def _load_versions(self):
        """Load version data from file."""
        if self.version_file.exists():
            with open(self.version_file) as f:
                data = json.load(f)
                
            for listing_id, versions in data.items():
                self._versions[int(listing_id)] = [
                    FileVersion(**v) for v in versions
                ]
    
    def _save_versions(self):
        """Save version data to file."""
        data = {}
        for listing_id, versions in self._versions.items():
            data[str(listing_id)] = [asdict(v) for v in versions]
        
        with open(self.version_file, 'w') as f:
            json.dump(data, f, indent=2)
    
    @staticmethod
    def _compute_hash(file_path: Path) -> str:
        """Compute SHA256 hash of a file."""
        sha256 = hashlib.sha256()
        
        with open(file_path, 'rb') as f:
            for chunk in iter(lambda: f.read(8192), b''):
                sha256.update(chunk)
        
        return sha256.hexdigest()
    
    def get_versions(self, listing_id: int) -> List[FileVersion]:
        """Get all versions for a listing."""
        return self._versions.get(listing_id, [])
    
    def get_current_version(self, listing_id: int) -> Optional[FileVersion]:
        """Get the current (latest) version."""
        versions = self.get_versions(listing_id)
        return versions[-1] if versions else None
    
    def upload_new_version(
        self,
        listing_id: int,
        file_path: str,
        version: str,
        notes: str = "",
        replace_existing: bool = True
    ) -> FileVersion:
        """
        Upload a new version of a digital file.
        
        Args:
            listing_id: The listing ID
            file_path: Path to the new file
            version: Version string (e.g., "1.0", "2.1")
            notes: Release notes for this version
            replace_existing: Whether to replace the current file
            
        Returns:
            FileVersion object for the new version
        """
        path = Path(file_path)
        file_hash = self._compute_hash(path)
        
        # Check if this exact file was already uploaded
        for v in self.get_versions(listing_id):
            if v.file_hash == file_hash:
                print(f"Warning: This file was already uploaded as version {v.version}")
        
        # Get existing files
        existing = self.file_ops.get_listing_files(self.shop_id, listing_id)
        existing_files = existing.get('results', [])
        
        etsy_file_id = None
        
        if replace_existing and existing_files:
            # Replace the first (primary) file
            old_file_id = existing_files[0]['listing_file_id']
            
            result = self.file_ops.replace_listing_file(
                self.shop_id, listing_id, old_file_id, file_path
            )
            etsy_file_id = result.get('listing_file_id')
        else:
            # Upload as new file
            result = self.file_ops.upload_listing_file(
                self.shop_id, listing_id, file_path
            )
            etsy_file_id = result.get('listing_file_id')
        
        # Create version record
        new_version = FileVersion(
            version=version,
            file_hash=file_hash,
            filename=path.name,
            uploaded_at=datetime.now().isoformat(),
            etsy_file_id=etsy_file_id,
            notes=notes
        )
        
        # Store version
        if listing_id not in self._versions:
            self._versions[listing_id] = []
        
        self._versions[listing_id].append(new_version)
        self._save_versions()
        
        return new_version
    
    def check_for_changes(
        self,
        listing_id: int,
        local_file: str
    ) -> bool:
        """
        Check if local file differs from uploaded version.
        
        Args:
            listing_id: The listing ID
            local_file: Path to local file
            
        Returns:
            True if file has changed, False otherwise
        """
        current = self.get_current_version(listing_id)
        
        if not current:
            return True  # No version exists
        
        local_hash = self._compute_hash(Path(local_file))
        return local_hash != current.file_hash
    
    def version_history(self, listing_id: int) -> str:
        """Get formatted version history."""
        versions = self.get_versions(listing_id)
        
        if not versions:
            return "No versions recorded"
        
        lines = [f"Version history for listing {listing_id}:", ""]
        
        for v in reversed(versions):
            lines.append(f"v{v.version} - {v.uploaded_at}")
            lines.append(f"  File: {v.filename}")
            if v.notes:
                lines.append(f"  Notes: {v.notes}")
            lines.append("")
        
        return '\n'.join(lines)

Automating File Updates

# scripts/auto_update_files.py
"""Automatically update digital files when local files change."""

import sys
import time
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

sys.path.insert(0, str(Path(__file__).parent.parent))

from config import Config
from etsy_client import EtsyClient
from etsy_client.versioning import VersionManager
from etsy_client.users import get_my_shop_id


class FileUpdateHandler(FileSystemEventHandler):
    """Handle file system events for auto-updating."""
    
    def __init__(
        self,
        version_manager: VersionManager,
        file_mapping: Dict[str, int]
    ):
        """
        Initialize handler.
        
        Args:
            version_manager: VersionManager instance
            file_mapping: Dict mapping file paths to listing IDs
        """
        self.version_manager = version_manager
        self.file_mapping = file_mapping
        self._debounce = {}
    
    def on_modified(self, event):
        """Handle file modification events."""
        if event.is_directory:
            return
        
        file_path = event.src_path
        
        # Check if this file is mapped to a listing
        listing_id = self.file_mapping.get(file_path)
        
        if not listing_id:
            return
        
        # Debounce (wait for file to finish writing)
        current_time = time.time()
        last_event = self._debounce.get(file_path, 0)
        
        if current_time - last_event < 2:
            return
        
        self._debounce[file_path] = current_time
        
        # Check if file actually changed
        if self.version_manager.check_for_changes(listing_id, file_path):
            print(f"\nFile changed: {file_path}")
            print("Uploading new version...")
            
            # Auto-increment version
            current = self.version_manager.get_current_version(listing_id)
            if current:
                # Simple version increment
                try:
                    major, minor = current.version.split('.')
                    new_version = f"{major}.{int(minor) + 1}"
                except:
                    new_version = f"{current.version}.1"
            else:
                new_version = "1.0"
            
            self.version_manager.upload_new_version(
                listing_id,
                file_path,
                new_version,
                notes="Auto-updated from file change"
            )
            
            print(f"✓ Uploaded version {new_version}")


def watch_files(
    file_mapping: Dict[str, int],
    watch_dirs: List[str] = None
):
    """
    Watch files for changes and auto-update Etsy.
    
    Args:
        file_mapping: Dict mapping file paths to listing IDs
        watch_dirs: Directories to watch (derived from file_mapping if not provided)
    """
    client = EtsyClient(
        api_key=Config.ETSY_API_KEY,
        access_token=Config.ETSY_ACCESS_TOKEN
    )
    
    shop_id = get_my_shop_id(client)
    version_manager = VersionManager(client, shop_id)
    
    handler = FileUpdateHandler(version_manager, file_mapping)
    observer = Observer()
    
    # Determine directories to watch
    if watch_dirs is None:
        watch_dirs = list(set(
            str(Path(p).parent) for p in file_mapping.keys()
        ))
    
    for dir_path in watch_dirs:
        observer.schedule(handler, dir_path, recursive=False)
        print(f"Watching: {dir_path}")
    
    observer.start()
    
    print("\nFile watcher started. Press Ctrl+C to stop.")
    
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        observer.stop()
    
    observer.join()
    print("\nFile watcher stopped.")


# Example configuration
EXAMPLE_MAPPING = {
    "/path/to/products/planner-2026.pdf": 123456789,
    "/path/to/products/wall-art-bundle.zip": 987654321,
}

if __name__ == "__main__":
    # watch_files(EXAMPLE_MAPPING)
    print("Configure EXAMPLE_MAPPING with your file paths and listing IDs")

Best Practices for Digital Files

File Naming Conventions

# etsy_client/file_naming.py
"""File naming utilities for digital products."""

import re
from pathlib import Path


def sanitize_filename(name: str) -> str:
    """
    Create a clean, customer-friendly filename.
    
    Args:
        name: Original filename
        
    Returns:
        Sanitized filename
    """
    # Remove special characters
    name = re.sub(r'[^\w\s.-]', '', name)
    
    # Replace multiple spaces/underscores with single hyphen
    name = re.sub(r'[\s_]+', '-', name)
    
    # Remove multiple hyphens
    name = re.sub(r'-+', '-', name)
    
    # Trim hyphens from ends
    name = name.strip('-')
    
    return name


def create_descriptive_filename(
    product_name: str,
    format_type: str,
    dimensions: str = None,
    version: str = None
) -> str:
    """
    Create a descriptive filename for customer downloads.
    
    Args:
        product_name: Name of the product
        format_type: File format (PDF, PNG, etc.)
        dimensions: Size/dimensions if applicable
        version: Version number
        
    Returns:
        Descriptive filename
    """
    parts = [sanitize_filename(product_name)]
    
    if dimensions:
        parts.append(sanitize_filename(dimensions))
    
    if version:
        parts.append(f"v{version}")
    
    filename = '-'.join(parts)
    extension = format_type.lower().lstrip('.')
    
    return f"{filename}.{extension}"


# Example:
# create_descriptive_filename(
#     "2026 Digital Planner",
#     "pdf",
#     "iPad-Portrait",
#     "2.0"
# )
# Returns: "2026-Digital-Planner-iPad-Portrait-v2.0.pdf"

File Organization

# etsy_client/file_organization.py
"""File organization for digital product businesses."""

from pathlib import Path
from typing import Dict, List
import shutil


class ProductFileOrganizer:
    """Organize digital product files for easy management."""
    
    def __init__(self, base_dir: str):
        """
        Initialize organizer.
        
        Args:
            base_dir: Base directory for all products
        """
        self.base_dir = Path(base_dir)
        self.base_dir.mkdir(parents=True, exist_ok=True)
    
    def create_product_structure(
        self,
        product_id: str,
        formats: List[str] = None
    ) -> Path:
        """
        Create directory structure for a new product.
        
        Args:
            product_id: Unique product identifier
            formats: List of format subdirectories to create
            
        Returns:
            Path to product directory
        """
        product_dir = self.base_dir / product_id
        product_dir.mkdir(exist_ok=True)
        
        # Standard subdirectories
        (product_dir / 'source').mkdir(exist_ok=True)  # Original/editable files
        (product_dir / 'export').mkdir(exist_ok=True)  # Customer-ready files
        (product_dir / 'images').mkdir(exist_ok=True)  # Listing images
        (product_dir / 'archive').mkdir(exist_ok=True)  # Old versions
        
        # Format-specific directories
        if formats:
            for fmt in formats:
                (product_dir / 'export' / fmt.lower()).mkdir(exist_ok=True)
        
        # Create metadata file
        metadata = {
            'product_id': product_id,
            'created': datetime.now().isoformat(),
            'formats': formats or [],
            'etsy_listing_id': None
        }
        
        import json
        with open(product_dir / 'metadata.json', 'w') as f:
            json.dump(metadata, f, indent=2)
        
        return product_dir
    
    def get_export_files(self, product_id: str) -> List[Path]:
        """Get all export-ready files for a product."""
        export_dir = self.base_dir / product_id / 'export'
        
        if not export_dir.exists():
            return []
        
        files = []
        for path in export_dir.rglob('*'):
            if path.is_file():
                files.append(path)
        
        return files
    
    def archive_version(
        self,
        product_id: str,
        version: str
    ):
        """Archive current export files before updating."""
        product_dir = self.base_dir / product_id
        export_dir = product_dir / 'export'
        archive_dir = product_dir / 'archive' / version
        
        if export_dir.exists():
            shutil.copytree(export_dir, archive_dir)

Key Takeaways

  1. Max 5 files per listing: Plan your file structure accordingly
  2. Use ZIP for many files: Bundle related files into a single ZIP
  3. Track versions: Maintain version history for customer support
  4. Descriptive filenames: Customers download many files; make yours identifiable
  5. Validate before upload: Check file types and sizes before API calls

Moving Forward

With digital file management mastered, you’re ready to handle the business side: orders and transactions. The next chapter covers processing sales, tracking revenue, and managing customer transactions through the API.


← Chapter 5: Managing Listings Table of Contents Chapter 7: Orders and Transactions →