irides-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. core/__init__.py +2 -0
  2. core/basic_test.py +78 -0
  3. core/check_mysql_connection.py +23 -0
  4. core/db_connector/__init__.py +12 -0
  5. core/db_connector/ai_service.py +133 -0
  6. core/db_connector/cache_manager.py +69 -0
  7. core/db_connector/config_service.py +116 -0
  8. core/db_connector/configurations.py +383 -0
  9. core/db_connector/connectors/__init__.py +19 -0
  10. core/db_connector/connectors/athena.py +204 -0
  11. core/db_connector/connectors/duckdb.py +225 -0
  12. core/db_connector/connectors/dynamodb.py +233 -0
  13. core/db_connector/connectors/mongodb.py +337 -0
  14. core/db_connector/connectors/mysql.py +264 -0
  15. core/db_connector/connectors/postgres.py +341 -0
  16. core/db_connector/connectors/sqlite.py +178 -0
  17. core/db_connector/connectors/trino.py +247 -0
  18. core/db_connector/exporting/__init__.py +4 -0
  19. core/db_connector/exporting/artifact_store.py +79 -0
  20. core/db_connector/exporting/markdown.py +94 -0
  21. core/db_connector/exporting/models.py +20 -0
  22. core/db_connector/exporting/okf.py +41 -0
  23. core/db_connector/exporting/preformatters.py +60 -0
  24. core/db_connector/interface.py +78 -0
  25. core/db_connector/job_store.py +315 -0
  26. core/db_connector/manager.py +75 -0
  27. core/db_connector/models/__init__.py +7 -0
  28. core/db_connector/models/column.py +10 -0
  29. core/db_connector/models/instance.py +7 -0
  30. core/db_connector/models/scan_job.py +37 -0
  31. core/db_connector/models/schema.py +7 -0
  32. core/db_connector/models/table.py +8 -0
  33. core/db_connector/models/table_details.py +51 -0
  34. core/db_connector/sql_utils.py +28 -0
  35. core/db_connector/storage.py +325 -0
  36. core/setup.py +15 -0
  37. irides_core-0.1.0.dist-info/METADATA +254 -0
  38. irides_core-0.1.0.dist-info/RECORD +40 -0
  39. irides_core-0.1.0.dist-info/WHEEL +5 -0
  40. irides_core-0.1.0.dist-info/top_level.txt +1 -0
