Skip to main content

Tools for master-worker media processing / ML - provides task plugins, worker runtime, and protocols for job persistence and file storage

Project description

cl_ml_tools

A Python library for building master-worker media processing / ML systems. Provides task plugins, worker runtime, and protocols for job persistence and file storage.

Features

  • Plugin-based architecture - Add new media processing / ML tasks without modifying core code
  • Protocol-driven design - Implement JobRepository and FileStorage to integrate with any database/storage
  • Dynamic discovery - Plugins auto-registered via Python entry points
  • Race-condition safe - Atomic job claiming prevents duplicate processing
  • FastAPI integration - Auto-generates REST endpoints for job creation

Installation

pip install cl_ml_tools

# With FastAPI support (for master/API server)
pip install cl_ml_tools[master]

# With compute plugins (image processing, etc.)
pip install cl_ml_tools[compute]

# All extras
pip install cl_ml_tools[master,compute]

Quick Start

1. Implement the Protocols

You need to implement two protocols to integrate with your system:

JobRepository Protocol

from typing import Optional, List
from cl_ml_tools.common.schemas import Job

class SQLiteJobRepository:
    """SQLite implementation of JobRepository protocol."""
    
    def __init__(self, db_path: str):
        import sqlite3
        self.conn = sqlite3.connect(db_path, check_same_thread=False)
        self.conn.row_factory = sqlite3.Row
        self._create_tables()
    
    def _create_tables(self):
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS jobs (
                job_id TEXT PRIMARY KEY,
                task_type TEXT NOT NULL,
                params TEXT NOT NULL,
                status TEXT DEFAULT 'queued',
                progress INTEGER DEFAULT 0,
                task_output TEXT,
                error_message TEXT,
                created_by TEXT,
                priority INTEGER DEFAULT 5,
                created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000)
            )
        """)
        self.conn.commit()
    
    def add_job(
        self, 
        job: Job, 
        created_by: Optional[str] = None, 
        priority: Optional[int] = None
    ) -> str:
        """Save job to database."""
        import json
        self.conn.execute(
            """INSERT INTO jobs (job_id, task_type, params, status, created_by, priority)
               VALUES (?, ?, ?, ?, ?, ?)""",
            (job.job_id, job.task_type, json.dumps(job.params), 
             job.status, created_by, priority or 5)
        )
        self.conn.commit()
        return job.job_id
    
    def get_job(self, job_id: str) -> Optional[Job]:
        """Get job by ID."""
        import json
        cursor = self.conn.execute(
            "SELECT * FROM jobs WHERE job_id = ?", (job_id,)
        )
        row = cursor.fetchone()
        if row:
            return Job(
                job_id=row['job_id'],
                task_type=row['task_type'],
                params=json.loads(row['params']),
                status=row['status'],
                progress=row['progress'],
                task_output=json.loads(row['task_output']) if row['task_output'] else None,
                error_message=row['error_message']
            )
        return None
    
    def update_job(self, job_id: str, **kwargs) -> bool:
        """Update job fields."""
        import json
        if 'task_output' in kwargs and kwargs['task_output'] is not None:
            kwargs['task_output'] = json.dumps(kwargs['task_output'])
        
        set_clause = ', '.join(f"{k} = ?" for k in kwargs.keys())
        values = list(kwargs.values()) + [job_id]
        
        cursor = self.conn.execute(
            f"UPDATE jobs SET {set_clause} WHERE job_id = ?", values
        )
        self.conn.commit()
        return cursor.rowcount > 0
    
    def fetch_next_job(self, task_types: List[str]) -> Optional[Job]:
        """
        Atomically claim next queued job.
        
        Uses BEGIN IMMEDIATE to acquire write lock, preventing race conditions
        when multiple workers are running.
        """
        import json
        cursor = self.conn.cursor()
        cursor.execute("BEGIN IMMEDIATE")
        
        try:
            placeholders = ','.join('?' * len(task_types))
            cursor.execute(f"""
                SELECT * FROM jobs 
                WHERE status = 'queued' AND task_type IN ({placeholders})
                ORDER BY priority DESC, created_at ASC
                LIMIT 1
            """, task_types)
            
            row = cursor.fetchone()
            if row:
                cursor.execute(
                    "UPDATE jobs SET status = 'processing' WHERE job_id = ?",
                    (row['job_id'],)
                )
                self.conn.commit()
                return Job(
                    job_id=row['job_id'],
                    task_type=row['task_type'],
                    params=json.loads(row['params']),
                    status='processing',  # Return with updated status
                    progress=row['progress'],
                    task_output=json.loads(row['task_output']) if row['task_output'] else None,
                    error_message=row['error_message']
                )
            
            self.conn.rollback()
            return None
        except Exception:
            self.conn.rollback()
            raise
    
    def delete_job(self, job_id: str) -> bool:
        """Delete job from database."""
        cursor = self.conn.execute(
            "DELETE FROM jobs WHERE job_id = ?", (job_id,)
        )
        self.conn.commit()
        return cursor.rowcount > 0

FileStorage Protocol

from pathlib import Path
import hashlib
import shutil

class LocalFileStorage:
    """Local filesystem implementation of FileStorage protocol."""
    
    def __init__(self, base_dir: str):
        self.base_dir = Path(base_dir)
        self.base_dir.mkdir(parents=True, exist_ok=True)
    
    def create_job_directory(self, job_id: str) -> Path:
        """Create job directory with input/output subdirs."""
        job_dir = self.base_dir / "jobs" / job_id
        (job_dir / "input").mkdir(parents=True, exist_ok=True)
        (job_dir / "output").mkdir(parents=True, exist_ok=True)
        return job_dir
    
    def get_input_path(self, job_id: str) -> Path:
        """Get absolute path to job's input directory."""
        return self.base_dir / "jobs" / job_id / "input"
    
    def get_output_path(self, job_id: str) -> Path:
        """Get absolute path to job's output directory."""
        return self.base_dir / "jobs" / job_id / "output"
    
    async def save_input_file(self, job_id: str, filename: str, file) -> dict:
        """Save uploaded file to job's input directory."""
        input_dir = self.get_input_path(job_id)
        file_path = input_dir / filename
        
        # Read and save file
        content = await file.read()
        file_path.write_bytes(content)
        
        # Calculate hash
        file_hash = hashlib.sha256(content).hexdigest()
        
        return {
            "filename": filename,
            "path": str(file_path),  # Absolute path
            "size": len(content),
            "hash": file_hash
        }
    
    def cleanup_job(self, job_id: str) -> bool:
        """Delete job directory and all files."""
        job_dir = self.base_dir / "jobs" / job_id
        if job_dir.exists():
            shutil.rmtree(job_dir)
            return True
        return False

