civex 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. civex/__init__.py +6 -0
  2. civex/cli/__init__.py +0 -0
  3. civex/cli/_docker.py +157 -0
  4. civex/cli/auth.py +112 -0
  5. civex/cli/clone.py +93 -0
  6. civex/cli/dataset.py +165 -0
  7. civex/cli/db.py +511 -0
  8. civex/cli/dump.py +227 -0
  9. civex/cli/init.py +126 -0
  10. civex/cli/log.py +142 -0
  11. civex/cli/plugin.py +19 -0
  12. civex/cli/plumbing.py +114 -0
  13. civex/cli/record.py +227 -0
  14. civex/cli/remote.py +62 -0
  15. civex/cli/resolve.py +35 -0
  16. civex/cli/schema.py +315 -0
  17. civex/cli/shell.py +56 -0
  18. civex/cli/status.py +43 -0
  19. civex/cli/store.py +137 -0
  20. civex/cli/sync.py +76 -0
  21. civex/cli/utils.py +84 -0
  22. civex/cli/worker.py +120 -0
  23. civex/cli/workflow.py +160 -0
  24. civex/config.py +229 -0
  25. civex/console.py +9 -0
  26. civex/context.py +140 -0
  27. civex/db/__init__.py +0 -0
  28. civex/db/models.py +207 -0
  29. civex/db/session.py +24 -0
  30. civex/desktop/__init__.py +0 -0
  31. civex/desktop/cli_entry.py +5 -0
  32. civex/desktop/tray.py +472 -0
  33. civex/domain/__init__.py +0 -0
  34. civex/domain/dtos.py +269 -0
  35. civex/domain/exceptions.py +44 -0
  36. civex/main.py +89 -0
  37. civex/plugins/__init__.py +0 -0
  38. civex/plugins/base.py +54 -0
  39. civex/plugins/builtins/__init__.py +0 -0
  40. civex/plugins/builtins/create_records_from_files.py +49 -0
  41. civex/plugins/builtins/extract_from_filename.py +89 -0
  42. civex/plugins/builtins/get_field.py +20 -0
  43. civex/plugins/builtins/load_csv.py +32 -0
  44. civex/plugins/builtins/load_file.py +31 -0
  45. civex/plugins/builtins/load_file_list.py +29 -0
  46. civex/plugins/builtins/match_files_to_records.py +98 -0
  47. civex/plugins/builtins/rows_to_records.py +54 -0
  48. civex/plugins/builtins/save_field.py +23 -0
  49. civex/plugins/builtins/save_fields.py +28 -0
  50. civex/plugins/builtins/upsert_records.py +68 -0
  51. civex/plugins/registry.py +65 -0
  52. civex/project.py +38 -0
  53. civex/repositories/__init__.py +0 -0
  54. civex/repositories/local/__init__.py +0 -0
  55. civex/repositories/local/audit_repo.py +203 -0
  56. civex/repositories/local/dataset_repo.py +65 -0
  57. civex/repositories/local/file_store.py +216 -0
  58. civex/repositories/local/job_repo.py +122 -0
  59. civex/repositories/local/record_repo.py +189 -0
  60. civex/repositories/local/schema_repo.py +136 -0
  61. civex/repositories/protocols.py +124 -0
  62. civex/server/__init__.py +0 -0
  63. civex/server/app.py +88 -0
  64. civex/server/background.py +76 -0
  65. civex/server/deps.py +26 -0
  66. civex/server/models.py +243 -0
  67. civex/server/routers/__init__.py +0 -0
  68. civex/server/routers/datasets.py +91 -0
  69. civex/server/routers/dump.py +207 -0
  70. civex/server/routers/files.py +29 -0
  71. civex/server/routers/jobs.py +77 -0
  72. civex/server/routers/plugins.py +78 -0
  73. civex/server/routers/records.py +150 -0
  74. civex/server/routers/remote.py +78 -0
  75. civex/server/routers/schemas.py +116 -0
  76. civex/server/routers/store.py +71 -0
  77. civex/server/routers/terminal.py +105 -0
  78. civex/server/routers/ui.py +360 -0
  79. civex/server/routers/workflows.py +180 -0
  80. civex/server/static/.gitkeep +0 -0
  81. civex/server/static/assets/index-BUBS2aN1.css +32 -0
  82. civex/server/static/assets/index-CWSVJAC7.js +129 -0
  83. civex/server/static/index.html +13 -0
  84. civex/server/templates/_jobs_table.html +53 -0
  85. civex/server/templates/base.html +60 -0
  86. civex/server/templates/dataset_detail.html +108 -0
  87. civex/server/templates/datasets.html +56 -0
  88. civex/server/templates/index.html +28 -0
  89. civex/server/templates/jobs.html +12 -0
  90. civex/server/templates/record_detail.html +108 -0
  91. civex/server/templates/schema_detail.html +74 -0
  92. civex/server/templates/schemas.html +58 -0
  93. civex/server/templates/workflows.html +54 -0
  94. civex/services/__init__.py +0 -0
  95. civex/services/dataset_service.py +50 -0
  96. civex/services/file_service.py +48 -0
  97. civex/services/helpers/coercion.py +0 -0
  98. civex/services/record_service.py +463 -0
  99. civex/services/schema_service.py +161 -0
  100. civex/services/store_service.py +78 -0
  101. civex/services/sync_service.py +130 -0
  102. civex/services/workflow_job_service.py +96 -0
  103. civex/sync/__init__.py +14 -0
  104. civex/sync/bundle.py +67 -0
  105. civex/sync/exporter.py +149 -0
  106. civex/sync/importer.py +178 -0
  107. civex/sync/transport.py +241 -0
  108. civex/workflows/__init__.py +0 -0
  109. civex/workflows/definition.py +48 -0
  110. civex/workflows/executor.py +107 -0
  111. civex-1.0.0.dist-info/METADATA +660 -0
  112. civex-1.0.0.dist-info/RECORD +118 -0
  113. civex-1.0.0.dist-info/WHEEL +5 -0
  114. civex-1.0.0.dist-info/entry_points.txt +3 -0
  115. civex-1.0.0.dist-info/licenses/LICENSE +37 -0
  116. civex-1.0.0.dist-info/scm_file_list.json +1839 -0
  117. civex-1.0.0.dist-info/scm_version.json +8 -0
  118. civex-1.0.0.dist-info/top_level.txt +1 -0
