sibi-flux 2026.1.5__py3-none-any.whl → 2026.1.7__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.
sibi_flux/cli.py CHANGED
@@ -34,6 +34,42 @@ def init(
34
34
  initialize_project(project_name, lib, app)
35
35
 
36
36
 
37
+ @app.command()
38
+ def create_app(
39
+ name: str = typer.Argument(..., help="Name of the application to create"),
40
+ ):
41
+ """
42
+ Create a new application within an existing Sibi Flux project.
43
+
44
+ Generates standard directory structure in `<project_root>/<name>`.
45
+ """
46
+ from sibi_flux.init.app import init_app
47
+ init_app(name)
48
+
49
+
50
+ @app.command()
51
+ def create_cubes(
52
+ app_name: str = typer.Argument(..., help="Name of the application"),
53
+ ):
54
+ """
55
+ Generate app-specific Datacube extensions from `<app_name>/datacubes/datacubes.yaml`.
56
+ """
57
+ from sibi_flux.init.cube_extender import create_cubes
58
+ create_cubes(app_name)
59
+
60
+
61
+ @app.command()
62
+ def propose_cubes(
63
+ db_domain: str = typer.Argument(..., help="Database domain to filter by (e.g., 'ibis_dev')"),
64
+ app_name: str = typer.Argument(..., help="Name of the target application"),
65
+ ):
66
+ """
67
+ Scan global registry for datacubes in <db_domain> and add them to <app_name>/datacubes/datacubes.yaml.
68
+ """
69
+ from sibi_flux.init.cube_proposer import propose_cubes
70
+ propose_cubes(db_domain, app_name)
71
+
72
+
37
73
  @app.command()
38
74
  def env(
39
75
  project_path: Path = typer.Argument(Path("."), help="Project root directory"),
sibi_flux/datacube/cli.py CHANGED
@@ -258,6 +258,42 @@ def sync(
258
258
 
259
259
  # Start with empty/default registry
260
260
  config_data = _load_and_resolve_config(config_path)
261
+
262
+ # Load existing Global Registry to preserve manual edits (e.g. custom_name)
263
+ # We must flatten the grouped structure (by config object) into a single tables dict
264
+ params = context.params or {}
265
+ reg_rel_path = params.get("paths", {}).get("repositories", {}).get(
266
+ "global_datacube_registry_file"
267
+ ) or params.get("global_datacube_registry_file")
268
+
269
+ if reg_rel_path:
270
+ reg_file = Path(reg_rel_path)
271
+ if not reg_file.is_absolute():
272
+ try:
273
+ # Heuristic: config_path is in generators/datacubes/, project root is 3 levels up
274
+ prj_root = config_path.parent.parent.parent
275
+ reg_file = prj_root / reg_rel_path
276
+ except Exception:
277
+ reg_file = Path.cwd() / reg_rel_path
278
+
279
+ if reg_file.exists():
280
+ try:
281
+ with open(reg_file, "r") as rf:
282
+ existing_reg_data = yaml.safe_load(rf) or {}
283
+
284
+ flat_tables = {}
285
+ for grp, tbls in existing_reg_data.items():
286
+ if isinstance(tbls, dict):
287
+ for t, t_meta in tbls.items():
288
+ # Inject the config object (group key) so DatacubeRegistry knows the connection
289
+ t_meta["connection_obj"] = grp
290
+ flat_tables[t] = t_meta
291
+
292
+ config_data["tables"] = flat_tables
293
+ console.print(f"[dim]Loaded {len(flat_tables)} existing registry entries for merge preservation.[/dim]")
294
+ except Exception as e:
295
+ console.print(f"[yellow]Warning: Could not load existing registry: {e}[/yellow]")
296
+
261
297
  registry = DatacubeRegistry(config_data, params=context.params)
262
298
 
263
299
  # --- Aggregation Phase ---
@@ -378,9 +414,16 @@ def sync(
378
414
  imp = [db_imp] if db_imp else registry.global_imports
379
415
  return resolve_db_url(conf_name, imp)
380
416
 
381
- _run_field_map_generation(
417
+ generated_maps = _run_field_map_generation(
382
418
  context, config_path, databases, get_url_safe, force=force
383
419
  )
420
+
421
+ if generated_maps:
422
+ for table_name, mod_path in generated_maps.items():
423
+ if table_name in registry.tables:
424
+ # console.print(f"[green]DEBUG: Updating {table_name} with field_map {mod_path}[/green]")
425
+ registry.tables[table_name]["field_map"] = mod_path
426
+
384
427
  # Ensure new modules are picked up
385
428
  importlib.invalidate_caches()
386
429
 
@@ -435,6 +478,35 @@ def sync(
435
478
  is_append = False
436
479
  existing_content = ""
437
480
 
481
+ # --- Registry Collection (Always run, even if skipped) ---
482
+ # Structure: {conf_obj: {table_name: {class_name: ..., path: ...}}}
483
+ for item in items:
484
+ t_name = item[0]
485
+ conf_obj = item[1]
486
+ cls_n = item[4]
487
+ # Calculate path relative to project root
488
+ try:
489
+ if "project_root" not in locals():
490
+ project_root = config_path.parent.parent.parent
491
+ rel_path = file_path.relative_to(project_root)
492
+ except Exception:
493
+ rel_path = file_path
494
+
495
+ if conf_obj not in generated_registry:
496
+ generated_registry[conf_obj] = {}
497
+
498
+ # Preserve custom_name from existing registry if present
499
+ existing_meta = registry.get_table_details(t_name)
500
+ custom_name = existing_meta.get("custom_name")
501
+
502
+ entry_data = {
503
+ "class_name": cls_n,
504
+ "path": str(rel_path),
505
+ "custom_name": custom_name,
506
+ }
507
+
508
+ generated_registry[conf_obj][t_name] = entry_data
509
+
438
510
  if file_path.exists() and not force:
439
511
  with open(file_path, "r") as f:
440
512
  existing_content = f.read()
@@ -481,12 +553,8 @@ def sync(
481
553
  )
482
554
 
483
555
  if not classes_code:
484
- if not is_append:
485
- summary_table.add_row(
486
- file_path_str, "0", "[red]Failed (No Classes Generated)[/red]"
487
- )
488
- else:
489
- summary_table.add_row(file_path_str, "0", "[red]Failed to Append[/red]")
556
+ status_msg = "[red]Failed (No Classes Generated)[/red]" if not is_append else "[red]Failed to Append[/red]"
557
+ summary_table.add_row(file_path_str, "0", status_msg)
490
558
  continue
491
559
 
492
560
  if not is_append:
@@ -515,29 +583,6 @@ def sync(
515
583
  )
516
584
  summary_table.add_row(file_path_str, str(len(items)), status_msg)
517
585
 
518
- # --- Registry Collection ---
519
- # Collect metadata for generated datacubes
520
- # Structure: {conf_obj: {table_name: {class_name: ..., path: ...}}}
521
- for item in items:
522
- t_name = item[0]
523
- conf_obj = item[1]
524
- cls_n = item[4]
525
- # Calculate path relative to project root
526
- try:
527
- if "project_root" not in locals():
528
- project_root = config_path.parent.parent.parent
529
- rel_path = file_path.relative_to(project_root)
530
- except Exception:
531
- rel_path = file_path
532
-
533
- if conf_obj not in generated_registry:
534
- generated_registry[conf_obj] = {}
535
-
536
- generated_registry[conf_obj][t_name] = {
537
- "class_name": cls_n,
538
- "path": str(rel_path),
539
- }
540
-
541
586
  console.print(summary_table)
542
587
 
543
588
  # --- Write Datacube Registry ---
@@ -566,8 +611,9 @@ def sync(
566
611
  with open(reg_file, "w") as f:
567
612
  yaml.dump(reg_data, f, sort_keys=False)
568
613
 
614
+ total_tables = sum(len(tables) for tables in generated_registry.values())
569
615
  console.print(
570
- f"[green]Updated Datacube Registry at {reg_rel_path} ({len(generated_registry)} entries)[/green]"
616
+ f"[green]Updated Datacube Registry at {reg_rel_path} ({total_tables} tables across {len(generated_registry)} groups)[/green]"
571
617
  )
572
618
  except Exception as e:
573
619
  console.print(f"[red]Failed to write Datacube Registry: {e}[/red]")
@@ -765,12 +811,29 @@ def discover(
765
811
  )
766
812
  continue
767
813
 
814
+ # Resolve Registry Path (Target)
815
+ reg_rel_path = params.get("paths", {}).get("repositories", {}).get(
816
+ "global_datacube_registry_file"
817
+ ) or params.get("global_datacube_registry_file")
818
+
819
+ real_registry_path = str(config_path) # Fallback to config if not defined (legacy behavior)
820
+ if reg_rel_path:
821
+ if Path(reg_rel_path).is_absolute():
822
+ real_registry_path = reg_rel_path
823
+ else:
824
+ try:
825
+ # Anchor to project root
826
+ prj_root = config_path.parent.parent.parent
827
+ real_registry_path = str(prj_root / reg_rel_path)
828
+ except Exception:
829
+ real_registry_path = str(config_path.parent / reg_rel_path)
830
+
768
831
  orchestrator = DiscoveryOrchestrator(
769
832
  field_registry=field_registry,
770
833
  params=context.params,
771
834
  rules_path=str(rules_path),
772
835
  whitelist_path=str(whitelist_path),
773
- registry_path=str(config_path),
836
+ registry_path=real_registry_path,
774
837
  db_connection_str=db_conn_str,
775
838
  db_config=db_config,
776
839
  )
@@ -1904,6 +1967,9 @@ def whitelist(
1904
1967
  # Merge: update rule defaults only if not set in existing
1905
1968
  merged = rule_meta.copy()
1906
1969
  merged.update(existing_meta) # Existing overwrites rule
1970
+
1971
+ # Legacy Cleanup: We moved custom_name to Registry
1972
+ merged.pop("custom_name", None)
1907
1973
 
1908
1974
  # Restore calculated paths (Enforce Relative)
1909
1975
  if "datacube_path" in rule_meta:
@@ -2099,6 +2165,8 @@ def _run_field_map_generation(
2099
2165
  "[yellow]Warning: Could not determine global field_maps_dir for clean build.[/yellow]"
2100
2166
  )
2101
2167
 
2168
+ generated_maps = {}
2169
+
2102
2170
  for db in target_dbs:
2103
2171
  # console.print(f"DEBUG: Processing DB entry: {db} (Type: {type(db)})")
2104
2172
  if isinstance(db, str):
@@ -2223,10 +2291,6 @@ def _run_field_map_generation(
2223
2291
  if not found:
2224
2292
  rules = []
2225
2293
 
2226
- # console.print(f"DEBUG: Loaded {len(rules)} rules from {rules_path} for {conn_obj}")
2227
-
2228
- # console.print(f"DEBUG: Loaded {len(rules)} rules from {rules_path}")
2229
-
2230
2294
  # Support List or Dict Format
2231
2295
  scoped_data = registry_data.get(conn_obj, {})
2232
2296
  if isinstance(scoped_data, list):
@@ -2448,6 +2512,21 @@ def _run_field_map_generation(
2448
2512
  with open(target_file, "w") as f:
2449
2513
  f.write("\n".join(lines))
2450
2514
 
2515
+ # Calculate Import Path
2516
+ try:
2517
+ # Ensure we get relative path to project root (which should be sys.path root)
2518
+ if "project_root" not in locals():
2519
+ project_root = Path.cwd()
2520
+
2521
+ rel_py_path = target_file.relative_to(project_root)
2522
+ module_path = str(rel_py_path.with_suffix("")).replace("/", ".")
2523
+ generated_maps[table_name] = f"{module_path}.field_map"
2524
+ except ValueError:
2525
+ # Fallback if outside project root?
2526
+ pass
2527
+ except Exception as e:
2528
+ pass
2529
+
2451
2530
  except Exception as e:
2452
2531
  console.print(f"[red]Error processing {table_name}: {e}[/red]")
2453
2532
  continue
@@ -2464,6 +2543,8 @@ def _run_field_map_generation(
2464
2543
  except Exception as e:
2465
2544
  console.print(f"[red]Failed to save Global Field Repository: {e}[/red]")
2466
2545
 
2546
+ return generated_maps
2547
+
2467
2548
 
2468
2549
  if __name__ == "__main__":
2469
2550
  app()
@@ -531,6 +531,15 @@ class DatacubeRegistry:
531
531
  or self.config.get("class_suffix")
532
532
  or self.params.get("class_suffix", "Dc")
533
533
  )
534
+ self._enforce_custom_names()
535
+
536
+ def _enforce_custom_names(self) -> None:
537
+ """
538
+ Ensures that if custom_name is set, it overrides class_name explicitly.
539
+ """
540
+ for table, meta in self.tables.items():
541
+ if meta.get("custom_name"):
542
+ meta["class_name"] = meta["custom_name"]
534
543
 
535
544
  def get_table_details(self, table_name: str) -> dict[str, Any]:
536
545
  return self.tables.get(table_name, {})
@@ -573,7 +582,10 @@ class DatacubeRegistry:
573
582
  elif k not in existing:
574
583
  existing[k] = v
575
584
 
576
- if "class_name" not in existing:
585
+ # Override class_name if custom_name is set
586
+ if existing.get("custom_name"):
587
+ existing["class_name"] = existing["custom_name"]
588
+ elif "class_name" not in existing:
577
589
  existing["class_name"] = new_details.get("class_name")
578
590
 
579
591
  self.tables[table] = existing
@@ -554,8 +554,23 @@ class DiscoveryOrchestrator:
554
554
  "[yellow]Prune active: Registry replaced with discovery results.[/]"
555
555
  )
556
556
  else:
557
- # Merge: Update existing keys, add new ones.
558
- current_data["tables"].update(new_entries)
557
+ # Smart Merge: Preserve 'custom_name' and 'class_name' from existing entries
558
+ # if they are not explicitly overridden by the new entry.
559
+ for table, new_meta in new_entries.items():
560
+ if table in current_data["tables"]:
561
+ existing = current_data["tables"][table]
562
+
563
+ # 1. Preserve custom_name if new entry doesn't specify one
564
+ if not new_meta.get("custom_name") and existing.get("custom_name"):
565
+ new_meta["custom_name"] = existing["custom_name"]
566
+
567
+ # 2. Also preserve class_name (since it's driven by custom_name)
568
+ # We only preserve class_name if custom_name was preserved
569
+ # AND new entry didn't explicitly change class_name logic (unlikely unless configured)
570
+ if existing.get("class_name"):
571
+ new_meta["class_name"] = existing["class_name"]
572
+
573
+ current_data["tables"][table] = new_meta
559
574
 
560
575
  # Sort tables for readability
561
576
  current_data["tables"] = dict(sorted(current_data["tables"].items()))
sibi_flux/init/app.py ADDED
@@ -0,0 +1,111 @@
1
+
2
+ from pathlib import Path
3
+ import os
4
+ from rich.console import Console
5
+ import typer
6
+ import yaml
7
+
8
+ console = Console()
9
+
10
+ def init_app(name: str) -> None:
11
+ """
12
+ Initialize a new application within the current project root.
13
+
14
+ Args:
15
+ name: The name of the application to create (e.g., 'inventory', 'pricing').
16
+ """
17
+
18
+ # 1. Validation: Ensure we are in a Sibi Flux project root (check for pyproject.toml as heuristic)
19
+ cwd = Path(os.getcwd())
20
+ if not (cwd / "pyproject.toml").exists():
21
+ console.print("[yellow]Warning: pyproject.toml not found. Are you in a project root?[/yellow]")
22
+
23
+ app_dir = cwd / name
24
+
25
+ if app_dir.exists():
26
+ console.print(f"[red]Error: Application directory '{name}' already exists.[/red]")
27
+ raise typer.Exit(code=1)
28
+
29
+ console.print(f"[bold blue]Initializing new application: {name}[/bold blue]")
30
+
31
+ # 2. Create Directory Structure
32
+ structure = [
33
+ "api",
34
+ "datacubes",
35
+ "readers",
36
+ "aggregators",
37
+ ]
38
+
39
+ app_dir.mkdir()
40
+ (app_dir / "__init__.py").touch()
41
+
42
+ for folder in structure:
43
+ path = app_dir / folder
44
+ path.mkdir()
45
+ (path / "__init__.py").touch()
46
+
47
+ # 2.1 Create datacubes extension registry template
48
+ datacubes_yaml = app_dir / "datacubes" / "datacubes.yaml"
49
+ template_yaml = """
50
+ cubes:
51
+ # Define your app-specific datacube extensions here
52
+ # - source: dataobjects.gencubes.ibis_dev.products.products_cubes.ProductsDc
53
+ # name: LogisticsProductsDc
54
+ # module: products # -> logistics/datacubes/products.py
55
+ """
56
+ datacubes_yaml.write_text(template_yaml.strip())
57
+
58
+ # 3. Create Basic Router Template
59
+ router_template = f"""
60
+ from fastapi import APIRouter
61
+
62
+ router = APIRouter()
63
+
64
+ @router.get("/")
65
+ async def root():
66
+ return {{"message": "Hello from {name}!"}}
67
+ """
68
+ (app_dir / "api" / "main.py").write_text(router_template.strip())
69
+
70
+ # 4. Register in conf/apps.yaml
71
+ conf_dir = cwd / "conf"
72
+ if not conf_dir.exists():
73
+ conf_dir.mkdir()
74
+
75
+ apps_yaml_path = conf_dir / "apps.yaml"
76
+
77
+ apps_data = {"apps": []}
78
+ if apps_yaml_path.exists():
79
+ try:
80
+ with open(apps_yaml_path, "r") as f:
81
+ loaded = yaml.safe_load(f)
82
+ if loaded and isinstance(loaded, dict) and "apps" in loaded:
83
+ apps_data = loaded
84
+ elif loaded is None:
85
+ pass # empty file
86
+ else:
87
+ console.print(f"[yellow]Warning: conf/apps.yaml has unexpected structure. Initializing with empty list.[/yellow]")
88
+ except Exception as e:
89
+ console.print(f"[yellow]Warning: Could not read existing apps.yaml: {e}[/yellow]")
90
+
91
+ if name not in apps_data["apps"]:
92
+ apps_data["apps"].append(name)
93
+ with open(apps_yaml_path, "w") as f:
94
+ yaml.dump(apps_data, f, default_flow_style=False)
95
+ console.print(f"[green]Registered '{name}' in conf/apps.yaml[/green]")
96
+ else:
97
+ console.print(f"[yellow]App '{name}' already registered in conf/apps.yaml[/yellow]")
98
+
99
+ # 5. Success Message & Instructions
100
+ console.print(f"[bold green]Successfully created application '{name}'![/bold green]")
101
+ console.print(f"Location: {name}/")
102
+ console.print("\n[yellow]Next Steps:[/yellow]")
103
+ console.print(f"1. Register your new router in [bold]main.py[/bold] (or wherever your app is defined):")
104
+
105
+ code_snippet = f"""
106
+ from {name}.api.main import router as {name}_router
107
+
108
+ app.include_router({name}_router, prefix="/{name}", tags=["{name}"])
109
+ """
110
+ console.print(code_snippet)
111
+
@@ -0,0 +1,149 @@
1
+
2
+ import yaml
3
+ from pathlib import Path
4
+ import os
5
+ from typing import List, Dict, Any
6
+ from rich.console import Console
7
+ import typer
8
+ import importlib.util
9
+ import sys
10
+
11
+ console = Console()
12
+
13
+ def create_cubes(app_name: str) -> None:
14
+ """
15
+ Generate app-specific Datacube extensions based on local configuration.
16
+
17
+ Args:
18
+ app_name: The name of the application (must exist in project root).
19
+ """
20
+ cwd = Path(os.getcwd())
21
+
22
+ # 1. Validate App Directory
23
+ app_dir = cwd / app_name
24
+ if not app_dir.exists() or not app_dir.is_dir():
25
+ console.print(f"[red]Error: Application directory '{app_name}' not found.[/red]")
26
+ raise typer.Exit(code=1)
27
+
28
+ datacubes_dir = app_dir / "datacubes"
29
+ if not datacubes_dir.exists():
30
+ console.print(f"[yellow]Creating 'datacubes' directory for {app_name}...[/yellow]")
31
+ datacubes_dir.mkdir(parents=True, exist_ok=True)
32
+ (datacubes_dir / "__init__.py").touch()
33
+
34
+ # 2. Load Registry (datacubes.yaml)
35
+ registry_path = datacubes_dir / "datacubes.yaml"
36
+ if not registry_path.exists():
37
+ console.print(f"[red]Error: Registry file not found at {registry_path}[/red]")
38
+ console.print("Please define your extensions in this file first.")
39
+ # Create a template if missing?
40
+ template = """
41
+ cubes:
42
+ # - source: dataobjects.gencubes.ibis_dev.products.products_cubes.ProductsDc
43
+ # name: LogisticsProductsDc
44
+ # module: products
45
+ """
46
+ registry_path.write_text(template.strip())
47
+ console.print(f"[green]Created template at {registry_path}. Edit it and run again.[/green]")
48
+ raise typer.Exit(code=1)
49
+
50
+ try:
51
+ with open(registry_path, "r") as f:
52
+ config = yaml.safe_load(f)
53
+ except Exception as e:
54
+ console.print(f"[red]Error parsing {registry_path}: {e}[/red]")
55
+ raise typer.Exit(code=1)
56
+
57
+ if not config or "cubes" not in config or not isinstance(config["cubes"], list):
58
+ console.print(f"[yellow]Warning: No 'cubes' list found in {registry_path}.[/yellow]")
59
+ return
60
+
61
+ # 3. Group by Module
62
+ from collections import defaultdict
63
+ cubes_by_module = defaultdict(list)
64
+ for entry in config["cubes"]:
65
+ mod = entry.get("module")
66
+ if mod:
67
+ cubes_by_module[mod].append(entry)
68
+
69
+ console.print(f"[bold blue]Processing {len(config['cubes'])} datacube extensions across {len(cubes_by_module)} modules...[/bold blue]")
70
+
71
+ for module_name, entries in cubes_by_module.items():
72
+ process_module_group(module_name, entries, datacubes_dir, app_name)
73
+
74
+
75
+ def process_module_group(module_name: str, entries: List[Dict[str, Any]], datacubes_dir: Path, app_name: str) -> None:
76
+ target_file = datacubes_dir / f"{module_name}.py"
77
+
78
+ # Read existing content if file exists
79
+ existing_content = ""
80
+ if target_file.exists():
81
+ existing_content = target_file.read_text()
82
+
83
+ new_imports = set()
84
+ new_classes = []
85
+
86
+ for entry in entries:
87
+ source_path = entry.get("source")
88
+ class_name = entry.get("name")
89
+
90
+ if not all([source_path, class_name]):
91
+ console.print(f"[red]Skipping invalid entry in {module_name}: {entry}[/red]")
92
+ continue
93
+
94
+ # Check for idempotency: does class definition already exist?
95
+ if f"class {class_name}" in existing_content:
96
+ console.print(f"[dim]Skipping {class_name} (already exists in {module_name}.py)[/dim]")
97
+ continue
98
+
99
+ # Parse Source
100
+ try:
101
+ source_module_str, source_class = source_path.rsplit(".", 1)
102
+ except ValueError:
103
+ console.print(f"[red]Invalid source format '{source_path}' for {class_name}[/red]")
104
+ continue
105
+
106
+ # Prepare Import
107
+ import_stmt = f"from {source_module_str} import {source_class}"
108
+
109
+ # Check if import exists in file or is already queued
110
+ if import_stmt not in existing_content:
111
+ new_imports.add(import_stmt)
112
+
113
+ # Prepare Class
114
+ class_code = f"""
115
+ class {class_name}({source_class}):
116
+ \"\"\"
117
+ App-specific extension of {source_class} for '{app_name}'.
118
+
119
+ Source: {source_path}
120
+ \"\"\"
121
+ pass
122
+ """
123
+ new_classes.append(class_code.strip())
124
+ console.print(f"[green]Queued generation for {class_name}[/green]")
125
+
126
+ if new_classes:
127
+ mode = "a" if target_file.exists() else "w"
128
+
129
+ content_parts = []
130
+
131
+ # Add imports first
132
+ if new_imports:
133
+ sorted_imports = sorted(list(new_imports))
134
+ content_parts.extend(sorted_imports)
135
+ content_parts.append("") # Spacer
136
+
137
+ # Add classes
138
+ content_parts.extend(new_classes)
139
+
140
+ with open(target_file, mode) as f:
141
+ if mode == "a" and existing_content.strip():
142
+ f.write("\n\n") # Ensure separation from previous content
143
+
144
+ f.write("\n\n".join(content_parts))
145
+ f.write("\n")
146
+
147
+ console.print(f"[green]Updated {target_file.name} with {len(new_classes)} new classes.[/green]")
148
+ else:
149
+ console.print(f"[dim]No changes needed for {target_file.name}[/dim]")
@@ -0,0 +1,134 @@
1
+
2
+ import yaml
3
+ from pathlib import Path
4
+ import os
5
+ from typing import List, Dict, Any
6
+ from rich.console import Console
7
+ import typer
8
+ import sys
9
+
10
+ console = Console()
11
+
12
+ def propose_cubes(db_domain: str, app_name: str) -> None:
13
+ """
14
+ Scans the global datacube registry for cubes matching 'db_domain'
15
+ and adds them to the app's datacubes.yaml.
16
+
17
+ Args:
18
+ db_domain: The database domain / folder name to filter by (e.g., 'ibis_dev', 'istmo360n').
19
+ app_name: The name of the target application.
20
+ """
21
+ cwd = Path(os.getcwd())
22
+
23
+ # 1. Validate App Directory
24
+ app_dir = cwd / app_name
25
+ if not app_dir.exists() or not app_dir.is_dir():
26
+ console.print(f"[red]Error: Application directory '{app_name}' not found.[/red]")
27
+ raise typer.Exit(code=1)
28
+
29
+ datacubes_dir = app_dir / "datacubes"
30
+ datacubes_dir.mkdir(parents=True, exist_ok=True) # Ensure exists
31
+
32
+ registry_path = datacubes_dir / "datacubes.yaml"
33
+
34
+ # 2. Locate Global Registry
35
+ # Heuristic: dataobjects/globals/datacube_registry.yaml
36
+ global_reg_path = cwd / "dataobjects/globals/datacube_registry.yaml"
37
+
38
+ if not global_reg_path.exists():
39
+ console.print(f"[red]Error: Global registry not found at {global_reg_path}[/red]")
40
+ console.print("Run 'uv run sibi-flux dc sync' first to generate the registry.")
41
+ raise typer.Exit(code=1)
42
+
43
+ try:
44
+ with open(global_reg_path, "r") as f:
45
+ global_data = yaml.safe_load(f)
46
+ except Exception as e:
47
+ console.print(f"[red]Error parsing global registry: {e}[/red]")
48
+ raise typer.Exit(code=1)
49
+
50
+ if not global_data:
51
+ console.print("[yellow]Global registry is empty.[/yellow]")
52
+ return
53
+
54
+ # 3. Find Matches
55
+ matches = []
56
+
57
+ # Registry Structure: {conf_obj: {table_name: {class_name: ..., path: ...}}}
58
+ for conf_obj, tables in global_data.items():
59
+ for table_name, meta in tables.items():
60
+ path_str = meta.get("path", "")
61
+ class_name = meta.get("class_name")
62
+
63
+ # Check if domain matches path logic
64
+ # e.g. path="dataobjects/gencubes/istmo360n/..." and db_domain="istmo360n"
65
+ if db_domain in path_str.split("/"):
66
+ # Clean source path for python import
67
+ # "dataobjects/gencubes/..." -> "dataobjects.gencubes...."
68
+ # remove extension .py
69
+ module_path = path_str.replace("/", ".").rstrip(".py")
70
+ source_path = f"{module_path}.{class_name}"
71
+
72
+ # Determine target module name (the last part of the path directory?)
73
+ # e.g. .../biometric/biometric_cubes.py -> "biometric"
74
+ try:
75
+ # heuristic: parent dir name or filename base?
76
+ # path: dataobjects/gencubes/istmo360n/biometric/biometric_cubes.py
77
+ # logical group: biometric
78
+
79
+ p = Path(path_str)
80
+ module_group = p.parent.name # biometric
81
+ if module_group == db_domain:
82
+ # fallback if nested directly under domain
83
+ module_group = p.stem.replace("_cubes", "")
84
+
85
+ except Exception:
86
+ module_group = "common"
87
+
88
+ matches.append({
89
+ "source": source_path,
90
+ "name": f"{app_name.capitalize()}{class_name}", # Default naming: App + Class
91
+ "module": module_group,
92
+ "original_class": class_name
93
+ })
94
+
95
+ if not matches:
96
+ console.print(f"[yellow]No datacubes found for domain '{db_domain}' in registry.[/yellow]")
97
+ return
98
+
99
+ console.print(f"[green]Found {len(matches)} matching datacubes.[/green]")
100
+
101
+ # 4. Update App Registry
102
+
103
+ # Load existing
104
+ current_config = {"cubes": []}
105
+ if registry_path.exists():
106
+ try:
107
+ with open(registry_path, "r") as f:
108
+ loaded = yaml.safe_load(f)
109
+ if loaded and "cubes" in loaded:
110
+ current_config = loaded
111
+ except Exception:
112
+ pass
113
+
114
+ # Deduplicate
115
+ existing_sources = {c.get("source") for c in current_config["cubes"]}
116
+ added_count = 0
117
+
118
+ for m in matches:
119
+ if m["source"] not in existing_sources:
120
+ entry = {
121
+ "source": m["source"],
122
+ "name": m["name"],
123
+ "module": m["module"]
124
+ }
125
+ current_config["cubes"].append(entry)
126
+ existing_sources.add(m["source"])
127
+ added_count += 1
128
+
129
+ # Save
130
+ with open(registry_path, "w") as f:
131
+ yaml.dump(current_config, f, sort_keys=False)
132
+
133
+ console.print(f"[bold green]Added {added_count} new entries to {registry_path}[/bold green]")
134
+ console.print(f"Next: Run [blue]uv run sibi-flux create-cubes {app_name}[/blue] to generate code.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sibi-flux
3
- Version: 2026.1.5
3
+ Version: 2026.1.7
4
4
  Summary: Sibi Toolkit: A collection of tools for Data Analysis/Engineering.
5
5
  Author: Luis Valverde
6
6
  Author-email: Luis Valverde <lvalverdeb@gmail.com>
@@ -52,30 +52,10 @@ Description-Content-Type: text/markdown
52
52
 
53
53
  **SibiFlux** is a production-grade resilient data engineering ecosystem designed to bridge the gap between local development, distributed computing, and agentic AI workflows. It provides a unified engine for hybrid data loading (batch + streaming), self-healing distributed operations, and native interfaces for AI agents via the Model Context Protocol (MCP).
54
54
 
55
- ```mermaid
56
- graph TD
57
- subgraph G1["Agentic Interface (MCP)"]
58
- Agent["AI Agent / Claude"] <--> Router["MCP Router"]
59
- Router <--> Resources["SibiFlux Resources"]
60
- end
61
-
62
- subgraph G2["Solutions Layer (Business Logic)"]
63
- Logistics["Logistics Solutions"]
64
- Enrichment["Enrichment Pipelines"]
65
- Cubes["DataCubes"]
66
- end
67
-
68
- subgraph G3["SibiFlux Core Engine"]
69
- DfHelper["DfHelper (Unified Loader)"]
70
- Cluster["Resilient Dask Cluster"]
71
- Managed["ManagedResource Lifecycle"]
72
- end
73
-
74
- Resources --> Cubes
75
- Logistics --> DfHelper
76
- Cubes --> DfHelper
77
- DfHelper --> Cluster
78
- ```
55
+
56
+
57
+ ## Documentation
58
+ Full documentation is available in [src/docs/index.md](src/docs/index.md).
79
59
 
80
60
  ## Core Architecture
81
61
 
@@ -7,7 +7,7 @@ sibi_flux/artifacts/parquet_engine/__init__.py,sha256=L9OBs-v6HMTZ-_7hrzr7J_oYF-
7
7
  sibi_flux/artifacts/parquet_engine/executor.py,sha256=PZt-xoASMfJjrfC6Dpks9mC4ptJVj2Le28Xl6MKqVU8,6887
8
8
  sibi_flux/artifacts/parquet_engine/manifest.py,sha256=AoeIUFgLPfzvxmchExZIJ_gKP1wnLfYBKWbCYG5XYKk,3324
9
9
  sibi_flux/artifacts/parquet_engine/planner.py,sha256=c_oWfZKMvtpyKH5v-UYlJRRFidRrZR5nW4YquMyRGCs,21640
10
- sibi_flux/cli.py,sha256=W3REv_qKGKNXt2c2iwI-o0jpewdtQJsr6RZASKV4rtk,1794
10
+ sibi_flux/cli.py,sha256=mZazvWBwMnHcvCHtit5cAFNDTukNnPVZHaqg5GRpHxU,2871
11
11
  sibi_flux/config/__init__.py,sha256=fVgxXW03kzHuVo1zXQkLJoXg6Q6yiooRqujii3L817w,64
12
12
  sibi_flux/config/manager.py,sha256=aiGgkwr1maYCwprt_vjdCpfGSycawBCERAjY3jn6xS4,5093
13
13
  sibi_flux/config/settings.py,sha256=F0-Y4Mqwwt5ZOcmFtcPfsXj-tSUZJPzy6LER83nZA20,4560
@@ -23,13 +23,13 @@ sibi_flux/dask_cluster/exceptions.py,sha256=apQZaUMgac8k2ZTTsvUd-VlWdo9-Nrh5b6St
23
23
  sibi_flux/dask_cluster/utils.py,sha256=Pr2qaow6GVyvM0hqKSM0ZQpe2Ot5ayfGYQiNhNpYA8Y,1342
24
24
  sibi_flux/datacube/__init__.py,sha256=ODEoa4r5RtzynIp-PdVDaJ-4BcPBj1L9VkLIF7RYSPE,91
25
25
  sibi_flux/datacube/_data_cube.py,sha256=Ofgy3JlR7N0eKijpUzI-ixFlISUd3CFsxnKd6a4fguE,12629
26
- sibi_flux/datacube/cli.py,sha256=mQwyj-NmjkKwDRXkJOZeXB6VDHGXGvmHnqbuqOCiYfI,92637
26
+ sibi_flux/datacube/cli.py,sha256=ss-inKDZe1kk3lWmUVx-bNZgd3eiAEEI0a9EiOHEIjI,96506
27
27
  sibi_flux/datacube/config_engine.py,sha256=3cmxycCqMzjYJ1fWn8IWrHTNwSQmRqxnPoOzc1lUtDk,8340
28
28
  sibi_flux/datacube/field_factory.py,sha256=Z3Yp6tGzrZ13rvKSjMFr9jvW7fazeNi4K1fAalxLujM,6376
29
29
  sibi_flux/datacube/field_mapper.py,sha256=V6aFYunl28DI7gSvrF7tcidPNX9QtOYymVxbumzQqPs,9334
30
30
  sibi_flux/datacube/field_registry.py,sha256=VBTqxNIn2-eWMiZi1oQK-719oyJfn3yhfV7Bz_0iNyU,4355
31
- sibi_flux/datacube/generator.py,sha256=DECeAbee4nQHDQsrpBYo6FiQERXHQ7XDmOs86abd9mY,36432
32
- sibi_flux/datacube/orchestrator.py,sha256=37iWkqY9ShMkjFqYhpb3dpioYX2GXOTyo5p52cO5t7I,24278
31
+ sibi_flux/datacube/generator.py,sha256=gPGQW114N7jfyGwsSXc-jsJup_C4qSj75oM_CoWuP7Y,36943
32
+ sibi_flux/datacube/orchestrator.py,sha256=ZS2kh1Q6FF1sPtDaTkIvo8Q2S33OrYogjOWDl15-u-k,25254
33
33
  sibi_flux/datacube/router.py,sha256=SdG0J4O9CSZeUkHjyWHFC8B3oH83fSdDHfMXZfmHfi0,10914
34
34
  sibi_flux/dataset/__init__.py,sha256=zYpf4-nalcQNSUIOCDG0CqQu2SKkm89I9AF6Zy6z1sE,53
35
35
  sibi_flux/dataset/_dataset.py,sha256=DDAshH3QjgFDaXp9E75nkKy8_Ft-Z2zP_chmJQTU9hg,6484
@@ -65,7 +65,10 @@ sibi_flux/df_helper/core/_query_config.py,sha256=bS_k0qmvom1uVhkcJngPV2pc9pOhQ0F
65
65
  sibi_flux/df_validator/__init__.py,sha256=lkgTVmeCmH0yqfpZ4oPrRMZS8Jy9lm4OF7DjTSWbgTY,66
66
66
  sibi_flux/df_validator/_df_validator.py,sha256=P8ARf0htKo7L9PMcEjTnobpgT4RsGOiuG81tVEntS4o,8936
67
67
  sibi_flux/init/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
68
+ sibi_flux/init/app.py,sha256=Ofx_Sowvv6Ri-tDhLYkYllB_e1-4227J-MqhURTAmhI,3678
68
69
  sibi_flux/init/core.py,sha256=ahl4wVAyOrl1ko-f2NcYxeX9yQBQceYnobqeH_Gfp98,7785
70
+ sibi_flux/init/cube_extender.py,sha256=wQPM0zncr97AiGCVM2BYo7dyaLKWIndV-0GvGNXvAMA,5269
71
+ sibi_flux/init/cube_proposer.py,sha256=VA7WF-pDtto9-UdN1IlB7u6TiuxgMbgy79TRooyBPro,5071
69
72
  sibi_flux/init/discovery_updater.py,sha256=3WONU50EnaIItaLncumd3hC6h059ykvoqMfRC5NAvi8,5652
70
73
  sibi_flux/init/env.py,sha256=WMczkJqdaCejSbkjcpW8a44GZdKicdh5GVfUqKYltBI,3792
71
74
  sibi_flux/init/env_engine.py,sha256=HYBik80qgp-_2RgnQY2edaKA30VY1k_3uAZ4TqIID2M,6247
@@ -120,7 +123,7 @@ sibi_flux/utils/file_utils.py,sha256=7OHUW65OTe6HlQ6wkDagDd7d0SCQ_-NEGmHlOJguKYw
120
123
  sibi_flux/utils/filepath_generator/__init__.py,sha256=YVFJhIewjwksb9E2t43ojNC-W_AqUDhkKxQVBIBMkY8,91
121
124
  sibi_flux/utils/filepath_generator/_filepath_generator.py,sha256=4HG-Ubvjtv6luL0z-A-8B6_r3o9YqBwATFXhOXiTbKc,6789
122
125
  sibi_flux/utils/retry.py,sha256=45t0MF2IoMayN9xkn5_FtakMq4HwZlGvHVd6qv8x1AY,1227
123
- sibi_flux-2026.1.5.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
124
- sibi_flux-2026.1.5.dist-info/entry_points.txt,sha256=6xrq5zuz_8wodJj4s49raopnuC3Owy_leZRkWtcXpTk,49
125
- sibi_flux-2026.1.5.dist-info/METADATA,sha256=sDMs7R9mWnNRg2O-0l3Au2bf9DCu9IGmMLE6t4VaoXw,10434
126
- sibi_flux-2026.1.5.dist-info/RECORD,,
126
+ sibi_flux-2026.1.7.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
127
+ sibi_flux-2026.1.7.dist-info/entry_points.txt,sha256=6xrq5zuz_8wodJj4s49raopnuC3Owy_leZRkWtcXpTk,49
128
+ sibi_flux-2026.1.7.dist-info/METADATA,sha256=ipSzbUZvavinUGwi79uEO6Yiy0ON172K8WwiTRouVmw,9886
129
+ sibi_flux-2026.1.7.dist-info/RECORD,,