2. Setup Master (API Server)

from fastapi import FastAPI
from cl_ml_tools.master import create_master_router

# Your protocol implementations
from my_app.repository import SQLiteJobRepository
from my_app.storage import LocalFileStorage

app = FastAPI(title="ML Tools")

# Initialize implementations
repository = SQLiteJobRepository("./jobs.db")
file_storage = LocalFileStorage("./media_storage")

# Auth dependency (implement your own)
async def get_current_user():
    return None  # Or return user object from JWT/session

# Mount all plugin routes (auto-discovered from entry points)
app.include_router(
    create_master_router(repository, file_storage, get_current_user),
    prefix="/api"
)

# Optional: Add job status endpoint
@app.get("/api/jobs/{job_id}")
async def get_job_status(job_id: str):
    job = repository.get_job(job_id)
    if not job:
        raise HTTPException(404, "Job not found")
    return job.model_dump()

Run the server:

uvicorn my_app.main:app --reload

Note: Available API endpoints are auto-generated from registered plugins. See pyproject.toml entry points and src/cl_ml_tools/plugins/ for available task types.

3. Setup Worker

import asyncio
from cl_ml_tools.worker import Worker

# Same repository as master (shared database)
from my_app.repository import SQLiteJobRepository

repository = SQLiteJobRepository("./jobs.db")
worker = Worker(repository)

