2
0

pizzasql.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. """
  2. PizzaSQL Client for Python
  3. A simple, Pythonic client for PizzaSQL.
  4. """
  5. from typing import Any, Dict, List, Optional, Union
  6. from dataclasses import dataclass
  7. from urllib.parse import urlparse
  8. import json
  9. try:
  10. import httpx
  11. _client_class = httpx.Client
  12. _async_client_class = httpx.AsyncClient
  13. except ImportError:
  14. import urllib.request
  15. import urllib.error
  16. _client_class = None
  17. _async_client_class = None
  18. @dataclass
  19. class Column:
  20. """Represents a column in a query result."""
  21. name: str
  22. type: str
  23. @dataclass
  24. class QueryResult:
  25. """Result of a SQL query."""
  26. columns: List[Column]
  27. rows: List[Dict[str, Any]]
  28. rows_affected: int
  29. last_insert_id: int
  30. execution_time: str
  31. def __iter__(self):
  32. return iter(self.rows)
  33. def __len__(self):
  34. return len(self.rows)
  35. def __getitem__(self, index):
  36. return self.rows[index]
  37. class PizzaSQLError(Exception):
  38. """Exception raised for PizzaSQL errors."""
  39. def __init__(self, code: str, message: str, details: Optional[Dict] = None):
  40. self.code = code
  41. self.message = message
  42. self.details = details
  43. super().__init__(f"[{code}] {message}")
  44. class PizzaSQL:
  45. """
  46. PizzaSQL client for Python.
  47. Usage:
  48. db = PizzaSQL('http://localhost:8080/mydb', api_key='your-key')
  49. rows = db.sql('SELECT * FROM users')
  50. """
  51. def __init__(
  52. self,
  53. uri: str,
  54. api_key: Optional[str] = None,
  55. timeout: float = 30.0
  56. ):
  57. """
  58. Create a new PizzaSQL connection.
  59. Args:
  60. uri: Database URI (e.g., 'http://localhost:8080/mydb')
  61. api_key: Optional API key for authentication
  62. timeout: Request timeout in seconds
  63. """
  64. parsed = urlparse(uri)
  65. self._base_url = f"{parsed.scheme}://{parsed.netloc}"
  66. self._database = parsed.path.lstrip('/') or None
  67. self._api_key = api_key
  68. self._timeout = timeout
  69. if _client_class:
  70. self._client = _client_class(timeout=timeout)
  71. else:
  72. self._client = None
  73. def _headers(self) -> Dict[str, str]:
  74. headers = {'Content-Type': 'application/json'}
  75. if self._api_key:
  76. headers['Authorization'] = f'Bearer {self._api_key}'
  77. if self._database:
  78. headers['X-Database'] = self._database
  79. return headers
  80. def _request(self, method: str, path: str, data: Optional[Dict] = None) -> Dict:
  81. url = f"{self._base_url}{path}"
  82. headers = self._headers()
  83. if self._client:
  84. # Use httpx
  85. if method == 'GET':
  86. response = self._client.get(url, headers=headers)
  87. else:
  88. response = self._client.post(url, headers=headers, json=data)
  89. result = response.json()
  90. if response.status_code >= 400:
  91. error = result.get('error', {})
  92. raise PizzaSQLError(
  93. error.get('code', 'UNKNOWN'),
  94. error.get('message', 'Unknown error'),
  95. error.get('details')
  96. )
  97. return result
  98. else:
  99. # Fallback to urllib
  100. req = urllib.request.Request(url, headers=headers)
  101. if data:
  102. req.data = json.dumps(data).encode('utf-8')
  103. try:
  104. with urllib.request.urlopen(req, timeout=self._timeout) as response:
  105. return json.loads(response.read().decode('utf-8'))
  106. except urllib.error.HTTPError as e:
  107. result = json.loads(e.read().decode('utf-8'))
  108. error = result.get('error', {})
  109. raise PizzaSQLError(
  110. error.get('code', 'UNKNOWN'),
  111. error.get('message', str(e)),
  112. error.get('details')
  113. )
  114. def _transform_rows(self, columns: List[Dict], rows: List[List]) -> List[Dict[str, Any]]:
  115. """Transform array rows to dictionaries."""
  116. return [
  117. {col['name']: row[i] for i, col in enumerate(columns)}
  118. for row in rows
  119. ]
  120. def query(self, sql: str, params: Optional[List] = None) -> QueryResult:
  121. """
  122. Execute a SQL query and return full result.
  123. Args:
  124. sql: SQL query string
  125. params: Optional list of parameters
  126. Returns:
  127. QueryResult with columns, rows, and metadata
  128. """
  129. result = self._request('POST', '/query', {
  130. 'sql': sql,
  131. 'params': params or []
  132. })
  133. columns = [Column(**col) for col in result.get('columns', [])]
  134. rows = self._transform_rows(result.get('columns', []), result.get('rows', []))
  135. return QueryResult(
  136. columns=columns,
  137. rows=rows,
  138. rows_affected=result.get('rowsAffected', 0),
  139. last_insert_id=result.get('lastInsertId', 0),
  140. execution_time=result.get('executionTime', '')
  141. )
  142. def sql(self, sql: str, params: Optional[List] = None) -> List[Dict[str, Any]]:
  143. """
  144. Execute a SQL query and return rows.
  145. Args:
  146. sql: SQL query string
  147. params: Optional list of parameters
  148. Returns:
  149. List of row dictionaries
  150. """
  151. return self.query(sql, params).rows
  152. def execute(
  153. self,
  154. statements: List[Dict[str, Any]],
  155. transaction: bool = True
  156. ) -> Dict[str, Any]:
  157. """
  158. Execute multiple statements in a batch.
  159. Args:
  160. statements: List of {'sql': ..., 'params': [...]} dicts
  161. transaction: Whether to wrap in a transaction
  162. Returns:
  163. Execution result with affected rows
  164. """
  165. return self._request('POST', '/execute', {
  166. 'statements': [
  167. {'sql': s['sql'], 'params': s.get('params', [])}
  168. for s in statements
  169. ],
  170. 'transaction': transaction
  171. })
  172. def tables(self) -> List[str]:
  173. """
  174. List all tables in the database.
  175. Returns:
  176. List of table names
  177. """
  178. result = self._request('GET', '/schema/tables')
  179. return result.get('tables', [])
  180. def schema(self, table_name: str) -> List[Column]:
  181. """
  182. Get schema for a specific table.
  183. Args:
  184. table_name: Name of the table
  185. Returns:
  186. List of Column objects
  187. """
  188. result = self._request('GET', f'/schema/tables/{table_name}')
  189. return [Column(**col) for col in result.get('columns', [])]
  190. def health(self) -> Dict[str, str]:
  191. """
  192. Check database health.
  193. Returns:
  194. Health status dict
  195. """
  196. return self._request('GET', '/health')
  197. def use(self, database: str) -> 'PizzaSQL':
  198. """
  199. Create a new client for a different database.
  200. Args:
  201. database: Database name
  202. Returns:
  203. New PizzaSQL client
  204. """
  205. client = PizzaSQL(self._base_url, self._api_key, self._timeout)
  206. client._database = database
  207. return client
  208. def close(self):
  209. """Close the underlying HTTP client."""
  210. if self._client and hasattr(self._client, 'close'):
  211. self._client.close()
  212. def __enter__(self):
  213. return self
  214. def __exit__(self, *args):
  215. self.close()
  216. # Convenience function
  217. def connect(uri: str, api_key: Optional[str] = None) -> PizzaSQL:
  218. """
  219. Create a new PizzaSQL connection.
  220. Args:
  221. uri: Database URI (e.g., 'http://localhost:8080/mydb')
  222. api_key: Optional API key for authentication
  223. Returns:
  224. PizzaSQL client instance
  225. """
  226. return PizzaSQL(uri, api_key)