quillsql 2.2.6__tar.gz → 2.2.8__tar.gz

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 (29) hide show
  1. {quillsql-2.2.6 → quillsql-2.2.8}/PKG-INFO +42 -1
  2. quillsql-2.2.8/README.md +87 -0
  3. quillsql-2.2.8/quillsql/__init__.py +14 -0
  4. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/core.py +103 -22
  5. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/db/bigquery.py +2 -1
  6. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/db/cached_connection.py +32 -9
  7. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/utils/__init__.py +1 -0
  8. quillsql-2.2.8/quillsql/utils/post_quill_executor.py +69 -0
  9. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql.egg-info/PKG-INFO +42 -1
  10. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql.egg-info/SOURCES.txt +1 -0
  11. {quillsql-2.2.6 → quillsql-2.2.8}/setup.py +1 -1
  12. quillsql-2.2.6/README.md +0 -46
  13. quillsql-2.2.6/quillsql/__init__.py +0 -5
  14. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/assets/__init__.py +0 -0
  15. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/assets/pgtypes.py +0 -0
  16. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/db/__init__.py +0 -0
  17. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/db/db_helper.py +0 -0
  18. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/db/postgres.py +0 -0
  19. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/error.py +0 -0
  20. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/utils/filters.py +0 -0
  21. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/utils/pivot_template.py +0 -0
  22. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/utils/run_query_processes.py +0 -0
  23. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/utils/schema_conversion.py +0 -0
  24. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql/utils/tenants.py +0 -0
  25. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql.egg-info/dependency_links.txt +0 -0
  26. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql.egg-info/requires.txt +0 -0
  27. {quillsql-2.2.6 → quillsql-2.2.8}/quillsql.egg-info/top_level.txt +0 -0
  28. {quillsql-2.2.6 → quillsql-2.2.8}/setup.cfg +0 -0
  29. {quillsql-2.2.6 → quillsql-2.2.8}/tests/test_core.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quillsql
3
- Version: 2.2.6
3
+ Version: 2.2.8
4
4
  Summary: Quill SDK for Python.
5
5
  Home-page: https://github.com/quill-sql/quill-python
6
6
  Author: Quill
@@ -67,3 +67,44 @@ async def quill_post(data: Request, user: dict = Depends(authenticate_jwt)):
67
67
 
68
68
  Then you can run your app like normally. Pass in this route to our react library
69
69
  on the frontend and you all set!
