vclient 0.2.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.
src/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """Dynamic API Adapter - Auto-discover and adapt to API schemas."""
2
+
3
+ from .inferrer import SchemaInferrer, infer_property_schema, infer_object_schema
4
+ from .sampler import EndpointSampler
5
+ from .codegen import ClientGenerator
6
+
7
+ __all__ = [
8
+ "SchemaInferrer",
9
+ "EndpointSampler",
10
+ "ClientGenerator",
11
+ "infer_property_schema",
12
+ "infer_object_schema",
13
+ ]
14
+
15
+ __version__ = "0.1.0"
src/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Entry point for vclient CLI when run as a module or executable."""
2
+ from src.cli import cli
3
+
4
+ if __name__ == "__main__":
5
+ cli()
src/cache.py ADDED
@@ -0,0 +1,159 @@
1
+ """SQLite-based schema caching."""
2
+ import sqlite3
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional
7
+
8
+ DEFAULT_DB_PATH = Path.home() / ".api-adapter" / "cache.db"
9
+
10
+
11
+ class SchemaCache:
12
+ """Persistent schema cache using SQLite."""
13
+
14
+ def __init__(self, db_path: str | Path | None = None) -> None:
15
+ self.db_path = Path(db_path) if db_path else DEFAULT_DB_PATH
16
+ self._conn: sqlite3.Connection | None = None
17
+
18
+ def __enter__(self) -> "SchemaCache":
19
+ self.connect()
20
+ return self
21
+
22
+ def __exit__(self, *args) -> None:
23
+ self.close()
24
+
25
+ def connect(self) -> None:
26
+ """Open connection and initialize schema."""
27
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
28
+ self._conn = sqlite3.connect(str(self.db_path))
29
+ self._conn.row_factory = sqlite3.Row
30
+ self._init_schema()
31
+
32
+ def close(self) -> None:
33
+ """Commit and close connection."""
34
+ if self._conn:
35
+ self._conn.commit()
36
+ self._conn.close()
37
+ self._conn = None
38
+
39
+ def _init_schema(self) -> None:
40
+ """Create tables if they don't exist."""
41
+ if not self._conn:
42
+ raise RuntimeError("Not connected to database")
43
+
44
+ cursor = self._conn.cursor()
45
+
46
+ cursor.execute("""
47
+ CREATE TABLE IF NOT EXISTS schema_snapshots (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ base_url TEXT NOT NULL,
50
+ captured_at TEXT NOT NULL,
51
+ schema_json TEXT NOT NULL
52
+ )
53
+ """)
54
+
55
+ cursor.execute("""
56
+ CREATE INDEX IF NOT EXISTS idx_snapshots_url
57
+ ON schema_snapshots(base_url)
58
+ """)
59
+
60
+ cursor.execute("""
61
+ CREATE TABLE IF NOT EXISTS drift_events (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ base_url TEXT NOT NULL,
64
+ detected_at TEXT NOT NULL,
65
+ old_snapshot_id INTEGER NOT NULL REFERENCES schema_snapshots(id),
66
+ new_snapshot_id INTEGER NOT NULL REFERENCES schema_snapshots(id),
67
+ similarity REAL NOT NULL,
68
+ diff_json TEXT NOT NULL
69
+ )
70
+ """)
71
+
72
+ self._conn.commit()
73
+
74
+ def save_snapshot(self, base_url: str, schema: Dict[str, Any]) -> int:
75
+ """Save schema snapshot and return row id."""
76
+ if not self._conn:
77
+ raise RuntimeError("Not connected to database")
78
+
79
+ cursor = self._conn.cursor()
80
+ now = datetime.now(timezone.utc).isoformat()
81
+ schema_json = json.dumps(schema, default=str)
82
+
83
+ cursor.execute(
84
+ "INSERT INTO schema_snapshots (base_url, captured_at, schema_json) VALUES (?, ?, ?)",
85
+ (base_url, now, schema_json)
86
+ )
87
+ self._conn.commit()
88
+ return cursor.lastrowid
89
+
90
+ def latest_snapshot(self, base_url: str) -> Optional[Dict[str, Any]]:
91
+ """Get most recent snapshot for base_url."""
92
+ if not self._conn:
93
+ raise RuntimeError("Not connected to database")
94
+
95
+ cursor = self._conn.cursor()
96
+ cursor.execute(
97
+ "SELECT schema_json FROM schema_snapshots WHERE base_url=? ORDER BY captured_at DESC LIMIT 1",
98
+ (base_url,)
99
+ )
100
+ row = cursor.fetchone()
101
+ if row:
102
+ return json.loads(row[0])
103
+ return None
104
+
105
+ def all_snapshots(self, base_url: str) -> list[Dict[str, Any]]:
106
+ """Get all snapshots for base_url ordered by captured_at DESC."""
107
+ if not self._conn:
108
+ raise RuntimeError("Not connected to database")
109
+
110
+ cursor = self._conn.cursor()
111
+ cursor.execute(
112
+ "SELECT schema_json FROM schema_snapshots WHERE base_url=? ORDER BY captured_at DESC",
113
+ (base_url,)
114
+ )
115
+ return [json.loads(row[0]) for row in cursor.fetchall()]
116
+
117
+ def save_drift_event(
118
+ self,
119
+ base_url: str,
120
+ old_id: int,
121
+ new_id: int,
122
+ similarity: float,
123
+ diff: Dict[str, Any],
124
+ ) -> int:
125
+ """Save drift event and return row id."""
126
+ if not self._conn:
127
+ raise RuntimeError("Not connected to database")
128
+
129
+ cursor = self._conn.cursor()
130
+ now = datetime.now(timezone.utc).isoformat()
131
+ diff_json = json.dumps(diff, default=str)
132
+
133
+ cursor.execute(
134
+ "INSERT INTO drift_events (base_url, detected_at, old_snapshot_id, new_snapshot_id, similarity, diff_json) "
135
+ "VALUES (?, ?, ?, ?, ?, ?)",
136
+ (base_url, now, old_id, new_id, similarity, diff_json)
137
+ )
138
+ self._conn.commit()
139
+ return cursor.lastrowid
140
+
141
+ def drift_history(self, base_url: str) -> list[Dict[str, Any]]:
142
+ """Get all drift events for base_url ordered by detected_at DESC."""
143
+ if not self._conn:
144
+ raise RuntimeError("Not connected to database")
145
+
146
+ cursor = self._conn.cursor()
147
+ cursor.execute(
148
+ "SELECT id, detected_at, similarity, diff_json FROM drift_events WHERE base_url=? ORDER BY detected_at DESC",
149
+ (base_url,)
150
+ )
151
+ results = []
152
+ for row in cursor.fetchall():
153
+ results.append({
154
+ "id": row[0],
155
+ "detected_at": row[1],
156
+ "similarity": row[2],
157
+ "diff": json.loads(row[3])
158
+ })
159
+ return results
src/cli.py ADDED
@@ -0,0 +1,262 @@
1
+ """Command-line interface for API Adapter."""
2
+ import click
3
+ import json
4
+ import asyncio
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from .sampler import EndpointSampler
9
+ from .inferrer import SchemaInferrer, compute_drift_diff
10
+ from .codegen import ClientGenerator
11
+ from .cache import SchemaCache
12
+
13
+
14
+ @click.group()
15
+ def cli():
16
+ """Dynamic API Adapter - Infer schemas from live APIs."""
17
+ pass
18
+
19
+
20
+ @cli.command()
21
+ @click.argument("url")
22
+ @click.option("--output", "-o", default="schema.json", help="Output file for inferred schema")
23
+ @click.option("--gen-python", is_flag=True, help="Generate Python client")
24
+ @click.option("--gen-typescript", is_flag=True, help="Generate TypeScript client")
25
+ @click.option("--gen-go", is_flag=True, help="Generate Go client")
26
+ @click.option("--gen-rust", is_flag=True, help="Generate Rust client")
27
+ @click.option("--gen-java", is_flag=True, help="Generate Java client")
28
+ @click.option("--gen-ruby", is_flag=True, help="Generate Ruby client")
29
+ @click.option("--timeout", "-t", default=10, help="Request timeout in seconds")
30
+ @click.option("--max-endpoints", "-e", default=50, help="Maximum endpoints to sample")
31
+ @click.option("--no-cache", is_flag=True, default=False, help="Skip cache read/write")
32
+ @click.option("--cache-db", default=None, help="Path to SQLite cache DB")
33
+ def infer(
34
+ url: str,
35
+ output: str,
36
+ gen_python: bool,
37
+ gen_typescript: bool,
38
+ gen_go: bool,
39
+ gen_rust: bool,
40
+ gen_java: bool,
41
+ gen_ruby: bool,
42
+ timeout: int,
43
+ max_endpoints: int,
44
+ no_cache: bool,
45
+ cache_db: Optional[str],
46
+ ):
47
+ """Infer API schema from a live API.
48
+
49
+ Example:
50
+ api-adapter infer https://api.example.com --output schema.json --gen-python
51
+ """
52
+ click.echo(f"🔍 Discovering endpoints from {url}...")
53
+
54
+ sampler = EndpointSampler(url, timeout=timeout, max_endpoints=max_endpoints)
55
+ responses = sampler.run()
56
+
57
+ click.echo(f"✅ Sampled {len(responses)} endpoints")
58
+
59
+ # Infer schema
60
+ click.echo("📊 Inferring schema...")
61
+ inferrer = SchemaInferrer(base_url=url)
62
+
63
+ for key, response in responses.items():
64
+ method, endpoint = key.split(" ", 1)
65
+ inferrer.add_response(endpoint, method, response)
66
+
67
+ schema = inferrer.to_dict()
68
+
69
+ # Save schema
70
+ output_path = Path(output)
71
+ output_path.write_text(inferrer.to_json())
72
+ click.echo(f"💾 Schema saved to {output}")
73
+
74
+ # Save to cache if not disabled
75
+ if not no_cache:
76
+ try:
77
+ with SchemaCache(db_path=cache_db) as cache:
78
+ snapshot_id = cache.save_snapshot(url, schema)
79
+ click.echo(f"📦 Snapshot cached (id={snapshot_id})")
80
+ except Exception as e:
81
+ click.echo(f"⚠️ Cache write failed: {e}")
82
+
83
+ # Generate clients if requested
84
+ if gen_python:
85
+ models_code = ClientGenerator.generate_python_models(url, schema["endpoints"])
86
+ models_file = Path("models.py")
87
+ models_file.write_text(models_code)
88
+ click.echo(f"🏗️ Python models generated: {models_file}")
89
+
90
+ client_code = ClientGenerator.generate_python(url, schema["endpoints"])
91
+ python_file = Path("client.py")
92
+ python_file.write_text(client_code)
93
+ click.echo(f"🐍 Python client generated: {python_file}")
94
+
95
+ if gen_typescript:
96
+ schemas_code = ClientGenerator.generate_typescript_schemas(url, schema["endpoints"])
97
+ schemas_file = Path("schemas.ts")
98
+ schemas_file.write_text(schemas_code)
99
+ click.echo(f"🔐 TypeScript schemas generated: {schemas_file}")
100
+
101
+ client_code = ClientGenerator.generate_typescript(url, schema["endpoints"])
102
+ ts_file = Path("client.ts")
103
+ ts_file.write_text(client_code)
104
+ click.echo(f"📘 TypeScript client generated: {ts_file}")
105
+
106
+ if gen_go:
107
+ client_code = ClientGenerator.generate_go(url, schema["endpoints"])
108
+ go_file = Path("client.go")
109
+ go_file.write_text(client_code)
110
+ click.echo(f"🐹 Go client generated: {go_file}")
111
+
112
+ if gen_rust:
113
+ client_code = ClientGenerator.generate_rust(url, schema["endpoints"])
114
+ rs_file = Path("client.rs")
115
+ rs_file.write_text(client_code)
116
+ click.echo(f"🦀 Rust client generated: {rs_file}")
117
+
118
+ if gen_java:
119
+ client_code = ClientGenerator.generate_java(url, schema["endpoints"])
120
+ java_file = Path("Client.java")
121
+ java_file.write_text(client_code)
122
+ click.echo(f"☕ Java client generated: {java_file}")
123
+
124
+ if gen_ruby:
125
+ client_code = ClientGenerator.generate_ruby(url, schema["endpoints"])
126
+ rb_file = Path("client.rb")
127
+ rb_file.write_text(client_code)
128
+ click.echo(f"💎 Ruby client generated: {rb_file}")
129
+
130
+ click.echo("✨ Done!")
131
+
132
+
133
+ @cli.command()
134
+ @click.argument("schema_file")
135
+ @click.option("--python", "output_python", help="Output file for Python client")
136
+ @click.option("--typescript", "output_typescript", help="Output file for TypeScript client")
137
+ @click.option("--go", "output_go", help="Output file for Go client")
138
+ @click.option("--rust", "output_rust", help="Output file for Rust client")
139
+ @click.option("--java", "output_java", help="Output file for Java client")
140
+ @click.option("--ruby", "output_ruby", help="Output file for Ruby client")
141
+ def codegen(
142
+ schema_file: str,
143
+ output_python: Optional[str],
144
+ output_typescript: Optional[str],
145
+ output_go: Optional[str],
146
+ output_rust: Optional[str],
147
+ output_java: Optional[str],
148
+ output_ruby: Optional[str],
149
+ ):
150
+ """Generate clients from an inferred schema.
151
+
152
+ Example:
153
+ api-adapter codegen schema.json --python client.py --typescript client.ts
154
+ """
155
+ schema_path = Path(schema_file)
156
+ if not schema_path.exists():
157
+ click.echo(f"❌ Schema file not found: {schema_file}", err=True)
158
+ return
159
+
160
+ with open(schema_path) as f:
161
+ schema = json.load(f)
162
+
163
+ base_url = schema.get("base_url", "https://api.example.com")
164
+ endpoints = schema.get("endpoints", {})
165
+
166
+ if output_python:
167
+ client_code = ClientGenerator.generate_python(base_url, endpoints)
168
+ Path(output_python).write_text(client_code)
169
+ click.echo(f"🐍 Python client: {output_python}")
170
+
171
+ if output_typescript:
172
+ client_code = ClientGenerator.generate_typescript(base_url, endpoints)
173
+ Path(output_typescript).write_text(client_code)
174
+ click.echo(f"📘 TypeScript client: {output_typescript}")
175
+
176
+ if output_go:
177
+ client_code = ClientGenerator.generate_go(base_url, endpoints)
178
+ Path(output_go).write_text(client_code)
179
+ click.echo(f"🐹 Go client: {output_go}")
180
+
181
+ if output_rust:
182
+ client_code = ClientGenerator.generate_rust(base_url, endpoints)
183
+ Path(output_rust).write_text(client_code)
184
+ click.echo(f"🦀 Rust client: {output_rust}")
185
+
186
+ if output_java:
187
+ client_code = ClientGenerator.generate_java(base_url, endpoints)
188
+ Path(output_java).write_text(client_code)
189
+ click.echo(f"☕ Java client: {output_java}")
190
+
191
+ if output_ruby:
192
+ client_code = ClientGenerator.generate_ruby(base_url, endpoints)
193
+ Path(output_ruby).write_text(client_code)
194
+ click.echo(f"💎 Ruby client: {output_ruby}")
195
+
196
+
197
+ @cli.command()
198
+ @click.argument("url")
199
+ @click.argument("schema_file")
200
+ @click.option("--threshold", default=0.9, help="Similarity threshold for drift detection")
201
+ @click.option("--no-cache", is_flag=True, default=False, help="Skip cache write")
202
+ @click.option("--cache-db", default=None, help="Path to SQLite cache DB")
203
+ def drift(url: str, schema_file: str, threshold: float, no_cache: bool, cache_db: Optional[str]):
204
+ """Detect schema drift from previous inference.
205
+
206
+ Example:
207
+ api-adapter drift https://api.example.com schema.json
208
+ """
209
+ schema_path = Path(schema_file)
210
+ if not schema_path.exists():
211
+ click.echo(f"❌ Schema file not found: {schema_file}", err=True)
212
+ return
213
+
214
+ with open(schema_path) as f:
215
+ old_schema = json.load(f)
216
+
217
+ click.echo(f"🔍 Re-inferring schema from {url}...")
218
+ sampler = EndpointSampler(url)
219
+ responses = sampler.run()
220
+
221
+ inferrer = SchemaInferrer(base_url=url)
222
+ for key, response in responses.items():
223
+ method, endpoint = key.split(" ", 1)
224
+ inferrer.add_response(endpoint, method, response)
225
+
226
+ new_schema = inferrer.to_dict()
227
+
228
+ # Compute drift diff using threshold
229
+ diff = compute_drift_diff(old_schema, new_schema)
230
+ similarity = diff["similarity"]
231
+
232
+ if similarity < threshold:
233
+ click.echo(f"⚠️ Schema drift detected! Similarity: {similarity:.2%} (threshold: {threshold:.0%})")
234
+ if diff["added_endpoints"]:
235
+ click.echo(f" Added endpoints: {', '.join(diff['added_endpoints'])}")
236
+ if diff["removed_endpoints"]:
237
+ click.echo(f" Removed endpoints: {', '.join(diff['removed_endpoints'])}")
238
+ if diff["added_fields"]:
239
+ click.echo(f" Added fields: {len(diff['added_fields'])}")
240
+ if diff["removed_fields"]:
241
+ click.echo(f" Removed fields: {len(diff['removed_fields'])}")
242
+ if diff["changed_types"]:
243
+ click.echo(f" Changed types: {len(diff['changed_types'])}")
244
+ else:
245
+ click.echo(f"✅ No significant drift (similarity: {similarity:.2%}, threshold: {threshold:.0%})")
246
+
247
+ # Save drift event to cache if not disabled
248
+ if not no_cache and similarity < threshold:
249
+ try:
250
+ with SchemaCache(db_path=cache_db) as cache:
251
+ old_snap = cache.latest_snapshot(url)
252
+ if old_snap:
253
+ old_id = 1 # In real usage, would query the ID
254
+ new_id = cache.save_snapshot(url, new_schema)
255
+ cache.save_drift_event(url, old_id, new_id, similarity, diff)
256
+ click.echo(f"📦 Drift event cached")
257
+ except Exception as e:
258
+ click.echo(f"⚠️ Cache write failed: {e}")
259
+
260
+
261
+ if __name__ == "__main__":
262
+ cli()