graflo 1.3.3__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 (64) hide show
  1. graflo/README.md +18 -0
  2. graflo/__init__.py +70 -0
  3. graflo/architecture/__init__.py +38 -0
  4. graflo/architecture/actor.py +1120 -0
  5. graflo/architecture/actor_util.py +450 -0
  6. graflo/architecture/edge.py +297 -0
  7. graflo/architecture/onto.py +374 -0
  8. graflo/architecture/resource.py +161 -0
  9. graflo/architecture/schema.py +136 -0
  10. graflo/architecture/transform.py +292 -0
  11. graflo/architecture/util.py +93 -0
  12. graflo/architecture/vertex.py +586 -0
  13. graflo/caster.py +655 -0
  14. graflo/cli/__init__.py +14 -0
  15. graflo/cli/ingest.py +194 -0
  16. graflo/cli/manage_dbs.py +197 -0
  17. graflo/cli/plot_schema.py +132 -0
  18. graflo/cli/xml2json.py +93 -0
  19. graflo/data_source/__init__.py +48 -0
  20. graflo/data_source/api.py +339 -0
  21. graflo/data_source/base.py +97 -0
  22. graflo/data_source/factory.py +298 -0
  23. graflo/data_source/file.py +133 -0
  24. graflo/data_source/memory.py +72 -0
  25. graflo/data_source/registry.py +82 -0
  26. graflo/data_source/sql.py +185 -0
  27. graflo/db/__init__.py +44 -0
  28. graflo/db/arango/__init__.py +22 -0
  29. graflo/db/arango/conn.py +1026 -0
  30. graflo/db/arango/query.py +180 -0
  31. graflo/db/arango/util.py +88 -0
  32. graflo/db/conn.py +377 -0
  33. graflo/db/connection/__init__.py +6 -0
  34. graflo/db/connection/config_mapping.py +18 -0
  35. graflo/db/connection/onto.py +688 -0
  36. graflo/db/connection/wsgi.py +29 -0
  37. graflo/db/manager.py +119 -0
  38. graflo/db/neo4j/__init__.py +16 -0
  39. graflo/db/neo4j/conn.py +639 -0
  40. graflo/db/postgres/__init__.py +156 -0
  41. graflo/db/postgres/conn.py +425 -0
  42. graflo/db/postgres/resource_mapping.py +139 -0
  43. graflo/db/postgres/schema_inference.py +245 -0
  44. graflo/db/postgres/types.py +148 -0
  45. graflo/db/tigergraph/__init__.py +9 -0
  46. graflo/db/tigergraph/conn.py +2212 -0
  47. graflo/db/util.py +49 -0
  48. graflo/filter/__init__.py +21 -0
  49. graflo/filter/onto.py +525 -0
  50. graflo/logging.conf +22 -0
  51. graflo/onto.py +190 -0
  52. graflo/plot/__init__.py +17 -0
  53. graflo/plot/plotter.py +556 -0
  54. graflo/util/__init__.py +23 -0
  55. graflo/util/chunker.py +751 -0
  56. graflo/util/merge.py +150 -0
  57. graflo/util/misc.py +37 -0
  58. graflo/util/onto.py +332 -0
  59. graflo/util/transform.py +448 -0
  60. graflo-1.3.3.dist-info/METADATA +190 -0
  61. graflo-1.3.3.dist-info/RECORD +64 -0
  62. graflo-1.3.3.dist-info/WHEEL +4 -0
  63. graflo-1.3.3.dist-info/entry_points.txt +5 -0
  64. graflo-1.3.3.dist-info/licenses/LICENSE +126 -0