70
+
71
+ ## Streaming
72
+
73
+ ```python
74
+ from quillsql import Quill
75
+ from fastapi.responses import StreamingResponse
76
+ import asyncio
77
+
78
+ quill = Quill(
79
+ private_key=os.getenv("QULL_PRIVATE_KEY"),
80
+ database_connection_string=os.getenv("POSTGRES_READ"),
81
+ database_type="postgresql"
82
+ )
83
+
84
+ @app.post("/quill-stream")
85
+ async def quill_post(data: Request, user: dict = Depends(authenticate_jwt)):
86
+ # assuming user fetched via auth middleware has an userId
87
+ user_id = user["user_id"]
88
+ body = await data.json()
89
+ metadata = body.get("metadata")
90
+
91
+ quill_stream = quill.stream(
92
+ tenants=[{"tenantField": "user_id", "tenantIds": [user_id]}],
93
+ metadata=metadata,
94
+ )
95
+
96
+ async def event_generator():
97
+ # Full event types list: https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#data-stream-protocol
98
+ async for event in quill_stream:
99
+ if event["type"] == "start":
100
+ pass
101
+ elif event["type"] == "text-delta":
102
+ yield event['delta']
103
+ elif event["type"] == "finish":
104
+ return
105
+ elif event["type"] == "error":
106
+ yield event['errorText']
107
+ await asyncio.sleep(0)
108
+
109
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
110
+ ```
@@ -0,0 +1,87 @@
1
+ # Quill Python SDK
2
+
3
+ ## Quickstart
4
+
5
+ First, install the quillsql package by running:
6
+
7
+ ```bash
8
+ $ pip install quillsql
9
+ ```
10
+
11
+ Then, add a `/quill` endpoint to your existing python server. For example, if
12
+ you were running a FASTAPI app, you would just add the endpoint like this:
13
+
14
+ ```python
15
+ from quillsql import Quill
16
+
17
+ quill = Quill(
18
+ private_key=os.getenv("QULL_PRIVATE_KEY"),
19
+ database_connection_string=os.getenv("POSTGRES_READ"),
20
+ database_type="postgresql"
21
+ )
22
+
23
+ security = HTTPBearer()
24
+
25
+ async def authenticate_jwt(token: str = Depends(security)):
26
+ # Your JWT validation logic here
27
+ # Return user object or raise HTTPException
28
+ user = validate_jwt_token(token.credentials)
29
+ return user
30
+
31
+ @app.post("/quill")
32
+ async def quill_post(data: Request, user: dict = Depends(authenticate_jwt)):
33
+ # assuming user fetched via auth middleware has an userId
34
+ user_id = user["user_id"]
35
+ body = await data.json()
36
+ metadata = body.get("metadata")
37
+
38
+ result = quill.query(
39
+ tenants=[{"tenantField": "user_id", "tenantIds": [user_id]}],
40
+ metadata=metadata
41
+ )
42
+ return result
43
+ ```
44
+
45
+ Then you can run your app like normally. Pass in this route to our react library
46
+ on the frontend and you all set!
47
+
48
+ ## Streaming
49
+
50
+ ```python
51
+ from quillsql import Quill
52
+ from fastapi.responses import StreamingResponse
53
+ import asyncio
54
+
55
+ quill = Quill(
56
+ private_key=os.getenv("QULL_PRIVATE_KEY"),
57
+ database_connection_string=os.getenv("POSTGRES_READ"),
58
+ database_type="postgresql"
59
+ )
60
+
61
+ @app.post("/quill-stream")
62
+ async def quill_post(data: Request, user: dict = Depends(authenticate_jwt)):
63
+ # assuming user fetched via auth middleware has an userId
64
+ user_id = user["user_id"]
65
+ body = await data.json()
66
+ metadata = body.get("metadata")
67
+
68
+ quill_stream = quill.stream(
69
+ tenants=[{"tenantField": "user_id", "tenantIds": [user_id]}],
70
+ metadata=metadata,
71
+ )
72
+
73
+ async def event_generator():
74
+ # Full event types list: https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#data-stream-protocol
75
+ async for event in quill_stream:
76
+ if event["type"] == "start":
77
+ pass
78
+ elif event["type"] == "text-delta":
79
+ yield event['delta']
80
+ elif event["type"] == "finish":
81
+ return
82
+ elif event["type"] == "error":
83
+ yield event['errorText']
84
+ await asyncio.sleep(0)
85
+
86
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
87
+ ```
@@ -0,0 +1,14 @@
1
+ # __init__.py
2
+
3
+ from .core import Quill
4
+ from .error import PgQueryError
5
+ from .utils import (
6
+ Filter,
7
+ FilterType,
8
+ FieldType,
9
+ StringOperator,
10
+ DateOperator,
11
+ NumberOperator,
12
+ NullOperator,
13
+ ParallelPostQuill,
14
+ )
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import codecs
2
3
  from dotenv import load_dotenv
3
4
 
4
5
  import requests
@@ -29,7 +30,7 @@ load_dotenv()
29
30
 
30
31
  ENV = os.getenv("PYTHON_ENV")
31
32
  DEV_HOST = "http://localhost:8080"
32
- PROD_HOST = "https://quill-344421.uc.r.appspot.com"
33
+ PROD_HOST = "https://api.quill.co"
33
34
  HOST = DEV_HOST if ENV == "development" else PROD_HOST
34
35
 
35
36
  SINGLE_TENANT = "QUILL_SINGLE_TENANT"
@@ -144,10 +145,14 @@ class Quill:
144
145
  None
145
146
  )
146
147
 
147
- distinct_values = parse_distinct_values(
148
- distinct_value_results["queryResults"][0],
149
- config.get("databaseType")
150
- )
148
+ distinct_value_query_results = (
149
+ distinct_value_results or {}
150
+ ).get("queryResults") or []
151
+ if distinct_value_query_results:
152
+ distinct_values = parse_distinct_values(
153
+ distinct_value_query_results[0],
154
+ config.get("databaseType")
155
+ )
151
156
 
152
157
  try:
153
158
  final_query = hydrate_pivot_template(template, distinct_values, config)
@@ -261,17 +266,16 @@ class Quill:
261
266
  else:
262
267
  tenant_flags = flags
263
268
 
