hdx-python-database 1.3.0__py3-none-any.whl → 1.3.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.
hdx/database/__init__.py CHANGED
@@ -1,31 +1,59 @@
1
1
  """Database utilities"""
2
+
2
3
  import logging
3
- from typing import Any, Optional, Type, Union
4
+ from typing import Any, Dict, List, Optional, Tuple, Type, Union
4
5
 
5
- from sqlalchemy import create_engine
6
+ from sqlalchemy import Engine, TableClause, create_engine
7
+ from sqlalchemy.exc import SQLAlchemyError
6
8
  from sqlalchemy.ext.automap import automap_base
7
- from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
9
+ from sqlalchemy.orm import DeclarativeBase, Session
8
10
  from sqlalchemy.pool import NullPool
11
+ from sqlalchemy.sql.ddl import CreateSchema, DropSchema
9
12
 
10
13
  from ._version import version as __version__ # noqa: F401
11
14
  from .dburi import get_connection_uri
12
15
  from .no_timezone import Base as NoTZBase
13
- from .postgresql import wait_for_postgresql
16
+ from .postgresql import restore_from_pgfile, wait_for_postgresql
17
+ from .views import view
14
18
  from .with_timezone import Base as TZBase
15
19
 
16
20
  logger = logging.getLogger(__name__)
17
21
 
18
22
 
23
+ class DatabaseError(Exception):
24
+ pass
25
+
26
+
27
+ def do_nothing_fn():
28
+ return None
29
+
30
+
19
31
  class Database:
