structured2graph 0.2.0__tar.gz → 0.2.1__tar.gz

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 (56) hide show
  1. {structured2graph-0.2.0 → structured2graph-0.2.1}/.env +3 -3
  2. {structured2graph-0.2.0 → structured2graph-0.2.1}/.env.example +3 -0
  3. {structured2graph-0.2.0 → structured2graph-0.2.1}/Dockerfile +7 -1
  4. {structured2graph-0.2.0 → structured2graph-0.2.1}/Dockerfile.local +6 -0
  5. {structured2graph-0.2.0 → structured2graph-0.2.1}/PKG-INFO +1 -1
  6. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/models/llm_models.py +24 -7
  7. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/strategies/deterministic.py +41 -18
  8. {structured2graph-0.2.0 → structured2graph-0.2.1}/database/analyzer.py +13 -6
  9. {structured2graph-0.2.0 → structured2graph-0.2.1}/main.py +49 -32
  10. {structured2graph-0.2.0 → structured2graph-0.2.1}/pyproject.toml +1 -1
  11. structured2graph-0.2.1/skills/publish-dockerhub.md +13 -0
  12. {structured2graph-0.2.0 → structured2graph-0.2.1}/utils/environment.py +26 -1
  13. {structured2graph-0.2.0 → structured2graph-0.2.1}/uv.lock +1 -1
  14. {structured2graph-0.2.0 → structured2graph-0.2.1}/.dockerignore +0 -0
  15. {structured2graph-0.2.0 → structured2graph-0.2.1}/.gitignore +0 -0
  16. {structured2graph-0.2.0 → structured2graph-0.2.1}/.python-version +0 -0
  17. {structured2graph-0.2.0 → structured2graph-0.2.1}/LICENSE +0 -0
  18. {structured2graph-0.2.0 → structured2graph-0.2.1}/PROMPT.md +0 -0
  19. {structured2graph-0.2.0 → structured2graph-0.2.1}/README.md +0 -0
  20. {structured2graph-0.2.0 → structured2graph-0.2.1}/__init__.py +0 -0
  21. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/__init__.py +0 -0
  22. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/__init__.py +0 -0
  23. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/hygm.py +0 -0
  24. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/models/__init__.py +0 -0
  25. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/models/graph_models.py +0 -0
  26. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/models/operations.py +0 -0
  27. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/models/sources.py +0 -0
  28. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/models/user_operations.py +0 -0
  29. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/strategies/__init__.py +0 -0
  30. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/strategies/base.py +0 -0
  31. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/strategies/llm.py +0 -0
  32. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/validation/__init__.py +0 -0
  33. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/validation/base.py +0 -0
  34. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/validation/graph_schema_validator.py +0 -0
  35. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/hygm/validation/memgraph_data_validator.py +0 -0
  36. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/migration_agent.py +0 -0
  37. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/schema/spec.json +0 -0
  38. {structured2graph-0.2.0 → structured2graph-0.2.1}/core/utils/meta_graph.py +0 -0
  39. {structured2graph-0.2.0 → structured2graph-0.2.1}/database/__init__.py +0 -0
  40. {structured2graph-0.2.0 → structured2graph-0.2.1}/database/adapters/__init__.py +0 -0
  41. {structured2graph-0.2.0 → structured2graph-0.2.1}/database/adapters/memgraph.py +0 -0
  42. {structured2graph-0.2.0 → structured2graph-0.2.1}/database/adapters/mysql.py +0 -0
  43. {structured2graph-0.2.0 → structured2graph-0.2.1}/database/adapters/postgresql.py +0 -0
  44. {structured2graph-0.2.0 → structured2graph-0.2.1}/database/factory.py +0 -0
  45. {structured2graph-0.2.0 → structured2graph-0.2.1}/database/models.py +0 -0
  46. {structured2graph-0.2.0 → structured2graph-0.2.1}/examples/__init__.py +0 -0
  47. {structured2graph-0.2.0 → structured2graph-0.2.1}/examples/basic_migration.py +0 -0
  48. {structured2graph-0.2.0 → structured2graph-0.2.1}/examples/constraint_operations_example.py +0 -0
  49. {structured2graph-0.2.0 → structured2graph-0.2.1}/query_generation/__init__.py +0 -0
  50. {structured2graph-0.2.0 → structured2graph-0.2.1}/query_generation/cypher_generator.py +0 -0
  51. {structured2graph-0.2.0 → structured2graph-0.2.1}/query_generation/schema_utilities.py +0 -0
  52. {structured2graph-0.2.0 → structured2graph-0.2.1}/skills/running-under-docker-in-background.md +0 -0
  53. {structured2graph-0.2.0 → structured2graph-0.2.1}/tests/__init__.py +0 -0
  54. {structured2graph-0.2.0 → structured2graph-0.2.1}/tests/test_integration.py +0 -0
  55. {structured2graph-0.2.0 → structured2graph-0.2.1}/utils/__init__.py +0 -0
  56. {structured2graph-0.2.0 → structured2graph-0.2.1}/utils/config.py +0 -0