264
- pre_query_results = (
265
- self.run_queries(
269
+ if metadata.get("preQueries"):
270
+ pre_query_results = self.run_queries(
266
271
  metadata.get("preQueries"),
267
272
  self.target_connection.database_type,
268
273
  metadata.get("databaseType"),
269
274
  metadata,
270
275
  metadata.get("runQueryConfig"),
271
276
  )
272
- if metadata.get("preQueries")
273
- else {}
274
- )
277
+ else:
278
+ pre_query_results = {}
275
279
 
276
280
  if metadata.get("runQueryConfig") and metadata.get("runQueryConfig").get(
277
281
  "overridePost"
@@ -383,6 +387,80 @@ class Quill:
383
387
  "status": "error",
384
388
  "data": responseMetadata,
385
389
  }
390
+
391
+ async def stream(
392
+ self,
393
+ tenants,
394
+ metadata,
395
+ flags=None,
396
+ ):
397
+ if not tenants:
398
+ raise ValueError("You may not pass an empty tenants array.")
399
+
400
+ if not metadata:
401
+ yield {"type": "error", "errorText": "Missing metadata."}
402
+ return
403
+
404
+ task = metadata.get("task")
405
+ if not task:
406
+ yield {"type": "error", "errorText": "Missing task."}
407
+ return
408
+
409
+ try:
410
+ # Set tenant IDs in the connection
411
+ self.target_connection.tenant_ids = extract_tenant_ids(tenants)
412
+
413
+ # Handle tenant flags synthesis
414
+ tenant_flags = None
415
+ if tenants[0] == SINGLE_TENANT and flags:
416
+ if flags and isinstance(flags[0], dict):
417
+ tenant_flags = [{'tenantField': SINGLE_TENANT, 'flags': flags}]
418
+ else:
419
+ tenant_flags = flags
420
+
421
+ payload = {
422
+ **metadata,
423
+ "tenants": tenants,
424
+ "flags": tenant_flags,
425
+ }
426
+ # Custom JSON Encoder to handle Enums
427
+ class EnumEncoder(json.JSONEncoder):
428
+ def default(self, obj):
429
+ if isinstance(obj, Enum):
430
+ return obj.value # Convert enum to its value (string in this case)
431
+ return super().default(obj)
432
+ url = f"{self.baseUrl}/sdk/{task}"
433
+ headers = {"Authorization": f"Bearer {self.private_key}", "Content-Type": "application/json","Accept": "text/event-stream"}
434
+ encoded = json.dumps(payload, cls=EnumEncoder)
435
+
436
+ resp = requests.post(url, data=encoded, headers=headers, stream=True)
437
+ decoder = codecs.getincrementaldecoder('utf-8')()
438
+ buf = ""
439
+ for chunk in resp.iter_content(chunk_size=4096):
440
+ buf += decoder.decode(chunk)
441
+ while "\n\n" in buf:
442
+ raw_event, buf = buf.split("\n\n", 1)
443
+ data_lines = []
444
+ for line in raw_event.splitlines():
445
+ if line.startswith("data:"):
446
+ data_lines.append(line[len("data:"):].strip())
447
+ if not data_lines:
448
+ continue
449
+ payload = "\n".join(data_lines)
450
+ if payload == "[DONE]":
451
+ break
452
+ yield json.loads(payload)
453
+
454
+ # flush any partial code points at the end
455
+ buf += decoder.decode(b"", final=True)
456
+ yield buf
457
+ return
458
+ except Exception as err:
459
+ yield {
460
+ "type": "error",
461
+ "errorText": str(err).splitlines()[0],
462
+ }
463
+ return
386
464
 
387
465
  def apply_limit(self, query, limit):
388
466
  # Simple logic: if query already has a limit, don't add another
@@ -396,24 +474,27 @@ class Quill:
396
474
  return query
397
475
  return f"{query.rstrip(';')} limit {limit}"
398
476
 
399
- def normalize_database_type(self, db_type):
400
- if not db_type:
401
- return None
402
- lowered = db_type.lower()
403
- if lowered in ("postgresql", "postgres"):
404
- return "postgres"
405
- return lowered
406
-
407
477
  def run_queries(
408
478
  self, queries, pkDatabaseType, databaseType=None, metadata=None, runQueryConfig=None
409
479
  ):
410
480
  results = {}
411
481
  if not queries:
412
482
  return {"queryResults": []}
413
- normalized_pk = self.normalize_database_type(pkDatabaseType)
414
- normalized_requested = self.normalize_database_type(databaseType)
415
- should_enforce_match = normalized_requested is not None
416
- if should_enforce_match and normalized_requested != normalized_pk:
483
+ def _normalize_db_type(db_type):
484
+ if not db_type:
485
+ return None
486
+ lowered = db_type.lower()
487
+ if lowered in ("postgresql", "postgres"):
488
+ return "postgres"
489
+ return lowered
490
+
491
+ normalized_metadata_db = _normalize_db_type(databaseType)
492
+ normalized_backend_db = _normalize_db_type(pkDatabaseType)
493
+ if (
494
+ normalized_metadata_db
495
+ and normalized_backend_db
496
+ and normalized_metadata_db != normalized_backend_db
497
+ ):
417
498
  return {"dbMismatched": True, "backendDatabaseType": pkDatabaseType}
418
499
  if runQueryConfig and runQueryConfig.get("arrayToMap"):
419
500
  mapped_array = array_to_map(
@@ -26,7 +26,8 @@ def format_bigquery_config(connection_string):
26
26
  "project": service_account.get("project_id"),
27
27
  "credentials": service_account,
28
28
  }
29
- except (ValueError, TypeError):
29
+ except (ValueError, TypeError) as e:
30
+ print("Invalid service account JSON.", e)
30
31
  return connection_string
31
32
 
32
33
 
@@ -29,20 +29,43 @@ class CachedConnection:
29
29
  )
