perspective-python 5.6.0__cp311-abi3-pyemscripten_2026_0_wasm32.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.
Files changed (90) hide show
  1. perspective/__init__.py +403 -0
  2. perspective/extension/finos-perspective-nbextension.json +5 -0
  3. perspective/handlers/__init__.py +11 -0
  4. perspective/handlers/aiohttp.py +69 -0
  5. perspective/handlers/starlette.py +63 -0
  6. perspective/handlers/tornado.py +192 -0
  7. perspective/perspective.abi3.so +0 -0
  8. perspective/templates/exported_widget.html.template +39 -0
  9. perspective/tests/__init__.py +11 -0
  10. perspective/tests/async/test_async_client.py +83 -0
  11. perspective/tests/async/test_websocket_client.py +124 -0
  12. perspective/tests/column_paths.py +33 -0
  13. perspective/tests/conftest.py +276 -0
  14. perspective/tests/core/__init__.py +11 -0
  15. perspective/tests/core/test_async.py +351 -0
  16. perspective/tests/multi_threaded/__init__.py +11 -0
  17. perspective/tests/multi_threaded/test_derived_threaded.py +103 -0
  18. perspective/tests/multi_threaded/test_multi_threaded.py +293 -0
  19. perspective/tests/server/__init__.py +11 -0
  20. perspective/tests/server/test_server.py +1016 -0
  21. perspective/tests/server/test_session.py +110 -0
  22. perspective/tests/table/__init__.py +11 -0
  23. perspective/tests/table/arrow/date32.arrow +0 -0
  24. perspective/tests/table/arrow/date64.arrow +0 -0
  25. perspective/tests/table/arrow/dict.arrow +0 -0
  26. perspective/tests/table/arrow/dict_update.arrow +0 -0
  27. perspective/tests/table/arrow/int_float_str.arrow +0 -0
  28. perspective/tests/table/arrow/int_float_str_file.arrow +0 -0
  29. perspective/tests/table/arrow/int_float_str_update.arrow +0 -0
  30. perspective/tests/table/object_sequence.py +402 -0
  31. perspective/tests/table/test_column_paths.py +88 -0
  32. perspective/tests/table/test_delete.py +124 -0
  33. perspective/tests/table/test_derived.py +145 -0
  34. perspective/tests/table/test_exception.py +73 -0
  35. perspective/tests/table/test_join.py +115 -0
  36. perspective/tests/table/test_leaks.py +105 -0
  37. perspective/tests/table/test_on_remove.py +140 -0
  38. perspective/tests/table/test_ports.py +178 -0
  39. perspective/tests/table/test_remove.py +102 -0
  40. perspective/tests/table/test_table.py +641 -0
  41. perspective/tests/table/test_table_arrow.py +500 -0
  42. perspective/tests/table/test_table_arrow_nested.py +535 -0
  43. perspective/tests/table/test_table_datetime.py +2544 -0
  44. perspective/tests/table/test_table_infer.py +201 -0
  45. perspective/tests/table/test_table_limit.py +45 -0
  46. perspective/tests/table/test_table_numpy.py +1022 -0
  47. perspective/tests/table/test_table_page_to_disk.py +304 -0
  48. perspective/tests/table/test_table_pandas.py +1018 -0
  49. perspective/tests/table/test_table_polars.py +251 -0
  50. perspective/tests/table/test_table_view_table.py +130 -0
  51. perspective/tests/table/test_to_arrow.py +416 -0
  52. perspective/tests/table/test_to_arrow_lz4.py +32 -0
  53. perspective/tests/table/test_to_arrow_zstd.py +41 -0
  54. perspective/tests/table/test_to_format.py +1030 -0
  55. perspective/tests/table/test_to_polars.py +26 -0
  56. perspective/tests/table/test_update.py +561 -0
  57. perspective/tests/table/test_update_arrow.py +980 -0
  58. perspective/tests/table/test_update_pandas.py +211 -0
  59. perspective/tests/table/test_view.py +2503 -0
  60. perspective/tests/table/test_view_expression.py +2256 -0
  61. perspective/tests/test_dependencies.py +53 -0
  62. perspective/tests/viewer/__init__.py +11 -0
  63. perspective/tests/viewer/test_viewer.py +246 -0
  64. perspective/tests/virtual_servers/__init__.py +12 -0
  65. perspective/tests/virtual_servers/test_coerce_types.py +464 -0
  66. perspective/tests/virtual_servers/test_duckdb.py +1280 -0
  67. perspective/tests/virtual_servers/test_polars.py +1075 -0
  68. perspective/tests/virtual_servers/test_postgres.py +964 -0
  69. perspective/tests/widget/__init__.py +11 -0
  70. perspective/tests/widget/test_anywidget_smoke.py +109 -0
  71. perspective/tests/widget/test_widget.py +278 -0
  72. perspective/tests/widget/test_widget_html_export.py +113 -0
  73. perspective/tests/widget/test_widget_pandas.py +453 -0
  74. perspective/virtual_servers/__init__.py +206 -0
  75. perspective/virtual_servers/clickhouse.py +296 -0
  76. perspective/virtual_servers/duckdb.py +367 -0
  77. perspective/virtual_servers/polars.py +750 -0
  78. perspective/virtual_servers/postgres.py +448 -0
  79. perspective/widget/__init__.py +351 -0
  80. perspective/widget/static/perspective-anywidget.css +1 -0
  81. perspective/widget/static/perspective-anywidget.js +31 -0
  82. perspective/widget/viewer/__init__.py +15 -0
  83. perspective/widget/viewer/viewer.py +355 -0
  84. perspective/widget/viewer/viewer_traitlets.py +98 -0
  85. perspective_python-5.6.0.dist-info/METADATA +127 -0
  86. perspective_python-5.6.0.dist-info/RECORD +90 -0
  87. perspective_python-5.6.0.dist-info/WHEEL +4 -0
  88. perspective_python-5.6.0.dist-info/licenses/LICENSE.md +193 -0
  89. perspective_python-5.6.0.dist-info/licenses/LICENSE_THIRDPARTY_cargo.yml +16075 -0
  90. perspective_python-5.6.0.dist-info/sboms/perspective-python.cyclonedx.json +5334 -0
