* feat: introduce hindsight-api-slim and hindsight-all-slim packages Closes #552 - Move all source code from hindsight-api/ to new hindsight-api-slim/ - hindsight-api-slim has heavy ML deps (torch, sentence-transformers, transformers, einops, flashrank, mlx, mlx-lm, safetensors) and pg0-embedded as optional extras: [local-ml], [embedded-db], [all] - hindsight-api becomes a zero-code meta-package depending on hindsight-api-slim[all] for full backward compatibility - Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed - hindsight-all updated to depend on hindsight-api-slim[all] - pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db] - Dockerfile: replace sed hack with proper uv sync --extra flags - Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and all path references throughout the repo * refactor: rename hindsight/ directory to hindsight-all/ * docs: document hindsight-api-slim and hindsight-all-slim package variants Add package variants table and extras explanation to installation.md * docs: remove emojis from installation.md, use professional tone * docs: link Docker slim variant to pip package variants section * docs: consolidate Docker image variants into single table * ci: fix working-directory paths after package restructure - Replace all hindsight-api → hindsight-api-slim in test.yml - Replace hindsight → hindsight-all in test.yml - Add --extra embedded-db to test-embed API install step * ci: add local-ml and embedded-db extras to API sync steps These extras were previously implicit in the old hindsight-api package (which bundled everything). Now that hindsight-api-slim uses optional extras, we must explicitly request local-ml and embedded-db in CI. * ci: add API install step with embedded-db to test-embed smoke test The smoke test starts hindsight-api as a daemon, which requires pg0-embedded. Add a dedicated install step for hindsight-api-slim with embedded-db extra so the daemon can start successfully. * ci: remove --no-install-project when using optional extras When --no-install-project is combined with --extra, the optional deps are not installed because extras require the project to be active. Remove --no-install-project from steps that need local-ml or embedded-db. * ci: fix ordering of uv sync steps to preserve optional extras When uv sync runs for a different workspace member, it removes optional extras installed for other members. Fix by always running extra-requiring API sync last, after other workspace member syncs. Also remove --no-install-project from embedded-db sync in test-embed, as --no-install-project prevents optional extras from being active. * ci: add local-ml extra to test-embed API install for smoke test The smoke test starts the full API server which needs sentence-transformers for local embeddings (default provider). Add local-ml extra to the install. * ci: simplify extras with --all-extras and add slim pip smoke test - Replace explicit --extra local-ml --extra embedded-db with --all-extras for cleaner, more maintainable sync steps - Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without local ML models, using Cohere for embeddings/reranking (mirrors Docker slim smoke test approach) * ci: simplify slim smoke test to health check only (mirrors Docker test)
138 lines
4.6 KiB
Python
138 lines
4.6 KiB
Python
"""
|
|
Safety tests to ensure all SQL queries use fully-qualified table names.
|
|
|
|
This prevents cross-tenant data access by ensuring every table reference
|
|
includes the schema prefix (e.g., public.memory_units instead of just memory_units).
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# All tables that MUST be schema-qualified in SQL queries
|
|
TABLES = [
|
|
"memory_units",
|
|
"memory_links",
|
|
"unit_entities",
|
|
"entities",
|
|
"entity_cooccurrences",
|
|
"banks",
|
|
"documents",
|
|
"chunks",
|
|
"async_operations",
|
|
"directives",
|
|
"mental_models",
|
|
]
|
|
|
|
# Files to scan for SQL queries
|
|
SCAN_PATHS = [
|
|
"hindsight_api/engine",
|
|
"hindsight_api/api",
|
|
]
|
|
|
|
# Files to exclude (e.g., migrations, tests)
|
|
EXCLUDE_PATTERNS = [
|
|
"alembic",
|
|
"__pycache__",
|
|
"test_",
|
|
]
|
|
|
|
|
|
def get_python_files() -> list[Path]:
|
|
"""Get all Python files to scan."""
|
|
root = Path(__file__).parent.parent
|
|
files = []
|
|
for scan_path in SCAN_PATHS:
|
|
path = root / scan_path
|
|
if path.exists():
|
|
for py_file in path.rglob("*.py"):
|
|
# Check exclusions
|
|
if any(excl in str(py_file) for excl in EXCLUDE_PATTERNS):
|
|
continue
|
|
files.append(py_file)
|
|
return files
|
|
|
|
|
|
def find_unqualified_table_refs(content: str, filename: str) -> list[tuple[int, str, str]]:
|
|
"""
|
|
Find SQL statements with unqualified table references.
|
|
|
|
Returns list of (line_number, table_name, line_content).
|
|
"""
|
|
violations = []
|
|
|
|
# Patterns that indicate SQL context
|
|
sql_keywords = r"(?:FROM|JOIN|INTO|UPDATE|DELETE\s+FROM)\s+"
|
|
|
|
# Additional SQL indicators to confirm this is actually SQL, not prose
|
|
sql_indicators = re.compile(
|
|
r"(SELECT|INSERT|DELETE|UPDATE|CREATE|ALTER|DROP|WHERE|SET|VALUES|"
|
|
r'f"""|f\'\'\'|""".*SELECT|\'\'\'.*SELECT)',
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
lines = content.split("\n")
|
|
for line_num, line in enumerate(lines, 1):
|
|
# Skip comments and strings that are clearly not SQL
|
|
stripped = line.strip()
|
|
if stripped.startswith("#"):
|
|
continue
|
|
|
|
for table in TABLES:
|
|
# Pattern: SQL keyword followed by unqualified table name
|
|
# Should match: FROM memory_units, JOIN memory_units, INTO memory_units
|
|
# Should NOT match: FROM public.memory_units, FROM {schema}.memory_units
|
|
# Should NOT match: fq_table("memory_units")
|
|
|
|
# Check for unqualified table after SQL keyword
|
|
pattern = rf"{sql_keywords}{table}(?:\s|$|,|\))"
|
|
|
|
if re.search(pattern, line, re.IGNORECASE):
|
|
# Check if it's actually qualified (has schema prefix)
|
|
qualified_pattern = rf"\.\s*{table}(?:\s|$|,|\))"
|
|
fq_table_pattern = rf'fq_table\s*\(\s*["\']?{table}'
|
|
|
|
if not re.search(qualified_pattern, line) and not re.search(
|
|
fq_table_pattern, line
|
|
):
|
|
# Additional check: line must have SQL indicators
|
|
# This avoids false positives in docstrings like "split into chunks"
|
|
if sql_indicators.search(line):
|
|
violations.append((line_num, table, stripped))
|
|
|
|
return violations
|
|
|
|
|
|
class TestSQLSchemaSafety:
|
|
"""Ensure all SQL uses schema-qualified table names."""
|
|
|
|
def test_no_unqualified_table_references(self):
|
|
"""All SQL queries must use fq_table() or schema.table format."""
|
|
all_violations = []
|
|
|
|
for py_file in get_python_files():
|
|
content = py_file.read_text()
|
|
violations = find_unqualified_table_refs(content, py_file.name)
|
|
|
|
for line_num, table, line in violations:
|
|
all_violations.append(
|
|
f"{py_file.relative_to(py_file.parent.parent)}:{line_num} - "
|
|
f"unqualified '{table}': {line[:80]}..."
|
|
)
|
|
|
|
if all_violations:
|
|
msg = (
|
|
f"Found {len(all_violations)} unqualified table references!\n"
|
|
"These could cause cross-tenant data access.\n"
|
|
"Use fq_table('table_name') for all table references.\n\n"
|
|
+ "\n".join(all_violations[:20]) # Show first 20
|
|
)
|
|
if len(all_violations) > 20:
|
|
msg += f"\n... and {len(all_violations) - 20} more"
|
|
pytest.fail(msg)
|
|
|
|
def test_tables_list_is_complete(self):
|
|
"""Verify we're checking for all tables (sanity check)."""
|
|
# This is a sanity check - if you add a new table, add it to TABLES
|
|
assert len(TABLES) >= 9, "Update TABLES list if you added new tables"
|