30
30
  return None
31
31
 
32
+ def _is_connection_closed(self):
33
+ return self.connection is None or getattr(self.connection, "closed", True)
34
+
35
+ def _open_connection(self):
36
+ self.connection = connect_to_db(
37
+ self.database_type, self.config, self.using_connection_string
38
+ )
39
+ return self.connection
40
+
41
+ def _close_connection(self):
42
+ if self.connection is None:
43
+ return
44
+ try:
45
+ self.connection.close()
46
+ except Exception:
47
+ pass
48
+ finally:
49
+ self.connection = None
50
+
32
51
  def exec_with_reconnect(self, sql):
33
52
  reconnect_count = 0
34
- while True:
35
- self.connection = connect_to_db(
36
- self.database_type, self.config, self.using_connection_string
37
- )
53
+ last_error = None
54
+ while reconnect_count < 10:
38
55
  try:
56
+ if self._is_connection_closed():
57
+ self._open_connection()
39
58
  return run_query_by_db(self.database_type, sql, self.connection)
40
59
  except psycopg2.Error as err:
41
- reconnect_count = reconnect_count + 1
42
- if reconnect_count < 10:
43
- continue
44
- else:
45
- raise PgQueryError(err, sql, err.diag.statement_position)
60
+ last_error = err
61
+ reconnect_count += 1
62
+ self._close_connection()
63
+ except Exception:
64
+ self._close_connection()
65
+ raise
66
+ diag = getattr(last_error, "diag", None)
67
+ position = getattr(diag, "statement_position", None)
68
+ raise PgQueryError(last_error or "Failed to execute query", sql, position)
46
69
 
47
70
  def exec(self, sql):
48
71
  try:
@@ -2,3 +2,4 @@
2
2
 
3
3
  from .run_query_processes import remove_fields, array_to_map
4
4
  from .filters import Filter, FilterType, FieldType, StringOperator, NumberOperator, NullOperator, DateOperator, convert_custom_filter