20
32
  """Database helper class to handle ssh tunnels, waiting for PostgreSQL to
21
- be up etc. Can be used in a with statement returning a Session object that
33
+ be up etc. Can be used in a with statement returning a Database object that
22
34
  if reflect is True will have a variable reflected_classes containing the
23
- reflected classes. db_has_tz which defaults to False indicates whether
24
- database datetime columns have timezones. If not, conversion occurs between
25
- Python datetimes with timezones to timezoneless database columns (but not
26
- when using reflection). If table_base is supplied, db_has_tz is ignored.
35
+ reflected classes. Either an SQLAlchemy engine, database uri or various
36
+ database parameters must be supplied.
37
+
38
+ db_has_tz which defaults to False indicates whether database datetime
39
+ columns have timezones. If not, conversion occurs between Python datetimes
40
+ with timezones to timezoneless database columns (but not when using
41
+ reflection). If table_base is supplied, db_has_tz is ignored.
42
+
43
+ The database can be restored from a given pg_restore file supplied in the
44
+ parameter pg_restore_file.
45
+
46
+ There is an option to wipe and create an empty schema in the database by
47
+ setting recreate_schema to True and setting a schema_name ("public" is the
48
+ default).
49
+
50
+ If a prepare function is supplied in prepare_fn, it will be executed before
51
+ Base.metadata.create_all and the results of it returned in instance variable
52
+ prepare_results.
27
53
 
28
54
  Args:
55
+ engine (Optional[Engine]): SQLAlchemy engine to use.
56
+ db_uri (Optional[str]): Connection URI.
29
57
  database (Optional[str]): Database name
30
58
  host (Optional[str]): Host where database is located
31
59
  port (Union[int, str, None]): Database port
@@ -37,6 +65,10 @@ class Database:
37
65
  table_base (Optional[Type[DeclarativeBase]]): Override table base. Defaults to None.
38
66
  reflect (bool): Whether to reflect existing tables. Defaults to False.
39
67
  **kwargs: See below
68
+ pg_restore_file (str): Restore database from pg_restore file
69
+ recreate_schema (bool): Whether to recreate schema
70
+ schema_name (str): Database schema name. Defaults to "public".
71
+ prepare_fn (Callable[[], None]]): Function to call before Base.metadata.create_all.
40
72
  ssh_host (str): SSH host (the server to connect to)
41
73
  ssh_port (int): SSH port. Defaults to 22.
42
74
  ssh_username (str): SSH username
@@ -48,6 +80,8 @@ class Database:
48
80
 
49
81
  def __init__(
50
82
  self,
83
+ engine: Optional[Engine] = None,
84
+ db_uri: Optional[str] = None,
51
85
  database: Optional[str] = None,
52
86
  host: Optional[str] = None,
53
87
  port: Union[int, str, None] = None,
@@ -62,6 +96,16 @@ class Database:
62
96
  ) -> None:
63
97
  if port is not None:
64
98
  port = int(port)
99
+ schema_name = None
100
+ if len(kwargs) == 0:
101
+ pg_restore_file = None
102
+ recreate_schema = False
103
+ prepare_fn = do_nothing_fn
104
+ else:
105
+ pg_restore_file = kwargs.pop("pg_restore_file", None)
106
+ recreate_schema = kwargs.pop("recreate_schema", False)
107
+ schema_name = kwargs.pop("schema", "public")
108
+ prepare_fn = kwargs.pop("prepare_fn", do_nothing_fn)
65
109
  if len(kwargs) != 0:
66
110
  try:
67
111
  import sshtunnel
@@ -89,64 +133,179 @@ class Database:
89
133
  port = self.server.local_bind_port
90
134
  else:
91
135
  self.server = None
92
- db_uri = get_connection_uri(
93
- database,
94
- host,
95
- port,
96
- username,
97
- password,
98
- dialect=dialect,
99
- driver=driver,
100
- )
101
- if dialect == "postgresql":
136
+ if not engine and not db_uri:
137
+ db_uri = get_connection_uri(
138
+ database,
139
+ host,
140
+ port,
141
+ username,
142
+ password,
143
+ dialect=dialect,
144
+ driver=driver,
145
+ )
146
+ if not db_uri:
147
+ raise DatabaseError(
148
+ "No engine, database uri or connection parameters provided!"
149
+ )
150
+ if not engine and dialect == "postgresql":
102
151
  wait_for_postgresql(db_uri)
152
+ if pg_restore_file and db_uri:
153
+ restore_from_pgfile(db_uri, pg_restore_file)
103
154
  if not table_base:
104
155
  if db_has_tz:
105
156
  table_base = TZBase
106
157
  else:
107
158
  table_base = NoTZBase
108
- self.session = self.get_session(
109
- db_uri, table_base=table_base, reflect=reflect
159
+ if not engine:
160
+ engine = create_engine(db_uri, poolclass=NullPool, echo=False)
161
+ self.engine: Engine = engine
162
+ if recreate_schema:
163
+ self.recreate_schema(engine, schema_name)
164
+ self.prepare_results = prepare_fn()
165
+ self.session, self.base = self.create_session(
166
+ engine,
167
+ table_base=table_base,
168
+ reflect=reflect,
110
169
  )
170
+ if reflect:
171
+ self.reflected_classes = self.base.classes
172
+ else:
173
+ self.reflected_classes = None
111
174
 
112
- def __enter__(self) -> Session:
113
- return self.session
114
-
115
- def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
175
+ def cleanup(self) -> None:
176
+ self.base.metadata.clear()
116
177
  self.session.close()
178
+ self.engine.dispose()
117
179
  if self.server is not None:
118
180
  self.server.stop()
119
181
 
182
+ def __enter__(self) -> "Database":
183
+ return self
184
+
185
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
186
+ self.cleanup()
187
+
188
+ def drop_all(self):
189
+ self.base.metadata.drop_all(self.engine)
190
+
191
+ def get_engine(self) -> Engine:
192
+ """Returns SQLAlchemy engine.
193
+
194
+ Returns:
195
+ sqlalchemy.Engine: SQLAlchemy engine
196
+ """
197
+ return self.engine
198
+
199
+ def get_session(self) -> Session:
200
+ """Returns SQLAlchemy session.
201
+
202
+ Returns:
203
+ sqlalchemy.orm.Session: SQLAlchemy session
204
+ """
205
+ return self.session
206
+
207
+ def get_reflected_classes(self) -> Any:
208
+ """Gets reflected classes.
209
+
210
+ Returns:
211
+ Any: Reflected classes
212
+ """
213
+ return self.reflected_classes
214
+
215
+ def get_prepare_results(self) -> Any:
216
+ """Returns results from prepare function.
217
+
218
+ Returns:
219
+ Any: Results from prepare function
220
+ """
221
+ return self.prepare_results
222
+
120
223
  @staticmethod
121
- def get_session(
122
- db_uri: str,
224
+ def create_session(
225
+ engine: Optional[Engine] = None,
226
+ db_uri: Optional[str] = None,
123
227
  table_base: Type[DeclarativeBase] = NoTZBase,
124
228
  reflect: bool = False,
125
- ) -> Session:
126
- """Gets SQLAlchemy session given url. Tables must inherit from Base in
229
+ ) -> Tuple[Session, Any]:
230
+ """Creates SQLAlchemy session given SQLAlchemy engine or database uri
231
+ (one of which must be supplied). Tables must inherit from Base in
127
232
  hdx.utilities.database unless base is defined. If reflect is True,
128
233
  classes will be reflected from an existing database and the reflected
129
- classes are returned in a variable reflected_classes in the returned
130
- Session object. Note that type annotation maps don't work with
131
- reflection.
234
+ classes will be returned. Note that type annotation maps don't work
235
+ with reflection.
132
236
 
133
237
  Args:
134
- db_uri (str): Connection URI
238
+ engine (Optional[Engine]): SQLAlchemy engine to use. Defaults to None (create from db_uri).
239
+ db_uri (Optional[str]): Connection URI. Defaults to None (use engine).
135
240
  table_base (Type[DeclarativeBase]): Base database table class. Defaults to NoTZBase.
136
241
  reflect (bool): Whether to reflect existing tables. Defaults to False.
137
242
 
138
243
  Returns:
139
- sqlalchemy.orm.Session: SQLAlchemy session
244
+ Tuple[Session, Any]: (SQLAlchemy session, base)
140
245
  """