async def run_worker():
    """Process jobs forever."""
    print(f"Worker started. Supported tasks: {worker.get_supported_task_types()}")
    
    while True:
        # Process one job (or all supported types if None)
        processed = await worker.run_once()
        
        if not processed:
            # No jobs available, wait before polling again
            await asyncio.sleep(1.0)

if __name__ == "__main__":
    asyncio.run(run_worker())

Run the worker:

python -m my_app.worker

Creating Custom Plugins

1. Create Plugin Structure

my_app/plugins/watermark/
├── __init__.py
├── schema.py
├── task.py
└── routes.py

2. Define Parameters Schema

# schema.py
from cl_ml_tools.common.schemas import BaseJobParams

class WatermarkParams(BaseJobParams):
    watermark_text: str
    position: str = "bottom-right"  # top-left, top-right, bottom-left, bottom-right
    opacity: float = 0.5

3. Implement Task

# task.py
from cl_ml_tools.common.compute_module import ComputeModule
from cl_ml_tools.common.schemas import Job
from .schema import WatermarkParams

class WatermarkTask(ComputeModule):
    @property
    def task_type(self) -> str:
        return "watermark"
    
    def get_schema(self):
        return WatermarkParams
    
    async def execute(self, job: Job, params: WatermarkParams, progress_callback=None):
        from PIL import Image, ImageDraw, ImageFont
        
        try:
            for i, (input_path, output_path) in enumerate(
                zip(params.input_paths, params.output_paths)
            ):
                # Load image
                img = Image.open(input_path)
                
                # Add watermark
                draw = ImageDraw.Draw(img)
                draw.text((10, 10), params.watermark_text, fill=(255, 255, 255, 128))
                
                # Save
                img.save(output_path)
                
                # Report progress
                if progress_callback:
                    progress_callback(int((i + 1) / len(params.input_paths) * 100))
            
            return {
                "status": "ok",
                "task_output": {"processed_files": params.output_paths}
            }
        except Exception as e:
            return {"status": "error", "error": str(e)}

4. Create Route Factory

# routes.py
from fastapi import APIRouter, UploadFile, File, Form, Depends
from typing import Callable
from uuid import uuid4

from cl_ml_tools.common.job_repository import JobRepository
from cl_ml_tools.common.file_storage import FileStorage
from cl_ml_tools.common.schemas import Job

def create_router(
    repository: JobRepository,
    file_storage: FileStorage,
    get_current_user: Callable
) -> APIRouter:
    router = APIRouter()

    @router.post("/jobs/watermark")
    async def create_watermark_job(
        file: UploadFile = File(...),
        watermark_text: str = Form(...),
        position: str = Form("bottom-right"),
        opacity: float = Form(0.5),
        priority: int = Form(5),
        user = Depends(get_current_user)
    ):
        job_id = str(uuid4())
        
        file_storage.create_job_directory(job_id)
        file_info = await file_storage.save_input_file(job_id, file.filename, file)
        
        input_path = file_info["path"]
        output_path = str(file_storage.get_output_path(job_id) / f"watermarked_{file.filename}")
        
        job = Job(
            job_id=job_id,
            task_type="watermark",
            params={
                "input_paths": [input_path],
                "output_paths": [output_path],
                "watermark_text": watermark_text,
                "position": position,
                "opacity": opacity
            }
        )
        repository.add_job(job, created_by=user.id if user else None, priority=priority)
        
        return {"job_id": job_id, "status": "queued"}

    return router

5. Register Plugin

Add to your pyproject.toml:

[project.entry-points."cl_ml_tools.tasks"]
watermark = "my_app.plugins.watermark.task:WatermarkTask"

