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.
- civex/__init__.py +6 -0
- civex/cli/__init__.py +0 -0
- civex/cli/_docker.py +157 -0
- civex/cli/auth.py +112 -0
- civex/cli/clone.py +93 -0
- civex/cli/dataset.py +165 -0
- civex/cli/db.py +511 -0
- civex/cli/dump.py +227 -0
- civex/cli/init.py +126 -0
- civex/cli/log.py +142 -0
- civex/cli/plugin.py +19 -0
- civex/cli/plumbing.py +114 -0
- civex/cli/record.py +227 -0
- civex/cli/remote.py +62 -0
- civex/cli/resolve.py +35 -0
- civex/cli/schema.py +315 -0
- civex/cli/shell.py +56 -0
- civex/cli/status.py +43 -0
- civex/cli/store.py +137 -0
- civex/cli/sync.py +76 -0
- civex/cli/utils.py +84 -0
- civex/cli/worker.py +120 -0
- civex/cli/workflow.py +160 -0
- civex/config.py +229 -0
- civex/console.py +9 -0
- civex/context.py +140 -0
- civex/db/__init__.py +0 -0
- civex/db/models.py +207 -0
- civex/db/session.py +24 -0
- civex/desktop/__init__.py +0 -0
- civex/desktop/cli_entry.py +5 -0
- civex/desktop/tray.py +472 -0
- civex/domain/__init__.py +0 -0
- civex/domain/dtos.py +269 -0
- civex/domain/exceptions.py +44 -0
- civex/main.py +89 -0
- civex/plugins/__init__.py +0 -0
- civex/plugins/base.py +54 -0
- civex/plugins/builtins/__init__.py +0 -0
- civex/plugins/builtins/create_records_from_files.py +49 -0
- civex/plugins/builtins/extract_from_filename.py +89 -0
- civex/plugins/builtins/get_field.py +20 -0
- civex/plugins/builtins/load_csv.py +32 -0
- civex/plugins/builtins/load_file.py +31 -0
- civex/plugins/builtins/load_file_list.py +29 -0
- civex/plugins/builtins/match_files_to_records.py +98 -0
- civex/plugins/builtins/rows_to_records.py +54 -0
- civex/plugins/builtins/save_field.py +23 -0
- civex/plugins/builtins/save_fields.py +28 -0
- civex/plugins/builtins/upsert_records.py +68 -0
- civex/plugins/registry.py +65 -0
- civex/project.py +38 -0
- civex/repositories/__init__.py +0 -0
- civex/repositories/local/__init__.py +0 -0
- civex/repositories/local/audit_repo.py +203 -0
- civex/repositories/local/dataset_repo.py +65 -0
- civex/repositories/local/file_store.py +216 -0
- civex/repositories/local/job_repo.py +122 -0
- civex/repositories/local/record_repo.py +189 -0
- civex/repositories/local/schema_repo.py +136 -0
- civex/repositories/protocols.py +124 -0
- civex/server/__init__.py +0 -0
- civex/server/app.py +88 -0
- civex/server/background.py +76 -0
- civex/server/deps.py +26 -0
- civex/server/models.py +243 -0
- civex/server/routers/__init__.py +0 -0
- civex/server/routers/datasets.py +91 -0
- civex/server/routers/dump.py +207 -0
- civex/server/routers/files.py +29 -0
- civex/server/routers/jobs.py +77 -0
- civex/server/routers/plugins.py +78 -0
- civex/server/routers/records.py +150 -0
- civex/server/routers/remote.py +78 -0
- civex/server/routers/schemas.py +116 -0
- civex/server/routers/store.py +71 -0
- civex/server/routers/terminal.py +105 -0
- civex/server/routers/ui.py +360 -0
- civex/server/routers/workflows.py +180 -0
- civex/server/static/.gitkeep +0 -0
- civex/server/static/assets/index-BUBS2aN1.css +32 -0
- civex/server/static/assets/index-CWSVJAC7.js +129 -0
- civex/server/static/index.html +13 -0
- civex/server/templates/_jobs_table.html +53 -0
- civex/server/templates/base.html +60 -0
- civex/server/templates/dataset_detail.html +108 -0
- civex/server/templates/datasets.html +56 -0
- civex/server/templates/index.html +28 -0
- civex/server/templates/jobs.html +12 -0
- civex/server/templates/record_detail.html +108 -0
- civex/server/templates/schema_detail.html +74 -0
- civex/server/templates/schemas.html +58 -0
- civex/server/templates/workflows.html +54 -0
- civex/services/__init__.py +0 -0
- civex/services/dataset_service.py +50 -0
- civex/services/file_service.py +48 -0
- civex/services/helpers/coercion.py +0 -0
- civex/services/record_service.py +463 -0
- civex/services/schema_service.py +161 -0
- civex/services/store_service.py +78 -0
- civex/services/sync_service.py +130 -0
- civex/services/workflow_job_service.py +96 -0
- civex/sync/__init__.py +14 -0
- civex/sync/bundle.py +67 -0
- civex/sync/exporter.py +149 -0
- civex/sync/importer.py +178 -0
- civex/sync/transport.py +241 -0
- civex/workflows/__init__.py +0 -0
- civex/workflows/definition.py +48 -0
- civex/workflows/executor.py +107 -0
- civex-1.0.0.dist-info/METADATA +660 -0
- civex-1.0.0.dist-info/RECORD +118 -0
- civex-1.0.0.dist-info/WHEEL +5 -0
- civex-1.0.0.dist-info/entry_points.txt +3 -0
- civex-1.0.0.dist-info/licenses/LICENSE +37 -0
- civex-1.0.0.dist-info/scm_file_list.json +1839 -0
- civex-1.0.0.dist-info/scm_version.json +8 -0
- civex-1.0.0.dist-info/top_level.txt +1 -0
civex/__init__.py
ADDED
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)
|