@@ -23,7 +23,7 @@ ANTHROPIC_API_KEY="sk-ant-api03-vXCucqmmzT44s_YtHgbq_Y7-aHlFC2YwRplQeWi_nhvzbvkk
23
23
  # SQL2MG_LOG_LEVEL=INFO
24
24
 
25
25
  # Source Database Selection - options: mysql, postgresql
26
- SOURCE_DB_TYPE=postgresql
26
+ SOURCE_DB_TYPE=mysql
27
27
 
28
28
  # # MySQL Database Configuration (used when SOURCE_DB_TYPE=mysql)
29
29
  MYSQL_HOST=localhost
@@ -33,8 +33,8 @@ MYSQL_DATABASE=employees
33
33
  MYSQL_PORT=3306
34
34
 
35
35
  # PostgreSQL Database Configuration (used when SOURCE_DB_TYPE=postgresql)
36
- # POSTGRES_HOST=localhost
37
- POSTGRES_HOST=postgres-dev
36
+ POSTGRES_HOST=localhost
37
+ # POSTGRES_HOST=postgres-dev
38
38
  POSTGRES_USER=postgres
39
39
  POSTGRES_PASSWORD=postgres
40
40
  POSTGRES_DATABASE=postgres
@@ -1,3 +1,6 @@
1
+ # NOTE: To configure environment, copy this file into .env file (under the same
2
+ # folder) and adjust the values.
3
+
1
4
  # LLM API Configuration (choose one or more)
2
5
  # OpenAI
3
6
  OPENAI_API_KEY=your_actual_openai_api_key
@@ -2,11 +2,17 @@ FROM python:3.12-slim
2
2
 
3
3
  WORKDIR /app
4
4
 
