dycw-utilities 0.148.5__py3-none-any.whl → 0.149.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dycw-utilities
3
- Version: 0.148.5
3
+ Version: 0.149.0
4
4
  Author-email: Derek Wan <d.wan@icloud.com>
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -1,4 +1,4 @@
1
- utilities/__init__.py,sha256=LCky1VazOaGgD8qf6DVgou1be6BJARw1j7YB_Kwfbgw,60
1
+ utilities/__init__.py,sha256=k25MoUA4CILG827gfaydYpGoUHao6zPu9nXCDQDr9DE,60
2
2
  utilities/altair.py,sha256=92E2lCdyHY4Zb-vCw6rEJIsWdKipuu-Tu2ab1ufUfAk,9079
3
3
  utilities/asyncio.py,sha256=z0w3fb-U5Ml5YXVaFFPClizXaQmjDO6YgZg-V9QL0VQ,16021
4
4
  utilities/atomicwrites.py,sha256=xcOWenTBRS0oat3kg7Sqe51AohNThMQ2ixPL7QCG8hw,5795
@@ -48,7 +48,7 @@ utilities/pickle.py,sha256=MBT2xZCsv0pH868IXLGKnlcqNx2IRVKYNpRcqiQQqxw,653
48
48
  utilities/platform.py,sha256=Ue9LSxYvg9yUXGKuz5aZoy_qkUEXde-v6B09exgSctU,2813
49
49
  utilities/polars.py,sha256=BgiDryAVOapi41ddfJqN0wYh_sDj8BNEYtPB36LaHdo,71824
50
50
  utilities/polars_ols.py,sha256=Uc9V5kvlWZ5cU93lKZ-cfAKdVFFw81tqwLW9PxtUvMs,5618
51
- utilities/postgres.py,sha256=zQx4rNMg65znDdcj_NC1ZPTtW-XkgSorlTe9CTSmrHk,9115
51
+ utilities/postgres.py,sha256=ctSaVXmkMwdjpi90tbC8O-08_30Nck99tdykgJQa8Wo,11672
52
52
  utilities/pottery.py,sha256=w2X80PXWwzdHdqSYJP6ESrPNNDP3xzpyuJn-fp-Vt3M,5969
53
53
  utilities/pqdm.py,sha256=BTsYPtbKQWwX-iXF4qCkfPG7DPxIB54J989n83bXrIo,3092
54
54
  utilities/psutil.py,sha256=KUlu4lrUw9Zg1V7ZGetpWpGb9DB8l_SSDWGbANFNCPU,2104
@@ -88,8 +88,8 @@ utilities/zoneinfo.py,sha256=oEH-nL3t4h9uawyZqWDtNtDAl6M-CLpLYGI_nI6DulM,1971
88
88
  utilities/pytest_plugins/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
89
89
  utilities/pytest_plugins/pytest_randomly.py,sha256=NXzCcGKbpgYouz5yehKb4jmxmi2SexKKpgF4M65bi10,414
90
90
  utilities/pytest_plugins/pytest_regressions.py,sha256=Iwhfv_OJH7UCPZCfoh7ugZ2Xjqjil-BBBsOb8sDwiGI,1471
91
- dycw_utilities-0.148.5.dist-info/METADATA,sha256=zKtIyZ---2ibI4hD6IeeSvJG2Gsx8CL31o-o1PxcWME,1697
92
- dycw_utilities-0.148.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
93
- dycw_utilities-0.148.5.dist-info/entry_points.txt,sha256=BOD_SoDxwsfJYOLxhrSXhHP_T7iw-HXI9f2WVkzYxvQ,135
94
- dycw_utilities-0.148.5.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
95
- dycw_utilities-0.148.5.dist-info/RECORD,,
91
+ dycw_utilities-0.149.0.dist-info/METADATA,sha256=AYiiL1k_AquVfj8pssxVFgjD65kqRMCb2DEJMXNAz28,1697
92
+ dycw_utilities-0.149.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
93
+ dycw_utilities-0.149.0.dist-info/entry_points.txt,sha256=BOD_SoDxwsfJYOLxhrSXhHP_T7iw-HXI9f2WVkzYxvQ,135
94
+ dycw_utilities-0.149.0.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
95
+ dycw_utilities-0.149.0.dist-info/RECORD,,
utilities/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.148.5"
3
+ __version__ = "0.149.0"
utilities/postgres.py CHANGED
@@ -12,6 +12,7 @@ from utilities.asyncio import stream_command
12
12
  from utilities.iterables import always_iterable
