bylaw-python 0.4.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.
bylaw_python/cli.py ADDED
@@ -0,0 +1,366 @@
1
+ """Ledgix CLI — local dev quickstart and management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import textwrap
11
+ import time
12
+ from pathlib import Path
13
+
14
+ import click
15
+
16
+ COMPOSE_FILE = "docker-compose.ledgix.yml"
17
+ ENV_FILE = ".env.ledgix"
18
+ MANIFEST_FILE = "ledgix.yaml"
19
+
20
+ VAULT_PORT = 8080
21
+ JUDGE_PORT = 8000
22
+ POSTGRES_PORT = 5433
23
+ DEV_API_KEY = "ldgx-dev-key-00000000"
24
+ DEV_AGENT_ID = "my-agent"
25
+ DEV_TENANT = "dev-tenant"
26
+
27
+ COMPOSE_TEMPLATE = textwrap.dedent("""\
28
+ services:
29
+ postgres:
30
+ image: pgvector/pgvector:pg16
31
+ container_name: ledgix_dev_postgres
32
+ restart: unless-stopped
33
+ ports:
34
+ - "127.0.0.1:{postgres_port}:5432"
35
+ environment:
36
+ POSTGRES_USER: ledgix
37
+ POSTGRES_PASSWORD: ledgix-dev
38
+ POSTGRES_DB: ledgix_dev
39
+ volumes:
40
+ - ledgix_pgdata:/var/lib/postgresql/data
41
+ healthcheck:
42
+ test: ["CMD-SHELL", "pg_isready -U ledgix"]
43
+ interval: 5s
44
+ timeout: 3s
45
+ retries: 10
46
+
47
+ vault:
48
+ image: ghcr.io/ledgix-dev/alcv-vault:latest
49
+ container_name: ledgix_dev_vault
50
+ restart: unless-stopped
51
+ ports:
52
+ - "127.0.0.1:{vault_port}:8000"
53
+ environment:
54
+ VAULT_PORT: "8000"
55
+ VAULT_JUDGE_URL: http://ledgix_dev_judge:8000
56
+ VAULT_JWT_ISSUER: alcv-vault
57
+ VAULT_JWT_AUDIENCE: ledgix-sdk
58
+ VAULT_JWT_TTL: "300"
59
+ VAULT_KEY_ID: dev-key-001
60
+ VAULT_ALLOW_INSECURE_DEV_MODE: "true"
61
+ VAULT_DEV_TENANT_ID: {tenant_id}
62
+ VAULT_DEV_API_KEY: {api_key}
63
+ VAULT_DEV_DB_URL: postgres://ledgix:ledgix-dev@ledgix_dev_postgres:5432/ledgix_dev?sslmode=disable
64
+ VAULT_RATE_LIMIT_RPS: "0"
65
+ depends_on:
66
+ postgres:
67
+ condition: service_healthy
68
+ networks:
69
+ - ledgix_dev_net
70
+
71
+ judge:
72
+ image: ghcr.io/ledgix-dev/llm-judge:latest
73
+ container_name: ledgix_dev_judge
74
+ restart: unless-stopped
75
+ environment:
76
+ DATABASE_URL: postgres://ledgix:ledgix-dev@ledgix_dev_postgres:5432/ledgix_dev?sslmode=disable
77
+ EMBEDDING_MODEL: bedrock/amazon.titan-embed-text-v2:0
78
+ EVAL_MODEL: bedrock/amazon.nova-pro-v1:0
79
+ AWS_REGION: us-east-1
80
+ LOG_FORMAT: json
81
+ depends_on:
82
+ postgres:
83
+ condition: service_healthy
84
+ networks:
85
+ - ledgix_dev_net
86
+ healthcheck:
87
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
88
+ interval: 10s
89
+ timeout: 3s
90
+ retries: 5
91
+ start_period: 10s
92
+
93
+ volumes:
94
+ ledgix_pgdata:
95
+
96
+ networks:
97
+ ledgix_dev_net:
98
+ driver: bridge
99
+ """)
100
+
101
+ MANIFEST_TEMPLATE = textwrap.dedent("""\
102
+ # Ledgix manifest — maps tool names to policy IDs.
103
+ # See https://docs.ledgix.dev/sdk/manifest for full syntax.
104
+ version: "1"
105
+ defaults:
106
+ review_mode: block
107
+
108
+ tools:
109
+ # Example: uncomment and adjust for your tools
110
+ # stripe_refund:
111
+ # policy_id: refund-policy
112
+ # confidence_floor: high
113
+ # send_email:
114
+ # policy_id: comms-policy
115
+ """)
116
+
117
+
118
+ def _docker_available() -> bool:
119
+ return shutil.which("docker") is not None
120
+
121
+
122
+ def _compose_cmd() -> list[str]:
123
+ result = subprocess.run(
124
+ ["docker", "compose", "version"],
125
+ capture_output=True, text=True,
126
+ )
127
+ if result.returncode == 0:
128
+ return ["docker", "compose"]
129
+ result2 = subprocess.run(
130
+ ["docker-compose", "version"],
131
+ capture_output=True, text=True,
132
+ )
133
+ if result2.returncode == 0:
134
+ return ["docker-compose"]
135
+ return []
136
+
137
+
138
+ def _compose_base() -> list[str]:
139
+ cmd = _compose_cmd()
140
+ if not cmd:
141
+ click.echo("Error: docker compose is not available.", err=True)
142
+ sys.exit(1)
143
+ return [*cmd, "-f", COMPOSE_FILE, "-p", "ledgix-dev"]
144
+
145
+
146
+ def _poll_health(url: str, timeout: int = 90) -> bool:
147
+ """Poll a health endpoint until it returns 200 or timeout."""
148
+ import urllib.request
149
+ import urllib.error
150
+
151
+ deadline = time.monotonic() + timeout
152
+ while time.monotonic() < deadline:
153
+ try:
154
+ req = urllib.request.Request(url, method="GET")
155
+ with urllib.request.urlopen(req, timeout=3) as resp:
156
+ if resp.status == 200:
157
+ return True
158
+ except (urllib.error.URLError, OSError, TimeoutError):
159
+ pass
160
+ time.sleep(2)
161
+ return False
162
+
163
+
164
+ @click.group()
165
+ @click.version_option(package_name="bylaw-python")
166
+ def main():
167
+ """Ledgix CLI — local development tools for the ALCV platform."""
168
+ pass
169
+
170
+
171
+ @main.command()
172
+ @click.option("--vault-port", default=VAULT_PORT, type=int, help="Host port for vault")
173
+ @click.option("--api-key", default=DEV_API_KEY, help="Dev API key")
174
+ @click.option("--tenant-id", default=DEV_TENANT, help="Dev tenant ID")
175
+ @click.option("--skip-health-check", is_flag=True, help="Don't wait for services to become healthy")
176
+ def init(vault_port: int, api_key: str, tenant_id: str, skip_health_check: bool):
177
+ """Scaffold and start a local Ledgix dev environment.
178
+
179
+ Creates docker-compose, env, and manifest files, then starts all
180
+ services. On a clean machine with Docker, first clearance request
181
+ should succeed within 60 seconds.
182
+ """
183
+ if not _docker_available():
184
+ click.echo("Error: Docker is not installed or not in PATH.", err=True)
185
+ click.echo("Install Docker Desktop: https://docs.docker.com/get-docker/", err=True)
186
+ sys.exit(1)
187
+
188
+ compose_cmd = _compose_cmd()
189
+ if not compose_cmd:
190
+ click.echo("Error: Neither 'docker compose' nor 'docker-compose' found.", err=True)
191
+ sys.exit(1)
192
+
193
+ cwd = Path.cwd()
194
+
195
+ compose_path = cwd / COMPOSE_FILE
196
+ if compose_path.exists():
197
+ click.echo(f" {COMPOSE_FILE} already exists, skipping.")
198
+ else:
199
+ compose_content = COMPOSE_TEMPLATE.format(
200
+ vault_port=vault_port,
201
+ postgres_port=POSTGRES_PORT,
202
+ api_key=api_key,
203
+ tenant_id=tenant_id,
204
+ )
205
+ compose_path.write_text(compose_content)
206
+ click.echo(f" Created {COMPOSE_FILE}")
207
+
208
+ env_path = cwd / ENV_FILE
209
+ if env_path.exists():
210
+ click.echo(f" {ENV_FILE} already exists, skipping.")
211
+ else:
212
+ env_content = "\n".join([
213
+ f"LEDGIX_VAULT_URL=http://localhost:{vault_port}",
214
+ f"LEDGIX_VAULT_API_KEY={api_key}",
215
+ f"LEDGIX_AGENT_ID={DEV_AGENT_ID}",
216
+ "",
217
+ ])
218
+ env_path.write_text(env_content)
219
+ click.echo(f" Created {ENV_FILE}")
220
+
221
+ manifest_path = cwd / MANIFEST_FILE
222
+ if manifest_path.exists():
223
+ click.echo(f" {MANIFEST_FILE} already exists, skipping.")
224
+ else:
225
+ manifest_path.write_text(MANIFEST_TEMPLATE)
226
+ click.echo(f" Created {MANIFEST_FILE}")
227
+
228
+ click.echo("\nStarting services...")
229
+ base = _compose_base()
230
+ result = subprocess.run([*base, "up", "-d", "--pull", "always"], capture_output=True, text=True)
231
+ if result.returncode != 0:
232
+ click.echo(f"Error starting services:\n{result.stderr}", err=True)
233
+ sys.exit(1)
234
+ click.echo(" Containers started.")
235
+
236
+ if not skip_health_check:
237
+ click.echo("\nWaiting for services to become healthy...")
238
+ vault_url = f"http://localhost:{vault_port}/health"
239
+ if _poll_health(vault_url, timeout=90):
240
+ click.echo(" Vault is healthy.")
241
+ else:
242
+ click.echo(
243
+ f" Warning: Vault did not respond at {vault_url} within 90s.\n"
244
+ " Run 'ledgix status' to check, or 'docker compose -f docker-compose.ledgix.yml logs' for details.",
245
+ err=True,
246
+ )
247
+
248
+ click.echo("\n" + "=" * 56)
249
+ click.echo(" Ledgix dev environment is ready!")
250
+ click.echo("=" * 56)
251
+ click.echo(f"""
252
+ Vault: http://localhost:{vault_port}
253
+ API Key: {api_key}
254
+ Tenant: {tenant_id}
255
+
256
+ Next steps:
257
+
258
+ 1. Add to your agent:
259
+
260
+ import bylaw_python as ledgix
261
+ ledgix.configure(
262
+ vault_url="http://localhost:{vault_port}",
263
+ vault_api_key="{api_key}",
264
+ agent_id="{DEV_AGENT_ID}",
265
+ )
266
+ ledgix.auto_instrument(tools)
267
+
268
+ 2. Or source the env file and configure() picks it up:
269
+
270
+ source {ENV_FILE} # or: set -a; . {ENV_FILE}; set +a
271
+ ledgix.configure()
272
+
273
+ 3. Upload a policy:
274
+
275
+ curl -X POST http://localhost:{vault_port}/admin/upload-policy \\
276
+ -H "X-Vault-API-Key: {api_key}" \\
277
+ -F "file=@your-policy.md" \\
278
+ -F "documentName=My Policy" \\
279
+ -F "policyId=my-policy"
280
+ """)
281
+
282
+
283
+ @main.command()
284
+ def status():
285
+ """Check whether the local Ledgix dev environment is running."""
286
+ if not (Path.cwd() / COMPOSE_FILE).exists():
287
+ click.echo(f"No {COMPOSE_FILE} found in current directory.")
288
+ click.echo("Run 'ledgix init' to create a local dev environment.")
289
+ return
290
+
291
+ base = _compose_base()
292
+ result = subprocess.run([*base, "ps", "--format", "json"], capture_output=True, text=True)
293
+ if result.returncode != 0:
294
+ click.echo("Could not query container status.")
295
+ click.echo(result.stderr)
296
+ return
297
+
298
+ containers: list[dict] = []
299
+ raw = result.stdout.strip()
300
+ if raw:
301
+ try:
302
+ parsed = json.loads(raw)
303
+ if isinstance(parsed, list):
304
+ containers = parsed
305
+ elif isinstance(parsed, dict):
306
+ containers = [parsed]
307
+ except json.JSONDecodeError:
308
+ for line in raw.splitlines():
309
+ line = line.strip()
310
+ if line:
311
+ try:
312
+ containers.append(json.loads(line))
313
+ except json.JSONDecodeError:
314
+ pass
315
+
316
+ if not containers:
317
+ click.echo("No containers running. Run 'ledgix init' to start.")
318
+ return
319
+
320
+ click.echo(f"{'Service':<20} {'State':<12} {'Health':<12} {'Ports'}")
321
+ click.echo("-" * 70)
322
+ for c in containers:
323
+ name = c.get("Service") or c.get("Name", "?")
324
+ state = c.get("State", "?")
325
+ health = c.get("Health", "-")
326
+ ports = c.get("Publishers") or c.get("Ports", "")
327
+ if isinstance(ports, list):
328
+ port_strs = []
329
+ for p in ports:
330
+ if isinstance(p, dict) and p.get("PublishedPort"):
331
+ port_strs.append(f":{p['PublishedPort']}")
332
+ ports = ", ".join(port_strs) if port_strs else "-"
333
+ click.echo(f"{name:<20} {state:<12} {health:<12} {ports}")
334
+
335
+ env_path = Path.cwd() / ENV_FILE
336
+ if env_path.exists():
337
+ click.echo(f"\nEnv file: {env_path}")
338
+
339
+
340
+ @main.command()
341
+ @click.option("--volumes", is_flag=True, help="Also remove data volumes")
342
+ @click.confirmation_option(prompt="This will stop and remove all Ledgix dev containers. Continue?")
343
+ def teardown(volumes: bool):
344
+ """Stop and remove the local Ledgix dev environment."""
345
+ compose_path = Path.cwd() / COMPOSE_FILE
346
+ if not compose_path.exists():
347
+ click.echo(f"No {COMPOSE_FILE} found in current directory. Nothing to tear down.")
348
+ return
349
+
350
+ base = _compose_base()
351
+ cmd = [*base, "down"]
352
+ if volumes:
353
+ cmd.append("-v")
354
+
355
+ result = subprocess.run(cmd, capture_output=True, text=True)
356
+ if result.returncode != 0:
357
+ click.echo(f"Error during teardown:\n{result.stderr}", err=True)
358
+ sys.exit(1)
359
+
360
+ click.echo("Ledgix dev environment stopped and removed.")
361
+ if volumes:
362
+ click.echo("Data volumes removed.")
363
+
364
+
365
+ if __name__ == "__main__":
366
+ main()