[project.entry-points."cl_ml_tools.routes"]
watermark = "my_app.plugins.watermark.routes:create_router"

The plugin is now auto-discovered by both master and worker!

Database-Specific Implementation Notes

PostgreSQL / MySQL

Use FOR UPDATE SKIP LOCKED for efficient concurrent job claiming:

from sqlalchemy import select
from sqlalchemy.orm import Session

def fetch_next_job(self, task_types: List[str], session: Session) -> Optional[Job]:
    stmt = (
        select(JobModel)
        .where(JobModel.status == 'queued')
        .where(JobModel.task_type.in_(task_types))
        .order_by(JobModel.priority.desc(), JobModel.created_at.asc())
        .limit(1)
        .with_for_update(skip_locked=True)  # Skip locked rows
    )
    
    job = session.execute(stmt).scalar_one_or_none()
    if job:
        job.status = 'processing'
        session.commit()
        return Job.model_validate(job)
    return None

SQLite

Use BEGIN IMMEDIATE for write lock (shown in Quick Start example above).

Note: SQLite locks the entire database, not individual rows. For high concurrency, use PostgreSQL or MySQL in production.

Available Plugins

See src/cl_ml_tools/plugins/ for built-in plugins and their documentation.

Registered plugins are listed in pyproject.toml under [project.entry-points."cl_ml_tools.tasks"].

Architecture

┌─────────────────────────────────────────────────────────────┐
│                     Your Application                        │
├─────────────────────────────────────────────────────────────┤
│  SQLiteJobRepository implements JobRepository               │
│  LocalFileStorage implements FileStorage                    │
│  get_current_user() for authentication                      │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                      cl_ml_tools                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Protocols:          Base Class:                            │
│  ┌──────────────┐    ┌────────────────────────────────┐    │
│  │JobRepository │    │ ComputeModule (ABC)            │    │
│  │FileStorage   │    │ - task_type                    │    │
│  └──────────────┘    │ - get_schema()                 │    │
│                      │ - execute(job, params, cb)     │    │
│                      └────────────────────────────────┘    │
│                                   ▲                         │
│  Plugins:                         │ extends                 │
│  ┌────────────────────────────────────────────────────┐    │
│  │ See src/cl_ml_tools/plugins/ for available tasks│    │
│  └────────────────────────────────────────────────────┘    │
│                                                             │
│  Runtime:                                                   │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ Worker - orchestrates job execution                 │   │
│  │ create_master_router() - aggregates plugin routes   │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

License

MIT License

Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Add your plugin to src/cl_ml_tools/plugins/
  4. Register entry points in pyproject.toml
  5. Add a README.md in your plugin directory documenting parameters
  6. Submit a pull request

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cl_ml_tools-0.2.0.tar.gz (21.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cl_ml_tools-0.2.0-py3-none-any.whl (24.2 kB view details)

Uploaded Python 3

File details

Details for the file cl_ml_tools-0.2.0.tar.gz.

File metadata

  • Download URL: cl_ml_tools-0.2.0.tar.gz
  • Upload date:
  • Size: 21.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.9

File hashes

Hashes for cl_ml_tools-0.2.0.tar.gz
Algorithm Hash digest
SHA256 1bb5764c873032a46acdb6fa129abf3b3527c096b84134e8bbadde413d52a883
MD5 29c85ce9669213a5b7432cc3ff2afcf6
BLAKE2b-256 cb0e4e1fb77ccdac320a8eb166c939db695e3f812ef4099c23ddfb53b8d81b20

See more details on using hashes here.

File details

Details for the file cl_ml_tools-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: cl_ml_tools-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 24.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.9

File hashes

Hashes for cl_ml_tools-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9d30ded497cbdef056af2f20ebcb34a7848cba2b72bc7e8819b88628ef953e8b
MD5 8ded63ff3b525686bf09ebcf744f879b
BLAKE2b-256 01cc4c5f4fafc22f67adef8c64379888f433379790b2212acde869a64585d655

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page