@@ -0,0 +1,448 @@
1
+ # ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ # ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ # ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ # ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ # ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ # ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ # ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ # ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ # ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ # ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ # ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+
14
+ import json
15
+ import logging
16
+ from datetime import date, datetime, time, timezone
17
+ from decimal import Decimal
18
+
19
+ import psycopg
20
+
21
+ import perspective
22
+ from perspective.virtual_servers import VirtualServerHandler, sql_table_describe
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Requires PostgreSQL >= 16: `any_value` is both advertised below and the SQL
27
+ # builder's fallback aggregate for columns with no explicit aggregate.
28
+ #
29
+ # Aggregates, in Postgres's own vocabulary. Only single-argument aggregates
30
+ # with scalar results are expressible - the builder emits `{agg}({col})`, so
31
+ # e.g. two-argument `string_agg` cannot be advertised.
32
+ INT_AGGS = [
33
+ "sum",
34
+ "avg",
35
+ "count",
36
+ "min",
37
+ "max",
38
+ "stddev",
39
+ "stddev_pop",
40
+ "stddev_samp",
41
+ "variance",
42
+ "var_pop",
43
+ "var_samp",
44
+ "bit_and",
45
+ "bit_or",
46
+ "bit_xor",
47
+ "any_value",
48
+ ]
49
+
50
+ # `bit_and`/`bit_or`/`bit_xor` are defined for integral types only.
51
+ FLOAT_AGGS = [a for a in INT_AGGS if not a.startswith("bit_")]
52
+
53
+ STRING_AGGS = [
54
+ "count",
55
+ "min",
56
+ "max",
57
+ "any_value",
58
+ ]
59
+
60
+ # Postgres has no `min(boolean)`/`max(boolean)`.
61
+ BOOL_AGGS = [
62
+ "count",
63
+ "bool_and",
64
+ "bool_or",
65
+ "every",
66
+ "any_value",
67
+ ]
68
+
69
+ # Window functions, in Postgres's own vocabulary - the advertised name is the
70
+ # SQL function, emitted verbatim. Excluded relative to the DuckDB handler:
71
+ # `product` and `median` (no such functions in Postgres), `rate` (its
72
+ # synthesis casts the order key to DOUBLE PRECISION, which errors for
73
+ # timestamp keys). `range` frames are advertised only for numeric source
74
+ # types - Postgres `RANGE n PRECEDING` requires a numeric order key, and
75
+ # errors on timestamps without an interval literal.
76
+ FRAMES = ["rows", "range", "cumulative"]
77
+ FRAMES_ANY = ["rows", "cumulative"]
78
+
79
+ WINDOW_AGGREGATES = [
80
+ {"name": "sum", "frames": FRAMES, "result_type": "float"},
81
+ {"name": "avg", "frames": FRAMES, "result_type": "float"},
82
+ {"name": "count", "frames": FRAMES, "result_type": "float"},
83
+ {"name": "min", "frames": FRAMES},
84
+ {"name": "max", "frames": FRAMES},
85
+ {"name": "stddev_samp", "frames": FRAMES, "result_type": "float"},
86
+ {"name": "stddev_pop", "frames": FRAMES, "result_type": "float"},
87
+ {"name": "var_samp", "frames": FRAMES, "result_type": "float"},
88
+ {"name": "var_pop", "frames": FRAMES, "result_type": "float"},
89
+ # Navigation.
90
+ {"name": "first_value", "frames": FRAMES},
91
+ {"name": "last_value", "frames": FRAMES},
92
+ {"name": "nth_value", "frames": FRAMES, "offset": True},
93
+ {"name": "lag", "offset": True},
94
+ {"name": "lead", "offset": True},
95
+ # Ranking. These take no source column - the window's `order_by` is their
96
+ # input - but Perspective requires one, so the choice of source is
97
+ # immaterial for them. Ranks are `bigint`, which Perspective's 32-bit
98
+ # `integer` cannot hold, so they are `float`.
99
+ {"name": "row_number", "result_type": "float"},
100
+ {"name": "rank", "result_type": "float"},
101
+ {"name": "dense_rank", "result_type": "float"},
102
+ {"name": "percent_rank", "result_type": "float"},
103
+ {"name": "cume_dist", "result_type": "float"},
104
+ # `ntile`'s argument is a bucket count rather than a row offset.
105
+ {"name": "ntile", "offset": True, "result_type": "float"},
106
+ # Perspective's own, with no Postgres equivalent - the SQL translation
107
+ # synthesizes it from `lag`.
108
+ {"name": "diff", "offset": True, "result_type": "float"},
109
+ ]
110
+
111
+ # Arithmetic is undefined for the non-numeric types; ordering and navigation
112
+ # are not.
113
+ WINDOW_AGGREGATES_ANY = [
114
+ {"name": "count", "frames": FRAMES_ANY, "result_type": "float"},
115
+ {"name": "min", "frames": FRAMES_ANY},
116
+ {"name": "max", "frames": FRAMES_ANY},
117
+ {"name": "first_value", "frames": FRAMES_ANY},
118
+ {"name": "last_value", "frames": FRAMES_ANY},
119
+ {"name": "nth_value", "frames": FRAMES_ANY, "offset": True},
120
+ {"name": "lag", "offset": True},
121
+ {"name": "lead", "offset": True},
122
+ {"name": "row_number", "result_type": "float"},
123
+ {"name": "rank", "result_type": "float"},
124
+ {"name": "dense_rank", "result_type": "float"},
125
+ {"name": "percent_rank", "result_type": "float"},
126
+ {"name": "cume_dist", "result_type": "float"},
127
+ {"name": "ntile", "offset": True, "result_type": "float"},
128
+ ]
129
+
130
+ COMPARE_OPS = [
131
+ "==",
132
+ "!=",
133
+ "IS DISTINCT FROM",
134
+ "IS NOT DISTINCT FROM",
135
+ ">=",
136
+ "<=",
137
+ ">",
138
+ "<",
139
+ ]
140
+
141
+ # `LIKE` only for strings - Postgres does not implicitly cast its operand.
142
+ STRING_FILTER_OPS = COMPARE_OPS + ["LIKE"]
143
+
144
+
145
+ class PostgresVirtualSession:
146
+ def __init__(self, callback, conninfo):
147
+ self.session = perspective.VirtualServer(PostgresVirtualServerHandler(conninfo))
148
+ self.callback = callback
149
+
150
+ def handle_request(self, msg):
151
+ self.callback(self.session.handle_request(msg))
152
+
153
+
154
+ class PostgresVirtualServer:
155
+ def __init__(self, conninfo=""):
156
+ self.conninfo = conninfo
157
+
158
+ def new_session(self, callback):
159
+ return PostgresVirtualSession(callback, self.conninfo)
160
+
161
+
162
+ class PostgresVirtualServerHandler(VirtualServerHandler):
163
+ """
164
+ An implementation of a `perspective.VirtualServerHandler` for PostgreSQL
165
+ (16 or later, for `any_value`).
166
+
167
+ Each handler owns one connection, and views are created as `TEMPORARY
168
+ VIEW`s: view names are connection-scoped so sessions cannot collide, every
169
+ viewport read re-plans the view's inner `ORDER BY` (a materialized
170
+ `CREATE TABLE AS` would depend on seq-scan order, which Postgres does not
171
+ guarantee), and all views drop automatically on disconnect.
172
+ """
173
+
174
+ def __init__(self, conninfo):
175
+ self.db = psycopg.connect(conninfo, autocommit=True)
176
+ self.sql_builder = perspective.GenericSQLVirtualServerModel(
177
+ {
178
+ "create_entity": "TEMPORARY VIEW",
179
+ "drop_entity": "VIEW",
180
+ "grouping_fn": "GROUPING",
181
+ "row_id_expr": "ctid",
182
+ "like_escape_clause": "\\",
183
+ "regex_fn": "regexp_like",
184
+ "describe_template": "SELECT * FROM ({}) AS __psp_describe__ LIMIT 0",
185
+ }
186
+ )
187
+
188
+ def get_features(self):
189
+ return {
190
+ "group_by": True,
191
+ # Postgres has no `PIVOT`; pivoting needs a two-pass
192
+ # filtered-aggregate strategy the SQL builder does not yet have.
193
+ "split_by": False,
194
+ "sort": True,
195
+ "expressions": True,
196
+ "view_derivations": True,
197
+ "group_rollup_mode": ["rollup", "flat", "total"],
198
+ # `ctid` is not a stable row identity, so natural-order windows
199
+ # are unsupported.
200
+ "unordered": True,
201
+ "filter_ops": {
202
+ "integer": COMPARE_OPS,
203
+ "float": COMPARE_OPS,
204
+ "string": STRING_FILTER_OPS,
205
+ "boolean": COMPARE_OPS,
206
+ "date": COMPARE_OPS,
207
+ "datetime": COMPARE_OPS,
208
+ },
209
+ "aggregates": {
210
+ "integer": INT_AGGS,
211
+ "float": FLOAT_AGGS,
212
+ "string": STRING_AGGS,
213
+ "boolean": BOOL_AGGS,
214
+ "date": STRING_AGGS,
215
+ "datetime": STRING_AGGS,
216
+ },
217
+ "window_aggregates": {
218
+ "integer": WINDOW_AGGREGATES,
219
+ "float": WINDOW_AGGREGATES,
220
+ "string": WINDOW_AGGREGATES_ANY,
221
+ "boolean": WINDOW_AGGREGATES_ANY,
222
+ "date": WINDOW_AGGREGATES_ANY,
223
+ "datetime": WINDOW_AGGREGATES_ANY,
224
+ },
225
+ }
226
+
227
+ def get_hosted_tables(self):
228
+ # `information_schema.tables` omits materialized views, so query the
229
+ # catalog directly.
230
+ query = """
231
+ SELECT n.nspname, c.relname
232
+ FROM pg_catalog.pg_class c
233
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
234
+ WHERE c.relkind IN ('r', 'v', 'm', 'f', 'p')
235
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
236
+ AND n.nspname NOT LIKE 'pg_toast%'
237
+ AND n.nspname NOT LIKE 'pg_temp%'
238
+ ORDER BY 1, 2
239
+ """
240
+ results = run_query(self.db, query)
241
+ return [f"{result[0]}.{result[1]}" for result in results]
242
+
243
+ def _describe(self, entity_id):
244
+ """The column name/OID pairs of any relation or query result.
245
+
246
+ `SELECT ... LIMIT 0` planning is the one schema mechanism that works
247
+ uniformly for tables, temporary views (which hide in `pg_temp`), and
248
+ expression validation - Postgres has no `DESCRIBE`.
249
+ """
250
+ cur = run_query(self.db, f"SELECT * FROM {entity_id} LIMIT 0", cursor=True)
251
+ return [(d.name, d.type_code) for d in cur.description]
252
+
253
+ def table_schema(self, table_name, config=None):
254
+ schema = {}
255
+ for col_name, oid in self._describe(table_name):
256
+ if not col_name.startswith("__"):
257
+ schema[col_name] = pg_oid_to_psp(oid, col_name)
258
+
259
+ return schema
260
+
261
+ def view_column_size(self, view_name, config):
262
+ n = len(self._describe(view_name))
263
+ gs = len(config["group_by"])
264
+ if gs > 0:
265
+ # Group views carry `__ROW_PATH_N__` columns, plus
266
+ # `__GROUPING_ID__` except in flat mode.
267
+ n -= gs
268
+ if config.get("group_rollup_mode", "rollup") != "flat":
269
+ n -= 1
270
+
271
+ return n
272
+
273
+ def table_size(self, table_name):
274
+ query = self.sql_builder.table_size(table_name)
275
+ results = run_query(self.db, query)
276
+ return results[0][0]
277
+
278
+ def table_make_view(self, table_name, view_name, config):
279
+ # Window order keys need column types for `range` frame emission.
280
+ schema = self.table_schema(table_name) if config.get("windows") else None
281
+ query = self.sql_builder.table_make_view(table_name, view_name, config, schema)
282
+ run_query(self.db, query, execute=True)
283
+
284
+ def table_describe(self, table_name, config):
285
+ schema = self.table_schema(table_name) if config.get("windows") else None
286
+ return sql_table_describe(
287
+ self.sql_builder, table_name, config, self._describe_query, schema
288
+ )
289
+
290
+ def _describe_query(self, query):
291
+ cur = run_query(self.db, query, cursor=True)
292
+ return {
293
+ d.name: pg_oid_to_psp(d.type_code, d.name)
294
+ for d in cur.description
295
+ if not d.name.startswith("__")
296
+ }
297
+
298
+ def view_make_table(self, view_name, table_name, config, schema=None):
299
+ query = self.sql_builder.view_make_table(
300
+ view_name, table_name, config, self.table_schema(view_name), schema
301
+ )
302
+
303
+ run_query(self.db, query, execute=True)
304
+
305
+ def view_delete(self, view_name):
306
+ query = self.sql_builder.view_delete(view_name)
307
+ run_query(self.db, query, execute=True)
308
+
309
+ def view_get_min_max(self, view_name, column_name, config):
310
+ query = self.sql_builder.view_get_min_max(view_name, column_name, config)
311
+ results = run_query(self.db, query)
312
+ row = results[0]
313
+ return (pg_to_py(row[0]), pg_to_py(row[1]))
314
+
315
+ def view_get_data(self, view_name, config, schema, viewport, data):
316
+ group_by = config["group_by"]
317
+ is_flat = config.get("group_rollup_mode", "rollup") == "flat"
318
+ query = self.sql_builder.view_get_data(view_name, config, viewport, schema)
319
+ cur = run_query(self.db, query, cursor=True)
320
+ results = cur.fetchall()
321
+ for cidx, desc in enumerate(cur.description):
322
+ dtype = pg_oid_to_psp(desc.type_code, desc.name)
323
+ for ridx, row in enumerate(results):
324
+ # `__ROW_PATH_N__` cells are kept only while the row's
325
+ # grouping id marks that level as un-rolled-up; flat views
326
+ # have no `__GROUPING_ID__` column and keep every level.
327
+ if len(group_by) == 0:
328
+ grouping_id = None
329
+ elif is_flat:
330
+ grouping_id = 0
331
+ else:
332
+ grouping_id = row[0]
333
+
334
+ value = pg_to_py(row[cidx])
335
+ if (
336
+ dtype == "string"
337
+ and value is not None
338
+ and not isinstance(value, str)
339
+ ):
340
+ value = str(value)
341
+
342
+ data.set_col(dtype, desc.name, ridx, value, grouping_id)
343
+
344
+
345
+ ################################################################################
346
+ #
347
+ # Postgres Utils
348
+
349
+ # Standard `pg_type` OIDs, stable across every supported Postgres version.
350
+ _BOOL_OIDS = frozenset([16])
351
+ _INT_OIDS = frozenset([21, 23]) # int2, int4
352
+ _FLOAT_OIDS = frozenset(
353
+ [20, 26, 700, 701, 790, 1700]
354
+ ) # int8, oid, float4/8, money, numeric
355
+ _DATE_OIDS = frozenset([1082])
356
+ _DATETIME_OIDS = frozenset([1083, 1114, 1184, 1266]) # time, timestamp(tz), timetz
357
+ _STRING_OIDS = frozenset(
358
+ [
359
+ 17, # bytea
360
+ 18, # char
361
+ 19, # name
362
+ 25, # text
363
+ 114, # json
364
+ 142, # xml
365
+ 1042, # bpchar
366
+ 1043, # varchar
367
+ 1186, # interval
368
+ 2950, # uuid
369
+ 3802, # jsonb
370
+ ]
371
+ )
372
+
373
+
374
+ def pg_oid_to_psp(oid, col_name=""):
375
+ """Convert a `pg_type` OID to a Perspective `ColumnType`.
376
+
377
+ Must agree with the value normalization in `pg_to_py`, which decides the
378
+ Python value the same column's data arrives as - the two are halves of
379
+ one contract.
380
+
381
+ `int8` and `numeric` go to `float` because Perspective's `integer` is
382
+ 32-bit; `time` goes to `datetime` because it renders as one. Everything
383
+ unrecognized - arrays, enums, ranges, user-defined types - renders as
384
+ text. Unknown is not fatal: raising here would take down the whole table
385
+ for one odd column.
386
+ """
387
+ if oid in _BOOL_OIDS:
388
+ return "boolean"
389
+
390
+ if oid in _INT_OIDS:
391
+ return "integer"
392
+
393
+ if oid in _FLOAT_OIDS:
394
+ return "float"
395
+
396
+ if oid in _DATE_OIDS:
397
+ return "date"
398
+
399
+ if oid in _DATETIME_OIDS:
400
+ return "datetime"
401
+
402
+ if oid not in _STRING_OIDS:
403
+ logger.warning(f"Unknown type OID '{oid}' for column '{col_name}'")
404
+
405
+ return "string"
406
+
407
+
408
+ def pg_to_py(value):
409
+ """Normalize a psycopg result value for `PerspectiveColumn.set_col`.
410
+
411
+ `datetime` first: it subclasses `date`, and the binding's date branch
412
+ would truncate the time-of-day. Naive timestamps are taken as UTC (the
413
+ engine's convention); `time` renders on the epoch date, matching how
414
+ narrower temporal types coerce in the Arrow path.
415
+ """
416
+ if isinstance(value, Decimal):
417
+ return float(value)
418
+
419
+ if isinstance(value, datetime):
420
+ if value.tzinfo is None:
421
+ value = value.replace(tzinfo=timezone.utc)
422
+
423
+ return int(value.timestamp() * 1000)
424
+
425
+ if isinstance(value, time):
426
+ return pg_to_py(datetime.combine(date(1970, 1, 1), value))
427
+
428
+ if isinstance(value, (dict, list)):
429
+ return json.dumps(value)
430
+
431
+ return value
432
+
433
+
434
+ def run_query(db, query, execute=False, cursor=False):
435
+ query = " ".join(query.split())
436
+ start = datetime.now(datetime.UTC)
437
+ try:
438
+ cur = db.execute(query)
439
+ except psycopg.Error as e:
440
+ logger.error(e)
441
+ logger.error(f"{query}")
442
+ raise
443
+ else:
444
+ logger.debug(f"{datetime.now(datetime.UTC) - start} {query}")
445
+ if cursor:
446
+ return cur
447
+ elif not execute:
448
+ return cur.fetchall()