141
- engine = create_engine(db_uri, poolclass=NullPool, echo=False)
142
- Session = sessionmaker(bind=engine)
246
+ if not engine:
247
+ if db_uri is None:
248
+ raise DatabaseError("No engine or database uri provided!")
249
+ engine = create_engine(db_uri, poolclass=NullPool, echo=False)
143
250
  if reflect:
144
251
  Base = automap_base(declarative_base=table_base)
145
252
  Base.prepare(autoload_with=engine)
146
- classes = Base.classes
253
+ table_base = Base
147
254
  else:
148
255
  table_base.metadata.create_all(engine)
149
- classes = None
150
- session = Session()
151
- session.reflected_classes = classes
152
- return session
256
+ return Session(engine), table_base
257
+
258
+ @staticmethod
259
+ def recreate_schema(engine: Engine, schema_name: str = "public") -> bool:
260
+ """Wipe and create empty schema in database using SQLAlchemy.
261
+
262
+ Args:
263
+ engine (Engine): SQLAlchemy engine to use.
264
+ schema_name (str): Schema name. Defaults to "public".
265
+
266
+ Returns:
267
+ bool: True if all successful, False if not
268
+ """
269
+ # Wipe and create an empty schema
270
+ try:
271
+ with engine.connect() as connection:
272
+ connection.execute(
273
+ DropSchema(schema_name, cascade=True, if_exists=True)
274
+ )
275
+ connection.commit()
276
+ connection.execute(
277
+ CreateSchema(schema_name, if_not_exists=True)
278
+ )
279
+ connection.commit()
280
+ return True
281
+ except SQLAlchemyError:
282
+ return False
283
+
284
+ @staticmethod
285
+ def prepare_view(view_params: Dict) -> TableClause:
286
+ """Prepare SQLAlchemy view from dictionary with keys: name, metadata and
287
+ selectable. Must be run before Base.metadata.create_all.
288
+
289
+ Args:
290
+ view_params (Dict): Dictionary with keys name, metadata, selectable
291
+
292
+ Returns:
293
+ TableClause: SQLAlchemy View
294
+ """
295
+ return view(**view_params)
296
+
297
+ @classmethod
298
+ def prepare_views(cls, view_params_list: List[Dict]) -> List[TableClause]:
299
+ """Prepare SQLAlchemy views from a list of dictionaries with keys: name,
300
+ metadata and selectable. Must be run before Base.metadata.create_all.
301
+
302
+ Args:
303
+ view_params_list (List[Dict]): List of dictionaries with view parameters
304
+
305
+ Returns:
306
+ List[TableClause]: SQLAlchemy Views
307
+ """
308
+ results = []
309
+ for view_params in view_params_list:
310
+ results.append(cls.prepare_view(view_params))
311
+ return results
hdx/database/_version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '1.3.0'
16
- __version_tuple__ = version_tuple = (1, 3, 0)
15
+ __version__ = version = '1.3.2'
16
+ __version_tuple__ = version_tuple = (1, 3, 2)
hdx/database/dburi.py CHANGED
@@ -62,6 +62,8 @@ def get_connection_uri(
62
62
  Returns:
63
63
  str: Connection URI
64
64
  """
65
+ if not database:
66
+ return ""
65
67
  strings = [dialect]
66
68
  if include_driver:
67
69
  if dialect == "postgresql" and driver is None:
@@ -1,4 +1,5 @@
1
1
  """Utilities for database datetime columns without timezone"""
2
+
2
3
  from datetime import datetime, timezone
3
4
  from typing import Optional
4
5
 
@@ -1,8 +1,11 @@
1
1
  """PostgreSQL specific utilities"""
2
+
2
3
  import logging
4
+ import subprocess
3
5
  import time
6
+ from os import environ
4
7
 
5
- from .dburi import remove_driver_from_uri
8
+ from .dburi import get_params_from_connection_uri, remove_driver_from_uri
6
9
 
7
10
  try:
8
11
  import psycopg
@@ -18,6 +21,10 @@ except ImportError:
18
21
  logger = logging.getLogger(__name__)
19
22
 
20
23
 
24
+ class PostgresError(Exception):
25
+ pass
26
+
27
+
21
28
  def wait_for_postgresql(db_uri: str) -> None:
22
29
  """Waits for PostgreSQL database to be up
23
30
 
@@ -38,3 +45,51 @@ def wait_for_postgresql(db_uri: str) -> None:
38
45
  break
39
46
  except psycopg.OperationalError:
40
47
  time.sleep(1)
48
+
49
+
50
+ def restore_from_pgfile(db_uri: str, pg_restore_file: str) -> str:
51
+ """Restore database from a pg_restore file created by pg_backup using the
52
+ pg_restore command.
53
+
54
+ Args:
55
+ db_uri (str): Connection URI
56
+ pg_restore_file (str): Path to the pg_restore database file
57
+
58
+ Returns:
59
+ str: Output from the pg_restore command
60
+ """
61
+ db_params = get_params_from_connection_uri(db_uri)
62
+ subprocess_params = ["pg_restore", "-c"]
63
+ for key, value in db_params.items():
64
+ match key:
65
+ case "database":
66
+ subprocess_params.append("-d")
67
+ case "host":
68
+ subprocess_params.append("-h")
69
+ case "port":
70
+ subprocess_params.append("-p")
71
+ case "username":
72
+ subprocess_params.append("-U")
73
+ case _:
74
+ continue
75
+ subprocess_params.append(f"{value}")
76
+
77
+ subprocess_params.append(f"{pg_restore_file}")
78
+ env = environ.copy()
79
+ password = db_params.get("password")
80
+ if password:
81
+ env["PGPASSWORD"] = password
82
+ process = subprocess.run(
83
+ subprocess_params, env=env, capture_output=True, encoding="utf-8"
84
+ )
85
+ try:
86
+ process.check_returncode()
87
+ except subprocess.CalledProcessError as ex:
88
+ command = " ".join(subprocess_params)
89
+ raise PostgresError(
90
+ f"{command} failed. Return code: {process.returncode}"
91
+ ) from ex
92
+
93
+ for line in process.stdout.splitlines():
94
+ logger.info(line)
95
+ return process.stdout
hdx/database/views.py CHANGED
@@ -1,10 +1,10 @@
1
1
  """
2
- Create a DB View.
2
+ Prepare database views ins SQLAlchemy. Must be run before calling
3
+ Base.metadata.create_all.
3
4
 
4
5
  Copied from:
5
6
  https://github.com/sqlalchemy/sqlalchemy/wiki/Views#sqlalchemy-14-20-version
6
7
  """
7
- from typing import Dict, List
8
8
 
9
9
  import sqlalchemy as sa
10
10
  from sqlalchemy import MetaData, Selectable, TableClause
@@ -46,7 +46,7 @@ def view_doesnt_exist(ddl, target, connection, **kw):
46
46
 
47
47
 
48
48
  def view(name: str, metadata: MetaData, selectable: Selectable) -> TableClause:
49
- """Create SQLAlchemy view
49
+ """Prepare SQLAlchemy view. Must be run before Base.metadata.create_all.
50
50
 
51
51
  Args:
52
52
  name (str): View name
@@ -73,31 +73,3 @@ def view(name: str, metadata: MetaData, selectable: Selectable) -> TableClause:
73
73
  DropView(name).execute_if(callable_=view_exists),
74
74
  )
