Skip to content

Feature: Auto-Type Dispatch & Pattern Matching for Namespace-Based Routing #17

@genro

Description

@genro

Feature Request: Auto-Type Dispatch + Pattern Matching

Executive Summary

Enhance SmartSwitch with two complementary features that eliminate boilerplate and enable powerful namespace-based dispatch:

  1. Auto-Type Inference: Automatically derive type-based dispatch from function type hints
  2. Pattern Matching: Enable regex-based function name matching with automatic type disambiguation

Motivation

Problem 1: Type Hint Duplication

Currently, SmartSwitch requires explicit typerule even when type hints are present:

@sw(typerule={'data': str})  # Redundant - already in signature
def process_string(data: str):
    return data.upper()

@sw(typerule={'data': int})  # Redundant - already in signature  
def process_number(data: int):
    return data * 2

This violates DRY principle - type information exists in two places.

Problem 2: Namespace-Based Operations

When implementing operations that accept different formats (JSON/dict, different protocols, etc.), we need separate function names but unified dispatch:

# Current: Must call by exact name
@sw
def add_json(data: str):
    record = json.loads(data)
    return db.insert(record)

@sw
def add_dict(data: dict):
    return db.insert(data)

# User must know which to call
sw('add_json')(data='{"name": "Alice"}')
sw('add_dict')(data={'name': 'Bob'})

# Desired: Unified namespace dispatch
sw('add.*')(data='{"name": "Alice"}')  # Auto-routes to add_json (str)
sw('add.*')(data={'name': 'Bob'})       # Auto-routes to add_dict (dict)

Proposed Solution

Feature 1: Auto-Type Inference

Before (v0.6.0):

@sw(typerule={'data': str})
def process_string(data: str):
    return data.upper()

@sw(typerule={'data': int})
def process_number(data: int):
    return data * 2

After (v0.7.0):

@sw  # Type hints auto-inferred
def process_string(data: str):
    return data.upper()

@sw  # Type hints auto-inferred
def process_number(data: int):
    return data * 2

# Auto-dispatch based on type
sw()(data="hello")  # → process_string
sw()(data=42)       # → process_number

Design Rules:

  • Auto-infer type hints ONLY when:
    • No explicit typerule provided
    • No explicit valrule provided
    • Function has at least one type hint
  • Auto-inferred typerule created from ALL parameters with hints
  • Explicit typerule always overrides hints
  • Return type hints are ignored (not used for dispatch)

Feature 2: Pattern Matching

Syntax: Full regex support via Python's re module

Examples:

sw('add.*')           # Match: add_json, add_dict, add_csv
sw('add_(json|dict)') # Match: add_json, add_dict only
sw(r'process_\w+')    # Match: process_string, process_number, etc.

Dispatch Algorithm:

  1. Filter functions by regex pattern
  2. For each candidate (in registration order):
    • Check signature compatibility (sig.bind())
    • Check type compatibility (using auto-inferred or explicit typerule)
    • If compatible → execute and return
  3. If no match → raise TypeError with candidates list

Priority Rules:

1. Named exact match:  sw('add_json')
2. Valrule match:      @sw(valrule=lambda x: x > 0)
3. Type match:         Auto-inferred from hints
4. Fallback:           No rules

Real-World Use Case

CRUD API with Multiple Formats:

from smartswitch import Switcher
import json

api = Switcher()

# JSON format
@api
def add_json(data: str, validate: bool = False):
    """Add record from JSON string."""
    record = json.loads(data)
    if validate:
        schema_check(record)
    return db.insert(record)

# Dict format with merge support
@api
def add_dict(data: dict, merge: bool = False, validate: bool = False):
    """Add record from dict."""
    if validate:
        schema_check(data)
    if merge:
        existing = db.get(data['id'])
        data = {**existing, **data}
    return db.insert(data)

# Unified dispatch - right function selected automatically
api('add.*')(data='{"name": "Alice"}')              # → add_json (str)
api('add.*')(data={'name': 'Bob'})                  # → add_dict (dict)
api('add.*')(data={'name': 'Charlie'}, merge=True)  # → add_dict (accepts merge)
api('add.*')(data='{}', validate=True)              # → add_json (str + validate)

Why this matters:

  • Unified API: Client calls add.*, not add_json vs add_dict
  • Type safety: Automatic routing based on actual data type
  • Extensibility: Add add_csv, add_xml without changing callers
  • Optional parameters: Functions with more specific params win when those params are provided

Technical Specification

Auto-Type Inference Implementation

# In Switcher.__call__() decorator
def __call__(self, name_or_func=None, /, **rules):
    def wrapper(func):
        # Auto-infer types if no explicit rules
        if "typerule" not in rules and "valrule" not in rules:
            from typing import get_type_hints
            hints = get_type_hints(func)
            if hints:
                # Build typerule from hints (exclude 'return')
                rules["typerule"] = {
                    param: hint 
                    for param, hint in hints.items()
                    if param != "return"
                }
        
        # Existing registration logic...

Pattern Matching Implementation

