erdscope 0.1.0__tar.gz → 0.2.0__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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: erdscope
3
- Version: 0.1.0
4
- Summary: Interactive, self-contained ER-diagram HTML and Excel table definitions from a live MySQL database — single file, zero required dependencies
3
+ Version: 0.2.0
4
+ Summary: Interactive, self-contained ER-diagram HTML and Excel table definitions from a live MySQL or PostgreSQL database — single file, zero required dependencies
5
5
  Author: tas6
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/orapli/erdscope
@@ -22,17 +22,30 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Provides-Extra: mysql
24
24
  Requires-Dist: PyMySQL; extra == "mysql"
25
+ Provides-Extra: postgres
26
+ Requires-Dist: psycopg[binary]; extra == "postgres"
25
27
  Provides-Extra: yaml
26
28
  Requires-Dist: PyYAML; extra == "yaml"
27
29
  Provides-Extra: all
28
30
  Requires-Dist: PyMySQL; extra == "all"
31
+ Requires-Dist: psycopg[binary]; extra == "all"
29
32
  Requires-Dist: PyYAML; extra == "all"
30
33
  Dynamic: license-file
31
34
 
32
35
  # erdscope
33
36
 
37
+ [![CI](https://github.com/orapli/erdscope/actions/workflows/ci.yml/badge.svg)](https://github.com/orapli/erdscope/actions/workflows/ci.yml)
38
+ [![PyPI](https://img.shields.io/pypi/v/erdscope)](https://pypi.org/project/erdscope/)
39
+
34
40
  Generate a **self-contained, interactive ER diagram** — and an **Excel table-definition
35
- workbook** — from a live MySQL database, with a single-file, zero-dependency Python CLI.
41
+ workbook** — from a live MySQL or PostgreSQL database, with a single-file,
42
+ zero-dependency Python CLI.
43
+
44
+ ```bash
45
+ pip install erdscope
46
+ erdscope mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
47
+ erdscope postgres://readonly@127.0.0.1:5432/myapp_production -o erd.html
48
+ ```
36
49
 
37
50
  The database is the source of truth (tables, columns, comments, indexes, real foreign
38
51
  keys). Application code (Rails / Prisma / Django) can optionally be layered on top to
@@ -52,17 +65,29 @@ Regenerate it anytime with `python3 docs/gen_demo.py`.
52
65
  **[Read the user manual →](https://orapli.github.io/erdscope/manual.html)** — installation,
53
66
  CLI/config reference, a full viewer guide, and troubleshooting. ([日本語版 →](https://orapli.github.io/erdscope/manual.ja.html))
54
67
 
55
- ## Usage
68
+ ## Install & usage
69
+
70
+ `pip install erdscope` (or `pipx install erdscope`) gives you the `erdscope` command.
71
+ Prefer not to install anything? `erd.py` is a single, dependency-free file — grab it
72
+ and run it with any Python 3.9+:
56
73
 
57
74
  ```bash
58
- python3 erd.py mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
75
+ curl -O https://raw.githubusercontent.com/orapli/erdscope/main/erd.py
76
+ python3 erd.py ... # identical to the erdscope command below
77
+ ```
78
+
79
+ ```bash
80
+ erdscope mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
81
+
82
+ # PostgreSQL: same thing (schema defaults to public; override with ?schema=name)
83
+ erdscope postgres://readonly@127.0.0.1:5432/myapp_production -o erd.html
59
84
 
60
85
  # enrich with association semantics parsed from application code (optional)
61
- python3 erd.py mysql://readonly@127.0.0.1:3306/myapp_production \
86
+ erdscope mysql://readonly@127.0.0.1:3306/myapp_production \
62
87
  --models /path/to/rails/app -o erd.html
63
88
 
64
89
  # also write a table-definition workbook
65
- python3 erd.py mysql://readonly@127.0.0.1:3306/myapp_production \
90
+ erdscope mysql://readonly@127.0.0.1:3306/myapp_production \
66
91
  --excel table_definitions.xlsx -o erd.html
67
92
  ```
68
93
 
@@ -70,7 +95,7 @@ Behind a bastion? Open an SSH tunnel first and point at localhost:
70
95
 
71
96
  ```bash
72
97
  ssh -N -L 3307:db-host:3306 bastion &
73
- python3 erd.py mysql://readonly@127.0.0.1:3307/myapp_production -o erd.html
98
+ erdscope mysql://readonly@127.0.0.1:3307/myapp_production -o erd.html
74
99
  ```
75
100
 
76
101
  Use a read-only account, and leave the password out of the URL — if it's not in
@@ -99,8 +124,8 @@ itself is made.
99
124
  Once the flag list above gets long, put it in a config file instead — `.erdscope.json`
100
125
  (or `.yml`/`.yaml` with PyYAML) next to where you run the tool is picked up automatically.
101
126
  Most keys mirror a CLI option (an explicit flag always wins over the config); the DB
102
- connection is config-only as `host`/`port`/`user`/`database` — deliberately with no
103
- password field — and `relations` manually declares relations no FK, code, or inference
127
+ connection is config-only as `engine`/`host`/`port`/`user`/`database` — deliberately with
128
+ no password field — and `relations` manually declares relations no FK, code, or inference
104
129
  can find. See the [Config file chapter of the manual](https://orapli.github.io/erdscope/manual.html#config-file) for the full key
105
130
  list and semantics, and [`erdscope.example.yml`](erdscope.example.yml) for a fully
106
131
  annotated sample based on the live demo's schema.
@@ -109,14 +134,17 @@ annotated sample based on the live demo's schema.
109
134
 
110
135
  `erd.py` runs with **zero required dependencies** — everything below is optional, and
111
136
  the tool degrades gracefully (falls back, or fails with a clear message) when a piece
112
- is missing.
137
+ is missing. If you installed via pip, extras pull them in for you:
138
+ `pip install 'erdscope[mysql]'` (PyMySQL), `'erdscope[postgres]'` (psycopg),
139
+ `'erdscope[yaml]'` (PyYAML), or `'erdscope[all]'`.
113
140
 
114
141
  | Library | Used for | If not installed |
115
142
  |---|---|---|
116
- | [PyMySQL](https://pypi.org/project/PyMySQL/) | The DB connection | Falls back to shelling out to the `mysql` CLI (must be on `PATH`) |
143
+ | [PyMySQL](https://pypi.org/project/PyMySQL/) | MySQL connections | Falls back to shelling out to the `mysql` CLI (must be on `PATH`) |
144
+ | [psycopg](https://pypi.org/project/psycopg/) (or psycopg2) | PostgreSQL connections | Falls back to shelling out to the `psql` CLI (must be on `PATH`) |
117
145
  | [PyYAML](https://pypi.org/project/PyYAML/) | Reading a `.yml`/`.yaml` config file | A `.json` config still works with no dependency; pointing `--config`/auto-discovery at a `.yml`/`.yaml` file without PyYAML installed exits with a clear error |
118
146
 
119
- Excel output (`--excel`) needs neither — it's written directly via the stdlib
147
+ Excel output (`--excel`) needs none of these — it's written directly via the stdlib
120
148
  `zipfile`/XML, not a spreadsheet library.
121
149
 
122
150
  Test-only, and only if you run that particular suite:
@@ -131,7 +159,8 @@ Test-only, and only if you run that particular suite:
131
159
  Feature highlights — each link goes to the relevant [manual](https://orapli.github.io/erdscope/manual.html) chapter:
132
160
 
133
161
  - **Database truth** — tables, columns (full SQL types, defaults, extras), table and
134
- column comments, indexes, and real FK constraints, read from `information_schema`
162
+ column comments, indexes, and real FK constraints, read from the database catalog
163
+ (`information_schema` on MySQL, `pg_catalog` on PostgreSQL)
135
164
  - **Code semantics on top** — `--models` merges Rails / Prisma / Django associations;
136
165
  declared, DB-FK, and inferred edges stay [visually distinct](https://orapli.github.io/erdscope/manual.html#viewer-edges)
137
166
  - **[Interactive exploration](https://orapli.github.io/erdscope/manual.html#viewer-guide)** — focus with depth and dependency
@@ -146,7 +175,8 @@ Feature highlights — each link goes to the relevant [manual](https://orapli.gi
146
175
  - **[Logical names](https://orapli.github.io/erdscope/manual.html#viewer-names)** — a table's DB comment doubles as a searchable
147
176
  logical name (e.g. `users(Customer accounts)`), with independent display modes for
148
177
  the live view and exports
149
- - **Extras** — dark mode, print stylesheet, resizable/collapsible panes
178
+ - **Extras** — a built-in `?` shortcuts/help popup, dark mode, print stylesheet,
179
+ resizable/collapsible panes
150
180
 
151
181
  ## Tests
152
182
 
@@ -170,8 +200,9 @@ python3 -m unittest tests.test_e2e -v
170
200
  ## Extending
171
201
 
172
202
  Everything downstream (UI, layouts, exports) consumes the intermediate representation
173
- documented at the top of `erd.py`. Adding PostgreSQL support means adding one
174
- `parse_postgres()` that produces that shape.
203
+ documented at the top of `erd.py`. Adding another database engine means adding one
204
+ parser that produces that shape — `parse_postgres()` (which reuses the MySQL
205
+ adapter's IR builder wholesale) is the working example of the pattern.
175
206
 
176
207
  ## License
177
208
 
@@ -1,7 +1,17 @@
1
1
  # erdscope
2
2
 
3
+ [![CI](https://github.com/orapli/erdscope/actions/workflows/ci.yml/badge.svg)](https://github.com/orapli/erdscope/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/erdscope)](https://pypi.org/project/erdscope/)
5
+
3
6
  Generate a **self-contained, interactive ER diagram** — and an **Excel table-definition
4
- workbook** — from a live MySQL database, with a single-file, zero-dependency Python CLI.
7
+ workbook** — from a live MySQL or PostgreSQL database, with a single-file,
8
+ zero-dependency Python CLI.
9
+
10
+ ```bash
11
+ pip install erdscope
12
+ erdscope mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
13
+ erdscope postgres://readonly@127.0.0.1:5432/myapp_production -o erd.html
14
+ ```
5
15
 
6
16
  The database is the source of truth (tables, columns, comments, indexes, real foreign
7
17
  keys). Application code (Rails / Prisma / Django) can optionally be layered on top to
@@ -21,17 +31,29 @@ Regenerate it anytime with `python3 docs/gen_demo.py`.
21
31
  **[Read the user manual →](https://orapli.github.io/erdscope/manual.html)** — installation,
22
32
  CLI/config reference, a full viewer guide, and troubleshooting. ([日本語版 →](https://orapli.github.io/erdscope/manual.ja.html))
23
33
 
24
- ## Usage
34
+ ## Install & usage
35
+
36
+ `pip install erdscope` (or `pipx install erdscope`) gives you the `erdscope` command.
37
+ Prefer not to install anything? `erd.py` is a single, dependency-free file — grab it
38
+ and run it with any Python 3.9+:
25
39
 
26
40
  ```bash
27
- python3 erd.py mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
41
+ curl -O https://raw.githubusercontent.com/orapli/erdscope/main/erd.py
42
+ python3 erd.py ... # identical to the erdscope command below
43
+ ```
44
+
45
+ ```bash
46
+ erdscope mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
47
+
48
+ # PostgreSQL: same thing (schema defaults to public; override with ?schema=name)
49
+ erdscope postgres://readonly@127.0.0.1:5432/myapp_production -o erd.html
28
50
 
29
51
  # enrich with association semantics parsed from application code (optional)
30
- python3 erd.py mysql://readonly@127.0.0.1:3306/myapp_production \
52
+ erdscope mysql://readonly@127.0.0.1:3306/myapp_production \
31
53
  --models /path/to/rails/app -o erd.html
32
54
 
33
55
  # also write a table-definition workbook
34
- python3 erd.py mysql://readonly@127.0.0.1:3306/myapp_production \
56
+ erdscope mysql://readonly@127.0.0.1:3306/myapp_production \
35
57
  --excel table_definitions.xlsx -o erd.html
36
58
  ```
37
59
 
@@ -39,7 +61,7 @@ Behind a bastion? Open an SSH tunnel first and point at localhost:
39
61
 
40
62
  ```bash
41
63
  ssh -N -L 3307:db-host:3306 bastion &
42
- python3 erd.py mysql://readonly@127.0.0.1:3307/myapp_production -o erd.html
64
+ erdscope mysql://readonly@127.0.0.1:3307/myapp_production -o erd.html
43
65
  ```
44
66
 
45
67
  Use a read-only account, and leave the password out of the URL — if it's not in
@@ -68,8 +90,8 @@ itself is made.
68
90
  Once the flag list above gets long, put it in a config file instead — `.erdscope.json`
69
91
  (or `.yml`/`.yaml` with PyYAML) next to where you run the tool is picked up automatically.
70
92
  Most keys mirror a CLI option (an explicit flag always wins over the config); the DB
71
- connection is config-only as `host`/`port`/`user`/`database` — deliberately with no
72
- password field — and `relations` manually declares relations no FK, code, or inference
93
+ connection is config-only as `engine`/`host`/`port`/`user`/`database` — deliberately with
94
+ no password field — and `relations` manually declares relations no FK, code, or inference
73
95
  can find. See the [Config file chapter of the manual](https://orapli.github.io/erdscope/manual.html#config-file) for the full key
74
96
  list and semantics, and [`erdscope.example.yml`](erdscope.example.yml) for a fully
75
97
  annotated sample based on the live demo's schema.
@@ -78,14 +100,17 @@ annotated sample based on the live demo's schema.
78
100
 
79
101
  `erd.py` runs with **zero required dependencies** — everything below is optional, and
80
102
  the tool degrades gracefully (falls back, or fails with a clear message) when a piece
81
- is missing.
103
+ is missing. If you installed via pip, extras pull them in for you:
104
+ `pip install 'erdscope[mysql]'` (PyMySQL), `'erdscope[postgres]'` (psycopg),
105
+ `'erdscope[yaml]'` (PyYAML), or `'erdscope[all]'`.
82
106
 
83
107
  | Library | Used for | If not installed |
84
108
  |---|---|---|
85
- | [PyMySQL](https://pypi.org/project/PyMySQL/) | The DB connection | Falls back to shelling out to the `mysql` CLI (must be on `PATH`) |
109
+ | [PyMySQL](https://pypi.org/project/PyMySQL/) | MySQL connections | Falls back to shelling out to the `mysql` CLI (must be on `PATH`) |
110
+ | [psycopg](https://pypi.org/project/psycopg/) (or psycopg2) | PostgreSQL connections | Falls back to shelling out to the `psql` CLI (must be on `PATH`) |
86
111
  | [PyYAML](https://pypi.org/project/PyYAML/) | Reading a `.yml`/`.yaml` config file | A `.json` config still works with no dependency; pointing `--config`/auto-discovery at a `.yml`/`.yaml` file without PyYAML installed exits with a clear error |
87
112
 
88
- Excel output (`--excel`) needs neither — it's written directly via the stdlib
113
+ Excel output (`--excel`) needs none of these — it's written directly via the stdlib
89
114
  `zipfile`/XML, not a spreadsheet library.
90
115
 
91
116
  Test-only, and only if you run that particular suite:
@@ -100,7 +125,8 @@ Test-only, and only if you run that particular suite:
100
125
  Feature highlights — each link goes to the relevant [manual](https://orapli.github.io/erdscope/manual.html) chapter:
101
126
 
102
127
  - **Database truth** — tables, columns (full SQL types, defaults, extras), table and
103
- column comments, indexes, and real FK constraints, read from `information_schema`
128
+ column comments, indexes, and real FK constraints, read from the database catalog
129
+ (`information_schema` on MySQL, `pg_catalog` on PostgreSQL)
104
130
  - **Code semantics on top** — `--models` merges Rails / Prisma / Django associations;
105
131
  declared, DB-FK, and inferred edges stay [visually distinct](https://orapli.github.io/erdscope/manual.html#viewer-edges)
106
132
  - **[Interactive exploration](https://orapli.github.io/erdscope/manual.html#viewer-guide)** — focus with depth and dependency
@@ -115,7 +141,8 @@ Feature highlights — each link goes to the relevant [manual](https://orapli.gi
115
141
  - **[Logical names](https://orapli.github.io/erdscope/manual.html#viewer-names)** — a table's DB comment doubles as a searchable
116
142
  logical name (e.g. `users(Customer accounts)`), with independent display modes for
117
143
  the live view and exports
118
- - **Extras** — dark mode, print stylesheet, resizable/collapsible panes
144
+ - **Extras** — a built-in `?` shortcuts/help popup, dark mode, print stylesheet,
145
+ resizable/collapsible panes
119
146
 
120
147
  ## Tests
121
148
 
@@ -139,8 +166,9 @@ python3 -m unittest tests.test_e2e -v
139
166
  ## Extending
140
167
 
141
168
  Everything downstream (UI, layouts, exports) consumes the intermediate representation
142
- documented at the top of `erd.py`. Adding PostgreSQL support means adding one
143
- `parse_postgres()` that produces that shape.
169
+ documented at the top of `erd.py`. Adding another database engine means adding one
170
+ parser that produces that shape — `parse_postgres()` (which reuses the MySQL
171
+ adapter's IR builder wholesale) is the working example of the pattern.
144
172
 
145
173
  ## License
146
174
 
@@ -3,6 +3,7 @@
3
3
  erdscope — interactive ER diagrams (and Excel table definitions) from a live database
4
4
  Usage:
5
5
  python3 erd.py mysql://readonly@host:3306/dbname [-o erd.html]
6
+ python3 erd.py postgres://readonly@host:5432/dbname[?schema=name]
6
7
  [--models /path/to/app] [--excel defs.xlsx]
7
8
 
8
9
  The database is the required source of truth; --models optionally overlays
@@ -105,11 +106,14 @@ def mysql_query_rows(url, sql):
105
106
  for line in r.stdout.splitlines()]
106
107
 
107
108
  _MYSQL_TSV_ESCAPES = {'0': '\0', 'b': '\b', 'n': '\n', 'r': '\r', 't': '\t', '\\': '\\'}
108
-
109
- def _unescape_mysql_field(s):
110
- """Undo mysql --batch (non-raw) tab-output escaping. \\N alone means
111
- SQL NULL (mapped to '' — same convention the PyMySQL path already uses
112
- for None); elsewhere \\0 \\b \\n \\r \\t \\\\ decode to their literal
109
+ # COPY TO STDOUT (the psql CLI path) escapes the same characters plus \f \v
110
+ _COPY_TSV_ESCAPES = {**_MYSQL_TSV_ESCAPES, 'f': '\f', 'v': '\v'}
111
+
112
+ def _unescape_tsv_field(s, escapes):
113
+ """Undo tab-separated-output escaping (mysql --batch, or COPY TO STDOUT
114
+ text format — they share the same core contract). \\N alone means SQL
115
+ NULL (mapped to '' — same convention the driver paths already use for
116
+ None); elsewhere the entries of `escapes` decode to their literal
113
117
  characters. A single left-to-right pass (not chained .replace() calls)
114
118
  is required: independent replacements can misfire on adjacent escapes,
115
119
  e.g. an escaped backslash immediately followed by an escaped tab."""
@@ -120,14 +124,20 @@ def _unescape_mysql_field(s):
120
124
  out, i, n = [], 0, len(s)
121
125
  while i < n:
122
126
  c = s[i]
123
- if c == '\\' and i + 1 < n and s[i + 1] in _MYSQL_TSV_ESCAPES:
124
- out.append(_MYSQL_TSV_ESCAPES[s[i + 1]])
127
+ if c == '\\' and i + 1 < n and s[i + 1] in escapes:
128
+ out.append(escapes[s[i + 1]])
125
129
  i += 2
126
130
  else:
127
131
  out.append(c)
128
132
  i += 1
129
133
  return ''.join(out)
130
134
 
135
+ def _unescape_mysql_field(s):
136
+ return _unescape_tsv_field(s, _MYSQL_TSV_ESCAPES)
137
+
138
+ def _unescape_copy_field(s):
139
+ return _unescape_tsv_field(s, _COPY_TSV_ESCAPES)
140
+
131
141
  def mysql_ir(table_rows, col_rows, fk_rows, index_rows):
132
142
  """Build the IR from information_schema rows (pure; unit-testable).
133
143
 
@@ -221,6 +231,143 @@ def parse_mysql(url):
221
231
  f"ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX")
222
232
  return mysql_ir(table_rows, col_rows, fk_rows, index_rows)
223
233
 
234
+ # ---------------------------------------------------------------------------
235
+ # PostgreSQL adapter (pg_catalog via psycopg/psycopg2 or the psql CLI)
236
+ # ---------------------------------------------------------------------------
237
+ def postgres_query_rows(url, sql):
238
+ """Run a query and return rows as tuples of strings ('' for NULL).
239
+ Prefers psycopg (v3), then psycopg2, when installed; otherwise shells
240
+ out to the psql CLI — same dependency-free spirit as mysql_query_rows.
241
+ The CLI path wraps the query in COPY (...) TO STDOUT, whose text format
242
+ escapes tabs/newlines/backslashes and spells NULL as \\N — the same
243
+ framing contract mysql --batch provides, so free-text comments can't
244
+ corrupt the field/record structure."""
245
+ u = urlparse(url)
246
+ host, port, db = u.hostname or '127.0.0.1', u.port or 5432, u.path.lstrip('/')
247
+ password = u.password or os.environ.get('PGPASSWORD', '')
248
+ driver = None
249
+ try:
250
+ import psycopg as driver # psycopg 3
251
+ except ImportError:
252
+ try:
253
+ import psycopg2 as driver
254
+ except ImportError:
255
+ pass
256
+ if driver:
257
+ try:
258
+ conn = driver.connect(host=host, port=port, user=u.username,
259
+ password=password, dbname=db)
260
+ except driver.Error as e:
261
+ sys.exit(f'Error: could not connect to PostgreSQL at {host}:{port}: {e}')
262
+ try:
263
+ with conn.cursor() as cur:
264
+ cur.execute(sql)
265
+ return [tuple('' if v is None else str(v) for v in r)
266
+ for r in cur.fetchall()]
267
+ except driver.Error as e:
268
+ sys.exit(f'Error: postgres query failed: {e}')
269
+ finally:
270
+ conn.close()
271
+ cmd = ['psql', '-X', '-q', '-v', 'ON_ERROR_STOP=1', '-h', host, '-p', str(port)]
272
+ if u.username:
273
+ cmd += ['-U', u.username]
274
+ cmd += ['-d', db, '-c', f'COPY ({sql}) TO STDOUT']
275
+ env = dict(os.environ)
276
+ if password:
277
+ env['PGPASSWORD'] = password # keep the password off the argv
278
+ try:
279
+ r = subprocess.run(cmd, capture_output=True, text=True, env=env)
280
+ except FileNotFoundError:
281
+ sys.exit('Error: neither psycopg/psycopg2 nor the psql CLI is available '
282
+ '(pip install "psycopg[binary]", or install a PostgreSQL client)')
283
+ if r.returncode != 0:
284
+ sys.exit(f'Error: postgres query failed: {r.stderr.strip()}')
285
+ return [tuple(_unescape_copy_field(v) for v in line.split('\t'))
286
+ for line in r.stdout.splitlines()]
287
+
288
+ def parse_postgres(url):
289
+ """Read the schema from a live PostgreSQL database and build the IR.
290
+
291
+ Shapes pg_catalog query results into the exact information_schema row
292
+ layout mysql_ir() consumes, so the (engine-agnostic) IR builder — PK
293
+ detection, unique-index 1:1 promotion, index assembly — is shared, not
294
+ duplicated. Notes vs. MySQL:
295
+ - schema defaults to 'public'; override with ?schema=name in the URL
296
+ - partitioned parents (relkind 'p') are included, their partitions and
297
+ views are not — matching MySQL's BASE TABLE filter in spirit
298
+ - identity/serial columns land in `extra` (like auto_increment), and a
299
+ serial's noisy nextval(...) default is blanked for display parity
300
+ """
301
+ u = urlparse(url)
302
+ db = u.path.lstrip('/')
303
+ if not re.fullmatch(r'\w+', db or ''):
304
+ sys.exit('Error: the postgres URL must include a database name, '
305
+ 'e.g. postgres://readonly@127.0.0.1:5432/myapp_production')
306
+ schema = 'public'
307
+ for part in u.query.split('&'):
308
+ if part.startswith('schema='):
309
+ schema = part[len('schema='):]
310
+ if not re.fullmatch(r'\w+', schema):
311
+ sys.exit(f'Error: invalid schema name {schema!r} in the postgres URL')
312
+ # No password in the URL and none set via PGPASSWORD: prompt (interactive
313
+ # only, same as the MySQL path). An empty answer leaves PGPASSWORD unset
314
+ # so libpq's own ~/.pgpass lookup still applies.
315
+ if u.password is None and not os.environ.get('PGPASSWORD') and sys.stdin.isatty():
316
+ pw = getpass.getpass(
317
+ f"PostgreSQL password for {u.username or 'postgres'}@{u.hostname or '127.0.0.1'}: ")
318
+ if pw:
319
+ os.environ['PGPASSWORD'] = pw
320
+ base = (f"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
321
+ f"WHERE n.nspname = '{schema}' AND c.relkind IN ('r', 'p') "
322
+ f"AND NOT c.relispartition")
323
+ table_rows = postgres_query_rows(url,
324
+ f"SELECT c.relname, COALESCE(obj_description(c.oid, 'pg_class'), '') "
325
+ f"{base} ORDER BY c.relname")
326
+ col_rows = postgres_query_rows(url,
327
+ f"SELECT c.relname, a.attname, "
328
+ f"format_type(a.atttypid, NULL), "
329
+ f"format_type(a.atttypid, a.atttypmod), "
330
+ f"CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END, "
331
+ f"CASE WHEN EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid "
332
+ f"AND i.indisprimary AND a.attnum = ANY(i.indkey)) THEN 'PRI' ELSE '' END, "
333
+ f"CASE WHEN pg_get_expr(ad.adbin, ad.adrelid) LIKE 'nextval(%' THEN '' "
334
+ f"ELSE COALESCE(pg_get_expr(ad.adbin, ad.adrelid), '') END, "
335
+ f"CASE WHEN a.attidentity <> '' THEN 'identity' "
336
+ f"WHEN pg_get_expr(ad.adbin, ad.adrelid) LIKE 'nextval(%' THEN 'serial' "
337
+ f"ELSE '' END, "
338
+ f"COALESCE(col_description(c.oid, a.attnum), '') "
339
+ f"FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid "
340
+ f"JOIN pg_namespace n ON n.oid = c.relnamespace "
341
+ f"LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum "
342
+ f"WHERE n.nspname = '{schema}' AND c.relkind IN ('r', 'p') "
343
+ f"AND NOT c.relispartition AND a.attnum > 0 AND NOT a.attisdropped "
344
+ f"ORDER BY c.relname, a.attnum")
345
+ fk_rows = postgres_query_rows(url,
346
+ f"SELECT conrel.relname, a.attname, confrel.relname "
347
+ f"FROM pg_constraint con "
348
+ f"JOIN pg_class conrel ON conrel.oid = con.conrelid "
349
+ f"JOIN pg_class confrel ON confrel.oid = con.confrelid "
350
+ f"JOIN pg_namespace n ON n.oid = conrel.relnamespace "
351
+ f"CROSS JOIN LATERAL unnest(con.conkey) AS k(attnum) "
352
+ f"JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum "
353
+ f"WHERE con.contype = 'f' AND n.nspname = '{schema}' "
354
+ f"ORDER BY conrel.relname, a.attname")
355
+ index_rows = postgres_query_rows(url,
356
+ f"SELECT c.relname, i.relname, "
357
+ f"CASE WHEN ix.indisunique THEN 0 ELSE 1 END, k.ord, "
358
+ f"COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.ord::int, true)) "
359
+ f"FROM pg_index ix "
360
+ f"JOIN pg_class c ON c.oid = ix.indrelid "
361
+ f"JOIN pg_class i ON i.oid = ix.indexrelid "
362
+ f"JOIN pg_namespace n ON n.oid = c.relnamespace "
363
+ f"CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) "
364
+ f"LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum "
365
+ f"AND k.attnum > 0 "
366
+ f"WHERE n.nspname = '{schema}' AND c.relkind IN ('r', 'p') "
367
+ f"AND NOT c.relispartition "
368
+ f"ORDER BY c.relname, i.relname, k.ord")
369
+ return mysql_ir(table_rows, col_rows, fk_rows, index_rows)
370
+
224
371
  def dedupe_db_fk(tables):
225
372
  """After merging code-declared associations, drop DB-FK associations for
226
373
  pairs that an explicit association already covers (either direction).
@@ -4777,7 +4924,7 @@ CONFIG_DEFAULTS = {
4777
4924
  # accidentally fill in when the pieces are separate. One config file per
4778
4925
  # database (e.g. erdscope.staging.json / erdscope.prod.json) is the
4779
4926
  # intended way to point --config at different targets.
4780
- CONFIG_CONNECTION_KEYS = {'host', 'port', 'user', 'database'}
4927
+ CONFIG_CONNECTION_KEYS = {'engine', 'host', 'port', 'user', 'database'}
4781
4928
  CONFIG_PASSWORD_KEYS = {'password', 'passwd', 'pwd', 'url', 'database_url'}
4782
4929
  # Expected type per key, checked at load time — YAML/JSON scalars are
4783
4930
  # exactly where a config typo goes undetected otherwise: max_rows as the
@@ -4871,15 +5018,19 @@ def _check_config_types(config, path):
4871
5018
  _SAFE_HOST_OR_USER = re.compile(r'[\w.\-]+')
4872
5019
 
4873
5020
  def assemble_config_url(config):
4874
- """Build a mysql:// URL from the config's host/port/user/database fields,
4875
- or None if `database` wasn't given (no connection info in the config at
4876
- all). Each part is validated against a safe charset before being pasted
5021
+ """Build a mysql:// or postgres:// URL (per the config's `engine`, default
5022
+ mysql) from the config's host/port/user/database fields, or None if
5023
+ `database` wasn't given (no connection info in the config at all).
5024
+ Each part is validated against a safe charset before being pasted
4877
5025
  into the URL string — host/user containing `/`, `@`, or `:` would
4878
5026
  silently shift what urlparse reads as the host/port/path when the
4879
5027
  assembled string is re-parsed downstream (verified empirically: a host
4880
5028
  of "x@evil" produces a URL whose username becomes "x" and whose actual
4881
5029
  host becomes "evil"), and there is no decoding step anywhere downstream
4882
5030
  to undo percent-encoding, so quoting isn't a fix either."""
5031
+ engine = config.get('engine', 'mysql')
5032
+ if engine not in ('mysql', 'postgres', 'postgresql'):
5033
+ sys.exit(f'Error: config `engine` must be "mysql" or "postgres", got {engine!r}')
4883
5034
  db = config.get('database')
4884
5035
  if db is None: # absent, or explicitly blank (e.g. a bare `database:` in YAML)
4885
5036
  return None
@@ -4890,7 +5041,7 @@ def assemble_config_url(config):
4890
5041
  sys.exit(f'Error: config `host` {host!r} has unsupported characters (letters/'
4891
5042
  f'digits/./- only here — IPv6 and other exotic hosts need the CLI '
4892
5043
  f'argument instead, not the config file)')
4893
- port = config.get('port', 3306)
5044
+ port = config.get('port', 3306 if engine == 'mysql' else 5432)
4894
5045
  if isinstance(port, bool) or (isinstance(port, float) and not port.is_integer()):
4895
5046
  sys.exit(f'Error: config `port` {port!r} is not a valid port number')
4896
5047
  try:
@@ -4909,7 +5060,8 @@ def assemble_config_url(config):
4909
5060
  sys.exit(f'Error: config `user` {user!r} has unsupported characters '
4910
5061
  f'(letters/digits/./- only)')
4911
5062
  auth = f'{user}@'
4912
- return f'mysql://{auth}{host}:{port}/{db}'
5063
+ scheme = 'mysql' if engine == 'mysql' else 'postgres'
5064
+ return f'{scheme}://{auth}{host}:{port}/{db}'
4913
5065
 
4914
5066
  # ---------------------------------------------------------------------------
4915
5067
  # CLI
@@ -4919,11 +5071,14 @@ def main():
4919
5071
  description='Generate an interactive ER diagram from a live database, '
4920
5072
  'optionally enriched with association semantics parsed from '
4921
5073
  'application code (Rails / Prisma / Django)')
4922
- p.add_argument('database', metavar='mysql://user@host:port/dbname', nargs='?',
4923
- help='Database connection URL. Can also be assembled from '
4924
- '`host`/`port`/`user`/`database` in the config file (no password '
4925
- 'field there use MYSQL_PWD, ~/.my.cnf, or the interactive '
4926
- 'prompt). A read-only account is recommended')
5074
+ p.add_argument('database', metavar='mysql://user@host/db | postgres://user@host/db',
5075
+ nargs='?',
5076
+ help='Database connection URL (postgres:// takes an optional '
5077
+ '?schema=name, default public). Can also be assembled from '
5078
+ '`engine`/`host`/`port`/`user`/`database` in the config file (no '
5079
+ 'password field there — use MYSQL_PWD/PGPASSWORD, '
5080
+ '~/.my.cnf/~/.pgpass, or the interactive prompt). A read-only '
5081
+ 'account is recommended')
4927
5082
  # SUPPRESS on every config-mirrorable flag so we can tell "explicitly
4928
5083
  # passed on the CLI" (attribute present) from "left to the config file /
4929
5084
  # built-in default" (attribute absent) — see the merge loop below.
@@ -4970,10 +5125,16 @@ def main():
4970
5125
 
4971
5126
  config = load_config(args)
4972
5127
  url = args.database or assemble_config_url(config)
4973
- if not url or not url.startswith('mysql://'):
4974
- sys.exit('Error: a database URL is required (currently mysql:// only) — pass it '
4975
- 'as the CLI argument, or set `database` (and optionally host/user/port) '
4976
- 'in the config file, e.g. mysql://readonly@127.0.0.1:3306/myapp_production')
5128
+ scheme = (url or '').split('://', 1)[0]
5129
+ if scheme == 'mysql':
5130
+ parse_db, engine_name = parse_mysql, 'MySQL'
5131
+ elif scheme in ('postgres', 'postgresql'):
5132
+ parse_db, engine_name = parse_postgres, 'PostgreSQL'
5133
+ else:
5134
+ sys.exit('Error: a database URL is required (mysql:// or postgres://) — pass it '
5135
+ 'as the CLI argument, or set `database` (and optionally engine/host/user/'
5136
+ 'port) in the config file, e.g. mysql://readonly@127.0.0.1:3306/myapp or '
5137
+ 'postgres://readonly@127.0.0.1:5432/myapp')
4977
5138
 
4978
5139
  if hasattr(args, 'table_map'):
4979
5140
  tm = {}
@@ -4991,8 +5152,8 @@ def main():
4991
5152
  setattr(args, key, config.get(key, default))
4992
5153
  relations = config.get('relations', []) # shape already validated by load_config()
4993
5154
 
4994
- tables = parse_mysql(url)
4995
- print(f'Fetched {len(tables)} tables from MySQL', file=sys.stderr)
5155
+ tables = parse_db(url)
5156
+ print(f'Fetched {len(tables)} tables from {engine_name}', file=sys.stderr)
4996
5157
 
4997
5158
  if args.models:
4998
5159
  mroot = Path(args.models).expanduser().resolve()
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: erdscope
3
- Version: 0.1.0
4
- Summary: Interactive, self-contained ER-diagram HTML and Excel table definitions from a live MySQL database — single file, zero required dependencies
3
+ Version: 0.2.0
4
+ Summary: Interactive, self-contained ER-diagram HTML and Excel table definitions from a live MySQL or PostgreSQL database — single file, zero required dependencies
5
5
  Author: tas6
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/orapli/erdscope
@@ -22,17 +22,30 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Provides-Extra: mysql
24
24
  Requires-Dist: PyMySQL; extra == "mysql"
25
+ Provides-Extra: postgres
26
+ Requires-Dist: psycopg[binary]; extra == "postgres"
25
27
  Provides-Extra: yaml
26
28
  Requires-Dist: PyYAML; extra == "yaml"
27
29
  Provides-Extra: all
28
30
  Requires-Dist: PyMySQL; extra == "all"
31
+ Requires-Dist: psycopg[binary]; extra == "all"
29
32
  Requires-Dist: PyYAML; extra == "all"
30
33
  Dynamic: license-file
31
34
 
32
35
  # erdscope
33
36
 
37
+ [![CI](https://github.com/orapli/erdscope/actions/workflows/ci.yml/badge.svg)](https://github.com/orapli/erdscope/actions/workflows/ci.yml)
38
+ [![PyPI](https://img.shields.io/pypi/v/erdscope)](https://pypi.org/project/erdscope/)
39
+
34
40
  Generate a **self-contained, interactive ER diagram** — and an **Excel table-definition
35
- workbook** — from a live MySQL database, with a single-file, zero-dependency Python CLI.
41
+ workbook** — from a live MySQL or PostgreSQL database, with a single-file,
42
+ zero-dependency Python CLI.
43
+
44
+ ```bash
45
+ pip install erdscope
46
+ erdscope mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
47
+ erdscope postgres://readonly@127.0.0.1:5432/myapp_production -o erd.html
48
+ ```
36
49
 
37
50
  The database is the source of truth (tables, columns, comments, indexes, real foreign
38
51
  keys). Application code (Rails / Prisma / Django) can optionally be layered on top to
@@ -52,17 +65,29 @@ Regenerate it anytime with `python3 docs/gen_demo.py`.
52
65
  **[Read the user manual →](https://orapli.github.io/erdscope/manual.html)** — installation,
53
66
  CLI/config reference, a full viewer guide, and troubleshooting. ([日本語版 →](https://orapli.github.io/erdscope/manual.ja.html))
54
67
 
55
- ## Usage
68
+ ## Install & usage
69
+
70
+ `pip install erdscope` (or `pipx install erdscope`) gives you the `erdscope` command.
71
+ Prefer not to install anything? `erd.py` is a single, dependency-free file — grab it
72
+ and run it with any Python 3.9+:
56
73
 
57
74
  ```bash
58
- python3 erd.py mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
75
+ curl -O https://raw.githubusercontent.com/orapli/erdscope/main/erd.py
76
+ python3 erd.py ... # identical to the erdscope command below
77
+ ```
78
+
79
+ ```bash
80
+ erdscope mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
81
+
82
+ # PostgreSQL: same thing (schema defaults to public; override with ?schema=name)
83
+ erdscope postgres://readonly@127.0.0.1:5432/myapp_production -o erd.html
59
84
 
60
85
  # enrich with association semantics parsed from application code (optional)
61
- python3 erd.py mysql://readonly@127.0.0.1:3306/myapp_production \
86
+ erdscope mysql://readonly@127.0.0.1:3306/myapp_production \
62
87
  --models /path/to/rails/app -o erd.html
63
88
 
64
89
  # also write a table-definition workbook
65
- python3 erd.py mysql://readonly@127.0.0.1:3306/myapp_production \
90
+ erdscope mysql://readonly@127.0.0.1:3306/myapp_production \
66
91
  --excel table_definitions.xlsx -o erd.html
67
92
  ```
68
93
 
@@ -70,7 +95,7 @@ Behind a bastion? Open an SSH tunnel first and point at localhost:
70
95
 
71
96
  ```bash
72
97
  ssh -N -L 3307:db-host:3306 bastion &
73
- python3 erd.py mysql://readonly@127.0.0.1:3307/myapp_production -o erd.html
98
+ erdscope mysql://readonly@127.0.0.1:3307/myapp_production -o erd.html
74
99
  ```
75
100
 
76
101
  Use a read-only account, and leave the password out of the URL — if it's not in
@@ -99,8 +124,8 @@ itself is made.
99
124
  Once the flag list above gets long, put it in a config file instead — `.erdscope.json`
100
125
  (or `.yml`/`.yaml` with PyYAML) next to where you run the tool is picked up automatically.
101
126
  Most keys mirror a CLI option (an explicit flag always wins over the config); the DB
102
- connection is config-only as `host`/`port`/`user`/`database` — deliberately with no
103
- password field — and `relations` manually declares relations no FK, code, or inference
127
+ connection is config-only as `engine`/`host`/`port`/`user`/`database` — deliberately with
128
+ no password field — and `relations` manually declares relations no FK, code, or inference
104
129
  can find. See the [Config file chapter of the manual](https://orapli.github.io/erdscope/manual.html#config-file) for the full key
105
130
  list and semantics, and [`erdscope.example.yml`](erdscope.example.yml) for a fully
106
131
  annotated sample based on the live demo's schema.
@@ -109,14 +134,17 @@ annotated sample based on the live demo's schema.
109
134
 
110
135
  `erd.py` runs with **zero required dependencies** — everything below is optional, and
111
136
  the tool degrades gracefully (falls back, or fails with a clear message) when a piece
112
- is missing.
137
+ is missing. If you installed via pip, extras pull them in for you:
138
+ `pip install 'erdscope[mysql]'` (PyMySQL), `'erdscope[postgres]'` (psycopg),
139
+ `'erdscope[yaml]'` (PyYAML), or `'erdscope[all]'`.
113
140
 
114
141
  | Library | Used for | If not installed |
115
142
  |---|---|---|
116
- | [PyMySQL](https://pypi.org/project/PyMySQL/) | The DB connection | Falls back to shelling out to the `mysql` CLI (must be on `PATH`) |
143
+ | [PyMySQL](https://pypi.org/project/PyMySQL/) | MySQL connections | Falls back to shelling out to the `mysql` CLI (must be on `PATH`) |
144
+ | [psycopg](https://pypi.org/project/psycopg/) (or psycopg2) | PostgreSQL connections | Falls back to shelling out to the `psql` CLI (must be on `PATH`) |
117
145
  | [PyYAML](https://pypi.org/project/PyYAML/) | Reading a `.yml`/`.yaml` config file | A `.json` config still works with no dependency; pointing `--config`/auto-discovery at a `.yml`/`.yaml` file without PyYAML installed exits with a clear error |
118
146
 
119
- Excel output (`--excel`) needs neither — it's written directly via the stdlib
147
+ Excel output (`--excel`) needs none of these — it's written directly via the stdlib
120
148
  `zipfile`/XML, not a spreadsheet library.
121
149
 
122
150
  Test-only, and only if you run that particular suite:
@@ -131,7 +159,8 @@ Test-only, and only if you run that particular suite:
131
159
  Feature highlights — each link goes to the relevant [manual](https://orapli.github.io/erdscope/manual.html) chapter:
132
160
 
133
161
  - **Database truth** — tables, columns (full SQL types, defaults, extras), table and
134
- column comments, indexes, and real FK constraints, read from `information_schema`
162
+ column comments, indexes, and real FK constraints, read from the database catalog
163
+ (`information_schema` on MySQL, `pg_catalog` on PostgreSQL)
135
164
  - **Code semantics on top** — `--models` merges Rails / Prisma / Django associations;
136
165
  declared, DB-FK, and inferred edges stay [visually distinct](https://orapli.github.io/erdscope/manual.html#viewer-edges)
137
166
  - **[Interactive exploration](https://orapli.github.io/erdscope/manual.html#viewer-guide)** — focus with depth and dependency
@@ -146,7 +175,8 @@ Feature highlights — each link goes to the relevant [manual](https://orapli.gi
146
175
  - **[Logical names](https://orapli.github.io/erdscope/manual.html#viewer-names)** — a table's DB comment doubles as a searchable
147
176
  logical name (e.g. `users(Customer accounts)`), with independent display modes for
148
177
  the live view and exports
149
- - **Extras** — dark mode, print stylesheet, resizable/collapsible panes
178
+ - **Extras** — a built-in `?` shortcuts/help popup, dark mode, print stylesheet,
179
+ resizable/collapsible panes
150
180
 
151
181
  ## Tests
152
182
 
@@ -170,8 +200,9 @@ python3 -m unittest tests.test_e2e -v
170
200
  ## Extending
171
201
 
172
202
  Everything downstream (UI, layouts, exports) consumes the intermediate representation
173
- documented at the top of `erd.py`. Adding PostgreSQL support means adding one
174
- `parse_postgres()` that produces that shape.
203
+ documented at the top of `erd.py`. Adding another database engine means adding one
204
+ parser that produces that shape — `parse_postgres()` (which reuses the MySQL
205
+ adapter's IR builder wholesale) is the working example of the pattern.
175
206
 
176
207
  ## License
177
208
 
@@ -1,10 +1,14 @@
1
1
 
2
2
  [all]
3
3
  PyMySQL
4
+ psycopg[binary]
4
5
  PyYAML
5
6
 
6
7
  [mysql]
7
8
  PyMySQL
8
9
 
10
+ [postgres]
11
+ psycopg[binary]
12
+
9
13
  [yaml]
10
14
  PyYAML
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "erdscope"
7
- version = "0.1.0"
8
- description = "Interactive, self-contained ER-diagram HTML and Excel table definitions from a live MySQL database — single file, zero required dependencies"
7
+ version = "0.2.0"
8
+ description = "Interactive, self-contained ER-diagram HTML and Excel table definitions from a live MySQL or PostgreSQL database — single file, zero required dependencies"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  license-files = ["LICENSE"]
@@ -24,8 +24,9 @@ classifiers = [
24
24
 
25
25
  [project.optional-dependencies]
26
26
  mysql = ["PyMySQL"]
27
+ postgres = ["psycopg[binary]"]
27
28
  yaml = ["PyYAML"]
28
- all = ["PyMySQL", "PyYAML"]
29
+ all = ["PyMySQL", "psycopg[binary]", "PyYAML"]
29
30
 
30
31
  [project.scripts]
31
32
  erdscope = "erd:main"
@@ -1449,5 +1449,152 @@ class TestAssembleConfigUrl(unittest.TestCase):
1449
1449
  erd.assemble_config_url({'host': 'h', 'database': 'shop', 'port': 3306.9})
1450
1450
 
1451
1451
 
1452
+ class TestPostgresQueryRows(unittest.TestCase):
1453
+ """postgres_query_rows prefers psycopg (v3), then psycopg2; the last
1454
+ resort is the psql CLI wrapping the query in COPY (...) TO STDOUT.
1455
+ Mirrors TestPyMysqlErrorHandling: driver failures must exit cleanly,
1456
+ and the CLI path must unescape COPY's text format."""
1457
+ def setUp(self):
1458
+ class FakePgError(Exception):
1459
+ pass
1460
+ self.FakePgError = FakePgError
1461
+ self.fake_psycopg = types.SimpleNamespace(Error=FakePgError, connect=None)
1462
+ self.addCleanup(sys.modules.pop, 'psycopg', None)
1463
+ sys.modules['psycopg'] = self.fake_psycopg
1464
+
1465
+ def test_connect_failure_exits_cleanly(self):
1466
+ def fail_connect(**kw):
1467
+ raise self.FakePgError('password authentication failed for user "x"')
1468
+ self.fake_psycopg.connect = fail_connect
1469
+ with self.assertRaises(SystemExit) as cm:
1470
+ erd.postgres_query_rows('postgres://x@127.0.0.1:5432/db', 'SELECT 1')
1471
+ self.assertIn('authentication failed', str(cm.exception))
1472
+
1473
+ def test_query_failure_exits_cleanly(self):
1474
+ class FakeCursor:
1475
+ def __enter__(self): return self
1476
+ def __exit__(self, *a): return False
1477
+ def execute(self, sql):
1478
+ raise self.outer.FakePgError('relation does not exist')
1479
+ FakeCursor.outer = self
1480
+ class FakeConn:
1481
+ def cursor(self): return FakeCursor()
1482
+ def close(self): pass
1483
+ self.fake_psycopg.connect = lambda **kw: FakeConn()
1484
+ with self.assertRaises(SystemExit) as cm:
1485
+ erd.postgres_query_rows('postgres://x@127.0.0.1:5432/db', 'SELECT 1')
1486
+ self.assertIn('relation does not exist', str(cm.exception))
1487
+
1488
+ def test_none_values_become_empty_strings(self):
1489
+ class FakeCursor:
1490
+ def __enter__(self): return self
1491
+ def __exit__(self, *a): return False
1492
+ def execute(self, sql): pass
1493
+ def fetchall(self): return [('users', None), (None, 42)]
1494
+ class FakeConn:
1495
+ def cursor(self): return FakeCursor()
1496
+ def close(self): pass
1497
+ self.fake_psycopg.connect = lambda **kw: FakeConn()
1498
+ rows = erd.postgres_query_rows('postgres://x@h:5432/db', 'SELECT 1')
1499
+ self.assertEqual(rows, [('users', ''), ('', '42')])
1500
+
1501
+
1502
+ class TestPostgresCliFallback(unittest.TestCase):
1503
+ """The psql path, forced by making both driver imports fail
1504
+ (sys.modules[name] = None makes `import name` raise ImportError)."""
1505
+ def setUp(self):
1506
+ for mod in ('psycopg', 'psycopg2'):
1507
+ self.addCleanup(sys.modules.pop, mod, None)
1508
+ sys.modules[mod] = None
1509
+
1510
+ def test_missing_psql_exits_with_install_hint(self):
1511
+ def no_psql(cmd, **kw):
1512
+ raise FileNotFoundError('psql')
1513
+ orig = erd.subprocess.run
1514
+ erd.subprocess.run = no_psql
1515
+ try:
1516
+ with self.assertRaises(SystemExit) as cm:
1517
+ erd.postgres_query_rows('postgres://x@h:5432/db', 'SELECT 1')
1518
+ finally:
1519
+ erd.subprocess.run = orig
1520
+ self.assertIn('psycopg', str(cm.exception))
1521
+ self.assertIn('psql', str(cm.exception))
1522
+
1523
+ def test_copy_output_is_unescaped_and_wrapped_in_copy(self):
1524
+ captured = {}
1525
+ def fake_run(cmd, **kw):
1526
+ captured['cmd'] = cmd
1527
+ return types.SimpleNamespace(
1528
+ returncode=0,
1529
+ stdout='users\tA comment with a\\ttab\n\\N\t\\N\n',
1530
+ stderr='')
1531
+ orig = erd.subprocess.run
1532
+ erd.subprocess.run = fake_run
1533
+ try:
1534
+ rows = erd.postgres_query_rows('postgres://ro@h:5433/db', 'SELECT x')
1535
+ finally:
1536
+ erd.subprocess.run = orig
1537
+ self.assertEqual(rows, [('users', 'A comment with a\ttab'), ('', '')])
1538
+ joined = ' '.join(captured['cmd'])
1539
+ self.assertIn('COPY (SELECT x) TO STDOUT', joined)
1540
+ self.assertIn('-p 5433', joined)
1541
+ self.assertIn('-U ro', joined)
1542
+
1543
+
1544
+ class TestUnescapeCopyField(unittest.TestCase):
1545
+ """COPY TO STDOUT text format shares mysql --batch's escape contract
1546
+ and adds \\f and \\v."""
1547
+ def test_null_marker(self):
1548
+ self.assertEqual(erd._unescape_copy_field('\\N'), '')
1549
+
1550
+ def test_formfeed_and_vertical_tab(self):
1551
+ self.assertEqual(erd._unescape_copy_field('a\\fb\\vc'), 'a\fb\vc')
1552
+
1553
+ def test_shared_escapes_still_decode(self):
1554
+ self.assertEqual(erd._unescape_copy_field('a\\tb\\nc'), 'a\tb\nc')
1555
+
1556
+ def test_escaped_backslash_then_escape_char(self):
1557
+ self.assertEqual(erd._unescape_copy_field('a\\\\tb'), 'a\\tb')
1558
+
1559
+
1560
+ class TestPostgresUrlAndSchema(unittest.TestCase):
1561
+ def test_missing_database_name_exits(self):
1562
+ with self.assertRaises(SystemExit) as cm:
1563
+ erd.parse_postgres('postgres://ro@h:5432/')
1564
+ self.assertIn('database name', str(cm.exception))
1565
+
1566
+ def test_invalid_schema_param_exits(self):
1567
+ with self.assertRaises(SystemExit) as cm:
1568
+ erd.parse_postgres('postgres://ro@h:5432/db?schema=bad-name')
1569
+ self.assertIn('schema', str(cm.exception))
1570
+
1571
+
1572
+ class TestPostgresConfigUrl(unittest.TestCase):
1573
+ """`engine: postgres` in the config switches the assembled URL's scheme
1574
+ and default port; mysql stays the default for existing configs."""
1575
+ def test_engine_postgres_assembles_postgres_url_with_5432_default(self):
1576
+ url = erd.assemble_config_url(
1577
+ {'engine': 'postgres', 'user': 'ro', 'host': 'h', 'database': 'shop'})
1578
+ self.assertEqual(url, 'postgres://ro@h:5432/shop')
1579
+
1580
+ def test_engine_postgresql_alias_accepted(self):
1581
+ url = erd.assemble_config_url({'engine': 'postgresql', 'database': 'shop'})
1582
+ self.assertEqual(url, 'postgres://127.0.0.1:5432/shop')
1583
+
1584
+ def test_engine_defaults_to_mysql(self):
1585
+ url = erd.assemble_config_url({'database': 'shop'})
1586
+ self.assertEqual(url, 'mysql://127.0.0.1:3306/shop')
1587
+
1588
+ def test_unknown_engine_exits(self):
1589
+ with self.assertRaises(SystemExit) as cm:
1590
+ erd.assemble_config_url({'engine': 'oracle', 'database': 'shop'})
1591
+ self.assertIn('engine', str(cm.exception))
1592
+
1593
+ def test_explicit_port_wins_over_engine_default(self):
1594
+ url = erd.assemble_config_url(
1595
+ {'engine': 'postgres', 'database': 'shop', 'port': 6432})
1596
+ self.assertEqual(url, 'postgres://127.0.0.1:6432/shop')
1597
+
1598
+
1452
1599
  if __name__ == '__main__':
1453
1600
  unittest.main()
File without changes
File without changes
File without changes