Basic Usage
This guide covers the fundamental patterns for building flows with Flokkit Flow.
Building Your First Pipeline
Simple Linear Pipeline
from flow import flow
import asyncio
async def main():
builder = flow("Simple Pipeline")
# Source: Generate data
def generate_data():
for i in range(5):
yield f"Item {i}"
# Transform: Process data
def process_item(item: str) -> str:
return item.upper()
# Sink: Output results
def print_result(item: str) -> None:
print(f"Processed: {item}")
# Connect nodes
(builder
.source(generate_data)
.to(process_item)
.to(print_result))
# Run pipeline
await builder.run()
asyncio.run(main())
Adding Multiple Transforms
(builder
.source(read_lines)
.to(strip_whitespace)
.to(parse_json)
.to(validate_schema)
.to(enrich_data)
.to(save_to_db))
Working with Different Data Types
Type-Safe Pipelines
from typing import Dict, List
from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
email: str
@dataclass
class EnrichedUser:
user: User
score: float
tags: List[str]
async def create_user_pipeline():
builder = flow("User Processing")
def fetch_users() -> User:
# Fetch from database
yield User(1, "Alice", "alice@example.com")
yield User(2, "Bob", "bob@example.com")
def calculate_score(user: User) -> float:
# Complex scoring logic
return len(user.name) * 10.0
def enrich_user(user: User) -> EnrichedUser:
score = calculate_score(user)
tags = ["active", "verified"] if score > 50 else ["new"]
return EnrichedUser(user, score, tags)
def save_enriched(enriched: EnrichedUser) -> None:
print(f"Saving {enriched.user.name} with score {enriched.score}")
(builder
.source(fetch_users)
.to(enrich_user)
.to(save_enriched))
await builder.run()
Filtering Data
Basic Filtering
def is_valid(item: dict) -> bool:
return item.get("status") == "active"
(builder
.source(fetch_items)
.filter(is_valid) # Only active items pass through
.to(process_item))
Filtering with Side Effects
def log_invalid(item: dict) -> bool:
if item.get("status") != "active":
logger.warning(f"Filtered out inactive item: {item['id']}")
return False
return True
(builder
.source(fetch_items)
.filter(log_invalid)
.to(process_item))
Observing Data with Tap
Logging Pipeline
def log_throughput(item: Any) -> None:
logger.info(f"Processing item: {item}")
(builder
.source(generate_data)
.tap(log_throughput) # Observe without consuming
.to(transform_data)
.tap(lambda x: metrics.increment("items_processed"))
.to(save_data))
Debugging with Tap
def debug_print(item: Any, label: str = "") -> None:
print(f"[DEBUG {label}] {item}")
(builder
.source(complex_source)
.tap(lambda x: debug_print(x, "after source"))
.to(transform_1)
.tap(lambda x: debug_print(x, "after transform1"))
.to(transform_2)
.tap(lambda x: debug_print(x, "after transform2"))
.to(final_sink))
Splitting and Merging Flows
Fan-Out Pattern
async def fan_out_example():
builder = flow("Fan-Out")
source = builder.source(generate_data)
# Split to multiple paths
path1 = source.to(processor_1)
path2 = source.to(processor_2)
path3 = source.to(processor_3)
# Each path processes independently
path1.to(sink_1)
path2.to(sink_2)
path3.to(sink_3)
await builder.run()
Fan-In Pattern
async def fan_in_example():
builder = flow("Fan-In")
# Multiple sources
source1 = builder.source(api_source_1)
source2 = builder.source(api_source_2)
source3 = builder.source(api_source_3)
# Merge into single stream
merged = source1.merge_with(source2, source3)
# Process merged stream
merged.to(unified_processor).to(save_results)
await builder.run()
Caching Expensive Operations
Enable Caching
def expensive_calculation(data: str) -> float:
# Simulate expensive operation
time.sleep(1)
return len(data) * 42.0
(builder
.source(generate_strings)
.to(expensive_calculation, cache=True) # Results are cached
.to(print_result))
Custom Cache Key
from functools import lru_cache
@lru_cache(maxsize=1000)
def cached_api_call(user_id: int) -> dict:
# This will only be called once per unique user_id
return fetch_user_data(user_id)
(builder
.source(get_user_ids)
.to(cached_api_call)
.to(process_user))
Error Handling Patterns
Continue on Error
def safe_process(item: dict) -> Optional[dict]:
try:
return risky_transform(item)
except ValueError as e:
logger.error(f"Failed to process {item}: {e}")
return None # Continue with None
(builder
.source(fetch_items)
.to(safe_process)
.filter(lambda x: x is not None) # Skip failed items
.to(save_result))
Error Output Stream
async def error_handling_flow():
builder = flow("Error Handling")
def process_with_errors(item: str) -> str:
if "error" in item:
raise ValueError(f"Invalid item: {item}")
return item.upper()
# Main path
source = builder.source(generate_mixed_data)
# Split good and bad items
good_items = source.filter(lambda x: "error" not in x)
bad_items = source.filter(lambda x: "error" in x)
# Process good items
good_items.to(process_with_errors).to(save_good)
# Handle bad items
bad_items.to(log_error).to(save_to_error_queue)
await builder.run()
Working with External Resources
Database Pipeline
class DatabasePipeline:
def __init__(self, connection_string: str):
self.connection_string = connection_string
self.pool = None
async def setup(self):
self.pool = await asyncpg.create_pool(self.connection_string)
async def cleanup(self):
if self.pool:
await self.pool.close()
async def fetch_records(self):
async with self.pool.acquire() as conn:
async for record in conn.cursor("SELECT * FROM users"):
yield dict(record)
async def update_record(self, record: dict):
async with self.pool.acquire() as conn:
await conn.execute(
"UPDATE users SET processed = true WHERE id = $1",
record["id"]
)
async def run_db_pipeline():
db = DatabasePipeline("postgresql://localhost/mydb")
await db.setup()
try:
builder = flow("Database Processing")
(builder
.source(db.fetch_records)
.to(transform_record)
.to(db.update_record))
await builder.run()
finally:
await db.cleanup()
Best Practices
1. Keep Functions Pure
# Good: Pure function
def double(x: int) -> int:
return x * 2
# Bad: Side effects in transform
def double_with_side_effect(x: int) -> int:
print(f"Doubling {x}") # Use tap() instead
return x * 2
2. Use Type Hints
# Good: Clear types
def parse_user(data: str) -> User:
return User.from_json(data)
# Bad: No type hints
def parse_user(data):
return User.from_json(data)
3. Handle None Values
def safe_transform(item: Optional[str]) -> Optional[str]:
if item is None:
return None
return item.upper()
4. Batch Operations
class BatchProcessor:
def __init__(self, batch_size: int = 100):
self.batch = []
self.batch_size = batch_size
async def process(self, item: dict) -> Optional[List[dict]]:
self.batch.append(item)
if len(self.batch) >= self.batch_size:
result = await self.process_batch(self.batch)
self.batch = []
return result
return None # No output yet
async def flush(self) -> List[dict]:
if self.batch:
result = await self.process_batch(self.batch)
self.batch = []
return result
return []
Next Steps
Learn about Async Programming patterns
Understand Backpressure handling
Explore Lifecycle Management
See Common Patterns