harlequin-postgres 0.1.2__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.
@@ -0,0 +1,3 @@
1
+ from harlequin_postgres.adapter import HarlequinPostgresAdapter
2
+
3
+ __all__ = ["HarlequinPostgresAdapter"]
@@ -0,0 +1,352 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Sequence
4
+
5
+ from harlequin.adapter import HarlequinAdapter, HarlequinConnection, HarlequinCursor
6
+ from harlequin.catalog import Catalog, CatalogItem
7
+ from harlequin.exception import HarlequinConnectionError, HarlequinQueryError
8
+ from psycopg2 import connect
9
+ from psycopg2.extensions import connection, cursor
10
+ from textual_fastdatatable.backend import AutoBackendType
11
+
12
+ from harlequin_postgres.cli_options import POSTGRES_OPTIONS
13
+
14
+
15
+ class HarlequinPostgresCursor(HarlequinCursor):
16
+ def __init__(self, conn: HarlequinPostgresConnection, cur: cursor) -> None:
17
+ self.conn = conn
18
+ self.cur = cur
19
+ self._limit: int | None = None
20
+
21
+ def columns(self) -> list[tuple[str, str]]:
22
+ assert self.cur.description is not None
23
+ return [
24
+ (col.name, self.conn._get_short_type_from_oid(col.type_code))
25
+ for col in self.cur.description
26
+ ]
27
+
28
+ def set_limit(self, limit: int) -> HarlequinPostgresCursor:
29
+ self._limit = limit
30
+ return self
31
+
32
+ def fetchall(self) -> AutoBackendType:
33
+ try:
34
+ if self._limit is None:
35
+ return self.cur.fetchall()
36
+ else:
37
+ return self.cur.fetchmany(self._limit)
38
+ except Exception as e:
39
+ raise HarlequinQueryError(
40
+ msg=str(e),
41
+ title="Harlequin encountered an error while executing your query.",
42
+ ) from e
43
+ finally:
44
+ self.cur.close()
45
+
46
+
47
+ class HarlequinPostgresConnection(HarlequinConnection):
48
+ def __init__(
49
+ self,
50
+ conn_str: Sequence[str],
51
+ *_: Any,
52
+ init_message: str = "",
53
+ options: dict[str, Any],
54
+ ) -> None:
55
+ self.init_message = init_message
56
+ try:
57
+ if conn_str and conn_str[0]:
58
+ self.conn: connection = connect(dsn=conn_str[0], **options)
59
+ else:
60
+ self.conn = connect(**options)
61
+ except Exception as e:
62
+ raise HarlequinConnectionError(
63
+ msg=str(e), title="Harlequin could not connect to Postgres."
64
+ ) from e
65
+
66
+ def execute(self, query: str) -> HarlequinCursor | None:
67
+ try:
68
+ with self.conn: # autocommit transaction
69
+ cur = self.conn.cursor()
70
+ cur.execute(query=query)
71
+ except Exception as e:
72
+ raise HarlequinQueryError(
73
+ msg=str(e),
74
+ title="Harlequin encountered an error while executing your query.",
75
+ ) from e
76
+ else:
77
+ if cur.description is not None:
78
+ return HarlequinPostgresCursor(self, cur)
79
+ else:
80
+ cur.close()
81
+ return None
82
+
83
+ def get_catalog(self) -> Catalog:
84
+ databases = self._get_databases()
85
+ db_items: list[CatalogItem] = []
86
+ for (db,) in databases:
87
+ schemas = self._get_schemas(db)
88
+ schema_items: list[CatalogItem] = []
89
+ for (schema,) in schemas:
90
+ relations = self._get_relations(db, schema)
91
+ rel_items: list[CatalogItem] = []
92
+ for rel, rel_type in relations:
93
+ cols = self._get_columns(db, schema, rel)
94
+ col_items = [
95
+ CatalogItem(
96
+ qualified_identifier=f'"{db}"."{schema}"."{rel}"."{col}"',
97
+ query_name=f'"{col}"',
98
+ label=col,
99
+ type_label=self._get_short_type(col_type),
100
+ )
101
+ for col, col_type in cols
102
+ ]
103
+ rel_items.append(
104
+ CatalogItem(
105
+ qualified_identifier=f'"{db}"."{schema}"."{rel}"',
106
+ query_name=f'"{db}"."{schema}"."{rel}"',
107
+ label=rel,
108
+ type_label="v" if rel_type == "VIEW" else "t",
109
+ children=col_items,
110
+ )
111
+ )
112
+ schema_items.append(
113
+ CatalogItem(
114
+ qualified_identifier=f'"{db}"."{schema}"',
115
+ query_name=f'"{db}"."{schema}"',
116
+ label=schema,
117
+ type_label="s",
118
+ children=rel_items,
119
+ )
120
+ )
121
+ db_items.append(
122
+ CatalogItem(
123
+ qualified_identifier=f'"{db}"',
124
+ query_name=f'"{db}"',
125
+ label=db,
126
+ type_label="db",
127
+ children=schema_items,
128
+ )
129
+ )
130
+ return Catalog(items=db_items)
131
+
132
+ def _get_databases(self) -> list[tuple[str]]:
133
+ with self.conn.cursor() as cur:
134
+ cur.execute(
135
+ """
136
+ select datname
137
+ from pg_database
138
+ where
139
+ datistemplate is false
140
+ and datallowconn is true
141
+ ;"""
142
+ )
143
+ results = cur.fetchall()
144
+ return results
145
+
146
+ def _get_schemas(self, dbname: str) -> list[tuple[str]]:
147
+ with self.conn.cursor() as cur:
148
+ cur.execute(
149
+ f"""
150
+ select schema_name
151
+ from information_schema.schemata
152
+ where
153
+ catalog_name = '{dbname}'
154
+ and schema_name != 'information_schema'
155
+ and schema_name not like 'pg_%'
156
+ ;"""
157
+ )
158
+ results = cur.fetchall()
159
+ return results
160
+
161
+ def _get_relations(self, dbname: str, schema: str) -> list[tuple[str, str]]:
162
+ with self.conn.cursor() as cur:
163
+ cur.execute(
164
+ f"""
165
+ select table_name, table_type
166
+ from information_schema.tables
167
+ where
168
+ table_catalog = '{dbname}'
169
+ and table_schema = '{schema}'
170
+ ;"""
171
+ )
172
+ results = cur.fetchall()
173
+ return results
174
+
175
+ def _get_columns(
176
+ self, dbname: str, schema: str, relation: str
177
+ ) -> list[tuple[str, str]]:
178
+ with self.conn.cursor() as cur:
179
+ cur.execute(
180
+ f"""
181
+ select column_name, data_type
182
+ from information_schema.columns
183
+ where
184
+ table_catalog = '{dbname}'
185
+ and table_schema = '{schema}'
186
+ and table_name = '{relation}'
187
+ ;"""
188
+ )
189
+ results = cur.fetchall()
190
+ return results
191
+
192
+ @staticmethod
193
+ def _get_short_type(type_name: str) -> str:
194
+ MAPPING = {
195
+ "bigint": "##",
196
+ "bigserial": "##",
197
+ "bit": "010",
198
+ "boolean": "t/f",
199
+ "box": "□",
200
+ "bytea": "b",
201
+ "character": "s",
202
+ "cidr": "ip",
203
+ "circle": "○",
204
+ "date": "d",
205
+ "double": "#.#",
206
+ "inet": "ip",
207
+ "integer": "#",
208
+ "interval": "|-|",
209
+ "json": "{}",
210
+ "jsonb": "b{}",
211
+ "line": "—",
212
+ "lseg": "-",
213
+ "macaddr": "mac",
214
+ "macaddr8": "mac",
215
+ "money": "$$",
216
+ "numeric": "#.#",
217
+ "path": "╭",
218
+ "pg_lsn": "lsn",
219
+ "pg_snapshot": "snp",
220
+ "point": "•",
221
+ "polygon": "▽",
222
+ "real": "#.#",
223
+ "smallint": "#",
224
+ "smallserial": "#",
225
+ "serial": "#",
226
+ "text": "s",
227
+ "time": "t",
228
+ "timestamp": "ts",
229
+ "tsquery": "tsq",
230
+ "tsvector": "tsv",
231
+ "txid_snapshot": "snp",
232
+ "uuid": "uid",
233
+ "xml": "xml",
234
+ "array": "[]",
235
+ }
236
+ return MAPPING.get(type_name.split("(")[0].split(" ")[0], "?")
237
+
238
+ @staticmethod
239
+ def _get_short_type_from_oid(oid: int) -> str:
240
+ MAPPING = {
241
+ 16: "t/f",
242
+ 17: "b",
243
+ 18: "s",
244
+ 19: "s",
245
+ 20: "##",
246
+ 21: "#",
247
+ 22: "[#]",
248
+ 23: "#",
249
+ 25: "s",
250
+ 26: "oid",
251
+ 114: "{}",
252
+ 142: "xml",
253
+ 600: "•",
254
+ 601: "-",
255
+ 602: "╭",
256
+ 603: "□",
257
+ 604: "▽",
258
+ 628: "—",
259
+ 651: "[ip]",
260
+ 700: "#.#",
261
+ 701: "#.#",
262
+ 704: "|-|",
263
+ 718: "○",
264
+ 790: "$$",
265
+ 829: "mac",
266
+ 869: "ip",
267
+ 650: "ip",
268
+ 774: "mac",
269
+ 1000: "[t/f]",
270
+ 1001: "[b]",
271
+ 1002: "[s]",
272
+ 1003: "[s]",
273
+ 1009: "[s]",
274
+ 1013: "[oid]",
275
+ 1014: "[s]",
276
+ 1015: "[s]",
277
+ 1016: "[#]",
278
+ 1021: "[#.#]",
279
+ 1022: "[#.#]",
280
+ 1028: "[oid]",
281
+ 1040: "[mac]",
282
+ 1041: "[ip]",
283
+ 1042: "s",
284
+ 1043: "s",
285
+ 1082: "d",
286
+ 1083: "t",
287
+ 1114: "ts",
288
+ 1115: "[ts]",
289
+ 1182: "[d]",
290
+ 1183: "[t]",
291
+ 1184: "ts",
292
+ 1185: "[ts]",
293
+ 1186: "|-|",
294
+ 1187: "[|-|]",
295
+ 1231: "[#.#]",
296
+ 1266: "t",
297
+ 1270: "[t]",
298
+ 1560: "010",
299
+ 1562: "010",
300
+ 1700: "#.#",
301
+ 2950: "uid",
302
+ 3614: "tsv",
303
+ 3615: "tsq",
304
+ 3802: "b{}",
305
+ }
306
+ return MAPPING.get(oid, "?")
307
+
308
+
309
+ class HarlequinPostgresAdapter(HarlequinAdapter):
310
+ ADAPTER_OPTIONS = POSTGRES_OPTIONS
311
+
312
+ def __init__(
313
+ self,
314
+ conn_str: Sequence[str],
315
+ host: str | None = None,
316
+ port: str | None = None,
317
+ dbname: str | None = None,
318
+ user: str | None = None,
319
+ password: str | None = None,
320
+ passfile: str | None = None,
321
+ require_auth: str | None = None,
322
+ channel_binding: str | None = None,
323
+ connect_timeout: int | None = None,
324
+ sslmode: str | None = None,
325
+ sslcert: str | None = None,
326
+ sslkey: str | None = None,
327
+ **_: Any,
328
+ ) -> None:
329
+ self.conn_str = conn_str
330
+ self.options = {
331
+ "host": host,
332
+ "port": port,
333
+ "dbname": dbname,
334
+ "user": user,
335
+ "password": password,
336
+ "passfile": passfile,
337
+ "require_auth": require_auth,
338
+ "channel_binding": channel_binding,
339
+ "connect_timeout": connect_timeout,
340
+ "sslmode": sslmode,
341
+ "sslcert": sslcert,
342
+ "sslkey": sslkey,
343
+ }
344
+
345
+ def connect(self) -> HarlequinPostgresConnection:
346
+ if len(self.conn_str) > 1:
347
+ raise HarlequinConnectionError(
348
+ "Cannot provide multiple connection strings to the Postgres adapter. "
349
+ f"{self.conn_str}"
350
+ )
351
+ conn = HarlequinPostgresConnection(self.conn_str, options=self.options)
352
+ return conn
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ from harlequin.options import (
4
+ FlagOption, # noqa
5
+ ListOption, # noqa
6
+ PathOption, # noqa
7
+ SelectOption, # noqa
8
+ TextOption,
9
+ )
10
+
11
+ host = TextOption(
12
+ name="host",
13
+ description=(
14
+ "Specifies the host name of the machine on which the server is running. "
15
+ "If the value begins with a slash, it is used as the directory for the "
16
+ "Unix-domain socket."
17
+ ),
18
+ short_decls=["-h"],
19
+ default="localhost",
20
+ )
21
+
22
+
23
+ port = TextOption(
24
+ name="port",
25
+ description=(
26
+ "Port number to connect to at the server host, or socket file name extension "
27
+ "for Unix-domain connections."
28
+ ),
29
+ short_decls=["-p"],
30
+ default="5432",
31
+ )
32
+
33
+
34
+ dbname = TextOption(
35
+ name="dbname",
36
+ description=(
37
+ "Port number to connect to at the server host, or socket file name extension "
38
+ "for Unix-domain connections."
39
+ ),
40
+ short_decls=["-d"],
41
+ default="postgres",
42
+ )
43
+
44
+
45
+ user = TextOption(
46
+ name="user",
47
+ description=("PostgreSQL user name to connect as."),
48
+ short_decls=["-u", "--username", "-U"],
49
+ )
50
+
51
+
52
+ password = TextOption(
53
+ name="password",
54
+ description=("Password to be used if the server demands password authentication."),
55
+ )
56
+
57
+
58
+ passfile = PathOption(
59
+ name="passfile",
60
+ description=(
61
+ "Specifies the name of the file used to store passwords. Defaults to "
62
+ "~/.pgpass, or %APPDATA%\postgresql\pgpass.conf on Windows. (No error is "
63
+ "reported if this file does not exist.)"
64
+ ),
65
+ resolve_path=True,
66
+ exists=False,
67
+ file_okay=True,
68
+ dir_okay=False,
69
+ )
70
+
71
+ require_auth = SelectOption(
72
+ name="require_auth",
73
+ description=(
74
+ "Specifies the authentication method that the client requires from the server. "
75
+ "If the server does not use the required method to authenticate the client, or "
76
+ "if the authentication handshake is not fully completed by the server, the "
77
+ "connection will fail."
78
+ ),
79
+ choices=["password", "md5", "gss", "sspi", "scram-sha-256", "none"],
80
+ )
81
+
82
+ channel_binding = SelectOption(
83
+ name="channel_binding",
84
+ description=(
85
+ "This option controls the client's use of channel binding. A setting of "
86
+ "require means that the connection must employ channel binding, prefer "
87
+ "means that the client will choose channel binding if available, and "
88
+ "disable prevents the use of channel binding. The default is prefer if "
89
+ "PostgreSQL is compiled with SSL support; otherwise the default is disable."
90
+ ),
91
+ choices=["require", "prefer", "disable"],
92
+ )
93
+
94
+
95
+ def _int_validator(s: str | None) -> tuple[bool, str]:
96
+ if s is None:
97
+ return True, ""
98
+ try:
99
+ _ = int(s)
100
+ except ValueError:
101
+ return False, f"Cannot convert {s} to an int!"
102
+ else:
103
+ return True, ""
104
+
105
+
106
+ connect_timeout = TextOption(
107
+ name="connect_timeout",
108
+ description=(
109
+ "Maximum time to wait while connecting, in seconds (write as an integer, "
110
+ "e.g., 10)."
111
+ ),
112
+ validator=_int_validator,
113
+ )
114
+
115
+ sslmode = SelectOption(
116
+ name="sslmode",
117
+ description=(
118
+ "Determines whether or with what priority a secure SSL TCP/IP connection will "
119
+ "be negotiated with the server."
120
+ ),
121
+ choices=["disable", "allow", "prefer", "require", "verify-ca", "verify-full"],
122
+ default="prefer",
123
+ )
124
+
125
+ sslcert = PathOption(
126
+ name="sslcert",
127
+ description=(
128
+ "Specifies the file name of the client SSL certificate. "
129
+ "Ignored if an SSL connection is not made."
130
+ ),
131
+ default="~/.postgresql/postgresql.crt",
132
+ )
133
+
134
+ sslkey = TextOption(
135
+ name="sslkey",
136
+ description=(
137
+ "Specifies the location for the secret key used for the client certificate. "
138
+ "It can either specify a file name that will be used instead of the default "
139
+ "~/.postgresql/postgresql.key, or it can specify a key obtained from an "
140
+ "external engine. An external engine specification should consist of a "
141
+ "colon-separated engine name and an engine-specific key identifier. This "
142
+ "parameter is ignored if an SSL connection is not made."
143
+ ),
144
+ )
145
+
146
+
147
+ POSTGRES_OPTIONS = [
148
+ host,
149
+ port,
150
+ dbname,
151
+ user,
152
+ password,
153
+ passfile,
154
+ require_auth,
155
+ channel_binding,
156
+ connect_timeout,
157
+ sslmode,
158
+ sslcert,
159
+ sslkey,
160
+ ]
File without changes
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Ted Conbeer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.1
2
+ Name: harlequin-postgres
3
+ Version: 0.1.2
4
+ Summary: A Harlequin adapter for Postgres.
5
+ License: MIT
6
+ Author: Ted Conbeer
7
+ Author-email: tconbeer@users.noreply.github.com
8
+ Requires-Python: >=3.8.1,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Requires-Dist: harlequin (>=1.4,<2.0)
15
+ Requires-Dist: psycopg2-binary (>=2.9.9,<3.0.0)
16
+ Description-Content-Type: text/markdown
17
+
18
+ # harlequin-postgres
19
+
20
+ This repo provides the Harlequin adapter for Postgres.
21
+
22
+ ## Installation
23
+
24
+ `harlequin-postgres` depends on `harlequin`, so installing this package will also install Harlequin.
25
+
26
+ ### Using pip
27
+
28
+ To install this adapter into an activated virtual environment:
29
+ ```bash
30
+ pip install harlequin-postgres
31
+ ```
32
+
33
+ ### Using poetry
34
+
35
+ ```bash
36
+ poetry add harlequin-postgres
37
+ ```
38
+
39
+ ### Using pipx
40
+
41
+ If you do not already have Harlequin installed:
42
+
43
+ ```bash
44
+ pip install harlequin-postgres
45
+ ```
46
+
47
+ If you would like to add the Postgres adapter to an existing Harlequin installation:
48
+
49
+ ```bash
50
+ pipx inject harlequin harlequin-postgres
51
+ ```
52
+
53
+ ### As an Extra
54
+ Alternatively, you can install Harlequin with the `postgres` extra:
55
+
56
+ ```bash
57
+ pip install harlequin[postgres]
58
+ ```
59
+
60
+ ```bash
61
+ poetry add harlequin[postgres]
62
+ ```
63
+
64
+ ```bash
65
+ pipx install harlequin[postgres]
66
+ ```
67
+
68
+ ## Usage and Configuration
69
+
70
+ You can open Harlequin with the Postgres adapter by selecting it with the `-a` option and passing a [Posgres DSN](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING):
71
+
72
+ ```bash
73
+ harlequin -a postgres "postgres://my-user:my-pass@localhost:5432/my-database"
74
+ ```
75
+
76
+ You can also pass all or parts of the connection string as separate options. The following is equivalent to the above DSN:
77
+
78
+ ```bash
79
+ harlequin -a postgres -h localhost -p 5432 -U my-user --password my-pass -d my-database
80
+ ```
81
+
82
+ Many more options are available; to see the full list, run:
83
+
84
+ ```bash
85
+ harlequin --help
86
+ ```
87
+
88
+ For more information, see the [Harlequin Docs](https://harlequin.sh/docs/postgres/index).
@@ -0,0 +1,9 @@
1
+ harlequin_postgres/__init__.py,sha256=ZUrCa_NVFJRbvxKXuzBhW2VS1RlR4hTJFL210L0r-x8,104
2
+ harlequin_postgres/adapter.py,sha256=WbAnZWkQmDJ6YOD_TFsFzzjdlW0F6SRDtFzTEG4Ejqk,11202
3
+ harlequin_postgres/cli_options.py,sha256=xHgSrN_BFmODCdDLUEYvRQ_X8salEqzhTGhQ3CE2-n0,4344
4
+ harlequin_postgres/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ harlequin_postgres-0.1.2.dist-info/LICENSE,sha256=Wdf1mXEX48JTe3Unchs3u8M7sUOB6v0fGsMI5WP7bB4,1068
6
+ harlequin_postgres-0.1.2.dist-info/METADATA,sha256=krvCYcA0Uwj-JJFX_Xa0Da9llKZRRbqgTLdNXljBMkc,2161
7
+ harlequin_postgres-0.1.2.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
8
+ harlequin_postgres-0.1.2.dist-info/entry_points.txt,sha256=wGOeVVp9meAYkVPIny5_dApH0h6Wg3ktne7TlRNnxyI,74
9
+ harlequin_postgres-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.7.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [harlequin.adapter]
2
+ postgres=harlequin_postgres:HarlequinPostgresAdapter
3
+