5
+ # Install editors for interactive mapping editing
6
+ RUN apt-get update && apt-get install -y --no-install-recommends vim-tiny neovim \
7
+ && ln -s /usr/bin/vim.tiny /usr/bin/vi \
8
+ && rm -rf /var/lib/apt/lists/*
9
+ ENV EDITOR=nvim
10
+
5
11
  # Install uv for fast dependency management
6
12
  COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
7
13
 
8
14
  # Install the released package from PyPI
9
- RUN uv pip install --system structured2graph
15
+ RUN uv pip install --system structured2graph==0.2.1
10
16
 
11
17
  # Copy .env.example as reference; users supply real values at runtime
12
18
  # via: docker run -it --env-file .env memgraph/structured2graph
@@ -2,6 +2,12 @@ FROM python:3.12-slim
2
2
 
3
3
  WORKDIR /app
4
4
 
5
+ # Install editors for interactive mapping editing
6
+ RUN apt-get update && apt-get install -y --no-install-recommends vim-tiny neovim \
7
+ && ln -s /usr/bin/vim.tiny /usr/bin/vi \
8
+ && rm -rf /var/lib/apt/lists/*
9
+ ENV EDITOR=nvim
10
+
5
11
  # Install uv for fast dependency management
6
12
  COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
7
13
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structured2graph
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Database migration agent from structured data (e.g. SQL) to graph.
5
5
  Project-URL: Homepage, https://github.com/memgraph/ai-toolkit
6
6
  Project-URL: Repository, https://github.com/memgraph/ai-toolkit
@@ -165,10 +165,17 @@ class LLMGraphModel(BaseModel):
165
165
  )
166
166
  node_constraints.append(graph_constraint)
167
167
 
168
+ # Build a lookup from node name → source table
169
+ node_table_map = {}
170
+ node_pk_map = {}
171
+ for node in self.nodes:
172
+ node_table_map[node.name] = node.source_table
173
+ node_pk_map[node.name] = node.primary_key
174
+
168
175
  # Convert relationships
169
176
  graph_relationships = []
170
177
  for llm_rel in self.relationships:
171
- # Find source/target node labels
178
+ # Find source/target node labels and tables
172
179
  from_labels = []
173
180
  to_labels = []
174
181
  for node in self.nodes:
@@ -177,6 +184,16 @@ class LLMGraphModel(BaseModel):
177
184
  if node.name == llm_rel.to_node:
178
185
  to_labels = node.labels
179
186
 
187
+ from_table = node_table_map.get(llm_rel.from_node, "")
188
+ to_table = node_table_map.get(llm_rel.to_node, "")
189
+ from_pk = node_pk_map.get(llm_rel.from_node, "id")
190
+ to_pk = node_pk_map.get(llm_rel.to_node, "id")
191
+
192
+ # For one-to-many the FK lives in the "from" side's table;
193
+ # use the target table as the source table name.
194
+ # Fall back to the from_table when we can't determine better.
195
+ source_table = to_table or from_table
196
+
180
197
  # Create relationship properties
181
198
  rel_properties = []
182
199
  for prop_name in llm_rel.properties:
@@ -188,14 +205,14 @@ class LLMGraphModel(BaseModel):
188
205
  )
189
206
  rel_properties.append(rel_prop)
190
207
 
191
- # Create relationship source
208
+ # Create relationship source with actual SQL table/column info
192
209
  rel_source = RelationshipSource(
193
- type="derived",
194
- name=f"{llm_rel.from_node}_{llm_rel.name}_{llm_rel.to_node}",
195
- location=f"derived.{llm_rel.name}",
210
+ type="table",
211
+ name=source_table,
212
+ location=f"database.schema.{source_table}",
196
213
  mapping={
197
- "start_node": llm_rel.from_node,
198
- "end_node": llm_rel.to_node,
214
+ "start_node": f"{from_table}.{from_pk}",
215
+ "end_node": f"{to_table}.{to_pk}",
199
216
  "edge_type": llm_rel.name,
200
217
  },
201
218
  )
@@ -147,17 +147,39 @@ class DeterministicStrategy(BaseModelingStrategy):
147
147
  primary_keys = from_table_info.get("primary_keys", [])
148
148
  from_pk = primary_keys[0] if primary_keys else f"{from_table}_id"
149
149
 
150
+ # Use the join table name for many-to-many, otherwise the from_table
151
+ join_table = rel_data.get("join_table", "")
152
+ source_table = join_table or from_table
153
+
154
+ # Build column references
155
+ if join_table:
156
+ # Many-to-many: both FK columns live in the join table
157
+ from_col = rel_data.get("join_from_column", "id")
158
+ to_col = rel_data.get("join_to_column", "id")
159
+ start_ref = f"{join_table}.{from_col}"
160
+ end_ref = f"{join_table}.{to_col}"
161
+ else:
162
+ # One-to-many: from_col is in from_table, to_col is in to_table
163
+ from_col = rel_data.get("from_column", "id")
164
+ to_col = rel_data.get("to_column", "id")
165
+ start_ref = f"{from_table}.{from_col}"
166
+ end_ref = f"{to_table}.{to_col}"
167
+
150
168
  # Create relationship source
169
+ mapping = {
170
+ "start_node": start_ref,
171
+ "end_node": end_ref,
172
+ "edge_type": rel_name,
173
+ "from_pk": from_pk,
174
+ }
175
+ if join_table:
176
+ mapping["join_table"] = join_table
177
+
151
178
  rel_source = RelationshipSource(
152
179
  type="table",
153
- name=rel_data.get("constraint_name", rel_name),
154
- location=f"database.schema.{from_table}",
155
- mapping={
156
- "start_node": (f"{from_table}.{rel_data.get('from_column', 'id')}"),
157
- "end_node": (f"{to_table}.{rel_data.get('to_column', 'id')}"),
158
- "edge_type": rel_name,
159
- "from_pk": from_pk, # Add primary key for migration agent
160
- },
180
+ name=source_table,
181
+ location=f"database.schema.{source_table}",
182
+ mapping=mapping,
161
183
  )
162
184
 
163
185
  # Create relationship
@@ -244,19 +266,20 @@ class DeterministicStrategy(BaseModelingStrategy):
244
266
 
245
267
  def _generate_relationship_name(self, rel_data: Dict[str, Any]) -> str:
246
268
  """Generate a semantic relationship name from relationship data."""
269
+ # For many-to-many, use the join table name directly
270
+ join_table = rel_data.get("join_table", "")
271
+ if join_table:
272
+ return join_table.upper()
273
+
247
274
  constraint_name = rel_data.get("constraint_name", "")
248
275
  if constraint_name:
249
276
  # Extract meaningful name from constraint
250
277
  if "_fk" in constraint_name:
251
- join_table = constraint_name.split("_fk")[0]
278
+ name = constraint_name.split("_fk")[0]
252
279
  else:
253
- join_table = constraint_name
280
+ name = constraint_name
281
+ return name.upper() if name else "CONNECTS"
254
282
 
255
- if join_table:
256
- return join_table.upper()
257
- else:
258
- return "CONNECTS"
259
- else:
260
- from_table = rel_data.get("from_table", "")
261
- to_table = rel_data.get("to_table", "")
262
- return f"{from_table.upper()}_TO_{to_table.upper()}"
283
+ from_table = rel_data.get("from_table", "")
284
+ to_table = rel_data.get("to_table", "")
285
+ return f"{from_table.upper()}_TO_{to_table.upper()}"
@@ -212,21 +212,28 @@ class DatabaseAnalyzer(ABC):
212
212
  if len(table_info.foreign_keys) < 2:
213
213
  return False
214
214
 
215
- # Count non-FK columns (excluding common metadata columns)
215
+ # Count non-FK columns (excluding common metadata / temporal columns
216
+ # that often appear on join tables as relationship properties)
216
217
  non_fk_columns = []
217
218
  fk_column_names = {fk.column_name for fk in table_info.foreign_keys}
218
- metadata_columns = {
219
+ auxiliary_columns = {
219
220
  "id",
220
221
  "created_at",
221
222
  "updated_at",
222
223
  "created_on",
223
224
  "updated_on",
224
225
  "timestamp",
226
+ "from_date",
227
+ "to_date",
228
+ "start_date",
229
+ "end_date",
230
+ "valid_from",
231
+ "valid_to",
225
232
  }
226
233
 
227
234
  for col in table_info.columns:
228
235
  field_name = col.name.lower()
229
- if col.name not in fk_column_names and field_name not in metadata_columns:
236
+ if col.name not in fk_column_names and field_name not in auxiliary_columns:
230
237
  non_fk_columns.append(col.name)
231
238
 
232
239
  # If most columns are foreign keys, it's likely a join table
@@ -234,9 +241,9 @@ class DatabaseAnalyzer(ABC):
234
241
  fk_ratio = len(table_info.foreign_keys) / total_columns
235
242
 
236
243
  # Consider it a join table if:
237
- # - At least 2 FKs and FK ratio > 0.5, OR
238
- # - All columns are FKs or metadata columns
239
- return (len(table_info.foreign_keys) >= 2 and fk_ratio > 0.5) or len(
244
+ # - At least 2 FKs and FK ratio >= 0.5, OR
245
+ # - All columns are FKs or auxiliary columns
246
+ return (len(table_info.foreign_keys) >= 2 and fk_ratio >= 0.5) or len(
240
247
  non_fk_columns
241
248
  ) == 0
242
249
 
@@ -59,6 +59,9 @@ LOG_LEVEL_CHOICES = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET"]
59
59
 
60
60
  PROVIDER_CHOICES = ["openai", "anthropic", "gemini"]
61
61
 
62
+ # Sentinel returned by edit_mapping_interactive to signal a reset
63
+ _RESET = object()
64
+
62
65
 
63
66
  def _lower_env(name: str) -> Optional[str]:
64
67
  value = os.getenv(name)
@@ -783,6 +786,7 @@ def edit_mapping_interactive(
783
786
  print(" /edit - open mapping in " + editor)
784
787
  print(" /save - save and exit")
785
788
  print(" /cancel - discard changes and exit")
789
+ print(" /reset - discard mapping and regenerate from scratch")
786
790
  print("\nOr describe changes in natural language (sent to LLM), e.g.:")
787
791
  print(" Add a Person label node mapped from the people table")
788
792
  print(" Rename label Person to User")
@@ -812,6 +816,8 @@ def edit_mapping_interactive(
812
816
  print("Mapping updated from editor.")
813
817
  print_mapping_summary(mapping)
814
818
  _print_editor_banner()
819
+ elif cmd == "reset":
820
+ return _RESET
815
821
  else:
816
822
  print(f"Unknown command: {user_input}")
817
823
  continue
@@ -851,34 +857,14 @@ def edit_mapping_interactive(
851
857
  return mapping
852
858
 
853
859
 
854
- def generate_mapping(
860
+ def _generate_fresh_mapping(
855
861
  agent: SQLToMemgraphAgent,
856
862
  source_db_config: Dict[str, Any],
857
- mapping_path: str,
858
- ) -> None:
859
- """
860
- Generate or edit a mapping file.
861
-
862
- If the file already exists it is loaded and the user enters an interactive
863
- editing session. Otherwise the source database is analysed and a new
864
- mapping is created from scratch.
865
- """
866
- output = Path(mapping_path)
867
-
868
- # If mapping already exists, load it and enter edit mode
869
- if output.exists():
870
- with open(output, "r", encoding="utf-8") as f:
871
- mapping = json.load(f)
872
-
873
- print(f"📄 Loaded existing mapping from {output}")
874
- print_mapping_summary(mapping)
875
- edit_mapping_interactive(mapping, agent.llm, mapping_path)
876
- return
877
-
863
+ ) -> Dict[str, Any]:
864
+ """Analyse the source database and return a new mapping dict."""
878
865
  from database.factory import DatabaseAnalyzerFactory
879
866
  from core.hygm import HyGM
880
867
 
881
- # 1. Connect and analyse the source database
882
868
  print("🔍 Analyzing source database schema...")
883
869
  config = source_db_config.copy()
884
870
  db_type = config.pop("database_type", "mysql")
@@ -891,7 +877,6 @@ def generate_mapping(
891
877
  analyzer.disconnect()
892
878
  print(f" Found {len(hygm_data.get('entity_tables', {}))} entity tables")
893
879
 
894
- # 2. Build the graph model via HyGM
895
880
  print("🎯 Creating graph model...")
896
881
  graph_modeler = HyGM(
897
882
  llm=agent.llm,
@@ -907,16 +892,48 @@ def generate_mapping(
907
892
  f"{len(graph_model.edges)} relationship types"
908
893
  )
909
894
 
910
- # 3. Write mapping file
911
- mapping = graph_model_to_mapping(graph_model)
912
- output.parent.mkdir(parents=True, exist_ok=True)
913
- with open(output, "w", encoding="utf-8") as f:
914
- json.dump(mapping, f, indent=2)
915
- print(f"\n📄 Mapping file written to {output}")
895
+ return graph_model_to_mapping(graph_model)
896
+
897
+
898
+ def generate_mapping(
899
+ agent: SQLToMemgraphAgent,
900
+ source_db_config: Dict[str, Any],
901
+ mapping_path: str,
902
+ ) -> None:
903
+ """
904
+ Generate or edit a mapping file.
905
+
906
+ If the file already exists it is loaded and the user enters an interactive
907
+ editing session. Otherwise the source database is analysed and a new
908
+ mapping is created from scratch. The user can type /reset at any point
909
+ to discard the mapping and regenerate from the source database.
910
+ """
911
+ output = Path(mapping_path)
912
+
913
+ if output.exists():
914
+ with open(output, "r", encoding="utf-8") as f:
915
+ mapping = json.load(f)
916
+ print(f"📄 Loaded existing mapping from {output}")
917
+ else:
918
+ mapping = _generate_fresh_mapping(agent, source_db_config)
919
+ output.parent.mkdir(parents=True, exist_ok=True)
920
+ with open(output, "w", encoding="utf-8") as f:
921
+ json.dump(mapping, f, indent=2)
922
+ print(f"\n📄 Mapping file written to {output}")
923
+
916
924
  print_mapping_summary(mapping)
917
925
 
918
- # 4. Enter interactive editing
919
- edit_mapping_interactive(mapping, agent.llm, mapping_path)
926
+ while True:
927
+ result = edit_mapping_interactive(mapping, agent.llm, mapping_path)
928
+ if result is not _RESET:
929
+ break
930
+ print("\n🔄 Resetting mapping — regenerating from source database...\n")
931
+ mapping = _generate_fresh_mapping(agent, source_db_config)
932
+ output.parent.mkdir(parents=True, exist_ok=True)
933
+ with open(output, "w", encoding="utf-8") as f:
934
+ json.dump(mapping, f, indent=2)
935
+ print(f"\n📄 Mapping file written to {output}")
936
+ print_mapping_summary(mapping)
920
937
 
921
938
 
922
939
  def main(argv: Optional[list[str]] = None) -> None:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "structured2graph"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Database migration agent from structured data (e.g. SQL) to graph."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Build and publish the structured2graph Docker image to Docker Hub
3
+ user-invocable: true
4
+ ---
5
+
6
+ Read the current version from `pyproject.toml` (the `version` field under `[project]`).
7
+
8
+ Build and push multi-platform (amd64 + arm64) with both the version tag and `latest`:
9
+ ```bash
10
+ docker buildx build --platform linux/amd64,linux/arm64 -t memgraph/structured2graph:<version> -t memgraph/structured2graph:latest --push .
11
+ ```
12
+
13
+ Replace `<version>` with the actual version from `pyproject.toml`.
@@ -42,9 +42,34 @@ def _strip_quotes_from_env() -> None:
42
42
  os.environ[key] = val[1:-1]
43
43
 
44
44
 
45
+ def _find_dotenv() -> Optional[str]:
46
+ """Search well-known locations for a .env file.
47
+
48
+ Returns the first path that exists, or None. The search order is:
49
+ 1. Current working directory
50
+ 2. /app (Docker WORKDIR convention)
51
+ 3. Directory containing this source file (dev checkout)
52
+ """
53
+ candidates = [
54
+ os.path.join(os.getcwd(), ".env"),
55
+ "/app/.env",
56
+ os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env"),
57
+ ]
58
+ for path in candidates:
59
+ if os.path.isfile(path):
60
+ return path
61
+ return None
62
+
63
+
45
64
  def load_environment() -> None:
46
65
  """Load environment variables from .env file."""
47
- load_dotenv()
66
+ dotenv_path = _find_dotenv()
67
+ if dotenv_path:
68
+ logger.info("Loading .env from %s", dotenv_path)
69
+ load_dotenv(dotenv_path)
70
+ else:
71
+ # Fall back to default find_dotenv() behaviour
72
+ load_dotenv()
48
73
  _strip_quotes_from_env()
49
74
 
50
75
 
@@ -3548,7 +3548,7 @@ wheels = [
3548
3548
 
3549
3549
  [[package]]
3550
3550
  name = "structured2graph"
3551
- version = "0.2.0"
3551
+ version = "0.2.1"
3552
3552
  source = { editable = "." }
3553
3553
  dependencies = [
3554
3554
  { name = "anthropic" },