core/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Irides core library."""
2
+
core/basic_test.py ADDED
@@ -0,0 +1,78 @@
1
+ import sys
2
+
3
+ from core.db_connector.connectors.mysql import MySQLConnector
4
+ from core.db_connector.cache_manager import CacheManager
5
+ from core.db_connector.models import TableDescription
6
+ from loguru import logger
7
+
8
+ # Configuration for MySQL Test (ADJUST THESE VALUES)
9
+ MYSQL_CONFIG = {
10
+ "host": "<your_mysql_host>", # e.g., "localhost" or "mysql_test_container"
11
+ "user": "<your_mysql_user>", # e.g., "root"
12
+ "password": "<your_mysql_password>", # e.g., "password"
13
+ "port": 3306,
14
+ # "database": "test_db" # Optional, for specific database operations
15
+ }
16
+
17
+ def run_basic_mysql_test():
18
+ logger.info("--- Running Basic MySQL Test ---")
19
+ connector = None
20
+ try:
21
+ cache_manager = CacheManager()
22
+ connector = MySQLConnector(connection_params=MYSQL_CONFIG, cache_manager=cache_manager)
23
+ logger.info("MySQL connection successful!")
24
+
25
+ logger.info("\n--- Listing Instances ---")
26
+ instances = connector.list_instances()
27
+ for instance in instances:
28
+ logger.info(f"Instance: {instance.name} (Version: {instance.version})")
29
+
30
+ logger.info(f"\n--- Listing Schemas for Instance: {instance.name} ---")
31
+ schemas = connector.list_schemas(instance_name=instance.name)
32
+ for schema in schemas:
33
+ logger.info(f" Schema: {schema.name}")
34
+
35
+ logger.info(f"\n --- Listing Tables for Schema: {schema.name} ---")
36
+ tables = connector.list_tables(instance_name=instance.name, schema_name=schema.name)
37
+ if not tables:
38
+ logger.info(f" No tables found in schema: {schema.name}")
39
+ for table in tables:
40
+ logger.info(f" Table: {table.name}")
41
+
42
+ logger.info(f"\n --- Describing Table: {table.name} in Schema: {schema.name} ---")
43
+ table_desc: TableDescription = connector.describe_table(instance_name=instance.name, schema_name=schema.name, table_name=table.name)
44
+
45
+ logger.info(f" Columns:")
46
+ if not table_desc.columns:
47
+ logger.info(f" No columns found for table: {table.name}")
48
+ for col in table_desc.columns:
49
+ logger.info(f" - Name: {col.name}, Type: {col.data_type}, Nullable: {col.is_nullable}, Default: {col.default_value}")
50
+
51
+ if table_desc.primary_key:
52
+ logger.info(f" Primary Key: {', '.join(table_desc.primary_key.column_names)}")
53
+
54
+ if table_desc.foreign_keys:
55
+ logger.info(f" Foreign Keys:")
56
+ for fk in table_desc.foreign_keys:
57
+ logger.info(f" - Column: {fk.column_name} -> {fk.referenced_table}.{fk.referenced_column} (Constraint: {fk.constraint_name})")
58
+
59
+ if table_desc.indexes:
60
+ logger.info(f" Indexes:")
61
+ for idx in table_desc.indexes:
62
+ logger.info(f" - Name: {idx.name}, Columns: {', '.join(idx.column_names)}, Unique: {idx.is_unique}, Primary: {idx.is_primary}, Type: {idx.type}")
63
+
64
+ logger.info("-" * 40) # Separator for readability
65
+
66
+ except ConnectionError as e:
67
+ logger.error(f"MySQL connection failed: {e}")
68
+ logger.exception("Connection Error Traceback:")
69
+ sys.exit(1)
70
+ except Exception as e:
71
+ logger.error(f"An unexpected error occurred: {e}")
72
+ logger.exception("Unexpected Error Traceback:")
73
+ sys.exit(1)
74
+ finally:
75
+ pass
76
+
77
+ if __name__ == "__main__":
78
+ run_basic_mysql_test()
@@ -0,0 +1,23 @@
1
+ import sys
2
+ from core.db_connector.connectors.mysql import MySQLConnector
3
+ from core.db_connector.cache_manager import CacheManager
4
+ from loguru import logger
5
+
6
+ MYSQL_TEST_CONFIG = {
7
+ "host": "host.docker.internal",
8
+ "user": "root",
9
+ "password": "",
10
+ "port": 3306,
11
+ }
12
+
13
+ try:
14
+ logger.info("Attempting MySQL connection check...")
15
+ temp_conn = MySQLConnector(
16
+ connection_params=MYSQL_TEST_CONFIG,
17
+ cache_manager=CacheManager(),
18
+ )
19
+ logger.info("MySQL connection successful!")
20
+ except Exception as e:
21
+ logger.error(f"MySQL connection failed: {e}")
22
+ logger.exception("Connection Error Traceback:")
23
+ sys.exit(1) # Exit with error code if connection fails
@@ -0,0 +1,12 @@
1
+ from .manager import ConnectorManager
2
+ from .storage import BaseMetadataStore, FileMetadataStore, get_metadata_store
3
+ from .ai_service import AIDocumentationService
4
+
5
+ __all__ = [
6
+ "ConnectorManager",
7
+ "BaseMetadataStore",
8
+ "FileMetadataStore",
9
+ "get_metadata_store",
10
+ "AIDocumentationService",
11
+ ]
12
+
@@ -0,0 +1,133 @@
1
+ from datetime import datetime, timezone
2
+ import json
3
+ import os
4
+ import time
5
+ from typing import Dict, Any, Optional
6
+ from loguru import logger
7
+
8
+
9
+ class AIDocumentationService:
10
+ """Service to generate basic AI documentation for database table schemas using LiteLLM."""
11
+
12
+ def __init__(self, model_name: Optional[str] = None):
13
+ self.model_name = model_name or os.getenv("LITELLM_MODEL") or "gpt-4o-mini"
14
+ self.timeout = int(os.getenv("LITELLM_TIMEOUT_SECONDS", "120"))
15
+ self.max_retries = int(os.getenv("LITELLM_MAX_RETRIES", "2"))
16
+ self.last_error: Optional[str] = None
17
+
18
+ def generate_table_documentation(
19
+ self, table_description: Dict[str, Any]
20
+ ) -> Optional[Dict[str, Any]]:
21
+ """Generates AI documentation for a table description payload using LiteLLM.
22
+
23
+ This method is non-blocking: if LiteLLM is not installed, if API keys are missing,
24
+ or if any API/network error occurs, it catches the exception, logs a warning,
25
+ and returns None without throwing an exception.
26
+ """
27
+ table_name = table_description.get("table_name", "unknown")
28
+ schema_name = table_description.get("schema_name", "unknown")
29
+ instance_name = table_description.get("instance_name", "unknown")
30
+ columns = table_description.get("columns", [])
31
+ self.last_error = None
32
+
33
+ prompt = (
34
+ f"Analyse the following database table schema and generate a concise business documentation.\n"
35
+ f"Instance Name: {instance_name}\n"
36
+ f"Schema Name: {schema_name}\n"
37
+ f"Table Name: {table_name}\n"
38
+ f"Columns: {json.dumps(columns, default=str)}\n"
39
+ f"Primary Key: {table_description.get('primary_key', [])}\n"
40
+ f"Foreign Keys: {json.dumps(table_description.get('foreign_keys', []), default=str)}\n\n"
41
+ f"Return a valid JSON object with the following keys:\n"
42
+ f"- 'summary': A high-level sentence describing the purpose of the table.\n"
43
+ f"- 'column_descriptions': A map of column_name -> brief functional description.\n"
44
+ )
45
+
46
+ try:
47
+ import litellm
48
+ except ImportError:
49
+ self.last_error = "litellm package is not installed"
50
+ logger.warning(f"AIDocumentationService: {self.last_error}. Skipping AI doc generation.")
51
+ return None
52
+
53
+ try:
54
+ logger.info(
55
+ f"AIDocumentationService: Generating AI docs for table '{table_name}' using model '{self.model_name}'"
56
+ )
57
+
58
+ kwargs = {}
59
+ if os.getenv("LITELLM_API_KEY"):
60
+ kwargs["api_key"] = os.getenv("LITELLM_API_KEY")
61
+ if os.getenv("LITELLM_API_BASE"):
62
+ kwargs["api_base"] = os.getenv("LITELLM_API_BASE")
63
+
64
+ messages = [
65
+ {
66
+ "role": "system",
67
+ "content": "You are an expert Data Architect. Produce clean, structured schema documentation in valid JSON format.",
68
+ },
69
+ {"role": "user", "content": prompt},
70
+ ]
71
+
72
+ last_attempt_error: Optional[Exception] = None
73
+ response = None
74
+ for attempt in range(self.max_retries):
75
+ try:
76
+ response = litellm.completion(
77
+ model=self.model_name,
78
+ messages=messages,
79
+ response_format={"type": "json_object"},
80
+ timeout=self.timeout,
81
+ **kwargs,
82
+ )
83
+ break
84
+ except Exception as attempt_exc:
85
+ last_attempt_error = attempt_exc
86
+ logger.warning(
87
+ f"AIDocumentationService: attempt {attempt + 1}/{self.max_retries} failed for '{table_name}': {attempt_exc}"
88
+ )
89
+ if attempt < self.max_retries - 1:
90
+ time.sleep(2)
91
+
92
+ if response is None:
93
+ raise last_attempt_error
94
+
95
+
96
+ content = response.choices[0].message.content
97
+ if not content:
98
+ self.last_error = f"Empty response content from model '{self.model_name}'"
99
+ logger.warning(f"AIDocumentationService: {self.last_error} for table '{table_name}'")
100
+ return None
101
+
102
+ # Strip markdown code fences if present (e.g. ```json ... ```)
103
+ stripped = content.strip()
104
+ if stripped.startswith("```"):
105
+ stripped = stripped.split("\n", 1)[1] if "\n" in stripped else stripped[3:]
106
+ if stripped.endswith("```"):
107
+ stripped = stripped[:-3].strip()
108
+ else:
109
+ stripped = stripped
110
+
111
+ try:
112
+ parsed_doc = json.loads(stripped)
113
+ except json.JSONDecodeError:
114
+ self.last_error = f"Non-JSON response from model '{self.model_name}': {content!r:.200}"
115
+ logger.warning(f"AIDocumentationService: {self.last_error} for table '{table_name}'")
116
+ return None
117
+
118
+ logger.info(
119
+ f"AIDocumentationService: AI docs generated for table '{table_name}' using model '{self.model_name}'"
120
+ )
121
+
122
+ return {
123
+ "summary": parsed_doc.get("summary", ""),
124
+ "column_descriptions": parsed_doc.get("column_descriptions", {}),
125
+ "generated_at": datetime.now(timezone.utc).isoformat(),
126
+ "model": self.model_name,
127
+ }
128
+ except Exception as e:
129
+ self.last_error = str(e)
130
+ logger.warning(
131
+ f"AIDocumentationService: Failed or skipped AI doc generation for table '{table_name}': {e}"
132
+ )
133
+ return None
@@ -0,0 +1,69 @@
1
+ import redis
2
+ import json
3
+ from typing import Optional, Any
4
+ from loguru import logger
5
+ import os
6
+
7
+ class CacheManager:
8
+ def __init__(
9
+ self,
10
+ host: str = 'localhost',
11
+ port: int = 6379,
12
+ db: int = 0,
13
+ ttl_seconds: int = 86400,
14
+ project_prefix: Optional[str] = None,
15
+ socket_connect_timeout: float = 2.0,
16
+ socket_timeout: float = 2.0,
17
+ ):
18
+ self.redis_client = None
19
+ self.ttl_seconds = ttl_seconds # Default 1 day
20
+ self.project_prefix = project_prefix if project_prefix is not None else os.getenv("CACHE_KEY_PREFIX", "multi-db-connector")
21
+ try:
22
+ self.redis_client = redis.Redis(
23
+ host=host,
24
+ port=port,
25
+ db=db,
26
+ decode_responses=True,
27
+ socket_connect_timeout=socket_connect_timeout,
28
+ socket_timeout=socket_timeout,
29
+ health_check_interval=30,
30
+ )
31
+ self.redis_client.ping()
32
+ logger.info(f"Successfully connected to Redis at {host}:{port}/{db}")
33
+ except redis.exceptions.RedisError as e:
34
+ logger.error(f"Could not connect to Redis cache at {host}:{port}/{db}: {e}")
35
+ self.redis_client = None # Ensure client is None if connection fails
36
+
37
+ def _serialize(self, data: Any) -> str:
38
+ """Serializes data to JSON string."""
39
+ return json.dumps(data)
40
+
41
+ def _deserialize(self, data_str: str) -> Any:
42
+ """Deserializes JSON string to data."""
43
+ return json.loads(data_str)
44
+
45
+ def get_cached_data(self, key: str, no_cache: bool = False) -> Optional[Any]:
46
+ prefixed_key = f"{self.project_prefix}:{key}" # Add prefix
47
+ if not self.redis_client or no_cache:
48
+ return None
49
+ try:
50
+ cached_data = self.redis_client.get(prefixed_key)
51
+ if cached_data:
52
+ logger.debug(f"Cache hit for key: {prefixed_key}")
53
+ return self._deserialize(cached_data)
54
+ logger.debug(f"Cache miss for key: {prefixed_key}")
55
+ return None
56
+ except Exception as e:
57
+ logger.error(f"Error retrieving data from Redis for key {prefixed_key}: {e}")
58
+ return None
59
+
60
+ def set_cached_data(self, key: str, data: Any, ttl: Optional[int] = None):
61
+ prefixed_key = f"{self.project_prefix}:{key}" # Add prefix
62
+ if not self.redis_client:
63
+ return
64
+ try:
65
+ ttl_to_use = ttl if ttl is not None else self.ttl_seconds
66
+ self.redis_client.setex(prefixed_key, ttl_to_use, self._serialize(data))
67
+ logger.debug(f"Data set in cache for key: {prefixed_key} with TTL: {ttl_to_use}s")
68
+ except Exception as e:
69
+ logger.error(f"Error setting data in Redis for key {prefixed_key}: {e}")
@@ -0,0 +1,116 @@
1
+ from typing import Dict, Any, List, Optional
2
+ from loguru import logger
3
+
4
+ from core.db_connector.manager import ConnectorManager
5
+ from core.db_connector.models import Instance
6
+ from core.db_connector.configurations import get_db_configurations
7
+
8
+
9
+ class ConfigService:
10
+ def __init__(self, connector_manager: ConnectorManager, config_file: Optional[str] = None):
11
+ self.connector_manager = connector_manager
12
+ self.db_configurations: Dict[str, Dict[str, Any]] = get_db_configurations(config_file=config_file)
13
+ logger.info(f"ConfigService: Initialized with {len(self.db_configurations)} database configurations.")
14
+
15
+ def get_available_configurations(self) -> List[str]:
16
+ logger.info("ConfigService: Fetching available configurations.")
17
+ return list(self.db_configurations.keys())
18
+
19
+ def _get_connector_details(self, config_name: str) -> Dict[str, Any]:
20
+ config = self.db_configurations.get(config_name)
21
+ if not config:
22
+ logger.error(f"ConfigService: Configuration name '{config_name}' not found.")
23
+ raise ValueError(f"Configuration name '{config_name}' not found.")
24
+
25
+ connector_type = config.get("connector_type")
26
+ connection_params = config.get("connection_params")
27
+
28
+ if not connector_type or not connection_params:
29
+ raise ValueError(
30
+ f"ConfigService: Configuration '{config_name}' is missing "
31
+ "'connector_type' or 'connection_params'."
32
+ )
33
+ return {"connector_type": connector_type, "connection_params": connection_params}
34
+
35
+ def _get_hosts(self, config_name: str) -> List[str]:
36
+ """Return explicitly configured connection hosts, when present.
37
+
38
+ Configurations may use either a multi-host ``hosts`` collection or a
39
+ flat ``host`` connection parameter. Connectors such as Athena and
40
+ DynamoDB discover instances remotely and expose neither.
41
+ """
42
+ details = self._get_connector_details(config_name)
43
+ connection_params = details["connection_params"]
44
+ hosts = [item["host"] for item in connection_params.get("hosts", [])]
45
+ if hosts:
46
+ return hosts
47
+ return [connection_params["host"]] if connection_params.get("host") else []
48
+
49
+ def configuration_matches_instance(
50
+ self, config_name: str, instance_name: str, no_cache: bool = False
51
+ ) -> bool:
52
+ """Return whether an instance belongs to a database configuration."""
53
+ configured_hosts = self._get_hosts(config_name)
54
+ if configured_hosts:
55
+ return instance_name in configured_hosts
56
+ return instance_name in {
57
+ instance.name for instance in self.list_instances(config_name, no_cache=no_cache)
58
+ }
59
+
60
+ def resolve_configurations_for_instance(
61
+ self, instance_name: str, no_cache: bool = False
62
+ ) -> List[str]:
63
+ return [
64
+ config_name
65
+ for config_name in self.get_available_configurations()
66
+ if self.configuration_matches_instance(config_name, instance_name, no_cache)
67
+ ]
68
+
69
+ def _get_connector_for_host(self, config_name: str, host: str):
70
+ details = self._get_connector_details(config_name)
71
+ host_params = next(
72
+ (h for h in details["connection_params"].get("hosts", []) if h["host"] == host),
73
+ None,
74
+ )
75
+ if host_params:
76
+ return self.connector_manager.get_connector(details["connector_type"], host_params)
77
+
78
+ # Flat connection parameters identify one connection, while ``host`` is
79
+ # an instance/catalog/region discovered through that connection.
80
+ if not details["connection_params"].get("hosts"):
81
+ return self.connector_manager.get_connector(
82
+ details["connector_type"], details["connection_params"]
83
+ )
84
+ raise ValueError(f"Host '{host}' not found in config '{config_name}'")
85
+
86
+ def list_instances(self, config_name: str, no_cache: bool = False) -> List[Instance]:
87
+ """List instances for both multi-host and flat connector configurations."""
88
+ configured_hosts = self._get_hosts(config_name)
89
+ if configured_hosts:
90
+ instances: List[Instance] = []
91
+ for host in configured_hosts:
92
+ connector = self._get_connector_for_host(config_name, host)
93
+ instances.extend(connector.list_instances(no_cache=no_cache))
94
+ return instances
95
+
96
+ details = self._get_connector_details(config_name)
97
+ connector = self.connector_manager.get_connector(
98
+ details["connector_type"], details["connection_params"]
99
+ )
100
+ return connector.list_instances(no_cache=no_cache)
101
+
102
+ def resolve_instance_names(
103
+ self, config_name: str, instance_name: Optional[str] = None, no_cache: bool = False
104
+ ) -> List[str]:
105
+ if instance_name:
106
+ return [instance_name]
107
+ return [instance.name for instance in self.list_instances(config_name, no_cache)]
108
+
109
+ def test_connection(self, config_name: str):
110
+ logger.info(f"ConfigService: Attempting to test connection for config: {config_name}")
111
+ for instance in self.list_instances(config_name, no_cache=True):
112
+ logger.info(
113
+ f"ConfigService: Successfully tested connection to {instance.name} "
114
+ f"for config: {config_name}"
115
+ )
116
+ return {"message": f"Successfully connected to all hosts in {config_name}."}