75
75
  return t
76
-
77
-
78
- def build_view(view_params: Dict) -> TableClause:
79
- """Create SQLAlchemy view from dictionary with keys: name, metadata and
80
- selectable
81
-
82
- Args:
83
- view_params (Dict): Dictionary with keys name, metadata, selectable
84
-
85
- Returns:
86
- TableClause: SQLAlchemy View
87
- """
88
- return view(**view_params)
89
-
90
-
91
- def build_views(view_params_list: List[Dict]) -> List[TableClause]:
92
- """Create SQLAlchemy views from a list of dictionaries with keys: name,
93
- metadata and selectable
94
-
95
- Args:
96
- view_params_list (List[Dict]): List of dictionaries with view parameters
97
- Returns:
98
- List[TableClause]: SQLAlchemy Views
99
- """
100
- results = []
101
- for view_params in view_params_list:
102
- results.append(build_view(view_params))
103
- return results
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: hdx-python-database
3
- Version: 1.3.0
3
+ Version: 1.3.2
4
4
  Summary: HDX Python database utilities
5
5
  Project-URL: Homepage, https://github.com/OCHA-DAP/hdx-python-database
6
6
  Author-email: Michael Rans <rans@email.com>
@@ -39,8 +39,7 @@ Description-Content-Type: text/markdown
39
39
 