13
13
  from utilities.logging import get_logger
14
14
  from utilities.os import temp_environ
15
+ from utilities.pathlib import ensure_suffix
15
16
  from utilities.sqlalchemy import get_table_name
16
17
  from utilities.timer import Timer
17
18
  from utilities.types import PathLike
@@ -31,7 +32,6 @@ async def pg_dump(
31
32
  path: PathLike,
32
33
  /,
33
34
  *,
34
- docker: str | None = None,
35
35
  format_: _PGDumpFormat = "plain",
36
36
  jobs: int | None = None,
37
37
  schemas: MaybeListStr | None = None,
@@ -40,26 +40,92 @@ async def pg_dump(
40
40
  tables_exc: MaybeSequence[TableOrORMInstOrClass | str] | None = None,
41
41
  inserts: bool = False,
42
42
  on_conflict_do_nothing: bool = False,
43
- logger: LoggerOrName | None = None,
43
+ docker: str | None = None,
44
44
  dry_run: bool = False,
45
+ logger: LoggerOrName | None = None,
45
46
  ) -> None:
46
47
  """Run `pg_dump`."""
47
48
  path = Path(path)
48
49
  path.parent.mkdir(parents=True, exist_ok=True)
49
- if url.database is None:
50
- raise _PGDumpDatabaseError(url=url)
51
- if url.host is None:
52
- raise _PGDumpHostError(url=url)
53
- if url.port is None:
54
- raise _PGDumpPortError(url=url)
55
- parts: list[str] = []
56
- if docker is not None:
57
- parts.extend(["docker", "exec", "-it", docker])
58
- parts.extend([
50
+ cmd = _build_pg_dump(
51
+ url,
52
+ path,
53
+ format_=format_,
54
+ jobs=jobs,
55
+ schemas=schemas,
56
+ schemas_exc=schemas_exc,
57
+ tables=tables,
58
+ tables_exc=tables_exc,
59
+ inserts=inserts,
60
+ on_conflict_do_nothing=on_conflict_do_nothing,
61
+ docker=docker,
62
+ )
63
+ if dry_run:
64
+ if logger is not None:
65
+ get_logger(logger=logger).info("Would run %r", str(cmd))
66
+ return
67
+ with temp_environ(PGPASSWORD=url.password), Timer() as timer: # pragma: no cover
68
+ try:
69
+ output = await stream_command(cmd)
70
+ except KeyboardInterrupt:
71
+ if logger is not None:
72
+ get_logger(logger=logger).info(
73
+ "Cancelled backup to %r after %s", str(path), timer
74
+ )
75
+ rmtree(path, ignore_errors=True)
76
+ else:
77
+ match output.return_code:
78
+ case 0:
79
+ if logger is not None:
80
+ get_logger(logger=logger).info(
81
+ "Backup to %r finished after %s", str(path), timer
82
+ )
83
+ case _:
84
+ if logger is not None:
85
+ get_logger(logger=logger).exception(
86
+ "Backup to %r failed after %s\nstderr:\n%s",
87
+ str(path),
88
+ timer,
89
+ output.stderr,
90
+ )
91
+ rmtree(path, ignore_errors=True)
92
+
93
+
94
+ def _build_pg_dump(
95
+ url: URL,
96
+ path: PathLike,
97
+ /,
98
+ *,
99
+ format_: _PGDumpFormat = "plain",
100
+ jobs: int | None = None,
101
+ schemas: MaybeListStr | None = None,
102
+ schemas_exc: MaybeListStr | None = None,
103
+ tables: MaybeSequence[TableOrORMInstOrClass | str] | None = None,
104
+ tables_exc: MaybeSequence[TableOrORMInstOrClass | str] | None = None,
105
+ inserts: bool = False,
106
+ on_conflict_do_nothing: bool = False,
107
+ docker: str | None = None,
108
+ ) -> str:
109
+ database, host, port = _extract_url(url)
110
+ match format_:
111
+ case "plain":
112
+ suffix = ".sql"
113
+ case "custom":
114
+ suffix = ".pgdump"
115
+ case "directory":
116
+ suffix = None
117
+ case "tar":
118
+ suffix = ".tar"
119
+ case _ as never:
120
+ assert_never(never)
121
+ file = Path(path)
122
+ if suffix is not None:
123
+ file = ensure_suffix(file, suffix)
124
+ parts: list[str] = [
59
125
  "pg_dump",
60
126
  # general options
61
- f"--dbname={url.database}",
62
- f"--file={str(path)!r}",
127
+ f"--dbname={database}",
128
+ f"--file={str(file)!r}",
63
129
  f"--format={format_}",
64
130
  "--verbose",
65
131
  # output options
@@ -69,10 +135,10 @@ async def pg_dump(
69
135
  "--no-privileges",
70
136
  "--if-exists",
71
137
  # connection options
72
- f"--host={url.host}",
73
- f"--port={url.port}",
138
+ f"--host={host}",
139
+ f"--port={port}",
74
140
  "--no-password",
75
- ])
141
+ ]
76
142
  if (format_ == "directory") and (jobs is not None):
77
143
  parts.append(f"--jobs={jobs}")
78
144
  if schemas is not None:
@@ -91,7 +157,43 @@ async def pg_dump(
91
157
  parts.append("--on-conflict-do-nothing")
92
158
  if url.username is not None:
93
159
  parts.append(f"--username={url.username}")
94
- cmd = " ".join(parts)
160
+ if docker is not None:
161
+ parts = _wrap_docker(parts, docker)
162
+ return " ".join(parts)
163
+
164
+
165
+ ##
166
+
167
+
168
+ async def restore(
169
+ url: URL,
170
+ path: PathLike,
171
+ /,
172
+ *,
173
+ psql: bool = False,
174
+ database: str | None = None,
175
+ data_only: bool = False,
176
+ jobs: int | None = None,
177
+ schemas: MaybeListStr | None = None,
178
+ schemas_exc: MaybeListStr | None = None,
179
+ tables: MaybeSequence[TableOrORMInstOrClass | str] | None = None,
180
+ docker: str | None = None,
181
+ dry_run: bool = False,
182
+ logger: LoggerOrName | None = None,
183
+ ) -> None:
184
+ """Run `pg_restore`/`psql`."""
185
+ cmd = _build_pg_restore_or_psql(
186
+ url,
187
+ path,
188
+ psql=psql,
189
+ database=database,
190
+ data_only=data_only,
191
+ jobs=jobs,
192
+ schemas=schemas,
193
+ schemas_exc=schemas_exc,
194
+ tables=tables,
195
+ docker=docker,
196
+ )
95
197
  if dry_run:
96
198
  if logger is not None:
97
199
  get_logger(logger=logger).info("Would run %r", str(cmd))
@@ -102,89 +204,75 @@ async def pg_dump(
102
204
  except KeyboardInterrupt:
103
205
  if logger is not None:
104
206
  get_logger(logger=logger).info(
105
- "Cancelled backup to %r after %s", str(path), timer
207
+ "Cancelled restore from %r after %s", str(path), timer
106
208
  )
107
- rmtree(path, ignore_errors=True)
108
209
  else:
109
210
  match output.return_code:
110
211
  case 0:
111
212
  if logger is not None:
112
213
  get_logger(logger=logger).info(
113
- "Backup to %r finished after %s", str(path), timer
214
+ "Restore from %r finished after %s", str(path), timer
114
215
  )
115
216
  case _:
116
217
  if logger is not None:
117
218
  get_logger(logger=logger).exception(
118
- "Backup to %r failed after %s\nstderr:\n%s",
219
+ "Restore from %r failed after %s\nstderr:\n%s",
119
220
  str(path),
120
221
  timer,
121
222
  output.stderr,
122
223
  )
123
- rmtree(path, ignore_errors=True)
124
-
125
-
126
- @dataclass(kw_only=True, slots=True)
127
- class PGDumpError(Exception):
128
- url: URL
129
-
130
-
131
- @dataclass(kw_only=True, slots=True)
132
- class _PGDumpDatabaseError(PGDumpError):
133
- @override
134
- def __str__(self) -> str:
135
- return f"Expected URL to contain a 'database'; got {self.url}"
136
-
137
-
138
- @dataclass(kw_only=True, slots=True)
139
- class _PGDumpHostError(PGDumpError):
140
- @override
141
- def __str__(self) -> str:
142
- return f"Expected URL to contain a 'host'; got {self.url}"
143
-
144
-
145
- @dataclass(kw_only=True, slots=True)
146
- class _PGDumpPortError(PGDumpError):
147
- @override
148
- def __str__(self) -> str:
149
- return f"Expected URL to contain a 'port'; got {self.url}"
150
224
 
151
225
 
152
226
  ##
153
227
 
154
228
 
155
- async def pg_restore(
229
+ def _build_pg_restore_or_psql(
156
230
  url: URL,
157
231
  path: PathLike,
158
232
  /,
159
233
  *,
234
+ psql: bool = False,
160
235
  database: str | None = None,
236
+ data_only: bool = False,
237
+ jobs: int | None = None,
238
+ schemas: MaybeListStr | None = None,
239
+ schemas_exc: MaybeListStr | None = None,
240
+ tables: MaybeSequence[TableOrORMInstOrClass | str] | None = None,
161
241
  docker: str | None = None,
242
+ ) -> str:
243
+ path = Path(path)
244
+ if (path.suffix == ".sql") or psql:
245
+ return _build_psql(url, path, database=database, docker=docker)
246
+ return _build_pg_restore(
247
+ url,
248
+ path,
249
+ database=database,
250
+ data_only=data_only,
251
+ jobs=jobs,
252
+ schemas=schemas,
253
+ schemas_exc=schemas_exc,
254
+ tables=tables,
255
+ docker=docker,
256
+ )
257
+
258
+
259
+ def _build_pg_restore(
260
+ url: URL,
261
+ path: PathLike,
262
+ /,
263
+ *,
264
+ database: str | None = None,
162
265
  data_only: bool = False,
163
266
  jobs: int | None = None,
164
267
  schemas: MaybeListStr | None = None,
165
268
  schemas_exc: MaybeListStr | None = None,
166
269
  tables: MaybeSequence[TableOrORMInstOrClass | str] | None = None,
167
- logger: LoggerOrName | None = None,
168
- dry_run: bool = False,
169
- ) -> None:
270
+ docker: str | None = None,
271
+ ) -> str:
170
272
  """Run `pg_restore`."""
171
- match database, url.database:
172
- case str() as database_use, _:
173
- ...
174
- case None, str() as database_use:
175
- ...
176
- case None, None:
177
- raise _PGRestoreDatabaseError(url=url)
178
- case _ as never:
179
- assert_never(never)
180
- if url.host is None:
181
- raise _PGRestoreHostError(url=url)
182
- if url.port is None:
183
- raise _PGRestorePortError(url=url)
184
- parts: list[str] = []
185
- if docker is not None:
186
- parts.extend(["docker", "exec", "-it", docker])
187
- parts.extend([
273
+ url_database, host, port = _extract_url(url)
274
+ database_use = url_database if database is None else database
275
+ parts: list[str] = [
188
276
  "pg_restore",
189
277
  # general options
190
278
  f"--dbname={database_use}",
@@ -194,10 +282,10 @@ async def pg_restore(
194
282
  "--no-owner",
195
283
  "--no-privileges",
196
284
  # connection options
197
- f"--host={url.host}",
198
- f"--port={url.port}",
285
+ f"--host={host}",
286
+ f"--port={port}",
199
287
  "--no-password",
200
- ])
288
+ ]
201
289
  if data_only:
202
290
  parts.append("--data-only")
203
291
  else:
@@ -212,68 +300,80 @@ async def pg_restore(
212
300
  parts.extend([f"--table={_get_table_name(t)}" for t in always_iterable(tables)])
213
301
  if url.username is not None:
214
302
  parts.append(f"--username={url.username}")
303
+ if docker is not None:
304
+ parts = _wrap_docker(parts, docker)
215
305
  parts.append(str(path))
216
- cmd = " ".join(parts)
217
- if dry_run:
218
- if logger is not None:
219
- get_logger(logger=logger).info("Would run %r", str(cmd))
220
- return
221
- with temp_environ(PGPASSWORD=url.password), Timer() as timer: # pragma: no cover
222
- try:
223
- output = await stream_command(cmd)
224
- except KeyboardInterrupt:
225
- if logger is not None:
226
- get_logger(logger=logger).info(
227
- "Cancelled restore from %r after %s", str(path), timer
228
- )
229
- else:
230
- match output.return_code:
231
- case 0:
232
- if logger is not None:
233
- get_logger(logger=logger).info(
234
- "Restore from %r finished after %s", str(path), timer
235
- )
236
- case _:
237
- if logger is not None:
238
- get_logger(logger=logger).exception(
239
- "Restore from %r failed after %s\nstderr:\n%s",
240
- str(path),
241
- timer,
242
- output.stderr,
243
- )
306
+ return " ".join(parts)
307
+
308
+
309
+ def _build_psql(
310
+ url: URL,
311
+ path: PathLike,
312
+ /,
313
+ *,
314
+ database: str | None = None,
315
+ docker: str | None = None,
316
+ ) -> str:
317
+ """Run `psql`."""
318
+ url_database, host, port = _extract_url(url)
319
+ database_use = url_database if database is None else database
320
+ parts: list[str] = [
321
+ "psql",
322
+ # general options
323
+ f"--dbname={database_use}",
324
+ f"--file={str(path)!r}",
325
+ # connection options
326
+ f"--host={host}",
327
+ f"--port={port}",
328
+ "--no-password",
329
+ ]
330
+ if url.username is not None:
331
+ parts.append(f"--username={url.username}")
332
+ if docker is not None:
333
+ parts = _wrap_docker(parts, docker)
334
+ return " ".join(parts)
335
+
336
+
337
+ ##
338
+
339
+
340
+ def _extract_url(url: URL, /) -> tuple[str, str, int]:
341
+ if url.database is None:
342
+ raise _ExtractURLDatabaseError(url=url)
343
+ if url.host is None:
344
+ raise _ExtractURLHostError(url=url)
345
+ if url.port is None:
346
+ raise _ExtractURLPortError(url=url)
347
+ return url.database, url.host, url.port
244
348
 
245
349
 
246
350
  @dataclass(kw_only=True, slots=True)
247
- class PGRestoreError(Exception):
351
+ class ExtractURLError(Exception):
248
352
  url: URL
249
353
 
250
354
 
251
355
  @dataclass(kw_only=True, slots=True)
252
- class _PGRestoreDatabaseError(PGRestoreError):
356
+ class _ExtractURLDatabaseError(ExtractURLError):
253
357
  @override
254
358
  def __str__(self) -> str:
255
359
  return f"Expected URL to contain a 'database'; got {self.url}"
256
360
 
257
361
 
258
362
  @dataclass(kw_only=True, slots=True)
259
- class _PGRestoreHostError(PGRestoreError):
363
+ class _ExtractURLHostError(ExtractURLError):
260
364
  @override
261
365
  def __str__(self) -> str:
262
366
  return f"Expected URL to contain a 'host'; got {self.url}"
263
367
 
264
368
 
265
369
  @dataclass(kw_only=True, slots=True)
266
- class _PGRestorePortError(PGRestoreError):
370
+ class _ExtractURLPortError(ExtractURLError):
267
371
  @override
268
372
  def __str__(self) -> str:
269
373
  return f"Expected URL to contain a 'port'; got {self.url}"
270
374
 
271
375
 
272
- ##
273
-
274
-
275
376
  def _get_table_name(obj: TableOrORMInstOrClass | str, /) -> str:
276
- """Get the table name from a Table or mapped class."""
277
377
  match obj:
278
378
  case Table() | DeclarativeBase() | type() as table_or_orm:
279
379
  return get_table_name(table_or_orm)
@@ -283,4 +383,8 @@ def _get_table_name(obj: TableOrORMInstOrClass | str, /) -> str:
283
383
  assert_never(never)
284
384
 
285
385
 
286
- __all__ = ["PGDumpError", "PGRestoreError", "pg_dump", "pg_restore"]
386
+ def _wrap_docker(parts: list[str], container: str, /) -> list[str]:
387
+ return ["docker", "exec", "-it", container, *parts]
388
+
389
+
390
+ __all__ = ["ExtractURLError", "pg_dump", "restore"]