def _wildcard_dispatch(self, pattern: str):
    """Dispatch with regex pattern."""
    import re
    
    # Compile regex
    try:
        regex = re.compile(pattern)
    except re.error as e:
        raise ValueError(f"Invalid regex pattern '{pattern}': {e}")
    
    # Filter candidates
    candidates = [
        (name, func) 
        for name, func in self._spells.items()
        if regex.match(name)
    ]
    
    if not candidates:
        raise ValueError(f"No handlers match pattern: {pattern}")
    
    # Return dispatcher
    def dispatcher(*args, **kwargs):
        import inspect
        from typing import get_type_hints
        
        # Try each candidate in registration order
        for name, func in candidates:
            sig = inspect.signature(func)
            
            # Check signature compatibility
            try:
                bound = sig.bind(*args, **kwargs)
            except TypeError:
                continue  # Signature doesn't accept these args
            
            # Check type compatibility
            hints = get_type_hints(func)
            compatible = True
            for param_name, value in bound.arguments.items():
                if param_name in hints:
                    expected_type = hints[param_name]
                    if not isinstance(value, expected_type):
                        compatible = False
                        break
            
            if compatible:
                return func(*args, **kwargs)
        
        # No match
        names = ', '.join(n for n, _ in candidates)
        raise TypeError(
            f"No handler in [{names}] compatible with arguments: "
            f"args={args}, kwargs={kwargs}"
        )
    
    return dispatcher

Precedence & Ambiguity Resolution

Dispatch Precedence:

  1. Exact name match: sw('add_json') → direct lookup
  2. Pattern match: sw('add.*') → filter + type dispatch
  3. Rule-based: sw() → valrule → typerule → fallback

Within pattern match:

  1. Signature compatibility (must accept provided args)
  2. Type compatibility (types must match)
  3. First compatible wins (registration order)

Optional Parameter Scoring:

  • Function accepting more provided optional params is preferred
  • If merge=True provided, add_dict(data, merge) wins over add_dict(data)

API Changes

New in v0.7.0

Auto-type inference (always active):

@sw
def func(x: int): pass  # Auto-creates typerule={'x': int}

Pattern dispatch:

sw('pattern.*')(args)   # Regex matching + type dispatch
sw(r'add_\w+')(args)    # Full regex syntax supported

Backward compatibility: 100% - existing code works unchanged

Testing Strategy

Auto-Type Inference Tests

def test_auto_infer_single_param():
    sw = Switcher()
    
    @sw
    def process_str(data: str):
        return "string"
    
    @sw
    def process_int(data: int):
        return "integer"
    
    assert sw()(data="test") == "string"
    assert sw()(data=42) == "integer"

def test_explicit_typerule_overrides_hints():
    sw = Switcher()
    
    @sw(typerule={'data': int})  # Explicit override
    def process(data: str):  # Hint ignored
        return "int"
    
    assert sw()(data=42) == "int"
    with pytest.raises(TypeError):
        sw()(data="test")  # str not accepted

def test_valrule_disables_auto_infer():
    sw = Switcher()
    
    @sw(valrule=lambda x: x > 0)
    def positive(x: int):  # Hint not used for dispatch
        return "positive"
    
    assert sw()(x=5) == "positive"
    assert sw()(x=-5) raises NoMatchError  # valrule failed

Pattern Matching Tests

def test_pattern_with_different_types():
    sw = Switcher()
    
    @sw
    def add_json(data: str):
        return "json"
    
    @sw
    def add_dict(data: dict):
        return "dict"
    
    # Pattern + type dispatch
    assert sw('add.*')(data='{}') == "json"
    assert sw('add.*')(data={}) == "dict"

def test_pattern_with_optional_params():
    sw = Switcher()
    
    @sw
    def add_simple(data: dict):
        return "simple"
    
    @sw
    def add_with_merge(data: dict, merge: bool = False):
        return "merge"
    
    # Without merge: first match (simple)
    assert sw('add.*')(data={}) == "simple"
    
    # With merge: add_with_merge wins (accepts merge)
    assert sw('add.*')(data={}, merge=True) == "merge"

def test_pattern_no_match_error():
    sw = Switcher()
    
    @sw
    def add_json(data: str):
        pass
    
    # Pattern matches but type doesn't
    with pytest.raises(TypeError, match="No handler.*compatible"):
        sw('add.*')(data=123)  # int not accepted by add_json

Implementation Phases

Phase 1: Auto-Type Inference (v0.7.0)

  • Implement auto-infer logic in Switcher.__call__()
  • Add comprehensive tests (15+ test cases)
  • Update documentation with examples
  • Verify backward compatibility (all existing tests pass)

Phase 2: Pattern Matching (v0.7.0)

  • Implement regex filtering in Switcher.__call__()
  • Implement _wildcard_dispatch() method
  • Add signature compatibility checking
  • Add comprehensive tests (20+ test cases)
  • Update documentation with use cases

Phase 3: Integration & Polish (v0.7.0)

  • Performance benchmarks (overhead of auto-infer + pattern match)
  • Edge case testing (complex types, Union, Optional, etc.)
  • Documentation: Migration guide for existing code
  • Documentation: Best practices for pattern-based APIs

Breaking Changes

None - this is a pure enhancement with 100% backward compatibility.

  • Existing code without type hints: works as before
  • Existing code with explicit typerule: works as before
  • Pattern matching: new feature, no existing behavior changed

Open Questions

  1. Union types: How to handle Union[int, str]?

    • Proposal: Match either type (try int, then str)
  2. Optional types: How to handle Optional[str]?

    • Proposal: Match str or None
  3. Generic types: How to handle List[str], Dict[str, int]?

    • Proposal: Match base type only (list, dict), ignore generic params
  4. Performance: What's the overhead of auto-infer + pattern match?

    • Need benchmarks before finalizing

Expected Benefits

  • Less boilerplate: Eliminate redundant typerule declarations
  • More Pythonic: Leverage existing type hints
  • Powerful APIs: Namespace-based routing enables flexible interfaces
  • Better DX: Users call logical operations (add.*), not implementation details (add_json)
  • Extensible: Add new formats without changing client code

Target Version

v0.7.0 - Expected Q1 2025

References


Status: Proposal - awaiting feedback and approval
Labels: enhancement, v0.7.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    deferredFeature deferred for future consideration

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions