dycw-utilities 0.148.4__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.4
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=VDgRGy8SY-uljlJ76REYaNomUoE5yqIY42dKSUSpjuY,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=Q1auvVqwUrIP6yhwvjCXg7zQbWgQ9czuJIa99eK6soM,8993
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.4.dist-info/METADATA,sha256=VH2ptiCLL6fP3q4XBpXw-0nsTyTeHnHrxpuyT0A5tFE,1697
92
- dycw_utilities-0.148.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
93
- dycw_utilities-0.148.4.dist-info/entry_points.txt,sha256=BOD_SoDxwsfJYOLxhrSXhHP_T7iw-HXI9f2WVkzYxvQ,135
94
- dycw_utilities-0.148.4.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
95
- dycw_utilities-0.148.4.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.4"
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,
@@ -39,26 +39,93 @@ async def pg_dump(
39
39
  tables: MaybeSequence[TableOrORMInstOrClass | str] | None = None,
40
40
  tables_exc: MaybeSequence[TableOrORMInstOrClass | str] | None = None,
41
41
  inserts: bool = False,
42
- logger: LoggerOrName | None = None,
42
+ on_conflict_do_nothing: bool = False,
43
+ docker: str | None = None,
43
44
  dry_run: bool = False,
45
+ logger: LoggerOrName | None = None,
44
46
  ) -> None:
45
47
  """Run `pg_dump`."""
46
48
  path = Path(path)
47
49
  path.parent.mkdir(parents=True, exist_ok=True)
48
- if url.database is None:
49
- raise _PGDumpDatabaseError(url=url)
50
- if url.host is None:
51
- raise _PGDumpHostError(url=url)
52
- if url.port is None:
53
- raise _PGDumpPortError(url=url)
54
- parts: list[str] = []
55
- if docker is not None:
56
- parts.extend(["docker", "exec", "-it", docker])
57
- 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] = [
58
125
  "pg_dump",
59
126
  # general options
60
- f"--dbname={url.database}",
61
- f"--file={str(path)!r}",
127
+ f"--dbname={database}",
128
+ f"--file={str(file)!r}",
62
129
  f"--format={format_}",
63
130
  "--verbose",
64
131
  # output options
@@ -68,10 +135,10 @@ async def pg_dump(
68
135
  "--no-privileges",
69
136
  "--if-exists",
70
137
  # connection options
71
- f"--host={url.host}",
72
- f"--port={url.port}",
138
+ f"--host={host}",
139
+ f"--port={port}",
73
140
  "--no-password",
74
- ])
141
+ ]
75
142
  if (format_ == "directory") and (jobs is not None):
76
143
  parts.append(f"--jobs={jobs}")
77
144
  if schemas is not None:
@@ -86,9 +153,47 @@ async def pg_dump(
86
153
  ])
87
154
  if inserts:
88
155
  parts.append("--inserts")
156
+ if on_conflict_do_nothing:
157
+ parts.append("--on-conflict-do-nothing")
89
158
  if url.username is not None:
90
159
  parts.append(f"--username={url.username}")
91
- 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
+ )
92
197
  if dry_run:
93
198
  if logger is not None:
94
199
  get_logger(logger=logger).info("Would run %r", str(cmd))
@@ -99,89 +204,75 @@ async def pg_dump(
99
204
  except KeyboardInterrupt:
100
205
  if logger is not None:
101
206
  get_logger(logger=logger).info(
102
- "Cancelled backup to %r after %s", str(path), timer
207
+ "Cancelled restore from %r after %s", str(path), timer
103
208
  )
104
- rmtree(path, ignore_errors=True)
105
209
  else:
106
210
  match output.return_code:
107
211
  case 0:
108
212
  if logger is not None:
109
213
  get_logger(logger=logger).info(
110
- "Backup to %r finished after %s", str(path), timer
214
+ "Restore from %r finished after %s", str(path), timer
111
215
  )
112
216
  case _:
113
217
  if logger is not None:
114
218
  get_logger(logger=logger).exception(
115
- "Backup to %r failed after %s\nstderr:\n%s",
219
+ "Restore from %r failed after %s\nstderr:\n%s",
116
220
  str(path),
117
221
  timer,
118
222
  output.stderr,
119
223
  )
120
- rmtree(path, ignore_errors=True)
121
-
122
-
123
- @dataclass(kw_only=True, slots=True)
124
- class PGDumpError(Exception):
125
- url: URL
126
-
127
-
128
- @dataclass(kw_only=True, slots=True)
129
- class _PGDumpDatabaseError(PGDumpError):
130
- @override
131
- def __str__(self) -> str:
132
- return f"Expected URL to contain a 'database'; got {self.url}"
133
-
134
-
135
- @dataclass(kw_only=True, slots=True)
136
- class _PGDumpHostError(PGDumpError):
137
- @override
138
- def __str__(self) -> str:
139
- return f"Expected URL to contain a 'host'; got {self.url}"
140
-
141
-
142
- @dataclass(kw_only=True, slots=True)
143
- class _PGDumpPortError(PGDumpError):
144
- @override
145
- def __str__(self) -> str:
146
- return f"Expected URL to contain a 'port'; got {self.url}"
147
224
 
148
225
 
149
226
  ##
150
227
 
151
228
 
152
- async def pg_restore(
229
+ def _build_pg_restore_or_psql(
153
230
  url: URL,
154
231
  path: PathLike,
155
232
  /,
156
233
  *,
234
+ psql: bool = False,
157
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,
158
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,
159
265
  data_only: bool = False,
160
266
  jobs: int | None = None,
161
267
  schemas: MaybeListStr | None = None,
162
268
  schemas_exc: MaybeListStr | None = None,
163
269
  tables: MaybeSequence[TableOrORMInstOrClass | str] | None = None,
164
- logger: LoggerOrName | None = None,
165
- dry_run: bool = False,
166
- ) -> None:
270
+ docker: str | None = None,
271
+ ) -> str:
167
272
  """Run `pg_restore`."""
168
- match database, url.database:
169
- case str() as database_use, _:
170
- ...
171
- case None, str() as database_use:
172
- ...
173
- case None, None:
174
- raise _PGRestoreDatabaseError(url=url)
175
- case _ as never:
176
- assert_never(never)
177
- if url.host is None:
178
- raise _PGRestoreHostError(url=url)
179
- if url.port is None:
180
- raise _PGRestorePortError(url=url)
181
- parts: list[str] = []
182
- if docker is not None:
183
- parts.extend(["docker", "exec", "-it", docker])
184
- 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] = [
185
276
  "pg_restore",
186
277
  # general options
187
278
  f"--dbname={database_use}",
@@ -191,10 +282,10 @@ async def pg_restore(
191
282
  "--no-owner",
192
283
  "--no-privileges",
193
284
  # connection options
194
- f"--host={url.host}",
195
- f"--port={url.port}",
285
+ f"--host={host}",
286
+ f"--port={port}",
196
287
  "--no-password",
197
- ])
288
+ ]
198
289
  if data_only:
199
290
  parts.append("--data-only")
200
291
  else:
@@ -209,68 +300,80 @@ async def pg_restore(
209
300
  parts.extend([f"--table={_get_table_name(t)}" for t in always_iterable(tables)])
210
301
  if url.username is not None:
211
302
  parts.append(f"--username={url.username}")
303
+ if docker is not None:
304
+ parts = _wrap_docker(parts, docker)
212
305
  parts.append(str(path))
213
- cmd = " ".join(parts)
214
- if dry_run:
215
- if logger is not None:
216
- get_logger(logger=logger).info("Would run %r", str(cmd))
217
- return
218
- with temp_environ(PGPASSWORD=url.password), Timer() as timer: # pragma: no cover
219
- try:
220
- output = await stream_command(cmd)
221
- except KeyboardInterrupt:
222
- if logger is not None:
223
- get_logger(logger=logger).info(
224
- "Cancelled restore from %r after %s", str(path), timer
225
- )
226
- else:
227
- match output.return_code:
228
- case 0:
229
- if logger is not None:
230
- get_logger(logger=logger).info(
231
- "Restore from %r finished after %s", str(path), timer
232
- )
233
- case _:
234
- if logger is not None:
235
- get_logger(logger=logger).exception(
236
- "Restore from %r failed after %s\nstderr:\n%s",
237
- str(path),
238
- timer,
239
- output.stderr,
240
- )
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
241
348
 
242
349
 
243
350
  @dataclass(kw_only=True, slots=True)
244
- class PGRestoreError(Exception):
351
+ class ExtractURLError(Exception):
245
352
  url: URL
246
353
 
247
354
 
248
355
  @dataclass(kw_only=True, slots=True)
249
- class _PGRestoreDatabaseError(PGRestoreError):
356
+ class _ExtractURLDatabaseError(ExtractURLError):
250
357
  @override
251
358
  def __str__(self) -> str:
252
359
  return f"Expected URL to contain a 'database'; got {self.url}"
253
360
 
254
361
 
255
362
  @dataclass(kw_only=True, slots=True)
256
- class _PGRestoreHostError(PGRestoreError):
363
+ class _ExtractURLHostError(ExtractURLError):
257
364
  @override
258
365
  def __str__(self) -> str:
259
366
  return f"Expected URL to contain a 'host'; got {self.url}"
260
367
 
261
368
 
262
369
  @dataclass(kw_only=True, slots=True)
263
- class _PGRestorePortError(PGRestoreError):
370
+ class _ExtractURLPortError(ExtractURLError):
264
371
  @override
265
372
  def __str__(self) -> str:
266
373
  return f"Expected URL to contain a 'port'; got {self.url}"
267
374
 
268
375
 
269
- ##
270
-
271
-
272
376
  def _get_table_name(obj: TableOrORMInstOrClass | str, /) -> str:
273
- """Get the table name from a Table or mapped class."""
274
377
  match obj:
275
378
  case Table() | DeclarativeBase() | type() as table_or_orm:
276
379
  return get_table_name(table_or_orm)
@@ -280,4 +383,8 @@ def _get_table_name(obj: TableOrORMInstOrClass | str, /) -> str:
280
383
  assert_never(never)
281
384
 
282
385
 
283
- __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"]