irides-cli 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.
- irides_cli/__init__.py +2 -0
- irides_cli/controllers/__init__.py +2 -0
- irides_cli/controllers/init_controller.py +78 -0
- irides_cli/controllers/introspection_controller.py +34 -0
- irides_cli/controllers/metadata_controller.py +23 -0
- irides_cli/dto/__init__.py +2 -0
- irides_cli/dto/requests.py +43 -0
- irides_cli/main.py +62 -0
- irides_cli/presentation/__init__.py +2 -0
- irides_cli/presentation/parser.py +47 -0
- irides_cli/services/__init__.py +2 -0
- irides_cli/services/introspection_service.py +94 -0
- irides_cli/services/metadata_service.py +27 -0
- irides_cli-0.1.0.dist-info/METADATA +150 -0
- irides_cli-0.1.0.dist-info/RECORD +18 -0
- irides_cli-0.1.0.dist-info/WHEEL +5 -0
- irides_cli-0.1.0.dist-info/entry_points.txt +2 -0
- irides_cli-0.1.0.dist-info/top_level.txt +1 -0
irides_cli/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Controller for initializing configuration templates."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SAMPLE_YAML = """# irides.yaml - Irides database configuration file
|
|
9
|
+
# Learn more: https://github.com/GianAndreaSechi/irides
|
|
10
|
+
|
|
11
|
+
targets:
|
|
12
|
+
# SQLite example (no server needed)
|
|
13
|
+
# local_sqlite:
|
|
14
|
+
# type: sqlite
|
|
15
|
+
# database: ./data.db
|
|
16
|
+
|
|
17
|
+
# PostgreSQL example
|
|
18
|
+
# my_postgres:
|
|
19
|
+
# type: postgres
|
|
20
|
+
# host: localhost
|
|
21
|
+
# port: 5432
|
|
22
|
+
# user: postgres
|
|
23
|
+
# password: "${PG_PASSWORD:-postgres}"
|
|
24
|
+
# database: my_database
|
|
25
|
+
|
|
26
|
+
# MySQL example
|
|
27
|
+
# my_mysql:
|
|
28
|
+
# type: mysql
|
|
29
|
+
# host: localhost
|
|
30
|
+
# port: 3306
|
|
31
|
+
# user: root
|
|
32
|
+
# password: "${MYSQL_PASSWORD}"
|
|
33
|
+
|
|
34
|
+
# MongoDB example
|
|
35
|
+
# my_mongo:
|
|
36
|
+
# type: mongodb
|
|
37
|
+
# host: localhost
|
|
38
|
+
# port: 27017
|
|
39
|
+
# username: admin
|
|
40
|
+
# password: "${MONGO_PASSWORD}"
|
|
41
|
+
# authSource: admin
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
SAMPLE_ENV = """# .env - Irides environment configuration
|
|
45
|
+
# Learn more: https://github.com/GianAndreaSechi/irides
|
|
46
|
+
|
|
47
|
+
DB_TARGETS=sample_pg
|
|
48
|
+
|
|
49
|
+
# PostgreSQL sample target
|
|
50
|
+
DB_TARGET_SAMPLE_PG_TYPE=postgres
|
|
51
|
+
DB_TARGET_SAMPLE_PG_HOST=localhost
|
|
52
|
+
DB_TARGET_SAMPLE_PG_PORT=5432
|
|
53
|
+
DB_TARGET_SAMPLE_PG_USER=postgres
|
|
54
|
+
DB_TARGET_SAMPLE_PG_PASSWORD=secret
|
|
55
|
+
DB_TARGET_SAMPLE_PG_DATABASE=my_database
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InitController:
|
|
60
|
+
"""Handles the 'init' CLI command."""
|
|
61
|
+
|
|
62
|
+
def execute(self, fmt: str = "yaml", force: bool = False) -> Dict[str, Any]:
|
|
63
|
+
target_filename = "irides.yaml" if fmt == "yaml" else ".env"
|
|
64
|
+
target_path = Path(target_filename)
|
|
65
|
+
|
|
66
|
+
if target_path.exists() and not force:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"'{target_filename}' already exists. Use --force to overwrite."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
content = SAMPLE_YAML if fmt == "yaml" else SAMPLE_ENV
|
|
72
|
+
target_path.write_text(content, encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"status": "created",
|
|
76
|
+
"file": target_filename,
|
|
77
|
+
"message": f"Template created at '{target_filename}'. Edit your database credentials and run 'irides configurations'.",
|
|
78
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Controller that maps parsed CLI arguments into introspection DTOs."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from irides_cli.dto.requests import DescribeRequest, ScopeRequest, TablesRequest
|
|
7
|
+
from irides_cli.services.introspection_service import IntrospectionService
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IntrospectionController:
|
|
11
|
+
def __init__(self, service: IntrospectionService | None = None, config_file: str | None = None) -> None:
|
|
12
|
+
self.service = service or IntrospectionService(config_file=config_file)
|
|
13
|
+
|
|
14
|
+
def execute(self, args: argparse.Namespace) -> Any:
|
|
15
|
+
if args.command == "configurations": return self.service.configurations()
|
|
16
|
+
if args.command == "connect": return self.service.connect(args.config_name)
|
|
17
|
+
if args.command == "instances": return self.service.instances(ScopeRequest(config_name=args.config_name, no_cache=args.no_cache))
|
|
18
|
+
if args.command == "schemas": return self.service.schemas(ScopeRequest(args.config_name, args.instance_name, args.no_cache))
|
|
19
|
+
if args.command == "tables": return self.service.tables(TablesRequest(args.config_name, args.instance_name, args.no_cache, args.schema_name, args.limit, args.offset))
|
|
20
|
+
return self.service.describe(
|
|
21
|
+
DescribeRequest(
|
|
22
|
+
config_name=args.config_name,
|
|
23
|
+
instance_name=args.instance_name,
|
|
24
|
+
no_cache=args.no_cache,
|
|
25
|
+
schema_name=args.schema_name,
|
|
26
|
+
table_name=args.table_name,
|
|
27
|
+
generate_ai_docs=args.generate_ai_docs,
|
|
28
|
+
save_metadata=args.save_metadata,
|
|
29
|
+
only_if_changed=args.only_if_changed,
|
|
30
|
+
export_markdown=args.export_markdown,
|
|
31
|
+
export_okf=args.export_okf,
|
|
32
|
+
preformat=args.preformat,
|
|
33
|
+
)
|
|
34
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Controller for metadata commands and payload validation."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from irides_cli.dto.requests import MetadataUpdateRequest, PageRequest
|
|
8
|
+
from irides_cli.services.metadata_service import MetadataService
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MetadataController:
|
|
12
|
+
def __init__(self, service: MetadataService | None = None) -> None:
|
|
13
|
+
self.service = service or MetadataService()
|
|
14
|
+
|
|
15
|
+
def execute(self, args: argparse.Namespace) -> Any:
|
|
16
|
+
page = PageRequest(args.page, args.page_size) if hasattr(args, "page") else None
|
|
17
|
+
if args.metadata_command == "instances": return self.service.instances(page)
|
|
18
|
+
if args.metadata_command == "databases": return self.service.databases(args.instance, page)
|
|
19
|
+
if args.metadata_command == "tables": return self.service.tables(args.instance, args.database, page)
|
|
20
|
+
if args.metadata_command == "get": return self.service.get(args.instance, args.database, args.table)
|
|
21
|
+
payload = json.loads(args.payload)
|
|
22
|
+
if not isinstance(payload, dict): raise ValueError("Metadata payload must be a JSON object.")
|
|
23
|
+
return self.service.update(MetadataUpdateRequest(args.instance, args.database, args.table, payload))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Typed command inputs, independent from argparse and core models."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class ScopeRequest:
|
|
9
|
+
config_name: Optional[str] = None
|
|
10
|
+
instance_name: Optional[str] = None
|
|
11
|
+
no_cache: bool = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class TablesRequest(ScopeRequest):
|
|
16
|
+
schema_name: Optional[str] = None
|
|
17
|
+
limit: Optional[int] = None
|
|
18
|
+
offset: Optional[int] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class DescribeRequest(TablesRequest):
|
|
23
|
+
table_name: Optional[str] = None
|
|
24
|
+
generate_ai_docs: bool = False
|
|
25
|
+
save_metadata: bool = True
|
|
26
|
+
only_if_changed: bool = False
|
|
27
|
+
export_markdown: bool = True
|
|
28
|
+
export_okf: bool = True
|
|
29
|
+
preformat: bool = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class PageRequest:
|
|
34
|
+
page: int = 1
|
|
35
|
+
page_size: int = 20
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class MetadataUpdateRequest:
|
|
40
|
+
instance_name: str
|
|
41
|
+
database_name: str
|
|
42
|
+
table_name: str
|
|
43
|
+
payload: Dict[str, Any]
|
irides_cli/main.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""CLI composition root and process-level error handling."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any, List, Optional
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
|
|
8
|
+
from irides_cli.controllers.init_controller import InitController
|
|
9
|
+
from irides_cli.controllers.introspection_controller import IntrospectionController
|
|
10
|
+
from irides_cli.controllers.metadata_controller import MetadataController
|
|
11
|
+
from irides_cli.presentation.parser import build_parser
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _json_default(value: Any) -> Any:
|
|
15
|
+
if hasattr(value, "model_dump"): return value.model_dump(mode="json")
|
|
16
|
+
if hasattr(value, "dict"): return value.dict()
|
|
17
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def print_json(data: Any) -> None:
|
|
21
|
+
print(json.dumps(data, default=_json_default, ensure_ascii=False, indent=2))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run(args: Any) -> Any:
|
|
25
|
+
if args.command == "init":
|
|
26
|
+
return InitController().execute(fmt=args.format, force=args.force)
|
|
27
|
+
if args.command == "metadata":
|
|
28
|
+
return MetadataController().execute(args)
|
|
29
|
+
|
|
30
|
+
config_file = getattr(args, "config_file", None)
|
|
31
|
+
controller = IntrospectionController(config_file=config_file)
|
|
32
|
+
result = controller.execute(args)
|
|
33
|
+
if args.command == "configurations" and isinstance(result, list) and len(result) == 0:
|
|
34
|
+
print(
|
|
35
|
+
"irides: notice: no database targets configured. Run 'irides init' to generate a configuration template or check your .env.",
|
|
36
|
+
file=sys.stderr,
|
|
37
|
+
)
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
42
|
+
args = build_parser().parse_args(argv)
|
|
43
|
+
|
|
44
|
+
env_file = getattr(args, "env_file", None)
|
|
45
|
+
if env_file:
|
|
46
|
+
load_dotenv(dotenv_path=env_file, override=True)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
result = run(args)
|
|
50
|
+
if result is None: raise ValueError("No metadata found.")
|
|
51
|
+
print_json(result)
|
|
52
|
+
return 0
|
|
53
|
+
except (ConnectionError, ValueError, json.JSONDecodeError) as error:
|
|
54
|
+
print(f"irides: {error}", file=sys.stderr)
|
|
55
|
+
return 1
|
|
56
|
+
except Exception as error:
|
|
57
|
+
print(f"irides: unexpected error: {error}", file=sys.stderr)
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Argparse command definitions; no business logic belongs here."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _add_scope_arguments(parser: argparse.ArgumentParser, *, table: bool = False) -> None:
|
|
7
|
+
parser.add_argument("-t", "--target", "--config", dest="config_name", help="Configured database target name")
|
|
8
|
+
parser.add_argument("--instance", dest="instance_name", help="Database instance")
|
|
9
|
+
if table: parser.add_argument("--schema", dest="schema_name", help="Schema/database name")
|
|
10
|
+
parser.add_argument("--no-cache", action="store_true", help="Bypass the Redis cache")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(prog="irides", description="Inspect databases through Irides core, without the API service.")
|
|
15
|
+
parser.add_argument("-e", "--env-file", dest="env_file", help="Path to .env file to load variables from")
|
|
16
|
+
parser.add_argument("-c", "--config-file", dest="config_file", help="Path to YAML/JSON configuration file (e.g. irides.yaml)")
|
|
17
|
+
|
|
18
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
|
|
20
|
+
init_cmd = subparsers.add_parser("init", help="Initialize a template configuration file (irides.yaml or .env)")
|
|
21
|
+
init_cmd.add_argument("-f", "--format", choices=["yaml", "env"], default="yaml", help="Config format: yaml (default) or env")
|
|
22
|
+
init_cmd.add_argument("--force", action="store_true", help="Overwrite existing configuration file")
|
|
23
|
+
|
|
24
|
+
subparsers.add_parser("configurations", help="List active configurations")
|
|
25
|
+
connect = subparsers.add_parser("connect", help="Test a configuration")
|
|
26
|
+
connect.add_argument("config_name", metavar="TARGET", help="Database target name to connect to")
|
|
27
|
+
instances = subparsers.add_parser("instances", help="List instances")
|
|
28
|
+
instances.add_argument("-t", "--target", "--config", dest="config_name", help="Configured database target name")
|
|
29
|
+
instances.add_argument("--no-cache", action="store_true")
|
|
30
|
+
schemas = subparsers.add_parser("schemas", help="List schemas"); _add_scope_arguments(schemas)
|
|
31
|
+
tables = subparsers.add_parser("tables", help="List tables"); _add_scope_arguments(tables, table=True); tables.add_argument("--limit", type=int); tables.add_argument("--offset", type=int)
|
|
32
|
+
describe = subparsers.add_parser("describe", help="Describe tables and optionally save metadata")
|
|
33
|
+
_add_scope_arguments(describe, table=True); describe.add_argument("--table", dest="table_name")
|
|
34
|
+
describe.add_argument("--generate-ai-docs", action="store_true"); describe.add_argument("--no-save-metadata", dest="save_metadata", action="store_false", default=True); describe.add_argument("--only-if-changed", action="store_true")
|
|
35
|
+
describe.add_argument("--no-export-markdown", dest="export_markdown", action="store_false", default=True, help="Do not generate the default Markdown export")
|
|
36
|
+
describe.add_argument("--save-markdown", dest="export_markdown", action="store_true", help=argparse.SUPPRESS)
|
|
37
|
+
describe.add_argument("--no-export-okf", dest="export_okf", action="store_false", default=True, help="Do not generate the default OKF bundle")
|
|
38
|
+
describe.add_argument("--no-preformat", dest="preformat", action="store_false", default=True, help="Export the full metadata instead of the essential view")
|
|
39
|
+
metadata = subparsers.add_parser("metadata", help="Read or update stored metadata"); metadata_sub = metadata.add_subparsers(dest="metadata_command", required=True)
|
|
40
|
+
for name in ("instances", "databases", "tables"):
|
|
41
|
+
command = metadata_sub.add_parser(name)
|
|
42
|
+
if name != "instances": command.add_argument("instance")
|
|
43
|
+
if name == "tables": command.add_argument("database")
|
|
44
|
+
command.add_argument("--page", type=int, default=1); command.add_argument("--page-size", type=int, default=20)
|
|
45
|
+
get = metadata_sub.add_parser("get"); get.add_argument("instance"); get.add_argument("database"); get.add_argument("table")
|
|
46
|
+
update = metadata_sub.add_parser("update"); update.add_argument("instance"); update.add_argument("database"); update.add_argument("table"); update.add_argument("payload", help="JSON object to merge into the metadata")
|
|
47
|
+
return parser
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Core-backed synchronous database introspection use cases."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Dict, Iterable, List
|
|
5
|
+
|
|
6
|
+
from core.db_connector.ai_service import AIDocumentationService
|
|
7
|
+
from core.db_connector.cache_manager import CacheManager
|
|
8
|
+
from core.db_connector.config_service import ConfigService
|
|
9
|
+
from core.db_connector.manager import ConnectorManager
|
|
10
|
+
from core.db_connector.models import Schema, Table
|
|
11
|
+
from core.db_connector.storage import get_metadata_store
|
|
12
|
+
from core.db_connector.exporting import ExportFormat, ExportOptions
|
|
13
|
+
|
|
14
|
+
from irides_cli.dto.requests import DescribeRequest, ScopeRequest, TablesRequest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IntrospectionService:
|
|
18
|
+
"""Runs live introspection without any dependency on API-layer code."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, config_service: ConfigService | None = None, config_file: str | None = None) -> None:
|
|
21
|
+
if config_service is None:
|
|
22
|
+
cache = CacheManager(
|
|
23
|
+
host=os.getenv("REDIS_HOST", "localhost"),
|
|
24
|
+
port=int(os.getenv("REDIS_PORT", "6379")),
|
|
25
|
+
db=int(os.getenv("REDIS_DB", "0")),
|
|
26
|
+
ttl_seconds=int(os.getenv("REDIS_TTL_SECONDS", "86400")),
|
|
27
|
+
socket_connect_timeout=float(os.getenv("REDIS_SOCKET_CONNECT_TIMEOUT_SECONDS", "2")),
|
|
28
|
+
socket_timeout=float(os.getenv("REDIS_CACHE_SOCKET_TIMEOUT_SECONDS", "2")),
|
|
29
|
+
)
|
|
30
|
+
config_service = ConfigService(ConnectorManager(cache), config_file=config_file)
|
|
31
|
+
self.config_service = config_service
|
|
32
|
+
|
|
33
|
+
def configurations(self) -> List[str]:
|
|
34
|
+
return self.config_service.get_available_configurations()
|
|
35
|
+
|
|
36
|
+
def connect(self, config_name: str) -> Dict[str, Any]:
|
|
37
|
+
return self.config_service.test_connection(config_name)
|
|
38
|
+
|
|
39
|
+
def instances(self, request: ScopeRequest) -> List[Any]:
|
|
40
|
+
names = [request.config_name] if request.config_name else self.configurations()
|
|
41
|
+
return [instance for name in names for instance in self.config_service.list_instances(name, no_cache=request.no_cache)]
|
|
42
|
+
|
|
43
|
+
def _scope(self, request: ScopeRequest) -> Iterable[tuple[str, str, Any]]:
|
|
44
|
+
configs = [request.config_name] if request.config_name else self.configurations()
|
|
45
|
+
for config in configs:
|
|
46
|
+
for instance in self.config_service.resolve_instance_names(config, request.instance_name, request.no_cache):
|
|
47
|
+
yield config, instance, self.config_service._get_connector_for_host(config, instance)
|
|
48
|
+
|
|
49
|
+
def schemas(self, request: ScopeRequest) -> List[Any]:
|
|
50
|
+
return [schema for _, instance, connector in self._scope(request) for schema in connector.list_schemas(instance_name=instance, no_cache=request.no_cache)]
|
|
51
|
+
|
|
52
|
+
def tables(self, request: TablesRequest) -> List[Any]:
|
|
53
|
+
found = []
|
|
54
|
+
for _, instance, connector in self._scope(request):
|
|
55
|
+
schemas = [Schema(name=request.schema_name)] if request.schema_name else connector.list_schemas(instance_name=instance, no_cache=request.no_cache)
|
|
56
|
+
for schema in schemas:
|
|
57
|
+
found.extend(connector.list_tables(instance_name=instance, schema_name=schema.name, limit=request.limit, offset=request.offset, no_cache=request.no_cache))
|
|
58
|
+
return found
|
|
59
|
+
|
|
60
|
+
def describe(self, request: DescribeRequest) -> List[Any]:
|
|
61
|
+
results = []
|
|
62
|
+
formats = []
|
|
63
|
+
if request.export_markdown:
|
|
64
|
+
formats.append(ExportFormat.MARKDOWN)
|
|
65
|
+
if request.export_okf:
|
|
66
|
+
formats.append(ExportFormat.OKF)
|
|
67
|
+
export_options = ExportOptions(formats=formats, preformat=request.preformat)
|
|
68
|
+
store = get_metadata_store() if request.save_metadata or formats else None
|
|
69
|
+
ai_service = AIDocumentationService() if request.generate_ai_docs else None
|
|
70
|
+
for config, instance, connector in self._scope(request):
|
|
71
|
+
schemas = [Schema(name=request.schema_name)] if request.schema_name else connector.list_schemas(instance_name=instance, no_cache=request.no_cache)
|
|
72
|
+
for schema in schemas:
|
|
73
|
+
tables = [Table(name=request.table_name, schema_name=schema.name)] if request.table_name else connector.list_tables(instance_name=instance, schema_name=schema.name, no_cache=request.no_cache)
|
|
74
|
+
for table in tables:
|
|
75
|
+
description = connector.describe_table(instance_name=instance, schema_name=schema.name, table_name=table.name, no_cache=request.no_cache)
|
|
76
|
+
schema_description = description.model_dump(exclude={"ai_documentation", "ai_generation_status", "ai_generation_error"})
|
|
77
|
+
ai_documentation = None
|
|
78
|
+
if ai_service:
|
|
79
|
+
ai_documentation = ai_service.generate_table_documentation(schema_description)
|
|
80
|
+
description = description.model_copy(update={"ai_documentation": ai_documentation, "ai_generation_status": "generated" if ai_documentation else "failed", "ai_generation_error": None if ai_documentation else ai_service.last_error})
|
|
81
|
+
results.append(description)
|
|
82
|
+
if store:
|
|
83
|
+
store.save_table_metadata(
|
|
84
|
+
config,
|
|
85
|
+
instance,
|
|
86
|
+
schema.name,
|
|
87
|
+
table.name,
|
|
88
|
+
schema_description,
|
|
89
|
+
ai_documentation,
|
|
90
|
+
only_if_changed=request.only_if_changed,
|
|
91
|
+
export_options=export_options,
|
|
92
|
+
save_metadata=request.save_metadata,
|
|
93
|
+
)
|
|
94
|
+
return results
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Operations over metadata persisted by core."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from core.db_connector.storage import BaseMetadataStore, get_metadata_store
|
|
6
|
+
|
|
7
|
+
from irides_cli.dto.requests import MetadataUpdateRequest, PageRequest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MetadataService:
|
|
11
|
+
def __init__(self, store: Optional[BaseMetadataStore] = None) -> None:
|
|
12
|
+
self.store = store or get_metadata_store()
|
|
13
|
+
|
|
14
|
+
def instances(self, page: PageRequest) -> Dict[str, Any]:
|
|
15
|
+
return self.store.list_instances(page.page, page.page_size)
|
|
16
|
+
|
|
17
|
+
def databases(self, instance_name: str, page: PageRequest) -> Dict[str, Any]:
|
|
18
|
+
return self.store.list_databases(instance_name, page.page, page.page_size)
|
|
19
|
+
|
|
20
|
+
def tables(self, instance_name: str, database_name: str, page: PageRequest) -> Dict[str, Any]:
|
|
21
|
+
return self.store.list_tables_metadata(instance_name, database_name, page.page, page.page_size)
|
|
22
|
+
|
|
23
|
+
def get(self, instance_name: str, database_name: str, table_name: str) -> Optional[Dict[str, Any]]:
|
|
24
|
+
return self.store.find_table_metadata(instance_name, database_name, table_name)
|
|
25
|
+
|
|
26
|
+
def update(self, request: MetadataUpdateRequest) -> Optional[Dict[str, Any]]:
|
|
27
|
+
return self.store.update_table_metadata(request.instance_name, request.database_name, request.table_name, request.payload)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: irides-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A command-line interface for discovering, inspecting, and managing structured database context for AI systems.
|
|
5
|
+
Author-email: Gian Andrea Sechi <me@gianandreasechi.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Source Code, https://github.com/GianAndreaSechi/irides/cli
|
|
8
|
+
Project-URL: Homepage, https://www.gianandreasechi.com
|
|
9
|
+
Keywords: database,introspection,cli,irides,schema
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Database
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: irides-core>=0.1.0
|
|
24
|
+
|
|
25
|
+
# Iride CLI
|
|
26
|
+
|
|
27
|
+
Command-line interface for database introspection, built only on the `core` package and Python's standard library. It does not require FastAPI, MCP, or the Redis worker.
|
|
28
|
+
|
|
29
|
+
## Structure
|
|
30
|
+
|
|
31
|
+
The CLI is organized by responsibility:
|
|
32
|
+
|
|
33
|
+
- `irides_cli/presentation/`: command definitions and `argparse` parsing;
|
|
34
|
+
- `irides_cli/controllers/`: maps command-line arguments to use cases;
|
|
35
|
+
- `irides_cli/dto/`: immutable, typed request DTOs;
|
|
36
|
+
- `irides_cli/services/`: live introspection and metadata operations using only `core`;
|
|
37
|
+
- `irides_cli/main.py`: composition root, JSON serialization, and process-level error handling.
|
|
38
|
+
|
|
39
|
+
## Local installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install -e ./core -e ./cli
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quickstart & Configuration
|
|
46
|
+
|
|
47
|
+
Irides supports two ways to configure database targets:
|
|
48
|
+
|
|
49
|
+
### Option 1: Declarative Config File (`irides.yaml`) — Recommended
|
|
50
|
+
Generate a template configuration with:
|
|
51
|
+
```bash
|
|
52
|
+
irides init
|
|
53
|
+
```
|
|
54
|
+
This creates an `irides.yaml` file in the current directory:
|
|
55
|
+
```yaml
|
|
56
|
+
targets:
|
|
57
|
+
my_postgres:
|
|
58
|
+
type: postgres
|
|
59
|
+
host: localhost
|
|
60
|
+
port: 5432
|
|
61
|
+
user: postgres
|
|
62
|
+
password: "${PG_PASSWORD:-secret}"
|
|
63
|
+
database: my_database
|
|
64
|
+
|
|
65
|
+
local_sqlite:
|
|
66
|
+
type: sqlite
|
|
67
|
+
database: ./app.db
|
|
68
|
+
```
|
|
69
|
+
You can pass a custom config file anytime using `-c` / `--config-file`:
|
|
70
|
+
```bash
|
|
71
|
+
irides -c /path/to/my_config.yaml configurations
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Option 2: Environment Variables (`.env`)
|
|
75
|
+
You can initialize an environment template with:
|
|
76
|
+
```bash
|
|
77
|
+
irides init --format env
|
|
78
|
+
```
|
|
79
|
+
Or specify an explicit `.env` file via `-e` / `--env-file`:
|
|
80
|
+
```bash
|
|
81
|
+
irides -e /path/to/.env configurations
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Commands
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# Initialize template configuration
|
|
88
|
+
irides init
|
|
89
|
+
|
|
90
|
+
# List configured database targets
|
|
91
|
+
irides configurations
|
|
92
|
+
|
|
93
|
+
# Test database connection
|
|
94
|
+
irides connect my_postgres
|
|
95
|
+
|
|
96
|
+
# List database instances
|
|
97
|
+
irides instances --target my_postgres
|
|
98
|
+
|
|
99
|
+
# List schemas in a database
|
|
100
|
+
irides schemas --target my_postgres --instance localhost
|
|
101
|
+
|
|
102
|
+
# List tables (supports --limit and --offset)
|
|
103
|
+
irides tables --target my_postgres --instance localhost --schema public --limit 50
|
|
104
|
+
|
|
105
|
+
# Introspect table schema
|
|
106
|
+
irides describe --target my_postgres --instance localhost --schema public --table orders
|
|
107
|
+
irides describe --target my_postgres --instance localhost --schema public --table orders --generate-ai-docs
|
|
108
|
+
irides describe --target my_postgres --instance localhost --schema public --table orders --no-export-okf
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
> **Note**: `--target` and `--config` are interchangeable aliases.
|
|
112
|
+
Omitting `--target`, `--instance`, `--schema`, or `--table` expands the scope, just like the corresponding API endpoints. Results are always written as JSON to stdout; errors are written to stderr. Add `--no-cache` to introspection commands to bypass Redis.
|
|
113
|
+
|
|
114
|
+
`describe` saves canonical JSON metadata and generates both **Markdown** and **Open Knowledge Format (OKF v0.2)** exports by default.
|
|
115
|
+
|
|
116
|
+
Available options for `describe`:
|
|
117
|
+
- `--generate-ai-docs`: generate domain summary and column descriptions via LiteLLM.
|
|
118
|
+
- `--no-save-metadata`: skip saving the canonical JSON metadata file (exports are still generated if enabled).
|
|
119
|
+
- `--only-if-changed`: skip writing if the schema is identical to the stored version.
|
|
120
|
+
- `--no-export-markdown`: disable the default Markdown export.
|
|
121
|
+
- `--no-export-okf`: disable the default OKF catalog bundle generation.
|
|
122
|
+
- `--no-preformat`: export full metadata instead of the essential deterministic record.
|
|
123
|
+
- `--save-markdown`: legacy compatibility flag for Markdown export.
|
|
124
|
+
|
|
125
|
+
Artifacts are persisted under `STORAGE_EXPORT_DIR` (default `storage/exports`):
|
|
126
|
+
- `storage/exports/markdown/{config}/{instance}/{schema}/{table}.md`
|
|
127
|
+
- `storage/exports/okf/catalog/{config}/{instance}/{schema}/{table}.md` (along with `storage/exports/okf/catalog/index.md`)
|
|
128
|
+
|
|
129
|
+
## Metadata
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
irides metadata instances
|
|
133
|
+
irides metadata databases db1.company.com
|
|
134
|
+
irides metadata tables db1.company.com production
|
|
135
|
+
irides metadata get db1.company.com production orders
|
|
136
|
+
irides metadata update db1.company.com production orders '{"owner":"data-team","tags":["billing"]}'
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The CLI does not include `scan` commands. They are asynchronous and explicitly require Redis Streams and the `worker` service.
|
|
140
|
+
|
|
141
|
+
## Docker
|
|
142
|
+
|
|
143
|
+
The CLI reads its configuration from `cli/.env` and uses the shared Redis network. Start by copying `.env.example` as shown above and configure at least one `DB_TARGETS` entry.
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
docker compose -f infra/docker-compose.infra.yml up -d
|
|
147
|
+
docker compose -f cli/docker-compose.yml run --rm irides-cli configurations
|
|
148
|
+
docker compose -f cli/docker-compose.yml run --rm irides-cli tables --config sales_mysql --schema public
|
|
149
|
+
docker compose -f cli/docker-compose.yml run --rm irides-cli describe --config sales_mysql --schema public --table orders
|
|
150
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
irides_cli/__init__.py,sha256=OzedwW-bb5L50Ff05PzSjgNq3A_pUS2E8E3hyaMGIIo,70
|
|
2
|
+
irides_cli/main.py,sha256=56uKy15A6BChieCuwW-OgpBIVs1OFyT-QygBaP81qlw,2166
|
|
3
|
+
irides_cli/controllers/__init__.py,sha256=EXPBbRAM1YwOccuwClNaExwSpW9q-0jf1YW9NRDzG-I,74
|
|
4
|
+
irides_cli/controllers/init_controller.py,sha256=CjzIFqq4vNRkeKyVUQjBGMXmlvpkWHjG5iLP49ZrkF4,2043
|
|
5
|
+
irides_cli/controllers/introspection_controller.py,sha256=nOmpM8jFjjb4bGS51AlyvHAHpGnApO6Q7kNhEyEWF3w,1778
|
|
6
|
+
irides_cli/controllers/metadata_controller.py,sha256=gBef019WAPhonrfBKmKrO-zim8NPalxXMEjSkyZShS4,1207
|
|
7
|
+
irides_cli/dto/__init__.py,sha256=flX6KI8y88xGFm0695iPG9kB0eLyJnPG3AQaWHHX-GE,56
|
|
8
|
+
irides_cli/dto/requests.py,sha256=PEc4JMqHPK69hGJ_nE-WbZjWJU-e_qRH7TRPer1m6E0,985
|
|
9
|
+
irides_cli/presentation/__init__.py,sha256=iZvd52E9HrQq_zjowdWIuaNabxUuyISlOEoSsCzlgng,47
|
|
10
|
+
irides_cli/presentation/parser.py,sha256=_3r9qRuqeI5Q-7ald4ROPVcpwGlgF7OxnL3DhSAhkLs,4114
|
|
11
|
+
irides_cli/services/__init__.py,sha256=s65xp7TJuJQOdtGEcIp29Qb1sIx8QEQMt2umze4qcLc,41
|
|
12
|
+
irides_cli/services/introspection_service.py,sha256=wkN4m3CCbttfpsG4rYEJ9gdjssRnMOuQPKlmNjMDF6c,5554
|
|
13
|
+
irides_cli/services/metadata_service.py,sha256=gzd6yEsc5g5j8RdJ4RfJaSETid3ICTnay4c_gfPrZi4,1298
|
|
14
|
+
irides_cli-0.1.0.dist-info/METADATA,sha256=Q7OUm3tamYDuTpS8aNRZ1Ri0I70RJhhZBMYyfbdgN-Y,5747
|
|
15
|
+
irides_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
16
|
+
irides_cli-0.1.0.dist-info/entry_points.txt,sha256=3j4p_T9X4dFzuLXUSc6AwbX7lMh3PmxPdCfagLTq1XM,48
|
|
17
|
+
irides_cli-0.1.0.dist-info/top_level.txt,sha256=TaY9gvMyoxfNe9t-JzbCNg8fpjB01_jAhvNlD1LTWxI,11
|
|
18
|
+
irides_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
irides_cli
|