civex/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ __version__ = version("civex")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0.dev"
civex/cli/__init__.py ADDED
File without changes
civex/cli/_docker.py ADDED
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import socket
5
+ import subprocess
6
+ import time
7
+
8
+ from civex.console import console
9
+
10
+
11
+ def docker_available() -> bool:
12
+ """Return True if Docker is installed and the current user can reach the socket."""
13
+ try:
14
+ result = subprocess.run(
15
+ ["docker", "info"],
16
+ capture_output=True,
17
+ timeout=15,
18
+ )
19
+ return result.returncode == 0
20
+ except FileNotFoundError:
21
+ return False
22
+ except subprocess.TimeoutExpired:
23
+ return False
24
+
25
+
26
+ def docker_error_hint() -> str:
27
+ """Return a human-readable reason why Docker isn't usable."""
28
+ try:
29
+ result = subprocess.run(
30
+ ["docker", "info"],
31
+ capture_output=True,
32
+ text=True,
33
+ timeout=15,
34
+ )
35
+ if result.returncode == 0:
36
+ return ""
37
+ combined = (result.stdout + result.stderr).lower()
38
+ if "permission denied" in combined and "docker.sock" in combined:
39
+ import getpass
40
+ user = getpass.getuser()
41
+ return (
42
+ f"Permission denied on the Docker socket.\n"
43
+ f" Fix: sudo usermod -aG docker {user}\n"
44
+ f" Then: newgrp docker (or log out and back in)"
45
+ )
46
+ if "cannot connect" in combined or "is the docker daemon running" in combined:
47
+ return "Docker daemon is not running. Start Docker Desktop or run: sudo systemctl start docker"
48
+ return result.stderr.strip() or "docker info returned a non-zero exit code"
49
+ except FileNotFoundError:
50
+ return "Docker is not installed. See https://www.docker.com/products/docker-desktop"
51
+ except subprocess.TimeoutExpired:
52
+ return "docker info timed out — Docker may be starting up, try again in a moment"
53
+
54
+
55
+ def container_name(project_name: str) -> str:
56
+ safe = re.sub(r"[^a-zA-Z0-9_-]", "-", project_name).strip("-")
57
+ return f"civex-{safe or 'project'}"
58
+
59
+
60
+ def find_free_port(preferred: int = 5432) -> int:
61
+ try:
62
+ s = socket.socket()
63
+ s.bind(("", preferred))
64
+ s.close()
65
+ return preferred
66
+ except OSError:
67
+ s = socket.socket()
68
+ s.bind(("", 0))
69
+ port = s.getsockname()[1]
70
+ s.close()
71
+ return port
72
+
73
+
74
+ def start_pg_container(name: str, port: int) -> tuple[bool, str]:
75
+ """Start or create the civex postgres container. Returns (ok, error_message)."""
76
+ # Reuse existing container if it already exists
77
+ inspect = subprocess.run(
78
+ ["docker", "inspect", "--format", "{{.State.Status}}", name],
79
+ capture_output=True,
80
+ text=True,
81
+ )
82
+ if inspect.returncode == 0:
83
+ status = inspect.stdout.strip()
84
+ if status != "running":
85
+ subprocess.run(["docker", "start", name], capture_output=True)
86
+ return True, ""
87
+
88
+ result = subprocess.run(
89
+ [
90
+ "docker", "run", "-d",
91
+ "--name", name,
92
+ "-p", f"{port}:5432",
93
+ "-e", "POSTGRES_HOST_AUTH_METHOD=trust",
94
+ "-e", "POSTGRES_DB=civex",
95
+ "-v", f"{name}-pgdata:/var/lib/postgresql/data",
96
+ "--restart", "unless-stopped",
97
+ "postgres:16",
98
+ ],
99
+ capture_output=True,
100
+ text=True,
101
+ )
102
+ if result.returncode != 0:
103
+ return False, result.stderr.strip()
104
+ return True, ""
105
+
106
+
107
+ def wait_for_postgres(port: int, timeout: int = 60) -> bool:
108
+ deadline = time.time() + timeout
109
+ while time.time() < deadline:
110
+ try:
111
+ result = subprocess.run(
112
+ ["pg_isready", "-h", "localhost", "-p", str(port), "-q"],
113
+ capture_output=True,
114
+ timeout=3,
115
+ )
116
+ if result.returncode == 0:
117
+ return True
118
+ except (FileNotFoundError, subprocess.TimeoutExpired):
119
+ # pg_isready not available — fall back to raw socket probe
120
+ try:
121
+ s = socket.create_connection(("localhost", port), timeout=1)
122
+ s.close()
123
+ return True
124
+ except OSError:
125
+ pass
126
+ time.sleep(1)
127
+ return False
128
+
129
+
130
+ def setup_docker_postgres(project_name: str) -> str | None:
131
+ """
132
+ Spin up (or reuse) a Docker postgres container for this project.
133
+ Returns a SQLAlchemy URL on success, None on failure.
134
+ """
135
+ name = container_name(project_name)
136
+ port = find_free_port(5432)
137
+
138
+ console.print("\nSetting up PostgreSQL via Docker...")
139
+ console.print(f" Starting container [bold]{name}[/bold]...", end=" ")
140
+
141
+ ok, err = start_pg_container(name, port)
142
+ if not ok:
143
+ console.print("[error]FAILED[/error]")
144
+ console.print(f" [dim]{err}[/dim]")
145
+ return None
146
+ console.print("[success]OK[/success]")
147
+
148
+ console.print(" Waiting for PostgreSQL...", end=" ")
149
+ if not wait_for_postgres(port):
150
+ console.print("[error]timed out[/error]")
151
+ console.print(
152
+ " [dim]Container started but postgres didn't respond within 60s.[/dim]"
153
+ )
154
+ return None
155
+ console.print("[success]OK[/success]")
156
+
157
+ return f"postgresql+psycopg2://postgres@localhost:{port}/civex"
civex/cli/auth.py ADDED
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from civex.console import console
9
+
10
+ app = typer.Typer(help="Authenticate with a civex-hub server.")
11
+
12
+ _TOKENS_PATH = Path.home() / ".civex" / "tokens.toml"
13
+
14
+
15
+ def _read_tokens() -> dict:
16
+ if not _TOKENS_PATH.exists():
17
+ return {}
18
+ with open(_TOKENS_PATH, "rb") as f:
19
+ return tomllib.load(f)
20
+
21
+
22
+ def _write_tokens(data: dict) -> None:
23
+ _TOKENS_PATH.parent.mkdir(parents=True, exist_ok=True)
24
+ lines = []
25
+ for url, entry in data.items():
26
+ lines.append(f'["{url}"]\n')
27
+ for k, v in entry.items():
28
+ lines.append(f'{k} = "{v}"\n')
29
+ lines.append("\n")
30
+ _TOKENS_PATH.write_text("".join(lines))
31
+
32
+
33
+ @app.command("login")
34
+ def login(
35
+ hub_url: str = typer.Argument(..., help="civex-hub base URL (e.g. https://civexhub.example.com)"),
36
+ username: str = typer.Option(None, "--username", "-u", help="Your username"),
37
+ token_name: str = typer.Option("default", "--token-name", help="Label for this token"),
38
+ ) -> None:
39
+ """Log in to a civex-hub server and store an API token."""
40
+ import urllib.request
41
+ import urllib.error
42
+ import json
43
+
44
+ hub_url = hub_url.rstrip("/")
45
+
46
+ if not username:
47
+ username = typer.prompt("Username")
48
+ password = typer.prompt("Password", hide_input=True)
49
+
50
+ payload = json.dumps({"username": username, "password": password, "name": token_name}).encode()
51
+ req = urllib.request.Request(
52
+ f"{hub_url}/auth/tokens",
53
+ data=payload,
54
+ headers={"Content-Type": "application/json"},
55
+ method="POST",
56
+ )
57
+ try:
58
+ with urllib.request.urlopen(req) as resp:
59
+ result = json.loads(resp.read())
60
+ except urllib.error.HTTPError as e:
61
+ body = e.read().decode(errors="replace")
62
+ console.print(f"[red]Login failed ({e.code}):[/red] {body}")
63
+ raise typer.Exit(1)
64
+ except Exception as e:
65
+ console.print(f"[red]Connection error:[/red] {e}")
66
+ raise typer.Exit(1)
67
+
68
+ raw_token = result.get("token")
69
+ if not raw_token:
70
+ console.print("[red]Unexpected response — no token received.[/red]")
71
+ raise typer.Exit(1)
72
+
73
+ tokens = _read_tokens()
74
+ tokens[hub_url] = {"token": raw_token, "username": username}
75
+ _write_tokens(tokens)
76
+
77
+ console.print(f"[green]Logged in to {hub_url} as {username}.[/green]")
78
+ console.print(f" Token saved to {_TOKENS_PATH}")
79
+
80
+
81
+ @app.command("logout")
82
+ def logout(
83
+ hub_url: str = typer.Argument(None, help="Hub URL to log out from (omit to log out of all)"),
84
+ ) -> None:
85
+ """Remove stored credentials for a civex-hub server."""
86
+ tokens = _read_tokens()
87
+ if not tokens:
88
+ console.print("[dim]Not logged in to any hub.[/dim]")
89
+ return
90
+
91
+ if hub_url:
92
+ hub_url = hub_url.rstrip("/")
93
+ if hub_url not in tokens:
94
+ console.print(f"[dim]Not logged in to {hub_url}.[/dim]")
95
+ return
96
+ del tokens[hub_url]
97
+ _write_tokens(tokens)
98
+ console.print(f"[success]Logged out of {hub_url}.[/success]")
99
+ else:
100
+ _write_tokens({})
101
+ console.print("[success]Logged out of all hubs.[/success]")
102
+
103
+
104
+ @app.command("status")
105
+ def status() -> None:
106
+ """Show which civex-hub servers you are logged in to."""
107
+ tokens = _read_tokens()
108
+ if not tokens:
109
+ console.print("[dim]Not logged in to any hub.[/dim]")
110
+ return
111
+ for url, entry in tokens.items():
112
+ console.print(f" {url} ([dim]{entry.get('username', '?')}[/dim])")
civex/cli/clone.py ADDED
@@ -0,0 +1,93 @@
1
+ """
2
+ civex clone <url> [local-dir] [--remote-civex <path>]
3
+
4
+ Clones a bare repository into a new local working directory.
5
+ Database rows are transferred via DTOs (SyncBundle). Objects are NOT downloaded
6
+ immediately — they are fetched on demand when accessed (lazy).
7
+
8
+ Use --remote-civex when civex is not on PATH on the remote (e.g. installed in a venv):
9
+ civex clone ssh://js521/~/civex-test-repo --remote-civex ~/venv/bin/civex
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ import typer
16
+ from sqlalchemy import create_engine
17
+ from sqlalchemy.orm import Session
18
+
19
+ from civex.console import console
20
+ from civex.db.models import Base
21
+ from civex.sync.importer import apply_bundle
22
+ from civex.sync.transport import SyncError, get_transport
23
+
24
+
25
+ def clone(
26
+ url: str = typer.Argument(..., help="Remote URL (ssh://user@host/path or file:///path)"),
27
+ local_dir: Path = typer.Argument(None, help="Destination directory (default: derived from URL)"),
28
+ remote_civex: str = typer.Option(
29
+ "civex",
30
+ "--remote-civex",
31
+ help="Path to the civex executable on the remote (use when civex is in a venv, e.g. ~/venv/bin/civex)",
32
+ ),
33
+ ) -> None:
34
+ """Clone a remote bare repository into a new local project."""
35
+ try:
36
+ transport, _remote_path = get_transport(url, remote_civex=remote_civex)
37
+ except SyncError as e:
38
+ console.print(f"[error]{e}[/error]")
39
+ raise typer.Exit(1)
40
+
41
+ # Derive local directory name from the URL if not specified.
42
+ if local_dir is None:
43
+ last = url.rstrip("/").rsplit("/", 1)[-1]
44
+ if last.endswith(".civex"):
45
+ last = last[: -len(".civex")]
46
+ local_dir = Path(last or "civex-repo")
47
+
48
+ local_dir = local_dir.resolve()
49
+ civex_dir = local_dir / ".civex"
50
+
51
+ if civex_dir.exists():
52
+ console.print(f"[error]Directory {local_dir} is already a civex project.[/error]")
53
+ raise typer.Exit(1)
54
+
55
+ console.print(f"Cloning from [bold]{url}[/bold] into [bold]{local_dir}[/bold] ...")
56
+
57
+ # Fetch the full bundle before creating any local state.
58
+ try:
59
+ bundle = transport.transfer_pack(since_seq=0)
60
+ except SyncError as e:
61
+ console.print(f"[error]Clone failed: {e}[/error]")
62
+ raise typer.Exit(1)
63
+
64
+ # Create local project structure.
65
+ civex_dir.mkdir(parents=True)
66
+ db_path = civex_dir / "civex.db"
67
+ db_url = f"sqlite:///{db_path.as_posix()}"
68
+
69
+ (civex_dir / "objects").mkdir()
70
+ (civex_dir / "workflows").mkdir()
71
+ (civex_dir / "plugins").mkdir()
72
+
73
+ engine = create_engine(db_url)
74
+ Base.metadata.create_all(engine)
75
+
76
+ with Session(engine) as session:
77
+ apply_bundle(session, bundle)
78
+ session.commit()
79
+ engine.dispose()
80
+
81
+ remote_lines = f'[db]\nurl = "{db_url}"\n\n[remote]\nurl = "{url}"\n'
82
+ if remote_civex != "civex":
83
+ remote_lines += f'remote_civex = "{remote_civex}"\n'
84
+ remote_lines += f'last_pulled_seq = {bundle.to_seq}\n'
85
+ remote_lines += 'last_pushed_seq = 0\n'
86
+ (civex_dir / "config.toml").write_text(remote_lines)
87
+
88
+ console.print("[success]Cloned successfully.[/success]")
89
+ console.print(f" Schemas {len(bundle.schemas)}")
90
+ console.print(f" Datasets {len(bundle.datasets)}")
91
+ console.print(f" Records {len(bundle.records)}")
92
+ console.print(f" Objects {len(bundle.object_refs)} available remotely (fetched on demand)")
93
+ console.print(f" Local dir {local_dir}")
civex/cli/dataset.py ADDED
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from collections import Counter
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.table import Table
9
+ from rich.tree import Tree
10
+
11
+ from civex.cli.utils import get_ctx as _ctx
12
+ from civex.console import console
13
+ from civex.domain.dtos import SchemaDTO
14
+ from civex.domain.exceptions import AlreadyExistsError, NotFoundError
15
+
16
+ app = typer.Typer(help="Manage datasets (named containers for studies or investigations)")
17
+
18
+
19
+ @app.command("create")
20
+ def dataset_create(
21
+ name: str = typer.Argument(...),
22
+ description: Optional[str] = typer.Option(None, "--description", "-d"),
23
+ ) -> None:
24
+ """Create a new dataset."""
25
+ ctx = _ctx()
26
+ try:
27
+ dataset = ctx.dataset_svc.create(name, description=description)
28
+ ctx.commit()
29
+ console.print(f"[success]Created dataset '{dataset.name}'.[/success]")
30
+ except AlreadyExistsError as e:
31
+ console.print(f"[error]{e}[/error]")
32
+ raise typer.Exit(1)
33
+
34
+
35
+ @app.command("list")
36
+ def dataset_list() -> None:
37
+ """List all datasets."""
38
+ ctx = _ctx()
39
+ datasets = ctx.dataset_svc.list_all()
40
+ if not datasets:
41
+ console.print("[info]No datasets yet. Use `civex dataset create` to add one.[/info]")
42
+ return
43
+
44
+ table = Table("Name", "Records", "Description")
45
+ for d in datasets:
46
+ table.add_row(d.name, str(d.record_count), d.description or "")
47
+ console.print(table)
48
+
49
+
50
+ @app.command("show")
51
+ def dataset_show(name: str = typer.Argument(...)) -> None:
52
+ """Show a dataset summary with record counts per schema."""
53
+ ctx = _ctx()
54
+ try:
55
+ d = ctx.dataset_svc.get(name)
56
+ except NotFoundError as e:
57
+ console.print(f"[error]{e}[/error]")
58
+ raise typer.Exit(1)
59
+
60
+ console.print(f"[bold]{d.name}[/bold]")
61
+ console.print(f" Records {d.record_count}")
62
+ if d.description:
63
+ console.print(f" {d.description}")
64
+
65
+ if d.record_count > 0:
66
+ records = ctx.record_svc.find(name, schema_name=None, filters=[], limit=100_000)
67
+ counts = Counter(r.schema_name for r in records)
68
+ table = Table("Schema", "Records")
69
+ for schema_name, count in sorted(counts.items()):
70
+ table.add_row(schema_name, str(count))
71
+ console.print(table)
72
+
73
+
74
+ @app.command("update")
75
+ def dataset_update(
76
+ name: str = typer.Argument(...),
77
+ rename: Optional[str] = typer.Option(None, "--rename", help="New name for the dataset"),
78
+ description: Optional[str] = typer.Option(None, "--description", "-d"),
79
+ ) -> None:
80
+ """Update a dataset's name or description."""
81
+ if rename is None and description is None:
82
+ console.print("[error]Provide at least one of --rename or --description.[/error]")
83
+ raise typer.Exit(1)
84
+ ctx = _ctx()
85
+ try:
86
+ dataset = ctx.dataset_svc.update(name, new_name=rename, description=description)
87
+ ctx.commit()
88
+ if rename and rename != name:
89
+ console.print(
90
+ f"[warning]Workflow configs that reference '{name}' by name will need updating.[/warning]"
91
+ )
92
+ console.print(f"[success]Updated dataset '{dataset.name}'.[/success]")
93
+ except (NotFoundError, AlreadyExistsError) as e:
94
+ console.print(f"[error]{e}[/error]")
95
+ raise typer.Exit(1)
96
+
97
+
98
+ @app.command("delete")
99
+ def dataset_delete(
100
+ name: str = typer.Argument(...),
101
+ yes: bool = typer.Option(False, "--yes", "-y"),
102
+ ) -> None:
103
+ """Delete a dataset and all its records."""
104
+ if not yes:
105
+ typer.confirm(f"Delete dataset '{name}' and all its records?", abort=True)
106
+ ctx = _ctx()
107
+ try:
108
+ ctx.dataset_svc.delete(name)
109
+ ctx.commit()
110
+ console.print(f"[success]Deleted '{name}'.[/success]")
111
+ except NotFoundError as e:
112
+ console.print(f"[error]{e}[/error]")
113
+ raise typer.Exit(1)
114
+
115
+
116
+ @app.command("graph")
117
+ def dataset_graph(name: str = typer.Argument(...)) -> None:
118
+ """Show the schema hierarchy for schemas present in a dataset."""
119
+ ctx = _ctx()
120
+ try:
121
+ ctx.dataset_svc.get(name)
122
+ except NotFoundError as e:
123
+ console.print(f"[error]{e}[/error]")
124
+ raise typer.Exit(1)
125
+
126
+ records = ctx.record_svc.find(name, schema_name=None, filters=[], limit=100_000)
127
+ if not records:
128
+ console.print("[info]Dataset has no records yet.[/info]")
129
+ return
130
+
131
+ schema_counts: Counter[uuid.UUID] = Counter(r.schema_id for r in records)
132
+ schema_ids_present = set(schema_counts)
133
+
134
+ all_schemas = ctx.schema_svc.list_all()
135
+ by_id: dict[uuid.UUID, SchemaDTO] = {s.id: s for s in all_schemas}
136
+
137
+ # Find root ancestors of all present schemas.
138
+ def _root(schema_id: uuid.UUID) -> uuid.UUID:
139
+ s = by_id.get(schema_id)
140
+ while s and s.parent_id and s.parent_id in by_id:
141
+ s = by_id[s.parent_id]
142
+ return s.id if s else schema_id
143
+
144
+ roots = {_root(sid) for sid in schema_ids_present}
145
+
146
+ children: dict[uuid.UUID, list[SchemaDTO]] = {}
147
+ for s in all_schemas:
148
+ if s.parent_id:
149
+ children.setdefault(s.parent_id, []).append(s)
150
+
151
+ def _label(schema: SchemaDTO) -> str:
152
+ count = schema_counts.get(schema.id, 0)
153
+ if schema.id in schema_ids_present:
154
+ return f"[bold]{schema.name}[/bold] [dim]({count} record{'s' if count != 1 else ''})[/dim]"
155
+ return f"[dim]{schema.name}[/dim]"
156
+
157
+ def _build(schema: SchemaDTO, branch: Tree) -> None:
158
+ for child in sorted(children.get(schema.id, []), key=lambda s: s.name):
159
+ _build(child, branch.add(_label(child)))
160
+
161
+ for root_id in sorted(roots, key=lambda i: by_id[i].name):
162
+ root = by_id[root_id]
163
+ tree = Tree(_label(root))
164
+ _build(root, tree)
165
+ console.print(tree)