40
40
  [![Build Status](https://github.com/OCHA-DAP/hdx-python-database/actions/workflows/run-python-tests.yaml/badge.svg)](https://github.com/OCHA-DAP/hdx-python-database/actions/workflows/run-python-tests.yaml)
41
41
  [![Coverage Status](https://coveralls.io/repos/github/OCHA-DAP/hdx-python-database/badge.svg?branch=main&ts=1)](https://coveralls.io/github/OCHA-DAP/hdx-python-database?branch=main)
42
- [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
43
- [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/)
42
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
44
43
  [![Downloads](https://img.shields.io/pypi/dm/hdx-python-database.svg)](https://pypistats.org/packages/hdx-python-database)
45
44
 
46
45
  The HDX Python Database Library provides utilities for connecting to databases in a standard way including
@@ -0,0 +1,12 @@
1
+ hdx/database/__init__.py,sha256=cOAnY4cOLcjxM556jAIfw9XqTIKk7mSwZCugIf3XpPA,11431
2
+ hdx/database/_version.py,sha256=ik9WUFYTvRcmzFEUSqkI2H_62uw0XFdUT3mTLq8-_RY,411
3
+ hdx/database/dburi.py,sha256=rw8RuLpU85vxYl5-Zk1SdZRAz-tqre_ot5aEnkjhxAQ,2977
4
+ hdx/database/no_timezone.py,sha256=Lw087G3qOf8H70o8Hq_VvOMZ3ANei-lIA_sUlpFU_Uo,1130
5
+ hdx/database/postgresql.py,sha256=BrEuRnuR2dt3HwJ-o_IzGFs8eT3iXKzDqMSNKPyQ18Y,2621
6
+ hdx/database/utils.py,sha256=6lLymWriMFCq8BZgrDt4V5z14GR3PbvG_WXZeBc99Ws,415
7
+ hdx/database/views.py,sha256=R_1Ni0U2CL3IlydxcB1vyZp7QoDZim6g-807jQIaWQU,1950
8
+ hdx/database/with_timezone.py,sha256=GUUylmTwQHp22yedmCVKpVJzvjWgdS9lg9KeqciNJkY,235
9
+ hdx_python_database-1.3.2.dist-info/METADATA,sha256=ZCPvpjSSs0PxElgVksIa2v7wBo2soTX52wfl4d9eau4,2691
10
+ hdx_python_database-1.3.2.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
11
+ hdx_python_database-1.3.2.dist-info/licenses/LICENSE,sha256=wc-4GpMn-ODs-U_bTe1YCiPVgvcjzrpYOx2wPuyAeII,1079
12
+ hdx_python_database-1.3.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.21.1
2
+ Generator: hatchling 1.25.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,12 +0,0 @@
1
- hdx/database/__init__.py,sha256=0CpgEC9GAqCtmZtBsTvwoDsW3T0VHY0aCNAh53piP6g,5835
2
- hdx/database/_version.py,sha256=HGwtpza1HCPtlyqElUvIyH97K44TO13CYiYVZNezQ1M,411
3
- hdx/database/dburi.py,sha256=hNksrjaCC7AtD_M5ef0DCjkS0rJCofQqdXVD2BHBIGc,2938
4
- hdx/database/no_timezone.py,sha256=jawupasbCwNo-lDbX24CtxaSilb6LS-jLx-wZAa58nM,1129
5
- hdx/database/postgresql.py,sha256=9VlcdHhBURZPDaazj1kEq8gA4hUFJY-5hIN9uzibOlM,969
6
- hdx/database/utils.py,sha256=6lLymWriMFCq8BZgrDt4V5z14GR3PbvG_WXZeBc99Ws,415
7
- hdx/database/views.py,sha256=qJ_f6DpBUSzAGuBbgOXwQDZGni7b-DOUlAdAjSXKwhs,2635
8
- hdx/database/with_timezone.py,sha256=GUUylmTwQHp22yedmCVKpVJzvjWgdS9lg9KeqciNJkY,235
9
- hdx_python_database-1.3.0.dist-info/METADATA,sha256=8lJk615nDYiwB9vT9JUzfd-mpGVa6rIFA6kP9WVMgxI,2785
10
- hdx_python_database-1.3.0.dist-info/WHEEL,sha256=TJPnKdtrSue7xZ_AVGkp9YXcvDrobsjBds1du3Nx6dc,87
11
- hdx_python_database-1.3.0.dist-info/licenses/LICENSE,sha256=wc-4GpMn-ODs-U_bTe1YCiPVgvcjzrpYOx2wPuyAeII,1079
12
- hdx_python_database-1.3.0.dist-info/RECORD,,