@@ -0,0 +1,688 @@
1
+ import abc
2
+ from enum import EnumMeta
3
+ from pathlib import Path
4
+ from strenum import StrEnum
5
+ from typing import Any, Dict, Type, TypeVar
6
+ from urllib.parse import urlparse
7
+
8
+ from pydantic import Field, model_validator
9
+ from pydantic import AliasChoices
10
+ from pydantic_settings import BaseSettings, SettingsConfigDict
11
+
12
+
13
+ class EnumMetaWithContains(EnumMeta):
14
+ """Enhanced EnumMeta that supports 'in' operator checks."""
15
+
16
+ def __contains__(cls, item, **kwargs):
17
+ try:
18
+ cls(item, **kwargs)
19
+ except ValueError:
20
+ return False
21
+ return True
22
+
23
+
24
+ # Type variable for DBConfig subclasses
25
+ T = TypeVar("T", bound="DBConfig")
26
+
27
+
28
+ class DBType(StrEnum, metaclass=EnumMetaWithContains):
29
+ """Enum representing different types of databases.
30
+
31
+ Includes both graph databases and source databases (SQL, NoSQL, etc.).
32
+ """
33
+
34
+ # Graph databases
35
+ ARANGO = "arango"
36
+ NEO4J = "neo4j"
37
+ TIGERGRAPH = "tigergraph"
38
+
39
+ # Source databases (SQL, NoSQL)
40
+ POSTGRES = "postgres"
41
+ MYSQL = "mysql"
42
+ MONGODB = "mongodb"
43
+ SQLITE = "sqlite"
44
+
45
+ @property
46
+ def config_class(self) -> Type["DBConfig"]:
47
+ """Get the appropriate config class for this database type."""
48
+ from .config_mapping import DB_TYPE_MAPPING
49
+
50
+ return DB_TYPE_MAPPING[self]
51
+
52
+
53
+ # Databases that can be used as sources (INPUT)
54
+ SOURCE_DATABASES: set[DBType] = {
55
+ DBType.ARANGO, # Graph DBs can be sources
56
+ DBType.NEO4J, # Graph DBs can be sources
57
+ DBType.TIGERGRAPH, # Graph DBs can be sources
58
+ DBType.POSTGRES, # SQL DBs
59
+ DBType.MYSQL,
60
+ DBType.MONGODB,
61
+ DBType.SQLITE,
62
+ }
63
+
64
+ # Databases that can be used as targets (OUTPUT)
65
+ TARGET_DATABASES: set[DBType] = {
66
+ DBType.ARANGO,
67
+ DBType.NEO4J,
68
+ DBType.TIGERGRAPH,
69
+ }
70
+
71
+
72
+ class DBConfig(BaseSettings, abc.ABC):
73
+ """Abstract base class for all database connection configurations using Pydantic BaseSettings."""
74
+
75
+ uri: str | None = Field(default=None, description="Backend URI")
76
+ username: str | None = Field(default=None, description="Authentication username")
77
+ password: str | None = Field(default=None, description="Authentication Password")
78
+ database: str | None = Field(
79
+ default=None,
80
+ description="Database name (backward compatibility, DB-specific mapping)",
81
+ )
82
+ schema_name: str | None = Field(
83
+ default=None,
84
+ validation_alias=AliasChoices("schema", "schema_name"),
85
+ description="Schema/graph name (unified internal structure)",
86
+ )
87
+ request_timeout: float = Field(
88
+ default=60.0, description="Request timeout in seconds"
89
+ )
90
+
91
+ @abc.abstractmethod
92
+ def _get_default_port(self) -> int:
93
+ """Get the default port for this db type."""
94
+ pass
95
+
96
+ @abc.abstractmethod
97
+ def _get_effective_database(self) -> str | None:
98
+ """Get the effective database name based on DB type.
99
+
100
+ For SQL databases: returns the database name
101
+ For graph databases: returns None (they don't have a database level)
102
+
103
+ Returns:
104
+ Database name or None
105
+ """
106
+ pass
107
+
108
+ @abc.abstractmethod
109
+ def _get_effective_schema(self) -> str | None:
110
+ """Get the effective schema/graph name based on DB type.
111
+
112
+ For SQL databases: returns the schema name
113
+ For graph databases: returns the graph/database name (mapped from user-facing field)
114
+
115
+ Returns:
116
+ Schema/graph name or None
117
+ """
118
+ pass
119
+
120
+ @property
121
+ def effective_database(self) -> str | None:
122
+ """Get the effective database name (delegates to concrete class)."""
123
+ return self._get_effective_database()
124
+
125
+ @property
126
+ def effective_schema(self) -> str | None:
127
+ """Get the effective schema/graph name (delegates to concrete class)."""
128
+ return self._get_effective_schema()
129
+
130
+ @model_validator(mode="after")
131
+ def _add_default_port_to_uri(self):
132
+ """Add default port to URI if missing."""
133
+ if self.uri is None:
134
+ return self
135
+
136
+ parsed = urlparse(self.uri)
137
+ if parsed.port is not None:
138
+ return self
139
+
140
+ # Add default port
141
+ default_port = self._get_default_port()
142
+ if parsed.scheme and parsed.hostname:
143
+ # Reconstruct URI with port
144
+ port_part = f":{default_port}" if default_port else ""
145
+ path_part = parsed.path or ""
146
+ query_part = f"?{parsed.query}" if parsed.query else ""
147
+ fragment_part = f"#{parsed.fragment}" if parsed.fragment else ""
148
+ self.uri = f"{parsed.scheme}://{parsed.hostname}{port_part}{path_part}{query_part}{fragment_part}"
149
+
150
+ return self
151
+
152
+ @property
153
+ def url(self) -> str | None:
154
+ """Backward compatibility property: alias for uri."""
155
+ return self.uri
156
+
157
+ @property
158
+ def url_without_port(self) -> str:
159
+ """Get URL without port."""
160
+ if self.uri is None:
161
+ raise ValueError("URI is not set")
162
+ parsed = urlparse(self.uri)
163
+ return f"{parsed.scheme}://{parsed.hostname}"
164
+
165
+ @property
166
+ def port(self) -> str | None:
167
+ """Get port from URI."""
168
+ if self.uri is None:
169
+ return None
170
+ parsed = urlparse(self.uri)
171
+ return str(parsed.port) if parsed.port else None
172
+
173
+ @property
174
+ def protocol(self) -> str:
175
+ """Get protocol/scheme from URI."""
176
+ if self.uri is None:
177
+ return "http"
178
+ parsed = urlparse(self.uri)
179
+ return parsed.scheme or "http"
180
+
181
+ @property
182
+ def hostname(self) -> str | None:
183
+ """Get hostname from URI."""
184
+ if self.uri is None:
185
+ return None
186
+ parsed = urlparse(self.uri)
187
+ return parsed.hostname
188
+
189
+ @property
190
+ def connection_type(self) -> "DBType":
191
+ """Get database type from class."""
192
+ # Map class to DBType - need to import here to avoid circular import
193
+ from .config_mapping import DB_TYPE_MAPPING
194
+
195
+ # Reverse lookup: find DBType for this class
196
+ for db_type, config_class in DB_TYPE_MAPPING.items():
197
+ if type(self) is config_class:
198
+ return db_type
199
+
200
+ # Fallback (shouldn't happen)
201
+ return DBType.ARANGO
202
+
203
+ def can_be_source(self) -> bool:
204
+ """Check if this database type can be used as a source."""
205
+ return self.connection_type in SOURCE_DATABASES
206
+
207
+ def can_be_target(self) -> bool:
208
+ """Check if this database type can be used as a target."""
209
+ return self.connection_type in TARGET_DATABASES
210
+
211
+ @classmethod
212
+ def from_dict(cls, data: Dict[str, Any]) -> "DBConfig":
213
+ """Create a connection config from a dictionary."""
214
+ if not isinstance(data, dict):
215
+ raise TypeError(f"Expected dict, got {type(data)}")
216
+
217
+ # Copy the data to avoid modifying the original
218
+ config_data = data.copy()
219
+
220
+ db_type = config_data.pop("db_type", None) or config_data.pop(
221
+ "connection_type", None
222
+ )
223
+ if not db_type:
224
+ raise ValueError("Missing 'db_type' or 'connection_type' in configuration")
225
+
226
+ try:
227
+ conn_type = DBType(db_type)
228
+ except ValueError:
229
+ raise ValueError(
230
+ f"Database type '{db_type}' not supported. "
231
+ f"Should be one of: {list(DBType)}"
232
+ )
233
+
234
+ # Map old 'url' field to 'uri' for backward compatibility
235
+ if "url" in config_data and "uri" not in config_data:
236
+ config_data["uri"] = config_data.pop("url")
237
+
238
+ # Map old credential fields
239
+ if "cred_name" in config_data and "username" not in config_data:
240
+ config_data["username"] = config_data.pop("cred_name")
241
+ if "cred_pass" in config_data and "password" not in config_data:
242
+ config_data["password"] = config_data.pop("cred_pass")
243
+
244
+ # Construct URI from protocol/hostname/port if uri is not provided
245
+ if "uri" not in config_data:
246
+ protocol = config_data.pop("protocol", "http")
247
+ hostname = config_data.pop("hostname", None)
248
+ port = config_data.pop("port", None)
249
+ hosts = config_data.pop("hosts", None)
250
+
251
+ if hosts:
252
+ # Use hosts as URI
253
+ config_data["uri"] = hosts
254
+ elif hostname:
255
+ # Construct URI from components
256
+ if port:
257
+ config_data["uri"] = f"{protocol}://{hostname}:{port}"
258
+ else:
259
+ config_data["uri"] = f"{protocol}://{hostname}"
260
+
261
+ # Get the appropriate config class and initialize it
262
+ config_class = conn_type.config_class
263
+ return config_class(**config_data)
264
+
265
+ @classmethod
266
+ def from_docker_env(cls, docker_dir: str | Path | None = None) -> "DBConfig":
267
+ """Load config from docker .env file.
268
+
269
+ Args:
270
+ docker_dir: Path to docker directory. If None, uses default based on db type.
271
+
272
+ Returns:
273
+ DBConfig instance loaded from .env file
274
+ """
275
+ raise NotImplementedError("Subclasses must implement from_docker_env")
276
+
277
+ @classmethod
278
+ def from_env(cls: Type[T], prefix: str | None = None) -> T:
279
+ """Load config from environment variables using Pydantic BaseSettings.
280
+
281
+ Supports custom prefixes for multiple configs:
282
+ - Default (prefix=None): Uses {BASE_PREFIX}URI, {BASE_PREFIX}USERNAME, etc.
283
+ - With prefix (prefix="USER"): Uses USER_{BASE_PREFIX}URI, USER_{BASE_PREFIX}USERNAME, etc.
284
+
285
+ Args:
286
+ prefix: Optional prefix for environment variables (e.g., "USER", "LAKE", "KG").
287
+ If None, uses default {BASE_PREFIX}* variables.
288
+
289
+ Returns:
290
+ DBConfig instance loaded from environment variables using Pydantic BaseSettings
291
+
292
+ Examples:
293
+ # Load default config (ARANGO_URI, ARANGO_USERNAME, etc.)
294
+ config = ArangoConfig.from_env()
295
+
296
+ # Load config with prefix (USER_ARANGO_URI, USER_ARANGO_USERNAME, etc.)
297
+ user_config = ArangoConfig.from_env(prefix="USER")
298
+ """
299
+ if prefix:
300
+ # Get the base prefix from the class's model_config
301
+ base_prefix = cls.model_config.get("env_prefix")
302
+ if not base_prefix:
303
+ raise ValueError(
304
+ f"Class {cls.__name__} does not have env_prefix configured in model_config"
305
+ )
306
+ # Create a new model class with modified env_prefix
307
+ new_prefix = f"{prefix.upper()}_{base_prefix}"
308
+ case_sensitive = cls.model_config.get("case_sensitive", False)
309
+ model_config = SettingsConfigDict(
310
+ env_prefix=new_prefix,
311
+ case_sensitive=case_sensitive,
312
+ )
313
+ # Create a new class dynamically with the modified prefix
314
+ temp_class = type(
315
+ f"{cls.__name__}WithPrefix", (cls,), {"model_config": model_config}
316
+ )
317
+ return temp_class()
318
+ else:
319
+ # Use default prefix - Pydantic will read from environment automatically
320
+ return cls()
321
+
322
+
323
+ class ArangoConfig(DBConfig):
324
+ """Configuration for ArangoDB connections."""
325
+
326
+ model_config = SettingsConfigDict(
327
+ env_prefix="ARANGO_",
328
+ case_sensitive=False,
329
+ )
330
+
331
+ def _get_default_port(self) -> int:
332
+ """Get default ArangoDB port."""
333
+ return 8529
334
+
335
+ def _get_effective_database(self) -> str | None:
336
+ """ArangoDB doesn't have a database level (connection -> database/graph -> collections)."""
337
+ return None
338
+
339
+ def _get_effective_schema(self) -> str | None:
340
+ """For ArangoDB, 'database' field maps to schema (graph) in unified model.
341
+
342
+ ArangoDB structure: connection -> database (graph) -> collections
343
+ Unified model: connection -> schema -> entities
344
+ """
345
+ return self.database
346
+
347
+ @classmethod
348
+ def from_docker_env(cls, docker_dir: str | Path | None = None) -> "ArangoConfig":
349
+ """Load ArangoDB config from docker/arango/.env file.
350
+
351
+ The .env file structure is minimal and may contain:
352
+ - ARANGO_PORT: Port number (defaults hostname to localhost, protocol to http)
353
+ - ARANGO_URI: Full URI (alternative to ARANGO_PORT)
354
+ - ARANGO_HOSTNAME: Hostname (defaults to localhost)
355
+ - ARANGO_PROTOCOL: Protocol (defaults to http)
356
+ - ARANGO_USERNAME: Username (defaults to root)
357
+ - ARANGO_PASSWORD: Password (or read from secret file if PATH_TO_SECRET is set)
358
+ - ARANGO_DATABASE: Database name (optional, can be set later)
359
+ - PATH_TO_SECRET: Path to secret file containing password (relative to docker_dir)
360
+ """
361
+ if docker_dir is None:
362
+ docker_dir = (
363
+ Path(__file__).parent.parent.parent.parent / "docker" / "arango"
364
+ )
365
+ else:
366
+ docker_dir = Path(docker_dir)
367
+
368
+ env_file = docker_dir / ".env"
369
+ if not env_file.exists():
370
+ raise FileNotFoundError(f"Environment file not found: {env_file}")
371
+
372
+ # Load .env file manually with simple variable expansion
373
+ env_vars: Dict[str, str] = {}
374
+ with open(env_file, "r") as f:
375
+ for line in f:
376
+ line = line.strip()
377
+ if line and not line.startswith("#") and "=" in line:
378
+ key, value = line.split("=", 1)
379
+ key = key.strip()
380
+ value = value.strip().strip('"').strip("'")
381
+ env_vars[key] = value
382
+
383
+ # Expand variables (simple single-pass expansion)
384
+ # First pass: expand ${SPEC} references
385
+ for key, value in env_vars.items():
386
+ if "${SPEC}" in value and "SPEC" in env_vars:
387
+ env_vars[key] = value.replace("${SPEC}", env_vars["SPEC"])
388
+
389
+ # Second pass: expand other variables (like ${CONTAINER_NAME})
390
+ for key, value in env_vars.items():
391
+ for var_name, var_value in env_vars.items():
392
+ var_ref = f"${{{var_name}}}"
393
+ if var_ref in value:
394
+ env_vars[key] = value.replace(var_ref, var_value)
395
+
396
+ # Map environment variables to config
397
+ config_data: Dict[str, Any] = {}
398
+
399
+ # URI construction
400
+ if "ARANGO_URI" in env_vars:
401
+ config_data["uri"] = env_vars["ARANGO_URI"]
402
+ elif "ARANGO_PORT" in env_vars:
403
+ port = env_vars["ARANGO_PORT"]
404
+ hostname = env_vars.get("ARANGO_HOSTNAME", "localhost")
405
+ protocol = env_vars.get("ARANGO_PROTOCOL", "http")
406
+ config_data["uri"] = f"{protocol}://{hostname}:{port}"
407
+ else:
408
+ # Default to localhost:8529 if nothing is specified
409
+ config_data["uri"] = "http://localhost:8529"
410
+
411
+ # Username (defaults to root for ArangoDB)
412
+ if "ARANGO_USERNAME" in env_vars:
413
+ config_data["username"] = env_vars["ARANGO_USERNAME"]
414
+ else:
415
+ config_data["username"] = "root"
416
+
417
+ # Password: check ARANGO_PASSWORD first, then try secret file
418
+ if "ARANGO_PASSWORD" in env_vars:
419
+ config_data["password"] = env_vars["ARANGO_PASSWORD"]
420
+ elif "PATH_TO_SECRET" in env_vars:
421
+ # Read password from secret file
422
+ secret_path_str = env_vars["PATH_TO_SECRET"]
423
+ # Handle relative paths (relative to docker_dir)
424
+ if secret_path_str.startswith("./"):
425
+ secret_path = docker_dir / secret_path_str[2:]
426
+ else:
427
+ secret_path = Path(secret_path_str)
428
+
429
+ if secret_path.exists():
430
+ with open(secret_path, "r") as f:
431
+ config_data["password"] = f.read().strip()
432
+ else:
433
+ # Secret file not found, password will be None (ArangoDB accepts empty string)
434
+ config_data["password"] = None
435
+
436
+ # Database (optional, can be set later or use Schema.general.name)
437
+ if "ARANGO_DATABASE" in env_vars:
438
+ config_data["database"] = env_vars["ARANGO_DATABASE"]
439
+
440
+ return cls(**config_data)
441
+
442
+
443
+ class Neo4jConfig(DBConfig):
444
+ """Configuration for Neo4j connections."""
445
+
446
+ model_config = SettingsConfigDict(
447
+ env_prefix="NEO4J_",
448
+ case_sensitive=False,
449
+ )
450
+
451
+ bolt_port: int | None = Field(default=None, description="Neo4j bolt protocol port")
452
+
453
+ def _get_default_port(self) -> int:
454
+ """Get default Neo4j HTTP port."""
455
+ return 7474
456
+
457
+ def _get_effective_database(self) -> str | None:
458
+ """Neo4j doesn't have a database level (connection -> database -> nodes/relationships)."""
459
+ return None
460
+
461
+ def _get_effective_schema(self) -> str | None:
462
+ """For Neo4j, 'database' field maps to schema (database) in unified model.
463
+
464
+ Neo4j structure: connection -> database -> nodes/relationships
465
+ Unified model: connection -> schema -> entities
466
+ """
467
+ return self.database
468
+
469
+ def __init__(self, **data):
470
+ """Initialize Neo4j config."""
471
+ super().__init__(**data)
472
+ # Set default bolt_port if not provided
473
+ if self.bolt_port is None:
474
+ self.bolt_port = 7687
475
+
476
+ @classmethod
477
+ def from_docker_env(cls, docker_dir: str | Path | None = None) -> "Neo4jConfig":
478
+ """Load Neo4j config from docker/neo4j/.env file."""
479
+ if docker_dir is None:
480
+ docker_dir = Path(__file__).parent.parent.parent.parent / "docker" / "neo4j"
481
+ else:
482
+ docker_dir = Path(docker_dir)
483
+
484
+ env_file = docker_dir / ".env"
485
+ if not env_file.exists():
486
+ raise FileNotFoundError(f"Environment file not found: {env_file}")
487
+
488
+ # Load .env file manually
489
+ env_vars: Dict[str, str] = {}
490
+ with open(env_file, "r") as f:
491
+ for line in f:
492
+ line = line.strip()
493
+ if line and not line.startswith("#") and "=" in line:
494
+ key, value = line.split("=", 1)
495
+ env_vars[key.strip()] = value.strip().strip('"').strip("'")
496
+
497
+ # Map environment variables to config
498
+ config_data: Dict[str, Any] = {}
499
+ # Neo4j typically uses bolt protocol
500
+ if "NEO4J_BOLT_PORT" in env_vars:
501
+ port = env_vars["NEO4J_BOLT_PORT"]
502
+ hostname = env_vars.get("NEO4J_HOSTNAME", "localhost")
503
+ config_data["uri"] = f"bolt://{hostname}:{port}"
504
+ config_data["bolt_port"] = int(port)
505
+ elif "NEO4J_URI" in env_vars:
506
+ config_data["uri"] = env_vars["NEO4J_URI"]
507
+
508
+ if "NEO4J_USERNAME" in env_vars:
509
+ config_data["username"] = env_vars["NEO4J_USERNAME"]
510
+ elif "NEO4J_AUTH" in env_vars:
511
+ # Parse NEO4J_AUTH format: username/password
512
+ auth = env_vars["NEO4J_AUTH"].split("/")
513
+ if len(auth) == 2:
514
+ config_data["username"] = auth[0]
515
+ config_data["password"] = auth[1]
516
+
517
+ if "NEO4J_PASSWORD" in env_vars:
518
+ config_data["password"] = env_vars["NEO4J_PASSWORD"]
519
+ if "NEO4J_DATABASE" in env_vars:
520
+ config_data["database"] = env_vars["NEO4J_DATABASE"]
521
+
522
+ return cls(**config_data)
523
+
524
+
525
+ class TigergraphConfig(DBConfig):
526
+ """Configuration for TigerGraph connections."""
527
+
528
+ model_config = SettingsConfigDict(
529
+ env_prefix="TIGERGRAPH_",
530
+ case_sensitive=False,
531
+ )
532
+
533
+ gs_port: int | None = Field(default=None, description="TigerGraph GSQL port")
534
+ secret: str | None = Field(
535
+ default=None, description="TigerGraph secret for token authentication"
536
+ )
537
+
538
+ def _get_default_port(self) -> int:
539
+ """Get default TigerGraph REST++ port."""
540
+ return 9000
541
+
542
+ def _get_effective_database(self) -> str | None:
543
+ """TigerGraph doesn't have a database level (connection -> schema -> vertices/edges)."""
544
+ return None
545
+
546
+ def _get_effective_schema(self) -> str | None:
547
+ """For TigerGraph, 'schema_name' field maps to schema (graph) in unified model.
548
+
549
+ TigerGraph structure: connection -> schema -> vertices/edges
550
+ Unified model: connection -> schema -> entities
551
+ """
552
+ return self.schema_name
553
+
554
+ def __init__(self, **data):
555
+ """Initialize TigerGraph config."""
556
+ super().__init__(**data)
557
+ # Set default gs_port if not provided
558
+ if self.gs_port is None:
559
+ self.gs_port = 14240
560
+
561
+ @classmethod
562
+ def from_docker_env(
563
+ cls, docker_dir: str | Path | None = None
564
+ ) -> "TigergraphConfig":
565
+ """Load TigerGraph config from docker/tigergraph/.env file."""
566
+ if docker_dir is None:
567
+ docker_dir = (
568
+ Path(__file__).parent.parent.parent.parent / "docker" / "tigergraph"
569
+ )
570
+ else:
571
+ docker_dir = Path(docker_dir)
572
+
573
+ env_file = docker_dir / ".env"
574
+ if not env_file.exists():
575
+ raise FileNotFoundError(f"Environment file not found: {env_file}")
576
+
577
+ # Load .env file manually
578
+ env_vars: Dict[str, str] = {}
579
+ with open(env_file, "r") as f:
580
+ for line in f:
581
+ line = line.strip()
582
+ if line and not line.startswith("#") and "=" in line:
583
+ key, value = line.split("=", 1)
584
+ env_vars[key.strip()] = value.strip().strip('"').strip("'")
585
+
586
+ # Map environment variables to config
587
+ config_data: Dict[str, Any] = {}
588
+ if "TG_REST" in env_vars or "TIGERGRAPH_PORT" in env_vars:
589
+ port = env_vars.get("TG_REST") or env_vars.get("TIGERGRAPH_PORT")
590
+ hostname = env_vars.get("TIGERGRAPH_HOSTNAME", "localhost")
591
+ protocol = env_vars.get("TIGERGRAPH_PROTOCOL", "http")
592
+ config_data["uri"] = f"{protocol}://{hostname}:{port}"
593
+
594
+ if "TG_WEB" in env_vars or "TIGERGRAPH_GS_PORT" in env_vars:
595
+ gs_port = env_vars.get("TG_WEB") or env_vars.get("TIGERGRAPH_GS_PORT")
596
+ config_data["gs_port"] = int(gs_port) if gs_port else None
597
+
598
+ if "TIGERGRAPH_USERNAME" in env_vars:
599
+ config_data["username"] = env_vars["TIGERGRAPH_USERNAME"]
600
+ if "TIGERGRAPH_PASSWORD" in env_vars or "GSQL_PASSWORD" in env_vars:
601
+ config_data["password"] = env_vars.get(
602
+ "TIGERGRAPH_PASSWORD"
603
+ ) or env_vars.get("GSQL_PASSWORD")
604
+ if "TIGERGRAPH_DATABASE" in env_vars:
605
+ config_data["database"] = env_vars["TIGERGRAPH_DATABASE"]
606
+
607
+ return cls(**config_data)
608
+
609
+
610
+ class PostgresConfig(DBConfig):
611
+ """Configuration for PostgreSQL connections."""
612
+
613
+ model_config = SettingsConfigDict(
614
+ env_prefix="POSTGRES_",
615
+ case_sensitive=False,
616
+ )
617
+
618
+ def _get_default_port(self) -> int:
619
+ """Get default PostgreSQL port."""
620
+ return 5432
621
+
622
+ def _get_effective_database(self) -> str | None:
623
+ """For PostgreSQL, 'database' field is the actual database name.
624
+
625
+ PostgreSQL structure: connection -> database -> schema -> table
626
+ Unified model: connection -> database -> schema -> entity
627
+ """
628
+ return self.database
629
+
630
+ def _get_effective_schema(self) -> str | None:
631
+ """For PostgreSQL, 'schema_name' field is the schema name.
632
+
633
+ PostgreSQL structure: connection -> database -> schema -> table
634
+ Unified model: connection -> database -> schema -> entity
635
+ """
636
+ return self.schema_name
637
+
638
+ @classmethod
639
+ def from_docker_env(cls, docker_dir: str | Path | None = None) -> "PostgresConfig":
640
+ """Load PostgreSQL config from docker/postgres/.env file."""
641
+ if docker_dir is None:
642
+ docker_dir = (
643
+ Path(__file__).parent.parent.parent.parent / "docker" / "postgres"
644
+ )
645
+ else:
646
+ docker_dir = Path(docker_dir)
647
+
648
+ env_file = docker_dir / ".env"
649
+ if not env_file.exists():
650
+ raise FileNotFoundError(f"Environment file not found: {env_file}")
651
+
652
+ # Load .env file manually
653
+ env_vars: Dict[str, str] = {}
654
+ with open(env_file, "r") as f:
655
+ for line in f:
656
+ line = line.strip()
657
+ if line and not line.startswith("#") and "=" in line:
658
+ key, value = line.split("=", 1)
659
+ env_vars[key.strip()] = value.strip().strip('"').strip("'")
660
+
661
+ # Map environment variables to config
662
+ config_data: Dict[str, Any] = {}
663
+ if "POSTGRES_URI" in env_vars:
664
+ config_data["uri"] = env_vars["POSTGRES_URI"]
665
+ elif "POSTGRES_PORT" in env_vars:
666
+ port = env_vars["POSTGRES_PORT"]
667
+ hostname = env_vars.get("POSTGRES_HOSTNAME", "localhost")
668
+ protocol = env_vars.get("POSTGRES_PROTOCOL", "postgresql")
669
+ config_data["uri"] = f"{protocol}://{hostname}:{port}"
670
+ elif "POSTGRES_HOST" in env_vars:
671
+ # PostgreSQL often uses POSTGRES_HOST instead of POSTGRES_HOSTNAME
672
+ port = env_vars.get("POSTGRES_PORT", "5432")
673
+ hostname = env_vars["POSTGRES_HOST"]
674
+ protocol = env_vars.get("POSTGRES_PROTOCOL", "postgresql")
675
+ config_data["uri"] = f"{protocol}://{hostname}:{port}"
676
+
677
+ if "POSTGRES_USER" in env_vars or "POSTGRES_USERNAME" in env_vars:
678
+ config_data["username"] = env_vars.get("POSTGRES_USER") or env_vars.get(
679
+ "POSTGRES_USERNAME"
680
+ )
681
+ if "POSTGRES_PASSWORD" in env_vars:
682
+ config_data["password"] = env_vars["POSTGRES_PASSWORD"]
683
+ if "POSTGRES_DB" in env_vars or "POSTGRES_DATABASE" in env_vars:
684
+ config_data["database"] = env_vars.get("POSTGRES_DB") or env_vars.get(
685
+ "POSTGRES_DATABASE"
686
+ )
687
+
688
+ return cls(**config_data)