5
+ from .post_quill_executor import ParallelPostQuill
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from concurrent.futures import Future, ThreadPoolExecutor
5
+ from typing import Callable, Optional
6
+
7
+
8
+ class ParallelPostQuill:
9
+ """
10
+ Provides a thread-pooled, drop-in replacement for Quill.post_quill.
11
+
12
+ Example
13
+ -------
14
+ parallel_post = ParallelPostQuill(
15
+ quill_factory=lambda: Quill(...),
16
+ max_workers=4,
17
+ )
18
+
19
+ # Synchronous (behaves like the original post_quill)
20
+ response = parallel_post("report", payload)
21
+
22
+ # Async-style execution
23
+ future = parallel_post("report", payload, wait=False)
24
+ response = future.result()
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ quill_factory: Callable[[], "Quill"],
30
+ max_workers: int = 5,
31
+ executor: Optional[ThreadPoolExecutor] = None,
32
+ ):
33
+ if executor is None:
34
+ self._executor = ThreadPoolExecutor(
35
+ max_workers=max_workers,
36
+ thread_name_prefix="post-quill",
37
+ )
38
+ self._owns_executor = True
39
+ else:
40
+ self._executor = executor
41
+ self._owns_executor = False
42
+
43
+ if not callable(quill_factory):
44
+ raise ValueError("quill_factory must be callable and create Quill instances.")
45
+
46
+ self._quill_factory = quill_factory
47
+ self._thread_local = threading.local()
48
+
49
+ def __call__(self, path: str, payload: dict, wait: bool = True):
50
+ future: Future = self._executor.submit(self._invoke, path, payload)
51
+ if wait:
52
+ return future.result()
53
+ return future
54
+
55
+ def shutdown(self, wait: bool = True):
56
+ if self._owns_executor:
57
+ self._executor.shutdown(wait=wait)
58
+
59
+ def _get_quill(self):
60
+ quill = getattr(self._thread_local, "quill", None)
61
+ if quill is None:
62
+ quill = self._quill_factory()
63
+ self._thread_local.quill = quill
64
+ return quill
65
+
66
+ def _invoke(self, path: str, payload: dict):
67
+ quill = self._get_quill()
68
+ return quill.post_quill(path, payload)
69
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quillsql
3
- Version: 2.2.6
3
+ Version: 2.2.8
4
4
  Summary: Quill SDK for Python.
5
5
  Home-page: https://github.com/quill-sql/quill-python
6
6
  Author: Quill
@@ -67,3 +67,44 @@ async def quill_post(data: Request, user: dict = Depends(authenticate_jwt)):
67
67
 
68
68
  Then you can run your app like normally. Pass in this route to our react library
69
69
  on the frontend and you all set!
70
+
71
+ ## Streaming
72
+
73
+ ```python
74
+ from quillsql import Quill
75
+ from fastapi.responses import StreamingResponse
76
+ import asyncio
77
+
78
+ quill = Quill(
79
+ private_key=os.getenv("QULL_PRIVATE_KEY"),
80
+ database_connection_string=os.getenv("POSTGRES_READ"),
81
+ database_type="postgresql"
82
+ )
83
+
84
+ @app.post("/quill-stream")
85
+ async def quill_post(data: Request, user: dict = Depends(authenticate_jwt)):
86
+ # assuming user fetched via auth middleware has an userId
87
+ user_id = user["user_id"]
88
+ body = await data.json()
89
+ metadata = body.get("metadata")
90
+
91
+ quill_stream = quill.stream(
92
+ tenants=[{"tenantField": "user_id", "tenantIds": [user_id]}],
93
+ metadata=metadata,
94
+ )
95
+
96
+ async def event_generator():
97
+ # Full event types list: https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#data-stream-protocol
98
+ async for event in quill_stream:
99
+ if event["type"] == "start":
100
+ pass
101
+ elif event["type"] == "text-delta":
102
+ yield event['delta']
103
+ elif event["type"] == "finish":
104
+ return
105
+ elif event["type"] == "error":
106
+ yield event['errorText']
107
+ await asyncio.sleep(0)
108
+
109
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
110
+ ```
@@ -18,6 +18,7 @@ quillsql/db/postgres.py
18
18
  quillsql/utils/__init__.py
19
19
  quillsql/utils/filters.py
20
20
  quillsql/utils/pivot_template.py
21
+ quillsql/utils/post_quill_executor.py
21
22
  quillsql/utils/run_query_processes.py
22
23
  quillsql/utils/schema_conversion.py
23
24
  quillsql/utils/tenants.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="quillsql",
5
- version="2.2.6",
5
+ version="2.2.8",
6
6
  packages=find_packages(),
7
7
  install_requires=[
8
8
  "psycopg2-binary",
quillsql-2.2.6/README.md DELETED
@@ -1,46 +0,0 @@
1
- # Quill Python SDK
2
-
3
- ## Quickstart
4
-
5
- First, install the quillsql package by running:
6
-
7
- ```bash
8
- $ pip install quillsql
9
- ```
10
-
11
- Then, add a `/quill` endpoint to your existing python server. For example, if
12
- you were running a FASTAPI app, you would just add the endpoint like this:
13
-
14
- ```python
15
- from quillsql import Quill
16
-
17
- quill = Quill(
18
- private_key=os.getenv("QULL_PRIVATE_KEY"),
19
- database_connection_string=os.getenv("POSTGRES_READ"),
20
- database_type="postgresql"
21
- )
22
-
23
- security = HTTPBearer()
24
-
25
- async def authenticate_jwt(token: str = Depends(security)):
26
- # Your JWT validation logic here
27
- # Return user object or raise HTTPException
28
- user = validate_jwt_token(token.credentials)
29
- return user
30
-
31
- @app.post("/quill")
32
- async def quill_post(data: Request, user: dict = Depends(authenticate_jwt)):
33
- # assuming user fetched via auth middleware has an userId
34
- user_id = user["user_id"]
35
- body = await data.json()
36
- metadata = body.get("metadata")
37
-
38
- result = quill.query(
39
- tenants=[{"tenantField": "user_id", "tenantIds": [user_id]}],
40
- metadata=metadata
41
- )
42
- return result
43
- ```
44
-
45
- Then you can run your app like normally. Pass in this route to our react library
46
- on the frontend and you all set!
@@ -1,5 +0,0 @@
1
- # __init__.py
2
-
3
- from .core import Quill
4
- from .error import PgQueryError
5
- from .utils import Filter, FilterType, FieldType, StringOperator, DateOperator, NumberOperator, NullOperator
File without changes
File without changes
File without changes