Mechanical only, and separated from the judgment calls that follow so the reviewable changes are not buried in a 98-file whitespace diff. 227 automatic fixes: 60 blank lines carrying whitespace, 60 unsorted import blocks, 34 Optional[X] to X | None, 28 unused imports, 16 deprecated typing imports, 12 datetime.timezone.utc to datetime.UTC, and assorted smaller modernisations. Then `ruff format` over src and tests: 98 files reformatted, 35 already conforming. No file among the unused-import findings defines __all__ or is an __init__.py, so nothing here removes a re-export. `make test`: 658 passed, unchanged from HEAD. Two things observed while verifying, neither addressed here: `pytest tests/` cannot collect — tests/e2e/test_orchestration_e2e.py uses an `e2e` marker that is not registered, and the config is strict about markers. This fails identically at HEAD, so it predates this change; `make test` passes because it ignores tests/e2e, tests/integration and tests/contracts. test_tatlock_tool_call_logging_calculator is flaky. It failed once in a full run with these changes and passed on the next, passes in isolation with them, and fails in isolation at HEAD. It is order- or timing-dependent, not a regression from this commit — established by running the full suite both ways rather than by reasoning about which change could have caused it. Co-Authored-By: Claude <noreply@anthropic.com>
149 lines
3.5 KiB
Python
149 lines
3.5 KiB
Python
"""
|
|
Multi-tenancy helpers for Tatlock.
|
|
|
|
Provides utilities for user namespace management across:
|
|
- Qdrant (collection per user for memories)
|
|
- Redis (user-scoped keys for session context)
|
|
|
|
Adapted from library-desk patterns.
|
|
"""
|
|
|
|
import re
|
|
|
|
|
|
def sanitize_user_id(user_id: str) -> str:
|
|
"""
|
|
Sanitize user ID for use in collection names, keys, and paths.
|
|
|
|
Converts special characters to underscores and ensures alphanumeric safety.
|
|
|
|
Args:
|
|
user_id: Raw user identifier (email, username, etc.)
|
|
|
|
Returns:
|
|
Sanitized user ID safe for use in identifiers
|
|
|
|
Examples:
|
|
>>> sanitize_user_id("john@example.com")
|
|
'john_at_example_com'
|
|
>>> sanitize_user_id("user.name")
|
|
'user_name'
|
|
>>> sanitize_user_id("User Name")
|
|
'user_name'
|
|
"""
|
|
sanitized = user_id.lower()
|
|
|
|
# Convert @ to _at_
|
|
sanitized = sanitized.replace("@", "_at_")
|
|
|
|
# Convert dots to underscores
|
|
sanitized = sanitized.replace(".", "_")
|
|
|
|
# Replace any non-alphanumeric characters with underscores
|
|
sanitized = re.sub(r"[^a-z0-9_]", "_", sanitized)
|
|
|
|
# Remove consecutive underscores
|
|
sanitized = re.sub(r"_+", "_", sanitized)
|
|
|
|
# Remove leading/trailing underscores
|
|
sanitized = sanitized.strip("_")
|
|
|
|
return sanitized
|
|
|
|
|
|
def get_memory_collection_name(user_id: str) -> str:
|
|
"""
|
|
Get Qdrant collection name for user's memories.
|
|
|
|
Pattern: memories_{sanitized_user_id}
|
|
|
|
Args:
|
|
user_id: User identifier
|
|
|
|
Returns:
|
|
Qdrant collection name
|
|
|
|
Examples:
|
|
>>> get_memory_collection_name("jpmschweitzer")
|
|
'memories_jpmschweitzer'
|
|
>>> get_memory_collection_name("john@example.com")
|
|
'memories_john_at_example_com'
|
|
"""
|
|
sanitized = sanitize_user_id(user_id)
|
|
return f"memories_{sanitized}"
|
|
|
|
|
|
def get_session_key(user_id: str, conversation_id: str) -> str:
|
|
"""
|
|
Get Redis key for session context.
|
|
|
|
Pattern: session:{sanitized_user}:{conversation_id}
|
|
|
|
Args:
|
|
user_id: User identifier
|
|
conversation_id: Conversation identifier
|
|
|
|
Returns:
|
|
Redis key for session context
|
|
|
|
Examples:
|
|
>>> get_session_key("jpmschweitzer", "conv_abc123")
|
|
'session:jpmschweitzer:conv_abc123'
|
|
"""
|
|
sanitized = sanitize_user_id(user_id)
|
|
return f"session:{sanitized}:{conversation_id}"
|
|
|
|
|
|
def get_entities_key(user_id: str, conversation_id: str) -> str:
|
|
"""
|
|
Get Redis key for recent entities in a conversation.
|
|
|
|
Pattern: entities:{sanitized_user}:{conversation_id}
|
|
|
|
Args:
|
|
user_id: User identifier
|
|
conversation_id: Conversation identifier
|
|
|
|
Returns:
|
|
Redis key for recent entities
|
|
|
|
Examples:
|
|
>>> get_entities_key("jpmschweitzer", "conv_abc123")
|
|
'entities:jpmschweitzer:conv_abc123'
|
|
"""
|
|
sanitized = sanitize_user_id(user_id)
|
|
return f"entities:{sanitized}:{conversation_id}"
|
|
|
|
|
|
def validate_user_id(user_id: str) -> bool:
|
|
"""
|
|
Validate that a user ID is acceptable.
|
|
|
|
Checks:
|
|
- Not empty
|
|
- Not too long (max 100 chars)
|
|
- Contains some alphanumeric characters
|
|
|
|
Args:
|
|
user_id: User identifier to validate
|
|
|
|
Returns:
|
|
True if valid, False otherwise
|
|
|
|
Examples:
|
|
>>> validate_user_id("jpmschweitzer")
|
|
True
|
|
>>> validate_user_id("")
|
|
False
|
|
>>> validate_user_id("a" * 101)
|
|
False
|
|
"""
|
|
if not user_id or len(user_id) > 100:
|
|
return False
|
|
|
|
# Must contain at least one alphanumeric character
|
|
if not re.search(r"[a-zA-Z0-9]", user_id):
|
|
return False
|
|
|
|
return True
|