Shell Commands

Flokkit Flow provides seamless integration with shell commands, allowing you to use external programs as nodes in your flow graphs. This enables powerful data processing pipelines that leverage existing command-line tools.

Overview

Shell commands can be integrated as:

  • Sources: Stream output from commands

  • Transforms: Process data through commands

  • Sinks: Send data to commands

All shell integrations are:

  • Type-safe

  • Async-native

  • Support streaming

  • Handle backpressure

  • Provide proper error handling

Basic Usage

Shell Command as Source

Use shell commands to generate or read data:

from flow import flow
from flow.shell import shell_source

# Stream lines from a file
cat_source = shell_source("cat /var/log/app.log")

await (
    flow()
    .source(cat_source, str)
    .transform(parse_log_line, LogEntry)
    .sink(store_in_db)
    .execute()
)

# Stream real-time logs
tail_logs = shell_source("tail -f /var/log/syslog")

await (
    flow()
    .source(tail_logs, str)
    .filter(lambda line: "ERROR" in line)
    .sink(alert_on_error)
    .execute()
)

Shell Command as Transform

Process data through external commands:

from flow.shell import shell_transform

# Text transformation with sed
clean_text = shell_transform("sed 's/[^a-zA-Z0-9 ]//g'")

await (
    flow()
    .source(dirty_texts, str)
    .transform(clean_text, str)
    .sink(save_clean_text)
    .execute()
)

# JSON processing with jq
extract_field = shell_transform("jq -r '.user.name'")

await (
    flow()
    .source(json_documents, str)
    .transform(extract_field, str)
    .sink(collect_names)
    .execute()
)

Shell Command as Sink

Send data to external commands:

from flow.shell import shell_sink

# Append to log file
append_log = shell_sink("tee -a application.log")

await (
    flow()
    .source(events, str)
    .transform(format_event, str)
    .sink(append_log)
    .execute()
)

# Send notifications
notify = shell_sink("notify-send 'Alert'", input_mode="args")

await (
    flow()
    .source(alerts, str)
    .sink(notify)
    .execute()
)

Advanced Features

The ShellCommand Class

For more control, use the ShellCommand class directly:

from flow.shell import ShellCommand

cmd = ShellCommand(
    "grep -E 'pattern'",
    input_mode="stdin",      # How to pass input: "stdin", "args", or "none"
    output_mode="lines",     # How to capture output: "lines", "all", or "none"
    cwd="/path/to/dir",      # Working directory
    env={"VAR": "value"},    # Environment variables
    timeout=30.0,            # Command timeout in seconds
    check=True,              # Raise on non-zero exit
)

# Use as transform
result = await cmd("input text")

Input Modes

Control how data is passed to commands:

# stdin mode - data passed via stdin
grep = shell_transform("grep ERROR", input_mode="stdin")

# args mode - data passed as command arguments
echo = shell_transform("echo", input_mode="args")

# none mode - no input (useful for sources)
date = shell_source("date +%s", input_mode="none")

Output Modes

Control how output is captured:

# lines mode - output split by lines (default)
lines = shell_source("ls -1", output_mode="lines")

# all mode - entire output as single string
content = shell_transform("cat", output_mode="all")

# none mode - no output captured (useful for sinks)
writer = shell_sink("tee output.txt", output_mode="none")

Custom Parsers

Parse command output into custom types:

import json

# Parse JSON output
def parse_json(text: str) -> dict:
    return json.loads(text)

json_source = shell_source(
    "curl -s https://api.example.com/data",
    dict,
    parser=parse_json
)

# Parse numbers
def parse_int(text: str) -> int:
    return int(text.strip())

number_gen = shell_source(
    "seq 1 10",
    int,
    parser=parse_int
)

Error Handling

Handle command failures gracefully:

# Don't raise on failure
cmd = ShellCommand("grep pattern", check=False)

async def safe_grep(text: str) -> str:
    result = await cmd(text)
    return result or "No match found"

# With timeout
risky_cmd = ShellCommand(
    "slow-command",
    timeout=5.0  # Kill after 5 seconds
)

try:
    result = await risky_cmd.run()
except TimeoutError:
    print("Command timed out")

Complex Pipelines

Build sophisticated pipelines combining multiple shell commands:

# Data processing pipeline
read_csv = shell_source("csvcut -c name,age,city data.csv")
filter_adults = shell_transform("awk -F, '$2 >= 18'")
sort_by_name = shell_transform("sort -t, -k1")
to_json = shell_transform("csvjson", parser=json.loads)

results = []
await (
    flow("CSV Processing")
    .source(read_csv, str)
    .transform(filter_adults, str)
    .transform(sort_by_name, str)
    .transform(to_json, dict)
    .sink(results.append)
    .execute()
)

Best Practices

Security

  1. Avoid shell injection: Never pass untrusted input directly to shell commands

  2. Use argument lists: Prefer list form over string commands

  3. Validate input: Always validate data before passing to commands

# BAD - vulnerable to injection
user_input = "file.txt; rm -rf /"
cmd = shell_source(f"cat {user_input}")  # DANGER!

# GOOD - safe from injection
cmd = ShellCommand(["cat", user_input])  # Safe

Performance

  1. Stream large data: Use streaming for large files

  2. Set timeouts: Prevent hanging on slow commands

  3. Handle backpressure: Commands respect flow backpressure

# Stream large files efficiently
large_file = shell_source("zcat huge.gz")

await (
    flow()
    .source(large_file, str)
    .batch(1000)  # Process in batches
    .transform(process_batch, List[str])
    .sink(bulk_insert)
    .execute()
)

Error Handling

  1. Check return codes: Use check=True for critical commands

  2. Handle timeouts: Set reasonable timeouts

  3. Log stderr: Capture error output for debugging

cmd = ShellCommand(
    "important-command",
    check=True,
    timeout=30.0
)

try:
    result = await cmd.run()
    if result.stderr:
        logger.warning(f"Command warnings: {result.stderr}")
except RuntimeError as e:
    logger.error(f"Command failed: {e}")
except TimeoutError:
    logger.error("Command timed out")

Examples

Log Analysis Pipeline

# Analyze web server logs
async def analyze_logs():
    # Source: Read compressed logs
    read_logs = shell_source("zcat /var/log/nginx/access.log.*.gz")
    
    # Transform: Extract timestamps and status codes
    extract_fields = shell_transform(
        "awk '{print $4, $9}'",
        parser=lambda s: s.strip().split()
    )
    
    # Transform: Filter 5xx errors
    filter_errors = shell_transform(
        "grep ' 5[0-9][0-9]$'",
        check=False  # Don't fail if no matches
    )
    
    errors = []
    await (
        flow("Log Analysis")
        .source(read_logs, str)
        .transform(extract_fields, list)
        .filter(lambda x: len(x) == 2)
        .transform(lambda x: f"{x[0]} {x[1]}", str)
        .transform(filter_errors, str)
        .sink(errors.append)
        .execute()
    )
    
    print(f"Found {len(errors)} 5xx errors")

Data ETL Pipeline

# Extract, transform, and load data
async def etl_pipeline():
    # Extract: Download data
    download = shell_source(
        "curl -s https://example.com/data.json | jq -c '.[]'"
    )
    
    # Transform: Process with Python
    async def enrich_record(json_str: str) -> dict:
        record = json.loads(json_str)
        record['processed_at'] = datetime.now().isoformat()
        return record
    
    # Load: Insert into database (via CLI tool)
    insert_cmd = shell_sink(
        "psql -c 'INSERT INTO records VALUES (%s)'",
        input_mode="args"
    )
    
    await (
        flow("ETL Pipeline")
        .source(download, str)
        .transform(enrich_record, dict)
        .transform(json.dumps, str)
        .sink(insert_cmd)
        .execute()
    )

Integration with Middleware

Shell commands work seamlessly with Flokkit Flow’s middleware system:

from flow import LoggingMiddleware, RetryMiddleware

# Add logging and retry to shell commands
logger = LoggingMiddleware()
retry = RetryMiddleware(max_attempts=3)

await (
    flow()
    .with_middleware(logger, retry)
    .source(shell_source("unstable-api-call"), str)
    .transform(parse_response, dict)
    .sink(save_result)
    .execute()
)

Limitations

  1. Platform dependent: Commands must exist on the system

  2. Text-based: Shell I/O is text-based (binary needs encoding)

  3. Process overhead: Each command spawns a new process

  4. Security risks: Be careful with untrusted input

Summary

Shell command integration in Flokkit Flow provides:

  • Seamless integration with existing tools

  • Type-safe command execution

  • Full async/streaming support

  • Flexible input/output modes

  • Robust error handling

This enables building powerful data pipelines that leverage the entire ecosystem of command-line tools while maintaining the safety and composability of Flokkit Flow.